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 = '