@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/LICENSE +21 -0
- package/README.md +274 -0
- package/bin/coldtea-qa.mjs +114 -0
- package/dist/main.js +4760 -0
- package/package.json +44 -0
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
|
+
};
|