Skip to content
Closed
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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ name: CI
on:
push:
pull_request:
schedule:
- cron: "17 4 * * 1" # weekly (Mon 04:17 UTC) — real-framework drift check
workflow_dispatch:

# Least privilege: validate.sh only reads the tree.
permissions:
Expand All @@ -16,6 +19,23 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Validate plugin
run: ./scripts/validate.sh
# Behavioral gate: drive the real scripts against tiny fixtures (build,
# bundle metadata, runtime port, server ownership, teardown). Hermetic —
# stand-in $PORT servers, no installs. Real frameworks run in fixtures-real.
- name: Behavioral fixture suite
run: ./scripts/test-fixtures.sh

# Real-framework drift lane: npm install + the real `npm run dev -- --port $PORT`
# Vite invocation, launched through a real bundle. Heavy and network-bound, so it
# runs weekly / on demand only — never gating per-PR. The hermetic stub-based
# suite in the `validate` job is what gates every push.
fixtures-real:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: macos-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Real-framework fixture lane (vite-real)
run: APP_IT_RUN_REAL=1 ./scripts/test-fixtures.sh

# Required: this job gates merge so a maintainer PR cannot accidentally regress
# the Windows scaffold. The macos-latest job validates the macOS plugin; this
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

- Added: internal behavioral **fixture suite** (`scripts/test-fixtures.sh` + `scripts/fixtures/`) that proves app-it works across the project shapes it claims to support. On every push the macOS CI lane drives the *real* scripts — `inspect.sh`, `desktop-build.sh`, the generated launcher, `desktop-doctor.sh`, `desktop-quit.sh` — against tiny shape-fixtures (Vite, Next, static-export, Vite+Express multiserver, hardcoded-port, Chrome-fallback) and asserts the headless-automatable rows of SKILL.md's Phase-4 checklist: build, bundle metadata, no placeholder leak, **runtime port**, server responding, **the server belongs to the launcher** (descendant-walk ownership), warm-reattach, and clean teardown. Hermetic by design — stand-in `$PORT` servers, no framework installs, a sandboxed `HOME`, and trap-based teardown, so it never touches your real `~/Applications`/`~/Library` state. A separate weekly `fixtures-real` lane (and the release checklist) runs one **real** `npm run dev -- --port $PORT` Vite app so framework drift can't silently rot the top recipe. GUI-only checks (window content, Dock icon, Cmd+Q/red-X, LaunchServices) stay a documented manual release smoke.
- Added: a documented `APP_IT_SMOKE` seam in the launcher templates (`run-template.sh`, `run-template-multiserver.sh`, `run-template-static-server.sh`). When set, the launcher does everything a real Dock click does *except* open the GUI window — server up, daemonized, reachable, pid/port recorded — then prints the runtime URL and exits. No effect on a normal launch; it's what lets CI (and anyone debugging over SSH) verify the server actually comes up without a display.
- Changed: **recipe-governance rule** — no new framework recipe is merged unless it's backed by a fixture or a reproducible smoke test (see [CONTRIBUTING.md](CONTRIBUTING.md)). Keeps the support matrix honest and the repo free of untested example apps.
- Fixed: **warm-reattach now works for real frameworks.** The launcher's process-ownership descendant walk (the F38 reattach gate in the `run-template*.sh` launchers, and `desktop:doctor`) only ever reached the *first* child generation on macOS — `pgrep -P` returns nothing when handed a space-joined list of PIDs, which is what the walk built up after generation 1. So for the common case where the dev server's HTTP listener sits deeper than one level (`npm run dev` → node → vite; `pnpm dev` → node → node → next-server), the launcher could not recognize its own running server: every Dock click cold-started a *new* server on a *new* port instead of reattaching to the warm one, leaking processes. The walk now expands one PID at a time so each `pgrep -P` call gets a clean argument. Caught by the new `deep-tree` fixture, which fails if the walk regresses to gen-1.
- Added: `desktop:doctor` — a self-diagnosing command for generated `app-it` launchers (`scripts/desktop-doctor.sh`). Run `npm run desktop:doctor` long after the build session to get a short, issue-ready report on one launcher: config + placeholder leakage, installed/build `.app`, Info.plist identity, ad-hoc signature, quarantine / iCloud signature-breaking xattrs, preferred-vs-runtime port, stale PID, **whether the process on the runtime port is actually in the recorded supervisor's descendant tree** (reuses the launcher's reattach gate), start-command binary resolution on the launcher's PATH, log/state paths, and **template drift** (feature-probes the installed `wrapper`/`run` against the current templates — no version stamp needed). `--tail[=N]` appends the launcher log. It is a diagnostic, not a fixer: read-only, deterministic, local (no network, no new dependencies), and it says "probably" when a check can't be certain. The opt-in `--fix-safe` flag touches **only app-it's own generated state** — stale pid/port files, this bundle's stale LaunchServices registration, the rebuilt icon, and quarantine on the generated `.app` — never the user's product code, dependencies, config, or anything outside app-it's artifacts. macOS `app-it` plugin only (the `app-it-static` companion has a different runtime model). Embodies Core principle #8 (*runtime truth beats build-time guess*) for end users.
- Added: `app-it-static` companion plugin (`plugins/app-it-static/`) — a macOS sibling of `app-it` for **finished or buildable** apps. Builds once, then serves the built output (`dist/`/`build/`/`out/`/…) from a tiny zero-dependency static server (~15 MB) or directly via `file://` (~0 MB) — **no dev server**, instead of the 300–700 MB a dev server holds. Reuses `app-it`'s native Swift WebKit window, icon pipeline, and one-folder Dock install (the five shared templates are byte-identical and CI guards them against drift). The served output is a snapshot; `desktop:rebuild` refreshes it. Inspired by r/ClaudeAI launch feedback (see README → Community nudge) and recorded in [ADR 0006](docs/decisions/0006-static-companion-snapshot-model.md).
- Added: Windows beta scaffold (`plugins/app-it-windows/`) — a sibling plugin mirroring the macOS contract with Windows primitives (WPF + WebView2 host, PowerShell lifecycle scripts, multi-resolution `.ico`, Start Menu `.lnk`). Build + lint gated by a required `windows-latest` CI job; **untested on real hardware, looking for a maintainer.** See [docs/WINDOWS.md](docs/WINDOWS.md).
Expand Down
16 changes: 15 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,25 @@ Please avoid broadening the macOS plugin into general desktop-app distribution,
Before opening a PR:

