garaje 0.1.4 → 0.2.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/dist/driver/compose.js +3 -0
- package/dist/host/deps.js +147 -0
- package/dist/host/doctor.js +152 -6
- package/dist/host/provider.js +43 -0
- package/dist/host/sync.js +43 -3
- package/dist/manifest.js +31 -15
- package/dist/park/index.js +146 -8
- package/dist/park/recipe.js +16 -2
- package/dist/park/toolchain.js +170 -0
- package/dist/upgrade/index.js +259 -5
- package/dist/upgrade/managed.js +117 -0
- package/dist/upgrade/pins.js +4 -2
- package/package.json +1 -1
- package/templates/.env.example +14 -2
- package/templates/.pi/settings.json +5 -3
- package/templates/.pi-lens.json +11 -0
- package/templates/compose.framework.yaml +21 -1
- package/templates/gitignore +4 -0
- package/templates/pi/Dockerfile +48 -0
- package/templates/pi/toolchain/apt-packages.txt +0 -0
- package/templates/pi/toolchain/mise.toml +14 -0
package/dist/park/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { readManifest } from "../manifest.js";
|
|
4
4
|
import { parseRecipe } from "./recipe.js";
|
|
5
5
|
import { generate } from "./compose.js";
|
|
6
|
+
import { generateToolchain, parseToolchainBays } from "./toolchain.js";
|
|
6
7
|
/** Bay folder names under <root>/bays that contain a recipe.yaml, sorted. */
|
|
7
8
|
export function discoverBays(root) {
|
|
8
9
|
const baysDir = join(root, "bays");
|
|
@@ -14,8 +15,21 @@ export function discoverBays(root) {
|
|
|
14
15
|
.sort();
|
|
15
16
|
}
|
|
16
17
|
/** Bay names declared in the committed manifest (garaje.yaml). Lenient: a
|
|
17
|
-
* missing or corrupt manifest yields no bays rather than throwing
|
|
18
|
-
* every caller
|
|
18
|
+
* missing or corrupt manifest yields no bays rather than throwing — safe for
|
|
19
|
+
* every CURRENT caller, because each is a CHECK that reports a bad manifest
|
|
20
|
+
* separately (doctor; `garaje park`'s unsynced-bay warning) and a `[]` here
|
|
21
|
+
* changes nothing about what gets reported.
|
|
22
|
+
*
|
|
23
|
+
* DO NOT reach for this in a WRITER. `garaje upgrade`'s planToolchain used to
|
|
24
|
+
* call this and treated the `[]` it got back as "the manifest genuinely
|
|
25
|
+
* declares no bays" — indistinguishable from "the manifest is missing or
|
|
26
|
+
* corrupt", so a bad garaje.yaml silently regenerated pi/toolchain/ down to
|
|
27
|
+
* an empty artifact instead of failing loud (fixed: planToolchain now calls
|
|
28
|
+
* readManifest directly and gives "can't read the manifest" its own outcome,
|
|
29
|
+
* distinct from "reads fine and says zero"). A future writer has to make
|
|
30
|
+
* that same distinction for itself — call readManifest and handle the
|
|
31
|
+
* throw — rather than reusing this function's leniency and re-collapsing
|
|
32
|
+
* the two states. */
|
|
19
33
|
export function manifestBays(root) {
|
|
20
34
|
try {
|
|
21
35
|
return readManifest(root).map((b) => b.name);
|
|
@@ -24,6 +38,52 @@ export function manifestBays(root) {
|
|
|
24
38
|
return [];
|
|
25
39
|
}
|
|
26
40
|
}
|
|
41
|
+
/** Parse the recipe of every named bay. Throws on a corrupt recipe; callers
|
|
42
|
+
* that are checks (doctor) report that rather than crashing. Exported so
|
|
43
|
+
* `garaje upgrade` can read whatever is currently parked without inventing a
|
|
44
|
+
* second recipe-loading path. */
|
|
45
|
+
export function readRecipes(root, names) {
|
|
46
|
+
return names.map((bay) => parseRecipe(readFileSync(join(root, "bays", bay, "recipe.yaml"), "utf8"), bay));
|
|
47
|
+
}
|
|
48
|
+
/** Where the committed pi/toolchain/ artifact lives. Exported so every caller
|
|
49
|
+
* that needs the paths (to check existence) or the content (to compare)
|
|
50
|
+
* shares one definition instead of re-deriving these two join() calls. */
|
|
51
|
+
export function toolchainArtifactPaths(root) {
|
|
52
|
+
return {
|
|
53
|
+
misePath: join(root, "pi", "toolchain", "mise.toml"),
|
|
54
|
+
aptPath: join(root, "pi", "toolchain", "apt-packages.txt"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** The committed pi/toolchain/ artifact's content, or undefined if either
|
|
58
|
+
* file is missing (a partial artifact counts as absent — same convention
|
|
59
|
+
* toolchainArtifactState and planToolchain already used before this was
|
|
60
|
+
* extracted). */
|
|
61
|
+
export function readToolchainArtifact(root) {
|
|
62
|
+
const { misePath, aptPath } = toolchainArtifactPaths(root);
|
|
63
|
+
if (!existsSync(misePath) || !existsSync(aptPath))
|
|
64
|
+
return undefined;
|
|
65
|
+
return { miseToml: readFileSync(misePath, "utf8"), aptPackages: readFileSync(aptPath, "utf8") };
|
|
66
|
+
}
|
|
67
|
+
/** Write pi/toolchain/{mise.toml,apt-packages.txt} generated from `recipes`.
|
|
68
|
+
* The ONE place that produces this artifact — `runPark` (synced bays) and
|
|
69
|
+
* `garaje upgrade` (whatever is parked at upgrade time) both call this
|
|
70
|
+
* instead of each re-deriving it. Passing an empty recipe list yields the
|
|
71
|
+
* valid empty artifact, so a garaje with no bays needs no special branch. */
|
|
72
|
+
export function writeToolchainArtifact(root, recipes) {
|
|
73
|
+
writeToolchain(root, generateToolchain(recipes));
|
|
74
|
+
}
|
|
75
|
+
/** Write an ALREADY-generated Toolchain to pi/toolchain/. Split out from
|
|
76
|
+
* writeToolchainArtifact so a caller that must generate (and validate) the
|
|
77
|
+
* artifact BEFORE deciding whether to write anything else — `runPark`,
|
|
78
|
+
* which must fail before touching compose.bays.yaml if the recipe set
|
|
79
|
+
* generateToolchain rejects — can write the exact bytes it already
|
|
80
|
+
* generated instead of generating (and risking throwing) a second time. */
|
|
81
|
+
export function writeToolchain(root, toolchain) {
|
|
82
|
+
const { misePath, aptPath } = toolchainArtifactPaths(root);
|
|
83
|
+
mkdirSync(join(root, "pi", "toolchain"), { recursive: true });
|
|
84
|
+
writeFileSync(misePath, toolchain.miseToml);
|
|
85
|
+
writeFileSync(aptPath, toolchain.aptPackages);
|
|
86
|
+
}
|
|
27
87
|
/** Regenerate <root>/compose.bays.yaml from ALL synced bays' recipes. Named
|
|
28
88
|
* args validate ("these bays must be synced with a recipe") rather than
|
|
29
89
|
* select: the artifact is a committed whole-set derivation, so parking one
|
|
@@ -44,18 +104,82 @@ export function runPark(root, bays) {
|
|
|
44
104
|
}
|
|
45
105
|
// The artifact derives from the SYNCED set; warn when the manifest lists
|
|
46
106
|
// more, so a partial sync can't silently narrow the committed artifact.
|
|
47
|
-
|
|
107
|
+
// Read STRICT here, not manifestBays()'s lenient default: a `bays:` entry
|
|
108
|
+
// with no usable `name:` is syntactically valid YAML that lenient mode
|
|
109
|
+
// silently drops, which made this exact warning go silent too (the bay
|
|
110
|
+
// just vanished from the list being checked, so it never showed up as
|
|
111
|
+
// "unsynced" either). This warning is advisory, not a hard gate — park is
|
|
112
|
+
// a writer and must not crash on a bad manifest — so a strict-read failure
|
|
113
|
+
// is itself reported as a warning (naming the manifest problem) rather
|
|
114
|
+
// than thrown or silently swallowed. A garaje.yaml that is simply ABSENT
|
|
115
|
+
// is not this warning's concern (there is nothing to diff against, and
|
|
116
|
+
// other checks — e.g. doctor's repo-shape section — already report a
|
|
117
|
+
// missing manifest); only a PRESENT-but-malformed one gets the warning.
|
|
118
|
+
let manifestNames;
|
|
119
|
+
if (!existsSync(join(root, "garaje.yaml"))) {
|
|
120
|
+
manifestNames = [];
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
try {
|
|
124
|
+
manifestNames = readManifest(root, { strict: true }).map((b) => b.name);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
process.stderr.write(`garaje park: WARNING — ${e.message} — cannot check for unsynced manifest bay(s)\n`);
|
|
128
|
+
manifestNames = [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const unsynced = manifestNames.filter((b) => !synced.includes(b));
|
|
48
132
|
if (unsynced.length) {
|
|
49
133
|
process.stderr.write(`garaje park: WARNING — writing the artifact without unsynced manifest bay(s): ${unsynced.join(", ")} (garaje sync them and re-park)\n`);
|
|
50
134
|
}
|
|
51
|
-
const recipes =
|
|
135
|
+
const recipes = readRecipes(root, synced);
|
|
52
136
|
const text = generate(recipes);
|
|
53
137
|
if (dump) {
|
|
54
138
|
process.stdout.write(text);
|
|
55
139
|
return { code: 0, wrote: synced.join(", ") };
|
|
56
140
|
}
|
|
141
|
+
// Generate (and validate — see generateToolchain's provenance round-trip
|
|
142
|
+
// assertion) the pi/toolchain/ artifact BEFORE writing anything. A recipe
|
|
143
|
+
// generateToolchain rejects (an unsafe bay name, or a provenance encoding
|
|
144
|
+
// failure) must fail park CLEANLY here — no stack trace, and neither
|
|
145
|
+
// compose.bays.yaml nor pi/toolchain/ touched — rather than writing
|
|
146
|
+
// compose.bays.yaml below and THEN throwing out of what used to be a
|
|
147
|
+
// second, later generateToolchain call. That order also used to print the
|
|
148
|
+
// narrowing WARNING below as a lie: it describes what pi/toolchain/ is
|
|
149
|
+
// about to lose, but if generation then failed, nothing was written at
|
|
150
|
+
// all.
|
|
151
|
+
let toolchain;
|
|
152
|
+
try {
|
|
153
|
+
toolchain = generateToolchain(recipes);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
process.stderr.write(`garaje park: ${e.message}\n`);
|
|
157
|
+
return { code: 1 };
|
|
158
|
+
}
|
|
159
|
+
// `garaje park` is the deliberate escape hatch for narrowing the committed
|
|
160
|
+
// pi/toolchain/ artifact (unlike `garaje upgrade`, which refuses outright)
|
|
161
|
+
// — but narrowing must never happen SILENTLY. The committed artifact
|
|
162
|
+
// records the exact bay set it was derived from (generateToolchain's
|
|
163
|
+
// `# bays: ...` line, read back by parseToolchainBays); if what we are
|
|
164
|
+
// about to write would drop any of those bays, warn loudly and name them
|
|
165
|
+
// before writing anyway. This is what closes the laundering route: doctor
|
|
166
|
+
// tells a user to run `garaje park` to fix a STALE artifact, and this is
|
|
167
|
+
// the only place left that can catch a park which "fixes" staleness by
|
|
168
|
+
// quietly dropping a bay instead.
|
|
169
|
+
const committedArtifact = readToolchainArtifact(root);
|
|
170
|
+
if (committedArtifact) {
|
|
171
|
+
const committedBays = parseToolchainBays(committedArtifact.miseToml);
|
|
172
|
+
if (committedBays !== undefined) {
|
|
173
|
+
const newBays = new Set(recipes.map((r) => r.name));
|
|
174
|
+
const dropped = committedBays.filter((b) => !newBays.has(b));
|
|
175
|
+
if (dropped.length > 0) {
|
|
176
|
+
process.stderr.write(`garaje park: WARNING — narrowing pi/toolchain/: dropping bay(s) the committed artifact was derived from: ${dropped.join(", ")} (if unintended, sync/author the missing bay(s) and re-park)\n`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
57
180
|
writeFileSync(join(root, "compose.bays.yaml"), text);
|
|
58
|
-
|
|
181
|
+
writeToolchain(root, toolchain);
|
|
182
|
+
process.stderr.write(`garaje park: wrote compose.bays.yaml + pi/toolchain/ for ${synced.join(", ")}\n`);
|
|
59
183
|
return { code: 0, wrote: synced.join(", ") };
|
|
60
184
|
}
|
|
61
185
|
/** Compare the COMMITTED bays artifact against a regeneration from the
|
|
@@ -68,6 +192,20 @@ export function baysArtifactState(root) {
|
|
|
68
192
|
const path = join(root, "compose.bays.yaml");
|
|
69
193
|
if (!existsSync(path))
|
|
70
194
|
return "missing";
|
|
71
|
-
|
|
72
|
-
|
|
195
|
+
return generate(readRecipes(root, names)) === readFileSync(path, "utf8") ? "fresh" : "stale";
|
|
196
|
+
}
|
|
197
|
+
/** Same regenerate-and-diff guard as baysArtifactState, for the pi toolchain.
|
|
198
|
+
* The pi image is a second consumer of the recipes; its artifact must not
|
|
199
|
+
* drift from them silently either. */
|
|
200
|
+
export function toolchainArtifactState(root) {
|
|
201
|
+
const names = discoverBays(root);
|
|
202
|
+
if (!names.length)
|
|
203
|
+
return "no-bays";
|
|
204
|
+
const artifact = readToolchainArtifact(root);
|
|
205
|
+
if (!artifact)
|
|
206
|
+
return "missing";
|
|
207
|
+
const want = generateToolchain(readRecipes(root, names));
|
|
208
|
+
return want.miseToml === artifact.miseToml && want.aptPackages === artifact.aptPackages
|
|
209
|
+
? "fresh"
|
|
210
|
+
: "stale";
|
|
73
211
|
}
|
package/dist/park/recipe.js
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import { parse as parseYaml } from "yaml";
|
|
2
|
-
/** Parse recipe YAML into a Recipe, defaulting name to the bay folder name.
|
|
2
|
+
/** Parse recipe YAML into a Recipe, defaulting name to the bay folder name.
|
|
3
|
+
*
|
|
4
|
+
* A `name:` key that parses to `null` (a bare `name:` with nothing after
|
|
5
|
+
* it, or an explicit `~` / `null`) or to the empty string is, for every
|
|
6
|
+
* purpose downstream, "no name was given" — it must fall back to the bay
|
|
7
|
+
* folder name exactly like an absent `name:` (`undefined`) does. This is
|
|
8
|
+
* read off the raw parsed `data`, not the `recipe as Recipe` cast below:
|
|
9
|
+
* that cast is unchecked (`data as Recipe`), so TypeScript's `name: string`
|
|
10
|
+
* cannot be trusted to reflect what untrusted YAML actually produced. Left
|
|
11
|
+
* unhandled, a recipe with an empty `name:` used to survive as a
|
|
12
|
+
* non-`undefined`, non-string `name` all the way into generateToolchain,
|
|
13
|
+
* where it could defeat the `# bays: ...` provenance encoding instead of
|
|
14
|
+
* cleanly taking the folder-name fallback. */
|
|
3
15
|
export function parseRecipe(text, fallbackName) {
|
|
4
16
|
const data = parseYaml(text);
|
|
5
17
|
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
6
18
|
throw new Error("recipe did not parse to a mapping");
|
|
7
19
|
}
|
|
8
20
|
const recipe = data;
|
|
9
|
-
|
|
21
|
+
const rawName = data.name;
|
|
22
|
+
if (rawName === undefined || rawName === null || rawName === "") {
|
|
10
23
|
recipe.name = fallbackName;
|
|
24
|
+
}
|
|
11
25
|
return recipe;
|
|
12
26
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { isSafeBayName } from "../host/deps.js";
|
|
2
|
+
/** The mise tool ids a recipe's `image:` block can declare. */
|
|
3
|
+
const TOOLS = ["node", "ruby"];
|
|
4
|
+
/**
|
|
5
|
+
* What ruby-build needs to compile Ruby from source under mise. Injected into
|
|
6
|
+
* the apt list ONLY when some bay declares a ruby — a garaje with no Ruby bay
|
|
7
|
+
* should not carry ~200MB of build deps it will never use.
|
|
8
|
+
*/
|
|
9
|
+
export const RUBY_BUILD_DEPS = [
|
|
10
|
+
"autoconf",
|
|
11
|
+
"build-essential",
|
|
12
|
+
"libffi-dev",
|
|
13
|
+
"libreadline-dev",
|
|
14
|
+
"libssl-dev",
|
|
15
|
+
"libyaml-dev",
|
|
16
|
+
"patch",
|
|
17
|
+
"zlib1g-dev",
|
|
18
|
+
];
|
|
19
|
+
/** Numeric-aware compare, so "3.10.1" sorts AFTER "3.9.1" (a plain lexical
|
|
20
|
+
* sort gets this backwards, and the artifact would churn). */
|
|
21
|
+
function byVersion(a, b) {
|
|
22
|
+
return a.localeCompare(b, undefined, { numeric: true });
|
|
23
|
+
}
|
|
24
|
+
/** Normalize a `.ruby-version` body ("ruby-3.4.1\n") to a bare version. */
|
|
25
|
+
export function normalizeRubyVersion(text) {
|
|
26
|
+
return text.trim().replace(/^ruby-/, "");
|
|
27
|
+
}
|
|
28
|
+
/** A TOML array of double-quoted strings: ["3.2.2", "3.4.1"]. */
|
|
29
|
+
function tomlArray(items) {
|
|
30
|
+
return `[${items.map((i) => JSON.stringify(i)).join(", ")}]`;
|
|
31
|
+
}
|
|
32
|
+
export function generateToolchain(recipes) {
|
|
33
|
+
const versions = new Map(TOOLS.map((t) => [t, new Set()]));
|
|
34
|
+
const packages = new Set();
|
|
35
|
+
for (const r of recipes) {
|
|
36
|
+
// The recipe's `name:` (or its bay-folder fallback) is embedded VERBATIM
|
|
37
|
+
// into the `# bays: ...` provenance line below — a comma-joined list with
|
|
38
|
+
// a `(none)` sentinel for "zero bays". An unvalidated name can defeat
|
|
39
|
+
// that encoding two ways: a name containing a comma (e.g. "web,api")
|
|
40
|
+
// parses back as MULTIPLE bays, permanently blocking `garaje upgrade`
|
|
41
|
+
// with no way to clear it; a name literally equal to "(none)" collides
|
|
42
|
+
// with the empty sentinel, parsing back to `[]` and neutering BOTH the
|
|
43
|
+
// provenance check and the isEmptyToolchain backstop at once. Neither
|
|
44
|
+
// name is reachable in a working garaje today (isSafeBayName already
|
|
45
|
+
// gates every OTHER bay-name path — sync's clone destination, doctor's
|
|
46
|
+
// bay-status check — and Docker rejects the resulting compose service
|
|
47
|
+
// key), but this is the one place that ENCODES the name into a
|
|
48
|
+
// string other code parses back apart, so it gets its own guard rather
|
|
49
|
+
// than trusting every caller upstream to have already checked.
|
|
50
|
+
if (!isSafeBayName(r.name)) {
|
|
51
|
+
throw new Error(`recipe name "${r.name}" is not a safe bay name (must match [A-Za-z0-9._-]+, and not be '.' or '..') — ` +
|
|
52
|
+
"refusing to embed it in pi/toolchain/'s `# bays:` provenance line");
|
|
53
|
+
}
|
|
54
|
+
const image = r.image ?? {};
|
|
55
|
+
for (const tool of TOOLS) {
|
|
56
|
+
const raw = image[tool];
|
|
57
|
+
if (raw === undefined || raw === null)
|
|
58
|
+
continue;
|
|
59
|
+
const v = String(raw).trim();
|
|
60
|
+
if (v !== "")
|
|
61
|
+
versions.get(tool)?.add(v);
|
|
62
|
+
}
|
|
63
|
+
for (const p of image.system_packages ?? [])
|
|
64
|
+
packages.add(p);
|
|
65
|
+
}
|
|
66
|
+
if ((versions.get("ruby")?.size ?? 0) > 0) {
|
|
67
|
+
for (const dep of RUBY_BUILD_DEPS)
|
|
68
|
+
packages.add(dep);
|
|
69
|
+
}
|
|
70
|
+
const toolLines = [];
|
|
71
|
+
for (const tool of TOOLS) {
|
|
72
|
+
const set = versions.get(tool);
|
|
73
|
+
if (!set || set.size === 0)
|
|
74
|
+
continue;
|
|
75
|
+
toolLines.push(`${tool} = ${tomlArray([...set].sort(byVersion))}`);
|
|
76
|
+
}
|
|
77
|
+
// The set of bays this artifact was derived from, recorded IN the artifact
|
|
78
|
+
// itself so a later regeneration (garaje upgrade's planToolchain) can tell
|
|
79
|
+
// a genuine "the manifest now declares fewer bays" from "disk happens to
|
|
80
|
+
// have fewer bays than what this was committed from" — see
|
|
81
|
+
// parseToolchainBays. Sorted + deduped so the line (and the whole file) is
|
|
82
|
+
// deterministic regardless of recipe order or a name declared twice.
|
|
83
|
+
const bayNames = [...new Set(recipes.map((r) => r.name))].sort();
|
|
84
|
+
const baysLine = bayNames.length ? bayNames.join(", ") : "(none)";
|
|
85
|
+
const miseToml = [
|
|
86
|
+
"# GENERATED by `garaje park` from bays/*/recipe.yaml. Do not edit by hand.",
|
|
87
|
+
"#",
|
|
88
|
+
"# The union of every parked bay's declared runtimes. mise installs all of",
|
|
89
|
+
"# them; each bay's own .ruby-version / .node-version then selects which one",
|
|
90
|
+
"# applies inside that bay. pi-lens spawns language servers with cwd = the bay",
|
|
91
|
+
"# root, so the mise shim on PATH resolves the right version with no glue.",
|
|
92
|
+
"#",
|
|
93
|
+
`# bays: ${baysLine}`,
|
|
94
|
+
"",
|
|
95
|
+
"[tools]",
|
|
96
|
+
...toolLines,
|
|
97
|
+
"",
|
|
98
|
+
"[settings]",
|
|
99
|
+
"# Honor each bay's .ruby-version / .node-version (they already ship them).",
|
|
100
|
+
`idiomatic_version_file_enable_tools = ${tomlArray([...TOOLS])}`,
|
|
101
|
+
"",
|
|
102
|
+
].join("\n");
|
|
103
|
+
const sorted = [...packages].sort();
|
|
104
|
+
const aptPackages = sorted.length ? `${sorted.join("\n")}\n` : "";
|
|
105
|
+
// Self-verifying provenance: don't just trust that the `# bays: ...` line
|
|
106
|
+
// above encodes bayNames correctly — prove it, using the SAME parser every
|
|
107
|
+
// downstream narrowing guard (runPark's, planToolchain's) relies on to read
|
|
108
|
+
// it back. isSafeBayName above already blocks the two encoding hazards this
|
|
109
|
+
// file's comments enumerate by hand (a comma in a name; a name literally
|
|
110
|
+
// equal to the "(none)" sentinel) — but enumerating hazards by hand is
|
|
111
|
+
// exactly how six review passes each found a new route into the same bug.
|
|
112
|
+
// This assertion does not depend on the hazard being anticipated: if
|
|
113
|
+
// ANYTHING about a name, this join, or the sentinel ever lets the header
|
|
114
|
+
// lie about the bays it was derived from, generation fails LOUD, right
|
|
115
|
+
// here, instead of shipping a provenance line that a later reader trusts.
|
|
116
|
+
const roundTrip = parseToolchainBays(miseToml);
|
|
117
|
+
const roundTripsClean = roundTrip !== undefined &&
|
|
118
|
+
roundTrip.length === bayNames.length &&
|
|
119
|
+
roundTrip.every((b, i) => b === bayNames[i]);
|
|
120
|
+
if (!roundTripsClean) {
|
|
121
|
+
throw new Error(`generateToolchain: internal error — the "# bays: ${baysLine}" provenance line does not round-trip. ` +
|
|
122
|
+
`Generated from bay(s) ${JSON.stringify(bayNames)}, but parseToolchainBays reads it back as ` +
|
|
123
|
+
`${JSON.stringify(roundTrip)}. A recipe name is defeating the provenance encoding — check the ` +
|
|
124
|
+
"recipe(s) named above for anything unusual in `name:`.");
|
|
125
|
+
}
|
|
126
|
+
return { miseToml, aptPackages };
|
|
127
|
+
}
|
|
128
|
+
/** True when a Toolchain carries no runtimes and no packages — the artifact
|
|
129
|
+
* produced by zero (or all-vacuous) recipes. Compared against the canonical
|
|
130
|
+
* empty generation rather than re-deriving "empty" from recipe internals, so
|
|
131
|
+
* this can never drift from generateToolchain's own aggregation logic.
|
|
132
|
+
*
|
|
133
|
+
* Exists so a WRITER (garaje upgrade's planToolchain) can refuse to replace
|
|
134
|
+
* an existing, non-empty committed toolchain artifact with an empty one —
|
|
135
|
+
* the structural invariant behind three separate bugs found in that seam. */
|
|
136
|
+
export function isEmptyToolchain(t) {
|
|
137
|
+
const empty = generateToolchain([]);
|
|
138
|
+
return t.miseToml === empty.miseToml && t.aptPackages === empty.aptPackages;
|
|
139
|
+
}
|
|
140
|
+
/** Read the `# bays: <names>` provenance line `generateToolchain` embeds in
|
|
141
|
+
* mise.toml back out again — the other half of that round-trip.
|
|
142
|
+
*
|
|
143
|
+
* Returns:
|
|
144
|
+
* - `string[]` (possibly empty, for the recorded `(none)`) when the line is
|
|
145
|
+
* present — the bay set the committed artifact was actually derived from.
|
|
146
|
+
* - `undefined` when no such line exists at all (a hand-written artifact, or
|
|
147
|
+
* one committed before this provenance line existed). Callers MUST NOT
|
|
148
|
+
* treat `undefined` the same as `[]`: one means "we don't know", the other
|
|
149
|
+
* means "derived from zero bays, and we know it".
|
|
150
|
+
*
|
|
151
|
+
* This is what lets planToolchain refuse to regenerate a NARROWER artifact
|
|
152
|
+
* than what is actually committed, closing the bug class where the on-disk
|
|
153
|
+
* bay set is smaller than the set the committed artifact came from (a
|
|
154
|
+
* gitignored bays/ on a fresh clone, a `bays:` entry silently dropped for
|
|
155
|
+
* lacking `name:`, a bay parked locally but never added to garaje.yaml, ...)
|
|
156
|
+
* — every one of those makes discoverBays()/readRecipes() return less than
|
|
157
|
+
* the committed truth, and no comparison of "would this regen be empty"
|
|
158
|
+
* alone can catch a narrowing that stops short of empty. */
|
|
159
|
+
export function parseToolchainBays(miseToml) {
|
|
160
|
+
const m = miseToml.match(/^# bays: (.*)$/m);
|
|
161
|
+
if (!m)
|
|
162
|
+
return undefined;
|
|
163
|
+
const body = m[1].trim();
|
|
164
|
+
if (body === "(none)")
|
|
165
|
+
return [];
|
|
166
|
+
return body
|
|
167
|
+
.split(",")
|
|
168
|
+
.map((s) => s.trim())
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
}
|