From f0ef69bcd9f08629953f0fcd3cd0035b814e5d35 Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Fri, 10 Jul 2026 15:22:23 -0600 Subject: [PATCH 1/7] feat(config): add global config fallback at ~/.config/traceroot/config.json --- src/commands/login.ts | 8 ++-- src/config/manager.ts | 47 +++++++++++++++++--- src/config/resolve.ts | 34 +++++++++++--- src/context.ts | 11 ++++- tests/config.manager.test.ts | 84 ++++++++++++++++++++++++++++++++++- tests/config.resolve.test.ts | 57 ++++++++++++++++++++++++ tests/context.test.ts | 41 +++++++++++++++++ tests/output.contract.test.ts | 8 ++-- 8 files changed, 268 insertions(+), 22 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index 68b7402..3fbf5ba 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -68,11 +68,13 @@ export async function runLogin(deps: LoginDeps): Promise { // An explicit override of either field (a higher-precedence flag/env/env-file) // is deliberate intent, not a bare re-invocation. Since `apiKeySource === - // "config"` already implies the key was not explicitly overridden, only the - // host needs a separate override check (e.g. `login --host ...`). + // "config"` (or `"global-config"`, the read-only global fallback) already + // implies the key was not explicitly overridden, only the host needs a + // separate override check (e.g. `login --host ...`). const hostOverridden = deps.hostSource === "flag" || deps.hostSource === "env" || deps.hostSource === "env-file"; - const alreadyLoggedIn = deps.apiKeySource === "config" && !hostOverridden; + const alreadyLoggedIn = + (deps.apiKeySource === "config" || deps.apiKeySource === "global-config") && !hostOverridden; const currentHost = deps.resolvedHost?.trim() || DEFAULT_HOST; let resolvedKey = deps.resolvedApiKey?.trim(); diff --git a/src/config/manager.ts b/src/config/manager.ts index fe6f033..ce40f6f 100644 --- a/src/config/manager.ts +++ b/src/config/manager.ts @@ -7,6 +7,7 @@ import { unlinkSync, writeFileSync, } from "node:fs"; +import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { type Config, ConfigError, type ConfigReadResult } from "./schema.js"; @@ -15,7 +16,8 @@ import { type Config, ConfigError, type ConfigReadResult } from "./schema.js"; * non-empty `TRACEROOT_CONFIG_PATH`, then a project-local * `./.traceroot/config.json` in the current working directory. The config lives * in the project directory (mirroring the `.env` the CLI already auto-discovers - * there), not the user's home folder. + * there), not the user's home folder. See {@link globalConfigPath} for the + * global fallback location `login` never writes to. */ export function configPath(path?: string): string { if (path !== undefined && path !== "") { @@ -33,6 +35,24 @@ export function configDir(path?: string): string { return dirname(configPath(path)); } +/** + * Resolves the global (per-user) config fallback path. Precedence: explicit + * `path` argument, then `$XDG_CONFIG_HOME/traceroot/config.json` when + * `XDG_CONFIG_HOME` is set, else `~/.config/traceroot/config.json`. This is a + * read-only fallback consulted when no project-local config is found; `login` + * never writes here (see {@link configPath}). + */ +export function globalConfigPath(path?: string): string { + if (path !== undefined && path !== "") { + return path; + } + const xdgConfigHome = process.env.XDG_CONFIG_HOME; + if (xdgConfigHome !== undefined && xdgConfigHome !== "") { + return join(xdgConfigHome, "traceroot", "config.json"); + } + return join(homedir(), ".config", "traceroot", "config.json"); +} + function isValidShape(value: unknown): value is Config { if (typeof value !== "object" || value === null) { return false; @@ -42,12 +62,13 @@ function isValidShape(value: unknown): value is Config { } /** - * Reads and validates the config file. Never throws for the four documented - * cases (valid, missing, invalid JSON, invalid shape); any other fs error - * (EACCES, EISDIR, …) is rethrown as a genuine fault. + * Reads and validates the config file at `target`. Never throws for the four + * documented cases (valid, missing, invalid JSON, invalid shape); any other fs + * error (EACCES, EISDIR, …) is rethrown as a genuine fault. Shared by + * {@link readConfig} (project-local) and {@link readGlobalConfig} (the global + * fallback) so both paths parse identically. */ -export function readConfig(path?: string): ConfigReadResult { - const target = configPath(path); +function parseConfigFile(target: string): ConfigReadResult { let raw: string; try { raw = readFileSync(target, "utf8"); @@ -86,6 +107,20 @@ export function readConfig(path?: string): ConfigReadResult { return { ok: true, config: { api_key: parsed.api_key, host_url: parsed.host_url } }; } +/** Reads and validates the project-local config file (see {@link configPath}). */ +export function readConfig(path?: string): ConfigReadResult { + return parseConfigFile(configPath(path)); +} + +/** + * Reads and validates the global (per-user) fallback config file (see + * {@link globalConfigPath}). Consulted only when no project-local config is + * found. + */ +export function readGlobalConfig(path?: string): ConfigReadResult { + return parseConfigFile(globalConfigPath(path)); +} + const SWALLOWED_CHMOD_CODES = new Set(["EPERM", "ENOSYS", "ENOTSUP"]); /** diff --git a/src/config/resolve.ts b/src/config/resolve.ts index aa2a13f..0bc1ef4 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -1,7 +1,14 @@ import type { Config } from "./schema.js"; /** Where a resolved value came from, in precedence order (high → low). */ -export type AuthSource = "flag" | "env-file" | "env" | "config" | "auto-env-file" | "none"; +export type AuthSource = + | "flag" + | "env-file" + | "env" + | "config" + | "global-config" + | "auto-env-file" + | "none"; export interface ResolvedField { value: string | undefined; @@ -23,11 +30,19 @@ export interface ResolveAuthOptions { flags?: AuthFlags; env?: NodeJS.ProcessEnv; readConfig?: () => Config | null; + /** + * Reads the global (per-user) fallback config — consulted only when the + * project-local config (`readConfig`) has no value for a field. Lower + * precedence than the project config, higher than the auto-discovered + * `.env`. + */ + readGlobalConfig?: () => Config | null; loadEnvFile?: (path: string) => Record; /** * Variables auto-discovered from a `.env` in the working directory. This is - * the LOWEST-precedence source (below the config file); explicit sources - * (flags, `--env-file`, process env, config) always win over it. + * the LOWEST-precedence source (below the global config file); explicit + * sources (flags, `--env-file`, process env, project config, global config) + * always win over it. */ autoEnvFile?: Record; } @@ -90,20 +105,23 @@ function firstPresent( /** * Resolves authentication fields from (high → low) flags, an explicit env file, - * the process environment, the config file, and finally a `.env` - * auto-discovered in the working directory. Each field is resolved - * independently. Never throws on missing values; only an env-file load error - * (e.g. {@link EnvFileNotFoundError}) is allowed to propagate. + * the process environment, the project config file, the global fallback config + * file, and finally a `.env` auto-discovered in the working directory. Each + * field is resolved independently. Never throws on missing values; only an + * env-file load error (e.g. {@link EnvFileNotFoundError}) is allowed to + * propagate. */ export function resolveAuth(options: ResolveAuthOptions = {}): ResolvedAuth { const flags = options.flags ?? {}; const env = options.env ?? {}; const readConfig = options.readConfig ?? (() => null); + const readGlobalConfig = options.readGlobalConfig ?? (() => null); const loadEnvFile = options.loadEnvFile ?? (() => ({})); const autoEnv = options.autoEnvFile ?? {}; const fileMap: Record = present(flags.envFile) ? loadEnvFile(flags.envFile) : {}; const config = readConfig() ?? ({} as Partial); + const globalConfig = readGlobalConfig() ?? ({} as Partial); const apiKey = firstPresent( [ @@ -111,6 +129,7 @@ export function resolveAuth(options: ResolveAuthOptions = {}): ResolvedAuth { { value: fileMap.TRACEROOT_API_KEY, source: "env-file" }, { value: env.TRACEROOT_API_KEY, source: "env" }, { value: config.api_key, source: "config" }, + { value: globalConfig.api_key, source: "global-config" }, { value: autoEnv.TRACEROOT_API_KEY, source: "auto-env-file" }, ], normalizeApiKey, @@ -122,6 +141,7 @@ export function resolveAuth(options: ResolveAuthOptions = {}): ResolvedAuth { { value: fileMap.TRACEROOT_HOST_URL, source: "env-file" }, { value: env.TRACEROOT_HOST_URL, source: "env" }, { value: config.host_url, source: "config" }, + { value: globalConfig.host_url, source: "global-config" }, { value: autoEnv.TRACEROOT_HOST_URL, source: "auto-env-file" }, ], normalizeHostUrl, diff --git a/src/context.ts b/src/context.ts index 0396a77..d8a41f6 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { DEFAULT_TIMEOUT_MS } from "./api/client.js"; import { loadEnvFileFromDisk, loadOptionalEnvFileFromDisk } from "./config/envFile.js"; -import { readConfig } from "./config/manager.js"; +import { readConfig, readGlobalConfig } from "./config/manager.js"; import { type ResolvedAuth, resolveAuth } from "./config/resolve.js"; import type { Config } from "./config/schema.js"; import { CliError } from "./output.js"; @@ -19,6 +19,8 @@ export interface GlobalOptions { export interface ContextDeps { env?: NodeJS.ProcessEnv; readConfig?: () => Config | null; + /** Reads the global (per-user) fallback config; defaults to the real global reader. */ + readGlobalConfig?: () => Config | null; loadEnvFile?: (path: string) => Record; /** Loads the auto-discovered working-directory `.env` (empty map if absent). */ loadAutoEnvFile?: () => Record; @@ -68,11 +70,18 @@ export function buildContext(globalOpts: GlobalOptions, deps: ContextDeps = {}): const result = readConfig(); return result.ok ? result.config : null; }); + const readGlobalConfigAdapter = + deps.readGlobalConfig ?? + (() => { + const result = readGlobalConfig(); + return result.ok ? result.config : null; + }); const auth = resolveAuth({ flags: { apiKey: globalOpts.apiKey, host: globalOpts.host, envFile: globalOpts.envFile }, env, readConfig: readConfigAdapter, + readGlobalConfig: readGlobalConfigAdapter, loadEnvFile, autoEnvFile: loadAutoEnvFile(), }); diff --git a/tests/config.manager.test.ts b/tests/config.manager.test.ts index 9ce6ed0..af59e47 100644 --- a/tests/config.manager.test.ts +++ b/tests/config.manager.test.ts @@ -1,8 +1,14 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { configPath, readConfig, writeConfig } from "../src/config/manager.js"; +import { + configPath, + globalConfigPath, + readConfig, + readGlobalConfig, + writeConfig, +} from "../src/config/manager.js"; import { ConfigError } from "../src/config/schema.js"; let dir: string; @@ -67,6 +73,44 @@ describe("readConfig", () => { }); }); +describe("readGlobalConfig", () => { + it("reads a valid config (shares parsing with readConfig)", () => { + const p = join(dir, "global-config.json"); + writeFileSync(p, JSON.stringify({ api_key: "k", host_url: "https://h" })); + const result = readGlobalConfig(p); + expect(result).toEqual({ + ok: true, + config: { api_key: "k", host_url: "https://h" }, + }); + }); + + it("returns missing (and does not throw) when the file is absent", () => { + const p = join(dir, "nope.json"); + expect(() => readGlobalConfig(p)).not.toThrow(); + expect(readGlobalConfig(p)).toEqual({ ok: false, reason: "missing" }); + }); + + it("rejects an invalid shape identically to readConfig", () => { + const p = join(dir, "global-config.json"); + writeFileSync(p, JSON.stringify({ api_key: 123, host_url: "https://h" })); + const result = readGlobalConfig(p); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.reason).toBe("invalid-shape"); + }); + + it("rejects invalid JSON without leaking the raw file bytes", () => { + const p = join(dir, "global-config.json"); + writeFileSync(p, '{ api_key: "tr_secret_LEAK", oops'); + const result = readGlobalConfig(p); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.reason).toBe("invalid-json"); + if (result.reason !== "invalid-json") throw new Error("unreachable"); + expect(result.error.message).not.toContain("tr_secret_LEAK"); + }); +}); + describe("writeConfig", () => { it("writes the expected shape, pretty-printed, creating parent dirs", () => { const p = join(dir, "nested", "deeper", "config.json"); @@ -170,3 +214,39 @@ describe("configPath", () => { } }); }); + +describe("globalConfigPath", () => { + let previousXdg: string | undefined; + + beforeEach(() => { + previousXdg = process.env.XDG_CONFIG_HOME; + }); + + afterEach(() => { + if (previousXdg === undefined) { + Reflect.deleteProperty(process.env, "XDG_CONFIG_HOME"); + } else { + process.env.XDG_CONFIG_HOME = previousXdg; + } + }); + + it("defaults to ~/.config/traceroot/config.json when XDG_CONFIG_HOME is unset", () => { + Reflect.deleteProperty(process.env, "XDG_CONFIG_HOME"); + expect(globalConfigPath()).toBe(join(homedir(), ".config", "traceroot", "config.json")); + }); + + it("honors XDG_CONFIG_HOME when set", () => { + process.env.XDG_CONFIG_HOME = join(dir, "xdg"); + expect(globalConfigPath()).toBe(join(dir, "xdg", "traceroot", "config.json")); + }); + + it("prefers an explicit path argument over XDG_CONFIG_HOME", () => { + process.env.XDG_CONFIG_HOME = join(dir, "xdg"); + expect(globalConfigPath(join(dir, "explicit.json"))).toBe(join(dir, "explicit.json")); + }); + + it("treats an empty XDG_CONFIG_HOME as unset", () => { + process.env.XDG_CONFIG_HOME = ""; + expect(globalConfigPath()).toBe(join(homedir(), ".config", "traceroot", "config.json")); + }); +}); diff --git a/tests/config.resolve.test.ts b/tests/config.resolve.test.ts index 8ce76bf..8ba6b7e 100644 --- a/tests/config.resolve.test.ts +++ b/tests/config.resolve.test.ts @@ -7,6 +7,10 @@ const config = (over: Partial): (() => Config) => { return () => ({ api_key: "cfg-key", host_url: "https://cfg", ...over }); }; +const globalConfig = (over: Partial): (() => Config) => { + return () => ({ api_key: "global-cfg-key", host_url: "https://global-cfg", ...over }); +}; + describe("resolveAuth api_key normalization", () => { it("strips a pasted TRACEROOT_API_KEY= prefix from a flag", () => { const result = resolveAuth({ flags: { apiKey: "TRACEROOT_API_KEY=tr_abc123" } }); @@ -76,6 +80,59 @@ describe("resolveAuth precedence (api_key)", () => { }); }); +describe("resolveAuth precedence (global config fallback)", () => { + it("project config beats global config", () => { + const result = resolveAuth({ + readConfig: config({}), + readGlobalConfig: globalConfig({}), + }); + expect(result.apiKey).toEqual({ value: "cfg-key", source: "config" }); + expect(result.hostUrl).toEqual({ value: "https://cfg", source: "config" }); + }); + + it("global config beats the auto-discovered .env", () => { + const result = resolveAuth({ + readConfig: () => null, + readGlobalConfig: globalConfig({}), + autoEnvFile: { TRACEROOT_API_KEY: "auto-key", TRACEROOT_HOST_URL: "https://auto" }, + }); + expect(result.apiKey).toEqual({ value: "global-cfg-key", source: "global-config" }); + expect(result.hostUrl).toEqual({ value: "https://global-cfg", source: "global-config" }); + }); + + it("falls back to global config when the project config is absent", () => { + const result = resolveAuth({ + readConfig: () => null, + readGlobalConfig: globalConfig({}), + }); + expect(result.apiKey).toEqual({ value: "global-cfg-key", source: "global-config" }); + expect(result.hostUrl).toEqual({ value: "https://global-cfg", source: "global-config" }); + }); + + it("env beats global config", () => { + const result = resolveAuth({ + env: { TRACEROOT_API_KEY: "env-key" }, + readConfig: () => null, + readGlobalConfig: globalConfig({}), + }); + expect(result.apiKey).toEqual({ value: "env-key", source: "env" }); + }); + + it("defaults readGlobalConfig to a no-op returning null", () => { + const result = resolveAuth({ readConfig: () => null }); + expect(result.apiKey).toEqual({ value: undefined, source: "none" }); + }); + + it("resolves each field independently across project and global config", () => { + const result = resolveAuth({ + readConfig: (): Config => ({ api_key: "cfg-key", host_url: "" }), + readGlobalConfig: globalConfig({}), + }); + expect(result.apiKey).toEqual({ value: "cfg-key", source: "config" }); + expect(result.hostUrl).toEqual({ value: "https://global-cfg", source: "global-config" }); + }); +}); + describe("resolveAuth when nothing is set", () => { it("reports none for both fields", () => { const result = resolveAuth(); diff --git a/tests/context.test.ts b/tests/context.test.ts index dd9af0e..ac217fb 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -6,6 +6,7 @@ import { CliError } from "../src/output.js"; const hermetic = { env: {}, readConfig: () => null, + readGlobalConfig: () => null, loadEnvFile: () => ({}), loadAutoEnvFile: () => ({}), }; @@ -33,6 +34,7 @@ describe("buildContext", () => { { env: {}, readConfig: () => null, + readGlobalConfig: () => null, loadEnvFile: () => ({}), loadAutoEnvFile: () => ({ TRACEROOT_API_KEY: "auto-key", @@ -81,10 +83,49 @@ describe("buildContext", () => { { env: {}, readConfig: () => ({ api_key: "cfg-key", host_url: "https://cfg" }), + readGlobalConfig: () => null, loadEnvFile: () => ({}), loadAutoEnvFile: () => ({ TRACEROOT_API_KEY: "auto-key" }), }, ); expect(ctx.auth.apiKey).toEqual({ value: "cfg-key", source: "config" }); }); + + it("wires the injected readGlobalConfig into auth resolution", () => { + const ctx = buildContext( + {}, + { + ...hermetic, + readConfig: () => null, + readGlobalConfig: () => ({ api_key: "global-key", host_url: "https://global" }), + }, + ); + expect(ctx.auth.apiKey).toEqual({ value: "global-key", source: "global-config" }); + expect(ctx.auth.hostUrl).toEqual({ value: "https://global", source: "global-config" }); + }); + + it("lets the project config win over the injected global config", () => { + const ctx = buildContext( + {}, + { + ...hermetic, + readConfig: () => ({ api_key: "cfg-key", host_url: "https://cfg" }), + readGlobalConfig: () => ({ api_key: "global-key", host_url: "https://global" }), + }, + ); + expect(ctx.auth.apiKey).toEqual({ value: "cfg-key", source: "config" }); + }); + + it("lets the injected global config win over the auto-discovered .env", () => { + const ctx = buildContext( + {}, + { + ...hermetic, + readConfig: () => null, + readGlobalConfig: () => ({ api_key: "global-key", host_url: "https://global" }), + loadAutoEnvFile: () => ({ TRACEROOT_API_KEY: "auto-key" }), + }, + ); + expect(ctx.auth.apiKey).toEqual({ value: "global-key", source: "global-config" }); + }); }); diff --git a/tests/output.contract.test.ts b/tests/output.contract.test.ts index 9505f5f..c8e0d61 100644 --- a/tests/output.contract.test.ts +++ b/tests/output.contract.test.ts @@ -6,12 +6,14 @@ import { describe, expect, it } from "vitest"; const binPath = fileURLToPath(new URL("../bin/traceroot.mjs", import.meta.url)); -// Isolate from any real ~/.traceroot/config.json and ambient credentials so -// `status` deterministically resolves no credentials and exercises the failure -// contract (non-zero exit, empty stdout, error on stderr) on every machine. +// Isolate from any real ./.traceroot/config.json, ~/.config/traceroot/config.json +// (the global fallback), and ambient credentials so `status` deterministically +// resolves no credentials and exercises the failure contract (non-zero exit, +// empty stdout, error on stderr) on every machine. const isolatedEnv: NodeJS.ProcessEnv = { ...process.env, TRACEROOT_CONFIG_PATH: join(tmpdir(), "traceroot-cli-no-such-config", "config.json"), + XDG_CONFIG_HOME: join(tmpdir(), "traceroot-cli-no-such-config-home"), TRACEROOT_API_KEY: "", TRACEROOT_HOST_URL: "", }; From e4b2096b0540defb8ee68ca7c4bf369c30ca409e Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Fri, 10 Jul 2026 15:22:26 -0600 Subject: [PATCH 2/7] feat(config): name checked config paths in missing-credential errors --- src/commands/shared.ts | 9 ++++-- tests/commands/shared.test.ts | 61 ++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/commands/shared.ts b/src/commands/shared.ts index 95d6aad..6ba90f4 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { type ApiClient, createApiClient } from "../api/client.js"; +import { configPath, globalConfigPath } from "../config/manager.js"; import { type Context, buildContext } from "../context.js"; import { CliError } from "../output.js"; @@ -23,14 +24,18 @@ export function contextFromCommand(command: Command): Context { export function requireApiClient(ctx: Context): ApiClient { const apiKey = ctx.auth.apiKey.value; const host = ctx.auth.hostUrl.value; + // Named so a user running from the "wrong" directory can see exactly where + // the CLI looked (project config is per-directory; the global config is a + // fallback). Only paths are named here — never key material. + const checkedPaths = `(checked ${configPath()} and ${globalConfigPath()} — config is per-directory, with a global fallback)`; if (apiKey === undefined) { throw new CliError( - "No API key found. Run `traceroot login`, or set TRACEROOT_API_KEY, or pass --api-key.", + `No API key found. Run \`traceroot login\`, set TRACEROOT_API_KEY, or pass --api-key. ${checkedPaths}`, ); } if (host === undefined) { throw new CliError( - "No host found. Run `traceroot login`, or set TRACEROOT_HOST_URL, or pass --host.", + `No host found. Run \`traceroot login\`, set TRACEROOT_HOST_URL, or pass --host. ${checkedPaths}`, ); } return createApiClient({ host, apiKey, timeoutMs: ctx.timeoutMs }); diff --git a/tests/commands/shared.test.ts b/tests/commands/shared.test.ts index f5039b2..4c1917f 100644 --- a/tests/commands/shared.test.ts +++ b/tests/commands/shared.test.ts @@ -1,9 +1,42 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Command } from "commander"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { contextFromCommand, requireApiClient } from "../../src/commands/shared.js"; +import { configPath, globalConfigPath } from "../../src/config/manager.js"; import type { Context } from "../../src/context.js"; import { CliError } from "../../src/output.js"; +// Isolate the project-local and global config paths from the real filesystem +// (real cwd `.traceroot/config.json` and real `~/.config/traceroot/config.json`) +// for any test that exercises the real resolution chain (`contextFromCommand`). +let isolationDir: string; +let prevConfigPath: string | undefined; +let prevXdgConfigHome: string | undefined; + +beforeEach(() => { + isolationDir = mkdtempSync(join(tmpdir(), "tr-shared-")); + prevConfigPath = process.env.TRACEROOT_CONFIG_PATH; + prevXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.TRACEROOT_CONFIG_PATH = join(isolationDir, "project", "config.json"); + process.env.XDG_CONFIG_HOME = join(isolationDir, "xdg"); +}); + +afterEach(() => { + if (prevConfigPath === undefined) { + Reflect.deleteProperty(process.env, "TRACEROOT_CONFIG_PATH"); + } else { + process.env.TRACEROOT_CONFIG_PATH = prevConfigPath; + } + if (prevXdgConfigHome === undefined) { + Reflect.deleteProperty(process.env, "XDG_CONFIG_HOME"); + } else { + process.env.XDG_CONFIG_HOME = prevXdgConfigHome; + } + rmSync(isolationDir, { recursive: true, force: true }); +}); + function makeContext( apiKey: string | undefined, host: string | undefined, @@ -70,6 +103,32 @@ describe("requireApiClient", () => { expect((err as CliError).message).not.toContain("tr_secret_LEAK"); } }); + + it("names both checked config paths in the missing-api-key error", () => { + const ctx = makeContext(undefined, "https://api.example.com"); + try { + requireApiClient(ctx); + throw new Error("expected requireApiClient to throw"); + } catch (err) { + expect(err).toBeInstanceOf(CliError); + const message = (err as CliError).message; + expect(message).toContain(configPath()); + expect(message).toContain(globalConfigPath()); + } + }); + + it("names both checked config paths in the missing-host error", () => { + const ctx = makeContext("tr_present", undefined); + try { + requireApiClient(ctx); + throw new Error("expected requireApiClient to throw"); + } catch (err) { + expect(err).toBeInstanceOf(CliError); + const message = (err as CliError).message; + expect(message).toContain(configPath()); + expect(message).toContain(globalConfigPath()); + } + }); }); describe("contextFromCommand", () => { From 14a68caf5c42ac656e1caca312699bbb857b5fd3 Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Fri, 10 Jul 2026 15:22:28 -0600 Subject: [PATCH 3/7] feat(status): report global config source in status and doctor --- src/commands/doctor.ts | 6 ++++- src/commands/status.ts | 22 ++++++++++++--- src/doctor/checks.ts | 24 ++++++++++++++--- tests/commands/doctor.test.ts | 50 ++++++++++++++++++++++++++++++----- tests/commands/status.test.ts | 35 +++++++++++++++++++++--- 5 files changed, 118 insertions(+), 19 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 54fc4a7..55742e4 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import { createApiClient } from "../api/client.js"; -import { configPath } from "../config/manager.js"; +import { configPath, globalConfigPath } from "../config/manager.js"; import type { Context } from "../context.js"; import { buildDoctorReport } from "../doctor/checks.js"; import type { DoctorCheck, DoctorReport } from "../doctor/types.js"; @@ -25,6 +25,8 @@ export interface RunDoctorDeps { cwd: string; env: NodeJS.ProcessEnv; configPath: string; + /** Path to the global (per-user) config fallback; see `globalConfigPath()`. */ + globalConfigPath: string; writers: Writers; /** Network credential validation; omitted in tests to stay offline. */ verifyCredentials?: (host: string, apiKey: string) => Promise; @@ -54,6 +56,7 @@ export async function runDoctor(deps: RunDoctorDeps): Promise { auth: ctx.auth, credentialsValid, configPath: deps.configPath, + globalConfigPath: deps.globalConfigPath, detection, env, }); @@ -95,6 +98,7 @@ export function registerDoctor(program: Command): void { cwd: process.cwd(), env: process.env, configPath: configPath(), + globalConfigPath: globalConfigPath(), writers: defaultWriters, verifyCredentials: async (host, apiKey) => { try { diff --git a/src/commands/status.ts b/src/commands/status.ts index f2c71b3..2d0217f 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import type { Command } from "commander"; import type { ApiClient } from "../api/client.js"; -import { configPath } from "../config/manager.js"; +import { configPath, globalConfigPath } from "../config/manager.js"; import type { AuthSource } from "../config/resolve.js"; import type { Context } from "../context.js"; import { type Writers, defaultWriters, writeJson } from "../output.js"; @@ -9,15 +9,29 @@ import { apiKeyLabel, identity } from "../render/identity.js"; import { createStyler } from "../render/style.js"; import { contextFromCommand, requireApiClient } from "./shared.js"; +/** The config file path that actually won, when the source is file-based. */ +function winningConfigPath(source: AuthSource): string | undefined { + switch (source) { + case "config": + return configPath(); + case "global-config": + return globalConfigPath(); + default: + return undefined; + } +} + /** * Human-readable description of where the credentials were resolved from — and, * for file-based sources, the path of that file (so `Config source` answers * "where is my config?"). */ function describeSource(source: AuthSource): string { + const path = winningConfigPath(source); + if (path !== undefined) { + return path; + } switch (source) { - case "config": - return configPath(); case "auto-env-file": return `${join(process.cwd(), ".env")} (auto-loaded)`; case "env-file": @@ -61,7 +75,7 @@ export async function runStatus(deps: StatusDeps): Promise { host: who.host, ui_base_url: who.ui_base_url, config_source: configSource, - config_path: configPath(), + config_path: winningConfigPath(configSource) ?? configPath(), }, writers, ); diff --git a/src/doctor/checks.ts b/src/doctor/checks.ts index 4e09fa5..d0081fa 100644 --- a/src/doctor/checks.ts +++ b/src/doctor/checks.ts @@ -12,6 +12,8 @@ export interface DoctorInput { /** Network validation result; `null` when it was skipped (no credentials). */ credentialsValid: boolean | null; configPath: string; + /** Path to the global (per-user) config fallback; see `globalConfigPath()`. */ + globalConfigPath: string; detection: RepoDetection; env: NodeJS.ProcessEnv; } @@ -32,10 +34,12 @@ function safeHostOrigin(raw: string): string { } /** Friendly description of where a credential resolved from (no secret values). */ -function describeSource(source: AuthSource, configPath: string): string { +function describeSource(source: AuthSource, configPath: string, globalConfigPath: string): string { switch (source) { case "config": return configPath; + case "global-config": + return globalConfigPath; case "auto-env-file": return ".env (auto-loaded)"; case "env-file": @@ -50,7 +54,7 @@ function describeSource(source: AuthSource, configPath: string): string { } function credentialChecks(input: DoctorInput): DoctorCheck[] { - const { auth, configPath, credentialsValid } = input; + const { auth, configPath, globalConfigPath, credentialsValid } = input; const checks: DoctorCheck[] = []; // API key and host are required for CLI readiness, so their absence is a hard @@ -61,7 +65,7 @@ function credentialChecks(input: DoctorInput): DoctorCheck[] { category: "credentials", status: hasKey ? "pass" : "fail", message: hasKey - ? `API key resolved from ${describeSource(auth.apiKey.source, configPath)}` + ? `API key resolved from ${describeSource(auth.apiKey.source, configPath, globalConfigPath)}` : "API key not found. Run `traceroot login`.", }); @@ -92,7 +96,7 @@ function credentialChecks(input: DoctorInput): DoctorCheck[] { } function localFileChecks(input: DoctorInput): DoctorCheck[] { - const { configPath } = input; + const { configPath, globalConfigPath } = input; const checks: DoctorCheck[] = []; const configExists = existsSync(configPath); @@ -103,6 +107,18 @@ function localFileChecks(input: DoctorInput): DoctorCheck[] { message: configExists ? `Config file present at ${configPath}` : "No config file found", }); + // Purely informational: the global fallback is optional, so its absence is + // never a warning — only note it when present. + const globalConfigExists = existsSync(globalConfigPath); + if (globalConfigExists) { + checks.push({ + name: "global_config_file_present", + category: "traceroot_files", + status: "pass", + message: `Global config file present at ${globalConfigPath}`, + }); + } + if (configExists) { const dir = dirname(configPath); if (basename(dir) === ".traceroot") { diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index 6996594..64777b1 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -43,14 +43,16 @@ function makeWriters(): { writers: Writers; out: StringSink; err: StringSink } { return { writers: { out, err }, out, err }; } -function makeCtx(opts: { apiKey?: string; host?: string; json?: boolean }): Context { +function makeCtx(opts: { + apiKey?: string; + host?: string; + json?: boolean; + source?: "config" | "global-config"; +}): Context { + const source = opts.source ?? "config"; const auth: ResolvedAuth = { - apiKey: opts.apiKey - ? { value: opts.apiKey, source: "config" } - : { value: undefined, source: "none" }, - hostUrl: opts.host - ? { value: opts.host, source: "config" } - : { value: undefined, source: "none" }, + apiKey: opts.apiKey ? { value: opts.apiKey, source } : { value: undefined, source: "none" }, + hostUrl: opts.host ? { value: opts.host, source } : { value: undefined, source: "none" }, }; return { auth, json: opts.json ?? false }; } @@ -69,6 +71,9 @@ const baseDeps = (cwdArg: string, writers: Writers) => ({ cwd: cwdArg, env: {} as NodeJS.ProcessEnv, configPath: join(cwdArg, ".traceroot", "config.json"), + // A path under the isolated temp cwd, never the real ~/.config, so the + // global-config-presence check never touches the real filesystem. + globalConfigPath: join(cwdArg, "global-config", "config.json"), writers, detection, }); @@ -252,6 +257,37 @@ describe("runDoctor", () => { expect(out.data).not.toContain("Recommended next step"); }); + it("reports the global config path as the source when credentials resolved from it", async () => { + const { writers } = makeWriters(); + const report = await runDoctor({ + ...baseDeps(cwd, writers), + ctx: makeCtx({ + apiKey: "tr_x", + host: "https://api.example.com", + source: "global-config", + }), + }); + const key = report.checks.find((c) => c.name === "api_key_resolved"); + expect(key?.message).toBe(`API key resolved from ${join(cwd, "global-config", "config.json")}`); + }); + + it("reports the global config file as absent when not present", async () => { + const { writers } = makeWriters(); + const report = await runDoctor({ ...baseDeps(cwd, writers), ctx: makeCtx({}) }); + expect(report.checks.find((c) => c.name === "global_config_file_present")).toBeUndefined(); + }); + + it("reports the global config file as present when it exists", async () => { + const globalConfigPath = join(cwd, "global-config", "config.json"); + mkdirSync(join(cwd, "global-config"), { recursive: true }); + writeFileSync(globalConfigPath, JSON.stringify({ api_key: "k", host_url: "https://h" })); + const { writers } = makeWriters(); + const report = await runDoctor({ ...baseDeps(cwd, writers), ctx: makeCtx({}) }); + const check = report.checks.find((c) => c.name === "global_config_file_present"); + expect(check?.status).toBe("pass"); + expect(check?.message).toContain(globalConfigPath); + }); + it("emits valid JSON with data.checks and data.summary", async () => { const { writers, out, err } = makeWriters(); await runDoctor({ diff --git a/tests/commands/status.test.ts b/tests/commands/status.test.ts index 1990411..34b2012 100644 --- a/tests/commands/status.test.ts +++ b/tests/commands/status.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import type { ApiClient, Whoami } from "../../src/api/client.js"; import { runStatus } from "../../src/commands/status.js"; +import { configPath, globalConfigPath } from "../../src/config/manager.js"; +import type { AuthSource } from "../../src/config/resolve.js"; import type { Context } from "../../src/context.js"; import { CliError, type Writers } from "../../src/output.js"; import { StringSink } from "../helpers/stringSink.js"; @@ -21,11 +23,11 @@ function makeWhoami(overrides: Partial = {}): Whoami { }; } -function makeContext(json: boolean): Context { +function makeContext(json: boolean, source: AuthSource = "config"): Context { return { auth: { - apiKey: { value: FULL_TOKEN, source: "config" }, - hostUrl: { value: "https://api.example.com", source: "config" }, + apiKey: { value: FULL_TOKEN, source }, + hostUrl: { value: "https://api.example.com", source }, }, json, }; @@ -88,6 +90,18 @@ describe("runStatus (human)", () => { expect(out.data).toContain("config"); }); + it("shows the global config path when credentials resolved from the global fallback", async () => { + const { writers, out } = makeWriters(); + const who = makeWhoami(); + await runStatus({ + ctx: makeContext(false, "global-config"), + client: fakeClient(() => Promise.resolve(who)), + writers, + }); + + expect(out.data).toContain(globalConfigPath()); + }); + it("shows the key name and hint without brackets", async () => { const { writers, out } = makeWriters(); const who = makeWhoami({ key_name: "ci-key", key_hint: "tr_***1234" }); @@ -162,11 +176,26 @@ describe("runStatus (--json)", () => { expect(parsed.host).toBe("https://api.example.com"); expect(parsed.ui_base_url).toBe("https://app.example.com"); expect(parsed.config_source).toBe("config"); + expect(parsed.config_path).toBe(configPath()); // Exactly one document: stripped of its single trailing newline, no extra lines. expect(out.data.trimEnd().split("\n")).toHaveLength(1); expect(err.data).toBe(""); }); + it("reports the global config path as config_path when the global config won", async () => { + const { writers, out } = makeWriters(); + const who = makeWhoami(); + await runStatus({ + ctx: makeContext(true, "global-config"), + client: fakeClient(() => Promise.resolve(who)), + writers, + }); + + const parsed = JSON.parse(out.data) as Record; + expect(parsed.config_source).toBe("global-config"); + expect(parsed.config_path).toBe(globalConfigPath()); + }); + it("never prints the full api token in JSON mode", async () => { const { writers, out } = makeWriters(); const who = makeWhoami(); From 29daf23bb0c0c98a328fdf05b218347c988f0419 Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Fri, 10 Jul 2026 15:22:31 -0600 Subject: [PATCH 4/7] docs: correct config location docs and document precedence --- README.md | 30 +++++++++++++++++++++++++----- src/config/schema.ts | 8 ++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 782e04c..e805482 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,32 @@ priority order: 1. Flags — `--api-key`, `--host` 2. Env file — `--env-file ` 3. Env vars — `TRACEROOT_API_KEY`, `TRACEROOT_HOST_URL` -4. Config file — `./.traceroot/config.json` -5. Auto-discovered `./.env` +4. Project config file — `./.traceroot/config.json` (relative to the current + working directory) +5. Global config file — `~/.config/traceroot/config.json` (or + `$XDG_CONFIG_HOME/traceroot/config.json` when `XDG_CONFIG_HOME` is set) +6. Auto-discovered `./.env` + +`traceroot login` validates the key, then writes the **project** config file +`./.traceroot/config.json` (`0600`, auto-gitignored) so later commands run from +the same directory need no flags. Override that path with +`TRACEROOT_CONFIG_PATH`. + +Because the project config is scoped to the current working directory, running +a command from a different directory won't see it. For a key you want available +everywhere, place it at the global fallback path +(`~/.config/traceroot/config.json`) — `login` never writes there itself; create +or copy it manually, e.g.: -`traceroot login` validates the key, then writes `./.traceroot/config.json` -(`0600`, auto-gitignored) so later commands need no flags. Override the path with -`TRACEROOT_CONFIG_PATH`. For CI or scripts, prefer env vars or flags: +```sh +mkdir -p ~/.config/traceroot +cp ./.traceroot/config.json ~/.config/traceroot/config.json +``` + +`traceroot status` and `traceroot doctor` both report which file (if any) your +credentials actually resolved from. If neither config file nor any of the +above is found, the CLI's error message names both paths it checked. For CI or +scripts, prefer env vars or flags: ```sh export TRACEROOT_API_KEY=tr_... diff --git a/src/config/schema.ts b/src/config/schema.ts index 0ff66c0..483d9fe 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,6 +1,10 @@ /** - * Shape of the persisted CLI configuration (~/.traceroot/config.json). - * Both fields are required. + * Shape of the persisted CLI configuration. `traceroot login` writes the + * project-local `./.traceroot/config.json` (relative to the current working + * directory); when no project-local config is found, the CLI also checks a + * global fallback at `~/.config/traceroot/config.json` (or + * `$XDG_CONFIG_HOME/traceroot/config.json` when set), which `login` never + * writes to. Both fields are required. */ export interface Config { api_key: string; From 74a19e4d02dfc4689f125285bf5bfac30cb658e2 Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Fri, 10 Jul 2026 15:29:54 -0600 Subject: [PATCH 5/7] test(helpers): isolate spawned CLI runs from global config discovery --- tests/helpers/runCli.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/helpers/runCli.ts b/tests/helpers/runCli.ts index f040548..c3f8c02 100644 --- a/tests/helpers/runCli.ts +++ b/tests/helpers/runCli.ts @@ -17,9 +17,23 @@ export function runCli(...args: string[]): CliResult { // (a lowest-precedence credential source) never picks up the repo's own // `.env` and leaks credentials into otherwise-hermetic spawn tests. const cwd = mkdtempSync(join(tmpdir(), "traceroot-cli-")); + // Also isolate every non-cwd credential source (mirroring + // tests/output.contract.test.ts): the project config override, the global + // config fallback under XDG_CONFIG_HOME/homedir, and ambient env + // credentials. Otherwise a real ~/.config/traceroot/config.json (or exported + // TRACEROOT_* vars) would resolve credentials and turn these hermetic spawn + // tests into live network calls. The rest of process.env (PATH etc.) is kept. + const env: NodeJS.ProcessEnv = { + ...process.env, + TRACEROOT_CONFIG_PATH: join(cwd, "no-such-config", "config.json"), + XDG_CONFIG_HOME: join(cwd, "no-such-config-home"), + TRACEROOT_API_KEY: "", + TRACEROOT_HOST_URL: "", + }; const result = spawnSync(process.execPath, [binPath, ...args], { encoding: "utf8", cwd, + env, }); return { stdout: result.stdout, From 05eaa097aab8bb47425b92f5da96d39060057244 Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Sat, 11 Jul 2026 00:07:00 -0600 Subject: [PATCH 6/7] fix(doctor): warn when the global config file is group or world readable --- src/doctor/checks.ts | 18 ++++++++++++++++++ tests/commands/doctor.test.ts | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/doctor/checks.ts b/src/doctor/checks.ts index d0081fa..2823e96 100644 --- a/src/doctor/checks.ts +++ b/src/doctor/checks.ts @@ -117,6 +117,24 @@ function localFileChecks(input: DoctorInput): DoctorCheck[] { status: "pass", message: `Global config file present at ${globalConfigPath}`, }); + + // Best-effort permission check; meaningless on win32, so skip it there. + if (process.platform !== "win32") { + try { + const mode = statSync(globalConfigPath).mode & 0o777; + const safe = (mode & 0o077) === 0; + checks.push({ + name: "global_config_permissions", + category: "traceroot_files", + status: safe ? "pass" : "warn", + message: safe + ? "Global config file permissions are restrictive (0600)" + : `Global config file is group/world-readable (mode ${mode.toString(8)}); run \`chmod 600 ${globalConfigPath}\`.`, + }); + } catch { + // ignore stat failures + } + } } if (configExists) { diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index 64777b1..95c9bf8 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -288,6 +288,37 @@ describe("runDoctor", () => { expect(check?.message).toContain(globalConfigPath); }); + it.skipIf(process.platform === "win32")( + "passes the global config permission check when the file is 0600", + async () => { + const globalConfigPath = join(cwd, "global-config", "config.json"); + mkdirSync(join(cwd, "global-config"), { recursive: true }); + writeFileSync(globalConfigPath, JSON.stringify({ api_key: "k", host_url: "https://h" })); + chmodSync(globalConfigPath, 0o600); + const { writers } = makeWriters(); + const report = await runDoctor({ ...baseDeps(cwd, writers), ctx: makeCtx({}) }); + const check = report.checks.find((c) => c.name === "global_config_permissions"); + expect(check?.status).toBe("pass"); + expect(check?.message).toBe("Global config file permissions are restrictive (0600)"); + }, + ); + + it.skipIf(process.platform === "win32")( + "warns when the global config file is group/world-readable", + async () => { + const globalConfigPath = join(cwd, "global-config", "config.json"); + mkdirSync(join(cwd, "global-config"), { recursive: true }); + writeFileSync(globalConfigPath, JSON.stringify({ api_key: "k", host_url: "https://h" })); + chmodSync(globalConfigPath, 0o644); + const { writers } = makeWriters(); + const report = await runDoctor({ ...baseDeps(cwd, writers), ctx: makeCtx({}) }); + const check = report.checks.find((c) => c.name === "global_config_permissions"); + expect(check?.status).toBe("warn"); + expect(check?.message).toContain("mode 644"); + expect(check?.message).toContain(`chmod 600 ${globalConfigPath}`); + }, + ); + it("emits valid JSON with data.checks and data.summary", async () => { const { writers, out, err } = makeWriters(); await runDoctor({ From e6fee6a5b438d6e41da4882cdd5957ffe34236fd Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Sat, 11 Jul 2026 00:08:54 -0600 Subject: [PATCH 7/7] fix(config): read the global config lazily so it stays a pure fallback --- src/config/resolve.ts | 26 ++++++++++++---- tests/config.resolve.test.ts | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/config/resolve.ts b/src/config/resolve.ts index 0bc1ef4..f11dffe 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -79,7 +79,8 @@ function normalizeApiKey(value: string): string { } interface Candidate { - value: string | undefined; + /** The candidate value, or a thunk for a source that must be read lazily. */ + value: string | undefined | (() => string | undefined); source: Exclude; } @@ -88,10 +89,14 @@ function firstPresent( normalize?: (value: string) => string, ): ResolvedField { for (const candidate of candidates) { - if (!present(candidate.value)) { + // A thunk is only invoked once every higher-precedence candidate was + // absent, so a lazily-read source (the global config) is never touched + // when an explicit source already supplied the value. + const raw = typeof candidate.value === "function" ? candidate.value() : candidate.value; + if (!present(raw)) { continue; } - const value = normalize ? normalize(candidate.value) : candidate.value; + const value = normalize ? normalize(raw) : raw; // Normalization can empty a value (e.g. a slashes-only host); if so, fall // through to the next candidate rather than reporting an empty value with a // concrete source. @@ -121,7 +126,16 @@ export function resolveAuth(options: ResolveAuthOptions = {}): ResolvedAuth { const fileMap: Record = present(flags.envFile) ? loadEnvFile(flags.envFile) : {}; const config = readConfig() ?? ({} as Partial); - const globalConfig = readGlobalConfig() ?? ({} as Partial); + + // The global config is a pure fallback: read it lazily (and at most once) so + // an unreadable ~/.config/traceroot/config.json can never break resolution + // when a higher-precedence source (flag, env file, env, project config) + // already supplies the field. + let globalConfigCache: Partial | undefined; + const globalConfig = (): Partial => { + globalConfigCache ??= readGlobalConfig() ?? ({} as Partial); + return globalConfigCache; + }; const apiKey = firstPresent( [ @@ -129,7 +143,7 @@ export function resolveAuth(options: ResolveAuthOptions = {}): ResolvedAuth { { value: fileMap.TRACEROOT_API_KEY, source: "env-file" }, { value: env.TRACEROOT_API_KEY, source: "env" }, { value: config.api_key, source: "config" }, - { value: globalConfig.api_key, source: "global-config" }, + { value: () => globalConfig().api_key, source: "global-config" }, { value: autoEnv.TRACEROOT_API_KEY, source: "auto-env-file" }, ], normalizeApiKey, @@ -141,7 +155,7 @@ export function resolveAuth(options: ResolveAuthOptions = {}): ResolvedAuth { { value: fileMap.TRACEROOT_HOST_URL, source: "env-file" }, { value: env.TRACEROOT_HOST_URL, source: "env" }, { value: config.host_url, source: "config" }, - { value: globalConfig.host_url, source: "global-config" }, + { value: () => globalConfig().host_url, source: "global-config" }, { value: autoEnv.TRACEROOT_HOST_URL, source: "auto-env-file" }, ], normalizeHostUrl, diff --git a/tests/config.resolve.test.ts b/tests/config.resolve.test.ts index 8ba6b7e..e773f22 100644 --- a/tests/config.resolve.test.ts +++ b/tests/config.resolve.test.ts @@ -133,6 +133,64 @@ describe("resolveAuth precedence (global config fallback)", () => { }); }); +describe("resolveAuth lazy global config read", () => { + const throwingGlobal = (): Config => { + throw new Error("EACCES: permission denied"); + }; + + it("resolves flag credentials even when the global config is unreadable", () => { + const result = resolveAuth({ + flags: { apiKey: "flag-key", host: "https://flag" }, + readGlobalConfig: throwingGlobal, + }); + expect(result.apiKey).toEqual({ value: "flag-key", source: "flag" }); + expect(result.hostUrl).toEqual({ value: "https://flag", source: "flag" }); + }); + + it("resolves env credentials even when the global config is unreadable", () => { + const result = resolveAuth({ + env: { TRACEROOT_API_KEY: "env-key", TRACEROOT_HOST_URL: "https://env" }, + readGlobalConfig: throwingGlobal, + }); + expect(result.apiKey).toEqual({ value: "env-key", source: "env" }); + expect(result.hostUrl).toEqual({ value: "https://env", source: "env" }); + }); + + it("resolves project-config credentials even when the global config is unreadable", () => { + const result = resolveAuth({ + readConfig: config({}), + readGlobalConfig: throwingGlobal, + }); + expect(result.apiKey).toEqual({ value: "cfg-key", source: "config" }); + expect(result.hostUrl).toEqual({ value: "https://cfg", source: "config" }); + }); + + it("never reads the global config when the project config supplies both fields", () => { + const readGlobalConfig = vi.fn(globalConfig({})); + resolveAuth({ readConfig: config({}), readGlobalConfig }); + expect(readGlobalConfig).not.toHaveBeenCalled(); + }); + + it("reads the global config at most once when both fields fall through to it", () => { + const readGlobalConfig = vi.fn(globalConfig({})); + const result = resolveAuth({ readConfig: () => null, readGlobalConfig }); + expect(result.apiKey).toEqual({ value: "global-cfg-key", source: "global-config" }); + expect(result.hostUrl).toEqual({ value: "https://global-cfg", source: "global-config" }); + expect(readGlobalConfig).toHaveBeenCalledTimes(1); + }); + + it("reads the global config when only one field falls through to it", () => { + const readGlobalConfig = vi.fn(globalConfig({})); + const result = resolveAuth({ + readConfig: (): Config => ({ api_key: "cfg-key", host_url: "" }), + readGlobalConfig, + }); + expect(result.apiKey).toEqual({ value: "cfg-key", source: "config" }); + expect(result.hostUrl).toEqual({ value: "https://global-cfg", source: "global-config" }); + expect(readGlobalConfig).toHaveBeenCalledTimes(1); + }); +}); + describe("resolveAuth when nothing is set", () => { it("reports none for both fields", () => { const result = resolveAuth();