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.
- package/.acdev/.env.example +13 -0
- package/README.md +231 -0
- package/bin/acdev.js +138 -0
- package/package.json +56 -0
- package/public/acdev_wordmark_logo.svg +10 -0
- package/public/app.js +3291 -0
- package/public/index.html +449 -0
- package/public/styles.css +1870 -0
- package/src/afterPrRules.js +116 -0
- package/src/agent.js +669 -0
- package/src/claude-auth.js +81 -0
- package/src/config.js +426 -0
- package/src/env.js +98 -0
- package/src/gh-auth.js +41 -0
- package/src/git.js +867 -0
- package/src/github.js +179 -0
- package/src/jira.js +418 -0
- package/src/paths.js +128 -0
- package/src/server.js +988 -0
- package/src/store.js +135 -0
- package/src/urls.js +16 -0
- package/src/usage.js +122 -0
|
@@ -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
|
+
}
|