cawdev-cli 1.0.4-beta → 1.0.6-beta
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/lib/agents.mjs +65 -0
- package/lib/ansi.mjs +2 -0
- package/lib/background-shells.mjs +108 -0
- package/lib/codex.mjs +219 -0
- package/lib/harness-prompt.mjs +14 -6
- package/lib/roadmap-format.mjs +52 -0
- package/lib/runner-config.mjs +3 -2
- package/mcp/server.mjs +53 -4
- package/package.json +1 -1
- package/runner/README.md +389 -11
- package/runner/attach.mjs +1838 -31
- package/runner/auto-workspaces.mjs +106 -0
- package/runner/bootstrap.mjs +37 -5
- package/runner/cawdev.mjs +45 -13
- package/runner/changed-lines.mjs +87 -0
- package/runner/configure.mjs +55 -5
- package/runner/input.mjs +21 -8
- package/runner/prefs.mjs +52 -0
- package/runner/runner.mjs +1256 -147
- package/runner/select.mjs +31 -4
- package/runner/split.mjs +923 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Checkouts made on demand — a second coding session on a project starts at
|
|
2
|
+
// once instead of waiting for the first one's checkout.
|
|
3
|
+
//
|
|
4
|
+
// R47 made a project's concurrency the number of its checkouts, and every one
|
|
5
|
+
// had to be written into the config by hand. With `autoWorkspaces: N` in the
|
|
6
|
+
// machine's config, the daemon makes up to N more itself when a coding run
|
|
7
|
+
// would otherwise wait for "no free workspace": a CLONE beside the first
|
|
8
|
+
// checkout, named `<first>-auto-<n>`. A clone and not a `git worktree`, on
|
|
9
|
+
// purpose: the daemon resets a never-pushed branch by checking the default
|
|
10
|
+
// branch out, and git refuses to check out a branch another worktree has —
|
|
11
|
+
// two clones share nothing and cannot refuse each other anything.
|
|
12
|
+
//
|
|
13
|
+
// The clone is LOCAL — from the first checkout, which needs no credential and
|
|
14
|
+
// copies objects instead of downloading them — and its `origin` is then put
|
|
15
|
+
// back to the first checkout's own remote, so it pushes and fetches where
|
|
16
|
+
// every checkout of the project does.
|
|
17
|
+
//
|
|
18
|
+
// Nothing here writes the config. The clones are found again by their NAME
|
|
19
|
+
// (`discoverAutoWorkspaces`), at boot and on SIGHUP, so deleting one — the
|
|
20
|
+
// attached terminal's `/drop`, or `rm -rf` — is the whole of removing it.
|
|
21
|
+
// Configured checkouts are never touched: only a `-auto-<n>` directory is one
|
|
22
|
+
// of these.
|
|
23
|
+
|
|
24
|
+
import { spawn } from 'node:child_process';
|
|
25
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
26
|
+
import { basename, dirname, join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
/** `<first>-auto-<n>`, and nothing else, is a checkout this module made. */
|
|
29
|
+
export function autoNumberOf(first, path) {
|
|
30
|
+
const match = new RegExp(`^${escape(basename(first))}-auto-(\\d+)$`).exec(basename(path));
|
|
31
|
+
return match && dirname(path) === dirname(first) ? Number(match[1]) : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether `path` is a checkout made on demand for the project whose first checkout is `first`. */
|
|
35
|
+
export function isAutoWorkspace(first, path) {
|
|
36
|
+
return autoNumberOf(first, path) !== null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The next free `-auto-<n>` beside `first`, numbered from 2 — the first checkout is 1. */
|
|
40
|
+
export function nextAutoWorkspace(first, existing) {
|
|
41
|
+
const taken = new Set(existing.map((path) => autoNumberOf(first, path)).filter((n) => n !== null));
|
|
42
|
+
let n = 2;
|
|
43
|
+
while (taken.has(n)) n += 1;
|
|
44
|
+
return join(dirname(first), `${basename(first)}-auto-${n}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function escape(text) {
|
|
48
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The on-demand checkouts that exist beside `first` right now, in number order. */
|
|
52
|
+
export async function discoverAutoWorkspaces(first) {
|
|
53
|
+
let names;
|
|
54
|
+
try {
|
|
55
|
+
names = await readdir(dirname(first));
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const found = [];
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
const path = join(dirname(first), name);
|
|
62
|
+
if (autoNumberOf(first, path) === null) continue;
|
|
63
|
+
const git = await stat(join(path, '.git')).catch(() => null);
|
|
64
|
+
if (git) found.push(path);
|
|
65
|
+
}
|
|
66
|
+
return found.sort((a, b) => autoNumberOf(first, a) - autoNumberOf(first, b));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function run(cwd, args) {
|
|
70
|
+
return new Promise((done, fail) => {
|
|
71
|
+
const child = spawn('git', args, {
|
|
72
|
+
cwd,
|
|
73
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
74
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
75
|
+
});
|
|
76
|
+
let out = '';
|
|
77
|
+
let err = '';
|
|
78
|
+
child.stdout.on('data', (chunk) => (out += chunk));
|
|
79
|
+
child.stderr.on('data', (chunk) => (err += chunk));
|
|
80
|
+
child.on('error', fail);
|
|
81
|
+
child.on('close', (code) => (code === 0
|
|
82
|
+
? done(out.trim())
|
|
83
|
+
: fail(new Error(`git ${args.join(' ')} failed: ${err.trim() || `exit ${code}`}`))));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Makes one more checkout of the project whose first checkout is `first`.
|
|
89
|
+
*
|
|
90
|
+
* @returns the new path. Throws with git's own words when it cannot, and
|
|
91
|
+
* leaves nothing half-made behind that `discoverAutoWorkspaces` would take
|
|
92
|
+
* for a checkout: a failed clone has no `.git`.
|
|
93
|
+
*/
|
|
94
|
+
export async function createAutoWorkspace(first, existing) {
|
|
95
|
+
const path = nextAutoWorkspace(first, existing);
|
|
96
|
+
const origin = await run(first, ['remote', 'get-url', 'origin']).catch(() => null);
|
|
97
|
+
await run(dirname(first), ['clone', '--quiet', first, path]);
|
|
98
|
+
if (origin) {
|
|
99
|
+
await run(path, ['remote', 'set-url', 'origin', origin]);
|
|
100
|
+
// Best effort: the run about to use this checkout fetches before it
|
|
101
|
+
// branches anyway, and a remote that wants a passphrase only a person can
|
|
102
|
+
// type must not stop the checkout from existing.
|
|
103
|
+
await run(path, ['fetch', '--quiet', '--prune', 'origin']).catch(() => null);
|
|
104
|
+
}
|
|
105
|
+
return path;
|
|
106
|
+
}
|
package/runner/bootstrap.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
32
32
|
import { homedir, hostname } from 'node:os';
|
|
33
33
|
import { dirname, join } from 'node:path';
|
|
34
34
|
import { painter } from '../lib/ansi.mjs';
|
|
35
|
+
import { agentLabel } from '../lib/agents.mjs';
|
|
35
36
|
import { absolute } from './paths.mjs';
|
|
36
37
|
import { Select, pickFromLine, pickManyFromLine, plainLines } from './select.mjs';
|
|
37
38
|
import { signInThroughBrowser, storedSession } from './sign-in.mjs';
|
|
@@ -193,7 +194,7 @@ export function cloneInto(gitUrl, path) {
|
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
/** Whether the agent is on the PATH at all — a cheaper question than "is it signed in". */
|
|
196
|
-
export async function findAgents(commands = ['agy', 'claude']) {
|
|
197
|
+
export async function findAgents(commands = ['agy', 'codex', 'claude']) {
|
|
197
198
|
const found = [];
|
|
198
199
|
for (const command of Array.isArray(commands) ? commands : [commands]) {
|
|
199
200
|
const where = await new Promise((done) => {
|
|
@@ -227,9 +228,40 @@ export function asker(input = process.stdin, output = process.stdout) {
|
|
|
227
228
|
return {
|
|
228
229
|
line: (prompt) => rl.question(prompt),
|
|
229
230
|
close: () => rl.close(),
|
|
231
|
+
/**
|
|
232
|
+
* Hands the terminal to somebody else while `work` runs — a `git clone`
|
|
233
|
+
* whose ssh asks for a key's passphrase.
|
|
234
|
+
*
|
|
235
|
+
* ssh reads that from `/dev/tty` itself, and an open readline is reading
|
|
236
|
+
* the SAME terminal in raw mode, echoing what it gets: the keys were
|
|
237
|
+
* shared between the two, the passphrase arrived with letters missing, and
|
|
238
|
+
* one of them was printed on the screen. So the interface stops reading and
|
|
239
|
+
* the terminal goes back to cooked mode — echo off is then ssh's to decide —
|
|
240
|
+
* and both are put back afterwards, whatever `work` did.
|
|
241
|
+
*/
|
|
242
|
+
async aside(work) {
|
|
243
|
+
const wasRaw = Boolean(input.isTTY && input.isRaw);
|
|
244
|
+
rl.pause();
|
|
245
|
+
if (wasRaw) input.setRawMode(false);
|
|
246
|
+
try {
|
|
247
|
+
return await work();
|
|
248
|
+
} finally {
|
|
249
|
+
if (wasRaw) input.setRawMode(true);
|
|
250
|
+
rl.resume();
|
|
251
|
+
}
|
|
252
|
+
},
|
|
230
253
|
};
|
|
231
254
|
}
|
|
232
255
|
|
|
256
|
+
/**
|
|
257
|
+
* `clone` with the terminal handed over for its duration, when the asker can —
|
|
258
|
+
* see `asker().aside`. An asker without it (a test's, the attached screen's)
|
|
259
|
+
* is not reading the terminal, so there is nothing to hand over.
|
|
260
|
+
*/
|
|
261
|
+
export function cloneAside(ask, clone, gitUrl, path) {
|
|
262
|
+
return typeof ask?.aside === 'function' ? ask.aside(() => clone(gitUrl, path)) : clone(gitUrl, path);
|
|
263
|
+
}
|
|
264
|
+
|
|
233
265
|
/**
|
|
234
266
|
* One choice, drawn the way a terminal that cannot be drawn on gets one.
|
|
235
267
|
*
|
|
@@ -380,7 +412,7 @@ export async function setUpThisMachine({
|
|
|
380
412
|
say('');
|
|
381
413
|
say(` ${ink.muted('Cloning')} ${ink.accent(project.gitUrl)} ${ink.muted('into')} ${ink.text(path)}`);
|
|
382
414
|
await mkdir(dirname(path), { recursive: true });
|
|
383
|
-
await clone
|
|
415
|
+
await cloneAside(ask, clone, project.gitUrl, path);
|
|
384
416
|
entries.push({ slug: project.slug, path });
|
|
385
417
|
}
|
|
386
418
|
|
|
@@ -393,7 +425,7 @@ export async function setUpThisMachine({
|
|
|
393
425
|
// credential behind them.
|
|
394
426
|
const agentInfos = await agent();
|
|
395
427
|
if (agentInfos.length === 0) {
|
|
396
|
-
say(` ${ink.warn('!')} ${ink.muted('
|
|
428
|
+
say(` ${ink.warn('!')} ${ink.muted('None of Claude Code, Antigravity CLI or Codex CLI is on PATH. Install at least one before a run can start.')}`);
|
|
397
429
|
throw new Error('No supported coding agent found.');
|
|
398
430
|
}
|
|
399
431
|
|
|
@@ -403,7 +435,7 @@ export async function setUpThisMachine({
|
|
|
403
435
|
title: 'Which coding agents do you want to use on this machine?',
|
|
404
436
|
rows: agentInfos.map((a) => ({
|
|
405
437
|
id: a.command,
|
|
406
|
-
label: a.command
|
|
438
|
+
label: agentLabel(a.command),
|
|
407
439
|
hint: a.where,
|
|
408
440
|
})),
|
|
409
441
|
}))).map((row) => agentInfos.find((a) => a.command === row.id));
|
|
@@ -414,7 +446,7 @@ export async function setUpThisMachine({
|
|
|
414
446
|
|
|
415
447
|
say('');
|
|
416
448
|
for (const a of chosenAgents) {
|
|
417
|
-
const agentDisplayName = a.command
|
|
449
|
+
const agentDisplayName = agentLabel(a.command);
|
|
418
450
|
say(` ${ink.muted('This machine spawns')} ${ink.text(a.command)} ${ink.muted(`(${a.where}) for every run.`)}`);
|
|
419
451
|
if (!await confirm(ask, `Is ${agentDisplayName} signed in on this machine?`)) {
|
|
420
452
|
throw new Error(
|
package/runner/cawdev.mjs
CHANGED
|
@@ -29,14 +29,15 @@ import { attach, urlFrom, valueOf } from './attach.mjs';
|
|
|
29
29
|
import { asker, askOne, mintForThisMachine, setUpThisMachine } from './bootstrap.mjs';
|
|
30
30
|
import { liveSockets, probeSocket, socketPathFor } from './control.mjs';
|
|
31
31
|
import {
|
|
32
|
-
addProject, addWorkspace, readConfigFile, reloadIfRunning,
|
|
33
|
-
setAgentEnabled,
|
|
32
|
+
addProject, addWorkspace, readConfigFile, reloadIfRunning, removeProject, setAcceptsRulesFromConsole,
|
|
33
|
+
setQuality, setAgentEnabled,
|
|
34
34
|
} from './configure.mjs';
|
|
35
35
|
import { restartNeededFor } from '../lib/runner-config.mjs';
|
|
36
36
|
import { Select } from './select.mjs';
|
|
37
37
|
import { storedSession } from './sign-in.mjs';
|
|
38
38
|
import { loadToken } from './token-store.mjs';
|
|
39
39
|
import { painter } from '../lib/ansi.mjs';
|
|
40
|
+
import { KNOWN_AGENTS } from '../lib/agents.mjs';
|
|
40
41
|
import { mark } from './brand.mjs';
|
|
41
42
|
import { cliVersion, isBelow } from '../lib/version.mjs';
|
|
42
43
|
|
|
@@ -62,7 +63,9 @@ const USAGE = `
|
|
|
62
63
|
cawdev config add-project serve one more project you can write to
|
|
63
64
|
cawdev config add-workspace another checkout for a project you
|
|
64
65
|
already serve — R47's concurrency
|
|
65
|
-
cawdev config
|
|
66
|
+
cawdev config remove-project <slug> stop serving a project (its checkouts
|
|
67
|
+
stay on disk)
|
|
68
|
+
cawdev config agent enable|disable <claude|agy|codex>
|
|
66
69
|
cawdev config rules on|off accept permission rules from the console
|
|
67
70
|
cawdev config quality on|off <slug> scan that project's branches with
|
|
68
71
|
SonarQube after a run's TEST stage
|
|
@@ -288,6 +291,21 @@ export function signInUrl(file, argv, env = process.env) {
|
|
|
288
291
|
return String(typed ?? file?.url ?? urlFrom(argv)).replace(/\/+$/, '');
|
|
289
292
|
}
|
|
290
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Where THIS launch signs in: `signInUrl` over the config the launch would use.
|
|
296
|
+
*
|
|
297
|
+
* The banner, the version check and the person's own session all asked
|
|
298
|
+
* `urlFrom(argv)`, which knows `--url` and `CAWDEV_URL` and never the config —
|
|
299
|
+
* so `cawdev --config local.config.json` minted the machine's token at the
|
|
300
|
+
* config's localhost and then opened the browser on app.cawdev.com for the
|
|
301
|
+
* person. One reading, taken once, so the three cannot disagree with the mint.
|
|
302
|
+
*/
|
|
303
|
+
export async function launchUrl(argv, env = process.env, cwd = process.cwd()) {
|
|
304
|
+
const configPath = await findConfig(argv, env, cwd);
|
|
305
|
+
const file = configPath ? await readConfigFile(configPath) : null;
|
|
306
|
+
return signInUrl(file, argv, env);
|
|
307
|
+
}
|
|
308
|
+
|
|
291
309
|
/**
|
|
292
310
|
* What `readConfig` will call this machine — and therefore the storage key.
|
|
293
311
|
*
|
|
@@ -381,10 +399,9 @@ async function warnIfVersionMismatched(url, ink) {
|
|
|
381
399
|
* Works with no config and no sign-in — `/api/health` needs neither — so it
|
|
382
400
|
* answers on a machine that has not been set up at all yet.
|
|
383
401
|
*/
|
|
384
|
-
async function runVersion(
|
|
402
|
+
async function runVersion(url, ink) {
|
|
385
403
|
const mine = await cliVersion();
|
|
386
404
|
console.log(` ${ink.muted('cawdev')} ${ink.text(mine)}`);
|
|
387
|
-
const url = urlFrom(argv);
|
|
388
405
|
try {
|
|
389
406
|
const response = await fetch(`${url}/api/health`);
|
|
390
407
|
const health = response.ok ? await response.json() : null;
|
|
@@ -407,6 +424,7 @@ async function runVersion(argv, ink) {
|
|
|
407
424
|
/**
|
|
408
425
|
* `cawdev config` — R283's granular half of the setup walk, on a machine
|
|
409
426
|
* already configured. A direct subcommand (`add-project`, `add-workspace`,
|
|
427
|
+
* `remove-project <slug>`,
|
|
410
428
|
* `agent enable|disable <command>`, `rules on|off`, `quality on|off <slug>`,
|
|
411
429
|
* `show`) or, with none, a
|
|
412
430
|
* menu — the same `Select` widget everything that offers a choice uses.
|
|
@@ -448,7 +466,8 @@ export async function pickAction(ask, say, pick = askOne) {
|
|
|
448
466
|
rows: [
|
|
449
467
|
{ id: 'add-project', label: 'Add a project' },
|
|
450
468
|
{ id: 'add-workspace', label: 'Add a workspace to a project' },
|
|
451
|
-
{ id: '
|
|
469
|
+
{ id: 'remove-project', label: 'Stop serving a project' },
|
|
470
|
+
{ id: 'agent-enable', label: `Enable an agent (${KNOWN_AGENTS.join(' / ')})` },
|
|
452
471
|
{ id: 'agent-disable', label: 'Disable an agent' },
|
|
453
472
|
{ id: 'rules-on', label: 'Accept permission rules from the console' },
|
|
454
473
|
{ id: 'rules-off', label: 'Stop accepting rules from the console' },
|
|
@@ -459,9 +478,12 @@ export async function pickAction(ask, say, pick = askOne) {
|
|
|
459
478
|
}));
|
|
460
479
|
const id = picked.row.id;
|
|
461
480
|
if (id === 'agent-enable' || id === 'agent-disable') {
|
|
462
|
-
const command = (await ask.line(
|
|
481
|
+
const command = (await ask.line(` Which agent — ${KNOWN_AGENTS.join(', ')}? `)).trim();
|
|
463
482
|
return ['agent', id === 'agent-enable' ? 'enable' : 'disable', command];
|
|
464
483
|
}
|
|
484
|
+
if (id === 'remove-project') {
|
|
485
|
+
return ['remove-project', (await ask.line(' Which project (slug)? ')).trim()];
|
|
486
|
+
}
|
|
465
487
|
if (id === 'rules-on') return ['rules', 'on'];
|
|
466
488
|
if (id === 'rules-off') return ['rules', 'off'];
|
|
467
489
|
if (id === 'quality-on' || id === 'quality-off') {
|
|
@@ -492,10 +514,18 @@ export async function runAction([verb, ...rest], { configPath, file, url, sessio
|
|
|
492
514
|
await reload(await addWorkspace({ configPath, file, url, session, ask, say, ink }));
|
|
493
515
|
return;
|
|
494
516
|
}
|
|
517
|
+
if (verb === 'remove-project') {
|
|
518
|
+
const [slug] = rest;
|
|
519
|
+
if (!slug) {
|
|
520
|
+
throw new Error('Name a project: cawdev config remove-project <slug>');
|
|
521
|
+
}
|
|
522
|
+
await reload(await removeProject({ file, configPath, ask, say, ink, slug }));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
495
525
|
if (verb === 'agent') {
|
|
496
526
|
const [sub, command] = rest;
|
|
497
|
-
if (!
|
|
498
|
-
throw new Error(
|
|
527
|
+
if (!KNOWN_AGENTS.includes(command)) {
|
|
528
|
+
throw new Error(`Name an agent: cawdev config agent enable|disable ${KNOWN_AGENTS.join('|')}`);
|
|
499
529
|
}
|
|
500
530
|
await reload(await setAgentEnabled({
|
|
501
531
|
file, configPath, ask, say, ink, command, enabled: sub === 'enable',
|
|
@@ -544,21 +574,23 @@ async function main() {
|
|
|
544
574
|
return;
|
|
545
575
|
}
|
|
546
576
|
|
|
577
|
+
const url = await launchUrl(argv);
|
|
578
|
+
|
|
547
579
|
if (argv.includes('--version')) {
|
|
548
|
-
await runVersion(
|
|
580
|
+
await runVersion(url, ink);
|
|
549
581
|
return;
|
|
550
582
|
}
|
|
551
583
|
|
|
552
584
|
// The mark, once, before anything else happens. It is the only decoration in
|
|
553
585
|
// the program and it is here because this is the moment somebody is waiting:
|
|
554
586
|
// a browser about to open, or a daemon about to boot.
|
|
555
|
-
for (const line of mark(ink, { tagline:
|
|
587
|
+
for (const line of mark(ink, { tagline: url })) {
|
|
556
588
|
console.log(`\n${line}\n`);
|
|
557
589
|
}
|
|
558
590
|
|
|
559
591
|
// R283. Best-effort, silent when the two agree — see the function's own
|
|
560
592
|
// comment for why this never throws.
|
|
561
|
-
await warnIfVersionMismatched(
|
|
593
|
+
await warnIfVersionMismatched(url, ink);
|
|
562
594
|
|
|
563
595
|
// Asked for by name, the walk runs even where one has been done before —
|
|
564
596
|
// that is what "again" means, and adding a project to this machine is the
|
|
@@ -575,7 +607,7 @@ async function main() {
|
|
|
575
607
|
}
|
|
576
608
|
|
|
577
609
|
const socketPath = await socketToAttach(argv, ink);
|
|
578
|
-
await attach(argv, { socketPath });
|
|
610
|
+
await attach(argv, { socketPath, url });
|
|
579
611
|
}
|
|
580
612
|
|
|
581
613
|
/**
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// R316: which lines a branch changed since it left its base.
|
|
2
|
+
//
|
|
3
|
+
// A scratch SonarQube project (scanAsScratchProject in runner.mjs) has no
|
|
4
|
+
// new-code period of its own to compare against — it is overwritten on every
|
|
5
|
+
// run of the branch, so SonarQube has never seen a "previous version" of it.
|
|
6
|
+
// "New" is redefined here as "on a line this branch changed since the
|
|
7
|
+
// merge-base", computed from git rather than from SonarQube.
|
|
8
|
+
//
|
|
9
|
+
// `parseUnifiedZero` is kept as its own pure function — the hunk arithmetic
|
|
10
|
+
// is the part worth getting right without a repository to run it against.
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
|
|
14
|
+
function git(cwd, args) {
|
|
15
|
+
return new Promise((resolvePromise, reject) => {
|
|
16
|
+
const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
17
|
+
let out = '';
|
|
18
|
+
let err = '';
|
|
19
|
+
child.stdout.on('data', (chunk) => (out += chunk));
|
|
20
|
+
child.stderr.on('data', (chunk) => (err += chunk));
|
|
21
|
+
child.on('error', reject);
|
|
22
|
+
child.on('close', (code) =>
|
|
23
|
+
code === 0
|
|
24
|
+
? resolvePromise(out)
|
|
25
|
+
: reject(new Error(`git ${args.join(' ')} failed: ${
|
|
26
|
+
err.trim() || out.trim() || `exit ${code}, and it said nothing`}`)),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `git diff -U0`'s zero-context unified output, as `Map<path, Set<line>>` of
|
|
35
|
+
* the lines each touched file gained. A deleted file (`+++ /dev/null`) is
|
|
36
|
+
* skipped — it has no lines left to be new on. `d` missing on a hunk header
|
|
37
|
+
* means one line added; `d` present as `0` means the hunk is a pure deletion
|
|
38
|
+
* and adds none.
|
|
39
|
+
*/
|
|
40
|
+
export function parseUnifiedZero(text) {
|
|
41
|
+
const changed = new Map();
|
|
42
|
+
let current = null;
|
|
43
|
+
for (const line of text.split('\n')) {
|
|
44
|
+
if (line.startsWith('+++ ')) {
|
|
45
|
+
const path = line.slice(4).trim();
|
|
46
|
+
current = path === '/dev/null' ? null : path.replace(/^b\//, '');
|
|
47
|
+
if (current && !changed.has(current)) {
|
|
48
|
+
changed.set(current, new Set());
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (current === null) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const hunk = HUNK.exec(line);
|
|
56
|
+
if (hunk) {
|
|
57
|
+
const start = Number(hunk[1]);
|
|
58
|
+
const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
59
|
+
const set = changed.get(current);
|
|
60
|
+
for (let offset = 0; offset < count; offset += 1) {
|
|
61
|
+
set.add(start + offset);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return changed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What `branch` changed since `base`, as `Map<path, Set<line> | 'ALL'>`.
|
|
70
|
+
*
|
|
71
|
+
* Diffs the working tree rather than `HEAD` — a coding run's uncommitted
|
|
72
|
+
* edits count too, which is the point: the scratch scan runs before anything
|
|
73
|
+
* is committed. An untracked file has no diff to parse and counts whole.
|
|
74
|
+
*/
|
|
75
|
+
export async function changedLines(cwd, base) {
|
|
76
|
+
const diff = await git(cwd,
|
|
77
|
+
['-c', 'core.quotePath=false', 'diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', base]);
|
|
78
|
+
const changed = parseUnifiedZero(diff);
|
|
79
|
+
const untracked = await git(cwd, ['ls-files', '--others', '--exclude-standard']);
|
|
80
|
+
for (const rawPath of untracked.split('\n')) {
|
|
81
|
+
const path = rawPath.trim();
|
|
82
|
+
if (path) {
|
|
83
|
+
changed.set(path, 'ALL');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return changed;
|
|
87
|
+
}
|
package/runner/configure.mjs
CHANGED
|
@@ -32,9 +32,10 @@ import { connect as connectTo } from 'node:net';
|
|
|
32
32
|
import { mkdir, readFile } from 'node:fs/promises';
|
|
33
33
|
import { dirname } from 'node:path';
|
|
34
34
|
import { painter } from '../lib/ansi.mjs';
|
|
35
|
+
import { agentLabel } from '../lib/agents.mjs';
|
|
35
36
|
import { probeSocket, socketPathFor } from './control.mjs';
|
|
36
37
|
import {
|
|
37
|
-
askOne, cloneInto, confirm, defaultRunnerName,
|
|
38
|
+
askOne, cloneAside, cloneInto, confirm, defaultRunnerName,
|
|
38
39
|
findAgents, mintRunnerToken, servable, writeRunnerConfig,
|
|
39
40
|
} from './bootstrap.mjs';
|
|
40
41
|
import {
|
|
@@ -177,7 +178,7 @@ async function settleCheckout({ path, slug, gitUrl, ask, say, ink, clone, inspec
|
|
|
177
178
|
}
|
|
178
179
|
say(` ${ink.muted('Cloning')} ${ink.accent(gitUrl)} ${ink.muted('into')} ${ink.text(path)}`);
|
|
179
180
|
await mkdir(dirname(path), { recursive: true });
|
|
180
|
-
await clone
|
|
181
|
+
await cloneAside(ask, clone, gitUrl, path);
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
/**
|
|
@@ -323,7 +324,7 @@ export async function setAgentEnabled({
|
|
|
323
324
|
if (!found) {
|
|
324
325
|
throw new Error(`${command} is not on this machine's PATH. Install it before enabling it here.`);
|
|
325
326
|
}
|
|
326
|
-
const label = command
|
|
327
|
+
const label = agentLabel(command);
|
|
327
328
|
if (!await confirm(ask, `Is ${label} signed in on this machine?`)) {
|
|
328
329
|
throw new Error(`Sign in first — run \`${command}\` once in a terminal and follow it.`);
|
|
329
330
|
}
|
|
@@ -344,6 +345,40 @@ export async function setAgentEnabled({
|
|
|
344
345
|
return next;
|
|
345
346
|
}
|
|
346
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Stop serving a project: its entry leaves the config, and nothing else moves.
|
|
350
|
+
* The checkouts stay on disk — they are somebody's working copies, and
|
|
351
|
+
* deleting a directory is not a config change — and the machine's token keeps
|
|
352
|
+
* its grant, which reaches nothing once the project is gone and reaches it
|
|
353
|
+
* again if it is added back. `projects` is read at boot, so the daemon serves
|
|
354
|
+
* it until a restart; the reload after this says so.
|
|
355
|
+
*
|
|
356
|
+
* The case it exists for: a project deleted in the console is still in this
|
|
357
|
+
* file, so the terminal kept offering it and the daemon kept polling for it.
|
|
358
|
+
*/
|
|
359
|
+
export async function removeProject({
|
|
360
|
+
file, configPath, slug,
|
|
361
|
+
ask, say, ink = painter(3),
|
|
362
|
+
write = writeRunnerConfig,
|
|
363
|
+
} = {}) {
|
|
364
|
+
const projects = file.projects ?? {};
|
|
365
|
+
if (!slug || !(slug in projects)) {
|
|
366
|
+
throw new Error(`This machine does not serve ${slug || 'that project'}. `
|
|
367
|
+
+ `It serves: ${Object.keys(projects).join(', ') || 'nothing yet'}.`);
|
|
368
|
+
}
|
|
369
|
+
const paths = pathsOf(projects[slug]);
|
|
370
|
+
const kept = paths.length ? ` Its checkout${paths.length === 1 ? '' : 's'} stay${paths.length === 1 ? 's' : ''} on disk: ${paths.join(', ')}.` : '';
|
|
371
|
+
if (!await confirm(ask, `Stop serving ${slug} on this machine?${kept}`)) {
|
|
372
|
+
throw new Error('Nothing changed.');
|
|
373
|
+
}
|
|
374
|
+
const { [slug]: _removed, ...rest } = projects;
|
|
375
|
+
const next = { ...file, projects: rest };
|
|
376
|
+
await write(configPath, next);
|
|
377
|
+
say(` ${ink.success('✓')} ${ink.muted(`This machine no longer serves ${slug}.`)}`
|
|
378
|
+
+ (paths.length ? ` ${ink.muted(`Delete ${paths.length === 1 ? 'its checkout' : 'its checkouts'} yourself if you no longer want ${paths.length === 1 ? 'it' : 'them'}.`)}` : ''));
|
|
379
|
+
return next;
|
|
380
|
+
}
|
|
381
|
+
|
|
347
382
|
/** Turn `acceptsRulesFromConsole` on or off — R126. */
|
|
348
383
|
export async function setAcceptsRulesFromConsole({
|
|
349
384
|
file, configPath, enabled,
|
|
@@ -377,10 +412,16 @@ export async function setAcceptsRulesFromConsole({
|
|
|
377
412
|
* — and a check here would refuse a config somebody is about to complete.
|
|
378
413
|
* The token is NOT asked for: it is an environment variable this machine
|
|
379
414
|
* exports, never a value in a file people commit.
|
|
415
|
+
*
|
|
416
|
+
* A third question — R316 — is what this machine does when the project's
|
|
417
|
+
* server refuses branches (Community Edition): `skip` reports an error
|
|
418
|
+
* reading, `main` re-scans as the project's main analysis, `project` scans
|
|
419
|
+
* the branch as its own scratch SonarQube project instead. Defaults to
|
|
420
|
+
* whatever is already in the block, `skip` on a fresh one.
|
|
380
421
|
*/
|
|
381
422
|
export async function setQuality({
|
|
382
423
|
file, configPath, slug, enabled,
|
|
383
|
-
command, tokenEnv,
|
|
424
|
+
command, tokenEnv, whenBranchesRefused,
|
|
384
425
|
ask, say, ink = painter(3),
|
|
385
426
|
write = writeRunnerConfig,
|
|
386
427
|
} = {}) {
|
|
@@ -400,7 +441,16 @@ export async function setQuality({
|
|
|
400
441
|
const chosenTokenEnv = tokenEnv
|
|
401
442
|
?? (String(await ask.line(' Environment variable holding the scanner token [SONAR_TOKEN]: ')).trim()
|
|
402
443
|
|| 'SONAR_TOKEN');
|
|
403
|
-
|
|
444
|
+
const existingWhen = entry.quality?.whenBranchesRefused ?? 'skip';
|
|
445
|
+
const chosenWhen = whenBranchesRefused
|
|
446
|
+
?? (String(await ask.line(` When the server refuses branches — skip, main or project [${existingWhen}]: `))
|
|
447
|
+
.trim().toLowerCase() || existingWhen);
|
|
448
|
+
if (!['skip', 'main', 'project'].includes(chosenWhen)) {
|
|
449
|
+
throw new Error(`"whenBranchesRefused" is "skip", "main" or "project" — not "${chosenWhen}".`);
|
|
450
|
+
}
|
|
451
|
+
entry.quality = {
|
|
452
|
+
...(entry.quality ?? {}), command: chosenCommand, tokenEnv: chosenTokenEnv, whenBranchesRefused: chosenWhen,
|
|
453
|
+
};
|
|
404
454
|
next = { ...file, projects: { ...projects, [slug]: entry } };
|
|
405
455
|
await write(configPath, next);
|
|
406
456
|
say(` ${ink.success('✓')} ${ink.muted(`${slug} scans its branches with`)} ${ink.text(chosenCommand)}`
|
package/runner/input.mjs
CHANGED
|
@@ -136,25 +136,38 @@ export class History {
|
|
|
136
136
|
this.draft = '';
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
/**
|
|
140
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Older. Returns null when there is nothing older, so the line is left alone.
|
|
141
|
+
*
|
|
142
|
+
* @param keep which entries this line walks — a command line walks the
|
|
143
|
+
* commands and skips the prompts sent to sessions, which it cannot send
|
|
144
|
+
*/
|
|
145
|
+
back(current = '', keep = () => true) {
|
|
141
146
|
if (this.at === this.entries.length) {
|
|
142
147
|
this.draft = current;
|
|
143
148
|
}
|
|
144
|
-
|
|
149
|
+
let at = this.at;
|
|
150
|
+
do {
|
|
151
|
+
at -= 1;
|
|
152
|
+
} while (at >= 0 && !keep(this.entries[at]));
|
|
153
|
+
if (at < 0) {
|
|
145
154
|
return null;
|
|
146
155
|
}
|
|
147
|
-
this.at
|
|
148
|
-
return this.entries[
|
|
156
|
+
this.at = at;
|
|
157
|
+
return this.entries[at];
|
|
149
158
|
}
|
|
150
159
|
|
|
151
160
|
/** Newer, ending at the draft you were writing. */
|
|
152
|
-
forward() {
|
|
161
|
+
forward(keep = () => true) {
|
|
153
162
|
if (this.at >= this.entries.length) {
|
|
154
163
|
return null;
|
|
155
164
|
}
|
|
156
|
-
this.at
|
|
157
|
-
|
|
165
|
+
let at = this.at;
|
|
166
|
+
do {
|
|
167
|
+
at += 1;
|
|
168
|
+
} while (at < this.entries.length && !keep(this.entries[at]));
|
|
169
|
+
this.at = at;
|
|
170
|
+
return at === this.entries.length ? this.draft : this.entries[at];
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
reset() {
|
package/runner/prefs.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this terminal remembers about how you like to work — beside history.mjs
|
|
3
|
+
* and in its shape: one file under ~/.cawdev, keyed by the platform's URL, 0600.
|
|
4
|
+
*
|
|
5
|
+
* One preference so far: the agent `/new` asks for when a command names none
|
|
6
|
+
* (`/agent codex`). Kept here and not on the platform, because it is a choice
|
|
7
|
+
* about THIS terminal — the project's default and your account's live in the
|
|
8
|
+
* console, and this sits in front of them without changing either.
|
|
9
|
+
*
|
|
10
|
+
* @author Claude Opus 5.5 (Claude Code)
|
|
11
|
+
* @author Moez Ben MBarka
|
|
12
|
+
*/
|
|
13
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export function prefsFile() {
|
|
18
|
+
return process.env.CAWDEV_PREFS_FILE ?? join(homedir(), '.cawdev', 'terminal.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function key(url) {
|
|
22
|
+
return String(url).replace(/\/+$/, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readAll() {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(await readFile(prefsFile(), 'utf8'));
|
|
28
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** This platform's preferences, `{}` when there are none or the file is unreadable. */
|
|
35
|
+
export async function loadPrefs(url) {
|
|
36
|
+
const mine = (await readAll())[key(url)];
|
|
37
|
+
return mine && typeof mine === 'object' ? mine : {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Merges `changes` into this platform's preferences; a null value removes the key. */
|
|
41
|
+
export async function savePrefs(url, changes) {
|
|
42
|
+
const all = await readAll();
|
|
43
|
+
const mine = { ...(all[key(url)] ?? {}) };
|
|
44
|
+
for (const [name, value] of Object.entries(changes)) {
|
|
45
|
+
if (value === null || value === undefined) delete mine[name];
|
|
46
|
+
else mine[name] = value;
|
|
47
|
+
}
|
|
48
|
+
all[key(url)] = mine;
|
|
49
|
+
await mkdir(dirname(prefsFile()), { recursive: true, mode: 0o700 });
|
|
50
|
+
await writeFile(prefsFile(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
|
51
|
+
await chmod(prefsFile(), 0o600).catch(() => undefined);
|
|
52
|
+
}
|