@haystackeditor/cli 0.12.3 → 0.13.1

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.
@@ -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
+ }
@@ -11,6 +11,7 @@ export interface CreatePROptions {
11
11
  base: string;
12
12
  body?: string;
13
13
  draft?: boolean;
14
+ token?: string;
14
15
  }
15
16
  export interface PRResponse {
16
17
  number: number;
@@ -29,11 +30,33 @@ export interface LabelInfo {
29
30
  color: string;
30
31
  description?: string;
31
32
  }
33
+ export interface GitHubUser {
34
+ login: string;
35
+ id: number;
36
+ }
37
+ export interface RepositoryPermissions {
38
+ admin?: boolean;
39
+ maintain?: boolean;
40
+ push?: boolean;
41
+ triage?: boolean;
42
+ pull?: boolean;
43
+ }
44
+ export interface RepositoryAccess {
45
+ full_name: string;
46
+ owner: {
47
+ login: string;
48
+ };
49
+ permissions?: RepositoryPermissions;
50
+ }
32
51
  export declare const HAYSTACK_LABELS: Record<string, LabelInfo>;
33
52
  /**
34
- * Get stored GitHub token, throw if not authenticated
53
+ * Get a GitHub token, throw if not authenticated
35
54
  */
36
- export declare function requireGitHubAuth(): Promise<string>;
55
+ export declare function requireGitHubAuth(options?: {
56
+ preferredLogin?: string;
57
+ owner?: string;
58
+ repo?: string;
59
+ }): Promise<string>;
37
60
  /**
38
61
  * Create a pull request
39
62
  */
@@ -41,43 +64,41 @@ export declare function createPullRequest(options: CreatePROptions): Promise<PRR
41
64
  /**
42
65
  * Check if a PR already exists for a branch
43
66
  */
44
- export declare function findExistingPR(owner: string, repo: string, head: string): Promise<PRResponse | null>;
67
+ export declare function findExistingPR(owner: string, repo: string, head: string, token?: string): Promise<PRResponse | null>;
45
68
  /**
46
69
  * Add labels to an issue/PR
47
70
  */
48
- export declare function addLabelsToIssue(owner: string, repo: string, issueNumber: number, labels: string[]): Promise<void>;
71
+ export declare function addLabelsToIssue(owner: string, repo: string, issueNumber: number, labels: string[], token?: string): Promise<void>;
49
72
  /**
50
73
  * Get all labels from an issue/PR
51
74
  */
52
- export declare function getLabelsFromIssue(owner: string, repo: string, issueNumber: number): Promise<string[]>;
75
+ export declare function getLabelsFromIssue(owner: string, repo: string, issueNumber: number, token?: string): Promise<string[]>;
53
76
  /**
54
77
  * Remove a label from an issue/PR
55
78
  */
56
- export declare function removeLabelFromIssue(owner: string, repo: string, issueNumber: number, labelName: string): Promise<void>;
79
+ export declare function removeLabelFromIssue(owner: string, repo: string, issueNumber: number, labelName: string, token?: string): Promise<void>;
57
80
  /**
58
81
  * Check if a label exists in the repo
59
82
  */
60
- export declare function labelExists(owner: string, repo: string, labelName: string): Promise<boolean>;
83
+ export declare function labelExists(owner: string, repo: string, labelName: string, token?: string): Promise<boolean>;
61
84
  /**
62
85
  * Create a label in the repo
63
86
  */
64
- export declare function createLabel(owner: string, repo: string, label: LabelInfo): Promise<void>;
87
+ export declare function createLabel(owner: string, repo: string, label: LabelInfo, token?: string): Promise<void>;
65
88
  /**
66
89
  * Ensure all required Haystack labels exist in the repo
67
90
  */
68
- export declare function ensureHaystackLabels(owner: string, repo: string, labelNames: string[]): Promise<void>;
91
+ export declare function ensureHaystackLabels(owner: string, repo: string, labelNames: string[], token?: string): Promise<void>;
69
92
  /**
70
93
  * Request reviewers on a pull request
71
94
  */
72
- export declare function requestReviewers(owner: string, repo: string, prNumber: number, reviewers: string[]): Promise<void>;
95
+ export declare function requestReviewers(owner: string, repo: string, prNumber: number, reviewers: string[], token?: string): Promise<void>;
73
96
  /**
74
97
  * Get the authenticated user's info
75
98
  */
76
- export declare function getCurrentUser(): Promise<{
77
- login: string;
78
- id: number;
79
- }>;
99
+ export declare function getCurrentUser(token?: string): Promise<GitHubUser>;
100
+ export declare function getRepositoryAccess(owner: string, repo: string, token?: string): Promise<RepositoryAccess>;
80
101
  /**
81
102
  * Check if user has push access to a repo
82
103
  */
83
- export declare function hasPushAccess(owner: string, repo: string): Promise<boolean>;
104
+ export declare function hasPushAccess(owner: string, repo: string, token?: string): Promise<boolean>;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Provides functions for creating PRs, managing labels, etc.
5
5
  */
6
- import { loadToken } from '../commands/login.js';
6
+ import { resolveAuthContext } from './auth.js';
7
7
  const GITHUB_API_BASE = 'https://api.github.com';
8
8
  // Label definitions for Haystack workflow
9
9
  export const HAYSTACK_LABELS = {
@@ -27,14 +27,14 @@ export const HAYSTACK_LABELS = {
27
27
  // Authentication
28
28
  // ============================================================================
29
29
  /**
30
- * Get stored GitHub token, throw if not authenticated
30
+ * Get a GitHub token, throw if not authenticated
31
31
  */
32
- export async function requireGitHubAuth() {
33
- const token = await loadToken();
34
- if (!token) {
35
- throw new Error('Not logged in. Run `haystack login` first.');
36
- }
37
- return token;
32
+ export async function requireGitHubAuth(options) {
33
+ const context = await resolveAuthContext(options);
34
+ return context.token;
35
+ }
36
+ async function resolveToken(token, options) {
37
+ return token ?? requireGitHubAuth(options);
38
38
  }
39
39
  // ============================================================================
40
40
  // API helpers
@@ -88,7 +88,10 @@ async function handleApiError(response, context) {
88
88
  * Create a pull request
89
89
  */
90
90
  export async function createPullRequest(options) {
91
- const token = await requireGitHubAuth();
91
+ const token = await resolveToken(options.token, {
92
+ owner: options.owner,
93
+ repo: options.repo,
94
+ });
92
95
  const response = await githubApiRequest('POST', `/repos/${options.owner}/${options.repo}/pulls`, token, {
93
96
  title: options.title,
94
97
  head: options.head,
@@ -139,11 +142,11 @@ async function findMergedPRForBranch(owner, repo, head, token) {
139
142
  /**
140
143
  * Check if a PR already exists for a branch
141
144
  */
142
- export async function findExistingPR(owner, repo, head) {
143
- const token = await requireGitHubAuth();
145
+ export async function findExistingPR(owner, repo, head, token) {
146
+ const authToken = await resolveToken(token, { owner, repo });
144
147
  // head format should be "owner:branch" for cross-repo or just "branch" for same-repo
145
148
  const headParam = head.includes(':') ? head : `${owner}:${head}`;
146
- const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/pulls?head=${encodeURIComponent(headParam)}&state=open`, token);
149
+ const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/pulls?head=${encodeURIComponent(headParam)}&state=open`, authToken);
147
150
  if (!response.ok) {
148
151
  if (response.status === 404)
149
152
  return null;
@@ -158,9 +161,9 @@ export async function findExistingPR(owner, repo, head) {
158
161
  /**
159
162
  * Add labels to an issue/PR
160
163
  */
161
- export async function addLabelsToIssue(owner, repo, issueNumber, labels) {
162
- const token = await requireGitHubAuth();
163
- const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, token, { labels });
164
+ export async function addLabelsToIssue(owner, repo, issueNumber, labels, token) {
165
+ const authToken = await resolveToken(token, { owner, repo });
166
+ const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, authToken, { labels });
164
167
  if (!response.ok) {
165
168
  await handleApiError(response, `adding labels to PR #${issueNumber}`);
166
169
  }
@@ -168,9 +171,9 @@ export async function addLabelsToIssue(owner, repo, issueNumber, labels) {
168
171
  /**
169
172
  * Get all labels from an issue/PR
170
173
  */
171
- export async function getLabelsFromIssue(owner, repo, issueNumber) {
172
- const token = await requireGitHubAuth();
173
- const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, token);
174
+ export async function getLabelsFromIssue(owner, repo, issueNumber, token) {
175
+ const authToken = await resolveToken(token, { owner, repo });
176
+ const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, authToken);
174
177
  if (!response.ok) {
175
178
  await handleApiError(response, `getting labels from PR #${issueNumber}`);
176
179
  }
@@ -180,9 +183,9 @@ export async function getLabelsFromIssue(owner, repo, issueNumber) {
180
183
  /**
181
184
  * Remove a label from an issue/PR
182
185
  */
183
- export async function removeLabelFromIssue(owner, repo, issueNumber, labelName) {
184
- const token = await requireGitHubAuth();
185
- const response = await githubApiRequest('DELETE', `/repos/${owner}/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(labelName)}`, token);
186
+ export async function removeLabelFromIssue(owner, repo, issueNumber, labelName, token) {
187
+ const authToken = await resolveToken(token, { owner, repo });
188
+ const response = await githubApiRequest('DELETE', `/repos/${owner}/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(labelName)}`, authToken);
186
189
  // 404 is fine - label wasn't on the issue
187
190
  if (!response.ok && response.status !== 404) {
188
191
  await handleApiError(response, `removing label '${labelName}' from PR #${issueNumber}`);
@@ -191,17 +194,17 @@ export async function removeLabelFromIssue(owner, repo, issueNumber, labelName)
191
194
  /**
192
195
  * Check if a label exists in the repo
193
196
  */
194
- export async function labelExists(owner, repo, labelName) {
195
- const token = await requireGitHubAuth();
196
- const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/labels/${encodeURIComponent(labelName)}`, token);
197
+ export async function labelExists(owner, repo, labelName, token) {
198
+ const authToken = await resolveToken(token, { owner, repo });
199
+ const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/labels/${encodeURIComponent(labelName)}`, authToken);
197
200
  return response.ok;
198
201
  }
199
202
  /**
200
203
  * Create a label in the repo
201
204
  */
202
- export async function createLabel(owner, repo, label) {
203
- const token = await requireGitHubAuth();
204
- const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/labels`, token, {
205
+ export async function createLabel(owner, repo, label, token) {
206
+ const authToken = await resolveToken(token, { owner, repo });
207
+ const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/labels`, authToken, {
205
208
  name: label.name,
206
209
  color: label.color,
207
210
  description: label.description,
@@ -214,14 +217,14 @@ export async function createLabel(owner, repo, label) {
214
217
  /**
215
218
  * Ensure all required Haystack labels exist in the repo
216
219
  */
217
- export async function ensureHaystackLabels(owner, repo, labelNames) {
220
+ export async function ensureHaystackLabels(owner, repo, labelNames, token) {
218
221
  for (const name of labelNames) {
219
222
  const labelInfo = HAYSTACK_LABELS[name];
220
223
  if (!labelInfo)
221
224
  continue;
222
- const exists = await labelExists(owner, repo, name);
225
+ const exists = await labelExists(owner, repo, name, token);
223
226
  if (!exists) {
224
- await createLabel(owner, repo, labelInfo);
227
+ await createLabel(owner, repo, labelInfo, token);
225
228
  }
226
229
  }
227
230
  }
@@ -231,9 +234,9 @@ export async function ensureHaystackLabels(owner, repo, labelNames) {
231
234
  /**
232
235
  * Request reviewers on a pull request
233
236
  */
234
- export async function requestReviewers(owner, repo, prNumber, reviewers) {
235
- const token = await requireGitHubAuth();
236
- const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/pulls/${prNumber}/requested_reviewers`, token, { reviewers });
237
+ export async function requestReviewers(owner, repo, prNumber, reviewers, token) {
238
+ const authToken = await resolveToken(token, { owner, repo });
239
+ const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/pulls/${prNumber}/requested_reviewers`, authToken, { reviewers });
237
240
  if (!response.ok) {
238
241
  await handleApiError(response, `requesting reviewers on PR #${prNumber}`);
239
242
  }
@@ -244,23 +247,28 @@ export async function requestReviewers(owner, repo, prNumber, reviewers) {
244
247
  /**
245
248
  * Get the authenticated user's info
246
249
  */
247
- export async function getCurrentUser() {
248
- const token = await requireGitHubAuth();
249
- const response = await githubApiRequest('GET', '/user', token);
250
+ export async function getCurrentUser(token) {
251
+ const authToken = await resolveToken(token);
252
+ const response = await githubApiRequest('GET', '/user', authToken);
250
253
  if (!response.ok) {
251
254
  await handleApiError(response, 'getting current user');
252
255
  }
253
256
  return response.json();
254
257
  }
258
+ export async function getRepositoryAccess(owner, repo, token) {
259
+ const authToken = await resolveToken(token, { owner, repo });
260
+ const response = await githubApiRequest('GET', `/repos/${owner}/${repo}`, authToken);
261
+ if (!response.ok) {
262
+ await handleApiError(response, `getting repository access for ${owner}/${repo}`);
263
+ }
264
+ return response.json();
265
+ }
255
266
  /**
256
267
  * Check if user has push access to a repo
257
268
  */
258
- export async function hasPushAccess(owner, repo) {
259
- const token = await requireGitHubAuth();
260
- const response = await githubApiRequest('GET', `/repos/${owner}/${repo}`, token);
261
- if (!response.ok) {
262
- return false;
263
- }
264
- const data = await response.json();
265
- return data.permissions?.push === true;
269
+ export async function hasPushAccess(owner, repo, token) {
270
+ const data = await getRepositoryAccess(owner, repo, token);
271
+ return data.permissions?.admin === true
272
+ || data.permissions?.maintain === true
273
+ || data.permissions?.push === true;
266
274
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.12.3",
3
+ "version": "0.13.1",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {