diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index da69607..bf1fc06 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1316,6 +1316,33 @@ Every check reuses the same helpers the real commands use, so the report reflect Credentials live at `~/.testsprite/credentials` (INI-style, mode `0600`) — one section per profile. **Profile resolution order** (highest first): `--profile` flag → `TESTSPRITE_PROFILE` env → `default`. Within a profile, the `TESTSPRITE_API_KEY` / `TESTSPRITE_API_URL` env vars override the file, so CI can run without ever touching `~/.testsprite/credentials`. +### Config file (`~/.testsprite/config`) + +Non-credential defaults live at `~/.testsprite/config` (INI-style, one section per profile, same `[default]`/`[profile name]` grammar as the credentials file). It is the lowest-precedence layer above the built-in defaults, so the resolution order for every setting it can hold is: + +``` +-- flag > TESTSPRITE_* env var > ~/.testsprite/config > built-in default +``` + +It can hold, per profile: + +| Key in config | Purpose | +| -------------------------- | ------------------------------------------------------------------------ | +| `api_url` / `endpoint_url` | Default API host (overridden by `--endpoint-url` / `TESTSPRITE_API_URL`) | +| `output` | Default `--output json\|text` (overridden by `--output`) | +| `project` | Default project id (overridden by `--project` / `TESTSPRITE_PROJECT_ID`) | + +API credentials (`api_key`) are intentionally **not** in the config file — they stay in `~/.testsprite/credentials`, mirroring the aws-cli split (secrets in one file, settings in another). `~/.testsprite/config` is created and managed by you; the CLI only reads it and never writes it. + +Example `~/.testsprite/config`: + +```ini +[default] +output = json +endpoint_url = https://api.testsprite.com +project = prj_abc123 +``` + ### Global flags These apply to every command: diff --git a/src/commands/project.ts b/src/commands/project.ts index b957130..70c59f0 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -10,7 +10,7 @@ import { resolveRequestTimeoutMs, type CommonOptions as FactoryCommonOptions, } from '../lib/client-factory.js'; -import { resolveProfileName } from '../lib/config.js'; +import { loadConfig, resolveProfileName } from '../lib/config.js'; import { ApiError, InterruptError, RequestTimeoutError } from '../lib/errors.js'; import { firstSuiteNextSteps } from '../lib/first-suite-hint.js'; import type { FetchImpl, HttpClient } from '../lib/http.js'; @@ -2131,6 +2131,10 @@ function requireDocsProjectId(projectId: string | undefined, deps: ProjectDeps): if (explicit && explicit.length > 0) return explicit; const envValue = (deps.env ?? process.env).TESTSPRITE_PROJECT_ID?.trim(); if (envValue && envValue.length > 0) return envValue; + // Lowest-precedence persistent default from ~/.testsprite/config, composing + // with TESTSPRITE_PROJECT_ID (flag > env > config). + const configProjectId = loadConfig({ env: deps.env ?? process.env }).projectId; + if (configProjectId && configProjectId.length > 0) return configProjectId; throw localValidationError( '--project is required; pass --project or set TESTSPRITE_PROJECT_ID', ); diff --git a/src/commands/test.ts b/src/commands/test.ts index 8bf5ce5..a168197 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -16168,7 +16168,10 @@ function resolveProjectId(projectId: string | undefined, deps: TestDeps): string if (explicit && explicit.length > 0) return explicit; const envValue = (deps.env ?? process.env).TESTSPRITE_PROJECT_ID; const trimmed = envValue?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : undefined; + if (trimmed && trimmed.length > 0) return trimmed; + // Lowest-precedence persistent default from ~/.testsprite/config for the + // resolved profile, composing with TESTSPRITE_PROJECT_ID (flag > env > config). + return loadConfig({ env: deps.env ?? process.env }).projectId; } function requireProjectId( projectId: string | undefined, diff --git a/src/index.ts b/src/index.ts index d2e3d6b..65279a9 100755 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,7 @@ import { createTunnelCommand } from './commands/tunnel.js'; import { createTestCommand } from './commands/test.js'; import { createTestListCommand } from './commands/testlist.js'; import { createUsageCommand } from './commands/usage.js'; -import { resolveProfileName } from './lib/config.js'; +import { loadConfig, resolveProfileName } from './lib/config.js'; import { classifyBillingRefusal } from './lib/billing-refusal.js'; import { TARGETS, type AgentTarget } from './lib/agent-targets.js'; import { @@ -415,7 +415,13 @@ try { process.exitCode = code; }; - const rawMode = program.opts<{ output?: string }>().output; + // Persistent default --output from ~/.testsprite/config sits below the flag + // (flag > env > config-file > built-in 'text'); the flag wins when present. + const config = loadConfig({ + profile: resolveProfileName(program.opts<{ profile?: string }>().profile), + env: process.env, + }); + const rawMode = program.opts<{ output?: string }>().output ?? config.output; const mode = isOutputMode(rawMode) ? rawMode : 'text'; const output = new Output(mode); if (err instanceof ApiError) { diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 2a58fc8..a433527 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -7,10 +7,12 @@ import { writeProfile } from './credentials.js'; let tmpRoot: string; let credentialsPath: string; +let configPath: string; beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'testsprite-config-')); credentialsPath = join(tmpRoot, 'credentials'); + configPath = join(tmpRoot, 'config'); }); describe('loadConfig', () => { @@ -120,6 +122,73 @@ describe('loadConfig', () => { }); expect(config.apiKey).toBe('sk-file'); }); + + it('uses config-file apiUrl (api_url) when flag and env are absent', () => { + writeProfile('default', { apiUrl: 'https://from-config.example.com' }, { path: configPath }); + const config = loadConfig({ env: {}, credentialsPath, configPath }); + expect(config.apiUrl).toBe('https://from-config.example.com'); + }); + + it('uses config-file endpoint_url as apiUrl', () => { + writeProfile('default', { apiUrl: 'https://from-config.example.com' }, { path: configPath }); + const config = loadConfig({ env: {}, credentialsPath, configPath }); + expect(config.apiUrl).toBe('https://from-config.example.com'); + }); + + it('uses config-file output when no --output flag is set', () => { + writeProfile('default', { output: 'json' }, { path: configPath }); + const config = loadConfig({ env: {}, credentialsPath, configPath }); + expect(config.output).toBe('json'); + }); + + it('uses config-file projectId when --project and TESTSPRITE_PROJECT_ID are absent', () => { + writeProfile('default', { projectId: 'proj_from_config' }, { path: configPath }); + const config = loadConfig({ env: {}, credentialsPath, configPath }); + expect(config.projectId).toBe('proj_from_config'); + }); + + it('flag endpointUrl overrides the config file', () => { + writeProfile('default', { apiUrl: 'https://from-config.example.com' }, { path: configPath }); + const config = loadConfig({ + endpointUrl: 'https://from-flag.example.com', + env: {}, + credentialsPath, + configPath, + }); + expect(config.apiUrl).toBe('https://from-flag.example.com'); + }); + + it('TESTSPRITE_API_URL overrides the config file', () => { + writeProfile('default', { apiUrl: 'https://from-config.example.com' }, { path: configPath }); + const config = loadConfig({ + env: { TESTSPRITE_API_URL: 'https://from-env.example.com' }, + credentialsPath, + configPath, + }); + expect(config.apiUrl).toBe('https://from-env.example.com'); + }); + + it('config-file project loses to TESTSPRITE_PROJECT_ID env', () => { + writeProfile('default', { projectId: 'proj_from_config' }, { path: configPath }); + const config = loadConfig({ + env: { TESTSPRITE_PROJECT_ID: 'proj_from_env' }, + credentialsPath, + configPath, + }); + // projectId comes purely from the config file; env composes at the caller. + expect(config.projectId).toBe('proj_from_config'); + }); + + it('resolves the config path from TESTSPRITE_CONFIG_FILE when configPath is omitted', () => { + const configRoot = mkdtempSync(join(tmpdir(), 'testsprite-config-env-')); + const envConfigPath = join(configRoot, 'config'); + writeProfile('default', { projectId: 'proj_from_env_config' }, { path: envConfigPath }); + const config = loadConfig({ + env: { ...process.env, TESTSPRITE_CONFIG_FILE: envConfigPath }, + credentialsPath, + }); + expect(config.projectId).toBe('proj_from_env_config'); + }); }); describe('defaultConfigPath', () => { diff --git a/src/lib/config.ts b/src/lib/config.ts index fe24e37..9b881cf 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -6,6 +6,8 @@ export interface Config { apiUrl: string; apiKey?: string; profile: string; + output?: string; + projectId?: string; } export interface LoadConfigOptions { @@ -13,6 +15,7 @@ export interface LoadConfigOptions { endpointUrl?: string; env?: NodeJS.ProcessEnv; credentialsPath?: string; + configPath?: string; } export const DEFAULT_API_URL = 'https://api.testsprite.com'; @@ -50,21 +53,33 @@ export function resolveProfileName( } /** - * Resolves the active profile name and its (apiUrl, apiKey) pair. + * Resolves the active profile name and its (apiUrl, apiKey) pair, plus the + * persistent settings carried by the config file (`~/.testsprite/config`). * * Resolution order, highest precedence first: * profile name: options.profile > env.TESTSPRITE_PROFILE > "default" * apiKey: env.TESTSPRITE_API_KEY > credentials file profile entry - * apiUrl: options.endpointUrl > env.TESTSPRITE_API_URL > credentials file > built-in default + * apiUrl: options.endpointUrl > env.TESTSPRITE_API_URL > + * credentials file > config file > built-in default + * output: --output flag (applied by the caller) > config file + * projectId: --project flag > env.TESTSPRITE_PROJECT_ID > config file * - * Env wins over the credentials file so CI / scripted callers can run without touching - * the user's ~/.testsprite/credentials. + * The config file is the lowest-precedence layer above the built-in defaults, + * so `flag > env > config-file > built-in default` holds for every setting it + * can hold (`output`, `endpoint_url`/`api_url`, `project`). API credentials + * (`api_key`) stay in `~/.testsprite/credentials` only — this mirrors the + * aws-cli split (credentials in one file, settings in another). + * + * Env wins over the credentials file so CI / scripted callers can run without + * touching the user's ~/.testsprite/credentials. */ export function loadConfig(options: LoadConfigOptions = {}): Config { const env = options.env ?? process.env; const profile = resolveProfileName(options.profile, env); const credentialsPath = options.credentialsPath ?? defaultCredentialsPath(); - const fileEntry = readProfile(profile, { path: credentialsPath }); + const configPath = options.configPath ?? env.TESTSPRITE_CONFIG_FILE ?? defaultConfigPath(); + const credentialsEntry = readProfile(profile, { path: credentialsPath }); + const configEntry = readProfile(profile, { path: configPath }); // Empty / whitespace-only env vars are treated as unset so they do not // short-circuit the `??` chain (e.g. `export TESTSPRITE_API_URL=` or @@ -77,8 +92,15 @@ export function loadConfig(options: LoadConfigOptions = {}): Config { const envApiKey = normalizeEnvVar(env.TESTSPRITE_API_KEY); return { - apiUrl: options.endpointUrl ?? envApiUrl ?? fileEntry?.apiUrl ?? DEFAULT_API_URL, - apiKey: envApiKey ?? fileEntry?.apiKey, + apiUrl: + options.endpointUrl ?? + envApiUrl ?? + credentialsEntry?.apiUrl ?? + configEntry?.apiUrl ?? + DEFAULT_API_URL, + apiKey: envApiKey ?? credentialsEntry?.apiKey, profile, + output: configEntry?.output, + projectId: configEntry?.projectId, }; } diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index b0eec1f..135a87c 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -56,6 +56,8 @@ export function defaultCredentialsPath(): string { export interface ProfileEntry { apiKey?: string; apiUrl?: string; + output?: string; + projectId?: string; } export type CredentialsFile = Record; @@ -112,11 +114,16 @@ interface RestrictiveModeOptions { const FILE_KEY_TO_FIELD: Record = { api_key: 'apiKey', api_url: 'apiUrl', + endpoint_url: 'apiUrl', + output: 'output', + project: 'projectId', }; const FIELD_TO_FILE_KEY: Record = { apiKey: 'api_key', apiUrl: 'api_url', + output: 'output', + projectId: 'project', }; const CREDENTIALS_LOCK_RETRY_MS = 25;