cawdev-cli 1.0.2-beta → 1.0.4-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/harness-prompt.mjs +12 -1
- package/lib/runner-config.mjs +81 -0
- package/lib/stage-tools.mjs +33 -2
- package/lib/tool-line.mjs +19 -8
- package/mcp/README.md +1 -0
- package/mcp/server.mjs +147 -0
- package/package.json +2 -5
- package/runner/README.md +124 -12
- package/runner/attach.mjs +87 -31
- package/runner/cawdev.mjs +36 -25
- package/runner/configure.mjs +144 -21
- package/runner/runner.mjs +560 -53
- package/runner/macbook-laptop.json +0 -48
package/runner/attach.mjs
CHANGED
|
@@ -50,13 +50,14 @@ import { connect } from 'node:net';
|
|
|
50
50
|
import { basename, dirname } from 'node:path';
|
|
51
51
|
import { listSockets, socketPathFor } from './control.mjs';
|
|
52
52
|
import {
|
|
53
|
-
addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
|
|
54
|
-
setAcceptsRulesFromConsole, setAgentEnabled, slugsOf,
|
|
53
|
+
RESTART_FROM_ATTACH, addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
|
|
54
|
+
setAcceptsRulesFromConsole, setAgentEnabled, setQuality, slugsOf,
|
|
55
55
|
} from './configure.mjs';
|
|
56
56
|
import {
|
|
57
57
|
TYPE_A_PATH, absolute, completePath, describe, inspectPath, listDirectories,
|
|
58
58
|
} from './paths.mjs';
|
|
59
59
|
import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
|
|
60
|
+
import { restartNeededFor } from '../lib/runner-config.mjs';
|
|
60
61
|
import { oneLine } from './brand.mjs';
|
|
61
62
|
import { Scrollback } from './scrollback.mjs';
|
|
62
63
|
import { Session, signInThroughBrowser, storedSession } from './sign-in.mjs';
|
|
@@ -490,6 +491,20 @@ export function footerLines(state, width, ink = painter(3)) {
|
|
|
490
491
|
|
|
491
492
|
lines.push(settingsBar(runner, runs, width, ink));
|
|
492
493
|
|
|
494
|
+
// R294: the config file has moved on from what this daemon booted with, in
|
|
495
|
+
// ways only a restart applies. A row while it is true and nothing when it
|
|
496
|
+
// is not — the daemon decides, off the file's own mtime, so an edit by hand
|
|
497
|
+
// shows here too and an edit reverted clears it. Pinned, because the one
|
|
498
|
+
// sentence R288 printed about this scrolled off with the next transcript
|
|
499
|
+
// line, and a person who added a project here believed it was being served.
|
|
500
|
+
const stale = Array.isArray(runner?.restartNeeded) ? runner.restartNeeded : [];
|
|
501
|
+
if (stale.length) {
|
|
502
|
+
lines.push(padVisible(clip(
|
|
503
|
+
` ${ink.warn('!')} ${ink.muted('config changed:')} ${ink.text(stale.join(', '))} ${ink.muted(`— restart to apply (${RESTART_FROM_ATTACH})`)}`,
|
|
504
|
+
width,
|
|
505
|
+
), width));
|
|
506
|
+
}
|
|
507
|
+
|
|
493
508
|
// Who and where. The runner's NAME is what the console shows and what
|
|
494
509
|
// `--runner` takes, so it is the word somebody would type; the email is what
|
|
495
510
|
// every action here is done as, and "watching only" is not a lesser state,
|
|
@@ -690,16 +705,36 @@ const KNOWN_AGENTS = ['claude', 'agy'];
|
|
|
690
705
|
* "enable" would ask the person to remember what the file says. The hint is
|
|
691
706
|
* where the current value lives; the label is the change.
|
|
692
707
|
*/
|
|
708
|
+
/** A project entry's `quality` block, whatever shape the entry is — R300. */
|
|
709
|
+
function qualityOf(entry) {
|
|
710
|
+
return entry && typeof entry === 'object' && entry.quality ? entry.quality : null;
|
|
711
|
+
}
|
|
712
|
+
|
|
693
713
|
export function configMenu(file, runner = {}) {
|
|
694
714
|
const slugs = slugsOf(file);
|
|
695
715
|
const agents = agentsOf(file);
|
|
696
716
|
const rulesOn = file.acceptsRulesFromConsole === true;
|
|
697
|
-
|
|
717
|
+
// R294: the file and the daemon can disagree, and the row says so rather
|
|
718
|
+
// than claiming the file is what runs. `served` is what the hello says the
|
|
719
|
+
// daemon booted with; a slug in the file and not there is "restart".
|
|
720
|
+
const stale = Array.isArray(runner.restartNeeded) ? runner.restartNeeded : [];
|
|
721
|
+
const served = Array.isArray(runner.projects) ? runner.projects : null;
|
|
722
|
+
const notServed = served && stale.includes('projects') ? slugs.filter((slug) => !served.includes(slug)) : [];
|
|
723
|
+
const serving = slugs.length
|
|
724
|
+
? `serves ${slugs.filter((slug) => !notServed.includes(slug)).join(', ') || 'nothing yet'}`
|
|
725
|
+
: 'serves nothing yet';
|
|
726
|
+
const pending = notServed.length ? ` · ${notServed.join(', ')} after a restart` : '';
|
|
727
|
+
const checkouts = slugs.map((slug) => {
|
|
728
|
+
const count = pathsOf(file.projects[slug]).length;
|
|
729
|
+
const booted = runner.workspaces?.[slug];
|
|
730
|
+
const moved = stale.includes('projects') && typeof booted === 'number' && booted !== count;
|
|
731
|
+
return `${slug}: ${moved ? `${booted}, ${count} after a restart` : count}`;
|
|
732
|
+
}).join(', ');
|
|
698
733
|
return new Select({
|
|
699
734
|
kind: 'config',
|
|
700
735
|
title: `this machine — ${runner.name ?? file.name ?? 'cawdev'}`,
|
|
701
736
|
rows: [
|
|
702
|
-
{ id: 'add-project', label: 'Add a project', hint:
|
|
737
|
+
{ id: 'add-project', label: 'Add a project', hint: `${serving}${pending}` },
|
|
703
738
|
{ id: 'add-workspace', label: 'Add a workspace to a project', hint: checkouts || 'no projects to add one to' },
|
|
704
739
|
...[...new Set([...KNOWN_AGENTS, ...agents])].map((command) => (agents.includes(command)
|
|
705
740
|
? { id: `agent:${command}`, label: `Disable ${command}`, hint: 'spawned here now' }
|
|
@@ -707,6 +742,11 @@ export function configMenu(file, runner = {}) {
|
|
|
707
742
|
rulesOn
|
|
708
743
|
? { id: 'rules', label: 'Stop accepting permission rules from the console', hint: 'on now' }
|
|
709
744
|
: { id: 'rules', label: 'Accept permission rules from the console', hint: 'off now' },
|
|
745
|
+
// R300: one row per served project, saying whether its branches are
|
|
746
|
+
// scanned here now — the state the row would change, R288's rule.
|
|
747
|
+
...slugs.map((slug) => (qualityOf(file.projects[slug])
|
|
748
|
+
? { id: `quality:${slug}`, label: `Stop scanning ${slug} with SonarQube`, hint: `${qualityOf(file.projects[slug]).command ?? 'sonar-scanner'} after TEST now` }
|
|
749
|
+
: { id: `quality:${slug}`, label: `Scan ${slug} with SonarQube after TEST`, hint: 'not scanned here' })),
|
|
710
750
|
{ id: 'show', label: "Show this machine's config", hint: runner.configPath ?? '' },
|
|
711
751
|
],
|
|
712
752
|
});
|
|
@@ -959,6 +999,13 @@ export class Attached {
|
|
|
959
999
|
this.runner = event.runner;
|
|
960
1000
|
this.runs = event.runs;
|
|
961
1001
|
this.chooseWatched();
|
|
1002
|
+
} else if (event.type === 'runner') {
|
|
1003
|
+
// R294: the daemon re-read its config — after a signal, or because the
|
|
1004
|
+
// file's mtime moved — and says what it applied and what it could not.
|
|
1005
|
+
// The hello was sent once, when this terminal attached; this is the
|
|
1006
|
+
// same object again, so the footer and the `c` screen read what is
|
|
1007
|
+
// true now.
|
|
1008
|
+
this.runner = event.runner;
|
|
962
1009
|
} else if (event.type === 'runs') {
|
|
963
1010
|
this.runs = event.runs;
|
|
964
1011
|
this.chooseWatched();
|
|
@@ -2125,22 +2172,22 @@ export class Attached {
|
|
|
2125
2172
|
if (!this.requireSignIn('add a project to this machine')) {
|
|
2126
2173
|
return undefined;
|
|
2127
2174
|
}
|
|
2128
|
-
await addProject({
|
|
2175
|
+
const next = await addProject({
|
|
2129
2176
|
configPath, file, url, session: this.session, ask, say, ink, pick,
|
|
2130
2177
|
askPath: (spec) => this.askPath(spec),
|
|
2131
2178
|
inspect: this.options.inspect ?? inspectPath,
|
|
2132
2179
|
clone: (gitUrl, path) => this.cloneInto(gitUrl, path),
|
|
2133
2180
|
});
|
|
2134
|
-
return this.
|
|
2181
|
+
return this.reloadDaemon(file, next);
|
|
2135
2182
|
}
|
|
2136
2183
|
if (chosen.id === 'add-workspace') {
|
|
2137
|
-
await addWorkspace({
|
|
2184
|
+
const next = await addWorkspace({
|
|
2138
2185
|
configPath, file, url, session: this.session, ask, say, ink, pick,
|
|
2139
2186
|
askPath: (spec) => this.askPath(spec),
|
|
2140
2187
|
inspect: this.options.inspect ?? inspectPath,
|
|
2141
2188
|
clone: (gitUrl, path) => this.cloneInto(gitUrl, path),
|
|
2142
2189
|
});
|
|
2143
|
-
return this.
|
|
2190
|
+
return this.reloadDaemon(file, next);
|
|
2144
2191
|
}
|
|
2145
2192
|
if (chosen.id.startsWith('agent:')) {
|
|
2146
2193
|
const command = chosen.id.slice('agent:'.length);
|
|
@@ -2148,44 +2195,53 @@ export class Attached {
|
|
|
2148
2195
|
file, configPath, ask, say, ink, command, enabled: !agentsOf(file).includes(command),
|
|
2149
2196
|
});
|
|
2150
2197
|
this.runner.agents = agentsOf(next);
|
|
2151
|
-
return this.reloadDaemon(next);
|
|
2198
|
+
return this.reloadDaemon(file, next);
|
|
2199
|
+
}
|
|
2200
|
+
if (chosen.id.startsWith('quality:')) {
|
|
2201
|
+
const slug = chosen.id.slice('quality:'.length);
|
|
2202
|
+
const next = await setQuality({
|
|
2203
|
+
file, configPath, ask, say, ink, slug, enabled: !qualityOf(file.projects?.[slug]),
|
|
2204
|
+
});
|
|
2205
|
+
return this.reloadDaemon(file, next);
|
|
2152
2206
|
}
|
|
2153
2207
|
if (chosen.id === 'rules') {
|
|
2154
2208
|
const next = await setAcceptsRulesFromConsole({
|
|
2155
2209
|
file, configPath, ask, say, ink, enabled: file.acceptsRulesFromConsole !== true,
|
|
2156
2210
|
});
|
|
2157
2211
|
this.runner.acceptsConsoleRules = next.acceptsRulesFromConsole === true;
|
|
2158
|
-
return this.reloadDaemon(next);
|
|
2212
|
+
return this.reloadDaemon(file, next);
|
|
2159
2213
|
}
|
|
2160
2214
|
return this.note('');
|
|
2161
2215
|
}
|
|
2162
2216
|
|
|
2163
2217
|
/**
|
|
2164
|
-
*
|
|
2165
|
-
*
|
|
2166
|
-
*
|
|
2218
|
+
* Every change ends here — R294. The daemon this terminal is attached to is
|
|
2219
|
+
* signalled through the pid the hello carries (R283's `reloadIfRunning`),
|
|
2220
|
+
* and what is printed is the DAEMON's verdict: the keys in
|
|
2221
|
+
* lib/runner-config.mjs's `HOT_SWAPPABLE` were applied, or the file has
|
|
2222
|
+
* moved on in ways only a restart picks up. The daemon pushes its re-read
|
|
2223
|
+
* hello to this terminal as a `runner` event, so `hello` here reads
|
|
2224
|
+
* `this.runner` and waits for `configReadAt` to move — no second socket.
|
|
2225
|
+
*
|
|
2226
|
+
* The footer then carries `restartNeeded` for as long as it is true, which
|
|
2227
|
+
* is the change from R288: a muted line that scrolled away was the only
|
|
2228
|
+
* thing that said a project added here was not being served yet.
|
|
2167
2229
|
*/
|
|
2168
|
-
async reloadDaemon(next) {
|
|
2169
|
-
await reloadIfRunning(next.name, (line) => this.say(line), this.ink, {
|
|
2170
|
-
|
|
2230
|
+
async reloadDaemon(file, next) {
|
|
2231
|
+
const verdict = await reloadIfRunning(next.name, (line) => this.say(line), this.ink, {
|
|
2232
|
+
hello: async () => this.runner,
|
|
2233
|
+
fallback: restartNeededFor(file, next),
|
|
2171
2234
|
// Injectable so a test can watch the signal without sending one.
|
|
2172
2235
|
kill: this.options.kill ?? ((pid, signal) => process.kill(pid, signal)),
|
|
2236
|
+
restartHint: RESTART_FROM_ATTACH,
|
|
2237
|
+
waitMs: this.options.reloadWaitMs ?? 3000,
|
|
2173
2238
|
});
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
* the row above look like it took effect. The same sentence `cawdev --setup`
|
|
2181
|
-
* says, because it is the same fact.
|
|
2182
|
-
*/
|
|
2183
|
-
afterProjectsChanged() {
|
|
2184
|
-
const ink = this.ink;
|
|
2185
|
-
this.say(` ${ink.muted('This daemon is still on the config it booted with.')}`);
|
|
2186
|
-
this.say(` ${ink.muted('Press')} ${ink.text('q')} ${ink.muted('to stop it, and the next')} `
|
|
2187
|
-
+ `${ink.text('cawdev')} ${ink.muted('starts one on what you just set up.')}`);
|
|
2188
|
-
return this.note('saved — q, then cawdev, to serve it');
|
|
2239
|
+
if (!verdict.running) {
|
|
2240
|
+
// Reachable when the hello carried no pid — a daemon older than R81.
|
|
2241
|
+
this.say(` ${this.ink.muted('Saved. This daemon is still on the config it booted with — q, then cawdev, to serve it.')}`);
|
|
2242
|
+
return this.note('saved — q, then cawdev, to serve it');
|
|
2243
|
+
}
|
|
2244
|
+
return this.note(verdict.restartNeeded.length ? 'saved — restart to apply' : '');
|
|
2189
2245
|
}
|
|
2190
2246
|
|
|
2191
2247
|
/**
|
package/runner/cawdev.mjs
CHANGED
|
@@ -29,9 +29,10 @@ 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
|
+
import { restartNeededFor } from '../lib/runner-config.mjs';
|
|
35
36
|
import { Select } from './select.mjs';
|
|
36
37
|
import { storedSession } from './sign-in.mjs';
|
|
37
38
|
import { loadToken } from './token-store.mjs';
|
|
@@ -63,6 +64,8 @@ const USAGE = `
|
|
|
63
64
|
already serve — R47's concurrency
|
|
64
65
|
cawdev config agent enable|disable <claude|agy>
|
|
65
66
|
cawdev config rules on|off accept permission rules from the console
|
|
67
|
+
cawdev config quality on|off <slug> scan that project's branches with
|
|
68
|
+
SonarQube after a run's TEST stage
|
|
66
69
|
cawdev config show this machine's config, token redacted
|
|
67
70
|
|
|
68
71
|
On a machine with no config, cawdev sets one up: it signs you in through the
|
|
@@ -404,7 +407,8 @@ async function runVersion(argv, ink) {
|
|
|
404
407
|
/**
|
|
405
408
|
* `cawdev config` — R283's granular half of the setup walk, on a machine
|
|
406
409
|
* already configured. A direct subcommand (`add-project`, `add-workspace`,
|
|
407
|
-
* `agent enable|disable <command>`, `rules on|off`, `
|
|
410
|
+
* `agent enable|disable <command>`, `rules on|off`, `quality on|off <slug>`,
|
|
411
|
+
* `show`) or, with none, a
|
|
408
412
|
* menu — the same `Select` widget everything that offers a choice uses.
|
|
409
413
|
*/
|
|
410
414
|
async function runConfig(args, ink) {
|
|
@@ -448,6 +452,8 @@ export async function pickAction(ask, say, pick = askOne) {
|
|
|
448
452
|
{ id: 'agent-disable', label: 'Disable an agent' },
|
|
449
453
|
{ id: 'rules-on', label: 'Accept permission rules from the console' },
|
|
450
454
|
{ id: 'rules-off', label: 'Stop accepting rules from the console' },
|
|
455
|
+
{ id: 'quality-on', label: 'Scan a project\'s branches with SonarQube after TEST' },
|
|
456
|
+
{ id: 'quality-off', label: 'Stop scanning a project\'s branches' },
|
|
451
457
|
{ id: 'show', label: 'Show this machine\'s config' },
|
|
452
458
|
],
|
|
453
459
|
}));
|
|
@@ -458,22 +464,32 @@ export async function pickAction(ask, say, pick = askOne) {
|
|
|
458
464
|
}
|
|
459
465
|
if (id === 'rules-on') return ['rules', 'on'];
|
|
460
466
|
if (id === 'rules-off') return ['rules', 'off'];
|
|
467
|
+
if (id === 'quality-on' || id === 'quality-off') {
|
|
468
|
+
const slug = (await ask.line(' Which project (slug)? ')).trim();
|
|
469
|
+
return ['quality', id === 'quality-on' ? 'on' : 'off', slug];
|
|
470
|
+
}
|
|
461
471
|
return [id];
|
|
462
472
|
}
|
|
463
473
|
|
|
464
|
-
export async function runAction([verb, ...rest], { configPath, file, url, session, ask, say, ink }) {
|
|
474
|
+
export async function runAction([verb, ...rest], { configPath, file, url, session, ask, say, ink, reloadOptions = {} }) {
|
|
465
475
|
if (verb === 'show') {
|
|
466
476
|
printConfig(file, ink, say);
|
|
467
477
|
return;
|
|
468
478
|
}
|
|
479
|
+
// R294: every change ends the same way — the daemon on this config, if one
|
|
480
|
+
// is running, is signalled and its own verdict is printed. For a project or
|
|
481
|
+
// a workspace that verdict is "needs a restart", because `config.projects`
|
|
482
|
+
// is read once at boot; but it is the daemon saying so about the file it is
|
|
483
|
+
// actually on, not this command guessing about a daemon it cannot see.
|
|
484
|
+
const reload = (next) => reloadIfRunning(file.name, say, ink, {
|
|
485
|
+
fallback: restartNeededFor(file, next), ...reloadOptions,
|
|
486
|
+
});
|
|
469
487
|
if (verb === 'add-project') {
|
|
470
|
-
await addProject({ configPath, file, url, session, ask, say, ink });
|
|
471
|
-
await afterProjectsChanged(file, say, ink);
|
|
488
|
+
await reload(await addProject({ configPath, file, url, session, ask, say, ink }));
|
|
472
489
|
return;
|
|
473
490
|
}
|
|
474
491
|
if (verb === 'add-workspace') {
|
|
475
|
-
await addWorkspace({ configPath, file, url, session, ask, say, ink });
|
|
476
|
-
await afterProjectsChanged(file, say, ink);
|
|
492
|
+
await reload(await addWorkspace({ configPath, file, url, session, ask, say, ink }));
|
|
477
493
|
return;
|
|
478
494
|
}
|
|
479
495
|
if (verb === 'agent') {
|
|
@@ -481,32 +497,27 @@ export async function runAction([verb, ...rest], { configPath, file, url, sessio
|
|
|
481
497
|
if (!['claude', 'agy'].includes(command)) {
|
|
482
498
|
throw new Error('Name an agent: cawdev config agent enable|disable claude|agy');
|
|
483
499
|
}
|
|
484
|
-
await setAgentEnabled({
|
|
500
|
+
await reload(await setAgentEnabled({
|
|
485
501
|
file, configPath, ask, say, ink, command, enabled: sub === 'enable',
|
|
486
|
-
});
|
|
487
|
-
await reloadIfRunning(file.name, say, ink);
|
|
502
|
+
}));
|
|
488
503
|
return;
|
|
489
504
|
}
|
|
490
505
|
if (verb === 'rules') {
|
|
491
506
|
const [sub] = rest;
|
|
492
|
-
await setAcceptsRulesFromConsole({ file, configPath, ask, say, ink, enabled: sub === 'on' });
|
|
493
|
-
await reloadIfRunning(file.name, say, ink);
|
|
507
|
+
await reload(await setAcceptsRulesFromConsole({ file, configPath, ask, say, ink, enabled: sub === 'on' }));
|
|
494
508
|
return;
|
|
495
509
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
export async function afterProjectsChanged(file, say, ink, { probe = probeSocket } = {}) {
|
|
506
|
-
if (await probe(socketPathFor(file.name))) {
|
|
507
|
-
say(` ${ink.muted('A runner is already running here, on the config it booted with.')}`);
|
|
508
|
-
say(` ${ink.muted('Attach and quit to stop it, and the next cawdev starts one on what you just set up.')}`);
|
|
510
|
+
if (verb === 'quality') {
|
|
511
|
+
// R300. The block is captured per project at boot, so the daemon's own
|
|
512
|
+
// verdict here is always "restart" — and it is the daemon that says so.
|
|
513
|
+
const [sub, slug] = rest;
|
|
514
|
+
if (!['on', 'off'].includes(sub) || !slug) {
|
|
515
|
+
throw new Error('Name a project: cawdev config quality on|off <slug>');
|
|
516
|
+
}
|
|
517
|
+
await reload(await setQuality({ file, configPath, ask, say, ink, slug, enabled: sub === 'on' }));
|
|
518
|
+
return;
|
|
509
519
|
}
|
|
520
|
+
throw new Error(`Not a cawdev config command: ${verb}. See cawdev --help.`);
|
|
510
521
|
}
|
|
511
522
|
|
|
512
523
|
/** The config as somebody would want to read it back — never the secret. */
|
package/runner/configure.mjs
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
// **Every change here writes the FILE and nothing else.** A daemon already
|
|
10
10
|
// running keeps the config it booted with — `cawdev --setup` already says so,
|
|
11
11
|
// and this repeats it rather than inventing a second story. The one exception
|
|
12
|
-
// is `reloadIfRunning`:
|
|
13
|
-
// fresh off the live `config` object on every use in runner.mjs (a
|
|
14
|
-
// the next heartbeat), so a SIGHUP that re-reads the file is enough
|
|
15
|
-
// them without a restart — see runner.mjs's own handler for why that
|
|
16
|
-
// signal and not a command sent over R52's read-only socket.
|
|
12
|
+
// is `reloadIfRunning`: the keys in lib/runner-config.mjs's `HOT_SWAPPABLE`
|
|
13
|
+
// are read fresh off the live `config` object on every use in runner.mjs (a
|
|
14
|
+
// spawn, or the next heartbeat), so a SIGHUP that re-reads the file is enough
|
|
15
|
+
// to apply them without a restart — see runner.mjs's own handler for why that
|
|
16
|
+
// is a signal and not a command sent over R52's read-only socket. R294: every
|
|
17
|
+
// change goes through it, and what it prints is the DAEMON's verdict read off
|
|
18
|
+
// the hello — applied, or these keys need a restart — never this file's guess
|
|
19
|
+
// about which keys the daemon can take.
|
|
17
20
|
//
|
|
18
21
|
// R288: the same functions have a SECOND door — the attached terminal's `c`
|
|
19
22
|
// key — and that is why every question here comes in through a parameter.
|
|
@@ -359,23 +362,86 @@ export async function setAcceptsRulesFromConsole({
|
|
|
359
362
|
}
|
|
360
363
|
|
|
361
364
|
/**
|
|
362
|
-
*
|
|
365
|
+
* Turn a project's SonarQube scan on or off — R300.
|
|
366
|
+
*
|
|
367
|
+
* On writes a `quality` block into the project's entry (`{ command, tokenEnv }`,
|
|
368
|
+
* the two things the daemon needs; `runner.mjs`'s `qualityBlockOf` fills in
|
|
369
|
+
* the rest), asking for each with its default offered — Enter keeps it. Off
|
|
370
|
+
* deletes the block and leaves every other key of the entry as it was. A bare
|
|
371
|
+
* path entry is promoted to `{workspaces}` on the way, R47's third form, the
|
|
372
|
+
* way `addWorkspace` promotes one.
|
|
373
|
+
*
|
|
374
|
+
* Nothing here checks the scanner is installed: unlike an agent, the scanner
|
|
375
|
+
* can be missing on a machine that will be told so per run — the daemon
|
|
376
|
+
* reports "not installed" as an error reading, which is the page saying it
|
|
377
|
+
* — and a check here would refuse a config somebody is about to complete.
|
|
378
|
+
* The token is NOT asked for: it is an environment variable this machine
|
|
379
|
+
* exports, never a value in a file people commit.
|
|
380
|
+
*/
|
|
381
|
+
export async function setQuality({
|
|
382
|
+
file, configPath, slug, enabled,
|
|
383
|
+
command, tokenEnv,
|
|
384
|
+
ask, say, ink = painter(3),
|
|
385
|
+
write = writeRunnerConfig,
|
|
386
|
+
} = {}) {
|
|
387
|
+
const projects = file.projects ?? {};
|
|
388
|
+
if (!slug || !(slug in projects)) {
|
|
389
|
+
throw new Error(`This machine does not serve ${slug || 'that project'}. `
|
|
390
|
+
+ `It serves: ${Object.keys(projects).join(', ') || 'nothing yet'}.`);
|
|
391
|
+
}
|
|
392
|
+
const entry = typeof projects[slug] === 'string'
|
|
393
|
+
? { workspaces: [projects[slug]] }
|
|
394
|
+
: { ...projects[slug] };
|
|
395
|
+
|
|
396
|
+
let next;
|
|
397
|
+
if (enabled) {
|
|
398
|
+
const chosenCommand = command
|
|
399
|
+
?? (String(await ask.line(' Scanner command [sonar-scanner]: ')).trim() || 'sonar-scanner');
|
|
400
|
+
const chosenTokenEnv = tokenEnv
|
|
401
|
+
?? (String(await ask.line(' Environment variable holding the scanner token [SONAR_TOKEN]: ')).trim()
|
|
402
|
+
|| 'SONAR_TOKEN');
|
|
403
|
+
entry.quality = { ...(entry.quality ?? {}), command: chosenCommand, tokenEnv: chosenTokenEnv };
|
|
404
|
+
next = { ...file, projects: { ...projects, [slug]: entry } };
|
|
405
|
+
await write(configPath, next);
|
|
406
|
+
say(` ${ink.success('✓')} ${ink.muted(`${slug} scans its branches with`)} ${ink.text(chosenCommand)}`
|
|
407
|
+
+ ` ${ink.muted('after TEST, token from')} ${ink.text('$' + chosenTokenEnv)}`);
|
|
408
|
+
say(` ${ink.muted(`Export ${chosenTokenEnv} where the daemon runs. It is never written to this file.`)}`);
|
|
409
|
+
} else {
|
|
410
|
+
if (!entry.quality) {
|
|
411
|
+
say(` ${ink.muted(`${slug} was already not scanned here.`)}`);
|
|
412
|
+
return file;
|
|
413
|
+
}
|
|
414
|
+
delete entry.quality;
|
|
415
|
+
next = { ...file, projects: { ...projects, [slug]: entry } };
|
|
416
|
+
await write(configPath, next);
|
|
417
|
+
say(` ${ink.success('✓')} ${ink.muted(`${slug} is no longer scanned on this machine.`)}`);
|
|
418
|
+
}
|
|
419
|
+
return next;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The hello of a daemon running on this exact config, or null.
|
|
363
424
|
*
|
|
364
425
|
* The socket is keyed by NAME (R52), not by config path, so a machine
|
|
365
426
|
* renamed between `--setup` and now would miss its own daemon — an edge case
|
|
366
427
|
* left alone deliberately: naming this wrong points the reload at nobody's
|
|
367
428
|
* process rather than somebody else's.
|
|
429
|
+
*
|
|
430
|
+
* R294: the whole `runner` object and not only its pid, because the hello is
|
|
431
|
+
* where the daemon says what it could not apply (`restartNeeded`) and when it
|
|
432
|
+
* last read the file (`configReadAt`) — the two fields `reloadIfRunning`
|
|
433
|
+
* phrases its answer from.
|
|
368
434
|
*/
|
|
369
|
-
async function
|
|
435
|
+
async function liveHello(name) {
|
|
370
436
|
const path = socketPathFor(name);
|
|
371
437
|
if (!(await probeSocket(path))) return null;
|
|
372
438
|
return new Promise((done) => {
|
|
373
439
|
const client = connectTo(path);
|
|
374
440
|
let buffer = '';
|
|
375
|
-
const finish = (
|
|
441
|
+
const finish = (runner) => {
|
|
376
442
|
clearTimeout(timer);
|
|
377
443
|
try { client.destroy(); } catch { /* already gone */ }
|
|
378
|
-
done(
|
|
444
|
+
done(runner);
|
|
379
445
|
};
|
|
380
446
|
const timer = setTimeout(() => finish(null), 1000);
|
|
381
447
|
client.setEncoding('utf8');
|
|
@@ -384,7 +450,7 @@ async function livePidFor(name) {
|
|
|
384
450
|
const newline = buffer.indexOf('\n');
|
|
385
451
|
if (newline === -1) return;
|
|
386
452
|
try {
|
|
387
|
-
finish(JSON.parse(buffer.slice(0, newline))?.runner
|
|
453
|
+
finish(JSON.parse(buffer.slice(0, newline))?.runner ?? null);
|
|
388
454
|
} catch {
|
|
389
455
|
finish(null);
|
|
390
456
|
}
|
|
@@ -393,20 +459,77 @@ async function livePidFor(name) {
|
|
|
393
459
|
});
|
|
394
460
|
}
|
|
395
461
|
|
|
462
|
+
/** How to restart, said from the shell: attach, quit, start again. */
|
|
463
|
+
export const RESTART_FROM_SHELL = 'cawdev, then q, then cawdev again';
|
|
464
|
+
/** The same, said from inside the attached terminal. */
|
|
465
|
+
export const RESTART_FROM_ATTACH = 'q, then cawdev';
|
|
466
|
+
|
|
396
467
|
/**
|
|
397
|
-
* SIGHUP a daemon already running on this config,
|
|
398
|
-
*
|
|
399
|
-
* command — see this file's header and runner.mjs's handler for
|
|
468
|
+
* SIGHUP a daemon already running on this config, and say what came of it.
|
|
469
|
+
*
|
|
470
|
+
* Never a socket command — see this file's header and runner.mjs's handler for
|
|
471
|
+
* why. R294: the daemon applies what it can (`HOT_SWAPPABLE`) and advertises
|
|
472
|
+
* the rest as `restartNeeded`, so this WAITS for the re-read — `configReadAt`
|
|
473
|
+
* moving past the one the hello carried before the signal — and repeats the
|
|
474
|
+
* daemon's own verdict rather than guessing at it. Three sentences, and each
|
|
475
|
+
* is a fact the daemon stated: applied and nothing else changed; applied, and
|
|
476
|
+
* these keys need a restart; signalled, and nothing came back.
|
|
477
|
+
*
|
|
478
|
+
* @param hello reads the daemon's current hello — the socket by default; the
|
|
479
|
+
* attached terminal hands in its own copy, which the daemon pushes to it
|
|
480
|
+
* @param restartHint how to restart from where the person is standing
|
|
481
|
+
* @param fallback what THIS door believes needs a restart — `restartNeededFor`
|
|
482
|
+
* over the file before and after its own write — used only for a daemon
|
|
483
|
+
* too old to say (no `configReadAt` on its hello). The daemon's answer wins
|
|
484
|
+
* whenever there is one, because the daemon knows what it booted with and
|
|
485
|
+
* the door only knows what it just changed
|
|
486
|
+
* @returns what happened, for a caller that wants to draw it:
|
|
487
|
+
* `{ running, applied, restartNeeded }`
|
|
400
488
|
*/
|
|
401
|
-
export async function reloadIfRunning(name, say, ink = painter(3), {
|
|
402
|
-
|
|
403
|
-
|
|
489
|
+
export async function reloadIfRunning(name, say, ink = painter(3), {
|
|
490
|
+
hello = liveHello,
|
|
491
|
+
kill = process.kill,
|
|
492
|
+
restartHint = RESTART_FROM_SHELL,
|
|
493
|
+
fallback = [],
|
|
494
|
+
waitMs = 3000,
|
|
495
|
+
tick = (ms) => new Promise((done) => setTimeout(done, ms)),
|
|
496
|
+
} = {}) {
|
|
497
|
+
const before = await hello(name);
|
|
498
|
+
if (!before?.pid) return { running: false, applied: false, restartNeeded: [] };
|
|
404
499
|
try {
|
|
405
|
-
kill(pid, 'SIGHUP');
|
|
406
|
-
say(` ${ink.success('✓')} ${ink.muted(`Told the running daemon (pid ${pid}) to re-read this — no restart needed.`)}`);
|
|
407
|
-
return true;
|
|
500
|
+
kill(before.pid, 'SIGHUP');
|
|
408
501
|
} catch (failure) {
|
|
409
|
-
say(` ${ink.warn('!')} ${ink.muted(`Could not signal the running daemon (${failure.message}) — restart it to pick this up.`)}`);
|
|
410
|
-
return false;
|
|
502
|
+
say(` ${ink.warn('!')} ${ink.muted(`Could not signal the running daemon (${failure.message}) — restart it to pick this up: ${restartHint}.`)}`);
|
|
503
|
+
return { running: true, applied: false, restartNeeded: [] };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// An older daemon carries no `configReadAt` and cannot be waited for; the
|
|
507
|
+
// most this can say about it is what R283 said.
|
|
508
|
+
let after = null;
|
|
509
|
+
if (before.configReadAt !== undefined) {
|
|
510
|
+
const deadline = Date.now() + waitMs;
|
|
511
|
+
while (Date.now() < deadline) {
|
|
512
|
+
const now = await hello(name);
|
|
513
|
+
if (now?.configReadAt && now.configReadAt !== before.configReadAt) {
|
|
514
|
+
after = now;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
await tick(100);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (!after && before.configReadAt !== undefined) {
|
|
522
|
+
say(` ${ink.warn('!')} ${ink.muted(`Signalled the daemon (pid ${before.pid}) but it did not re-read the file — restart it: ${restartHint}.`)}`);
|
|
523
|
+
return { running: true, applied: false, restartNeeded: [] };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const restartNeeded = after
|
|
527
|
+
? (Array.isArray(after.restartNeeded) ? after.restartNeeded : [])
|
|
528
|
+
: fallback;
|
|
529
|
+
if (restartNeeded.length) {
|
|
530
|
+
say(` ${ink.warn('!')} ${ink.muted('Saved. The running daemon (pid')} ${ink.text(String(before.pid))}${ink.muted(') needs a restart to pick up')} ${ink.text(restartNeeded.join(', '))} ${ink.muted(`— ${restartHint}.`)}`);
|
|
531
|
+
} else {
|
|
532
|
+
say(` ${ink.success('✓')} ${ink.muted(`Told the running daemon (pid ${before.pid}) to re-read this — applied, no restart needed.`)}`);
|
|
411
533
|
}
|
|
534
|
+
return { running: true, applied: true, restartNeeded };
|
|
412
535
|
}
|