@haystackeditor/cli 0.26.0 → 0.27.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.
@@ -27,74 +27,23 @@ import chalk from 'chalk';
27
27
  import { withSchema } from '../schema.js';
28
28
  import { findGitRoot } from '../utils/hooks.js';
29
29
  import { resolveAuthContext } from '../utils/auth.js';
30
- import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
30
+ import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, repoApiPath, } from '../utils/haystack-api.js';
31
31
  import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
32
32
  import { CRAWL_BRIEF_MAX_FUNCTIONS, CRAWL_BRIEF_MAX_GROUPS, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_MAX_IDEAS, CRAWL_MAX_STAND_IN_LINES, CRAWL_MAX_STAND_IN_ROWS, CRAWL_MAX_TEXT_CHARS, CRAWL_WAIT_MAX_MS, CRAWL_POOLS, } from './crawl-contract.js';
33
33
  import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
34
+ import { SPOT_WORDS, TERMINAL, VERDICT_WORDS, VERDICTS, currentStep, errorWords, headline, plural, reportedStatus, results, verifyReport, } from './crawl-report.js';
34
35
  import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, reviewNotes, standInGapNotes, waitForOnboarding, } from './verify-onboarding.js';
35
36
  import { formatCapture, readPreVerifyCapture } from './capture-brief.js';
36
- const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
37
37
  /** A held read (waitAfter): the service's hold, then the time any read gets. */
38
38
  const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
39
39
  const RUN_ID = /^cv_[0-9a-f]{48}$/;
40
40
  const REPOSITORY_COMPONENT = /^[A-Za-z0-9_.-]{1,100}$/;
41
41
  const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'prepared', 'incomplete', 'cancelled'];
42
- const TERMINAL = new Set(['completed', 'prepared', 'incomplete', 'cancelled']);
43
42
  const STAGES = ['reconcile', 'source', 'worlds', 'blast', 'knobs', 'prepare', 'crawl', 'publish', 'cleanup'];
44
43
  const ERROR_CODES = ['source-mismatch', 'blast-unavailable', 'world-build-failed',
45
44
  'prepare-failed', 'crawl-failed', 'no-capacity', 'stalled', 'lease-lost', 'cancelled'];
46
- const VERDICTS = ['bug', 'cant-tell', 'being-judged', 'not-a-bug'];
47
45
  const SPOT_STAGES = ['resolved', 'loaded', 'exposed', 'activated', 'pressed', 'observed', 'confirmed', 'judged'];
48
46
  const REUSE = ['hit', 'miss', null];
49
- const STATUS_WORDS = {
50
- queued: 'waiting for a machine',
51
- preparing: 'getting ready',
52
- crawling: 'exploring the app',
53
- draining: 'finishing the last checks',
54
- completed: 'finished',
55
- prepared: 'built and ready to check',
56
- incomplete: 'stopped before finishing',
57
- cancelled: 'cancelled',
58
- };
59
- const STAGE_WORDS = {
60
- reconcile: 'cleaning up after an earlier try',
61
- source: 'rebuilding your change',
62
- worlds: 'building and starting the app with and without your change',
63
- blast: 'waiting for the analysis of what your change touches',
64
- knobs: "reading the app's settings",
65
- prepare: 'setting up starting points next to your change',
66
- crawl: 'exploring the app',
67
- publish: 'saving the results',
68
- cleanup: 'shutting down the machines it used',
69
- };
70
- const ERROR_WORDS = {
71
- 'source-mismatch': 'your change could not be rebuilt exactly as it was captured',
72
- 'blast-unavailable': 'the analysis of what your change touches was not available',
73
- 'world-build-failed': 'the app did not build or start, with or without your change',
74
- 'prepare-failed': 'setting up starting points next to your change failed',
75
- 'crawl-failed': 'exploring the app failed',
76
- 'no-capacity': 'there was no room on the machines for it',
77
- stalled: 'it stopped making progress',
78
- 'lease-lost': 'the machine running it stopped responding',
79
- cancelled: 'it was cancelled',
80
- };
81
- const SPOT_WORDS = {
82
- resolved: 'never reached',
83
- loaded: 'loaded but never ran',
84
- exposed: 'shown on screen',
85
- activated: 'ran',
86
- pressed: 'ran when its control was pressed',
87
- observed: 'a difference seen',
88
- confirmed: 'a difference seen and confirmed',
89
- // The stage, not the verdict: a judged difference may be judged not a bug. The findings carry the verdicts.
90
- judged: 'a difference seen, confirmed and judged',
91
- };
92
- const VERDICT_WORDS = {
93
- bug: 'Bug',
94
- 'cant-tell': "Can't tell",
95
- 'being-judged': 'Still being judged',
96
- 'not-a-bug': 'Not a bug',
97
- };
98
47
  /** A response the CLI will not print: malformed, contradictory, or another revision's. */
