@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.
package/README.md CHANGED
@@ -151,14 +151,6 @@ Exit codes: 0 set up, 1 failed, 2 changes shown but not made, 3 onboarding
151
151
  blocked, 4 not logged in, 5 the Haystack GitHub App is not installed, 6 set up
152
152
  but onboarding stopped before finishing (run it again).
153
153
 
154
- ### `haystack status`
155
-
156
- Check if your project is configured:
157
-
158
- ```bash
159
- haystack status
160
- ```
161
-
162
154
  ### `haystack login`
163
155
 
164
156
  Authenticate with GitHub (required for setup and secrets):
@@ -16,7 +16,7 @@ import { spawnSync } from 'node:child_process';
16
16
  import { existsSync, readFileSync } from 'node:fs';
17
17
  import { join, posix } from 'node:path';
18
18
  import { CAPTURE_KEY_PATTERN, CAPTURE_STATUS_PATH, CAPTURE_VERSION } from '../commands/capture-contract.js';
19
- import { haystackFetch, haystackJson } from '../utils/haystack-api.js';
19
+ import { haystackFetch, haystackJson, repoApiPath } from '../utils/haystack-api.js';
20
20
  export const CAPTURE_CONFIG_PATH = '.haystack/capture.json';
21
21
  export function captureConfigFile(appDir) {
22
22
  return join(appDir, CAPTURE_CONFIG_PATH);
@@ -100,8 +100,8 @@ export function parseOrigins(given) {
100
100
  /** Registers the application and its production origins (rule 8b): POST /api/agent/cloud-verifier/capture/key, a
101
101
  * repository member's call, idempotent (the same application gets the same key; new origins replace the old). */
102
102
  export async function registerCaptureApp(token, repository, app, origins) {
103
- const query = new URLSearchParams({ repository, app });
104
- const response = await haystackJson(`/api/agent/cloud-verifier/capture/key?${query}`, token, {
103
+ const query = new URLSearchParams({ app });
104
+ const response = await haystackJson(repoApiPath(repository, `/capture/key?${query}`), token, {
105
105
  method: 'POST',
106
106
  headers: { 'Content-Type': 'application/json' },
107
107
  body: JSON.stringify({ origins }),
@@ -116,8 +116,8 @@ export async function registerCaptureApp(token, repository, app, origins) {
116
116
  /** The application's registration as Haystack holds it now (GET /api/agent/cloud-verifier/capture/status, lane A): its key
117
117
  * and production origins, or null when the application is not registered (404). */
118
118
  export async function readCaptureRegistration(token, repository, app) {
119
- const query = new URLSearchParams({ repository, app });
120
- const response = await haystackFetch(`${CAPTURE_STATUS_PATH}?${query}`, token, {}, [404]);
119
+ const query = new URLSearchParams({ app });
120
+ const response = await haystackFetch(repoApiPath(repository, `${CAPTURE_STATUS_PATH}?${query}`), token, {}, [404]);
121
121
  if (response.status === 404) {
122
122
  await response.body?.cancel();
123
123
  return null;
@@ -7,7 +7,7 @@
7
7
  import { join, posix } from 'node:path';
8
8
  import chalk from 'chalk';
9
9
  import { CAPTURE_CONFIG_PATH, captureConfigDirs, readCaptureConfig } from '../capture/app-config.js';
10
- import { classifyHttpError } from '../utils/haystack-api.js';
10
+ import { classifyHttpError, repoApiPath } from '../utils/haystack-api.js';
11
11
  import { gatewayFetch } from './case-batch.js';
12
12
  import { CAPTURE_APPLICATION_ID, CAPTURE_WINDOW_PATH, captureChangedRoutes, } from './capture-contract.js';
13
13
  /** An app's application id from its record, or why the record cannot give one. */
@@ -34,9 +34,10 @@ async function readApp(gitRoot, app, repository, token, files, now) {
34
34
  return { app, applicationId: null, state: 'unavailable', reason: application.unreadable };
35
35
  const { applicationId } = application;
36
36
  try {
37
- const response = await gatewayFetch(`${CAPTURE_WINDOW_PATH}?repository=${encodeURIComponent(repository)}&app=${encodeURIComponent(applicationId)}`, token);
37
+ const path = repoApiPath(repository, `${CAPTURE_WINDOW_PATH}?app=${encodeURIComponent(applicationId)}`);
38
+ const response = await gatewayFetch(path, token);
38
39
  if (!response.ok)
39
- throw await classifyHttpError(response, `Haystack API ${CAPTURE_WINDOW_PATH}`);
40
+ throw await classifyHttpError(response, `Haystack API ${path}`);
40
41
  const answer = await response.json();
41
42
  if (answer.version !== 'capture-v1' || answer.applicationId !== applicationId)
42
43
  throw new Error('the capture window answer names another application');
@@ -10,7 +10,7 @@ export const CAPTURE_SCRIPT_MAX_BYTES = 8 * 1024;
10
10
  export const CAPTURE_ROUTE_ID_MAX = 200;
11
11
  /** Rule 4 Publishing: the largest route manifest (current.json and <release>.json), in bytes. */
12
12
  export const CAPTURE_MANIFEST_MAX_BYTES = 256 * 1024;
13
- export const CAPTURE_WINDOW_PATH = '/api/agent/cloud-verifier/capture/window';
13
+ export const CAPTURE_WINDOW_PATH = '/capture/window';
14
14
  /** Rule 8(e): telemetry older than this is reported stale; rule 7b: a capture window older than this is stale. */
15
15
  export const CAPTURE_EVIDENCE_FRESH_HOURS = 24;
16
16
  export const CAPTURE_ROUTE_UNKNOWN = 'unknown';
@@ -39,8 +39,8 @@ export function captureChangedRoutes(window, files, now) {
39
39
  }
40
40
  export const TELEMETRY_TOKEN_VERSION = 'telemetry-token-v1';
41
41
  export const TELEMETRY_ENVIRONMENT = 'production';
42
- export const TELEMETRY_TOKEN_PATH = '/api/agent/cloud-verifier/telemetry/token';
43
- export const TELEMETRY_SERVER_STATUS_PATH = '/api/agent/cloud-verifier/telemetry/server';
42
+ export const TELEMETRY_TOKEN_PATH = '/telemetry/token';
43
+ export const TELEMETRY_SERVER_STATUS_PATH = '/telemetry/server';
44
44
  export const TELEMETRY_TOKEN_SHA256 = /^[0-9a-f]{64}$/;
45
45
  /** Lane A's capture status: GET /api/agent/cloud-verifier/capture/status?repository=&app= (rule 8b's key registration). */
46
- export const CAPTURE_STATUS_PATH = '/api/agent/cloud-verifier/capture/status';
46
+ export const CAPTURE_STATUS_PATH = '/capture/status';
@@ -0,0 +1,196 @@
1
+ export const TERMINAL = new Set(['completed', 'prepared', 'incomplete', 'cancelled']);
2
+ export const VERDICTS = ['bug', 'cant-tell', 'being-judged', 'not-a-bug'];
3
+ export const STATUS_WORDS = {
4
+ queued: 'waiting for a machine',
5
+ preparing: 'getting ready',
6
+ crawling: 'exploring the app',
7
+ draining: 'finishing the last checks',
8
+ completed: 'finished',
9
+ prepared: 'built and ready to check',
10
+ incomplete: 'stopped before finishing',
11
+ cancelled: 'cancelled',
12
+ };
13
+ export const STAGE_WORDS = {
14
+ reconcile: 'cleaning up after an earlier try',
15
+ source: 'rebuilding your change',
16
+ worlds: 'building and starting the app with and without your change',
17
+ blast: 'waiting for the analysis of what your change touches',
18
+ knobs: "reading the app's settings",
19
+ prepare: 'setting up starting points next to your change',
20
+ crawl: 'exploring the app',
21
+ publish: 'saving the results',
22
+ cleanup: 'shutting down the machines it used',
23
+ };
24
+ export const ERROR_WORDS = {
25
+ 'source-mismatch': 'your change could not be rebuilt exactly as it was captured',
26
+ 'blast-unavailable': 'the analysis of what your change touches was not available',
27
+ 'world-build-failed': 'the app did not build or start, with or without your change',
28
+ 'prepare-failed': 'setting up starting points next to your change failed',
29
+ 'crawl-failed': 'exploring the app failed',
30
+ 'no-capacity': 'there was no room on the machines for it',
31
+ stalled: 'it stopped making progress',
32
+ 'lease-lost': 'the machine running it stopped responding',
33
+ cancelled: 'it was cancelled',
34
+ };
35
+ export const SPOT_WORDS = {
36
+ resolved: 'never reached',
37
+ loaded: 'loaded but never ran',
38
+ exposed: 'shown on screen',
39
+ activated: 'ran',
40
+ pressed: 'ran when its control was pressed',
41
+ observed: 'a difference seen',
42
+ confirmed: 'a difference seen and confirmed',
43
+ // The stage, not the verdict: a judged difference may be judged not a bug. The findings carry the verdicts.
44
+ judged: 'a difference seen, confirmed and judged',
45
+ };
46
+ export const VERDICT_WORDS = {
47
+ bug: 'Bug',
48
+ 'cant-tell': "Can't tell",
49
+ 'being-judged': 'Still being judged',
50
+ 'not-a-bug': 'Not a bug',
51
+ };
52
+ export function plural(count, noun) {
53
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
54
+ }
55
+ export function errorWords(error) {
56
+ return error.code === 'stalled'
57
+ ? `${ERROR_WORDS.stalled} while ${STAGE_WORDS[error.stage]}`
58
+ : ERROR_WORDS[error.code];
59
+ }
60
+ /** What a running crawl is doing now: its stage, except while it waits for a
61
+ * machine or finishes its last checks, which its status says better. */
62
+ export function currentStep(view) {
63
+ return view.status === 'queued' || view.status === 'draining' ? STATUS_WORDS[view.status] : STAGE_WORDS[view.stage];
64
+ }
65
+ /** The crawl's results: its sealed manifest, else (amendment 9) its answer at the budget, which has the same findings and reach. */
66
+ export function results(view) {
67
+ return view.manifest ?? (view.answer?.status === 'answered' ? view.answer : null);
68
+ }
69
+ /** The status the command reports: a running crawl that has answered (amendment 9: its budget is up and its work is
70
+ * done; only its shutdown remains) reads as completed. */
71
+ export function reportedStatus(view) {
72
+ return !TERMINAL.has(view.status) && view.answer?.status === 'answered' ? 'completed' : view.status;
73
+ }
74
+ export function headline(view) {
75
+ const bugs = results(view)?.bugs ?? view.totals.bugs;
76
+ const found = bugs > 0 ? ` It found ${plural(bugs, 'bug')} before it stopped.` : '';
77
+ switch (reportedStatus(view)) {
78
+ case 'completed':
79
+ return bugs > 0 ? `Found ${plural(bugs, 'bug')} in your change.` : 'No bugs found in your change.';
80
+ case 'incomplete': {
81
+ const error = results(view)?.error ?? view.error;
82
+ return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
83
+ }
84
+ case 'prepared':
85
+ // Amendment 12: the turn-end hook built and froze this change; `haystack verify` asks for the crawl itself.
86
+ return 'Your change is built and ready to check, but was not crawled yet. Run `haystack verify` to check it.';
87
+ case 'cancelled':
88
+ return `The crawl was cancelled.${found}`;
89
+ default: {
90
+ const so = bugs > 0 ? ` ${plural(bugs, 'bug')} found so far.` : '';
91
+ if (view.cancelRequested) {
92
+ return `Being cancelled: ${currentStep(view)}.${so}`;
93
+ }
94
+ return `Still running: ${currentStep(view)}.${so}`;
95
+ }
96
+ }
97
+ }
98
+ export function verifyReport(view) {
99
+ const status = reportedStatus(view);
100
+ const published = results(view);
101
+ const error = published?.error ?? view.error;
102
+ const explored = published !== null && view.clocks.crawlStartedAt !== null;
103
+ const findings = explored ? VERDICTS.flatMap(verdict => published.findings.filter(finding => finding.verdict === verdict)) : [];
104
+ const ideaWords = { covered: 'tried', blocked: 'could not try', unfinished: 'not finished' };
105
+ return {
106
+ runId: view.runId,
107
+ repository: view.repository,
108
+ title: view.changeTitle,
109
+ baseCommit: view.baseCommit,
110
+ workCommit: view.workCommit,
111
+ status,
112
+ headline: headline(view),
113
+ bugs: published?.bugs ?? view.totals.bugs,
114
+ findings: findings.map(finding => ({
115
+ id: finding.id,
116
+ verdict: finding.verdict,
117
+ why: finding.why,
118
+ start: finding.start,
119
+ steps: finding.steps,
120
+ outsideServices: finding.standIns && (finding.standIns.old.length > 0 || finding.standIns.new.length > 0)
121
+ ? { withoutChange: finding.standIns.old, withChange: finding.standIns.new }
122
+ : null,
123
+ })),
124
+ spots: explored ? published.reach.spots.map(spot => ({
125
+ file: spot.file,
126
+ line: spot.line,
127
+ reached: spot.furthest === null ? 'never reached' : SPOT_WORDS[spot.furthest],
128
+ reachedBy: spot.reachedBy && { start: spot.reachedBy.start, steps: spot.reachedBy.steps, page: spot.reachedBy.page },
129
+ findingIds: spot.findingIds,
130
+ })) : [],
131
+ ideas: explored && published.ideas !== undefined
132
+ ? published.ideas.map(idea => ({ text: idea.text, result: ideaWords[idea.state], reason: idea.reason, findingIds: idea.findingIds }))
133
+ : null,
134
+ neverRan: explored ? {
135
+ files: published.reach.unreachedChangedFiles,
136
+ functions: published.reach.blast === null ? null : { ran: published.reach.blast.ran, total: published.reach.blast.functions },
137
+ } : null,
138
+ notFinishedInTime: explored ? published.notFinishedInTime : 0,
139
+ outsideServiceGaps: explored ? published.standIns?.gaps ?? [] : [],
140
+ error: error ? { code: error.code, message: error.message } : null,
141
+ machinesNotProvenShutDown: TERMINAL.has(view.status) ? view.totals.cleanupUnproven : 0,
142
+ };
143
+ }
144
+ /** The report as markdown: headings an agent can navigate, bugs first, each with the steps to see it. */
145
+ export function reportMarkdown(report) {
146
+ const lines = [`# ${report.headline}`, '',
147
+ `Change: ${report.title}`, `Check ${report.runId} of ${report.repository}: base ${report.baseCommit.slice(0, 12)}, change ${report.workCommit.slice(0, 12)}.`];
148
+ if (report.error && report.status !== 'completed')
149
+ lines.push(`Details: ${report.error.message}`);
150
+ if (report.neverRan === null) {
151
+ lines.push('', TERMINAL.has(report.status)
152
+ ? 'Nothing about the change was measured: the crawl did not explore the app.'
153
+ : 'Where the change shows up, and what the crawl found, appear when it finishes.');
154
+ return lines.join('\n') + '\n';
155
+ }
156
+ lines.push('', '## Where the change shows up in the app');
157
+ if (report.spots.length === 0)
158
+ lines.push('No changed spots were recorded.');
159
+ for (const spot of report.spots) {
160
+ lines.push(`- \`${spot.file}:${spot.line}\`: ${spot.reached}${spot.findingIds.length ? ` (${plural(spot.findingIds.length, 'finding')})` : ''}`);
161
+ if (spot.reachedBy)
162
+ lines.push(` - reached by: ${[spot.reachedBy.start, ...spot.reachedBy.steps].join(' > ')}${spot.reachedBy.page ? ` (page ${spot.reachedBy.page})` : ''}`);
163
+ }
164
+ lines.push('', '## What the crawl found');
165
+ if (report.findings.length === 0)
166
+ lines.push('No confirmed differences between the app with and without the change.');
167
+ report.findings.forEach((finding, index) => {
168
+ lines.push(`${index + 1}. **${VERDICT_WORDS[finding.verdict]}** (\`${finding.id}\`): ${finding.why ?? 'no reason was recorded.'}`, ` Starting from ${finding.start}:`, ...finding.steps.map((step, n) => ` ${n + 1}. ${step}`));
169
+ if (finding.outsideServices) {
170
+ lines.push(` Outside services on this path, answered by stand-ins: without the change ${finding.outsideServices.withoutChange.join('; ') || 'none'};`
171
+ + ` with it ${finding.outsideServices.withChange.join('; ') || 'none'}.`);
172
+ }
173
+ });
174
+ if (report.notFinishedInTime > 0) {
175
+ lines.push(`${plural(report.notFinishedInTime, 'difference')} ${report.notFinishedInTime === 1 ? 'was' : 'were'} still being checked when its time ran out.`);
176
+ }
177
+ if (report.ideas !== null) {
178
+ lines.push('', '## Your ideas');
179
+ for (const idea of report.ideas)
180
+ lines.push(`- ${idea.result}: ${idea.text}. ${idea.reason}`);
181
+ }
182
+ lines.push('', '## What the crawl never ran');
183
+ if (report.neverRan.files.length === 0)
184
+ lines.push('Every changed file ran at least once.');
185
+ else
186
+ lines.push('Changed files no path ran:', ...report.neverRan.files.map(file => `- \`${file}\``));
187
+ if (report.neverRan.functions)
188
+ lines.push(`${report.neverRan.functions.ran} of ${plural(report.neverRan.functions.total, 'affected function')} ran.`);
189
+ if (report.outsideServiceGaps.length > 0) {
190
+ lines.push('', '## Outside services the copy could not imitate', 'Missing coverage of the environment, not bugs in the change:', ...report.outsideServiceGaps.map(gap => `- ${gap}`));
191
+ }
192
+ if (report.machinesNotProvenShutDown > 0) {
193
+ lines.push('', `${plural(report.machinesNotProvenShutDown, 'machine')} it used could not be proven shut down.`);
194
+ }
195
+ return lines.join('\n') + '\n';
196
+ }
@@ -14,8 +14,8 @@ import chalk from 'chalk';
14
14
  import { DATABASE_PROFILE_UPLOAD_REQUEST_VERSION, parseDatabaseProfileUpload, } from './db-profile-contract.js';
15
15
  import { readDatabaseProfileFile } from './db-profile-show.js';
16
16
  import { resolveAuthContext } from '../utils/auth.js';
17
- import { classifyHttpError, haystackApiUrl, readWithRetries, transientReadFailure } from '../utils/haystack-api.js';
18
- export const DATABASE_PROFILES_PATH = '/api/agent/cloud-verifier/database-profiles';
17
+ import { classifyHttpError, haystackApiUrl, readWithRetries, transientReadFailure, repoApiPath } from '../utils/haystack-api.js';
18
+ export const DATABASE_PROFILES_PATH = '/database-profiles';
19
19
  /** GitHub's owner and repository name alphabets. */
20
20
  const REPOSITORY = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9_.-]{1,100})$/u;
21
21
  /** Run-map dependency ids are stable kebab-case ids (the same check as
@@ -60,7 +60,7 @@ export async function uploadDatabaseProfile(request, token) {
60
60
  }
61
61
  }
62
62
  async function uploadDatabaseProfileOnce(request, token) {
63
- const response = await fetch(haystackApiUrl(DATABASE_PROFILES_PATH), {
63
+ const response = await fetch(haystackApiUrl(repoApiPath(request.repository, DATABASE_PROFILES_PATH)), {
64
64
  method: 'POST',
65
65
  headers: {
66
66
  Authorization: `Bearer ${token}`,
@@ -53,7 +53,7 @@ export async function feedbackCommand(words, options) {
53
53
  const message = words.length === 1 && words[0] === '-' ? readFileSync(0, 'utf8') : words.join(' ');
54
54
  const request = buildFeedbackRequest(message, options, currentRepository());
55
55
  const { token } = await resolveAuthContext({ preferredLogin: options.account });
56
- await haystackJson('/api/cli/feedback', token, {
56
+ await haystackJson('/v1/feedback', token, {
57
57
  method: 'POST',
58
58
  headers: { 'Content-Type': 'application/json' },
59
59
  body: JSON.stringify(request),
@@ -26,7 +26,7 @@ import { createRequire } from 'node:module';
26
26
  import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import chalk from 'chalk';
29
- import { classifyHttpError } from '../utils/haystack-api.js';
29
+ import { classifyHttpError, repoApiPath } from '../utils/haystack-api.js';
30
30
  import { gatewayFetch } from './case-batch.js';
31
31
  import { CAPTURE_EVIDENCE_FRESH_HOURS, CAPTURE_STATUS_PATH, } from './capture-contract.js';
32
32
  import { captureAnswers, planCapture } from './init-capture.js';
@@ -961,7 +961,7 @@ async function readJson(path, token) {
961
961
  }
962
962
  /** Lane A's registration of the application: its newest capture event, or null when it is not registered (404) or has none. */
963
963
  async function captureReceived(repository, app, token) {
964
- const status = await readJson(`${CAPTURE_STATUS_PATH}?repository=${encodeURIComponent(repository)}&app=${encodeURIComponent(app)}`, token);
964
+ const status = await readJson(repoApiPath(repository, `${CAPTURE_STATUS_PATH}?app=${encodeURIComponent(app)}`), token);
965
965
  return status === null ? null : received(status.lastSeenAt, status.release);
966
966
  }
967
967
  function withReceived(part, evidence) {
@@ -56,7 +56,7 @@ export const AGENT_NOTE = [
56
56
  ' `haystack verify answer <question-id> <choice>`.',
57
57
  '- Read its short report. `--json` prints the same report as data; `--raw` prints the crawl\'s whole record (large), for scripts;',
58
58
  ' `haystack verify --help` explains the output and exit codes.',
59
- '- If `haystack` is not installed, run each command as `npx -y @haystackeditor/cli@latest <command>`.',
59
+ '- If `haystack` is not installed, run each command as `npx -y -p @haystackeditor/cli@latest haystack <command>`.',
60
60
  '- If Haystack fails, confuses you or gets in your way, say so in a sentence with',
61
61
  ' `haystack feedback "<what happened>"` and keep going: the Haystack team sees it at once.',
62
62
  NOTE_END,
@@ -22,7 +22,7 @@ import { basename, dirname, join, resolve } from 'node:path';
22
22
  import { createInterface } from 'node:readline/promises';
23
23
  import chalk from 'chalk';
24
24
  import { resolveAuthContext } from '../utils/auth.js';
25
- import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
25
+ import { classifyHttpError, HaystackApiError, repoApiPath } from '../utils/haystack-api.js';
26
26
  import { gatewayFetch } from './case-batch.js';
27
27
  import { TELEMETRY_ENVIRONMENT, TELEMETRY_SERVER_STATUS_PATH, TELEMETRY_TOKEN_PATH, TELEMETRY_TOKEN_SHA256, TELEMETRY_TOKEN_VERSION, } from './capture-contract.js';
28
28
  /** GitHub's owner and repository name alphabets. */
@@ -51,7 +51,7 @@ async function confirmed(question) {
51
51
  }
52
52
  /** GET the repository's server-telemetry status: its active token's hash, the endpoint, the newest flush. */
53
53
  export async function readTelemetryServerStatus(repository, token) {
54
- const path = `${TELEMETRY_SERVER_STATUS_PATH}?repository=${encodeURIComponent(repository)}`;
54
+ const path = repoApiPath(repository, TELEMETRY_SERVER_STATUS_PATH);
55
55
  const response = await gatewayFetch(path, token);
56
56
  if (!response.ok)
57
57
  throw await classifyHttpError(response, `Haystack API ${TELEMETRY_SERVER_STATUS_PATH}`);
@@ -199,7 +199,7 @@ async function mintUnderLock(repository, file, authToken, options) {
199
199
  let answer;
200
200
  try {
201
201
  const request = { repository, tokenSha256, rotate: options.rotate === true };
202
- const response = await gatewayFetch(`${TELEMETRY_TOKEN_PATH}?repository=${encodeURIComponent(repository)}`, authToken, {
202
+ const response = await gatewayFetch(repoApiPath(repository, TELEMETRY_TOKEN_PATH), authToken, {
203
203
  method: 'POST', body: JSON.stringify(request),
204
204
  });
205
205
  if (!response.ok)
@@ -5,10 +5,10 @@
5
5
  * - `haystack login --headless [--label NAME] [--scope repo:owner/name,...]`
6
6
  * Opens the browser to mint a token via the auth-worker, then writes it
7
7
  * locally so subsequent CLI calls auth via Bearer.
8
- * - `haystack tokens list`
8
+ * - `haystack auth tokens`
9
9
  * Lists tokens on the server side (the local file only stores ONE
10
10
  * active token at a time; the server may have many tied to your user).
11
- * - `haystack tokens revoke <id>`
11
+ * - `haystack auth revoke <id>`
12
12
  * Revokes a server-side token. If the revoked token is the one
13
13
  * currently on disk, the local file is wiped so the next CLI call
14
14
  * doesn't keep using a dead token.
@@ -123,12 +123,12 @@ export async function headlessLoginCommand(opts) {
123
123
  console.log(` ${chalk.dim('path:')} ${path}`);
124
124
  console.log(` ${chalk.dim('id:')} ${result.id}`);
125
125
  console.log(chalk.dim(' Subsequent `haystack` calls will use this token.'));
126
- console.log(chalk.dim(' Revoke server-side with: haystack tokens revoke ' + result.id));
126
+ console.log(chalk.dim(' Revoke server-side with: haystack auth revoke ' + result.id));
127
127
  }
128
128
  export async function headlessLogoutCommand() {
129
129
  await clearHeadlessToken();
130
130
  console.log(chalk.green('✓ Headless token removed from disk.'));
131
- console.log(chalk.dim(' Server-side token is still active. Revoke it with `haystack tokens revoke <id>`.'));
131
+ console.log(chalk.dim(' Server-side token is still active. Revoke it with `haystack auth revoke <id>`.'));
132
132
  }
133
133
  export async function listTokensCommand(opts) {
134
134
  const token = await loadToken();
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { resolveAuthContext } from '../utils/auth.js';
3
- import { haystackJson } from '../utils/haystack-api.js';
3
+ import { haystackJson, repoApiPath } from '../utils/haystack-api.js';
4
4
  const RUN_ID = /^cv_[0-9a-f]{48}$/;
5
5
  const CASE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
6
6
  const ACTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@@ -27,8 +27,8 @@ async function context(runId, repository) {
27
27
  const { token } = await resolveAuthContext({ owner, repo });
28
28
  return {
29
29
  token,
30
- path: `/api/agent/cloud-verifier/runs/${runId}/cases`,
31
- query: `?repository=${encodeURIComponent(repository)}`,
30
+ path: repoApiPath(repository, `/runs/${runId}/cases`),
31
+ query: '',
32
32
  };
33
33
  }
34
34
  export async function verifyCasesCommand(runId, options) {
@@ -1,5 +1,5 @@
1
1
  import { resolveAuthContext } from '../utils/auth.js';
2
- import { haystackJson, HaystackApiError } from '../utils/haystack-api.js';
2
+ import { haystackJson, HaystackApiError, repoApiPath } from '../utils/haystack-api.js';
3
3
  const HEALTH = ['healthy', 'degraded', 'infrastructure-error', 'not-executed', 'unknown'];
4
4
  const LIFECYCLES = ['complete', 'failed', 'cancelled', 'budget-stopped', 'account-action'];
5
5
  const RUN_ID = /^cv_[0-9a-f]{48}$/;
@@ -139,7 +139,7 @@ export async function verifyHistoryCommand(repository, options) {
139
139
  const auth = await resolveAuthContext({ preferredLogin: options.account, owner, repo: name });
140
140
  let response;
141
141
  try {
142
- response = await haystackJson(`/api/agent/cloud-verifier/history/${encodeURIComponent(owner)}/${encodeURIComponent(name)}?limit=${limit}`, auth.token, { method: 'GET' });
142
+ response = await haystackJson(repoApiPath(`${owner}/${name}`, `/runs?limit=${limit}`), auth.token, { method: 'GET' });
143
143
  }
144
144
  catch (error) {
145
145
  if (error instanceof HaystackApiError && error.status === 404) {
@@ -10,17 +10,14 @@
10
10
  import chalk from 'chalk';
11
11
  import { withSchema } from '../schema.js';
12
12
  import { resolveAuthContext } from '../utils/auth.js';
13
- import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
13
+ import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, repoApiPath, } from '../utils/haystack-api.js';
14
14
  import { trackOnboardingEvent } from '../utils/telemetry.js';
15
15
  import { gatewayFetch } from './case-batch.js';
16
16
  import { ONBOARDING_STAGES, ONBOARDING_VERSION, } from './onboarding-contract.js';
17
17
  import { sameNotes } from './onboarding-notes.js';
18
18
  import { deriveBaseSha, EXPLICIT_WALL_MS, resolveOriginRepository } from './verify-precompute.js';
19
19
  import { findGitRoot } from '../utils/hooks.js';
20
- const ONBOARDING_PATH = '/api/agent/cloud-verifier/onboarding';
21
- const FACTS_PATH = `${ONBOARDING_PATH}/facts`;
22
- const ANSWERS_PATH = `${ONBOARDING_PATH}/answers`;
23
- const NOTES_PATH = `${ONBOARDING_PATH}/notes`;
20
+ const onboardingPath = (repository, rest = '') => repoApiPath(repository, `/onboarding${rest}`);
24
21
  const ONBOARD_RUN_ID = /^ob_[0-9a-f]{48}$/;
25
22
  const SHA256 = /^[0-9a-f]{64}$/;
26
23
  const COMMIT = /^[0-9a-f]{40}$/;
@@ -155,11 +152,11 @@ async function body(response, what) {
155
152
  export async function readOnboardingStatus(repository, baseCommit, token, onboardRunId = null) {
156
153
  if (!COMMIT.test(baseCommit))
157
154
  throw new Error(`Invalid base commit ${baseCommit}.`);
158
- const path = `${ONBOARDING_PATH}?repository=${encodeURIComponent(repository)}&baseCommit=${baseCommit}`
159
- + (onboardRunId === null ? '' : `&onboardRunId=${encodeURIComponent(onboardRunId)}`);
155
+ const path = onboardingPath(repository, `?base=${baseCommit}`)
156
+ + (onboardRunId === null ? '' : `&run=${encodeURIComponent(onboardRunId)}`);
160
157
  const response = await gatewayFetch(path, token);
161
158
  if (!response.ok)
162
- throw await classifyHttpError(response, `Haystack API ${ONBOARDING_PATH}`);
159
+ throw await classifyHttpError(response, `Haystack API ${path}`);
163
160
  return parseOnboardingStatus(await body(response, 'a status'), { repository, baseCommit });
164
161
  }
165
162
  /** Rule 12's start POST: onboard this base now, before any change exists. Idempotent: a
@@ -167,11 +164,11 @@ export async function readOnboardingStatus(repository, baseCommit, token, onboar
167
164
  export async function startOnboarding(repository, baseCommit, token) {
168
165
  if (!COMMIT.test(baseCommit))
169
166
  throw new Error(`Invalid base commit ${baseCommit}.`);
170
- const response = await gatewayFetch(`${ONBOARDING_PATH}?repository=${encodeURIComponent(repository)}`, token, {
167
+ const response = await gatewayFetch(onboardingPath(repository), token, {
171
168
  method: 'POST', body: JSON.stringify({ repository, baseCommit }),
172
169
  });
173
170
  if (!response.ok)
174
- throw await classifyHttpError(response, `Haystack API ${ONBOARDING_PATH}`);
171
+ throw await classifyHttpError(response, `Haystack API ${onboardingPath(repository)}`);
175
172
  return parseOnboardingStatus(await body(response, 'a status'), { repository, baseCommit });
176
173
  }
177
174
  /** How often a command reads onboarding status while it runs. The status route holds no read (a crawl's reads wait for a
@@ -357,9 +354,9 @@ function repositoryOf(value) {
357
354
  return `${origin.owner}/${origin.repository}`;
358
355
  }
359
356
  export async function readFacts(repository, token) {
360
- const response = await gatewayFetch(`${FACTS_PATH}?repository=${encodeURIComponent(repository)}`, token);
357
+ const response = await gatewayFetch(onboardingPath(repository, '/facts'), token);
361
358
  if (!response.ok)
362
- throw await classifyHttpError(response, `Haystack API ${FACTS_PATH}`);
359
+ throw await classifyHttpError(response, `Haystack API ${onboardingPath(repository, '/facts')}`);
363
360
  return parseFactsView(await body(response, 'facts'), repository);
364
361
  }
365
362
  /** `haystack verify answer <question-id> <choice>`: files the answer to an open
@@ -379,7 +376,7 @@ export async function verifyAnswerCommand(questionId, choice, options) {
379
376
  if (!question.choices.includes(choice)) {
380
377
  throw new Error(`"${choice}" is not one of the question's choices: ${question.choices.map(item => JSON.stringify(item)).join(', ')}.`);
381
378
  }
382
- const response = await gatewayFetch(`${ANSWERS_PATH}?repository=${encodeURIComponent(repository)}`, auth.token, {
379
+ const response = await gatewayFetch(onboardingPath(repository, '/answers'), auth.token, {
383
380
  method: 'POST', body: JSON.stringify({ repository, expectedFactsDigest: facts.factsDigest, questionId, answer: choice }),
384
381
  });
385
382
  if (response.status === 409 && attempt < 3) {
@@ -387,7 +384,7 @@ export async function verifyAnswerCommand(questionId, choice, options) {
387
384
  continue;
388
385
  }
389
386
  if (!response.ok)
390
- throw await classifyHttpError(response, `Haystack API ${ANSWERS_PATH}`);
387
+ throw await classifyHttpError(response, `Haystack API ${onboardingPath(repository, '/answers')}`);
391
388
  const view = parseFactsView(await body(response, 'facts'), repository);
392
389
  if (options.json) {
393
390
  process.stdout.write(`${JSON.stringify(withSchema('verify-answer', { facts: view }), null, 2)}\n`);
@@ -407,7 +404,7 @@ export async function writeOnboardingNotes(repository, notes, token) {
407
404
  if (sameNotes(facts.facts.notes, notes))
408
405
  return 'unchanged';
409
406
  const request = { repository, expectedFactsDigest: facts.factsDigest, notes };
410
- const response = await gatewayFetch(`${NOTES_PATH}?repository=${encodeURIComponent(repository)}`, token, {
407
+ const response = await gatewayFetch(onboardingPath(repository, '/notes'), token, {
411
408
  method: 'POST', body: JSON.stringify(request),
412
409
  });
413
410
  if (response.status === 409 && attempt < 3) {
@@ -415,7 +412,7 @@ export async function writeOnboardingNotes(repository, notes, token) {
415
412
  continue;
416
413
  }
417
414
  if (!response.ok)
418
- throw await classifyHttpError(response, `Haystack API ${NOTES_PATH}`);
415
+ throw await classifyHttpError(response, `Haystack API ${onboardingPath(repository, '/notes')}`);
419
416
  if (!sameNotes(parseFactsView(await body(response, 'facts'), repository).facts.notes, notes))
420
417
  invalid('the notes it answered with');
421
418
  return 'written';
@@ -8,7 +8,7 @@ import chalk from 'chalk';
8
8
  import { NotLoggedInError, resolveAuthContext } from '../utils/auth.js';
9
9
  import { findGitRoot, parseRemoteUrl, } from '../utils/git.js';
10
10
  import { resolveVerifyBaseSha } from '../utils/verify-base.js';
11
- import { HaystackApiError, haystackFetch, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_REQUIRED_CODE, } from '../utils/haystack-api.js';
11
+ import { HaystackApiError, haystackFetch, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_REQUIRED_CODE, repoApiPath, } from '../utils/haystack-api.js';
12
12
  import { trackPrecomputeEvent } from '../utils/telemetry.js';
13
13
  import { findSecretsInPatch, isSecretBearingPath } from '../utils/secret-paths.js';
14
14
  import { startPrecomputeDelivery } from './precompute-delivery.js';
@@ -325,8 +325,7 @@ function precomputeResult(response) {
325
325
  * service no longer has it). */
326
326
  async function uploadSourcePatch(request, token, signal, force = false) {
327
327
  const gzipped = Buffer.from(request.patchGzBase64, 'base64');
328
- const path = `/api/agent/cloud-verifier/source-patches/${request.patchSha256}`
329
- + `?owner=${encodeURIComponent(request.owner)}&repository=${encodeURIComponent(request.repository)}`;
328
+ const path = repoApiPath(`${request.owner}/${request.repository}`, `/patches/${request.patchSha256}`);
330
329
  const found = force ? null : await haystackFetch(path, token, { method: 'HEAD', signal }, [404]);
331
330
  if (found === null || found.status === 404) {
332
331
  await haystackFetch(path, token, { method: 'PUT', headers: { 'Content-Type': 'application/gzip' }, body: gzipped, signal });
@@ -339,7 +338,7 @@ async function submitCapture(request, token, signal) {
339
338
  const { patchGzBase64: _inline, ...named } = request;
340
339
  const submit = async (force) => {
341
340
  const patchBytes = await uploadSourcePatch(request, token, signal, force);
342
- return haystackJson('/api/agent/cloud-verifier/front-half', token, { method: 'POST',
341
+ return haystackJson(repoApiPath(`${request.owner}/${request.repository}`, '/checks'), token, { method: 'POST',
343
342
  headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...named, patchBytes }), signal });
344
343
  };
345
344
  try {