# 0.2 — Phase 1: .app skeleton + embedded Python (Implementation Plan)

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Produce `dist/AccountPilot.app` — a notarized, drag-to-Applications macOS app that opens to a placeholder window showing "AccountPilot 0.2.0 — Python 3.13.x — accountpilot 0.1.4". The bundled CLI at `Contents/Resources/bin/accountpilot` works when invoked from a terminal. The signed FDA helper lives at `Contents/Helpers/accountpilot-fda-helper`. No real UI yet — that's Phase 2.

**Architecture:** SwiftUI app shell built with XcodeGen-generated `.xcodeproj` + `xcodebuild`. Embeds `python-build-standalone` (relocatable Python distribution from astral-sh) at `Contents/Frameworks/Python.framework`. The `accountpilot` Python package + transitive deps install via `pip install --target=` into `Contents/Resources/python/site-packages/` — **no venv**. Avoiding venv sidesteps the absolute-shebang relocation problem (venv scripts bake build-time absolute paths into their shebang lines, breaking when the .app moves to `/Applications/`). A 3-line shell shim at `Contents/Resources/bin/accountpilot` sets `PYTHONPATH` and invokes the bundled Python with `-m accountpilot.cli`.

**Tech Stack:** Swift 5.9 / SwiftUI · XcodeGen · `xcodebuild` · `python-build-standalone` (Python 3.13.1) · existing Swift FDA helper (0.1.1) · `codesign --options runtime` · `xcrun notarytool` · `xcrun stapler`

---

## Prerequisites

Before starting Task 1, ensure:

- [ ] `xcode-select -p` returns a path (Xcode CLT installed)
- [ ] `swift --version` reports 5.9+
- [ ] `brew install xcodegen create-dmg` (XcodeGen for project generation; create-dmg used in Phase 5 but install both now)
- [ ] Notarization keychain profile `accountpilot-notary` exists. Verify: `xcrun notarytool history --keychain-profile accountpilot-notary` returns history.
- [ ] Developer ID Application cert installed: `security find-identity -v -p codesigning | grep "Developer ID Application: FAZLA"` returns a line with team `P2R7PD8VGY`.
- [ ] Repo is at `~/Code/core/accountpilot`. All paths in this plan are absolute under that root unless otherwise noted.
- [ ] You are on a branch dedicated to Phase 1: `git checkout -b 0.2-p1-app-skeleton`

## File structure (created by this phase)

```
app/
├── .gitignore                                   # build artifacts, derived data
├── project.yml                                  # XcodeGen spec
├── AccountPilot/
│   ├── AccountPilotApp.swift                    # @main SwiftUI entry
│   ├── ContentView.swift                        # placeholder window: shows version triple
│   ├── PythonRuntime.swift                      # Process wrapper for embedded Python
│   ├── Info.plist                               # CFBundleIdentifier=com.accountpilot.app
│   ├── AccountPilot.entitlements                # hardened-runtime entitlements
│   └── Assets.xcassets/                         # placeholder icon (full set in Phase 4)
│       └── AppIcon.appiconset/
│           └── Contents.json                    # icon manifest (uses system globe glyph for now)
├── AccountPilotTests/
│   └── PythonRuntimeTests.swift                 # exercises bundled-python invocation
└── scripts/
    ├── bundle-python.sh                         # downloads python-build-standalone, installs accountpilot
    ├── bundle-helper.sh                         # copies fda-helper into Contents/Helpers/
    ├── build-app.sh                             # orchestrator: xcodebuild → bundle-python → bundle-helper → sign → notarize
    └── verify-app.sh                            # post-build smoke test, run by build-app.sh and CI
```

**Files modified outside `app/`:**

- `pyproject.toml` — bump version `0.1.4` → `0.2.0`
- `CHANGELOG.md` — open `[0.2.0]` section
- `.gitignore` (root) — add `app/build/` and `dist/AccountPilot.app/`

---

## Task 1: Scaffold `app/` directory + .gitignore

**Files:**
- Create: `app/.gitignore`
- Create: `app/AccountPilot/` (empty)
- Create: `app/AccountPilotTests/` (empty)
- Create: `app/scripts/` (empty)
- Modify: `.gitignore` (root)

- [ ] **Step 1: Create directory tree**

```bash
mkdir -p app/AccountPilot/Assets.xcassets/AppIcon.appiconset
mkdir -p app/AccountPilotTests
mkdir -p app/scripts
```

- [ ] **Step 2: Write `app/.gitignore`**

```
# Xcode build artifacts
build/
DerivedData/
*.xcodeproj/

# generated bundle
*.app/

# python-build-standalone download cache
.python-cache/
```

- [ ] **Step 3: Append to root `.gitignore`**

```
# 0.2 .app build artifacts
dist/AccountPilot.app/
dist/AccountPilot-*.dmg
app/build/
```

- [ ] **Step 4: Verify dirs exist + commit**

```bash
ls -la app/
git add app/.gitignore .gitignore
git commit -m "chore(app): scaffold app/ directory tree"
```

Expected `ls` output: `AccountPilot AccountPilotTests scripts .gitignore`

---

## Task 2: Write XcodeGen spec + generate `.xcodeproj`

**Files:**
- Create: `app/project.yml`

- [ ] **Step 1: Write `app/project.yml`**

```yaml
name: AccountPilot
options:
  bundleIdPrefix: com.accountpilot
  deploymentTarget:
    macOS: "13.0"
  developmentLanguage: en

settings:
  base:
    SWIFT_VERSION: "5.9"
    DEVELOPMENT_TEAM: P2R7PD8VGY
    CODE_SIGN_STYLE: Manual
    CODE_SIGN_IDENTITY: "Developer ID Application: FAZLA GIDA ANONIM SIRKETI (P2R7PD8VGY)"
    OTHER_CODE_SIGN_FLAGS: "--timestamp"
    ENABLE_HARDENED_RUNTIME: YES
    MARKETING_VERSION: "0.2.0"
    CURRENT_PROJECT_VERSION: "1"

targets:
  AccountPilot:
    type: application
    platform: macOS
    sources:
      - path: AccountPilot
    resources:
      - path: AccountPilot/Assets.xcassets
    info:
      path: AccountPilot/Info.plist
      properties:
        CFBundleIdentifier: com.accountpilot.app
        CFBundleName: AccountPilot
        CFBundleDisplayName: AccountPilot
        CFBundleShortVersionString: "0.2.0"
        CFBundleVersion: "1"
        LSMinimumSystemVersion: "13.0"
        LSUIElement: false      # full app for now; Phase 3 may flip to status-bar-only
        NSHumanReadableCopyright: "Copyright (C) 2026 Hasan Arda Eren. AGPL-3.0-or-later."
    entitlements:
      path: AccountPilot/AccountPilot.entitlements
      properties:
        com.apple.security.app-sandbox: false       # FDA helper requires non-sandboxed parent
        com.apple.security.cs.allow-jit: false
        com.apple.security.cs.allow-unsigned-executable-memory: false
        com.apple.security.cs.disable-library-validation: false

  AccountPilotTests:
    type: bundle.unit-test
    platform: macOS
    sources:
      - path: AccountPilotTests
    dependencies:
      - target: AccountPilot
```

- [ ] **Step 2: Generate the .xcodeproj**

```bash
cd app && xcodegen generate
```

Expected output: `Created project at /Users/.../app/AccountPilot.xcodeproj`

- [ ] **Step 3: Verify project loads**

```bash
xcodebuild -project app/AccountPilot.xcodeproj -list
```

Expected output should include `Targets: AccountPilot, AccountPilotTests`.

- [ ] **Step 4: Commit**

```bash
git add app/project.yml
# .xcodeproj is in app/.gitignore — regenerated from project.yml
git commit -m "build(app): XcodeGen project spec"
```

---

## Task 3: Write minimal SwiftUI app + ContentView placeholder

**Files:**
- Create: `app/AccountPilot/AccountPilotApp.swift`
- Create: `app/AccountPilot/ContentView.swift`
- Create: `app/AccountPilot/Assets.xcassets/Contents.json`
- Create: `app/AccountPilot/Assets.xcassets/AppIcon.appiconset/Contents.json`

- [ ] **Step 1: Write `AccountPilotApp.swift`**

```swift
import SwiftUI

@main
struct AccountPilotApp: App {
    var body: some Scene {
        WindowGroup("AccountPilot") {
            ContentView()
                .frame(minWidth: 480, minHeight: 320)
        }
        .windowResizability(.contentMinSize)
    }
}
```

- [ ] **Step 2: Write `ContentView.swift` (placeholder — real UI is Phase 2)**

```swift
import SwiftUI

struct ContentView: View {
    @State private var pythonVersion: String = "loading…"
    @State private var accountpilotVersion: String = "loading…"
    @State private var loadError: String?

    var body: some View {
        VStack(spacing: 12) {
            Image(systemName: "tray.full")
                .font(.system(size: 40))
                .foregroundStyle(.tint)
            Text("AccountPilot 0.2.0")
                .font(.title2.weight(.semibold))
            Text("Python: \(pythonVersion)")
                .font(.system(.body, design: .monospaced))
            Text("accountpilot: \(accountpilotVersion)")
                .font(.system(.body, design: .monospaced))
            if let err = loadError {
                Text(err)
                    .font(.caption)
                    .foregroundStyle(.red)
                    .textSelection(.enabled)
            }
        }
        .padding(40)
        .task { await loadVersions() }
    }

    private func loadVersions() async {
        do {
            pythonVersion = try await PythonRuntime.shared.run(
                ["-c", "import platform; print(platform.python_version())"]
            ).trimmingCharacters(in: .whitespacesAndNewlines)
            accountpilotVersion = try await PythonRuntime.shared.run(
                ["-m", "accountpilot.cli", "--version"]
            ).trimmingCharacters(in: .whitespacesAndNewlines)
        } catch {
            loadError = "\(error)"
        }
    }
}
```

- [ ] **Step 3: Write minimal Assets.xcassets manifests**

`app/AccountPilot/Assets.xcassets/Contents.json`:
```json
{
  "info" : {
    "author" : "xcode",
    "version" : 1
  }
}
```

