@monster-orange/den 0.4.20 → 0.4.22

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 (46) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +167 -19
  4. package/package.json +1 -1
  5. package/service/install.sh +213 -63
  6. package/src/shared/agents/den-claude.ts +13 -1
  7. package/src/shared/agents/records.ts +6 -0
  8. package/src/shared/agents/runtime.ts +6 -0
  9. package/src/shared/agents/session-name.ts +21 -1
  10. package/src/shared/attest-code.ts +125 -0
  11. package/src/shared/claim-code.ts +6 -2
  12. package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
  13. package/src/shared/github-app.ts +4 -0
  14. package/src/shared/github-device.ts +38 -1
  15. package/src/shared/launchd-session.ts +92 -0
  16. package/src/shared/owner-github.ts +100 -0
  17. package/src/shared/product.ts +45 -5
  18. package/src/shared/profile.ts +26 -2
  19. package/src/shared/sessions/client.ts +24 -0
  20. package/src/shared/sessions/host.ts +114 -5
  21. package/src/shared/sessions/types.ts +5 -0
  22. package/src/shared/web-session.ts +112 -6
  23. package/src/ui/cli/agent-ops.ts +20 -2
  24. package/src/ui/cli/agents-command.ts +32 -1
  25. package/src/ui/cli/create.ts +92 -6
  26. package/src/ui/cli/doctor-command.ts +126 -15
  27. package/src/ui/cli/doctor-owner.ts +122 -0
  28. package/src/ui/cli/doctor-where.ts +203 -0
  29. package/src/ui/cli/index.ts +1 -1
  30. package/src/ui/cli/service-command.ts +233 -11
  31. package/src/ui/cli/sessions-command.ts +46 -12
  32. package/src/ui/cli/web-command.ts +78 -12
  33. package/src/ui/server/device-flows.ts +45 -10
  34. package/src/ui/server/github-routes.ts +169 -16
  35. package/src/ui/server/index.ts +147 -12
  36. package/src/ui/server/public/agents-page.js +103 -6
  37. package/src/ui/server/public/agents.html +30 -0
  38. package/src/ui/server/public/error-page.js +115 -33
  39. package/src/ui/server/public/error.html +16 -0
  40. package/src/ui/server/public/header.js +5 -7
  41. package/src/ui/server/public/i18n/es.js +24 -1
  42. package/src/ui/server/public/i18n/ja.js +24 -1
  43. package/src/ui/server/public/settings-registry.js +1 -1
  44. package/src/ui/server/public/shell.js +24 -1
  45. package/src/ui/server/route-classes.ts +7 -0
  46. package/src/ui/server/session-routes.ts +4 -0
@@ -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,
@@ -71,7 +71,10 @@ import {
71
71
  type CatalogEntry,
72
72
  } from "../../shared/agents/catalog.js";
73
73
  import { checkRuntimeBin } from "../../shared/agents/catalog-runtime.js";
74
- import { agentSessionName } from "../../shared/agents/session-name.js";
74
+ import {
75
+ agentSessionName,
76
+ peerSessionName,
77
+ } from "../../shared/agents/session-name.js";
75
78
  import { cliEntrypoint } from "../../shared/engine.js";
76
79
  import { getDistRoot } from "../../shared/paths.js";
77
80
  import { envKey } from "../../shared/product.js";
@@ -1637,11 +1640,17 @@ export async function startAgent(
1637
1640
  );
1638
1641
  ptyOrRefuse(d);
1639
1642
  const allow = allowedFor(d, persona, ch.extras);
