Skip to content

Commit 681cb57

Browse files
committed
feat(config): read ~/.testsprite/config for persistent defaults (endpoint_url, output, project_id)
1 parent e53257d commit 681cb57

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
@@ -65,7 +65,7 @@ import { assertNotLocal } from '../lib/target-url.js';
6565
import { createTicker } from '../lib/ticker.js';
6666
import { RateThrottle } from '../lib/rate-throttle.js';
6767
import { resolvePortalBase, resolvePortalUrl } from '../lib/facade.js';
68-
import { loadConfig } from '../lib/config.js';
68+
import { loadConfig, readConfigFileSettings } from '../lib/config.js';
6969

7070
/**
7171
* `details` debug block per the CLI OpenAPI `Test` schema
@@ -444,7 +444,7 @@ export async function runList(opts: ListOptions, deps: TestDeps = {}): Promise<P
444444
// (exit 3) when the caller also lacks a configured key. Order matters
445445
// for the CLI error spec §2 — bad input is a caller bug, not an auth
446446
// gate.
447-
requireProjectId(opts.projectId);
447+
opts.projectId = requireProjectId(opts.projectId, opts.profile);
448448

449449
const paginationFlags: PaginationFlags = validatePaginationFlags({
450450
pageSize: opts.pageSize,
@@ -671,7 +671,7 @@ export async function runCreate(
671671
assertChainedRunKeyFits(opts.run, opts.idempotencyKey);
672672
// Validate inputs before touching credentials or fs — matches the
673673
// M2 read commands' "input gates first, then auth, then I/O" ordering.
674-
requireProjectId(opts.projectId);
674+
opts.projectId = requireProjectId(opts.projectId, opts.profile);
675675
requireNonEmpty('name', opts.name);
676676
// P1-3: client-side length checks matching server limits (name ≤200,
677677
// description ≤2000) so the user gets instant, actionable errors instead
@@ -5097,7 +5097,7 @@ export async function runTestRunAll(
50975097
deps: TestDeps = {},
50985098
): Promise<BatchRunFreshResponse | undefined> {
50995099
assertIdempotencyKey(opts.idempotencyKey);
5100-
requireProjectId(opts.projectId);
5100+
opts.projectId = requireProjectId(opts.projectId, opts.profile);
51015101
if (
51025102
!Number.isInteger(opts.maxConcurrency) ||
51035103
opts.maxConcurrency < 1 ||
@@ -7772,10 +7772,25 @@ interface StepsFlagOpts {
77727772
runId?: string;
77737773
}
77747774

7775-
function requireProjectId(projectId: string): void {
7776-
if (typeof projectId !== 'string' || projectId.length === 0) {
7777-
throw localValidationError('project', 'is required');
7775+
/**
7776+
* Resolve the effective project id: the `--project` flag when set, otherwise
7777+
* the `project_id` persisted in the settings config file
7778+
* (`~/.testsprite/config`), so a repo/agent can pin the project once instead
7779+
* of repeating it per command. Throws VALIDATION_ERROR (exit 5) when neither
7780+
* is present.
7781+
*/
7782+
function requireProjectId(projectId: string | undefined, profile = 'default'): string {
7783+
const resolved =
7784+
typeof projectId === 'string' && projectId.length > 0
7785+
? projectId
7786+
: readConfigFileSettings(profile).projectId;
7787+
if (typeof resolved !== 'string' || resolved.length === 0) {
7788+
throw localValidationError(
7789+
'project',
7790+
'is required (pass --project or set project_id in ~/.testsprite/config)',
7791+
);
77787792
}
7793+
return resolved;
77797794
}
77807795

