garaje 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli.js +74 -0
  3. package/dist/driver/compose.js +127 -0
  4. package/dist/driver/index.js +33 -0
  5. package/dist/driver/types.js +7 -0
  6. package/dist/host/attach.js +12 -0
  7. package/dist/host/auth-presets.js +13 -0
  8. package/dist/host/auth-resolvers.js +46 -0
  9. package/dist/host/auth.js +140 -0
  10. package/dist/host/bay.js +61 -0
  11. package/dist/host/compose.js +65 -0
  12. package/dist/host/docker.js +15 -0
  13. package/dist/host/doctor.js +119 -0
  14. package/dist/host/down.js +17 -0
  15. package/dist/host/init.js +131 -0
  16. package/dist/host/logs.js +12 -0
  17. package/dist/host/pi.js +15 -0
  18. package/dist/host/sync.js +7 -0
  19. package/dist/host/up.js +34 -0
  20. package/dist/park/compose.js +190 -0
  21. package/dist/park/dockerfile.js +23 -0
  22. package/dist/park/index.js +77 -0
  23. package/dist/park/ports.js +11 -0
  24. package/dist/park/recipe.js +12 -0
  25. package/dist/sessions/collect.js +86 -0
  26. package/dist/sessions/render.js +24 -0
  27. package/dist/sessions/run.js +14 -0
  28. package/dist/upgrade/classify.js +137 -0
  29. package/dist/upgrade/index.js +94 -0
  30. package/dist/upgrade/pins.js +13 -0
  31. package/package.json +26 -0
  32. package/templates/.agent/README.md +17 -0
  33. package/templates/.agent/commands/.gitkeep +0 -0
  34. package/templates/.agent/rules/.gitkeep +0 -0
  35. package/templates/.agent/skills/.gitkeep +0 -0
  36. package/templates/.env.example +3 -0
  37. package/templates/.garaje-version +1 -0
  38. package/templates/.pi/README.md +17 -0
  39. package/templates/.pi/settings.json +13 -0
  40. package/templates/AGENTS.md +38 -0
  41. package/templates/compose.framework.yaml +60 -0
  42. package/templates/docker-compose.yml +13 -0
  43. package/templates/garaje.yaml +14 -0
  44. package/templates/gitignore +26 -0
  45. package/templates/pi/Dockerfile +56 -0
  46. package/templates/pi/PI_VERSION +1 -0
  47. package/templates/pi/entrypoint.sh +49 -0
