personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

config: active-journal matrix for sol config journal + dispatcher exit-code fix

cmd_journal now refuses to silently orphan or destroy a populated journal.
The active-state matrix (current/target × active/not-active) gates four new
flags: --move (atomic os.rename, same-filesystem only), --switch (wrapper-only,
data untouched), --merge (refuses with verbatim instructions pointing at
sol call journal merge), --force (escape hatch). --yes / --dry-run / no-flag
control whether the plan summary executes or is shown for review. All
non-executing paths leave the target directory uncreated.

Decision logic factored into a pure decide() over a JournalChange dataclass,
with a separate execute() for IO. service_is_installed() and
service_is_running() are new public helpers in think.service; _restart,
_status, and _up now share these. cmd_journal branches on running state
upstream so the prior lode's --if-installed restart no longer wakes a
stopped service on --move.

Two collateral fixes:
- think/sol_cli.py:run_command was discarding integer returns from
module.main() (process exited 0 even when the dispatched command
returned non-zero). Now honored. SystemExit semantics unchanged.
Regression test invokes the dispatcher via real subprocess.
- think/install_guard.py:_current_journal_for_alias fallback path
changed from ~/Documents/Solstone to ~/Documents/journal for vocabulary
consistency.

journal_is_active(path) added to think.utils — reads <path>/config/journal.json
directly, never raises, used to detect whether a journal has an owner before
any wrapper rewrite.

Exit-code convention: 0 = success / noop / dry-run, 1 = refusal /
validation, 2 = partial-state failure (rename succeeded but wrapper write
failed; or wrapper rewrote but service start returned non-zero).