```bash
./scripts/validate.sh
./scripts/validate.sh # fast static gate (lint, syntax, plist, manifests)
```

For changes to the launcher scripts, the build pipeline, or framework support, also run the behavioral suite — it drives the real scripts against tiny project fixtures (build → runtime port → server ownership → teardown):

```bash
./scripts/test-fixtures.sh # macOS; hermetic, no installs
```

If your change updates user-visible behavior, add a short note to `CHANGELOG.md`.

## Framework recipes need a fixture

app-it claims to work across a set of project shapes (Vite, Next, static export, multi-server, …). A *recipe* is a claim; a *fixture* is the proof.

**No new framework recipe is merged unless it's backed by a fixture in [`scripts/fixtures/`](scripts/fixtures/) or a reproducible smoke test.** This keeps the support matrix honest — every shape we say we handle has a test that fails when we stop handling it — and it keeps the repo from drifting into a zoo of untested example apps.

Adding one is usually small: a tiny project-shape directory under `scripts/fixtures/<name>/` plus one section in `scripts/test-fixtures.sh`. See [scripts/fixtures/README.md](scripts/fixtures/README.md) for what each fixture must guard and what the suite deliberately does not prove. The open `good first issue` recipe tickets (Vite + React, SvelteKit, Astro) are good starting points — each should land with its fixture.

## Recognition

Contributors show up in GitHub's own contributors list — and not just for code. Testing on real hardware, packaging, bug reports, and ideas all earn a mention in the README's community thanks. If a contribution doesn't already credit you, say so in your PR (or open an issue) and the maintainer will add you.
11 changes: 9 additions & 2 deletions docs/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

Before publishing a release:

