@haystackeditor/cli 0.19.0 ā 0.21.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 +38 -35
- package/dist/commands/fleet-policy.js +1 -22
- package/dist/commands/init.js +376 -129
- package/dist/commands/install-session-hooks.js +27 -10
- package/dist/commands/login.js +133 -34
- package/dist/commands/onboarding-contract.js +6 -0
- package/dist/commands/setup.js +1 -2
- package/dist/commands/verify-onboarding.js +365 -0
- package/dist/commands/verify-precompute.js +37 -14
- package/dist/commands/verify.js +108 -25
- package/dist/index.js +99 -26
- package/dist/schema.js +5 -1
- package/dist/utils/config.js +0 -16
- package/dist/utils/haystack-api.js +12 -1
- package/dist/utils/secrets.js +0 -51
- package/dist/utils/telemetry.js +32 -0
- package/package.json +2 -2
- package/schemas/init.v1.json +35 -0
- package/schemas/login.v1.json +17 -0
- package/schemas/verify-answer.v1.json +86 -0
- package/schemas/verify-onboarding.v1.json +13 -0
- package/schemas/verify.v1.json +270 -2
- package/dist/utils/detect.js +0 -507
package/dist/commands/init.js
CHANGED
|
@@ -1,152 +1,399 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* haystack init
|
|
2
|
+
* haystack init ā set a repository up for `haystack verify` in one command.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* There is no setup file to write or keep current: the service works out how to
|
|
5
|
+
* run the app from what the repository already keeps working (APP-ONBOARDING-V1).
|
|
6
|
+
* init makes at most two small project-local changes, shows each as a diff
|
|
7
|
+
* before writing, and writes them only with consent (--yes, or yes at the prompt):
|
|
8
|
+
* 1. Claude Code's Stop hook, `haystack verify precompute --hook`, in the
|
|
9
|
+
* per-developer .claude/settings.local.json (kept out of commits through
|
|
10
|
+
* .git/info/exclude), when Claude Code is in use here;
|
|
11
|
+
* 2. a short marked block in AGENTS.md (and in CLAUDE.md when the repository
|
|
12
|
+
* keeps one) telling coding agents to run `haystack verify` after a change.
|
|
13
|
+
* It then starts onboarding the app at the base `haystack verify` would use, so
|
|
14
|
+
* the app is usually ready by the first verify. Running it again changes only
|
|
15
|
+
* what is missing or out of date and shows where onboarding is.
|
|
16
|
+
*
|
|
17
|
+
* Exit codes: 0 set up (onboarding running or ready), 1 failed, 2 changes shown
|
|
18
|
+
* but not made (run with --yes), 3 onboarding blocked (the output says what
|
|
19
|
+
* would unblock it), 4 not logged in, 5 the Haystack GitHub App is not
|
|
20
|
+
* installed, 6 set up but onboarding stopped before finishing (run init again).
|
|
5
21
|
*/
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
25
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
26
|
import chalk from 'chalk';
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
import { withSchema } from '../schema.js';
|
|
28
|
+
import { NotLoggedInError, resolveAuthContext } from '../utils/auth.js';
|
|
29
|
+
import { HAYSTACK_APP_INSTALL_URL, HaystackApiError, INSTALLATION_REQUIRED_CODE } from '../utils/haystack-api.js';
|
|
30
|
+
import { findGitRoot } from '../utils/hooks.js';
|
|
31
|
+
import { assertNoSymlinkOnPath } from '../utils/safe-write.js';
|
|
32
|
+
import { CLAUDE_LOCAL_SETTINGS, CLAUDE_SHARED_SETTINGS, HAYSTACK_PRECOMPUTE_HOOK_COMMAND, claudeEntries, claudeSettingsPath, isCLIInstalled, isGitIgnored, isInstalledGlobally, isPrecomputeCommand, readClaudeSettings, } from './install-session-hooks.js';
|
|
33
|
+
import { formatOnboarding, reportOnboardingState, startOnboarding } from './verify-onboarding.js';
|
|
34
|
+
import { deriveBaseSha, EXPLICIT_WALL_MS, resolveOriginRepository } from './verify-precompute.js';
|
|
35
|
+
const NOTE_BEGIN = '<!-- haystack:begin -->';
|
|
36
|
+
const NOTE_END = '<!-- haystack:end -->';
|
|
37
|
+
/** The agent instructions init keeps in AGENTS.md / CLAUDE.md, between its markers. */
|
|
38
|
+
export const AGENT_NOTE = [
|
|
39
|
+
NOTE_BEGIN,
|
|
40
|
+
'## Haystack',
|
|
41
|
+
'',
|
|
42
|
+
'After you change this app\'s code, run `haystack verify` before you call the work done. It runs the app with and',
|
|
43
|
+
'without your change, explores it, and reports where the change shows up and what broke.',
|
|
44
|
+
'',
|
|
45
|
+
'- It often takes 10 to 15 minutes. If your command times out, run it again: it picks up the same crawl.',
|
|
46
|
+
'- Fix each bug it reports that your change caused, then run `haystack verify` again.',
|
|
47
|
+
'- If it says onboarding needs an answer, ask the user that question and file their choice with',
|
|
48
|
+
' `haystack verify answer <question-id> <choice>`.',
|
|
49
|
+
'- `haystack verify --json` prints one JSON document; `haystack verify --help` explains the output and exit codes.',
|
|
50
|
+
'- If `haystack` is not installed, run each command as `npx -y @haystackeditor/cli@latest <command>`.',
|
|
51
|
+
NOTE_END,
|
|
52
|
+
].join('\n');
|
|
53
|
+
const EXIT_CODES = { planned: 2, 'login-required': 4, 'app-required': 5 };
|
|
54
|
+
/** init's exit once set up: 0 onboarding running or ready, 3 blocked, 6 stopped before finishing. */
|
|
55
|
+
function setUpExitCode(onboarding) {
|
|
56
|
+
if (onboarding.state === 'blocked')
|
|
57
|
+
return 3;
|
|
58
|
+
return onboarding.state === 'ready' || onboarding.state === 'onboarding' ? 0 : 6;
|
|
59
|
+
}
|
|
60
|
+
/* ------------------------------------------------------------------- plan */
|
|
61
|
+
function isPlainFile(path) {
|
|
62
|
+
try {
|
|
63
|
+
return lstatSync(path).isFile();
|
|
19
64
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (detected.isMonorepo) {
|
|
30
|
-
console.log(` Monorepo: ${chalk.bold(detected.monorepoTool || 'yes')}`);
|
|
31
|
-
if (detected.services?.length) {
|
|
32
|
-
console.log(` Services: ${chalk.bold(detected.services.length)} packages detected`);
|
|
33
|
-
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error.code === 'ENOENT')
|
|
67
|
+
return false;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function isPlainDirectory(path) {
|
|
72
|
+
try {
|
|
73
|
+
return lstatSync(path).isDirectory();
|
|
34
74
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
console.log(` Dev server: ${chalk.bold(config.dev_server.command)} (port ${config.dev_server.port})\n`);
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code === 'ENOENT')
|
|
77
|
+
return false;
|
|
78
|
+
throw error;
|
|
40
79
|
}
|
|
41
|
-
const configPath = await saveConfig(config);
|
|
42
|
-
console.log(chalk.green(`ā Created ${configPath}`));
|
|
43
|
-
// Security validation
|
|
44
|
-
await runSecurityCheck(configPath);
|
|
45
|
-
console.log(chalk.green('\nDone! Run `haystack setup` to configure rules, signals, and policies.\n'));
|
|
46
80
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const name = resolveProjectName(pkgJson);
|
|
69
|
-
if (detected.isMonorepo && detected.services?.length) {
|
|
70
|
-
const services = {};
|
|
71
|
-
for (const s of detected.services) {
|
|
72
|
-
services[s.name] = {
|
|
73
|
-
...(s.root !== './' ? { root: s.root } : {}),
|
|
74
|
-
command: s.suggestedCommand || 'pnpm dev',
|
|
75
|
-
...(s.type === 'server' && s.suggestedPort ? { port: s.suggestedPort } : {}),
|
|
76
|
-
...(s.type === 'batch' ? { type: 'batch' } : {}),
|
|
77
|
-
ready_pattern: 'ready|started|listening|Local:',
|
|
78
|
-
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
79
|
-
...(s.suggestedDependsOn?.length ? { depends_on: s.suggestedDependsOn } : {}),
|
|
80
|
-
};
|
|
81
|
+
function linesOf(text) {
|
|
82
|
+
return text === '' ? [] : text.replace(/\n$/, '').split('\n');
|
|
83
|
+
}
|
|
84
|
+
/** The lines `after` adds to or removes from `before` (`+`/`-`), with one line of
|
|
85
|
+
* context (` `) around each run of changes and `ā¦` where unchanged lines are skipped. */
|
|
86
|
+
export function lineDiff(before, after) {
|
|
87
|
+
const a = linesOf(before);
|
|
88
|
+
const b = linesOf(after);
|
|
89
|
+
let prefix = 0;
|
|
90
|
+
while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix])
|
|
91
|
+
prefix += 1;
|
|
92
|
+
let suffix = 0;
|
|
93
|
+
while (suffix < a.length - prefix && suffix < b.length - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix])
|
|
94
|
+
suffix += 1;
|
|
95
|
+
const oldMiddle = a.slice(prefix, a.length - suffix);
|
|
96
|
+
const newMiddle = b.slice(prefix, b.length - suffix);
|
|
97
|
+
// Longest common subsequence of the differing middle; these are small files.
|
|
98
|
+
const lcs = Array.from({ length: oldMiddle.length + 1 }, () => new Array(newMiddle.length + 1).fill(0));
|
|
99
|
+
for (let i = oldMiddle.length - 1; i >= 0; i -= 1) {
|
|
100
|
+
for (let j = newMiddle.length - 1; j >= 0; j -= 1) {
|
|
101
|
+
lcs[i][j] = oldMiddle[i] === newMiddle[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
81
102
|
}
|
|
82
|
-
return {
|
|
83
|
-
version: '1',
|
|
84
|
-
name,
|
|
85
|
-
services,
|
|
86
|
-
};
|
|
87
103
|
}
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
function resolveProjectName(pkgJson) {
|
|
101
|
-
try {
|
|
102
|
-
return parseRemoteUrl().repo;
|
|
103
|
-
}
|
|
104
|
-
catch (err) {
|
|
105
|
-
// Classify by type, not message text (PR007). A remote that exists but
|
|
106
|
-
// isn't recognized deserves a warning ā the fallback name may not match
|
|
107
|
-
// the repo. A missing remote/repo is normal for fresh projects.
|
|
108
|
-
if (err instanceof UnsupportedRemoteUrlError) {
|
|
109
|
-
console.log(chalk.yellow(`ā Git remote URL not recognized (${err.message}); using package.json/directory name instead`));
|
|
104
|
+
const ops = a.slice(0, prefix).map(line => ` ${line}`);
|
|
105
|
+
let i = 0;
|
|
106
|
+
let j = 0;
|
|
107
|
+
while (i < oldMiddle.length || j < newMiddle.length) {
|
|
108
|
+
if (i < oldMiddle.length && j < newMiddle.length && oldMiddle[i] === newMiddle[j]) {
|
|
109
|
+
ops.push(` ${oldMiddle[i]}`);
|
|
110
|
+
i += 1;
|
|
111
|
+
j += 1;
|
|
112
|
+
}
|
|
113
|
+
else if (j < newMiddle.length && (i === oldMiddle.length || lcs[i][j + 1] >= lcs[i + 1][j])) {
|
|
114
|
+
ops.push(`+${newMiddle[j]}`);
|
|
115
|
+
j += 1;
|
|
110
116
|
}
|
|
111
117
|
else {
|
|
112
|
-
|
|
118
|
+
ops.push(`-${oldMiddle[i]}`);
|
|
119
|
+
i += 1;
|
|
113
120
|
}
|
|
114
121
|
}
|
|
115
|
-
|
|
122
|
+
ops.push(...a.slice(a.length - suffix).map(line => ` ${line}`));
|
|
123
|
+
const changed = (k) => ops[k] !== undefined && ops[k][0] !== ' ';
|
|
124
|
+
const shown = [];
|
|
125
|
+
for (let k = 0; k < ops.length; k += 1) {
|
|
126
|
+
if (changed(k) || changed(k - 1) || changed(k + 1))
|
|
127
|
+
shown.push(ops[k]);
|
|
128
|
+
else if (shown.length > 0 && shown[shown.length - 1] !== 'ā¦')
|
|
129
|
+
shown.push('ā¦');
|
|
130
|
+
}
|
|
131
|
+
if (shown[shown.length - 1] === 'ā¦')
|
|
132
|
+
shown.pop();
|
|
133
|
+
return shown;
|
|
116
134
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
function buildDevServer(detected, scripts, env) {
|
|
123
|
-
const script = scripts?.dev ? 'dev' : scripts?.start ? 'start' : null;
|
|
124
|
-
if (!script)
|
|
125
|
-
return undefined;
|
|
126
|
-
const pm = detected.packageManager;
|
|
127
|
-
// npm and bun need the `run` form; pnpm/yarn run scripts directly.
|
|
128
|
-
const command = pm === 'npm' || pm === 'bun' ? `${pm} run ${script}` : `${pm} ${script}`;
|
|
135
|
+
function change(gitRoot, target, reason, content) {
|
|
136
|
+
const before = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
|
137
|
+
if (before === content)
|
|
138
|
+
return null;
|
|
139
|
+
const inside = relative(gitRoot, target);
|
|
129
140
|
return {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
141
|
+
path: inside.startsWith('..') || isAbsolute(inside) ? target : inside,
|
|
142
|
+
action: before === null ? 'create' : 'update',
|
|
143
|
+
reason,
|
|
144
|
+
diff: lineDiff(before ?? '', content),
|
|
145
|
+
target,
|
|
146
|
+
content,
|
|
134
147
|
};
|
|
135
148
|
}
|
|
136
|
-
|
|
149
|
+
function git(gitRoot, args) {
|
|
150
|
+
return execFileSync('git', args, { cwd: gitRoot, encoding: 'utf8' }).trim();
|
|
151
|
+
}
|
|
152
|
+
/** The ignore rule git applies to `path` (`source` as git names it), or null when none matches. */
|
|
153
|
+
function ignoreRule(gitRoot, path) {
|
|
154
|
+
let reported;
|
|
137
155
|
try {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// unreadable file, malformed JSON ā degrades name resolution and
|
|
144
|
-
// dev-server detection, so say so instead of silently falling back.
|
|
145
|
-
if (err.code === 'ENOENT') {
|
|
156
|
+
reported = git(gitRoot, ['check-ignore', '-v', '--no-index', '--', path]);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
// Exit status 1: no rule matches.
|
|
160
|
+
if (error.status === 1)
|
|
146
161
|
return null;
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
const rule = /^(.*):\d+:(.*)\t/.exec(reported);
|
|
165
|
+
if (!rule)
|
|
166
|
+
throw new Error(`Could not read git's ignore rule for ${path}: ${reported}`);
|
|
167
|
+
return { source: rule[1], pattern: rule[2] };
|
|
168
|
+
}
|
|
169
|
+
/** Claude Code's Stop hook in the per-developer settings, and the exclude line that keeps that file out of commits. */
|
|
170
|
+
function planStopHook(gitRoot, notices) {
|
|
171
|
+
if (!isPlainDirectory(join(gitRoot, '.claude')) && !isCLIInstalled('claude'))
|
|
172
|
+
return [];
|
|
173
|
+
const settingsPath = `.claude/${CLAUDE_LOCAL_SETTINGS}`;
|
|
174
|
+
const localPath = claudeSettingsPath(gitRoot, CLAUDE_LOCAL_SETTINGS);
|
|
175
|
+
if (git(gitRoot, ['ls-files', '--', settingsPath]) !== '') {
|
|
176
|
+
// A hook that uploads working-tree snapshots is each developer's own opt-in, never inherited from a commit.
|
|
177
|
+
notices.push(`${settingsPath} is committed to the repository, so init left it alone: a hook there would run for everyone who pulls.`
|
|
178
|
+
+ ` To add Claude Code's Stop hook, untrack it (\`git rm --cached ${settingsPath}\`) and run \`haystack init\` again.`);
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
// .gitignore outranks .git/info/exclude, so an un-ignore rule there would put the hook in commits whatever init adds.
|
|
182
|
+
const rule = ignoreRule(gitRoot, settingsPath);
|
|
183
|
+
if (rule !== null && rule.pattern.startsWith('!') && basename(rule.source) === '.gitignore') {
|
|
184
|
+
notices.push(`${rule.source} un-ignores ${settingsPath} (${rule.pattern}), so init cannot keep it out of commits and left it alone.`
|
|
185
|
+
+ ' Remove that rule and run `haystack init` again to add Claude Code\'s Stop hook.');
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
// A settings file init cannot parse costs only the hook: the agent note and onboarding still go ahead.
|
|
189
|
+
const settings = {};
|
|
190
|
+
for (const [name, file] of [['local', CLAUDE_LOCAL_SETTINGS], ['shared', CLAUDE_SHARED_SETTINGS]]) {
|
|
191
|
+
try {
|
|
192
|
+
settings[name] = readClaudeSettings(claudeSettingsPath(gitRoot, file));
|
|
147
193
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
194
|
+
catch (error) {
|
|
195
|
+
notices.push(`.claude/${file} is not valid JSON (${error instanceof Error ? error.message : String(error)}), so init left`
|
|
196
|
+
+ ' Claude Code\'s Stop hook out. Fix the file and run `haystack init` again to add it.');
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const { local, shared } = settings;
|
|
201
|
+
const changes = [];
|
|
202
|
+
// Claude Code merges both files: a hook either one carries is installed.
|
|
203
|
+
const installed = [...claudeEntries(local, 'Stop'), ...claudeEntries(shared, 'Stop')].some(entry => entry.hooks?.some(isPrecomputeCommand));
|
|
204
|
+
if (!installed && !isInstalledGlobally('haystack')) {
|
|
205
|
+
notices.push('Claude Code\'s Stop hook runs `haystack`, which is not installed globally here (this run came through npx or a project\'s own copy), so init did not add it.'
|
|
206
|
+
+ ' Install it with `npm install -g @haystackeditor/cli` and run `haystack init` again to add the hook.');
|
|
207
|
+
}
|
|
208
|
+
else if (!installed) {
|
|
209
|
+
const settings = structuredClone(local ?? {});
|
|
210
|
+
const hooks = (settings.hooks ?? {});
|
|
211
|
+
settings.hooks = hooks;
|
|
212
|
+
const entry = { matcher: '', hooks: [{ type: 'command', command: HAYSTACK_PRECOMPUTE_HOOK_COMMAND }] };
|
|
213
|
+
hooks.Stop = [...claudeEntries(settings, 'Stop'), entry];
|
|
214
|
+
const content = `${JSON.stringify(settings, null, 2)}\n`;
|
|
215
|
+
changes.push({
|
|
216
|
+
path: settingsPath,
|
|
217
|
+
action: local === undefined ? 'create' : 'update',
|
|
218
|
+
reason: 'Claude Code prepares each change for `haystack verify` when a session stops (this checkout only).',
|
|
219
|
+
// An existing file is never echoed: personal settings can hold tokens. Only the entry init adds is shown.
|
|
220
|
+
diff: local === undefined ? lineDiff('', content)
|
|
221
|
+
: [' ⦠existing settings, kept as they are', '+ hooks.Stop gains:', ...JSON.stringify(entry, null, 2).split('\n').map(line => `+${line}`)],
|
|
222
|
+
target: localPath,
|
|
223
|
+
content,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if ((changes.length > 0 || existsSync(localPath)) && !isGitIgnored(gitRoot, settingsPath)) {
|
|
227
|
+
const excludePath = resolve(gitRoot, git(gitRoot, ['rev-parse', '--git-path', 'info/exclude']));
|
|
228
|
+
const before = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
|
|
229
|
+
const line = `/${settingsPath}`;
|
|
230
|
+
// A line already there is outranked by an un-ignore rule in the same file after it; adding it again would not help.
|
|
231
|
+
const excluded = before.split('\n').includes(line) ? null : change(gitRoot, excludePath, 'Keeps the per-developer Claude Code settings out of commits.', `${before}${before === '' || before.endsWith('\n') ? '' : '\n'}${line}\n`);
|
|
232
|
+
if (excluded)
|
|
233
|
+
changes.push(excluded);
|
|
234
|
+
}
|
|
235
|
+
return changes;
|
|
236
|
+
}
|
|
237
|
+
function sameFile(a, b) {
|
|
238
|
+
try {
|
|
239
|
+
return realpathSync(a) === realpathSync(b);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
if (error.code === 'ENOENT')
|
|
243
|
+
return false;
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** The agent note in AGENTS.md (which Codex, Cursor and most agents read), and in
|
|
248
|
+
* CLAUDE.md when the repository keeps one that does not import AGENTS.md. */
|
|
249
|
+
function planAgentNotes(gitRoot, notices) {
|
|
250
|
+
const agents = join(gitRoot, 'AGENTS.md');
|
|
251
|
+
const claude = join(gitRoot, 'CLAUDE.md');
|
|
252
|
+
const targets = [];
|
|
253
|
+
if (isPlainFile(agents) || !(existsSync(agents) || isLink(agents)))
|
|
254
|
+
targets.push('AGENTS.md');
|
|
255
|
+
if (isPlainFile(claude) && !/^@AGENTS\.md\b/m.test(readFileSync(claude, 'utf8')))
|
|
256
|
+
targets.push('CLAUDE.md');
|
|
257
|
+
if (!targets.includes('AGENTS.md') && !(targets.includes('CLAUDE.md') && sameFile(agents, claude))) {
|
|
258
|
+
notices.push(`AGENTS.md is not a plain file, so init left it alone. Add this note where your agents read it:\n${AGENT_NOTE}`);
|
|
259
|
+
}
|
|
260
|
+
return targets.flatMap(name => {
|
|
261
|
+
const target = join(gitRoot, name);
|
|
262
|
+
const before = existsSync(target) ? readFileSync(target, 'utf8') : '';
|
|
263
|
+
const begin = before.indexOf(NOTE_BEGIN);
|
|
264
|
+
const end = begin === -1 ? -1 : before.indexOf(NOTE_END, begin);
|
|
265
|
+
const after = begin !== -1 && end !== -1
|
|
266
|
+
? `${before.slice(0, begin)}${AGENT_NOTE}${before.slice(end + NOTE_END.length)}`
|
|
267
|
+
: before.trim() === '' ? `${AGENT_NOTE}\n`
|
|
268
|
+
: `${before}${before.endsWith('\n') ? '' : '\n'}\n${AGENT_NOTE}\n`;
|
|
269
|
+
const planned = change(gitRoot, target, 'Tells coding agents to run `haystack verify` after a change.', after);
|
|
270
|
+
return planned ? [planned] : [];
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
function isLink(path) {
|
|
274
|
+
try {
|
|
275
|
+
return lstatSync(path).isSymbolicLink();
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (error.code === 'ENOENT')
|
|
279
|
+
return false;
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** Everything init would change in this checkout; reads only. */
|
|
284
|
+
export function planInit(gitRoot) {
|
|
285
|
+
const notices = [];
|
|
286
|
+
return { changes: [...planStopHook(gitRoot, notices), ...planAgentNotes(gitRoot, notices)], notices };
|
|
287
|
+
}
|
|
288
|
+
function applyChange(gitRoot, planned) {
|
|
289
|
+
const inside = relative(gitRoot, planned.target);
|
|
290
|
+
if (!inside.startsWith('..') && !isAbsolute(inside))
|
|
291
|
+
assertNoSymlinkOnPath(gitRoot, planned.target);
|
|
292
|
+
else if (isLink(planned.target))
|
|
293
|
+
throw new Error(`Refusing to write ${planned.target}: it is a symlink.`);
|
|
294
|
+
mkdirSync(dirname(planned.target), { recursive: true });
|
|
295
|
+
writeFileSync(planned.target, planned.content);
|
|
296
|
+
}
|
|
297
|
+
/* -------------------------------------------------------------- command */
|
|
298
|
+
function colored(line) {
|
|
299
|
+
if (line.startsWith('+'))
|
|
300
|
+
return chalk.green(line);
|
|
301
|
+
if (line.startsWith('-'))
|
|
302
|
+
return chalk.red(line);
|
|
303
|
+
return chalk.dim(line);
|
|
304
|
+
}
|
|
305
|
+
async function confirmed() {
|
|
306
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
307
|
+
try {
|
|
308
|
+
return /^y(es)?$/i.test((await prompt.question('Make these changes? [y/N] ')).trim());
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
prompt.close();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
export async function initCommand(options) {
|
|
315
|
+
// In --json mode stdout carries the one document; every other line goes to stderr.
|
|
316
|
+
const say = (line = '') => { if (options.json)
|
|
317
|
+
console.error(line);
|
|
318
|
+
else
|
|
319
|
+
console.log(line); };
|
|
320
|
+
const gitRoot = findGitRoot();
|
|
321
|
+
if (!gitRoot)
|
|
322
|
+
throw new Error('Run `haystack init` inside a checkout of your app\'s repository.');
|
|
323
|
+
let origin;
|
|
324
|
+
try {
|
|
325
|
+
origin = resolveOriginRepository(gitRoot);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
throw new Error('This checkout has no GitHub `origin` remote. Haystack verifies GitHub repositories: add the repository as `origin`, then run `haystack init` again.');
|
|
329
|
+
}
|
|
330
|
+
const repository = `${origin.owner}/${origin.repository}`;
|
|
331
|
+
const plan = planInit(gitRoot);
|
|
332
|
+
const finish = (status, onboarding, exitCode) => {
|
|
333
|
+
if (options.json) {
|
|
334
|
+
const changes = plan.changes.map(({ path, action, reason, diff }) => ({ path, action, reason, diff }));
|
|
335
|
+
process.stdout.write(`${JSON.stringify(withSchema('init', { status, repository, changes, notices: plan.notices, onboarding }), null, 2)}\n`);
|
|
336
|
+
}
|
|
337
|
+
process.exitCode = exitCode;
|
|
338
|
+
};
|
|
339
|
+
say(chalk.bold(`Haystack setup for ${repository}`));
|
|
340
|
+
if (plan.changes.length === 0)
|
|
341
|
+
say(chalk.dim('Nothing in this checkout needs changing.'));
|
|
342
|
+
for (const planned of plan.changes) {
|
|
343
|
+
say('');
|
|
344
|
+
say(`${chalk.bold(planned.action === 'create' ? 'Create' : 'Update')} ${planned.path}: ${planned.reason}`);
|
|
345
|
+
for (const line of planned.diff)
|
|
346
|
+
say(` ${colored(line)}`);
|
|
347
|
+
}
|
|
348
|
+
for (const notice of plan.notices)
|
|
349
|
+
say(`\n${chalk.yellow(notice)}`);
|
|
350
|
+
say('');
|
|
351
|
+
let token;
|
|
352
|
+
try {
|
|
353
|
+
token = (await resolveAuthContext({ owner: origin.owner, repo: origin.repository })).token;
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
if (!(error instanceof NotLoggedInError))
|
|
357
|
+
throw error;
|
|
358
|
+
say(chalk.yellow('Not logged in, so nothing was changed. Run `haystack login`, then `haystack init` again.'));
|
|
359
|
+
finish('login-required', null, EXIT_CODES['login-required']);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (plan.changes.length > 0 && !options.yes) {
|
|
363
|
+
const interactive = !options.json && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
364
|
+
if (!interactive || !await confirmed()) {
|
|
365
|
+
say('Nothing was changed. Run `haystack init --yes` to make these changes.');
|
|
366
|
+
finish('planned', null, EXIT_CODES.planned);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
for (const planned of plan.changes) {
|
|
371
|
+
applyChange(gitRoot, planned);
|
|
372
|
+
say(chalk.green(`ā ${planned.action === 'create' ? 'Created' : 'Updated'} ${planned.path}`));
|
|
373
|
+
}
|
|
374
|
+
let onboarding;
|
|
375
|
+
try {
|
|
376
|
+
onboarding = await startOnboarding(repository, deriveBaseSha(gitRoot, Date.now() + EXPLICIT_WALL_MS), token);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
if (error instanceof HaystackApiError && error.code === INSTALLATION_REQUIRED_CODE) {
|
|
380
|
+
say(chalk.yellow(`Haystack needs its GitHub App on ${origin.owner} to read the repository. Install it at`));
|
|
381
|
+
say(` ${HAYSTACK_APP_INSTALL_URL}`);
|
|
382
|
+
say('then run `haystack init` again.');
|
|
383
|
+
finish('app-required', null, EXIT_CODES['app-required']);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const done = plan.changes.length > 0 ? 'The changes above were made, but onboarding' : 'Onboarding';
|
|
387
|
+
throw new Error(`${done} could not start: ${error instanceof Error ? error.message : String(error)} Run \`haystack init\` again to start it.`);
|
|
388
|
+
}
|
|
389
|
+
reportOnboardingState(onboarding);
|
|
390
|
+
say(formatOnboarding(onboarding));
|
|
391
|
+
say('');
|
|
392
|
+
if (onboarding.state === 'ready')
|
|
393
|
+
say(chalk.green('Ready. After your next change, run `haystack verify`.'));
|
|
394
|
+
else if (onboarding.state === 'onboarding') {
|
|
395
|
+
say(chalk.green('Haystack is preparing the app in the background; the first time can take half an hour.'));
|
|
396
|
+
say('Keep working: `haystack verify` waits for it when needed, and `haystack verify onboarding --wait` follows it.');
|
|
151
397
|
}
|
|
398
|
+
finish('done', onboarding, setUpExitCode(onboarding));
|
|
152
399
|
}
|
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
import { execFileSync } from 'child_process';
|
|
15
15
|
import chalk from 'chalk';
|
|
16
16
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
17
|
-
import { join } from 'path';
|
|
17
|
+
import { join, sep } from 'path';
|
|
18
18
|
import { findGitRoot } from '../utils/git.js';
|
|
19
19
|
import { assertNoSymlinkOnPath } from '../utils/safe-write.js';
|
|
20
20
|
// ============================================================================
|
|
21
21
|
// CLI detection (reused from triage runner pattern)
|
|
22
22
|
// ============================================================================
|
|
23
|
-
function isCLIInstalled(name) {
|
|
23
|
+
export function isCLIInstalled(name) {
|
|
24
24
|
try {
|
|
25
25
|
execFileSync(process.platform === 'win32' ? 'where' : 'which', [name], {
|
|
26
26
|
stdio: 'ignore',
|
|
@@ -31,6 +31,23 @@ function isCLIInstalled(name) {
|
|
|
31
31
|
return false;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
/** Whether `name` is installed where a command run later (a Claude Code hook)
|
|
35
|
+
* finds it. Anything under a node_modules directory is left out: npx, pnpm dlx,
|
|
36
|
+
* bunx and npm's own project .bin are put on PATH for one run only. */
|
|
37
|
+
export function isInstalledGlobally(name) {
|
|
38
|
+
const windows = process.platform === 'win32';
|
|
39
|
+
let found;
|
|
40
|
+
try {
|
|
41
|
+
found = execFileSync(windows ? 'where' : 'which', windows ? [name] : ['-a', name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
// Exit status 1: nothing by that name on PATH.
|
|
45
|
+
if (error.status === 1)
|
|
46
|
+
return false;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return found.split(/\r?\n/).some(path => path !== '' && !path.includes(`${sep}node_modules${sep}`));
|
|
50
|
+
}
|
|
34
51
|
function detectInstalledCLIs() {
|
|
35
52
|
const installed = [];
|
|
36
53
|
if (isCLIInstalled('claude'))
|
|
@@ -45,27 +62,27 @@ function detectInstalledCLIs() {
|
|
|
45
62
|
// Claude Code: Native SessionStart hook
|
|
46
63
|
// ============================================================================
|
|
47
64
|
const HAYSTACK_HOOK_COMMAND = 'haystack triage --hook';
|
|
48
|
-
const HAYSTACK_PRECOMPUTE_HOOK_COMMAND = 'haystack verify precompute --hook';
|
|
65
|
+
export const HAYSTACK_PRECOMPUTE_HOOK_COMMAND = 'haystack verify precompute --hook';
|
|
49
66
|
// The per-developer file is the default: a hook that uploads working-tree
|
|
50
67
|
// snapshots must be opted into by each developer, not inherited from a commit.
|
|
51
|
-
const CLAUDE_LOCAL_SETTINGS = 'settings.local.json';
|
|
52
|
-
const CLAUDE_SHARED_SETTINGS = 'settings.json';
|
|
53
|
-
function claudeSettingsPath(gitRoot, fileName) {
|
|
68
|
+
export const CLAUDE_LOCAL_SETTINGS = 'settings.local.json';
|
|
69
|
+
export const CLAUDE_SHARED_SETTINGS = 'settings.json';
|
|
70
|
+
export function claudeSettingsPath(gitRoot, fileName) {
|
|
54
71
|
return join(gitRoot, '.claude', fileName);
|
|
55
72
|
}
|
|
56
|
-
function readClaudeSettings(settingsPath) {
|
|
73
|
+
export function readClaudeSettings(settingsPath) {
|
|
57
74
|
if (!existsSync(settingsPath))
|
|
58
75
|
return undefined;
|
|
59
76
|
return JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
60
77
|
}
|
|
61
|
-
function claudeEntries(settings, event) {
|
|
78
|
+
export function claudeEntries(settings, event) {
|
|
62
79
|
const hooks = settings?.hooks;
|
|
63
80
|
const entries = hooks?.[event];
|
|
64
81
|
return Array.isArray(entries) ? entries : [];
|
|
65
82
|
}
|
|
66
83
|
const isTriageCommand = (h) => Boolean(h.command?.includes('haystack triage') || h.command?.includes('haystack check-pending'));
|
|
67
|
-
const isPrecomputeCommand = (h) => Boolean(h.command?.includes(HAYSTACK_PRECOMPUTE_HOOK_COMMAND));
|
|
68
|
-
function isGitIgnored(gitRoot, path) {
|
|
84
|
+
export const isPrecomputeCommand = (h) => Boolean(h.command?.includes(HAYSTACK_PRECOMPUTE_HOOK_COMMAND));
|
|
85
|
+
export function isGitIgnored(gitRoot, path) {
|
|
69
86
|
try {
|
|
70
87
|
execFileSync('git', ['check-ignore', '-q', '--', path], { cwd: gitRoot, stdio: 'ignore' });
|
|
71
88
|
return true;
|