@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.
- package/CHANGELOG.md +243 -0
- package/README.md +24 -9
- package/docs/distributions.md +320 -26
- package/docs/reference.md +8 -3
- package/package.json +4 -3
- package/skills/install-den/SKILL.md +114 -0
- package/src/shared/agent-setup.ts +9 -0
- package/src/shared/auth/sudo-vault.ts +115 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +7 -0
- package/src/shared/dev-metrics/emit.ts +59 -2
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/port-pick.ts +60 -0
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/create-main.ts +1 -1
- package/src/ui/cli/create.ts +263 -30
- package/src/ui/cli/doctor-command.ts +67 -4
- package/src/ui/cli/doctor-onboard.ts +291 -0
- package/src/ui/cli/flag-parser.ts +5 -1
- package/src/ui/cli/index.ts +246 -4
- package/src/ui/cli/json-out.ts +49 -0
- package/src/ui/cli/mcp-connect.ts +179 -15
- package/src/ui/cli/repo-command.ts +490 -0
- package/src/ui/cli/repo-detect.ts +192 -0
- package/src/ui/cli/web-command.ts +513 -33
- package/src/ui/mcp/connect.ts +134 -0
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +463 -27
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/i18n/es.js +74 -8
- package/src/ui/server/public/i18n/ja.js +74 -8
- package/src/ui/server/public/local-dev.html +16 -4
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/settings-page.js +10 -3
- package/src/ui/server/public/settings-registry.js +392 -1
- package/src/ui/server/public/shell.js +123 -10
- package/src/ui/server/public/topbar.js +7 -3
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +102 -2
- 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:
|
|
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),
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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(
|
|
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:
|
|
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
|
+
}
|