@haystackeditor/cli 0.15.31 → 0.17.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 +141 -61
- package/dist/assets/hooks/scripts/commit-msg.sh +3 -0
- package/dist/assets/hooks/scripts/post-commit.sh +3 -0
- package/dist/assets/hooks/scripts/pre-commit.sh +11 -6
- package/dist/assets/hooks/scripts/pre-push.sh +3 -0
- package/dist/assets/hooks/scripts/prepare-commit-msg.sh +3 -0
- package/dist/commands/case-batch-contract.js +694 -0
- package/dist/commands/case-batch.js +1011 -0
- package/dist/commands/cloud-verifier-identity-census.js +5 -2
- package/dist/commands/combination-search-hook.js +139 -0
- package/dist/commands/dismiss.js +2 -1
- package/dist/commands/hooks.js +66 -7
- package/dist/commands/install-session-hooks.js +131 -44
- package/dist/commands/policy.js +32 -45
- package/dist/commands/precompute-delivery.js +6 -1
- package/dist/commands/scaffold-provisional-universe.js +8 -10
- package/dist/commands/setup.js +32 -10
- package/dist/commands/submit.js +39 -71
- package/dist/commands/telemetry.js +17 -2
- package/dist/commands/triage.js +2 -1
- package/dist/commands/verify-explore.js +1 -0
- package/dist/commands/verify-hosted-mcp.js +3 -12
- package/dist/commands/verify-hosted-reproducibility.js +49 -547
- package/dist/commands/verify-hosted.js +66 -168
- package/dist/commands/verify-precompute.js +64 -11
- package/dist/commands/verify.js +51 -139
- package/dist/index.js +290 -366
- package/dist/lazy.js +8 -0
- package/dist/schema.js +2 -1
- package/dist/tools/detect.js +3 -24
- package/dist/triage/astra.js +199 -0
- package/dist/triage/prompts.js +145 -179
- package/dist/triage/runner.js +104 -300
- package/dist/triage/types.js +2 -2
- package/dist/types.js +2 -6
- package/dist/utils/auth.js +14 -2
- package/dist/utils/git.js +60 -29
- package/dist/utils/github-api.js +14 -1
- package/dist/utils/haystack-api.js +38 -7
- package/dist/utils/hooks.js +43 -6
- package/dist/utils/prompter.js +25 -10
- package/dist/utils/safe-write.js +31 -0
- package/dist/utils/secret-paths.js +115 -0
- package/dist/utils/secrets.js +0 -1
- package/dist/utils/telemetry.js +34 -14
- package/dist/utils/update-check.js +151 -0
- package/package.json +19 -14
- package/schemas/case-batch.v1.json +235 -0
- package/schemas/cloud-verifier.v1.json +55 -100
- package/schemas/submit.v1.json +4 -2
- package/dist/commands/ask.d.ts +0 -14
- package/dist/commands/cloud-verifier-behaviors.d.ts +0 -27
- package/dist/commands/cloud-verifier-data-store-census.d.ts +0 -47
- package/dist/commands/cloud-verifier-data-store-drift.d.ts +0 -42
- package/dist/commands/cloud-verifier-identity-census.d.ts +0 -88
- package/dist/commands/cloud-verifier-materialization.d.ts +0 -16
- package/dist/commands/cloud-verifier-pascal-selector-census.d.ts +0 -29
- package/dist/commands/cloud-verifier-python-manifest-selector-census.d.ts +0 -27
- package/dist/commands/cloud-verifier-specialized-operational-census.d.ts +0 -51
- package/dist/commands/cloud-verifier-universe.d.ts +0 -31
- package/dist/commands/config.d.ts +0 -46
- package/dist/commands/design-verify.d.ts +0 -33
- package/dist/commands/dismiss.d.ts +0 -29
- package/dist/commands/hooks.d.ts +0 -13
- package/dist/commands/inbox.d.ts +0 -65
- package/dist/commands/init.d.ts +0 -10
- package/dist/commands/install-session-hooks.d.ts +0 -17
- package/dist/commands/login.d.ts +0 -8
- package/dist/commands/mcp.d.ts +0 -1
- package/dist/commands/policy.d.ts +0 -31
- package/dist/commands/pr-status.d.ts +0 -144
- package/dist/commands/pr.d.ts +0 -40
- package/dist/commands/precompute-delivery-contract.d.ts +0 -68
- package/dist/commands/precompute-delivery.d.ts +0 -20
- package/dist/commands/prepare-universe-review.d.ts +0 -115
- package/dist/commands/production-source-deny-policy.d.ts +0 -15
- package/dist/commands/request-review.d.ts +0 -26
- package/dist/commands/review.d.ts +0 -25
- package/dist/commands/rules.d.ts +0 -4
- package/dist/commands/scaffold-provisional-universe.d.ts +0 -468
- package/dist/commands/schema-cmd.d.ts +0 -2
- package/dist/commands/setup.d.ts +0 -28
- package/dist/commands/skills.d.ts +0 -8
- package/dist/commands/status.d.ts +0 -4
- package/dist/commands/submit.d.ts +0 -30
- package/dist/commands/system-map.d.ts +0 -42
- package/dist/commands/telemetry.d.ts +0 -53
- package/dist/commands/tokens.d.ts +0 -14
- package/dist/commands/triage.d.ts +0 -35
- package/dist/commands/verify-core.d.ts +0 -449
- package/dist/commands/verify-core.js +0 -789
- package/dist/commands/verify-explore.d.ts +0 -14
- package/dist/commands/verify-history.d.ts +0 -14
- package/dist/commands/verify-hosted-mcp.d.ts +0 -12
- package/dist/commands/verify-hosted-reproducibility.d.ts +0 -90
- package/dist/commands/verify-hosted.d.ts +0 -92
- package/dist/commands/verify-mcp.d.ts +0 -8
- package/dist/commands/verify-mcp.js +0 -517
- package/dist/commands/verify-ops.d.ts +0 -158
- package/dist/commands/verify-ops.js +0 -1148
- package/dist/commands/verify-precompute.d.ts +0 -30
- package/dist/commands/verify-reproducibility.d.ts +0 -85
- package/dist/commands/verify-reproducibility.js +0 -494
- package/dist/commands/verify-reseal.d.ts +0 -9
- package/dist/commands/verify-reseal.js +0 -148
- package/dist/commands/verify-sandboxes.d.ts +0 -95
- package/dist/commands/verify-sandboxes.js +0 -352
- package/dist/commands/verify.d.ts +0 -28
- package/dist/commands/webhooks.d.ts +0 -30
- package/dist/index.d.ts +0 -22
- package/dist/schema.d.ts +0 -28
- package/dist/states.d.ts +0 -29
- package/dist/tools/detect.d.ts +0 -50
- package/dist/triage/prompts.d.ts +0 -24
- package/dist/triage/runner.d.ts +0 -34
- package/dist/triage/types.d.ts +0 -42
- package/dist/types/verify-history.d.ts +0 -38
- package/dist/types.d.ts +0 -1684
- package/dist/utils/action-output.d.ts +0 -24
- package/dist/utils/analysis-api.d.ts +0 -187
- package/dist/utils/auth.d.ts +0 -79
- package/dist/utils/config.d.ts +0 -24
- package/dist/utils/design-verifier-api.d.ts +0 -208
- package/dist/utils/design-verifier-history.d.ts +0 -46
- package/dist/utils/design-verifier-result.d.ts +0 -74
- package/dist/utils/detect.d.ts +0 -43
- package/dist/utils/git.d.ts +0 -135
- package/dist/utils/github-api.d.ts +0 -104
- package/dist/utils/haystack-api.d.ts +0 -37
- package/dist/utils/hooks.d.ts +0 -12
- package/dist/utils/pending-state.d.ts +0 -40
- package/dist/utils/pr-ref.d.ts +0 -27
- package/dist/utils/prompter.d.ts +0 -85
- package/dist/utils/secrets.d.ts +0 -47
- package/dist/utils/telemetry.d.ts +0 -19
- /package/dist/commands/{precompute-delivery-worker.d.ts → combination-search-hook-contract.js} +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { scanForSecrets } from './secrets.js';
|
|
2
|
+
/**
|
|
3
|
+
* File names that conventionally hold credentials. The precompute upload
|
|
4
|
+
* withholds these regardless of content, because a scanner cannot recognise
|
|
5
|
+
* every secret format (a raw key file has no telltale prefix).
|
|
6
|
+
*/
|
|
7
|
+
const EXACT_NAMES = new Set([
|
|
8
|
+
'.env',
|
|
9
|
+
'credentials.json',
|
|
10
|
+
'.npmrc',
|
|
11
|
+
'.pypirc',
|
|
12
|
+
'.netrc',
|
|
13
|
+
]);
|
|
14
|
+
// Committed templates are how teams document which variables a service needs.
|
|
15
|
+
const ENV_TEMPLATES = new Set(['.env.example', '.env.sample']);
|
|
16
|
+
const SUFFIXES = ['.pem', '.key', '.p12', '.pfx', '.keystore', '.tfvars'];
|
|
17
|
+
const PREFIXES = ['id_rsa', 'id_ed25519', 'secrets.'];
|
|
18
|
+
/** True when a repository-relative path names a conventionally secret-bearing file. */
|
|
19
|
+
export function isSecretBearingPath(path) {
|
|
20
|
+
const segments = path.split('/').filter(segment => segment.length > 0);
|
|
21
|
+
if (segments.length === 0)
|
|
22
|
+
return false;
|
|
23
|
+
for (let index = 0; index + 1 < segments.length; index += 1) {
|
|
24
|
+
if (segments[index] === '.haystack' && segments[index + 1] === 'secrets')
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
const name = segments[segments.length - 1].toLowerCase();
|
|
28
|
+
if (EXACT_NAMES.has(name))
|
|
29
|
+
return true;
|
|
30
|
+
if (name.startsWith('.env.'))
|
|
31
|
+
return !ENV_TEMPLATES.has(name);
|
|
32
|
+
if (SUFFIXES.some(suffix => name.endsWith(suffix)))
|
|
33
|
+
return true;
|
|
34
|
+
if (PREFIXES.some(prefix => name.startsWith(prefix)))
|
|
35
|
+
return true;
|
|
36
|
+
return name.startsWith('service-account') && name.endsWith('.json');
|
|
37
|
+
}
|
|
38
|
+
const GIT_ESCAPES = {
|
|
39
|
+
a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92,
|
|
40
|
+
};
|
|
41
|
+
/** Reverses git's C-style path quoting (named escapes and octal bytes). */
|
|
42
|
+
function unquoteGitPath(quoted) {
|
|
43
|
+
const bytes = [];
|
|
44
|
+
for (let index = 0; index < quoted.length; index += 1) {
|
|
45
|
+
const character = quoted[index];
|
|
46
|
+
if (character !== '\\') {
|
|
47
|
+
bytes.push(...Buffer.from(character, 'utf8'));
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const next = quoted[index + 1];
|
|
51
|
+
const octal = quoted.slice(index + 1, index + 4);
|
|
52
|
+
if (next !== undefined && next in GIT_ESCAPES) {
|
|
53
|
+
bytes.push(GIT_ESCAPES[next]);
|
|
54
|
+
index += 1;
|
|
55
|
+
}
|
|
56
|
+
else if (/^[0-7]{3}$/.test(octal)) {
|
|
57
|
+
bytes.push(Number.parseInt(octal, 8));
|
|
58
|
+
index += 3;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
throw new Error('Unrecognised escape in a git path.');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return Buffer.from(bytes).toString('utf8');
|
|
65
|
+
}
|
|
66
|
+
/** Path named by a `diff --git a/P b/P` header produced with --no-renames. */
|
|
67
|
+
export function pathFromDiffHeader(header) {
|
|
68
|
+
const prefix = 'diff --git ';
|
|
69
|
+
if (!header.startsWith(prefix))
|
|
70
|
+
throw new Error('Not a diff header.');
|
|
71
|
+
const rest = header.slice(prefix.length);
|
|
72
|
+
// Without renames both sides name the same path, so the header is symmetric.
|
|
73
|
+
const side = rest.slice(0, (rest.length - 1) / 2);
|
|
74
|
+
const quoted = side.startsWith('"');
|
|
75
|
+
const other = quoted ? `"b/${side.slice(3)}` : `b/${side.slice(2)}`;
|
|
76
|
+
if (rest !== `${side} ${other}` || !side.startsWith(quoted ? '"a/' : 'a/')) {
|
|
77
|
+
throw new Error('Unexpected diff header.');
|
|
78
|
+
}
|
|
79
|
+
return quoted ? unquoteGitPath(side.slice(1, -1)).slice(2) : side.slice(2);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Scans only the lines a `git diff --no-renames` patch adds and returns, per
|
|
83
|
+
* file, the name of the first high-severity secret pattern found. The matched
|
|
84
|
+
* value never leaves this function.
|
|
85
|
+
*/
|
|
86
|
+
export function findSecretsInPatch(patch) {
|
|
87
|
+
const hits = new Map();
|
|
88
|
+
let currentPath;
|
|
89
|
+
let inHunk = false;
|
|
90
|
+
let added = [];
|
|
91
|
+
const flush = () => {
|
|
92
|
+
if (currentPath === undefined || added.length === 0)
|
|
93
|
+
return;
|
|
94
|
+
const finding = scanForSecrets(added.join('\n'), currentPath)
|
|
95
|
+
.find(item => item.severity === 'high');
|
|
96
|
+
if (finding && !hits.has(currentPath))
|
|
97
|
+
hits.set(currentPath, finding.pattern);
|
|
98
|
+
};
|
|
99
|
+
for (const line of patch.split('\n')) {
|
|
100
|
+
if (line.startsWith('diff --git ')) {
|
|
101
|
+
flush();
|
|
102
|
+
currentPath = pathFromDiffHeader(line);
|
|
103
|
+
inHunk = false;
|
|
104
|
+
added = [];
|
|
105
|
+
}
|
|
106
|
+
else if (line.startsWith('@@')) {
|
|
107
|
+
inHunk = true;
|
|
108
|
+
}
|
|
109
|
+
else if (inHunk && line.startsWith('+')) {
|
|
110
|
+
added.push(line.slice(1));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
flush();
|
|
114
|
+
return hits;
|
|
115
|
+
}
|
package/dist/utils/secrets.js
CHANGED
|
@@ -160,7 +160,6 @@ function isSafePattern(match) {
|
|
|
160
160
|
*/
|
|
161
161
|
export function scanForSecrets(content, filename) {
|
|
162
162
|
const findings = [];
|
|
163
|
-
const lines = content.split('\n');
|
|
164
163
|
for (const secretPattern of SECRET_PATTERNS) {
|
|
165
164
|
// Reset regex state
|
|
166
165
|
secretPattern.pattern.lastIndex = 0;
|
package/dist/utils/telemetry.js
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
* All calls are non-blocking and never throw — telemetry failures must not
|
|
8
8
|
* break CLI commands.
|
|
9
9
|
*/
|
|
10
|
-
import { readFileSync } from 'node:fs';
|
|
11
|
-
import { dirname, resolve } from 'node:path';
|
|
10
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
|
-
import * as os from 'os';
|
|
14
|
-
import {
|
|
13
|
+
import * as os from 'node:os';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
15
|
const POSTHOG_HOST = 'https://us.i.posthog.com';
|
|
16
16
|
const POSTHOG_API_KEY = process.env.HAYSTACK_POSTHOG_KEY || 'phc_7wHrCoMlcwuftLe6N38hPb9wKHb4OK0wNw9ayeRySad';
|
|
17
17
|
const CURRENT_FILE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
@@ -55,25 +55,45 @@ function minimizeMetadata(metadata) {
|
|
|
55
55
|
}
|
|
56
56
|
return minimized;
|
|
57
57
|
}
|
|
58
|
+
const TELEMETRY_ID_PATTERN = /^[0-9a-f]{24}$/;
|
|
59
|
+
let cachedTelemetryId;
|
|
58
60
|
/**
|
|
59
|
-
* Pseudonymous stable
|
|
60
|
-
*
|
|
61
|
-
*
|
|
61
|
+
* Pseudonymous stable install identifier for PostHog distinct_id: 24 random
|
|
62
|
+
* hex chars generated once and kept in ~/.haystack/telemetry-id (0600). It is
|
|
63
|
+
* not derived from the username or hostname, so it cannot be reversed to
|
|
64
|
+
* them. A missing, unreadable or malformed file gets a fresh id. Never throws.
|
|
62
65
|
*/
|
|
63
66
|
function getDistinctId() {
|
|
67
|
+
if (cachedTelemetryId)
|
|
68
|
+
return `cli:${cachedTelemetryId}`;
|
|
69
|
+
let idPath;
|
|
64
70
|
try {
|
|
65
|
-
|
|
71
|
+
idPath = join(os.homedir(), '.haystack', 'telemetry-id');
|
|
72
|
+
const stored = readFileSync(idPath, 'utf8').trim();
|
|
73
|
+
if (TELEMETRY_ID_PATTERN.test(stored)) {
|
|
74
|
+
cachedTelemetryId = stored;
|
|
75
|
+
return `cli:${stored}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Missing or unreadable: a new id is generated below.
|
|
80
|
+
cachedTelemetryId = undefined;
|
|
81
|
+
}
|
|
82
|
+
const fresh = randomBytes(12).toString('hex');
|
|
83
|
+
cachedTelemetryId = fresh;
|
|
84
|
+
if (idPath) {
|
|
66
85
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
mkdirSync(dirname(idPath), { recursive: true });
|
|
87
|
+
writeFileSync(idPath, `${fresh}\n`, { mode: 0o600 });
|
|
88
|
+
chmodSync(idPath, 0o600);
|
|
69
89
|
}
|
|
70
90
|
catch {
|
|
71
|
-
|
|
91
|
+
// Could not persist (read-only home, etc.): this process keeps the
|
|
92
|
+
// random id and the next run tries again. Telemetry must not break the CLI.
|
|
93
|
+
cachedTelemetryId = fresh;
|
|
72
94
|
}
|
|
73
95
|
}
|
|
74
|
-
|
|
75
|
-
return 'cli:anonymous';
|
|
76
|
-
}
|
|
96
|
+
return `cli:${fresh}`;
|
|
77
97
|
}
|
|
78
98
|
/**
|
|
79
99
|
* Track an error event. Fire-and-forget — never throws.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "New version available" notice.
|
|
3
|
+
*
|
|
4
|
+
* Runs after a command finishes, never before it starts. At most once per 24
|
|
5
|
+
* hours (timestamp in ~/.haystack/update-check.json, mode 0600) it asks the
|
|
6
|
+
* npm registry for the latest @haystackeditor/cli with a 1.5 second timeout,
|
|
7
|
+
* and prints one dim stderr line when that version is newer than this one.
|
|
8
|
+
*
|
|
9
|
+
* It stays off when stdout is not a TTY, when --json is passed, and when
|
|
10
|
+
* HAYSTACK_NO_UPDATE_CHECK or CI is set, so scripts and agents never see it.
|
|
11
|
+
* Failures print nothing unless DEBUG is set.
|
|
12
|
+
*/
|
|
13
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
export const UPDATE_CHECK_TIMEOUT_MS = 1500;
|
|
19
|
+
export const LATEST_URL = 'https://registry.npmjs.org/@haystackeditor/cli/latest';
|
|
20
|
+
/** Parse `X.Y.Z` or `X.Y.Z-pre`; anything else is not comparable. */
|
|
21
|
+
function parseVersion(version) {
|
|
22
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
|
|
23
|
+
if (!match)
|
|
24
|
+
return null;
|
|
25
|
+
return {
|
|
26
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
27
|
+
prerelease: match[4] !== undefined,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* True when `latest` is a strictly newer release than `current`. A prerelease
|
|
32
|
+
* `latest` is never offered. A release with the same core as a prerelease
|
|
33
|
+
* `current` is newer (0.16.0 is newer than 0.16.0-rc.1).
|
|
34
|
+
*/
|
|
35
|
+
export function isNewerVersion(latest, current) {
|
|
36
|
+
const l = parseVersion(latest);
|
|
37
|
+
const c = parseVersion(current);
|
|
38
|
+
if (!l || !c || l.prerelease)
|
|
39
|
+
return false;
|
|
40
|
+
for (let i = 0; i < 3; i++) {
|
|
41
|
+
if (l.core[i] !== c.core[i])
|
|
42
|
+
return l.core[i] > c.core[i];
|
|
43
|
+
}
|
|
44
|
+
return c.prerelease;
|
|
45
|
+
}
|
|
46
|
+
/** True when no check has run within the interval (or the clock went backwards). */
|
|
47
|
+
export function isCheckDue(state, now, intervalMs = UPDATE_CHECK_INTERVAL_MS) {
|
|
48
|
+
if (!state)
|
|
49
|
+
return true;
|
|
50
|
+
const elapsed = now - state.lastCheckedAt;
|
|
51
|
+
return elapsed < 0 || elapsed >= intervalMs;
|
|
52
|
+
}
|
|
53
|
+
export function isUpdateCheckEnabled({ argv, env, stdoutIsTTY }) {
|
|
54
|
+
if (!stdoutIsTTY)
|
|
55
|
+
return false;
|
|
56
|
+
if (env.HAYSTACK_NO_UPDATE_CHECK || env.CI)
|
|
57
|
+
return false;
|
|
58
|
+
return !argv.includes('--json');
|
|
59
|
+
}
|
|
60
|
+
export function defaultStatePath() {
|
|
61
|
+
return join(homedir(), '.haystack', 'update-check.json');
|
|
62
|
+
}
|
|
63
|
+
function readState(statePath, debug) {
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = readFileSync(statePath, 'utf8');
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (err.code !== 'ENOENT') {
|
|
70
|
+
debug(`could not read ${statePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(raw);
|
|
76
|
+
return typeof parsed.lastCheckedAt === 'number' ? { lastCheckedAt: parsed.lastCheckedAt } : null;
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
debug(`ignoring unreadable ${statePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function writeState(statePath, state) {
|
|
84
|
+
mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
|
|
85
|
+
writeFileSync(statePath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
86
|
+
// writeFileSync only applies `mode` when it creates the file.
|
|
87
|
+
chmodSync(statePath, 0o600);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Returns the newer version when one should be announced, otherwise null.
|
|
91
|
+
* The timestamp is recorded before the request so an offline machine pays the
|
|
92
|
+
* timeout at most once per interval.
|
|
93
|
+
*/
|
|
94
|
+
export async function checkForUpdate(deps) {
|
|
95
|
+
const { currentVersion, now, statePath, fetchLatest, debug } = deps;
|
|
96
|
+
if (!isCheckDue(readState(statePath, debug), now))
|
|
97
|
+
return null;
|
|
98
|
+
try {
|
|
99
|
+
writeState(statePath, { lastCheckedAt: now });
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
debug(`could not write ${statePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
|
107
|
+
try {
|
|
108
|
+
const latest = await fetchLatest(controller.signal);
|
|
109
|
+
return isNewerVersion(latest, currentVersion) ? latest : null;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
debug(`registry lookup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function fetchLatestFromRegistry(signal) {
|
|
120
|
+
const response = await fetch(LATEST_URL, { signal, headers: { accept: 'application/json' } });
|
|
121
|
+
if (!response.ok)
|
|
122
|
+
throw new Error(`registry returned HTTP ${response.status}`);
|
|
123
|
+
const body = (await response.json());
|
|
124
|
+
if (typeof body.version !== 'string')
|
|
125
|
+
throw new Error('registry response has no version');
|
|
126
|
+
return body.version;
|
|
127
|
+
}
|
|
128
|
+
/** Entry point for src/index.ts, called once the command has finished. */
|
|
129
|
+
export async function notifyIfUpdateAvailable(currentVersion) {
|
|
130
|
+
const environment = {
|
|
131
|
+
argv: process.argv.slice(2),
|
|
132
|
+
env: process.env,
|
|
133
|
+
stdoutIsTTY: process.stdout.isTTY === true,
|
|
134
|
+
};
|
|
135
|
+
if (!isUpdateCheckEnabled(environment))
|
|
136
|
+
return;
|
|
137
|
+
const debug = (message) => {
|
|
138
|
+
if (process.env.DEBUG)
|
|
139
|
+
console.error(`haystack: update check: ${message}`);
|
|
140
|
+
};
|
|
141
|
+
const newer = await checkForUpdate({
|
|
142
|
+
currentVersion,
|
|
143
|
+
now: Date.now(),
|
|
144
|
+
statePath: defaultStatePath(),
|
|
145
|
+
fetchLatest: fetchLatestFromRegistry,
|
|
146
|
+
debug,
|
|
147
|
+
});
|
|
148
|
+
if (!newer)
|
|
149
|
+
return;
|
|
150
|
+
console.error(chalk.dim(`haystack ${newer} is available (you have ${currentVersion}). Update: npm i -g @haystackeditor/cli@latest`));
|
|
151
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haystackeditor/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "rm -rf dist && tsc && cp -r src/assets dist/assets",
|
|
11
11
|
"dev": "tsc --watch",
|
|
12
|
+
"lint": "eslint src",
|
|
12
13
|
"start": "node dist/index.js",
|
|
13
14
|
"benchmark:telemetry": "pnpm build && node scripts/benchmark-telemetry-overhead.mjs",
|
|
14
15
|
"benchmark:telemetry:corpus": "pnpm build && node scripts/benchmark-open-source-telemetry.mjs",
|
|
@@ -39,23 +40,27 @@
|
|
|
39
40
|
"url": "https://github.com/haystackeditor/haystack-review/issues"
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
|
-
"@
|
|
43
|
+
"@aws-sdk/client-ssm": "3.987.0",
|
|
44
|
+
"@babel/core": "8.0.6",
|
|
45
|
+
"@inquirer/prompts": "8.7.2",
|
|
43
46
|
"@modelcontextprotocol/sdk": "1.26.0",
|
|
44
|
-
"chalk": "
|
|
45
|
-
"commander": "
|
|
47
|
+
"chalk": "6.0.0",
|
|
48
|
+
"commander": "15.0.0",
|
|
46
49
|
"fast-glob": "3.3.3",
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"yaml": "2.8.2",
|
|
51
|
-
"zod": "3.25.76"
|
|
50
|
+
"ws": "8.21.3",
|
|
51
|
+
"yaml": "2.9.1",
|
|
52
|
+
"zod": "4.6.5"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
|
-
"@
|
|
55
|
-
"@types/node": "
|
|
55
|
+
"@eslint/js": "8.57.1",
|
|
56
|
+
"@types/node": "22.19.8",
|
|
56
57
|
"@types/ws": "8.18.1",
|
|
57
|
-
"typescript": "
|
|
58
|
-
"
|
|
58
|
+
"@typescript-eslint/eslint-plugin": "7.18.0",
|
|
59
|
+
"@typescript-eslint/parser": "7.18.0",
|
|
60
|
+
"eslint": "8.57.1",
|
|
61
|
+
"globals": "13.24.0",
|
|
62
|
+
"typescript": "6.0.3",
|
|
63
|
+
"vitest": "5.0.1"
|
|
59
64
|
},
|
|
60
65
|
"files": [
|
|
61
66
|
"dist",
|
|
@@ -63,6 +68,6 @@
|
|
|
63
68
|
],
|
|
64
69
|
"bundledDependencies": [],
|
|
65
70
|
"engines": {
|
|
66
|
-
"node": ">=18"
|
|
71
|
+
"node": ">=22.18"
|
|
67
72
|
}
|
|
68
73
|
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://docs.haystackeditor.com/cli/schemas/case-batch.v1.json",
|
|
4
|
+
"title": "Haystack hosted case batch (CASE-BATCH-V1)",
|
|
5
|
+
"description": "Machine-readable output of `haystack case-batch submit|status|cancel|finalize|bundle --json`. The run-side shapes mirror infra/lambda/haystack-design-verifier-shared/case_batch_contracts.ts.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schema_version", "operation", "run_id"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema_version": { "const": "1.0.1" },
|
|
10
|
+
"operation": { "enum": ["submit", "status", "cancel", "finalize", "bundle"] },
|
|
11
|
+
"run_id": { "type": "string", "pattern": "^cv_[0-9a-f]{48}$" },
|
|
12
|
+
"repository": { "type": "string", "pattern": "^[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}$" },
|
|
13
|
+
"admitted": { "enum": ["created", "existing"] },
|
|
14
|
+
"base_commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
|
|
15
|
+
"head_commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
|
|
16
|
+
"base_world": { "type": "string", "pattern": "^frz_[0-9a-f]{16}$" },
|
|
17
|
+
"head_world": { "type": "string", "pattern": "^frz_[0-9a-f]{16}$" },
|
|
18
|
+
"idempotency_key": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$" },
|
|
19
|
+
"cases": {
|
|
20
|
+
"description": "On submit, the number of cases sent. On status, the per-case rows.",
|
|
21
|
+
"oneOf": [
|
|
22
|
+
{ "type": "integer", "minimum": 1, "maximum": 5000 },
|
|
23
|
+
{
|
|
24
|
+
"type": "array",
|
|
25
|
+
"items": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"required": ["id", "position", "status", "outcome_status", "unknown_reason", "wall_ms", "cleanup_proven"],
|
|
28
|
+
"properties": {
|
|
29
|
+
"id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,160}$" },
|
|
30
|
+
"position": { "type": "integer", "minimum": 0, "maximum": 4999 },
|
|
31
|
+
"label": { "type": "string" },
|
|
32
|
+
"condition_id": { "type": "string" },
|
|
33
|
+
"status": { "enum": ["running", "completed", "incomplete"] },
|
|
34
|
+
"launched_at_ms": { "type": "integer", "minimum": 0 },
|
|
35
|
+
"finished_at_ms": { "type": ["integer", "null"], "minimum": 0 },
|
|
36
|
+
"outcome_status": { "enum": ["AGREE", "DIFFER", "UNKNOWN", null] },
|
|
37
|
+
"unknown_reason": {
|
|
38
|
+
"type": ["string", "null"],
|
|
39
|
+
"description": "Open by ruling 1: an unrecognised reason still reads as UNKNOWN."
|
|
40
|
+
},
|
|
41
|
+
"wall_ms": { "type": ["integer", "null"], "minimum": 0 },
|
|
42
|
+
"cleanup_proven": { "type": ["boolean", "null"] },
|
|
43
|
+
"comparison_path": { "type": ["string", "null"] },
|
|
44
|
+
"case_bundle_path": { "type": ["string", "null"] },
|
|
45
|
+
"differing_outcomes": { "type": "array", "items": { "type": "string" } },
|
|
46
|
+
"detail": { "type": ["string", "null"] },
|
|
47
|
+
"execution_path": { "enum": ["resident-sealed-plan", "sealed-plan", "record-replay", null] },
|
|
48
|
+
"node_ids": {
|
|
49
|
+
"description": "v4: the node each arm's VM was placed on; null when the outcome names none.",
|
|
50
|
+
"type": ["object", "null"],
|
|
51
|
+
"required": ["base1", "head"],
|
|
52
|
+
"properties": {
|
|
53
|
+
"base1": { "type": ["string", "null"] },
|
|
54
|
+
"head": { "type": ["string", "null"] }
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"failure": {
|
|
58
|
+
"description": "v4: the typed failure behind an UNKNOWN that is not an arrival or wall outcome, and behind an arrival-failed whose two arms stalled on the same step (error_class same-step-stall); null otherwise. Source is open like unknown_reason: a source added later still reads.",
|
|
59
|
+
"type": ["object", "null"],
|
|
60
|
+
"required": ["source", "arm", "error_class", "error_code", "node_id"],
|
|
61
|
+
"properties": {
|
|
62
|
+
"source": { "type": "string", "description": "control-plane, transport, harness or runner" },
|
|
63
|
+
"arm": { "enum": ["base1", "head", null] },
|
|
64
|
+
"error_class": { "type": "string" },
|
|
65
|
+
"error_code": { "type": ["string", "null"] },
|
|
66
|
+
"node_id": { "type": ["string", "null"] }
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"phases": { "type": ["object", "null"] },
|
|
70
|
+
"vm_ids": { "type": "array", "items": { "type": "string" } },
|
|
71
|
+
"lease_ids": { "type": "array", "items": { "type": "string" } }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
"limits": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"required": ["max_concurrent_cases", "per_case_wall_ms", "total_budget_ms"],
|
|
80
|
+
"properties": {
|
|
81
|
+
"max_concurrent_cases": { "type": "integer", "minimum": 1, "maximum": 500 },
|
|
82
|
+
"per_case_wall_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 },
|
|
83
|
+
"total_budget_ms": { "type": "integer", "minimum": 10000, "maximum": 1800000 }
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"source_patch_attached": { "type": "boolean" },
|
|
87
|
+
"status": { "enum": ["queued", "preparing", "searching", "draining", "completed", "incomplete", "cancelled"] },
|
|
88
|
+
"terminal": { "type": "boolean" },
|
|
89
|
+
"timed_out": { "type": "boolean" },
|
|
90
|
+
"generation": { "type": "integer", "minimum": 0 },
|
|
91
|
+
"observed_at": { "type": "string" },
|
|
92
|
+
"cleanup_complete": { "type": "boolean" },
|
|
93
|
+
"vm_ids": { "type": "integer", "minimum": 0 },
|
|
94
|
+
"worlds": {
|
|
95
|
+
"type": "object",
|
|
96
|
+
"properties": {
|
|
97
|
+
"base": { "type": ["string", "null"] },
|
|
98
|
+
"head": { "type": ["string", "null"] }
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"clocks": {
|
|
102
|
+
"type": "object",
|
|
103
|
+
"properties": {
|
|
104
|
+
"preparation_started_at": { "type": ["string", "null"] },
|
|
105
|
+
"search_started_at": { "type": ["string", "null"] },
|
|
106
|
+
"launches_closed_at": { "type": ["string", "null"] },
|
|
107
|
+
"finished_at": { "type": ["string", "null"] },
|
|
108
|
+
"search_budget_ms": { "type": "integer", "minimum": 0 },
|
|
109
|
+
"search_wall_ms": { "type": "integer", "minimum": 0 },
|
|
110
|
+
"drain_wall_ms": { "type": "integer", "minimum": 0 }
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"totals": {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"required": ["cases", "launched", "settled", "agree", "differ", "unknown", "cleanup_proven", "cleanup_unproven", "leases_released"],
|
|
116
|
+
"properties": {
|
|
117
|
+
"cases": { "type": "integer", "minimum": 0 },
|
|
118
|
+
"launched": { "type": "integer", "minimum": 0 },
|
|
119
|
+
"settled": { "type": "integer", "minimum": 0 },
|
|
120
|
+
"agree": { "type": "integer", "minimum": 0 },
|
|
121
|
+
"differ": { "type": "integer", "minimum": 0 },
|
|
122
|
+
"unknown": { "type": "integer", "minimum": 0 },
|
|
123
|
+
"cleanup_proven": { "type": "integer", "minimum": 0 },
|
|
124
|
+
"cleanup_unproven": { "type": "integer", "minimum": 0 },
|
|
125
|
+
"leases_released": { "type": "integer", "minimum": 0 }
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"admission": {
|
|
129
|
+
"type": "object",
|
|
130
|
+
"required": ["requested_cases", "admitted_cases", "pool", "free_vm_slots", "reserved_vm_slots", "worker_processes", "cases_per_process"],
|
|
131
|
+
"properties": {
|
|
132
|
+
"requested_cases": { "type": "integer", "minimum": 0 },
|
|
133
|
+
"admitted_cases": { "type": "integer", "minimum": 0 },
|
|
134
|
+
"pool": { "enum": ["small", "big"] },
|
|
135
|
+
"free_vm_slots": { "type": "integer", "minimum": 0 },
|
|
136
|
+
"reserved_vm_slots": { "type": "integer", "minimum": 0 },
|
|
137
|
+
"worker_processes": { "type": "integer", "minimum": 0 },
|
|
138
|
+
"cases_per_process": { "type": "integer", "minimum": 0 }
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
"unknown_reasons": { "type": "object", "additionalProperties": { "type": "integer", "minimum": 1 } },
|
|
142
|
+
"cases_returned": { "type": "integer", "minimum": 0 },
|
|
143
|
+
"cases_truncated": { "type": "boolean" },
|
|
144
|
+
"pages": { "type": "integer", "minimum": 1 },
|
|
145
|
+
"waves": {
|
|
146
|
+
"type": "array",
|
|
147
|
+
"items": {
|
|
148
|
+
"type": "object",
|
|
149
|
+
"required": ["wave", "at_ms", "launched"],
|
|
150
|
+
"properties": {
|
|
151
|
+
"wave": { "type": "integer", "minimum": 0 },
|
|
152
|
+
"at_ms": { "type": "integer", "minimum": 0 },
|
|
153
|
+
"launched": { "type": "integer", "minimum": 0 }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"waves_truncated": { "type": "boolean" },
|
|
158
|
+
"exit_code": { "type": "integer", "minimum": 0, "maximum": 2 },
|
|
159
|
+
"error": { "type": "string" },
|
|
160
|
+
"cancel_requested": { "const": true },
|
|
161
|
+
"previous_status": { "description": "On finalize, the batch's recorded status just before it.", "type": "string" },
|
|
162
|
+
"last_updated_at": { "description": "On finalize, the batch record's last update before it.", "type": "string" },
|
|
163
|
+
"eligibility": { "description": "On finalize, what made the batch finalizable.", "enum": ["cancel-requested", "stale"] },
|
|
164
|
+
"reason": { "description": "On finalize, the operator's reason, recorded as the batch error.", "type": "string" },
|
|
165
|
+
"finalized_at": { "type": "string" },
|
|
166
|
+
"next_command": { "type": "string" },
|
|
167
|
+
"out_dir": { "type": "string" },
|
|
168
|
+
"manifest": {
|
|
169
|
+
"type": "object",
|
|
170
|
+
"required": ["path", "sha256", "bytes"],
|
|
171
|
+
"properties": {
|
|
172
|
+
"path": { "const": "manifest.json" },
|
|
173
|
+
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
|
|
174
|
+
"bytes": { "type": "integer", "minimum": 0 }
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
"artifacts": {
|
|
178
|
+
"type": "array",
|
|
179
|
+
"items": {
|
|
180
|
+
"type": "object",
|
|
181
|
+
"required": ["path", "sha256", "bytes", "verified"],
|
|
182
|
+
"properties": {
|
|
183
|
+
"path": { "type": "string" },
|
|
184
|
+
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
|
|
185
|
+
"bytes": { "type": "integer", "minimum": 0 },
|
|
186
|
+
"verified": { "const": true }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
"artifact_count": { "type": "integer", "minimum": 0 },
|
|
191
|
+
"pending": {
|
|
192
|
+
"description": "On bundle, the listed PNGs the service reported as still uploading (HTTP 409, reason artifact-pending): the coordinator publishes a batch's PNGs after it is terminal. Nothing is written for them. Non-empty means the command exited 75 (EX_TEMPFAIL); retry, for example with --only for each path.",
|
|
193
|
+
"type": "array",
|
|
194
|
+
"items": { "type": "string" }
|
|
195
|
+
},
|
|
196
|
+
"failed": {
|
|
197
|
+
"description": "On bundle, the listed PNGs the service reported as failed for good (HTTP 410, reason artifact-failed): the coordinator gave up on their upload and recorded why. Nothing is written for them and a retry cannot fetch them. Non-empty with nothing pending means the command exited 1.",
|
|
198
|
+
"type": "array",
|
|
199
|
+
"items": {
|
|
200
|
+
"type": "object",
|
|
201
|
+
"additionalProperties": false,
|
|
202
|
+
"required": ["path", "error_class", "status", "message"],
|
|
203
|
+
"properties": {
|
|
204
|
+
"path": { "type": "string" },
|
|
205
|
+
"error_class": { "type": "string" },
|
|
206
|
+
"status": { "type": ["integer", "null"] },
|
|
207
|
+
"message": { "type": "string" }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
"total_bytes": { "type": "integer", "minimum": 0 }
|
|
212
|
+
},
|
|
213
|
+
"allOf": [
|
|
214
|
+
{
|
|
215
|
+
"if": { "properties": { "operation": { "const": "status" } }, "required": ["operation"] },
|
|
216
|
+
"then": { "required": ["repository", "status", "terminal", "timed_out", "totals", "cases", "cases_returned", "cases_truncated", "exit_code"] }
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
"if": { "properties": { "operation": { "const": "submit" } }, "required": ["operation"] },
|
|
220
|
+
"then": { "required": ["repository", "admitted", "base_commit", "head_commit", "idempotency_key", "cases", "limits", "next_command"] }
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"if": { "properties": { "operation": { "const": "cancel" } }, "required": ["operation"] },
|
|
224
|
+
"then": { "required": ["repository", "cancel_requested"] }
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"if": { "properties": { "operation": { "const": "finalize" } }, "required": ["operation"] },
|
|
228
|
+
"then": { "required": ["repository", "status", "terminal", "cleanup_complete", "generation", "previous_status", "last_updated_at", "eligibility", "reason", "finalized_at"] }
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"if": { "properties": { "operation": { "const": "bundle" } }, "required": ["operation"] },
|
|
232
|
+
"then": { "required": ["repository", "out_dir", "manifest", "artifacts", "artifact_count", "total_bytes"] }
|
|
233
|
+
}
|
|
234
|
+
]
|
|
235
|
+
}
|