`app/AccountPilot/Assets.xcassets/AppIcon.appiconset/Contents.json` — empty appicon set (system uses default until Phase 4 ships icons):
```json
{
  "images" : [],
  "info" : {
    "author" : "xcode",
    "version" : 1
  }
}
```

- [ ] **Step 4: Build the app shell** (will fail to load Python until Task 5 lands — that's fine, just verifies Swift compiles)

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build
```

Expected: `** BUILD SUCCEEDED **` at the end. Compiler will warn about missing `PythonRuntime` — fine, we add it in Task 5. Actually, this will FAIL to compile because `PythonRuntime.shared` doesn't exist yet. Skip the build step; it'll first succeed in Task 5.

- [ ] **Step 5: Commit**

```bash
git add app/AccountPilot/
git commit -m "feat(app): SwiftUI placeholder ContentView"
```

---

## Task 4: Bundle Python — `bundle-python.sh`

**Files:**
- Create: `app/scripts/bundle-python.sh`

This script downloads python-build-standalone (a relocatable Python build maintained by astral-sh, used by uv and ruff), extracts it into the .app bundle's Frameworks dir, then installs the `accountpilot` package + deps into `Contents/Resources/python/site-packages/` via `pip install --target=`. **No venv** — venv scripts bake build-time absolute paths into shebangs that break after relocation.

- [ ] **Step 1: Write `bundle-python.sh`**

```bash
#!/usr/bin/env bash
# Embed a relocatable Python.framework into the .app bundle and install
# the accountpilot package + dependencies into a flat site-packages
# directory inside the bundle.
#
# Inputs (from build-app.sh): $APP_BUNDLE — absolute path to AccountPilot.app
# Output:
#   $APP_BUNDLE/Contents/Frameworks/Python.framework/   (interpreter + stdlib)
#   $APP_BUNDLE/Contents/Resources/python/site-packages/ (accountpilot + deps)
#
# python-build-standalone is relocatable: its dyld load commands and
# stdlib paths are already rewritten to be bundle-relative.
# https://github.com/astral-sh/python-build-standalone
#
# We deliberately DO NOT use venv. venv writes the build-time absolute
# path of the parent interpreter into every script's shebang, which
# breaks when the .app is dragged to /Applications/. Using
# `pip install --target=` produces a flat site-packages with no
# relocation-sensitive scripts.

set -euo pipefail

if [[ -z "${APP_BUNDLE:-}" ]]; then
    echo "error: APP_BUNDLE env var required (path to .app)" >&2
    exit 64
fi

PYTHON_VERSION="3.13.1"
PYTHON_BUILD_TAG="20250115"   # bump as upstream releases new builds
ARCH="$(uname -m)"            # arm64 or x86_64

case "$ARCH" in
    arm64)   PBS_ARCH="aarch64-apple-darwin" ;;
    x86_64)  PBS_ARCH="x86_64-apple-darwin" ;;
    *) echo "error: unsupported arch $ARCH" >&2; exit 65 ;;
esac

CACHE_DIR="$(pwd)/.python-cache"
mkdir -p "$CACHE_DIR"
TARBALL="cpython-${PYTHON_VERSION}+${PYTHON_BUILD_TAG}-${PBS_ARCH}-install_only.tar.gz"
TARBALL_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PYTHON_BUILD_TAG}/${TARBALL}"

if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
    echo "==> downloading python-build-standalone $PYTHON_VERSION ($PBS_ARCH)"
    curl -fL --output "$CACHE_DIR/$TARBALL" "$TARBALL_URL"
fi

FW_DIR="$APP_BUNDLE/Contents/Frameworks"
mkdir -p "$FW_DIR"
rm -rf "$FW_DIR/Python.framework" "$FW_DIR/python"
tar -xzf "$CACHE_DIR/$TARBALL" -C "$FW_DIR"
mv "$FW_DIR/python" "$FW_DIR/Python.framework"

PYTHON_BIN="$FW_DIR/Python.framework/bin/python3"
test -x "$PYTHON_BIN" || { echo "error: $PYTHON_BIN not executable" >&2; exit 70; }

SITE_PACKAGES="$APP_BUNDLE/Contents/Resources/python/site-packages"
mkdir -p "$SITE_PACKAGES"

echo "==> installing accountpilot + deps into $SITE_PACKAGES via pip --target"
"$PYTHON_BIN" -m pip install --upgrade pip --quiet
"$PYTHON_BIN" -m pip install --quiet --target="$SITE_PACKAGES" "$(pwd)"

echo "==> verifying embedded accountpilot loads against bundled python"
PYTHONPATH="$SITE_PACKAGES" "$PYTHON_BIN" -c \
    "import accountpilot; print('accountpilot OK from', accountpilot.__file__)"
PYTHONPATH="$SITE_PACKAGES" "$PYTHON_BIN" -m accountpilot.cli --version

