@haystackeditor/cli 0.15.31 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +111 -60
  2. package/dist/assets/hooks/scripts/commit-msg.sh +3 -0
  3. package/dist/assets/hooks/scripts/post-commit.sh +3 -0
  4. package/dist/assets/hooks/scripts/pre-commit.sh +11 -6
  5. package/dist/assets/hooks/scripts/pre-push.sh +3 -0
  6. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +3 -0
  7. package/dist/commands/case-batch-contract.js +608 -0
  8. package/dist/commands/case-batch.js +713 -0
  9. package/dist/commands/cloud-verifier-identity-census.js +5 -2
  10. package/dist/commands/combination-search-hook.js +139 -0
  11. package/dist/commands/dismiss.js +2 -1
  12. package/dist/commands/hooks.js +66 -7
  13. package/dist/commands/install-session-hooks.js +131 -44
  14. package/dist/commands/policy.js +32 -45
  15. package/dist/commands/precompute-delivery.js +6 -1
  16. package/dist/commands/scaffold-provisional-universe.js +8 -10
  17. package/dist/commands/setup.js +32 -10
  18. package/dist/commands/submit.js +7 -2
  19. package/dist/commands/telemetry.js +17 -2
  20. package/dist/commands/triage.js +2 -1
  21. package/dist/commands/verify-explore.js +1 -0
  22. package/dist/commands/verify-hosted-mcp.js +3 -12
  23. package/dist/commands/verify-hosted-reproducibility.js +49 -547
  24. package/dist/commands/verify-hosted.js +66 -168
  25. package/dist/commands/verify-precompute.js +57 -10
  26. package/dist/commands/verify.js +51 -139
  27. package/dist/index.js +238 -350
  28. package/dist/lazy.js +8 -0
  29. package/dist/schema.js +1 -0
  30. package/dist/tools/detect.js +3 -24
  31. package/dist/triage/prompts.js +46 -45
  32. package/dist/triage/runner.js +119 -33
  33. package/dist/types.js +3 -3
  34. package/dist/utils/auth.js +14 -2
  35. package/dist/utils/git.js +60 -29
  36. package/dist/utils/github-api.js +14 -1
  37. package/dist/utils/haystack-api.js +26 -7
  38. package/dist/utils/hooks.js +43 -6
  39. package/dist/utils/prompter.js +25 -10
  40. package/dist/utils/safe-write.js +31 -0
  41. package/dist/utils/secret-paths.js +115 -0
  42. package/dist/utils/secrets.js +0 -1
  43. package/dist/utils/telemetry.js +34 -14
  44. package/dist/utils/update-check.js +151 -0
  45. package/package.json +18 -14
  46. package/schemas/case-batch.v1.json +184 -0
  47. package/schemas/cloud-verifier.v1.json +55 -100
  48. package/dist/commands/ask.d.ts +0 -14
  49. package/dist/commands/cloud-verifier-behaviors.d.ts +0 -27
  50. package/dist/commands/cloud-verifier-data-store-census.d.ts +0 -47
  51. package/dist/commands/cloud-verifier-data-store-drift.d.ts +0 -42
  52. package/dist/commands/cloud-verifier-identity-census.d.ts +0 -88
  53. package/dist/commands/cloud-verifier-materialization.d.ts +0 -16
  54. package/dist/commands/cloud-verifier-pascal-selector-census.d.ts +0 -29
  55. package/dist/commands/cloud-verifier-python-manifest-selector-census.d.ts +0 -27
  56. package/dist/commands/cloud-verifier-specialized-operational-census.d.ts +0 -51
  57. package/dist/commands/cloud-verifier-universe.d.ts +0 -31
  58. package/dist/commands/config.d.ts +0 -46
  59. package/dist/commands/design-verify.d.ts +0 -33
  60. package/dist/commands/dismiss.d.ts +0 -29
  61. package/dist/commands/hooks.d.ts +0 -13
  62. package/dist/commands/inbox.d.ts +0 -65
  63. package/dist/commands/init.d.ts +0 -10
  64. package/dist/commands/install-session-hooks.d.ts +0 -17
  65. package/dist/commands/login.d.ts +0 -8
  66. package/dist/commands/mcp.d.ts +0 -1
  67. package/dist/commands/policy.d.ts +0 -31
  68. package/dist/commands/pr-status.d.ts +0 -144
  69. package/dist/commands/pr.d.ts +0 -40
  70. package/dist/commands/precompute-delivery-contract.d.ts +0 -68
  71. package/dist/commands/precompute-delivery.d.ts +0 -20
  72. package/dist/commands/prepare-universe-review.d.ts +0 -115
  73. package/dist/commands/production-source-deny-policy.d.ts +0 -15
  74. package/dist/commands/request-review.d.ts +0 -26
  75. package/dist/commands/review.d.ts +0 -25
  76. package/dist/commands/rules.d.ts +0 -4
  77. package/dist/commands/scaffold-provisional-universe.d.ts +0 -468
  78. package/dist/commands/schema-cmd.d.ts +0 -2
  79. package/dist/commands/setup.d.ts +0 -28
  80. package/dist/commands/skills.d.ts +0 -8
  81. package/dist/commands/status.d.ts +0 -4
  82. package/dist/commands/submit.d.ts +0 -30
  83. package/dist/commands/system-map.d.ts +0 -42
  84. package/dist/commands/telemetry.d.ts +0 -53
  85. package/dist/commands/tokens.d.ts +0 -14
  86. package/dist/commands/triage.d.ts +0 -35
  87. package/dist/commands/verify-core.d.ts +0 -449
  88. package/dist/commands/verify-core.js +0 -789
  89. package/dist/commands/verify-explore.d.ts +0 -14
  90. package/dist/commands/verify-history.d.ts +0 -14
  91. package/dist/commands/verify-hosted-mcp.d.ts +0 -12
  92. package/dist/commands/verify-hosted-reproducibility.d.ts +0 -90
  93. package/dist/commands/verify-hosted.d.ts +0 -92
  94. package/dist/commands/verify-mcp.d.ts +0 -8
  95. package/dist/commands/verify-mcp.js +0 -517
  96. package/dist/commands/verify-ops.d.ts +0 -158
  97. package/dist/commands/verify-ops.js +0 -1148
  98. package/dist/commands/verify-precompute.d.ts +0 -30
  99. package/dist/commands/verify-reproducibility.d.ts +0 -85
  100. package/dist/commands/verify-reproducibility.js +0 -494
  101. package/dist/commands/verify-reseal.d.ts +0 -9
  102. package/dist/commands/verify-reseal.js +0 -148
  103. package/dist/commands/verify-sandboxes.d.ts +0 -95
  104. package/dist/commands/verify-sandboxes.js +0 -352
  105. package/dist/commands/verify.d.ts +0 -28
  106. package/dist/commands/webhooks.d.ts +0 -30
  107. package/dist/index.d.ts +0 -22
  108. package/dist/schema.d.ts +0 -28
  109. package/dist/states.d.ts +0 -29
  110. package/dist/tools/detect.d.ts +0 -50
  111. package/dist/triage/prompts.d.ts +0 -24
  112. package/dist/triage/runner.d.ts +0 -34
  113. package/dist/triage/types.d.ts +0 -42
  114. package/dist/types/verify-history.d.ts +0 -38
  115. package/dist/types.d.ts +0 -1684
  116. package/dist/utils/action-output.d.ts +0 -24
  117. package/dist/utils/analysis-api.d.ts +0 -187
  118. package/dist/utils/auth.d.ts +0 -79
  119. package/dist/utils/config.d.ts +0 -24
  120. package/dist/utils/design-verifier-api.d.ts +0 -208
  121. package/dist/utils/design-verifier-history.d.ts +0 -46
  122. package/dist/utils/design-verifier-result.d.ts +0 -74
  123. package/dist/utils/detect.d.ts +0 -43
  124. package/dist/utils/git.d.ts +0 -135
  125. package/dist/utils/github-api.d.ts +0 -104
  126. package/dist/utils/haystack-api.d.ts +0 -37
  127. package/dist/utils/hooks.d.ts +0 -12
  128. package/dist/utils/pending-state.d.ts +0 -40
  129. package/dist/utils/pr-ref.d.ts +0 -27
  130. package/dist/utils/prompter.d.ts +0 -85
  131. package/dist/utils/secrets.d.ts +0 -47
  132. package/dist/utils/telemetry.d.ts +0 -19
  133. /package/dist/commands/{precompute-delivery-worker.d.ts → combination-search-hook-contract.js} +0 -0
