@haystackeditor/cli 0.18.0 → 0.20.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.
@@ -2,12 +2,20 @@
2
2
  * Login command - GitHub OAuth device flow
3
3
  */
4
4
  import chalk from 'chalk';
5
+ import * as fs from 'fs/promises';
5
6
  import * as path from 'path';
6
7
  import * as os from 'os';
8
+ import { withSchema } from '../schema.js';
7
9
  import { getActiveLogin, getRepoDefaultAccount, listStoredAccounts, removeAccount, saveAuthenticatedToken, setActiveAccount, } from '../utils/auth.js';
8
10
  import { parseRemoteUrl } from '../utils/git.js';
9
11
  const GITHUB_CLIENT_ID = 'Ov23liW3JE38D3gZWa85'; // Haystack GitHub OAuth App
10
12
  const CONFIG_DIR = path.join(os.homedir(), '.haystack');
13
+ // A started sign-in the next `haystack login` finishes. The device code in it is
14
+ // a credential until GitHub expires it, so the file is 0600 like credentials.json.
15
+ const PENDING_LOGIN_FILE = path.join(CONFIG_DIR, 'login-pending.json');
16
+ // A pending sign-in this close to expiring is replaced rather than shown to a
17
+ // person; one that is only being finished is used until it expires.
18
+ const SHOWN_LOGIN_MARGIN_MS = 60_000;
11
19
  /**
12
20
  * Start device flow and get user code
13
21
  */
@@ -28,25 +36,79 @@ async function startDeviceFlow() {
28
36
  }
29
37
  return response.json();
30
38
  }
39
+ async function readPendingLogin(marginMs) {
40
+ let raw;
41
+ try {
42
+ raw = await fs.readFile(PENDING_LOGIN_FILE, 'utf8');
43
+ }
44
+ catch (error) {
45
+ if (error.code === 'ENOENT')
46
+ return null;
47
+ throw error;
48
+ }
49
+ const pending = JSON.parse(raw);
50
+ if (typeof pending.deviceCode !== 'string' || typeof pending.userCode !== 'string' || typeof pending.verificationUri !== 'string'
51
+ || typeof pending.interval !== 'number' || typeof pending.expiresAt !== 'number') {
52
+ throw new Error(`${PENDING_LOGIN_FILE} is not a sign-in this CLI started; delete it and run \`haystack login\` again.`);
53
+ }
54
+ return pending.expiresAt - Date.now() > marginMs ? pending : null;
55
+ }
56
+ async function savePendingLogin(flow) {
57
+ const pending = {
58
+ deviceCode: flow.device_code, userCode: flow.user_code, verificationUri: flow.verification_uri,
59
+ interval: flow.interval, expiresAt: Date.now() + flow.expires_in * 1000,
60
+ };
61
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
62
+ await fs.writeFile(PENDING_LOGIN_FILE, `${JSON.stringify(pending, null, 2)}\n`, { mode: 0o600 });
63
+ await fs.chmod(PENDING_LOGIN_FILE, 0o600);
64
+ return pending;
65
+ }
66
+ async function clearPendingLogin() {
67
+ await fs.rm(PENDING_LOGIN_FILE, { force: true });
68
+ }
69
+ /** GitHub ended the device flow (expired, denied, or another OAuth error): its code is spent. */
70
+ class DeviceFlowEnded extends Error {
71
+ constructor(message) {
72
+ super(message);
73
+ this.name = 'DeviceFlowEnded';
74
+ }
75
+ }
31
76
  /**
32
- * Poll for access token
77
+ * Poll for access token. A request that does not reach GitHub, or an answer
78
+ * that is not GitHub's OAuth JSON, leaves the code usable: the next
79
+ * `haystack login` finishes with it.
33
80
  */
