Skip to content

Commit a6fcaef

Browse files
authored
feat: multi-instance management (#109) (#118)
Closes #109. Sub-issues #110, #111, #112, #113, #114, #115 all closed. ## Summary Refactor `@forgeplan/web` for multi-instance use: 1. **citty-based CLI** — `bin/forgeplan-web.mjs` shrinks 367→5 lines; subcommands live in `bin/commands/{init,update,start}.mjs`; helpers in `bin/lib/`. Single allow-listed dep (`citty@0.2.2`) per amended rule 23. 2. **`--scope user|project` flag** — init writes to `<cwd>/.forgeplan-web/` or `~/.forgeplan-web/`. `init -y` keeps defaulting to project (compat); interactive prompt picks user as the new default. Non-TTY without flag fails clearly. 3. **`start` resolution chain** — project → user → prompt-init. Explicit `--scope` short-circuits the chain. 4. **Global instance registry** at `~/.forgeplan-web/instances.json` — incremental ports, pid + heartbeat liveness, atomic tmp+rename writes, stale sweep on every start. New `/api/instances` readonly endpoint mirrors the registry to the UI. 5. **Combobox primitive** in `template/src/shared/ui/combobox/` (bits-ui 2.18.1; default + mono variants × sm/md sizes; showcased on /playground; rule-24 clean). 6. **HealthBar instance switcher** — when ≥2 instances are running, the project label becomes a Combobox; selecting another instance navigates the browser to `http://host:port`. Single-instance behavior unchanged. ## Why Original issue: install `forgeplan-web` once per user (not per project), run several instances simultaneously, switch between them from any instance's UI. Refined plan keeps backwards compat (`--scope project` is still the `init -y` default) so existing setups don't break. ## Forgeplan artifacts (all `active`, R_eff > 0) - ADR-003 — amend rule 23 to permit citty in `bin/` - ADR-004 — registry path + JSON format + atomic writes - PRD-024 + RFC-020 — citty integration - PRD-025 + RFC-021 — `--scope` flag + user-scope path - PRD-026 + RFC-022 — start resolution chain - PRD-027 + RFC-023 + SPEC-003 — instance registry - PRD-028 + RFC-024 — Combobox primitive - PRD-029 + RFC-025 — HealthBar switcher - EVID-029..034 — smoke + svelte-check + manual scenarios per PRD Rules touched (each via dedicated PRD/ADR): - `.claude/rules/20-init-host-isolation.md` — user-scope writes are gitignore-silent (PRD-025) - `.claude/rules/22-readonly-proxy.md` — `/api/instances` allow-list extension (PRD-027) - `.claude/rules/23-bin-zero-deps.md` — citty named exception (ADR-003) ## Test plan - [x] `npm run smoke` — PASS at every wave (citty refactor, scope wiring, registry, HealthBar) - [x] `npm run build` — svelte-check 1056 files / 0 errors / 0 warnings - [x] Manual: `init -y --scope user` writes only to `~/.forgeplan-web/`, host `.gitignore` untouched - [x] Manual: `init -y` (no flag) defaults to project (compat preserved) - [x] Manual: Two instances on 5174 + 5175 (or first free pair); both register; SIGKILL on one swept on next start; heartbeat ticks at ~30s; deregister within 2s of SIGTERM - [x] Manual: HealthBar shows Combobox when ≥2 instances; single-instance fallback verified - [x] Manual: `/api/instances` returns standard envelope `{ ok, data: { instances }, cmd: "registry:read" }` - [x] `forgeplan health` clean post-Wave-7 - [x] Rule 23 verifier exits 0 (only `citty` + `node:*` + relative siblings in `bin/`) - [x] Rule 24 grep clean (no upper-layer `:global()` reaches into Combobox internals) - [ ] CI matrix (ubuntu/macos/windows × Node 22) — runs on PR open ## Out of scope (deferred follow-ups) - Cross-host federation (talking to instances on other machines) - Auth / per-instance access control - Migration tool for existing project-scope `.forgeplan-web/` → user-scope - Full XDG compliance (`XDG_CONFIG_HOME` etc.) — RFC-021 keeps `~/.forgeplan-web/` for predictability - Smoke coverage for `--scope user` path + multi-instance scenario (current smoke covers single project-scope instance only) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 1d096b5 + 4705676 commit a6fcaef

55 files changed

Lines changed: 7624 additions & 429 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,47 @@
11
# `init` host isolation
22

3-
`bin/forgeplan-web.mjs init` runs in the user's project directory. It
4-
MUST write only inside `<cwd>/.forgeplan-web/`, with **one** narrow
5-
exception: appending an ignore rule for `.forgeplan-web/` to the host's
6-
`.gitignore` (see ADR-001). Every other host write is forbidden.
3+
`bin/forgeplan-web.mjs init` runs in the user's project directory. Its
4+
write boundary depends on the resolved scope (PRD-025 / RFC-021):
75

8-
## Forbidden writes from `init`
6+
- **Project scope** (`--scope project`, or `-y` without `--scope`, or the
7+
user picks "project" interactively): writes only inside
8+
`<cwd>/.forgeplan-web/`, with **one** narrow exception — appending an
9+
ignore rule for `.forgeplan-web/` to the host's `.gitignore` (see
10+
ADR-001). Every other host write is forbidden.
11+
- **User scope** (`--scope user`, or the user picks "user" interactively):
12+
writes only inside `~/.forgeplan-web/` (resolved from `os.homedir()`,
13+
per RFC-021 / ADR-004). The host project's `.gitignore` MUST NOT be
14+
touched — the home directory is workspace-agnostic and the user-scope
15+
scaffold is shared across projects, so an "ignore" hint in any
16+
individual project would be misleading. User scope likewise does NOT
17+
require a `.forgeplan/` workspace in the cwd at install time; the
18+
workspace is bound at `start` time via `FORGEPLAN_CWD`.
19+
20+
## Forbidden writes from `init` (both scopes)
921

