@haystackeditor/cli 0.13.0 → 0.13.2

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.
@@ -22,7 +22,7 @@
22
22
  import chalk from 'chalk';
23
23
  import { checkAnalysisReady, fetchRatingSynthesis, fetchAutoFixManifest } from '../utils/analysis-api.js';
24
24
  import { parseRemoteUrl } from '../utils/git.js';
25
- import { loadToken } from './login.js';
25
+ import { loadToken } from '../utils/auth.js';
26
26
  import { loadPendingSubmit, updatePendingSubmit, clearPendingSubmit } from '../utils/pending-state.js';
27
27
  // ============================================================================
28
28
  // PR identifier parsing
@@ -248,9 +248,18 @@ export async function triageCommand(identifier, options) {
248
248
  const pr = resolvePR(identifier, options);
249
249
  if (!pr)
250
250
  return;
251
- // Load GitHub token for private repo auth
252
- const token = await loadToken() || undefined;
253
251
  const isQuiet = options.hook || options.json;
252
+ // Load GitHub token for private repo auth
253
+ let token;
254
+ try {
255
+ token = await loadToken({ owner: pr.owner, repo: pr.repo }) || undefined;
256
+ }
257
+ catch (err) {
258
+ if (!isQuiet) {
259
+ console.log(chalk.yellow(`\n⚠ Could not load Haystack credentials: ${err.message}`));
260
+ console.log(chalk.dim(' Continuing without GitHub auth. Private repos may require `haystack login`.\n'));
261
+ }
262
+ }
254
263
  if (!isQuiet) {
255
264
  console.log(chalk.dim(`\nFetching analysis for ${pr.owner}/${pr.repo}#${pr.prNumber}...`));
256
265
  }
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  import { Command } from 'commander';
23
23
  import { statusCommand } from './commands/status.js';
24
24
  import { initCommand } from './commands/init.js';
25
- import { loginCommand, logoutCommand } from './commands/login.js';
25
+ import { authListCommand, authUseCommand, loginCommand, logoutCommand, whoamiCommand, } from './commands/login.js';
26
26
  import { handleAgenticTool, handleAutoMerge, handleWaitForReviewers, isAutoMergeEnabled } from './commands/config.js';
27
27
  import { installSkills, listSkills } from './commands/skills.js';
28
28
  import { hooksInstall, hooksStatus, hooksUpdate } from './commands/hooks.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.0');
41
+ .version('0.13.2');
42
42
  program
43
43
  .command('init')
44
44
  .description('Create .haystack.json configuration')
@@ -78,15 +78,31 @@ program
78
78
  .action(statusCommand);
79
79
  program
80
80
  .command('login')
81
- .description('Authenticate with GitHub')
81
+ .description('Add or refresh a saved GitHub account')
82
82
  .action(loginCommand);
83
83
  program
84
- .command('logout')
85
- .description('Remove stored credentials')
84
+ .command('logout [login]')
85
+ .description('Remove a saved GitHub account (defaults to the active account)')
86
86
  .action(logoutCommand);
87
+ program
88
+ .command('whoami')
89
+ .description('Show the active Haystack GitHub account')
90
+ .action(whoamiCommand);
91
+ const authProgram = program
92
+ .command('auth')
93
+ .description('Manage saved Haystack GitHub accounts');
94
+ authProgram
95
+ .command('list')
96
+ .description('List saved Haystack GitHub accounts')
97
+ .action(authListCommand);
98
+ authProgram
99
+ .command('use <login>')
100
+ .description('Set the active Haystack GitHub account')
101
+ .action(authUseCommand);
87
102
  program
88
103
  .command('submit')
89
104
  .description('Create a PR from current changes')
105
+ .option('--account <login>', 'Saved Haystack account login to use for GitHub API calls')
90
106
  .option('--title <title>', 'PR title (default: last commit message)')
91
107
  .option('--body <body>', 'PR body (default: commit messages since base)')
