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.
- package/dist/driver/compose.js +3 -0
- package/dist/host/deps.js +147 -0
- package/dist/host/doctor.js +152 -6
- package/dist/host/provider.js +43 -0
- package/dist/host/sync.js +43 -3
- package/dist/manifest.js +31 -15
- package/dist/park/index.js +146 -8
- package/dist/park/recipe.js +16 -2
- package/dist/park/toolchain.js +170 -0
- package/dist/upgrade/index.js +259 -5
- package/dist/upgrade/managed.js +117 -0
- package/dist/upgrade/pins.js +4 -2
- package/package.json +1 -1
- package/templates/.env.example +14 -2
- package/templates/.pi/settings.json +5 -3
- package/templates/.pi-lens.json +11 -0
- package/templates/compose.framework.yaml +21 -1
- package/templates/gitignore +4 -0
- package/templates/pi/Dockerfile +48 -0
- package/templates/pi/toolchain/apt-packages.txt +0 -0
- package/templates/pi/toolchain/mise.toml +14 -0
package/dist/upgrade/index.js
CHANGED
|
@@ -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
|
|
71
|
-
|
|
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(`\
|
|
86
|
-
`
|
|
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
|
+
}
|
package/dist/upgrade/pins.js
CHANGED
|
@@ -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))
|
|
29
|
+
return sources.find((s) => /@garaje\/base|packages\/base/.test(s));
|
|
28
30
|
}
|
package/package.json
CHANGED
package/templates/.env.example
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
-
# Copy to .env
|
|
2
|
-
#
|
|
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": "
|
|
4
|
-
"defaultModel": "
|
|
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
|
}
|
|
@@ -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
|
-
-
|
|
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:
|
package/templates/gitignore
CHANGED
|
@@ -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/
|
package/templates/pi/Dockerfile
CHANGED
|
@@ -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"]
|