1022
- The host's `package.json`, `pnpm-lock.yaml`, `package-lock.json`,
1123
`yarn.lock`.
1224
- The host's `.npmrc`, `.editorconfig`, or any other root config.
1325
- The host's `node_modules/`.
1426
- The host's `.forgeplan/` (the workspace is *read* via the bundled
1527
`forgeplan` CLI; never written by us).
16-
- Any file outside `<cwd>/.forgeplan-web/` other than the single
17-
`.gitignore` exception below.
28+
- Under `--scope user`: the host's `.gitignore` MUST NOT be created,
29+
read-then-rewritten, or appended. User scope is gitignore-silent.
30+
- Any file outside the resolved target directory (project: `<cwd>/.forgeplan-web/`,
31+
user: `~/.forgeplan-web/`) other than the single `.gitignore`
32+
exception below — and that exception applies to project scope only.
1833

1934
## Allowed writes
2035

21-
- Anything inside `<cwd>/.forgeplan-web/`.
22-
- `<cwd>/.forgeplan-web/forgeplan-web.json` (workspace pointer the
23-
SvelteKit server reads at runtime).
24-
- `<cwd>/.gitignore`**append-only**, with these constraints:
36+
- **Project scope** only: anything inside `<cwd>/.forgeplan-web/`,
37+
including `<cwd>/.forgeplan-web/forgeplan-web.json` (workspace pointer
38+
the SvelteKit server reads at runtime).
39+
- **User scope** only: anything inside `~/.forgeplan-web/`, including
40+
`~/.forgeplan-web/forgeplan-web.json`. The `workspaceRoot` field in
41+
the user-scope config is `null` at init-time (the user-scope dist is
42+
workspace-agnostic; `start` binds the workspace from cwd or
43+
`FORGEPLAN_CWD` at runtime).
44+
- **Project scope** only: `<cwd>/.gitignore`**append-only**, with these constraints:
2545
- Add at most one ignore line: `.forgeplan-web/`.
2646
- The append is idempotent. Detection regex per line:
2747
`^[ \t]*\.forgeplan-web\/?[ \t]*$`. If a matching line is already
@@ -46,13 +66,18 @@ idempotent, reversible, and opt-out — see ADR-001.
4666

4767
## Verification
4868

49-
- `git status` in the host project after `init` must show changes only
50-
inside `.forgeplan-web/` and (at most) a single appended block at the
51-
end of `.gitignore`.
52-
- The `init()` function in `bin/forgeplan-web.mjs` must not call
53-
`writeFileSync` / `copyFileSync` / `mkdirSync` with any path that
54-
does not resolve under `target` (`.forgeplan-web/`) or under the
55-
host's `.gitignore`.
56-
- Running `init` twice must not duplicate the `.gitignore` entry.
69+
- After `init -y` or `init --scope project`: `git status` in the host
70+
project must show changes only inside `.forgeplan-web/` and (at most)
71+
a single appended block at the end of `.gitignore`.
72+
- After `init --scope user`: `git status` in the cwd must show **no**
73+
changes at all. All writes land under `~/.forgeplan-web/`.
74+
- `runInit()` in `bin/commands/init.mjs` must not call `writeFileSync`
75+
/ `copyFileSync` / `mkdirSync` with any path that does not resolve
76+
under the resolved scope target (`<cwd>/.forgeplan-web/` or
77+
`~/.forgeplan-web/`) — and, only under project scope, the host's
78+
`.gitignore`.
79+
- Running `init -y` twice must not duplicate the `.gitignore` entry.
5780
- Running `init -y --no-gitignore` must not touch `.gitignore` at all
5881
(no create, no append, no read-then-rewrite).
82+
- `ensureGitignore` is called from `runInit` only when `scope ===
83+
"project"`; user-scope code paths must not even invoke it.

