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/lib/agents.mjs +38 -0
- package/lib/background-shells.mjs +108 -0
- package/lib/codex.mjs +5 -0
- package/lib/roadmap-format.mjs +52 -0
- package/mcp/server.mjs +62 -11
- package/package.json +1 -1
- package/runner/README.md +191 -22
- package/runner/attach.mjs +708 -69
- package/runner/cawdev.mjs +17 -2
- package/runner/configure.mjs +34 -0
- package/runner/input.mjs +26 -9
- package/runner/prefs.mjs +52 -0
- package/runner/restart.mjs +191 -0
- package/runner/runner.mjs +323 -27
- package/runner/sign-in.mjs +11 -0
- package/runner/split.mjs +109 -16
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,
|
|
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)) {
|
package/runner/configure.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
140
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Older. Returns null when there is nothing older, so the line is left alone.
|
|
141
|
+
*
|
|
142
|
+
* @param keep which entries this line walks — a command line walks the
|
|
143
|
+
* commands and skips the prompts sent to sessions, which it cannot send
|
|
144
|
+
*/
|
|
145
|
+
back(current = '', keep = () => true) {
|
|
141
146
|
if (this.at === this.entries.length) {
|
|
142
147
|
this.draft = current;
|
|
143
148
|
}
|
|
144
|
-
|
|
149
|
+
let at = this.at;
|
|
150
|
+
do {
|
|
151
|
+
at -= 1;
|
|
152
|
+
} while (at >= 0 && !keep(this.entries[at]));
|
|
153
|
+
if (at < 0) {
|
|
145
154
|
return null;
|
|
146
155
|
}
|
|
147
|
-
this.at
|
|
148
|
-
return this.entries[
|
|
156
|
+
this.at = at;
|
|
157
|
+
return this.entries[at];
|
|
149
158
|
}
|
|
150
159
|
|
|
151
160
|
/** Newer, ending at the draft you were writing. */
|
|
152
|
-
forward() {
|
|
161
|
+
forward(keep = () => true) {
|
|
153
162
|
if (this.at >= this.entries.length) {
|
|
154
163
|
return null;
|
|
155
164
|
}
|
|
156
|
-
this.at
|
|
157
|
-
|
|
165
|
+
let at = this.at;
|
|
166
|
+
do {
|
|
167
|
+
at += 1;
|
|
168
|
+
} while (at < this.entries.length && !keep(this.entries[at]));
|
|
169
|
+
this.at = at;
|
|
170
|
+
return at === this.entries.length ? this.draft : this.entries[at];
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
reset() {
|
|
@@ -179,7 +192,11 @@ export function completions(text, commands) {
|
|
|
179
192
|
return [];
|
|
180
193
|
}
|
|
181
194
|
const word = typed.slice(1).toLowerCase();
|
|
182
|
-
|
|
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
|
/**
|
package/runner/prefs.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this terminal remembers about how you like to work — beside history.mjs
|
|
3
|
+
* and in its shape: one file under ~/.cawdev, keyed by the platform's URL, 0600.
|
|
4
|
+
*
|
|
5
|
+
* One preference so far: the agent `/new` asks for when a command names none
|
|
6
|
+
* (`/agent codex`). Kept here and not on the platform, because it is a choice
|
|
7
|
+
* about THIS terminal — the project's default and your account's live in the
|
|
8
|
+
* console, and this sits in front of them without changing either.
|
|
9
|
+
*
|
|
10
|
+
* @author Claude Opus 5.5 (Claude Code)
|
|
11
|
+
* @author Moez Ben MBarka
|
|
12
|
+
*/
|
|
13
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export function prefsFile() {
|
|
18
|
+
return process.env.CAWDEV_PREFS_FILE ?? join(homedir(), '.cawdev', 'terminal.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function key(url) {
|
|
22
|
+
return String(url).replace(/\/+$/, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readAll() {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(await readFile(prefsFile(), 'utf8'));
|
|
28
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** This platform's preferences, `{}` when there are none or the file is unreadable. */
|
|
35
|
+
export async function loadPrefs(url) {
|
|
36
|
+
const mine = (await readAll())[key(url)];
|
|
37
|
+
return mine && typeof mine === 'object' ? mine : {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Merges `changes` into this platform's preferences; a null value removes the key. */
|
|
41
|
+
export async function savePrefs(url, changes) {
|
|
42
|
+
const all = await readAll();
|
|
43
|
+
const mine = { ...(all[key(url)] ?? {}) };
|
|
44
|
+
for (const [name, value] of Object.entries(changes)) {
|
|
45
|
+
if (value === null || value === undefined) delete mine[name];
|
|
46
|
+
else mine[name] = value;
|
|
47
|
+
}
|
|
48
|
+
all[key(url)] = mine;
|
|
49
|
+
await mkdir(dirname(prefsFile()), { recursive: true, mode: 0o700 });
|
|
50
|
+
await writeFile(prefsFile(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
|
51
|
+
await chmod(prefsFile(), 0o600).catch(() => undefined);
|
|
52
|
+
}
|
|
@@ -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
|
+
}
|