+1385 -152
+52 -1
tests/test_config.py
··· 8 8 9 9 import pytest 10 10 11 - from think.utils import get_config 11 + from think.utils import get_config, journal_is_active 12 12 13 13 14 14 @pytest.fixture ··· 184 184 assert isinstance(config["identity"]["email_addresses"], list) 185 185 assert isinstance(config["identity"]["timezone"], str) 186 186 assert isinstance(config["identity"]["bio"], str) 187 + 188 + 189 + def _write_journal_config(journal_path, config): 190 + config_dir = journal_path / "config" 191 + config_dir.mkdir(parents=True, exist_ok=True) 192 + (config_dir / "journal.json").write_text(json.dumps(config), encoding="utf-8") 193 + 194 + 195 + @pytest.mark.parametrize( 196 + ("config", "expected"), 197 + [ 198 + ({"identity": {"name": "Active User"}}, True), 199 + ({}, False), 200 + ({"identity": {"name": ""}}, False), 201 + ({"identity": {"name": " "}}, False), 202 + ], 203 + ) 204 + def test_journal_is_active_from_config(tmp_path, config, expected): 205 + _write_journal_config(tmp_path, config) 206 + assert journal_is_active(tmp_path) is expected 207 + 208 + 209 + def test_journal_is_active_with_fixtures(monkeypatch): 210 + monkeypatch.setenv("SOLSTONE_JOURNAL", "tests/fixtures/journal") 211 + assert journal_is_active("tests/fixtures/journal") is True 212 + 213 + 214 + def test_journal_is_active_false_for_empty_dir(tmp_path): 215 + assert journal_is_active(tmp_path) is False 216 + 217 + 218 + def test_journal_is_active_false_without_config(tmp_path): 219 + (tmp_path / "config").mkdir() 220 + assert journal_is_active(tmp_path) is False 221 + 222 + 223 + def test_journal_is_active_false_for_malformed_json(tmp_path): 224 + config_dir = tmp_path / "config" 225 + config_dir.mkdir() 226 + (config_dir / "journal.json").write_text("{bad json", encoding="utf-8") 227 + assert journal_is_active(tmp_path) is False 228 + 229 + 230 + def test_journal_is_active_false_for_path_that_is_not_directory(tmp_path): 231 + file_path = tmp_path / "journal.json" 232 + file_path.write_text("{}", encoding="utf-8") 233 + assert journal_is_active(file_path) is False 234 + 235 + 236 + def test_journal_is_active_false_for_absent_path(tmp_path): 237 + assert journal_is_active(tmp_path / "missing") is False
+610 -21
tests/test_config_cli.py
··· 3 3 4 4 from __future__ import annotations 5 5 6 + import json 7 + from dataclasses import replace 6 8 from pathlib import Path 7 9 from unittest.mock import MagicMock 8 10 ··· 44 46 return alias 45 47 46 48 49 + def make_journal(path: Path, *, active: bool | None = None) -> Path: 50 + path.mkdir(parents=True, exist_ok=True) 51 + if active is None: 52 + return path 53 + config_dir = path / "config" 54 + config_dir.mkdir(parents=True, exist_ok=True) 55 + (config_dir / "journal.json").write_text( 56 + json.dumps({"identity": {"name": "Owner" if active else ""}}), 57 + encoding="utf-8", 58 + ) 59 + return path 60 + 61 + 62 + def assert_wrapper(alias: Path, *, journal: str, sol_bin: str) -> None: 63 + assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 64 + "journal": journal, 65 + "sol_bin": sol_bin, 66 + } 67 + 68 + 69 + def patch_service(monkeypatch, *, installed: bool, running: bool): 70 + monkeypatch.setattr(config_cli, "service_is_installed", lambda: installed) 71 + monkeypatch.setattr(config_cli, "service_is_running", lambda: running) 72 + 73 + 74 + def service_run_mock(*, returncodes: list[int] | None = None): 75 + if returncodes is None: 76 + return MagicMock(return_value=MagicMock(returncode=0, stdout="", stderr="")) 77 + return MagicMock( 78 + side_effect=[ 79 + MagicMock(returncode=code, stdout="", stderr="") for code in returncodes 80 + ] 81 + ) 82 + 83 + 47 84 def test_config_command_registered(): 48 85 from think import sol_cli as sol 49 86 ··· 103 140 ] 104 141 105 142 143 + def test_show_ignores_service_mock(home_root, monkeypatch, tmp_path, capsys): 144 + journal = str((tmp_path / "journal").resolve()) 145 + target = ensure_expected_target(tmp_path / "repo") 146 + make_managed_wrapper(home_root, journal=journal, sol_bin=str(target)) 147 + monkeypatch.setenv("SOLSTONE_JOURNAL", journal) 148 + monkeypatch.setattr(config_cli, "service_is_installed", lambda: False) 149 + 150 + rc = config_cli.cmd_show() 151 + captured = capsys.readouterr() 152 + 153 + assert rc == 0 154 + assert captured.err == "" 155 + assert "wrapper-status: managed" in captured.out 156 + 157 + 106 158 def test_journal_noops_when_path_already_embedded( 107 159 home_root, monkeypatch, tmp_path, capsys 108 160 ): ··· 110 162 target = ensure_expected_target(tmp_path / "repo") 111 163 alias = make_managed_wrapper(home_root, journal=target_path, sol_bin=str(target)) 112 164 original = alias.read_text(encoding="utf-8") 165 + patch_service(monkeypatch, installed=False, running=False) 166 + run_mock = service_run_mock() 167 + monkeypatch.setattr(config_cli.subprocess, "run", run_mock) 113 168 114 169 rc = config_cli.cmd_journal(target_path) 115 170 captured = capsys.readouterr() ··· 118 173 assert captured.err == "" 119 174 assert captured.out == f"sol config: journal already set to {target_path}\n" 120 175 assert alias.read_text(encoding="utf-8") == original 176 + run_mock.assert_not_called() 121 177 122 178 123 179 def test_journal_rewrites_wrapper(home_root, monkeypatch, tmp_path, capsys): 124 - source_path = str((tmp_path / "source").resolve()) 180 + source = make_journal(tmp_path / "source", active=False) 125 181 target_path = str((tmp_path / "target").resolve()) 126 182 target = ensure_expected_target(tmp_path / "repo") 127 - alias = make_managed_wrapper(home_root, journal=source_path, sol_bin=str(target)) 128 - run_mock = MagicMock(return_value=MagicMock(returncode=0)) 183 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 184 + patch_service(monkeypatch, installed=False, running=False) 185 + run_mock = service_run_mock() 129 186 monkeypatch.setattr(config_cli.subprocess, "run", run_mock) 130 - monkeypatch.chdir(home_root) 131 187 132 188 rc = config_cli.cmd_journal(target_path) 133 189 captured = capsys.readouterr() 134 190 135 191 assert rc == 0 136 192 assert captured.err == "" 137 - assert captured.out == f"sol config: journal set to {target_path}\n" 138 - assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 139 - "journal": target_path, 140 - "sol_bin": str(target), 141 - } 142 - run_mock.assert_called_once_with( 143 - [str(target), "service", "restart", "--if-installed"], 144 - check=False, 145 - ) 193 + assert captured.out == "service not installed; wrapper updated.\n" 194 + assert_wrapper(alias, journal=target_path, sol_bin=str(target)) 195 + run_mock.assert_not_called() 196 + 197 + 198 + @pytest.mark.parametrize( 199 + ("current_active", "target_active", "expected_flags"), 200 + [ 201 + (False, True, "--switch, --merge, --force"), 202 + (True, False, "--move, --switch"), 203 + (True, True, "--switch, --merge, --force"), 204 + ], 205 + ) 206 + def test_journal_refuses_without_flag_for_active_matrix_cells( 207 + home_root, 208 + monkeypatch, 209 + tmp_path, 210 + capsys, 211 + current_active, 212 + target_active, 213 + expected_flags, 214 + ): 215 + source = make_journal(tmp_path / "source", active=current_active) 216 + target_path = tmp_path / "target" 217 + if target_active: 218 + make_journal(target_path, active=True) 219 + target = ensure_expected_target(tmp_path / "repo") 220 + make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 221 + patch_service(monkeypatch, installed=False, running=False) 222 + 223 + rc = config_cli.cmd_journal(str(target_path)) 224 + captured = capsys.readouterr() 225 + 226 + assert rc == 1 227 + assert captured.out == "" 228 + assert f"current is {'active' if current_active else 'not active'}" in captured.err 229 + assert f"target is {'active' if target_active else 'not active'}" in captured.err 230 + assert f"valid flags: {expected_flags}" in captured.err 231 + if not target_active: 232 + assert not target_path.exists() 146 233 147 234 148 235 def test_journal_refuses_without_managed_wrapper(home_root, tmp_path, capsys): ··· 191 278 192 279 193 280 def test_journal_exits_2_on_restart_failure(home_root, monkeypatch, tmp_path, capsys): 194 - source_path = str((tmp_path / "source").resolve()) 281 + source = make_journal(tmp_path / "source", active=False) 195 282 target_path = str((tmp_path / "target").resolve()) 196 283 target = ensure_expected_target(tmp_path / "repo") 197 - alias = make_managed_wrapper(home_root, journal=source_path, sol_bin=str(target)) 284 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 285 + patch_service(monkeypatch, installed=True, running=True) 198 286 monkeypatch.setattr( 199 287 config_cli.subprocess, 200 288 "run", 201 - MagicMock(return_value=MagicMock(returncode=1)), 289 + service_run_mock(returncodes=[1]), 202 290 ) 203 - monkeypatch.chdir(home_root) 204 291 205 292 rc = config_cli.cmd_journal(target_path) 206 293 captured = capsys.readouterr() ··· 208 295 assert rc == 2 209 296 assert captured.out == "" 210 297 assert "wrapper rewritten to" in captured.err 211 - assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 212 - "journal": target_path, 213 - "sol_bin": str(target), 214 - } 298 + assert_wrapper(alias, journal=target_path, sol_bin=str(target)) 299 + 300 + 301 + def test_journal_switch_without_yes_prints_plan( 302 + home_root, monkeypatch, tmp_path, capsys 303 + ): 304 + source = make_journal(tmp_path / "source", active=True) 305 + target_path = tmp_path / "target" 306 + target = ensure_expected_target(tmp_path / "repo") 307 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 308 + patch_service(monkeypatch, installed=False, running=False) 309 + run_mock = service_run_mock() 310 + monkeypatch.setattr(config_cli.subprocess, "run", run_mock) 311 + 312 + rc = config_cli.cmd_journal( 313 + str(target_path), 314 + action=config_cli.RequestedAction.SWITCH, 315 + ) 316 + captured = capsys.readouterr() 317 + 318 + assert rc == 1 319 + assert "sol config journal - plan summary" in captured.out 320 + assert "action: switch" in captured.out 321 + assert "re-run with --yes to proceed" in captured.out 322 + assert "current journal is left intact." in captured.out 323 + assert not target_path.exists() 324 + assert_wrapper(alias, journal=str(source), sol_bin=str(target)) 325 + run_mock.assert_not_called() 326 + 327 + 328 + def test_journal_switch_dry_run_prints_plan(home_root, monkeypatch, tmp_path, capsys): 329 + source = make_journal(tmp_path / "source", active=True) 330 + target_path = tmp_path / "target" 331 + target = ensure_expected_target(tmp_path / "repo") 332 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 333 + patch_service(monkeypatch, installed=False, running=False) 334 + 335 + rc = config_cli.cmd_journal( 336 + str(target_path), 337 + action=config_cli.RequestedAction.SWITCH, 338 + dry_run=True, 339 + ) 340 + captured = capsys.readouterr() 341 + 342 + assert rc == 0 343 + assert "dry-run: yes; nothing will be changed" in captured.out 344 + assert not target_path.exists() 345 + assert_wrapper(alias, journal=str(source), sol_bin=str(target)) 346 + 347 + 348 + def test_journal_switch_active_to_not_active_updates_wrapper( 349 + home_root, monkeypatch, tmp_path, capsys 350 + ): 351 + source = make_journal(tmp_path / "source", active=True) 352 + target_path = tmp_path / "target" 353 + make_journal(target_path, active=False) 354 + target = ensure_expected_target(tmp_path / "repo") 355 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 356 + patch_service(monkeypatch, installed=False, running=False) 357 + 358 + rc = config_cli.cmd_journal( 359 + str(target_path), 360 + action=config_cli.RequestedAction.SWITCH, 361 + yes=True, 362 + ) 363 + captured = capsys.readouterr() 364 + 365 + assert rc == 0 366 + assert captured.out == "service not installed; wrapper updated.\n" 367 + assert source.exists() 368 + assert target_path.exists() 369 + assert_wrapper(alias, journal=str(target_path.resolve()), sol_bin=str(target)) 370 + 371 + 372 + def test_journal_switch_active_to_active_updates_wrapper( 373 + home_root, monkeypatch, tmp_path, capsys 374 + ): 375 + source = make_journal(tmp_path / "source", active=True) 376 + target_path = make_journal(tmp_path / "target", active=True) 377 + target = ensure_expected_target(tmp_path / "repo") 378 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 379 + patch_service(monkeypatch, installed=False, running=False) 380 + 381 + rc = config_cli.cmd_journal( 382 + str(target_path), 383 + action=config_cli.RequestedAction.SWITCH, 384 + yes=True, 385 + ) 386 + captured = capsys.readouterr() 387 + 388 + assert rc == 0 389 + assert captured.out == "service not installed; wrapper updated.\n" 390 + assert source.exists() 391 + assert target_path.exists() 392 + assert_wrapper(alias, journal=str(target_path.resolve()), sol_bin=str(target)) 393 + 394 + 395 + @pytest.mark.parametrize( 396 + ("current_active", "target_active"), 397 + [(False, False), (False, True), (True, False), (True, True)], 398 + ) 399 + def test_journal_merge_always_refuses_with_instructions( 400 + home_root, 401 + monkeypatch, 402 + tmp_path, 403 + capsys, 404 + current_active, 405 + target_active, 406 + ): 407 + source = make_journal(tmp_path / "source", active=current_active) 408 + target_path = tmp_path / "target" 409 + if target_active: 410 + make_journal(target_path, active=True) 411 + target = ensure_expected_target(tmp_path / "repo") 412 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 413 + patch_service(monkeypatch, installed=True, running=True) 414 + run_mock = service_run_mock() 415 + monkeypatch.setattr(config_cli.subprocess, "run", run_mock) 416 + original = alias.read_text(encoding="utf-8") 417 + 418 + rc = config_cli.cmd_journal( 419 + str(target_path), 420 + action=config_cli.RequestedAction.MERGE, 421 + ) 422 + captured = capsys.readouterr() 423 + 424 + assert rc == 1 425 + assert captured.err == "" 426 + assert captured.out == config_cli.MERGE_INSTRUCTIONS + "\n" 427 + assert target_path.exists() is target_active 428 + assert alias.read_text(encoding="utf-8") == original 429 + run_mock.assert_not_called() 430 + 431 + 432 + def test_journal_force_switches_active_target_without_yes( 433 + home_root, monkeypatch, tmp_path, capsys 434 + ): 435 + source = make_journal(tmp_path / "source", active=False) 436 + target_path = make_journal(tmp_path / "target", active=True) 437 + target = ensure_expected_target(tmp_path / "repo") 438 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 439 + patch_service(monkeypatch, installed=False, running=False) 440 + 441 + rc = config_cli.cmd_journal( 442 + str(target_path), 443 + action=config_cli.RequestedAction.FORCE, 444 + ) 445 + captured = capsys.readouterr() 446 + 447 + assert rc == 0 448 + assert ( 449 + "warning: --force bypasses confirmation and target activity checks" 450 + in captured.err 451 + ) 452 + assert captured.out.endswith("service not installed; wrapper updated.\n") 453 + assert_wrapper(alias, journal=str(target_path.resolve()), sol_bin=str(target)) 454 + 455 + 456 + def test_journal_proceed_installed_not_running_does_not_restart( 457 + home_root, monkeypatch, tmp_path, capsys 458 + ): 459 + source = make_journal(tmp_path / "source", active=False) 460 + target_path = tmp_path / "target" 461 + target = ensure_expected_target(tmp_path / "repo") 462 + make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 463 + patch_service(monkeypatch, installed=True, running=False) 464 + run_mock = service_run_mock() 465 + monkeypatch.setattr(config_cli.subprocess, "run", run_mock) 466 + 467 + rc = config_cli.cmd_journal(str(target_path)) 468 + captured = capsys.readouterr() 469 + 470 + assert rc == 0 471 + assert captured.out == "service installed but not running; wrapper updated.\n" 472 + run_mock.assert_not_called() 473 + 474 + 475 + def test_journal_proceed_installed_running_restarts( 476 + home_root, monkeypatch, tmp_path, capsys 477 + ): 478 + source = make_journal(tmp_path / "source", active=False) 479 + target_path = tmp_path / "target" 480 + target = ensure_expected_target(tmp_path / "repo") 481 + make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 482 + patch_service(monkeypatch, installed=True, running=True) 483 + run_mock = service_run_mock(returncodes=[0]) 484 + monkeypatch.setattr(config_cli.subprocess, "run", run_mock) 485 + 486 + rc = config_cli.cmd_journal(str(target_path)) 487 + captured = capsys.readouterr() 488 + 489 + assert rc == 0 490 + assert captured.out == "wrapper updated; service restarted.\n" 491 + run_mock.assert_called_once_with( 492 + [str(target), "service", "restart", "--if-installed"], 493 + check=False, 494 + ) 495 + 496 + 497 + def test_journal_move_happy_path(home_root, monkeypatch, tmp_path, capsys): 498 + source = make_journal(tmp_path / "source", active=True) 499 + target_path = tmp_path / "target" 500 + target = ensure_expected_target(tmp_path / "repo") 501 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 502 + patch_service(monkeypatch, installed=False, running=False) 503 + run_mock = service_run_mock() 504 + monkeypatch.setattr(config_cli.subprocess, "run", run_mock) 505 + 506 + rc = config_cli.cmd_journal( 507 + str(target_path), 508 + action=config_cli.RequestedAction.MOVE, 509 + yes=True, 510 + ) 511 + captured = capsys.readouterr() 512 + 513 + assert rc == 0 514 + assert captured.out == "service not installed; journal moved; wrapper updated.\n" 515 + assert not source.exists() 516 + assert target_path.exists() 517 + assert_wrapper(alias, journal=str(target_path.resolve()), sol_bin=str(target)) 518 + run_mock.assert_not_called() 519 + 520 + 521 + def test_journal_move_cross_filesystem_refuses( 522 + home_root, monkeypatch, tmp_path, capsys 523 + ): 524 + source = make_journal(tmp_path / "source", active=True) 525 + target_path = tmp_path / "target" 526 + target = ensure_expected_target(tmp_path / "repo") 527 + make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 528 + patch_service(monkeypatch, installed=False, running=False) 529 + original_build_change = config_cli.build_change 530 + 531 + def fake_build_change(*args, **kwargs): 532 + change = original_build_change(*args, **kwargs) 533 + return replace( 534 + change, same_filesystem=False, current_device=1, target_parent_device=2 535 + ) 536 + 537 + monkeypatch.setattr(config_cli, "build_change", fake_build_change) 538 + 539 + rc = config_cli.cmd_journal( 540 + str(target_path), 541 + action=config_cli.RequestedAction.MOVE, 542 + yes=True, 543 + ) 544 + captured = capsys.readouterr() 545 + 546 + assert rc == 1 547 + assert "cannot move across filesystems" in captured.err 548 + assert "device=1" in captured.err 549 + assert "device=2" in captured.err 550 + assert "sol call journal merge" in captured.err 551 + 552 + 553 + def test_journal_move_target_exists_refuses(home_root, monkeypatch, tmp_path, capsys): 554 + source = make_journal(tmp_path / "source", active=True) 555 + target_path = make_journal(tmp_path / "target", active=False) 556 + target = ensure_expected_target(tmp_path / "repo") 557 + make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 558 + patch_service(monkeypatch, installed=False, running=False) 559 + 560 + rc = config_cli.cmd_journal( 561 + str(target_path), 562 + action=config_cli.RequestedAction.MOVE, 563 + yes=True, 564 + ) 565 + captured = capsys.readouterr() 566 + 567 + assert rc == 1 568 + assert "move target already exists" in captured.err 569 + 570 + 571 + def test_journal_move_missing_current_refuses(home_root, monkeypatch, tmp_path, capsys): 572 + source_path = tmp_path / "missing-source" 573 + target_path = tmp_path / "target" 574 + target = ensure_expected_target(tmp_path / "repo") 575 + make_managed_wrapper(home_root, journal=str(source_path), sol_bin=str(target)) 576 + patch_service(monkeypatch, installed=False, running=False) 577 + 578 + rc = config_cli.cmd_journal( 579 + str(target_path), 580 + action=config_cli.RequestedAction.MOVE, 581 + yes=True, 582 + ) 583 + captured = capsys.readouterr() 584 + 585 + assert rc == 1 586 + assert "move source does not exist" in captured.err 587 + 588 + 589 + def test_journal_move_target_parent_missing_refuses( 590 + home_root, monkeypatch, tmp_path, capsys 591 + ): 592 + source = make_journal(tmp_path / "source", active=True) 593 + target = ensure_expected_target(tmp_path / "repo") 594 + make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 595 + missing_target = tmp_path / "missing-parent" / "target" 596 + 597 + rc = config_cli.cmd_journal( 598 + str(missing_target), 599 + action=config_cli.RequestedAction.MOVE, 600 + yes=True, 601 + ) 602 + captured = capsys.readouterr() 603 + 604 + assert rc == 1 605 + assert "move target parent does not exist" in captured.err 606 + 607 + 608 + def test_journal_move_without_yes_prints_plan(home_root, monkeypatch, tmp_path, capsys): 609 + source = make_journal(tmp_path / "source", active=True) 610 + target_path = tmp_path / "target" 611 + target = ensure_expected_target(tmp_path / "repo") 612 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 613 + patch_service(monkeypatch, installed=False, running=False) 614 + 615 + rc = config_cli.cmd_journal( 616 + str(target_path), 617 + action=config_cli.RequestedAction.MOVE, 618 + ) 619 + captured = capsys.readouterr() 620 + 621 + assert rc == 1 622 + assert "sol config journal - plan summary" in captured.out 623 + assert "action: move" in captured.out 624 + assert "filesystem: same device" in captured.out 625 + assert "re-run with --yes to proceed" in captured.out 626 + assert source.exists() 627 + assert not target_path.exists() 628 + assert_wrapper(alias, journal=str(source), sol_bin=str(target)) 629 + 630 + 631 + def test_journal_move_dry_run_prints_plan(home_root, monkeypatch, tmp_path, capsys): 632 + source = make_journal(tmp_path / "source", active=True) 633 + target_path = tmp_path / "target" 634 + target = ensure_expected_target(tmp_path / "repo") 635 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 636 + patch_service(monkeypatch, installed=False, running=False) 637 + 638 + rc = config_cli.cmd_journal( 639 + str(target_path), 640 + action=config_cli.RequestedAction.MOVE, 641 + dry_run=True, 642 + ) 643 + captured = capsys.readouterr() 644 + 645 + assert rc == 0 646 + assert "dry-run: yes; nothing will be changed" in captured.out 647 + assert source.exists() 648 + assert not target_path.exists() 649 + assert_wrapper(alias, journal=str(source), sol_bin=str(target)) 650 + 651 + 652 + def test_journal_move_rolls_back_on_wrapper_write_failure( 653 + home_root, monkeypatch, tmp_path, capsys 654 + ): 655 + source = make_journal(tmp_path / "source", active=True) 656 + target_path = tmp_path / "target" 657 + target = ensure_expected_target(tmp_path / "repo") 658 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 659 + patch_service(monkeypatch, installed=False, running=False) 660 + monkeypatch.setattr( 661 + config_cli, 662 + "write_wrapper_atomic", 663 + MagicMock(side_effect=OSError("disk full")), 664 + ) 665 + 666 + rc = config_cli.cmd_journal( 667 + str(target_path), 668 + action=config_cli.RequestedAction.MOVE, 669 + yes=True, 670 + ) 671 + captured = capsys.readouterr() 672 + 673 + assert rc == 2 674 + assert "restored original journal" in captured.err 675 + assert source.exists() 676 + assert not target_path.exists() 677 + assert_wrapper(alias, journal=str(source), sol_bin=str(target)) 678 + 679 + 680 + def test_journal_move_exits_2_when_service_start_fails( 681 + home_root, monkeypatch, tmp_path, capsys 682 + ): 683 + source = make_journal(tmp_path / "source", active=True) 684 + target_path = tmp_path / "target" 685 + target = ensure_expected_target(tmp_path / "repo") 686 + alias = make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 687 + patch_service(monkeypatch, installed=True, running=True) 688 + run_mock = service_run_mock(returncodes=[0, 1]) 689 + monkeypatch.setattr(config_cli.subprocess, "run", run_mock) 690 + 691 + rc = config_cli.cmd_journal( 692 + str(target_path), 693 + action=config_cli.RequestedAction.MOVE, 694 + yes=True, 695 + ) 696 + captured = capsys.readouterr() 697 + 698 + assert rc == 2 699 + assert ( 700 + captured.err 701 + == f"wrapper updated to {target_path.resolve()} but service start failed; restart manually\n" 702 + ) 703 + assert not source.exists() 704 + assert target_path.exists() 705 + assert_wrapper(alias, journal=str(target_path.resolve()), sol_bin=str(target)) 706 + 707 + 708 + def test_journal_switch_service_not_installed_does_not_restart( 709 + home_root, monkeypatch, tmp_path, capsys 710 + ): 711 + source = make_journal(tmp_path / "source", active=True) 712 + target_path = tmp_path / "target" 713 + target = ensure_expected_target(tmp_path / "repo") 714 + make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 715 + patch_service(monkeypatch, installed=False, running=False) 716 + run_mock = service_run_mock() 717 + monkeypatch.setattr(config_cli.subprocess, "run", run_mock) 718 + 719 + rc = config_cli.cmd_journal( 720 + str(target_path), 721 + action=config_cli.RequestedAction.SWITCH, 722 + yes=True, 723 + ) 724 + captured = capsys.readouterr() 725 + 726 + assert rc == 0 727 + assert captured.out == "service not installed; wrapper updated.\n" 728 + run_mock.assert_not_called() 729 + 730 + 731 + def test_journal_switch_service_installed_not_running_does_not_restart( 732 + home_root, monkeypatch, tmp_path, capsys 733 + ): 734 + source = make_journal(tmp_path / "source", active=True) 735 + target_path = tmp_path / "target" 736 + target = ensure_expected_target(tmp_path / "repo") 737 + make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 738 + patch_service(monkeypatch, installed=True, running=False) 739 + run_mock = service_run_mock() 740 + monkeypatch.setattr(config_cli.subprocess, "run", run_mock) 741 + 742 + rc = config_cli.cmd_journal( 743 + str(target_path), 744 + action=config_cli.RequestedAction.SWITCH, 745 + yes=True, 746 + ) 747 + captured = capsys.readouterr() 748 + 749 + assert rc == 0 750 + assert captured.out == "service installed but not running; wrapper updated.\n" 751 + run_mock.assert_not_called() 752 + 753 + 754 + def test_journal_move_service_installed_not_running_does_not_touch_service( 755 + home_root, monkeypatch, tmp_path, capsys 756 + ): 757 + source = make_journal(tmp_path / "source", active=True) 758 + target_path = tmp_path / "target" 759 + target = ensure_expected_target(tmp_path / "repo") 760 + make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 761 + patch_service(monkeypatch, installed=True, running=False) 762 + run_mock = service_run_mock() 763 + monkeypatch.setattr(config_cli.subprocess, "run", run_mock) 764 + 765 + rc = config_cli.cmd_journal( 766 + str(target_path), 767 + action=config_cli.RequestedAction.MOVE, 768 + yes=True, 769 + ) 770 + captured = capsys.readouterr() 771 + 772 + assert rc == 0 773 + assert ( 774 + captured.out 775 + == "service installed but not running; journal moved; wrapper updated.\n" 776 + ) 777 + run_mock.assert_not_called() 778 + 779 + 780 + def test_journal_move_service_running_stops_and_starts( 781 + home_root, monkeypatch, tmp_path, capsys 782 + ): 783 + source = make_journal(tmp_path / "source", active=True) 784 + target_path = tmp_path / "target" 785 + target = ensure_expected_target(tmp_path / "repo") 786 + make_managed_wrapper(home_root, journal=str(source), sol_bin=str(target)) 787 + patch_service(monkeypatch, installed=True, running=True) 788 + run_mock = service_run_mock(returncodes=[0, 0]) 789 + monkeypatch.setattr(config_cli.subprocess, "run", run_mock) 790 + 791 + rc = config_cli.cmd_journal( 792 + str(target_path), 793 + action=config_cli.RequestedAction.MOVE, 794 + yes=True, 795 + ) 796 + captured = capsys.readouterr() 797 + 798 + assert rc == 0 799 + assert captured.out == "journal moved; wrapper updated; service restarted.\n" 800 + assert [call.args[0] for call in run_mock.call_args_list] == [ 801 + [str(target), "service", "stop"], 802 + [str(target), "service", "start"], 803 + ]
+14
tests/test_install_guard.py
··· 93 93 94 94 95 95 class TestWrapperHelpers: 96 + def test_current_journal_for_alias_falls_back_to_documents_journal( 97 + self, home_root, monkeypatch 98 + ): 99 + from think import utils as think_utils 100 + 101 + def raise_not_configured(): 102 + raise think_utils.SolstoneNotConfigured("not configured") 103 + 104 + monkeypatch.setattr(think_utils, "get_journal_info", raise_not_configured) 105 + 106 + assert install_guard._current_journal_for_alias() == str( 107 + home_root / "Documents" / "journal" 108 + ) 109 + 96 110 def test_render_wrapper_round_trip_simple(self): 97 111 journal = "/tmp/solstone" 98 112 sol_bin = "/tmp/repo/.venv/bin/sol"
+70
tests/test_service.py
··· 147 147 assert "SOLSTONE_JOURNAL" not in env 148 148 149 149 150 + class TestServiceHelpers: 151 + def test_service_is_installed_true_linux(self, monkeypatch, tmp_path): 152 + unit_path = tmp_path / "solstone.service" 153 + unit_path.write_text("", encoding="utf-8") 154 + monkeypatch.setattr(service, "_platform", lambda: "linux") 155 + monkeypatch.setattr(service, "_unit_path", lambda: unit_path) 156 + assert service.service_is_installed() is True 157 + 158 + def test_service_is_installed_false_linux(self, monkeypatch, tmp_path): 159 + monkeypatch.setattr(service, "_platform", lambda: "linux") 160 + monkeypatch.setattr( 161 + service, "_unit_path", lambda: tmp_path / "missing" / "solstone.service" 162 + ) 163 + assert service.service_is_installed() is False 164 + 165 + def test_service_is_installed_true_darwin(self, monkeypatch, tmp_path): 166 + plist_path = tmp_path / "org.solpbc.solstone.plist" 167 + plist_path.write_text("", encoding="utf-8") 168 + monkeypatch.setattr(service, "_platform", lambda: "darwin") 169 + monkeypatch.setattr(service, "_plist_path", lambda: plist_path) 170 + assert service.service_is_installed() is True 171 + 172 + def test_service_is_installed_false_darwin(self, monkeypatch, tmp_path): 173 + monkeypatch.setattr(service, "_platform", lambda: "darwin") 174 + monkeypatch.setattr( 175 + service, 176 + "_plist_path", 177 + lambda: tmp_path / "missing" / "org.solpbc.solstone.plist", 178 + ) 179 + assert service.service_is_installed() is False 180 + 181 + def test_service_is_running_false_fast_when_not_installed(self, monkeypatch): 182 + run_mock = MagicMock() 183 + monkeypatch.setattr(service, "service_is_installed", lambda: False) 184 + monkeypatch.setattr(service.subprocess, "run", run_mock) 185 + assert service.service_is_running() is False 186 + run_mock.assert_not_called() 187 + 188 + def test_service_is_running_true_linux(self, monkeypatch): 189 + monkeypatch.setattr(service, "service_is_installed", lambda: True) 190 + monkeypatch.setattr(service, "_platform", lambda: "linux") 191 + run_mock = MagicMock(return_value=MagicMock(stdout="active\n")) 192 + monkeypatch.setattr(service.subprocess, "run", run_mock) 193 + assert service.service_is_running() is True 194 + 195 + @pytest.mark.parametrize("state", ["inactive\n", "failed\n"]) 196 + def test_service_is_running_false_linux(self, monkeypatch, state): 197 + monkeypatch.setattr(service, "service_is_installed", lambda: True) 198 + monkeypatch.setattr(service, "_platform", lambda: "linux") 199 + run_mock = MagicMock(return_value=MagicMock(stdout=state)) 200 + monkeypatch.setattr(service.subprocess, "run", run_mock) 201 + assert service.service_is_running() is False 202 + 203 + def test_service_is_running_true_darwin(self, monkeypatch): 204 + monkeypatch.setattr(service, "service_is_installed", lambda: True) 205 + monkeypatch.setattr(service, "_platform", lambda: "darwin") 206 + monkeypatch.setattr(service.os, "getuid", lambda: 501) 207 + run_mock = MagicMock(return_value=MagicMock(returncode=0)) 208 + monkeypatch.setattr(service.subprocess, "run", run_mock) 209 + assert service.service_is_running() is True 210 + 211 + def test_service_is_running_false_darwin(self, monkeypatch): 212 + monkeypatch.setattr(service, "service_is_installed", lambda: True) 213 + monkeypatch.setattr(service, "_platform", lambda: "darwin") 214 + monkeypatch.setattr(service.os, "getuid", lambda: 501) 215 + run_mock = MagicMock(return_value=MagicMock(returncode=1)) 216 + monkeypatch.setattr(service.subprocess, "run", run_mock) 217 + assert service.service_is_running() is False 218 + 219 + 150 220 class TestStatus: 151 221 def test_not_installed_linux(self, monkeypatch, tmp_path, capsys): 152 222 monkeypatch.setattr(sys, "platform", "linux")
+23 -1
tests/test_sol.py
··· 3 3 4 4 """Tests for sol.py unified CLI.""" 5 5 6 + import os 7 + import subprocess 6 8 import sys 7 9 from unittest.mock import MagicMock, patch 8 10 ··· 67 69 def test_run_command_success(self): 68 70 """Test running a command that exits cleanly.""" 69 71 mock_module = MagicMock() 70 - mock_module.main = MagicMock() 72 + mock_module.main = MagicMock(return_value=None) 71 73 72 74 with patch("importlib.import_module", return_value=mock_module): 73 75 exit_code = sol.run_command("test.module") ··· 119 121 with patch("importlib.import_module", return_value=mock_module): 120 122 exit_code = sol.run_command("test.module") 121 123 assert exit_code == 1 124 + 125 + def test_main_propagates_integer_return_code_via_real_subprocess(self, tmp_path): 126 + """Would fail on the parent commit because cmd_journal() returned 1 but sol exited 0.""" 127 + env = {**os.environ, "SOLSTONE_JOURNAL": str(tmp_path)} 128 + result = subprocess.run( 129 + [ 130 + sys.executable, 131 + "-m", 132 + "think.sol_cli", 133 + "config", 134 + "journal", 135 + "/tmp/with$dollar", 136 + ], 137 + capture_output=True, 138 + text=True, 139 + env=env, 140 + cwd=str(tmp_path), 141 + ) 142 + 143 + assert result.returncode == 1 122 144 123 145 124 146 class TestGetStatus:
+556 -74
think/config_cli.py
··· 9 9 import os 10 10 import subprocess 11 11 import sys 12 + from dataclasses import dataclass 13 + from enum import Enum 12 14 from pathlib import Path 13 15 14 16 from think.install_guard import ( ··· 19 21 wrapper_lock, 20 22 write_wrapper_atomic, 21 23 ) 22 - from think.utils import SolstoneNotConfigured, get_journal_info, get_project_root 24 + from think.service import service_is_installed, service_is_running 25 + from think.utils import ( 26 + SolstoneNotConfigured, 27 + get_journal_info, 28 + get_project_root, 29 + journal_is_active, 30 + ) 31 + 32 + MERGE_INSTRUCTIONS = "\n".join( 33 + [ 34 + "sol config: --merge is not handled here.", 35 + "use 'sol call journal merge <source> --dry-run' to preview the merge.", 36 + "use 'sol call journal merge <source>' to perform the merge.", 37 + ] 38 + ) 39 + 40 + 41 + class RequestedAction(Enum): 42 + MOVE = "move" 43 + SWITCH = "switch" 44 + MERGE = "merge" 45 + FORCE = "force" 46 + 47 + 48 + class Action(Enum): 49 + PROCEED = "proceed" 50 + MOVE = "move" 51 + SWITCH = "switch" 52 + MERGE = "merge" 53 + NOOP = "noop" 54 + REFUSE = "refuse" 55 + 56 + 57 + @dataclass(frozen=True) 58 + class JournalChange: 59 + current_path: Path 60 + target_path: Path 61 + paths_equal: bool 62 + current_active: bool 63 + target_active: bool 64 + current_exists: bool 65 + target_exists: bool 66 + target_parent_exists: bool 67 + current_device: int | None 68 + target_parent_device: int | None 69 + same_filesystem: bool | None 70 + service_installed: bool 71 + service_running: bool 72 + action: RequestedAction | None 73 + yes: bool 74 + dry_run: bool 75 + sol_bin: str 76 + alias: Path 77 + 78 + 79 + @dataclass(frozen=True) 80 + class Decision: 81 + action: Action 82 + exit_code: int 83 + message: str | None = None 84 + plan_only: bool = False 23 85 24 86 25 87 def _read_wrapper_status() -> tuple[str, str | None]: ··· 40 102 return "managed", parsed["journal"] 41 103 42 104 105 + def _wrapper_refusal(alias: Path) -> str: 106 + return ( 107 + "sol config: refused: " 108 + f"{alias} is not a managed wrapper (run 'make install-service' to " 109 + "install the wrapper first)" 110 + ) 111 + 112 + 113 + def _state_label(active: bool) -> str: 114 + return "active" if active else "not active" 115 + 116 + 117 + def _valid_flags(change: JournalChange) -> str: 118 + if change.current_active and not change.target_active: 119 + return "--move, --switch" 120 + return "--switch, --merge, --force" 121 + 122 + 123 + def _refusal_message(change: JournalChange) -> str: 124 + return ( 125 + "sol config: refused: " 126 + f"current is {_state_label(change.current_active)} and target is " 127 + f"{_state_label(change.target_active)}; valid flags: {_valid_flags(change)}" 128 + ) 129 + 130 + 131 + def _move_target_exists_message(change: JournalChange) -> str: 132 + return f"sol config: refused: move target already exists: {change.target_path}" 133 + 134 + 135 + def _move_missing_current_message(change: JournalChange) -> str: 136 + return f"sol config: refused: move source does not exist: {change.current_path}" 137 + 138 + 139 + def _move_missing_parent_message(change: JournalChange) -> str: 140 + return f"sol config: refused: move target parent does not exist: {change.target_path.parent}" 141 + 142 + 143 + def _move_cross_filesystem_message(change: JournalChange) -> str: 144 + return ( 145 + "sol config: refused: cannot move across filesystems " 146 + f"(current device={change.current_device}, target parent device={change.target_parent_device}); " 147 + "use 'sol call journal merge <source>' instead" 148 + ) 149 + 150 + 151 + def _move_requires_inactive_target_message(change: JournalChange) -> str: 152 + return ( 153 + "sol config: refused: " 154 + f"--move requires a not active target; current is {_state_label(change.current_active)} " 155 + f"and target is {_state_label(change.target_active)}; valid flags: --switch, --merge, --force" 156 + ) 157 + 158 + 159 + def _plan_closer(change: JournalChange) -> str: 160 + if change.dry_run: 161 + return "dry-run: yes; nothing will be changed" 162 + return "re-run with --yes to proceed" 163 + 164 + 165 + def _service_summary(change: JournalChange, decision: Decision) -> str: 166 + if decision.action is Action.MOVE: 167 + if not change.service_installed: 168 + return "service: not installed; will move and rewrite wrapper" 169 + if not change.service_running: 170 + return "service: installed but not running; will move and rewrite wrapper" 171 + return ( 172 + "service: installed and running; will stop, move, rewrite wrapper, restart" 173 + ) 174 + 175 + if not change.service_installed: 176 + return "service: not installed; will rewrite wrapper" 177 + if not change.service_running: 178 + return "service: installed but not running; will rewrite wrapper" 179 + return "service: installed and running; will rewrite wrapper, restart" 180 + 181 + 182 + def render_plan(change: JournalChange, decision: Decision) -> str: 183 + lines = [ 184 + "sol config journal - plan summary", 185 + "", 186 + f"current: {change.current_path} ({_state_label(change.current_active)})", 187 + f"target: {change.target_path} ({_state_label(change.target_active)})", 188 + f"action: {decision.action.value}", 189 + _service_summary(change, decision), 190 + ] 191 + 192 + if decision.action is Action.MOVE: 193 + filesystem = "same device" if change.same_filesystem else "different devices" 194 + lines.append(f"filesystem: {filesystem}") 195 + 196 + if decision.action is Action.SWITCH: 197 + lines.extend( 198 + [ 199 + "", 200 + "current journal is left intact. " 201 + f"to re-adopt it later: sol config journal {change.current_path} --switch --yes", 202 + ] 203 + ) 204 + 205 + lines.extend(["", _plan_closer(change)]) 206 + return "\n".join(lines) 207 + 208 + 209 + def _rewrite_wrapper(change: JournalChange) -> str | None: 210 + try: 211 + current_content = change.alias.read_text(encoding="utf-8") 212 + except OSError as exc: 213 + print( 214 + f"sol config: refused: cannot read {change.alias}: {exc}", 215 + file=sys.stderr, 216 + ) 217 + return None 218 + 219 + current = parse_wrapper(current_content) 220 + if current is None: 221 + print(_wrapper_refusal(change.alias), file=sys.stderr) 222 + return None 223 + 224 + target_str = str(change.target_path) 225 + if current["journal"] == target_str: 226 + return current["sol_bin"] 227 + 228 + new_content = render_wrapper(target_str, current["sol_bin"]) 229 + write_wrapper_atomic(change.alias, new_content) 230 + return current["sol_bin"] 231 + 232 + 233 + def _service_command(sol_bin: str, subcommand: str) -> subprocess.CompletedProcess: 234 + return subprocess.run( 235 + [sol_bin, "service", subcommand], 236 + check=False, 237 + capture_output=True, 238 + text=True, 239 + ) 240 + 241 + 242 + def _maybe_restart_current_service(change: JournalChange) -> None: 243 + if not change.service_running: 244 + return 245 + try: 246 + _service_command(change.sol_bin, "start") 247 + except FileNotFoundError as exc: 248 + print( 249 + f"sol config: rollback warning: could not restart service ({exc})", 250 + file=sys.stderr, 251 + ) 252 + 253 + 254 + def _run_switch(change: JournalChange) -> int: 255 + try: 256 + change.target_path.mkdir(parents=True, exist_ok=True) 257 + except OSError as exc: 258 + print( 259 + f"sol config: refused: cannot create {change.target_path}: {exc}", 260 + file=sys.stderr, 261 + ) 262 + return 1 263 + 264 + with wrapper_lock(): 265 + try: 266 + restart_sol = _rewrite_wrapper(change) 267 + except OSError as exc: 268 + print( 269 + f"sol config: refused: cannot rewrite {change.alias}: {exc}", 270 + file=sys.stderr, 271 + ) 272 + return 1 273 + 274 + if restart_sol is None: 275 + return 1 276 + 277 + if not change.service_installed: 278 + print("service not installed; wrapper updated.") 279 + return 0 280 + 281 + if not change.service_running: 282 + print("service installed but not running; wrapper updated.") 283 + return 0 284 + 285 + try: 286 + result = subprocess.run( 287 + [restart_sol, "service", "restart", "--if-installed"], 288 + check=False, 289 + ) 290 + except FileNotFoundError as exc: 291 + print( 292 + f"sol config: wrapper rewritten to {change.target_path} but service restart could not run ({exc}); restart manually", 293 + file=sys.stderr, 294 + ) 295 + return 2 296 + 297 + if result.returncode != 0: 298 + print( 299 + "sol config: wrapper rewritten to " 300 + f"{change.target_path} but 'sol service restart --if-installed' exited " 301 + f"{result.returncode}; investigate and restart manually", 302 + file=sys.stderr, 303 + ) 304 + return 2 305 + 306 + print("wrapper updated; service restarted.") 307 + return 0 308 + 309 + 310 + def _run_move(change: JournalChange) -> int: 311 + current = change.current_path 312 + target = change.target_path 313 + 314 + if not change.target_parent_exists: 315 + print(_move_missing_parent_message(change), file=sys.stderr) 316 + return 1 317 + if not current.exists(): 318 + print(_move_missing_current_message(change), file=sys.stderr) 319 + return 1 320 + if target.exists() or target.is_symlink(): 321 + print(_move_target_exists_message(change), file=sys.stderr) 322 + return 1 323 + if change.same_filesystem is False: 324 + print(_move_cross_filesystem_message(change), file=sys.stderr) 325 + return 1 326 + 327 + if change.service_running: 328 + try: 329 + stop_result = _service_command(change.sol_bin, "stop") 330 + except FileNotFoundError as exc: 331 + print( 332 + f"sol config: could not stop service before move ({exc})", 333 + file=sys.stderr, 334 + ) 335 + return 2 336 + if stop_result.returncode != 0: 337 + print( 338 + "sol config: could not stop service before move", 339 + file=sys.stderr, 340 + ) 341 + return 2 342 + 343 + try: 344 + os.rename(current, target) 345 + except OSError as exc: 346 + _maybe_restart_current_service(change) 347 + print(f"sol config: move failed: {exc}", file=sys.stderr) 348 + return 1 349 + 350 + with wrapper_lock(): 351 + try: 352 + restart_sol = _rewrite_wrapper(change) 353 + except OSError as exc: 354 + rollback_ok = True 355 + try: 356 + if target.exists(): 357 + os.rename(target, current) 358 + except OSError as rollback_exc: 359 + rollback_ok = False 360 + print( 361 + f"sol config: rollback failed after wrapper write error: {rollback_exc}", 362 + file=sys.stderr, 363 + ) 364 + _maybe_restart_current_service(change) 365 + message = f"sol config: move failed during wrapper update: {exc}" 366 + if rollback_ok: 367 + message += "; restored original journal" 368 + print(message, file=sys.stderr) 369 + return 2 370 + 371 + if restart_sol is None: 372 + try: 373 + os.rename(target, current) 374 + except OSError as rollback_exc: 375 + print( 376 + f"sol config: rollback failed after wrapper validation error: {rollback_exc}", 377 + file=sys.stderr, 378 + ) 379 + _maybe_restart_current_service(change) 380 + return 1 381 + 382 + if not change.service_installed: 383 + print("service not installed; journal moved; wrapper updated.") 384 + return 0 385 + 386 + if not change.service_running: 387 + print("service installed but not running; journal moved; wrapper updated.") 388 + return 0 389 + 390 + try: 391 + start_result = _service_command(restart_sol, "start") 392 + except FileNotFoundError: 393 + print( 394 + f"wrapper updated to {target} but service start failed; restart manually", 395 + file=sys.stderr, 396 + ) 397 + return 2 398 + 399 + if start_result.returncode != 0: 400 + print( 401 + f"wrapper updated to {target} but service start failed; restart manually", 402 + file=sys.stderr, 403 + ) 404 + return 2 405 + 406 + print("journal moved; wrapper updated; service restarted.") 407 + return 0 408 + 409 + 410 + def _run_noop(change: JournalChange, _decision: Decision) -> int: 411 + print(f"sol config: journal already set to {change.target_path}") 412 + return 0 413 + 414 + 415 + def _refuse(decision: Decision) -> int: 416 + if decision.message: 417 + print(decision.message, file=sys.stderr) 418 + return decision.exit_code 419 + 420 + 421 + def decide(change: JournalChange) -> Decision: 422 + if change.action is RequestedAction.MERGE: 423 + return Decision(Action.MERGE, 1, MERGE_INSTRUCTIONS) 424 + 425 + if change.paths_equal: 426 + return Decision(Action.NOOP, 0) 427 + 428 + if change.action is None: 429 + if not change.current_active and not change.target_active: 430 + return Decision(Action.PROCEED, 0) 431 + return Decision(Action.REFUSE, 1, _refusal_message(change)) 432 + 433 + if change.action is RequestedAction.FORCE: 434 + return Decision(Action.SWITCH, 0) 435 + 436 + if change.action is RequestedAction.MOVE: 437 + if not change.target_parent_exists: 438 + return Decision(Action.REFUSE, 1, _move_missing_parent_message(change)) 439 + if not change.current_exists: 440 + return Decision(Action.REFUSE, 1, _move_missing_current_message(change)) 441 + if change.target_exists: 442 + return Decision(Action.REFUSE, 1, _move_target_exists_message(change)) 443 + if change.target_active: 444 + return Decision( 445 + Action.REFUSE, 1, _move_requires_inactive_target_message(change) 446 + ) 447 + if change.same_filesystem is False: 448 + return Decision(Action.REFUSE, 1, _move_cross_filesystem_message(change)) 449 + if change.dry_run: 450 + return Decision(Action.MOVE, 0, plan_only=True) 451 + if not change.yes: 452 + return Decision(Action.MOVE, 1, plan_only=True) 453 + return Decision(Action.MOVE, 0) 454 + 455 + if change.action is RequestedAction.SWITCH: 456 + if change.dry_run: 457 + return Decision(Action.SWITCH, 0, plan_only=True) 458 + if not change.yes: 459 + return Decision(Action.SWITCH, 1, plan_only=True) 460 + return Decision(Action.SWITCH, 0) 461 + 462 + return Decision(Action.REFUSE, 1, _refusal_message(change)) 463 + 464 + 465 + def execute(change: JournalChange, decision: Decision) -> int: 466 + if change.action is RequestedAction.FORCE: 467 + print( 468 + "sol config: warning: --force bypasses confirmation and target activity checks", 469 + file=sys.stderr, 470 + ) 471 + 472 + if decision.action is Action.MERGE: 473 + print(decision.message or MERGE_INSTRUCTIONS) 474 + return decision.exit_code 475 + if decision.action is Action.REFUSE: 476 + return _refuse(decision) 477 + if decision.action is Action.NOOP: 478 + return _run_noop(change, decision) 479 + if decision.plan_only: 480 + print(render_plan(change, decision)) 481 + return decision.exit_code 482 + if decision.action in {Action.PROCEED, Action.SWITCH}: 483 + return _run_switch(change) 484 + if decision.action is Action.MOVE: 485 + return _run_move(change) 486 + return 1 487 + 488 + 489 + def build_change( 490 + args: argparse.Namespace, *, alias_path: Path, sol_bin: str, current_path: Path 491 + ) -> JournalChange: 492 + target_path = Path(args.path).expanduser().resolve() 493 + current_path = current_path.expanduser().resolve() 494 + current_exists = current_path.exists() 495 + target_exists = target_path.exists() or target_path.is_symlink() 496 + target_parent_exists = target_path.parent.exists() 497 + current_device = None 498 + target_parent_device = None 499 + same_filesystem = None 500 + if current_exists: 501 + try: 502 + current_device = os.stat(current_path).st_dev 503 + except OSError: 504 + current_device = None 505 + if target_parent_exists: 506 + try: 507 + target_parent_device = os.stat(target_path.parent).st_dev 508 + except OSError: 509 + target_parent_device = None 510 + if current_device is not None and target_parent_device is not None: 511 + same_filesystem = current_device == target_parent_device 512 + 513 + installed = service_is_installed() 514 + running = service_is_running() if installed else False 515 + 516 + return JournalChange( 517 + current_path=current_path, 518 + target_path=target_path, 519 + paths_equal=current_path == target_path, 520 + current_active=journal_is_active(current_path), 521 + target_active=journal_is_active(target_path), 522 + current_exists=current_exists, 523 + target_exists=target_exists, 524 + target_parent_exists=target_parent_exists, 525 + current_device=current_device, 526 + target_parent_device=target_parent_device, 527 + same_filesystem=same_filesystem, 528 + service_installed=installed, 529 + service_running=running, 530 + action=args.action, 531 + yes=args.yes, 532 + dry_run=args.dry_run, 533 + sol_bin=sol_bin, 534 + alias=alias_path, 535 + ) 536 + 537 + 43 538 def cmd_show() -> int: 44 539 wrapper_status, embedded_journal = _read_wrapper_status() 45 540 ··· 66 561 return 0 67 562 68 563 69 - def cmd_journal(target_path: str) -> int: 564 + def cmd_journal( 565 + target_path: str, 566 + *, 567 + action: RequestedAction | None = None, 568 + yes: bool = False, 569 + dry_run: bool = False, 570 + ) -> int: 70 571 target = Path(target_path).expanduser().resolve() 71 572 target_str = str(target) 72 573 ··· 90 591 ) 91 592 return 1 92 593 93 - try: 94 - target.mkdir(parents=True, exist_ok=True) 95 - except OSError as exc: 594 + if action is RequestedAction.MOVE and not target.parent.exists(): 96 595 print( 97 - f"sol config: refused: cannot create {target_str}: {exc}", file=sys.stderr 596 + f"sol config: refused: move target parent does not exist: {target.parent}", 597 + file=sys.stderr, 98 598 ) 99 599 return 1 100 600 101 601 alias = alias_path() 102 602 if not alias.exists() or alias.is_symlink(): 103 - print( 104 - "sol config: refused: " 105 - f"{alias} is not a managed wrapper (run 'make install-service' to " 106 - "install the wrapper first)", 107 - file=sys.stderr, 108 - ) 603 + print(_wrapper_refusal(alias), file=sys.stderr) 109 604 return 1 110 605 111 606 try: ··· 116 611 117 612 parsed = parse_wrapper(content) 118 613 if parsed is None: 119 - print( 120 - "sol config: refused: " 121 - f"{alias} is not a managed wrapper (run 'make install-service' to " 122 - "install the wrapper first)", 123 - file=sys.stderr, 124 - ) 614 + print(_wrapper_refusal(alias), file=sys.stderr) 125 615 return 1 126 616 127 - if parsed["journal"] == target_str: 128 - print(f"sol config: journal already set to {target_str}") 129 - return 0 130 - 131 - restart_sol = parsed["sol_bin"] 132 - with wrapper_lock(): 133 - try: 134 - current_content = alias.read_text(encoding="utf-8") 135 - except OSError as exc: 136 - print(f"sol config: refused: cannot read {alias}: {exc}", file=sys.stderr) 137 - return 1 138 - 139 - current = parse_wrapper(current_content) 140 - if current is None: 141 - print( 142 - "sol config: refused: " 143 - f"{alias} is not a managed wrapper (run 'make install-service' to " 144 - "install the wrapper first)", 145 - file=sys.stderr, 146 - ) 147 - return 1 148 - 149 - if current["journal"] == target_str: 150 - print(f"sol config: journal already set to {target_str}") 151 - return 0 152 - 153 - new_content = render_wrapper(target_str, current["sol_bin"]) 154 - write_wrapper_atomic(alias, new_content) 155 - restart_sol = current["sol_bin"] 156 - 157 - try: 158 - result = subprocess.run( 159 - [restart_sol, "service", "restart", "--if-installed"], 160 - check=False, 161 - ) 162 - except FileNotFoundError as exc: 163 - print( 164 - "sol config: wrapper rewritten to " 165 - f"{target_str} but service restart could not run ({exc}); restart " 166 - "manually", 167 - file=sys.stderr, 168 - ) 169 - return 2 170 - 171 - if result.returncode != 0: 172 - print( 173 - "sol config: wrapper rewritten to " 174 - f"{target_str} but 'sol service restart --if-installed' exited " 175 - f"{result.returncode}; investigate and restart manually", 176 - file=sys.stderr, 177 - ) 178 - return 2 179 - 180 - print(f"sol config: journal set to {target_str}") 181 - return 0 617 + args = argparse.Namespace( 618 + path=target_str, 619 + action=action, 620 + yes=yes, 621 + dry_run=dry_run, 622 + ) 623 + change = build_change( 624 + args, 625 + alias_path=alias, 626 + sol_bin=parsed["sol_bin"], 627 + current_path=Path(parsed["journal"]), 628 + ) 629 + decision = decide(change) 630 + return execute(change, decision) 182 631 183 632 184 633 def main() -> int: ··· 192 641 journal_parser.add_argument( 193 642 "path", help="absolute path to the new journal directory" 194 643 ) 644 + action_group = journal_parser.add_mutually_exclusive_group() 645 + action_group.add_argument( 646 + "--move", 647 + dest="action", 648 + action="store_const", 649 + const=RequestedAction.MOVE, 650 + ) 651 + action_group.add_argument( 652 + "--switch", 653 + dest="action", 654 + action="store_const", 655 + const=RequestedAction.SWITCH, 656 + ) 657 + action_group.add_argument( 658 + "--merge", 659 + dest="action", 660 + action="store_const", 661 + const=RequestedAction.MERGE, 662 + ) 663 + action_group.add_argument( 664 + "--force", 665 + dest="action", 666 + action="store_const", 667 + const=RequestedAction.FORCE, 668 + ) 669 + confirm_group = journal_parser.add_mutually_exclusive_group() 670 + confirm_group.add_argument("--yes", action="store_true") 671 + confirm_group.add_argument("--dry-run", action="store_true") 195 672 args = parser.parse_args() 196 673 197 674 if args.cmd == "show": 198 675 return cmd_show() 199 676 if args.cmd == "journal": 200 - return cmd_journal(args.path) 677 + return cmd_journal( 678 + args.path, 679 + action=args.action, 680 + yes=args.yes, 681 + dry_run=args.dry_run, 682 + ) 201 683 return 1 202 684 203 685
+1 -1
think/install_guard.py
··· 154 154 try: 155 155 path, _ = think_utils.get_journal_info() 156 156 except getattr(think_utils, "SolstoneNotConfigured", RuntimeError): 157 - path = str(Path.home() / "Documents" / "Solstone") 157 + path = str(Path.home() / "Documents" / "journal") 158 158 return path 159 159 160 160
+36 -52
think/service.py
··· 59 59 return str(Path.home() / ".local" / "bin" / "sol") 60 60 61 61 62 + def service_is_installed() -> bool: 63 + """Return whether the user service definition is installed.""" 64 + return _plist_path().exists() if _platform() == "darwin" else _unit_path().exists() 65 + 66 + 67 + def service_is_running() -> bool: 68 + """Return whether the background service is currently running.""" 69 + if not service_is_installed(): 70 + return False 71 + if _platform() == "darwin": 72 + result = subprocess.run( 73 + ["launchctl", "print", f"gui/{os.getuid()}/{SERVICE_LABEL}"], 74 + capture_output=True, 75 + text=True, 76 + ) 77 + return result.returncode == 0 78 + result = subprocess.run( 79 + ["systemctl", "--user", "is-active", SYSTEMD_UNIT], 80 + capture_output=True, 81 + text=True, 82 + ) 83 + return result.stdout.strip() == "active" 84 + 85 + 62 86 def _collect_env() -> dict[str, str]: 63 87 """Collect environment variables for the service file. 64 88 ··· 381 405 382 406 def _restart(if_installed: bool = False) -> int: 383 407 platform = _platform() 384 - if platform == "darwin": 385 - installed = _plist_path().exists() 386 - else: 387 - installed = _unit_path().exists() 388 - 389 - if not installed: 408 + if not service_is_installed(): 390 409 if if_installed: 391 410 return 0 392 411 print( ··· 426 445 def _status() -> int: 427 446 platform = _platform() 428 447 429 - if platform == "darwin": 430 - installed = _plist_path().exists() 431 - else: 432 - installed = _unit_path().exists() 433 - 434 - if not installed: 448 + if not service_is_installed(): 435 449 print("Service: not installed") 436 450 print("Run 'sol service install' to install, or 'sol up' to install and start.") 437 451 return 1 438 452 439 453 print("Service: installed") 440 454 441 - if platform == "darwin": 442 - uid = os.getuid() 443 - result = subprocess.run( 444 - ["launchctl", "print", f"gui/{uid}/{SERVICE_LABEL}"], 445 - capture_output=True, 446 - text=True, 447 - ) 448 - if result.returncode == 0: 455 + if service_is_running(): 456 + if platform == "darwin": 449 457 print("State: running (launchd)") 450 458 else: 451 - print("State: stopped") 452 - return 0 459 + print("State: running (systemd)") 460 + elif platform == "darwin": 461 + print("State: stopped") 462 + return 0 453 463 else: 454 464 result = subprocess.run( 455 465 ["systemctl", "--user", "is-active", SYSTEMD_UNIT], ··· 457 467 text=True, 458 468 ) 459 469 state = result.stdout.strip() 460 - if state == "active": 461 - print("State: running (systemd)") 462 - else: 463 - print(f"State: {state}") 464 - return 0 470 + print(f"State: {state}") 471 + return 0 465 472 466 473 print() 467 474 from think.health_cli import health_check ··· 502 509 503 510 def _up(port: int = DEFAULT_SERVICE_PORT) -> int: 504 511 """Install if needed, start if not running, show status.""" 505 - platform = _platform() 506 - 507 - if platform == "darwin": 508 - installed = _plist_path().exists() 509 - else: 510 - installed = _unit_path().exists() 511 - 512 - if not installed: 512 + if not service_is_installed(): 513 513 print("Installing service...") 514 514 rc = _install(port=port) 515 515 if rc != 0: 516 516 return rc 517 517 518 - if platform == "darwin": 519 - uid = os.getuid() 520 - result = subprocess.run( 521 - ["launchctl", "print", f"gui/{uid}/{SERVICE_LABEL}"], 522 - capture_output=True, 523 - text=True, 524 - ) 525 - running = result.returncode == 0 526 - else: 527 - result = subprocess.run( 528 - ["systemctl", "--user", "is-active", SYSTEMD_UNIT], 529 - capture_output=True, 530 - text=True, 531 - ) 532 - running = result.stdout.strip() == "active" 533 - 534 - if not running: 518 + if not service_is_running(): 535 519 print("Starting service...") 536 520 rc = _start() 537 521 if rc != 0:
+2 -2
think/sol_cli.py
··· 263 263 264 264 # Call main - it may call sys.exit() internally 265 265 try: 266 - module.main() 267 - return 0 266 + result = module.main() 267 + return 0 if result is None else int(result) 268 268 except SystemExit as e: 269 269 # Preserve exit code from subcommand 270 270 # SystemExit can have int code, string message, or None
+21
think/utils.py
··· 619 619 _default_config: dict[str, Any] | None = None 620 620 621 621 622 + def journal_is_active(path: str | Path) -> bool: 623 + """Return whether ``path`` points to a claimed journal.""" 624 + try: 625 + journal_path = Path(path) 626 + if not journal_path.is_dir(): 627 + return False 628 + config_path = journal_path / "config" / "journal.json" 629 + with open(config_path, "r", encoding="utf-8") as f: 630 + config = json.load(f) 631 + owner_name = config["identity"]["name"] 632 + return isinstance(owner_name, str) and bool(owner_name.strip()) 633 + except ( 634 + OSError, 635 + json.JSONDecodeError, 636 + KeyError, 637 + TypeError, 638 + AttributeError, 639 + ): 640 + return False 641 + 642 + 622 643 def get_config() -> dict[str, Any]: 623 644 """Return the journal configuration from config/journal.json. 624 645