echo "==> bundle-python: done"
```

- [ ] **Step 2: Make executable**

```bash
chmod +x app/scripts/bundle-python.sh
```

- [ ] **Step 3: Test against an empty fixture .app**

```bash
# Smoke-test the script without going through xcodebuild
mkdir -p /tmp/fake-bundle/Contents
APP_BUNDLE=/tmp/fake-bundle ./app/scripts/bundle-python.sh
ls /tmp/fake-bundle/Contents/Frameworks/Python.framework/bin/python3
ls /tmp/fake-bundle/Contents/Resources/python/site-packages/accountpilot/__init__.py
PYTHONPATH=/tmp/fake-bundle/Contents/Resources/python/site-packages \
    /tmp/fake-bundle/Contents/Frameworks/Python.framework/bin/python3 \
    -m accountpilot.cli --version
rm -rf /tmp/fake-bundle
```

Expected last line: `accountpilot, version 0.1.4` (becomes `0.2.0` after Task 9 bumps the version).

- [ ] **Step 4: Commit**

```bash
git add app/scripts/bundle-python.sh
git commit -m "feat(app): bundle-python.sh — embed relocatable Python + install accountpilot"
```

---

## Task 5: Write `PythonRuntime.swift` + first Swift unit test

**Files:**
- Create: `app/AccountPilot/PythonRuntime.swift`
- Create: `app/AccountPilotTests/PythonRuntimeTests.swift`

- [ ] **Step 1: Write `PythonRuntime.swift`**

```swift
import Foundation

/// Process wrapper around the bundled Python interpreter at
/// Contents/Resources/python/bin/python. All Python invocations from
/// Swift go through this — keeps the boundary one-way (Swift → Python
/// CLI) and matches what agents see when calling the same CLI.
struct PythonRuntime {
    static let shared = PythonRuntime()

    enum RuntimeError: Error, CustomStringConvertible {
        case interpreterMissing(URL)
        case nonZeroExit(Int32, stderr: String)

        var description: String {
            switch self {
            case .interpreterMissing(let url):
                return "embedded Python interpreter not found at \(url.path)"
            case .nonZeroExit(let code, let stderr):
                return "python exited \(code): \(stderr.trimmingCharacters(in: .whitespacesAndNewlines))"
            }
        }
    }

    /// Invoke the bundled Python with `args`. Returns stdout. Throws
    /// `RuntimeError.nonZeroExit` if exit code != 0.
    @discardableResult
    func run(_ args: [String]) async throws -> String {
        let interpreter = bundledInterpreter()
        guard FileManager.default.isExecutableFile(atPath: interpreter.path) else {
            throw RuntimeError.interpreterMissing(interpreter)
        }
        let process = Process()
        process.executableURL = interpreter
        process.arguments = args
        // Bundled accountpilot package lives in a flat site-packages
        // directory (no venv — see bundle-python.sh comment for why).
        // Tell Python where to find it via PYTHONPATH.
        var env = ProcessInfo.processInfo.environment
        env["PYTHONPATH"] = Bundle.main.bundleURL
            .appendingPathComponent("Contents/Resources/python/site-packages")
            .path
        process.environment = env

        let stdoutPipe = Pipe()
        let stderrPipe = Pipe()
        process.standardOutput = stdoutPipe
        process.standardError = stderrPipe

        try process.run()
        // Consume pipes off the main thread to avoid deadlock on large output.
        async let stdoutData = readAll(stdoutPipe.fileHandleForReading)
        async let stderrData = readAll(stderrPipe.fileHandleForReading)
        process.waitUntilExit()

        let stdout = String(data: await stdoutData, encoding: .utf8) ?? ""
        let stderr = String(data: await stderrData, encoding: .utf8) ?? ""

        if process.terminationStatus != 0 {
            throw RuntimeError.nonZeroExit(process.terminationStatus, stderr: stderr)
        }
        return stdout
    }

    private func bundledInterpreter() -> URL {
        // Bundle.main.bundlePath = /Applications/AccountPilot.app
        Bundle.main.bundleURL
            .appendingPathComponent("Contents/Frameworks/Python.framework/bin/python3")
    }

    private func readAll(_ handle: FileHandle) async -> Data {
        await withCheckedContinuation { cont in
            DispatchQueue.global(qos: .utility).async {
                cont.resume(returning: handle.readDataToEndOfFile())
            }
        }
    }
}
```

- [ ] **Step 2: Write the failing unit test**

```swift
import XCTest
@testable import AccountPilot

final class PythonRuntimeTests: XCTestCase {
    /// Smoke-test: bundled interpreter prints something to stdout.
    /// This test runs against the *built* .app bundle that XCTest links into,
    /// so Bundle.main is the test bundle, NOT the .app — we resolve the
    /// .app's Python by walking up to the host app bundle.
    func test_run_returnsStdoutFromBundledPython() async throws {
        let stdout = try await PythonRuntime.shared.run(
            ["-c", "print('hello from embedded python')"]
        )
        XCTAssertTrue(stdout.contains("hello from embedded python"),
                      "got stdout=\(stdout)")
    }

    func test_run_throwsOnNonZeroExit() async throws {
        do {
            _ = try await PythonRuntime.shared.run(["-c", "import sys; sys.exit(7)"])
            XCTFail("expected RuntimeError.nonZeroExit")
        } catch PythonRuntime.RuntimeError.nonZeroExit(let code, _) {
            XCTAssertEqual(code, 7)
        }
    }
}
```

- [ ] **Step 3: Regenerate Xcode project + build**

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build
```

