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
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
// and a bare one refuses. There is no --yes flag. --surface narrows the plan
|
|
31
31
|
// (by prefix, so `statusline` covers the launcher too); it confirms nothing —
|
|
32
32
|
// except that naming the statusline surface is how a user opts into it
|
|
33
|
-
// without the config flag.
|
|
33
|
+
// without the config flag. One change is never confirmed by a name: a channel
|
|
34
|
+
// switch planned with no channel answer (the converge story's rule 2) is asked
|
|
35
|
+
// at a terminal and, without one, answered `channel` — nothing is written.
|
|
34
36
|
//
|
|
35
37
|
// Surface handlers are keyed by the manifest's surfaces.<kind>.format, never
|
|
36
38
|
// by a harness id: adding a harness is adding harnesses/<id>.json, and this
|
|
@@ -49,6 +51,25 @@
|
|
|
49
51
|
// surface is then planned against the install path it produces — from npx,
|
|
50
52
|
// the package's own root is a directory the package manager collects.
|
|
51
53
|
//
|
|
54
|
+
// A SHELL-ROOTED harness (its registration conditioned on a distribution root:
|
|
55
|
+
// Codex) has its plugin root only in its distribution shell. install and
|
|
56
|
+
// upgrade fetch that shell at this root's own version before plan(), once per
|
|
57
|
+
// run (fetch-shell.mjs), and hand plan() the fetched root per harness id; plan
|
|
58
|
+
// and uninstall never fetch, and decide that registration from the host's
|
|
59
|
+
// facts and the version alone. plan() stays synchronous and pure either way;
|
|
60
|
+
// runVerb removes the run's scratch after apply() in a `finally`.
|
|
61
|
+
//
|
|
62
|
+
// Before that fetch, install and upgrade decide about the copies of ours this
|
|
63
|
+
// project already runs (convergence.mjs; the story "Setup and upgrade converge
|
|
64
|
+
// this project onto the running version…", rules 6 and 8): a copy newer than
|
|
65
|
+
// this root is never downgraded — the run stops and names the update when
|
|
66
|
+
// the registry's latest reaches it, and proceeds, saying why, when it does not
|
|
67
|
+
// — and a package root run in text says when a newer release is published.
|
|
68
|
+
// The registry is read there, at most once; plan() is handed the decision.
|
|
69
|
+
// Under the channel "git" (the keep, its rule 5) the kept copy's own plan is
|
|
70
|
+
// read there too, through its bin; plan() is handed it, and plans that
|
|
71
|
+
// harness as one host-style step that runs the copy's own upgrade.
|
|
72
|
+
//
|
|
52
73
|
// Direction: installer → surfaces ← doctor; installer → provenance ← doctor.
|
|
53
74
|
// Doctor never imports this file.
|
|
54
75
|
//
|
|
@@ -64,12 +85,15 @@ import { homedir } from "node:os";
|
|
|
64
85
|
import { randomUUID } from "node:crypto";
|
|
65
86
|
import { fileURLToPath } from "node:url";
|
|
66
87
|
import { spawnSync } from "node:child_process";
|
|
67
|
-
import { caps as termCaps, painter, icon as termIcon, duration, stepReporter, wrap,
|
|
68
|
-
import { loadHarness, loadHarnesses, harnessIds, sourceHarness, detectHarnesses, harnessRefusal, packageCommand } from "./harness.mjs";
|
|
88
|
+
import { caps as termCaps, painter, icon as termIcon, duration, stepReporter, liveLine, wrap, askApply, plain, badge as badgeText, heading, rowLines, kv, commandBlock, PLAIN } from "./term.mjs";
|
|
89
|
+
import { loadHarness, loadHarnesses, harnessIds, sourceHarness, detectHarnesses, harnessRefusal, packageCommand, insideHostSession, registrationDeferred, bundlingShellHarnessId, cachePaths, commonBlockFiles, agentsBlockRow, configPath, childEnv } from "./harness.mjs";
|
|
69
90
|
import { FOREIGN_TEXT, GRAMMAR_VERSION } from "./provenance.mjs";
|
|
70
|
-
import { analyseBlock, analyseJsonEntry, analyseStampedFile, analyseRegistration, analysePortableRegistration, analyseLayout, isOurFile, readText } from "./surfaces.mjs";
|
|
91
|
+
import { analyseBlock, analyseJsonEntry, analyseStampedFile, analyseRegistration, analysePortableRegistration, analyseLayout, analyseHarnessState, isOurFile, readText, registrationRenderRoot, registrationPaths } from "./surfaces.mjs";
|
|
71
92
|
import { payloadFiles, renderPortableCatalog } from "./portable-registration.mjs";
|
|
72
|
-
import {
|
|
93
|
+
import { rootVersion, rootName, fetchDecision, fetchArgv, npmRegistry, npmUserRegistry, REGISTRY_BUDGET_MS, fetchShell, fetchRefusal, fetchRunId, sweepFetchRuns } from "./fetch-shell.mjs";
|
|
94
|
+
import { HOST_BUDGET_MS, startFailure, runHost } from "./run-host.mjs";
|
|
95
|
+
import { runningCopies, newerCopies, lowestVersion, readLatest, decide, proceedCase, newerCopyDeferral, repairAdvice, advised, updateNotice, checksRoot, noticeRoot, updateCheckOff, channelChoices, switchConsequence, switchReason, noCopyToKeep, keepArgv, keepResult, readKeepPlan, ownUpgrade, npmRowBeside, DEFAULT_REGISTRY, NOTICE_BUDGET_MS, READ_TIMEOUT_MS } from "./convergence.mjs";
|
|
96
|
+
import { pluginRoot, channelMark, CHANNEL_SWITCH_KEY, writeFileAtomic, writeExclusiveMetadata, ensureStateDir, ensureRuntimeDir, removeAgentsBlock, replaceAgentsBlock, readConfigAt, isPluginCacheRoot, isEphemeralRoot, statusLineIsOurWiring, claudeHome, packageDigest, writeOwnTree, removeOwnTree, cmpPrecedence, importsLine, whichOnPath as whichOnPathFromLib, moveStateDir, mergeEntryLog, movePath, removeInside, statusLineScriptPath, layoutPaths, stagePortableMarketplace, finishPortableMarketplace, rollbackPortableMarketplace, removeTreeUnder, projectDirRefusal, registrationProvenance, envFlag, selectCopy, copyUpdateAdvice, takesVerbose } from "./lib.mjs";
|
|
73
97
|
|
|
74
98
|
import { GENERATOR } from "./surfaces.mjs";
|
|
75
99
|
export { GENERATOR };
|
|
@@ -107,9 +131,16 @@ const HANDLERS = {
|
|
|
107
131
|
// line slot) decides whether the exclusive launcher is written at all.
|
|
108
132
|
const KIND_ORDER = { registration: 0, shared: 1, exclusive: 2 };
|
|
109
133
|
|
|
134
|
+
// Planned once per RUN by plan(), never once per harness (the story "One run
|
|
135
|
+
// plans the agents block once, and a bare uninstall selects every harness the
|
|
136
|
+
// project uses", rule 1). `s` carries the run's common file list as `files`;
|
|
137
|
+
// `ctx.run` is every harness of the run that has a block, `ctx.harness` the
|
|
138
|
+
// first of them, `ctx.readers` set when the run names more than one, and
|
|
139
|
+
// `ctx.layout` the roster's layout name (rule 3).
|
|
110
140
|
function planAgentsBlock(ctx, key, s) {
|
|
111
141
|
const { projectDir, mode, root } = ctx;
|
|
112
|
-
const
|
|
142
|
+
const run = ctx.run || [ctx.harness];
|
|
143
|
+
const a = analyseBlock(projectDir, s, { root, layout: ctx.layout || null, readers: ctx.readers || null });
|
|
113
144
|
const { withBlock, preferred, claude, importLine, PREFERRED, FALLBACK } = a;
|
|
114
145
|
const items = [];
|
|
115
146
|
if (a.refusal) return [{ surface: key, kind: "shared", path: a.files[0].path, entry: "projectstore:agents", state: "refused", action: "refuse", reason: a.refusal }];
|
|
@@ -118,8 +149,15 @@ function planAgentsBlock(ctx, key, s) {
|
|
|
118
149
|
// A CLAUDE.md that is nothing but the import registration added is ours to
|
|
119
150
|
// delete when the block goes (ADR-002 decision 4); anything else stays.
|
|
120
151
|
const onlyImport = (text) => String(text ?? "").split("\n").every((l) => !l.trim() || l.trim() === importLine);
|
|
121
|
-
|
|
122
|
-
|
|
152
|
+
// A file the removal leaves empty is deleted when it held only the block
|
|
153
|
+
// (rule 4) — and the fallback file, as before. Keyed on the fallback alone,
|
|
154
|
+
// a run's common list (whose last entry is AGENTS.md) and Claude Code alone
|
|
155
|
+
// both left a 0-byte AGENTS.md (measured 2026-10-08).
|
|
156
|
+
const removal = (e, extra = {}) => {
|
|
157
|
+
const after = removeAgentsBlock(e.text);
|
|
158
|
+
return { surface: key, kind: "shared", path: e.path, entry: `projectstore:agents v${e.block.v}`, state: "ours-current", action: "remove", reason: null,
|
|
159
|
+
before: e.text, after, deleteIfEmpty: e.file === FALLBACK || !after.trim(), ...extra };
|
|
160
|
+
};
|
|
123
161
|
|
|
124
162
|
if (mode === "uninstall") {
|
|
125
163
|
if (!withBlock.length) return [{ surface: key, kind: "shared", path: preferred.path, entry: "projectstore:agents", state: "ours-absent", action: "skip", reason: "no block to remove" }];
|
|
@@ -139,9 +177,14 @@ function planAgentsBlock(ctx, key, s) {
|
|
|
139
177
|
// self-describing region a user can delete, where a deleted file another
|
|
140
178
|
// harness reads is silent breakage. `--surface agents_block` still removes
|
|
141
179
|
// it, which is the confirmation the gate asks for everywhere else.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
180
|
+
//
|
|
181
|
+
// "Another" means a manifest OUTSIDE this run (rule 4): a run that names
|
|
182
|
+
// every harness reading the file is the last one, and removes the block
|
|
183
|
+
// once. Excluding only the planning harness kept the block for a harness
|
|
184
|
+
// that was in the same run.
|
|
185
|
+
const inRun = new Set(run.map((m) => m?.id));
|
|
186
|
+
const outside = [...loadHarnesses().values()].filter((m) => !inRun.has(m.id));
|
|
187
|
+
const alsoRead = (file) => outside.some((m) => (agentsBlockRow(m)?.[1].files || []).includes(file));
|
|
145
188
|
for (const e of withBlock) {
|
|
146
189
|
// Naming the surface removes it regardless: `--surface agents_block`
|
|
147
190
|
// is the confirmation without a terminal, and a terminal is asked
|
|
@@ -180,7 +223,16 @@ function planAgentsBlock(ctx, key, s) {
|
|
|
180
223
|
// the import need not be one this harness can read. Codex's list is a
|
|
181
224
|
// single entry, which also made the old condition — block in PREFERRED and
|
|
182
225
|
// not in FALLBACK — impossible to satisfy, so nothing was ever cleaned up.
|
|
226
|
+
//
|
|
227
|
+
// Except a BRIDGE (rule 4: a narrowed uninstall "leaves the block and the
|
|
228
|
+
// import in place"): the import in the file a harness outside this run
|
|
229
|
+
// reads by itself, pointing at a block this run keeps for that harness.
|
|
230
|
+
// It is the only way that harness sees the block; taking it out because it
|
|
231
|
+
// is ours kept the block and blinded its reader. The source harness's
|
|
232
|
+
// own uninstall still removes its own file's import, as before.
|
|
183
233
|
const blockGone = new Set(items.filter((i) => i.action === "remove" && i.surface === key).map((i) => rel(projectDir, i.path)));
|
|
234
|
+
const blockKept = new Set(items.filter((i) => i.action === "skip" && i.surface === key).map((i) => rel(projectDir, i.path)));
|
|
235
|
+
const outsideNatives = new Set(outside.map((m) => agentsBlockRow(m)?.[1].reads_natively).filter(Boolean));
|
|
184
236
|
const union = [...new Set([...loadHarnesses().values()].flatMap((m) => m.surfaces?.agents_block?.files || []))];
|
|
185
237
|
for (const file of union) {
|
|
186
238
|
if (blockGone.has(file)) continue;
|
|
@@ -188,7 +240,8 @@ function planAgentsBlock(ctx, key, s) {
|
|
|
188
240
|
if (!e.present || e.text === null || !hasImport(e.text)) continue;
|
|
189
241
|
const ours = onlyImport(e.text);
|
|
190
242
|
const dangles = vanishing.has(importLine.slice(1));
|
|
191
|
-
|
|
243
|
+
const bridge = blockKept.has(importLine.slice(1)) && outsideNatives.has(file);
|
|
244
|
+
if ((!ours && !dangles) || bridge) continue;
|
|
192
245
|
const after = e.text.split("\n").filter((l) => l.trim() !== importLine).join("\n").replace(/^\n+/, "");
|
|
193
246
|
items.push({ surface: `${key}_import`, kind: "shared", path: join(projectDir, file), entry: importLine, state: "ours-current", action: "remove",
|
|
194
247
|
reason: ours ? `${file} holds only the import registration added` : `${importLine} points at a file this uninstall removes`,
|
|
@@ -217,8 +270,10 @@ function planAgentsBlock(ctx, key, s) {
|
|
|
217
270
|
// The distinction is what keeps a Claude-Code-only project from acquiring
|
|
218
271
|
// an AGENTS.md it never asked for, while still moving the substance the
|
|
219
272
|
// moment a harness that can only read AGENTS.md is installed.
|
|
273
|
+
// A run of several harnesses says which of them cannot read the file the
|
|
274
|
+
// block leaves (rule 1); one harness keeps its text, byte for byte.
|
|
220
275
|
items.push(removal(current, { state: "ours-stale", reason: `migrating to ${preferred.file}` }));
|
|
221
|
-
items.push({ surface: key, kind: "shared", path: preferred.path, entry, state: "ours-absent", action: preferred.present ? "add" : "create", reason: `migrated from ${current.file}`,
|
|
276
|
+
items.push({ surface: key, kind: "shared", path: preferred.path, entry, state: "ours-absent", action: preferred.present ? "add" : "create", reason: ctx.readers && !current.own ? a.reason : `migrated from ${current.file}`,
|
|
222
277
|
before: preferred.present ? preferred.text : null, after: replaceAgentsBlock(preferred.present ? preferred.text : "", a.desired) });
|
|
223
278
|
} else if (current.block.v === a.version && current.block.block === a.desired) {
|
|
224
279
|
items.push({ surface: key, kind: "shared", path: current.path, entry, state: "ours-current", action: "skip", reason: null });
|
|
@@ -249,11 +304,16 @@ function planAgentsBlock(ctx, key, s) {
|
|
|
249
304
|
// had an AGENTS.md took the block into it and created no bridge — so Claude
|
|
250
305
|
// Code, the harness that ran the install, could not see what it had just
|
|
251
306
|
// installed. `reads_natively` is the manifest's, so this is a file list, not
|
|
252
|
-
// a harness name
|
|
253
|
-
|
|
254
|
-
|
|
307
|
+
// a harness name — and it is every RUN harness's (rule 1): one run for two
|
|
308
|
+
// harnesses leaves both native files present, each holding the block or the
|
|
309
|
+
// import.
|
|
310
|
+
const natives = new Set(run.map((m) => agentsBlockRow(m)?.[1].reads_natively).filter(Boolean));
|
|
311
|
+
// The import item sits under the run's first harness, which need not be the
|
|
312
|
+
// one that reads the file: in a run of several, the reason names the reader.
|
|
313
|
+
// One harness is its own reader and keeps its text, byte for byte.
|
|
314
|
+
const reader = (file) => (ctx.readers && run.find((m) => agentsBlockRow(m)?.[1].reads_natively === file)?.display_name) || "this harness";
|
|
255
315
|
for (const e of a.files) {
|
|
256
|
-
const mustExist =
|
|
316
|
+
const mustExist = natives.has(e.file);
|
|
257
317
|
if (!blockFile || e.file === blockFile || (!e.present && !mustExist)) continue;
|
|
258
318
|
const line = `@${blockFile}`;
|
|
259
319
|
const rewrite = items.find((i) => i.action === "remove" && i.path === e.path);
|
|
@@ -263,7 +323,7 @@ function planAgentsBlock(ctx, key, s) {
|
|
|
263
323
|
const after = line + "\n" + (text.startsWith("\n") || !text.trim() ? "" : "\n") + text;
|
|
264
324
|
if (rewrite) { rewrite.after = after; rewrite.deleteIfEmpty = false; rewrite.reason += `; ${line} import added`; }
|
|
265
325
|
else items.push({ surface: `${key}_import`, kind: "shared", path: e.path, entry: line, state: "ours-absent", action: e.present ? "add" : "create",
|
|
266
|
-
reason: e.present ? `${blockFile} carries the block; ${e.file} must import it` : `${e.file} is what
|
|
326
|
+
reason: e.present ? `${blockFile} carries the block; ${e.file} must import it` : `${e.file} is what ${reader(e.file)} reads by itself; it is created to import ${blockFile}`,
|
|
267
327
|
before: e.present ? text : null, after });
|
|
268
328
|
}
|
|
269
329
|
return items;
|
|
@@ -310,9 +370,27 @@ function portableListFact(stdout, id) {
|
|
|
310
370
|
};
|
|
311
371
|
}
|
|
312
372
|
|
|
373
|
+
// A shell-rooted registration, in the order its rows are decided (the story
|
|
374
|
+
// "Install and upgrade fetch a shell-rooted harness at the bin's own version,
|
|
375
|
+
// once per run; plan and uninstall never fetch", rules 6–8):
|
|
376
|
+
// 1. a project uninstall never touches the global registration;
|
|
377
|
+
// 2. no payload and no version (a direct plan() caller), or a shell whose
|
|
378
|
+
// named root is not a plugin root: today's deferred rows, unchanged;
|
|
379
|
+
// 3. a run from the harness's own shell's core: deferred, it does not fetch;
|
|
380
|
+
// 4. foreign or conflicting: refused;
|
|
381
|
+
// 5. unavailable (the host CLI missing, nothing of ours): deferred;
|
|
382
|
+
// 6. global removal, from the host's facts — it stages nothing, so it needs
|
|
383
|
+
// no payload;
|
|
384
|
+
// 7. install and upgrade: from the fetched or named payload, or, with only
|
|
385
|
+
// the version, the dry run — every host fact decided, the payload's
|
|
386
|
+
// digest compared once a fetch brings it, and a write row's steps led by
|
|
387
|
+
// the fetch it would run.
|
|
388
|
+
// The analysis inputs are recorded privately on the row: apply()'s recheck
|
|
389
|
+
// and its read-back re-analyse with exactly those, never with the root's
|
|
390
|
+
// version or the variable as it stands by then.
|
|
313
391
|
function planPortableRegistration(ctx, key, s) {
|
|
314
|
-
const { projectDir, mode, root, home, env, harness, globalRemoval } = ctx;
|
|
315
|
-
const a = analysePortableRegistration(projectDir, s, { root, home, harness, env });
|
|
392
|
+
const { projectDir, mode, root, home, env, harness, globalRemoval, version = null, fetched = null } = ctx;
|
|
393
|
+
const a = analysePortableRegistration(projectDir, s, { root, home, harness, env, version, fetched });
|
|
316
394
|
const fill = { dir: a.paths.dir, marketplace: s.marketplace_name, id: a.id };
|
|
317
395
|
const base = {
|
|
318
396
|
surface: key,
|
|
@@ -336,39 +414,78 @@ function planPortableRegistration(ctx, key, s) {
|
|
|
336
414
|
installed_digest: a.installed?.digest || null,
|
|
337
415
|
enabled: a.enabled,
|
|
338
416
|
},
|
|
417
|
+
analysed: { payloadRoot: a.payloadRoot || null, version: a.versionOnly ? version : null },
|
|
339
418
|
};
|
|
340
|
-
// The same fact the analyser computed: no portable payload in this run. A
|
|
341
|
-
// shell that named a root which is not a plugin root is broken, and says so
|
|
342
|
-
// through `incomplete`; the core run alone simply defers.
|
|
343
|
-
if (!a.payloadRoot) {
|
|
344
|
-
if (env.PROJECTSTORE_DISTRIBUTION_ROOT) ctx.incomplete = true;
|
|
345
|
-
return [{ ...base, action: "skip", deferred: true, reason: env.PROJECTSTORE_DISTRIBUTION_ROOT
|
|
346
|
-
? `${env.PROJECTSTORE_DISTRIBUTION_ROOT} is not a portable plugin root; ${harness.display_name}'s registration runs only from its built distribution shell`
|
|
347
|
-
: `the core package is not ${harness.display_name}'s plugin root; registration runs only from its built distribution shell` }];
|
|
348
|
-
}
|
|
349
419
|
if (mode === "uninstall" && !globalRemoval) {
|
|
350
420
|
return [{ ...base, action: "skip", reason: "the plugin package and cache are global; project uninstall removes only project-owned surfaces. Use uninstall --global with an explicit harness to preview global removal" }];
|
|
351
421
|
}
|
|
422
|
+
// No payload and no version: a direct plan() caller, which keeps today's
|
|
423
|
+
// deferral. A shell that named a root which is not a plugin root is broken,
|
|
424
|
+
// and says so through `incomplete`, as before — except for a removal,
|
|
425
|
+
// which needs no payload at all.
|
|
426
|
+
const namedRoot = env.PROJECTSTORE_DISTRIBUTION_ROOT || null;
|
|
427
|
+
if (!a.payloadRoot && (!a.versionOnly || (namedRoot && mode !== "uninstall"))) {
|
|
428
|
+
if (namedRoot) ctx.incomplete = true;
|
|
429
|
+
return [{ ...base, action: "skip", deferred: true, reason: namedRoot
|
|
430
|
+
? `${namedRoot} is not a portable plugin root; ${harness.display_name}'s registration runs only from its built distribution shell`
|
|
431
|
+
: `the core package is not ${harness.display_name}'s plugin root; registration runs only from its built distribution shell` }];
|
|
432
|
+
}
|
|
433
|
+
// Rule 8: the core a shell bundles — the doctor skill's copy in the host's
|
|
434
|
+
// plugin cache, an npx copy or a global install of the shell, recognised by
|
|
435
|
+
// the shell's package name and never by a path — keeps today's deferral. A
|
|
436
|
+
// fetch there would change the host's home from inside a session (the
|
|
437
|
+
// front-door ADR's first open question; such a session is marked only by
|
|
438
|
+
// registration_defer_env, which defers the registration below, and only
|
|
439
|
+
// when the host's exec tool sets it), and needs the network inside its
|
|
440
|
+
// sandbox. Only this harness's own shell counts.
|
|
441
|
+
if (!a.payloadRoot && bundlingShellHarnessId({ core: root }) === harness.id) {
|
|
442
|
+
return [{ ...base, action: "skip", deferred: true, reason: "this run is from the plugin's own copy, which does not fetch" }];
|
|
443
|
+
}
|
|
352
444
|
if (["foreign", "conflict"].includes(a.state)) return [{ ...base, action: "refuse", reason: a.refusal }];
|
|
353
445
|
if (a.state === "unavailable") { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true }]; }
|
|
354
446
|
if (mode === "uninstall") {
|
|
355
447
|
if (a.state === "absent") return [{ ...base, action: "skip", reason: "no global registration of ours" }];
|
|
356
448
|
if (!a.bin) { ctx.incomplete = true; return [{ ...base, action: "skip", reason: `\`${s.cli.bin}\` is not on PATH; global registration is left untouched` }]; }
|
|
449
|
+
// The removal runs the same host CLI against the same home as an install,
|
|
450
|
+
// so it defers where an install would (the setup story, rule 15), with
|
|
451
|
+
// the host's checks above decided first, as the host plugin
|
|
452
|
+
// registration's removal decides them.
|
|
453
|
+
if (registrationDeferred(env, harness)) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: deferralReason(harness, env) }]; }
|
|
357
454
|
const steps = [];
|
|
358
455
|
if (a.installed) steps.push(hostStep(a, s, "uninstall", fill, `explicit global removal forgets ${a.id}`));
|
|
359
456
|
if (a.market && s.cli.commands.marketplace_remove) steps.push(hostStep(a, s, "marketplace_remove", fill, `explicit global removal forgets marketplace ${s.marketplace_name}`));
|
|
360
457
|
if (a.ownership) steps.push({ kind: "portable-remove", path: a.paths.dir, homeBase: a.paths.home, why: "the stable marketplace source is owned by this installer" });
|
|
361
458
|
return [{ ...base, action: "remove", steps, reason: "explicit global removal; project overrides are preserved" }];
|
|
362
459
|
}
|
|
363
|
-
|
|
460
|
+
// A registration newer than this run (the converge story's rule 6): the
|
|
461
|
+
// rows name both versions and, when the run proceeds, which case it was —
|
|
462
|
+
// the registry's latest below it, no answer, the check switched off, or for
|
|
463
|
+
// plan's dry run that install and upgrade read the registry first.
|
|
464
|
+
const newerCase = a.newer ? proceedCase(ctx.convergence, a.ownership.version) : null;
|
|
465
|
+
// An unchanged row carries no note by default (presentation spec contract
|
|
466
|
+
// 7); that its payload digest waits for a fetch is said under --verbose.
|
|
467
|
+
if (a.state === "current") return [{ ...base, action: "skip", reason: newerCase ? `${a.reason}; ${newerCase}` : a.reason, ...(a.versionOnly ? { digestDeferred: true } : {}) }];
|
|
468
|
+
// A newer registration that is damaged keeps its real reason and is never
|
|
469
|
+
// staged over with this run's older payload: it is repaired from its own
|
|
470
|
+
// version. Nothing of the host's runs, so the run is incomplete.
|
|
471
|
+
if (a.newer) {
|
|
472
|
+
ctx.incomplete = true;
|
|
473
|
+
return [{ ...base, action: "skip", deferred: true, ...advised(`${a.reason}${newerCase ? `; ${newerCase}` : ""}`, [repairAdvice(harness, a.ownership.version)]) }];
|
|
474
|
+
}
|
|
364
475
|
if (!a.bin) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: `\`${s.cli.bin}\` is not on PATH; no registration mutation was attempted` }]; }
|
|
476
|
+
// Decision 2 of the front-door ADR: the registration defers in the planned
|
|
477
|
+
// harness's own session, as the host-plugin registration does — by its
|
|
478
|
+
// session marker, or by its runtime.registration_defer_env (Codex's
|
|
479
|
+
// session_env is empty by design; the setup story, rule 15).
|
|
480
|
+
if (registrationDeferred(env, harness)) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: `${a.reason ? a.reason + "; " : ""}${deferralReason(harness, env)}` }]; }
|
|
365
481
|
const steps = [];
|
|
366
482
|
const digest = a.desiredDigest;
|
|
367
483
|
const mustWrite = !a.ownership || a.ownership.version !== a.desiredVersion || a.contentDiffers || a.state === "stale";
|
|
368
484
|
if (mustWrite) {
|
|
369
|
-
|
|
485
|
+
// Version-only, the payload is counted after the fetch: no listing, no root.
|
|
486
|
+
const files = a.versionOnly ? null : payloadFiles(a.payloadRoot);
|
|
370
487
|
const ownership = { [s.provenance_key]: { grammar: GRAMMAR_VERSION, version: a.desiredVersion, generator: GENERATOR, digest } };
|
|
371
|
-
steps.push({ kind: "portable-write", path: a.paths.dir, from: a.payloadRoot, files, subdir: s.plugin_subdir, catalogRel: s.manifest, catalog: renderPortableCatalog(s), ownershipRel: s.ownership_manifest, ownership, why: a.ownership ? `stage ${a.desiredVersion} over ${a.ownership.version}` : `stage ${a.desiredVersion}` });
|
|
488
|
+
steps.push({ kind: "portable-write", path: a.paths.dir, from: a.payloadRoot || null, files, subdir: s.plugin_subdir, catalogRel: s.manifest, catalog: renderPortableCatalog(s), ownershipRel: s.ownership_manifest, ownership, why: a.ownership ? `stage ${a.desiredVersion} over ${a.ownership.version}` : `stage ${a.desiredVersion}` });
|
|
372
489
|
}
|
|
373
490
|
if (!a.market) steps.push(hostStep(a, s, "marketplace_add", fill, "register the stable local marketplace source globally"));
|
|
374
491
|
if (!a.installed || a.installedVersion !== a.desiredVersion || mustWrite) steps.push(hostStep(a, s, "install", fill, `materialise and enable ${a.id} from the staged source`));
|
|
@@ -382,6 +499,14 @@ function planPortableRegistration(ctx, key, s) {
|
|
|
382
499
|
if (steps.some((x) => x.kind === "portable-write" || (x.kind === "host" && x.name !== "list"))) {
|
|
383
500
|
steps.unshift({ ...hostStep(a, s, "list", fill, "check that the host can report what it installs, before anything changes"), name: "preflight" });
|
|
384
501
|
}
|
|
502
|
+
// The dry run's first step is the fetch install or upgrade would run (rule
|
|
503
|
+
// 6), spelled by the run's own argv builder. plan spawns nothing, so the
|
|
504
|
+
// run id and the registry are literal placeholders, and the cache path is
|
|
505
|
+
// resolved but never made.
|
|
506
|
+
if (a.versionOnly && harness.install?.shell) {
|
|
507
|
+
const prefix = cachePaths({ env, home }).fetchRun("<run-id>") || "<cache>/fetch/<run-id>";
|
|
508
|
+
steps.unshift({ kind: "fetch", bin: "npm", argv: fetchArgv({ prefix, registry: "<registry>", shell: harness.install.shell, version }), why: `install and upgrade fetch ${harness.install.shell} at this version first; its payload is counted and its digest compared after the fetch` });
|
|
509
|
+
}
|
|
385
510
|
return [{ ...base, action: a.state === "absent" ? "create" : "update", steps, verify: { version: a.desiredVersion, digest }, reason: a.reason }];
|
|
386
511
|
}
|
|
387
512
|
|
|
@@ -400,14 +525,18 @@ export function registrationManifest(s, { pkg, projectDir, disabled = [], digest
|
|
|
400
525
|
|
|
401
526
|
// Inside a live session of the host, its CLI and the session both rewrite the
|
|
402
527
|
// same settings files on their own schedules; the registration is planned
|
|
403
|
-
// only from a terminal outside one
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
528
|
+
// only from a terminal outside one (insideHostSession, harness.mjs).
|
|
529
|
+
const inSessionReason = (harness) => `this runs inside a ${harness.display_name} session, whose exit rewrites the same settings files the host's CLI writes — run it from a terminal outside the session`;
|
|
530
|
+
|
|
531
|
+
// Why a registration defers (registrationDeferred, harness.mjs). The session's
|
|
532
|
+
// own marker keeps the measured reason above, byte for byte. A
|
|
533
|
+
// registration_defer_env variable alone names itself instead: what such a
|
|
534
|
+
// session does to the host's configuration is not measured, only that the
|
|
535
|
+
// session is there (the setup story, rule 15).
|
|
536
|
+
function deferralReason(harness, env) {
|
|
537
|
+
if (insideHostSession(env, harness)) return inSessionReason(harness);
|
|
538
|
+
const variable = (harness?.runtime?.registration_defer_env || []).find((k) => env && env[k]);
|
|
539
|
+
return `this runs inside a ${harness.display_name} session (${variable} is set), and the host's CLI would change the configuration that session runs on — run it from a terminal outside the session`;
|
|
411
540
|
}
|
|
412
541
|
|
|
413
542
|
function planRegistration(ctx, key, s) {
|
|
@@ -420,14 +549,17 @@ function planRegistration(ctx, key, s) {
|
|
|
420
549
|
const fill = { dir: a.paths.dir, marketplace: s.marketplace_name, id, other: id };
|
|
421
550
|
const notOnPath = `\`${binName}\` is not on PATH — the registration is left as it is; put the host's CLI on PATH and run install again`;
|
|
422
551
|
const named = (ctx.surfaces || []).includes(key);
|
|
423
|
-
|
|
552
|
+
// The registration defers by the planned harness's session marker or its
|
|
553
|
+
// registration_defer_env (registrationDeferred; the setup story, rule 15).
|
|
554
|
+
const deferred = registrationDeferred(env, harness);
|
|
555
|
+
const inSession = deferred ? deferralReason(harness, env) : null;
|
|
424
556
|
const other = (o) => ({ surface: `${key}_others`, kind: "registration", path: a.paths.projectSettings, entry: o.key, state: "enabled", home: base.home, scope: base.scope });
|
|
425
557
|
|
|
426
558
|
if (mode === "uninstall") {
|
|
427
559
|
if (a.state === "unavailable" || a.state === "absent") return [{ ...base, action: "skip", reason: a.state === "absent" ? a.reason : a.reason }];
|
|
428
560
|
if (a.state === "foreign") return [{ ...base, action: "refuse", reason: a.refusal }];
|
|
429
561
|
if (!bin) { ctx.incomplete = true; return [{ ...base, action: "skip", reason: notOnPath }]; }
|
|
430
|
-
if (
|
|
562
|
+
if (deferred) { ctx.incomplete = true; return [{ ...base, action: "skip", reason: inSession }]; }
|
|
431
563
|
const steps = [];
|
|
432
564
|
if (a.installed) steps.push(hostStep(a, s, "uninstall", fill, `the host forgets ${id} for this checkout (its row and enablement; other checkouts keep theirs)`));
|
|
433
565
|
const last = a.otherProjects === 0;
|
|
@@ -441,6 +573,10 @@ function planRegistration(ctx, key, s) {
|
|
|
441
573
|
// manifest says what install silenced somewhere; a copy the user disabled by
|
|
442
574
|
// hand in another checkout is not ours to turn on (reviewer, 2026-09-05).
|
|
443
575
|
for (const o of a.dir.disabled.filter((k) => a.disabledHere.includes(k))) steps.push(hostStep(a, s, "enable", { ...fill, other: o }, `${o} was silenced for this checkout by install; it is turned back on`));
|
|
576
|
+
// The mark goes with the registration (the converge story's rule 4): the
|
|
577
|
+
// binding stops recording that this checkout chose the npm install. After
|
|
578
|
+
// the host's steps, so a failed one leaves the record as it was.
|
|
579
|
+
if (ctx.binding?.hasKey) steps.push({ kind: "unmark", path: ctx.binding.path, key: CHANNEL_SWITCH_KEY, why: "the switch goes with the registration: this checkout's binding stops recording that it chose the npm install" });
|
|
444
580
|
if (last && a.dir.present) steps.push({ kind: "remove", path: a.paths.dir, why: "our marketplace directory, removed whole (its manifest carries our provenance field); no other checkout is installed from it" });
|
|
445
581
|
return [{ ...base, action: "remove", steps, reason: a.otherProjects ? `${a.otherProjects} other checkout(s) are installed from the shared directory; it and the host's marketplace entry stay` : a.reason }];
|
|
446
582
|
}
|
|
@@ -457,10 +593,42 @@ function planRegistration(ctx, key, s) {
|
|
|
457
593
|
const items = [];
|
|
458
594
|
const needsHost = a.state !== "current" || a.others.length > 0;
|
|
459
595
|
if (needsHost && !bin) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: `${a.reason ? a.reason + "; " : ""}${notOnPath}` }]; }
|
|
460
|
-
if (needsHost &&
|
|
596
|
+
if (needsHost && deferred) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: `${a.reason ? a.reason + "; " : ""}${inSession}` }]; }
|
|
597
|
+
// Our directory newer than this package and damaged (the converge story's
|
|
598
|
+
// rule 6): never rewritten from the older package — this checkout and every
|
|
599
|
+
// other would then update down to it. It is repaired from its own version;
|
|
600
|
+
// nothing of the host's runs, and the run is incomplete.
|
|
601
|
+
if (a.newer && a.dir.digestOk === false) {
|
|
602
|
+
ctx.incomplete = true;
|
|
603
|
+
const newerCase = proceedCase(ctx.convergence, a.dir.pkg);
|
|
604
|
+
return [{ ...base, action: "skip", deferred: true, ...advised(`the directory holds ${a.dir.pkg}, newer than this package (${a.pkg}), and its payload does not match its digest (edited by hand or half-written)${newerCase ? `; ${newerCase}` : ""}`, [repairAdvice(harness, a.dir.pkg)]) }];
|
|
605
|
+
}
|
|
606
|
+
// A copy from another marketplace newer than this package (rule 6), under
|
|
607
|
+
// no channel answer: switching it off would run the project on an older
|
|
608
|
+
// package, and the git channel serves main, often ahead of latest. The
|
|
609
|
+
// registration and every switch defer; no host command runs. The reason and
|
|
610
|
+
// its ways out print once, under the registration; each switch row points
|
|
611
|
+
// there.
|
|
612
|
+
// Newer than what the project would run with the copy off — the shared
|
|
613
|
+
// directory's version when another checkout wrote a newer one — not merely
|
|
614
|
+
// newer than this package: under that, turning it off is no downgrade.
|
|
615
|
+
const newerOther = a.others.filter((o) => typeof o.version === "string" && cmpPrecedence(o.version, a.targetPkg || a.pkg) > 0).sort((x, y) => cmpPrecedence(y.version, x.version))[0];
|
|
616
|
+
if (newerOther && (ctx.channel ?? null) === null) {
|
|
617
|
+
ctx.incomplete = true;
|
|
618
|
+
const deferral = newerCopyDeferral({ copy: newerOther, running: a.pkg, decision: ctx.convergence, harness, s });
|
|
619
|
+
return [{ ...base, action: "skip", deferred: true, ...deferral }, ...a.others.map((o) => ({ ...other(o), action: "skip", deferred: true, reason: `not switched off — see ${id} above` }))];
|
|
620
|
+
}
|
|
621
|
+
// What the mark (rule 2) is planned from, after the harness's rows: the
|
|
622
|
+
// switches this run plans, and contract 13's silenced set — the copies this
|
|
623
|
+
// registration turned off that this checkout still holds off — for a
|
|
624
|
+
// registration this checkout holds.
|
|
625
|
+
ctx.channelFacts = { switches: a.others.map((o) => o.key), silenced: ["current", "stale"].includes(a.state) ? a.dir.disabled.filter((k) => a.disabledHere.includes(k)) : [] };
|
|
461
626
|
|
|
462
627
|
if (a.state === "current") {
|
|
463
|
-
|
|
628
|
+
// A directory and row newer than this package: rung 12, not downgraded,
|
|
629
|
+
// and — when the run proceeds — which case it was (rule 6).
|
|
630
|
+
const newerCase = a.newer ? proceedCase(ctx.convergence, a.dir.pkg) : null;
|
|
631
|
+
items.push({ ...base, action: "skip", ...(newerCase ? { reason: `${a.reason}; ${newerCase}` } : {}) });
|
|
464
632
|
} else {
|
|
465
633
|
const steps = [];
|
|
466
634
|
// The directory is rewritten when it is missing, older than this package or
|
|
@@ -489,15 +657,193 @@ function planRegistration(ctx, key, s) {
|
|
|
489
657
|
// A competing enabled registration of the same plugin is silenced for THIS
|
|
490
658
|
// checkout only, as a precaution the preview names (whether two enabled copies
|
|
491
659
|
// load twice is the live test's row). Our manifest records it for uninstall.
|
|
660
|
+
//
|
|
661
|
+
// That is the channel switch (the converge story's rule 2): its row says
|
|
662
|
+
// what it means, as the consequence line — a `note` step after the host
|
|
663
|
+
// step, so the JSON item keeps its keys, drawn first beneath the row
|
|
664
|
+
// (presentation spec contract 7). Under no channel answer it is asked, never
|
|
665
|
+
// silent: without a terminal the gate answers `channel` (confirm()). Under
|
|
666
|
+
// the channel "npm" the row also names a newer copy's versions and case
|
|
667
|
+
// (rule 6), and that an unbound project's switch is recorded once it is
|
|
668
|
+
// bound — words only that channel adds, so the null row keeps its shape.
|
|
492
669
|
for (const o of a.others) {
|
|
493
670
|
const steps = [];
|
|
494
671
|
if (a.state === "current" && !a.dir.disabled.includes(o.key)) steps.push({ kind: "write", path: a.paths.dir, files: 0, manifestOnly: true, manifest: { ...a.dir.manifest, [s.provenance_key]: { ...a.dir.prov, disabled: [...a.dir.disabled, o.key] } }, why: "our manifest records what install silences, so uninstall can turn it back on" });
|
|
495
672
|
steps.push(hostStep(a, s, "disable", { ...fill, other: o.key }, `${o.key} (${o.version}) is enabled for this checkout too; two enabled copies of one plugin would load twice — silenced in this checkout's local settings only, never globally`));
|
|
496
|
-
|
|
673
|
+
steps.push(consequenceStep(switchConsequence(harness)));
|
|
674
|
+
const reason = ctx.channel === "npm" ? switchReason({ copy: o, running: a.pkg, runs: a.targetPkg, decision: ctx.convergence, unbound: !ctx.binding?.bound }) : null;
|
|
675
|
+
items.push({ ...other(o), action: "disable", ...(reason ? { reason } : {}), steps });
|
|
497
676
|
}
|
|
498
677
|
return items;
|
|
499
678
|
}
|
|
500
679
|
|
|
680
|
+
// A row's consequence (presentation spec contract 7) carried as a `note` step:
|
|
681
|
+
// the envelope reads `{ kind: "note", why }`, as any note; PLAN draws it first
|
|
682
|
+
// beneath its row, in the explanation role, by the mark kept off the envelope.
|
|
683
|
+
function consequenceStep(why) {
|
|
684
|
+
const st = { kind: "note", why };
|
|
685
|
+
Object.defineProperty(st, "consequence", { value: true, enumerable: false });
|
|
686
|
+
return st;
|
|
687
|
+
}
|
|
688
|
+
const isConsequence = (st) => st?.kind === "note" && st.consequence === true;
|
|
689
|
+
// A channel switch: a copy from another marketplace turned off for this checkout.
|
|
690
|
+
const isSwitch = (i) => String(i?.surface || "").endsWith("_others") && i.action === "disable";
|
|
691
|
+
|
|
692
|
+
// The channel "git" (the converge story's rules 1 and 5), for the first
|
|
693
|
+
// harness of the run with a host plugin registration — the channel concerns
|
|
694
|
+
// that registration alone; Codex has none. The copy kept is contract 12's
|
|
695
|
+
// selection (lib's selectCopy), limited to copies from another marketplace —
|
|
696
|
+
// the git marketplace, a fork's — so the npm registration's copy is never the
|
|
697
|
+
// kept one. None enabled for this project is a refusal: there is nothing to
|
|
698
|
+
// keep. A copy of ours this checkout's local settings hold off — a hand
|
|
699
|
+
// removal of the npm row leaves the git copy off — is named with the way to
|
|
700
|
+
// turn it back on. Read-only; plan() and the prepare step both call it, so the
|
|
701
|
+
// refusal is decided before any shell fetch.
|
|
702
|
+
// → null: no harness of the run has a host plugin registration
|
|
703
|
+
// → { harness, key, s, copy: null, refusal }: nothing to keep
|
|
704
|
+
// → { harness, key, s, copy }: the copy, with selectCopy's capability read
|
|
705
|
+
function keepTarget(projectDir, ids, { root, home, env }) {
|
|
706
|
+
for (const id of ids) {
|
|
707
|
+
const harness = loadHarness(id);
|
|
708
|
+
const row = Object.entries(harness?.surfaces || {}).find(([k, s]) => !k.startsWith("_") && s?.format === "host-plugin-registration");
|
|
709
|
+
if (!row) continue;
|
|
710
|
+
const [key, s] = row;
|
|
711
|
+
const copy = selectCopy(home, projectDir, { harness, only: "marketplace" });
|
|
712
|
+
if (copy) return { harness, key, s, copy };
|
|
713
|
+
const a = analyseRegistration(projectDir, s, { root, home, harness, env });
|
|
714
|
+
const off = (a.disabledHere || []).filter((k) => k !== a.id && k.split("@")[0] === s.plugin_name);
|
|
715
|
+
return { harness, key, s, copy: null, refusal: noCopyToKeep({ harness, surface: key, off }) };
|
|
716
|
+
}
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// The environment the kept copy's bin runs in, for its plan's read and for
|
|
721
|
+
// the keep step: the run's own, through childEnv for who called, then the
|
|
722
|
+
// KEPT harness's own runtime names from its manifest — never the caller's
|
|
723
|
+
// active harness's, which from a Codex terminal would name Codex's: the
|
|
724
|
+
// copy's root as its plugin root, so the child renders from itself; the
|
|
725
|
+
// project; and its home pinned, as every host command's is, so the child reads
|
|
726
|
+
// the home this run read and never another. Every other manifest's plugin-root
|
|
727
|
+
// variable is dropped: the child is that one harness's copy.
|
|
728
|
+
function keepEnv(env, { harness, projectDir, copyRoot, harnessHome }) {
|
|
729
|
+
const out = childEnv(env);
|
|
730
|
+
for (const m of loadHarnesses().values()) if (m.runtime?.plugin_root_env) delete out[m.runtime.plugin_root_env];
|
|
731
|
+
const r = harness.runtime || {};
|
|
732
|
+
if (r.plugin_root_env) out[r.plugin_root_env] = copyRoot;
|
|
733
|
+
if (r.project_dir_env) out[r.project_dir_env] = projectDir;
|
|
734
|
+
if (r.home_env) out[r.home_env] = harnessHome;
|
|
735
|
+
return out;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// The surface key the keep's one item carries (kind `channel`).
|
|
739
|
+
const KEEP_SURFACE = "channel";
|
|
740
|
+
|
|
741
|
+
// The keep (the converge story's rule 5): this project goes on loading the
|
|
742
|
+
// git-marketplace copy, and that copy renders its own surfaces here. One item
|
|
743
|
+
// of kind `channel` under the harness, in place of every row of its own —
|
|
744
|
+
// registration, competing copies, status line, launcher, host-managed report.
|
|
745
|
+
// Its one step is host-style: the copy's bin in the layout remedy's form
|
|
746
|
+
// (keepArgv), its touches the writes the copy's own plan listed (`read`, made
|
|
747
|
+
// by the prepare step) less the agents block's rows, which this run's one
|
|
748
|
+
// block item writes before it (the story "One run plans the agents block
|
|
749
|
+
// once…", rule 1). No write left: no item. The other rows it can be:
|
|
750
|
+
// - a copy that cannot run the form (lib's capability read): skip, with the
|
|
751
|
+
// update in its channel — in the row's reason, since DONE never prints when
|
|
752
|
+
// nothing else writes, and as DONE's next line;
|
|
753
|
+
// - a plan that printed no result: skip, naming the copy's own upgrade, which
|
|
754
|
+
// the person runs with its own preview — never run unpreviewed;
|
|
755
|
+
// - a plan that refused: a refuse row, and each refusal on the plan's, led by
|
|
756
|
+
// the copy's key and version;
|
|
757
|
+
// - inside the harness's own session: skip, deferred, as the registration is;
|
|
758
|
+
// - a copy plan that left work for later and lists no write (its layout move,
|
|
759
|
+
// deferred inside a session): skip, deferred, naming what it deferred — the
|
|
760
|
+
// run never reads as converged while the copy has work pending;
|
|
761
|
+
// - a run narrowed by `--surface`: skip — the copy's upgrade takes no
|
|
762
|
+
// --surface (naming the status line would opt it in), so a narrowed run
|
|
763
|
+
// leaves this harness's surfaces to it.
|
|
764
|
+
// This checkout's npm row enabled beside the copy stays, and is named with its
|
|
765
|
+
// uninstall (rule 4), in the row and in DONE.
|
|
766
|
+
function planKeep(ctx, target, read) {
|
|
767
|
+
const { projectDir, env, home, root, harness, surfaces } = ctx;
|
|
768
|
+
const { key, s, copy } = target;
|
|
769
|
+
const a = analyseRegistration(projectDir, s, { root, home, harness, env });
|
|
770
|
+
const beside = a.installed?.present && a.enabled ? npmRowBeside({ harness, id: a.id, surface: key }) : null;
|
|
771
|
+
const besides = beside ? [beside] : [];
|
|
772
|
+
const named = `${copy.key} ${copy.version || "(no version recorded)"}`;
|
|
773
|
+
// Built whole, never spread into place: `next` (what DONE says) and `kept`
|
|
774
|
+
// (DONE's fact and its tip: the copy, and whether its CLI takes --verbose)
|
|
775
|
+
// stay off the envelope as properties that are not enumerable.
|
|
776
|
+
const item = (fields, next = []) => {
|
|
777
|
+
const it = { harness: harness.id, surface: KEEP_SURFACE, kind: "channel", path: copy.path, entry: copy.key, state: "kept", home: claudeHome(home), scope: copy.scope || null, ...fields };
|
|
778
|
+
const all = [...next, ...besides];
|
|
779
|
+
if (all.length) Object.defineProperty(it, "next", { value: all, enumerable: false });
|
|
780
|
+
Object.defineProperty(it, "kept", { value: { key: copy.key, version: copy.version || null, bin: copy.missing ? null : copy.bin, verbose: !copy.missing && takesVerbose(copy.path) }, enumerable: false });
|
|
781
|
+
return it;
|
|
782
|
+
};
|
|
783
|
+
const kept = `this project keeps ${named} from the git marketplace, and that copy writes its own ${harness.display_name} surfaces here — no registration changes`;
|
|
784
|
+
if (surfaces && surfaces.length) {
|
|
785
|
+
return [item({ action: "skip", ...advised(`${kept}; ${named}'s own upgrade takes no --surface, so a run narrowed by --surface leaves these surfaces to it — run without --surface to converge them`, besides) })];
|
|
786
|
+
}
|
|
787
|
+
if (copy.missing) {
|
|
788
|
+
ctx.incomplete = true;
|
|
789
|
+
const advice = copyUpdateAdvice(copy, { cannot: copy.missing === "bin" ? "predates the move and cannot write this project's surfaces itself" : "predates --no-register and cannot write this project's surfaces without registering", then: "then run this again" });
|
|
790
|
+
return [item({ action: "skip", deferred: true, ...advised(advice, besides) }, [{ lead: advice }])];
|
|
791
|
+
}
|
|
792
|
+
if (!read) {
|
|
793
|
+
ctx.incomplete = true;
|
|
794
|
+
return [item({ action: "skip", deferred: true, ...advised(`${kept}; install and upgrade read that copy's own plan first, and this plan did not`, besides) })];
|
|
795
|
+
}
|
|
796
|
+
if (!read.envelope) {
|
|
797
|
+
ctx.incomplete = true;
|
|
798
|
+
const how = read.error || (read.status !== null && read.status !== undefined ? `it exited ${read.status}` : "it printed nothing");
|
|
799
|
+
const said = read.stderr ? `: ${read.stderr.split("\n")[0]}` : "";
|
|
800
|
+
const own = ownUpgrade(copy, harness);
|
|
801
|
+
return [item({ action: "skip", deferred: true, ...advised(`${named}'s own plan printed no result to read (${how}${said}), so nothing is planned for it.`, [own, ...besides]) }, [own])];
|
|
802
|
+
}
|
|
803
|
+
if (!read.ok) {
|
|
804
|
+
const quoted = read.refusals.length ? read.refusals : ["its plan refused and named no reason"];
|
|
805
|
+
ctx.refusals.push(...quoted.map((r) => `${named}: ${r}`));
|
|
806
|
+
return [item({ state: "refused", action: "refuse", reason: `the kept copy's own plan refused, so nothing is written: ${quoted.join("; ")}`, advice: { text: `${named}'s own plan refused (below), so nothing is written`, blocks: [] } })];
|
|
807
|
+
}
|
|
808
|
+
const block = agentsBlockRow(harness);
|
|
809
|
+
const blockRows = block ? [block[0], `${block[0]}_import`] : [];
|
|
810
|
+
const writes = read.writes.filter((w) => !blockRows.includes(w.surface));
|
|
811
|
+
if (!writes.length && read.incomplete) {
|
|
812
|
+
ctx.incomplete = true;
|
|
813
|
+
const why = (read.deferred || []).filter((d) => !blockRows.includes(d.surface)).map((d) => (d.reason ? `${d.surface}: ${d.reason}` : d.surface));
|
|
814
|
+
return [item({ action: "skip", deferred: true, ...advised(`${named}'s own plan left work for later${why.length ? ` — ${why.join("; ")}` : ", and names none of it"}`, besides) })];
|
|
815
|
+
}
|
|
816
|
+
if (!writes.length) return beside ? [item({ action: "skip", ...advised(`${named} has nothing to change here`, besides) })] : [];
|
|
817
|
+
if (insideHostSession(env, harness)) {
|
|
818
|
+
ctx.incomplete = true;
|
|
819
|
+
return [item({ action: "skip", deferred: true, ...advised(`${kept}; ${inSessionReason(harness)}`, besides) })];
|
|
820
|
+
}
|
|
821
|
+
const step = { kind: "host", name: "keep", argv: keepArgv(copy, { verb: "upgrade", harness, projectDir }), why: `the kept copy's own upgrade, from its own bin without registering (the layout remedy's form), read back as JSON; it re-plans from the files as they stand then, after the agents block above`, touches: writes };
|
|
822
|
+
return [item({ action: "update", ...advised(kept, besides), steps: [step] })];
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// The mark (the converge story's rule 2), for the channel "npm" only: one
|
|
826
|
+
// item of kind `binding`, last in its harness's group, adding `channel_switch:
|
|
827
|
+
// { by: "setup", at: <the time of apply> }` to the binding and keeping every
|
|
828
|
+
// other key. Planned when this run switches a copy off, bound or not; or, to
|
|
829
|
+
// adopt an earlier switch, when the registration this checkout holds already
|
|
830
|
+
// keeps a copy off and a binding exists to record it in. Never over a mark
|
|
831
|
+
// already there: a re-run after a switch is zero-change. The file is the one
|
|
832
|
+
// configPath resolves at apply time, after the layout item moved it; the
|
|
833
|
+
// preview names the new path when the plan moves the binding.
|
|
834
|
+
function planMark(ctx, facts) {
|
|
835
|
+
const b = ctx.binding;
|
|
836
|
+
if (!b || b.hasMark || !facts) return null;
|
|
837
|
+
const switching = facts.switches.length > 0;
|
|
838
|
+
const adopting = !switching && facts.silenced.length > 0 && b.bound;
|
|
839
|
+
if (!switching && !adopting) return null;
|
|
840
|
+
const reason = !b.bound ? "the project is not bound yet: the switch is recorded only if a binding exists by the time of apply"
|
|
841
|
+
: adopting ? `this checkout already holds ${facts.silenced.join(", ")} off for the npm registration; the switch is recorded`
|
|
842
|
+
: null;
|
|
843
|
+
return { surface: CHANNEL_SWITCH_KEY, kind: "binding", path: b.path, entry: CHANNEL_SWITCH_KEY, state: b.bound ? "absent" : "unbound", action: "add", reason,
|
|
844
|
+
steps: [{ kind: "mark", path: b.path, key: CHANNEL_SWITCH_KEY, why: "this checkout chose the npm install; doctor stops naming the copy it turned off, and the registration's uninstall removes the record with it" }] };
|
|
845
|
+
}
|
|
846
|
+
|
|
501
847
|
function planJsonEntry(ctx, key, s) {
|
|
502
848
|
const { projectDir, mode, root, home } = ctx;
|
|
503
849
|
if (s.supported === false) {
|
|
@@ -587,18 +933,60 @@ function planStampedFile(ctx, key, s) {
|
|
|
587
933
|
|
|
588
934
|
// ─── plan ──────────────────────────────────────────────────────────────
|
|
589
935
|
|
|
590
|
-
|
|
936
|
+
// The surface rows a run leaves out: a --surface that does not name the key
|
|
937
|
+
// (by prefix, so `statusline` covers the launcher), or --no-register's
|
|
938
|
+
// registration. One predicate for plan() and the shell fetch's decision.
|
|
939
|
+
function surfaceExcluded(key, s, { surfaces = null, register = true } = {}) {
|
|
940
|
+
return Boolean(surfaces && !surfaces.some((x) => key === x || key.startsWith(x + "_"))) || (register === false && s.kind === "registration");
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// `state` selects by the state directory too (upgrade's set, the shell-fetch
|
|
944
|
+
// story's rule 5); `version` is the running root's, for a shell-rooted
|
|
945
|
+
// registration planned without its payload; `payloadRoots` holds a fetched
|
|
946
|
+
// root per harness id — never PROJECTSTORE_DISTRIBUTION_ROOT, which names one
|
|
947
|
+
// root for every harness and is inherited by every child the run spawns.
|
|
948
|
+
// `layout` is a layout NAME: the agents block's roster is rendered from it,
|
|
949
|
+
// the layout the run leaves; null keeps the binding's (the story "One run
|
|
950
|
+
// plans the agents block once…", rule 3). `convergence` is the prepare step's
|
|
951
|
+
// decision about the copies this project runs (convergence.mjs, the converge
|
|
952
|
+
// story's rule 6): a stop is rendered as the plan's refusal, and the proceed
|
|
953
|
+
// cases as the newer rows' words. plan() reads no registry and decides
|
|
954
|
+
// nothing about it; without a decision (plan's dry run) a newer row says
|
|
955
|
+
// that install and upgrade read the registry first — from a root that checks
|
|
956
|
+
// at all. `fetchedFrom` names the project's registry a shell was fetched
|
|
957
|
+
// from, when it is not the user's ({ registry, user, harnesses }).
|
|
958
|
+
// `channel` is the answer to setup's channel question (the converge story's
|
|
959
|
+
// rule 1): "npm" switches a copy from another marketplace off and records the
|
|
960
|
+
// switch in the binding; "git" keeps it; null — what install and upgrade pass
|
|
961
|
+
// — plans the switch as before, and the gate asks it at a terminal and refuses
|
|
962
|
+
// it without one. `keep` is the prepare step's read of the kept copy's own
|
|
963
|
+
// plan under the channel "git" ({ copy, read }, rule 5); without it the keep's
|
|
964
|
+
// row says that copy's plan was not read.
|
|
965
|
+
export function plan(projectDir, { harnesses = [], mode = "install", env = process.env, home = homedir(), root = pluginRoot(), surfaces = null, globalRemoval = false, register = true, state = false, version = null, payloadRoots = null, layout = null, convergence = null, fetchedFrom = null, channel = null, keep = null } = {}) {
|
|
591
966
|
projectDir = resolve(projectDir);
|
|
592
|
-
const detected = detectHarnesses(projectDir);
|
|
967
|
+
const detected = detectHarnesses(projectDir, { state });
|
|
593
968
|
const named = harnesses.filter(Boolean);
|
|
594
969
|
const ids = named.length ? named : detected.map((d) => d.id);
|
|
595
970
|
const out = { projectDir, mode, named: named.length > 0, detected, harnesses: [], reports: [], items: [], refusals: [], ok: true, incomplete: false, root, plannedAgainst: {} };
|
|
971
|
+
// Not enumerable, like hostManaged: plan --json keeps the shape it had.
|
|
972
|
+
Object.defineProperty(out, "version", { value: version, enumerable: false });
|
|
973
|
+
Object.defineProperty(out, "payloadRoots", { value: { ...(payloadRoots || {}) }, enumerable: false });
|
|
974
|
+
Object.defineProperty(out, "convergence", { value: convergence, enumerable: false });
|
|
975
|
+
Object.defineProperty(out, "fetchedFrom", { value: fetchedFrom, enumerable: false });
|
|
976
|
+
// The channel answer, for the gate (confirm()): a switch planned under null
|
|
977
|
+
// is asked at a terminal and refused without one.
|
|
978
|
+
Object.defineProperty(out, "channel", { value: channel, enumerable: false });
|
|
596
979
|
const unknown = named.filter((id) => !harnessIds().includes(id));
|
|
597
980
|
if (unknown.length) {
|
|
598
981
|
out.refusals.push(`unknown harness: ${unknown.join(", ")} — known: ${harnessIds().join(", ")}`);
|
|
599
982
|
out.ok = false;
|
|
600
983
|
return out;
|
|
601
984
|
}
|
|
985
|
+
if (![null, "npm", "git"].includes(channel)) {
|
|
986
|
+
out.refusals.push(`unknown channel: ${channel} — known: npm, git`);
|
|
987
|
+
out.ok = false;
|
|
988
|
+
return out;
|
|
989
|
+
}
|
|
602
990
|
// A project that does not exist is refused before contract 8's detection
|
|
603
991
|
// could stand in for it ("no harness detected"), and before anything is
|
|
604
992
|
// written, staged, locked or spawned (contract 19).
|
|
@@ -610,10 +998,40 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
|
|
|
610
998
|
return out;
|
|
611
999
|
}
|
|
612
1000
|
if (!ids.length) {
|
|
613
|
-
out.refusals.push(harnessRefusal(projectDir));
|
|
1001
|
+
out.refusals.push(harnessRefusal(projectDir, { state }));
|
|
1002
|
+
out.ok = false;
|
|
1003
|
+
return out;
|
|
1004
|
+
}
|
|
1005
|
+
// The stop (rule 6), decided in the prepare step: this project runs a newer
|
|
1006
|
+
// copy and the registry's latest reaches it. Nothing is planned; the
|
|
1007
|
+
// refusal carries the whole text, and its two commands — each printed on a
|
|
1008
|
+
// line of its own (presentation spec contract 6) — are kept off the
|
|
1009
|
+
// envelope, as hostManaged is.
|
|
1010
|
+
if (convergence?.stop) {
|
|
1011
|
+
out.harnesses = ids;
|
|
1012
|
+
out.refusals.push(convergence.stop.text);
|
|
614
1013
|
out.ok = false;
|
|
1014
|
+
Object.defineProperty(out, "stop", { value: { lead: convergence.stop.lead, commands: [...convergence.stop.commands], between: convergence.stop.between }, enumerable: false });
|
|
615
1015
|
return out;
|
|
616
1016
|
}
|
|
1017
|
+
// The channel "git" (rule 1): with no copy from another marketplace enabled
|
|
1018
|
+
// for this project there is nothing to keep, and nothing is planned. The
|
|
1019
|
+
// refusal carries the whole text; its commands, each printed on a line of
|
|
1020
|
+
// its own, are kept off the envelope, as the stop's are. Otherwise the
|
|
1021
|
+
// harness the copy is kept for is planned by the keep alone (rule 5).
|
|
1022
|
+
const keeping = channel === "git" && mode !== "uninstall" ? keepTarget(projectDir, ids, { root, home, env }) : null;
|
|
1023
|
+
if (keeping && !keeping.copy) {
|
|
1024
|
+
out.harnesses = ids;
|
|
1025
|
+
out.refusals.push(keeping.refusal.reason);
|
|
1026
|
+
out.ok = false;
|
|
1027
|
+
if (keeping.refusal.advice) Object.defineProperty(out, "refusalAdvice", { value: keeping.refusal.advice, enumerable: false });
|
|
1028
|
+
return out;
|
|
1029
|
+
}
|
|
1030
|
+
// The prepare step's read, when it read the copy this plan keeps.
|
|
1031
|
+
const keepRead = keeping && keep?.copy?.path === keeping.copy.path ? keep.read ?? null : null;
|
|
1032
|
+
// What the newer rows say: the prepare step's decision; for a dry run, the
|
|
1033
|
+
// note that install and upgrade read first — only from a root that checks.
|
|
1034
|
+
const decision = convergence ?? (mode === "install" && !checksRoot(root, { home, env }) ? { checks: false } : null);
|
|
617
1035
|
const cfg = readConfigAt(projectDir);
|
|
618
1036
|
const optIn = new Set(surfaces || []);
|
|
619
1037
|
if (cfg?.statusline?.enabled === true) optIn.add("statusline");
|
|
@@ -622,13 +1040,83 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
|
|
|
622
1040
|
// planned against the new paths; its cleanup is planned last (below).
|
|
623
1041
|
const layoutHarness = loadHarness(ids.find((id) => { const p = layoutPaths(projectDir, { harnessDir: loadHarness(id).runtime?.harness_dir || null }); return existsSync(p.legacy.binding) || existsSync(p.legacy.runtime); }) || ids[0]);
|
|
624
1042
|
const layoutCtx = { projectDir, mode, env, home, root, harness: layoutHarness, incomplete: false, surfaces };
|
|
625
|
-
|
|
626
|
-
|
|
1043
|
+
// Under the keep, the layout move and its cleanup are the kept copy's own
|
|
1044
|
+
// run when their harness is the one kept: moved under a pre-move copy, the
|
|
1045
|
+
// project would read as unbound to it (rule 5).
|
|
1046
|
+
const moved = keeping && layoutHarness.id === keeping.harness.id ? { first: [], last: [] } : planLayout(layoutCtx);
|
|
1047
|
+
for (const item of moved.first) out.items.push({ harness: layoutHarness.id, ...item });
|
|
627
1048
|
if (layoutCtx.incomplete) out.incomplete = true;
|
|
1049
|
+
// The binding a channel switch is recorded in (the converge story's rules 2
|
|
1050
|
+
// and 4): the file configPath resolves now, whether it exists, and whether it
|
|
1051
|
+
// carries the mark. Apply resolves it again, after the layout item; when the
|
|
1052
|
+
// plan moves the binding, the preview names where the move puts it.
|
|
1053
|
+
const binding = (() => {
|
|
1054
|
+
const at = configPath(projectDir, env);
|
|
1055
|
+
const bound = existsSync(at);
|
|
1056
|
+
let cfg = null;
|
|
1057
|
+
if (bound) { try { cfg = JSON.parse(readFileSync(at, "utf8")); } catch { cfg = null; } }
|
|
1058
|
+
const movesIt = moved.first.some((i) => i.action === "migrate");
|
|
1059
|
+
return { path: movesIt ? layoutPaths(projectDir).binding : at, bound, hasMark: Boolean(channelMark(cfg)), hasKey: Boolean(cfg && typeof cfg === "object" && Object.hasOwn(cfg, CHANNEL_SWITCH_KEY)) };
|
|
1060
|
+
})();
|
|
1061
|
+
const marks = [];
|
|
1062
|
+
// The agents block is the PROJECT's, not a harness's (the story "One run
|
|
1063
|
+
// plans the agents block once, and a bare uninstall selects every harness
|
|
1064
|
+
// the project uses", rule 1): planned once for the run, before the loop and
|
|
1065
|
+
// from the files as they stand before it, by the first run harness that has
|
|
1066
|
+
// one, over the files every run harness names, with an import in every run
|
|
1067
|
+
// harness's native file. Planned per harness, each read the same snapshot,
|
|
1068
|
+
// so two harnesses wrote two blocks and an uninstall of both kept it for
|
|
1069
|
+
// "another harness" in the same run. Rendered from `root`, never from a
|
|
1070
|
+
// registration's install path or a fetched shell: every copy renders the
|
|
1071
|
+
// same block. --surface narrows it as it narrowed each harness's row.
|
|
1072
|
+
const blockRun = ids.map((id) => loadHarness(id)).filter((m) => agentsBlockRow(m));
|
|
1073
|
+
const owner = blockRun[0] || null;
|
|
1074
|
+
let blockItems = [];
|
|
1075
|
+
let blockKey = null;
|
|
1076
|
+
if (owner) {
|
|
1077
|
+
const [key, s] = agentsBlockRow(owner);
|
|
1078
|
+
if (!surfaceExcluded(key, s, { surfaces, register })) {
|
|
1079
|
+
const readers = blockRun.length > 1 ? blockRun.map((m) => ({ display: m.display_name, files: agentsBlockRow(m)[1].files || [] })) : null;
|
|
1080
|
+
blockItems = planAgentsBlock({ projectDir, mode, root, harness: owner, run: blockRun, readers, layout, surfaces: surfaces || [] }, key, { ...s, files: commonBlockFiles(blockRun) });
|
|
1081
|
+
blockKey = key;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
// Placed once, at the owner's own row, so one harness's plan keeps its order;
|
|
1085
|
+
// after the loop if that row never came. The seam for the convergence
|
|
1086
|
+
// story's keep step: it calls placeBlock() before it pushes itself, so the
|
|
1087
|
+
// block precedes the keep and covers the whole run, Claude Code included.
|
|
1088
|
+
let placed = false;
|
|
1089
|
+
const placeBlock = () => {
|
|
1090
|
+
if (placed) return;
|
|
1091
|
+
placed = true;
|
|
1092
|
+
for (const item of blockItems) out.items.push({ harness: owner.id, ...item });
|
|
1093
|
+
};
|
|
1094
|
+
// The other run harnesses plan no block item; the preview names the owner's
|
|
1095
|
+
// at their position (presentation spec contract 7) — when there IS a block,
|
|
1096
|
+
// on disk or planned: the one written or kept, else the one removed. Never
|
|
1097
|
+
// under "no block to remove" or a refusal, where "the same block" names
|
|
1098
|
+
// nothing. Not enumerable, like hostManaged: plan --json keeps the shape it
|
|
1099
|
+
// had.
|
|
1100
|
+
const blockOf = (i) => i.surface === blockKey;
|
|
1101
|
+
const shown = blockItems.find((i) => blockOf(i) && (["add", "create", "replace-entry"].includes(i.action) || (i.action === "skip" && i.state !== "ours-absent")))
|
|
1102
|
+
|| blockItems.find((i) => blockOf(i) && i.action === "remove");
|
|
1103
|
+
Object.defineProperty(out, "sharedBlock", { value: shown ? { harness: owner.id, display: owner.display_name, file: rel(projectDir, shown.path), others: [] } : null, enumerable: false });
|
|
628
1104
|
for (const id of ids) {
|
|
629
1105
|
const harness = loadHarness(id);
|
|
630
1106
|
out.harnesses.push(id);
|
|
631
|
-
const ctx = { projectDir, mode, env, home, root, harness, optIn, slotForeign: new Set(), incomplete: false, renderRoot: root, surfaces: surfaces || [], globalRemoval };
|
|
1107
|
+
const ctx = { projectDir, mode, env, home, root, harness, optIn, slotForeign: new Set(), incomplete: false, renderRoot: root, surfaces: surfaces || [], globalRemoval, version, fetched: payloadRoots?.[id] || null, convergence: decision, channel, binding, channelFacts: null, refusals: [] };
|
|
1108
|
+
// The keep (rule 5): the run's one block first — over the whole run set,
|
|
1109
|
+
// this harness included — then the keep's one item, and none of this
|
|
1110
|
+
// harness's own rows. Placed here when this harness owns the block; named
|
|
1111
|
+
// as the same block, above, when an earlier harness placed it.
|
|
1112
|
+
if (keeping && id === keeping.harness.id) {
|
|
1113
|
+
if (id !== owner?.id && out.sharedBlock && agentsBlockRow(harness)) out.sharedBlock.others.push({ harness: id, display: harness.display_name, at: out.items.length });
|
|
1114
|
+
placeBlock();
|
|
1115
|
+
for (const item of planKeep(ctx, keeping, keepRead)) out.items.push(item);
|
|
1116
|
+
out.refusals.push(...ctx.refusals);
|
|
1117
|
+
if (ctx.incomplete) out.incomplete = true;
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
632
1120
|
const hostRows = [];
|
|
633
1121
|
const unsupportedHost = [];
|
|
634
1122
|
let registration = null;
|
|
@@ -638,7 +1126,7 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
|
|
|
638
1126
|
// --no-register: this run changes the project's files and nothing of the
|
|
639
1127
|
// host's (the layout move run from an installed copy; the layout spec,
|
|
640
1128
|
// contract 12 as amended 2026-10-03).
|
|
641
|
-
const excluded = (
|
|
1129
|
+
const excluded = surfaceExcluded(key, s, { surfaces, register });
|
|
642
1130
|
if (excluded) {
|
|
643
1131
|
// A registration this run leaves out still decides the render root,
|
|
644
1132
|
// read-only — HERE, before the surfaces that render against it (the
|
|
@@ -649,8 +1137,9 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
|
|
|
649
1137
|
// fixes, S1).
|
|
650
1138
|
if (s.kind === "registration" && !isPluginCacheRoot(root, home)) {
|
|
651
1139
|
const analyser = s.format === "portable-plugin-registration" ? analysePortableRegistration : analyseRegistration;
|
|
652
|
-
const a = analyser(projectDir, s, { root, home, harness, env });
|
|
653
|
-
|
|
1140
|
+
const a = analyser(projectDir, s, { root, home, harness, env, version, fetched: ctx.fetched });
|
|
1141
|
+
const installed = registrationRenderRoot(a);
|
|
1142
|
+
if (installed) ctx.renderRoot = installed;
|
|
654
1143
|
}
|
|
655
1144
|
continue;
|
|
656
1145
|
}
|
|
@@ -662,6 +1151,13 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
|
|
|
662
1151
|
// waiting for it somewhere. An unsupported surface is named below by its
|
|
663
1152
|
// own row, with the reason the manifest gives.
|
|
664
1153
|
if (s.kind === "host") { if (s.supported !== false) hostRows.push(key); else unsupportedHost.push([key, s]); continue; }
|
|
1154
|
+
// The run's block, planned above: the owner places it here, every other
|
|
1155
|
+
// harness only records where the preview names it.
|
|
1156
|
+
if (s.format === "markdown-block") {
|
|
1157
|
+
if (id === owner?.id) placeBlock();
|
|
1158
|
+
else if (out.sharedBlock) out.sharedBlock.others.push({ harness: id, display: harness.display_name, at: out.items.length });
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
665
1161
|
const handler = HANDLERS[s.format];
|
|
666
1162
|
if (!handler) { out.refusals.push(`${id}: surface ${key} has format ${s.format}, which this installer cannot handle`); continue; }
|
|
667
1163
|
const items = handler(ctx, key, s);
|
|
@@ -671,7 +1167,11 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
|
|
|
671
1167
|
// Phase two (contract 4′): the rest is planned against the root the
|
|
672
1168
|
// registration produces — when it produces one on this run or has.
|
|
673
1169
|
const own = items.find((i) => i.surface === key);
|
|
674
|
-
|
|
1170
|
+
// The host-managed report names where a host plugin registration is
|
|
1171
|
+
// loaded in place: the plugin's root in our directory, from the same
|
|
1172
|
+
// registrationPaths the analysis used. Kept off the item, so plan
|
|
1173
|
+
// --json keeps the shape it had.
|
|
1174
|
+
registration = own ? { ...own, payload: s.format === "host-plugin-registration" ? registrationPaths(s, { home, projectDir, harness }).payload : null } : null;
|
|
675
1175
|
if (own && mode !== "uninstall" && ["create", "update", "skip"].includes(own.action) && own.root && !own.deferred) ctx.renderRoot = own.root;
|
|
676
1176
|
}
|
|
677
1177
|
}
|
|
@@ -696,8 +1196,24 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
|
|
|
696
1196
|
for (const [key, s] of unsupportedHost) {
|
|
697
1197
|
out.items.push({ harness: id, surface: key, kind: "host", path: null, entry: null, state: "unsupported", action: "skip", reason: s.why_unsupported || "not supported for this harness yet" });
|
|
698
1198
|
}
|
|
1199
|
+
// The mark, for the channel "npm" only: placed last of all (below), so it
|
|
1200
|
+
// is the last row of this harness's group and applies after the layout
|
|
1201
|
+
// item has moved the binding it is recorded in.
|
|
1202
|
+
if (mode !== "uninstall" && channel === "npm") {
|
|
1203
|
+
const mark = planMark(ctx, ctx.channelFacts);
|
|
1204
|
+
if (mark) marks.push({ harness: id, ...mark });
|
|
1205
|
+
}
|
|
1206
|
+
// Last for this harness: its state directory, which a project uninstall
|
|
1207
|
+
// empties of the welcome marker and removes once empty. A narrowed
|
|
1208
|
+
// uninstall plans no such step, as it plans no layout cleanup.
|
|
1209
|
+
if (mode === "uninstall" && !surfaces) {
|
|
1210
|
+
const item = planHarnessState(projectDir, harness, out.items.filter((i) => i.harness === id));
|
|
1211
|
+
if (item) out.items.push({ harness: id, ...item });
|
|
1212
|
+
}
|
|
699
1213
|
}
|
|
700
|
-
|
|
1214
|
+
placeBlock();
|
|
1215
|
+
for (const item of moved.last) out.items.push({ harness: layoutHarness.id, ...item });
|
|
1216
|
+
out.items.push(...marks);
|
|
701
1217
|
if (out.items.some((i) => i.action === "refuse")) out.ok = false;
|
|
702
1218
|
if (out.refusals.length) out.ok = false;
|
|
703
1219
|
return out;
|
|
@@ -732,7 +1248,7 @@ function planLayout(ctx) {
|
|
|
732
1248
|
if (!a.legacy.runtime || !a.legacy.runtimeOurs) return none;
|
|
733
1249
|
return { first: [], last: [{ ...base, surface: "layout_cleanup", path: P.legacy.runtime, state: "legacy", action: "remove", reason: "the legacy runtime directory is ours (its .gitignore header) and goes with the state", steps: [
|
|
734
1250
|
{ kind: "remove-legacy-runtime", path: P.legacy.runtime, why: "the pre-0.28 state directory, removed whole" },
|
|
735
|
-
{ kind: "note", why: ".projectstore/state/ (sessions, the entry log
|
|
1251
|
+
{ kind: "note", why: ".projectstore/state/ (sessions, the entry log) and the binding stay: the hooks' records and bind's file, not install's — delete .projectstore/ by hand to disown fully" },
|
|
736
1252
|
] }] };
|
|
737
1253
|
}
|
|
738
1254
|
if (a.twoConfigs && !a.resumable) return { first: [{ ...base, action: "refuse", reason: `two bindings: ${P.legacy.binding} (legacy) and ${P.binding} — keep one and delete the other (usually the legacy one), then run install again; nothing is written while both exist` }], last: [] };
|
|
@@ -758,6 +1274,29 @@ function planLayout(ctx) {
|
|
|
758
1274
|
return { first, last };
|
|
759
1275
|
}
|
|
760
1276
|
|
|
1277
|
+
// A project uninstall's last item per harness (the shell-fetch story's rule
|
|
1278
|
+
// 5; install spec contract 13 as amended): .projectstore/state/<id>/ is
|
|
1279
|
+
// emptied of the welcome marker — recognised by analyseHarnessState — and
|
|
1280
|
+
// removed once empty, so the next bare upgrade stops selecting the harness
|
|
1281
|
+
// it disowned. Planned for a directory holding the marker, or nothing; a
|
|
1282
|
+
// directory holding only what is not ours is left without a row. It never
|
|
1283
|
+
// touches a launcher: that is its surface item's to remove, when it is ours.
|
|
1284
|
+
// What stays — a launcher this plan does not remove, a file of the user's —
|
|
1285
|
+
// is named in the preview, and keeps the directory. Not a guarantee: a
|
|
1286
|
+
// harness whose plugin stays installed for the machine fires its hooks here
|
|
1287
|
+
// again, its welcome comes back, and so does the signal — accurately.
|
|
1288
|
+
function planHarnessState(projectDir, harness, planned) {
|
|
1289
|
+
const a = analyseHarnessState(projectDir, harness.id);
|
|
1290
|
+
if (!a.present || (!a.marker && a.names.length)) return null;
|
|
1291
|
+
const removing = new Set(planned.filter((i) => i.kind === "exclusive" && i.action === "remove" && dirname(i.path) === a.dir).map((i) => basename(i.path)));
|
|
1292
|
+
const left = a.others.filter((n) => !removing.has(n));
|
|
1293
|
+
const steps = [];
|
|
1294
|
+
if (a.marker) steps.push({ kind: "delete", path: a.marker, why: "the welcome marker the SessionStart hook wrote; a bare upgrade selects the harness by it" });
|
|
1295
|
+
steps.push({ kind: "rmdir-state", path: a.dir, left, why: left.length ? `${left.join(", ")} ${left.length === 1 ? "is" : "are"} not this step's to remove, and keep${left.length === 1 ? "s" : ""} the directory` : "removed once empty" });
|
|
1296
|
+
return { surface: "harness_state", kind: "layout", path: a.dir, entry: null, state: a.marker ? "ours" : "empty", action: "remove",
|
|
1297
|
+
reason: a.marker ? `${harness.display_name}'s welcome marker — the state directory selects ${harness.display_name} for a bare upgrade` : `an empty state directory of ${harness.display_name}`, steps };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
761
1300
|
function applyLayout(p, i, { failed, home = homedir(), onStep = null }) {
|
|
762
1301
|
const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
|
|
763
1302
|
const within = p.projectDir;
|
|
@@ -818,6 +1357,15 @@ function applyLayout(p, i, { failed, home = homedir(), onStep = null }) {
|
|
|
818
1357
|
out.steps.push({ kind: st.kind, ok: true, removed: true });
|
|
819
1358
|
}
|
|
820
1359
|
else if (st.kind === "remove-legacy-runtime") { removeInside(st.path, within, { recursive: true }); out.steps.push({ kind: st.kind, ok: true, removed: true }); }
|
|
1360
|
+
else if (st.kind === "rmdir-state") {
|
|
1361
|
+
// rmdirSync on an empty directory only: whatever is left — read now,
|
|
1362
|
+
// after the items before it ran — keeps it, and is named.
|
|
1363
|
+
let left = [];
|
|
1364
|
+
try { left = readdirSync(st.path).sort(); } catch { out.steps.push({ kind: st.kind, ok: true, removed: false }); continue; }
|
|
1365
|
+
if (left.length) { out.steps.push({ kind: st.kind, ok: true, removed: false, reason: `${left.join(", ")} remain` }); continue; }
|
|
1366
|
+
rmdirSync(st.path);
|
|
1367
|
+
out.steps.push({ kind: st.kind, ok: true, removed: true });
|
|
1368
|
+
}
|
|
821
1369
|
} catch (e) { return fail(st.kind, e && e.message ? e.message : String(e)); }
|
|
822
1370
|
finally { if (onStep) onStep(st, "end", stepResult(out, seen)); }
|
|
823
1371
|
}
|
|
@@ -883,7 +1431,14 @@ function hostManagedReport(m, rows, registration = null) {
|
|
|
883
1431
|
const lines = [`${m.display_name}: ${rows.join(", ")} are installed by ${inst.mechanism || "the host"} — nothing to write.`];
|
|
884
1432
|
if (registration && registration.entry) {
|
|
885
1433
|
const how = registration.action === "skip" && registration.state === "current" ? "registered here" : registration.action === "refuse" ? "refused, see below" : registration.action === "skip" ? "not registered on this run, see below" : `registered by this run (${registration.action})`;
|
|
886
|
-
|
|
1434
|
+
// A host plugin registration is a marketplace added from a local path, and
|
|
1435
|
+
// the host loads its plugin in place, from the plugin's root inside that
|
|
1436
|
+
// directory (`payload`, set by plan()); the install path is the registry's
|
|
1437
|
+
// copy, which the launcher renders (the story "The SessionStart hook and
|
|
1438
|
+
// install agree on the status line…", measured on host 2.1.293 and
|
|
1439
|
+
// 2.1.296). A portable registration's host loads the copy.
|
|
1440
|
+
const where = !registration.root ? "" : registration.payload ? ` — install path ${registration.root}; the host loads them in place from ${registration.payload}` : ` — the host loads them from ${registration.root}`;
|
|
1441
|
+
lines.push(` They come from the registration ${registration.entry}, ${how}${where}.`);
|
|
887
1442
|
}
|
|
888
1443
|
if (inst.why_not_scripted) lines.push(` ${inst.why_not_scripted}`);
|
|
889
1444
|
for (const s of inst.steps || []) lines.push(` ${s}`);
|
|
@@ -896,119 +1451,286 @@ const isWrite = (i) => !["skip", "refuse"].includes(i.action);
|
|
|
896
1451
|
|
|
897
1452
|
// ─── preview ───────────────────────────────────────────────────────────
|
|
898
1453
|
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
|
|
910
|
-
const
|
|
1454
|
+
// PLAN (presentation spec contract 7, which revises the layout clauses of
|
|
1455
|
+
// install spec contract 18): the badge, the project path, a summary line of
|
|
1456
|
+
// counts, then the rows grouped under each run harness's display name, in run
|
|
1457
|
+
// order. A row is one aligned line — glyph, action word, target, note — and
|
|
1458
|
+
// beneath it what the reader consents to (install spec contract 9: every path
|
|
1459
|
+
// written and every host argv, with the files it touches), the reason when
|
|
1460
|
+
// there is one, and a consequence the reader must know. Internal words —
|
|
1461
|
+
// kinds, provenance states, entry markers, roadmap ids — and each step's why
|
|
1462
|
+
// are behind `--verbose`. Groups and counts are computed here, never added to
|
|
1463
|
+
// the plan: plan --json keeps its keys. Colour and glyphs are the caller's
|
|
1464
|
+
// stream's (`caps`, term.mjs); the default is plain text in Unicode.
|
|
1465
|
+
const unicodeGlyph = (name) => termIcon({ ascii: false }, name);
|
|
1466
|
+
|
|
1467
|
+
// Every action a plan item can carry, as its glyph. A write never wears the
|
|
1468
|
+
// skip glyph: `add` and `replace-entry` are changes as much as `create` and
|
|
1469
|
+
// `update`.
|
|
1470
|
+
// A copy from another marketplace turned off for this checkout is a channel
|
|
1471
|
+
// switch, `disable` (the converge story; contract 7's `switch` word, ⇄).
|
|
1472
|
+
const ACTION_ICON = { create: "create", add: "create", update: "update", "replace-entry": "update", migrate: "migrate", disable: "switch", cleanup: "cleanup", remove: "remove", prune: "remove", skip: "skip", refuse: "refuse" };
|
|
1473
|
+
// …and as a person's word (contract 7's table). A registration's write takes
|
|
1474
|
+
// `register`; a skip reads `unchanged` when the item is current and carries
|
|
1475
|
+
// no reason, and `skipped` otherwise — deferred, opted out, unsupported, or
|
|
1476
|
+
// left in place: a current row with a reason is one the run did not act on
|
|
1477
|
+
// for that reason (uninstall keeping a user-global registration, a host CLI
|
|
1478
|
+
// not on PATH), and the reason beneath it is what the reader acts on.
|
|
1479
|
+
const ACTION_WORD = { create: "add", add: "add", update: "update", "replace-entry": "update", migrate: "update", disable: "switch", cleanup: "remove", remove: "remove", prune: "remove", refuse: "refused" };
|
|
1480
|
+
// Each word's colour role (contract 3): a refusal's ▲, a channel switch and a
|
|
1481
|
+
// removal are attention; red is for what failed.
|
|
1482
|
+
const WORD_ROLE = { add: "done", register: "done", update: "action", switch: "attention", remove: "attention", refused: "attention", unchanged: "explanation", skipped: "explanation" };
|
|
1483
|
+
// APPLY says a word while its step runs and once it has (contract 8).
|
|
1484
|
+
const WORD_TENSES = { add: ["adding", "added"], register: ["registering", "registered"], update: ["updating", "updated"], switch: ["switching", "switched"], remove: ["removing", "removed"] };
|
|
1485
|
+
|
|
1486
|
+
const CURRENT_STATES = new Set(["current", "ours-current", "ours"]);
|
|
1487
|
+
const isUnchanged = (i) => i.action === "skip" && !i.deferred && !i.reason && CURRENT_STATES.has(i.state);
|
|
1488
|
+
// A surface this harness does not have: folded into one line per harness.
|
|
1489
|
+
const isUnsupported = (i) => i.action === "skip" && i.state === "unsupported";
|
|
1490
|
+
// A row of something another project wrote last (install spec contract 12):
|
|
1491
|
+
// never collapsed, and its note says whose it is.
|
|
1492
|
+
const writtenElsewhere = (i) => i.state === "current" && Boolean(i.writtenBy) && !i.sameProject;
|
|
1493
|
+
// A row that writes nothing and asks nothing of the reader collapses into
|
|
1494
|
+
// one line per group naming it (contract 7).
|
|
1495
|
+
const isQuiet = (i) => isUnchanged(i) && !writtenElsewhere(i) && !(i.steps || []).length;
|
|
1496
|
+
// The registration row a fetched shell's registry lines sit beneath, when the
|
|
1497
|
+
// run fetched one from a registry that is not the user's.
|
|
1498
|
+
const fetchedFor = (p, i) => (p.fetchedFrom && i.kind === "registration" && !String(i.surface).endsWith("_others") && p.fetchedFrom.harnesses.includes(i.harness) ? p.fetchedFrom : null);
|
|
1499
|
+
|
|
1500
|
+
function actionWord(i) {
|
|
1501
|
+
if (i.action === "skip") return isUnchanged(i) ? "unchanged" : "skipped";
|
|
1502
|
+
if (i.kind === "registration" && ["create", "add", "update"].includes(i.action)) return "register";
|
|
1503
|
+
return ACTION_WORD[i.action] || "update";
|
|
1504
|
+
}
|
|
1505
|
+
const glyphName = (i) => ACTION_ICON[i.action] || (isWrite(i) ? "update" : "skip");
|
|
1506
|
+
// The surface key in words: `agents_block` reads "agents block".
|
|
1507
|
+
const surfaceWords = (key) => String(key).replace(/_/g, " ");
|
|
1508
|
+
// What a row names: a registration by its plugin id — its paths are the
|
|
1509
|
+
// consent lines beneath it — anything else by its path, project-relative.
|
|
1510
|
+
function rowTarget(p, i) {
|
|
1511
|
+
// …and the keep (kind `channel`) by the key of the copy it keeps.
|
|
1512
|
+
if ((i.kind === "registration" || i.kind === "channel") && i.entry) return i.entry;
|
|
1513
|
+
if (i.path === null) return `${i.surface} [${i.harness}, no filesystem path]`;
|
|
1514
|
+
return rel(p.projectDir, i.path);
|
|
1515
|
+
}
|
|
1516
|
+
function rowNote(i, { verbose, glyph }) {
|
|
1517
|
+
const words = writtenElsewhere(i) ? `last written by ${i.writtenBy}` : surfaceWords(i.surface);
|
|
1518
|
+
if (!verbose) return words;
|
|
1519
|
+
return `${words} ${glyph("dot")} ${i.kind}${i.entry && i.kind !== "registration" ? ` [${i.entry}]` : ""}`;
|
|
1520
|
+
}
|
|
1521
|
+
// --verbose's line beneath a row: the provenance state, the reason and the
|
|
1522
|
+
// plan's own action, as install spec contract 18 wrote them.
|
|
1523
|
+
function transitionText(i, glyph) {
|
|
1524
|
+
let state = writtenElsewhere(i) ? `current, last written by ${i.writtenBy}` : i.state;
|
|
1525
|
+
// A reason with commands gives its prose here; the commands follow, alone.
|
|
1526
|
+
const reason = i.advice ? i.advice.text : i.reason;
|
|
1527
|
+
if (reason && i.action !== "refuse") state += ` (${reason})`;
|
|
1528
|
+
// A dry run's unchanged registration: right by every host fact, its
|
|
1529
|
+
// payload digest still to compare. Said only where the reasoning is.
|
|
1530
|
+
if (i.digestDeferred) state += " (the payload digest is compared when install or upgrade fetches the shell)";
|
|
1531
|
+
return `${state} ${glyph("transition")} ${i.action}${i.action === "refuse" && i.reason ? ": " + i.reason : ""}`;
|
|
1532
|
+
}
|
|
911
1533
|
|
|
912
1534
|
// One step of an item as the lines it shows: always the action and its target,
|
|
913
1535
|
// then — under --verbose — why it runs.
|
|
914
|
-
function stepLines(p, st, { verbose, paint, width = 0 }) {
|
|
1536
|
+
function stepLines(p, st, { verbose, paint, width = 0, glyph = unicodeGlyph }) {
|
|
915
1537
|
const r = (x) => rel(p.projectDir, x);
|
|
1538
|
+
const to = ` ${glyph("transition")} `;
|
|
916
1539
|
const lead = " ";
|
|
917
1540
|
const sub = " ";
|
|
918
|
-
const why = verbose && st.why ? wrap(st.why, width, sub).split("\n").map((l, k) => (k ? "" : sub) + paint("
|
|
1541
|
+
const why = verbose && st.why ? wrap(st.why, width, sub).split("\n").map((l, k) => (k ? "" : sub) + paint("explanation", l)) : [];
|
|
1542
|
+
// A consent line (install spec contract 9): the step's verb and what it
|
|
1543
|
+
// touches, in the explanation role (presentation spec contracts 3 and 7).
|
|
1544
|
+
const said = (text) => `${lead}${paint("explanation", text)}`;
|
|
919
1545
|
switch (st.kind) {
|
|
920
1546
|
case "host": {
|
|
921
|
-
|
|
922
|
-
|
|
1547
|
+
// The keep step's argv is whole (its bin is the Node that runs us), and
|
|
1548
|
+
// its touches are the kept copy's planned writes, each with its action.
|
|
1549
|
+
const out = [said(`$ ${(st.bin ? [st.bin, ...st.argv] : st.argv).join(" ")}`)];
|
|
1550
|
+
if (st.touches.length) out.push(`${sub}${paint("explanation", `touches ${st.touches.map((t) => (typeof t === "string" ? r(t) : `${t.path ? r(t.path) : t.surface} (${t.action})`)).join(", ")}`)}`);
|
|
923
1551
|
return [...out, ...why];
|
|
924
1552
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
case "
|
|
1553
|
+
// The fetch a dry run would run first (the shell-fetch story's rule 6),
|
|
1554
|
+
// with the fetch glyph (presentation spec contract 4).
|
|
1555
|
+
case "fetch": return [`${lead}${paint("action", glyph("fetch"))} ${paint("explanation", [st.bin, ...st.argv].join(" "))}`, ...why];
|
|
1556
|
+
case "note": return wrap(`note: ${st.why}`, width, sub).split("\n").map((l, k) => (k ? l : lead + paint("attention", "note:") + l.slice(5)));
|
|
1557
|
+
case "write": return [said(`write ${st.path}${st.manifestOnly ? " (manifest only)" : ` (${st.files} files + the manifest)`}`), ...why];
|
|
1558
|
+
// A dry run names no file count: the payload is counted after the fetch.
|
|
1559
|
+
case "portable-write": return [said(`stage ${st.path} (${Array.isArray(st.files) ? `${st.files.length} payload files` : "the fetched payload"} + catalogue + ownership)`), ...why];
|
|
1560
|
+
case "rmdir-state": return [said(`remove ${r(st.path)}/ once empty${st.left?.length ? ` ${glyph("dash")} ${st.left.join(", ")} stay${st.left.length === 1 ? "s" : ""} and keep${st.left.length === 1 ? "s" : ""} it` : ""}`), ...why];
|
|
928
1561
|
case "portable-remove":
|
|
929
|
-
case "remove": return [
|
|
930
|
-
case "unregister": return [
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
case "
|
|
934
|
-
case "
|
|
935
|
-
case "
|
|
936
|
-
case "move-
|
|
1562
|
+
case "remove": return [said(`remove ${st.path}`), ...why];
|
|
1563
|
+
case "unregister": return [said(`edit ${r(st.path)} [${st.pointer}.${st.name}]${to}removed`), ...why];
|
|
1564
|
+
// The channel switch's record in the binding (the converge story's rules
|
|
1565
|
+
// 2 and 4): its time is the apply's, unknown to a preview.
|
|
1566
|
+
case "mark": return [said(`edit ${r(st.path)} [${st.key}]${to}by "setup", at the time of apply`), ...why];
|
|
1567
|
+
case "unmark": return [said(`edit ${r(st.path)} [${st.key}]${to}removed`), ...why];
|
|
1568
|
+
case "ensure": return [said(`ensure ${r(st.path)}/`), ...why];
|
|
1569
|
+
case "move-state": return [said(`move ${r(st.from)}/${to}${r(st.to)}/ (${st.files} entries)`), ...why];
|
|
1570
|
+
case "merge-log": return [said(`merge ${r(st.from)}${to}${r(st.to)}`), ...why];
|
|
1571
|
+
case "delete": return [said(`delete ${r(st.path)}`), ...why];
|
|
1572
|
+
case "move-marker": return [said(`move ${r(st.from)}${to}${r(st.to)}`), ...why];
|
|
1573
|
+
case "move-binding": return [said(`move ${r(st.from)}${to}${r(st.to)} (agents${to}${r(st.overlay)})`), ...why];
|
|
937
1574
|
case "remove-legacy-launcher":
|
|
938
1575
|
case "rmdir-legacy":
|
|
939
|
-
case "remove-legacy-runtime": return [
|
|
1576
|
+
case "remove-legacy-runtime": return [said(`remove ${r(st.path)}`), ...why];
|
|
940
1577
|
default: return [];
|
|
941
1578
|
}
|
|
942
1579
|
}
|
|
943
1580
|
|
|
944
|
-
|
|
1581
|
+
const displayName = (id) => loadHarness(id)?.display_name || id;
|
|
1582
|
+
// The harnesses a plan names, by their display names — for the badge.
|
|
945
1583
|
function displayNames(p) {
|
|
946
|
-
return p.harnesses.map(
|
|
1584
|
+
return p.harnesses.map(displayName).join(", ") || "(no harness)";
|
|
947
1585
|
}
|
|
948
1586
|
|
|
949
|
-
const tilde = (path) =>
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1587
|
+
const tilde = (path, home = homedir()) => (path === home || path.startsWith(home + "/") ? "~" + path.slice(home.length) : path);
|
|
1588
|
+
|
|
1589
|
+
// The badge (presentation spec contract 7): `projectstore <verb> · <display
|
|
1590
|
+
// names>`, the first line of a verb's output. A run that fetches prints it
|
|
1591
|
+
// before the fetch line, and its preview then leaves it out (`badge: false`),
|
|
1592
|
+
// so it is printed once.
|
|
1593
|
+
export function badgeLine(p, { verb = null, caps: c = PLAIN } = {}) {
|
|
1594
|
+
return badgeText(c, `projectstore ${verb || p.mode}`, [displayNames(p)]);
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// The update notice in the badge's slot (the converge story's rule 8;
|
|
1598
|
+
// presentation spec contracts 6 and 7): the note glyph, the text in the
|
|
1599
|
+
// explanation role, then the command alone on its line after a blank one, in
|
|
1600
|
+
// the action role, and a blank line before the preview.
|
|
1601
|
+
export function noticeText(n, c = PLAIN) {
|
|
1602
|
+
const paint = painter(c);
|
|
1603
|
+
return [` ${paint("attention", termIcon(c, "note"))} ${paint("explanation", n.text)}`, ...commandBlock(c, [n.command], { indent: 4 }), ""].join("\n") + "\n";
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// The summary line: the counts by kind of change, each in its word's role.
|
|
1607
|
+
// Unsupported rows are not counted: they are the harness's, not the run's.
|
|
1608
|
+
const COUNTED = [["add", (n) => `${n} to add`], ["register", (n) => `${n} to register`], ["update", (n) => `${n} to update`], ["switch", (n) => `${n} to switch`], ["remove", (n) => `${n} to remove`], ["unchanged", (n) => `${n} unchanged`], ["skipped", (n) => `${n} skipped`]];
|
|
1609
|
+
function planSummary(p, c) {
|
|
1610
|
+
if (!p.ok) return heading(c, "Plan", [["refused", "attention"]]);
|
|
1611
|
+
const n = {};
|
|
1612
|
+
for (const i of p.items) if (!isUnsupported(i)) n[actionWord(i)] = (n[actionWord(i)] || 0) + 1;
|
|
1613
|
+
const counts = COUNTED.filter(([w]) => n[w]).map(([w, say]) => [say(n[w]), WORD_ROLE[w]]);
|
|
1614
|
+
return heading(c, "Plan", counts.length ? counts : [["nothing to change", "explanation"]]);
|
|
1615
|
+
}
|
|
953
1616
|
|
|
954
|
-
|
|
955
|
-
|
|
1617
|
+
// `home` shortens the project path to ~ (the caller's home; a test's fixed
|
|
1618
|
+
// one); everything else the screen needs is in `p`.
|
|
1619
|
+
export function renderPreview(p, { verbose = false, verb = null, caps: c = PLAIN, badge = true, home = homedir() } = {}) {
|
|
1620
|
+
const paint = painter(c);
|
|
1621
|
+
const glyph = (name) => termIcon(c, name);
|
|
1622
|
+
const width = c.live ? c.width : 0;
|
|
956
1623
|
const writes = p.items.filter(isWrite);
|
|
1624
|
+
// Prose, wrapped to the terminal with every line indented; a path or a
|
|
1625
|
+
// command inside it is one word and never broken (term.mjs wrap).
|
|
1626
|
+
const prose = (indent, text, role = "explanation") => wrap(text, width, indent).split("\n").map((l, k) => indent + paint(role, k ? l.slice(indent.length) : l));
|
|
957
1627
|
const lines = [
|
|
958
|
-
|
|
959
|
-
` ${paint("
|
|
1628
|
+
...(badge ? [badgeLine(p, { verb, caps: c })] : []),
|
|
1629
|
+
` ${paint("explanation", tilde(p.projectDir, home))}`,
|
|
960
1630
|
"",
|
|
961
1631
|
];
|
|
962
|
-
// Prose, wrapped to the terminal with every line indented; a path or a
|
|
963
|
-
// command inside it is one word and never broken (term.mjs wrap).
|
|
964
|
-
const prose = (indent, text, style = "gray") => wrap(text, width, indent).split("\n").map((l, k) => (k ? "" : indent) + paint(style, l));
|
|
965
1632
|
if (verbose) {
|
|
966
1633
|
for (const [h, r] of Object.entries(p.plannedAgainst || {})) lines.push(...prose(" ", `${h}: the surfaces below are planned against the host's install path ${r}, not this package at ${p.root}.`), "");
|
|
967
1634
|
for (const r of p.reports) lines.push(...r.split("\n").flatMap((l) => (l.trim() ? prose(" ", l) : [""])), "");
|
|
968
1635
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
//
|
|
972
|
-
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1636
|
+
lines.push(planSummary(p, c));
|
|
1637
|
+
// An emptied state directory is pruned with the launcher removed from it;
|
|
1638
|
+
// said once, beneath the first such row.
|
|
1639
|
+
let pruneSaid = false;
|
|
1640
|
+
const beneath = (i) => {
|
|
1641
|
+
const out = [];
|
|
1642
|
+
// A user-scope registration reaches past this project: contract 7's
|
|
1643
|
+
// consequence line, on the line beneath its row, before anything else.
|
|
1644
|
+
if (isWrite(i) && i.kind === "registration" && i.scope === "user") out.push(...prose(" ", `user-wide: the plugin is ${i.action === "remove" ? "removed" : "registered"} for every project ${displayName(i.harness)} opens, not only this one`));
|
|
1645
|
+
// …and a channel switch's (the converge story's rule 2), carried as its
|
|
1646
|
+
// note step: first, before the reason and the consent lines.
|
|
1647
|
+
for (const st of (i.steps || []).filter(isConsequence)) out.push(...prose(" ", st.why));
|
|
1648
|
+
if (verbose) out.push(...prose(" ", transitionText(i, glyph)));
|
|
1649
|
+
else if (i.reason && !isUnsupported(i)) out.push(...prose(" ", i.advice ? i.advice.text : i.reason, i.action === "refuse" ? "attention" : "explanation"));
|
|
1650
|
+
// The reason's ways out (the converge story): each lead, then its command
|
|
1651
|
+
// alone on its line after a blank one (contract 6), and a blank line after.
|
|
1652
|
+
if (i.advice?.blocks?.length) {
|
|
1653
|
+
for (const b of i.advice.blocks) out.push(...prose(" ", b.lead), ...commandBlock(c, [b.command], { indent: 10 }), "");
|
|
980
1654
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
if (i.
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
}
|
|
999
|
-
const
|
|
1000
|
-
for (const h of p.hostManaged || []) {
|
|
1655
|
+
// A shell this run fetched from a registry that is not the user's (the
|
|
1656
|
+
// converge story's rule 8, as amended 2026-10-09): both named. Neutral
|
|
1657
|
+
// words: the fetch's URL can come from the project's .npmrc or from the
|
|
1658
|
+
// run's own environment (`npx --registry`), and either reads the same.
|
|
1659
|
+
const from = fetchedFor(p, i);
|
|
1660
|
+
if (from) out.push(...prose(" ", `fetched from ${from.registry}`), ...prose(" ", `your npm's own registry is ${from.user}`));
|
|
1661
|
+
for (const st of (i.steps || []).filter((x) => !isConsequence(x))) out.push(...stepLines(p, st, { verbose, paint, width, glyph }));
|
|
1662
|
+
if (i.kind === "registration" && i.home && i.surface && !i.surface.endsWith("_others")) out.push(` ${paint("explanation", `(harness home ${i.home}${i.scope ? `, scope ${i.scope}` : ""})`)}`);
|
|
1663
|
+
if (i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim()) out.push(` ${paint("explanation", "(the file would hold nothing else and is removed)")}`);
|
|
1664
|
+
if (!pruneSaid && i.action === "remove" && i.kind === "exclusive") { pruneSaid = true; out.push(` ${paint("explanation", `(an emptied ${rel(p.projectDir, dirname(i.path))}/ is pruned)`)}`); }
|
|
1665
|
+
return out;
|
|
1666
|
+
};
|
|
1667
|
+
// The run's one agents block, named again where each other harness's row
|
|
1668
|
+
// would have been (contract 7; the story "One run plans the agents block
|
|
1669
|
+
// once…", rule 1): in that harness's group, before its first item at or
|
|
1670
|
+
// after the recorded index, else at the group's end. Not a step and not an
|
|
1671
|
+
// item: nothing is planned there.
|
|
1672
|
+
const sameBlock = () => ` ${paint("explanation", glyph("skip"))} ${p.sharedBlock.file} ${glyph("dash")} the same block as ${p.sharedBlock.display}, above`;
|
|
1673
|
+
const hostLines = (h) => {
|
|
1001
1674
|
const from = h.entry ? `from the registration ${h.entry}` : "from the host's own plugin system";
|
|
1002
|
-
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1675
|
+
return wrap(`${h.display} installs ${h.rows.join(", ")} itself, ${from}`, width, " ").split("\n").map((l, k) => (k ? ` ${paint("explanation", l.slice(4))}` : ` ${paint("explanation", glyph("skip"))} ${paint("explanation", l)}`));
|
|
1676
|
+
};
|
|
1677
|
+
const ids = [...p.harnesses, ...new Set(p.items.map((i) => i.harness).filter((h) => !p.harnesses.includes(h)))];
|
|
1678
|
+
for (const id of ids) {
|
|
1679
|
+
const entries = p.items.map((i, at) => ({ i, at })).filter((e) => e.i.harness === id);
|
|
1680
|
+
const others = (p.sharedBlock?.others || []).filter((o) => o.harness === id).sort((a, b) => a.at - b.at);
|
|
1681
|
+
const host = (p.hostManaged || []).filter((h) => h.harness === id);
|
|
1682
|
+
if (!entries.length && !others.length && !host.length) continue;
|
|
1683
|
+
const folded = verbose ? [] : entries.filter((e) => isUnsupported(e.i));
|
|
1684
|
+
// A registration this run fetched from another registry keeps its row, so
|
|
1685
|
+
// the registry lines beneath it show on every run, not only on a change.
|
|
1686
|
+
const quiet = verbose ? [] : entries.filter((e) => isQuiet(e.i) && !fetchedFor(p, e.i));
|
|
1687
|
+
const shown = entries.filter((e) => !folded.includes(e) && !quiet.includes(e));
|
|
1688
|
+
const specs = shown.map(({ i }) => ({ glyph: glyphName(i), role: WORD_ROLE[actionWord(i)], action: actionWord(i), target: rowTarget(p, i), note: rowNote(i, { verbose, glyph }) }));
|
|
1689
|
+
if (quiet.length) specs.push({ glyph: "unchanged", role: "explanation", action: "unchanged", target: paint("explanation", quiet.map((e) => surfaceWords(e.i.surface)).join(", ")) });
|
|
1690
|
+
// Unsupported rows keep one line per harness (contract 18), named in
|
|
1691
|
+
// words as the unchanged line names its rows; --verbose lists each with
|
|
1692
|
+
// the manifest's reason.
|
|
1693
|
+
if (folded.length) specs.push({ glyph: "unchanged", role: "explanation", action: "skipped", target: paint("explanation", `not on ${displayName(id)}: ${folded.map((e) => surfaceWords(e.i.surface)).join(", ")}`), note: "--verbose says why" });
|
|
1694
|
+
const drawn = rowLines(c, specs);
|
|
1695
|
+
lines.push("", paint("emphasis", displayName(id)));
|
|
1696
|
+
const pending = [...others];
|
|
1697
|
+
shown.forEach(({ i, at }, k) => {
|
|
1698
|
+
while (pending.length && pending[0].at <= at) { pending.shift(); lines.push(sameBlock()); }
|
|
1699
|
+
lines.push(...drawn[k], ...beneath(i));
|
|
1700
|
+
});
|
|
1701
|
+
for (const _ of pending) lines.push(sameBlock());
|
|
1702
|
+
for (const extra of drawn.slice(shown.length)) lines.push(...extra);
|
|
1703
|
+
for (const h of host) lines.push(...hostLines(h));
|
|
1704
|
+
}
|
|
1705
|
+
if (p.refusals.length) lines.push("");
|
|
1706
|
+
// A plan-level refusal: its message in the attention role, as an item's
|
|
1707
|
+
// refusal reason is (presentation spec contract 3 as amended 2026-10-11).
|
|
1708
|
+
p.refusals.forEach((r, k) => {
|
|
1709
|
+
const lead = ` ${paint("attention", glyph("refuse"))} ${paint("attention", "refused")} `;
|
|
1710
|
+
const pad = " ".repeat(plain(lead).length);
|
|
1711
|
+
// The stop (the converge story's rule 6) carries its commands apart from
|
|
1712
|
+
// its words: each is printed alone on its line after a blank one, with
|
|
1713
|
+
// nothing after it (contract 6), the way a person copies it.
|
|
1714
|
+
const stop = k === 0 && p.stop ? p.stop : null;
|
|
1715
|
+
// The same for a refusal that names its ways out (the channel "git" with
|
|
1716
|
+
// nothing to keep): its prose, then each lead and its command alone.
|
|
1717
|
+
const advice = k === 0 && !stop && p.refusalAdvice ? p.refusalAdvice : null;
|
|
1718
|
+
const [first, ...rest] = wrap(stop ? stop.lead : advice ? advice.text : r, width, pad).split("\n");
|
|
1719
|
+
lines.push(lead + paint("attention", first), ...rest.map((l) => pad + paint("attention", l.slice(pad.length))));
|
|
1720
|
+
if (stop) lines.push(...commandBlock(c, [stop.commands[0]], { indent: 4 }), "", ...prose(" ", stop.between), ...commandBlock(c, stop.commands.slice(1), { indent: 4 }));
|
|
1721
|
+
if (advice) for (const b of advice.blocks) lines.push("", ...prose(" ", b.lead), ...commandBlock(c, [b.command], { indent: 4 }));
|
|
1722
|
+
});
|
|
1723
|
+
// The closing lines, in a person's words: only what is listed is written,
|
|
1724
|
+
// and a $ line is the host's own CLI at work.
|
|
1725
|
+
const closing = [];
|
|
1726
|
+
if (p.ok && writes.length) closing.push(...prose(" ", `Only the entries listed above are written ${glyph("dash")} nothing else in those files is read, rewritten or removed.`));
|
|
1727
|
+
if (p.items.some((i) => (i.steps || []).some((s) => s.kind === "host" && s.name !== "keep"))) closing.push(...prose(" ", "Each $ line runs the host's own CLI, which writes the host-owned files named after it."));
|
|
1728
|
+
if (p.items.some((i) => isWrite(i) && (i.steps || []).some((s) => s.kind === "host" && s.name === "keep"))) closing.push(...prose(" ", "The kept copy's $ line runs that copy's own projectstore, which writes the files named after it."));
|
|
1729
|
+
if (closing.length) lines.push("", ...closing);
|
|
1730
|
+
if (!p.ok) lines.push("", ` ${paint("attention", "Nothing will be written: resolve the refusals above first.")}`);
|
|
1731
|
+
// A plan that wrote nothing because part of it could not be planned never
|
|
1732
|
+
// reads as converged (the converge story's keep, a copy with work pending).
|
|
1733
|
+
else if (!writes.length) lines.push("", p.incomplete ? " Nothing will be written: one surface could not be planned (see above)." : " Nothing to change.");
|
|
1012
1734
|
else if (p.incomplete) lines.push("", " One surface could not be planned (see above); the rest proceeds.");
|
|
1013
1735
|
return lines.join("\n") + "\n";
|
|
1014
1736
|
}
|
|
@@ -1023,7 +1745,7 @@ export function renderPreview(p, { verbose = false, verb = null, paint = (_style
|
|
|
1023
1745
|
export function isInteractive({ stdin = null, stdout = null, env = process.env, json = false } = {}) {
|
|
1024
1746
|
if (json) return false;
|
|
1025
1747
|
if (!(stdin && stdin.isTTY && stdout && stdout.isTTY)) return false;
|
|
1026
|
-
if (env
|
|
1748
|
+
if (envFlag(env, "CI")) return false;
|
|
1027
1749
|
// A host session's own tool may run us in a pseudo-terminal; the manifests'
|
|
1028
1750
|
// session markers say when that is happening — every manifest's, not only
|
|
1029
1751
|
// the planned harness's: a Claude Code agent running the Codex shell is
|
|
@@ -1033,19 +1755,36 @@ export function isInteractive({ stdin = null, stdout = null, env = process.env,
|
|
|
1033
1755
|
return true;
|
|
1034
1756
|
}
|
|
1035
1757
|
|
|
1758
|
+
// Will this run ask? The gate's own test, shared with the shell fetch's
|
|
1759
|
+
// decision so a run is fetched for exactly when it can be asked: passing
|
|
1760
|
+
// `ask` means "this is a terminal" (the tests' TTY double).
|
|
1761
|
+
export function asksAtTerminal({ stdin = null, stdout = null, ask = null, env = process.env, json = false } = {}) {
|
|
1762
|
+
return !json && (ask ? true : isInteractive({ stdin, stdout, env, json }));
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1036
1765
|
// Streams and `ask` are parameters so the terminal branch is testable without
|
|
1037
1766
|
// a pseudo-terminal; passing `ask` means "this is a terminal". Without streams
|
|
1038
|
-
// the library never asks — the bin and main() pass theirs.
|
|
1039
|
-
|
|
1767
|
+
// the library never asks — the bin and main() pass theirs. `caps` is the
|
|
1768
|
+
// asking stream's (colour and glyphs), read from `stdout` when not given;
|
|
1769
|
+
// with neither, the question is unpainted, in Unicode.
|
|
1770
|
+
//
|
|
1771
|
+
// One answer more (the converge story's rule 2): a channel switch planned
|
|
1772
|
+
// under no channel answer is the ADR's choice made at a terminal — asked
|
|
1773
|
+
// there, previewed, never silent. Without one, naming the harness does not
|
|
1774
|
+
// confirm it, a bare run is refused for it too, and nothing is written:
|
|
1775
|
+
// `channel`, ahead of the whole non-interactive branch. A plan built without
|
|
1776
|
+
// plan() (no `channel` on it) reads as no answer.
|
|
1777
|
+
export const switchUnanswered = (p) => (p.channel ?? null) === null && p.items.some(isSwitch);
|
|
1778
|
+
export async function confirm(p, { stdin = null, stdout = null, ask = null, env = process.env, json = false, caps = stdout ? termCaps(stdout, env) : null } = {}) {
|
|
1040
1779
|
if (!p.ok) return { confirmed: false, why: "refused" };
|
|
1041
1780
|
const writes = p.items.filter(isWrite);
|
|
1042
1781
|
if (!writes.length) return { confirmed: false, why: "nothing-to-do" };
|
|
1043
|
-
const interactive =
|
|
1782
|
+
const interactive = asksAtTerminal({ stdin, stdout, ask, env, json });
|
|
1783
|
+
if (!interactive && switchUnanswered(p)) return { confirmed: false, why: "channel" };
|
|
1044
1784
|
if (!interactive) return p.named ? { confirmed: true, why: "named" } : { confirmed: false, why: "non-tty" };
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
return /^(y(es)?)?$/i.test(String(answer).trim()) ? { confirmed: true, why: "answered" } : { confirmed: false, why: "declined" };
|
|
1785
|
+
// The question's bytes and its answer rule are term.mjs's, shared with
|
|
1786
|
+
// `scaffold --write`; the refused / nothing / named branches above stay here.
|
|
1787
|
+
return (await askApply(writes.length, { stdin, stdout, ask, ...(caps ? { caps } : {}) })) ? { confirmed: true, why: "answered" } : { confirmed: false, why: "declined" };
|
|
1049
1788
|
}
|
|
1050
1789
|
|
|
1051
1790
|
// ─── apply ─────────────────────────────────────────────────────────────
|
|
@@ -1055,6 +1794,10 @@ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(
|
|
|
1055
1794
|
const done = [];
|
|
1056
1795
|
let registrationFailed = false;
|
|
1057
1796
|
let layoutFailed = false;
|
|
1797
|
+
// Per harness: whether its registration applied without failure, and each
|
|
1798
|
+
// switch's outcome — what the order rule and the mark read.
|
|
1799
|
+
const registered = new Map();
|
|
1800
|
+
const switched = new Map();
|
|
1058
1801
|
// One item's writes, returning the record apply reports for it. The order
|
|
1059
1802
|
// and the failure rules are the loop's; onItem only watches (contract 18).
|
|
1060
1803
|
const one = (i) => {
|
|
@@ -1063,12 +1806,44 @@ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(
|
|
|
1063
1806
|
if (r.failed) { done.failed = r.failed; layoutFailed = true; }
|
|
1064
1807
|
return r;
|
|
1065
1808
|
}
|
|
1809
|
+
if (i.kind === "registration" && isSwitch(i)) {
|
|
1810
|
+
// The order (the converge story's rule 2): a switch applies only when
|
|
1811
|
+
// this run's registration for the same harness is current, or applied
|
|
1812
|
+
// without failure. Turning the copy off after a failed registration left
|
|
1813
|
+
// the checkout loading no copy at all (measured on 0.29.2).
|
|
1814
|
+
const reg = p.items.find((x) => x.harness === i.harness && x.kind === "registration" && !String(x.surface).endsWith("_others"));
|
|
1815
|
+
const ready = Boolean(reg) && (isWrite(reg) ? registered.get(i.harness) === true : reg.state === "current");
|
|
1816
|
+
if (!ready) {
|
|
1817
|
+
if (!switched.has(i.harness)) switched.set(i.harness, []);
|
|
1818
|
+
switched.get(i.harness).push(false);
|
|
1819
|
+
return { path: i.path, action: "skipped", surface: i.surface, reason: `the npm registration did not complete; ${i.entry} stays on for this checkout` };
|
|
1820
|
+
}
|
|
1821
|
+
const r = applyRegistration(p, i, { env, spawn, home, onStep });
|
|
1822
|
+
if (r.failed) { done.failed = r.failed; registrationFailed = true; }
|
|
1823
|
+
if (!switched.has(i.harness)) switched.set(i.harness, []);
|
|
1824
|
+
switched.get(i.harness).push(!r.failed);
|
|
1825
|
+
return r;
|
|
1826
|
+
}
|
|
1066
1827
|
if (i.kind === "registration") {
|
|
1067
1828
|
const r = applyRegistration(p, i, { env, spawn, home, onStep });
|
|
1068
1829
|
// A registration that did not complete leaves the surfaces planned against
|
|
1069
1830
|
// its install path unwritten: a launcher pointing at nothing is worse than
|
|
1070
1831
|
// none. Surfaces rendered from the package root (the block) still apply.
|
|
1071
1832
|
if (r.failed) { done.failed = r.failed; registrationFailed = true; }
|
|
1833
|
+
registered.set(i.harness, !r.failed);
|
|
1834
|
+
return r;
|
|
1835
|
+
}
|
|
1836
|
+
if (i.kind === "binding") {
|
|
1837
|
+
const r = applyMark(p, i, { env, onStep, switched: switched.get(i.harness) || [] });
|
|
1838
|
+
if (r.failed) done.failed = r.failed;
|
|
1839
|
+
return r;
|
|
1840
|
+
}
|
|
1841
|
+
if (i.kind === "channel") {
|
|
1842
|
+
// The keep (the converge story's rule 5): the kept copy's own upgrade.
|
|
1843
|
+
// A run it left incomplete makes this one incomplete; it is not a stop.
|
|
1844
|
+
const r = applyKeep(p, i, { env, spawn, home, onStep });
|
|
1845
|
+
if (r.failed) done.failed = r.failed;
|
|
1846
|
+
if (r.incomplete) done.incomplete = true;
|
|
1072
1847
|
return r;
|
|
1073
1848
|
}
|
|
1074
1849
|
if (registrationFailed && i.plannedAgainst) return { path: i.path, action: "skipped", surface: i.surface, reason: "the registration did not complete; this surface was planned against its install path" };
|
|
@@ -1110,6 +1885,94 @@ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(
|
|
|
1110
1885
|
return done;
|
|
1111
1886
|
}
|
|
1112
1887
|
|
|
1888
|
+
// The mark (the converge story's rule 2), at apply: the binding is the one
|
|
1889
|
+
// configPath resolves now — after the layout item moved it, or a bind wrote it
|
|
1890
|
+
// — and must be the one the preview named; a mark is never written to a file
|
|
1891
|
+
// the preview did not name, so a legacy binding is never re-created. Skipped,
|
|
1892
|
+
// with the reason, when no binding exists, when a switch it records was
|
|
1893
|
+
// skipped or failed in this run, or when a mark is already there. Every other
|
|
1894
|
+
// key of the binding is kept.
|
|
1895
|
+
function applyMark(p, i, { env, onStep = null, switched = [] }) {
|
|
1896
|
+
const out = { path: i.path, action: i.action, surface: i.surface };
|
|
1897
|
+
const skipped = (reason) => ({ ...out, action: "skipped", reason });
|
|
1898
|
+
if (switched.some((ok) => !ok)) return skipped("the switch did not complete; nothing is recorded");
|
|
1899
|
+
const at = configPath(p.projectDir, env);
|
|
1900
|
+
if (!existsSync(at)) return skipped("the project is not bound; the switch is recorded once it is");
|
|
1901
|
+
if (resolve(at) !== resolve(i.path)) return skipped(`the binding is at ${at}, not where the plan named it; nothing is recorded`);
|
|
1902
|
+
let cfg;
|
|
1903
|
+
try { cfg = JSON.parse(readFileSync(at, "utf8")); } catch { cfg = null; }
|
|
1904
|
+
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) return skipped(`${at} is not a JSON object; the switch is not recorded`);
|
|
1905
|
+
if (channelMark(cfg)) return { ...out, action: "skip", reason: "the switch is already recorded" };
|
|
1906
|
+
const st = (i.steps || []).find((x) => x.kind === "mark") || { kind: "mark", path: at, key: CHANNEL_SWITCH_KEY };
|
|
1907
|
+
if (onStep) onStep(st, "start");
|
|
1908
|
+
try {
|
|
1909
|
+
writeFileAtomic(at, JSON.stringify({ ...cfg, [CHANNEL_SWITCH_KEY]: { by: "setup", at: new Date().toISOString() } }, null, 2) + "\n", { sweep: false });
|
|
1910
|
+
} catch (e) {
|
|
1911
|
+
if (onStep) onStep(st, "end", { ok: false });
|
|
1912
|
+
return { ...out, failed: { step: "mark", status: null, stderr: e && e.message ? e.message : String(e) } };
|
|
1913
|
+
}
|
|
1914
|
+
if (onStep) onStep(st, "end", { ok: true });
|
|
1915
|
+
return { ...out, steps: [{ kind: "mark", path: at, ok: true }] };
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// The keep step (the converge story's rule 5), at apply: the kept copy's own
|
|
1919
|
+
// upgrade, spawned as a host command is — the project as its working
|
|
1920
|
+
// directory, the run's environment with the copy's root as the plugin root and
|
|
1921
|
+
// the harness home pinned, 120 s — through the caller's spawn, so APPLY prints
|
|
1922
|
+
// its $ line. Its envelope is read: one APPLY line beneath per item the copy
|
|
1923
|
+
// applied, the ones the preview did not list marked "(not in the preview)" —
|
|
1924
|
+
// the copy plans afresh, and a file that changed after the question can add a
|
|
1925
|
+
// row. STOPPED (contract 19's cause form, the argv, the status and stderr) is
|
|
1926
|
+
// the copy's own failure, an `ok` it reports false, a gate of its that
|
|
1927
|
+
// confirmed nothing, or no envelope to read: what it wrote is then not known.
|
|
1928
|
+
// The copy's run left incomplete leaves this one incomplete.
|
|
1929
|
+
function applyKeep(p, i, { env, spawn, home, onStep = null }) {
|
|
1930
|
+
const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
|
|
1931
|
+
const st = (i.steps || []).find((x) => x.kind === "host");
|
|
1932
|
+
const harness = loadHarness(i.harness);
|
|
1933
|
+
const [bin, ...argv] = st.argv;
|
|
1934
|
+
const r = spawn(bin, argv, { env: keepEnv(env, { harness, projectDir: p.projectDir, copyRoot: i.path, harnessHome: i.home || claudeHome(home) }), cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: HOST_BUDGET_MS, maxBuffer: 1 << 26 });
|
|
1935
|
+
const status = r.status ?? null;
|
|
1936
|
+
const stderr = String(r.stderr || "").trim();
|
|
1937
|
+
const result = keepResult(r.stdout);
|
|
1938
|
+
const stop = (said) => {
|
|
1939
|
+
out.steps.push({ kind: "host", argv: st.argv, status, ok: false, stderr: said });
|
|
1940
|
+
out.failed = { step: "keep", status, stderr: said, argv: st.argv };
|
|
1941
|
+
// How it ended, for STOPPED, as a host command's (not enumerable).
|
|
1942
|
+
Object.defineProperty(out.failed, "couldNotStart", { value: Boolean(r.error) && !r.signal && status === null, enumerable: false });
|
|
1943
|
+
Object.defineProperty(out.failed, "signal", { value: r.signal || null, enumerable: false });
|
|
1944
|
+
return out;
|
|
1945
|
+
};
|
|
1946
|
+
if (r.error && !result) return stop(startFailure(r.error, { bin, resolved: bin, cwd: p.projectDir }));
|
|
1947
|
+
if (!result) return stop([stderr, `${i.entry} printed no result to read, so what it wrote is not known; its own plan starts from what is on disk now`].filter(Boolean).join("\n"));
|
|
1948
|
+
// What the copy applied, beneath its $ line — even when it then stopped.
|
|
1949
|
+
const listed = new Set((st.touches || []).map((t) => `${t.surface}\n${t.path}`));
|
|
1950
|
+
const applied = Array.isArray(result.applied) ? result.applied.filter((x) => x && typeof x === "object" && !["skip", "skipped"].includes(x.action)) : [];
|
|
1951
|
+
const kept = [];
|
|
1952
|
+
for (const rec of applied) {
|
|
1953
|
+
const sub = { kind: "kept", surface: rec.surface ?? null, action: rec.action ?? null, path: rec.path ?? null, ...(listed.has(`${rec.surface}\n${rec.path}`) ? {} : { unplanned: true }) };
|
|
1954
|
+
if (onStep) { onStep(sub, "start"); onStep(sub, "end", { ok: !rec.failed }); }
|
|
1955
|
+
kept.push({ ...sub, ok: !rec.failed });
|
|
1956
|
+
}
|
|
1957
|
+
const gate = result.gate && typeof result.gate === "object" ? result.gate : null;
|
|
1958
|
+
const said = [];
|
|
1959
|
+
if (result.failed && typeof result.failed === "object") {
|
|
1960
|
+
const f = result.failed;
|
|
1961
|
+
said.push(`${i.entry} stopped at ${f.step || "a step"}${f.status !== null && f.status !== undefined ? ` (exit ${f.status})` : ""}${Array.isArray(f.argv) ? `: $ ${f.argv.join(" ")}` : ""}`, ...String(f.stderr || "").split("\n").filter(Boolean));
|
|
1962
|
+
} else if (result.ok === false) said.push(`${i.entry} reported that its run did not succeed`);
|
|
1963
|
+
else if (gate && gate.confirmed !== true && gate.why !== "nothing-to-do") said.push(`${i.entry} wrote nothing: its own gate answered ${gate.why || "no"}`);
|
|
1964
|
+
if (said.length) {
|
|
1965
|
+
for (const x of (Array.isArray(result.refusals) ? result.refusals : [])) said.push(String(x));
|
|
1966
|
+
const back = stop([stderr, ...said].filter(Boolean).join("\n"));
|
|
1967
|
+
back.steps.push(...kept);
|
|
1968
|
+
return back;
|
|
1969
|
+
}
|
|
1970
|
+
out.steps.push({ kind: "host", argv: st.argv, status, ok: true }, ...kept);
|
|
1971
|
+
if (!applied.length) { out.action = "skip"; out.reason = `${i.entry} found nothing to change`; }
|
|
1972
|
+
if (result.incomplete === true) out.incomplete = true;
|
|
1973
|
+
return out;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1113
1976
|
// The registration's steps, in order, each leaving a state plan() can read.
|
|
1114
1977
|
// The host binary runs with the harness home pinned in its environment — the
|
|
1115
1978
|
// same home the plan was read from — and with the project as its cwd, which is
|
|
@@ -1131,10 +1994,14 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
|
|
|
1131
1994
|
if (lock !== null) { try { closeSync(lock); } catch {} lock = null; }
|
|
1132
1995
|
if (lockPath) { try { unlinkSync(lockPath); } catch {} lockPath = null; }
|
|
1133
1996
|
};
|
|
1997
|
+
// What the plan analysed with (planPortableRegistration's private record):
|
|
1998
|
+
// the recheck and the read-back below re-analyse with exactly these — the
|
|
1999
|
+
// fetched payload's root, or the version a version-only row was read at.
|
|
2000
|
+
const analysed = { root: p.root, payloadRoot: i.analysed?.payloadRoot || null, version: i.analysed?.version || null, home, harness, env: childEnv, ignoreJournal: true };
|
|
1134
2001
|
const readHostList = () => {
|
|
1135
2002
|
const argv = s.cli.commands.list || [];
|
|
1136
2003
|
const bin = whichOnPathFromLib(s.cli.bin, env);
|
|
1137
|
-
const r = spawn(bin || s.cli.bin, argv, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout:
|
|
2004
|
+
const r = spawn(bin || s.cli.bin, argv, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: HOST_BUDGET_MS });
|
|
1138
2005
|
const rows = !r.error && r.status === 0 ? portableListFacts(r.stdout) : null;
|
|
1139
2006
|
out.steps.push({ kind: "portable-recovery-list", argv: [s.cli.bin, ...argv], status: r.status ?? null, ok: Boolean(rows) });
|
|
1140
2007
|
return { rows, stderr: r.error ? startFailure(r.error, { bin: s.cli.bin, resolved: bin, cwd: p.projectDir }) : String(r.stderr || "").trim() };
|
|
@@ -1207,6 +2074,12 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
|
|
|
1207
2074
|
: `${why}; ${journalPath} was retained and blocks automatic mutation until a later run proves the previous state` };
|
|
1208
2075
|
return out;
|
|
1209
2076
|
};
|
|
2077
|
+
// A staging step with no payload is a dry run's row (a fetch the run never
|
|
2078
|
+
// made); it is refused before the lock, with nothing staged or spawned.
|
|
2079
|
+
if (portable && (i.steps || []).some((st) => st.kind === "portable-write" && !st.from)) {
|
|
2080
|
+
out.failed = { step: "portable-write", status: null, stderr: `${i.entry}: this row was planned without ${harness.display_name}'s payload — its shell was not fetched — so there is nothing to stage; nothing is written. Run install or upgrade from a terminal, where the shell is fetched` };
|
|
2081
|
+
return out;
|
|
2082
|
+
}
|
|
1210
2083
|
if (portable) {
|
|
1211
2084
|
const lockDir = join(i.home, "projectstore");
|
|
1212
2085
|
mkdirSync(lockDir, { recursive: true });
|
|
@@ -1288,7 +2161,7 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
|
|
|
1288
2161
|
return out;
|
|
1289
2162
|
}
|
|
1290
2163
|
}
|
|
1291
|
-
const current = analysePortableRegistration(p.projectDir, s,
|
|
2164
|
+
const current = analysePortableRegistration(p.projectDir, s, analysed);
|
|
1292
2165
|
const desiredCurrent = current.state === "current"
|
|
1293
2166
|
&& current.desiredVersion === i.verify?.version
|
|
1294
2167
|
&& current.desiredDigest?.sha256 === i.verify?.digest?.sha256
|
|
@@ -1318,7 +2191,7 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
|
|
|
1318
2191
|
return out;
|
|
1319
2192
|
}
|
|
1320
2193
|
}
|
|
1321
|
-
const fail = (step, status, stderr, argv = null) => {
|
|
2194
|
+
const fail = (step, status, stderr, argv = null, ended = null) => {
|
|
1322
2195
|
if (staged) {
|
|
1323
2196
|
// A first install can fail after Codex has accepted the marketplace but
|
|
1324
2197
|
// before verification. Best-effort compensation happens while the staged
|
|
@@ -1331,7 +2204,7 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
|
|
|
1331
2204
|
if (!Array.isArray(template)) continue;
|
|
1332
2205
|
const args = template.map((t) => t.replace(/\{(\w+)\}/g, (_, k) => fill[k] ?? `{${k}}`));
|
|
1333
2206
|
const bin = whichOnPathFromLib(s.cli.bin, env);
|
|
1334
|
-
const r = spawn(bin || s.cli.bin, args, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout:
|
|
2207
|
+
const r = spawn(bin || s.cli.bin, args, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: HOST_BUDGET_MS });
|
|
1335
2208
|
const ok = !r.error && r.status === 0;
|
|
1336
2209
|
out.steps.push({ kind: "portable-compensate", name, argv: [s.cli.bin, ...args], status: r.status ?? null, ok, ...(ok ? {} : { stderr: r.error ? startFailure(r.error, { bin: s.cli.bin, resolved: bin, cwd: p.projectDir }) : String((r.stderr || "") + (r.stdout || "")).trim() }) });
|
|
1337
2210
|
}
|
|
@@ -1351,6 +2224,13 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
|
|
|
1351
2224
|
staged = null;
|
|
1352
2225
|
}
|
|
1353
2226
|
out.failed = { step, status, stderr, ...(argv ? { argv } : {}) };
|
|
2227
|
+
// How a host command ended, for STOPPED (presentation spec contract 8):
|
|
2228
|
+
// whether it could start at all, and the signal that stopped one that
|
|
2229
|
+
// did. Not enumerable, so the --json envelope keeps the record's shape.
|
|
2230
|
+
if (ended) {
|
|
2231
|
+
Object.defineProperty(out.failed, "couldNotStart", { value: Boolean(ended.couldNotStart), enumerable: false });
|
|
2232
|
+
Object.defineProperty(out.failed, "signal", { value: ended.signal || null, enumerable: false });
|
|
2233
|
+
}
|
|
1354
2234
|
releaseLock();
|
|
1355
2235
|
return out;
|
|
1356
2236
|
};
|
|
@@ -1384,7 +2264,8 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
|
|
|
1384
2264
|
// Re-check at apply time what plan() proved: the directory is absent or ours.
|
|
1385
2265
|
if (existsSync(st.path)) {
|
|
1386
2266
|
let ours = false;
|
|
1387
|
-
|
|
2267
|
+
// The one reader of the provenance field (contract 2, amended 2026-10-10).
|
|
2268
|
+
try { ours = Boolean(registrationProvenance(JSON.parse(readFileSync(manifestPath, "utf8")), s)); } catch {}
|
|
1388
2269
|
if (!ours) { out.steps.push({ kind: "write", ok: false }); return fail("write", null, `${st.path} changed under the plan: its manifest is no longer ours; nothing is written`); }
|
|
1389
2270
|
}
|
|
1390
2271
|
if (st.manifestOnly) writeFileAtomic(manifestPath, JSON.stringify(st.manifest, null, 2) + "\n", { sweep: false });
|
|
@@ -1397,12 +2278,25 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
|
|
|
1397
2278
|
if (!settings || typeof settings !== "object" || Array.isArray(settings)) { out.steps.push({ kind: "unregister", ok: false }); return fail("unregister", null, `${st.path} is not a JSON object; the marketplace entry was not removed`); }
|
|
1398
2279
|
if (settings[st.pointer] && typeof settings[st.pointer] === "object") { delete settings[st.pointer][st.name]; writeFileAtomic(st.path, JSON.stringify(settings, null, 2) + "\n", { sweep: false }); }
|
|
1399
2280
|
out.steps.push({ kind: "unregister", path: st.path, ok: true });
|
|
2281
|
+
} else if (st.kind === "unmark") {
|
|
2282
|
+
// The switch's record goes with the registration (the converge story's
|
|
2283
|
+
// rule 4): the one key, from the binding configPath resolves now; every
|
|
2284
|
+
// other key stays. A binding already without it is left as it is.
|
|
2285
|
+
const at = configPath(p.projectDir, env);
|
|
2286
|
+
let cfg = null;
|
|
2287
|
+
if (existsSync(at)) {
|
|
2288
|
+
try { cfg = JSON.parse(readFileSync(at, "utf8")); } catch { cfg = null; }
|
|
2289
|
+
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) { out.steps.push({ kind: "unmark", ok: false }); return fail("unmark", null, `${at} is not a JSON object; ${st.key} was not removed`); }
|
|
2290
|
+
}
|
|
2291
|
+
const has = Boolean(cfg) && Object.hasOwn(cfg, st.key);
|
|
2292
|
+
if (has) { delete cfg[st.key]; writeFileAtomic(at, JSON.stringify(cfg, null, 2) + "\n", { sweep: false }); }
|
|
2293
|
+
out.steps.push({ kind: "unmark", path: at, ok: true, removed: has });
|
|
1400
2294
|
} else if (st.kind === "remove") {
|
|
1401
2295
|
removeOwnTree(st.path, home);
|
|
1402
2296
|
out.steps.push({ kind: "remove", path: st.path, ok: true });
|
|
1403
2297
|
} else if (st.kind === "host") {
|
|
1404
2298
|
const bin = whichOnPathFromLib(st.bin, env);
|
|
1405
|
-
const r = spawn(bin || st.bin, st.argv, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout:
|
|
2299
|
+
const r = spawn(bin || st.bin, st.argv, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: HOST_BUDGET_MS });
|
|
1406
2300
|
const ran = !r.error && r.status === 0;
|
|
1407
2301
|
// A preflight that ran must also answer in the shape verification reads:
|
|
1408
2302
|
// a host that exits 0 with text would pass on its status alone.
|
|
@@ -1422,8 +2316,12 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
|
|
|
1422
2316
|
: said;
|
|
1423
2317
|
out.steps.push({ kind: "host", argv: [st.bin, ...st.argv], status: r.status ?? null, ok, ...(ok ? {} : { stderr: told }) });
|
|
1424
2318
|
// An unreadable answer exited 0; STOPPED names it by what it said, as
|
|
1425
|
-
// the final list's own failure does, not by an "exited 0".
|
|
1426
|
-
|
|
2319
|
+
// the final list's own failure does, not by an "exited 0". A command
|
|
2320
|
+
// that could not start has an error and neither a status nor a signal
|
|
2321
|
+
// (ENOENT, EACCES); a timeout's error comes with the signal that
|
|
2322
|
+
// stopped it, so that command ran.
|
|
2323
|
+
const couldNotStart = Boolean(r.error) && !r.signal && (r.status === null || r.status === undefined);
|
|
2324
|
+
if (!ok) return fail(st.name, unreadable ? null : r.status ?? null, told, [st.bin, ...st.argv], { couldNotStart, signal: r.signal || null });
|
|
1427
2325
|
if (portable && st.name === "list") {
|
|
1428
2326
|
hostList = portableListFact(r.stdout, i.entry);
|
|
1429
2327
|
if (!hostList) return fail("list", null, `${st.bin} ${st.argv.join(" ")} did not report ${i.entry} in JSON output`, [st.bin, ...st.argv]);
|
|
@@ -1440,7 +2338,7 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
|
|
|
1440
2338
|
// was rendered against must be the one the host recorded for this checkout.
|
|
1441
2339
|
if (i.verify) {
|
|
1442
2340
|
const a = portable
|
|
1443
|
-
? analysePortableRegistration(p.projectDir, s,
|
|
2341
|
+
? analysePortableRegistration(p.projectDir, s, analysed)
|
|
1444
2342
|
: analyseRegistration(p.projectDir, s, { root: p.root, home, harness, env });
|
|
1445
2343
|
if (portable) {
|
|
1446
2344
|
const sourceDigestOk = a.sourceDigest?.sha256 === i.verify.digest?.sha256 && a.sourceDigest?.count === i.verify.digest?.count;
|
|
@@ -1472,18 +2370,8 @@ function homeEnvName(harnessId) {
|
|
|
1472
2370
|
try { return loadHarness(harnessId).runtime.home_env; } catch { return sourceHarness().runtime.home_env; }
|
|
1473
2371
|
}
|
|
1474
2372
|
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
// (measured 2026-10-05). The filesystem tells them apart, in this order (the
|
|
1478
|
-
// install spec, contract 19); any other error passes through as Node wrote it.
|
|
1479
|
-
function startFailure(err, { bin, resolved, cwd }) {
|
|
1480
|
-
let dir = false;
|
|
1481
|
-
try { dir = statSync(cwd).isDirectory(); } catch {}
|
|
1482
|
-
if (!dir) return `${bin} could not start: the working directory ${JSON.stringify(String(cwd))} is not an existing directory`;
|
|
1483
|
-
if (!resolved) return `${bin} could not start: it was not found on PATH`;
|
|
1484
|
-
if (["ENOENT", "EACCES", "ENOEXEC"].includes(err?.code)) return `${bin} could not start: ${resolved} exists but could not be run (${err.code})`;
|
|
1485
|
-
return String(err?.message || err);
|
|
1486
|
-
}
|
|
2373
|
+
// startFailure — what Node cannot say about a child that did not start —
|
|
2374
|
+
// lives in run-host.mjs, shared with the shell fetch (contract 19).
|
|
1487
2375
|
|
|
1488
2376
|
// rmdirSync refuses a non-empty directory — that refusal IS the guarantee
|
|
1489
2377
|
// (contract 13): we prune only a directory we emptied. The runtime dir's own
|
|
@@ -1500,70 +2388,309 @@ function pruneEmptyDir(dir, projectDir) {
|
|
|
1500
2388
|
|
|
1501
2389
|
// ─── verbs ─────────────────────────────────────────────────────────────
|
|
1502
2390
|
|
|
2391
|
+
// What `plan` is given by both entry points (main() and the bin's
|
|
2392
|
+
// runInstallVerb), so the two cannot drift: the root, and its version for a
|
|
2393
|
+
// shell-rooted registration planned without its payload — the dry run.
|
|
2394
|
+
export function planOptions(opts = {}) {
|
|
2395
|
+
const root = opts.root || pluginRoot();
|
|
2396
|
+
return { ...opts, root, version: opts.version ?? rootVersion(root) };
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
// Before plan(): the shell fetch (the shell-fetch story's rules 1–4 and 9).
|
|
2400
|
+
// For each harness in the run's set whose registration is shell-rooted, rule
|
|
2401
|
+
// 1 decides from the manifest, the environment, the run's root and a
|
|
2402
|
+
// version-only analysis; a harness that passes is fetched once, verified and
|
|
2403
|
+
// handed to plan() as payloadRoots[id]. The selection refusals (an unknown
|
|
2404
|
+
// harness, a missing project, no harness) are plan()'s and come first: none
|
|
2405
|
+
// of them fetches. A failed fetch is a refusal before the plan: a stub with
|
|
2406
|
+
// plan()'s early-return keys, so every caller reads it as it reads a refused
|
|
2407
|
+
// plan. `cleanup` removes the run's scratch — the caller calls it in a
|
|
2408
|
+
// `finally`, after apply(), since staging copies from it; best effort, the
|
|
2409
|
+
// next fetching run's sweep converges on whatever it leaves. Ctrl+C here is
|
|
2410
|
+
// the terminal's SIGINT and leaves the scratch to that sweep: no listener is
|
|
2411
|
+
// held before the plan.
|
|
2412
|
+
// opts.onFetch(label, ids) → a live line ({ end, abort }) or null
|
|
2413
|
+
// opts.fetchSpawn, opts.fetchBudget → the tests' npm and clock
|
|
2414
|
+
//
|
|
2415
|
+
// Before the fetch, the converge story's rules 6 and 8, in this order (its
|
|
2416
|
+
// Technical Notes): the copies this project runs (read-only); the registry
|
|
2417
|
+
// read — awaited when a copy is newer than this root, else started for the
|
|
2418
|
+
// update notice and awaited after the fetch, before PLAN; the stop, which
|
|
2419
|
+
// returns here with nothing fetched, written, locked or spawned but the
|
|
2420
|
+
// registry resolver; the keep read, for the channel "git" (rule 5): the kept
|
|
2421
|
+
// copy's own plan, read through its bin, and no fetch when plan() will refuse;
|
|
2422
|
+
// then the fetch, which skips a newer registration. The user's registry
|
|
2423
|
+
// is resolved at most once: for a read, and by every run that fetches a shell
|
|
2424
|
+
// — which also resolves the project's, and names it in PLAN when the two
|
|
2425
|
+
// differ (the 2026-10-09 amendment). A run that neither fetches nor reads
|
|
2426
|
+
// resolves neither. On the notice-only path the resolver and the request
|
|
2427
|
+
// share one 2 s bound.
|
|
2428
|
+
// opts.notice → the caller prints text (runVerb's `out`): the notice may show
|
|
2429
|
+
// opts.fetch, opts.now, opts.timers → the tests' registry read and clock
|
|
2430
|
+
async function prepareRun(verb, projectDir, opts = {}) {
|
|
2431
|
+
const env = opts.env || process.env;
|
|
2432
|
+
const home = opts.home || homedir();
|
|
2433
|
+
const root = opts.root || pluginRoot();
|
|
2434
|
+
const version = rootVersion(root);
|
|
2435
|
+
const none = { version, payloadRoots: {}, fetched: [], refused: null, convergence: null, notice: null, cleanup: () => {} };
|
|
2436
|
+
if (!["install", "upgrade"].includes(verb)) return none;
|
|
2437
|
+
projectDir = resolve(projectDir);
|
|
2438
|
+
const state = verb === "upgrade";
|
|
2439
|
+
const named = (opts.harnesses || []).filter(Boolean);
|
|
2440
|
+
if (named.some((id) => !harnessIds().includes(id)) || projectDirRefusal(projectDir)) return none;
|
|
2441
|
+
const detected = detectHarnesses(projectDir, { state });
|
|
2442
|
+
const ids = named.length ? named : detected.map((d) => d.id);
|
|
2443
|
+
if (!ids.length) return none;
|
|
2444
|
+
// One version-only analysis per shell-rooted registration, shared by the
|
|
2445
|
+
// running copies and the fetch's decision.
|
|
2446
|
+
const analyses = new Map();
|
|
2447
|
+
const analyse = (harness, s) => {
|
|
2448
|
+
if (!analyses.has(harness.id)) analyses.set(harness.id, analysePortableRegistration(projectDir, s, { root, home, harness, env, version }));
|
|
2449
|
+
return analyses.get(harness.id);
|
|
2450
|
+
};
|
|
2451
|
+
// 1. The user's registry: resolved once, when first needed (the 2026-10-09
|
|
2452
|
+
// amendment); null when npm could not say.
|
|
2453
|
+
let userRegistry = null;
|
|
2454
|
+
const userRegistryOf = (budget) => (userRegistry ||= npmUserRegistry({ home, env, spawn: opts.fetchSpawn, budget }).catch(() => null));
|
|
2455
|
+
// The read. `shared`: the resolver and the request fit in one bound — the
|
|
2456
|
+
// notice-only path's 2 s; otherwise the request has its own 2 s after a
|
|
2457
|
+
// resolver allowed rule 6's 10 s.
|
|
2458
|
+
const read = (budget, { atLeast = null, shared = false } = {}) => {
|
|
2459
|
+
const t0 = Date.now();
|
|
2460
|
+
return userRegistryOf(budget)
|
|
2461
|
+
.then((registry) => {
|
|
2462
|
+
const timeoutMs = shared ? budget - (Date.now() - t0) : READ_TIMEOUT_MS;
|
|
2463
|
+
if (timeoutMs < 1) return { latest: null, from: null, error: `no answer within ${budget / 1000} s` };
|
|
2464
|
+
return readLatest({ registry: registry || DEFAULT_REGISTRY, cachePath: cachePaths({ env, home }).registry, fetch: opts.fetch, now: opts.now, timers: opts.timers, atLeast, timeoutMs });
|
|
2465
|
+
})
|
|
2466
|
+
.catch((e) => ({ latest: null, from: null, error: String(e?.message || e) }));
|
|
2467
|
+
};
|
|
2468
|
+
// 2. The copies this project runs: from a package root or a checkout only;
|
|
2469
|
+
// a host's copy is updated through its own channel and never stopped.
|
|
2470
|
+
const checks = Boolean(version) && checksRoot(root, { home, env });
|
|
2471
|
+
// Under the channel "git" no host plugin copy counts: the run renders
|
|
2472
|
+
// nothing for that harness (rule 6). "npm" and null count them alike.
|
|
2473
|
+
const copies = checks ? runningCopies(projectDir, { harnesses: ids, root, home, env, version, analyse, channel: opts.channel ?? null }) : [];
|
|
2474
|
+
const newer = newerCopies(copies, version);
|
|
2475
|
+
const off = updateCheckOff(env);
|
|
2476
|
+
// The notice: text mode, from a package root, never under CI or the switch.
|
|
2477
|
+
const wantNotice = Boolean(opts.notice) && !opts.json && Boolean(version) && noticeRoot(root, { home, env }) && !envFlag(env, "CI") && !off;
|
|
2478
|
+
// 3. The read: one request per run at most, shared by rule 6 and the notice.
|
|
2479
|
+
// --json and CI do not switch rule 6's read off: it decides what is written.
|
|
2480
|
+
let answer = null, pending = null;
|
|
2481
|
+
if (newer.length && !off) answer = await read(REGISTRY_BUDGET_MS, { atLeast: lowestVersion(newer) });
|
|
2482
|
+
else if (wantNotice) pending = read(NOTICE_BUDGET_MS, { shared: true });
|
|
2483
|
+
// 4. The stop: nothing after it runs.
|
|
2484
|
+
const convergence = checks
|
|
2485
|
+
? decide({ copies, version, read: answer, skipped: off && newer.length > 0, verb, args: { harnesses: named, surfaces: opts.surfaces || null, register: opts.register !== false } })
|
|
2486
|
+
: { checks: false };
|
|
2487
|
+
if (convergence.stop) return { ...none, convergence };
|
|
2488
|
+
// 5. The keep (rule 5), for the channel "git": the copy kept, and its own
|
|
2489
|
+
// plan read through its bin — before the fetch, so a run plan() will refuse
|
|
2490
|
+
// fetches nothing: no copy to keep (rule 1), or the copy's plan refused. A
|
|
2491
|
+
// copy that cannot run the form, and a run narrowed by --surface, read
|
|
2492
|
+
// nothing; plan() says why. Inside a session the plan is read all the same.
|
|
2493
|
+
let keep = null, refuses = false;
|
|
2494
|
+
if ((opts.channel ?? null) === "git") {
|
|
2495
|
+
const target = keepTarget(projectDir, ids, { root, home, env });
|
|
2496
|
+
if (target && !target.copy) refuses = true;
|
|
2497
|
+
else if (target && !target.copy.missing && !(opts.surfaces && opts.surfaces.length)) {
|
|
2498
|
+
const childEnvironment = keepEnv(env, { harness: target.harness, projectDir, copyRoot: target.copy.path, harnessHome: claudeHome(home) });
|
|
2499
|
+
const read = await readKeepPlan(target.copy, { harness: target.harness, projectDir, env: childEnvironment, run: runHost, budget: HOST_BUDGET_MS });
|
|
2500
|
+
keep = { copy: target.copy, read };
|
|
2501
|
+
if (read.envelope && !read.ok) refuses = true;
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
// 6. The fetch.
|
|
2505
|
+
const fetchedPart = refuses ? { ...none, fetchedFrom: null } : await fetchShells(verb, projectDir, { ...opts, env, home, root, version, named, detected, ids, analyse, userRegistryOf });
|
|
2506
|
+
const latest = pending ? (await pending).latest : answer?.latest;
|
|
2507
|
+
const notice = wantNotice ? updateNotice({ latest, version, root, verb }) : null;
|
|
2508
|
+
return { ...fetchedPart, convergence, notice, keep };
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
async function fetchShells(verb, projectDir, { env, home, root, version, named, detected, ids, analyse, userRegistryOf, ...opts }) {
|
|
2512
|
+
const none = { version, payloadRoots: {}, fetched: [], refused: null, fetchedFrom: null, cleanup: () => {} };
|
|
2513
|
+
const interactive = asksAtTerminal({ ...opts, env });
|
|
2514
|
+
const ownShell = bundlingShellHarnessId({ core: root });
|
|
2515
|
+
const jobs = [];
|
|
2516
|
+
for (const id of ids) {
|
|
2517
|
+
const harness = loadHarness(id);
|
|
2518
|
+
for (const [key, s] of Object.entries(harness.surfaces || {})) {
|
|
2519
|
+
if (key.startsWith("_") || s.format !== "portable-plugin-registration") continue;
|
|
2520
|
+
const excluded = surfaceExcluded(key, s, { surfaces: opts.surfaces || null, register: opts.register });
|
|
2521
|
+
const analysis = excluded ? null : analyse(harness, s);
|
|
2522
|
+
const decision = fetchDecision({ verb, harness, s, excluded, named: named.length > 0, interactive, analysis, ownShell: ownShell === id, env });
|
|
2523
|
+
if (decision.fetch) jobs.push({ harness, s });
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
if (!jobs.length) return none;
|
|
2527
|
+
const refuse = (message, cleanup = () => {}) => ({ ...none, cleanup, refused: { projectDir, mode: "install", named: named.length > 0, detected, harnesses: ids, reports: [], items: [], refusals: [message], ok: false, incomplete: false, root, plannedAgainst: {} } });
|
|
2528
|
+
const cache = cachePaths({ env, home });
|
|
2529
|
+
if (!cache.root) return refuse(fetchRefusal("ENOCACHE", { variable: cache.variable }));
|
|
2530
|
+
sweepFetchRuns(cache.fetch);
|
|
2531
|
+
const runDir = cache.fetchRun(fetchRunId());
|
|
2532
|
+
try { mkdirSync(runDir, { recursive: true }); }
|
|
2533
|
+
// The root is named, not the run's own directory: that is where the user acts.
|
|
2534
|
+
catch (e) { return refuse(fetchRefusal("ECACHE", { path: cache.root, errno: e?.code, from: cache.from, variable: cache.variable })); }
|
|
2535
|
+
const cleanup = () => { try { removeTreeUnder(runDir, cache.root, { maxRetries: 3 }); } catch {} };
|
|
2536
|
+
const coreName = rootName(root);
|
|
2537
|
+
const budget = opts.fetchBudget || HOST_BUDGET_MS;
|
|
2538
|
+
let line = null;
|
|
2539
|
+
try {
|
|
2540
|
+
// Two resolvers, side by side: the project's registry, which the fetch
|
|
2541
|
+
// uses (its .npmrc counts), and the user's, which PLAN names beside it
|
|
2542
|
+
// when they differ — a shell from a registry the user did not choose is
|
|
2543
|
+
// said, never silent (the 2026-10-09 amendment).
|
|
2544
|
+
const [registry, user] = await Promise.all([
|
|
2545
|
+
npmRegistry(projectDir, { env, spawn: opts.fetchSpawn, budget: Math.min(budget, REGISTRY_BUDGET_MS) }),
|
|
2546
|
+
userRegistryOf(Math.min(budget, REGISTRY_BUDGET_MS)),
|
|
2547
|
+
]);
|
|
2548
|
+
// A user read that failed means npm's default (the amendment), so a
|
|
2549
|
+
// project registry is still named when the user's could not be read.
|
|
2550
|
+
const bare = (u) => String(u || "").replace(/\/+$/, "");
|
|
2551
|
+
const mine = user || DEFAULT_REGISTRY;
|
|
2552
|
+
const differs = Boolean(registry) && bare(registry) !== bare(mine);
|
|
2553
|
+
const payloadRoots = {}, fetched = [];
|
|
2554
|
+
for (const job of jobs) {
|
|
2555
|
+
const { harness } = job;
|
|
2556
|
+
const pkg = `${harness.install.shell}@${version}`;
|
|
2557
|
+
// One prefix per shell: npm prunes what a prefix holds that its own
|
|
2558
|
+
// install did not ask for, so a second shell would take the first away.
|
|
2559
|
+
const dir = jobs.length === 1 ? runDir : join(runDir, harness.id);
|
|
2560
|
+
if (dir !== runDir) mkdirSync(dir, { recursive: true });
|
|
2561
|
+
line = opts.onFetch ? opts.onFetch(`fetching ${pkg} for ${harness.display_name}`, ids) : null;
|
|
2562
|
+
const r = await fetchShell({ ...job, version, coreName }, { dir, registry, env, spawn: opts.fetchSpawn, budget });
|
|
2563
|
+
if (!r.ok) {
|
|
2564
|
+
if (line) line.end(false, `fetch failed ${pkg}`, r.code);
|
|
2565
|
+
return refuse(r.refusal, cleanup);
|
|
2566
|
+
}
|
|
2567
|
+
if (line) line.end(true, `fetched ${pkg} for ${harness.display_name}`);
|
|
2568
|
+
line = null;
|
|
2569
|
+
payloadRoots[harness.id] = r.root;
|
|
2570
|
+
fetched.push({ harness: harness.id, package: harness.install.shell, version, ms: r.ms });
|
|
2571
|
+
}
|
|
2572
|
+
return { version, payloadRoots, fetched, refused: null, fetchedFrom: differs ? { registry, user: mine, harnesses: fetched.map((f) => f.harness) } : null, cleanup };
|
|
2573
|
+
} catch (e) {
|
|
2574
|
+
// The caller's `finally` is not reached from here: the scratch goes now.
|
|
2575
|
+
if (line) line.abort();
|
|
2576
|
+
cleanup();
|
|
2577
|
+
throw e;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
|
|
1503
2581
|
export async function runVerb(verb, projectDir, opts = {}) {
|
|
1504
2582
|
const mode = verb === "uninstall" ? "uninstall" : "install"; // upgrade is install re-run (contract 14)
|
|
1505
|
-
const p = plan(projectDir, { ...opts, mode });
|
|
1506
2583
|
const env = opts.env || process.env;
|
|
2584
|
+
const home = opts.home || homedir();
|
|
1507
2585
|
// `out` is the text-mode caller's stdout. With it, this prints: the plan
|
|
1508
|
-
// first, then the question, then each step as it runs, then DONE.
|
|
2586
|
+
// first, then the question, then each step as it runs, then DONE. A run
|
|
2587
|
+
// that fetches prints its badge first, then the fetch line beneath it, then
|
|
2588
|
+
// the preview without the badge (presentation spec contract 7's slot).
|
|
1509
2589
|
const out = opts.out || null;
|
|
1510
2590
|
const c = out ? termCaps(out, env) : null;
|
|
1511
|
-
|
|
1512
|
-
const
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
2591
|
+
let badged = false;
|
|
2592
|
+
const onFetch = out ? (label, ids) => {
|
|
2593
|
+
if (!badged) { out.write(badgeLine({ harnesses: ids, mode }, { verb, caps: c }) + "\n"); badged = true; }
|
|
2594
|
+
return liveLine(out, c, label, { env });
|
|
2595
|
+
} : null;
|
|
2596
|
+
const prep = await prepareRun(verb, projectDir, { ...opts, env, onFetch, notice: Boolean(out) });
|
|
2597
|
+
try {
|
|
2598
|
+
// upgrade selects by the state directory too (rule 5), and so does a
|
|
2599
|
+
// project uninstall, setup's mirror: it leaves the project for every
|
|
2600
|
+
// harness that used it (the story "One run plans the agents block once…",
|
|
2601
|
+
// rule 5). `uninstall --global` keeps contract 8 — a state directory is a
|
|
2602
|
+
// project fact. install and plan keep contract 8, so a bare plan and a
|
|
2603
|
+
// bare install still agree.
|
|
2604
|
+
const p = prep.refused || plan(projectDir, { ...opts, mode, state: verb === "upgrade" || (verb === "uninstall" && !opts.globalRemoval), version: prep.version, payloadRoots: prep.payloadRoots, convergence: prep.convergence, fetchedFrom: prep.fetchedFrom || null, keep: prep.keep || null });
|
|
2605
|
+
const view = { verbose: Boolean(opts.verbose), verb, caps: c || PLAIN, home };
|
|
2606
|
+
const preview = renderPreview(p, view);
|
|
2607
|
+
// The update notice (the converge story's rule 8): in the header slot,
|
|
2608
|
+
// under the badge and after the fetch line, before PLAN. Never beside the
|
|
2609
|
+
// stop, whose refusal names the update itself.
|
|
2610
|
+
if (out && prep.notice) {
|
|
2611
|
+
if (!badged) { out.write(badgeLine(p, { verb, caps: c }) + "\n"); badged = true; }
|
|
2612
|
+
out.write(noticeText(prep.notice, c));
|
|
2613
|
+
}
|
|
2614
|
+
if (out) out.write((badged ? renderPreview(p, { ...view, badge: false }) : preview) + "\n");
|
|
2615
|
+
const gate = await confirm(p, { ...opts, env, ...(c ? { caps: c } : {}) });
|
|
2616
|
+
const result = { verb, plan: p, preview, gate, applied: [], failed: null, elapsed: 0, fetched: prep.fetched };
|
|
2617
|
+
if (gate.confirmed) {
|
|
2618
|
+
// DONE's time leaves the fetch out: it wrote nothing of the project's.
|
|
2619
|
+
const t0 = Date.now();
|
|
2620
|
+
const reporter = out ? applyReporter(out, c, p, { home }) : null;
|
|
2621
|
+
const spawn = opts.spawn || spawnSync;
|
|
2622
|
+
result.applied = apply(p, { env, spawn: reporter ? reporter.spawn(spawn) : spawn, home, onItem: reporter ? reporter.onItem : null, onStep: reporter ? reporter.onStep : null });
|
|
2623
|
+
result.failed = result.applied.failed || null;
|
|
2624
|
+
// A kept copy's run left incomplete (the converge story's rule 5) leaves
|
|
2625
|
+
// this one incomplete: both entries read the plan's flag for the exit.
|
|
2626
|
+
if (result.applied.incomplete) p.incomplete = true;
|
|
2627
|
+
result.elapsed = Date.now() - t0;
|
|
2628
|
+
if (out) out.write(renderDone(result, { caps: c, verbose: Boolean(opts.verbose), cwd: opts.cwd || process.cwd() }));
|
|
2629
|
+
}
|
|
2630
|
+
return result;
|
|
2631
|
+
} finally {
|
|
2632
|
+
prep.cleanup();
|
|
2633
|
+
}
|
|
1527
2634
|
}
|
|
1528
2635
|
|
|
1529
|
-
// APPLY, one line per step
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
1533
|
-
|
|
2636
|
+
// APPLY, one line per step as it finishes (presentation spec contract 8):
|
|
2637
|
+
// the glyph, what was done in the past tense — "added CLAUDE.md", having read
|
|
2638
|
+
// "adding CLAUDE.md" while it ran on a terminal — and the duration. An item
|
|
2639
|
+
// with steps — a registration, the layout move — prints its own line as it
|
|
2640
|
+
// starts ("registering …"), one line per step beneath it — each host
|
|
2641
|
+
// command, staging write, write and move — and, once all of them have run,
|
|
2642
|
+
// its finished line in the past tense ("registered …") with the item's whole
|
|
2643
|
+
// time, so a transcript never ends on a line that reads as still running.
|
|
2644
|
+
// No heading: the plan's last line and the question are above it. `now` is
|
|
2645
|
+
// the test's clock; `home` shortens paths to ~.
|
|
2646
|
+
export function applyReporter(out, c, p, { now = Date.now, home = homedir() } = {}) {
|
|
1534
2647
|
const paint = painter(c);
|
|
1535
|
-
const
|
|
1536
|
-
const
|
|
1537
|
-
|
|
1538
|
-
const
|
|
1539
|
-
|
|
1540
|
-
const
|
|
1541
|
-
|
|
2648
|
+
const glyph = (name) => termIcon(c, name);
|
|
2649
|
+
const item = stepReporter(out, c, { indent: 2, now });
|
|
2650
|
+
const step = stepReporter(out, c, { indent: 6, now });
|
|
2651
|
+
const said = (i) => {
|
|
2652
|
+
const word = actionWord(i);
|
|
2653
|
+
const [running, done] = WORD_TENSES[word] || [word, word];
|
|
2654
|
+
const target = rowTarget(p, i);
|
|
2655
|
+
return { word, running: `${running} ${target}`, done: `${done} ${target}` };
|
|
1542
2656
|
};
|
|
1543
|
-
|
|
2657
|
+
// The mark too: a skip prints its reason (the project not bound, the switch
|
|
2658
|
+
// not done) beneath the line it started.
|
|
2659
|
+
// …and the keep, whose $ line and the kept copy's own items print beneath it.
|
|
2660
|
+
const stepped = (i) => i.kind === "registration" || i.kind === "layout" || i.kind === "binding" || i.kind === "channel";
|
|
2661
|
+
const began = new Map();
|
|
1544
2662
|
return {
|
|
1545
2663
|
onItem(i, phase, r) {
|
|
1546
|
-
|
|
2664
|
+
const s = said(i);
|
|
1547
2665
|
if (stepped(i)) {
|
|
1548
|
-
if (phase === "start") out.write(` ${paint(
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
2666
|
+
if (phase === "start") { began.set(i, now()); out.write(` ${paint(WORD_ROLE[s.word], glyph(glyphName(i)))} ${s.running}\n`); return; }
|
|
2667
|
+
const t0 = began.get(i) ?? now();
|
|
2668
|
+
began.delete(i);
|
|
2669
|
+
if (phase === "abort") step.abort();
|
|
2670
|
+
else if (r && (r.action === "skipped" || r.action === "skip")) out.write(` ${paint("explanation", glyph("skip"))} ${paint("explanation", `${r.action === "skip" ? "nothing to do" : "skipped"}: ${r.reason || "an earlier item failed"}`)}\n`);
|
|
2671
|
+
else if (r && r.failed) out.write(` ${paint("error", glyph("fail"))} ${paint("error", "stopped")}\n`);
|
|
2672
|
+
else item.finish(s.done, t0);
|
|
1552
2673
|
return;
|
|
1553
2674
|
}
|
|
1554
|
-
if (phase === "start") item.start(
|
|
2675
|
+
if (phase === "start") item.start(s.running);
|
|
1555
2676
|
else if (phase === "abort") item.abort();
|
|
1556
|
-
else
|
|
2677
|
+
else {
|
|
2678
|
+
// An item that failed or was skipped keeps the words it ran under.
|
|
2679
|
+
const failed = Boolean(r && (r.failed || r.action === "skipped"));
|
|
2680
|
+
item.end(!failed, r && r.action === "skipped" ? "skipped" : "", failed ? {} : { label: s.done });
|
|
2681
|
+
}
|
|
1557
2682
|
},
|
|
1558
2683
|
onStep(st, phase, r) {
|
|
1559
|
-
const
|
|
1560
|
-
if (!
|
|
1561
|
-
if (phase === "start") step.start(
|
|
1562
|
-
|
|
2684
|
+
const l = stepLabel(p, st, { home, glyph });
|
|
2685
|
+
if (!l) return;
|
|
2686
|
+
if (phase === "start") { step.start(l.running); return; }
|
|
2687
|
+
const ok = r ? r.ok : true;
|
|
2688
|
+
const kept = Boolean(r && r.kept);
|
|
2689
|
+
// A step that left its target in place did not do its verb.
|
|
2690
|
+
step.end(ok, kept ? "kept" : "", ok ? { kept, label: kept ? l.base : l.done } : {});
|
|
1563
2691
|
},
|
|
1564
2692
|
spawn(inner) {
|
|
1565
2693
|
return (bin, argv, o) => {
|
|
1566
|
-
open();
|
|
1567
2694
|
step.start(`$ ${basename(String(bin))} ${argv.join(" ")}`);
|
|
1568
2695
|
let r;
|
|
1569
2696
|
try { r = inner(bin, argv, o); }
|
|
@@ -1575,33 +2702,60 @@ function applyReporter(out, c, p) {
|
|
|
1575
2702
|
};
|
|
1576
2703
|
}
|
|
1577
2704
|
|
|
1578
|
-
// A step as its APPLY line names it: the verb and the target, short
|
|
1579
|
-
// full form
|
|
1580
|
-
|
|
1581
|
-
|
|
2705
|
+
// A step as its APPLY line names it: the verb and the target, short — the
|
|
2706
|
+
// full form, file counts and entry pointers, was in PLAN. `base` is the plain
|
|
2707
|
+
// verb, for a step that left its target in place.
|
|
2708
|
+
const STEP_TENSES = { stage: ["staging", "staged"], lift: ["lifting", "lifted"], write: ["writing", "wrote"], edit: ["editing", "edited"], ensure: ["ensuring", "ensured"], move: ["moving", "moved"], merge: ["merging", "merged"], remove: ["removing", "removed"] };
|
|
2709
|
+
function stepLabel(p, st, { home = homedir(), glyph = unicodeGlyph } = {}) {
|
|
2710
|
+
const r = (x) => tilde(rel(p.projectDir, x), home);
|
|
2711
|
+
const to = ` ${glyph("transition")} `;
|
|
2712
|
+
const said = (verb, rest) => ({ base: `${verb} ${rest}`, running: `${STEP_TENSES[verb][0]} ${rest}`, done: `${STEP_TENSES[verb][1]} ${rest}` });
|
|
1582
2713
|
switch (st.kind) {
|
|
1583
|
-
case "portable-write": return
|
|
1584
|
-
case "recover": return
|
|
1585
|
-
case "write": return
|
|
1586
|
-
case "unregister": return
|
|
1587
|
-
case "
|
|
1588
|
-
case "
|
|
1589
|
-
case "
|
|
2714
|
+
case "portable-write": return said("stage", r(st.path));
|
|
2715
|
+
case "recover": return said("lift", `${r(st.path)} ${glyph("dash")} the previous state proved${st.lifted ? `; it held: ${String(st.lifted).split("\n")[0]}` : ""}`);
|
|
2716
|
+
case "write": return said("write", r(st.path));
|
|
2717
|
+
case "unregister": return said("edit", `${r(st.path)} [${st.pointer}.${st.name}]`);
|
|
2718
|
+
case "mark":
|
|
2719
|
+
case "unmark": return said("edit", `${r(st.path)} [${st.key}]`);
|
|
2720
|
+
case "ensure": return said("ensure", `${r(st.path)}/`);
|
|
2721
|
+
case "move-state": return said("move", `${r(st.from)}/${to}${r(st.to)}/`);
|
|
2722
|
+
case "merge-log": return said("merge", `${r(st.from)}${to}${r(st.to)}`);
|
|
1590
2723
|
case "move-marker":
|
|
1591
|
-
case "move-binding": return
|
|
2724
|
+
case "move-binding": return said("move", `${r(st.from)}${to}${r(st.to)}`);
|
|
1592
2725
|
case "delete":
|
|
1593
2726
|
case "portable-remove":
|
|
1594
2727
|
case "remove":
|
|
1595
2728
|
case "remove-legacy-launcher":
|
|
1596
2729
|
case "rmdir-legacy":
|
|
1597
|
-
case "remove-legacy-runtime": return
|
|
2730
|
+
case "remove-legacy-runtime": return said("remove", r(st.path));
|
|
2731
|
+
case "rmdir-state": return said("remove", `${r(st.path)}/`);
|
|
2732
|
+
// An item the kept copy applied (the converge story's rule 5), in this
|
|
2733
|
+
// run's words for its action; one the preview did not list says so.
|
|
2734
|
+
case "kept": {
|
|
2735
|
+
const word = ACTION_WORD[st.action] || "update";
|
|
2736
|
+
const [running, done] = WORD_TENSES[word] || [word, word];
|
|
2737
|
+
const target = `${st.path ? r(st.path) : st.surface}${st.unplanned ? " (not in the preview)" : ""}`;
|
|
2738
|
+
return { base: `${word} ${target}`, running: `${running} ${target}`, done: `${done} ${target}` };
|
|
2739
|
+
}
|
|
1598
2740
|
default: return null;
|
|
1599
2741
|
}
|
|
1600
2742
|
}
|
|
1601
2743
|
|
|
1602
|
-
// DONE
|
|
1603
|
-
//
|
|
1604
|
-
|
|
2744
|
+
// DONE and STOPPED (presentation spec contract 8). DONE: the count and the
|
|
2745
|
+
// time; Next, one line per harness — its display name in a column, its
|
|
2746
|
+
// manifest's next steps beside it, deduplicated; the facts the run settled
|
|
2747
|
+
// (the version, for now); then the tip — the shells' `plan` commands with
|
|
2748
|
+
// --verbose unless it was given, each on its own line (contract 6). Never
|
|
2749
|
+
// from a harness-id branch: every word comes from the manifests. STOPPED:
|
|
2750
|
+
// what applied, the step that failed by its argv and exit status or signal —
|
|
2751
|
+
// or, for a command that could not start, its cause in their place, once —
|
|
2752
|
+
// or by its item, the output beneath, and what to do. `cwd` decides whether
|
|
2753
|
+
// the tip's --project is "$PWD".
|
|
2754
|
+
export function renderDone(r, { caps: c = PLAIN, verbose = false, cwd = process.cwd() } = {}) {
|
|
2755
|
+
const paint = painter(c);
|
|
2756
|
+
const glyph = (name) => termIcon(c, name);
|
|
2757
|
+
const width = c.live ? c.width : 0;
|
|
2758
|
+
const dash = glyph("dash");
|
|
1605
2759
|
const p = r.plan;
|
|
1606
2760
|
// What applied: a record that neither failed nor was skipped — a recheck
|
|
1607
2761
|
// that found the work already done is not a change.
|
|
@@ -1613,34 +2767,106 @@ export function renderDone(r, { paint = (_style, text) => String(text), glyph =
|
|
|
1613
2767
|
const record = r.applied.find((a) => a.failed);
|
|
1614
2768
|
const item = record ? p.items.find((i) => i.surface === record.surface && i.path === record.path) : null;
|
|
1615
2769
|
const what = f.argv ? "a host command failed" : `${f.step} failed`;
|
|
1616
|
-
lines.push(`${paint("
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
if (f.
|
|
1620
|
-
|
|
1621
|
-
|
|
2770
|
+
lines.push(`${paint("emphasis", "Stopped")} ${dash} ${changes} applied, then ${what}`);
|
|
2771
|
+
const said = f.stderr ? String(f.stderr).split("\n") : [];
|
|
2772
|
+
const mark = ` ${paint("error", glyph("fail"))} `;
|
|
2773
|
+
if (f.argv) {
|
|
2774
|
+
// How the command ended: its exit status, the signal that stopped it,
|
|
2775
|
+
// or — only for one that could not start — its cause in their place,
|
|
2776
|
+
// said once (install spec contract 19). A command that ran and failed
|
|
2777
|
+
// otherwise (an answer in text where JSON is read) keeps its whole
|
|
2778
|
+
// message beneath.
|
|
2779
|
+
const ended = f.status !== null && f.status !== undefined ? ` exited ${f.status}`
|
|
2780
|
+
: f.signal ? ` killed by ${f.signal}`
|
|
2781
|
+
: f.couldNotStart && said.length ? ` ${dash} ${said.shift()}`
|
|
2782
|
+
: "";
|
|
2783
|
+
lines.push(`${mark}$ ${f.argv.join(" ")}${ended}`);
|
|
2784
|
+
} else lines.push(`${mark}${f.step}${item ? ` (${surfaceWords(item.surface)} ${rowTarget(p, item)})` : ""}`);
|
|
2785
|
+
for (const l of said) lines.push(` ${paint("explanation", l)}`);
|
|
2786
|
+
const closing = item && item.kind === "registration"
|
|
2787
|
+
? "The surfaces planned against its install path were not written; run the verb again once it succeeds."
|
|
2788
|
+
: "Run the verb again once the cause is fixed: its plan starts from what is on disk now.";
|
|
2789
|
+
lines.push(...wrap(closing, width, " ").split("\n").map((l, k) => (k ? l : ` ${l}`)));
|
|
1622
2790
|
return lines.join("\n") + "\n";
|
|
1623
2791
|
}
|
|
1624
|
-
lines.push(`${paint("
|
|
1625
|
-
const next = [],
|
|
1626
|
-
const here = (() => { try { return realpathSync(p.projectDir) === realpathSync(process.cwd()); } catch { return p.projectDir === process.cwd(); } })();
|
|
2792
|
+
lines.push(`${paint("emphasis", "Done")} ${dash} ${changes} in ${duration(r.elapsed || 0)}`);
|
|
2793
|
+
const next = [], seen = new Set();
|
|
1627
2794
|
for (const id of p.harnesses) {
|
|
1628
2795
|
const m = loadHarness(id);
|
|
1629
2796
|
if (!m) continue;
|
|
1630
|
-
const steps = r.verb === "uninstall" ? [`restart ${m.display_name}`] : (m.install?.next || []);
|
|
1631
|
-
for (const s of steps)
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
2797
|
+
const steps = (r.verb === "uninstall" ? [`restart ${m.display_name}`] : (m.install?.next || [])).filter((s) => !seen.has(s));
|
|
2798
|
+
for (const s of steps) seen.add(s);
|
|
2799
|
+
if (steps.length) next.push([m.display_name, steps.join("; ")]);
|
|
2800
|
+
}
|
|
2801
|
+
// The keep's own next lines (the converge story's rule 5): the update a copy
|
|
2802
|
+
// that could not run the keep needs, its own upgrade when its plan printed
|
|
2803
|
+
// no result, the npm row's uninstall — each lead, then its command alone on
|
|
2804
|
+
// its line (contract 6).
|
|
2805
|
+
const kept = p.items.filter((i) => i.kind === "channel" && Array.isArray(i.next)).flatMap((i) => i.next);
|
|
2806
|
+
if (next.length || kept.length) lines.push("", paint("emphasis", "Next"));
|
|
2807
|
+
if (next.length) {
|
|
2808
|
+
const kw = Math.max(...next.map(([name]) => name.length));
|
|
2809
|
+
const col = " ".repeat(2 + kw + 2);
|
|
2810
|
+
for (const [name, text] of next) {
|
|
2811
|
+
const [first, ...rest] = wrap(text, width, col).split("\n");
|
|
2812
|
+
lines.push(` ${paint("explanation", name)}${" ".repeat(kw - name.length + 2)}${first}`, ...rest);
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
kept.forEach((b, k) => {
|
|
2816
|
+
if (k || next.length) lines.push("");
|
|
2817
|
+
lines.push(...wrap(b.lead, width, " ").split("\n").map((l, n) => (n ? l : ` ${l}`)));
|
|
2818
|
+
if (b.command) lines.push(...commandBlock(c, [b.command], { indent: 4 }));
|
|
2819
|
+
});
|
|
2820
|
+
// The facts: the version, and under the keep (the converge story's rule 5)
|
|
2821
|
+
// the copy each kept harness runs — this package's version is not what that
|
|
2822
|
+
// harness's surfaces were written by.
|
|
2823
|
+
const keeps = p.items.filter((i) => i.kind === "channel" && i.kept);
|
|
2824
|
+
const facts = [...(p.version ? [["version", p.version]] : []), ...keeps.map((i) => ["kept", `${displayName(i.harness)}: ${i.kept.key}${i.kept.version ? ` ${i.kept.version}` : ""}`])];
|
|
2825
|
+
if (facts.length) lines.push("", ...kv(c, facts));
|
|
2826
|
+
// The tip: each shell's `plan` command, carrying --verbose unless it was
|
|
2827
|
+
// given, so the --verbose tip is this one and DONE has one tip, never
|
|
2828
|
+
// more than contract 18's two. Each command stands alone on its line, the
|
|
2829
|
+
// flag last (contract 6). `plan` previews an install: after an uninstall
|
|
2830
|
+
// it would preview the opposite of what just ran, so there is no tip.
|
|
2831
|
+
// A kept harness's tip is the kept copy's own plan, from its bin with
|
|
2832
|
+
// --no-register — this package's plan previews the other channel — and
|
|
2833
|
+
// none when that copy cannot run it; --verbose only where its CLI takes it.
|
|
2834
|
+
const here = (() => { try { return realpathSync(p.projectDir) === realpathSync(cwd); } catch { return p.projectDir === cwd; } })();
|
|
2835
|
+
const previews = [];
|
|
2836
|
+
let reasoned = true;
|
|
2837
|
+
if (r.verb !== "uninstall") {
|
|
2838
|
+
for (const id of p.harnesses) {
|
|
2839
|
+
const m = loadHarness(id);
|
|
2840
|
+
const k = keeps.find((i) => i.harness === id);
|
|
2841
|
+
if (k && k.kept.bin && !verbose && !k.kept.verbose) reasoned = false;
|
|
2842
|
+
const project = `--project ${here ? '"$PWD"' : `"${p.projectDir}"`}`;
|
|
2843
|
+
const cmd = k ? (k.kept.bin ? `node "${k.kept.bin}" plan --harness ${id} --no-register ${project}${verbose || !k.kept.verbose ? "" : " --verbose"}` : null)
|
|
2844
|
+
: m ? packageCommand(m, "plan", { args: `${project}${verbose ? "" : " --verbose"}` }) : null;
|
|
2845
|
+
if (cmd && !previews.includes(cmd)) previews.push(cmd);
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
const lead = verbose || !reasoned ? "To preview without writing:" : "To preview without writing, with every row's reasoning:";
|
|
2849
|
+
if (previews.length) lines.push("", ` ${paint("explanation", lead)}`, ...commandBlock(c, previews));
|
|
1641
2850
|
return lines.join("\n") + "\n";
|
|
1642
2851
|
}
|
|
1643
2852
|
|
|
2853
|
+
// What a text run that wrote nothing says after its plan: one helper for
|
|
2854
|
+
// main() and the bin's runInstallVerb, so the two entries cannot drift (the
|
|
2855
|
+
// converge story's rule 2). `harnesses` and `surfaces` are the run's own
|
|
2856
|
+
// arguments, which the channel gate's command repeats — never --project. Its
|
|
2857
|
+
// commands are rule 2's helper's (channelChoices), each alone on its line
|
|
2858
|
+
// after a blank one (presentation spec contract 6).
|
|
2859
|
+
export function gateText(r, { verb, harnesses = [], surfaces = null, caps: c = PLAIN } = {}) {
|
|
2860
|
+
const why = r.gate?.why;
|
|
2861
|
+
if (why === "non-tty") return `Nothing written: without a terminal, a bare ${verb} refuses. Name the harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`;
|
|
2862
|
+
if (why === "declined") return "Nothing written.\n";
|
|
2863
|
+
if (why === "channel") {
|
|
2864
|
+
const keys = [...new Set(r.plan.items.filter(isSwitch).map((i) => i.entry))];
|
|
2865
|
+
return channelChoices({ gate: { verb, args: { harnesses, surfaces }, keys } }).map((b) => [b.lead, ...commandBlock(c, [b.command], { indent: 4 })].join("\n")).join("\n\n") + "\n";
|
|
2866
|
+
}
|
|
2867
|
+
return "";
|
|
2868
|
+
}
|
|
2869
|
+
|
|
1644
2870
|
// ─── main ──────────────────────────────────────────────────────────────
|
|
1645
2871
|
|
|
1646
2872
|
function usage() {
|
|
@@ -1668,7 +2894,11 @@ function publicStep({ manifest, ...s }) {
|
|
|
1668
2894
|
if (s.kind === "portable-write" && Array.isArray(s.files)) s.files = s.files.length;
|
|
1669
2895
|
return s;
|
|
1670
2896
|
}
|
|
1671
|
-
|
|
2897
|
+
// `analysed` (the recheck's inputs) and `digestDeferred` (a --verbose note)
|
|
2898
|
+
// are the plan's own, never the envelope's.
|
|
2899
|
+
// `advice` is the reason's commands, kept apart for PLAN to draw alone on
|
|
2900
|
+
// their lines; the envelope has them in `reason`, one part per line.
|
|
2901
|
+
export const publicItem = ({ before, after, steps, observed, analysed, digestDeferred, advice, ...rest }) => steps
|
|
1672
2902
|
? { ...rest, steps: steps.map(publicStep) }
|
|
1673
2903
|
: rest;
|
|
1674
2904
|
|
|
@@ -1694,20 +2924,21 @@ async function main() {
|
|
|
1694
2924
|
projectDir = resolve(projectDir || (src && process.env[src.runtime?.project_dir_env]) || process.cwd());
|
|
1695
2925
|
const opts = { harnesses, surfaces: surfaces.length ? surfaces : null, globalRemoval, register, json, verbose, stdin: process.stdin, stdout: process.stdout };
|
|
1696
2926
|
if (verb === "plan") {
|
|
1697
|
-
const p = plan(projectDir, opts);
|
|
2927
|
+
const p = plan(projectDir, planOptions(opts));
|
|
1698
2928
|
const c = termCaps(process.stdout, process.env);
|
|
1699
|
-
process.stdout.write(json ? JSON.stringify({ ...p, items: p.items.map(publicItem) }, null, 2) + "\n" : renderPreview(p, { verbose, verb,
|
|
2929
|
+
process.stdout.write(json ? JSON.stringify({ ...p, items: p.items.map(publicItem) }, null, 2) + "\n" : renderPreview(p, { verbose, verb, caps: c }));
|
|
1700
2930
|
process.exit(p.ok && !p.incomplete ? 0 : 1);
|
|
1701
2931
|
}
|
|
1702
2932
|
const r = await runVerb(verb, projectDir, json ? opts : { ...opts, out: process.stdout });
|
|
2933
|
+
// The bin's form (scripts/cli.mjs): a gate that confirmed nothing — a
|
|
2934
|
+
// channel switch without a terminal among them — is not ok through either entry.
|
|
2935
|
+
const ok = r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do");
|
|
1703
2936
|
if (json) {
|
|
1704
|
-
process.stdout.write(JSON.stringify({ verb, ok
|
|
1705
|
-
} else
|
|
1706
|
-
process.stdout.write(
|
|
1707
|
-
} else if (r.gate.why === "declined") {
|
|
1708
|
-
process.stdout.write("Nothing written.\n");
|
|
2937
|
+
process.stdout.write(JSON.stringify({ verb, ok, gate: r.gate, applied: r.applied, failed: r.failed, incomplete: r.plan.incomplete, items: r.plan.items.map(publicItem), refusals: r.plan.refusals, reports: r.plan.reports, fetched: r.fetched }, null, 2) + "\n");
|
|
2938
|
+
} else {
|
|
2939
|
+
process.stdout.write(gateText(r, { verb, harnesses, surfaces: opts.surfaces, caps: termCaps(process.stdout, process.env) }));
|
|
1709
2940
|
}
|
|
1710
|
-
process.exit(
|
|
2941
|
+
process.exit(ok ? 0 : 1);
|
|
1711
2942
|
}
|
|
1712
2943
|
|
|
1713
2944
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|