acdev 1.0.2 → 1.0.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.
@@ -79,3 +79,33 @@ export function checkClaudeAuth() {
79
79
 
80
80
  return { ok: false, reason: 'claude-not-authenticated' };
81
81
  }
82
+
83
+ /**
84
+ * Method string for Settings / API — never a secret.
85
+ * @param {ClaudeAuthResult} result
86
+ * @returns {ClaudeAuthMethod | 'missing'}
87
+ */
88
+ export function publicClaudeAuthMethod(result) {
89
+ return result.ok ? result.method : 'missing';
90
+ }
91
+
92
+ /**
93
+ * Human-readable startup error for a failed {@link checkClaudeAuth}.
94
+ * @param {ClaudeAuthResult} [_result]
95
+ */
96
+ export function formatClaudeAuthError(_result) {
97
+ return [
98
+ '✖ No Anthropic / Claude Code authentication found.',
99
+ '',
100
+ 'Authenticate with one of:',
101
+ ' 1. Claude Pro/Max subscription (browser): install Claude Code, then run',
102
+ ' `claude auth login`. Settings cannot complete browser OAuth — that still',
103
+ ' needs the CLI on this machine.',
104
+ ' 2. Subscription token: `claude setup-token`, then set CLAUDE_CODE_OAUTH_TOKEN',
105
+ ' in Settings → Claude authentication or `.acdev/.env`',
106
+ ' 3. Anthropic API key (API billing): set ANTHROPIC_API_KEY in Settings or `.acdev/.env`',
107
+ '',
108
+ 'API keys take precedence over subscription login. For UI-only testing without',
109
+ 'auth: pass `--stub-agent`.',
110
+ ].join('\n');
111
+ }
package/src/config.js CHANGED
@@ -1,47 +1,58 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { checkClaudeAuth, publicClaudeAuthMethod } from './claude-auth.js';
3
4
  import { maskSecret } from './env.js';
5
+ import { checkGhAuth, githubTokenFromEnv, originRemoteInfo } from './gh-auth.js';
4
6
  import { normalizeJiraBaseUrl } from './jira.js';
7
+ import {
8
+ DEFAULT_MODEL,
9
+ MODEL_OPTIONS,
10
+ isValidModelId,
11
+ } from './models.js';
5
12
  import { dataDir } from './paths.js';
6
13
 
7
- /** Model IDs accepted by the Claude Agent SDK / Claude Code. */
8
- export const MODEL_OPTIONS = [
9
- { id: 'claude-sonnet-5', label: 'Sonnet 5' },
10
- { id: 'claude-opus-5', label: 'Opus 5' },
11
- ];
12
-
13
- export const DEFAULT_MODEL = 'claude-sonnet-5';
14
+ export { DEFAULT_MODEL, MODEL_OPTIONS } from './models.js';
14
15
 
15
16
  /** Tool names the agent may be granted via config. */
16
17
  export const KNOWN_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
17
18
 
18
19
  export const TICKET_SOURCES = /** @type {const} */ (['github', 'jira']);
19
20
 
20
- /** GitHub post-PR actions (issues have no Jira-like workflow statuses). */
21
- export const GITHUB_AFTER_PR_ACTIONS = /** @type {const} */ ([
21
+ /**
22
+ * Shared post-PR actions for Jira and GitHub Issues rules.
23
+ * - Jira `set_status` / `close_issue`: workflow transitions (close → Done-like status).
24
+ * - GitHub `set_status`: applies a label named after `targetStatus` (no board API).
25
+ * - Both `add_label`: add the configured label name.
26
+ */
27
+ export const AFTER_PR_ACTIONS = /** @type {const} */ ([
22
28
  'none',
29
+ 'set_status',
23
30
  'add_label',
24
31
  'close_issue',
25
32
  ]);
26
33
 
27
- const ALLOWED_MODELS = new Set(MODEL_OPTIONS.map((m) => m.id));
34
+ /** @deprecated Prefer AFTER_PR_ACTIONS same vocabulary for both sources. */
35
+ export const GITHUB_AFTER_PR_ACTIONS = AFTER_PR_ACTIONS;
36
+
28
37
  const ALLOWED_TOOLS_SET = new Set(KNOWN_TOOLS);
29
38
  const ALLOWED_TICKET_SOURCES = new Set(TICKET_SOURCES);
30
- const ALLOWED_GITHUB_AFTER_PR_ACTIONS = new Set(GITHUB_AFTER_PR_ACTIONS);
39
+ const ALLOWED_AFTER_PR_ACTIONS = new Set(AFTER_PR_ACTIONS);
40
+
41
+ /** @typedef {'none' | 'set_status' | 'add_label' | 'close_issue'} AfterPrAction */
42
+
43
+ const DEFAULT_AFTER_PR_OPENED = {
44
+ enabled: false,
45
+ action: /** @type {AfterPrAction} */ ('none'),
46
+ targetStatus: 'In Review',
47
+ label: '',
48
+ };
31
49
 
32
50
  const DEFAULT_JIRA_RULES = {
33
- afterPrOpened: {
34
- enabled: false,
35
- targetStatus: 'In Review',
36
- },
51
+ afterPrOpened: structuredClone(DEFAULT_AFTER_PR_OPENED),
37
52
  };
38
53
 
39
54
  const DEFAULT_GITHUB_RULES = {
40
- afterPrOpened: {
41
- enabled: false,
42
- action: /** @type {'none' | 'add_label' | 'close_issue'} */ ('none'),
43
- label: '',
44
- },
55
+ afterPrOpened: structuredClone(DEFAULT_AFTER_PR_OPENED),
45
56
  };
46
57
 
47
58
  const DEFAULTS = {
@@ -60,17 +71,19 @@ const DEFAULTS = {
60
71
  };
61
72
 
62
73
  /**
63
- * Normalize / validate jiraRules from config or PATCH body.
74
+ * @param {string} prefix e.g. jiraRules / githubRules
64
75
  * @param {unknown} raw
65
- * @param {{ throwOnInvalid?: boolean }} [opts]
76
+ * @param {{ throwOnInvalid?: boolean, migrateLegacyJira?: boolean }} [opts]
66
77
  * @returns {typeof DEFAULT_JIRA_RULES}
67
78
  */
68
- export function normalizeJiraRules(raw, opts = {}) {
79
+ function normalizeAfterPrRules(prefix, raw, opts = {}) {
69
80
  const throwOnInvalid = opts.throwOnInvalid === true;
70
- const base = structuredClone(DEFAULT_JIRA_RULES);
81
+ const base = {
82
+ afterPrOpened: structuredClone(DEFAULT_AFTER_PR_OPENED),
83
+ };
71
84
  if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
72
85
  if (throwOnInvalid && raw != null) {
73
- throw new Error('jiraRules must be an object');
86
+ throw new Error(`${prefix} must be an object`);
74
87
  }
75
88
  return base;
76
89
  }
@@ -82,25 +95,40 @@ export function normalizeJiraRules(raw, opts = {}) {
82
95
  : {};
83
96
 
84
97
  if (throwOnInvalid && obj.afterPrOpened != null && (typeof obj.afterPrOpened !== 'object' || Array.isArray(obj.afterPrOpened))) {
85
- throw new Error('jiraRules.afterPrOpened must be an object');
98
+ throw new Error(`${prefix}.afterPrOpened must be an object`);
86
99
  }
87
100
 
88
101
  if (after.enabled !== undefined) {
89
102
  if (typeof after.enabled !== 'boolean') {
90
- if (throwOnInvalid) throw new Error('jiraRules.afterPrOpened.enabled must be a boolean');
103
+ if (throwOnInvalid) throw new Error(`${prefix}.afterPrOpened.enabled must be a boolean`);
91
104
  } else {
92
105
  base.afterPrOpened.enabled = after.enabled;
93
106
  }
94
107
  }
95
108
 
109
+ if (after.action !== undefined) {
110
+ if (typeof after.action !== 'string' || !ALLOWED_AFTER_PR_ACTIONS.has(after.action)) {
111
+ if (throwOnInvalid) {
112
+ throw new Error(
113
+ `Invalid ${prefix}.afterPrOpened.action "${after.action}". Allowed: ${AFTER_PR_ACTIONS.join(', ')}`
114
+ );
115
+ }
116
+ } else {
117
+ base.afterPrOpened.action = /** @type {AfterPrAction} */ (after.action);
118
+ }
119
+ } else if (opts.migrateLegacyJira && base.afterPrOpened.enabled) {
120
+ // Legacy Jira configs only had enabled + targetStatus (implied move status).
121
+ base.afterPrOpened.action = 'set_status';
122
+ }
123
+
96
124
  if (after.targetStatus !== undefined) {
97
125
  if (typeof after.targetStatus !== 'string') {
98
- if (throwOnInvalid) throw new Error('jiraRules.afterPrOpened.targetStatus must be a string');
126
+ if (throwOnInvalid) throw new Error(`${prefix}.afterPrOpened.targetStatus must be a string`);
99
127
  } else {
100
128
  const trimmed = after.targetStatus.trim();
101
129
  if (!trimmed) {
102
130
  if (throwOnInvalid) {
103
- throw new Error('jiraRules.afterPrOpened.targetStatus must be a non-empty string');
131
+ throw new Error(`${prefix}.afterPrOpened.targetStatus must be a non-empty string`);
104
132
  }
105
133
  } else {
106
134
  base.afterPrOpened.targetStatus = trimmed;
@@ -108,58 +136,9 @@ export function normalizeJiraRules(raw, opts = {}) {
108
136
  }
109
137
  }
110
138
 
111
- return base;
112
- }
113
-
114
- /**
115
- * Normalize / validate githubRules from config or PATCH body.
116
- * @param {unknown} raw
117
- * @param {{ throwOnInvalid?: boolean }} [opts]
118
- * @returns {typeof DEFAULT_GITHUB_RULES}
119
- */
120
- export function normalizeGithubRules(raw, opts = {}) {
121
- const throwOnInvalid = opts.throwOnInvalid === true;
122
- const base = structuredClone(DEFAULT_GITHUB_RULES);
123
- if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
124
- if (throwOnInvalid && raw != null) {
125
- throw new Error('githubRules must be an object');
126
- }
127
- return base;
128
- }
129
- /** @type {Record<string, unknown>} */
130
- const obj = /** @type {Record<string, unknown>} */ (raw);
131
- const after =
132
- obj.afterPrOpened != null && typeof obj.afterPrOpened === 'object' && !Array.isArray(obj.afterPrOpened)
133
- ? /** @type {Record<string, unknown>} */ (obj.afterPrOpened)
134
- : {};
135
-
136
- if (throwOnInvalid && obj.afterPrOpened != null && (typeof obj.afterPrOpened !== 'object' || Array.isArray(obj.afterPrOpened))) {
137
- throw new Error('githubRules.afterPrOpened must be an object');
138
- }
139
-
140
- if (after.enabled !== undefined) {
141
- if (typeof after.enabled !== 'boolean') {
142
- if (throwOnInvalid) throw new Error('githubRules.afterPrOpened.enabled must be a boolean');
143
- } else {
144
- base.afterPrOpened.enabled = after.enabled;
145
- }
146
- }
147
-
148
- if (after.action !== undefined) {
149
- if (typeof after.action !== 'string' || !ALLOWED_GITHUB_AFTER_PR_ACTIONS.has(after.action)) {
150
- if (throwOnInvalid) {
151
- throw new Error(
152
- `Invalid githubRules.afterPrOpened.action "${after.action}". Allowed: ${GITHUB_AFTER_PR_ACTIONS.join(', ')}`
153
- );
154
- }
155
- } else {
156
- base.afterPrOpened.action = /** @type {'none' | 'add_label' | 'close_issue'} */ (after.action);
157
- }
158
- }
159
-
160
139
  if (after.label !== undefined) {
161
140
  if (typeof after.label !== 'string') {
162
- if (throwOnInvalid) throw new Error('githubRules.afterPrOpened.label must be a string');
141
+ if (throwOnInvalid) throw new Error(`${prefix}.afterPrOpened.label must be a string`);
163
142
  } else {
164
143
  base.afterPrOpened.label = after.label.trim();
165
144
  }
@@ -171,12 +150,45 @@ export function normalizeGithubRules(raw, opts = {}) {
171
150
  base.afterPrOpened.action === 'add_label' &&
172
151
  !base.afterPrOpened.label
173
152
  ) {
174
- throw new Error('githubRules.afterPrOpened.label is required when action is add_label');
153
+ throw new Error(`${prefix}.afterPrOpened.label is required when action is add_label`);
154
+ }
155
+
156
+ if (
157
+ throwOnInvalid &&
158
+ base.afterPrOpened.enabled &&
159
+ base.afterPrOpened.action === 'set_status' &&
160
+ !base.afterPrOpened.targetStatus
161
+ ) {
162
+ throw new Error(`${prefix}.afterPrOpened.targetStatus is required when action is set_status`);
175
163
  }
176
164
 
177
165
  return base;
178
166
  }
179
167
 
168
+ /**
169
+ * Normalize / validate jiraRules from config or PATCH body.
170
+ * Legacy `{ enabled, targetStatus }` (no `action`) migrates to `set_status` when enabled.
171
+ * @param {unknown} raw
172
+ * @param {{ throwOnInvalid?: boolean }} [opts]
173
+ * @returns {typeof DEFAULT_JIRA_RULES}
174
+ */
175
+ export function normalizeJiraRules(raw, opts = {}) {
176
+ return normalizeAfterPrRules('jiraRules', raw, {
177
+ ...opts,
178
+ migrateLegacyJira: true,
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Normalize / validate githubRules from config or PATCH body.
184
+ * @param {unknown} raw
185
+ * @param {{ throwOnInvalid?: boolean }} [opts]
186
+ * @returns {typeof DEFAULT_GITHUB_RULES}
187
+ */
188
+ export function normalizeGithubRules(raw, opts = {}) {
189
+ return normalizeAfterPrRules('githubRules', raw, opts);
190
+ }
191
+
180
192
  /**
181
193
  * Persist config to `.acdev/config.json`.
182
194
  * @param {string} repoRoot
@@ -222,7 +234,7 @@ function positiveInt(value, field) {
222
234
 
223
235
  /**
224
236
  * Load or create `.acdev/config.json` inside the repo root.
225
- * Unknown / removed model IDs are mapped to {@link DEFAULT_MODEL}.
237
+ * Empty / malformed model IDs are mapped to {@link DEFAULT_MODEL}.
226
238
  * @param {string} repoRoot
227
239
  * @returns {typeof DEFAULTS}
228
240
  */
@@ -254,9 +266,11 @@ export function loadConfig(repoRoot) {
254
266
  githubRules: normalizeGithubRules(raw.githubRules),
255
267
  };
256
268
 
257
- if (!ALLOWED_MODELS.has(config.model)) {
269
+ if (!isValidModelId(config.model)) {
258
270
  config.model = DEFAULT_MODEL;
259
271
  saveConfig(repoRoot, persistable(config));
272
+ } else {
273
+ config.model = String(config.model).trim();
260
274
  }
261
275
 
262
276
  if (!ALLOWED_TICKET_SOURCES.has(config.ticketSource)) {
@@ -284,12 +298,12 @@ export function loadConfig(repoRoot) {
284
298
  */
285
299
  export function updateConfig(repoRoot, config, patch) {
286
300
  if (patch.model != null) {
287
- if (!ALLOWED_MODELS.has(patch.model)) {
301
+ if (!isValidModelId(patch.model)) {
288
302
  throw new Error(
289
- `Invalid model "${patch.model}". Allowed: ${[...ALLOWED_MODELS].join(', ')}`
303
+ `Invalid model "${patch.model}". Expected a non-empty Claude model id`
290
304
  );
291
305
  }
292
- config.model = patch.model;
306
+ config.model = String(patch.model).trim();
293
307
  }
294
308
 
295
309
  if (patch.baseBranch !== undefined) {
@@ -373,12 +387,19 @@ export function updateConfig(repoRoot, config, patch) {
373
387
 
374
388
  /**
375
389
  * Public-safe config snapshot for the UI / API.
376
- * Never returns the full Jira API token.
390
+ * Never returns full secrets (Jira token, GH PAT, Anthropic keys).
377
391
  * @param {object} config
378
- * @param {{ repoRoot?: string }} [opts]
392
+ * @param {{
393
+ * repoRoot?: string,
394
+ * stubAgent?: boolean,
395
+ * ghAuth?: import('./gh-auth.js').GhAuthResult,
396
+ * claudeAuth?: import('./claude-auth.js').ClaudeAuthResult,
397
+ * }} [opts]
379
398
  */
380
399
  export function publicConfig(config, opts = {}) {
381
- const model = ALLOWED_MODELS.has(config.model) ? config.model : DEFAULT_MODEL;
400
+ const model = isValidModelId(config.model)
401
+ ? String(config.model).trim()
402
+ : DEFAULT_MODEL;
382
403
  const repoName = opts.repoRoot ? path.basename(opts.repoRoot) : undefined;
383
404
  const ticketSource = ALLOWED_TICKET_SOURCES.has(config.ticketSource)
384
405
  ? config.ticketSource
@@ -401,6 +422,13 @@ export function publicConfig(config, opts = {}) {
401
422
  jiraBaseUrl && jiraEmail && tokenMask.set
402
423
  );
403
424
 
425
+ const ghAuth = opts.ghAuth ?? checkGhAuth();
426
+ const claudeAuth = opts.claudeAuth ?? checkClaudeAuth();
427
+ const origin = originRemoteInfo(opts.repoRoot);
428
+ const ghTokenMask = maskSecret(githubTokenFromEnv());
429
+ const anthropicMask = maskSecret(process.env.ANTHROPIC_API_KEY);
430
+ const claudeOauthMask = maskSecret(process.env.CLAUDE_CODE_OAUTH_TOKEN);
431
+
404
432
  return {
405
433
  model,
406
434
  baseBranch: config.baseBranch,
@@ -421,6 +449,20 @@ export function publicConfig(config, opts = {}) {
421
449
  jiraApiTokenSet: tokenMask.set,
422
450
  jiraApiTokenMasked: tokenMask.masked,
423
451
  jiraConfigured,
452
+ ghAuthOk: ghAuth.ok === true,
453
+ ghInstalled: Boolean(ghAuth.ghInstalled),
454
+ ghTokenSet: ghTokenMask.set,
455
+ ghTokenMasked: ghTokenMask.masked,
456
+ ghAuthMethod: ghAuth.ok ? ghAuth.method : null,
457
+ originRemoteUrl: origin.url,
458
+ originRemoteSsh: origin.ssh,
459
+ claudeAuthOk: claudeAuth.ok === true,
460
+ claudeAuthMethod: publicClaudeAuthMethod(claudeAuth),
461
+ anthropicApiKeySet: anthropicMask.set,
462
+ anthropicApiKeyMasked: anthropicMask.masked,
463
+ claudeOauthTokenSet: claudeOauthMask.set,
464
+ claudeOauthTokenMasked: claudeOauthMask.masked,
465
+ stubAgent: opts.stubAgent === true,
424
466
  ...(repoName ? { repoName } : {}),
425
467
  };
426
468
  }
package/src/env.js CHANGED
@@ -6,7 +6,7 @@ import { dataDir } from './paths.js';
6
6
  /**
7
7
  * Load environment variables from `.acdev/.env` in the repo root.
8
8
  * Existing process.env values take precedence (standard dotenv behavior).
9
- * Loads ANTHROPIC_*, CLAUDE_*, JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN, etc.
9
+ * Loads ANTHROPIC_*, CLAUDE_*, GH_TOKEN, GITHUB_TOKEN, JIRA_*, etc.
10
10
  * @param {string} repoRoot
11
11
  */
12
12
  export function loadEnv(repoRoot) {
package/src/gh-auth.js CHANGED
@@ -1,8 +1,25 @@
1
1
  import { execFileSync, execSync } from 'node:child_process';
2
2
 
3
+ /** @typedef {'token' | 'gh-login'} GhAuthMethod */
4
+ /** @typedef {{
5
+ * ok: true,
6
+ * ghInstalled: true,
7
+ * tokenSet: boolean,
8
+ * method: GhAuthMethod,
9
+ * } | {
10
+ * ok: false,
11
+ * reason: 'not-found' | 'not-authenticated',
12
+ * ghInstalled: boolean,
13
+ * tokenSet: boolean,
14
+ * method: null,
15
+ * }} GhAuthResult */
16
+
3
17
  /** @type {() => string | null} */
4
18
  let ghBinaryResolver = findGhBinary;
5
19
 
20
+ /** @type {() => NodeJS.ProcessEnv} */
21
+ let envResolver = () => process.env;
22
+
6
23
  /** @param {() => string | null} fn */
7
24
  export function _setGhBinaryResolver(fn) {
8
25
  ghBinaryResolver = fn;
@@ -12,6 +29,15 @@ export function _resetGhBinaryResolver() {
12
29
  ghBinaryResolver = findGhBinary;
13
30
  }
14
31
 
32
+ /** @param {() => NodeJS.ProcessEnv} fn */
33
+ export function _setEnvResolver(fn) {
34
+ envResolver = fn;
35
+ }
36
+
37
+ export function _resetEnvResolver() {
38
+ envResolver = () => process.env;
39
+ }
40
+
15
41
  function findGhBinary() {
16
42
  try {
17
43
  return execSync('command -v gh', { encoding: 'utf8', shell: true }).trim();
@@ -21,21 +47,112 @@ function findGhBinary() {
21
47
  }
22
48
 
23
49
  /**
24
- * Verify GitHub CLI is installed and the active github.com account is authenticated.
25
- * @returns {{ ok: true } | { ok: false, reason: 'not-found' | 'not-authenticated' }}
50
+ * Token gh honors for github.com API calls (`GH_TOKEN` then `GITHUB_TOKEN`).
51
+ * @param {NodeJS.ProcessEnv} [env]
52
+ * @returns {string}
53
+ */
54
+ export function githubTokenFromEnv(env = process.env) {
55
+ return String(env.GH_TOKEN || env.GITHUB_TOKEN || '').trim();
56
+ }
57
+
58
+ /**
59
+ * SSH remotes can push/fetch git; they do not authenticate `gh` for issues/PRs.
60
+ * @param {string | null | undefined} url
61
+ */
62
+ export function originRemoteLooksSsh(url) {
63
+ return /^(?:git@|ssh:\/\/)/i.test(String(url || ''));
64
+ }
65
+
66
+ /**
67
+ * Read-only origin remote URL (SSH vs HTTPS). SSH is not gh API auth.
68
+ * @param {string | undefined | null} repoRoot
69
+ * @returns {{ url: string | null, ssh: boolean }}
70
+ */
71
+ export function originRemoteInfo(repoRoot) {
72
+ if (!repoRoot) return { url: null, ssh: false };
73
+ try {
74
+ const url = execFileSync('git', ['-C', repoRoot, 'remote', 'get-url', 'origin'], {
75
+ encoding: 'utf8',
76
+ stdio: ['ignore', 'pipe', 'pipe'],
77
+ }).trim();
78
+ if (!url) return { url: null, ssh: false };
79
+ return { url, ssh: originRemoteLooksSsh(url) };
80
+ } catch {
81
+ return { url: null, ssh: false };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Verify GitHub CLI is installed and github.com is authenticated.
87
+ *
88
+ * Success covers interactive `gh auth login` and a PAT in `GH_TOKEN` /
89
+ * `GITHUB_TOKEN` (gh reads those env vars). SSH remotes are not checked here.
90
+ *
91
+ * @returns {GhAuthResult}
26
92
  */
27
93
  export function checkGhAuth() {
94
+ const env = { ...process.env, ...envResolver() };
95
+ const token = githubTokenFromEnv(env);
96
+ const tokenSet = Boolean(token);
97
+ if (token) {
98
+ env.GH_TOKEN = token;
99
+ }
100
+
28
101
  const gh = ghBinaryResolver();
29
102
  if (!gh) {
30
- return { ok: false, reason: 'not-found' };
103
+ return {
104
+ ok: false,
105
+ reason: 'not-found',
106
+ ghInstalled: false,
107
+ tokenSet,
108
+ method: null,
109
+ };
31
110
  }
32
111
 
33
112
  try {
34
- execFileSync(gh, ['auth', 'status', '--hostname', 'github.com', '--active'], {
113
+ // `--active` refers to stored gh accounts. Env tokens are not "active"
114
+ // in hosts.yml, so omit it when GH_TOKEN / GITHUB_TOKEN is set.
115
+ const args = ['auth', 'status', '--hostname', 'github.com'];
116
+ if (!tokenSet) {
117
+ args.push('--active');
118
+ }
119
+ execFileSync(gh, args, {
35
120
  stdio: 'ignore',
121
+ env,
36
122
  });
37
- return { ok: true };
123
+ return {
124
+ ok: true,
125
+ ghInstalled: true,
126
+ tokenSet,
127
+ method: tokenSet ? 'token' : 'gh-login',
128
+ };
38
129
  } catch {
39
- return { ok: false, reason: 'not-authenticated' };
130
+ return {
131
+ ok: false,
132
+ reason: 'not-authenticated',
133
+ ghInstalled: true,
134
+ tokenSet,
135
+ method: null,
136
+ };
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Human-readable startup error for a failed {@link checkGhAuth}.
142
+ * @param {GhAuthResult} result
143
+ */
144
+ export function formatGhAuthError(result) {
145
+ if (result.reason === 'not-found') {
146
+ return '✖ GitHub CLI (gh) not found in PATH. Install it from https://cli.github.com/';
40
147
  }
148
+ return [
149
+ '✖ GitHub CLI is not authenticated for github.com.',
150
+ '',
151
+ 'Authenticate with one of:',
152
+ ' 1. Interactive (browser/device): gh auth login -h github.com',
153
+ ' 2. Personal access token: Settings → GitHub / PR auth, or set GH_TOKEN in `.acdev/.env`',
154
+ ' 3. Non-interactive CLI: echo YOUR_PAT | gh auth login --with-token -h github.com',
155
+ '',
156
+ 'SSH remotes can push/fetch git, but do not authenticate gh for issues or PRs.',
157
+ ].join('\n');
41
158
  }