@haystackeditor/cli 0.25.1 → 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 +39 -463
- package/dist/capture/app-config.js +30 -6
- package/dist/commands/capture-brief.js +45 -35
- 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 +66 -0
- package/dist/commands/init-telemetry.js +55 -9
- package/dist/commands/init.js +8 -3
- package/dist/commands/lockfile-pin.js +307 -0
- 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 +31 -118
- package/dist/index.js +55 -968
- package/dist/schema.js +4 -10
- package/dist/utils/haystack-api.js +8 -36
- package/package.json +1 -5
- package/schemas/feedback.v1.json +13 -0
- package/schemas/pre-verify.v2.json +240 -0
- package/schemas/verify-raw.v1.json +1132 -0
- package/schemas/verify.v2.json +655 -0
- package/dist/assets/hooks/agent-context/detect.ts +0 -316
- package/dist/assets/hooks/agent-context/format.ts +0 -100
- package/dist/assets/hooks/agent-context/index.ts +0 -41
- package/dist/assets/hooks/agent-context/parsers/claude.ts +0 -262
- package/dist/assets/hooks/agent-context/parsers/codex.ts +0 -416
- package/dist/assets/hooks/agent-context/parsers/gemini.ts +0 -155
- package/dist/assets/hooks/agent-context/parsers/opencode.ts +0 -174
- package/dist/assets/hooks/agent-context/tsconfig.json +0 -14
- package/dist/assets/hooks/agent-context/types.ts +0 -58
- package/dist/assets/hooks/llm-rules-template.md +0 -59
- package/dist/assets/hooks/package-lock.json +0 -598
- package/dist/assets/hooks/package.json +0 -12
- package/dist/assets/hooks/scripts/commit-msg.sh +0 -5
- package/dist/assets/hooks/scripts/post-commit.sh +0 -5
- package/dist/assets/hooks/scripts/pre-commit.sh +0 -175
- package/dist/assets/hooks/scripts/pre-push.sh +0 -25
- package/dist/assets/hooks/scripts/prepare-commit-msg.sh +0 -5
- package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +0 -528
- package/dist/assets/hooks/truncation-checker/index.ts +0 -595
- package/dist/assets/hooks/truncation-checker/tsconfig.json +0 -13
- package/dist/assets/skills/map-cloud-verifier-universe/SKILL.md +0 -2051
- package/dist/assets/skills/map-cloud-verifier-universe/agents/openai.yaml +0 -4
- package/dist/assets/skills/map-cloud-verifier-universe/references/output-contract.md +0 -3411
- package/dist/assets/skills/map-your-system.md +0 -143
- package/dist/assets/skills/submit.md +0 -200
- package/dist/commands/ask.js +0 -20
- package/dist/commands/cloud-verifier-behaviors.js +0 -218
- package/dist/commands/cloud-verifier-data-store-census.js +0 -539
- package/dist/commands/cloud-verifier-data-store-drift.js +0 -158
- package/dist/commands/cloud-verifier-identity-census.js +0 -4060
- package/dist/commands/cloud-verifier-materialization.js +0 -704
- package/dist/commands/cloud-verifier-pascal-selector-census.js +0 -1382
- package/dist/commands/cloud-verifier-python-manifest-selector-census.js +0 -2015
- package/dist/commands/cloud-verifier-specialized-operational-census.js +0 -11432
- package/dist/commands/cloud-verifier-universe.js +0 -10178
- package/dist/commands/config.js +0 -549
- package/dist/commands/design-verify.js +0 -311
- package/dist/commands/dismiss.js +0 -159
- package/dist/commands/hooks.js +0 -226
- package/dist/commands/inbox.js +0 -137
- package/dist/commands/mcp.js +0 -201
- package/dist/commands/policy.js +0 -371
- package/dist/commands/pr-status.js +0 -207
- package/dist/commands/pr.js +0 -105
- package/dist/commands/prepare-universe-review.js +0 -1092
- package/dist/commands/production-source-deny-policy.js +0 -100
- package/dist/commands/request-review.js +0 -74
- package/dist/commands/review.js +0 -191
- package/dist/commands/rules.js +0 -98
- package/dist/commands/scaffold-provisional-universe.js +0 -806
- package/dist/commands/setup.js +0 -1170
- package/dist/commands/skills.js +0 -447
- package/dist/commands/status.js +0 -35
- package/dist/commands/submit.js +0 -745
- package/dist/commands/system-map.js +0 -228
- package/dist/commands/triage.js +0 -598
- package/dist/commands/webhooks.js +0 -241
- package/dist/states.js +0 -46
- package/dist/tools/detect.js +0 -832
- package/dist/triage/astra.js +0 -202
- package/dist/triage/prompts.js +0 -188
- package/dist/triage/runner.js +0 -200
- package/dist/triage/types.js +0 -7
- package/dist/types.js +0 -326
- package/dist/utils/action-output.js +0 -26
- package/dist/utils/analysis-api.js +0 -416
- package/dist/utils/config.js +0 -54
- package/dist/utils/design-verifier-api.js +0 -294
- package/dist/utils/design-verifier-history.js +0 -79
- package/dist/utils/design-verifier-result.js +0 -424
- package/dist/utils/github-api.js +0 -324
- package/dist/utils/pending-state.js +0 -86
- package/dist/utils/pr-ref.js +0 -56
- package/dist/utils/prompter.js +0 -328
- package/schemas/action.v1.json +0 -22
- package/schemas/ask.v1.json +0 -40
- package/schemas/inbox.v1.json +0 -27
- package/schemas/pr-status.v1.json +0 -61
- package/schemas/pr.v1.json +0 -97
- package/schemas/pr.v3.json +0 -45
- package/schemas/setup.v1.json +0 -75
- package/schemas/submit.v1.json +0 -90
- package/schemas/triage.v1.json +0 -103
- package/schemas/triage.v2.json +0 -64
|
@@ -1,63 +1,73 @@
|
|
|
1
1
|
/** CAPTURE-V1 rule 7b in `haystack pre-verify`: the routes a change touches (the brief's blast-radius files and the changed
|
|
2
2
|
* files, joined through the route manifest's source entries) with their share of captured sessions over the window, the union
|
|
3
|
-
* over all of them, freshness and drops; sessions on routes no manifest names are unmapped, never zero.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
import { join } from 'node:path';
|
|
3
|
+
* over all of them, freshness and drops; sessions on routes no manifest names are unmapped, never zero. One section per app
|
|
4
|
+
* whose `.haystack/capture.json` (rule 8b, written by `haystack init` in the app's directory, apps/web in a monorepo) records it,
|
|
5
|
+
* found as init finds them (app-config.ts); a checkout without one has no capture and the brief is exactly as before. A capture
|
|
6
|
+
* read that fails is reported in its section and never fails the command. */
|
|
7
|
+
import { join, posix } from 'node:path';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
|
-
import {
|
|
9
|
+
import { CAPTURE_CONFIG_PATH, captureConfigDirs, readCaptureConfig } from '../capture/app-config.js';
|
|
10
|
+
import { classifyHttpError, repoApiPath } from '../utils/haystack-api.js';
|
|
10
11
|
import { gatewayFetch } from './case-batch.js';
|
|
11
12
|
import { CAPTURE_APPLICATION_ID, CAPTURE_WINDOW_PATH, captureChangedRoutes, } from './capture-contract.js';
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const path = join(gitRoot, CAPTURE_IDENTITY_FILE);
|
|
17
|
-
if (!existsSync(path))
|
|
18
|
-
return null;
|
|
19
|
-
let value;
|
|
13
|
+
/** An app's application id from its record, or why the record cannot give one. */
|
|
14
|
+
function captureApplication(gitRoot, app) {
|
|
15
|
+
const file = posix.join(app, CAPTURE_CONFIG_PATH);
|
|
16
|
+
let config;
|
|
20
17
|
try {
|
|
21
|
-
|
|
18
|
+
config = readCaptureConfig(join(gitRoot, app));
|
|
22
19
|
}
|
|
23
20
|
catch (error) {
|
|
24
|
-
return { unreadable: `${
|
|
25
|
-
}
|
|
26
|
-
const applicationId = value?.applicationId;
|
|
27
|
-
if (typeof applicationId !== 'string' || !CAPTURE_APPLICATION_ID.test(applicationId)) {
|
|
28
|
-
return { unreadable: `${CAPTURE_IDENTITY_FILE} names no valid applicationId` };
|
|
21
|
+
return { unreadable: `${file}: ${error instanceof Error ? error.message : String(error)}` };
|
|
29
22
|
}
|
|
30
|
-
|
|
23
|
+
if (config === null)
|
|
24
|
+
return { unreadable: `${file} is gone` };
|
|
25
|
+
if (config.app !== app)
|
|
26
|
+
return { unreadable: `${file} records the app ${config.app}, not its own directory; run \`haystack init --app ${app}\`` };
|
|
27
|
+
if (!CAPTURE_APPLICATION_ID.test(config.applicationId))
|
|
28
|
+
return { unreadable: `${file} names no valid applicationId` };
|
|
29
|
+
return { applicationId: config.applicationId };
|
|
31
30
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
|
|
35
|
-
const application = captureApplication(gitRoot);
|
|
36
|
-
if (application === null)
|
|
37
|
-
return null;
|
|
31
|
+
async function readApp(gitRoot, app, repository, token, files, now) {
|
|
32
|
+
const application = captureApplication(gitRoot, app);
|
|
38
33
|
if ('unreadable' in application)
|
|
39
|
-
return { applicationId: null, state: 'unavailable', reason: application.unreadable };
|
|
34
|
+
return { app, applicationId: null, state: 'unavailable', reason: application.unreadable };
|
|
40
35
|
const { applicationId } = application;
|
|
41
36
|
try {
|
|
42
|
-
const
|
|
37
|
+
const path = repoApiPath(repository, `${CAPTURE_WINDOW_PATH}?app=${encodeURIComponent(applicationId)}`);
|
|
38
|
+
const response = await gatewayFetch(path, token);
|
|
43
39
|
if (!response.ok)
|
|
44
|
-
throw await classifyHttpError(response, `Haystack API ${
|
|
40
|
+
throw await classifyHttpError(response, `Haystack API ${path}`);
|
|
45
41
|
const answer = await response.json();
|
|
46
42
|
if (answer.version !== 'capture-v1' || answer.applicationId !== applicationId)
|
|
47
43
|
throw new Error('the capture window answer names another application');
|
|
48
44
|
if (answer.window === null)
|
|
49
|
-
return { applicationId, state: 'no-data' };
|
|
50
|
-
return { applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
|
|
45
|
+
return { app, applicationId, state: 'no-data' };
|
|
46
|
+
return { app, applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
|
|
51
47
|
}
|
|
52
48
|
catch (error) {
|
|
53
|
-
return { applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
|
|
49
|
+
return { app, applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
|
|
54
50
|
}
|
|
55
51
|
}
|
|
52
|
+
/** The pre-verify capture sections, one per app with a record, or null for a checkout with none. `files`: the brief's function
|
|
53
|
+
* files and the changed files. */
|
|
54
|
+
export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
|
|
55
|
+
let apps;
|
|
56
|
+
try {
|
|
57
|
+
apps = captureConfigDirs(gitRoot, []);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return [{ app: '.', applicationId: null, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) }];
|
|
61
|
+
}
|
|
62
|
+
if (apps.length === 0)
|
|
63
|
+
return null;
|
|
64
|
+
return Promise.all(apps.map(app => readApp(gitRoot, app, repository, token, files, now)));
|
|
65
|
+
}
|
|
56
66
|
const percent = (share) => share === null ? 'n/a' : `${(share * 100).toFixed(1)}%`;
|
|
57
67
|
const plain = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '); // eslint-disable-line no-control-regex
|
|
58
|
-
/**
|
|
68
|
+
/** One app's block as a coding agent reads it, after the brief. */
|
|
59
69
|
export function formatCapture(capture) {
|
|
60
|
-
const lines = [chalk.bold(
|
|
70
|
+
const lines = [chalk.bold(`What real users do where your change is (captured sessions of ${capture.app === '.' ? 'the app at the repository root' : plain(capture.app)})`)];
|
|
61
71
|
if (capture.state === 'unavailable') {
|
|
62
72
|
lines.push(` Captured sessions could not be read${capture.applicationId ? ` for ${plain(capture.applicationId)}` : ''}: ${plain(capture.reason)}`);
|
|
63
73
|
return lines.join('\n');
|
|
@@ -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}`,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* haystack feedback <message...>
|
|
3
|
+
*
|
|
4
|
+
* Tells the Haystack team something, in a sentence: a verify that went wrong,
|
|
5
|
+
* an output that confused you, a missing option. Meant for coding agents as
|
|
6
|
+
* much as for people: when Haystack gets in the way, say so here and keep
|
|
7
|
+
* going. The message lands in the team's Slack at once, with the CLI version,
|
|
8
|
+
* the platform and the repository (when run inside a GitHub checkout).
|
|
9
|
+
*
|
|
10
|
+
* `-` as the whole message reads it from stdin.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import { withSchema } from '../schema.js';
|
|
17
|
+
import { resolveAuthContext } from '../utils/auth.js';
|
|
18
|
+
import { parseRemoteUrl } from '../utils/git.js';
|
|
19
|
+
import { haystackJson } from '../utils/haystack-api.js';
|
|
20
|
+
export const FEEDBACK_MAX_CHARS = 4000;
|
|
21
|
+
function cliVersion() {
|
|
22
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
|
23
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
24
|
+
}
|
|
25
|
+
/** The checkout's GitHub repository, or null outside one (feedback never needs a repository). */
|
|
26
|
+
function currentRepository() {
|
|
27
|
+
try {
|
|
28
|
+
const remote = parseRemoteUrl();
|
|
29
|
+
return `${remote.owner}/${remote.repo}`;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function buildFeedbackRequest(message, options, repository) {
|
|
36
|
+
const text = message.trim();
|
|
37
|
+
if (!text)
|
|
38
|
+
throw new Error('Say what happened: haystack feedback "<message>" (or - to read it from stdin)');
|
|
39
|
+
if (text.length > FEEDBACK_MAX_CHARS) {
|
|
40
|
+
throw new Error(`Feedback is ${text.length} characters; keep it under ${FEEDBACK_MAX_CHARS}`);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
message: text,
|
|
44
|
+
context: {
|
|
45
|
+
cliVersion: cliVersion(),
|
|
46
|
+
platform: `${process.platform}-${process.arch} node ${process.versions.node}`,
|
|
47
|
+
repository,
|
|
48
|
+
runId: options.run ?? null,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export async function feedbackCommand(words, options) {
|
|
53
|
+
const message = words.length === 1 && words[0] === '-' ? readFileSync(0, 'utf8') : words.join(' ');
|
|
54
|
+
const request = buildFeedbackRequest(message, options, currentRepository());
|
|
55
|
+
const { token } = await resolveAuthContext({ preferredLogin: options.account });
|
|
56
|
+
await haystackJson('/v1/feedback', token, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify(request),
|
|
60
|
+
});
|
|
61
|
+
if (options.json) {
|
|
62
|
+
process.stdout.write(JSON.stringify(withSchema('feedback', { status: 'sent' }), null, 2) + '\n');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
console.log(chalk.green('Sent to the Haystack team.'));
|
|
66
|
+
}
|
|
@@ -20,19 +20,21 @@
|
|
|
20
20
|
* - the report (8e). InitTelemetryReport, plus the person's steps beside it: the ingest token (init never mints, prints,
|
|
21
21
|
* stores or reads one), production's environment, the start command, the deploy and the off switches (13c).
|
|
22
22
|
*/
|
|
23
|
-
import { spawnSync } from 'node:child_process';
|
|
24
|
-
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
23
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
24
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
25
25
|
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';
|
|
33
33
|
import { discoverApps } from '../capture/adapters/index.js';
|
|
34
|
+
import { CAPTURE_CONFIG_PATH, recordedApps } from '../capture/app-config.js';
|
|
34
35
|
import { loadBabel } from '../capture/js-ast.js';
|
|
35
36
|
import { lineDiff } from './init.js';
|
|
37
|
+
import { lockfileDrift, mergePnpmLockfile } from './lockfile-pin.js';
|
|
36
38
|
import { readTelemetryServerStatus } from './telemetry-token.js';
|
|
37
39
|
import { readFacts } from './verify-onboarding.js';
|
|
38
40
|
/** Lane D1: with the user's yes, init checks browser capture's registration with Haystack around the writes. */
|
|
@@ -214,15 +216,20 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
|
|
|
214
216
|
const spec = `${CLI_PACKAGE}@${version}`;
|
|
215
217
|
const dev = field === 'devDependencies';
|
|
216
218
|
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.
|
|
217
221
|
const argv = manager === null ? null : {
|
|
218
222
|
pnpm: ['pnpm', 'add', '--save-exact', dev ? '--save-dev' : '--save-prod', ...workspaceRoot, spec],
|
|
219
|
-
npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', spec],
|
|
223
|
+
npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', '--install-strategy=nested', spec],
|
|
220
224
|
yarn: ['yarn', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
|
|
221
225
|
bun: ['bun', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
|
|
222
226
|
}[manager.name];
|
|
223
227
|
const action = promoting ? `Moved from devDependencies to dependencies at ${version}` : `Pinned to ${version}`;
|
|
228
|
+
const lockfileNote = manager?.name === 'pnpm'
|
|
229
|
+
? `, 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\`)`
|
|
230
|
+
: `, which adds only the CLI's entries to ${manager?.name}'s lockfile`;
|
|
224
231
|
const reason = argv
|
|
225
|
-
? `${why} ${action} with \`${argv.join(' ')}\` in ${inside(gitRoot, appDir)}
|
|
232
|
+
? `${why} ${action} with \`${argv.join(' ')}\` in ${inside(gitRoot, appDir)}${lockfileNote}.`
|
|
226
233
|
: `${why} ${action}: a newer release never reaches your build unless you change it.`;
|
|
227
234
|
// The package manager's own word is not taken for it: the manifest must say what the integration needs.
|
|
228
235
|
const check = () => {
|
|
@@ -235,7 +242,39 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
|
|
|
235
242
|
return null;
|
|
236
243
|
};
|
|
237
244
|
const touches = manager === null ? [] : LOCKFILES.map(([lockfile]) => join(manager.root, lockfile));
|
|
238
|
-
|
|
245
|
+
const exec = manager === null || argv === null ? undefined : () => pin(manager, appDir, argv);
|
|
246
|
+
return { kind: 'change', change: change(gitRoot, pkg.path, reason, after, content, argv ? { argv, cwd: appDir, touches, check, exec } : undefined) };
|
|
247
|
+
}
|
|
248
|
+
/** The lockfile a package manager writes, in a format lockfile-pin.ts reads (bun's binary lockb aside). */
|
|
249
|
+
function textLockfile(manager) {
|
|
250
|
+
const name = { pnpm: 'pnpm-lock.yaml', npm: 'package-lock.json', yarn: 'yarn.lock', bun: 'bun.lock' }[manager.name];
|
|
251
|
+
const path = join(manager.root, name);
|
|
252
|
+
return isFile(path) ? { path, format: manager.name } : null;
|
|
253
|
+
}
|
|
254
|
+
/** 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. */
|
|
256
|
+
function pin(manager, appDir, argv) {
|
|
257
|
+
const run = (command) => { execFileSync(command[0], command.slice(1), { cwd: appDir, stdio: ['ignore', 2, 2] }); };
|
|
258
|
+
const lockfile = textLockfile(manager);
|
|
259
|
+
const before = lockfile ? readFileSync(lockfile.path, 'utf8') : null;
|
|
260
|
+
const importer = posix.normalize(relative(manager.root, appDir).split(sep).join('/')) || '.';
|
|
261
|
+
if (manager.name === 'pnpm' && lockfile && before !== null) {
|
|
262
|
+
// pnpm resolves the CLI, init keeps the rest as it was, and the install takes exactly that lockfile (proving it).
|
|
263
|
+
run([...argv, '--lockfile-only']);
|
|
264
|
+
writeFileSync(lockfile.path, mergePnpmLockfile(before, readFileSync(lockfile.path, 'utf8'), importer, CLI_PACKAGE));
|
|
265
|
+
run(['pnpm', 'install', '--frozen-lockfile']);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
run(argv);
|
|
269
|
+
}
|
|
270
|
+
if (!lockfile || before === null)
|
|
271
|
+
return;
|
|
272
|
+
const moved = lockfileDrift(lockfile.format, before, readFileSync(lockfile.path, 'utf8'), importer, CLI_PACKAGE);
|
|
273
|
+
if (moved.length > 0) {
|
|
274
|
+
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`);
|
|
277
|
+
}
|
|
239
278
|
}
|
|
240
279
|
function isNode(value) {
|
|
241
280
|
return !!value && typeof value === 'object' && typeof value.type === 'string' && typeof value.start === 'number';
|
|
@@ -819,13 +858,18 @@ export async function planTelemetry(gitRoot, repository, options) {
|
|
|
819
858
|
// One rule for which directories are apps (capture/adapters/index.ts): a web framework an adapter claims, or a package
|
|
820
859
|
// with a start script.
|
|
821
860
|
await loadBabel();
|
|
822
|
-
|
|
861
|
+
// The user's earlier answer first: the one app a .haystack/capture.json records (with its origins and consent, which
|
|
862
|
+
// captureAnswers reads from it). Several records are as ambiguous as several apps.
|
|
863
|
+
const discovered = await discoverApps(gitRoot);
|
|
864
|
+
const recorded = recordedApps(gitRoot, discovered);
|
|
865
|
+
const apps = recorded.length > 0 ? recorded : discovered;
|
|
823
866
|
if (apps.length !== 1) {
|
|
824
867
|
// Rule 8(a): init never picks among several apps, and never guesses one where it found none.
|
|
825
868
|
const ask = apps.length === 0
|
|
826
869
|
? 'init found no app here (no web framework it recognizes and no package with a start script). If the repository has one that serves production, run '
|
|
827
870
|
+ '`haystack init` again with `--app <its directory>`.'
|
|
828
|
-
: `Ask your user which of these apps serves production: ${apps.join(', ')}
|
|
871
|
+
: `Ask your user which of these apps serves production: ${apps.join(', ')}${recorded.length > 0 ? ` (each has a ${CAPTURE_CONFIG_PATH})` : ''};`
|
|
872
|
+
+ ' then run `haystack init` again with `--app <that directory>`.';
|
|
829
873
|
const questions = [ask, ...captureQuestions(options.origins, options.consent)];
|
|
830
874
|
// A notice too, so a preview (which reports no telemetry yet) already says what to ask.
|
|
831
875
|
notices.push(`Telemetry waits for your user's answers: ${questions.join(' ')}`);
|
|
@@ -834,6 +878,8 @@ export async function planTelemetry(gitRoot, repository, options) {
|
|
|
834
878
|
captureRegistration: null };
|
|
835
879
|
}
|
|
836
880
|
appDir = realpathSync(join(gitRoot, apps[0]));
|
|
881
|
+
if (recorded.length > 0)
|
|
882
|
+
notices.push(`Telemetry: the app is ${apps[0]}, as its ${CAPTURE_CONFIG_PATH} records (\`--app\` picks another).`);
|
|
837
883
|
}
|
|
838
884
|
const app = inside(gitRoot, appDir);
|
|
839
885
|
const changes = [];
|
|
@@ -915,7 +961,7 @@ async function readJson(path, token) {
|
|
|
915
961
|
}
|
|
916
962
|
/** Lane A's registration of the application: its newest capture event, or null when it is not registered (404) or has none. */
|
|
917
963
|
async function captureReceived(repository, app, token) {
|
|
918
|
-
const status = await readJson(`${CAPTURE_STATUS_PATH}?
|
|
964
|
+
const status = await readJson(repoApiPath(repository, `${CAPTURE_STATUS_PATH}?app=${encodeURIComponent(app)}`), token);
|
|
919
965
|
return status === null ? null : received(status.lastSeenAt, status.release);
|
|
920
966
|
}
|
|
921
967
|
function withReceived(part, evidence) {
|
package/dist/commands/init.js
CHANGED
|
@@ -54,9 +54,11 @@ export const AGENT_NOTE = [
|
|
|
54
54
|
'- Fix each bug it reports that your change caused, then run `haystack verify` again.',
|
|
55
55
|
'- If it says onboarding needs an answer, ask the user that question and file their choice with',
|
|
56
56
|
' `haystack verify answer <question-id> <choice>`.',
|
|
57
|
-
'- Read its short report. `--json` prints the
|
|
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
|
+
'- If Haystack fails, confuses you or gets in your way, say so in a sentence with',
|
|
61
|
+
' `haystack feedback "<what happened>"` and keep going: the Haystack team sees it at once.',
|
|
60
62
|
NOTE_END,
|
|
61
63
|
].join('\n');
|
|
62
64
|
const EXIT_CODES = { planned: 2, 'login-required': 4, 'app-required': 5 };
|
|
@@ -310,7 +312,10 @@ function applyChange(gitRoot, planned) {
|
|
|
310
312
|
if (planned.run) {
|
|
311
313
|
const before = existsSync(planned.target) ? readFileSync(planned.target, 'utf8') : '';
|
|
312
314
|
// Its output goes to stderr: in --json mode stdout carries only the one document.
|
|
313
|
-
|
|
315
|
+
if (planned.run.exec)
|
|
316
|
+
planned.run.exec();
|
|
317
|
+
else
|
|
318
|
+
execFileSync(planned.run.argv[0], planned.run.argv.slice(1), { cwd: planned.run.cwd, stdio: ['ignore', 2, 2] });
|
|
314
319
|
const failed = planned.run.check?.() ?? null;
|
|
315
320
|
if (failed !== null)
|
|
316
321
|
throw new Error(`\`${planned.run.argv.join(' ')}\` ran, but ${failed}.`);
|