@haystackeditor/cli 0.19.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.
@@ -13,6 +13,7 @@ import { trackPrecomputeEvent } from '../utils/telemetry.js';
13
13
  import { findSecretsInPatch, isSecretBearingPath } from '../utils/secret-paths.js';
14
14
  import { startPrecomputeDelivery } from './precompute-delivery.js';
15
15
  import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
16
+ import { blockLines, parseOnboardingBlock } from './verify-onboarding.js';
16
17
  // --hook has five seconds to capture the exact source and publish local custody.
17
18
  // Its detached sender owns the bounded network delivery and acknowledgement.
18
19
  // An explicit invocation still waits synchronously for the server response.
@@ -66,7 +67,7 @@ function gitOutput(args, gitRoot, deadline, options = {}) {
66
67
  throw new PrecomputeFailure('Could not derive the repository snapshot.');
67
68
  }
68
69
  }
69
- function resolveOriginRepository(gitRoot) {
70
+ export function resolveOriginRepository(gitRoot) {
70
71
  try {
71
72
  const remote = parseRemoteUrl('origin');
72
73
  if (!isRepositoryComponent(remote.owner)
@@ -80,8 +81,8 @@ function resolveOriginRepository(gitRoot) {
80
81
  throw new PrecomputeFailure('No supported origin repository was found.');
81
82
  }
82
83
  }
83
- /** The shared base rule (utils/verify-base.ts), bounded by the hook's deadline. */
84
- function deriveBaseSha(gitRoot, deadline) {
84
+ /** The shared base rule (utils/verify-base.ts), bounded by the caller's deadline. */
85
+ export function deriveBaseSha(gitRoot, deadline) {
85
86
  try {
86
87
  return resolveVerifyBaseSha('HEAD', {
87
88
  run: (args, options) => gitOutput(args, gitRoot, deadline, options?.remote
@@ -332,7 +333,8 @@ function readWalkerStatus(value) {
332
333
  }
333
334
  throw new PrecomputeFailure('The precompute service returned an invalid walker status.');
334
335
  }
335
- const CRAWL_UNAVAILABLE_REASONS = ['not-configured', 'runner-unavailable', 'profile-ambiguous', 'superseded', 'enqueue-failed'];
336
+ const CRAWL_UNAVAILABLE_REASONS = ['runner-unavailable', 'superseded', 'enqueue-failed'];
337
+ const isOnboardRunId = (value) => typeof value === 'string' && /^ob_[0-9a-f]{48}$/.test(value);
336
338
  function readCrawlHookStatus(value) {
337
339
  if (value && typeof value === 'object' && !Array.isArray(value)) {
338
340
  const record = value;
@@ -343,6 +345,18 @@ function readCrawlHookStatus(value) {
343
345
  const reason = CRAWL_UNAVAILABLE_REASONS.find(known => known === record.reason);
344
346
  if (record.status === 'unavailable' && reason !== undefined)
345
347
  return { status: record.status, reason };
348
+ // Crawl amendment 8: the base has no published onboarding record yet.
349
+ if (record.status === 'onboarding' && isOnboardRunId(record.onboardRunId))
350
+ return { status: 'onboarding', onboardRunId: record.onboardRunId };
351
+ }
352
+ if (Object.keys(record).length === 3 && record.status === 'onboarding-blocked'
353
+ && (record.onboardRunId === null || isOnboardRunId(record.onboardRunId))) {
354
+ try {
355
+ return { status: 'onboarding-blocked', onboardRunId: record.onboardRunId, block: parseOnboardingBlock(record.block) };
356
+ }
357
+ catch (error) {
358
+ throw new PrecomputeFailure(`The precompute service returned an invalid onboarding block (${error instanceof Error ? error.message : String(error)}).`);
359
+ }
346
360
  }
347
361
  }
348
362
  throw new PrecomputeFailure('The precompute service returned an invalid crawl status.');
@@ -350,9 +364,7 @@ function readCrawlHookStatus(value) {
350
364
  /** Why the service could not start a crawl, in plain words. */
351
365
  export function crawlUnavailableText(reason) {
352
366
  switch (reason) {
353
- case 'not-configured': return 'this repository is not set up for crawling yet';
354
367
  case 'runner-unavailable': return 'no machine that can crawl this repository is available right now';
355
- case 'profile-ambiguous': return 'the machines that crawl this repository disagree about how to run it';
356
368
  case 'superseded': return 'a newer stop in this repository came first';
357
369
  case 'enqueue-failed': return 'the service could not queue it';
358
370
  }
@@ -449,6 +461,11 @@ function printResult(result) {
449
461
  console.log(`A matching crawl already exists (${crawl.runId}).`);
450
462
  else if (crawl?.status === 'unavailable')
451
463
  console.log(`Crawl unavailable: ${crawlUnavailableText(crawl.reason)}.`);
464
+ else if (crawl?.status === 'onboarding') {
465
+ console.log(`The app is being onboarded for this base (${crawl.onboardRunId}); the crawl runs when it is ready. Run \`haystack verify\` to follow it.`);
466
+ }
467
+ else if (crawl?.status === 'onboarding-blocked')
468
+ console.log(blockLines(crawl.block).join('\n'));
452
469
  else
453
470
  console.log('Crawl status was not reported by the service.');
454
471
  }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `haystack verify` — the crawl of the current change (CRAWL-V1, amendment 1).
2
+ * `haystack verify` — the crawl of the current change (CRAWL-V1, amendments 1
3
+ * and 8).
3
4
  *
4
5
  * It captures the checkout (committed and uncommitted) exactly as the stop
5
6
  * hook does, finds the caller's crawl for that exact capture (its code and its
@@ -13,14 +14,23 @@
13
14
  * it; it stops at the crawl's answer at its budget, or when the crawl ends, and
14
15
  * prints where the change shows up in the running app and what the crawl found.
15
16
  * The hosted fleet run of pushed commits is `haystack verify hosted start|status`.
17
+ *
18
+ * A base the service has not onboarded yet answers the submission `onboarding`
19
+ * (APP-ONBOARDING-V1): the command follows the onboarding's stage list until
20
+ * it is ready, then submits the capture again, which finds the crawl the
21
+ * service admitted from the first submission (or starts it), and follows that.
22
+ * A blocked onboarding prints why and exactly what would unblock it.
16
23
  */
24
+ import { execFileSync } from 'node:child_process';
17
25
  import chalk from 'chalk';
18
26
  import { withSchema } from '../schema.js';
27
+ import { findGitRoot } from '../utils/hooks.js';
19
28
  import { resolveAuthContext } from '../utils/auth.js';
20
29
  import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
21
30
  import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
22
31
  import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_WAIT_MAX_MS, } from './crawl-contract.js';
23
32
  import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
33
+ import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, waitForOnboarding } from './verify-onboarding.js';
24
34
  const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
25
35
  /** A held read (waitAfter): the service's hold, then the time any read gets. */
26
36
  const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
@@ -73,7 +83,8 @@ const SPOT_WORDS = {
73
83
  pressed: 'ran when its control was pressed',
74
84
  observed: 'a difference seen',
75
85
  confirmed: 'a difference seen and confirmed',
76
- judged: 'judged a bug',
86
+ // The stage, not the verdict: a judged difference may be judged not a bug. The findings carry the verdicts.
87
+ judged: 'a difference seen, confirmed and judged',
77
88
  };
78
89
  const VERDICT_WORDS = {
79
90
  bug: 'Bug',
@@ -336,7 +347,9 @@ function headline(view) {
336
347
  }
337
348
  function spotLines(spot) {
338
349
  const how = spot.furthest === null ? 'never reached' : SPOT_WORDS[spot.furthest];
339
- const lines = [` ${chalk.bold(`${safe(spot.file)}:${spot.line}`)} ${how}`];
350
+ const found = spot.findingIds.length === 0 ? ''
351
+ : ` (${spot.findingIds.length} finding${spot.findingIds.length === 1 ? '' : 's'} below)`;
352
+ const lines = [` ${chalk.bold(`${safe(spot.file)}:${spot.line}`)} ${how}${found}`];
340
353
  if (spot.reachedBy) {
341
354
  lines.push(` reached by: ${[spot.reachedBy.start, ...spot.reachedBy.steps].map(safe).join(' > ')}`);
342
355
  if (spot.reachedBy.page !== null)
@@ -530,7 +543,8 @@ function staleWords(found, stale) {
530
543
  }
531
544
  /** Submits the capture through the explicit precompute submission when no crawl
532
545
  * of it exists or the one found is stale. Returns the acknowledged run id (null
533
- * when no crawl could be started) and the line to report. */
546
+ * when no crawl could be started), whether the base is being onboarded or its
547
+ * onboarding is blocked, and the line to report. */
534
548
  async function submitCapture(capture, token, found) {
535
549
  let acknowledgment;
536
550
  // A momentary 429/5xx (a server or runner restarting) is asked again, as the stop hook's sender does: admission is
@@ -550,23 +564,55 @@ async function submitCapture(capture, token, found) {
550
564
  const crawl = acknowledgment.crawl;
551
565
  const before = found === null ? 'No crawl existed for this change' : staleWords(found.view, found.stale);
552
566
  if (crawl === undefined) {
553
- return { runId: null, line: `${before}, and the service did not say whether it started ${found === null ? 'one' : 'another'}.` };
567
+ return { runId: null, onboarding: null,
568
+ line: `${before}, and the service did not say whether it started ${found === null ? 'one' : 'another'}.` };
554
569
  }
555
570
  if (crawl.status === 'unavailable') {
556
- return { runId: null, line: `${before}, and ${found === null ? 'none' : 'another'} could not be started: ${crawlUnavailableText(crawl.reason)}.` };
571
+ return { runId: null, onboarding: null,
572
+ line: `${before}, and ${found === null ? 'none' : 'another'} could not be started: ${crawlUnavailableText(crawl.reason)}.` };
573
+ }
574
+ if (crawl.status === 'onboarding') {
575
+ return { runId: null, onboarding: 'onboarding', line: `${before}. The app is not onboarded for this base yet: `
576
+ + `the service is preparing it (${crawl.onboardRunId}) and runs the crawl when it is ready.` };
577
+ }
578
+ if (crawl.status === 'onboarding-blocked') {
579
+ return { runId: null, onboarding: 'onboarding-blocked', line: `${before}, and the app's onboarding for this base is blocked.` };
557
580
  }
558
581
  if (found === null) {
559
- return { runId: crawl.runId, line: crawl.status === 'queued'
582
+ return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
560
583
  ? `No crawl existed for this change yet; started one (${crawl.runId}).`
561
584
  : `The service already had a crawl for this change (${crawl.runId}).` };
562
585
  }
563
586
  if (!anotherCrawl(found.stale) && crawl.runId === found.view.runId) {
564
- return { runId: crawl.runId, line: `${before}; asked the service to run it again.` };
587
+ return { runId: crawl.runId, onboarding: null, line: `${before}; asked the service to run it again.` };
565
588
  }
566
- return { runId: crawl.runId, line: crawl.status === 'queued'
589
+ return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
567
590
  ? `${before}; started one for this change (${crawl.runId}).`
568
591
  : `${before}; the service already had one for this change (${crawl.runId}).` };
569
592
  }
593
+ function checkedChange(baseCommit, workCommit) {
594
+ const gitRoot = findGitRoot();
595
+ if (!gitRoot)
596
+ throw new Error('Run this command from a git repository.');
597
+ const files = execFileSync('git', ['diff', '--name-only', '-z', '--no-renames', baseCommit, workCommit], { cwd: gitRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).split('\0').filter(path => path !== '');
598
+ return { baseCommit, workCommit, files };
599
+ }
600
+ /** One line naming what the crawl checks, so an agent knows which edits it covers. */
601
+ function checkedLine(change) {
602
+ const shown = change.files.slice(0, 5).join(', ');
603
+ const more = change.files.length > 5 ? ` and ${change.files.length - 5} more` : '';
604
+ return `Checking ${change.files.length} changed file${change.files.length === 1 ? '' : 's'} against ${change.baseCommit.slice(0, 12)}`
605
+ + ` (where HEAD meets origin's default branch), committed and uncommitted: ${shown}${more}.`;
606
+ }
607
+ /** Prints the onboarding (and nothing else) as the command's answer: `--json`
608
+ * carries OnboardingStatusV1. A block exits 3: its ask says what to do. */
609
+ function finishWithOnboarding(status, json, change) {
610
+ if (json)
611
+ process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding: status, crawl: null }), null, 2)}\n`);
612
+ else
613
+ console.log(formatOnboarding(status));
614
+ process.exitCode = onboardingExitCode(status);
615
+ }
570
616
  export async function verifyCommand(options) {
571
617
  // In --json mode stdout carries the one document; every other line goes to stderr.
572
618
  const note = (line) => { if (options.json)
@@ -575,12 +621,17 @@ export async function verifyCommand(options) {
575
621
  console.log(line); };
576
622
  const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo), minutesOption(options.minutes));
577
623
  const request = capture.derivation.request;
624
+ const change = checkedChange(request.baseCommit, request.workCommit);
578
625
  if (!request.crawl) {
579
626
  note(`Nothing to verify: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
580
627
  if (options.json)
581
- process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: null }), null, 2)}\n`);
628
+ process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding: null, crawl: null }), null, 2)}\n`);
582
629
  return;
583
630
  }
631
+ note(checkedLine(change));
632
+ // Measured: a 3-minute crawl answered 13 minutes after the command started (building and preparing both apps).
633
+ note('A crawl often takes 10 to 15 minutes. If this command times out or is stopped, the crawl keeps going:'
634
+ + ' run `haystack verify` again to pick up the same crawl.');
584
635
  const identity = {
585
636
  repository: `${request.owner}/${request.repository}`,
586
637
  baseCommit: request.baseCommit,
@@ -591,14 +642,32 @@ export async function verifyCommand(options) {
591
642
  };
592
643
  const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
593
644
  let view = await readCrawl(identity, auth.token);
645
+ let onboarding = null;
594
646
  const stale = view === null ? null : staleCrawl(view, identity);
595
647
  if (view === null || stale !== null) {
596
648
  const found = view === null || stale === null ? null : { view, stale };
597
- const submitted = await submitCapture(capture, auth.token, found);
649
+ let submitted = await submitCapture(capture, auth.token, found);
598
650
  note(submitted.line);
651
+ if (submitted.onboarding !== null) {
652
+ onboarding = await readOnboardingStatus(identity.repository, identity.baseCommit, auth.token);
653
+ reportOnboardingState(onboarding);
654
+ if (options.wait !== false && onboarding.state === 'onboarding') {
655
+ onboarding = await waitForOnboarding(onboarding, auth.token, progress, 'haystack verify');
656
+ reportOnboardingState(onboarding);
657
+ }
658
+ if (onboarding.state !== 'ready') {
659
+ finishWithOnboarding(onboarding, options.json, change);
660
+ return;
661
+ }
662
+ // Ready: the service admitted the waiting crawl when the record published; this
663
+ // submission of the same capture finds it (or starts it, when a newer stop replaced it).
664
+ note('The app is onboarded for this base.');
665
+ submitted = await submitCapture(capture, auth.token, found);
666
+ note(submitted.line);
667
+ }
599
668
  if (submitted.runId === null) {
600
669
  if (options.json)
601
- process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: null }), null, 2)}\n`);
670
+ process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding, crawl: null }), null, 2)}\n`);
602
671
  process.exitCode = 1;
603
672
  return;
604
673
  }
@@ -611,9 +680,13 @@ export async function verifyCommand(options) {
611
680
  view = await waitForCrawl(view, identity, auth.token);
612
681
  }
613
682
  if (options.json)
614
- process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: view }), null, 2)}\n`);
615
- else
683
+ process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding, crawl: view }), null, 2)}\n`);
684
+ else {
616
685
  console.log(formatCrawl(view));
686
+ const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? [])];
687
+ if (notes.length)
688
+ console.log(['', ...notes.map(note => `Note: ${safe(note)}`)].join('\n'));
689
+ }
617
690
  const exitCode = crawlExitCode(view);
