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.
@@ -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,9 +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";
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
+ }
8
55
  /** `garaje doctor` — host-side sanity checks. Exit code = failed-check count. */
9
56
  export function runDoctor(root) {
10
57
  let fails = 0;
@@ -59,10 +106,33 @@ export function runDoctor(root) {
59
106
  else
60
107
  fail(`@garaje/base path pin "${source}" missing — no package.json at ${dir}`);
61
108
  }
62
- if (existsSync(join(root, ".env")))
63
- pass(".env present");
64
- else
65
- warn(".env missing copy .env.example and add ANTHROPIC_API_KEY (or use /login)");
109
+ // .env is the garaje's single credential surface (compose forwards it via
110
+ // env_file). Provider-neutral: any non-empty *_API_KEY counts, so adding a
111
+ // new provider needs no change here.
112
+ const envPath = join(root, ".env");
113
+ if (!existsSync(envPath)) {
114
+ warn(".env missing — copy .env.example and add a provider key (e.g. MIXLAYER_API_KEY)");
115
+ }
116
+ else {
117
+ // .env could be a directory (stray mkdir, misconfigured tool) or otherwise
118
+ // unreadable; report that as a failed check rather than crashing (same
119
+ // contract as the garaje.yaml and recipe checks below).
120
+ let envText;
121
+ try {
122
+ envText = readFileSync(envPath, "utf8");
123
+ }
124
+ catch (e) {
125
+ fail(`.env unreadable — ${e.message}`);
126
+ }
127
+ if (envText !== undefined) {
128
+ if (hasInferenceCredential(envText)) {
129
+ pass(".env carries an inference credential");
130
+ }
131
+ else {
132
+ warn(".env has no non-empty *_API_KEY — pi will start but cannot answer");
133
+ }
134
+ }
135
+ }
66
136
  // --- runtime state ---
67
137
  section("runtime");
68
138
  if (driver.configOk())
@@ -84,11 +154,28 @@ export function runDoctor(root) {
84
154
  if (!bays.length)
85
155
  warn("no bays parked (add one to garaje.yaml, then garaje sync)");
86
156
  for (const b of bays) {
87
- 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)
88
160
  pass(`${b.name}: ${statusLine(b)} (${b.ref})`);
89
161
  else
90
162
  warn(`${b.name}: ${statusLine(b)} — run \`garaje sync ${b.name}\``);
91
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
+ }
92
179
  }
