@monster-orange/den 0.4.4 → 0.4.6
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 +243 -0
- package/README.md +24 -9
- package/docs/distributions.md +320 -26
- package/docs/reference.md +8 -3
- package/package.json +4 -3
- package/skills/install-den/SKILL.md +114 -0
- package/src/shared/agent-setup.ts +9 -0
- package/src/shared/auth/sudo-vault.ts +115 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +7 -0
- package/src/shared/dev-metrics/emit.ts +59 -2
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/port-pick.ts +60 -0
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/create-main.ts +1 -1
- package/src/ui/cli/create.ts +263 -30
- package/src/ui/cli/doctor-command.ts +67 -4
- package/src/ui/cli/doctor-onboard.ts +291 -0
- package/src/ui/cli/flag-parser.ts +5 -1
- package/src/ui/cli/index.ts +246 -4
- package/src/ui/cli/json-out.ts +49 -0
- package/src/ui/cli/mcp-connect.ts +179 -15
- package/src/ui/cli/repo-command.ts +490 -0
- package/src/ui/cli/repo-detect.ts +192 -0
- package/src/ui/cli/web-command.ts +513 -33
- package/src/ui/mcp/connect.ts +134 -0
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +463 -27
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/i18n/es.js +74 -8
- package/src/ui/server/public/i18n/ja.js +74 -8
- package/src/ui/server/public/local-dev.html +16 -4
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/settings-page.js +10 -3
- package/src/ui/server/public/settings-registry.js +392 -1
- package/src/ui/server/public/shell.js +123 -10
- package/src/ui/server/public/topbar.js +7 -3
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +102 -2
- package/src/ui/server/ui-assets.ts +2 -0
|
@@ -52,11 +52,34 @@ export class OnePasswordProvider implements SecretsProvider {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** Overwrite the field; an item that isn't there yet is created (a Password item). */
|
|
55
56
|
async set(ref: SecretRef, value: string): Promise<void> {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
const vault = ref.vault ? ["--vault", ref.vault] : [];
|
|
58
|
+
const field = `${ref.field}[password]=${value}`;
|
|
59
|
+
try {
|
|
60
|
+
await execFileAsync("op", ["item", "edit", ref.item, ...vault, field], {
|
|
61
|
+
timeout: this.timeout,
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
try {
|
|
65
|
+
await execFileAsync(
|
|
66
|
+
"op",
|
|
67
|
+
[
|
|
68
|
+
"item",
|
|
69
|
+
"create",
|
|
70
|
+
"--category",
|
|
71
|
+
"password",
|
|
72
|
+
"--title",
|
|
73
|
+
ref.item,
|
|
74
|
+
...vault,
|
|
75
|
+
field,
|
|
76
|
+
],
|
|
77
|
+
{ timeout: this.timeout },
|
|
78
|
+
);
|
|
79
|
+
} catch {
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
60
83
|
}
|
|
61
84
|
|
|
62
85
|
async preflight(): Promise<PreflightResult> {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a: the owner's GitHub identity. `<state dir>/web-owner.json`, mode
|
|
3
|
+
* 0600: `{ v: 1, owner: "github:<numeric id>", login, linkedAt }`. The id
|
|
4
|
+
* is what sign-in compares; the login is for display only (a login can be
|
|
5
|
+
* renamed, the id can't). No token is ever stored: the device flow's token
|
|
6
|
+
* is dropped right after `GET /user`.
|
|
7
|
+
*
|
|
8
|
+
* Written by `<cli> web owner link` and Settings › Access; removed by
|
|
9
|
+
* `web owner unlink`. Re-read whenever it changes on disk.
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes } from "crypto";
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { getStateDir } from "./paths.js";
|
|
15
|
+
|
|
16
|
+
export const WEB_OWNER_FILE = "web-owner.json";
|
|
17
|
+
|
|
18
|
+
export interface WebOwner {
|
|
19
|
+
/** `github:<numeric id>`. */
|
|
20
|
+
owner: `github:${number}`;
|
|
21
|
+
/** The GitHub login at link time, for display. */
|
|
22
|
+
login: string;
|
|
23
|
+
/** ISO time it was linked. */
|
|
24
|
+
linkedAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function webOwnerPath(stateDir: string = getStateDir()): string {
|
|
28
|
+
return path.join(stateDir, WEB_OWNER_FILE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
32
|
+
|
|
33
|
+
/** The linked owner, or null (none, or a file that isn't one). */
|
|
34
|
+
export function readWebOwner(
|
|
35
|
+
stateDir: string = getStateDir(),
|
|
36
|
+
): WebOwner | null {
|
|
37
|
+
let raw: unknown;
|
|
38
|
+
try {
|
|
39
|
+
raw = JSON.parse(fs.readFileSync(webOwnerPath(stateDir), "utf-8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const o = raw as Partial<WebOwner> | null;
|
|
44
|
+
if (
|
|
45
|
+
!o ||
|
|
46
|
+
typeof o.owner !== "string" ||
|
|
47
|
+
!/^github:[1-9][0-9]{0,15}$/.test(o.owner) ||
|
|
48
|
+
typeof o.login !== "string" ||
|
|
49
|
+
!LOGIN.test(o.login)
|
|
50
|
+
)
|
|
51
|
+
return null;
|
|
52
|
+
return {
|
|
53
|
+
owner: o.owner,
|
|
54
|
+
login: o.login,
|
|
55
|
+
linkedAt: typeof o.linkedAt === "string" ? o.linkedAt : "",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Link `id` (`login` for display) as the owner: atomic, 0600. */
|
|
60
|
+
export function writeWebOwner(
|
|
61
|
+
identity: { id: number; login: string },
|
|
62
|
+
stateDir: string = getStateDir(),
|
|
63
|
+
now: Date = new Date(),
|
|
64
|
+
): WebOwner {
|
|
65
|
+
if (!Number.isSafeInteger(identity.id) || identity.id <= 0)
|
|
66
|
+
throw new Error("a GitHub account id is a positive integer");
|
|
67
|
+
if (!LOGIN.test(identity.login))
|
|
68
|
+
throw new Error("that is not a GitHub login");
|
|
69
|
+
const rec: WebOwner = {
|
|
70
|
+
owner: `github:${identity.id}`,
|
|
71
|
+
login: identity.login,
|
|
72
|
+
linkedAt: now.toISOString(),
|
|
73
|
+
};
|
|
74
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
75
|
+
const file = webOwnerPath(stateDir);
|
|
76
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
77
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ v: 1, ...rec })}\n`, {
|
|
78
|
+
mode: 0o600,
|
|
79
|
+
});
|
|
80
|
+
fs.renameSync(tmp, file);
|
|
81
|
+
return rec;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Unlink the owner; the one it was, or null when none was linked. */
|
|
85
|
+
export function clearWebOwner(
|
|
86
|
+
stateDir: string = getStateDir(),
|
|
87
|
+
): WebOwner | null {
|
|
88
|
+
const was = readWebOwner(stateDir);
|
|
89
|
+
fs.rmSync(webOwnerPath(stateDir), { force: true });
|
|
90
|
+
return was;
|
|
91
|
+
}
|
|
@@ -21,6 +21,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
|
21
21
|
import * as fs from "fs";
|
|
22
22
|
import * as path from "path";
|
|
23
23
|
import * as yaml from "yaml";
|
|
24
|
+
import { parseMachineName } from "./cli-command.js";
|
|
24
25
|
import { getStateDir } from "./paths.js";
|
|
25
26
|
|
|
26
27
|
export const LOGIN_CODE_TTL_MS = 60_000;
|
|
@@ -98,6 +99,14 @@ export function checkLoginCode(
|
|
|
98
99
|
return { ok: true, nonce: m[2], expiresAt };
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/** A login code's nonce, from its shape alone (unverified), or null. */
|
|
103
|
+
export function loginCodeNonce(code: string): string | null {
|
|
104
|
+
const m = /^v1\.([A-Za-z0-9_-]{22})\.[0-9a-z]{1,11}\.[A-Za-z0-9_-]{43}$/.exec(
|
|
105
|
+
code,
|
|
106
|
+
);
|
|
107
|
+
return m ? m[1] : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
101
110
|
/**
|
|
102
111
|
* Where a sign-in may send the browser: a same-origin relative path, else
|
|
103
112
|
* `/`. `//evil`, `/\evil`, `http://evil`, a control character or a path back
|
|
@@ -167,8 +176,30 @@ export interface WebAuthConfig {
|
|
|
167
176
|
* never the distribution's), and warned about at start.
|
|
168
177
|
*/
|
|
169
178
|
plainHttpInDistribution?: string;
|
|
179
|
+
/**
|
|
180
|
+
* `web.github_client_id` (SEC-2a): the GitHub App whose device flow signs
|
|
181
|
+
* the owner in. user.yml over the distribution config; unset: the MO Den
|
|
182
|
+
* app's (github-app.ts).
|
|
183
|
+
*/
|
|
184
|
+
githubClientId?: string;
|
|
185
|
+
/**
|
|
186
|
+
* `web.edge_unattested` (SEC-2a, user.yml only): what an edge request
|
|
187
|
+
* whose client Den can't place gets. `signin` (the default): it signs in;
|
|
188
|
+
* `local`: it is treated as this machine (0.4.4's allow).
|
|
189
|
+
*/
|
|
190
|
+
edgeUnattested: EdgeUnattested;
|
|
191
|
+
/** The distribution config's file when it sets `web.edge_unattested`: ignored there. */
|
|
192
|
+
edgeUnattestedInDistribution?: string;
|
|
193
|
+
/**
|
|
194
|
+
* `web.machine_name` (SEC-2a, user.yml only): the name the signed-out
|
|
195
|
+
* page gives this machine ("runs on <name>"). Unset: the short hostname.
|
|
196
|
+
*/
|
|
197
|
+
machineName?: string;
|
|
170
198
|
}
|
|
171
199
|
|
|
200
|
+
/** `web.edge_unattested`'s values. */
|
|
201
|
+
export type EdgeUnattested = "local" | "signin";
|
|
202
|
+
|
|
172
203
|
function readYaml(file: string): Record<string, unknown> {
|
|
173
204
|
try {
|
|
174
205
|
const v = yaml.parse(fs.readFileSync(file, "utf-8")) as unknown;
|
|
@@ -210,6 +241,12 @@ export function ignoredConfigWarning(cfg: WebAuthConfig): string | null {
|
|
|
210
241
|
`${cfg.ignored.join(", ")}: ignored. A browser session never expires; ` +
|
|
211
242
|
"it ends at sign-out or `web logout`. Remove the key to silence this.",
|
|
212
243
|
);
|
|
244
|
+
if (cfg.edgeUnattestedInDistribution)
|
|
245
|
+
lines.push(
|
|
246
|
+
`web.edge_unattested (${cfg.edgeUnattestedInDistribution}): ignored. ` +
|
|
247
|
+
"It is a choice for one machine, read only from config/local/user.yml; " +
|
|
248
|
+
"move it there on the machine that needs it, and remove it here.",
|
|
249
|
+
);
|
|
213
250
|
if (cfg.plainHttpInDistribution)
|
|
214
251
|
lines.push(
|
|
215
252
|
`web.allow_plain_http_remote (${cfg.plainHttpInDistribution}): ignored. ` +
|
|
@@ -241,15 +278,47 @@ export function readWebAuthConfig(
|
|
|
241
278
|
throw new Error(
|
|
242
279
|
`web.allow_plain_http_remote must be true or false, got ${JSON.stringify(plain)}`,
|
|
243
280
|
);
|
|
281
|
+
const unattested = user.edge_unattested;
|
|
282
|
+
if (
|
|
283
|
+
unattested !== undefined &&
|
|
284
|
+
unattested !== "local" &&
|
|
285
|
+
unattested !== "signin"
|
|
286
|
+
)
|
|
287
|
+
throw new Error(
|
|
288
|
+
`web.edge_unattested must be local or signin, got ${JSON.stringify(unattested)}`,
|
|
289
|
+
);
|
|
290
|
+
const clientId =
|
|
291
|
+
clientIdOf(user, "config/local/user.yml") ?? clientIdOf(team, configFile);
|
|
292
|
+
const machineName = parseMachineName(user.machine_name);
|
|
244
293
|
return {
|
|
294
|
+
...(machineName ? { machineName } : {}),
|
|
245
295
|
allowPlainHttpRemote: plain === true,
|
|
246
296
|
ignored,
|
|
297
|
+
edgeUnattested: unattested ?? "signin",
|
|
298
|
+
...(clientId !== undefined ? { githubClientId: clientId } : {}),
|
|
247
299
|
...(team.allow_plain_http_remote !== undefined
|
|
248
300
|
? { plainHttpInDistribution: configFile }
|
|
249
301
|
: {}),
|
|
302
|
+
...(team.edge_unattested !== undefined
|
|
303
|
+
? { edgeUnattestedInDistribution: configFile }
|
|
304
|
+
: {}),
|
|
250
305
|
};
|
|
251
306
|
}
|
|
252
307
|
|
|
308
|
+
/** `web.github_client_id` in one file: a GitHub App client id, or unset. */
|
|
309
|
+
function clientIdOf(
|
|
310
|
+
w: Record<string, unknown>,
|
|
311
|
+
file: string,
|
|
312
|
+
): string | undefined {
|
|
313
|
+
const v = w.github_client_id;
|
|
314
|
+
if (v === undefined || v === null) return undefined;
|
|
315
|
+
if (typeof v !== "string" || !/^[A-Za-z0-9._-]{0,64}$/.test(v.trim()))
|
|
316
|
+
throw new Error(
|
|
317
|
+
`web.github_client_id (${file}) must be a GitHub App client id (letters, digits, dots, dashes), got ${JSON.stringify(v)}`,
|
|
318
|
+
);
|
|
319
|
+
return v.trim();
|
|
320
|
+
}
|
|
321
|
+
|
|
253
322
|
// ── The session store ──────────────────────────────────────────────────────
|
|
254
323
|
|
|
255
324
|
export interface SessionRecord {
|
|
@@ -260,6 +329,14 @@ export interface SessionRecord {
|
|
|
260
329
|
csrf: string;
|
|
261
330
|
/** The Host it was issued on. */
|
|
262
331
|
host: string;
|
|
332
|
+
/** SEC-2a: how it signed in: a GitHub sign-in, or a one-time link (pairing). Unset: a link (before 0.4.5). */
|
|
333
|
+
via?: "github" | "link" | "code";
|
|
334
|
+
/** SEC-2a: the client's address when Den could attest it. */
|
|
335
|
+
client?: string;
|
|
336
|
+
/** SEC-2a: a GitHub sign-in's login (the chip's "@login"). */
|
|
337
|
+
login?: string;
|
|
338
|
+
/** SEC-2a: not yet greeted ("This device is signed in…", once). */
|
|
339
|
+
welcome?: true;
|
|
263
340
|
}
|
|
264
341
|
|
|
265
342
|
interface StoreFile {
|
|
@@ -384,7 +461,13 @@ export class WebSessionStore {
|
|
|
384
461
|
}
|
|
385
462
|
|
|
386
463
|
/** A new session: its id (the cookie's value) and record. */
|
|
387
|
-
create(meta: {
|
|
464
|
+
create(meta: {
|
|
465
|
+
userAgent?: string;
|
|
466
|
+
host?: string;
|
|
467
|
+
via?: "github" | "link" | "code";
|
|
468
|
+
client?: string;
|
|
469
|
+
login?: string;
|
|
470
|
+
}): {
|
|
388
471
|
id: string;
|
|
389
472
|
session: SessionView;
|
|
390
473
|
} {
|
|
@@ -400,6 +483,10 @@ export class WebSessionStore {
|
|
|
400
483
|
host: String(meta.host ?? "")
|
|
401
484
|
.toLowerCase()
|
|
402
485
|
.slice(0, 255),
|
|
486
|
+
...(meta.via ? { via: meta.via } : {}),
|
|
487
|
+
...(meta.client ? { client: String(meta.client).slice(0, 64) } : {}),
|
|
488
|
+
...(meta.login ? { login: String(meta.login).slice(0, 64) } : {}),
|
|
489
|
+
welcome: true,
|
|
403
490
|
};
|
|
404
491
|
const data = structuredClone(this.load());
|
|
405
492
|
this.prune(data, now);
|
|
@@ -438,6 +525,19 @@ export class WebSessionStore {
|
|
|
438
525
|
}
|
|
439
526
|
}
|
|
440
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Whether this session is yet to be greeted, once: true the first time
|
|
530
|
+
* after its sign-in, and never again (the flag is cleared).
|
|
531
|
+
*/
|
|
532
|
+
takeWelcome(key: string): boolean {
|
|
533
|
+
const data = this.load();
|
|
534
|
+
if (!data.sessions[key]?.welcome) return false;
|
|
535
|
+
const next = structuredClone(data);
|
|
536
|
+
delete next.sessions[key].welcome;
|
|
537
|
+
this.trySave(next);
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
|
|
441
541
|
/** Every session, newest first. */
|
|
442
542
|
list(): SessionView[] {
|
|
443
543
|
return Object.entries(this.load().sessions)
|
|
@@ -489,3 +589,37 @@ export class WebSessionStore {
|
|
|
489
589
|
export function sessionIdPrefix(key: string): string {
|
|
490
590
|
return key.slice(0, 8);
|
|
491
591
|
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* A linked device's name from its User-Agent (SEC-2a: "Chrome on macOS"),
|
|
595
|
+
* or "Unknown browser".
|
|
596
|
+
*/
|
|
597
|
+
export function deviceName(userAgent: string | undefined): string {
|
|
598
|
+
const ua = String(userAgent ?? "");
|
|
599
|
+
const browser = /Edg\//.test(ua)
|
|
600
|
+
? "Edge"
|
|
601
|
+
: /Firefox\//.test(ua)
|
|
602
|
+
? "Firefox"
|
|
603
|
+
: /(Chrome|CriOS)\//.test(ua)
|
|
604
|
+
? "Chrome"
|
|
605
|
+
: /Safari\//.test(ua)
|
|
606
|
+
? "Safari"
|
|
607
|
+
: /curl\//.test(ua)
|
|
608
|
+
? "curl"
|
|
609
|
+
: null;
|
|
610
|
+
const os = /iPhone|iPad|iPod/.test(ua)
|
|
611
|
+
? "iOS"
|
|
612
|
+
: /Android/.test(ua)
|
|
613
|
+
? "Android"
|
|
614
|
+
: /Mac OS X|Macintosh/.test(ua)
|
|
615
|
+
? "macOS"
|
|
616
|
+
: /Windows/.test(ua)
|
|
617
|
+
? "Windows"
|
|
618
|
+
: /CrOS/.test(ua)
|
|
619
|
+
? "ChromeOS"
|
|
620
|
+
: /Linux/.test(ua)
|
|
621
|
+
? "Linux"
|
|
622
|
+
: null;
|
|
623
|
+
if (browser && os) return `${browser} on ${os}`;
|
|
624
|
+
return browser ?? (os ? `A browser on ${os}` : "Unknown browser");
|
|
625
|
+
}
|
package/src/ui/cli/create.ts
CHANGED
|
@@ -15,7 +15,10 @@ import * as fs from "node:fs";
|
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import { ENGINE_PACKAGE, getEngineRoot } from "../../shared/paths.js";
|
|
17
17
|
import { profileFrom } from "../../shared/profile.js";
|
|
18
|
+
import { parse as parseYaml } from "yaml";
|
|
18
19
|
import { plural } from "../../shared/plural.js";
|
|
20
|
+
import { DEFAULT_PORT } from "../../shared/constants.js";
|
|
21
|
+
import { pickFreePort } from "../../shared/port-pick.js";
|
|
19
22
|
|
|
20
23
|
export interface CreateOptions {
|
|
21
24
|
/** Product name: lower-case `[a-z][a-z0-9-]*`. */
|
|
@@ -296,18 +299,34 @@ export function writeDistribution(dir: string, opts: CreateOptions): string[] {
|
|
|
296
299
|
|
|
297
300
|
export interface ParsedCreateArgs extends Omit<CreateOptions, "denVersion"> {
|
|
298
301
|
dir: string;
|
|
302
|
+
/** `--yes`: never prompt (create never does; accepted so agents can say so). */
|
|
303
|
+
yes: boolean;
|
|
304
|
+
/** `--json`: print one JSON object on stdout, nothing else. */
|
|
305
|
+
json: boolean;
|
|
306
|
+
/** `--port <n>`: the web UI's port, taken as given (no search). */
|
|
307
|
+
port?: number;
|
|
299
308
|
}
|
|
300
309
|
|
|
301
310
|
const CREATE_USAGE =
|
|
302
|
-
"usage: den create <name> [dir] [--title <title>] [--cli <cli>] [--env-prefix <PREFIX>]";
|
|
311
|
+
"usage: den create <name> [dir] [--title <title>] [--cli <cli>] [--env-prefix <PREFIX>] [--port <n>] [--yes] [--json]";
|
|
303
312
|
|
|
304
313
|
/** Parse `create` argv (after the word `create`). `cwd` resolves the dir. */
|
|
305
314
|
export function parseCreateArgs(argv: string[], cwd: string): ParsedCreateArgs {
|
|
306
315
|
const positional: string[] = [];
|
|
307
316
|
const flags: Record<string, string> = {};
|
|
317
|
+
let yes = false;
|
|
318
|
+
let json = false;
|
|
308
319
|
for (let i = 0; i < argv.length; i++) {
|
|
309
320
|
const a = argv[i];
|
|
310
|
-
|
|
321
|
+
if (a === "--yes" || a === "-y") {
|
|
322
|
+
yes = true;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (a === "--json") {
|
|
326
|
+
json = true;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const m = /^--(title|cli|env-prefix|port)(?:=(.*))?$/.exec(a);
|
|
311
330
|
if (m) {
|
|
312
331
|
const value = m[2] ?? argv[++i];
|
|
313
332
|
if (value === undefined)
|
|
@@ -326,9 +345,19 @@ export function parseCreateArgs(argv: string[], cwd: string): ParsedCreateArgs {
|
|
|
326
345
|
title: flags.title,
|
|
327
346
|
cli: flags.cli,
|
|
328
347
|
envPrefix: flags["env-prefix"],
|
|
348
|
+
yes,
|
|
349
|
+
json,
|
|
350
|
+
...(flags.port !== undefined ? { port: parsePortFlag(flags.port) } : {}),
|
|
329
351
|
};
|
|
330
352
|
}
|
|
331
353
|
|
|
354
|
+
function parsePortFlag(v: string): number {
|
|
355
|
+
const n = Number(v);
|
|
356
|
+
if (!/^\d+$/.test(v) || n < 1 || n > 65535)
|
|
357
|
+
throw new Error(`--port must be a port number\n${CREATE_USAGE}`);
|
|
358
|
+
return n;
|
|
359
|
+
}
|
|
360
|
+
|
|
332
361
|
export const CREATE_HELP = `${CREATE_USAGE}
|
|
333
362
|
|
|
334
363
|
Scaffold a new Den distribution in [dir] (default ./<name>):
|
|
@@ -338,41 +367,245 @@ config/environments/local.json, manifests/ and tests/pillars/.
|
|
|
338
367
|
|
|
339
368
|
--title <title> brand name shown to people (default: <Name>)
|
|
340
369
|
--cli <cli> the command people type (default: <name>)
|
|
341
|
-
--env-prefix <PREFIX> env-var prefix (default: <NAME>)
|
|
370
|
+
--env-prefix <PREFIX> env-var prefix (default: <NAME>)
|
|
371
|
+
--port <n> the web UI's port (default: ${DEFAULT_PORT}, or the next
|
|
372
|
+
free one above it when that is in use)
|
|
373
|
+
--yes, -y never prompt (create never does; for agents)
|
|
374
|
+
--json print one JSON object: { ok, dir, cli, existed, port, next }
|
|
375
|
+
|
|
376
|
+
Safe to re-run: a [dir] that already holds this distribution answers ok
|
|
377
|
+
(existed: true) and writes nothing.`;
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Whether `dir` already holds the distribution `create` would make: its
|
|
381
|
+
* den.yml names this product (and this cli, when one was given).
|
|
382
|
+
*/
|
|
383
|
+
export function existingDistribution(
|
|
384
|
+
dir: string,
|
|
385
|
+
opts: { name: string; cli?: string },
|
|
386
|
+
): boolean {
|
|
387
|
+
let text: string;
|
|
388
|
+
try {
|
|
389
|
+
text = fs.readFileSync(path.join(dir, "den.yml"), "utf-8");
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
const name = /^\s+name:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
394
|
+
if (name !== opts.name) return false;
|
|
395
|
+
const cli = /^\s+cli:\s*(\S+)\s*$/m.exec(text)?.[1] ?? opts.name;
|
|
396
|
+
return cli === (opts.cli ?? opts.name);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** What `create --json` prints. */
|
|
400
|
+
export interface CreateJson {
|
|
401
|
+
ok: boolean;
|
|
402
|
+
dir?: string;
|
|
403
|
+
cli?: string;
|
|
404
|
+
existed?: boolean;
|
|
405
|
+
files?: number;
|
|
406
|
+
/** The web UI's port for this distribution. */
|
|
407
|
+
port?: number;
|
|
408
|
+
/** Set when DEFAULT_PORT was in use and `port` was picked instead. */
|
|
409
|
+
portPicked?: { wanted: number; file: string };
|
|
410
|
+
next?: string[];
|
|
411
|
+
error?: string;
|
|
412
|
+
fix?: string;
|
|
413
|
+
ask?: string;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function cdPathFor(cwd: string, dir: string): string {
|
|
417
|
+
// Relative only when the new directory is under the caller's cwd; a
|
|
418
|
+
// path that climbs out (../../..) is harder to read than the absolute one.
|
|
419
|
+
const rel = path.relative(cwd, dir);
|
|
420
|
+
return rel === ""
|
|
421
|
+
? "."
|
|
422
|
+
: rel.startsWith("..") || path.isAbsolute(rel)
|
|
423
|
+
? dir
|
|
424
|
+
: rel;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The web port a distribution at `dir` is set to (user, then team, config). */
|
|
428
|
+
export function configuredWebPort(dir: string, name: string): number {
|
|
429
|
+
for (const rel of [
|
|
430
|
+
path.join("config", "local", "user.yml"),
|
|
431
|
+
path.join("config", `${name}.yml`),
|
|
432
|
+
]) {
|
|
433
|
+
try {
|
|
434
|
+
const doc = parseYaml(fs.readFileSync(path.join(dir, rel), "utf-8")) as {
|
|
435
|
+
web?: { port?: unknown };
|
|
436
|
+
} | null;
|
|
437
|
+
const p = doc?.web?.port;
|
|
438
|
+
if (typeof p === "number" && Number.isInteger(p)) return p;
|
|
439
|
+
} catch {
|
|
440
|
+
/* absent or unreadable: next layer */
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return DEFAULT_PORT;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export interface CreatePortOptions {
|
|
447
|
+
/**
|
|
448
|
+
* The port to start from: DEFAULT_PORT unless a test passes a port of its
|
|
449
|
+
* own (so no test probes the real default). The CLI reads it from
|
|
450
|
+
* DEN_WEB_PORT_START.
|
|
451
|
+
*/
|
|
452
|
+
startPort?: number;
|
|
453
|
+
inUse?: (port: number) => Promise<boolean>;
|
|
454
|
+
}
|
|
342
455
|
|
|
343
|
-
/**
|
|
344
|
-
export function
|
|
456
|
+
/** DEN_WEB_PORT_START, for tests and CI: never probe the real default. */
|
|
457
|
+
export function startPortFromEnv(
|
|
458
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
459
|
+
): number | undefined {
|
|
460
|
+
const v = env.DEN_WEB_PORT_START?.trim();
|
|
461
|
+
if (!v) return undefined;
|
|
462
|
+
const n = Number(v);
|
|
463
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : undefined;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* `create` as data: never prompts, never throws. A dir that already holds
|
|
468
|
+
* this distribution is ok (existed); any other non-empty dir is an error
|
|
469
|
+
* that names a fix.
|
|
470
|
+
*
|
|
471
|
+
* The web port: `--port` as given; else the default when it is free, else
|
|
472
|
+
* the next free port above it (never a reserved Den port), written to the
|
|
473
|
+
* new distribution's config/local/user.yml as `web.port`. Checking a port
|
|
474
|
+
* sends nothing to whoever holds it (port-pick.ts). A re-run reports the
|
|
475
|
+
* port the distribution is set to and picks nothing.
|
|
476
|
+
*/
|
|
477
|
+
export async function createResult(
|
|
478
|
+
args: ParsedCreateArgs,
|
|
479
|
+
cwd: string,
|
|
480
|
+
denVersion: string = runningDenVersion(),
|
|
481
|
+
portOpts: CreatePortOptions = {},
|
|
482
|
+
): Promise<CreateJson> {
|
|
483
|
+
const cli = args.cli ?? args.name;
|
|
484
|
+
const next = (dir: string): string[] => [
|
|
485
|
+
`cd ${cdPathFor(cwd, dir)}`,
|
|
486
|
+
"npm install",
|
|
487
|
+
`./bin/${cli} repo add <path|git-url> --json`,
|
|
488
|
+
];
|
|
489
|
+
try {
|
|
490
|
+
validateCreateOptions({ ...args, denVersion });
|
|
491
|
+
if (existingDistribution(args.dir, args))
|
|
492
|
+
return {
|
|
493
|
+
ok: true,
|
|
494
|
+
dir: args.dir,
|
|
495
|
+
cli,
|
|
496
|
+
existed: true,
|
|
497
|
+
port: configuredWebPort(args.dir, args.name),
|
|
498
|
+
next: next(args.dir),
|
|
499
|
+
};
|
|
500
|
+
// Refuse a busy dir before looking for a port.
|
|
501
|
+
if (
|
|
502
|
+
fs.existsSync(args.dir) &&
|
|
503
|
+
fs.statSync(args.dir).isDirectory() &&
|
|
504
|
+
fs.readdirSync(args.dir).length > 0
|
|
505
|
+
)
|
|
506
|
+
writeDistribution(args.dir, { ...args, denVersion }); // throws "not empty"
|
|
507
|
+
const wanted = args.port ?? portOpts.startPort ?? DEFAULT_PORT;
|
|
508
|
+
const port =
|
|
509
|
+
args.port ??
|
|
510
|
+
(await pickFreePort({ start: wanted, inUse: portOpts.inUse })) ??
|
|
511
|
+
wanted;
|
|
512
|
+
const written = writeDistribution(args.dir, { ...args, denVersion });
|
|
513
|
+
let portPicked: CreateJson["portPicked"];
|
|
514
|
+
// Written when the port differs from what the distribution would use
|
|
515
|
+
// anyway (an explicit --port that isn't the default, or a picked one).
|
|
516
|
+
if (port !== (args.port !== undefined ? DEFAULT_PORT : wanted)) {
|
|
517
|
+
const rel = path.join("config", "local", "user.yml");
|
|
518
|
+
const file = path.join(args.dir, rel);
|
|
519
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
520
|
+
fs.writeFileSync(
|
|
521
|
+
file,
|
|
522
|
+
`# Yours, not the team's (gitignored). Set by create: ${
|
|
523
|
+
args.port !== undefined
|
|
524
|
+
? "--port"
|
|
525
|
+
: `port ${wanted} was in use on this machine`
|
|
526
|
+
}.\nweb:\n port: ${port}\n`,
|
|
527
|
+
{ encoding: "utf-8", flag: "wx" },
|
|
528
|
+
);
|
|
529
|
+
written.push(rel);
|
|
530
|
+
if (args.port === undefined) portPicked = { wanted, file: rel };
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
ok: true,
|
|
534
|
+
dir: args.dir,
|
|
535
|
+
cli,
|
|
536
|
+
existed: false,
|
|
537
|
+
files: written.length,
|
|
538
|
+
port,
|
|
539
|
+
...(portPicked ? { portPicked } : {}),
|
|
540
|
+
next: next(args.dir),
|
|
541
|
+
};
|
|
542
|
+
} catch (e) {
|
|
543
|
+
const error = (e as Error).message;
|
|
544
|
+
if (/is not empty/.test(error))
|
|
545
|
+
return {
|
|
546
|
+
ok: false,
|
|
547
|
+
error,
|
|
548
|
+
fix: `npx ${ENGINE_PACKAGE} create ${args.name} ${path.join(path.dirname(args.dir), `${args.name}-den`)} --yes --json`,
|
|
549
|
+
ask: `${args.dir} already has files that are not a ${args.name} distribution. Which new or empty folder should the distribution go in?`,
|
|
550
|
+
};
|
|
551
|
+
return {
|
|
552
|
+
ok: false,
|
|
553
|
+
error,
|
|
554
|
+
ask: "Which name should the distribution have? Lower-case letters, digits and dashes, starting with a letter.",
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Run `create` end to end; resolves to the process exit code. */
|
|
560
|
+
export async function runCreate(
|
|
561
|
+
argv: string[],
|
|
562
|
+
cwd: string,
|
|
563
|
+
portOpts: CreatePortOptions = { startPort: startPortFromEnv() },
|
|
564
|
+
): Promise<number> {
|
|
345
565
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
346
566
|
console.log(CREATE_HELP);
|
|
347
567
|
return 0;
|
|
348
568
|
}
|
|
569
|
+
const json = argv.includes("--json");
|
|
570
|
+
let args: ParsedCreateArgs;
|
|
349
571
|
try {
|
|
350
|
-
|
|
351
|
-
const written = writeDistribution(args.dir, {
|
|
352
|
-
...args,
|
|
353
|
-
denVersion: runningDenVersion(),
|
|
354
|
-
});
|
|
355
|
-
const cli = args.cli ?? args.name;
|
|
356
|
-
// Relative only when the new directory is under the caller's cwd; a
|
|
357
|
-
// path that climbs out (../../..) is harder to read than the absolute one.
|
|
358
|
-
const rel = path.relative(cwd, args.dir);
|
|
359
|
-
const cdPath =
|
|
360
|
-
rel === ""
|
|
361
|
-
? "."
|
|
362
|
-
: rel.startsWith("..") || path.isAbsolute(rel)
|
|
363
|
-
? args.dir
|
|
364
|
-
: rel;
|
|
365
|
-
console.log(
|
|
366
|
-
`Created ${args.name} in ${args.dir} (${plural(written.length, "file")}).`,
|
|
367
|
-
);
|
|
368
|
-
console.log("");
|
|
369
|
-
console.log("Next:");
|
|
370
|
-
console.log(` cd ${cdPath}`);
|
|
371
|
-
console.log(" npm install");
|
|
372
|
-
console.log(` ./bin/${cli} --help`);
|
|
373
|
-
return 0;
|
|
572
|
+
args = parseCreateArgs(argv, cwd);
|
|
374
573
|
} catch (e) {
|
|
375
|
-
|
|
574
|
+
if (json) {
|
|
575
|
+
console.log(
|
|
576
|
+
JSON.stringify({
|
|
577
|
+
ok: false,
|
|
578
|
+
error: (e as Error).message.split("\n")[0],
|
|
579
|
+
fix: `npx ${ENGINE_PACKAGE} create <name> [dir] --yes --json`,
|
|
580
|
+
} satisfies CreateJson),
|
|
581
|
+
);
|
|
582
|
+
} else console.error(`create: ${(e as Error).message}`);
|
|
583
|
+
return 1;
|
|
584
|
+
}
|
|
585
|
+
const r = await createResult(args, cwd, runningDenVersion(), portOpts);
|
|
586
|
+
if (json) {
|
|
587
|
+
console.log(JSON.stringify(r));
|
|
588
|
+
return r.ok ? 0 : 1;
|
|
589
|
+
}
|
|
590
|
+
if (!r.ok) {
|
|
591
|
+
console.error(`create: ${r.error}`);
|
|
376
592
|
return 1;
|
|
377
593
|
}
|
|
594
|
+
const dir = r.dir ?? args.dir;
|
|
595
|
+
console.log(
|
|
596
|
+
r.existed
|
|
597
|
+
? `${args.name} is already in ${dir}: nothing to do.`
|
|
598
|
+
: `Created ${args.name} in ${dir} (${plural(r.files ?? 0, "file")}).`,
|
|
599
|
+
);
|
|
600
|
+
console.log(
|
|
601
|
+
r.portPicked
|
|
602
|
+
? `Web UI port: ${r.port} (${r.portPicked.wanted} is in use here; set in ${r.portPicked.file}).`
|
|
603
|
+
: `Web UI port: ${r.port}.`,
|
|
604
|
+
);
|
|
605
|
+
console.log("");
|
|
606
|
+
console.log("Next:");
|
|
607
|
+
console.log(` cd ${cdPathFor(cwd, dir)}`);
|
|
608
|
+
console.log(" npm install");
|
|
609
|
+
console.log(` ./bin/${r.cli} --help`);
|
|
610
|
+
return 0;
|
|
378
611
|
}
|