acdev 1.0.0

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,81 @@
1
+ import { execFileSync, execSync } from 'node:child_process';
2
+
3
+ /** @typedef {'api-key' | 'auth-token' | 'oauth-token' | 'claude-code-login'} ClaudeAuthMethod */
4
+ /** @typedef {{ ok: true, method: ClaudeAuthMethod } | { ok: false, reason: 'missing' | 'claude-not-authenticated' }} ClaudeAuthResult */
5
+
6
+ /** @type {() => string | null} */
7
+ let claudeBinaryResolver = findClaudeBinary;
8
+
9
+ /** @type {() => NodeJS.ProcessEnv} */
10
+ let envResolver = () => process.env;
11
+
12
+ /** @param {() => string | null} fn */
13
+ export function _setClaudeBinaryResolver(fn) {
14
+ claudeBinaryResolver = fn;
15
+ }
16
+
17
+ export function _resetClaudeBinaryResolver() {
18
+ claudeBinaryResolver = findClaudeBinary;
19
+ }
20
+
21
+ /** @param {() => NodeJS.ProcessEnv} fn */
22
+ export function _setEnvResolver(fn) {
23
+ envResolver = fn;
24
+ }
25
+
26
+ export function _resetEnvResolver() {
27
+ envResolver = () => process.env;
28
+ }
29
+
30
+ function findClaudeBinary() {
31
+ try {
32
+ return execSync('command -v claude', { encoding: 'utf8', shell: true }).trim();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Whether Claude Agent SDK / Claude Code can authenticate without prompting.
40
+ *
41
+ * Supported (same precedence as Claude Code docs, simplified for startup checks):
42
+ * - ANTHROPIC_API_KEY — Console API billing
43
+ * - ANTHROPIC_AUTH_TOKEN — bearer / gateway
44
+ * - CLAUDE_CODE_OAUTH_TOKEN — long-lived token from `claude setup-token` (subscription)
45
+ * - Claude Code CLI login — `claude auth login` (browser / Pro·Max subscription)
46
+ *
47
+ * @returns {ClaudeAuthResult}
48
+ */
49
+ export function checkClaudeAuth() {
50
+ const env = envResolver();
51
+
52
+ if (env.ANTHROPIC_API_KEY) {
53
+ return { ok: true, method: 'api-key' };
54
+ }
55
+ if (env.ANTHROPIC_AUTH_TOKEN) {
56
+ return { ok: true, method: 'auth-token' };
57
+ }
58
+ if (env.CLAUDE_CODE_OAUTH_TOKEN) {
59
+ return { ok: true, method: 'oauth-token' };
60
+ }
61
+
62
+ const claude = claudeBinaryResolver();
63
+ if (!claude) {
64
+ return { ok: false, reason: 'missing' };
65
+ }
66
+
67
+ try {
68
+ const stdout = execFileSync(claude, ['auth', 'status', '--json'], {
69
+ encoding: 'utf8',
70
+ stdio: ['ignore', 'pipe', 'pipe'],
71
+ });
72
+ const status = JSON.parse(stdout);
73
+ if (status?.loggedIn === true) {
74
+ return { ok: true, method: 'claude-code-login' };
75
+ }
76
+ } catch {
77
+ // not logged in, or status command failed
78
+ }
79
+
80
+ return { ok: false, reason: 'claude-not-authenticated' };
81
+ }
package/src/config.js ADDED
@@ -0,0 +1,426 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { maskSecret } from './env.js';
4
+ import { normalizeJiraBaseUrl } from './jira.js';
5
+ import { dataDir } from './paths.js';
6
+
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
+
15
+ /** Tool names the agent may be granted via config. */
16
+ export const KNOWN_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
17
+
18
+ export const TICKET_SOURCES = /** @type {const} */ (['github', 'jira']);
19
+
20
+ /** GitHub post-PR actions (issues have no Jira-like workflow statuses). */
21
+ export const GITHUB_AFTER_PR_ACTIONS = /** @type {const} */ ([
22
+ 'none',
23
+ 'add_label',
24
+ 'close_issue',
25
+ ]);
26
+
27
+ const ALLOWED_MODELS = new Set(MODEL_OPTIONS.map((m) => m.id));
28
+ const ALLOWED_TOOLS_SET = new Set(KNOWN_TOOLS);
29
+ const ALLOWED_TICKET_SOURCES = new Set(TICKET_SOURCES);
30
+ const ALLOWED_GITHUB_AFTER_PR_ACTIONS = new Set(GITHUB_AFTER_PR_ACTIONS);
31
+
32
+ const DEFAULT_JIRA_RULES = {
33
+ afterPrOpened: {
34
+ enabled: false,
35
+ targetStatus: 'In Review',
36
+ },
37
+ };
38
+
39
+ const DEFAULT_GITHUB_RULES = {
40
+ afterPrOpened: {
41
+ enabled: false,
42
+ action: /** @type {'none' | 'add_label' | 'close_issue'} */ ('none'),
43
+ label: '',
44
+ },
45
+ };
46
+
47
+ const DEFAULTS = {
48
+ baseBranch: 'develop',
49
+ testCommand: null,
50
+ maxAgentTurns: 30,
51
+ allowedTools: [...KNOWN_TOOLS],
52
+ agentTimeoutMs: 900_000,
53
+ model: DEFAULT_MODEL,
54
+ ticketSource: /** @type {'github' | 'jira'} */ ('github'),
55
+ jiraBaseUrl: '',
56
+ /** PR body phrase for Jira tickets, e.g. "Relates to PROJ-123". */
57
+ jiraPrLinkPhrase: 'Relates to',
58
+ jiraRules: structuredClone(DEFAULT_JIRA_RULES),
59
+ githubRules: structuredClone(DEFAULT_GITHUB_RULES),
60
+ };
61
+
62
+ /**
63
+ * Normalize / validate jiraRules from config or PATCH body.
64
+ * @param {unknown} raw
65
+ * @param {{ throwOnInvalid?: boolean }} [opts]
66
+ * @returns {typeof DEFAULT_JIRA_RULES}
67
+ */
68
+ export function normalizeJiraRules(raw, opts = {}) {
69
+ const throwOnInvalid = opts.throwOnInvalid === true;
70
+ const base = structuredClone(DEFAULT_JIRA_RULES);
71
+ if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
72
+ if (throwOnInvalid && raw != null) {
73
+ throw new Error('jiraRules must be an object');
74
+ }
75
+ return base;
76
+ }
77
+ /** @type {Record<string, unknown>} */
78
+ const obj = /** @type {Record<string, unknown>} */ (raw);
79
+ const after =
80
+ obj.afterPrOpened != null && typeof obj.afterPrOpened === 'object' && !Array.isArray(obj.afterPrOpened)
81
+ ? /** @type {Record<string, unknown>} */ (obj.afterPrOpened)
82
+ : {};
83
+
84
+ if (throwOnInvalid && obj.afterPrOpened != null && (typeof obj.afterPrOpened !== 'object' || Array.isArray(obj.afterPrOpened))) {
85
+ throw new Error('jiraRules.afterPrOpened must be an object');
86
+ }
87
+
88
+ if (after.enabled !== undefined) {
89
+ if (typeof after.enabled !== 'boolean') {
90
+ if (throwOnInvalid) throw new Error('jiraRules.afterPrOpened.enabled must be a boolean');
91
+ } else {
92
+ base.afterPrOpened.enabled = after.enabled;
93
+ }
94
+ }
95
+
96
+ if (after.targetStatus !== undefined) {
97
+ if (typeof after.targetStatus !== 'string') {
98
+ if (throwOnInvalid) throw new Error('jiraRules.afterPrOpened.targetStatus must be a string');
99
+ } else {
100
+ const trimmed = after.targetStatus.trim();
101
+ if (!trimmed) {
102
+ if (throwOnInvalid) {
103
+ throw new Error('jiraRules.afterPrOpened.targetStatus must be a non-empty string');
104
+ }
105
+ } else {
106
+ base.afterPrOpened.targetStatus = trimmed;
107
+ }
108
+ }
109
+ }
110
+
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
+ if (after.label !== undefined) {
161
+ if (typeof after.label !== 'string') {
162
+ if (throwOnInvalid) throw new Error('githubRules.afterPrOpened.label must be a string');
163
+ } else {
164
+ base.afterPrOpened.label = after.label.trim();
165
+ }
166
+ }
167
+
168
+ if (
169
+ throwOnInvalid &&
170
+ base.afterPrOpened.enabled &&
171
+ base.afterPrOpened.action === 'add_label' &&
172
+ !base.afterPrOpened.label
173
+ ) {
174
+ throw new Error('githubRules.afterPrOpened.label is required when action is add_label');
175
+ }
176
+
177
+ return base;
178
+ }
179
+
180
+ /**
181
+ * Persist config to `.acdev/config.json`.
182
+ * @param {string} repoRoot
183
+ * @param {object} config
184
+ */
185
+ export function saveConfig(repoRoot, config) {
186
+ const configDir = dataDir(repoRoot);
187
+ const configPath = path.join(configDir, 'config.json');
188
+ if (!fs.existsSync(configDir)) {
189
+ fs.mkdirSync(configDir, { recursive: true });
190
+ }
191
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
192
+ }
193
+
194
+ function persistable(config) {
195
+ return {
196
+ baseBranch: config.baseBranch,
197
+ testCommand: config.testCommand,
198
+ maxAgentTurns: config.maxAgentTurns,
199
+ allowedTools: config.allowedTools,
200
+ agentTimeoutMs: config.agentTimeoutMs,
201
+ model: config.model,
202
+ ticketSource: config.ticketSource,
203
+ jiraBaseUrl: config.jiraBaseUrl || '',
204
+ jiraPrLinkPhrase: config.jiraPrLinkPhrase || 'Relates to',
205
+ jiraRules: normalizeJiraRules(config.jiraRules),
206
+ githubRules: normalizeGithubRules(config.githubRules),
207
+ };
208
+ }
209
+
210
+ /**
211
+ * @param {unknown} value
212
+ * @param {string} field
213
+ * @returns {number}
214
+ */
215
+ function positiveInt(value, field) {
216
+ const n = typeof value === 'number' ? value : Number(value);
217
+ if (!Number.isInteger(n) || n <= 0) {
218
+ throw new Error(`${field} must be a positive integer`);
219
+ }
220
+ return n;
221
+ }
222
+
223
+ /**
224
+ * Load or create `.acdev/config.json` inside the repo root.
225
+ * Unknown / removed model IDs are mapped to {@link DEFAULT_MODEL}.
226
+ * @param {string} repoRoot
227
+ * @returns {typeof DEFAULTS}
228
+ */
229
+ export function loadConfig(repoRoot) {
230
+ const configDir = dataDir(repoRoot);
231
+ const configPath = path.join(configDir, 'config.json');
232
+
233
+ if (!fs.existsSync(configDir)) {
234
+ fs.mkdirSync(configDir, { recursive: true });
235
+ }
236
+
237
+ if (!fs.existsSync(configPath)) {
238
+ const initial = {
239
+ ...DEFAULTS,
240
+ allowedTools: [...KNOWN_TOOLS],
241
+ jiraRules: structuredClone(DEFAULT_JIRA_RULES),
242
+ githubRules: structuredClone(DEFAULT_GITHUB_RULES),
243
+ };
244
+ fs.writeFileSync(configPath, JSON.stringify(persistable(initial), null, 2) + '\n', 'utf8');
245
+ return initial;
246
+ }
247
+
248
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
249
+ const config = {
250
+ ...DEFAULTS,
251
+ ...raw,
252
+ allowedTools: raw.allowedTools ?? [...KNOWN_TOOLS],
253
+ jiraRules: normalizeJiraRules(raw.jiraRules),
254
+ githubRules: normalizeGithubRules(raw.githubRules),
255
+ };
256
+
257
+ if (!ALLOWED_MODELS.has(config.model)) {
258
+ config.model = DEFAULT_MODEL;
259
+ saveConfig(repoRoot, persistable(config));
260
+ }
261
+
262
+ if (!ALLOWED_TICKET_SOURCES.has(config.ticketSource)) {
263
+ config.ticketSource = 'github';
264
+ }
265
+
266
+ if (typeof config.jiraBaseUrl !== 'string') {
267
+ config.jiraBaseUrl = '';
268
+ }
269
+
270
+ if (typeof config.jiraPrLinkPhrase !== 'string' || !config.jiraPrLinkPhrase.trim()) {
271
+ config.jiraPrLinkPhrase = 'Relates to';
272
+ }
273
+
274
+ return config;
275
+ }
276
+
277
+ /**
278
+ * Apply a partial update, persist, and return the new config.
279
+ * Mutates `config` in place so the running server picks up changes.
280
+ * @param {string} repoRoot
281
+ * @param {object} config
282
+ * @param {Partial<typeof DEFAULTS>} patch
283
+ * @returns {typeof DEFAULTS}
284
+ */
285
+ export function updateConfig(repoRoot, config, patch) {
286
+ if (patch.model != null) {
287
+ if (!ALLOWED_MODELS.has(patch.model)) {
288
+ throw new Error(
289
+ `Invalid model "${patch.model}". Allowed: ${[...ALLOWED_MODELS].join(', ')}`
290
+ );
291
+ }
292
+ config.model = patch.model;
293
+ }
294
+
295
+ if (patch.baseBranch !== undefined) {
296
+ if (typeof patch.baseBranch !== 'string' || !patch.baseBranch.trim()) {
297
+ throw new Error('baseBranch must be a non-empty string');
298
+ }
299
+ config.baseBranch = patch.baseBranch.trim();
300
+ }
301
+
302
+ if (patch.testCommand !== undefined) {
303
+ if (patch.testCommand === null) {
304
+ config.testCommand = null;
305
+ } else if (typeof patch.testCommand === 'string') {
306
+ const trimmed = patch.testCommand.trim();
307
+ config.testCommand = trimmed === '' ? null : trimmed;
308
+ } else {
309
+ throw new Error('testCommand must be a string or null');
310
+ }
311
+ }
312
+
313
+ if (patch.maxAgentTurns !== undefined && patch.maxAgentTurns !== null) {
314
+ config.maxAgentTurns = positiveInt(patch.maxAgentTurns, 'maxAgentTurns');
315
+ }
316
+
317
+ if (patch.agentTimeoutMs !== undefined && patch.agentTimeoutMs !== null) {
318
+ config.agentTimeoutMs = positiveInt(patch.agentTimeoutMs, 'agentTimeoutMs');
319
+ }
320
+
321
+ if (patch.allowedTools !== undefined) {
322
+ if (!Array.isArray(patch.allowedTools)) {
323
+ throw new Error('allowedTools must be an array of tool names');
324
+ }
325
+ const unknown = patch.allowedTools.filter((t) => !ALLOWED_TOOLS_SET.has(t));
326
+ if (unknown.length > 0) {
327
+ throw new Error(
328
+ `Invalid allowedTools: ${unknown.join(', ')}. Allowed: ${KNOWN_TOOLS.join(', ')}`
329
+ );
330
+ }
331
+ // Preserve known order; drop duplicates
332
+ const selected = new Set(patch.allowedTools);
333
+ config.allowedTools = KNOWN_TOOLS.filter((t) => selected.has(t));
334
+ }
335
+
336
+ if (patch.ticketSource !== undefined) {
337
+ if (!ALLOWED_TICKET_SOURCES.has(patch.ticketSource)) {
338
+ throw new Error(
339
+ `Invalid ticketSource "${patch.ticketSource}". Allowed: ${TICKET_SOURCES.join(', ')}`
340
+ );
341
+ }
342
+ config.ticketSource = patch.ticketSource;
343
+ }
344
+
345
+ if (patch.jiraBaseUrl !== undefined) {
346
+ if (patch.jiraBaseUrl === null || patch.jiraBaseUrl === '') {
347
+ config.jiraBaseUrl = '';
348
+ } else if (typeof patch.jiraBaseUrl === 'string') {
349
+ config.jiraBaseUrl = normalizeJiraBaseUrl(patch.jiraBaseUrl);
350
+ } else {
351
+ throw new Error('jiraBaseUrl must be a string');
352
+ }
353
+ }
354
+
355
+ if (patch.jiraPrLinkPhrase !== undefined) {
356
+ if (typeof patch.jiraPrLinkPhrase !== 'string' || !patch.jiraPrLinkPhrase.trim()) {
357
+ throw new Error('jiraPrLinkPhrase must be a non-empty string');
358
+ }
359
+ config.jiraPrLinkPhrase = patch.jiraPrLinkPhrase.trim();
360
+ }
361
+
362
+ if (patch.jiraRules !== undefined) {
363
+ config.jiraRules = normalizeJiraRules(patch.jiraRules, { throwOnInvalid: true });
364
+ }
365
+
366
+ if (patch.githubRules !== undefined) {
367
+ config.githubRules = normalizeGithubRules(patch.githubRules, { throwOnInvalid: true });
368
+ }
369
+
370
+ saveConfig(repoRoot, persistable(config));
371
+ return config;
372
+ }
373
+
374
+ /**
375
+ * Public-safe config snapshot for the UI / API.
376
+ * Never returns the full Jira API token.
377
+ * @param {object} config
378
+ * @param {{ repoRoot?: string }} [opts]
379
+ */
380
+ export function publicConfig(config, opts = {}) {
381
+ const model = ALLOWED_MODELS.has(config.model) ? config.model : DEFAULT_MODEL;
382
+ const repoName = opts.repoRoot ? path.basename(opts.repoRoot) : undefined;
383
+ const ticketSource = ALLOWED_TICKET_SOURCES.has(config.ticketSource)
384
+ ? config.ticketSource
385
+ : 'github';
386
+
387
+ const envBase = (process.env.JIRA_BASE_URL || '').trim();
388
+ const jiraBaseUrl =
389
+ (config.jiraBaseUrl || '').trim() ||
390
+ (envBase ? (() => {
391
+ try {
392
+ return normalizeJiraBaseUrl(envBase);
393
+ } catch {
394
+ return envBase;
395
+ }
396
+ })() : '');
397
+
398
+ const jiraEmail = (process.env.JIRA_EMAIL || '').trim();
399
+ const tokenMask = maskSecret(process.env.JIRA_API_TOKEN);
400
+ const jiraConfigured = Boolean(
401
+ jiraBaseUrl && jiraEmail && tokenMask.set
402
+ );
403
+
404
+ return {
405
+ model,
406
+ baseBranch: config.baseBranch,
407
+ testCommand: config.testCommand ?? null,
408
+ maxAgentTurns: config.maxAgentTurns,
409
+ agentTimeoutMs: config.agentTimeoutMs,
410
+ allowedTools: Array.isArray(config.allowedTools)
411
+ ? [...config.allowedTools]
412
+ : [...KNOWN_TOOLS],
413
+ models: MODEL_OPTIONS,
414
+ knownTools: KNOWN_TOOLS,
415
+ ticketSource,
416
+ jiraBaseUrl,
417
+ jiraPrLinkPhrase: config.jiraPrLinkPhrase || 'Relates to',
418
+ jiraRules: normalizeJiraRules(config.jiraRules),
419
+ githubRules: normalizeGithubRules(config.githubRules),
420
+ jiraEmail: jiraEmail || null,
421
+ jiraApiTokenSet: tokenMask.set,
422
+ jiraApiTokenMasked: tokenMask.masked,
423
+ jiraConfigured,
424
+ ...(repoName ? { repoName } : {}),
425
+ };
426
+ }
package/src/env.js ADDED
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import dotenv from 'dotenv';
4
+ import { dataDir } from './paths.js';
5
+
6
+ /**
7
+ * Load environment variables from `.acdev/.env` in the repo root.
8
+ * Existing process.env values take precedence (standard dotenv behavior).
9
+ * Loads ANTHROPIC_*, CLAUDE_*, JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN, etc.
10
+ * @param {string} repoRoot
11
+ */
12
+ export function loadEnv(repoRoot) {
13
+ const envPath = path.join(dataDir(repoRoot), '.env');
14
+ if (!fs.existsSync(envPath)) {
15
+ return;
16
+ }
17
+ dotenv.config({ path: envPath, quiet: true });
18
+ }
19
+
20
+ /**
21
+ * Path to `.acdev/.env`.
22
+ * @param {string} repoRoot
23
+ */
24
+ export function envFilePath(repoRoot) {
25
+ return path.join(dataDir(repoRoot), '.env');
26
+ }
27
+
28
+ /**
29
+ * Upsert key=value pairs in `.acdev/.env` without clobbering unrelated keys.
30
+ * Updates process.env for the written keys.
31
+ * @param {string} repoRoot
32
+ * @param {Record<string, string | null | undefined>} vars
33
+ * Pass `null` or `''` to remove a key.
34
+ */
35
+ export function upsertEnvVars(repoRoot, vars) {
36
+ const envPath = envFilePath(repoRoot);
37
+ const dir = path.dirname(envPath);
38
+ if (!fs.existsSync(dir)) {
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ }
41
+
42
+ /** @type {Map<string, string>} */
43
+ const map = new Map();
44
+ /** @type {string[]} */
45
+ let preamble = [];
46
+
47
+ if (fs.existsSync(envPath)) {
48
+ const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/);
49
+ /** @type {string[]} */
50
+ const keptPreamble = [];
51
+ for (const line of lines) {
52
+ const trimmed = line.trim();
53
+ if (!trimmed || trimmed.startsWith('#')) {
54
+ if (map.size === 0) keptPreamble.push(line);
55
+ continue;
56
+ }
57
+ const eq = trimmed.indexOf('=');
58
+ if (eq === -1) continue;
59
+ const key = trimmed.slice(0, eq).trim();
60
+ let value = trimmed.slice(eq + 1);
61
+ if (
62
+ (value.startsWith('"') && value.endsWith('"')) ||
63
+ (value.startsWith("'") && value.endsWith("'"))
64
+ ) {
65
+ value = value.slice(1, -1);
66
+ }
67
+ map.set(key, value);
68
+ }
69
+ preamble = keptPreamble;
70
+ }
71
+
72
+ for (const [key, value] of Object.entries(vars)) {
73
+ if (value == null || value === '') {
74
+ map.delete(key);
75
+ delete process.env[key];
76
+ } else {
77
+ map.set(key, String(value));
78
+ process.env[key] = String(value);
79
+ }
80
+ }
81
+
82
+ const body = [...map.entries()].map(([k, v]) => `${k}=${v}`).join('\n');
83
+ const header = preamble.length ? preamble.join('\n').replace(/\n+$/, '') + '\n' : '';
84
+ const content = header + (body ? body + '\n' : '');
85
+ fs.writeFileSync(envPath, content, 'utf8');
86
+ }
87
+
88
+ /**
89
+ * Mask a secret for API responses.
90
+ * @param {string | undefined | null} value
91
+ * @returns {{ set: boolean, masked: string | null }}
92
+ */
93
+ export function maskSecret(value) {
94
+ const v = String(value || '');
95
+ if (!v) return { set: false, masked: null };
96
+ if (v.length <= 4) return { set: true, masked: '••••' };
97
+ return { set: true, masked: `${'•'.repeat(Math.min(8, v.length - 4))}${v.slice(-4)}` };
98
+ }
package/src/gh-auth.js ADDED
@@ -0,0 +1,41 @@
1
+ import { execFileSync, execSync } from 'node:child_process';
2
+
3
+ /** @type {() => string | null} */
4
+ let ghBinaryResolver = findGhBinary;
5
+
6
+ /** @param {() => string | null} fn */
7
+ export function _setGhBinaryResolver(fn) {
8
+ ghBinaryResolver = fn;
9
+ }
10
+
11
+ export function _resetGhBinaryResolver() {
12
+ ghBinaryResolver = findGhBinary;
13
+ }
14
+
15
+ function findGhBinary() {
16
+ try {
17
+ return execSync('command -v gh', { encoding: 'utf8', shell: true }).trim();
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ /**
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' }}
26
+ */
27
+ export function checkGhAuth() {
28
+ const gh = ghBinaryResolver();
29
+ if (!gh) {
30
+ return { ok: false, reason: 'not-found' };
31
+ }
32
+
33
+ try {
34
+ execFileSync(gh, ['auth', 'status', '--hostname', 'github.com', '--active'], {
35
+ stdio: 'ignore',
36
+ });
37
+ return { ok: true };
38
+ } catch {
39
+ return { ok: false, reason: 'not-authenticated' };
40
+ }
41
+ }