@monster-orange/den 0.4.21 → 0.4.23
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/CHANGELOG.md +115 -0
- package/docs/distributions.md +186 -42
- package/package.json +1 -1
- package/service/install.sh +213 -63
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/guardrails/rotation-hook.ts +5 -0
- package/src/shared/agents/records.ts +6 -0
- package/src/shared/agents/rotate-settings.ts +309 -0
- package/src/shared/agents/rotation.ts +110 -5
- package/src/shared/agents/runtime.ts +6 -0
- package/src/shared/agents/session-name.ts +21 -1
- package/src/shared/den-config.ts +27 -12
- package/src/shared/launchd-session.ts +92 -0
- package/src/shared/sessions/client.ts +24 -0
- package/src/shared/sessions/host.ts +114 -5
- package/src/shared/sessions/types.ts +5 -0
- package/src/ui/cli/agent-ops.ts +469 -17
- package/src/ui/cli/agents-command.ts +200 -3
- package/src/ui/cli/doctor-command.ts +72 -0
- package/src/ui/cli/doctor-where.ts +203 -0
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/server-agent-ops.ts +2 -2
- package/src/ui/cli/service-command.ts +217 -11
- package/src/ui/cli/sessions-command.ts +46 -12
- package/src/ui/cli/web-command.ts +36 -5
- package/src/ui/cli/web-service.ts +160 -0
- package/src/ui/server/agent-routes.ts +149 -5
- package/src/ui/server/index.ts +17 -5
- package/src/ui/server/public/agents-page.js +316 -27
- package/src/ui/server/public/agents.html +62 -0
- package/src/ui/server/public/i18n/es.js +58 -2
- package/src/ui/server/public/i18n/ja.js +54 -2
- package/src/ui/server/public/settings-registry.js +204 -0
- package/src/ui/server/public/settings.html +43 -4
- package/src/ui/server/public/shell.js +56 -2
- package/src/ui/server/route-classes.ts +21 -0
- package/src/ui/server/session-routes.ts +27 -5
|
@@ -14,6 +14,19 @@
|
|
|
14
14
|
* (install.sh print), the label, the paths and whose server answers on the
|
|
15
15
|
* port, and writes and loads nothing; exit 1 when that server is foreign.
|
|
16
16
|
* `status` also names a foreign server on the port.
|
|
17
|
+
*
|
|
18
|
+
* D18: `install --mode login|always`. login: LaunchAgents in the desktop
|
|
19
|
+
* session, stopped at logout. always: LaunchDaemons that run as you, kept
|
|
20
|
+
* across logout (one sudo). Without --mode, the mode already installed
|
|
21
|
+
* (else login): re-running install never changes it. Both modes also
|
|
22
|
+
* install the session host as its own job, on Den's node copy
|
|
23
|
+
* (`<stateDir>/bin/<cli>-sessions-host`, the path Full Disk Access is
|
|
24
|
+
* granted to), and `<cli> sessions` then waits for that job instead of
|
|
25
|
+
* starting a host of its own.
|
|
26
|
+
*
|
|
27
|
+
* D26: "running" means the server answering on the port is the job's own
|
|
28
|
+
* process (its pid is launchd's). A plain `web up` server of this install
|
|
29
|
+
* holding the port is stopped first; `status` says when one holds it.
|
|
17
30
|
*/
|
|
18
31
|
import {
|
|
19
32
|
ownerGithubLine,
|
|
@@ -21,8 +34,15 @@ import {
|
|
|
21
34
|
type OwnerGithubResult,
|
|
22
35
|
} from "../../shared/owner-github.js";
|
|
23
36
|
import { spawnSync } from "child_process";
|
|
37
|
+
import * as fs from "fs";
|
|
24
38
|
import * as os from "os";
|
|
25
39
|
import * as path from "path";
|
|
40
|
+
import { getStateDir } from "../../shared/paths.js";
|
|
41
|
+
import {
|
|
42
|
+
serviceJobState,
|
|
43
|
+
type ServiceJob,
|
|
44
|
+
type ServiceMode,
|
|
45
|
+
} from "./doctor-where.js";
|
|
26
46
|
import {
|
|
27
47
|
readDenConfig,
|
|
28
48
|
readWebServiceConfig,
|
|
@@ -31,6 +51,7 @@ import {
|
|
|
31
51
|
import { readWebPort } from "../../shared/edge/config.js";
|
|
32
52
|
import { getDistRoot, getEngineRoot } from "../../shared/paths.js";
|
|
33
53
|
import { product } from "../../shared/product.js";
|
|
54
|
+
import { ALWAYS_UNPROVEN } from "../../shared/launchd-session.js";
|
|
34
55
|
import {
|
|
35
56
|
classifyServerInfo,
|
|
36
57
|
ForeignServerError,
|
|
@@ -53,7 +74,7 @@ const SUBCOMMANDS = ["install", "status", "print", "uninstall"] as const;
|
|
|
53
74
|
|
|
54
75
|
/** The flags each subcommand takes; any other is refused before acting. */
|
|
55
76
|
const SUBCOMMAND_FLAGS: Record<(typeof SUBCOMMANDS)[number], string[]> = {
|
|
56
|
-
install: ["--plan"],
|
|
77
|
+
install: ["--plan", "--mode"],
|
|
57
78
|
status: [],
|
|
58
79
|
print: [],
|
|
59
80
|
uninstall: [],
|
|
@@ -105,6 +126,14 @@ export async function waitForServerInfo(
|
|
|
105
126
|
|
|
106
127
|
export interface ServiceDeps {
|
|
107
128
|
run?: ServiceRunner;
|
|
129
|
+
/** The state dir (default getStateDir()): Den's node copy, the host job file. */
|
|
130
|
+
stateDir?: string;
|
|
131
|
+
/** D18: the job installed for a label, if any (default: plists + launchctl). */
|
|
132
|
+
job?: (label: string) => ServiceJob | null;
|
|
133
|
+
/** D26: the pid launchd runs a job as, or null (default: launchctl print). */
|
|
134
|
+
jobPid?: (job: ServiceJob, label: string) => number | null;
|
|
135
|
+
/** D26: stop a plain server of this install (default: SIGTERM, wait). */
|
|
136
|
+
stopPlain?: (pid: number, port: number) => Promise<boolean>;
|
|
108
137
|
/** D17: record web.owner_github (default: from gh, unless `run` is a test's). */
|
|
109
138
|
recordOwner?: () => OwnerGithubResult;
|
|
110
139
|
config?: DenConfig;
|
|
@@ -142,20 +171,108 @@ export function identityLine(port: number, id: ServerIdentity): string {
|
|
|
142
171
|
}
|
|
143
172
|
}
|
|
144
173
|
|
|
174
|
+
/** D18: the session host's job label: the web label's `.web` → `.sessions`. */
|
|
175
|
+
export function hostLabel(label: string): string {
|
|
176
|
+
return `${label.replace(/\.web$/, "")}.sessions`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** D18: Den's node copy for the session host, the path Full Disk Access names. */
|
|
180
|
+
export function hostExePath(stateDir: string, cli: string): string {
|
|
181
|
+
return path.join(stateDir, "bin", `${cli}-sessions-host`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* D18: the file that says the session host runs as a launchd job
|
|
186
|
+
* (`<cli> sessions` waits for it instead of starting one).
|
|
187
|
+
*/
|
|
188
|
+
export function hostJobFile(stateDir: string): string {
|
|
189
|
+
return path.join(stateDir, "sessions-host-job.json");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** `--mode <m>` / `--mode=<m>` from install's args; undefined when absent. */
|
|
193
|
+
function modeArg(args: string[]): ServiceMode | undefined {
|
|
194
|
+
let v: string | undefined;
|
|
195
|
+
for (let i = 0; i < args.length; i++) {
|
|
196
|
+
if (args[i] === "--mode") v = args[i + 1];
|
|
197
|
+
else if (args[i].startsWith("--mode=")) v = args[i].slice(7);
|
|
198
|
+
}
|
|
199
|
+
if (v === undefined) return undefined;
|
|
200
|
+
if (v === "login" || v === "always") return v;
|
|
201
|
+
throw new Error(`service install --mode takes login or always, not "${v}"`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Install's args without `--mode <m>`, for the unknown-argument check. */
|
|
205
|
+
function withoutMode(args: string[]): string[] {
|
|
206
|
+
const out: string[] = [];
|
|
207
|
+
for (let i = 0; i < args.length; i++) {
|
|
208
|
+
if (args[i] === "--mode") i++;
|
|
209
|
+
else if (!args[i].startsWith("--mode=")) out.push(args[i]);
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function defaultJobPid(job: ServiceJob, label: string): number | null {
|
|
215
|
+
const r = spawnSync("launchctl", ["print", `${job.domain}/${label}`], {
|
|
216
|
+
encoding: "utf-8",
|
|
217
|
+
timeout: 3000,
|
|
218
|
+
});
|
|
219
|
+
if (r.status !== 0) return null;
|
|
220
|
+
const m = /^\s*pid = (\d+)/m.exec(r.stdout ?? "");
|
|
221
|
+
return m ? Number(m[1]) : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function defaultStopPlain(pid: number, port: number): Promise<boolean> {
|
|
225
|
+
const { killWebProcess } = await import("./web-command.js");
|
|
226
|
+
killWebProcess(pid, "SIGTERM");
|
|
227
|
+
const deadline = Date.now() + 10_000;
|
|
228
|
+
while (Date.now() < deadline) {
|
|
229
|
+
try {
|
|
230
|
+
process.kill(pid, 0);
|
|
231
|
+
} catch {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
235
|
+
}
|
|
236
|
+
void port;
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** `service help`: the subcommands and the two install modes. */
|
|
241
|
+
export function serviceHelp(cli: string): string[] {
|
|
242
|
+
return [
|
|
243
|
+
`${cli} service: run the web UI and the session host as launchd jobs (macOS)`,
|
|
244
|
+
``,
|
|
245
|
+
` install [--mode login|always] install (or refresh) and start them`,
|
|
246
|
+
` install --plan what install would do; writes and loads nothing`,
|
|
247
|
+
` status launchd's view: state, pid, last exit code`,
|
|
248
|
+
` print the web plist it would install; installs nothing`,
|
|
249
|
+
` uninstall stop them and remove their plists`,
|
|
250
|
+
``,
|
|
251
|
+
` --mode login (default) LaunchAgents in the desktop session: stop at logout, with every agent`,
|
|
252
|
+
` --mode always LaunchDaemons running as you, one sudo: meant to keep agents running across logout. Opt-in, ${ALWAYS_UNPROVEN}`,
|
|
253
|
+
` Without --mode, install keeps the mode already installed.`,
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
|
|
145
257
|
/** Run `service <sub>`; returns the exit code. */
|
|
146
258
|
export async function cmdService(
|
|
147
259
|
args: string[],
|
|
148
260
|
deps: ServiceDeps = {},
|
|
149
261
|
): Promise<number> {
|
|
150
|
-
|
|
151
|
-
|
|
262
|
+
if (args[0] === undefined || ["help", "--help", "-h"].includes(args[0])) {
|
|
263
|
+
for (const l of serviceHelp(product().cli))
|
|
264
|
+
(deps.log ?? ((x: string) => console.log(x)))(l);
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
const sub = args[0] as (typeof SUBCOMMANDS)[number];
|
|
268
|
+
if (!(SUBCOMMANDS as readonly string[]).includes(sub))
|
|
152
269
|
throw new Error(
|
|
153
|
-
|
|
270
|
+
`Unknown service command "${sub}". Use: ${SUBCOMMANDS.join(" | ")} (${product().cli} service help)`,
|
|
154
271
|
);
|
|
155
272
|
// A typo'd --plan must not install for real.
|
|
156
|
-
const extra =
|
|
157
|
-
.slice(1)
|
|
158
|
-
|
|
273
|
+
const extra = (
|
|
274
|
+
sub === "install" ? withoutMode(args.slice(1)) : args.slice(1)
|
|
275
|
+
).filter((a) => !SUBCOMMAND_FLAGS[sub!].includes(a));
|
|
159
276
|
if (extra.length)
|
|
160
277
|
throw new Error(
|
|
161
278
|
`service ${sub}: unknown argument${extra.length > 1 ? "s" : ""} ${extra.join(" ")}` +
|
|
@@ -164,6 +281,7 @@ export async function cmdService(
|
|
|
164
281
|
: ""),
|
|
165
282
|
);
|
|
166
283
|
const plan = sub === "install" && args.includes("--plan");
|
|
284
|
+
const askedMode = sub === "install" ? modeArg(args) : undefined;
|
|
167
285
|
// print (and install --plan) only write the plist to stdout; the rest
|
|
168
286
|
// talk to launchd.
|
|
169
287
|
if (
|
|
@@ -185,10 +303,24 @@ export async function cmdService(
|
|
|
185
303
|
"service",
|
|
186
304
|
"install.sh",
|
|
187
305
|
);
|
|
188
|
-
const
|
|
306
|
+
const home = deps.home ?? os.homedir();
|
|
307
|
+
const jobOf =
|
|
308
|
+
deps.job ??
|
|
309
|
+
((l: string) => serviceJobState(l, home, process.getuid?.() ?? 0));
|
|
310
|
+
const installed = jobOf(label);
|
|
311
|
+
// D18: without --mode, the mode it has (re-running install keeps it).
|
|
312
|
+
const mode: ServiceMode = askedMode ?? installed?.mode ?? "login";
|
|
313
|
+
const stateDir = deps.stateDir ?? getStateDir();
|
|
314
|
+
const env: Record<string, string> = {
|
|
189
315
|
DEN_ROOT: distRoot,
|
|
190
316
|
DEN_SERVICE_LABEL: label,
|
|
191
317
|
DEN_SERVICE_LOGS: logs,
|
|
318
|
+
...(sub === "install" || sub === "print"
|
|
319
|
+
? {
|
|
320
|
+
DEN_SERVICE_MODE: mode,
|
|
321
|
+
DEN_SERVICE_HOST_EXE: hostExePath(stateDir, product().cli),
|
|
322
|
+
}
|
|
323
|
+
: {}),
|
|
192
324
|
};
|
|
193
325
|
const run = deps.run ?? defaultRunner;
|
|
194
326
|
const log = deps.log ?? ((l: string) => console.log(l));
|
|
@@ -197,12 +329,20 @@ export async function cmdService(
|
|
|
197
329
|
const identify =
|
|
198
330
|
deps.identify ?? ((p: number, o: OwnServer) => identifyServer(p, o));
|
|
199
331
|
|
|
332
|
+
const jobPid = deps.jobPid ?? defaultJobPid;
|
|
200
333
|
if (plan) {
|
|
201
|
-
const
|
|
334
|
+
const dir =
|
|
335
|
+
mode === "always"
|
|
336
|
+
? "/Library/LaunchDaemons"
|
|
337
|
+
: path.join(home, "Library", "LaunchAgents");
|
|
202
338
|
log(`service install --plan (read-only: nothing is written or loaded)`);
|
|
339
|
+
log(
|
|
340
|
+
` mode: ${mode}${mode === "always" ? ` (LaunchDaemons running as you: meant to keep running across logout, ${ALWAYS_UNPROVEN}; opt-in; one sudo)` : " (LaunchAgents: stop at logout)"}`,
|
|
341
|
+
);
|
|
203
342
|
log(` label: ${label}`);
|
|
343
|
+
log(` plist: ${path.join(dir, `${label}.plist`)}`);
|
|
204
344
|
log(
|
|
205
|
-
`
|
|
345
|
+
` host: ${hostLabel(label)} on ${hostExePath(stateDir, product().cli)} (grant this path Full Disk Access for protected folders)`,
|
|
206
346
|
);
|
|
207
347
|
log(` logs: ${path.join(logs, "web.log")}`);
|
|
208
348
|
log(` root: ${distRoot}`);
|
|
@@ -210,6 +350,15 @@ export async function cmdService(
|
|
|
210
350
|
log(` url: http://localhost:${port}`);
|
|
211
351
|
const id = await identify(port, own);
|
|
212
352
|
log(` server: ${identityLine(port, id)}`);
|
|
353
|
+
// D26: what install does with a plain server of this install.
|
|
354
|
+
if (
|
|
355
|
+
id.kind === "ours" &&
|
|
356
|
+
id.pid &&
|
|
357
|
+
id.pid !== (installed ? jobPid(installed, label) : null)
|
|
358
|
+
)
|
|
359
|
+
log(
|
|
360
|
+
` install stops this plain server (pid ${id.pid}) first; the service runs it then`,
|
|
361
|
+
);
|
|
213
362
|
log(` plist it would write:`);
|
|
214
363
|
// install.sh print writes the plist to stdout only.
|
|
215
364
|
const code = run("bash", [script, "print"], env);
|
|
@@ -221,6 +370,26 @@ export async function cmdService(
|
|
|
221
370
|
return 0;
|
|
222
371
|
}
|
|
223
372
|
|
|
373
|
+
// D26: a plain `web up` server of this install on the port would answer
|
|
374
|
+
// for the job (which then crash-loops on the taken port): stop it first.
|
|
375
|
+
if (sub === "install") {
|
|
376
|
+
const id = await identify(port, own);
|
|
377
|
+
const pid = id.kind === "ours" ? id.pid : undefined;
|
|
378
|
+
const theJob = installed ? jobPid(installed, label) : null;
|
|
379
|
+
if (pid && pid !== theJob) {
|
|
380
|
+
const stopped = await (deps.stopPlain ?? defaultStopPlain)(pid, port);
|
|
381
|
+
if (!stopped) {
|
|
382
|
+
log(
|
|
383
|
+
`✗ a plain server of this install (pid ${pid}) holds port ${port} and did not stop: ${product().cli} web down, then service install again`,
|
|
384
|
+
);
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
log(
|
|
388
|
+
`… stopped the plain server of this install (pid ${pid}) on port ${port}: the service runs it now`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
224
393
|
// D17: whoever installs it can connect their GitHub account from any
|
|
225
394
|
// browser (web.owner_github, from gh), before the server reads it.
|
|
226
395
|
if (sub === "install") {
|
|
@@ -229,18 +398,43 @@ export async function cmdService(
|
|
|
229
398
|
(deps.run ? () => ({ state: "linked" as const }) : recordOwnerGithub);
|
|
230
399
|
const line = ownerGithubLine(rec(), product().cli);
|
|
231
400
|
if (line) log(line);
|
|
401
|
+
if (mode === "always")
|
|
402
|
+
log(`note: --mode always is opt-in and ${ALWAYS_UNPROVEN}`);
|
|
232
403
|
}
|
|
233
404
|
const code = run("bash", [script, sub!], {
|
|
234
405
|
...env,
|
|
235
406
|
// The script then leaves saying "running" to us (after the poll below).
|
|
236
407
|
...(sub === "install" ? { DEN_SERVICE_READY_CHECK: "external" } : {}),
|
|
237
408
|
});
|
|
409
|
+
if (sub === "uninstall" && code === 0)
|
|
410
|
+
fs.rmSync(hostJobFile(stateDir), { force: true });
|
|
238
411
|
if (sub === "status") {
|
|
239
412
|
const id = await identify(port, own);
|
|
240
413
|
if (id.kind === "foreign") log(`✗ ${identityLine(port, id)}`);
|
|
414
|
+
// D26: the server on the port is the job's, or say whose it is.
|
|
415
|
+
if (id.kind === "ours" && id.pid && installed) {
|
|
416
|
+
const theJob = jobPid(installed, label);
|
|
417
|
+
if (theJob !== id.pid) {
|
|
418
|
+
log(
|
|
419
|
+
`✗ port ${port} is served by pid ${id.pid}, not the service (${theJob ? `pid ${theJob}` : "not running"}): a plain server of this install holds it. ${product().cli} web down, then ${product().cli} service install`,
|
|
420
|
+
);
|
|
421
|
+
return 1;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
241
424
|
return code;
|
|
242
425
|
}
|
|
243
426
|
if (sub !== "install" || code !== 0) return code;
|
|
427
|
+
// D18: `<cli> sessions` waits for the host job instead of starting one.
|
|
428
|
+
try {
|
|
429
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
430
|
+
fs.writeFileSync(
|
|
431
|
+
hostJobFile(stateDir),
|
|
432
|
+
JSON.stringify({ label: hostLabel(label), mode }) + "\n",
|
|
433
|
+
{ mode: 0o600 },
|
|
434
|
+
);
|
|
435
|
+
} catch {
|
|
436
|
+
/* sessions then start a host of their own, as before */
|
|
437
|
+
}
|
|
244
438
|
|
|
245
439
|
const url = `http://localhost:${port}/api/server-info`;
|
|
246
440
|
const waitReady =
|
|
@@ -258,8 +452,20 @@ export async function cmdService(
|
|
|
258
452
|
return 1;
|
|
259
453
|
}
|
|
260
454
|
if (ready) {
|
|
455
|
+
// D26: the answer must come from the job itself, not from another
|
|
456
|
+
// process of this install on the port (the job then crash-loops).
|
|
457
|
+
const job = jobOf(label);
|
|
458
|
+
const id = await identify(port, own);
|
|
459
|
+
const served = id.kind === "ours" ? id.pid : undefined;
|
|
460
|
+
const theJob = job ? jobPid(job, label) : null;
|
|
461
|
+
if (job && served && theJob !== served) {
|
|
462
|
+
log(
|
|
463
|
+
`✗ ${label} installed, but port ${port} is served by pid ${served}, not the job (${theJob ? `pid ${theJob}` : "not running"}): ${product().cli} web down, then ${product().cli} service install`,
|
|
464
|
+
);
|
|
465
|
+
return 1;
|
|
466
|
+
}
|
|
261
467
|
log(
|
|
262
|
-
`✓ ${label} running at http://localhost:${port} (logs: ${path.join(logs, "web.log")})`,
|
|
468
|
+
`✓ ${label} running (${mode}) at http://localhost:${port} (logs: ${path.join(logs, "web.log")})`,
|
|
263
469
|
);
|
|
264
470
|
return 0;
|
|
265
471
|
}
|
|
@@ -133,6 +133,33 @@ export interface Resolved {
|
|
|
133
133
|
stdout: NodeJS.WriteStream;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* D18: the session host's launchd job, when `service install` set one up
|
|
138
|
+
* and its plist is still there: then the job starts the host (in the
|
|
139
|
+
* session its mode chose), never a process of ours, which would run in
|
|
140
|
+
* this caller's launchd session (D20: a desktop one ends at logout).
|
|
141
|
+
*/
|
|
142
|
+
export function hostJob(
|
|
143
|
+
stateDir: string,
|
|
144
|
+
home: string = process.env.HOME ?? "",
|
|
145
|
+
exists: (p: string) => boolean = fs.existsSync,
|
|
146
|
+
): { label: string; mode: string } | null {
|
|
147
|
+
try {
|
|
148
|
+
const v = JSON.parse(
|
|
149
|
+
fs.readFileSync(path.join(stateDir, "sessions-host-job.json"), "utf-8"),
|
|
150
|
+
) as { label?: unknown; mode?: unknown };
|
|
151
|
+
if (typeof v.label !== "string" || !/^[\w.-]+$/.test(v.label)) return null;
|
|
152
|
+
const plists = [
|
|
153
|
+
path.join("/Library/LaunchDaemons", `${v.label}.plist`),
|
|
154
|
+
path.join(home, "Library", "LaunchAgents", `${v.label}.plist`),
|
|
155
|
+
];
|
|
156
|
+
if (!plists.some((p) => exists(p))) return null;
|
|
157
|
+
return { label: v.label, mode: String(v.mode ?? "") };
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
136
163
|
/** Start `<cli> sessions host` in the background, detached, logging to the state dir. */
|
|
137
164
|
function spawnHost(stateDir: string): void {
|
|
138
165
|
const cmd = cliEntrypoint();
|
|
@@ -164,17 +191,17 @@ export function resolveDeps(d: SessionsDeps): Resolved {
|
|
|
164
191
|
cli: d.cli ?? product().cli,
|
|
165
192
|
connect:
|
|
166
193
|
d.connect ??
|
|
167
|
-
(() =>
|
|
168
|
-
|
|
194
|
+
(() => {
|
|
195
|
+
const job = hostJob(stateDir());
|
|
196
|
+
return ensureHost({
|
|
169
197
|
socket: sessionPaths(stateDir()).socket,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
product().cli,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
})),
|
|
198
|
+
// D18: launchd starts (and restarts) the host job; wait for it.
|
|
199
|
+
spawnHost: job ? () => undefined : () => spawnHost(stateDir()),
|
|
200
|
+
logHint: job
|
|
201
|
+
? `its launchd job ${job.label} (${job.mode}): ${product().cli} service status`
|
|
202
|
+
: path.join(stateDir(), "logs", product().cli, "sessions-host.log"),
|
|
203
|
+
});
|
|
204
|
+
}),
|
|
178
205
|
connectExisting:
|
|
179
206
|
d.connectExisting ??
|
|
180
207
|
(d.connect
|
|
@@ -314,6 +341,8 @@ export function contractInfo(s: SessionInfo): SessionInfo {
|
|
|
314
341
|
startedAt: s.startedAt,
|
|
315
342
|
...(s.exited ? { exited: true } : {}),
|
|
316
343
|
...(s.exitCode !== undefined ? { exitCode: s.exitCode } : {}),
|
|
344
|
+
// D20: where its host ran (Aqua: it ends at logout).
|
|
345
|
+
...(s.launchd ? { launchd: s.launchd } : {}),
|
|
317
346
|
};
|
|
318
347
|
}
|
|
319
348
|
|
|
@@ -677,10 +706,15 @@ function attachTerminal(
|
|
|
677
706
|
|
|
678
707
|
async function host(args: string[], d: Resolved): Promise<number> {
|
|
679
708
|
const where = "sessions host";
|
|
680
|
-
const p = parseArgs(where, args, { "--stop": false });
|
|
709
|
+
const p = parseArgs(where, args, { "--stop": false, "--stand-by": false });
|
|
681
710
|
noExtra(where, p);
|
|
682
711
|
if (!p.flags["--stop"])
|
|
683
|
-
return runSessionHost({
|
|
712
|
+
return runSessionHost({
|
|
713
|
+
stateDir: d.stateDir(),
|
|
714
|
+
pty: () => loadPty(),
|
|
715
|
+
// D18: the launchd job's: wait out a host started before it.
|
|
716
|
+
standBy: Boolean(p.flags["--stand-by"]),
|
|
717
|
+
});
|
|
684
718
|
const c = await d.connectExisting();
|
|
685
719
|
if (!c) {
|
|
686
720
|
d.out("no session host is running");
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
ownerGithubLine,
|
|
3
3
|
recordOwnerGithub,
|
|
4
4
|
} from "../../shared/owner-github.js";
|
|
5
|
+
import { launchdSession, webUpWarning } from "../../shared/launchd-session.js";
|
|
5
6
|
import chalk from "chalk";
|
|
6
7
|
import { execFileSync, spawn, spawnSync } from "child_process";
|
|
7
8
|
import * as fs from "fs";
|
|
@@ -69,6 +70,7 @@ import {
|
|
|
69
70
|
} from "./server-identity.js";
|
|
70
71
|
|
|
71
72
|
import { product } from "../../shared/product.js";
|
|
73
|
+
import { webServiceAction, type WebServiceDeps } from "./web-service.js";
|
|
72
74
|
import {
|
|
73
75
|
CLAIM_CODE_TTL_MS,
|
|
74
76
|
claimLink,
|
|
@@ -1264,6 +1266,12 @@ export function cmdWebToken(
|
|
|
1264
1266
|
return 0;
|
|
1265
1267
|
}
|
|
1266
1268
|
|
|
1269
|
+
/** Tests: the launchd service seams (web-service.ts); never the real launchctl. */
|
|
1270
|
+
let webServiceDeps: WebServiceDeps | undefined;
|
|
1271
|
+
export function setWebServiceDepsForTest(d: WebServiceDeps | undefined): void {
|
|
1272
|
+
webServiceDeps = d;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1267
1275
|
export async function cmdWeb(args: string[]): Promise<void> {
|
|
1268
1276
|
// A subcommand with its own help answers its own --help (it is
|
|
1269
1277
|
// dispatched first); the rest (up, down, restart, open) share this one.
|
|
@@ -1313,7 +1321,26 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1313
1321
|
const identity = await refuseForeignServer(options.port, ownServer());
|
|
1314
1322
|
const vouched = identity.kind === "ours" ? identity.pid : undefined;
|
|
1315
1323
|
|
|
1316
|
-
|
|
1324
|
+
// With the launchd service installed, it owns the server: restart and up
|
|
1325
|
+
// go through launchd, down is refused (launchd would start it again).
|
|
1326
|
+
const svc = await webServiceAction(
|
|
1327
|
+
options.action,
|
|
1328
|
+
options.port,
|
|
1329
|
+
identity.kind === "ours",
|
|
1330
|
+
(p) => waitForDenWeb(p),
|
|
1331
|
+
webServiceDeps,
|
|
1332
|
+
);
|
|
1333
|
+
if (svc.handled) {
|
|
1334
|
+
if (svc.code !== 0) {
|
|
1335
|
+
process.exitCode = svc.code;
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
recordRunningDenWebServer(options.port);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
if (svc.handled) {
|
|
1342
|
+
/* the service runs it: on to opening the browser */
|
|
1343
|
+
} else if (options.action === "down") {
|
|
1317
1344
|
const stopped = await stopDenWebServer(options.port, vouched);
|
|
1318
1345
|
if (stopped.stopped) {
|
|
1319
1346
|
const mode = stopped.forced ? "force stopped" : "stopped";
|
|
@@ -1342,7 +1369,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1342
1369
|
if (line) console.log(chalk.dim(`\n ${line}`));
|
|
1343
1370
|
}
|
|
1344
1371
|
|
|
1345
|
-
if (options.action === "restart") {
|
|
1372
|
+
if (options.action === "restart" && !svc.handled) {
|
|
1346
1373
|
const stopped = await stopDenWebServer(options.port, vouched);
|
|
1347
1374
|
if (stopped.pids.length > 0 && !stopped.stopped) {
|
|
1348
1375
|
throw new Error(
|
|
@@ -1368,8 +1395,9 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1368
1395
|
);
|
|
1369
1396
|
if (ignored) console.log(chalk.yellow(`\n ⚠ ${ignored}`));
|
|
1370
1397
|
|
|
1371
|
-
const apiRunning = await isDenWebApiRunning(options.port);
|
|
1372
|
-
let running =
|
|
1398
|
+
const apiRunning = svc.handled || (await isDenWebApiRunning(options.port));
|
|
1399
|
+
let running =
|
|
1400
|
+
svc.handled || (apiRunning && (await isDenWebUiReady(options.port)));
|
|
1373
1401
|
let started: StartedServer | null = null;
|
|
1374
1402
|
|
|
1375
1403
|
if (apiRunning && !running) {
|
|
@@ -1388,6 +1416,9 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1388
1416
|
}
|
|
1389
1417
|
|
|
1390
1418
|
if (!running) {
|
|
1419
|
+
// D20: started from an ssh (Background) shell, it lives there.
|
|
1420
|
+
const where = webUpWarning(launchdSession(), product().cli);
|
|
1421
|
+
if (where) console.log(chalk.yellow(`\n ⚠ ${where}`));
|
|
1391
1422
|
console.log(chalk.cyan(`\n Starting ${product().cli} web on ${url}...`));
|
|
1392
1423
|
try {
|
|
1393
1424
|
started = startDenWebServer(options);
|
|
@@ -1411,7 +1442,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1411
1442
|
console.log(chalk.red(` ERROR Failed to start ${product().cli} web`));
|
|
1412
1443
|
throw error;
|
|
1413
1444
|
}
|
|
1414
|
-
} else {
|
|
1445
|
+
} else if (!svc.handled) {
|
|
1415
1446
|
recordRunningDenWebServer(options.port);
|
|
1416
1447
|
console.log(
|
|
1417
1448
|
chalk.green(`\n OK ${product().title} web is already running at ${url}`),
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `web up | down | restart` when this install's web server runs as a
|
|
3
|
+
* launchd service (`<cli> service install`).
|
|
4
|
+
*
|
|
5
|
+
* Before, `web restart` stopped the job's server and started a plain one.
|
|
6
|
+
* launchd brought the job straight back (KeepAlive), it took the port, and
|
|
7
|
+
* the plain server died seconds after "OK ... started". Now, with the
|
|
8
|
+
* service installed and the command on its port:
|
|
9
|
+
*
|
|
10
|
+
* - restart: `launchctl kickstart -k`, then the job's new pid and the
|
|
11
|
+
* service's log;
|
|
12
|
+
* - up: a job that is not answering is started the same way;
|
|
13
|
+
* - down: refused, because launchd would only start it again; it names
|
|
14
|
+
* `<cli> service uninstall`.
|
|
15
|
+
*
|
|
16
|
+
* In always mode the job is in the system domain: kickstart there needs
|
|
17
|
+
* sudo, so the command is printed for the person to run, never run here.
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from "child_process";
|
|
20
|
+
import * as os from "os";
|
|
21
|
+
import * as path from "path";
|
|
22
|
+
import {
|
|
23
|
+
readDenConfig,
|
|
24
|
+
readWebServiceConfig,
|
|
25
|
+
} from "../../shared/den-config.js";
|
|
26
|
+
import { readWebPort } from "../../shared/edge/config.js";
|
|
27
|
+
import { getDistRoot } from "../../shared/paths.js";
|
|
28
|
+
import { product } from "../../shared/product.js";
|
|
29
|
+
import { serviceJobState, type ServiceJob } from "./doctor-where.js";
|
|
30
|
+
|
|
31
|
+
export interface WebService {
|
|
32
|
+
label: string;
|
|
33
|
+
/** The service's web.log. */
|
|
34
|
+
log: string;
|
|
35
|
+
job: ServiceJob;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WebServiceDeps {
|
|
39
|
+
/** The installed service for this install's port, or null. */
|
|
40
|
+
service?: (port: number) => WebService | null;
|
|
41
|
+
/** launchctl kickstart [-k] <domain>/<label>; its exit code. */
|
|
42
|
+
kickstart?: (target: string, kill: boolean) => number;
|
|
43
|
+
/** The pid launchd runs the job as, or null. */
|
|
44
|
+
jobPid?: (svc: WebService) => number | null;
|
|
45
|
+
/** Until the job's server answers as this install's (true) or time runs out. */
|
|
46
|
+
waitReady?: (port: number) => Promise<boolean>;
|
|
47
|
+
log?: (line: string) => void;
|
|
48
|
+
/** Between pid checks while the job comes back (default 250 ms). */
|
|
49
|
+
pollMs?: number;
|
|
50
|
+
/** How long to wait for the job's new pid (default 30 s). */
|
|
51
|
+
pidTimeoutMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type WebServiceResult =
|
|
55
|
+
| { handled: false }
|
|
56
|
+
| { handled: true; code: number; pid?: number };
|
|
57
|
+
|
|
58
|
+
function defaultService(port: number): WebService | null {
|
|
59
|
+
if (process.platform !== "darwin") return null;
|
|
60
|
+
const distRoot = getDistRoot();
|
|
61
|
+
// The service serves this install's configured port only.
|
|
62
|
+
if (port !== readWebPort(distRoot)) return null;
|
|
63
|
+
const { label, logs } = readWebServiceConfig(
|
|
64
|
+
readDenConfig(),
|
|
65
|
+
distRoot,
|
|
66
|
+
os.homedir(),
|
|
67
|
+
);
|
|
68
|
+
const job = serviceJobState(label, os.homedir(), process.getuid?.() ?? 0);
|
|
69
|
+
if (!job || job.state !== "loaded") return null;
|
|
70
|
+
return { label, log: path.join(logs, "web.log"), job };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function defaultKickstart(target: string, kill: boolean): number {
|
|
74
|
+
return (
|
|
75
|
+
spawnSync("launchctl", ["kickstart", ...(kill ? ["-k"] : []), target], {
|
|
76
|
+
stdio: "ignore",
|
|
77
|
+
timeout: 10_000,
|
|
78
|
+
}).status ?? 1
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function defaultJobPid(svc: WebService): number | null {
|
|
83
|
+
const r = spawnSync(
|
|
84
|
+
"launchctl",
|
|
85
|
+
["print", `${svc.job.domain}/${svc.label}`],
|
|
86
|
+
{ encoding: "utf-8", timeout: 3000 },
|
|
87
|
+
);
|
|
88
|
+
if (r.status !== 0) return null;
|
|
89
|
+
const m = /^\s*pid = (\d+)/m.exec(r.stdout ?? "");
|
|
90
|
+
return m ? Number(m[1]) : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What `web <action>` does when the service is installed. `answering`:
|
|
97
|
+
* this install's server already answers on the port. Not handled: no
|
|
98
|
+
* service (or another port), and `up` while it answers.
|
|
99
|
+
*/
|
|
100
|
+
export async function webServiceAction(
|
|
101
|
+
action: "up" | "down" | "restart" | "open",
|
|
102
|
+
port: number,
|
|
103
|
+
answering: boolean,
|
|
104
|
+
waitReadyDefault: (port: number) => Promise<boolean>,
|
|
105
|
+
deps: WebServiceDeps = {},
|
|
106
|
+
): Promise<WebServiceResult> {
|
|
107
|
+
if (action === "open") return { handled: false };
|
|
108
|
+
const svc = (deps.service ?? defaultService)(port);
|
|
109
|
+
if (!svc) return { handled: false };
|
|
110
|
+
if (action === "up" && answering) return { handled: false };
|
|
111
|
+
const log = deps.log ?? ((l: string) => console.log(l));
|
|
112
|
+
const { cli, title } = product();
|
|
113
|
+
const target = `${svc.job.domain}/${svc.label}`;
|
|
114
|
+
|
|
115
|
+
if (action === "down") {
|
|
116
|
+
log(
|
|
117
|
+
`✗ ${title} web runs as the service ${svc.label} (${svc.job.mode}): stopping it only makes launchd start it again.`,
|
|
118
|
+
);
|
|
119
|
+
log(` To stop it for good: ${cli} service uninstall`);
|
|
120
|
+
log(` To restart it: ${cli} web restart`);
|
|
121
|
+
return { handled: true, code: 1 };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (svc.job.mode === "always") {
|
|
125
|
+
log(
|
|
126
|
+
`${title} web runs as the service ${svc.label} (always). Restarting it needs sudo:`,
|
|
127
|
+
);
|
|
128
|
+
log(` sudo launchctl kickstart -k ${target}`);
|
|
129
|
+
return { handled: true, code: 1 };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const jobPid = deps.jobPid ?? defaultJobPid;
|
|
133
|
+
const before = jobPid(svc);
|
|
134
|
+
const code = (deps.kickstart ?? defaultKickstart)(target, true);
|
|
135
|
+
if (code !== 0) {
|
|
136
|
+
log(`✗ launchctl kickstart -k ${target} failed (exit ${code})`);
|
|
137
|
+
log(` See ${svc.log}`);
|
|
138
|
+
return { handled: true, code: 1 };
|
|
139
|
+
}
|
|
140
|
+
// A new pid, then the server answering.
|
|
141
|
+
const deadline = Date.now() + (deps.pidTimeoutMs ?? 30_000);
|
|
142
|
+
let pid = jobPid(svc);
|
|
143
|
+
while ((pid === null || pid === before) && Date.now() < deadline) {
|
|
144
|
+
await sleep(deps.pollMs ?? 250);
|
|
145
|
+
pid = jobPid(svc);
|
|
146
|
+
}
|
|
147
|
+
const ready =
|
|
148
|
+
pid !== null &&
|
|
149
|
+
pid !== before &&
|
|
150
|
+
(await (deps.waitReady ?? waitReadyDefault)(port));
|
|
151
|
+
if (!ready) {
|
|
152
|
+
log(`✗ the service ${svc.label} did not come back. See ${svc.log}`);
|
|
153
|
+
return { handled: true, code: 1 };
|
|
154
|
+
}
|
|
155
|
+
log(
|
|
156
|
+
`OK ${title} web ${action === "up" ? "started" : "restarted"} by its service ${svc.label} (pid ${pid})`,
|
|
157
|
+
);
|
|
158
|
+
log(` Log: ${svc.log}`);
|
|
159
|
+
return { handled: true, code: 0, pid: pid! };
|
|
160
|
+
}
|