@monster-orange/den 0.4.20 → 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.
@@ -8,6 +8,9 @@
8
8
  * distribution root can be found (e.g. help text printed outside a checkout).
9
9
  */
10
10
 
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { parse as parseYaml } from "yaml";
11
14
  import { getDistRoot } from "./paths.js";
12
15
  import {
13
16
  loadProfile,
@@ -17,7 +20,11 @@ import {
17
20
  } from "./profile.js";
18
21
 
19
22
  export interface Product extends ProductProfile {
20
- /** Display name: the name with its first letter upper-cased ("Den"). */
23
+ /**
24
+ * Display name (D11): the org config's brand.name ("Acme Den") when it is
25
+ * safe to show anywhere (TITLE_SAFE), else the name with its first letter
26
+ * upper-cased ("Acme-den").
27
+ */
21
28
  title: string;
22
29
  /** The YAML manifest file name (".den.yml"). */
23
30
  manifestYml: string;
@@ -25,11 +32,43 @@ export interface Product extends ProductProfile {
25
32
  manifestJson: string;
26
33
  }
27
34
 
28
- function withDerived(p: ProductProfile): Product {
35
+ /**
36
+ * What a brand.name may hold to be the display title: letters, digits,
37
+ * spaces, ".", "_" and "-", up to 40. The title is filled into page scripts
38
+ * (inside "…" and '…' strings) and HTML attributes, so no quote, backslash,
39
+ * "<" or "&" may reach it (the 0.4.20 hyphen bug's kin).
40
+ */
41
+ export const TITLE_SAFE = /^[\p{L}\p{N}][\p{L}\p{N} ._-]{0,39}$/u;
42
+
43
+ /** The name, first letter upper-cased: "acme-den" → "Acme-den". */
44
+ export function nameTitle(name: string): string {
45
+ return name.charAt(0).toUpperCase() + name.slice(1);
46
+ }
47
+
48
+ /** The org config's brand.name, trimmed, or undefined. Never throws. */
49
+ export function brandNameAt(root: string | null, configFile: string) {
50
+ if (!root) return undefined;
51
+ try {
52
+ const raw = parseYaml(
53
+ fs.readFileSync(path.join(root, configFile), "utf-8"),
54
+ ) as { brand?: { name?: unknown } } | null;
55
+ const n = raw?.brand?.name;
56
+ return typeof n === "string" && n.trim() ? n.trim() : undefined;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ /** The display title: a safe brand.name, else nameTitle. */
63
+ export function titleOf(name: string, brandName?: string): string {
64
+ return brandName && TITLE_SAFE.test(brandName) ? brandName : nameTitle(name);
65
+ }
66
+
67
+ function withDerived(p: ProductProfile, root: string | null = null): Product {
29
68
  const yml = p.manifestFiles[0];
30
69
  return {
31
70
  ...p,
32
- title: p.name.charAt(0).toUpperCase() + p.name.slice(1),
71
+ title: titleOf(p.name, brandNameAt(root, p.configFile)),
33
72
  manifestYml: yml,
34
73
  manifestJson: p.manifestFiles[1] ?? yml.replace(/\.ya?ml$/, "") + ".json",
35
74
  };
@@ -40,10 +79,11 @@ let derived: { profile: ProductProfile; product: Product } | null = null;
40
79
  /** The running product. Never throws. */
41
80
  export function product(): Product {
42
81
  try {
43
- const profile = loadProfile(getDistRoot());
82
+ const root = getDistRoot();
83
+ const profile = loadProfile(root);
44
84
  // Same profile object (loadProfile caches it): same derived names.
45
85
  if (derived?.profile !== profile)
46
- derived = { profile, product: withDerived(profile) };
86
+ derived = { profile, product: withDerived(profile, root) };
47
87
  return derived.product;
48
88
  } catch {
49
89
  const pinned = process.env.DEN_PROFILE?.trim();
@@ -58,6 +58,13 @@ interface RawProduct {
58
58
  }
59
59
 
60
60
  const SLUG = /^[a-z][a-z0-9-]*$/;
61
+ /**
62
+ * A path the pages show (config, manifest, services and workspaces dirs):
63
+ * letters, digits, ".", "_", "-" and "/". The web pages carry these
64
+ * names as placeholders filled when served (product.ts productTokens), so
65
+ * a quote, backslash, "$" or "<" in one would break a page's script.
66
+ */
67
+ const SAFE_PATH = /^[A-Za-z0-9._/-]+$/;
61
68
  const PREFIX = /^[A-Z][A-Z0-9_]*$/;
62
69
 
63
70
  const str = (v: unknown): string | undefined =>
@@ -83,7 +90,7 @@ export function profileFrom(
83
90
  const name = str(raw.name) ?? "den";
84
91
  if (!SLUG.test(name))
85
92
  throw new Error(
86
- `den.yml: product.name "${name}" must be lower-case letters, digits and "-"`,
93
+ `den.yml: product.name "${name}" must be lower-case letters, digits and "-", starting with a letter`,
87
94
  );
88
95
  const envPrefix =
89
96
  str(raw.env_prefix) ?? name.toUpperCase().replace(/-/g, "_");
@@ -91,11 +98,28 @@ export function profileFrom(
91
98
  throw new Error(
92
99
  `den.yml: product.env_prefix "${envPrefix}" must be upper-case letters, digits and "_"`,
93
100
  );
101
+ const cli = str(raw.cli) ?? name;
102
+ if (!SLUG.test(cli))
103
+ throw new Error(
104
+ `den.yml: product.cli "${cli}" must be lower-case letters, digits and "-", starting with a letter`,
105
+ );
106
+ for (const key of [
107
+ "config",
108
+ "manifest",
109
+ "services_dir",
110
+ "workspaces_dir",
111
+ ] as const) {
112
+ const v = str(raw[key]);
113
+ if (v !== undefined && !SAFE_PATH.test(v))
114
+ throw new Error(
115
+ `den.yml: product.${key} "${v}" may use only letters, digits, ".", "_", "-" and "/"`,
116
+ );
117
+ }
94
118
  const manifest = str(raw.manifest) ?? `.${name}.yml`;
95
119
  const json = manifest.replace(/\.ya?ml$/, ".json");
96
120
  return {
97
121
  name,
98
- cli: str(raw.cli) ?? name,
122
+ cli,
99
123
  envPrefix,
100
124
  configFile: str(raw.config) ?? `config/${name}.yml`,
101
125
  manifestFiles: json === manifest ? [manifest] : [manifest, json],
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * SEC-1b (SEC-1 spec §2): the browser's sign-in.
3
3
  *
4
- * - The login code: one-time, valid for 60 s, derived from web.secret.
4
+ * - The login code: one-time, valid for `web.login_link_ttl` (D16: 5
5
+ * minutes unless set; it was 60 s), derived from web.secret.
5
6
  * `v1.<nonce>.<expiry>.<mac>`, the mac HMAC-SHA256(secret, the rest). The
6
7
  * CLI mints it (it reads the secret as the same OS user); the server
7
8
  * checks the mac in constant time and the expiry, and records the nonce
@@ -24,7 +25,12 @@ import * as yaml from "yaml";
24
25
  import { parseMachineName, parseSshUser } from "./cli-command.js";
25
26
  import { getStateDir } from "./paths.js";
26
27
 
27
- export const LOGIN_CODE_TTL_MS = 60_000;
28
+ /** D16: how long a sign-in link lasts unless `web.login_link_ttl` says. */
29
+ export const LOGIN_CODE_TTL_MS = 5 * 60_000;
30
+ /** The longest `web.login_link_ttl` may be (and the most a code is believed). */
31
+ export const LOGIN_CODE_MAX_TTL_MS = 60 * 60_000;
32
+ /** The shortest `web.login_link_ttl` may be. */
33
+ export const LOGIN_CODE_MIN_TTL_MS = 30_000;
28
34
  /** Clock skew allowed between the CLI that mints a code and the server. */
29
35
  const LOGIN_CODE_SKEW_MS = 5_000;
30
36
  const LOGIN_CONTEXT = "den-login-v1:";
@@ -57,13 +63,14 @@ function mac(secret: Buffer, body: string): Buffer {
57
63
  .digest();
58
64
  }
59
65
 
60
- /** A fresh login code, valid for 60 s from `now`. */
66
+ /** A fresh login code, valid for `ttlMs` (web.login_link_ttl) from `now`. */
61
67
  export function mintLoginCode(
62
68
  secret: Buffer,
63
69
  now: number = Date.now(),
70
+ ttlMs: number = LOGIN_CODE_TTL_MS,
64
71
  ): string {
65
72
  const nonce = randomBytes(16).toString("base64url");
66
- const body = `v1.${nonce}.${(now + LOGIN_CODE_TTL_MS).toString(36)}`;
73
+ const body = `v1.${nonce}.${(now + ttlMs).toString(36)}`;
67
74
  return `${body}.${mac(secret, body).toString("base64url")}`;
68
75
  }
69
76
 
@@ -72,7 +79,8 @@ export type LoginCodeCheck =
72
79
  | { ok: false; reason: "malformed" | "bad_mac" | "expired" };
73
80
 
74
81
  /**
75
- * Whether `code` is one this secret minted and still in its 60 s. The mac is
82
+ * Whether `code` is one this secret minted and still in its time (at most
83
+ * LOGIN_CODE_MAX_TTL_MS: web.login_link_ttl's ceiling). The mac is
76
84
  * compared in constant time. Single use is the caller's (consumeCode).
77
85
  */
78
86
  export function checkLoginCode(
@@ -93,7 +101,7 @@ export function checkLoginCode(
93
101
  if (
94
102
  !Number.isFinite(expiresAt) ||
95
103
  now >= expiresAt ||
96
- expiresAt - now > LOGIN_CODE_TTL_MS + LOGIN_CODE_SKEW_MS
104
+ expiresAt - now > LOGIN_CODE_MAX_TTL_MS + LOGIN_CODE_SKEW_MS
97
105
  )
98
106
  return { ok: false, reason: "expired" };
99
107
  return { ok: true, nonce: m[2], expiresAt };
@@ -201,6 +209,77 @@ export interface WebAuthConfig {
201
209
  * Den runs as. "": no account on the page.
202
210
  */
203
211
  sshUser?: string;
212
+ /**
213
+ * `web.owner_github` (D17, user.yml only): the GitHub login of the person
214
+ * who runs this instance, recorded from `gh` at `web up` or `service
215
+ * install`. With no owner linked, that login's GitHub sign-in, from any
216
+ * machine, becomes the owner.
217
+ */
218
+ ownerGithub?: string;
219
+ /**
220
+ * `web.allowed_github_org` (D17; the distribution config, or user.yml):
221
+ * with no owner linked, a member of this GitHub org who proves they are
222
+ * at this machine ("This is my computer") becomes the owner.
223
+ */
224
+ allowedGithubOrg?: string;
225
+ /** The file `web.allowed_github_org` came from. */
226
+ allowedGithubOrgFile?: string;
227
+ /**
228
+ * `web.login_link_ttl` (D16; user.yml over the distribution config): how
229
+ * long a sign-in link lasts, in ms. Unset: LOGIN_CODE_TTL_MS (5 minutes).
230
+ */
231
+ loginLinkTtlMs: number;
232
+ }
233
+
234
+ /** A GitHub login (or org name): letters, digits, single dashes, ≤ 39. */
235
+ const GITHUB_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
236
+
237
+ /** `web.owner_github` or `web.allowed_github_org`: a GitHub name, or unset. */
238
+ export function parseGithubName(
239
+ v: unknown,
240
+ key: string,
241
+ file: string,
242
+ ): string | undefined {
243
+ if (v === undefined || v === null) return undefined;
244
+ const s = typeof v === "string" ? v.trim().replace(/^@/, "") : "";
245
+ if (s === "" && typeof v === "string") return undefined;
246
+ if (!GITHUB_NAME.test(s))
247
+ throw new Error(
248
+ `${key} (${file}) must be a GitHub name (letters, digits and dashes), got ${JSON.stringify(v)}`,
249
+ );
250
+ return s;
251
+ }
252
+
253
+ /**
254
+ * `web.login_link_ttl`: `90s`, `5m`, `1h`, or a number of seconds, between
255
+ * 30 s and 60 min. Unset: undefined. Throws naming the key otherwise.
256
+ */
257
+ export function parseLoginLinkTtl(
258
+ v: unknown,
259
+ file: string,
260
+ ): number | undefined {
261
+ if (v === undefined || v === null) return undefined;
262
+ const m =
263
+ typeof v === "number"
264
+ ? [String(v), String(v), "s"]
265
+ : typeof v === "string"
266
+ ? /^\s*(\d+)\s*(s|m|h)?\s*$/i.exec(v)
267
+ : null;
268
+ const ms = m
269
+ ? Number(m[1]) *
270
+ ({ s: 1000, m: 60_000, h: 3_600_000 } as Record<string, number>)[
271
+ (m[2] ?? "s").toLowerCase()
272
+ ]
273
+ : NaN;
274
+ if (
275
+ !Number.isFinite(ms) ||
276
+ ms < LOGIN_CODE_MIN_TTL_MS ||
277
+ ms > LOGIN_CODE_MAX_TTL_MS
278
+ )
279
+ throw new Error(
280
+ `web.login_link_ttl (${file}) must be a time between 30s and 60m (such as 5m), got ${JSON.stringify(v)}`,
281
+ );
282
+ return ms;
204
283
  }
205
284
 
206
285
  /** `web.edge_unattested`'s values. */
@@ -300,7 +379,34 @@ export function readWebAuthConfig(
300
379
  user.ssh_user !== undefined
301
380
  ? parseSshUser(user.ssh_user)
302
381
  : parseSshUser(team.ssh_user);
382
+ const ownerGithub = parseGithubName(
383
+ user.owner_github,
384
+ "web.owner_github",
385
+ "config/local/user.yml",
386
+ );
387
+ const userOrg = parseGithubName(
388
+ user.allowed_github_org,
389
+ "web.allowed_github_org",
390
+ "config/local/user.yml",
391
+ );
392
+ const teamOrg = parseGithubName(
393
+ team.allowed_github_org,
394
+ "web.allowed_github_org",
395
+ configFile,
396
+ );
397
+ const allowedGithubOrg = userOrg ?? teamOrg;
398
+ const ttl =
399
+ parseLoginLinkTtl(user.login_link_ttl, "config/local/user.yml") ??
400
+ parseLoginLinkTtl(team.login_link_ttl, configFile);
303
401
  return {
402
+ ...(ownerGithub ? { ownerGithub } : {}),
403
+ ...(allowedGithubOrg
404
+ ? {
405
+ allowedGithubOrg,
406
+ allowedGithubOrgFile: userOrg ? "config/local/user.yml" : configFile,
407
+ }
408
+ : {}),
409
+ loginLinkTtlMs: ttl ?? LOGIN_CODE_TTL_MS,
304
410
  ...(machineName ? { machineName } : {}),
305
411
  ...(sshUser !== undefined ? { sshUser } : {}),
306
412
  allowPlainHttpRemote: plain === true,
@@ -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)}`);
@@ -51,6 +51,7 @@ import {
51
51
  } from "../../shared/sessions/den-pty.js";
52
52
  import {
53
53
  getStrategy,
54
+ strategyIds,
54
55
  strategyKinds,
55
56
  } from "../../shared/strategies/registry.js";
56
57
  import {
@@ -65,6 +66,7 @@ import {
65
66
  type OnboardDeps,
66
67
  } from "./doctor-onboard.js";
67
68
  import { printJson } from "./json-out.js";
69
+ import { ownerCheck, ownerLine } from "./doctor-owner.js";
68
70
 
69
71
  export interface DoctorRow {
70
72
  kind: string;
@@ -193,22 +195,45 @@ export function doctorRows(env: NodeJS.ProcessEnv = process.env): DoctorRow[] {
193
195
  ...kinds.filter((k) => !(k in SELECTORS)),
194
196
  ];
195
197
  const rows = ordered.map(
196
- (kind) =>
197
- SELECTORS[kind]?.(env) ?? {
198
- kind,
199
- selected: "",
200
- implicit: false,
201
- available: false,
202
- reason: "no selector for this kind",
203
- problem: false,
204
- warnings: [],
205
- },
198
+ (kind) => SELECTORS[kind]?.(env) ?? everyStrategyRow(kind, env),
206
199
  );
207
200
  const op = onePasswordTokenRow(env);
208
201
  if (op) rows.splice(rows.findIndex((r) => r.kind === "secrets") + 1, 0, op);
209
202
  return rows;
210
203
  }
211
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
+
212
237
  /**
213
238
  * The 1password row: shown only when 1Password is the configured provider
214
239
  * (secret_env refs and the engine's own secrets resolve through it) and the
@@ -241,15 +266,19 @@ function onePasswordTokenRow(env: NodeJS.ProcessEnv): DoctorRow | null {
241
266
  export function formatDoctor(rows: DoctorRow[], title: string): string[] {
242
267
  const lines = [chalk.bold(`${title} doctor: strategies`), ""];
243
268
  const width = Math.max(...rows.map((r) => r.kind.length));
244
- for (const r of rows) {
245
- const sel = r.selected
269
+ const selOf = (r: DoctorRow) =>
270
+ r.selected
246
271
  ? `${r.selected}${r.implicit ? " (implicit)" : ""}`
247
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);
248
277
  const state = r.available
249
278
  ? chalk.green("✓ available")
250
279
  : (r.problem ? chalk.red : chalk.yellow)("✗ unavailable");
251
280
  lines.push(
252
- ` ${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}` : ""}`,
253
282
  );
254
283
  for (const w of r.warnings) lines.push(chalk.yellow(` ⚠ ${w}`));
255
284
  }
@@ -347,10 +376,13 @@ export function strategyCheck(r: DoctorRow): OnboardCheck {
347
376
  export async function doctorJson(
348
377
  deps: OnboardDeps,
349
378
  rows: DoctorRow[] = doctorRows(),
379
+ owner: () => Promise<OnboardCheck> = () => ownerCheck(),
350
380
  ): Promise<{ ok: boolean; checks: OnboardCheck[] }> {
351
381
  const checks = [
352
382
  ...(await onboardingChecks(deps)),
353
383
  ...rows.map(strategyCheck),
384
+ // D17: who can connect GitHub to this instance from the browser.
385
+ await owner(),
354
386
  ];
355
387
  return { ok: !checks.some((c) => c.status === "fail"), checks };
356
388
  }
@@ -390,6 +422,11 @@ export async function cmdDoctor(args: string[]): Promise<number> {
390
422
  for (const l of formatDoctor(rows, readBrand().name)) console.log(l);
391
423
  const held = approvalsLines(() => readMcpConfig().approvals);
392
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";
393
430
  for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
394
431
  const cli = product().cli;
395
432
  if (!probe) {
@@ -398,7 +435,7 @@ export async function cmdDoctor(args: string[]): Promise<number> {
398
435
  agentsConfigured(readConfigOrNull(), getStateDir()),
399
436
  ))
400
437
  console.log(l);
401
- return rows.some((r) => r.problem) || held.problem ? 1 : 0;
438
+ return rows.some((r) => r.problem) || held.problem || ownerProblem ? 1 : 0;
402
439
  }
403
440
  const { runEnvProbe, formatProbe } = await import("./env-probe.js");
404
441
  const { resolveDeps, plainSessionEnv } =
@@ -417,5 +454,7 @@ export async function cmdDoctor(args: string[]): Promise<number> {
417
454
  console.log("");
418
455
  for (const l of formatProbe(results)) console.log(l);
419
456
  const found = results.some((r) => r.findings.length);
420
- 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;
421
460
  }