@haystackeditor/cli 0.15.13 → 0.15.15

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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Command runner for verification contract commands.
3
+ *
4
+ * Every contract command (setup, preflight, scenario, safety check) runs
5
+ * through here so the evidence trail is uniform: output is streamed to the
6
+ * terminal AND teed to a log file under the artifact dir, and the result
7
+ * carries exit code + duration for the manifest's `commands_run` section.
8
+ */
9
+ import { spawn, spawnSync } from 'node:child_process';
10
+ import { createWriteStream } from 'node:fs';
11
+ import * as fs from 'node:fs/promises';
12
+ import * as path from 'node:path';
13
+ export async function runCommand(command, opts) {
14
+ const startedAt = Date.now();
15
+ let logStream;
16
+ if (opts.logFile) {
17
+ await fs.mkdir(path.dirname(opts.logFile), { recursive: true });
18
+ logStream = createWriteStream(opts.logFile);
19
+ logStream.write(`$ ${command}\n`);
20
+ }
21
+ const record = await new Promise((resolvePromise) => {
22
+ // detached puts the child in its own process group so a timeout kill
23
+ // reaches grandchildren (dev servers spawned by package-manager wrappers),
24
+ // not just the shell.
25
+ const child = spawn(command, {
26
+ cwd: opts.cwd,
27
+ shell: true,
28
+ detached: process.platform !== 'win32',
29
+ env: { ...process.env, ...opts.env, HAYSTACK_VERIFICATION: '1' },
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ });
32
+ let timedOut = false;
33
+ const timer = setTimeout(() => {
34
+ timedOut = true;
35
+ killTree(child.pid);
36
+ }, opts.timeoutMs);
37
+ const onChunk = (chunk, stderr) => {
38
+ logStream?.write(chunk);
39
+ if (!opts.quiet) {
40
+ (stderr ? process.stderr : process.stdout).write(chunk);
41
+ }
42
+ };
43
+ child.stdout?.on('data', (c) => onChunk(c, false));
44
+ child.stderr?.on('data', (c) => onChunk(c, true));
45
+ const finish = (exitCode) => {
46
+ clearTimeout(timer);
47
+ const status = timedOut ? 'timeout' : exitCode === 0 ? 'passed' : 'failed';
48
+ if (timedOut) {
49
+ logStream?.write(`\n[haystack] command timed out after ${opts.timeoutMs}ms and was killed\n`);
50
+ }
51
+ resolvePromise({
52
+ command,
53
+ status,
54
+ exit_code: exitCode,
55
+ duration_ms: Date.now() - startedAt,
56
+ });
57
+ };
58
+ child.on('error', (err) => {
59
+ logStream?.write(`\n[haystack] failed to spawn: ${err.message}\n`);
60
+ finish(null);
61
+ });
62
+ child.on('close', (code) => finish(code));
63
+ });
64
+ if (logStream) {
65
+ await new Promise((res) => logStream.end(res));
66
+ record.log_path = path.relative(opts.cwd, opts.logFile);
67
+ }
68
+ return record;
69
+ }
70
+ function killTree(pid) {
71
+ if (pid === undefined)
72
+ return;
73
+ try {
74
+ if (process.platform === 'win32') {
75
+ const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
76
+ windowsHide: true,
77
+ encoding: 'utf8',
78
+ });
79
+ if (result.error) {
80
+ throw result.error;
81
+ }
82
+ if (result.status !== 0) {
83
+ const detail = result.stderr.trim() || result.stdout.trim() || `taskkill exited with code ${result.status}`;
84
+ if (/not found|no running instance/i.test(detail)) {
85
+ return;
86
+ }
87
+ throw new Error(detail);
88
+ }
89
+ }
90
+ else {
91
+ // Negative pid → the whole process group (see `detached` above).
92
+ process.kill(-pid, 'SIGKILL');
93
+ }
94
+ }
95
+ catch (error) {
96
+ if (error.code === 'ESRCH') {
97
+ return;
98
+ }
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ process.stderr.write(`\n[haystack] failed to kill process tree for pid ${pid}: ${message}\n`);
101
+ }
102
+ }
@@ -0,0 +1,10 @@
1
+ export interface SafetyCheck {
2
+ name: string;
3
+ status: 'pass' | 'warn' | 'fail';
4
+ detail: string;
5
+ }
6
+ export interface SafetyReport {
7
+ ok: boolean;
8
+ checks: SafetyCheck[];
9
+ }
10
+ export declare function checkSafety(rootDir?: string): Promise<SafetyReport>;
@@ -0,0 +1,252 @@
1
+ /**
2
+ * `haystack verification check-safety` — verifies the verification setup
3
+ * itself can't hurt anyone:
4
+ *
5
+ * 1. The contract's safety policy forbids real side effects.
6
+ * 2. The repo's production-disabled check (if configured) actually passes.
7
+ * 3. Dev-only helper routes (/__dev/, log-me-in, dev-login) are gated
8
+ * behind an environment check.
9
+ * 4. Neither the contract nor collected artifacts contain secrets.
10
+ *
11
+ * Checks are heuristic by design (static scans, not proofs) — the strongest
12
+ * guarantee remains the repo-owned `safety.production_disabled_check` command,
13
+ * which should be a real test asserting dev routes 404 in production mode.
14
+ */
15
+ import * as fs from 'node:fs/promises';
16
+ import * as path from 'node:path';
17
+ import fg from 'fast-glob';
18
+ import { loadContract } from './contract.js';
19
+ import { runCommand } from './runner.js';
20
+ const SOURCE_GLOBS = ['**/*.{ts,tsx,js,jsx,mjs,cjs,py,rb,go,php}'];
21
+ const SCAN_IGNORE = [
22
+ '**/node_modules/**',
23
+ '**/dist/**',
24
+ '**/build/**',
25
+ '**/.next/**',
26
+ '**/vendor/**',
27
+ '**/.git/**',
28
+ '**/*.test.*',
29
+ '**/*.spec.*',
30
+ ];
31
+ const MAX_SCAN_FILES = 5000;
32
+ const MAX_FILE_BYTES = 512 * 1024;
33
+ /** Signatures of dev-only helpers that must never be reachable in production. */
34
+ const DEV_HELPER_PATTERNS = [
35
+ /\/__dev\//,
36
+ /log[-_]?me[-_]?in/i,
37
+ /dev[-_]login/i,
38
+ ];
39
+ /** Evidence that a file gates behavior on the runtime environment. */
40
+ const ENV_GUARD_PATTERNS = [
41
+ /NODE_ENV/,
42
+ /import\.meta\.env\.(DEV|PROD|MODE)/,
43
+ /RAILS_ENV/,
44
+ /DJANGO_SETTINGS|settings\.DEBUG/,
45
+ /APP_ENV|FLASK_ENV|ENVIRONMENT/,
46
+ ];
47
+ const SECRET_PATTERNS = [
48
+ { name: 'AWS access key', regex: /AKIA[0-9A-Z]{16}/ },
49
+ { name: 'GitHub token', regex: /gh[pousr]_[A-Za-z0-9]{36,}/ },
50
+ { name: 'GitHub fine-grained token', regex: /github_pat_[A-Za-z0-9_]{20,}/ },
51
+ { name: 'OpenAI-style key', regex: /sk-[A-Za-z0-9_-]{24,}/ },
52
+ { name: 'Anthropic key', regex: /sk-ant-[A-Za-z0-9_-]{24,}/ },
53
+ { name: 'Haystack CLI token', regex: /hsk_live_[A-Za-z0-9]{16,}/ },
54
+ { name: 'Slack token', regex: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
55
+ { name: 'Private key block', regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
56
+ ];
57
+ export async function checkSafety(rootDir = process.cwd()) {
58
+ const checks = [];
59
+ const loaded = await loadContract(rootDir);
60
+ if (loaded.status !== 'ok') {
61
+ checks.push({
62
+ name: 'contract',
63
+ status: 'fail',
64
+ detail: loaded.status === 'missing'
65
+ ? 'No verification contract — run `haystack verification init` first'
66
+ : `Contract is invalid: ${loaded.errors[0]}`,
67
+ });
68
+ return { ok: false, checks };
69
+ }
70
+ const contract = loaded.contract;
71
+ checkPolicy(contract, checks);
72
+ await runProductionDisabledCheck(contract, rootDir, checks);
73
+ await scanDevHelpers(rootDir, contract, checks);
74
+ await scanSecrets(rootDir, loaded.path, contract, checks);
75
+ return { ok: !checks.some((c) => c.status === 'fail'), checks };
76
+ }
77
+ function checkPolicy(contract, checks) {
78
+ const { safety } = contract;
79
+ const allowed = [
80
+ ['allow_real_email', safety.allow_real_email],
81
+ ['allow_real_payments', safety.allow_real_payments],
82
+ ['allow_customer_data', safety.allow_customer_data],
83
+ ].filter(([, v]) => v);
84
+ if (allowed.length > 0) {
85
+ for (const [flag] of allowed) {
86
+ checks.push({
87
+ name: 'safety-policy',
88
+ status: 'fail',
89
+ detail: `safety.${flag} is true — set it to false and use sandboxed/mocked services instead`,
90
+ });
91
+ }
92
+ }
93
+ else {
94
+ checks.push({
95
+ name: 'safety-policy',
96
+ status: 'pass',
97
+ detail: 'Policy forbids real email, real payments, and customer data',
98
+ });
99
+ }
100
+ }
101
+ async function runProductionDisabledCheck(contract, rootDir, checks) {
102
+ const command = contract.safety.production_disabled_check;
103
+ if (!command) {
104
+ checks.push({
105
+ name: 'production-disabled-check',
106
+ status: 'warn',
107
+ detail: 'No safety.production_disabled_check configured — add a test proving dev-only helpers are disabled in production builds',
108
+ });
109
+ return;
110
+ }
111
+ const logFile = path.resolve(rootDir, contract.environment.artifact_dir, 'logs', 'production-disabled-check.log');
112
+ const result = await runCommand(command, {
113
+ cwd: rootDir,
114
+ timeoutMs: 5 * 60 * 1000,
115
+ logFile,
116
+ quiet: true,
117
+ });
118
+ checks.push({
119
+ name: 'production-disabled-check',
120
+ status: result.status === 'passed' ? 'pass' : 'fail',
121
+ detail: result.status === 'passed'
122
+ ? `\`${command}\` passed (${(result.duration_ms / 1000).toFixed(1)}s)`
123
+ : `\`${command}\` ${result.status} (exit ${result.exit_code ?? 'n/a'}) — see ${result.log_path}`,
124
+ });
125
+ }
126
+ async function scanDevHelpers(rootDir, contract, checks) {
127
+ const files = await fg(SOURCE_GLOBS, {
128
+ cwd: rootDir,
129
+ ignore: [...SCAN_IGNORE, path.posix.join(contract.environment.artifact_dir, '**')],
130
+ dot: false,
131
+ onlyFiles: true,
132
+ suppressErrors: true,
133
+ });
134
+ const ungated = [];
135
+ let gatedCount = 0;
136
+ let skippedCount = 0;
137
+ for (const file of files.slice(0, MAX_SCAN_FILES)) {
138
+ const full = path.join(rootDir, file);
139
+ let content;
140
+ try {
141
+ const stat = await fs.stat(full);
142
+ if (stat.size > MAX_FILE_BYTES) {
143
+ skippedCount++;
144
+ continue;
145
+ }
146
+ content = await fs.readFile(full, 'utf-8');
147
+ }
148
+ catch {
149
+ skippedCount++;
150
+ continue;
151
+ }
152
+ if (!DEV_HELPER_PATTERNS.some((p) => p.test(content)))
153
+ continue;
154
+ if (ENV_GUARD_PATTERNS.some((p) => p.test(content))) {
155
+ gatedCount++;
156
+ }
157
+ else {
158
+ ungated.push(file);
159
+ }
160
+ }
161
+ if (ungated.length > 0) {
162
+ checks.push({
163
+ name: 'dev-helper-gating',
164
+ status: 'fail',
165
+ detail: `Dev-only helper code with no environment guard in: ${ungated.slice(0, 5).join(', ')}${ungated.length > 5 ? ` (+${ungated.length - 5} more)` : ''} — gate on NODE_ENV (or equivalent) so it cannot run in production`,
166
+ });
167
+ }
168
+ else {
169
+ checks.push({
170
+ name: 'dev-helper-gating',
171
+ status: 'pass',
172
+ detail: gatedCount > 0
173
+ ? `${gatedCount} file(s) with dev helpers all reference an environment guard`
174
+ : 'No dev-only helper routes detected',
175
+ });
176
+ }
177
+ if (files.length > MAX_SCAN_FILES) {
178
+ checks.push({
179
+ name: 'dev-helper-gating',
180
+ status: 'warn',
181
+ detail: `Scanned the first ${MAX_SCAN_FILES} of ${files.length} source files — coverage was truncated`,
182
+ });
183
+ }
184
+ if (skippedCount > 0) {
185
+ // Reduced coverage must be visible — silence reads as "covered".
186
+ checks.push({
187
+ name: 'dev-helper-gating',
188
+ status: 'warn',
189
+ detail: `${skippedCount} source file(s) were skipped (unreadable or >${MAX_FILE_BYTES / 1024}KB) and not scanned`,
190
+ });
191
+ }
192
+ }
193
+ async function scanSecrets(rootDir, contractPath, contract, checks) {
194
+ const targets = [contractPath];
195
+ const artifactDir = path.resolve(rootDir, contract.environment.artifact_dir);
196
+ const artifactFiles = await fg('**/*', {
197
+ cwd: artifactDir,
198
+ onlyFiles: true,
199
+ dot: true,
200
+ suppressErrors: true,
201
+ });
202
+ for (const file of artifactFiles)
203
+ targets.push(path.join(artifactDir, file));
204
+ const findings = [];
205
+ let skippedCount = 0;
206
+ for (const target of targets) {
207
+ let content;
208
+ try {
209
+ const stat = await fs.stat(target);
210
+ if (stat.size > 1024 * 1024) {
211
+ skippedCount++; // binary/huge artifacts — not scanned
212
+ continue;
213
+ }
214
+ content = await fs.readFile(target, 'utf-8');
215
+ }
216
+ catch {
217
+ skippedCount++;
218
+ continue;
219
+ }
220
+ for (const { name, regex } of SECRET_PATTERNS) {
221
+ const match = content.match(regex);
222
+ if (match) {
223
+ findings.push(`${name} in ${path.relative(rootDir, target)} (${redact(match[0])})`);
224
+ }
225
+ }
226
+ }
227
+ if (findings.length > 0) {
228
+ checks.push({
229
+ name: 'secret-scan',
230
+ status: 'fail',
231
+ detail: `Possible secrets found: ${findings.join('; ')} — redact logs/artifacts and rotate anything real`,
232
+ });
233
+ }
234
+ else {
235
+ checks.push({
236
+ name: 'secret-scan',
237
+ status: 'pass',
238
+ detail: `No secrets detected in the contract or ${artifactFiles.length - skippedCount} artifact file(s)`,
239
+ });
240
+ }
241
+ if (skippedCount > 0) {
242
+ // Reduced coverage must be visible — silence reads as "covered".
243
+ checks.push({
244
+ name: 'secret-scan',
245
+ status: 'warn',
246
+ detail: `${skippedCount} file(s) were skipped (unreadable or >1MB) and not scanned for secrets`,
247
+ });
248
+ }
249
+ }
250
+ function redact(secret) {
251
+ return `${secret.slice(0, 8)}…`;
252
+ }
@@ -0,0 +1,12 @@
1
+ export type Readiness = 'ready' | 'partially_ready' | 'not_ready' | 'unsafe';
2
+ export type CheckLevel = 'pass' | 'warn' | 'fail' | 'unsafe';
3
+ export interface ValidationItem {
4
+ level: CheckLevel;
5
+ message: string;
6
+ }
7
+ export interface ValidationReport {
8
+ readiness: Readiness;
9
+ contract_path: string;
10
+ items: ValidationItem[];
11
+ }
12
+ export declare function validateContract(rootDir?: string): Promise<ValidationReport>;
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Contract validator — answers "can Haystack trust this repo's verification
3
+ * setup?" without running anything expensive.
4
+ *
5
+ * Readiness verdicts:
6
+ * ready all checks pass
7
+ * partially_ready usable, but with gaps (warnings)
8
+ * not_ready contract missing/invalid or references broken commands
9
+ * unsafe the contract permits real external side effects, or
10
+ * defines login helpers with no production-disabled proof
11
+ */
12
+ import * as fs from 'node:fs/promises';
13
+ import * as path from 'node:path';
14
+ import { loadContract } from './contract.js';
15
+ /** Package-manager subcommands that are built-ins, not package.json scripts. */
16
+ const PM_BUILTINS = new Set([
17
+ 'install', 'i', 'ci', 'add', 'remove', 'rm', 'uninstall', 'update', 'up', 'upgrade',
18
+ 'exec', 'dlx', 'npx', 'create', 'init', 'link', 'unlink', 'publish', 'pack',
19
+ 'audit', 'outdated', 'why', 'list', 'ls', 'prune', 'rebuild', 'config', 'store',
20
+ ]);
21
+ export async function validateContract(rootDir = process.cwd()) {
22
+ const loaded = await loadContract(rootDir);
23
+ if (loaded.status === 'missing') {
24
+ return {
25
+ readiness: 'not_ready',
26
+ contract_path: loaded.path,
27
+ items: [
28
+ {
29
+ level: 'fail',
30
+ message: `No contract found at ${path.relative(rootDir, loaded.path)} — run \`haystack verification init\``,
31
+ },
32
+ ],
33
+ };
34
+ }
35
+ if (loaded.status === 'invalid') {
36
+ return {
37
+ readiness: 'not_ready',
38
+ contract_path: loaded.path,
39
+ items: loaded.errors.map((error) => ({ level: 'fail', message: error })),
40
+ };
41
+ }
42
+ const contract = loaded.contract;
43
+ const items = [];
44
+ items.push({ level: 'pass', message: 'Contract parses and matches the schema' });
45
+ // ── Command references ─────────────────────────────────────────────────────
46
+ const packageScripts = await readPackageScripts(rootDir);
47
+ const commands = [];
48
+ const env = contract.environment;
49
+ if (env.setup)
50
+ commands.push({ label: 'environment.setup', command: env.setup });
51
+ if (env.start)
52
+ commands.push({ label: 'environment.start', command: env.start });
53
+ commands.push({ label: 'environment.preflight', command: env.preflight });
54
+ if (env.reset)
55
+ commands.push({ label: 'environment.reset', command: env.reset });
56
+ if (contract.safety.production_disabled_check) {
57
+ commands.push({
58
+ label: 'safety.production_disabled_check',
59
+ command: contract.safety.production_disabled_check,
60
+ });
61
+ }
62
+ for (const [name, scenario] of Object.entries(contract.scenarios)) {
63
+ commands.push({ label: `scenarios.${name}`, command: scenario.command });
64
+ }
65
+ for (const [name, persona] of Object.entries(contract.personas)) {
66
+ commands.push({ label: `personas.${name}.login`, command: persona.login });
67
+ }
68
+ for (const { label, command } of commands) {
69
+ const issue = await checkCommandReference(command, rootDir, packageScripts);
70
+ if (issue) {
71
+ items.push({ level: issue.level, message: `${label}: ${issue.message}` });
72
+ }
73
+ }
74
+ // ── Coverage warnings ──────────────────────────────────────────────────────
75
+ const scenarioNames = Object.keys(contract.scenarios);
76
+ items.push({
77
+ level: 'pass',
78
+ message: `${scenarioNames.length} scenario${scenarioNames.length === 1 ? '' : 's'} defined (${scenarioNames.join(', ')})`,
79
+ });
80
+ if (!scenarioNames.includes('smoke')) {
81
+ items.push({
82
+ level: 'warn',
83
+ message: 'No `smoke` scenario — add a basic logged-in smoke test so every risky PR has a baseline check',
84
+ });
85
+ }
86
+ if (Object.keys(contract.personas).length === 0) {
87
+ items.push({
88
+ level: 'warn',
89
+ message: 'No personas defined — logged-in and role-based flows cannot be verified',
90
+ });
91
+ }
92
+ if (!env.start) {
93
+ items.push({
94
+ level: 'warn',
95
+ message: 'No start command — scenarios must boot the app themselves',
96
+ });
97
+ }
98
+ if (!env.reset) {
99
+ items.push({
100
+ level: 'warn',
101
+ message: 'No reset command — repeated runs may not start from a clean state',
102
+ });
103
+ }
104
+ const artifactDir = path.resolve(rootDir, env.artifact_dir);
105
+ if (await pathExists(artifactDir)) {
106
+ items.push({ level: 'pass', message: `Artifact directory exists (${env.artifact_dir})` });
107
+ }
108
+ else {
109
+ items.push({
110
+ level: 'pass',
111
+ message: `Artifact directory configured (${env.artifact_dir}) — will be created on first run`,
112
+ });
113
+ }
114
+ // ── Safety ─────────────────────────────────────────────────────────────────
115
+ const safety = contract.safety;
116
+ for (const [flag, value] of [
117
+ ['allow_real_email', safety.allow_real_email],
118
+ ['allow_real_payments', safety.allow_real_payments],
119
+ ['allow_customer_data', safety.allow_customer_data],
120
+ ]) {
121
+ if (value) {
122
+ items.push({
123
+ level: 'unsafe',
124
+ message: `safety.${flag} is true — verification must not have real external side effects`,
125
+ });
126
+ }
127
+ }
128
+ if (Object.keys(contract.personas).length > 0 && !safety.production_disabled_check) {
129
+ items.push({
130
+ level: 'unsafe',
131
+ message: 'Personas/login helpers are defined but safety.production_disabled_check is missing — there is no proof dev login is disabled in production builds',
132
+ });
133
+ }
134
+ if (safety.production_disabled_check) {
135
+ items.push({ level: 'pass', message: 'Production-disabled check configured' });
136
+ }
137
+ return {
138
+ readiness: computeReadiness(items),
139
+ contract_path: loaded.path,
140
+ items,
141
+ };
142
+ }
143
+ function computeReadiness(items) {
144
+ if (items.some((i) => i.level === 'unsafe'))
145
+ return 'unsafe';
146
+ if (items.some((i) => i.level === 'fail'))
147
+ return 'not_ready';
148
+ if (items.some((i) => i.level === 'warn'))
149
+ return 'partially_ready';
150
+ return 'ready';
151
+ }
152
+ async function readPackageScripts(rootDir) {
153
+ try {
154
+ const pkg = JSON.parse(await fs.readFile(path.join(rootDir, 'package.json'), 'utf-8'));
155
+ return pkg.scripts ?? {};
156
+ }
157
+ catch {
158
+ return null; // Not a Node project — script checks don't apply.
159
+ }
160
+ }
161
+ /**
162
+ * Best-effort static check that a contract command points at something real.
163
+ * Only definitive breakage is a `fail`; ambiguous cases (bare `pnpm foo` that
164
+ * could resolve to a node_modules binary) are warnings.
165
+ */
166
+ async function checkCommandReference(command, rootDir, packageScripts) {
167
+ const tokens = command.trim().split(/\s+/);
168
+ // `bash scripts/x`, `sh ./scripts/x`, `node scripts/x.mjs`, or a direct
169
+ // `./scripts/x` — the referenced file must exist.
170
+ let scriptPath = null;
171
+ if (['bash', 'sh', 'node', 'python', 'python3', 'ruby'].includes(tokens[0]) && tokens[1]) {
172
+ scriptPath = tokens[1];
173
+ }
174
+ else if (tokens[0]?.startsWith('./') || tokens[0]?.startsWith('scripts/')) {
175
+ scriptPath = tokens[0];
176
+ }
177
+ if (scriptPath && !scriptPath.startsWith('-')) {
178
+ if (!(await pathExists(path.resolve(rootDir, scriptPath)))) {
179
+ return { level: 'fail', message: `references ${scriptPath}, which does not exist` };
180
+ }
181
+ return null;
182
+ }
183
+ // Package-manager script references.
184
+ if (packageScripts && ['pnpm', 'npm', 'yarn', 'bun'].includes(tokens[0])) {
185
+ let scriptName;
186
+ let explicitRun = false;
187
+ if (tokens[1] === 'run' && tokens[2]) {
188
+ scriptName = tokens[2];
189
+ explicitRun = true;
190
+ }
191
+ else if (tokens[1] && !tokens[1].startsWith('-')) {
192
+ scriptName = tokens[1];
193
+ }
194
+ if (!scriptName || PM_BUILTINS.has(scriptName))
195
+ return null;
196
+ if (packageScripts[scriptName])
197
+ return null;
198
+ if (explicitRun) {
199
+ return { level: 'fail', message: `script "${scriptName}" is not in package.json scripts` };
200
+ }
201
+ // Bare `pnpm foo` can also resolve to node_modules/.bin/foo.
202
+ if (await pathExists(path.join(rootDir, 'node_modules', '.bin', scriptName)))
203
+ return null;
204
+ return {
205
+ level: 'warn',
206
+ message: `"${scriptName}" is not in package.json scripts (and node_modules/.bin/${scriptName} was not found)`,
207
+ };
208
+ }
209
+ return null;
210
+ }
211
+ async function pathExists(p) {
212
+ try {
213
+ await fs.access(p);
214
+ return true;
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.13",
3
+ "version": "0.15.15",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,12 +40,14 @@
40
40
  "fast-glob": "3.3.3",
41
41
  "glob": "10.5.0",
42
42
  "inquirer": "9.3.8",
43
+ "ws": "8.19.0",
43
44
  "yaml": "2.8.2",
44
45
  "zod": "3.25.76"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@types/inquirer": "9.0.9",
48
49
  "@types/node": "20.19.30",
50
+ "@types/ws": "8.18.1",
49
51
  "typescript": "5.9.3"
50
52
  },
51
53
  "files": [