@haystackeditor/cli 0.14.1 → 0.14.3

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.
@@ -24,7 +24,19 @@ import { trackError, trackSetupEvent } from '../utils/telemetry.js';
24
24
  // Constants
25
25
  // =============================================================================
26
26
  const HAYSTACK_API = 'https://haystackeditor.com';
27
+ // User-level GitHub calls (repo listing, App-installation check, token
28
+ // verification) go direct: they're scoped to the authenticated user, so OAuth
29
+ // App Policy org restrictions don't apply to them.
27
30
  const GITHUB_API = 'https://api.github.com';
31
+ // Per-repo reads + writes go through the auth-worker proxy, which swaps in the
32
+ // GitHub App installation token server-side. That's what lets the config write
33
+ // and the bootstrap PR succeed on orgs that restrict the OAuth app — the same
34
+ // path the web onboarding wizard uses. Authenticated with the CLI's GitHub
35
+ // token via `Authorization: Bearer` (the proxy validates it against GitHub
36
+ // /user, then uses its own installation/OAuth token upstream). Commits + PRs
37
+ // created this way are authored by haystack[bot], which is the intended
38
+ // behavior for onboarding config and matches the web wizard.
39
+ const GITHUB_PROXY = `${HAYSTACK_API}/api/github`;
28
40
  const CATEGORY_LABELS = {
29
41
  testing: 'Testing',
30
42
  style: 'Style',
@@ -33,16 +45,11 @@ const CATEGORY_LABELS = {
33
45
  docs: 'Documentation',
34
46
  other: 'Other',
35
47
  };
36
- const SIGNAL_TYPE_LABELS = {
37
- ci_check: 'CI Check',
38
- bot_review: 'Bot Review',
39
- status_check: 'Status Check',
40
- };
41
- const POLICY_TYPE_LABELS = {
42
- approval_count: 'Approval Count',
43
- codeowner_review: 'Codeowner Review',
44
- team_review: 'Team Review',
45
- area_owner: 'Area Owner',
48
+ const SEVERITY_LABELS = {
49
+ critical: 'Critical',
50
+ high: 'High',
51
+ medium: 'Medium',
52
+ low: 'Low',
46
53
  };
47
54
  async function fetchUserRepos(token) {
48
55
  const repos = [];
@@ -71,27 +78,6 @@ async function fetchUserRepos(token) {
71
78
  .filter((r) => !r.archived)
72
79
  .map((r) => r.full_name);
73
80
  }
74
- async function fetchExistingConfig(owner, repo, token) {
75
- const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/.haystack.json`, {
76
- headers: {
77
- Authorization: `Bearer ${token}`,
78
- Accept: 'application/vnd.github.v3+json',
79
- 'User-Agent': 'Haystack-CLI',
80
- },
81
- });
82
- if (response.status === 404)
83
- return null;
84
- if (!response.ok)
85
- throw new Error(`Failed to fetch config: ${response.status}`);
86
- const data = (await response.json());
87
- const raw = Buffer.from(data.content, 'base64').toString('utf-8');
88
- try {
89
- return { config: JSON.parse(raw), sha: data.sha };
90
- }
91
- catch {
92
- throw new Error('existing .haystack.json is malformed JSON — delete or fix it manually');
93
- }
94
- }
95
81
  // Bootstrap PR constants — kept in sync with the web wizard
96
82
  // (src/features/onboarding/services/onboardingApi.ts). The branch prefix and
97
83
  // the two labels are load-bearing: the worker's bootstrap merge path
@@ -100,14 +86,19 @@ const CONFIG_COMMIT_MSG = 'chore: configure Haystack via CLI setup wizard';
100
86
  const AUTO_MERGE_LABEL = 'haystack:auto-merge';
101
87
  const ONBOARDING_BOOTSTRAP_LABEL = 'haystack:onboarding-bootstrap';
102
88
  const BOOTSTRAP_PR_TITLE = 'Configure Haystack';
103
- const BOOTSTRAP_PR_BODY = 'This PR was opened automatically by the Haystack CLI setup wizard because the default branch is protected.\n\n' +
89
+ const BOOTSTRAP_PR_BODY_INTRO = 'This PR was opened automatically by the Haystack CLI setup wizard because the default branch is protected.\n\n' +
104
90
  'It adds the Haystack onboarding config:\n' +
105
- '- `.haystack.json` — enables the Haystack merge queue and review automation\n' +
106
- '- `.entire/settings.json` — enables AI-session trace capture (powers intent-drift triage)\n\n' +
107
- 'Both files are committed to this branch; `.entire/settings.json` lands in a follow-up commit ' +
108
- 'during setup step 7, so it may not be present the instant the PR opens.\n\n' +
109
- 'Haystack will bootstrap-merge this PR automatically once the labels are applied. ' +
91
+ '- `.haystack/pr-rules.yml` — rules Haystack enforces on every PR\n' +
92
+ '- `.haystack/review-policy.md` — path-scoped review policies + review instructions\n' +
93
+ '- `.haystack.json` your Haystack merge-queue settings\n' +
94
+ '- `.entire/settings.json` enables AI-session trace capture (powers intent-drift triage)\n\n';
95
+ // Auto-merge ON: Haystack bootstrap-merges the PR itself once labeled.
96
+ const BOOTSTRAP_PR_OUTRO_AUTOMERGE = 'Haystack will bootstrap-merge this PR automatically once the labels are applied. ' +
110
97
  'You can tweak the files from GitHub or re-run `haystack setup` anytime.';
98
+ // Auto-merge OFF: the repo opted out of auto-merge, so this PR is NOT enrolled
99
+ // (no labels). It waits for the user to review and merge it themselves.
100
+ const BOOTSTRAP_PR_OUTRO_MANUAL = 'Auto-merge is off for this repo, so this PR is yours to review: check the files and merge it ' +
101
+ 'whenever you\'re ready. You can tweak them from GitHub or re-run `haystack setup` anytime.';
111
102
  /**
112
103
  * Encode a branch name for use in a URL path while preserving `/`
113
104
  * separators (e.g. `release/stable`). encodeURIComponent on the whole
@@ -135,7 +126,7 @@ async function isBranchProtected(owner, repo, branch, token) {
135
126
  const headers = ghHeaders(token);
136
127
  const enc = encodeBranchPath(branch);
137
128
  // Legacy branch protection — the `protected` boolean is authoritative.
138
- const branchRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/branches/${enc}`, { headers });
129
+ const branchRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/branches/${enc}`, { headers });
139
130
  if (branchRes.ok) {
140
131
  const data = (await branchRes.json());
141
132
  if (data.protected === true)
@@ -146,7 +137,7 @@ async function isBranchProtected(owner, repo, branch, token) {
146
137
  }
147
138
  // Repository rulesets — `rules/branches` collapses every active rule on
148
139
  // the branch into one list. A `pull_request` rule = "must merge via PR".
149
- const rulesRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/rules/branches/${enc}`, { headers });
140
+ const rulesRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/rules/branches/${enc}`, { headers });
150
141
  if (rulesRes.ok) {
151
142
  const rules = (await rulesRes.json());
152
143
  if (rules.some((r) => r.type === 'pull_request'))
@@ -166,7 +157,7 @@ function ghHeaders(token) {
166
157
  };
167
158
  }
168
159
  async function getDefaultBranch(owner, repo, token) {
169
- const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, { headers: ghHeaders(token) });
160
+ const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}`, { headers: ghHeaders(token) });
170
161
  if (!res.ok) {
171
162
  throw new Error(`Failed to read repo metadata: ${res.status} — ${await res.text()}`);
172
163
  }
@@ -177,14 +168,14 @@ async function getBranchSha(owner, repo, branch, token) {
177
168
  // path — encodeURIComponent would turn `release/stable` into
178
169
  // `release%2Fstable` and 404. encodeBranchPath escapes each segment but
179
170
  // keeps the `/` separators.
180
- const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/ref/heads/${encodeBranchPath(branch)}`, { headers: ghHeaders(token) });
171
+ const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/ref/heads/${encodeBranchPath(branch)}`, { headers: ghHeaders(token) });
181
172
  if (!res.ok) {
182
173
  throw new Error(`Failed to read branch ref: ${res.status} — ${await res.text()}`);
183
174
  }
184
175
  return (await res.json()).object.sha;
185
176
  }
