@haystackeditor/cli 0.25.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +39 -463
  2. package/dist/capture/app-config.js +30 -6
  3. package/dist/commands/capture-brief.js +45 -35
  4. package/dist/commands/capture-contract.js +4 -4
  5. package/dist/commands/crawl-report.js +196 -0
  6. package/dist/commands/db-profile-upload.js +3 -3
  7. package/dist/commands/feedback.js +66 -0
  8. package/dist/commands/init-telemetry.js +55 -9
  9. package/dist/commands/init.js +8 -3
  10. package/dist/commands/lockfile-pin.js +307 -0
  11. package/dist/commands/telemetry-token.js +3 -3
  12. package/dist/commands/tokens.js +4 -4
  13. package/dist/commands/verify-explore.js +3 -3
  14. package/dist/commands/verify-history.js +2 -2
  15. package/dist/commands/verify-onboarding.js +13 -16
  16. package/dist/commands/verify-precompute.js +3 -4
  17. package/dist/commands/verify.js +31 -118
  18. package/dist/index.js +55 -968
  19. package/dist/schema.js +4 -10
  20. package/dist/utils/haystack-api.js +8 -36
  21. package/package.json +1 -5
  22. package/schemas/feedback.v1.json +13 -0
  23. package/schemas/pre-verify.v2.json +240 -0
  24. package/schemas/verify-raw.v1.json +1132 -0
  25. package/schemas/verify.v2.json +655 -0
  26. package/dist/assets/hooks/agent-context/detect.ts +0 -316
  27. package/dist/assets/hooks/agent-context/format.ts +0 -100
  28. package/dist/assets/hooks/agent-context/index.ts +0 -41
  29. package/dist/assets/hooks/agent-context/parsers/claude.ts +0 -262
  30. package/dist/assets/hooks/agent-context/parsers/codex.ts +0 -416
  31. package/dist/assets/hooks/agent-context/parsers/gemini.ts +0 -155
  32. package/dist/assets/hooks/agent-context/parsers/opencode.ts +0 -174
  33. package/dist/assets/hooks/agent-context/tsconfig.json +0 -14
  34. package/dist/assets/hooks/agent-context/types.ts +0 -58
  35. package/dist/assets/hooks/llm-rules-template.md +0 -59
  36. package/dist/assets/hooks/package-lock.json +0 -598
  37. package/dist/assets/hooks/package.json +0 -12
  38. package/dist/assets/hooks/scripts/commit-msg.sh +0 -5
  39. package/dist/assets/hooks/scripts/post-commit.sh +0 -5
  40. package/dist/assets/hooks/scripts/pre-commit.sh +0 -175
  41. package/dist/assets/hooks/scripts/pre-push.sh +0 -25
  42. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +0 -5
  43. package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +0 -528
  44. package/dist/assets/hooks/truncation-checker/index.ts +0 -595
  45. package/dist/assets/hooks/truncation-checker/tsconfig.json +0 -13
  46. package/dist/assets/skills/map-cloud-verifier-universe/SKILL.md +0 -2051
  47. package/dist/assets/skills/map-cloud-verifier-universe/agents/openai.yaml +0 -4
  48. package/dist/assets/skills/map-cloud-verifier-universe/references/output-contract.md +0 -3411
  49. package/dist/assets/skills/map-your-system.md +0 -143
  50. package/dist/assets/skills/submit.md +0 -200
  51. package/dist/commands/ask.js +0 -20
  52. package/dist/commands/cloud-verifier-behaviors.js +0 -218
  53. package/dist/commands/cloud-verifier-data-store-census.js +0 -539
  54. package/dist/commands/cloud-verifier-data-store-drift.js +0 -158
  55. package/dist/commands/cloud-verifier-identity-census.js +0 -4060
  56. package/dist/commands/cloud-verifier-materialization.js +0 -704
  57. package/dist/commands/cloud-verifier-pascal-selector-census.js +0 -1382
  58. package/dist/commands/cloud-verifier-python-manifest-selector-census.js +0 -2015
  59. package/dist/commands/cloud-verifier-specialized-operational-census.js +0 -11432
  60. package/dist/commands/cloud-verifier-universe.js +0 -10178
  61. package/dist/commands/config.js +0 -549
  62. package/dist/commands/design-verify.js +0 -311
  63. package/dist/commands/dismiss.js +0 -159
  64. package/dist/commands/hooks.js +0 -226
  65. package/dist/commands/inbox.js +0 -137
  66. package/dist/commands/mcp.js +0 -201
  67. package/dist/commands/policy.js +0 -371
  68. package/dist/commands/pr-status.js +0 -207
  69. package/dist/commands/pr.js +0 -105
  70. package/dist/commands/prepare-universe-review.js +0 -1092
  71. package/dist/commands/production-source-deny-policy.js +0 -100
  72. package/dist/commands/request-review.js +0 -74
  73. package/dist/commands/review.js +0 -191
  74. package/dist/commands/rules.js +0 -98
  75. package/dist/commands/scaffold-provisional-universe.js +0 -806
  76. package/dist/commands/setup.js +0 -1170
  77. package/dist/commands/skills.js +0 -447
  78. package/dist/commands/status.js +0 -35
  79. package/dist/commands/submit.js +0 -745
  80. package/dist/commands/system-map.js +0 -228
  81. package/dist/commands/triage.js +0 -598
  82. package/dist/commands/webhooks.js +0 -241
  83. package/dist/states.js +0 -46
  84. package/dist/tools/detect.js +0 -832
  85. package/dist/triage/astra.js +0 -202
  86. package/dist/triage/prompts.js +0 -188
  87. package/dist/triage/runner.js +0 -200
  88. package/dist/triage/types.js +0 -7
  89. package/dist/types.js +0 -326
  90. package/dist/utils/action-output.js +0 -26
  91. package/dist/utils/analysis-api.js +0 -416
  92. package/dist/utils/config.js +0 -54
  93. package/dist/utils/design-verifier-api.js +0 -294
  94. package/dist/utils/design-verifier-history.js +0 -79
  95. package/dist/utils/design-verifier-result.js +0 -424
  96. package/dist/utils/github-api.js +0 -324
  97. package/dist/utils/pending-state.js +0 -86
  98. package/dist/utils/pr-ref.js +0 -56
  99. package/dist/utils/prompter.js +0 -328
  100. package/schemas/action.v1.json +0 -22
  101. package/schemas/ask.v1.json +0 -40
  102. package/schemas/inbox.v1.json +0 -27
  103. package/schemas/pr-status.v1.json +0 -61
  104. package/schemas/pr.v1.json +0 -97
  105. package/schemas/pr.v3.json +0 -45
  106. package/schemas/setup.v1.json +0 -75
  107. package/schemas/submit.v1.json +0 -90
  108. package/schemas/triage.v1.json +0 -103
  109. package/schemas/triage.v2.json +0 -64