99
48
  export class CrawlResponseError extends Error {
100
49
  constructor(message) {
@@ -363,16 +312,15 @@ async function responseBody(response) {
363
312
  function read(path, token, waitAfter) {
364
313
  return waitAfter === undefined
365
314
  ? gatewayFetch(path, token)
366
- : gatewayFetch(`${path}&waitAfter=${encodeURIComponent(waitAfter)}`, token, { method: 'GET', timeoutMs: HELD_READ_TIMEOUT_MS });
315
+ : gatewayFetch(`${path}${path.includes('?') ? '&' : '?'}waitAfter=${encodeURIComponent(waitAfter)}`, token, { method: 'GET', timeoutMs: HELD_READ_TIMEOUT_MS });
367
316
  }
368
317
  /** The caller's newest crawl of this capture's revision, or null. Its change
369
318
  * title may differ from the capture's (sameChange). */
370
319
  async function readCrawl(identity, token, waitAfter) {
371
- const path = `${CRAWLS_PATH}?repository=${encodeURIComponent(identity.repository)}`
372
- + `&workCommit=${encodeURIComponent(identity.workCommit)}`;
320
+ const path = repoApiPath(identity.repository, `/checks?commit=${encodeURIComponent(identity.workCommit)}`);
373
321
  const response = await read(path, token, waitAfter);
374
322
  if (!response.ok)
375
- throw await classifyHttpError(response, `Haystack API ${CRAWLS_PATH}`);
323
+ throw await classifyHttpError(response, `Haystack API ${path}`);
376
324
  const body = await responseBody(response);
377
325
  if (!isRecord(body) || !Object.hasOwn(body, 'crawl'))
378
326
  invalid('no crawl field');
@@ -382,9 +330,10 @@ async function readCrawl(identity, token, waitAfter) {
382
330
  async function readCrawlRun(runId, identity, token, waitAfter) {
383
331
  if (!RUN_ID.test(runId))
384
332
  invalid('an acknowledged run id');
385
- const response = await read(`${CRAWLS_PATH}/${runId}?repository=${encodeURIComponent(identity.repository)}`, token, waitAfter);
333
+ const path = repoApiPath(identity.repository, `/checks/${runId}`);
334
+ const response = await read(path, token, waitAfter);
386
335
  if (!response.ok)
387
- throw await classifyHttpError(response, `Haystack API ${CRAWLS_PATH}/${runId}`);
336
+ throw await classifyHttpError(response, `Haystack API ${path}`);
388
337
  const view = parseCrawlView(await responseBody(response), identity);
389
338
  if (view.runId !== runId || !sameChange(view, identity)) {
390
339
  throw new CrawlResponseError(`The crawl service returned another crawl for ${runId}; it is not shown.`);
@@ -397,52 +346,6 @@ function safe(value) {
397
346
  // eslint-disable-next-line no-control-regex -- replacing control characters is the point
398
347
  return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
399
348
  }
400
- function plural(count, noun) {
401
- return `${count} ${noun}${count === 1 ? '' : 's'}`;
402
- }
403
- function errorWords(error) {
404
- return error.code === 'stalled'
405
- ? `${ERROR_WORDS.stalled} while ${STAGE_WORDS[error.stage]}`
406
- : ERROR_WORDS[error.code];
407
- }
408
- /** What a running crawl is doing now: its stage, except while it waits for a
409
- * machine or finishes its last checks, which its status says better. */
410
- function currentStep(view) {
411
- return view.status === 'queued' || view.status === 'draining' ? STATUS_WORDS[view.status] : STAGE_WORDS[view.stage];
412
- }
413
- /** The crawl's results: its sealed manifest, else (amendment 9) its answer at the budget, which has the same findings and reach. */
414
- function results(view) {
415
- return view.manifest ?? (view.answer?.status === 'answered' ? view.answer : null);
416
- }
417
- /** The status the command reports: a running crawl that has answered (amendment 9: its budget is up and its work is
418
- * done; only its shutdown remains) reads as completed. */
419
- function reportedStatus(view) {
420
- return !TERMINAL.has(view.status) && view.answer?.status === 'answered' ? 'completed' : view.status;
421
- }
422
- function headline(view) {
423
- const bugs = results(view)?.bugs ?? view.totals.bugs;
424
- const found = bugs > 0 ? ` It found ${plural(bugs, 'bug')} before it stopped.` : '';
425
- switch (reportedStatus(view)) {
426
- case 'completed':
427
- return bugs > 0 ? `Found ${plural(bugs, 'bug')} in your change.` : 'No bugs found in your change.';
428
- case 'incomplete': {
429
- const error = results(view)?.error ?? view.error;
430
- return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
431
- }
432
- case 'prepared':
433
- // Amendment 12: the turn-end hook built and froze this change; `haystack verify` asks for the crawl itself.
434
- return 'Your change is built and ready to check, but was not crawled yet. Run `haystack verify` to check it.';
435
- case 'cancelled':
436
- return `The crawl was cancelled.${found}`;
437
- default: {
438
- const so = bugs > 0 ? ` ${plural(bugs, 'bug')} found so far.` : '';
439
- if (view.cancelRequested) {
440
- return `Being cancelled: ${currentStep(view)}.${so}`;
441
- }
442
- return `Still running: ${currentStep(view)}.${so}`;
443
- }
444
- }
445
- }
446
349
  function spotLines(spot) {
447
350
  const how = spot.furthest === null ? 'never reached' : SPOT_WORDS[spot.furthest];
448
351
  const found = spot.findingIds.length === 0 ? ''
@@ -555,57 +458,12 @@ export function formatCrawl(view) {
555
458
  }
556
459
  return lines.join('\n');
557
460
  }
558
- export function verifyReport(view) {
559
- const status = reportedStatus(view);
560
- const published = results(view);
561
- const error = published?.error ?? view.error;
562
- const explored = published !== null && view.clocks.crawlStartedAt !== null;
563
- const findings = explored ? VERDICTS.flatMap(verdict => published.findings.filter(finding => finding.verdict === verdict)) : [];
564
- const ideaWords = { covered: 'tried', blocked: 'could not try', unfinished: 'not finished' };
565
- return {
566
- runId: view.runId,
567
- repository: view.repository,
568
- title: view.changeTitle,
569
- baseCommit: view.baseCommit,
570
- workCommit: view.workCommit,
571
- status,
572
- headline: headline(view),
573
- bugs: published?.bugs ?? view.totals.bugs,
574
- findings: findings.map(finding => ({
575
- id: finding.id,
576
- verdict: finding.verdict,
577
- why: finding.why,
578
- start: finding.start,
579
- steps: finding.steps,
580
- outsideServices: finding.standIns && (finding.standIns.old.length > 0 || finding.standIns.new.length > 0)
581
- ? { withoutChange: finding.standIns.old, withChange: finding.standIns.new }
582
- : null,
583
- })),
584
- spots: explored ? published.reach.spots.map(spot => ({
585
- file: spot.file,
586
- line: spot.line,
587
- reached: spot.furthest === null ? 'never reached' : SPOT_WORDS[spot.furthest],
588
- reachedBy: spot.reachedBy && { start: spot.reachedBy.start, steps: spot.reachedBy.steps, page: spot.reachedBy.page },
589
- findingIds: spot.findingIds,
590
- })) : [],
591
- ideas: explored && published.ideas !== undefined
592
- ? published.ideas.map(idea => ({ text: idea.text, result: ideaWords[idea.state], reason: idea.reason, findingIds: idea.findingIds }))
593
- : null,
594
- neverRan: explored ? {
595
- files: published.reach.unreachedChangedFiles,
596
- functions: published.reach.blast === null ? null : { ran: published.reach.blast.ran, total: published.reach.blast.functions },
597
- } : null,
598
- notFinishedInTime: explored ? published.notFinishedInTime : 0,
599
- outsideServiceGaps: explored ? published.standIns?.gaps ?? [] : [],
600
- error: error ? { code: error.code, message: error.message } : null,
601
- machinesNotProvenShutDown: TERMINAL.has(view.status) ? view.totals.cleanupUnproven : 0,
602
- };
603
- }
604
461
  /** Case-batch status conventions: 0 finished (or answered: amendment 9, its work
605
462
  * is done and only its shutdown remains), or still running with --no-wait;
606
463
  * 2 ended without finishing (incomplete, cancelled) or finished with cleanup
607
464
  * unproven (an answered crawl has not finished its cleanup, so it is not judged); bugs
608
465
  * found never change it. The command's own failures exit 1 elsewhere. */
466
+ export { verifyReport };
609
467
  export function crawlExitCode(view) {
610
468
  const status = reportedStatus(view);
611
469
  if (!TERMINAL.has(status))
package/dist/index.js CHANGED
@@ -1,23 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Haystack CLI
3
+ * Haystack CLI: `haystack verify` runs your app with and without a change and shows what it broke.
4
4
  *
5
- * Set up your project for Haystack.
6
- * Automated PR review, triage, and merge queue for AI-assisted development.
5
+ * haystack init Set this repository up for `haystack verify` and start onboarding the app
6
+ * haystack verify After a change: crawl the app with and without it, and report what broke
7
+ * haystack feedback Tell the Haystack team what went wrong
8
+ * haystack login Sign in with GitHub
7
9
  *
8
- * Usage:
9
- * npx @haystackeditor/cli init # Set this repository up for `haystack verify`
10
- * npx @haystackeditor/cli setup # Interactive onboarding wizard
11
- * npx @haystackeditor/cli status # Check configuration
12
- * npx @haystackeditor/cli login # Authenticate with GitHub
13
- * npx @haystackeditor/cli submit # Create a PR (auto-merge or review)
14
- * npx @haystackeditor/cli triage # Check last submitted PR
15
- * npx @haystackeditor/cli triage 123 # View analysis results for a PR
16
- * npx @haystackeditor/cli dismiss 123 # Dismiss findings for a PR
17
- * npx @haystackeditor/cli mark-reviewed 123 # Mark review as not needed
18
- * npx @haystackeditor/cli request-review 123 # Tag a PR as needing human review
19
- * npx @haystackeditor/cli pr-status 123 # Show PR status in Haystack pipeline
20
- * npx @haystackeditor/cli config # Manage preferences
10
+ * Without a global install: npx -y -p @haystackeditor/cli@latest haystack <command>
21
11
  */
22
12
  import { readFileSync } from 'node:fs';
23
13
  import { fileURLToPath } from 'node:url';
@@ -29,7 +19,6 @@ import { lazy } from './lazy.js';
29
19
  // Command modules load on first use (see src/lazy.ts). Keep new commands
30
20
  // on this pattern: a static import here puts its whole dependency graph on
31
21
  // the startup path of every invocation, including --version and --help.
32
- const statusCommand = lazy(() => import('./commands/status.js'), 'statusCommand');
33
22
  const initCommand = lazy(() => import('./commands/init.js'), 'initCommand');
34
23
  const captureManifestCommand = lazy(() => import('./commands/capture-manifest.js'), 'captureManifestCommand');
35
24
  const authListCommand = lazy(() => import('./commands/login.js'), 'authListCommand');
@@ -93,7 +82,7 @@ program
93
82
  // ignored them. Every command created below inherits this setting.
94
83
  .allowExcessArguments();
95
84
  program
96
- .command('schema [name]')
85
+ .command('schema [name]', { hidden: true })
97
86
  .description('Print the JSON Schema for a command\'s --json output')
98
87
  .action((name) => (name ? schemaCommand(name) : listSchemas()));
99
88
  program
@@ -170,10 +159,6 @@ Examples:
170
159
  haystack init --yes --json --notes haystack-notes.json
171
160
  `)
172
161
  .action(options => runPublicCommand(() => initCommand(options), options.json));
173
- program
174
- .command('status')
175
- .description('Check if .haystack.json exists and is valid')
176
- .action(statusCommand);
177
162
  const verify = program
178
163
  .command('verify')
179
164
  .description('Crawl the running app with your current change: where it shows up and what broke')
@@ -670,7 +655,7 @@ GitHub expires it.
670
655
  program
671
656
  .command('logout [login]')
672
657
  .description('Remove a saved GitHub account (use --headless to remove the local CLI token)')
673
- .option('--headless', 'Remove the local hsk_live_* token file (does not revoke server-side — use `haystack tokens revoke <id>`)')
658
+ .option('--headless', 'Remove the local hsk_live_* token file (does not revoke server-side — use `haystack auth revoke <id>`)')
674
659
  .action(async (login, opts) => {
675
660
  if (opts.headless) {
676
661
  try {
@@ -684,24 +669,37 @@ program
684
669
  }
685
670
  return logoutCommand(login);
686
671
  });
687
- // ─── tokens (server-side hsk_live_*) ─────────────────────────────────────────
688
- const tokens = program.command('tokens').description('Manage server-side hsk_live_* tokens');
689
- tokens
672
+ program
673
+ .command('whoami')
674
+ .description('Show the active Haystack GitHub account')
675
+ .action(whoamiCommand);
676
+ const authProgram = program
677
+ .command('auth')
678
+ .description('Saved accounts and long-lived (hsk_live) tokens');
679
+ authProgram
690
680
  .command('list')
691
- .description('List your CLI tokens')
681
+ .description('List saved Haystack GitHub accounts')
682
+ .action(authListCommand);
683
+ authProgram
684
+ .command('use <login>')
685
+ .description('Set the active Haystack GitHub account')
686
+ .action(authUseCommand);
687
+ authProgram
688
+ .command('tokens')
689
+ .description('List your long-lived (hsk_live) tokens')
692
690
  .option('--json', 'Machine-readable output')
693
691
  .action(async (opts) => {
694
692
  try {
695
693
  await listTokensCommand(opts);
696
694
  }
697
695
  catch (err) {
698
- console.error(chalk.red('list failed:'), err instanceof Error ? err.message : err);
696
+ console.error(chalk.red('tokens failed:'), err instanceof Error ? err.message : err);
699
697
  process.exit(1);
700
698
  }
701
699
  });
702
- tokens
700
+ authProgram
703
701
  .command('revoke <id>')
704
- .description('Revoke a token by id (cannot be undone)')
702
+ .description('Revoke a long-lived token by id (cannot be undone)')
705
703
  .action(async (id) => {
706
704
  try {
707
705
  await revokeTokenCommand(id);
@@ -711,21 +709,6 @@ tokens
711
709
  process.exit(1);
712
710
  }
713
711
  });
714
- program
715
- .command('whoami')
716
- .description('Show the active Haystack GitHub account')
717
- .action(whoamiCommand);
718
- const authProgram = program
719
- .command('auth')
720
- .description('Manage saved Haystack GitHub accounts');
721
- authProgram
722
- .command('list')
723
- .description('List saved Haystack GitHub accounts')
724
- .action(authListCommand);
725
- authProgram
726
- .command('use <login>')
727
- .description('Set the active Haystack GitHub account')
728
- .action(authUseCommand);
729
712
  const telemetry = program.command('telemetry').description('Add privacy-safe production telemetry without an application SDK')
730
713
  .addHelpText('after', `
731
714
  Next.js bundles its server code, so instead of \`instrument\` add the CLI as a
@@ -1,6 +1,14 @@
1
1
  export function haystackApiBase() {
2
2
  return process.env.HAYSTACK_API_BASE ?? 'https://haystackeditor.com';
3
3
  }
4
+ /** A public API path for `repository` (owner/repo): /v1/repos/{owner}/{repo}{rest}. */
5
+ export function repoApiPath(repository, rest) {
6
+ const parts = repository.split('/');
7
+ if (parts.length !== 2 || parts.some(part => !/^[A-Za-z0-9_.-]{1,100}$/.test(part))) {
8
+ throw new Error(`Invalid repository ${repository}: it must be owner/repo.`);
9
+ }
10
+ return `/v1/repos/${parts[0]}/${parts[1]}${rest}`;
11
+ }
4
12
  export function haystackApiUrl(path) {
5
13
  if (!path.startsWith('/'))
6
14
  throw new Error('Haystack API path must be absolute');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
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": {
@@ -1,35 +0,0 @@
1
- /**
2
- * haystack status - Check if .haystack.json exists and is valid
3
- */
4
- import chalk from 'chalk';
5
- import { loadConfig, findConfigPath } from '../utils/config.js';
6
- export async function statusCommand() {
7
- const configPath = await findConfigPath();
8
- if (!configPath) {
9
- console.log(chalk.yellow('⚠ No .haystack.json found'));
10
- console.log(chalk.dim('\nRun `haystack init` to create one.\n'));
11
- process.exit(1);
12
- }
13
- console.log(chalk.green(`✓ Found ${configPath}`));
14
- try {
15
- const config = await loadConfig(configPath);
16
- if (!config) {
17
- console.log(chalk.red('✗ Failed to load config'));
18
- process.exit(1);
19
- }
20
- console.log(chalk.green('✓ Config is valid\n'));
21
- // Show config summary
22
- if (config.name) {
23
- console.log(` ${chalk.dim('Name:')} ${config.name}`);
24
- }
25
- if (config.services) {
26
- const serviceNames = Object.keys(config.services);
27
- console.log(` ${chalk.dim('Services:')} ${serviceNames.join(', ')}`);
28
- }
29
- console.log('');
30
- }
31
- catch (e) {
32
- console.log(chalk.red(`✗ Config is invalid: ${e.message}`));
33
- process.exit(1);
34
- }
35
- }