@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.
- package/CHANGELOG.md +164 -0
- package/README.md +7 -7
- package/docs/distributions.md +167 -19
- package/package.json +1 -1
- package/service/install.sh +213 -63
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/records.ts +6 -0
- package/src/shared/agents/runtime.ts +6 -0
- package/src/shared/agents/session-name.ts +21 -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/launchd-session.ts +92 -0
- 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/sessions/client.ts +24 -0
- package/src/shared/sessions/host.ts +114 -5
- package/src/shared/sessions/types.ts +5 -0
- package/src/shared/web-session.ts +112 -6
- package/src/ui/cli/agent-ops.ts +20 -2
- package/src/ui/cli/agents-command.ts +32 -1
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +126 -15
- package/src/ui/cli/doctor-owner.ts +122 -0
- package/src/ui/cli/doctor-where.ts +203 -0
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/service-command.ts +233 -11
- package/src/ui/cli/sessions-command.ts +46 -12
- package/src/ui/cli/web-command.ts +78 -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 +147 -12
- package/src/ui/server/public/agents-page.js +103 -6
- package/src/ui/server/public/agents.html +30 -0
- 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 +24 -1
- package/src/ui/server/public/i18n/ja.js +24 -1
- package/src/ui/server/public/settings-registry.js +1 -1
- package/src/ui/server/public/shell.js +24 -1
- package/src/ui/server/route-classes.ts +7 -0
- 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
|
|
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/agent-ops.ts
CHANGED
|
@@ -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 {
|
|
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: {
|
|
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);
|
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)}`);
|