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.
- package/README.md +1 -1
- package/bin/projectstore-claude.mjs +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 +44 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +16 -20
- package/node_modules/projectstore/commands/doctor.md +12 -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 +2 -2
- package/node_modules/projectstore/commands/story.md +15 -2
- 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 +12 -3
- package/node_modules/projectstore/harnesses/claude-code.json +23 -8
- package/node_modules/projectstore/harnesses/codex.json +16 -6
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +622 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
- package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
- package/node_modules/projectstore/hooks/session-start.mjs +56 -20
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +12 -2
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
- package/node_modules/projectstore/scripts/band.mjs +207 -0
- package/node_modules/projectstore/scripts/binding.mjs +733 -62
- package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
- package/node_modules/projectstore/scripts/cli.mjs +1463 -267
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +638 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
- package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +283 -37
- package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
- package/node_modules/projectstore/scripts/kanban.mjs +6 -21
- package/node_modules/projectstore/scripts/lib.mjs +933 -199
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +441 -72
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +139 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +84 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +55 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
- package/node_modules/projectstore/scripts/term.mjs +1027 -52
- package/node_modules/projectstore/scripts/tokens.mjs +106 -28
- package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
- package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- 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
|
@@ -16,35 +16,89 @@
|
|
|
16
16
|
// another vault. Neither asks on a terminal, unlike install: there is no
|
|
17
17
|
// preview to show — the whole write is the three values the caller just
|
|
18
18
|
// typed, and the refusal texts name the second word. A rebind keeps every
|
|
19
|
-
// other key of the config (statusline, agents,
|
|
20
|
-
//
|
|
19
|
+
// other key of the config (statusline, agents, channel_keep …): those are the
|
|
20
|
+
// project's decisions, not the vault's.
|
|
21
21
|
//
|
|
22
22
|
// Never reads ambient cwd or ambient env: the caller resolves the project
|
|
23
23
|
// (cli.mjs's resolveProject) and supplies the author. Normative: the CLI
|
|
24
24
|
// story's slice-2 plan (PS-CORE); the fresh config's keys mirror
|
|
25
25
|
// commands/bind.md step 3 (a test pins the two together).
|
|
26
|
+
//
|
|
27
|
+
// `init` creates a whole vault (the story "Scaffold is a core verb, and init
|
|
28
|
+
// creates a whole vault…", PS-CORE; the front-door ADR's decision 7): for a
|
|
29
|
+
// directory that is missing, or there and empty, the plan is the binding's
|
|
30
|
+
// AND the scaffold's, refused as one before anything is made; the apply is
|
|
31
|
+
// mkdir, the binding, `git init`, then the scaffold. Naming the vault stays
|
|
32
|
+
// the confirmation, as for `bind` — `init`'s command line names what it
|
|
33
|
+
// writes, which `scaffold --write`'s does not, so `init` asks nothing even at
|
|
34
|
+
// a terminal. A directory that is not empty is bound as before and never
|
|
35
|
+
// touched: no `git init` inside someone's files, no scaffold. For `bind`, and
|
|
36
|
+
// for that `init`, the scaffold plan only decides the "Next" line.
|
|
37
|
+
|
|
38
|
+
//
|
|
39
|
+
// Setup composes these too (the story "projectstore setup asks, shows one
|
|
40
|
+
// plan and installs every harness it finds, with the vault", rules 7 and
|
|
41
|
+
// 10): planInherit() is a worktree's binding copied from its main checkout;
|
|
42
|
+
// `scaffoldExisting` adds the scaffold to a bind of an existing vault whose
|
|
43
|
+
// layout folders are missing; bindPlanSteps() is the one list of steps the
|
|
44
|
+
// installer's PLAN shows for a vault and applyBind() reports through
|
|
45
|
+
// `onStep`; recheckBind() is the apply-time check that the preview still
|
|
46
|
+
// holds; gitTop() is the git top setup's first question reads.
|
|
26
47
|
|
|
27
48
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, realpathSync } from "node:fs";
|
|
49
|
+
import { spawnSync } from "node:child_process";
|
|
28
50
|
import { join, resolve, isAbsolute, dirname } from "node:path";
|
|
29
51
|
import { homedir } from "node:os";
|
|
30
|
-
import {
|
|
31
|
-
|
|
52
|
+
import {
|
|
53
|
+
writeFileAtomic,
|
|
54
|
+
pluginRoot,
|
|
55
|
+
ensureRuntimeDir,
|
|
56
|
+
layoutPaths,
|
|
57
|
+
commandForm,
|
|
58
|
+
readConfigAt,
|
|
59
|
+
fromTerminalIn,
|
|
60
|
+
CHANNEL_SWITCH_KEY,
|
|
61
|
+
CHANNEL_KEEP_KEY,
|
|
62
|
+
} from "./lib.mjs";
|
|
63
|
+
import { configPath as harnessConfigPath, packageCommand } from "./harness.mjs";
|
|
64
|
+
import { planScaffold, applyScaffold } from "./scaffold.mjs";
|
|
65
|
+
import { icon, kv, commandBlock, wrap, PLAIN } from "./term.mjs";
|
|
32
66
|
|
|
33
67
|
export const DEFAULT_LAYOUT = "engineering";
|
|
34
68
|
export const DEFAULT_LANGUAGE = "en";
|
|
35
69
|
// commands/bind.md step 3, in its order.
|
|
36
|
-
export const FRESH_CONFIG_KEYS = Object.freeze([
|
|
70
|
+
export const FRESH_CONFIG_KEYS = Object.freeze([
|
|
71
|
+
"vault_path",
|
|
72
|
+
"layout",
|
|
73
|
+
"auto_inject",
|
|
74
|
+
"language",
|
|
75
|
+
"tags",
|
|
76
|
+
"default_author",
|
|
77
|
+
"active_skills",
|
|
78
|
+
"approval_mode",
|
|
79
|
+
]);
|
|
37
80
|
|
|
38
81
|
export function layoutNames(root = pluginRoot()) {
|
|
39
82
|
const dir = join(root, "scaffold", "layouts");
|
|
40
83
|
if (!existsSync(dir)) return [];
|
|
41
|
-
return readdirSync(dir)
|
|
84
|
+
return readdirSync(dir)
|
|
85
|
+
.filter((n) => n.endsWith(".json"))
|
|
86
|
+
.map((n) => n.replace(/\.json$/, ""))
|
|
87
|
+
.sort();
|
|
42
88
|
}
|
|
43
89
|
|
|
44
90
|
export function languageNames(root = pluginRoot()) {
|
|
45
91
|
const dir = join(root, "templates");
|
|
46
92
|
if (!existsSync(dir)) return [];
|
|
47
|
-
return readdirSync(dir)
|
|
93
|
+
return readdirSync(dir)
|
|
94
|
+
.filter((n) => {
|
|
95
|
+
try {
|
|
96
|
+
return statSync(join(dir, n)).isDirectory();
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
.sort();
|
|
48
102
|
}
|
|
49
103
|
|
|
50
104
|
// `~` expanded, made absolute against the project, trailing slash dropped.
|
|
@@ -61,27 +115,72 @@ export function normaliseVaultPath(p, projectDir, home = homedir()) {
|
|
|
61
115
|
return s;
|
|
62
116
|
}
|
|
63
117
|
|
|
64
|
-
const realOr = (p) => {
|
|
118
|
+
const realOr = (p) => {
|
|
119
|
+
try {
|
|
120
|
+
return realpathSync(p);
|
|
121
|
+
} catch {
|
|
122
|
+
return p;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
65
125
|
|
|
66
126
|
// The existing config as {cfg, corrupt}: a file that exists but does not parse
|
|
67
127
|
// is not "unbound" here — treating it so would overwrite the keys a rebind
|
|
68
128
|
// promises to keep.
|
|
69
129
|
function readExisting(configPath) {
|
|
70
130
|
if (!existsSync(configPath)) return { cfg: null, corrupt: false };
|
|
71
|
-
try {
|
|
131
|
+
try {
|
|
132
|
+
return { cfg: JSON.parse(readFileSync(configPath, "utf8")), corrupt: false };
|
|
133
|
+
} catch {
|
|
134
|
+
return { cfg: null, corrupt: true };
|
|
135
|
+
}
|
|
72
136
|
}
|
|
73
137
|
|
|
74
|
-
const shellQuote = (s) => (/^[A-Za-z0-9_
|
|
138
|
+
export const shellQuote = (s) => (/^[A-Za-z0-9_/.~-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`);
|
|
139
|
+
|
|
140
|
+
// A directory with no entries at all. A `.DS_Store` makes it not empty: that
|
|
141
|
+
// edge stays visible rather than guessed at. Unreadable is not empty either.
|
|
142
|
+
function isEmptyDir(p) {
|
|
143
|
+
try {
|
|
144
|
+
return statSync(p).isDirectory() && readdirSync(p).length === 0;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
75
149
|
|
|
76
150
|
// What `bind` would write. `state`: "unbound" (fresh bind), "same" (already
|
|
77
151
|
// bound to this vault — nothing to write), "different" (bound elsewhere —
|
|
78
152
|
// needs --rebind). Refusals are the reasons apply() must not run; each has a
|
|
79
153
|
// code: USAGE (exit 2 upstream), MISSING, REBIND, BOUND, UNREADABLE,
|
|
80
|
-
// NO_PROJECT
|
|
81
|
-
|
|
154
|
+
// NO_PROJECT, and on `init`'s whole-vault path the scaffold plan's own
|
|
155
|
+
// (exit 1). `buildsVault`: `init` on a missing or empty directory — mkdir
|
|
156
|
+
// (createsVault, missing only), git and the scaffold follow the binding.
|
|
157
|
+
// `scaffold` is the scaffold plan for the vault, on every path that has one.
|
|
158
|
+
// `scaffoldExisting` (setup's "bind existing + scaffold", its rule 7): an
|
|
159
|
+
// existing, non-empty vault whose scaffold plan has a `create` row is bound
|
|
160
|
+
// AND scaffolded — never `git init`ed, never mkdir'd — `scaffoldsExisting`;
|
|
161
|
+
// the scaffold's refusals are then the plan's, refused as one.
|
|
162
|
+
export function planBind(
|
|
163
|
+
projectDir,
|
|
164
|
+
{
|
|
165
|
+
vault,
|
|
166
|
+
layout = null,
|
|
167
|
+
language = null,
|
|
168
|
+
rebind = false,
|
|
169
|
+
init = false,
|
|
170
|
+
scaffoldExisting = false,
|
|
171
|
+
author = "",
|
|
172
|
+
env = process.env,
|
|
173
|
+
root = pluginRoot(),
|
|
174
|
+
home = homedir(),
|
|
175
|
+
} = {},
|
|
176
|
+
) {
|
|
82
177
|
const refusals = [];
|
|
83
178
|
const configPath = harnessConfigPath(projectDir, env);
|
|
84
|
-
if (!existsSync(projectDir))
|
|
179
|
+
if (!existsSync(projectDir))
|
|
180
|
+
refusals.push({
|
|
181
|
+
code: "NO_PROJECT",
|
|
182
|
+
message: `${projectDir} does not exist — a binding belongs to an existing project directory`,
|
|
183
|
+
});
|
|
85
184
|
const norm = normaliseVaultPath(vault, projectDir, home);
|
|
86
185
|
const vaultPath = typeof norm === "string" ? norm : null;
|
|
87
186
|
if (!norm) refusals.push({ code: "USAGE", message: "a vault path is required" });
|
|
@@ -89,77 +188,649 @@ export function planBind(projectDir, { vault, layout = null, language = null, re
|
|
|
89
188
|
const layouts = layoutNames(root);
|
|
90
189
|
const languages = languageNames(root);
|
|
91
190
|
const { cfg: before, corrupt } = readExisting(configPath);
|
|
92
|
-
if (corrupt)
|
|
191
|
+
if (corrupt)
|
|
192
|
+
refusals.push({
|
|
193
|
+
code: "UNREADABLE",
|
|
194
|
+
message: `${configPath} exists but is not valid JSON — fix or remove it before binding; nothing is overwritten`,
|
|
195
|
+
});
|
|
93
196
|
const storedVault = before ? normaliseVaultPath(before.vault_path, projectDir, home) : null;
|
|
94
|
-
const sameVault = Boolean(
|
|
197
|
+
const sameVault = Boolean(
|
|
198
|
+
vaultPath &&
|
|
199
|
+
typeof storedVault === "string" &&
|
|
200
|
+
(storedVault === vaultPath ||
|
|
201
|
+
(existsSync(storedVault) && existsSync(vaultPath) && realOr(storedVault) === realOr(vaultPath))),
|
|
202
|
+
);
|
|
95
203
|
const state = !before ? "unbound" : sameVault ? "same" : "different";
|
|
96
|
-
const chosenLayout =
|
|
97
|
-
|
|
98
|
-
const
|
|
204
|
+
const chosenLayout =
|
|
205
|
+
state === "same" ? before.layout || DEFAULT_LAYOUT : (layout ?? (before ? before.layout : null) ?? DEFAULT_LAYOUT);
|
|
206
|
+
const chosenLanguage =
|
|
207
|
+
state === "same"
|
|
208
|
+
? before.language || DEFAULT_LANGUAGE
|
|
209
|
+
: (language ?? (before ? before.language : null) ?? DEFAULT_LANGUAGE);
|
|
210
|
+
const ignored =
|
|
211
|
+
state === "same"
|
|
212
|
+
? [
|
|
213
|
+
layout !== null && layout !== chosenLayout ? "layout" : null,
|
|
214
|
+
language !== null && language !== chosenLanguage ? "language" : null,
|
|
215
|
+
].filter(Boolean)
|
|
216
|
+
: [];
|
|
99
217
|
if (state !== "same") {
|
|
100
|
-
if (layouts.length && !layouts.includes(chosenLayout))
|
|
101
|
-
|
|
218
|
+
if (layouts.length && !layouts.includes(chosenLayout))
|
|
219
|
+
refusals.push({ code: "USAGE", message: `unknown layout "${chosenLayout}" — one of: ${layouts.join(", ")}` });
|
|
220
|
+
if (languages.length && !languages.includes(chosenLanguage))
|
|
221
|
+
refusals.push({
|
|
222
|
+
code: "USAGE",
|
|
223
|
+
message: `unknown language "${chosenLanguage}" — one of: ${languages.join(", ")}`,
|
|
224
|
+
});
|
|
102
225
|
}
|
|
103
226
|
const vaultExists = vaultPath ? existsSync(vaultPath) : false;
|
|
104
|
-
if (vaultPath && vaultExists && !statSync(vaultPath).isDirectory())
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
227
|
+
if (vaultPath && vaultExists && !statSync(vaultPath).isDirectory())
|
|
228
|
+
refusals.push({ code: "USAGE", message: `${vaultPath} is not a directory` });
|
|
229
|
+
// init in the core's form, alone on the last line, the directory in the
|
|
230
|
+
// lead (the story "User-facing text says where to run…", rules 1, 4 and 6).
|
|
231
|
+
if (vaultPath && !vaultExists && !init)
|
|
232
|
+
refusals.push({
|
|
233
|
+
code: "MISSING",
|
|
234
|
+
message: `${vaultPath} does not exist. Create it first, or create and bind it in one step ${fromTerminalIn(projectDir)}:\n${packageCommand([], "init", { args: `${shellQuote(String(vault))}${rebind ? " --rebind" : ""}` })}`,
|
|
235
|
+
});
|
|
236
|
+
if (state === "different" && !rebind)
|
|
237
|
+
refusals.push({
|
|
238
|
+
code: "REBIND",
|
|
239
|
+
message: `${projectDir} is bound to ${before.vault_path}; pass --rebind to point it at ${vaultPath} instead (every other setting is kept)`,
|
|
240
|
+
});
|
|
241
|
+
const scaffold = vaultPath ? planScaffold(vaultPath, { layout: chosenLayout, language: chosenLanguage, root }) : null;
|
|
242
|
+
const missingFolders = Boolean(scaffold && scaffold.ok && scaffold.creates > 0);
|
|
243
|
+
// Only while the vault is there. A binding whose vault directory was deleted
|
|
244
|
+
// made `init` refuse as BOUND, so "run init" — the scaffold verb's own
|
|
245
|
+
// advice for a missing vault — led nowhere; such an `init` rebuilds it
|
|
246
|
+
// under the binding it already has, writing no config.
|
|
247
|
+
// The scaffold that completes it: prose, then each form alone on its line,
|
|
248
|
+
// the terminal's last (the story "User-facing text says where to run and
|
|
249
|
+
// names no project path and no shell", rules 4 and 6).
|
|
250
|
+
if (state === "same" && init && vaultExists)
|
|
251
|
+
refusals.push({
|
|
252
|
+
code: "BOUND",
|
|
253
|
+
message: missingFolders
|
|
254
|
+
? [
|
|
255
|
+
`${projectDir} is already bound to ${vaultPath}. Create the layout's missing folders and READMEs, in a session or from a terminal:`,
|
|
256
|
+
commandForm("scaffold", { env }),
|
|
257
|
+
packageCommand([], "scaffold", { args: "--write" }),
|
|
258
|
+
].join("\n")
|
|
259
|
+
: `${projectDir} is already bound to ${vaultPath}`,
|
|
260
|
+
});
|
|
261
|
+
const buildable = Boolean(init && vaultPath && refusals.length === 0 && (!vaultExists || isEmptyDir(vaultPath)));
|
|
262
|
+
// The whole-vault path is refused as one plan: a missing template or folder
|
|
263
|
+
// string stops the mkdir too. Elsewhere the scaffold plan's refusals are not
|
|
264
|
+
// the binding's — a bind into a vault this language cannot scaffold still
|
|
265
|
+
// binds, and its Next line names `status`.
|
|
266
|
+
if (buildable && scaffold) refusals.push(...scaffold.refusals);
|
|
267
|
+
const existing = Boolean(
|
|
268
|
+
scaffoldExisting && vaultPath && vaultExists && state !== "same" && !buildable && scaffold && scaffold.creates > 0,
|
|
269
|
+
);
|
|
270
|
+
if (existing) refusals.push(...scaffold.refusals);
|
|
271
|
+
const after =
|
|
272
|
+
state === "same"
|
|
273
|
+
? before
|
|
274
|
+
: state === "different"
|
|
275
|
+
? { ...before, vault_path: vaultPath, layout: chosenLayout, language: chosenLanguage }
|
|
276
|
+
: {
|
|
277
|
+
vault_path: vaultPath,
|
|
278
|
+
layout: chosenLayout,
|
|
279
|
+
auto_inject: true,
|
|
280
|
+
language: chosenLanguage,
|
|
281
|
+
tags: [],
|
|
282
|
+
default_author: author || "",
|
|
283
|
+
active_skills: true,
|
|
284
|
+
approval_mode: "always",
|
|
285
|
+
};
|
|
286
|
+
const keptKeys =
|
|
287
|
+
state === "different"
|
|
288
|
+
? Object.keys(before)
|
|
289
|
+
.filter((k) => !["vault_path", "layout", "language"].includes(k))
|
|
290
|
+
.sort()
|
|
291
|
+
: [];
|
|
292
|
+
const ok = refusals.length === 0;
|
|
112
293
|
return {
|
|
113
|
-
projectDir,
|
|
114
|
-
|
|
294
|
+
projectDir,
|
|
295
|
+
configPath,
|
|
296
|
+
vault: vaultPath,
|
|
297
|
+
vaultExists,
|
|
298
|
+
layout: chosenLayout,
|
|
299
|
+
language: chosenLanguage,
|
|
300
|
+
ignored,
|
|
301
|
+
state,
|
|
302
|
+
before,
|
|
303
|
+
after,
|
|
304
|
+
keptKeys,
|
|
305
|
+
writes: ok && state !== "same",
|
|
306
|
+
createsVault: Boolean(init && vaultPath && !vaultExists && ok),
|
|
307
|
+
buildsVault: buildable && ok,
|
|
308
|
+
scaffoldsExisting: existing && ok,
|
|
309
|
+
scaffold,
|
|
310
|
+
refusals,
|
|
311
|
+
ok,
|
|
312
|
+
inherit: null,
|
|
115
313
|
};
|
|
116
314
|
}
|
|
117
315
|
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
316
|
+
// The keys a worktree's binding never takes from its main checkout: each
|
|
317
|
+
// records a choice about THIS checkout's own rows in the host's registry
|
|
318
|
+
// (local scope, one row per checkout), so another checkout's answer does not
|
|
319
|
+
// apply here (the setup story, rule 7; commands/bind.md step 0a).
|
|
320
|
+
export const INHERIT_DROPS = Object.freeze([CHANNEL_SWITCH_KEY, CHANNEL_KEEP_KEY]);
|
|
321
|
+
|
|
322
|
+
// The worktree row (the setup story, rule 7): `projectDir`'s binding copied
|
|
323
|
+
// from the checkout `from` — read through readConfigAt, the new path then the
|
|
324
|
+
// legacy one — every key verbatim but those in `drop`, as bind's inherit
|
|
325
|
+
// path (step 0a) copies it. The vault is the parent's and is shared: it is
|
|
326
|
+
// never created, git-initialised or scaffolded here. A relative vault_path
|
|
327
|
+
// is relative to the parent, so the copy carries it resolved there. The plan
|
|
328
|
+
// has planBind's shape, so applyBind writes it (with the .projectstore/
|
|
329
|
+
// .gitignore) and bindResult reports it; `inherit` names the checkout, the
|
|
330
|
+
// keys never copied (`drop`) and those its binding carried (`dropped`).
|
|
331
|
+
// Refusals: NO_PROJECT, USAGE (no checkout named,
|
|
332
|
+
// a `~user` vault path, a vault that is not a directory — as planBind
|
|
333
|
+
// refuses it), NOT_BOUND (the checkout has no binding, or one that
|
|
334
|
+
// names no vault), MISSING (its vault is gone), UNREADABLE, and REBIND for a
|
|
335
|
+
// project bound elsewhere — inheriting never rebinds.
|
|
336
|
+
export function planInherit(
|
|
337
|
+
projectDir,
|
|
338
|
+
{ from, drop = INHERIT_DROPS, env = process.env, root = pluginRoot(), home = homedir() } = {},
|
|
339
|
+
) {
|
|
340
|
+
const refusals = [];
|
|
341
|
+
const configPath = harnessConfigPath(projectDir, env);
|
|
342
|
+
if (!existsSync(projectDir))
|
|
343
|
+
refusals.push({
|
|
344
|
+
code: "NO_PROJECT",
|
|
345
|
+
message: `${projectDir} does not exist — a binding belongs to an existing project directory`,
|
|
346
|
+
});
|
|
347
|
+
const parent = from ? readConfigAt(from) : null;
|
|
348
|
+
const usable =
|
|
349
|
+
parent &&
|
|
350
|
+
typeof parent === "object" &&
|
|
351
|
+
!Array.isArray(parent) &&
|
|
352
|
+
typeof parent.vault_path === "string" &&
|
|
353
|
+
parent.vault_path.trim() !== "";
|
|
354
|
+
if (!from) refusals.push({ code: "USAGE", message: "a checkout to inherit the binding from is required" });
|
|
355
|
+
else if (!usable)
|
|
356
|
+
refusals.push({
|
|
357
|
+
code: "NOT_BOUND",
|
|
358
|
+
message: `${from} has no binding naming a vault, so there is nothing to inherit`,
|
|
359
|
+
});
|
|
360
|
+
const norm = usable ? normaliseVaultPath(parent.vault_path, from, home) : null;
|
|
361
|
+
const vaultPath = typeof norm === "string" ? norm : null;
|
|
362
|
+
if (norm && norm.error) refusals.push({ code: "USAGE", message: norm.error });
|
|
363
|
+
const vaultExists = vaultPath ? existsSync(vaultPath) : false;
|
|
364
|
+
if (vaultPath && !vaultExists)
|
|
365
|
+
refusals.push({ code: "MISSING", message: `${vaultPath}, the vault ${from} is bound to, does not exist` });
|
|
366
|
+
// As planBind refuses it: a vault is a directory.
|
|
367
|
+
const vaultIsDir = vaultExists && statSync(vaultPath).isDirectory();
|
|
368
|
+
if (vaultExists && !vaultIsDir)
|
|
369
|
+
refusals.push({ code: "USAGE", message: `${vaultPath}, the vault ${from} is bound to, is not a directory` });
|
|
370
|
+
const { cfg: before, corrupt } = readExisting(configPath);
|
|
371
|
+
if (corrupt)
|
|
372
|
+
refusals.push({
|
|
373
|
+
code: "UNREADABLE",
|
|
374
|
+
message: `${configPath} exists but is not valid JSON — fix or remove it before binding; nothing is overwritten`,
|
|
375
|
+
});
|
|
376
|
+
const storedVault = before ? normaliseVaultPath(before.vault_path, projectDir, home) : null;
|
|
377
|
+
const sameVault = Boolean(
|
|
378
|
+
vaultPath &&
|
|
379
|
+
typeof storedVault === "string" &&
|
|
380
|
+
(storedVault === vaultPath ||
|
|
381
|
+
(existsSync(storedVault) && existsSync(vaultPath) && realOr(storedVault) === realOr(vaultPath))),
|
|
382
|
+
);
|
|
383
|
+
const state = !before ? "unbound" : sameVault ? "same" : "different";
|
|
384
|
+
if (state === "different")
|
|
385
|
+
refusals.push({
|
|
386
|
+
code: "REBIND",
|
|
387
|
+
message: `${projectDir} is bound to ${before.vault_path}; inheriting never rebinds`,
|
|
388
|
+
});
|
|
389
|
+
const dropped = usable ? drop.filter((k) => Object.hasOwn(parent, k)) : [];
|
|
390
|
+
const copied = usable ? Object.fromEntries(Object.entries(parent).filter(([k]) => !drop.includes(k))) : null;
|
|
391
|
+
// `~` means the same home in both checkouts; a relative path does not.
|
|
392
|
+
if (copied && vaultPath && !isAbsolute(parent.vault_path) && !parent.vault_path.trim().startsWith("~"))
|
|
393
|
+
copied.vault_path = vaultPath;
|
|
394
|
+
const layout = (copied && copied.layout) || DEFAULT_LAYOUT;
|
|
395
|
+
const language = (copied && copied.language) || DEFAULT_LANGUAGE;
|
|
396
|
+
const scaffold = vaultPath && vaultIsDir ? planScaffold(vaultPath, { layout, language, root }) : null;
|
|
397
|
+
const ok = refusals.length === 0;
|
|
398
|
+
return {
|
|
399
|
+
projectDir,
|
|
400
|
+
configPath,
|
|
401
|
+
vault: vaultPath,
|
|
402
|
+
vaultExists,
|
|
403
|
+
layout,
|
|
404
|
+
language,
|
|
405
|
+
ignored: [],
|
|
406
|
+
state,
|
|
407
|
+
before,
|
|
408
|
+
after: state === "same" ? before : copied,
|
|
409
|
+
keptKeys: [],
|
|
410
|
+
writes: ok && state === "unbound",
|
|
411
|
+
createsVault: false,
|
|
412
|
+
buildsVault: false,
|
|
413
|
+
scaffoldsExisting: false,
|
|
414
|
+
scaffold,
|
|
415
|
+
refusals,
|
|
416
|
+
ok,
|
|
417
|
+
inherit: from ? { from, drop: [...drop], dropped } : null,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// The steps a binding plan writes, in apply's order — one list for the
|
|
422
|
+
// installer's PLAN (setup's `vault` item, the story's rule 10) and for the
|
|
423
|
+
// APPLY lines applyBind reports through `onStep`, so the two never disagree:
|
|
424
|
+
// mkdir the vault's directory (createsVault)
|
|
425
|
+
// bind the binding at configPath — for an inherit, the checkout it
|
|
426
|
+
// copies and the keys it drops
|
|
427
|
+
// git-init `git init --quiet` in the vault (buildsVault)
|
|
428
|
+
// scaffold one per layout folder with something to create — the folder
|
|
429
|
+
// and its README.md — then the vault's README.md: `rels` are
|
|
430
|
+
// vault-relative, a folder with its trailing slash
|
|
431
|
+
export function bindPlanSteps(p) {
|
|
432
|
+
if (!p || !p.ok) return [];
|
|
433
|
+
const steps = [];
|
|
434
|
+
if (p.createsVault) steps.push({ kind: "mkdir", path: p.vault, why: "the vault's directory" });
|
|
435
|
+
if (p.writes) {
|
|
436
|
+
steps.push({
|
|
437
|
+
kind: "bind",
|
|
438
|
+
path: p.configPath,
|
|
439
|
+
vault: p.vault,
|
|
440
|
+
layout: p.layout,
|
|
441
|
+
language: p.language,
|
|
442
|
+
...(p.inherit ? { from: p.inherit.from, drop: [...p.inherit.drop], dropped: [...p.inherit.dropped] } : {}),
|
|
443
|
+
why: p.inherit
|
|
444
|
+
? `the binding of ${p.inherit.from}, copied but for the keys that record that checkout's own registry rows; with .projectstore/.gitignore, which keeps it machine-local`
|
|
445
|
+
: "the binding, with .projectstore/.gitignore, which keeps it machine-local",
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
if (p.buildsVault)
|
|
449
|
+
steps.push({
|
|
450
|
+
kind: "git-init",
|
|
451
|
+
path: p.vault,
|
|
452
|
+
argv: ["git", "init", "--quiet"],
|
|
453
|
+
why: "the vault's own repository, with no commit, on your own init.defaultBranch",
|
|
454
|
+
});
|
|
455
|
+
if ((p.buildsVault || p.scaffoldsExisting) && p.scaffold && p.scaffold.ok) steps.push(...scaffoldSteps(p.scaffold));
|
|
456
|
+
return steps;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function scaffoldSteps(s) {
|
|
460
|
+
const out = [];
|
|
461
|
+
const shown = (r) => (r.type === "folder" ? `${r.rel}/` : r.rel);
|
|
462
|
+
const byRel = new Map(s.rows.map((r) => [r.rel, r]));
|
|
463
|
+
for (const r of s.rows.filter((x) => x.type === "folder")) {
|
|
464
|
+
const own = [r, byRel.get(`${r.rel}/README.md`)].filter((x) => x && x.action === "create");
|
|
465
|
+
if (own.length)
|
|
466
|
+
out.push({
|
|
467
|
+
kind: "scaffold",
|
|
468
|
+
path: own[0].abs,
|
|
469
|
+
vault: s.vault,
|
|
470
|
+
rels: own.map(shown),
|
|
471
|
+
why: "the layout's folder and its index",
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
const top = s.rows.find((r) => r.type === "vault-readme");
|
|
475
|
+
if (top && top.action === "create")
|
|
476
|
+
out.push({
|
|
477
|
+
kind: "scaffold",
|
|
478
|
+
path: top.abs,
|
|
479
|
+
vault: s.vault,
|
|
480
|
+
rels: [shown(top)],
|
|
481
|
+
why: "the vault's own README, its folder table",
|
|
482
|
+
});
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// The apply-time check of a binding plan (the setup story, rule 10): the
|
|
487
|
+
// binding is still what the preview read — absent, or the same JSON, at the
|
|
488
|
+
// same path — and a vault the plan builds is still missing or empty. Returns
|
|
489
|
+
// the reason it does not hold, or null.
|
|
490
|
+
export function recheckBind(p, { env = process.env } = {}) {
|
|
491
|
+
const at = harnessConfigPath(p.projectDir, env);
|
|
492
|
+
const { cfg, corrupt } = readExisting(at);
|
|
493
|
+
if (
|
|
494
|
+
corrupt ||
|
|
495
|
+
resolve(at) !== resolve(p.configPath) ||
|
|
496
|
+
JSON.stringify(cfg ?? null) !== JSON.stringify(p.before ?? null)
|
|
497
|
+
)
|
|
498
|
+
return "the binding changed after the preview";
|
|
499
|
+
if (p.buildsVault && existsSync(p.vault) && !isEmptyDir(p.vault))
|
|
500
|
+
return `${p.vault} changed after the preview: it is no longer missing or empty`;
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// `git init` in a vault `init` just made: no commit, on the person's own
|
|
505
|
+
// init.defaultBranch (no `-b`), quiet, with its stderr captured so git's
|
|
506
|
+
// default-branch hint never reaches the person — its exit status alone
|
|
507
|
+
// decides done or failed. The vault is the cwd, so a spawn ENOENT can only
|
|
508
|
+
// mean git is not on PATH: the one skip the front-door ADR allows, and
|
|
509
|
+
// doctor's vault-git finding then stays. Every variable that locates a
|
|
510
|
+
// repository is dropped (GIT_LOCAL_ENV, git's own `rev-parse
|
|
511
|
+
// --local-env-vars`): inherited from a hook or a script, GIT_DIR alone would
|
|
512
|
+
// make this init create — or reinitialise — a repository somewhere else.
|
|
513
|
+
// `rev-parse --show-toplevel` from the new directory says whether it lies
|
|
514
|
+
// inside a work tree (realpath'd: macOS answers /private/var for /var); git
|
|
515
|
+
// init still runs, and the result carries that top. A failure carries its
|
|
516
|
+
// remedy, so a caller reading --json can relay it as the text does.
|
|
517
|
+
const GIT_LOCAL_ENV = Object.freeze([
|
|
518
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
519
|
+
"GIT_CONFIG",
|
|
520
|
+
"GIT_CONFIG_PARAMETERS",
|
|
521
|
+
"GIT_CONFIG_COUNT",
|
|
522
|
+
"GIT_OBJECT_DIRECTORY",
|
|
523
|
+
"GIT_DIR",
|
|
524
|
+
"GIT_WORK_TREE",
|
|
525
|
+
"GIT_IMPLICIT_WORK_TREE",
|
|
526
|
+
"GIT_GRAFT_FILE",
|
|
527
|
+
"GIT_INDEX_FILE",
|
|
528
|
+
"GIT_NO_REPLACE_OBJECTS",
|
|
529
|
+
"GIT_REPLACE_REF_BASE",
|
|
530
|
+
"GIT_PREFIX",
|
|
531
|
+
"GIT_SHALLOW_FILE",
|
|
532
|
+
"GIT_COMMON_DIR",
|
|
533
|
+
]);
|
|
534
|
+
const gitEnv = (env) => {
|
|
535
|
+
const out = { ...(env || {}) };
|
|
536
|
+
for (const k of GIT_LOCAL_ENV) delete out[k];
|
|
537
|
+
return out;
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// The top of the git work tree `cwd` lies in, realpath'd (macOS answers
|
|
541
|
+
// /private/var for /var), or null: outside a work tree (exit 128), git not on
|
|
542
|
+
// PATH, or no answer within `timeout` — each means "not in one" (the setup
|
|
543
|
+
// story, rule 4: 2 s). The environment is cleaned of every variable that
|
|
544
|
+
// locates a repository, as for git init: an inherited GIT_DIR would answer
|
|
545
|
+
// for another repository than the folder's.
|
|
546
|
+
export function gitTop(cwd, { env = process.env, spawn = spawnSync, timeout = 2000 } = {}) {
|
|
547
|
+
let r;
|
|
548
|
+
try {
|
|
549
|
+
r = spawn("git", ["rev-parse", "--show-toplevel"], {
|
|
550
|
+
cwd,
|
|
551
|
+
env: gitEnv(env),
|
|
552
|
+
encoding: "utf8",
|
|
553
|
+
timeout,
|
|
554
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
555
|
+
});
|
|
556
|
+
} catch {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
if (!r || r.error || r.status !== 0) return null;
|
|
560
|
+
const top = String(r.stdout || "").trim();
|
|
561
|
+
return top ? realOr(top) : null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function runGit(vault, { spawn = spawnSync, env = process.env } = {}) {
|
|
565
|
+
const opts = { cwd: vault, env: gitEnv(env), encoding: "utf8", timeout: 15000, stdio: ["ignore", "pipe", "pipe"] };
|
|
566
|
+
const absent = (r) => r.error && r.error.code === "ENOENT";
|
|
567
|
+
// The remedy: its lead names the vault, then git's command and doctor's fix
|
|
568
|
+
// in the session, each alone on its line — the commands setup's ▲ row
|
|
569
|
+
// names for the same failure (the story "User-facing text says where to
|
|
570
|
+
// run and names no project path and no shell", rule 4).
|
|
571
|
+
const failed = (message) => ({
|
|
572
|
+
failed: message,
|
|
573
|
+
remedy: [
|
|
574
|
+
`Run git init in ${JSON.stringify(String(vault))} from a terminal, or doctor's fix in a session, which offers it:`,
|
|
575
|
+
`git -C ${shellQuote(String(vault))} init`,
|
|
576
|
+
commandForm("doctor", { args: "--fix", env }),
|
|
577
|
+
].join("\n"),
|
|
578
|
+
});
|
|
579
|
+
const top = spawn("git", ["rev-parse", "--show-toplevel"], opts);
|
|
580
|
+
if (absent(top)) return { skipped: "git is not on PATH" };
|
|
581
|
+
const inside = top.status === 0 && top.stdout && top.stdout.trim() ? top.stdout.trim() : null;
|
|
582
|
+
const r = spawn("git", ["init", "--quiet"], opts);
|
|
583
|
+
if (absent(r)) return { skipped: "git is not on PATH" };
|
|
584
|
+
if (r.error) return failed(r.error.message);
|
|
585
|
+
if (r.status !== 0) return failed(String(r.stderr || "").trim() || `git init exited with ${r.status}`);
|
|
586
|
+
return inside && realOr(inside) !== realOr(vault) ? { done: true, inside } : { done: true };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// What finishes the vault once the binding is written: its lead, then the
|
|
590
|
+
// session's form and the core's through npx, each alone on its line, the
|
|
591
|
+
// directory in the lead (the story "User-facing text says where to run and
|
|
592
|
+
// names no project path and no shell", rules 1, 4 and 6). --json carries it
|
|
593
|
+
// one part per line.
|
|
594
|
+
const scaffoldRemedy = (projectDir, env) =>
|
|
595
|
+
[
|
|
596
|
+
`The binding is written. Finish the vault in a session, or ${fromTerminalIn(projectDir)}:`,
|
|
597
|
+
commandForm("scaffold", { env }),
|
|
598
|
+
packageCommand([], "scaffold", { args: "--write" }),
|
|
599
|
+
].join("\n");
|
|
600
|
+
|
|
601
|
+
// Writes what the plan says, in the story's order: the vault directory when
|
|
602
|
+
// init asked for it (not when it is there and empty), the config through
|
|
603
|
+
// writeFileAtomic (its directory made first — the helper never mkdirs), then,
|
|
604
|
+
// on init's whole-vault path, `git init` and the scaffold. Refuses to run a
|
|
605
|
+
// plan with refusals; a "same" plan writes nothing unless it rebuilds a vault
|
|
606
|
+
// that went missing. A git or scaffold step that fails is reported in `done`
|
|
607
|
+
// and the next step still runs — the binding is already written, so a re-run
|
|
608
|
+
// of `init` would only be refused, and the output names the one command that
|
|
609
|
+
// finishes the vault instead. `spawn` is a parameter so tests can stand in
|
|
610
|
+
// for git. A plan with `scaffoldsExisting` (an existing vault, bound) runs
|
|
611
|
+
// the scaffold after the binding, with no mkdir and no git init.
|
|
612
|
+
//
|
|
613
|
+
// `onStep(step, phase, result)` watches, as the installer's apply() steps
|
|
614
|
+
// do: each step of bindPlanSteps(plan) is reported "start", then "end" with
|
|
615
|
+
// { ok } — { ok, kept, label } for a git init skipped (git not on PATH) or a
|
|
616
|
+
// scaffold step whose files were all there already, { ok, warn } for a
|
|
617
|
+
// failed git init, which never stops the run. The scaffold's steps are
|
|
618
|
+
// reported once it has run: it writes every folder before any README.
|
|
619
|
+
export function applyBind(plan, { spawn = spawnSync, env = process.env, onStep = null } = {}) {
|
|
620
|
+
if (!plan.ok)
|
|
621
|
+
throw Object.assign(new Error(plan.refusals.map((r) => r.message).join("; ")), { code: plan.refusals[0].code });
|
|
622
|
+
const done = { created_vault: false, wrote_config: false, git: null, scaffold: null };
|
|
623
|
+
const steps = onStep ? bindPlanSteps(plan) : [];
|
|
624
|
+
const stepOf = (kind) => steps.find((s) => s.kind === kind) || null;
|
|
625
|
+
const tell = (st, phase, r) => {
|
|
626
|
+
if (onStep && st) onStep(st, phase, r);
|
|
627
|
+
};
|
|
628
|
+
// A write that throws ends its step as failed before the error leaves.
|
|
629
|
+
const watched = (st, fn) => {
|
|
630
|
+
tell(st, "start");
|
|
631
|
+
try {
|
|
632
|
+
fn();
|
|
633
|
+
} catch (e) {
|
|
634
|
+
tell(st, "end", { ok: false });
|
|
635
|
+
throw e;
|
|
636
|
+
}
|
|
637
|
+
tell(st, "end", { ok: true });
|
|
638
|
+
};
|
|
639
|
+
if (plan.createsVault) {
|
|
640
|
+
watched(stepOf("mkdir"), () => mkdirSync(plan.vault, { recursive: true }));
|
|
641
|
+
done.created_vault = true;
|
|
642
|
+
}
|
|
643
|
+
if (plan.writes)
|
|
644
|
+
watched(stepOf("bind"), () => {
|
|
645
|
+
// Contract 5 names bind among the writers of the line-merged
|
|
646
|
+
// .projectstore/.gitignore, and it was the one writer that never did:
|
|
647
|
+
// measured 2026-09-06 on a fresh project, `init` left .projectstore/
|
|
648
|
+
// holding the binding alone, so `git add -A` committed a machine-local
|
|
649
|
+
// absolute vault path. Only when we are writing the NEW path — a rebind of
|
|
650
|
+
// a not-yet-migrated project still writes the legacy file (contract 8),
|
|
651
|
+
// and creating .projectstore/ beside a legacy layout is the migration
|
|
652
|
+
// item's write, not ours.
|
|
653
|
+
if (plan.configPath === layoutPaths(plan.projectDir).binding) {
|
|
654
|
+
try {
|
|
655
|
+
ensureRuntimeDir(plan.projectDir);
|
|
656
|
+
} catch {}
|
|
657
|
+
}
|
|
658
|
+
mkdirSync(dirname(plan.configPath), { recursive: true });
|
|
659
|
+
writeFileAtomic(plan.configPath, JSON.stringify(plan.after, null, 2) + "\n");
|
|
660
|
+
done.wrote_config = true;
|
|
661
|
+
});
|
|
662
|
+
if (plan.buildsVault) {
|
|
663
|
+
const st = stepOf("git-init");
|
|
664
|
+
tell(st, "start");
|
|
665
|
+
done.git = runGit(plan.vault, { spawn, env });
|
|
666
|
+
const g = done.git;
|
|
667
|
+
tell(
|
|
668
|
+
st,
|
|
669
|
+
"end",
|
|
670
|
+
g.failed
|
|
671
|
+
? { ok: true, warn: `git init failed: ${String(g.failed).split("\n")[0]}` }
|
|
672
|
+
: g.skipped
|
|
673
|
+
? { ok: true, kept: true, label: `skipped git init: ${g.skipped}` }
|
|
674
|
+
: { ok: true },
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
if (plan.buildsVault || plan.scaffoldsExisting) {
|
|
678
|
+
try {
|
|
679
|
+
done.scaffold = applyScaffold(plan.scaffold);
|
|
680
|
+
} catch (e) {
|
|
681
|
+
done.scaffold = { failed: e && e.message ? e.message : String(e), remedy: scaffoldRemedy(plan.projectDir, env) };
|
|
682
|
+
}
|
|
683
|
+
const made = new Set(done.scaffold.failed ? [] : done.scaffold.created.map((r) => r.replace(/\/$/, "")));
|
|
684
|
+
for (const st of steps.filter((s) => s.kind === "scaffold")) {
|
|
685
|
+
tell(st, "start");
|
|
686
|
+
if (done.scaffold.failed) {
|
|
687
|
+
tell(st, "end", { ok: false });
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
tell(st, "end", { ok: true, kept: !st.rels.some((r) => made.has(r.replace(/\/$/, ""))) });
|
|
136
691
|
}
|
|
137
|
-
mkdirSync(dirname(plan.configPath), { recursive: true });
|
|
138
|
-
writeFileAtomic(plan.configPath, JSON.stringify(plan.after, null, 2) + "\n");
|
|
139
|
-
done.wrote_config = true;
|
|
140
692
|
}
|
|
141
693
|
return done;
|
|
142
694
|
}
|
|
143
695
|
|
|
144
696
|
// The result a front-end reports: the decided values, never the file body.
|
|
697
|
+
// `git` and `scaffold` are null unless `init` built the vault (`scaffold`
|
|
698
|
+
// also when setup scaffolded an existing one): git is
|
|
699
|
+
// {done: true[, inside]}, {skipped: reason} or {failed: message, remedy};
|
|
700
|
+
// scaffold is {created, exists} or {failed: message, remedy}.
|
|
145
701
|
export function bindResult(plan, done) {
|
|
146
702
|
return {
|
|
147
|
-
state: plan.state,
|
|
148
|
-
|
|
149
|
-
|
|
703
|
+
state: plan.state,
|
|
704
|
+
vault_path: plan.vault,
|
|
705
|
+
vault_exists: Boolean(done && done.created_vault) || plan.vaultExists,
|
|
706
|
+
layout: plan.layout,
|
|
707
|
+
language: plan.language,
|
|
708
|
+
config_path: plan.configPath,
|
|
709
|
+
wrote: Boolean(done && done.wrote_config),
|
|
710
|
+
created_vault: Boolean(done && done.created_vault),
|
|
711
|
+
kept_keys: plan.keptKeys,
|
|
712
|
+
ignored: plan.ignored,
|
|
713
|
+
refusals: plan.refusals,
|
|
714
|
+
git: (done && done.git) || null,
|
|
715
|
+
scaffold: (done && done.scaffold) || null,
|
|
150
716
|
};
|
|
151
717
|
}
|
|
152
718
|
|
|
153
|
-
|
|
719
|
+
// Whether a run left the vault unfinished: a failed git init or scaffold is
|
|
720
|
+
// exit 1 with `ok: false`, though everything before it was written.
|
|
721
|
+
export function bindFailed(done) {
|
|
722
|
+
return Boolean(done && ((done.git && done.git.failed) || (done.scaffold && done.scaffold.failed)));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// A line of prose, wrapped on a live terminal with a hanging indent of two
|
|
726
|
+
// (presentation spec contract 5); `lead` is what the first line starts
|
|
727
|
+
// with. A path or a command inside it is one word and never broken.
|
|
728
|
+
const prose = (c, text, lead = "") => lead + wrap(text, c.live ? c.width : 0, lead + " ");
|
|
729
|
+
|
|
730
|
+
// The bind Next lines, one decision: the scaffold plan has a `create` row.
|
|
731
|
+
// Both forms — the session's and the bin's — because the bind interview
|
|
732
|
+
// relays this to a person who runs slash commands, and a git-marketplace
|
|
733
|
+
// install has no bin on PATH: the terminal's form is the core through npx
|
|
734
|
+
// (harness.mjs packageCommand), through a shell too — what a person runs next
|
|
735
|
+
// names no shell (the story "User-facing text says where to run and names no
|
|
736
|
+
// project path and no shell", rule 6). `--write`: at a terminal that form
|
|
737
|
+
// shows the plan and asks anyway. Each command stands on its own line, after
|
|
738
|
+
// a blank one (presentation spec contract 6), so a copy takes exactly the
|
|
739
|
+
// command.
|
|
740
|
+
function nextLines(p, env, c) {
|
|
741
|
+
return p.scaffold && p.scaffold.ok && p.scaffold.creates > 0
|
|
742
|
+
? [
|
|
743
|
+
"",
|
|
744
|
+
prose(c, "Next: create the layout's missing folders and READMEs, in a session or from a terminal:"),
|
|
745
|
+
...commandBlock(c, [commandForm("scaffold", { env }), packageCommand([], "scaffold", { args: "--write" })]),
|
|
746
|
+
]
|
|
747
|
+
: ["", prose(c, "Next: see where the project stands:"), ...commandBlock(c, [packageCommand([], "status")])];
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// One line per step that ran. The whole-vault path's own Next lines: the
|
|
751
|
+
// derived views are reconcile's, offered rather than run.
|
|
752
|
+
function vaultSteps(p, done, c) {
|
|
154
753
|
const lines = [];
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
754
|
+
// A step's remedy: its lead as prose, then each command after it alone on
|
|
755
|
+
// its line, after a blank one (the story "User-facing text says where to
|
|
756
|
+
// run…", rule 4). A remedy of one line is prose alone.
|
|
757
|
+
const remedy = (text) => {
|
|
758
|
+
const [lead, ...commands] = String(text).split("\n");
|
|
759
|
+
return [prose(c, lead, " "), ...(commands.length ? commandBlock(c, commands, { indent: 4 }) : [])];
|
|
760
|
+
};
|
|
761
|
+
const g = done.git;
|
|
762
|
+
if (g && g.done) lines.push(prose(c, `Initialised a git repository in ${p.vault} (no commit).`));
|
|
763
|
+
if (g && g.inside)
|
|
764
|
+
lines.push(
|
|
765
|
+
prose(
|
|
766
|
+
c,
|
|
767
|
+
`\`${p.vault}\` is inside the git work tree at \`${g.inside}\`; the vault gets its own repository, which that repository will see as an embedded one.`,
|
|
768
|
+
),
|
|
769
|
+
);
|
|
770
|
+
if (g && g.skipped)
|
|
771
|
+
lines.push(
|
|
772
|
+
prose(
|
|
773
|
+
c,
|
|
774
|
+
`Skipped git init: ${g.skipped}. The vault works without one; doctor's vault-git finding stays until it is a repository.`,
|
|
775
|
+
),
|
|
776
|
+
);
|
|
777
|
+
if (g && g.failed) lines.push(prose(c, `git init failed: ${g.failed}`), ...remedy(g.remedy));
|
|
778
|
+
const s = done.scaffold;
|
|
779
|
+
if (s && s.failed) lines.push(prose(c, `Scaffold failed: ${s.failed}`), ...remedy(s.remedy));
|
|
780
|
+
else if (s)
|
|
781
|
+
lines.push(
|
|
782
|
+
prose(
|
|
783
|
+
c,
|
|
784
|
+
`Scaffolded the ${p.layout} layout (${p.language}): ${s.created.length} folders and READMEs created${s.exists.length ? `, ${s.exists.length} already there` : ""}.`,
|
|
785
|
+
),
|
|
786
|
+
);
|
|
787
|
+
if (!bindFailed(done))
|
|
788
|
+
lines.push(
|
|
789
|
+
"",
|
|
790
|
+
prose(c, `Next, optional: generate the derived views ${icon(c, "dash")} kanban.md, graph.md and code-map.md:`),
|
|
791
|
+
...commandBlock(c, [packageCommand([], "reconcile", { args: "--write" })]),
|
|
792
|
+
);
|
|
793
|
+
return lines;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// bind's and init's screen: what was written, the binding as key–value lines,
|
|
797
|
+
// then what to run next (presentation spec contracts 5 and 6). Its prose
|
|
798
|
+
// wraps on a live terminal. A refusal prints its message as it is. `caps` is
|
|
799
|
+
// the stream's (term.mjs); the default is plain text.
|
|
800
|
+
export function renderBindPlan(p, done = null, { env = process.env, caps: c = PLAIN } = {}) {
|
|
801
|
+
const lines = [];
|
|
802
|
+
if (!p.ok) {
|
|
803
|
+
for (const r of p.refusals) lines.push(r.message);
|
|
158
804
|
return lines.join("\n") + "\n";
|
|
159
805
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (
|
|
806
|
+
const ignoredNote = p.ignored.length
|
|
807
|
+
? ` ${icon(c, "dash")} --${p.ignored.join(" and --")} ignored: a change of ${p.ignored.join("/")} is not a rebind (edit the config, or rebind to another vault)`
|
|
808
|
+
: "";
|
|
809
|
+
if (p.state === "same" && !(done && p.buildsVault)) {
|
|
810
|
+
lines.push(prose(c, `Already bound to ${p.vault}${ignoredNote}.`), ...nextLines(p, env, c));
|
|
811
|
+
return lines.join("\n") + "\n";
|
|
812
|
+
}
|
|
813
|
+
if (done && done.created_vault) lines.push(prose(c, `Created ${p.vault}`));
|
|
814
|
+
if (p.state === "same")
|
|
815
|
+
lines.push(
|
|
816
|
+
prose(c, `Already bound to ${p.vault}${ignoredNote}; its directory was missing, so init made the vault again.`),
|
|
817
|
+
);
|
|
818
|
+
else {
|
|
819
|
+
lines.push(
|
|
820
|
+
prose(
|
|
821
|
+
c,
|
|
822
|
+
`Wrote ${p.configPath}${p.state === "different" ? ` (rebind from ${p.before.vault_path}; kept: ${p.keptKeys.join(", ") || "nothing else"})` : ""}`,
|
|
823
|
+
),
|
|
824
|
+
);
|
|
825
|
+
lines.push(
|
|
826
|
+
...kv(c, [
|
|
827
|
+
["vault_path", p.vault],
|
|
828
|
+
["layout", p.layout],
|
|
829
|
+
["language", p.language],
|
|
830
|
+
]),
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
if (done && p.buildsVault) lines.push(...vaultSteps(p, done, c));
|
|
834
|
+
else if (done) lines.push(...nextLines(p, env, c));
|
|
164
835
|
return lines.join("\n") + "\n";
|
|
165
836
|
}
|