@sleepy_panda_srl/portulan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +87 -0
  4. package/cli/README.md +337 -0
  5. package/cli/compile.mjs +2209 -0
  6. package/cli/control-chars.mjs +570 -0
  7. package/cli/discover.mjs +985 -0
  8. package/cli/doctor.mjs +2803 -0
  9. package/cli/feedback.mjs +970 -0
  10. package/cli/gate.mjs +271 -0
  11. package/cli/index.mjs +1390 -0
  12. package/cli/init.mjs +1729 -0
  13. package/cli/librarian.mjs +1384 -0
  14. package/cli/manifest.mjs +59 -0
  15. package/cli/new.mjs +803 -0
  16. package/cli/pack-version.mjs +543 -0
  17. package/cli/plugin-lint.mjs +1566 -0
  18. package/cli/portulan.mjs +266 -0
  19. package/cli/recipe-set.mjs +425 -0
  20. package/cli/rule-carriers.mjs +489 -0
  21. package/cli/skills-set.mjs +715 -0
  22. package/cli/stop-gate.mjs +434 -0
  23. package/cli/upgrade.mjs +897 -0
  24. package/cli/vendor.mjs +1504 -0
  25. package/core/README.md +46 -0
  26. package/core/engine.md +47 -0
  27. package/core/operating/autonomy.md +100 -0
  28. package/core/operating/evolution.md +84 -0
  29. package/core/operating/loop.md +83 -0
  30. package/core/operating/memory.md +161 -0
  31. package/core/operating/safety.md +42 -0
  32. package/core/operating/verification.md +76 -0
  33. package/core/personas/README.md +40 -0
  34. package/core/personas/implementer.md +51 -0
  35. package/core/personas/librarian.md +73 -0
  36. package/core/personas/reviewer.md +52 -0
  37. package/core/skills/README.md +52 -0
  38. package/core/skills/clarify/SKILL.md +42 -0
  39. package/core/skills/codify/SKILL.md +65 -0
  40. package/core/skills/consolidate/SKILL.md +121 -0
  41. package/core/templates/gate-policy.md +76 -0
  42. package/core/templates/handoff.md +29 -0
  43. package/core/templates/memory-entry.md +44 -0
  44. package/core/templates/pack.md +94 -0
  45. package/core/templates/persona.md +69 -0
  46. package/core/templates/proposal.md +27 -0
  47. package/core/templates/repo-card.md +28 -0
  48. package/core/templates/skill.md +57 -0
  49. package/core/templates/task.md +26 -0
  50. package/core/templates/workspace.md +81 -0
  51. package/package.json +41 -0
  52. package/packs/.claude-plugin/plugin.json +22 -0
  53. package/packs/README.md +61 -0
  54. package/packs/rituals/README.md +13 -0
  55. package/packs/rituals/checkpoints/README.md +105 -0
  56. package/packs/rituals/checkpoints/pack.json +36 -0
  57. package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
  58. package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
  59. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
  60. package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
  61. package/packs/stacks/README.md +8 -0
  62. package/packs/tools/README.md +17 -0
  63. package/packs/tools/github/README.md +65 -0
  64. package/packs/tools/github/pack.json +29 -0
  65. package/packs/tools/github/verify/README.md +51 -0
  66. package/packs/tools/github/verify/actions-pinned.sh +132 -0
  67. package/spec/README.md +399 -0
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
  70. package/spec/migrations/README.md +95 -0
  71. package/spec/pack.schema.json +201 -0
  72. package/spec/slots.md +796 -0
  73. package/spec/workspace.schema.json +438 -0
@@ -0,0 +1,897 @@
1
+ #!/usr/bin/env node
2
+ // `upgrade` — migrate a workspace, in either residence.
3
+ //
4
+ // The eighth of the eight subcommands `docs/vision.md` names, and the last to be built. Row 7 of
5
+ // `docs/plan.md`: *"`upgrade` migrates a workspace in **either** residence."*
6
+ //
7
+ // ## What a migration is, and where it lives
8
+ //
9
+ // Not here. `spec/migrations/` is the contract — a directory of steps, each a module — because a
10
+ // migration is part of the **Workspace Definition** rather than part of a tool, and `spec/` ships in
11
+ // `package.json`'s `files` so it would reach an `npx` user if the package were published — publishing
12
+ // is Gated and has not happened, so there is no such user today, and whether there ever is remains
13
+ // open (#242). See `../.portulan/identity.md`. This file is the runner: it decides which
14
+ // steps a workspace owes, applies them, and grades the result with the real validator.
15
+ //
16
+ // **Two kinds of step**, by the maintainer's ruling of 2026-08-12: a `version` step migrates a
17
+ // Workspace Definition MAJOR, and a `repair` fixes something a rewriter owes a workspace it touched.
18
+ // Without the second kind this tool would be machinery with no subject — the train's only MAJOR
19
+ // migration is `1.0 → 2.0` and **nothing in this repository declares 1.0**.
20
+ //
21
+ // ## The three states a workspace can be in relative to this bundle, and why two of them are refusals
22
+ //
23
+ // `doctor` refuses a manifest whose MAJOR differs from the schema's — in **either direction** — and
24
+ // refuses a MINOR ahead of it. The direction matters here in a way it does not there:
25
+ //
26
+ // - **behind by a MAJOR** — the case this tool exists for. `doctor` cannot grade it, so the pre-state
27
+ // gate below is skipped and the result is graded post-state only.
28
+ // - **ahead** (MAJOR or MINOR) — **exit 2**. This bundle is older than the workspace; nothing here
29
+ // knows the contract it was written against, and the remedy is to upgrade the CLI. Branching on the
30
+ // *fact* of `doctor`'s refusal rather than on the direction sent such a workspace into the plan,
31
+ // where every step answered *not owed* and the run exited **0 — nothing owed**: a green rendered by
32
+ // a tool that could not read the workspace. Caught at the session-open checkpoint.
33
+ // - **level, or behind by a MINOR** — gradeable, and graded before anything is planned.
34
+ //
35
+ // The same hole has a second mouth: a workspace behind by a MAJOR that **no step reaches** would also
36
+ // plan to nothing and exit 0. That is refused too, further down, for the same reason.
37
+ //
38
+ // ## Exit codes
39
+ //
40
+ // `0` succeeded — nothing owed, or applied and green · `1` a verdict — steps owed under `--check`, a
41
+ // red workspace before or after, **and a pointer that does not resolve** (`not-installed` or
42
+ // `ambiguous`, which is `discover.mjs`'s own mapping rather than a second opinion about it) · `2`
43
+ // could not run, including a step that **could not tell**, either direction this bundle cannot help
44
+ // with, and a `--write` aimed at an installed workspace.
45
+ //
46
+ // Zero dependencies, ESM, no build step — the session-0 ruling.
47
+
48
+ import fs from "node:fs";
49
+ import path from "node:path";
50
+ import { fileURLToPath, pathToFileURL } from "node:url";
51
+
52
+ import { inspect, schemaVersion } from "./doctor.mjs";
53
+ import { resolveGovernor } from "./discover.mjs";
54
+ // The guarded walk, not a fourth implementation of one. `vendor`'s `walk` already refuses a symlink
55
+ // anywhere under a workspace — rule 2 of the three a tool writing into somebody's tree owes — and
56
+ // three `collisions()` implementing one rule is already an open complaint against this repository
57
+ // (#169). Its errors are re-worded below so a reader is never told `vendor` failed.
58
+ import { walk } from "./vendor.mjs";
59
+
60
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
61
+ const BUNDLE = path.resolve(HERE, "..");
62
+ const MIGRATIONS = path.join(BUNDLE, "spec", "migrations");
63
+
64
+ /** Distinguishes one run's staging files from another's, alongside the pid. */
65
+ let stagingSeq = 0;
66
+
67
+ /** Everything that means `upgrade` could not run. Carries no verdict about a workspace. */
68
+ export class UpgradeError extends Error {}
69
+
70
+ /**
71
+ * The Workspace Definition version **this bundle implements**, derived from the schema's `$id`.
72
+ *
73
+ * Derived rather than written down, and through `doctor`'s own exported reader, so this file is not a
74
+ * second carrier of the current version. A literal here would be wrong the hour the schema moves —
75
+ * which is the defect class this repository has repaired more often than any other.
76
+ */
77
+ export function bundleSpec({ schemaPath } = {}) {
78
+ const file = schemaPath ?? path.join(BUNDLE, "spec", "workspace.schema.json");
79
+ // The THIRD site of the read-versus-parse rule, found by sweeping after Copilot named the second.
80
+ // This one reads a file the bundle ships rather than one a user wrote, so a malformed schema is a
81
+ // broken install rather than somebody's typo — which changes who the sentence is for, not whether
82
+ // it should be accurate. Two carriers here were already one too many.
83
+ let text;
84
+ try {
85
+ text = fs.readFileSync(file, "utf8");
86
+ } catch (cause) {
87
+ throw new UpgradeError(`the Workspace Definition at ${file} could not be read — ${cause.code ?? cause.message}`);
88
+ }
89
+ let schema;
90
+ try {
91
+ schema = JSON.parse(text);
92
+ } catch (cause) {
93
+ throw new UpgradeError(`the Workspace Definition at ${file} does not parse as JSON — ${cause.message}. This bundle's own schema is unreadable, which is a broken install rather than a fault in any workspace`);
94
+ }
95
+ return schemaVersion(schema);
96
+ }
97
+
98
+ /**
99
+ * The chain, in id order, read from the directory rather than from a list.
100
+ *
101
+ * There is no registry to keep in sync with the tree: a step is a file, and the filename is the
102
+ * order. A hand-maintained list beside a directory is the second carrier this repository keeps
103
+ * repairing out of its own design.
104
+ */
105
+ export async function loadSteps({ dir = MIGRATIONS } = {}) {
106
+ let names;
107
+ try {
108
+ names = fs.readdirSync(dir);
109
+ } catch (cause) {
110
+ throw new UpgradeError(`the migrations directory at ${dir} could not be read — ${cause.code ?? cause.message}`);
111
+ }
112
+ const steps = [];
113
+ for (const name of names.filter((n) => n.endsWith(".mjs")).sort()) {
114
+ const file = path.join(dir, name);
115
+ let module;
116
+ try {
117
+ module = await import(pathToFileURL(file).href);
118
+ } catch (cause) {
119
+ throw new UpgradeError(`${file} could not be loaded — ${cause.message}`);
120
+ }
121
+ const step = module.step;
122
+ if (step === null || typeof step !== "object") {
123
+ throw new UpgradeError(`${file} exports no \`step\` object — every module in ${dir} is a migration step`);
124
+ }
125
+ if (step.id !== path.basename(name, ".mjs")) {
126
+ // The id IS the order, and the order is the filename. A step whose id disagrees with its
127
+ // file would sort by one and report as the other.
128
+ throw new UpgradeError(`${file} declares id \`${step.id}\`, which is not its filename — the id is the chain's order`);
129
+ }
130
+ steps.push(step);
131
+ }
132
+ return steps;
133
+ }
134
+
135
+ /**
136
+ * Read a workspace directory into the view `spec/migrations/README.md` promises a step.
137
+ *
138
+ * Read and parse stay two failures with two repairs: a `SyntaxError` carries no `.code`, and
139
+ * reporting it under *could not be read* sends somebody with a malformed manifest to look at
140
+ * permissions.
141
+ */
142
+ export function readWorkspace(dir) {
143
+ const root = path.resolve(dir);
144
+ const file = path.join(root, "workspace.json");
145
+
146
+ let text;
147
+ try {
148
+ text = fs.readFileSync(file, "utf8");
149
+ } catch (error) {
150
+ // Only `ENOENT` means absent. An `EACCES` is a question that could not be answered, and
151
+ // answering it *nothing there* is the fail-open.
152
+ return {
153
+ ok: false,
154
+ code: 2,
155
+ reason:
156
+ error.code === "ENOENT"
157
+ ? `${file} does not exist — that is not a workspace directory`
158
+ : `${file} could not be read — ${error.code ?? error.message}`,
159
+ };
160
+ }
161
+
162
+ let manifest;
163
+ try {
164
+ manifest = JSON.parse(text);
165
+ } catch (error) {
166
+ return { ok: false, code: 2, reason: `${file} does not parse as JSON — ${error.message}` };
167
+ }
168
+
169
+ let files;
170
+ try {
171
+ files = walk(root).map((entry) => entry.rel);
172
+ } catch (error) {
173
+ // **Framed, never rewritten.** `walk` is `vendor`'s and its refusals speak in `vendor`'s voice
174
+ // — *"this refuses to copy through one"* — which is the wrong verb for a tool that is
175
+ // migrating. The first attempt at this substituted the wording, and it was **inert**: the
176
+ // pattern said `refusing to copy through` and the message says `refuses`. A fix that could not
177
+ // fire, under a comment asserting it did — this repository's dominant defect class, committed
178
+ // here in the change that cites it. Copilot's promoted note, round 2 on #231.
179
+ //
180
+ // Rewriting was the wrong shape anyway: matching another module's sentence makes this file a
181
+ // second carrier of `vendor`'s wording, free to rot the next time that sentence is edited. So
182
+ // the prefix says who declined and why, and the borrowed sentence is left exactly as its owner
183
+ // wrote it.
184
+ return { ok: false, code: 2, reason: `${root} could not be read for migration — ${error.message}` };
185
+ }
186
+
187
+ const cache = new Map();
188
+ return {
189
+ ok: true,
190
+ ws: {
191
+ dir: root,
192
+ manifest,
193
+ manifestText: text,
194
+ list: () => files,
195
+ // The READ sibling of the write guard. `list()` only ever yields paths `walk` enumerated
196
+ // inside the workspace, so a step iterating it is safe — but `read` takes whatever a step
197
+ // hands it, and the step contract does not constrain that. A tool that refused to WRITE
198
+ // outside the workspace while reading anything on the disk would be guarding one half of
199
+ // the same rule. Found by sweeping after Copilot caught the `restore` sibling.
200
+ read: (rel) => {
201
+ const at = inside(root, rel);
202
+ if (at === null) throw new UpgradeError(`\`${rel}\` resolves outside ${root} — refusing to read there`);
203
+ if (!cache.has(rel)) cache.set(rel, fs.readFileSync(at, "utf8"));
204
+ return cache.get(rel);
205
+ },
206
+ },
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Which workspace does this run act on?
212
+ *
213
+ * A governing kind is its own answer. A `pointer` is resolved through `cli/discover.mjs`, and the
214
+ * resolver's **own `sentence`** travels back with the verdict — one carrier, so every surface that
215
+ * reports a resolution prints the same words rather than four paraphrases of them.
216
+ */
217
+ export async function resolveTarget(dir, options = {}) {
218
+ const root = path.resolve(dir);
219
+ const file = path.join(root, "workspace.json");
220
+ // **Read and parse are two failures with two repairs, and this had them as one.** `readWorkspace`
221
+ // twelve lines up already keeps them apart — a `SyntaxError` carries no `.code`, so a malformed
222
+ // manifest fell through to the *could not be read* sentence and sent the reader to look at
223
+ // permissions and paths instead of at their JSON. One rule, two sites, enforced at one: `0020`,
224
+ // and the second site is inside the file whose own contract states the distinction.
225
+ // Copilot's suppressed note, round 7 on #231.
226
+ const fail = (sentence) => ({ state: "could-not-look", dir: null, resolution: null, sentence });
227
+
228
+ let text;
229
+ try {
230
+ text = fs.readFileSync(file, "utf8");
231
+ } catch (error) {
232
+ return fail(
233
+ error.code === "ENOENT"
234
+ ? `${file} does not exist — that is not a workspace directory`
235
+ : `${file} could not be read — ${error.code ?? error.message}`,
236
+ );
237
+ }
238
+
239
+ let manifest;
240
+ try {
241
+ manifest = JSON.parse(text);
242
+ } catch (error) {
243
+ return fail(`${file} does not parse as JSON — ${error.message}`);
244
+ }
245
+ if (manifest?.kind !== "pointer") {
246
+ // **Not `discover`'s wording, deliberately, and the difference is the argument.** `discover`
247
+ // answers *this repository's workspace resides here* because it is asked about a repository's
248
+ // own `.portulan` — that is its question. This tool takes ANY workspace directory: `examples/`,
249
+ // a feed-side portfolio, a path a user typed. Borrowing the sentence would have made a claim
250
+ // about ownership from the mere absence of `kind: pointer`, which is a different fact from the
251
+ // one that was checked. So it says what it actually established. Copilot, round 1 on #231.
252
+ const kind = typeof manifest?.kind === "string" ? `\`${manifest.kind}\`` : "a governing";
253
+ return {
254
+ state: "resides-here",
255
+ dir: root,
256
+ resolution: null,
257
+ sentence: `\`${root}\` carries ${kind} manifest, so it is the workspace this run acts on`,
258
+ };
259
+ }
260
+ const resolution = resolveGovernor(manifest.governed_by, options);
261
+ return {
262
+ state: resolution.state,
263
+ dir: resolution.state === "resolved" ? resolution.root : null,
264
+ resolution,
265
+ sentence: resolution.sentence,
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Ask every step whether it is owed. Three answers, and the third is not a shorter plan.
271
+ *
272
+ * **A step that THROWS is `could not tell`, not a crash.** The three-valued answer exists precisely so
273
+ * that "this step could not work out whether it applies" has somewhere to go other than a green — and
274
+ * an unhandled exception routed around it entirely, taking the whole tool down instead of producing
275
+ * the designed exit 2. A step is a module from `spec/migrations/`, so a bug in one, or an unexpected
276
+ * read error inside it, must degrade to the answer the contract already has a word for.
277
+ * Copilot's promoted note, round 6 on #231.
278
+ */
279
+ export async function planFor(ws, ctx, steps) {
280
+ const entries = [];
281
+ let owed = 0;
282
+ let unknown = 0;
283
+ for (const step of steps) {
284
+ let answer;
285
+ try {
286
+ answer = await step.owed(ws, ctx);
287
+ // **The VALUE, not the presence of the key.** A first cut accepted any object carrying an
288
+ // `owed` property, which let `{ owed: "yes" }` or `{ owed: undefined }` through: neither is
289
+ // `true` and neither is `null`, so the arithmetic below counted them as **not owed** — a
290
+ // false green produced by the very guard added to prevent one. Checking that a field exists
291
+ // is not checking that its value is one this contract defines.
292
+ // Copilot, round 8 on #231.
293
+ if (answer === null || typeof answer !== "object" || ![true, false, null].includes(answer.owed)) {
294
+ answer = { owed: null, because: `${step.id} returned no usable verdict from \`owed\` (${JSON.stringify(answer?.owed)})` };
295
+ } else if (typeof answer.because !== "string" || answer.because === "") {
296
+ // The reason is what a reader is given when the run refuses. A missing one is not
297
+ // grounds to discard a good verdict, so the verdict stands and the sentence is named
298
+ // as absent rather than printed as `undefined`.
299
+ answer = { owed: answer.owed, because: `${step.id} gave no reason` };
300
+ }
301
+ } catch (error) {
302
+ answer = { owed: null, because: `${step.id} threw while deciding whether it is owed — ${error.message}` };
303
+ }
304
+ entries.push({ step, owed: answer.owed, because: answer.because });
305
+ if (answer.owed === null) unknown += 1;
306
+ else if (answer.owed === true) owed += 1;
307
+ }
308
+ return { entries, owed, unknown };
309
+ }
310
+
311
+ /**
312
+ * Resolve a step-supplied relative path inside `root`, or `null` if it escapes.
313
+ *
314
+ * **One carrier, and it has two callers because it has to.** `applyEdits` writes and `restore`
315
+ * writes back, and a guard on the first alone leaves the second reachable with the same value — one
316
+ * rule enforced at one of its two sites, which is `0020` exactly. That is not hypothetical here:
317
+ * `applyEdits` got the guard first and `restore` did not, and Copilot found the sibling before this
318
+ * session's own sweep did.
319
+ *
320
+ * `resolve`, never `join`: `path.join(root, "/etc/passwd")` CONCATENATES to `<root>/etc/passwd`,
321
+ * which hides an absolute path from the check instead of exposing it. And resolution rather than
322
+ * pattern-matching, because every interesting escape parses fine and only fails once resolved.
323
+ */
324
+ export function inside(root, rel) {
325
+ const resolved = path.resolve(root, rel);
326
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return null;
327
+ return resolved;
328
+ }
329
+
330
+ /**
331
+ * The other half of containment: the path on DISK, not the path as a string.
332
+ *
333
+ * `inside()` is lexical. A symlink at any **parent component** makes the write escape while the
334
+ * string still resolves inside the root — `<root>/link/a.md` passes every character-level test and
335
+ * lands wherever `link` points. `{ flag: "wx" }` guards only the final component. This is the rule
336
+ * `cli/vendor.mjs` states as rule 2 and enforces by walking the chain, and `cli/skills-set.mjs`
337
+ * spells the same walk; this file had inherited the rule and implemented only its lexical half.
338
+ * Copilot, round 11 on #231.
339
+ *
340
+ * Components that do not exist are fine — they are about to be created, and nothing can be followed
341
+ * through a path that is not there — so the walk stops at the first `ENOENT`. Anything else is a
342
+ * question that could not be answered, and is refused rather than assumed absent: rule 3.
343
+ *
344
+ * The root itself is not checked, deliberately, and that is `vendor`'s boundary: **above the named
345
+ * path, resolve — the caller named it, and on macOS `os.tmpdir()` runs through `/var`. At it and
346
+ * below it, refuse.**
347
+ *
348
+ * Returns a reason to refuse, or `null` when the chain is clean.
349
+ */
350
+ function linkOnPath(root, resolved) {
351
+ const rel = path.relative(root, resolved);
352
+ if (rel === "") return null;
353
+ let at = root;
354
+ for (const part of rel.split(path.sep)) {
355
+ at = path.join(at, part);
356
+ let stat;
357
+ try {
358
+ stat = fs.lstatSync(at);
359
+ } catch (error) {
360
+ if (error.code === "ENOENT") return null;
361
+ return `${at} could not be examined — ${error.code ?? error.message}`;
362
+ }
363
+ if (stat.isSymbolicLink()) return `${at} is a symlink, and this refuses to write through one`;
364
+ }
365
+ return null;
366
+ }
367
+
368
+ /**
369
+ * Remove directories this run created, deepest first, stopping at the first that will not go.
370
+ *
371
+ * **One carrier, two callers.** `applyEdits` needs it on every failure path between creating a
372
+ * directory and recording the snapshot that would have owned it, and `restore` needs it when undoing
373
+ * a file that did not exist. Two copies of this loop would be one rule at two sites — and the second
374
+ * site is precisely where the first copy was missing.
375
+ *
376
+ * Stops rather than forces: a directory that will not `rmdir` is one that is not empty, which means
377
+ * something else is in it and it was not ours to remove after all.
378
+ *
379
+ * **`ENOENT` is not that case, and stopping on it was a bug.** A directory already gone — removed out
380
+ * of band, or by a `rm -rf` of a subtree — satisfies the goal rather than blocking it, and treating it
381
+ * as an obstacle abandoned every parent above it while they may well have been empty. Absence means
382
+ * *done*, not *stop*. Copilot's promoted note, round 6 on #231.
383
+ */
384
+ function unwindDirs(dirs) {
385
+ for (const made of [...dirs].reverse()) {
386
+ try {
387
+ fs.rmdirSync(made);
388
+ } catch (error) {
389
+ if (error.code === "ENOENT") continue;
390
+ break;
391
+ }
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Write the edits, keeping the previous contents so a red verdict can be undone.
397
+ *
398
+ * Each file lands by write-temp-then-rename, so no file is ever half-written. **The mode is carried
399
+ * across**: `rename(2)` replaces the inode, and `verify/index.sh` is executable — a repair that left
400
+ * the rail unexecutable would have broken the thing it exists to fix.
401
+ *
402
+ * **A parent directory is created where an edit names one that does not exist**, and the directories
403
+ * created are recorded so a rollback can remove them again. Neither step shipped today writes a new
404
+ * file, so nothing exercised this in production — which is exactly why it was worth fixing rather than
405
+ * leaving: a later step adding a nested file would have failed `ENOENT` on a perfectly valid edit.
406
+ * Copilot's promoted note, round 2 on #231.
407
+ */
408
+ export function applyEdits(dir, edits, options = {}) {
409
+ // `write` is an injection point for the suite, the way `init` injects its reader and `skills-set`
410
+ // injects its manifest. No production caller passes it. It exists because the window this guards —
411
+ // `mkdir` succeeds, the write then fails — is a disk-full or permissions race that cannot be
412
+ // staged honestly on a real filesystem, and a failure path with no test is how the orphan it
413
+ // guards against got here in the first place.
414
+ const write = options.write ?? fs.writeFileSync;
415
+ const root = path.resolve(dir);
416
+ const snapshots = [];
417
+ for (const edit of edits) {
418
+ // **A path built from somebody else's text is contained before it is opened.** A step's
419
+ // `edit.file` is a value this tool did not author — an absolute path, or one climbing out
420
+ // with `..`, would have this writing outside the workspace it was pointed at. Nothing in the
421
+ // step contract prevents either, and a schema could not: the value is computed, not declared.
422
+ //
423
+ // This repository has already paid for the class once, at a persona's free-text `name`, where
424
+ // `../../poison` had `doctor` read, grade and GREEN a file outside the tree. `cli/compile.mjs`
425
+ // guards the same way. Copilot, round 4 on #231.
426
+ const file = inside(root, edit.file);
427
+ if (file === null) {
428
+ return {
429
+ ok: false,
430
+ snapshots,
431
+ reason: `\`${edit.file}\` resolves outside ${root} — refusing to write there`,
432
+ };
433
+ }
434
+ const linked = linkOnPath(root, file);
435
+ if (linked !== null) return { ok: false, snapshots, reason: linked };
436
+
437
+ let previous = null;
438
+ let mode = null;
439
+ try {
440
+ previous = fs.readFileSync(file, "utf8");
441
+ mode = fs.lstatSync(file).mode;
442
+ } catch (error) {
443
+ if (error.code !== "ENOENT") {
444
+ return { ok: false, snapshots, reason: `${file} could not be read before writing — ${error.code ?? error.message}` };
445
+ }
446
+ }
447
+
448
+ // Shallowest first, so `unwindDirs` can take them in reverse. `lstat`, never `existsSync`:
449
+ // the latter follows links and answers *false* for an unreadable path, which would turn "I
450
+ // could not look" into "it is not there" — rule 3, at a third site.
451
+ const wanted = [];
452
+ for (let at = path.dirname(file); at.startsWith(root) && at !== root; at = path.dirname(at)) {
453
+ try {
454
+ fs.lstatSync(at);
455
+ break;
456
+ } catch (error) {
457
+ if (error.code !== "ENOENT") {
458
+ return { ok: false, snapshots, reason: `${at} could not be examined — ${error.code ?? error.message}` };
459
+ }
460
+ wanted.unshift(at);
461
+ }
462
+ }
463
+
464
+ // **What was actually made**, not what was wanted. The snapshot is pushed only after a
465
+ // successful rename, so every failure between here and there has to clean up its own
466
+ // directories or they are orphaned — created, unrecorded, and invisible to `restore`. That
467
+ // is the leak Copilot's promoted note found in the fix that added them, one round earlier.
468
+ const created = [];
469
+ try {
470
+ for (const dir of wanted) {
471
+ try {
472
+ fs.mkdirSync(dir);
473
+ created.push(dir);
474
+ } catch (error) {
475
+ // A directory created between the `lstat` probe above and this line satisfies the
476
+ // goal rather than defeating it — but only if what appeared is a DIRECTORY. An
477
+ // `EEXIST` from a file of that name is a real failure, and swallowing both would
478
+ // be the fail-open. Not recorded in `created`: this run did not make it, so this
479
+ // run does not get to remove it. Copilot's promoted note, round 6.
480
+ if (error.code !== "EEXIST" || !fs.lstatSync(dir).isDirectory()) throw error;
481
+ }
482
+ }
483
+ } catch (error) {
484
+ unwindDirs(created);
485
+ return { ok: false, snapshots, reason: `${path.dirname(file)} could not be created — ${error.code ?? error.message}` };
486
+ }
487
+
488
+ // **Unique per edit, not a fixed suffix.** Two `upgrade --write` runs over one workspace —
489
+ // concurrent CI invocations, or a human beside a pipeline — would otherwise race on the same
490
+ // staging path and lose or corrupt an edit. Still in the same directory, so the `rename` stays
491
+ // atomic. Copilot, round 6 on #231.
492
+ const staging = `${file}.portulan-upgrade.${process.pid}.${stagingSeq++}`;
493
+ try {
494
+ // **Exclusive create.** The staging path is a path `walk` never saw — it does not exist
495
+ // when the workspace is read — so the containment guard above says nothing about it. With
496
+ // default flags a symlink planted there would be **followed**, writing outside the
497
+ // workspace and defeating that guard entirely; a leftover file would be clobbered. `wx`
498
+ // fails with `EEXIST` on anything already at the path, symlink included, which is the
499
+ // fail-closed direction and rule 2 of the three a tool writing into somebody's tree owes.
500
+ // Copilot, round 10 on #231.
501
+ write(staging, edit.next, { flag: "wx" });
502
+ if (mode !== null) fs.chmodSync(staging, mode);
503
+ fs.renameSync(staging, file);
504
+ } catch (error) {
505
+ try {
506
+ fs.rmSync(staging, { force: true });
507
+ } catch {
508
+ /* the staging file is the lesser problem; the write failure is what gets reported */
509
+ }
510
+ unwindDirs(created);
511
+ return { ok: false, snapshots, reason: `${file} could not be written — ${error.code ?? error.message}` };
512
+ }
513
+ snapshots.push({ file: edit.file, previous, mode, created });
514
+ }
515
+ return { ok: true, snapshots };
516
+ }
517
+
518
+ /**
519
+ * Put back what `applyEdits` replaced.
520
+ *
521
+ * **It reports what it did not restore rather than claiming the tree is clean.** "The workspace is
522
+ * exactly as it was" is a promise a mid-restore `EACCES` breaks, and a rollback that lies about
523
+ * having succeeded is worse than one that failed loudly.
524
+ */
525
+ export function restore(dir, snapshots) {
526
+ const root = path.resolve(dir);
527
+ const restored = [];
528
+ const failed = [];
529
+ // **Reverse order — an unwind runs against the stack that made it.** Two edits landing in the same
530
+ // newly-created directory record it as `created` on the FIRST of them only; forwards, that
531
+ // snapshot's `rmdir` fails because the second file is still there, and the directory is left
532
+ // behind. Backwards, the second file goes first and the directory is empty when its turn comes.
533
+ // Found by sweeping this file for the sibling of the note that added the directories, rather than
534
+ // by a round that raised it.
535
+ for (const snapshot of [...snapshots].reverse()) {
536
+ // The SIBLING of the guard in `applyEdits`, and it is here because it was missing.
537
+ // `applyEdits` got containment first and this did not — one rule at one of its two sites,
538
+ // which is `0020`, in the change whose own commit message was about sweeping for siblings.
539
+ // Copilot found it; the sweep did not. A snapshot normally comes from `applyEdits` and is
540
+ // therefore already contained, but this function is exported and takes them from a caller.
541
+ const file = inside(root, snapshot.file);
542
+ if (file === null || linkOnPath(root, file) !== null) {
543
+ failed.push(snapshot.file);
544
+ continue;
545
+ }
546
+ try {
547
+ if (snapshot.previous === null) {
548
+ fs.rmSync(file, { force: true });
549
+ // And the directories this run had to create for it. Leaving them behind would make
550
+ // "the workspace is exactly as it was" false in a second way — quieter than a stray
551
+ // file, and still not true.
552
+ unwindDirs(snapshot.created ?? []);
553
+ } else {
554
+ // The sibling: a rollback writes back to a path that existed when the run began, but
555
+ // "existed then" is not "is a regular file now". Refusing to write through a link
556
+ // here costs nothing and keeps the rule whole at both write sites.
557
+ const at = fs.lstatSync(file, { throwIfNoEntry: false });
558
+ if (at?.isSymbolicLink()) throw new UpgradeError(`${file} is a symlink — refusing to restore through it`);
559
+ fs.writeFileSync(file, snapshot.previous);
560
+ if (snapshot.mode !== null && snapshot.mode !== undefined) fs.chmodSync(file, snapshot.mode);
561
+ }
562
+ restored.push(snapshot.file);
563
+ } catch {
564
+ failed.push(snapshot.file);
565
+ }
566
+ }
567
+ if (failed.length) {
568
+ return {
569
+ ok: false,
570
+ restored,
571
+ failed,
572
+ reason: `${failed.length} file(s) could not be put back: ${failed.join(", ")}`,
573
+ };
574
+ }
575
+ return { ok: true, restored, failed };
576
+ }
577
+
578
+ export function usage() {
579
+ return [
580
+ "portulan upgrade — migrate a workspace to this bundle's Workspace Definition",
581
+ "",
582
+ " portulan upgrade [--check | --write] [--tree <path>] <workspace-dir>",
583
+ "",
584
+ " (no flag) print the steps this workspace owes and write nothing",
585
+ " --check exit 1 if any step is owed — the rail a pipeline runs",
586
+ " --write apply the chain, then grade the result with doctor",
587
+ " --tree the value a 1.0 `repository` workspace needs and this will not guess",
588
+ "",
589
+ // Precisely which half is refused. The older wording — \"never inside the install\" — read as
590
+ // *this tool does not touch an installed workspace*, and a bare or `--check` run DOES read one
591
+ // to work out what it owes. Only writing is refused. Copilot's promoted note, round 3.
592
+ "A pointer is resolved through the host's installed-plugin record; the installed workspace is",
593
+ "read and reported on, and `--write` is refused there — migrate it at its own directory.",
594
+ "",
595
+ "Exit codes: 0 succeeded · 1 a red verdict · 2 could not run.",
596
+ ].join("\n");
597
+ }
598
+
599
+ const KNOWN_FLAGS = new Set(["--check", "--write", "--help", "-h"]);
600
+
601
+ export async function run(argv = [], options = {}) {
602
+ const stdout = options.stdout ?? process.stdout;
603
+ const stderr = options.stderr ?? process.stderr;
604
+ const say = (line) => stdout.write(`${line}\n`);
605
+ const warn = (line) => stderr.write(`${line}\n`);
606
+ const env = options.env ?? process.env;
607
+
608
+ const positional = [];
609
+ const flags = [];
610
+ let tree = null;
611
+ for (let i = 0; i < argv.length; i += 1) {
612
+ const arg = argv[i];
613
+ if (arg === "--tree") {
614
+ // **Three refusals, matching the three parsers that already own this rule** —
615
+ // `cli/init.mjs`, `cli/new.mjs` and `cli/vendor.mjs` each refuse a missing value, a value
616
+ // that reads as another flag, and an empty one. This took only the first, so
617
+ // `--tree --write <dir>` swallowed `--write` as the tree value and **silently dropped the
618
+ // write**: a report where the user asked for a migration. A fourth parser inheriting half
619
+ // a rule is the shape this pull request has now hit three times.
620
+ // Copilot, round 12 on #231.
621
+ const value = argv[i + 1];
622
+ if (value === undefined) {
623
+ warn("upgrade: `--tree` needs a value — it is the one thing this tool will not guess");
624
+ return 2;
625
+ }
626
+ if (value.startsWith("-")) {
627
+ warn(`upgrade: \`--tree\` needs a value and the next argument is \`${value}\` — refusing to read a flag as one`);
628
+ return 2;
629
+ }
630
+ if (value.trim() === "") {
631
+ warn("upgrade: `--tree` was given an empty value, and no option here has a meaningful empty value");
632
+ return 2;
633
+ }
634
+ tree = value;
635
+ i += 1;
636
+ continue;
637
+ }
638
+ if (arg.startsWith("-")) {
639
+ flags.push(arg);
640
+ continue;
641
+ }
642
+ positional.push(arg);
643
+ }
644
+
645
+ // Refused rather than dropped. A silently-ignored `--wrote` is a user who believes they asked for
646
+ // something and got a report instead — `discover`'s rule, for `discover`'s reason.
647
+ const unknown = flags.filter((flag) => !KNOWN_FLAGS.has(flag));
648
+ if (unknown.length) {
649
+ warn(`upgrade: unknown flag \`${unknown[0]}\` — run \`portulan upgrade --help\``);
650
+ return 2;
651
+ }
652
+ if (flags.includes("--help") || flags.includes("-h")) {
653
+ say(usage());
654
+ return 0;
655
+ }
656
+
657
+ const check = flags.includes("--check");
658
+ const write = flags.includes("--write");
659
+ if (check && write) {
660
+ warn("upgrade: --check and --write are two questions; pass one");
661
+ return 2;
662
+ }
663
+ if (positional.length !== 1) {
664
+ warn(`upgrade: name exactly one workspace directory (given ${positional.length})`);
665
+ return 2;
666
+ }
667
+
668
+ let spec;
669
+ let steps;
670
+ try {
671
+ spec = bundleSpec();
672
+ // Injected only by the suite, alongside `write` and `env`. The apply loop's guards against a
673
+ // step returning an unusable plan cannot be reached from the two steps this bundle ships —
674
+ // both are well-behaved — and a failure path with no test is how the defect those guards
675
+ // exist for arrived in the first place.
676
+ steps = options.steps ?? (await loadSteps());
677
+ } catch (error) {
678
+ warn(`upgrade: ${error.message}`);
679
+ return 2;
680
+ }
681
+
682
+ // ---- which workspace, and in which residence
683
+ const target = await resolveTarget(positional[0], { env });
684
+ if (target.state === "could-not-look") {
685
+ warn(`upgrade: ${target.sentence}`);
686
+ return 2;
687
+ }
688
+ if (target.state === "not-installed" || target.state === "ambiguous") {
689
+ warn(`upgrade: ${target.sentence}`);
690
+ return 1;
691
+ }
692
+ if (target.state === "resolved") {
693
+ say(`upgrade: ${target.sentence}`);
694
+ if (write) {
695
+ // A cache install is a **materialisation** whose identity is a version claim, not one of
696
+ // proposal 0017's two residences. Writing here would leave a directory that no longer
697
+ // matches the version the host's record names for it, and a reinstall would silently
698
+ // revert it. The workspace itself is migrated at its own directory — the only act that
699
+ // re-pins a feed.
700
+ warn(
701
+ `upgrade: \`${target.dir}\` is an installed workspace, pinned at the version the host's record names for it. ` +
702
+ "Refusing to migrate a copy: run this against the workspace's own directory and republish, then reinstall here",
703
+ );
704
+ return 2;
705
+ }
706
+ }
707
+
708
+ const read = readWorkspace(target.dir);
709
+ if (!read.ok) {
710
+ warn(`upgrade: ${read.reason}`);
711
+ return read.code;
712
+ }
713
+ const ws = read.ws;
714
+ // The SHORTER of the two spellings. A relative path is what a reader wants for a workspace inside
715
+ // the tree they are standing in, and turns into `../../../../…` for a target that is not — a
716
+ // temp directory, or an install in the plugin cache. Neither is wrong; one is unreadable.
717
+ const relative = path.relative(process.cwd(), ws.dir);
718
+ const shown = relative && relative.length < ws.dir.length ? relative : ws.dir;
719
+
720
+ // ---- which direction is this workspace off in
721
+ const declared = /^([0-9]+)\.([0-9]+)$/.exec(ws.manifest?.portulan?.spec ?? "");
722
+ if (!declared) {
723
+ warn(`upgrade: ${shown} declares no readable \`portulan.spec\` — refusing to guess which contract it was written against`);
724
+ return 2;
725
+ }
726
+ const major = Number(declared[1]);
727
+ const minor = Number(declared[2]);
728
+ const behindByMajor = major < spec.major;
729
+ if (major > spec.major || (major === spec.major && minor > spec.minor)) {
730
+ warn(
731
+ `upgrade: ${shown} declares Workspace Definition ${major}.${minor} and this bundle implements ` +
732
+ `${spec.major}.${spec.minor} — this bundle is OLDER than the workspace. Upgrade the CLI; nothing here ` +
733
+ "knows the contract that workspace was written against",
734
+ );
735
+ return 2;
736
+ }
737
+
738
+ // ---- the pre-state gate, which can only ever apply to a gradeable workspace
739
+ if (!behindByMajor) {
740
+ let pre;
741
+ try {
742
+ pre = await inspect(ws.dir);
743
+ } catch (error) {
744
+ warn(`upgrade: doctor could not grade ${shown} — ${error.message}`);
745
+ return 2;
746
+ }
747
+ const fails = pre.findings.filter((finding) => finding.severity === "fail");
748
+ if (fails.length) {
749
+ warn(
750
+ `upgrade: doctor reports ${fails.length} failing check(s) on ${shown} at the version it already declares. ` +
751
+ "Refusing to migrate: a migration would layer a second problem on the first, and a red afterwards " +
752
+ "could not be told from one this run caused",
753
+ );
754
+ for (const finding of fails.slice(0, 10)) warn(`upgrade: ${finding.check} — ${finding.message}`);
755
+ return 1;
756
+ }
757
+ }
758
+
759
+ // ---- the plan
760
+ const ctx = { bundle: BUNDLE, spec, tree };
761
+ const plan = await planFor(ws, ctx, steps);
762
+
763
+ if (plan.unknown > 0) {
764
+ for (const entry of plan.entries.filter((e) => e.owed === null)) {
765
+ warn(`upgrade: ${entry.step.id} could not tell — ${entry.because}`);
766
+ }
767
+ warn("upgrade: refusing to report a workspace current while a step could not answer — nothing looked is never nothing wrong");
768
+ return 2;
769
+ }
770
+
771
+ // The second mouth of the direction hole: a workspace behind by a MAJOR that no step reaches
772
+ // would plan to nothing and exit 0, which is the same false green from the other end.
773
+ if (behindByMajor && plan.owed === 0) {
774
+ warn(
775
+ `upgrade: ${shown} declares ${major}.${minor} and no migration in this chain reaches it — refusing to ` +
776
+ `report it current when this bundle implements ${spec.major}.${spec.minor} and doctor will not grade it`,
777
+ );
778
+ return 2;
779
+ }
780
+
781
+ if (plan.owed === 0) {
782
+ say(`upgrade: ${shown} owes nothing — it is current at ${major}.${minor}`);
783
+ return 0;
784
+ }
785
+
786
+ for (const entry of plan.entries.filter((e) => e.owed === true)) {
787
+ say(`upgrade: ${entry.step.id} (${entry.step.kind}) — ${entry.step.title}`);
788
+ say(`upgrade: ${entry.because}`);
789
+ }
790
+
791
+ // WHICH invocation to suggest depends on where the target came from. Telling the operator of a
792
+ // resolved install to "run with --write" names the one command this tool then refuses with exit
793
+ // 2 — advice and refusal disagreeing about the same act, which is the sibling of the refusal
794
+ // itself. Caught at the pre-commit checkpoint, reproduced end to end on a real install.
795
+ const advice =
796
+ target.state === "resolved"
797
+ ? "run this against the workspace's own directory and republish — an installed copy is not migrated in place"
798
+ : "run with --write to apply them";
799
+
800
+ if (check) {
801
+ warn(`upgrade: ${shown} owes ${plan.owed} step(s) — ${advice}`);
802
+ return 1;
803
+ }
804
+ if (!write) {
805
+ say(`upgrade: ${plan.owed} step(s) owed. Nothing was written — ${advice}`);
806
+ return 0;
807
+ }
808
+
809
+ // ---- apply, one step at a time, re-reading between them
810
+ let snapshots = [];
811
+ let current = ws;
812
+ const undo = () => {
813
+ const result = restore(current.dir, snapshots);
814
+ if (!result.ok) {
815
+ warn(`upgrade: the rollback was INCOMPLETE — put back ${result.restored.join(", ") || "nothing"}; NOT put back ${result.failed.join(", ")}`);
816
+ return false;
817
+ }
818
+ return true;
819
+ };
820
+
821
+ for (const entry of plan.entries.filter((e) => e.owed === true)) {
822
+ // **A step that throws mid-chain must not take the rollback with it.** `plan()` is a module's
823
+ // code, and an exception here — after earlier steps have already written — would abort the
824
+ // process with `undo()` never called, leaving a half-migrated workspace and no record of it.
825
+ // Converted into the refusal the loop already knows how to unwind from. Copilot, round 6.
826
+ let planned;
827
+ try {
828
+ planned = await entry.step.plan(current, ctx);
829
+ // Same lesson as `planFor` above, at the second site: presence of `ok` is not a usable
830
+ // plan. `{ ok: true }` with no `edits` array made `applyEdits` throw on `undefined` —
831
+ // **bypassing the rollback entirely** and leaving a partially migrated workspace, which is
832
+ // the exact outcome the try/catch around this call exists to prevent. `{ ok: false }` with
833
+ // no `reason` printed `undefined` at the user. Copilot, round 8 on #231.
834
+ if (planned === null || typeof planned !== "object" || typeof planned.ok !== "boolean") {
835
+ planned = { ok: false, reason: `${entry.step.id} returned no plan` };
836
+ } else if (planned.ok && !Array.isArray(planned.edits)) {
837
+ planned = { ok: false, reason: `${entry.step.id} reported a plan with no \`edits\` array — refusing to apply a plan it did not describe` };
838
+ } else if (!planned.ok && (typeof planned.reason !== "string" || planned.reason === "")) {
839
+ planned = { ok: false, reason: `${entry.step.id} refused without giving a reason` };
840
+ }
841
+ } catch (error) {
842
+ planned = { ok: false, reason: `${entry.step.id} threw while planning its edits — ${error.message}` };
843
+ }
844
+ if (!planned.ok) {
845
+ if (!undo()) return 2;
846
+ warn(`upgrade: ${entry.step.id} — ${planned.reason}`);
847
+ if (snapshots.length) warn("upgrade: nothing was left behind — the steps that had already run were rolled back");
848
+ return 2;
849
+ }
850
+ const applied = applyEdits(current.dir, planned.edits);
851
+ snapshots = [...snapshots, ...applied.snapshots];
852
+ if (!applied.ok) {
853
+ if (!undo()) return 2;
854
+ warn(`upgrade: ${entry.step.id} — ${applied.reason}`);
855
+ return 2;
856
+ }
857
+ const again = readWorkspace(current.dir);
858
+ if (!again.ok) {
859
+ if (!undo()) return 2;
860
+ warn(`upgrade: ${shown} could not be re-read after ${entry.step.id} — ${again.reason}`);
861
+ return 2;
862
+ }
863
+ current = again.ws;
864
+ }
865
+
866
+ // ---- and grade what it produced, with the real validator
867
+ let post;
868
+ try {
869
+ post = await inspect(current.dir);
870
+ } catch (error) {
871
+ if (!undo()) return 2;
872
+ warn(`upgrade: doctor could not grade the migrated workspace — ${error.message}. Rolled back`);
873
+ return 2;
874
+ }
875
+ const fails = post.findings.filter((finding) => finding.severity === "fail");
876
+ if (fails.length) {
877
+ if (!undo()) return 2;
878
+ warn(
879
+ behindByMajor
880
+ ? `upgrade: the migrated workspace is red — ${fails.length} failing check(s). ${shown} declared ` +
881
+ `${major}.${minor}, which doctor refuses outright, so its pre-state could not be graded: these may be ` +
882
+ "faults it already carried rather than ones this run introduced. Rolled back — the workspace is as it was"
883
+ : `upgrade: the migrated workspace is red — ${fails.length} failing check(s), where it was green before. ` +
884
+ "Rolled back — the workspace is as it was",
885
+ );
886
+ for (const finding of fails.slice(0, 10)) warn(`upgrade: ${finding.check} — ${finding.message}`);
887
+ return 1;
888
+ }
889
+
890
+ const written = [...new Set(snapshots.map((s) => s.file))];
891
+ say(`upgrade: applied ${plan.owed} step(s) to ${shown} — ${written.join(", ")}. doctor is green`);
892
+ return 0;
893
+ }
894
+
895
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
896
+ process.exitCode = await run(process.argv.slice(2));
897
+ }