cawdev-cli 1.0.5-beta → 1.0.7-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/cawdev.mjs CHANGED
@@ -29,8 +29,8 @@ 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, setQuality,
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';
@@ -63,6 +63,8 @@ const USAGE = `
63
63
  cawdev config add-project serve one more project you can write to
64
64
  cawdev config add-workspace another checkout for a project you
65
65
  already serve — R47's concurrency
66
+ cawdev config remove-project <slug> stop serving a project (its checkouts
67
+ stay on disk)
66
68
  cawdev config agent enable|disable <claude|agy|codex>
67
69
  cawdev config rules on|off accept permission rules from the console
68
70
  cawdev config quality on|off <slug> scan that project's branches with
@@ -422,6 +424,7 @@ async function runVersion(url, ink) {
422
424
  /**
423
425
  * `cawdev config` — R283's granular half of the setup walk, on a machine
424
426
  * already configured. A direct subcommand (`add-project`, `add-workspace`,
427
+ * `remove-project <slug>`,
425
428
  * `agent enable|disable <command>`, `rules on|off`, `quality on|off <slug>`,
426
429
  * `show`) or, with none, a
427
430
  * menu — the same `Select` widget everything that offers a choice uses.
@@ -463,6 +466,7 @@ export async function pickAction(ask, say, pick = askOne) {
463
466
  rows: [
464
467
  { id: 'add-project', label: 'Add a project' },
465
468
  { id: 'add-workspace', label: 'Add a workspace to a project' },
469
+ { id: 'remove-project', label: 'Stop serving a project' },
466
470
  { id: 'agent-enable', label: `Enable an agent (${KNOWN_AGENTS.join(' / ')})` },
467
471
  { id: 'agent-disable', label: 'Disable an agent' },
468
472
  { id: 'rules-on', label: 'Accept permission rules from the console' },
@@ -477,6 +481,9 @@ export async function pickAction(ask, say, pick = askOne) {
477
481
  const command = (await ask.line(` Which agent — ${KNOWN_AGENTS.join(', ')}? `)).trim();
478
482
  return ['agent', id === 'agent-enable' ? 'enable' : 'disable', command];
479
483
  }
484
+ if (id === 'remove-project') {
485
+ return ['remove-project', (await ask.line(' Which project (slug)? ')).trim()];
486
+ }
480
487
  if (id === 'rules-on') return ['rules', 'on'];
481
488
  if (id === 'rules-off') return ['rules', 'off'];
482
489
  if (id === 'quality-on' || id === 'quality-off') {
@@ -507,6 +514,14 @@ export async function runAction([verb, ...rest], { configPath, file, url, sessio
507
514
  await reload(await addWorkspace({ configPath, file, url, session, ask, say, ink }));
508
515
  return;
509
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
+ }
510
525
  if (verb === 'agent') {
511
526
  const [sub, command] = rest;
512
527
  if (!KNOWN_AGENTS.includes(command)) {
@@ -345,6 +345,40 @@ export async function setAgentEnabled({
345
345
  return next;
346
346
  }
347
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
+
348
382
  /** Turn `acceptsRulesFromConsole` on or off — R126. */
349
383
  export async function setAcceptsRulesFromConsole({
350
384
  file, configPath, enabled,
package/runner/input.mjs CHANGED
@@ -136,25 +136,38 @@ export class History {
136
136
  this.draft = '';
137
137
  }
138
138
 
139
- /** Older. Returns null when there is nothing older, so the line is left alone. */
140
- back(current = '') {
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
- if (this.at === 0) {
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 -= 1;
148
- return this.entries[this.at];
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 += 1;
157
- return this.at === this.entries.length ? this.draft : this.entries[this.at];
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() {
@@ -179,7 +192,11 @@ export function completions(text, commands) {
179
192
  return [];
180
193
  }
181
194
  const word = typed.slice(1).toLowerCase();
182
- return commands.filter(([name]) => name.slice(1).toLowerCase().startsWith(word));
195
+ const found = commands.filter(([name]) => name.slice(1).toLowerCase().startsWith(word));
196
+ // A name typed in full comes first, so enter runs `/log` and not `/login`,
197
+ // the first longer one in the list.
198
+ const exact = found.filter(([name]) => name.slice(1).toLowerCase() === word);
199
+ return [...exact, ...found.filter((row) => !exact.includes(row))];
183
200
  }
184
201
 
185
202
  /**
@@ -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
+ }
@@ -0,0 +1,191 @@
1
+ // What a daemon that restarted under its sessions does about them — R339.
2
+ //
3
+ // A daemon killed mid-run (most often for memory, by the kernel, while the
4
+ // session ran a test suite) comes back healthy with an empty `running` map.
5
+ // The platform could only infer what happened: five minutes later
6
+ // StaleRunSweeper failed the run as "its runner is alive but stopped driving
7
+ // it", a sentence that names no cause and arrives after somebody has already
8
+ // gone looking. The one process that KNOWS is the new daemon, so the old one
9
+ // leaves a note: the runs it is driving, written beside the socket every time
10
+ // that set changes. On boot the new one reads it and reports each run it no
11
+ // longer holds as DAEMON_RESTARTED, at once.
12
+ //
13
+ // And so it happens less: `sessionMemoryMax` spawns each agent in a systemd
14
+ // scope with a memory ceiling, so a runaway test suite takes down its own
15
+ // session — the ordinary "agent was terminated" path — and not the daemon.
16
+ //
17
+ // Pure where it can be. The file, the scope probe and the kill are the only
18
+ // side effects, and each takes what it touches as an argument.
19
+
20
+ import { spawn } from 'node:child_process';
21
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
22
+ import { homedir } from 'node:os';
23
+ import { basename, dirname, join } from 'node:path';
24
+
25
+ /**
26
+ * Where a runner's note lives — one per runner id, because one machine may
27
+ * run two configs. NOT in the run directory, which holds sockets and nothing
28
+ * else (the terminal lists it to find daemons): beside it, so a test that
29
+ * points CAWDEV_RUN_DIR at a temporary directory keeps its notes there too.
30
+ */
31
+ export function drivingPath(runnerId, env = process.env) {
32
+ const dir = env.CAWDEV_DRIVING_DIR
33
+ ?? (env.CAWDEV_RUN_DIR ? `${env.CAWDEV_RUN_DIR}-driving` : join(homedir(), '.cawdev', 'driving'));
34
+ return join(dir, `${runnerId}.json`);
35
+ }
36
+
37
+ /**
38
+ * The note: who wrote it and what it was driving. `command` is the child's
39
+ * command's base name, so the next daemon can tell its own orphan from an
40
+ * unrelated process that was handed the same pid after a reboot.
41
+ */
42
+ export function snapshotOf(pid, running) {
43
+ const runs = [];
44
+ for (const [id, child] of running) {
45
+ runs.push({
46
+ id,
47
+ projectSlug: child?.cawdevProjectSlug ?? null,
48
+ childPid: Number.isInteger(child?.pid) ? child.pid : null,
49
+ command: child?.cawdevCommand ? basename(child.cawdevCommand) : null,
50
+ });
51
+ }
52
+ return { pid, runs };
53
+ }
54
+
55
+ /** Atomic: a daemon killed mid-write must not leave half a note for the next one. */
56
+ export async function writeDriving(path, snapshot) {
57
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
58
+ const temporary = `${path}.${process.pid}.tmp`;
59
+ await writeFile(temporary, JSON.stringify(snapshot), { mode: 0o600 });
60
+ await rename(temporary, path);
61
+ }
62
+
63
+ /** The previous note, or null — a missing or unreadable one is "nothing was being driven". */
64
+ export async function readDriving(path) {
65
+ try {
66
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
67
+ return parsed && Number.isInteger(parsed.pid) && Array.isArray(parsed.runs) ? parsed : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The runs a previous daemon left behind, or none.
75
+ *
76
+ * None when the note is this process's own, and none when its writer is
77
+ * still alive: that is a second daemon on the same runner id, and its runs
78
+ * are its own business.
79
+ */
80
+ export function leftBehind(previous, myPid, isAlive) {
81
+ if (!previous || previous.pid === myPid || isAlive(previous.pid)) {
82
+ return [];
83
+ }
84
+ return previous.runs.filter((run) => typeof run.id === 'string' && run.projectSlug);
85
+ }
86
+
87
+ /** Whether a pid is a live process. Signal 0 tests without sending. */
88
+ export function isAlive(pid) {
89
+ if (!Number.isInteger(pid) || pid <= 0) return false;
90
+ try {
91
+ process.kill(pid, 0);
92
+ return true;
93
+ } catch (failure) {
94
+ return failure.code === 'EPERM';
95
+ }
96
+ }
97
+
98
+ /** Live states a restart does not concern: they hold no process. */
99
+ const NOT_DRIVEN = new Set(['QUEUED', 'PAUSED', 'USAGE_LIMITED']);
100
+
101
+ /**
102
+ * Whether the platform's view of a run means a restart killed it: live,
103
+ * holding a process, and still this runner's. A run carried on elsewhere in
104
+ * the meantime is not ours to fail.
105
+ */
106
+ export function killedByRestart(run, runnerId) {
107
+ return Boolean(run?.live)
108
+ && !NOT_DRIVEN.has(run.state)
109
+ && (!run.runnerId || run.runnerId === runnerId);
110
+ }
111
+
112
+ /**
113
+ * What the run says — the cause, the evidence to look for, and the way out.
114
+ * `stopped` is the signal a daemon that stopped ON PURPOSE wrote into its
115
+ * note; without one it was killed, and memory is the usual reason.
116
+ */
117
+ export function restartSummary({ name, previousPid, pid, orphanStopped, stopped = null }) {
118
+ const why = stopped
119
+ ? `It was stopped (${stopped}) — by somebody, or by the service manager restarting it.`
120
+ : 'Most often the machine ran out of memory — `journalctl -k | grep -i "killed process"` '
121
+ + 'says so on Linux; `sessionMemoryMax` in the runner config keeps a session from taking '
122
+ + 'the daemon with it.';
123
+ return `The cawdev daemon on ${name} restarted while driving this run (it was pid `
124
+ + `${previousPid}; it is pid ${pid} now), and the agent session went with it. ${why}`
125
+ + (orphanStopped ? ' The session it left running unattended has been stopped.' : '')
126
+ + ' The workspace is kept with whatever the session had written: carry it on there, or '
127
+ + 'discard it.';
128
+ }
129
+
130
+ // --- the memory ceiling ----------------------------------------------------
131
+
132
+ /**
133
+ * `sessionMemoryMax` as systemd reads it — `6G`, `512M`, `75%` — or null.
134
+ * Anything else is null, and the daemon says so rather than guessing.
135
+ */
136
+ export function memoryMaxOf(value) {
137
+ if (value === undefined || value === null || value === '') return null;
138
+ const text = String(value).trim();
139
+ return /^\d+(\.\d+)?[KMGT]?$|^\d+(\.\d+)?%$/i.test(text) ? text.toUpperCase() : null;
140
+ }
141
+
142
+ /**
143
+ * The command line that runs `command args` under the ceiling — or the same
144
+ * command line, when there is no ceiling to apply. `systemd-run --scope` execs
145
+ * the command in place, so the pid, the process group and the pipes are the
146
+ * agent's own: cancelling (`kill(-pid)`) and the stream readers do not change.
147
+ */
148
+ export function scoped(command, args, scope) {
149
+ if (!scope?.memoryMax) {
150
+ return { command, args };
151
+ }
152
+ return {
153
+ command: 'systemd-run',
154
+ args: [`--${scope.manager}`, '--scope', '--quiet', '--collect', '--no-ask-password',
155
+ '-p', `MemoryMax=${scope.memoryMax}`, '--', command, ...args],
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Whether this machine can apply the ceiling: Linux, and a systemd that will
161
+ * make a scope for this user. Tried with `true` once at boot, because a
162
+ * daemon run as a system service has no user manager and every spawn would
163
+ * otherwise fail on a command that cannot work here.
164
+ */
165
+ export async function probeScope(memoryMax, { platform = process.platform, run = runQuietly } = {}) {
166
+ if (!memoryMax) return { scope: null, why: null };
167
+ if (platform !== 'linux') {
168
+ return { scope: null, why: `sessionMemoryMax needs Linux and systemd; this is ${platform}` };
169
+ }
170
+ for (const manager of ['user', 'system']) {
171
+ const code = await run('systemd-run',
172
+ [`--${manager}`, '--scope', '--quiet', '--collect', '--no-ask-password',
173
+ '-p', `MemoryMax=${memoryMax}`, 'true']);
174
+ if (code === 0) {
175
+ return { scope: { manager, memoryMax }, why: null };
176
+ }
177
+ }
178
+ return {
179
+ scope: null,
180
+ why: 'systemd-run could not make a scope here (no user manager — `loginctl enable-linger` '
181
+ + 'for this user, or run the daemon as root)',
182
+ };
183
+ }
184
+
185
+ function runQuietly(command, args) {
186
+ return new Promise((resolve) => {
187
+ const child = spawn(command, args, { stdio: 'ignore' });
188
+ child.on('error', () => resolve(-1));
189
+ child.on('close', (code) => resolve(code));
190
+ });
191
+ }