Skip to content

Commit 0e11244

Browse files
committed
feat(config): read ~/.testsprite/config for persistent defaults (endpoint_url, output, project_id)
1 parent 26821e1 commit 0e11244

5 files changed

Lines changed: 203 additions & 21 deletions

File tree

src/commands/test.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ import {
9292
import { createTicker } from '../lib/ticker.js';
9393
import { RateThrottle } from '../lib/rate-throttle.js';
9494
import { resolvePortalBase, resolvePortalUrl } from '../lib/facade.js';
95-
import { loadConfig } from '../lib/config.js';
95+
import { loadConfig, readConfigFileSettings } from '../lib/config.js';
9696
import {
9797
flakyExitCode,
9898
renderFlakyText,
@@ -552,7 +552,7 @@ export async function runList(opts: ListOptions, deps: TestDeps = {}): Promise<P
552552
// (exit 3) when the caller also lacks a configured key. Order matters
553553
// for the CLI error spec §2 — bad input is a caller bug, not an auth
554554
// gate.
555-
const projectId = resolveProjectId(opts.projectId, deps);
555+
const projectId = resolveProjectId(opts.projectId, deps, opts.profile);
556556
requireProjectId(projectId);
557557

558558
const paginationFlags: PaginationFlags = validatePaginationFlags({
@@ -791,7 +791,7 @@ export async function runCreate(
791791
assertChainedRunKeyFits(opts.run, opts.idempotencyKey);
792792
// Validate inputs before touching credentials or fs — matches the
793793
// M2 read commands' "input gates first, then auth, then I/O" ordering.
794-
const projectId = resolveProjectId(opts.projectId, deps);
794+
const projectId = resolveProjectId(opts.projectId, deps, opts.profile);
795795
requireProjectId(projectId);
796796
requireNonEmpty('name', opts.name);
797797
// P1-3: client-side length checks matching server limits (name ≤200,
@@ -6411,7 +6411,7 @@ export async function runTestRunAll(
64116411
deps: TestDeps = {},
64126412
): Promise<BatchRunFreshResponse | undefined> {
64136413
assertIdempotencyKey(opts.idempotencyKey);
6414-
const projectId = resolveProjectId(opts.projectId, deps);
6414+
const projectId = resolveProjectId(opts.projectId, deps, opts.profile);
64156415
requireProjectId(projectId);
64166416
if (
64176417
!Number.isInteger(opts.maxConcurrency) ||
@@ -9176,7 +9176,7 @@ export function createTestCommand(deps: TestDeps = {}): Command {
91769176

91779177
if (isAll) {
91789178
// --all path: wave-ordered fresh batch run.
9179-
const projectId = resolveProjectId(cmdOpts.project, deps);
9179+
const projectId = resolveProjectId(cmdOpts.project, deps, resolveCommonOptions(command).profile);
91809180
requireProjectId(
91819181
projectId,
91829182
'--all requires a project id - pass --project <id> or set TESTSPRITE_PROJECT_ID',
@@ -9805,16 +9805,28 @@ interface StepsFlagOpts {
98059805
runId?: string;
98069806
}
98079807

9808-
function resolveProjectId(projectId: string | undefined, deps: TestDeps): string | undefined {
9808+
/**
9809+
* Resolve the effective project id: the `--project` flag when set, then the
9810+
* `TESTSPRITE_PROJECT_ID` env var, then the `project_id` persisted in the
9811+
* settings config file (`~/.testsprite/config`) — flag > env > config file —
9812+
* so a repo/agent can pin the project once instead of repeating it per command.
9813+
*/
9814+
function resolveProjectId(
9815+
projectId: string | undefined,
9816+
deps: TestDeps,
9817+
profile = 'default',
9818+
): string | undefined {
98099819
const explicit = projectId?.trim();
98109820
if (explicit && explicit.length > 0) return explicit;
98119821
const envValue = (deps.env ?? process.env).TESTSPRITE_PROJECT_ID;
98129822
const trimmed = envValue?.trim();
9813-
return trimmed && trimmed.length > 0 ? trimmed : undefined;
9823+
if (trimmed && trimmed.length > 0) return trimmed;
9824+
const fromConfig = readConfigFileSettings(profile, { env: deps.env }).projectId;
9825+
return typeof fromConfig === 'string' && fromConfig.length > 0 ? fromConfig : undefined;
98149826
}
98159827
function requireProjectId(
98169828
projectId: string | undefined,
9817-
message = 'is required; pass --project <id> or set TESTSPRITE_PROJECT_ID',
9829+
message = 'is required; pass --project <id>, set TESTSPRITE_PROJECT_ID, or set project_id in ~/.testsprite/config',
98189830
): asserts projectId is string {
98199831
if (typeof projectId !== 'string' || projectId.length === 0) {
98209832
throw localValidationError('project', message);

src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { createProjectCommand } from './commands/project.js';
1414
import { createTestCommand } from './commands/test.js';
1515
import { createUsageCommand } from './commands/usage.js';
16+
import { readConfigFileSettings } from './lib/config.js';
1617
import { ApiError, CLIError, InterruptError, RequestTimeoutError } from './lib/errors.js';
1718
import { installBrokenPipeGuard, installSignalHandlers } from './lib/interrupt.js';
1819
import { Output, isOutputMode } from './lib/output.js';
@@ -34,6 +35,17 @@ if (shouldRejectNodeVersion(process.versions.node)) {
3435

3536
const program = new Command();
3637

38+
/**
39+
* Default for the global `--output` flag: the `output` key of the profile's
40+
* section in `~/.testsprite/config` when present. Flag values and the
41+
* env-selected profile still win; an invalid or absent value falls back to
42+
* 'text' (the historical default).
43+
*/
44+
function configFileOutputDefault(): string {
45+
const settings = readConfigFileSettings(process.env.TESTSPRITE_PROFILE ?? 'default');
46+
return isOutputMode(settings.output) ? settings.output : 'text';
47+
}
48+
3749
// exitOverride() causes Commander to throw CommanderError instead of calling
3850
// process.exit() directly, giving our catch block a chance to remap error
3951
// exit codes (e.g. missing-argument → exit 5 per taxonomy).
@@ -43,7 +55,7 @@ program
4355
.name('testsprite')
4456
.description('Official TestSprite command-line interface')
4557
.version(VERSION)
46-
.option('--output <mode>', 'Output format (json|text)', 'text')
58+
.option('--output <mode>', 'Output format (json|text)', configFileOutputDefault())
4759
.option('--profile <name>', 'Configuration profile to use')
4860
.option('--endpoint-url <url>', 'Override the API endpoint host')
4961
.option(

src/lib/config.test.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { mkdtempSync } from 'node:fs';
1+
import { mkdtempSync, writeFileSync } from 'node:fs';
22
import { homedir, tmpdir } from 'node:os';
33
import { join } from 'node:path';
44
import { beforeEach, describe, expect, it } from 'vitest';
5-
import { defaultConfigPath, loadConfig } from './config.js';
5+
import { defaultConfigPath, loadConfig, readConfigFileSettings } from './config.js';
66
import { writeProfile } from './credentials.js';
77

88
let tmpRoot: string;
@@ -127,3 +127,53 @@ describe('defaultConfigPath', () => {
127127
expect(defaultConfigPath()).toBe(join(homedir(), '.testsprite', 'config'));
128128
});
129129
});
130+
131+
describe('readConfigFileSettings + the config-file layer of loadConfig', () => {
132+
function writeConfigFile(content: string): string {
133+
const path = join(mkdtempSync(join(tmpdir(), 'testsprite-config-')), 'config');
134+
writeFileSync(path, content, 'utf8');
135+
return path;
136+
}
137+
138+
it('reads endpoint_url/output/project_id from the profile section', () => {
139+
const path = writeConfigFile(
140+
'[default]\nendpoint_url = https://selfhosted.example.com\noutput = json\nproject_id = project_cfg\n',
141+
);
142+
expect(readConfigFileSettings('default', { path })).toEqual({
143+
endpointUrl: 'https://selfhosted.example.com',
144+
output: 'json',
145+
projectId: 'project_cfg',
146+
});
147+
// A different profile section is not leaked into.
148+
expect(readConfigFileSettings('other', { path })).toEqual({});
149+
});
150+
151+
it('missing file and unknown keys are non-fatal', () => {
152+
expect(readConfigFileSettings('default', { path: '/nope/definitely/missing' })).toEqual({});
153+
const path = writeConfigFile('[default]\nmystery_key = x\n');
154+
expect(readConfigFileSettings('default', { path })).toEqual({});
155+
});
156+
157+
it('resolves the path from TESTSPRITE_CONFIG_FILE when no explicit path is given', () => {
158+
const path = writeConfigFile('[default]\nproject_id = project_from_env_path\n');
159+
const settings = readConfigFileSettings('default', {
160+
env: { TESTSPRITE_CONFIG_FILE: path },
161+
});
162+
expect(settings.projectId).toBe('project_from_env_path');
163+
});
164+
165+
it('loadConfig: config-file endpoint_url sits above the built-in default and below env', () => {
166+
const path = writeConfigFile('[default]\nendpoint_url = https://cfg.example.com\n');
167+
const missingCreds = join(mkdtempSync(join(tmpdir(), 'testsprite-creds-')), 'credentials');
168+
// Only the config file supplies a URL -> it wins over the built-in default.
169+
const fromConfig = loadConfig({ env: {}, credentialsPath: missingCreds, configPath: path });
170+
expect(fromConfig.apiUrl).toBe('https://cfg.example.com');
171+
// Env still outranks the config file.
172+
const fromEnv = loadConfig({
173+
env: { TESTSPRITE_API_URL: 'https://env.example.com' },
174+
credentialsPath: missingCreds,
175+
configPath: path,
176+
});
177+
expect(fromEnv.apiUrl).toBe('https://env.example.com');
178+
});
179+
});

src/lib/config.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import { existsSync, readFileSync } from 'node:fs';
12
import { homedir } from 'node:os';
23
import { join } from 'node:path';
3-
import { DEFAULT_PROFILE, defaultCredentialsPath, readProfile } from './credentials.js';
4+
import {
5+
DEFAULT_PROFILE,
6+
defaultCredentialsPath,
7+
parseIniFile,
8+
readProfile,
9+
} from './credentials.js';
410

511
export interface Config {
612
apiUrl: string;
@@ -13,6 +19,8 @@ export interface LoadConfigOptions {
1319
endpointUrl?: string;
1420
env?: NodeJS.ProcessEnv;
1521
credentialsPath?: string;
22+
/** Settings file override; defaults to `TESTSPRITE_CONFIG_FILE` or `~/.testsprite/config`. */
23+
configPath?: string;
1624
}
1725

1826
const DEFAULT_API_URL = 'https://api.testsprite.com';
@@ -26,22 +34,79 @@ export function defaultConfigPath(): string {
2634
return join(homedir(), '.testsprite', 'config');
2735
}
2836

37+
/**
38+
* Non-credential defaults a user can persist in `~/.testsprite/config`
39+
* (aws-cli's credentials-vs-config split: the key lives in `credentials`,
40+
* settings live here). INI sections are profile names, mirroring the
41+
* credentials file.
42+
*/
43+
export interface ConfigFileSettings {
44+
endpointUrl?: string;
45+
output?: string;
46+
projectId?: string;
47+
}
48+
49+
/** INI key -> settings field. sourceRef: DOCUMENTATION.md configuration table. */
50+
const CONFIG_KEY_TO_FIELD: Record<string, keyof ConfigFileSettings> = {
51+
endpoint_url: 'endpointUrl',
52+
output: 'output',
53+
project_id: 'projectId',
54+
};
55+
56+
export interface ReadConfigFileOptions {
57+
env?: NodeJS.ProcessEnv;
58+
path?: string;
59+
}
60+
61+
/**
62+
* Read the `[profile]` section of the settings config file. Missing or
63+
* unreadable file (or section) yields `{}` so the cascade falls through to
64+
* built-in defaults; the file is optional by design. Reuses the hardened INI
65+
* parser the credentials file uses (null-prototype + proto-key guards).
66+
*/
67+
export function readConfigFileSettings(
68+
profile: string,
69+
options: ReadConfigFileOptions = {},
70+
): ConfigFileSettings {
71+
const env = options.env ?? process.env;
72+
const path = options.path ?? env.TESTSPRITE_CONFIG_FILE ?? defaultConfigPath();
73+
let content: string;
74+
try {
75+
if (!existsSync(path)) return {};
76+
content = readFileSync(path, 'utf-8');
77+
} catch {
78+
// Unreadable settings file: settings are optional, never fatal.
79+
return {};
80+
}
81+
const section = parseIniFile(content)[profile];
82+
if (!section) return {};
83+
const settings: ConfigFileSettings = {};
84+
for (const [rawKey, field] of Object.entries(CONFIG_KEY_TO_FIELD)) {
85+
const value = section[rawKey];
86+
if (typeof value === 'string' && value.length > 0) settings[field] = value;
87+
}
88+
return settings;
89+
}
90+
2991
/**
3092
* Resolves the active profile name and its (apiUrl, apiKey) pair.
3193
*
3294
* Resolution order, highest precedence first:
3395
* profile name: options.profile > env.TESTSPRITE_PROFILE > "default"
3496
* apiKey: env.TESTSPRITE_API_KEY > credentials file profile entry
35-
* apiUrl: options.endpointUrl > env.TESTSPRITE_API_URL > credentials file > built-in default
97+
* apiUrl: options.endpointUrl > env.TESTSPRITE_API_URL > credentials file
98+
* > config file `endpoint_url` > built-in default
3699
*
37100
* Env wins over the credentials file so CI / scripted callers can run without touching
38-
* the user's ~/.testsprite/credentials.
101+
* the user's ~/.testsprite/credentials. The config file sits just above the built-in
102+
* default: it is where a user persists "this machine talks to this endpoint" once.
39103
*/
40104
export function loadConfig(options: LoadConfigOptions = {}): Config {
41105
const env = options.env ?? process.env;
42106
const profile = options.profile ?? normalizeEnvVar(env.TESTSPRITE_PROFILE) ?? DEFAULT_PROFILE;
43107
const credentialsPath = options.credentialsPath ?? defaultCredentialsPath();
44108
const fileEntry = readProfile(profile, { path: credentialsPath });
109+
const settings = readConfigFileSettings(profile, { env, path: options.configPath });
45110

46111
// Empty / whitespace-only env vars are treated as unset so they do not
47112
// short-circuit the `??` chain (e.g. `export TESTSPRITE_API_URL=` or
@@ -54,7 +119,12 @@ export function loadConfig(options: LoadConfigOptions = {}): Config {
54119
const envApiKey = normalizeEnvVar(env.TESTSPRITE_API_KEY);
55120

56121
return {
57-
apiUrl: options.endpointUrl ?? envApiUrl ?? fileEntry?.apiUrl ?? DEFAULT_API_URL,
122+
apiUrl:
123+
options.endpointUrl ??
124+
envApiUrl ??
125+
fileEntry?.apiUrl ??
126+
settings.endpointUrl ??
127+
DEFAULT_API_URL,
58128
apiKey: envApiKey ?? fileEntry?.apiKey,
59129
profile,
60130
};

src/lib/credentials.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,20 +100,39 @@ const CREDENTIALS_LOCK_RETRY_MS = 25;
100100
const CREDENTIALS_LOCK_WAIT_MS = 5_000;
101101
const CREDENTIALS_LOCK_STALE_MS = 30_000;
102102

103-
export function parseCredentials(content: string): CredentialsFile {
104-
const result: CredentialsFile = {};
105-
let currentEntry: ProfileEntry | null = null;
103+
/**
104+
* Generic hardened INI walk shared by the credentials file and the settings
105+
* config file (`~/.testsprite/config`). Returns every `[section]`'s raw
106+
* key=value pairs; callers map the keys they understand.
107+
*/
108+
export function parseIniFile(content: string): Record<string, Record<string, string>> {
109+
// Null-prototype accumulator so a `[__proto__]` / `[constructor]` section
110+
// cannot alias a shared prototype object and let the following key=value
111+
// lines pollute every object in the process (prototype-pollution hardening).
112+
// The result is copied into a plain object on return for caller back-compat.
113+
const result: Record<string, Record<string, string>> = Object.create(null);
114+
let currentEntry: Record<string, string> | null = null;
106115
for (const rawLine of content.split('\n')) {
107116
const line = rawLine.trim();
108117
if (line === '' || line.startsWith('#') || line.startsWith(';')) continue;
109118
const sectionMatch = /^\[([^\]]+)\]$/.exec(line);
110119
if (sectionMatch) {
111120
const sectionName = sectionMatch[1]!.trim();
121+
// Defense in depth alongside the null-prototype accumulator: never treat a
122+
// prototype-polluting key as a profile section. Skip its key=value lines.
123+
if (
124+
sectionName === '__proto__' ||
125+
sectionName === 'constructor' ||
126+
sectionName === 'prototype'
127+
) {
128+
currentEntry = null;
129+
continue;
130+
}
112131
const existing = result[sectionName];
113132
if (existing) {
114133
currentEntry = existing;
115134
} else {
116-
const newEntry: ProfileEntry = {};
135+
const newEntry: Record<string, string> = Object.create(null) as Record<string, string>;
117136
result[sectionName] = newEntry;
118137
currentEntry = newEntry;
119138
}
@@ -124,8 +143,27 @@ export function parseCredentials(content: string): CredentialsFile {
124143
if (eqIndex < 0) continue;
125144
const rawKey = line.slice(0, eqIndex).trim();
126145
const rawValue = line.slice(eqIndex + 1).trim();
127-
const field = FILE_KEY_TO_FIELD[rawKey];
128-
if (field) currentEntry[field] = rawValue;
146+
// Same guard for keys: `__proto__ = x` must never become a property write
147+
// on a shared prototype when a caller copies the section into a plain map.
148+
if (rawKey === '__proto__' || rawKey === 'constructor' || rawKey === 'prototype') continue;
149+
currentEntry[rawKey] = rawValue;
150+
}
151+
// Return plain objects so callers (and test matchers) see normal prototypes.
152+
const plain: Record<string, Record<string, string>> = {};
153+
for (const [name, entry] of Object.entries(result)) plain[name] = { ...entry };
154+
return plain;
155+
}
156+
157+
export function parseCredentials(content: string): CredentialsFile {
158+
const sections = parseIniFile(content);
159+
const result: CredentialsFile = {};
160+
for (const [name, keyValues] of Object.entries(sections)) {
161+
const entry: ProfileEntry = {};
162+
for (const [rawKey, rawValue] of Object.entries(keyValues)) {
163+
const field = FILE_KEY_TO_FIELD[rawKey];
164+
if (field) entry[field] = rawValue;
165+
}
166+
result[name] = entry;
129167
}
130168
return result;
131169
}

0 commit comments

Comments
 (0)