garaje 0.1.1 → 0.1.3

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/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # garaje
2
+
3
+ **Park your codebases. Let the agent drive.**
4
+
5
+ `garaje` is a command-line tool for running the [pi](https://pi.dev) coding
6
+ agent against the codebases you actually work on — each one containerized with
7
+ its own datastores, alongside the agent, in a single Docker Compose project
8
+ called a **garaje**. You scaffold a garaje, hand it your repos, and the
9
+ in-container agent wires each one up for agentic development.
10
+
11
+ The design goal is **portability**: your dev environment is declared as data
12
+ (a manifest + per-codebase recipes), the agent harness is a swappable package,
13
+ and the same garaje runs locally on Docker today (Kubernetes is on the roadmap).
14
+
15
+ ```bash
16
+ npm install -g garaje
17
+ ```
18
+
19
+ > Requires **Docker** (Desktop or Engine) on the host. The agent and its tools
20
+ > run in containers; `garaje` itself is a thin, dependency-light wrapper over
21
+ > `docker compose`.
22
+
23
+ ---
24
+
25
+ ## Quick start
26
+
27
+ ```bash
28
+ # 1. Scaffold a new garaje (a git repo that hosts your parked codebases)
29
+ garaje init my-workspace
30
+ cd my-workspace
31
+
32
+ # 2. Start it — builds the agent container and brings the project up
33
+ garaje up --build
34
+
35
+ # 3. Drop into the agent
36
+ garaje pi
37
+ ```
38
+
39
+ Then, from inside the agent, point it at a codebase and ask it to **park** it —
40
+ the agent introspects the repo, writes a `recipe.yaml` describing how it runs
41
+ (toolchain, processes, datastores, env), generates the container config, and
42
+ commits it. From then on that codebase is a first-class, live-reloading,
43
+ agent-assisted dev target in your garaje.
44
+
45
+ Stop without losing your work or your credentials:
46
+
47
+ ```bash
48
+ garaje down # refuses -v, so the agent's auth + provisioned tokens survive
49
+ garaje up
50
+ garaje pi -c # continue your most recent session
51
+ ```
52
+
53
+ ## Concepts
54
+
55
+ | Term | What it is |
56
+ |------|-----------|
57
+ | **garaje** | A git repo + Docker Compose project that runs the pi agent next to the codebases it works on. One per developer, or one shared per team. |
58
+ | **bay** | A parked codebase inside a garaje (`bays/<name>`), materialized from the manifest. The garaje commits the *manifest*, not the code. |
59
+ | **`recipe.yaml`** | A platform-neutral description of how one codebase runs — its toolchain, app processes, backing services, env, and secrets. Lives in the codebase; the agent authors it by introspection. |
60
+ | **[`@garaje/base`](https://www.npmjs.com/package/@garaje/base)** | The agent-side substrate — the persona, rules, roles, and skills the in-container agent loads. Pinned declaratively in `.pi/settings.json`; installed automatically in-container. |
61
+
62
+ ## Commands
63
+
64
+ ```
65
+ garaje init <name> [dir] scaffold a new garaje
66
+ garaje up [--build] build + start the garaje (host-only)
67
+ garaje down stop it (refuses -v; auth/tokens persist)
68
+ garaje pi [-c] open the pi agent TUI (-c continues the last session)
69
+ garaje attach attach to the running agent
70
+ garaje logs tail container logs
71
+ garaje doctor health-check the garaje
72
+ garaje sync [name] clone the manifest's codebases into bays/
73
+ garaje list list parked bays
74
+ garaje park [name] regenerate the compose from the bays' recipes
75
+ garaje bay <up|down|logs|ps> operate a parked bay's services
76
+ garaje auth <add|list|remove> provision git / gh / integration credentials
77
+ garaje upgrade [--to <v>] bump the framework + base pins, flag override drift
78
+ garaje sessions-index index the agent's session transcripts
79
+ ```
80
+
81
+ ## How it works
82
+
83
+ - **Compose split by ownership.** A thin top-level `docker-compose.yml` includes
84
+ the framework layer (the agent container); parked codebases live in a
85
+ generated `compose.bays.yaml`. The agent shapes services by editing recipes,
86
+ never the compose files it can't reconfigure itself.
87
+ - **Bind-mounted, live-reloading.** The repo is mounted into the containers, so
88
+ edits the agent makes take effect immediately — no image rebuilds for code
89
+ changes.
90
+ - **Least-privilege Docker.** The agent reaches Docker only through a
91
+ default-deny socket proxy, never the raw socket.
92
+ - **Layered, declarative config.** The shared `@garaje/base` package is pinned
93
+ and consumed read-only; a garaje customizes by layering local overrides on
94
+ top — so upgrading the framework is a pin bump, not a merge.
95
+
96
+ ## Framework & harness portability
97
+
98
+ garaje separates the *dev environment* (yours, committed as data) from the
99
+ *agent harness* (a package). Today the harness is pi via `@garaje/base`; the
100
+ seam is deliberately thin so a different harness is a different base package,
101
+ not a rewrite. The runtime is Docker Compose locally, with a Kubernetes driver
102
+ planned for the cloud — the same recipe, two runtimes.
103
+
104
+ `garaje` and `@garaje/base` are released in lockstep. Upgrade a garaje with
105
+ `garaje upgrade`, which bumps the pins and flags any local override that now
106
+ shadows or orphans a changed base resource.
107
+
108
+ ## License
109
+
110
+ MIT
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { runLogs } from "./host/logs.js";
9
9
  import { runPi } from "./host/pi.js";
10
10
  import { runAttach } from "./host/attach.js";
11
11
  import { runDoctor } from "./host/doctor.js";
12
- import { runSyncList } from "./host/sync.js";
12
+ import { runList, runSync } from "./host/sync.js";
13
13
  import { runAuth } from "./host/auth.js";
14
14
  import { runSessionsIndex } from "./sessions/run.js";
15
15
  import { inContainer } from "./driver/index.js";
@@ -47,9 +47,9 @@ export function main(argv) {
47
47
  case "doctor":
48
48
  return runDoctor(root);
49
49
  case "sync":
50
- return runSyncList(root, "sync", rest);
50
+ return runSync(root, rest);
51
51
  case "list":
52
- return runSyncList(root, "list", rest);
52
+ return runList(root);
53
53
  case "park":
54
54
  return runPark(root, rest).code;
55
55
  case "sessions-index":
@@ -1,8 +1,10 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
4
  import { driverContext, getDriver } from "../driver/index.js";
5
5
  import { baysArtifactState, discoverBays, manifestBays } from "../park/index.js";
6
+ import { basePinSource } from "../upgrade/pins.js";
7
+ import { bayStatuses, statusLine } from "./sync.js";
6
8
  /** `garaje doctor` — host-side sanity checks. Exit code = failed-check count. */
7
9
  export function runDoctor(root) {
8
10
  let fails = 0;
@@ -33,7 +35,6 @@ export function runDoctor(root) {
33
35
  "garaje.yaml",
34
36
  "pi/Dockerfile",
35
37
  "pi/entrypoint.sh",
36
- "packages/base/package.json",
37
38
  ".pi/settings.json",
38
39
  ]) {
39
40
  if (existsSync(join(root, f)))
@@ -41,6 +42,23 @@ export function runDoctor(root) {
41
42
  else
42
43
  fail(`${f} missing`);
43
44
  }
45
+ // The base package is an npm pin in a scaffolded garaje and a relative path
46
+ // in the framework monorepo. Only the path form has anything on disk to check.
47
+ const settings = join(root, ".pi", "settings.json");
48
+ const source = existsSync(settings)
49
+ ? basePinSource(readFileSync(settings, "utf8"))
50
+ : undefined;
51
+ if (!source)
52
+ fail("@garaje/base is not pinned in .pi/settings.json");
53
+ else if (source.startsWith("npm:"))
54
+ pass(`@garaje/base pinned: ${source}`);
55
+ else {
56
+ const dir = resolve(join(root, ".pi"), source);
57
+ if (existsSync(join(dir, "package.json")))
58
+ pass(`@garaje/base pinned: ${source}`);
59
+ else
60
+ fail(`@garaje/base path pin "${source}" missing — no package.json at ${dir}`);
61
+ }
44
62
  if (existsSync(join(root, ".env")))
45
63
  pass(".env present");
46
64
  else
@@ -57,14 +75,24 @@ export function runDoctor(root) {
57
75
  pass("pi service running");
58
76
  else
59
77
  fail("pi service NOT running (try garaje up)");
60
- // --- bays (delegate to the retained bash list) ---
78
+ // --- bays ---
61
79
  section("bays");
62
- const list = spawnSync("bash", [join(root, "bin", "garaje-sync"), "list"], { encoding: "utf8" });
63
- const bayLines = (list.stdout ?? "").split(/\r?\n/).slice(1).filter((l) => l.trim());
64
- if (bayLines.length)
65
- bayLines.forEach((l) => pass(l));
66
- else
67
- warn("no bays parked (add one to garaje.yaml, then garaje sync)");
80
+ // readManifest throws on a missing/corrupt garaje.yaml; doctor reports that
81
+ // as a failed check rather than crashing (same contract as the artifact check).
82
+ try {
83
+ const bays = bayStatuses(root);
84
+ if (!bays.length)
85
+ warn("no bays parked (add one to garaje.yaml, then garaje sync)");
86
+ for (const b of bays) {
87
+ if (b.synced)
88
+ pass(`${b.name}: ${statusLine(b)} (${b.ref})`);
89
+ else
90
+ warn(`${b.name}: ${statusLine(b)} — run \`garaje sync ${b.name}\``);
91
+ }
92
+ }
93
+ catch (e) {
94
+ fail(`bay status check errored — garaje.yaml failed to parse: ${e.message}`);
95
+ }
68
96
  const unhealthy = driver.psText()
69
97
  .split(/\r?\n/)
70
98
  .filter((l) => /unhealthy/i.test(l));
@@ -77,7 +105,7 @@ export function runDoctor(root) {
77
105
  if (artifact === "fresh")
78
106
  pass("bays artifact matches the recipes (regenerate-and-diff)");
79
107
  else if (artifact === "no-bays")
80
- warn("no synced bays — artifact staleness not checkable");
108
+ warn("no parked bays (no recipe.yaml) — artifact staleness not checkable");
81
109
  else if (artifact === "missing")
82
110
  fail("bays artifact missing — run `garaje park` and commit the result");
83
111
  else
@@ -86,9 +114,12 @@ export function runDoctor(root) {
86
114
  catch (e) {
87
115
  fail(`bays artifact check errored — a recipe failed to parse: ${e.message}`);
88
116
  }
89
- const unsynced = manifestBays(root).filter((b) => !discoverBays(root).includes(b));
90
- if (unsynced.length) {
91
- warn(`manifest bay(s) not synced: ${unsynced.join(", ")} — the artifact excludes them (run garaje sync)`);
117
+ // "Parked" (has a recipe.yaml) is a stricter state than "synced" (cloned);
118
+ // only parked bays contribute to the artifact.
119
+ const parked = discoverBays(root);
120
+ const unparked = manifestBays(root).filter((b) => !parked.includes(b));
121
+ if (unparked.length) {
122
+ warn(`manifest bay(s) with no recipe.yaml: ${unparked.join(", ")} — the artifact excludes them`);
92
123
  }
93
124
  // --- pi container ---
94
125
  section("pi");
package/dist/host/sync.js CHANGED
@@ -1,7 +1,88 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync } from "node:fs";
2
3
  import { join } from "node:path";
3
- /** `garaje sync` / `garaje list` — delegate to the retained bash helper. */
4
- export function runSyncList(root, sub, extra) {
5
- const helper = join(root, "bin", "garaje-sync");
6
- return spawnSync("bash", [helper, sub, ...extra], { cwd: root, stdio: "inherit" }).status ?? 1;
4
+ import { readManifest } from "../manifest.js";
5
+ function git(args) {
6
+ return spawnSync("git", args, { stdio: "inherit" }).status ?? 1;
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() : "?" };
18
+ });
19
+ }
20
+ /** One human-readable line per bay. Pure — doctor reuses this. */
21
+ export function statusLine(b) {
22
+ return b.synced ? `synced @ ${b.head}` : "not synced";
23
+ }
24
+ /** The `garaje list` table. Pure. */
25
+ export function renderList(rows) {
26
+ const w = (header, pick) => Math.max(header.length, ...rows.map((b) => pick(b).length || 1));
27
+ const repo = (b) => b.repo || "-";
28
+ const wName = w("BAY", (b) => b.name);
29
+ const wRepo = w("REPO", repo);
30
+ const wRef = w("REF", (b) => b.ref);
31
+ const line = (a, b, c, d) => `${a.padEnd(wName)} ${b.padEnd(wRepo)} ${c.padEnd(wRef)} ${d}\n`;
32
+ return (line("BAY", "REPO", "REF", "STATUS") +
33
+ rows.map((b) => line(b.name, repo(b), b.ref, statusLine(b))).join(""));
34
+ }
35
+ /** `garaje list` — show the manifest and which bays are materialized. */
36
+ export function runList(root) {
37
+ process.stdout.write(renderList(bayStatuses(root)));
38
+ return 0;
39
+ }
40
+ /** `garaje sync [name...]` — clone/update the manifest's bays into bays/. */
41
+ export function runSync(root, names) {
42
+ if (spawnSync("git", ["--version"], { stdio: "ignore" }).status !== 0) {
43
+ process.stderr.write("error: git not found in PATH\n");
44
+ return 1;
45
+ }
46
+ const entries = readManifest(root);
47
+ const unknown = names.filter((n) => !entries.some((b) => b.name === n));
48
+ if (unknown.length) {
49
+ process.stderr.write(`error: not in garaje.yaml: ${unknown.join(", ")} (declare the bay in the manifest first)\n`);
50
+ return 1;
51
+ }
52
+ const wanted = names.length ? entries.filter((b) => names.includes(b.name)) : entries;
53
+ mkdirSync(join(root, "bays"), { recursive: true });
54
+ let synced = 0;
55
+ for (const bay of wanted) {
56
+ if (!bay.repo) {
57
+ process.stderr.write(`skip ${bay.name}: no repo in manifest\n`);
58
+ continue;
59
+ }
60
+ const dest = join(root, "bays", bay.name);
61
+ if (existsSync(join(dest, ".git"))) {
62
+ process.stdout.write(`==> ${bay.name}: fetching (${bay.ref})\n`);
63
+ if (git(["-C", dest, "fetch", "--quiet", "origin", bay.ref]) !== 0 ||
64
+ git(["-C", dest, "checkout", "--quiet", bay.ref]) !== 0) {
65
+ process.stderr.write(`error: ${bay.name}: fetch/checkout of ${bay.ref} failed\n`);
66
+ return 1;
67
+ }
68
+ // Fast-forward only when ref names a branch. A tag or sha leaves HEAD
69
+ // detached, and there is nothing to advance — that is not a failure.
70
+ const onBranch = spawnSync("git", ["-C", dest, "symbolic-ref", "-q", "HEAD"], { stdio: "ignore" })
71
+ .status === 0;
72
+ if (onBranch && git(["-C", dest, "merge", "--quiet", "--ff-only", "FETCH_HEAD"]) !== 0) {
73
+ process.stderr.write(`error: ${bay.name}: ${bay.ref} is not fast-forwardable\n`);
74
+ return 1;
75
+ }
76
+ }
77
+ else {
78
+ process.stdout.write(`==> ${bay.name}: cloning ${bay.repo} (${bay.ref})\n`);
79
+ if (git(["clone", "--quiet", "--branch", bay.ref, bay.repo, dest]) !== 0) {
80
+ process.stderr.write(`error: ${bay.name}: clone failed\n`);
81
+ return 1;
82
+ }
83
+ }
84
+ synced += 1;
85
+ }
86
+ process.stdout.write(`synced ${synced} bay(s) into bays/\n`);
87
+ return 0;
7
88
  }
@@ -0,0 +1,24 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ /** Parse the `bays:` list out of a garaje.yaml. Entries without a name are
5
+ * dropped; `ref` defaults to main. Pure — throws only on malformed YAML. */
6
+ export function parseManifest(text) {
7
+ const data = parseYaml(text);
8
+ const bays = Array.isArray(data?.bays) ? data.bays : [];
9
+ return bays
10
+ .filter((b) => Boolean(b) && typeof b === "object")
11
+ .map((b) => ({
12
+ name: String(b.name ?? "").trim(),
13
+ repo: String(b.repo ?? "").trim(),
14
+ ref: String(b.ref ?? "main").trim() || "main",
15
+ }))
16
+ .filter((b) => b.name);
17
+ }
18
+ /** Read <root>/garaje.yaml. Throws if it is missing or unparseable. */
19
+ export function readManifest(root) {
20
+ const path = join(root, "garaje.yaml");
21
+ if (!existsSync(path))
22
+ throw new Error("no garaje.yaml at the garaje root");
23
+ return parseManifest(readFileSync(path, "utf8"));
24
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { parse as parseYaml } from "yaml";
3
+ import { readManifest } from "../manifest.js";
4
4
  import { parseRecipe } from "./recipe.js";
5
5
  import { generate } from "./compose.js";
6
6
  /** Bay folder names under <root>/bays that contain a recipe.yaml, sorted. */
@@ -13,16 +13,12 @@ export function discoverBays(root) {
13
13
  .map((e) => e.name)
14
14
  .sort();
15
15
  }
16
- /** Bay names declared in the committed manifest (garaje.yaml). */
16
+ /** Bay names declared in the committed manifest (garaje.yaml). Lenient: a
17
+ * missing or corrupt manifest yields no bays rather than throwing, because
18
+ * every caller here is a check that reports on it separately. */
17
19
  export function manifestBays(root) {
18
- const path = join(root, "garaje.yaml");
19
- if (!existsSync(path))
20
- return [];
21
20
  try {
22
- const data = parseYaml(readFileSync(path, "utf8"));
23
- return (data?.bays ?? [])
24
- .map((b) => b?.name)
25
- .filter((n) => Boolean(n));
21
+ return readManifest(root).map((b) => b.name);
26
22
  }
27
23
  catch {
28
24
  return [];
@@ -11,3 +11,18 @@ export function currentBaseVersion(settingsText) {
11
11
  const m = settingsText.match(/npm:@garaje\/base@([0-9A-Za-z.\-]+)/);
12
12
  return m ? m[1] : undefined;
13
13
  }
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. */
16
+ export function basePinSource(settingsText) {
17
+ let sources;
18
+ try {
19
+ const data = JSON.parse(settingsText);
20
+ sources = (data.packages ?? [])
21
+ .map((p) => p?.source)
22
+ .filter((s) => Boolean(s));
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ return sources.find((s) => /@garaje\/base|packages\/base/.test(s)) ?? sources[0];
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "garaje",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",