Skip to content

Commit 1726a9b

Browse files
committed
feat(config): read ~/.testsprite/config for persistent defaults (endpoint_url, output, project_id)
1 parent 3305dfa commit 1726a9b

5 files changed

Lines changed: 205 additions & 20 deletions

File tree

src/commands/test.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ import { assertNotLocal } from '../lib/target-url.js';
8181
import { createTicker } from '../lib/ticker.js';
8282
import { RateThrottle } from '../lib/rate-throttle.js';
8383
import { resolvePortalBase, resolvePortalUrl } from '../lib/facade.js';
84-
import { loadConfig } from '../lib/config.js';
84+
import { loadConfig, readConfigFileSettings } from '../lib/config.js';
8585
import {
8686
flakyExitCode,
8787
renderFlakyText,
@@ -481,7 +481,7 @@ export async function runList(opts: ListOptions, deps: TestDeps = {}): Promise<P
481481
// (exit 3) when the caller also lacks a configured key. Order matters
482482
// for the CLI error spec §2 — bad input is a caller bug, not an auth
483483
// gate.
484-
requireProjectId(opts.projectId);
484+
opts.projectId = requireProjectId(opts.projectId, opts.profile);
485485

486486
const paginationFlags: PaginationFlags = validatePaginationFlags({
487487
pageSize: opts.pageSize,
@@ -708,7 +708,7 @@ export async function runCreate(
708708
assertChainedRunKeyFits(opts.run, opts.idempotencyKey);
709709
// Validate inputs before touching credentials or fs — matches the
710710
// M2 read commands' "input gates first, then auth, then I/O" ordering.
711-
requireProjectId(opts.projectId);
711+
opts.projectId = requireProjectId(opts.projectId, opts.profile);
712712
requireNonEmpty('name', opts.name);
713713
// P1-3: client-side length checks matching server limits (name ≤200,
714714
// description ≤2000) so the user gets instant, actionable errors instead
@@ -5869,7 +5869,7 @@ export async function runTestRunAll(
58695869
deps: TestDeps = {},
58705870
): Promise<BatchRunFreshResponse | undefined> {
58715871
assertIdempotencyKey(opts.idempotencyKey);
5872-
requireProjectId(opts.projectId);
5872+
opts.projectId = requireProjectId(opts.projectId, opts.profile);
58735873
if (
58745874
!Number.isInteger(opts.maxConcurrency) ||
58755875
opts.maxConcurrency < 1 ||
@@ -9059,10 +9059,25 @@ interface StepsFlagOpts {
90599059
runId?: string;
90609060
}
90619061

9062-
function requireProjectId(projectId: string): void {
9063-
if (typeof projectId !== 'string' || projectId.length === 0) {
9064-
throw localValidationError('project', 'is required');
9062+
/**
9063+
* Resolve the effective project id: the `--project` flag when set, otherwise
9064+
* the `project_id` persisted in the settings config file
9065+
* (`~/.testsprite/config`), so a repo/agent can pin the project once instead
9066+
* of repeating it per command. Throws VALIDATION_ERROR (exit 5) when neither
9067+
* is present.
9068+
*/
9069+
function requireProjectId(projectId: string | undefined, profile = 'default'): string {
9070+
const resolved =
9071+
typeof projectId === 'string' && projectId.length > 0
9072+
? projectId
9073+
: readConfigFileSettings(profile).projectId;
9074+
if (typeof resolved !== 'string' || resolved.length === 0) {
9075+
throw localValidationError(
9076+
'project',
9077+
'is required (pass --project or set project_id in ~/.testsprite/config)',
9078+
);
90659079
}
9080+
return resolved;
90669081
}
90679082

90689083
/**

src/index.ts

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

3435
const program = new Command();
3536

37+
/**
38+
* Default for the global `--output` flag: the `output` key of the profile's
39+
* section in `~/.testsprite/config` when present. Flag values and the
40+
* env-selected profile still win; an invalid or absent value falls back to
41+
* 'text' (the historical default).
42+
*/
43+
function configFileOutputDefault(): string {
44+
const settings = readConfigFileSettings(process.env.TESTSPRITE_PROFILE ?? 'default');
45+
return isOutputMode(settings.output) ? settings.output : 'text';
46+
}
47+
3648
// exitOverride() causes Commander to throw CommanderError instead of calling
3749
// process.exit() directly, giving our catch block a chance to remap error
3850
// exit codes (e.g. missing-argument → exit 5 per taxonomy).
@@ -42,7 +54,7 @@ program
4254
.name('testsprite')
4355
.description('Official TestSprite command-line interface')
4456
.version(VERSION)
45-
.option('--output <mode>', 'Output format (json|text)', 'text')
57+
.option('--output <mode>', 'Output format (json|text)', configFileOutputDefault())
4658
.option('--profile <name>', 'Configuration profile to use')
4759
.option('--endpoint-url <url>', 'Override the API endpoint host')
4860
.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;
@@ -110,3 +110,53 @@ describe('defaultConfigPath', () => {
110110
expect(defaultConfigPath()).toBe(join(homedir(), '.testsprite', 'config'));
111111
});
112112
});
113+
114+
describe('readConfigFileSettings + the config-file layer of loadConfig', () => {
115+
function writeConfigFile(content: string): string {
116+
const path = join(mkdtempSync(join(tmpdir(), 'testsprite-config-')), 'config');
117+
writeFileSync(path, content, 'utf8');
118+
return path;
119+
}
120+
121+
it('reads endpoint_url/output/project_id from the profile section', () => {
122+
const path = writeConfigFile(
123+
'[default]\nendpoint_url = https://selfhosted.example.com\noutput = json\nproject_id = project_cfg\n',
124+
);
125+
expect(readConfigFileSettings('default', { path })).toEqual({
126+
endpointUrl: 'https://selfhosted.example.com',
127+
output: 'json',
128+
projectId: 'project_cfg',
129+
});
130+
// A different profile section is not leaked into.
131+
expect(readConfigFileSettings('other', { path })).toEqual({});
132+
});
133+
134+
it('missing file and unknown keys are non-fatal', () => {
135+
expect(readConfigFileSettings('default', { path: '/nope/definitely/missing' })).toEqual({});
136+
const path = writeConfigFile('[default]\nmystery_key = x\n');
137+
expect(readConfigFileSettings('default', { path })).toEqual({});
138+
});
139+
140+
it('resolves the path from TESTSPRITE_CONFIG_FILE when no explicit path is given', () => {
141+
const path = writeConfigFile('[default]\nproject_id = project_from_env_path\n');
142+
const settings = readConfigFileSettings('default', {
143+
env: { TESTSPRITE_CONFIG_FILE: path },
144+
});
145+
expect(settings.projectId).toBe('project_from_env_path');
146+
});
147+
148+
it('loadConfig: config-file endpoint_url sits above the built-in default and below env', () => {
149+
const path = writeConfigFile('[default]\nendpoint_url = https://cfg.example.com\n');
150+
const missingCreds = join(mkdtempSync(join(tmpdir(), 'testsprite-creds-')), 'credentials');
151+
// Only the config file supplies a URL -> it wins over the built-in default.
152+
const fromConfig = loadConfig({ env: {}, credentialsPath: missingCreds, configPath: path });
153+
expect(fromConfig.apiUrl).toBe('https://cfg.example.com');
154+
// Env still outranks the config file.
155+
const fromEnv = loadConfig({
156+
env: { TESTSPRITE_API_URL: 'https://env.example.com' },
157+
credentialsPath: missingCreds,
158+
configPath: path,
159+
});
160+
expect(fromEnv.apiUrl).toBe('https://env.example.com');
161+
});
162+
});

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 ?? 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=` in a shell
@@ -50,7 +115,12 @@ export function loadConfig(options: LoadConfigOptions = {}): Config {
50115
const envApiKey = normalizeEnvVar(env.TESTSPRITE_API_KEY);
51116

52117
return {
53-
apiUrl: options.endpointUrl ?? envApiUrl ?? fileEntry?.apiUrl ?? DEFAULT_API_URL,
118+
apiUrl:
119+
options.endpointUrl ??
120+
envApiUrl ??
121+
fileEntry?.apiUrl ??
122+
settings.endpointUrl ??
123+
DEFAULT_API_URL,
54124
apiKey: envApiKey ?? fileEntry?.apiKey,
55125
profile,
56126
};

src/lib/credentials.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,20 +71,39 @@ const FIELD_TO_FILE_KEY: Record<keyof ProfileEntry, string> = {
7171
apiUrl: 'api_url',
7272
};
7373

74-
export function parseCredentials(content: string): CredentialsFile {
75-
const result: CredentialsFile = {};
76-
let currentEntry: ProfileEntry | null = null;
74+
/**
75+
* Generic hardened INI walk shared by the credentials file and the settings
76+
* config file (`~/.testsprite/config`). Returns every `[section]`'s raw
77+
* key=value pairs; callers map the keys they understand.
78+
*/
79+
export function parseIniFile(content: string): Record<string, Record<string, string>> {
80+
// Null-prototype accumulator so a `[__proto__]` / `[constructor]` section
81+
// cannot alias a shared prototype object and let the following key=value
82+
// lines pollute every object in the process (prototype-pollution hardening).
83+
// The result is copied into a plain object on return for caller back-compat.
84+
const result: Record<string, Record<string, string>> = Object.create(null);
85+
let currentEntry: Record<string, string> | null = null;
7786
for (const rawLine of content.split('\n')) {
7887
const line = rawLine.trim();
7988
if (line === '' || line.startsWith('#') || line.startsWith(';')) continue;
8089
const sectionMatch = /^\[([^\]]+)\]$/.exec(line);
8190
if (sectionMatch) {
8291
const sectionName = sectionMatch[1]!.trim();
92+
// Defense in depth alongside the null-prototype accumulator: never treat a
93+
// prototype-polluting key as a profile section. Skip its key=value lines.
94+
if (
95+
sectionName === '__proto__' ||
96+
sectionName === 'constructor' ||
97+
sectionName === 'prototype'
98+
) {
99+
currentEntry = null;
100+
continue;
101+
}
83102
const existing = result[sectionName];
84103
if (existing) {
85104
currentEntry = existing;
86105
} else {
87-
const newEntry: ProfileEntry = {};
106+
const newEntry: Record<string, string> = Object.create(null) as Record<string, string>;
88107
result[sectionName] = newEntry;
89108
currentEntry = newEntry;
90109
}
@@ -95,8 +114,27 @@ export function parseCredentials(content: string): CredentialsFile {
95114
if (eqIndex < 0) continue;
96115
const rawKey = line.slice(0, eqIndex).trim();
97116
const rawValue = line.slice(eqIndex + 1).trim();
98-
const field = FILE_KEY_TO_FIELD[rawKey];
99-
if (field) currentEntry[field] = rawValue;
117+
// Same guard for keys: `__proto__ = x` must never become a property write
118+
// on a shared prototype when a caller copies the section into a plain map.
119+
if (rawKey === '__proto__' || rawKey === 'constructor' || rawKey === 'prototype') continue;
120+
currentEntry[rawKey] = rawValue;
121+
}
122+
// Return plain objects so callers (and test matchers) see normal prototypes.
123+
const plain: Record<string, Record<string, string>> = {};
124+
for (const [name, entry] of Object.entries(result)) plain[name] = { ...entry };
125+
return plain;
126+
}
127+
128+
export function parseCredentials(content: string): CredentialsFile {
129+
const sections = parseIniFile(content);
130+
const result: CredentialsFile = {};
131+
for (const [name, keyValues] of Object.entries(sections)) {
132+
const entry: ProfileEntry = {};
133+
for (const [rawKey, rawValue] of Object.entries(keyValues)) {
134+
const field = FILE_KEY_TO_FIELD[rawKey];
135+
if (field) entry[field] = rawValue;
136+
}
137+
result[name] = entry;
100138
}
101139
return result;
102140
}

0 commit comments

Comments
 (0)