@@ -0,0 +1,86 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const PREVIEW_LEN = 60;
4
+ function clean(text) {
5
+ return text.replace(/[\r\n]+/g, " ").trim().split(/\s+/).join(" ");
6
+ }
7
+ /** Parse one .jsonl session file into a row, or null if it isn't a session. */
8
+ function parseSession(path) {
9
+ let lines;
10
+ try {
11
+ lines = readFileSync(path, "utf8").split(/\r?\n/);
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ let header;
17
+ try {
18
+ header = JSON.parse(lines[0] ?? "");
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ if (!header || header.type !== "session")
24
+ return null;
25
+ let messages = 0;
26
+ let cost = 0;
27
+ let preview = "";
28
+ for (const line of lines.slice(1)) {
29
+ if (!line)
30
+ continue;
31
+ let obj;
32
+ try {
33
+ obj = JSON.parse(line);
34
+ }
35
+ catch {
36
+ continue;
37
+ }
38
+ if (obj?.type !== "message")
39
+ continue;
40
+ messages += 1;
41
+ const msg = obj.message ?? {};
42
+ if (msg.role === "user" && !preview) {
43
+ const content = msg.content;
44
+ if (typeof content === "string")
45
+ preview = content;
46
+ else if (Array.isArray(content)) {
47
+ const block = content.find((b) => b && b.type === "text");
48
+ if (block)
49
+ preview = block.text ?? "";
50
+ }
51
+ }
52
+ if (msg.role === "assistant") {
53
+ cost += Number(msg.usage?.cost?.total ?? 0) || 0;
54
+ }
55
+ }
56
+ const mtime = statSync(path).mtimeMs;
57
+ return {
58
+ id: header.id ?? "",
59
+ last: new Date(mtime).toISOString().replace(/\.\d{3}Z$/, "Z"),
60
+ messages,
61
+ cost,
62
+ preview: clean(preview).slice(0, PREVIEW_LEN),
63
+ mtime,
64
+ };
65
+ }
66
+ // All parseable sessions under <root>/sessions/*/*.jsonl, newest-first.
67
+ export function collectSessions(root) {
68
+ const dir = join(root, "sessions");
69
+ if (!existsSync(dir))
70
+ return [];
71
+ const rows = [];
72
+ for (const sub of readdirSync(dir, { withFileTypes: true })) {
73
+ if (!sub.isDirectory())
74
+ continue;
75
+ const subPath = join(dir, sub.name);
76
+ for (const f of readdirSync(subPath)) {
77
+ if (!f.endsWith(".jsonl"))
78
+ continue;
79
+ const parsed = parseSession(join(subPath, f));
80
+ if (parsed)
81
+ rows.push(parsed);
82
+ }
83
+ }
84
+ rows.sort((a, b) => b.mtime - a.mtime);
85
+ return rows.map(({ mtime, ...row }) => row);
86
+ }
@@ -0,0 +1,24 @@
1
+ const MAX_ROWS = 100;
2
+ /** Render sessions/INDEX.md. Pure: caller supplies rows + the generated-at stamp. */
3
+ export function renderIndex(rows, generatedAt) {
4
+ const lines = [
5
+ "# Sessions index",
6
+ "",
7
+ `_Auto-generated by \`garaje sessions-index\` at ${generatedAt}._ Regenerated on every \`garaje up\`.`,
8
+ "",
9
+ `${rows.length} session(s) total. Showing newest ${Math.min(rows.length, MAX_ROWS)}.`,
10
+ "",
11
+ "Resume by short id with `garaje pi --session <id>`, or pick interactively with `garaje pi -r`.",
12
+ "",
13
+ "| last activity | id | msgs | cost | preview |",
14
+ "|---|---|---:|---:|---|",
15
+ ];
16
+ for (const s of rows.slice(0, MAX_ROWS)) {
17
+ const shortId = s.id ? s.id.slice(0, 8) : "?";
18
+ const preview = (s.preview || "_(no user messages yet)_").replace(/\|/g, "\\|");
19
+ const cost = s.cost ? `$${s.cost.toFixed(4)}` : "—";
20
+ lines.push(`| ${s.last} | \`${shortId}\` | ${s.messages} | ${cost} | ${preview} |`);
21
+ }
22
+ lines.push("");
23
+ return lines.join("\n");
24
+ }
@@ -0,0 +1,14 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { collectSessions } from "./collect.js";
4
+ import { renderIndex } from "./render.js";
5
+ /** Regenerate <root>/sessions/INDEX.md. Safe to re-run; idempotent. */
6
+ export function runSessionsIndex(root) {
7
+ const dir = join(root, "sessions");
8
+ mkdirSync(dir, { recursive: true });
9
+ const rows = collectSessions(root);
10
+ const generatedAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
11
+ writeFileSync(join(dir, "INDEX.md"), renderIndex(rows, generatedAt));
12
+ process.stderr.write(`sessions-index: wrote sessions/INDEX.md (${rows.length} sessions)\n`);
13
+ return { code: 0 };
14
+ }
@@ -0,0 +1,137 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /** name (relative, forward-slashed) -> file content, for *.md under dir. */
4
+ function scanMd(dir) {
5
+ const out = new Map();
6
+ const walk = (d, base = "") => {
7
+ let entries;
8
+ try {
9
+ entries = readdirSync(d, { withFileTypes: true });
10
+ }
11
+ catch {
12
+ return; // missing dir -> no resources
13
+ }
14
+ for (const e of entries) {
15
+ const rel = base ? `${base}/${e.name}` : e.name;
16
+ if (e.isDirectory())
17
+ walk(join(d, e.name), rel);
18
+ else if (e.isFile() && e.name.endsWith(".md"))
19
+ out.set(rel, readFileSync(join(d, e.name), "utf8"));
20
+ }
21
+ };
22
+ walk(dir);
23
+ return out;
24
+ }
25
+ function readJsonObj(file) {
26
+ try {
27
+ const v = JSON.parse(readFileSync(file, "utf8"));
28
+ return v && typeof v === "object" ? v : {};
29
+ }
30
+ catch {
31
+ return {};
32
+ }
33
+ }
34
+ /** Named-file resources (rules, agents): shadow = local hides a changed/added
35
+ * base file; orphan = local file whose base counterpart was removed. */
36
+ function classifyNamedFiles(oldDir, newDir, localDir, resource) {
37
+ const oldF = scanMd(oldDir);
38
+ const newF = scanMd(newDir);
39
+ const local = new Set(scanMd(localDir).keys());
40
+ const out = [];
41
+ for (const [name, newText] of newF) {
42
+ const changed = !oldF.has(name) || oldF.get(name) !== newText;
43
+ if (changed && local.has(name)) {
44
+ const verb = oldF.has(name) ? "changed" : "added";
45
+ out.push({
46
+ kind: "shadow",
47
+ resource,
48
+ name,
49
+ detail: `base ${verb} ${resource} '${name}', but a local override shadows it — the update is hidden until you reconcile`,
50
+ });
51
+ }
52
+ }
53
+ for (const name of local) {
54
+ if (oldF.has(name) && !newF.has(name)) {
55
+ out.push({
56
+ kind: "orphan",
57
+ resource,
58
+ name,
59
+ detail: `local ${resource} '${name}' overrode a base file removed in the new version — the override is now dead`,
60
+ });
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ /** presets.json: keys, not files. Shadow = local key hides a changed/added base
66
+ * key; orphan = local key whose base key was removed. */
67
+ function classifyPresets(oldFile, newFile, localFile) {
68
+ const oldK = readJsonObj(oldFile);
69
+ const newK = readJsonObj(newFile);
70
+ const local = readJsonObj(localFile);
71
+ const out = [];
72
+ for (const [k, v] of Object.entries(newK)) {
73
+ const changed = !(k in oldK) || JSON.stringify(oldK[k]) !== JSON.stringify(v);
74
+ if (changed && k in local) {
75
+ const verb = k in oldK ? "changed" : "added";
76
+ out.push({
77
+ kind: "shadow",
78
+ resource: "preset",
79
+ name: k,
80
+ detail: `base ${verb} preset '${k}', but a local preset shadows it`,
81
+ });
82
+ }
83
+ }
84
+ for (const k of Object.keys(local)) {
85
+ if (k in oldK && !(k in newK)) {
86
+ out.push({
87
+ kind: "orphan",
88
+ resource: "preset",
89
+ name: k,
90
+ detail: `local preset '${k}' overrode a base preset removed in the new version — dead override`,
91
+ });
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ /** Pull the extension basenames a garaje excludes via `!extensions/<x>` in its
97
+ * base-pin `extensions` filter array. */
98
+ export function extractExtensionFilters(settingsFile) {
99
+ const s = readJsonObj(settingsFile);
100
+ const pkgs = Array.isArray(s.packages) ? s.packages : [];
101
+ const out = [];
102
+ for (const p of pkgs) {
103
+ if (p && typeof p === "object" && Array.isArray(p.extensions)) {
104
+ for (const g of p.extensions) {
105
+ if (typeof g === "string" && g.startsWith("!extensions/")) {
106
+ out.push(g.slice("!extensions/".length));
107
+ }
108
+ }
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+ /** A `!`-filter orphans when its target extension is gone in the new base — the
114
+ * intended exclusion silently stops applying, so a base extension may now load. */
115
+ function classifyFilters(newExtDir, filters) {
116
+ const out = [];
117
+ for (const f of filters) {
118
+ if (!existsSync(join(newExtDir, f))) {
119
+ out.push({
120
+ kind: "orphan",
121
+ resource: "filter",
122
+ name: f,
123
+ detail: `.pi/settings.json filters out base extension '${f}', which the new version renamed or removed — the exclusion no longer applies`,
124
+ });
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+ /** Diff old-base vs new-base against the garaje's small local override layer. */
130
+ export function classify(oldBaseDir, newBaseDir, cwd) {
131
+ return [
132
+ ...classifyNamedFiles(join(oldBaseDir, "rules"), join(newBaseDir, "rules"), join(cwd, ".agent/rules"), "rule"),
133
+ ...classifyNamedFiles(join(oldBaseDir, "agents"), join(newBaseDir, "agents"), join(cwd, ".pi/agents"), "agent"),
134
+ ...classifyPresets(join(oldBaseDir, "presets.json"), join(newBaseDir, "presets.json"), join(cwd, ".pi/presets.json")),
135
+ ...classifyFilters(join(newBaseDir, "extensions"), extractExtensionFilters(join(cwd, ".pi/settings.json"))),
136
+ ];
137
+ }
@@ -0,0 +1,94 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { classify } from "./classify.js";
6
+ import { currentBaseVersion, rewriteBasePin, rewriteDockerfileCliPin } from "./pins.js";
7
+ /** Resolve @garaje/base@<version> into a directory tree via `npm pack`, or
8
+ * undefined if it can't be fetched (e.g. pre-publish / offline). */
9
+ function fetchBaseTree(version) {
10
+ const dir = mkdtempSync(join(tmpdir(), `garaje-base-${version}-`));
11
+ const packed = spawnSync("npm", ["pack", `@garaje/base@${version}`, "--pack-destination", dir], {
12
+ stdio: "ignore",
13
+ });
14
+ if (packed.status !== 0) {
15
+ rmSync(dir, { recursive: true, force: true });
16
+ return undefined;
17
+ }
18
+ const tgz = spawnSync("sh", ["-c", `ls "${dir}"/*.tgz`], { encoding: "utf8" }).stdout.trim();
19
+ const untar = spawnSync("tar", ["-xzf", tgz, "-C", dir], { stdio: "ignore" });
20
+ if (untar.status !== 0) {
21
+ rmSync(dir, { recursive: true, force: true });
22
+ return undefined;
23
+ }
24
+ return join(dir, "package"); // npm tarballs root everything under package/
25
+ }
26
+ function parseTo(args) {
27
+ const i = args.indexOf("--to");
28
+ if (i >= 0)
29
+ return args[i + 1];
30
+ return undefined;
31
+ }
32
+ /** `garaje upgrade [--to <version>]` — bump the base + CLI pins and classify. */
33
+ export function runUpgrade(root, args) {
34
+ const settingsPath = join(root, ".pi/settings.json");
35
+ const dockerfilePath = join(root, "pi/Dockerfile");
36
+ const versionPath = join(root, ".garaje-version");
37
+ if (!existsSync(settingsPath)) {
38
+ process.stderr.write("garaje upgrade: no .pi/settings.json — run inside a garaje\n");
39
+ return 1;
40
+ }
41
+ const to = parseTo(args) ?? resolveLatest();
42
+ if (!to) {
43
+ process.stderr.write("garaje upgrade: could not resolve a target version (pass --to <version>)\n");
44
+ return 1;
45
+ }
46
+ if (!/^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.]+)?$/.test(to)) {
47
+ process.stderr.write(`garaje upgrade: invalid --to target '${to}' — expected a semver version\n`);
48
+ return 1;
49
+ }
50
+ const settingsText = readFileSync(settingsPath, "utf8");
51
+ const from = currentBaseVersion(settingsText);
52
+ // Classify old vs new base against the local override layer, if both trees resolve.
53
+ let findings = [];
54
+ let classified = false;
55
+ const newTree = fetchBaseTree(to);
56
+ const oldTree = from ? fetchBaseTree(from) : undefined;
57
+ if (newTree && oldTree) {
58
+ findings = classify(oldTree, newTree, root);
59
+ classified = true;
60
+ }
61
+ else {
62
+ process.stderr.write(`garaje upgrade: could not fetch @garaje/base@${from ?? "?"}/${to} to classify — ` +
63
+ `skipping the shadow/orphan check (run it after the packages are published)\n`);
64
+ }
65
+ // Always clean up any temp tree we fetched (the tree path ends in /package).
66
+ for (const t of [oldTree, newTree]) {
67
+ if (t)
68
+ rmSync(t.replace(/\/package$/, ""), { recursive: true, force: true });
69
+ }
70
+ // Rewrite the three committed pins.
71
+ writeFileSync(settingsPath, rewriteBasePin(settingsText, to));
72
+ if (existsSync(dockerfilePath)) {
73
+ writeFileSync(dockerfilePath, rewriteDockerfileCliPin(readFileSync(dockerfilePath, "utf8"), to));
74
+ }
75
+ writeFileSync(versionPath, `${to}\n`);
76
+ // Report.
77
+ process.stdout.write(`garaje upgrade: pins bumped ${from ?? "(local)"} -> ${to}\n`);
78
+ for (const f of findings) {
79
+ process.stdout.write(` ${f.kind.toUpperCase()} [${f.resource}] ${f.name}: ${f.detail}\n`);
80
+ }
81
+ const nextHeader = classified
82
+ ? ` # review the ${findings.length} override(s) above, then:\n`
83
+ : ` # classification was skipped (base trees unavailable) — re-run this after the ` +
84
+ `packages are published to check for shadow/orphan overrides, then:\n`;
85
+ process.stdout.write(`\nNext:\n${nextHeader}` +
86
+ ` npm i -g garaje@${to}\n garaje up --build\n git add -A && git commit -m 'upgrade garaje to ${to}'\n`);
87
+ return findings.length > 0 ? 1 : 0;
88
+ }
89
+ /** Latest published `garaje` version, or undefined (pre-publish / offline). */
90
+ function resolveLatest() {
91
+ const r = spawnSync("npm", ["view", "garaje", "version"], { encoding: "utf8" });
92
+ const v = (r.stdout || "").trim();
93
+ return r.status === 0 && v ? v : undefined;
94
+ }
@@ -0,0 +1,13 @@
1
+ /** Replace the pinned @garaje/base npm version; leave a local-path source alone. */
2
+ export function rewriteBasePin(settingsText, newVersion) {
3
+ return settingsText.replace(/(npm:@garaje\/base@)[0-9A-Za-z.\-]+/g, `$1${newVersion}`);
4
+ }
5
+ /** Replace the `garaje@<v>` CLI install literal in a scaffolded pi/Dockerfile. */
6
+ export function rewriteDockerfileCliPin(dockerfileText, newVersion) {
7
+ return dockerfileText.replace(/(garaje@)[0-9A-Za-z.\-]+/g, `$1${newVersion}`);
8
+ }
9
+ /** The version currently pinned for @garaje/base, or undefined for a local path. */
10
+ export function currentBaseVersion(settingsText) {
11
+ const m = settingsText.match(/npm:@garaje\/base@([0-9A-Za-z.\-]+)/);
12
+ return m ? m[1] : undefined;
13
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "garaje",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/teammates-work/garaje.git",
8
+ "directory": "packages/cli"
9
+ },
10
+ "type": "module",
11
+ "bin": {
12
+ "garaje": "dist/cli.js"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "templates"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "test": "vitest run",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "dependencies": {
24
+ "yaml": "^2.4.0"
25
+ }
26
+ }
@@ -0,0 +1,17 @@
1
+ # `.agent/` — local override layer
2
+
3
+ This directory is the LOCAL override layer over the `@garaje/base` framework
4
+ substrate. The substrate itself — the extension bundle, base rules, canonical
5
+ role prompts, and generated presets/agents — ships in the `@garaje/base` pi
6
+ package. Consumption is declarative: `.pi/settings.json` pins `@garaje/base` as
7
+ a `packages` source, and pi merges its content with what's here at load time.
8
+
9
+ `.agent/rules/*.md` are local overrides layered over `@garaje/base`'s `rules/`:
10
+ a file here whose name matches a base rule file shadows it entirely (local
11
+ wins), and a file here with a new name is layered in additively. `commands/`
12
+ and `skills/` are for local, repo-specific slash commands and pi skills that
13
+ supplement the base bundle; both start empty.
14
+
15
+ `AGENTS.md` at the repo root is always loaded first; base rules and local rules
16
+ in `.agent/rules/` layer on top, merged in filename order with local overrides
17
+ winning on name collisions.
File without changes
File without changes
File without changes
@@ -0,0 +1,3 @@
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).
3
+ ANTHROPIC_API_KEY=
@@ -0,0 +1 @@
1
+ __GARAJE_VERSION__
@@ -0,0 +1,17 @@
1
+ # `.pi/` — project settings + optional local overrides
2
+
3
+ `settings.json` is this garaje's pi configuration, including the `packages`
4
+ pin that consumes `@garaje/base` — the extension bundle, rules, prompts/roles,
5
+ and generated presets/agents all live in that package. Hand-edit
6
+ `settings.json` directly; it's committed as-is.
7
+
8
+ This directory can optionally hold project-local overrides that merge over the
9
+ base package's at load time:
10
+
11
+ - `presets.json` — project-only presets, merged with the base package's
12
+ (consumed by the `preset` extension, `/preset <name>`).
13
+ - `agents/<role>.md` — project-only subagent definitions, merged with the base
14
+ package's `agents/` (consumed by the `subagent` extension).
15
+
16
+ Neither is generated — they're empty unless this garaje needs a preset or role
17
+ that doesn't belong in the shared `@garaje/base` package.
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://pi.dev/schemas/settings.json",
3
+ "defaultProvider": "anthropic",
4
+ "defaultModel": "claude-opus-4-8",
5
+ "defaultThinkingLevel": "medium",
6
+ "theme": "dark",
7
+ "packages": [
8
+ {
9
+ "source": "__BASE_PIN__",
10
+ "extensions": ["extensions/**", "!extensions/session-name.ts"]
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,38 @@
1
+ # Garaje workspace
2
+
3
+ You are running inside the `pi` container of a docker-compose project — a
4
+ **garaje**. Your working directory `/workspace` is the repo root, bind-mounted
5
+ read-write from the host. Editing a file here changes it on the host and inside
6
+ the running containers simultaneously.
7
+
8
+ A garaje parks one or more **codebases** into `bays/` and runs them for
9
+ agentic dev. Each parked codebase declares how it runs in its own `recipe.yaml`.
10
+
11
+ ## Agent contract
12
+
13
+ This file is the canonical entry point. Layered guidance lives in
14
+ [`.agent/rules/`](.agent/rules/) and in the pinned `@garaje/base` package's
15
+ rules; they merge alphabetically with local rules winning on name collisions.
16
+ See [`.agent/README.md`](.agent/README.md) for the directory contract.
17
+
18
+ ## Layout
19
+
20
+ - `bays/<name>/` — parked codebases, materialized by `garaje sync` from
21
+ `garaje.yaml`. Gitignored; each is its own git checkout. This is where the
22
+ application code you work on lives.
23
+ - `compose.framework.yaml` — the pi service itself. **You cannot edit this**
24
+ (protected). `docker-compose.yml` is likewise protected.
25
+ - `compose.bays.yaml` — generated from each bay's `recipe.yaml` by
26
+ `garaje park`; generated + committed. Don't hand-edit it — change the recipe.
27
+ - `.agent/` — the local override layer (rules, commands, skills) over the
28
+ `@garaje/base` package.
29
+ - `scratch/` — host-shared dropbox for screenshots, logs, paste-ins (gitignored).
30
+ - `sessions/` — pi session transcripts, host-visible (gitignored).
31
+
32
+ ## Working on a parked codebase
33
+
34
+ Each bay carries a `recipe.yaml` describing how it runs (toolchain, app
35
+ processes, backing services, env, secrets). After editing a bay's source, the
36
+ services live-reload per that codebase's own dev setup; prove a change by
37
+ hitting the affected process (e.g. `curl` the app's health endpoint) from
38
+ inside this container over the compose network.
@@ -0,0 +1,60 @@
1
+ # Framework-owned. Defines the pi agent container itself + its docker proxy.
2
+ #
3
+ # WRITE-PROTECTED: the agent cannot edit this file (enforced by
4
+ # packages/base/extensions/protected-paths.ts). Parked-codebase services are generated
5
+ # into compose.bays.yaml by `garaje park` and layered in by the bin tooling.
6
+ #
7
+ # This file names no codebase on purpose — the pi service is codebase-agnostic.
8
+
9
+ services:
10
+ pi:
11
+ build: ./pi
12
+ working_dir: /workspace
13
+ volumes:
14
+ - ./:/workspace
15
+ - pi-config:/root/.pi/agent
16
+ - garaje-auth:/root/.garaje-auth
17
+ environment:
18
+ - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
19
+ # The agent's ONLY docker endpoint: the whitelisting proxy below.
20
+ # The raw socket is never mounted here (north-star §5.1 layer 1).
21
+ - DOCKER_HOST=tcp://docker-proxy:2375
22
+ # Marker for `garaje` in-container behavior: host-only verbs refuse,
23
+ # bay verbs run in the bays-only driver scope.
24
+ - GARAJE_IN_CONTAINER=1
25
+ # Host path of this garaje root, baked at `garaje up` time. Generated
26
+ # bind mounts use ${GARAJE_HOST_ROOT:-.} because bind sources resolve
27
+ # on the daemon (host) side even when compose runs in-container.
28
+ - GARAJE_HOST_ROOT=${PWD:-}
29
+ depends_on:
30
+ - docker-proxy
31
+
32
+ # Whitelisting socket proxy (tecnativa/docker-socket-proxy): default-deny;
33
+ # the env flags below open exactly what parking a bay needs — build, image,
34
+ # container lifecycle, exec, networks, volumes. Swarm/secrets/system stay
35
+ # denied. NOTE: the proxy filters by ENDPOINT, not by target container —
36
+ # per-container protection of the pi service is layers 2+3 (compose split,
37
+ # command-intent guard). No published ports: only compose-network peers
38
+ # (the pi service) can reach it.
39
+ docker-proxy:
40
+ image: tecnativa/docker-socket-proxy:0.3.0
41
+ volumes:
42
+ - /var/run/docker.sock:/var/run/docker.sock:ro
43
+ environment:
44
+ BUILD: 1
45
+ CONTAINERS: 1
46
+ EXEC: 1
47
+ GRPC: 1
48
+ IMAGES: 1
49
+ INFO: 1
50
+ NETWORKS: 1
51
+ POST: 1
52
+ SESSION: 1
53
+ VOLUMES: 1
54
+ ALLOW_START: 1
55
+ ALLOW_STOP: 1
56
+ ALLOW_RESTARTS: 1
57
+
58
+ volumes:
59
+ pi-config:
60
+ garaje-auth:
@@ -0,0 +1,13 @@
1
+ # Framework-owned, WRITE-PROTECTED (see packages/base/extensions/protected-paths.ts).
2
+ #
3
+ # Thin composition root. It wires in the framework layer (the pi service).
4
+ # Parked-codebase services live in compose.bays.yaml, which is GENERATED from
5
+ # each bay's recipe.yaml by `garaje park` and layered in by the bin tooling
6
+ # (see bin/_lib.sh). Both files sit at the repo root so build contexts and
7
+ # bind mounts resolve unambiguously.
8
+ #
9
+ # The compose project name is NOT hardcoded here — it comes from the committed
10
+ # `name:` in garaje.yaml, passed as `docker compose -p <name>` by bin/_lib.sh,
11
+ # so each garaje owns its own identity (and stable volume prefix).
12
+ include:
13
+ - compose.framework.yaml
@@ -0,0 +1,14 @@
1
+ # garaje.yaml — the parking manifest. COMMITTED.
2
+ #
3
+ # Lists which codebases this garaje parks. `garaje sync` clones each entry
4
+ # into bays/<name> (gitignored), the bind-mounted folder the agent works in.
5
+ # The garaje commits this manifest, not the code (no submodules).
6
+ #
7
+ # A dev who already has a checkout can override per-bay to bind-mount their
8
+ # local path instead — see garaje.local.yaml (gitignored). [override: TODO]
9
+
10
+ # This garaje's name (set at `garaje init`). Drives the docker compose project
11
+ # name, and thus the volume prefix where auth.json lives (<name>_pi-config).
12
+ name: __GARAJE_NAME__
13
+
14
+ bays: []
@@ -0,0 +1,26 @@
1
+ # Local secrets
2
+ .env
3
+
4
+ # Node (only appears if a service is run outside its container)
5
+ node_modules/
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.pyc
10
+
11
+ # JetBrains IDE
12
+ .idea/
13
+
14
+ # Agent runtime artifacts — host-visible but not committed.
15
+ /sessions/*
16
+ !/sessions/.gitkeep
17
+ scratch/*
18
+ !scratch/.gitkeep
19
+
20
+ # Parked codebases — materialized by `garaje sync` from garaje.yaml.
21
+ # The garaje commits the manifest (garaje.yaml), not the code.
22
+ bays/*
23
+ !bays/.gitkeep
24
+
25
+ # Dev-local garaje overrides (per-bay bind-mount paths, personal settings).
26
+ garaje.local.yaml