618
691
  if (exitCode !== 0)
619
692
  process.exitCode = exitCode;
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Automated PR review, triage, and merge queue for AI-assisted development.
7
7
  *
8
8
  * Usage:
9
- * npx @haystackeditor/cli init # Set up .haystack.json
9
+ * npx @haystackeditor/cli init # Set this repository up for `haystack verify`
10
10
  * npx @haystackeditor/cli setup # Interactive onboarding wizard
11
11
  * npx @haystackeditor/cli status # Check configuration
12
12
  * npx @haystackeditor/cli login # Authenticate with GitHub
@@ -133,7 +133,7 @@ function getVersion() {
133
133
  const program = new Command();
134
134
  program
135
135
  .name('haystack')
136
- .description('Haystack CLI — automated PR review, triage, and merge queue')
136
+ .description('Haystack CLI — haystack verify runs your app with and without a change and shows what it broke')
137
137
  .version(getVersion())
138
138
  // commander 13 made surplus positional arguments an error; the CLI has always
139
139
  // ignored them. Every command created below inherits this setting.
@@ -144,18 +144,38 @@ program
144
144
  .action((name) => (name ? schemaCommand(name) : listSchemas()));
145
145
  program
146
146
  .command('init')
147
- .description('Create .haystack.json configuration')
148
- .option('-f, --force', 'Overwrite existing .haystack.json')
147
+ .description('Set this repository up for `haystack verify` and start onboarding the app')
148
+ .option('-y, --yes', 'Make the changes without asking')
149
+ .option('--json', 'The result as one JSON document on stdout (see `haystack schema init`)')
149
150
  .addHelpText('after', `
150
- This creates a .haystack.json file with auto-detected settings:
151
- • Dev server command and port
152
- • Services (for monorepos)
153
- • Auth bypass for CI environments
151
+ There is no setup file: Haystack works out how to run the app from what the
152
+ repository already keeps working. init shows each change as a diff, then makes
153
+ it with --yes (or a yes at the prompt):
154
+ • Claude Code's Stop hook (\`haystack verify precompute --hook\`) in the
155
+ per-developer .claude/settings.local.json, kept out of commits, when
156
+ Claude Code is in use here
157
+ • a short note in AGENTS.md (and CLAUDE.md when the repository keeps one)
158
+ telling coding agents to run \`haystack verify\` after a change
159
+ Then it starts onboarding the app, so it is usually ready by the first
160
+ \`haystack verify\`. Running it again changes only what is missing and shows
161
+ where onboarding is (\`haystack verify onboarding\` shows that too).
154
162
 
155
- After running, use \`haystack setup\` to scan your repos for rules,
156
- CI signals, and review policies.
163
+ Exit codes:
164
+ 0 set up: onboarding is running or the app is ready
165
+ 1 the command failed
166
+ 2 changes were shown but not made: run \`haystack init --yes\`
167
+ 3 onboarding is blocked: the output says what would unblock it
168
+ 4 not logged in: run \`haystack login\`
169
+ 5 the Haystack GitHub App is not installed on the repository's owner:
170
+ the output links to it
171
+ 6 set up, but onboarding stopped before finishing: run \`haystack init\` again
172
+
173
+ Examples:
174
+ haystack init
175
+ haystack init --yes
176
+ haystack init --yes --json
157
177
  `)
158
- .action(initCommand);
178
+ .action(options => runPublicCommand(() => initCommand(options), options.json));
159
179
  program
160
180
  .command('setup')
161
181
  .description('Onboarding wizard — scan repos for rules/policies and write Haystack config')
@@ -233,13 +253,22 @@ changed spot, how far the crawl got with it, and the steps that reached it),
233
253
  what the crawl found (bugs first, with the steps to see each), and the changed