.claude/rules/22-readonly-proxy.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ revision of this rule. See PRD-012 / RFC-011.
2929

3030
## Allow-list extension: `/api/update-check` (non-forgeplan)
3131

32-
`/api/update-check` is the **single** non-forgeplan endpoint permitted from
32+
`/api/update-check` is one of the non-forgeplan endpoints permitted from
3333
`/api/*`. It probes the npm registry for the latest published version of
3434
`@forgeplan/web` so the UI can surface an "Update available" affordance.
3535

@@ -52,6 +52,40 @@ Any additional non-forgeplan endpoint (whether it hits npm, GitHub,
5252
crates.io, or anything else) requires a new Forgeplan artifact and a fresh
5353
amendment to this rule. See PRD-013 / RFC-012.
5454

55+
## Allow-list extension: `/api/instances` (non-forgeplan)
56+
57+
`/api/instances` is the second non-forgeplan endpoint permitted from
58+
`/api/*`. It exposes a read-only mirror of the global instance registry at
59+
`~/.forgeplan-web/instances.json` (PRD-027 / RFC-023 / SPEC-003 / ADR-004) so
60+
the UI can surface an instance switcher (PRD-029, Wave 6).
61+
62+
Constraints (every one of these is enforceable from the diff):
63+
64+
- Method: `GET` only.
65+
- File path: the registry file is resolved as
66+
`path.join(os.homedir(), ".forgeplan-web", "instances.json")`
67+
**no interpolation, no env override, no user input** on the path.
68+
- **No spawn, no `fs.write*` / `fs.mkdir*` / `fs.rename*` / `fs.unlink*`**.
69+
The endpoint MUST NOT mutate the registry; mutations live exclusively in
70+
`bin/lib/registry.mjs`. The endpoint reads via `node:fs.readFileSync`
71+
only and applies an in-process liveness sweep (`process.kill(pid, 0)` +
72+
heartbeat freshness ≤ 60 s) before returning.
73+
- No Forgeplan invocation, no network call, no host filesystem mutation.
74+
The only side-effect is a process-local in-memory cache (2 s TTL, single
75+
inflight promise) inside
76+
`template/src/shared/server/registry.ts#readInstances`.
77+
- Response shape mirrors the standard envelope: `{ ok, data: { instances },
78+
cmd: "registry:read", error? }`. `instances` MUST conform to the
79+
SPEC-003 v1 row shape (id / host / port / pid / scope / workspaceRoot /
80+
projectName / startedAt / heartbeatAt / webVersion / forgeplanCli);
81+
malformed rows are silently dropped from the live view.
82+
- Errors (file read, JSON parse) MUST fall back to `{ ok: false, error,
83+
data: { instances: [] } }` — never throw.
84+
85+
Any additional non-forgeplan endpoint (whether it hits npm, GitHub,
86+
crates.io, the local filesystem outside the registry, or anything else)
87+
requires a new Forgeplan artifact and a fresh amendment to this rule.
88+
5589
## Forbidden `forgeplan` subcommands from any `/api/*` endpoint
5690

5791
Any subcommand that mutates the workspace:
@@ -97,3 +131,8 @@ browser invalidates that.
97131
- `grep -RIn "fetch(" template/src/routes/api/` must show external URLs
98132
only inside `update-check/+server.ts`, and the URL must appear as a
99133
string literal (`https://registry.npmjs.org/@forgeplan/web/latest`).
134+
- `template/src/routes/api/instances/+server.ts` MUST NOT contain any
135+
`spawn`, `execFile`, `writeFileSync`, `renameSync`, or `mkdirSync`
136+
call (read-only constraint above). The reader
137+
`template/src/shared/server/registry.ts` MAY only call
138+
`existsSync` + `readFileSync` against `~/.forgeplan-web/instances.json`.

.claude/rules/23-bin-zero-deps.md

Lines changed: 80 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,50 @@
1-
# `bin/` zero runtime dependencies
1+
# `bin/` near-zero runtime dependencies (amended by ADR-003)
22

