@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.
- package/CHANGELOG.md +96 -0
- package/README.md +7 -7
- package/docs/distributions.md +72 -7
- 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/web-session.ts +112 -6
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +54 -15
- 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 +143 -12
- 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 +10 -1
- package/src/ui/server/public/i18n/ja.js +10 -1
- package/src/ui/server/public/settings-registry.js +1 -1
- package/src/ui/server/public/shell.js +10 -1
- package/src/ui/server/route-classes.ts +7 -0
package/src/shared/product.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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();
|
package/src/shared/profile.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 +
|
|
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
|
|
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 >
|
|
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,
|
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)}`);
|
|
@@ -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
|
-
|
|
245
|
-
|
|
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(
|
|
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 ||
|
|
457
|
+
return rows.some((r) => r.problem) || held.problem || ownerProblem || found
|
|
458
|
+
? 1
|
|
459
|
+
: 0;
|
|
421
460
|
}
|