234
254
  code it never ran.
235
255
 
256
+ The first verify on a base the service has not onboarded yet prepares the app
257
+ (reads the repository, plans how to run it, builds its runtime, starts it,
258
+ prepares its data and test accounts, proves a workflow); the command shows each
259
+ step, and the crawl runs once the app is ready. When onboarding cannot go on it
260
+ says why and what would unblock it, for example a question to answer with
261
+ \`haystack verify answer <question-id> <choice>\`.
262
+
236
263
  Exit codes:
237
264
  0 the crawl answered or finished (the bugs it found are in the output), or
238
265
  it is still running and --no-wait was given
239
266
  1 the command failed, or no crawl could be started for this change
240
267
  2 the crawl ended without finishing: it stopped early or was cancelled
241
268
  (a newer stop in the repository replaces an older crawl), or the
242
- machines it used could not be proven shut down
269
+ machines it used could not be proven shut down; or onboarding stopped
270
+ before finishing
271
+ 3 onboarding is blocked: the output says what would unblock it
243
272
 
244
273
  The hosted fleet run of pushed commits is \`haystack verify hosted start\`.
245
274
 
@@ -273,6 +302,49 @@ function verifyCommandOptions(command) {
273
302
  }
274
303
  return options;
275
304
  }
305
+ verify
306
+ .command('answer')
307
+ .description('Answer a question onboarding asked about your app, then run `haystack init` to continue')
308
+ .argument('<question-id>', 'The question id onboarding printed')
309
+ .argument('<choice>', 'One of the question\'s choices, exactly as printed')
310
+ .option('--repo <owner/repo>', 'GitHub repository (default: origin remote)')
311
+ .option('--account <login>', 'Use a specific saved Haystack account')
312
+ .option('--json', 'The repository\'s onboarding facts after the answer, as one JSON document')
313
+ .addHelpText('after', `
314
+ An answer is a fact about your app (never setup); onboarding plans from it the
315
+ next time it runs, so \`haystack init\` or a new \`haystack verify\` continues
316
+ from there.
317
+
318
+ Example:
319
+ haystack verify answer q_<id> "postgres 15 (terraform/db.tf)"
320
+ `)
321
+ .action(async (questionId, choice, _options, cmd) => {
322
+ const options = verifyCommandOptions(cmd);
323
+ const { verifyAnswerCommand } = await import('./commands/verify-onboarding.js');
324
+ return runPublicCommand(() => verifyAnswerCommand(questionId, choice, options), options.json);
325
+ });
326
+ verify
327
+ .command('onboarding')
328
+ .description('Show where onboarding of this checkout\'s base is, without starting anything')
329
+ .option('--account <login>', 'Use a specific saved Haystack account')
330
+ .option('--wait', 'Follow onboarding until it is ready, blocked or stopped')
331
+ .option('--json', 'The onboarding as one JSON document (see `haystack schema verify-onboarding`)')
332
+ .addHelpText('after', `
333
+ Reads the onboarding of the base \`haystack verify\` would use for this
334
+ checkout. \`haystack init\` starts it; so does the first \`haystack verify\`.
335
+
336
+ Exit codes:
337
+ 0 the app is ready, or onboarding is running
338
+ 1 the command failed
339
+ 2 onboarding has not started, stopped before finishing, or was cancelled:
340
+ \`haystack init\` starts it again
341
+ 3 onboarding is blocked: the output says what would unblock it
342
+ `)
343
+ .action(async (_options, cmd) => {
344
+ const options = verifyCommandOptions(cmd);
345
+ const { verifyOnboardingCommand } = await import('./commands/verify-onboarding.js');
346
+ return runPublicCommand(() => verifyOnboardingCommand(options), options.json);
347
+ });
276
348
  verify
277
349
  .command('history')
278
350
  .description('Read your recent finished hosted verification runs without starting a run')
@@ -570,6 +642,15 @@ program
570
642
  .option('--label <name>', '(--headless) Label to display in the dashboard for this token')
571
643
  .option('--scope <repos>', '(--headless) Comma-separated repos to scope the token to (or "all")', (val) => val.split(',').map((s) => s.trim()).filter(Boolean))
572
644
  .option('--token <hsk_live_...>', '(--headless) Skip the browser handshake — paste a token minted in the dashboard')
645
+ .option('--no-wait', 'Print the sign-in URL and code and return; the next `haystack login` finishes signing in')
646
+ .option('--json', 'The sign-in (URL and code, or the account signed in) as one JSON document (see `haystack schema login`)')
647
+ .addHelpText('after', `
648
+ Signing in uses GitHub's device flow: open the URL, enter the code. A coding
649
+ agent runs \`haystack login --no-wait\`, shows the person the URL and code, and
650
+ then runs \`haystack login\`, which waits until the code is entered. A login
651
+ stopped while it waits is finished the same way, with the same code, until
652
+ GitHub expires it.
653
+ `)
573
654
  .action(async (opts) => {
574
655
  if (opts.headless) {
575
656
  try {
@@ -581,7 +662,7 @@ program
581
662
  }
582
663
  return;
583
664
  }
584
- return loginCommand();
665
+ return loginCommand({ wait: opts.wait, json: opts.json });
585
666
  });
