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/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,9 +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";
|
|
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
|
+
}
|
|
8
55
|
/** `garaje doctor` — host-side sanity checks. Exit code = failed-check count. */
|
|
9
56
|
export function runDoctor(root) {
|
|
10
57
|
let fails = 0;
|
|
@@ -59,10 +106,33 @@ export function runDoctor(root) {
|
|
|
59
106
|
else
|
|
60
107
|
fail(`@garaje/base path pin "${source}" missing — no package.json at ${dir}`);
|
|
61
108
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
109
|
+
// .env is the garaje's single credential surface (compose forwards it via
|
|
110
|
+
// env_file). Provider-neutral: any non-empty *_API_KEY counts, so adding a
|
|
111
|
+
// new provider needs no change here.
|
|
112
|
+
const envPath = join(root, ".env");
|
|
113
|
+
if (!existsSync(envPath)) {
|
|
114
|
+
warn(".env missing — copy .env.example and add a provider key (e.g. MIXLAYER_API_KEY)");
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
// .env could be a directory (stray mkdir, misconfigured tool) or otherwise
|
|
118
|
+
// unreadable; report that as a failed check rather than crashing (same
|
|
119
|
+
// contract as the garaje.yaml and recipe checks below).
|
|
120
|
+
let envText;
|
|
121
|
+
try {
|
|
122
|
+
envText = readFileSync(envPath, "utf8");
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
fail(`.env unreadable — ${e.message}`);
|
|
126
|
+
}
|
|
127
|
+
if (envText !== undefined) {
|
|
128
|
+
if (hasInferenceCredential(envText)) {
|
|
129
|
+
pass(".env carries an inference credential");
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
warn(".env has no non-empty *_API_KEY — pi will start but cannot answer");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
66
136
|
// --- runtime state ---
|
|
67
137
|
section("runtime");
|
|
68
138
|
if (driver.configOk())
|
|
@@ -84,11 +154,28 @@ export function runDoctor(root) {
|
|
|
84
154
|
if (!bays.length)
|
|
85
155
|
warn("no bays parked (add one to garaje.yaml, then garaje sync)");
|
|
86
156
|
for (const b of bays) {
|
|
87
|
-
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)
|
|
88
160
|
pass(`${b.name}: ${statusLine(b)} (${b.ref})`);
|
|
89
161
|
else
|
|
90
162
|
warn(`${b.name}: ${statusLine(b)} — run \`garaje sync ${b.name}\``);
|
|
91
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
|
+
}
|
|
92
179
|
}
|
|
93
180
|
catch (e) {
|
|
94
181
|
fail(`bay status check errored — garaje.yaml failed to parse: ${e.message}`);
|
|
@@ -114,6 +201,22 @@ export function runDoctor(root) {
|
|
|
114
201
|
catch (e) {
|
|
115
202
|
fail(`bays artifact check errored — a recipe failed to parse: ${e.message}`);
|
|
116
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);
|
|
117
220
|
// "Parked" (has a recipe.yaml) is a stricter state than "synced" (cloned);
|
|
118
221
|
// only parked bays contribute to the artifact.
|
|
119
222
|
const parked = discoverBays(root);
|
|
@@ -139,6 +242,49 @@ export function runDoctor(root) {
|
|
|
139
242
|
pass(`pi binary works: ${piver}`);
|
|
140
243
|
else
|
|
141
244
|
fail("pi --version did not print a version");
|
|
245
|
+
// A key stored by `/login` outranks .env: model-registry resolves
|
|
246
|
+
// `apiKeyFromAuthStorage ?? providerConfig.apiKey`. Existence only — doctor
|
|
247
|
+
// never reads this file.
|
|
248
|
+
if (driver.execCapture("pi", ["test", "-f", "/root/.pi/agent/auth.json"]).status === 0) {
|
|
249
|
+
warn("/root/.pi/agent/auth.json exists — a stored `/login` credential OVERRIDES .env");
|
|
250
|
+
}
|
|
251
|
+
// Proves the provider extension loaded AND a credential is present. It
|
|
252
|
+
// does NOT validate the key: pi lists models for a bogus key exactly as it
|
|
253
|
+
// would for a real one — but with NO credential at all, `pi --list-models`
|
|
254
|
+
// exits 0 and prints "No models available" instead of a table. That is a
|
|
255
|
+
// separate condition from a model genuinely missing from a populated
|
|
256
|
+
// table, so it must warn, not fail (a freshly scaffolded garaje with no
|
|
257
|
+
// key yet must not report a hard failure here).
|
|
258
|
+
const model = existsSync(settings)
|
|
259
|
+
? defaultModelOf(readFileSync(settings, "utf8"))
|
|
260
|
+
: undefined;
|
|
261
|
+
if (!model) {
|
|
262
|
+
warn("no defaultModel in .pi/settings.json — pi will prompt for a model");
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const listed = driver.execCapture("pi", ["pi", "--list-models"]);
|
|
266
|
+
if (listed.status !== 0) {
|
|
267
|
+
fail(`\`pi --list-models\` exited ${listed.status} — could not check defaultModel`);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
const verdict = classifyModelList(listed.stdout, model);
|
|
271
|
+
if (verdict === "no-models") {
|
|
272
|
+
// A credential can exist (e.g. a key for a provider this garaje
|
|
273
|
+
// isn't configured to use) while no *loaded* provider has a usable
|
|
274
|
+
// one — CI's exact case: MIXLAYER_API_KEY set, but this repo's
|
|
275
|
+
// settings select anthropic and no mixlayer extension is pinned.
|
|
276
|
+
// Naming only "no credential" would misdirect toward the key when
|
|
277
|
+
// the real cause may be a missing provider extension instead.
|
|
278
|
+
warn(`pi lists no models — no loaded provider has a usable credential (key missing, or its provider extension isn't installed); defaultModel "${model}" cannot be verified`);
|
|
279
|
+
}
|
|
280
|
+
else if (verdict === "listed") {
|
|
281
|
+
pass(`defaultModel registered: ${model} (extension loaded, credential present; key NOT validated)`);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
fail(`defaultModel "${model}" absent from \`pi --list-models\` — provider extension missing or model id wrong`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
142
288
|
}
|
|
143
289
|
// --- summary ---
|
|
144
290
|
section("summary");
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Pure helpers behind `garaje doctor`'s inference-provider checks. No fs, no
|
|
2
|
+
* spawn — doctor's container probes are covered by scripts/ci-smoke instead. */
|
|
3
|
+
/** True when a dotenv body sets any *_API_KEY to a non-empty value. A blank
|
|
4
|
+
* assignment doesn't count: a scaffolded `.env.example` copied verbatim has
|
|
5
|
+
* `MIXLAYER_API_KEY=` and therefore no credential. Lines may optionally be
|
|
6
|
+
* prefixed with `export ` (for shell sourcing compatibility). */
|
|
7
|
+
export function hasInferenceCredential(envText) {
|
|
8
|
+
for (const raw of envText.split(/\r?\n/)) {
|
|
9
|
+
const line = raw.trim();
|
|
10
|
+
if (!line || line.startsWith("#"))
|
|
11
|
+
continue;
|
|
12
|
+
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*_API_KEY)\s*=\s*(.*)$/.exec(line);
|
|
13
|
+
if (!m)
|
|
14
|
+
continue;
|
|
15
|
+
if (m[2].trim().replace(/^["']|["']$/g, "").length > 0)
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
/** The `defaultModel` a garaje's committed pi settings select, if any. */
|
|
21
|
+
export function defaultModelOf(settingsText) {
|
|
22
|
+
try {
|
|
23
|
+
const data = JSON.parse(settingsText);
|
|
24
|
+
return typeof data.defaultModel === "string" && data.defaultModel
|
|
25
|
+
? data.defaultModel
|
|
26
|
+
: undefined;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Whether `pi --list-models` output registers the given model id. */
|
|
33
|
+
export function modelListed(listOutput, model) {
|
|
34
|
+
return listOutput.split(/\r?\n/).some((l) => l.includes(model));
|
|
35
|
+
}
|
|
36
|
+
/** Classify `pi --list-models` output. pi prints "No models available" when no
|
|
37
|
+
* provider credential is present — a different condition from a model that is
|
|
38
|
+
* genuinely missing from a populated table. */
|
|
39
|
+
export function classifyModelList(listOutput, model) {
|
|
40
|
+
if (/no models available/i.test(listOutput))
|
|
41
|
+
return "no-models";
|
|
42
|
+
return modelListed(listOutput, model) ? "listed" : "absent";
|
|
43
|
+
}
|
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
|
}
|