@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +121 -13
  4. package/package.json +1 -1
  5. package/src/shared/attest-code.ts +125 -0
  6. package/src/shared/claim-code.ts +6 -2
  7. package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
  8. package/src/shared/github-app.ts +4 -0
  9. package/src/shared/github-device.ts +38 -1
  10. package/src/shared/owner-github.ts +100 -0
  11. package/src/shared/product.ts +45 -5
  12. package/src/shared/profile.ts +26 -2
  13. package/src/shared/secrets/index.ts +71 -3
  14. package/src/shared/secrets/onepassword.ts +87 -14
  15. package/src/shared/secrets/op-token.ts +270 -0
  16. package/src/shared/secrets/secret-env.ts +19 -6
  17. package/src/shared/secrets/strategies.ts +6 -0
  18. package/src/shared/secrets/types.ts +8 -0
  19. package/src/shared/web-session.ts +112 -6
  20. package/src/ui/cli/create.ts +92 -6
  21. package/src/ui/cli/doctor-command.ts +108 -17
  22. package/src/ui/cli/doctor-owner.ts +122 -0
  23. package/src/ui/cli/service-command.ts +16 -0
  24. package/src/ui/cli/web-command.ts +74 -12
  25. package/src/ui/server/device-flows.ts +45 -10
  26. package/src/ui/server/github-routes.ts +169 -16
  27. package/src/ui/server/index.ts +148 -13
  28. package/src/ui/server/public/error-page.js +115 -33
  29. package/src/ui/server/public/error.html +16 -0
  30. package/src/ui/server/public/header.js +5 -7
  31. package/src/ui/server/public/i18n/es.js +11 -1
  32. package/src/ui/server/public/i18n/ja.js +11 -1
  33. package/src/ui/server/public/settings-registry.js +23 -1
  34. package/src/ui/server/public/shell.js +11 -1
  35. package/src/ui/server/route-classes.ts +7 -0
@@ -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
- const written = writeDistribution(args.dir, { ...args, denVersion });
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
- next: next(args.dir),
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
- const st = secretsStatus(readSecretsConfig(), env);
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
- return ordered.map(
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
- for (const r of rows) {
193
- const sel = r.selected
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(20)} ${state}${r.reason ? `: ${r.reason}` : ""}`,
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 || found ? 1 : 0;
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).