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
package/dist/driver/compose.js
CHANGED
|
@@ -120,6 +120,9 @@ export class ComposeDriver {
|
|
|
120
120
|
requiredFiles() {
|
|
121
121
|
return ["docker-compose.yml", "compose.framework.yaml"];
|
|
122
122
|
}
|
|
123
|
+
frameworkOwnedFiles() {
|
|
124
|
+
return ["docker-compose.yml", "compose.framework.yaml"];
|
|
125
|
+
}
|
|
123
126
|
}
|
|
124
127
|
/** The generated bays layer's presence — driver-internal filename knowledge. */
|
|
125
128
|
export function hasBaysLayer(root) {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { parseRecipe } from "../park/recipe.js";
|
|
5
|
+
/** Lockfiles whose content decides whether a bay's pi-side deps are stale. */
|
|
6
|
+
const LOCKFILES = ["Gemfile.lock", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
|
|
7
|
+
/** A bay name is interpolated into an `sh -c` script below, and is also joined
|
|
8
|
+
* onto `root` as a bare path component (here and in sync.ts). Anything outside
|
|
9
|
+
* this set is refused rather than escaped — bays are directory names, and a
|
|
10
|
+
* name that needs shell escaping is a name we do not want. The character
|
|
11
|
+
* class alone is not enough: `.` and `..` are pure runs of an allowed
|
|
12
|
+
* character but are not bay directories at all — they resolve to `bays/`
|
|
13
|
+
* itself or the garaje root, so both are refused explicitly too. */
|
|
14
|
+
const SAFE_BAY = /^[A-Za-z0-9._-]+$/;
|
|
15
|
+
/** Whether `name` is safe to use both as a shell argument (installBayDeps) and
|
|
16
|
+
* as a `bays/<name>` path component (installBayDeps; sync.ts's clone/fetch
|
|
17
|
+
* destination; and sync.ts's bayStatus, which otherwise resolves '..' to the
|
|
18
|
+
* garaje root and reports it "synced" at the root's own HEAD). Exported so
|
|
19
|
+
* every call site shares one guard instead of each keeping its own copy of
|
|
20
|
+
* the regex and the `.`/`..` exclusion.
|
|
21
|
+
*
|
|
22
|
+
* Takes `unknown`, not `string`, and leads with a `typeof` check rather than
|
|
23
|
+
* handing the value straight to `SAFE_BAY.test`. `RegExp.prototype.test`
|
|
24
|
+
* coerces its argument to a string before matching — so `test(null)` matches
|
|
25
|
+
* against the literal string `"null"` and PASSES. A caller with a real
|
|
26
|
+
* compile-time `string` type never notices; the hazard is every caller that
|
|
27
|
+
* reaches this with a value whose static type lies — e.g. a `Recipe.name`
|
|
28
|
+
* read off an unchecked `as Recipe` cast over parsed YAML, where a `name:`
|
|
29
|
+
* key that parses to `null` is reachable at runtime despite the field being
|
|
30
|
+
* typed `string`. This is a security guard (path traversal, shell
|
|
31
|
+
* injection) as well as a provenance guard, so it must fail closed for
|
|
32
|
+
* every non-string input, not just the ones some caller's types happen to
|
|
33
|
+
* rule out. */
|
|
34
|
+
export function isSafeBayName(name) {
|
|
35
|
+
return typeof name === "string" && SAFE_BAY.test(name) && name !== "." && name !== "..";
|
|
36
|
+
}
|
|
37
|
+
/** Content hash of a bay's lockfiles, MIXED with its recipe's declared runtime
|
|
38
|
+
* versions (image.ruby / image.node). The lockfile alone is not enough: bump
|
|
39
|
+
* a bay's Ruby (recipe + .ruby-version) without touching Gemfile.lock, and a
|
|
40
|
+
* lockfile-only hash would still match the recorded stamp — `garaje sync`
|
|
41
|
+
* would report "up to date" while BUNDLE_PATH/ruby/<new-abi>/ is empty and
|
|
42
|
+
* every `bundle exec` fails. Order-stable; a missing lockfile or a recipe
|
|
43
|
+
* with no declared runtime simply contributes nothing. A corrupt recipe is
|
|
44
|
+
* reported elsewhere (doctor / park); here it just contributes no
|
|
45
|
+
* runtime-version bytes rather than throwing mid-sync. */
|
|
46
|
+
export function lockfileHash(bayDir) {
|
|
47
|
+
const h = createHash("sha256");
|
|
48
|
+
for (const name of [...LOCKFILES].sort()) {
|
|
49
|
+
const p = join(bayDir, name);
|
|
50
|
+
if (!existsSync(p))
|
|
51
|
+
continue;
|
|
52
|
+
h.update(name);
|
|
53
|
+
h.update(readFileSync(p));
|
|
54
|
+
}
|
|
55
|
+
const recipePath = join(bayDir, "recipe.yaml");
|
|
56
|
+
if (existsSync(recipePath)) {
|
|
57
|
+
try {
|
|
58
|
+
const recipe = parseRecipe(readFileSync(recipePath, "utf8"), basename(bayDir));
|
|
59
|
+
const image = recipe.image ?? {};
|
|
60
|
+
for (const tool of ["ruby", "node"]) {
|
|
61
|
+
const raw = image[tool];
|
|
62
|
+
if (raw === undefined || raw === null)
|
|
63
|
+
continue;
|
|
64
|
+
h.update(tool);
|
|
65
|
+
h.update(String(raw).trim());
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// corrupt recipe — contribute nothing rather than crash the hash.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return h.digest("hex").slice(0, 16);
|
|
73
|
+
}
|
|
74
|
+
/** Where a bay's recorded hash lives ON THE pi-deps VOLUME (a container path). */
|
|
75
|
+
export function stampPath(bay) {
|
|
76
|
+
return `/garaje/deps/.stamp/${bay}`;
|
|
77
|
+
}
|
|
78
|
+
/** The `setup` commands a bay's recipe declares. The recipe is the single
|
|
79
|
+
* source of truth for "how this project installs" — we do not invent a
|
|
80
|
+
* second one for the pi side. */
|
|
81
|
+
export function setupCommands(root, bay) {
|
|
82
|
+
const p = join(root, "bays", bay, "recipe.yaml");
|
|
83
|
+
if (!existsSync(p))
|
|
84
|
+
return [];
|
|
85
|
+
return parseRecipe(readFileSync(p, "utf8"), bay).image?.setup ?? [];
|
|
86
|
+
}
|
|
87
|
+
/** One line of `garaje sync` output for a dep outcome. `width` should be the
|
|
88
|
+
* longest bay name among everything being reported this run (the caller
|
|
89
|
+
* knows that; a single outcome does not) — padEnd never truncates, so a name
|
|
90
|
+
* longer than `width` still gets its explicit separating space rather than
|
|
91
|
+
* running straight into "deps" (e.g. an 8-char default swallowed the space
|
|
92
|
+
* for a 9-char name like "hello-bay"). */
|
|
93
|
+
export function renderDepsOutcome(o, width = 8) {
|
|
94
|
+
const tag = ` ${o.bay.padEnd(width)} deps `;
|
|
95
|
+
switch (o.kind) {
|
|
96
|
+
case "installed":
|
|
97
|
+
return `${tag}installed ✓\n`;
|
|
98
|
+
case "fresh":
|
|
99
|
+
return `${tag}up to date (lockfile unchanged) ✓\n`;
|
|
100
|
+
case "no-setup":
|
|
101
|
+
return `${tag}nothing to install (recipe declares no setup)\n`;
|
|
102
|
+
case "failed":
|
|
103
|
+
return `${tag}FAILED\n${o.detail.replace(/^/gm, " ")}\n`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Install one bay's dependencies inside the pi container, so pi-lens's language
|
|
108
|
+
* servers and linters see the real project. Idempotent: keyed on the bay's
|
|
109
|
+
* lockfile hash, recorded on the pi-deps volume.
|
|
110
|
+
*
|
|
111
|
+
* Gems land under the container-wide BUNDLE_PATH (/garaje/deps/bundle), which a
|
|
112
|
+
* single shared path can serve for every bay — bundler namespaces by Ruby ABI
|
|
113
|
+
* directory and activates strictly from the calling project's Gemfile.lock.
|
|
114
|
+
*
|
|
115
|
+
* `onStart`, if given, fires right before the setup script actually runs — NOT
|
|
116
|
+
* before the freshness check, and not at all for a "fresh" or "no-setup"
|
|
117
|
+
* outcome. `execCapture` buffers output, so a cold `bundle install` on a real
|
|
118
|
+
* Rails app runs 20s-to-minutes with zero output otherwise; callers use this
|
|
119
|
+
* to tell the user an install is starting and why it may be slow.
|
|
120
|
+
*/
|
|
121
|
+
export function installBayDeps(root, bay, driver, onStart) {
|
|
122
|
+
if (!isSafeBayName(bay)) {
|
|
123
|
+
return { kind: "failed", bay, detail: `unsafe bay name '${bay}' — refusing to run it in a shell` };
|
|
124
|
+
}
|
|
125
|
+
const setup = setupCommands(root, bay);
|
|
126
|
+
if (!setup.length)
|
|
127
|
+
return { kind: "no-setup", bay };
|
|
128
|
+
const want = lockfileHash(join(root, "bays", bay));
|
|
129
|
+
const stamp = stampPath(bay);
|
|
130
|
+
const seen = driver.execCapture("pi", ["sh", "-c", `cat ${stamp} 2>/dev/null || true`]);
|
|
131
|
+
if (seen.status === 0 && seen.stdout.trim() === want)
|
|
132
|
+
return { kind: "fresh", bay };
|
|
133
|
+
onStart?.(bay);
|
|
134
|
+
const script = [
|
|
135
|
+
"set -e",
|
|
136
|
+
`cd /workspace/bays/${bay}`,
|
|
137
|
+
...setup,
|
|
138
|
+
"mkdir -p /garaje/deps/.stamp",
|
|
139
|
+
`printf %s ${want} > ${stamp}`,
|
|
140
|
+
].join("\n");
|
|
141
|
+
const r = driver.execCapture("pi", ["sh", "-c", script]);
|
|
142
|
+
if (r.status !== 0) {
|
|
143
|
+
const detail = (r.stderr || r.stdout).trim().split("\n").slice(-3).join("\n");
|
|
144
|
+
return { kind: "failed", bay, detail: detail || `setup exited ${r.status}` };
|
|
145
|
+
}
|
|
146
|
+
return { kind: "installed", bay };
|
|
147
|
+
}
|
package/dist/host/doctor.js
CHANGED
|
@@ -2,10 +2,56 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { driverContext, getDriver } from "../driver/index.js";
|
|
5
|
-
import { baysArtifactState, discoverBays, manifestBays } from "../park/index.js";
|
|
5
|
+
import { baysArtifactState, discoverBays, manifestBays, toolchainArtifactState, } from "../park/index.js";
|
|
6
|
+
import { normalizeRubyVersion } from "../park/toolchain.js";
|
|
7
|
+
import { parseRecipe } from "../park/recipe.js";
|
|
8
|
+
import { readManifest } from "../manifest.js";
|
|
6
9
|
import { basePinSource } from "../upgrade/pins.js";
|
|
7
10
|
import { bayStatuses, statusLine } from "./sync.js";
|
|
8
11
|
import { classifyModelList, defaultModelOf, hasInferenceCredential } from "./provider.js";
|
|
12
|
+
/** Bays whose recipe `image.ruby` disagrees with their own `.ruby-version`.
|
|
13
|
+
* mise installs what the RECIPE declares, but resolves per-bay from the
|
|
14
|
+
* version FILE — so a disagreement means the bay asks for a ruby that was
|
|
15
|
+
* never installed, and its language server dies for a reason nobody would
|
|
16
|
+
* trace back to the recipe. */
|
|
17
|
+
export function rubyVersionMismatches(root) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const bay of discoverBays(root)) {
|
|
20
|
+
const dir = join(root, "bays", bay);
|
|
21
|
+
let declared;
|
|
22
|
+
try {
|
|
23
|
+
const recipe = parseRecipe(readFileSync(join(dir, "recipe.yaml"), "utf8"), bay);
|
|
24
|
+
const raw = recipe.image?.ruby;
|
|
25
|
+
declared = raw === undefined || raw === null ? undefined : String(raw).trim();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
continue; // a corrupt recipe is reported by the artifact check, not here
|
|
29
|
+
}
|
|
30
|
+
if (!declared)
|
|
31
|
+
continue;
|
|
32
|
+
const versionFile = join(dir, ".ruby-version");
|
|
33
|
+
if (!existsSync(versionFile))
|
|
34
|
+
continue;
|
|
35
|
+
// existsSync is true for a directory too — a stray `mkdir .ruby-version`
|
|
36
|
+
// (or any other unreadable path) must not crash doctor. Treat it the same
|
|
37
|
+
// as a MISSING version file: skip this bay silently and keep checking the
|
|
38
|
+
// others, since an unreadable version file is not something doctor can
|
|
39
|
+
// report anything useful about.
|
|
40
|
+
let versionText;
|
|
41
|
+
try {
|
|
42
|
+
versionText = readFileSync(versionFile, "utf8");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const wanted = normalizeRubyVersion(versionText);
|
|
48
|
+
if (wanted && wanted !== declared) {
|
|
49
|
+
out.push(`${bay}: recipe.yaml declares ruby ${declared} but .ruby-version asks for ${wanted} — ` +
|
|
50
|
+
`mise installs the recipe's, so the language server will not start`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
9
55
|
/** `garaje doctor` — host-side sanity checks. Exit code = failed-check count. */
|
|
10
56
|
export function runDoctor(root) {
|
|
11
57
|
let fails = 0;
|
|
@@ -108,11 +154,28 @@ export function runDoctor(root) {
|
|
|
108
154
|
if (!bays.length)
|
|
109
155
|
warn("no bays parked (add one to garaje.yaml, then garaje sync)");
|
|
110
156
|
for (const b of bays) {
|
|
111
|
-
if (b.
|
|
157
|
+
if (b.unsafe)
|
|
158
|
+
fail(`${b.name}: ${statusLine(b)} (bay names must be plain directory names, not '.' or '..')`);
|
|
159
|
+
else if (b.synced)
|
|
112
160
|
pass(`${b.name}: ${statusLine(b)} (${b.ref})`);
|
|
113
161
|
else
|
|
114
162
|
warn(`${b.name}: ${statusLine(b)} — run \`garaje sync ${b.name}\``);
|
|
115
163
|
}
|
|
164
|
+
// bayStatuses() above (readManifest, lenient) already proved garaje.yaml
|
|
165
|
+
// itself parses as YAML — so if a STRICT re-read throws here, it can only
|
|
166
|
+
// be because a `bays:` entry is well-formed YAML but still has no usable
|
|
167
|
+
// name, which lenient mode silently drops instead of erroring. That drop
|
|
168
|
+
// is otherwise completely invisible: the bay just never appears above.
|
|
169
|
+
try {
|
|
170
|
+
readManifest(root, { strict: true });
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
// (e as Error).message already carries a "garaje.yaml: " prefix (see
|
|
174
|
+
// parseManifest) — do not prepend a second one.
|
|
175
|
+
warn(`${e.message} — lenient parsing silently drops this bay ` +
|
|
176
|
+
"(name it to fix; `garaje upgrade` refuses to narrow a committed toolchain because of it, " +
|
|
177
|
+
"but every other bay-aware command just silently ignores it)");
|
|
178
|
+
}
|
|
116
179
|
}
|
|
117
180
|
catch (e) {
|
|
118
181
|
fail(`bay status check errored — garaje.yaml failed to parse: ${e.message}`);
|
|
@@ -138,6 +201,22 @@ export function runDoctor(root) {
|
|
|
138
201
|
catch (e) {
|
|
139
202
|
fail(`bays artifact check errored — a recipe failed to parse: ${e.message}`);
|
|
140
203
|
}
|
|
204
|
+
try {
|
|
205
|
+
const tc = toolchainArtifactState(root);
|
|
206
|
+
if (tc === "fresh")
|
|
207
|
+
pass("pi toolchain artifact matches the recipes (regenerate-and-diff)");
|
|
208
|
+
else if (tc === "no-bays")
|
|
209
|
+
warn("no parked bays — pi toolchain not checkable");
|
|
210
|
+
else if (tc === "missing")
|
|
211
|
+
fail("pi/toolchain/ missing — run `garaje park` and commit the result");
|
|
212
|
+
else
|
|
213
|
+
fail("pi toolchain artifact STALE — recipes changed; run `garaje park` and commit the diff");
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
fail(`pi toolchain check errored — a recipe failed to parse: ${e.message}`);
|
|
217
|
+
}
|
|
218
|
+
for (const m of rubyVersionMismatches(root))
|
|
219
|
+
warn(m);
|
|
141
220
|
// "Parked" (has a recipe.yaml) is a stricter state than "synced" (cloned);
|
|
142
221
|
// only parked bays contribute to the artifact.
|
|
143
222
|
const parked = discoverBays(root);
|
package/dist/host/sync.js
CHANGED
|
@@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { readManifest } from "../manifest.js";
|
|
5
|
+
import { driverContext, getDriver } from "../driver/index.js";
|
|
6
|
+
import { installBayDeps, isSafeBayName, renderDepsOutcome } from "./deps.js";
|
|
5
7
|
function git(args) {
|
|
6
8
|
return spawnSync("git", args, { stdio: "inherit" }).status ?? 1;
|
|
7
9
|
}
|
|
@@ -21,6 +23,12 @@ function readOrReport(root) {
|
|
|
21
23
|
}
|
|
22
24
|
/** Whether one manifest bay is materialized, and at which commit. */
|
|
23
25
|
function bayStatus(root, bay) {
|
|
26
|
+
// Same guard as installBayDeps and runSync's clone/fetch: an unsafe name
|
|
27
|
+
// (e.g. '..') joined onto `bays/` resolves outside bays/ entirely — for
|
|
28
|
+
// '..' that is the garaje root itself, whose own .git would otherwise be
|
|
29
|
+
// reported as this bay "synced" at the garaje's real HEAD.
|
|
30
|
+
if (!isSafeBayName(bay.name))
|
|
31
|
+
return { ...bay, synced: false, unsafe: true };
|
|
24
32
|
const dest = join(root, "bays", bay.name);
|
|
25
33
|
if (!existsSync(join(dest, ".git")))
|
|
26
34
|
return { ...bay, synced: false };
|
|
@@ -36,6 +44,8 @@ export function bayStatuses(root) {
|
|
|
36
44
|
}
|
|
37
45
|
/** One human-readable line per bay. Pure — doctor reuses this. */
|
|
38
46
|
export function statusLine(b) {
|
|
47
|
+
if (b.unsafe)
|
|
48
|
+
return "invalid bay name — fix garaje.yaml";
|
|
39
49
|
return b.synced ? `synced @ ${b.head}` : "not synced";
|
|
40
50
|
}
|
|
41
51
|
/** The `garaje list` table. Pure. */
|
|
@@ -57,8 +67,16 @@ export function runList(root) {
|
|
|
57
67
|
process.stdout.write(renderList(entries.map((b) => bayStatus(root, b))));
|
|
58
68
|
return 0;
|
|
59
69
|
}
|
|
60
|
-
/** `garaje sync [name...]` — clone/update the manifest's bays into
|
|
61
|
-
|
|
70
|
+
/** `garaje sync [--no-deps] [name...]` — clone/update the manifest's bays into
|
|
71
|
+
* bays/, then install each one's dependencies inside the pi container so
|
|
72
|
+
* pi-lens's language servers and linters see the real project.
|
|
73
|
+
*
|
|
74
|
+
* The dep step DEGRADES, never fails: `garaje sync` is the verb you run on a
|
|
75
|
+
* fresh clone BEFORE `garaje up`, so it must not acquire a hard dependency on
|
|
76
|
+
* the pi container being up. */
|
|
77
|
+
export function runSync(root, argv) {
|
|
78
|
+
const noDeps = argv.includes("--no-deps");
|
|
79
|
+
const names = argv.filter((a) => a !== "--no-deps");
|
|
62
80
|
if (spawnSync("git", ["--version"], { stdio: "ignore" }).status !== 0) {
|
|
63
81
|
process.stderr.write("error: git not found in PATH\n");
|
|
64
82
|
return 1;
|
|
@@ -79,6 +97,10 @@ export function runSync(root, names) {
|
|
|
79
97
|
process.stderr.write(`skip ${bay.name}: no repo in manifest\n`);
|
|
80
98
|
continue;
|
|
81
99
|
}
|
|
100
|
+
if (!isSafeBayName(bay.name)) {
|
|
101
|
+
process.stderr.write(`error: garaje.yaml: unsafe bay name '${bay.name}' — fix the manifest (bay names must be plain directory names, not '.' or '..')\n`);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
82
104
|
const dest = join(root, "bays", bay.name);
|
|
83
105
|
if (existsSync(join(dest, ".git"))) {
|
|
84
106
|
process.stdout.write(`==> ${bay.name}: fetching (${bay.ref})\n`);
|
|
@@ -106,5 +128,23 @@ export function runSync(root, names) {
|
|
|
106
128
|
synced += 1;
|
|
107
129
|
}
|
|
108
130
|
process.stdout.write(`synced ${synced} bay(s) into bays/\n`);
|
|
109
|
-
|
|
131
|
+
if (noDeps)
|
|
132
|
+
return 0;
|
|
133
|
+
const driver = getDriver(driverContext(root));
|
|
134
|
+
if (!driver.runningServices().includes("pi")) {
|
|
135
|
+
process.stdout.write(" deps skipped — pi is not running (garaje up, then re-sync)\n");
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
let depsFailed = 0;
|
|
139
|
+
// Width for both the "starting" line and the outcome line, so they line up:
|
|
140
|
+
// the longest bay name being reported this run, not a fixed guess (a fixed
|
|
141
|
+
// 8 silently ran a 9-char name like "hello-bay" straight into "deps").
|
|
142
|
+
const width = wanted.length ? Math.max(...wanted.map((b) => b.name.length)) : 0;
|
|
143
|
+
for (const bay of wanted) {
|
|
144
|
+
const outcome = installBayDeps(root, bay.name, driver, (name) => process.stdout.write(` ${name.padEnd(width)} deps installing (recipe setup — a cold cache can take a while)...\n`));
|
|
145
|
+
process.stdout.write(renderDepsOutcome(outcome, width));
|
|
146
|
+
if (outcome.kind === "failed")
|
|
147
|
+
depsFailed += 1;
|
|
148
|
+
}
|
|
149
|
+
return depsFailed > 0 ? 1 : 0;
|
|
110
150
|
}
|
package/dist/manifest.js
CHANGED
|
@@ -1,24 +1,40 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parse as parseYaml } from "yaml";
|
|
4
|
-
/** Parse the `bays:` list out of a garaje.yaml.
|
|
5
|
-
*
|
|
6
|
-
|
|
4
|
+
/** Parse the `bays:` list out of a garaje.yaml. `ref` defaults to main. Pure —
|
|
5
|
+
* throws on malformed YAML always, and (with `{ strict: true }`) on a
|
|
6
|
+
* well-formed-YAML entry that still can't yield a name. */
|
|
7
|
+
export function parseManifest(text, opts = {}) {
|
|
7
8
|
const data = parseYaml(text);
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
const rawBays = Array.isArray(data?.bays) ? data.bays : [];
|
|
10
|
+
const bays = [];
|
|
11
|
+
rawBays.forEach((b, i) => {
|
|
12
|
+
if (!b || typeof b !== "object") {
|
|
13
|
+
if (opts.strict)
|
|
14
|
+
throw new Error(`garaje.yaml: bays[${i}] is not a mapping`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const rec = b;
|
|
18
|
+
const name = String(rec.name ?? "").trim();
|
|
19
|
+
if (!name) {
|
|
20
|
+
if (opts.strict)
|
|
21
|
+
throw new Error(`garaje.yaml: bays[${i}] has no name`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
bays.push({
|
|
25
|
+
name,
|
|
26
|
+
repo: String(rec.repo ?? "").trim(),
|
|
27
|
+
ref: String(rec.ref ?? "main").trim() || "main",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
return bays;
|
|
17
31
|
}
|
|
18
|
-
/** Read <root>/garaje.yaml. Throws if it is missing or unparseable
|
|
19
|
-
|
|
32
|
+
/** Read <root>/garaje.yaml. Throws if it is missing or unparseable (and, with
|
|
33
|
+
* `{ strict: true }`, if a `bays:` entry can't yield a name — see
|
|
34
|
+
* ParseManifestOptions). */
|
|
35
|
+
export function readManifest(root, opts = {}) {
|
|
20
36
|
const path = join(root, "garaje.yaml");
|
|
21
37
|
if (!existsSync(path))
|
|
22
38
|
throw new Error("no garaje.yaml at the garaje root");
|
|
23
|
-
return parseManifest(readFileSync(path, "utf8"));
|
|
39
|
+
return parseManifest(readFileSync(path, "utf8"), opts);
|
|
24
40
|
}
|
package/dist/park/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { readManifest } from "../manifest.js";
|
|
4
4
|
import { parseRecipe } from "./recipe.js";
|
|
5
5
|
import { generate } from "./compose.js";
|
|
6
|
+
import { generateToolchain, parseToolchainBays } from "./toolchain.js";
|
|
6
7
|
/** Bay folder names under <root>/bays that contain a recipe.yaml, sorted. */
|
|
7
8
|
export function discoverBays(root) {
|
|
8
9
|
const baysDir = join(root, "bays");
|
|
@@ -14,8 +15,21 @@ export function discoverBays(root) {
|
|
|
14
15
|
.sort();
|
|
15
16
|
}
|
|
16
17
|
/** Bay names declared in the committed manifest (garaje.yaml). Lenient: a
|
|
17
|
-
* missing or corrupt manifest yields no bays rather than throwing
|
|
18
|
-
* every caller
|
|
18
|
+
* missing or corrupt manifest yields no bays rather than throwing — safe for
|
|
19
|
+
* every CURRENT caller, because each is a CHECK that reports a bad manifest
|
|
20
|
+
* separately (doctor; `garaje park`'s unsynced-bay warning) and a `[]` here
|
|
21
|
+
* changes nothing about what gets reported.
|
|
22
|
+
*
|
|
23
|
+
* DO NOT reach for this in a WRITER. `garaje upgrade`'s planToolchain used to
|
|
24
|
+
* call this and treated the `[]` it got back as "the manifest genuinely
|
|
25
|
+
* declares no bays" — indistinguishable from "the manifest is missing or
|
|
26
|
+
* corrupt", so a bad garaje.yaml silently regenerated pi/toolchain/ down to
|
|
27
|
+
* an empty artifact instead of failing loud (fixed: planToolchain now calls
|
|
28
|
+
* readManifest directly and gives "can't read the manifest" its own outcome,
|
|
29
|
+
* distinct from "reads fine and says zero"). A future writer has to make
|
|
30
|
+
* that same distinction for itself — call readManifest and handle the
|
|
31
|
+
* throw — rather than reusing this function's leniency and re-collapsing
|
|
32
|
+
* the two states. */
|
|
19
33
|
export function manifestBays(root) {
|
|
20
34
|
try {
|
|
21
35
|
return readManifest(root).map((b) => b.name);
|
|
@@ -24,6 +38,52 @@ export function manifestBays(root) {
|
|
|
24
38
|
return [];
|
|
25
39
|
}
|
|
26
40
|
}
|
|
41
|
+
/** Parse the recipe of every named bay. Throws on a corrupt recipe; callers
|
|
42
|
+
* that are checks (doctor) report that rather than crashing. Exported so
|
|
43
|
+
* `garaje upgrade` can read whatever is currently parked without inventing a
|
|
44
|
+
* second recipe-loading path. */
|
|
45
|
+
export function readRecipes(root, names) {
|
|
46
|
+
return names.map((bay) => parseRecipe(readFileSync(join(root, "bays", bay, "recipe.yaml"), "utf8"), bay));
|
|
47
|
+
}
|
|
48
|
+
/** Where the committed pi/toolchain/ artifact lives. Exported so every caller
|
|
49
|
+
* that needs the paths (to check existence) or the content (to compare)
|
|
50
|
+
* shares one definition instead of re-deriving these two join() calls. */
|
|
51
|
+
export function toolchainArtifactPaths(root) {
|
|
52
|
+
return {
|
|
53
|
+
misePath: join(root, "pi", "toolchain", "mise.toml"),
|
|
54
|
+
aptPath: join(root, "pi", "toolchain", "apt-packages.txt"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** The committed pi/toolchain/ artifact's content, or undefined if either
|
|
58
|
+
* file is missing (a partial artifact counts as absent — same convention
|
|
59
|
+
* toolchainArtifactState and planToolchain already used before this was
|
|
60
|
+
* extracted). */
|
|
61
|
+
export function readToolchainArtifact(root) {
|
|
62
|
+
const { misePath, aptPath } = toolchainArtifactPaths(root);
|
|
63
|
+
if (!existsSync(misePath) || !existsSync(aptPath))
|
|
64
|
+
return undefined;
|
|
65
|
+
return { miseToml: readFileSync(misePath, "utf8"), aptPackages: readFileSync(aptPath, "utf8") };
|
|
66
|
+
}
|
|
67
|
+
/** Write pi/toolchain/{mise.toml,apt-packages.txt} generated from `recipes`.
|
|
68
|
+
* The ONE place that produces this artifact — `runPark` (synced bays) and
|
|
69
|
+
* `garaje upgrade` (whatever is parked at upgrade time) both call this
|
|
70
|
+
* instead of each re-deriving it. Passing an empty recipe list yields the
|
|
71
|
+
* valid empty artifact, so a garaje with no bays needs no special branch. */
|
|
72
|
+
export function writeToolchainArtifact(root, recipes) {
|
|
73
|
+
writeToolchain(root, generateToolchain(recipes));
|
|
74
|
+
}
|
|
75
|
+
/** Write an ALREADY-generated Toolchain to pi/toolchain/. Split out from
|
|
76
|
+
* writeToolchainArtifact so a caller that must generate (and validate) the
|
|
77
|
+
* artifact BEFORE deciding whether to write anything else — `runPark`,
|
|
78
|
+
* which must fail before touching compose.bays.yaml if the recipe set
|
|
79
|
+
* generateToolchain rejects — can write the exact bytes it already
|
|
80
|
+
* generated instead of generating (and risking throwing) a second time. */
|
|
81
|
+
export function writeToolchain(root, toolchain) {
|
|
82
|
+
const { misePath, aptPath } = toolchainArtifactPaths(root);
|
|
83
|
+
mkdirSync(join(root, "pi", "toolchain"), { recursive: true });
|
|
84
|
+
writeFileSync(misePath, toolchain.miseToml);
|
|
85
|
+
writeFileSync(aptPath, toolchain.aptPackages);
|
|
86
|
+
}
|
|
27
87
|
/** Regenerate <root>/compose.bays.yaml from ALL synced bays' recipes. Named
|
|
28
88
|
* args validate ("these bays must be synced with a recipe") rather than
|
|
29
89
|
* select: the artifact is a committed whole-set derivation, so parking one
|
|
@@ -44,18 +104,82 @@ export function runPark(root, bays) {
|
|
|
44
104
|
}
|
|
45
105
|
// The artifact derives from the SYNCED set; warn when the manifest lists
|
|
46
106
|
// more, so a partial sync can't silently narrow the committed artifact.
|
|
47
|
-
|
|
107
|
+
// Read STRICT here, not manifestBays()'s lenient default: a `bays:` entry
|
|
108
|
+
// with no usable `name:` is syntactically valid YAML that lenient mode
|
|
109
|
+
// silently drops, which made this exact warning go silent too (the bay
|
|
110
|
+
// just vanished from the list being checked, so it never showed up as
|
|
111
|
+
// "unsynced" either). This warning is advisory, not a hard gate — park is
|
|
112
|
+
// a writer and must not crash on a bad manifest — so a strict-read failure
|
|
113
|
+
// is itself reported as a warning (naming the manifest problem) rather
|
|
114
|
+
// than thrown or silently swallowed. A garaje.yaml that is simply ABSENT
|
|
115
|
+
// is not this warning's concern (there is nothing to diff against, and
|
|
116
|
+
// other checks — e.g. doctor's repo-shape section — already report a
|
|
117
|
+
// missing manifest); only a PRESENT-but-malformed one gets the warning.
|
|
118
|
+
let manifestNames;
|
|
119
|
+
if (!existsSync(join(root, "garaje.yaml"))) {
|
|
120
|
+
manifestNames = [];
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
try {
|
|
124
|
+
manifestNames = readManifest(root, { strict: true }).map((b) => b.name);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
process.stderr.write(`garaje park: WARNING — ${e.message} — cannot check for unsynced manifest bay(s)\n`);
|
|
128
|
+
manifestNames = [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const unsynced = manifestNames.filter((b) => !synced.includes(b));
|
|
48
132
|
if (unsynced.length) {
|
|
49
133
|
process.stderr.write(`garaje park: WARNING — writing the artifact without unsynced manifest bay(s): ${unsynced.join(", ")} (garaje sync them and re-park)\n`);
|
|
50
134
|
}
|
|
51
|
-
const recipes =
|
|
135
|
+
const recipes = readRecipes(root, synced);
|
|
52
136
|
const text = generate(recipes);
|
|
53
137
|
if (dump) {
|
|
54
138
|
process.stdout.write(text);
|
|
55
139
|
return { code: 0, wrote: synced.join(", ") };
|
|
56
140
|
}
|
|
141
|
+
// Generate (and validate — see generateToolchain's provenance round-trip
|
|
142
|
+
// assertion) the pi/toolchain/ artifact BEFORE writing anything. A recipe
|
|
143
|
+
// generateToolchain rejects (an unsafe bay name, or a provenance encoding
|
|
144
|
+
// failure) must fail park CLEANLY here — no stack trace, and neither
|
|
145
|
+
// compose.bays.yaml nor pi/toolchain/ touched — rather than writing
|
|
146
|
+
// compose.bays.yaml below and THEN throwing out of what used to be a
|
|
147
|
+
// second, later generateToolchain call. That order also used to print the
|
|
148
|
+
// narrowing WARNING below as a lie: it describes what pi/toolchain/ is
|
|
149
|
+
// about to lose, but if generation then failed, nothing was written at
|
|
150
|
+
// all.
|
|
151
|
+
let toolchain;
|
|
152
|
+
try {
|
|
153
|
+
toolchain = generateToolchain(recipes);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
process.stderr.write(`garaje park: ${e.message}\n`);
|
|
157
|
+
return { code: 1 };
|
|
158
|
+
}
|
|
159
|
+
// `garaje park` is the deliberate escape hatch for narrowing the committed
|
|
160
|
+
// pi/toolchain/ artifact (unlike `garaje upgrade`, which refuses outright)
|
|
161
|
+
// — but narrowing must never happen SILENTLY. The committed artifact
|
|
162
|
+
// records the exact bay set it was derived from (generateToolchain's
|
|
163
|
+
// `# bays: ...` line, read back by parseToolchainBays); if what we are
|
|
164
|
+
// about to write would drop any of those bays, warn loudly and name them
|
|
165
|
+
// before writing anyway. This is what closes the laundering route: doctor
|
|
166
|
+
// tells a user to run `garaje park` to fix a STALE artifact, and this is
|
|
167
|
+
// the only place left that can catch a park which "fixes" staleness by
|
|
168
|
+
// quietly dropping a bay instead.
|
|
169
|
+
const committedArtifact = readToolchainArtifact(root);
|
|
170
|
+
if (committedArtifact) {
|
|
171
|
+
const committedBays = parseToolchainBays(committedArtifact.miseToml);
|
|
172
|
+
if (committedBays !== undefined) {
|
|
173
|
+
const newBays = new Set(recipes.map((r) => r.name));
|
|
174
|
+
const dropped = committedBays.filter((b) => !newBays.has(b));
|
|
175
|
+
if (dropped.length > 0) {
|
|
176
|
+
process.stderr.write(`garaje park: WARNING — narrowing pi/toolchain/: dropping bay(s) the committed artifact was derived from: ${dropped.join(", ")} (if unintended, sync/author the missing bay(s) and re-park)\n`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
57
180
|
writeFileSync(join(root, "compose.bays.yaml"), text);
|
|
58
|
-
|
|
181
|
+
writeToolchain(root, toolchain);
|
|
182
|
+
process.stderr.write(`garaje park: wrote compose.bays.yaml + pi/toolchain/ for ${synced.join(", ")}\n`);
|
|
59
183
|
return { code: 0, wrote: synced.join(", ") };
|
|
60
184
|
}
|
|
61
185
|
/** Compare the COMMITTED bays artifact against a regeneration from the
|
|
@@ -68,6 +192,20 @@ export function baysArtifactState(root) {
|
|
|
68
192
|
const path = join(root, "compose.bays.yaml");
|
|
69
193
|
if (!existsSync(path))
|
|
70
194
|
return "missing";
|
|
71
|
-
|
|
72
|
-
|
|
195
|
+
return generate(readRecipes(root, names)) === readFileSync(path, "utf8") ? "fresh" : "stale";
|
|
196
|
+
}
|
|
197
|
+
/** Same regenerate-and-diff guard as baysArtifactState, for the pi toolchain.
|
|
198
|
+
* The pi image is a second consumer of the recipes; its artifact must not
|
|
199
|
+
* drift from them silently either. */
|
|
200
|
+
export function toolchainArtifactState(root) {
|
|
201
|
+
const names = discoverBays(root);
|
|
202
|
+
if (!names.length)
|
|
203
|
+
return "no-bays";
|
|
204
|
+
const artifact = readToolchainArtifact(root);
|
|
205
|
+
if (!artifact)
|
|
206
|
+
return "missing";
|
|
207
|
+
const want = generateToolchain(readRecipes(root, names));
|
|
208
|
+
return want.miseToml === artifact.miseToml && want.aptPackages === artifact.aptPackages
|
|
209
|
+
? "fresh"
|
|
210
|
+
: "stale";
|
|
73
211
|
}
|
package/dist/park/recipe.js
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import { parse as parseYaml } from "yaml";
|
|
2
|
-
/** Parse recipe YAML into a Recipe, defaulting name to the bay folder name.
|
|
2
|
+
/** Parse recipe YAML into a Recipe, defaulting name to the bay folder name.
|
|
3
|
+
*
|
|
4
|
+
* A `name:` key that parses to `null` (a bare `name:` with nothing after
|
|
5
|
+
* it, or an explicit `~` / `null`) or to the empty string is, for every
|
|
6
|
+
* purpose downstream, "no name was given" — it must fall back to the bay
|
|
7
|
+
* folder name exactly like an absent `name:` (`undefined`) does. This is
|
|
8
|
+
* read off the raw parsed `data`, not the `recipe as Recipe` cast below:
|
|
9
|
+
* that cast is unchecked (`data as Recipe`), so TypeScript's `name: string`
|
|
10
|
+
* cannot be trusted to reflect what untrusted YAML actually produced. Left
|
|
11
|
+
* unhandled, a recipe with an empty `name:` used to survive as a
|
|
12
|
+
* non-`undefined`, non-string `name` all the way into generateToolchain,
|
|
13
|
+
* where it could defeat the `# bays: ...` provenance encoding instead of
|
|
14
|
+
* cleanly taking the folder-name fallback. */
|
|
3
15
|
export function parseRecipe(text, fallbackName) {
|
|
4
16
|
const data = parseYaml(text);
|
|
5
17
|
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
6
18
|
throw new Error("recipe did not parse to a mapping");
|
|
7
19
|
}
|
|
8
20
|
const recipe = data;
|
|
9
|
-
|
|
21
|
+
const rawName = data.name;
|
|
22
|
+
if (rawName === undefined || rawName === null || rawName === "") {
|
|
10
23
|
recipe.name = fallbackName;
|
|
24
|
+
}
|
|
11
25
|
return recipe;
|
|
12
26
|
}
|