package/dist/utils/git.js CHANGED
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Provides functions for git operations: branch management, push, remote parsing.
5
5
  */
6
- import { execFileSync, execSync } from 'child_process';
6
+ import { execFileSync } from 'child_process';
7
7
  import * as fs from 'fs';
8
8
  import * as os from 'os';
9
9
  import * as path from 'path';
10
+ import { isHeadlessToken } from './auth.js';
10
11
  // Re-export findGitRoot from hooks for consistency
11
12
  export { findGitRoot } from './hooks.js';
12
13
  // ============================================================================
@@ -17,7 +18,7 @@ export { findGitRoot } from './hooks.js';
17
18
  */
18
19
  export function getCurrentBranch() {
19
20
  try {
20
- return execSync('git rev-parse --abbrev-ref HEAD', {
21
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
21
22
  encoding: 'utf-8',
22
23
  stdio: ['pipe', 'pipe', 'pipe'],
23
24
  }).trim();
@@ -33,17 +34,25 @@ export function getCurrentBranch() {
33
34
  */
34
35
  export function getDefaultBranch() {
35
36
  try {
36
- // Try to get from remote HEAD
37
- const remoteHead = execSync('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo ""', {
38
- encoding: 'utf-8',
39
- stdio: ['pipe', 'pipe', 'pipe'],
40
- }).trim();
37
+ // Try to get from remote HEAD. `git symbolic-ref` exits non-zero when
38
+ // origin/HEAD is unset, which is normal for many clones; treat that as
39
+ // "no remote HEAD" and move on to the branch listing below.
40
+ let remoteHead = '';
41
+ try {
42
+ remoteHead = execFileSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], {
43
+ encoding: 'utf-8',
44
+ stdio: ['pipe', 'pipe', 'pipe'],
45
+ }).trim();
46
+ }
47
+ catch {
48
+ remoteHead = '';
49
+ }
41
50
  if (remoteHead) {
42
51
  // refs/remotes/origin/main -> main
43
52
  return remoteHead.replace('refs/remotes/origin/', '');
44
53
  }
45
54
  // Fallback: check if main or master exists
46
- const branches = execSync('git branch -r', {
55
+ const branches = execFileSync('git', ['branch', '-r'], {
47
56
  encoding: 'utf-8',
48
57
  stdio: ['pipe', 'pipe', 'pipe'],
49
58
  });
@@ -62,7 +71,7 @@ export function getDefaultBranch() {
62
71
  */
63
72
  export function hasUncommittedChanges() {
64
73
  try {
65
- const status = execSync('git status --porcelain', {
74
+ const status = execFileSync('git', ['status', '--porcelain'], {
66
75
  encoding: 'utf-8',
67
76
  stdio: ['pipe', 'pipe', 'pipe'],
68
77
  }).trim();
@@ -76,17 +85,24 @@ export function hasUncommittedChanges() {
76
85
  * Check if current branch has unpushed commits
77
86
  */
78
87
  export function hasUnpushedCommits() {
88
+ let currentBranch;
79
89
  try {
80
- const currentBranch = getCurrentBranch();
81
- const unpushed = execSync(`git log origin/${currentBranch}..HEAD --oneline 2>/dev/null || echo ""`, {
90
+ currentBranch = getCurrentBranch();
91
+ }
92
+ catch {
93
+ // Branch may not have an upstream, assume there are commits to push
94
+ return true;
95
+ }
96
+ try {
97
+ const unpushed = execFileSync('git', ['log', '--oneline', '--end-of-options', `origin/${currentBranch}..HEAD`], {
82
98
  encoding: 'utf-8',
83
99
  stdio: ['pipe', 'pipe', 'pipe'],
84
100
  }).trim();
85
101
  return unpushed.length > 0;
86
102
  }
87
103
  catch {
88
- // Branch may not have an upstream, assume there are commits to push
89
- return true;
104
+ // No origin/<branch> yet: same result the old `|| echo ""` produced.
105
+ return false;
90
106
  }
91
107
  }
92
108
  /**
@@ -106,7 +122,7 @@ export function getBranchInfo() {
106
122
  */
107
123
  export function createBranch(branchName) {
108
124
  try {
109
- execSync(`git checkout -b "${branchName}"`, {
125
+ execFileSync('git', ['checkout', '-b', branchName], {
110
126
  encoding: 'utf-8',
111
127
  stdio: ['pipe', 'pipe', 'pipe'],
112
128
  });
@@ -124,7 +140,7 @@ export function createBranch(branchName) {
124
140
  */
125
141
  export function branchExists(branchName) {
126
142
  try {
127
- execSync(`git rev-parse --verify "${branchName}" 2>/dev/null`, {
143
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', '--end-of-options', branchName], {
128
144
  encoding: 'utf-8',
129
145
  stdio: ['pipe', 'pipe', 'pipe'],
130
146
  });
@@ -139,7 +155,7 @@ export function branchExists(branchName) {
139
155
  */
140
156
  export function remoteBranchExists(remoteName, branchName) {
141
157
  try {
142
- execSync(`git ls-remote --heads ${remoteName} ${branchName}`, {
158
+ execFileSync('git', ['ls-remote', '--heads', '--', remoteName, branchName], {
143
159
  encoding: 'utf-8',
144
160
  stdio: ['pipe', 'pipe', 'pipe'],
145
161
  });
@@ -153,8 +169,14 @@ export function remoteBranchExists(remoteName, branchName) {
153
169
  * Checkout an existing branch
154
170
  */
155
171
  export function checkoutBranch(branchName) {
172
+ // `git checkout` does not honour `--end-of-options` (git 2.39 reads it as a
173
+ // pathspec), so a leading dash is refused here and the trailing `--` keeps
174
+ // the name from being read as a path. Git never creates a ref starting with '-'.
175
+ if (branchName.startsWith('-')) {
176
+ throw new Error(`Failed to checkout branch: invalid branch name "${branchName}"`);
177
+ }
156
178
  try {
157
- execSync(`git checkout "${branchName}"`, {
179
+ execFileSync('git', ['checkout', branchName, '--'], {
158
180
  encoding: 'utf-8',
159
181
  stdio: ['pipe', 'pipe', 'pipe'],
160
182
  });
@@ -164,6 +186,8 @@ export function checkoutBranch(branchName) {
164
186
  throw new Error(`Failed to checkout branch: ${message}`);
165
187
  }
166
188
  }
189
+ /** One-line failure for a push attempted with a Haystack headless token. */
190
+ export const HEADLESS_PUSH_ERROR = 'Pushing a branch needs a GitHub credential, and a Haystack headless token (hsk_live_) cannot push: run `haystack login`, then retry.';
167
191
  /**
168
192
  * Push current branch to remote with upstream tracking.
169
193
  *
@@ -178,11 +202,15 @@ export function checkoutBranch(branchName) {
178
202
  */
179
203
  export function pushBranch(remoteName, branchName, auth) {
180
204
  if (auth) {
205
+ if (isHeadlessToken(auth.token)) {
206
+ // Never hand a Haystack token to github.com as a git password.
207
+ throw new Error(HEADLESS_PUSH_ERROR);
208
+ }
181
209
  pushBranchWithToken(remoteName, branchName, auth.token);
182
210
  return;
183
211
  }
184
212
  try {
185
- execSync(`git push -u ${remoteName} "${branchName}"`, {
213
+ execFileSync('git', ['push', '-u', '--end-of-options', remoteName, branchName], {
186
214
  encoding: 'utf-8',
187
215
  stdio: ['pipe', 'pipe', 'pipe'],
188
216
  });
@@ -214,14 +242,17 @@ function pushBranchWithToken(remoteName, branchName, token) {
214
242
  // means the user's configured url/pushurl/credential-helper settings
215
243
  // are untouched — sidesteps precedence and multi-value edge cases
216
244
  // around `remote.<name>.url` vs `remote.<name>.pushurl`.
217
- execSync(`git -c credential.helper= push "${pushUrl}" "HEAD:refs/heads/${branchName}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: authedEnv });
245
+ execFileSync('git', ['-c', 'credential.helper=', 'push', '--end-of-options', pushUrl, `HEAD:refs/heads/${branchName}`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: authedEnv });
218
246
  // Pushing to a raw URL doesn't update refs/remotes/<name>/<branch>. Seed
219
247
  // it from the server's authoritative state with a targeted fetch — this
220
248
  // reflects any server-side rewrite or hook side effects, not a fabricated
221
249
  // pre-push SHA. Then set upstream tracking to the named remote.
222
250
  try {
223
- execSync(`git -c credential.helper= fetch "${pushUrl}" "refs/heads/${branchName}:refs/remotes/${remoteName}/${branchName}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: authedEnv });
224
- execSync(`git branch --set-upstream-to=${remoteName}/${branchName} "${branchName}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
251
+ execFileSync('git', [
252
+ '-c', 'credential.helper=', 'fetch', '--end-of-options', pushUrl,
253
+ `refs/heads/${branchName}:refs/remotes/${remoteName}/${branchName}`,
254
+ ], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: authedEnv });
255
+ execFileSync('git', ['branch', `--set-upstream-to=${remoteName}/${branchName}`, '--end-of-options', branchName], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
225
256
  }
226
257
  catch (err) {
227
258
  // Non-fatal: push succeeded; upstream tracking is a convenience.
@@ -272,7 +303,7 @@ export class UnsupportedRemoteUrlError extends Error {
272
303
  */
273
304
  export function parseRemoteUrl(remoteName = 'origin') {
274
305
  try {
275
- const url = execSync(`git remote get-url ${remoteName}`, {
306
+ const url = execFileSync('git', ['remote', 'get-url', '--', remoteName], {
276
307
  encoding: 'utf-8',
277
308
  stdio: ['pipe', 'pipe', 'pipe'],
278
309
  }).trim();
@@ -320,7 +351,7 @@ export function parseRemoteUrl(remoteName = 'origin') {
320
351
  */
321
352
  export function getLastCommitMessage() {
322
353
  try {
323
- return execSync('git log -1 --format=%s', {
354
+ return execFileSync('git', ['log', '-1', '--format=%s'], {
324
355
  encoding: 'utf-8',
325
356
  stdio: ['pipe', 'pipe', 'pipe'],
326
357
  }).trim();
@@ -336,7 +367,7 @@ export function getLastCommitMessage() {
336
367
  */
337
368
  export function getLastCommitSha() {
338
369
  try {
339
- return execSync('git rev-parse --short HEAD', {
370
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
340
371
  encoding: 'utf-8',
341
372
  stdio: ['pipe', 'pipe', 'pipe'],
342
373
  }).trim();
@@ -352,13 +383,13 @@ export function getLastCommitSha() {
352
383
  export function getCommitMessagesSinceBase(baseBranch) {
353
384
  try {
354
385
  // Use origin/<base> to compare against remote base branch
355
- const mergeBase = execSync(`git merge-base origin/${baseBranch} HEAD`, {
386
+ const mergeBase = execFileSync('git', ['merge-base', '--end-of-options', `origin/${baseBranch}`, 'HEAD'], {
356
387
  encoding: 'utf-8',
357
388
  stdio: ['pipe', 'pipe', 'pipe'],
358
389
  }).trim();
359
390
  if (!mergeBase)
360
391
  return '';
361
- return execSync(`git log ${mergeBase}..HEAD --format=%B --reverse`, {
392
+ return execFileSync('git', ['log', '--format=%B', '--reverse', '--end-of-options', `${mergeBase}..HEAD`], {
362
393
  encoding: 'utf-8',
363
394
  stdio: ['pipe', 'pipe', 'pipe'],
364
395
  }).trim();
@@ -366,7 +397,7 @@ export function getCommitMessagesSinceBase(baseBranch) {
366
397
  catch {
367
398
  // Fallback: try just the last commit's full message
368
399
  try {
369
- return execSync('git log -1 --format=%B', {
400
+ return execFileSync('git', ['log', '-1', '--format=%B'], {
370
401
  encoding: 'utf-8',
371
402
  stdio: ['pipe', 'pipe', 'pipe'],
372
403
  }).trim();
@@ -461,7 +492,7 @@ export function resolveDiffBaseRef(baseBranch, options = {}) {
461
492
  // if <base> was rewound on the remote).
462
493
  let fetched = false;
463
494
  try {
464
- execFileSync('git', ['fetch', 'origin', `+refs/heads/${baseBranch}:${remoteRef}`], {
495
+ execFileSync('git', ['fetch', '--end-of-options', 'origin', `+refs/heads/${baseBranch}:${remoteRef}`], {
465
496
  ...gitCommandOptions(options),
466
497
  env: options.timeoutMs
467
498
  ? { ...process.env, GIT_TERMINAL_PROMPT: '0' }
@@ -496,7 +527,7 @@ export function resolveDiffBaseRef(baseBranch, options = {}) {
496
527
  */
497
528
  export function getCommitsAhead(baseBranch) {
498
529
  try {
499
- const output = execSync(`git rev-list --count ${baseBranch}..HEAD`, {
530
+ const output = execFileSync('git', ['rev-list', '--count', '--end-of-options', `${baseBranch}..HEAD`], {
500
531
  encoding: 'utf-8',
501
532
  stdio: ['pipe', 'pipe', 'pipe'],
502
533
  }).trim();
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Provides functions for creating PRs, managing labels, etc.
5
5
  */
6
- import { resolveAuthContext } from './auth.js';
6
+ import { isHeadlessToken, resolveAuthContext } from './auth.js';
7
7
  import { trackError } from './telemetry.js';
8
8
  const GITHUB_API_BASE = 'https://api.github.com';
9
9
  // OAuth App Policy fallback. Orgs can restrict our OAuth app, which makes the
@@ -22,6 +22,13 @@ const GITHUB_PROXY_BASE = 'https://haystackeditor.com/api/github';
22
22
  * `x-ratelimit-remaining: 0` and the proxy can't help with those, so we don't
23
23
  * retry them.
24
24
  */
25
+ /**
26
+ * REST base URL for a token. Haystack headless tokens (hsk_live_*) only work
27
+ * through the Haystack proxy and must never reach api.github.com.
28
+ */
29
+ export function githubRestBaseFor(token) {
30
+ return isHeadlessToken(token) ? GITHUB_PROXY_BASE : GITHUB_API_BASE;
31
+ }
25
32
  function isOAuthPolicyForbidden(response) {
26
33
  return response.status === 403 && response.headers.get('x-ratelimit-remaining') !== '0';
27
34
  }
@@ -75,6 +82,12 @@ async function githubApiRequest(method, path, token, body) {
75
82
  },
76
83
  body: body ? JSON.stringify(body) : undefined,
77
84
  };
85
+ // A Haystack headless token (hsk_live_*) is not a GitHub credential: sending
86
+ // it to api.github.com would leak it to GitHub and fail anyway. Only the
87
+ // Haystack proxy understands it (it resolves an App installation token).
88
+ if (isHeadlessToken(token)) {
89
+ return fetch(`${GITHUB_PROXY_BASE}${path}`, init);
90
+ }
78
91
  const direct = await fetch(`${GITHUB_API_BASE}${path}`, init);
79
92
  if (!isOAuthPolicyForbidden(direct))
80
93
  return direct;
@@ -13,10 +13,13 @@ export function haystackApiUrl(path) {
13
13
  */
14
14
  export class HaystackApiError extends Error {
15
15
  status;
16
- constructor(status, message) {
16
+ /** The server's stable `error` code when the body carried one. */
17
+ code;
18
+ constructor(status, message, code = null) {
17
19
  super(message);
18
20
  this.name = 'HaystackApiError';
19
21
  this.status = status;
22
+ this.code = code;
20
23
  }
21
24
  get isAuthError() {
22
25
  return this.status === 401 || this.status === 403;
@@ -29,7 +32,17 @@ function headers(token, extra) {
29
32
  result.set('User-Agent', 'Haystack-CLI');
30
33
  return result;
31
34
  }
32
- /** Extract the server's own message if the body is JSON; truncate raw bodies. */
35
+ /**
36
+ * The server returns this `error` code (HTTP 403) when the authenticated
37
+ * account has no paid subscription, so the CLI can say so instead of
38
+ * reporting a missing resource or a repository permission problem.
39
+ */
40
+ export const SUBSCRIPTION_REQUIRED_CODE = 'subscription_required';
41
+ export const SUBSCRIPTION_REQUIRED_MESSAGE = 'This account has no active Haystack subscription. See https://haystack.sh/pricing or email akshay@haystack.sh to get access.';
42
+ /**
43
+ * Extract the server's own message (and its `error` code) if the body is
44
+ * JSON; truncate raw bodies.
45
+ */
33
46
  async function serverMessage(response) {
34
47
  let text;
35
48
  try {
@@ -39,17 +52,20 @@ async function serverMessage(response) {
39
52
  // A transport failure while reading the error body is itself signal —
40
53
  // say so instead of reporting it as "no detail" (the status-based
41
54
  // classification in classifyHttpError stands either way).
42
- return `error body unreadable: ${err instanceof Error ? err.message : String(err)}`;
55
+ return { detail: `error body unreadable: ${err instanceof Error ? err.message : String(err)}`, code: null };
43
56
  }
44
57
  if (!text)
45
- return null;
58
+ return { detail: null, code: null };
46
59
  try {
47
60
  const parsed = JSON.parse(text);
48
- return parsed.message ?? parsed.error ?? null;
61
+ return {
62
+ detail: parsed.message ?? parsed.error ?? null,
63
+ code: typeof parsed.error === 'string' ? parsed.error : null,
64
+ };
49
65
  }
50
66
  catch {
51
67
  // Not JSON (HTML error page, plain text) — never dump a full raw body.
52
- return text.slice(0, 200);
68
+ return { detail: text.slice(0, 200), code: null };
53
69
  }
54
70
  }
55
71
  /**
@@ -57,8 +73,11 @@ async function serverMessage(response) {
57
73
  * class it failed with, and the next command to run.
58
74
  */
59
75
  export async function classifyHttpError(response, context) {
60
- const detail = await serverMessage(response);
76
+ const { detail, code } = await serverMessage(response);
61
77
  const suffix = detail ? ` (${detail})` : '';
78
+ if (response.status === 403 && code === SUBSCRIPTION_REQUIRED_CODE) {
79
+ return new HaystackApiError(403, SUBSCRIPTION_REQUIRED_MESSAGE, code);
80
+ }
62
81
  if (response.status === 401) {
63
82
  return new HaystackApiError(401, `${context}: authentication failed${suffix}. Run \`haystack login\` to re-authenticate.`);
64
83
  }
@@ -6,6 +6,7 @@ import { existsSync } from 'fs';
6
6
  import * as path from 'path';
7
7
  import { execSync } from 'child_process';
8
8
  import { fileURLToPath } from 'url';
9
+ import { assertNoSymlinkOnPath } from './safe-write.js';
9
10
  // ============================================================================
10
11
  // Constants
11
12
  // ============================================================================
@@ -48,22 +49,54 @@ export function getAssetsDir() {
48
49
  export async function copyHookFiles(hooksDir) {
49
50
  const assetsDir = getAssetsDir();
50
51
  const scriptsDir = path.join(assetsDir, 'scripts');
52
+ // Every destination is checked against the repository root: copyFile and
53
+ // chmod follow symlinks, so a committed `hooks/pre-commit -> ~/.zshrc`
54
+ // would otherwise be overwritten and made executable.
55
+ const repoRoot = path.dirname(hooksDir);
51
56
  // Ensure hooks directory exists
57
+ assertNoSymlinkOnPath(repoRoot, hooksDir);
52
58
  await fs.mkdir(hooksDir, { recursive: true });
53
59
  // Copy shell scripts (strip .sh extension, set executable)
54
60
  for (const hook of HOOK_SCRIPTS) {
55
61
  const src = path.join(scriptsDir, `${hook}.sh`);
56
62
  const dest = path.join(hooksDir, hook);
63
+ assertNoSymlinkOnPath(repoRoot, dest);
57
64
  await fs.copyFile(src, dest);
58
65
  await fs.chmod(dest, 0o755);
59
66
  }
60
67
  // Copy agent-context module
61
- await copyDirRecursive(path.join(assetsDir, 'agent-context'), path.join(hooksDir, 'agent-context'));
68
+ await copyDirRecursive(repoRoot, path.join(assetsDir, 'agent-context'), path.join(hooksDir, 'agent-context'));
62
69
  // Copy truncation-checker module
63
- await copyDirRecursive(path.join(assetsDir, 'truncation-checker'), path.join(hooksDir, 'truncation-checker'));
70
+ await copyDirRecursive(repoRoot, path.join(assetsDir, 'truncation-checker'), path.join(hooksDir, 'truncation-checker'));
64
71
  // Copy hooks package.json
65
- await fs.copyFile(path.join(assetsDir, 'package.json'), path.join(hooksDir, 'package.json'));
66
- await fs.copyFile(path.join(assetsDir, 'package-lock.json'), path.join(hooksDir, 'package-lock.json'));
72
+ for (const manifest of ['package.json', 'package-lock.json']) {
73
+ const dest = path.join(hooksDir, manifest);
74
+ assertNoSymlinkOnPath(repoRoot, dest);
75
+ await fs.copyFile(path.join(assetsDir, manifest), dest);
76
+ }
77
+ }
78
+ /** Every file `copyHookFiles` writes: its bundled source and its path inside hooks/. */
79
+ export async function listHookInstallFiles() {
80
+ const assetsDir = getAssetsDir();
81
+ const files = HOOK_SCRIPTS.map(hook => ({
82
+ src: path.join(assetsDir, 'scripts', `${hook}.sh`),
83
+ rel: hook,
84
+ }));
85
+ const walk = async (dir, rel) => {
86
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
87
+ const childRel = path.posix.join(rel, entry.name);
88
+ if (entry.isDirectory())
89
+ await walk(path.join(dir, entry.name), childRel);
90
+ else
91
+ files.push({ src: path.join(dir, entry.name), rel: childRel });
92
+ }
93
+ };
94
+ await walk(path.join(assetsDir, 'agent-context'), 'agent-context');
95
+ await walk(path.join(assetsDir, 'truncation-checker'), 'truncation-checker');
96
+ for (const manifest of ['package.json', 'package-lock.json']) {
97
+ files.push({ src: path.join(assetsDir, manifest), rel: manifest });
98
+ }
99
+ return files;
67
100
  }
68
101
  /** Install npm dependencies in the hooks directory */
69
102
  export async function installHookDeps(hooksDir) {
@@ -76,6 +109,8 @@ export async function installHookDeps(hooksDir) {
76
109
  /** Copy LLM rules template if none exists */
77
110
  export async function copyLlmRulesTemplate(repoRoot) {
78
111
  const dest = path.join(repoRoot, 'LLM_RULES.md');
112
+ // existsSync follows symlinks, so a dangling link reads as absent.
113
+ assertNoSymlinkOnPath(repoRoot, dest);
79
114
  if (existsSync(dest))
80
115
  return false;
81
116
  const src = path.join(getAssetsDir(), 'llm-rules-template.md');
@@ -85,16 +120,18 @@ export async function copyLlmRulesTemplate(repoRoot) {
85
120
  // ============================================================================
86
121
  // Helpers
87
122
  // ============================================================================
88
- async function copyDirRecursive(src, dest) {
123
+ async function copyDirRecursive(repoRoot, src, dest) {
124
+ assertNoSymlinkOnPath(repoRoot, dest);
89
125
  await fs.mkdir(dest, { recursive: true });
90
126
  const entries = await fs.readdir(src, { withFileTypes: true });
91
127
  for (const entry of entries) {
92
128
  const srcPath = path.join(src, entry.name);
93
129
  const destPath = path.join(dest, entry.name);
94
130
  if (entry.isDirectory()) {
95
- await copyDirRecursive(srcPath, destPath);
131
+ await copyDirRecursive(repoRoot, srcPath, destPath);
96
132
  }
97
133
  else {
134
+ assertNoSymlinkOnPath(repoRoot, destPath);
98
135
  await fs.copyFile(srcPath, destPath);
99
136
  }
100
137
  }
@@ -17,7 +17,7 @@
17
17
  * redirects `console.*` to stderr so stdout carries only NDJSON.
18
18
  */
19
19
  import chalk from 'chalk';
20
- import inquirer from 'inquirer';
20
+ import { Separator, checkbox, confirm } from '@inquirer/prompts';
21
21
  import * as readline from 'node:readline';
22
22
  import { readFileSync } from 'node:fs';
23
23
  function isSeparator(c) {
@@ -44,6 +44,27 @@ export function loadAnswersFile(path) {
44
44
  // ---------------------------------------------------------------------------
45
45
  // TTY (human) prompter
46
46
  // ---------------------------------------------------------------------------
47
+ /**
48
+ * Await an @inquirer/prompts prompt, keeping the Ctrl+C behaviour the CLI had
49
+ * under inquirer 9: that version answered Ctrl+C by re-sending SIGINT to the
50
+ * process (which terminates it) and never settled the prompt. @inquirer/prompts
51
+ * instead rejects with an ExitPromptError, which would surface as an uncaught
52
+ * error with a stack trace, so translate it back.
53
+ */
54
+ export async function runPrompt(prompt) {
55
+ try {
56
+ return await prompt;
57
+ }
58
+ catch (err) {
59
+ if (err instanceof Error && err.name === 'ExitPromptError') {
60
+ process.kill(process.pid, 'SIGINT');
61
+ console.log('');
62
+ return new Promise(() => { });
63
+ }
64
+ throw err;
65
+ }
66
+ }
67
+ // ---------------------------------------------------------------------------
47
68
  class TtyPrompter {
48
69
  answers;
49
70
  interactive = true;
@@ -56,19 +77,13 @@ class TtyPrompter {
56
77
  async confirm(q) {
57
78
  if (q.id in this.answers)
58
79
  return toBool(this.answers[q.id]);
59
- const { value } = await inquirer.prompt([
60
- { type: 'confirm', name: 'value', message: q.message, default: q.default ?? true },
61
- ]);
62
- return value;
80
+ return runPrompt(confirm({ message: q.message, default: q.default ?? true }));
63
81
  }
64
82
  async multiselect(q) {
65
83
  if (q.id in this.answers)
66
84
  return asArray(this.answers[q.id], q.id);
67
- const choices = q.choices.map((c) => isSeparator(c) ? new inquirer.Separator(c.separator) : { name: c.name, value: c.value, checked: c.checked });
68
- const { value } = await inquirer.prompt([
69
- { type: 'checkbox', name: 'value', message: q.message, choices: choices, pageSize: 20 },
70
- ]);
71
- return value;
85
+ const choices = q.choices.map((c) => isSeparator(c) ? new Separator(c.separator) : { name: c.name, value: c.value, checked: c.checked });
86
+ return runPrompt(checkbox({ message: q.message, choices, pageSize: 20 }));
72
87
  }
73
88
  async action(q) {
74
89
  if (this.answers[q.id] === 'skip')
@@ -0,0 +1,31 @@
1
+ import { lstatSync } from 'fs';
2
+ import { isAbsolute, join, relative, sep } from 'path';
3
+ /**
4
+ * Refuses a write whose target, or any directory between `root` and it, is a
5
+ * symlink. A repository can commit `AGENTS.md -> ~/.zshrc`; following it would
6
+ * make an install command edit a file outside the repository. Mirrors the
7
+ * per-component checks in `skills.ts`. Missing components are fine: the caller
8
+ * creates them, and a component that does not exist cannot be a symlink.
9
+ */
10
+ export function assertNoSymlinkOnPath(root, target) {
11
+ const rel = relative(root, target);
12
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
13
+ throw new Error(`Refusing to write ${target}: it is not inside ${root}.`);
14
+ }
15
+ let current = root;
16
+ for (const component of rel.split(sep)) {
17
+ current = join(current, component);
18
+ let isSymlink;
19
+ try {
20
+ isSymlink = lstatSync(current).isSymbolicLink();
21
+ }
22
+ catch (error) {
23
+ if (error.code === 'ENOENT')
24
+ return;
25
+ throw error;
26
+ }
27
+ if (isSymlink) {
28
+ throw new Error(`Refusing to write ${target}: ${current} is a symlink.`);
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,115 @@
1
+ import { scanForSecrets } from './secrets.js';
2
+ /**
3
+ * File names that conventionally hold credentials. The precompute upload
4
+ * withholds these regardless of content, because a scanner cannot recognise
5
+ * every secret format (a raw key file has no telltale prefix).
6
+ */
7
+ const EXACT_NAMES = new Set([
8
+ '.env',
9
+ 'credentials.json',
10
+ '.npmrc',
11
+ '.pypirc',
12
+ '.netrc',
13
+ ]);
14
+ // Committed templates are how teams document which variables a service needs.
15
+ const ENV_TEMPLATES = new Set(['.env.example', '.env.sample']);
16
+ const SUFFIXES = ['.pem', '.key', '.p12', '.pfx', '.keystore', '.tfvars'];
17
+ const PREFIXES = ['id_rsa', 'id_ed25519', 'secrets.'];
18
+ /** True when a repository-relative path names a conventionally secret-bearing file. */
19
+ export function isSecretBearingPath(path) {
20
+ const segments = path.split('/').filter(segment => segment.length > 0);
21
+ if (segments.length === 0)
22
+ return false;
23
+ for (let index = 0; index + 1 < segments.length; index += 1) {
24
+ if (segments[index] === '.haystack' && segments[index + 1] === 'secrets')
25
+ return true;
26
+ }
27
+ const name = segments[segments.length - 1].toLowerCase();
28
+ if (EXACT_NAMES.has(name))
29
+ return true;
30
+ if (name.startsWith('.env.'))
31
+ return !ENV_TEMPLATES.has(name);
32
+ if (SUFFIXES.some(suffix => name.endsWith(suffix)))
33
+ return true;
34
+ if (PREFIXES.some(prefix => name.startsWith(prefix)))
35
+ return true;
36
+ return name.startsWith('service-account') && name.endsWith('.json');
37
+ }
38
+ const GIT_ESCAPES = {
39
+ a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92,
40
+ };
41
+ /** Reverses git's C-style path quoting (named escapes and octal bytes). */
42
+ function unquoteGitPath(quoted) {
43
+ const bytes = [];
44
+ for (let index = 0; index < quoted.length; index += 1) {
45
+ const character = quoted[index];
46
+ if (character !== '\\') {
47
+ bytes.push(...Buffer.from(character, 'utf8'));
48
+ continue;
49
+ }
50
+ const next = quoted[index + 1];
51
+ const octal = quoted.slice(index + 1, index + 4);
52
+ if (next !== undefined && next in GIT_ESCAPES) {
53
+ bytes.push(GIT_ESCAPES[next]);
54
+ index += 1;
55
+ }
56
+ else if (/^[0-7]{3}$/.test(octal)) {
57
+ bytes.push(Number.parseInt(octal, 8));
58
+ index += 3;
59
+ }
60
+ else {
61
+ throw new Error('Unrecognised escape in a git path.');
62
+ }
63
+ }
64
+ return Buffer.from(bytes).toString('utf8');
65
+ }
66
+ /** Path named by a `diff --git a/P b/P` header produced with --no-renames. */
67
+ export function pathFromDiffHeader(header) {
68
+ const prefix = 'diff --git ';
69
+ if (!header.startsWith(prefix))
70
+ throw new Error('Not a diff header.');
71
+ const rest = header.slice(prefix.length);
72
+ // Without renames both sides name the same path, so the header is symmetric.
73
+ const side = rest.slice(0, (rest.length - 1) / 2);
74
+ const quoted = side.startsWith('"');
75
+ const other = quoted ? `"b/${side.slice(3)}` : `b/${side.slice(2)}`;
76
+ if (rest !== `${side} ${other}` || !side.startsWith(quoted ? '"a/' : 'a/')) {
77
+ throw new Error('Unexpected diff header.');
78
+ }
79
+ return quoted ? unquoteGitPath(side.slice(1, -1)).slice(2) : side.slice(2);
80
+ }
81
+ /**
82
+ * Scans only the lines a `git diff --no-renames` patch adds and returns, per
83
+ * file, the name of the first high-severity secret pattern found. The matched
84
+ * value never leaves this function.
85
+ */
86
+ export function findSecretsInPatch(patch) {
87
+ const hits = new Map();
88
+ let currentPath;
89
+ let inHunk = false;
90
+ let added = [];
91
+ const flush = () => {
92
+ if (currentPath === undefined || added.length === 0)
93
+ return;
94
+ const finding = scanForSecrets(added.join('\n'), currentPath)
95
+ .find(item => item.severity === 'high');
96
+ if (finding && !hits.has(currentPath))
97
+ hits.set(currentPath, finding.pattern);
98
+ };
99
+ for (const line of patch.split('\n')) {
100
+ if (line.startsWith('diff --git ')) {
101
+ flush();
102
+ currentPath = pathFromDiffHeader(line);
103
+ inHunk = false;
104
+ added = [];
105
+ }
106
+ else if (line.startsWith('@@')) {
107
+ inHunk = true;
108
+ }
109
+ else if (inHunk && line.startsWith('+')) {
110
+ added.push(line.slice(1));
111
+ }
112
+ }
113
+ flush();
114
+ return hits;
115
+ }