@datadisco/qa 0.2.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +172 -10
  2. package/dist/cli.js +1323 -110
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -10,16 +10,24 @@ var API_TOKEN_ENV_KEY = "DATADISCO_API_TOKEN";
10
10
  var ConfigError = class extends Error {
11
11
  };
12
12
  function resolveConfig(input = {}) {
13
- const env = input.env ?? process.env;
14
- const apiUrl = input.apiUrl || env.DATADISCO_API_URL || DEFAULT_API_URL;
15
- const apiToken = input.apiToken || env[API_TOKEN_ENV_KEY];
13
+ const apiUrl = resolveApiUrl(input);
14
+ const apiToken = resolveExplicitApiToken(input);
16
15
  if (!apiToken) {
17
16
  throw new ConfigError(`Missing API token: pass --api-token or set ${API_TOKEN_ENV_KEY}.`);
18
17
  }
18
+ return { apiUrl, apiToken };
19
+ }
20
+ function resolveApiUrl(input = {}) {
21
+ const env = input.env ?? process.env;
22
+ const apiUrl = input.apiUrl || env.DATADISCO_API_URL || DEFAULT_API_URL;
19
23
  if (!apiUrl.startsWith("https://")) {
20
24
  throw new ConfigError(`API url must be https, got "${apiUrl}".`);
21
25
  }
22
- return { apiUrl, apiToken };
26
+ return apiUrl;
27
+ }
28
+ function resolveExplicitApiToken(input = {}) {
29
+ const env = input.env ?? process.env;
30
+ return input.apiToken || env[API_TOKEN_ENV_KEY] || void 0;
23
31
  }
24
32
  function assertValidPreviewUrl(url) {
25
33
  let parsed;
@@ -33,110 +41,562 @@ function assertValidPreviewUrl(url) {
33
41
  }
34
42
  }
35
43
 
