@monster-orange/den 0.2.7 → 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 +1655 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +619 -3
- 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/agent-actor.ts +38 -3
- 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 +63 -0
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +185 -7
- 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/catalog.ts +52 -1
- package/src/shared/environment-manager/default-workspace.ts +26 -3
- package/src/shared/environment-manager/git-remote.ts +49 -0
- 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/workspace-manager.ts +67 -25
- package/src/shared/environment-manager/workspace-org.ts +26 -11
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +328 -0
- 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 +24 -5
- package/src/ui/cli/clone-plan.ts +154 -23
- 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 +62 -3
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +649 -72
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/org-command.ts +12 -2
- 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/up-args.ts +15 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +298 -0
- package/src/ui/mcp/server.ts +70 -8
- package/src/ui/mcp/tools.ts +151 -24
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/audit.ts +90 -3
- 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 +29 -45
- 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 +590 -45
- 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 +15 -3
- 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 +24 -4
- package/src/ui/server/release-service.ts +25 -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 +55 -9
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- package/themes/den/mark.svg +6 -0
|
@@ -1,51 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Secrets-backed persistence for the local-dev sudo password.
|
|
3
3
|
*
|
|
4
|
-
* Goes through the configured secrets strategy (
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Goes through the configured secrets strategy (see src/shared/secrets),
|
|
5
|
+
* addressing the named ref `sudo`. There is no built-in location: a
|
|
6
|
+
* distribution that wants the saved password sets `secrets.refs.sudo` in its
|
|
7
|
+
* config. Without it, the vault path is "not configured" and only the
|
|
8
|
+
* interactive unlock is offered; nothing is spawned.
|
|
9
9
|
*
|
|
10
10
|
* All read helpers fail *soft*: if the backend isn't installed, the user isn't
|
|
11
|
-
* signed in, or the value is missing, they return
|
|
12
|
-
* fall back to the manual unlock
|
|
11
|
+
* signed in, the ref is not configured or the value is missing, they return
|
|
12
|
+
* null/false so the server can fall back to the manual unlock without
|
|
13
|
+
* surfacing CLI noise.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
|
-
import {
|
|
16
|
-
OP_API_KEYS_VAULT,
|
|
17
|
-
OP_DEV_KEYS_ITEM,
|
|
18
|
-
OP_DEN_SUDO_FIELD,
|
|
19
|
-
} from "../constants.js";
|
|
20
16
|
import {
|
|
21
17
|
getSecretsProvider,
|
|
22
18
|
namedSecretRef,
|
|
19
|
+
notConfiguredMessage,
|
|
20
|
+
readSecretsConfig,
|
|
21
|
+
secretsStatus,
|
|
22
|
+
SecretsNotConfiguredError,
|
|
23
23
|
type SecretRef,
|
|
24
|
+
type SecretsProviderName,
|
|
24
25
|
} from "../secrets/index.js";
|
|
26
|
+
import { product } from "../product.js";
|
|
25
27
|
|
|
26
|
-
/**
|
|
27
|
-
export const
|
|
28
|
-
vault: OP_API_KEYS_VAULT,
|
|
29
|
-
item: OP_DEV_KEYS_ITEM,
|
|
30
|
-
field: OP_DEN_SUDO_FIELD,
|
|
31
|
-
};
|
|
28
|
+
/** Name of the sudo ref under `secrets.refs`. */
|
|
29
|
+
export const SUDO_REF_NAME = "sudo";
|
|
32
30
|
|
|
33
|
-
/** The
|
|
34
|
-
function sudoRef(): SecretRef {
|
|
35
|
-
|
|
31
|
+
/** The configured sudo ref, or null when `secrets.refs.sudo` is not set. */
|
|
32
|
+
export function sudoRef(): SecretRef | null {
|
|
33
|
+
try {
|
|
34
|
+
return namedSecretRef(SUDO_REF_NAME, readSecretsConfig());
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
/** True when the
|
|
40
|
+
/** True when the sudo ref is configured AND its backend is ready. */
|
|
39
41
|
export async function isSecretsBackendReady(): Promise<boolean> {
|
|
42
|
+
if (!sudoRef()) return false;
|
|
40
43
|
return getSecretsProvider().isReady();
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
/** @deprecated use isSecretsBackendReady */
|
|
44
47
|
export const isOpSignedIn = isSecretsBackendReady;
|
|
45
48
|
|
|
46
|
-
/** Read the stored sudo password. Null when unavailable or empty. */
|
|
49
|
+
/** Read the stored sudo password. Null when unavailable, unconfigured or empty. */
|
|
47
50
|
export async function readSudoFromVault(): Promise<string | null> {
|
|
48
|
-
|
|
51
|
+
const ref = sudoRef();
|
|
52
|
+
if (!ref) return null;
|
|
53
|
+
return getSecretsProvider().tryGet(ref);
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/** Whether a non-empty sudo password is stored (without returning it). */
|
|
@@ -55,15 +60,72 @@ export async function hasSudoInVault(): Promise<boolean> {
|
|
|
55
60
|
|
|
56
61
|
/** Save (or overwrite) the sudo password. Throws on backend errors. */
|
|
57
62
|
export async function saveSudoToVault(password: string): Promise<void> {
|
|
58
|
-
|
|
63
|
+
const ref = sudoRef();
|
|
64
|
+
if (!ref)
|
|
65
|
+
throw new SecretsNotConfiguredError(notConfiguredMessage(SUDO_REF_NAME));
|
|
66
|
+
await getSecretsProvider().set(ref, password);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What the UI's unlock form may offer: `/api/sudo-password/vault-status`. */
|
|
70
|
+
export interface SudoVaultStatus {
|
|
71
|
+
/** The selected strategy id. */
|
|
72
|
+
backend: SecretsProviderName;
|
|
73
|
+
/** Name people see ("1Password"), for "Save to <label>". */
|
|
74
|
+
label: string;
|
|
75
|
+
/** `secrets.refs.sudo` is set for this backend. */
|
|
76
|
+
configured: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* The backend can work here, whether or not a sudo ref is configured
|
|
79
|
+
* (`configured` says that). With no ref: detect() only (a PATH check,
|
|
80
|
+
* nothing spawned), and only for an explicitly chosen provider; an unset
|
|
81
|
+
* provider is never probed (it is not needed). With a ref: detect() and
|
|
82
|
+
* signed in.
|
|
83
|
+
*/
|
|
84
|
+
backendAvailable: boolean;
|
|
85
|
+
/** Why it is not available (not configured, CLI not on PATH, signed out). */
|
|
86
|
+
reason?: string;
|
|
87
|
+
/** The distribution's org config file (e.g. config/acme.yml), for UI copy. */
|
|
88
|
+
configFile: string;
|
|
89
|
+
hasStored: boolean;
|
|
90
|
+
/** @deprecated alias of backendAvailable; removed in a later minor. */
|
|
91
|
+
opAvailable: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function sudoVaultStatus(): Promise<SudoVaultStatus> {
|
|
95
|
+
const cfg = readSecretsConfig();
|
|
96
|
+
const ref = sudoRef();
|
|
97
|
+
const pre = secretsStatus(cfg, process.env, { detect: false });
|
|
98
|
+
// Probe only what is needed or was chosen: a ref, or an explicit backend.
|
|
99
|
+
const probe = !!ref || (!pre.implicit && pre.backend !== "none");
|
|
100
|
+
const st = probe ? secretsStatus(cfg, process.env) : pre;
|
|
101
|
+
const out = (
|
|
102
|
+
backendAvailable: boolean,
|
|
103
|
+
hasStored: boolean,
|
|
104
|
+
reason?: string,
|
|
105
|
+
): SudoVaultStatus => ({
|
|
106
|
+
backend: st.backend,
|
|
107
|
+
label: st.label,
|
|
108
|
+
configured: !!ref,
|
|
109
|
+
backendAvailable,
|
|
110
|
+
...(reason ? { reason } : {}),
|
|
111
|
+
configFile: product().configFile,
|
|
112
|
+
hasStored,
|
|
113
|
+
opAvailable: backendAvailable,
|
|
114
|
+
});
|
|
115
|
+
if (!ref)
|
|
116
|
+
return out(
|
|
117
|
+
probe && st.detect.available,
|
|
118
|
+
false,
|
|
119
|
+
notConfiguredMessage(SUDO_REF_NAME),
|
|
120
|
+
);
|
|
121
|
+
if (!st.detect.available)
|
|
122
|
+
return out(false, false, `not available: ${st.detect.reason ?? ""}`.trim());
|
|
123
|
+
if (!(await getSecretsProvider().isReady()))
|
|
124
|
+
return out(false, false, `${st.label} is not signed in`);
|
|
125
|
+
return out(true, await hasSudoInVault());
|
|
59
126
|
}
|
|
60
127
|
|
|
61
128
|
/** Name of the active backend, for UI copy ("Save to Doppler"). */
|
|
62
129
|
export function secretsBackendLabel(): string {
|
|
63
|
-
|
|
64
|
-
return n === "1password"
|
|
65
|
-
? "1Password"
|
|
66
|
-
: n === "doppler"
|
|
67
|
-
? "Doppler"
|
|
68
|
-
: "environment";
|
|
130
|
+
return secretsStatus().label;
|
|
69
131
|
}
|
|
@@ -115,9 +115,13 @@ const localJwtProvider: TokenProvider = {
|
|
|
115
115
|
const l = cfg.auth?.localJwt;
|
|
116
116
|
if (!l) throw new Error(`auth.localJwt is not set for ${cfg.name}`);
|
|
117
117
|
const env = o.env ?? process.env;
|
|
118
|
+
// An explicit key the environment file names: not a default ref, so it
|
|
119
|
+
// resolves as it always has (env first, then the selected backend).
|
|
120
|
+
const fromEnv = env[l.secretKey];
|
|
121
|
+
const backend = fromEnv ? null : getSecretsProvider();
|
|
118
122
|
const secret =
|
|
119
|
-
|
|
120
|
-
(await
|
|
123
|
+
fromEnv ??
|
|
124
|
+
(await backend!.tryGet({
|
|
121
125
|
vault: "",
|
|
122
126
|
item: "",
|
|
123
127
|
field: l.secretKey,
|
|
@@ -125,7 +129,7 @@ const localJwtProvider: TokenProvider = {
|
|
|
125
129
|
}));
|
|
126
130
|
if (!secret) {
|
|
127
131
|
throw new Error(
|
|
128
|
-
`local-jwt signing secret ${l.secretKey} not found in env or the secrets backend`,
|
|
132
|
+
`local-jwt signing secret ${l.secretKey} not found in env or the ${backend?.name ?? "configured"} secrets backend (secrets.provider)`,
|
|
129
133
|
);
|
|
130
134
|
}
|
|
131
135
|
const ttl = l.ttlSeconds ?? 3600;
|
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
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A distribution config that fails validation (ORG-1d's `orgs:` checks, for
|
|
3
|
+
* now). It is the user's to fix, not a crash: the CLI prints it and exits 1
|
|
4
|
+
* before doing anything, and the web server answers it as
|
|
5
|
+
* `{ ok: false, error, code: "config_invalid" }` with status 422.
|
|
6
|
+
*
|
|
7
|
+
* The message names config keys (`orgs.initech.repos`), never values a
|
|
8
|
+
* secret could hide in.
|
|
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
|
+
|
|
22
|
+
export class ConfigError extends Error {
|
|
23
|
+
readonly code = "config_invalid";
|
|
24
|
+
/** Every problem found (0.3.0-dev.40), in config order. */
|
|
25
|
+
readonly issues: ConfigIssue[];
|
|
26
|
+
|
|
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
|
+
);
|
|
38
|
+
this.name = "ConfigError";
|
|
39
|
+
this.issues = list;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isConfigError(e: unknown): e is ConfigError {
|
|
44
|
+
return e instanceof ConfigError;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The web server's status for a ConfigError: the request was fine, the config is not. */
|
|
48
|
+
export const CONFIG_ERROR_STATUS = 422;
|
|
49
|
+
|
|
50
|
+
/** The JSON body for a ConfigError (the `{ok, error, code}` frame R26 extends). */
|
|
51
|
+
export function configErrorBody(e: ConfigError): {
|
|
52
|
+
ok: false;
|
|
53
|
+
error: string;
|
|
54
|
+
code: "config_invalid";
|
|
55
|
+
issues: Array<{ file: string; key: string; message: string }>;
|
|
56
|
+
} {
|
|
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
|
+
};
|
|
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
|
@@ -16,6 +16,8 @@ import type { Environment, ReleaseSourceConfig } from "./types/index.js";
|
|
|
16
16
|
import { getDistRoot } from "./paths.js";
|
|
17
17
|
import type { SecretsConfig } from "./secrets/index.js";
|
|
18
18
|
import type { OrgConfig } from "./org.js";
|
|
19
|
+
import { validateOrgSources, type WorkOrgsConfig } from "./org-sources.js";
|
|
20
|
+
import { ConfigError, isConfigError } from "./config-error.js";
|
|
19
21
|
import type { TestAccountsConfig } from "./auth/test-accounts.js";
|
|
20
22
|
|
|
21
23
|
import { product } from "./product.js";
|
|
@@ -194,11 +196,13 @@ export interface DenConfig {
|
|
|
194
196
|
/** GitHub org slug used to list org repos in the web UI (e.g. "Globex-Corp"). */
|
|
195
197
|
github_org?: string;
|
|
196
198
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
199
|
+
* Work orgs, keyed by org id: a display name and an optional colour. An
|
|
200
|
+
* entry with neither `sources` nor `repos` is the GitHub owner of its own
|
|
201
|
+
* name (ORG-1a). ORG-1d: `sources: [{host, owner|group}]` binds the org to
|
|
202
|
+
* git-host owners, and `repos: [...]` lists repos outright. See
|
|
203
|
+
* src/shared/org-sources.ts.
|
|
200
204
|
*/
|
|
201
|
-
orgs?:
|
|
205
|
+
orgs?: WorkOrgsConfig;
|
|
202
206
|
/** Derived-dependency-graph tuning (aliases + edge overrides). */
|
|
203
207
|
graph?: GraphConfig;
|
|
204
208
|
/**
|
|
@@ -337,6 +341,33 @@ export interface UserConfig {
|
|
|
337
341
|
density?: string;
|
|
338
342
|
panelWidth?: number;
|
|
339
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 };
|
|
340
371
|
}
|
|
341
372
|
|
|
342
373
|
const ALL_ENVIRONMENTS: Environment[] = [
|
|
@@ -399,12 +430,34 @@ export function mergeUserConfig(patch: Partial<UserConfig>): UserConfig {
|
|
|
399
430
|
return next;
|
|
400
431
|
}
|
|
401
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
|
+
|
|
402
455
|
/**
|
|
403
456
|
* The GitHub org managed repos clone from. Den has no default: an org that
|
|
404
457
|
* needs one sets `github_org` in its config.
|
|
405
458
|
*/
|
|
406
459
|
export function requireGithubOrg(config: DenConfig = readDenConfig()): string {
|
|
407
|
-
const org = config
|
|
460
|
+
const org = configuredGithubOrg(config);
|
|
408
461
|
if (!org)
|
|
409
462
|
throw new Error(
|
|
410
463
|
`github_org is not set: add \`github_org: <your-org>\` to ${DEN_CONFIG_PATH}`,
|
|
@@ -412,6 +465,53 @@ export function requireGithubOrg(config: DenConfig = readDenConfig()): string {
|
|
|
412
465
|
return org;
|
|
413
466
|
}
|
|
414
467
|
|
|
468
|
+
let checked: { key: string; error: ConfigError | null } | null = null;
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Throw the config's validation error (a ConfigError naming its keys), if it
|
|
472
|
+
* has one, without reading anything else. The CLI runs this before any
|
|
473
|
+
* command that touches orgs, the catalog, clones or releases, and the web
|
|
474
|
+
* server before every config-reading route, so a config that fails
|
|
475
|
+
* validation stops them all, not only those that happen to read `orgs:`.
|
|
476
|
+
* A missing file, or one that does not parse, is left to readDenConfig.
|
|
477
|
+
* Memoised on the file's mtime and size: the server checks per request.
|
|
478
|
+
*/
|
|
479
|
+
export function assertDenConfigValid(): void {
|
|
480
|
+
let key: string;
|
|
481
|
+
try {
|
|
482
|
+
const st = fs.statSync(DEN_CONFIG_PATH);
|
|
483
|
+
key = `${st.mtimeMs}:${st.size}`;
|
|
484
|
+
} catch {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (checked?.key !== key) {
|
|
488
|
+
let error: ConfigError | null = null;
|
|
489
|
+
try {
|
|
490
|
+
const parsed = yaml.parse(fs.readFileSync(DEN_CONFIG_PATH, "utf-8")) as {
|
|
491
|
+
orgs?: unknown;
|
|
492
|
+
} | null;
|
|
493
|
+
validateOrgSources(parsed?.orgs, product().configFile);
|
|
494
|
+
} catch (e) {
|
|
495
|
+
if (!isConfigError(e)) return;
|
|
496
|
+
error = e;
|
|
497
|
+
}
|
|
498
|
+
checked = { key, error };
|
|
499
|
+
}
|
|
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;
|
|
513
|
+
}
|
|
514
|
+
|
|
415
515
|
/**
|
|
416
516
|
* Read the effective den config, merging the shared deployment config with
|
|
417
517
|
* per-engineer overrides from config/local/user.yml.
|
|
@@ -422,6 +522,15 @@ export function readDenConfig(): DenConfig {
|
|
|
422
522
|
try {
|
|
423
523
|
const raw = fs.readFileSync(DEN_CONFIG_PATH, "utf-8");
|
|
424
524
|
const parsed = yaml.parse(raw) as Partial<DenConfig> | null;
|
|
525
|
+
// ORG-1d keys (orgs.<id>.sources / .repos) are checked here, where a bad
|
|
526
|
+
// value throws naming its key; configs without them are not touched.
|
|
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
|
+
}
|
|
425
534
|
// `environments` may be malformed (scalar/object) in a hand-edited file —
|
|
426
535
|
// fall back to the default set rather than throwing on .filter below.
|
|
427
536
|
const envs = Array.isArray(parsed?.environments)
|
|
@@ -434,7 +543,7 @@ export function readDenConfig(): DenConfig {
|
|
|
434
543
|
production_envs: parsed?.production_envs,
|
|
435
544
|
repos_root: parsed?.repos_root,
|
|
436
545
|
github_org: parsed?.github_org,
|
|
437
|
-
orgs
|
|
546
|
+
orgs,
|
|
438
547
|
graph: parsed?.graph,
|
|
439
548
|
org: parsed?.org,
|
|
440
549
|
applications: parsed?.applications,
|
|
@@ -638,6 +747,11 @@ export interface ResolvedWorkspaceBlueprint {
|
|
|
638
747
|
focus: boolean;
|
|
639
748
|
/** `org:` (ORG-1a): the org this workspace belongs to, when the blueprint names one. */
|
|
640
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;
|
|
641
755
|
}
|
|
642
756
|
|
|
643
757
|
/** Which blueprint shape the distribution uses, and its workspaces. */
|
|
@@ -646,7 +760,32 @@ export interface WorkspaceBlueprints {
|
|
|
646
760
|
workspaces: ResolvedWorkspaceBlueprint[];
|
|
647
761
|
}
|
|
648
762
|
|
|
649
|
-
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
|
+
}
|
|
650
789
|
|
|
651
790
|
function blueprintStrings(where: string, key: string, v: unknown): string[] {
|
|
652
791
|
if (v === undefined) return [];
|
|
@@ -678,6 +817,10 @@ function resolveBlueprint(
|
|
|
678
817
|
throw new Error(`${where}.focus must be true or false`);
|
|
679
818
|
if (bp.org !== undefined && (typeof bp.org !== "string" || !bp.org.trim()))
|
|
680
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
|
+
);
|
|
681
824
|
return {
|
|
682
825
|
name,
|
|
683
826
|
repos: blueprintStrings(where, "repos", bp.repos),
|
|
@@ -685,7 +828,42 @@ function resolveBlueprint(
|
|
|
685
828
|
autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
|
|
686
829
|
focus: bp.focus === true,
|
|
687
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;
|
|
688
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;
|
|
689
867
|
}
|
|
690
868
|
|
|
691
869
|
/**
|