Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 30 additions & 14 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ __pycache__/
*.pyc
build/
dist/
dist-dev/
release/
release-dev/
packaging/build-assets/
*.spec
!packaging/persona.spec
Expand Down
79 changes: 74 additions & 5 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""
from __future__ import annotations

import json
import os
import sys
from pathlib import Path
Expand All @@ -23,13 +24,68 @@
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():
return Path(getattr(sys, "_MEIPASS"))
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():
Expand Down Expand Up @@ -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",
Expand All @@ -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)
18 changes: 18 additions & 0 deletions main_dev.py
Original file line number Diff line number Diff line change
@@ -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)
40 changes: 40 additions & 0 deletions packaging/Persona-dev.iss
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions packaging/build-dev-macos.sh
Original file line number Diff line number Diff line change
@@ -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"
43 changes: 43 additions & 0 deletions packaging/build-dev-windows.ps1
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading