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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Features
- Add new `--version` CLI flag
- Add new `show-config` CLI command


# 0.2.1 (2026-06-23)
Expand Down
7 changes: 7 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ tuor run

# Spawn VM and run custom command
tuor run -- echo "hi"

# Print the effective config (after inheritance & defaults, in config.json shape)
# that `run` sees before it's turned into a session spec, as JSON. Secret values
# are redacted unless --show-secrets is given.
tuor show-config
tuor show-config --show-secrets # Include real secret values
tuor show-config | jq . # Diagnostics go to stderr, so stdout is clean
```

See [Configuration](./Configuration.md) for how to configure Tuor.
4 changes: 2 additions & 2 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { buildCommand } from "@stricli/core";
import { loadConfig } from "../config/load.ts";
import { loadSessionSpec } from "../config/load.ts";
import { runSession } from "../core/session.ts";

type Flags = {
Expand All @@ -8,7 +8,7 @@ type Flags = {

export const command = buildCommand({
func: async (flags: Flags, ...args: string[]) => {
const { spec } = loadConfig();
const { spec } = loadSessionSpec();

if (flags.dangerouslyOpenNetwork) {
spec.network = { mode: "open" };
Expand Down
82 changes: 82 additions & 0 deletions src/cli/show-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, test } from "vitest";
import type { DefaultedConfig } from "../config/defaults.ts";
import { redactSecrets } from "./show-config.ts";

const baseConfig: DefaultedConfig = {
user: "root",
workdir: "/workspace",
guestHomeDir: "/root",
network: { mode: "restricted", allowedHosts: [], allowedInternalHosts: [] },
};

describe("redactSecrets", () => {
test("replaces a secret's literal value, preserving injectForHosts", () => {
const config: DefaultedConfig = {
...baseConfig,
env: {
EDITOR: "vim",
GH_TOKEN: {
secret: true,
injectForHosts: ["*.github.com"],
value: "ghp_supersecret",
},
},
};

const result = redactSecrets(config);

expect(result.env).toEqual({
EDITOR: "vim",
GH_TOKEN: {
secret: true,
injectForHosts: ["*.github.com"],
value: "<redacted>",
},
});
});

test("leaves a host-sourced secret (no value) untouched", () => {
const config: DefaultedConfig = {
...baseConfig,
env: {
API_KEY: { secret: true, injectForHosts: ["api.example.com"] },
},
};

const result = redactSecrets(config);

expect(result.env).toEqual({
API_KEY: { secret: true, injectForHosts: ["api.example.com"] },
});
});

test("does not mutate the input config", () => {
const config: DefaultedConfig = {
...baseConfig,
env: {
TOKEN: {
secret: true,
injectForHosts: ["api.example.com"],
value: "real-value",
},
},
};

redactSecrets(config);

const token = config.env?.TOKEN;
expect(typeof token === "object" && token.value).toBe("real-value");
});

test("returns the config unchanged when there are no secrets", () => {
const config: DefaultedConfig = {
...baseConfig,
env: { EDITOR: "vim" },
};
expect(redactSecrets(config)).toBe(config);
});

test("returns the config unchanged when there is no env", () => {
expect(redactSecrets(baseConfig)).toBe(baseConfig);
});
});
67 changes: 67 additions & 0 deletions src/cli/show-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { buildCommand, type CommandContext } from "@stricli/core";
import type { DefaultedConfig } from "../config/defaults.ts";
import { loadEffectiveConfig } from "../config/load.ts";
import type { EnvValue } from "../config/schema.ts";

type Flags = {
readonly showSecrets?: boolean;
};

export const command = buildCommand({
func(this: CommandContext, flags: Flags) {
const { config } = loadEffectiveConfig();
const output = flags.showSecrets ? config : redactSecrets(config);
this.process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
},
parameters: {
flags: {
showSecrets: {
kind: "boolean",
brief: "Include real secret values instead of redacting them",
optional: true,
},
},
},
docs: {
brief:
"Print the effective config (after inheritance & defaults) that `run` would use",
customUsage: [
{ input: "", brief: "Print the effective config (secrets redacted)" },
{ input: "--show-secrets", brief: "Include real secret values" },
{ input: "| jq .", brief: "Pipe the JSON to another tool" },
],
},
});

/**
* Return a copy of `config` with every secret env var's literal value replaced
* by a placeholder, so the effective config can be printed without leaking
* tokens. Only entries marked `secret: true` are touched (matching how `run`
* treats secrets); host-sourced secrets carry no value and non-secret vars are
* left as-is. The input is not mutated; if nothing was redacted the original is
* returned unchanged.
*/
export function redactSecrets(config: DefaultedConfig): DefaultedConfig {
if (!config.env) return config;

let redactedAny = false;
const env: Record<string, EnvValue> = {};
for (const [key, value] of Object.entries(config.env)) {
if (isSecretWithValue(value)) {
env[key] = { ...value, value: "<redacted>" };
redactedAny = true;
} else {
env[key] = value;
}
}

return redactedAny ? { ...config, env } : config;
}

function isSecretWithValue(
value: EnvValue,
): value is Extract<EnvValue, { secret: true }> & { value: string } {
return (
typeof value === "object" && "secret" in value && value.value !== undefined
);
}
70 changes: 70 additions & 0 deletions src/config/defaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, test } from "vitest";
import { applyConfigDefaults } from "./defaults.ts";
import type { TuorConfig } from "./schema.ts";

function config(overrides: Partial<TuorConfig> = {}): TuorConfig {
return { user: "root", workdir: "/", ...overrides };
}

describe("applyConfigDefaults", () => {
describe("network", () => {
test("defaults omitted network to restricted with empty allowlists", () => {
const result = applyConfigDefaults(config());
expect(result.network).toEqual({
mode: "restricted",
allowedHosts: [],
allowedInternalHosts: [],
});
});

test("passes open network through unchanged", () => {
const result = applyConfigDefaults(config({ network: { mode: "open" } }));
expect(result.network).toEqual({ mode: "open" });
});

test("fills missing allowlists on a restricted network", () => {
const result = applyConfigDefaults(
config({ network: { mode: "restricted", allowedHosts: ["*.gh.com"] } }),
);
expect(result.network).toEqual({
mode: "restricted",
allowedHosts: ["*.gh.com"],
allowedInternalHosts: [],
});
});
});

describe("guestHomeDir", () => {
test("infers /root for the root user when omitted", () => {
const result = applyConfigDefaults(config({ user: "root" }));
expect(result.guestHomeDir).toBe("/root");
});

test("infers /home/<user> for a non-root user when omitted", () => {
const result = applyConfigDefaults(config({ user: "dev" }));
expect(result.guestHomeDir).toBe("/home/dev");
});

test("preserves an explicit guestHomeDir", () => {
const result = applyConfigDefaults(
config({ guestHomeDir: "/custom/home" }),
);
expect(result.guestHomeDir).toBe("/custom/home");
});
});

test("leaves other fields untouched", () => {
const input = config({ user: "dev", workdir: "/work", rootfsSize: "2G" });
const result = applyConfigDefaults(input);
expect(result.user).toBe("dev");
expect(result.workdir).toBe("/work");
expect(result.rootfsSize).toBe("2G");
});

test("does not mutate the input config", () => {
const input = config();
applyConfigDefaults(input);
expect(input.network).toBeUndefined();
expect(input.guestHomeDir).toBeUndefined();
});
});
56 changes: 56 additions & 0 deletions src/config/defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { NetworkSpec } from "../core/session.ts";
import { inferGuestHomeDir } from "./homedir.ts";
import type { TuorConfig } from "./schema.ts";

// --- Types ---

/**
* A {@link TuorConfig} with all *config-level* defaults materialized. This is
* the "effective config" a user reasons about: same shape as `config.json`
* (after inheritance/merge), with the defaults that would otherwise be filled in
* silently made explicit — but *before* the structural conversion into a
* `SessionSpec` (see {@link createSessionSpecFromConfig}).
*
* `network` uses `NetworkSpec` (allow-lists always present) so the type itself
* guarantees the default was applied; its JSON shape is identical to a
* fully-populated `NetworkConfig`.
*/
export type DefaultedConfig = Omit<TuorConfig, "network" | "guestHomeDir"> & {
network: NetworkSpec;
guestHomeDir: string;
};

// --- Public API ---

/**
* Fill the config-level defaults that don't come from the arktype schema:
* `network` (block-all when omitted) and `guestHomeDir` (inferred from `user`).
*
* Pure and dependency-free. Schema defaults (`user`, `workdir`, mount `mode`,
* `nixLd`) are already applied by `parseConfig`; computed conversions (path
* expansion, env/secret split, nix→mounts, overlay state dirs, …) are *not*
* defaults and stay in {@link createSessionSpecFromConfig}.
*/
export function applyConfigDefaults(config: TuorConfig): DefaultedConfig {
return {
...config,
network: defaultNetwork(config.network),
guestHomeDir: config.guestHomeDir ?? inferGuestHomeDir(config.user),
};
}

// --- Internals ---

function defaultNetwork(network: TuorConfig["network"]): NetworkSpec {
if (!network) {
return { mode: "restricted", allowedHosts: [], allowedInternalHosts: [] };
}
if (network.mode === "open") {
return network;
}
return {
mode: "restricted",
allowedHosts: network.allowedHosts ?? [],
allowedInternalHosts: network.allowedInternalHosts ?? [],
};
}
35 changes: 27 additions & 8 deletions src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,29 @@ import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { SessionSpec } from "../core/session.ts";
import { applyConfigDefaults, type DefaultedConfig } from "./defaults.ts";
import { interpolateVars } from "./interpolate-vars.ts";
import { findAllConfigDirs, mergeConfigs } from "./merge.ts";
import { resolveConfig } from "./resolve.ts";
import { createSessionSpecFromConfig } from "./resolve.ts";
import { parseConfig } from "./schema.ts";

export type LoadedConfig = {
export type LoadedEffectiveConfig = {
config: DefaultedConfig;
closestConfigDir: string;
};

export type LoadedSessionSpec = {
spec: SessionSpec;
closestConfigDir: string;
};

/**
* Discover, parse, merge, and resolve Tuor configuration.
* Exits the process if no config is found.
* Discover, parse, merge, and default Tuor configuration into the effective
* config a user reasons about (same shape as `config.json`, defaults filled).
* This is the artifact `show-config` prints. Exits the process if no config is
* found.
*/
export function loadConfig(): LoadedConfig {
export function loadEffectiveConfig(): LoadedEffectiveConfig {
const configDirs = findAllConfigDirs(process.cwd(), homedir());
if (configDirs.length === 0) {
console.error(
Expand All @@ -26,7 +34,9 @@ export function loadConfig(): LoadedConfig {
}

for (const dir of configDirs) {
console.log(`Loading config: ${join(dir, "config.json")}`);
// Informational, not data: keep it off stdout so commands like
// `show-config` can emit clean, pipeable output.
console.error(`Loading config: ${join(dir, "config.json")}`);
}

// Interpolate $VAR / ${VAR} against the host env per layer (before parsing,
Expand All @@ -41,9 +51,18 @@ export function loadConfig(): LoadedConfig {
),
configDir: dir,
}));
const config = mergeConfigs(layers);
const merged = mergeConfigs(layers);
const closestConfigDir = configDirs[configDirs.length - 1]!;
const spec = resolveConfig(config, closestConfigDir, homedir());

return { config: applyConfigDefaults(merged), closestConfigDir };
}

/**
* Load the effective config and convert it into the `SessionSpec` that `run`
* boots a VM from.
*/
export function loadSessionSpec(): LoadedSessionSpec {
const { config, closestConfigDir } = loadEffectiveConfig();
const spec = createSessionSpecFromConfig(config, closestConfigDir, homedir());
return { spec, closestConfigDir };
}
Loading