projectstore-claude 0.29.2 → 0.29.4

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.
Files changed (76) hide show
  1. package/README.md +1 -1
  2. package/bin/projectstore-claude.mjs +1 -1
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  5. package/node_modules/projectstore/README.md +44 -7
  6. package/node_modules/projectstore/commands/bar.md +30 -0
  7. package/node_modules/projectstore/commands/bind.md +16 -20
  8. package/node_modules/projectstore/commands/doctor.md +12 -7
  9. package/node_modules/projectstore/commands/scaffold.md +24 -16
  10. package/node_modules/projectstore/commands/status.md +1 -1
  11. package/node_modules/projectstore/commands/statusline.md +2 -2
  12. package/node_modules/projectstore/commands/story.md +15 -2
  13. package/node_modules/projectstore/docs/alongside.md +167 -0
  14. package/node_modules/projectstore/docs/extending.md +17 -3
  15. package/node_modules/projectstore/docs/getting-started.md +18 -9
  16. package/node_modules/projectstore/docs/harnesses.md +1 -1
  17. package/node_modules/projectstore/docs/how-it-works.md +12 -3
  18. package/node_modules/projectstore/harnesses/claude-code.json +23 -8
  19. package/node_modules/projectstore/harnesses/codex.json +16 -6
  20. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  21. package/node_modules/projectstore/hooks/band.tsx +622 -0
  22. package/node_modules/projectstore/hooks/hooks.json +1 -0
  23. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  24. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  25. package/node_modules/projectstore/hooks/session-start.mjs +56 -20
  26. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  27. package/node_modules/projectstore/package.json +12 -2
  28. package/node_modules/projectstore/scaffold/headings.json +8 -0
  29. package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
  30. package/node_modules/projectstore/scripts/band.mjs +207 -0
  31. package/node_modules/projectstore/scripts/binding.mjs +733 -62
  32. package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
  33. package/node_modules/projectstore/scripts/cli.mjs +1463 -267
  34. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  35. package/node_modules/projectstore/scripts/convergence.mjs +638 -0
  36. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  37. package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
  38. package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
  39. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  40. package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
  41. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  42. package/node_modules/projectstore/scripts/harness.mjs +283 -37
  43. package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
  44. package/node_modules/projectstore/scripts/kanban.mjs +6 -21
  45. package/node_modules/projectstore/scripts/lib.mjs +933 -199
  46. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  47. package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
  48. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  49. package/node_modules/projectstore/scripts/query.mjs +441 -72
  50. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  51. package/node_modules/projectstore/scripts/run-host.mjs +139 -0
  52. package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
  53. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  54. package/node_modules/projectstore/scripts/stages.mjs +84 -0
  55. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  56. package/node_modules/projectstore/scripts/statusline.mjs +55 -14
  57. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  58. package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
  59. package/node_modules/projectstore/scripts/term.mjs +1027 -52
  60. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  61. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  62. package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
  63. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  64. package/node_modules/projectstore/templates/de/strings.json +63 -1
  65. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  66. package/node_modules/projectstore/templates/en/strings.json +63 -1
  67. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  68. package/node_modules/projectstore/templates/es/strings.json +63 -1
  69. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  70. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  71. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  72. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  73. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  74. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  75. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  76. package/package.json +2 -2
@@ -29,7 +29,6 @@
29
29
  import { existsSync, readFileSync } from "node:fs";
30
30
  import { join, basename } from "node:path";
31
31
  import { spawnSync } from "node:child_process";
32
- import { resolve } from "node:path";
33
32
  import {
34
33
  readConfig,
35
34
  readConfigAt,
@@ -40,7 +39,8 @@ import {
40
39
  slugIdentity,
41
40
  displayNumberOf,
42
41
  compareArtifactOrder,
43
- writeFileAtomic, isMain,
42
+ writeFileAtomic,
43
+ isMain,
44
44
  commandForm,
45
45
  } from "./lib.mjs";
46
46
  import { childEnv } from "./harness.mjs";
@@ -63,11 +63,19 @@ function runGenerator(script) {
63
63
  env: _env || childEnv(process.env, { projectRoot: _projectDir || projectRoot() }),
64
64
  });
