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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 teammates.work
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/cli.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { pathToFileURL } from "node:url";
4
+ import { runPark } from "./park/index.js";
5
+ import { runInit } from "./host/init.js";
6
+ import { runUp } from "./host/up.js";
7
+ import { runDown } from "./host/down.js";
8
+ import { runLogs } from "./host/logs.js";
9
+ import { runPi } from "./host/pi.js";
10
+ import { runAttach } from "./host/attach.js";
11
+ import { runDoctor } from "./host/doctor.js";
12
+ import { runSyncList } from "./host/sync.js";
13
+ import { runAuth } from "./host/auth.js";
14
+ import { runSessionsIndex } from "./sessions/run.js";
15
+ import { inContainer } from "./driver/index.js";
16
+ import { runBay } from "./host/bay.js";
17
+ import { runUpgrade } from "./upgrade/index.js";
18
+ /** Verbs the in-container agent may not run: it cannot rebirth or reset the
19
+ * container it lives in (north-star §5.4 — self-lifecycle is host-only). */
20
+ const HOST_ONLY = new Set(["up", "down", "attach", "upgrade"]);
21
+ export function hostOnlyRefusal(cmd, env) {
22
+ if (HOST_ONLY.has(cmd) && inContainer(env)) {
23
+ return (`garaje ${cmd}: host-only (self-lifecycle) — the agent cannot manage its own ` +
24
+ `container. Stage + commit the change, then ask the developer to run it on the host.`);
25
+ }
26
+ return undefined;
27
+ }
28
+ export function main(argv) {
29
+ const [cmd, ...rest] = argv;
30
+ const root = process.cwd();
31
+ const refusal = hostOnlyRefusal(cmd ?? "", process.env);
32
+ if (refusal) {
33
+ process.stderr.write(refusal + "\n");
34
+ return 1;
35
+ }
36
+ switch (cmd) {
37
+ case "up":
38
+ return runUp(root, rest);
39
+ case "down":
40
+ return runDown(root, rest);
41
+ case "logs":
42
+ return runLogs(root, rest);
43
+ case "pi":
44
+ return runPi(root, rest);
45
+ case "attach":
46
+ return runAttach(root, rest);
47
+ case "doctor":
48
+ return runDoctor(root);
49
+ case "sync":
50
+ return runSyncList(root, "sync", rest);
51
+ case "list":
52
+ return runSyncList(root, "list", rest);
53
+ case "park":
54
+ return runPark(root, rest).code;
55
+ case "sessions-index":
56
+ return runSessionsIndex(root).code;
57
+ case "auth":
58
+ return runAuth(root, rest);
59
+ case "bay":
60
+ return runBay(root, rest);
61
+ case "init":
62
+ return runInit(root, rest);
63
+ case "upgrade":
64
+ return runUpgrade(root, rest);
65
+ default:
66
+ process.stderr.write(`garaje: unknown subcommand: ${cmd ?? ""} ` +
67
+ `(try: init, up, down, logs, pi, attach, doctor, sync, list, park, bay, sessions-index, auth, upgrade)\n`);
68
+ return 1;
69
+ }
70
+ }
71
+ if (process.argv[1] &&
72
+ import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
73
+ process.exit(main(process.argv.slice(2)));
74
+ }
@@ -0,0 +1,127 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ /** The compose files a scope layers, in order. Bays scope excludes the
5
+ * framework layer entirely — that exclusion IS the structural containment. */
6
+ export function composeFiles(scope, baysActive) {
7
+ if (scope === "bays")
8
+ return ["compose.bays.yaml"];
9
+ return baysActive
10
+ ? ["docker-compose.yml", "compose.bays.yaml"]
11
+ : ["docker-compose.yml"];
12
+ }
13
+ /** The argv to pass after `docker compose`. Mirrors the retired
14
+ * host/compose.ts (and before it bin/_lib.sh `compose`). */
15
+ export function composeArgs(ctx, extra) {
16
+ const files = composeFiles(ctx.scope, ctx.baysActive).flatMap((f) => ["-f", f]);
17
+ const project = ctx.project ? ["-p", ctx.project] : [];
18
+ return [...project, ...files, ...extra];
19
+ }
20
+ /** The committed compose project name — the first top-level `name:` line. */
21
+ export function readProjectName(root) {
22
+ const path = join(root, "garaje.yaml");
23
+ if (!existsSync(path))
24
+ return undefined;
25
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
26
+ const m = /^name:\s*(.+?)\s*$/.exec(line);
27
+ if (m)
28
+ return m[1].replace(/^["']|["']$/g, "");
29
+ }
30
+ return undefined;
31
+ }
32
+ /** Service names from `ps --status running --services` output. */
33
+ export function parseServicesOutput(stdout) {
34
+ return stdout
35
+ .split(/\r?\n/)
36
+ .map((l) => l.trim())
37
+ .filter(Boolean);
38
+ }
39
+ export class ComposeDriver {
40
+ ctx;
41
+ constructor(ctx) {
42
+ this.ctx = ctx;
43
+ }
44
+ args(extra) {
45
+ return composeArgs(this.ctx, extra);
46
+ }
47
+ /** PWD is pinned to the root so `${PWD:-}` interpolation in the framework
48
+ * compose sees the garaje root even when the CLI is invoked from elsewhere. */
49
+ env() {
50
+ const env = { ...process.env, PWD: this.ctx.root };
51
+ if (this.ctx.scope === "bays") {
52
+ // The framework services (pi, docker-proxy) are intentional "orphans" of
53
+ // the bays-only file set — compose's suggestion to remove them must
54
+ // never reach the agent (that removal would take down its own container).
55
+ env.COMPOSE_IGNORE_ORPHANS = "1";
56
+ }
57
+ return env;
58
+ }
59
+ run(extra) {
60
+ return (spawnSync("docker", ["compose", ...this.args(extra)], {
61
+ cwd: this.ctx.root,
62
+ stdio: "inherit",
63
+ env: this.env(),
64
+ }).status ?? 1);
65
+ }
66
+ capture(extra, input) {
67
+ const r = spawnSync("docker", ["compose", ...this.args(extra)], {
68
+ cwd: this.ctx.root,
69
+ encoding: "utf8",
70
+ input,
71
+ env: this.env(),
72
+ });
73
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
74
+ }
75
+ up(opts = {}) {
76
+ return this.run([
77
+ "up",
78
+ "-d",
79
+ ...(opts.build ? ["--build"] : []),
80
+ ...(opts.extra ?? []),
81
+ ...(opts.services ?? []),
82
+ ]);
83
+ }
84
+ down(opts = {}) {
85
+ return this.run(["down", ...(opts.extra ?? []), ...(opts.services ?? [])]);
86
+ }
87
+ logs(opts = {}) {
88
+ return this.run([
89
+ "logs",
90
+ ...(opts.follow ? ["-f"] : []),
91
+ ...(opts.tail !== undefined ? [`--tail=${opts.tail}`] : []),
92
+ ...(opts.extra ?? []),
93
+ ...(opts.services ?? []),
94
+ ]);
95
+ }
96
+ psText() {
97
+ return this.capture(["ps"]).stdout;
98
+ }
99
+ runningServices() {
100
+ return parseServicesOutput(this.capture(["ps", "--status", "running", "--services"]).stdout);
101
+ }
102
+ configOk() {
103
+ return this.capture(["config", "-q"]).status === 0;
104
+ }
105
+ exec(service, argv) {
106
+ return this.run(["exec", "-it", service, ...argv]);
107
+ }
108
+ execCapture(service, argv, opts = {}) {
109
+ return this.capture(["exec", "-T", service, ...argv], opts.input);
110
+ }
111
+ port(service, containerPort) {
112
+ const r = this.capture(["port", service, String(containerPort)]);
113
+ const line = (r.stdout.trim().split(/\r?\n/)[0] ?? "").trim();
114
+ return r.status === 0 && line ? line : undefined;
115
+ }
116
+ volumeName(volume) {
117
+ const project = this.ctx.project ?? basename(this.ctx.root).toLowerCase();
118
+ return `${project}_${volume}`;
119
+ }
120
+ requiredFiles() {
121
+ return ["docker-compose.yml", "compose.framework.yaml"];
122
+ }
123
+ }
124
+ /** The generated bays layer's presence — driver-internal filename knowledge. */
125
+ export function hasBaysLayer(root) {
126
+ return existsSync(join(root, "compose.bays.yaml"));
127
+ }
@@ -0,0 +1,33 @@
1
+ import { ComposeDriver, hasBaysLayer, readProjectName } from "./compose.js";
2
+ import { discoverBays } from "../park/index.js";
3
+ export { hasBaysLayer } from "./compose.js";
4
+ /** Build a DriverContext for a garaje root. Default scope: the full stack. */
5
+ export function driverContext(root, scope = "garaje") {
6
+ return {
7
+ root,
8
+ project: readProjectName(root),
9
+ scope,
10
+ baysActive: hasBaysLayer(root) && discoverBays(root).length > 0,
11
+ };
12
+ }
13
+ /** Compose is the only driver until Phase 8 adds k8s — no registry yet (YAGNI). */
14
+ export function getDriver(ctx) {
15
+ return new ComposeDriver(ctx);
16
+ }
17
+ /** True inside the pi container (env marker set in the framework compose). */
18
+ export function inContainer(env = process.env) {
19
+ return env.GARAJE_IN_CONTAINER === "1";
20
+ }
21
+ /** Start the stack if `service` isn't running. 0 when running (or started).
22
+ * In-container this refuses to fall back to a full-stack `up` — that would
23
+ * try to recreate the stack the agent itself lives in. */
24
+ export function ensureRunning(driver, service, env = process.env) {
25
+ if (driver.runningServices().includes(service))
26
+ return 0;
27
+ if (inContainer(env)) {
28
+ process.stderr.write(`(${service} not running — cannot start the stack from inside the container; run garaje up on the host)\n`);
29
+ return 1;
30
+ }
31
+ process.stderr.write(`(${service} container not running; starting the stack)\n`);
32
+ return driver.up();
33
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The runtime-driver seam (north-star design §4.1): up/down/exec/logs/ports/
3
+ * volumes is the only dialect layers above codegen may speak. Compose
4
+ * knowledge lives exclusively in driver/compose.ts; the k8s driver (Phase 8)
5
+ * implements this same interface.
6
+ */
7
+ export {};
@@ -0,0 +1,12 @@
1
+ import { requireDocker } from "./docker.js";
2
+ import { driverContext, getDriver } from "../driver/index.js";
3
+ /**
4
+ * `garaje attach` — interactive shell in the pi container (the former
5
+ * `jack-in`), or a one-shot command with `garaje attach <cmd...>`.
6
+ */
7
+ export function runAttach(root, extra) {
8
+ requireDocker();
9
+ const driver = getDriver(driverContext(root));
10
+ const argv = extra.length ? ["/bin/bash", "-c", extra.join(" ")] : ["/bin/bash"];
11
+ return driver.exec("pi", argv);
12
+ }
@@ -0,0 +1,13 @@
1
+ /** Framework-shipped preset catalog. git/gh only for Plan 1. */
2
+ export const AUTH_PRESETS = {
3
+ github: {
4
+ name: "github",
5
+ hint: "GitHub for git-over-https + gh, from your host `gh auth token` " +
6
+ "(override with --token <t> or --resolver env:NAME|cmd:<sh> for a scoped PAT)",
7
+ defaultResolver: "gh",
8
+ install: "gh-login",
9
+ },
10
+ };
11
+ export function getPreset(name) {
12
+ return AUTH_PRESETS[name];
13
+ }
@@ -0,0 +1,46 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * Resolve a secret VALUE host-side from a resolver spec, at `garaje auth add`
4
+ * time (not per launch). Supported forms:
5
+ * - `gh` → `gh auth token` (the host gh CLI's current token)
6
+ * - `env:NAME` → the host environment variable NAME
7
+ * - `cmd:<shell>`→ trimmed stdout of the shell command
8
+ * Throws on an unknown scheme, a missing/empty value, or multi-line output.
9
+ * (Single-line is enforced because `gh auth login --with-token` reads ALL of
10
+ * stdin, so an embedded newline — e.g. a tool that prints a notice before the
11
+ * token — would corrupt the credential.)
12
+ */
13
+ export function resolveSecret(spec) {
14
+ const value = resolveRaw(spec);
15
+ if (/[\r\n]/.test(value)) {
16
+ throw new Error(`resolver ${spec}: output must be a single line (got multiple lines) — ` +
17
+ "make the command emit only the secret (e.g. pipe through `tail -1`)");
18
+ }
19
+ return value;
20
+ }
21
+ function resolveRaw(spec) {
22
+ if (spec === "gh") {
23
+ const r = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
24
+ const out = (r.stdout ?? "").trim();
25
+ if (r.status !== 0 || !out) {
26
+ throw new Error("resolver gh: `gh auth token` failed (is gh installed + logged in?)");
27
+ }
28
+ return out;
29
+ }
30
+ if (spec.startsWith("env:")) {
31
+ const name = spec.slice(4);
32
+ const val = (process.env[name] ?? "").trim();
33
+ if (!val)
34
+ throw new Error(`resolver env:${name}: environment variable ${name} is unset or empty`);
35
+ return val;
36
+ }
37
+ if (spec.startsWith("cmd:")) {
38
+ const r = spawnSync("sh", ["-c", spec.slice(4)], { encoding: "utf8" });
39
+ const out = (r.stdout ?? "").trim();
40
+ if (r.status !== 0 || !out) {
41
+ throw new Error(`resolver cmd: command failed or produced no output: ${spec.slice(4)}`);
42
+ }
43
+ return out;
44
+ }
45
+ throw new Error(`unknown resolver: ${spec} (use gh, env:NAME, or cmd:<shell>)`);
46
+ }
@@ -0,0 +1,140 @@
1
+ import { requireDocker, die } from "./docker.js";
2
+ import { driverContext, ensureRunning, getDriver } from "../driver/index.js";
3
+ import { resolveSecret } from "./auth-resolvers.js";
4
+ import { getPreset } from "./auth-presets.js";
5
+ /**
6
+ * Parse `garaje auth <sub> [name] [--token t] [--resolver spec]`. Pure.
7
+ * Supports `--flag value` and `--flag=value`. Throws on a missing/empty flag
8
+ * value or an unrecognized argument — so a forgotten value can't silently fall
9
+ * back to provisioning the full host `gh` token, and a stray flag can't be piped
10
+ * to gh as the secret.
11
+ */
12
+ export function parseAuthArgs(args) {
13
+ const [sub, ...rest] = args;
14
+ const out = { sub: sub ?? "" };
15
+ for (let i = 0; i < rest.length; i++) {
16
+ const a = rest[i];
17
+ if (a === "--token" || a === "--resolver") {
18
+ const val = rest[++i];
19
+ if (val === undefined || val === "" || val.startsWith("-")) {
20
+ throw new Error(`garaje auth: ${a} needs a value`);
21
+ }
22
+ if (a === "--token")
23
+ out.token = val;
24
+ else
25
+ out.resolver = val;
26
+ }
27
+ else if (a.startsWith("--token=") || a.startsWith("--resolver=")) {
28
+ const eq = a.indexOf("=");
29
+ const val = a.slice(eq + 1);
30
+ if (val === "")
31
+ throw new Error(`garaje auth: ${a.slice(0, eq)} needs a value`);
32
+ if (a.startsWith("--token="))
33
+ out.token = val;
34
+ else
35
+ out.resolver = val;
36
+ }
37
+ else if (!a.startsWith("-") && out.name === undefined) {
38
+ out.name = a;
39
+ }
40
+ else {
41
+ throw new Error(`garaje auth: unexpected argument '${a}'`);
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ /** `garaje auth` — provision integration auth into the garaje-auth store. */
47
+ export function runAuth(root, args) {
48
+ let parsed;
49
+ try {
50
+ parsed = parseAuthArgs(args);
51
+ }
52
+ catch (e) {
53
+ die(`${e.message}`);
54
+ }
55
+ const { sub, name, token, resolver } = parsed;
56
+ if (sub !== "add" && sub !== "list" && sub !== "remove") {
57
+ process.stderr.write("garaje auth: usage: garaje auth {add <tool> [--token t|--resolver spec] | list | remove <tool>}\n");
58
+ return 1;
59
+ }
60
+ requireDocker();
61
+ const driver = getDriver(driverContext(root));
62
+ switch (sub) {
63
+ case "add":
64
+ return authAdd(driver, name, token, resolver);
65
+ case "list":
66
+ return authList(driver);
67
+ case "remove":
68
+ return authRemove(driver, name);
69
+ }
70
+ return 1;
71
+ }
72
+ /** The exec argv (NO secret) + the stdin payload (the token) for
73
+ * `gh auth login --with-token`. Kept pure so a test can lock the security
74
+ * property: the token travels on stdin, never in argv. */
75
+ export function ghLoginExec(secret) {
76
+ return {
77
+ argv: ["gh", "auth", "login", "--hostname", "github.com", "--with-token"],
78
+ input: secret + "\n",
79
+ };
80
+ }
81
+ /** Start the stack; die with a clear message if it fails to come up. */
82
+ function requirePiRunning(driver, verb) {
83
+ if (ensureRunning(driver, "pi") !== 0) {
84
+ die(`garaje auth ${verb}: could not start the pi stack — see the error above`);
85
+ }
86
+ }
87
+ function authAdd(driver, name, token, resolver) {
88
+ if (!name)
89
+ die("garaje auth add: which integration? (e.g. garaje auth add github)");
90
+ const preset = getPreset(name);
91
+ if (!preset)
92
+ die(`garaje auth add: unknown integration '${name}' (known: github)`);
93
+ // The preset drives which provisioning flow runs; refuse one this verb can't
94
+ // install so a future (non-GitHub) preset's secret is never piped to gh.
95
+ if (preset.install !== "gh-login") {
96
+ die(`garaje auth add: '${name}' uses an unsupported auth method (Plan 1 provisions github via gh)`);
97
+ }
98
+ let secret;
99
+ try {
100
+ secret = token ?? resolveSecret(resolver ?? preset.defaultResolver);
101
+ }
102
+ catch (e) {
103
+ die(`garaje auth add ${name}: ${e.message}`);
104
+ }
105
+ requirePiRunning(driver, "add");
106
+ // The token only persists if THIS image carries the auth wiring
107
+ // (GH_CONFIG_DIR → the garaje-auth volume). A stale container from before that
108
+ // landed would "succeed" but write off-volume and lose the token on recreate.
109
+ const cfg = driver.execCapture("pi", ["printenv", "GH_CONFIG_DIR"]);
110
+ if (cfg.status !== 0 || !cfg.stdout.trim()) {
111
+ die("garaje auth add: the pi image is missing the auth wiring (GH_CONFIG_DIR). Run `garaje up --build` first.");
112
+ }
113
+ const { argv, input } = ghLoginExec(secret);
114
+ const r = driver.execCapture("pi", argv, { input });
115
+ if (r.status !== 0) {
116
+ process.stderr.write(r.stderr || r.stdout);
117
+ process.stderr.write("garaje auth add github: gh auth login failed (see gh's output above — token invalid/expired or wrong scopes, or a container error)\n");
118
+ return r.status;
119
+ }
120
+ process.stderr.write("garaje auth: github added — gh + git (https) authenticated for the agent\n");
121
+ return 0;
122
+ }
123
+ function authList(driver) {
124
+ requirePiRunning(driver, "list");
125
+ // `gh auth status` exits non-zero when no account is configured; passing that
126
+ // through lets callers script "is the agent authed?" off the exit code.
127
+ const r = driver.execCapture("pi", ["gh", "auth", "status"]);
128
+ process.stdout.write(r.stdout);
129
+ process.stderr.write(r.stderr);
130
+ return r.status;
131
+ }
132
+ function authRemove(driver, name) {
133
+ if (name !== "github")
134
+ die("garaje auth remove: only 'github' is supported (Plan 1)");
135
+ requirePiRunning(driver, "remove");
136
+ const r = driver.execCapture("pi", ["gh", "auth", "logout", "--hostname", "github.com"]);
137
+ process.stdout.write(r.stdout);
138
+ process.stderr.write(r.stderr);
139
+ return r.status;
140
+ }
@@ -0,0 +1,61 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { requireDocker, die } from "./docker.js";
4
+ import { driverContext, getDriver, hasBaysLayer } from "../driver/index.js";
5
+ import { parseRecipe } from "../park/recipe.js";
6
+ import { bayServiceNames } from "../park/compose.js";
7
+ const USAGE = "garaje bay {up|down|logs|ps} <name> [args...] — bay-scoped lifecycle " +
8
+ "(runs against the parked-services layer only; safe in-container)\n";
9
+ /** Extra args the bay verbs must refuse: in the bays-only scope the framework
10
+ * services (pi, docker-proxy) are "orphans" of the file set, so orphan
11
+ * removal would take down the agent's own container. */
12
+ export function bayExtraRejection(extra) {
13
+ if (extra.includes("--remove-orphans")) {
14
+ return ("garaje bay: --remove-orphans is refused — in the bays-only scope the framework " +
15
+ "services (pi, docker-proxy) count as orphans, so this would remove the agent's own container");
16
+ }
17
+ return undefined;
18
+ }
19
+ /**
20
+ * `garaje bay <verb> <name>` — lifecycle for ONE parked bay, in the bays-only
21
+ * driver scope. The framework layer (the pi service) is not in that scope's
22
+ * file set, so these verbs structurally cannot touch the agent's own
23
+ * container (north-star §5.1 layer 2 / spec §3.2).
24
+ */
25
+ export function runBay(root, args) {
26
+ const [sub, name, ...extra] = args;
27
+ if (!sub || !name || !["up", "down", "logs", "ps"].includes(sub)) {
28
+ process.stderr.write(USAGE);
29
+ return 1;
30
+ }
31
+ const rejection = bayExtraRejection(extra);
32
+ if (rejection)
33
+ die(rejection);
34
+ const recipePath = join(root, "bays", name, "recipe.yaml");
35
+ if (!existsSync(recipePath)) {
36
+ die(`garaje bay: no recipe at bays/${name}/recipe.yaml (sync first?)`);
37
+ }
38
+ if (!hasBaysLayer(root)) {
39
+ die("garaje bay: no generated bays layer — run `garaje park` first");
40
+ }
41
+ const services = bayServiceNames(parseRecipe(readFileSync(recipePath, "utf8"), name));
42
+ requireDocker();
43
+ const driver = getDriver(driverContext(root, "bays"));
44
+ switch (sub) {
45
+ case "up":
46
+ // build:true so recipe edits take effect every prove-loop iteration.
47
+ return driver.up({ build: true, services, extra });
48
+ case "down":
49
+ return driver.down({ services, extra });
50
+ case "logs":
51
+ // Bounded tail, no follow: agents must get their prompt back.
52
+ return driver.logs({ tail: 200, services, extra });
53
+ case "ps": {
54
+ const lines = driver.psText().split(/\r?\n/);
55
+ const out = [lines[0], ...lines.slice(1).filter((l) => l.includes(`${name}-`))];
56
+ process.stdout.write(out.filter(Boolean).join("\n") + "\n");
57
+ return 0;
58
+ }
59
+ }
60
+ return 1;
61
+ }
@@ -0,0 +1,65 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ /**
5
+ * The argv to pass after `docker compose`. Mirrors bin/_lib.sh `compose`:
6
+ * always `-f docker-compose.yml`, layer `-f compose.bays.yaml` when present,
7
+ * and prepend `-p <name>` when the garaje declares one.
8
+ */
9
+ export function composeArgs(ctx, extra) {
10
+ const files = ["-f", "docker-compose.yml"];
11
+ if (ctx.hasBaysCompose)
12
+ files.push("-f", "compose.bays.yaml");
13
+ const project = ctx.name ? ["-p", ctx.name] : [];
14
+ return [...project, ...files, ...extra];
15
+ }
16
+ /** The committed compose project name — the first top-level `name:` line. */
17
+ export function readProjectName(root) {
18
+ const path = join(root, "garaje.yaml");
19
+ if (!existsSync(path))
20
+ return undefined;
21
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
22
+ const m = /^name:\s*(.+?)\s*$/.exec(line);
23
+ if (m)
24
+ return m[1].replace(/^["']|["']$/g, "");
25
+ }
26
+ return undefined;
27
+ }
28
+ export function composeContext(root) {
29
+ return {
30
+ name: readProjectName(root),
31
+ hasBaysCompose: existsSync(join(root, "compose.bays.yaml")),
32
+ };
33
+ }
34
+ /** Run `docker compose` from the repo root, inheriting stdio; returns exit code. */
35
+ export function compose(root, extra) {
36
+ const args = composeArgs(composeContext(root), extra);
37
+ return spawnSync("docker", ["compose", ...args], { cwd: root, stdio: "inherit" }).status ?? 1;
38
+ }
39
+ /** Run `docker compose` and capture stdout/stderr instead of inheriting. */
40
+ export function composeCapture(root, extra) {
41
+ const args = composeArgs(composeContext(root), extra);
42
+ const r = spawnSync("docker", ["compose", ...args], { cwd: root, encoding: "utf8" });
43
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
44
+ }
45
+ /** Like composeCapture, but feeds `input` to the process's stdin. */
46
+ export function composeCaptureInput(root, extra, input) {
47
+ const args = composeArgs(composeContext(root), extra);
48
+ const r = spawnSync("docker", ["compose", ...args], { cwd: root, encoding: "utf8", input });
49
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
50
+ }
51
+ /**
52
+ * Start the stack if the `pi` service isn't already running. Returns 0 when pi
53
+ * is (or becomes) running, else the non-zero `up -d` exit code so callers can
54
+ * bail cleanly instead of exec-ing against a stack that failed to start. The
55
+ * running-check is inlined (rather than importing pi.ts's parseRunningServices)
56
+ * to avoid a compose ↔ pi import cycle — pi.ts already imports from compose.ts.
57
+ */
58
+ export function ensurePiRunning(root) {
59
+ const ps = composeCapture(root, ["ps", "--status", "running", "--services"]);
60
+ const running = ps.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
61
+ if (running.includes("pi"))
62
+ return 0;
63
+ process.stderr.write("(pi container not running; starting the stack)\n");
64
+ return compose(root, ["up", "-d"]);
65
+ }
@@ -0,0 +1,15 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /** Print an error and exit non-zero. Mirrors bin/_lib.sh `die`. */
3
+ export function die(msg) {
4
+ process.stderr.write(`error: ${msg}\n`);
5
+ process.exit(1);
6
+ }
7
+ /** Ensure the docker CLI is on PATH and the daemon is reachable. */
8
+ export function requireDocker() {
9
+ if (spawnSync("docker", ["--version"], { stdio: "ignore" }).status !== 0) {
10
+ die("docker not found in PATH");
11
+ }
12
+ if (spawnSync("docker", ["info"], { stdio: "ignore" }).status !== 0) {
13
+ die("docker daemon not reachable (is Docker Desktop running?)");
14
+ }
15
+ }