93
180
  catch (e) {
94
181
  fail(`bay status check errored — garaje.yaml failed to parse: ${e.message}`);
@@ -114,6 +201,22 @@ export function runDoctor(root) {
114
201
  catch (e) {
115
202
  fail(`bays artifact check errored — a recipe failed to parse: ${e.message}`);
116
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);
117
220
  // "Parked" (has a recipe.yaml) is a stricter state than "synced" (cloned);
118
221
  // only parked bays contribute to the artifact.
119
222
  const parked = discoverBays(root);
@@ -139,6 +242,49 @@ export function runDoctor(root) {
139
242
  pass(`pi binary works: ${piver}`);
140
243
  else
141
244
  fail("pi --version did not print a version");
245
+ // A key stored by `/login` outranks .env: model-registry resolves
246
+ // `apiKeyFromAuthStorage ?? providerConfig.apiKey`. Existence only — doctor
247
+ // never reads this file.
248
+ if (driver.execCapture("pi", ["test", "-f", "/root/.pi/agent/auth.json"]).status === 0) {
249
+ warn("/root/.pi/agent/auth.json exists — a stored `/login` credential OVERRIDES .env");
250
+ }
251
+ // Proves the provider extension loaded AND a credential is present. It
252
+ // does NOT validate the key: pi lists models for a bogus key exactly as it
253
+ // would for a real one — but with NO credential at all, `pi --list-models`
254
+ // exits 0 and prints "No models available" instead of a table. That is a
255
+ // separate condition from a model genuinely missing from a populated
256
+ // table, so it must warn, not fail (a freshly scaffolded garaje with no
257
+ // key yet must not report a hard failure here).
258
+ const model = existsSync(settings)
259
+ ? defaultModelOf(readFileSync(settings, "utf8"))
260
+ : undefined;
261
+ if (!model) {
262
+ warn("no defaultModel in .pi/settings.json — pi will prompt for a model");
263
+ }
264
+ else {
265
+ const listed = driver.execCapture("pi", ["pi", "--list-models"]);
266
+ if (listed.status !== 0) {
267
+ fail(`\`pi --list-models\` exited ${listed.status} — could not check defaultModel`);
268
+ }
269
+ else {
270
+ const verdict = classifyModelList(listed.stdout, model);
271
+ if (verdict === "no-models") {
272
+ // A credential can exist (e.g. a key for a provider this garaje
273
+ // isn't configured to use) while no *loaded* provider has a usable
274
+ // one — CI's exact case: MIXLAYER_API_KEY set, but this repo's
275
+ // settings select anthropic and no mixlayer extension is pinned.
276
+ // Naming only "no credential" would misdirect toward the key when
277
+ // the real cause may be a missing provider extension instead.
278
+ warn(`pi lists no models — no loaded provider has a usable credential (key missing, or its provider extension isn't installed); defaultModel "${model}" cannot be verified`);
279
+ }
280
+ else if (verdict === "listed") {
281
+ pass(`defaultModel registered: ${model} (extension loaded, credential present; key NOT validated)`);
282
+ }
283
+ else {
284
+ fail(`defaultModel "${model}" absent from \`pi --list-models\` — provider extension missing or model id wrong`);
285
+ }
286
+ }
287
+ }
142
288
  }
143
289
  // --- summary ---
144
290
  section("summary");
@@ -0,0 +1,43 @@
1
+ /** Pure helpers behind `garaje doctor`'s inference-provider checks. No fs, no
2
+ * spawn — doctor's container probes are covered by scripts/ci-smoke instead. */
3
+ /** True when a dotenv body sets any *_API_KEY to a non-empty value. A blank
4
+ * assignment doesn't count: a scaffolded `.env.example` copied verbatim has
5
+ * `MIXLAYER_API_KEY=` and therefore no credential. Lines may optionally be
6
+ * prefixed with `export ` (for shell sourcing compatibility). */
7
+ export function hasInferenceCredential(envText) {
8
+ for (const raw of envText.split(/\r?\n/)) {
9
+ const line = raw.trim();
10
+ if (!line || line.startsWith("#"))
11
+ continue;
12
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*_API_KEY)\s*=\s*(.*)$/.exec(line);
13
+ if (!m)
14
+ continue;
15
+ if (m[2].trim().replace(/^["']|["']$/g, "").length > 0)
16
+ return true;
17
+ }
18
+ return false;
19
+ }
20
+ /** The `defaultModel` a garaje's committed pi settings select, if any. */
21
+ export function defaultModelOf(settingsText) {
22
+ try {
23
+ const data = JSON.parse(settingsText);
24
+ return typeof data.defaultModel === "string" && data.defaultModel
25
+ ? data.defaultModel
26
+ : undefined;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ /** Whether `pi --list-models` output registers the given model id. */
33
+ export function modelListed(listOutput, model) {
34
+ return listOutput.split(/\r?\n/).some((l) => l.includes(model));
35
+ }
36
+ /** Classify `pi --list-models` output. pi prints "No models available" when no
37
+ * provider credential is present — a different condition from a model that is
38
+ * genuinely missing from a populated table. */
39
+ export function classifyModelList(listOutput, model) {
40
+ if (/no models available/i.test(listOutput))
41
+ return "no-models";
42
+ return modelListed(listOutput, model) ? "listed" : "absent";
43
+ }
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
  }