3-
`bin/forgeplan-web.mjs` is invoked by `npx @forgeplan/web init` and
4-
`npx @forgeplan/web start`. The bin script itself MUST run with Node
5-
built-ins only — it does its job (copy `dist/` into `.forgeplan-web/`,
6-
spawn `node .forgeplan-web/index.js`) without needing any third-party
7-
import.
3+
`bin/forgeplan-web.mjs` is invoked by `npx @forgeplan/web init`,
4+
`npx @forgeplan/web update`, and `npx @forgeplan/web start`. The bin
5+
script does its job (copy `dist/` into `.forgeplan-web/`, spawn
6+
`node .forgeplan-web/index.js`) with Node built-ins plus exactly **one**
7+
allow-listed CLI library: `citty` (per ADR-003).
88

9-
## Allowed in `bin/`
9+
ADR-003 amended the historical "zero-dep" rule to a **named-allowlist**
10+
rule: `citty` (and any of its transitive deps) is the **only** named
11+
exception. Adding any other third-party import to `bin/` requires a new
12+
ADR — not an amendment to this one.
13+
14+
## Allowed in `bin/` (amended by ADR-003)
1015

1116
- `node:*` modules (`node:fs`, `node:child_process`, `node:path`,
1217
`node:url`, `node:os`, `node:crypto`, `node:util`).
13-
- Relative imports of sibling `.mjs` files **inside `bin/`** (e.g.
14-
`import { … } from "./banner.mjs"`). These are first-party code that
15-
ships in the `bin/` folder of the published tarball; they introduce
16-
no third-party resolution at `npx` time. Each such sibling file is
17-
itself bound by this rule (zero `node_modules/` imports, `node:*`-only).
18+
- Relative imports of sibling `.mjs` files inside `bin/` (e.g.
19+
`import { … } from "./banner.mjs"`, `from "./lib/config.mjs"`,
20+
`from "./commands/init.mjs"`). These are first-party code that ships
21+
in the `bin/` folder of the published tarball; they introduce no
22+
third-party resolution at `npx` time. Each such sibling file is
23+
itself bound by this rule (`node:*` + `citty` only, plus relative
24+
siblings).
25+
- **`citty`** — CLI framework providing `defineCommand`, `runMain`,
26+
`parseArgs`, typed args, auto-help, and subcommand routing. Pinned
27+
to `^0.2.2` (caret-minor). Bumping major/minor requires re-evaluating
28+
ADR-003. As of `citty@0.2.2` the package has zero runtime
29+
`dependencies` (it bundles its own helpers and uses
30+
`node:util.parseArgs`); should a future version reintroduce
31+
transitive deps, those are covered by this rule too as long as they
32+
are pulled by `citty` and not declared at the root.
1833
- Synchronous I/O (`mkdirSync`, `cpSync`, …) — the script is short and
1934
CLI-bound; readability beats event-loop nicety.
2035

2136
## Forbidden in `bin/`
2237

23-
- Any `import` from a third-party package.
24-
- Any `require()` from `node_modules/` at the package root.
25-
- Spawning `node_modules/.bin/*` binaries from the package root (root
26-
has no `node_modules`).
27-
- Adding entries to the root `package.json#dependencies` to support the
28-
bin script.
38+
- Any third-party import OTHER than `citty` (and any transitive deps
39+
it pulls). E.g. `chalk`, `commander`, `yargs`, `figlet`, `prompts`,
40+
`ora`, `kleur` are all forbidden. If you think one is needed, open a
41+
new ADR.
42+
- Any `require()` from `node_modules/` at the package root that is not
43+
resolved through citty.
44+
- Spawning `node_modules/.bin/*` binaries from the package root.
45+
- Adding any other entry to root `package.json#dependencies` to support
46+
the bin script. Currently allowed: `{ "citty": "^0.2.2" }` — and
47+
nothing else.
2948

3049
## Note on `dist/` and `dist-experimental/`
3150

@@ -43,8 +62,8 @@ The published tarball ships **two** pre-built artifacts (PRD-014 / RFC-013):
4362

4463
In both cases the bin script only `spawn()`s `node` against the
4564
artifact's `index.js` — it never imports anything from the artifact's
46-
internals. This rule is about the bin script itself staying zero-dep;
47-
the artifacts are governed by rule 21.
65+
internals. This rule is about the bin script itself staying bound to
66+
the citty + node:* allow-list; the artifacts are governed by rule 21.
4867

