@coldtea/qa 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js ADDED
@@ -0,0 +1,4760 @@
1
+ // ../cli-core/src/exitCode.ts
2
+ var EXIT_PASS = 0;
3
+ var EXIT_FAIL = 1;
4
+ var EXIT_COULDNT_RUN = 2;
5
+ var EXIT_CONFLICT = 3;
6
+ var EXIT_CONFIG = 4;
7
+ function assertNever(value) {
8
+ throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
9
+ }
10
+ var AUTH_ERROR_CODES = /* @__PURE__ */ new Set([
11
+ "unauthorized",
12
+ "insufficient_scope"
13
+ ]);
14
+ var CALLER_ERROR_CODES = /* @__PURE__ */ new Set([
15
+ "not_found",
16
+ "invalid_request",
17
+ "invalid_cursor",
18
+ "payload_too_large"
19
+ ]);
20
+ function exitCodeFor(input) {
21
+ switch (input.kind) {
22
+ case "success":
23
+ return EXIT_PASS;
24
+ case "api_error": {
25
+ if (AUTH_ERROR_CODES.has(input.code)) {
26
+ return EXIT_CONFIG;
27
+ }
28
+ if (input.conflictCodes?.has(input.code)) {
29
+ return EXIT_CONFLICT;
30
+ }
31
+ if (CALLER_ERROR_CODES.has(input.code)) {
32
+ return EXIT_CONFIG;
33
+ }
34
+ if (input.status === 401 || input.status === 403) {
35
+ return EXIT_CONFIG;
36
+ }
37
+ if (input.status === 409) {
38
+ return EXIT_CONFLICT;
39
+ }
40
+ if (input.status === 400 || input.status === 404 || input.status === 413) {
41
+ return EXIT_CONFIG;
42
+ }
43
+ return EXIT_COULDNT_RUN;
44
+ }
45
+ case "config_error":
46
+ return EXIT_CONFIG;
47
+ default:
48
+ return assertNever(input);
49
+ }
50
+ }
51
+
52
+ // ../cli-core/src/http.ts
53
+ var DEFAULT_BASE_URL = "https://www.coldtea.ai";
54
+ var BASE_URL_ENV_VAR = "COLDTEA_BASE_URL";
55
+ var REQUEST_ID_HEADER = "X-Coldtea-Request-Id";
56
+ function baseUrlIsSetButEmpty(env) {
57
+ const raw = env[BASE_URL_ENV_VAR];
58
+ return raw !== void 0 && raw.trim() === "";
59
+ }
60
+ function resolveBaseUrl(env = process.env) {
61
+ const override = env[BASE_URL_ENV_VAR]?.trim();
62
+ if (!override) {
63
+ return DEFAULT_BASE_URL;
64
+ }
65
+ return override.replace(/\/+$/, "");
66
+ }
67
+ var ALLOW_INSECURE_HTTP_ENV_VAR = "COLDTEA_ALLOW_INSECURE_HTTP";
68
+ function isLoopbackHost(hostname) {
69
+ const host = hostname.replace(/\.$/, "").toLowerCase();
70
+ if (host === "localhost" || host === "0.0.0.0") {
71
+ return true;
72
+ }
73
+ if (/^127(\.\d{1,3}){3}$/.test(host)) {
74
+ return true;
75
+ }
76
+ if (host.startsWith("[") && host.endsWith("]")) {
77
+ const v6 = host.slice(1, -1);
78
+ return v6 === "::1" || /^::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}$/.test(v6);
79
+ }
80
+ return false;
81
+ }
82
+ function urlIsInsecure(raw, allowHttpAnywhere = false) {
83
+ let url;
84
+ try {
85
+ url = new URL(raw);
86
+ } catch {
87
+ return true;
88
+ }
89
+ if (url.protocol === "https:") {
90
+ return false;
91
+ }
92
+ if (url.protocol !== "http:") {
93
+ return true;
94
+ }
95
+ return allowHttpAnywhere ? false : !isLoopbackHost(url.hostname);
96
+ }
97
+ function allowsInsecureHttp(env = process.env) {
98
+ return env[ALLOW_INSECURE_HTTP_ENV_VAR]?.trim() === "1";
99
+ }
100
+ function baseUrlIsInsecure(env = process.env) {
101
+ return urlIsInsecure(resolveBaseUrl(env), allowsInsecureHttp(env));
102
+ }
103
+ function isRecord(value) {
104
+ return typeof value === "object" && value !== null && !Array.isArray(value);
105
+ }
106
+ function readMeta(payload) {
107
+ const meta = isRecord(payload.meta) ? payload.meta : {};
108
+ return {
109
+ requestId: typeof meta.requestId === "string" ? meta.requestId : null,
110
+ nextCursor: typeof meta.nextCursor === "string" ? meta.nextCursor : null,
111
+ meta
112
+ };
113
+ }
114
+ function invalidResponse(message, requestId) {
115
+ return {
116
+ ok: false,
117
+ status: 0,
118
+ error: { code: "invalid_response", message, details: null },
119
+ requestId
120
+ };
121
+ }
122
+ async function v1Request(input) {
123
+ const fetchImpl = input.fetchImpl ?? fetch;
124
+ const baseUrl = input.baseUrl ?? DEFAULT_BASE_URL;
125
+ const url = `${baseUrl}${input.path}`;
126
+ const headers = {
127
+ Authorization: `Bearer ${input.apiKey}`
128
+ };
129
+ if (input.body !== void 0) {
130
+ headers["Content-Type"] = "application/json";
131
+ }
132
+ let response;
133
+ try {
134
+ response = await fetchImpl(url, {
135
+ method: input.method,
136
+ headers,
137
+ body: input.body === void 0 ? void 0 : JSON.stringify(input.body)
138
+ });
139
+ } catch (error) {
140
+ const message = error instanceof Error ? error.message : String(error);
141
+ return {
142
+ ok: false,
143
+ status: 0,
144
+ error: {
145
+ code: "network_error",
146
+ message: `Could not reach ${baseUrl}: ${message}`,
147
+ details: null
148
+ },
149
+ requestId: null
150
+ };
151
+ }
152
+ const headerRequestId = response.headers.get(REQUEST_ID_HEADER);
153
+ if (response.status === 204) {
154
+ return {
155
+ ok: true,
156
+ status: 204,
157
+ data: void 0,
158
+ nextCursor: null,
159
+ // A 204 has no body, so there is no `meta` to carry — the request id
160
+ // lives in the header alone.
161
+ meta: {},
162
+ requestId: headerRequestId
163
+ };
164
+ }
165
+ let payload;
166
+ try {
167
+ payload = await response.json();
168
+ } catch {
169
+ return invalidResponse(
170
+ `Expected JSON from ${url} but got a non-JSON ${response.status} response`,
171
+ headerRequestId
172
+ );
173
+ }
174
+ if (!isRecord(payload)) {
175
+ return invalidResponse(
176
+ `The ${response.status} response from ${url} is not a /v1 envelope`,
177
+ headerRequestId
178
+ );
179
+ }
180
+ const meta = readMeta(payload);
181
+ const requestId = meta.requestId ?? headerRequestId;
182
+ if (!response.ok) {
183
+ const error = isRecord(payload.error) ? payload.error : {};
184
+ return {
185
+ ok: false,
186
+ status: response.status,
187
+ error: {
188
+ code: typeof error.code === "string" ? error.code : "invalid_response",
189
+ message: typeof error.message === "string" ? error.message : `Request failed with status ${response.status}`,
190
+ details: error.details ?? null
191
+ },
192
+ requestId
193
+ };
194
+ }
195
+ if (!("data" in payload)) {
196
+ return invalidResponse(
197
+ `The ${response.status} response from ${url} is not a /v1 envelope`,
198
+ requestId
199
+ );
200
+ }
201
+ return {
202
+ ok: true,
203
+ status: response.status,
204
+ data: payload.data,
205
+ nextCursor: meta.nextCursor,
206
+ meta: meta.meta,
207
+ requestId
208
+ };
209
+ }
210
+
211
+ // ../cli-core/src/output.ts
212
+ function shouldUseColor(env, stdoutIsTTY) {
213
+ if ("NO_COLOR" in env) {
214
+ return false;
215
+ }
216
+ return stdoutIsTTY;
217
+ }
218
+ function canPrompt(stdinIsTTY, stdoutIsTTY) {
219
+ return stdinIsTTY && stdoutIsTTY;
220
+ }
221
+ var Output = class {
222
+ json;
223
+ colorEnabled;
224
+ interactive;
225
+ /**
226
+ * Whether stdin is a terminal, separately from {@link interactive}.
227
+ *
228
+ * `interactive` is stdin AND stdout, which is the right question for "may I
229
+ * prompt". It is the WRONG question for "did someone pipe me a secret":
230
+ * `coldtea-qa credentials create … > out.txt` has a terminal stdin and a
231
+ * redirected stdout, so `interactive` is false while there is nothing to
232
+ * read — and a command that read stdin there would hang.
233
+ */
234
+ stdinIsTTY;
235
+ stdout;
236
+ stderr;
237
+ constructor(options) {
238
+ const env = options.env ?? process.env;
239
+ this.json = options.json;
240
+ this.stdout = options.stdout;
241
+ this.stderr = options.stderr;
242
+ this.colorEnabled = shouldUseColor(env, options.stdoutIsTTY ?? false);
243
+ this.stdinIsTTY = options.stdinIsTTY ?? false;
244
+ this.interactive = canPrompt(this.stdinIsTTY, options.stdoutIsTTY ?? false);
245
+ }
246
+ color(code, text) {
247
+ return this.colorEnabled ? `\x1B[${code}m${text}\x1B[0m` : text;
248
+ }
249
+ bold(text) {
250
+ return this.color("1", text);
251
+ }
252
+ dim(text) {
253
+ return this.color("2", text);
254
+ }
255
+ red(text) {
256
+ return this.color("31", text);
257
+ }
258
+ /** Machine output: the exact payload, one document, stdout. */
259
+ data(payload) {
260
+ this.stdout.write(`${JSON.stringify(payload, null, 2)}
261
+ `);
262
+ }
263
+ /**
264
+ * A SUCCESS, IN THE SAME ENVELOPE THE API SENDS: `{ data, meta }`.
265
+ *
266
+ * `--json` used to unwrap to `data` alone, which threw `meta` away — so a
267
+ * SUCCESSFUL call could not give you a request id, the one thing the
268
+ * reference tells you to quote when reporting a problem, while a failing
269
+ * one could. Errors already used `{ error, meta }`; matching it here means
270
+ * one reader handles every response a Coldtea CLI emits and every response
271
+ * the API does.
272
+ */
273
+ success(data, meta) {
274
+ this.data({ data, meta });
275
+ }
276
+ /** Human output line. No-op under --json so stdout stays parseable. */
277
+ line(text = "") {
278
+ if (!this.json) {
279
+ this.stdout.write(`${text}
280
+ `);
281
+ }
282
+ }
283
+ /**
284
+ * An error, to stderr in both modes. `requestId` is printed whenever the
285
+ * server gave one, so it can be quoted in support.
286
+ *
287
+ * `meta.requestId`, MATCHING THE API'S OWN ENVELOPE. This emitted a
288
+ * top-level `requestId` while `/v1` errors and every success this class
289
+ * writes carry `{ …, meta: { requestId } }` — so a caller who wrote one
290
+ * reader for the API's errors needed a second, different one for the
291
+ * CLI's, over the exact field the contract tells everyone to quote. Fixed
292
+ * before anything built on this file ships: after npm, this shape is
293
+ * frozen.
294
+ */
295
+ error(error, requestId = null) {
296
+ if (this.json) {
297
+ this.stderr.write(`${JSON.stringify({ error, meta: { requestId } })}
298
+ `);
299
+ return;
300
+ }
301
+ this.stderr.write(
302
+ `${this.red("error")} ${error.message} (${error.code})
303
+ `
304
+ );
305
+ if (requestId) {
306
+ this.stderr.write(`${this.dim(`request id: ${requestId}`)}
307
+ `);
308
+ }
309
+ }
310
+ /**
311
+ * A human-readable line elaborating an error (e.g. one row per 409
312
+ * conflict), to stderr. No-op under --json: there the whole error —
313
+ * details included — is already the single JSON document on stderr, and
314
+ * extra prose would corrupt it.
315
+ */
316
+ errorNote(text) {
317
+ if (!this.json) {
318
+ this.stderr.write(`${text}
319
+ `);
320
+ }
321
+ }
322
+ };
323
+
324
+ // ../cli-core/src/table.ts
325
+ function renderTable(headers, rows) {
326
+ const widths = headers.map(
327
+ (header, column) => Math.max(header.length, ...rows.map((row) => (row[column] ?? "").length))
328
+ );
329
+ const renderRow = (cells) => cells.map(
330
+ (cell2, column) => (
331
+ // The last column is never padded: no trailing whitespace.
332
+ column === widths.length - 1 ? cell2 : cell2.padEnd(widths[column])
333
+ )
334
+ ).join(" ").trimEnd();
335
+ return [renderRow(headers), ...rows.map(renderRow)].join("\n");
336
+ }
337
+
338
+ // ../cli-core/src/config.ts
339
+ var API_KEY_ENV_VAR = "COLDTEA_API_KEY";
340
+ var API_KEY_PREFIX = "coldtea_sk_";
341
+ function resolveApiKey(input) {
342
+ const env = input.env ?? process.env;
343
+ const raw = input.flag ?? env[API_KEY_ENV_VAR];
344
+ const apiKey = raw?.trim();
345
+ if (!apiKey) {
346
+ return {
347
+ ok: false,
348
+ message: `No API key. Set ${API_KEY_ENV_VAR} or pass --api-key. Keys are created in the Coldtea app, under Settings \u2192 API Keys.`
349
+ };
350
+ }
351
+ if (!apiKey.startsWith(API_KEY_PREFIX)) {
352
+ return {
353
+ ok: false,
354
+ message: `That does not look like a Coldtea API key: it should start with ${API_KEY_PREFIX}.`
355
+ };
356
+ }
357
+ return { ok: true, apiKey };
358
+ }
359
+
360
+ // ../cli-core/src/cli.ts
361
+ import { parseArgs } from "node:util";
362
+
363
+ // ../cli-core/src/shared.ts
364
+ function requireApiKey(context, output) {
365
+ const key = resolveApiKey({ flag: context.flags.apiKey, env: context.env });
366
+ if (!key.ok) {
367
+ output.error({ code: "config_error", message: key.message, details: null });
368
+ return { ok: false, code: exitCodeFor({ kind: "config_error" }) };
369
+ }
370
+ return { ok: true, value: key.apiKey };
371
+ }
372
+ function usageError(output, message) {
373
+ output.error({ code: "config_error", message, details: null });
374
+ return exitCodeFor({ kind: "config_error" });
375
+ }
376
+ function isRetryableFailure(result) {
377
+ return result.status === 0 || result.status >= 500;
378
+ }
379
+ function apiFailure(output, result, options = {}) {
380
+ output.error(result.error, result.requestId);
381
+ if (result.error.code === "invalid_request" && isRecord2(result.error.details)) {
382
+ const issues = result.error.details.issues;
383
+ if (Array.isArray(issues)) {
384
+ for (const issue of issues.filter(isRecord2)) {
385
+ if (typeof issue.path === "string" && typeof issue.message === "string") {
386
+ output.errorNote(` ${issue.path}: ${issue.message}`);
387
+ }
388
+ }
389
+ }
390
+ }
391
+ if (result.error.code === "insufficient_scope" && isRecord2(result.error.details)) {
392
+ const { required, granted } = result.error.details;
393
+ if (typeof required === "string") {
394
+ const holds = Array.isArray(granted) && granted.length > 0 ? granted.join(", ") : "nothing";
395
+ output.errorNote(` Needs ${required}; this key holds ${holds}.`);
396
+ output.errorNote(
397
+ " A key's access cannot be changed, only replaced. Create one in the Coldtea app under Settings \u2192 API Keys."
398
+ );
399
+ if (options.scopeNote !== void 0) {
400
+ output.errorNote(` ${options.scopeNote}`);
401
+ }
402
+ }
403
+ }
404
+ return exitCodeFor({
405
+ kind: "api_error",
406
+ status: result.status,
407
+ code: result.error.code,
408
+ conflictCodes: options.conflictCodes
409
+ });
410
+ }
411
+ function api(context, apiKey, input) {
412
+ return v1Request({
413
+ ...input,
414
+ apiKey,
415
+ baseUrl: context.baseUrl,
416
+ fetchImpl: context.fetchImpl
417
+ });
418
+ }
419
+ function isRecord2(value) {
420
+ return typeof value === "object" && value !== null && !Array.isArray(value);
421
+ }
422
+ function asString(value) {
423
+ return typeof value === "string" ? value : null;
424
+ }
425
+ function asNumber(value) {
426
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
427
+ }
428
+ function cell(value) {
429
+ if (value === null || value === void 0) {
430
+ return "\u2014";
431
+ }
432
+ return String(value);
433
+ }
434
+ function formatEpoch(value) {
435
+ const ms = asNumber(value);
436
+ if (ms === null) {
437
+ return "\u2014";
438
+ }
439
+ const date = new Date(ms);
440
+ return Number.isNaN(date.getTime()) ? "\u2014" : date.toISOString();
441
+ }
442
+ function emitListJson(output, result) {
443
+ output.data({
444
+ data: result.data,
445
+ meta: {
446
+ ...result.meta,
447
+ nextCursor: result.nextCursor,
448
+ ...result.requestId === null ? {} : { requestId: result.requestId }
449
+ }
450
+ });
451
+ }
452
+ function emitJson(output, result) {
453
+ output.data({
454
+ data: result.data,
455
+ meta: {
456
+ ...result.meta,
457
+ ...result.requestId === null ? {} : { requestId: result.requestId }
458
+ }
459
+ });
460
+ }
461
+ function nextCursorFootnote(output, nextCursor) {
462
+ if (nextCursor !== null) {
463
+ output.line();
464
+ output.line(output.dim(`More pages: --cursor ${nextCursor}`));
465
+ }
466
+ }
467
+ var LIST_FLAGS = {
468
+ limit: { type: "string" },
469
+ cursor: { type: "string" }
470
+ };
471
+ function listQuery(values) {
472
+ const parts = [];
473
+ if (typeof values.limit === "string") {
474
+ parts.push(`limit=${encodeURIComponent(values.limit)}`);
475
+ }
476
+ if (typeof values.cursor === "string") {
477
+ parts.push(`cursor=${encodeURIComponent(values.cursor)}`);
478
+ }
479
+ return parts;
480
+ }
481
+
482
+ // ../cli-core/src/cli.ts
483
+ var UNIVERSAL_FLAGS = {
484
+ json: { type: "boolean", default: false },
485
+ "api-key": { type: "string" },
486
+ // NO SHORT ALIASES, AND NO `version` AT ALL. In non-strict mode
487
+ // `parseArgs` expands ANY single-dash token into shorts, so with
488
+ // `short: "h"` on help, `tasks create "Title" -dash` read as -d -a -s -h,
489
+ // set help, printed usage and exited 0 — a green CI step that filed
490
+ // nothing. `-h`/`-v`/`--version` are bare-invocation tokens matched
491
+ // EXACTLY (see hasGlobalFlag); after a command, `-v` is an unknown
492
+ // option and exit 4, the same answer `git status --version` gives.
493
+ help: { type: "boolean", default: false }
494
+ };
495
+ function optionValueIndices(tokens) {
496
+ const consumed = /* @__PURE__ */ new Set();
497
+ for (const token of tokens ?? []) {
498
+ if (token.kind === "option" && token.inlineValue === false && token.value !== void 0) {
499
+ consumed.add(token.index + 1);
500
+ }
501
+ }
502
+ return consumed;
503
+ }
504
+ function hasGlobalFlag(argv, consumed, ...tokens) {
505
+ const terminator = argv.indexOf("--");
506
+ const end = terminator === -1 ? argv.length : terminator;
507
+ for (let index = 0; index < end; index += 1) {
508
+ if (!consumed.has(index) && tokens.includes(argv[index])) {
509
+ return true;
510
+ }
511
+ }
512
+ return false;
513
+ }
514
+ function defaultSleep(ms) {
515
+ return new Promise((resolve) => setTimeout(resolve, ms));
516
+ }
517
+ async function defaultReadStdin() {
518
+ const chunks = [];
519
+ for await (const chunk of process.stdin) {
520
+ chunks.push(Buffer.from(chunk));
521
+ }
522
+ return Buffer.concat(chunks).toString("utf8");
523
+ }
524
+ async function defaultReadLine(question) {
525
+ const { createInterface } = await import("node:readline/promises");
526
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
527
+ try {
528
+ return await rl.question(question);
529
+ } finally {
530
+ rl.close();
531
+ }
532
+ }
533
+ function runCliFrom(definition) {
534
+ const globalOptions = {
535
+ ...UNIVERSAL_FLAGS,
536
+ ...definition.globalFlags ?? {}
537
+ };
538
+ function unionOptions() {
539
+ const options = { ...globalOptions };
540
+ for (const def of definition.commands) {
541
+ for (const [name, spec] of Object.entries(def.flags)) {
542
+ options[name] = spec;
543
+ }
544
+ }
545
+ return options;
546
+ }
547
+ function findCommand(positionals) {
548
+ for (const length of [2, 1]) {
549
+ const candidate = positionals.slice(0, length);
550
+ if (candidate.length < length) {
551
+ continue;
552
+ }
553
+ for (const def of definition.commands) {
554
+ if (def.words.length === length && def.words.every((word, index) => word === candidate[index])) {
555
+ return def;
556
+ }
557
+ }
558
+ }
559
+ return null;
560
+ }
561
+ function familyHelp(word) {
562
+ const def = definition.commands.find(
563
+ (candidate) => candidate.words[0] === word
564
+ );
565
+ return def ? def.help : null;
566
+ }
567
+ return async function runCli2(argv, io = {}) {
568
+ const env = io.env ?? process.env;
569
+ const stdout = io.stdout ?? process.stdout;
570
+ const stderr = io.stderr ?? process.stderr;
571
+ const stdoutIsTTY = io.stdoutIsTTY ?? Boolean(process.stdout.isTTY);
572
+ const stdinIsTTY = io.stdinIsTTY ?? Boolean(process.stdin.isTTY);
573
+ const loose = parseArgs({
574
+ args: argv,
575
+ options: unionOptions(),
576
+ allowPositionals: true,
577
+ strict: false,
578
+ tokens: true
579
+ });
580
+ const consumed = optionValueIndices(loose.tokens);
581
+ const [first, second] = loose.positionals;
582
+ if (first === void 0) {
583
+ if (hasGlobalFlag(argv, consumed, "--version", "-v")) {
584
+ stdout.write(`${definition.version}
585
+ `);
586
+ return exitCodeFor({ kind: "success" });
587
+ }
588
+ if (hasGlobalFlag(argv, consumed, "--help", "-h")) {
589
+ stdout.write(definition.usage);
590
+ return exitCodeFor({ kind: "success" });
591
+ }
592
+ stderr.write(definition.usage);
593
+ return exitCodeFor({ kind: "config_error" });
594
+ }
595
+ if (first === "help") {
596
+ const help = second === void 0 ? definition.usage : familyHelp(second) ?? null;
597
+ if (help === null) {
598
+ stderr.write(`Unknown command "${second}"
599
+
600
+ ${definition.usage}`);
601
+ return exitCodeFor({ kind: "config_error" });
602
+ }
603
+ stdout.write(help);
604
+ return exitCodeFor({ kind: "success" });
605
+ }
606
+ const def = findCommand(loose.positionals);
607
+ if (def === null) {
608
+ const help = familyHelp(first);
609
+ if (help !== null && second === void 0 && hasGlobalFlag(argv, consumed, "--help")) {
610
+ stdout.write(help);
611
+ return exitCodeFor({ kind: "success" });
612
+ }
613
+ if (help !== null && second === void 0 && hasGlobalFlag(argv, consumed, "-h", "-v")) {
614
+ stderr.write(
615
+ `Unknown option. After a command, spell flags out: --help.
616
+
617
+ ${help}`
618
+ );
619
+ return exitCodeFor({ kind: "config_error" });
620
+ }
621
+ if (help !== null) {
622
+ stderr.write(
623
+ `Unknown command "${loose.positionals.slice(0, 2).join(" ")}"
624
+
625
+ ${help}`
626
+ );
627
+ } else {
628
+ stderr.write(`Unknown command "${first}"
629
+
630
+ ${definition.usage}`);
631
+ }
632
+ return exitCodeFor({ kind: "config_error" });
633
+ }
634
+ let parsed;
635
+ try {
636
+ parsed = parseArgs({
637
+ args: argv,
638
+ options: { ...globalOptions, ...def.flags },
639
+ allowPositionals: true,
640
+ strict: true
641
+ });
642
+ } catch (error) {
643
+ stderr.write(
644
+ `${error instanceof Error ? error.message : String(error)}
645
+
646
+ ${def.help}`
647
+ );
648
+ return exitCodeFor({ kind: "config_error" });
649
+ }
650
+ if (parsed.values.help === true) {
651
+ stdout.write(def.help);
652
+ return exitCodeFor({ kind: "success" });
653
+ }
654
+ const output = new Output({
655
+ json: parsed.values.json === true,
656
+ stdout,
657
+ stderr,
658
+ env,
659
+ stdoutIsTTY,
660
+ stdinIsTTY
661
+ });
662
+ if (baseUrlIsSetButEmpty(env)) {
663
+ return usageError(
664
+ output,
665
+ `${BASE_URL_ENV_VAR} is set but empty, which used to mean production. Unset it to use ${DEFAULT_BASE_URL}, or give it a full origin like http://localhost:3003.`
666
+ );
667
+ }
668
+ if (baseUrlIsInsecure(env)) {
669
+ return usageError(
670
+ output,
671
+ `${BASE_URL_ENV_VAR} must be an https origin \u2014 every request sends your API key there. Plain http is allowed only on loopback (localhost, 127.0.0.0/8, [::1]). For an http-only private host, set COLDTEA_ALLOW_INSECURE_HTTP=1 \u2014 knowing the key then travels in plaintext.`
672
+ );
673
+ }
674
+ const apiKeyFlag = typeof parsed.values["api-key"] === "string" ? parsed.values["api-key"] : void 0;
675
+ const base = {
676
+ env,
677
+ flags: {
678
+ json: parsed.values.json === true,
679
+ apiKey: apiKeyFlag
680
+ },
681
+ baseUrl: resolveBaseUrl(env),
682
+ fetchImpl: io.fetchImpl,
683
+ sleep: io.sleepImpl ?? defaultSleep,
684
+ now: io.nowImpl ?? Date.now,
685
+ readLine: io.readLineImpl ?? defaultReadLine,
686
+ readStdin: io.readStdinImpl ?? defaultReadStdin
687
+ };
688
+ const context = definition.buildContext({ base, values: parsed.values });
689
+ return def.run(context, output, {
690
+ values: parsed.values,
691
+ positionals: parsed.positionals.slice(def.words.length)
692
+ });
693
+ };
694
+ }
695
+
696
+ // src/config.ts
697
+ var PROJECT_ID_ENV_VAR = "COLDTEA_PROJECT_ID";
698
+ function resolveProjectId(input) {
699
+ const env = input.env ?? process.env;
700
+ const projectId = (input.flag ?? env[PROJECT_ID_ENV_VAR])?.trim();
701
+ return projectId ? projectId : null;
702
+ }
703
+
704
+ // src/exitCode.ts
705
+ var RUN_OUTCOME_SEVERITY = {
706
+ failed: 5,
707
+ in_progress: 4,
708
+ couldnt_run: 3,
709
+ couldnt_verify: 2,
710
+ warning: 1,
711
+ passed: 0
712
+ };
713
+ var RUN_OUTCOMES = new Set(
714
+ Object.keys(RUN_OUTCOME_SEVERITY)
715
+ );
716
+ function worstOf(outcomes) {
717
+ let worst = "passed";
718
+ for (const outcome of outcomes) {
719
+ if (RUN_OUTCOME_SEVERITY[outcome] > RUN_OUTCOME_SEVERITY[worst]) {
720
+ worst = outcome;
721
+ }
722
+ }
723
+ return worst;
724
+ }
725
+ var QA_CONFLICT_CODES = /* @__PURE__ */ new Set([
726
+ "report_name_taken",
727
+ "repo_already_linked",
728
+ "idempotency_key_reused",
729
+ "project_merged",
730
+ "merge_in_progress",
731
+ "merge_repo_mismatch",
732
+ "environment_in_use",
733
+ "credential_in_use",
734
+ "build_already_confirmed",
735
+ "build_referenced_by_tests"
736
+ ]);
737
+ function exitCodeForOutcome(outcome, failOnWarning) {
738
+ switch (outcome) {
739
+ case "passed":
740
+ return EXIT_PASS;
741
+ case "warning":
742
+ return failOnWarning ? EXIT_FAIL : EXIT_PASS;
743
+ case "failed":
744
+ return EXIT_FAIL;
745
+ case "couldnt_verify":
746
+ return EXIT_COULDNT_RUN;
747
+ case "couldnt_run":
748
+ return EXIT_COULDNT_RUN;
749
+ case "in_progress":
750
+ return EXIT_COULDNT_RUN;
751
+ default:
752
+ return assertNever(outcome);
753
+ }
754
+ }
755
+ function exitCodeFor2(input) {
756
+ switch (input.kind) {
757
+ case "success":
758
+ case "config_error":
759
+ return exitCodeFor(input);
760
+ case "run":
761
+ return exitCodeForOutcome(input.outcome, input.failOnWarning);
762
+ case "batch": {
763
+ const base = exitCodeForOutcome(input.worstOutcome, input.failOnWarning);
764
+ if (base === EXIT_PASS && input.skippedCount > 0) {
765
+ return EXIT_COULDNT_RUN;
766
+ }
767
+ return base;
768
+ }
769
+ case "api_error":
770
+ return exitCodeFor({
771
+ ...input,
772
+ conflictCodes: QA_CONFLICT_CODES
773
+ });
774
+ default:
775
+ return assertNever(input);
776
+ }
777
+ }
778
+
779
+ // src/commands/whoami.ts
780
+ function isStringArray(value) {
781
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
782
+ }
783
+ function isRecord3(value) {
784
+ return typeof value === "object" && value !== null && !Array.isArray(value);
785
+ }
786
+ function parseMeData(data) {
787
+ if (!isRecord3(data)) {
788
+ return null;
789
+ }
790
+ const record = data;
791
+ const apiKey = isRecord3(record.apiKey) ? record.apiKey : null;
792
+ if (typeof record.organizationId !== "string" || typeof record.organizationName !== "string" || apiKey === null || typeof apiKey.name !== "string" || typeof apiKey.prefix !== "string" || typeof apiKey.last4 !== "string" || !isStringArray(apiKey.scopes)) {
793
+ return null;
794
+ }
795
+ return {
796
+ organizationId: record.organizationId,
797
+ organizationName: record.organizationName,
798
+ apiKey: {
799
+ name: apiKey.name,
800
+ prefix: apiKey.prefix,
801
+ last4: apiKey.last4,
802
+ scopes: apiKey.scopes,
803
+ createdAt: apiKey.createdAt,
804
+ lastUsedAt: apiKey.lastUsedAt
805
+ }
806
+ };
807
+ }
808
+ function formatTimestamp(value) {
809
+ if (value === null || value === void 0) {
810
+ return "never";
811
+ }
812
+ if (typeof value !== "number" && typeof value !== "string") {
813
+ return "never";
814
+ }
815
+ const date = new Date(value);
816
+ return Number.isNaN(date.getTime()) ? String(value) : date.toISOString();
817
+ }
818
+ async function whoami(context, output) {
819
+ const key = resolveApiKey({ flag: context.flags.apiKey, env: context.env });
820
+ if (!key.ok) {
821
+ output.error({ code: "config_error", message: key.message, details: null });
822
+ return exitCodeFor2({ kind: "config_error" });
823
+ }
824
+ const result = await v1Request({
825
+ method: "GET",
826
+ path: "/v1/me",
827
+ apiKey: key.apiKey,
828
+ baseUrl: context.baseUrl,
829
+ fetchImpl: context.fetchImpl
830
+ });
831
+ if (!result.ok) {
832
+ output.error(result.error, result.requestId);
833
+ return exitCodeFor2({
834
+ kind: "api_error",
835
+ status: result.status,
836
+ code: result.error.code
837
+ });
838
+ }
839
+ if (output.json) {
840
+ output.success(result.data, {
841
+ ...result.meta,
842
+ ...result.requestId === null ? {} : { requestId: result.requestId }
843
+ });
844
+ return exitCodeFor2({ kind: "success" });
845
+ }
846
+ const me = parseMeData(result.data);
847
+ if (me === null) {
848
+ output.error(
849
+ {
850
+ code: "invalid_response",
851
+ message: "The /v1/me response is missing expected fields",
852
+ details: null
853
+ },
854
+ result.requestId
855
+ );
856
+ return exitCodeFor2({
857
+ kind: "api_error",
858
+ status: 0,
859
+ code: "invalid_response"
860
+ });
861
+ }
862
+ output.line(
863
+ `${output.bold("Organization")} ${me.organizationName} ${output.dim(`(${me.organizationId})`)}`
864
+ );
865
+ output.line(
866
+ `${output.bold("Key")} ${me.apiKey.name} ${output.dim(`(${me.apiKey.prefix}\u2026${me.apiKey.last4})`)}`
867
+ );
868
+ output.line(`${output.bold("Scopes")} ${me.apiKey.scopes.join(", ")}`);
869
+ output.line(
870
+ `${output.bold("Created")} ${formatTimestamp(me.apiKey.createdAt)}`
871
+ );
872
+ output.line(
873
+ `${output.bold("Last used")} ${formatTimestamp(me.apiKey.lastUsedAt)}`
874
+ );
875
+ return exitCodeFor2({ kind: "success" });
876
+ }
877
+
878
+ // src/commands/shared.ts
879
+ function requireApiKey2(context, output) {
880
+ return requireApiKey(context, output);
881
+ }
882
+ function requireProjectId(context, output) {
883
+ if (context.projectId === null) {
884
+ output.error({
885
+ code: "config_error",
886
+ message: `This command needs a project. Pass --project or set ${PROJECT_ID_ENV_VAR}. \`coldtea-qa projects list\` shows the ids.`,
887
+ details: null
888
+ });
889
+ return { ok: false, code: exitCodeFor2({ kind: "config_error" }) };
890
+ }
891
+ return { ok: true, value: context.projectId };
892
+ }
893
+ function usageError2(output, message) {
894
+ return usageError(output, message);
895
+ }
896
+ function apiFailure2(output, result) {
897
+ return apiFailure(output, result, {
898
+ conflictCodes: QA_CONFLICT_CODES,
899
+ scopeNote: "The scopes are qa:read (list and read), qa:write (create, edit, delete, link a repo) and qa:run (start and stop runs)."
900
+ });
901
+ }
902
+ var VCS_FLAGS = {
903
+ "vcs-provider": { type: "string" },
904
+ "vcs-repository-id": { type: "string" },
905
+ "vcs-head-sha": { type: "string" },
906
+ "vcs-pr-number": { type: "string" }
907
+ };
908
+ function readVcsFlags(output, values, options) {
909
+ const names = [
910
+ "vcs-provider",
911
+ "vcs-repository-id",
912
+ "vcs-head-sha",
913
+ "vcs-pr-number"
914
+ ];
915
+ const required = options.requirePrNumber ? names : names.slice(0, 3);
916
+ const passed = (name) => typeof values[name] === "string";
917
+ const read = (name) => typeof values[name] === "string" ? values[name].trim() : "";
918
+ if (!names.some(passed)) {
919
+ return { ok: true, vcs: null };
920
+ }
921
+ if (!required.every(passed)) {
922
+ usageError2(
923
+ output,
924
+ options.requirePrNumber ? "--vcs-provider, --vcs-repository-id, --vcs-head-sha and --vcs-pr-number travel together" : "--vcs-provider, --vcs-repository-id and --vcs-head-sha travel together (pr-number is optional)"
925
+ );
926
+ return { ok: false };
927
+ }
928
+ if (required.some((name) => read(name) === "")) {
929
+ return { ok: true, vcs: null };
930
+ }
931
+ const vcs = {
932
+ provider: read("vcs-provider"),
933
+ repositoryId: read("vcs-repository-id"),
934
+ headSha: read("vcs-head-sha")
935
+ };
936
+ const prNumber = read("vcs-pr-number");
937
+ if (prNumber !== "") {
938
+ const parsed = Number(prNumber);
939
+ if (!Number.isInteger(parsed) || parsed <= 0) {
940
+ usageError2(output, "--vcs-pr-number must be a positive integer");
941
+ return { ok: false };
942
+ }
943
+ vcs.prNumber = parsed;
944
+ }
945
+ return { ok: true, vcs };
946
+ }
947
+
948
+ // src/commands/projects.ts
949
+ var PROJECTS_HELP = `coldtea-qa projects \u2014 manage QA projects
950
+
951
+ Usage
952
+ coldtea-qa projects list [--include-archived] [--limit <n>] [--cursor <c>]
953
+ coldtea-qa projects create <name>
954
+ coldtea-qa projects get <projectId>
955
+ coldtea-qa projects link-repo <projectId> --provider <github|bitbucket> --repository-id <id>
956
+
957
+ Flags
958
+ --include-archived List archived projects too
959
+ --provider Repository provider: github or bitbucket
960
+ --repository-id The provider's stable repository id (not owner/name)
961
+
962
+ Notes
963
+ A project holds at most one repository. Linking over an existing link is
964
+ a conflict (exit 3); unlink first. repository-id is the provider's stable
965
+ id, never a URL.
966
+ `;
967
+ function projectRow(project) {
968
+ const repo = isRecord2(project.repo) ? `${cell(project.repo.provider)}:${cell(project.repo.repositoryId)}` : "\u2014";
969
+ const counts = isRecord2(project.counts) ? project.counts : {};
970
+ return [
971
+ cell(project.id),
972
+ cell(project.name),
973
+ repo,
974
+ cell(counts.tests),
975
+ cell(counts.groups),
976
+ formatEpoch(project.lastRunAt)
977
+ ];
978
+ }
979
+ function printProject(output, project) {
980
+ output.line(
981
+ `${output.bold("Project")} ${cell(project.name)} ${output.dim(`(${cell(project.id)})`)}`
982
+ );
983
+ const repo = isRecord2(project.repo) ? `${cell(project.repo.provider)}:${cell(project.repo.repositoryId)}` : "not linked";
984
+ output.line(`${output.bold("Repo")} ${repo}`);
985
+ const counts = isRecord2(project.counts) ? project.counts : {};
986
+ output.line(`${output.bold("Tests")} ${cell(counts.tests)}`);
987
+ output.line(`${output.bold("Groups")} ${cell(counts.groups)}`);
988
+ output.line(`${output.bold("Created")} ${formatEpoch(project.createdAt)}`);
989
+ output.line(`${output.bold("Last run")} ${formatEpoch(project.lastRunAt)}`);
990
+ if (project.archivedAt !== null && project.archivedAt !== void 0) {
991
+ output.line(
992
+ `${output.bold("Archived")} ${formatEpoch(project.archivedAt)}`
993
+ );
994
+ }
995
+ }
996
+ var projectsList = {
997
+ words: ["projects", "list"],
998
+ summary: "projects list List the organization's projects",
999
+ help: PROJECTS_HELP,
1000
+ flags: {
1001
+ ...LIST_FLAGS,
1002
+ "include-archived": { type: "boolean", default: false }
1003
+ },
1004
+ async run(context, output, args) {
1005
+ const key = requireApiKey2(context, output);
1006
+ if (!key.ok) {
1007
+ return key.code;
1008
+ }
1009
+ const query = listQuery(args.values);
1010
+ if (args.values["include-archived"] === true) {
1011
+ query.push("includeArchived=true");
1012
+ }
1013
+ const suffix = query.length > 0 ? `?${query.join("&")}` : "";
1014
+ const result = await api(context, key.value, {
1015
+ method: "GET",
1016
+ path: `/v1/projects${suffix}`
1017
+ });
1018
+ if (!result.ok) {
1019
+ return apiFailure2(output, result);
1020
+ }
1021
+ if (output.json) {
1022
+ emitListJson(output, result);
1023
+ return exitCodeFor2({ kind: "success" });
1024
+ }
1025
+ const projects = Array.isArray(result.data) ? result.data : [];
1026
+ if (projects.length === 0) {
1027
+ output.line(
1028
+ 'No projects. Create one: coldtea-qa projects create "Storefront"'
1029
+ );
1030
+ } else {
1031
+ output.line(
1032
+ renderTable(
1033
+ ["ID", "NAME", "REPO", "TESTS", "GROUPS", "LAST RUN"],
1034
+ projects.filter(isRecord2).map(projectRow)
1035
+ )
1036
+ );
1037
+ }
1038
+ nextCursorFootnote(output, result.nextCursor);
1039
+ return exitCodeFor2({ kind: "success" });
1040
+ }
1041
+ };
1042
+ var projectsCreate = {
1043
+ words: ["projects", "create"],
1044
+ summary: "projects create <name> Create a project",
1045
+ help: PROJECTS_HELP,
1046
+ flags: {},
1047
+ async run(context, output, args) {
1048
+ const name = args.positionals[0];
1049
+ if (!name) {
1050
+ return usageError2(output, "Usage: coldtea-qa projects create <name>");
1051
+ }
1052
+ const key = requireApiKey2(context, output);
1053
+ if (!key.ok) {
1054
+ return key.code;
1055
+ }
1056
+ const result = await api(context, key.value, {
1057
+ method: "POST",
1058
+ path: "/v1/projects",
1059
+ body: { name }
1060
+ });
1061
+ if (!result.ok) {
1062
+ return apiFailure2(output, result);
1063
+ }
1064
+ if (output.json) {
1065
+ emitJson(output, result);
1066
+ return exitCodeFor2({ kind: "success" });
1067
+ }
1068
+ if (isRecord2(result.data)) {
1069
+ output.line(`Created project ${cell(result.data.id)}`);
1070
+ printProject(output, result.data);
1071
+ }
1072
+ return exitCodeFor2({ kind: "success" });
1073
+ }
1074
+ };
1075
+ var projectsGet = {
1076
+ words: ["projects", "get"],
1077
+ summary: "projects get <id> Show one project",
1078
+ help: PROJECTS_HELP,
1079
+ flags: {},
1080
+ async run(context, output, args) {
1081
+ const projectId = args.positionals[0];
1082
+ if (!projectId) {
1083
+ return usageError2(output, "Usage: coldtea-qa projects get <projectId>");
1084
+ }
1085
+ const key = requireApiKey2(context, output);
1086
+ if (!key.ok) {
1087
+ return key.code;
1088
+ }
1089
+ const result = await api(context, key.value, {
1090
+ method: "GET",
1091
+ path: `/v1/projects/${encodeURIComponent(projectId)}`
1092
+ });
1093
+ if (!result.ok) {
1094
+ return apiFailure2(output, result);
1095
+ }
1096
+ if (output.json) {
1097
+ emitJson(output, result);
1098
+ return exitCodeFor2({ kind: "success" });
1099
+ }
1100
+ if (isRecord2(result.data)) {
1101
+ const answeredId = asString(result.data.id);
1102
+ if (answeredId !== null && answeredId !== projectId) {
1103
+ output.line(
1104
+ output.dim(
1105
+ `${projectId} was merged away \u2014 showing the surviving project ${answeredId}.`
1106
+ )
1107
+ );
1108
+ }
1109
+ printProject(output, result.data);
1110
+ }
1111
+ return exitCodeFor2({ kind: "success" });
1112
+ }
1113
+ };
1114
+ var projectsLinkRepo = {
1115
+ words: ["projects", "link-repo"],
1116
+ summary: "projects link-repo <id> Link a repository (--provider, --repository-id)",
1117
+ help: PROJECTS_HELP,
1118
+ flags: {
1119
+ provider: { type: "string" },
1120
+ "repository-id": { type: "string" }
1121
+ },
1122
+ async run(context, output, args) {
1123
+ const projectId = args.positionals[0];
1124
+ const provider = args.values.provider;
1125
+ const repositoryId = args.values["repository-id"];
1126
+ if (!projectId || typeof provider !== "string" || typeof repositoryId !== "string") {
1127
+ return usageError2(
1128
+ output,
1129
+ "Usage: coldtea-qa projects link-repo <projectId> --provider <github|bitbucket> --repository-id <id>"
1130
+ );
1131
+ }
1132
+ const key = requireApiKey2(context, output);
1133
+ if (!key.ok) {
1134
+ return key.code;
1135
+ }
1136
+ const result = await api(context, key.value, {
1137
+ method: "POST",
1138
+ path: `/v1/projects/${encodeURIComponent(projectId)}/repo`,
1139
+ body: { provider, repositoryId }
1140
+ });
1141
+ if (!result.ok) {
1142
+ const code = apiFailure2(output, result);
1143
+ const details = isRecord2(result.error.details) ? result.error.details : {};
1144
+ if (result.error.code === "report_name_taken" && Array.isArray(details.conflicts)) {
1145
+ for (const conflict of details.conflicts.filter(isRecord2)) {
1146
+ const takenBy = isRecord2(conflict.takenBy) ? `taken by ${cell(conflict.takenBy.projectName)} (${cell(conflict.takenBy.projectId)})` : "taken by another organization";
1147
+ output.errorNote(` \xB7 "${cell(conflict.reportName)}" \u2014 ${takenBy}`);
1148
+ }
1149
+ output.errorNote(
1150
+ " Rename those rules (or unlink on the other side), then link again."
1151
+ );
1152
+ }
1153
+ if (result.error.code === "repo_already_linked" && isRecord2(details.repo)) {
1154
+ output.errorNote(
1155
+ ` Currently linked: ${cell(details.repo.provider)}:${cell(details.repo.repositoryId)}`
1156
+ );
1157
+ }
1158
+ return code;
1159
+ }
1160
+ if (output.json) {
1161
+ emitJson(output, result);
1162
+ return exitCodeFor2({ kind: "success" });
1163
+ }
1164
+ if (isRecord2(result.data)) {
1165
+ output.line(
1166
+ `Linked ${cell(result.data.provider)}:${cell(result.data.repositoryId)} to ${projectId}`
1167
+ );
1168
+ }
1169
+ return exitCodeFor2({ kind: "success" });
1170
+ }
1171
+ };
1172
+ var projectCommands = [
1173
+ projectsList,
1174
+ projectsCreate,
1175
+ projectsGet,
1176
+ projectsLinkRepo
1177
+ ];
1178
+
1179
+ // src/commands/tests.ts
1180
+ var TESTS_HELP = `coldtea-qa tests \u2014 manage QA tests
1181
+
1182
+ Usage
1183
+ coldtea-qa tests list [--group <groupId>] [--limit <n>] [--cursor <c>]
1184
+ coldtea-qa tests create <description> (--url <url> | --deployment | --mobile <android|ios>)
1185
+ [--title <t>] [--group <groupId>]
1186
+ coldtea-qa tests get <testId>
1187
+ coldtea-qa tests update <testId> [--title <t>] [--description <d>]
1188
+ [--url <url>] [--group <groupId>]
1189
+ coldtea-qa tests delete <testId>
1190
+
1191
+ Flags
1192
+ --group On list: only this group's tests. On create/update: the
1193
+ group the test belongs to (update --group moves it).
1194
+ --url create: the page a static_url test opens.
1195
+ update: change a static_url test's page.
1196
+ --deployment create: test the deployment URL of the linked repository
1197
+ --mobile create: test a mobile app build (android or ios)
1198
+ --title Short label (optional; the description is the test)
1199
+ --description update: replace the test's description
1200
+
1201
+ Notes
1202
+ list and create need a project (--project or COLDTEA_PROJECT_ID);
1203
+ get/update/delete work on the test id alone. Exactly one target flag is
1204
+ required on create. Deleting archives the test and cancels its runs.
1205
+ `;
1206
+ function chooseTarget(output, values) {
1207
+ const chosen = [];
1208
+ if (typeof values.url === "string") {
1209
+ chosen.push({ type: "static_url", url: values.url });
1210
+ }
1211
+ if (values.deployment === true) {
1212
+ chosen.push({ type: "deployment_url" });
1213
+ }
1214
+ if (typeof values.mobile === "string") {
1215
+ if (values.mobile !== "android" && values.mobile !== "ios") {
1216
+ usageError2(output, "--mobile must be android or ios");
1217
+ return { ok: false };
1218
+ }
1219
+ chosen.push({ type: "mobile_app", platform: values.mobile });
1220
+ }
1221
+ if (chosen.length !== 1) {
1222
+ usageError2(
1223
+ output,
1224
+ "A test needs exactly one target: --url <url>, --deployment, or --mobile <android|ios>"
1225
+ );
1226
+ return { ok: false };
1227
+ }
1228
+ return { ok: true, target: chosen[0] };
1229
+ }
1230
+ function describeTarget(target) {
1231
+ if (!isRecord2(target)) {
1232
+ return "\u2014";
1233
+ }
1234
+ switch (target.type) {
1235
+ case "static_url":
1236
+ return `url ${cell(target.url)}`;
1237
+ case "deployment_url":
1238
+ return "deployment";
1239
+ case "mobile_app":
1240
+ return `mobile ${cell(target.platform)}`;
1241
+ default:
1242
+ return cell(target.type);
1243
+ }
1244
+ }
1245
+ function testRow(test) {
1246
+ const label = typeof test.title === "string" && test.title !== "" ? test.title : typeof test.description === "string" ? test.description.length > 60 ? `${test.description.slice(0, 57)}\u2026` : test.description : "\u2014";
1247
+ return [
1248
+ cell(test.id),
1249
+ label,
1250
+ describeTarget(test.target),
1251
+ cell(test.groupId),
1252
+ formatEpoch(test.createdAt)
1253
+ ];
1254
+ }
1255
+ function printTest(output, test) {
1256
+ output.line(`${output.bold("Test")} ${cell(test.id)}`);
1257
+ output.line(`${output.bold("Title")} ${cell(test.title)}`);
1258
+ output.line(`${output.bold("Description")} ${cell(test.description)}`);
1259
+ output.line(`${output.bold("Target")} ${describeTarget(test.target)}`);
1260
+ output.line(`${output.bold("Group")} ${cell(test.groupId)}`);
1261
+ output.line(`${output.bold("Project")} ${cell(test.projectId)}`);
1262
+ const trigger = isRecord2(test.trigger) ? cell(test.trigger.type) : "\u2014";
1263
+ output.line(`${output.bold("Trigger")} ${trigger}`);
1264
+ output.line(`${output.bold("Created")} ${formatEpoch(test.createdAt)}`);
1265
+ if (test.archivedAt !== null && test.archivedAt !== void 0) {
1266
+ output.line(
1267
+ `${output.bold("Archived")} ${formatEpoch(test.archivedAt)}`
1268
+ );
1269
+ }
1270
+ }
1271
+ var testsList = {
1272
+ words: ["tests", "list"],
1273
+ summary: "tests list List a project's tests",
1274
+ help: TESTS_HELP,
1275
+ flags: { ...LIST_FLAGS, group: { type: "string" } },
1276
+ async run(context, output, args) {
1277
+ const key = requireApiKey2(context, output);
1278
+ if (!key.ok) {
1279
+ return key.code;
1280
+ }
1281
+ const project = requireProjectId(context, output);
1282
+ if (!project.ok) {
1283
+ return project.code;
1284
+ }
1285
+ const query = listQuery(args.values);
1286
+ if (typeof args.values.group === "string") {
1287
+ query.push(`groupId=${encodeURIComponent(args.values.group)}`);
1288
+ }
1289
+ const suffix = query.length > 0 ? `?${query.join("&")}` : "";
1290
+ const result = await api(context, key.value, {
1291
+ method: "GET",
1292
+ path: `/v1/projects/${encodeURIComponent(project.value)}/tests${suffix}`
1293
+ });
1294
+ if (!result.ok) {
1295
+ return apiFailure2(output, result);
1296
+ }
1297
+ if (output.json) {
1298
+ emitListJson(output, result);
1299
+ return exitCodeFor2({ kind: "success" });
1300
+ }
1301
+ const tests = Array.isArray(result.data) ? result.data : [];
1302
+ if (tests.length === 0) {
1303
+ output.line(
1304
+ 'No tests. Create one: coldtea-qa tests create "<what to check>" --url <url>'
1305
+ );
1306
+ } else {
1307
+ output.line(
1308
+ renderTable(
1309
+ ["ID", "TEST", "TARGET", "GROUP", "CREATED"],
1310
+ tests.filter(isRecord2).map(testRow)
1311
+ )
1312
+ );
1313
+ }
1314
+ nextCursorFootnote(output, result.nextCursor);
1315
+ return exitCodeFor2({ kind: "success" });
1316
+ }
1317
+ };
1318
+ var testsCreate = {
1319
+ words: ["tests", "create"],
1320
+ summary: "tests create <description> Create a test (one target flag)",
1321
+ help: TESTS_HELP,
1322
+ flags: {
1323
+ url: { type: "string" },
1324
+ deployment: { type: "boolean", default: false },
1325
+ mobile: { type: "string" },
1326
+ title: { type: "string" },
1327
+ group: { type: "string" }
1328
+ },
1329
+ async run(context, output, args) {
1330
+ const description = args.positionals[0];
1331
+ if (!description) {
1332
+ return usageError2(
1333
+ output,
1334
+ "Usage: coldtea-qa tests create <description> (--url <url> | --deployment | --mobile <android|ios>)"
1335
+ );
1336
+ }
1337
+ const choice = chooseTarget(output, args.values);
1338
+ if (!choice.ok) {
1339
+ return exitCodeFor2({ kind: "config_error" });
1340
+ }
1341
+ const key = requireApiKey2(context, output);
1342
+ if (!key.ok) {
1343
+ return key.code;
1344
+ }
1345
+ const project = requireProjectId(context, output);
1346
+ if (!project.ok) {
1347
+ return project.code;
1348
+ }
1349
+ const body = {
1350
+ description,
1351
+ target: choice.target
1352
+ };
1353
+ if (typeof args.values.title === "string") {
1354
+ body.title = args.values.title;
1355
+ }
1356
+ if (typeof args.values.group === "string") {
1357
+ body.groupId = args.values.group;
1358
+ }
1359
+ const result = await api(context, key.value, {
1360
+ method: "POST",
1361
+ path: `/v1/projects/${encodeURIComponent(project.value)}/tests`,
1362
+ body
1363
+ });
1364
+ if (!result.ok) {
1365
+ return apiFailure2(output, result);
1366
+ }
1367
+ if (output.json) {
1368
+ emitJson(output, result);
1369
+ return exitCodeFor2({ kind: "success" });
1370
+ }
1371
+ if (isRecord2(result.data)) {
1372
+ output.line(`Created test ${cell(result.data.id)}`);
1373
+ printTest(output, result.data);
1374
+ }
1375
+ return exitCodeFor2({ kind: "success" });
1376
+ }
1377
+ };
1378
+ var testsGet = {
1379
+ words: ["tests", "get"],
1380
+ summary: "tests get <id> Show one test",
1381
+ help: TESTS_HELP,
1382
+ flags: {},
1383
+ async run(context, output, args) {
1384
+ const testId = args.positionals[0];
1385
+ if (!testId) {
1386
+ return usageError2(output, "Usage: coldtea-qa tests get <testId>");
1387
+ }
1388
+ const key = requireApiKey2(context, output);
1389
+ if (!key.ok) {
1390
+ return key.code;
1391
+ }
1392
+ const result = await api(context, key.value, {
1393
+ method: "GET",
1394
+ path: `/v1/tests/${encodeURIComponent(testId)}`
1395
+ });
1396
+ if (!result.ok) {
1397
+ return apiFailure2(output, result);
1398
+ }
1399
+ if (output.json) {
1400
+ emitJson(output, result);
1401
+ return exitCodeFor2({ kind: "success" });
1402
+ }
1403
+ if (isRecord2(result.data)) {
1404
+ printTest(output, result.data);
1405
+ }
1406
+ return exitCodeFor2({ kind: "success" });
1407
+ }
1408
+ };
1409
+ var testsUpdate = {
1410
+ words: ["tests", "update"],
1411
+ summary: "tests update <id> Edit a test; --group moves it",
1412
+ help: TESTS_HELP,
1413
+ flags: {
1414
+ title: { type: "string" },
1415
+ description: { type: "string" },
1416
+ url: { type: "string" },
1417
+ group: { type: "string" }
1418
+ },
1419
+ async run(context, output, args) {
1420
+ const testId = args.positionals[0];
1421
+ if (!testId) {
1422
+ return usageError2(
1423
+ output,
1424
+ "Usage: coldtea-qa tests update <testId> [--title] [--description] [--url] [--group]"
1425
+ );
1426
+ }
1427
+ const body = {};
1428
+ if (typeof args.values.title === "string") {
1429
+ body.title = args.values.title;
1430
+ }
1431
+ if (typeof args.values.description === "string") {
1432
+ body.description = args.values.description;
1433
+ }
1434
+ if (typeof args.values.url === "string") {
1435
+ body.targetUrl = args.values.url;
1436
+ }
1437
+ if (typeof args.values.group === "string") {
1438
+ body.groupId = args.values.group;
1439
+ }
1440
+ if (Object.keys(body).length === 0) {
1441
+ return usageError2(
1442
+ output,
1443
+ "Nothing to update: pass at least one of --title, --description, --url, --group"
1444
+ );
1445
+ }
1446
+ const key = requireApiKey2(context, output);
1447
+ if (!key.ok) {
1448
+ return key.code;
1449
+ }
1450
+ const result = await api(context, key.value, {
1451
+ method: "PATCH",
1452
+ path: `/v1/tests/${encodeURIComponent(testId)}`,
1453
+ body
1454
+ });
1455
+ if (!result.ok) {
1456
+ return apiFailure2(output, result);
1457
+ }
1458
+ if (output.json) {
1459
+ emitJson(output, result);
1460
+ return exitCodeFor2({ kind: "success" });
1461
+ }
1462
+ if (isRecord2(result.data)) {
1463
+ output.line(`Updated test ${cell(result.data.id)}`);
1464
+ printTest(output, result.data);
1465
+ }
1466
+ return exitCodeFor2({ kind: "success" });
1467
+ }
1468
+ };
1469
+ var testsDelete = {
1470
+ words: ["tests", "delete"],
1471
+ summary: "tests delete <id> Archive a test (cancels its runs)",
1472
+ help: TESTS_HELP,
1473
+ flags: {},
1474
+ async run(context, output, args) {
1475
+ const testId = args.positionals[0];
1476
+ if (!testId) {
1477
+ return usageError2(output, "Usage: coldtea-qa tests delete <testId>");
1478
+ }
1479
+ const key = requireApiKey2(context, output);
1480
+ if (!key.ok) {
1481
+ return key.code;
1482
+ }
1483
+ const result = await api(context, key.value, {
1484
+ method: "DELETE",
1485
+ path: `/v1/tests/${encodeURIComponent(testId)}`
1486
+ });
1487
+ if (!result.ok) {
1488
+ return apiFailure2(output, result);
1489
+ }
1490
+ if (output.json) {
1491
+ emitJson(output, result);
1492
+ return exitCodeFor2({ kind: "success" });
1493
+ }
1494
+ output.line(`Archived test ${testId}`);
1495
+ return exitCodeFor2({ kind: "success" });
1496
+ }
1497
+ };
1498
+ var testCommands = [
1499
+ testsList,
1500
+ testsCreate,
1501
+ testsGet,
1502
+ testsUpdate,
1503
+ testsDelete
1504
+ ];
1505
+
1506
+ // src/commands/groups.ts
1507
+ var GROUPS_HELP = `coldtea-qa groups \u2014 manage test groups
1508
+
1509
+ Usage
1510
+ coldtea-qa groups list [--limit <n>] [--cursor <c>]
1511
+ coldtea-qa groups create <name> [--description <d>] [--environment <id>]
1512
+ coldtea-qa groups get <groupId>
1513
+ coldtea-qa groups update <groupId> [--name <n>] [--description <d>]
1514
+ [--environment <id> | --environment none]
1515
+ coldtea-qa groups delete <groupId>
1516
+
1517
+ Flags
1518
+ --environment <id> Where this group's runs open, unless a run overrides it
1519
+ with its own --environment. Pass "none" to clear it and
1520
+ have runs open signed out.
1521
+
1522
+ Notes
1523
+ list and create need a project (--project or COLDTEA_PROJECT_ID);
1524
+ get/update/delete work on the group id alone. Deleting archives the
1525
+ group AND its tests, and cancels their runs.
1526
+ `;
1527
+ function readEnvironmentFlag(value) {
1528
+ if (typeof value !== "string" || value.trim() === "") {
1529
+ return void 0;
1530
+ }
1531
+ return value.trim() === "none" ? null : value.trim();
1532
+ }
1533
+ function groupRow(group) {
1534
+ const settings = isRecord2(group.settings) ? group.settings : {};
1535
+ return [
1536
+ cell(group.id),
1537
+ cell(group.name),
1538
+ cell(group.description),
1539
+ cell(settings.environmentId),
1540
+ formatEpoch(group.createdAt)
1541
+ ];
1542
+ }
1543
+ function printGroup(output, group) {
1544
+ output.line(
1545
+ `${output.bold("Group")} ${cell(group.name)} ${output.dim(`(${cell(group.id)})`)}`
1546
+ );
1547
+ output.line(`${output.bold("Description")} ${cell(group.description)}`);
1548
+ output.line(`${output.bold("Project")} ${cell(group.projectId)}`);
1549
+ const settings = isRecord2(group.settings) ? group.settings : {};
1550
+ output.line(
1551
+ `${output.bold("Environment")} ${settings.environmentId === null ? "none \u2014 runs open signed out" : cell(settings.environmentId)}`
1552
+ );
1553
+ output.line(`${output.bold("Created")} ${formatEpoch(group.createdAt)}`);
1554
+ if (group.archivedAt !== null && group.archivedAt !== void 0) {
1555
+ output.line(
1556
+ `${output.bold("Archived")} ${formatEpoch(group.archivedAt)}`
1557
+ );
1558
+ }
1559
+ }
1560
+ var groupsList = {
1561
+ words: ["groups", "list"],
1562
+ summary: "groups list List a project's groups",
1563
+ help: GROUPS_HELP,
1564
+ flags: { ...LIST_FLAGS },
1565
+ async run(context, output, args) {
1566
+ const key = requireApiKey2(context, output);
1567
+ if (!key.ok) {
1568
+ return key.code;
1569
+ }
1570
+ const project = requireProjectId(context, output);
1571
+ if (!project.ok) {
1572
+ return project.code;
1573
+ }
1574
+ const query = listQuery(args.values);
1575
+ const suffix = query.length > 0 ? `?${query.join("&")}` : "";
1576
+ const result = await api(context, key.value, {
1577
+ method: "GET",
1578
+ path: `/v1/projects/${encodeURIComponent(project.value)}/groups${suffix}`
1579
+ });
1580
+ if (!result.ok) {
1581
+ return apiFailure2(output, result);
1582
+ }
1583
+ if (output.json) {
1584
+ emitListJson(output, result);
1585
+ return exitCodeFor2({ kind: "success" });
1586
+ }
1587
+ const groups = Array.isArray(result.data) ? result.data : [];
1588
+ if (groups.length === 0) {
1589
+ output.line('No groups. Create one: coldtea-qa groups create "Smoke"');
1590
+ } else {
1591
+ output.line(
1592
+ renderTable(
1593
+ ["ID", "NAME", "DESCRIPTION", "ENVIRONMENT", "CREATED"],
1594
+ groups.filter(isRecord2).map(groupRow)
1595
+ )
1596
+ );
1597
+ }
1598
+ nextCursorFootnote(output, result.nextCursor);
1599
+ return exitCodeFor2({ kind: "success" });
1600
+ }
1601
+ };
1602
+ var groupsCreate = {
1603
+ words: ["groups", "create"],
1604
+ summary: "groups create <name> Create a group",
1605
+ help: GROUPS_HELP,
1606
+ flags: {
1607
+ description: { type: "string" },
1608
+ environment: { type: "string" }
1609
+ },
1610
+ async run(context, output, args) {
1611
+ const name = args.positionals[0];
1612
+ if (!name) {
1613
+ return usageError2(output, "Usage: coldtea-qa groups create <name>");
1614
+ }
1615
+ const key = requireApiKey2(context, output);
1616
+ if (!key.ok) {
1617
+ return key.code;
1618
+ }
1619
+ const project = requireProjectId(context, output);
1620
+ if (!project.ok) {
1621
+ return project.code;
1622
+ }
1623
+ const body = { name };
1624
+ if (typeof args.values.description === "string") {
1625
+ body.description = args.values.description;
1626
+ }
1627
+ const environment = readEnvironmentFlag(args.values.environment);
1628
+ if (environment !== void 0) {
1629
+ body.settings = { environmentId: environment };
1630
+ }
1631
+ const result = await api(context, key.value, {
1632
+ method: "POST",
1633
+ path: `/v1/projects/${encodeURIComponent(project.value)}/groups`,
1634
+ body
1635
+ });
1636
+ if (!result.ok) {
1637
+ return apiFailure2(output, result);
1638
+ }
1639
+ if (output.json) {
1640
+ emitJson(output, result);
1641
+ return exitCodeFor2({ kind: "success" });
1642
+ }
1643
+ if (isRecord2(result.data)) {
1644
+ output.line(`Created group ${cell(result.data.id)}`);
1645
+ printGroup(output, result.data);
1646
+ }
1647
+ return exitCodeFor2({ kind: "success" });
1648
+ }
1649
+ };
1650
+ var groupsGet = {
1651
+ words: ["groups", "get"],
1652
+ summary: "groups get <id> Show one group",
1653
+ help: GROUPS_HELP,
1654
+ flags: {},
1655
+ async run(context, output, args) {
1656
+ const groupId = args.positionals[0];
1657
+ if (!groupId) {
1658
+ return usageError2(output, "Usage: coldtea-qa groups get <groupId>");
1659
+ }
1660
+ const key = requireApiKey2(context, output);
1661
+ if (!key.ok) {
1662
+ return key.code;
1663
+ }
1664
+ const result = await api(context, key.value, {
1665
+ method: "GET",
1666
+ path: `/v1/groups/${encodeURIComponent(groupId)}`
1667
+ });
1668
+ if (!result.ok) {
1669
+ return apiFailure2(output, result);
1670
+ }
1671
+ if (output.json) {
1672
+ emitJson(output, result);
1673
+ return exitCodeFor2({ kind: "success" });
1674
+ }
1675
+ if (isRecord2(result.data)) {
1676
+ printGroup(output, result.data);
1677
+ }
1678
+ return exitCodeFor2({ kind: "success" });
1679
+ }
1680
+ };
1681
+ var groupsUpdate = {
1682
+ words: ["groups", "update"],
1683
+ summary: "groups update <id> Rename or re-describe a group",
1684
+ help: GROUPS_HELP,
1685
+ flags: {
1686
+ name: { type: "string" },
1687
+ description: { type: "string" },
1688
+ environment: { type: "string" }
1689
+ },
1690
+ async run(context, output, args) {
1691
+ const groupId = args.positionals[0];
1692
+ if (!groupId) {
1693
+ return usageError2(
1694
+ output,
1695
+ "Usage: coldtea-qa groups update <groupId> [--name] [--description]"
1696
+ );
1697
+ }
1698
+ const body = {};
1699
+ if (typeof args.values.name === "string") {
1700
+ body.name = args.values.name;
1701
+ }
1702
+ if (typeof args.values.description === "string") {
1703
+ body.description = args.values.description;
1704
+ }
1705
+ const environment = readEnvironmentFlag(args.values.environment);
1706
+ if (environment !== void 0) {
1707
+ body.settings = { environmentId: environment };
1708
+ }
1709
+ if (Object.keys(body).length === 0) {
1710
+ return usageError2(
1711
+ output,
1712
+ "Nothing to update: pass --name, --description and/or --environment"
1713
+ );
1714
+ }
1715
+ const key = requireApiKey2(context, output);
1716
+ if (!key.ok) {
1717
+ return key.code;
1718
+ }
1719
+ const result = await api(context, key.value, {
1720
+ method: "PATCH",
1721
+ path: `/v1/groups/${encodeURIComponent(groupId)}`,
1722
+ body
1723
+ });
1724
+ if (!result.ok) {
1725
+ return apiFailure2(output, result);
1726
+ }
1727
+ if (output.json) {
1728
+ emitJson(output, result);
1729
+ return exitCodeFor2({ kind: "success" });
1730
+ }
1731
+ if (isRecord2(result.data)) {
1732
+ output.line(`Updated group ${cell(result.data.id)}`);
1733
+ printGroup(output, result.data);
1734
+ }
1735
+ return exitCodeFor2({ kind: "success" });
1736
+ }
1737
+ };
1738
+ var groupsDelete = {
1739
+ words: ["groups", "delete"],
1740
+ summary: "groups delete <id> Archive a group and its tests",
1741
+ help: GROUPS_HELP,
1742
+ flags: {},
1743
+ async run(context, output, args) {
1744
+ const groupId = args.positionals[0];
1745
+ if (!groupId) {
1746
+ return usageError2(output, "Usage: coldtea-qa groups delete <groupId>");
1747
+ }
1748
+ const key = requireApiKey2(context, output);
1749
+ if (!key.ok) {
1750
+ return key.code;
1751
+ }
1752
+ const result = await api(context, key.value, {
1753
+ method: "DELETE",
1754
+ path: `/v1/groups/${encodeURIComponent(groupId)}`
1755
+ });
1756
+ if (!result.ok) {
1757
+ return apiFailure2(output, result);
1758
+ }
1759
+ if (output.json) {
1760
+ emitJson(output, result);
1761
+ return exitCodeFor2({ kind: "success" });
1762
+ }
1763
+ output.line(`Archived group ${groupId} (its tests are archived too)`);
1764
+ return exitCodeFor2({ kind: "success" });
1765
+ }
1766
+ };
1767
+ var groupCommands = [
1768
+ groupsList,
1769
+ groupsCreate,
1770
+ groupsGet,
1771
+ groupsUpdate,
1772
+ groupsDelete
1773
+ ];
1774
+
1775
+ // src/crid.ts
1776
+ import { randomBytes } from "node:crypto";
1777
+ import { execFileSync } from "node:child_process";
1778
+ var CI_SHA_ENV_VARS = [
1779
+ "GITHUB_SHA",
1780
+ // GitHub Actions
1781
+ "CI_COMMIT_SHA",
1782
+ // GitLab CI
1783
+ "CIRCLE_SHA1",
1784
+ // CircleCI
1785
+ "BITBUCKET_COMMIT",
1786
+ // Bitbucket Pipelines
1787
+ "BUILDKITE_COMMIT",
1788
+ // Buildkite
1789
+ "VERCEL_GIT_COMMIT_SHA"
1790
+ // Vercel builds
1791
+ ];
1792
+ var SHA_PATTERN = /^[0-9a-f]{7,64}$/i;
1793
+ function defaultResolveGitSha() {
1794
+ try {
1795
+ const sha = execFileSync("git", ["rev-parse", "HEAD"], {
1796
+ encoding: "utf8",
1797
+ stdio: ["ignore", "pipe", "ignore"],
1798
+ timeout: 2e3
1799
+ }).trim();
1800
+ return SHA_PATTERN.test(sha) ? sha : null;
1801
+ } catch {
1802
+ return null;
1803
+ }
1804
+ }
1805
+ function defaultRandom() {
1806
+ return randomBytes(6).toString("hex");
1807
+ }
1808
+ function discoverSha(options = {}) {
1809
+ const env = options.env ?? process.env;
1810
+ for (const name of CI_SHA_ENV_VARS) {
1811
+ const value = env[name]?.trim();
1812
+ if (value && SHA_PATTERN.test(value)) {
1813
+ return value.toLowerCase();
1814
+ }
1815
+ }
1816
+ return (options.resolveGitSha ?? defaultResolveGitSha)();
1817
+ }
1818
+ function deriveClientRequestId(testId, options = {}) {
1819
+ const sha = discoverSha(options);
1820
+ const suffix = sha ?? (options.random ?? defaultRandom)();
1821
+ return `mqcr_${testId}_${suffix}`;
1822
+ }
1823
+
1824
+ // src/duration.ts
1825
+ var DURATION_PATTERN = /^(\d+)(ms|s|m|h)?$/;
1826
+ var UNIT_MS = {
1827
+ ms: 1,
1828
+ s: 1e3,
1829
+ m: 6e4,
1830
+ h: 36e5
1831
+ };
1832
+ function parseDuration(raw) {
1833
+ const match = DURATION_PATTERN.exec(raw.trim());
1834
+ if (!match) {
1835
+ return null;
1836
+ }
1837
+ const value = Number(match[1]);
1838
+ const ms = value * UNIT_MS[match[2] ?? "s"];
1839
+ return ms > 0 ? ms : null;
1840
+ }
1841
+ function formatDuration(ms) {
1842
+ if (ms % 36e5 === 0) {
1843
+ return `${ms / 36e5}h`;
1844
+ }
1845
+ if (ms % 6e4 === 0) {
1846
+ return `${ms / 6e4}m`;
1847
+ }
1848
+ if (ms % 1e3 === 0) {
1849
+ return `${ms / 1e3}s`;
1850
+ }
1851
+ return `${ms}ms`;
1852
+ }
1853
+
1854
+ // src/poll.ts
1855
+ var DEFAULT_POLL_INTERVAL_MS = 3e3;
1856
+ var FAILURE_TOLERANCE = 3;
1857
+ async function poll(input) {
1858
+ const intervalMs = input.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1859
+ const deadline = input.now() + input.timeoutMs;
1860
+ let last = null;
1861
+ let consecutiveFailures = 0;
1862
+ for (; ; ) {
1863
+ const step = await input.step();
1864
+ if ("failure" in step) {
1865
+ consecutiveFailures += 1;
1866
+ if (!step.retryable || consecutiveFailures >= FAILURE_TOLERANCE) {
1867
+ return { kind: "failed", failure: step.failure };
1868
+ }
1869
+ } else {
1870
+ consecutiveFailures = 0;
1871
+ if (step.done) {
1872
+ return { kind: "done", value: step.value };
1873
+ }
1874
+ last = step.value;
1875
+ }
1876
+ if (input.now() >= deadline) {
1877
+ return { kind: "timeout", last };
1878
+ }
1879
+ await input.sleep(intervalMs);
1880
+ }
1881
+ }
1882
+
1883
+ // src/commands/run.ts
1884
+ var RUN_HELP = `coldtea-qa run \u2014 start QA runs
1885
+
1886
+ Usage
1887
+ coldtea-qa run test <testId> [flags]
1888
+ coldtea-qa run group <groupId> [flags]
1889
+
1890
+ Flags
1891
+ --wait Follow the run(s) to a verdict; exit reflects it
1892
+ --timeout <dur> With --wait: how long to follow (90s, 10m, 2h;
1893
+ bare numbers are seconds; default 30m)
1894
+ --label <text> Label the run(s) in the dashboard
1895
+ --target-url <url> The deployment to test (required when the test
1896
+ targets deployment_url)
1897
+ --build <artifactId> The uploaded build to run against (mobile tests
1898
+ without a default build)
1899
+ --environment <id> Run in this environment instead of the group's,
1900
+ replacing it whole rather than merging. Pass
1901
+ "none" to run signed out, as nobody; omit it to
1902
+ inherit the group's environment.
1903
+
1904
+ Attaching a run to a pull request
1905
+ --vcs-provider <p> github or bitbucket
1906
+ --vcs-repository-id <id> The provider's stable repository id
1907
+ --vcs-head-sha <sha> The commit under test
1908
+ --vcs-pr-number <n> The pull request this run is for
1909
+
1910
+ All four travel together; pass none of them to run without a pull
1911
+ request. They STAMP the run with its commit and PR so the dashboard and
1912
+ the report page show what was tested. They do NOT post a check back onto
1913
+ the PR: check names are reserved per repository by rules, so a check from
1914
+ CI comes from a webhook rule (POST /v1/hooks/<token>), not from here.
1915
+
1916
+ An empty value counts as absent, so passing an unset CI variable (a branch
1917
+ build's $BITRISE_PULL_REQUEST, say) is safe rather than fatal.
1918
+
1919
+ Exit codes (with --wait)
1920
+ 0 every run passed (warnings pass unless --fail-on-warning)
1921
+ 1 a run failed \u2014 the app is broken
1922
+ 2 couldn't run or verify, timed out, or the batch skipped tests
1923
+ Without --wait: 0 once the queue accepts, except a partial batch, which
1924
+ exits 2 \u2014 a test skipped at admission never ran, and neither did a queued
1925
+ run the listing does not show.
1926
+
1927
+ A run whose outcome this CLI does not recognise is never dropped from a
1928
+ batch: the command fails and names it, because the alternative is exit 0
1929
+ on a batch it could not read.
1930
+
1931
+ Retries
1932
+ In CI, re-running the SAME commit with the SAME flags replays the
1933
+ original run instead of billing twice. Same commit but DIFFERENT flags
1934
+ (--label, --target-url, --build) answers 409 idempotency_key_reused by
1935
+ design \u2014 keep the flags identical, or run on a new commit.
1936
+ `;
1937
+ var DEFAULT_WAIT_TIMEOUT_MS = 30 * 6e4;
1938
+ function asOutcome(value) {
1939
+ return typeof value === "string" && RUN_OUTCOMES.has(value) ? value : null;
1940
+ }
1941
+ function resolveTimeout(output, values) {
1942
+ if (typeof values.timeout !== "string") {
1943
+ return { ok: true, timeoutMs: DEFAULT_WAIT_TIMEOUT_MS };
1944
+ }
1945
+ const timeoutMs = parseDuration(values.timeout);
1946
+ if (timeoutMs === null) {
1947
+ usageError2(
1948
+ output,
1949
+ "--timeout must be a positive duration like 90s, 10m or 2h"
1950
+ );
1951
+ return { ok: false };
1952
+ }
1953
+ return { ok: true, timeoutMs };
1954
+ }
1955
+ function startFailure(output, result, sentVcs) {
1956
+ const code = apiFailure2(output, result);
1957
+ if (sentVcs && result.error.code === "identity_unverifiable") {
1958
+ output.errorNote(
1959
+ "The --vcs-* flags are verified against the provider, so they need the Coldtea GitHub App connected to that repository. Without it, start the run without them: it still runs, it just is not attached to a pull request."
1960
+ );
1961
+ }
1962
+ return code;
1963
+ }
1964
+ function startBody(crid, values, vcs) {
1965
+ const body = { clientRequestId: crid };
1966
+ if (vcs !== null) {
1967
+ body.vcs = vcs;
1968
+ }
1969
+ const environment = typeof values.environment === "string" ? values.environment.trim() : "";
1970
+ if (environment !== "") {
1971
+ body.environmentId = environment === "none" ? null : environment;
1972
+ }
1973
+ if (typeof values.label === "string") {
1974
+ body.label = values.label;
1975
+ }
1976
+ if (typeof values["target-url"] === "string") {
1977
+ body.targetUrl = values["target-url"];
1978
+ }
1979
+ if (typeof values.build === "string") {
1980
+ body.buildReference = {
1981
+ type: "uploaded_file",
1982
+ artifactId: values.build
1983
+ };
1984
+ }
1985
+ return body;
1986
+ }
1987
+ var MALFORMED_RUN = {
1988
+ code: "invalid_response",
1989
+ message: "The /v1 run payload is missing expected fields",
1990
+ retryable: true
1991
+ };
1992
+ function readRunView(data) {
1993
+ if (!isRecord2(data)) {
1994
+ return { ok: false, problem: MALFORMED_RUN };
1995
+ }
1996
+ const runId = asString(data.id);
1997
+ if (runId === null || typeof data.outcome !== "string") {
1998
+ return { ok: false, problem: MALFORMED_RUN };
1999
+ }
2000
+ const outcome = asOutcome(data.outcome);
2001
+ if (outcome === null) {
2002
+ return {
2003
+ ok: false,
2004
+ problem: {
2005
+ code: "unsupported_outcome",
2006
+ message: `Run ${runId} reports outcome "${data.outcome}", which this coldtea-qa does not know how to judge. Upgrade the CLI.`,
2007
+ retryable: false
2008
+ }
2009
+ };
2010
+ }
2011
+ const runIssue = isRecord2(data.runIssue) ? {
2012
+ kind: asString(data.runIssue.kind),
2013
+ message: asString(data.runIssue.message)
2014
+ } : null;
2015
+ return {
2016
+ ok: true,
2017
+ view: {
2018
+ runId,
2019
+ outcome,
2020
+ status: asString(data.status),
2021
+ liveViewUrl: asString(data.liveViewUrl),
2022
+ reportUrl: asString(data.reportUrl),
2023
+ runIssue,
2024
+ summary: asString(data.summary),
2025
+ raw: data
2026
+ }
2027
+ };
2028
+ }
2029
+ function runViewFailure(problem, requestId) {
2030
+ return {
2031
+ ok: false,
2032
+ status: 0,
2033
+ error: { code: problem.code, message: problem.message, details: null },
2034
+ requestId
2035
+ };
2036
+ }
2037
+ function invalidRunResponse(output, requestId) {
2038
+ output.error(
2039
+ {
2040
+ code: "invalid_response",
2041
+ message: "The /v1 run payload is missing expected fields",
2042
+ details: null
2043
+ },
2044
+ requestId
2045
+ );
2046
+ return exitCodeFor2({
2047
+ kind: "api_error",
2048
+ status: 0,
2049
+ code: "invalid_response"
2050
+ });
2051
+ }
2052
+ function printRunLine(output, view) {
2053
+ const outcome = view.outcome === "failed" ? output.red(view.outcome) : view.outcome;
2054
+ const reason = view.runIssue?.message ?? view.summary ?? view.runIssue?.kind ?? null;
2055
+ const issue = reason === null ? "" : ` \u2014 ${reason}`;
2056
+ output.line(`${view.runId} ${outcome}${issue}`);
2057
+ const link = view.reportUrl ?? view.liveViewUrl;
2058
+ if (link !== null) {
2059
+ output.line(` ${output.dim(link)}`);
2060
+ }
2061
+ }
2062
+ var runTest = {
2063
+ words: ["run", "test"],
2064
+ summary: "run test <id> Start one test run (--wait follows it)",
2065
+ help: RUN_HELP,
2066
+ flags: {
2067
+ wait: { type: "boolean", default: false },
2068
+ environment: { type: "string" },
2069
+ ...VCS_FLAGS,
2070
+ timeout: { type: "string" },
2071
+ label: { type: "string" },
2072
+ "target-url": { type: "string" },
2073
+ build: { type: "string" }
2074
+ },
2075
+ async run(context, output, args) {
2076
+ const testId = args.positionals[0];
2077
+ if (!testId) {
2078
+ return usageError2(output, "Usage: coldtea-qa run test <testId>");
2079
+ }
2080
+ const timeout = resolveTimeout(output, args.values);
2081
+ if (!timeout.ok) {
2082
+ return exitCodeFor2({ kind: "config_error" });
2083
+ }
2084
+ const key = requireApiKey2(context, output);
2085
+ if (!key.ok) {
2086
+ return key.code;
2087
+ }
2088
+ const vcsFlags = readVcsFlags(output, args.values, {
2089
+ requirePrNumber: true
2090
+ });
2091
+ if (!vcsFlags.ok) {
2092
+ return exitCodeFor2({ kind: "config_error" });
2093
+ }
2094
+ const crid = deriveClientRequestId(testId, { env: context.env });
2095
+ const started = await api(context, key.value, {
2096
+ method: "POST",
2097
+ path: `/v1/tests/${encodeURIComponent(testId)}/runs`,
2098
+ body: startBody(crid, args.values, vcsFlags.vcs)
2099
+ });
2100
+ if (!started.ok) {
2101
+ return startFailure(output, started, vcsFlags.vcs !== null);
2102
+ }
2103
+ const runId = isRecord2(started.data) ? asString(started.data.runId) : null;
2104
+ if (runId === null) {
2105
+ return invalidRunResponse(output, started.requestId);
2106
+ }
2107
+ const replayed = isRecord2(started.data) && started.data.replayed === true;
2108
+ if (args.values.wait !== true) {
2109
+ const read = await api(context, key.value, {
2110
+ method: "GET",
2111
+ path: `/v1/runs/${encodeURIComponent(runId)}`
2112
+ });
2113
+ const parsed = read.ok ? readRunView(read.data) : null;
2114
+ const view2 = parsed?.ok === true ? parsed.view : null;
2115
+ if (output.json) {
2116
+ output.success(view2?.raw ?? started.data, {});
2117
+ return exitCodeFor2({ kind: "success" });
2118
+ }
2119
+ output.line(
2120
+ replayed ? `Replayed run ${runId} \u2014 an earlier request for this test on this commit started it. Nothing new was queued.` : `Queued run ${runId}`
2121
+ );
2122
+ const link = view2?.reportUrl ?? view2?.liveViewUrl;
2123
+ if (link) {
2124
+ output.line(output.dim(link));
2125
+ }
2126
+ return exitCodeFor2({ kind: "success" });
2127
+ }
2128
+ let urlPrinted = false;
2129
+ const result = await poll({
2130
+ timeoutMs: timeout.timeoutMs,
2131
+ sleep: context.sleep,
2132
+ now: context.now,
2133
+ step: async () => {
2134
+ const read = await api(context, key.value, {
2135
+ method: "GET",
2136
+ path: `/v1/runs/${encodeURIComponent(runId)}`
2137
+ });
2138
+ if (!read.ok) {
2139
+ return { failure: read, retryable: isRetryableFailure(read) };
2140
+ }
2141
+ const parsed = readRunView(read.data);
2142
+ if (!parsed.ok) {
2143
+ return {
2144
+ failure: runViewFailure(parsed.problem, read.requestId),
2145
+ retryable: parsed.problem.retryable
2146
+ };
2147
+ }
2148
+ const view2 = parsed.view;
2149
+ if (!urlPrinted && view2.liveViewUrl !== null) {
2150
+ output.line(output.dim(`Live view: ${view2.liveViewUrl}`));
2151
+ urlPrinted = true;
2152
+ }
2153
+ return { done: view2.outcome !== "in_progress", value: view2 };
2154
+ }
2155
+ });
2156
+ if (result.kind === "failed") {
2157
+ return pollUnreadable(output, result.failure, `Run ${runId}`);
2158
+ }
2159
+ if (result.kind === "timeout") {
2160
+ const view2 = result.last;
2161
+ if (output.json) {
2162
+ output.success(view2?.raw ?? started.data, {});
2163
+ } else {
2164
+ output.line(
2165
+ `Run ${runId} still in progress after ${formatDuration(timeout.timeoutMs)}`
2166
+ );
2167
+ const link = view2?.reportUrl ?? view2?.liveViewUrl;
2168
+ if (link) {
2169
+ output.line(output.dim(link));
2170
+ }
2171
+ }
2172
+ return exitCodeFor2({
2173
+ kind: "run",
2174
+ outcome: "in_progress",
2175
+ failOnWarning: context.flags.failOnWarning
2176
+ });
2177
+ }
2178
+ const view = result.value;
2179
+ if (output.json) {
2180
+ output.success(view.raw, {});
2181
+ } else {
2182
+ printRunLine(output, view);
2183
+ }
2184
+ return exitCodeFor2({
2185
+ kind: "run",
2186
+ outcome: view.outcome,
2187
+ failOnWarning: context.flags.failOnWarning
2188
+ });
2189
+ }
2190
+ };
2191
+ function pollUnreadable(output, failure, startedWhat) {
2192
+ const code = apiFailure2(output, failure);
2193
+ if (failure.error.code === "insufficient_scope") {
2194
+ output.errorNote(
2195
+ ` ${startedWhat} was started and IS running \u2014 this key just cannot read it back. Starting a run needs qa:run; following one needs qa:read. Use a key with both, or drop --wait and read the run with a qa:read key once it settles.`
2196
+ );
2197
+ }
2198
+ return code;
2199
+ }
2200
+ async function fetchBatchRuns(context, apiKey, batchId) {
2201
+ const views = [];
2202
+ let cursor = null;
2203
+ do {
2204
+ const query = `batchId=${encodeURIComponent(batchId)}&limit=100${cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`}`;
2205
+ const page = await api(context, apiKey, {
2206
+ method: "GET",
2207
+ path: `/v1/runs?${query}`
2208
+ });
2209
+ if (!page.ok) {
2210
+ return { ok: false, failure: page, retryable: isRetryableFailure(page) };
2211
+ }
2212
+ for (const item of Array.isArray(page.data) ? page.data : []) {
2213
+ const parsed = readRunView(item);
2214
+ if (!parsed.ok) {
2215
+ return {
2216
+ ok: false,
2217
+ failure: runViewFailure(parsed.problem, page.requestId),
2218
+ retryable: parsed.problem.retryable
2219
+ };
2220
+ }
2221
+ views.push(parsed.view);
2222
+ }
2223
+ cursor = page.nextCursor;
2224
+ } while (cursor !== null);
2225
+ return { ok: true, views };
2226
+ }
2227
+ function batchCounters(data) {
2228
+ const record = isRecord2(data) ? data : {};
2229
+ const credits = isRecord2(record.insufficientCredits) ? {
2230
+ cost: record.insufficientCredits.cost,
2231
+ available: record.insufficientCredits.available
2232
+ } : null;
2233
+ return {
2234
+ batchId: asString(record.batchId),
2235
+ runCount: Array.isArray(record.runs) ? record.runs.length : 0,
2236
+ // Absent on a server that predates the field, and `false` is the safe
2237
+ // read: it means "say nothing extra" rather than "claim a replay".
2238
+ replayed: record.replayed === true,
2239
+ skippedCount: typeof record.skippedCount === "number" ? record.skippedCount : 0,
2240
+ failedCount: typeof record.failedCount === "number" ? record.failedCount : 0,
2241
+ insufficientCredits: credits
2242
+ };
2243
+ }
2244
+ function reportUnseenRuns(output, queued, visible) {
2245
+ output.error({
2246
+ code: "incomplete_batch",
2247
+ message: `The batch queued ${queued} run(s) but only ${visible} came back from /v1/runs. The missing run(s) were not judged.`,
2248
+ details: { queued, visible }
2249
+ });
2250
+ }
2251
+ var runGroup = {
2252
+ words: ["run", "group"],
2253
+ summary: "run group <id> Start a batch of a group's tests",
2254
+ help: RUN_HELP,
2255
+ flags: {
2256
+ wait: { type: "boolean", default: false },
2257
+ environment: { type: "string" },
2258
+ ...VCS_FLAGS,
2259
+ timeout: { type: "string" },
2260
+ label: { type: "string" },
2261
+ "target-url": { type: "string" },
2262
+ build: { type: "string" }
2263
+ },
2264
+ async run(context, output, args) {
2265
+ const groupId = args.positionals[0];
2266
+ if (!groupId) {
2267
+ return usageError2(output, "Usage: coldtea-qa run group <groupId>");
2268
+ }
2269
+ const timeout = resolveTimeout(output, args.values);
2270
+ if (!timeout.ok) {
2271
+ return exitCodeFor2({ kind: "config_error" });
2272
+ }
2273
+ const key = requireApiKey2(context, output);
2274
+ if (!key.ok) {
2275
+ return key.code;
2276
+ }
2277
+ const vcsFlags = readVcsFlags(output, args.values, {
2278
+ requirePrNumber: true
2279
+ });
2280
+ if (!vcsFlags.ok) {
2281
+ return exitCodeFor2({ kind: "config_error" });
2282
+ }
2283
+ const crid = deriveClientRequestId(groupId, { env: context.env });
2284
+ const started = await api(context, key.value, {
2285
+ method: "POST",
2286
+ path: `/v1/groups/${encodeURIComponent(groupId)}/runs`,
2287
+ body: startBody(crid, args.values, vcsFlags.vcs)
2288
+ });
2289
+ if (!started.ok) {
2290
+ return startFailure(output, started, vcsFlags.vcs !== null);
2291
+ }
2292
+ const counters = batchCounters(started.data);
2293
+ const batchId = counters.batchId;
2294
+ if (batchId === null) {
2295
+ return invalidRunResponse(output, started.requestId);
2296
+ }
2297
+ const neverRan = counters.skippedCount + counters.failedCount;
2298
+ if (counters.runCount === 0 && neverRan === 0) {
2299
+ if (!output.json) {
2300
+ output.line(
2301
+ `Batch ${counters.batchId}: 0 run(s) queued \u2014 nothing was tested.`
2302
+ );
2303
+ } else {
2304
+ emitJson(output, started);
2305
+ }
2306
+ output.errorNote(
2307
+ counters.replayed ? " This is a REPLAY of an earlier batch for this group on this commit, which was empty when it ran. Nothing new started, and adding tests will not change it. Re-run on a new commit, or pass --label to start a fresh batch." : ` Group ${groupId} has no tests to run. See what is in it: coldtea-qa tests list --group ${groupId}`
2308
+ );
2309
+ return exitCodeFor2({
2310
+ kind: "batch",
2311
+ worstOutcome: "couldnt_run",
2312
+ skippedCount: 0,
2313
+ failOnWarning: context.flags.failOnWarning
2314
+ });
2315
+ }
2316
+ if (!output.json) {
2317
+ output.line(
2318
+ `Batch ${counters.batchId}: ${counters.runCount} run(s) queued`
2319
+ );
2320
+ if (counters.skippedCount > 0) {
2321
+ output.line(`Skipped at admission: ${counters.skippedCount}`);
2322
+ }
2323
+ if (counters.failedCount > 0) {
2324
+ output.line(`Failed to start: ${counters.failedCount}`);
2325
+ }
2326
+ if (counters.insufficientCredits !== null) {
2327
+ output.line(
2328
+ `Insufficient credits: needs ${cell(counters.insufficientCredits.cost)}, available ${cell(counters.insufficientCredits.available)}`
2329
+ );
2330
+ }
2331
+ }
2332
+ if (counters.replayed && !output.json) {
2333
+ output.line(
2334
+ output.dim(
2335
+ "Replayed: an earlier request for this group on this commit started these runs. Nothing new was queued."
2336
+ )
2337
+ );
2338
+ }
2339
+ if (args.values.wait !== true) {
2340
+ const listed = await fetchBatchRuns(context, key.value, batchId);
2341
+ if (output.json) {
2342
+ emitJson(output, started);
2343
+ }
2344
+ if (!listed.ok) {
2345
+ return apiFailure2(output, listed.failure);
2346
+ }
2347
+ if (!output.json) {
2348
+ for (const view of listed.views) {
2349
+ output.line(`${view.runId} ${cell(view.status)}`);
2350
+ const link = view.reportUrl ?? view.liveViewUrl;
2351
+ if (link !== null) {
2352
+ output.line(` ${output.dim(link)}`);
2353
+ }
2354
+ }
2355
+ }
2356
+ const unseen = Math.max(counters.runCount - listed.views.length, 0);
2357
+ if (unseen > 0) {
2358
+ reportUnseenRuns(output, counters.runCount, listed.views.length);
2359
+ }
2360
+ const unaccounted = neverRan + unseen;
2361
+ if (unaccounted > 0) {
2362
+ return exitCodeFor2({
2363
+ kind: "batch",
2364
+ worstOutcome: "passed",
2365
+ skippedCount: unaccounted,
2366
+ failOnWarning: context.flags.failOnWarning
2367
+ });
2368
+ }
2369
+ return exitCodeFor2({ kind: "success" });
2370
+ }
2371
+ if (counters.runCount === 0) {
2372
+ if (output.json) {
2373
+ emitJson(output, started);
2374
+ }
2375
+ if (neverRan > 0) {
2376
+ return exitCodeFor2({
2377
+ kind: "batch",
2378
+ worstOutcome: "passed",
2379
+ skippedCount: neverRan,
2380
+ failOnWarning: context.flags.failOnWarning
2381
+ });
2382
+ }
2383
+ return exitCodeFor2({ kind: "success" });
2384
+ }
2385
+ const result = await poll({
2386
+ timeoutMs: timeout.timeoutMs,
2387
+ sleep: context.sleep,
2388
+ now: context.now,
2389
+ step: async () => {
2390
+ const listed = await fetchBatchRuns(context, key.value, batchId);
2391
+ if (!listed.ok) {
2392
+ return { failure: listed.failure, retryable: listed.retryable };
2393
+ }
2394
+ const done = listed.views.length >= counters.runCount && listed.views.every((view) => view.outcome !== "in_progress");
2395
+ return { done, value: listed.views };
2396
+ }
2397
+ });
2398
+ if (result.kind === "failed") {
2399
+ return pollUnreadable(output, result.failure, `Batch ${batchId}`);
2400
+ }
2401
+ const timedOut = result.kind === "timeout";
2402
+ const views = timedOut ? result.last ?? [] : result.value;
2403
+ if (output.json) {
2404
+ output.success(
2405
+ {
2406
+ batchId,
2407
+ skippedCount: counters.skippedCount,
2408
+ failedCount: counters.failedCount,
2409
+ insufficientCredits: counters.insufficientCredits,
2410
+ timedOut,
2411
+ runs: views.map((view) => view.raw)
2412
+ },
2413
+ {}
2414
+ );
2415
+ } else {
2416
+ if (timedOut) {
2417
+ output.line(
2418
+ `Batch ${batchId} still in progress after ${formatDuration(timeout.timeoutMs)}`
2419
+ );
2420
+ }
2421
+ for (const view of views) {
2422
+ printRunLine(output, view);
2423
+ }
2424
+ }
2425
+ if (views.length < counters.runCount) {
2426
+ reportUnseenRuns(output, counters.runCount, views.length);
2427
+ }
2428
+ const worstOutcome = timedOut ? worstOf(["in_progress", ...views.map((view) => view.outcome)]) : worstOf(views.map((view) => view.outcome));
2429
+ return exitCodeFor2({
2430
+ kind: "batch",
2431
+ worstOutcome,
2432
+ skippedCount: neverRan,
2433
+ failOnWarning: context.flags.failOnWarning
2434
+ });
2435
+ }
2436
+ };
2437
+ var runCommands = [runTest, runGroup];
2438
+
2439
+ // src/commands/runs.ts
2440
+ var RUNS_HELP = `coldtea-qa runs \u2014 inspect runs
2441
+
2442
+ Usage
2443
+ coldtea-qa runs get <runId>
2444
+ coldtea-qa runs stop <runId>
2445
+ coldtea-qa runs list [scope] [filters] [--limit <n>] [--cursor <c>]
2446
+
2447
+ Scope (at most one; --test/--group/--batch override --project)
2448
+ --project <id> Runs in one project (or COLDTEA_PROJECT_ID)
2449
+ --test <id> One test's runs
2450
+ --group <id> One group's runs
2451
+ --batch <id> One batch's runs
2452
+
2453
+ Filters
2454
+ --status <s,\u2026> queued, running, waiting, stopping, completed,
2455
+ failed, canceled (comma-separate for several)
2456
+ --outcome <o,\u2026> in_progress, passed, failed, warning,
2457
+ couldnt_verify, couldnt_run
2458
+ --since <when> Only runs created at/after this moment \u2014 epoch
2459
+ milliseconds or an ISO-8601 date/time
2460
+
2461
+ Newest first. With no scope, the whole organization's runs.
2462
+
2463
+ READ \`outcome\`, NOT \`status\`. A run that could not reach a verdict reports
2464
+ status "failed" with outcome "couldnt_verify" \u2014 branching on status calls an
2465
+ app broken when nothing was.
2466
+ `;
2467
+ function sinceToEpochMs(raw) {
2468
+ if (/^\d+$/.test(raw)) {
2469
+ return Number(raw);
2470
+ }
2471
+ const parsed = Date.parse(raw);
2472
+ return Number.isNaN(parsed) ? null : parsed;
2473
+ }
2474
+ function runRow(run) {
2475
+ return [
2476
+ cell(run.id),
2477
+ cell(run.status),
2478
+ cell(run.outcome),
2479
+ cell(run.testId),
2480
+ cell(run.batchId),
2481
+ formatEpoch(run.createdAt)
2482
+ ];
2483
+ }
2484
+ var runsGet = {
2485
+ words: ["runs", "get"],
2486
+ summary: "runs get <id> Show one run and why it ended that way",
2487
+ help: RUNS_HELP,
2488
+ flags: {},
2489
+ async run(context, output, args) {
2490
+ const runId = args.positionals[0];
2491
+ if (!runId) {
2492
+ return usageError2(output, "Usage: coldtea-qa runs get <runId>");
2493
+ }
2494
+ const key = requireApiKey2(context, output);
2495
+ if (!key.ok) {
2496
+ return key.code;
2497
+ }
2498
+ const result = await api(context, key.value, {
2499
+ method: "GET",
2500
+ path: `/v1/runs/${encodeURIComponent(runId)}`
2501
+ });
2502
+ if (!result.ok) {
2503
+ return apiFailure2(output, result);
2504
+ }
2505
+ if (output.json) {
2506
+ emitJson(output, result);
2507
+ return exitCodeFor2({ kind: "success" });
2508
+ }
2509
+ if (!isRecord2(result.data)) {
2510
+ return exitCodeFor2({ kind: "success" });
2511
+ }
2512
+ const run = result.data;
2513
+ output.line(`${output.bold("Run")} ${cell(run.id)}`);
2514
+ output.line(`${output.bold("Outcome")} ${cell(run.outcome)}`);
2515
+ output.line(
2516
+ `${output.bold("Status")} ${cell(run.status)} ${output.dim("(read outcome, not this)")}`
2517
+ );
2518
+ const issue = isRecord2(run.runIssue) ? run.runIssue : null;
2519
+ if (issue) {
2520
+ output.line(
2521
+ `${output.bold("Why")} ${cell(issue.message ?? issue.kind)}`
2522
+ );
2523
+ }
2524
+ output.line(`${output.bold("Test")} ${cell(run.testId)}`);
2525
+ output.line(`${output.bold("Target")} ${cell(run.targetUrl)}`);
2526
+ output.line(`${output.bold("Started")} ${formatEpoch(run.createdAt)}`);
2527
+ output.line(`${output.bold("Finished")} ${formatEpoch(run.completedAt)}`);
2528
+ if (typeof run.summary === "string" && run.summary !== "") {
2529
+ output.line();
2530
+ output.line(run.summary);
2531
+ }
2532
+ if (typeof run.reportUrl === "string") {
2533
+ output.line();
2534
+ output.line(output.dim(run.reportUrl));
2535
+ }
2536
+ return exitCodeFor2({ kind: "success" });
2537
+ }
2538
+ };
2539
+ var runsList = {
2540
+ words: ["runs", "list"],
2541
+ summary: "runs list List runs (filters mirror the API)",
2542
+ help: RUNS_HELP,
2543
+ flags: {
2544
+ ...LIST_FLAGS,
2545
+ test: { type: "string" },
2546
+ group: { type: "string" },
2547
+ batch: { type: "string" },
2548
+ status: { type: "string" },
2549
+ outcome: { type: "string" },
2550
+ since: { type: "string" }
2551
+ },
2552
+ async run(context, output, args) {
2553
+ const explicitScopes = ["test", "group", "batch"].filter(
2554
+ (name) => typeof args.values[name] === "string"
2555
+ );
2556
+ if (explicitScopes.length > 1) {
2557
+ return usageError2(
2558
+ output,
2559
+ "Pick one scope: --test, --group or --batch (they cannot combine)"
2560
+ );
2561
+ }
2562
+ const key = requireApiKey2(context, output);
2563
+ if (!key.ok) {
2564
+ return key.code;
2565
+ }
2566
+ const query = listQuery(args.values);
2567
+ if (explicitScopes.length === 1) {
2568
+ const name = explicitScopes[0];
2569
+ const param = { test: "testId", group: "groupId", batch: "batchId" }[name];
2570
+ query.push(`${param}=${encodeURIComponent(args.values[name])}`);
2571
+ } else if (context.projectId !== null) {
2572
+ query.push(`projectId=${encodeURIComponent(context.projectId)}`);
2573
+ }
2574
+ if (typeof args.values.status === "string") {
2575
+ query.push(`status=${encodeURIComponent(args.values.status)}`);
2576
+ }
2577
+ if (typeof args.values.outcome === "string") {
2578
+ query.push(`outcome=${encodeURIComponent(args.values.outcome)}`);
2579
+ }
2580
+ if (typeof args.values.since === "string") {
2581
+ const since = sinceToEpochMs(args.values.since);
2582
+ if (since === null) {
2583
+ return usageError2(
2584
+ output,
2585
+ "--since must be epoch milliseconds or an ISO-8601 date/time"
2586
+ );
2587
+ }
2588
+ query.push(`since=${since}`);
2589
+ }
2590
+ const suffix = query.length > 0 ? `?${query.join("&")}` : "";
2591
+ const result = await api(context, key.value, {
2592
+ method: "GET",
2593
+ path: `/v1/runs${suffix}`
2594
+ });
2595
+ if (!result.ok) {
2596
+ return apiFailure2(output, result);
2597
+ }
2598
+ if (output.json) {
2599
+ emitListJson(output, result);
2600
+ return exitCodeFor2({ kind: "success" });
2601
+ }
2602
+ const runs = Array.isArray(result.data) ? result.data : [];
2603
+ if (runs.length === 0) {
2604
+ output.line("No runs.");
2605
+ } else {
2606
+ output.line(
2607
+ renderTable(
2608
+ ["ID", "STATUS", "OUTCOME", "TEST", "BATCH", "CREATED"],
2609
+ runs.filter(isRecord2).map(runRow)
2610
+ )
2611
+ );
2612
+ }
2613
+ nextCursorFootnote(output, result.nextCursor);
2614
+ return exitCodeFor2({ kind: "success" });
2615
+ }
2616
+ };
2617
+ var runsStop = {
2618
+ words: ["runs", "stop"],
2619
+ summary: "runs stop <id> Stop a run that is still going",
2620
+ help: RUNS_HELP,
2621
+ flags: {},
2622
+ async run(context, output, args) {
2623
+ const runId = args.positionals[0];
2624
+ if (!runId) {
2625
+ return usageError2(output, "Usage: coldtea-qa runs stop <runId>");
2626
+ }
2627
+ const key = requireApiKey2(context, output);
2628
+ if (!key.ok) {
2629
+ return key.code;
2630
+ }
2631
+ const result = await api(context, key.value, {
2632
+ method: "POST",
2633
+ path: `/v1/runs/${encodeURIComponent(runId)}/stop`
2634
+ });
2635
+ if (!result.ok) {
2636
+ return apiFailure2(output, result);
2637
+ }
2638
+ if (output.json) {
2639
+ emitJson(output, result);
2640
+ return exitCodeFor2({ kind: "success" });
2641
+ }
2642
+ const status = isRecord2(result.data) ? cell(result.data.status) : "\u2014";
2643
+ output.line(
2644
+ result.status === 202 ? `Stopping run ${runId} (${status})` : `Run ${runId} had already settled (${status})`
2645
+ );
2646
+ return exitCodeFor2({ kind: "success" });
2647
+ }
2648
+ };
2649
+ var runsCommands = [runsGet, runsStop, runsList];
2650
+
2651
+ // src/commands/builds.ts
2652
+ import { createHash } from "node:crypto";
2653
+ import { createReadStream, openAsBlob, promises as fs } from "node:fs";
2654
+ import { basename } from "node:path";
2655
+ var BUILDS_HELP = `coldtea-qa builds \u2014 upload app builds
2656
+
2657
+ Usage
2658
+ coldtea-qa builds upload <file> --platform <android|ios> [flags]
2659
+ coldtea-qa builds get <buildId>
2660
+
2661
+ Flags
2662
+ --platform <p> android (.apk) or ios (Simulator .app in
2663
+ .zip or .tar.gz)
2664
+ --build-version <v> Version label for the build
2665
+ --vcs-provider <p> Version-control provenance, passed through
2666
+ --vcs-repository-id <id> to registration (all three of provider,
2667
+ --vcs-head-sha <sha> repository-id and head-sha together;
2668
+ --vcs-pr-number <n> pr-number optional)
2669
+ --no-wait Exit after confirm instead of waiting for
2670
+ registration
2671
+ --timeout <dur> How long to wait for registration
2672
+ (default 15m)
2673
+
2674
+ upload needs a project (--project or COLDTEA_PROJECT_ID); get works on the
2675
+ build id alone. A failed registration exits 2 and prints the failure code
2676
+ (e.g. sha256_mismatch).
2677
+
2678
+ \`--no-wait\` hands back a build id before registration finishes.
2679
+ \`builds get\` is how you read it afterwards \u2014 without it the id was unusable.
2680
+ `;
2681
+ var DEFAULT_BUILD_TIMEOUT_MS = 15 * 6e4;
2682
+ function sha256OfFile(path) {
2683
+ return new Promise((resolve, reject) => {
2684
+ const hash = createHash("sha256");
2685
+ const stream = createReadStream(path);
2686
+ stream.on("error", reject);
2687
+ stream.on("data", (chunk) => hash.update(chunk));
2688
+ stream.on("end", () => resolve(hash.digest("hex")));
2689
+ });
2690
+ }
2691
+ function buildFailureExit(output, failure, requestId) {
2692
+ const record = isRecord2(failure) ? failure : {};
2693
+ const code = asString(record.code) ?? "registration_failed";
2694
+ const message = asString(record.message) ?? "The build failed to register.";
2695
+ output.error({ code, message, details: null }, requestId);
2696
+ return exitCodeFor2({ kind: "api_error", status: 0, code });
2697
+ }
2698
+ var buildsUpload = {
2699
+ words: ["builds", "upload"],
2700
+ summary: "builds upload <file> Upload a build (--platform required)",
2701
+ help: BUILDS_HELP,
2702
+ flags: {
2703
+ platform: { type: "string" },
2704
+ "build-version": { type: "string" },
2705
+ ...VCS_FLAGS,
2706
+ "no-wait": { type: "boolean", default: false },
2707
+ timeout: { type: "string" }
2708
+ },
2709
+ async run(context, output, args) {
2710
+ const filePath = args.positionals[0];
2711
+ const platform = args.values.platform;
2712
+ if (!filePath || typeof platform !== "string") {
2713
+ return usageError2(
2714
+ output,
2715
+ "Usage: coldtea-qa builds upload <file> --platform <android|ios>"
2716
+ );
2717
+ }
2718
+ if (platform !== "android" && platform !== "ios") {
2719
+ return usageError2(output, "--platform must be android or ios");
2720
+ }
2721
+ const vcsFlags = readVcsFlags(output, args.values, {
2722
+ requirePrNumber: false
2723
+ });
2724
+ if (!vcsFlags.ok) {
2725
+ return exitCodeFor2({ kind: "config_error" });
2726
+ }
2727
+ let timeoutMs = DEFAULT_BUILD_TIMEOUT_MS;
2728
+ if (typeof args.values.timeout === "string") {
2729
+ const parsed = parseDuration(args.values.timeout);
2730
+ if (parsed === null) {
2731
+ return usageError2(
2732
+ output,
2733
+ "--timeout must be a positive duration like 90s, 10m or 2h"
2734
+ );
2735
+ }
2736
+ timeoutMs = parsed;
2737
+ }
2738
+ const key = requireApiKey2(context, output);
2739
+ if (!key.ok) {
2740
+ return key.code;
2741
+ }
2742
+ const project = requireProjectId(context, output);
2743
+ if (!project.ok) {
2744
+ return project.code;
2745
+ }
2746
+ let sizeBytes;
2747
+ try {
2748
+ const stat = await fs.stat(filePath);
2749
+ if (!stat.isFile()) {
2750
+ return usageError2(output, `${filePath} is not a file`);
2751
+ }
2752
+ sizeBytes = stat.size;
2753
+ } catch {
2754
+ return usageError2(output, `Cannot read ${filePath}`);
2755
+ }
2756
+ const fileName = basename(filePath);
2757
+ let sha256;
2758
+ try {
2759
+ sha256 = await sha256OfFile(filePath);
2760
+ } catch {
2761
+ return usageError2(output, `Cannot read ${filePath}`);
2762
+ }
2763
+ const buildVersion = typeof args.values["build-version"] === "string" ? args.values["build-version"] : null;
2764
+ const identity = createHash("sha256").update(
2765
+ [
2766
+ sha256,
2767
+ fileName,
2768
+ platform,
2769
+ buildVersion ?? "",
2770
+ JSON.stringify(vcsFlags.vcs ?? null)
2771
+ ].join("")
2772
+ ).digest("hex").slice(0, 32);
2773
+ const initiateBody = {
2774
+ platform,
2775
+ fileName,
2776
+ sizeBytes,
2777
+ clientRequestId: `mqcr_build_${identity}`
2778
+ };
2779
+ if (buildVersion !== null) {
2780
+ initiateBody.buildVersion = buildVersion;
2781
+ }
2782
+ if (vcsFlags.vcs !== null) {
2783
+ initiateBody.vcs = vcsFlags.vcs;
2784
+ }
2785
+ const initiated = await api(context, key.value, {
2786
+ method: "POST",
2787
+ path: `/v1/projects/${encodeURIComponent(project.value)}/builds`,
2788
+ body: initiateBody
2789
+ });
2790
+ if (!initiated.ok) {
2791
+ return apiFailure2(output, initiated);
2792
+ }
2793
+ const initiatedData = isRecord2(initiated.data) ? initiated.data : {};
2794
+ const buildId = asString(initiatedData.buildId);
2795
+ const upload = isRecord2(initiatedData.upload) ? initiatedData.upload : null;
2796
+ const uploadUrl = upload === null ? null : asString(upload.url);
2797
+ const uploadId = upload === null ? null : asString(upload.uploadId);
2798
+ const objectKey = upload === null ? null : asString(upload.objectKey);
2799
+ if (buildId === null || upload === null || uploadUrl === null || uploadId === null || objectKey === null) {
2800
+ output.error(
2801
+ {
2802
+ code: "invalid_response",
2803
+ message: "The initiate response is missing expected fields",
2804
+ details: null
2805
+ },
2806
+ initiated.requestId
2807
+ );
2808
+ return exitCodeFor2({
2809
+ kind: "api_error",
2810
+ status: 0,
2811
+ code: "invalid_response"
2812
+ });
2813
+ }
2814
+ if (urlIsInsecure(uploadUrl, allowsInsecureHttp(context.env))) {
2815
+ output.error({
2816
+ code: "insecure_upload_url",
2817
+ message: `The server's upload URL is not https (${uploadUrl.split("?")[0]}). Refusing to send the build in plaintext. If this is a trusted http-only host, set COLDTEA_ALLOW_INSECURE_HTTP=1.`,
2818
+ details: null
2819
+ });
2820
+ return exitCodeFor2({ kind: "config_error" });
2821
+ }
2822
+ output.line(`Build ${buildId}: uploading ${fileName} (${sizeBytes} bytes)`);
2823
+ const fetchImpl = context.fetchImpl ?? fetch;
2824
+ const uploadHeaders = {};
2825
+ if (isRecord2(upload.headers)) {
2826
+ for (const [name, value] of Object.entries(upload.headers)) {
2827
+ if (typeof value === "string") {
2828
+ uploadHeaders[name] = value;
2829
+ }
2830
+ }
2831
+ }
2832
+ let putStatus;
2833
+ try {
2834
+ const putResponse = await fetchImpl(uploadUrl, {
2835
+ method: asString(upload.method) ?? "PUT",
2836
+ headers: uploadHeaders,
2837
+ body: await openAsBlob(filePath)
2838
+ });
2839
+ putStatus = putResponse.status;
2840
+ } catch (error) {
2841
+ const message = error instanceof Error ? error.message : String(error);
2842
+ output.error({
2843
+ code: "upload_failed",
2844
+ message: `Could not upload to the signed URL: ${message}`,
2845
+ details: null
2846
+ });
2847
+ return exitCodeFor2({
2848
+ kind: "api_error",
2849
+ status: 0,
2850
+ code: "upload_failed"
2851
+ });
2852
+ }
2853
+ if (putStatus < 200 || putStatus >= 300) {
2854
+ output.error({
2855
+ code: "upload_failed",
2856
+ message: `The signed-URL upload answered ${putStatus}. Re-run to get a fresh URL (they expire after 10 minutes).`,
2857
+ details: null
2858
+ });
2859
+ return exitCodeFor2({
2860
+ kind: "api_error",
2861
+ status: 0,
2862
+ code: "upload_failed"
2863
+ });
2864
+ }
2865
+ const confirmBody = {
2866
+ uploadId,
2867
+ objectKey,
2868
+ fileName,
2869
+ sizeBytes,
2870
+ platform,
2871
+ clientRequestId: `mqcr_confirm_${identity}`,
2872
+ sha256
2873
+ };
2874
+ if (vcsFlags.vcs !== null) {
2875
+ confirmBody.vcs = vcsFlags.vcs;
2876
+ }
2877
+ const confirmed = await api(context, key.value, {
2878
+ method: "POST",
2879
+ path: `/v1/builds/${encodeURIComponent(buildId)}/confirm`,
2880
+ body: confirmBody
2881
+ });
2882
+ if (!confirmed.ok) {
2883
+ return apiFailure2(output, confirmed);
2884
+ }
2885
+ if (args.values["no-wait"] === true) {
2886
+ if (output.json) {
2887
+ emitJson(output, confirmed);
2888
+ } else {
2889
+ output.line(`Build ${buildId} registering (--no-wait: not following)`);
2890
+ }
2891
+ return exitCodeFor2({ kind: "success" });
2892
+ }
2893
+ const result = await poll({
2894
+ timeoutMs,
2895
+ sleep: context.sleep,
2896
+ now: context.now,
2897
+ step: async () => {
2898
+ const read = await api(context, key.value, {
2899
+ method: "GET",
2900
+ path: `/v1/builds/${encodeURIComponent(buildId)}`
2901
+ });
2902
+ if (!read.ok) {
2903
+ return { failure: read, retryable: isRetryableFailure(read) };
2904
+ }
2905
+ const record2 = isRecord2(read.data) ? read.data : {};
2906
+ const status = asString(record2.status);
2907
+ return {
2908
+ done: status === "ready" || status === "failed",
2909
+ value: { record: record2, requestId: read.requestId }
2910
+ };
2911
+ }
2912
+ });
2913
+ if (result.kind === "failed") {
2914
+ return apiFailure2(output, result.failure);
2915
+ }
2916
+ if (result.kind === "timeout") {
2917
+ output.error({
2918
+ code: "timeout",
2919
+ message: `Build ${buildId} was still registering after ${formatDuration(timeoutMs)}. Poll it with: coldtea-qa builds \u2014 GET /v1/builds/${buildId}`,
2920
+ details: null
2921
+ });
2922
+ return exitCodeFor2({ kind: "api_error", status: 0, code: "timeout" });
2923
+ }
2924
+ const { record, requestId } = result.value;
2925
+ if (asString(record.status) === "failed") {
2926
+ if (output.json) {
2927
+ output.success(record, {});
2928
+ }
2929
+ return buildFailureExit(output, record.failure, requestId);
2930
+ }
2931
+ if (output.json) {
2932
+ output.success(record, {});
2933
+ } else {
2934
+ output.line(`Build ${buildId} ready`);
2935
+ output.line(`${output.bold("App")} ${cell(record.appKey)}`);
2936
+ output.line(`${output.bold("Version")} ${cell(record.version)}`);
2937
+ output.line(`${output.bold("Artifact")} ${cell(record.artifactId)}`);
2938
+ output.line(
2939
+ output.dim(
2940
+ "Run against it with: coldtea-qa run test <id> --build " + cell(record.artifactId)
2941
+ )
2942
+ );
2943
+ }
2944
+ return exitCodeFor2({ kind: "success" });
2945
+ }
2946
+ };
2947
+ var buildsGet = {
2948
+ words: ["builds", "get"],
2949
+ summary: "builds get <id> Show one build and its artifact",
2950
+ help: BUILDS_HELP,
2951
+ flags: {},
2952
+ async run(context, output, args) {
2953
+ const buildId = args.positionals[0];
2954
+ if (!buildId) {
2955
+ return usageError2(output, "Usage: coldtea-qa builds get <buildId>");
2956
+ }
2957
+ const key = requireApiKey2(context, output);
2958
+ if (!key.ok) {
2959
+ return key.code;
2960
+ }
2961
+ const result = await api(context, key.value, {
2962
+ method: "GET",
2963
+ path: `/v1/builds/${encodeURIComponent(buildId)}`
2964
+ });
2965
+ if (!result.ok) {
2966
+ return apiFailure2(output, result);
2967
+ }
2968
+ if (output.json) {
2969
+ emitJson(output, result);
2970
+ return exitCodeFor2({ kind: "success" });
2971
+ }
2972
+ if (!isRecord2(result.data)) {
2973
+ return exitCodeFor2({ kind: "success" });
2974
+ }
2975
+ const build = result.data;
2976
+ output.line(`${output.bold("Build")} ${cell(build.buildId)}`);
2977
+ output.line(`${output.bold("Status")} ${cell(build.status)}`);
2978
+ output.line(`${output.bold("Platform")} ${cell(build.platform)}`);
2979
+ output.line(`${output.bold("Version")} ${cell(build.version)}`);
2980
+ const artifactId = asString(build.artifactId);
2981
+ output.line(`${output.bold("Artifact")} ${artifactId ?? "\u2014"}`);
2982
+ if (typeof build.failureCode === "string") {
2983
+ output.line(`${output.bold("Failure")} ${cell(build.failureCode)}`);
2984
+ }
2985
+ if (artifactId !== null) {
2986
+ output.line();
2987
+ output.line(
2988
+ output.dim(
2989
+ `Run against it with: coldtea-qa run test <id> --build ${artifactId}`
2990
+ )
2991
+ );
2992
+ }
2993
+ return exitCodeFor2({ kind: "success" });
2994
+ }
2995
+ };
2996
+ var buildCommands = [buildsUpload, buildsGet];
2997
+
2998
+ // src/commands/merge.ts
2999
+ var MERGE_HELP = `coldtea-qa merge \u2014 merge one project into another
3000
+
3001
+ Usage
3002
+ coldtea-qa merge <sourceProjectId> --into <survivorProjectId> [--yes]
3003
+
3004
+ Flags
3005
+ --into <id> REQUIRED, named on purpose: the surviving project.
3006
+ The source project ends archived and merged into it.
3007
+ --yes Skip the interactive confirmation (CI)
3008
+ --timeout <dur> How long to follow the merge (default 30m)
3009
+
3010
+ The plan is always printed first. Interactively you must type "merge" to
3011
+ proceed; without a terminal, --yes is required or nothing is merged
3012
+ (exit 4). A merge cannot be undone.
3013
+ `;
3014
+ var DEFAULT_MERGE_TIMEOUT_MS = 30 * 6e4;
3015
+ function printMoves(output, moved, verb) {
3016
+ const record = isRecord2(moved) ? moved : {};
3017
+ output.line(
3018
+ ` ${verb}: ${cell(record.groups)} group(s), ${cell(record.tests)} test(s), ${cell(record.runs)} run(s), ${cell(record.environments)} environment(s), ${cell(record.rules)} rule(s), ${cell(record.reportNames)} report name(s)`
3019
+ );
3020
+ output.line(
3021
+ ` ${verb}: ${cell(record.credentials)} credential(s), ${cell(record.testUsers)} test user(s), ${cell(record.authTargets)} sign-in(s)`
3022
+ );
3023
+ }
3024
+ function printRepoOutcome(output, repo) {
3025
+ const record = isRecord2(repo) ? repo : {};
3026
+ switch (record.outcome) {
3027
+ case "none":
3028
+ output.line(" Repo: neither side is linked \u2014 nothing to transfer");
3029
+ break;
3030
+ case "transfer":
3031
+ output.line(
3032
+ ` Repo: ${cell(record.repositoryId)} transfers to the survivor`
3033
+ );
3034
+ break;
3035
+ case "same_repo":
3036
+ output.line(
3037
+ " Repo: both sides link the same repository \u2014 the redundant link is dropped"
3038
+ );
3039
+ break;
3040
+ default:
3041
+ output.line(` Repo: ${cell(record.outcome)}`);
3042
+ }
3043
+ }
3044
+ var merge = {
3045
+ words: ["merge"],
3046
+ summary: "merge <src> --into <id> Merge a project into a survivor",
3047
+ help: MERGE_HELP,
3048
+ flags: {
3049
+ into: { type: "string" },
3050
+ yes: { type: "boolean", default: false },
3051
+ timeout: { type: "string" }
3052
+ },
3053
+ async run(context, output, args) {
3054
+ const source = args.positionals[0];
3055
+ const into = args.values.into;
3056
+ if (!source || typeof into !== "string" || into === "") {
3057
+ return usageError2(
3058
+ output,
3059
+ "Usage: coldtea-qa merge <sourceProjectId> --into <survivorProjectId>"
3060
+ );
3061
+ }
3062
+ let timeoutMs = DEFAULT_MERGE_TIMEOUT_MS;
3063
+ if (typeof args.values.timeout === "string") {
3064
+ const parsed = parseDuration(args.values.timeout);
3065
+ if (parsed === null) {
3066
+ return usageError2(
3067
+ output,
3068
+ "--timeout must be a positive duration like 90s, 10m or 2h"
3069
+ );
3070
+ }
3071
+ timeoutMs = parsed;
3072
+ }
3073
+ const key = requireApiKey2(context, output);
3074
+ if (!key.ok) {
3075
+ return key.code;
3076
+ }
3077
+ const plan = await api(context, key.value, {
3078
+ method: "POST",
3079
+ path: `/v1/projects/${encodeURIComponent(source)}/merge/plan`,
3080
+ body: { into }
3081
+ });
3082
+ if (!plan.ok) {
3083
+ return apiFailure2(output, plan);
3084
+ }
3085
+ const planData = isRecord2(plan.data) ? plan.data : {};
3086
+ output.line(`Merge plan: ${source} \u2192 ${into}`);
3087
+ printMoves(output, planData.moves, "Moves");
3088
+ printRepoOutcome(output, planData.repo);
3089
+ output.line();
3090
+ if (args.values.yes !== true) {
3091
+ if (!output.interactive) {
3092
+ return usageError2(
3093
+ output,
3094
+ "A merge cannot be undone and needs confirmation: re-run with --yes (no interactive terminal to ask)."
3095
+ );
3096
+ }
3097
+ const answer = await context.readLine(
3098
+ `Merge ${source} into ${into}? This cannot be undone. Type "merge" to continue: `
3099
+ );
3100
+ if (answer.trim() !== "merge") {
3101
+ return usageError2(output, "Not confirmed \u2014 nothing was merged.");
3102
+ }
3103
+ }
3104
+ const started = await api(context, key.value, {
3105
+ method: "POST",
3106
+ path: `/v1/projects/${encodeURIComponent(source)}/merge`,
3107
+ body: {
3108
+ into,
3109
+ // Stable on purpose: retrying the same merge replays/resumes it.
3110
+ clientRequestId: `mqcr_merge_${source}_${into}`
3111
+ }
3112
+ });
3113
+ if (!started.ok) {
3114
+ return apiFailure2(output, started);
3115
+ }
3116
+ const mergeId = isRecord2(started.data) ? asString(started.data.mergeId) : null;
3117
+ if (mergeId === null) {
3118
+ output.error(
3119
+ {
3120
+ code: "invalid_response",
3121
+ message: "The merge response is missing expected fields",
3122
+ details: null
3123
+ },
3124
+ started.requestId
3125
+ );
3126
+ return exitCodeFor2({
3127
+ kind: "api_error",
3128
+ status: 0,
3129
+ code: "invalid_response"
3130
+ });
3131
+ }
3132
+ output.line(`Merge ${mergeId} running\u2026`);
3133
+ const result = await poll({
3134
+ timeoutMs,
3135
+ sleep: context.sleep,
3136
+ now: context.now,
3137
+ step: async () => {
3138
+ const read = await api(context, key.value, {
3139
+ method: "GET",
3140
+ path: `/v1/merges/${encodeURIComponent(mergeId)}`
3141
+ });
3142
+ if (!read.ok) {
3143
+ return { failure: read, retryable: isRetryableFailure(read) };
3144
+ }
3145
+ const record2 = isRecord2(read.data) ? read.data : {};
3146
+ const status = asString(record2.status);
3147
+ return {
3148
+ done: status === "completed" || status === "failed",
3149
+ value: { record: record2, requestId: read.requestId }
3150
+ };
3151
+ }
3152
+ });
3153
+ if (result.kind === "failed") {
3154
+ return apiFailure2(output, result.failure);
3155
+ }
3156
+ if (result.kind === "timeout") {
3157
+ output.error({
3158
+ code: "timeout",
3159
+ message: `Merge ${mergeId} was still running after ${formatDuration(timeoutMs)}. It continues server-side; re-run the same command to follow it again.`,
3160
+ details: null
3161
+ });
3162
+ return exitCodeFor2({ kind: "api_error", status: 0, code: "timeout" });
3163
+ }
3164
+ const { record, requestId } = result.value;
3165
+ if (asString(record.status) === "failed") {
3166
+ if (output.json) {
3167
+ output.success(record, {});
3168
+ }
3169
+ output.error(
3170
+ {
3171
+ code: "merge_failed",
3172
+ message: asString(record.failure) ?? "The merge failed part-way; what already moved stays moved. Re-running the same command resumes it.",
3173
+ details: null
3174
+ },
3175
+ requestId
3176
+ );
3177
+ return exitCodeFor2({
3178
+ kind: "api_error",
3179
+ status: 0,
3180
+ code: "merge_failed"
3181
+ });
3182
+ }
3183
+ if (output.json) {
3184
+ output.success(record, {});
3185
+ } else {
3186
+ output.line(`Merged ${source} into ${into}`);
3187
+ printMoves(output, record.moved, "Moved");
3188
+ output.line(output.dim(`References to ${source} now answer as ${into}.`));
3189
+ }
3190
+ return exitCodeFor2({ kind: "success" });
3191
+ }
3192
+ };
3193
+ var mergeCommands = [merge];
3194
+
3195
+ // src/commands/environments.ts
3196
+ var ENVIRONMENTS_HELP = `coldtea-qa environments \u2014 where runs open
3197
+
3198
+ Usage
3199
+ coldtea-qa environments list [--limit <n>] [--cursor <c>]
3200
+ coldtea-qa environments create <name> --url <url>
3201
+ coldtea-qa environments create <name> --url <url> --credential qcr_\u2026 \\
3202
+ (--vercel-project <id> | --any-url)
3203
+ coldtea-qa environments get env_\u2026
3204
+ coldtea-qa environments update env_\u2026 [--name <n>] [--url <url>]
3205
+ [--credential <id>] [--acknowledge]
3206
+ coldtea-qa environments delete env_\u2026
3207
+
3208
+ Flags
3209
+ --url <url> The address runs open, http or https. Required on create.
3210
+ --credential <id> A stored credential to get past a wall in front of it
3211
+ (coldtea-qa credentials list). Needs a scope, below.
3212
+ --vercel-project With --credential: spend it only on this Vercel project's
3213
+ deployments.
3214
+ --any-url With --credential: spend it on every URL a run brings.
3215
+ Unbounded, and said out loud rather than by omission.
3216
+ --acknowledge Required to change the URL or sign-in of an environment
3217
+ that trigger rules depend on. Without it the edit is
3218
+ refused with 409 naming how many rules and which.
3219
+
3220
+ A stored credential always needs one of --vercel-project or --any-url. The
3221
+ address on this row does not bound it: a run brings its own URL, and the scope
3222
+ is the only thing saying which URLs the secret may be spent on.
3223
+
3224
+ Notes
3225
+ list and create need a project (--project or COLDTEA_PROJECT_ID); the rest
3226
+ work on the environment id alone. An environment with no project is visible
3227
+ in every project in the TeaHouse.
3228
+
3229
+ Only WEB environments are creatable here. A mobile environment needs a
3230
+ device profile whose id comes from the provider, so make those in the app \u2014
3231
+ --url is not the missing piece if you were after a mobile one.
3232
+
3233
+ Deleting is refused with exit 3 while a group or test still points at it.
3234
+ `;
3235
+ async function accessForCredential(context, apiKey, output, credentialId) {
3236
+ const MAX_PAGES = 50;
3237
+ let match;
3238
+ let cursor = null;
3239
+ let pages = 0;
3240
+ let unlisted = 0;
3241
+ do {
3242
+ if (pages >= MAX_PAGES) {
3243
+ usageError2(
3244
+ output,
3245
+ `Searched ${MAX_PAGES} pages of credentials without finding ${credentialId}. Check the id, or attach the credential in the app.`
3246
+ );
3247
+ return { ok: false };
3248
+ }
3249
+ pages += 1;
3250
+ const page = await api(context, apiKey, {
3251
+ method: "GET",
3252
+ path: `/v1/credentials?limit=100${cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`}`
3253
+ });
3254
+ if (!page.ok) {
3255
+ apiFailure2(output, page);
3256
+ return { ok: false };
3257
+ }
3258
+ match = (Array.isArray(page.data) ? page.data : []).filter(isRecord2).find((credential) => credential.credentialId === credentialId);
3259
+ if (typeof page.meta.unlistedCredentials === "number") {
3260
+ unlisted = page.meta.unlistedCredentials;
3261
+ }
3262
+ cursor = page.nextCursor;
3263
+ } while (!match && cursor !== null);
3264
+ if (!match) {
3265
+ usageError2(
3266
+ output,
3267
+ unlisted > 0 ? `Could not find credential ${credentialId}. This listing cannot name ${unlisted} credential(s) stored before credentials recorded a TeaHouse, and it may be one of them \u2014 attach it in the Coldtea app, which can see them.` : `No credential ${credentialId} in this TeaHouse. List them with: coldtea-qa credentials list`
3268
+ );
3269
+ return { ok: false };
3270
+ }
3271
+ if (typeof match.wallType !== "string") {
3272
+ usageError2(
3273
+ output,
3274
+ `Credential ${credentialId} has no method recorded, so it cannot be attached to an environment.`
3275
+ );
3276
+ return { ok: false };
3277
+ }
3278
+ return { ok: true, access: { type: match.wallType, credentialId } };
3279
+ }
3280
+ function optionalScope(output, values) {
3281
+ const vercelProject = typeof values["vercel-project"] === "string" ? values["vercel-project"] : void 0;
3282
+ const anyUrl = values["any-url"] === true;
3283
+ if (vercelProject !== void 0 && anyUrl) {
3284
+ usageError2(
3285
+ output,
3286
+ "Pick one: --vercel-project <id> bounds the credential to that project, --any-url does not bound it at all"
3287
+ );
3288
+ return { ok: false };
3289
+ }
3290
+ if (vercelProject !== void 0) {
3291
+ return { ok: true, scope: { vercelProjectId: vercelProject } };
3292
+ }
3293
+ if (anyUrl) {
3294
+ return { ok: true, scope: { allowAnyUrl: true } };
3295
+ }
3296
+ return { ok: true, scope: null };
3297
+ }
3298
+ function scopeForBorrow(output, values) {
3299
+ const vercelProject = typeof values["vercel-project"] === "string" ? values["vercel-project"] : void 0;
3300
+ const anyUrl = values["any-url"] === true;
3301
+ if (vercelProject !== void 0 && anyUrl) {
3302
+ usageError2(
3303
+ output,
3304
+ "Pick one: --vercel-project <id> bounds the credential to that project, --any-url does not bound it at all"
3305
+ );
3306
+ return { ok: false };
3307
+ }
3308
+ if (vercelProject !== void 0) {
3309
+ return { ok: true, scope: { vercelProjectId: vercelProject } };
3310
+ }
3311
+ if (anyUrl) {
3312
+ return { ok: true, scope: { allowAnyUrl: true } };
3313
+ }
3314
+ usageError2(
3315
+ output,
3316
+ "--credential needs a scope: the address on this environment does not bound the secret, because a run brings its own URL.\n --vercel-project <id> spend it only on that Vercel project's deployments\n --any-url spend it on every URL a run brings (unbounded)"
3317
+ );
3318
+ return { ok: false };
3319
+ }
3320
+ function checkUrlScheme(output, url) {
3321
+ let parsed;
3322
+ try {
3323
+ parsed = new URL(url);
3324
+ } catch {
3325
+ usageError2(
3326
+ output,
3327
+ `--url must be a full address, e.g. https://staging.example \u2014 "${url}" is not one`
3328
+ );
3329
+ return false;
3330
+ }
3331
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
3332
+ usageError2(
3333
+ output,
3334
+ `--url must be http or https \u2014 "${parsed.protocol}" is not an address a run can open`
3335
+ );
3336
+ return false;
3337
+ }
3338
+ return true;
3339
+ }
3340
+ function printEnvironment(output, environment) {
3341
+ output.line(
3342
+ `${output.bold("Environment")} ${cell(environment.name)} ${output.dim(`(${cell(environment.environmentId)})`)}`
3343
+ );
3344
+ output.line(`${output.bold("Kind")} ${cell(environment.kind)}`);
3345
+ const web = isRecord2(environment.web) ? environment.web : null;
3346
+ if (web) {
3347
+ output.line(`${output.bold("URL")} ${cell(web.url)}`);
3348
+ const access = isRecord2(web.access) ? cell(web.access.type) : "none";
3349
+ output.line(`${output.bold("Access")} ${access}`);
3350
+ if (isRecord2(web.access) && web.access.type !== "none") {
3351
+ const scope = isRecord2(web.scope) ? web.scope : null;
3352
+ output.line(
3353
+ `${output.bold("Scope")} ${scope === null ? "unscoped \u2014 this credential may be spent on any URL a run brings" : scope.allowAnyUrl === true ? "any URL (unbounded)" : `Vercel project ${cell(scope.vercelProjectId)}`}`
3354
+ );
3355
+ }
3356
+ }
3357
+ const mobile = isRecord2(environment.mobile) ? environment.mobile : null;
3358
+ if (mobile) {
3359
+ output.line(`${output.bold("Platform")} ${cell(mobile.platform)}`);
3360
+ }
3361
+ output.line(
3362
+ `${output.bold("Project")} ${environment.projectId === void 0 ? "every project in this TeaHouse" : cell(environment.projectId)}`
3363
+ );
3364
+ output.line(
3365
+ `${output.bold("Credential")} ${cell(environment.credentialName)}`
3366
+ );
3367
+ output.line(
3368
+ `${output.bold("Created")} ${formatEpoch(environment.createdAt)}`
3369
+ );
3370
+ }
3371
+ var environmentsList = {
3372
+ words: ["environments", "list"],
3373
+ summary: "environments list List where runs can open",
3374
+ help: ENVIRONMENTS_HELP,
3375
+ flags: { ...LIST_FLAGS },
3376
+ async run(context, output, args) {
3377
+ const key = requireApiKey2(context, output);
3378
+ if (!key.ok) {
3379
+ return key.code;
3380
+ }
3381
+ const project = requireProjectId(context, output);
3382
+ if (!project.ok) {
3383
+ return project.code;
3384
+ }
3385
+ const query = listQuery(args.values);
3386
+ const suffix = query.length > 0 ? `?${query.join("&")}` : "";
3387
+ const result = await api(context, key.value, {
3388
+ method: "GET",
3389
+ path: `/v1/projects/${encodeURIComponent(project.value)}/environments${suffix}`
3390
+ });
3391
+ if (!result.ok) {
3392
+ return apiFailure2(output, result);
3393
+ }
3394
+ if (output.json) {
3395
+ emitListJson(output, result);
3396
+ return exitCodeFor2({ kind: "success" });
3397
+ }
3398
+ const environments = Array.isArray(result.data) ? result.data : [];
3399
+ if (environments.length === 0) {
3400
+ output.line(
3401
+ 'No environments. Create one: coldtea-qa environments create "Staging" --url <url>'
3402
+ );
3403
+ } else {
3404
+ output.line(
3405
+ renderTable(
3406
+ ["ID", "NAME", "KIND", "URL", "CREDENTIAL"],
3407
+ environments.filter(isRecord2).map((environment) => {
3408
+ const web = isRecord2(environment.web) ? environment.web : null;
3409
+ const mobile = isRecord2(environment.mobile) ? environment.mobile : null;
3410
+ return [
3411
+ cell(environment.environmentId),
3412
+ cell(environment.name),
3413
+ cell(environment.kind),
3414
+ web ? cell(web.url) : mobile ? cell(mobile.platform) : "\u2014",
3415
+ cell(environment.credentialName)
3416
+ ];
3417
+ })
3418
+ )
3419
+ );
3420
+ }
3421
+ nextCursorFootnote(output, result.nextCursor);
3422
+ return exitCodeFor2({ kind: "success" });
3423
+ }
3424
+ };
3425
+ var environmentsCreate = {
3426
+ words: ["environments", "create"],
3427
+ summary: "environments create <name> Create a web environment (--url)",
3428
+ help: ENVIRONMENTS_HELP,
3429
+ flags: {
3430
+ url: { type: "string" },
3431
+ credential: { type: "string" },
3432
+ "vercel-project": { type: "string" },
3433
+ "any-url": { type: "boolean", default: false }
3434
+ },
3435
+ async run(context, output, args) {
3436
+ const name = args.positionals[0];
3437
+ const url = args.values.url;
3438
+ if (!name || typeof url !== "string") {
3439
+ return usageError2(
3440
+ output,
3441
+ "Usage: coldtea-qa environments create <name> --url <url>"
3442
+ );
3443
+ }
3444
+ if (!checkUrlScheme(output, url)) {
3445
+ return exitCodeFor2({ kind: "config_error" });
3446
+ }
3447
+ const key = requireApiKey2(context, output);
3448
+ if (!key.ok) {
3449
+ return key.code;
3450
+ }
3451
+ const project = requireProjectId(context, output);
3452
+ if (!project.ok) {
3453
+ return project.code;
3454
+ }
3455
+ const web = { url, access: { type: "none" } };
3456
+ if (typeof args.values.credential === "string") {
3457
+ const scope = scopeForBorrow(output, args.values);
3458
+ if (!scope.ok) {
3459
+ return exitCodeFor2({ kind: "config_error" });
3460
+ }
3461
+ const access = await accessForCredential(
3462
+ context,
3463
+ key.value,
3464
+ output,
3465
+ args.values.credential
3466
+ );
3467
+ if (!access.ok) {
3468
+ return exitCodeFor2({ kind: "config_error" });
3469
+ }
3470
+ web.access = access.access;
3471
+ web.scope = scope.scope;
3472
+ } else if (typeof args.values["vercel-project"] === "string" || args.values["any-url"] === true) {
3473
+ return usageError2(
3474
+ output,
3475
+ "--vercel-project and --any-url only mean something with --credential: a scope bounds a stored secret, and this environment has none"
3476
+ );
3477
+ }
3478
+ const result = await api(context, key.value, {
3479
+ method: "POST",
3480
+ path: `/v1/projects/${encodeURIComponent(project.value)}/environments`,
3481
+ body: { name, kind: "web", web }
3482
+ });
3483
+ if (!result.ok) {
3484
+ return apiFailure2(output, result);
3485
+ }
3486
+ if (output.json) {
3487
+ emitJson(output, result);
3488
+ return exitCodeFor2({ kind: "success" });
3489
+ }
3490
+ if (isRecord2(result.data)) {
3491
+ output.line(`Created environment ${cell(result.data.environmentId)}`);
3492
+ printEnvironment(output, result.data);
3493
+ }
3494
+ return exitCodeFor2({ kind: "success" });
3495
+ }
3496
+ };
3497
+ var environmentsGet = {
3498
+ words: ["environments", "get"],
3499
+ summary: "environments get <id> Show one environment",
3500
+ help: ENVIRONMENTS_HELP,
3501
+ flags: {},
3502
+ async run(context, output, args) {
3503
+ const environmentId = args.positionals[0];
3504
+ if (!environmentId) {
3505
+ return usageError2(output, "Usage: coldtea-qa environments get <id>");
3506
+ }
3507
+ const key = requireApiKey2(context, output);
3508
+ if (!key.ok) {
3509
+ return key.code;
3510
+ }
3511
+ const result = await api(context, key.value, {
3512
+ method: "GET",
3513
+ path: `/v1/environments/${encodeURIComponent(environmentId)}`
3514
+ });
3515
+ if (!result.ok) {
3516
+ return apiFailure2(output, result);
3517
+ }
3518
+ if (output.json) {
3519
+ emitJson(output, result);
3520
+ return exitCodeFor2({ kind: "success" });
3521
+ }
3522
+ if (isRecord2(result.data)) {
3523
+ printEnvironment(output, result.data);
3524
+ }
3525
+ return exitCodeFor2({ kind: "success" });
3526
+ }
3527
+ };
3528
+ var environmentsUpdate = {
3529
+ words: ["environments", "update"],
3530
+ summary: "environments update <id> Edit one (--acknowledge for rules)",
3531
+ help: ENVIRONMENTS_HELP,
3532
+ flags: {
3533
+ name: { type: "string" },
3534
+ url: { type: "string" },
3535
+ credential: { type: "string" },
3536
+ "vercel-project": { type: "string" },
3537
+ "any-url": { type: "boolean", default: false },
3538
+ acknowledge: { type: "boolean", default: false }
3539
+ },
3540
+ async run(context, output, args) {
3541
+ const environmentId = args.positionals[0];
3542
+ if (!environmentId) {
3543
+ return usageError2(
3544
+ output,
3545
+ "Usage: coldtea-qa environments update <id> [--name] [--url] [--credential]"
3546
+ );
3547
+ }
3548
+ const wantsUpdate = typeof args.values.name === "string" || typeof args.values.url === "string" || typeof args.values.credential === "string";
3549
+ if (!wantsUpdate) {
3550
+ return usageError2(
3551
+ output,
3552
+ "Nothing to update: pass at least one of --name, --url, --credential"
3553
+ );
3554
+ }
3555
+ if (typeof args.values.url === "string" && !checkUrlScheme(output, args.values.url)) {
3556
+ return exitCodeFor2({ kind: "config_error" });
3557
+ }
3558
+ if (typeof args.values.credential !== "string" && (typeof args.values["vercel-project"] === "string" || args.values["any-url"] === true)) {
3559
+ return usageError2(
3560
+ output,
3561
+ "--vercel-project and --any-url only mean something with --credential: a scope bounds a stored secret"
3562
+ );
3563
+ }
3564
+ const key = requireApiKey2(context, output);
3565
+ if (!key.ok) {
3566
+ return key.code;
3567
+ }
3568
+ const body = {};
3569
+ if (typeof args.values.name === "string") {
3570
+ body.name = args.values.name;
3571
+ }
3572
+ const web = {};
3573
+ if (typeof args.values.url === "string") {
3574
+ web.url = args.values.url;
3575
+ }
3576
+ if (typeof args.values.credential === "string") {
3577
+ const scope = optionalScope(output, args.values);
3578
+ if (!scope.ok) {
3579
+ return exitCodeFor2({ kind: "config_error" });
3580
+ }
3581
+ const access = await accessForCredential(
3582
+ context,
3583
+ key.value,
3584
+ output,
3585
+ args.values.credential
3586
+ );
3587
+ if (!access.ok) {
3588
+ return exitCodeFor2({ kind: "config_error" });
3589
+ }
3590
+ web.access = access.access;
3591
+ if (scope.scope !== null) {
3592
+ web.scope = scope.scope;
3593
+ }
3594
+ }
3595
+ if (Object.keys(web).length > 0) {
3596
+ body.web = web;
3597
+ }
3598
+ if (args.values.acknowledge === true) {
3599
+ body.acknowledgeAffectedRules = true;
3600
+ }
3601
+ const result = await api(context, key.value, {
3602
+ method: "PATCH",
3603
+ path: `/v1/environments/${encodeURIComponent(environmentId)}`,
3604
+ body
3605
+ });
3606
+ if (!result.ok) {
3607
+ const code = apiFailure2(output, result);
3608
+ if (result.error.code === "invalid_request" && /web\.scope is required/.test(result.error.message)) {
3609
+ output.errorNote(
3610
+ " This environment has no scope to keep. Add --vercel-project <id> to bound the credential to one Vercel project, or --any-url to spend it on every URL a run brings."
3611
+ );
3612
+ }
3613
+ if (result.error.code === "environment_edit_affects_rules") {
3614
+ output.errorNote(
3615
+ " Trigger rules depend on this environment. Re-run with --acknowledge to change it anyway."
3616
+ );
3617
+ }
3618
+ return code;
3619
+ }
3620
+ if (output.json) {
3621
+ emitJson(output, result);
3622
+ return exitCodeFor2({ kind: "success" });
3623
+ }
3624
+ if (isRecord2(result.data)) {
3625
+ output.line(`Updated environment ${cell(result.data.environmentId)}`);
3626
+ if (args.values["any-url"] === true) {
3627
+ output.line(
3628
+ output.dim(
3629
+ "Scope is now UNBOUNDED: this credential may be spent on any URL a run brings. Re-bound it with --vercel-project <id>."
3630
+ )
3631
+ );
3632
+ }
3633
+ printEnvironment(output, result.data);
3634
+ }
3635
+ return exitCodeFor2({ kind: "success" });
3636
+ }
3637
+ };
3638
+ var environmentsDelete = {
3639
+ words: ["environments", "delete"],
3640
+ summary: "environments delete <id> Delete one (refused while in use)",
3641
+ help: ENVIRONMENTS_HELP,
3642
+ flags: {},
3643
+ async run(context, output, args) {
3644
+ const environmentId = args.positionals[0];
3645
+ if (!environmentId) {
3646
+ return usageError2(output, "Usage: coldtea-qa environments delete <id>");
3647
+ }
3648
+ const key = requireApiKey2(context, output);
3649
+ if (!key.ok) {
3650
+ return key.code;
3651
+ }
3652
+ const result = await api(context, key.value, {
3653
+ method: "DELETE",
3654
+ path: `/v1/environments/${encodeURIComponent(environmentId)}`
3655
+ });
3656
+ if (!result.ok) {
3657
+ return apiFailure2(output, result);
3658
+ }
3659
+ output.line(`Deleted environment ${environmentId}`);
3660
+ return exitCodeFor2({ kind: "success" });
3661
+ }
3662
+ };
3663
+ var environmentCommands = [
3664
+ environmentsList,
3665
+ environmentsCreate,
3666
+ environmentsGet,
3667
+ environmentsUpdate,
3668
+ environmentsDelete
3669
+ ];
3670
+
3671
+ // src/commands/rules.ts
3672
+ var RULES_HELP = `coldtea-qa rules \u2014 run QA without being asked
3673
+
3674
+ Usage
3675
+ coldtea-qa rules list [--limit <n>] [--cursor <c>]
3676
+ coldtea-qa rules create <name> --webhook --groups mqg_a,mqg_b
3677
+ coldtea-qa rules create <name> --deployment [--branch main] --groups \u2026
3678
+ coldtea-qa rules create <name> --schedule "0 3 * * *" --against env_\u2026 \\
3679
+ [--timezone UTC]
3680
+ coldtea-qa rules update qtr_\u2026 [--name <n>] [--enable | --disable]
3681
+ coldtea-qa rules delete qtr_\u2026
3682
+
3683
+ When to run (exactly one)
3684
+ --webhook Your CI calls a URL. The URL is returned ONCE, on create.
3685
+ --deployment A deployment of the linked repository succeeded.
3686
+ --schedule <cron> On a clock, e.g. "0 3 * * *".
3687
+
3688
+ Flags
3689
+ --groups <ids> Comma-separated group ids to run. Required on create.
3690
+ --branch <name> With --deployment: only this branch.
3691
+ --pr-only With --deployment: only deployments for a pull request.
3692
+ This is already the default \u2014 pass it to be explicit.
3693
+ --all-deployments With --deployment: every deployment, INCLUDING direct
3694
+ pushes to main. Wider than the default, so it is a flag
3695
+ you type rather than one you get by saying nothing.
3696
+ --timezone <tz> With --schedule: defaults to UTC.
3697
+ --checks Report a check back onto the pull request.
3698
+ --against <id> Run in this environment instead of each group's.
3699
+ REQUIRED with --schedule: a schedule has no deployment to
3700
+ bring an address with it, so something must say where the
3701
+ run opens.
3702
+
3703
+ This is the only way a run reports a check onto a pull request. Starting a run
3704
+ directly stamps the commit and posts nothing \u2014 check names are reserved per
3705
+ repository by the rule that owns them.
3706
+
3707
+ list and create need a project (--project or COLDTEA_PROJECT_ID); update and
3708
+ delete work on the rule id alone.
3709
+ `;
3710
+ function describeWhen(when) {
3711
+ if (!isRecord2(when)) {
3712
+ return "\u2014";
3713
+ }
3714
+ if (when.type === "schedule") {
3715
+ return `schedule ${cell(when.cron)} ${cell(when.timezone)}`;
3716
+ }
3717
+ if (when.type === "webhook") {
3718
+ return "webhook";
3719
+ }
3720
+ if (when.type === "deployment") {
3721
+ const match = isRecord2(when.match) ? when.match : {};
3722
+ const branch = match.branch === null ? "any branch" : cell(match.branch);
3723
+ return `deployment ${branch}`;
3724
+ }
3725
+ return cell(when.type);
3726
+ }
3727
+ function printRule(output, rule) {
3728
+ output.line(
3729
+ `${output.bold("Rule")} ${cell(rule.name)} ${output.dim(`(${cell(rule.ruleId)})`)}`
3730
+ );
3731
+ output.line(
3732
+ `${output.bold("When")} ${describeWhen(rule.when)}${rule.enabled === false ? " (disabled)" : ""}`
3733
+ );
3734
+ const run = isRecord2(rule.run) ? rule.run : {};
3735
+ output.line(
3736
+ `${output.bold("Runs")} ${Array.isArray(run.groupIds) ? run.groupIds.join(", ") : "\u2014"}`
3737
+ );
3738
+ output.line(
3739
+ `${output.bold("Checks")} ${rule.checkRunsEnabled === true ? "yes" : "no"}`
3740
+ );
3741
+ output.line(`${output.bold("Against")} ${cell(rule.against)}`);
3742
+ output.line(`${output.bold("Created")} ${formatEpoch(rule.createdAt)}`);
3743
+ if (typeof rule.url === "string") {
3744
+ output.line();
3745
+ output.line(`${output.bold("Hook URL")} ${rule.url}`);
3746
+ output.line(
3747
+ output.dim(
3748
+ "Shown once \u2014 it contains the token. Store it in your CI's secrets now; there is no way to read it back."
3749
+ )
3750
+ );
3751
+ }
3752
+ }
3753
+ var rulesList = {
3754
+ words: ["rules", "list"],
3755
+ summary: "rules list List a project's trigger rules",
3756
+ help: RULES_HELP,
3757
+ flags: { ...LIST_FLAGS },
3758
+ async run(context, output, args) {
3759
+ const key = requireApiKey2(context, output);
3760
+ if (!key.ok) {
3761
+ return key.code;
3762
+ }
3763
+ const project = requireProjectId(context, output);
3764
+ if (!project.ok) {
3765
+ return project.code;
3766
+ }
3767
+ const query = listQuery(args.values);
3768
+ const suffix = query.length > 0 ? `?${query.join("&")}` : "";
3769
+ const result = await api(context, key.value, {
3770
+ method: "GET",
3771
+ path: `/v1/projects/${encodeURIComponent(project.value)}/rules${suffix}`
3772
+ });
3773
+ if (!result.ok) {
3774
+ return apiFailure2(output, result);
3775
+ }
3776
+ if (output.json) {
3777
+ emitListJson(output, result);
3778
+ return exitCodeFor2({ kind: "success" });
3779
+ }
3780
+ const rules = Array.isArray(result.data) ? result.data : [];
3781
+ if (rules.length === 0) {
3782
+ output.line(
3783
+ 'No rules, so QA runs only when something asks it to. Create one: coldtea-qa rules create "CI" --webhook --groups <ids>'
3784
+ );
3785
+ } else {
3786
+ output.line(
3787
+ renderTable(
3788
+ ["ID", "NAME", "WHEN", "ENABLED", "CHECKS"],
3789
+ rules.filter(isRecord2).map((rule) => [
3790
+ cell(rule.ruleId),
3791
+ cell(rule.name),
3792
+ describeWhen(rule.when),
3793
+ rule.enabled === false ? "no" : "yes",
3794
+ rule.checkRunsEnabled === true ? "yes" : "no"
3795
+ ])
3796
+ )
3797
+ );
3798
+ }
3799
+ nextCursorFootnote(output, result.nextCursor);
3800
+ return exitCodeFor2({ kind: "success" });
3801
+ }
3802
+ };
3803
+ var rulesCreate = {
3804
+ words: ["rules", "create"],
3805
+ summary: "rules create <name> Create a trigger rule (one --when)",
3806
+ help: RULES_HELP,
3807
+ flags: {
3808
+ webhook: { type: "boolean", default: false },
3809
+ deployment: { type: "boolean", default: false },
3810
+ schedule: { type: "string" },
3811
+ timezone: { type: "string" },
3812
+ branch: { type: "string" },
3813
+ "pr-only": { type: "boolean", default: false },
3814
+ "all-deployments": { type: "boolean", default: false },
3815
+ groups: { type: "string" },
3816
+ checks: { type: "boolean", default: false },
3817
+ against: { type: "string" }
3818
+ },
3819
+ async run(context, output, args) {
3820
+ const name = args.positionals[0];
3821
+ if (!name) {
3822
+ return usageError2(
3823
+ output,
3824
+ "Usage: coldtea-qa rules create <name> (--webhook | --deployment | --schedule <cron>) --groups <ids>"
3825
+ );
3826
+ }
3827
+ const chosen = [];
3828
+ if (args.values.webhook === true) {
3829
+ chosen.push({ type: "webhook" });
3830
+ }
3831
+ if (args.values.deployment === true) {
3832
+ const prOnly = args.values["pr-only"] === true;
3833
+ const allDeployments = args.values["all-deployments"] === true;
3834
+ if (prOnly && allDeployments) {
3835
+ return usageError2(
3836
+ output,
3837
+ "Pick one: --pr-only runs on pull-request deployments, --all-deployments runs on every deployment including pushes to main"
3838
+ );
3839
+ }
3840
+ const match = {
3841
+ branch: typeof args.values.branch === "string" ? args.values.branch : null,
3842
+ source: null
3843
+ };
3844
+ if (prOnly) {
3845
+ match.requiresPullRequest = true;
3846
+ }
3847
+ if (allDeployments) {
3848
+ match.requiresPullRequest = false;
3849
+ }
3850
+ chosen.push({ type: "deployment", match });
3851
+ }
3852
+ if (typeof args.values.schedule === "string") {
3853
+ chosen.push({
3854
+ type: "schedule",
3855
+ cron: args.values.schedule,
3856
+ timezone: typeof args.values.timezone === "string" ? args.values.timezone : "UTC"
3857
+ });
3858
+ }
3859
+ if (chosen.length !== 1) {
3860
+ return usageError2(
3861
+ output,
3862
+ "A rule needs exactly one trigger: --webhook, --deployment, or --schedule <cron>"
3863
+ );
3864
+ }
3865
+ const groupIds = typeof args.values.groups === "string" ? args.values.groups.split(",").map((id) => id.trim()).filter((id) => id !== "") : [];
3866
+ if (groupIds.length === 0) {
3867
+ return usageError2(
3868
+ output,
3869
+ "--groups is required: comma-separated group ids this rule runs (coldtea-qa groups list)"
3870
+ );
3871
+ }
3872
+ const key = requireApiKey2(context, output);
3873
+ if (!key.ok) {
3874
+ return key.code;
3875
+ }
3876
+ const project = requireProjectId(context, output);
3877
+ if (!project.ok) {
3878
+ return project.code;
3879
+ }
3880
+ const body = {
3881
+ name,
3882
+ when: chosen[0],
3883
+ run: { groupIds, explorationEnabled: false },
3884
+ checkRunsEnabled: args.values.checks === true
3885
+ };
3886
+ if (typeof args.values.against === "string") {
3887
+ body.against = args.values.against;
3888
+ }
3889
+ const result = await api(context, key.value, {
3890
+ method: "POST",
3891
+ path: `/v1/projects/${encodeURIComponent(project.value)}/rules`,
3892
+ body
3893
+ });
3894
+ if (!result.ok) {
3895
+ const code = apiFailure2(output, result);
3896
+ if (result.error.code === "report_name_taken") {
3897
+ output.errorNote(
3898
+ " Another project has reserved that check name on this repository. Rename the rule, or unlink there."
3899
+ );
3900
+ }
3901
+ return code;
3902
+ }
3903
+ if (output.json) {
3904
+ emitJson(output, result);
3905
+ return exitCodeFor2({ kind: "success" });
3906
+ }
3907
+ if (isRecord2(result.data)) {
3908
+ output.line(`Created rule ${cell(result.data.ruleId)}`);
3909
+ printRule(output, result.data);
3910
+ }
3911
+ return exitCodeFor2({ kind: "success" });
3912
+ }
3913
+ };
3914
+ var rulesUpdate = {
3915
+ words: ["rules", "update"],
3916
+ summary: "rules update <id> Rename, enable or disable a rule",
3917
+ help: RULES_HELP,
3918
+ flags: {
3919
+ name: { type: "string" },
3920
+ enable: { type: "boolean", default: false },
3921
+ disable: { type: "boolean", default: false },
3922
+ checks: { type: "boolean", default: false },
3923
+ "no-checks": { type: "boolean", default: false }
3924
+ },
3925
+ async run(context, output, args) {
3926
+ const ruleId = args.positionals[0];
3927
+ if (!ruleId) {
3928
+ return usageError2(
3929
+ output,
3930
+ "Usage: coldtea-qa rules update <id> [--name <n>] [--enable | --disable]"
3931
+ );
3932
+ }
3933
+ if (args.values.enable === true && args.values.disable === true) {
3934
+ return usageError2(output, "Pick one: --enable or --disable");
3935
+ }
3936
+ if (args.values.checks === true && args.values["no-checks"] === true) {
3937
+ return usageError2(output, "Pick one: --checks or --no-checks");
3938
+ }
3939
+ const body = {};
3940
+ if (typeof args.values.name === "string") {
3941
+ body.name = args.values.name;
3942
+ }
3943
+ if (args.values.enable === true) {
3944
+ body.enabled = true;
3945
+ }
3946
+ if (args.values.disable === true) {
3947
+ body.enabled = false;
3948
+ }
3949
+ if (args.values.checks === true) {
3950
+ body.checkRunsEnabled = true;
3951
+ }
3952
+ if (args.values["no-checks"] === true) {
3953
+ body.checkRunsEnabled = false;
3954
+ }
3955
+ if (Object.keys(body).length === 0) {
3956
+ return usageError2(
3957
+ output,
3958
+ "Nothing to update: pass --name, --enable/--disable, or --checks/--no-checks"
3959
+ );
3960
+ }
3961
+ const key = requireApiKey2(context, output);
3962
+ if (!key.ok) {
3963
+ return key.code;
3964
+ }
3965
+ const result = await api(context, key.value, {
3966
+ method: "PATCH",
3967
+ path: `/v1/rules/${encodeURIComponent(ruleId)}`,
3968
+ body
3969
+ });
3970
+ if (!result.ok) {
3971
+ return apiFailure2(output, result);
3972
+ }
3973
+ if (output.json) {
3974
+ emitJson(output, result);
3975
+ return exitCodeFor2({ kind: "success" });
3976
+ }
3977
+ if (isRecord2(result.data)) {
3978
+ output.line(`Updated rule ${cell(result.data.ruleId)}`);
3979
+ printRule(output, result.data);
3980
+ }
3981
+ return exitCodeFor2({ kind: "success" });
3982
+ }
3983
+ };
3984
+ var rulesDelete = {
3985
+ words: ["rules", "delete"],
3986
+ summary: "rules delete <id> Delete a rule",
3987
+ help: RULES_HELP,
3988
+ flags: {},
3989
+ async run(context, output, args) {
3990
+ const ruleId = args.positionals[0];
3991
+ if (!ruleId) {
3992
+ return usageError2(output, "Usage: coldtea-qa rules delete <id>");
3993
+ }
3994
+ const key = requireApiKey2(context, output);
3995
+ if (!key.ok) {
3996
+ return key.code;
3997
+ }
3998
+ const result = await api(context, key.value, {
3999
+ method: "DELETE",
4000
+ path: `/v1/rules/${encodeURIComponent(ruleId)}`
4001
+ });
4002
+ if (!result.ok) {
4003
+ return apiFailure2(output, result);
4004
+ }
4005
+ output.line(`Deleted rule ${ruleId}`);
4006
+ output.line(
4007
+ output.dim(
4008
+ "The check name it reserved on the repository is free for another project now."
4009
+ )
4010
+ );
4011
+ return exitCodeFor2({ kind: "success" });
4012
+ }
4013
+ };
4014
+ var ruleCommands = [
4015
+ rulesList,
4016
+ rulesCreate,
4017
+ rulesUpdate,
4018
+ rulesDelete
4019
+ ];
4020
+
4021
+ // src/commands/credentials.ts
4022
+ var CREDENTIALS_HELP = `coldtea-qa credentials \u2014 stored secrets
4023
+
4024
+ Usage
4025
+ coldtea-qa credentials list [--project prj_\u2026] [--limit <n>] [--cursor <c>]
4026
+ echo -n "<secret>" | coldtea-qa credentials create <name> --method <m> \\
4027
+ [--username <u>] [--project prj_\u2026] [--environment env_\u2026]
4028
+ coldtea-qa credentials rename qcr_\u2026 --name "<new name>"
4029
+ echo -n "<secret>" | coldtea-qa credentials rotate qcr_\u2026 --method <m> [--username <u>]
4030
+ coldtea-qa credentials delete qcr_\u2026
4031
+
4032
+ Methods
4033
+ vercel_bypass A Vercel protection bypass token
4034
+ basic_auth HTTP basic auth; needs --username too
4035
+ custom_headers A flat JSON object of header names to values, sent as
4036
+ the name/value pairs the API wants.
4037
+
4038
+ The secret is read from STDIN, never from a flag \u2014 a command line is visible
4039
+ in shell history, in \`ps\`, and in CI logs. Pipe it:
4040
+
4041
+ echo -n "$VERCEL_BYPASS" | coldtea-qa credentials create "Preview wall" \\
4042
+ --method vercel_bypass
4043
+
4044
+ Where it sits
4045
+ --project <id> The project this credential belongs to. Without the
4046
+ FLAG it is TeaHouse-wide \u2014 spendable from every project
4047
+ in the account. Placement is permanent: nothing can move
4048
+ a credential afterwards. COLDTEA_PROJECT_ID does not
4049
+ place one, on purpose; \`list\` does read it.
4050
+ --environment <id> The environment that owns it. Without one it belongs to
4051
+ nobody and stays attachable anywhere.
4052
+
4053
+ Nothing reads a secret back. \`list\` shows metadata only, and rotating answers
4054
+ with the method and a timestamp. A listing may also say that credentials exist
4055
+ it cannot name \u2014 those were stored before credentials recorded a TeaHouse, and
4056
+ the Coldtea app shows them.
4057
+
4058
+ Deleting is refused with exit 3 while an environment still references it.
4059
+ `;
4060
+ var METHODS = ["vercel_bypass", "basic_auth", "custom_headers"];
4061
+ function readMethod(output, value) {
4062
+ if (typeof value !== "string" || !METHODS.includes(value)) {
4063
+ usageError2(output, `--method must be one of: ${METHODS.join(", ")}`);
4064
+ return null;
4065
+ }
4066
+ return value;
4067
+ }
4068
+ async function readSecretFromStdin(output, stdinIsTTY, readStdin) {
4069
+ if (stdinIsTTY) {
4070
+ usageError2(
4071
+ output,
4072
+ 'Pipe the secret in rather than typing it as an argument, e.g.\n echo -n "$TOKEN" | coldtea-qa credentials create "Preview wall" --method vercel_bypass'
4073
+ );
4074
+ return null;
4075
+ }
4076
+ const secret = (await readStdin()).replace(/\r?\n$/, "");
4077
+ if (secret === "") {
4078
+ usageError2(
4079
+ output,
4080
+ "Nothing arrived on stdin, so there is no secret to store."
4081
+ );
4082
+ return null;
4083
+ }
4084
+ return secret;
4085
+ }
4086
+ function secretPayload(output, method, secret, values) {
4087
+ if (method === "vercel_bypass") {
4088
+ return { method, token: secret };
4089
+ }
4090
+ if (method === "basic_auth") {
4091
+ if (typeof values.username !== "string" || values.username === "") {
4092
+ usageError2(output, "--username is required with --method basic_auth");
4093
+ return null;
4094
+ }
4095
+ return { method, username: values.username, password: secret };
4096
+ }
4097
+ let parsed;
4098
+ try {
4099
+ parsed = JSON.parse(secret);
4100
+ } catch {
4101
+ usageError2(
4102
+ output,
4103
+ 'With --method custom_headers the piped value must be JSON, e.g. {"X-Api-Key":"\u2026"}'
4104
+ );
4105
+ return null;
4106
+ }
4107
+ if (!isRecord2(parsed)) {
4108
+ usageError2(
4109
+ output,
4110
+ "custom_headers must be a JSON object of header names to values."
4111
+ );
4112
+ return null;
4113
+ }
4114
+ const headers = Object.entries(parsed).map(([name, value]) => ({
4115
+ name,
4116
+ value
4117
+ }));
4118
+ if (headers.length === 0) {
4119
+ usageError2(output, "custom_headers needs at least one header.");
4120
+ return null;
4121
+ }
4122
+ const MAX_HEADERS = 20;
4123
+ if (headers.length > MAX_HEADERS) {
4124
+ usageError2(
4125
+ output,
4126
+ `${headers.length} headers \u2014 the most a credential can hold is ${MAX_HEADERS}.`
4127
+ );
4128
+ return null;
4129
+ }
4130
+ const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
4131
+ const invalidName = headers.find(
4132
+ ({ name }) => !HEADER_NAME.test(name.trim())
4133
+ );
4134
+ if (invalidName) {
4135
+ const name = invalidName.name;
4136
+ const reason = name.trim() === "" ? "a header name cannot be empty" : /[\s:]/.test(name) ? "header names cannot contain spaces or colons" : "header names may only use letters, digits and !#$%&'*+-.^_`|~";
4137
+ const suggestion = name.trim().replace(/\s+/g, "-");
4138
+ usageError2(
4139
+ output,
4140
+ `"${name}" is not a valid header name \u2014 ${reason}.` + (suggestion !== name && HEADER_NAME.test(suggestion) ? ` Did you mean "${suggestion}"?` : "")
4141
+ );
4142
+ return null;
4143
+ }
4144
+ const longName = headers.find(({ name }) => name.trim().length > 128);
4145
+ if (longName) {
4146
+ usageError2(
4147
+ output,
4148
+ `Header "${longName.name.slice(0, 32)}\u2026" is ${longName.name.trim().length} characters \u2014 a header name may be at most 128.`
4149
+ );
4150
+ return null;
4151
+ }
4152
+ const notAString = headers.find(({ value }) => typeof value !== "string");
4153
+ if (notAString) {
4154
+ usageError2(
4155
+ output,
4156
+ `Header "${notAString.name}" must be a string. custom_headers is a flat object of header names to values.`
4157
+ );
4158
+ return null;
4159
+ }
4160
+ const emptyValue = headers.find(
4161
+ ({ value }) => value.trim() === ""
4162
+ );
4163
+ if (emptyValue) {
4164
+ usageError2(
4165
+ output,
4166
+ `Header "${emptyValue.name}" has no value. A header a run sends empty is one the wall behind it will not accept.`
4167
+ );
4168
+ return null;
4169
+ }
4170
+ const brokenValue = headers.find(
4171
+ ({ value }) => /[\r\n]/.test(value)
4172
+ );
4173
+ if (brokenValue) {
4174
+ usageError2(
4175
+ output,
4176
+ `Header "${brokenValue.name}" has a line break in its value \u2014 a value that can carry one is a value that can be made to look like two headers.`
4177
+ );
4178
+ return null;
4179
+ }
4180
+ return { method, headers };
4181
+ }
4182
+ function unlistedCount(meta) {
4183
+ return typeof meta.unlistedCredentials === "number" ? meta.unlistedCredentials : 0;
4184
+ }
4185
+ function unlistedFootnote(output, meta) {
4186
+ const unlisted = unlistedCount(meta);
4187
+ if (unlisted === 0) {
4188
+ return;
4189
+ }
4190
+ output.line();
4191
+ output.line(
4192
+ output.dim(
4193
+ `At least ${unlisted} more credential(s) exist that this listing cannot name \u2014 they were stored before credentials recorded a TeaHouse. The Coldtea app shows them.`
4194
+ )
4195
+ );
4196
+ }
4197
+ var credentialsList = {
4198
+ words: ["credentials", "list"],
4199
+ summary: "credentials list List stored secrets (metadata only)",
4200
+ help: CREDENTIALS_HELP,
4201
+ flags: { ...LIST_FLAGS },
4202
+ async run(context, output, args) {
4203
+ const key = requireApiKey2(context, output);
4204
+ if (!key.ok) {
4205
+ return key.code;
4206
+ }
4207
+ const query = listQuery(args.values);
4208
+ if (context.projectId !== null) {
4209
+ query.push(`projectId=${encodeURIComponent(context.projectId)}`);
4210
+ }
4211
+ const suffix = query.length > 0 ? `?${query.join("&")}` : "";
4212
+ const result = await api(context, key.value, {
4213
+ method: "GET",
4214
+ path: `/v1/credentials${suffix}`
4215
+ });
4216
+ if (!result.ok) {
4217
+ return apiFailure2(output, result);
4218
+ }
4219
+ if (output.json) {
4220
+ emitListJson(output, result);
4221
+ return exitCodeFor2({ kind: "success" });
4222
+ }
4223
+ const credentials = Array.isArray(result.data) ? result.data : [];
4224
+ if (credentials.length === 0) {
4225
+ output.line(
4226
+ unlistedCount(result.meta) > 0 ? "No credentials this listing can name." : 'No credentials. Store one: echo -n "$SECRET" | coldtea-qa credentials create "<name>" --method <m>'
4227
+ );
4228
+ } else {
4229
+ output.line(
4230
+ renderTable(
4231
+ ["ID", "NAME", "METHOD", "USED BY", "UPDATED"],
4232
+ credentials.filter(isRecord2).map((credential) => {
4233
+ const used = Array.isArray(credential.environments) ? credential.environments.length : 0;
4234
+ return [
4235
+ cell(credential.credentialId),
4236
+ cell(credential.name),
4237
+ cell(credential.wallType),
4238
+ used === 0 ? "nothing" : `${used} environment(s)`,
4239
+ formatEpoch(credential.updatedAt)
4240
+ ];
4241
+ })
4242
+ )
4243
+ );
4244
+ }
4245
+ unlistedFootnote(output, result.meta);
4246
+ nextCursorFootnote(output, result.nextCursor);
4247
+ return exitCodeFor2({ kind: "success" });
4248
+ }
4249
+ };
4250
+ var credentialsCreate = {
4251
+ words: ["credentials", "create"],
4252
+ summary: "credentials create <name> Store a secret (piped on stdin)",
4253
+ help: CREDENTIALS_HELP,
4254
+ flags: {
4255
+ method: { type: "string" },
4256
+ username: { type: "string" },
4257
+ environment: { type: "string" }
4258
+ },
4259
+ async run(context, output, args) {
4260
+ const name = args.positionals[0];
4261
+ if (!name) {
4262
+ return usageError2(
4263
+ output,
4264
+ 'Usage: echo -n "<secret>" | coldtea-qa credentials create <name> --method <m>'
4265
+ );
4266
+ }
4267
+ const method = readMethod(output, args.values.method);
4268
+ if (method === null) {
4269
+ return exitCodeFor2({ kind: "config_error" });
4270
+ }
4271
+ const key = requireApiKey2(context, output);
4272
+ if (!key.ok) {
4273
+ return key.code;
4274
+ }
4275
+ const secret = await readSecretFromStdin(
4276
+ output,
4277
+ output.stdinIsTTY,
4278
+ context.readStdin
4279
+ );
4280
+ if (secret === null) {
4281
+ return exitCodeFor2({ kind: "config_error" });
4282
+ }
4283
+ const payload = secretPayload(output, method, secret, args.values);
4284
+ if (payload === null) {
4285
+ return exitCodeFor2({ kind: "config_error" });
4286
+ }
4287
+ const body = { ...payload, name };
4288
+ if (context.flags.project !== void 0) {
4289
+ body.projectId = context.flags.project;
4290
+ }
4291
+ if (typeof args.values.environment === "string") {
4292
+ body.environmentId = args.values.environment;
4293
+ }
4294
+ const result = await api(context, key.value, {
4295
+ method: "POST",
4296
+ path: "/v1/credentials",
4297
+ body
4298
+ });
4299
+ if (!result.ok) {
4300
+ return apiFailure2(output, result);
4301
+ }
4302
+ if (output.json) {
4303
+ emitJson(output, result);
4304
+ return exitCodeFor2({ kind: "success" });
4305
+ }
4306
+ if (isRecord2(result.data)) {
4307
+ output.line(
4308
+ `Stored credential ${cell(result.data.credentialId)} (${cell(result.data.wallType)})`
4309
+ );
4310
+ output.line(
4311
+ output.dim(
4312
+ `Attach it with: coldtea-qa environments create <name> --url <url> --credential ${cell(result.data.credentialId)}`
4313
+ )
4314
+ );
4315
+ }
4316
+ return exitCodeFor2({ kind: "success" });
4317
+ }
4318
+ };
4319
+ var credentialsRename = {
4320
+ words: ["credentials", "rename"],
4321
+ summary: "credentials rename <id> Rename one (--name)",
4322
+ help: CREDENTIALS_HELP,
4323
+ flags: { name: { type: "string" } },
4324
+ async run(context, output, args) {
4325
+ const credentialId = args.positionals[0];
4326
+ if (!credentialId || typeof args.values.name !== "string") {
4327
+ return usageError2(
4328
+ output,
4329
+ 'Usage: coldtea-qa credentials rename <id> --name "<new name>"'
4330
+ );
4331
+ }
4332
+ const key = requireApiKey2(context, output);
4333
+ if (!key.ok) {
4334
+ return key.code;
4335
+ }
4336
+ const result = await api(context, key.value, {
4337
+ method: "PATCH",
4338
+ path: `/v1/credentials/${encodeURIComponent(credentialId)}`,
4339
+ body: { name: args.values.name }
4340
+ });
4341
+ if (!result.ok) {
4342
+ return apiFailure2(output, result);
4343
+ }
4344
+ if (output.json) {
4345
+ emitJson(output, result);
4346
+ return exitCodeFor2({ kind: "success" });
4347
+ }
4348
+ output.line(`Renamed credential ${credentialId}`);
4349
+ return exitCodeFor2({ kind: "success" });
4350
+ }
4351
+ };
4352
+ var credentialsRotate = {
4353
+ words: ["credentials", "rotate"],
4354
+ summary: "credentials rotate <id> Replace the secret (piped on stdin)",
4355
+ help: CREDENTIALS_HELP,
4356
+ flags: { method: { type: "string" }, username: { type: "string" } },
4357
+ async run(context, output, args) {
4358
+ const credentialId = args.positionals[0];
4359
+ if (!credentialId) {
4360
+ return usageError2(
4361
+ output,
4362
+ 'Usage: echo -n "<secret>" | coldtea-qa credentials rotate <id> --method <m>'
4363
+ );
4364
+ }
4365
+ const method = readMethod(output, args.values.method);
4366
+ if (method === null) {
4367
+ return exitCodeFor2({ kind: "config_error" });
4368
+ }
4369
+ const key = requireApiKey2(context, output);
4370
+ if (!key.ok) {
4371
+ return key.code;
4372
+ }
4373
+ const secret = await readSecretFromStdin(
4374
+ output,
4375
+ output.stdinIsTTY,
4376
+ context.readStdin
4377
+ );
4378
+ if (secret === null) {
4379
+ return exitCodeFor2({ kind: "config_error" });
4380
+ }
4381
+ const payload = secretPayload(output, method, secret, args.values);
4382
+ if (payload === null) {
4383
+ return exitCodeFor2({ kind: "config_error" });
4384
+ }
4385
+ const result = await api(context, key.value, {
4386
+ method: "POST",
4387
+ path: `/v1/credentials/${encodeURIComponent(credentialId)}/secret`,
4388
+ body: payload
4389
+ });
4390
+ if (!result.ok) {
4391
+ return apiFailure2(output, result);
4392
+ }
4393
+ if (output.json) {
4394
+ emitJson(output, result);
4395
+ return exitCodeFor2({ kind: "success" });
4396
+ }
4397
+ output.line(`Rotated credential ${credentialId}`);
4398
+ output.line(
4399
+ output.dim(
4400
+ "Every environment that references it now signs in with the new secret."
4401
+ )
4402
+ );
4403
+ return exitCodeFor2({ kind: "success" });
4404
+ }
4405
+ };
4406
+ var credentialsDelete = {
4407
+ words: ["credentials", "delete"],
4408
+ summary: "credentials delete <id> Delete one (refused while in use)",
4409
+ help: CREDENTIALS_HELP,
4410
+ flags: {},
4411
+ async run(context, output, args) {
4412
+ const credentialId = args.positionals[0];
4413
+ if (!credentialId) {
4414
+ return usageError2(output, "Usage: coldtea-qa credentials delete <id>");
4415
+ }
4416
+ const key = requireApiKey2(context, output);
4417
+ if (!key.ok) {
4418
+ return key.code;
4419
+ }
4420
+ const result = await api(context, key.value, {
4421
+ method: "DELETE",
4422
+ path: `/v1/credentials/${encodeURIComponent(credentialId)}`
4423
+ });
4424
+ if (!result.ok) {
4425
+ const code = apiFailure2(output, result);
4426
+ if (result.error.code === "credential_in_use") {
4427
+ output.errorNote(
4428
+ " Detach it from every environment that uses it first: coldtea-qa credentials list shows which."
4429
+ );
4430
+ }
4431
+ return code;
4432
+ }
4433
+ output.line(`Deleted credential ${credentialId}`);
4434
+ return exitCodeFor2({ kind: "success" });
4435
+ }
4436
+ };
4437
+ var credentialCommands = [
4438
+ credentialsList,
4439
+ credentialsCreate,
4440
+ credentialsRename,
4441
+ credentialsRotate,
4442
+ credentialsDelete
4443
+ ];
4444
+
4445
+ // src/commands/repositories.ts
4446
+ var REPOSITORIES_HELP = `coldtea-qa repositories \u2014 repositories we can see
4447
+
4448
+ Usage
4449
+ coldtea-qa repositories list [--provider <github|bitbucket>]
4450
+ [--limit <n>] [--cursor <c>]
4451
+
4452
+ The repositories the Coldtea GitHub App or Bitbucket install can reach. Use
4453
+ the ID column with:
4454
+
4455
+ coldtea-qa projects link-repo prj_\u2026 --provider github --repository-id <id>
4456
+
4457
+ Match on the id, never on the name: \`fullName\` is display only and follows
4458
+ a rename, while the id does not.
4459
+ `;
4460
+ var repositoriesList = {
4461
+ words: ["repositories", "list"],
4462
+ summary: "repositories list List repositories we can see (for link-repo)",
4463
+ help: REPOSITORIES_HELP,
4464
+ flags: { ...LIST_FLAGS, provider: { type: "string" } },
4465
+ async run(context, output, args) {
4466
+ const key = requireApiKey2(context, output);
4467
+ if (!key.ok) {
4468
+ return key.code;
4469
+ }
4470
+ const query = listQuery(args.values);
4471
+ if (typeof args.values.provider === "string") {
4472
+ query.push(`provider=${encodeURIComponent(args.values.provider)}`);
4473
+ }
4474
+ const suffix = query.length > 0 ? `?${query.join("&")}` : "";
4475
+ const result = await api(context, key.value, {
4476
+ method: "GET",
4477
+ path: `/v1/repositories${suffix}`
4478
+ });
4479
+ if (!result.ok) {
4480
+ return apiFailure2(output, result);
4481
+ }
4482
+ if (output.json) {
4483
+ emitListJson(output, result);
4484
+ return exitCodeFor2({ kind: "success" });
4485
+ }
4486
+ const repositories = Array.isArray(result.data) ? result.data : [];
4487
+ if (repositories.length === 0) {
4488
+ output.line("No repositories. Connect the Coldtea app to one first.");
4489
+ } else {
4490
+ output.line(
4491
+ renderTable(
4492
+ ["ID", "PROVIDER", "NAME"],
4493
+ repositories.filter(isRecord2).map((repo) => [
4494
+ cell(repo.repositoryId),
4495
+ cell(repo.provider),
4496
+ cell(repo.fullName)
4497
+ ])
4498
+ )
4499
+ );
4500
+ }
4501
+ nextCursorFootnote(output, result.nextCursor);
4502
+ return exitCodeFor2({ kind: "success" });
4503
+ }
4504
+ };
4505
+ var repositoryCommands = [repositoriesList];
4506
+
4507
+ // src/commands/apps.ts
4508
+ var APPS_HELP = `coldtea-qa apps \u2014 registered mobile apps
4509
+
4510
+ Usage
4511
+ coldtea-qa apps list [--limit <n>] [--cursor <c>]
4512
+
4513
+ The apps registered from uploaded builds, and the builds under each. Needs a
4514
+ project (--project or COLDTEA_PROJECT_ID).
4515
+
4516
+ The ARTIFACT column is what a run is started against:
4517
+
4518
+ coldtea-qa run test mqt_\u2026 --build <artifactId>
4519
+ `;
4520
+ var SKIPS_HELP = `coldtea-qa skips \u2014 why a rule did not run
4521
+
4522
+ Usage
4523
+ coldtea-qa skips list [--rule <ruleId>] [--limit <n>] [--cursor <c>]
4524
+
4525
+ A rule that declines to fire leaves no run behind, so this listing is the only
4526
+ record that it happened. Reasons include rule_disabled, project_not_active,
4527
+ out_of_scope, no_target_url, no_runnable_tests and insufficient_credits.
4528
+
4529
+ Skips are kept for 30 days. An empty list means nothing was skipped in that
4530
+ window, not that nothing was ever skipped.
4531
+
4532
+ Needs a project (--project or COLDTEA_PROJECT_ID).
4533
+ `;
4534
+ var appsList = {
4535
+ words: ["apps", "list"],
4536
+ summary: "apps list List a project's mobile apps",
4537
+ help: APPS_HELP,
4538
+ flags: { ...LIST_FLAGS },
4539
+ async run(context, output, args) {
4540
+ const key = requireApiKey2(context, output);
4541
+ if (!key.ok) {
4542
+ return key.code;
4543
+ }
4544
+ const project = requireProjectId(context, output);
4545
+ if (!project.ok) {
4546
+ return project.code;
4547
+ }
4548
+ const query = listQuery(args.values);
4549
+ const suffix = query.length > 0 ? `?${query.join("&")}` : "";
4550
+ const result = await api(context, key.value, {
4551
+ method: "GET",
4552
+ path: `/v1/projects/${encodeURIComponent(project.value)}/apps${suffix}`
4553
+ });
4554
+ if (!result.ok) {
4555
+ return apiFailure2(output, result);
4556
+ }
4557
+ if (output.json) {
4558
+ emitListJson(output, result);
4559
+ return exitCodeFor2({ kind: "success" });
4560
+ }
4561
+ const apps = (Array.isArray(result.data) ? result.data : []).filter(
4562
+ isRecord2
4563
+ );
4564
+ if (apps.length === 0) {
4565
+ output.line("No apps. Upload a build first: coldtea-qa builds upload");
4566
+ return exitCodeFor2({ kind: "success" });
4567
+ }
4568
+ const rows = apps.flatMap((app) => {
4569
+ const builds = Array.isArray(app.builds) ? app.builds.filter(isRecord2) : [];
4570
+ if (builds.length === 0) {
4571
+ return [[cell(app.appKey), cell(app.platform), "\u2014", "\u2014", "\u2014"]];
4572
+ }
4573
+ return builds.map((build) => [
4574
+ cell(app.appKey),
4575
+ cell(app.platform),
4576
+ cell(build.version),
4577
+ cell(build.artifactId),
4578
+ formatEpoch(build.createdAt)
4579
+ ]);
4580
+ });
4581
+ output.line(
4582
+ renderTable(["APP", "PLATFORM", "VERSION", "ARTIFACT", "CREATED"], rows)
4583
+ );
4584
+ nextCursorFootnote(output, result.nextCursor);
4585
+ return exitCodeFor2({ kind: "success" });
4586
+ }
4587
+ };
4588
+ var skipsList = {
4589
+ words: ["skips", "list"],
4590
+ summary: "skips list Why a project's rules did not run",
4591
+ help: SKIPS_HELP,
4592
+ flags: { ...LIST_FLAGS, rule: { type: "string" } },
4593
+ async run(context, output, args) {
4594
+ const key = requireApiKey2(context, output);
4595
+ if (!key.ok) {
4596
+ return key.code;
4597
+ }
4598
+ const project = requireProjectId(context, output);
4599
+ if (!project.ok) {
4600
+ return project.code;
4601
+ }
4602
+ const query = listQuery(args.values);
4603
+ if (typeof args.values.rule === "string") {
4604
+ query.push(`ruleId=${encodeURIComponent(args.values.rule)}`);
4605
+ }
4606
+ const suffix = query.length > 0 ? `?${query.join("&")}` : "";
4607
+ const result = await api(context, key.value, {
4608
+ method: "GET",
4609
+ path: `/v1/projects/${encodeURIComponent(project.value)}/skips${suffix}`
4610
+ });
4611
+ if (!result.ok) {
4612
+ return apiFailure2(output, result);
4613
+ }
4614
+ if (output.json) {
4615
+ emitListJson(output, result);
4616
+ return exitCodeFor2({ kind: "success" });
4617
+ }
4618
+ const skips = Array.isArray(result.data) ? result.data : [];
4619
+ if (skips.length === 0) {
4620
+ output.line(
4621
+ "No skips in the last 30 days \u2014 that is as far back as they are kept. Either every rule that matched fired, or there are no rules yet (coldtea-qa rules list)."
4622
+ );
4623
+ } else {
4624
+ output.line(
4625
+ renderTable(
4626
+ ["WHEN", "RULE", "TRIGGER", "REASON"],
4627
+ skips.filter(isRecord2).map((skip) => [
4628
+ formatEpoch(skip.at),
4629
+ cell(skip.ruleId),
4630
+ cell(skip.trigger),
4631
+ cell(skip.reason)
4632
+ ])
4633
+ )
4634
+ );
4635
+ }
4636
+ nextCursorFootnote(output, result.nextCursor);
4637
+ return exitCodeFor2({ kind: "success" });
4638
+ }
4639
+ };
4640
+ var appCommands = [appsList, skipsList];
4641
+
4642
+ // src/main.ts
4643
+ var CLI_VERSION = "0.1.0";
4644
+ var WHOAMI_HELP = `coldtea-qa whoami \u2014 show the organization and key
4645
+
4646
+ Usage
4647
+ coldtea-qa whoami [--json]
4648
+
4649
+ Calls GET /v1/me: which organization this key belongs to, the key's name,
4650
+ its scopes, and when it was last used.
4651
+ `;
4652
+ var whoamiCommand = {
4653
+ words: ["whoami"],
4654
+ summary: "whoami Show the organization and key this CLI authenticates as",
4655
+ help: WHOAMI_HELP,
4656
+ flags: {},
4657
+ run: (context, output) => whoami(context, output)
4658
+ };
4659
+ var ALL_COMMANDS = [
4660
+ whoamiCommand,
4661
+ ...projectCommands,
4662
+ ...testCommands,
4663
+ ...groupCommands,
4664
+ ...runCommands,
4665
+ ...runsCommands,
4666
+ ...buildCommands,
4667
+ ...mergeCommands,
4668
+ ...environmentCommands,
4669
+ ...ruleCommands,
4670
+ ...credentialCommands,
4671
+ ...repositoryCommands,
4672
+ ...appCommands
4673
+ ];
4674
+ var USAGE = `coldtea-qa \u2014 Coldtea QA from the command line
4675
+
4676
+ Usage
4677
+ coldtea-qa <command> [flags]
4678
+
4679
+ Start here
4680
+ 1 coldtea-qa whoami who you are, and what your key can do
4681
+ 2 coldtea-qa projects list find a project id, then export
4682
+ COLDTEA_PROJECT_ID
4683
+ 3 coldtea-qa groups create "Smoke" tests live in groups
4684
+ 4 coldtea-qa tests create "Buy an item with a saved card" \\
4685
+ --url https://shop.example the description IS the test: say
4686
+ what a person would do, in English
4687
+ 5 coldtea-qa run test mqt_\u2026 --wait exit code is the verdict
4688
+
4689
+ Behind a login? Store the secret (coldtea-qa credentials create), point an
4690
+ environment at the site (coldtea-qa environments create), then give the group
4691
+ that environment (coldtea-qa groups update --environment).
4692
+
4693
+ Want it automatic on every pull request? coldtea-qa rules create.
4694
+
4695
+ Commands
4696
+ ${ALL_COMMANDS.map((def) => ` ${def.summary}`).join("\n")}
4697
+
4698
+ help [command] Show help (e.g. coldtea-qa help run)
4699
+
4700
+ Flags
4701
+ --json Machine-readable output on stdout
4702
+ --project <id> Project id (or COLDTEA_PROJECT_ID)
4703
+ --api-key <key> API key. Prefer COLDTEA_API_KEY: a flag is visible in
4704
+ \`ps\` while the process runs, in shell history, and in
4705
+ any CI log that echoes the step.
4706
+ --fail-on-warning Exit 1 when a run outcome is warning
4707
+ --help Show help. \`-h\` works on a bare \`coldtea-qa\`, but after
4708
+ a command spell it out \u2014 \`-h\` means HOST in too much
4709
+ tooling for a QA run to guess.
4710
+ -v, --version Print the version. Bare invocation only: after a command
4711
+ \`-v\` is an unknown option (exit 4), not a pass.
4712
+
4713
+ Environment
4714
+ COLDTEA_API_KEY coldtea_sk_\u2026 key. The key identifies your organization.
4715
+ COLDTEA_PROJECT_ID Default project id
4716
+ COLDTEA_BASE_URL API origin (default https://www.coldtea.ai). Https
4717
+ only \u2014 plain http works on loopback, or with
4718
+ COLDTEA_ALLOW_INSECURE_HTTP=1
4719
+ NO_COLOR Disable color
4720
+
4721
+ Exit codes
4722
+ 0 pass \xB7 1 fail \xB7 2 couldn't run or verify \xB7 3 conflict \xB7 4 auth/config
4723
+
4724
+ 2 is worth retrying unchanged; 4 never is. A typo'd id or a malformed
4725
+ request is 4, not 2 \u2014 retrying it can only fail again.
4726
+ `;
4727
+ var GLOBAL_FLAGS = {
4728
+ "fail-on-warning": { type: "boolean", default: false },
4729
+ project: { type: "string" }
4730
+ };
4731
+ var GLOBAL_OPTIONS = {
4732
+ ...UNIVERSAL_FLAGS,
4733
+ ...GLOBAL_FLAGS
4734
+ };
4735
+ var runCli = runCliFrom({
4736
+ binary: "coldtea-qa",
4737
+ version: CLI_VERSION,
4738
+ usage: USAGE,
4739
+ commands: ALL_COMMANDS,
4740
+ globalFlags: GLOBAL_FLAGS,
4741
+ buildContext: ({ base, values }) => ({
4742
+ ...base,
4743
+ flags: {
4744
+ json: base.flags.json,
4745
+ apiKey: base.flags.apiKey,
4746
+ failOnWarning: values["fail-on-warning"] === true,
4747
+ project: typeof values.project === "string" ? values.project : void 0
4748
+ },
4749
+ projectId: resolveProjectId({
4750
+ flag: typeof values.project === "string" ? values.project : void 0,
4751
+ env: base.env
4752
+ })
4753
+ })
4754
+ });
4755
+ export {
4756
+ ALL_COMMANDS,
4757
+ GLOBAL_FLAGS,
4758
+ GLOBAL_OPTIONS,
4759
+ runCli
4760
+ };