-
Notifications
You must be signed in to change notification settings - Fork 113
Expand file tree
/
Copy pathindex.ts
More file actions
373 lines (354 loc) · 16.2 KB
/
Copy pathindex.ts
File metadata and controls
373 lines (354 loc) · 16.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
#!/usr/bin/env node
import { Command, CommanderError } from 'commander';
import { createAgentCommand } from './commands/agent.js';
import { createAuthCommand } from './commands/auth.js';
import { createCompletionCommand, type CompletionSpec } from './commands/completion.js';
import { createDoctorCommand } from './commands/doctor.js';
import {
createDeprecatedInitCommand,
createSetupCommand,
runConfigureViaSetup,
} from './commands/init.js';
import { createProjectCommand } from './commands/project.js';
import { createTestCommand } from './commands/test.js';
import { createUsageCommand } from './commands/usage.js';
import { readConfigFileSettings } from './lib/config.js';
import { ApiError, CLIError, InterruptError, RequestTimeoutError } from './lib/errors.js';
import { installBrokenPipeGuard, installSignalHandlers } from './lib/interrupt.js';
import { Output, isOutputMode } from './lib/output.js';
import { maybeInstallProxyAgent } from './lib/proxy.js';
import { renderCommanderError, rephraseUnknownOption } from './lib/render-error.js';
import { maybeEmitSkillNudge } from './lib/skill-nudge.js';
import { maybeNotifyUpdate } from './lib/update-check.js';
import { VERSION } from './version.js';
import { shouldRejectNodeVersion } from './version-guard.js';
// Guard: exit early with a clear message on unsupported Node.js versions,
// rather than failing later with a cryptic ESM/runtime error.
if (shouldRejectNodeVersion(process.versions.node)) {
process.stderr.write(
`Error: testsprite requires Node.js >= 20 (found ${process.versions.node}).\nInstall the latest LTS from https://nodejs.org\n`,
);
process.exit(1);
}
const program = new Command();
/**
* Profile used for CONFIG-FILE DEFAULTS. The `--output` default must be
* computed before Commander parses argv, so honor the same precedence the
* real resolution uses (`--profile` flag > TESTSPRITE_PROFILE > "default")
* by peeking argv for the flag. Both `--profile <name>` and `--profile=<name>`
* spellings are recognized; anything unparseable falls back down the chain.
*/
function profileForDefaults(): string {
const argv = process.argv;
const flagIndex = argv.indexOf('--profile');
const next = flagIndex !== -1 ? argv[flagIndex + 1] : undefined;
if (typeof next === 'string' && next.length > 0 && !next.startsWith('-')) return next;
const inline = argv.find(arg => arg.startsWith('--profile='));
const inlineValue = inline?.slice('--profile='.length);
if (typeof inlineValue === 'string' && inlineValue.length > 0) return inlineValue;
return process.env.TESTSPRITE_PROFILE ?? 'default';
}
/**
* Default for the global `--output` flag: the `output` key of the selected
* profile's section in `~/.testsprite/config` when present. An explicit
* `--output` flag still wins; an invalid or absent value falls back to
* 'text' (the historical default).
*/
function configFileOutputDefault(): string {
const settings = readConfigFileSettings(profileForDefaults());
return isOutputMode(settings.output) ? settings.output : 'text';
}
// exitOverride() causes Commander to throw CommanderError instead of calling
// process.exit() directly, giving our catch block a chance to remap error
// exit codes (e.g. missing-argument → exit 5 per taxonomy).
program.exitOverride();
program
.name('testsprite')
.description('Official TestSprite command-line interface')
.version(VERSION)
.option('--output <mode>', 'Output format (json|text)', configFileOutputDefault())
.option('--profile <name>', 'Configuration profile to use')
.option('--endpoint-url <url>', 'Override the API endpoint host')
.option(
'--verbose',
'Emit human-readable HTTP retry / backoff / polling-mode transitions to stderr. Less noisy than --debug; useful for diagnosing hangs without the full trace.',
)
.option('--debug', 'Print HTTP method/path, request id, latency, retry decisions to stderr')
.option(
'--dry-run',
'Skip the network and credentials; emit a canned sample matching the OpenAPI contract. Useful for learning the CLI surface without an API key. Note: file inputs you pass (--plan-from/--plans/--steps) are still read and validated locally; only --code-file uses a placeholder.',
)
.option(
'--request-timeout <seconds>',
'Client-side per-request timeout in seconds (default: 120). Aborts any single fetch that does not complete within this deadline. ' +
'Override via TESTSPRITE_REQUEST_TIMEOUT_MS env var (milliseconds). ' +
'Range: 1–600. Does not affect the --timeout polling ceiling for `test run/wait`.',
);
// `setup` is the primary onboarding command, listed FIRST in --help so a coding
// agent reaches it before anything else. `init` is kept as a hidden, deprecated
// alias (invisible to --help; still works for existing scripts/agents).
program.addCommand(createSetupCommand({}));
program.addCommand(createDeprecatedInitCommand({}), { hidden: true });
// `auth configure` is a hidden, deprecated alias that runs FULL `setup`
// (configure + skill install), so an agent reaching for the old command still
// ends up with the skill. `setup` remains the ONLY path that writes credentials.
const authCommand = createAuthCommand();
authCommand
.command('configure', { hidden: true })
.option(
'--from-env',
'Read TESTSPRITE_API_KEY (and optionally TESTSPRITE_API_URL) from the environment instead of prompting',
false,
)
.action(async (cmdOpts: { fromEnv?: boolean }, command: Command) => {
process.stderr.write(
'[deprecated] `testsprite auth configure` now runs full setup (configure + skill install) — ' +
'use `testsprite setup` (add --no-agent to skip the skill).\n',
);
await runConfigureViaSetup(command, {}, Boolean(cmdOpts.fromEnv));
});
program.addCommand(authCommand);
program.addCommand(createProjectCommand({}));
program.addCommand(createTestCommand());
program.addCommand(createAgentCommand({}));
program.addCommand(createUsageCommand());
program.addCommand(createDoctorCommand());
program.addCommand(createCompletionCommand(() => buildCompletionSpec()));
// Derive the shell-completion spec from the fully-assembled command tree at call
// time (not module-load), so `testsprite completion` can never drift from the
// real commands, subcommands, and global flags.
function buildCompletionSpec(): CompletionSpec {
const subcommands: Record<string, string[]> = {};
for (const command of program.commands) {
const subs = command.commands.map(sub => sub.name()).filter(name => name !== 'help');
if (subs.length > 0) subcommands[command.name()] = subs;
}
const flags = program.options
.map(option => option.long)
.filter((long): long is string => typeof long === 'string');
if (!flags.includes('--help')) flags.push('--help');
return {
program: 'testsprite',
commands: [...new Set([...program.commands.map(command => command.name()), 'help'])],
subcommands,
globalFlags: flags,
};
}
// Buffer Commander error messages instead of writing immediately. The catch
// block re-emits in the correct format (JSON or text) once the requested
// output mode is known. Safe because applyExitOverrideDeep ensures every
// command throws CommanderError rather than calling process.exit directly.
let pendingCommanderErrorMsg: string | null = null;
// Propagate exitOverride AND the buffered outputError config to every
// subcommand in the tree. Commander's addCommand() does NOT inherit either
// from the parent, so commands built externally (createTestCommand, etc.) and
// attached via addCommand() still have _exitCallback = null and a default
// outputError that writes immediately. Both must be applied after the full
// command tree is assembled so every leaf subcommand behaves consistently.
function applyExitOverrideDeep(cmd: Command): void {
cmd.exitOverride();
cmd.configureOutput({
outputError(str, _write) {
const rephrased = rephraseUnknownOption(str);
pendingCommanderErrorMsg = rephrased !== null ? `${rephrased}\n` : str;
},
});
for (const child of cmd.commands) {
applyExitOverrideDeep(child);
}
}
applyExitOverrideDeep(program);
/**
* Render a leaf command's full path (group + leaf), e.g. `test run` /
* `auth whoami`, by walking parents up to (but not including) the root program.
*/
function commandPathOf(cmd: Command): string {
const names: string[] = [];
let cur: Command | null = cmd;
while (cur && cur.parent) {
names.unshift(cur.name());
cur = cur.parent;
}
return names.join(' ');
}
// Best-effort onboarding nudge (see lib/skill-nudge.ts): when a configured
// caller drives a verify-loop command in a project with no installed skill,
// point it at `testsprite setup`. A preAction hook runs before every leaf
// action; the helper self-gates (text-only, non-dry-run, a small command
// allowlist, opt-out via TESTSPRITE_NO_SKILL_WARNING) and never throws.
program.hook('preAction', (_thisCommand, actionCommand) => {
const globals = actionCommand.optsWithGlobals() as {
output?: string;
profile?: string;
dryRun?: boolean;
};
const commandPath = commandPathOf(actionCommand);
maybeEmitSkillNudge({
commandPath,
output: isOutputMode(globals.output) ? globals.output : 'text',
dryRun: globals.dryRun ?? false,
profile: globals.profile ?? 'default',
cwd: process.cwd(),
env: process.env,
});
// Best-effort update notice (see lib/update-check.ts): self-gates on the
// opt-out env, CI, TTY, and a 24h cache; the wiring adds the flag-level
// gates the lib cannot see. Skipped for `completion` (its stdout is eval'd
// by shells), under --output json, and under --dry-run. Deliberately not
// awaited: an advisory must never delay the real command.
if (globals.output !== 'json' && globals.dryRun !== true && commandPath !== 'completion') {
void maybeNotifyUpdate();
}
});
// Clean process lifecycle (DEV-331 piece 1, errors.md §8.1): during a `--wait`
// poll the scope is armed — the first SIGINT/SIGTERM/SIGHUP aborts gracefully
// and the wait path prints an honest partial (the run KEEPS executing and
// billing server-side) + re-attach hint before exiting 128+signum; a second
// signal hard-exits. Outside an armed scope: a clear one-line message +
// immediate exit (instead of Node's silent abrupt kill). Plus an EPIPE guard
// so piping to a reader that closes early (`| head`) exits cleanly instead of
// dumping a raw `write EPIPE` stack.
installSignalHandlers();
installBrokenPipeGuard();
// Corporate/CI proxies: honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (Node's fetch
// ignores them by default). No-op when no proxy variable is set.
maybeInstallProxyAgent();
try {
await program.parseAsync(process.argv);
} catch (err) {
const rawMode = program.opts<{ output?: string }>().output;
const mode = isOutputMode(rawMode) ? rawMode : 'text';
if (err instanceof ApiError) {
if (mode === 'json') {
const envelope = {
error: {
code: err.code,
message: err.message,
nextAction: err.nextAction,
requestId: err.requestId,
details: err.details,
},
};
process.stderr.write(`${JSON.stringify(envelope, null, 2)}\n`);
} else {
process.stderr.write(`Error: ${err.message}\n`);
if (err.nextAction) process.stderr.write(`${err.nextAction}\n`);
if (err.requestId && err.requestId !== 'local')
process.stderr.write(`requestId: ${err.requestId}\n`);
// C1: surface requiredScopes / grantedScopes on AUTH_FORBIDDEN
if (err.code === 'AUTH_FORBIDDEN') {
const required = err.getDetail('requiredScopes');
const granted = err.getDetail('grantedScopes');
if (Array.isArray(required) && required.length > 0) {
process.stderr.write(` required: ${(required as string[]).join(', ')}\n`);
}
if (Array.isArray(granted)) {
process.stderr.write(` granted: ${(granted as string[]).join(', ')}\n`);
}
}
// Surface the version gap on CLIENT_TOO_OLD so the user sees exactly what
// moved without parsing the message string. (No "latest" line — the npm
// update-notice is the single source of truth for the newest release.)
if (err.code === 'CLIENT_TOO_OLD') {
const your = err.getDetail('yourVersion');
const min = err.getDetail('minVersion');
if (typeof your === 'string' && typeof min === 'string') {
process.stderr.write(` your version: ${your}, minimum supported: ${min}\n`);
}
}
}
process.exit(err.exitCode);
}
const output = new Output(mode);
if (err instanceof InterruptError) {
// Graceful detach (DEV-331 piece 1, errors.md §8.1): the wait-path catch
// block already printed the honest partial + re-attach hint. Exit with
// the conventional 128+signum code; `INTERRUPTED` is deliberately outside
// the error catalog. Note: Ctrl-C does NOT cancel the server-side run.
if (mode === 'json') {
const envelope = {
error: {
code: 'INTERRUPTED',
message: err.message,
nextAction:
'The server-side run (if any) keeps executing and billing. ' +
'Re-attach with: testsprite test wait <runId>, or stop it with: testsprite test cancel <runId> ' +
'(runId is in the partial JSON on stdout).',
requestId: 'local',
details: { signal: err.signal },
},
};
process.stderr.write(`${JSON.stringify(envelope, null, 2)}\n`);
} else {
process.stderr.write(`Error: ${err.message}\n`);
}
process.exit(err.exitCode);
}
if (err instanceof RequestTimeoutError) {
// Structured rendering for per-request timeouts: JSON mode emits a
// machine-readable envelope; text mode emits the message with a hint.
if (mode === 'json') {
const envelope = {
error: {
code: 'REQUEST_TIMEOUT',
message: err.message,
nextAction:
'Increase --request-timeout <seconds> or set TESTSPRITE_REQUEST_TIMEOUT_MS. ' +
'Check that the backend is reachable and not overloaded.',
requestId: err.requestId,
details: { timeoutMs: err.timeoutMs },
},
};
process.stderr.write(`${JSON.stringify(envelope, null, 2)}\n`);
} else {
process.stderr.write(`Error: ${err.message}\n`);
}
process.exit(err.exitCode);
}
if (err instanceof CommanderError) {
// Map exit codes per the CLI taxonomy:
// help / version → 0 (user asked for it; Commander already wrote the text)
// parse errors → 5 (VALIDATION_ERROR family: missing arg, invalid
// option, unknown command, etc.)
//
// Two distinct help codes exist in Commander 12:
// 'commander.helpDisplayed' — thrown by `-h/--help` flag handler
// 'commander.help' — thrown by the built-in `help [command]`
// subcommand (used by `test help`, `project
// help`, `help test`, etc.)
// Both are user-initiated "show me help" requests and must exit 0 per the
// AWS-CLI convention. Failing to map 'commander.help' caused these paths
// to fall through to the generic `process.exit(5)` branch (dogfood P1-4).
if (
err.code === 'commander.helpDisplayed' ||
err.code === 'commander.help' ||
err.code === 'commander.version'
) {
process.exit(0);
}
// For parse errors, write the buffered message in the correct format.
// rawMode from program.opts() is reliable when --output was parsed before
// the error. When the error occurs first (e.g. `testsprite badcmd --output
// json`), rawMode is the default 'text'; scan argv as a best-effort
// fallback so machine consumers still receive a JSON envelope.
const commanderMode = (() => {
if (rawMode === 'json') return 'json' as const;
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i]!;
if (arg === '--output' && process.argv[i + 1] === 'json') return 'json' as const;
if (arg === '--output=json') return 'json' as const;
}
return mode;
})();
process.stderr.write(
renderCommanderError(pendingCommanderErrorMsg, err.message, commanderMode),
);
process.exit(5);
}
if (err instanceof CLIError) {
output.error(err.message);
process.exit(err.exitCode);
}
output.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}