92
108
  .option('--body-file <path>', 'Read PR body from file (use "-" for stdin)')
@@ -164,6 +180,7 @@ Examples:
164
180
  haystack submit --draft # Create as draft PR
165
181
  haystack submit --review # ⚠ Blocks auto-merge, needs human approval
166
182
  haystack submit --review octocat # ⚠ Blocks auto-merge, requests review from octocat
183
+ haystack submit --account octocat # Use a specific saved Haystack account for this submit
167
184
  haystack submit --max-turns 12 # Raise triage turn cap to 12 per checker
168
185
  haystack submit --triage-timeout 300 # Raise triage wall-clock to 5 minutes
169
186
  `)
@@ -0,0 +1,50 @@
1
+ export interface StoredAccount {
2
+ github_token: string;
3
+ created_at: string;
4
+ }
5
+ export interface CredentialsStore {
6
+ version: 2;
7
+ activeLogin: string | null;
8
+ accounts: Record<string, StoredAccount>;
9
+ repoDefaults: Record<string, string>;
10
+ }
11
+ export interface GitHubUser {
12
+ login: string;
13
+ id: number;
14
+ }
15
+ export type VerifyGitHubTokenResult = {
16
+ status: 'ok';
17
+ user: GitHubUser;
18
+ } | {
19
+ status: 'invalid';
20
+ } | {
21
+ status: 'error';
22
+ message: string;
23
+ };
24
+ export interface ResolveAuthOptions {
25
+ preferredLogin?: string;
26
+ owner?: string;
27
+ repo?: string;
28
+ }
29
+ export interface ResolvedAuthContext {
30
+ login: string;
31
+ token: string;
32
+ source: 'preferred' | 'repo-default' | 'active';
33
+ }
34
+ export interface AccountSummary {
35
+ login: string;
36
+ createdAt: string;
37
+ isActive: boolean;
38
+ repoDefaultCount: number;
39
+ }
40
+ export declare function verifyGitHubToken(token: string): Promise<VerifyGitHubTokenResult>;
41
+ export declare function loadCredentialsStore(): Promise<CredentialsStore>;
42
+ export declare function saveAuthenticatedToken(token: string): Promise<GitHubUser>;
43
+ export declare function listStoredAccounts(): Promise<AccountSummary[]>;
44
+ export declare function setActiveAccount(login: string): Promise<void>;
45
+ export declare function removeAccount(login?: string): Promise<string | null>;
46
+ export declare function getActiveLogin(): Promise<string | null>;
47
+ export declare function getRepoDefaultAccount(owner: string, repo: string): Promise<string | null>;
48
+ export declare function setRepoDefaultAccount(owner: string, repo: string, login: string): Promise<void>;
49
+ export declare function resolveAuthContext(options?: ResolveAuthOptions): Promise<ResolvedAuthContext>;
50
+ export declare function loadToken(options?: ResolveAuthOptions): Promise<string | null>;
@@ -0,0 +1,386 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ const CONFIG_DIR = path.join(os.homedir(), '.haystack');
5
+ const TOKEN_FILE = path.join(CONFIG_DIR, 'credentials.json');
6
+ const TOKEN_FILE_DISPLAY = '~/.haystack/credentials.json';
7
+ const GITHUB_LOGIN_PATTERN = /^[A-Za-z\d](?:[A-Za-z\d]|-(?=[A-Za-z\d])){0,38}$/;
8
+ function emptyStore() {
9
+ return {
10
+ version: 2,
11
+ activeLogin: null,
12
+ accounts: {},
13
+ repoDefaults: {},
14
+ };
15
+ }
16
+ function isLegacyCredentials(value) {
17
+ if (!value || typeof value !== 'object')
18
+ return false;
19
+ const candidate = value;
20
+ const keys = Object.keys(candidate);
21
+ return keys.length === 2
22
+ && keys.includes('github_token')
23
+ && keys.includes('created_at')
24
+ && typeof candidate.github_token === 'string'
25
+ && typeof candidate.created_at === 'string';
26
+ }
27
+ function hasAmbiguousLegacyCredentialsShape(value) {
28
+ if (!value || typeof value !== 'object')
29
+ return false;
30
+ const candidate = value;
31
+ const hasLegacyFields = 'github_token' in candidate || 'created_at' in candidate;
32
+ const hasV2Fields = 'version' in candidate
33
+ || 'accounts' in candidate
34
+ || 'activeLogin' in candidate
35
+ || 'repoDefaults' in candidate;
36
+ return hasLegacyFields && hasV2Fields;
37
+ }
38
+ function isStoredCredentialsShape(value) {
39
+ if (!value || typeof value !== 'object')
40
+ return false;
41
+ const candidate = value;
42
+ return 'version' in candidate
43
+ || 'accounts' in candidate
44
+ || 'activeLogin' in candidate
45
+ || 'repoDefaults' in candidate;
46
+ }
47
+ function normalizeGitHubLogin(value) {
48
+ if (typeof value !== 'string')
49
+ return null;
50
+ const normalized = value.trim();
51
+ if (!normalized || !GITHUB_LOGIN_PATTERN.test(normalized)) {
52
+ return null;
53
+ }
54
+ return normalized;
55
+ }
56
+ function findStoredLogin(accounts, login) {
57
+ const normalized = normalizeGitHubLogin(login);
58
+ if (!normalized)
59
+ return null;
60
+ const lookupKey = normalized.toLowerCase();
61
+ for (const storedLogin of Object.keys(accounts)) {
62
+ if (storedLogin.toLowerCase() === lookupKey) {
63
+ return storedLogin;
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+ async function readGitHubErrorMessage(response) {
69
+ const body = await response.text().catch(() => '');
70
+ if (!body)
71
+ return '';
72
+ try {
73
+ const parsed = JSON.parse(body);
74
+ return typeof parsed.message === 'string' ? parsed.message : body;
75
+ }
76
+ catch {
77
+ return body;
78
+ }
79
+ }
80
+ function isInvalidTokenForbidden(response, message) {
81
+ const ssoHeader = response.headers.get('x-github-sso');
82
+ if (ssoHeader && !ssoHeader.startsWith('partial-results')) {
83
+ return true;
84
+ }
85
+ const normalizedMessage = message.toLowerCase();
86
+ return normalizedMessage.includes('resource protected by organization saml enforcement')
87
+ || normalizedMessage.includes('single sign-on')
88
+ || normalizedMessage.includes('must grant your oauth token access')
89
+ || normalizedMessage.includes('resource not accessible by personal access token');
90
+ }
91
+ function normalizeStore(value) {
92
+ if (!value || typeof value !== 'object') {
93
+ return emptyStore();
94
+ }
95
+ const raw = value;
96
+ const accounts = {};
97
+ if (raw.accounts && typeof raw.accounts === 'object') {
98
+ for (const [login, account] of Object.entries(raw.accounts)) {
99
+ const normalizedLogin = normalizeGitHubLogin(login);
100
+ if (normalizedLogin &&
101
+ !findStoredLogin(accounts, normalizedLogin) &&
102
+ account &&
103
+ typeof account === 'object' &&
104
+ typeof account.github_token === 'string' &&
105
+ typeof account.created_at === 'string') {
106
+ accounts[normalizedLogin] = {
107
+ github_token: account.github_token,
108
+ created_at: account.created_at,
109
+ };
110
+ }
111
+ }
112
+ }
113
+ const accountLogins = Object.keys(accounts);
114
+ const activeLogin = findStoredLogin(accounts, raw.activeLogin) ?? accountLogins[0] ?? null;
115
+ const repoDefaults = {};
116
+ if (raw.repoDefaults && typeof raw.repoDefaults === 'object') {
117
+ for (const [repoFullName, login] of Object.entries(raw.repoDefaults)) {
118
+ const storedLogin = findStoredLogin(accounts, login);
119
+ if (storedLogin) {
120
+ repoDefaults[repoFullName] = storedLogin;
121
+ }
122
+ }
123
+ }
124
+ return {
125
+ version: 2,
126
+ activeLogin,
127
+ accounts,
128
+ repoDefaults,
129
+ };
130
+ }
131
+ async function ensureConfigDir() {
132
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
133
+ }
134
+ async function saveStore(store) {
135
+ const normalized = normalizeStore(store);
136
+ if (Object.keys(normalized.accounts).length === 0) {
137
+ await fs.rm(TOKEN_FILE, { force: true });
138
+ return;
139
+ }
140
+ await ensureConfigDir();
141
+ await fs.writeFile(TOKEN_FILE, JSON.stringify(normalized, null, 2), {
142
+ mode: 0o600,
143
+ });
144
+ await fs.chmod(TOKEN_FILE, 0o600);
145
+ }
146
+ export async function verifyGitHubToken(token) {
147
+ try {
148
+ const response = await fetch('https://api.github.com/user', {
149
+ headers: {
150
+ Authorization: `Bearer ${token}`,
151
+ 'User-Agent': 'Haystack-CLI',
152
+ },
153
+ });
154
+ if (response.status === 401) {
155
+ return { status: 'invalid' };
156
+ }
157
+ if (!response.ok) {
158
+ const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
159
+ if (response.status === 403 && rateLimitRemaining === '0') {
160
+ return {
161
+ status: 'error',
162
+ message: 'GitHub API rate limit exceeded while verifying credentials.',
163
+ };
164
+ }
165
+ const message = await readGitHubErrorMessage(response);
166
+ if (response.status === 403 && isInvalidTokenForbidden(response, message)) {
167
+ return { status: 'invalid' };
168
+ }
169
+ return {
170
+ status: 'error',
171
+ message: message
172
+ ? `GitHub API returned ${response.status} while verifying credentials: ${message}`
173
+ : `GitHub API returned ${response.status} while verifying credentials.`,
174
+ };
175
+ }
176
+ return {
177
+ status: 'ok',
178
+ user: await response.json(),
179
+ };
180
+ }
181
+ catch (error) {
182
+ return {
183
+ status: 'error',
184
+ message: error instanceof Error ? error.message : String(error),
185
+ };
186
+ }
187
+ }
188
+ async function migrateLegacyCredentials(legacy) {
189
+ const verification = await verifyGitHubToken(legacy.github_token);
190
+ if (verification.status === 'invalid') {
191
+ const store = emptyStore();
192
+ await saveStore(store);
193
+ return store;
194
+ }
195
+ if (verification.status === 'error') {
196
+ throw new Error(`Could not migrate legacy credentials from ${TOKEN_FILE_DISPLAY}: ${verification.message}`);
197
+ }
198
+ const user = verification.user;
199
+ const store = {
200
+ version: 2,
201
+ activeLogin: user.login,
202
+ accounts: {
203
+ [user.login]: {
204
+ github_token: legacy.github_token,
205
+ created_at: legacy.created_at,
206
+ },
207
+ },
208
+ repoDefaults: {},
209
+ };
210
+ await saveStore(store);
211
+ return store;
212
+ }
213
+ export async function loadCredentialsStore() {
214
+ let content;
215
+ try {
216
+ content = await fs.readFile(TOKEN_FILE, 'utf-8');
217
+ }
218
+ catch (error) {
219
+ if (error?.code === 'ENOENT') {
220
+ return emptyStore();
221
+ }
222
+ throw new Error(`Failed to read ${TOKEN_FILE_DISPLAY}: ${error instanceof Error ? error.message : String(error)}`);
223
+ }
224
+ let parsed;
225
+ try {
226
+ parsed = JSON.parse(content);
227
+ }
228
+ catch (error) {
229
+ throw new Error(`Failed to parse ${TOKEN_FILE_DISPLAY}: ${error instanceof Error ? error.message : String(error)}`);
230
+ }
231
+ if (isLegacyCredentials(parsed)) {
232
+ return migrateLegacyCredentials(parsed);
233
+ }
234
+ if (hasAmbiguousLegacyCredentialsShape(parsed)) {
235
+ throw new Error(`Failed to parse ${TOKEN_FILE_DISPLAY}: unsupported mixed legacy/v2 credentials format.`);
236
+ }
237
+ if (!isStoredCredentialsShape(parsed)) {
238
+ throw new Error(`Failed to parse ${TOKEN_FILE_DISPLAY}: unsupported credentials format.`);
239
+ }
240
+ if (parsed.version !== undefined && parsed.version !== 2) {
241
+ throw new Error(`Failed to parse ${TOKEN_FILE_DISPLAY}: unsupported credentials version ${String(parsed.version)}.`);
242
+ }
243
+ return normalizeStore(parsed);
244
+ }
245
+ function repoKey(owner, repo) {
246
+ if (!owner || !repo)
247
+ return null;
248
+ return `${owner}/${repo}`;
249
+ }
250
+ export async function saveAuthenticatedToken(token) {
251
+ const verification = await verifyGitHubToken(token);
252
+ if (verification.status === 'invalid') {
253
+ throw new Error('GitHub token expired or invalid. Run `haystack login` again.');
254
+ }
255
+ if (verification.status === 'error') {
256
+ throw new Error(`Failed to verify token: ${verification.message}`);
257
+ }
258
+ const user = verification.user;
259
+ const store = await loadCredentialsStore();
260
+ store.accounts[user.login] = {
261
+ github_token: token,
262
+ created_at: new Date().toISOString(),
263
+ };
264
+ store.activeLogin = user.login;
265
+ await saveStore(store);
266
+ return user;
267
+ }
268
+ export async function listStoredAccounts() {
269
+ const store = await loadCredentialsStore();
270
+ const repoDefaultCounts = new Map();
271
+ for (const login of Object.values(store.repoDefaults)) {
272
+ repoDefaultCounts.set(login, (repoDefaultCounts.get(login) ?? 0) + 1);
273
+ }
274
+ return Object.entries(store.accounts)
275
+ .map(([login, account]) => ({
276
+ login,
277
+ createdAt: account.created_at,
278
+ isActive: store.activeLogin === login,
279
+ repoDefaultCount: repoDefaultCounts.get(login) ?? 0,
280
+ }))
281
+ .sort((a, b) => {
282
+ if (a.isActive !== b.isActive)
283
+ return a.isActive ? -1 : 1;
284
+ return a.login.localeCompare(b.login);
285
+ });
286
+ }
287
+ export async function setActiveAccount(login) {
288
+ const store = await loadCredentialsStore();
289
+ const storedLogin = findStoredLogin(store.accounts, login);
290
+ if (!storedLogin) {
291
+ throw new Error(`No saved Haystack account for ${login}.`);
292
+ }
293
+ store.activeLogin = storedLogin;
294
+ await saveStore(store);
295
+ }
296
+ export async function removeAccount(login) {
297
+ const store = await loadCredentialsStore();
298
+ const targetLogin = login ? findStoredLogin(store.accounts, login) : store.activeLogin;
299
+ if (login && !targetLogin) {
300
+ throw new Error(`No saved Haystack account for ${login}.`);
301
+ }
302
+ if (!targetLogin || !store.accounts[targetLogin]) {
303
+ return null;
304
+ }
305
+ delete store.accounts[targetLogin];
306
+ for (const [repoFullName, mappedLogin] of Object.entries(store.repoDefaults)) {
307
+ if (mappedLogin === targetLogin) {
308
+ delete store.repoDefaults[repoFullName];
309
+ }
310
+ }
311
+ if (store.activeLogin === targetLogin) {
312
+ store.activeLogin = Object.keys(store.accounts)[0] ?? null;
313
+ }
314
+ await saveStore(store);
315
+ return targetLogin;
316
+ }
317
+ export async function getActiveLogin() {
318
+ const store = await loadCredentialsStore();
319
+ return store.activeLogin;
320
+ }
321
+ export async function getRepoDefaultAccount(owner, repo) {
322
+ const store = await loadCredentialsStore();
323
+ const key = repoKey(owner, repo);
324
+ if (!key)
325
+ return null;
326
+ return store.repoDefaults[key] ?? null;
327
+ }
328
+ export async function setRepoDefaultAccount(owner, repo, login) {
329
+ const store = await loadCredentialsStore();
330
+ const storedLogin = findStoredLogin(store.accounts, login);
331
+ if (!storedLogin) {
332
+ throw new Error(`No saved Haystack account for ${login}.`);
333
+ }
334
+ const key = repoKey(owner, repo);
335
+ if (!key)
336
+ return;
337
+ store.repoDefaults[key] = storedLogin;
338
+ await saveStore(store);
339
+ }
340
+ export async function resolveAuthContext(options = {}) {
341
+ const store = await loadCredentialsStore();
342
+ const { preferredLogin, owner, repo } = options;
343
+ if (preferredLogin) {
344
+ const storedLogin = findStoredLogin(store.accounts, preferredLogin);
345
+ if (!storedLogin) {
346
+ throw new Error(`No saved Haystack account for ${preferredLogin}. Run \`haystack login\` to add it or \`haystack auth list\` to inspect saved accounts.`);
347
+ }
348
+ const account = store.accounts[storedLogin];
349
+ return {
350
+ login: storedLogin,
351
+ token: account.github_token,
352
+ source: 'preferred',
353
+ };
354
+ }
355
+ const repoFullName = repoKey(owner, repo);
356
+ if (repoFullName) {
357
+ const repoLogin = store.repoDefaults[repoFullName];
358
+ if (repoLogin && store.accounts[repoLogin]) {
359
+ return {
360
+ login: repoLogin,
361
+ token: store.accounts[repoLogin].github_token,
362
+ source: 'repo-default',
363
+ };
364
+ }
365
+ }
366
+ if (store.activeLogin && store.accounts[store.activeLogin]) {
367
+ return {
368
+ login: store.activeLogin,
369
+ token: store.accounts[store.activeLogin].github_token,
370
+ source: 'active',
371
+ };
372
+ }
373
+ throw new Error('Not logged in. Run `haystack login` first.');
374
+ }
375
+ export async function loadToken(options = {}) {
376
+ try {
377
+ const context = await resolveAuthContext(options);
378
+ return context.token;
379
+ }
380
+ catch (error) {
381
+ if (error instanceof Error && error.message.startsWith('Not logged in.')) {
382
+ return null;
383
+ }
384
+ throw error;
385
+ }
386
+ }
@@ -52,10 +52,23 @@ export declare function remoteBranchExists(remoteName: string, branchName: strin
52
52
  * Checkout an existing branch
53
53
  */
54
54
  export declare function checkoutBranch(branchName: string): void;
55
+ export interface PushAuth {
56
+ token: string;
57
+ owner: string;
58
+ repo: string;
59
+ }
55
60
  /**
56
- * Push current branch to remote with upstream tracking
61
+ * Push current branch to remote with upstream tracking.
62
+ *
63
+ * 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`.
68
+ *
69
+ * Without `auth`, falls back to `git push -u <remote>` using ambient creds.
57
70
  */
58
- export declare function pushBranch(remoteName: string, branchName: string): void;
71
+ export declare function pushBranch(remoteName: string, branchName: string, auth?: PushAuth): void;
59
72
  /**
60
73
  * Parse remote URL to extract owner and repo
61
74
  * Supports both SSH and HTTPS URLs
package/dist/utils/git.js CHANGED
@@ -4,6 +4,9 @@
4
4
  * Provides functions for git operations: branch management, push, remote parsing.
5
5
  */
6
6
  import { execSync } from 'child_process';
7
+ import * as fs from 'fs';
8
+ import * as os from 'os';
9
+ import * as path from 'path';
7
10
  // Re-export findGitRoot from hooks for consistency
8
11
  export { findGitRoot } from './hooks.js';
9
12
  // ============================================================================
@@ -159,13 +162,22 @@ export function checkoutBranch(branchName) {
159
162
  throw new Error(`Failed to checkout branch: ${message}`);
160
163
  }
161
164
  }
162
- // ============================================================================
163
- // Push operations
164
- // ============================================================================
165
165
  /**
166
- * Push current branch to remote with upstream tracking
166
+ * Push current branch to remote with upstream tracking.
167
+ *
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`.
173
+ *
174
+ * Without `auth`, falls back to `git push -u <remote>` using ambient creds.
167
175
  */
168
- export function pushBranch(remoteName, branchName) {
176
+ export function pushBranch(remoteName, branchName, auth) {
177
+ if (auth) {
178
+ pushBranchWithToken(remoteName, branchName, auth);
179
+ return;
180
+ }
169
181
  try {
170
182
  execSync(`git push -u ${remoteName} "${branchName}"`, {
171
183
  encoding: 'utf-8',
@@ -173,15 +185,64 @@ export function pushBranch(remoteName, branchName) {
173
185
  });
174
186
  }
175
187
  catch (err) {
176
- const message = err instanceof Error ? err.message : String(err);
177
- if (message.includes('permission denied') || message.includes('403')) {
178
- throw new Error('Permission denied. Check your GitHub credentials.');
188
+ throw translatePushError(err);
189
+ }
190
+ }
191
+ function pushBranchWithToken(remoteName, branchName, auth) {
192
+ const askpassPath = path.join(os.tmpdir(), `haystack-askpass-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.sh`);
193
+ // Script reads token from env at call time — keeps it out of the script contents and argv.
194
+ const script = `#!/bin/sh\ncase "$1" in\n Username*) printf '%s' "x-access-token" ;;\n *) printf '%s' "$HAYSTACK_GIT_TOKEN" ;;\nesac\n`;
195
+ fs.writeFileSync(askpassPath, script, { mode: 0o700 });
196
+ 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
+ });
208
+ try {
209
+ execSync(`git branch --set-upstream-to=${remoteName}/${branchName} "${branchName}"`, {
210
+ encoding: 'utf-8',
211
+ stdio: ['pipe', 'pipe', 'pipe'],
212
+ });
213
+ }
214
+ catch (err) {
215
+ // Non-fatal: push succeeded; upstream tracking is a convenience.
216
+ const message = err instanceof Error ? err.message : String(err);
217
+ console.warn(`Warning: failed to set upstream tracking for ${branchName}: ${message}`);
218
+ }
219
+ }
220
+ catch (err) {
221
+ throw translatePushError(err, auth);
222
+ }
223
+ finally {
224
+ try {
225
+ fs.unlinkSync(askpassPath);
226
+ }
227
+ catch (err) {
228
+ const message = err instanceof Error ? err.message : String(err);
229
+ // Best-effort cleanup; log so we can spot leaking askpass scripts in /tmp.
230
+ console.warn(`Warning: failed to remove temp askpass script ${askpassPath}: ${message}`);
179
231
  }
180
- if (message.includes('remote rejected')) {
181
- throw new Error('Remote rejected the push. The branch may be protected.');
232
+ }
233
+ }
234
+ function translatePushError(err, auth) {
235
+ const message = err instanceof Error ? err.message : String(err);
236
+ 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.`);
182
239
  }
183
- throw new Error(`Failed to push: ${message}`);
240
+ return new Error('Permission denied. Check your GitHub credentials.');
241
+ }
242
+ if (message.includes('remote rejected')) {
243
+ return new Error('Remote rejected the push. The branch may be protected.');
184
244
  }
245
+ return new Error(`Failed to push: ${message}`);
185
246
  }
186
247
  // ============================================================================
187
248
  // Remote operations