cawdev-cli 1.0.3-beta → 1.0.5-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.
@@ -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
+ }
@@ -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(project.gitUrl, path);
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('Neither Claude Code nor Antigravity CLI found on PATH. Install at least one before a run can start.')}`);
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 === 'agy' ? 'Antigravity CLI' : 'Claude Code',
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 === 'agy' ? 'Antigravity CLI' : 'Claude Code';
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,7 +29,7 @@ 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, setAcceptsRulesFromConsole,
32
+ addProject, addWorkspace, readConfigFile, reloadIfRunning, setAcceptsRulesFromConsole, setQuality,
33
33
  setAgentEnabled,
34
34
  } from './configure.mjs';
35
35
  import { restartNeededFor } from '../lib/runner-config.mjs';
@@ -37,6 +37,7 @@ 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,8 +63,10 @@ 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 agent enable|disable <claude|agy>
66
+ cawdev config agent enable|disable <claude|agy|codex>
66
67
  cawdev config rules on|off accept permission rules from the console
68
+ cawdev config quality on|off <slug> scan that project's branches with
69
+ SonarQube after a run's TEST stage
67
70
  cawdev config show this machine's config, token redacted
68
71
 
69
72
  On a machine with no config, cawdev sets one up: it signs you in through the
@@ -286,6 +289,21 @@ export function signInUrl(file, argv, env = process.env) {
286
289
  return String(typed ?? file?.url ?? urlFrom(argv)).replace(/\/+$/, '');
287
290
  }
288
291
 
292
+ /**
293
+ * Where THIS launch signs in: `signInUrl` over the config the launch would use.
294
+ *
295
+ * The banner, the version check and the person's own session all asked
296
+ * `urlFrom(argv)`, which knows `--url` and `CAWDEV_URL` and never the config —
297
+ * so `cawdev --config local.config.json` minted the machine's token at the
298
+ * config's localhost and then opened the browser on app.cawdev.com for the
299
+ * person. One reading, taken once, so the three cannot disagree with the mint.
300
+ */
301
+ export async function launchUrl(argv, env = process.env, cwd = process.cwd()) {
302
+ const configPath = await findConfig(argv, env, cwd);
303
+ const file = configPath ? await readConfigFile(configPath) : null;
304
+ return signInUrl(file, argv, env);
305
+ }
306
+
289
307
  /**
290
308
  * What `readConfig` will call this machine — and therefore the storage key.
291
309
  *
@@ -379,10 +397,9 @@ async function warnIfVersionMismatched(url, ink) {
379
397
  * Works with no config and no sign-in — `/api/health` needs neither — so it
380
398
  * answers on a machine that has not been set up at all yet.
381
399
  */
382
- async function runVersion(argv, ink) {
400
+ async function runVersion(url, ink) {
383
401
  const mine = await cliVersion();
384
402
  console.log(` ${ink.muted('cawdev')} ${ink.text(mine)}`);
385
- const url = urlFrom(argv);
386
403
  try {
387
404
  const response = await fetch(`${url}/api/health`);
388
405
  const health = response.ok ? await response.json() : null;
@@ -405,7 +422,8 @@ async function runVersion(argv, ink) {
405
422
  /**
406
423
  * `cawdev config` — R283's granular half of the setup walk, on a machine
407
424
  * already configured. A direct subcommand (`add-project`, `add-workspace`,
408
- * `agent enable|disable <command>`, `rules on|off`, `show`) or, with none, a
425
+ * `agent enable|disable <command>`, `rules on|off`, `quality on|off <slug>`,
426
+ * `show`) or, with none, a
409
427
  * menu — the same `Select` widget everything that offers a choice uses.
410
428
  */
411
429
  async function runConfig(args, ink) {
@@ -445,20 +463,26 @@ export async function pickAction(ask, say, pick = askOne) {
445
463
  rows: [
446
464
  { id: 'add-project', label: 'Add a project' },
447
465
  { id: 'add-workspace', label: 'Add a workspace to a project' },
448
- { id: 'agent-enable', label: 'Enable an agent (claude / agy)' },
466
+ { id: 'agent-enable', label: `Enable an agent (${KNOWN_AGENTS.join(' / ')})` },
449
467
  { id: 'agent-disable', label: 'Disable an agent' },
450
468
  { id: 'rules-on', label: 'Accept permission rules from the console' },
451
469
  { id: 'rules-off', label: 'Stop accepting rules from the console' },
470
+ { id: 'quality-on', label: 'Scan a project\'s branches with SonarQube after TEST' },
471
+ { id: 'quality-off', label: 'Stop scanning a project\'s branches' },
452
472
  { id: 'show', label: 'Show this machine\'s config' },
453
473
  ],
454
474
  }));
455
475
  const id = picked.row.id;
456
476
  if (id === 'agent-enable' || id === 'agent-disable') {
457
- const command = (await ask.line(' Which agent — claude or agy? ')).trim();
477
+ const command = (await ask.line(` Which agent — ${KNOWN_AGENTS.join(', ')}? `)).trim();
458
478
  return ['agent', id === 'agent-enable' ? 'enable' : 'disable', command];
459
479
  }
460
480
  if (id === 'rules-on') return ['rules', 'on'];
461
481
  if (id === 'rules-off') return ['rules', 'off'];
482
+ if (id === 'quality-on' || id === 'quality-off') {
483
+ const slug = (await ask.line(' Which project (slug)? ')).trim();
484
+ return ['quality', id === 'quality-on' ? 'on' : 'off', slug];
485
+ }
462
486
  return [id];
463
487
  }
464
488
 
@@ -485,8 +509,8 @@ export async function runAction([verb, ...rest], { configPath, file, url, sessio
485
509
  }
486
510
  if (verb === 'agent') {
487
511
  const [sub, command] = rest;
488
- if (!['claude', 'agy'].includes(command)) {
489
- throw new Error('Name an agent: cawdev config agent enable|disable claude|agy');
512
+ if (!KNOWN_AGENTS.includes(command)) {
513
+ throw new Error(`Name an agent: cawdev config agent enable|disable ${KNOWN_AGENTS.join('|')}`);
490
514
  }
491
515
  await reload(await setAgentEnabled({
492
516
  file, configPath, ask, say, ink, command, enabled: sub === 'enable',
@@ -498,6 +522,16 @@ export async function runAction([verb, ...rest], { configPath, file, url, sessio
498
522
  await reload(await setAcceptsRulesFromConsole({ file, configPath, ask, say, ink, enabled: sub === 'on' }));
499
523
  return;
500
524
  }
525
+ if (verb === 'quality') {
526
+ // R300. The block is captured per project at boot, so the daemon's own
527
+ // verdict here is always "restart" — and it is the daemon that says so.
528
+ const [sub, slug] = rest;
529
+ if (!['on', 'off'].includes(sub) || !slug) {
530
+ throw new Error('Name a project: cawdev config quality on|off <slug>');
531
+ }
532
+ await reload(await setQuality({ file, configPath, ask, say, ink, slug, enabled: sub === 'on' }));
533
+ return;
534
+ }
501
535
  throw new Error(`Not a cawdev config command: ${verb}. See cawdev --help.`);
502
536
  }
503
537
 
@@ -525,21 +559,23 @@ async function main() {
525
559
  return;
526
560
  }
527
561
 
562
+ const url = await launchUrl(argv);
563
+
528
564
  if (argv.includes('--version')) {
529
- await runVersion(argv, ink);
565
+ await runVersion(url, ink);
530
566
  return;
531
567
  }
532
568
 
533
569
  // The mark, once, before anything else happens. It is the only decoration in
534
570
  // the program and it is here because this is the moment somebody is waiting:
535
571
  // a browser about to open, or a daemon about to boot.
536
- for (const line of mark(ink, { tagline: urlFrom(argv) })) {
572
+ for (const line of mark(ink, { tagline: url })) {
537
573
  console.log(`\n${line}\n`);
538
574
  }
539
575
 
540
576
  // R283. Best-effort, silent when the two agree — see the function's own
541
577
  // comment for why this never throws.
542
- await warnIfVersionMismatched(urlFrom(argv), ink);
578
+ await warnIfVersionMismatched(url, ink);
543
579
 
544
580
  // Asked for by name, the walk runs even where one has been done before —
545
581
  // that is what "again" means, and adding a project to this machine is the
@@ -556,7 +592,7 @@ async function main() {
556
592
  }
557
593
 
558
594
  const socketPath = await socketToAttach(argv, ink);
559
- await attach(argv, { socketPath });
595
+ await attach(argv, { socketPath, url });
560
596
  }
561
597
 
562
598
  /**
@@ -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
+ }
@@ -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(gitUrl, path);
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 === 'agy' ? 'Antigravity CLI' : 'Claude Code';
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
  }
@@ -361,6 +362,79 @@ export async function setAcceptsRulesFromConsole({
361
362
  return next;
362
363
  }
363
364
 
365
+ /**
366
+ * Turn a project's SonarQube scan on or off — R300.
367
+ *
368
+ * On writes a `quality` block into the project's entry (`{ command, tokenEnv }`,
369
+ * the two things the daemon needs; `runner.mjs`'s `qualityBlockOf` fills in
370
+ * the rest), asking for each with its default offered — Enter keeps it. Off
371
+ * deletes the block and leaves every other key of the entry as it was. A bare
372
+ * path entry is promoted to `{workspaces}` on the way, R47's third form, the
373
+ * way `addWorkspace` promotes one.
374
+ *
375
+ * Nothing here checks the scanner is installed: unlike an agent, the scanner
376
+ * can be missing on a machine that will be told so per run — the daemon
377
+ * reports "not installed" as an error reading, which is the page saying it
378
+ * — and a check here would refuse a config somebody is about to complete.
379
+ * The token is NOT asked for: it is an environment variable this machine
380
+ * exports, never a value in a file people commit.
381
+ *
382
+ * A third question — R316 — is what this machine does when the project's
383
+ * server refuses branches (Community Edition): `skip` reports an error
384
+ * reading, `main` re-scans as the project's main analysis, `project` scans
385
+ * the branch as its own scratch SonarQube project instead. Defaults to
386
+ * whatever is already in the block, `skip` on a fresh one.
387
+ */
388
+ export async function setQuality({
389
+ file, configPath, slug, enabled,
390
+ command, tokenEnv, whenBranchesRefused,
391
+ ask, say, ink = painter(3),
392
+ write = writeRunnerConfig,
393
+ } = {}) {
394
+ const projects = file.projects ?? {};
395
+ if (!slug || !(slug in projects)) {
396
+ throw new Error(`This machine does not serve ${slug || 'that project'}. `
397
+ + `It serves: ${Object.keys(projects).join(', ') || 'nothing yet'}.`);
398
+ }
399
+ const entry = typeof projects[slug] === 'string'
400
+ ? { workspaces: [projects[slug]] }
401
+ : { ...projects[slug] };
402
+
403
+ let next;
404
+ if (enabled) {
405
+ const chosenCommand = command
406
+ ?? (String(await ask.line(' Scanner command [sonar-scanner]: ')).trim() || 'sonar-scanner');
407
+ const chosenTokenEnv = tokenEnv
408
+ ?? (String(await ask.line(' Environment variable holding the scanner token [SONAR_TOKEN]: ')).trim()
409
+ || 'SONAR_TOKEN');
410
+ const existingWhen = entry.quality?.whenBranchesRefused ?? 'skip';
411
+ const chosenWhen = whenBranchesRefused
412
+ ?? (String(await ask.line(` When the server refuses branches — skip, main or project [${existingWhen}]: `))
413
+ .trim().toLowerCase() || existingWhen);
414
+ if (!['skip', 'main', 'project'].includes(chosenWhen)) {
415
+ throw new Error(`"whenBranchesRefused" is "skip", "main" or "project" — not "${chosenWhen}".`);
416
+ }
417
+ entry.quality = {
418
+ ...(entry.quality ?? {}), command: chosenCommand, tokenEnv: chosenTokenEnv, whenBranchesRefused: chosenWhen,
419
+ };
420
+ next = { ...file, projects: { ...projects, [slug]: entry } };
421
+ await write(configPath, next);
422
+ say(` ${ink.success('✓')} ${ink.muted(`${slug} scans its branches with`)} ${ink.text(chosenCommand)}`
423
+ + ` ${ink.muted('after TEST, token from')} ${ink.text('$' + chosenTokenEnv)}`);
424
+ say(` ${ink.muted(`Export ${chosenTokenEnv} where the daemon runs. It is never written to this file.`)}`);
425
+ } else {
426
+ if (!entry.quality) {
427
+ say(` ${ink.muted(`${slug} was already not scanned here.`)}`);
428
+ return file;
429
+ }
430
+ delete entry.quality;
431
+ next = { ...file, projects: { ...projects, [slug]: entry } };
432
+ await write(configPath, next);
433
+ say(` ${ink.success('✓')} ${ink.muted(`${slug} is no longer scanned on this machine.`)}`);
434
+ }
435
+ return next;
436
+ }
437
+
364
438
  /**
365
439
  * The hello of a daemon running on this exact config, or null.
366
440
  *