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
|
@@ -6,11 +6,23 @@ import { createHash } from "node:crypto";
|
|
|
6
6
|
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
7
7
|
import { join, resolve } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { whichOnPath } from "./lib.mjs";
|
|
9
|
+
import { whichOnPath, cmpPrecedence } from "./lib.mjs";
|
|
10
10
|
|
|
11
|
-
const readJson = (path) => {
|
|
11
|
+
const readJson = (path) => {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
12
18
|
const samePath = (a, b) => {
|
|
13
|
-
const real = (p) => {
|
|
19
|
+
const real = (p) => {
|
|
20
|
+
try {
|
|
21
|
+
return realpathSync(p);
|
|
22
|
+
} catch {
|
|
23
|
+
return resolve(p);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
14
26
|
return Boolean(a && b) && real(a) === real(b);
|
|
15
27
|
};
|
|
16
28
|
|
|
@@ -19,19 +31,31 @@ function parseValue(raw) {
|
|
|
19
31
|
if (s === "true") return true;
|
|
20
32
|
if (s === "false") return false;
|
|
21
33
|
if (s.startsWith('"') && s.endsWith('"')) {
|
|
22
|
-
try {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(s);
|
|
36
|
+
} catch {
|
|
37
|
+
return s.slice(1, -1);
|
|
38
|
+
}
|
|
23
39
|
}
|
|
24
40
|
return s;
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
export function readTomlSections(path) {
|
|
28
44
|
let text;
|
|
29
|
-
try {
|
|
45
|
+
try {
|
|
46
|
+
text = readFileSync(path, "utf8");
|
|
47
|
+
} catch {
|
|
48
|
+
return new Map();
|
|
49
|
+
}
|
|
30
50
|
const sections = new Map();
|
|
31
51
|
let current = null;
|
|
32
52
|
for (const line of text.split(/\r?\n/)) {
|
|
33
53
|
const header = line.match(/^\s*\[([^\]]+)]\s*(?:#.*)?$/);
|
|
34
|
-
if (header) {
|
|
54
|
+
if (header) {
|
|
55
|
+
current = header[1];
|
|
56
|
+
if (!sections.has(current)) sections.set(current, {});
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
35
59
|
if (!current || /^\s*(?:#|$)/.test(line)) continue;
|
|
36
60
|
const kv = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*(.*?)\s*(?:#.*)?$/);
|
|
37
61
|
if (kv) sections.get(current)[kv[1]] = parseValue(kv[2]);
|
|
@@ -43,17 +67,23 @@ export function payloadFiles(root) {
|
|
|
43
67
|
const out = [];
|
|
44
68
|
const walk = (at, rel = "") => {
|
|
45
69
|
let entries;
|
|
46
|
-
try {
|
|
70
|
+
try {
|
|
71
|
+
entries = readdirSync(at, { withFileTypes: true });
|
|
72
|
+
} catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
47
75
|
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
48
76
|
if (e.name.startsWith(".DS_Store")) continue;
|
|
49
|
-
const abs = join(at, e.name),
|
|
77
|
+
const abs = join(at, e.name),
|
|
78
|
+
next = rel ? `${rel}/${e.name}` : e.name;
|
|
50
79
|
// npm creates command shims as symlinks here. The shell invokes the
|
|
51
80
|
// bundled core by its real path, so these convenience links are neither
|
|
52
81
|
// runtime input nor portable plugin payload.
|
|
53
82
|
if (next === "node_modules/.bin" || next.startsWith("node_modules/.bin/")) continue;
|
|
54
83
|
const st = lstatSync(abs);
|
|
55
84
|
if (st.isSymbolicLink()) throw new Error(`plugin payload contains a symlink: ${next}`);
|
|
56
|
-
if (st.isDirectory()) walk(abs, next);
|
|
85
|
+
if (st.isDirectory()) walk(abs, next);
|
|
86
|
+
else if (st.isFile()) out.push(next);
|
|
57
87
|
}
|
|
58
88
|
};
|
|
59
89
|
walk(root);
|
|
@@ -65,7 +95,11 @@ export function payloadDigest(root) {
|
|
|
65
95
|
const h = createHash("sha256");
|
|
66
96
|
for (const rel of files) {
|
|
67
97
|
h.update(rel + "\n");
|
|
68
|
-
h.update(
|
|
98
|
+
h.update(
|
|
99
|
+
createHash("sha256")
|
|
100
|
+
.update(readFileSync(join(root, rel)))
|
|
101
|
+
.digest("hex") + "\n",
|
|
102
|
+
);
|
|
69
103
|
}
|
|
70
104
|
return { count: files.length, sha256: h.digest("hex") };
|
|
71
105
|
}
|
|
@@ -92,41 +126,86 @@ export function renderPortableCatalog(s) {
|
|
|
92
126
|
return {
|
|
93
127
|
name: s.marketplace_name,
|
|
94
128
|
interface: { displayName: "ProjectStore (npm)" },
|
|
95
|
-
plugins: [
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
129
|
+
plugins: [
|
|
130
|
+
{
|
|
131
|
+
name: s.plugin_name,
|
|
132
|
+
source: { source: "local", path: `./${s.plugin_subdir}` },
|
|
133
|
+
policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
|
|
134
|
+
category: "Developer Tools",
|
|
135
|
+
},
|
|
136
|
+
],
|
|
101
137
|
};
|
|
102
138
|
}
|
|
103
139
|
|
|
104
|
-
function pluginTable(id) {
|
|
105
|
-
|
|
140
|
+
function pluginTable(id) {
|
|
141
|
+
return `plugins.${JSON.stringify(id)}`;
|
|
142
|
+
}
|
|
143
|
+
function marketplaceTable(name) {
|
|
144
|
+
return `marketplaces.${name}`;
|
|
145
|
+
}
|
|
106
146
|
|
|
107
147
|
function pluginIds(sections) {
|
|
108
148
|
const ids = [];
|
|
109
149
|
for (const key of sections.keys()) {
|
|
110
150
|
const m = key.match(/^plugins\.(.+)$/);
|
|
111
151
|
if (!m) continue;
|
|
112
|
-
try {
|
|
152
|
+
try {
|
|
153
|
+
ids.push(m[1].startsWith('"') ? JSON.parse(m[1]) : m[1]);
|
|
154
|
+
} catch {}
|
|
113
155
|
}
|
|
114
156
|
return ids;
|
|
115
157
|
}
|
|
116
158
|
|
|
159
|
+
// The manifests a portable plugin root may carry, in the order they are read:
|
|
160
|
+
// an Agent Plugins plugin.json at the root, then the legacy one Codex
|
|
161
|
+
// validates — from 0.28.2 the shell's one manifest, since Codex picks a root
|
|
162
|
+
// one first and loads no hooks from it (openai/codex#37027). The one place
|
|
163
|
+
// either is spelled; the shell fetch's verification names them by kind.
|
|
164
|
+
const PAYLOAD_MANIFESTS = { root: ["plugin.json"], legacy: [".codex-plugin", "plugin.json"] };
|
|
165
|
+
export function payloadManifestPaths(root) {
|
|
166
|
+
return Object.values(PAYLOAD_MANIFESTS).map((parts) => join(root, ...parts));
|
|
167
|
+
}
|
|
168
|
+
export function payloadManifestPath(root, kind) {
|
|
169
|
+
return join(root, ...PAYLOAD_MANIFESTS[kind]);
|
|
170
|
+
}
|
|
171
|
+
// Is `root` a portable plugin root: does it carry either manifest?
|
|
172
|
+
export function isPortablePluginRoot(root) {
|
|
173
|
+
return Boolean(root) && payloadManifestPaths(root).some((p) => existsSync(p));
|
|
174
|
+
}
|
|
175
|
+
// The first manifest that parses, or null — or, with `kind`, that one only.
|
|
176
|
+
export function payloadManifest(root, { kind = null } = {}) {
|
|
177
|
+
for (const p of kind ? [payloadManifestPath(root, kind)] : payloadManifestPaths(root)) {
|
|
178
|
+
const m = readJson(p);
|
|
179
|
+
if (m) return m;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
117
184
|
function cacheEntries(paths, s) {
|
|
118
185
|
const base = join(paths.cacheDir, s.marketplace_name, s.plugin_name);
|
|
119
186
|
let names;
|
|
120
|
-
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
187
|
+
try {
|
|
188
|
+
names = readdirSync(base);
|
|
189
|
+
} catch {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
return names
|
|
193
|
+
.map((name) => {
|
|
194
|
+
const path = join(base, name);
|
|
195
|
+
const manifest = payloadManifest(path);
|
|
196
|
+
let at = 0;
|
|
197
|
+
try {
|
|
198
|
+
at = statSync(path).mtimeMs;
|
|
199
|
+
} catch {}
|
|
200
|
+
if (!manifest || typeof manifest.version !== "string") return null;
|
|
201
|
+
let digest = null;
|
|
202
|
+
try {
|
|
203
|
+
digest = payloadDigest(path);
|
|
204
|
+
} catch {}
|
|
205
|
+
return { path, version: manifest.version, at, digest };
|
|
206
|
+
})
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.sort((a, b) => b.at - a.at || b.version.localeCompare(a.version));
|
|
130
209
|
}
|
|
131
210
|
|
|
132
211
|
// Where the payload to register lives. A surface conditioned on a distribution
|
|
@@ -139,18 +218,46 @@ function cacheEntries(paths, s) {
|
|
|
139
218
|
// the harness's directory (S2 of the 2026-10-03 review in "The Codex shell's
|
|
140
219
|
// plugin root: .codex-plugin, the rendered surfaces, and the first Codex
|
|
141
220
|
// install").
|
|
142
|
-
|
|
221
|
+
//
|
|
222
|
+
// `fetched` is the root a core run fetched for this harness (the shell fetch
|
|
223
|
+
// of install and upgrade). The variable wins: a set one is used as is, a root
|
|
224
|
+
// or not, and the fetched root is consulted only when it is unset — a shell
|
|
225
|
+
// that names its own root is never second-guessed by a fetch.
|
|
226
|
+
export function portablePayloadRoot(s, { root, env = process.env, fetched = null } = {}) {
|
|
143
227
|
const named = env.PROJECTSTORE_DISTRIBUTION_ROOT || null;
|
|
144
228
|
if (s.condition !== "distribution_root") return named || root;
|
|
145
|
-
|
|
146
|
-
return
|
|
229
|
+
const candidate = named || fetched || null;
|
|
230
|
+
return candidate && isPortablePluginRoot(candidate) ? candidate : null;
|
|
147
231
|
}
|
|
148
232
|
|
|
149
|
-
|
|
233
|
+
// Two modes. With a payload, every rung is decided, the published digest
|
|
234
|
+
// against the recorded one included. With a `version` and no payload — plan's
|
|
235
|
+
// dry run, both uninstalls, a run whose fetch was skipped, and the recheck of
|
|
236
|
+
// an item planned that way — every rung that reads host facts is decided, and
|
|
237
|
+
// the one comparison that needs the payload's bytes is not: its digest, so
|
|
238
|
+
// `contentDiffers` and the same-version, different-content conflict. That is
|
|
239
|
+
// the one rung a local-tarball install at an unchanged version trips, and it
|
|
240
|
+
// is decided after a real fetch. `unavailable` for want of a payload fires
|
|
241
|
+
// only when there is neither. Doctor passes neither and reads as before.
|
|
242
|
+
export function analysePortableRegistration(
|
|
243
|
+
projectDir,
|
|
244
|
+
s,
|
|
245
|
+
{
|
|
246
|
+
root,
|
|
247
|
+
payloadRoot: explicitPayloadRoot = null,
|
|
248
|
+
version = null,
|
|
249
|
+
fetched = null,
|
|
250
|
+
home = homedir(),
|
|
251
|
+
harness,
|
|
252
|
+
env = process.env,
|
|
253
|
+
ignoreJournal = false,
|
|
254
|
+
} = {},
|
|
255
|
+
) {
|
|
150
256
|
const paths = portableRegistrationPaths(s, { home, projectDir, harness, env });
|
|
151
|
-
const payloadRoot = explicitPayloadRoot || portablePayloadRoot(s, { root, env });
|
|
152
|
-
const desiredManifest = payloadRoot ? (
|
|
153
|
-
const
|
|
257
|
+
const payloadRoot = explicitPayloadRoot || portablePayloadRoot(s, { root, env, fetched });
|
|
258
|
+
const desiredManifest = payloadRoot ? payloadManifest(payloadRoot) : null;
|
|
259
|
+
const versionOnly = !payloadRoot && typeof version === "string" && version !== "";
|
|
260
|
+
const desiredVersion = desiredManifest?.version || (versionOnly ? version : null);
|
|
154
261
|
const id = `${s.plugin_name}@${s.marketplace_name}`;
|
|
155
262
|
const bin = whichOnPath(s.cli.bin, env);
|
|
156
263
|
const ownership = readJson(paths.ownership)?.[s.provenance_key] || null;
|
|
@@ -165,39 +272,152 @@ export function analysePortableRegistration(projectDir, s, { root, payloadRoot:
|
|
|
165
272
|
const others = [...new Set([...pluginIds(global), ...pluginIds(project)])]
|
|
166
273
|
.filter((other) => other !== id && other.split("@")[0] === s.plugin_name)
|
|
167
274
|
.map((other) => {
|
|
168
|
-
const g = global.get(pluginTable(other)) || null,
|
|
275
|
+
const g = global.get(pluginTable(other)) || null,
|
|
276
|
+
p = project.get(pluginTable(other)) || null;
|
|
169
277
|
return { id: other, enabled: typeof p?.enabled === "boolean" ? p.enabled : g?.enabled === true };
|
|
170
278
|
});
|
|
279
|
+
// A registration newer than what this run offers (the converge story's rule
|
|
280
|
+
// 6): set before the ladder, so a damaged one keeps it beside its real
|
|
281
|
+
// reason, and the cache is then read at the version the host registered —
|
|
282
|
+
// keyed on the desired version, a newer registration's integrity rungs
|
|
283
|
+
// checked the wrong cache directory.
|
|
284
|
+
const newer =
|
|
285
|
+
typeof ownership?.version === "string" &&
|
|
286
|
+
typeof desiredVersion === "string" &&
|
|
287
|
+
cmpPrecedence(ownership.version, desiredVersion) > 0;
|
|
171
288
|
const caches = cacheEntries(paths, s);
|
|
172
|
-
const installed = caches.find((e) => e.version === desiredVersion) || caches[0] || null;
|
|
173
|
-
const out = {
|
|
289
|
+
const installed = caches.find((e) => e.version === (newer ? ownership.version : desiredVersion)) || caches[0] || null;
|
|
290
|
+
const out = {
|
|
291
|
+
id,
|
|
292
|
+
paths,
|
|
293
|
+
path: paths.dir,
|
|
294
|
+
payloadRoot,
|
|
295
|
+
desiredVersion,
|
|
296
|
+
versionOnly,
|
|
297
|
+
bin,
|
|
298
|
+
ownership,
|
|
299
|
+
journal,
|
|
300
|
+
catalog,
|
|
301
|
+
market,
|
|
302
|
+
globalPlugin,
|
|
303
|
+
projectPlugin,
|
|
304
|
+
enabled,
|
|
305
|
+
others,
|
|
306
|
+
caches,
|
|
307
|
+
installed,
|
|
308
|
+
installPath: installed?.path || null,
|
|
309
|
+
installedVersion: installed?.version || null,
|
|
310
|
+
state: "absent",
|
|
311
|
+
reason: null,
|
|
312
|
+
produced: Boolean(desiredManifest),
|
|
313
|
+
contentDiffers: null,
|
|
314
|
+
newer,
|
|
315
|
+
};
|
|
174
316
|
const enabledOther = others.find((other) => other.enabled);
|
|
175
317
|
const nothing = !existsSync(paths.dir) && !market && !globalPlugin && !installed && !enabledOther;
|
|
176
|
-
if (!desiredManifest
|
|
177
|
-
|
|
318
|
+
if (!desiredManifest && !versionOnly)
|
|
319
|
+
return {
|
|
320
|
+
...out,
|
|
321
|
+
state: "unavailable",
|
|
322
|
+
reason: payloadRoot
|
|
323
|
+
? `${payloadRoot} is not a portable plugin root`
|
|
324
|
+
: `nothing to register from this run: ${harness?.display_name || "this harness"}'s plugin is registered from its distribution shell, not from the core`,
|
|
325
|
+
};
|
|
326
|
+
if (!bin && nothing)
|
|
327
|
+
return {
|
|
328
|
+
...out,
|
|
329
|
+
state: "unavailable",
|
|
330
|
+
reason: `\`${s.cli.bin}\` is not on PATH — the registration needs the host CLI`,
|
|
331
|
+
};
|
|
178
332
|
let desiredDigest = null;
|
|
179
|
-
|
|
333
|
+
if (!versionOnly) {
|
|
334
|
+
try {
|
|
335
|
+
desiredDigest = payloadDigest(payloadRoot);
|
|
336
|
+
} catch (e) {
|
|
337
|
+
return { ...out, state: "unavailable", reason: e.message };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
180
340
|
out.desiredDigest = desiredDigest;
|
|
181
|
-
if (enabledOther)
|
|
341
|
+
if (enabledOther)
|
|
342
|
+
return {
|
|
343
|
+
...out,
|
|
344
|
+
state: "conflict",
|
|
345
|
+
refusal: `${enabledOther.id} is already enabled; two ProjectStore plugins would load the same skills and hooks. Disable or remove that registration explicitly, then retry`,
|
|
346
|
+
};
|
|
182
347
|
// A journal the rollback could not prove names the run that left it, when,
|
|
183
348
|
// and its error, so the reader of the plan is not told recovery is assured
|
|
184
349
|
// (issue #28). Every other phase reads as before.
|
|
185
|
-
if (journal && !ignoreJournal)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
350
|
+
if (journal && !ignoreJournal)
|
|
351
|
+
return {
|
|
352
|
+
...out,
|
|
353
|
+
state: "stale",
|
|
354
|
+
reason:
|
|
355
|
+
journal.phase === "recovery-required"
|
|
356
|
+
? `a previous run${journal.failed_at ? ` (${journal.failed_at})` : ""} could not prove its restore: ${String(journal.error || "no error recorded").split("\n")[0]}; this run re-proves the previous state before applying this plan, and refuses if it cannot (${paths.journal})`
|
|
357
|
+
: "an interrupted registration has a recovery journal; the next confirmed run recovers it before applying this plan",
|
|
358
|
+
};
|
|
359
|
+
if (existsSync(paths.dir) && !ownership)
|
|
360
|
+
return {
|
|
361
|
+
...out,
|
|
362
|
+
state: "foreign",
|
|
363
|
+
refusal: `${paths.dir} exists without ${s.ownership_manifest}; a source this installer does not own holds the stable marketplace path`,
|
|
364
|
+
};
|
|
365
|
+
if (market?.source && !samePath(market.source, paths.dir))
|
|
366
|
+
return {
|
|
367
|
+
...out,
|
|
368
|
+
state: "foreign",
|
|
369
|
+
refusal: `${paths.globalConfig} points marketplace ${s.marketplace_name} at ${market.source}, not ${paths.dir}`,
|
|
370
|
+
};
|
|
190
371
|
if (nothing) return out;
|
|
191
372
|
let sourceDigest = null;
|
|
192
|
-
try {
|
|
373
|
+
try {
|
|
374
|
+
if (existsSync(paths.payload)) sourceDigest = payloadDigest(paths.payload);
|
|
375
|
+
} catch {}
|
|
193
376
|
out.sourceDigest = sourceDigest;
|
|
194
|
-
out.contentDiffers =
|
|
377
|
+
out.contentDiffers = versionOnly
|
|
378
|
+
? null
|
|
379
|
+
: Boolean(ownership?.digest) &&
|
|
380
|
+
(ownership.digest.sha256 !== desiredDigest.sha256 || ownership.digest.count !== desiredDigest.count);
|
|
195
381
|
if (!ownership || !catalog) return { ...out, state: "stale", reason: "the owned marketplace source is incomplete" };
|
|
196
|
-
if (ownership.version === desiredVersion && out.contentDiffers)
|
|
197
|
-
|
|
382
|
+
if (!versionOnly && ownership.version === desiredVersion && out.contentDiffers)
|
|
383
|
+
return {
|
|
384
|
+
...out,
|
|
385
|
+
state: "conflict",
|
|
386
|
+
refusal: `release ${desiredVersion} already owns this marketplace with a different payload digest`,
|
|
387
|
+
};
|
|
388
|
+
if (
|
|
389
|
+
!sourceDigest ||
|
|
390
|
+
sourceDigest.sha256 !== ownership.digest?.sha256 ||
|
|
391
|
+
sourceDigest.count !== ownership.digest?.count
|
|
392
|
+
)
|
|
393
|
+
return { ...out, state: "stale", reason: "the stable source does not match its recorded digest" };
|
|
198
394
|
if (!market) return { ...out, state: "stale", reason: "the host does not know the marketplace" };
|
|
199
|
-
if (!installed || installed.version !== ownership.version)
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
395
|
+
if (!installed || installed.version !== ownership.version)
|
|
396
|
+
return { ...out, state: "stale", reason: "the host cache does not contain the registered payload version" };
|
|
397
|
+
if (
|
|
398
|
+
!installed.digest ||
|
|
399
|
+
installed.digest.sha256 !== ownership.digest?.sha256 ||
|
|
400
|
+
installed.digest.count !== ownership.digest?.count
|
|
401
|
+
)
|
|
402
|
+
return {
|
|
403
|
+
...out,
|
|
404
|
+
state: "stale",
|
|
405
|
+
reason: "the materialised host cache does not match the registered payload digest",
|
|
406
|
+
};
|
|
407
|
+
// The newer rung, after the integrity rungs: a sound registration newer
|
|
408
|
+
// than this run is current and never staged over (rule 6). A damaged one
|
|
409
|
+
// stayed stale above, with its real reason; the planner skips it.
|
|
410
|
+
if (newer)
|
|
411
|
+
return {
|
|
412
|
+
...out,
|
|
413
|
+
state: "current",
|
|
414
|
+
reason: `the registration is at ${ownership.version}, newer than this package (${desiredVersion}) — not downgraded`,
|
|
415
|
+
};
|
|
416
|
+
if (ownership.version !== desiredVersion)
|
|
417
|
+
return { ...out, state: "stale", reason: `installed ${ownership.version}; package offers ${desiredVersion}` };
|
|
418
|
+
return {
|
|
419
|
+
...out,
|
|
420
|
+
state: "current",
|
|
421
|
+
reason: enabled ? null : "installed globally but disabled by effective configuration",
|
|
422
|
+
};
|
|
203
423
|
}
|
|
@@ -88,15 +88,18 @@ export const STALE_TEXT = Object.freeze({
|
|
|
88
88
|
|
|
89
89
|
// Contract 5's resolution wording (Ivan Morozov's), in one place for the
|
|
90
90
|
// verbs that refuse and the report that names.
|
|
91
|
-
export const FOREIGN_TEXT =
|
|
91
|
+
export const FOREIGN_TEXT =
|
|
92
|
+
"a file we did not write sits at our path — rename it if it is yours, or delete it to let install take the name";
|
|
92
93
|
|
|
93
94
|
const normalizeEol = (s) => s.replace(/\r\n/g, "\n");
|
|
94
95
|
|
|
95
96
|
function unsupportedFormat(format) {
|
|
96
97
|
return new Error(
|
|
97
98
|
`format "${format}" cannot carry a provenance line — a JSON surface is a ` +
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
`SHARED surface (install spec contracts 2 and 6), owned per entry, not stamped; ` +
|
|
100
|
+
`known stampable formats: ${Object.keys(DELIMITERS)
|
|
101
|
+
.filter((k) => DELIMITERS[k])
|
|
102
|
+
.join(", ")}`,
|
|
100
103
|
);
|
|
101
104
|
}
|
|
102
105
|
|
|
@@ -149,7 +152,8 @@ export function provenanceLine({ src, srcHash, pkg, project, render }) {
|
|
|
149
152
|
// whitespace would be emitted, fail to parse back, derive `foreign`, and
|
|
150
153
|
// make our own file immune to uninstall — refuse it here instead.
|
|
151
154
|
for (const [k, v] of Object.entries({ src, pkg })) {
|
|
152
|
-
if (/\s/.test(v))
|
|
155
|
+
if (/\s/.test(v))
|
|
156
|
+
throw new Error(`provenanceLine: field "${k}" may not contain whitespace (got ${JSON.stringify(v)})`);
|
|
153
157
|
}
|
|
154
158
|
if (!/^[0-9a-f]{12}$/.test(srcHash) || !/^[0-9a-f]{12}$/.test(render)) {
|
|
155
159
|
throw new Error("provenanceLine: srcHash and render must be 12 lowercase hex characters");
|
|
@@ -198,7 +202,9 @@ function insertionOffset(body, format) {
|
|
|
198
202
|
const m = /^---\n(?:[\s\S]*?\n)?---(?:\n|$)/.exec(body);
|
|
199
203
|
if (m) return m[0].length;
|
|
200
204
|
if (body.startsWith("---\n") || body === "---") {
|
|
201
|
-
throw new Error(
|
|
205
|
+
throw new Error(
|
|
206
|
+
"insertStamp: markdown body opens a frontmatter block that never closes — refusing to stamp before it",
|
|
207
|
+
);
|
|
202
208
|
}
|
|
203
209
|
}
|
|
204
210
|
if (format === "mjs" && body.startsWith("#!")) {
|
|
@@ -219,7 +225,9 @@ export function insertStamp(body, { format, harness, source, generator = DEFAULT
|
|
|
219
225
|
delimitersFor(format);
|
|
220
226
|
const text = normalizeEol(String(body));
|
|
221
227
|
if (bareTokenRe().test(text)) {
|
|
222
|
-
throw new Error(
|
|
228
|
+
throw new Error(
|
|
229
|
+
`insertStamp: the body already carries a bare ${PROVENANCE_TOKEN} line — it would survive into the installed file`,
|
|
230
|
+
);
|
|
223
231
|
}
|
|
224
232
|
const at = insertionOffset(text, format);
|
|
225
233
|
const block = banner(format, { harness, source, generator, remedy }) + PROVENANCE_TOKEN + "\n";
|
|
@@ -250,7 +258,10 @@ export function substituteProvenance(tokenized, { format, src, srcHash, pkg, pro
|
|
|
250
258
|
// read and be born "edited by hand" — a false accusation reinstalling cannot
|
|
251
259
|
// clear. The cost: an edit confined to such a quoted line does not move
|
|
252
260
|
// `render=`; strictly narrower harm than a permanent false positive.
|
|
253
|
-
export function stamp(
|
|
261
|
+
export function stamp(
|
|
262
|
+
body,
|
|
263
|
+
{ format, src, srcHash, pkg, project, harness, generator = DEFAULT_GENERATOR, remedy = DEFAULT_REMEDY },
|
|
264
|
+
) {
|
|
254
265
|
const tokenized = insertStamp(body, { format, harness, source: src, generator, remedy });
|
|
255
266
|
const render = renderHash(neutralizeProject(tokenizeProvenance(tokenized) ?? tokenized, project));
|
|
256
267
|
const text = substituteProvenance(tokenized, { format, src, srcHash, pkg, project, render });
|
|
@@ -336,7 +347,8 @@ export function deriveState({ file, sourceHash: srcNow, pkg, renderNowHash, proj
|
|
|
336
347
|
provenance,
|
|
337
348
|
writtenBy: provenance ? provenance.project : null,
|
|
338
349
|
sameProject: Boolean(
|
|
339
|
-
provenance &&
|
|
350
|
+
provenance &&
|
|
351
|
+
typeof project === "string" &&
|
|
340
352
|
stripTrailingSlash(provenance.project) === stripTrailingSlash(project),
|
|
341
353
|
),
|
|
342
354
|
});
|
|
@@ -348,8 +360,12 @@ export function deriveState({ file, sourceHash: srcNow, pkg, renderNowHash, proj
|
|
|
348
360
|
const parsed = parseProvenance(file.text);
|
|
349
361
|
if (!parsed) return result("foreign");
|
|
350
362
|
const provenance = {
|
|
351
|
-
v: parsed.v,
|
|
352
|
-
|
|
363
|
+
v: parsed.v,
|
|
364
|
+
src: parsed.src,
|
|
365
|
+
srcHash: parsed.srcHash,
|
|
366
|
+
pkg: parsed.pkg,
|
|
367
|
+
project: parsed.project,
|
|
368
|
+
render: parsed.render,
|
|
353
369
|
};
|
|
354
370
|
|
|
355
371
|
// A newer grammar than this parser knows. The spec does not say; reporting
|