@haystackeditor/cli 0.26.0 → 0.27.1

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);
@@ -62,7 +62,8 @@ export function recordedApps(gitRoot, candidates) {
62
62
  return readCaptureConfig(join(gitRoot, dir))?.app === dir;
63
63
  }
64
64
  catch (error) {
65
- throw new Error(`${posix.join(dir, CAPTURE_CONFIG_PATH)}: ${error instanceof Error ? error.message : String(error)}`);
65
+ const message = error instanceof Error ? error.message : String(error);
66
+ throw new Error(dir === '.' ? message : message.replace(CAPTURE_CONFIG_PATH, posix.join(dir, CAPTURE_CONFIG_PATH)));
66
67
  }
67
68
  });
68
69
  }
@@ -100,8 +101,8 @@ export function parseOrigins(given) {
100
101
  /** Registers the application and its production origins (rule 8b): POST /api/agent/cloud-verifier/capture/key, a
101
102
  * repository member's call, idempotent (the same application gets the same key; new origins replace the old). */
102
103
  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, {
104
+ const query = new URLSearchParams({ app });
105
+ const response = await haystackJson(repoApiPath(repository, `/capture/key?${query}`), token, {
105
106
  method: 'POST',
106
107
  headers: { 'Content-Type': 'application/json' },
107
108
  body: JSON.stringify({ origins }),
@@ -116,8 +117,8 @@ export async function registerCaptureApp(token, repository, app, origins) {
116
117
  /** The application's registration as Haystack holds it now (GET /api/agent/cloud-verifier/capture/status, lane A): its key
117
118
  * and production origins, or null when the application is not registered (404). */
118
119
  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]);
120
+ const query = new URLSearchParams({ app });
121
+ const response = await haystackFetch(repoApiPath(repository, `${CAPTURE_STATUS_PATH}?${query}`), token, {}, [404]);
121
122
  if (response.status === 404) {
122
123
  await response.body?.cancel();
123
124
  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),
@@ -21,12 +21,12 @@
21
21
  * stores or reads one), production's environment, the start command, the deploy and the off switches (13c).
22
22
  */
23
23
  import { execFileSync, spawnSync } from 'node:child_process';
24
- import { existsSync, lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
24
+ import { accessSync, constants, existsSync, lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
25
25
  import { createRequire } from 'node:module';
26
- import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
26
+ import { delimiter, 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';
@@ -103,7 +103,8 @@ function section(json, name) {
103
103
  }
104
104
  const LOCKFILES = [['pnpm-lock.yaml', 'pnpm'], ['package-lock.json', 'npm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun']];
105
105
  /** The package manager that owns the app's lockfile: the nearest `packageManager` field, else the nearest lockfile, from
106
- * the app up to the repository root. null: no lockfile, so the dependency is written into package.json itself. */
106
+ * the app up to the repository root (`from` says which). null: no lockfile, so the dependency is written into package.json
107
+ * itself. */
107
108
  function packageManager(gitRoot, appDir) {
108
109
  for (let directory = appDir;; directory = dirname(directory)) {
109
110
  const manifest = join(directory, 'package.json');
@@ -111,16 +112,32 @@ function packageManager(gitRoot, appDir) {
111
112
  const declared = readPackage(manifest).json.packageManager;
112
113
  const name = typeof declared === 'string' ? /^(pnpm|npm|yarn|bun)@/.exec(declared)?.[1] : undefined;
113
114
  if (name)
114
- return { name: name, root: directory };
115
+ return { name: name, root: directory, from: `${inside(gitRoot, manifest)}'s packageManager field` };
115
116
  }
116
117
  for (const [lockfile, name] of LOCKFILES) {
117
118
  if (isFile(join(directory, lockfile)))
118
- return { name, root: directory };
119
+ return { name, root: directory, from: inside(gitRoot, join(directory, lockfile)) };
119
120
  }
120
121
  if (directory === gitRoot || dirname(directory) === directory)
121
122
  return null;
122
123
  }
123
124
  }
125
+ /** Whether `command` is an executable on PATH (a package manager init would run, checked before it plans to). */
126
+ function onPath(command) {
127
+ const extensions = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
128
+ for (const directory of (process.env.PATH ?? '').split(delimiter)) {
129
+ if (directory === '')
130
+ continue;
131
+ for (const extension of extensions) {
132
+ try {
133
+ accessSync(join(directory, `${command}${extension}`), constants.X_OK);
134
+ return true;
135
+ }
136
+ catch { /* not in this directory */ }
137
+ }
138
+ }
139
+ return false;
140
+ }
124
141
  /** The first release whose sites record repository-relative paths (what a registered tenant's src_prefix '' relies on)
125
142
  * and that ships the `./next` entry: earlier releases are not compatible with either integration. */
126
143
  const CLI_COMPATIBLE_SINCE = [0, 24, 0];
@@ -216,14 +233,29 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
216
233
  const spec = `${CLI_PACKAGE}@${version}`;
217
234
  const dev = field === 'devDependencies';
218
235
  const workspaceRoot = isFile(join(appDir, 'pnpm-workspace.yaml')) ? ['--workspace-root'] : [];
219
- // Each package manager's add, as it changes no lockfile entry but the CLI's own (lockfile-pin.ts): npm with its tree
220
- // nested under the CLI (hoisting would replace packages the app already locks), pnpm through the merge.
236
+ // Each package manager's add, as it changes no lockfile entry but the CLI's own (lockfile-pin.ts; pnpm through the merge).
221
237
  const argv = manager === null ? null : {
222
238
  pnpm: ['pnpm', 'add', '--save-exact', dev ? '--save-dev' : '--save-prod', ...workspaceRoot, spec],
223
- npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', '--install-strategy=nested', spec],
239
+ npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', spec],
224
240
  yarn: ['yarn', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
225
241
  bun: ['bun', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
226
242
  }[manager.name];
243
+ const appPath = inside(gitRoot, appDir) || '.';
244
+ // npm re-resolves entries the CLI does not own whatever the flags (39 in fosrl/pangolin, with --install-strategy=nested
245
+ // too), so init never rewrites an npm lockfile: the person adds the CLI with npm and sees the lockfile's diff.
246
+ if (manager?.name === 'npm' && argv) {
247
+ return { kind: 'manual', why: `${appPath} uses npm (${manager.from}), whose install also re-resolves lockfile entries the CLI does not own, so init`
248
+ + ` does not run it. Run \`${argv.join(' ')}\` in ${appPath}, check package-lock.json's diff, then run \`haystack init\` again` };
249
+ }
250
+ // A package manager that is not installed is never run (rule 13: telemetry never fails init).
251
+ if (manager && argv && !onPath(manager.name)) {
252
+ const provide = (manager.name === 'pnpm' || manager.name === 'yarn') && onPath('corepack')
253
+ ? `run \`corepack enable ${manager.name}\` (corepack is installed here)`
254
+ : `install it (${manager.name === 'bun' ? 'https://bun.sh' : manager.name === 'yarn' ? 'https://yarnpkg.com/getting-started/install' : 'https://pnpm.io/installation'})`;
255
+ return { kind: 'manual', why: `${appPath} uses ${manager.name} (${manager.from}), which is not installed here, so init did not run it. Either`
256
+ + ` ${provide} and run \`haystack init\` again, or run \`${argv.join(' ')}\` in ${appPath} wherever ${manager.name} is installed and then`
257
+ + ' run `haystack init` again' };
258
+ }
227
259
  const action = promoting ? `Moved from devDependencies to dependencies at ${version}` : `Pinned to ${version}`;
228
260
  const lockfileNote = manager?.name === 'pnpm'
229
261
  ? `, keeping every other entry of pnpm's lockfile as it was (pnpm's own add re-resolves packages the CLI shares with the app; init adds only the CLI's entries, then \`pnpm install --frozen-lockfile\`)`
@@ -247,12 +279,14 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
247
279
  }
248
280
  /** The lockfile a package manager writes, in a format lockfile-pin.ts reads (bun's binary lockb aside). */
249
281
  function textLockfile(manager) {
250
- const name = { pnpm: 'pnpm-lock.yaml', npm: 'package-lock.json', yarn: 'yarn.lock', bun: 'bun.lock' }[manager.name];
282
+ if (manager.name === 'npm')
283
+ throw new Error('init never runs npm to pin the CLI');
284
+ const name = { pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock', bun: 'bun.lock' }[manager.name];
251
285
  const path = join(manager.root, name);
252
286
  return isFile(path) ? { path, format: manager.name } : null;
253
287
  }
254
288
  /** Runs the add, so that the lockfile gains the CLI's entries and nothing else moves (rule 13); throws (init then restores
255
- * every file) when the package manager moved anything else. */
289
+ * telemetry's files and leaves telemetry manual) when the package manager moved anything else. */
256
290
  function pin(manager, appDir, argv) {
257
291
  const run = (command) => { execFileSync(command[0], command.slice(1), { cwd: appDir, stdio: ['ignore', 2, 2] }); };
258
292
  const lockfile = textLockfile(manager);
@@ -272,8 +306,8 @@ function pin(manager, appDir, argv) {
272
306
  const moved = lockfileDrift(lockfile.format, before, readFileSync(lockfile.path, 'utf8'), importer, CLI_PACKAGE);
273
307
  if (moved.length > 0) {
274
308
  throw new Error(`\`${argv.join(' ')}\` also changed ${moved.length} lockfile ${moved.length === 1 ? 'entry' : 'entries'} the CLI does not own`
275
- + ` (${moved.slice(0, 5).join(', ')}${moved.length > 5 ? ', …' : ''}), so init put every file back. Add ${CLI_PACKAGE} at its exact version`
276
- + ` to ${inside(manager.root, appDir) || '.'} yourself, keeping the lockfile's other entries, then run \`haystack init\` again`);
309
+ + ` (${moved.slice(0, 5).join(', ')}${moved.length > 5 ? ', …' : ''}). Add ${CLI_PACKAGE} at its exact version to`
310
+ + ` ${inside(manager.root, appDir) || '.'} yourself, keeping the lockfile's other entries, then run \`haystack init\` again`);
277
311
  }
278
312
  }
279
313
  function isNode(value) {
@@ -961,7 +995,7 @@ async function readJson(path, token) {
961
995
  }
962
996
  /** Lane A's registration of the application: its newest capture event, or null when it is not registered (404) or has none. */
963
997
  async function captureReceived(repository, app, token) {
964
- const status = await readJson(`${CAPTURE_STATUS_PATH}?repository=${encodeURIComponent(repository)}&app=${encodeURIComponent(app)}`, token);
998
+ const status = await readJson(repoApiPath(repository, `${CAPTURE_STATUS_PATH}?app=${encodeURIComponent(app)}`), token);
965
999
  return status === null ? null : received(status.lastSeenAt, status.release);
966
1000
  }
967
1001
  function withReceived(part, evidence) {
@@ -1006,6 +1040,22 @@ export function previewTelemetry(plan, repository, notes) {
1006
1040
  const profile = dbProfile(repository, null, notes);
1007
1041
  return { telemetry: { capture: plan.capture, server: serverPartOf(plan), dbProfileCommand: profile?.command ?? null }, steps };
1008
1042
  }
1043
+ /** A telemetry plan with nothing planned, for a run whose telemetry could not be planned at all (failedTelemetry says why). */
1044
+ export function emptyTelemetryPlan() {
1045
+ return { app: null, changes: [], notices: [], server: { status: 'manual', kind: null, steps: [] }, settings: null,
1046
+ capture: { status: 'manual', steps: [], received: null }, captureRegistration: null };
1047
+ }
1048
+ /** Telemetry that could not be set up (a package manager failed, a file init could not read, Haystack not answering): each
1049
+ * part it would have set up is manual with what happened, every file telemetry changed is back, and the rest of init stands
1050
+ * (telemetry never fails init). */
1051
+ export function failedTelemetry(plan, failure) {
1052
+ const step = `init could not set up telemetry: ${failure.replace(/\.?\s*$/, '')}. It put back every file telemetry changed; the rest of init is`
1053
+ + ' done. Fix that, then run `haystack init` again.';
1054
+ // A part that was already waiting for answers keeps its questions after what happened.
1055
+ const part = (value) => (value.status === 'unsupported' ? value
1056
+ : { status: 'manual', steps: [step, ...(value.status === 'manual' ? value.steps : [])], received: null });
1057
+ return { telemetry: { capture: part(plan.capture), server: part(serverPartOf(plan)), dbProfileCommand: null }, steps: [step] };
1058
+ }
1009
1059
  /** Rule 8(e): the report, and what the person does for telemetry to arrive (the token, production's environment, the start,
1010
1060
  * the deploy) with the off switches (rule 13c). `onboarding` and `notes` feed the data profile. */
1011
1061
  export async function telemetryReport(plan, context) {