36
- // src/context.ts
37
- import { readFileSync } from "fs";
38
- var REPO_PATTERN = /^[^/\s]+\/[^/\s]+$/;
39
- var PR_REF_PATTERN = /^refs\/pull\/(\d+)\//;
40
- function resolvePrContext(input = {}) {
41
- const env = input.env ?? process.env;
42
- const payload = loadEventPayload(input, env);
44
+ // src/connection.ts
45
+ function findGithubConnection(outcome) {
46
+ if (!outcome.ok) return void 0;
47
+ return outcome.connections.find((connection) => connection.kind === "GITHUB");
48
+ }
49
+ function connectRepoUrl(apiUrl, repo) {
50
+ const url = new URL("/qa/dance", apiUrl);
51
+ url.searchParams.set("repo", repo);
52
+ return url.toString();
53
+ }
54
+ function describeConnectionProblem(connection) {
55
+ if (!connection.hasInstallation) {
56
+ return "the DataDisco GitHub App is not installed on this repo \u2014 reinstall it from the connection page";
57
+ }
58
+ if (!connection.enabled) return "the connection is disabled \u2014 enable it from the connection page";
59
+ if (connection.status !== "ACTIVE") {
60
+ return `the connection status is ${connection.status} \u2014 check the connection page`;
61
+ }
62
+ return void 0;
63
+ }
64
+
65
+ // src/gh.ts
66
+ function checkGh(run2) {
67
+ if (!run2("gh", ["--version"]).ok) return "missing";
68
+ return run2("gh", ["auth", "status"]).ok ? "ready" : "unauthenticated";
69
+ }
70
+ function setRepoSecret(run2, repo, name, value) {
71
+ return run2("gh", ["secret", "set", name, "--repo", repo], value);
72
+ }
73
+ function listRepoSecretNames(run2, repo) {
74
+ const result = run2("gh", [
75
+ "secret",
76
+ "list",
77
+ "--repo",
78
+ repo,
79
+ "--json",
80
+ "name",
81
+ "--jq",
82
+ ".[].name"
83
+ ]);
84
+ if (!result.ok) return void 0;
85
+ return result.stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
86
+ }
87
+
88
+ // src/host-detection.ts
89
+ import { join } from "path";
90
+ var DEPLOY_EVENT_HOSTS = /* @__PURE__ */ new Set(["vercel", "netlify", "amplify"]);
91
+ var HOST_MARKERS = [
92
+ { host: "vercel", path: "vercel.json" },
93
+ { host: "vercel", path: ".vercel" },
94
+ { host: "netlify", path: "netlify.toml" },
95
+ { host: "amplify", path: "amplify.yml" },
96
+ { host: "fly", path: "fly.toml" },
97
+ { host: "render", path: "render.yaml" },
98
+ { host: "railway", path: "railway.json" },
99
+ { host: "railway", path: "railway.toml" }
100
+ ];
101
+ var WORKFLOW_KEYWORDS = [
102
+ { host: "vercel", pattern: /\bvercel\b/i, label: "vercel" },
103
+ { host: "netlify", pattern: /\bnetlify\b/i, label: "netlify" },
104
+ { host: "amplify", pattern: /\bamplify\b/i, label: "amplify" },
105
+ { host: "fly", pattern: /\bfly(?:ctl|\.io)?\b|superfly/i, label: "fly" },
106
+ { host: "render", pattern: /\brender(?:\.com)?\b/i, label: "render" },
107
+ { host: "railway", pattern: /\brailway\b/i, label: "railway" }
108
+ ];
109
+ var GENERIC_DEPLOY_PATTERN = /deploy/i;
110
+ var WORKFLOWS_DIR = join(".github", "workflows");
111
+ var WORKFLOW_FILE_PATTERN = /\.ya?ml$/;
112
+ function isDeployEventHost(host) {
113
+ return DEPLOY_EVENT_HOSTS.has(host);
114
+ }
115
+ function detectDeployHost(repoRoot, files) {
116
+ const markerHits = findMarkerFiles(repoRoot, files);
117
+ const workflowHits2 = scanWorkflows(repoRoot, files);
118
+ const hosts = [...markerHits, ...workflowHits2].map((hit) => hit.host);
119
+ const host = hosts.find((candidate) => candidate !== void 0) ?? "unknown";
43
120
  return {
44
- repo: resolveRepo(input, env),
45
- prNumber: resolvePrNumber(input, env, payload)
121
+ host,
122
+ evidence: [...markerHits, ...workflowHits2].map((hit) => hit.evidence),
123
+ // A workflow naming Vercel/Netlify/Amplify deploys through their CLI, which creates no
124
+ // GitHub Deployments — only the marker files point at the Git integration.
125
+ deployEvents: namesDeployEventHost(markerHits) && !namesDeployEventHost(workflowHits2)
46
126
  };
47
127
  }
48
- function resolveRunContext(input = {}) {
49
- const env = input.env ?? process.env;
50
- const payload = loadEventPayload(input, env);
128
+ function namesDeployEventHost(hits) {
129
+ return hits.some((hit) => hit.host !== void 0 && DEPLOY_EVENT_HOSTS.has(hit.host));
130
+ }
131
+ function findMarkerFiles(repoRoot, files) {
132
+ return HOST_MARKERS.filter((marker) => files.exists(join(repoRoot, marker.path))).map(
133
+ (marker) => ({ host: marker.host, evidence: marker.path })
134
+ );
135
+ }
136
+ function scanWorkflows(repoRoot, files) {
137
+ const workflowsDir = join(repoRoot, WORKFLOWS_DIR);
138
+ const workflowFiles = files.listDir(workflowsDir).filter((name) => WORKFLOW_FILE_PATTERN.test(name));
139
+ return workflowFiles.flatMap((name) => {
140
+ const content = files.readFile(join(workflowsDir, name)) ?? "";
141
+ return workflowHits(`${WORKFLOWS_DIR}/${name}`, content);
142
+ });
143
+ }
144
+ function workflowHits(workflowPath, content) {
145
+ const hostHits = WORKFLOW_KEYWORDS.filter((keyword) => keyword.pattern.test(content)).map(
146
+ (keyword) => ({ host: keyword.host, evidence: describeWorkflowHit(workflowPath, keyword) })
147
+ );
148
+ if (hostHits.length === 0 && GENERIC_DEPLOY_PATTERN.test(content)) {
149
+ return [{ host: void 0, evidence: `${workflowPath} mentions deploy` }];
150
+ }
151
+ return hostHits;
152
+ }
153
+ function describeWorkflowHit(workflowPath, keyword) {
154
+ const suffix = DEPLOY_EVENT_HOSTS.has(keyword.host) ? " (deploys from CI?)" : "";
155
+ return `${workflowPath} mentions ${keyword.label}${suffix}`;
156
+ }
157
+
158
+ // src/config-file.ts
159
+ import { homedir } from "os";
160
+ import { join as join2 } from "path";
161
+ var CONFIG_DIR_NAME = "datadisco";
162
+ var CONFIG_FILE_NAME = "qa.json";
163
+ var OWNER_ONLY_FILE_MODE = 384;
164
+ var OWNER_ONLY_DIR_MODE = 448;
165
+ function resolveConfigFilePath(env = process.env, home = homedir()) {
166
+ const configHome = env.XDG_CONFIG_HOME || join2(home, ".config");
167
+ return join2(configHome, CONFIG_DIR_NAME, CONFIG_FILE_NAME);
168
+ }
169
+ function readStoredToken(apiUrl, deps) {
170
+ return readTokens(deps)[apiUrl];
171
+ }
172
+ function saveStoredToken(apiUrl, approval, deps) {
173
+ const tokens = readTokens(deps);
174
+ tokens[apiUrl] = {
175
+ token: approval.apiToken,
176
+ workspaceName: approval.workspaceName,
177
+ workspaceSlug: approval.workspaceSlug,
178
+ savedAt: new Date(deps.now()).toISOString()
179
+ };
180
+ writeTokens(tokens, deps);
181
+ }
182
+ function deleteStoredToken(apiUrl, deps) {
183
+ const tokens = readTokens(deps);
184
+ if (!(apiUrl in tokens)) return false;
185
+ delete tokens[apiUrl];
186
+ writeTokens(tokens, deps);
187
+ return true;
188
+ }
189
+ function readTokens(deps) {
190
+ const raw = deps.readFile(deps.configFilePath);
191
+ if (!raw) return {};
192
+ const parsed = parseJson(raw);
193
+ if (!isRecord(parsed) || !isRecord(parsed.tokens)) return {};
194
+ return collectStoredTokens(parsed.tokens);
195
+ }
196
+ function writeTokens(tokens, deps) {
197
+ const content = `${JSON.stringify({ tokens }, null, 2)}
198
+ `;
199
+ deps.writeFile(deps.configFilePath, content, {
200
+ mode: OWNER_ONLY_FILE_MODE,
201
+ dirMode: OWNER_ONLY_DIR_MODE
202
+ });
203
+ }
204
+ function collectStoredTokens(value) {
205
+ const tokens = {};
206
+ for (const [apiUrl, entry] of Object.entries(value)) {
207
+ const stored = parseStoredToken(entry);
208
+ if (stored) tokens[apiUrl] = stored;
209
+ }
210
+ return tokens;
211
+ }
212
+ function parseStoredToken(value) {
213
+ if (!isRecord(value) || typeof value.token !== "string") return void 0;
51
214
  return {
52
- repo: resolveRepo(input, env),
53
- prNumber: resolvePrNumber(input, env, payload),
54
- headSha: resolveHeadSha(input, env, payload)
215
+ token: value.token,
216
+ workspaceName: stringOr(value.workspaceName, ""),
217
+ workspaceSlug: stringOr(value.workspaceSlug, ""),
218
+ savedAt: stringOr(value.savedAt, "")
55
219
  };
56
220
  }
57
- function resolveRepo(input, env = {}) {
58
- const repo = input.repo ?? env.GITHUB_REPOSITORY;
59
- if (!repo) {
60
- throw new ConfigError(
61
- "Repository not set: pass --repo owner/name (auto-detected from $GITHUB_REPOSITORY in GitHub Actions)."
62
- );
221
+ function parseJson(raw) {
222
+ try {
223
+ return JSON.parse(raw);
224
+ } catch {
225
+ return void 0;
63
226
  }
64
- if (!REPO_PATTERN.test(repo)) {
65
- throw new ConfigError(`--repo must look like "owner/name", got "${repo}".`);
227
+ }
228
+ function isRecord(value) {
229
+ return typeof value === "object" && value !== null;
230
+ }
231
+ function stringOr(value, fallback) {
232
+ return typeof value === "string" ? value : fallback;
233
+ }
234
+
235
+ // src/pairing.ts
236
+ var SECOND_MS = 1e3;
237
+ var MIN_POLL_INTERVAL_MS = SECOND_MS;
238
+ var MAX_BACKOFF_INTERVAL_MS = 30 * SECOND_MS;
239
+ var TOO_MANY_REQUESTS = 429;
240
+ var SERVER_ERROR = 500;
241
+ var NO_RESPONSE = 0;
242
+ async function pairDevice(api, deps, options) {
243
+ const started = await api.pair(deps.deviceName());
244
+ if (!started.ok) {
245
+ return { ok: false, error: describePairFailure(started.error, started.code) };
66
246
  }
67
- return repo;
247
+ await announcePairing(started.pairing, deps, options);
248
+ return pollUntilResolved(api, started.pairing, deps);
68
249
  }
69
- function resolvePrNumber(input, env = {}, payload) {
70
- const candidate = input.pr ?? parsePrFromRef(env.GITHUB_REF) ?? payload.prNumber;
71
- const prNumber = Number(candidate);
72
- if (candidate === void 0 || !Number.isInteger(prNumber) || prNumber <= 0) {
73
- throw new ConfigError(
74
- "Pull request number not set: pass --pr <number> (auto-detected from the pull_request event in GitHub Actions)."
75
- );
250
+ async function signIn(apiUrl, api, deps, options) {
251
+ const result = await pairDevice(api, deps, options);
252
+ if (!result.ok) return result;
253
+ saveStoredToken(apiUrl, result.approval, deps);
254
+ deps.logger.success(`Signed in to workspace ${result.approval.workspaceName}`);
255
+ return result;
256
+ }
257
+ async function announcePairing(pairing, deps, options) {
258
+ deps.logger.info(`Open ${pairing.verificationUrl} and confirm code ${pairing.userCode}`);
259
+ if (options.openBrowser) {
260
+ await deps.openBrowser(pairing.verificationUrl);
76
261
  }
77
- return prNumber;
78
262
  }
79
- function resolveHeadSha(input, env = {}, payload) {
80
- const headSha = input.sha ?? payload.headSha ?? env.GITHUB_SHA;
81
- if (!headSha) {
263
+ async function pollUntilResolved(api, pairing, deps) {
264
+ const deadline = deps.now() + pairing.expiresInSeconds * SECOND_MS;
265
+ let interval = Math.max(pairing.intervalSeconds * SECOND_MS, MIN_POLL_INTERVAL_MS);
266
+ let lastTransientError;
267
+ while (true) {
268
+ await deps.sleep(interval);
269
+ const outcome = await pollOnce(api, pairing.deviceCode);
270
+ if (isTransientFailure(outcome)) {
271
+ lastTransientError = outcome.error;
272
+ if (isRateLimited(outcome)) interval = Math.min(interval * 2, MAX_BACKOFF_INTERVAL_MS);
273
+ } else {
274
+ const resolved = resolvePoll(outcome);
275
+ if (resolved) return resolved;
276
+ lastTransientError = void 0;
277
+ }
278
+ if (deps.now() >= deadline) {
279
+ const detail = lastTransientError ? ` (last error: ${lastTransientError})` : "";
280
+ return {
281
+ ok: false,
282
+ error: `Pairing code expired before it was confirmed. Try again.${detail}`
283
+ };
284
+ }
285
+ }
286
+ }
287
+ async function pollOnce(api, deviceCode) {
288
+ try {
289
+ return await api.pollPairing(deviceCode);
290
+ } catch (error) {
291
+ const message = error instanceof Error ? error.message : String(error);
292
+ return { ok: false, status: NO_RESPONSE, error: message };
293
+ }
294
+ }
295
+ function isTransientFailure(outcome) {
296
+ if (outcome.ok) return false;
297
+ if (outcome.code && outcome.code in POLL_FAILURE_MESSAGES) return false;
298
+ return outcome.status === NO_RESPONSE || outcome.status >= SERVER_ERROR || isRateLimited(outcome);
299
+ }
300
+ function isRateLimited(outcome) {
301
+ return !outcome.ok && (outcome.status === TOO_MANY_REQUESTS || outcome.code === "RATE_LIMITED");
302
+ }
303
+ function resolvePoll(outcome) {
304
+ if (!outcome.ok) {
305
+ return { ok: false, error: describePollFailure(outcome.error, outcome.code) };
306
+ }
307
+ return outcome.pending ? void 0 : { ok: true, approval: outcome.approval };
308
+ }
309
+ function describePairFailure(error, code) {
310
+ if (code === "RATE_LIMITED") {
311
+ return `Too many sign-in attempts \u2014 wait a minute and try again. (${error})`;
312
+ }
313
+ return `Could not start sign-in: ${error}`;
314
+ }
315
+ var POLL_FAILURE_MESSAGES = {
316
+ DENIED: "Sign-in was denied in the browser.",
317
+ EXPIRED: "Pairing code expired before it was confirmed. Try again.",
318
+ ALREADY_CLAIMED: "This pairing code was already used. Run the command again for a fresh code.",
319
+ NOT_FOUND: "The server no longer recognizes this pairing code. Run the command again."
320
+ };
321
+ function describePollFailure(error, code) {
322
+ const known = code ? POLL_FAILURE_MESSAGES[code] : void 0;
323
+ return known ?? `Sign-in failed: ${error}`;
324
+ }
325
+
326
+ // src/session.ts
327
+ function findExistingToken(options, apiUrl, deps) {
328
+ return resolveExplicitApiToken(options) ?? readStoredToken(apiUrl, deps)?.token;
329
+ }
330
+ async function resolveSession(options, deps) {
331
+ const apiUrl = resolveApiUrl(options);
332
+ const existing = findExistingToken(options, apiUrl, deps);
333
+ if (existing) {
334
+ deps.logger.info(describeTokenSource(options, apiUrl, deps));
335
+ return {
336
+ ok: true,
337
+ session: { apiUrl, apiToken: existing, api: deps.createApi({ apiUrl, apiToken: existing }) }
338
+ };
339
+ }
340
+ deps.logger.info("No saved sign-in for this API \u2014 pairing this device\u2026");
341
+ const result = await signIn(apiUrl, deps.createApi({ apiUrl }), deps, {
342
+ openBrowser: options.browser !== false
343
+ });
344
+ if (!result.ok) return result;
345
+ const apiToken = result.approval.apiToken;
346
+ return { ok: true, session: { apiUrl, apiToken, api: deps.createApi({ apiUrl, apiToken }) } };
347
+ }
348
+ function describeTokenSource(options, apiUrl, deps) {
349
+ if (resolveExplicitApiToken(options)) return "Using the API token from the flag/environment.";
350
+ const stored = readStoredToken(apiUrl, deps);
351
+ return stored?.workspaceName ? `Using the saved sign-in for workspace ${stored.workspaceName}.` : "Using the saved sign-in.";
352
+ }
353
+
354
+ // src/workflow-recipe.ts
355
+ import { join as join3 } from "path";
356
+ var WORKFLOW_RELATIVE_PATH = join3(".github", "workflows", "datadisco-qa.yml");
357
+ var API_TOKEN_SECRET_NAME = "DATADISCO_API_TOKEN";
358
+ var PATTERN_PLACEHOLDER = "{number}";
359
+ var ACTION_REF = "Data-Disco-Inc/qa-action@v1";
360
+ var PR_NUMBER_EXPRESSION = "${{ github.event.pull_request.number }}";
361
+ var SECRET_EXPRESSION = `\${{ secrets.${API_TOKEN_SECRET_NAME} }}`;
362
+ var DEPLOYMENT_STATUS_TRIGGER = /^\s*deployment_status\s*:/m;
363
+ function workflowFilePath(repoRoot) {
364
+ return join3(repoRoot, WORKFLOW_RELATIVE_PATH);
365
+ }
366
+ function assertValidPreviewUrlPattern(pattern) {
367
+ if (!pattern.startsWith("https://")) {
368
+ throw new ConfigError(`Preview URL pattern must start with https://, got "${pattern}".`);
369
+ }
370
+ if (!pattern.includes(PATTERN_PLACEHOLDER)) {
82
371
  throw new ConfigError(
83
- "Head SHA not set: pass --sha <sha> (the commit the preview was built from)."
372
+ `Preview URL pattern must contain ${PATTERN_PLACEHOLDER}, e.g. https://pr-${PATTERN_PLACEHOLDER}.preview.example.com`
84
373
  );
85
374
  }
86
- return headSha;
87
375
  }
88
- function parsePrFromRef(ref) {
89
- const match = ref?.match(PR_REF_PATTERN);
90
- return match ? match[1] : void 0;
376
+ function describeRecipe(pattern) {
377
+ return `report-preview on pull_request using ${pattern}`;
91
378
  }
92
- function loadEventPayload(input, env = {}) {
93
- const read = input.readEventPayload ?? (() => readEventFile(env.GITHUB_EVENT_PATH));
94
- const raw = safeRead(read);
95
- if (!isRecord(raw)) return {};
96
- const pullRequest = isRecord(raw.pull_request) ? raw.pull_request : void 0;
97
- const head = isRecord(pullRequest?.head) ? pullRequest.head : void 0;
98
- return {
99
- prNumber: numberOrUndefined(pullRequest?.number ?? raw.number),
100
- headSha: stringOrUndefined(head?.sha)
101
- };
379
+ function triggersOnDeploymentStatus(workflow) {
380
+ return DEPLOYMENT_STATUS_TRIGGER.test(workflow);
102
381
  }
103
- function readEventFile(path) {
104
- if (!path) return void 0;
105
- return JSON.parse(readFileSync(path, "utf8"));
382
+ function renderWorkflow(pattern) {
383
+ const url = pattern.split(PATTERN_PLACEHOLDER).join(PR_NUMBER_EXPRESSION);
384
+ return `name: DataDisco QA
385
+ on:
386
+ pull_request:
387
+ types: [opened, synchronize, reopened, ready_for_review]
388
+
389
+ permissions:
390
+ contents: read
391
+
392
+ jobs:
393
+ report-preview:
394
+ runs-on: ubuntu-latest
395
+ steps:
396
+ # ...wait for or deploy your preview before this step...
397
+ - uses: ${ACTION_REF}
398
+ with:
399
+ command: report-preview
400
+ url: ${url}
401
+ api-token: ${SECRET_EXPRESSION}
402
+ `;
106
403
  }
107
- function safeRead(read) {
404
+
405
+ // src/commands/setup-deps.ts
406
+ import { hostname, userInfo } from "os";
407
+
408
+ // src/files.ts
409
+ import {
410
+ chmodSync,
411
+ existsSync,
412
+ mkdirSync,
413
+ readdirSync,
414
+ readFileSync,
415
+ writeFileSync
416
+ } from "fs";
417
+ import { dirname } from "path";
418
+ var nodeFileSystem = {
419
+ exists: (path) => existsSync(path),
420
+ readFile: (path) => tryRead(path),
421
+ listDir: (path) => tryList(path),
422
+ writeFile: (path, content, options) => {
423
+ mkdirSync(dirname(path), { recursive: true, mode: options?.dirMode });
424
+ writeFileSync(path, content, { encoding: "utf8", mode: options?.mode });
425
+ if (options?.mode !== void 0) chmodSync(path, options.mode);
426
+ }
427
+ };
428
+ function tryRead(path) {
108
429
  try {
109
- return read();
430
+ return readFileSync(path, "utf8");
110
431
  } catch {
111
432
  return void 0;
112
433
  }
113
434
  }
114
- function isRecord(value) {
115
- return typeof value === "object" && value !== null;
435
+ function tryList(path) {
436
+ try {
437
+ return readdirSync(path);
438
+ } catch {
439
+ return [];
440
+ }
116
441
  }
117
- function numberOrUndefined(value) {
118
- return typeof value === "number" ? value : void 0;
442
+
443
+ // src/git.ts
444
+ import { execFileSync } from "child_process";
445
+ var defaultRunner = (args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
446
+ function detectRepoFullName(run2 = defaultRunner) {
447
+ const remote = tryGit(run2, ["remote", "get-url", "origin"]);
448
+ return remote ? parseRepoFromRemote(remote) : void 0;
119
449
  }
120
- function stringOrUndefined(value) {
121
- return typeof value === "string" ? value : void 0;
450
+ function detectHeadSha(run2 = defaultRunner) {
451
+ return tryGit(run2, ["rev-parse", "HEAD"]);
452
+ }
453
+ function parseRepoFromRemote(remote) {
454
+ const match = remote.match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/);
455
+ return match ? match[1] : void 0;
456
+ }
457
+ function tryGit(run2, args) {
458
+ try {
459
+ return run2(args) || void 0;
460
+ } catch {
461
+ return void 0;
462
+ }
463
+ }
464
+ function detectRepoRoot(run2 = defaultRunner) {
465
+ return tryGit(run2, ["rev-parse", "--show-toplevel"]);
466
+ }
467
+
468
+ // src/prompt.ts
469
+ import { createInterface } from "readline";
470
+ var PromptCancelledError = class extends Error {
471
+ constructor() {
472
+ super("Cancelled.");
473
+ this.name = "PromptCancelledError";
474
+ }
475
+ };
476
+ var YES_ANSWER = /^y(es)?$/i;
477
+ var NO_ANSWER = /^n(o)?$/i;
478
+ function createLinePrompt(input, output) {
479
+ let readline;
480
+ let ended = false;
481
+ let cancelled = false;
482
+ const lines = [];
483
+ const waiting = [];
484
+ const tty = input;
485
+ const cancel = () => {
486
+ cancelled = true;
487
+ for (const next of waiting.splice(0)) next.reject(new PromptCancelledError());
488
+ };
489
+ const open = () => {
490
+ if (readline) return readline;
491
+ readline = createInterface({ input, output });
492
+ readline.on("line", (line) => {
493
+ const next = waiting.shift();
494
+ if (next) next.resolve(line);
495
+ else lines.push(line);
496
+ });
497
+ readline.on("SIGINT", () => {
498
+ output.write("\n");
499
+ cancel();
500
+ readline?.close();
501
+ });
502
+ readline.on("close", () => {
503
+ ended = true;
504
+ if (tty.isTTY) cancel();
505
+ else for (const next of waiting.splice(0)) next.resolve(void 0);
506
+ });
507
+ return readline;
508
+ };
509
+ const nextLine = () => {
510
+ if (lines.length) return Promise.resolve(lines.shift());
511
+ if (ended) return Promise.resolve(void 0);
512
+ return new Promise((resolve, reject) => waiting.push({ resolve, reject }));
513
+ };
514
+ return async (question, defaultValue) => {
515
+ if (cancelled) throw new PromptCancelledError();
516
+ const reader = open();
517
+ const text = formatQuestion(question, defaultValue);
518
+ if (ended) {
519
+ output.write(`${text}
520
+ `);
521
+ } else {
522
+ reader.setPrompt(text);
523
+ if (reader.terminal) tty.setRawMode?.(true);
524
+ reader.prompt();
525
+ reader.resume();
526
+ }
527
+ const answer = (await nextLine() ?? "").trim();
528
+ if (!ended) {
529
+ reader.pause();
530
+ if (reader.terminal) tty.setRawMode?.(false);
531
+ }
532
+ return answer || defaultValue || "";
533
+ };
534
+ }
535
+ var stdinPrompt;
536
+ var terminalPrompt = (question, defaultValue) => {
537
+ stdinPrompt ??= createLinePrompt(process.stdin, process.stderr);
538
+ return stdinPrompt(question, defaultValue);
539
+ };
540
+ async function confirm(prompt, question, defaultYes) {
541
+ const answer = await prompt(`${question} (${defaultYes ? "Y/n" : "y/N"})`);
542
+ if (YES_ANSWER.test(answer)) return true;
543
+ if (NO_ANSWER.test(answer)) return false;
544
+ return defaultYes;
545
+ }
546
+ function formatQuestion(question, defaultValue) {
547
+ return defaultValue ? `${question} [${defaultValue}] ` : `${question} `;
548
+ }
549
+
550
+ // src/shell.ts
551
+ import { spawn, spawnSync } from "child_process";
552
+ import { platform } from "os";
553
+ var runCommand = (command, args, input) => {
554
+ const result = spawnSync(command, args, { encoding: "utf8", input, stdio: "pipe" });
555
+ return {
556
+ ok: result.status === 0 && !result.error,
557
+ stdout: result.stdout ?? "",
558
+ stderr: result.stderr ?? ""
559
+ };
560
+ };
561
+ var BROWSER_OPENERS = {
562
+ darwin: { command: "open", args: [] },
563
+ win32: { command: "cmd", args: ["/c", "start", ""] }
564
+ };
565
+ var DEFAULT_BROWSER_OPENER = { command: "xdg-open", args: [] };
566
+ function openBrowser(url) {
567
+ const opener = BROWSER_OPENERS[platform()] ?? DEFAULT_BROWSER_OPENER;
568
+ try {
569
+ const child = spawn(opener.command, [...opener.args, url], {
570
+ detached: true,
571
+ stdio: "ignore"
572
+ });
573
+ child.on("error", () => {
574
+ });
575
+ child.unref();
576
+ } catch {
577
+ }
578
+ return Promise.resolve();
122
579
  }
123
580
 
124
581
  // src/api.ts
125
582
  var REPORT_PREVIEW_PATH = "/api/qa/report-preview";
126
583
  var RUN_STATUS_PATH = "/api/qa/run-status";
584
+ var PAIR_PATH = "/api/qa/cli/pair";
585
+ var PAIR_POLL_PATH = "/api/qa/cli/pair/poll";
586
+ var CONNECTIONS_PATH = "/api/v1/qa/connections";
127
587
  function createApiClient(config, fetchImpl = fetch) {
128
588
  return {
129
589
  reportPreview: (params) => reportPreview(config, params, fetchImpl),
130
- getRunStatus: (params) => getRunStatus(config, params, fetchImpl)
590
+ getRunStatus: (params) => getRunStatus(config, params, fetchImpl),
591
+ pair: (deviceName) => pair(config, deviceName, fetchImpl),
592
+ pollPairing: (deviceCode) => pollPairing(config, deviceCode, fetchImpl),
593
+ listConnections: (repo) => listConnections(config, repo, fetchImpl)
131
594
  };
132
595
  }
133
596
  async function reportPreview(config, params, fetchImpl) {
134
597
  const response = await fetchImpl(joinUrl(config.apiUrl, REPORT_PREVIEW_PATH), {
135
598
  method: "POST",
136
- headers: {
137
- authorization: `Bearer ${config.apiToken}`,
138
- "content-type": "application/json"
139
- },
599
+ headers: { ...authHeaders(config), "content-type": "application/json" },
140
600
  body: JSON.stringify({
141
601
  repo: params.repo,
142
602
  prNumber: params.prNumber,
@@ -149,25 +609,72 @@ async function reportPreview(config, params, fetchImpl) {
149
609
  return { ok: true, runId: payload.runId };
150
610
  }
151
611
  return {
152
- ok: false,
153
- status: response.status,
154
- error: errorMessage(payload),
155
- retryable: booleanOrUndefined(payload.retryable),
156
- code: stringOrUndefined2(payload.code)
612
+ ...failure(response, payload),
613
+ retryable: booleanOrUndefined(payload.retryable)
157
614
  };
158
615
  }
159
616
  async function getRunStatus(config, params, fetchImpl) {
160
617
  const url = new URL(joinUrl(config.apiUrl, RUN_STATUS_PATH));
161
618
  url.searchParams.set("repo", params.repo);
162
- url.searchParams.set("prNumber", String(params.prNumber));
163
- const response = await fetchImpl(url.toString(), {
164
- headers: { authorization: `Bearer ${config.apiToken}` }
165
- });
619
+ setRunLookupParam(url, params);
620
+ const response = await fetchImpl(url.toString(), { headers: authHeaders(config) });
166
621
  const payload = await readJson(response);
167
622
  if (response.ok && isRunStatus(payload.run)) {
168
623
  return { ok: true, run: payload.run };
169
624
  }
170
- return { ok: false, status: response.status, error: errorMessage(payload) };
625
+ return failure(response, payload);
626
+ }
627
+ async function pair(config, deviceName, fetchImpl) {
628
+ const response = await fetchImpl(joinUrl(config.apiUrl, PAIR_PATH), {
629
+ method: "POST",
630
+ headers: { "content-type": "application/json" },
631
+ body: JSON.stringify({ deviceName })
632
+ });
633
+ const payload = await readJson(response);
634
+ const pairing = response.ok ? parsePairingStart(payload.data) : void 0;
635
+ if (pairing) {
636
+ return { ok: true, pairing };
637
+ }
638
+ return failure(response, payload);
639
+ }
640
+ async function pollPairing(config, deviceCode, fetchImpl) {
641
+ const response = await fetchImpl(joinUrl(config.apiUrl, PAIR_POLL_PATH), {
642
+ method: "POST",
643
+ headers: { "content-type": "application/json" },
644
+ body: JSON.stringify({ deviceCode })
645
+ });
646
+ const payload = await readJson(response);
647
+ const data = response.ok && isRecord2(payload.data) ? payload.data : void 0;
648
+ if (data?.status === "PENDING") {
649
+ return { ok: true, pending: true };
650
+ }
651
+ const approval = data?.status === "APPROVED" ? parsePairingApproval(data) : void 0;
652
+ if (approval) {
653
+ return { ok: true, pending: false, approval };
654
+ }
655
+ return failure(response, payload);
656
+ }
657
+ async function listConnections(config, repo, fetchImpl) {
658
+ const url = new URL(joinUrl(config.apiUrl, CONNECTIONS_PATH));
659
+ url.searchParams.set("repo", repo);
660
+ const response = await fetchImpl(url.toString(), { headers: authHeaders(config) });
661
+ const payload = await readJson(response);
662
+ const data = response.ok && isRecord2(payload.data) ? payload.data : void 0;
663
+ const workspace = parseWorkspace(data?.workspace);
664
+ if (workspace && Array.isArray(data?.connections)) {
665
+ return { ok: true, workspace, connections: parseConnections(data.connections) };
666
+ }
667
+ return failure(response, payload);
668
+ }
669
+ function setRunLookupParam(url, params) {
670
+ if (params.prNumber !== void 0) {
671
+ url.searchParams.set("prNumber", String(params.prNumber));
672
+ } else if (params.headSha) {
673
+ url.searchParams.set("headSha", params.headSha);
674
+ }
675
+ }
676
+ function authHeaders(config) {
677
+ return config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {};
171
678
  }
172
679
  function joinUrl(base, path) {
173
680
  return `${base.replace(/\/+$/, "")}${path}`;
@@ -180,6 +687,14 @@ async function readJson(response) {
180
687
  return {};
181
688
  }
182
689
  }
690
+ function failure(response, payload) {
691
+ return {
692
+ ok: false,
693
+ status: response.status,
694
+ error: errorMessage(payload),
695
+ code: stringOrUndefined(payload.code)
696
+ };
697
+ }
183
698
  function errorMessage(payload) {
184
699
  return isString(payload.error) ? payload.error : "Request failed.";
185
700
  }
@@ -189,15 +704,103 @@ function isRecord2(value) {
189
704
  function isString(value) {
190
705
  return typeof value === "string";
191
706
  }
192
- function stringOrUndefined2(value) {
707
+ function stringOrUndefined(value) {
193
708
  return isString(value) ? value : void 0;
194
709
  }
710
+ function stringOrNull(value) {
711
+ return isString(value) ? value : null;
712
+ }
713
+ function numberOrNull(value) {
714
+ return typeof value === "number" ? value : null;
715
+ }
195
716
  function booleanOrUndefined(value) {
196
717
  return typeof value === "boolean" ? value : void 0;
197
718
  }
719
+ function booleanOr(value, fallback) {
720
+ return typeof value === "boolean" ? value : fallback;
721
+ }
198
722
  function isRunStatus(value) {
199
723
  return isRecord2(value) && isString(value.id) && isString(value.status) && typeof value.blockerCount === "number";
200
724
  }
725
+ function parsePairingStart(value) {
726
+ if (!isRecord2(value) || !isString(value.deviceCode) || !isString(value.userCode) || !isString(value.verificationUrl) || typeof value.expiresInSeconds !== "number" || typeof value.intervalSeconds !== "number") {
727
+ return void 0;
728
+ }
729
+ return {
730
+ deviceCode: value.deviceCode,
731
+ userCode: value.userCode,
732
+ verificationUrl: value.verificationUrl,
733
+ expiresInSeconds: value.expiresInSeconds,
734
+ intervalSeconds: value.intervalSeconds
735
+ };
736
+ }
737
+ function parsePairingApproval(value) {
738
+ if (!isString(value.apiToken)) return void 0;
739
+ return {
740
+ apiToken: value.apiToken,
741
+ workspaceName: stringOrNull(value.workspaceName) ?? "",
742
+ workspaceSlug: stringOrNull(value.workspaceSlug) ?? ""
743
+ };
744
+ }
745
+ function parseWorkspace(value) {
746
+ if (!isRecord2(value) || !isString(value.id)) return void 0;
747
+ return {
748
+ id: value.id,
749
+ name: stringOrNull(value.name) ?? "",
750
+ slug: stringOrNull(value.slug) ?? ""
751
+ };
752
+ }
753
+ function parseConnections(values) {
754
+ const connections = [];
755
+ for (const value of values) {
756
+ const connection = parseConnection(value);
757
+ if (connection) connections.push(connection);
758
+ }
759
+ return connections;
760
+ }
761
+ function parseConnection(value) {
762
+ if (!isRecord2(value) || !isString(value.id) || !isString(value.url)) return void 0;
763
+ const kind = parseConnectionKind(value.kind);
764
+ const previewUrlSource = parsePreviewUrlSource(value.previewUrlSource);
765
+ if (!kind || !previewUrlSource) return void 0;
766
+ return {
767
+ id: value.id,
768
+ slug: stringOrNull(value.slug) ?? "",
769
+ kind,
770
+ status: stringOrNull(value.status) ?? "",
771
+ enabled: booleanOr(value.enabled, false),
772
+ repoFullName: stringOrNull(value.repoFullName),
773
+ hasInstallation: booleanOr(value.hasInstallation, false),
774
+ previewUrlSource,
775
+ previewUrlPattern: stringOrNull(value.previewUrlPattern),
776
+ deploymentEnvironment: stringOrNull(value.deploymentEnvironment),
777
+ waitForChecks: booleanOr(value.waitForChecks, false),
778
+ liveUrl: stringOrNull(value.liveUrl),
779
+ findingPrefix: stringOrNull(value.findingPrefix),
780
+ url: value.url,
781
+ lastRun: parseLastRun(value.lastRun)
782
+ };
783
+ }
784
+ function parseConnectionKind(value) {
785
+ if (value === "GITHUB" || value === "URL_ONLY") return value;
786
+ return void 0;
787
+ }
788
+ function parsePreviewUrlSource(value) {
789
+ if (value === "DEPLOYMENT_STATUS" || value === "URL_PATTERN") return value;
790
+ return void 0;
791
+ }
792
+ function parseLastRun(value) {
793
+ if (!isRecord2(value) || !isString(value.id)) return null;
794
+ return {
795
+ id: value.id,
796
+ status: stringOrNull(value.status) ?? "",
797
+ verdict: stringOrNull(value.verdict),
798
+ previewUrl: stringOrNull(value.previewUrl),
799
+ createdAt: stringOrNull(value.createdAt) ?? "",
800
+ prNumber: numberOrNull(value.prNumber),
801
+ branch: stringOrNull(value.branch)
802
+ };
803
+ }
201
804
 
202
805
  // src/logger.ts
203
806
  import pc from "picocolors";
@@ -224,6 +827,605 @@ var defaultDeps = {
224
827
  now: () => Date.now()
225
828
  };
226
829
 
830
+ // src/commands/setup-deps.ts
831
+ var PROBE_TIMEOUT_MS = 5e3;
832
+ var METHOD_NOT_ALLOWED = 405;
833
+ var defaultSetupDeps = {
834
+ ...defaultDeps,
835
+ ...nodeFileSystem,
836
+ prompt: terminalPrompt,
837
+ openBrowser,
838
+ runCommand,
839
+ detectRepo: () => detectRepoFullName(),
840
+ repoRoot: () => detectRepoRoot() ?? process.cwd(),
841
+ deviceName: () => `${hostname()} (${currentUsername()})`,
842
+ configFilePath: resolveConfigFilePath(),
843
+ probeUrl: (url) => probeUrl(url)
844
+ };
845
+ function currentUsername(lookup = userInfo) {
846
+ try {
847
+ return lookup().username;
848
+ } catch {
849
+ return process.env.USER ?? process.env.USERNAME ?? "unknown";
850
+ }
851
+ }
852
+ async function probeUrl(url) {
853
+ try {
854
+ const head = await fetch(url, {
855
+ method: "HEAD",
856
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
857
+ });
858
+ if (head.status !== METHOD_NOT_ALLOWED) return { ok: head.ok, status: head.status };
859
+ const get = await fetch(url, { method: "GET", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
860
+ return { ok: get.ok, status: get.status };
861
+ } catch (error) {
862
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
863
+ }
864
+ }
865
+
866
+ // src/commands/doctor.ts
867
+ var MARKS = { pass: "\u2713", fail: "\u2717", skip: "\u2013" };
868
+ var REPO_HINT = "pass --repo owner/name or run inside a checkout with a github.com origin remote";
869
+ var INIT_HINT = "run `npx @datadisco/qa init`";
870
+ async function runDoctor(options, deps = defaultSetupDeps) {
871
+ const checks = await collectChecks(options, deps);
872
+ for (const check of checks) printCheck(check, deps);
873
+ return checks.some((check) => check.state === "fail") ? 1 : 0;
874
+ }
875
+ async function collectChecks(options, deps) {
876
+ const apiUrl = resolveApiUrl(options);
877
+ const apiToken = findExistingToken(options, apiUrl, deps);
878
+ if (!apiToken) {
879
+ return [fail("API token", "run `datadisco-qa login`")];
880
+ }
881
+ const repo = options.repo ?? deps.detectRepo();
882
+ if (!repo) {
883
+ return [pass("API token present"), fail("Repository", REPO_HINT)];
884
+ }
885
+ return checksForRepo(deps.createApi({ apiUrl, apiToken }), apiUrl, repo, deps);
886
+ }
887
+ async function checksForRepo(api, apiUrl, repo, deps) {
888
+ const outcome = await api.listConnections(repo);
889
+ if (!outcome.ok) {
890
+ return [tokenFailure(outcome.status, outcome.error)];
891
+ }
892
+ const connection = findGithubConnection(outcome);
893
+ if (!connection) {
894
+ return [
895
+ pass(`API token valid for workspace ${outcome.workspace.name}`),
896
+ fail(`Repository ${repo} is not connected`, `connect it at ${connectRepoUrl(apiUrl, repo)}`)
897
+ ];
898
+ }
899
+ const detection = detectDeployHost(deps.repoRoot(), deps);
900
+ const workflow = readWorkflowState(deps);
901
+ const notNeeded = reasonCiReportNotNeeded(connection, detection, workflow);
902
+ return [
903
+ pass(`API token valid for workspace ${outcome.workspace.name}`),
904
+ connectionCheck(repo, connection),
905
+ previewSourceCheck(connection, detection, workflow),
906
+ notNeeded ? skip(`Workflow not needed: ${notNeeded}`) : workflowCheck(workflow),
907
+ notNeeded ? skip(`${API_TOKEN_SECRET_NAME} secret not needed: ${notNeeded}`) : secretCheck(repo, deps),
908
+ await liveUrlCheck(connection, deps),
909
+ lastRunCheck(connection.lastRun)
910
+ ];
911
+ }
912
+ function readWorkflowState(deps) {
913
+ const path = workflowFilePath(deps.repoRoot());
914
+ if (!deps.exists(path)) return "missing";
915
+ return triggersOnDeploymentStatus(deps.readFile(path) ?? "") ? "deployment-status" : "reports-previews";
916
+ }
917
+ function tokenFailure(status, error) {
918
+ if (status === 401) return fail("API token rejected", "run `datadisco-qa login`");
919
+ return fail(`Could not list connections (${error})`, "check --api-url and your network");
920
+ }
921
+ function connectionCheck(repo, connection) {
922
+ const problem = describeConnectionProblem(connection);
923
+ return problem ? fail(`Repository ${repo} is connected but unhealthy`, `${problem}: ${connection.url}`) : pass(`Repository ${repo} is connected (${connection.url})`);
924
+ }
925
+ function previewSourceCheck(connection, detection, workflow) {
926
+ if (connection.previewUrlSource === "URL_PATTERN") {
927
+ return pass(`Preview source: URL pattern ${connection.previewUrlPattern ?? "(unset)"}`);
928
+ }
929
+ const lastPreview = connection.lastRun?.previewUrl;
930
+ if (lastPreview) {
931
+ return pass(`Preview source: deploy events (last preview ${lastPreview})`);
932
+ }
933
+ if (detection.deployEvents) {
934
+ return pass(`Preview source: deploy events from ${detection.host} (no preview received yet)`);
935
+ }
936
+ if (workflow === "reports-previews") {
937
+ return pass(`Preview source: reported by ${WORKFLOW_RELATIVE_PATH} (no preview received yet)`);
938
+ }
939
+ if (workflow === "deployment-status") {
940
+ return fail(
941
+ `Preview source: ${WORKFLOW_RELATIVE_PATH} runs on deployment_status, and no preview URL has ever arrived`,
942
+ `${describeMissingDeployEvents(detection)}, so it never fires \u2014 report a URL pattern instead; ${INIT_HINT}`
943
+ );
944
+ }
945
+ return fail(
946
+ "Preview source: deploy events, but no preview URL has ever arrived",
947
+ `${describeMissingDeployEvents(detection)} \u2014 add a CI report or a URL pattern; ${INIT_HINT}`
948
+ );
949
+ }
950
+ function describeMissingDeployEvents(detection) {
951
+ if (detection.host === "unknown") return "the deploy host was not detected";
952
+ return isDeployEventHost(detection.host) ? `${detection.host} is deployed from CI, which emits no deploy events` : `${detection.host} does not emit deploy events`;
953
+ }
954
+ function reasonCiReportNotNeeded(connection, detection, workflow) {
955
+ if (workflow !== "missing") return void 0;
956
+ if (connection.previewUrlSource === "URL_PATTERN") return "previews come from the URL pattern";
957
+ if (detection.deployEvents) return `${detection.host} emits deploy events`;
958
+ if (connection.lastRun?.previewUrl) return "previews already arrive as deploy events";
959
+ return void 0;
960
+ }
961
+ function workflowCheck(workflow) {
962
+ return workflow !== "missing" ? pass(`${WORKFLOW_RELATIVE_PATH} present`) : fail(`${WORKFLOW_RELATIVE_PATH} missing`, INIT_HINT);
963
+ }
964
+ function secretCheck(repo, deps) {
965
+ const gh = checkGh(deps.runCommand);
966
+ if (gh !== "ready") {
967
+ return skip(
968
+ `${API_TOKEN_SECRET_NAME} secret not checked (gh ${gh === "missing" ? "not installed" : "not signed in"})`
969
+ );
970
+ }
971
+ const names = listRepoSecretNames(deps.runCommand, repo);
972
+ if (!names)
973
+ return skip(`${API_TOKEN_SECRET_NAME} secret not checked (gh could not list secrets)`);
974
+ return names.includes(API_TOKEN_SECRET_NAME) ? pass(`${API_TOKEN_SECRET_NAME} secret set on ${repo}`) : fail(
975
+ `${API_TOKEN_SECRET_NAME} secret missing on ${repo}`,
976
+ `gh secret set ${API_TOKEN_SECRET_NAME} --repo ${repo}`
977
+ );
978
+ }
979
+ async function liveUrlCheck(connection, deps) {
980
+ if (!connection.liveUrl) return skip("Live URL not set");
981
+ const probe = await deps.probeUrl(connection.liveUrl);
982
+ if (probe.ok) return pass(`Live URL reachable (${connection.liveUrl})`);
983
+ const detail = probe.status !== void 0 ? `HTTP ${probe.status}` : probe.error ?? "unreachable";
984
+ return fail(
985
+ `Live URL unreachable (${connection.liveUrl}: ${detail})`,
986
+ "check the URL on the connection page"
987
+ );
988
+ }
989
+ function lastRunCheck(lastRun) {
990
+ if (!lastRun) return skip("No QA runs yet");
991
+ const target = lastRun.prNumber !== null ? `PR #${lastRun.prNumber}` : lastRun.branch ?? "unknown ref";
992
+ const verdict = lastRun.verdict ? `, verdict ${lastRun.verdict}` : "";
993
+ return pass(`Last run: ${lastRun.status}${verdict} on ${target} at ${lastRun.createdAt}`);
994
+ }
995
+ function printCheck(check, deps) {
996
+ const line = `${MARKS[check.state]} ${check.label}`;
997
+ if (check.state === "pass") return deps.logger.success(line);
998
+ if (check.state === "skip") return deps.logger.info(line);
999
+ deps.logger.error(check.hint ? `${line} \u2014 ${check.hint}` : line);
1000
+ }
1001
+ function pass(label) {
1002
+ return { state: "pass", label };
1003
+ }
1004
+ function fail(label, hint) {
1005
+ return { state: "fail", label, hint };
1006
+ }
1007
+ function skip(label) {
1008
+ return { state: "skip", label };
1009
+ }
1010
+
1011
+ // src/commands/init.ts
1012
+ var INIT_EXIT_CODE = {
1013
+ DONE: 0,
1014
+ ERROR: 1,
1015
+ STOPPED_WAITING: 2
1016
+ };
1017
+ var CONNECTION_POLL_INTERVAL_MS = 5e3;
1018
+ var CONNECTION_WAIT_TIMEOUT_MS = 10 * 6e4;
1019
+ var EXAMPLE_PATTERN = `https://pr-${PATTERN_PLACEHOLDER}.preview.example.com`;
1020
+ var REPO_MISSING_MESSAGE = "Repository not set: pass --repo owner/name, or run inside a git checkout with a github.com origin remote.";
1021
+ async function runInit(options, deps = defaultSetupDeps) {
1022
+ const session = await resolveSession(options, deps);
1023
+ if (!session.ok) {
1024
+ deps.logger.error(session.error);
1025
+ return INIT_EXIT_CODE.ERROR;
1026
+ }
1027
+ const { apiUrl, apiToken, api } = session.session;
1028
+ const repo = resolveRepo(options, deps);
1029
+ deps.logger.info(`Repository: ${repo}`);
1030
+ const connection = await ensureConnection(api, apiUrl, repo, options, deps);
1031
+ if (typeof connection === "number") return connection;
1032
+ const detection = detectDeployHost(deps.repoRoot(), deps);
1033
+ deps.logger.info(describeDetection(detection));
1034
+ const written = await configurePreviewReporting(connection, detection, options, deps);
1035
+ if (written === "needs-pattern") return INIT_EXIT_CODE.ERROR;
1036
+ if (written === "workflow-present") {
1037
+ await ensureSecret(repo, apiToken, options, deps);
1038
+ }
1039
+ printSummary(connection, written, deps);
1040
+ return INIT_EXIT_CODE.DONE;
1041
+ }
1042
+ function resolveRepo(options, deps) {
1043
+ const repo = options.repo ?? deps.detectRepo();
1044
+ if (!repo) throw new ConfigError(REPO_MISSING_MESSAGE);
1045
+ return repo;
1046
+ }
1047
+ async function ensureConnection(api, apiUrl, repo, options, deps) {
1048
+ const outcome = await api.listConnections(repo);
1049
+ if (!outcome.ok) {
1050
+ deps.logger.error(describeListFailure(outcome.status, outcome.error));
1051
+ return INIT_EXIT_CODE.ERROR;
1052
+ }
1053
+ const existing = findGithubConnection(outcome);
1054
+ if (existing) {
1055
+ deps.logger.success(`Connected to workspace ${outcome.workspace.name} (${existing.url})`);
1056
+ return existing;
1057
+ }
1058
+ const connectUrl = connectRepoUrl(apiUrl, repo);
1059
+ deps.logger.warn("This repo is not connected yet.");
1060
+ deps.logger.info(`Connect it at ${connectUrl}`);
1061
+ if (options.browser !== false) await deps.openBrowser(connectUrl);
1062
+ if (options.yes) {
1063
+ deps.logger.info("Re-run `datadisco-qa init` once the repo is connected.");
1064
+ return INIT_EXIT_CODE.STOPPED_WAITING;
1065
+ }
1066
+ return waitForConnection(api, repo, deps);
1067
+ }
1068
+ async function waitForConnection(api, repo, deps) {
1069
+ const deadline = deps.now() + CONNECTION_WAIT_TIMEOUT_MS;
1070
+ deps.logger.info("Waiting for you to connect the repo in the browser\u2026");
1071
+ while (deps.now() < deadline) {
1072
+ await deps.sleep(CONNECTION_POLL_INTERVAL_MS);
1073
+ const outcome = await pollConnections(api, repo);
1074
+ if (!outcome.ok && isFinalListFailure(outcome.status)) {
1075
+ deps.logger.error(describeListFailure(outcome.status, outcome.error));
1076
+ return INIT_EXIT_CODE.ERROR;
1077
+ }
1078
+ const connection = findGithubConnection(outcome);
1079
+ if (connection) {
1080
+ deps.logger.success(`Connected: ${connection.url}`);
1081
+ return connection;
1082
+ }
1083
+ }
1084
+ deps.logger.error(
1085
+ "Gave up waiting for the repo to be connected. Re-run `datadisco-qa init` once it is."
1086
+ );
1087
+ return INIT_EXIT_CODE.STOPPED_WAITING;
1088
+ }
1089
+ async function pollConnections(api, repo) {
1090
+ try {
1091
+ return await api.listConnections(repo);
1092
+ } catch (error) {
1093
+ return { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
1094
+ }
1095
+ }
1096
+ function isFinalListFailure(status) {
1097
+ return status >= 400 && status < 500 && status !== 429;
1098
+ }
1099
+ function describeListFailure(status, error) {
1100
+ if (status === 401) {
1101
+ return `The API token was rejected (${error}). Run \`datadisco-qa login\` to sign in again.`;
1102
+ }
1103
+ return `Could not look up the repo's connections: ${error}`;
1104
+ }
1105
+ function describeDetection(detection) {
1106
+ const evidence = detection.evidence.length > 0 ? ` (${detection.evidence.join(", ")})` : "";
1107
+ return detection.host === "unknown" ? `Deploy host: not detected${evidence}` : `Deploy host: ${detection.host}${evidence}`;
1108
+ }
1109
+ async function configurePreviewReporting(connection, detection, options, deps) {
1110
+ if (connection.previewUrlSource === "URL_PATTERN") {
1111
+ deps.logger.info(
1112
+ `Nothing to add: the connection derives previews from the URL pattern ${connection.previewUrlPattern ?? ""}`.trimEnd()
1113
+ );
1114
+ return "nothing-to-add";
1115
+ }
1116
+ if (detection.deployEvents) {
1117
+ deps.logger.info("Nothing to add: DataDisco picks previews up from deploy events.");
1118
+ return "nothing-to-add";
1119
+ }
1120
+ return offerWorkflow(connection, options, deps);
1121
+ }
1122
+ async function offerWorkflow(connection, options, deps) {
1123
+ deps.logger.info(
1124
+ "Previews from this host don't reach GitHub as deploy events, so CI needs to report each preview URL."
1125
+ );
1126
+ const path = workflowFilePath(deps.repoRoot());
1127
+ if (deps.exists(path) && !await confirmOverwrite(options, deps)) {
1128
+ deps.logger.info(`Keeping the existing ${WORKFLOW_RELATIVE_PATH}.`);
1129
+ return "workflow-present";
1130
+ }
1131
+ const pattern = await choosePattern(connection, options, deps);
1132
+ if (!pattern) {
1133
+ printPatternNeeded(connection, deps);
1134
+ return "needs-pattern";
1135
+ }
1136
+ deps.writeFile(path, renderWorkflow(pattern));
1137
+ deps.logger.success(`Wrote ${WORKFLOW_RELATIVE_PATH}: ${describeRecipe(pattern)}`);
1138
+ return "workflow-present";
1139
+ }
1140
+ async function confirmOverwrite(options, deps) {
1141
+ if (options.yes) return false;
1142
+ return confirm(deps.prompt, `${WORKFLOW_RELATIVE_PATH} already exists. Overwrite it?`, false);
1143
+ }
1144
+ async function choosePattern(connection, options, deps) {
1145
+ const suggested = connection.previewUrlPattern ?? void 0;
1146
+ if (options.yes) {
1147
+ if (suggested) assertValidPreviewUrlPattern(suggested);
1148
+ return suggested;
1149
+ }
1150
+ return askPattern(suggested, deps);
1151
+ }
1152
+ async function askPattern(suggested, deps) {
1153
+ let defaultValue = suggested;
1154
+ for (; ; ) {
1155
+ const pattern = await deps.prompt(
1156
+ `Preview URL pattern with ${PATTERN_PLACEHOLDER}, e.g. ${EXAMPLE_PATTERN}`,
1157
+ defaultValue
1158
+ );
1159
+ if (!pattern) return void 0;
1160
+ try {
1161
+ assertValidPreviewUrlPattern(pattern);
1162
+ return pattern;
1163
+ } catch (error) {
1164
+ if (!(error instanceof ConfigError)) throw error;
1165
+ deps.logger.error(error.message);
1166
+ defaultValue = void 0;
1167
+ }
1168
+ }
1169
+ }
1170
+ function printPatternNeeded(connection, deps) {
1171
+ deps.logger.error(
1172
+ `A preview URL pattern is needed to report previews from CI, e.g. ${EXAMPLE_PATTERN}. Nothing was written.`
1173
+ );
1174
+ deps.logger.info(
1175
+ `Set one on ${connection.url} or re-run \`datadisco-qa init\` and enter it when asked.`
1176
+ );
1177
+ }
1178
+ async function ensureSecret(repo, apiToken, options, deps) {
1179
+ const gh = checkGh(deps.runCommand);
1180
+ if (gh !== "ready") {
1181
+ printManualSecretSteps(repo, gh, deps);
1182
+ return;
1183
+ }
1184
+ const existing = listRepoSecretNames(deps.runCommand, repo);
1185
+ if (existing?.includes(API_TOKEN_SECRET_NAME)) {
1186
+ await replaceExistingSecret(repo, apiToken, options, deps);
1187
+ return;
1188
+ }
1189
+ const wanted = await confirmStoreSecret(repo, existing !== void 0, options, deps);
1190
+ if (!wanted) {
1191
+ printManualSecretSteps(repo, gh, deps);
1192
+ return;
1193
+ }
1194
+ storeSecret(repo, apiToken, gh, deps);
1195
+ }
1196
+ async function replaceExistingSecret(repo, apiToken, options, deps) {
1197
+ const replace = !options.yes && await confirm(
1198
+ deps.prompt,
1199
+ `${API_TOKEN_SECRET_NAME} is already set on ${repo}. Replace it with your personal sign-in token?`,
1200
+ false
1201
+ );
1202
+ if (!replace) {
1203
+ deps.logger.info(`Keeping the existing ${API_TOKEN_SECRET_NAME} secret on ${repo}.`);
1204
+ return;
1205
+ }
1206
+ storeSecret(repo, apiToken, "ready", deps);
1207
+ }
1208
+ async function confirmStoreSecret(repo, listed, options, deps) {
1209
+ const question = `Store your personal sign-in token as the ${API_TOKEN_SECRET_NAME} Actions secret on ${repo} via gh?`;
1210
+ if (listed) return options.yes || confirm(deps.prompt, question, true);
1211
+ deps.logger.warn(`Could not check whether ${API_TOKEN_SECRET_NAME} is already set on ${repo}.`);
1212
+ return !options.yes && confirm(deps.prompt, `${question} It replaces the secret if set.`, false);
1213
+ }
1214
+ function storeSecret(repo, apiToken, gh, deps) {
1215
+ const result = setRepoSecret(deps.runCommand, repo, API_TOKEN_SECRET_NAME, apiToken);
1216
+ if (result.ok) {
1217
+ deps.logger.success(`Set the ${API_TOKEN_SECRET_NAME} secret on ${repo}.`);
1218
+ return;
1219
+ }
1220
+ deps.logger.warn(`gh could not set the secret: ${result.stderr.trim() || "unknown error"}`);
1221
+ printManualSecretSteps(repo, gh, deps);
1222
+ }
1223
+ var MANUAL_SECRET_REASONS = {
1224
+ missing: "gh is not installed",
1225
+ unauthenticated: "gh is not signed in (run `gh auth login`)",
1226
+ ready: "skipped"
1227
+ };
1228
+ function printManualSecretSteps(repo, gh, deps) {
1229
+ deps.logger.info(
1230
+ `Add the ${API_TOKEN_SECRET_NAME} secret manually (${MANUAL_SECRET_REASONS[gh]}):`
1231
+ );
1232
+ deps.logger.info(` 1. Open https://github.com/${repo}/settings/secrets/actions/new`);
1233
+ deps.logger.info(` 2. Name: ${API_TOKEN_SECRET_NAME}`);
1234
+ deps.logger.info(
1235
+ " 3. Value: the workspace API token from Workspace settings \u2192 Integrations \u2192 API tokens"
1236
+ );
1237
+ deps.logger.info(` (or: gh secret set ${API_TOKEN_SECRET_NAME} --repo ${repo})`);
1238
+ }
1239
+ function printSummary(connection, written, deps) {
1240
+ deps.logger.info("");
1241
+ deps.logger.success(`Done. QA for this repo: ${connection.url}`);
1242
+ deps.logger.info(
1243
+ written === "workflow-present" ? `Commit ${WORKFLOW_RELATIVE_PATH} and open a pull request to start the first run.` : "Nothing was written \u2014 open a pull request to start the first run."
1244
+ );
1245
+ deps.logger.info("Run `npx @datadisco/qa doctor` any time to re-check.");
1246
+ }
1247
+
1248
+ // src/commands/login.ts
1249
+ async function runLogin(options, deps = defaultSetupDeps) {
1250
+ const apiUrl = resolveApiUrl(options);
1251
+ const result = await signIn(apiUrl, deps.createApi({ apiUrl }), deps, {
1252
+ openBrowser: options.browser !== false
1253
+ });
1254
+ if (!result.ok) {
1255
+ deps.logger.error(result.error);
1256
+ return 1;
1257
+ }
1258
+ deps.logger.info(`Token saved to ${deps.configFilePath}`);
1259
+ return 0;
1260
+ }
1261
+
1262
+ // src/commands/logout.ts
1263
+ async function runLogout(options, deps = defaultSetupDeps) {
1264
+ const apiUrl = resolveApiUrl(options);
1265
+ if (!deleteStoredToken(apiUrl, deps)) {
1266
+ deps.logger.info(`No saved sign-in for ${apiUrl}.`);
1267
+ return 0;
1268
+ }
1269
+ deps.logger.success(`Signed out of ${apiUrl} (removed from ${deps.configFilePath}).`);
1270
+ return 0;
1271
+ }
1272
+
1273
+ // src/context.ts
1274
+ import { readFileSync as readFileSync2 } from "fs";
1275
+ var REPO_PATTERN = /^[^/\s]+\/[^/\s]+$/;
1276
+ var PR_REF_PATTERN = /^refs\/pull\/(\d+)\//;
1277
+ var REPO_MISSING_MESSAGE2 = "Repository not set: pass --repo owner/name (auto-detected from the GitHub Actions env or a github.com origin remote on the git checkout).";
1278
+ var HEAD_SHA_MISSING_MESSAGE = "Head SHA not set: pass --sha <sha> (auto-detected from the GitHub Actions env or the git checkout's HEAD).";
1279
+ var PR_NUMBER_MISSING_MESSAGE = "Pull request number not set: pass --pr <number> (auto-detected in GitHub Actions, CircleCI, Jenkins, Buildkite, Travis CI, and Drone).";
1280
+ var PR_OR_SHA_MISSING_MESSAGE = "Neither a pull request number nor a head SHA could be resolved: pass --pr <number> or --sha <sha> (or run inside a supported CI, or a git checkout).";
1281
+ function resolveRunContext(input = {}) {
1282
+ const context = resolveRunContextAllowingShaOnly(input);
1283
+ if (context.prNumber === void 0) {
1284
+ throw new ConfigError(PR_NUMBER_MISSING_MESSAGE);
1285
+ }
1286
+ return { ...context, prNumber: context.prNumber };
1287
+ }
1288
+ function resolveRunContextAllowingShaOnly(input = {}) {
1289
+ const env = input.env ?? process.env;
1290
+ const payload = loadEventPayload(input, env);
1291
+ const headSha = resolveHeadSha(input, env, payload) ?? detectHeadSha(input.gitRunner);
1292
+ if (!headSha) {
1293
+ throw new ConfigError(HEAD_SHA_MISSING_MESSAGE);
1294
+ }
1295
+ return {
1296
+ repo: resolveRepo2(input, env),
1297
+ prNumber: resolvePrNumber(input, env, payload),
1298
+ headSha
1299
+ };
1300
+ }
1301
+ function resolvePrContext(input = {}) {
1302
+ const env = input.env ?? process.env;
1303
+ const payload = loadEventPayload(input, env);
1304
+ const prNumber = resolvePrNumber(input, env, payload);
1305
+ const knownHeadSha = resolveHeadSha(input, env, payload);
1306
+ const headSha = prNumber === void 0 ? knownHeadSha ?? detectHeadSha(input.gitRunner) : knownHeadSha;
1307
+ if (prNumber === void 0 && !headSha) {
1308
+ throw new ConfigError(PR_OR_SHA_MISSING_MESSAGE);
1309
+ }
1310
+ return { repo: resolveRepo2(input, env), prNumber, headSha };
1311
+ }
1312
+ function resolveRepo2(input, env = {}) {
1313
+ const repo = input.repo ?? env.GITHUB_REPOSITORY ?? detectRepoFullName(input.gitRunner);
1314
+ if (!repo) {
1315
+ throw new ConfigError(REPO_MISSING_MESSAGE2);
1316
+ }
1317
+ if (!REPO_PATTERN.test(repo)) {
1318
+ throw new ConfigError(`--repo must look like "owner/name", got "${repo}".`);
1319
+ }
1320
+ return repo;
1321
+ }
1322
+ function resolveHeadSha(input, env = {}, payload) {
1323
+ return input.sha ?? payload.headSha ?? env.GITHUB_SHA;
1324
+ }
1325
+ function resolvePrNumber(input, env = {}, payload) {
1326
+ const candidate = input.pr ?? parsePrFromRef(env.GITHUB_REF) ?? payload.prNumber ?? resolvePrNumberFromCiEnv(env);
1327
+ return toPositiveInteger(candidate);
1328
+ }
1329
+ function toPositiveInteger(candidate) {
1330
+ if (candidate === void 0) return void 0;
1331
+ const parsed = Number(candidate);
1332
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
1333
+ }
1334
+ function parsePrFromRef(ref) {
1335
+ const match = ref?.match(PR_REF_PATTERN);
1336
+ return match ? match[1] : void 0;
1337
+ }
1338
+ var CI_PR_ENV_SOURCES = [
1339
+ {
1340
+ // CircleCI: CIRCLE_PULL_REQUEST is a URL (https://github.com/o/r/pull/123);
1341
+ // CIRCLE_PR_NUMBER is a plain number and is checked as a fallback.
1342
+ ci: "CircleCI",
1343
+ read: (env) => numericPrCandidate(trailingNumber(env.CIRCLE_PULL_REQUEST)) ?? numericPrCandidate(env.CIRCLE_PR_NUMBER)
1344
+ },
1345
+ {
1346
+ // Jenkins (Multibranch/PR builders): CHANGE_ID is the PR number.
1347
+ ci: "Jenkins",
1348
+ read: (env) => numericPrCandidate(env.CHANGE_ID)
1349
+ },
1350
+ {
1351
+ // Buildkite: BUILDKITE_PULL_REQUEST is "false" on non-PR builds.
1352
+ ci: "Buildkite",
1353
+ read: (env) => numericPrCandidate(env.BUILDKITE_PULL_REQUEST)
1354
+ },
1355
+ {
1356
+ // Travis CI: TRAVIS_PULL_REQUEST is "false" on non-PR builds.
1357
+ ci: "Travis CI",
1358
+ read: (env) => numericPrCandidate(env.TRAVIS_PULL_REQUEST)
1359
+ },
1360
+ {
1361
+ // Drone: DRONE_PULL_REQUEST is the PR number, unset outside PR builds.
1362
+ ci: "Drone",
1363
+ read: (env) => numericPrCandidate(env.DRONE_PULL_REQUEST)
1364
+ }
1365
+ ];
1366
+ function resolvePrNumberFromCiEnv(env) {
1367
+ for (const source of CI_PR_ENV_SOURCES) {
1368
+ const candidate = source.read(env);
1369
+ if (candidate !== void 0) return candidate;
1370
+ }
1371
+ return void 0;
1372
+ }
1373
+ function trailingNumber(value) {
1374
+ const match = value?.match(/(\d+)\/?$/);
1375
+ return match?.[1];
1376
+ }
1377
+ function numericPrCandidate(value) {
1378
+ if (!value || value === "false") return void 0;
1379
+ const parsed = Number(value);
1380
+ return Number.isInteger(parsed) && parsed > 0 ? value : void 0;
1381
+ }
1382
+ function loadEventPayload(input, env = {}) {
1383
+ const read = input.readEventPayload ?? (() => readEventFile(env.GITHUB_EVENT_PATH));
1384
+ const raw = safeRead(read);
1385
+ if (!isRecord3(raw)) return {};
1386
+ const pullRequest = isRecord3(raw.pull_request) ? raw.pull_request : void 0;
1387
+ const head = isRecord3(pullRequest?.head) ? pullRequest.head : void 0;
1388
+ return {
1389
+ prNumber: numberOrUndefined(pullRequest?.number ?? raw.number),
1390
+ headSha: stringOrUndefined2(head?.sha)
1391
+ };
1392
+ }
1393
+ function readEventFile(path) {
1394
+ if (!path) return void 0;
1395
+ return JSON.parse(readFileSync2(path, "utf8"));
1396
+ }
1397
+ function safeRead(read) {
1398
+ try {
1399
+ return read();
1400
+ } catch {
1401
+ return void 0;
1402
+ }
1403
+ }
1404
+ function isRecord3(value) {
1405
+ return typeof value === "object" && value !== null;
1406
+ }
1407
+ function numberOrUndefined(value) {
1408
+ return typeof value === "number" ? value : void 0;
1409
+ }
1410
+ function stringOrUndefined2(value) {
1411
+ return typeof value === "string" ? value : void 0;
1412
+ }
1413
+ function describeContextTarget(context) {
1414
+ if (context.prNumber !== void 0) return `${context.repo}#${context.prNumber}`;
1415
+ if (context.headSha) return `${context.repo}@${context.headSha}`;
1416
+ return context.repo;
1417
+ }
1418
+
1419
+ // src/legacy-server.ts
1420
+ var LEGACY_SERVER_BAD_REQUEST_STATUS = 400;
1421
+ function isUnresolvedPrOnLegacyServer(options) {
1422
+ return !options.prNumberKnown && options.status === LEGACY_SERVER_BAD_REQUEST_STATUS && !options.code;
1423
+ }
1424
+ var LEGACY_SERVER_PR_HINT = "if this is an older DataDisco server, pass --pr <number> explicitly";
1425
+ function withLegacyServerPrHint(error) {
1426
+ return `${error} (${LEGACY_SERVER_PR_HINT})`;
1427
+ }
1428
+
227
1429
  // src/verdict.ts
228
1430
  var EXIT_CODE = {
229
1431
  PASSED: 0,
@@ -301,7 +1503,7 @@ var POLL_INTERVAL_MS = 15e3;
301
1503
  var MINUTE_MS = 6e4;
302
1504
  async function waitForVerdict(api, context, deps, timeoutMs) {
303
1505
  const deadline = deps.now() + timeoutMs;
304
- deps.logger.info(`Waiting for QA verdict on ${context.repo}#${context.prNumber}\u2026`);
1506
+ deps.logger.info(`Waiting for QA verdict on ${describeContextTarget(context)}\u2026`);
305
1507
  let lastStatus;
306
1508
  while (true) {
307
1509
  const outcome = await api.getRunStatus(context);
@@ -316,7 +1518,9 @@ async function waitForVerdict(api, context, deps, timeoutMs) {
316
1518
  return verdict.exitCode;
317
1519
  }
318
1520
  } else if (outcome.status !== 404) {
319
- deps.logger.error(`Could not read QA run status: ${outcome.error}`);
1521
+ deps.logger.error(
1522
+ `Could not read QA run status: ${describeRunStatusFailure(outcome, context)}`
1523
+ );
320
1524
  return 1;
321
1525
  }
322
1526
  if (deps.now() >= deadline) {
@@ -328,6 +1532,16 @@ async function waitForVerdict(api, context, deps, timeoutMs) {
328
1532
  await deps.sleep(POLL_INTERVAL_MS);
329
1533
  }
330
1534
  }
1535
+ function describeRunStatusFailure(outcome, context) {
1536
+ if (isUnresolvedPrOnLegacyServer({
1537
+ prNumberKnown: context.prNumber !== void 0,
1538
+ status: outcome.status,
1539
+ code: outcome.code
1540
+ })) {
1541
+ return withLegacyServerPrHint(outcome.error);
1542
+ }
1543
+ return outcome.error;
1544
+ }
331
1545
  var DEFAULT_TIMEOUT_FLAG = {
332
1546
  flag: "--timeout",
333
1547
  defaultMinutes: DEFAULT_TIMEOUT_MINUTES
@@ -359,7 +1573,7 @@ async function runReportPreview(options, deps = defaultDeps) {
359
1573
  }
360
1574
  assertValidPreviewUrl(options.url);
361
1575
  const config = resolveConfig(options);
362
- const context = resolveRunContext(options);
1576
+ const context = resolveRunContextAllowingShaOnly(options);
363
1577
  const waitRunTimeoutMs = resolveTimeoutMs(options.waitRunTimeout, WAIT_RUN_TIMEOUT_SPEC);
364
1578
  const api = deps.createApi(config);
365
1579
  const outcome = await reportPreviewUntilRunExists(api, context, options.url, deps, {
@@ -367,11 +1581,11 @@ async function runReportPreview(options, deps = defaultDeps) {
367
1581
  waitRunTimeoutMs
368
1582
  });
369
1583
  if (!outcome.ok) {
370
- deps.logger.error(`Could not report preview URL: ${describeFailure(outcome)}`);
1584
+ deps.logger.error(`Could not report preview URL: ${describeFailure(outcome, context)}`);
371
1585
  return 1;
372
1586
  }
373
1587
  deps.logger.success(
374
- `Preview reported for ${context.repo}#${context.prNumber} \u2014 QA run ${outcome.runId} queued.`
1588
+ `Preview reported for ${describeContextTarget(context)} \u2014 QA run ${outcome.runId} queued.`
375
1589
  );
376
1590
  return 0;
377
1591
  }
@@ -385,7 +1599,14 @@ async function reportPreviewUntilRunExists(api, context, url, deps, policy) {
385
1599
  function isRunNotCreatedYet(outcome) {
386
1600
  return !outcome.ok && outcome.status === RUN_NOT_CREATED_YET_STATUS && outcome.retryable !== false;
387
1601
  }
388
- function describeFailure(outcome) {
1602
+ function describeFailure(outcome, context) {
1603
+ if (isUnresolvedPrOnLegacyServer({
1604
+ prNumberKnown: context.prNumber !== void 0,
1605
+ status: outcome.status,
1606
+ code: outcome.code
1607
+ })) {
1608
+ return withLegacyServerPrHint(outcome.error);
1609
+ }
389
1610
  return outcome.code ? `${outcome.error} [${outcome.code}]` : outcome.error;
390
1611
  }
391
1612
  async function retryReportPreviewUntilRunExists(api, context, url, deps, waitRunTimeoutMs) {
@@ -410,28 +1631,6 @@ function withRunNeverAppearedHint(error) {
410
1631
  return `${error} (gave up waiting \u2014 the QA run never appeared)`;
411
1632
  }
412
1633
 
413
- // src/git.ts
414
- import { execFileSync } from "child_process";
415
- var defaultRunner = (args) => execFileSync("git", args, { encoding: "utf8" }).trim();
416
- function detectRepoFullName(run2 = defaultRunner) {
417
- const remote = tryGit(run2, ["remote", "get-url", "origin"]);
418
- return remote ? parseRepoFromRemote(remote) : void 0;
419
- }
420
- function detectHeadSha(run2 = defaultRunner) {
421
- return tryGit(run2, ["rev-parse", "HEAD"]);
422
- }
423
- function parseRepoFromRemote(remote) {
424
- const match = remote.match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/);
425
- return match ? match[1] : void 0;
426
- }
427
- function tryGit(run2, args) {
428
- try {
429
- return run2(args) || void 0;
430
- } catch {
431
- return void 0;
432
- }
433
- }
434
-
435
1634
  // src/tunnel-provider.ts
436
1635
  import localtunnel from "localtunnel";
437
1636
  async function openTunnel(port) {
@@ -498,6 +1697,16 @@ async function runWait(options, deps = defaultDeps) {
498
1697
 
499
1698
  // src/cli.ts
500
1699
  var cli = cac("datadisco-qa");
1700
+ cli.command("init", "Sign in, connect this repo, and set up preview reporting").option("--yes", "Skip prompts: never wait, never overwrite, take the defaults").option("--repo <owner/name>", "Repository (defaults to the git origin remote)").option("--api-url <url>", "DataDisco API base URL").option(
1701
+ "--api-token <token>",
1702
+ "Workspace API token (or DATADISCO_API_TOKEN, or the saved sign-in)"
1703
+ ).option("--no-browser", "Print URLs instead of opening them in a browser").action((options) => run(() => runInit(options)));
1704
+ cli.command("doctor", "Check the sign-in, repo connection, preview source, workflow, and secret").option("--repo <owner/name>", "Repository (defaults to the git origin remote)").option("--api-url <url>", "DataDisco API base URL").option(
1705
+ "--api-token <token>",
1706
+ "Workspace API token (or DATADISCO_API_TOKEN, or the saved sign-in)"
1707
+ ).action((options) => run(() => runDoctor(options)));
1708
+ cli.command("login", "Sign in to DataDisco from this machine and save the token").option("--api-url <url>", "DataDisco API base URL").option("--no-browser", "Print the verification URL instead of opening it").action((options) => run(() => runLogin(options)));
1709
+ cli.command("logout", "Forget the saved sign-in for the API URL").option("--api-url <url>", "DataDisco API base URL").action((options) => run(() => runLogout(options)));
501
1710
  cli.command("report-preview", "Report a PR's preview-deploy URL so its pending QA run can start").option("--url <url>", "The deployed preview URL for the PR head SHA").option("--repo <owner/name>", "Repository the PR belongs to").option("--pr <number>", "Pull request number").option("--sha <sha>", "Head SHA the preview was built from").option("--api-url <url>", "DataDisco API base URL").option("--api-token <token>", "Workspace API token (or DATADISCO_API_TOKEN)").option(
502
1711
  "--no-wait-for-run",
503
1712
  "Fail immediately if the QA run hasn't been created yet, instead of retrying"
@@ -505,12 +1714,16 @@ cli.command("report-preview", "Report a PR's preview-deploy URL so its pending Q
505
1714
  "--wait-run-timeout <minutes>",
506
1715
  "How long to retry while the QA run hasn't been created yet (default 12)"
507
1716
  ).action((options) => run(() => runReportPreview(options)));
508
- cli.command("wait", "Block until the PR's QA run reaches a verdict").option("--repo <owner/name>", "Repository the PR belongs to").option("--pr <number>", "Pull request number").option("--timeout <minutes>", "Give up after this many minutes (default 30)").option("--api-url <url>", "DataDisco API base URL").option("--api-token <token>", "Workspace API token (or DATADISCO_API_TOKEN)").action((options) => run(() => runWait(options)));
1717
+ cli.command("wait", "Block until the PR's QA run reaches a verdict").option("--repo <owner/name>", "Repository the PR belongs to").option("--pr <number>", "Pull request number").option("--sha <sha>", "Head SHA to look up the run by, if the PR number isn't known").option("--timeout <minutes>", "Give up after this many minutes (default 30)").option("--api-url <url>", "DataDisco API base URL").option("--api-token <token>", "Workspace API token (or DATADISCO_API_TOKEN)").action((options) => run(() => runWait(options)));
509
1718
  cli.command("tunnel", "Expose a local build and start a QA run against it").option("--port <port>", "Local port serving the build").option("--url <url>", "Use an externally managed tunnel URL instead of --port").option("--repo <owner/name>", "Repository (defaults to the git origin remote)").option("--pr <number>", "Pull request number").option("--sha <sha>", "Head SHA (defaults to the local git HEAD)").option("--no-wait", "Queue the run and exit without waiting for a verdict").option("--timeout <minutes>", "Give up waiting after this many minutes").option("--api-url <url>", "DataDisco API base URL").option("--api-token <token>", "Workspace API token (or DATADISCO_API_TOKEN)").action((options) => run(() => runTunnel(options)));
510
1719
  async function run(command) {
511
1720
  try {
512
1721
  process.exitCode = await command();
513
1722
  } catch (error) {
1723
+ if (error instanceof PromptCancelledError) {
1724
+ process.exitCode = 130;
1725
+ return;
1726
+ }
514
1727
  if (error instanceof ConfigError) {
515
1728
  console.error(pc2.red(error.message));
516
1729
  process.exitCode = 1;