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.
- package/LICENSE +21 -0
- package/dist/cli.js +74 -0
- package/dist/driver/compose.js +127 -0
- package/dist/driver/index.js +33 -0
- package/dist/driver/types.js +7 -0
- package/dist/host/attach.js +12 -0
- package/dist/host/auth-presets.js +13 -0
- package/dist/host/auth-resolvers.js +46 -0
- package/dist/host/auth.js +140 -0
- package/dist/host/bay.js +61 -0
- package/dist/host/compose.js +65 -0
- package/dist/host/docker.js +15 -0
- package/dist/host/doctor.js +119 -0
- package/dist/host/down.js +17 -0
- package/dist/host/init.js +131 -0
- package/dist/host/logs.js +12 -0
- package/dist/host/pi.js +15 -0
- package/dist/host/sync.js +7 -0
- package/dist/host/up.js +34 -0
- package/dist/park/compose.js +190 -0
- package/dist/park/dockerfile.js +23 -0
- package/dist/park/index.js +77 -0
- package/dist/park/ports.js +11 -0
- package/dist/park/recipe.js +12 -0
- package/dist/sessions/collect.js +86 -0
- package/dist/sessions/render.js +24 -0
- package/dist/sessions/run.js +14 -0
- package/dist/upgrade/classify.js +137 -0
- package/dist/upgrade/index.js +94 -0
- package/dist/upgrade/pins.js +13 -0
- package/package.json +26 -0
- package/templates/.agent/README.md +17 -0
- package/templates/.agent/commands/.gitkeep +0 -0
- package/templates/.agent/rules/.gitkeep +0 -0
- package/templates/.agent/skills/.gitkeep +0 -0
- package/templates/.env.example +3 -0
- package/templates/.garaje-version +1 -0
- package/templates/.pi/README.md +17 -0
- package/templates/.pi/settings.json +13 -0
- package/templates/AGENTS.md +38 -0
- package/templates/compose.framework.yaml +60 -0
- package/templates/docker-compose.yml +13 -0
- package/templates/garaje.yaml +14 -0
- package/templates/gitignore +26 -0
- package/templates/pi/Dockerfile +56 -0
- package/templates/pi/PI_VERSION +1 -0
- package/templates/pi/entrypoint.sh +49 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { driverContext, getDriver } from "../driver/index.js";
|
|
5
|
+
import { baysArtifactState, discoverBays, manifestBays } from "../park/index.js";
|
|
6
|
+
/** `garaje doctor` — host-side sanity checks. Exit code = failed-check count. */
|
|
7
|
+
export function runDoctor(root) {
|
|
8
|
+
let fails = 0;
|
|
9
|
+
const pass = (m) => process.stdout.write(` \x1b[32mok\x1b[0m ${m}\n`);
|
|
10
|
+
const warn = (m) => process.stdout.write(` \x1b[33mwarn\x1b[0m ${m}\n`);
|
|
11
|
+
const fail = (m) => {
|
|
12
|
+
process.stdout.write(` \x1b[31mfail\x1b[0m ${m}\n`);
|
|
13
|
+
fails += 1;
|
|
14
|
+
};
|
|
15
|
+
const section = (m) => process.stdout.write(`\n== ${m} ==\n`);
|
|
16
|
+
const driver = getDriver(driverContext(root));
|
|
17
|
+
// --- host ---
|
|
18
|
+
section("host");
|
|
19
|
+
const ver = spawnSync("docker", ["--version"], { encoding: "utf8" });
|
|
20
|
+
if (ver.status === 0)
|
|
21
|
+
pass(`docker CLI: ${(ver.stdout ?? "").trim()}`);
|
|
22
|
+
else
|
|
23
|
+
fail("docker CLI not found in PATH");
|
|
24
|
+
const info = spawnSync("docker", ["info"], { stdio: "ignore" });
|
|
25
|
+
if (info.status === 0)
|
|
26
|
+
pass("docker daemon reachable");
|
|
27
|
+
else
|
|
28
|
+
fail("docker daemon not reachable (is Docker Desktop running?)");
|
|
29
|
+
// --- repo shape ---
|
|
30
|
+
section("repo");
|
|
31
|
+
for (const f of [
|
|
32
|
+
...driver.requiredFiles(),
|
|
33
|
+
"garaje.yaml",
|
|
34
|
+
"pi/Dockerfile",
|
|
35
|
+
"pi/entrypoint.sh",
|
|
36
|
+
"packages/base/package.json",
|
|
37
|
+
".pi/settings.json",
|
|
38
|
+
]) {
|
|
39
|
+
if (existsSync(join(root, f)))
|
|
40
|
+
pass(`${f} present`);
|
|
41
|
+
else
|
|
42
|
+
fail(`${f} missing`);
|
|
43
|
+
}
|
|
44
|
+
if (existsSync(join(root, ".env")))
|
|
45
|
+
pass(".env present");
|
|
46
|
+
else
|
|
47
|
+
warn(".env missing — copy .env.example and add ANTHROPIC_API_KEY (or use /login)");
|
|
48
|
+
// --- runtime state ---
|
|
49
|
+
section("runtime");
|
|
50
|
+
if (driver.configOk())
|
|
51
|
+
pass("runtime config parses");
|
|
52
|
+
else
|
|
53
|
+
fail("runtime config failed to parse");
|
|
54
|
+
const running = driver.runningServices();
|
|
55
|
+
const piRunning = running.includes("pi");
|
|
56
|
+
if (piRunning)
|
|
57
|
+
pass("pi service running");
|
|
58
|
+
else
|
|
59
|
+
fail("pi service NOT running (try garaje up)");
|
|
60
|
+
// --- bays (delegate to the retained bash list) ---
|
|
61
|
+
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)");
|
|
68
|
+
const unhealthy = driver.psText()
|
|
69
|
+
.split(/\r?\n/)
|
|
70
|
+
.filter((l) => /unhealthy/i.test(l));
|
|
71
|
+
if (unhealthy.length)
|
|
72
|
+
fail(`unhealthy container(s):\n${unhealthy.join("\n")}`);
|
|
73
|
+
// baysArtifactState re-parses every synced recipe; a corrupt/mid-edit
|
|
74
|
+
// recipe throws, and doctor must report that as a failed check, not crash.
|
|
75
|
+
try {
|
|
76
|
+
const artifact = baysArtifactState(root);
|
|
77
|
+
if (artifact === "fresh")
|
|
78
|
+
pass("bays artifact matches the recipes (regenerate-and-diff)");
|
|
79
|
+
else if (artifact === "no-bays")
|
|
80
|
+
warn("no synced bays — artifact staleness not checkable");
|
|
81
|
+
else if (artifact === "missing")
|
|
82
|
+
fail("bays artifact missing — run `garaje park` and commit the result");
|
|
83
|
+
else
|
|
84
|
+
fail("bays artifact STALE — recipes changed; run `garaje park` and commit the diff");
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
fail(`bays artifact check errored — a recipe failed to parse: ${e.message}`);
|
|
88
|
+
}
|
|
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)`);
|
|
92
|
+
}
|
|
93
|
+
// --- pi container ---
|
|
94
|
+
section("pi");
|
|
95
|
+
if (piRunning) {
|
|
96
|
+
const isLink = (p) => driver.execCapture("pi", ["test", "-L", p]).status === 0;
|
|
97
|
+
// The symlink hack is gone: extensions must NOT be a symlink anymore.
|
|
98
|
+
if (isLink("/root/.pi/agent/extensions"))
|
|
99
|
+
fail("/root/.pi/agent/extensions is a symlink (Phase 3 symlink hack regressed)");
|
|
100
|
+
else
|
|
101
|
+
pass("no extensions symlink (bundle loads via @garaje/base package)");
|
|
102
|
+
if (isLink("/root/.pi/agent/sessions"))
|
|
103
|
+
pass("/root/.pi/agent/sessions symlinked to /workspace/sessions");
|
|
104
|
+
else
|
|
105
|
+
fail("/root/.pi/agent/sessions not symlinked");
|
|
106
|
+
const piver = driver.execCapture("pi", ["pi", "--version"]).stdout.trim();
|
|
107
|
+
if (/[0-9]/.test(piver))
|
|
108
|
+
pass(`pi binary works: ${piver}`);
|
|
109
|
+
else
|
|
110
|
+
fail("pi --version did not print a version");
|
|
111
|
+
}
|
|
112
|
+
// --- summary ---
|
|
113
|
+
section("summary");
|
|
114
|
+
if (fails === 0)
|
|
115
|
+
process.stdout.write(" \x1b[32mall checks passed\x1b[0m\n");
|
|
116
|
+
else
|
|
117
|
+
process.stdout.write(` \x1b[31m${fails} check(s) failed\x1b[0m\n`);
|
|
118
|
+
return fails;
|
|
119
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { requireDocker, die } from "./docker.js";
|
|
2
|
+
import { driverContext, getDriver } from "../driver/index.js";
|
|
3
|
+
/** True if the args would remove volumes — which would wipe both `pi-config`
|
|
4
|
+
* (pi's own /login auth.json) and `garaje-auth` (provisioned git/gh tokens). */
|
|
5
|
+
export function hasVolumeFlag(args) {
|
|
6
|
+
return args.some((a) => a === "-v" || a === "--volumes");
|
|
7
|
+
}
|
|
8
|
+
/** `garaje down` — stop the stack; refuse volume removal. */
|
|
9
|
+
export function runDown(root, extra) {
|
|
10
|
+
requireDocker();
|
|
11
|
+
if (hasVolumeFlag(extra)) {
|
|
12
|
+
die("refusing to remove volumes — that would wipe pi's /login auth (pi-config) " +
|
|
13
|
+
"AND your provisioned git/gh + integration tokens (garaje-auth). " +
|
|
14
|
+
"If you really mean it, run the runtime's own `down -v` directly.");
|
|
15
|
+
}
|
|
16
|
+
return getDriver(driverContext(root)).down({ extra });
|
|
17
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, chmodSync, statSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join, resolve, basename } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* Parse `garaje init <name> [dir] [--base-pin <spec>] [--no-git] [--force]`.
|
|
7
|
+
* Pure. Supports `--base-pin value` and `--base-pin=value`. Throws on a missing
|
|
8
|
+
* name, a `--base-pin` without a value, or an unrecognized argument.
|
|
9
|
+
*/
|
|
10
|
+
export function parseInitArgs(args) {
|
|
11
|
+
const out = { name: "", git: true, force: false };
|
|
12
|
+
let sawName = false;
|
|
13
|
+
for (let i = 0; i < args.length; i++) {
|
|
14
|
+
const a = args[i];
|
|
15
|
+
if (a === "--base-pin") {
|
|
16
|
+
const val = args[++i];
|
|
17
|
+
if (val === undefined || val === "" || val.startsWith("-")) {
|
|
18
|
+
throw new Error("garaje init: --base-pin needs a value");
|
|
19
|
+
}
|
|
20
|
+
out.basePin = val;
|
|
21
|
+
}
|
|
22
|
+
else if (a.startsWith("--base-pin=")) {
|
|
23
|
+
const val = a.slice("--base-pin=".length);
|
|
24
|
+
if (val === "")
|
|
25
|
+
throw new Error("garaje init: --base-pin needs a value");
|
|
26
|
+
out.basePin = val;
|
|
27
|
+
}
|
|
28
|
+
else if (a === "--no-git") {
|
|
29
|
+
out.git = false;
|
|
30
|
+
}
|
|
31
|
+
else if (a === "--force") {
|
|
32
|
+
out.force = true;
|
|
33
|
+
}
|
|
34
|
+
else if (!a.startsWith("-") && !sawName) {
|
|
35
|
+
out.name = a;
|
|
36
|
+
sawName = true;
|
|
37
|
+
}
|
|
38
|
+
else if (!a.startsWith("-") && out.dir === undefined) {
|
|
39
|
+
out.dir = a;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
throw new Error(`garaje init: unexpected argument '${a}'`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!sawName || out.name === "") {
|
|
46
|
+
throw new Error("garaje init: a garaje name is required (garaje init <name>)");
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
51
|
+
const TEMPLATES = join(HERE, "../../templates"); // packages/cli/templates
|
|
52
|
+
const RUNTIME_DIRS = ["bays", "sessions", "scratch"];
|
|
53
|
+
/** This CLI's own version — the lockstep version the scaffold pins. */
|
|
54
|
+
function cliVersion() {
|
|
55
|
+
const pkg = JSON.parse(readFileSync(join(HERE, "../../package.json"), "utf8"));
|
|
56
|
+
return pkg.version;
|
|
57
|
+
}
|
|
58
|
+
/** Recursively list files (not dirs) under dir, as absolute paths. */
|
|
59
|
+
function walkFiles(dir) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
62
|
+
const abs = join(dir, e.name);
|
|
63
|
+
if (e.isDirectory())
|
|
64
|
+
out.push(...walkFiles(abs));
|
|
65
|
+
else
|
|
66
|
+
out.push(abs);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** Apply the scaffold tokens by literal substring replace (never regex). */
|
|
71
|
+
function applyTokens(text, tokens) {
|
|
72
|
+
let out = text;
|
|
73
|
+
for (const [k, v] of Object.entries(tokens))
|
|
74
|
+
out = out.split(k).join(v);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** Map a template's relative path to its emitted path (npm strips a literal
|
|
78
|
+
* `.gitignore` from a published package, so it ships de-dotted). */
|
|
79
|
+
function emitPath(rel) {
|
|
80
|
+
return basename(rel) === "gitignore"
|
|
81
|
+
? join(dirname(rel), ".gitignore")
|
|
82
|
+
: rel;
|
|
83
|
+
}
|
|
84
|
+
export function runInit(root, args) {
|
|
85
|
+
let a;
|
|
86
|
+
try {
|
|
87
|
+
a = parseInitArgs(args);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
process.stderr.write(`${e.message}\n`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
const target = a.dir ? resolve(root, a.dir) : join(root, a.name);
|
|
94
|
+
if (existsSync(join(target, "garaje.yaml"))) {
|
|
95
|
+
process.stderr.write(`garaje init: ${target} already looks like a garaje (garaje.yaml exists)\n`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
if (existsSync(target) && readdirSync(target).length > 0 && !a.force) {
|
|
99
|
+
process.stderr.write(`garaje init: ${target} is not empty (use --force to scaffold into it anyway)\n`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
const version = cliVersion();
|
|
103
|
+
const tokens = {
|
|
104
|
+
__GARAJE_NAME__: a.name,
|
|
105
|
+
__BASE_PIN__: a.basePin ?? `npm:@garaje/base@${version}`,
|
|
106
|
+
__GARAJE_VERSION__: version,
|
|
107
|
+
};
|
|
108
|
+
for (const abs of walkFiles(TEMPLATES)) {
|
|
109
|
+
const rel = abs.slice(TEMPLATES.length + 1);
|
|
110
|
+
const dest = join(target, emitPath(rel));
|
|
111
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
112
|
+
writeFileSync(dest, applyTokens(readFileSync(abs, "utf8"), tokens));
|
|
113
|
+
chmodSync(dest, statSync(abs).mode & 0o777);
|
|
114
|
+
}
|
|
115
|
+
for (const d of RUNTIME_DIRS) {
|
|
116
|
+
mkdirSync(join(target, d), { recursive: true });
|
|
117
|
+
writeFileSync(join(target, d, ".gitkeep"), "");
|
|
118
|
+
}
|
|
119
|
+
if (a.git) {
|
|
120
|
+
const r = spawnSync("git", ["init", "-q"], { cwd: target, stdio: "ignore" });
|
|
121
|
+
if (r.status !== 0) {
|
|
122
|
+
process.stderr.write("garaje init: warning — 'git init' failed (is git installed?); scaffold written anyway\n");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
process.stdout.write(`Scaffolded garaje '${a.name}' at ${target}\n\n` +
|
|
126
|
+
`Next:\n` +
|
|
127
|
+
` cd ${a.dir ?? a.name}\n` +
|
|
128
|
+
` # add a codebase to garaje.yaml, then: garaje park <repo>\n` +
|
|
129
|
+
` git add -A && git commit -m 'init garaje'\n`);
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { requireDocker } from "./docker.js";
|
|
2
|
+
import { driverContext, getDriver } from "../driver/index.js";
|
|
3
|
+
/** Default to follow + recent context unless the caller passed any flag. */
|
|
4
|
+
export function logsOpts(extra) {
|
|
5
|
+
const hasFlag = extra.some((a) => a.startsWith("-"));
|
|
6
|
+
return hasFlag ? { extra } : { follow: true, tail: 100, services: extra };
|
|
7
|
+
}
|
|
8
|
+
/** `garaje logs` — tail logs for one or all services. */
|
|
9
|
+
export function runLogs(root, extra) {
|
|
10
|
+
requireDocker();
|
|
11
|
+
return getDriver(driverContext(root)).logs(logsOpts(extra));
|
|
12
|
+
}
|
package/dist/host/pi.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { requireDocker } from "./docker.js";
|
|
2
|
+
import { driverContext, ensureRunning, getDriver } from "../driver/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* `garaje pi` — launch the pi TUI in the running pi container, auto-starting
|
|
5
|
+
* the stack first if needed. All extra args pass through to `pi` (so `-c`,
|
|
6
|
+
* `-r`, `--session <id>`, `--name "x"`, `-p "..."` all work here).
|
|
7
|
+
*/
|
|
8
|
+
export function runPi(root, extra) {
|
|
9
|
+
requireDocker();
|
|
10
|
+
const driver = getDriver(driverContext(root));
|
|
11
|
+
const upStatus = ensureRunning(driver, "pi");
|
|
12
|
+
if (upStatus !== 0)
|
|
13
|
+
return upStatus;
|
|
14
|
+
return driver.exec("pi", ["pi", ...extra]);
|
|
15
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
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;
|
|
7
|
+
}
|
package/dist/host/up.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { requireDocker } from "./docker.js";
|
|
2
|
+
import { driverContext, getDriver } from "../driver/index.js";
|
|
3
|
+
import { discoverBays, runPark } from "../park/index.js";
|
|
4
|
+
import { runSessionsIndex } from "../sessions/run.js";
|
|
5
|
+
/**
|
|
6
|
+
* `garaje up` — regenerate the parked-bays layer (best-effort), bring the
|
|
7
|
+
* stack up, refresh the sessions index (best-effort), then print the service
|
|
8
|
+
* table. Forwards extra args to the runtime's `up` (e.g. `--build`).
|
|
9
|
+
*/
|
|
10
|
+
export function runUp(root, extra) {
|
|
11
|
+
requireDocker();
|
|
12
|
+
// Regenerate the bays layer from synced recipes so `up` reflects what's
|
|
13
|
+
// parked. Best-effort: a bay without a recipe yet just means no output.
|
|
14
|
+
if (discoverBays(root).length > 0) {
|
|
15
|
+
try {
|
|
16
|
+
runPark(root, []);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* best-effort */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const driver = getDriver(driverContext(root));
|
|
23
|
+
const status = driver.up({ extra });
|
|
24
|
+
if (status !== 0)
|
|
25
|
+
return status;
|
|
26
|
+
try {
|
|
27
|
+
runSessionsIndex(root);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* best-effort */
|
|
31
|
+
}
|
|
32
|
+
process.stdout.write(driver.psText());
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { PortAllocator } from "./ports.js";
|
|
2
|
+
import { devDockerfile } from "./dockerfile.js";
|
|
3
|
+
/** Match the old Python tool's scalar formatting for env values. */
|
|
4
|
+
function envValue(v) {
|
|
5
|
+
if (v === true)
|
|
6
|
+
return "True";
|
|
7
|
+
if (v === false)
|
|
8
|
+
return "False";
|
|
9
|
+
if (v === null)
|
|
10
|
+
return "None";
|
|
11
|
+
return String(v);
|
|
12
|
+
}
|
|
13
|
+
/** A flow-style list of double-quoted strings, e.g. ["CMD", "curl"]. */
|
|
14
|
+
function flowList(items) {
|
|
15
|
+
const esc = (s) => s.replace(/[\x80-]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
16
|
+
return "[" + items.map((i) => esc(JSON.stringify(String(i)))).join(", ") + "]";
|
|
17
|
+
}
|
|
18
|
+
/** The 4-line compose healthcheck block for a given test argv + retries. */
|
|
19
|
+
function healthcheckLines(test, retries) {
|
|
20
|
+
return [
|
|
21
|
+
" healthcheck:",
|
|
22
|
+
` test: ${test}`,
|
|
23
|
+
" interval: 5s",
|
|
24
|
+
" timeout: 3s",
|
|
25
|
+
` retries: ${retries}`,
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
/** The `build:` block for the shared dev image, indented for a service body. */
|
|
29
|
+
function buildLines(name, image) {
|
|
30
|
+
const out = [
|
|
31
|
+
" build:",
|
|
32
|
+
` context: ./bays/${name}`,
|
|
33
|
+
" dockerfile_inline: |",
|
|
34
|
+
];
|
|
35
|
+
for (const line of devDockerfile(image))
|
|
36
|
+
out.push(` ${line}`);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
function emitBay(recipe, ports) {
|
|
40
|
+
const name = recipe.name;
|
|
41
|
+
const image = recipe.image ?? {};
|
|
42
|
+
const workdir = image.workdir ?? "/app";
|
|
43
|
+
const target = recipe.mount?.target ?? workdir;
|
|
44
|
+
const devImage = `garaje-${name}-dev`;
|
|
45
|
+
const net = name;
|
|
46
|
+
const L = [];
|
|
47
|
+
const vols = [];
|
|
48
|
+
const bundleVol = `${name}-bundle`;
|
|
49
|
+
const nodeVol = `${name}-node-modules`;
|
|
50
|
+
vols.push(bundleVol, nodeVol);
|
|
51
|
+
// Shared app config emitted inline per service (no YAML anchors).
|
|
52
|
+
const appCommon = (indent = " ") => {
|
|
53
|
+
const out = [
|
|
54
|
+
`${indent}working_dir: ${target}`,
|
|
55
|
+
`${indent}volumes:`,
|
|
56
|
+
// Bind sources resolve on the DAEMON side; build contexts stream from
|
|
57
|
+
// the CLIENT side. In-container invocations (cwd /workspace) therefore
|
|
58
|
+
// need the HOST path for mounts: GARAJE_HOST_ROOT is baked into the pi
|
|
59
|
+
// service env at `garaje up`. On the host it's unset/empty and "."
|
|
60
|
+
// preserves the old relative behavior.
|
|
61
|
+
`${indent} - \${GARAJE_HOST_ROOT:-.}/bays/${name}:${target}`,
|
|
62
|
+
`${indent} - ${bundleVol}:/usr/local/bundle`,
|
|
63
|
+
`${indent} - ${nodeVol}:${target}/node_modules`,
|
|
64
|
+
];
|
|
65
|
+
const envEntries = [];
|
|
66
|
+
for (const [k, v] of Object.entries(recipe.env ?? {})) {
|
|
67
|
+
envEntries.push(`${indent} ${k}: "${envValue(v)}"`);
|
|
68
|
+
}
|
|
69
|
+
for (const sec of recipe.required_secrets ?? []) {
|
|
70
|
+
// File-delivered secrets arrive via the bind mount — no env guard.
|
|
71
|
+
if (sec.file)
|
|
72
|
+
continue;
|
|
73
|
+
const sn = sec.env ?? sec.name;
|
|
74
|
+
envEntries.push(`${indent} ${sn}: "\${${sn}:?set ${sn} in .env}"`);
|
|
75
|
+
}
|
|
76
|
+
// A dangling `environment:` key is invalid for a bay with no env/secrets.
|
|
77
|
+
if (envEntries.length)
|
|
78
|
+
out.push(`${indent}environment:`, ...envEntries);
|
|
79
|
+
out.push(`${indent}networks: [${net}]`);
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
// --- backing services ---
|
|
83
|
+
for (const [sname, svc] of Object.entries(recipe.services ?? {})) {
|
|
84
|
+
L.push(` ${name}-${sname}:`);
|
|
85
|
+
L.push(` image: ${svc.image}`);
|
|
86
|
+
if (svc.env) {
|
|
87
|
+
L.push(" environment:");
|
|
88
|
+
for (const [k, v] of Object.entries(svc.env)) {
|
|
89
|
+
L.push(` ${k}: "${envValue(v)}"`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (svc.volume) {
|
|
93
|
+
const volname = svc.volume.split(":")[0];
|
|
94
|
+
vols.push(volname);
|
|
95
|
+
L.push(" volumes:");
|
|
96
|
+
L.push(` - ${svc.volume}`);
|
|
97
|
+
}
|
|
98
|
+
const health = svc.health?.cmd;
|
|
99
|
+
if (health) {
|
|
100
|
+
L.push(...healthcheckLines(flowList(["CMD", ...health]), 10));
|
|
101
|
+
}
|
|
102
|
+
L.push(" networks:");
|
|
103
|
+
L.push(` ${net}:`);
|
|
104
|
+
L.push(` aliases: [${sname}]`);
|
|
105
|
+
}
|
|
106
|
+
// --- oneshot init: builds the dev image, runs setup + bootstrap ---
|
|
107
|
+
const setup = image.setup ?? [];
|
|
108
|
+
const bootstrap = recipe.bootstrap ?? [];
|
|
109
|
+
const initCmd = [...setup, ...bootstrap].join(" && ") || "true";
|
|
110
|
+
L.push(` ${name}-init:`);
|
|
111
|
+
L.push(` image: ${devImage}`);
|
|
112
|
+
L.push(...buildLines(name, image));
|
|
113
|
+
L.push(` command: ["bash", "-lc", ${JSON.stringify(initCmd)}]`);
|
|
114
|
+
L.push(' restart: "no"');
|
|
115
|
+
// A dangling `depends_on:` key is invalid for a bay with no backing services.
|
|
116
|
+
const backing = Object.keys(recipe.services ?? {});
|
|
117
|
+
if (backing.length) {
|
|
118
|
+
L.push(" depends_on:");
|
|
119
|
+
for (const sname of backing) {
|
|
120
|
+
L.push(` ${name}-${sname}:`);
|
|
121
|
+
L.push(" condition: service_healthy");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
L.push(...appCommon());
|
|
125
|
+
// --- app processes ---
|
|
126
|
+
for (const [pname, proc] of Object.entries(recipe.processes ?? {})) {
|
|
127
|
+
L.push(` ${name}-${pname}:`);
|
|
128
|
+
L.push(` image: ${devImage}`);
|
|
129
|
+
L.push(" pull_policy: never");
|
|
130
|
+
L.push(` command: ${JSON.stringify(proc.command)}`);
|
|
131
|
+
const port = proc.port;
|
|
132
|
+
if (port) {
|
|
133
|
+
const host = ports.take(Number(port));
|
|
134
|
+
L.push(" ports:");
|
|
135
|
+
L.push(` - "${host}:${port}"`);
|
|
136
|
+
}
|
|
137
|
+
L.push(" depends_on:");
|
|
138
|
+
L.push(` ${name}-init:`);
|
|
139
|
+
L.push(" condition: service_completed_successfully");
|
|
140
|
+
const hpath = proc.health?.http;
|
|
141
|
+
if (hpath && port) {
|
|
142
|
+
const url = `http://localhost:${port}${hpath}`;
|
|
143
|
+
L.push(...healthcheckLines(flowList(["CMD", "curl", "-f", url]), 20));
|
|
144
|
+
}
|
|
145
|
+
L.push(...appCommon());
|
|
146
|
+
}
|
|
147
|
+
return { services: L.join("\n"), networks: [net], volumes: vols };
|
|
148
|
+
}
|
|
149
|
+
/** Render the full compose.bays.yaml text for the given recipes. */
|
|
150
|
+
export function generate(recipes) {
|
|
151
|
+
const ports = new PortAllocator();
|
|
152
|
+
const serviceBlocks = [];
|
|
153
|
+
let nets = [];
|
|
154
|
+
let vols = [];
|
|
155
|
+
for (const r of recipes) {
|
|
156
|
+
const { services, networks, volumes } = emitBay(r, ports);
|
|
157
|
+
serviceBlocks.push(services);
|
|
158
|
+
nets = nets.concat(networks);
|
|
159
|
+
vols = vols.concat(volumes);
|
|
160
|
+
}
|
|
161
|
+
const out = [
|
|
162
|
+
"# compose.bays.yaml — GENERATED by `garaje park`. Do not edit by hand.",
|
|
163
|
+
"# Derived from garaje.yaml + each bay's recipe.yaml. COMMITTED as the",
|
|
164
|
+
"# provisioning artifact; `garaje doctor` flags drift from the recipes.",
|
|
165
|
+
"",
|
|
166
|
+
"services:",
|
|
167
|
+
];
|
|
168
|
+
out.push(serviceBlocks.join("\n"));
|
|
169
|
+
out.push("");
|
|
170
|
+
out.push("networks:");
|
|
171
|
+
for (const n of [...new Set(nets)])
|
|
172
|
+
out.push(` ${n}: {}`);
|
|
173
|
+
out.push("");
|
|
174
|
+
out.push("volumes:");
|
|
175
|
+
for (const v of [...new Set(vols)])
|
|
176
|
+
out.push(` ${v}: {}`);
|
|
177
|
+
out.push("");
|
|
178
|
+
return out.join("\n");
|
|
179
|
+
}
|
|
180
|
+
/** The compose service names a bay's recipe generates, in emit order. The
|
|
181
|
+
* driver-facing bay→services map — derived from the recipe (the IR), so
|
|
182
|
+
* callers never parse generated compose. */
|
|
183
|
+
export function bayServiceNames(recipe) {
|
|
184
|
+
const name = recipe.name;
|
|
185
|
+
return [
|
|
186
|
+
...Object.keys(recipe.services ?? {}).map((s) => `${name}-${s}`),
|
|
187
|
+
`${name}-init`,
|
|
188
|
+
...Object.keys(recipe.processes ?? {}).map((p) => `${name}-${p}`),
|
|
189
|
+
];
|
|
190
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The inline dev-image Dockerfile lines (unindented). Mirrors garaje-park. */
|
|
2
|
+
export function devDockerfile(image) {
|
|
3
|
+
const ruby = image.ruby ?? "3";
|
|
4
|
+
const workdir = image.workdir ?? "/app";
|
|
5
|
+
const pkgs = [...(image.system_packages ?? [])];
|
|
6
|
+
for (const required of ["curl", "ca-certificates"]) {
|
|
7
|
+
if (!pkgs.includes(required))
|
|
8
|
+
pkgs.push(required);
|
|
9
|
+
}
|
|
10
|
+
const df = [`FROM ruby:${ruby}-slim`];
|
|
11
|
+
df.push("RUN apt-get update && apt-get install -y --no-install-recommends " +
|
|
12
|
+
pkgs.join(" ") +
|
|
13
|
+
" && rm -rf /var/lib/apt/lists/*");
|
|
14
|
+
const node = image.node;
|
|
15
|
+
if (node) {
|
|
16
|
+
const major = String(node).split(".")[0];
|
|
17
|
+
df.push(`RUN curl -fsSL https://deb.nodesource.com/setup_${major}.x | bash - ` +
|
|
18
|
+
"&& apt-get install -y --no-install-recommends nodejs " +
|
|
19
|
+
"&& rm -rf /var/lib/apt/lists/*");
|
|
20
|
+
}
|
|
21
|
+
df.push(`WORKDIR ${workdir}`);
|
|
22
|
+
return df;
|
|
23
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { parseRecipe } from "./recipe.js";
|
|
5
|
+
import { generate } from "./compose.js";
|
|
6
|
+
/** Bay folder names under <root>/bays that contain a recipe.yaml, sorted. */
|
|
7
|
+
export function discoverBays(root) {
|
|
8
|
+
const baysDir = join(root, "bays");
|
|
9
|
+
if (!existsSync(baysDir))
|
|
10
|
+
return [];
|
|
11
|
+
return readdirSync(baysDir, { withFileTypes: true })
|
|
12
|
+
.filter((e) => e.isDirectory() && existsSync(join(baysDir, e.name, "recipe.yaml")))
|
|
13
|
+
.map((e) => e.name)
|
|
14
|
+
.sort();
|
|
15
|
+
}
|
|
16
|
+
/** Bay names declared in the committed manifest (garaje.yaml). */
|
|
17
|
+
export function manifestBays(root) {
|
|
18
|
+
const path = join(root, "garaje.yaml");
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
return [];
|
|
21
|
+
try {
|
|
22
|
+
const data = parseYaml(readFileSync(path, "utf8"));
|
|
23
|
+
return (data?.bays ?? [])
|
|
24
|
+
.map((b) => b?.name)
|
|
25
|
+
.filter((n) => Boolean(n));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Regenerate <root>/compose.bays.yaml from ALL synced bays' recipes. Named
|
|
32
|
+
* args validate ("these bays must be synced with a recipe") rather than
|
|
33
|
+
* select: the artifact is a committed whole-set derivation, so parking one
|
|
34
|
+
* bay must never drop siblings from it. */
|
|
35
|
+
export function runPark(root, bays) {
|
|
36
|
+
const dump = bays[0] === "--dump";
|
|
37
|
+
const named = dump ? bays.slice(1) : bays;
|
|
38
|
+
const synced = discoverBays(root);
|
|
39
|
+
for (const bay of named) {
|
|
40
|
+
if (!synced.includes(bay)) {
|
|
41
|
+
process.stderr.write(`garaje park: no recipe at bays/${bay}/recipe.yaml\n`);
|
|
42
|
+
return { code: 1 };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!synced.length) {
|
|
46
|
+
process.stderr.write("garaje park: no bays with a recipe.yaml (sync first?)\n");
|
|
47
|
+
return { code: 1 };
|
|
48
|
+
}
|
|
49
|
+
// The artifact derives from the SYNCED set; warn when the manifest lists
|
|
50
|
+
// more, so a partial sync can't silently narrow the committed artifact.
|
|
51
|
+
const unsynced = manifestBays(root).filter((b) => !synced.includes(b));
|
|
52
|
+
if (unsynced.length) {
|
|
53
|
+
process.stderr.write(`garaje park: WARNING — writing the artifact without unsynced manifest bay(s): ${unsynced.join(", ")} (garaje sync them and re-park)\n`);
|
|
54
|
+
}
|
|
55
|
+
const recipes = synced.map((bay) => parseRecipe(readFileSync(join(root, "bays", bay, "recipe.yaml"), "utf8"), bay));
|
|
56
|
+
const text = generate(recipes);
|
|
57
|
+
if (dump) {
|
|
58
|
+
process.stdout.write(text);
|
|
59
|
+
return { code: 0, wrote: synced.join(", ") };
|
|
60
|
+
}
|
|
61
|
+
writeFileSync(join(root, "compose.bays.yaml"), text);
|
|
62
|
+
process.stderr.write(`garaje park: wrote compose.bays.yaml for ${synced.join(", ")}\n`);
|
|
63
|
+
return { code: 0, wrote: synced.join(", ") };
|
|
64
|
+
}
|
|
65
|
+
/** Compare the COMMITTED bays artifact against a regeneration from the
|
|
66
|
+
* synced recipes. The artifact is committed output (Phase 4 Plan 2); this
|
|
67
|
+
* is the guard that keeps it from drifting silently from its inputs. */
|
|
68
|
+
export function baysArtifactState(root) {
|
|
69
|
+
const names = discoverBays(root);
|
|
70
|
+
if (!names.length)
|
|
71
|
+
return "no-bays";
|
|
72
|
+
const path = join(root, "compose.bays.yaml");
|
|
73
|
+
if (!existsSync(path))
|
|
74
|
+
return "missing";
|
|
75
|
+
const recipes = names.map((bay) => parseRecipe(readFileSync(join(root, "bays", bay, "recipe.yaml"), "utf8"), bay));
|
|
76
|
+
return generate(recipes) === readFileSync(path, "utf8") ? "fresh" : "stale";
|
|
77
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Assigns host ports, bumping past ones already taken (matches garaje-park). */
|
|
2
|
+
export class PortAllocator {
|
|
3
|
+
used = new Set();
|
|
4
|
+
take(containerPort) {
|
|
5
|
+
let host = containerPort;
|
|
6
|
+
while (this.used.has(host))
|
|
7
|
+
host += 1;
|
|
8
|
+
this.used.add(host);
|
|
9
|
+
return host;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
/** Parse recipe YAML into a Recipe, defaulting name to the bay folder name. */
|
|
3
|
+
export function parseRecipe(text, fallbackName) {
|
|
4
|
+
const data = parseYaml(text);
|
|
5
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
6
|
+
throw new Error("recipe did not parse to a mapping");
|
|
7
|
+
}
|
|
8
|
+
const recipe = data;
|
|
9
|
+
if (recipe.name === undefined)
|
|
10
|
+
recipe.name = fallbackName;
|
|
11
|
+
return recipe;
|
|
12
|
+
}
|