diff --git a/CHANGELOG.md b/CHANGELOG.md index 5493aa1..004975b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Unreleased +## Features +- Config: Add `bootCommands`, a list of shell commands run once (as root, in the + configured `workdir`) right after the VM boots and before the interactive + shell / user command. Commands run in order and boot aborts on the first + non-zero exit. Across config layers the lists are concatenated (parent first). + # 0.3.1 (2026-07-08) diff --git a/docs/Configuration.md b/docs/Configuration.md index 4d164e3..90f5626 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -10,6 +10,7 @@ on), which in turn inherit from the global `~/.config/tuor/config.json`. In general, child settings override parent settings, except in the following cases: - Env vars, mounts, volumes get merged (shallow merge). +- Boot commands are concatenated (parent commands run first). - Network: Child network mode overrides parent mode; allowed hosts are merged. @@ -70,6 +71,13 @@ that is not set on the host is an error. "injectForHosts": ["*.github.com"] } }, + // Shell commands run once, as root, right after boot and before the shell / + // user command, in the configured workdir. Run in order; a non-zero exit + // aborts boot (fail fast). Handy for provisioning the guest. + "bootCommands": [ + "apk add --no-cache ripgrep", + "mkdir -p /workspace/.cache" + ], "mounts": [ { // Absolute or relative to config.json diff --git a/src/config/merge.test.ts b/src/config/merge.test.ts index 4ab617c..49fb428 100644 --- a/src/config/merge.test.ts +++ b/src/config/merge.test.ts @@ -189,6 +189,14 @@ describe("mergeConfigs", () => { expect(result.volumes).toHaveLength(2); }); + test("bootCommands are concatenated parent-first", () => { + const result = mergeConfigs([ + layer("/a", { bootCommands: ["echo parent"] }), + layer("/b", { bootCommands: ["echo child"] }), + ]); + expect(result.bootCommands).toEqual(["echo parent", "echo child"]); + }); + test("parent-only mounts preserved when child has none", () => { const result = mergeConfigs([ layer("/a", { mounts: [mount({ hostPath: "/data" })] }), diff --git a/src/config/merge.ts b/src/config/merge.ts index 280c456..1e74037 100644 --- a/src/config/merge.ts +++ b/src/config/merge.ts @@ -103,6 +103,7 @@ function mergeTwoConfigs(parent: TuorConfig, child: TuorConfig): TuorConfig { // Arrays: concatenate ...mergeArrayField(parent.mounts, child.mounts, "mounts"), ...mergeArrayField(parent.volumes, child.volumes, "volumes"), + ...mergeArrayField(parent.bootCommands, child.bootCommands, "bootCommands"), // Objects: shallow merge ...mergeEnv(parent.env, child.env), diff --git a/src/config/resolve.test.ts b/src/config/resolve.test.ts index acbe59c..c783486 100644 --- a/src/config/resolve.test.ts +++ b/src/config/resolve.test.ts @@ -636,6 +636,16 @@ describe("createSessionSpecFromConfig env integration", () => { const spec = resolve({ env: { MY_VAR: "hello" } }, "/cfg"); expect(spec.secrets).toBeUndefined(); }); + + test("passes bootCommands through to the session spec", () => { + const spec = resolve({ bootCommands: ["npm ci", "echo ready"] }); + expect(spec.bootCommands).toEqual(["npm ci", "echo ready"]); + }); + + test("omits bootCommands when none configured", () => { + const spec = resolve({}); + expect(spec.bootCommands).toBeUndefined(); + }); }); describe("_getOverlayStateDir", () => { diff --git a/src/config/resolve.ts b/src/config/resolve.ts index 1a3cbda..7e7fbf3 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -114,6 +114,9 @@ export function createSessionSpecFromConfig( ...(hasEnv ? { env: mergedEnv } : {}), ...(hasSecrets ? { secrets } : {}), ...(qemu ? { qemu } : {}), + ...(config.bootCommands && config.bootCommands.length > 0 + ? { bootCommands: config.bootCommands } + : {}), }; } diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index d5acb02..6bfa82a 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -132,6 +132,16 @@ describe("parseConfig", () => { expect(config.mounts![0]).not.toHaveProperty("ignoreFileRefs"); }); + test("accepts bootCommands as a list of shell strings", () => { + const config = parseConfig({ bootCommands: ["npm ci", "mkdir -p /cache"] }); + expect(config.bootCommands).toEqual(["npm ci", "mkdir -p /cache"]); + }); + + test("omits bootCommands when not specified", () => { + const config = parseConfig({}); + expect(config.bootCommands).toBeUndefined(); + }); + test("accepts env with string values", () => { const config = parseConfig({ env: { MY_VAR: "hello" } }); expect(config.env).toEqual({ MY_VAR: "hello" }); @@ -347,6 +357,8 @@ describe("parseConfig", () => { "volume with unknown field", { volumes: [{ guestPath: "/x", hostPath: "/y" }] }, ], + ["bootCommands with empty-string entry", { bootCommands: [""] }], + ["bootCommands as a bare string", { bootCommands: "npm ci" }], ["non-object input", "not an object"], ["empty ignore array", { mounts: [{ hostPath: "/x", ignore: [] }] }], ["env with non-string value", { env: { X: { value: 123 } } }], diff --git a/src/config/schema.ts b/src/config/schema.ts index e862c1a..bb1f941 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -10,6 +10,18 @@ const types = scope({ TuorConfig: { "+": "reject", + /** + * Shell commands run once, as root, right after the VM boots and before the + * interactive shell / user command starts. Each entry is a command line + * executed via `sh -c` in the configured `workdir`. Useful for provisioning + * the guest (installing packages, seeding directories, …). + * + * Commands run in order; if any exits non-zero, boot is aborted and the VM + * is shut down (fail fast) so the workload never runs in a half-provisioned + * guest. Across config layers the lists are concatenated (parent first). + */ + "bootCommands?": "(string > 0)[]", + /** Environment variables to set in the guest. */ "env?": { "[string]": "EnvValue" }, diff --git a/src/core/session.test.ts b/src/core/session.test.ts new file mode 100644 index 0000000..8e7767d --- /dev/null +++ b/src/core/session.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; +import { _formatCommandOutput } from "./session.ts"; + +describe("_formatCommandOutput", () => { + test("returns empty string when both streams are empty", () => { + expect(_formatCommandOutput("", "")).toBe(""); + }); + + test("gutter-prefixes each stdout line", () => { + expect(_formatCommandOutput("line1\nline2", "")).toBe( + " │ line1\n │ line2", + ); + }); + + test("appends stderr after stdout", () => { + expect(_formatCommandOutput("out", "err")).toBe(" │ out\n │ err"); + }); + + test("trims trailing newline so no blank gutter line is printed", () => { + expect(_formatCommandOutput("out\n", "")).toBe(" │ out"); + }); + + test("preserves leading indentation within a line", () => { + expect(_formatCommandOutput(" indented", "")).toBe(" │ indented"); + }); + + test("skips an empty stream and shows only the other", () => { + expect(_formatCommandOutput("", "only err")).toBe(" │ only err"); + }); +}); diff --git a/src/core/session.ts b/src/core/session.ts index 65b3a9d..7704237 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -43,6 +43,11 @@ export type SessionSpec = { env?: Record; secrets?: Record; qemu?: QemuSpec; + /** + * Shell command lines run once, as root, after boot and before the shell. + * See TuorConfig.bootCommands. + */ + bootCommands?: string[]; }; // --- Public API --- @@ -78,6 +83,10 @@ export async function runSession( sandbox: spec.qemu, }); + if (spec.bootCommands && spec.bootCommands.length > 0) { + await runBootCommands(vm, spec.bootCommands, spec.workdir); + } + if (command) { console.log("Executing user command…"); } else { @@ -99,6 +108,63 @@ export async function runSession( // --- Internals --- +/** + * Run each configured boot command in order, as root (the VM's default exec + * user). Each command's output is captured (buffered) and echoed under an + * explicit header so it's clearly attributable to the command that produced it. + * Fails fast: the first non-zero exit aborts by throwing, so the caller shuts + * the VM down before the workload runs in a half-provisioned guest. + */ +async function runBootCommands( + vm: VM, + bootCommands: string[], + cwd: string, +): Promise { + for (const bootCommand of bootCommands) { + console.log(`Running boot command: ${bootCommand}`); + const result = await vm.exec(["/bin/sh", "-c", bootCommand], { + cwd, + stdout: "buffer", + stderr: "buffer", + }); + // Echo stdout & stderr unconditionally for now. In the future we might add + // verbosity levels (-v/-vv) and gate output behind them. + const output = _formatCommandOutput(result.stdout, result.stderr); + if (output) { + console.log(output); + } + if (result.exitCode !== 0) { + await vm.close(); + throw new Error( + `Boot command failed (exit ${result.exitCode}): ${bootCommand}`, + ); + } + } +} + +/** + * Merge a boot command's captured stdout and stderr into a single block for + * logging, prefixing each line with a gutter so command output is visually + * distinct from Tuor's own log lines. Returns "" when there is nothing to show. + * + * stdout and stderr are concatenated (stdout first). Buffer mode keeps them in + * separate buffers, so their original interleaving is not preserved — for now + * we accept this trade-off. + */ +export function _formatCommandOutput(stdout: string, stderr: string): string { + const combined = [stdout, stderr] + .map((stream) => stream.trimEnd()) + .filter((stream) => stream.length > 0) + .join("\n"); + if (combined.length === 0) { + return ""; + } + return combined + .split("\n") + .map((line) => ` │ ${line}`) + .join("\n"); +} + function buildNetworkOptions( network: NetworkSpec, secrets?: Record,