4968
After the bundled shape graduates from `--experimental` (see
5069
`TODO(rfc-013-graduation)` in `bin/forgeplan-web.mjs` and
@@ -53,10 +72,13 @@ tarball and this section will collapse to one paragraph.
5372

5473
## Required
5574

56-
- The root `package.json` must keep `dependencies` empty (or absent). It
57-
may have `devDependencies` for repo tooling (currently: `esbuild` for
58-
building `dist-experimental/`). The published tarball ships `bin/`
59-
(zero-dep), `dist/` (with its own `node_modules/`),
75+
- The root `package.json` must keep `dependencies` to **exactly** the
76+
citty entry: `{ "citty": "^0.2.2" }` (caret-minor pin per ADR-003).
77+
No other runtime deps allowed at the root. `devDependencies` for
78+
repo tooling are unconstrained by this rule (currently: `esbuild`).
79+
- The published tarball ships `bin/` (incl. `bin/cli.mjs`,
80+
`bin/commands/*.mjs`, `bin/lib/*.mjs`, `bin/banner.mjs`,
81+
`bin/forgeplan-web.mjs`), `dist/` (with its own `node_modules/`),
6082
`dist-experimental/` (single bundle, no `node_modules/`), and
6183
`README.md`.
6284
- `package.json#engines` pins Node ≥ `^20.19.0 || >=22.12.0`. Any change
@@ -67,31 +89,51 @@ tarball and this section will collapse to one paragraph.
6789
`npx @forgeplan/web init` is the user's first contact with the package.
6890
The package itself is pre-built — `init` is a `cp -r` of `dist/` into
6991
`.forgeplan-web/`, and `start` is a `spawn(node, dist/index.js)`. The bin
70-
must not pull in third-party code that npm would have to resolve before
71-
invocation; doing so would re-introduce the exact latency we removed by
72-
shipping a pre-built artifact.
92+
must not pull in arbitrary third-party code that npm would have to
93+
resolve before invocation; doing so would re-introduce the exact latency
94+
we removed by shipping a pre-built artifact. The citty exception is
95+
ADR-003's deliberate trade-off: ~5KB / ~50ms for subcommand routing,
96+
typed args, auto-help, and a future prompt hook (#111).
7397

7498
## Verification
7599

76100
```bash
77-
# bin/* must only import node:* modules or relative sibling .mjs files.
78-
# Allowed: from 'node:fs', from "./banner.mjs", from "../bin/x.mjs"
79-
# Forbidden: from 'chalk', from 'figlet', from any bare specifier.
101+
# bin/* must only import node:* modules, citty, or relative sibling .mjs files.
102+
# Allowed: from 'node:fs', from 'citty', from "./banner.mjs",
103+
# from "./lib/config.mjs", from "./commands/init.mjs"
104+
# Forbidden: from 'chalk', from 'commander', from any other bare specifier.
80105
# Also handles multi-line `import { … } from "node:fs";` because we match
81106
# the `from "…"` line itself, regardless of where the `import` keyword sits.
82-
for f in bin/*.mjs; do
107+
fail=0
108+
for f in $(find bin -name '*.mjs' -type f); do
83109
hits=$(grep -E "(from|require\()\s*['\"]" "$f" \
84-
| grep -vE "(from|require\()\s*['\"]node:" \
110+
| grep -vE "(from|require\()\s*['\"](node:|citty['\"])" \
85111
| grep -vE "(from|require\()\s*['\"]\\.{1,2}/" || true)
86112
if [ -z "$hits" ]; then
87-
echo "OK ($f): no third-party deps"
113+
echo "OK ($f): bin allow-list (node:*, citty, relative)"
88114
else
89-
echo "FAIL ($f): third-party imports found:"
115+
echo "FAIL ($f): non-allow-listed imports found:"
90116
echo "$hits"
91-
exit 1
117+
fail=1
92118
fi
93119
done
94-
95-
# root package must not declare runtime deps
96-
node -e "const p=require('./package.json'); if (p.dependencies && Object.keys(p.dependencies).length) { console.error('FAIL: runtime deps present', p.dependencies); process.exit(1)} else { console.log('OK: no runtime deps') }"
120+
[ "$fail" -eq 0 ] || exit 1
121+
122+
# root package may declare ONLY citty as runtime dep
123+
node -e "
124+
const p = require('./package.json');
125+
const deps = p.dependencies || {};
126+
const keys = Object.keys(deps).sort();
127+
const allowed = ['citty'];
128+
const extra = keys.filter((k) => !allowed.includes(k));
129+
if (extra.length) {
130+
console.error('FAIL: unexpected runtime deps', extra);
131+
process.exit(1);
132+
}
133+
if (!deps.citty) {
134+
console.error('FAIL: citty missing from dependencies (ADR-003 invariant I3)');
135+
process.exit(1);
136+
}
137+
console.log('OK: root deps = { citty:', deps.citty, '}');
138+
"
97139
```

0 commit comments

Comments
 (0)