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.
- package/dist/driver/compose.js +3 -0
- package/dist/host/deps.js +147 -0
- package/dist/host/doctor.js +81 -2
- 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/.pi/settings.json +2 -1
- package/templates/.pi-lens.json +11 -0
- package/templates/compose.framework.yaml +6 -0
- package/templates/pi/Dockerfile +48 -0
- package/templates/pi/toolchain/apt-packages.txt +0 -0
- package/templates/pi/toolchain/mise.toml +14 -0
|
@@ -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
|
+
}
|
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
|
@@ -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:
|