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
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
// projectstore — setup.mjs: the `setup` verb, the one documented way in (the
|
|
2
|
+
// story "projectstore setup asks, shows one plan and installs every harness it
|
|
3
|
+
// finds, with the vault", PS-HARNESS; the front-door ADR's decisions 1, 2, 6
|
|
4
|
+
// and 7).
|
|
5
|
+
//
|
|
6
|
+
// It composes; it re-implements nothing. In rule 3's order:
|
|
7
|
+
// 1. contract 19's refusal of a project that does not exist;
|
|
8
|
+
// 2. the git top (binding.mjs gitTop) and whether the current folder is its
|
|
9
|
+
// own project (rule 4's names, from the manifests and LAYOUT);
|
|
10
|
+
// 3. question 1, the project — or --project, or the current folder;
|
|
11
|
+
// 4. the facts, read for the answered project: detection with the state
|
|
12
|
+
// directories, each host's presence, the binding, the copies of ours
|
|
13
|
+
// from another marketplace and the versions they run, the folder beside
|
|
14
|
+
// the project, a worktree's main checkout, the status-line slot;
|
|
15
|
+
// 5. one pure decide* per question, answered on the question rail
|
|
16
|
+
// (term.mjs openRail) at a terminal, or by the flags without one — and
|
|
17
|
+
// the refusals of those flags;
|
|
18
|
+
// 6. the vault plan: binding.mjs planBind with `init`, or planInherit for a
|
|
19
|
+
// worktree — pure, its refusals stop the run here;
|
|
20
|
+
// 7. runVerb("setup", …), the installer's own run: the registry read, the
|
|
21
|
+
// stop, the keep's read, the fetch, plan(), PLAN, the one question,
|
|
22
|
+
// apply() and DONE — with setup's DONE lines, built here.
|
|
23
|
+
//
|
|
24
|
+
// The rail closes (`└`) before any network work, in a `finally`, and what was
|
|
25
|
+
// typed ahead is drained before the line-mode question that follows it.
|
|
26
|
+
// Nothing is written before that question: before it this reads files, spawns
|
|
27
|
+
// `git rev-parse` (and, in a linked worktree, worktree.mjs's own git reads),
|
|
28
|
+
// and may read a kept copy's own plan, read the registry and fetch a shell
|
|
29
|
+
// into the user-level scratch — exactly what install does.
|
|
30
|
+
//
|
|
31
|
+
// Loaded lazily by cli.mjs, as the install family is, so the MCP server's
|
|
32
|
+
// module graph gains nothing. Every home, environment, spawn and stream is a
|
|
33
|
+
// parameter. No harness id is spelled here: harnesses are found by their
|
|
34
|
+
// manifests' surfaces, and every command a harness runs comes from
|
|
35
|
+
// invocation(). Pure node, no external deps.
|
|
36
|
+
|
|
37
|
+
import { existsSync, statSync, readdirSync, accessSync, realpathSync, constants } from "node:fs";
|
|
38
|
+
import { join, dirname, basename, resolve } from "node:path";
|
|
39
|
+
import { homedir } from "node:os";
|
|
40
|
+
import { spawnSync } from "node:child_process";
|
|
41
|
+
import {
|
|
42
|
+
loadHarness,
|
|
43
|
+
loadHarnesses,
|
|
44
|
+
harnessIds,
|
|
45
|
+
detectHarnesses,
|
|
46
|
+
hostPresence,
|
|
47
|
+
agentsBlockRow,
|
|
48
|
+
insideHostSession,
|
|
49
|
+
registrationDeferred,
|
|
50
|
+
invocation,
|
|
51
|
+
projectRootDeclared,
|
|
52
|
+
harnessNameLines,
|
|
53
|
+
LAYOUT,
|
|
54
|
+
} from "./harness.mjs";
|
|
55
|
+
import { readConfigAt, projectDirRefusal, selectCopy, channelKeep, cmpPrecedence } from "./lib.mjs";
|
|
56
|
+
import {
|
|
57
|
+
planBind,
|
|
58
|
+
planInherit,
|
|
59
|
+
normaliseVaultPath,
|
|
60
|
+
gitTop,
|
|
61
|
+
shellQuote,
|
|
62
|
+
bindResult,
|
|
63
|
+
layoutNames,
|
|
64
|
+
languageNames,
|
|
65
|
+
} from "./binding.mjs";
|
|
66
|
+
import { analyseRegistration, analyseJsonEntry } from "./surfaces.mjs";
|
|
67
|
+
import { runningCopies, coreCommand, channelChoices, pinFor } from "./convergence.mjs";
|
|
68
|
+
import { runVerb, isInteractive, gateText, channelText, publicItem, planOptions } from "./install-harness.mjs";
|
|
69
|
+
import { resolveBinding } from "./worktree.mjs";
|
|
70
|
+
import * as term from "./term.mjs";
|
|
71
|
+
|
|
72
|
+
// The questions' titles (rules 4–9), each the answered line's too.
|
|
73
|
+
export const TITLES = Object.freeze({
|
|
74
|
+
project: "Which folder is the project?",
|
|
75
|
+
harnesses: "Install projectstore for",
|
|
76
|
+
channel: "Channel",
|
|
77
|
+
vault: "Where should this project's notes live?",
|
|
78
|
+
path: "Which folder holds the vault?",
|
|
79
|
+
statusline: (display) => `Show projectstore in ${display}'s status line?`,
|
|
80
|
+
});
|
|
81
|
+
// The channel question's text, the ADR's (rule 6), as the note under its title.
|
|
82
|
+
export const CHANNEL_TEXT =
|
|
83
|
+
"This project loads projectstore from the git marketplace (updated by /plugin). Switch this project to the npm install, or keep the git marketplace?";
|
|
84
|
+
export const CHANNEL_ANSWERS = Object.freeze({ npm: "Switch to npm", git: "Keep the git marketplace" });
|
|
85
|
+
const UNRECORDED = "nothing is recorded: this project has no binding; setup asks again once it is bound";
|
|
86
|
+
|
|
87
|
+
const real = (p) => {
|
|
88
|
+
try {
|
|
89
|
+
return realpathSync(p);
|
|
90
|
+
} catch {
|
|
91
|
+
return resolve(p);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const tilde = (p, home) =>
|
|
95
|
+
home && (p === home || String(p).startsWith(home + "/")) ? "~" + String(p).slice(home.length) : String(p);
|
|
96
|
+
const writableDir = (dir) => {
|
|
97
|
+
try {
|
|
98
|
+
accessSync(dir, constants.W_OK);
|
|
99
|
+
return statSync(dir).isDirectory();
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const surfaceOf = (m, test) =>
|
|
105
|
+
Object.entries(m?.surfaces || {}).find(([k, s]) => !k.startsWith("_") && s && test(s)) || null;
|
|
106
|
+
const registrationOf = (m) => surfaceOf(m, (s) => s.kind === "registration");
|
|
107
|
+
// The harness whose project can switch channels: the one with a host plugin
|
|
108
|
+
// registration (cli.mjs's switchHarness reads it the same way).
|
|
109
|
+
const hostRegistrationOf = (m) => surfaceOf(m, (s) => s.format === "host-plugin-registration");
|
|
110
|
+
// The harness with a status-line slot of ours (install-harness.mjs's flagHarness).
|
|
111
|
+
const statuslineOf = (m) => {
|
|
112
|
+
const s = m?.surfaces?.statusline;
|
|
113
|
+
return s && s.supported !== false && s.format === "json-entry" ? s : null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ─── Rule 4: which folder is the project ───────────────────────────────
|
|
117
|
+
|
|
118
|
+
// The names that make a folder its own project (rule 4): every manifest's
|
|
119
|
+
// agents-block files and harness directory, and our own directory — derived,
|
|
120
|
+
// never a literal list.
|
|
121
|
+
export function ownProjectNames(manifests = [...loadHarnesses().values()]) {
|
|
122
|
+
const names = new Set();
|
|
123
|
+
for (const m of manifests) {
|
|
124
|
+
for (const f of agentsBlockRow(m)?.[1].files || []) names.add(f);
|
|
125
|
+
if (m.runtime?.harness_dir) names.add(m.runtime.harness_dir);
|
|
126
|
+
}
|
|
127
|
+
names.add(LAYOUT.root);
|
|
128
|
+
return [...names];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Question 1 (rule 4). Not asked with a project given, outside a work tree or
|
|
132
|
+
// at its top; asked in a subfolder of one, at a terminal, its default the top
|
|
133
|
+
// — unless the folder holds one of the names above, then itself.
|
|
134
|
+
export function decideProject({ cwd, top, given = false, own = false, home = null }) {
|
|
135
|
+
if (given || !top || real(cwd) === real(top)) return { asked: false, project: cwd, top: given ? null : top };
|
|
136
|
+
const choices = [
|
|
137
|
+
{ value: top, label: tilde(top, home), reason: "the top of this git work tree" },
|
|
138
|
+
{ value: cwd, label: tilde(cwd, home), reason: "the current folder" },
|
|
139
|
+
];
|
|
140
|
+
if (own) choices.reverse();
|
|
141
|
+
return { asked: true, project: cwd, top, choices, default: choices[0].value };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Rule 5: the checklist ──────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
// Who is listed (rule 5): a harness whose registration is not conditioned on
|
|
147
|
+
// a distribution root (the core is its plugin root), or whose manifest names
|
|
148
|
+
// its published shell.
|
|
149
|
+
export function listedHarnesses(manifests = [...loadHarnesses().values()]) {
|
|
150
|
+
return manifests.filter((m) => {
|
|
151
|
+
const r = registrationOf(m);
|
|
152
|
+
return !r || r[1].condition !== "distribution_root" || Boolean(m.install?.shell);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Each listed harness's row (rule 5's table, top to bottom): pre-ticked when
|
|
157
|
+
// used in this project — by its directory or our state directory — or, when
|
|
158
|
+
// none is, when its host's CLI is on PATH and its home exists.
|
|
159
|
+
export function harnessRows({ listed, detected, presence, home = null }) {
|
|
160
|
+
const used = new Map(detected.map((d) => [d.id, d]));
|
|
161
|
+
const anyUsed = listed.some((m) => used.has(m.id));
|
|
162
|
+
return listed.map((m) => {
|
|
163
|
+
const d = used.get(m.id);
|
|
164
|
+
const p = presence.get(m.id) || {};
|
|
165
|
+
const at = p.homeDir ? tilde(p.homeDir, home) : null;
|
|
166
|
+
const row = (ticked, why) => ({
|
|
167
|
+
id: m.id,
|
|
168
|
+
display: m.display_name,
|
|
169
|
+
ticked,
|
|
170
|
+
why,
|
|
171
|
+
cli: p.cli || null,
|
|
172
|
+
bin: p.bin || null,
|
|
173
|
+
});
|
|
174
|
+
if (d) return row(true, `used in this project (${d.evidence}/)`);
|
|
175
|
+
if (p.cli && anyUsed) return row(false, `not used in this project yet; ${p.bin} is on PATH`);
|
|
176
|
+
if (p.cli && p.home) return row(true, `${p.bin} is on PATH`);
|
|
177
|
+
if (p.cli) return row(false, `${p.bin} is on PATH but has not run on this machine yet (no ${at})`);
|
|
178
|
+
if (p.home && p.bin)
|
|
179
|
+
return row(false, `${at} exists, but ${p.bin} is not on PATH — install ${m.display_name} first`);
|
|
180
|
+
return row(false, "not found on this machine");
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Rule 6: the channel ────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
// The value plan() receives, or the question, or the refusal (rule 6's
|
|
187
|
+
// table). `selected`: the copy contract 12's selection keeps, from another
|
|
188
|
+
// marketplace, with the version it runs; `kept`: the binding's channel_keep;
|
|
189
|
+
// `sessionCopy`: the copy the selection finds at all, for "inside a session
|
|
190
|
+
// running the git-marketplace copy".
|
|
191
|
+
export function decideChannel({
|
|
192
|
+
inSet,
|
|
193
|
+
others = [],
|
|
194
|
+
flag = null,
|
|
195
|
+
interactive = false,
|
|
196
|
+
running = null,
|
|
197
|
+
selected = null,
|
|
198
|
+
kept = null,
|
|
199
|
+
inSession = false,
|
|
200
|
+
sessionCopy = null,
|
|
201
|
+
}) {
|
|
202
|
+
if (flag) return { asked: false, value: flag, why: "flag" };
|
|
203
|
+
if (!inSet || !others.length) return { asked: false, value: null, why: "none" };
|
|
204
|
+
if (interactive) {
|
|
205
|
+
const newer = Boolean(selected?.version && running && cmpPrecedence(selected.version, running) > 0);
|
|
206
|
+
const keepReason = kept
|
|
207
|
+
? `kept on ${String(kept.at).slice(0, 10)}`
|
|
208
|
+
: "/plugin keeps updating it; setup refreshes this project's files through that copy";
|
|
209
|
+
const switchReason = newer
|
|
210
|
+
? `switching runs ${running} in place of ${selected.version}; if ${selected.version} is published, setup stops after these questions and names it`
|
|
211
|
+
: "setup and upgrade update it from now on; /plugin update no longer reaches this project";
|
|
212
|
+
return {
|
|
213
|
+
asked: true,
|
|
214
|
+
default: newer || kept ? "git" : "npm",
|
|
215
|
+
newer,
|
|
216
|
+
choices: [
|
|
217
|
+
{ value: "npm", label: CHANNEL_ANSWERS.npm, reason: switchReason },
|
|
218
|
+
{ value: "git", label: CHANNEL_ANSWERS.git, reason: keepReason },
|
|
219
|
+
],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (inSession && sessionCopy?.channel === "marketplace") return { asked: false, value: "git", why: "session" };
|
|
223
|
+
if (kept) return { asked: false, value: "git", why: "recorded" };
|
|
224
|
+
return { asked: false, value: null, refuse: true };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Rule 7: the vault ──────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
// The folder beside the project, `<name>-vault`: missing, empty, full or a
|
|
230
|
+
// file, and whether it — or its parent, when it is missing — can be written.
|
|
231
|
+
export function siblingOf(project) {
|
|
232
|
+
const path = join(dirname(project), `${basename(project)}-vault`);
|
|
233
|
+
let state = "missing";
|
|
234
|
+
try {
|
|
235
|
+
const st = statSync(path);
|
|
236
|
+
state = !st.isDirectory() ? "file" : readdirSync(path).length ? "full" : "empty";
|
|
237
|
+
} catch {}
|
|
238
|
+
return {
|
|
239
|
+
path,
|
|
240
|
+
state,
|
|
241
|
+
writable: state === "missing" ? writableDir(dirname(path)) : state === "empty" ? writableDir(path) : null,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The vault question's choices (rule 7's table, read top to bottom): the
|
|
246
|
+
// first from the worktree's main checkout, else from the folder beside the
|
|
247
|
+
// project; then "Another folder…" (unless it is the first) and "Not now".
|
|
248
|
+
export function vaultChoices({ sibling, worktree = null, layout, home = null }) {
|
|
249
|
+
const other = {
|
|
250
|
+
value: "other",
|
|
251
|
+
label: "Another folder…",
|
|
252
|
+
reason: "a missing or empty one is created; one with files is bound",
|
|
253
|
+
};
|
|
254
|
+
const first = worktree
|
|
255
|
+
? {
|
|
256
|
+
value: "inherit",
|
|
257
|
+
label: `Use ${tilde(worktree.vault, home)}`,
|
|
258
|
+
reason: `the vault of ${tilde(worktree.main, home)}, with its settings`,
|
|
259
|
+
}
|
|
260
|
+
: ["missing", "empty"].includes(sibling.state) && sibling.writable
|
|
261
|
+
? {
|
|
262
|
+
value: "create",
|
|
263
|
+
label: `Create ${tilde(sibling.path, home)}`,
|
|
264
|
+
reason: `a new vault beside the project: its own git repository, with the ${layout} folders`,
|
|
265
|
+
}
|
|
266
|
+
: sibling.state === "full"
|
|
267
|
+
? { value: "use", label: `Use ${tilde(sibling.path, home)}`, reason: "it already exists; it is bound as it is" }
|
|
268
|
+
: {
|
|
269
|
+
...other,
|
|
270
|
+
reason:
|
|
271
|
+
sibling.state === "file"
|
|
272
|
+
? `${tilde(sibling.path, home)} is a file`
|
|
273
|
+
: `${tilde(dirname(sibling.path), home)} is not writable`,
|
|
274
|
+
};
|
|
275
|
+
return [
|
|
276
|
+
first,
|
|
277
|
+
...(first.value === "other" ? [] : [other]),
|
|
278
|
+
{ value: "none", label: "Not now", reason: "installed unbound" },
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Why a typed vault path is refused, inline (rule 7), or null: a ~user path,
|
|
283
|
+
// a file, a missing folder whose parent is not a writable folder. Relative to
|
|
284
|
+
// the project, `~` expanded, as bind and init read it.
|
|
285
|
+
export function vaultPathRefusal(typed, project, home) {
|
|
286
|
+
const n = normaliseVaultPath(typed, project, home);
|
|
287
|
+
if (!n) return "type a path";
|
|
288
|
+
if (typeof n !== "string") return n.error;
|
|
289
|
+
try {
|
|
290
|
+
if (!statSync(n).isDirectory()) return `${tilde(n, home)} is a file, not a folder`;
|
|
291
|
+
return null;
|
|
292
|
+
} catch {}
|
|
293
|
+
return writableDir(dirname(n)) ? null : `${tilde(dirname(n), home)} is not a writable folder`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─── Rule 9: the status line ────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
// Asked when the harness with a status-line slot is in the set, the run
|
|
299
|
+
// leaves a binding that does not say yet, the slot is not someone else's and
|
|
300
|
+
// the channel is not the keep — under which DONE names the copy's own command.
|
|
301
|
+
export function decideStatusline({ inSet, leaves, stored, slot, channel }) {
|
|
302
|
+
if (!inSet || !leaves || typeof stored === "boolean" || slot === "theirs") return { asked: false };
|
|
303
|
+
if (channel === "git") return { asked: false, underKeep: true };
|
|
304
|
+
return { asked: true };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── The run ───────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
// What the run knows of the project it answered for (rule 3, step 4).
|
|
310
|
+
function factsOf(project, { env, home, root, version, interactive }) {
|
|
311
|
+
const manifests = [...loadHarnesses().values()];
|
|
312
|
+
const listed = listedHarnesses(manifests);
|
|
313
|
+
const detected = detectHarnesses(project, { state: true });
|
|
314
|
+
const presence = new Map(manifests.map((m) => [m.id, hostPresence(m, { env, home })]));
|
|
315
|
+
const binding = readConfigAt(project);
|
|
316
|
+
const channelHarness = manifests.find((m) => hostRegistrationOf(m)) || null;
|
|
317
|
+
let others = [],
|
|
318
|
+
selected = null,
|
|
319
|
+
sessionCopy = null;
|
|
320
|
+
if (channelHarness) {
|
|
321
|
+
const [, s] = hostRegistrationOf(channelHarness);
|
|
322
|
+
others = analyseRegistration(project, s, { root, home, harness: channelHarness, env }).others;
|
|
323
|
+
const pick = selectCopy(home, project, { harness: channelHarness, only: "marketplace" });
|
|
324
|
+
// The version it runs, as the convergence story reads it (runningCopies,
|
|
325
|
+
// under no channel answer: local and read-only).
|
|
326
|
+
const copies = runningCopies(project, { harnesses: [channelHarness.id], root, home, env, channel: null, version });
|
|
327
|
+
selected = pick ? { ...pick, version: copies.find((c) => c.key === pick.key)?.version ?? pick.version } : null;
|
|
328
|
+
sessionCopy = selectCopy(home, project, { harness: channelHarness });
|
|
329
|
+
}
|
|
330
|
+
const slotHarness = manifests.find((m) => statuslineOf(m)) || null;
|
|
331
|
+
const slot = slotHarness ? analyseJsonEntry(project, statuslineOf(slotHarness), { root, home }).state : null;
|
|
332
|
+
// A worktree's main checkout (worktree.mjs): only where it can be offered —
|
|
333
|
+
// at a terminal, unbound, in a linked worktree. Read for the project
|
|
334
|
+
// answered, never from the git top, which --project leaves unread.
|
|
335
|
+
let worktree = null;
|
|
336
|
+
if (interactive && !binding && inLinkedWorktree(project)) {
|
|
337
|
+
const w = resolveBinding(project);
|
|
338
|
+
if (w.state === "inheritable" && w.mainCheckout) {
|
|
339
|
+
const n = normaliseVaultPath(w.vaultPath, w.mainCheckout, home);
|
|
340
|
+
if (typeof n === "string") worktree = { main: w.mainCheckout, vault: n };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
manifests,
|
|
345
|
+
listed,
|
|
346
|
+
detected,
|
|
347
|
+
presence,
|
|
348
|
+
binding,
|
|
349
|
+
channelHarness,
|
|
350
|
+
others,
|
|
351
|
+
selected,
|
|
352
|
+
sessionCopy,
|
|
353
|
+
slotHarness,
|
|
354
|
+
slot,
|
|
355
|
+
sibling: siblingOf(project),
|
|
356
|
+
worktree,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Whether `dir` may sit in a linked git worktree, from the files alone: the
|
|
361
|
+
// nearest `.git` at or above it is a file (a gitdir pointer), not a folder.
|
|
362
|
+
// Only then does worktree.mjs spend its git reads to confirm it and find the
|
|
363
|
+
// main checkout, so an unbound run anywhere else spawns nothing more.
|
|
364
|
+
function inLinkedWorktree(dir) {
|
|
365
|
+
for (let at = resolve(dir); ; at = dirname(at)) {
|
|
366
|
+
try {
|
|
367
|
+
return statSync(join(at, ".git")).isFile();
|
|
368
|
+
} catch {}
|
|
369
|
+
if (dirname(at) === at) return false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// The arguments a bare run's refusal repeats after one --harness: what named
|
|
374
|
+
// its vault, channel, layout and language — the channel only for the harness
|
|
375
|
+
// whose channel it is (`channel`), as setup refuses it in a run without that
|
|
376
|
+
// harness. Never --project (the ADR's decision 5).
|
|
377
|
+
function repeatArgs(values, vaultPath, { channel = false } = {}) {
|
|
378
|
+
return [
|
|
379
|
+
vaultPath ? `--vault ${shellQuote(vaultPath)}` : "",
|
|
380
|
+
channel && values.channel ? `--channel ${values.channel}` : "",
|
|
381
|
+
values.layout ? `--layout ${values.layout}` : "",
|
|
382
|
+
values.language ? `--language ${values.language}` : "",
|
|
383
|
+
]
|
|
384
|
+
.filter(Boolean)
|
|
385
|
+
.join(" ");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Questions 1 to 6 (rule 3, steps 3 to 5): on the rail when one is open,
|
|
389
|
+
// else from the flags and the rules for a run without a terminal. Writes
|
|
390
|
+
// nothing. Returns the decisions, or { cancelled } at a Ctrl+C or the end of
|
|
391
|
+
// input, or { early } — a refusal or a usage error — before any plan.
|
|
392
|
+
async function questions(rail, s) {
|
|
393
|
+
const {
|
|
394
|
+
start,
|
|
395
|
+
top,
|
|
396
|
+
given,
|
|
397
|
+
own,
|
|
398
|
+
values,
|
|
399
|
+
named,
|
|
400
|
+
flagChannel,
|
|
401
|
+
layout,
|
|
402
|
+
env,
|
|
403
|
+
home,
|
|
404
|
+
root,
|
|
405
|
+
version,
|
|
406
|
+
c,
|
|
407
|
+
refused,
|
|
408
|
+
usage,
|
|
409
|
+
summary,
|
|
410
|
+
} = s;
|
|
411
|
+
const glyphs = (t) => term.glyphText(c, t);
|
|
412
|
+
const cancelled = (a) => a === term.CANCELLED;
|
|
413
|
+
const CANCEL = { cancelled: true };
|
|
414
|
+
|
|
415
|
+
// Question 1, the project (rule 4).
|
|
416
|
+
const q1 = decideProject({ cwd: start, top, given, own, home });
|
|
417
|
+
let project = q1.project;
|
|
418
|
+
if (rail && q1.asked) {
|
|
419
|
+
const a = await rail.select(
|
|
420
|
+
TITLES.project,
|
|
421
|
+
q1.choices.map((x) => ({ ...x, reason: glyphs(x.reason) })),
|
|
422
|
+
{ default: q1.default },
|
|
423
|
+
);
|
|
424
|
+
if (cancelled(a)) return CANCEL;
|
|
425
|
+
project = a;
|
|
426
|
+
} else if (rail) rail.answered(TITLES.project, tilde(project, home));
|
|
427
|
+
summary.project = project;
|
|
428
|
+
// Without a terminal, in a subfolder of a work tree: PLAN's header names the top.
|
|
429
|
+
const header =
|
|
430
|
+
!rail && q1.asked
|
|
431
|
+
? [glyphs(`inside the git work tree ${tilde(top, home)} — this run sets up this folder only`)]
|
|
432
|
+
: [];
|
|
433
|
+
|
|
434
|
+
// The facts, for the project answered (rule 3, step 4).
|
|
435
|
+
const runEnv = s.ownEnv ? s.ownEnv(project) : env;
|
|
436
|
+
const f = factsOf(project, { env: runEnv, home, root, version, interactive: Boolean(rail) });
|
|
437
|
+
const rows = harnessRows({ listed: f.listed, detected: f.detected, presence: f.presence, home });
|
|
438
|
+
summary.harnesses = rows.map(({ id, ticked, why }) => ({ id, ticked, why }));
|
|
439
|
+
|
|
440
|
+
// Question 2, the harnesses (rule 5), or --harness.
|
|
441
|
+
let set;
|
|
442
|
+
if (named.length) {
|
|
443
|
+
set = named;
|
|
444
|
+
if (rail) rail.answered(TITLES.harnesses, set.map((id) => loadHarness(id).display_name).join(", "));
|
|
445
|
+
} else if (rail) {
|
|
446
|
+
const a = await rail.multi(
|
|
447
|
+
TITLES.harnesses,
|
|
448
|
+
rows.map((r) => ({ value: r.id, label: r.display, reason: glyphs(r.why), on: r.ticked })),
|
|
449
|
+
);
|
|
450
|
+
if (cancelled(a)) return CANCEL;
|
|
451
|
+
set = a;
|
|
452
|
+
} else {
|
|
453
|
+
set = rows.filter((r) => r.ticked).map((r) => r.id);
|
|
454
|
+
// Contract 8's refusal, with rule 5's reasons: install's rows (harnessNameLines).
|
|
455
|
+
if (!set.length)
|
|
456
|
+
return {
|
|
457
|
+
early: refused(
|
|
458
|
+
project,
|
|
459
|
+
[
|
|
460
|
+
`No harness is used in ${project} or ready on this machine, and none named. Name one:`,
|
|
461
|
+
...harnessNameLines(rows),
|
|
462
|
+
].join("\n"),
|
|
463
|
+
),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
summary.harnesses = rows.map(({ id, why }) => ({ id, ticked: set.includes(id), why }));
|
|
467
|
+
|
|
468
|
+
// Question 3, the channel (rule 6), or --channel, or rule 6's table.
|
|
469
|
+
const ch = f.channelHarness;
|
|
470
|
+
const chInSet = Boolean(ch && set.includes(ch.id));
|
|
471
|
+
if (flagChannel && !chInSet)
|
|
472
|
+
return {
|
|
473
|
+
early: usage(
|
|
474
|
+
`--channel chooses ${ch ? ch.display_name : "the host plugin"}'s channel; this run does not install ${ch ? ch.display_name : "it"}`,
|
|
475
|
+
),
|
|
476
|
+
};
|
|
477
|
+
const dc = decideChannel({
|
|
478
|
+
inSet: chInSet,
|
|
479
|
+
others: f.others,
|
|
480
|
+
flag: flagChannel,
|
|
481
|
+
interactive: Boolean(rail),
|
|
482
|
+
running: version,
|
|
483
|
+
selected: f.selected,
|
|
484
|
+
kept: channelKeep(f.binding),
|
|
485
|
+
inSession: ch ? insideHostSession(runEnv, ch) : false,
|
|
486
|
+
sessionCopy: f.sessionCopy,
|
|
487
|
+
});
|
|
488
|
+
if (dc.refuse) {
|
|
489
|
+
const blocks = channelChoices({
|
|
490
|
+
gate: { args: { harnesses: named }, keys: f.others.map((o) => o.key), version: pinFor(root, version) },
|
|
491
|
+
});
|
|
492
|
+
return {
|
|
493
|
+
early: refused(
|
|
494
|
+
project,
|
|
495
|
+
blocks
|
|
496
|
+
.flatMap((b) => [b.lead, b.command])
|
|
497
|
+
.filter(Boolean)
|
|
498
|
+
.join("\n"),
|
|
499
|
+
channelText(blocks, c) + "\n",
|
|
500
|
+
),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
let channel = dc.value ?? null;
|
|
504
|
+
if (rail && dc.asked) {
|
|
505
|
+
const a = await rail.select(
|
|
506
|
+
TITLES.channel,
|
|
507
|
+
dc.choices.map((x) => ({ ...x, reason: glyphs(x.reason) })),
|
|
508
|
+
{ default: dc.default, note: CHANNEL_TEXT },
|
|
509
|
+
);
|
|
510
|
+
if (cancelled(a)) return CANCEL;
|
|
511
|
+
channel = a;
|
|
512
|
+
} else if (rail && dc.why === "flag") rail.answered(TITLES.channel, CHANNEL_ANSWERS[channel]);
|
|
513
|
+
summary.channel = channel;
|
|
514
|
+
|
|
515
|
+
// Question 4, the vault (rule 7), or --vault.
|
|
516
|
+
let vault = null; // { kind: "inherit", from } | { kind: "path", path, scaffold }
|
|
517
|
+
if (f.binding) {
|
|
518
|
+
const n = normaliseVaultPath(f.binding.vault_path, project, home);
|
|
519
|
+
const boundPath = typeof n === "string" ? n : String(f.binding.vault_path);
|
|
520
|
+
const want = values.vault ? normaliseVaultPath(values.vault, project, home) : null;
|
|
521
|
+
// A --vault that names no folder (a ~user path) is refused as an unbound
|
|
522
|
+
// run refuses it: it is never compared with the bound vault, nor dropped.
|
|
523
|
+
if (want && typeof want !== "string") return { early: refused(project, want.error) };
|
|
524
|
+
if (typeof want === "string" && real(want) !== real(boundPath)) {
|
|
525
|
+
const command = coreCommand("bind", { args: `${shellQuote(want)} --rebind` });
|
|
526
|
+
const why = `this project is bound to ${boundPath}; setup never rebinds`;
|
|
527
|
+
return {
|
|
528
|
+
early: refused(
|
|
529
|
+
project,
|
|
530
|
+
`${why}\n${command}`,
|
|
531
|
+
[
|
|
532
|
+
`${why}. To point it at ${want}, from a terminal in this project:`,
|
|
533
|
+
...term.commandBlock(c, [command], { indent: 4 }),
|
|
534
|
+
].join("\n") + "\n",
|
|
535
|
+
),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
const ignored = [
|
|
539
|
+
values.layout && values.layout !== (f.binding.layout || "engineering") ? "--layout" : null,
|
|
540
|
+
values.language && values.language !== (f.binding.language || "en") ? "--language" : null,
|
|
541
|
+
].filter(Boolean);
|
|
542
|
+
if (rail)
|
|
543
|
+
rail.answered(
|
|
544
|
+
TITLES.vault,
|
|
545
|
+
[
|
|
546
|
+
tilde(boundPath, home),
|
|
547
|
+
f.binding.layout || "engineering",
|
|
548
|
+
f.binding.language || "en",
|
|
549
|
+
...(ignored.length ? [`${ignored.join(" and ")} ignored: a bound project keeps its own`] : []),
|
|
550
|
+
].join(` ${term.icon(c, "dot")} `),
|
|
551
|
+
);
|
|
552
|
+
} else if (values.vault) {
|
|
553
|
+
const n = normaliseVaultPath(values.vault, project, home);
|
|
554
|
+
const why = vaultPathRefusal(values.vault, project, home);
|
|
555
|
+
if (why) return { early: refused(project, why) };
|
|
556
|
+
vault = { kind: "path", path: n };
|
|
557
|
+
if (rail) rail.answered(TITLES.vault, tilde(n, home));
|
|
558
|
+
} else if (rail) {
|
|
559
|
+
const choices = vaultChoices({ sibling: f.sibling, worktree: f.worktree, layout, home }).map((x) => ({
|
|
560
|
+
...x,
|
|
561
|
+
label: glyphs(x.label),
|
|
562
|
+
reason: glyphs(x.reason),
|
|
563
|
+
}));
|
|
564
|
+
for (;;) {
|
|
565
|
+
const a = await rail.select(TITLES.vault, choices, { default: choices[0].value });
|
|
566
|
+
if (cancelled(a)) return CANCEL;
|
|
567
|
+
if (a === "none") break;
|
|
568
|
+
if (a === "inherit") {
|
|
569
|
+
vault = { kind: "inherit", from: f.worktree.main };
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
if (a === "create" || a === "use") {
|
|
573
|
+
vault = { kind: "path", path: f.sibling.path };
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
// Another folder…: a path, or Esc back to the choice (the rail erased it).
|
|
577
|
+
const typed = await rail.path(TITLES.path, {
|
|
578
|
+
default: tilde(f.sibling.path, home),
|
|
579
|
+
check: (x) => vaultPathRefusal(x, project, home),
|
|
580
|
+
});
|
|
581
|
+
if (cancelled(typed)) return CANCEL;
|
|
582
|
+
if (typed === term.BACK) continue;
|
|
583
|
+
vault = { kind: "path", path: normaliseVaultPath(typed, project, home) };
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// An existing vault whose layout folders are missing: one more confirm at a
|
|
588
|
+
// terminal (rule 7). Without one it is bound only, and DONE says how.
|
|
589
|
+
if (vault?.kind === "path") {
|
|
590
|
+
const probe = planBind(project, {
|
|
591
|
+
vault: vault.path,
|
|
592
|
+
layout: values.layout ?? null,
|
|
593
|
+
language: values.language ?? null,
|
|
594
|
+
init: true,
|
|
595
|
+
scaffoldExisting: true,
|
|
596
|
+
env: runEnv,
|
|
597
|
+
root,
|
|
598
|
+
home,
|
|
599
|
+
});
|
|
600
|
+
vault.missing = probe.ok && probe.scaffoldsExisting ? probe.scaffold.creates : 0;
|
|
601
|
+
vault.scaffold = false;
|
|
602
|
+
if (rail && vault.missing) {
|
|
603
|
+
const a = await rail.confirm(
|
|
604
|
+
glyphs(
|
|
605
|
+
`${tilde(vault.path, home)} has no ${probe.layout} layout yet. Add its ${vault.missing} folders and indexes?`,
|
|
606
|
+
),
|
|
607
|
+
{ note: "Existing files are never changed." },
|
|
608
|
+
);
|
|
609
|
+
if (cancelled(a)) return CANCEL;
|
|
610
|
+
vault.scaffold = a;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// The binding the run leaves, and whether it already says the status line.
|
|
614
|
+
const leaves = Boolean(f.binding || vault);
|
|
615
|
+
const stored = (f.binding || (vault?.kind === "inherit" ? readConfigAt(vault.from) : null))?.statusline?.enabled;
|
|
616
|
+
// A keep with no binding to record it in says so (rule 6), once the vault's
|
|
617
|
+
// answer has settled that there is none.
|
|
618
|
+
if (rail && channel === "git" && !leaves)
|
|
619
|
+
rail.answered(TITLES.channel, `${CHANNEL_ANSWERS.git} ${term.icon(c, "dot")} ${UNRECORDED}`);
|
|
620
|
+
|
|
621
|
+
// Question 6, the status line (rule 9).
|
|
622
|
+
const sh = f.slotHarness;
|
|
623
|
+
const ds = decideStatusline({ inSet: Boolean(sh && set.includes(sh.id)), leaves, stored, slot: f.slot, channel });
|
|
624
|
+
let statusline = null;
|
|
625
|
+
if (rail && ds.asked) {
|
|
626
|
+
const a = await rail.confirm(TITLES.statusline(sh.display_name), {
|
|
627
|
+
note: `your current epic and story, above any status line you already use; restart ${sh.display_name} to see it`,
|
|
628
|
+
});
|
|
629
|
+
if (cancelled(a)) return CANCEL;
|
|
630
|
+
statusline = a;
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
project,
|
|
634
|
+
header,
|
|
635
|
+
runEnv,
|
|
636
|
+
f,
|
|
637
|
+
set,
|
|
638
|
+
namedRun: Boolean(rail) || named.length > 0,
|
|
639
|
+
channel,
|
|
640
|
+
vault,
|
|
641
|
+
statusline,
|
|
642
|
+
underKeep: rail && ds.underKeep ? sh : null,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Setup's DONE lines (rules 10, 11 and 13), from the run's result: the ▲ row
|
|
647
|
+
// of a failed git init; per harness, what a missing host or a host session
|
|
648
|
+
// left to do, each with its terminal command; the status line under the
|
|
649
|
+
// keep; an existing vault left without its layout; the bind lines when the
|
|
650
|
+
// project is still unbound; and the tip. Commands in the core form, pinned to
|
|
651
|
+
// the running version, never --project (rule 11); a harness's own commands
|
|
652
|
+
// from invocation().
|
|
653
|
+
function doneLines(r, { project, runEnv, home, version, channel, vaultPlan, underKeep, verbose }) {
|
|
654
|
+
const p = r.plan;
|
|
655
|
+
const blocks = [];
|
|
656
|
+
for (const id of p.harnesses) {
|
|
657
|
+
const m = loadHarness(id);
|
|
658
|
+
if (!m) continue;
|
|
659
|
+
const pr = hostPresence(m, { env: runEnv, home });
|
|
660
|
+
// A missing host only when this run needed it (rule 5): its registration
|
|
661
|
+
// row is a skip whose reason says the host's CLI is not on PATH, as every
|
|
662
|
+
// registration planner words it. A row that needed no host (one already
|
|
663
|
+
// current) says nothing, whatever PATH holds.
|
|
664
|
+
const needed =
|
|
665
|
+
pr.bin &&
|
|
666
|
+
!pr.cli &&
|
|
667
|
+
p.items.some(
|
|
668
|
+
(i) =>
|
|
669
|
+
i.harness === id &&
|
|
670
|
+
i.kind === "registration" &&
|
|
671
|
+
i.action === "skip" &&
|
|
672
|
+
String(i.reason || "").includes(`\`${pr.bin}\` is not on PATH`),
|
|
673
|
+
);
|
|
674
|
+
if (needed) {
|
|
675
|
+
blocks.push({
|
|
676
|
+
lead: `${m.display_name} is not installed on this machine (${pr.bin} was not found on PATH). Install it, then run this from a terminal in this project:`,
|
|
677
|
+
commands: [coreCommand("setup", { version, args: `--harness ${id}` })],
|
|
678
|
+
});
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
const mine = p.items.filter((i) => i.harness === id && i.deferred);
|
|
682
|
+
const left = [];
|
|
683
|
+
if (
|
|
684
|
+
registrationDeferred(runEnv, m) &&
|
|
685
|
+
mine.some((i) => i.kind === "registration" && !String(i.surface).endsWith("_others"))
|
|
686
|
+
)
|
|
687
|
+
left.push("the plugin registration");
|
|
688
|
+
if (insideHostSession(runEnv, m) && mine.some((i) => i.kind === "channel"))
|
|
689
|
+
left.push("the kept copy's own refresh");
|
|
690
|
+
if (insideHostSession(runEnv, m) && p.items.some((i) => i.kind === "layout" && i.deferred && i.harness === id))
|
|
691
|
+
left.push("the layout move");
|
|
692
|
+
// Rule 13: `--channel npm` when that was the answer, and only for the
|
|
693
|
+
// harness whose channel it is — setup refuses --channel in a run without
|
|
694
|
+
// that harness (exit 2). A keep is not repeated: once recorded, it is the
|
|
695
|
+
// default already (rule 6).
|
|
696
|
+
const again = channel === "npm" && hostRegistrationOf(m) ? "--channel npm" : "";
|
|
697
|
+
if (left.length)
|
|
698
|
+
blocks.push({
|
|
699
|
+
lead: `Left for a terminal outside this ${m.display_name} session: ${left.join(", ")}. From a terminal in this project, run:`,
|
|
700
|
+
commands: [coreCommand("setup", { version, args: [`--harness ${id}`, again].filter(Boolean).join(" ") })],
|
|
701
|
+
after: `then restart ${m.display_name}.`,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
if (underKeep)
|
|
705
|
+
blocks.push({
|
|
706
|
+
lead: `To show projectstore in the status line, run this in ${underKeep.display_name}:`,
|
|
707
|
+
commands: [invocation(underKeep, "statusline", { args: "on" })],
|
|
708
|
+
});
|
|
709
|
+
const bp = vaultPlan;
|
|
710
|
+
if (
|
|
711
|
+
bp &&
|
|
712
|
+
bp.ok &&
|
|
713
|
+
!bp.inherit &&
|
|
714
|
+
!bp.buildsVault &&
|
|
715
|
+
!bp.scaffoldsExisting &&
|
|
716
|
+
bp.scaffold?.ok &&
|
|
717
|
+
bp.scaffold.creates > 0
|
|
718
|
+
) {
|
|
719
|
+
blocks.push({
|
|
720
|
+
lead: `${tilde(bp.vault, home)} has no ${bp.layout} layout yet. To add its missing folders and indexes, from a terminal in this project:`,
|
|
721
|
+
commands: [coreCommand("scaffold", { version, args: "--write" })],
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
if (!readConfigAt(project)) {
|
|
725
|
+
p.harnesses.forEach((id, k) => {
|
|
726
|
+
const m = loadHarness(id);
|
|
727
|
+
if (m)
|
|
728
|
+
blocks.push({
|
|
729
|
+
lead: `${k ? "" : "This project has no vault yet. "}Bind a vault, in ${m.display_name}:`,
|
|
730
|
+
commands: [invocation(m, "bind", { args: "<vault-path>" })],
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
// Doctor's fix in each run harness's own form, as the bind lines are.
|
|
735
|
+
const fixes = [
|
|
736
|
+
...new Set(
|
|
737
|
+
p.harnesses
|
|
738
|
+
.map((id) => loadHarness(id))
|
|
739
|
+
.filter(Boolean)
|
|
740
|
+
.map((m) => invocation(m, "doctor", { args: "--fix" })),
|
|
741
|
+
),
|
|
742
|
+
];
|
|
743
|
+
const warnings = (r.warnings || []).map((w) => ({
|
|
744
|
+
text: `the vault has no git repository: git init failed (${String(w.message || "").split("\n")[0]}). Run git init in it from a terminal, or doctor's fix in a session, which offers it:`,
|
|
745
|
+
commands: [`git -C ${shellQuote(w.path)} init`, ...fixes],
|
|
746
|
+
}));
|
|
747
|
+
const tip = {
|
|
748
|
+
lead: verbose
|
|
749
|
+
? "To preview without writing:"
|
|
750
|
+
: "To preview without writing (--verbose adds every row's reasoning):",
|
|
751
|
+
commands: [coreCommand("setup", { args: "--dry-run" })],
|
|
752
|
+
};
|
|
753
|
+
return { warnings, blocks, tip };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// DONE's Next lines without a DONE: a run that had nothing to write still
|
|
757
|
+
// says what a missing host or a session left, and how to bind.
|
|
758
|
+
function nextText(blocks, c) {
|
|
759
|
+
if (!blocks.length) return "";
|
|
760
|
+
const width = c.live ? c.width : 0;
|
|
761
|
+
const lines = ["", term.heading(c, "Next")];
|
|
762
|
+
blocks.forEach((b, k) => {
|
|
763
|
+
if (k) lines.push("");
|
|
764
|
+
lines.push(
|
|
765
|
+
...term
|
|
766
|
+
.wrap(b.lead, width, " ")
|
|
767
|
+
.split("\n")
|
|
768
|
+
.map((l, n) => (n ? l : ` ${l}`)),
|
|
769
|
+
);
|
|
770
|
+
if ((b.commands || []).length) lines.push(...term.commandBlock(c, b.commands, { indent: 4 }));
|
|
771
|
+
if (b.after) lines.push("", ` ${b.after}`);
|
|
772
|
+
});
|
|
773
|
+
return lines.join("\n") + "\n";
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// The `setup` verb. `ctx` is cli.mjs's run context — values, the resolved
|
|
777
|
+
// project, env, cwd, the three streams and the tests' `ask` — plus the bin's
|
|
778
|
+
// own: `root` (the package root), `ownEnv(project)` (the environment the
|
|
779
|
+
// installer runs in) and `author(project)` (a fresh binding's
|
|
780
|
+
// default_author). The tests' hooks: `home`, `proc` (the rail's process),
|
|
781
|
+
// `escapeMs` (its Esc timeout), `gitSpawn`, `drainTicks`, and runVerb's own
|
|
782
|
+
// `spawn`, `fetchSpawn`, `fetch`, `now` and `timers`. Returns { code,
|
|
783
|
+
// project, ok, result } — `result` the envelope's, which cli.mjs writes under
|
|
784
|
+
// --json — or { code: 2, error } for a usage error. Exit codes (rule 1): 0 applied, nothing to do or a clean dry
|
|
785
|
+
// run; 1 a refusal, a declined question, an incomplete plan, a failed step or
|
|
786
|
+
// a failed git init; 2 usage; 130 cancelled at a question.
|
|
787
|
+
export async function runSetup(ctx) {
|
|
788
|
+
const { values = {}, env = process.env, stdin = null, stdout = null, ask = null } = ctx;
|
|
789
|
+
const root = ctx.root;
|
|
790
|
+
const home = ctx.home || homedir();
|
|
791
|
+
// The running version, as plan reads it for a shell-rooted row (planOptions).
|
|
792
|
+
const version = planOptions({ root }).version;
|
|
793
|
+
const json = Boolean(values.json);
|
|
794
|
+
const dryRun = Boolean(values["dry-run"]);
|
|
795
|
+
const verbose = Boolean(values.verbose);
|
|
796
|
+
const named = (values.harness || []).filter(Boolean);
|
|
797
|
+
const write = (text) => {
|
|
798
|
+
if (!json && stdout && text) stdout.write(text);
|
|
799
|
+
};
|
|
800
|
+
const usage = (error) => ({ code: 2, error });
|
|
801
|
+
|
|
802
|
+
// Usage, before anything is read (exit 2).
|
|
803
|
+
const unknown = named.filter((id) => !harnessIds().includes(id));
|
|
804
|
+
if (unknown.length) return usage(`unknown harness: ${unknown.join(", ")} — known: ${harnessIds().join(", ")}`);
|
|
805
|
+
const flagChannel = values.channel ?? null;
|
|
806
|
+
if (flagChannel !== null && !["npm", "git"].includes(flagChannel))
|
|
807
|
+
return usage(`--channel takes npm or git, not ${JSON.stringify(flagChannel)}`);
|
|
808
|
+
const layouts = layoutNames(root),
|
|
809
|
+
languages = languageNames(root);
|
|
810
|
+
if (values.layout && layouts.length && !layouts.includes(values.layout))
|
|
811
|
+
return usage(`unknown layout "${values.layout}" — one of: ${layouts.join(", ")}`);
|
|
812
|
+
if (values.language && languages.length && !languages.includes(values.language))
|
|
813
|
+
return usage(`unknown language "${values.language}" — one of: ${languages.join(", ")}`);
|
|
814
|
+
|
|
815
|
+
// 1. Contract 19: the project given (--project, or a variable that names
|
|
816
|
+
// it), else the current folder, refused when it is not a directory.
|
|
817
|
+
const given = Boolean(values.project || env.PROJECTSTORE_PROJECT_DIR || projectRootDeclared(env));
|
|
818
|
+
const start = given ? resolve(ctx.project) : resolve(ctx.cwd || process.cwd());
|
|
819
|
+
const summary = { project: start, git_top: null, harnesses: [], channel: null, vault: null, statusline: null };
|
|
820
|
+
// A refusal before any plan: its text is written by whoever returns it,
|
|
821
|
+
// once the rail (if one is open) has closed beneath the answers.
|
|
822
|
+
const refused = (project, message, text = `${message}\n`) => ({
|
|
823
|
+
code: 1,
|
|
824
|
+
project,
|
|
825
|
+
ok: false,
|
|
826
|
+
text,
|
|
827
|
+
result: {
|
|
828
|
+
gate: { confirmed: false, why: "refused" },
|
|
829
|
+
applied: [],
|
|
830
|
+
failed: null,
|
|
831
|
+
incomplete: false,
|
|
832
|
+
plannedAgainst: {},
|
|
833
|
+
items: [],
|
|
834
|
+
refusals: [message],
|
|
835
|
+
reports: [],
|
|
836
|
+
fetched: [],
|
|
837
|
+
setup: summary,
|
|
838
|
+
},
|
|
839
|
+
});
|
|
840
|
+
const say = ({ text, ...r }) => {
|
|
841
|
+
write(text);
|
|
842
|
+
return r;
|
|
843
|
+
};
|
|
844
|
+
const missing = projectDirRefusal(start);
|
|
845
|
+
if (missing) return say(refused(start, missing));
|
|
846
|
+
|
|
847
|
+
// 2. Only the git top, and whether this folder is its own project (rule 4).
|
|
848
|
+
const top = given ? null : gitTop(start, { env, spawn: ctx.gitSpawn || spawnSync });
|
|
849
|
+
summary.git_top = top;
|
|
850
|
+
const own = top ? ownProjectNames().some((n) => existsSync(join(start, n))) : false;
|
|
851
|
+
|
|
852
|
+
// The rail, only where a person can answer it (rule 2): an interactive run
|
|
853
|
+
// (TTYs, no CI, no --json, no session marker) on a terminal that takes raw
|
|
854
|
+
// mode. Anywhere else the run is one without a terminal: no question, and
|
|
855
|
+
// the confirm is --harness — the gate gets no streams to ask on.
|
|
856
|
+
const c = stdout ? term.caps(stdout, env) : term.PLAIN;
|
|
857
|
+
const interactive = !json && isInteractive({ stdin, stdout, env, json }) && term.railAvailable(stdin, c);
|
|
858
|
+
let rail = null,
|
|
859
|
+
q = null;
|
|
860
|
+
try {
|
|
861
|
+
if (interactive)
|
|
862
|
+
rail = term.openRail({
|
|
863
|
+
stdin,
|
|
864
|
+
stdout,
|
|
865
|
+
caps: c,
|
|
866
|
+
proc: ctx.proc || process,
|
|
867
|
+
head: "projectstore setup",
|
|
868
|
+
...(ctx.escapeMs ? { escapeMs: ctx.escapeMs } : {}),
|
|
869
|
+
});
|
|
870
|
+
q = await questions(rail, {
|
|
871
|
+
start,
|
|
872
|
+
top,
|
|
873
|
+
given,
|
|
874
|
+
own,
|
|
875
|
+
values,
|
|
876
|
+
named,
|
|
877
|
+
flagChannel,
|
|
878
|
+
layout: values.layout || "engineering",
|
|
879
|
+
env,
|
|
880
|
+
home,
|
|
881
|
+
root,
|
|
882
|
+
version,
|
|
883
|
+
c,
|
|
884
|
+
refused,
|
|
885
|
+
usage,
|
|
886
|
+
summary,
|
|
887
|
+
ownEnv: ctx.ownEnv,
|
|
888
|
+
});
|
|
889
|
+
} finally {
|
|
890
|
+
// `└` before any network work, whatever ended the questions.
|
|
891
|
+
if (rail) rail.close(q?.cancelled ? `Cancelled ${term.icon(c, "dash")} nothing was written` : "");
|
|
892
|
+
}
|
|
893
|
+
if (q.cancelled) return { code: 130, project: summary.project, ok: false, result: null };
|
|
894
|
+
if (q.early) return q.early.error ? q.early : say(q.early);
|
|
895
|
+
const { project, runEnv, set, channel, vault, statusline } = q;
|
|
896
|
+
|
|
897
|
+
// 6. The vault plan, pure; its refusals stop the run here. One spelling of
|
|
898
|
+
// the project for it and for plan(), and the run's environment for both.
|
|
899
|
+
let vaultPlan = null;
|
|
900
|
+
if (vault?.kind === "inherit") vaultPlan = planInherit(project, { from: vault.from, env: runEnv, root, home });
|
|
901
|
+
else if (vault?.kind === "path")
|
|
902
|
+
vaultPlan = planBind(project, {
|
|
903
|
+
vault: vault.path,
|
|
904
|
+
layout: values.layout ?? null,
|
|
905
|
+
language: values.language ?? null,
|
|
906
|
+
init: true,
|
|
907
|
+
scaffoldExisting: Boolean(vault.scaffold),
|
|
908
|
+
author: dryRun || !ctx.author ? "" : ctx.author(project),
|
|
909
|
+
env: runEnv,
|
|
910
|
+
root,
|
|
911
|
+
home,
|
|
912
|
+
});
|
|
913
|
+
if (vaultPlan && !vaultPlan.ok) return say(refused(project, vaultPlan.refusals.map((x) => x.message).join("\n")));
|
|
914
|
+
summary.vault = vaultPlan ? bindResult(vaultPlan, null) : null;
|
|
915
|
+
|
|
916
|
+
// 7. The installer's own run, with setup's inputs.
|
|
917
|
+
const done = (r) =>
|
|
918
|
+
doneLines(r, { project, runEnv, home, version, channel, vaultPlan, underKeep: q.underKeep, verbose });
|
|
919
|
+
const r = await runVerb("setup", project, {
|
|
920
|
+
harnesses: set,
|
|
921
|
+
named: q.namedRun,
|
|
922
|
+
channel,
|
|
923
|
+
vault: vaultPlan,
|
|
924
|
+
statusline,
|
|
925
|
+
layout: vaultPlan ? vaultPlan.layout : null,
|
|
926
|
+
root,
|
|
927
|
+
env: runEnv,
|
|
928
|
+
home,
|
|
929
|
+
cwd: ctx.cwd,
|
|
930
|
+
json,
|
|
931
|
+
verbose,
|
|
932
|
+
dryRun,
|
|
933
|
+
out: json ? null : stdout,
|
|
934
|
+
badged: Boolean(rail),
|
|
935
|
+
header: q.header,
|
|
936
|
+
stdin: rail ? stdin : null,
|
|
937
|
+
stdout: rail ? stdout : null,
|
|
938
|
+
ask: rail ? ask : null,
|
|
939
|
+
drain: rail ? () => term.drainInput(stdin, { ticks: ctx.drainTicks ?? 2 }) : null,
|
|
940
|
+
done,
|
|
941
|
+
spawn: ctx.spawn,
|
|
942
|
+
fetchSpawn: ctx.fetchSpawn,
|
|
943
|
+
fetch: ctx.fetch,
|
|
944
|
+
now: ctx.now,
|
|
945
|
+
timers: ctx.timers,
|
|
946
|
+
});
|
|
947
|
+
const after = readConfigAt(project);
|
|
948
|
+
summary.statusline = typeof after?.statusline?.enabled === "boolean" ? after.statusline.enabled : null;
|
|
949
|
+
const applied = r.applied.find((a) => a && a.surface === "vault" && a.bind);
|
|
950
|
+
if (applied) summary.vault = applied.bind;
|
|
951
|
+
// A failed git init is no stop, but the run is not ok (rule 10).
|
|
952
|
+
const warned = (r.warnings || []).length > 0;
|
|
953
|
+
const ok =
|
|
954
|
+
r.plan.ok &&
|
|
955
|
+
!r.plan.incomplete &&
|
|
956
|
+
!r.failed &&
|
|
957
|
+
!warned &&
|
|
958
|
+
(r.gate.confirmed || ["nothing-to-do", "dry-run"].includes(r.gate.why));
|
|
959
|
+
if (!r.gate.confirmed) {
|
|
960
|
+
write(
|
|
961
|
+
gateText(r, {
|
|
962
|
+
verb: "setup",
|
|
963
|
+
harnesses: named,
|
|
964
|
+
caps: c,
|
|
965
|
+
args: (id) => repeatArgs(values, vault?.path || null, { channel: id === q.f.channelHarness?.id }),
|
|
966
|
+
}),
|
|
967
|
+
);
|
|
968
|
+
if (r.gate.why === "nothing-to-do") write(nextText(done(r).blocks, c));
|
|
969
|
+
}
|
|
970
|
+
return {
|
|
971
|
+
code: ok ? 0 : 1,
|
|
972
|
+
project,
|
|
973
|
+
ok,
|
|
974
|
+
result: {
|
|
975
|
+
gate: r.gate,
|
|
976
|
+
applied: r.applied,
|
|
977
|
+
failed: r.failed,
|
|
978
|
+
incomplete: r.plan.incomplete,
|
|
979
|
+
plannedAgainst: r.plan.plannedAgainst,
|
|
980
|
+
items: r.plan.items.map(publicItem),
|
|
981
|
+
refusals: r.plan.refusals,
|
|
982
|
+
reports: r.plan.reports,
|
|
983
|
+
fetched: r.fetched || [],
|
|
984
|
+
setup: summary,
|
|
985
|
+
},
|
|
986
|
+
};
|
|
987
|
+
}
|