@@ -1,1170 +0,0 @@
1
- /**
2
- * haystack setup - Interactive onboarding wizard
3
- *
4
- * CLI equivalent of the web onboarding wizard. Walks through:
5
- * 1. Login check
6
- * 2. Repository selection
7
- * 3. Scan for coding rules
8
- * 4. Scan for wait-for signals (CI checks, bot reviews)
9
- * 5. Scan for review policies
10
- * 6. Review discovered items (toggle on/off)
11
- * 7. Confirm & write .haystack.json to selected repos
12
- * 8. Offer to install local Haystack hooks
13
- */
14
- import chalk from 'chalk';
15
- import { execFileSync, execSync } from 'child_process';
16
- import { basename } from 'path';
17
- import { resolveAuthContext } from '../utils/auth.js';
18
- import { githubRestBaseFor } from '../utils/github-api.js';
19
- import { HAYSTACK_APP_INSTALL_URL } from '../utils/haystack-api.js';
20
- import { describeHookOverwrites, hooksInstall } from './hooks.js';
21
- import { installSessionHooks } from './install-session-hooks.js';
22
- import { findGitRoot } from '../utils/hooks.js';
23
- import { trackError } from '../utils/telemetry.js';
24
- import { createPrompter, loadAnswersFile, PromptCancelledError } from '../utils/prompter.js';
25
- /** The active prompter for this `setup` invocation (TTY or JSON). */
26
- let ui;
27
- /**
28
- * Translate CLI flags into a pre-supplied answer map keyed by question id.
29
- * Explicit flags win; `--answers` fills the rest. Anything still missing is
30
- * asked at runtime (TTY prompt or JSON `question` event).
31
- */
32
- function buildAnswerMap(options) {
33
- const answers = options.answersFile ? loadAnswersFile(options.answersFile) : {};
34
- if (options.repos && options.repos.length > 0)
35
- answers.select_repos = options.repos;
36
- if (typeof options.autoMerge === 'boolean')
37
- answers.auto_merge = options.autoMerge;
38
- if (options.yes) {
39
- // Accept the interactive wizard's defaults non-interactively: keep every
40
- // discovered item (skip the toggle UI), confirm the write, and say yes to
41
- // the same things the prompts default to (auto-merge and hooks). Use
42
- // --no-auto-merge to opt out. Only fill ids not
43
- // already set by an explicit flag or --answers.
44
- if (!('want_to_toggle' in answers))
45
- answers.want_to_toggle = false;
46
- if (!('confirm_write' in answers))
47
- answers.confirm_write = true;
48
- if (!('auto_merge' in answers)) {
49
- answers.auto_merge = true;
50
- // Consequential default applied without an interactive prompt — say so.
51
- console.error('--yes: enabling auto-merge (the wizard default). Pass --no-auto-merge to opt out.');
52
- }
53
- if (!('install_hooks' in answers))
54
- answers.install_hooks = true;
55
- // Don't auto-launch a browser in an unattended --yes run; the feed URL is
56
- // still printed and returned in the result for the caller to open.
57
- if (!('open_feed' in answers))
58
- answers.open_feed = false;
59
- }
60
- return answers;
61
- }
62
- // =============================================================================
63
- // Constants
64
- // =============================================================================
65
- const HAYSTACK_API = 'https://haystackeditor.com';
66
- // The token verification call below uses githubRestBaseFor: direct to GitHub
67
- // for an OAuth token (user-scoped, so OAuth App Policy does not apply), the
68
- // Haystack proxy for an hsk_live token, which must never reach GitHub.
69
- // Per-repo reads + writes go through the auth-worker proxy, which swaps in the
70
- // GitHub App installation token server-side. That's what lets the config write
71
- // and the bootstrap PR succeed on orgs that restrict the OAuth app — the same
72
- // path the web onboarding wizard uses. Authenticated with the CLI's GitHub
73
- // token via `Authorization: Bearer` (the proxy validates it against GitHub
74
- // /user, then uses its own installation/OAuth token upstream). Commits + PRs
75
- // created this way are authored by haystack[bot], which is the intended
76
- // behavior for onboarding config and matches the web wizard.
77
- const GITHUB_PROXY = `${HAYSTACK_API}/api/github`;
78
- const CATEGORY_LABELS = {
79
- testing: 'Testing',
80
- style: 'Style',
81
- security: 'Security',
82
- performance: 'Performance',
83
- docs: 'Documentation',
84
- other: 'Other',
85
- };
86
- const SEVERITY_LABELS = {
87
- critical: 'Critical',
88
- high: 'High',
89
- medium: 'Medium',
90
- low: 'Low',
91
- };
92
- // =============================================================================
93
- // GitHub API helpers
94
- // =============================================================================
95
- /**
96
- * Source the onboarding repo list from the user's Haystack GitHub App
97
- * installations (resolved server-side with the App JWT via
98
- * /api/github/user-installations) instead of `GET /user/repos` with the user's
99
- * OAuth token. Every repo here is covered by an installation, so the picker can
100
- * only ever offer repos Haystack can actually access — no OAuth GitHub-data call,
101
- * and no "access denied" downstream when a chosen repo turns out not to be
102
- * install-covered. Sorted most-recently-pushed first.
103
- */
104
- async function fetchInstallCoveredRepos(token) {
105
- const installations = await fetchHaystackInstallations(token);
106
- const pushedByName = new Map();
107
- for (const inst of installations) {
108
- for (const repo of inst.repositories ?? []) {
109
- if (repo?.full_name && !pushedByName.has(repo.full_name)) {
110
- pushedByName.set(repo.full_name, repo.pushed_at ?? '');
111
- }
112
- }
113
- }
114
- return [...pushedByName.entries()]
115
- .sort((a, b) => b[1].localeCompare(a[1])) // ISO pushed_at desc → recent first
116
- .map(([fullName]) => fullName);
117
- }
118
- // Bootstrap PR constants — kept in sync with the web wizard
119
- // (src/features/onboarding/services/onboardingApi.ts). The branch prefix and
120
- // the two labels are load-bearing: the worker's bootstrap merge path
121
- // (agent/cloudflare/src/merge-queue-cron.ts) keys off them.
122
- const CONFIG_COMMIT_MSG = 'chore: configure Haystack via CLI setup wizard';
123
- const AUTO_MERGE_LABEL = 'haystack:auto-merge';
124
- const ONBOARDING_BOOTSTRAP_LABEL = 'haystack:onboarding-bootstrap';
125
- const BOOTSTRAP_PR_TITLE = 'Configure Haystack';
126
- const BOOTSTRAP_PR_BODY_INTRO = 'This PR was opened automatically by the Haystack CLI setup wizard to configure Haystack for this repo.\n\n' +
127
- 'It adds the Haystack onboarding config:\n' +
128
- '- `.haystack/pr-rules.yml` — rules Haystack enforces on every PR\n' +
129
- '- `.haystack/review-policy.md` — path-scoped review policies + review instructions\n' +
130
- '- `.haystack.json` — your Haystack merge-queue settings\n\n';
131
- // Auto-merge ON: Haystack bootstrap-merges the PR itself once labeled.
132
- const BOOTSTRAP_PR_OUTRO_AUTOMERGE = 'Haystack will bootstrap-merge this PR automatically once the labels are applied. ' +
133
- 'You can tweak the files from GitHub or re-run `haystack setup` anytime.';
134
- // Auto-merge OFF: the repo opted out of auto-merge, so this PR is NOT enrolled
135
- // (no labels). It waits for the user to review and merge it themselves.
136
- 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 ' +
137
- 'whenever you\'re ready. You can tweak them from GitHub or re-run `haystack setup` anytime.';
138
- /**
139
- * Encode a branch name for use in a URL path while preserving `/`
140
- * separators (e.g. `release/stable`). encodeURIComponent on the whole
141
- * string would turn `/` into `%2F`, which several GitHub endpoints reject.
142
- */
143
- function encodeBranchPath(branch) {
144
- return branch.split('/').map(encodeURIComponent).join('/');
145
- }
146
- function ghHeaders(token) {
147
- return {
148
- Authorization: `Bearer ${token}`,
149
- Accept: 'application/vnd.github.v3+json',
150
- 'Content-Type': 'application/json',
151
- 'User-Agent': 'Haystack-CLI',
152
- };
153
- }
154
- async function getDefaultBranch(owner, repo, token) {
155
- const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}`, { headers: ghHeaders(token) });
156
- if (!res.ok) {
157
- throw new Error(`Failed to read repo metadata: ${res.status} — ${await res.text()}`);
158
- }
159
- return (await res.json()).default_branch;
160
- }
161
- async function getBranchSha(owner, repo, branch, token) {
162
- // GitHub's git/ref/heads/{ref} endpoint expects literal slashes in the ref
163
- // path — encodeURIComponent would turn `release/stable` into
164
- // `release%2Fstable` and 404. encodeBranchPath escapes each segment but
165
- // keeps the `/` separators.
166
- const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/ref/heads/${encodeBranchPath(branch)}`, { headers: ghHeaders(token) });
167
- if (!res.ok) {
168
- throw new Error(`Failed to read branch ref: ${res.status} — ${await res.text()}`);
169
- }
170
- return (await res.json()).object.sha;
171
- }
172
- async function createBranch(owner, repo, branch, sha, token) {
173
- const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/refs`, {
174
- method: 'POST',
175
- headers: ghHeaders(token),
176
- body: JSON.stringify({ ref: `refs/heads/${branch}`, sha }),
177
- });
178
- if (!res.ok) {
179
- throw new Error(`Failed to create branch: ${res.status} — ${await res.text()}`);
180
- }
181
- }
182
- async function createBootstrapPR(owner, repo, head, base, token, autoMergeEnabled) {
183
- const body = BOOTSTRAP_PR_BODY_INTRO +
184
- (autoMergeEnabled ? BOOTSTRAP_PR_OUTRO_AUTOMERGE : BOOTSTRAP_PR_OUTRO_MANUAL);
185
- const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/pulls`, {
186
- method: 'POST',
187
- headers: ghHeaders(token),
188
- body: JSON.stringify({ title: BOOTSTRAP_PR_TITLE, head, base, body }),
189
- });
190
- if (!res.ok) {
191
- throw new Error(`Failed to open PR: ${res.status} — ${await res.text()}`);
192
- }
193
- return (await res.json());
194
- }
195
- /**
196
- * Build a single commit containing `files` on top of `baseSha` via the Git
197
- * Data API (blobs → tree → commit). Returns the new commit sha.
198
- *
199
- * Why not `PUT /contents/`: that updates a ref, and a ruleset whose
200
- * `pull_request` rule targets `~ALL` branches blocks ref *updates* on
201
- * EVERY branch — including a fresh onboarding branch. So
202
- * createBranch-then-commitFileToBranch fails at the commit step. Building
203
- * the commit as a standalone object and then *creating* a ref at it only
204
- * ever creates a ref, never updates one (and there is no `creation`
205
- * restriction in those rulesets), so it gets through. Also works fine for
206
- * normally-protected repos. Critically: on a `~ALL` repo you cannot add a
207
- * second commit afterward either, so BOTH onboarding files must land in
208
- * this one commit.
209
- */
210
- async function createCommitWithFiles(owner, repo, baseSha, files, message, token) {
211
- const headers = ghHeaders(token);
212
- // The new tree extends the base commit's tree.
213
- const baseCommitRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/commits/${baseSha}`, { headers });
214
- if (!baseCommitRes.ok) {
215
- throw new Error(`Failed to read base commit: ${baseCommitRes.status} — ${await baseCommitRes.text()}`);
216
- }
217
- const baseTreeSha = (await baseCommitRes.json()).tree.sha;
218
- // One blob per file.
219
- const treeEntries = await Promise.all(files.map(async (f) => {
220
- const blobRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/blobs`, {
221
- method: 'POST',
222
- headers,
223
- body: JSON.stringify({ content: f.content, encoding: 'utf-8' }),
224
- });
225
- if (!blobRes.ok) {
226
- throw new Error(`Failed to create blob for ${f.path}: ${blobRes.status} — ${await blobRes.text()}`);
227
- }
228
- const blob = (await blobRes.json());
229
- return { path: f.path, mode: '100644', type: 'blob', sha: blob.sha };
230
- }));
231
- const treeRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/trees`, {
232
- method: 'POST',
233
- headers,
234
- body: JSON.stringify({ base_tree: baseTreeSha, tree: treeEntries }),
235
- });
236
- if (!treeRes.ok) {
237
- throw new Error(`Failed to create tree: ${treeRes.status} — ${await treeRes.text()}`);
238
- }
239
- const tree = (await treeRes.json());
240
- const commitRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/git/commits`, {
241
- method: 'POST',
242
- headers,
243
- body: JSON.stringify({ message, tree: tree.sha, parents: [baseSha] }),
244
- });
245
- if (!commitRes.ok) {
246
- throw new Error(`Failed to create commit: ${commitRes.status} — ${await commitRes.text()}`);
247
- }
248
- return (await commitRes.json()).sha;
249
- }
250
- async function addBootstrapLabels(owner, repo, prNumber, prUrl, token, autoMerge) {
251
- // CRITICAL, not best-effort. ONBOARDING_BOOTSTRAP_LABEL is the identity marker
252
- // that keeps the PR recognized in the user's Analyzing / Good to Merge tabs
253
- // (isOnboardingBootstrapPR) and lets the worker's bootstrap path discover it —
254
- // so it's ALWAYS applied. AUTO_MERGE_LABEL enrolls the PR for auto-merge, so add
255
- // it only when auto-merge is on. The label API failing is almost always
256
- // transient, so retry a few times before giving up.
257
- const labels = autoMerge
258
- ? [ONBOARDING_BOOTSTRAP_LABEL, AUTO_MERGE_LABEL]
259
- : [ONBOARDING_BOOTSTRAP_LABEL];
260
- let lastErr = '';
261
- for (let attempt = 1; attempt <= 3; attempt++) {
262
- const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/issues/${prNumber}/labels`, {
263
- method: 'POST',
264
- headers: ghHeaders(token),
265
- body: JSON.stringify({ labels }),
266
- });
267
- if (res.ok)
268
- return;
269
- lastErr = `${res.status} — ${await res.text()}`;
270
- if (attempt < 3) {
271
- await new Promise((resolve) => setTimeout(resolve, attempt * 500));
272
- }
273
- }
274
- // Retries exhausted. The PR exists with the config committed — it just
275
- // won't auto-merge without the labels. Throw an actionable error: name
276
- // the PR, name the exact labels, and warn against re-running (which would
277
- // open a duplicate PR on a fresh timestamped branch).
278
- throw new Error(`opened PR ${prUrl} but couldn't apply the auto-merge labels after 3 tries (${lastErr}). ` +
279
- `Add these labels to the PR manually so Haystack picks it up: ${labels.join(', ')}. ` +
280
- `Don't re-run \`haystack setup\` — that opens a duplicate PR.`);
281
- }
282
- /**
283
- * Assign the onboarding actor (the human running `haystack setup`) to the
284
- * bootstrap PR.
285
- *
286
- * Load-bearing for backfill parity with the web wizard
287
- * (src/features/onboarding/services/onboardingApi.ts → assignAuthorToPr): when
288
- * the bootstrap PR merges, the webhook handler's
289
- * `_extract_onboarding_backfill_actor` figures out whose existing open PRs to
290
- * analyze by reading the merged PR's assignees. The PR is authored by
291
- * haystack[bot] (excluded as a bot) and carries no actor marker, so WITHOUT an
292
- * assignee the actor can't be determined and the backfill is silently skipped
293
- * (`[ONBOARDING_BACKFILL] Could not determine onboarding actor`) — leaving a
294
- * freshly-onboarded user's pre-existing PRs un-analyzed. The web wizard assigns
295
- * the user; the CLI must do the same.
296
- *
297
- * Best-effort: the PR is already open + labeled (which is what gates onboarding
298
- * completion), so a failed assignment must not fail setup. Tracked so we notice
299
- * if it starts failing systemically (e.g. after a GitHub App permission
300
- * narrowing).
301
- */
302
- async function assignActorToPr(owner, repo, prNumber, login, token) {
303
- if (!login) {
304
- trackError('haystack_setup_assign_actor_no_login', { repo: `${owner}/${repo}`, pr_number: prNumber });
305
- return;
306
- }
307
- try {
308
- const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/issues/${prNumber}/assignees`, {
309
- method: 'POST',
310
- headers: ghHeaders(token),
311
- body: JSON.stringify({ assignees: [login] }),
312
- });
313
- if (!res.ok) {
314
- trackError('haystack_setup_assign_actor_failed', {
315
- repo: `${owner}/${repo}`,
316
- pr_number: prNumber,
317
- status: res.status,
318
- });
319
- }
320
- }
321
- catch (err) {
322
- trackError('haystack_setup_assign_actor_failed', {
323
- repo: `${owner}/${repo}`,
324
- pr_number: prNumber,
325
- error_message: err instanceof Error ? err.message : String(err),
326
- });
327
- }
328
- }
329
- /**
330
- * Protected-branch fallback: build ONE commit containing ALL the onboarding
331
- * config files via the Git Data API, CREATE
332
- * a branch ref at it, open a PR, and apply the auto-merge + bootstrap labels.
333
- *
334
- * Why every file in one commit: a ruleset whose `pull_request` rule targets
335
- * `~ALL` branches blocks ref *updates* on every branch — so you can neither
336
- * commit to the default branch NOR add a second commit to the onboarding
337
- * branch afterward. The only thing allowed is *creating* a ref. So all the
338
- * bootstrap-critical files (`.haystack/pr-rules.yml`, `.haystack/review-policy.md`,
339
- * and `.haystack.json`) must land in the single commit the
340
- * branch is created at. Mirrors the web wizard's PR-fallback path.
341
- */
342
- async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBranch, autoMergeEnabled, assigneeLogin) {
343
- const baseSha = await getBranchSha(owner, repo, defaultBranch, token);
344
- // Branch prefix MUST stay `haystack/onboarding-` — the worker keys off it.
345
- // The timestamp avoids collisions when setup is re-run.
346
- const branchName = `haystack/onboarding-${Date.now()}`;
347
- const files = configFiles.map((f) => ({
348
- path: f.path,
349
- content: f.content,
350
- }));
351
- const commitSha = await createCommitWithFiles(owner, repo, baseSha, files, CONFIG_COMMIT_MSG, token);
352
- await createBranch(owner, repo, branchName, commitSha, token);
353
- const pr = await createBootstrapPR(owner, repo, branchName, defaultBranch, token, autoMergeEnabled);
354
- // Assign the actor BEFORE labeling. The onboarding-bootstrap backfill keys off
355
- // the merged PR's assignees, and the auto-merge label can let the merge-queue
356
- // cron merge this PR promptly — so the assignee must be in place first, else
357
- // the merge webhook fires without it and the backfill can't attribute the run.
358
- await assignActorToPr(owner, repo, pr.number, assigneeLogin, token);
359
- // Always apply the onboarding-bootstrap marker so the PR stays recognized in
360
- // the user's tabs (isOnboardingBootstrapPR); enroll for auto-merge only when
361
- // the repo hasn't opted out. With auto-merge off the PR is still opened, marked,
362
- // and analyzed — it just waits for the user to merge it.
363
- await addBootstrapLabels(owner, repo, pr.number, pr.html_url, token, autoMergeEnabled);
364
- return { prUrl: pr.html_url };
365
- }
366
- /**
367
- * Write the onboarding config files to a repo by ALWAYS opening a PR
368
- * (branch → one commit → PR) — never a direct commit to the default branch.
369
- * Matches the web wizard (#1936): a silent push to someone's default branch is
370
- * jarring and hard to undo, so onboarding always goes through a "Configure
371
- * Haystack" PR. The PR is enrolled for auto-merge only when the user kept
372
- * auto-merge on; otherwise it waits for them to review and merge it.
373
- */
374
- async function writeOnboardingFilesToRepo(owner, repo, files, autoMergeEnabled, token, assigneeLogin) {
375
- const defaultBranch = await getDefaultBranch(owner, repo, token);
376
- const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled, assigneeLogin);
377
- return { bootstrapPR };
378
- }
379
- const SCAN_POLL_INTERVAL_MS = 1500;
380
- // A run still marked `running` but whose KV state hasn't been touched for this
381
- // long is treated as dead (CF isolate killed mid-scan). Mirrors the web's
382
- // RESUME_FRESHNESS window so we surface a retryable error instead of hanging.
383
- const SCAN_STALE_MS = 90_000;
384
- /**
385
- * Run one unified onboarding scan for a single repo, returning the proposed
386
- * rules / policies / instructions. Authenticated with the CLI's GitHub token
387
- * (the scan endpoint validates it against GitHub /user, the same trust model as
388
- * the cookie session the web uses).
389
- */
390
- async function runUnifiedScan(repo, token) {
391
- const startResp = await fetch(`${HAYSTACK_API}/api/onboarding/scan`, {
392
- method: 'POST',
393
- headers: {
394
- 'Content-Type': 'application/json',
395
- Authorization: `Bearer ${token}`,
396
- },
397
- body: JSON.stringify({ repo, scanType: 'unified' }),
398
- });
399
- if (!startResp.ok) {
400
- const text = await startResp.text().catch(() => '');
401
- throw new Error(`Scan kickoff failed: ${startResp.status}${text ? ` ${text}` : ''}`.trim());
402
- }
403
- const { runId } = (await startResp.json());
404
- if (!runId)
405
- throw new Error('Scan kickoff returned no runId');
406
- let lastProgressKey = '';
407
- let loggedPollFetchError = false;
408
- let loggedPollStatusError = false;
409
- // eslint-disable-next-line no-constant-condition
410
- while (true) {
411
- await new Promise((resolve) => setTimeout(resolve, SCAN_POLL_INTERVAL_MS));
412
- let statusResp;
413
- try {
414
- statusResp = await fetch(`${HAYSTACK_API}/api/onboarding/scan/status?runId=${encodeURIComponent(runId)}`, { headers: { Authorization: `Bearer ${token}` } });
415
- }
416
- catch (err) {
417
- // Transient network blip — the KV state is authoritative and won't vanish
418
- // from one failed poll, so keep going.
419
- if (!loggedPollFetchError) {
420
- loggedPollFetchError = true;
421
- trackError('haystack_setup_scan_status_poll_fetch_error', {
422
- repo,
423
- error: err instanceof Error ? err.message : String(err),
424
- });
425
- }
426
- continue;
427
- }
428
- if (statusResp.status === 404) {
429
- // State expired (TTL) or runId never registered — surface, don't hang.
430
- ui.clearProgress();
431
- throw new Error('Scan state expired — please retry.');
432
- }
433
- if (!statusResp.ok) {
434
- // Transient (5xx etc.) — back off and retry on the next tick.
435
- if (!loggedPollStatusError) {
436
- loggedPollStatusError = true;
437
- trackError('haystack_setup_scan_status_poll_non_ok', {
438
- repo,
439
- status: statusResp.status,
440
- });
441
- }
442
- continue;
443
- }
444
- const state = (await statusResp.json());
445
- if (state.progress) {
446
- const key = `${state.progress.message}|${state.progress.detail ?? ''}`;
447
- if (key !== lastProgressKey) {
448
- lastProgressKey = key;
449
- ui.progress(state.progress.message, state.progress.detail);
450
- }
451
- }
452
- if (state.status === 'done') {
453
- ui.clearProgress();
454
- if (!state.result)
455
- throw new Error('Scan completed but returned no result.');
456
- return {
457
- rules: state.result.rules ?? [],
458
- policies: state.result.policies ?? [],
459
- instructions: state.result.instructions ?? [],
460
- };
461
- }
462
- if (state.status === 'error') {
463
- ui.clearProgress();
464
- throw new Error(state.error || 'Scan failed');
465
- }
466
- if (state.status === 'running' && Date.now() - state.updatedAt > SCAN_STALE_MS) {
467
- ui.clearProgress();
468
- throw new Error('Scan stalled (no progress) — please retry.');
469
- }
470
- }
471
- }
472
- // =============================================================================
473
- // Display helpers
474
- // =============================================================================
475
- function printSectionHeader(title, count, total) {
476
- console.log(`\n ${chalk.bold(title)} ${chalk.dim(`${count}/${total} enabled`)}`);
477
- console.log(chalk.dim(' ' + '─'.repeat(50)));
478
- }
479
- function printRule(rule, index) {
480
- const status = rule.enabled ? chalk.green('ON ') : chalk.dim('OFF');
481
- const cat = chalk.dim(`[${CATEGORY_LABELS[rule.category] || rule.category}]`);
482
- console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${rule.name} ${cat}`);
483
- console.log(` ${chalk.dim(rule.description)}`);
484
- }
485
- function printPolicy(policy, index) {
486
- const status = policy.enabled ? chalk.green('ON ') : chalk.dim('OFF');
487
- const sev = chalk.dim(`[${SEVERITY_LABELS[policy.severity] || policy.severity}]`);
488
- console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${policy.name} ${sev}`);
489
- if (policy.paths.length > 0) {
490
- console.log(` ${chalk.dim(`Paths: ${policy.paths.join(', ')}`)}`);
491
- }
492
- console.log(` ${chalk.dim(policy.reason)}`);
493
- }
494
- function printInstruction(instruction, index) {
495
- const status = instruction.enabled ? chalk.green('ON ') : chalk.dim('OFF');
496
- console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${instruction.text}`);
497
- }
498
- // =============================================================================
499
- // Wizard steps
500
- // =============================================================================
501
- // =============================================================================
502
- // Step 0.5: Ensure the Haystack GitHub App is installed
503
- // =============================================================================
504
- const APP_INSTALL_POLL_INTERVAL_MS = 3000;
505
- const APP_INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
506
- class InstallationsAuthError extends Error {
507
- status;
508
- constructor(status, message) {
509
- super(message);
510
- this.status = status;
511
- this.name = 'InstallationsAuthError';
512
- }
513
- }
514
- async function fetchHaystackInstallations(token) {
515
- // Detect the Haystack App via the auth-worker, NOT GitHub's
516
- // `GET /user/installations`. That GitHub endpoint requires a GitHub App
517
- // user-to-server token; the CLI's `haystack login` token is a classic OAuth
518
- // token, so GitHub answers 403 ("must authenticate with an access token
519
- // authorized to a GitHub App") — which made step 0 falsely report the App as
520
- // uninstalled and exit. The auth-worker resolves installations server-side
521
- // with the App's own JWT, so it works with the CLI's token. Every entry it
522
- // returns IS a Haystack installation (the worker only knows about this App),
523
- // so there's no app_slug to filter on.
524
- const res = await fetch(`${HAYSTACK_API}/api/github/user-installations`, {
525
- headers: {
526
- Authorization: `Bearer ${token}`,
527
- Accept: 'application/json',
528
- 'User-Agent': 'Haystack-CLI',
529
- },
530
- });
531
- if (!res.ok) {
532
- // 401/403 here are genuine auth failures (the token the worker validates is
533
- // bad), unlike the GitHub endpoint's spurious 403. Retrying won't recover;
534
- // bail with a distinct error type so the poll loop can prompt re-auth.
535
- if (res.status === 401 || res.status === 403) {
536
- throw new InstallationsAuthError(res.status, `${res.status} ${await res.text()}`);
537
- }
538
- throw new Error(`failed to fetch installations: ${res.status} ${await res.text()}`);
539
- }
540
- const data = (await res.json());
541
- return data.installations ?? [];
542
- }
543
- function tryOpenBrowser(url) {
544
- // Best-effort browser open. Never blocks setup — the URL is also printed.
545
- // Windows: `start` is a cmd.exe builtin, not a standalone executable, so we
546
- // can't invoke it via execFileSync directly. Route through cmd /c.
547
- try {
548
- if (process.platform === 'win32') {
549
- // The empty quoted string is `start`'s "window title" arg — required
550
- // when the first arg might be quoted, prevents start from misparsing
551
- // the URL as a title. /c terminates cmd after the command runs.
552
- execSync(`cmd /c start "" "${url.replace(/"/g, '%22')}"`, { stdio: 'ignore' });
553
- }
554
- else {
555
- const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
556
- execFileSync(opener, [url], { stdio: 'ignore' });
557
- }
558
- return true;
559
- }
560
- catch (err) {
561
- // Log so operators see systemic browser-open failures (corporate Linux
562
- // without xdg-open, weird PATH, etc.). The user-visible UX is unchanged
563
- // since the URL was already printed for manual copy.
564
- console.log(chalk.dim(` (Couldn't auto-open browser: ${err.message})`));
565
- return false;
566
- }
567
- }
568
- /** True if the App is installed; tolerates transient errors (returns false so
569
- * the poll keeps waiting) but rethrows a genuine auth failure so the caller can
570
- * bail and prompt re-auth. */
571
- async function isAppInstalled(token) {
572
- try {
573
- return (await fetchHaystackInstallations(token)).length > 0;
574
- }
575
- catch (err) {
576
- // Auth failures are permanent — surface them. Anything else (5xx, network
577
- // blip) is transient during the install poll: return false so the poll
578
- // keeps waiting instead of crashing the wizard.
579
- if (err instanceof InstallationsAuthError)
580
- throw err;
581
- // Transient (5xx / network / rate limit): log so it isn't a silent fallback
582
- // (PR001), then return false so the install poll keeps waiting. Re-throwing
583
- // here — a literal reading of "no silent fallback" — would reintroduce the
584
- // poll-crash this function exists to prevent, so we log instead.
585
- trackError('haystack_setup_app_check_transient_error', {
586
- error_message: err instanceof Error ? err.message : String(err),
587
- });
588
- return false;
589
- }
590
- }
591
- async function stepEnsureAppInstalled(token) {
592
- console.log(chalk.bold(' Step 0: Verify GitHub App'));
593
- // Initial check — use the raw fetch (not isAppInstalled, which swallows
594
- // transient errors) so we can distinguish three outcomes: installed (done),
595
- // genuine auth failure (bail + prompt re-auth), or a transient probe error
596
- // (refuse-to-fail and proceed rather than brick setup over a blip).
597
- try {
598
- if ((await fetchHaystackInstallations(token)).length > 0) {
599
- console.log(chalk.green(' ✓ Haystack App installed\n'));
600
- return;
601
- }
602
- }
603
- catch (err) {
604
- if (err instanceof InstallationsAuthError) {
605
- console.log(chalk.yellow(`\n Your GitHub token is no longer valid (${err.status}).`));
606
- console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
607
- trackError('haystack_setup_installations_auth_failure', { status: err.status });
608
- process.exit(1);
609
- }
610
- console.log(chalk.yellow(` Could not check App installations: ${err.message}`));
611
- console.log(chalk.dim(` Continuing — install manually at ${HAYSTACK_APP_INSTALL_URL} if not yet installed.\n`));
612
- trackError('haystack_setup_installations_probe_failed', { error_message: err.message });
613
- return;
614
- }
615
- console.log(chalk.yellow(' The Haystack GitHub App is not installed yet.'));
616
- console.log(chalk.dim(" Without it, Haystack can't analyze, triage, or merge your PRs."));
617
- // Only auto-open a browser for a local interactive user. Under a coding agent
618
- // (JSON mode) the `action` event carries the URL for the agent to relay.
619
- if (ui.interactive) {
620
- const opened = tryOpenBrowser(HAYSTACK_APP_INSTALL_URL);
621
- if (opened)
622
- console.log(chalk.dim(' (Opened in your browser. Pick the repos to grant access.)'));
623
- }
624
- // ui.action emits an `action` event (JSON) or prints + spins (TTY), then polls
625
- // until the App is detected or it times out. A mid-poll auth failure surfaces
626
- // as a thrown InstallationsAuthError.
627
- let result;
628
- try {
629
- result = await ui.action({
630
- id: 'install_app',
631
- message: 'Install the Haystack GitHub App, then it is detected automatically.',
632
- url: HAYSTACK_APP_INSTALL_URL,
633
- check: () => isAppInstalled(token),
634
- pollIntervalMs: APP_INSTALL_POLL_INTERVAL_MS,
635
- timeoutMs: APP_INSTALL_TIMEOUT_MS,
636
- });
637
- }
638
- catch (err) {
639
- if (err instanceof InstallationsAuthError) {
640
- console.log(chalk.yellow(`\n Your GitHub token became invalid (${err.status}) while waiting.`));
641
- console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
642
- trackError('haystack_setup_installations_auth_failure', { status: err.status, during: 'poll' });
643
- process.exit(1);
644
- }
645
- throw err;
646
- }
647
- if (result === 'ok') {
648
- console.log(chalk.green('\n ✓ App installed\n'));
649
- return;
650
- }
651
- if (result === 'skip') {
652
- // Caller intentionally skipped — proceed (refuse-to-fail), but warn that the
653
- // App is needed for Haystack to actually act on the configured repos.
654
- console.log(chalk.yellow('\n Skipping GitHub App verification.'));
655
- console.log(chalk.dim(' Haystack needs the App installed to analyze, triage, or merge your PRs.\n'));
656
- return;
657
- }
658
- // result === 'timeout'
659
- trackError('haystack_setup_app_install_timeout', { timeout_ms: APP_INSTALL_TIMEOUT_MS });
660
- console.log(chalk.yellow('\n Timed out waiting for App install.'));
661
- console.log(chalk.dim(` Install at ${HAYSTACK_APP_INSTALL_URL}, then re-run \`haystack setup\`.\n`));
662
- process.exit(1);
663
- }
664
- async function stepSelectRepos(token) {
665
- console.log(chalk.bold('\n Step 1: Select repositories\n'));
666
- console.log(chalk.dim(' Fetching your repositories...'));
667
- const repos = await fetchInstallCoveredRepos(token);
668
- if (repos.length === 0) {
669
- console.log(chalk.yellow('\n No repositories found on your Haystack App installation(s).'));
670
- console.log(chalk.dim(` Add repositories to the install at ${HAYSTACK_APP_INSTALL_URL}, then re-run \`haystack setup\`.\n`));
671
- process.exit(1);
672
- }
673
- let selectedRepos = [];
674
- for (;;) {
675
- selectedRepos = await ui.multiselect({
676
- id: 'select_repos',
677
- message: 'Select repositories to configure:',
678
- choices: repos.map((r) => ({ name: r, value: r })),
679
- });
680
- if (selectedRepos.length > 0)
681
- break;
682
- // Interactive live pick: keep the picker open so the user can correct an
683
- // empty selection (restores the old inquirer `validate`). But a PRE-SUPPLIED
684
- // empty selection (--answers select_repos:[]) would be returned unchanged
685
- // every iteration — re-prompting can't fix it, so fail instead of looping.
686
- // Non-interactive likewise can't be corrected.
687
- if (!ui.interactive || ui.hasPreset('select_repos')) {
688
- console.log(chalk.yellow('\n No repositories selected. Pass --repo <owner/name>.\n'));
689
- process.exit(1);
690
- }
691
- console.log(chalk.yellow(' Select at least one repository.'));
692
- }
693
- console.log(chalk.green(` ${selectedRepos.length} repo(s) selected\n`));
694
- return selectedRepos;
695
- }
696
- // File paths written during onboarding (must match the web wizard + worker:
697
- // src/features/onboarding/services/onboardingApi.ts + the merge-queue cron's
698
- // ONBOARDING_ALLOWED_PATHS).
699
- const PR_RULES_PATH = '.haystack/pr-rules.yml';
700
- const REVIEW_POLICY_PATH = '.haystack/review-policy.md';
701
- const HAYSTACK_CONFIG_PATH = '.haystack.json';
702
- /**
703
- * Scan each selected repo with one unified scan and collect the proposed
704
- * rules / policies / instructions per repo. A failed scan for one repo records
705
- * an empty result (and a warning) rather than aborting the whole wizard.
706
- */
707
- async function stepScanRepos(repos, token) {
708
- console.log(chalk.bold('\n Step 2: Scanning your repositories\n'));
709
- console.log(chalk.dim(" Haystack reads each repo's PR history to learn its review conventions.\n"));
710
- const byRepo = new Map();
711
- for (const repo of repos) {
712
- console.log(chalk.dim(` Scanning ${repo}...`));
713
- try {
714
- const result = await runUnifiedScan(repo, token);
715
- byRepo.set(repo, result);
716
- const counts = [];
717
- if (result.rules.length)
718
- counts.push(`${result.rules.length} rule(s)`);
719
- if (result.policies.length)
720
- counts.push(`${result.policies.length} policy/policies`);
721
- if (result.instructions.length)
722
- counts.push(`${result.instructions.length} instruction(s)`);
723
- console.log(counts.length
724
- ? chalk.green(` ✓ ${repo}: ${counts.join(', ')}`)
725
- : chalk.dim(` ${repo}: nothing discovered`));
726
- }
727
- catch (err) {
728
- console.log(chalk.yellow(` ⚠ ${repo}: scan failed (${err.message})`));
729
- console.log(chalk.dim(' Continuing — this repo will get a baseline config you can tune later.'));
730
- byRepo.set(repo, { rules: [], policies: [], instructions: [] });
731
- }
732
- }
733
- return byRepo;
734
- }
735
- /**
736
- * Show the discovered rules / policies / instructions per repo and let the user
737
- * toggle any off. Mutates the `enabled` flags on the items in `byRepo` in place.
738
- */
739
- async function stepReview(byRepo) {
740
- const total = [...byRepo.values()].reduce((n, r) => n + r.rules.length + r.policies.length + r.instructions.length, 0);
741
- if (total === 0) {
742
- console.log(chalk.dim('\n No items discovered — skipping review.\n'));
743
- return;
744
- }
745
- console.log(chalk.bold('\n Step 3: Review discovered configuration\n'));
746
- console.log(chalk.dim(' Toggle items on/off. Only enabled items will be written.\n'));
747
- for (const [repo, result] of byRepo) {
748
- if (result.rules.length + result.policies.length + result.instructions.length === 0)
749
- continue;
750
- console.log(`\n ${chalk.bold.cyan(repo)}`);
751
- if (result.rules.length > 0) {
752
- printSectionHeader('Coding Rules', result.rules.filter((r) => r.enabled).length, result.rules.length);
753
- result.rules.forEach((r, i) => printRule(r, i));
754
- }
755
- if (result.policies.length > 0) {
756
- printSectionHeader('Review Policies', result.policies.filter((p) => p.enabled).length, result.policies.length);
757
- result.policies.forEach((p, i) => printPolicy(p, i));
758
- }
759
- if (result.instructions.length > 0) {
760
- printSectionHeader('Review Instructions', result.instructions.filter((ins) => ins.enabled).length, result.instructions.length);
761
- result.instructions.forEach((ins, i) => printInstruction(ins, i));
762
- }
763
- }
764
- const wantToToggle = await ui.confirm({
765
- id: 'want_to_toggle',
766
- message: 'Would you like to toggle any items?',
767
- default: false,
768
- });
769
- if (!wantToToggle)
770
- return;
771
- // NUL separates repo / kind / id in the checkbox value so it can't collide
772
- // with any character in a repo slug or an item id.
773
- const SEP = '\u0000';
774
- const allItems = [];
775
- for (const [repo, result] of byRepo) {
776
- if (result.rules.length + result.policies.length + result.instructions.length === 0)
777
- continue;
778
- allItems.push({ separator: `── ${repo} ──` });
779
- result.rules.forEach((r) => {
780
- allItems.push({
781
- name: `${r.name} ${chalk.dim(`[rule · ${CATEGORY_LABELS[r.category] || r.category}]`)}`,
782
- value: `${repo}${SEP}rule${SEP}${r.id}`,
783
- checked: r.enabled,
784
- });
785
- });
786
- result.policies.forEach((p) => {
787
- allItems.push({
788
- name: `${p.name} ${chalk.dim(`[policy · ${SEVERITY_LABELS[p.severity] || p.severity}]`)}`,
789
- value: `${repo}${SEP}policy${SEP}${p.id}`,
790
- checked: p.enabled,
791
- });
792
- });
793
- result.instructions.forEach((ins) => {
794
- allItems.push({
795
- name: `${ins.text} ${chalk.dim('[instruction]')}`,
796
- value: `${repo}${SEP}instruction${SEP}${ins.id}`,
797
- checked: ins.enabled,
798
- });
799
- });
800
- }
801
- const enabled = await ui.multiselect({
802
- id: 'review_enabled_items',
803
- message: 'Select items to enable:',
804
- choices: allItems,
805
- });
806
- const enabledSet = new Set(enabled);
807
- for (const [repo, result] of byRepo) {
808
- result.rules.forEach((r) => { r.enabled = enabledSet.has(`${repo}${SEP}rule${SEP}${r.id}`); });
809
- result.policies.forEach((p) => { p.enabled = enabledSet.has(`${repo}${SEP}policy${SEP}${p.id}`); });
810
- result.instructions.forEach((ins) => { ins.enabled = enabledSet.has(`${repo}${SEP}instruction${SEP}${ins.id}`); });
811
- }
812
- }
813
- // ── Config builders ──────────────────────────────────────────────────────
814
- // Mirror src/features/onboarding/components/ConfirmStep.tsx (buildPrRules,
815
- // buildReviewPolicyMarkdown) + services/onboardingApi.ts (serializePrRulesYaml,
816
- // buildHaystackConfigJson) so the CLI and web wizard emit byte-identical files.
817
- /** Build pr-rules.yml entries from the proposed rules. Everything onboarding
818
- * surfaces is LLM-evaluated (natural-language rules extracted from review
819
- * comments), so type:'llm' and the description doubles as the prompt. */
820
- function buildPrRules(rules) {
821
- const PROPOSED_TO_YML_CATEGORY = {
822
- testing: 'patterns',
823
- style: 'style',
824
- security: 'security',
825
- performance: 'performance',
826
- docs: 'patterns',
827
- other: 'patterns',
828
- };
829
- return rules
830
- .filter((r) => r.enabled)
831
- .map((r) => ({
832
- id: r.id,
833
- name: r.name,
834
- type: 'llm',
835
- severity: 'warning',
836
- message: r.description,
837
- category: PROPOSED_TO_YML_CATEGORY[r.category],
838
- llm: { prompt: r.description },
839
- }));
840
- }
841
- /** Render review-policy.md from path-scoped policies + universal instructions.
842
- * Returns null when nothing is enabled (caller skips the file). */
843
- function buildReviewPolicyMarkdown(policies, instructions) {
844
- const enabledPolicies = policies.filter((p) => p.enabled);
845
- const enabledInstructions = instructions.filter((i) => i.enabled);
846
- if (enabledPolicies.length === 0 && enabledInstructions.length === 0)
847
- return null;
848
- const lines = ['# Review Policies', ''];
849
- for (const policy of enabledPolicies) {
850
- lines.push(`## ${policy.name}`);
851
- lines.push(`- **Paths**: ${policy.paths.map((p) => '`' + p + '`').join(', ')}`);
852
- lines.push(`- **Severity**: ${policy.severity}`);
853
- lines.push(`- **Reason**: ${policy.reason}`);
854
- lines.push('');
855
- }
856
- if (enabledInstructions.length > 0) {
857
- lines.push('## Instructions');
858
- for (const instr of enabledInstructions) {
859
- lines.push(`- ${instr.text}`);
860
- }
861
- lines.push('');
862
- }
863
- return lines.join('\n');
864
- }
865
- /** `.haystack.json` enabling the merge queue, reflecting the user's auto-merge
866
- * choice. Mirrors the web wizard's buildHaystackConfigJson shape. Auto-merge is
867
- * the master switch: when off, nothing lands in the queue, so the two sub-flags
868
- * follow it. */
869
- function buildHaystackConfigJson(autoMerge) {
870
- const config = {
871
- preferences: { auto_merge: autoMerge },
872
- merge_queue: {
873
- merge_queue: autoMerge,
874
- auto_resolve_conflicts: autoMerge,
875
- auto_fix_ci_failures: autoMerge,
876
- },
877
- };
878
- return JSON.stringify(config, null, 2) + '\n';
879
- }
880
- // Minimal YAML serializer for the pr-rules.yml shape (keeps us off a yaml dep;
881
- // mirrors onboardingApi.ts serializePrRulesYaml).
882
- function yamlScalar(s) {
883
- return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
884
- }
885
- function yamlBlockOrScalar(s, indentCols) {
886
- if (!s.includes('\n'))
887
- return yamlScalar(s);
888
- const pad = ' '.repeat(indentCols);
889
- const body = s.split('\n').map((line) => `${pad}${line}`).join('\n');
890
- return `|\n${body}`;
891
- }
892
- function serializePrRulesYaml(rules) {
893
- if (rules.length === 0) {
894
- return '# No rules configured yet. Add entries under `rules:` below.\nrules: []\n';
895
- }
896
- const lines = ['rules:'];
897
- for (const rule of rules) {
898
- lines.push(` - id: ${yamlScalar(rule.id)}`);
899
- lines.push(` name: ${yamlScalar(rule.name)}`);
900
- lines.push(` type: ${rule.type}`);
901
- lines.push(` severity: ${rule.severity}`);
902
- if (rule.category)
903
- lines.push(` category: ${yamlScalar(rule.category)}`);
904
- lines.push(` message: ${yamlScalar(rule.message)}`);
905
- if (rule.llm) {
906
- lines.push(` llm:`);
907
- lines.push(` prompt: ${yamlBlockOrScalar(rule.llm.prompt, 8)}`);
908
- if (rule.llm.files)
909
- lines.push(` files: ${yamlScalar(rule.llm.files)}`);
910
- }
911
- if (rule.pattern) {
912
- lines.push(` pattern:`);
913
- lines.push(` regex: ${yamlScalar(rule.pattern.regex)}`);
914
- if (rule.pattern.files)
915
- lines.push(` files: ${yamlScalar(rule.pattern.files)}`);
916
- if (rule.pattern.exclude)
917
- lines.push(` exclude: ${yamlScalar(rule.pattern.exclude)}`);
918
- }
919
- }
920
- return lines.join('\n') + '\n';
921
- }
922
- /** Build the onboarding files to write for one repo from its (toggled) scan
923
- * result. `.haystack.json` is always written (it enables the merge queue); the
924
- * rules + policy files only when there's enabled content for them. */
925
- function buildOnboardingFiles(result, autoMerge) {
926
- const files = [];
927
- const prRules = buildPrRules(result.rules);
928
- if (prRules.length > 0) {
929
- files.push({ path: PR_RULES_PATH, content: serializePrRulesYaml(prRules) });
930
- }
931
- const reviewPolicyMd = buildReviewPolicyMarkdown(result.policies, result.instructions);
932
- if (reviewPolicyMd) {
933
- files.push({ path: REVIEW_POLICY_PATH, content: reviewPolicyMd });
934
- }
935
- files.push({ path: HAYSTACK_CONFIG_PATH, content: buildHaystackConfigJson(autoMerge) });
936
- return files;
937
- }
938
- async function stepConfirm(byRepo, token, assigneeLogin) {
939
- const selectedRepos = [...byRepo.keys()];
940
- console.log(chalk.bold('\n Step 4: Confirm & write configuration\n'));
941
- // Per-repo summary of what will be written.
942
- for (const [repo, result] of byRepo) {
943
- const enabledRules = result.rules.filter((r) => r.enabled).length;
944
- const enabledPolicies = result.policies.filter((p) => p.enabled).length;
945
- const enabledInstructions = result.instructions.filter((i) => i.enabled).length;
946
- const parts = [];
947
- if (enabledRules > 0)
948
- parts.push(`${enabledRules} rule(s) → ${PR_RULES_PATH}`);
949
- if (enabledPolicies + enabledInstructions > 0) {
950
- parts.push(`${enabledPolicies + enabledInstructions} policy/instruction(s) → ${REVIEW_POLICY_PATH}`);
951
- }
952
- parts.push(`merge queue → ${HAYSTACK_CONFIG_PATH}`);
953
- console.log(` ${chalk.bold(repo)}`);
954
- parts.forEach((p) => console.log(` ${chalk.dim('•')} ${chalk.dim(p)}`));
955
- }
956
- console.log('');
957
- // Auto-merge is the master switch. Default ON (matches the web wizard default
958
- // + the repo-level preferences.auto_merge opt-in model). When off, the
959
- // bootstrap PR is opened but NOT labeled, so the user merges it manually and
960
- // no PRs auto-merge.
961
- const autoMerge = await ui.confirm({
962
- id: 'auto_merge',
963
- message: 'Enable auto-merge? (Haystack merges PRs automatically once they pass review)',
964
- default: true,
965
- });
966
- const confirmed = await ui.confirm({
967
- id: 'confirm_write',
968
- message: `Write Haystack config to ${selectedRepos.length} repo(s)?`,
969
- default: true,
970
- });
971
- if (!confirmed) {
972
- console.log(chalk.yellow('\n Setup cancelled.\n'));
973
- return { confirmed: false, bootstrapPRs: new Map() };
974
- }
975
- const failedRepos = [];
976
- const bootstrapPRs = new Map();
977
- for (const repoFullName of selectedRepos) {
978
- const [owner, repo] = repoFullName.split('/');
979
- const result = byRepo.get(repoFullName);
980
- const files = buildOnboardingFiles(result, autoMerge);
981
- try {
982
- ui.progress(`Opening Configure Haystack PR for ${repoFullName}...`);
983
- const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token, assigneeLogin);
984
- ui.clearProgress();
985
- // Onboarding always opens a PR (never a direct commit). With auto-merge on,
986
- // Haystack merges it once its labels are processed; with auto-merge off it
987
- // waits for the user.
988
- bootstrapPRs.set(repoFullName, writeResult.bootstrapPR);
989
- // Don't print the GitHub PR URL: the user reviews and merges from their
990
- // Haystack feed, not the GitHub PR page. The feed link is the single CTA
991
- // in the Next steps block below.
992
- console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(' (configured)'));
993
- }
994
- catch (err) {
995
- ui.clearProgress();
996
- console.log(chalk.red(` ✗ ${repoFullName}: ${err.message}`));
997
- failedRepos.push(repoFullName);
998
- }
999
- }
1000
- if (failedRepos.length > 0) {
1001
- console.log(chalk.red(`\n Setup failed for ${failedRepos.length} repo(s).\n`));
1002
- process.exit(1);
1003
- }
1004
- console.log(chalk.green('\n Setup complete!\n'));
1005
- return { confirmed: true, bootstrapPRs };
1006
- }
1007
- // =============================================================================
1008
- // Main command
1009
- // =============================================================================
1010
- export async function setupCommand(options = {}) {
1011
- // Create the prompter FIRST: in JSON mode it redirects console.* to stderr so
1012
- // stdout carries only NDJSON, which the steps below rely on.
1013
- ui = createPrompter({ json: options.json, answers: buildAnswerMap(options) });
1014
- try {
1015
- await runSetupFlow(options);
1016
- }
1017
- catch (err) {
1018
- // An agent cancelling a prompt is a clean exit, not a crash.
1019
- if (err instanceof PromptCancelledError) {
1020
- console.log(chalk.yellow('\n Setup cancelled.\n'));
1021
- ui.result({ status: 'cancelled' });
1022
- return;
1023
- }
1024
- throw err;
1025
- }
1026
- finally {
1027
- ui.close();
1028
- }
1029
- }
1030
- async function runSetupFlow(options) {
1031
- console.log(chalk.cyan('\n Haystack Setup Wizard\n'));
1032
- // Step 0: Check login
1033
- let authContext;
1034
- try {
1035
- authContext = await resolveAuthContext();
1036
- }
1037
- catch (err) {
1038
- console.log(chalk.yellow(` ${err.message}\n`));
1039
- process.exit(1);
1040
- }
1041
- const token = authContext.token;
1042
- // Verify token
1043
- const userResponse = await fetch(`${githubRestBaseFor(token)}/user`, {
1044
- headers: {
1045
- Authorization: `Bearer ${token}`,
1046
- 'User-Agent': 'Haystack-CLI',
1047
- },
1048
- });
1049
- if (!userResponse.ok) {
1050
- console.log(chalk.yellow(' Token expired. Run `haystack login` again.\n'));
1051
- process.exit(1);
1052
- }
1053
- const user = (await userResponse.json());
1054
- console.log(chalk.dim(` Logged in as ${chalk.bold(user.login)}\n`));
1055
- // Step 0.5: Ensure the Haystack GitHub App is installed. Without it, all
1056
- // the server-side machinery (merge queue, analysis, fixer, review chat) is
1057
- // dark — the user would write .haystack.json and then nothing would happen.
1058
- await stepEnsureAppInstalled(token);
1059
- // Step 1: Select repos
1060
- const selectedRepos = await stepSelectRepos(token);
1061
- // Step 2: Unified scan per repo (rules + policies + instructions)
1062
- const byRepo = await stepScanRepos(selectedRepos, token);
1063
- // Step 3: Review (toggle items off)
1064
- await stepReview(byRepo);
1065
- // Step 4: Confirm & write the config files. Pass the authenticated user's
1066
- // login so each bootstrap PR is assigned to them — the onboarding-bootstrap
1067
- // backfill keys off the merged PR's assignees to decide whose existing open
1068
- // PRs to analyze (parity with the web wizard).
1069
- const { confirmed, bootstrapPRs } = await stepConfirm(byRepo, token, user.login);
1070
- if (!confirmed) {
1071
- ui.result({ status: 'cancelled' });
1072
- return;
1073
- }
1074
- // Step 7: Offer Haystack's local quality hooks when setup runs in a selected repo.
1075
- await stepInstallHooks(selectedRepos, options.json === true);
1076
- // Close the loop with ONE prominent CTA: the Haystack feed. The Configure
1077
- // Haystack PR (and every PR after it) is reviewed and merged from the feed,
1078
- // NOT the GitHub PR page, so the feed is the only link we surface. In JSON
1079
- // mode these console writes go to stderr, so a driving agent instead gets the
1080
- // same content as `summary` in the `result` event to relay verbatim.
1081
- const feedUrl = `${HAYSTACK_API}/inbox`;
1082
- const prs = [...bootstrapPRs.entries()].map(([repo, pr]) => ({ repo, url: pr.prUrl }));
1083
- // Plain-text summary an agent can print as-is (no ANSI codes).
1084
- const summary = [
1085
- 'Open your Haystack feed to review and merge your Configure Haystack PR:',
1086
- ` ${feedUrl}`,
1087
- ].join('\n');
1088
- // Styled version for the human at a TTY.
1089
- console.log(chalk.green.bold('\n Next steps\n'));
1090
- console.log(' Open your Haystack feed to review and merge your Configure Haystack PR:');
1091
- console.log(` ${chalk.cyan(feedUrl)}\n`);
1092
- if (ui.interactive) {
1093
- const openFeed = await ui.confirm({ id: 'open_feed', message: 'Open your Haystack feed now?', default: true });
1094
- if (openFeed)
1095
- tryOpenBrowser(feedUrl);
1096
- }
1097
- // Final structured outcome for non-interactive/agent callers. `summary` is the
1098
- // ready-to-print Next steps text so a driving agent can surface it verbatim.
1099
- // `pullRequests` stays as structured metadata (programmatic use), but the
1100
- // human-facing copy points only to the feed.
1101
- ui.result({
1102
- status: 'complete',
1103
- repos: selectedRepos,
1104
- pullRequests: prs,
1105
- feedUrl,
1106
- summary,
1107
- });
1108
- }
1109
- // =============================================================================
1110
- // Step 7: Install Haystack git hooks
1111
- // =============================================================================
1112
- async function stepInstallHooks(selectedRepos, json) {
1113
- // Offer to install local git hooks if cwd is inside one of the selected repos
1114
- const gitRoot = findGitRoot();
1115
- if (gitRoot) {
1116
- const gitDirName = basename(gitRoot).toLowerCase();
1117
- const repoName = selectedRepos.find((r) => {
1118
- const name = r.split('/')[1]?.replace(/\.git$/, '');
1119
- return name && gitDirName === name.toLowerCase();
1120
- });
1121
- if (repoName) {
1122
- try {
1123
- const installHooks = await ui.confirm({
1124
- id: 'install_hooks',
1125
- message: `Install local hooks for ${repoName}? (git hooks in hooks/ via core.hooksPath; Claude Code session and verification-precompute hooks in your .claude/settings.local.json)`,
1126
- default: true,
1127
- });
1128
- if (installHooks) {
1129
- console.log('');
1130
- const overwrites = await describeHookOverwrites(gitRoot);
1131
- let installGitHooks = true;
1132
- if (overwrites.length > 0) {
1133
- console.log(chalk.yellow(' Installing Haystack hooks would:'));
1134
- for (const change of overwrites)
1135
- console.log(chalk.yellow(` - ${change}`));
1136
- if (json || !process.stdin.isTTY) {
1137
- console.log(' Existing git hooks were left alone; to replace them, run `haystack hooks install --force` yourself.');
1138
- installGitHooks = false;
1139
- }
1140
- else {
1141
- installGitHooks = await ui.confirm({
1142
- id: 'overwrite_hooks',
1143
- message: 'Overwrite these existing hooks?',
1144
- default: false,
1145
- });
1146
- if (!installGitHooks) {
1147
- console.log(chalk.dim(' Existing git hooks were left alone. To replace them later: haystack hooks install --force'));
1148
- }
1149
- }
1150
- }
1151
- if (installGitHooks)
1152
- await hooksInstall({ force: overwrites.length > 0 });
1153
- await installSessionHooks({ cli: 'claude' });
1154
- }
1155
- }
1156
- catch (err) {
1157
- console.log(chalk.yellow(` ⚠ Hook installation failed: ${err instanceof Error ? err.message : err}`));
1158
- console.log(chalk.dim(' You can install hooks later with: haystack hooks install'));
1159
- }
1160
- }
1161
- else {
1162
- console.log(chalk.dim(' To install git hooks in a repo, cd into it and run:'));
1163
- console.log(chalk.dim(' haystack hooks install\n'));
1164
- }
1165
- }
1166
- else {
1167
- console.log(chalk.dim(' To install git hooks in a repo, cd into it and run:'));
1168
- console.log(chalk.dim(' haystack hooks install\n'));
1169
- }
1170
- }