garaje 0.1.3 → 0.1.5

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.
@@ -5,6 +5,7 @@ import { driverContext, getDriver } from "../driver/index.js";
5
5
  import { baysArtifactState, discoverBays, manifestBays } from "../park/index.js";
6
6
  import { basePinSource } from "../upgrade/pins.js";
7
7
  import { bayStatuses, statusLine } from "./sync.js";
8
+ import { classifyModelList, defaultModelOf, hasInferenceCredential } from "./provider.js";
8
9
  /** `garaje doctor` — host-side sanity checks. Exit code = failed-check count. */
9
10
  export function runDoctor(root) {
10
11
  let fails = 0;
@@ -59,10 +60,33 @@ export function runDoctor(root) {
59
60
  else
60
61
  fail(`@garaje/base path pin "${source}" missing — no package.json at ${dir}`);
61
62
  }
62
- if (existsSync(join(root, ".env")))
63
- pass(".env present");
64
- else
65
- warn(".env missing copy .env.example and add ANTHROPIC_API_KEY (or use /login)");
63
+ // .env is the garaje's single credential surface (compose forwards it via
64
+ // env_file). Provider-neutral: any non-empty *_API_KEY counts, so adding a
65
+ // new provider needs no change here.
66
+ const envPath = join(root, ".env");
67
+ if (!existsSync(envPath)) {
68
+ warn(".env missing — copy .env.example and add a provider key (e.g. MIXLAYER_API_KEY)");
69
+ }
70
+ else {
71
+ // .env could be a directory (stray mkdir, misconfigured tool) or otherwise
72
+ // unreadable; report that as a failed check rather than crashing (same
73
+ // contract as the garaje.yaml and recipe checks below).
74
+ let envText;
75
+ try {
76
+ envText = readFileSync(envPath, "utf8");
77
+ }
78
+ catch (e) {
79
+ fail(`.env unreadable — ${e.message}`);
80
+ }
81
+ if (envText !== undefined) {
82
+ if (hasInferenceCredential(envText)) {
83
+ pass(".env carries an inference credential");
84
+ }
85
+ else {
86
+ warn(".env has no non-empty *_API_KEY — pi will start but cannot answer");
87
+ }
88
+ }
89
+ }
66
90
  // --- runtime state ---
67
91
  section("runtime");
68
92
  if (driver.configOk())
@@ -139,6 +163,49 @@ export function runDoctor(root) {
139
163
  pass(`pi binary works: ${piver}`);
140
164
  else
141
165
  fail("pi --version did not print a version");
166
+ // A key stored by `/login` outranks .env: model-registry resolves
167
+ // `apiKeyFromAuthStorage ?? providerConfig.apiKey`. Existence only — doctor
168
+ // never reads this file.
169
+ if (driver.execCapture("pi", ["test", "-f", "/root/.pi/agent/auth.json"]).status === 0) {
170
+ warn("/root/.pi/agent/auth.json exists — a stored `/login` credential OVERRIDES .env");
171
+ }
172
+ // Proves the provider extension loaded AND a credential is present. It
173
+ // does NOT validate the key: pi lists models for a bogus key exactly as it
174
+ // would for a real one — but with NO credential at all, `pi --list-models`
175
+ // exits 0 and prints "No models available" instead of a table. That is a
176
+ // separate condition from a model genuinely missing from a populated
177
+ // table, so it must warn, not fail (a freshly scaffolded garaje with no
178
+ // key yet must not report a hard failure here).
179
+ const model = existsSync(settings)
180
+ ? defaultModelOf(readFileSync(settings, "utf8"))
181
+ : undefined;
182
+ if (!model) {
183
+ warn("no defaultModel in .pi/settings.json — pi will prompt for a model");
184
+ }
185
+ else {
186
+ const listed = driver.execCapture("pi", ["pi", "--list-models"]);
187
+ if (listed.status !== 0) {
188
+ fail(`\`pi --list-models\` exited ${listed.status} — could not check defaultModel`);
189
+ }
190
+ else {
191
+ const verdict = classifyModelList(listed.stdout, model);
192
+ if (verdict === "no-models") {
193
+ // A credential can exist (e.g. a key for a provider this garaje
194
+ // isn't configured to use) while no *loaded* provider has a usable
195
+ // one — CI's exact case: MIXLAYER_API_KEY set, but this repo's
196
+ // settings select anthropic and no mixlayer extension is pinned.
197
+ // Naming only "no credential" would misdirect toward the key when
198
+ // the real cause may be a missing provider extension instead.
199
+ 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`);
200
+ }
201
+ else if (verdict === "listed") {
202
+ pass(`defaultModel registered: ${model} (extension loaded, credential present; key NOT validated)`);
203
+ }
204
+ else {
205
+ fail(`defaultModel "${model}" absent from \`pi --list-models\` — provider extension missing or model id wrong`);
206
+ }
207
+ }
208
+ }
142
209
  }
143
210
  // --- summary ---
144
211
  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
@@ -5,17 +5,34 @@ import { readManifest } from "../manifest.js";
5
5
  function git(args) {
6
6
  return spawnSync("git", args, { stdio: "inherit" }).status ?? 1;
7
7
  }
8
- /** Cross the manifest with what is actually checked out under bays/. */
9
- export function bayStatuses(root) {
10
- return readManifest(root).map((bay) => {
11
- const dest = join(root, "bays", bay.name);
12
- if (!existsSync(join(dest, ".git")))
13
- return { ...bay, synced: false };
14
- const rev = spawnSync("git", ["-C", dest, "rev-parse", "--short", "HEAD"], {
15
- encoding: "utf8",
16
- });
17
- return { ...bay, synced: true, head: rev.status === 0 ? rev.stdout.trim() : "?" };
8
+ /** Read the manifest, reporting a bad one as an error rather than a stack
9
+ * trace. A `bays: []` line followed by list items is the common way to get
10
+ * here — it looks like an append but is not valid YAML. */
11
+ function readOrReport(root) {
12
+ try {
13
+ return readManifest(root);
14
+ }
15
+ catch (e) {
16
+ const first = e.message.split("\n")[0];
17
+ process.stderr.write(`error: garaje.yaml: ${first}\n`);
18
+ process.stderr.write("hint: bays must be a YAML list; an empty `bays: []` cannot be followed by `- name:` items\n");
19
+ return undefined;
20
+ }
21
+ }
22
+ /** Whether one manifest bay is materialized, and at which commit. */
23
+ function bayStatus(root, bay) {
24
+ const dest = join(root, "bays", bay.name);
25
+ if (!existsSync(join(dest, ".git")))
26
+ return { ...bay, synced: false };
27
+ const rev = spawnSync("git", ["-C", dest, "rev-parse", "--short", "HEAD"], {
28
+ encoding: "utf8",
18
29
  });
30
+ return { ...bay, synced: true, head: rev.status === 0 ? rev.stdout.trim() : "?" };
31
+ }
32
+ /** Cross the manifest with what is actually checked out under bays/. Throws on
33
+ * a bad manifest; doctor reports that as a failed check. */
34
+ export function bayStatuses(root) {
35
+ return readManifest(root).map((bay) => bayStatus(root, bay));
19
36
  }
20
37
  /** One human-readable line per bay. Pure — doctor reuses this. */
21
38
  export function statusLine(b) {
@@ -34,7 +51,10 @@ export function renderList(rows) {
34
51
  }
35
52
  /** `garaje list` — show the manifest and which bays are materialized. */
36
53
  export function runList(root) {
37
- process.stdout.write(renderList(bayStatuses(root)));
54
+ const entries = readOrReport(root);
55
+ if (!entries)
56
+ return 1;
57
+ process.stdout.write(renderList(entries.map((b) => bayStatus(root, b))));
38
58
  return 0;
39
59
  }
40
60
  /** `garaje sync [name...]` — clone/update the manifest's bays into bays/. */
@@ -43,7 +63,9 @@ export function runSync(root, names) {
43
63
  process.stderr.write("error: git not found in PATH\n");
44
64
  return 1;
45
65
  }
46
- const entries = readManifest(root);
66
+ const entries = readOrReport(root);
67
+ if (!entries)
68
+ return 1;
47
69
  const unknown = names.filter((n) => !entries.some((b) => b.name === n));
48
70
  if (unknown.length) {
49
71
  process.stderr.write(`error: not in garaje.yaml: ${unknown.join(", ")} (declare the bay in the manifest first)\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "garaje",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,15 @@
1
- # Copy to .env and fill in to use API-key auth for the pi container.
2
- # Alternatively leave blank and run `/login` inside pi (persisted via the pi-config volume).
1
+ # Copy to .env, then fill in one provider key.
2
+ #
3
+ # This file is the garaje's ONLY credential surface: compose forwards .env into
4
+ # the pi container (env_file). A variable exported in your shell does NOT reach
5
+ # the agent.
6
+
7
+ # The scaffolded provider. Create a key at https://console.mixlayer.com/app/api-keys
8
+ MIXLAYER_API_KEY=
9
+
10
+ # Only needed if you switch .pi/settings.json back to defaultProvider "anthropic".
3
11
  ANTHROPIC_API_KEY=
12
+
13
+ # NOTE: a key stored by `/login` inside pi is written to ~/.pi/agent/auth.json on
14
+ # the pi-config volume, survives rebuilds, and TAKES PRECEDENCE over this file.
15
+ # `garaje doctor` warns when that file exists.
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "$schema": "https://pi.dev/schemas/settings.json",
3
- "defaultProvider": "anthropic",
4
- "defaultModel": "claude-opus-4-8",
3
+ "defaultProvider": "mixlayer",
4
+ "defaultModel": "qwen/qwen3.5-397b-a17b",
5
5
  "defaultThinkingLevel": "medium",
6
6
  "theme": "dark",
7
7
  "packages": [
8
8
  {
9
9
  "source": "__BASE_PIN__",
10
10
  "extensions": ["extensions/**", "!extensions/session-name.ts"]
11
- }
11
+ },
12
+ "npm:pi-mixlayer@0.4.0"
12
13
  ]
13
14
  }
@@ -14,8 +14,18 @@ services:
14
14
  - ./:/workspace
15
15
  - pi-config:/root/.pi/agent
16
16
  - garaje-auth:/root/.garaje-auth
17
+ # Provider credentials come from .env — a garaje's single credential surface.
18
+ # Any pi provider extension (pi-mixlayer, pi-openai, …) resolves its key from
19
+ # the container environment, so adding a provider needs no framework release.
20
+ # A shell-exported variable does NOT reach the agent; put it in .env.
21
+ # required:false keeps `garaje up` working before .env exists.
22
+ env_file:
23
+ - path: .env
24
+ required: false
17
25
  environment:
18
- - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
26
+ # Framework-owned. `environment:` overrides `env_file`, so a stray .env
27
+ # entry cannot hijack any of these.
28
+ #
19
29
  # The agent's ONLY docker endpoint: the whitelisting proxy below.
20
30
  # The raw socket is never mounted here (north-star §5.1 layer 1).
21
31
  - DOCKER_HOST=tcp://docker-proxy:2375
@@ -26,6 +36,10 @@ services:
26
36
  # bind mounts use ${GARAJE_HOST_ROOT:-.} because bind sources resolve
27
37
  # on the daemon (host) side even when compose runs in-container.
28
38
  - GARAJE_HOST_ROOT=${PWD:-}
39
+ # pi/Dockerfile sets this via ENV, which env_file (unlike environment:)
40
+ # does NOT outrank — restated here so a stray .env line cannot redirect
41
+ # the agent's gh config directory (and its git/gh credential wiring).
42
+ - GH_CONFIG_DIR=/root/.garaje-auth/gh
29
43
  depends_on:
30
44
  - docker-proxy
31
45
 
@@ -24,3 +24,7 @@ bays/*
24
24
 
25
25
  # Dev-local garaje overrides (per-bay bind-mount paths, personal settings).
26
26
  garaje.local.yaml
27
+
28
+ # Pi packages installed into project scope by `pi install -l`. The package
29
+ # manifest lives in the committed .pi/settings.json; these are its artifacts.
30
+ /.pi/npm/