Expected: `** BUILD SUCCEEDED **`. The .app shell now compiles. Tests will fail until we bundle Python in Task 7.

- [ ] **Step 4: Commit**

```bash
git add app/AccountPilot/PythonRuntime.swift app/AccountPilotTests/PythonRuntimeTests.swift
git commit -m "feat(app): PythonRuntime — Process wrapper for embedded Python"
```

---

## Task 6: Bundle the FDA helper — `bundle-helper.sh`

**Files:**
- Create: `app/scripts/bundle-helper.sh`

The helper from `helpers/fda-helper/.build/release/` (built by `scripts/release-helper.sh` from earlier 0.1.x work) gets copied into `Contents/Helpers/` of the .app. The bundle's overall codesign step (Task 8) re-signs everything together.

- [ ] **Step 1: Write `bundle-helper.sh`**

```bash
#!/usr/bin/env bash
# Copy the signed FDA helper into the .app bundle's Contents/Helpers/.
#
# Inputs:
#   $APP_BUNDLE — absolute path to AccountPilot.app
#   Optionally: $HELPER_BIN — explicit path to helper binary (defaults
#               to repo's dist/accountpilot-fda-helper from release-helper.sh)
#
# Output: $APP_BUNDLE/Contents/Helpers/accountpilot-fda-helper

set -euo pipefail

if [[ -z "${APP_BUNDLE:-}" ]]; then
    echo "error: APP_BUNDLE env var required" >&2
    exit 64
fi

HELPER_BIN="${HELPER_BIN:-$(pwd)/dist/accountpilot-fda-helper}"
if [[ ! -x "$HELPER_BIN" ]]; then
    echo "error: helper binary not at $HELPER_BIN" >&2
    echo "       run scripts/release-helper.sh first to build + sign + notarize the helper" >&2
    exit 66
fi

HELPERS_DIR="$APP_BUNDLE/Contents/Helpers"
mkdir -p "$HELPERS_DIR"
cp -f "$HELPER_BIN" "$HELPERS_DIR/accountpilot-fda-helper"
chmod +x "$HELPERS_DIR/accountpilot-fda-helper"

echo "==> bundle-helper: copied $HELPER_BIN -> $HELPERS_DIR/"

# Sanity-check the helper's identity hasn't drifted
codesign -dv "$HELPERS_DIR/accountpilot-fda-helper" 2>&1 | grep -E "Identifier|TeamIdentifier"
```

- [ ] **Step 2: Make executable + smoke-test**

```bash
chmod +x app/scripts/bundle-helper.sh
mkdir -p /tmp/fake-bundle2
APP_BUNDLE=/tmp/fake-bundle2 ./app/scripts/bundle-helper.sh
ls -la /tmp/fake-bundle2/Contents/Helpers/
/tmp/fake-bundle2/Contents/Helpers/accountpilot-fda-helper --version
rm -rf /tmp/fake-bundle2
```

Expected last line: `accountpilot-fda-helper 0.1.1`.

- [ ] **Step 3: Commit**

```bash
git add app/scripts/bundle-helper.sh
git commit -m "feat(app): bundle-helper.sh — copy signed FDA helper into Contents/Helpers/"
```

---

## Task 7: Write CLI shim at `Contents/Resources/bin/accountpilot`

The bundled CLI shim is what agents (and the `/usr/local/bin/` symlink offered in Phase 6) ultimately invoke. It's a 5-line bash script that resolves the bundle root, sets `PYTHONPATH` to the bundled `site-packages`, and exec's the bundled Python with `-m accountpilot.cli`. **No reliance on venv-baked paths** — the only absolute path involved is the runtime location of the .app, which the shim derives via `$BASH_SOURCE`.

**Files:**
- Add a new section to `app/scripts/bundle-python.sh`.

- [ ] **Step 1: Edit `bundle-python.sh` — append CLI shim writeout**

Insert before the final `echo "==> bundle-python: done"`:

```bash
echo "==> writing public CLI shim at Contents/Resources/bin/accountpilot"
SHIM_DIR="$APP_BUNDLE/Contents/Resources/bin"
mkdir -p "$SHIM_DIR"
cat > "$SHIM_DIR/accountpilot" <<'SHIM'
#!/bin/bash
# Public CLI entry. Agents install /usr/local/bin/accountpilot as a
# symlink to this file. Resolves the bundle root at runtime so the .app
# is fully relocatable.
set -e
SHIM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUNDLE_ROOT="$(cd "$SHIM_DIR/../../.." && pwd)"
export PYTHONPATH="$BUNDLE_ROOT/Contents/Resources/python/site-packages"
exec "$BUNDLE_ROOT/Contents/Frameworks/Python.framework/bin/python3" \
    -m accountpilot.cli "$@"
SHIM
chmod +x "$SHIM_DIR/accountpilot"
```

- [ ] **Step 2: Re-run smoke test**

```bash
mkdir -p /tmp/fake-bundle3/Contents
APP_BUNDLE=/tmp/fake-bundle3 ./app/scripts/bundle-python.sh
/tmp/fake-bundle3/Contents/Resources/bin/accountpilot --version
# Then move the bundle and confirm the shim still works (relocation test)
mv /tmp/fake-bundle3 /tmp/fake-bundle3-moved
/tmp/fake-bundle3-moved/Contents/Resources/bin/accountpilot --version
rm -rf /tmp/fake-bundle3-moved
```

