garaje 0.1.5 → 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.
@@ -120,6 +120,9 @@ export class ComposeDriver {
120
120
  requiredFiles() {
121
121
  return ["docker-compose.yml", "compose.framework.yaml"];
122
122
  }
123
+ frameworkOwnedFiles() {
124
+ return ["docker-compose.yml", "compose.framework.yaml"];
125
+ }
123
126
  }
124
127
  /** The generated bays layer's presence — driver-internal filename knowledge. */
125
128
  export function hasBaysLayer(root) {
@@ -0,0 +1,147 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ import { parseRecipe } from "../park/recipe.js";
5
+ /** Lockfiles whose content decides whether a bay's pi-side deps are stale. */
6
+ const LOCKFILES = ["Gemfile.lock", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
7
+ /** A bay name is interpolated into an `sh -c` script below, and is also joined
8
+ * onto `root` as a bare path component (here and in sync.ts). Anything outside
9
+ * this set is refused rather than escaped — bays are directory names, and a
10
+ * name that needs shell escaping is a name we do not want. The character
11
+ * class alone is not enough: `.` and `..` are pure runs of an allowed
12
+ * character but are not bay directories at all — they resolve to `bays/`
13
+ * itself or the garaje root, so both are refused explicitly too. */
14
+ const SAFE_BAY = /^[A-Za-z0-9._-]+$/;
15
+ /** Whether `name` is safe to use both as a shell argument (installBayDeps) and
16
+ * as a `bays/<name>` path component (installBayDeps; sync.ts's clone/fetch
17
+ * destination; and sync.ts's bayStatus, which otherwise resolves '..' to the
18
+ * garaje root and reports it "synced" at the root's own HEAD). Exported so
19
+ * every call site shares one guard instead of each keeping its own copy of
20
+ * the regex and the `.`/`..` exclusion.
21
+ *
22
+ * Takes `unknown`, not `string`, and leads with a `typeof` check rather than
23
+ * handing the value straight to `SAFE_BAY.test`. `RegExp.prototype.test`
24
+ * coerces its argument to a string before matching — so `test(null)` matches
25
+ * against the literal string `"null"` and PASSES. A caller with a real
26
+ * compile-time `string` type never notices; the hazard is every caller that
27
+ * reaches this with a value whose static type lies — e.g. a `Recipe.name`
28
+ * read off an unchecked `as Recipe` cast over parsed YAML, where a `name:`
29
+ * key that parses to `null` is reachable at runtime despite the field being
30
+ * typed `string`. This is a security guard (path traversal, shell
31
+ * injection) as well as a provenance guard, so it must fail closed for
32
+ * every non-string input, not just the ones some caller's types happen to
33
+ * rule out. */
34
+ export function isSafeBayName(name) {
35
+ return typeof name === "string" && SAFE_BAY.test(name) && name !== "." && name !== "..";
36
+ }
37
+ /** Content hash of a bay's lockfiles, MIXED with its recipe's declared runtime
38
+ * versions (image.ruby / image.node). The lockfile alone is not enough: bump
39
+ * a bay's Ruby (recipe + .ruby-version) without touching Gemfile.lock, and a
40
+ * lockfile-only hash would still match the recorded stamp — `garaje sync`
41
+ * would report "up to date" while BUNDLE_PATH/ruby/<new-abi>/ is empty and
42
+ * every `bundle exec` fails. Order-stable; a missing lockfile or a recipe
43
+ * with no declared runtime simply contributes nothing. A corrupt recipe is
44
+ * reported elsewhere (doctor / park); here it just contributes no
45
+ * runtime-version bytes rather than throwing mid-sync. */
46
+ export function lockfileHash(bayDir) {
47
+ const h = createHash("sha256");
48
+ for (const name of [...LOCKFILES].sort()) {
49
+ const p = join(bayDir, name);
50
+ if (!existsSync(p))
51
+ continue;
52
+ h.update(name);
53
+ h.update(readFileSync(p));
54
+ }
55
+ const recipePath = join(bayDir, "recipe.yaml");
56
+ if (existsSync(recipePath)) {
57
+ try {
58
+ const recipe = parseRecipe(readFileSync(recipePath, "utf8"), basename(bayDir));
59
+ const image = recipe.image ?? {};
60
+ for (const tool of ["ruby", "node"]) {
61
+ const raw = image[tool];
62
+ if (raw === undefined || raw === null)
63
+ continue;
64
+ h.update(tool);
65
+ h.update(String(raw).trim());
66
+ }
67
+ }
68
+ catch {
69
+ // corrupt recipe — contribute nothing rather than crash the hash.
70
+ }
71
+ }
72
+ return h.digest("hex").slice(0, 16);
73
+ }
74
+ /** Where a bay's recorded hash lives ON THE pi-deps VOLUME (a container path). */
75
+ export function stampPath(bay) {
76
+ return `/garaje/deps/.stamp/${bay}`;
77
+ }
78
+ /** The `setup` commands a bay's recipe declares. The recipe is the single
79
+ * source of truth for "how this project installs" — we do not invent a
80
+ * second one for the pi side. */
81
+ export function setupCommands(root, bay) {
82
+ const p = join(root, "bays", bay, "recipe.yaml");
83
+ if (!existsSync(p))
84
+ return [];
85
+ return parseRecipe(readFileSync(p, "utf8"), bay).image?.setup ?? [];
86
+ }
87
+ /** One line of `garaje sync` output for a dep outcome. `width` should be the
88
+ * longest bay name among everything being reported this run (the caller
89
+ * knows that; a single outcome does not) — padEnd never truncates, so a name
90
+ * longer than `width` still gets its explicit separating space rather than
91
+ * running straight into "deps" (e.g. an 8-char default swallowed the space
92
+ * for a 9-char name like "hello-bay"). */
93
+ export function renderDepsOutcome(o, width = 8) {
94
+ const tag = ` ${o.bay.padEnd(width)} deps `;
95
+ switch (o.kind) {
96
+ case "installed":
97
+ return `${tag}installed ✓\n`;
98
+ case "fresh":
99
+ return `${tag}up to date (lockfile unchanged) ✓\n`;
100
+ case "no-setup":
101
+ return `${tag}nothing to install (recipe declares no setup)\n`;
102
+ case "failed":
103
+ return `${tag}FAILED\n${o.detail.replace(/^/gm, " ")}\n`;
104
+ }
105
+ }
106
+ /**
107
+ * Install one bay's dependencies inside the pi container, so pi-lens's language
108
+ * servers and linters see the real project. Idempotent: keyed on the bay's
109
+ * lockfile hash, recorded on the pi-deps volume.
110
+ *
111
+ * Gems land under the container-wide BUNDLE_PATH (/garaje/deps/bundle), which a
112
+ * single shared path can serve for every bay — bundler namespaces by Ruby ABI
113
+ * directory and activates strictly from the calling project's Gemfile.lock.
114
+ *
115
+ * `onStart`, if given, fires right before the setup script actually runs — NOT
116
+ * before the freshness check, and not at all for a "fresh" or "no-setup"
117
+ * outcome. `execCapture` buffers output, so a cold `bundle install` on a real
118
+ * Rails app runs 20s-to-minutes with zero output otherwise; callers use this
119
+ * to tell the user an install is starting and why it may be slow.
120
+ */
121
+ export function installBayDeps(root, bay, driver, onStart) {
122
+ if (!isSafeBayName(bay)) {
123
+ return { kind: "failed", bay, detail: `unsafe bay name '${bay}' — refusing to run it in a shell` };
124
+ }
125
+ const setup = setupCommands(root, bay);
126
+ if (!setup.length)
127
+ return { kind: "no-setup", bay };
128
+ const want = lockfileHash(join(root, "bays", bay));
129
+ const stamp = stampPath(bay);
130
+ const seen = driver.execCapture("pi", ["sh", "-c", `cat ${stamp} 2>/dev/null || true`]);
131
+ if (seen.status === 0 && seen.stdout.trim() === want)
132
+ return { kind: "fresh", bay };
133
+ onStart?.(bay);
134
+ const script = [
135
+ "set -e",
136
+ `cd /workspace/bays/${bay}`,
137
+ ...setup,
138
+ "mkdir -p /garaje/deps/.stamp",
139
+ `printf %s ${want} > ${stamp}`,
140
+ ].join("\n");
141
+ const r = driver.execCapture("pi", ["sh", "-c", script]);
142
+ if (r.status !== 0) {
143
+ const detail = (r.stderr || r.stdout).trim().split("\n").slice(-3).join("\n");
144
+ return { kind: "failed", bay, detail: detail || `setup exited ${r.status}` };
145
+ }
146
+ return { kind: "installed", bay };
147
+ }
@@ -2,10 +2,56 @@ import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { driverContext, getDriver } from "../driver/index.js";
5
- import { baysArtifactState, discoverBays, manifestBays } from "../park/index.js";
5
+ import { baysArtifactState, discoverBays, manifestBays, toolchainArtifactState, } from "../park/index.js";
6
+ import { normalizeRubyVersion } from "../park/toolchain.js";
7
+ import { parseRecipe } from "../park/recipe.js";
8
+ import { readManifest } from "../manifest.js";
6
9
  import { basePinSource } from "../upgrade/pins.js";
7
10
  import { bayStatuses, statusLine } from "./sync.js";
8
11
  import { classifyModelList, defaultModelOf, hasInferenceCredential } from "./provider.js";
12
+ /** Bays whose recipe `image.ruby` disagrees with their own `.ruby-version`.
13
+ * mise installs what the RECIPE declares, but resolves per-bay from the
14
+ * version FILE — so a disagreement means the bay asks for a ruby that was
15
+ * never installed, and its language server dies for a reason nobody would
16
+ * trace back to the recipe. */
17
+ export function rubyVersionMismatches(root) {
18
+ const out = [];
19
+ for (const bay of discoverBays(root)) {
20
+ const dir = join(root, "bays", bay);
21
+ let declared;
22
+ try {
23
+ const recipe = parseRecipe(readFileSync(join(dir, "recipe.yaml"), "utf8"), bay);
24
+ const raw = recipe.image?.ruby;
25
+ declared = raw === undefined || raw === null ? undefined : String(raw).trim();
26
+ }
27
+ catch {
28
+ continue; // a corrupt recipe is reported by the artifact check, not here
29
+ }
30
+ if (!declared)
31
+ continue;
32
+ const versionFile = join(dir, ".ruby-version");
33
+ if (!existsSync(versionFile))
34
+ continue;
35
+ // existsSync is true for a directory too — a stray `mkdir .ruby-version`
36
+ // (or any other unreadable path) must not crash doctor. Treat it the same
37
+ // as a MISSING version file: skip this bay silently and keep checking the
38
+ // others, since an unreadable version file is not something doctor can
39
+ // report anything useful about.
40
+ let versionText;
41
+ try {
42
+ versionText = readFileSync(versionFile, "utf8");
43
+ }
44
+ catch {
45
+ continue;
46
+ }
47
+ const wanted = normalizeRubyVersion(versionText);
48
+ if (wanted && wanted !== declared) {
49
+ out.push(`${bay}: recipe.yaml declares ruby ${declared} but .ruby-version asks for ${wanted} — ` +
50
+ `mise installs the recipe's, so the language server will not start`);
51
+ }
52
+ }
53
+ return out;
54
+ }
9
55
  /** `garaje doctor` — host-side sanity checks. Exit code = failed-check count. */
10
56
  export function runDoctor(root) {
11
57
  let fails = 0;
@@ -108,11 +154,28 @@ export function runDoctor(root) {
108
154
  if (!bays.length)
109
155
  warn("no bays parked (add one to garaje.yaml, then garaje sync)");
110
156
  for (const b of bays) {
111
- if (b.synced)
157
+ if (b.unsafe)
158
+ fail(`${b.name}: ${statusLine(b)} (bay names must be plain directory names, not '.' or '..')`);
159
+ else if (b.synced)
112
160
  pass(`${b.name}: ${statusLine(b)} (${b.ref})`);
113
161
  else
114
162
  warn(`${b.name}: ${statusLine(b)} — run \`garaje sync ${b.name}\``);
115
163
  }
164
+ // bayStatuses() above (readManifest, lenient) already proved garaje.yaml
165
+ // itself parses as YAML — so if a STRICT re-read throws here, it can only
166
+ // be because a `bays:` entry is well-formed YAML but still has no usable
167
+ // name, which lenient mode silently drops instead of erroring. That drop
168
+ // is otherwise completely invisible: the bay just never appears above.
169
+ try {
170
+ readManifest(root, { strict: true });
171
+ }
172
+ catch (e) {
173
+ // (e as Error).message already carries a "garaje.yaml: " prefix (see
174
+ // parseManifest) — do not prepend a second one.
175
+ warn(`${e.message} — lenient parsing silently drops this bay ` +
176
+ "(name it to fix; `garaje upgrade` refuses to narrow a committed toolchain because of it, " +
177
+ "but every other bay-aware command just silently ignores it)");
178
+ }
116
179
  }
117
180
  catch (e) {
118
181
  fail(`bay status check errored — garaje.yaml failed to parse: ${e.message}`);
@@ -138,6 +201,22 @@ export function runDoctor(root) {
138
201
  catch (e) {
139
202
  fail(`bays artifact check errored — a recipe failed to parse: ${e.message}`);
140
203
  }
204
+ try {
205
+ const tc = toolchainArtifactState(root);
206
+ if (tc === "fresh")
207
+ pass("pi toolchain artifact matches the recipes (regenerate-and-diff)");
208
+ else if (tc === "no-bays")
209
+ warn("no parked bays — pi toolchain not checkable");
210
+ else if (tc === "missing")
211
+ fail("pi/toolchain/ missing — run `garaje park` and commit the result");
212
+ else
213
+ fail("pi toolchain artifact STALE — recipes changed; run `garaje park` and commit the diff");
214
+ }
215
+ catch (e) {
216
+ fail(`pi toolchain check errored — a recipe failed to parse: ${e.message}`);
217
+ }
218
+ for (const m of rubyVersionMismatches(root))
219
+ warn(m);
141
220
  // "Parked" (has a recipe.yaml) is a stricter state than "synced" (cloned);
142
221
  // only parked bays contribute to the artifact.
143
222
  const parked = discoverBays(root);
package/dist/host/sync.js CHANGED
@@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { readManifest } from "../manifest.js";
5
+ import { driverContext, getDriver } from "../driver/index.js";
6
+ import { installBayDeps, isSafeBayName, renderDepsOutcome } from "./deps.js";
5
7
  function git(args) {
6
8
  return spawnSync("git", args, { stdio: "inherit" }).status ?? 1;
7
9
  }
@@ -21,6 +23,12 @@ function readOrReport(root) {
21
23
  }
22
24
  /** Whether one manifest bay is materialized, and at which commit. */
23
25
  function bayStatus(root, bay) {
26
+ // Same guard as installBayDeps and runSync's clone/fetch: an unsafe name
27
+ // (e.g. '..') joined onto `bays/` resolves outside bays/ entirely — for
28
+ // '..' that is the garaje root itself, whose own .git would otherwise be
29
+ // reported as this bay "synced" at the garaje's real HEAD.
30
+ if (!isSafeBayName(bay.name))
31
+ return { ...bay, synced: false, unsafe: true };
24
32
  const dest = join(root, "bays", bay.name);
25
33
  if (!existsSync(join(dest, ".git")))
26
34
  return { ...bay, synced: false };
@@ -36,6 +44,8 @@ export function bayStatuses(root) {
36
44
  }
37
45
  /** One human-readable line per bay. Pure — doctor reuses this. */
38
46
  export function statusLine(b) {
47
+ if (b.unsafe)
48
+ return "invalid bay name — fix garaje.yaml";
39
49
  return b.synced ? `synced @ ${b.head}` : "not synced";
40
50
  }
41
51
  /** The `garaje list` table. Pure. */
@@ -57,8 +67,16 @@ export function runList(root) {
57
67
  process.stdout.write(renderList(entries.map((b) => bayStatus(root, b))));
58
68
  return 0;
59
69
  }
60
- /** `garaje sync [name...]` — clone/update the manifest's bays into bays/. */
61
- export function runSync(root, names) {
70
+ /** `garaje sync [--no-deps] [name...]` — clone/update the manifest's bays into
71
+ * bays/, then install each one's dependencies inside the pi container so
72
+ * pi-lens's language servers and linters see the real project.
73
+ *
74
+ * The dep step DEGRADES, never fails: `garaje sync` is the verb you run on a
75
+ * fresh clone BEFORE `garaje up`, so it must not acquire a hard dependency on
76
+ * the pi container being up. */
77
+ export function runSync(root, argv) {
78
+ const noDeps = argv.includes("--no-deps");
79
+ const names = argv.filter((a) => a !== "--no-deps");
62
80
  if (spawnSync("git", ["--version"], { stdio: "ignore" }).status !== 0) {
63
81
  process.stderr.write("error: git not found in PATH\n");
64
82
  return 1;
@@ -79,6 +97,10 @@ export function runSync(root, names) {
79
97
  process.stderr.write(`skip ${bay.name}: no repo in manifest\n`);
80
98
  continue;
81
99
  }
100
+ if (!isSafeBayName(bay.name)) {
101
+ process.stderr.write(`error: garaje.yaml: unsafe bay name '${bay.name}' — fix the manifest (bay names must be plain directory names, not '.' or '..')\n`);
102
+ return 1;
103
+ }
82
104
  const dest = join(root, "bays", bay.name);
83
105
  if (existsSync(join(dest, ".git"))) {
84
106
  process.stdout.write(`==> ${bay.name}: fetching (${bay.ref})\n`);
@@ -106,5 +128,23 @@ export function runSync(root, names) {
106
128
  synced += 1;
107
129
  }
108
130
  process.stdout.write(`synced ${synced} bay(s) into bays/\n`);
109
- return 0;
131
+ if (noDeps)
132
+ return 0;
133
+ const driver = getDriver(driverContext(root));
134
+ if (!driver.runningServices().includes("pi")) {
135
+ process.stdout.write(" deps skipped — pi is not running (garaje up, then re-sync)\n");
136
+ return 0;
137
+ }
138
+ let depsFailed = 0;
139
+ // Width for both the "starting" line and the outcome line, so they line up:
140
+ // the longest bay name being reported this run, not a fixed guess (a fixed
141
+ // 8 silently ran a 9-char name like "hello-bay" straight into "deps").
142
+ const width = wanted.length ? Math.max(...wanted.map((b) => b.name.length)) : 0;
143
+ for (const bay of wanted) {
144
+ const outcome = installBayDeps(root, bay.name, driver, (name) => process.stdout.write(` ${name.padEnd(width)} deps installing (recipe setup — a cold cache can take a while)...\n`));
145
+ process.stdout.write(renderDepsOutcome(outcome, width));
146
+ if (outcome.kind === "failed")
147
+ depsFailed += 1;
148
+ }
149
+ return depsFailed > 0 ? 1 : 0;
110
150
  }
package/dist/manifest.js CHANGED
@@ -1,24 +1,40 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
- /** Parse the `bays:` list out of a garaje.yaml. Entries without a name are
5
- * dropped; `ref` defaults to main. Pure throws only on malformed YAML. */
6
- export function parseManifest(text) {
4
+ /** Parse the `bays:` list out of a garaje.yaml. `ref` defaults to main. Pure —
5
+ * throws on malformed YAML always, and (with `{ strict: true }`) on a
6
+ * well-formed-YAML entry that still can't yield a name. */
7
+ export function parseManifest(text, opts = {}) {
7
8
  const data = parseYaml(text);
8
- const bays = Array.isArray(data?.bays) ? data.bays : [];
9
- return bays
10
- .filter((b) => Boolean(b) && typeof b === "object")
11
- .map((b) => ({
12
- name: String(b.name ?? "").trim(),
13
- repo: String(b.repo ?? "").trim(),
14
- ref: String(b.ref ?? "main").trim() || "main",
15
- }))
16
- .filter((b) => b.name);
9
+ const rawBays = Array.isArray(data?.bays) ? data.bays : [];
10
+ const bays = [];
11
+ rawBays.forEach((b, i) => {
12
+ if (!b || typeof b !== "object") {
13
+ if (opts.strict)
14
+ throw new Error(`garaje.yaml: bays[${i}] is not a mapping`);
15
+ return;
16
+ }
17
+ const rec = b;
18
+ const name = String(rec.name ?? "").trim();
19
+ if (!name) {
20
+ if (opts.strict)
21
+ throw new Error(`garaje.yaml: bays[${i}] has no name`);
22
+ return;
23
+ }
24
+ bays.push({
25
+ name,
26
+ repo: String(rec.repo ?? "").trim(),
27
+ ref: String(rec.ref ?? "main").trim() || "main",
28
+ });
29
+ });
30
+ return bays;
17
31
  }
18
- /** Read <root>/garaje.yaml. Throws if it is missing or unparseable. */
19
- export function readManifest(root) {
32
+ /** Read <root>/garaje.yaml. Throws if it is missing or unparseable (and, with
33
+ * `{ strict: true }`, if a `bays:` entry can't yield a name — see
34
+ * ParseManifestOptions). */
35
+ export function readManifest(root, opts = {}) {
20
36
  const path = join(root, "garaje.yaml");
21
37
  if (!existsSync(path))
22
38
  throw new Error("no garaje.yaml at the garaje root");
23
- return parseManifest(readFileSync(path, "utf8"));
39
+ return parseManifest(readFileSync(path, "utf8"), opts);
24
40
  }
@@ -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
  }