projectstore-claude 0.29.2 → 0.29.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +27 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +14 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/harnesses/claude-code.json +18 -3
- package/node_modules/projectstore/harnesses/codex.json +12 -1
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +594 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/session-start.mjs +6 -2
- package/node_modules/projectstore/package.json +2 -1
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
- package/node_modules/projectstore/scripts/band.mjs +156 -0
- package/node_modules/projectstore/scripts/binding.mjs +157 -17
- package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
- package/node_modules/projectstore/scripts/cli.mjs +545 -179
- package/node_modules/projectstore/scripts/convergence.mjs +468 -0
- package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
- package/node_modules/projectstore/scripts/doctor.mjs +80 -62
- package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
- package/node_modules/projectstore/scripts/harness.mjs +178 -7
- package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
- package/node_modules/projectstore/scripts/kanban.mjs +4 -20
- package/node_modules/projectstore/scripts/lib.mjs +257 -58
- package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
- package/node_modules/projectstore/scripts/query.mjs +83 -12
- package/node_modules/projectstore/scripts/run-host.mjs +94 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
- package/node_modules/projectstore/scripts/stages.mjs +80 -0
- package/node_modules/projectstore/scripts/statusline.mjs +25 -2
- package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
- package/node_modules/projectstore/scripts/term.mjs +729 -39
- package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- package/package.json +2 -2
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// projectstore — fetch-shell.mjs: the shell fetch of install and upgrade.
|
|
2
|
+
//
|
|
3
|
+
// A harness whose plugin root ships only in its distribution shell (Codex:
|
|
4
|
+
// its registration surface carries `condition: "distribution_root"`) cannot
|
|
5
|
+
// be registered from the core alone. So a core run that stages fetches that
|
|
6
|
+
// shell, at the core's own version, into a scratch directory under the
|
|
7
|
+
// user-level cache, verifies it, and plans the harness from it in the same
|
|
8
|
+
// plan() as every other harness — one PLAN, one question, one APPLY (the
|
|
9
|
+
// ADR "One front door: projectstore setup installs every harness it finds;
|
|
10
|
+
// shells are release artifacts, not user commands", decision 3; the story
|
|
11
|
+
// "Install and upgrade fetch a shell-rooted harness at the bin's own version,
|
|
12
|
+
// once per run; plan and uninstall never fetch", rules 1–4 and 9).
|
|
13
|
+
//
|
|
14
|
+
// rootVersion(root) → <root>/package.json's version, or null
|
|
15
|
+
// fetchDecision({…}) → { fetch, why } — rule 1, pure
|
|
16
|
+
// fetchArgv({ prefix, registry, … }) → rule 2's npm argv, plan's and the run's
|
|
17
|
+
// npmRegistry(projectDir, { env }) → the registry npx would see there, or null
|
|
18
|
+
// npmUserRegistry({ home, env }) → the user's own registry, read from home, or null
|
|
19
|
+
// fetchShell(job, { dir, registry }) → { ok, root, ms } or { ok: false, code, refusal }
|
|
20
|
+
// verifyFetched(root, { … }) → null, or the refusal naming file and values
|
|
21
|
+
// fetchRefusal(code, ctx) → rule 4's texts
|
|
22
|
+
// sweepFetchRuns(fetchDir) → the leftovers of dead runs, removed
|
|
23
|
+
// fetchRunId() → <pid>-<epoch ms>
|
|
24
|
+
//
|
|
25
|
+
// Nothing here decides a registration's rows: the analyser and the installer
|
|
26
|
+
// do, from the fetched root this module hands them. Only install-harness.mjs
|
|
27
|
+
// imports this file; hooks, doctor, surfaces and lib never do (the
|
|
28
|
+
// portability suite asserts it). Pure node, no external deps.
|
|
29
|
+
|
|
30
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { registrationDeferred } from "./harness.mjs";
|
|
33
|
+
import { removeTreeUnder, pidAlive, whichOnPath } from "./lib.mjs";
|
|
34
|
+
import { isPortablePluginRoot, payloadManifest, payloadManifestPath, payloadManifestPaths } from "./portable-registration.mjs";
|
|
35
|
+
import { runHost, startFailure, HOST_BUDGET_MS } from "./run-host.mjs";
|
|
36
|
+
|
|
37
|
+
const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
38
|
+
|
|
39
|
+
// The version a shell-rooted harness is fetched at has one source: the
|
|
40
|
+
// package.json of the root the plan runs from — never `latest`, never a range.
|
|
41
|
+
export function rootVersion(root) {
|
|
42
|
+
const v = readJson(join(String(root || ""), "package.json"))?.version;
|
|
43
|
+
return typeof v === "string" && v ? v : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The core's own package name, which the fetched shell bundles under its
|
|
47
|
+
// node_modules (the shells ADR: the shell pins the core exactly).
|
|
48
|
+
export function rootName(root) {
|
|
49
|
+
const n = readJson(join(String(root || ""), "package.json"))?.name;
|
|
50
|
+
return typeof n === "string" && n ? n : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const STAGING_VERBS = ["install", "upgrade", "setup"];
|
|
54
|
+
|
|
55
|
+
// Rule 1, as one pure decision per harness. A run fetches a harness's shell
|
|
56
|
+
// only when every condition holds; the first that fails is `why`, which the
|
|
57
|
+
// tests read and nothing prints (the row a skipped fetch shows is the
|
|
58
|
+
// installer's, rule 1's table).
|
|
59
|
+
// verb — the run stages: install or upgrade (and setup, when it lands)
|
|
60
|
+
// harness, s — the manifest and its registration surface
|
|
61
|
+
// excluded — --no-register, or a --surface that does not name it
|
|
62
|
+
// named — a harness was named: the confirmation without a terminal
|
|
63
|
+
// interactive — a person at a terminal will be asked (isInteractive, or `ask`)
|
|
64
|
+
// analysis — a version-only analysis of the registration
|
|
65
|
+
// ownShell — the run is this harness's own shell's core (rule 8)
|
|
66
|
+
// env — the run's: a named distribution root, a session marker or a
|
|
67
|
+
// registration_defer_env variable
|
|
68
|
+
export function fetchDecision({ verb, harness, s, excluded = false, named = false, interactive = false, analysis = null, ownShell = false, env = process.env } = {}) {
|
|
69
|
+
if (!STAGING_VERBS.includes(verb)) return { fetch: false, why: "verb" };
|
|
70
|
+
if (s?.condition !== "distribution_root" || !harness?.install?.shell) return { fetch: false, why: "not-shell-rooted" };
|
|
71
|
+
if (excluded) return { fetch: false, why: "excluded" };
|
|
72
|
+
// A set root is used as is — a root or not — and nothing is fetched.
|
|
73
|
+
if (env && env.PROJECTSTORE_DISTRIBUTION_ROOT) return { fetch: false, why: "root-named" };
|
|
74
|
+
// A bare run that cannot be asked refuses at the gate; its preview is the dry run.
|
|
75
|
+
if (!named && !interactive) return { fetch: false, why: "not-interactive" };
|
|
76
|
+
// The registration defers in the harness's own session (decision 2) — by
|
|
77
|
+
// its session marker or its registration_defer_env (the setup story, rule
|
|
78
|
+
// 15) — and the fetch is skipped for a registration that will defer.
|
|
79
|
+
if (registrationDeferred(env, harness)) return { fetch: false, why: "in-session" };
|
|
80
|
+
// The host's facts alone decide these rows; a payload would change none.
|
|
81
|
+
if (["unavailable", "foreign", "conflict"].includes(analysis?.state)) return { fetch: false, why: analysis.state };
|
|
82
|
+
// A registration newer than the running version is never staged over (the
|
|
83
|
+
// converge story's rule 6): its row is the newer rung's, sound or damaged,
|
|
84
|
+
// decided from the version and the host's facts alone.
|
|
85
|
+
if (analysis?.newer) return { fetch: false, why: "newer" };
|
|
86
|
+
// No host CLI: the row defers whatever of ours exists, so a fetch — which
|
|
87
|
+
// may fail offline — must not turn a deferral into a refusal (the ADR: the
|
|
88
|
+
// fetch is skipped for a harness whose registration will defer).
|
|
89
|
+
if (!analysis?.bin) return { fetch: false, why: "no-host-cli" };
|
|
90
|
+
if (ownShell) return { fetch: false, why: "own-shell" };
|
|
91
|
+
return { fetch: true, why: null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Rule 2's argv, after `npm`. One builder for plan's dry-run step (literal
|
|
95
|
+
// <run-id> and <registry>) and the run's own fetch. --prefix hides a
|
|
96
|
+
// project's .npmrc, so only the registry is forwarded; with none resolved,
|
|
97
|
+
// npm resolves it from user config. The four --fetch-… flags bound one
|
|
98
|
+
// request to about 96 s and a refused connection to about 6 s (npm 11.19's
|
|
99
|
+
// defaults wait 10 s, then 60 s: 70.3 s measured); the run's own budget is the
|
|
100
|
+
// outer bound. --ignore-scripts: no install-time code from a registry runs.
|
|
101
|
+
export function fetchArgv({ prefix, registry = null, shell, version }) {
|
|
102
|
+
return [
|
|
103
|
+
"install", "--prefix", prefix, "--no-save", "--ignore-scripts", "--no-audit", "--no-fund", "--no-update-notifier", "--json",
|
|
104
|
+
...(registry ? ["--registry", registry] : []),
|
|
105
|
+
"--fetch-timeout=30000", "--fetch-retries=2", "--fetch-retry-mintimeout=1000", "--fetch-retry-maxtimeout=5000",
|
|
106
|
+
`${shell}@${version}`,
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Node runs a .cmd only through a shell, and a shell splits on spaces, so on
|
|
111
|
+
// win32 the bin and every argument are quoted. Unverified, as the install
|
|
112
|
+
// spec already says of Windows.
|
|
113
|
+
const WIN = process.platform === "win32";
|
|
114
|
+
const winQuote = (a) => `"${String(a).replace(/"/g, '""')}"`;
|
|
115
|
+
function npmCall(argv, env) {
|
|
116
|
+
const resolved = whichOnPath("npm", env);
|
|
117
|
+
const bin = resolved || (WIN ? "npm.cmd" : "npm");
|
|
118
|
+
return { resolved, bin: WIN ? winQuote(bin) : bin, argv: WIN ? argv.map(winQuote) : argv, shell: WIN };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The registry `npx` run in this project would see: one `npm config get
|
|
122
|
+
// registry` with the project as its working directory, so a registry its
|
|
123
|
+
// .npmrc names counts (npm walks up to the nearest package.json or
|
|
124
|
+
// node_modules), and an outer npx's npm_config_registry outranks it as npm
|
|
125
|
+
// ranks the environment above a file (measured). Null when the read fails or
|
|
126
|
+
// runs past its own short budget — a local read that hangs must not add a
|
|
127
|
+
// second 120 s to the fetch's — and the fetch then passes no --registry.
|
|
128
|
+
// --no-update-notifier: npm otherwise asks the registry about itself here too.
|
|
129
|
+
// Exported for the update notice.
|
|
130
|
+
export const REGISTRY_BUDGET_MS = 10000;
|
|
131
|
+
export async function npmRegistry(projectDir, { env = process.env, spawn, budget = REGISTRY_BUDGET_MS } = {}) {
|
|
132
|
+
const call = npmCall(["config", "get", "registry", "--no-update-notifier"], env);
|
|
133
|
+
const r = await runHost(call.bin, call.argv, { env, cwd: projectDir, budget, spawn, shell: call.shell });
|
|
134
|
+
if (r.error || r.status !== 0) return null;
|
|
135
|
+
const value = String(r.stdout || "").trim().split(/\r?\n/).pop()?.trim() || "";
|
|
136
|
+
return value && !/\s/.test(value) && value !== "undefined" ? value : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The registry the user's npm resolves outside any project (the converge
|
|
140
|
+
// story's rule 8, as amended 2026-10-09): `npm config get registry` run from
|
|
141
|
+
// the home directory, with every lowercase npm_config_* variable removed from
|
|
142
|
+
// its environment — what an outer npx injects into its child, and a user's
|
|
143
|
+
// own lowercase variable too. Only the uppercase NPM_CONFIG_* survive, with
|
|
144
|
+
// ~/.npmrc and the global npmrc. What chooses code before the question reads
|
|
145
|
+
// this one: the update notice and rule 6's read. Only the previewed shell
|
|
146
|
+
// fetch keeps the project's registry (npmRegistry, above). Null when the read
|
|
147
|
+
// fails; the caller then uses the public registry.
|
|
148
|
+
export async function npmUserRegistry({ home = null, env = process.env, spawn, budget = REGISTRY_BUDGET_MS } = {}) {
|
|
149
|
+
const clean = {};
|
|
150
|
+
for (const [k, v] of Object.entries(env || {})) if (!k.startsWith("npm_config_")) clean[k] = v;
|
|
151
|
+
return npmRegistry(home || process.cwd(), { env: clean, spawn, budget });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// npm's --json error, which it prints on stdout: { error: { code, summary,
|
|
155
|
+
// detail } }. stderr is the fallback, read for its `npm error code X` line
|
|
156
|
+
// only — npm's raw stderr, log path included, is never shown.
|
|
157
|
+
export function npmError(stdout, stderr) {
|
|
158
|
+
try {
|
|
159
|
+
const e = JSON.parse(String(stdout || ""))?.error;
|
|
160
|
+
if (e && typeof e === "object") return { code: String(e.code || "") || null, summary: String(e.summary || "").trim(), detail: String(e.detail || "").trim() };
|
|
161
|
+
} catch {}
|
|
162
|
+
const code = String(stderr || "").match(/npm (?:error|ERR!) code (\S+)/)?.[1] || null;
|
|
163
|
+
const summary = String(stderr || "").split(/\r?\n/).map((l) => l.replace(/^npm (?:error|ERR!) ?/, "").trim()).find((l) => l && !/^code \S+$/.test(l) && !/complete log of this run/i.test(l)) || "";
|
|
164
|
+
return { code, summary, detail: "" };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const seconds = (ms) => `${Number((ms / 1000).toFixed(ms % 1000 ? 1 : 0))} s`;
|
|
168
|
+
|
|
169
|
+
// Rule 4's refusals, by npm's code or ours. `ctx`: pkg ("<shell>@<v>"),
|
|
170
|
+
// shell, registry (null: npm's own), display (the harness's display name),
|
|
171
|
+
// budget, summary (npm's), cause (a start failure's), path/from/variable (the
|
|
172
|
+
// cache). Every way out is a terminal command or a variable; none names a
|
|
173
|
+
// version but the one this run is at.
|
|
174
|
+
export function fetchRefusal(code, ctx = {}) {
|
|
175
|
+
const { pkg = "the shell", shell = "the shell", registry = null, display = "this harness", budget = HOST_BUDGET_MS, summary = "", cause = "", path = null, from = null, variable = null } = ctx;
|
|
176
|
+
const where = registry || "the registry npm resolves";
|
|
177
|
+
const without = `name --harness without ${display}`;
|
|
178
|
+
switch (code) {
|
|
179
|
+
case "ETARGET":
|
|
180
|
+
case "E404":
|
|
181
|
+
return `${pkg} is not on ${where} (npm ${code}); ${display} is registered from that shell at this exact version, and no other version is tried. Just released? The shell is published after the core, so retry in a few minutes. From a checkout at an unpublished version, point PROJECTSTORE_DISTRIBUTION_ROOT at the output of \`npm run shells:build -- --dev\` (dist/build/${shell}). Or ${without}.`;
|
|
182
|
+
// Our budget, not npm's: npm's own ETIMEDOUT (a socket) is any other code.
|
|
183
|
+
case "EBUDGET":
|
|
184
|
+
return `fetching ${pkg} from ${where} took longer than ${seconds(budget)} and was stopped. Retry, or ${without}.`;
|
|
185
|
+
case "FETCH_TIMEOUT":
|
|
186
|
+
return `npm gave up on ${ctx.url || where} (network timeout) while fetching ${pkg}. Retry, or ${without}.`;
|
|
187
|
+
case "E401":
|
|
188
|
+
case "E403":
|
|
189
|
+
return `${where} refused the credentials for ${pkg} (npm ${code}). A project .npmrc's token does not reach this fetch — only its registry does — so the registry's token must be in user config: ~/.npmrc, or an NPM_CONFIG_… variable. Or point PROJECTSTORE_DISTRIBUTION_ROOT at a ${shell} root obtained another way.`;
|
|
190
|
+
case "ESTART":
|
|
191
|
+
return `${cause}; ${display} is registered from its shell, which npm fetches. Put npm on PATH, or ${without}.`;
|
|
192
|
+
case "ENOCACHE":
|
|
193
|
+
return `the shell fetch has no cache directory: ${variable} is not an absolute path and the home directory is unknown. Set ${variable} to a writable directory, then run this again.`;
|
|
194
|
+
case "ECACHE":
|
|
195
|
+
return `the cache directory ${path} cannot be written (${ctx.errno || "error"}); it comes from ${from}. Set ${variable} to a writable directory, then run this again.`;
|
|
196
|
+
default:
|
|
197
|
+
return `npm could not fetch ${pkg} from ${where}: ${code || "an unknown error"}${summary ? ` — ${summary.split(/\r?\n/)[0]}` : ""}. ${display} is registered from its shell, which needs the network: retry once the registry is reachable, or ${without}.`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Rule 3: the fetched tree is what this run plans, before it is planned —
|
|
202
|
+
// the shell's name and version, a plugin root by the analyser's own predicate,
|
|
203
|
+
// its plugin manifest's name and version, and the bundled core at the same
|
|
204
|
+
// version (the pairing the shells ADR promises: the core Codex runs is the
|
|
205
|
+
// core this release rendered). Null when it holds; otherwise the refusal,
|
|
206
|
+
// naming the file and both values. Nothing is staged either way.
|
|
207
|
+
export function verifyFetched(root, { shell, version, pluginName, coreName }) {
|
|
208
|
+
const pkg = `${shell}@${version}`;
|
|
209
|
+
const mismatch = (file, what, got, want) => `the fetched ${pkg} is not what this run plans: ${file} has ${what} ${JSON.stringify(got ?? null)}, expected ${JSON.stringify(want)} — nothing is staged`;
|
|
210
|
+
const pkgFile = join(root, "package.json");
|
|
211
|
+
const shellPkg = readJson(pkgFile);
|
|
212
|
+
if (shellPkg?.name !== shell) return mismatch(pkgFile, "name", shellPkg?.name, shell);
|
|
213
|
+
if (shellPkg?.version !== version) return mismatch(pkgFile, "version", shellPkg?.version, version);
|
|
214
|
+
if (!isPortablePluginRoot(root)) return `the fetched ${pkg} is not a plugin root: neither ${payloadManifestPaths(root).join(" nor ")} exists — nothing is staged`;
|
|
215
|
+
// Exactly the shell's one manifest, the one the host validates: a root
|
|
216
|
+
// manifest would be read first and load no hooks, so it never passes.
|
|
217
|
+
const manifestFile = payloadManifestPath(root, "legacy");
|
|
218
|
+
const rootFile = payloadManifestPath(root, "root");
|
|
219
|
+
if (existsSync(rootFile)) return `the fetched ${pkg} carries ${rootFile}, which the host reads before ${manifestFile} and loads no hooks from — nothing is staged`;
|
|
220
|
+
const manifest = payloadManifest(root, { kind: "legacy" });
|
|
221
|
+
if (!manifest) return `the fetched ${pkg} has no readable plugin manifest at ${manifestFile} — nothing is staged`;
|
|
222
|
+
if (manifest.name !== pluginName) return mismatch(manifestFile, "name", manifest.name, pluginName);
|
|
223
|
+
if (manifest.version !== version) return mismatch(manifestFile, "version", manifest.version, version);
|
|
224
|
+
const coreFile = join(root, "node_modules", coreName, "package.json");
|
|
225
|
+
const core = readJson(coreFile);
|
|
226
|
+
if (core?.version !== version) return mismatch(coreFile, "version", core?.version, version);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The fetch itself: npm, asynchronously, in `dir` (made by the caller), with
|
|
231
|
+
// the run's environment. The payload root is computed, never read from npm's
|
|
232
|
+
// output: npm's --json result printed it with one UUID-like segment redacted
|
|
233
|
+
// as *** (measured). A failure is a refusal before the plan, never a fallback.
|
|
234
|
+
export async function fetchShell({ harness, s, version, coreName }, { dir, registry = null, env = process.env, spawn, budget = HOST_BUDGET_MS } = {}) {
|
|
235
|
+
const shell = harness.install.shell;
|
|
236
|
+
const pkg = `${shell}@${version}`;
|
|
237
|
+
const ctx = { pkg, shell, registry, display: harness.display_name, budget };
|
|
238
|
+
const call = npmCall(fetchArgv({ prefix: dir, registry, shell, version }), env);
|
|
239
|
+
const t0 = Date.now();
|
|
240
|
+
const r = await runHost(call.bin, call.argv, { env, cwd: dir, budget, spawn, shell: call.shell });
|
|
241
|
+
const ms = Date.now() - t0;
|
|
242
|
+
if (r.error) {
|
|
243
|
+
if (r.error.code === "ETIMEDOUT") return { ok: false, ms, code: "ETIMEDOUT", refusal: fetchRefusal("EBUDGET", ctx) };
|
|
244
|
+
return { ok: false, ms, code: r.error.code || "ESTART", refusal: fetchRefusal("ESTART", { ...ctx, cause: startFailure(r.error, { bin: "npm", resolved: call.resolved, cwd: dir }) }) };
|
|
245
|
+
}
|
|
246
|
+
if (r.status !== 0) {
|
|
247
|
+
const e = npmError(r.stdout, r.stderr);
|
|
248
|
+
// npm reports a request it gave up on as FETCH_ERROR, "network timeout
|
|
249
|
+
// at: <url>" (measured in review, 2026-10-08), not as ETIMEDOUT.
|
|
250
|
+
const timeout = e.code === "FETCH_ERROR" && /network timeout at:\s*(\S+)/.exec(e.summary);
|
|
251
|
+
if (timeout) return { ok: false, ms, code: e.code, refusal: fetchRefusal("FETCH_TIMEOUT", { ...ctx, url: timeout[1] }) };
|
|
252
|
+
return { ok: false, ms, code: e.code || `exit ${r.status}`, refusal: fetchRefusal(e.code, { ...ctx, summary: e.summary }) };
|
|
253
|
+
}
|
|
254
|
+
const root = join(dir, "node_modules", shell);
|
|
255
|
+
const wrong = verifyFetched(root, { shell, version, pluginName: s.plugin_name, coreName });
|
|
256
|
+
if (wrong) return { ok: false, ms, code: "EVERIFY", refusal: wrong };
|
|
257
|
+
return { ok: true, ms, root };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// <pid>-<epoch ms>: the run that owns a scratch directory, by its name.
|
|
261
|
+
export function fetchRunId() {
|
|
262
|
+
return `${process.pid}-${Date.now()}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Rule 9's sweep, run by the next fetching run: every <fetch>/<pid>-<ms>
|
|
266
|
+
// whose pid is dead goes. A live pid — ours or not (EPERM) — keeps its
|
|
267
|
+
// directory, which may be a run waiting at its question for days, and any
|
|
268
|
+
// other name stays. No age rule: a reused pid keeps a leftover until a later
|
|
269
|
+
// sweep, which costs about 1.4 MB, never data. An npm still writing into a
|
|
270
|
+
// dead run's directory is caught by a later sweep: it converges.
|
|
271
|
+
export function sweepFetchRuns(fetchDir) {
|
|
272
|
+
let names;
|
|
273
|
+
try { names = readdirSync(fetchDir); } catch { return []; }
|
|
274
|
+
const removed = [];
|
|
275
|
+
for (const n of names) {
|
|
276
|
+
const m = /^(\d+)-\d+$/.exec(n);
|
|
277
|
+
if (!m) continue;
|
|
278
|
+
const pid = Number(m[1]);
|
|
279
|
+
if (!pid || pid === process.pid || pidAlive(pid)) continue;
|
|
280
|
+
try { removeTreeUnder(join(fetchDir, n), fetchDir, { maxRetries: 3 }); removed.push(n); } catch {}
|
|
281
|
+
}
|
|
282
|
+
return removed;
|
|
283
|
+
}
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
//
|
|
25
25
|
// Pure node, no external deps — same constraint as lib.mjs. Read-only.
|
|
26
26
|
|
|
27
|
-
import { readFileSync, existsSync, readdirSync, realpathSync } from "node:fs";
|
|
28
|
-
import { join, dirname, resolve, sep } from "node:path";
|
|
27
|
+
import { readFileSync, existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
28
|
+
import { join, dirname, resolve, sep, relative, isAbsolute } from "node:path";
|
|
29
29
|
import { homedir } from "node:os";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
|
|
@@ -226,25 +226,141 @@ export function resetDetection() {
|
|
|
226
226
|
// process" from branded environment variables, and using it here would
|
|
227
227
|
// conjure .claude/ for a Codex user. Not memoised: it is per directory, and a
|
|
228
228
|
// test builds a project per case.
|
|
229
|
-
|
|
229
|
+
//
|
|
230
|
+
// `state` adds the second signal `upgrade` reads: a harness whose state
|
|
231
|
+
// directory of ours exists (layoutPaths(p).harnessState(id), layout spec
|
|
232
|
+
// contract 5). A Codex session wrote its welcome marker there, so Codex is
|
|
233
|
+
// used here even with no .codex/ (measured in this repository, 2026-10-08).
|
|
234
|
+
// Never a machine-global registration: one exists for every project on the
|
|
235
|
+
// machine and would select its harness everywhere. One entry per manifest,
|
|
236
|
+
// the directory first, in manifest order as before.
|
|
237
|
+
export function detectHarnesses(projectDir, { dir = MANIFEST_DIR, state = false } = {}) {
|
|
230
238
|
const out = [];
|
|
239
|
+
const p = state ? layoutPaths(projectDir, { dir }) : null;
|
|
231
240
|
for (const m of loadHarnesses(dir).values()) {
|
|
232
241
|
const d = m.runtime?.harness_dir;
|
|
233
|
-
if (d && existsSync(join(projectDir, d))) out.push({ id: m.id, why: "directory", evidence: d });
|
|
242
|
+
if (d && existsSync(join(projectDir, d))) { out.push({ id: m.id, why: "directory", evidence: d }); continue; }
|
|
243
|
+
if (!p) continue;
|
|
244
|
+
const at = p.harnessState(m.id);
|
|
245
|
+
let isDir = false;
|
|
246
|
+
try { isDir = statSync(at).isDirectory(); } catch {}
|
|
247
|
+
if (isDir) out.push({ id: m.id, why: "state", evidence: relative(projectDir, at) });
|
|
234
248
|
}
|
|
235
249
|
return out;
|
|
236
250
|
}
|
|
237
251
|
|
|
252
|
+
// A pure PATH walk for a host binary — a read, not a subprocess: the analysers
|
|
253
|
+
// run on every doctor call. Returns the absolute path or null. It lives here,
|
|
254
|
+
// beside hostPresence, which needs it; lib.mjs re-exports it under the name
|
|
255
|
+
// it already published.
|
|
256
|
+
export function whichOnPath(name, env = process.env) {
|
|
257
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
258
|
+
const exts = process.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
|
|
259
|
+
for (const dir of String(env.PATH || "").split(sep).filter(Boolean)) {
|
|
260
|
+
for (const ext of exts) {
|
|
261
|
+
const p = join(dir, name + ext);
|
|
262
|
+
try { const st = statSync(p); if (st.isFile() && (process.platform === "win32" || (st.mode & 0o111))) return p; } catch {}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Whether a harness's host is on this MACHINE (the story "projectstore setup
|
|
269
|
+
// asks, shows one plan and installs every harness it finds, with the vault",
|
|
270
|
+
// rule 5): its registration surface's CLI found on PATH, and its home
|
|
271
|
+
// directory present — the manifest's home variable when set, else
|
|
272
|
+
// <home>/<home_default>. Not detectHarnesses, which answers for a PROJECT, and
|
|
273
|
+
// not detectHarnessId, which names the harness running this process. Setup's
|
|
274
|
+
// pre-tick is the one place machine presence counts; install keeps contract 8.
|
|
275
|
+
// Every name comes from the manifest. Read-only: a stat per PATH entry and one
|
|
276
|
+
// for the home.
|
|
277
|
+
// → { cli, home, bin, homeDir }: `cli` is the absolute path found, or null;
|
|
278
|
+
// `home` the directory when it exists, or null. `bin` and `homeDir` are
|
|
279
|
+
// what was looked for (null when the manifest names none), so a reason can
|
|
280
|
+
// name them when either is missing.
|
|
281
|
+
export function hostPresence(manifest, { env = process.env, home = homedir() } = {}) {
|
|
282
|
+
const s = Object.entries(manifest?.surfaces || {}).find(([k, x]) => !k.startsWith("_") && x?.kind === "registration" && x.supported !== false && typeof x.cli?.bin === "string" && x.cli.bin)?.[1] || null;
|
|
283
|
+
const bin = s ? s.cli.bin : null;
|
|
284
|
+
const r = manifest?.runtime || {};
|
|
285
|
+
const fromEnv = r.home_env && env ? env[r.home_env] : null;
|
|
286
|
+
const homeDir = fromEnv || (home && r.home_default ? join(home, r.home_default) : null);
|
|
287
|
+
let present = false;
|
|
288
|
+
if (homeDir) { try { present = statSync(homeDir).isDirectory(); } catch {} }
|
|
289
|
+
return { cli: bin ? whichOnPath(bin, env || {}) : null, home: present ? homeDir : null, bin, homeDir };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The agents block of a RUN (the story "One run plans the agents block once,
|
|
293
|
+
// and a bare uninstall selects every harness the project uses", rules 1–2):
|
|
294
|
+
// the files every manifest of the run names for it, in the FIRST manifest's
|
|
295
|
+
// order. The block is written to the first of them, so it lands where every
|
|
296
|
+
// harness of the run can read it, natively or through the import the run
|
|
297
|
+
// creates. One manifest gives its own list unchanged, which is what keeps a
|
|
298
|
+
// single-harness plan as it was.
|
|
299
|
+
export function commonBlockFiles(manifests) {
|
|
300
|
+
const lists = manifests.map((m) => agentsBlockRow(m)?.[1].files || []);
|
|
301
|
+
if (!lists.length) return [];
|
|
302
|
+
return lists[0].filter((f) => lists.every((l) => l.includes(f)));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// A manifest's agents-block row as [key, surface], or null: found by its
|
|
306
|
+
// format, as the installer's handlers are keyed, never by the key's name.
|
|
307
|
+
export function agentsBlockRow(manifest) {
|
|
308
|
+
return Object.entries(manifest?.surfaces || {}).find(([k, s]) => !k.startsWith("_") && s?.format === "markdown-block") || null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Rule 2, for the schema test: the manifests' lists share a file, or a run of
|
|
312
|
+
// all of them has nowhere to write the block. Null when they do; otherwise
|
|
313
|
+
// the problem, naming each manifest's list.
|
|
314
|
+
export function blockTargetProblem(manifests) {
|
|
315
|
+
const withBlock = manifests.filter((m) => agentsBlockRow(m));
|
|
316
|
+
if (commonBlockFiles(withBlock).length) return null;
|
|
317
|
+
return `the agents-block files lists share no file, so a run of every harness has no file to write the block to: ${withBlock.map((m) => `${m.id} ${JSON.stringify(agentsBlockRow(m)[1].files || [])}`).join(", ")}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
238
320
|
// The refusal when nothing is detected and nothing is named — built from the
|
|
239
|
-
// manifests, so a new harness appears in it without an edit here.
|
|
240
|
-
|
|
321
|
+
// manifests, so a new harness appears in it without an edit here. With
|
|
322
|
+
// `state` (upgrade's set) it names both signals the selection read.
|
|
323
|
+
export function harnessRefusal(projectDir, { dir = MANIFEST_DIR, state = false } = {}) {
|
|
241
324
|
const lines = [`No harness detected in ${projectDir}, and none named. Name one:`];
|
|
325
|
+
const p = state ? layoutPaths(projectDir, { dir }) : null;
|
|
242
326
|
for (const m of loadHarnesses(dir).values()) {
|
|
243
|
-
|
|
327
|
+
const by = `detected by its project directory: ${m.runtime?.harness_dir || "?"}`;
|
|
328
|
+
lines.push(` --harness ${m.id} (${m.display_name}; ${p ? `${by}, or by its state directory: ${relative(projectDir, p.harnessState(m.id))}` : by})`);
|
|
244
329
|
}
|
|
245
330
|
return lines.join("\n");
|
|
246
331
|
}
|
|
247
332
|
|
|
333
|
+
// Inside a live session of the host, its CLI and the session both rewrite the
|
|
334
|
+
// same settings files on their own schedules; the registration is planned
|
|
335
|
+
// only from a terminal outside one. The host marks its sessions in the
|
|
336
|
+
// environment (manifest runtime.session_env). It lives here, beside
|
|
337
|
+
// detection, so the installer and the shell fetch read one predicate without
|
|
338
|
+
// an import cycle.
|
|
339
|
+
export function insideHostSession(env, harness) {
|
|
340
|
+
// runtime.session_env, not detect_env: a Bash tool inside a session carries
|
|
341
|
+
// the session marker, not the plugin-root variables a hook receives
|
|
342
|
+
// (measured 2026-09-05; the critic's third pass caught the first draft
|
|
343
|
+
// keying on detect_env, which never fired in a session).
|
|
344
|
+
return (harness?.runtime?.session_env || []).some((k) => env && env[k]);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Whether a harness's registration — and the shell fetch that would feed it —
|
|
348
|
+
// defers in this environment (the setup story, rule 15: the front-door ADR's
|
|
349
|
+
// first open question, answered by the maintainer on 2026-10-08). It defers
|
|
350
|
+
// inside the harness's own session (insideHostSession, runtime.session_env),
|
|
351
|
+
// or while one of its runtime.registration_defer_env variables is set. That
|
|
352
|
+
// second list defers the registration and the fetch and nothing else:
|
|
353
|
+
// isInteractive, the layout move and session identity read session_env alone.
|
|
354
|
+
// Codex's exec tool sets CODEX_THREAD_ID in every command (measured
|
|
355
|
+
// 2026-10-05), while its session_env stays empty so the doctor skill's
|
|
356
|
+
// `upgrade --json` still moves the layout there. Keyed on the PLANNED
|
|
357
|
+
// harness's manifest: inside one host's session another host's registration
|
|
358
|
+
// does not defer.
|
|
359
|
+
export function registrationDeferred(env, harness) {
|
|
360
|
+
if (insideHostSession(env, harness)) return true;
|
|
361
|
+
return (harness?.runtime?.registration_defer_env || []).some((k) => env && env[k]);
|
|
362
|
+
}
|
|
363
|
+
|
|
248
364
|
export function activeHarness(env = process.env, dir = MANIFEST_DIR) {
|
|
249
365
|
return loadHarness(detectHarnessId(env, dir), dir) || sourceHarness(dir);
|
|
250
366
|
}
|
|
@@ -516,6 +632,34 @@ export function layoutPaths(projectDir, { harnessDir = null, dir = MANIFEST_DIR
|
|
|
516
632
|
};
|
|
517
633
|
}
|
|
518
634
|
|
|
635
|
+
// ─── The user-level cache (layout spec contract 0, the user-level class) ─
|
|
636
|
+
//
|
|
637
|
+
// What is ours on the machine but belongs to no project and no harness home:
|
|
638
|
+
// the shell fetch's scratch (<root>/fetch/<pid>-<ms>/, removed when its run
|
|
639
|
+
// ends) and the registry answer the update notice caches for a day
|
|
640
|
+
// (<root>/registry.json, named now and read by no one yet). The XDG Base
|
|
641
|
+
// Directory spec puts a cache at $XDG_CACHE_HOME when that is an absolute
|
|
642
|
+
// path and ignores a relative one; an empty one is ignored too. Otherwise it
|
|
643
|
+
// is <home>/.cache. With neither, `root` is null and the fetch refuses. This
|
|
644
|
+
// is the one place either name is spelled (contract 0's grep): `from` and
|
|
645
|
+
// `variable` carry them into a refusal's text. env and home are always
|
|
646
|
+
// parameters.
|
|
647
|
+
export function cachePaths({ env = process.env, home = homedir() } = {}) {
|
|
648
|
+
const variable = "XDG_CACHE_HOME";
|
|
649
|
+
const xdg = env ? env[variable] : null;
|
|
650
|
+
const fromEnv = typeof xdg === "string" && xdg !== "" && isAbsolute(xdg);
|
|
651
|
+
const base = fromEnv ? xdg : (home ? join(home, ".cache") : null);
|
|
652
|
+
const root = base ? join(base, "projectstore") : null;
|
|
653
|
+
return {
|
|
654
|
+
root,
|
|
655
|
+
fetch: root ? join(root, "fetch") : null,
|
|
656
|
+
fetchRun: (id) => (root ? join(root, "fetch", id) : null),
|
|
657
|
+
registry: root ? join(root, "registry.json") : null,
|
|
658
|
+
from: fromEnv ? variable : "the home directory",
|
|
659
|
+
variable,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
519
663
|
// The command a user types from a terminal for this harness (the layout ADR
|
|
520
664
|
// decision 6; layout spec contract 12): the harness's distribution shell when
|
|
521
665
|
// the manifest names one — `npx projectstore-claude install --project "…"`,
|
|
@@ -531,6 +675,17 @@ export function packageCommand(harness, verb, { version = null, args = "" } = {}
|
|
|
531
675
|
return `npx ${pkg} ${verb}${fixed}${args ? ` ${args}` : ""}`;
|
|
532
676
|
}
|
|
533
677
|
|
|
678
|
+
// The package this run was invoked as, for a command it prints to be typed
|
|
679
|
+
// next: the shell it came through (the shell's bin sets PROJECTSTORE_SHELL,
|
|
680
|
+
// and fixes the harness, so a verb that takes --harness needs none), else the
|
|
681
|
+
// core. packageCommand() above is the install family's, per harness; this one
|
|
682
|
+
// is for the verbs that name no harness (bind, status, scaffold, reconcile),
|
|
683
|
+
// which every shell passes through. `npx` runs it with no bin on PATH.
|
|
684
|
+
export function invokedAs(env = process.env) {
|
|
685
|
+
const shell = env.PROJECTSTORE_SHELL || null;
|
|
686
|
+
return { shell, cmd: shell ? `npx ${shell}` : "npx projectstore" };
|
|
687
|
+
}
|
|
688
|
+
|
|
534
689
|
// The overlay a harness reads: <project>/.projectstore/harness/<overlay>.json —
|
|
535
690
|
// the manifest's runtime.overlay, the harness id by convention (the layout ADR,
|
|
536
691
|
// decision 3). Null only when no manifest at all can be found.
|
|
@@ -552,6 +707,22 @@ export function harnessForOverlay(key, dir = MANIFEST_DIR) {
|
|
|
552
707
|
return all.find((m) => m.runtime?.overlay === key) || all.find((m) => m.id === key) || null;
|
|
553
708
|
}
|
|
554
709
|
|
|
710
|
+
// Whether a harness draws the band above the prompt (the band spec, contract
|
|
711
|
+
// 1): the one predicate for "a harness without a band", read by the generator
|
|
712
|
+
// (which keeps the module out of such a harness's tree) and by `band status`.
|
|
713
|
+
export function hasBand(manifest) {
|
|
714
|
+
return manifest?.surfaces?.band?.supported === true;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// The session id the host hands a tool process, by the manifest's name for
|
|
718
|
+
// it (runtime.session_id_env), or null where the harness names none or the
|
|
719
|
+
// process got none.
|
|
720
|
+
export function hostSessionId(env = process.env, harness = activeHarness(env)) {
|
|
721
|
+
const k = harness?.runtime?.session_id_env;
|
|
722
|
+
const v = k && env ? env[k] : null;
|
|
723
|
+
return typeof v === "string" && v ? v : null;
|
|
724
|
+
}
|
|
725
|
+
|
|
555
726
|
// A reader's fallback: the new path when it exists, else the legacy one when
|
|
556
727
|
// THAT exists, else the new path (a writer's target) — at most two existsSync
|
|
557
728
|
// calls, never a directory scan (contract 1; the SessionStart budget).
|