1+ import { existsSync , readFileSync } from 'node:fs' ;
12import { homedir } from 'node:os' ;
23import { 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
511export 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
1826const 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 */
40104export 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 } ;
0 commit comments