@monster-orange/den 0.4.4 → 0.4.6

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 (57) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +24 -9
  3. package/docs/distributions.md +320 -26
  4. package/docs/reference.md +8 -3
  5. package/package.json +4 -3
  6. package/skills/install-den/SKILL.md +114 -0
  7. package/src/shared/agent-setup.ts +9 -0
  8. package/src/shared/auth/sudo-vault.ts +115 -11
  9. package/src/shared/cli-command.ts +186 -0
  10. package/src/shared/confirm-code.ts +34 -0
  11. package/src/shared/constants.ts +7 -0
  12. package/src/shared/den-config.ts +7 -0
  13. package/src/shared/dev-metrics/emit.ts +59 -2
  14. package/src/shared/edge/config.ts +39 -0
  15. package/src/shared/edge/render.ts +92 -18
  16. package/src/shared/edge/runtime.ts +57 -1
  17. package/src/shared/edge/types.ts +11 -1
  18. package/src/shared/github-app.ts +94 -0
  19. package/src/shared/github-device.ts +286 -0
  20. package/src/shared/port-pick.ts +60 -0
  21. package/src/shared/secrets/onepassword.ts +27 -4
  22. package/src/shared/web-owner.ts +91 -0
  23. package/src/shared/web-session.ts +135 -1
  24. package/src/ui/cli/create-main.ts +1 -1
  25. package/src/ui/cli/create.ts +263 -30
  26. package/src/ui/cli/doctor-command.ts +67 -4
  27. package/src/ui/cli/doctor-onboard.ts +291 -0
  28. package/src/ui/cli/flag-parser.ts +5 -1
  29. package/src/ui/cli/index.ts +246 -4
  30. package/src/ui/cli/json-out.ts +49 -0
  31. package/src/ui/cli/mcp-connect.ts +179 -15
  32. package/src/ui/cli/repo-command.ts +490 -0
  33. package/src/ui/cli/repo-detect.ts +192 -0
  34. package/src/ui/cli/web-command.ts +513 -33
  35. package/src/ui/mcp/connect.ts +134 -0
  36. package/src/ui/server/device-flows.ts +274 -0
  37. package/src/ui/server/edge-exposure.ts +16 -4
  38. package/src/ui/server/github-routes.ts +321 -0
  39. package/src/ui/server/http-errors.ts +10 -0
  40. package/src/ui/server/index.ts +463 -27
  41. package/src/ui/server/login-waits.ts +203 -0
  42. package/src/ui/server/pair-requests.ts +237 -0
  43. package/src/ui/server/pair-routes.ts +196 -0
  44. package/src/ui/server/public/error-page.js +452 -40
  45. package/src/ui/server/public/error.html +59 -0
  46. package/src/ui/server/public/i18n/es.js +74 -8
  47. package/src/ui/server/public/i18n/ja.js +74 -8
  48. package/src/ui/server/public/local-dev.html +16 -4
  49. package/src/ui/server/public/qr.js +317 -0
  50. package/src/ui/server/public/settings-page.js +10 -3
  51. package/src/ui/server/public/settings-registry.js +392 -1
  52. package/src/ui/server/public/shell.js +123 -10
  53. package/src/ui/server/public/topbar.js +7 -3
  54. package/src/ui/server/public/workspace-picker.js +1 -1
  55. package/src/ui/server/request-guard.ts +103 -36
  56. package/src/ui/server/route-classes.ts +102 -2
  57. package/src/ui/server/ui-assets.ts +2 -0
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import { execFileSync, spawn, spawnSync } from "child_process";
3
3
  import * as fs from "fs";
4
4
  import * as http from "http";
5
+ import * as net from "net";
5
6
  import * as path from "path";
6
7
  import { pathToFileURL } from "url";
7
8
  import { CLI_SPAWN_TIMEOUT_MS, DEFAULT_PORT } from "../../shared/constants.js";
@@ -20,7 +21,9 @@ import {
20
21
  readWebSecret,
21
22
  rotateWebSecret,
22
23
  } from "../../shared/web-secret.js";
