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/src/server.js CHANGED
@@ -33,8 +33,11 @@ import {
33
33
  } from './agent.js';
34
34
  import { publicConfig, updateConfig } from './config.js';
35
35
  import { upsertEnvVars } from './env.js';
36
+ import { listModels } from './models.js';
36
37
  import { splitIssueUrls } from './urls.js';
37
38
  import { usageFromLogs, withJobUsage } from './usage.js';
39
+ import { checkGhAuth } from './gh-auth.js';
40
+ import { checkClaudeAuth } from './claude-auth.js';
38
41
 
39
42
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
40
43
 
@@ -76,6 +79,23 @@ function jobDedupeKey(job) {
76
79
  }
77
80
  return `github:${job.issueUrl}`;
78
81
  }
82
+
83
+ /**
84
+ * Empty string = leave the existing secret unchanged; null = clear.
85
+ * @param {Record<string, string | null>} envPatch
86
+ * @param {string} envKey
87
+ * @param {unknown} value
88
+ */
89
+ function applySecretField(envPatch, envKey, value) {
90
+ if (value === undefined) return;
91
+ if (value === null) {
92
+ envPatch[envKey] = null;
93
+ return;
94
+ }
95
+ if (typeof value === 'string' && value.trim()) {
96
+ envPatch[envKey] = value.trim();
97
+ }
98
+ }
79
99
  /**
80
100
  * Normalize + validate POST /api/jobs/:id/review body.
81
101
  * @param {unknown} body
@@ -146,9 +166,13 @@ export function normalizeReviewComments(body) {
146
166
  * listChangedFiles?: typeof listChangedFiles,
147
167
  * applyFileExclusions?: typeof applyFileExclusions,
148
168
  * transitionJiraIssue?: Function,
169
+ * addJiraIssueLabel?: Function,
170
+ * closeJiraIssue?: Function,
149
171
  * addIssueLabel?: Function,
150
172
  * closeIssue?: Function,
151
173
  * resolveJiraCredentials?: Function,
174
+ * checkGhAuth?: typeof checkGhAuth,
175
+ * checkClaudeAuth?: typeof checkClaudeAuth,
152
176
  * },
153
177
  * }} options
154
178
  */
@@ -162,6 +186,52 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
162
186
  const doGetDiff = deps.getDiff || getDiff;
163
187
  const doListChangedFiles = deps.listChangedFiles || listChangedFiles;
164
188
  const doApplyFileExclusions = deps.applyFileExclusions || applyFileExclusions;
189
+ const doCheckGhAuth = deps.checkGhAuth || checkGhAuth;
190
+ const doCheckClaudeAuth = deps.checkClaudeAuth || checkClaudeAuth;
191
+
192
+ /**
193
+ * Reject enqueue / agent / PR actions when required auth is missing.
194
+ * @param {{ needGh?: boolean, needClaude?: boolean }} [opts]
195
+ * @returns {{ status: number, error: string, code: string } | null}
196
+ */
197
+ function authGate(opts = {}) {
198
+ const needGh = opts.needGh !== false;
199
+ const needClaude = opts.needClaude === true && !useStubAgent;
200
+
201
+ if (needGh) {
202
+ const gh = doCheckGhAuth();
203
+ if (!gh.ok) {
204
+ const error =
205
+ gh.reason === 'not-found'
206
+ ? 'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/, then restart acdev.'
207
+ : 'GitHub is not authenticated. Configure a PAT in Settings → Authentication, or run gh auth login.';
208
+ return { status: 400, error, code: 'gh_auth_required' };
209
+ }
210
+ }
211
+
212
+ if (needClaude) {
213
+ const claude = doCheckClaudeAuth();
214
+ if (!claude.ok) {
215
+ return {
216
+ status: 400,
217
+ error:
218
+ 'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, run claude auth login, or start with --stub-agent.',
219
+ code: 'claude_auth_required',
220
+ };
221
+ }
222
+ }
223
+
224
+ return null;
225
+ }
226
+
227
+ function publicConfigPayload() {
228
+ return publicConfig(config, {
229
+ repoRoot,
230
+ stubAgent: useStubAgent,
231
+ ghAuth: doCheckGhAuth(),
232
+ claudeAuth: doCheckClaudeAuth(),
233
+ });
234
+ }
165
235
 
166
236
  /** @type {Map<string, Set<import('http').ServerResponse>>} */
167
237
  const subscribers = new Map();
@@ -201,6 +271,17 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
201
271
  let job = store.getJob(jobId);
202
272
  if (!job) return;
203
273
 
274
+ const gate = authGate({ needGh: true, needClaude: true });
275
+ if (gate) {
276
+ appendLog(job, 'error', gate.error);
277
+ store.updateJob(jobId, { status: 'failed', error: gate.error });
278
+ const updated = store.getJob(jobId);
279
+ if (updated?.logs?.length) {
280
+ emitEvent(jobId, updated.logs[updated.logs.length - 1]);
281
+ }
282
+ return;
283
+ }
284
+
204
285
  try {
205
286
  job = setStatus(jobId, 'syncing');
206
287
  if (!job) return;
@@ -329,6 +410,21 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
329
410
  let job = store.getJob(jobId);
330
411
  if (!job || job.status !== 'applying_feedback') return;
331
412
 
413
+ const gate = authGate({ needGh: false, needClaude: true });
414
+ if (gate) {
415
+ appendLog(job, 'error', gate.error);
416
+ store.updateJob(jobId, {
417
+ status: 'failed',
418
+ error: gate.error,
419
+ pendingReviewFeedback: undefined,
420
+ });
421
+ const updated = store.getJob(jobId);
422
+ if (updated?.logs?.length) {
423
+ emitEvent(jobId, updated.logs[updated.logs.length - 1]);
424
+ }
425
+ return;
426
+ }
427
+
332
428
  const feedback = job.pendingReviewFeedback;
333
429
  if (!feedback || (!feedback.generalComment && !(feedback.lineComments || []).length)) {
334
430
  const message = 'Missing review feedback payload';
@@ -441,7 +537,20 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
441
537
 
442
538
  app.get('/api/config', (_req, res) => {
443
539
  try {
444
- res.json(publicConfig(config, { repoRoot }));
540
+ res.json(publicConfigPayload());
541
+ } catch (err) {
542
+ res.status(500).json({ error: err.message });
543
+ }
544
+ });
545
+
546
+ app.get('/api/models', async (req, res) => {
547
+ try {
548
+ const force =
549
+ req.query.refresh === '1' ||
550
+ req.query.refresh === 'true' ||
551
+ req.query.force === '1';
552
+ const result = await listModels({ selected: config.model, force });
553
+ res.json(result);
445
554
  } catch (err) {
446
555
  res.status(500).json({ error: err.message });
447
556
  }
@@ -451,7 +560,8 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
451
560
  try {
452
561
  const patch = req.body || {};
453
562
 
454
- // Jira secrets → .env (never persisted in config.json)
563
+ // Secrets → .acdev/.env (never persisted in config.json).
564
+ // Empty string = leave unchanged; null = clear.
455
565
  /** @type {Record<string, string | null>} */
456
566
  const envPatch = {};
457
567
  if (patch.jiraEmail !== undefined) {
@@ -459,16 +569,10 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
459
569
  typeof patch.jiraEmail === 'string' ? patch.jiraEmail.trim() : '';
460
570
  envPatch.JIRA_EMAIL = email || null;
461
571
  }
462
- if (patch.jiraApiToken !== undefined) {
463
- const token =
464
- typeof patch.jiraApiToken === 'string' ? patch.jiraApiToken.trim() : '';
465
- // Empty string means "leave unchanged" when token already set — only clear if null
466
- if (patch.jiraApiToken === null) {
467
- envPatch.JIRA_API_TOKEN = null;
468
- } else if (token) {
469
- envPatch.JIRA_API_TOKEN = token;
470
- }
471
- }
572
+ applySecretField(envPatch, 'JIRA_API_TOKEN', patch.jiraApiToken);
573
+ applySecretField(envPatch, 'GH_TOKEN', patch.ghToken);
574
+ applySecretField(envPatch, 'ANTHROPIC_API_KEY', patch.anthropicApiKey);
575
+ applySecretField(envPatch, 'CLAUDE_CODE_OAUTH_TOKEN', patch.claudeOauthToken);
472
576
  if (patch.jiraBaseUrl !== undefined && typeof patch.jiraBaseUrl === 'string') {
473
577
  // Also mirror base URL into env for convenience when set via Settings
474
578
  const trimmed = patch.jiraBaseUrl.trim();
@@ -483,10 +587,13 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
483
587
  const {
484
588
  jiraEmail: _e,
485
589
  jiraApiToken: _t,
590
+ ghToken: _gh,
591
+ anthropicApiKey: _ak,
592
+ claudeOauthToken: _oa,
486
593
  ...configPatch
487
594
  } = patch;
488
595
  updateConfig(repoRoot, config, configPatch);
489
- res.json(publicConfig(config, { repoRoot }));
596
+ res.json(publicConfigPayload());
490
597
  } catch (err) {
491
598
  res.status(400).json({ error: err.message });
492
599
  }
@@ -516,6 +623,11 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
516
623
 
517
624
  app.post('/api/issues', (req, res) => {
518
625
  try {
626
+ const gate = authGate({ needGh: true, needClaude: true });
627
+ if (gate) {
628
+ return res.status(gate.status).json({ error: gate.error, code: gate.code });
629
+ }
630
+
519
631
  const urls = splitIssueUrls(req.body?.urls);
520
632
  if (urls.length === 0) {
521
633
  return res.status(400).json({
@@ -691,6 +803,11 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
691
803
 
692
804
  app.post('/api/jobs/:id/review', (req, res) => {
693
805
  try {
806
+ const gate = authGate({ needGh: false, needClaude: true });
807
+ if (gate) {
808
+ return res.status(gate.status).json({ error: gate.error, code: gate.code });
809
+ }
810
+
694
811
  const job = store.getJob(req.params.id);
695
812
  if (!job) {
696
813
  return res.status(404).json({ error: 'Job not found' });
@@ -736,6 +853,11 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
736
853
 
737
854
  app.post('/api/jobs/:id/approve', async (req, res) => {
738
855
  try {
856
+ const gate = authGate({ needGh: true, needClaude: false });
857
+ if (gate) {
858
+ return res.status(gate.status).json({ error: gate.error, code: gate.code });
859
+ }
860
+
739
861
  const job = store.getJob(req.params.id);
740
862
  if (!job) {
741
863
  return res.status(404).json({ error: 'Job not found' });
@@ -814,6 +936,8 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
814
936
  appendLog: (j, type, payload) => appendLog(j, type, payload),
815
937
  deps: {
816
938
  transitionJiraIssue: deps.transitionJiraIssue,
939
+ addJiraIssueLabel: deps.addJiraIssueLabel,
940
+ closeJiraIssue: deps.closeJiraIssue,
817
941
  addIssueLabel: deps.addIssueLabel,
818
942
  closeIssue: deps.closeIssue,
819
943
  resolveJiraCredentials: deps.resolveJiraCredentials,
@@ -912,6 +1036,11 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
912
1036
 
913
1037
  app.post('/api/jobs/:id/retry', (req, res) => {
914
1038
  try {
1039
+ const gate = authGate({ needGh: true, needClaude: true });
1040
+ if (gate) {
1041
+ return res.status(gate.status).json({ error: gate.error, code: gate.code });
1042
+ }
1043
+
915
1044
  const job = store.getJob(req.params.id);
916
1045
  if (!job) {
917
1046
  return res.status(404).json({ error: 'Job not found' });