77817796
/**

src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { createProjectCommand } from './commands/project.js';
1111
import { createTestCommand } from './commands/test.js';
1212
import { createUsageCommand } from './commands/usage.js';
13+
import { readConfigFileSettings } from './lib/config.js';
1314
import { ApiError, CLIError, RequestTimeoutError } from './lib/errors.js';
1415
import { Output, isOutputMode } from './lib/output.js';
1516
import { renderCommanderError, rephraseUnknownOption } from './lib/render-error.js';
@@ -18,6 +19,17 @@ import { VERSION } from './version.js';
1819

1920
const program = new Command();
2021

22+
/**
23+
* Default for the global `--output` flag: the `output` key of the profile's
24+
* section in `~/.testsprite/config` when present. Flag values and the
25+
* env-selected profile still win; an invalid or absent value falls back to
26+
* 'text' (the historical default).
27+
*/
28+
function configFileOutputDefault(): string {
29+
const settings = readConfigFileSettings(process.env.TESTSPRITE_PROFILE ?? 'default');
30+
return isOutputMode(settings.output) ? settings.output : 'text';
31+
}
32+
2133
// exitOverride() causes Commander to throw CommanderError instead of calling
2234
// process.exit() directly, giving our catch block a chance to remap error
2335
// exit codes (e.g. missing-argument → exit 5 per taxonomy).
@@ -27,7 +39,7 @@ program
2739
.name('testsprite')
2840
.description('Official TestSprite command-line interface')
2941
.version(VERSION)
30-
.option('--output <mode>', 'Output format (json|text)', 'text')
42+
.option('--output <mode>', 'Output format (json|text)', configFileOutputDefault())
3143
.option('--profile <name>', 'Configuration profile to use')
3244
.option('--endpoint-url <url>', 'Override the API endpoint host')
3345
.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;
@@ -88,3 +88,53 @@ describe('defaultConfigPath', () => {
8888
expect(defaultConfigPath()).toBe(join(homedir(), '.testsprite', 'config'));
8989
});
9090
});
91+
92+
describe('readConfigFileSettings + the config-file layer of loadConfig', () => {
93+
function writeConfigFile(content: string): string {
94+
const path = join(mkdtempSync(join(tmpdir(), 'testsprite-config-')), 'config');
95+
writeFileSync(path, content, 'utf8');
96+
return path;
97+
}
98+
99+
it('reads endpoint_url/output/project_id from the profile section', () => {
100+
const path = writeConfigFile(
101+
'[default]\nendpoint_url = https://selfhosted.example.com\noutput = json\nproject_id = project_cfg\n',
102+
);
103+
expect(readConfigFileSettings('default', { path })).toEqual({
104+
endpointUrl: 'https://selfhosted.example.com',
105+
output: 'json',
106+
projectId: 'project_cfg',
107+
});
108+
// A different profile section is not leaked into.
109+
expect(readConfigFileSettings('other', { path })).toEqual({});
110+
});
111+
112+
it('missing file and unknown keys are non-fatal', () => {
113+
expect(readConfigFileSettings('default', { path: '/nope/definitely/missing' })).toEqual({});
114+
const path = writeConfigFile('[default]\nmystery_key = x\n');
115+
expect(readConfigFileSettings('default', { path })).toEqual({});
116+
});
117+
118+
it('resolves the path from TESTSPRITE_CONFIG_FILE when no explicit path is given', () => {
119+
const path = writeConfigFile('[default]\nproject_id = project_from_env_path\n');
120+
const settings = readConfigFileSettings('default', {
121+
env: { TESTSPRITE_CONFIG_FILE: path },
122+
});
123+
expect(settings.projectId).toBe('project_from_env_path');
124+
});
125+
126+
it('loadConfig: config-file endpoint_url sits above the built-in default and below env', () => {
127+
const path = writeConfigFile('[default]\nendpoint_url = https://cfg.example.com\n');
128+
const missingCreds = join(mkdtempSync(join(tmpdir(), 'testsprite-creds-')), 'credentials');
129+
// Only the config file supplies a URL -> it wins over the built-in default.
130+
const fromConfig = loadConfig({ env: {}, credentialsPath: missingCreds, configPath: path });
131+
expect(fromConfig.apiUrl).toBe('https://cfg.example.com');
132+
// Env still outranks the config file.
133+
const fromEnv = loadConfig({
134+
env: { TESTSPRITE_API_URL: 'https://env.example.com' },
135+
credentialsPath: missingCreds,
136+
configPath: path,
137+
});
138+
expect(fromEnv.apiUrl).toBe('https://env.example.com');
139+
});
140+
});

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';
@@ -21,25 +29,87 @@ export function defaultConfigPath(): string {
2129
return join(homedir(), '.testsprite', 'config');
2230
}
2331

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

41106
return {
42-
apiUrl: options.endpointUrl ?? env.TESTSPRITE_API_URL ?? fileEntry?.apiUrl ?? DEFAULT_API_URL,
107+
apiUrl:
108+
options.endpointUrl ??
109+
env.TESTSPRITE_API_URL ??
110+
fileEntry?.apiUrl ??
111+
settings.endpointUrl ??
112+
DEFAULT_API_URL,
43113
apiKey: env.TESTSPRITE_API_KEY ?? fileEntry?.apiKey,
44114
profile,
45115
};

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)