Skip to content
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,32 @@ priority order:
1. Flags — `--api-key`, `--host`
2. Env file — `--env-file <path>`
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_...
Expand Down
6 changes: 5 additions & 1 deletion src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<boolean>;
Expand Down Expand Up @@ -54,6 +56,7 @@ export async function runDoctor(deps: RunDoctorDeps): Promise<DoctorReport> {
auth: ctx.auth,
credentialsValid,
configPath: deps.configPath,
globalConfigPath: deps.globalConfigPath,
detection,
env,
});
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,13 @@ export async function runLogin(deps: LoginDeps): Promise<void> {

// 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();
Expand Down
9 changes: 7 additions & 2 deletions src/commands/shared.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 });
Expand Down
22 changes: 18 additions & 4 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
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";
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":
Expand Down Expand Up @@ -61,7 +75,7 @@ export async function runStatus(deps: StatusDeps): Promise<void> {
host: who.host,
ui_base_url: who.ui_base_url,
config_source: configSource,
config_path: configPath(),
config_path: winningConfigPath(configSource) ?? configPath(),
},
writers,
);
Expand Down
47 changes: 41 additions & 6 deletions src/config/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 !== "") {
Expand All @@ -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;
Expand All @@ -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");
Expand Down Expand Up @@ -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"]);

/**
Expand Down
54 changes: 44 additions & 10 deletions src/config/resolve.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string, string>;
/**
* 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<string, string>;
}
Expand Down Expand Up @@ -64,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<AuthSource, "none">;
}

Expand All @@ -73,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.
Expand All @@ -90,27 +110,40 @@ 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<string, string> = present(flags.envFile) ? loadEnvFile(flags.envFile) : {};
const config = readConfig() ?? ({} as Partial<Config>);

// 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<Config> | undefined;
const globalConfig = (): Partial<Config> => {
globalConfigCache ??= readGlobalConfig() ?? ({} as Partial<Config>);
return globalConfigCache;
};

const apiKey = firstPresent(
[
{ value: flags.apiKey, source: "flag" },
{ 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,
Expand All @@ -122,6 +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: autoEnv.TRACEROOT_HOST_URL, source: "auto-env-file" },
],
normalizeHostUrl,
Expand Down
8 changes: 6 additions & 2 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading
Loading