Both `--version` calls must succeed — proves the shim's bundle-root resolution works after relocation.

- [ ] **Step 3: Commit**

```bash
git add app/scripts/bundle-python.sh
git commit -m "feat(app): public CLI shim at Contents/Resources/bin/accountpilot"
```

---

## Task 8: Codesign — sign every Mach-O depth-first, then the bundle

`codesign --deep` is deprecated and unreliable for nested frameworks. The robust pattern: walk every Mach-O inside Frameworks/, XPCServices/, Helpers/, sign each individually, then sign the outer .app last.

**Files:**
- Create: `app/scripts/codesign-app.sh`

- [ ] **Step 1: Write `codesign-app.sh`**

```bash
#!/usr/bin/env bash
# Codesign every Mach-O inside the .app bundle, depth-first, then the
# outer bundle. Required because --deep is unreliable for nested
# Python.framework + Helpers/.

set -euo pipefail

if [[ -z "${APP_BUNDLE:-}" ]]; then
    echo "error: APP_BUNDLE env var required" >&2
    exit 64
fi
: "${APPLE_DEV_ID:=Developer ID Application: FAZLA GIDA ANONIM SIRKETI (P2R7PD8VGY)}"

ENT="$(pwd)/app/AccountPilot/AccountPilot.entitlements"
HELPER_ENT="$(pwd)/helpers/fda-helper/helper.entitlements"

# 1. Sign every Mach-O inside Frameworks/Python.framework
echo "==> signing Python.framework Mach-Os"
find "$APP_BUNDLE/Contents/Frameworks/Python.framework" \
     -type f \( -name "*.dylib" -o -name "*.so" -o -perm -u+x \) \
     -print0 | while IFS= read -r -d '' mach; do
    # skip Python files, hash files, etc. by checking magic
    head -c 4 "$mach" | xxd -p | grep -qE '^(cffaedfe|cefaedfe|feedface|feedfacf)' || continue
    codesign --force --sign "$APPLE_DEV_ID" --options runtime --timestamp "$mach"
done

# 2. Sign the helper with its own entitlements
echo "==> signing FDA helper"
codesign --force --sign "$APPLE_DEV_ID" \
    --options runtime \
    --entitlements "$HELPER_ENT" \
    --identifier "com.accountpilot.fda-helper" \
    --timestamp \
    "$APP_BUNDLE/Contents/Helpers/accountpilot-fda-helper"

# 3. Sign the outer bundle with the app's entitlements
echo "==> signing AccountPilot.app"
codesign --force --sign "$APPLE_DEV_ID" \
    --options runtime \
    --entitlements "$ENT" \
    --identifier "com.accountpilot.app" \
    --timestamp \
    "$APP_BUNDLE"

# 4. Verify
echo "==> verifying signature"
codesign --verify --verbose=2 "$APP_BUNDLE"
codesign --verify --verbose=2 "$APP_BUNDLE/Contents/Helpers/accountpilot-fda-helper"

echo "==> codesign: done"
```

- [ ] **Step 2: Make executable**

```bash
chmod +x app/scripts/codesign-app.sh
```

- [ ] **Step 3: Commit**

```bash
git add app/scripts/codesign-app.sh
git commit -m "feat(app): codesign-app.sh — depth-first Mach-O signing"
```

---

## Task 9: Bump version + write `build-app.sh` orchestrator

**Files:**
- Modify: `pyproject.toml`
- Modify: `CHANGELOG.md`
- Create: `app/scripts/build-app.sh`

- [ ] **Step 1: Bump Python pkg version**

In `pyproject.toml`, change `version = "0.1.4"` → `version = "0.2.0"`.

- [ ] **Step 2: Open `[0.2.0]` CHANGELOG section**

Insert above `## [0.1.4]`:

```markdown
## [0.2.0] — IN PROGRESS (AP-SP5)

### Added
- **macOS .app distribution.** AccountPilot now ships as a drag-to-Applications
  `.app` bundle with embedded Python.framework. End users no longer need brew or
  pip. Phase 1 ships the skeleton: a notarized .app that opens and runs the
  bundled CLI. Subsequent phases add account UI, FDA wizard, sync status,
  data viewer, and Sparkle auto-update.

(more entries land per phase)
```

- [ ] **Step 3: Write `build-app.sh`**

