@monster-orange/den 0.4.19 → 0.4.21
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 +122 -0
- package/README.md +7 -7
- package/docs/distributions.md +121 -13
- package/package.json +1 -1
- package/src/shared/attest-code.ts +125 -0
- package/src/shared/claim-code.ts +6 -2
- package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
- package/src/shared/github-app.ts +4 -0
- package/src/shared/github-device.ts +38 -1
- package/src/shared/owner-github.ts +100 -0
- package/src/shared/product.ts +45 -5
- package/src/shared/profile.ts +26 -2
- package/src/shared/secrets/index.ts +71 -3
- package/src/shared/secrets/onepassword.ts +87 -14
- package/src/shared/secrets/op-token.ts +270 -0
- package/src/shared/secrets/secret-env.ts +19 -6
- package/src/shared/secrets/strategies.ts +6 -0
- package/src/shared/secrets/types.ts +8 -0
- package/src/shared/web-session.ts +112 -6
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +108 -17
- package/src/ui/cli/doctor-owner.ts +122 -0
- package/src/ui/cli/service-command.ts +16 -0
- package/src/ui/cli/web-command.ts +74 -12
- package/src/ui/server/device-flows.ts +45 -10
- package/src/ui/server/github-routes.ts +169 -16
- package/src/ui/server/index.ts +148 -13
- package/src/ui/server/public/error-page.js +115 -33
- package/src/ui/server/public/error.html +16 -0
- package/src/ui/server/public/header.js +5 -7
- package/src/ui/server/public/i18n/es.js +11 -1
- package/src/ui/server/public/i18n/ja.js +11 -1
- package/src/ui/server/public/settings-registry.js +23 -1
- package/src/ui/server/public/shell.js +11 -1
- package/src/ui/server/route-classes.ts +7 -0
package/src/ui/cli/create.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
16
17
|
import { ENGINE_PACKAGE, getEngineRoot } from "../../shared/paths.js";
|
|
17
18
|
import { profileFrom } from "../../shared/profile.js";
|
|
18
19
|
import { parse as parseYaml } from "yaml";
|
|
@@ -31,6 +32,44 @@ export interface CreateOptions {
|
|
|
31
32
|
envPrefix?: string;
|
|
32
33
|
/** The Den version the distribution pins (exact). */
|
|
33
34
|
denVersion: string;
|
|
35
|
+
/**
|
|
36
|
+
* D17: `web.allowed_github_org`: the GitHub org whose members may claim
|
|
37
|
+
* an instance of it (each developer's own). Unset: a commented line.
|
|
38
|
+
*/
|
|
39
|
+
githubOrg?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const GITHUB_ORG = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The GitHub owner of a git remote URL (https or ssh), or null:
|
|
46
|
+
* `git@github.com:acme/acme-den.git` → `acme`.
|
|
47
|
+
*/
|
|
48
|
+
export function githubOwnerOfRemote(url: string): string | null {
|
|
49
|
+
const m =
|
|
50
|
+
/^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|(?:ssh:\/\/)?git@github\.com[:/])([^/]+)\//i.exec(
|
|
51
|
+
url.trim(),
|
|
52
|
+
);
|
|
53
|
+
return m && GITHUB_ORG.test(m[1]) ? m[1] : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The GitHub owner of the repo `dir` is in (its nearest existing folder's
|
|
58
|
+
* `origin`), or null: no repo, no origin, not GitHub.
|
|
59
|
+
*/
|
|
60
|
+
export function githubOrgOfDir(dir: string): string | null {
|
|
61
|
+
let d = path.resolve(dir);
|
|
62
|
+
while (!fs.existsSync(d) && path.dirname(d) !== d) d = path.dirname(d);
|
|
63
|
+
try {
|
|
64
|
+
const url = execFileSync("git", ["-C", d, "remote", "get-url", "origin"], {
|
|
65
|
+
encoding: "utf-8",
|
|
66
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
67
|
+
timeout: 5_000,
|
|
68
|
+
});
|
|
69
|
+
return githubOwnerOfRemote(url);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
34
73
|
}
|
|
35
74
|
|
|
36
75
|
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
@@ -58,6 +97,10 @@ export function validateCreateOptions(opts: CreateOptions): void {
|
|
|
58
97
|
throw new Error("--title must not be empty");
|
|
59
98
|
if (opts.title !== undefined && /[\n"\\]/.test(opts.title))
|
|
60
99
|
throw new Error("--title must be one line without quotes or backslashes");
|
|
100
|
+
if (opts.githubOrg !== undefined && !GITHUB_ORG.test(opts.githubOrg))
|
|
101
|
+
throw new Error(
|
|
102
|
+
`--github-org "${opts.githubOrg}" must be a GitHub org name (letters, digits and dashes)`,
|
|
103
|
+
);
|
|
61
104
|
// The profile validates the env prefix the same way den.yml would.
|
|
62
105
|
profileFrom({
|
|
63
106
|
name: opts.name,
|
|
@@ -152,6 +195,13 @@ export function renderDistribution(
|
|
|
152
195
|
"# Unset, Den lists no GitHub repos (and never calls gh for them). E.g.",
|
|
153
196
|
"# github_org: example-org",
|
|
154
197
|
"",
|
|
198
|
+
"# Who may claim an instance. Each developer runs their own and opens it",
|
|
199
|
+
"# in the browser; a member of this GitHub org connects their GitHub",
|
|
200
|
+
"# account there, on their own computer, and owns that instance.",
|
|
201
|
+
...(opts.githubOrg
|
|
202
|
+
? ["web:", ` allowed_github_org: ${opts.githubOrg}`]
|
|
203
|
+
: ["# web:", "# allowed_github_org: your-github-org"]),
|
|
204
|
+
"",
|
|
155
205
|
"# Secrets backend: none (this distribution needs no secrets store), or",
|
|
156
206
|
"# env (plain environment variables), doppler or 1password. A backend's",
|
|
157
207
|
"# refs say where Den's own features find their secrets, e.g.",
|
|
@@ -307,8 +357,14 @@ export interface ParsedCreateArgs extends Omit<CreateOptions, "denVersion"> {
|
|
|
307
357
|
port?: number;
|
|
308
358
|
}
|
|
309
359
|
|
|
360
|
+
/** Where create finds web.allowed_github_org when --github-org isn't given. */
|
|
361
|
+
export interface CreateOrgOptions {
|
|
362
|
+
/** The GitHub owner of the repo the new folder is in (default: git's). */
|
|
363
|
+
githubOrgOf?: (dir: string) => string | null;
|
|
364
|
+
}
|
|
365
|
+
|
|
310
366
|
const CREATE_USAGE =
|
|
311
|
-
"usage: den create <name> [dir] [--title <title>] [--cli <cli>] [--env-prefix <PREFIX>] [--port <n>] [--yes] [--json]";
|
|
367
|
+
"usage: den create <name> [dir] [--title <title>] [--cli <cli>] [--env-prefix <PREFIX>] [--port <n>] [--github-org <org>] [--yes] [--json]";
|
|
312
368
|
|
|
313
369
|
/** Parse `create` argv (after the word `create`). `cwd` resolves the dir. */
|
|
314
370
|
export function parseCreateArgs(argv: string[], cwd: string): ParsedCreateArgs {
|
|
@@ -326,7 +382,7 @@ export function parseCreateArgs(argv: string[], cwd: string): ParsedCreateArgs {
|
|
|
326
382
|
json = true;
|
|
327
383
|
continue;
|
|
328
384
|
}
|
|
329
|
-
const m = /^--(title|cli|env-prefix|port)(?:=(.*))?$/.exec(a);
|
|
385
|
+
const m = /^--(title|cli|env-prefix|port|github-org)(?:=(.*))?$/.exec(a);
|
|
330
386
|
if (m) {
|
|
331
387
|
const value = m[2] ?? argv[++i];
|
|
332
388
|
if (value === undefined)
|
|
@@ -345,6 +401,9 @@ export function parseCreateArgs(argv: string[], cwd: string): ParsedCreateArgs {
|
|
|
345
401
|
title: flags.title,
|
|
346
402
|
cli: flags.cli,
|
|
347
403
|
envPrefix: flags["env-prefix"],
|
|
404
|
+
...(flags["github-org"] !== undefined
|
|
405
|
+
? { githubOrg: flags["github-org"].replace(/^@/, "") }
|
|
406
|
+
: {}),
|
|
348
407
|
yes,
|
|
349
408
|
json,
|
|
350
409
|
...(flags.port !== undefined ? { port: parsePortFlag(flags.port) } : {}),
|
|
@@ -370,6 +429,9 @@ config/environments/local.json, manifests/ and tests/pillars/.
|
|
|
370
429
|
--env-prefix <PREFIX> env-var prefix (default: <NAME>)
|
|
371
430
|
--port <n> the web UI's port (default: ${DEFAULT_PORT}, or the next
|
|
372
431
|
free one above it when that is in use)
|
|
432
|
+
--github-org <org> web.allowed_github_org: members of this GitHub org
|
|
433
|
+
connect their GitHub account to their own instance
|
|
434
|
+
(default: the owner of the GitHub repo [dir] is in)
|
|
373
435
|
--yes, -y never prompt (create never does; for agents)
|
|
374
436
|
--json print one JSON object: { ok, dir, cli, existed, port, next }
|
|
375
437
|
|
|
@@ -407,6 +469,8 @@ export interface CreateJson {
|
|
|
407
469
|
port?: number;
|
|
408
470
|
/** Set when DEFAULT_PORT was in use and `port` was picked instead. */
|
|
409
471
|
portPicked?: { wanted: number; file: string };
|
|
472
|
+
/** D17: web.allowed_github_org as written, or null when it wasn't known. */
|
|
473
|
+
githubOrg?: string | null;
|
|
410
474
|
next?: string[];
|
|
411
475
|
error?: string;
|
|
412
476
|
fix?: string;
|
|
@@ -478,12 +542,17 @@ export async function createResult(
|
|
|
478
542
|
args: ParsedCreateArgs,
|
|
479
543
|
cwd: string,
|
|
480
544
|
denVersion: string = runningDenVersion(),
|
|
481
|
-
portOpts: CreatePortOptions = {},
|
|
545
|
+
portOpts: CreatePortOptions & CreateOrgOptions = {},
|
|
482
546
|
): Promise<CreateJson> {
|
|
483
547
|
const cli = args.cli ?? args.name;
|
|
484
|
-
const next = (dir: string): string[] => [
|
|
548
|
+
const next = (dir: string, org?: string | null): string[] => [
|
|
485
549
|
`cd ${cdPathFor(cwd, dir)}`,
|
|
486
550
|
"npm install",
|
|
551
|
+
...(org === null
|
|
552
|
+
? [
|
|
553
|
+
`set web.allowed_github_org: <your GitHub org> in config/${args.name}.yml (who may claim an instance)`,
|
|
554
|
+
]
|
|
555
|
+
: []),
|
|
487
556
|
`./bin/${cli} repo add <path|git-url> --json`,
|
|
488
557
|
];
|
|
489
558
|
try {
|
|
@@ -509,7 +578,17 @@ export async function createResult(
|
|
|
509
578
|
args.port ??
|
|
510
579
|
(await pickFreePort({ start: wanted, inUse: portOpts.inUse })) ??
|
|
511
580
|
wanted;
|
|
512
|
-
|
|
581
|
+
// D17: the org whose members may claim an instance: --github-org, else
|
|
582
|
+
// the owner of the GitHub repo the folder is in.
|
|
583
|
+
const githubOrg =
|
|
584
|
+
args.githubOrg ??
|
|
585
|
+
(portOpts.githubOrgOf ?? githubOrgOfDir)(args.dir) ??
|
|
586
|
+
undefined;
|
|
587
|
+
const written = writeDistribution(args.dir, {
|
|
588
|
+
...args,
|
|
589
|
+
denVersion,
|
|
590
|
+
...(githubOrg ? { githubOrg } : {}),
|
|
591
|
+
});
|
|
513
592
|
let portPicked: CreateJson["portPicked"];
|
|
514
593
|
// Written when the port differs from what the distribution would use
|
|
515
594
|
// anyway (an explicit --port that isn't the default, or a picked one).
|
|
@@ -537,7 +616,8 @@ export async function createResult(
|
|
|
537
616
|
files: written.length,
|
|
538
617
|
port,
|
|
539
618
|
...(portPicked ? { portPicked } : {}),
|
|
540
|
-
|
|
619
|
+
githubOrg: githubOrg ?? null,
|
|
620
|
+
next: next(args.dir, githubOrg ?? null),
|
|
541
621
|
};
|
|
542
622
|
} catch (e) {
|
|
543
623
|
const error = (e as Error).message;
|
|
@@ -602,6 +682,12 @@ export async function runCreate(
|
|
|
602
682
|
? `Web UI port: ${r.port} (${r.portPicked.wanted} is in use here; set in ${r.portPicked.file}).`
|
|
603
683
|
: `Web UI port: ${r.port}.`,
|
|
604
684
|
);
|
|
685
|
+
if (!r.existed)
|
|
686
|
+
console.log(
|
|
687
|
+
r.githubOrg
|
|
688
|
+
? `GitHub org: ${r.githubOrg} (web.allowed_github_org: its members connect their GitHub account to their own instance).`
|
|
689
|
+
: `GitHub org: not known. Set web.allowed_github_org in config/${args.name}.yml, or pass --github-org <org>, so your team can connect GitHub from the browser.`,
|
|
690
|
+
);
|
|
605
691
|
console.log("");
|
|
606
692
|
console.log("Next:");
|
|
607
693
|
console.log(` cd ${cdPathFor(cwd, dir)}`);
|
|
@@ -37,8 +37,13 @@ import { readBrand } from "../../shared/brand.js";
|
|
|
37
37
|
import { hasEdgeBlock, readEdgeConfig } from "../../shared/edge/config.js";
|
|
38
38
|
import { edgeTlsWarnings, tlsStrategyFor } from "../../shared/edge/tls.js";
|
|
39
39
|
import {
|
|
40
|
+
describeOpTokenSource,
|
|
41
|
+
opTokenSourceProblem,
|
|
42
|
+
parseOpTokenSource,
|
|
40
43
|
readSecretsConfig,
|
|
44
|
+
resolveProviderName,
|
|
41
45
|
secretsStatus,
|
|
46
|
+
type SecretsConfig,
|
|
42
47
|
} from "../../shared/secrets/index.js";
|
|
43
48
|
import {
|
|
44
49
|
DEN_PTY_ID,
|
|
@@ -46,6 +51,7 @@ import {
|
|
|
46
51
|
} from "../../shared/sessions/den-pty.js";
|
|
47
52
|
import {
|
|
48
53
|
getStrategy,
|
|
54
|
+
strategyIds,
|
|
49
55
|
strategyKinds,
|
|
50
56
|
} from "../../shared/strategies/registry.js";
|
|
51
57
|
import {
|
|
@@ -60,6 +66,7 @@ import {
|
|
|
60
66
|
type OnboardDeps,
|
|
61
67
|
} from "./doctor-onboard.js";
|
|
62
68
|
import { printJson } from "./json-out.js";
|
|
69
|
+
import { ownerCheck, ownerLine } from "./doctor-owner.js";
|
|
63
70
|
|
|
64
71
|
export interface DoctorRow {
|
|
65
72
|
kind: string;
|
|
@@ -77,7 +84,22 @@ type Selector = (env: NodeJS.ProcessEnv) => DoctorRow;
|
|
|
77
84
|
|
|
78
85
|
const SELECTORS: Record<string, Selector> = {
|
|
79
86
|
secrets(env) {
|
|
80
|
-
|
|
87
|
+
let cfg: SecretsConfig;
|
|
88
|
+
try {
|
|
89
|
+
cfg = readSecretsConfig();
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// A bad secrets.* key (naming the key): a problem, not a crash.
|
|
92
|
+
return {
|
|
93
|
+
kind: "secrets",
|
|
94
|
+
selected: "",
|
|
95
|
+
implicit: false,
|
|
96
|
+
available: false,
|
|
97
|
+
reason: (err as Error).message,
|
|
98
|
+
problem: true,
|
|
99
|
+
warnings: [],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const st = secretsStatus(cfg, env);
|
|
81
103
|
return {
|
|
82
104
|
kind: "secrets",
|
|
83
105
|
selected: st.backend,
|
|
@@ -172,32 +194,91 @@ export function doctorRows(env: NodeJS.ProcessEnv = process.env): DoctorRow[] {
|
|
|
172
194
|
...Object.keys(SELECTORS).filter((k) => kinds.includes(k)),
|
|
173
195
|
...kinds.filter((k) => !(k in SELECTORS)),
|
|
174
196
|
];
|
|
175
|
-
|
|
176
|
-
(kind) =>
|
|
177
|
-
SELECTORS[kind]?.(env) ?? {
|
|
178
|
-
kind,
|
|
179
|
-
selected: "",
|
|
180
|
-
implicit: false,
|
|
181
|
-
available: false,
|
|
182
|
-
reason: "no selector for this kind",
|
|
183
|
-
problem: false,
|
|
184
|
-
warnings: [],
|
|
185
|
-
},
|
|
197
|
+
const rows = ordered.map(
|
|
198
|
+
(kind) => SELECTORS[kind]?.(env) ?? everyStrategyRow(kind, env),
|
|
186
199
|
);
|
|
200
|
+
const op = onePasswordTokenRow(env);
|
|
201
|
+
if (op) rows.splice(rows.findIndex((r) => r.kind === "secrets") + 1, 0, op);
|
|
202
|
+
return rows;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A kind no config selects one of (usage sources, D9): every registered
|
|
207
|
+
* strategy is in use, so the row names them all (implicit) and is available
|
|
208
|
+
* when any is. Never a problem: nothing was configured wrong. (It said
|
|
209
|
+
* "(invalid config) ✗ no selector for this kind" on every fresh install.)
|
|
210
|
+
*/
|
|
211
|
+
function everyStrategyRow(kind: string, env: NodeJS.ProcessEnv): DoctorRow {
|
|
212
|
+
const ids = strategyIds(kind);
|
|
213
|
+
const found = ids.map((id) => {
|
|
214
|
+
try {
|
|
215
|
+
return getStrategy(kind, id)?.detect(env) ?? { available: false };
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return { available: false, reason: (e as Error).message };
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
const available = found.some((d) => d.available);
|
|
221
|
+
return {
|
|
222
|
+
kind,
|
|
223
|
+
selected: ids.join(", "),
|
|
224
|
+
implicit: true,
|
|
225
|
+
available,
|
|
226
|
+
reason: available
|
|
227
|
+
? undefined
|
|
228
|
+
: found
|
|
229
|
+
.map((d) => d.reason)
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.join("; ") || undefined,
|
|
232
|
+
problem: false,
|
|
233
|
+
warnings: [],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The 1password row: shown only when 1Password is the configured provider
|
|
239
|
+
* (secret_env refs and the engine's own secrets resolve through it) and the
|
|
240
|
+
* configured token source yields no token. A warning, not a failure: a
|
|
241
|
+
* signed-in desktop app needs none. Checks the source without reading a
|
|
242
|
+
* keychain secret, so nothing prompts.
|
|
243
|
+
*/
|
|
244
|
+
function onePasswordTokenRow(env: NodeJS.ProcessEnv): DoctorRow | null {
|
|
245
|
+
try {
|
|
246
|
+
const cfg = readSecretsConfig();
|
|
247
|
+
if (resolveProviderName(cfg, env) !== "1password") return null;
|
|
248
|
+
const src = parseOpTokenSource(cfg.onepassword?.token);
|
|
249
|
+
const why = opTokenSourceProblem(src, { env });
|
|
250
|
+
if (!why) return null;
|
|
251
|
+
return {
|
|
252
|
+
kind: "1password",
|
|
253
|
+
selected: describeOpTokenSource(src),
|
|
254
|
+
implicit: false,
|
|
255
|
+
available: false,
|
|
256
|
+
reason:
|
|
257
|
+
"this process has no service-account token. Set secrets.onepassword.token.",
|
|
258
|
+
problem: false,
|
|
259
|
+
warnings: [why],
|
|
260
|
+
};
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
187
264
|
}
|
|
188
265
|
|
|
189
266
|
export function formatDoctor(rows: DoctorRow[], title: string): string[] {
|
|
190
267
|
const lines = [chalk.bold(`${title} doctor: strategies`), ""];
|
|
191
268
|
const width = Math.max(...rows.map((r) => r.kind.length));
|
|
192
|
-
|
|
193
|
-
|
|
269
|
+
const selOf = (r: DoctorRow) =>
|
|
270
|
+
r.selected
|
|
194
271
|
? `${r.selected}${r.implicit ? " (implicit)" : ""}`
|
|
195
272
|
: "(invalid config)";
|
|
273
|
+
// The state column lines up even past 20 ("den-claude (implicit)").
|
|
274
|
+
const selWidth = Math.max(20, ...rows.map((r) => selOf(r).length));
|
|
275
|
+
for (const r of rows) {
|
|
276
|
+
const sel = selOf(r);
|
|
196
277
|
const state = r.available
|
|
197
278
|
? chalk.green("✓ available")
|
|
198
279
|
: (r.problem ? chalk.red : chalk.yellow)("✗ unavailable");
|
|
199
280
|
lines.push(
|
|
200
|
-
` ${r.kind.padEnd(width)} ${sel.padEnd(
|
|
281
|
+
` ${r.kind.padEnd(width)} ${sel.padEnd(selWidth)} ${state}${r.reason ? `: ${r.reason}` : ""}`,
|
|
201
282
|
);
|
|
202
283
|
for (const w of r.warnings) lines.push(chalk.yellow(` ⚠ ${w}`));
|
|
203
284
|
}
|
|
@@ -295,10 +376,13 @@ export function strategyCheck(r: DoctorRow): OnboardCheck {
|
|
|
295
376
|
export async function doctorJson(
|
|
296
377
|
deps: OnboardDeps,
|
|
297
378
|
rows: DoctorRow[] = doctorRows(),
|
|
379
|
+
owner: () => Promise<OnboardCheck> = () => ownerCheck(),
|
|
298
380
|
): Promise<{ ok: boolean; checks: OnboardCheck[] }> {
|
|
299
381
|
const checks = [
|
|
300
382
|
...(await onboardingChecks(deps)),
|
|
301
383
|
...rows.map(strategyCheck),
|
|
384
|
+
// D17: who can connect GitHub to this instance from the browser.
|
|
385
|
+
await owner(),
|
|
302
386
|
];
|
|
303
387
|
return { ok: !checks.some((c) => c.status === "fail"), checks };
|
|
304
388
|
}
|
|
@@ -338,6 +422,11 @@ export async function cmdDoctor(args: string[]): Promise<number> {
|
|
|
338
422
|
for (const l of formatDoctor(rows, readBrand().name)) console.log(l);
|
|
339
423
|
const held = approvalsLines(() => readMcpConfig().approvals);
|
|
340
424
|
for (const l of held.lines) console.log(l);
|
|
425
|
+
// D17: who can connect GitHub to this instance from the browser.
|
|
426
|
+
const owner = await ownerCheck();
|
|
427
|
+
console.log("");
|
|
428
|
+
console.log(ownerLine(owner));
|
|
429
|
+
const ownerProblem = owner.status === "fail";
|
|
341
430
|
for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
|
|
342
431
|
const cli = product().cli;
|
|
343
432
|
if (!probe) {
|
|
@@ -346,7 +435,7 @@ export async function cmdDoctor(args: string[]): Promise<number> {
|
|
|
346
435
|
agentsConfigured(readConfigOrNull(), getStateDir()),
|
|
347
436
|
))
|
|
348
437
|
console.log(l);
|
|
349
|
-
return rows.some((r) => r.problem) || held.problem ? 1 : 0;
|
|
438
|
+
return rows.some((r) => r.problem) || held.problem || ownerProblem ? 1 : 0;
|
|
350
439
|
}
|
|
351
440
|
const { runEnvProbe, formatProbe } = await import("./env-probe.js");
|
|
352
441
|
const { resolveDeps, plainSessionEnv } =
|
|
@@ -365,5 +454,7 @@ export async function cmdDoctor(args: string[]): Promise<number> {
|
|
|
365
454
|
console.log("");
|
|
366
455
|
for (const l of formatProbe(results)) console.log(l);
|
|
367
456
|
const found = results.some((r) => r.findings.length);
|
|
368
|
-
return rows.some((r) => r.problem) || held.problem ||
|
|
457
|
+
return rows.some((r) => r.problem) || held.problem || ownerProblem || found
|
|
458
|
+
? 1
|
|
459
|
+
: 0;
|
|
369
460
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doctor's owner check (D17): who can connect their GitHub account to this
|
|
3
|
+
* instance from the browser.
|
|
4
|
+
*
|
|
5
|
+
* - An owner is linked: ok, naming it.
|
|
6
|
+
* - No owner, but web.owner_github or web.allowed_github_org says who may
|
|
7
|
+
* claim it: ok, naming them.
|
|
8
|
+
* - Neither, and the edge is reachable from other machines: ✗ (a fail):
|
|
9
|
+
* nobody can get in from a browser there, and the page can only name
|
|
10
|
+
* the settings.
|
|
11
|
+
* - Neither, edge not reachable beyond this machine: a warning.
|
|
12
|
+
*/
|
|
13
|
+
import { readWebOwner } from "../../shared/web-owner.js";
|
|
14
|
+
import {
|
|
15
|
+
readWebAuthConfig,
|
|
16
|
+
type WebAuthConfig,
|
|
17
|
+
} from "../../shared/web-session.js";
|
|
18
|
+
import { getDistRoot, getStateDir } from "../../shared/paths.js";
|
|
19
|
+
import { product } from "../../shared/product.js";
|
|
20
|
+
import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
|
|
21
|
+
import { edgeNames, webRouteHosts } from "../../shared/edge/render.js";
|
|
22
|
+
import { edgeExposure } from "../../shared/edge/exposure.js";
|
|
23
|
+
import type { OnboardCheck } from "./doctor-onboard.js";
|
|
24
|
+
import { ghLogin, ghNotSignedInHint } from "../../shared/owner-github.js";
|
|
25
|
+
|
|
26
|
+
export interface OwnerCheckDeps {
|
|
27
|
+
owner?: () => { login: string } | null;
|
|
28
|
+
web?: () => WebAuthConfig;
|
|
29
|
+
/** Why the edge is reachable beyond this machine, or null. */
|
|
30
|
+
exposed?: () => Promise<string | null>;
|
|
31
|
+
configFile?: string;
|
|
32
|
+
/** The login gh is signed in as here, or null (default: ask gh). */
|
|
33
|
+
ghLogin?: () => string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What to do when web.owner_github isn't set: gh is signed in but nothing
|
|
38
|
+
* recorded it yet (a restart does), or gh isn't signed in.
|
|
39
|
+
*/
|
|
40
|
+
export function ownerGithubHint(login: string | null, cli: string): string {
|
|
41
|
+
return login
|
|
42
|
+
? `gh is signed in as @${login}: run \`${cli} web restart\` to record it as this Den's owner (web.owner_github), then connect your GitHub account from any browser.`
|
|
43
|
+
: ghNotSignedInHint(cli);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function defaultExposed(): Promise<string | null> {
|
|
47
|
+
try {
|
|
48
|
+
const cfg = readEdgeConfig();
|
|
49
|
+
const hosts = webRouteHosts(cfg, new Set([readWebPort()])).map(
|
|
50
|
+
(r) => r.host,
|
|
51
|
+
);
|
|
52
|
+
return await edgeExposure(
|
|
53
|
+
hosts,
|
|
54
|
+
cfg.ports ? edgeNames(cfg.org).container : null,
|
|
55
|
+
);
|
|
56
|
+
} catch {
|
|
57
|
+
return null; // no edge
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function ownerCheck(
|
|
62
|
+
deps: OwnerCheckDeps = {},
|
|
63
|
+
): Promise<OnboardCheck> {
|
|
64
|
+
const owner = (deps.owner ?? (() => readWebOwner(getStateDir())))();
|
|
65
|
+
if (owner)
|
|
66
|
+
return {
|
|
67
|
+
id: "web:owner",
|
|
68
|
+
status: "ok",
|
|
69
|
+
detail: `owner: @${owner.login} (linked; Sign in with GitHub from any browser)`,
|
|
70
|
+
};
|
|
71
|
+
let web: WebAuthConfig;
|
|
72
|
+
try {
|
|
73
|
+
web = (
|
|
74
|
+
deps.web ?? (() => readWebAuthConfig(getDistRoot(), product().configFile))
|
|
75
|
+
)();
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return { id: "web:owner", status: "fail", detail: (e as Error).message };
|
|
78
|
+
}
|
|
79
|
+
const configFile = deps.configFile ?? product().configFile;
|
|
80
|
+
const who = [
|
|
81
|
+
...(web.ownerGithub
|
|
82
|
+
? [`@${web.ownerGithub} from any browser (web.owner_github)`]
|
|
83
|
+
: []),
|
|
84
|
+
...(web.allowedGithubOrg
|
|
85
|
+
? [
|
|
86
|
+
`a member of ${web.allowedGithubOrg} on this computer (web.allowed_github_org)`,
|
|
87
|
+
]
|
|
88
|
+
: []),
|
|
89
|
+
];
|
|
90
|
+
// D17: owner_github carries most people (org memberships are usually
|
|
91
|
+
// private); without it, say how to get it.
|
|
92
|
+
const ghHint = web.ownerGithub
|
|
93
|
+
? {}
|
|
94
|
+
: { ask: ownerGithubHint((deps.ghLogin ?? ghLogin)(), product().cli) };
|
|
95
|
+
if (who.length)
|
|
96
|
+
return {
|
|
97
|
+
id: "web:owner",
|
|
98
|
+
status: "ok",
|
|
99
|
+
detail: `no owner yet; who may connect GitHub to claim it: ${who.join(", or ")}`,
|
|
100
|
+
...ghHint,
|
|
101
|
+
};
|
|
102
|
+
const exposed = await (deps.exposed ?? defaultExposed)();
|
|
103
|
+
// Only this machine reaches it: a note, not a warning (every fresh
|
|
104
|
+
// install starts this way, D12).
|
|
105
|
+
return {
|
|
106
|
+
id: "web:owner",
|
|
107
|
+
status: exposed ? "fail" : "ok",
|
|
108
|
+
detail:
|
|
109
|
+
`no owner, and nothing says who may claim it, so nobody can connect GitHub from the browser` +
|
|
110
|
+
(exposed
|
|
111
|
+
? `; the edge is reachable from other machines (${exposed})`
|
|
112
|
+
: ""),
|
|
113
|
+
ask: `Which GitHub org's members may claim it (web.allowed_github_org in ${configFile}), or which GitHub login runs it here (web.owner_github in config/local/user.yml)?`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The check as doctor prints it. */
|
|
118
|
+
export function ownerLine(c: OnboardCheck): string {
|
|
119
|
+
const mark =
|
|
120
|
+
c.status === "fail" ? "✗" : c.status === "warn" ? "⚠" : c.ask ? "ℹ" : "✓";
|
|
121
|
+
return ` ${mark} ${c.detail}${c.ask ? `\n ${c.ask}` : ""}`;
|
|
122
|
+
}
|
|
@@ -15,6 +15,11 @@
|
|
|
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
17
|
*/
|
|
18
|
+
import {
|
|
19
|
+
ownerGithubLine,
|
|
20
|
+
recordOwnerGithub,
|
|
21
|
+
type OwnerGithubResult,
|
|
22
|
+
} from "../../shared/owner-github.js";
|
|
18
23
|
import { spawnSync } from "child_process";
|
|
19
24
|
import * as os from "os";
|
|
20
25
|
import * as path from "path";
|
|
@@ -100,6 +105,8 @@ export async function waitForServerInfo(
|
|
|
100
105
|
|
|
101
106
|
export interface ServiceDeps {
|
|
102
107
|
run?: ServiceRunner;
|
|
108
|
+
/** D17: record web.owner_github (default: from gh, unless `run` is a test's). */
|
|
109
|
+
recordOwner?: () => OwnerGithubResult;
|
|
103
110
|
config?: DenConfig;
|
|
104
111
|
distRoot?: string;
|
|
105
112
|
engineRoot?: string;
|
|
@@ -214,6 +221,15 @@ export async function cmdService(
|
|
|
214
221
|
return 0;
|
|
215
222
|
}
|
|
216
223
|
|
|
224
|
+
// D17: whoever installs it can connect their GitHub account from any
|
|
225
|
+
// browser (web.owner_github, from gh), before the server reads it.
|
|
226
|
+
if (sub === "install") {
|
|
227
|
+
const rec =
|
|
228
|
+
deps.recordOwner ??
|
|
229
|
+
(deps.run ? () => ({ state: "linked" as const }) : recordOwnerGithub);
|
|
230
|
+
const line = ownerGithubLine(rec(), product().cli);
|
|
231
|
+
if (line) log(line);
|
|
232
|
+
}
|
|
217
233
|
const code = run("bash", [script, sub!], {
|
|
218
234
|
...env,
|
|
219
235
|
// The script then leaves saying "running" to us (after the poll below).
|