@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 +0 -8
- package/dist/capture/app-config.js +7 -6
- package/dist/commands/capture-brief.js +4 -3
- package/dist/commands/capture-contract.js +4 -4
- package/dist/commands/crawl-report.js +196 -0
- package/dist/commands/db-profile-upload.js +3 -3
- package/dist/commands/feedback.js +1 -1
- package/dist/commands/init-telemetry.js +64 -14
- package/dist/commands/init.js +96 -39
- package/dist/commands/lockfile-pin.js +4 -8
- package/dist/commands/telemetry-token.js +3 -3
- package/dist/commands/tokens.js +4 -4
- package/dist/commands/verify-explore.js +3 -3
- package/dist/commands/verify-history.js +2 -2
- package/dist/commands/verify-onboarding.js +13 -16
- package/dist/commands/verify-precompute.js +3 -4
- package/dist/commands/verify.js +9 -151
- package/dist/index.js +28 -45
- package/dist/utils/haystack-api.js +8 -0
- package/package.json +1 -1
- package/dist/commands/status.js +0 -35
- package/dist/types.js +0 -326
- package/dist/utils/config.js +0 -54
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
|
-
|
|
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({
|
|
104
|
-
const response = await haystackJson(`/
|
|
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({
|
|
120
|
-
const response = await haystackFetch(`${CAPTURE_STATUS_PATH}?${query}
|
|
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
|
|
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 ${
|
|
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 = '/
|
|
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 = '/
|
|
43
|
-
export const TELEMETRY_SERVER_STATUS_PATH = '/
|
|
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 = '/
|
|
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 = '/
|
|
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('/
|
|
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
|
|
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
|
|
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',
|
|
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
|
-
|
|
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
|
-
*
|
|
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 ? ', …' : ''})
|
|
276
|
-
+ `
|
|
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}?
|
|
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) {
|