@haystackeditor/cli 0.15.30 → 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.
- package/README.md +111 -60
- package/dist/assets/hooks/scripts/commit-msg.sh +3 -0
- package/dist/assets/hooks/scripts/post-commit.sh +3 -0
- package/dist/assets/hooks/scripts/pre-commit.sh +11 -6
- package/dist/assets/hooks/scripts/pre-push.sh +3 -0
- package/dist/assets/hooks/scripts/prepare-commit-msg.sh +3 -0
- package/dist/commands/case-batch-contract.js +608 -0
- package/dist/commands/case-batch.js +713 -0
- package/dist/commands/cloud-verifier-identity-census.js +5 -2
- package/dist/commands/combination-search-hook.js +139 -0
- package/dist/commands/design-verify.js +27 -1
- package/dist/commands/dismiss.js +2 -1
- package/dist/commands/hooks.js +66 -7
- package/dist/commands/install-session-hooks.js +131 -44
- package/dist/commands/policy.js +32 -45
- package/dist/commands/precompute-delivery.js +6 -1
- package/dist/commands/scaffold-provisional-universe.js +8 -10
- package/dist/commands/setup.js +32 -10
- package/dist/commands/submit.js +7 -2
- package/dist/commands/telemetry.js +17 -2
- package/dist/commands/triage.js +2 -1
- package/dist/commands/verify-explore.js +1 -0
- package/dist/commands/verify-history.js +152 -0
- package/dist/commands/verify-hosted-mcp.js +3 -12
- package/dist/commands/verify-hosted-reproducibility.js +49 -547
- package/dist/commands/verify-hosted.js +180 -163
- package/dist/commands/verify-precompute.js +57 -10
- package/dist/commands/verify.js +51 -139
- package/dist/index.js +264 -348
- package/dist/lazy.js +8 -0
- package/dist/schema.js +1 -0
- package/dist/tools/detect.js +3 -24
- package/dist/triage/prompts.js +46 -45
- package/dist/triage/runner.js +119 -33
- package/dist/types/verify-history.js +1 -0
- package/dist/types.js +3 -3
- package/dist/utils/auth.js +14 -2
- package/dist/utils/design-verifier-api.js +23 -0
- package/dist/utils/design-verifier-history.js +79 -0
- package/dist/utils/git.js +60 -29
- package/dist/utils/github-api.js +14 -1
- package/dist/utils/haystack-api.js +26 -7
- package/dist/utils/hooks.js +43 -6
- package/dist/utils/prompter.js +25 -10
- package/dist/utils/safe-write.js +31 -0
- package/dist/utils/secret-paths.js +115 -0
- package/dist/utils/secrets.js +0 -1
- package/dist/utils/telemetry.js +34 -14
- package/dist/utils/update-check.js +151 -0
- package/package.json +18 -14
- package/schemas/case-batch.v1.json +184 -0
- package/schemas/cloud-verifier.v1.json +55 -100
- package/dist/commands/ask.d.ts +0 -14
- package/dist/commands/cloud-verifier-behaviors.d.ts +0 -27
- package/dist/commands/cloud-verifier-data-store-census.d.ts +0 -47
- package/dist/commands/cloud-verifier-data-store-drift.d.ts +0 -42
- package/dist/commands/cloud-verifier-identity-census.d.ts +0 -88
- package/dist/commands/cloud-verifier-materialization.d.ts +0 -16
- package/dist/commands/cloud-verifier-pascal-selector-census.d.ts +0 -29
- package/dist/commands/cloud-verifier-python-manifest-selector-census.d.ts +0 -27
- package/dist/commands/cloud-verifier-specialized-operational-census.d.ts +0 -51
- package/dist/commands/cloud-verifier-universe.d.ts +0 -31
- package/dist/commands/config.d.ts +0 -46
- package/dist/commands/design-verify.d.ts +0 -31
- package/dist/commands/dismiss.d.ts +0 -29
- package/dist/commands/hooks.d.ts +0 -13
- package/dist/commands/inbox.d.ts +0 -65
- package/dist/commands/init.d.ts +0 -10
- package/dist/commands/install-session-hooks.d.ts +0 -17
- package/dist/commands/login.d.ts +0 -8
- package/dist/commands/mcp.d.ts +0 -1
- package/dist/commands/policy.d.ts +0 -31
- package/dist/commands/pr-status.d.ts +0 -144
- package/dist/commands/pr.d.ts +0 -40
- package/dist/commands/precompute-delivery-contract.d.ts +0 -68
- package/dist/commands/precompute-delivery.d.ts +0 -20
- package/dist/commands/prepare-universe-review.d.ts +0 -115
- package/dist/commands/production-source-deny-policy.d.ts +0 -15
- package/dist/commands/request-review.d.ts +0 -26
- package/dist/commands/review.d.ts +0 -25
- package/dist/commands/rules.d.ts +0 -4
- package/dist/commands/scaffold-provisional-universe.d.ts +0 -468
- package/dist/commands/schema-cmd.d.ts +0 -2
- package/dist/commands/setup.d.ts +0 -28
- package/dist/commands/skills.d.ts +0 -8
- package/dist/commands/status.d.ts +0 -4
- package/dist/commands/submit.d.ts +0 -30
- package/dist/commands/system-map.d.ts +0 -42
- package/dist/commands/telemetry.d.ts +0 -53
- package/dist/commands/tokens.d.ts +0 -14
- package/dist/commands/triage.d.ts +0 -35
- package/dist/commands/verify-core.d.ts +0 -449
- package/dist/commands/verify-core.js +0 -789
- package/dist/commands/verify-explore.d.ts +0 -14
- package/dist/commands/verify-hosted-mcp.d.ts +0 -12
- package/dist/commands/verify-hosted-reproducibility.d.ts +0 -90
- package/dist/commands/verify-hosted.d.ts +0 -92
- package/dist/commands/verify-mcp.d.ts +0 -8
- package/dist/commands/verify-mcp.js +0 -517
- package/dist/commands/verify-ops.d.ts +0 -158
- package/dist/commands/verify-ops.js +0 -1148
- package/dist/commands/verify-precompute.d.ts +0 -30
- package/dist/commands/verify-reproducibility.d.ts +0 -85
- package/dist/commands/verify-reproducibility.js +0 -494
- package/dist/commands/verify-reseal.d.ts +0 -9
- package/dist/commands/verify-reseal.js +0 -148
- package/dist/commands/verify-sandboxes.d.ts +0 -95
- package/dist/commands/verify-sandboxes.js +0 -352
- package/dist/commands/verify.d.ts +0 -28
- package/dist/commands/webhooks.d.ts +0 -30
- package/dist/index.d.ts +0 -22
- package/dist/schema.d.ts +0 -28
- package/dist/states.d.ts +0 -29
- package/dist/tools/detect.d.ts +0 -50
- package/dist/triage/prompts.d.ts +0 -24
- package/dist/triage/runner.d.ts +0 -34
- package/dist/triage/types.d.ts +0 -42
- package/dist/types.d.ts +0 -1684
- package/dist/utils/action-output.d.ts +0 -24
- package/dist/utils/analysis-api.d.ts +0 -187
- package/dist/utils/auth.d.ts +0 -79
- package/dist/utils/config.d.ts +0 -24
- package/dist/utils/design-verifier-api.d.ts +0 -200
- package/dist/utils/design-verifier-result.d.ts +0 -74
- package/dist/utils/detect.d.ts +0 -43
- package/dist/utils/git.d.ts +0 -135
- package/dist/utils/github-api.d.ts +0 -104
- package/dist/utils/haystack-api.d.ts +0 -37
- package/dist/utils/hooks.d.ts +0 -12
- package/dist/utils/pending-state.d.ts +0 -40
- package/dist/utils/pr-ref.d.ts +0 -27
- package/dist/utils/prompter.d.ts +0 -85
- package/dist/utils/secrets.d.ts +0 -47
- package/dist/utils/telemetry.d.ts +0 -19
- /package/dist/commands/{precompute-delivery-worker.d.ts → combination-search-hook-contract.js} +0 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const outcomes = ['healthy', 'degraded', 'invalid', 'failed', 'unknown'];
|
|
2
|
+
export function isDesignVerifierHistoryReport(value, expected) {
|
|
3
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
4
|
+
return false;
|
|
5
|
+
const report = value;
|
|
6
|
+
if (report.version !== 'dv-cli-history-v1' || report.owner !== expected.owner ||
|
|
7
|
+
report.repo !== expected.repo || report.limit !== expected.limit ||
|
|
8
|
+
!Number.isSafeInteger(report.retentionDays) || report.retentionDays <= 0 ||
|
|
9
|
+
!Array.isArray(report.runs) || report.runs.length > report.limit || !report.summary)
|
|
10
|
+
return false;
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
const counts = { healthy: 0, degraded: 0, invalid: 0, failed: 0, unknown: 0 };
|
|
13
|
+
const blockers = new Map();
|
|
14
|
+
for (const run of report.runs) {
|
|
15
|
+
if (!run || !Number.isSafeInteger(run.pr) || run.pr < 1 || typeof run.runId !== 'string')
|
|
16
|
+
return false;
|
|
17
|
+
const prefix = `${expected.owner}--${expected.repo}--${run.pr}--`;
|
|
18
|
+
if (!run.runId.startsWith(prefix) ||
|
|
19
|
+
!/^[0-9a-f]{12}(?:--attempt-[1-9]\d*)?$/.test(run.runId.slice(prefix.length)) ||
|
|
20
|
+
seen.has(run.runId) || !outcomes.includes(run.outcome) ||
|
|
21
|
+
!['succeeded', 'failed', 'failed-stale'].includes(run.status) ||
|
|
22
|
+
(run.outcome === 'failed') !== (run.status !== 'succeeded') ||
|
|
23
|
+
typeof run.createdAt !== 'string' || !Number.isFinite(Date.parse(run.createdAt)) ||
|
|
24
|
+
(run.finishedAt !== null && (typeof run.finishedAt !== 'string' || !Number.isFinite(Date.parse(run.finishedAt)))) ||
|
|
25
|
+
(run.baseSha !== null && typeof run.baseSha !== 'string') ||
|
|
26
|
+
(run.headSha !== null && typeof run.headSha !== 'string') ||
|
|
27
|
+
(run.admitted !== null && (!Number.isSafeInteger(run.admitted) || run.admitted < 0)) ||
|
|
28
|
+
!Array.isArray(run.losses) || run.losses.some(loss => !loss || typeof loss.code !== 'string' || !loss.code || typeof loss.owner !== 'string' ||
|
|
29
|
+
!loss.owner || !['degraded', 'invalid'].includes(loss.impact) ||
|
|
30
|
+
!Number.isSafeInteger(loss.count) || loss.count < 1))
|
|
31
|
+
return false;
|
|
32
|
+
if (['healthy', 'degraded'].includes(run.outcome) && (run.admitted === null || run.admitted === 0))
|
|
33
|
+
return false;
|
|
34
|
+
seen.add(run.runId);
|
|
35
|
+
counts[run.outcome]++;
|
|
36
|
+
for (const code of new Set(run.losses.map(loss => loss.code))) {
|
|
37
|
+
blockers.set(code, (blockers.get(code) || 0) + 1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const summary = report.summary;
|
|
41
|
+
const usableRate = report.runs.length ? (counts.healthy + counts.degraded) / report.runs.length : null;
|
|
42
|
+
if (summary.total !== report.runs.length || summary.usableRate !== usableRate ||
|
|
43
|
+
outcomes.some(outcome => summary[outcome] !== counts[outcome]) ||
|
|
44
|
+
!Array.isArray(summary.topBlockers) || summary.topBlockers.length !== blockers.size)
|
|
45
|
+
return false;
|
|
46
|
+
const seenBlockers = new Set();
|
|
47
|
+
return summary.topBlockers.every(blocker => {
|
|
48
|
+
if (!blocker || seenBlockers.has(blocker.code) || blockers.get(blocker.code) !== blocker.affectedRuns)
|
|
49
|
+
return false;
|
|
50
|
+
seenBlockers.add(blocker.code);
|
|
51
|
+
return true;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function terminalSafe(value) {
|
|
55
|
+
return Array.from(value).filter(character => {
|
|
56
|
+
const point = character.codePointAt(0);
|
|
57
|
+
return point >= 32 && !(point >= 127 && point <= 159);
|
|
58
|
+
}).join('');
|
|
59
|
+
}
|
|
60
|
+
export function formatDesignVerifierHistory(report) {
|
|
61
|
+
const { summary } = report;
|
|
62
|
+
const lines = [`Execution history for ${terminalSafe(report.owner)}/${terminalSafe(report.repo)}`,
|
|
63
|
+
`Latest ${summary.total} recorded terminal attempts within ${report.retentionDays} days (limit ${report.limit}).`];
|
|
64
|
+
if (summary.total === 0) {
|
|
65
|
+
lines.push('No recorded attempts in this window. History starts when recording is deployed.');
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
68
|
+
lines.push(`Usable evidence: ${Math.round(summary.usableRate * 100)}% of attempts (${summary.healthy + summary.degraded}/${summary.total}).`, `Fully healthy: ${summary.healthy}; partially degraded: ${summary.degraded}; invalid: ${summary.invalid}; failed: ${summary.failed}; unknown: ${summary.unknown}.`, 'Usable includes partial evidence from degraded attempts. This measures execution reliability, not bug-detection accuracy.', '');
|
|
69
|
+
for (const run of report.runs) {
|
|
70
|
+
lines.push(`${run.finishedAt || run.createdAt} PR #${run.pr} ${run.outcome} ${run.admitted ?? '?'} admitted cases`, ` ${run.runId}`);
|
|
71
|
+
}
|
|
72
|
+
if (summary.topBlockers.length) {
|
|
73
|
+
lines.push('', 'Most frequent blockers (attempts affected):');
|
|
74
|
+
for (const blocker of summary.topBlockers.slice(0, 5)) {
|
|
75
|
+
lines.push(` ${terminalSafe(blocker.code)}: ${blocker.affectedRuns}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|
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
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
//
|
|
89
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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();
|
package/dist/utils/github-api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
}
|
package/dist/utils/hooks.js
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
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
|
}
|
package/dist/utils/prompter.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
68
|
-
|
|
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
|
+
}
|