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/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
- const checkouts = slugs.map((slug) => `${slug}: ${pathsOf(file.projects[slug]).length}`).join(', ');
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: slugs.length ? `serves ${slugs.join(', ')}` : 'serves nothing yet' },
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.afterProjectsChanged();
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.afterProjectsChanged();
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
- * The two settings the daemon re-reads on SIGHUP, applied to the one this
2165
- * terminal is attached to — R283's `reloadIfRunning` with the pid handed in,
2166
- * since the hello already says which process this is.
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
- pidOf: async () => this.runner?.pid ?? null,
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
- return this.note('');
2175
- }
2176
-
2177
- /**
2178
- * Adding a project or a workspace is not hot-swappable — `config.projects`
2179
- * is read once at boot — so this says what happens next rather than letting
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`, `show`) or, with none, a
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
- throw new Error(`Not a cawdev config command: ${verb}. See cawdev --help.`);
497
- }
498
-
499
- /**
500
- * Adding a project or a workspace is NOT hot-swappable — `config.projects` is
501
- * read once at boot and threaded everywhere by value, unlike the three
502
- * settings runner.mjs's SIGHUP handler re-reads. So this only says why
503
- * nothing happened yet, on the daemon this config would actually restart as.
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. */
@@ -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`: `agentCommands` and `acceptsRulesFromConsole` are read
13
- // fresh off the live `config` object on every use in runner.mjs (a spawn, or
14
- // the next heartbeat), so a SIGHUP that re-reads the file is enough to apply
15
- // them without a restart — see runner.mjs's own handler for why that is a
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
- * The pid of a daemon running on this exact config, or null.
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 livePidFor(name) {
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 = (pid) => {
441
+ const finish = (runner) => {
376
442
  clearTimeout(timer);
377
443
  try { client.destroy(); } catch { /* already gone */ }
378
- done(pid);
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?.pid ?? null);
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, so `agentCommands` and
398
- * `acceptsRulesFromConsole` take effect without a restart. Never a socket
399
- * command — see this file's header and runner.mjs's handler for why.
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), { pidOf = livePidFor, kill = process.kill } = {}) {
402
- const pid = await pidOf(name);
403
- if (!pid) return false;
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
  }