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.
@@ -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
+ }
@@ -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.5",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,6 +9,7 @@
9
9
  "source": "__BASE_PIN__",
10
10
  "extensions": ["extensions/**", "!extensions/session-name.ts"]
11
11
  },
12
- "npm:pi-mixlayer@0.4.0"
12
+ "npm:pi-mixlayer@0.4.0",
13
+ "npm:pi-lens"
13
14
  ]
14
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,6 +14,11 @@ 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
17
22
  # Provider credentials come from .env — a garaje's single credential surface.
18
23
  # Any pi provider extension (pi-mixlayer, pi-openai, …) resolves its key from
19
24
  # the container environment, so adding a provider needs no framework release.
@@ -72,3 +77,4 @@ services:
72
77
  volumes:
73
78
  pi-config:
74
79
  garaje-auth:
80
+ pi-deps: