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.
- package/.acdev/.env.example +8 -2
- package/README.md +66 -24
- package/bin/acdev.js +13 -25
- package/package.json +1 -1
- package/public/app.js +469 -93
- package/public/index.html +201 -63
- package/public/styles.css +105 -21
- package/src/afterPrRules.js +63 -8
- package/src/claude-auth.js +30 -0
- package/src/config.js +130 -88
- package/src/env.js +1 -1
- package/src/gh-auth.js +123 -6
- package/src/jira.js +171 -0
- package/src/models.js +366 -0
- package/src/server.js +142 -13
package/src/claude-auth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
79
|
+
function normalizeAfterPrRules(prefix, raw, opts = {}) {
|
|
69
80
|
const throwOnInvalid = opts.throwOnInvalid === true;
|
|
70
|
-
const base =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
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 (!
|
|
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 (!
|
|
301
|
+
if (!isValidModelId(patch.model)) {
|
|
288
302
|
throw new Error(
|
|
289
|
-
`Invalid model "${patch.model}".
|
|
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
|
|
390
|
+
* Never returns full secrets (Jira token, GH PAT, Anthropic keys).
|
|
377
391
|
* @param {object} config
|
|
378
|
-
* @param {{
|
|
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 =
|
|
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_*,
|
|
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
|
-
*
|
|
25
|
-
* @
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
ghInstalled: true,
|
|
126
|
+
tokenSet,
|
|
127
|
+
method: tokenSet ? 'token' : 'gh-login',
|
|
128
|
+
};
|
|
38
129
|
} catch {
|
|
39
|
-
return {
|
|
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
|
}
|