@monster-orange/den 0.2.8 → 0.3.0
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 +1547 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +507 -4
- package/make/den.mk +19 -4
- package/make/journeys.mk +2 -1
- package/make/local_dev.mk +6 -13
- package/make/secrets-1password.mk +18 -0
- package/make/test.mk +1 -1
- package/package.json +9 -2
- package/schemas/den.plugin.v1.json +156 -0
- package/schemas/den.theme.v1.json +269 -0
- package/scripts/auth-configure.ts +16 -8
- package/scripts/heal-config.ts +0 -5
- package/scripts/setup-1password.ts +41 -19
- package/scripts/setup-secrets.ts +67 -101
- package/src/pillars/api/global-setup.ts +1 -1
- package/src/pillars/journeys/sandbox.ts +23 -18
- package/src/pillars/journeys/tools/browser-session.ts +2 -2
- package/src/scripts/setup-default-workspace.ts +2 -2
- package/src/shared/auth/headless-login.ts +1 -1
- package/src/shared/auth/sudo-vault.ts +93 -31
- package/src/shared/auth/token-providers/index.ts +7 -3
- package/src/shared/brand.ts +7 -0
- package/src/shared/config-error.ts +33 -3
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +141 -5
- package/src/shared/edge/config.ts +147 -21
- package/src/shared/edge/exec.ts +31 -0
- package/src/shared/edge/included.ts +169 -0
- package/src/shared/edge/render.ts +69 -14
- package/src/shared/edge/runtime.ts +128 -121
- package/src/shared/edge/status.ts +68 -8
- package/src/shared/edge/tls.ts +328 -0
- package/src/shared/edge/types.ts +25 -9
- package/src/shared/environment-manager/default-workspace.ts +16 -1
- package/src/shared/environment-manager/health.ts +73 -38
- package/src/shared/environment-manager/scope.ts +168 -0
- package/src/shared/environment-manager/service-runner.ts +789 -43
- package/src/shared/environment-manager/up-targets.ts +132 -0
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +78 -59
- package/src/shared/org.ts +53 -5
- package/src/shared/plugins/manifest.ts +406 -0
- package/src/shared/plugins/store.ts +358 -0
- package/src/shared/process/legacy-adopt.ts +285 -0
- package/src/shared/process/process-group.ts +202 -0
- package/src/shared/process/spawn-streaming.ts +7 -0
- package/src/shared/secrets/doppler.ts +1 -1
- package/src/shared/secrets/index.ts +279 -50
- package/src/shared/secrets/none.ts +56 -0
- package/src/shared/secrets/strategies.ts +202 -0
- package/src/shared/secrets/types.ts +1 -1
- package/src/shared/smoke/git-remote.ts +40 -0
- package/src/shared/stacks/manifest-commands.ts +1 -1
- package/src/shared/stacks/namespace.ts +178 -0
- package/src/shared/stacks/owners.ts +303 -0
- package/src/shared/stacks/render.ts +8 -3
- package/src/shared/stacks/runtime.ts +100 -16
- package/src/shared/stacks/status.ts +63 -12
- package/src/shared/strategies/registry.ts +89 -0
- package/src/shared/theme/contract.ts +538 -0
- package/src/shared/types/index.ts +12 -3
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +131 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/flag-parser.ts +7 -2
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +548 -60
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/stack-command.ts +63 -2
- package/src/ui/cli/test-runner.ts +11 -9
- package/src/ui/cli/theme-command.ts +136 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +22 -1
- package/src/ui/mcp/tools.ts +50 -7
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/daemon-reaper.ts +29 -1
- package/src/ui/server/engine-stamp.ts +83 -0
- package/src/ui/server/github-service.ts +23 -44
- package/src/ui/server/graph-page.ts +20 -0
- package/src/ui/server/graph-prefs.ts +44 -1
- package/src/ui/server/http-errors.ts +293 -0
- package/src/ui/server/http-helpers.ts +22 -3
- package/src/ui/server/index.ts +532 -51
- package/src/ui/server/public/appearance.js +531 -0
- package/src/ui/server/public/edge-page.js +248 -0
- package/src/ui/server/public/edge.html +251 -0
- package/src/ui/server/public/environments.html +65 -83
- package/src/ui/server/public/error-page.js +247 -0
- package/src/ui/server/public/error.html +260 -0
- package/src/ui/server/public/graph-model.js +332 -14
- package/src/ui/server/public/graph.css +141 -47
- package/src/ui/server/public/graph.js +223 -44
- package/src/ui/server/public/header.js +396 -600
- package/src/ui/server/public/health-fit.js +248 -0
- package/src/ui/server/public/health-rollup.js +128 -0
- package/src/ui/server/public/i18n/es.js +287 -0
- package/src/ui/server/public/i18n/ja.js +276 -0
- package/src/ui/server/public/index.html +139 -438
- package/src/ui/server/public/local-dev.html +3468 -4044
- package/src/ui/server/public/logs.html +71 -208
- package/src/ui/server/public/panel.js +14 -2
- package/src/ui/server/public/release.html +175 -389
- package/src/ui/server/public/services.html +596 -770
- package/src/ui/server/public/settings-page.js +759 -0
- package/src/ui/server/public/settings-registry.js +521 -0
- package/src/ui/server/public/settings.html +590 -0
- package/src/ui/server/public/shell.js +3869 -0
- package/src/ui/server/public/topbar.js +1413 -0
- package/src/ui/server/public/version-label.js +12 -0
- package/src/ui/server/public/workspace-picker.js +74 -34
- package/src/ui/server/readonly-routes.ts +12 -1
- package/src/ui/server/services-list.ts +94 -17
- package/src/ui/server/settings-distribution.ts +74 -0
- package/src/ui/server/shell-prefs.ts +192 -0
- package/src/ui/server/theme-routes.ts +292 -0
- package/src/ui/server/theme-runtime.ts +407 -0
- package/src/ui/server/ui-assets.ts +16 -1
- package/src/ui/server/ui-language.ts +50 -0
- package/src/ui/server/workspace-routes.ts +45 -3
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- package/themes/den/mark.svg +6 -0
package/src/shared/brand.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* name: Acme Dev # shown in titles and the banner
|
|
9
9
|
* tagline: Acme local dev and E2E harness
|
|
10
10
|
* banner: [ "...", "..." ] # optional ASCII art lines
|
|
11
|
+
* theme: acme # the web UI's default theme (an id in
|
|
12
|
+
* # plugins/<id>/, or den)
|
|
11
13
|
*/
|
|
12
14
|
import * as fs from "fs";
|
|
13
15
|
import * as path from "path";
|
|
@@ -19,6 +21,8 @@ export interface BrandConfig {
|
|
|
19
21
|
name: string;
|
|
20
22
|
tagline: string;
|
|
21
23
|
banner?: string[];
|
|
24
|
+
/** The web UI's default theme id; the user's appearance.theme wins. */
|
|
25
|
+
theme?: string;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export function defaultBrand(): BrandConfig {
|
|
@@ -43,6 +47,9 @@ export function readBrand(root: string = getDistRoot()): BrandConfig {
|
|
|
43
47
|
name: raw.name?.trim() || fallback.name,
|
|
44
48
|
tagline: raw.tagline?.trim() || fallback.tagline,
|
|
45
49
|
banner: Array.isArray(raw.banner) ? raw.banner.map(String) : undefined,
|
|
50
|
+
...(typeof raw.theme === "string" && raw.theme.trim()
|
|
51
|
+
? { theme: raw.theme.trim() }
|
|
52
|
+
: {}),
|
|
46
53
|
};
|
|
47
54
|
return cached;
|
|
48
55
|
}
|
|
@@ -7,12 +7,36 @@
|
|
|
7
7
|
* The message names config keys (`orgs.initech.repos`), never values a
|
|
8
8
|
* secret could hide in.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* One validation problem: the config file (as the distribution names it),
|
|
12
|
+
* the key path (`orgs.acme.sources[0].owner`) and what is wrong with it.
|
|
13
|
+
* `text` is the whole line after "<file>: ".
|
|
14
|
+
*/
|
|
15
|
+
export interface ConfigIssue {
|
|
16
|
+
file: string;
|
|
17
|
+
key: string;
|
|
18
|
+
message: string;
|
|
19
|
+
text: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
export class ConfigError extends Error {
|
|
11
23
|
readonly code = "config_invalid";
|
|
24
|
+
/** Every problem found (0.3.0-dev.40), in config order. */
|
|
25
|
+
readonly issues: ConfigIssue[];
|
|
12
26
|
|
|
13
|
-
|
|
14
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The message is one line per issue, "<file>: <text>". A plain string is
|
|
29
|
+
* one issue-less message.
|
|
30
|
+
*/
|
|
31
|
+
constructor(issues: ConfigIssue[] | string) {
|
|
32
|
+
const list = typeof issues === "string" ? [] : issues;
|
|
33
|
+
super(
|
|
34
|
+
typeof issues === "string"
|
|
35
|
+
? issues
|
|
36
|
+
: list.map((i) => `${i.file}: ${i.text}`).join("\n"),
|
|
37
|
+
);
|
|
15
38
|
this.name = "ConfigError";
|
|
39
|
+
this.issues = list;
|
|
16
40
|
}
|
|
17
41
|
}
|
|
18
42
|
|
|
@@ -28,6 +52,12 @@ export function configErrorBody(e: ConfigError): {
|
|
|
28
52
|
ok: false;
|
|
29
53
|
error: string;
|
|
30
54
|
code: "config_invalid";
|
|
55
|
+
issues: Array<{ file: string; key: string; message: string }>;
|
|
31
56
|
} {
|
|
32
|
-
return {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: e.message,
|
|
60
|
+
code: e.code,
|
|
61
|
+
issues: e.issues.map(({ file, key, message }) => ({ file, key, message })),
|
|
62
|
+
};
|
|
33
63
|
}
|
package/src/shared/constants.ts
CHANGED
|
@@ -5,17 +5,7 @@
|
|
|
5
5
|
* If it's specific to one module (e.g. a Playwright config timeout), keep it local.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
// ── 1Password strategy (only used when secrets.provider: 1password) ──────────────────────────────────────────────────────────────
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Default 1Password account domain used by every `op` invocation across den.
|
|
13
|
-
* Kept in sync with the `OP_ACCOUNT ?= …` default in the Makefile. Scripts that
|
|
14
|
-
* may run outside `make` (setup-1password, auth-configure)
|
|
15
|
-
* set `process.env.OP_ACCOUNT ||= OP_ACCOUNT_DEFAULT` at entry so op picks up
|
|
16
|
-
* the right account even when invoked directly via `npx tsx`.
|
|
17
|
-
*/
|
|
18
|
-
export const OP_ACCOUNT_DEFAULT = "example.1password.com";
|
|
8
|
+
// ── Test accounts ─────────────────────────────────────────────────────────────
|
|
19
9
|
|
|
20
10
|
/**
|
|
21
11
|
* Default vault for shared test accounts. Deprecated: the vault is org data —
|
|
@@ -24,19 +14,8 @@ export const OP_ACCOUNT_DEFAULT = "example.1password.com";
|
|
|
24
14
|
*/
|
|
25
15
|
export const OP_TEST_VAULT = "test-accounts";
|
|
26
16
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
/** Item within OP_API_KEYS_VAULT holding the developer's API keys. */
|
|
31
|
-
export const OP_DEV_KEYS_ITEM = "Developer API Keys";
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Field within OP_DEV_KEYS_ITEM where the user's local admin (sudo) password
|
|
35
|
-
* is optionally stored. Populated when the user checks "Save to 1Password" on
|
|
36
|
-
* the den local-dev unlock banner; read on server startup so subsequent
|
|
37
|
-
* sessions unlock without prompting.
|
|
38
|
-
*/
|
|
39
|
-
export const OP_DEN_SUDO_FIELD = `${envKey("LOCAL_ADMIN_PWD")}`;
|
|
17
|
+
// No 1Password account, vault, item or field is built in: a distribution
|
|
18
|
+
// names its own in `secrets` (src/shared/secrets).
|
|
40
19
|
|
|
41
20
|
// ── Timeouts ─────────────────────────────────────────────────────────────────
|
|
42
21
|
|
package/src/shared/den-config.ts
CHANGED
|
@@ -341,6 +341,33 @@ export interface UserConfig {
|
|
|
341
341
|
density?: string;
|
|
342
342
|
panelWidth?: number;
|
|
343
343
|
};
|
|
344
|
+
/**
|
|
345
|
+
* Web UI appearance: theme id and mode (system | dark | light), set from
|
|
346
|
+
* the header. See src/ui/server/appearance-prefs.ts.
|
|
347
|
+
*/
|
|
348
|
+
appearance?: { theme?: string; mode?: string };
|
|
349
|
+
/**
|
|
350
|
+
* The web UI's language (R19): en (the default, never stored), ja or es.
|
|
351
|
+
* See src/ui/server/ui-language.ts.
|
|
352
|
+
*/
|
|
353
|
+
language?: string;
|
|
354
|
+
/**
|
|
355
|
+
* The rail's pins and collapse, and (R21 Navigation) the pages it hides
|
|
356
|
+
* and their order (src/ui/server/shell-prefs.ts).
|
|
357
|
+
*/
|
|
358
|
+
shell?: {
|
|
359
|
+
pins?: string[];
|
|
360
|
+
collapsed?: boolean;
|
|
361
|
+
hidden?: string[];
|
|
362
|
+
order?: string[];
|
|
363
|
+
};
|
|
364
|
+
/**
|
|
365
|
+
* What the web UI and `<cli> status` show: `scope` is "all" (every active
|
|
366
|
+
* workspace, the default) or one active workspace's name. A view filter
|
|
367
|
+
* only: the focused workspace stays the default target for commands. See
|
|
368
|
+
* src/shared/environment-manager/scope.ts.
|
|
369
|
+
*/
|
|
370
|
+
view?: { scope?: string };
|
|
344
371
|
}
|
|
345
372
|
|
|
346
373
|
const ALL_ENVIRONMENTS: Environment[] = [
|
|
@@ -403,12 +430,34 @@ export function mergeUserConfig(patch: Partial<UserConfig>): UserConfig {
|
|
|
403
430
|
return next;
|
|
404
431
|
}
|
|
405
432
|
|
|
433
|
+
/**
|
|
434
|
+
* A scaffold or docs placeholder rather than a real org: `your-github-org`,
|
|
435
|
+
* `your-org`, `<your-org>`, `<slug>`, `example-org`. Den treats it as unset.
|
|
436
|
+
*/
|
|
437
|
+
export function isPlaceholderGithubOrg(org: string): boolean {
|
|
438
|
+
return /^(your[-_]?(github[-_]?)?org|<.*>|example[-_]?org)$/i.test(
|
|
439
|
+
org.trim(),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The configured `github_org`, or null when it is unset or still a
|
|
445
|
+
* placeholder ("not configured": Den runs no gh for it).
|
|
446
|
+
*/
|
|
447
|
+
export function configuredGithubOrg(
|
|
448
|
+
config: DenConfig = readDenConfig(),
|
|
449
|
+
): string | null {
|
|
450
|
+
const org =
|
|
451
|
+
typeof config.github_org === "string" ? config.github_org.trim() : "";
|
|
452
|
+
return org && !isPlaceholderGithubOrg(org) ? org : null;
|
|
453
|
+
}
|
|
454
|
+
|
|
406
455
|
/**
|
|
407
456
|
* The GitHub org managed repos clone from. Den has no default: an org that
|
|
408
457
|
* needs one sets `github_org` in its config.
|
|
409
458
|
*/
|
|
410
459
|
export function requireGithubOrg(config: DenConfig = readDenConfig()): string {
|
|
411
|
-
const org = config
|
|
460
|
+
const org = configuredGithubOrg(config);
|
|
412
461
|
if (!org)
|
|
413
462
|
throw new Error(
|
|
414
463
|
`github_org is not set: add \`github_org: <your-org>\` to ${DEN_CONFIG_PATH}`,
|
|
@@ -448,7 +497,19 @@ export function assertDenConfigValid(): void {
|
|
|
448
497
|
}
|
|
449
498
|
checked = { key, error };
|
|
450
499
|
}
|
|
451
|
-
if (checked.error) throw new ConfigError(checked.error.
|
|
500
|
+
if (checked.error) throw new ConfigError(checked.error.issues);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
let orgsWaived = false;
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* For `down` only (0.3.0-dev.40): from now on in this process, a config
|
|
507
|
+
* whose `orgs:` fails validation reads as one with no org data instead of
|
|
508
|
+
* throwing, so stopping is never stranded by it. Every other key is read as
|
|
509
|
+
* usual; a file that does not parse still throws.
|
|
510
|
+
*/
|
|
511
|
+
export function waiveInvalidOrgs(): void {
|
|
512
|
+
orgsWaived = true;
|
|
452
513
|
}
|
|
453
514
|
|
|
454
515
|
/**
|
|
@@ -463,7 +524,13 @@ export function readDenConfig(): DenConfig {
|
|
|
463
524
|
const parsed = yaml.parse(raw) as Partial<DenConfig> | null;
|
|
464
525
|
// ORG-1d keys (orgs.<id>.sources / .repos) are checked here, where a bad
|
|
465
526
|
// value throws naming its key; configs without them are not touched.
|
|
466
|
-
|
|
527
|
+
let orgs = parsed?.orgs;
|
|
528
|
+
try {
|
|
529
|
+
validateOrgSources(orgs, product().configFile);
|
|
530
|
+
} catch (e) {
|
|
531
|
+
if (!orgsWaived || !isConfigError(e)) throw e;
|
|
532
|
+
orgs = undefined;
|
|
533
|
+
}
|
|
467
534
|
// `environments` may be malformed (scalar/object) in a hand-edited file —
|
|
468
535
|
// fall back to the default set rather than throwing on .filter below.
|
|
469
536
|
const envs = Array.isArray(parsed?.environments)
|
|
@@ -476,7 +543,7 @@ export function readDenConfig(): DenConfig {
|
|
|
476
543
|
production_envs: parsed?.production_envs,
|
|
477
544
|
repos_root: parsed?.repos_root,
|
|
478
545
|
github_org: parsed?.github_org,
|
|
479
|
-
orgs
|
|
546
|
+
orgs,
|
|
480
547
|
graph: parsed?.graph,
|
|
481
548
|
org: parsed?.org,
|
|
482
549
|
applications: parsed?.applications,
|
|
@@ -680,6 +747,11 @@ export interface ResolvedWorkspaceBlueprint {
|
|
|
680
747
|
focus: boolean;
|
|
681
748
|
/** `org:` (ORG-1a): the org this workspace belongs to, when the blueprint names one. */
|
|
682
749
|
org?: string;
|
|
750
|
+
/**
|
|
751
|
+
* `stack_namespace:`: the Compose project suffix this workspace's stacks
|
|
752
|
+
* run under, in place of its own name (see stacks/namespace.ts).
|
|
753
|
+
*/
|
|
754
|
+
stackNamespace?: string;
|
|
683
755
|
}
|
|
684
756
|
|
|
685
757
|
/** Which blueprint shape the distribution uses, and its workspaces. */
|
|
@@ -688,7 +760,32 @@ export interface WorkspaceBlueprints {
|
|
|
688
760
|
workspaces: ResolvedWorkspaceBlueprint[];
|
|
689
761
|
}
|
|
690
762
|
|
|
691
|
-
const BLUEPRINT_KEYS = [
|
|
763
|
+
const BLUEPRINT_KEYS = [
|
|
764
|
+
"branch",
|
|
765
|
+
"repos",
|
|
766
|
+
"autostart_off",
|
|
767
|
+
"org",
|
|
768
|
+
"stack_namespace",
|
|
769
|
+
];
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* A stack namespace (and a Compose project suffix): lowercase letters,
|
|
773
|
+
* digits and dashes, not starting or ending with a dash.
|
|
774
|
+
*/
|
|
775
|
+
export const STACK_NAMESPACE_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
776
|
+
|
|
777
|
+
/** `v` as a stack namespace, or undefined when unset; throws naming `key`. */
|
|
778
|
+
export function checkStackNamespace(
|
|
779
|
+
key: string,
|
|
780
|
+
v: unknown,
|
|
781
|
+
): string | undefined {
|
|
782
|
+
if (v === undefined || v === null) return undefined;
|
|
783
|
+
if (typeof v !== "string" || !STACK_NAMESPACE_RE.test(v))
|
|
784
|
+
throw new Error(
|
|
785
|
+
`${key} must be lowercase letters, digits and dashes (got ${JSON.stringify(v)})`,
|
|
786
|
+
);
|
|
787
|
+
return v;
|
|
788
|
+
}
|
|
692
789
|
|
|
693
790
|
function blueprintStrings(where: string, key: string, v: unknown): string[] {
|
|
694
791
|
if (v === undefined) return [];
|
|
@@ -720,6 +817,10 @@ function resolveBlueprint(
|
|
|
720
817
|
throw new Error(`${where}.focus must be true or false`);
|
|
721
818
|
if (bp.org !== undefined && (typeof bp.org !== "string" || !bp.org.trim()))
|
|
722
819
|
throw new Error(`${where}.org must be an org id (a key of orgs:)`);
|
|
820
|
+
const stackNamespace = checkStackNamespace(
|
|
821
|
+
`${where}.stack_namespace`,
|
|
822
|
+
bp.stack_namespace,
|
|
823
|
+
);
|
|
723
824
|
return {
|
|
724
825
|
name,
|
|
725
826
|
repos: blueprintStrings(where, "repos", bp.repos),
|
|
@@ -727,7 +828,42 @@ function resolveBlueprint(
|
|
|
727
828
|
autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
|
|
728
829
|
focus: bp.focus === true,
|
|
729
830
|
...(typeof bp.org === "string" ? { org: bp.org.trim() } : {}),
|
|
831
|
+
...(stackNamespace ? { stackNamespace } : {}),
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Each blueprint workspace's `stack_namespace`, by workspace name (the
|
|
837
|
+
* single `defaults.workspace` is `default`). Reads only that key, so a
|
|
838
|
+
* blueprint malformed elsewhere still gives its namespaces: the stacks'
|
|
839
|
+
* Compose projects never fall back to another name because of an unrelated
|
|
840
|
+
* error. A bad `stack_namespace` itself throws, naming the key.
|
|
841
|
+
*/
|
|
842
|
+
export function readBlueprintStackNamespaces(
|
|
843
|
+
config: DenConfig = readDenConfig(),
|
|
844
|
+
): Record<string, string> {
|
|
845
|
+
const out: Record<string, string> = {};
|
|
846
|
+
const defaults: unknown = config.defaults;
|
|
847
|
+
if (!defaults || typeof defaults !== "object" || Array.isArray(defaults))
|
|
848
|
+
return out;
|
|
849
|
+
const { workspace, workspaces } = defaults as Record<string, unknown>;
|
|
850
|
+
const take = (where: string, name: string, raw: unknown) => {
|
|
851
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
852
|
+
const ns = checkStackNamespace(
|
|
853
|
+
`${where}.stack_namespace`,
|
|
854
|
+
(raw as Record<string, unknown>).stack_namespace,
|
|
855
|
+
);
|
|
856
|
+
if (ns) out[name] = ns;
|
|
730
857
|
};
|
|
858
|
+
if (
|
|
859
|
+
workspaces &&
|
|
860
|
+
typeof workspaces === "object" &&
|
|
861
|
+
!Array.isArray(workspaces)
|
|
862
|
+
)
|
|
863
|
+
for (const [name, raw] of Object.entries(workspaces))
|
|
864
|
+
take(`defaults.workspaces.${name}`, name, raw);
|
|
865
|
+
else if (workspace != null) take("defaults.workspace", "default", workspace);
|
|
866
|
+
return out;
|
|
731
867
|
}
|
|
732
868
|
|
|
733
869
|
/**
|
|
@@ -12,9 +12,17 @@
|
|
|
12
12
|
* publish: true # false: another proxy fronts the edge
|
|
13
13
|
* http_port: 80
|
|
14
14
|
* https_port: 443
|
|
15
|
+
* tls: none # http only; or (see tls.ts):
|
|
16
|
+
* # tls: { strategy: mkcert } # own local CA, per machine
|
|
17
|
+
* # tls: { strategy: mkcert, ca: ~/path/to/ca } # an existing CA; never minted
|
|
18
|
+
* # tls: { strategy: files, include: edge/dynamic, mounts: {...} }
|
|
19
|
+
* # # included config holds the certs; optional ca: the CA that
|
|
20
|
+
* # # signed them, trusted by health checks
|
|
21
|
+
* # legacy (still read, mapped to tls, deprecated):
|
|
22
|
+
* # caroot: <ca> → tls: { strategy: mkcert, ca: <ca> }
|
|
23
|
+
* # issue_cert: false → tls: { strategy: files }
|
|
24
|
+
* # neither, no tls → mkcert with a local CA, and a warning
|
|
15
25
|
* # opt-ins (all off by default):
|
|
16
|
-
* caroot: ~/path/to/mkcert-ca # use an existing CA; never mint one
|
|
17
|
-
* issue_cert: false # included config holds the certs
|
|
18
26
|
* https_redirect: false # included config redirects per name
|
|
19
27
|
* docker_labels: { network: apps-network }
|
|
20
28
|
* include: edge/dynamic # dynamic-config files, repo-relative
|
|
@@ -35,7 +43,8 @@ import { cachedRead } from "../read-cache.js";
|
|
|
35
43
|
import { readManifestStacks } from "../stacks/config.js";
|
|
36
44
|
import { getActiveWorkspaces } from "../environment-manager/workspaces.js";
|
|
37
45
|
import { assertDomain } from "./render.js";
|
|
38
|
-
import
|
|
46
|
+
import { tlsStrategyIds } from "./tls.js";
|
|
47
|
+
import type { EdgeConfig, EdgeTls } from "./types.js";
|
|
39
48
|
|
|
40
49
|
import { product } from "../product.js";
|
|
41
50
|
export const DEFAULT_TRAEFIK_IMAGE = "traefik:v3.7.17";
|
|
@@ -51,6 +60,7 @@ interface RawEdge {
|
|
|
51
60
|
publish?: boolean;
|
|
52
61
|
http_port?: number;
|
|
53
62
|
https_port?: number;
|
|
63
|
+
tls?: unknown;
|
|
54
64
|
caroot?: string;
|
|
55
65
|
issue_cert?: boolean;
|
|
56
66
|
https_redirect?: boolean;
|
|
@@ -120,6 +130,108 @@ function mounts(v: RawEdge["mounts"], root: string): Record<string, string> {
|
|
|
120
130
|
return out;
|
|
121
131
|
}
|
|
122
132
|
|
|
133
|
+
const TLS_KEYS: Record<string, string[]> = {
|
|
134
|
+
none: ["strategy"],
|
|
135
|
+
mkcert: ["strategy", "ca"],
|
|
136
|
+
files: ["strategy", "ca", "include", "mounts"],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
interface ResolvedTls {
|
|
140
|
+
tls: EdgeTls;
|
|
141
|
+
from: EdgeConfig["tlsFrom"];
|
|
142
|
+
/** `files`' own include and mounts (raw, resolved by the caller). */
|
|
143
|
+
include?: string;
|
|
144
|
+
mounts?: Record<string, string>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `edge.tls` (user.yml's replaces the team's), or the legacy keys mapped to
|
|
149
|
+
* it (EXT-1 rule 6): `issue_cert: false` → files, `caroot` → mkcert with
|
|
150
|
+
* `ca`, neither → mkcert with a local CA (implicit, warned).
|
|
151
|
+
*/
|
|
152
|
+
function readTls(team: RawEdge, user: RawEdge, root: string): ResolvedTls {
|
|
153
|
+
const raw = user.tls ?? team.tls;
|
|
154
|
+
const caroot = user.caroot ?? team.caroot;
|
|
155
|
+
const issue = user.issue_cert ?? team.issue_cert;
|
|
156
|
+
const ids = tlsStrategyIds();
|
|
157
|
+
const builtIns = ids.join(", ");
|
|
158
|
+
if (raw === undefined) {
|
|
159
|
+
const ca = caroot ? resolvePath(caroot, root) : null;
|
|
160
|
+
return {
|
|
161
|
+
tls:
|
|
162
|
+
issue === false
|
|
163
|
+
? { strategy: "files", ca }
|
|
164
|
+
: { strategy: "mkcert", ca },
|
|
165
|
+
from: caroot === undefined && issue === undefined ? "implicit" : "legacy",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const legacy = [
|
|
169
|
+
...(caroot !== undefined ? ["caroot"] : []),
|
|
170
|
+
...(issue !== undefined ? ["issue_cert"] : []),
|
|
171
|
+
];
|
|
172
|
+
if (legacy.length)
|
|
173
|
+
throw new Error(
|
|
174
|
+
`edge.tls replaces edge.${legacy.join(" and edge.")}; remove ${legacy.length > 1 ? "them" : "it"} ` +
|
|
175
|
+
"(caroot is tls.ca, issue_cert: false is tls.strategy: files).",
|
|
176
|
+
);
|
|
177
|
+
const obj: Record<string, unknown> =
|
|
178
|
+
typeof raw === "string"
|
|
179
|
+
? { strategy: raw }
|
|
180
|
+
: raw && typeof raw === "object" && !Array.isArray(raw)
|
|
181
|
+
? (raw as Record<string, unknown>)
|
|
182
|
+
: {};
|
|
183
|
+
const id = obj.strategy;
|
|
184
|
+
if (typeof id !== "string" || !id)
|
|
185
|
+
throw new Error(
|
|
186
|
+
`edge.tls must be a strategy id or { strategy: <id>, ... }; the built-ins are ${builtIns}`,
|
|
187
|
+
);
|
|
188
|
+
if (!ids.includes(id))
|
|
189
|
+
throw new Error(
|
|
190
|
+
`edge.tls.strategy "${id}" is not a known TLS strategy; the built-ins are ${builtIns}`,
|
|
191
|
+
);
|
|
192
|
+
const allowed = TLS_KEYS[id] ?? ["strategy"];
|
|
193
|
+
for (const k of Object.keys(obj))
|
|
194
|
+
if (!allowed.includes(k))
|
|
195
|
+
throw new Error(
|
|
196
|
+
`edge.tls.${k} does not apply to strategy ${id} (it takes ${allowed.join(", ")})`,
|
|
197
|
+
);
|
|
198
|
+
let ca: string | null = null;
|
|
199
|
+
if (obj.ca !== undefined) {
|
|
200
|
+
if (typeof obj.ca !== "string" || !obj.ca.trim())
|
|
201
|
+
throw new Error(
|
|
202
|
+
"edge.tls.ca must be the path to an existing CA directory",
|
|
203
|
+
);
|
|
204
|
+
ca = resolvePath(obj.ca, root);
|
|
205
|
+
}
|
|
206
|
+
if (
|
|
207
|
+
obj.include !== undefined &&
|
|
208
|
+
(typeof obj.include !== "string" || !obj.include)
|
|
209
|
+
)
|
|
210
|
+
throw new Error(
|
|
211
|
+
"edge.tls.include must be a directory of dynamic-config files",
|
|
212
|
+
);
|
|
213
|
+
if (
|
|
214
|
+
obj.mounts !== undefined &&
|
|
215
|
+
(!obj.mounts || typeof obj.mounts !== "object" || Array.isArray(obj.mounts))
|
|
216
|
+
)
|
|
217
|
+
throw new Error("edge.tls.mounts must map host paths to container paths");
|
|
218
|
+
const tls: EdgeTls =
|
|
219
|
+
id === "none"
|
|
220
|
+
? { strategy: "none" }
|
|
221
|
+
: { strategy: id as "mkcert" | "files", ca };
|
|
222
|
+
return {
|
|
223
|
+
tls,
|
|
224
|
+
from: "explicit",
|
|
225
|
+
include: obj.include as string | undefined,
|
|
226
|
+
mounts: obj.mounts as Record<string, string> | undefined,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** True when the org config has an `edge:` block (an edge is configured). */
|
|
231
|
+
export function hasEdgeBlock(root: string = getDistRoot()): boolean {
|
|
232
|
+
return layers(root).team.edge !== undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
123
235
|
/** The web UI's route name: the brand name as a slug ("Den" → "den"). */
|
|
124
236
|
function webRouteName(team: Record<string, unknown>): string {
|
|
125
237
|
const name = (team.brand as { name?: string } | undefined)?.name?.trim();
|
|
@@ -188,6 +300,22 @@ function computeEdgeConfig(root: string): EdgeConfig {
|
|
|
188
300
|
throw new Error("edge.aliases must be a list of domains");
|
|
189
301
|
aliases.forEach(assertDomain);
|
|
190
302
|
const publish = user.publish ?? team.publish ?? true;
|
|
303
|
+
const tls = readTls(team, user, root);
|
|
304
|
+
if (tls.include !== undefined && team.include !== undefined)
|
|
305
|
+
throw new Error(
|
|
306
|
+
"edge.include and edge.tls.include are both set; keep one (tls.include for files' certs)",
|
|
307
|
+
);
|
|
308
|
+
const include = tls.include ?? team.include;
|
|
309
|
+
// The state dir the config names (or the XDG default), DEN_EDGE_DIR aside.
|
|
310
|
+
const configuredStateDir = expandHome(
|
|
311
|
+
user.state_dir ??
|
|
312
|
+
team.state_dir ??
|
|
313
|
+
path.join(
|
|
314
|
+
process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"),
|
|
315
|
+
team.org,
|
|
316
|
+
"edge",
|
|
317
|
+
),
|
|
318
|
+
);
|
|
191
319
|
return {
|
|
192
320
|
org: team.org,
|
|
193
321
|
domain: team.domain,
|
|
@@ -214,26 +342,24 @@ function computeEdgeConfig(root: string): EdgeConfig {
|
|
|
214
342
|
),
|
|
215
343
|
}
|
|
216
344
|
: null,
|
|
217
|
-
stateDir:
|
|
218
|
-
process.env.DEN_EDGE_DIR
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
caroot:
|
|
229
|
-
(user.caroot ?? team.caroot)
|
|
230
|
-
? resolvePath((user.caroot ?? team.caroot)!, root)
|
|
231
|
-
: null,
|
|
232
|
-
issueCert: (user.issue_cert ?? team.issue_cert) !== false,
|
|
345
|
+
stateDir:
|
|
346
|
+
process.env.DEN_EDGE_DIR !== undefined
|
|
347
|
+
? expandHome(process.env.DEN_EDGE_DIR)
|
|
348
|
+
: configuredStateDir,
|
|
349
|
+
// Set only when DEN_EDGE_DIR overrides it: the dir the config names.
|
|
350
|
+
...(process.env.DEN_EDGE_DIR !== undefined &&
|
|
351
|
+
expandHome(process.env.DEN_EDGE_DIR) !== configuredStateDir
|
|
352
|
+
? { defaultStateDir: configuredStateDir }
|
|
353
|
+
: {}),
|
|
354
|
+
tls: tls.tls,
|
|
355
|
+
tlsFrom: tls.from,
|
|
233
356
|
httpsRedirect: (user.https_redirect ?? team.https_redirect) !== false,
|
|
234
357
|
dockerLabels: dockerLabels(user.docker_labels ?? team.docker_labels),
|
|
235
|
-
include:
|
|
236
|
-
mounts: mounts(
|
|
358
|
+
include: include ? resolvePath(include, root) : null,
|
|
359
|
+
mounts: mounts(
|
|
360
|
+
{ ...(team.mounts ?? {}), ...(user.mounts ?? {}), ...(tls.mounts ?? {}) },
|
|
361
|
+
root,
|
|
362
|
+
),
|
|
237
363
|
networks: [
|
|
238
364
|
...new Set([
|
|
239
365
|
...list(team.networks, "networks"),
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Running the edge's CLIs (docker, mkcert): one place, one error shape. */
|
|
2
|
+
import { spawnSync } from "child_process";
|
|
3
|
+
|
|
4
|
+
export function sh(
|
|
5
|
+
cmd: string,
|
|
6
|
+
args: string[],
|
|
7
|
+
opts: { env?: NodeJS.ProcessEnv; inherit?: boolean } = {},
|
|
8
|
+
) {
|
|
9
|
+
const r = spawnSync(cmd, args, {
|
|
10
|
+
encoding: "utf-8",
|
|
11
|
+
env: { ...process.env, ...opts.env },
|
|
12
|
+
stdio: opts.inherit ? "inherit" : "pipe",
|
|
13
|
+
});
|
|
14
|
+
if (r.error) throw new Error(`${cmd} is not installed (${r.error.message})`);
|
|
15
|
+
return {
|
|
16
|
+
code: r.status ?? 1,
|
|
17
|
+
out: (r.stdout ?? "").trim(),
|
|
18
|
+
err: (r.stderr ?? "").trim(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function must(
|
|
23
|
+
cmd: string,
|
|
24
|
+
args: string[],
|
|
25
|
+
opts: { env?: NodeJS.ProcessEnv } = {},
|
|
26
|
+
): string {
|
|
27
|
+
const r = sh(cmd, args, opts);
|
|
28
|
+
if (r.code !== 0)
|
|
29
|
+
throw new Error(`${cmd} ${args.join(" ")} failed:\n${r.err || r.out}`);
|
|
30
|
+
return r.out;
|
|
31
|
+
}
|