```bash
#!/usr/bin/env bash
# Orchestrator: build Swift .app, embed Python, copy helper, codesign,
# notarize, staple. Output: dist/AccountPilot.app
#
# Pre-reqs:
#   - helpers/fda-helper/release-helper.sh has been run (dist/accountpilot-fda-helper exists)
#   - notarization keychain profile 'accountpilot-notary' exists

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$REPO_ROOT"

DIST="$REPO_ROOT/dist"
APP_BUNDLE="$DIST/AccountPilot.app"
mkdir -p "$DIST"
rm -rf "$APP_BUNDLE"

echo "==> [1/5] xcodebuild AccountPilot.app shell"
cd app && xcodegen generate && cd ..
xcodebuild -project app/AccountPilot.xcodeproj \
    -scheme AccountPilot \
    -configuration Release \
    -derivedDataPath app/build/ \
    CODE_SIGNING_ALLOWED=NO \
    -skipMacroValidation \
    build
BUILT_APP=$(find app/build/Build/Products/Release -name "AccountPilot.app" -maxdepth 2 -type d | head -1)
test -d "$BUILT_APP" || { echo "error: xcodebuild did not produce AccountPilot.app"; exit 70; }
cp -R "$BUILT_APP" "$APP_BUNDLE"

echo "==> [2/5] embed Python.framework + accountpilot venv"
APP_BUNDLE="$APP_BUNDLE" "$REPO_ROOT/app/scripts/bundle-python.sh"

echo "==> [3/5] copy FDA helper into Contents/Helpers/"
APP_BUNDLE="$APP_BUNDLE" "$REPO_ROOT/app/scripts/bundle-helper.sh"

echo "==> [4/5] codesign (depth-first)"
APP_BUNDLE="$APP_BUNDLE" "$REPO_ROOT/app/scripts/codesign-app.sh"

echo "==> [5/5] notarize + staple"
ZIP="$DIST/AccountPilot-staging.zip"
rm -f "$ZIP"
ditto -c -k --keepParent "$APP_BUNDLE" "$ZIP"
xcrun notarytool submit "$ZIP" --keychain-profile accountpilot-notary --wait
xcrun stapler staple "$APP_BUNDLE"
xcrun stapler validate "$APP_BUNDLE"
rm -f "$ZIP"

echo "==> done"
echo "    .app: $APP_BUNDLE"
"$REPO_ROOT/app/scripts/verify-app.sh"
```

- [ ] **Step 4: Make executable**

```bash
chmod +x app/scripts/build-app.sh
```

- [ ] **Step 5: Commit**

```bash
git add pyproject.toml CHANGELOG.md app/scripts/build-app.sh
git commit -m "build(app): build-app.sh orchestrator + bump to 0.2.0"
```

---

## Task 10: Write `verify-app.sh` — post-build smoke test

**Files:**
- Create: `app/scripts/verify-app.sh`

- [ ] **Step 1: Write `verify-app.sh`**

```bash
#!/usr/bin/env bash
# Post-build verification of dist/AccountPilot.app. Run automatically by
# build-app.sh; can be re-run independently. Exits non-zero on any
# missing or mis-signed component.

set -euo pipefail

APP_BUNDLE="${APP_BUNDLE:-$(pwd)/dist/AccountPilot.app}"
test -d "$APP_BUNDLE" || { echo "error: $APP_BUNDLE missing"; exit 1; }

fail() { echo "FAIL: $*" >&2; exit 1; }
pass() { echo "  ✓ $*"; }

echo "==> verify $APP_BUNDLE"

# 1. Bundle structure
for path in \
    "Contents/MacOS/AccountPilot" \
    "Contents/Frameworks/Python.framework/bin/python3" \
    "Contents/Resources/python/site-packages/accountpilot/__init__.py" \
    "Contents/Resources/python/site-packages/accountpilot/cli.py" \
    "Contents/Resources/bin/accountpilot" \
    "Contents/Helpers/accountpilot-fda-helper" \
    "Contents/Info.plist"; do
    test -e "$APP_BUNDLE/$path" || fail "missing $path"
    pass "exists $path"
done

# 2. Codesign of outer bundle
codesign --verify --verbose=2 "$APP_BUNDLE" 2>&1 | grep -q "valid on disk" \
    || fail "outer bundle codesign invalid"
pass "outer bundle signature valid"

# 3. Helper signing identity unchanged
codesign -dv "$APP_BUNDLE/Contents/Helpers/accountpilot-fda-helper" 2>&1 \
    | grep -q "TeamIdentifier=P2R7PD8VGY" || fail "helper missing P2R7PD8VGY team"
codesign -dv "$APP_BUNDLE/Contents/Helpers/accountpilot-fda-helper" 2>&1 \
    | grep -q "Identifier=com.accountpilot.fda-helper" || fail "helper bundle id wrong"
pass "helper signing identity correct"

# 4. App bundle id
codesign -dv "$APP_BUNDLE" 2>&1 | grep -q "Identifier=com.accountpilot.app" \
    || fail "app bundle id should be com.accountpilot.app"
pass "app bundle id correct"

# 5. Notarization staple
xcrun stapler validate "$APP_BUNDLE" >/dev/null 2>&1 \
    || fail "notarization staple invalid"
pass "notarization stapled"

# 6. Public CLI works
"$APP_BUNDLE/Contents/Resources/bin/accountpilot" --version | grep -q "0.2.0" \
    || fail "bundled CLI does not report 0.2.0"
pass "CLI reports correct version"

# 7. FDA helper runs (returns EACCES without grant — that's expected)
"$APP_BUNDLE/Contents/Helpers/accountpilot-fda-helper" --version | grep -q "0.1.1" \
    || fail "FDA helper does not report 0.1.1"
pass "FDA helper runs and reports 0.1.1"

echo "==> all verification passed"
```

