@haystackeditor/cli 0.25.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -455
- package/dist/capture/app-config.js +25 -1
- package/dist/commands/capture-brief.js +41 -32
- package/dist/commands/feedback.js +66 -0
- package/dist/commands/init-telemetry.js +53 -7
- package/dist/commands/init.js +7 -2
- package/dist/commands/lockfile-pin.js +307 -0
- package/dist/commands/verify.js +68 -13
- package/dist/index.js +27 -923
- package/dist/schema.js +4 -10
- package/dist/utils/haystack-api.js +0 -36
- package/package.json +1 -5
- package/schemas/feedback.v1.json +13 -0
- package/schemas/pre-verify.v2.json +240 -0
- package/schemas/verify-raw.v1.json +1132 -0
- package/schemas/verify.v2.json +655 -0
- package/dist/assets/hooks/agent-context/detect.ts +0 -316
- package/dist/assets/hooks/agent-context/format.ts +0 -100
- package/dist/assets/hooks/agent-context/index.ts +0 -41
- package/dist/assets/hooks/agent-context/parsers/claude.ts +0 -262
- package/dist/assets/hooks/agent-context/parsers/codex.ts +0 -416
- package/dist/assets/hooks/agent-context/parsers/gemini.ts +0 -155
- package/dist/assets/hooks/agent-context/parsers/opencode.ts +0 -174
- package/dist/assets/hooks/agent-context/tsconfig.json +0 -14
- package/dist/assets/hooks/agent-context/types.ts +0 -58
- package/dist/assets/hooks/llm-rules-template.md +0 -59
- package/dist/assets/hooks/package-lock.json +0 -598
- package/dist/assets/hooks/package.json +0 -12
- package/dist/assets/hooks/scripts/commit-msg.sh +0 -5
- package/dist/assets/hooks/scripts/post-commit.sh +0 -5
- package/dist/assets/hooks/scripts/pre-commit.sh +0 -175
- package/dist/assets/hooks/scripts/pre-push.sh +0 -25
- package/dist/assets/hooks/scripts/prepare-commit-msg.sh +0 -5
- package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +0 -528
- package/dist/assets/hooks/truncation-checker/index.ts +0 -595
- package/dist/assets/hooks/truncation-checker/tsconfig.json +0 -13
- package/dist/assets/skills/map-cloud-verifier-universe/SKILL.md +0 -2051
- package/dist/assets/skills/map-cloud-verifier-universe/agents/openai.yaml +0 -4
- package/dist/assets/skills/map-cloud-verifier-universe/references/output-contract.md +0 -3411
- package/dist/assets/skills/map-your-system.md +0 -143
- package/dist/assets/skills/submit.md +0 -200
- package/dist/commands/ask.js +0 -20
- package/dist/commands/cloud-verifier-behaviors.js +0 -218
- package/dist/commands/cloud-verifier-data-store-census.js +0 -539
- package/dist/commands/cloud-verifier-data-store-drift.js +0 -158
- package/dist/commands/cloud-verifier-identity-census.js +0 -4060
- package/dist/commands/cloud-verifier-materialization.js +0 -704
- package/dist/commands/cloud-verifier-pascal-selector-census.js +0 -1382
- package/dist/commands/cloud-verifier-python-manifest-selector-census.js +0 -2015
- package/dist/commands/cloud-verifier-specialized-operational-census.js +0 -11432
- package/dist/commands/cloud-verifier-universe.js +0 -10178
- package/dist/commands/config.js +0 -549
- package/dist/commands/design-verify.js +0 -311
- package/dist/commands/dismiss.js +0 -159
- package/dist/commands/hooks.js +0 -226
- package/dist/commands/inbox.js +0 -137
- package/dist/commands/mcp.js +0 -201
- package/dist/commands/policy.js +0 -371
- package/dist/commands/pr-status.js +0 -207
- package/dist/commands/pr.js +0 -105
- package/dist/commands/prepare-universe-review.js +0 -1092
- package/dist/commands/production-source-deny-policy.js +0 -100
- package/dist/commands/request-review.js +0 -74
- package/dist/commands/review.js +0 -191
- package/dist/commands/rules.js +0 -98
- package/dist/commands/scaffold-provisional-universe.js +0 -806
- package/dist/commands/setup.js +0 -1170
- package/dist/commands/skills.js +0 -447
- package/dist/commands/submit.js +0 -745
- package/dist/commands/system-map.js +0 -228
- package/dist/commands/triage.js +0 -598
- package/dist/commands/webhooks.js +0 -241
- package/dist/states.js +0 -46
- package/dist/tools/detect.js +0 -832
- package/dist/triage/astra.js +0 -202
- package/dist/triage/prompts.js +0 -188
- package/dist/triage/runner.js +0 -200
- package/dist/triage/types.js +0 -7
- package/dist/utils/action-output.js +0 -26
- package/dist/utils/analysis-api.js +0 -416
- package/dist/utils/design-verifier-api.js +0 -294
- package/dist/utils/design-verifier-history.js +0 -79
- package/dist/utils/design-verifier-result.js +0 -424
- package/dist/utils/github-api.js +0 -324
- package/dist/utils/pending-state.js +0 -86
- package/dist/utils/pr-ref.js +0 -56
- package/dist/utils/prompter.js +0 -328
- package/schemas/action.v1.json +0 -22
- package/schemas/ask.v1.json +0 -40
- package/schemas/inbox.v1.json +0 -27
- package/schemas/pr-status.v1.json +0 -61
- package/schemas/pr.v1.json +0 -97
- package/schemas/pr.v3.json +0 -45
- package/schemas/setup.v1.json +0 -75
- package/schemas/submit.v1.json +0 -90
- package/schemas/triage.v1.json +0 -103
- package/schemas/triage.v2.json +0 -64
package/dist/commands/setup.js
DELETED
|
@@ -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
|
-
}
|