@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.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +87 -0
- package/cli/README.md +337 -0
- package/cli/compile.mjs +2209 -0
- package/cli/control-chars.mjs +570 -0
- package/cli/discover.mjs +985 -0
- package/cli/doctor.mjs +2803 -0
- package/cli/feedback.mjs +970 -0
- package/cli/gate.mjs +271 -0
- package/cli/index.mjs +1390 -0
- package/cli/init.mjs +1729 -0
- package/cli/librarian.mjs +1384 -0
- package/cli/manifest.mjs +59 -0
- package/cli/new.mjs +803 -0
- package/cli/pack-version.mjs +543 -0
- package/cli/plugin-lint.mjs +1566 -0
- package/cli/portulan.mjs +266 -0
- package/cli/recipe-set.mjs +425 -0
- package/cli/rule-carriers.mjs +489 -0
- package/cli/skills-set.mjs +715 -0
- package/cli/stop-gate.mjs +434 -0
- package/cli/upgrade.mjs +897 -0
- package/cli/vendor.mjs +1504 -0
- package/core/README.md +46 -0
- package/core/engine.md +47 -0
- package/core/operating/autonomy.md +100 -0
- package/core/operating/evolution.md +84 -0
- package/core/operating/loop.md +83 -0
- package/core/operating/memory.md +161 -0
- package/core/operating/safety.md +42 -0
- package/core/operating/verification.md +76 -0
- package/core/personas/README.md +40 -0
- package/core/personas/implementer.md +51 -0
- package/core/personas/librarian.md +73 -0
- package/core/personas/reviewer.md +52 -0
- package/core/skills/README.md +52 -0
- package/core/skills/clarify/SKILL.md +42 -0
- package/core/skills/codify/SKILL.md +65 -0
- package/core/skills/consolidate/SKILL.md +121 -0
- package/core/templates/gate-policy.md +76 -0
- package/core/templates/handoff.md +29 -0
- package/core/templates/memory-entry.md +44 -0
- package/core/templates/pack.md +94 -0
- package/core/templates/persona.md +69 -0
- package/core/templates/proposal.md +27 -0
- package/core/templates/repo-card.md +28 -0
- package/core/templates/skill.md +57 -0
- package/core/templates/task.md +26 -0
- package/core/templates/workspace.md +81 -0
- package/package.json +41 -0
- package/packs/.claude-plugin/plugin.json +22 -0
- package/packs/README.md +61 -0
- package/packs/rituals/README.md +13 -0
- package/packs/rituals/checkpoints/README.md +105 -0
- package/packs/rituals/checkpoints/pack.json +36 -0
- package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
- package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
- package/packs/stacks/README.md +8 -0
- package/packs/tools/README.md +17 -0
- package/packs/tools/github/README.md +65 -0
- package/packs/tools/github/pack.json +29 -0
- package/packs/tools/github/verify/README.md +51 -0
- package/packs/tools/github/verify/actions-pinned.sh +132 -0
- package/spec/README.md +399 -0
- package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
- package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
- package/spec/migrations/README.md +95 -0
- package/spec/pack.schema.json +201 -0
- package/spec/slots.md +796 -0
- package/spec/workspace.schema.json +438 -0
package/cli/upgrade.mjs
ADDED
|
@@ -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
|
+
}
|