@haystackeditor/cli 0.13.2 → 0.13.4

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.
@@ -85,6 +85,23 @@ Use `--auto-fix` only for issues like:
85
85
  haystack submit --auto-fix
86
86
  ```
87
87
 
88
+ Repos that want auto-fix on by default for every `haystack submit` can opt
89
+ in via `.haystack.json`:
90
+
91
+ ```json
92
+ {
93
+ "preferences": {
94
+ "auto_fix": true
95
+ }
96
+ }
97
+ ```
98
+
99
+ When set, `haystack submit` applies the `haystack:auto-fix` label without
100
+ needing the explicit flag. Auto-fix is still alpha — use the repo-level
101
+ opt-in only when the team is comfortable with the sandbox fixer running on
102
+ mechanical findings. `haystack submit --no-auto-fix` overrides the repo
103
+ default for a single submission.
104
+
88
105
  ### Review Mode
89
106
 
90
107
  > **Do NOT use `--review` unless the user explicitly asks for human review.**
@@ -21,5 +21,17 @@ export declare function getAutoMergeStatus(): Promise<void>;
21
21
  export declare function enableAutoMerge(): Promise<void>;
22
22
  export declare function disableAutoMerge(): Promise<void>;
23
23
  export declare function handleAutoMerge(action?: string): Promise<void>;
24
+ /**
25
+ * Check if auto-fix is enabled for the current repo.
26
+ * Returns false if .haystack.json is missing or on error.
27
+ *
28
+ * Mirrors isAutoMergeEnabled() — direct file read so missing config returns
29
+ * false instead of process.exit(1).
30
+ */
31
+ export declare function isAutoFixEnabled(): Promise<boolean>;
32
+ export declare function getAutoFixStatus(): Promise<void>;
33
+ export declare function enableAutoFix(): Promise<void>;
34
+ export declare function disableAutoFix(): Promise<void>;
35
+ export declare function handleAutoFix(action?: string): Promise<void>;
24
36
  export declare function handleWaitForReviewers(action?: string, reviewers?: string[]): Promise<void>;
25
37
  export {};
@@ -11,6 +11,10 @@ import { execSync } from 'child_process';
11
11
  import { resolveAuthContext } from '../utils/auth.js';
12
12
  import { AI_REVIEWER_SOURCES, AI_REVIEWER_DISPLAY_NAMES, AI_REVIEWER_BOT_USERNAMES } from '../types.js';
13
13
  const API_BASE = 'https://haystackeditor.com/api/preferences';
14
+ const REPO_PREFERENCE_DEFAULTS = {
15
+ auto_merge: false,
16
+ auto_fix: false,
17
+ };
14
18
  const AGENTIC_TOOL_LABELS = {
15
19
  'opencode': 'OpenCode (Haystack billing)',
16
20
  'claude-code': 'Claude Code (your Claude Max subscription)',
@@ -49,8 +53,7 @@ function saveHaystackConfig(config, configPath) {
49
53
  }
50
54
  function getPreference(key) {
51
55
  const { config } = loadHaystackConfig();
52
- const defaults = { auto_merge: false };
53
- return config.preferences?.[key] ?? defaults[key];
56
+ return config.preferences?.[key] ?? REPO_PREFERENCE_DEFAULTS[key];
54
57
  }
55
58
  function setPreference(key, value) {
56
59
  const { config, path } = loadHaystackConfig();
@@ -165,6 +168,19 @@ export async function handleAgenticTool(tool) {
165
168
  // ============================================================================
166
169
  // Auto-merge (repo-level via .haystack.json)
167
170
  // ============================================================================
171
+ /**
172
+ * Surface unexpected errors when reading repo-level preferences while keeping
173
+ * the silent fallback for the expected "no .haystack.json" case. ENOENT means
174
+ * the user simply hasn't opted in; everything else (parse errors, permission
175
+ * denied, not-a-git-repo) is a config problem the user should see — silently
176
+ * defaulting to disabled would otherwise hide the misconfiguration.
177
+ */
178
+ function logPreferenceLookupFailure(preference, err) {
179
+ if (err?.code === 'ENOENT')
180
+ return;
181
+ const message = err instanceof Error ? err.message : String(err);
182
+ console.error(chalk.dim(`[haystack] Could not resolve ${preference} preference (${message}); defaulting to disabled.`));
183
+ }
168
184
  /**
169
185
  * Check if auto-merge is enabled for the current repo.
170
186
  * Returns false if .haystack.json is missing or on error.
@@ -180,7 +196,8 @@ export async function isAutoMergeEnabled() {
180
196
  const config = JSON.parse(raw);
181
197
  return config.preferences?.auto_merge ?? false;
182
198
  }
183
- catch {
199
+ catch (err) {
200
+ logPreferenceLookupFailure('auto_merge', err);
184
201
  return false;
185
202
  }
186
203
  }
@@ -235,6 +252,82 @@ export async function handleAutoMerge(action) {
235
252
  }
236
253
  }
237
254
  // ============================================================================
255
+ // Auto-fix (repo-level via .haystack.json) — alpha
256
+ // ============================================================================
257
+ /**
258
+ * Check if auto-fix is enabled for the current repo.
259
+ * Returns false if .haystack.json is missing or on error.
260
+ *
261
+ * Mirrors isAutoMergeEnabled() — direct file read so missing config returns
262
+ * false instead of process.exit(1).
263
+ */
264
+ export async function isAutoFixEnabled() {
265
+ try {
266
+ const root = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
267
+ const raw = readFileSync(resolve(root, '.haystack.json'), 'utf-8');
268
+ const config = JSON.parse(raw);
269
+ return config.preferences?.auto_fix ?? false;
270
+ }
271
+ catch (err) {
272
+ logPreferenceLookupFailure('auto_fix', err);
273
+ return false;
274
+ }
275
+ }
276
+ export async function getAutoFixStatus() {
277
+ const enabled = getPreference('auto_fix');
278
+ console.log(chalk.bold('\nAuto-fix (alpha):'), enabled
279
+ ? chalk.green('enabled')
280
+ : chalk.yellow('disabled'));
281
+ console.log();
282
+ if (enabled) {
283
+ console.log(chalk.dim('PRs submitted via `haystack submit` will be labeled'));
284
+ console.log(chalk.dim('haystack:auto-fix. Analysis will dispatch the alpha'));
285
+ console.log(chalk.dim('sandbox fixer for straightforward mechanical issues.'));
286
+ }
287
+ else {
288
+ console.log(chalk.dim('Auto-fix is disabled. Issues surface in the Feed for review.'));
289
+ console.log(chalk.dim('Enable with: haystack config auto-fix on'));
290
+ }
291
+ console.log(chalk.yellow('⚠ Auto-fix is alpha. Expect rough edges.'));
292
+ console.log(chalk.dim('(Stored in .haystack.json — applies to all repo contributors)'));
293
+ console.log();
294
+ }
295
+ export async function enableAutoFix() {
296
+ setPreference('auto_fix', true);
297
+ console.log(chalk.green('\nAuto-fix (alpha) enabled.\n'));
298
+ console.log(chalk.dim('PRs submitted via `haystack submit` will be labeled'));
299
+ console.log(chalk.dim('haystack:auto-fix. The sandbox fixer will attempt'));
300
+ console.log(chalk.dim('straightforward mechanical fixes before issues hit the Feed.'));
301
+ console.log(chalk.yellow('⚠ Auto-fix is alpha. Expect rough edges.'));
302
+ console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
303
+ }
304
+ export async function disableAutoFix() {
305
+ setPreference('auto_fix', false);
306
+ console.log(chalk.yellow('\nAuto-fix disabled.\n'));
307
+ console.log(chalk.dim('Issues will surface in the Feed for human review.'));
308
+ console.log(chalk.dim('Re-enable with: haystack config auto-fix on'));
309
+ console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
310
+ }
311
+ export async function handleAutoFix(action) {
312
+ switch (action?.toLowerCase()) {
313
+ case 'on':
314
+ case 'enable':
315
+ case 'true':
316
+ return enableAutoFix();
317
+ case 'off':
318
+ case 'disable':
319
+ case 'false':
320
+ return disableAutoFix();
321
+ case 'status':
322
+ case undefined:
323
+ return getAutoFixStatus();
324
+ default:
325
+ console.error(chalk.red(`\nUnknown action: ${action}`));
326
+ console.log('\nUsage: haystack config auto-fix [on|off|status]\n');
327
+ process.exit(1);
328
+ }
329
+ }
330
+ // ============================================================================
238
331
  // Wait-for-reviewers (repo-level via .haystack.json)
239
332
  // ============================================================================
240
333
  /** Reverse map: bot username → friendly source name */
@@ -367,11 +367,7 @@ export async function submitCommand(options) {
367
367
  process.exit(1);
368
368
  }
369
369
  try {
370
- pushBranch('origin', currentBranch, {
371
- token: githubToken,
372
- owner: remoteInfo.owner,
373
- repo: remoteInfo.repo,
374
- });
370
+ pushBranch('origin', currentBranch, { token: githubToken });
375
371
  console.log(chalk.green('✓ Branch pushed'));
376
372
  }
377
373
  catch (err) {
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ import { Command } from 'commander';
23
23
  import { statusCommand } from './commands/status.js';
24
24
  import { initCommand } from './commands/init.js';
25
25
  import { authListCommand, authUseCommand, loginCommand, logoutCommand, whoamiCommand, } from './commands/login.js';
26
- import { handleAgenticTool, handleAutoMerge, handleWaitForReviewers, isAutoMergeEnabled } from './commands/config.js';
26
+ import { handleAgenticTool, handleAutoMerge, handleAutoFix, handleWaitForReviewers, isAutoMergeEnabled, isAutoFixEnabled } from './commands/config.js';
27
27
  import { installSkills, listSkills } from './commands/skills.js';
28
28
  import { hooksInstall, hooksStatus, hooksUpdate } from './commands/hooks.js';
29
29
  import { submitCommand } from './commands/submit.js';
@@ -38,7 +38,7 @@ const program = new Command();
38
38
  program
39
39
  .name('haystack')
40
40
  .description('Haystack CLI — automated PR review, triage, and merge queue')
41
- .version('0.13.2');
41
+ .version('0.13.4');
42
42
  program
43
43
  .command('init')
44
44
  .description('Create .haystack.json configuration')
@@ -110,7 +110,7 @@ program
110
110
  .option('--draft', 'Create as draft PR')
111
111
  .option('--review [reviewer]', 'Request human review (BLOCKS auto-merge — only use when explicitly asked)')
112
112
  .option('--force', 'Skip pre-PR triage checks')
113
- .option('--auto-fix', 'Alpha auto-fix for straightforward mechanical issues (discouraged)')
113
+ .option('--auto-fix', 'Alpha auto-fix for straightforward mechanical issues (default: from .haystack.json preferences.auto_fix; --no-auto-fix to disable)')
114
114
  .option('--auto-merge', 'Apply auto-merge label (default: from .haystack.json, --no-auto-merge to disable)')
115
115
  .option('--no-wait', 'Skip waiting for analysis results')
116
116
  .option('--max-turns <n>', 'Max agentic turns per triage checker (overrides .haystack.json triage.maxTurns)', (v) => parseInt(v, 10))
@@ -189,6 +189,12 @@ Examples:
189
189
  if (options.autoMerge === undefined) {
190
190
  options.autoMerge = await isAutoMergeEnabled();
191
191
  }
192
+ // Resolve --auto-fix default from .haystack.json (preferences.auto_fix).
193
+ // Auto-fix remains alpha — the explicit --auto-fix flag still surfaces
194
+ // discouragement warnings; opting in via repo config is the supported path.
195
+ if (options.autoFix === undefined) {
196
+ options.autoFix = await isAutoFixEnabled();
197
+ }
192
198
  return submitCommand(options);
193
199
  });
194
200
  program
@@ -373,6 +379,27 @@ Examples:
373
379
  haystack config auto-merge off # Disable auto-merge
374
380
  `)