65
65
  if (r.status !== 0) return { error: (r.stderr || "generator failed").trim() };
66
- try { return JSON.parse(r.stdout); } catch { return { error: "unparseable generator output" }; }
66
+ try {
67
+ return JSON.parse(r.stdout);
68
+ } catch {
69
+ return { error: "unparseable generator output" };
70
+ }
67
71
  }
68
72
 
69
73
  const normalize = (s) =>
70
- s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
74
+ s
75
+ .split("\n")
76
+ .filter((l) => !l.startsWith("generated_at:"))
77
+ .join("\n")
78
+ .trimEnd();
71
79
 
72
80
  const errMsg = (e) => String((e && e.message) || e);
73
81
 
@@ -78,9 +86,9 @@ const errMsg = (e) => String((e && e.message) || e);
78
86
  // cannot provide them; the class-wide selectors skip, like bare invocation.
79
87
  export function resolveSelection(layout, onlyRaw) {
80
88
  const validSelectors = () =>
81
- [layout.kanban ? "kanban" : null, "codemap", "graph", "indexes",
82
- ...layout.folders.map((f) => `indexes=${f.path}`)]
83
- .filter(Boolean).join(", ");
89
+ [layout.kanban ? "kanban" : null, "codemap", "graph", "indexes", ...layout.folders.map((f) => `indexes=${f.path}`)]
90
+ .filter(Boolean)
91
+ .join(", ");
84
92
  if (onlyRaw == null) {
85
93
  return {
86
94
  explicit: false,
@@ -91,7 +99,10 @@ export function resolveSelection(layout, onlyRaw) {
91
99
  };
92
100
  }
93
101
  const sel = { explicit: true, kanban: false, codemap: false, graph: false, indexes: [] };
94
- const parts = String(onlyRaw).split(",").map((s) => s.trim()).filter(Boolean);
102
+ const parts = String(onlyRaw)
103
+ .split(",")
104
+ .map((s) => s.trim())
105
+ .filter(Boolean);
95
106
  if (!parts.length) return { error: `empty --only selection — valid: ${validSelectors()}` };
96
107
  for (const raw of parts) {
97
108
  if (raw === "kanban") {
@@ -118,7 +129,8 @@ export function resolveSelection(layout, onlyRaw) {
118
129
  return { error: `no folder "${p}" in this layout — valid: ${validSelectors()}` };
119
130
  }
120
131
  const existing = sel.indexes.find((x) => x.path === p);
121
- if (existing) existing.named = true; // upgrade, whatever the order
132
+ if (existing)
133
+ existing.named = true; // upgrade, whatever the order
122
134
  else sel.indexes.push({ path: p, named: true });
123
135
  } else {
124
136
  return { error: `unknown selector "${raw}" — valid: ${validSelectors()}` };
@@ -156,7 +168,7 @@ const SKIP = {
156
168
  function derivedTarget(script, explicit = false, fallbackPath = null) {
157
169
  const g = runGenerator(script);
158
170
  if (g.error) return { ...(fallbackPath ? { path: fallbackPath } : {}), error: g.error };
159
- let onDisk = null;
171
+ let onDisk;
160
172
  try {
161
173
  onDisk = existsSync(g.path) ? readFileSync(g.path, "utf8") : null;
162
174
  } catch (e) {
@@ -195,7 +207,8 @@ export function rebuildIndexRows(original, folder, artifacts) {
195
207
  const inFolder = artifacts.filter((a) =>
196
208
  folder.kind === "epic"
197
209
  ? a.kind === "epic" && a.rel.startsWith(`${folder.path}/`)
198
- : a.kind === folder.kind && a.rel === `${folder.path}/${basename(a.rel)}`);
210
+ : a.kind === folder.kind && a.rel === `${folder.path}/${basename(a.rel)}`,
211
+ );
199
212
  // Ordering per SPEC-002 contract 8: date ascending (date:, else created:),
200
213
  // display number then slug as tiebreak — the grandfathered ADR-001…N order
201
214
  // survives because same-date groups tiebreak by number.
@@ -292,7 +305,8 @@ export function writeIndexWithRetry(readmePath, rebuild, { attempts = 3 } = {})
292
305
  // shrinks the staleness window to microseconds (spec contract 1).
293
306
  function applyDerived(script, explicit, fallbackPath) {
294
307
  const t = derivedTarget(script, explicit, fallbackPath);
295
- if (t.error) return { ...(t.path ? { path: t.path } : {}), changed: t.changed === true, written: false, error: t.error };
308
+ if (t.error)
309
+ return { ...(t.path ? { path: t.path } : {}), changed: t.changed === true, written: false, error: t.error };
296
310
  if (t.skipped) return { path: t.path, changed: false, written: false, skipped: t.skipped, stats: t.stats };
297
311
  if (!t.changed) return { path: t.path, changed: false, written: false, stats: t.stats };
298
312
  try {
@@ -307,7 +321,11 @@ export function runReconcile({ write = false, only = null, projectDir = null, en
307
321
  _projectDir = projectDir;
308
322
  _env = env;
309
323
  const cfg = projectDir ? readConfigAt(projectDir) : readConfig();
310
- if (!cfg) { const e = new Error(`No projectstore config. Run ${commandForm("bind")} first.`); e.code = "UNBOUND"; throw e; }
324
+ if (!cfg) {
325
+ const e = new Error(`No projectstore config. Run ${commandForm("bind")} first.`);
326
+ e.code = "UNBOUND";
327
+ throw e;
328
+ }
311
329
  const layout = loadLayout(cfg.layout);
312
330
  const sel = resolveSelection(layout, only);
313
331
  if (sel.error) throw new Error(sel.error);
@@ -334,15 +352,24 @@ export function runReconcile({ write = false, only = null, projectDir = null, en
334
352
  const kanbanPath = layout.kanban ? join(cfg.vault_path, layout.kanban.file || "kanban.md") : null;
335
353
  if (!sel.kanban) out.kanban = { skipped: "not selected" };
336
354
  else if (!layout.kanban) out.kanban = { skipped: "layout has no kanban" };
337
- else out.kanban = write ? applyDerived("kanban.mjs", sel.explicit, kanbanPath) : derivedTarget("kanban.mjs", sel.explicit, kanbanPath);
355
+ else
356
+ out.kanban = write
357
+ ? applyDerived("kanban.mjs", sel.explicit, kanbanPath)
358
+ : derivedTarget("kanban.mjs", sel.explicit, kanbanPath);
338
359
 
339
360
  const codemapPath = join(cfg.vault_path, "code-map.md");
340
361
  if (!sel.codemap) out.codemap = { skipped: "not selected" };
341
- else out.codemap = write ? applyDerived("codemap.mjs", sel.explicit, codemapPath) : derivedTarget("codemap.mjs", sel.explicit, codemapPath);
362
+ else
363
+ out.codemap = write
364
+ ? applyDerived("codemap.mjs", sel.explicit, codemapPath)
365
+ : derivedTarget("codemap.mjs", sel.explicit, codemapPath);
342
366
 
343
367
  const graphPath = join(cfg.vault_path, "graph.md");
344
368
  if (!sel.graph) out.graph = { skipped: "not selected" };
345
- else out.graph = write ? applyDerived("graph.mjs", sel.explicit, graphPath) : derivedTarget("graph.mjs", sel.explicit, graphPath);
369
+ else
370
+ out.graph = write
371
+ ? applyDerived("graph.mjs", sel.explicit, graphPath)
372
+ : derivedTarget("graph.mjs", sel.explicit, graphPath);
346
373
 
347
374
  out.indexes = [];
348
375
  for (const { path: folderPath, named } of sel.indexes) {
@@ -359,7 +386,13 @@ export function runReconcile({ write = false, only = null, projectDir = null, en
359
386
  if (r.unusable) {
360
387
  // Pre-flighted named targets can only reach this via a race.
361
388
  if (named) {
362
- out.indexes.push({ path: readmePath, folder: folder.path, changed: false, written: false, error: r.unusable });
389
+ out.indexes.push({
390
+ path: readmePath,
391
+ folder: folder.path,
392
+ changed: false,
393
+ written: false,
394
+ error: r.unusable,
395
+ });
363
396
  }
364
397
  continue;
365
398
  }
@@ -374,7 +407,11 @@ export function runReconcile({ write = false, only = null, projectDir = null, en
374
407
  const t = rebuildIndex(cfg, folder, artifacts);
375
408
  if (t === null) {
376
409
  if (named) {
377
- out.indexes.push({ path: readmePath, folder: folder.path, error: "absent or no managed index table (changed since pre-flight)" });
410
+ out.indexes.push({
411
+ path: readmePath,
412
+ folder: folder.path,
413
+ error: "absent or no managed index table (changed since pre-flight)",
414
+ });
378
415
  }
379
416
  continue;
380
417
  }
@@ -0,0 +1,139 @@
1
+ // projectstore — run-host.mjs: one asynchronous runner for a host command.
2
+ //
3
+ // The install verbs ran every child through spawnSync, which blocks the event
4
+ // loop: nothing can animate while it waits. The shell fetch of install and
5
+ // upgrade (the story "Install and upgrade fetch a shell-rooted harness at the
6
+ // bin's own version, once per run; plan and uninstall never fetch") is the
7
+ // first child a person watches for seconds, so it gets a runner that yields:
8
+ // a spawn with stdin ignored and both outputs piped, resolved with spawnSync's
9
+ // own result shape so a caller written for one reads the other. APPLY's host
10
+ // commands move onto it in a later story (the APPLY story adds `signal`).
11
+ //
12
+ // runHost(bin, argv, { env, cwd, budget, spawn, shell })
13
+ // → Promise<{ status, signal, stdout, stderr, error }>
14
+ // startFailure(err, { bin, resolved, cwd }) → what Node could not say
15
+ // HOST_BUDGET_MS → the outer bound, 120 s
16
+ //
17
+ // Leaf module: node built-ins only. Only install-harness.mjs and
18
+ // fetch-shell.mjs import it; hooks, doctor, surfaces and lib never do (the
19
+ // portability suite asserts it), so the SessionStart graph never loads it.
20
+
21
+ import { spawn as nodeSpawn } from "node:child_process";
22
+ import { statSync } from "node:fs";
23
+
24
+ // The bound every host command gets, ours, outside whatever the child sets
25
+ // for itself (npm's per-request timeouts, retried, can outlast it).
26
+ export const HOST_BUDGET_MS = 120000;
27
+
28
+ // How long the pipes may drain after the child exits. A grandchild that keeps
29
+ // a pipe open must not hold the run: `close` waited 3 s for one where `exit`
30
+ // came at 99 ms (measured 2026-10-09).
31
+ const DRAIN_MS = 200;
32
+
33
+ // spawnSync's shape, asynchronously: `status` is the exit code, or null when
34
+ // the child never started or was stopped; `error` carries the start failure
35
+ // (never close's -2), or ETIMEDOUT when the budget ran out. Output is decoded
36
+ // as UTF-8 by the streams themselves, so a character split across two writes
37
+ // stays whole (measured). The budget's SIGTERM settles at once: a child that
38
+ // ignores it, or a grandchild still writing, is left to the OS — its pipes are
39
+ // destroyed and its handle unref'd, so nothing of it keeps this process open.
40
+ // `shell` exists for win32, where npm is npm.cmd and runs only through a
41
+ // shell; that path is unverified, as the install spec says of Windows.
42
+ export function runHost(
43
+ bin,
44
+ argv,
45
+ { env = process.env, cwd = process.cwd(), budget = HOST_BUDGET_MS, spawn = nodeSpawn, shell = false } = {},
46
+ ) {
47
+ return new Promise((settle) => {
48
+ const out = [],
49
+ err = [];
50
+ let child = null,
51
+ done = false,
52
+ timer = null,
53
+ drain = null,
54
+ exited = null;
55
+ const release = () => {
56
+ for (const s of [child?.stdout, child?.stderr]) {
57
+ try {
58
+ s?.destroy();
59
+ } catch {}
60
+ }
61
+ try {
62
+ child?.unref?.();
63
+ } catch {}
64
+ };
65
+ const finish = (r) => {
66
+ if (done) return;
67
+ done = true;
68
+ clearTimeout(timer);
69
+ clearTimeout(drain);
70
+ settle({ status: null, signal: null, stdout: out.join(""), stderr: err.join(""), error: undefined, ...r });
71
+ };
72
+ try {
73
+ child = spawn(bin, argv, { env, cwd, stdio: ["ignore", "pipe", "pipe"], shell });
74
+ } catch (e) {
75
+ finish({ error: e });
76
+ return;
77
+ }
78
+ for (const [s, sink] of [
79
+ [child.stdout, out],
80
+ [child.stderr, err],
81
+ ]) {
82
+ if (!s) continue;
83
+ s.setEncoding("utf8");
84
+ s.on("data", (d) => sink.push(d));
85
+ // A stream that ends after the exit may be the last one the drain waits on.
86
+ s.on("end", () => {
87
+ if (exited && drained()) finish(exited);
88
+ });
89
+ s.on("error", () => {});
90
+ }
91
+ const drained = () => [child.stdout, child.stderr].every((s) => !s || s.readableEnded || s.destroyed);
92
+ child.on("error", (e) => {
93
+ finish({ error: e, status: null });
94
+ release();
95
+ });
96
+ child.on("exit", (code, signal) => {
97
+ exited = { status: code, signal };
98
+ if (drained()) {
99
+ finish(exited);
100
+ return;
101
+ }
102
+ drain = setTimeout(() => {
103
+ finish(exited);
104
+ release();
105
+ }, DRAIN_MS);
106
+ });
107
+ timer = setTimeout(() => {
108
+ try {
109
+ child.kill("SIGTERM");
110
+ } catch {}
111
+ finish({
112
+ status: null,
113
+ signal: "SIGTERM",
114
+ error: Object.assign(new Error(`${bin} ran past its ${budget} ms budget and was stopped`), {
115
+ code: "ETIMEDOUT",
116
+ }),
117
+ });
118
+ release();
119
+ }, budget);
120
+ });
121
+ }
122
+
123
+ // What Node cannot say: spawnSync reports a working directory that does not
124
+ // exist and a binary not on PATH with the same `spawnSync <bin> ENOENT`
125
+ // (measured 2026-10-05), and spawn the same `spawn <bin> ENOENT`. The
126
+ // filesystem tells them apart, in this order (the install spec, contract 19);
127
+ // any other error passes through as Node wrote it.
128
+ export function startFailure(err, { bin, resolved, cwd }) {
129
+ let dir = false;
130
+ try {
131
+ dir = statSync(cwd).isDirectory();
132
+ } catch {}
133
+ if (!dir)
134
+ return `${bin} could not start: the working directory ${JSON.stringify(String(cwd))} is not an existing directory`;
135
+ if (!resolved) return `${bin} could not start: it was not found on PATH`;
136
+ if (["ENOENT", "EACCES", "ENOEXEC"].includes(err?.code))
137
+ return `${bin} could not start: ${resolved} exists but could not be run (${err.code})`;
138
+ return String(err?.message || err);
139
+ }
@@ -0,0 +1,244 @@
1
+ // projectstore — scaffold.mjs
2
+ //
3
+ // A vault's skeleton — the layout's folders, their README indexes and the
4
+ // vault's own README — as a plan and an apply: the one place it is written,
5
+ // for the CLI's `scaffold` verb, for `init` (binding.mjs) and, through the
6
+ // bin, for the session command and the Codex skill. So a vault made in a
7
+ // session, by `init` and by `setup` is the same bytes (the front-door ADR's
8
+ // decision 7). Before this module a session wrote the READMEs itself, picking
9
+ // each folder's name and description "based on the folder kind": two sessions
10
+ // wrote two vaults, and the SessionStart skeleton's Purpose column, which
11
+ // reads a folder's README prose, repeated whatever words the model chose.
12
+ // The names and descriptions are data now: templates/<lang>/strings.json →
13
+ // folders.<kind>, in the binding's language, with no English fallback.
14
+ //
15
+ // The shape is binding.mjs's. planScaffold() is pure over the filesystem: one
16
+ // row per folder, per folder README and for the vault README, each `create`
17
+ // or `exists`, and the reasons it refuses before anything is written.
18
+ // applyScaffold() writes the `create` rows and nothing else. A file that
19
+ // exists is never rewritten, whatever its content: the index rows inside a
20
+ // README are reconcile's, and a README edited by hand is the person's. "The
21
+ // layout's folders are missing" means the plan has a `create` row — a folder
22
+ // that is there without its README counts, because reconcile drops an index
23
+ // it cannot find without a word (the locale spec, contract 5).
24
+ //
25
+ // A leaf: lib.mjs and node built-ins only. binding.mjs imports this module,
26
+ // so the reverse import would be a cycle. Normative: the story "Scaffold is a
27
+ // core verb, and init creates a whole vault…" (PS-CORE).
28
+
29
+ import { existsSync, statSync, mkdirSync } from "node:fs";
30
+ import { join, basename } from "node:path";
31
+ import {
32
+ pluginRoot,
33
+ loadLayout,
34
+ loadTemplate,
35
+ loadStrings,
36
+ renderTemplate,
37
+ writeFileAtomic,
38
+ commandForm,
39
+ fromTerminalIn,
40
+ } from "./lib.mjs";
41
+ import { packageCommand } from "./harness.mjs";
42
+
43
+ const README = "README.md";
44
+
45
+ const kindsOf = (layout) => [...new Set((layout.folders || []).map((f) => f.kind))];
46
+ const usable = (v) => typeof v === "string" && v.trim() !== "";
47
+ const pathKind = (p) => {
48
+ try {
49
+ return statSync(p).isDirectory() ? "dir" : "file";
50
+ } catch {
51
+ return null;
52
+ }
53
+ };
54
+
55
+ // What a layout needs from a language before anything is written: the vault
56
+ // README's template, the folder README's when a folder asks for one, and a
57
+ // name and a description for every kind the layout's folders declare —
58
+ // `diagram` included, though no command creates one. planScaffold refuses on
59
+ // exactly this list and doctor's checkLayoutTemplates warns on it, so the two
60
+ // cannot disagree about what a language lacks.
61
+ export function missingScaffoldRequirements(layout, lang, root = pluginRoot()) {
62
+ const missing = [];
63
+ const templates = ["vault-readme", ...((layout.folders || []).some((f) => f.readme) ? ["folder-readme"] : [])];
64
+ for (const name of templates) {
65
+ if (!existsSync(join(root, "templates", String(lang), `${name}.md.tmpl`))) {
66
+ missing.push({
67
+ what: "template",
68
+ name,
69
+ message: `language "${lang}" has no ${name} template (templates/${lang}/${name}.md.tmpl)`,
70
+ });
71
+ }
72
+ }
73
+ const strings = loadStrings(lang, root);
74
+ for (const kind of kindsOf(layout)) {
75
+ const s = strings && strings.folders && strings.folders[kind];
76
+ if (!s || !usable(s.name) || !usable(s.description)) {
77
+ missing.push({
78
+ what: "string",
79
+ name: kind,
80
+ message: `language "${lang}" has no name and description for the folder kind "${kind}" (templates/${lang}/strings.json → folders.${kind})`,
81
+ });
82
+ }
83
+ }
84
+ return missing;
85
+ }
86
+
87
+ // The vault README's folder table: a row per layout folder, its name linked
88
+ // to the folder's README (to the folder, when the layout asks for none) and
89
+ // its description — the same strings the folder READMEs carry, so the two
90
+ // never call one folder by two names.
91
+ function folderRows(layout, strings) {
92
+ return layout.folders
93
+ .map((f) => {
94
+ const s = strings.folders[f.kind];
95
+ return `| [${s.name}](${f.path}/${f.readme ? README : ""}) | ${s.description} |`;
96
+ })
97
+ .join("\n");
98
+ }
99
+
100
+ // What `scaffold` would write into `vault`. A vault that does not exist yet
101
+ // is planned as all `create` — what `init` plans before its mkdir. Refusal
102
+ // codes: LAYOUT, TEMPLATE, STRINGS, NOT_A_DIRECTORY, FOLDER_IS_FILE; every
103
+ // one is exit 1 upstream, and any one means nothing is written. A `create`
104
+ // row carries the bytes it would write; an `exists` row carries none.
105
+ export function planScaffold(vault, { layout, language, root = pluginRoot() } = {}) {
106
+ const refusals = [];
107
+ const rows = [];
108
+ const plan = { vault, layout, language, rows, creates: 0, refusals, ok: false };
109
+ let spec;
110
+ try {
111
+ spec = loadLayout(layout, root);
112
+ if (!spec || !Array.isArray(spec.folders)) throw new Error("it declares no folders");
113
+ } catch (e) {
114
+ refusals.push({ code: "LAYOUT", message: `the layout "${layout}" does not load: ${e.message}` });
115
+ return plan;
116
+ }
117
+ for (const m of missingScaffoldRequirements(spec, language, root))
118
+ refusals.push({ code: m.what === "template" ? "TEMPLATE" : "STRINGS", message: m.message });
119
+ if (pathKind(vault) === "file")
120
+ refusals.push({ code: "NOT_A_DIRECTORY", message: `${vault} is a file, not a vault directory` });
121
+ if (refusals.length) return plan;
122
+ const strings = loadStrings(language, root);
123
+ const folderTemplate = spec.folders.some((f) => f.readme) ? loadTemplate(language, "folder-readme", root) : null;
124
+ const row = (type, rel, render = null) => {
125
+ const abs = join(vault, rel);
126
+ const action = existsSync(abs) ? "exists" : "create";
127
+ rows.push({ type, rel, abs, action, ...(render && action === "create" ? { content: render() } : {}) });
128
+ };
129
+ for (const f of spec.folders) {
130
+ if (pathKind(join(vault, f.path)) === "file")
131
+ refusals.push({
132
+ code: "FOLDER_IS_FILE",
133
+ message: `${f.path} exists in ${vault} as a file — the layout's ${f.kind} folder cannot be created there`,
134
+ });
135
+ row("folder", f.path);
136
+ if (f.readme) {
137
+ const s = strings.folders[f.kind];
138
+ row("readme", `${f.path}/${README}`, () =>
139
+ renderTemplate(folderTemplate, { folder_name: s.name, folder_description: s.description }),
140
+ );
141
+ }
142
+ }
143
+ // Last, and from the basename alone: nothing path-dependent beyond the
144
+ // directory's name, so a vault made here and one made by `init` elsewhere
145
+ // under the same name are byte-identical.
146
+ row("vault-readme", README, () =>
147
+ renderTemplate(loadTemplate(language, "vault-readme", root), {
148
+ vault_name: basename(vault),
149
+ folder_rows: folderRows(spec, strings),
150
+ }),
151
+ );
152
+ plan.creates = rows.filter((r) => r.action === "create").length;
153
+ plan.ok = refusals.length === 0;
154
+ return plan;
155
+ }
156
+
157
+ // Writes the plan's `create` rows and nothing else: every folder first —
158
+ // writeFileAtomic never mkdirs — then each README, its absence checked again
159
+ // at the moment of writing, so a file that appeared after the plan (another
160
+ // session, a hand) is reported as there and never rewritten. The vault root is
161
+ // the caller's to make: `init` makes it, and a binding whose vault vanished is
162
+ // refused upstream, never recreated here behind the person's back.
163
+ export function applyScaffold(plan) {
164
+ if (!plan.ok)
165
+ throw Object.assign(new Error(plan.refusals.map((r) => r.message).join("; ")), {
166
+ code: plan.refusals[0] ? plan.refusals[0].code : "REFUSED",
167
+ });
168
+ if (pathKind(plan.vault) !== "dir")
169
+ throw Object.assign(new Error(`${plan.vault} does not exist — scaffold never creates the vault itself`), {
170
+ code: "MISSING",
171
+ });
172
+ const made = new Set();
173
+ for (const r of plan.rows) {
174
+ if (r.type !== "folder" || r.action !== "create" || existsSync(r.abs)) continue;
175
+ mkdirSync(r.abs, { recursive: true });
176
+ made.add(r.rel);
177
+ }
178
+ for (const r of plan.rows) {
179
+ if (r.type === "folder" || r.action !== "create" || existsSync(r.abs)) continue;
180
+ writeFileAtomic(r.abs, r.content);
181
+ made.add(r.rel);
182
+ }
183
+ return {
184
+ created: plan.rows.filter((r) => made.has(r.rel)).map((r) => r.rel),
185
+ exists: plan.rows.filter((r) => !made.has(r.rel)).map((r) => r.rel),
186
+ };
187
+ }
188
+
189
+ // The result a front-end reports: the rows and what became of them, never a
190
+ // file body. `path` is vault-relative.
191
+ export function scaffoldResult(plan, done = null) {
192
+ return {
193
+ vault_path: plan.vault,
194
+ layout: plan.layout,
195
+ language: plan.language,
196
+ rows: plan.rows.map((r) => ({ type: r.type, path: r.rel, action: r.action })),
197
+ creates: plan.creates,
198
+ wrote: Boolean(done),
199
+ created: done ? done.created : [],
200
+ exists: done ? done.exists : plan.rows.filter((r) => r.action === "exists").map((r) => r.rel),
201
+ refusals: plan.refusals,
202
+ };
203
+ }
204
+
205
+ const shown = (r) => (r.type === "folder" ? `${r.rel}/` : r.rel);
206
+
207
+ // The plan as text. A bare plan ends with the command that writes it, in both
208
+ // forms — the session's and the core's through npx, since a git-marketplace
209
+ // install has no bin on PATH — and with `--write`, because at a terminal that
210
+ // form shows this plan again and asks. Each stands alone on its line after a
211
+ // blank one, and the lead names `project`, where the terminal's form runs
212
+ // (the story "User-facing text says where to run…", rules 1, 4 and 6).
213
+ // `asking`: the question follows on the same stream, so the closing lines are
214
+ // left out. After an apply, what was created and what was already there.
215
+ export function renderScaffoldPlan(plan, done = null, { env = process.env, asking = false, project = null } = {}) {
216
+ if (!plan.ok) return [...plan.refusals.map((r) => r.message), "Nothing written."].join("\n") + "\n";
217
+ const where = `${plan.vault} (layout ${plan.layout}, language ${plan.language})`;
218
+ if (done) {
219
+ const lines = [
220
+ `Scaffolded ${where}`,
221
+ ...plan.rows.filter((r) => done.created.includes(r.rel)).map((r) => ` created ${shown(r)}`),
222
+ ];
223
+ lines.push(
224
+ done.exists.length
225
+ ? `Created ${done.created.length}; ${done.exists.length} already there, left as ${done.exists.length === 1 ? "it was" : "they were"}.`
226
+ : `Created ${done.created.length}.`,
227
+ );
228
+ return lines.join("\n") + "\n";
229
+ }
230
+ const lines = [`Scaffold ${where}`, ...plan.rows.map((r) => ` ${r.action} ${shown(r)}`), ""];
231
+ const there = plan.rows.length - plan.creates;
232
+ if (!plan.creates) lines.push("Nothing to create: every folder and README the layout declares is there.");
233
+ else {
234
+ lines.push(`${plan.creates} to create${there ? `; ${there} already there, never rewritten` : ""}.`);
235
+ if (!asking)
236
+ lines.push(
237
+ `To write them, in a session or ${project ? fromTerminalIn(project) : "from a terminal in this project"}:`,
238
+ "",
239
+ ` ${commandForm("scaffold", { env })}`,
240
+ ` ${packageCommand([], "scaffold", { args: "--write" })}`,
241
+ );
242
+ }
243
+ return lines.join("\n") + "\n";
244
+ }