Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
8 changes: 8 additions & 0 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.


Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/config/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })] }),
Expand Down
1 change: 1 addition & 0 deletions src/config/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions src/config/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
3 changes: 3 additions & 0 deletions src/config/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ export function createSessionSpecFromConfig(
...(hasEnv ? { env: mergedEnv } : {}),
...(hasSecrets ? { secrets } : {}),
...(qemu ? { qemu } : {}),
...(config.bootCommands && config.bootCommands.length > 0
? { bootCommands: config.bootCommands }
: {}),
};
}

Expand Down
12 changes: 12 additions & 0 deletions src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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 } } }],
Expand Down
12 changes: 12 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },

Expand Down
30 changes: 30 additions & 0 deletions src/core/session.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
66 changes: 66 additions & 0 deletions src/core/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export type SessionSpec = {
env?: Record<string, string>;
secrets?: Record<string, SecretSpec>;
qemu?: QemuSpec;
/**
* Shell command lines run once, as root, after boot and before the shell.
* See TuorConfig.bootCommands.
*/
bootCommands?: string[];
};

// --- Public API ---
Expand Down Expand Up @@ -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 {
Expand All @@ -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<void> {
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<string, SecretSpec>,
Expand Down