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.
@@ -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, because
18
- * every caller here is a check that reports on it separately. */
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
- const unsynced = manifestBays(root).filter((b) => !synced.includes(b));
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 = synced.map((bay) => parseRecipe(readFileSync(join(root, "bays", bay, "recipe.yaml"), "utf8"), bay));
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
- process.stderr.write(`garaje park: wrote compose.bays.yaml for ${synced.join(", ")}\n`);
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
- const recipes = names.map((bay) => parseRecipe(readFileSync(join(root, "bays", bay, "recipe.yaml"), "utf8"), bay));
72
- return generate(recipes) === readFileSync(path, "utf8") ? "fresh" : "stale";
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
  }
@@ -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
- if (recipe.name === undefined)
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
+ }