@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `stack ls | up <name…> [--build] | down <name…> | restart <name…> | health <name…> | ps <name> | logs <name> [args]`
|
|
2
|
+
* `stack ls | up <name…> [--build] | down <name…> [--force] | restart <name…> | health <name…> | ps <name> | logs <name> [args]`
|
|
3
3
|
*
|
|
4
4
|
* Runs repos' Docker Compose projects with no host ports, each as its own
|
|
5
5
|
* project (`<stack>-<workspace>`), served by the edge at
|
|
@@ -20,7 +20,19 @@ import {
|
|
|
20
20
|
stackRestartService,
|
|
21
21
|
stackUp,
|
|
22
22
|
} from "../../shared/stacks/runtime.js";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
loadStackContext,
|
|
25
|
+
rejoinRunningStacks,
|
|
26
|
+
stackStatuses,
|
|
27
|
+
} from "../../shared/stacks/status.js";
|
|
28
|
+
import {
|
|
29
|
+
assertStackClaim,
|
|
30
|
+
forgetStackOwners,
|
|
31
|
+
recordStackOwner,
|
|
32
|
+
releaseStackOwner,
|
|
33
|
+
stackRefcounted,
|
|
34
|
+
} from "../../shared/stacks/owners.js";
|
|
35
|
+
import { resolveStackNamespace } from "../../shared/stacks/namespace.js";
|
|
24
36
|
import type { StackConfig } from "../../shared/stacks/types.js";
|
|
25
37
|
import { STACK_HEALTH_UNKNOWN_EXIT } from "../../shared/constants.js";
|
|
26
38
|
|
|
@@ -104,6 +116,19 @@ export async function cmdStack(argv: string[]): Promise<void> {
|
|
|
104
116
|
const ctx = loadStackContext();
|
|
105
117
|
const { edge, stacks } = ctx;
|
|
106
118
|
const names = args.filter((a) => !a.startsWith("-"));
|
|
119
|
+
// workspaces.yml overriding a different blueprint stack_namespace.
|
|
120
|
+
if (ctx.denWorkspace && ctx.namespaceSource === "workspace")
|
|
121
|
+
for (const w of resolveStackNamespace(ctx.denWorkspace).warnings)
|
|
122
|
+
console.error(chalk.yellow(`⚠ ${w}`));
|
|
123
|
+
/** Each stack's other live owners; throws on a checkout mismatch. */
|
|
124
|
+
const claims = (targets: string[]): Map<string, string[]> => {
|
|
125
|
+
const out = new Map<string, string[]>();
|
|
126
|
+
if (!stackRefcounted(ctx)) return out;
|
|
127
|
+
for (const n of targets)
|
|
128
|
+
if (stacks[n])
|
|
129
|
+
out.set(n, assertStackClaim(ctx, stacks[n], ctx.denWorkspace));
|
|
130
|
+
return out;
|
|
131
|
+
};
|
|
107
132
|
const pick = () => {
|
|
108
133
|
if (!names.length)
|
|
109
134
|
throw new Error(
|
|
@@ -131,13 +156,25 @@ export async function cmdStack(argv: string[]): Promise<void> {
|
|
|
131
156
|
// Re-render the edge so its cert covers every stack, then start the stacks.
|
|
132
157
|
const order = startOrder(pick(), stacks);
|
|
133
158
|
const secrets = await resolveStacksSecretEnv(order.map((n) => stacks[n]));
|
|
159
|
+
// A shared namespace: refused before anything starts when another
|
|
160
|
+
// workspace runs one of these projects from a different checkout.
|
|
161
|
+
const shared = claims(order);
|
|
134
162
|
await edgeUp(edge);
|
|
163
|
+
rejoinRunningStacks();
|
|
135
164
|
for (const n of order) {
|
|
136
165
|
console.log(chalk.cyan(`\n▶ ${n} (${projectName(n, ctx.workspace)})`));
|
|
166
|
+
if (shared.get(n)?.length)
|
|
167
|
+
console.log(
|
|
168
|
+
chalk.gray(
|
|
169
|
+
` shared with [${shared.get(n)!.join(", ")}] (stack namespace ${ctx.workspace})`,
|
|
170
|
+
),
|
|
171
|
+
);
|
|
137
172
|
stackUp(ctx, stacks[n], {
|
|
138
173
|
build: args.includes("--build"),
|
|
139
174
|
secretEnv: secrets.get(n),
|
|
140
175
|
});
|
|
176
|
+
if (stackRefcounted(ctx))
|
|
177
|
+
recordStackOwner(ctx, stacks[n], ctx.denWorkspace);
|
|
141
178
|
for (const route of Object.keys(stacks[n].routes))
|
|
142
179
|
console.log(chalk.green(` ✓ ${routeUrl(`${route}.${n}`, edge)}`));
|
|
143
180
|
}
|
|
@@ -158,14 +195,24 @@ export async function cmdStack(argv: string[]): Promise<void> {
|
|
|
158
195
|
}
|
|
159
196
|
return;
|
|
160
197
|
}
|
|
198
|
+
const shared = claims(pick());
|
|
161
199
|
await edgeUp(edge);
|
|
200
|
+
rejoinRunningStacks();
|
|
162
201
|
for (const n of pick()) {
|
|
163
202
|
console.log(chalk.cyan(`↻ ${n}`));
|
|
203
|
+
if (shared.get(n)?.length)
|
|
204
|
+
console.log(
|
|
205
|
+
chalk.yellow(
|
|
206
|
+
` Warning: restarting a shared stack — this interrupts [${shared.get(n)!.join(", ")}]`,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
164
209
|
stackDown(ctx, stacks[n]);
|
|
165
210
|
stackUp(ctx, stacks[n], {
|
|
166
211
|
build: args.includes("--build"),
|
|
167
212
|
secretEnv: secrets.get(n),
|
|
168
213
|
});
|
|
214
|
+
if (stackRefcounted(ctx))
|
|
215
|
+
recordStackOwner(ctx, stacks[n], ctx.denWorkspace);
|
|
169
216
|
}
|
|
170
217
|
return;
|
|
171
218
|
}
|
|
@@ -173,8 +220,22 @@ export async function cmdStack(argv: string[]): Promise<void> {
|
|
|
173
220
|
for (const n of startOrder(pick(), stacks)
|
|
174
221
|
.reverse()
|
|
175
222
|
.filter((n) => names.includes(n))) {
|
|
223
|
+
// A shared namespace: the last live owner stops it; any other
|
|
224
|
+
// detaches. `--force` stops it whoever else holds it.
|
|
225
|
+
if (stackRefcounted(ctx) && !args.includes("--force")) {
|
|
226
|
+
const r = releaseStackOwner(ctx, stacks[n], ctx.denWorkspace);
|
|
227
|
+
if (!r.stop) {
|
|
228
|
+
console.log(
|
|
229
|
+
chalk.cyan(
|
|
230
|
+
`▼ ${n}: detached workspace "${ctx.denWorkspace}" — ${projectName(n, ctx.workspace)} still running for [${r.remaining.join(", ")}]`,
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
176
236
|
console.log(chalk.cyan(`▼ ${n}`));
|
|
177
237
|
stackDown(ctx, stacks[n]);
|
|
238
|
+
forgetStackOwners(ctx, stacks[n]);
|
|
178
239
|
}
|
|
179
240
|
return;
|
|
180
241
|
case "ps":
|
|
@@ -24,6 +24,12 @@ import {
|
|
|
24
24
|
tsxCommand,
|
|
25
25
|
type EngineCommand,
|
|
26
26
|
} from "../../shared/engine.js";
|
|
27
|
+
import { product } from "../../shared/product.js";
|
|
28
|
+
|
|
29
|
+
/** Remediation when test sessions cannot be created (no vendor assumed). */
|
|
30
|
+
const SETUP_SECRETS_HINT = ` make ${product().cli}-setup-secrets - Set up the selected secrets.provider`;
|
|
31
|
+
const CI_CREDENTIALS_HINT =
|
|
32
|
+
" CI: E2E_<ROLE>_EMAIL / E2E_<ROLE>_PASSWORD env vars";
|
|
27
33
|
|
|
28
34
|
/** After Playwright smoke runs: fail if storage fixture handoff was left behind. */
|
|
29
35
|
function smokePostRunTeardownVerifyStep(env: NodeJS.ProcessEnv): CommandStep {
|
|
@@ -913,13 +919,11 @@ export class TestRunner {
|
|
|
913
919
|
if (shouldSetup) {
|
|
914
920
|
console.log(
|
|
915
921
|
chalk.cyan(
|
|
916
|
-
"\n📝 Sessions are created automatically — ensure
|
|
922
|
+
"\n📝 Sessions are created automatically — ensure the secrets backend is ready:\n",
|
|
917
923
|
),
|
|
918
924
|
);
|
|
919
|
-
console.log(chalk.cyan(
|
|
920
|
-
console.log(
|
|
921
|
-
chalk.cyan(" make auth-configure - One-time admin setup\n"),
|
|
922
|
-
);
|
|
925
|
+
console.log(chalk.cyan(SETUP_SECRETS_HINT));
|
|
926
|
+
console.log(chalk.cyan(`${CI_CREDENTIALS_HINT}\n`));
|
|
923
927
|
return false;
|
|
924
928
|
} else {
|
|
925
929
|
console.log(chalk.red("Cannot run tests without authentication\n"));
|
|
@@ -934,10 +938,8 @@ export class TestRunner {
|
|
|
934
938
|
"\n⚠️ Authentication failed — sessions are created fresh each run\n",
|
|
935
939
|
),
|
|
936
940
|
);
|
|
937
|
-
console.log(chalk.cyan(
|
|
938
|
-
console.log(
|
|
939
|
-
chalk.cyan(" make auth-configure - One-time admin setup\n"),
|
|
940
|
-
);
|
|
941
|
+
console.log(chalk.cyan(SETUP_SECRETS_HINT));
|
|
942
|
+
console.log(chalk.cyan(`${CI_CREDENTIALS_HINT}\n`));
|
|
941
943
|
return false;
|
|
942
944
|
}
|
|
943
945
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `theme validate | add | list | remove | new`
|
|
3
|
+
*
|
|
4
|
+
* Sugar over `plugin *` (PL-2a): the same commands, filtered to kind theme,
|
|
5
|
+
* with the same output and exit codes. Only `new` is theme-specific.
|
|
6
|
+
*
|
|
7
|
+
* A theme is a den-theme.json (den.theme/v1) with its files, in any git repo.
|
|
8
|
+
* `add` fetches one at an exact ref, validates it, and vendors it into the
|
|
9
|
+
* distribution (`plugins/<id>/`) with a lock entry (`plugins.lock.json`: kinds,
|
|
10
|
+
* source, ref, commit, content hash), so it is pinned the way Den is, works offline,
|
|
11
|
+
* and the whole team gets the same files from the distribution's own repo.
|
|
12
|
+
* Den's default theme ships inside Den (themes/den in the package).
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
import * as path from "path";
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
import { getDistRoot, getEngineRoot } from "../../shared/paths.js";
|
|
18
|
+
import { product } from "../../shared/product.js";
|
|
19
|
+
import { readBrand } from "../../shared/brand.js";
|
|
20
|
+
import { THEME_FILE } from "../../shared/theme/contract.js";
|
|
21
|
+
import { PLUGIN_ID, PLUGIN_ID_RULE } from "../../shared/plugins/manifest.js";
|
|
22
|
+
import {
|
|
23
|
+
PLUGINS_DIR,
|
|
24
|
+
hashFiles,
|
|
25
|
+
parsePluginSource,
|
|
26
|
+
pluginDirFor,
|
|
27
|
+
type LockEntry,
|
|
28
|
+
type PluginSource,
|
|
29
|
+
} from "../../shared/plugins/store.js";
|
|
30
|
+
import { runPluginCommand, type PluginDeps } from "./plugin-command.js";
|
|
31
|
+
|
|
32
|
+
// The plugin store is where themes live (PL-2a); these names stay for
|
|
33
|
+
// theme-runtime and older callers.
|
|
34
|
+
export {
|
|
35
|
+
LOCK_FILE,
|
|
36
|
+
PLUGINS_DIR,
|
|
37
|
+
installedThemeIds,
|
|
38
|
+
readLock,
|
|
39
|
+
refKind,
|
|
40
|
+
type GitRunner,
|
|
41
|
+
type LockEntry,
|
|
42
|
+
} from "../../shared/plugins/store.js";
|
|
43
|
+
export { validationSummary } from "./plugin-command.js";
|
|
44
|
+
|
|
45
|
+
export type ThemeSource = PluginSource;
|
|
46
|
+
export const parseThemeSource = parsePluginSource;
|
|
47
|
+
export const hashTheme = hashFiles;
|
|
48
|
+
export const themeDirFor = pluginDirFor;
|
|
49
|
+
|
|
50
|
+
/** The lock's theme entries (the lock is shared with other plugin kinds). */
|
|
51
|
+
export function themesIn(lock: Record<string, LockEntry>): LockEntry[] {
|
|
52
|
+
return Object.values(lock).filter((e) => e.kinds?.includes("theme"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ThemeDeps = PluginDeps;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run `theme <sub>`; returns the exit code. add, list, validate and remove
|
|
59
|
+
* are `plugin <sub>` filtered to kind theme; new is the theme scaffold.
|
|
60
|
+
*/
|
|
61
|
+
export async function cmdTheme(
|
|
62
|
+
args: string[],
|
|
63
|
+
deps: ThemeDeps = {},
|
|
64
|
+
): Promise<number> {
|
|
65
|
+
const log = deps.log ?? ((l: string) => console.log(l));
|
|
66
|
+
const distRoot = deps.distRoot ?? getDistRoot();
|
|
67
|
+
const [sub, ...rest] = args;
|
|
68
|
+
const cli = product().cli;
|
|
69
|
+
|
|
70
|
+
switch (sub) {
|
|
71
|
+
case "validate":
|
|
72
|
+
case "add":
|
|
73
|
+
case "list":
|
|
74
|
+
case "remove":
|
|
75
|
+
case undefined:
|
|
76
|
+
return runPluginCommand(args, "theme", deps);
|
|
77
|
+
|
|
78
|
+
case "new": {
|
|
79
|
+
// A local theme to edit: a copy of the built-in one, in plugins/<id>/,
|
|
80
|
+
// committed with the distribution so the whole team gets it.
|
|
81
|
+
const id = rest[0];
|
|
82
|
+
if (!id || !PLUGIN_ID.test(id) || id === "den") {
|
|
83
|
+
log(
|
|
84
|
+
chalk.red(
|
|
85
|
+
`Usage: ${cli} theme new <id> (${PLUGIN_ID_RULE}; not "den")`,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
const dest = themeDirFor(distRoot, id);
|
|
91
|
+
if (fs.existsSync(dest)) {
|
|
92
|
+
log(
|
|
93
|
+
chalk.red(`✗ ${PLUGINS_DIR}/${id}/ already exists; nothing changed`),
|
|
94
|
+
);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const from = path.join(
|
|
98
|
+
deps.engineRoot ?? getEngineRoot(),
|
|
99
|
+
"themes",
|
|
100
|
+
"den",
|
|
101
|
+
);
|
|
102
|
+
// Not Den's tab icon: a new theme's tab shows its own mark.
|
|
103
|
+
const denIcon = path.join(from, "favicon.ico");
|
|
104
|
+
fs.cpSync(from, dest, {
|
|
105
|
+
recursive: true,
|
|
106
|
+
filter: (src) => src !== denIcon,
|
|
107
|
+
});
|
|
108
|
+
const file = path.join(dest, THEME_FILE);
|
|
109
|
+
const m = JSON.parse(fs.readFileSync(file, "utf-8")) as Record<
|
|
110
|
+
string,
|
|
111
|
+
unknown
|
|
112
|
+
>;
|
|
113
|
+
delete m.favicon;
|
|
114
|
+
const name = id
|
|
115
|
+
.split("-")
|
|
116
|
+
.map((w) => w[0].toUpperCase() + w.slice(1))
|
|
117
|
+
.join(" ");
|
|
118
|
+
Object.assign(m, { id, name, version: "0.1.0" });
|
|
119
|
+
delete m.homepage;
|
|
120
|
+
// The author is the theme's maintainer (plugin rule 1): the team.
|
|
121
|
+
m.author = readBrand().name;
|
|
122
|
+
fs.writeFileSync(file, `${JSON.stringify(m, null, 2)}\n`);
|
|
123
|
+
log(
|
|
124
|
+
` ${id} → ${PLUGINS_DIR}/${id}/ (a copy of den: edit ${THEME_FILE} and mark.svg)`,
|
|
125
|
+
);
|
|
126
|
+
log(` ${cli} theme validate checks it; commit it and your team has it.`);
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
default:
|
|
131
|
+
log(
|
|
132
|
+
`Usage: ${cli} theme [list] | validate [dir] | new <id> | add <repo>[/<subpath>]@<ref> | remove <id>`,
|
|
133
|
+
);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -4,11 +4,12 @@ import * as fs from "fs";
|
|
|
4
4
|
import * as http from "http";
|
|
5
5
|
import * as path from "path";
|
|
6
6
|
import { pathToFileURL } from "url";
|
|
7
|
+
import { CLI_SPAWN_TIMEOUT_MS, DEFAULT_PORT } from "../../shared/constants.js";
|
|
7
8
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "../../shared/
|
|
9
|
+
readSecretsConfig,
|
|
10
|
+
secretsProcessEnv,
|
|
11
|
+
type SecretsConfig,
|
|
12
|
+
} from "../../shared/secrets/index.js";
|
|
12
13
|
import { getDistRoot, getEngineRoot, getStateDir } from "../../shared/paths.js";
|
|
13
14
|
import { hasDenUiMarker } from "../../shared/ui-marker.js";
|
|
14
15
|
import { engineBin, engineModuleFile } from "../../shared/engine.js";
|
|
@@ -269,26 +270,43 @@ async function getHttpText(
|
|
|
269
270
|
});
|
|
270
271
|
}
|
|
271
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The API is up: /api/server-info answers whatever the config holds
|
|
275
|
+
* (0.3.0-dev.42: /api/health answers 422 under an invalid config, so
|
|
276
|
+
* `web up` reported a running server as not ready). A server too old for
|
|
277
|
+
* server-info is asked /api/health, as before.
|
|
278
|
+
*/
|
|
272
279
|
async function isDenWebApiRunning(port: number): Promise<boolean> {
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
+
const json = async (p: string): Promise<unknown> => {
|
|
281
|
+
const response = await getHttpText(port, p);
|
|
282
|
+
if (!response || response.statusCode !== 200) return undefined;
|
|
283
|
+
try {
|
|
284
|
+
return JSON.parse(response.body) as unknown;
|
|
285
|
+
} catch {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const info = await json("/api/server-info");
|
|
290
|
+
if (
|
|
291
|
+
info &&
|
|
292
|
+
typeof info === "object" &&
|
|
293
|
+
typeof (info as { pid?: unknown }).pid === "number"
|
|
294
|
+
)
|
|
295
|
+
return true;
|
|
296
|
+
return Array.isArray(await json("/api/health"));
|
|
280
297
|
}
|
|
281
298
|
|
|
282
299
|
async function isDenWebUiReady(port: number): Promise<boolean> {
|
|
283
300
|
const response = await getHttpText(port, "/index.html");
|
|
284
301
|
return (
|
|
285
|
-
|
|
302
|
+
// 422: the "Config is invalid" page, the shell serving as it should.
|
|
303
|
+
(response?.statusCode === 200 || response?.statusCode === 422) &&
|
|
286
304
|
/<html[\s>]/i.test(response.body) &&
|
|
287
305
|
hasDenUiMarker(response.body)
|
|
288
306
|
);
|
|
289
307
|
}
|
|
290
308
|
|
|
291
|
-
async function isDenWebRunning(port: number): Promise<boolean> {
|
|
309
|
+
export async function isDenWebRunning(port: number): Promise<boolean> {
|
|
292
310
|
return (await isDenWebApiRunning(port)) && (await isDenWebUiReady(port));
|
|
293
311
|
}
|
|
294
312
|
|
|
@@ -350,6 +368,20 @@ export function resolveServerEntrypoint(engine: string = getEngineRoot()): {
|
|
|
350
368
|
};
|
|
351
369
|
}
|
|
352
370
|
|
|
371
|
+
/**
|
|
372
|
+
* The env the web server daemon starts with: the caller's, its port, and
|
|
373
|
+
* whatever the selected secrets strategy gives (only 1password gives one,
|
|
374
|
+
* OP_ACCOUNT, from the distribution's `secrets.onepassword.account` or the
|
|
375
|
+
* caller's own OP_ACCOUNT). Den adds no account of its own.
|
|
376
|
+
*/
|
|
377
|
+
export function webServerEnv(
|
|
378
|
+
port: number,
|
|
379
|
+
base: NodeJS.ProcessEnv = process.env,
|
|
380
|
+
cfg: SecretsConfig = readSecretsConfig(),
|
|
381
|
+
): NodeJS.ProcessEnv {
|
|
382
|
+
return { ...base, PORT: String(port), ...secretsProcessEnv(cfg, base) };
|
|
383
|
+
}
|
|
384
|
+
|
|
353
385
|
function startDenWebServer(options: WebCommandOptions): StartedServer {
|
|
354
386
|
const root = getDistRoot();
|
|
355
387
|
const entrypoint = resolveServerEntrypoint();
|
|
@@ -369,11 +401,7 @@ function startDenWebServer(options: WebCommandOptions): StartedServer {
|
|
|
369
401
|
{
|
|
370
402
|
cwd: root,
|
|
371
403
|
detached: true,
|
|
372
|
-
env:
|
|
373
|
-
...process.env,
|
|
374
|
-
PORT: String(options.port),
|
|
375
|
-
OP_ACCOUNT: process.env.OP_ACCOUNT ?? OP_ACCOUNT_DEFAULT,
|
|
376
|
-
},
|
|
404
|
+
env: webServerEnv(options.port),
|
|
377
405
|
stdio: ["ignore", logFd, logFd],
|
|
378
406
|
},
|
|
379
407
|
);
|
|
@@ -435,11 +463,7 @@ export function spawnDetachedWebRestart(port: number): number | undefined {
|
|
|
435
463
|
{
|
|
436
464
|
cwd: root,
|
|
437
465
|
detached: true,
|
|
438
|
-
env:
|
|
439
|
-
...process.env,
|
|
440
|
-
PORT: String(port),
|
|
441
|
-
OP_ACCOUNT: process.env.OP_ACCOUNT ?? OP_ACCOUNT_DEFAULT,
|
|
442
|
-
},
|
|
466
|
+
env: webServerEnv(port),
|
|
443
467
|
stdio: ["ignore", logFd, logFd],
|
|
444
468
|
},
|
|
445
469
|
);
|
package/src/ui/mcp/api.ts
CHANGED
|
@@ -85,6 +85,28 @@ export class DenHttpError extends Error {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* An error body as a line: the server's R26b JSON ({ ok: false, error,
|
|
90
|
+
* code, requestId }) as its message and request id, anything else as is.
|
|
91
|
+
*/
|
|
92
|
+
export function errorText(body: string): string {
|
|
93
|
+
try {
|
|
94
|
+
const j = JSON.parse(body) as {
|
|
95
|
+
error?: unknown;
|
|
96
|
+
code?: unknown;
|
|
97
|
+
requestId?: unknown;
|
|
98
|
+
};
|
|
99
|
+
if (j && typeof j.error === "string")
|
|
100
|
+
return (
|
|
101
|
+
j.error +
|
|
102
|
+
(typeof j.requestId === "string" ? ` (request ${j.requestId})` : "")
|
|
103
|
+
);
|
|
104
|
+
} catch {
|
|
105
|
+
/* not JSON */
|
|
106
|
+
}
|
|
107
|
+
return body;
|
|
108
|
+
}
|
|
109
|
+
|
|
88
110
|
/** Read routes: GET only. */
|
|
89
111
|
const READ_ROUTES = new Set([
|
|
90
112
|
"/api/health",
|
|
@@ -268,8 +290,12 @@ export function createDenApi(opts: {
|
|
|
268
290
|
signal,
|
|
269
291
|
});
|
|
270
292
|
if (!res.ok) {
|
|
271
|
-
|
|
272
|
-
|
|
293
|
+
throw new DenHttpError(
|
|
294
|
+
method,
|
|
295
|
+
path,
|
|
296
|
+
res.status,
|
|
297
|
+
errorText((await res.text()).slice(0, 2000)),
|
|
298
|
+
);
|
|
273
299
|
}
|
|
274
300
|
return res;
|
|
275
301
|
} catch (e) {
|
package/src/ui/mcp/scope.ts
CHANGED
|
@@ -103,6 +103,27 @@ function printable(v: string): string {
|
|
|
103
103
|
return v.replace(/[^\x20-\x7e]/g, "?").slice(0, 80);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A path echoed back in an error: one printable line, never cut mid-name
|
|
108
|
+
* (0.3.0-dev.40, a consumer's 0.2.8 nit: printable's 80-character cap cut
|
|
109
|
+
* the filename). Up to `max` characters it is whole; a longer one keeps its
|
|
110
|
+
* start and its last segment whole, eliding the middle with "…".
|
|
111
|
+
*/
|
|
112
|
+
export function printablePath(v: string, max = 160): string {
|
|
113
|
+
const s = v.replace(/[^\x20-\x7e]/g, "?");
|
|
114
|
+
if (s.length <= max) return s;
|
|
115
|
+
const cut = s.lastIndexOf("/");
|
|
116
|
+
const tail = cut >= 0 ? s.slice(cut) : s;
|
|
117
|
+
// The last segment is kept whole whatever its length; the room left is
|
|
118
|
+
// shared between the path's start and the directories just before it.
|
|
119
|
+
const room = max - tail.length - 1;
|
|
120
|
+
if (room <= 0) return `…${tail}`;
|
|
121
|
+
const head = s.slice(0, Math.ceil(room / 2));
|
|
122
|
+
const end = s.length - tail.length;
|
|
123
|
+
const more = s.slice(end - (room - head.length), end);
|
|
124
|
+
return `${head}…${more}${tail}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
106
127
|
/** The refusal for a call that names `other`. */
|
|
107
128
|
export function otherWorkspace(
|
|
108
129
|
tool: string,
|
|
@@ -272,6 +293,6 @@ export async function assertPathInScope(
|
|
|
272
293
|
.map((r) => path.resolve(r));
|
|
273
294
|
if (!roots.some((r) => target === r || target.startsWith(r + path.sep)))
|
|
274
295
|
throw new ScopeRefusal(
|
|
275
|
-
`${tool}: ${
|
|
296
|
+
`${tool}: ${printablePath(p)} is not inside a repo of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
|
|
276
297
|
);
|
|
277
298
|
}
|
package/src/ui/mcp/tools.ts
CHANGED
|
@@ -601,13 +601,17 @@ export function versionBefore(a: string, b: string): boolean {
|
|
|
601
601
|
return false;
|
|
602
602
|
}
|
|
603
603
|
|
|
604
|
-
/** The running web server's
|
|
605
|
-
async function
|
|
604
|
+
/** The running web server's /api/server-info, or null when it has none. */
|
|
605
|
+
async function readServerInfo(
|
|
606
|
+
api: DenApi,
|
|
607
|
+
): Promise<Record<string, unknown> | null> {
|
|
606
608
|
try {
|
|
607
|
-
const info = (await api.get("/api/server-info")) as
|
|
608
|
-
return typeof info
|
|
609
|
+
const info = (await api.get("/api/server-info")) as unknown;
|
|
610
|
+
return info && typeof info === "object"
|
|
611
|
+
? (info as Record<string, unknown>)
|
|
612
|
+
: null;
|
|
609
613
|
} catch (e) {
|
|
610
|
-
if (e instanceof DenHttpError) return
|
|
614
|
+
if (e instanceof DenHttpError) return null;
|
|
611
615
|
throw e;
|
|
612
616
|
}
|
|
613
617
|
}
|
|
@@ -621,11 +625,13 @@ async function notFoundMessage(
|
|
|
621
625
|
e: DenHttpError,
|
|
622
626
|
tool: string,
|
|
623
627
|
ctx: ToolContext,
|
|
628
|
+
info: () => Promise<Record<string, unknown> | null>,
|
|
624
629
|
): Promise<string> {
|
|
625
630
|
const plain = `Error: ${tool}: the web server answered 404 for ${e.method} ${e.path}: ${e.body}`;
|
|
626
631
|
const since = ROUTE_SINCE[e.path];
|
|
627
632
|
if (!since) return plain;
|
|
628
|
-
const
|
|
633
|
+
const v = (await info())?.version;
|
|
634
|
+
const version = typeof v === "string" ? v : undefined;
|
|
629
635
|
if (version !== undefined && !versionBefore(version, since)) return plain;
|
|
630
636
|
return (
|
|
631
637
|
`web server is Den ${version ?? `< ${since}`}; ` +
|
|
@@ -664,6 +670,30 @@ async function reportRefusal(
|
|
|
664
670
|
}
|
|
665
671
|
}
|
|
666
672
|
|
|
673
|
+
/**
|
|
674
|
+
* MD-2: when the web server runs an older engine than the one on disk (a
|
|
675
|
+
* pin upgraded Den under it), every failed call says so, naming both
|
|
676
|
+
* versions and the restart. Null when the engines agree or it can't tell.
|
|
677
|
+
*/
|
|
678
|
+
function staleEngineMessage(
|
|
679
|
+
tool: string,
|
|
680
|
+
ctx: ToolContext,
|
|
681
|
+
info: Record<string, unknown> | null,
|
|
682
|
+
): string | null {
|
|
683
|
+
if (info?.engineStale !== true) return null;
|
|
684
|
+
const ver = (x: unknown) =>
|
|
685
|
+
x &&
|
|
686
|
+
typeof x === "object" &&
|
|
687
|
+
typeof (x as { version?: unknown }).version === "string"
|
|
688
|
+
? (x as { version: string }).version
|
|
689
|
+
: "unknown";
|
|
690
|
+
return (
|
|
691
|
+
`Error: ${tool}: Den was updated to ${ver(info.onDisk)}, but the web server ` +
|
|
692
|
+
`is still running ${ver(info.loaded)}. Restart it: ${ctx.cli} web restart ` +
|
|
693
|
+
`(or your service restart).`
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
667
697
|
/**
|
|
668
698
|
* A scoped server's arguments: the workspace exists (checked once per
|
|
669
699
|
* server), and a tool that takes a workspace gets the scoped one; naming
|
|
@@ -709,8 +739,21 @@ export async function callTool(
|
|
|
709
739
|
text: redactSecrets(JSON.stringify(e.out, null, 2)),
|
|
710
740
|
isError: true,
|
|
711
741
|
};
|
|
742
|
+
// server-info is read lazily, once, and only after a call failed.
|
|
743
|
+
let infoRead: Promise<Record<string, unknown> | null> | null = null;
|
|
744
|
+
const info = () => (infoRead ??= readServerInfo(ctx.api).catch(() => null));
|
|
745
|
+
if (e instanceof DenHttpError) {
|
|
746
|
+
const stale = staleEngineMessage(name, ctx, await info());
|
|
747
|
+
if (stale)
|
|
748
|
+
return {
|
|
749
|
+
text: redactSecrets(
|
|
750
|
+
`${stale} (${e.method} ${e.path} answered ${e.status}: ${e.body})`,
|
|
751
|
+
),
|
|
752
|
+
isError: true,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
712
755
|
if (e instanceof DenHttpError && e.status === 404) {
|
|
713
|
-
const text = await notFoundMessage(e, name, ctx).catch(
|
|
756
|
+
const text = await notFoundMessage(e, name, ctx, info).catch(
|
|
714
757
|
(inner: Error) => `Error: ${inner.message}`,
|
|
715
758
|
);
|
|
716
759
|
return { text: redactSecrets(text), isError: true };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Appearance preferences: the theme and mode this engineer chose. Stored in
|
|
3
|
+
* the user config as `appearance:` and served by /api/settings, like the
|
|
4
|
+
* graph prefs. A pure UI preference: changing it writes this block of
|
|
5
|
+
* config/local/user.yml and nothing else (no edge, services or restart).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type ModePref = "system" | "dark" | "light";
|
|
9
|
+
|
|
10
|
+
export interface AppearancePrefs {
|
|
11
|
+
/**
|
|
12
|
+
* A theme id, or null to follow the org default (the org config's
|
|
13
|
+
* `brand.theme`, else den). A named theme is pinned even when it is the
|
|
14
|
+
* org default today: you keep it when the org changes `brand.theme`.
|
|
15
|
+
*/
|
|
16
|
+
theme: string | null;
|
|
17
|
+
/** system follows prefers-color-scheme. */
|
|
18
|
+
mode: ModePref;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const APPEARANCE_DEFAULTS: AppearancePrefs = {
|
|
22
|
+
theme: null,
|
|
23
|
+
mode: "system",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const MODES: readonly ModePref[] = ["system", "dark", "light"];
|
|
27
|
+
const THEME_ID = /^[a-z][a-z0-9-]{1,39}$/;
|
|
28
|
+
|
|
29
|
+
const isObject = (v: unknown): v is Record<string, unknown> =>
|
|
30
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The stored block, normalised. A stored theme id is kept even when that
|
|
34
|
+
* theme is not installed now (resolution warns and falls back), so a theme
|
|
35
|
+
* that is briefly missing does not lose the preference.
|
|
36
|
+
*/
|
|
37
|
+
export function appearancePrefs(stored: unknown): AppearancePrefs {
|
|
38
|
+
const out: AppearancePrefs = { ...APPEARANCE_DEFAULTS };
|
|
39
|
+
if (!isObject(stored)) return out;
|
|
40
|
+
if (typeof stored.theme === "string" && THEME_ID.test(stored.theme))
|
|
41
|
+
out.theme = stored.theme;
|
|
42
|
+
if (MODES.includes(stored.mode as ModePref))
|
|
43
|
+
out.mode = stored.mode as ModePref;
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Merge a POSTed (partial) block. `theme` must name an installed theme that
|
|
49
|
+
* validates; null or "" clears it ("Org default": follow the org); any bad
|
|
50
|
+
* field fails the whole patch.
|
|
51
|
+
*
|
|
52
|
+
* A theme picked by name is stored by name, even one that is the org
|
|
53
|
+
* default now (0.3.0-dev.11): that is a pin, and the menu shows it as one.
|
|
54
|
+
* Only "Org default" follows the org when it changes `brand.theme`.
|
|
55
|
+
*/
|
|
56
|
+
export function mergeAppearancePrefs(
|
|
57
|
+
current: AppearancePrefs,
|
|
58
|
+
patch: unknown,
|
|
59
|
+
isUsableTheme: (id: string) => boolean,
|
|
60
|
+
): { ok: true; value: AppearancePrefs } | { ok: false; error: string } {
|
|
61
|
+
if (!isObject(patch))
|
|
62
|
+
return { ok: false, error: "appearance must be an object" };
|
|
63
|
+
const out: AppearancePrefs = { ...current };
|
|
64
|
+
for (const [key, v] of Object.entries(patch)) {
|
|
65
|
+
if (key === "mode") {
|
|
66
|
+
// null clears it to the default (0.3.0-dev.32), as theme's does.
|
|
67
|
+
if (v === null) out.mode = APPEARANCE_DEFAULTS.mode;
|
|
68
|
+
else if (!MODES.includes(v as ModePref))
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: `appearance.mode must be one of ${MODES.map((m) => `"${m}"`).join(", ")}`,
|
|
72
|
+
};
|
|
73
|
+
else out.mode = v as ModePref;
|
|
74
|
+
} else if (key === "theme") {
|
|
75
|
+
if (v === null || v === "") out.theme = null;
|
|
76
|
+
else if (typeof v !== "string" || !THEME_ID.test(v) || !isUsableTheme(v))
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: `appearance.theme: no valid installed theme "${String(v)}"`,
|
|
80
|
+
};
|
|
81
|
+
else out.theme = v;
|
|
82
|
+
} else return { ok: false, error: `unknown appearance setting "${key}"` };
|
|
83
|
+
}
|
|
84
|
+
return { ok: true, value: out };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What user.yml stores: only what differs from the defaults. */
|
|
88
|
+
export function storedAppearance(
|
|
89
|
+
p: AppearancePrefs,
|
|
90
|
+
): { theme?: string; mode?: ModePref } | undefined {
|
|
91
|
+
const out: { theme?: string; mode?: ModePref } = {};
|
|
92
|
+
if (p.theme) out.theme = p.theme;
|
|
93
|
+
if (p.mode !== APPEARANCE_DEFAULTS.mode) out.mode = p.mode;
|
|
94
|
+
return Object.keys(out).length ? out : undefined;
|
|
95
|
+
}
|