- [ ] **Step 2: Make executable**

```bash
chmod +x app/scripts/verify-app.sh
```

- [ ] **Step 3: Commit**

```bash
git add app/scripts/verify-app.sh
git commit -m "test(app): verify-app.sh — post-build smoke test"
```

---

## Task 11: First end-to-end build + drag-to-Applications smoke test

- [ ] **Step 1: Run release-helper.sh if helper not already in dist/**

```bash
test -x dist/accountpilot-fda-helper || ./scripts/release-helper.sh
```

(May take ~3 min for notarization. Skip if `dist/accountpilot-fda-helper` already exists from earlier 0.1.1 work.)

- [ ] **Step 2: Run the orchestrator**

```bash
./app/scripts/build-app.sh
```

Expected total runtime: ~3-7 min depending on notary queue.

Expected final output: `==> all verification passed`

- [ ] **Step 3: Drag-to-Applications smoke test (manual)**

```bash
# Remove any prior copy
rm -rf /Applications/AccountPilot.app
cp -R dist/AccountPilot.app /Applications/AccountPilot.app
# Open
open /Applications/AccountPilot.app
```

Expected:
- Window opens within 3 seconds
- Shows three lines:
  - `AccountPilot 0.2.0`
  - `Python: 3.13.1`
  - `accountpilot: 0.2.0`
- No "unidentified developer" warning (notarization is stapled)

- [ ] **Step 4: CLI smoke test from terminal**

```bash
/Applications/AccountPilot.app/Contents/Resources/bin/accountpilot --version
/Applications/AccountPilot.app/Contents/Resources/bin/accountpilot --help | head -5
/Applications/AccountPilot.app/Contents/Helpers/accountpilot-fda-helper --version
```

Expected outputs respectively:
- `accountpilot, version 0.2.0`
- Click help text including `Commands:` section
- `accountpilot-fda-helper 0.1.1`

- [ ] **Step 5: Commit nothing (no source changes from this task — it's pure verification)**

If anything failed, return to Phase 1 root cause investigation before declaring Phase 1 done.

---

## Task 12: Run XCTest suite + final commit

- [ ] **Step 1: Run unit tests against the built app**

```bash
xcodebuild test -project app/AccountPilot.xcodeproj \
    -scheme AccountPilot \
    -derivedDataPath app/build/ \
    -skipMacroValidation
```

Expected: both `PythonRuntimeTests` pass.

Note: XCTest runs against the test bundle, which links into the .app — `Bundle.main` in tests is the test bundle, not `/Applications/AccountPilot.app`. If `test_run_returnsStdoutFromBundledPython` fails because the test bundle has no embedded Python, that's expected behavior we'll fix in Phase 2 by introducing a `PythonRuntime.bundleURL` injection point. For Phase 1, tests passing only when run *against the installed .app* is acceptable. Document this limitation in `app/AccountPilotTests/README.md`:

```markdown
# AccountPilot Tests — Phase 1 Limitation

PythonRuntime resolves the bundled Python via `Bundle.main`. In `xcodebuild test`,
`Bundle.main` is the test bundle, not the host .app. Tests that exercise the
real Python invocation will fail in `xcodebuild test` until Phase 2 introduces
an injectable bundle root. Manual verification via `verify-app.sh` is the
canonical Phase 1 test gate.
```

- [ ] **Step 2: Final phase commit**

```bash
git add app/AccountPilotTests/README.md
git commit -m "docs(app): Phase 1 test runner limitation"
```

- [ ] **Step 3: Push branch + open PR**

```bash
git push -u origin 0.2-p1-app-skeleton
gh pr create --title "0.2 Phase 1: .app skeleton + embedded Python" \
  --body "Implements Phase 1 of docs/plans/0.2-app-pivot.md.

  - Drag-to-Applications AccountPilot.app
  - Embedded Python.framework (3.13.1 from python-build-standalone)
  - accountpilot venv installed inside Contents/Resources/python/
  - Public CLI shim at Contents/Resources/bin/accountpilot
  - Signed FDA helper at Contents/Helpers/accountpilot-fda-helper
  - Notarized + stapled
  - Verification: app/scripts/verify-app.sh passes

  Phase 1 plan: docs/plans/0.2-p1-app-skeleton.md
  Master pivot brief: docs/plans/0.2-app-pivot.md"
```

---

## Phase 1 done when

- [ ] `./app/scripts/build-app.sh` runs to completion ending in `==> all verification passed`
- [ ] `dist/AccountPilot.app` is notarized + stapled (`xcrun stapler validate dist/AccountPilot.app` succeeds)
- [ ] Drag to `/Applications`, double-click, window opens with the version triple
- [ ] `/Applications/AccountPilot.app/Contents/Resources/bin/accountpilot --version` works
- [ ] `/Applications/AccountPilot.app/Contents/Helpers/accountpilot-fda-helper --version` works
- [ ] PR open against `main`, all CI green

## Phase 2 starts after

- Phase 1 PR merged
- New branch `0.2-p2-accounts-ui`
- Write `docs/plans/0.2-p2-accounts-ui.md` via `superpowers:writing-plans`