- [ ] `./scripts/validate.sh` passes locally.
- [ ] `./scripts/validate.sh` passes locally (fast static gate).
- [ ] `./scripts/test-fixtures.sh` passes locally (hermetic behavioral suite).
- [ ] `APP_IT_RUN_REAL=1 ./scripts/test-fixtures.sh` passes (real-Vite lane — confirms the current framework still launches end-to-end).
- [ ] `claude plugin validate .` passes with the current Claude Code CLI.
- [ ] `claude plugin validate plugins/app-it/.claude-plugin/plugin.json` passes with the current Claude Code CLI.
- [ ] Codex install smoke passes in a temp home (both installable plugins):
Expand All @@ -16,7 +18,12 @@ Before publishing a release:
- [ ] Shared templates are byte-identical across `app-it` and `app-it-static` (validate.sh's drift guard passes).
- [ ] The README install command matches the intended GitHub repo.
- [ ] No local paths, private notes, generated bundles, or test artifacts are tracked.
- [ ] A real local project has been appified and opened from `~/Applications/App It/`.
- [ ] **Manual GUI smoke** (the rows CI can't see — SKILL.md Phase-4 rows 12–16). The headless suite proves build → server → port → ownership → teardown; this proves the window actually works. Appify a real project (the `scripts/fixtures/vite-basic` shape is enough — `npm install` it first, or use any local app), install it, open it from `~/Applications/App It/`, and confirm by eye:
- the window shows the app (not an error page);
- the Dock icon is *ours* (not Chrome's / Safari's);
- Cmd+Q kills the app **and** its dev server (`lsof -ti tcp:<port>` is empty after);
- closing the window with the red X leaves the server warm (re-open is instant);
- the standard shortcuts respond (Cmd+R reload, Cmd+W close, zoom, fullscreen).

For the first public GitHub setup:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,16 @@ if [ -f "$PID_FILE" ] && [ -f "$PORT_FILE" ]; then
DESCENDANTS="$EXPECTED_PID"
CURRENT="$EXPECTED_PID"
for _ in 1 2 3 4; do
NEXT_GEN="$(pgrep -P "$CURRENT" 2>/dev/null | tr '\n' ' ')"
[ -z "$NEXT_GEN" ] && break
# Expand one PID per pgrep call. macOS `pgrep -P` returns nothing
# for a space-joined / trailing-space argument, so passing the
# whole generation at once would silently halt the walk at the
# first level and miss deeper listeners. Walk per-pid so each
# call is clean.
NEXT_GEN=""
for _pid in $CURRENT; do
NEXT_GEN="$NEXT_GEN $(pgrep -P "$_pid" 2>/dev/null | tr '\n' ' ')"
done
[ -z "${NEXT_GEN// /}" ] && break
DESCENDANTS="$DESCENDANTS $NEXT_GEN"
CURRENT="$NEXT_GEN"
done
Expand Down Expand Up @@ -150,6 +158,17 @@ fi

URL="http://localhost:$CHOSEN_PORT"

# --- Headless smoke seam (CI / SSH / --check) --------------------------
# The static server is up, daemonized, reachable, and its pid/port are
# recorded. With APP_IT_SMOKE set, print the runtime URL and exit 0 instead
# of opening the GUI window, so a headless caller can probe it (curl,
# desktop:doctor) and then stop it (desktop:quit). Zero effect on a normal
# Dock launch (APP_IT_SMOKE unset).
if [ -n "${APP_IT_SMOKE:-}" ]; then
echo "app-it smoke: $APP_NAME ready at $URL (server pid $(cat "$PID_FILE" 2>/dev/null))"
exit 0
fi

# --- Hand off to the native WebKit wrapper -----------------------------
# exec replaces this bash process with the Swift binary so the .app keeps its
# own Dock icon and single-instance activation. No polyfill for finished apps.
Expand Down
13 changes: 10 additions & 3 deletions plugins/app-it/skills/app-it/templates/desktop-doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,17 @@ LAUNCHER_PATH="$HOME/.bun/bin:$HOME/.deno/bin:$HOME/.volta/bin:$HOME/.local/shar
# space-separated. Mirrors run-template.sh's reattach gate so "does the running
# server belong to this launcher" uses the SAME ownership test the launcher does.
walk_descendants() {
local root="$1" current="$1" tree="$1" gen
local root="$1" current="$1" tree="$1" gen _pid
for _ in 1 2 3 4; do
gen="$(pgrep -P "$current" 2>/dev/null | tr '\n' ' ')"
[ -z "$gen" ] && break
# One PID per pgrep call: macOS `pgrep -P` returns nothing for a
# space-joined / trailing-space argument, so a multi-PID generation
# would halt the walk and miss deeper listeners (pnpm → node →
# next-server, npm → node-vite). Walk per-pid so each call is clean.
gen=""
for _pid in $current; do
gen="$gen $(pgrep -P "$_pid" 2>/dev/null | tr '\n' ' ')"
done
[ -z "${gen// /}" ] && break
tree="$tree $gen"; current="$gen"
done
printf '%s' "$tree"
Expand Down
12 changes: 10 additions & 2 deletions plugins/app-it/skills/app-it/templates/run-template-chrome.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,16 @@ if [ -f "$PID_FILE" ] && [ -f "$PORT_FILE" ]; then
DESCENDANTS="$EXPECTED_PID"
CURRENT="$EXPECTED_PID"
for _ in 1 2 3 4; do
NEXT_GEN="$(pgrep -P "$CURRENT" 2>/dev/null | tr '\n' ' ')"
[ -z "$NEXT_GEN" ] && break
# Expand one PID per pgrep call. macOS `pgrep -P` returns nothing
# for a space-joined / trailing-space argument, so passing the
# whole generation at once would silently halt the walk at the
# first level and miss deeper listeners (npm → node-vite,
# pnpm → node → next-server). Walk per-pid so each call is clean.
NEXT_GEN=""
for _pid in $CURRENT; do
NEXT_GEN="$NEXT_GEN $(pgrep -P "$_pid" 2>/dev/null | tr '\n' ' ')"
done
[ -z "${NEXT_GEN// /}" ] && break
DESCENDANTS="$DESCENDANTS $NEXT_GEN"
CURRENT="$NEXT_GEN"
done
Expand Down
22 changes: 19 additions & 3 deletions plugins/app-it/skills/app-it/templates/run-template-multiserver.sh
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,14 @@ descendant_holds_port() {
local descendants="$supervisor"
local current="$supervisor"
for _ in 1 2 3 4; do
local next_gen
next_gen="$(pgrep -P "$current" 2>/dev/null | tr '\n' ' ')"
[ -z "$next_gen" ] && break
# One PID per pgrep call — macOS `pgrep -P` returns nothing for a
# space-joined argument, so a multi-PID generation would otherwise halt
# the walk and miss deeper listeners.
local next_gen="" _pid
for _pid in $current; do
next_gen="$next_gen $(pgrep -P "$_pid" 2>/dev/null | tr '\n' ' ')"
done
[ -z "${next_gen// /}" ] && break
descendants="$descendants $next_gen"
current="$next_gen"
done
Expand Down Expand Up @@ -236,6 +241,17 @@ fi

URL="http://localhost:$CHOSEN_FE_PORT"

# --- Headless smoke seam (CI / SSH / --check) --------------------------
# Both servers are up, daemonized, and recorded (server.{pid,port} +
# backend.{pid,port}); the frontend is reachable. With APP_IT_SMOKE set,
# print the runtime URLs and exit 0 instead of opening the GUI window, so a
# headless caller can probe both ports (curl, desktop:doctor) and stop them
# (desktop:quit). Zero effect on a normal Dock launch (APP_IT_SMOKE unset).
if [ -n "${APP_IT_SMOKE:-}" ]; then
echo "app-it smoke: $APP_NAME ready at $URL (fe pid $(cat "$PID_FILE" 2>/dev/null) :$CHOSEN_FE_PORT, be pid $(cat "$BACKEND_PID_FILE" 2>/dev/null) :$CHOSEN_BE_PORT)"
exit 0
fi

# --- Hand off to the native WebKit wrapper -----------------------------
WRAPPER="$HERE/wrapper"
if [ ! -x "$WRAPPER" ]; then
Expand Down
24 changes: 22 additions & 2 deletions plugins/app-it/skills/app-it/templates/run-template.sh
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,16 @@ if [ -f "$PID_FILE" ] && [ -f "$PORT_FILE" ]; then
DESCENDANTS="$EXPECTED_PID"
CURRENT="$EXPECTED_PID"
for _ in 1 2 3 4; do
NEXT_GEN="$(pgrep -P "$CURRENT" 2>/dev/null | tr '\n' ' ')"
[ -z "$NEXT_GEN" ] && break
# Expand one PID per pgrep call. macOS `pgrep -P` returns nothing
# for a space-joined / trailing-space argument, so passing the
# whole generation at once would silently halt the walk at the
# first level and miss deeper listeners (npm → node-vite,
# pnpm → node → next-server). Walk per-pid so each call is clean.
NEXT_GEN=""
for _pid in $CURRENT; do
NEXT_GEN="$NEXT_GEN $(pgrep -P "$_pid" 2>/dev/null | tr '\n' ' ')"
done
[ -z "${NEXT_GEN// /}" ] && break
DESCENDANTS="$DESCENDANTS $NEXT_GEN"
CURRENT="$NEXT_GEN"
done
Expand Down Expand Up @@ -236,6 +244,18 @@ fi

URL="http://localhost:$CHOSEN_PORT"

# --- Headless smoke seam (CI / SSH / --check) --------------------------
# Everything a real Dock click does has now run EXCEPT opening the window:
# the dev server is up, daemonized, reachable, and its pid/port are recorded.
# With APP_IT_SMOKE set, print the runtime URL and exit 0 instead of handing
# off to the GUI wrapper. The server stays warm so the caller can probe it
# (curl, desktop:doctor) and then stop it (desktop:quit). Zero effect on a
# normal Dock launch (APP_IT_SMOKE unset).
if [ -n "${APP_IT_SMOKE:-}" ]; then
echo "app-it smoke: $APP_NAME ready at $URL (server pid $(cat "$PID_FILE" 2>/dev/null))"
exit 0
fi

# --- Hand off to the native WebKit wrapper -----------------------------
# exec replaces this bash process with the Swift binary. The .app's identity
# stays intact (CFBundleIdentifier from Info.plist), so the Dock keeps showing
Expand Down
Loading
Loading