projectstore-claude 0.29.2 → 0.29.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +27 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +14 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/harnesses/claude-code.json +18 -3
- package/node_modules/projectstore/harnesses/codex.json +12 -1
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +594 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/session-start.mjs +6 -2
- package/node_modules/projectstore/package.json +2 -1
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
- package/node_modules/projectstore/scripts/band.mjs +156 -0
- package/node_modules/projectstore/scripts/binding.mjs +157 -17
- package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
- package/node_modules/projectstore/scripts/cli.mjs +545 -179
- package/node_modules/projectstore/scripts/convergence.mjs +468 -0
- package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
- package/node_modules/projectstore/scripts/doctor.mjs +80 -62
- package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
- package/node_modules/projectstore/scripts/harness.mjs +178 -7
- package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
- package/node_modules/projectstore/scripts/kanban.mjs +4 -20
- package/node_modules/projectstore/scripts/lib.mjs +257 -58
- package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
- package/node_modules/projectstore/scripts/query.mjs +83 -12
- package/node_modules/projectstore/scripts/run-host.mjs +94 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
- package/node_modules/projectstore/scripts/stages.mjs +80 -0
- package/node_modules/projectstore/scripts/statusline.mjs +25 -2
- package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
- package/node_modules/projectstore/scripts/term.mjs +729 -39
- package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- package/package.json +2 -2
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// projectstore — convergence.mjs: what install and upgrade decide, before
|
|
2
|
+
// the plan, about the copies of ours this project already runs (the story
|
|
3
|
+
// "Setup and upgrade converge this project onto the running version: the
|
|
4
|
+
// channel switch, no downgrade, and the update notice", rules 1, 2, 5, 6 and 8; the
|
|
5
|
+
// ADR "One front door: projectstore setup installs every harness it finds;
|
|
6
|
+
// shells are release artifacts, not user commands", decision 4).
|
|
7
|
+
//
|
|
8
|
+
// runningCopies(projectDir, { harnesses, root, home, env, channel, … })
|
|
9
|
+
// → rule 6's copies, read-only
|
|
10
|
+
// readLatest({ registry, cachePath, fetch, now, timers, timeoutMs, atLeast })
|
|
11
|
+
// → { latest, from, error }: one read, cached a day
|
|
12
|
+
// decide({ copies, version, read, skipped, verb, args })
|
|
13
|
+
// → stop or proceed, with the stop's texts
|
|
14
|
+
// proceedCase(decision, v) → the words a newer row adds when not stopped
|
|
15
|
+
// channelChoices({ harness, s, copy, running, gate }) → the ways this release
|
|
16
|
+
// offers to settle a project's channel (rule
|
|
17
|
+
// 2's helper: the gate's text, and a newer copy's)
|
|
18
|
+
// newerCopyDeferral({ copy, running, decision, harness, s })
|
|
19
|
+
// → a newer copy not switched off: { reason, advice }
|
|
20
|
+
// switchConsequence(harness) → a switch row's consequence line (rule 2)
|
|
21
|
+
// switchReason({ copy, running, runs, decision, unbound })
|
|
22
|
+
// → what a switch row adds under the channel "npm"
|
|
23
|
+
// noCopyToKeep({ harness, surface, off }) → the channel "git" with nothing to keep
|
|
24
|
+
// keepArgv(copy, { verb, harness, projectDir }) → the kept copy's bin, remedy form + --json
|
|
25
|
+
// readKeepPlan(copy, { harness, projectDir, env, run, budget })
|
|
26
|
+
// → the kept copy's own plan: writes, ok, incomplete, refusals
|
|
27
|
+
// keepResult(stdout) → a bin --json envelope's `result`, or null
|
|
28
|
+
// ownUpgrade(copy, harness), npmRowBeside({ harness, id, surface }) → the keep's next lines
|
|
29
|
+
// repairAdvice(harness, v) → a damaged newer registration's way out
|
|
30
|
+
// advised(text, blocks) → { reason, advice }: prose, then commands alone
|
|
31
|
+
// updateNotice({ latest, version, root, verb }) → the notice's text and command
|
|
32
|
+
// hostCopyRoot, checksRoot, noticeRoot (root, { home, env }), updateCheckOff(env)
|
|
33
|
+
//
|
|
34
|
+
// The one owner of the stop: the prepare step calls decide() and, on a stop,
|
|
35
|
+
// short-circuits before the keep read and the fetch; plan() only renders what
|
|
36
|
+
// it is handed and reads no registry. Every clock, timer, fetch and cache
|
|
37
|
+
// path is a parameter, so each rule is a unit test with plain values. The
|
|
38
|
+
// one file it touches is the registry cache, written after a successful read.
|
|
39
|
+
//
|
|
40
|
+
// Imports lib, harness, portable-registration and surfaces, never the fetch
|
|
41
|
+
// or the host runner: every spawn and resolver is the caller's. Only the
|
|
42
|
+
// installer imports this file; doctor, lib and the hooks never do, so the
|
|
43
|
+
// SessionStart graph never loads it (tests/convergence.test.mjs asserts
|
|
44
|
+
// both). Pure node, no external deps — the read is Node's own fetch.
|
|
45
|
+
|
|
46
|
+
import { readFileSync, mkdirSync, existsSync, realpathSync } from "node:fs";
|
|
47
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
48
|
+
import { homedir } from "node:os";
|
|
49
|
+
import { cmpPrecedence, installChannel, projectCopiesByKey, writeFileAtomic, envFlag } from "./lib.mjs";
|
|
50
|
+
import { loadHarness, loadHarnesses, packageCommand } from "./harness.mjs";
|
|
51
|
+
import { analysePortableRegistration, portableRegistrationPaths } from "./portable-registration.mjs";
|
|
52
|
+
import { registrationPaths } from "./surfaces.mjs";
|
|
53
|
+
|
|
54
|
+
// The package every literal command here names: the core, in the core form
|
|
55
|
+
// (rule 9's command-form rule). The shells bundle it; the notice and the stop
|
|
56
|
+
// name the package that runs, never a shell.
|
|
57
|
+
export const PACKAGE = "projectstore";
|
|
58
|
+
// What the read uses when the user's registry cannot be resolved.
|
|
59
|
+
export const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
|
|
60
|
+
// The abbreviated packument: what `npm install` itself reads (8 728 bytes
|
|
61
|
+
// against 50 896 for the full document, measured 2026-10-09).
|
|
62
|
+
export const ACCEPT = "application/vnd.npm.install-v1+json";
|
|
63
|
+
// The request's bound. On the notice-only path it is the whole bound — the
|
|
64
|
+
// user's registry resolver and the request share it; rule 6's read keeps the
|
|
65
|
+
// resolver's own 10 s (REGISTRY_BUDGET_MS, fetch-shell.mjs) before it.
|
|
66
|
+
export const READ_TIMEOUT_MS = 2000;
|
|
67
|
+
export const NOTICE_BUDGET_MS = 2000;
|
|
68
|
+
export const FRESH_MS = 24 * 60 * 60 * 1000;
|
|
69
|
+
export const NO_CHECK_ENV = "PROJECTSTORE_NO_UPDATE_CHECK";
|
|
70
|
+
|
|
71
|
+
// Strict semver 2.0 (semver.org's own grammar): a `dist-tags.latest` that is
|
|
72
|
+
// anything else counts as missing — never printed, never compared.
|
|
73
|
+
const NUM = "(?:0|[1-9]\\d*)";
|
|
74
|
+
const PRE = `(?:${NUM}|\\d*[A-Za-z-][0-9A-Za-z-]*)`;
|
|
75
|
+
const SEMVER = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}(?:-${PRE}(?:\\.${PRE})*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`);
|
|
76
|
+
export const isSemver = (v) => typeof v === "string" && SEMVER.test(v);
|
|
77
|
+
|
|
78
|
+
// ─── Which roots check (rule 6) and show the notice (rule 8) ────────────
|
|
79
|
+
|
|
80
|
+
const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
81
|
+
const within = (root, dir) => { const r = real(root), d = real(dir); return r === d || r.startsWith(d + sep); };
|
|
82
|
+
|
|
83
|
+
// A copy a host runs, updated through its own channel (layout spec contract
|
|
84
|
+
// 12): never stopped, never told to update. installChannel names the host's
|
|
85
|
+
// cache copies; two more roots a session runs are recognised here, from the
|
|
86
|
+
// manifests' own paths (the review of PR1, F2):
|
|
87
|
+
// - our registration's marketplace directory: a host that loads it in place
|
|
88
|
+
// runs the plugin from <dir>/<plugin_subdir> (the npm registration's root);
|
|
89
|
+
// - anything under a portable registration's stable source or its host's
|
|
90
|
+
// plugin cache: the core a harness's shell bundles there (the Codex skills'
|
|
91
|
+
// root, <harness home>/plugins/cache/…/node_modules/projectstore).
|
|
92
|
+
// Doctor keeps installChannel's answers; only these two checks read this.
|
|
93
|
+
export function hostCopyRoot(root, { home = homedir(), env = process.env } = {}) {
|
|
94
|
+
if (!root) return false;
|
|
95
|
+
if (["registration", "marketplace"].includes(installChannel(root, { home }))) return true;
|
|
96
|
+
for (const m of loadHarnesses().values()) {
|
|
97
|
+
for (const s of Object.values(m.surfaces || {})) {
|
|
98
|
+
if (s?.kind !== "registration") continue;
|
|
99
|
+
if (s.format === "host-plugin-registration") {
|
|
100
|
+
const p = registrationPaths(s, { home, harness: m });
|
|
101
|
+
if (within(root, p.payload) || within(root, p.cacheDir)) return true;
|
|
102
|
+
} else if (s.format === "portable-plugin-registration") {
|
|
103
|
+
const p = portableRegistrationPaths(s, { home, projectDir: home, harness: m, env });
|
|
104
|
+
if (within(root, p.dir) || within(root, p.cacheDir)) return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Who checks (rule 6): a package root or a checkout that is not a host's
|
|
112
|
+
// copy. Who shows the notice (rule 8): a package root that is not one. A
|
|
113
|
+
// checkout is updated by git and shows no notice.
|
|
114
|
+
export function checksRoot(root, { home = homedir(), env = process.env } = {}) {
|
|
115
|
+
return ["package", "checkout"].includes(installChannel(root, { home })) && !hostCopyRoot(root, { home, env });
|
|
116
|
+
}
|
|
117
|
+
export function noticeRoot(root, { home = homedir(), env = process.env } = {}) {
|
|
118
|
+
return installChannel(root, { home }) === "package" && !hostCopyRoot(root, { home, env });
|
|
119
|
+
}
|
|
120
|
+
export const updateCheckOff = (env) => envFlag(env, NO_CHECK_ENV);
|
|
121
|
+
|
|
122
|
+
// ─── The copies this project runs (rule 6) ──────────────────────────────
|
|
123
|
+
|
|
124
|
+
// For each harness of the run:
|
|
125
|
+
// - a host plugin registration: contract 12's rows, one per key — the row the
|
|
126
|
+
// host loads here (lib's projectCopiesByKey, the same rows the registration's
|
|
127
|
+
// competing copies are read from) — of any marketplace: the npm
|
|
128
|
+
// registration's row and a git-marketplace copy alike. Under channel "git"
|
|
129
|
+
// none: that run renders nothing for it;
|
|
130
|
+
// - a portable registration: the global registration's recorded version,
|
|
131
|
+
// when it is effectively enabled for this project.
|
|
132
|
+
// `analyse(harness, s)` lets the caller share one analysis with the fetch.
|
|
133
|
+
export function runningCopies(projectDir, { harnesses = [], root, home = homedir(), env = process.env, channel = null, version = null, analyse = null } = {}) {
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const id of harnesses) {
|
|
136
|
+
const harness = loadHarness(id);
|
|
137
|
+
if (!harness) continue;
|
|
138
|
+
for (const [key, s] of Object.entries(harness.surfaces || {})) {
|
|
139
|
+
if (key.startsWith("_") || s?.kind !== "registration") continue;
|
|
140
|
+
if (s.format === "host-plugin-registration") {
|
|
141
|
+
if (channel === "git") continue;
|
|
142
|
+
for (const e of projectCopiesByKey(home, projectDir)) {
|
|
143
|
+
if (typeof e.version === "string" && e.version) out.push({ harness: id, key: e.key, version: e.version, path: e.path, scope: e.scope, own: e.own });
|
|
144
|
+
}
|
|
145
|
+
} else if (s.format === "portable-plugin-registration") {
|
|
146
|
+
const a = analyse ? analyse(harness, s) : analysePortableRegistration(projectDir, s, { root, home, harness, env, version });
|
|
147
|
+
const v = a?.ownership?.version;
|
|
148
|
+
if (typeof v === "string" && v && a.enabled) out.push({ harness: id, key: a.id, version: v });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const newerCopies = (copies, version) => (version ? copies.filter((c) => cmpPrecedence(c.version, version) > 0) : []);
|
|
156
|
+
// The lowest of them: a fresh cache entry below it is read again (rule 8).
|
|
157
|
+
export const lowestVersion = (copies) => copies.map((c) => c.version).sort(cmpPrecedence)[0] || null;
|
|
158
|
+
|
|
159
|
+
// ─── The registry read (rule 8) ──────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function readCache(cachePath) {
|
|
162
|
+
if (!cachePath) return {};
|
|
163
|
+
try {
|
|
164
|
+
const c = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
165
|
+
return c && typeof c === "object" && !Array.isArray(c) ? c : {};
|
|
166
|
+
} catch { return {}; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Fresh for a day; an `at` in the future is stale (a clock that moved back).
|
|
170
|
+
function fresh(entry, t) {
|
|
171
|
+
const at = Date.parse(entry?.at || "");
|
|
172
|
+
return Number.isFinite(at) && at <= t && t - at < FRESH_MS;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// One request, bounded: the timer aborts the request and settles the read,
|
|
176
|
+
// whatever the fetch does after — a fetch that never settles costs the bound.
|
|
177
|
+
// An answer that is not read (an error status) has its body cancelled, so
|
|
178
|
+
// its connection is released.
|
|
179
|
+
function request(url, { fetch, timers, timeoutMs }) {
|
|
180
|
+
return new Promise((settle) => {
|
|
181
|
+
let done = false, timer = null;
|
|
182
|
+
const ac = typeof AbortController === "function" ? new AbortController() : null;
|
|
183
|
+
const finish = (r) => {
|
|
184
|
+
if (done) return;
|
|
185
|
+
done = true;
|
|
186
|
+
if (timer !== null) { try { timers.clearTimeout(timer); } catch {} }
|
|
187
|
+
settle(r);
|
|
188
|
+
};
|
|
189
|
+
timer = timers.setTimeout(() => {
|
|
190
|
+
timer = null;
|
|
191
|
+
try { ac?.abort(); } catch {}
|
|
192
|
+
finish({ error: `no answer within ${timeoutMs / 1000} s` });
|
|
193
|
+
}, timeoutMs);
|
|
194
|
+
try { timer?.unref?.(); } catch {}
|
|
195
|
+
Promise.resolve()
|
|
196
|
+
.then(() => fetch(url, { headers: { accept: ACCEPT }, ...(ac ? { signal: ac.signal } : {}) }))
|
|
197
|
+
.then(async (res) => {
|
|
198
|
+
if (!res || !res.ok) {
|
|
199
|
+
try { Promise.resolve(res?.body?.cancel?.()).catch(() => {}); } catch {}
|
|
200
|
+
return finish({ error: `HTTP ${res?.status ?? "error"}` });
|
|
201
|
+
}
|
|
202
|
+
let body;
|
|
203
|
+
try { body = await res.json(); } catch { return finish({ error: "the answer is not JSON" }); }
|
|
204
|
+
const latest = body && typeof body === "object" ? body["dist-tags"]?.latest : undefined;
|
|
205
|
+
if (!isSemver(latest)) return finish({ error: "the answer names no dist-tags.latest" });
|
|
206
|
+
finish({ latest });
|
|
207
|
+
})
|
|
208
|
+
.catch((e) => finish({ error: String(e?.cause?.code || e?.code || e?.message || "the request failed") }));
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The cache's one write per answer (a failed read is never cached): the entry
|
|
213
|
+
// keyed by the registry this run resolved, and `user_latest`, which the
|
|
214
|
+
// self-update ADR's session line reads without a registry of its own. This
|
|
215
|
+
// read never learns a publish time: `published_at` stays as it was for the
|
|
216
|
+
// same latest, else null. A write that fails is ignored.
|
|
217
|
+
function writeCache(cachePath, cache, registry, latest, at) {
|
|
218
|
+
if (!cachePath) return;
|
|
219
|
+
const prev = cache.user_latest && typeof cache.user_latest === "object" ? cache.user_latest : null;
|
|
220
|
+
const next = { ...cache, [registry]: { latest, at }, user_latest: { latest, at, published_at: prev && prev.latest === latest && typeof prev.published_at === "string" ? prev.published_at : null } };
|
|
221
|
+
try {
|
|
222
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
223
|
+
writeFileAtomic(cachePath, JSON.stringify(next, null, 2) + "\n", { sweep: false });
|
|
224
|
+
} catch {}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// `GET <registry>/projectstore` with the abbreviated Accept header, no
|
|
228
|
+
// credentials, bounded by `timeoutMs`; the answer is `dist-tags.latest`. A
|
|
229
|
+
// fresh entry for this registry answers instead — unless it is below
|
|
230
|
+
// `atLeast`, the lowest newer copy rule 6 asks about: a release may have come
|
|
231
|
+
// out since it was written. The cache never supplies a URL: an entry is only
|
|
232
|
+
// looked up with the registry this run resolved. Never throws.
|
|
233
|
+
export async function readLatest({ registry, cachePath = null, fetch = globalThis.fetch, now = Date.now, timers = globalThis, timeoutMs = READ_TIMEOUT_MS, atLeast = null } = {}) {
|
|
234
|
+
try {
|
|
235
|
+
const cache = readCache(cachePath);
|
|
236
|
+
const entry = cache[registry];
|
|
237
|
+
const t = now();
|
|
238
|
+
if (entry && isSemver(entry.latest) && fresh(entry, t) && !(atLeast && cmpPrecedence(entry.latest, atLeast) < 0)) {
|
|
239
|
+
return { latest: entry.latest, from: "cache", error: null };
|
|
240
|
+
}
|
|
241
|
+
if (typeof fetch !== "function") return { latest: null, from: null, error: "no fetch in this Node" };
|
|
242
|
+
const url = `${String(registry).replace(/\/+$/, "")}/${PACKAGE}`;
|
|
243
|
+
const r = await request(url, { fetch, timers, timeoutMs });
|
|
244
|
+
if (!r.latest) return { latest: null, from: "network", error: r.error };
|
|
245
|
+
writeCache(cachePath, cache, registry, r.latest, new Date(now()).toISOString());
|
|
246
|
+
return { latest: r.latest, from: "network", error: null };
|
|
247
|
+
} catch (e) {
|
|
248
|
+
return { latest: null, from: null, error: String(e?.message || e) };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Stop or proceed (rule 6) ───────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
// A command in the core form: `npx projectstore[@<v>] <verb> <args>`.
|
|
255
|
+
export function coreCommand(verb, { version = null, args = "" } = {}) {
|
|
256
|
+
return `npx ${PACKAGE}${version ? `@${version}` : ""} ${verb}${args ? ` ${args}` : ""}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Prose, then each command on a line of its own after its lead, nothing after
|
|
260
|
+
// it (presentation spec contract 6). `reason` is the whole of it with every
|
|
261
|
+
// part on its own line — the envelope's form, which an agent relays as it
|
|
262
|
+
// is; `advice` is what PLAN draws, the commands in their own blocks.
|
|
263
|
+
export function advised(text, blocks = []) {
|
|
264
|
+
return { reason: [text, ...blocks.flatMap((b) => [b.lead, b.command])].join("\n"), advice: { text, blocks } };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The run's own selection, repeated: --harness and --surface as given, and
|
|
268
|
+
// --no-register; never --project (the ADR's decision 5).
|
|
269
|
+
function runArgs({ harnesses = [], surfaces = null, register = true } = {}) {
|
|
270
|
+
return [...harnesses.map((h) => `--harness ${h}`), ...(surfaces || []).map((s) => `--surface ${s}`), ...(register === false ? ["--no-register"] : [])].join(" ");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Stop when the registry's latest is at least a newer copy this project runs
|
|
274
|
+
// — the newest such copy is named. A copy the latest does not reach, or no
|
|
275
|
+
// answer, proceeds: the rows say which case it was (proceedCase). `read` is
|
|
276
|
+
// readLatest's answer, null when nothing was read; `skipped` says the read
|
|
277
|
+
// was switched off by PROJECTSTORE_NO_UPDATE_CHECK. The refusal's text puts
|
|
278
|
+
// each command on a line of its own, never followed by punctuation.
|
|
279
|
+
export function decide({ copies = [], version = null, read = null, skipped = false, verb = "upgrade", args = {} } = {}) {
|
|
280
|
+
const newer = newerCopies(copies, version);
|
|
281
|
+
const decision = { checks: true, version, newer, read: skipped ? { skipped: true } : read, stop: null };
|
|
282
|
+
const latest = read && isSemver(read.latest) ? read.latest : null;
|
|
283
|
+
if (skipped || !latest) return decision;
|
|
284
|
+
const top = newer.filter((c) => cmpPrecedence(latest, c.version) >= 0).sort((a, b) => cmpPrecedence(b.version, a.version))[0];
|
|
285
|
+
if (!top) return decision;
|
|
286
|
+
const lead = `this project runs ${PACKAGE} ${top.version} (${top.key}), newer than this package (${version}), and ${top.version} is published. Nothing is written. Run the newer one from a terminal in this project:`;
|
|
287
|
+
const commands = [coreCommand(verb, { version: "latest", args: runArgs(args) }), `npm install -g ${PACKAGE}@latest`];
|
|
288
|
+
const between = "or install it for the whole machine:";
|
|
289
|
+
decision.stop = { copy: top, lead, commands, between, text: [lead, commands[0], between, commands[1]].join("\n") };
|
|
290
|
+
return decision;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// The case a newer row names when the run proceeds (rule 6), for the copy at
|
|
294
|
+
// `v`: the dry run's note when no decision was made (plan reads nothing), none
|
|
295
|
+
// for a root that never checks, else what the read found.
|
|
296
|
+
export function proceedCase(decision, v) {
|
|
297
|
+
if (decision === null || decision === undefined) return `install and upgrade read the registry first, and stop when ${v} is published`;
|
|
298
|
+
if (!decision.checks) return null;
|
|
299
|
+
const r = decision.read;
|
|
300
|
+
if (!r) return null;
|
|
301
|
+
if (r.skipped) return `the registry was not read (${NO_CHECK_ENV})`;
|
|
302
|
+
if (!isSemver(r.latest)) return `the registry could not be read (${r.error || "no answer"})`;
|
|
303
|
+
if (cmpPrecedence(r.latest, v) < 0) return `the registry's latest (${r.latest}) does not reach ${v}`;
|
|
304
|
+
return `${v} is published (the registry's latest is ${r.latest})`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Rule 2's helper: the ways this release offers to settle which copy a
|
|
308
|
+
// project runs, when a copy from another marketplace is enabled for it. Only
|
|
309
|
+
// verbs this release has are named, never `setup`, which does not exist yet;
|
|
310
|
+
// the setup story replaces both branches below with its own commands
|
|
311
|
+
// (`npx projectstore setup`, and its `--channel npm` and `--channel git`),
|
|
312
|
+
// here and nowhere else.
|
|
313
|
+
// - `gate` ({ verb, args, keys }): the channel gate's text (rule 2). A switch
|
|
314
|
+
// planned under no channel answer is a choice made at a terminal, and this
|
|
315
|
+
// release has no verb that takes the answer without one, so it names the
|
|
316
|
+
// terminal run of the same verb with the run's own --harness and --surface,
|
|
317
|
+
// in the core form (rule 9).
|
|
318
|
+
// - otherwise, a newer copy that is not switched off (rule 6): the copy's own
|
|
319
|
+
// bin in the layout remedy's form (layout spec contract 12), which converges
|
|
320
|
+
// the project on that copy; and the host's own disable, built from the
|
|
321
|
+
// manifest as a plan step is, which leaves the project to this package's
|
|
322
|
+
// registration on the next run.
|
|
323
|
+
export function channelChoices({ harness = null, s = null, copy = null, running = null, gate = null } = {}) {
|
|
324
|
+
if (gate) {
|
|
325
|
+
const from = gate.keys?.length ? ` (${gate.keys.join(", ")})` : "";
|
|
326
|
+
return [{ lead: `Nothing written: this project loads ${PACKAGE} from the git marketplace${from}, and moving it to the npm install is a choice made at a terminal in this project:`, command: coreCommand(gate.verb || "upgrade", { args: runArgs(gate.args || {}) }) }];
|
|
327
|
+
}
|
|
328
|
+
const blocks = [];
|
|
329
|
+
const bin = copy?.path ? join(copy.path, "bin", "projectstore.mjs") : null;
|
|
330
|
+
if (bin && existsSync(bin)) blocks.push({ lead: "Run the newer copy instead, from a terminal in this project:", command: `node "${bin}" upgrade --harness ${harness.id} --no-register` });
|
|
331
|
+
const disable = s?.cli?.commands?.disable;
|
|
332
|
+
if (Array.isArray(disable) && disable.length && s.cli.bin) {
|
|
333
|
+
const argv = disable.map((t) => t.replace(/\{(\w+)\}/g, (_, k) => (k === "other" || k === "id" ? copy.key : `{${k}}`)));
|
|
334
|
+
blocks.push({ lead: `Or move this project to this package's registration, the older ${running}: turn that copy off for this project, then repeat this run:`, command: [s.cli.bin, ...argv].join(" ") });
|
|
335
|
+
}
|
|
336
|
+
return blocks;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// A newer copy from another marketplace under channel null: not switched
|
|
340
|
+
// off, because switching it off would run the project on an older package.
|
|
341
|
+
// The rows name both versions and the case, then the ways out.
|
|
342
|
+
export function newerCopyDeferral({ copy, running, decision, harness, s }) {
|
|
343
|
+
const c = proceedCase(decision, copy.version);
|
|
344
|
+
const text = `this project runs a newer copy (${copy.key} ${copy.version}) than this package (${running}), so it is not switched off${c ? ` — ${c}` : ""}.`;
|
|
345
|
+
return advised(text, channelChoices({ harness, s, copy, running }));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The consequence line of a switch (rule 2; presentation spec contract 7):
|
|
349
|
+
// the ADR's three facts — the git-marketplace copy is off for this project,
|
|
350
|
+
// the host's update no longer reaches it, and what updates it from now on —
|
|
351
|
+
// with no backticks, and no command followed by punctuation. The command is
|
|
352
|
+
// packageCommand's, so it keeps the shell form until the text-cleanup story
|
|
353
|
+
// moves it (rule 9's command-form rule).
|
|
354
|
+
export function switchConsequence(harness) {
|
|
355
|
+
return `this project stops loading the git-marketplace copy, and /plugin update no longer reaches it — from now on it is updated by ${packageCommand(harness, "upgrade")}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The words a switch row adds under the channel "npm" (rules 2 and 6): over a
|
|
359
|
+
// copy newer than this package, both versions, the case the registry read
|
|
360
|
+
// found, and that the project will run the older one; for a project not bound
|
|
361
|
+
// yet, that the switch is recorded once it is.
|
|
362
|
+
export function switchReason({ copy, running, runs = running, decision, unbound = false }) {
|
|
363
|
+
const parts = [];
|
|
364
|
+
// Against what the project runs once the copy is off (`runs`: the shared
|
|
365
|
+
// directory's version when another checkout wrote a newer one), not against
|
|
366
|
+
// this package: a copy older than that is no downgrade, and saying so
|
|
367
|
+
// would be false.
|
|
368
|
+
if (copy && typeof copy.version === "string" && runs && cmpPrecedence(copy.version, runs) > 0) {
|
|
369
|
+
const c = proceedCase(decision, copy.version);
|
|
370
|
+
parts.push(`${copy.key} ${copy.version} is newer than this package (${running})${c ? ` — ${c}` : ""}; turning it off runs this project on the older ${runs}`);
|
|
371
|
+
}
|
|
372
|
+
if (unbound) parts.push("the project is not bound yet: the switch is recorded in its binding once it is");
|
|
373
|
+
return parts.length ? parts.join("; ") : null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The channel "git" with no copy from another marketplace enabled for this
|
|
377
|
+
// project (rule 1): there is nothing to keep, and nothing is planned. The way
|
|
378
|
+
// back from an earlier switch is the registration's uninstall (rule 4); a copy
|
|
379
|
+
// of ours this checkout's local settings hold off — what a hand removal of the
|
|
380
|
+
// npm row leaves — is turned back on in the host. Each command alone on its
|
|
381
|
+
// line (presentation spec contract 6).
|
|
382
|
+
export function noCopyToKeep({ harness, surface, off = [] }) {
|
|
383
|
+
const blocks = off.map((key) => ({ lead: `This checkout's local settings hold ${key} off. To load it again, run this in ${harness.display_name}:`, command: `/plugin enable ${key}` }));
|
|
384
|
+
blocks.push({ lead: "The way back from an earlier switch to the npm install is its uninstall, from a terminal in this project:", command: coreCommand("uninstall", { args: runArgs({ harnesses: [harness.id], surfaces: [surface] }) }) });
|
|
385
|
+
return advised(`no git-marketplace copy of ${PACKAGE} is enabled for this project, so there is none to keep. Nothing is written.`, blocks);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ─── The keep (rule 5) ──────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
// The keep runs the kept copy's own bin in the layout remedy's form (layout
|
|
391
|
+
// spec contract 12): `--harness <id> --no-register --project <dir>`, plus
|
|
392
|
+
// `--json`, which lets the run read the child's result and keeps the child
|
|
393
|
+
// from asking — the run asks one question, its own. Never `--surface`: naming
|
|
394
|
+
// the status line would opt it in (rule 5). `process.execPath`, not `node`:
|
|
395
|
+
// the Node that runs us is sure to exist, and the PLAN shows the argv verbatim.
|
|
396
|
+
export function keepArgv(copy, { verb, harness, projectDir }) {
|
|
397
|
+
return [process.execPath, join(copy.path, "bin", "projectstore.mjs"), verb, "--harness", harness.id, "--no-register", "--json", "--project", projectDir];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// The bin's `--json` envelope, `{ schema_version, verb, project, ok, result }`:
|
|
401
|
+
// its `result`, or null when stdout holds none. The top-level `ok` is never
|
|
402
|
+
// read — for plan it is `ok && !incomplete`, for a write verb the gate too.
|
|
403
|
+
export function keepResult(stdout) {
|
|
404
|
+
let env = null;
|
|
405
|
+
try { env = JSON.parse(String(stdout || "").trim()); } catch { return null; }
|
|
406
|
+
const r = env && typeof env === "object" && !Array.isArray(env) ? env.result : null;
|
|
407
|
+
return r && typeof r === "object" && !Array.isArray(r) ? r : null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Rule 5's read of the kept copy's own plan, before plan(), in the prepare
|
|
411
|
+
// step: `plan --json` through its bin, with the project as the working
|
|
412
|
+
// directory and the caller's environment (the copy's root as the plugin root,
|
|
413
|
+
// the harness home pinned), stdin ignored, bounded at `budget`. `run` is the
|
|
414
|
+
// caller's asynchronous runner, runHost's shape: this module spawns nothing
|
|
415
|
+
// itself. `plan` is a dry run in every version the capability read passes, so
|
|
416
|
+
// the read writes nothing. Never throws. Returns:
|
|
417
|
+
// { argv, envelope: false, status, stderr, error } no result to read
|
|
418
|
+
// { argv, envelope: true, ok, incomplete, refusals, writes, deferred, status }
|
|
419
|
+
// `writes` are the plan's rows that change something, as { surface, action,
|
|
420
|
+
// path }; `refusals` its plan-level refusals, then each refused row's reason;
|
|
421
|
+
// `deferred` the rows it left for later, as { surface, reason }.
|
|
422
|
+
export async function readKeepPlan(copy, { harness, projectDir, env, run, budget = 120000 } = {}) {
|
|
423
|
+
const argv = keepArgv(copy, { verb: "plan", harness, projectDir });
|
|
424
|
+
let r;
|
|
425
|
+
try { r = await run(argv[0], argv.slice(1), { env, cwd: projectDir, budget }); }
|
|
426
|
+
catch (e) { r = { status: null, stdout: "", stderr: "", error: e }; }
|
|
427
|
+
const status = r?.status ?? null;
|
|
428
|
+
const stderr = String(r?.stderr || "").trim();
|
|
429
|
+
const result = keepResult(r?.stdout);
|
|
430
|
+
if (!result) return { argv, envelope: false, status, stderr, error: r?.error ? String(r.error.message || r.error) : null };
|
|
431
|
+
const items = Array.isArray(result.items) ? result.items.filter((i) => i && typeof i === "object") : [];
|
|
432
|
+
const writes = items.filter((i) => !["skip", "refuse"].includes(i.action)).map((i) => ({ surface: String(i.surface ?? ""), action: String(i.action ?? ""), path: i.path ?? null }));
|
|
433
|
+
const refusals = [...(Array.isArray(result.refusals) ? result.refusals : []), ...items.filter((i) => i.action === "refuse").map((i) => i.reason)].filter((x) => typeof x === "string" && x);
|
|
434
|
+
const deferred = items.filter((i) => i.deferred === true).map((i) => ({ surface: String(i.surface ?? ""), reason: typeof i.reason === "string" ? i.reason : null }));
|
|
435
|
+
return { argv, envelope: true, ok: result.ok !== false, incomplete: result.incomplete === true, refusals, writes, deferred, status };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The kept copy's own upgrade, in the form channelChoices names a copy's bin:
|
|
439
|
+
// for a copy whose plan printed no result — run by the person, with its own
|
|
440
|
+
// preview and its own question (we never run what we could not preview,
|
|
441
|
+
// install spec contract 9).
|
|
442
|
+
export function ownUpgrade(copy, harness) {
|
|
443
|
+
return { lead: "Run that copy's own upgrade from a terminal in this project; it shows its own plan and asks:", command: `node "${join(copy.path, "bin", "projectstore.mjs")}" upgrade --harness ${harness.id} --no-register` };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// This checkout's npm registration enabled beside the kept copy: the keep
|
|
447
|
+
// changes no registration, so it stays; its uninstall (rule 4) drops it.
|
|
448
|
+
export function npmRowBeside({ harness, id, surface }) {
|
|
449
|
+
return { lead: `This checkout's npm registration (${id}) stays enabled beside the kept copy. To drop it, from a terminal in this project:`, command: coreCommand("uninstall", { args: runArgs({ harnesses: [harness.id], surfaces: [surface] }) }) };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// A newer registration that is damaged: never rewritten or staged over from
|
|
453
|
+
// the older package; repaired from its own version.
|
|
454
|
+
export function repairAdvice(harness, v) {
|
|
455
|
+
return { lead: "not downgraded — repair it from its own version:", command: coreCommand("upgrade", { version: v, args: `--harness ${harness.id}` }) };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ─── The update notice (rule 8) ─────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
// The notice, when the registry's latest ranks above the running version: its
|
|
461
|
+
// text, and the command on a line of its own (presentation spec contract 6).
|
|
462
|
+
// A root under npx's cache is told npx; anything else the global install,
|
|
463
|
+
// the ADR's form — npm install -g would add a global copy an npx user never had.
|
|
464
|
+
export function updateNotice({ latest, version, root, verb = "upgrade" } = {}) {
|
|
465
|
+
if (!isSemver(latest) || !version || cmpPrecedence(latest, version) <= 0) return null;
|
|
466
|
+
const npx = /[\\/]_npx[\\/]/.test(String(root || ""));
|
|
467
|
+
return { latest, text: `newer published: ${latest} (this is ${version}). To update:`, command: npx ? coreCommand(verb, { version: "latest" }) : `npm install -g ${PACKAGE}@latest` };
|
|
468
|
+
}
|