375
381
  .action(handleAutoMerge);
382
+ config
383
+ .command('auto-fix [action]')
384
+ .description('(Alpha) Auto-fix mechanical issues on PRs submitted via haystack submit (on|off|status)')
385
+ .addHelpText('after', `
386
+ Actions:
387
+ on, enable Enable auto-fix for mechanical issues
388
+ off, disable Disable auto-fix
389
+ status Show current status (default)
390
+
391
+ ⚠ Auto-fix is alpha. When enabled, PRs submitted via \`haystack submit\`
392
+ are labeled haystack:auto-fix. The Haystack analysis pipeline then
393
+ dispatches the sandbox fixer for issues classified as straightforward
394
+ and mechanical. Judgment calls, policy violations, and weak coverage
395
+ findings still surface in the Feed for human review.
396
+
397
+ Examples:
398
+ haystack config auto-fix # Show current status
399
+ haystack config auto-fix on # Enable auto-fix (alpha)
400
+ haystack config auto-fix off # Disable auto-fix
401
+ `)
402
+ .action(handleAutoFix);
376
403
  config
377
404
  .command('wait-for-reviewers [action] [reviewers...]')
378
405
  .description('Configure which AI reviewers the merge queue waits for')
@@ -54,17 +54,16 @@ export declare function remoteBranchExists(remoteName: string, branchName: strin
54
54
  export declare function checkoutBranch(branchName: string): void;
55
55
  export interface PushAuth {
56
56
  token: string;
57
- owner: string;
58
- repo: string;
59
57
  }
60
58
  /**
61
59
  * Push current branch to remote with upstream tracking.
62
60
  *
63
61
  * When `auth` is provided, the push is authenticated with the given GitHub
64
- * token via GIT_ASKPASS against `https://github.com/{owner}/{repo}.git`
65
- * independent of the user's ambient git credential helper. Upstream tracking
66
- * is then set against the original `remoteName` so the token URL never lands
67
- * in `.git/config`.
62
+ * token via GIT_ASKPASS against an explicit HTTPS URL, independent of the
63
+ * user's ambient git credential helper and independent of whatever
64
+ * `remote.<name>.url` / `pushurl` they have configured. Remote-tracking
65
+ * refs are then seeded locally so upstream tracking (`-u`-style) works on
66
+ * first push. `.git/config` is never touched.
68
67
  *
69
68
  * Without `auth`, falls back to `git push -u <remote>` using ambient creds.
70
69
  */
package/dist/utils/git.js CHANGED
@@ -166,16 +166,17 @@ export function checkoutBranch(branchName) {
166
166
  * Push current branch to remote with upstream tracking.
167
167
  *
168
168
  * When `auth` is provided, the push is authenticated with the given GitHub
169
- * token via GIT_ASKPASS against `https://github.com/{owner}/{repo}.git`
170
- * independent of the user's ambient git credential helper. Upstream tracking
171
- * is then set against the original `remoteName` so the token URL never lands
172
- * in `.git/config`.
169
+ * token via GIT_ASKPASS against an explicit HTTPS URL, independent of the
170
+ * user's ambient git credential helper and independent of whatever
171
+ * `remote.<name>.url` / `pushurl` they have configured. Remote-tracking
172
+ * refs are then seeded locally so upstream tracking (`-u`-style) works on
173
+ * first push. `.git/config` is never touched.
173
174
  *
174
175
  * Without `auth`, falls back to `git push -u <remote>` using ambient creds.
175
176
  */
176
177
  export function pushBranch(remoteName, branchName, auth) {
177
178
  if (auth) {
178
- pushBranchWithToken(remoteName, branchName, auth);
179
+ pushBranchWithToken(remoteName, branchName, auth.token);
179
180
  return;
180
181
  }
181
182
  try {
@@ -188,28 +189,37 @@ export function pushBranch(remoteName, branchName, auth) {
188
189
  throw translatePushError(err);
189
190
  }
190
191
  }
191
- function pushBranchWithToken(remoteName, branchName, auth) {
192
+ function pushBranchWithToken(remoteName, branchName, token) {
193
+ // Derive owner/repo from the remote itself so the push URL always matches
194
+ // the tracking ref we'll seed below. No way for caller and remote identity
195
+ // to silently disagree.
196
+ const { owner, repo } = parseRemoteUrl(remoteName);
197
+ const pushUrl = `https://github.com/${owner}/${repo}.git`;
198
+ const repoSlug = `${owner}/${repo}`;
192
199
  const askpassPath = path.join(os.tmpdir(), `haystack-askpass-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.sh`);
193
200
  // Script reads token from env at call time — keeps it out of the script contents and argv.
194
201
  const script = `#!/bin/sh\ncase "$1" in\n Username*) printf '%s' "x-access-token" ;;\n *) printf '%s' "$HAYSTACK_GIT_TOKEN" ;;\nesac\n`;
195
202
  fs.writeFileSync(askpassPath, script, { mode: 0o700 });
203
+ const authedEnv = {
204
+ ...process.env,
205
+ GIT_ASKPASS: askpassPath,
206
+ GIT_TERMINAL_PROMPT: '0',
207
+ HAYSTACK_GIT_TOKEN: token,
208
+ };
196
209
  try {
197
- const pushUrl = `https://github.com/${auth.owner}/${auth.repo}.git`;
198
- execSync(`git push "${pushUrl}" "HEAD:refs/heads/${branchName}"`, {
199
- encoding: 'utf-8',
200
- stdio: ['pipe', 'pipe', 'pipe'],
201
- env: {
202
- ...process.env,
203
- GIT_ASKPASS: askpassPath,
204
- GIT_TERMINAL_PROMPT: '0',
205
- HAYSTACK_GIT_TOKEN: auth.token,
206
- },
207
- });
210
+ // Push to an explicit HTTPS URL with GIT_ASKPASS, bypassing ambient
211
+ // credential helpers entirely. Using a raw URL (not the named remote)
212
+ // means the user's configured url/pushurl/credential-helper settings
213
+ // are untouched — sidesteps precedence and multi-value edge cases
214
+ // around `remote.<name>.url` vs `remote.<name>.pushurl`.
215
+ execSync(`git -c credential.helper= push "${pushUrl}" "HEAD:refs/heads/${branchName}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: authedEnv });
216
+ // Pushing to a raw URL doesn't update refs/remotes/<name>/<branch>. Seed
217
+ // it from the server's authoritative state with a targeted fetch — this
218
+ // reflects any server-side rewrite or hook side effects, not a fabricated
219
+ // pre-push SHA. Then set upstream tracking to the named remote.
208
220
  try {
209
- execSync(`git branch --set-upstream-to=${remoteName}/${branchName} "${branchName}"`, {
210
- encoding: 'utf-8',
211
- stdio: ['pipe', 'pipe', 'pipe'],
212
- });
221
+ execSync(`git -c credential.helper= fetch "${pushUrl}" "refs/heads/${branchName}:refs/remotes/${remoteName}/${branchName}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: authedEnv });
222
+ execSync(`git branch --set-upstream-to=${remoteName}/${branchName} "${branchName}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
213
223
  }
214
224
  catch (err) {
215
225
  // Non-fatal: push succeeded; upstream tracking is a convenience.
@@ -218,7 +228,7 @@ function pushBranchWithToken(remoteName, branchName, auth) {
218
228
  }
219
229
  }
220
230
  catch (err) {
221
- throw translatePushError(err, auth);
231
+ throw translatePushError(err, repoSlug);
222
232
  }
223
233
  finally {
224
234
  try {
@@ -231,11 +241,11 @@ function pushBranchWithToken(remoteName, branchName, auth) {
231
241
  }
232
242
  }
233
243
  }
234
- function translatePushError(err, auth) {
244
+ function translatePushError(err, repoSlug) {
235
245
  const message = err instanceof Error ? err.message : String(err);
236
246
  if (message.includes('permission denied') || message.includes('403')) {
237
- if (auth) {
238
- return new Error(`Permission denied pushing to ${auth.owner}/${auth.repo}. The Haystack account token lacks write access to this repo — run \`haystack auth list\` and pick an account that's a collaborator.`);
247
+ if (repoSlug) {
248
+ return new Error(`Permission denied pushing to ${repoSlug}. The Haystack account token lacks write access to this repo — run \`haystack auth list\` and pick an account that's a collaborator.`);
239
249
  }
240
250
  return new Error('Permission denied. Check your GitHub credentials.');
241
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {