@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +167 -19
  4. package/package.json +1 -1
  5. package/service/install.sh +213 -63
  6. package/src/shared/agents/den-claude.ts +13 -1
  7. package/src/shared/agents/records.ts +6 -0
  8. package/src/shared/agents/runtime.ts +6 -0
  9. package/src/shared/agents/session-name.ts +21 -1
  10. package/src/shared/attest-code.ts +125 -0
  11. package/src/shared/claim-code.ts +6 -2
  12. package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
  13. package/src/shared/github-app.ts +4 -0
  14. package/src/shared/github-device.ts +38 -1
  15. package/src/shared/launchd-session.ts +92 -0
  16. package/src/shared/owner-github.ts +100 -0
  17. package/src/shared/product.ts +45 -5
  18. package/src/shared/profile.ts +26 -2
  19. package/src/shared/sessions/client.ts +24 -0
  20. package/src/shared/sessions/host.ts +114 -5
  21. package/src/shared/sessions/types.ts +5 -0
  22. package/src/shared/web-session.ts +112 -6
  23. package/src/ui/cli/agent-ops.ts +20 -2
  24. package/src/ui/cli/agents-command.ts +32 -1
  25. package/src/ui/cli/create.ts +92 -6
  26. package/src/ui/cli/doctor-command.ts +126 -15
  27. package/src/ui/cli/doctor-owner.ts +122 -0
  28. package/src/ui/cli/doctor-where.ts +203 -0
  29. package/src/ui/cli/index.ts +1 -1
  30. package/src/ui/cli/service-command.ts +233 -11
  31. package/src/ui/cli/sessions-command.ts +46 -12
  32. package/src/ui/cli/web-command.ts +78 -12
  33. package/src/ui/server/device-flows.ts +45 -10
  34. package/src/ui/server/github-routes.ts +169 -16
  35. package/src/ui/server/index.ts +147 -12
  36. package/src/ui/server/public/agents-page.js +103 -6
  37. package/src/ui/server/public/agents.html +30 -0
  38. package/src/ui/server/public/error-page.js +115 -33
  39. package/src/ui/server/public/error.html +16 -0
  40. package/src/ui/server/public/header.js +5 -7
  41. package/src/ui/server/public/i18n/es.js +24 -1
  42. package/src/ui/server/public/i18n/ja.js +24 -1
  43. package/src/ui/server/public/settings-registry.js +1 -1
  44. package/src/ui/server/public/shell.js +24 -1
  45. package/src/ui/server/route-classes.ts +7 -0
  46. 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
- const sub = args[0] as (typeof SUBCOMMANDS)[number] | undefined;
144
- if (!(SUBCOMMANDS as readonly string[]).includes(sub ?? ""))
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
- `${sub ? `Unknown service command "${sub}". ` : ""}Use: ${SUBCOMMANDS.join(" | ")}`,
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 = args
150
- .slice(1)
151
- .filter((a) => !SUBCOMMAND_FLAGS[sub!].includes(a));
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 env = {
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 home = deps.home ?? os.homedir();
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
- ` plist: ${path.join(home, "Library", "LaunchAgents", `${label}.plist`)}`,
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
- ensureHost({
194
+ (() => {
195
+ const job = hostJob(stateDir());
196
+ return ensureHost({
169
197
  socket: sessionPaths(stateDir()).socket,
170
- spawnHost: () => spawnHost(stateDir()),
171
- logHint: path.join(
172
- stateDir(),
173
- "logs",
174
- product().cli,
175
- "sessions-host.log",
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({ stateDir: d.stateDir(), pty: () => loadPty() });
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 (60 s) and its code, then wait until it's used",
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, 60 s). It
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(` Sign in (once, within 60 s): ${signIn()}\n`);
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(origin, mintLoginCode(ensureWebSecret(stateDir)), next);
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 60 s) for one browser, and a\n" +
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 60 seconds; it signs in one browser, once.\n`,
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 loginCode = mintLoginCode(secret, now);
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 60 seconds; it signs in one browser, once.\n`,
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 60 seconds, in the browser to sign in. ` +
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
- io.err(`${product().cli}: waiting for the link to be used…\n`);
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
- const deadline = Date.now() + (opts.maxWaitMs ?? 7 * 60_000);
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})` : ""}` +