586
667
  program
587
668
  .command('logout [login]')
@@ -1531,15 +1612,6 @@ fleetPolicy
1531
1612
  const { fleetPolicyHistoryCommand } = await import('./commands/fleet-policy.js');
1532
1613
  await runAdminCommand(() => fleetPolicyHistoryCommand(repository, options));
1533
1614
  });
1534
- fleetPolicy
1535
- .command('import-kv')
1536
- .description('One time: import the KV policies and tenant overrides into D1')
1537
- .option('--dry-run', 'Report what would be imported without writing')
1538
- .option(workerUrlOption, workerUrlHelp)
1539
- .action(async (options) => {
1540
- const { fleetPolicyImportKvCommand } = await import('./commands/fleet-policy.js');
1541
- await runAdminCommand(() => fleetPolicyImportKvCommand(options));
1542
- });
1543
1615
  // ─── rules ───────────────────────────────────────────────────────────────────
1544
1616
  const rules = program.command('rules').description('Manage .haystack/pr-rules.yml');
1545
1617
  rules
package/dist/schema.js CHANGED
@@ -20,7 +20,11 @@ export const SCHEMA_VERSIONS = {
20
20
  action: '1.0.0',
21
21
  'cloud-verifier': '1.0.0',
22
22
  'case-batch': '1.0.1',
23
- verify: '1.0.0',
23
+ verify: '1.0.2',
24
+ 'verify-answer': '1.0.0',
25
+ 'verify-onboarding': '1.0.0',
26
+ init: '1.0.0',
27
+ login: '1.0.0',
24
28
  error: '1.0.0',
25
29
  };
26
30
  /** Wrap a payload with the `schema_version` envelope. */
@@ -42,22 +42,6 @@ export async function loadConfig(configPath) {
42
42
  throw new Error(`Failed to parse ${resolvedPath}: ${err.message}`);
43
43
  }
44
44
  }
45
- /**
46
- * Save config to file
47
- */
48
- export async function saveConfig(config, configPath) {
49
- const resolvedPath = configPath || path.join(process.cwd(), CONFIG_FILENAME);
50
- const content = JSON.stringify(config, null, 2);
51
- await fs.writeFile(resolvedPath, content, 'utf-8');
52
- return resolvedPath;
53
- }
54
- /**
55
- * Check if config exists
56
- */
57
- export async function configExists(startDir) {
58
- const configPath = await findConfigPath(startDir);
59
- return configPath !== null;
60
- }
61
45
  /**
62
46
  * Get the project root (directory containing .haystack.json or current dir)
63
47
  */
@@ -45,6 +45,13 @@ export const SUBSCRIPTION_REQUIRED_MESSAGE = 'This account has no active Haystac
45
45
  * repository and team). Its message names the repository; print it as is.
46
46
  */
47
47
  export const REPO_NOT_ENTITLED_CODE = 'repo_not_entitled';
48
+ /**
49
+ * The server returns this `code` (HTTP 403, beside a readable `error`) when the
50
+ * repository's owner has not installed the Haystack GitHub App, which the
51
+ * service needs to read the repository.
52
+ */
53
+ export const INSTALLATION_REQUIRED_CODE = 'installation-required';
54
+ export const HAYSTACK_APP_INSTALL_URL = 'https://github.com/apps/haystack-code-reviewer-pr-hook/installations/new';
48
55
  /**
49
56
  * Extract the server's own message (and its `error` code) if the body is
50
57
  * JSON; truncate raw bodies.
@@ -66,7 +73,8 @@ async function serverMessage(response) {
66
73
  const parsed = JSON.parse(text);
67
74
  return {
68
75
  detail: parsed.message ?? parsed.error ?? null,
69
- code: typeof parsed.error === 'string' ? parsed.error : null,
76
+ // Routes that answer { error: <readable text>, code } carry the stable code in `code`.
77
+ code: typeof parsed.code === 'string' ? parsed.code : typeof parsed.error === 'string' ? parsed.error : null,
70
78
  };
71
79
  }
72
80
  catch {
@@ -90,6 +98,9 @@ export async function classifyHttpError(response, context) {
90
98
  : 'This account is not entitled to use Haystack on this repository. See https://haystack.sh/pricing or email akshay@haystack.sh to get access.';
91
99
  return new HaystackApiError(403, message, code);
92
100
  }
101
+ if (response.status === 403 && code === INSTALLATION_REQUIRED_CODE) {
102
+ return new HaystackApiError(403, `${detail ?? 'Install the Haystack GitHub App on the repository\'s owner'}: ${HAYSTACK_APP_INSTALL_URL}`, code);
103
+ }
93
104
  if (response.status === 401) {
94
105
  return new HaystackApiError(401, `${context}: authentication failed${suffix}. Run \`haystack login\` to re-authenticate.`);
95
106
  }
@@ -9,8 +9,6 @@
9
9
  * - detect-secrets
10
10
  * - truffleHog
11
11
  */
12
- import * as fs from 'fs/promises';
13
- import * as path from 'path';
14
12
  /**
15
13
  * Common secret patterns
16
14
  */
@@ -188,52 +186,3 @@ export function scanForSecrets(content, filename) {
188
186
  }
189
187
  return findings;
190
188
  }
191
- /**
192
- * Scan a file for secrets
193
- */
194
- export async function scanFile(filePath) {
195
- try {
196
- const content = await fs.readFile(filePath, 'utf-8');
197
- return scanForSecrets(content, path.basename(filePath));
198
- }
199
- catch {
200
- return [];
201
- }
202
- }
203
- /**
204
- * Scan .haystack.json specifically for secrets
205
- */
206
- export async function scanHaystackConfig(configPath) {
207
- return scanFile(configPath);
208
- }
209
- /**
210
- * Validate that a config doesn't contain hardcoded secrets
211
- * Returns true if safe, false if secrets detected
212
- */
213
- export async function validateConfigSecurity(configPath) {
214
- const findings = await scanHaystackConfig(configPath);
215
- const highSeverity = findings.filter((f) => f.severity === 'high');
216
- return {
217
- safe: highSeverity.length === 0,
218
- findings,
219
- };
220
- }
221
- /**
222
- * Get a security report string for display
223
- */
224
- export function formatSecurityReport(findings) {
225
- if (findings.length === 0) {
226
- return '✓ No secrets detected';
227
- }
228
- const lines = [
229
- `⚠️ ${findings.length} potential secret(s) detected:\n`,
230
- ];
231
- for (const finding of findings) {
232
- const severity = finding.severity === 'high' ? '🔴' : finding.severity === 'medium' ? '🟡' : '🟢';
233
- lines.push(` ${severity} ${finding.file}:${finding.line}:${finding.column}`);
234
- lines.push(` ${finding.description}`);
235
- lines.push(` Matched: ${finding.match}\n`);
236
- }
237
- lines.push('Use $VARIABLE syntax to reference secrets instead of hardcoding values.\n');
238
- return lines.join('\n');
239
- }
@@ -197,6 +197,38 @@ export function trackPrecomputeEvent(outcome, wallMs, timeoutMs) {
197
197
  // Telemetry must never crash or delay the CLI.
198
198
  }
199
199
  }
200
+ /** Track one onboarding state `haystack verify` saw: started, blocked (with the block's kind) or ready. */
201
+ export function trackOnboardingEvent(outcome, kind) {
202
+ try {
203
+ if (telemetryDisabled() || typeof globalThis.fetch !== 'function')
204
+ return;
205
+ const event = {
206
+ api_key: POSTHOG_API_KEY,
207
+ distinct_id: getDistinctId(),
208
+ event: 'cli_verify_onboarding',
209
+ properties: {
210
+ cli_version: getCLIVersion(),
211
+ os: process.platform,
212
+ arch: process.arch,
213
+ timestamp: new Date().toISOString(),
214
+ ...minimizeMetadata({ outcome, ...(kind === undefined ? {} : { kind }) }),
215
+ $ip: null,
216
+ },
217
+ timestamp: new Date().toISOString(),
218
+ };
219
+ globalThis.fetch(`${POSTHOG_HOST}/capture/`, {
220
+ method: 'POST',
221
+ headers: { 'Content-Type': 'application/json' },
222
+ body: JSON.stringify(event),
223
+ }).catch(() => {
224
+ // Documented fire-and-forget product telemetry: delivery cannot affect
225
+ // the CLI command being measured.
226
+ });
227
+ }
228
+ catch {
229
+ // Telemetry must never crash the CLI
230
+ }
231
+ }
200
232
  function getCLIVersion() {
201
233
  try {
202
234
  const packageJson = JSON.parse(readFileSync(PACKAGE_JSON_PATH, 'utf8'));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.19.0",
4
- "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
3
+ "version": "0.20.0",
4
+ "description": "haystack verify: run your app with and without a change, and see what the change broke",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "haystack": "dist/index.js"