34
81
  async function pollForToken(deviceCode, interval) {
82
+ const unreachable = (detail) => new Error(`Could not get GitHub's answer (${detail}). Run \`haystack login\` again; it continues with the same code.`);
35
83
  for (;;) {
36
84
  await new Promise(resolve => setTimeout(resolve, interval * 1000));
37
- const response = await fetch('https://github.com/login/oauth/access_token', {
38
- method: 'POST',
39
- headers: {
40
- 'Accept': 'application/json',
41
- 'Content-Type': 'application/json',
42
- },
43
- body: JSON.stringify({
44
- client_id: GITHUB_CLIENT_ID,
45
- device_code: deviceCode,
46
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
47
- }),
48
- });
49
- const data = await response.json();
85
+ let response;
86
+ try {
87
+ response = await fetch('https://github.com/login/oauth/access_token', {
88
+ method: 'POST',
89
+ headers: {
90
+ 'Accept': 'application/json',
91
+ 'Content-Type': 'application/json',
92
+ },
93
+ body: JSON.stringify({
94
+ client_id: GITHUB_CLIENT_ID,
95
+ device_code: deviceCode,
96
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
97
+ }),
98
+ });
99
+ }
100
+ catch (error) {
101
+ throw unreachable(error instanceof Error ? error.message : String(error));
102
+ }
103
+ if (!response.ok)
104
+ throw unreachable(`HTTP ${response.status}`);
105
+ let data;
106
+ try {
107
+ data = await response.json();
108
+ }
109
+ catch (error) {
110
+ throw unreachable(`an answer that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
111
+ }
50
112
  if (data.access_token) {
51
113
  return data.access_token;
52
114
  }
@@ -60,38 +122,75 @@ async function pollForToken(deviceCode, interval) {
60
122
  continue;
61
123
  }
62
124
  if (data.error === 'expired_token') {
63
- throw new Error('Authorization timed out. Please try again.');
125
+ throw new DeviceFlowEnded('Authorization timed out. Please try again.');
64
126
  }
65
127
  if (data.error === 'access_denied') {
66
- throw new Error('Authorization denied by user.');
128
+ throw new DeviceFlowEnded('Authorization denied by user.');
67
129
  }
68
- throw new Error(`OAuth error: ${data.error}`);
130
+ throw new DeviceFlowEnded(`OAuth error: ${data.error}`);
69
131
  }
70
132
  }
71
- export async function loginCommand() {
133
+ /**
134
+ * `haystack login`: GitHub's device flow. It prints a URL and a code for the
135
+ * person to enter, then waits until they have. `--no-wait` prints them and
136
+ * returns at once, so a coding agent can show them before it runs
137
+ * `haystack login` again to finish; a run that is stopped while waiting is
138
+ * finished the same way, with the same code, until GitHub expires it.
139
+ */
140
+ export async function loginCommand(options = {}) {
141
+ // In --json mode stdout carries the one document; every other line goes to stderr.
142
+ const say = (line) => { if (options.json)
143
+ console.error(line);
144
+ else
145
+ console.log(line); };
72
146
  try {
73
- console.log(chalk.bold('\nHaystack Login\n'));
147
+ say(chalk.bold('\nHaystack Login\n'));
74
148
  const activeLogin = await getActiveLogin();
75
149
  if (activeLogin) {
76
- console.log(chalk.dim(`Current active account: ${chalk.bold(activeLogin)}`));
77
- console.log(chalk.dim('This login will add or refresh a saved account and make it active.\n'));
78
- }
79
- console.log('Authenticating with GitHub...\n');
80
- // Start device flow
81
- const deviceFlow = await startDeviceFlow();
82
- console.log(chalk.yellow('Open this URL in your browser:\n'));
83
- console.log(` ${chalk.bold(deviceFlow.verification_uri)}\n`);
84
- console.log(chalk.yellow('And enter this code:\n'));
85
- console.log(` ${chalk.bold.cyan(deviceFlow.user_code)}\n`);
86
- console.log(chalk.dim('Waiting for authorization...'));
87
- // Poll for token
88
- const token = await pollForToken(deviceFlow.device_code, deviceFlow.interval);
150
+ say(chalk.dim(`Current active account: ${chalk.bold(activeLogin)}`));
151
+ say(chalk.dim('This login will add or refresh a saved account and make it active.\n'));
152
+ }
153
+ const pending = await readPendingLogin(options.wait === false ? SHOWN_LOGIN_MARGIN_MS : 0)
154
+ ?? await savePendingLogin(await startDeviceFlow());
155
+ say(chalk.yellow('Open this URL in your browser:\n'));
156
+ say(` ${chalk.bold(pending.verificationUri)}\n`);
157
+ say(chalk.yellow('And enter this code:\n'));
158
+ say(` ${chalk.bold.cyan(pending.userCode)}\n`);
159
+ if (options.wait === false) {
160
+ say('Run `haystack login` once the code is entered; it finishes signing in.');
161
+ if (options.json) {
162
+ process.stdout.write(`${JSON.stringify(withSchema('login', {
163
+ status: 'pending', verificationUri: pending.verificationUri, userCode: pending.userCode,
164
+ expiresAt: new Date(pending.expiresAt).toISOString(), login: null,
165
+ }), null, 2)}\n`);
166
+ }
167
+ return;
168
+ }
169
+ say(chalk.dim('Waiting for authorization...'));
170
+ let token;
171
+ try {
172
+ token = await pollForToken(pending.deviceCode, pending.interval);
173
+ }
174
+ catch (error) {
175
+ // Expired or denied: the code is spent, so the next login starts afresh.
176
+ if (error instanceof DeviceFlowEnded)
177
+ await clearPendingLogin();
178
+ throw error;
179
+ }
180
+ await clearPendingLogin();
89
181
  // Verify and save
90
182
  const user = await saveAuthenticatedToken(token);
91
- console.log(chalk.green(`\nLogged in as ${chalk.bold(user.login)}`));
92
- console.log(chalk.dim(`Credentials saved to ${path.join(CONFIG_DIR, 'credentials.json')}\n`));
183
+ say(chalk.green(`\nLogged in as ${chalk.bold(user.login)}`));
184
+ say(chalk.dim(`Credentials saved to ${path.join(CONFIG_DIR, 'credentials.json')}\n`));
185
+ if (options.json) {
186
+ process.stdout.write(`${JSON.stringify(withSchema('login', {
187
+ status: 'logged-in', verificationUri: null, userCode: null, expiresAt: null, login: user.login,
188
+ }), null, 2)}\n`);
189
+ }
93
190
  }
94
191
  catch (error) {
192
+ if (options.json)
193
+ process.stdout.write(`${JSON.stringify(withSchema('error', { status: 'error', error: `Login failed: ${error.message}` }), null, 2)}\n`);
95
194
  console.error(chalk.red(`\nLogin failed: ${error.message}\n`));
96
195
  process.exit(1);
97
196
  }
@@ -0,0 +1,6 @@
1
+ /** FIXED CONTRACT — mirror of APP-ONBOARDING-V1
2
+ * (infra/lambda/haystack-design-verifier-shared/onboarding_contracts.ts) for the
3
+ * published CLI, whose build cannot import repository files outside src. Only
4
+ * the shapes the CLI reads or sends are mirrored; the source file wins. */
5
+ export const ONBOARDING_VERSION = 'app-onboarding-v1';
6
+ export const ONBOARDING_STAGES = ['inventory', 'plan', 'runtime', 'boot', 'data', 'accounts', 'workflow', 'publish', 'cleanup'];
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
6
+ import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
7
7
  const requestFile = 'request.json';
8
8
  const receiptFile = 'receipt.json';
9
9
  const deliverySchema = 'precompute-delivery-v1';
@@ -150,10 +150,13 @@ function validateRequest(value) {
150
150
  throw new Error('Invalid captured crawl submission.');
151
151
  return;
152
152
  }
153
- if (!object(crawl) || !onlyKeys(crawl, ['treeSha', 'changeTitle'])
153
+ // Amendment 8: budgetMs only when the capture asked for a time, whole seconds from 1 to 30 minutes.
154
+ if (!object(crawl) || !onlyKeys(crawl, 'budgetMs' in crawl ? ['treeSha', 'changeTitle', 'budgetMs'] : ['treeSha', 'changeTitle'])
154
155
  || !characters(crawl.treeSha, '0123456789abcdef', 40)
155
156
  || request.cacheKey.split('-')[2] !== crawl.treeSha.slice(0, 16)
156
- || typeof crawl.changeTitle !== 'string' || crawl.changeTitle.length > CRAWL_TITLE_MAX_CHARS) {
157
+ || typeof crawl.changeTitle !== 'string' || crawl.changeTitle.length > CRAWL_TITLE_MAX_CHARS
158
+ || ('budgetMs' in crawl && (typeof crawl.budgetMs !== 'number' || !Number.isInteger(crawl.budgetMs) || crawl.budgetMs % 1000 !== 0
159
+ || crawl.budgetMs < CRAWL_BUDGET_MIN_MS || crawl.budgetMs > CRAWL_BUDGET_MAX_MS))) {
157
160
  throw new Error('Invalid captured crawl submission.');
158
161
  }
159
162
  }
@@ -16,6 +16,7 @@ import { execFileSync, execSync } from 'child_process';
16
16
  import { basename } from 'path';
17
17
  import { resolveAuthContext } from '../utils/auth.js';
18
18
  import { githubRestBaseFor } from '../utils/github-api.js';
19
+ import { HAYSTACK_APP_INSTALL_URL } from '../utils/haystack-api.js';
19
20
  import { describeHookOverwrites, hooksInstall } from './hooks.js';
20
21
  import { installSessionHooks } from './install-session-hooks.js';
21
22
  import { findGitRoot } from '../utils/hooks.js';
@@ -500,8 +501,6 @@ function printInstruction(instruction, index) {
500
501
  // =============================================================================
501
502
  // Step 0.5: Ensure the Haystack GitHub App is installed
502
503
  // =============================================================================
503
- const HAYSTACK_APP_SLUG = 'haystack-code-reviewer-pr-hook';
504
- const HAYSTACK_APP_INSTALL_URL = `https://github.com/apps/${HAYSTACK_APP_SLUG}/installations/new`;
505
504
  const APP_INSTALL_POLL_INTERVAL_MS = 3000;
506
505
  const APP_INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
507
506
  class InstallationsAuthError extends Error {
@@ -0,0 +1,365 @@
1
+ /**
2
+ * App onboarding as `haystack verify` shows it (APP-ONBOARDING-V1 rule 12,
3
+ * crawl amendment 8). A repository base without a published onboarding record
4
+ * answers the crawl submission `onboarding` (the service prepares the app and
5
+ * runs the crawl when it is ready) or `onboarding-blocked` (with what would
6
+ * unblock it). This module reads and prints the onboarding status, the stage
7
+ * list, the block and its ask, and files an answer to a question
8
+ * (`haystack verify answer <id> <choice>`).
9
+ */
10
+ import chalk from 'chalk';
11
+ import { withSchema } from '../schema.js';
12
+ import { resolveAuthContext } from '../utils/auth.js';
13
+ import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
14
+ import { trackOnboardingEvent } from '../utils/telemetry.js';
15
+ import { gatewayFetch } from './case-batch.js';
16
+ import { ONBOARDING_STAGES, ONBOARDING_VERSION, } from './onboarding-contract.js';
17
+ import { deriveBaseSha, EXPLICIT_WALL_MS, resolveOriginRepository } from './verify-precompute.js';
18
+ import { findGitRoot } from '../utils/hooks.js';
19
+ const ONBOARDING_PATH = '/api/agent/cloud-verifier/onboarding';
20
+ const FACTS_PATH = `${ONBOARDING_PATH}/facts`;
21
+ const ANSWERS_PATH = `${ONBOARDING_PATH}/answers`;
22
+ const ONBOARD_RUN_ID = /^ob_[0-9a-f]{48}$/;
23
+ const SHA256 = /^[0-9a-f]{64}$/;
24
+ const COMMIT = /^[0-9a-f]{40}$/;
25
+ const STATES = ['ready', 'onboarding', 'blocked', 'incomplete', 'cancelled', 'not-started'];
26
+ const BLOCK_KINDS = ['not-enabled', 'no-web-app', 'unsupported', 'question', 'outside-credential',
27
+ 'outside-dependency', 'evidence-too-large', 'budget', 'proof-failed'];
28
+ const STAGE_STATUSES = ['pending', 'running', 'ok', 'reused', 'failed', 'blocked'];
29
+ const STAGE_WORDS = {
30
+ inventory: 'reading the repository', plan: 'planning how to run the app', runtime: 'building its runtime',
31
+ boot: 'starting the app', data: 'preparing its data', accounts: 'creating test accounts', workflow: 'proving a workflow',
32
+ publish: 'publishing', cleanup: 'cleaning up',
33
+ };
34
+ const BLOCK_WORDS = {
35
+ 'not-enabled': 'onboarding is not enabled for this team yet',
36
+ 'no-web-app': 'no web app was found in the repository',
37
+ unsupported: 'the app uses something onboarding cannot run yet',
38
+ question: 'onboarding needs an answer',
39
+ 'outside-credential': 'the app needs an outside provider\'s credential',
40
+ 'outside-dependency': 'the app needs an outside service',
41
+ 'evidence-too-large': 'the repository evidence is too large for one planning request',
42
+ budget: 'onboarding used its model or repair budget',
43
+ 'proof-failed': 'the app could not be proven to work',
44
+ };
45
+ /** A response the CLI will not print: malformed or another repository's. */
46
+ export class OnboardingResponseError extends Error {
47
+ constructor(message) {
48
+ super(message);
49
+ this.name = 'OnboardingResponseError';
50
+ }
51
+ }
52
+ function invalid(what) {
53
+ throw new OnboardingResponseError(`The onboarding service returned an invalid answer (${what}); nothing is shown.`);
54
+ }
55
+ function isRecord(value) {
56
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
57
+ }
58
+ function isStrings(value) {
59
+ return Array.isArray(value) && value.every(item => typeof item === 'string');
60
+ }
61
+ function safe(value) {
62
+ // Server text never moves the cursor or rewrites the terminal.
63
+ // eslint-disable-next-line no-control-regex -- replacing control characters is the point
64
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
65
+ }
66
+ export function parseOnboardingBlock(value) {
67
+ if (!isRecord(value) || !BLOCK_KINDS.includes(value.kind)
68
+ || !(value.stage === 'admission' || ONBOARDING_STAGES.includes(value.stage))
69
+ || typeof value.reason !== 'string' || !isStrings(value.evidence)
70
+ || (value.names !== undefined && !isStrings(value.names)))
71
+ invalid('a block');
72
+ const ask = value.ask;
73
+ if (ask !== null) {
74
+ if (!isRecord(ask) || typeof ask.message !== 'string' || (ask.command !== undefined && typeof ask.command !== 'string'))
75
+ invalid('a block\'s ask');
76
+ const question = ask.question;
77
+ if (question !== undefined && (!isRecord(question) || typeof question.id !== 'string' || !isStrings(question.choices))) {
78
+ invalid('a block\'s question');
79
+ }
80
+ }
81
+ return value;
82
+ }
83
+ /** Accepts only a well-formed status of exactly this repository and base. */
84
+ export function parseOnboardingStatus(value, expected) {
85
+ if (!isRecord(value) || value.version !== ONBOARDING_VERSION || value.repository !== expected.repository
86
+ || value.baseCommit !== expected.baseCommit || !STATES.includes(value.state))
87
+ invalid('its identity');
88
+ if (value.onboardRunId !== null && !(typeof value.onboardRunId === 'string' && ONBOARD_RUN_ID.test(value.onboardRunId)))
89
+ invalid('its run id');
90
+ if (value.recordDigest !== null && !(typeof value.recordDigest === 'string' && SHA256.test(value.recordDigest)))
91
+ invalid('its record');
92
+ if (!isStrings(value.dataAbsences))
93
+ invalid('its data absences');
94
+ if (!isStrings(value.versionChoices))
95
+ invalid('its version choices');
96
+ if (value.block !== null)
97
+ parseOnboardingBlock(value.block);
98
+ const checkpoint = value.checkpoint;
99
+ if (checkpoint !== null) {
100
+ if (!isRecord(checkpoint) || !ONBOARDING_STAGES.includes(checkpoint.stage) || !isRecord(checkpoint.execution)
101
+ || !Array.isArray(checkpoint.execution.stages))
102
+ invalid('its checkpoint');
103
+ for (const stage of checkpoint.execution.stages) {
104
+ if (!isRecord(stage) || !ONBOARDING_STAGES.includes(stage.stage)
105
+ || !STAGE_STATUSES.includes(stage.status))
106
+ invalid('its stage list');
107
+ }
108
+ }
109
+ if ((value.state === 'blocked') !== (value.block !== null))
110
+ invalid('its block');
111
+ if ((value.state === 'ready') !== (value.recordDigest !== null))
112
+ invalid('its record');
113
+ return value;
114
+ }
115
+ function parseFactsView(value, repository) {
116
+ if (!isRecord(value) || value.repository !== repository || typeof value.factsDigest !== 'string' || !SHA256.test(value.factsDigest)
117
+ || !isRecord(value.facts) || !Array.isArray(value.openQuestions))
118
+ invalid('the facts');
119
+ for (const question of value.openQuestions) {
120
+ if (!isRecord(question) || typeof question.id !== 'string' || typeof question.message !== 'string' || !isStrings(question.choices)) {
121
+ invalid('an open question');
122
+ }
123
+ }
124
+ return value;
125
+ }
126
+ async function body(response, what) {
127
+ try {
128
+ return await response.json();
129
+ }
130
+ catch (error) {
131
+ throw new OnboardingResponseError(`The onboarding service returned ${what} that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
132
+ }
133
+ }
134
+ /** The onboarding of `repository` at `baseCommit`, now. */
135
+ export async function readOnboardingStatus(repository, baseCommit, token) {
136
+ if (!COMMIT.test(baseCommit))
137
+ throw new Error(`Invalid base commit ${baseCommit}.`);
138
+ const path = `${ONBOARDING_PATH}?repository=${encodeURIComponent(repository)}&baseCommit=${baseCommit}`;
139
+ const response = await gatewayFetch(path, token);
140
+ if (!response.ok)
141
+ throw await classifyHttpError(response, `Haystack API ${ONBOARDING_PATH}`);
142
+ return parseOnboardingStatus(await body(response, 'a status'), { repository, baseCommit });
143
+ }
144
+ /** Rule 12's start POST: onboard this base now, before any change exists. Idempotent: a
145
+ * ready, blocked or running onboarding is answered as it is. */
146
+ export async function startOnboarding(repository, baseCommit, token) {
147
+ if (!COMMIT.test(baseCommit))
148
+ throw new Error(`Invalid base commit ${baseCommit}.`);
149
+ const response = await gatewayFetch(`${ONBOARDING_PATH}?repository=${encodeURIComponent(repository)}`, token, {
150
+ method: 'POST', body: JSON.stringify({ repository, baseCommit }),
151
+ });
152
+ if (!response.ok)
153
+ throw await classifyHttpError(response, `Haystack API ${ONBOARDING_PATH}`);
154
+ return parseOnboardingStatus(await body(response, 'a status'), { repository, baseCommit });
155
+ }
156
+ /** How often a command reads onboarding status while it runs. The status route holds no read (a crawl's reads wait for a
157
+ * change, amendment 9); an onboarding takes tens of minutes, so a read every 15 s shows each stage promptly. */
158
+ const ONBOARDING_READ_EVERY_MS = 15_000;
159
+ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
160
+ /** Polls the base's onboarding until it is no longer running. No wall-time cap:
161
+ * an interrupt stops waiting and leaves the onboarding running (`resume` is the
162
+ * command that follows it again). A momentary 429/5xx (a server or runner
163
+ * restarting) is read again at the next poll. */
164
+ export async function waitForOnboarding(first, token, progress, resume) {
165
+ const interrupted = () => {
166
+ console.error(`\nStopped waiting. The onboarding keeps running; run \`${resume}\` again to follow it.`);
167
+ process.exit(130);
168
+ };
169
+ process.once('SIGINT', interrupted);
170
+ try {
171
+ let status = first;
172
+ progress(`Waiting for onboarding ${status.onboardRunId ?? ''} (Ctrl-C stops waiting; the onboarding keeps running).`);
173
+ progress(onboardingStep(status));
174
+ while (status.state === 'onboarding') {
175
+ await delay(ONBOARDING_READ_EVERY_MS);
176
+ let next;
177
+ try {
178
+ next = await readOnboardingStatus(status.repository, status.baseCommit, token);
179
+ }
180
+ catch (error) {
181
+ if (!(error instanceof HaystackApiError && [429, 502, 503, 504].includes(error.status)))
182
+ throw error;
183
+ progress(`The onboarding status is unavailable for now (${error.status}); reading it again.`);
184
+ continue;
185
+ }
186
+ if (onboardingStep(next) !== onboardingStep(status))
187
+ progress(onboardingStep(next));
188
+ status = next;
189
+ }
190
+ return status;
191
+ }
192
+ finally {
193
+ process.removeListener('SIGINT', interrupted);
194
+ }
195
+ }
196
+ /** A command that answers with an onboarding exits 3 when it is blocked (its ask says what to do),
197
+ * 2 when it is neither running nor ready, and 0 when it is ready or running. */
198
+ export function onboardingExitCode(status) {
199
+ if (status.state === 'blocked')
200
+ return 3;
201
+ if (status.state === 'incomplete' || status.state === 'cancelled' || status.state === 'not-started')
202
+ return 2;
203
+ return 0;
204
+ }
205
+ /* ----------------------------------------------------------------- output */
206
+ /** What onboarding is doing now, in one line: the stage in progress and the stages done. */
207
+ export function onboardingStep(status) {
208
+ const stages = status.checkpoint?.execution.stages ?? [];
209
+ const done = stages.filter(stage => stage.status === 'ok' || stage.status === 'reused').map(stage => stage.stage);
210
+ const current = status.checkpoint?.stage;
211
+ const doing = status.state !== 'onboarding' ? status.state
212
+ : current === undefined ? 'queued' : STAGE_WORDS[current];
213
+ return `Onboarding: ${doing}${done.length ? ` (done: ${done.join(', ')})` : ''}.`;
214
+ }
215
+ /** The stage list, one line per stage the run has reached. */
216
+ export function onboardingStageLines(status) {
217
+ const stages = status.checkpoint?.execution.stages ?? [];
218
+ const byStage = new Map(stages.map(stage => [stage.stage, stage]));
219
+ return ONBOARDING_STAGES.filter(stage => byStage.has(stage)).map(stage => {
220
+ const entry = byStage.get(stage);
221
+ const repairs = entry.attempts.length ? `, ${entry.attempts.length} repair${entry.attempts.length === 1 ? '' : 's'}` : '';
222
+ return ` ${stage.padEnd(9)} ${entry.status}${repairs}`;
223
+ });
224
+ }
225
+ /** A block: why onboarding stopped and exactly what would unblock it. */
226
+ export function blockLines(block) {
227
+ const lines = [chalk.bold(`Onboarding is blocked: ${BLOCK_WORDS[block.kind]}.`), ` ${safe(block.reason)}`];
228
+ if (block.names?.length)
229
+ lines.push(` Names: ${block.names.map(safe).join(', ')}`);
230
+ if (block.ask === null) {
231
+ lines.push(' Nothing you can run changes this yet.');
232
+ return lines;
233
+ }
234
+ lines.push('', safe(block.ask.message));
235
+ if (block.ask.command)
236
+ lines.push(` Run: ${safe(block.ask.command)}`);
237
+ const question = block.ask.question;
238
+ if (question) {
239
+ lines.push(' Answer with one of:');
240
+ for (const choice of question.choices)
241
+ lines.push(` haystack verify answer ${question.id} ${JSON.stringify(choice)}`);
242
+ lines.push(' then run `haystack init` to continue onboarding with it.');
243
+ }
244
+ return lines;
245
+ }
246
+ export function formatOnboarding(status) {
247
+ const lines = [chalk.dim(` Onboarding ${status.onboardRunId ?? '(none)'} of ${safe(status.repository)} at base ${status.baseCommit.slice(0, 12)}`)];
248
+ switch (status.state) {
249
+ case 'ready':
250
+ lines.push('The app is onboarded for this base.');
251
+ break;
252
+ case 'onboarding':
253
+ lines.push(onboardingStep(status));
254
+ break;
255
+ case 'not-started':
256
+ lines.push('Onboarding has not started for this base. `haystack init` starts it.');
257
+ break;
258
+ case 'incomplete':
259
+ lines.push(`Onboarding stopped before finishing${status.checkpoint?.error ? `: ${safe(status.checkpoint.error.message)}` : ''}.`
260
+ + ' Run `haystack init` again to continue it.');
261
+ break;
262
+ case 'cancelled':
263
+ lines.push('Onboarding was cancelled. Run `haystack init` to start it again.');
264
+ break;
265
+ case 'blocked': break;
266
+ }
267
+ const stages = onboardingStageLines(status);
268
+ if (stages.length)
269
+ lines.push('', ...stages);
270
+ if (status.block)
271
+ lines.push('', ...blockLines(status.block));
272
+ if (status.dataAbsences.length)
273
+ lines.push('', ...status.dataAbsences.map(absence => chalk.yellow(`Note: ${safe(absence)}`)));
274
+ if (status.versionChoices.length)
275
+ lines.push('', ...status.versionChoices.map(choice => chalk.yellow(`Note: ${safe(choice)}`)));
276
+ return lines.join('\n');
277
+ }
278
+ /** Telemetry: onboarding started, blocked or ready, once per state a command sees. */
279
+ export function reportOnboardingState(status) {
280
+ if (status.state === 'onboarding')
281
+ trackOnboardingEvent('started');
282
+ else if (status.state === 'blocked')
283
+ trackOnboardingEvent('blocked', status.block?.kind);
284
+ else if (status.state === 'ready')
285
+ trackOnboardingEvent('ready');
286
+ }
287
+ function repositoryOf(value) {
288
+ if (value !== undefined) {
289
+ const parts = value.split('/');
290
+ if (parts.length !== 2 || !parts.every(part => /^[A-Za-z0-9_.-]{1,100}$/.test(part) && part !== '.' && part !== '..')) {
291
+ throw new Error(`Invalid repository "${value}". Use an exact GitHub owner/repository name.`);
292
+ }
293
+ return value;
294
+ }
295
+ const gitRoot = findGitRoot();
296
+ if (!gitRoot)
297
+ throw new Error('Run this command from a git repository, or pass --repo owner/repo.');
298
+ const origin = resolveOriginRepository(gitRoot);
299
+ return `${origin.owner}/${origin.repository}`;
300
+ }
301
+ async function readFacts(repository, token) {
302
+ const response = await gatewayFetch(`${FACTS_PATH}?repository=${encodeURIComponent(repository)}`, token);
303
+ if (!response.ok)
304
+ throw await classifyHttpError(response, `Haystack API ${FACTS_PATH}`);
305
+ return parseFactsView(await body(response, 'facts'), repository);
306
+ }
307
+ /** `haystack verify answer <question-id> <choice>`: files the answer to an open
308
+ * onboarding question with compare-and-swap on the facts it read; a concurrent
309
+ * change of the facts is read again (the question must still be open). */
310
+ export async function verifyAnswerCommand(questionId, choice, options) {
311
+ const repository = repositoryOf(options.repo);
312
+ const [owner, repo] = repository.split('/');
313
+ const auth = await resolveAuthContext({ preferredLogin: options.account, owner, repo });
314
+ for (let attempt = 1;; attempt += 1) {
315
+ const facts = await readFacts(repository, auth.token);
316
+ const question = facts.openQuestions.find(open => open.id === questionId);
317
+ if (!question) {
318
+ throw new Error(`No open onboarding question of ${repository} has the id ${questionId}.`
319
+ + (facts.openQuestions.length ? ` Open: ${facts.openQuestions.map(open => open.id).join(', ')}.` : ' None is open.'));
320
+ }
321
+ if (!question.choices.includes(choice)) {
322
+ throw new Error(`"${choice}" is not one of the question's choices: ${question.choices.map(item => JSON.stringify(item)).join(', ')}.`);
323
+ }
324
+ const response = await gatewayFetch(`${ANSWERS_PATH}?repository=${encodeURIComponent(repository)}`, auth.token, {
325
+ method: 'POST', body: JSON.stringify({ repository, expectedFactsDigest: facts.factsDigest, questionId, answer: choice }),
326
+ });
327
+ if (response.status === 409 && attempt < 3) {
328
+ await response.body?.cancel();
329
+ continue;
330
+ }
331
+ if (!response.ok)
332
+ throw await classifyHttpError(response, `Haystack API ${ANSWERS_PATH}`);
333
+ const view = parseFactsView(await body(response, 'facts'), repository);
334
+ if (options.json) {
335
+ process.stdout.write(`${JSON.stringify(withSchema('verify-answer', { facts: view }), null, 2)}\n`);
336
+ }
337
+ else {
338
+ console.log(chalk.green(`Answered ${questionId}: ${safe(choice)}.`));
339
+ console.log('Run `haystack init` (or `haystack verify` after a change) to continue onboarding with it.');
340
+ }
341
+ return;
342
+ }
343
+ }
344
+ const clock = () => new Date().toTimeString().slice(0, 8);
345
+ /** `haystack verify onboarding`: the onboarding of the base `haystack verify` would use for this
346
+ * checkout, read without starting anything; --wait follows it while it runs. */
347
+ export async function verifyOnboardingCommand(options) {
348
+ const gitRoot = findGitRoot();
349
+ if (!gitRoot)
350
+ throw new Error('Run this command inside a checkout of the app.');
351
+ const origin = resolveOriginRepository(gitRoot);
352
+ const repository = `${origin.owner}/${origin.repository}`;
353
+ const auth = await resolveAuthContext({ preferredLogin: options.account, owner: origin.owner, repo: origin.repository });
354
+ let status = await readOnboardingStatus(repository, deriveBaseSha(gitRoot, Date.now() + EXPLICIT_WALL_MS), auth.token);
355
+ reportOnboardingState(status);
356
+ if (options.wait && status.state === 'onboarding') {
357
+ status = await waitForOnboarding(status, auth.token, line => console.error(chalk.dim(`${clock()} ${line}`)), 'haystack verify onboarding --wait');
358
+ reportOnboardingState(status);
359
+ }
360
+ if (options.json)
361
+ process.stdout.write(`${JSON.stringify(withSchema('verify-onboarding', { onboarding: status }), null, 2)}\n`);
362
+ else
363
+ console.log(formatOnboarding(status));
364
+ process.exitCode = onboardingExitCode(status);
365
+ }