186
177
  async function createBranch(owner, repo, branch, sha, token) {
187
- const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/refs`, {
178
+ const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/refs`, {
188
179
  method: 'POST',
189
180
  headers: ghHeaders(token),
190
181
  body: JSON.stringify({ ref: `refs/heads/${branch}`, sha }),
@@ -193,11 +184,13 @@ async function createBranch(owner, repo, branch, sha, token) {
193
184
  throw new Error(`Failed to create branch: ${res.status} — ${await res.text()}`);
194
185
  }
195
186
  }
196
- async function createBootstrapPR(owner, repo, head, base, token) {
197
- const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/pulls`, {
187
+ async function createBootstrapPR(owner, repo, head, base, token, autoMergeEnabled) {
188
+ const body = BOOTSTRAP_PR_BODY_INTRO +
189
+ (autoMergeEnabled ? BOOTSTRAP_PR_OUTRO_AUTOMERGE : BOOTSTRAP_PR_OUTRO_MANUAL);
190
+ const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/pulls`, {
198
191
  method: 'POST',
199
192
  headers: ghHeaders(token),
200
- body: JSON.stringify({ title: BOOTSTRAP_PR_TITLE, head, base, body: BOOTSTRAP_PR_BODY }),
193
+ body: JSON.stringify({ title: BOOTSTRAP_PR_TITLE, head, base, body }),
201
194
  });
202
195
  if (!res.ok) {
203
196
  throw new Error(`Failed to open PR: ${res.status} — ${await res.text()}`);
@@ -222,14 +215,14 @@ async function createBootstrapPR(owner, repo, head, base, token) {
222
215
  async function createCommitWithFiles(owner, repo, baseSha, files, message, token) {
223
216
  const headers = ghHeaders(token);
224
217
  // The new tree extends the base commit's tree.
225
- const baseCommitRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/commits/${baseSha}`, { headers });
218
+ const baseCommitRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/commits/${baseSha}`, { headers });
226
219
  if (!baseCommitRes.ok) {
227
220
  throw new Error(`Failed to read base commit: ${baseCommitRes.status} — ${await baseCommitRes.text()}`);
228
221
  }
229
222
  const baseTreeSha = (await baseCommitRes.json()).tree.sha;
230
223
  // One blob per file.
231
224
  const treeEntries = await Promise.all(files.map(async (f) => {
232
- const blobRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/blobs`, {
225
+ const blobRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/blobs`, {
233
226
  method: 'POST',
234
227
  headers,
235
228
  body: JSON.stringify({ content: f.content, encoding: 'utf-8' }),
@@ -240,7 +233,7 @@ async function createCommitWithFiles(owner, repo, baseSha, files, message, token
240
233
  const blob = (await blobRes.json());
241
234
  return { path: f.path, mode: '100644', type: 'blob', sha: blob.sha };
242
235
  }));
243
- const treeRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/trees`, {
236
+ const treeRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/trees`, {
244
237
  method: 'POST',
245
238
  headers,
246
239
  body: JSON.stringify({ base_tree: baseTreeSha, tree: treeEntries }),
@@ -249,7 +242,7 @@ async function createCommitWithFiles(owner, repo, baseSha, files, message, token
249
242
  throw new Error(`Failed to create tree: ${treeRes.status} — ${await treeRes.text()}`);
250
243
  }
251
244
  const tree = (await treeRes.json());
252
- const commitRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/commits`, {
245
+ const commitRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/commits`, {
253
246
  method: 'POST',
254
247
  headers,
255
248
  body: JSON.stringify({ message, tree: tree.sha, parents: [baseSha] }),
@@ -280,7 +273,7 @@ async function buildEntireSettingsContent(repoFullName, token) {
280
273
  let originalContent = '';
281
274
  let exists = false;
282
275
  let existingSha = null;
283
- const getResp = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/.entire/settings.json`, { headers });
276
+ const getResp = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/.entire/settings.json`, { headers });
284
277
  if (getResp.ok) {
285
278
  exists = true;
286
279
  const data = (await getResp.json());
@@ -325,7 +318,7 @@ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token) {
325
318
  const labels = [AUTO_MERGE_LABEL, ONBOARDING_BOOTSTRAP_LABEL];
326
319
  let lastErr = '';
327
320
  for (let attempt = 1; attempt <= 3; attempt++) {
328
- const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/issues/${prNumber}/labels`, {
321
+ const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/issues/${prNumber}/labels`, {
329
322
  method: 'POST',
330
323
  headers: ghHeaders(token),
331
324
  body: JSON.stringify({ labels }),
@@ -345,28 +338,70 @@ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token) {
345
338
  `Add these labels to the PR manually so Haystack picks it up: ${labels.join(', ')}. ` +
346
339
  `Don't re-run \`haystack setup\` — that opens a duplicate PR.`);
347
340
  }
341
+ /** Wraps a contents-API write failure with its HTTP status so the caller can
342
+ * distinguish a protection rejection (409/422 → PR fallback) from a genuine
343
+ * error (→ surface it). */
344
+ class GitHubWriteError extends Error {
345
+ status;
346
+ body;
347
+ constructor(status, body) {
348
+ super(`GitHub write failed: ${status}`);
349
+ this.status = status;
350
+ this.body = body;
351
+ this.name = 'GitHubWriteError';
352
+ }
353
+ }
354
+ /** Read the blob SHA of an existing file (needed to update it). Non-OK (incl.
355
+ * 404 = file doesn't exist yet) returns null; the subsequent PUT then creates
356
+ * the file, and surfaces a real error if the write itself fails. */
357
+ async function fetchFileSha(owner, repo, path, token) {
358
+ const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/${path}`, { headers: ghHeaders(token) });
359
+ if (!res.ok)
360
+ return null;
361
+ const data = (await res.json());
362
+ return data.sha ?? null;
363
+ }
364
+ /** PUT a single file to the default branch via the contents API. Throws
365
+ * GitHubWriteError on any non-OK response so the caller can branch on status. */
366
+ async function putFile(owner, repo, path, content, message, sha, token) {
367
+ const body = {
368
+ message,
369
+ content: Buffer.from(content).toString('base64'),
370
+ };
371
+ if (sha)
372
+ body.sha = sha;
373
+ const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/${path}`, {
374
+ method: 'PUT',
375
+ headers: ghHeaders(token),
376
+ body: JSON.stringify(body),
377
+ });
378
+ if (!res.ok) {
379
+ throw new GitHubWriteError(res.status, await res.text().catch(() => ''));
380
+ }
381
+ }
348
382
  /**
349
- * Protected-branch fallback: build ONE commit containing both onboarding
350
- * files (`.haystack.json` + `.entire/settings.json`) via the Git Data API,
351
- * CREATE a branch ref at it, open a PR, and apply the auto-merge + bootstrap
352
- * labels.
383
+ * Protected-branch fallback: build ONE commit containing ALL the onboarding
384
+ * files (`configFiles` + `.entire/settings.json`) via the Git Data API, CREATE
385
+ * a branch ref at it, open a PR, and apply the auto-merge + bootstrap labels.
353
386
  *
354
- * Why both files in one commit: a ruleset whose `pull_request` rule targets
387
+ * Why every file in one commit: a ruleset whose `pull_request` rule targets
355
388
  * `~ALL` branches blocks ref *updates* on every branch — so you can neither
356
389
  * commit to the default branch NOR add a second commit to the onboarding
357
- * branch afterward. The only thing allowed is *creating* a ref. So both
358
- * bootstrap-critical files (`.haystack.json` enables the merge queue,
359
- * `.entire/settings.json` enables the trace capture intent-drift triage
360
- * runs on) must land in the single commit the branch is created at.
390
+ * branch afterward. The only thing allowed is *creating* a ref. So all the
391
+ * bootstrap-critical files (`.haystack/pr-rules.yml`, `.haystack/review-policy.md`,
392
+ * `.haystack.json` enables the merge queue, `.entire/settings.json` enables the
393
+ * trace capture intent-drift triage runs on) must land in the single commit the
394
+ * branch is created at. Mirrors the web wizard's PR-fallback path.
361
395
  */
362
- async function openBootstrapConfigPR(owner, repo, configContent, token, defaultBranch) {
396
+ async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBranch, autoMergeEnabled) {
363
397
  const baseSha = await getBranchSha(owner, repo, defaultBranch, token);
364
398
  // Branch prefix MUST stay `haystack/onboarding-` — the worker keys off it.
365
399
  // The timestamp avoids collisions when setup is re-run.
366
400
  const branchName = `haystack/onboarding-${Date.now()}`;
367
- const files = [
368
- { path: '.haystack.json', content: configContent },
369
- ];
401
+ const files = configFiles.map((f) => ({
402
+ path: f.path,
403
+ content: f.content,
404
+ }));
370
405
  // Bundle .entire/settings.json into the same commit — on a ~ALL ruleset
371
406
  // we can't add it later. It's repo-level config (independent of whether
372
407
  // this dev installs the Entire binary locally), so it rides along here.
@@ -399,137 +434,137 @@ async function openBootstrapConfigPR(owner, repo, configContent, token, defaultB
399
434
  }
400
435
  const commitSha = await createCommitWithFiles(owner, repo, baseSha, files, CONFIG_COMMIT_MSG, token);
401
436
  await createBranch(owner, repo, branchName, commitSha, token);
402
- const pr = await createBootstrapPR(owner, repo, branchName, defaultBranch, token);
403
- await addBootstrapLabels(owner, repo, pr.number, pr.html_url, token);
437
+ const pr = await createBootstrapPR(owner, repo, branchName, defaultBranch, token, autoMergeEnabled);
438
+ // Only enroll the PR for auto-merge when the repo hasn't opted out. Applying
439
+ // the labels on an auto-merge-disabled repo would force-merge a config PR the
440
+ // user wants to review themselves (e.g. a repo set auto-merge-off via the web
441
+ // wizard, then re-run through `haystack setup`). Without labels the worker
442
+ // never picks it up; the PR is still opened and analyzed, it just waits.
443
+ if (autoMergeEnabled) {
444
+ await addBootstrapLabels(owner, repo, pr.number, pr.html_url, token);
445
+ }
404
446
  return { prUrl: pr.html_url, entireSettingsBundled };
405
447
  }
406
448
  /**
407
- * Write `.haystack.json` to a repo. Tries a direct commit to the default
408
- * branch first; on a protected-branch / ruleset error, falls back to opening
409
- * a bootstrap PR (branch + commit + PR + labels) so setup still completes.
449
+ * Write the onboarding config files to a repo. Tries a direct commit per file
450
+ * to the default branch first; on a protected-branch / ruleset rejection
451
+ * (409/422 confirmed by a structured protection probe) falls back to ONE
452
+ * bootstrap PR containing all files. Mirrors the web wizard's
453
+ * writeOnboardingFiles.
410
454
  *
411
455
  * Returns `{ bootstrapPR }` when the PR fallback was used (so the caller can
412
- * thread the branch into step 7), `{}` on a clean direct commit.
456
+ * thread the branch into step 7), `{}` on clean direct commits.
413
457
  */
414
- async function writeConfigToRepo(owner, repo, config, existingSha, token) {
415
- const content = JSON.stringify(config, null, 2) + '\n';
416
- const body = {
417
- message: CONFIG_COMMIT_MSG,
418
- content: Buffer.from(content).toString('base64'),
419
- };
420
- if (existingSha)
421
- body.sha = existingSha;
422
- const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/.haystack.json`, {
423
- method: 'PUT',
424
- headers: ghHeaders(token),
425
- body: JSON.stringify(body),
426
- });
427
- if (response.ok)
458
+ async function writeOnboardingFilesToRepo(owner, repo, files, autoMergeEnabled, token) {
459
+ // Look up existing SHAs once on the default branch so updates to files that
460
+ // already exist carry the right sha. Done before any write so a mid-loop
461
+ // failure doesn't leave us guessing.
462
+ const shaByPath = new Map();
463
+ for (const f of files) {
464
+ shaByPath.set(f.path, await fetchFileSha(owner, repo, f.path, token));
465
+ }
466
+ try {
467
+ for (const f of files) {
468
+ await putFile(owner, repo, f.path, f.content, f.message, shaByPath.get(f.path) ?? null, token);
469
+ }
428
470
  return {};
429
- const err = await response.text();
430
- // A 409/422 means *something* rejected the direct commit. Probe GitHub's
431
- // structured protection signals to find out whether it was branch
432
- // protection / a ruleset (→ open a bootstrap PR) or a genuine bad request
433
- // (→ surface the error). Other statuses (auth, 404, 5xx) are never a
434
- // protected-branch situation, so don't bother probing.
435
- if (response.status === 409 || response.status === 422) {
471
+ }
472
+ catch (err) {
473
+ // Only a 409/422 can be a protection rejection. Anything else (auth, 404,
474
+ // 5xx, network) is a genuine error surface it.
475
+ const status = err instanceof GitHubWriteError ? err.status : 0;
476
+ if (status !== 409 && status !== 422)
477
+ throw err;
478
+ // Probe GitHub's *structured* protection signals to decide: protection
479
+ // (→ PR fallback) vs. a genuine bad request / sha conflict (→ surface the
480
+ // error). This replaces brittle error-message substring matching.
436
481
  const defaultBranch = await getDefaultBranch(owner, repo, token);
437
- if (await isBranchProtected(owner, repo, defaultBranch, token)) {
438
- // Protected — open a bootstrap PR instead of failing.
439
- const bootstrapPR = await openBootstrapConfigPR(owner, repo, content, token, defaultBranch);
440
- return { bootstrapPR };
482
+ if (!(await isBranchProtected(owner, repo, defaultBranch, token))) {
483
+ throw err;
441
484
  }
485
+ const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled);
486
+ return { bootstrapPR };
442
487
  }
443
- throw new Error(`Failed to write config: ${response.status} — ${err}`);
444
488
  }
445
- // =============================================================================
446
- // Merge helpers
447
- // =============================================================================
489
+ const SCAN_POLL_INTERVAL_MS = 1500;
490
+ // A run still marked `running` but whose KV state hasn't been touched for this
491
+ // long is treated as dead (CF isolate killed mid-scan). Mirrors the web's
492
+ // RESUME_FRESHNESS window so we surface a retryable error instead of hanging.
493
+ const SCAN_STALE_MS = 90_000;
448
494
  /**
449
- * Deep merge two plain objects. Arrays are replaced (not concatenated)
450
- * since config arrays like `rules` and `wait_for` are complete sets from
451
- * the wizard and should override, not append to, existing values.
495
+ * Run one unified onboarding scan for a single repo, returning the proposed
496
+ * rules / policies / instructions. Authenticated with the CLI's GitHub token
497
+ * (the scan endpoint validates it against GitHub /user, the same trust model as
498
+ * the cookie session the web uses).
452
499
  */
453
- function deepMerge(target, source) {
454
- const result = { ...target };
455
- for (const key of Object.keys(source)) {
456
- const srcVal = source[key];
457
- const tgtVal = result[key];
458
- if (srcVal !== null &&
459
- typeof srcVal === 'object' &&
460
- !Array.isArray(srcVal) &&
461
- tgtVal !== null &&
462
- typeof tgtVal === 'object' &&
463
- !Array.isArray(tgtVal)) {
464
- result[key] = deepMerge(tgtVal, srcVal);
465
- }
466
- else {
467
- result[key] = srcVal;
468
- }
469
- }
470
- return result;
471
- }
472
- // =============================================================================
473
- // Onboarding scan (SSE client)
474
- // =============================================================================
475
- async function runScan(repos, scanType, token) {
476
- const response = await fetch(`${HAYSTACK_API}/api/onboarding/scan`, {
500
+ async function runUnifiedScan(repo, token) {
501
+ const startResp = await fetch(`${HAYSTACK_API}/api/onboarding/scan`, {
477
502
  method: 'POST',
478
503
  headers: {
479
504
  'Content-Type': 'application/json',
480
505
  Authorization: `Bearer ${token}`,
481
506
  },
482
- body: JSON.stringify({ repos, scanType }),
507
+ body: JSON.stringify({ repo, scanType: 'unified' }),
483
508
  });
484
- if (!response.ok) {
485
- throw new Error(`Scan failed: ${response.status}`);
486
- }
487
- return parseSseStream(response);
488
- }
489
- async function parseSseStream(response) {
490
- const text = await response.text();
491
- const lines = text.split('\n');
492
- let items = [];
493
- let eventType = '';
494
- const eventDataLines = [];
495
- function flushEvent() {
496
- if (!eventType || eventDataLines.length === 0)
497
- return;
498
- const eventData = eventDataLines.join('\n');
499
- const parsed = JSON.parse(eventData);
500
- if (eventType === 'progress') {
501
- const progress = parsed;
502
- process.stdout.write(`\r${chalk.dim(' ' + progress.message)}`);
503
- if (progress.detail) {
504
- process.stdout.write(chalk.dim(` — ${progress.detail}`));
505
- }
506
- process.stdout.write('\x1b[K');
509
+ if (!startResp.ok) {
510
+ const text = await startResp.text().catch(() => '');
511
+ throw new Error(`Scan kickoff failed: ${startResp.status}${text ? ` ${text}` : ''}`.trim());
512
+ }
513
+ const { runId } = (await startResp.json());
514
+ if (!runId)
515
+ throw new Error('Scan kickoff returned no runId');
516
+ let lastProgressKey = '';
517
+ // eslint-disable-next-line no-constant-condition
518
+ while (true) {
519
+ await new Promise((resolve) => setTimeout(resolve, SCAN_POLL_INTERVAL_MS));
520
+ let statusResp;
521
+ try {
522
+ statusResp = await fetch(`${HAYSTACK_API}/api/onboarding/scan/status?runId=${encodeURIComponent(runId)}`, { headers: { Authorization: `Bearer ${token}` } });
507
523
  }
508
- else if (eventType === 'result') {
509
- items = parsed.items;
524
+ catch {
525
+ // Transient network blip — the KV state is authoritative and won't vanish
526
+ // from one failed poll, so keep going.
527
+ continue;
510
528
  }
511
- else if (eventType === 'error') {
512
- throw new Error(parsed.message);
529
+ if (statusResp.status === 404) {
530
+ // State expired (TTL) or runId never registered — surface, don't hang.
531
+ process.stdout.write('\r\x1b[K');
532
+ throw new Error('Scan state expired — please retry.');
513
533
  }
514
- eventType = '';
515
- eventDataLines.length = 0;
516
- }
517
- for (const line of lines) {
518
- if (line.startsWith('event: ')) {
519
- eventType = line.slice(7).trim();
534
+ if (!statusResp.ok) {
535
+ // Transient (5xx etc.) back off and retry on the next tick.
536
+ continue;
537
+ }
538
+ const state = (await statusResp.json());
539
+ if (state.progress) {
540
+ const key = `${state.progress.message}|${state.progress.detail ?? ''}`;
541
+ if (key !== lastProgressKey) {
542
+ lastProgressKey = key;
543
+ process.stdout.write(`\r${chalk.dim(' ' + state.progress.message)}`);
544
+ if (state.progress.detail)
545
+ process.stdout.write(chalk.dim(` — ${state.progress.detail}`));
546
+ process.stdout.write('\x1b[K');
547
+ }
548
+ }
549
+ if (state.status === 'done') {
550
+ process.stdout.write('\r\x1b[K');
551
+ if (!state.result)
552
+ throw new Error('Scan completed but returned no result.');
553
+ return {
554
+ rules: state.result.rules ?? [],
555
+ policies: state.result.policies ?? [],
556
+ instructions: state.result.instructions ?? [],
557
+ };
520
558
  }
521
- else if (line.startsWith('data: ')) {
522
- eventDataLines.push(line.slice(6));
559
+ if (state.status === 'error') {
560
+ process.stdout.write('\r\x1b[K');
561
+ throw new Error(state.error || 'Scan failed');
523
562
  }
524
- else if (line === '') {
525
- flushEvent();
563
+ if (state.status === 'running' && Date.now() - state.updatedAt > SCAN_STALE_MS) {
564
+ process.stdout.write('\r\x1b[K');
565
+ throw new Error('Scan stalled (no progress) — please retry.');
526
566
  }
527
567
  }
528
- // Flush any remaining buffered event at EOF (stream may not end with blank line)
529
- flushEvent();
530
- // Clear progress line
531
- process.stdout.write('\r\x1b[K');
532
- return items;
533
568
  }
534
569
  // =============================================================================
535
570
  // Display helpers
@@ -544,19 +579,18 @@ function printRule(rule, index) {
544
579
  console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${rule.name} ${cat}`);
545
580
  console.log(` ${chalk.dim(rule.description)}`);
546
581
  }
547
- function printSignal(signal, index) {
548
- const status = signal.enabled ? chalk.green('ON ') : chalk.dim('OFF');
549
- const type = chalk.dim(`[${SIGNAL_TYPE_LABELS[signal.type] || signal.type}]`);
550
- console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${signal.name} ${type}`);
551
- if (signal.pattern) {
552
- console.log(` ${chalk.dim(`Pattern: ${signal.pattern}`)}`);
553
- }
554
- }
555
582
  function printPolicy(policy, index) {
556
583
  const status = policy.enabled ? chalk.green('ON ') : chalk.dim('OFF');
557
- const type = chalk.dim(`[${POLICY_TYPE_LABELS[policy.type] || policy.type}]`);
558
- console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${policy.name} ${type}`);
559
- console.log(` ${chalk.dim(policy.description)}`);
584
+ const sev = chalk.dim(`[${SEVERITY_LABELS[policy.severity] || policy.severity}]`);
585
+ console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${policy.name} ${sev}`);
586
+ if (policy.paths.length > 0) {
587
+ console.log(` ${chalk.dim(`Paths: ${policy.paths.join(', ')}`)}`);
588
+ }
589
+ console.log(` ${chalk.dim(policy.reason)}`);
590
+ }
591
+ function printInstruction(instruction, index) {
592
+ const status = instruction.enabled ? chalk.green('ON ') : chalk.dim('OFF');
593
+ console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${instruction.text}`);
560
594
  }
561
595
  // =============================================================================
562
596
  // Wizard steps
@@ -745,51 +779,76 @@ async function stepSelectRepos(token) {
745
779
  console.log(chalk.green(` ${selectedRepos.length} repo(s) selected\n`));
746
780
  return selectedRepos;
747
781
  }
748
- async function stepScan(repos, scanType, stepNum, token) {
749
- const labels = {
750
- rules: 'coding rules',
751
- wait_for: 'CI/bot signals',
752
- policies: 'review policies',
753
- };
754
- console.log(chalk.bold(`\n Step ${stepNum}: Scanning for ${labels[scanType]}\n`));
755
- try {
756
- const items = await runScan(repos, scanType, token);
757
- if (items.length === 0) {
758
- console.log(chalk.dim(` No ${labels[scanType]} discovered.`));
782
+ // File paths written during onboarding (must match the web wizard + worker:
783
+ // src/features/onboarding/services/onboardingApi.ts + the merge-queue cron's
784
+ // ONBOARDING_ALLOWED_PATHS).
785
+ const PR_RULES_PATH = '.haystack/pr-rules.yml';
786
+ const REVIEW_POLICY_PATH = '.haystack/review-policy.md';
787
+ const HAYSTACK_CONFIG_PATH = '.haystack.json';
788
+ const RULES_COMMIT_MSG = 'chore: configure Haystack rules via CLI setup wizard';
789
+ const POLICY_COMMIT_MSG = 'chore: add review policies via CLI setup wizard';
790
+ /**
791
+ * Scan each selected repo with one unified scan and collect the proposed
792
+ * rules / policies / instructions per repo. A failed scan for one repo records
793
+ * an empty result (and a warning) rather than aborting the whole wizard.
794
+ */
795
+ async function stepScanRepos(repos, token) {
796
+ console.log(chalk.bold('\n Step 2: Scanning your repositories\n'));
797
+ console.log(chalk.dim(" Haystack reads each repo's PR history to learn its review conventions.\n"));
798
+ const byRepo = new Map();
799
+ for (const repo of repos) {
800
+ console.log(chalk.dim(` Scanning ${repo}...`));
801
+ try {
802
+ const result = await runUnifiedScan(repo, token);
803
+ byRepo.set(repo, result);
804
+ const counts = [];
805
+ if (result.rules.length)
806
+ counts.push(`${result.rules.length} rule(s)`);
807
+ if (result.policies.length)
808
+ counts.push(`${result.policies.length} policy/policies`);
809
+ if (result.instructions.length)
810
+ counts.push(`${result.instructions.length} instruction(s)`);
811
+ console.log(counts.length
812
+ ? chalk.green(` ✓ ${repo}: ${counts.join(', ')}`)
813
+ : chalk.dim(` ${repo}: nothing discovered`));
759
814
  }
760
- else {
761
- console.log(chalk.green(` Found ${items.length} ${labels[scanType]}`));
815
+ catch (err) {
816
+ console.log(chalk.yellow(` ${repo}: scan failed (${err.message})`));
817
+ console.log(chalk.dim(' Continuing — this repo will get a baseline config you can tune later.'));
818
+ byRepo.set(repo, { rules: [], policies: [], instructions: [] });
762
819
  }
763
- return items;
764
- }
765
- catch (err) {
766
- console.log(chalk.yellow(` Scan failed: ${err.message}`));
767
- console.log(chalk.dim(' Skipping this scan — you can configure manually later.\n'));
768
- return [];
769
820
  }
821
+ return byRepo;
770
822
  }
771
- async function stepReview(rules, signals, policies) {
772
- const totalItems = rules.length + signals.length + policies.length;
773
- if (totalItems === 0) {
823
+ /**
824
+ * Show the discovered rules / policies / instructions per repo and let the user
825
+ * toggle any off. Mutates the `enabled` flags on the items in `byRepo` in place.
826
+ */
827
+ async function stepReview(byRepo) {
828
+ const total = [...byRepo.values()].reduce((n, r) => n + r.rules.length + r.policies.length + r.instructions.length, 0);
829
+ if (total === 0) {
774
830
  console.log(chalk.dim('\n No items discovered — skipping review.\n'));
775
- return { rules, signals, policies };
831
+ return;
776
832
  }
777
- console.log(chalk.bold('\n Step 5: Review discovered configuration\n'));
833
+ console.log(chalk.bold('\n Step 3: Review discovered configuration\n'));
778
834
  console.log(chalk.dim(' Toggle items on/off. Only enabled items will be written.\n'));
779
- // Show current state
780
- if (rules.length > 0) {
781
- printSectionHeader('Coding Rules', rules.filter((r) => r.enabled).length, rules.length);
782
- rules.forEach((r, i) => printRule(r, i));
783
- }
784
- if (signals.length > 0) {
785
- printSectionHeader('Wait-For Signals', signals.filter((s) => s.enabled).length, signals.length);
786
- signals.forEach((s, i) => printSignal(s, i));
787
- }
788
- if (policies.length > 0) {
789
- printSectionHeader('Review Policies', policies.filter((p) => p.enabled).length, policies.length);
790
- policies.forEach((p, i) => printPolicy(p, i));
835
+ for (const [repo, result] of byRepo) {
836
+ if (result.rules.length + result.policies.length + result.instructions.length === 0)
837
+ continue;
838
+ console.log(`\n ${chalk.bold.cyan(repo)}`);
839
+ if (result.rules.length > 0) {
840
+ printSectionHeader('Coding Rules', result.rules.filter((r) => r.enabled).length, result.rules.length);
841
+ result.rules.forEach((r, i) => printRule(r, i));
842
+ }
843
+ if (result.policies.length > 0) {
844
+ printSectionHeader('Review Policies', result.policies.filter((p) => p.enabled).length, result.policies.length);
845
+ result.policies.forEach((p, i) => printPolicy(p, i));
846
+ }
847
+ if (result.instructions.length > 0) {
848
+ printSectionHeader('Review Instructions', result.instructions.filter((ins) => ins.enabled).length, result.instructions.length);
849
+ result.instructions.forEach((ins, i) => printInstruction(ins, i));
850
+ }
791
851
  }
792
- // Ask if they want to toggle anything
793
852
  const { wantToToggle } = await inquirer.prompt([
794
853
  {
795
854
  type: 'confirm',
@@ -798,127 +857,215 @@ async function stepReview(rules, signals, policies) {
798
857
  default: false,
799
858
  },
800
859
  ]);
801
- if (wantToToggle) {
802
- // Build a list of all items for toggling
803
- const allItems = [];
804
- if (rules.length > 0) {
805
- allItems.push(new inquirer.Separator('── Coding Rules ──'));
806
- rules.forEach((r) => {
807
- allItems.push({
808
- name: `${r.name} ${chalk.dim(`[${CATEGORY_LABELS[r.category] || r.category}]`)}`,
809
- value: `rule:${r.id}`,
810
- checked: r.enabled,
811
- });
812
- });
813
- }
814
- if (signals.length > 0) {
815
- allItems.push(new inquirer.Separator('── Wait-For Signals ──'));
816
- signals.forEach((s) => {
817
- allItems.push({
818
- name: `${s.name} ${chalk.dim(`[${SIGNAL_TYPE_LABELS[s.type] || s.type}]`)}`,
819
- value: `signal:${s.id}`,
820
- checked: s.enabled,
821
- });
822
- });
823
- }
824
- if (policies.length > 0) {
825
- allItems.push(new inquirer.Separator('── Review Policies ──'));
826
- policies.forEach((p) => {
827
- allItems.push({
828
- name: `${p.name} ${chalk.dim(`[${POLICY_TYPE_LABELS[p.type] || p.type}]`)}`,
829
- value: `policy:${p.id}`,
830
- checked: p.enabled,
831
- });
860
+ if (!wantToToggle)
861
+ return;
862
+ // NUL separates repo / kind / id in the checkbox value so it can't collide
863
+ // with any character in a repo slug or an item id.
864
+ const SEP = '\u0000';
865
+ const allItems = [];
866
+ for (const [repo, result] of byRepo) {
867
+ if (result.rules.length + result.policies.length + result.instructions.length === 0)
868
+ continue;
869
+ allItems.push(new inquirer.Separator(`── ${repo} ──`));
870
+ result.rules.forEach((r) => {
871
+ allItems.push({
872
+ name: `${r.name} ${chalk.dim(`[rule · ${CATEGORY_LABELS[r.category] || r.category}]`)}`,
873
+ value: `${repo}${SEP}rule${SEP}${r.id}`,
874
+ checked: r.enabled,
832
875
  });
833
- }
834
- const { enabled } = await inquirer.prompt([
835
- {
836
- type: 'checkbox',
837
- name: 'enabled',
838
- message: 'Select items to enable:',
839
- choices: allItems,
840
- pageSize: 20,
841
- },
842
- ]);
843
- // Apply toggling
844
- rules.forEach((r) => {
845
- r.enabled = enabled.includes(`rule:${r.id}`);
846
876
  });
847
- signals.forEach((s) => {
848
- s.enabled = enabled.includes(`signal:${s.id}`);
877
+ result.policies.forEach((p) => {
878
+ allItems.push({
879
+ name: `${p.name} ${chalk.dim(`[policy · ${SEVERITY_LABELS[p.severity] || p.severity}]`)}`,
880
+ value: `${repo}${SEP}policy${SEP}${p.id}`,
881
+ checked: p.enabled,
882
+ });
849
883
  });
850
- policies.forEach((p) => {
851
- p.enabled = enabled.includes(`policy:${p.id}`);
884
+ result.instructions.forEach((ins) => {
885
+ allItems.push({
886
+ name: `${ins.text} ${chalk.dim('[instruction]')}`,
887
+ value: `${repo}${SEP}instruction${SEP}${ins.id}`,
888
+ checked: ins.enabled,
889
+ });
852
890
  });
853
891
  }
854
- return { rules, signals, policies };
892
+ const { enabled } = await inquirer.prompt([
893
+ {
894
+ type: 'checkbox',
895
+ name: 'enabled',
896
+ message: 'Select items to enable:',
897
+ choices: allItems,
898
+ pageSize: 20,
899
+ },
900
+ ]);
901
+ const enabledSet = new Set(enabled);
902
+ for (const [repo, result] of byRepo) {
903
+ result.rules.forEach((r) => { r.enabled = enabledSet.has(`${repo}${SEP}rule${SEP}${r.id}`); });
904
+ result.policies.forEach((p) => { p.enabled = enabledSet.has(`${repo}${SEP}policy${SEP}${p.id}`); });
905
+ result.instructions.forEach((ins) => { ins.enabled = enabledSet.has(`${repo}${SEP}instruction${SEP}${ins.id}`); });
906
+ }
855
907
  }
856
- function buildConfig(rules, signals, policies) {
857
- const config = {
858
- version: 1,
908
+ // ── Config builders ──────────────────────────────────────────────────────
909
+ // Mirror src/features/onboarding/components/ConfirmStep.tsx (buildPrRules,
910
+ // buildReviewPolicyMarkdown) + services/onboardingApi.ts (serializePrRulesYaml,
911
+ // buildHaystackConfigJson) so the CLI and web wizard emit byte-identical files.
912
+ /** Build pr-rules.yml entries from the proposed rules. Everything onboarding
913
+ * surfaces is LLM-evaluated (natural-language rules extracted from review
914
+ * comments), so type:'llm' and the description doubles as the prompt. */
915
+ function buildPrRules(rules) {
916
+ const PROPOSED_TO_YML_CATEGORY = {
917
+ testing: 'patterns',
918
+ style: 'style',
919
+ security: 'security',
920
+ performance: 'performance',
921
+ docs: 'patterns',
922
+ other: 'patterns',
859
923
  };
860
- const enabledRules = rules.filter((r) => r.enabled);
861
- const enabledSignals = signals.filter((s) => s.enabled);
862
- const enabledPolicies = policies.filter((p) => p.enabled);
863
- if (enabledRules.length > 0) {
864
- config.rules = enabledRules.map((r) => ({
865
- name: r.name,
866
- description: r.description,
867
- category: r.category,
868
- }));
869
- }
870
- if (enabledSignals.length > 0) {
871
- config.wait_for = enabledSignals.map((s) => ({
872
- name: s.name,
873
- type: s.type,
874
- source: s.source,
875
- pattern: s.pattern,
876
- }));
877
- }
878
- if (enabledPolicies.length > 0) {
879
- config.review_policies = enabledPolicies.map((p) => ({
880
- name: p.name,
881
- type: p.type,
882
- description: p.description,
883
- config: p.config,
884
- }));
885
- }
886
- return config;
924
+ return rules
925
+ .filter((r) => r.enabled)
926
+ .map((r) => ({
927
+ id: r.id,
928
+ name: r.name,
929
+ type: 'llm',
930
+ severity: 'warning',
931
+ message: r.description,
932
+ category: PROPOSED_TO_YML_CATEGORY[r.category],
933
+ llm: { prompt: r.description },
934
+ }));
887
935
  }
888
- async function stepConfirm(selectedRepos, rules, signals, policies, token) {
889
- const config = buildConfig(rules, signals, policies);
890
- const enabledRules = rules.filter((r) => r.enabled);
891
- const enabledSignals = signals.filter((s) => s.enabled);
936
+ /** Render review-policy.md from path-scoped policies + universal instructions.
937
+ * Returns null when nothing is enabled (caller skips the file). */
938
+ function buildReviewPolicyMarkdown(policies, instructions) {
892
939
  const enabledPolicies = policies.filter((p) => p.enabled);
893
- const totalEnabled = enabledRules.length + enabledSignals.length + enabledPolicies.length;
894
- console.log(chalk.bold('\n Step 6: Confirm & write configuration\n'));
895
- // Summary
896
- const parts = [];
897
- if (enabledRules.length > 0)
898
- parts.push(`${enabledRules.length} rules`);
899
- if (enabledSignals.length > 0)
900
- parts.push(`${enabledSignals.length} signals`);
901
- if (enabledPolicies.length > 0)
902
- parts.push(`${enabledPolicies.length} policies`);
903
- if (totalEnabled === 0) {
904
- console.log(chalk.dim(' No items enabled. Config will contain version only.\n'));
940
+ const enabledInstructions = instructions.filter((i) => i.enabled);
941
+ if (enabledPolicies.length === 0 && enabledInstructions.length === 0)
942
+ return null;
943
+ const lines = ['# Review Policies', ''];
944
+ for (const policy of enabledPolicies) {
945
+ lines.push(`## ${policy.name}`);
946
+ lines.push(`- **Paths**: ${policy.paths.map((p) => '`' + p + '`').join(', ')}`);
947
+ lines.push(`- **Severity**: ${policy.severity}`);
948
+ lines.push(`- **Reason**: ${policy.reason}`);
949
+ lines.push('');
950
+ }
951
+ if (enabledInstructions.length > 0) {
952
+ lines.push('## Instructions');
953
+ for (const instr of enabledInstructions) {
954
+ lines.push(`- ${instr.text}`);
955
+ }
956
+ lines.push('');
905
957
  }
906
- else {
907
- console.log(` ${chalk.dim('Items:')} ${parts.join(', ')}`);
908
- }
909
- console.log(` ${chalk.dim('Repos:')} ${selectedRepos.join(', ')}`);
910
- // Preview
911
- console.log(chalk.dim('\n ┌─ .haystack.json ─────────────────────────────────'));
912
- const preview = JSON.stringify(config, null, 2);
913
- preview.split('\n').forEach((line) => {
914
- console.log(chalk.dim(' │ ') + chalk.white(line));
915
- });
916
- console.log(chalk.dim(' └─────────────────────────────────────────────────\n'));
958
+ return lines.join('\n');
959
+ }
960
+ /** `.haystack.json` enabling the merge queue, reflecting the user's auto-merge
961
+ * choice. Mirrors the web wizard's buildHaystackConfigJson shape. Auto-merge is
962
+ * the master switch: when off, nothing lands in the queue, so the two sub-flags
963
+ * follow it. */
964
+ function buildHaystackConfigJson(autoMerge) {
965
+ const config = {
966
+ preferences: { auto_merge: autoMerge },
967
+ merge_queue: {
968
+ merge_queue: autoMerge,
969
+ auto_resolve_conflicts: autoMerge,
970
+ auto_fix_ci_failures: autoMerge,
971
+ },
972
+ };
973
+ return JSON.stringify(config, null, 2) + '\n';
974
+ }
975
+ // Minimal YAML serializer for the pr-rules.yml shape (keeps us off a yaml dep;
976
+ // mirrors onboardingApi.ts serializePrRulesYaml).
977
+ function yamlScalar(s) {
978
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
979
+ }
980
+ function yamlBlockOrScalar(s, indentCols) {
981
+ if (!s.includes('\n'))
982
+ return yamlScalar(s);
983
+ const pad = ' '.repeat(indentCols);
984
+ const body = s.split('\n').map((line) => `${pad}${line}`).join('\n');
985
+ return `|\n${body}`;
986
+ }
987
+ function serializePrRulesYaml(rules) {
988
+ if (rules.length === 0) {
989
+ return '# No rules configured yet. Add entries under `rules:` below.\nrules: []\n';
990
+ }
991
+ const lines = ['rules:'];
992
+ for (const rule of rules) {
993
+ lines.push(` - id: ${yamlScalar(rule.id)}`);
994
+ lines.push(` name: ${yamlScalar(rule.name)}`);
995
+ lines.push(` type: ${rule.type}`);
996
+ lines.push(` severity: ${rule.severity}`);
997
+ if (rule.category)
998
+ lines.push(` category: ${yamlScalar(rule.category)}`);
999
+ lines.push(` message: ${yamlScalar(rule.message)}`);
1000
+ if (rule.llm) {
1001
+ lines.push(` llm:`);
1002
+ lines.push(` prompt: ${yamlBlockOrScalar(rule.llm.prompt, 8)}`);
1003
+ if (rule.llm.files)
1004
+ lines.push(` files: ${yamlScalar(rule.llm.files)}`);
1005
+ }
1006
+ if (rule.pattern) {
1007
+ lines.push(` pattern:`);
1008
+ lines.push(` regex: ${yamlScalar(rule.pattern.regex)}`);
1009
+ if (rule.pattern.files)
1010
+ lines.push(` files: ${yamlScalar(rule.pattern.files)}`);
1011
+ if (rule.pattern.exclude)
1012
+ lines.push(` exclude: ${yamlScalar(rule.pattern.exclude)}`);
1013
+ }
1014
+ }
1015
+ return lines.join('\n') + '\n';
1016
+ }
1017
+ /** Build the onboarding files to write for one repo from its (toggled) scan
1018
+ * result. `.haystack.json` is always written (it enables the merge queue); the
1019
+ * rules + policy files only when there's enabled content for them. */
1020
+ function buildOnboardingFiles(result, autoMerge) {
1021
+ const files = [];
1022
+ const prRules = buildPrRules(result.rules);
1023
+ if (prRules.length > 0) {
1024
+ files.push({ path: PR_RULES_PATH, content: serializePrRulesYaml(prRules), message: RULES_COMMIT_MSG });
1025
+ }
1026
+ const reviewPolicyMd = buildReviewPolicyMarkdown(result.policies, result.instructions);
1027
+ if (reviewPolicyMd) {
1028
+ files.push({ path: REVIEW_POLICY_PATH, content: reviewPolicyMd, message: POLICY_COMMIT_MSG });
1029
+ }
1030
+ files.push({ path: HAYSTACK_CONFIG_PATH, content: buildHaystackConfigJson(autoMerge), message: CONFIG_COMMIT_MSG });
1031
+ return files;
1032
+ }
1033
+ async function stepConfirm(byRepo, token) {
1034
+ const selectedRepos = [...byRepo.keys()];
1035
+ console.log(chalk.bold('\n Step 4: Confirm & write configuration\n'));
1036
+ // Per-repo summary of what will be written.
1037
+ for (const [repo, result] of byRepo) {
1038
+ const enabledRules = result.rules.filter((r) => r.enabled).length;
1039
+ const enabledPolicies = result.policies.filter((p) => p.enabled).length;
1040
+ const enabledInstructions = result.instructions.filter((i) => i.enabled).length;
1041
+ const parts = [];
1042
+ if (enabledRules > 0)
1043
+ parts.push(`${enabledRules} rule(s) → ${PR_RULES_PATH}`);
1044
+ if (enabledPolicies + enabledInstructions > 0) {
1045
+ parts.push(`${enabledPolicies + enabledInstructions} policy/instruction(s) → ${REVIEW_POLICY_PATH}`);
1046
+ }
1047
+ parts.push(`merge queue → ${HAYSTACK_CONFIG_PATH}`);
1048
+ console.log(` ${chalk.bold(repo)}`);
1049
+ parts.forEach((p) => console.log(` ${chalk.dim('•')} ${chalk.dim(p)}`));
1050
+ }
1051
+ console.log('');
1052
+ // Auto-merge is the master switch. Default ON (matches the web wizard default
1053
+ // + the repo-level preferences.auto_merge opt-in model). When off, the
1054
+ // bootstrap PR is opened but NOT labeled, so the user merges it manually and
1055
+ // no PRs auto-merge.
1056
+ const { autoMerge } = await inquirer.prompt([
1057
+ {
1058
+ type: 'confirm',
1059
+ name: 'autoMerge',
1060
+ message: 'Enable auto-merge? (Haystack merges PRs automatically once they pass review)',
1061
+ default: true,
1062
+ },
1063
+ ]);
917
1064
  const { confirmed } = await inquirer.prompt([
918
1065
  {
919
1066
  type: 'confirm',
920
1067
  name: 'confirmed',
921
- message: `Write .haystack.json to ${selectedRepos.length} repo(s)?`,
1068
+ message: `Write Haystack config to ${selectedRepos.length} repo(s)?`,
922
1069
  default: true,
923
1070
  },
924
1071
  ]);
@@ -926,24 +1073,27 @@ async function stepConfirm(selectedRepos, rules, signals, policies, token) {
926
1073
  console.log(chalk.yellow('\n Setup cancelled.\n'));
927
1074
  return { confirmed: false, bootstrapPRs: new Map() };
928
1075
  }
929
- // Write to each repo
930
1076
  const failedRepos = [];
931
1077
  const bootstrapPRs = new Map();
932
1078
  for (const repoFullName of selectedRepos) {
933
1079
  const [owner, repo] = repoFullName.split('/');
1080
+ const result = byRepo.get(repoFullName);
1081
+ const files = buildOnboardingFiles(result, autoMerge);
934
1082
  try {
935
1083
  process.stdout.write(chalk.dim(` Writing to ${repoFullName}...`));
936
- const existing = await fetchExistingConfig(owner, repo, token);
937
- const mergedConfig = existing ? deepMerge(existing.config, config) : config;
938
- const result = await writeConfigToRepo(owner, repo, mergedConfig, existing?.sha ?? null, token);
1084
+ const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token);
939
1085
  process.stdout.write('\r\x1b[K');
940
- if (result.bootstrapPR) {
941
- // Default branch was protected — config landed in a bootstrap PR
942
- // that Haystack will auto-merge once its labels are processed.
943
- // Step 7 adds .entire/settings.json to this same PR.
944
- bootstrapPRs.set(repoFullName, result.bootstrapPR);
945
- console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(' (default branch protected — opened a PR)'));
946
- console.log(chalk.dim(` ${result.bootstrapPR.prUrl}`));
1086
+ if (writeResult.bootstrapPR) {
1087
+ // Default branch was protected — config landed in a bootstrap PR. With
1088
+ // auto-merge on, Haystack merges it once its labels are processed; with
1089
+ // auto-merge off it waits for the user. Step 7 ensures
1090
+ // .entire/settings.json is on the same PR.
1091
+ bootstrapPRs.set(repoFullName, writeResult.bootstrapPR);
1092
+ const note = autoMerge
1093
+ ? ' (default branch protected — opened a PR)'
1094
+ : ' (default branch protected — opened a PR for you to review and merge)';
1095
+ console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(note));
1096
+ console.log(chalk.dim(` ${writeResult.bootstrapPR.prUrl}`));
947
1097
  }
948
1098
  else {
949
1099
  console.log(chalk.green(` ✓ ${repoFullName}`));
@@ -996,14 +1146,12 @@ export async function setupCommand() {
996
1146
  await stepEnsureAppInstalled(token);
997
1147
  // Step 1: Select repos
998
1148
  const selectedRepos = await stepSelectRepos(token);
999
- // Steps 2-4: Scan for rules, wait-for signals, and review policies
1000
- const rules = (await stepScan(selectedRepos, 'rules', 2, token));
1001
- const signals = (await stepScan(selectedRepos, 'wait_for', 3, token));
1002
- const policies = (await stepScan(selectedRepos, 'policies', 4, token));
1003
- // Step 5: Review
1004
- const reviewed = await stepReview(rules, signals, policies);
1005
- // Step 6: Confirm & write
1006
- const { confirmed, bootstrapPRs } = await stepConfirm(selectedRepos, reviewed.rules, reviewed.signals, reviewed.policies, token);
1149
+ // Step 2: Unified scan per repo (rules + policies + instructions)
1150
+ const byRepo = await stepScanRepos(selectedRepos, token);
1151
+ // Step 3: Review (toggle items off)
1152
+ await stepReview(byRepo);
1153
+ // Step 4: Confirm & write the config files
1154
+ const { confirmed, bootstrapPRs } = await stepConfirm(byRepo, token);
1007
1155
  if (!confirmed)
1008
1156
  return;
1009
1157
  // Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it
@@ -1125,7 +1273,7 @@ async function configureEntireSettingsViaAPI(repoFullName, token) {
1125
1273
  };
1126
1274
  if (existingSha)
1127
1275
  body.sha = existingSha;
1128
- const putResp = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/.entire/settings.json`, {
1276
+ const putResp = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/.entire/settings.json`, {
1129
1277
  method: 'PUT',
1130
1278
  headers,
1131
1279
  body: JSON.stringify(body),
@@ -1204,11 +1352,12 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1204
1352
  bundled++;
1205
1353
  }
1206
1354
  else {
1207
- // Step 6 shipped the bootstrap PR with .haystack.json only — the
1208
- // optional settings read failed transiently. Don't count it as
1209
- // configured; the warning was already shown in step 6.
1355
+ // The bootstrap PR shipped the config files but not
1356
+ // .entire/settings.json — its optional read failed transiently. Don't
1357
+ // count it as configured; the warning was already shown when the PR
1358
+ // was opened.
1210
1359
  console.log(chalk.yellow(` ⚠ ${repoFullName}`) +
1211
- chalk.dim(' — bootstrap PR has .haystack.json only; re-run setup to add session tracking'));
1360
+ chalk.dim(' — bootstrap PR has config files only; re-run setup to add session tracking'));
1212
1361
  }
1213
1362
  continue;
1214
1363
  }
package/dist/index.js CHANGED
@@ -19,6 +19,9 @@
19
19
  * npx @haystackeditor/cli pr-status 123 # Show PR status in Haystack pipeline
20
20
  * npx @haystackeditor/cli config # Manage preferences
21
21
  */
22
+ import { readFileSync } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { dirname, join } from 'node:path';
22
25
  import { Command } from 'commander';
23
26
  import { statusCommand } from './commands/status.js';
24
27
  import { initCommand } from './commands/init.js';
@@ -34,11 +37,23 @@ import { dismissCommand, markReviewedCommand, undismissCommand } from './command
34
37
  import { requestReviewCommand } from './commands/request-review.js';
35
38
  import { prStatusCommand } from './commands/pr-status.js';
36
39
  import { setupCommand } from './commands/setup.js';
40
+ /** Read the published version from package.json (dist/index.js → ../package.json)
41
+ * so `haystack --version` can't drift from the package version. Falls back
42
+ * gracefully so the flag never throws if the file can't be read. */
43
+ function getVersion() {
44
+ try {
45
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
46
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? '0.0.0';
47
+ }
48
+ catch {
49
+ return '0.0.0';
50
+ }
51
+ }
37
52
  const program = new Command();
38
53
  program
39
54
  .name('haystack')
40
55
  .description('Haystack CLI — automated PR review, triage, and merge queue')
41
- .version('0.14.1');
56
+ .version(getVersion());
42
57
  program
43
58
  .command('init')
44
59
  .description('Create .haystack.json configuration')
@@ -4,7 +4,27 @@
4
4
  * Provides functions for creating PRs, managing labels, etc.
5
5
  */
6
6
  import { resolveAuthContext } from './auth.js';
7
+ import { trackError } from './telemetry.js';
7
8
  const GITHUB_API_BASE = 'https://api.github.com';
9
+ // OAuth App Policy fallback. Orgs can restrict our OAuth app, which makes the
10
+ // user's token 403 on every API call to that org — so `haystack submit` can't
11
+ // open PRs, add labels, or request reviewers there. The auth-worker GitHub
12
+ // proxy swaps in the GitHub App installation token server-side (the App isn't
13
+ // subject to OAuth App Policy / SAML SSO enforcement the way the OAuth token
14
+ // is), so on such a 403 we retry the same request through it. Direct calls are
15
+ // tried first, so on unrestricted orgs nothing changes and PRs stay
16
+ // user-authored; only the fallback path is performed by the App (haystack[bot]).
17
+ const GITHUB_PROXY_BASE = 'https://haystackeditor.com/api/github';
18
+ /**
19
+ * A 403 that is NOT a rate-limit is almost always OAuth App Policy or SAML SSO
20
+ * enforcement blocking our OAuth app on this org — exactly what the
21
+ * installation-token proxy can bypass. Rate-limit 403s carry
22
+ * `x-ratelimit-remaining: 0` and the proxy can't help with those, so we don't
23
+ * retry them.
24
+ */
25
+ function isOAuthPolicyForbidden(response) {
26
+ return response.status === 403 && response.headers.get('x-ratelimit-remaining') !== '0';
27
+ }
8
28
  // Label definitions for Haystack workflow
9
29
  export const HAYSTACK_LABELS = {
10
30
  'haystack:auto-merge': {
@@ -43,8 +63,9 @@ async function resolveToken(token, options) {
43
63
  * Make an authenticated GitHub API request
44
64
  */
45
65
  async function githubApiRequest(method, path, token, body) {
46
- const url = `${GITHUB_API_BASE}${path}`;
47
- const response = await fetch(url, {
66
+ // body is serialized to a string (not a stream), so the same init can be
67
+ // reused for the proxy retry below.
68
+ const init = {
48
69
  method,
49
70
  headers: {
50
71
  Authorization: `Bearer ${token}`,
@@ -53,8 +74,17 @@ async function githubApiRequest(method, path, token, body) {
53
74
  'User-Agent': 'Haystack-CLI',
54
75
  },
55
76
  body: body ? JSON.stringify(body) : undefined,
56
- });
57
- return response;
77
+ };
78
+ const direct = await fetch(`${GITHUB_API_BASE}${path}`, init);
79
+ if (!isOAuthPolicyForbidden(direct))
80
+ return direct;
81
+ // Org restricts our OAuth app — retry through the proxy on the App's
82
+ // installation token. Same `Authorization: Bearer <github-token>`; the proxy
83
+ // validates it, then uses its own token upstream. If the proxy can't help
84
+ // (e.g. the App isn't installed on this owner), fall back to the original
85
+ // response so handleApiError surfaces GitHub's actual "org restricted" 403.
86
+ const proxied = await fetch(`${GITHUB_PROXY_BASE}${path}`, init);
87
+ return proxied.ok ? proxied : direct;
58
88
  }
59
89
  /**
60
90
  * Handle API errors consistently
@@ -66,6 +96,16 @@ async function handleApiError(response, context) {
66
96
  if (response.status === 403) {
67
97
  const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
68
98
  if (rateLimitRemaining === '0') {
99
+ // Log every rate-limit hit so we can watch real-world frequency and decide
100
+ // whether the CLI needs its own token bucket. Routes to PostHog (and on to
101
+ // Slack via the ops-alert pipeline). Fire-and-forget; never blocks.
102
+ trackError('cli_github_rate_limited', {
103
+ context,
104
+ status: 403,
105
+ rate_limit_limit: response.headers.get('x-ratelimit-limit'),
106
+ rate_limit_reset: response.headers.get('x-ratelimit-reset'),
107
+ rate_limit_resource: response.headers.get('x-ratelimit-resource'),
108
+ });
69
109
  throw new Error('GitHub API rate limit exceeded. Please wait and try again.');
70
110
  }
71
111
  throw new Error(`Permission denied: ${context}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {