diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 457f7cc..9e0d428 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -39,24 +39,40 @@ jobs: run: | uv run python - <<'PY' from pathlib import Path + import tomllib from spiritus.bundle_config import check_environment, load_bundle_config root = Path.cwd() - config = load_bundle_config( - root / "spiritus.bundle.toml", - project_root=root, - defer_resource_validation=True, - ) - # The verify executable is generated by the release bundle step; - # this PR check intentionally validates the pre-build environment. - missing = [ - item for item in check_environment(config) - if not item.startswith("verify command ") - ] - if missing: - raise SystemExit("Missing Spiritus prerequisites: " + "; ".join(missing)) - print(f"Spiritus bundle configuration is valid for Persona {config.spec.version}") + project = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + for config_path in (root / "spiritus.bundle.toml", root / "spiritus.bundle.dev.toml"): + config = load_bundle_config( + config_path, + project_root=root, + defer_resource_validation=True, + ) + # The verify executable is generated by the release bundle step; + # this PR check intentionally validates the pre-build environment. + missing = [ + item for item in check_environment(config) + if not item.startswith("verify command ") + ] + if missing: + raise SystemExit( + f"Missing Spiritus prerequisites for {config_path.name}: " + + "; ".join(missing) + ) + if config_path.name == "spiritus.bundle.dev.toml": + expected = project["project"]["version"] + "-dev" + if config.spec.version != expected: + raise SystemExit( + f"Development bundle version is {config.spec.version!r}; " + f"expected {expected!r}" + ) + print( + f"Spiritus bundle configuration is valid for " + f"{config.spec.name} {config.spec.version} ({config.spec.app_id})" + ) PY - name: Run Persona tests diff --git a/.gitignore b/.gitignore index a4820b0..71648c2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,9 @@ __pycache__/ *.pyc build/ dist/ +dist-dev/ release/ +release-dev/ packaging/build-assets/ *.spec !packaging/persona.spec diff --git a/main.py b/main.py index 195e76a..5f9a638 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +import json import os import sys from pathlib import Path @@ -23,6 +24,10 @@ from persona_updates import check_for_updates +_APP_ID = os.environ.get("PERSONA_APP_ID", "persona") +_APP_TITLE = os.environ.get("PERSONA_APP_TITLE", "Persona") + + def _bundle_root() -> Path: """Return the read-only resource directory used by PyInstaller.""" if is_bundled(): @@ -30,6 +35,57 @@ def _bundle_root() -> Path: return Path(__file__).resolve().parent +def _sync_bundled_agent_config( + bundle_root: Path | None = None, + app_data_root: Path | None = None, +) -> bool: + """Merge the packaged Persona agents into writable app data. + + Spiritus seed files are intentionally copied only when they do not exist, + so an upgrade can otherwise keep an old ``opencode.json`` forever. Persona + owns the agents shipped in that file; merge those definitions on startup + while preserving user-owned agents and unrelated configuration. + """ + source_path = (bundle_root or _bundle_root()) / "opencode.json" + target_root = app_data_root or project_root(Path(__file__).resolve().parent, _APP_ID) + target_path = target_root / "opencode.json" + if not source_path.is_file(): + return False + + source = json.loads(source_path.read_text(encoding="utf-8-sig")) + if not isinstance(source, dict): + raise ValueError(f"bundled OpenCode config must be an object: {source_path}") + source_agents = source.get("agent") or {} + if not isinstance(source_agents, dict): + raise ValueError(f"bundled OpenCode agents must be an object: {source_path}") + + if target_path.is_file(): + target = json.loads(target_path.read_text(encoding="utf-8-sig")) + if not isinstance(target, dict): + raise ValueError(f"writable OpenCode config must be an object: {target_path}") + else: + target = {} + + target_agents = target.get("agent") + if not isinstance(target_agents, dict): + target_agents = {} + target["agent"] = target_agents + + changed = False + for name, definition in source_agents.items(): + if target_agents.get(name) != definition: + target_agents[name] = definition + changed = True + + if changed or not target_path.is_file(): + target_root.mkdir(parents=True, exist_ok=True) + target_path.write_text( + json.dumps(target, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return changed + + def _verify_bundle() -> None: """Smoke-test resources used by CI without opening a native window.""" if not is_bundled(): @@ -61,15 +117,25 @@ def _verify_bundle() -> None: raise SystemExit( "Spiritus did not configure the bundled Playwright browser path" ) - seeded_config = project_root(Path(__file__).resolve().parent, "persona") / "opencode.json" + seeded_config = project_root(Path(__file__).resolve().parent, _APP_ID) / "opencode.json" + try: + _sync_bundled_agent_config(root, seeded_config.parent) + except (OSError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit(f"could not prepare writable OpenCode config: {exc}") from exc if not seeded_config.is_file(): raise SystemExit(f"missing seeded writable OpenCode config: {seeded_config}") + try: + config = json.loads(seeded_config.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"invalid writable OpenCode config: {seeded_config}: {exc}") from exc + if "profile-pdf" not in (config.get("agent") or {}): + raise SystemExit(f"writable OpenCode config is missing profile-pdf: {seeded_config}") print(f"Persona bundle OK (OpenCode {version})") APP = AppConfig( - app_id="persona", - app_title="Persona", + app_id=_APP_ID, + app_title=_APP_TITLE, app_root=Path(__file__).resolve().parent, ui_dir="ui", # this app ships its own front-end workspace_dirname="workspace", @@ -87,8 +153,11 @@ def _verify_bundle() -> None: if "--check-bundle" in sys.argv: _verify_bundle() elif "--check-updates" in sys.argv: - import json - print(json.dumps(check_for_updates(), sort_keys=True)) else: + if is_bundled(): + try: + _sync_bundled_agent_config() + except (OSError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit(f"could not prepare writable OpenCode config: {exc}") from exc run(APP) diff --git a/main_dev.py b/main_dev.py new file mode 100644 index 0000000..6db7464 --- /dev/null +++ b/main_dev.py @@ -0,0 +1,18 @@ +"""Development Persona entrypoint with isolated application identity.""" +from __future__ import annotations + +import os +import sys + +os.environ.setdefault("PERSONA_APP_ID", "persona-dev") +os.environ.setdefault("PERSONA_APP_TITLE", "Persona Dev") + +from spiritus import run # noqa: E402 +from main import APP, _verify_bundle # noqa: E402 + + +if __name__ == "__main__": + if "--check-bundle" in sys.argv: + _verify_bundle() + else: + run(APP) diff --git a/packaging/Persona-dev.iss b/packaging/Persona-dev.iss new file mode 100644 index 0000000..2bfa0a5 --- /dev/null +++ b/packaging/Persona-dev.iss @@ -0,0 +1,40 @@ +#ifndef AppVersion +#define AppVersion "0.1.6-dev" +#endif + +#define AppName "Persona Dev" +#define AppPublisher "Dekode1859" +#define AppExeName "PersonaDev.exe" + +[Setup] +AppId={{8A6EAF79-0A1E-4F46-A4BB-0F8E0D6D2A44} +AppName={#AppName} +AppVersion={#AppVersion} +AppPublisher={#AppPublisher} +DefaultDirName={localappdata}\Programs\{#AppName} +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +ArchitecturesInstallIn64BitMode=x64compatible +OutputDir=..\release-dev +OutputBaseFilename=Persona-Dev-Setup-{#AppVersion} +Compression=lzma2/fast +SolidCompression=yes +WizardStyle=modern +UninstallDisplayIcon={app}\{#AppExeName} + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:" + +[Files] +Source: "..\dist-dev\PersonaDev\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}" +Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#AppExeName}"; Description: "Launch {#AppName}"; Flags: nowait postinstall skipifsilent diff --git a/packaging/build-dev-macos.sh b/packaging/build-dev-macos.sh new file mode 100644 index 0000000..17aaccd --- /dev/null +++ b/packaging/build-dev-macos.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +VERSION="$(sed -n 's/^version = "\([^"]*\)"$/\1/p' pyproject.toml | head -n 1)" +if [[ -z "$VERSION" ]]; then + echo "Could not read the Persona version from pyproject.toml" >&2 + exit 1 +fi +DEV_VERSION="${VERSION}-dev" +ENGINE="packaging/build-assets/engine/opencode=engine" +uv run python packaging/prepare-assets.py +uv run spiritus bundle --project-root "$ROOT" \ + --entrypoint main_dev.py --name PersonaDev --app-id persona-dev --app-version "$DEV_VERSION" \ + --data "ui=ui" --data "schemas=schemas" --data "opencode.json=." \ + --data "spiritus.bundle.dev.toml=spiritus.bundle.toml" \ + --data "scanner/linkedin_scan.py=scanner" \ + --data "packaging/build-assets/ms-playwright=ms-playwright" \ + --binary "$ENGINE" \ + --collect-package playwright --collect-package pypdf --collect-package webview \ + --runtime-env-path "PLAYWRIGHT_BROWSERS_PATH=ms-playwright" \ + --seed-file "opencode.json=opencode.json" \ + --output-dir dist-dev --work-dir build/spiritus-dev +uv run spiritus bundle-check "$ROOT/dist-dev/PersonaDev.app" --app-id persona-dev +"$ROOT/dist-dev/PersonaDev.app/Contents/MacOS/PersonaDev" --check-bundle + +mkdir -p "$ROOT/release-dev" +rm -f "$ROOT/release-dev/Persona-Dev-${DEV_VERSION}-macos.dmg" +hdiutil create \ + -volname "Persona Dev" \ + -srcfolder "$ROOT/dist-dev/PersonaDev.app" \ + -ov \ + -format UDZO \ + "$ROOT/release-dev/Persona-Dev-${DEV_VERSION}-macos.dmg" diff --git a/packaging/build-dev-windows.ps1 b/packaging/build-dev-windows.ps1 new file mode 100644 index 0000000..3c5afcd --- /dev/null +++ b/packaging/build-dev-windows.ps1 @@ -0,0 +1,43 @@ +$ErrorActionPreference = "Stop" + +$Root = (Resolve-Path (Join-Path $PSScriptRoot "..\")).Path +Push-Location $Root +try { + $Version = (Get-Content (Join-Path $Root "pyproject.toml") | + Select-String '^version\s*=\s*"([^"]+)"$').Matches.Groups[1].Value + if (-not $Version) { throw "Could not read the Persona version from pyproject.toml" } + $DevVersion = "$Version-dev" + $Engine = "packaging/build-assets/engine/opencode.exe=engine" + uv run python packaging/prepare-assets.py + uv run spiritus bundle --project-root $Root ` + --entrypoint main_dev.py --name PersonaDev --app-id persona-dev --app-version $DevVersion ` + --data "ui=ui" --data "schemas=schemas" --data "opencode.json=." ` + --data "spiritus.bundle.dev.toml=spiritus.bundle.toml" ` + --data "scanner/linkedin_scan.py=scanner" ` + --data "packaging/build-assets/ms-playwright=ms-playwright" ` + --binary $Engine ` + --collect-package playwright --collect-package pypdf --collect-package webview ` + --runtime-env-path "PLAYWRIGHT_BROWSERS_PATH=ms-playwright" ` + --seed-file "opencode.json=opencode.json" ` + --output-dir dist-dev --work-dir build/spiritus-dev + if ($LASTEXITCODE -ne 0) { throw "Spiritus Persona Dev bundle workflow failed" } + uv run spiritus bundle-check (Join-Path $Root "dist-dev\PersonaDev") --app-id persona-dev + if ($LASTEXITCODE -ne 0) { throw "Persona Dev bundle verification failed" } + & (Join-Path $Root "dist-dev\PersonaDev\PersonaDev.exe") --check-bundle + if ($LASTEXITCODE -ne 0) { throw "Persona Dev application verification failed" } + + $Iscc = Get-Command iscc -ErrorAction SilentlyContinue + if (-not $Iscc) { + $KnownIscc = @( + "${env:LOCALAPPDATA}\Programs\Inno Setup 6\ISCC.exe", + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "${env:ProgramFiles}\Inno Setup 6\ISCC.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($KnownIscc) { $Iscc = @{ Source = $KnownIscc } } + else { throw "Inno Setup compiler (iscc) is required for the dev installer." } + } + New-Item -ItemType Directory -Force -Path (Join-Path $Root "release-dev") | Out-Null + & $Iscc.Source "/DAppVersion=$DevVersion" (Join-Path $Root "packaging\Persona-dev.iss") +} finally { + Pop-Location +} diff --git a/persona_updates.py b/persona_updates.py index 0931658..5e8f502 100644 --- a/persona_updates.py +++ b/persona_updates.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import os import platform import subprocess import sys @@ -13,6 +14,7 @@ StagedUpdate, SubprocessInstallerHandoff, UpdateCheck, + UpdateArtifact, UpdateClient, UpdateConfig, UpdateError, @@ -22,6 +24,11 @@ _STAGED_UPDATE: StagedUpdate | None = None +_STAGED_VERSION: str | None = None + + +def _app_id() -> str: + return os.environ.get("PERSONA_APP_ID", "persona") def _resource_root() -> Path: @@ -50,7 +57,7 @@ def _config(root: Path, *, session: Any = None) -> UpdateConfig: payload = tomllib.load(handle) return UpdateConfig.from_mapping( payload["updates"], - app_id="persona", + app_id=_app_id(), current_version=_current_version(root), session=session, ) @@ -66,10 +73,86 @@ def _client(root: Path, *, session: Any = None) -> UpdateClient: def _staging_dir(root: Path) -> Path: if is_bundled(): - return app_data_dir("persona") / "updates" + return app_data_dir(_app_id()) / "updates" return root / ".spiritus-update-staging" +def _staged_manifest_path(root: Path) -> Path: + return _staging_dir(root) / "staged.json" + + +def _staged_payload(staged: StagedUpdate, version: str, current_version: str) -> dict[str, object]: + return { + "status": "ready", + "available": False, + "current_version": current_version, + "version": version, + "staged_path": str(staged.path), + "staged_bytes": staged.bytes, + "staged_sha256": staged.sha256, + } + + +def _load_staged_update(root: Path) -> tuple[StagedUpdate, str] | None: + manifest_path = _staged_manifest_path(root) + if not manifest_path.is_file(): + return None + try: + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + artifact_payload = payload["artifact"] + artifact = UpdateArtifact( + filename=str(artifact_payload["filename"]), + url=str(artifact_payload["url"]), + platform=artifact_payload.get("platform"), + architecture=artifact_payload.get("architecture"), + kind=artifact_payload.get("kind"), + sha256=artifact_payload.get("sha256"), + signature_url=artifact_payload.get("signature_url"), + size=artifact_payload.get("size"), + ) + staged_path = Path(str(payload["path"])).resolve() + staging_dir = _staging_dir(root).resolve() + if staged_path.parent != staging_dir or not staged_path.is_file(): + raise ValueError("staged update file is missing") + staged = StagedUpdate( + artifact=artifact, + path=staged_path, + bytes=int(payload["bytes"]), + sha256=str(payload["sha256"]), + ) + version = str(payload["version"]).strip() + if not version: + raise ValueError("staged update version is missing") + return staged, version + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError): + manifest_path.unlink(missing_ok=True) + return None + + +def _remember_staged_update(root: Path, staged: StagedUpdate, version: str) -> None: + manifest_path = _staged_manifest_path(root) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "version": version, + "path": str(staged.path.resolve()), + "bytes": staged.bytes, + "sha256": staged.sha256, + "artifact": { + "filename": staged.artifact.filename, + "url": staged.artifact.url, + "platform": staged.artifact.platform, + "architecture": staged.artifact.architecture, + "kind": staged.artifact.kind, + "sha256": staged.artifact.sha256, + "signature_url": staged.artifact.signature_url, + "size": staged.artifact.size, + }, + } + temporary = manifest_path.with_suffix(".json.tmp") + temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + os.replace(temporary, manifest_path) + + def _result_payload(result: UpdateCheck) -> dict[str, object]: payload: dict[str, object] = { "status": result.status.value, @@ -97,7 +180,13 @@ def _result_payload(result: UpdateCheck) -> dict[str, object]: def check_for_updates(*, session: Any = None, root: Path | None = None) -> dict[str, object]: """Return JSON-safe update status for the Persona bridge/UI.""" + global _STAGED_UPDATE, _STAGED_VERSION + root = root or _resource_root() + existing = _load_staged_update(root) + if existing: + _STAGED_UPDATE, _STAGED_VERSION = existing + return _staged_payload(_STAGED_UPDATE, _STAGED_VERSION, _current_version(root)) return _result_payload(_client(root, session=session).check()) @@ -108,9 +197,13 @@ def stage_update( destination: Path | None = None, ) -> dict[str, object]: """Download and verify the selected installer without launching it.""" - global _STAGED_UPDATE + global _STAGED_UPDATE, _STAGED_VERSION root = root or _resource_root() + existing = _load_staged_update(root) + if existing: + _STAGED_UPDATE, _STAGED_VERSION = existing + return _staged_payload(_STAGED_UPDATE, _STAGED_VERSION, _current_version(root)) client = _client(root, session=session) result = client.check() payload = _result_payload(result) @@ -122,6 +215,17 @@ def stage_update( payload.update({"status": "error", "error": str(exc)}) return payload _STAGED_UPDATE = staged + _STAGED_VERSION = payload.get("version") if isinstance(payload.get("version"), str) else None + if not _STAGED_VERSION: + _STAGED_UPDATE = None + return {"status": "error", "error": "The update did not include a release version."} + try: + _remember_staged_update(root, staged, _STAGED_VERSION) + except OSError as exc: + _STAGED_UPDATE = None + _STAGED_VERSION = None + payload.update({"status": "error", "error": f"Could not save the downloaded update: {exc}"}) + return payload payload.update( { "staged_path": str(staged.path), @@ -134,9 +238,14 @@ def stage_update( def launch_staged_update(path: str | Path) -> dict[str, object]: """Launch the verified installer and close Persona so it can update safely.""" - global _STAGED_UPDATE + global _STAGED_UPDATE, _STAGED_VERSION staged = _STAGED_UPDATE + if staged is None: + loaded = _load_staged_update(_resource_root()) + if loaded: + staged, _STAGED_VERSION = loaded + _STAGED_UPDATE = staged if staged is None: raise UpdateInstallerError("no verified installer is ready to launch") candidate = Path(path).resolve() @@ -149,6 +258,8 @@ def launch_staged_update(path: str | Path) -> dict[str, object]: SubprocessInstallerHandoff().launch(staged) _STAGED_UPDATE = None + _STAGED_VERSION = None + _staged_manifest_path(_resource_root()).unlink(missing_ok=True) try: import webview diff --git a/pyproject.toml b/pyproject.toml index 6628c85..c8aed23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "persona" -version = "0.1.5" +version = "0.1.6" description = "Persona - a Spiritus application for profile, job tracking, and tailored applications" requires-python = ">=3.11" dependencies = [ diff --git a/spiritus.bundle.dev.toml b/spiritus.bundle.dev.toml new file mode 100644 index 0000000..0cec61e --- /dev/null +++ b/spiritus.bundle.dev.toml @@ -0,0 +1,47 @@ +# Development bundle specification. This installs beside production Persona. +format = 1 +platforms = ["windows", "macos"] +entrypoint = "main_dev.py" +name = "PersonaDev" +app_id = "persona-dev" +version = "0.1.6-dev" +console = false +bundle_identifier = "com.dekode.persona.dev" + +datas = [ + "ui=ui", + "schemas=schemas", + "opencode.json=.", + "spiritus.bundle.dev.toml=spiritus.bundle.toml", + "scanner/linkedin_scan.py=scanner", + "packaging/build-assets/ms-playwright=ms-playwright", +] +binaries = [ + "packaging/build-assets/engine/{engine_binary}=engine", +] +collect_packages = ["playwright", "pypdf", "webview"] +hidden_imports = [] + +[runtime_env_paths] +PLAYWRIGHT_BROWSERS_PATH = "ms-playwright" + +[seed_files] +"opencode.json" = "opencode.json" + +[updates] +enabled = true +channel = "stable" +versioning = "semver" + +[updates.source] +type = "github" +repository = "Dekode1859/Persona" + +[updates.assets] +windows_x86_64 = "Persona-Setup-{version}.exe" +macos_x86_64 = "Persona-{version}-macos.dmg" +macos_arm64 = "Persona-{version}-macos.dmg" + +[hooks] +prepare = ["uv", "run", "python", "packaging/prepare-assets.py"] +verify = ["{executable}", "--check-bundle"] diff --git a/spiritus.bundle.toml b/spiritus.bundle.toml index cf130d1..3d3bed7 100644 --- a/spiritus.bundle.toml +++ b/spiritus.bundle.toml @@ -4,7 +4,7 @@ platforms = ["windows", "macos"] entrypoint = "main.py" name = "Persona" app_id = "persona" -version = "0.1.5" +version = "0.1.6" console = false bundle_identifier = "com.dekode.persona" diff --git a/tests/test_persona_updates.py b/tests/test_persona_updates.py new file mode 100644 index 0000000..0748cfb --- /dev/null +++ b/tests/test_persona_updates.py @@ -0,0 +1,89 @@ +"""Persona-owned update lifecycle and restart-persistence tests.""" +from __future__ import annotations + +import hashlib +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import persona_updates +from spiritus import ReleaseCandidate, StagedUpdate, UpdateArtifact, UpdateCheck, UpdateStatus + + +class _FakeClient: + def __init__(self, staged: StagedUpdate): + self.staged = staged + self.artifact = staged.artifact + + def check(self) -> UpdateCheck: + return UpdateCheck( + UpdateStatus.AVAILABLE, + "0.1.6", + candidate=ReleaseCandidate(version="0.2.0", artifacts=(self.artifact,)), + artifact=self.artifact, + ) + + def stage_update(self, result: UpdateCheck, destination: Path) -> StagedUpdate: + destination.mkdir(parents=True, exist_ok=True) + target = destination / self.staged.path.name + target.write_bytes(self.staged.path.read_bytes()) + return StagedUpdate(self.staged.artifact, target, self.staged.bytes, self.staged.sha256) + + +class PersonaUpdateTests(unittest.TestCase): + def setUp(self) -> None: + persona_updates._STAGED_UPDATE = None + persona_updates._STAGED_VERSION = None + + def tearDown(self) -> None: + persona_updates._STAGED_UPDATE = None + persona_updates._STAGED_VERSION = None + + def test_staged_update_is_restored_after_startup(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / "pyproject.toml").write_text('[project]\nversion = "0.1.6"\n', encoding="utf-8") + payload = b"fake verified installer" + source = root / "source.exe" + source.write_bytes(payload) + digest = hashlib.sha256(payload).hexdigest() + artifact = UpdateArtifact( + filename="Persona-Setup-0.2.0.exe", + url="https://example.test/Persona-Setup-0.2.0.exe", + sha256=digest, + size=len(payload), + ) + client = _FakeClient(StagedUpdate(artifact, source, len(payload), digest)) + + with patch.object(persona_updates, "_client", return_value=client): + result = persona_updates.stage_update(root=root) + self.assertEqual(result["status"], "available") + self.assertTrue((root / ".spiritus-update-staging" / "staged.json").is_file()) + + persona_updates._STAGED_UPDATE = None + persona_updates._STAGED_VERSION = None + with patch.object(persona_updates, "_client", side_effect=AssertionError("network called")): + restored = persona_updates.check_for_updates(root=root) + self.assertEqual(restored["status"], "ready") + self.assertEqual(restored["version"], "0.2.0") + + def test_invalid_staged_manifest_is_discarded(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / "pyproject.toml").write_text('[project]\nversion = "0.1.6"\n', encoding="utf-8") + staging = root / ".spiritus-update-staging" + staging.mkdir() + (staging / "staged.json").write_text(json.dumps({"path": "missing.exe"}), encoding="utf-8") + + with patch.object(persona_updates, "_client") as client: + client.return_value.check.return_value = UpdateCheck(UpdateStatus.CURRENT, "0.1.6") + result = persona_updates.check_for_updates(root=root) + + self.assertEqual(result["status"], "current") + self.assertFalse((staging / "staged.json").exists()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_profile_import_runs.py b/tests/test_profile_import_runs.py index 0ae7873..0461e93 100644 --- a/tests/test_profile_import_runs.py +++ b/tests/test_profile_import_runs.py @@ -10,6 +10,7 @@ from unittest.mock import patch from app_bridge import PersonaBridge +from main import _sync_bundled_agent_config from spiritus.config import AppConfig, WorkspaceFolder @@ -110,6 +111,63 @@ def test_profile_schema_is_declared_as_a_bundle_resource(self) -> None: spec = tomllib.loads(Path("spiritus.bundle.toml").read_text(encoding="utf-8")) self.assertIn("schemas=schemas", spec["datas"]) + def test_profile_pdf_agent_is_declared_in_the_packaged_config(self) -> None: + config = json.loads(Path("opencode.json").read_text(encoding="utf-8")) + self.assertIn("profile-pdf", config["agent"]) + spec = tomllib.loads(Path("spiritus.bundle.toml").read_text(encoding="utf-8")) + self.assertIn("opencode.json=.", spec["datas"]) + self.assertEqual(spec["seed_files"]["opencode.json"], "opencode.json") + + def test_development_bundle_has_a_separate_application_identity(self) -> None: + spec = tomllib.loads(Path("spiritus.bundle.dev.toml").read_text(encoding="utf-8")) + self.assertEqual(spec["entrypoint"], "main_dev.py") + self.assertEqual(spec["name"], "PersonaDev") + self.assertEqual(spec["app_id"], "persona-dev") + self.assertEqual(spec["bundle_identifier"], "com.dekode.persona.dev") + project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) + self.assertEqual(spec["version"], project["project"]["version"] + "-dev") + self.assertIn("spiritus.bundle.dev.toml=spiritus.bundle.toml", spec["datas"]) + + def test_bundled_agents_migrate_into_existing_app_data(self) -> None: + bundle = self.root / "bundle" + app_data = self.root / "app-data" + bundle.mkdir() + app_data.mkdir() + (bundle / "opencode.json").write_text(json.dumps({ + "agent": { + "profile": {"prompt": "new prompt"}, + "profile-pdf": {"mode": "primary", "prompt": "verbatim"}, + }, + "model": "keep-the-user-setting", + }), encoding="utf-8") + (app_data / "opencode.json").write_text(json.dumps({ + "agent": { + "profile": {"prompt": "old prompt"}, + "custom": {"prompt": "user agent"}, + }, + "model": "user-selected-model", + }), encoding="utf-8") + + self.assertTrue(_sync_bundled_agent_config(bundle, app_data)) + migrated = json.loads((app_data / "opencode.json").read_text(encoding="utf-8")) + self.assertEqual(migrated["agent"]["profile"], {"prompt": "new prompt"}) + self.assertEqual(migrated["agent"]["profile-pdf"]["mode"], "primary") + self.assertEqual(migrated["agent"]["custom"], {"prompt": "user agent"}) + self.assertEqual(migrated["model"], "user-selected-model") + self.assertFalse(_sync_bundled_agent_config(bundle, app_data)) + + def test_bundled_agents_are_seeded_when_app_data_is_new(self) -> None: + bundle = self.root / "bundle" + app_data = self.root / "new-app-data" + bundle.mkdir() + (bundle / "opencode.json").write_text(json.dumps({ + "agent": {"profile-pdf": {"mode": "primary"}}, + }), encoding="utf-8") + + self.assertTrue(_sync_bundled_agent_config(bundle, app_data)) + migrated = json.loads((app_data / "opencode.json").read_text(encoding="utf-8")) + self.assertIn("profile-pdf", migrated["agent"]) + def test_schema_failure_is_a_durable_profile_diagnostic(self) -> None: near_miss = _empty_profile() near_miss["skill_buckets"] = [{"name": "Tools", "skills": ["Python"]}] diff --git a/ui/app.js b/ui/app.js index b0fa22d..9e9d5d4 100644 --- a/ui/app.js +++ b/ui/app.js @@ -5446,14 +5446,67 @@ const updateFlow = { state: 'idle', stagedPath: null, version: null, + currentVersion: null, + surface: 'settings', }; +function hideUpdateToast() { + document.getElementById('update-toast')?.classList.add('hidden'); +} + +function renderUpdateToast(state, details = {}) { + const toast = document.getElementById('update-toast'); + if (!toast) return; + const version = details.version || updateFlow.version || 'latest'; + const current = details.current_version || updateFlow.currentVersion || 'current version'; + const title = { + available: 'Persona update available', + downloading: 'Updating Persona', + ready: 'Persona update ready', + launching: 'Starting Persona update', + download_error: 'Persona update could not download', + launch_error: 'Persona update could not start', + }[state]; + if (!title) { + hideUpdateToast(); + return; + } + let icon = 'download'; + let copy = `Version ${version} is available. You are running ${current}.`; + let actions = 'Later'; + if (state === 'downloading') { + icon = 'refresh-cw'; + copy = `Downloading and verifying Persona ${version}…`; + actions = ''; + } else if (state === 'ready') { + icon = 'circle-check'; + copy = `Persona ${version} is downloaded and verified. Apply it now, or leave it for the next time you open Persona.`; + actions = 'LaterApply update now'; + } else if (state === 'launching') { + icon = 'refresh-cw'; + copy = 'Starting the installer. Persona will close now.'; + actions = ''; + } else if (state === 'download_error' || state === 'launch_error') { + icon = 'triangle-alert'; + copy = details.message || 'Try again from Settings → Updates.'; + actions = 'Dismiss'; + } else { + actions = 'Update laterUpdate now'; + } + const iconMarkup = ['downloading', 'launching'].includes(state) + ? '' + : ``; + toast.innerHTML = `
${iconMarkup}${title}
${escHtml(copy)}
${actions}
`; + toast.classList.remove('hidden'); +} + function renderUpdateFlow(state, details = {}) { const button = document.getElementById('btn-update-action'); const status = document.getElementById('updates-status'); if (!button || !status) return; updateFlow.state = state; if (details.version) updateFlow.version = details.version; + if (details.current_version) updateFlow.currentVersion = details.current_version; const version = details.version || updateFlow.version || 'latest'; const busy = ['checking', 'downloading', 'launching'].includes(state); button.disabled = busy; @@ -5496,13 +5549,18 @@ function renderUpdateFlow(state, details = {}) { ['checking', 'downloading', 'launching'].includes(state) ? 'status-info' : state === 'idle' ? '' : 'status-err' }`; + if (updateFlow.surface === 'startup') renderUpdateToast(state, details); } function applyUpdateCheckResult(result) { - updateFlow.stagedPath = null; + if (result.status !== 'ready') updateFlow.stagedPath = null; updateFlow.version = result.version || result.current_version || null; + updateFlow.currentVersion = result.current_version || updateFlow.currentVersion; if (result.status === 'available') { - renderUpdateFlow('available', { version: result.version }); + renderUpdateFlow('available', { version: result.version, current_version: result.current_version }); + } else if (result.status === 'ready') { + updateFlow.stagedPath = result.staged_path || null; + renderUpdateFlow('ready', { version: result.version, current_version: result.current_version }); } else if (result.status === 'current') { renderUpdateFlow('current', { version: result.version, current_version: result.current_version }); } else if (result.status === 'no_compatible_asset') { @@ -5512,7 +5570,8 @@ function applyUpdateCheckResult(result) { } } -async function checkForUpdates() { +async function checkForUpdates({ startup = false } = {}) { + updateFlow.surface = startup ? 'startup' : 'settings'; renderUpdateFlow('checking'); try { applyUpdateCheckResult(await bridge.updatesCheck()); @@ -5525,6 +5584,11 @@ async function stagePersonaUpdate() { renderUpdateFlow('downloading'); try { const result = await bridge.updatesStage(); + if (result.status === 'ready') { + updateFlow.stagedPath = result.staged_path || null; + renderUpdateFlow('ready', { version: result.version, current_version: result.current_version }); + return; + } if (result.status !== 'available') { if (result.status === 'error') { renderUpdateFlow('download_error', { message: result.error || 'The update could not be downloaded. Try again.' }); @@ -5537,7 +5601,7 @@ async function stagePersonaUpdate() { throw new Error('The installer was not returned by the update service.'); } updateFlow.stagedPath = result.staged_path; - renderUpdateFlow('ready', { version: result.version }); + renderUpdateFlow('ready', { version: result.version, current_version: result.current_version }); } catch (e) { renderUpdateFlow('download_error', { message: `The update could not be downloaded. ${e.message}` }); } @@ -6397,6 +6461,11 @@ function wire() { () => document.getElementById('analysis-history-dialog').hide()); document.getElementById('btn-save-key').addEventListener('click', saveKey); document.getElementById('btn-update-action').addEventListener('click', runUpdateAction); + document.getElementById('update-toast').addEventListener('click', e => { + const action = e.target.closest('[data-update-toast]')?.dataset.updateToast; + if (action === 'later') hideUpdateToast(); + if (action === 'now') runUpdateAction(); + }); document.getElementById('btn-set-model').addEventListener('click', setModel); document.getElementById('connected-list').addEventListener('click', e => { const btn = e.target.closest('.provider-tag-remove'); @@ -6520,6 +6589,7 @@ async function init() { const [hasProfile] = await Promise.all([loadProfile(), loadBrowserProfileStatus()]); if (hasProfile) renderProfileSections(); showProfileSubview('main'); + checkForUpdates({ startup: true }); } init(); diff --git a/ui/index.html b/ui/index.html index 1a1c5ae..d39cb4c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -427,6 +427,7 @@

Paste to Analyze +