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,9 +1,16 @@
1
1
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { classify } from "./classify.js";
6
7
  import { currentBaseVersion, rewriteBasePin, rewriteDockerfileCliPin } from "./pins.js";
8
+ import { DEFAULT_PACKAGES, isScaffoldedGaraje, mergeSettingsPackages, missingDefaultPackages, rematerialize, scaffoldPiLensConfig, } from "./managed.js";
9
+ import { discoverBays, readRecipes, readToolchainArtifact, writeToolchainArtifact, } from "../park/index.js";
10
+ import { generateToolchain, isEmptyToolchain, parseToolchainBays, } from "../park/toolchain.js";
11
+ import { readManifest } from "../manifest.js";
12
+ const HERE = dirname(fileURLToPath(import.meta.url));
13
+ const TEMPLATES = join(HERE, "../../templates"); // packages/cli/templates
7
14
  /** Resolve @garaje/base@<version> into a directory tree via `npm pack`, or
8
15
  * undefined if it can't be fetched (e.g. pre-publish / offline). */
9
16
  function fetchBaseTree(version) {
@@ -29,6 +36,200 @@ function parseTo(args) {
29
36
  return args[i + 1];
30
37
  return undefined;
31
38
  }
39
+ /** Re-read recipes one bay at a time to name whichever one is corrupt, for a
40
+ * better error message. Reuses readRecipes itself rather than re-implementing
41
+ * any parsing — this only narrows down which single bay a whole-list throw
42
+ * came from. */
43
+ function describeRecipeFailure(root, bays, err) {
44
+ for (const bay of bays) {
45
+ try {
46
+ readRecipes(root, [bay]);
47
+ }
48
+ catch (e) {
49
+ return `bay '${bay}' has a corrupt recipe.yaml (${e.message}) — fix it, then re-run upgrade`;
50
+ }
51
+ }
52
+ return `a bay recipe.yaml failed to parse (${err.message}) — fix it, then re-run upgrade`;
53
+ }
54
+ /** Bays the manifest names but discoverBays() didn't find parked (no
55
+ * recipe.yaml) — split into "not even cloned" (garaje sync will help) vs
56
+ * "cloned but missing recipe.yaml" (sync is a no-op there; `garaje park`
57
+ * only fails outright when NOTHING anywhere is parked, so it silently
58
+ * excludes this bay rather than fixing it — the real remedy is authoring
59
+ * bays/<name>/recipe.yaml). doctor.ts draws this same "parked is stricter
60
+ * than synced/cloned" line; this mirrors it with a concrete per-bay remedy
61
+ * instead of one blanket "sync then park" that is actively wrong for a
62
+ * bay that is already cloned. */
63
+ function describeUnparkedBays(root, names) {
64
+ const notCloned = names.filter((n) => !existsSync(join(root, "bays", n, ".git")));
65
+ const clonedNoRecipe = names.filter((n) => !notCloned.includes(n));
66
+ const parts = [];
67
+ if (notCloned.length) {
68
+ parts.push(`not synced: ${notCloned.join(", ")} (run \`garaje sync\`, then \`garaje park\`)`);
69
+ }
70
+ if (clonedNoRecipe.length) {
71
+ const authorHint = clonedNoRecipe.length === 1
72
+ ? `bays/${clonedNoRecipe[0]}/recipe.yaml`
73
+ : clonedNoRecipe.map((n) => `bays/${n}/recipe.yaml`).join(", ");
74
+ parts.push(`cloned but missing recipe.yaml: ${clonedNoRecipe.join(", ")} (author ${authorHint}, ` +
75
+ "then `garaje park` — `garaje sync` is a no-op here)");
76
+ }
77
+ return parts.join("; ");
78
+ }
79
+ /**
80
+ * Decide the pi/toolchain/ outcome for `garaje upgrade`, using BOTH the
81
+ * committed manifest (garaje.yaml, read STRICTLY via readManifest — see
82
+ * below) and what is actually on disk (discoverBays) — never discoverBays
83
+ * alone, and never manifestBays().
84
+ *
85
+ * bays/* is gitignored in every garaje: a fresh clone has the manifest but
86
+ * none of the bay working trees. discoverBays() then returns [], and
87
+ * regenerating from that would silently gut a correct, committed toolchain
88
+ * artifact down to empty (Finding A) — worse than the bug this replaced,
89
+ * because `garaje up --build` then succeeds with a Ruby-less pi container.
90
+ *
91
+ * Decision table:
92
+ * - garaje.yaml unreadable (missing, corrupt, OR — strict mode — a `bays:`
93
+ * entry with no usable `name:`) and pi/toolchain/ IS committed -> leave it
94
+ * (it is still the last-known truth) and say why.
95
+ * - garaje.yaml unreadable and pi/toolchain/ is NOT committed -> fail before
96
+ * any write: there is nothing to derive the toolchain from and nothing to
97
+ * fall back on (Finding C — manifestBays() used to collapse this into the
98
+ * same `[]` as "the manifest genuinely declares no bays", and `write` then
99
+ * gutted or fabricated an empty artifact instead of failing loud).
100
+ * - every manifest bay parked (including the vacuous case: manifest declares
101
+ * none) -> regenerate from the parked recipes; readRecipes can still throw
102
+ * on a corrupt recipe, which this catches and reports as "fail" rather than
103
+ * letting it escape as an uncaught exception (Finding B) — UNLESS doing so
104
+ * would NARROW a committed artifact (see the structural invariant below),
105
+ * in which case that fires instead of "write".
106
+ * - some of the manifest's bays are not parked -> the committed artifact, if
107
+ * present, was generated from these recipes when they WERE parked, so it
108
+ * is still ground truth: leave it untouched and say why (which bay(s), and
109
+ * the CORRECT remedy per bay — see describeUnparkedBays).
110
+ * - same, but pi/toolchain/ doesn't exist at all -> nothing to fall back on;
111
+ * fail the upgrade outright rather than materialize a Dockerfile whose
112
+ * `COPY toolchain/` has nothing to copy.
113
+ *
114
+ * STRUCTURAL INVARIANT: never replace a committed toolchain artifact with one
115
+ * derived from a SMALLER bay set than the artifact itself records. Four
116
+ * separate bugs have now reached "artifact silently narrowed, image builds,
117
+ * pi is missing a runtime" by four different routes — a gitignored bays/ on a
118
+ * fresh clone, a corrupt garaje.yaml, a `bays:` entry silently dropped for
119
+ * lacking `name:` (syntactically valid YAML — parseManifest's lenient mode
120
+ * does not throw on it), and a bay parked locally but never added to
121
+ * garaje.yaml at all. Patching each route individually kept finding a new
122
+ * one; the actual invariant is about the ARTIFACT, not any one cause:
123
+ * `generateToolchain` now records the bay set it was derived from in a
124
+ * `# bays: ...` comment (parseToolchainBays reads it back). If the committed
125
+ * artifact's recorded set is not a subset of the set we are about to
126
+ * regenerate from — i.e. regenerating would drop a bay the committed
127
+ * artifact reflects — refuse, whatever the cause, and name the dropped
128
+ * bay(s). Regenerating from the SAME set, or a LARGER one, is unaffected.
129
+ * `isEmptyToolchain` remains as a backstop for an artifact committed before
130
+ * this provenance line existed (no `# bays:` to compare against).
131
+ */
132
+ export function planToolchain(root) {
133
+ const committed = readToolchainArtifact(root); // undefined = nothing committed at all
134
+ // Read the manifest STRICTLY — this is a WRITER, not a check. manifestBays()
135
+ // is lenient (missing/corrupt -> []) because every OTHER caller is a check
136
+ // that reports a bad manifest and moves on; see its docstring in
137
+ // ../park/index.ts for why a writer must not reuse that leniency. Strict
138
+ // mode additionally throws on a `bays:` entry with no usable name — see
139
+ // ParseManifestOptions in ../manifest.ts — so THAT silent-drop route is
140
+ // caught here directly, same as a missing/corrupt garaje.yaml, rather than
141
+ // relying solely on the narrowing check below to notice its effect.
142
+ let manifest;
143
+ try {
144
+ manifest = readManifest(root, { strict: true });
145
+ }
146
+ catch (e) {
147
+ const reason = e.message;
148
+ if (committed) {
149
+ return {
150
+ action: "leave",
151
+ message: `garaje.yaml is unreadable (${reason}) — pi/toolchain/ left as committed (fix garaje.yaml, then re-run upgrade)`,
152
+ };
153
+ }
154
+ return {
155
+ action: "fail",
156
+ message: `garaje.yaml is unreadable (${reason}) and pi/toolchain/ does not exist — the toolchain can't ` +
157
+ `be derived and the image won't build. Fix garaje.yaml, then re-run upgrade.`,
158
+ };
159
+ }
160
+ const manifestNames = manifest.map((b) => b.name);
161
+ const parked = discoverBays(root);
162
+ const unparked = manifestNames.filter((b) => !parked.includes(b));
163
+ if (unparked.length === 0) {
164
+ let recipes;
165
+ try {
166
+ recipes = readRecipes(root, parked);
167
+ }
168
+ catch (e) {
169
+ return { action: "fail", message: describeRecipeFailure(root, parked, e) };
170
+ }
171
+ // generateToolchain can ALSO throw (an unsafe bay name, or — the
172
+ // structural backstop — a provenance round-trip failure), and used to do
173
+ // so OUTSIDE any try/catch here, routing around the `action: "fail"` path
174
+ // this whole ToolchainPlan design exists to provide: the exception
175
+ // propagated straight out of planToolchain, then out of runUpgrade (which
176
+ // has no top-level catch either), and `garaje upgrade` printed a raw
177
+ // Node stack trace instead of a clean, actionable failure.
178
+ let toolchain;
179
+ try {
180
+ toolchain = generateToolchain(recipes);
181
+ }
182
+ catch (e) {
183
+ return {
184
+ action: "fail",
185
+ message: `pi/toolchain/ can't be generated (${e.message}) — fix the recipe, then re-run upgrade`,
186
+ };
187
+ }
188
+ if (committed) {
189
+ const committedBays = parseToolchainBays(committed.miseToml);
190
+ if (committedBays !== undefined) {
191
+ // The provenance line is present — do the precise per-bay diff.
192
+ const newBays = new Set(recipes.map((r) => r.name));
193
+ const dropped = committedBays.filter((b) => !newBays.has(b));
194
+ if (dropped.length > 0) {
195
+ return {
196
+ action: "fail",
197
+ message: `regenerating pi/toolchain/ from garaje.yaml would drop bay(s) the committed artifact ` +
198
+ `was derived from: ${dropped.join(", ")} — refusing to narrow it. This can happen even ` +
199
+ "with syntactically valid YAML (e.g. a `bays:` entry missing `name:`), or when a bay is " +
200
+ "parked locally but not (yet) declared in garaje.yaml. Sync the missing bay(s) " +
201
+ "(`garaje sync`) and re-run upgrade, or fix garaje.yaml; if you really mean to drop " +
202
+ "them, run `garaje park` yourself, then re-run upgrade.",
203
+ };
204
+ }
205
+ }
206
+ else if (isEmptyToolchain(toolchain) && !isEmptyToolchain(committed)) {
207
+ // Backstop: the committed artifact predates the `# bays:` provenance
208
+ // line, so there is nothing to diff against — fall back to the
209
+ // coarser "would this regeneration go empty" check.
210
+ return {
211
+ action: "fail",
212
+ message: `regenerating pi/toolchain/ from garaje.yaml would produce an EMPTY artifact, but the ` +
213
+ `committed one is not — refusing to overwrite it. This can happen even with syntactically ` +
214
+ "valid YAML (e.g. a `bays:` entry missing `name:`). Check garaje.yaml; if you really want to " +
215
+ "shrink to zero bays, run `garaje park` yourself, then re-run upgrade.",
216
+ };
217
+ }
218
+ }
219
+ return { action: "write", recipes };
220
+ }
221
+ if (!committed) {
222
+ return {
223
+ action: "fail",
224
+ message: `pi/toolchain/ is missing and cannot be derived — ${describeUnparkedBays(root, unparked)}. ` +
225
+ `Re-run upgrade once fixed.`,
226
+ };
227
+ }
228
+ return {
229
+ action: "leave",
230
+ message: `${describeUnparkedBays(root, unparked)} — pi/toolchain/ left as committed`,
231
+ };
232
+ }
32
233
  /** `garaje upgrade [--to <version>]` — bump the base + CLI pins and classify. */
33
234
  export function runUpgrade(root, args) {
34
235
  const settingsPath = join(root, ".pi/settings.json");
@@ -67,23 +268,76 @@ export function runUpgrade(root, args) {
67
268
  if (t)
68
269
  rmSync(t.replace(/\/package$/, ""), { recursive: true, force: true });
69
270
  }
70
- // Rewrite the three committed pins.
71
- writeFileSync(settingsPath, rewriteBasePin(settingsText, to));
271
+ // Rewrite the committed pins, then re-materialize the framework-managed files
272
+ // and merge the framework-default packages. Without the latter two, a
273
+ // framework change reaches NEW garajes only. Both the rematerialize and the
274
+ // settings merge are declined in the framework monorepo (base pinned by
275
+ // relative path, not npm:) — one guard, one meaning: "this is not a
276
+ // scaffolded garaje, so upgrade does nothing structural to it." The
277
+ // version-pin rewrites above/below still run in both cases.
278
+ const scaffolded = isScaffoldedGaraje(root);
279
+ // Decide the pi/toolchain/ outcome BEFORE any write below runs. A
280
+ // re-materialized pi/Dockerfile's `COPY toolchain/` step must never be
281
+ // written on a promise the toolchain regen can't keep — a corrupt recipe or
282
+ // an undeliverable artifact has to fail upgrade OUTRIGHT, before pins,
283
+ // pi/Dockerfile, or anything else is touched, so a failed upgrade leaves the
284
+ // garaje exactly as it was (see planToolchain). Declined for the monorepo,
285
+ // same guard as rematerialize: pi/Dockerfile there is hand-maintained, not
286
+ // re-materialized, so upgrade has no reason to touch its sibling artifact.
287
+ const toolchainPlan = scaffolded ? planToolchain(root) : { action: "skip" };
288
+ if (toolchainPlan.action === "fail") {
289
+ process.stderr.write(`garaje upgrade: ${toolchainPlan.message}\n`);
290
+ return 1;
291
+ }
292
+ const rewritten = rewriteBasePin(settingsText, to);
293
+ const merged = scaffolded ? mergeSettingsPackages(rewritten, DEFAULT_PACKAGES) : rewritten;
294
+ writeFileSync(settingsPath, merged);
72
295
  if (existsSync(dockerfilePath)) {
73
296
  writeFileSync(dockerfilePath, rewriteDockerfileCliPin(readFileSync(dockerfilePath, "utf8"), to));
74
297
  }
75
298
  writeFileSync(versionPath, `${to}\n`);
299
+ const managed = rematerialize(root, TEMPLATES, to);
300
+ // Apply the toolchain decision made above. "write" (co-covers both "every
301
+ // manifest bay synced" and "manifest declares none" — an empty recipe list
302
+ // yields the valid empty artifact) and "leave" (bay(s) unsynced but the
303
+ // committed artifact still stands, untouched) are the only two live cases
304
+ // here; "fail" already returned above, and "skip" is the monorepo no-op.
305
+ if (toolchainPlan.action === "write") {
306
+ writeToolchainArtifact(root, toolchainPlan.recipes);
307
+ }
308
+ // Scaffold .pi-lens.json if this garaje predates it (create-only — never
309
+ // clobber a customized one).
310
+ const piLensCreated = scaffoldPiLensConfig(root, TEMPLATES);
76
311
  // Report.
77
312
  process.stdout.write(`garaje upgrade: pins bumped ${from ?? "(local)"} -> ${to}\n`);
78
313
  for (const f of findings) {
79
314
  process.stdout.write(` ${f.kind.toUpperCase()} [${f.resource}] ${f.name}: ${f.detail}\n`);
80
315
  }
316
+ for (const m of managed) {
317
+ process.stdout.write(` managed ${m.file.padEnd(24)}${m.changed ? "rewritten" : "unchanged"}\n`);
318
+ }
319
+ if (toolchainPlan.action === "write") {
320
+ process.stdout.write(` managed pi/toolchain/ regenerated from ${toolchainPlan.recipes.length} bay recipe(s)\n`);
321
+ }
322
+ else if (toolchainPlan.action === "leave") {
323
+ process.stdout.write(` managed pi/toolchain/ left as-is (${toolchainPlan.message})\n`);
324
+ }
325
+ if (piLensCreated) {
326
+ process.stdout.write(` scaffold .pi-lens.json created (was missing; template defaults)\n`);
327
+ }
328
+ const addedPkgs = scaffolded ? missingDefaultPackages(settingsText, DEFAULT_PACKAGES) : [];
329
+ for (const p of addedPkgs) {
330
+ process.stdout.write(` merged .pi/settings.json + ${p}\n`);
331
+ }
81
332
  const nextHeader = classified
82
333
  ? ` # review the ${findings.length} override(s) above, then:\n`
83
334
  : ` # classification was skipped (base trees unavailable) — re-run this after the ` +
84
335
  `packages are published to check for shadow/orphan overrides, then:\n`;
85
- process.stdout.write(`\nNext:\n${nextHeader}` +
86
- ` npm i -g garaje@${to}\n garaje up --build\n git add -A && git commit -m 'upgrade garaje to ${to}'\n`);
336
+ process.stdout.write(`\nNote: this upgrade ran using the CLI you already have installed — install ` +
337
+ `garaje@${to} globally BEFORE running \`garaje upgrade\`, not after (the ` +
338
+ `version that performs the upgrade is whatever is already on PATH).\n` +
339
+ `\nNext:\n${nextHeader}` +
340
+ ` garaje up --build\n garaje sync\n git add -A && git commit -m 'upgrade garaje to ${to}'\n`);
87
341
  return findings.length > 0 ? 1 : 0;
88
342
  }
89
343
  /** Latest published `garaje` version, or undefined (pre-publish / offline). */
@@ -0,0 +1,117 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { basePinSource } from "./pins.js";
4
+ import { driverContext, getDriver } from "../driver/index.js";
5
+ /**
6
+ * Files the FRAMEWORK owns end-to-end. They are already write-protected from the
7
+ * agent, and — since the toolchain moved into the generated pi/toolchain/ —
8
+ * they carry no per-garaje content at all. That is what makes overwriting them
9
+ * safe, and what lets a framework change reach a garaje that already exists.
10
+ *
11
+ * .pi/settings.json is deliberately NOT here: it is garaje-owned (model,
12
+ * provider, their own packages) and is MERGED instead. See mergeSettingsPackages.
13
+ *
14
+ * The compose file(s) are NOT hardcoded here: compose filenames are
15
+ * driver-internal knowledge (north-star §4.1 — the runtime-driver seam is the
16
+ * only dialect layers above codegen may speak). We ask the driver for its own
17
+ * frameworkOwnedFiles() instead — NOT requiredFiles(), which only promises a
18
+ * file must exist (doctor), not that it is safe to overwrite — so a future
19
+ * k8s driver (Phase 8) gets its own framework-owned manifests re-materialized
20
+ * for free, without also clobbering a manifest a dev is meant to customize,
21
+ * and no compose literal leaks above the seam.
22
+ */
23
+ export function managedFiles(root) {
24
+ const driver = getDriver(driverContext(root));
25
+ return ["pi/Dockerfile", "pi/entrypoint.sh", ...driver.frameworkOwnedFiles()];
26
+ }
27
+ /** pi packages every garaje should have. Merged into .pi/settings.json, never
28
+ * imposed over the garaje's own choices. */
29
+ export const DEFAULT_PACKAGES = ["npm:pi-lens"];
30
+ /** A package spec without its version: npm:pi-lens@1.2.3 -> npm:pi-lens. The
31
+ * leading @ of a scoped name (npm:@garaje/base) is not a version separator —
32
+ * only an @ after the last / (i.e. after the scope) can introduce a version. */
33
+ export function packageName(spec) {
34
+ const at = spec.lastIndexOf("@");
35
+ const boundary = Math.max(spec.indexOf(":"), spec.lastIndexOf("/"));
36
+ return at > boundary ? spec.slice(0, at) : spec;
37
+ }
38
+ /** Names already present in settingsText's package list (by packageName, not
39
+ * exact spec — a default already pinned to a version still counts). */
40
+ function presentPackageNames(settingsText) {
41
+ const data = JSON.parse(settingsText);
42
+ const packages = Array.isArray(data.packages) ? data.packages : [];
43
+ return new Set(packages
44
+ .map((p) => (typeof p === "string" ? p : p?.source))
45
+ .filter((s) => typeof s === "string")
46
+ .map(packageName));
47
+ }
48
+ /** Which of `defaults` are absent from settingsText, matched by package name
49
+ * the same way mergeSettingsPackages decides what to add — so a stdout
50
+ * report of "what got added" can never disagree with what actually did. */
51
+ export function missingDefaultPackages(settingsText, defaults) {
52
+ const present = presentPackageNames(settingsText);
53
+ return defaults.filter((def) => !present.has(packageName(def)));
54
+ }
55
+ /** Add any missing framework-default packages. The garaje's model, provider,
56
+ * theme and own packages are untouched, and a default it already pinned to a
57
+ * version is left at that version. */
58
+ export function mergeSettingsPackages(settingsText, defaults) {
59
+ const data = JSON.parse(settingsText);
60
+ const packages = Array.isArray(data.packages) ? [...data.packages] : [];
61
+ for (const def of missingDefaultPackages(settingsText, defaults)) {
62
+ packages.push(def);
63
+ }
64
+ data.packages = packages;
65
+ return `${JSON.stringify(data, null, 2)}\n`;
66
+ }
67
+ /** True for a scaffolded garaje (base pinned from npm), false for the framework
68
+ * monorepo (base pinned by relative path), whose root pi/Dockerfile legitimately
69
+ * differs from the template and must never be clobbered. Also gates the
70
+ * .pi/settings.json package merge: the monorepo's own settings.json must not
71
+ * be touched either. */
72
+ export function isScaffoldedGaraje(root) {
73
+ const settings = join(root, ".pi", "settings.json");
74
+ if (!existsSync(settings))
75
+ return false;
76
+ const source = basePinSource(readFileSync(settings, "utf8"));
77
+ return Boolean(source?.startsWith("npm:"));
78
+ }
79
+ /** Scaffold `.pi-lens.json` from the template if it is missing. It is
80
+ * garaje-owned once it exists (a garaje may have customized its ignore
81
+ * list), so this only ever CREATES — never overwrites. Declined for the
82
+ * monorepo, same guard as rematerialize/mergeSettingsPackages: this repo is
83
+ * not a scaffolded garaje. Returns true iff the file was created. */
84
+ export function scaffoldPiLensConfig(root, templatesDir) {
85
+ if (!isScaffoldedGaraje(root))
86
+ return false;
87
+ const dest = join(root, ".pi-lens.json");
88
+ if (existsSync(dest))
89
+ return false;
90
+ const template = join(templatesDir, ".pi-lens.json");
91
+ if (!existsSync(template))
92
+ return false;
93
+ writeFileSync(dest, readFileSync(template, "utf8"));
94
+ return true;
95
+ }
96
+ /** Rewrite every framework-managed file from the CLI's bundled templates.
97
+ * Returns one outcome per file; an empty array means we declined (monorepo). */
98
+ export function rematerialize(root, templatesDir, version) {
99
+ if (!isScaffoldedGaraje(root))
100
+ return [];
101
+ const out = [];
102
+ for (const rel of managedFiles(root)) {
103
+ const templatePath = join(templatesDir, rel);
104
+ if (!existsSync(templatePath))
105
+ continue;
106
+ const want = readFileSync(templatePath, "utf8").split("__GARAJE_VERSION__").join(version);
107
+ const dest = join(root, rel);
108
+ const have = existsSync(dest) ? readFileSync(dest, "utf8") : undefined;
109
+ if (have === want) {
110
+ out.push({ file: rel, changed: false });
111
+ continue;
112
+ }
113
+ writeFileSync(dest, want);
114
+ out.push({ file: rel, changed: true });
115
+ }
116
+ return out;
117
+ }
@@ -12,7 +12,9 @@ export function currentBaseVersion(settingsText) {
12
12
  return m ? m[1] : undefined;
13
13
  }
14
14
  /** The @garaje/base package source pinned in .pi/settings.json: an `npm:` spec
15
- * in a scaffolded garaje, a relative path in the framework monorepo. */
15
+ * in a scaffolded garaje, a relative path in the framework monorepo. Returns
16
+ * undefined if no package matches — the caller must treat that as "not
17
+ * pinned", not fall back to an unrelated package. */
16
18
  export function basePinSource(settingsText) {
17
19
  let sources;
18
20
  try {
@@ -24,5 +26,5 @@ export function basePinSource(settingsText) {
24
26
  catch {
25
27
  return undefined;
26
28
  }
27
- return sources.find((s) => /@garaje\/base|packages\/base/.test(s)) ?? sources[0];
29
+ return sources.find((s) => /@garaje\/base|packages\/base/.test(s));
28
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "garaje",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,15 @@
1
- # Copy to .env and fill in to use API-key auth for the pi container.
2
- # Alternatively leave blank and run `/login` inside pi (persisted via the pi-config volume).
1
+ # Copy to .env, then fill in one provider key.
2
+ #
3
+ # This file is the garaje's ONLY credential surface: compose forwards .env into
4
+ # the pi container (env_file). A variable exported in your shell does NOT reach
5
+ # the agent.
6
+
7
+ # The scaffolded provider. Create a key at https://console.mixlayer.com/app/api-keys
8
+ MIXLAYER_API_KEY=
9
+
10
+ # Only needed if you switch .pi/settings.json back to defaultProvider "anthropic".
3
11
  ANTHROPIC_API_KEY=
12
+
13
+ # NOTE: a key stored by `/login` inside pi is written to ~/.pi/agent/auth.json on
14
+ # the pi-config volume, survives rebuilds, and TAKES PRECEDENCE over this file.
15
+ # `garaje doctor` warns when that file exists.
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "$schema": "https://pi.dev/schemas/settings.json",
3
- "defaultProvider": "anthropic",
4
- "defaultModel": "claude-opus-4-8",
3
+ "defaultProvider": "mixlayer",
4
+ "defaultModel": "qwen/qwen3.5-397b-a17b",
5
5
  "defaultThinkingLevel": "medium",
6
6
  "theme": "dark",
7
7
  "packages": [
8
8
  {
9
9
  "source": "__BASE_PIN__",
10
10
  "extensions": ["extensions/**", "!extensions/session-name.ts"]
11
- }
11
+ },
12
+ "npm:pi-mixlayer@0.4.0",
13
+ "npm:pi-lens"
12
14
  ]
13
15
  }
@@ -0,0 +1,11 @@
1
+ {
2
+ "ignore": [
3
+ "sessions/**",
4
+ "scratch/**",
5
+ ".pi/**",
6
+ "bays/*/node_modules/**",
7
+ "bays/*/tmp/**",
8
+ "bays/*/log/**",
9
+ "bays/*/vendor/**"
10
+ ]
11
+ }
@@ -14,8 +14,23 @@ services:
14
14
  - ./:/workspace
15
15
  - pi-config:/root/.pi/agent
16
16
  - garaje-auth:/root/.garaje-auth
17
+ # pi-side project dependencies (gems, node_modules) for the parked bays,
18
+ # installed by `garaje sync`. Names NO bay on purpose: the framework layer
19
+ # stays codebase-agnostic, so the framework/bays compose split that carries
20
+ # containment (bays scope excludes this file entirely) is untouched.
21
+ - pi-deps:/garaje/deps
22
+ # Provider credentials come from .env — a garaje's single credential surface.
23
+ # Any pi provider extension (pi-mixlayer, pi-openai, …) resolves its key from
24
+ # the container environment, so adding a provider needs no framework release.
25
+ # A shell-exported variable does NOT reach the agent; put it in .env.
26
+ # required:false keeps `garaje up` working before .env exists.
27
+ env_file:
28
+ - path: .env
29
+ required: false
17
30
  environment:
18
- - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
31
+ # Framework-owned. `environment:` overrides `env_file`, so a stray .env
32
+ # entry cannot hijack any of these.
33
+ #
19
34
  # The agent's ONLY docker endpoint: the whitelisting proxy below.
20
35
  # The raw socket is never mounted here (north-star §5.1 layer 1).
21
36
  - DOCKER_HOST=tcp://docker-proxy:2375
@@ -26,6 +41,10 @@ services:
26
41
  # bind mounts use ${GARAJE_HOST_ROOT:-.} because bind sources resolve
27
42
  # on the daemon (host) side even when compose runs in-container.
28
43
  - GARAJE_HOST_ROOT=${PWD:-}
44
+ # pi/Dockerfile sets this via ENV, which env_file (unlike environment:)
45
+ # does NOT outrank — restated here so a stray .env line cannot redirect
46
+ # the agent's gh config directory (and its git/gh credential wiring).
47
+ - GH_CONFIG_DIR=/root/.garaje-auth/gh
29
48
  depends_on:
30
49
  - docker-proxy
31
50
 
@@ -58,3 +77,4 @@ services:
58
77
  volumes:
59
78
  pi-config:
60
79
  garaje-auth:
80
+ pi-deps:
@@ -24,3 +24,7 @@ bays/*
24
24
 
25
25
  # Dev-local garaje overrides (per-bay bind-mount paths, personal settings).
26
26
  garaje.local.yaml
27
+
28
+ # Pi packages installed into project scope by `pi install -l`. The package
29
+ # manifest lives in the committed .pi/settings.json; these are its artifacts.
30
+ /.pi/npm/
@@ -21,6 +21,54 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
21
21
  && apt-get update && apt-get install -y --no-install-recommends gh docker-ce-cli docker-buildx-plugin docker-compose-plugin \
22
22
  && rm -rf /var/lib/apt/lists/*
23
23
 
24
+ # --- Per-bay toolchains -------------------------------------------------------
25
+ # GENERATED by `garaje park` from every parked bay's recipe.yaml — the same
26
+ # declaration the bay images build from. The agent's container therefore carries
27
+ # exactly the runtimes of the code it edits, which is what lets pi-lens spawn a
28
+ # language server and a linter per bay.
29
+ #
30
+ # These layers sit BEFORE the pi/npm layers on purpose. Docker invalidates every
31
+ # layer after a changed one; Ruby compiles from source under mise (~5-10 min).
32
+ # A PI_VERSION bump is frequent and must NOT recompile Ruby. Parking a new bay
33
+ # is rare, and may legitimately pay that recompile cost.
34
+ COPY toolchain/ /tmp/toolchain/
35
+
36
+ # The apt list is a bare, newline-separated file (no comments — xargs would read
37
+ # a '#' line as a package name). Empty for a garaje with no bays, hence the -s test.
38
+ RUN if [ -s /tmp/toolchain/apt-packages.txt ]; then \
39
+ apt-get update \
40
+ && xargs -a /tmp/toolchain/apt-packages.txt \
41
+ apt-get install -y --no-install-recommends \
42
+ && rm -rf /var/lib/apt/lists/*; \
43
+ fi
44
+
45
+ # mise in SHIMS mode. A shim resolves its tool's version from the CALLING
46
+ # directory — and pi-lens spawns language servers with cwd = the bay root. So
47
+ # each bay's own .ruby-version selects its runtime with zero integration code,
48
+ # and two bays on different Ruby versions each get the right one.
49
+ ENV MISE_DATA_DIR=/root/.local/share/mise
50
+ ENV PATH=/root/.local/share/mise/shims:/root/.local/bin:$PATH
51
+
52
+ # Pinned for build reproducibility: an unpinned installer means two teammates
53
+ # who build a month apart get different mise versions — not theoretical, this
54
+ # exact version's install already warns "precompiled ruby will be the default
55
+ # in 2026.8.0". Bump deliberately, matching PI_VERSION's own pinning discipline.
56
+ ARG MISE_VERSION=2026.7.5
57
+ RUN export MISE_VERSION="${MISE_VERSION}" \
58
+ && curl -fsSL https://mise.run | sh \
59
+ && mkdir -p /root/.config/mise \
60
+ && cp /tmp/toolchain/mise.toml /root/.config/mise/config.toml \
61
+ && mise install --yes \
62
+ && mise reshim \
63
+ && rm -rf /tmp/toolchain
64
+
65
+ # pi-side project dependencies live on the pi-deps volume (compose.framework.yaml).
66
+ # ONE shared BUNDLE_PATH serves EVERY bay: bundler namespaces gems by Ruby ABI
67
+ # directory and activates strictly from the calling project's Gemfile.lock. So
68
+ # `bundle exec rubocop` in bays/rtfm gets rtfm's pinned cops and cannot see
69
+ # stacey's gems. Verified against ruby:3.4-slim — see the design spec §2.
70
+ ENV BUNDLE_PATH=/garaje/deps/bundle
71
+
24
72
  # Pi version is pinned in pi/PI_VERSION so a pi bump is a one-file diff
25
73
  # teammates pick up on `git pull && garaje up --build`. Bring the file in first
26
74
  # as its own layer so editing it busts the cache for the npm install layer
File without changes
@@ -0,0 +1,14 @@
1
+ # GENERATED by `garaje park` from bays/*/recipe.yaml. Do not edit by hand.
2
+ #
3
+ # The union of every parked bay's declared runtimes. mise installs all of
4
+ # them; each bay's own .ruby-version / .node-version then selects which one
5
+ # applies inside that bay. pi-lens spawns language servers with cwd = the bay
6
+ # root, so the mise shim on PATH resolves the right version with no glue.
7
+ #
8
+ # bays: (none)
9
+
10
+ [tools]
11
+
12
+ [settings]
13
+ # Honor each bay's .ruby-version / .node-version (they already ship them).
14
+ idiomatic_version_file_enable_tools = ["node", "ruby"]