@monster-orange/den 0.4.20 → 0.4.22
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 +164 -0
- package/README.md +7 -7
- package/docs/distributions.md +167 -19
- package/package.json +1 -1
- package/service/install.sh +213 -63
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/records.ts +6 -0
- package/src/shared/agents/runtime.ts +6 -0
- package/src/shared/agents/session-name.ts +21 -1
- package/src/shared/attest-code.ts +125 -0
- package/src/shared/claim-code.ts +6 -2
- package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
- package/src/shared/github-app.ts +4 -0
- package/src/shared/github-device.ts +38 -1
- package/src/shared/launchd-session.ts +92 -0
- package/src/shared/owner-github.ts +100 -0
- package/src/shared/product.ts +45 -5
- package/src/shared/profile.ts +26 -2
- 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/shared/web-session.ts +112 -6
- package/src/ui/cli/agent-ops.ts +20 -2
- package/src/ui/cli/agents-command.ts +32 -1
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +126 -15
- package/src/ui/cli/doctor-owner.ts +122 -0
- package/src/ui/cli/doctor-where.ts +203 -0
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/service-command.ts +233 -11
- package/src/ui/cli/sessions-command.ts +46 -12
- package/src/ui/cli/web-command.ts +78 -12
- package/src/ui/server/device-flows.ts +45 -10
- package/src/ui/server/github-routes.ts +169 -16
- package/src/ui/server/index.ts +147 -12
- package/src/ui/server/public/agents-page.js +103 -6
- package/src/ui/server/public/agents.html +30 -0
- package/src/ui/server/public/error-page.js +115 -33
- package/src/ui/server/public/error.html +16 -0
- package/src/ui/server/public/header.js +5 -7
- package/src/ui/server/public/i18n/es.js +24 -1
- package/src/ui/server/public/i18n/ja.js +24 -1
- package/src/ui/server/public/settings-registry.js +1 -1
- package/src/ui/server/public/shell.js +24 -1
- package/src/ui/server/route-classes.ts +7 -0
- package/src/ui/server/session-routes.ts +4 -0
|
@@ -14,10 +14,35 @@
|
|
|
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
|
*/
|
|
31
|
+
import {
|
|
32
|
+
ownerGithubLine,
|
|
33
|
+
recordOwnerGithub,
|
|
34
|
+
type OwnerGithubResult,
|
|
35
|
+
} from "../../shared/owner-github.js";
|
|
18
36
|
import { spawnSync } from "child_process";
|
|
37
|
+
import * as fs from "fs";
|
|
19
38
|
import * as os from "os";
|
|
20
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";
|
|
21
46
|
import {
|
|
22
47
|
readDenConfig,
|
|
23
48
|
readWebServiceConfig,
|
|
@@ -26,6 +51,7 @@ import {
|
|
|
26
51
|
import { readWebPort } from "../../shared/edge/config.js";
|
|
27
52
|
import { getDistRoot, getEngineRoot } from "../../shared/paths.js";
|
|
28
53
|
import { product } from "../../shared/product.js";
|
|
54
|
+
import { ALWAYS_UNPROVEN } from "../../shared/launchd-session.js";
|
|
29
55
|
import {
|
|
30
56
|
classifyServerInfo,
|
|
31
57
|
ForeignServerError,
|
|
@@ -48,7 +74,7 @@ const SUBCOMMANDS = ["install", "status", "print", "uninstall"] as const;
|
|
|
48
74
|
|
|
49
75
|
/** The flags each subcommand takes; any other is refused before acting. */
|
|
50
76
|
const SUBCOMMAND_FLAGS: Record<(typeof SUBCOMMANDS)[number], string[]> = {
|
|
51
|
-
install: ["--plan"],
|
|
77
|
+
install: ["--plan", "--mode"],
|
|
52
78
|
status: [],
|
|
53
79
|
print: [],
|
|
54
80
|
uninstall: [],
|
|
@@ -100,6 +126,16 @@ export async function waitForServerInfo(
|
|
|
100
126
|
|
|
101
127
|
export interface ServiceDeps {
|
|
102
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>;
|
|
137
|
+
/** D17: record web.owner_github (default: from gh, unless `run` is a test's). */
|
|
138
|
+
recordOwner?: () => OwnerGithubResult;
|
|
103
139
|
config?: DenConfig;
|
|
104
140
|
distRoot?: string;
|
|
105
141
|
engineRoot?: string;
|
|
@@ -135,20 +171,108 @@ export function identityLine(port: number, id: ServerIdentity): string {
|
|
|
135
171
|
}
|
|
136
172
|
}
|
|
137
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
|
+
|
|
138
257
|
/** Run `service <sub>`; returns the exit code. */
|
|
139
258
|
export async function cmdService(
|
|
140
259
|
args: string[],
|
|
141
260
|
deps: ServiceDeps = {},
|
|
142
261
|
): Promise<number> {
|
|
143
|
-
|
|
144
|
-
|
|
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))
|
|
145
269
|
throw new Error(
|
|
146
|
-
|
|
270
|
+
`Unknown service command "${sub}". Use: ${SUBCOMMANDS.join(" | ")} (${product().cli} service help)`,
|
|
147
271
|
);
|
|
148
272
|
// A typo'd --plan must not install for real.
|
|
149
|
-
const extra =
|
|
150
|
-
.slice(1)
|
|
151
|
-
|
|
273
|
+
const extra = (
|
|
274
|
+
sub === "install" ? withoutMode(args.slice(1)) : args.slice(1)
|
|
275
|
+
).filter((a) => !SUBCOMMAND_FLAGS[sub!].includes(a));
|
|
152
276
|
if (extra.length)
|
|
153
277
|
throw new Error(
|
|
154
278
|
`service ${sub}: unknown argument${extra.length > 1 ? "s" : ""} ${extra.join(" ")}` +
|
|
@@ -157,6 +281,7 @@ export async function cmdService(
|
|
|
157
281
|
: ""),
|
|
158
282
|
);
|
|
159
283
|
const plan = sub === "install" && args.includes("--plan");
|
|
284
|
+
const askedMode = sub === "install" ? modeArg(args) : undefined;
|
|
160
285
|
// print (and install --plan) only write the plist to stdout; the rest
|
|
161
286
|
// talk to launchd.
|
|
162
287
|
if (
|
|
@@ -178,10 +303,24 @@ export async function cmdService(
|
|
|
178
303
|
"service",
|
|
179
304
|
"install.sh",
|
|
180
305
|
);
|
|
181
|
-
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> = {
|
|
182
315
|
DEN_ROOT: distRoot,
|
|
183
316
|
DEN_SERVICE_LABEL: label,
|
|
184
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
|
+
: {}),
|
|
185
324
|
};
|
|
186
325
|
const run = deps.run ?? defaultRunner;
|
|
187
326
|
const log = deps.log ?? ((l: string) => console.log(l));
|
|
@@ -190,12 +329,20 @@ export async function cmdService(
|
|
|
190
329
|
const identify =
|
|
191
330
|
deps.identify ?? ((p: number, o: OwnServer) => identifyServer(p, o));
|
|
192
331
|
|
|
332
|
+
const jobPid = deps.jobPid ?? defaultJobPid;
|
|
193
333
|
if (plan) {
|
|
194
|
-
const
|
|
334
|
+
const dir =
|
|
335
|
+
mode === "always"
|
|
336
|
+
? "/Library/LaunchDaemons"
|
|
337
|
+
: path.join(home, "Library", "LaunchAgents");
|
|
195
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
|
+
);
|
|
196
342
|
log(` label: ${label}`);
|
|
343
|
+
log(` plist: ${path.join(dir, `${label}.plist`)}`);
|
|
197
344
|
log(
|
|
198
|
-
`
|
|
345
|
+
` host: ${hostLabel(label)} on ${hostExePath(stateDir, product().cli)} (grant this path Full Disk Access for protected folders)`,
|
|
199
346
|
);
|
|
200
347
|
log(` logs: ${path.join(logs, "web.log")}`);
|
|
201
348
|
log(` root: ${distRoot}`);
|
|
@@ -203,6 +350,15 @@ export async function cmdService(
|
|
|
203
350
|
log(` url: http://localhost:${port}`);
|
|
204
351
|
const id = await identify(port, own);
|
|
205
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
|
+
);
|
|
206
362
|
log(` plist it would write:`);
|
|
207
363
|
// install.sh print writes the plist to stdout only.
|
|
208
364
|
const code = run("bash", [script, "print"], env);
|
|
@@ -214,17 +370,71 @@ export async function cmdService(
|
|
|
214
370
|
return 0;
|
|
215
371
|
}
|
|
216
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
|
+
|
|
393
|
+
// D17: whoever installs it can connect their GitHub account from any
|
|
394
|
+
// browser (web.owner_github, from gh), before the server reads it.
|
|
395
|
+
if (sub === "install") {
|
|
396
|
+
const rec =
|
|
397
|
+
deps.recordOwner ??
|
|
398
|
+
(deps.run ? () => ({ state: "linked" as const }) : recordOwnerGithub);
|
|
399
|
+
const line = ownerGithubLine(rec(), product().cli);
|
|
400
|
+
if (line) log(line);
|
|
401
|
+
if (mode === "always")
|
|
402
|
+
log(`note: --mode always is opt-in and ${ALWAYS_UNPROVEN}`);
|
|
403
|
+
}
|
|
217
404
|
const code = run("bash", [script, sub!], {
|
|
218
405
|
...env,
|
|
219
406
|
// The script then leaves saying "running" to us (after the poll below).
|
|
220
407
|
...(sub === "install" ? { DEN_SERVICE_READY_CHECK: "external" } : {}),
|
|
221
408
|
});
|
|
409
|
+
if (sub === "uninstall" && code === 0)
|
|
410
|
+
fs.rmSync(hostJobFile(stateDir), { force: true });
|
|
222
411
|
if (sub === "status") {
|
|
223
412
|
const id = await identify(port, own);
|
|
224
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
|
+
}
|
|
225
424
|
return code;
|
|
226
425
|
}
|
|
227
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
|
+
}
|
|
228
438
|
|
|
229
439
|
const url = `http://localhost:${port}/api/server-info`;
|
|
230
440
|
const waitReady =
|
|
@@ -242,8 +452,20 @@ export async function cmdService(
|
|
|
242
452
|
return 1;
|
|
243
453
|
}
|
|
244
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
|
+
}
|
|
245
467
|
log(
|
|
246
|
-
`✓ ${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")})`,
|
|
247
469
|
);
|
|
248
470
|
return 0;
|
|
249
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");
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ownerGithubLine,
|
|
3
|
+
recordOwnerGithub,
|
|
4
|
+
} from "../../shared/owner-github.js";
|
|
5
|
+
import { launchdSession, webUpWarning } from "../../shared/launchd-session.js";
|
|
1
6
|
import chalk from "chalk";
|
|
2
7
|
import { execFileSync, spawn, spawnSync } from "child_process";
|
|
3
8
|
import * as fs from "fs";
|
|
@@ -27,6 +32,7 @@ import {
|
|
|
27
32
|
ignoredConfigWarning,
|
|
28
33
|
localhostOrigin,
|
|
29
34
|
loginLink,
|
|
35
|
+
LOGIN_CODE_TTL_MS,
|
|
30
36
|
mintLoginCode,
|
|
31
37
|
parseOrigin,
|
|
32
38
|
readWebAuthConfig,
|
|
@@ -1182,7 +1188,7 @@ export function displayWebHelp(): void {
|
|
|
1182
1188
|
" open Start if needed, and sign this browser in (a new tab)",
|
|
1183
1189
|
);
|
|
1184
1190
|
console.log(
|
|
1185
|
-
" login Print a one-time sign-in link (
|
|
1191
|
+
" login Print a one-time sign-in link (5 min; web.login_link_ttl) and its code, then wait until it's used",
|
|
1186
1192
|
);
|
|
1187
1193
|
console.log(" sessions List the signed-in browsers");
|
|
1188
1194
|
console.log(
|
|
@@ -1330,6 +1336,13 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1330
1336
|
return;
|
|
1331
1337
|
}
|
|
1332
1338
|
|
|
1339
|
+
// D17: whoever starts it can connect their GitHub account from any
|
|
1340
|
+
// browser (web.owner_github, from gh), before the server reads it.
|
|
1341
|
+
if (options.action === "up" || options.action === "restart") {
|
|
1342
|
+
const line = ownerGithubLine(recordOwnerGithub(), product().cli);
|
|
1343
|
+
if (line) console.log(chalk.dim(`\n ${line}`));
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1333
1346
|
if (options.action === "restart") {
|
|
1334
1347
|
const stopped = await stopDenWebServer(options.port, vouched);
|
|
1335
1348
|
if (stopped.pids.length > 0 && !stopped.stopped) {
|
|
@@ -1376,6 +1389,9 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1376
1389
|
}
|
|
1377
1390
|
|
|
1378
1391
|
if (!running) {
|
|
1392
|
+
// D20: started from an ssh (Background) shell, it lives there.
|
|
1393
|
+
const where = webUpWarning(launchdSession(), product().cli);
|
|
1394
|
+
if (where) console.log(chalk.yellow(`\n ⚠ ${where}`));
|
|
1379
1395
|
console.log(chalk.cyan(`\n Starting ${product().cli} web on ${url}...`));
|
|
1380
1396
|
try {
|
|
1381
1397
|
started = startDenWebServer(options);
|
|
@@ -1411,7 +1427,8 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1411
1427
|
return;
|
|
1412
1428
|
}
|
|
1413
1429
|
|
|
1414
|
-
// SEC-1b: what opens is a sign-in link (localhost, one-time,
|
|
1430
|
+
// SEC-1b: what opens is a sign-in link (localhost, one-time, D16:
|
|
1431
|
+
// web.login_link_ttl). It
|
|
1415
1432
|
// is printed only when the browser couldn't be opened: the one line the
|
|
1416
1433
|
// person then needs.
|
|
1417
1434
|
// Inside an agent's session: the plain page, never a sign-in link.
|
|
@@ -1453,7 +1470,9 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1453
1470
|
} else {
|
|
1454
1471
|
console.log(chalk.yellow("\n Could not open a browser automatically."));
|
|
1455
1472
|
}
|
|
1456
|
-
console.log(
|
|
1473
|
+
console.log(
|
|
1474
|
+
` Sign in (once, within ${ttlText(loginLinkTtl())}): ${signIn()}\n`,
|
|
1475
|
+
);
|
|
1457
1476
|
}
|
|
1458
1477
|
|
|
1459
1478
|
// ── SEC-1b: sign-in (`web login`, `web sessions`, `web logout`) ─────────────
|
|
@@ -1467,7 +1486,11 @@ export function freshLoginLink(
|
|
|
1467
1486
|
): string {
|
|
1468
1487
|
const refused = personGrantRefusal(process.env);
|
|
1469
1488
|
if (refused) throw new Error(refused);
|
|
1470
|
-
return loginLink(
|
|
1489
|
+
return loginLink(
|
|
1490
|
+
origin,
|
|
1491
|
+
mintLoginCode(ensureWebSecret(stateDir), Date.now(), loginLinkTtl()),
|
|
1492
|
+
next,
|
|
1493
|
+
);
|
|
1471
1494
|
}
|
|
1472
1495
|
|
|
1473
1496
|
/**
|
|
@@ -1616,7 +1639,8 @@ function parseLoginArgs(
|
|
|
1616
1639
|
|
|
1617
1640
|
const LOGIN_HELP = () =>
|
|
1618
1641
|
LOGIN_USAGE() +
|
|
1619
|
-
" Prints a one-time sign-in link (open it within
|
|
1642
|
+
" Prints a one-time sign-in link (open it within web.login_link_ttl, 5 minutes\n" +
|
|
1643
|
+
" unless set) for one browser, and a\n" +
|
|
1620
1644
|
" code the page behind the link asks for, then waits until the link is used:\n" +
|
|
1621
1645
|
' "Signed in: <browser> (<address>) at <time>", or "Expired: nobody used the\n' +
|
|
1622
1646
|
' link" (exit 1). On localhost nothing asks for a sign-in; it is needed when\n' +
|
|
@@ -1655,12 +1679,36 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
|
|
|
1655
1679
|
io.stateDir ?? getStateDir(),
|
|
1656
1680
|
);
|
|
1657
1681
|
io.err(
|
|
1658
|
-
`${product().cli}: open this link within
|
|
1682
|
+
`${product().cli}: open this link within ${ttlText(loginLinkTtl())}; it signs in one browser, once.\n`,
|
|
1659
1683
|
);
|
|
1660
1684
|
io.out(`${link}\n`);
|
|
1661
1685
|
return 0;
|
|
1662
1686
|
}
|
|
1663
1687
|
|
|
1688
|
+
/** D16: web.login_link_ttl, read now (LOGIN_CODE_TTL_MS when unreadable). */
|
|
1689
|
+
export function loginLinkTtl(): number {
|
|
1690
|
+
try {
|
|
1691
|
+
return readWebAuthConfig(getDistRoot(), product().configFile)
|
|
1692
|
+
.loginLinkTtlMs;
|
|
1693
|
+
} catch {
|
|
1694
|
+
return LOGIN_CODE_TTL_MS;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** "5 minutes", "90 seconds", "1 minute". */
|
|
1699
|
+
export function ttlText(ms: number): string {
|
|
1700
|
+
const s = Math.round(ms / 1000);
|
|
1701
|
+
if (s % 60 !== 0) return `${s} seconds`;
|
|
1702
|
+
const m = s / 60;
|
|
1703
|
+
return m === 1 ? "1 minute" : `${m} minutes`;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/** D16: the countdown's text, "4:59 left". */
|
|
1707
|
+
export function countdownLeft(ms: number): string {
|
|
1708
|
+
const s = Math.max(0, Math.ceil(ms / 1000));
|
|
1709
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")} left`;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1664
1712
|
/** "10:42" in this machine's time. */
|
|
1665
1713
|
function clockTime(iso: string): string {
|
|
1666
1714
|
const d = new Date(iso);
|
|
@@ -1680,7 +1728,7 @@ function clockTime(iso: string): string {
|
|
|
1680
1728
|
export async function cmdWebLoginWait(
|
|
1681
1729
|
args: string[],
|
|
1682
1730
|
io: CliIo = stdIo,
|
|
1683
|
-
opts: { pollMs?: number; maxWaitMs?: number } = {},
|
|
1731
|
+
opts: { pollMs?: number; maxWaitMs?: number; countdown?: boolean } = {},
|
|
1684
1732
|
): Promise<number> {
|
|
1685
1733
|
if (isHelp(args)) {
|
|
1686
1734
|
io.out(LOGIN_HELP());
|
|
@@ -1701,7 +1749,8 @@ export async function cmdWebLoginWait(
|
|
|
1701
1749
|
const stateDir = io.stateDir ?? getStateDir();
|
|
1702
1750
|
const secret = ensureWebSecret(stateDir);
|
|
1703
1751
|
const now = Date.now();
|
|
1704
|
-
const
|
|
1752
|
+
const ttl = loginLinkTtl();
|
|
1753
|
+
const loginCode = mintLoginCode(secret, now, ttl);
|
|
1705
1754
|
const checked = checkLoginCode(loginCode, secret, now);
|
|
1706
1755
|
if (!checked.ok) {
|
|
1707
1756
|
io.err(`${product().cli}: could not make a sign-in link\n`);
|
|
@@ -1722,7 +1771,7 @@ export async function cmdWebLoginWait(
|
|
|
1722
1771
|
io.err(
|
|
1723
1772
|
`${product().cli}: no ${product().title} web server answers on port ${a.port}, so this ` +
|
|
1724
1773
|
"doesn't wait and the link asks for no code (as --no-wait). Start the server " +
|
|
1725
|
-
`(\`${product().cli} web up\`), then open this link within
|
|
1774
|
+
`(\`${product().cli} web up\`), then open this link within ${ttlText(ttl)}; it signs in one browser, once.\n`,
|
|
1726
1775
|
);
|
|
1727
1776
|
io.out(`${link}\n`);
|
|
1728
1777
|
return 0;
|
|
@@ -1733,15 +1782,23 @@ export async function cmdWebLoginWait(
|
|
|
1733
1782
|
return 1;
|
|
1734
1783
|
}
|
|
1735
1784
|
io.err(
|
|
1736
|
-
`${product().cli}: open this link within
|
|
1785
|
+
`${product().cli}: open this link within ${ttlText(ttl)}, in the browser to sign in. ` +
|
|
1737
1786
|
`It asks for this code: ${confirm}\n`,
|
|
1738
1787
|
);
|
|
1739
1788
|
io.out(`${link}\n`);
|
|
1740
|
-
|
|
1789
|
+
// D16: on a terminal, the time left, in place.
|
|
1790
|
+
const tty = opts.countdown ?? (io === stdIo && !!process.stderr.isTTY);
|
|
1791
|
+
const waiting = `${product().cli}: waiting for the link to be used…`;
|
|
1792
|
+
const tick = () =>
|
|
1793
|
+
io.err(`\r${waiting} ${countdownLeft(checked.expiresAt - Date.now())} `);
|
|
1794
|
+
if (tty) tick();
|
|
1795
|
+
else io.err(`${waiting}\n`);
|
|
1741
1796
|
const pollMs = opts.pollMs ?? 1000;
|
|
1742
|
-
|
|
1797
|
+
// The link's own time, then the 5 minutes an opened link has for its code.
|
|
1798
|
+
const deadline = Date.now() + (opts.maxWaitMs ?? ttl + 6 * 60_000);
|
|
1743
1799
|
for (;;) {
|
|
1744
1800
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
1801
|
+
if (tty) tick();
|
|
1745
1802
|
const r = await serverCall(
|
|
1746
1803
|
stateDir,
|
|
1747
1804
|
a.port,
|
|
@@ -1754,6 +1811,15 @@ export async function cmdWebLoginWait(
|
|
|
1754
1811
|
client?: string;
|
|
1755
1812
|
usedAt?: string;
|
|
1756
1813
|
};
|
|
1814
|
+
// The countdown's line ends before the outcome's.
|
|
1815
|
+
const over =
|
|
1816
|
+
!r ||
|
|
1817
|
+
j.state === "used" ||
|
|
1818
|
+
j.state === "refused" ||
|
|
1819
|
+
j.state === "expired" ||
|
|
1820
|
+
r.status === 404 ||
|
|
1821
|
+
Date.now() > deadline;
|
|
1822
|
+
if (tty && over) io.err("\n");
|
|
1757
1823
|
if (r && j.state === "used") {
|
|
1758
1824
|
io.out(
|
|
1759
1825
|
`Signed in: ${j.device ?? "a browser"}${j.client ? ` (${j.client})` : ""}` +
|