1643
+ // D24: the name peers see it by, from the name it will have (below).
1644
+ const peerName = peerSessionName(d.cli, ws, {
1645
+ id,
1646
+ displayName: given ?? prev?.displayName ?? persona.displayName,
1647
+ });
1640
1648
  const input = {
1641
1649
  ...launchInput(d, cfg, persona, ws, id, model, det.path, undefined, {
1642
1650
  ...(ch.permission ? { permission: ch.permission } : {}),
1643
1651
  ...(rt.permissions ? { allow } : {}),
1644
1652
  }),
1653
+ sessionLabel: peerName,
1645
1654
  ...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
1646
1655
  // 0.4.18: Start agent here: in a folder of the workspace's.
1647
1656
  ...(req.folder !== undefined && !more.bringIn
@@ -1699,6 +1708,7 @@ export async function startAgent(
1699
1708
  workspace: ws,
1700
1709
  cwd: input.worktree,
1701
1710
  sessionName: sessionName(d.dist, ws, id),
1711
+ peerName,
1702
1712
  // AG-4b: a name given to the agent is the agent's, not its
1703
1713
  // conversation's: Start fresh and Relaunch keep it. AG-10.8:
1704
1714
  // else its persona's name ("Rita").
@@ -2175,8 +2185,11 @@ export async function resumeAgent(
2175
2185
  },
2176
2186
  );
2177
2187
  // Resume in the cwd the agent had, wherever the workspace points now.
2188
+ // D24 (and D25: a rename since) the name peers see it by.
2189
+ const peerName = peerSessionName(d.cli, rec.workspace, rec);
2178
2190
  const at = {
2179
2191
  ...input,
2192
+ sessionLabel: peerName,
2180
2193
  worktree: rec.cwd,
2181
2194
  ...(rec.resumeToken ? { resumeToken: rec.resumeToken } : {}),
2182
2195
  };
@@ -2211,7 +2224,12 @@ export async function resumeAgent(
2211
2224
  d,
2212
2225
  c,
2213
2226
  {
2214
- rec: { ...base, cwd: rec.cwd, ...(token ? { launched: true } : {}) },
2227
+ rec: {
2228
+ ...base,
2229
+ cwd: rec.cwd,
2230
+ peerName,
2231
+ ...(token ? { launched: true } : {}),
2232
+ },
2215
2233
  argv: out.argv,
2216
2234
  env: out.env,
2217
2235
  passthrough: rt.envPassthrough,
@@ -87,6 +87,7 @@ import {
87
87
  relaunchAgent,
88
88
  removeAgent,
89
89
  removeStopped,
90
+ renameAgent,
90
91
  resolveAgentsDeps,
91
92
  resumeAgent,
92
93
  resumeInterrupted,
@@ -99,6 +100,7 @@ import {
99
100
  type RuntimeChoice,
100
101
  type Started,
101
102
  } from "./agent-ops.js";
103
+ import { peerSessionName } from "../../shared/agents/session-name.js";
102
104
  import {
103
105
  columns,
104
106
  guarded,
@@ -119,7 +121,7 @@ export {
119
121
 
120
122
  export const AGENTS_HELP = {
121
123
  usage:
122
- "agents <list|start|stop|resume|relaunch|import|persona|rm|personas|doctor|handback-check|handoff-check> ...",
124
+ "agents <list|start|stop|resume|relaunch|import|persona|rename|rm|personas|doctor|handback-check|handoff-check> ...",
123
125
  lines: [
124
126
  "Agents: a persona and a workspace, run by a runtime in a session that",
125
127
  "outlives the web server (see `{cli} sessions`).",
@@ -156,6 +158,11 @@ export const AGENTS_HELP = {
156
158
  " persona's role, permissions and commands; id, name, folder and model stay.",
157
159
  " Mid-turn it asks: --after-turn waits for the turn to end, --now stops it.",
158
160
  " A person's only (refused in an agent's session)",
161
+ "{cli} agents rename <id> <name> [-w <ws>]",
162
+ "{cli} agents rename <id> --clear [-w <ws>]",
163
+ " name an agent (its tab, row and pane header); the id never changes.",
164
+ " Other sessions see it as {cli}-<ws>:<name> from its next start or resume.",
165
+ " --clear: back to its id",
159
166
  "{cli} agents rm <id> [-w <ws>]",
160
167
  "{cli} agents rm --stopped [-w <ws>]",
161
168
  " forget a stopped, interrupted or exited agent (never a running one);",
@@ -513,6 +520,28 @@ async function resumeAll(args: string[], d: AgentsResolved): Promise<number> {
513
520
  }
514
521
 
515
522
  /** `agents rm <id> [-w <ws>]`, `agents rm --stopped [-w <ws>]` (AG-14.1b). */
523
+ /** D25: `agents rename <id> <name>|--clear [-w <ws>]`. */
524
+ function rename(args: string[], d: AgentsResolved): number {
525
+ const where = "agents rename";
526
+ const p = parseArgs(where, args, { "--workspace": true, "--clear": false });
527
+ const [id, ...words] = p.positional;
528
+ if (!id) throw new UsageError(`${where} needs <id> and a name, or --clear`);
529
+ const name = words.join(" ").trim();
530
+ if (p.flags["--clear"] && name)
531
+ throw new UsageError(`${where}: give a name or --clear, not both`);
532
+ if (!p.flags["--clear"] && !name)
533
+ throw new UsageError(`${where} needs a name (or --clear)`);
534
+ const ws =
535
+ workspaceFlag(where, p) ?? readRecord(d.stateDir(), id)?.workspace ?? "";
536
+ const rec = renameAgent(d, id, ws, p.flags["--clear"] ? null : name);
537
+ d.out(
538
+ rec.displayName
539
+ ? `renamed agent ${id} to "${rec.displayName}"; others see it as ${peerSessionName(d.cli, rec.workspace, rec)} from its next start or resume`
540
+ : `agent ${id} has no name now; others see it as ${peerSessionName(d.cli, rec.workspace, rec)} from its next start or resume`,
541
+ );
542
+ return 0;
543
+ }
544
+
516
545
  async function rm(args: string[], d: AgentsResolved): Promise<number> {
517
546
  const where = "agents rm";
518
547
  const p = parseArgs(where, args, { "--workspace": true, "--stopped": false });
@@ -767,6 +796,8 @@ export async function cmdAgents(
767
796
  case "persona":
768
797
  await loadAgentWorkspaces(deps);
769
798
  return await persona(rest, d);
799
+ case "rename":
800
+ return rename(rest, d);
770
801
  case "rm":
771
802
  await loadAgentWorkspaces(deps);
772
803
  return await rm(rest, d);
@@ -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)}`);