@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,67 @@
1
+ # Install Haystack Verification
2
+
3
+ Add a Haystack verification contract to this repository so Haystack (and any
4
+ coding agent) can safely verify risky PRs and produce reviewable evidence.
5
+
6
+ The deliverable is a small, reviewable PR containing:
7
+
8
+ - `.haystack/verification.yml` — the verification contract
9
+ - `scripts/haystack-preflight` — proves the app is up and answering
10
+ - `scripts/haystack-smoke` — boots the app, runs a basic logged-in check, captures evidence
11
+ - `.haystack/AGENTS.md` — how agents should use the contract
12
+
13
+ ## Ground rules
14
+
15
+ 1. **No production behavior changes.** Anything you add must be dev/test-only,
16
+ clearly isolated, and easy for a reviewer to verify as inert in production.
17
+ 2. **Prefer existing test utilities** (seed scripts, factories, test logins,
18
+ fixtures) over new application code.
19
+ 3. **Only add dev-only endpoints when necessary**, and gate them so they cannot
20
+ run in production (`NODE_ENV !== 'production'` or the framework equivalent).
21
+ If you add one, also add a test proving it is disabled in production mode,
22
+ and wire that test into `safety.production_disabled_check`.
23
+ 4. **Never reference real credentials, real payment providers, real email
24
+ delivery, or customer data.** Sandbox or mock everything external.
25
+ 5. **Keep the integration small.** A working smoke scenario beats ten
26
+ aspirational ones.
27
+
28
+ ## Steps
29
+
30
+ 1. **Scaffold.** Run `npx @haystackeditor/cli verification init` (or
31
+ `haystack verification init` if installed). This detects the framework and
32
+ writes starter files. Review everything it generated.
33
+
34
+ 2. **Inspect the repo.** Identify:
35
+ - framework, package manager, and how the app starts locally
36
+ - how the app knows it's "ready" (port, health endpoint, log line)
37
+ - existing seed/test data and how tests authenticate
38
+ - existing E2E/smoke tests you can reuse as scenarios
39
+
40
+ 3. **Fix the environment section.** Make `setup`, `start`, and `preflight`
41
+ real: `setup` must work on a clean clone; `preflight` must exit 0 only when
42
+ the app is actually usable (prefer a health endpoint over a port check).
43
+
44
+ 4. **Wire personas if a safe login path exists.** Personas are seeded fake
45
+ users (admin/member/viewer). Use an existing test-login helper if there is
46
+ one. If none exists and one is genuinely needed, add a dev-only helper per
47
+ ground rule 3 — otherwise leave personas empty.
48
+
49
+ 5. **Make the smoke scenario real.** It should boot the app, reach a
50
+ meaningful logged-in (or at least rendered) state, and leave evidence in the
51
+ artifact directory: server logs, the captured page, and screenshots if a
52
+ browser runner is available. If part of the flow can't be tested, append a
53
+ line to `<artifact_dir>/gaps.txt` saying what was skipped.
54
+
55
+ 6. **Validate until clean.** Iterate until both pass:
56
+ ```bash
57
+ haystack verification validate
58
+ haystack verification check-safety
59
+ haystack verification run smoke
60
+ ```
61
+ `validate` must not report `not_ready` or `unsafe`; `run smoke` must end
62
+ `verified` and write a manifest + review packet under the artifact dir.
63
+
64
+ 7. **Open a reviewable PR.** Keep it focused on the verification contract.
65
+ In the description, state explicitly: what was added, why each piece is
66
+ dev-only, and what the smoke scenario proves. Include the review packet
67
+ from your successful `run smoke` as evidence.
@@ -151,7 +151,20 @@ This uses GitHub device flow - no tokens to copy/paste.
151
151
  - The command uses your current branch as the PR head
152
152
  - Labels are applied automatically based on your chosen mode
153
153
  - You don't need to push manually - the command handles it
