From 88fced8040066b65ab00c663d86bccafd24f744a Mon Sep 17 00:00:00 2001 From: codethief Date: Thu, 18 Jun 2026 00:10:15 +0200 Subject: [PATCH] Add show-config command to CLI --- CHANGELOG.md | 1 + docs/CLI.md | 7 ++++ src/cli/run.ts | 4 +- src/cli/show-config.test.ts | 82 +++++++++++++++++++++++++++++++++++++ src/cli/show-config.ts | 67 ++++++++++++++++++++++++++++++ src/config/defaults.test.ts | 70 +++++++++++++++++++++++++++++++ src/config/defaults.ts | 56 +++++++++++++++++++++++++ src/config/load.ts | 35 ++++++++++++---- src/config/resolve.test.ts | 14 +++++-- src/config/resolve.ts | 41 +++++++------------ src/main.ts | 3 +- 11 files changed, 338 insertions(+), 42 deletions(-) create mode 100644 src/cli/show-config.test.ts create mode 100644 src/cli/show-config.ts create mode 100644 src/config/defaults.test.ts create mode 100644 src/config/defaults.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f5af2d3..9fada38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features - Add new `--version` CLI flag +- Add new `show-config` CLI command # 0.2.1 (2026-06-23) diff --git a/docs/CLI.md b/docs/CLI.md index 1cf7a25..58c7bb2 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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. diff --git a/src/cli/run.ts b/src/cli/run.ts index 8cac615..dec3f84 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -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 = { @@ -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" }; diff --git a/src/cli/show-config.test.ts b/src/cli/show-config.test.ts new file mode 100644 index 0000000..8005292 --- /dev/null +++ b/src/cli/show-config.test.ts @@ -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: "", + }, + }); + }); + + 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); + }); +}); diff --git a/src/cli/show-config.ts b/src/cli/show-config.ts new file mode 100644 index 0000000..3607c76 --- /dev/null +++ b/src/cli/show-config.ts @@ -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 = {}; + for (const [key, value] of Object.entries(config.env)) { + if (isSecretWithValue(value)) { + env[key] = { ...value, value: "" }; + redactedAny = true; + } else { + env[key] = value; + } + } + + return redactedAny ? { ...config, env } : config; +} + +function isSecretWithValue( + value: EnvValue, +): value is Extract & { value: string } { + return ( + typeof value === "object" && "secret" in value && value.value !== undefined + ); +} diff --git a/src/config/defaults.test.ts b/src/config/defaults.test.ts new file mode 100644 index 0000000..e09c01a --- /dev/null +++ b/src/config/defaults.test.ts @@ -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 { + 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/ 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(); + }); +}); diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 0000000..ce5fc14 --- /dev/null +++ b/src/config/defaults.ts @@ -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 & { + 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 ?? [], + }; +} diff --git a/src/config/load.ts b/src/config/load.ts index 24bd4ea..a171c50 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -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( @@ -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, @@ -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 }; } diff --git a/src/config/resolve.test.ts b/src/config/resolve.test.ts index 02bb347..acbe59c 100644 --- a/src/config/resolve.test.ts +++ b/src/config/resolve.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from "vitest"; +import { applyConfigDefaults } from "./defaults.ts"; import type { IgnoreFileDeps } from "./ignore-files.ts"; import type { NixDeps } from "./nix.ts"; import { _getOverlayStateDir, _resolveEnv, + createSessionSpecFromConfig, type ResolveDeps, - resolveConfig, } from "./resolve.ts"; import type { TuorConfig } from "./schema.ts"; @@ -33,10 +34,15 @@ function resolve( deps = validDeps, ) { const full: TuorConfig = { user: "root", workdir: "/", ...config }; - return resolveConfig(full, configDir, HOST_HOME, deps); + return createSessionSpecFromConfig( + applyConfigDefaults(full), + configDir, + HOST_HOME, + deps, + ); } -describe("resolveConfig", () => { +describe("createSessionSpecFromConfig", () => { describe("mount resolution", () => { test("resolves relative hostPath against configDir", () => { const spec = resolve({ @@ -552,7 +558,7 @@ describe("_resolveEnv", () => { }); }); -describe("resolveConfig env integration", () => { +describe("createSessionSpecFromConfig env integration", () => { test("omits env when not configured and no nix", () => { const spec = resolve({}); expect(spec.env).toBeUndefined(); diff --git a/src/config/resolve.ts b/src/config/resolve.ts index a752066..1a3cbda 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -6,14 +6,10 @@ import type { VolumeSpec, } from "../core/mounts.ts"; import { validateMounts } from "../core/mounts.ts"; -import type { - NetworkSpec, - QemuSpec, - SecretSpec, - SessionSpec, -} from "../core/session.ts"; +import type { QemuSpec, SecretSpec, SessionSpec } from "../core/session.ts"; import type { ScopedPattern } from "../core/shadow.ts"; -import { expandTilde, inferGuestHomeDir } from "./homedir.ts"; +import type { DefaultedConfig } from "./defaults.ts"; +import { expandTilde } from "./homedir.ts"; import { collectIgnorePatterns, DEFAULT_IGNORE_FILE_REFS, @@ -43,13 +39,20 @@ export type ResolveDeps = { // --- Public API --- -export function resolveConfig( - config: TuorConfig, +/** + * Convert an already-defaulted config into the `SessionSpec` that `run` boots a + * VM from. This is pure *structural conversion* (path expansion, env/secret + * split, nix→mounts, computed overlay dirs) plus filesystem validation — all + * config-level defaults are expected to be filled in already (see + * {@link applyConfigDefaults}). + */ +export function createSessionSpecFromConfig( + config: DefaultedConfig, configDir: string, hostHomeDir: string, deps: ResolveDeps = defaultResolveDeps, ): SessionSpec { - const guestHomeDir = config.guestHomeDir ?? inferGuestHomeDir(config.user); + const guestHomeDir = config.guestHomeDir; // Resolve workdir const { guestWorkdir, workdirMount } = resolveWorkdir( @@ -99,14 +102,12 @@ export function resolveConfig( const hasEnv = Object.keys(mergedEnv).length > 0; const hasSecrets = Object.keys(secrets).length > 0; - const network = resolveNetwork(config.network); - const qemu = resolveQemu(config.qemu); return { user: config.user, workdir: guestWorkdir, - network, + network: config.network, mounts: allMounts, ...(volumes.length > 0 ? { volumes } : {}), ...(config.rootfsSize ? { rootfsSize: config.rootfsSize } : {}), @@ -230,20 +231,6 @@ function resolveWorkdir( }; } -function resolveNetwork(network: TuorConfig["network"]): NetworkSpec { - if (!network) { - return { mode: "restricted", allowedHosts: [], allowedInternalHosts: [] }; - } else if (network.mode === "open") { - return network; - } else { - return { - mode: "restricted", - allowedHosts: network.allowedHosts ?? [], - allowedInternalHosts: network.allowedInternalHosts ?? [], - }; - } -} - /** * Pass the configured QEMU knobs through verbatim. We ship no defaults: any * field left unset falls back to Gondolin's own auto-selection (which detects diff --git a/src/main.ts b/src/main.ts index 2582119..584fb29 100755 --- a/src/main.ts +++ b/src/main.ts @@ -3,9 +3,10 @@ import { buildApplication, buildRouteMap, run } from "@stricli/core"; import packagejson from "../package.json" with { type: "json" }; import { command as init } from "./cli/init.ts"; import { command as run_ } from "./cli/run.ts"; +import { command as showConfig } from "./cli/show-config.ts"; const root = buildRouteMap({ - routes: { init, run: run_ }, + routes: { init, run: run_, showConfig }, docs: { brief: "CLI for sandboxing coding agents and other dev tools" }, });