24
+ import { newConfirmCode } from "../../shared/confirm-code.js";
23
25
  import {
26
+ checkLoginCode,
24
27
  ignoredConfigWarning,
25
28
  localhostOrigin,
26
29
  loginLink,
@@ -35,6 +38,24 @@ import { revokeAllActorTokens } from "../../shared/actor-token.js";
35
38
  import { engineBin, engineModuleFile } from "../../shared/engine.js";
36
39
  import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
37
40
  import { webRouteHosts } from "../../shared/edge/render.js";
41
+ import {
42
+ expiringTokenWarning,
43
+ GITHUB_DEVICE_PAGE,
44
+ githubClientId,
45
+ githubEndpoints,
46
+ noClientIdMessage,
47
+ } from "../../shared/github-app.js";
48
+ import {
49
+ githubDeviceClient,
50
+ githubPrincipal,
51
+ runDeviceFlow,
52
+ type GithubDeviceClient,
53
+ } from "../../shared/github-device.js";
54
+ import {
55
+ clearWebOwner,
56
+ readWebOwner,
57
+ writeWebOwner,
58
+ } from "../../shared/web-owner.js";
38
59
  import {
39
60
  ownServer,
40
61
  processCwd,
@@ -225,6 +246,24 @@ export function buildDenWebUrl(
225
246
  return `http://${resolveDenWebHost(hostsContent)}:${port}`;
226
247
  }
227
248
 
249
+ /**
250
+ * The address `web up` names: the bound one when DEN_BIND_HOST is a
251
+ * specific address beyond loopback (a LAN-only bind doesn't answer on
252
+ * localhost), else buildDenWebUrl's.
253
+ */
254
+ export function webUpUrl(
255
+ port: number,
256
+ bind: string | undefined = process.env.DEN_BIND_HOST,
257
+ hostsContent?: string,
258
+ ): string {
259
+ const to = webProbeTarget(bind, port);
260
+ const loopback =
261
+ to.host === "::1" || /^127\./.test(to.host) || to.host === "localhost";
262
+ return loopback
263
+ ? buildDenWebUrl(port, hostsContent)
264
+ : `http://${to.hostHeader}`;
265
+ }
266
+
228
267
  export function denWebUrlAliases(port: number): string[] {
229
268
  return [
230
269
  `http://${product().cli}:${port}`,
@@ -252,6 +291,29 @@ export function isDenWebTabUrl(rawUrl: string, port: number): boolean {
252
291
  }
253
292
  }
254
293
 
294
+ /**
295
+ * Where `web up`'s probes (readiness, the CLI's own calls) reach the
296
+ * server, and the Host they send: the address DEN_BIND_HOST binds. Unset,
297
+ * 0.0.0.0 or `::` answer on loopback, so 127.0.0.1; one specific address
298
+ * (a LAN IP with web.allow_plain_http_remote, or ::1) answers only there.
299
+ * The Host names that address (an IPv6 one bracketed): the Host allowlist
300
+ * accepts it (a loopback name, or the bound LAN address).
301
+ */
302
+ export function webProbeTarget(
303
+ bind: string | undefined,
304
+ port: number,
305
+ ): { host: string; hostHeader: string } {
306
+ const b = (bind ?? "")
307
+ .trim()
308
+ .toLowerCase()
309
+ .replace(/^\[|\]$/g, "");
310
+ const host = b === "" || b === "0.0.0.0" || b === "::" ? "127.0.0.1" : b;
311
+ return {
312
+ host,
313
+ hostHeader: `${net.isIPv6(host) ? `[${host}]` : host}:${port}`,
314
+ };
315
+ }
316
+
255
317
  async function getHttpText(
256
318
  port: number,
257
319
  requestPath: string,
@@ -266,12 +328,18 @@ async function getHttpText(
266
328
  resolve(value);
267
329
  };
268
330
 
331
+ const to = webProbeTarget(process.env.DEN_BIND_HOST, port);
269
332
  const req = http.get(
270
333
  {
271
- host: "127.0.0.1",
334
+ host: to.host,
272
335
  port,
273
336
  path: requestPath,
274
337
  timeout: 1_500,
338
+ agent: false,
339
+ setHost: false,
340
+ // The CLI's bearer, as serverCall sends: an address beyond loopback
341
+ // answers an unsigned probe 401 signin_required, never ready.
342
+ headers: { host: to.hostHeader, ...cliAuthHeaders() },
275
343
  },
276
344
  (res) => {
277
345
  let body = "";
@@ -1072,6 +1140,8 @@ export const WEB_SUBCOMMANDS_WITH_HELP = [
1072
1140
  "login",
1073
1141
  "sessions",
1074
1142
  "logout",
1143
+ "owner",
1144
+ "approve",
1075
1145
  ] as const;
1076
1146
 
1077
1147
  export function displayWebHelp(): void {
@@ -1082,12 +1152,16 @@ export function displayWebHelp(): void {
1082
1152
  console.log(` ${product().cli} web --setup-browser-access`);
1083
1153
  console.log(` ${product().cli} web token --print`);
1084
1154
  console.log(
1085
- ` ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--port <port>]`,
1155
+ ` ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--no-wait] [--port <port>]`,
1086
1156
  );
1087
1157
  console.log(` ${product().cli} web sessions [--json] [--port <port>]`);
1088
1158
  console.log(
1089
1159
  ` ${product().cli} web logout [<id prefix>] [--all] [--port <port>]`,
1090
1160
  );
1161
+ console.log(` ${product().cli} web owner [link|show|unlink] [--json]`);
1162
+ console.log(
1163
+ ` ${product().cli} web approve <code> | --list [--json] [--port <port>]`,
1164
+ );
1091
1165
  console.log();
1092
1166
  console.log(chalk.bold("Actions:"));
1093
1167
  console.log(
@@ -1101,7 +1175,7 @@ export function displayWebHelp(): void {
1101
1175
  " open Start if needed, and sign this browser in (a new tab)",
1102
1176
  );
1103
1177
  console.log(
1104
- " login Print a one-time sign-in link (60 s), for another browser",
1178
+ " login Print a one-time sign-in link (60 s) and its code, then wait until it's used",
1105
1179
  );
1106
1180
  console.log(" sessions List the signed-in browsers");
1107
1181
  console.log(
@@ -1110,12 +1184,19 @@ export function displayWebHelp(): void {
1110
1184
  console.log(
1111
1185
  " token --print Print your CLI token, for curl (Authorization: Bearer …)",
1112
1186
  );
1187
+ console.log(
1188
+ " owner link Link your GitHub account as this Den's owner (Sign in with GitHub)",
1189
+ );
1190
+ console.log(
1191
+ " approve <code> Let in a computer whose signed-out page shows this code",
1192
+ );
1113
1193
  console.log(" (start/stop remain accepted as legacy aliases for up/down)");
1114
1194
  console.log();
1115
1195
  console.log(chalk.bold("Options:"));
1116
1196
  console.log(" --port, -p <port> Use a non-default web port");
1117
1197
  console.log(
1118
1198
  " --no-open Start or check the server without opening a browser",
1199
+ " --json (up, restart) log on stderr; stdout: { ok, url, port, version? }",
1119
1200
  );
1120
1201
  console.log(
1121
1202
  " --setup-browser-access Request macOS Automation access for browser tab reuse",
@@ -1174,7 +1255,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
1174
1255
  return;
1175
1256
  }
1176
1257
  if (args[0] === "login") {
1177
- process.exitCode = cmdWebLogin(args.slice(1));
1258
+ process.exitCode = await cmdWebLoginWait(args.slice(1));
1178
1259
  return;
1179
1260
  }
1180
1261
  if (args[0] === "sessions") {
@@ -1185,6 +1266,14 @@ export async function cmdWeb(args: string[]): Promise<void> {
1185
1266
  process.exitCode = await cmdWebLogout(args.slice(1));
1186
1267
  return;
1187
1268
  }
1269
+ if (args[0] === "owner") {
1270
+ process.exitCode = await cmdWebOwner(args.slice(1));
1271
+ return;
1272
+ }
1273
+ if (args[0] === "approve") {
1274
+ process.exitCode = await cmdWebApprove(args.slice(1));
1275
+ return;
1276
+ }
1188
1277
  if (args.includes("--help") || args.includes("-h")) {
1189
1278
  displayWebHelp();
1190
1279
  return;
@@ -1196,7 +1285,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
1196
1285
  return;
1197
1286
  }
1198
1287
 
1199
- const url = buildDenWebUrl(options.port);
1288
+ const url = webUpUrl(options.port);
1200
1289
 
1201
1290
  // Never act on another distribution's server: not "already running", and
1202
1291
  // never a signal to it (down, restart).
@@ -1392,31 +1481,19 @@ function takePort(args: string[]): { port: number; rest: string[] } {
1392
1481
  return { port, rest };
1393
1482
  }
1394
1483
 
1395
- /**
1396
- * `<cli> web login [--next <path>] [--host <name> | --origin <url>]`: a fresh sign-in link
1397
- * on stdout, alone (the only place a login code is ever printed); a note on
1398
- * stderr. `--host` for an edge hostname (its URL from the edge config),
1399
- * `--origin` for any allowed origin (a LAN name), where the cookie must be
1400
- * set (the signed-out page there says which).
1401
- */
1402
- export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1403
- const usage = `Usage: ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--port <port>]\n`;
1404
- if (isHelp(args)) {
1405
- io.out(
1406
- usage +
1407
- " Prints a one-time sign-in link (it works once, within 60 s) for one browser.\n" +
1408
- " On localhost nothing asks for a sign-in; it is needed when this server is\n" +
1409
- " reached from another machine (pass the edge hostname as --host, or a LAN\n" +
1410
- " address as --origin).\n" +
1411
- " --next <path> where the browser lands after signing in (same origin only)\n" +
1412
- " --host <name> an edge hostname that routes here (an edge domain or alias,\n" +
1413
- " a VPN name among them): the link uses its edge URL\n" +
1414
- " --origin <url> the address the browser uses, http(s)://host[:port]\n",
1415
- );
1416
- return 0;
1417
- }
1484
+ const LOGIN_USAGE = () =>
1485
+ `Usage: ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--no-wait] [--port <port>]\n`;
1486
+
1487
+ /** `web login`'s arguments, or an exit code (usage or a bad value, already said). */
1488
+ function parseLoginArgs(
1489
+ args: string[],
1490
+ io: CliIo,
1491
+ ):
1492
+ | { port: number; next: string; origin: string | null; noWait: boolean }
1493
+ | number {
1418
1494
  let next = "/";
1419
1495
  let origin: string | null = null;
1496
+ let noWait = false;
1420
1497
  let port: number;
1421
1498
  let rest: string[];
1422
1499
  try {
@@ -1433,6 +1510,10 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1433
1510
  : a.startsWith(`${flag}=`)
1434
1511
  ? a.slice(flag.length + 1)
1435
1512
  : undefined;
1513
+ if (a === "--no-wait") {
1514
+ noWait = true;
1515
+ continue;
1516
+ }
1436
1517
  const n = val("--next");
1437
1518
  const o = val("--origin");
1438
1519
  const h = val("--host");
@@ -1453,14 +1534,46 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1453
1534
  return 2;
1454
1535
  }
1455
1536
  } else {
1456
- io.err(usage);
1537
+ io.err(LOGIN_USAGE());
1457
1538
  return 2;
1458
1539
  }
1459
1540
  }
1541
+ return { port, next, origin, noWait };
1542
+ }
1543
+
1544
+ const LOGIN_HELP = () =>
1545
+ LOGIN_USAGE() +
1546
+ " Prints a one-time sign-in link (open it within 60 s) for one browser, and a\n" +
1547
+ " code the page behind the link asks for, then waits until the link is used:\n" +
1548
+ ' "Signed in: <browser> (<address>) at <time>", or "Expired: nobody used the\n' +
1549
+ ' link" (exit 1). On localhost nothing asks for a sign-in; it is needed when\n' +
1550
+ " this server is reached from another machine (pass the edge hostname as\n" +
1551
+ " --host, or a LAN address as --origin).\n" +
1552
+ " --next <path> where the browser lands after signing in (same origin only)\n" +
1553
+ " --host <name> an edge hostname that routes here (an edge domain or alias,\n" +
1554
+ " a VPN name among them): the link uses its edge URL\n" +
1555
+ " --origin <url> the address the browser uses, http(s)://host[:port]\n" +
1556
+ " --no-wait print the link and exit; the link signs in by itself, no\n" +
1557
+ " code (for scripts)\n";
1558
+
1559
+ /**
1560
+ * `<cli> web login --no-wait`: a fresh sign-in link on stdout, alone (the
1561
+ * only place a login code is ever printed); a note on stderr. `--host` for
1562
+ * an edge hostname (its URL from the edge config), `--origin` for any
1563
+ * allowed origin (a LAN name), where the cookie must be set (the
1564
+ * signed-out page there says which). The link signs in when opened.
1565
+ */
1566
+ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1567
+ if (isHelp(args)) {
1568
+ io.out(LOGIN_HELP());
1569
+ return 0;
1570
+ }
1571
+ const a = parseLoginArgs(args, io);
1572
+ if (typeof a === "number") return a;
1460
1573
  const link = freshLoginLink(
1461
- port,
1462
- next,
1463
- origin ?? localhostOrigin(port),
1574
+ a.port,
1575
+ a.next,
1576
+ a.origin ?? localhostOrigin(a.port),
1464
1577
  io.stateDir ?? getStateDir(),
1465
1578
  );
1466
1579
  io.err(
@@ -1470,6 +1583,121 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1470
1583
  return 0;
1471
1584
  }
1472
1585
 
1586
+ /** "10:42" in this machine's time. */
1587
+ function clockTime(iso: string): string {
1588
+ const d = new Date(iso);
1589
+ return Number.isNaN(d.getTime())
1590
+ ? iso
1591
+ : `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1592
+ }
1593
+
1594
+ /**
1595
+ * `<cli> web login` (SEC-2a): the link and an 8-character code, then a
1596
+ * wait until the link is used or expires. The page behind the link asks
1597
+ * for the code before it signs anything in. The CLI registers the link's
1598
+ * nonce and the code with the running server (its bearer) and polls that
1599
+ * wait; the code is shown here only, never logged. With no server running
1600
+ * (or `--no-wait`) it prints the link alone, as before.
1601
+ */
1602
+ export async function cmdWebLoginWait(
1603
+ args: string[],
1604
+ io: CliIo = stdIo,
1605
+ opts: { pollMs?: number; maxWaitMs?: number } = {},
1606
+ ): Promise<number> {
1607
+ if (isHelp(args)) {
1608
+ io.out(LOGIN_HELP());
1609
+ return 0;
1610
+ }
1611
+ const a = parseLoginArgs(args, io);
1612
+ if (typeof a === "number") return a;
1613
+ if (a.noWait)
1614
+ return cmdWebLogin(
1615
+ args.filter((x) => x !== "--no-wait"),
1616
+ io,
1617
+ );
1618
+ const stateDir = io.stateDir ?? getStateDir();
1619
+ const secret = ensureWebSecret(stateDir);
1620
+ const now = Date.now();
1621
+ const loginCode = mintLoginCode(secret, now);
1622
+ const checked = checkLoginCode(loginCode, secret, now);
1623
+ if (!checked.ok) {
1624
+ io.err(`${product().cli}: could not make a sign-in link\n`);
1625
+ return 1;
1626
+ }
1627
+ const confirm = newConfirmCode();
1628
+ const reg = await serverCall(stateDir, a.port, "POST", "/api/web/logins", {
1629
+ nonce: checked.nonce,
1630
+ code: confirm,
1631
+ expiresAt: checked.expiresAt,
1632
+ });
1633
+ const link = loginLink(
1634
+ a.origin ?? localhostOrigin(a.port),
1635
+ loginCode,
1636
+ a.next,
1637
+ );
1638
+ if (!reg) {
1639
+ io.err(
1640
+ `${product().cli}: no ${product().title} web server answers on port ${a.port}, so this ` +
1641
+ "doesn't wait and the link asks for no code (as --no-wait). Start the server " +
1642
+ `(\`${product().cli} web up\`), then open this link within 60 seconds; it signs in one browser, once.\n`,
1643
+ );
1644
+ io.out(`${link}\n`);
1645
+ return 0;
1646
+ }
1647
+ const id = (reg.json as { id?: unknown } | null)?.id;
1648
+ if (reg.status !== 200 || typeof id !== "string") {
1649
+ io.err(`${product().cli}: ${apiError(reg)}\n`);
1650
+ return 1;
1651
+ }
1652
+ io.err(
1653
+ `${product().cli}: open this link within 60 seconds, in the browser to sign in. ` +
1654
+ `It asks for this code: ${confirm}\n`,
1655
+ );
1656
+ io.out(`${link}\n`);
1657
+ io.err(`${product().cli}: waiting for the link to be used…\n`);
1658
+ const pollMs = opts.pollMs ?? 1000;
1659
+ const deadline = Date.now() + (opts.maxWaitMs ?? 7 * 60_000);
1660
+ for (;;) {
1661
+ await new Promise((r) => setTimeout(r, pollMs));
1662
+ const r = await serverCall(
1663
+ stateDir,
1664
+ a.port,
1665
+ "GET",
1666
+ `/api/web/logins/${id}`,
1667
+ );
1668
+ const j = (r?.json ?? {}) as {
1669
+ state?: string;
1670
+ device?: string;
1671
+ client?: string;
1672
+ usedAt?: string;
1673
+ };
1674
+ if (r && j.state === "used") {
1675
+ io.out(
1676
+ `Signed in: ${j.device ?? "a browser"}${j.client ? ` (${j.client})` : ""}` +
1677
+ ` at ${clockTime(j.usedAt ?? new Date().toISOString())}\n`,
1678
+ );
1679
+ return 0;
1680
+ }
1681
+ if (r && j.state === "refused") {
1682
+ io.err("Refused: too many wrong codes; the link no longer works\n");
1683
+ return 1;
1684
+ }
1685
+ if (
1686
+ (r && (j.state === "expired" || r.status === 404)) ||
1687
+ Date.now() > deadline
1688
+ ) {
1689
+ io.err("Expired: nobody used the link\n");
1690
+ return 1;
1691
+ }
1692
+ if (!r) {
1693
+ io.err(
1694
+ `${product().cli}: the server stopped answering; the link can't be used now\n`,
1695
+ );
1696
+ return 1;
1697
+ }
1698
+ }
1699
+ }
1700
+
1473
1701
  /**
1474
1702
  * `web login --host <name>`: the origin a browser uses for an edge hostname
1475
1703
  * that routes to this server (edge.routes under any edge domain or alias),
@@ -1523,14 +1751,17 @@ function serverCall(
1523
1751
  ): Promise<{ status: number; json: unknown } | null> {
1524
1752
  return new Promise((resolve) => {
1525
1753
  const payload = body === undefined ? undefined : JSON.stringify(body);
1754
+ const to = webProbeTarget(process.env.DEN_BIND_HOST, port);
1526
1755
  const req = http.request(
1527
1756
  {
1528
- host: "127.0.0.1",
1757
+ host: to.host,
1529
1758
  port,
1530
1759
  path: p,
1531
1760
  method,
1532
1761
  timeout: 10_000,
1762
+ setHost: false,
1533
1763
  headers: {
1764
+ host: to.hostHeader,
1534
1765
  ...cliAuthHeaders(stateDir),
1535
1766
  ...(payload !== undefined
1536
1767
  ? {
@@ -1737,3 +1968,252 @@ export async function cmdWebLogout(
1737
1968
  );
1738
1969
  return 0;
1739
1970
  }
1971
+
1972
+ // ── SEC-2a: approve this computer with a code (`web approve`) ──────────────
1973
+
1974
+ interface WaitingRow {
1975
+ code: string;
1976
+ device: string;
1977
+ client?: string;
1978
+ createdAt: string;
1979
+ expiresAt: string;
1980
+ }
1981
+
1982
+ /** "3 min ago" for a list: whole minutes, or "just now". */
1983
+ function agoText(iso: string, now = Date.now()): string {
1984
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
1985
+ return s < 60 ? "just now" : `${Math.round(s / 60)} min ago`;
1986
+ }
1987
+
1988
+ /**
1989
+ * `<cli> web approve <code> | --list [--json]`: let in a computer whose
1990
+ * signed-out page shows `<code>` (case and dashes don't matter), or list
1991
+ * the ones waiting. It asks the running server, with this CLI's bearer:
1992
+ * the requests live in its memory only, so with no server there is
1993
+ * nothing to approve.
1994
+ */
1995
+ export async function cmdWebApprove(
1996
+ args: string[],
1997
+ io: CliIo = stdIo,
1998
+ ): Promise<number> {
1999
+ let port: number;
2000
+ let rest: string[];
2001
+ try {
2002
+ ({ port, rest } = takePort(args));
2003
+ } catch (e) {
2004
+ io.err(`${(e as Error).message}
2005
+ `);
2006
+ return 2;
2007
+ }
2008
+ const usage = `Usage: ${product().cli} web approve <code> | --list [--json] [--port <port>]\n`;
2009
+ if (isHelp(rest)) {
2010
+ io.out(
2011
+ usage +
2012
+ " Lets in the computer whose signed-out page shows <code> (like K7Q-42M; case\n" +
2013
+ " and dashes don't matter). That page then signs in by itself. A code lasts\n" +
2014
+ " 10 minutes and works once.\n" +
2015
+ " --list the computers waiting: code, browser, address, age\n" +
2016
+ " --json with --list: as JSON\n",
2017
+ );
2018
+ return 0;
2019
+ }
2020
+ const list = rest.includes("--list");
2021
+ const asJson = rest.includes("--json");
2022
+ const codes = rest.filter((a) => a !== "--list" && a !== "--json");
2023
+ if (
2024
+ codes.some((a) => a.startsWith("-")) ||
2025
+ (list ? codes.length !== 0 : codes.length !== 1 || asJson)
2026
+ ) {
2027
+ io.err(usage);
2028
+ return 2;
2029
+ }
2030
+ const stateDir = io.stateDir ?? getStateDir();
2031
+ const r = list
2032
+ ? await serverCall(stateDir, port, "GET", "/api/web/waiting")
2033
+ : await serverCall(stateDir, port, "POST", "/api/web/approve", {
2034
+ code: codes[0],
2035
+ });
2036
+ if (!r) {
2037
+ io.err(
2038
+ `${product().cli}: no ${product().title} web server answers on port ${port}. ` +
2039
+ "A waiting computer's code lives in the running server: start it " +
2040
+ `(\`${product().cli} web up\`) and reload the page on the other computer.\n`,
2041
+ );
2042
+ return 1;
2043
+ }
2044
+ if (r.status !== 200) {
2045
+ io.err(`${product().cli}: ${apiError(r)}\n`);
2046
+ return 1;
2047
+ }
2048
+ if (list) {
2049
+ const rows = ((r.json as { waiting?: WaitingRow[] }).waiting ?? []).map(
2050
+ (w) => ({ ...w }),
2051
+ );
2052
+ if (asJson) {
2053
+ io.out(`${JSON.stringify(rows, null, 2)}\n`);
2054
+ return 0;
2055
+ }
2056
+ if (rows.length === 0) {
2057
+ io.out("No computers are waiting for approval.\n");
2058
+ return 0;
2059
+ }
2060
+ const table = [
2061
+ ["CODE", "BROWSER", "ADDRESS", "WAITING"],
2062
+ ...rows.map((w) => [
2063
+ w.code,
2064
+ w.device,
2065
+ w.client ?? "",
2066
+ agoText(w.createdAt),
2067
+ ]),
2068
+ ];
2069
+ const widths = table[0].map((_, c) =>
2070
+ Math.max(...table.map((row) => row[c].length)),
2071
+ );
2072
+ for (const row of table)
2073
+ io.out(
2074
+ `${row
2075
+ .map((cell, c) =>
2076
+ c === row.length - 1 ? cell : cell.padEnd(widths[c]),
2077
+ )
2078
+ .join(" ")
2079
+ .trimEnd()}\n`,
2080
+ );
2081
+ return 0;
2082
+ }
2083
+ const w = r.json as WaitingRow;
2084
+ io.out(
2085
+ `Approved: ${w.device}${w.client ? ` (${w.client})` : ""}. ` +
2086
+ "Its page signs in by itself.\n",
2087
+ );
2088
+ return 0;
2089
+ }
2090
+
2091
+ // ── SEC-2a: the owner (`web owner link`, `show`, `unlink`) ─────────────────
2092
+
2093
+ /**
2094
+ * `<cli> web owner [link|show|unlink] [--json]`: the GitHub account this
2095
+ * Den belongs to, which Sign in with GitHub admits.
2096
+ * - link: GitHub's device flow, here in the terminal: the code and
2097
+ * github.com/login/device, then a wait while you approve it. Den keeps
2098
+ * `github:<id>` and the login (a 0600 file in the state dir) and drops
2099
+ * the token at once. Refused while an owner is linked.
2100
+ * - show (the default): who it is.
2101
+ * - unlink: forget it. Signed-in browsers stay signed in.
2102
+ * `client` and `sleep` are a test's.
2103
+ */
2104
+ export async function cmdWebOwner(
2105
+ args: string[],
2106
+ io: CliIo & {
2107
+ client?: GithubDeviceClient;
2108
+ sleep?: (ms: number) => Promise<void>;
2109
+ clientId?: string;
2110
+ } = stdIo,
2111
+ ): Promise<number> {
2112
+ const usage = `Usage: ${product().cli} web owner [link|show|unlink] [--json]\n`;
2113
+ if (isHelp(args)) {
2114
+ io.out(
2115
+ usage +
2116
+ " link link your GitHub account as this Den's owner (GitHub's device flow:\n" +
2117
+ ` enter the code it shows at ${GITHUB_DEVICE_PAGE}); only its id is kept\n` +
2118
+ " show who the owner is (the default)\n" +
2119
+ " unlink forget the owner; signed-in browsers stay signed in\n" +
2120
+ " The owner can then sign in from any device: the signed-out page's\n" +
2121
+ " Sign in with GitHub. No token is stored.\n",
2122
+ );
2123
+ return 0;
2124
+ }
2125
+ const asJson = args.includes("--json");
2126
+ const rest = args.filter((a) => a !== "--json");
2127
+ const sub = rest[0] ?? "show";
2128
+ if (rest.length > 1 || !["link", "show", "unlink"].includes(sub)) {
2129
+ io.err(usage);
2130
+ return 2;
2131
+ }
2132
+ const stateDir = io.stateDir ?? getStateDir();
2133
+ const cli = product().cli;
2134
+ if (sub === "show") {
2135
+ const o = readWebOwner(stateDir);
2136
+ if (asJson) io.out(`${JSON.stringify({ owner: o }, null, 2)}\n`);
2137
+ else
2138
+ io.out(
2139
+ o
2140
+ ? `Owner: @${o.login} (${o.owner}), linked ${o.linkedAt.replace("T", " ").replace(/\.\d+Z$/, "Z")}.\n`
2141
+ : `No owner is linked. Run \`${cli} web owner link\`.\n`,
2142
+ );
2143
+ return 0;
2144
+ }
2145
+ if (sub === "unlink") {
2146
+ const was = clearWebOwner(stateDir);
2147
+ if (asJson) io.out(`${JSON.stringify({ unlinked: was }, null, 2)}\n`);
2148
+ else
2149
+ io.out(
2150
+ was
2151
+ ? `Unlinked @${was.login}. Signed-in browsers stay signed in (\`${cli} web logout\` ends them).\n`
2152
+ : "No owner was linked.\n",
2153
+ );
2154
+ return 0;
2155
+ }
2156
+ const linked = readWebOwner(stateDir);
2157
+ if (linked) {
2158
+ io.err(
2159
+ `${cli}: this Den's owner is @${linked.login} already; \`${cli} web owner unlink\` first.\n`,
2160
+ );
2161
+ return 1;
2162
+ }
2163
+ let clientId = io.clientId;
2164
+ if (clientId === undefined) {
2165
+ try {
2166
+ clientId = githubClientId(
2167
+ readWebAuthConfig(getDistRoot(), product().configFile).githubClientId,
2168
+ );
2169
+ } catch (e) {
2170
+ io.err(`${cli}: ${(e as Error).message}\n`);
2171
+ return 1;
2172
+ }
2173
+ }
2174
+ if (!clientId) {
2175
+ io.err(`${cli}: ${noClientIdMessage(cli)}\n`);
2176
+ return 1;
2177
+ }
2178
+ const ep = githubEndpoints();
2179
+ let outcome;
2180
+ try {
2181
+ outcome = await runDeviceFlow({
2182
+ clientId,
2183
+ client: io.client ?? githubDeviceClient(ep),
2184
+ ...(io.sleep ? { sleep: io.sleep } : {}),
2185
+ minIntervalMs: ep.fake || io.client ? 0 : 1000,
2186
+ onCode: (c) =>
2187
+ io.out(
2188
+ `Open ${GITHUB_DEVICE_PAGE} and enter this code:\n\n ${c.userCode}\n\n` +
2189
+ `Waiting for you to approve it on GitHub (it asks for no permissions; ` +
2190
+ `the code lasts ${Math.round(c.expiresIn / 60)} min)…\n`,
2191
+ ),
2192
+ });
2193
+ } catch (e) {
2194
+ io.err(`${cli}: ${(e as Error).message}\n`);
2195
+ return 1;
2196
+ }
2197
+ if (outcome.kind !== "ok") {
2198
+ io.err(
2199
+ `${cli}: ${
2200
+ outcome.kind === "expired"
2201
+ ? "the code expired before it was approved; run it again"
2202
+ : outcome.kind === "denied"
2203
+ ? "the request was cancelled on GitHub"
2204
+ : outcome.message
2205
+ }\n`,
2206
+ );
2207
+ return 1;
2208
+ }
2209
+ const rec = writeWebOwner(outcome.identity, stateDir);
2210
+ if (outcome.expiringToken)
2211
+ io.err(`${product().cli}: warning: ${expiringTokenWarning()}\n`);
2212
+ if (asJson) io.out(`${JSON.stringify({ owner: rec }, null, 2)}\n`);
2213
+ else
2214
+ io.out(
2215
+ `Linked @${rec.login} (${githubPrincipal(outcome.identity.id)}) as this Den's owner; ` +
2216
+ "the token was not kept. Sign in with GitHub from your other devices now works.\n",
2217
+ );
2218
+ return 0;
2219
+ }