154
- - Use `haystack triage <pr>` to view full analysis results (rating, findings, agent fix prompts) for any PR at any time
154
+
155
+ ## Close the Loop After Submitting
156
+
157
+ After `haystack submit` creates the PR, **run `haystack triage <pr>` before ending your turn.**
158
+ It blocks until Haystack's full analysis completes (waking instantly on the completion event),
159
+ then prints the rating, findings, and a per-finding `agentFixPrompt`.
160
+
161
+ - **Findings marked auto-fixing**: leave them alone — Haystack's fixer is already on them
162
+ - **Actionable findings**: address them (the `agentFixPrompt` is written for you), commit, and
163
+ push — analysis re-runs on the new commit
164
+ - **Good to merge**: report the rating to your user and stop
165
+
166
+ Don't end the session right after submit without checking — the verdict usually lands within a
167
+ few minutes, and acting on it immediately is much cheaper than a human bouncing the PR back.
155
168
 
156
169
  ---
157
170
 
@@ -4,8 +4,10 @@
4
4
  * Creates .haystack.json with auto-detected settings.
5
5
  */
6
6
  import chalk from 'chalk';
7
+ import * as fs from 'fs/promises';
7
8
  import * as path from 'path';
8
9
  import { detectProject } from '../utils/detect.js';
10
+ import { parseRemoteUrl, UnsupportedRemoteUrlError } from '../utils/git.js';
9
11
  import { saveConfig, configExists } from '../utils/config.js';
10
12
  import { validateConfigSecurity, formatSecurityReport } from '../utils/secrets.js';
11
13
  export async function initCommand(options) {
@@ -32,7 +34,10 @@ export async function initCommand(options) {
32
34
  }
33
35
  console.log('');
34
36
  // Build config from detection (no interactive prompts)
35
- const config = buildConfigFromDetection(detected);
37
+ const config = await buildConfigFromDetection(detected);
38
+ if (config.dev_server) {
39
+ console.log(` Dev server: ${chalk.bold(config.dev_server.command)} (port ${config.dev_server.port})\n`);
40
+ }
36
41
  const configPath = await saveConfig(config);
37
42
  console.log(chalk.green(`✓ Created ${configPath}`));
38
43
  // Security validation
@@ -53,12 +58,14 @@ async function runSecurityCheck(configPath) {
53
58
  console.log(chalk.dim('\n' + formatSecurityReport(findings)));
54
59
  }
55
60
  }
56
- function buildConfigFromDetection(detected) {
61
+ async function buildConfigFromDetection(detected) {
57
62
  const env = {};
58
63
  if (detected.suggestedAuthBypass) {
59
64
  const [key, value] = detected.suggestedAuthBypass.split('=');
60
65
  env[key] = value || 'true';
61
66
  }
67
+ const pkgJson = await readPackageJson();
68
+ const name = resolveProjectName(pkgJson);
62
69
  if (detected.isMonorepo && detected.services?.length) {
63
70
  const services = {};
64
71
  for (const s of detected.services) {
@@ -74,12 +81,72 @@ function buildConfigFromDetection(detected) {
74
81
  }
75
82
  return {
76
83
  version: '1',
77
- name: path.basename(process.cwd()),
84
+ name,
78
85
  services,
79
86
  };
80
87
  }
88
+ const devServer = buildDevServer(detected, pkgJson?.scripts, env);
81
89
  return {
82
90
  version: '1',
83
- name: path.basename(process.cwd()),
91
+ name,
92
+ ...(devServer ? { dev_server: devServer } : {}),
84
93
  };
85
94
  }
95
+ /**
96
+ * Prefer the GitHub repo name, then package.json name, then the directory
97
+ * name. The directory basename alone is often a worktree or clone-dir name
98
+ * that has nothing to do with the project.
99
+ */
100
+ function resolveProjectName(pkgJson) {
101
+ try {
102
+ return parseRemoteUrl().repo;
103
+ }
104
+ catch (err) {
105
+ // Classify by type, not message text (PR007). A remote that exists but
106
+ // isn't recognized deserves a warning — the fallback name may not match
107
+ // the repo. A missing remote/repo is normal for fresh projects.
108
+ if (err instanceof UnsupportedRemoteUrlError) {
109
+ console.log(chalk.yellow(`⚠ Git remote URL not recognized (${err.message}); using package.json/directory name instead`));
110
+ }
111
+ else {
112
+ console.log(chalk.dim(' No git remote found; using package.json/directory name'));
113
+ }
114
+ }
115
+ return pkgJson?.name || path.basename(process.cwd());
116
+ }
117
+ /**
118
+ * Build a dev_server block for single-repo projects, but only when there is
119
+ * real evidence of one: a dev/start script in package.json. Without that
120
+ * gate we'd fabricate commands like `npm dev` that don't exist.
121
+ */
122
+ function buildDevServer(detected, scripts, env) {
123
+ const script = scripts?.dev ? 'dev' : scripts?.start ? 'start' : null;
124
+ if (!script)
125
+ return undefined;
126
+ const pm = detected.packageManager;
127
+ // npm and bun need the `run` form; pnpm/yarn run scripts directly.
128
+ const command = pm === 'npm' || pm === 'bun' ? `${pm} run ${script}` : `${pm} ${script}`;
129
+ return {
130
+ command,
131
+ port: detected.suggestedPort || 3000,
132
+ ready_pattern: detected.suggestedReadyPattern || 'ready|started|listening|Local:',
133
+ ...(Object.keys(env).length > 0 ? { env } : {}),
134
+ };
135
+ }
136
+ async function readPackageJson() {
137
+ try {
138
+ const raw = await fs.readFile(path.join(process.cwd(), 'package.json'), 'utf-8');
139
+ return JSON.parse(raw);
140
+ }
141
+ catch (err) {
142
+ // A missing package.json is normal (non-Node projects). Anything else —
143
+ // unreadable file, malformed JSON — degrades name resolution and
144
+ // dev-server detection, so say so instead of silently falling back.
145
+ if (err.code === 'ENOENT') {
146
+ return null;
147
+ }
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ console.log(chalk.yellow(`⚠ Could not read package.json (${message}); skipping dev-server detection`));
150
+ return null;
151
+ }
152
+ }
@@ -94,32 +94,31 @@ const SEVERITY_LABELS = {
94
94
  medium: 'Medium',
95
95
  low: 'Low',
96
96
  };
97
- async function fetchUserRepos(token) {
98
- const repos = [];
99
- let page = 1;
100
- // Fetch up to 200 repos (non-archived, sorted by recent push)
101
- while (page <= 2) {
102
- const response = await fetch(`${GITHUB_API}/user/repos?sort=pushed&per_page=100&page=${page}&type=owner`, {
103
- headers: {
104
- Authorization: `Bearer ${token}`,
105
- Accept: 'application/vnd.github.v3+json',
106
- 'User-Agent': 'Haystack-CLI',
107
- },
108
- });
109
- if (!response.ok) {
110
- throw new Error(`Failed to fetch repos: ${response.status}`);
97
+ // =============================================================================
98
+ // GitHub API helpers
99
+ // =============================================================================
100
+ /**
101
+ * Source the onboarding repo list from the user's Haystack GitHub App
102
+ * installations (resolved server-side with the App JWT via
103
+ * /api/github/user-installations) instead of `GET /user/repos` with the user's
104
+ * OAuth token. Every repo here is covered by an installation, so the picker can
105
+ * only ever offer repos Haystack can actually access — no OAuth GitHub-data call,
106
+ * and no "access denied" downstream when a chosen repo turns out not to be
107
+ * install-covered. Sorted most-recently-pushed first.
108
+ */
109
+ async function fetchInstallCoveredRepos(token) {
110
+ const installations = await fetchHaystackInstallations(token);
111
+ const pushedByName = new Map();
112
+ for (const inst of installations) {
113
+ for (const repo of inst.repositories ?? []) {
114
+ if (repo?.full_name && !pushedByName.has(repo.full_name)) {
115
+ pushedByName.set(repo.full_name, repo.pushed_at ?? '');
116
+ }
111
117
  }
112
- const batch = (await response.json());
113
- if (batch.length === 0)
114
- break;
115
- repos.push(...batch);
116
- if (batch.length < 100)
117
- break;
118
- page++;
119
118
  }
120
- return repos
121
- .filter((r) => !r.archived)
122
- .map((r) => r.full_name);
119
+ return [...pushedByName.entries()]
120
+ .sort((a, b) => b[1].localeCompare(a[1])) // ISO pushed_at desc → recent first
121
+ .map(([fullName]) => fullName);
123
122
  }
124
123
  // Bootstrap PR constants — kept in sync with the web wizard
125
124
  // (src/features/onboarding/services/onboardingApi.ts). The branch prefix and
@@ -762,9 +761,10 @@ async function stepEnsureAppInstalled(token) {
762
761
  async function stepSelectRepos(token) {
763
762
  console.log(chalk.bold('\n Step 1: Select repositories\n'));
764
763
  console.log(chalk.dim(' Fetching your repositories...'));
765
- const repos = await fetchUserRepos(token);
764
+ const repos = await fetchInstallCoveredRepos(token);
766
765
  if (repos.length === 0) {
767
- console.log(chalk.yellow('\n No repositories found.\n'));
766
+ console.log(chalk.yellow('\n No repositories found on your Haystack App installation(s).'));
767
+ console.log(chalk.dim(` Add repositories to the install at ${HAYSTACK_APP_INSTALL_URL}, then re-run \`haystack setup\`.\n`));
768
768
  process.exit(1);
769
769
  }
770
770
  let selectedRepos = [];
@@ -20,6 +20,7 @@
20
20
  * (default) Rich terminal output
21
21
  */
22
22
  import chalk from 'chalk';
23
+ import WebSocket from 'ws';
23
24
  import { checkAnalysisReady, fetchRatingSynthesis, fetchAutoFixManifest } from '../utils/analysis-api.js';
24
25
  import { parseRemoteUrl } from '../utils/git.js';
25
26
  import { loadToken } from '../utils/auth.js';
@@ -202,31 +203,111 @@ function printHookOutput(pr, synthesis, manifest, status) {
202
203
  }
203
204
  }
204
205
  // ============================================================================
205
- // Polling
206
+ // Waiting (event-driven with polling fallback)
206
207
  // ============================================================================
207
208
  const POLL_INTERVAL_MS = 5_000;
208
209
  const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
210
+ /** With a live event subscription, checks are event-driven; this slow cycle
211
+ * is only a safety net against a silently dead socket. */
212
+ const SUBSCRIBED_RECHECK_MS = 60_000;
213
+ const PR_EVENTS_WS_BASE = process.env.HAYSTACK_PR_EVENTS_WS_URL ?? 'wss://haystackeditor.com/api/pr-events';
214
+ /**
215
+ * Subscribe to the pr-events worker for this PR — the same real-time channel
216
+ * the web inbox uses. Any event (analysis_complete, commit_pushed, ...) calls
217
+ * `onEvent`, and the caller re-checks analysis state immediately instead of
218
+ * discovering it on the next poll tick. Returns null when a socket can't even
219
+ * be constructed; connection failures after that surface via isConnected().
220
+ */
221
+ function subscribeToPrEvents(owner, repo, prNumber, token, onEvent, quiet) {
222
+ // Diagnostics are dim stderr lines, once per failure mode — polling is the
223
+ // designed fallback, but WHY live updates are off must stay visible (PR001).
224
+ // quiet (--hook) mode stays terse: the hook output is machine-parsed.
225
+ const diag = (msg) => {
226
+ if (!quiet)
227
+ process.stderr.write(`\n ${chalk.dim(`[live updates unavailable: ${msg}; using polling]`)}\n`);
228
+ };
229
+ try {
230
+ const ws = new WebSocket(`${PR_EVENTS_WS_BASE}/${owner}/${repo}/${prNumber}/subscribe`, {
231
+ headers: { Authorization: `Bearer ${token}` },
232
+ });
233
+ let connected = false;
234
+ let reported = false;
235
+ ws.on('open', () => { connected = true; });
236
+ ws.on('message', () => onEvent());
237
+ ws.on('error', (err) => {
238
+ connected = false;
239
+ if (!reported) {
240
+ reported = true;
241
+ diag(err.message);
242
+ }
243
+ });
244
+ ws.on('close', () => { connected = false; });
245
+ const keepalive = setInterval(() => {
246
+ // A failed send means the socket died between ticks; the 'error'/'close'
247
+ // handlers above report it and flip isConnected() — nothing extra to say.
248
+ try {
249
+ ws.send('ping');
250
+ }
251
+ catch { /* surfaced via error/close handlers */ }
252
+ }, 30_000);
253
+ keepalive.unref?.();
254
+ return {
255
+ isConnected: () => connected,
256
+ close: () => {
257
+ clearInterval(keepalive);
258
+ // Idempotent teardown, not a fallback: close() on an already-dead
259
+ // socket can throw in ws and there is genuinely nothing to handle.
260
+ try {
261
+ ws.close();
262
+ }
263
+ catch { /* already closed */ }
264
+ },
265
+ };
266
+ }
267
+ catch (err) {
268
+ diag(err instanceof Error ? err.message : String(err));
269
+ return null;
270
+ }
271
+ }
209
272
  async function waitForAnalysis(owner, repo, prNumber, timeoutMs, token, quiet) {
210
273
  const deadline = Date.now() + timeoutMs;
211
274
  let consecutiveErrors = 0;
212
- while (Date.now() < deadline) {
213
- const result = await checkAnalysisReady(owner, repo, prNumber, token);
214
- if (result.status === 'ready')
215
- return 'ready';
216
- if (result.status === 'error') {
217
- consecutiveErrors++;
218
- if (consecutiveErrors >= 5)
219
- return 'error';
220
- }
221
- else {
222
- consecutiveErrors = 0;
223
- }
224
- if (!quiet) {
225
- process.stderr.write(`\r ${chalk.dim('Waiting for analysis to complete...')}${' '.repeat(5)}`);
275
+ // Event-driven wake: a pr-events message resolves the current sleep so the
276
+ // re-check happens the moment analysis completes, not on the next tick.
277
+ let wake = null;
278
+ const subscription = token
279
+ ? subscribeToPrEvents(owner, repo, prNumber, token, () => wake?.(), quiet)
280
+ : null;
281
+ try {
282
+ while (Date.now() < deadline) {
283
+ const result = await checkAnalysisReady(owner, repo, prNumber, token);
284
+ if (result.status === 'ready')
285
+ return 'ready';
286
+ if (result.status === 'error') {
287
+ consecutiveErrors++;
288
+ if (consecutiveErrors >= 5)
289
+ return 'error';
290
+ }
291
+ else {
292
+ consecutiveErrors = 0;
293
+ }
294
+ const live = subscription?.isConnected() ?? false;
295
+ if (!quiet) {
296
+ const mode = live ? 'Waiting for analysis (live)...' : 'Waiting for analysis to complete...';
297
+ process.stderr.write(`\r ${chalk.dim(mode)}${' '.repeat(5)}`);
298
+ }
299
+ const interval = Math.min(live ? SUBSCRIBED_RECHECK_MS : POLL_INTERVAL_MS, Math.max(deadline - Date.now(), 0));
300
+ await new Promise((resolve) => {
301
+ const timer = setTimeout(() => { wake = null; resolve(); }, interval);
302
+ timer.unref?.();
303
+ wake = () => { clearTimeout(timer); wake = null; resolve(); };
304
+ });
226
305
  }
227
- await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
306
+ return 'pending';
307
+ }
308
+ finally {
309
+ subscription?.close();
228
310
  }
229
- return 'pending';
230
311
  }
231
312
  // ============================================================================
232
313
  // PR resolution
@@ -0,0 +1,21 @@
1
+ export declare function verificationInitCommand(options: {
2
+ force?: boolean;
3
+ skill?: boolean;
4
+ }): Promise<void>;
5
+ export declare function verificationValidateCommand(options: {
6
+ json?: boolean;
7
+ }): Promise<void>;
8
+ export declare function verificationPreflightCommand(): Promise<void>;
9
+ export declare function verificationRunCommand(scenarioName: string, options: {
10
+ json?: boolean;
11
+ skipPreflight?: boolean;
12
+ setup?: boolean;
13
+ timeout?: number;
14
+ }): Promise<void>;
15
+ export declare function verificationCollectCommand(options: {
16
+ scenario?: string;
17
+ json?: boolean;
18
+ }): Promise<void>;
19
+ export declare function verificationCheckSafetyCommand(options: {
20
+ json?: boolean;
21
+ }): Promise<void>;