@monster-orange/den 0.2.8 → 0.4.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 +1731 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +730 -6
- 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 +12 -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/agents/default-pack/be-dev.md +20 -0
- package/src/shared/agents/default-pack/devops.md +14 -0
- package/src/shared/agents/default-pack/fe-dev.md +20 -0
- package/src/shared/agents/default-pack/orchestrator.md +16 -0
- package/src/shared/agents/default-pack/planner.md +17 -0
- package/src/shared/agents/default-pack/reviewer.md +14 -0
- package/src/shared/agents/default-pack/ux-designer.md +15 -0
- package/src/shared/agents/den-claude.ts +173 -0
- package/src/shared/agents/generic.ts +52 -0
- package/src/shared/agents/personas.ts +218 -0
- package/src/shared/agents/records.ts +118 -0
- package/src/shared/agents/runtime.ts +192 -0
- package/src/shared/agents/runtimes.ts +47 -0
- 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 +257 -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/engine.ts +23 -0
- 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/environment-manager/workspaces.ts +19 -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/secret-env.ts +70 -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/sessions/client.ts +277 -0
- package/src/shared/sessions/den-pty.ts +97 -0
- package/src/shared/sessions/env.ts +79 -0
- package/src/shared/sessions/host.ts +667 -0
- package/src/shared/sessions/names.ts +85 -0
- package/src/shared/sessions/paths.ts +27 -0
- package/src/shared/sessions/protocol.ts +72 -0
- package/src/shared/sessions/pty.ts +141 -0
- package/src/shared/sessions/ring-buffer.ts +160 -0
- package/src/shared/sessions/types.ts +81 -0
- 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/agents-command.ts +812 -0
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +260 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/env-probe.ts +266 -0
- package/src/ui/cli/flag-parser.ts +18 -5
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +580 -60
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/sessions-command.ts +718 -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 +145 -20
- package/src/ui/mcp/server.ts +27 -2
- package/src/ui/mcp/tools.ts +94 -12
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/audit.ts +35 -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 +539 -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 +37 -3
- 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 +48 -18
- 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/den-config.ts
CHANGED
|
@@ -243,6 +243,11 @@ export interface DenConfig {
|
|
|
243
243
|
* only; `actions` opts in to named actions. See readMcpConfig.
|
|
244
244
|
*/
|
|
245
245
|
mcp?: McpConfig;
|
|
246
|
+
/**
|
|
247
|
+
* Agents: `max_concurrent` caps the sessions running at once in this
|
|
248
|
+
* distribution (default 4). See readAgentsConfig.
|
|
249
|
+
*/
|
|
250
|
+
agents?: AgentsConfig;
|
|
246
251
|
/**
|
|
247
252
|
* The web UI: `port` (read by edge/config.ts readWebPort) and `service`,
|
|
248
253
|
* the launchd service `<cli> service` installs. See readWebServiceConfig.
|
|
@@ -341,6 +346,33 @@ export interface UserConfig {
|
|
|
341
346
|
density?: string;
|
|
342
347
|
panelWidth?: number;
|
|
343
348
|
};
|
|
349
|
+
/**
|
|
350
|
+
* Web UI appearance: theme id and mode (system | dark | light), set from
|
|
351
|
+
* the header. See src/ui/server/appearance-prefs.ts.
|
|
352
|
+
*/
|
|
353
|
+
appearance?: { theme?: string; mode?: string };
|
|
354
|
+
/**
|
|
355
|
+
* The web UI's language (R19): en (the default, never stored), ja or es.
|
|
356
|
+
* See src/ui/server/ui-language.ts.
|
|
357
|
+
*/
|
|
358
|
+
language?: string;
|
|
359
|
+
/**
|
|
360
|
+
* The rail's pins and collapse, and (R21 Navigation) the pages it hides
|
|
361
|
+
* and their order (src/ui/server/shell-prefs.ts).
|
|
362
|
+
*/
|
|
363
|
+
shell?: {
|
|
364
|
+
pins?: string[];
|
|
365
|
+
collapsed?: boolean;
|
|
366
|
+
hidden?: string[];
|
|
367
|
+
order?: string[];
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* What the web UI and `<cli> status` show: `scope` is "all" (every active
|
|
371
|
+
* workspace, the default) or one active workspace's name. A view filter
|
|
372
|
+
* only: the focused workspace stays the default target for commands. See
|
|
373
|
+
* src/shared/environment-manager/scope.ts.
|
|
374
|
+
*/
|
|
375
|
+
view?: { scope?: string };
|
|
344
376
|
}
|
|
345
377
|
|
|
346
378
|
const ALL_ENVIRONMENTS: Environment[] = [
|
|
@@ -403,12 +435,34 @@ export function mergeUserConfig(patch: Partial<UserConfig>): UserConfig {
|
|
|
403
435
|
return next;
|
|
404
436
|
}
|
|
405
437
|
|
|
438
|
+
/**
|
|
439
|
+
* A scaffold or docs placeholder rather than a real org: `your-github-org`,
|
|
440
|
+
* `your-org`, `<your-org>`, `<slug>`, `example-org`. Den treats it as unset.
|
|
441
|
+
*/
|
|
442
|
+
export function isPlaceholderGithubOrg(org: string): boolean {
|
|
443
|
+
return /^(your[-_]?(github[-_]?)?org|<.*>|example[-_]?org)$/i.test(
|
|
444
|
+
org.trim(),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The configured `github_org`, or null when it is unset or still a
|
|
450
|
+
* placeholder ("not configured": Den runs no gh for it).
|
|
451
|
+
*/
|
|
452
|
+
export function configuredGithubOrg(
|
|
453
|
+
config: DenConfig = readDenConfig(),
|
|
454
|
+
): string | null {
|
|
455
|
+
const org =
|
|
456
|
+
typeof config.github_org === "string" ? config.github_org.trim() : "";
|
|
457
|
+
return org && !isPlaceholderGithubOrg(org) ? org : null;
|
|
458
|
+
}
|
|
459
|
+
|
|
406
460
|
/**
|
|
407
461
|
* The GitHub org managed repos clone from. Den has no default: an org that
|
|
408
462
|
* needs one sets `github_org` in its config.
|
|
409
463
|
*/
|
|
410
464
|
export function requireGithubOrg(config: DenConfig = readDenConfig()): string {
|
|
411
|
-
const org = config
|
|
465
|
+
const org = configuredGithubOrg(config);
|
|
412
466
|
if (!org)
|
|
413
467
|
throw new Error(
|
|
414
468
|
`github_org is not set: add \`github_org: <your-org>\` to ${DEN_CONFIG_PATH}`,
|
|
@@ -448,7 +502,19 @@ export function assertDenConfigValid(): void {
|
|
|
448
502
|
}
|
|
449
503
|
checked = { key, error };
|
|
450
504
|
}
|
|
451
|
-
if (checked.error) throw new ConfigError(checked.error.
|
|
505
|
+
if (checked.error) throw new ConfigError(checked.error.issues);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let orgsWaived = false;
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* For `down` only (0.3.0-dev.40): from now on in this process, a config
|
|
512
|
+
* whose `orgs:` fails validation reads as one with no org data instead of
|
|
513
|
+
* throwing, so stopping is never stranded by it. Every other key is read as
|
|
514
|
+
* usual; a file that does not parse still throws.
|
|
515
|
+
*/
|
|
516
|
+
export function waiveInvalidOrgs(): void {
|
|
517
|
+
orgsWaived = true;
|
|
452
518
|
}
|
|
453
519
|
|
|
454
520
|
/**
|
|
@@ -463,7 +529,13 @@ export function readDenConfig(): DenConfig {
|
|
|
463
529
|
const parsed = yaml.parse(raw) as Partial<DenConfig> | null;
|
|
464
530
|
// ORG-1d keys (orgs.<id>.sources / .repos) are checked here, where a bad
|
|
465
531
|
// value throws naming its key; configs without them are not touched.
|
|
466
|
-
|
|
532
|
+
let orgs = parsed?.orgs;
|
|
533
|
+
try {
|
|
534
|
+
validateOrgSources(orgs, product().configFile);
|
|
535
|
+
} catch (e) {
|
|
536
|
+
if (!orgsWaived || !isConfigError(e)) throw e;
|
|
537
|
+
orgs = undefined;
|
|
538
|
+
}
|
|
467
539
|
// `environments` may be malformed (scalar/object) in a hand-edited file —
|
|
468
540
|
// fall back to the default set rather than throwing on .filter below.
|
|
469
541
|
const envs = Array.isArray(parsed?.environments)
|
|
@@ -476,7 +548,7 @@ export function readDenConfig(): DenConfig {
|
|
|
476
548
|
production_envs: parsed?.production_envs,
|
|
477
549
|
repos_root: parsed?.repos_root,
|
|
478
550
|
github_org: parsed?.github_org,
|
|
479
|
-
orgs
|
|
551
|
+
orgs,
|
|
480
552
|
graph: parsed?.graph,
|
|
481
553
|
org: parsed?.org,
|
|
482
554
|
applications: parsed?.applications,
|
|
@@ -485,6 +557,7 @@ export function readDenConfig(): DenConfig {
|
|
|
485
557
|
env_health_urls: parsed?.env_health_urls,
|
|
486
558
|
security: parsed?.security,
|
|
487
559
|
mcp: parsed?.mcp,
|
|
560
|
+
agents: parsed?.agents,
|
|
488
561
|
web: parsed?.web,
|
|
489
562
|
test_accounts: parsed?.test_accounts,
|
|
490
563
|
defaults: parsed?.defaults,
|
|
@@ -662,6 +735,116 @@ export function readMcpConfig(
|
|
|
662
735
|
};
|
|
663
736
|
}
|
|
664
737
|
|
|
738
|
+
// ── Agents (`<cli> sessions`, `<cli> agents`) ─────────────────────────────────
|
|
739
|
+
|
|
740
|
+
/** `agents:` in the org config. */
|
|
741
|
+
export interface AgentsConfig {
|
|
742
|
+
/** Sessions that may run at once (default 4). */
|
|
743
|
+
max_concurrent?: number;
|
|
744
|
+
/**
|
|
745
|
+
* Persona packs (AG-2a): directories of persona files, relative to the
|
|
746
|
+
* distribution root. Den's default pack is always read first; a pack's
|
|
747
|
+
* persona replaces one of the same id.
|
|
748
|
+
*/
|
|
749
|
+
persona_packs?: string[];
|
|
750
|
+
/**
|
|
751
|
+
* Any other key is an agent id: `agents.<id>.grants` widens that agent's
|
|
752
|
+
* MCP scope to other workspaces (AG spec §5b). Config only: an agent
|
|
753
|
+
* never grants itself one.
|
|
754
|
+
*/
|
|
755
|
+
[agentId: string]: unknown;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
export const DEFAULT_AGENTS_MAX_CONCURRENT = 4;
|
|
759
|
+
|
|
760
|
+
/** The keys of `agents:` that are not an agent id. */
|
|
761
|
+
export const AGENTS_RESERVED_KEYS = ["max_concurrent", "persona_packs"];
|
|
762
|
+
|
|
763
|
+
/** One agent's own block, `agents.<id>`. */
|
|
764
|
+
export interface AgentGrantsConfig {
|
|
765
|
+
/** Workspaces beyond its own that its MCP serves. */
|
|
766
|
+
grants: string[];
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** `agents:` with its defaults applied. */
|
|
770
|
+
export interface ResolvedAgentsConfig {
|
|
771
|
+
maxConcurrent: number;
|
|
772
|
+
/** As configured (relative to the distribution root). */
|
|
773
|
+
personaPacks: string[];
|
|
774
|
+
/** `agents.<id>` blocks, by agent id. */
|
|
775
|
+
agents: Record<string, AgentGrantsConfig>;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** An agent id or workspace name: what WORKSPACE_NAME_RE accepts. */
|
|
779
|
+
const AGENT_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
780
|
+
|
|
781
|
+
const isMapping = (v: unknown): v is Record<string, unknown> =>
|
|
782
|
+
v !== null && typeof v === "object" && !Array.isArray(v);
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* The effective agents config. Anything malformed throws, naming the key:
|
|
786
|
+
* a typo must not lift the cap, drop a pack or widen a grant.
|
|
787
|
+
*/
|
|
788
|
+
export function readAgentsConfig(
|
|
789
|
+
config: DenConfig = readDenConfig(),
|
|
790
|
+
): ResolvedAgentsConfig {
|
|
791
|
+
const raw = (config.agents ?? {}) as unknown;
|
|
792
|
+
if (!isMapping(raw))
|
|
793
|
+
throw new Error(`agents: must be a mapping (got ${JSON.stringify(raw)})`);
|
|
794
|
+
const out: ResolvedAgentsConfig = {
|
|
795
|
+
maxConcurrent: DEFAULT_AGENTS_MAX_CONCURRENT,
|
|
796
|
+
personaPacks: [],
|
|
797
|
+
agents: {},
|
|
798
|
+
};
|
|
799
|
+
const max = raw.max_concurrent;
|
|
800
|
+
if (max !== undefined) {
|
|
801
|
+
if (typeof max !== "number" || !Number.isInteger(max) || max < 1)
|
|
802
|
+
throw new Error(
|
|
803
|
+
`agents.max_concurrent: must be a whole number of at least 1 (got ${JSON.stringify(max)})`,
|
|
804
|
+
);
|
|
805
|
+
out.maxConcurrent = max;
|
|
806
|
+
}
|
|
807
|
+
const packs = raw.persona_packs;
|
|
808
|
+
if (packs !== undefined && packs !== null) {
|
|
809
|
+
if (!Array.isArray(packs))
|
|
810
|
+
throw new Error(
|
|
811
|
+
`agents.persona_packs: must be a list of paths (got ${JSON.stringify(packs)})`,
|
|
812
|
+
);
|
|
813
|
+
packs.forEach((p, i) => {
|
|
814
|
+
if (typeof p !== "string" || !p.trim())
|
|
815
|
+
throw new Error(
|
|
816
|
+
`agents.persona_packs[${i}]: must be a non-empty path (got ${JSON.stringify(p)})`,
|
|
817
|
+
);
|
|
818
|
+
});
|
|
819
|
+
out.personaPacks = packs as string[];
|
|
820
|
+
}
|
|
821
|
+
for (const [id, block] of Object.entries(raw)) {
|
|
822
|
+
if (AGENTS_RESERVED_KEYS.includes(id)) continue;
|
|
823
|
+
const where = `agents.${id}`;
|
|
824
|
+
if (!AGENT_KEY_RE.test(id) || !isMapping(block))
|
|
825
|
+
throw new Error(
|
|
826
|
+
`${where}: unknown key (agents: takes max_concurrent, persona_packs, ` +
|
|
827
|
+
`or an agent id mapping to { grants: [...] })`,
|
|
828
|
+
);
|
|
829
|
+
for (const k of Object.keys(block))
|
|
830
|
+
if (k !== "grants")
|
|
831
|
+
throw new Error(`${where}.${k}: unknown key (an agent takes grants)`);
|
|
832
|
+
const grants = block.grants ?? [];
|
|
833
|
+
if (!Array.isArray(grants))
|
|
834
|
+
throw new Error(
|
|
835
|
+
`${where}.grants: must be a list of workspace names (got ${JSON.stringify(grants)})`,
|
|
836
|
+
);
|
|
837
|
+
grants.forEach((g, i) => {
|
|
838
|
+
if (typeof g !== "string" || !AGENT_KEY_RE.test(g))
|
|
839
|
+
throw new Error(
|
|
840
|
+
`${where}.grants[${i}]: ${JSON.stringify(g)} is not a workspace name`,
|
|
841
|
+
);
|
|
842
|
+
});
|
|
843
|
+
out.agents[id] = { grants: [...new Set(grants as string[])] };
|
|
844
|
+
}
|
|
845
|
+
return out;
|
|
846
|
+
}
|
|
847
|
+
|
|
665
848
|
// ── Web UI service (`<cli> service`) ──────────────────────────────────────────
|
|
666
849
|
|
|
667
850
|
/** `web:` in the org config. */
|
|
@@ -680,6 +863,11 @@ export interface ResolvedWorkspaceBlueprint {
|
|
|
680
863
|
focus: boolean;
|
|
681
864
|
/** `org:` (ORG-1a): the org this workspace belongs to, when the blueprint names one. */
|
|
682
865
|
org?: string;
|
|
866
|
+
/**
|
|
867
|
+
* `stack_namespace:`: the Compose project suffix this workspace's stacks
|
|
868
|
+
* run under, in place of its own name (see stacks/namespace.ts).
|
|
869
|
+
*/
|
|
870
|
+
stackNamespace?: string;
|
|
683
871
|
}
|
|
684
872
|
|
|
685
873
|
/** Which blueprint shape the distribution uses, and its workspaces. */
|
|
@@ -688,7 +876,32 @@ export interface WorkspaceBlueprints {
|
|
|
688
876
|
workspaces: ResolvedWorkspaceBlueprint[];
|
|
689
877
|
}
|
|
690
878
|
|
|
691
|
-
const BLUEPRINT_KEYS = [
|
|
879
|
+
const BLUEPRINT_KEYS = [
|
|
880
|
+
"branch",
|
|
881
|
+
"repos",
|
|
882
|
+
"autostart_off",
|
|
883
|
+
"org",
|
|
884
|
+
"stack_namespace",
|
|
885
|
+
];
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* A stack namespace (and a Compose project suffix): lowercase letters,
|
|
889
|
+
* digits and dashes, not starting or ending with a dash.
|
|
890
|
+
*/
|
|
891
|
+
export const STACK_NAMESPACE_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
892
|
+
|
|
893
|
+
/** `v` as a stack namespace, or undefined when unset; throws naming `key`. */
|
|
894
|
+
export function checkStackNamespace(
|
|
895
|
+
key: string,
|
|
896
|
+
v: unknown,
|
|
897
|
+
): string | undefined {
|
|
898
|
+
if (v === undefined || v === null) return undefined;
|
|
899
|
+
if (typeof v !== "string" || !STACK_NAMESPACE_RE.test(v))
|
|
900
|
+
throw new Error(
|
|
901
|
+
`${key} must be lowercase letters, digits and dashes (got ${JSON.stringify(v)})`,
|
|
902
|
+
);
|
|
903
|
+
return v;
|
|
904
|
+
}
|
|
692
905
|
|
|
693
906
|
function blueprintStrings(where: string, key: string, v: unknown): string[] {
|
|
694
907
|
if (v === undefined) return [];
|
|
@@ -720,6 +933,10 @@ function resolveBlueprint(
|
|
|
720
933
|
throw new Error(`${where}.focus must be true or false`);
|
|
721
934
|
if (bp.org !== undefined && (typeof bp.org !== "string" || !bp.org.trim()))
|
|
722
935
|
throw new Error(`${where}.org must be an org id (a key of orgs:)`);
|
|
936
|
+
const stackNamespace = checkStackNamespace(
|
|
937
|
+
`${where}.stack_namespace`,
|
|
938
|
+
bp.stack_namespace,
|
|
939
|
+
);
|
|
723
940
|
return {
|
|
724
941
|
name,
|
|
725
942
|
repos: blueprintStrings(where, "repos", bp.repos),
|
|
@@ -727,9 +944,44 @@ function resolveBlueprint(
|
|
|
727
944
|
autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
|
|
728
945
|
focus: bp.focus === true,
|
|
729
946
|
...(typeof bp.org === "string" ? { org: bp.org.trim() } : {}),
|
|
947
|
+
...(stackNamespace ? { stackNamespace } : {}),
|
|
730
948
|
};
|
|
731
949
|
}
|
|
732
950
|
|
|
951
|
+
/**
|
|
952
|
+
* Each blueprint workspace's `stack_namespace`, by workspace name (the
|
|
953
|
+
* single `defaults.workspace` is `default`). Reads only that key, so a
|
|
954
|
+
* blueprint malformed elsewhere still gives its namespaces: the stacks'
|
|
955
|
+
* Compose projects never fall back to another name because of an unrelated
|
|
956
|
+
* error. A bad `stack_namespace` itself throws, naming the key.
|
|
957
|
+
*/
|
|
958
|
+
export function readBlueprintStackNamespaces(
|
|
959
|
+
config: DenConfig = readDenConfig(),
|
|
960
|
+
): Record<string, string> {
|
|
961
|
+
const out: Record<string, string> = {};
|
|
962
|
+
const defaults: unknown = config.defaults;
|
|
963
|
+
if (!defaults || typeof defaults !== "object" || Array.isArray(defaults))
|
|
964
|
+
return out;
|
|
965
|
+
const { workspace, workspaces } = defaults as Record<string, unknown>;
|
|
966
|
+
const take = (where: string, name: string, raw: unknown) => {
|
|
967
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
968
|
+
const ns = checkStackNamespace(
|
|
969
|
+
`${where}.stack_namespace`,
|
|
970
|
+
(raw as Record<string, unknown>).stack_namespace,
|
|
971
|
+
);
|
|
972
|
+
if (ns) out[name] = ns;
|
|
973
|
+
};
|
|
974
|
+
if (
|
|
975
|
+
workspaces &&
|
|
976
|
+
typeof workspaces === "object" &&
|
|
977
|
+
!Array.isArray(workspaces)
|
|
978
|
+
)
|
|
979
|
+
for (const [name, raw] of Object.entries(workspaces))
|
|
980
|
+
take(`defaults.workspaces.${name}`, name, raw);
|
|
981
|
+
else if (workspace != null) take("defaults.workspace", "default", workspace);
|
|
982
|
+
return out;
|
|
983
|
+
}
|
|
984
|
+
|
|
733
985
|
/**
|
|
734
986
|
* The distribution's workspace blueprint, validated: `defaults.workspace`
|
|
735
987
|
* (one workspace, `default`) or `defaults.workspaces` (named workspaces).
|
|
@@ -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
|
+
}
|