@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
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The theme contract, den.theme/v1: a look and feel for the web UI, published
|
|
3
|
+
* in a git repo as `den-theme.json` (see schemas/den.theme.v1.json and the
|
|
4
|
+
* den-themes repo's README).
|
|
5
|
+
*
|
|
6
|
+
* validateThemeDir checks what `<cli> theme validate` and `theme add` check:
|
|
7
|
+
* the JSON Schema, the contrast rules below (both modes), the files the
|
|
8
|
+
* manifest names, and that the id matches the directory when asked.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
13
|
+
import addFormats from "ajv-formats";
|
|
14
|
+
import semver from "semver";
|
|
15
|
+
import { engineVersion, getEngineRoot } from "../paths.js";
|
|
16
|
+
|
|
17
|
+
export const THEME_SCHEMA_ID = "den.theme/v1";
|
|
18
|
+
export const THEME_FILE = "den-theme.json";
|
|
19
|
+
|
|
20
|
+
export type ThemeMode = "dark" | "light";
|
|
21
|
+
export type ColorToken =
|
|
22
|
+
| "bg"
|
|
23
|
+
| "surface"
|
|
24
|
+
| "raised"
|
|
25
|
+
| "sunken"
|
|
26
|
+
| "text"
|
|
27
|
+
| "muted"
|
|
28
|
+
| "faint"
|
|
29
|
+
| "border"
|
|
30
|
+
| "border-strong"
|
|
31
|
+
| "accent"
|
|
32
|
+
| "accent-text"
|
|
33
|
+
| "accent-fill"
|
|
34
|
+
| "on-accent"
|
|
35
|
+
| "ok"
|
|
36
|
+
| "warn"
|
|
37
|
+
| "bad"
|
|
38
|
+
| "remote";
|
|
39
|
+
|
|
40
|
+
export interface ThemeManifest {
|
|
41
|
+
schema: typeof THEME_SCHEMA_ID;
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
version: string;
|
|
45
|
+
license: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
author?: string;
|
|
48
|
+
homepage?: string;
|
|
49
|
+
den?: string;
|
|
50
|
+
default_mode?: ThemeMode;
|
|
51
|
+
type?: { sans?: string; mono?: string; display?: string; ja?: string };
|
|
52
|
+
shape?: {
|
|
53
|
+
radius?: string;
|
|
54
|
+
"radius-button"?: string;
|
|
55
|
+
density?: "compact" | "comfortable";
|
|
56
|
+
};
|
|
57
|
+
mark?: {
|
|
58
|
+
svg?: string;
|
|
59
|
+
svg_dark?: string;
|
|
60
|
+
png?: string;
|
|
61
|
+
png_dark?: string;
|
|
62
|
+
webp?: string;
|
|
63
|
+
webp_dark?: string;
|
|
64
|
+
text?: string;
|
|
65
|
+
shape?: "square" | "rounded" | "circle";
|
|
66
|
+
};
|
|
67
|
+
/** The browser-tab icon (.png, .svg or .ico); else the mark's file. */
|
|
68
|
+
favicon?: string;
|
|
69
|
+
fonts?: {
|
|
70
|
+
family: string;
|
|
71
|
+
license: string;
|
|
72
|
+
files: { path: string; weight?: string; style?: "normal" | "italic" }[];
|
|
73
|
+
}[];
|
|
74
|
+
modes: Record<
|
|
75
|
+
ThemeMode,
|
|
76
|
+
Record<ColorToken, string> & { shadow?: string; bar?: string }
|
|
77
|
+
>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* [foreground, background, minimum ratio]: every text token on every ground
|
|
82
|
+
* at 4.5:1 (WCAG 1.4.3), and the non-text UI tokens on every ground at 3:1
|
|
83
|
+
* (1.4.11). faint is text too (captions, labels), so it is held to 4.5.
|
|
84
|
+
*/
|
|
85
|
+
/** Every token Den draws text in. accent is never text (use accent-text). */
|
|
86
|
+
export const TEXT_TOKENS: readonly ColorToken[] = [
|
|
87
|
+
"text",
|
|
88
|
+
"muted",
|
|
89
|
+
"faint",
|
|
90
|
+
"accent-text",
|
|
91
|
+
"ok",
|
|
92
|
+
"warn",
|
|
93
|
+
"bad",
|
|
94
|
+
"remote",
|
|
95
|
+
];
|
|
96
|
+
/** Every ground text and controls sit on. */
|
|
97
|
+
export const GROUNDS: readonly ColorToken[] = [
|
|
98
|
+
"bg",
|
|
99
|
+
"surface",
|
|
100
|
+
"raised",
|
|
101
|
+
"sunken",
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
export const CONTRAST_RULES: readonly [ColorToken, ColorToken, number][] = [
|
|
105
|
+
...TEXT_TOKENS.flatMap((fg) =>
|
|
106
|
+
GROUNDS.map((bg): [ColorToken, ColorToken, number] => [fg, bg, 4.5]),
|
|
107
|
+
),
|
|
108
|
+
["on-accent", "accent-fill", 4.5],
|
|
109
|
+
// Non-text UI: focus rings, selection, control borders (WCAG 1.4.11).
|
|
110
|
+
...(["accent", "border-strong"] as const).flatMap((fg) =>
|
|
111
|
+
GROUNDS.map((bg): [ColorToken, ColorToken, number] => [fg, bg, 3]),
|
|
112
|
+
),
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** WCAG relative luminance of #rrggbb. */
|
|
116
|
+
function luminance(hex: string): number {
|
|
117
|
+
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
|
118
|
+
const [r, g, b] = c.map((v) =>
|
|
119
|
+
v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4,
|
|
120
|
+
);
|
|
121
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** WCAG contrast ratio of two #rrggbb colors (1–21). */
|
|
125
|
+
export function contrastRatio(a: string, b: string): number {
|
|
126
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
127
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ContrastFailure {
|
|
131
|
+
mode: ThemeMode;
|
|
132
|
+
fg: ColorToken;
|
|
133
|
+
bg: ColorToken;
|
|
134
|
+
ratio: number;
|
|
135
|
+
min: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function checkContrast(theme: ThemeManifest): ContrastFailure[] {
|
|
139
|
+
const out: ContrastFailure[] = [];
|
|
140
|
+
for (const mode of ["dark", "light"] as const) {
|
|
141
|
+
const t = theme.modes[mode];
|
|
142
|
+
for (const [fg, bg, min] of CONTRAST_RULES) {
|
|
143
|
+
const ratio = contrastRatio(t[fg], t[bg]);
|
|
144
|
+
// Unrounded, as the den-themes validator: 4.49 fails 4.5.
|
|
145
|
+
if (ratio < min)
|
|
146
|
+
out.push({ mode, fg, bg, ratio: Math.floor(ratio * 100) / 100, min });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ThemeIssue {
|
|
153
|
+
/** JSON pointer into den-theme.json, or "" for the file itself. */
|
|
154
|
+
at: string;
|
|
155
|
+
message: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface ThemeValidation {
|
|
159
|
+
ok: boolean;
|
|
160
|
+
theme?: ThemeManifest;
|
|
161
|
+
issues: ThemeIssue[];
|
|
162
|
+
contrast: ContrastFailure[];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let validator: ReturnType<InstanceType<typeof Ajv2020>["compile"]> | null =
|
|
166
|
+
null;
|
|
167
|
+
|
|
168
|
+
function schemaValidator() {
|
|
169
|
+
if (validator) return validator;
|
|
170
|
+
const schema = JSON.parse(
|
|
171
|
+
fs.readFileSync(
|
|
172
|
+
path.join(getEngineRoot(), "schemas", "den.theme.v1.json"),
|
|
173
|
+
"utf-8",
|
|
174
|
+
),
|
|
175
|
+
) as object;
|
|
176
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
177
|
+
addFormats(ajv);
|
|
178
|
+
validator = ajv.compile(schema);
|
|
179
|
+
return validator;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Why an SVG mark is unsafe, or null. Den shows it with <img>, which runs
|
|
184
|
+
* nothing, but the file is also opened directly: refuse script, handlers,
|
|
185
|
+
* foreign HTML, entities and anything fetched from elsewhere.
|
|
186
|
+
*/
|
|
187
|
+
export function unsafeSvg(svg: string): string | null {
|
|
188
|
+
if (/<script/i.test(svg)) return "has a <script>";
|
|
189
|
+
if (/<foreignObject/i.test(svg)) return "has a <foreignObject>";
|
|
190
|
+
if (/<!ENTITY|<!DOCTYPE/i.test(svg)) return "declares entities";
|
|
191
|
+
if (/\son\w+\s*=/i.test(svg)) return "has an event handler";
|
|
192
|
+
if (/(?:xlink:)?href\s*=\s*["'](?!#)/i.test(svg))
|
|
193
|
+
return "references an external resource (href must start with #)";
|
|
194
|
+
if (/url\(\s*["']?(?!#)/i.test(svg))
|
|
195
|
+
return "references an external resource (url() must start with #)";
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The image kinds a mark can be drawn from: one per mark. */
|
|
200
|
+
export const MARK_IMAGE_KINDS = ["svg", "png", "webp"] as const;
|
|
201
|
+
export type MarkImageKind = (typeof MARK_IMAGE_KINDS)[number];
|
|
202
|
+
|
|
203
|
+
/** The image kinds a mark names (svg, png, webp; a dark variant counts). */
|
|
204
|
+
export function markImageKinds(
|
|
205
|
+
mark: ThemeManifest["mark"] | undefined,
|
|
206
|
+
): MarkImageKind[] {
|
|
207
|
+
const m = mark ?? {};
|
|
208
|
+
return MARK_IMAGE_KINDS.filter((k) => m[k] || m[`${k}_dark`]);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The mark's image file for a mode: the dark variant in dark mode when the
|
|
213
|
+
* mark has one, else the base file; null for a text mark (or none).
|
|
214
|
+
*/
|
|
215
|
+
export function markImage(
|
|
216
|
+
mark: ThemeManifest["mark"] | undefined,
|
|
217
|
+
mode: ThemeMode,
|
|
218
|
+
): { kind: MarkImageKind; file: string } | null {
|
|
219
|
+
const m = mark ?? {};
|
|
220
|
+
for (const kind of MARK_IMAGE_KINDS) {
|
|
221
|
+
const base = m[kind];
|
|
222
|
+
if (!base) continue;
|
|
223
|
+
const dark = m[`${kind}_dark`];
|
|
224
|
+
return { kind, file: mode === "dark" && dark ? dark : base };
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The largest raster mark or favicon file, in bytes. */
|
|
230
|
+
export const IMAGE_MAX_BYTES = 64 * 1024;
|
|
231
|
+
/** A raster mark's smallest side: 2× the top bar's 22px mark. */
|
|
232
|
+
export const RASTER_MIN_PX = 44;
|
|
233
|
+
|
|
234
|
+
const PNG_SIGNATURE = Buffer.from([
|
|
235
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A PNG's or a WebP's size in pixels, from its own header (PNG IHDR; WebP
|
|
240
|
+
* VP8, VP8L or VP8X), or null when `buf` is not that format.
|
|
241
|
+
*/
|
|
242
|
+
export function rasterSize(
|
|
243
|
+
buf: Buffer,
|
|
244
|
+
format: "png" | "webp",
|
|
245
|
+
): { width: number; height: number } | null {
|
|
246
|
+
if (format === "png") {
|
|
247
|
+
if (buf.length < 24 || !buf.subarray(0, 8).equals(PNG_SIGNATURE))
|
|
248
|
+
return null;
|
|
249
|
+
if (buf.toString("latin1", 12, 16) !== "IHDR") return null;
|
|
250
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
251
|
+
}
|
|
252
|
+
if (
|
|
253
|
+
buf.length < 30 ||
|
|
254
|
+
buf.toString("latin1", 0, 4) !== "RIFF" ||
|
|
255
|
+
buf.toString("latin1", 8, 12) !== "WEBP"
|
|
256
|
+
)
|
|
257
|
+
return null;
|
|
258
|
+
const chunk = buf.toString("latin1", 12, 16);
|
|
259
|
+
if (chunk === "VP8X")
|
|
260
|
+
return {
|
|
261
|
+
width: buf.readUIntLE(24, 3) + 1,
|
|
262
|
+
height: buf.readUIntLE(27, 3) + 1,
|
|
263
|
+
};
|
|
264
|
+
if (chunk === "VP8L") {
|
|
265
|
+
if (buf[20] !== 0x2f) return null;
|
|
266
|
+
const b = buf.readUInt32LE(21);
|
|
267
|
+
return { width: (b & 0x3fff) + 1, height: ((b >>> 14) & 0x3fff) + 1 };
|
|
268
|
+
}
|
|
269
|
+
if (chunk === "VP8 ") {
|
|
270
|
+
if (buf[23] !== 0x9d || buf[24] !== 0x01 || buf[25] !== 0x2a) return null;
|
|
271
|
+
return {
|
|
272
|
+
width: buf.readUInt16LE(26) & 0x3fff,
|
|
273
|
+
height: buf.readUInt16LE(28) & 0x3fff,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Whether `buf` starts as an .ico file does (00 00 01 00). */
|
|
280
|
+
function isIco(buf: Buffer): boolean {
|
|
281
|
+
return (
|
|
282
|
+
buf.length >= 6 &&
|
|
283
|
+
buf[0] === 0 &&
|
|
284
|
+
buf[1] === 0 &&
|
|
285
|
+
buf[2] === 1 &&
|
|
286
|
+
buf[3] === 0
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* What is wrong with a raster mark or favicon file, as messages (empty when
|
|
292
|
+
* nothing): its first bytes must be its extension's format, it is at most
|
|
293
|
+
* 64 KB, and a raster mark is at least 44×44 px.
|
|
294
|
+
*/
|
|
295
|
+
export function imageFileIssues(
|
|
296
|
+
rel: string,
|
|
297
|
+
buf: Buffer,
|
|
298
|
+
opts: { minPx?: number } = {},
|
|
299
|
+
): string[] {
|
|
300
|
+
const ext = path.extname(rel).toLowerCase();
|
|
301
|
+
const out: string[] = [];
|
|
302
|
+
if (buf.length > IMAGE_MAX_BYTES)
|
|
303
|
+
out.push(
|
|
304
|
+
`${rel} is ${Math.ceil(buf.length / 1024)} KB: a mark or favicon file is at most ${IMAGE_MAX_BYTES / 1024} KB`,
|
|
305
|
+
);
|
|
306
|
+
if (ext === ".ico") {
|
|
307
|
+
if (!isIco(buf))
|
|
308
|
+
out.push(`${rel} is not an ICO file (it does not start 00 00 01 00)`);
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
const format = ext === ".png" ? "png" : ext === ".webp" ? "webp" : null;
|
|
312
|
+
if (!format) return out;
|
|
313
|
+
const size = rasterSize(buf, format);
|
|
314
|
+
if (!size) {
|
|
315
|
+
out.push(
|
|
316
|
+
format === "png"
|
|
317
|
+
? `${rel} is not a PNG file (it does not start with the PNG signature 89 50 4E 47 0D 0A 1A 0A)`
|
|
318
|
+
: `${rel} is not a WebP file (it does not start RIFF….WEBP with a VP8, VP8L or VP8X header)`,
|
|
319
|
+
);
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
const min = opts.minPx ?? 0;
|
|
323
|
+
if (size.width < min || size.height < min)
|
|
324
|
+
out.push(
|
|
325
|
+
`${rel} is ${size.width}×${size.height} px: a raster mark is at least ${min}×${min} px (2× the 22px bar mark)`,
|
|
326
|
+
);
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Every file the manifest references, relative to the theme's directory. */
|
|
331
|
+
export function themeFiles(theme: ThemeManifest): string[] {
|
|
332
|
+
return [
|
|
333
|
+
theme.mark?.svg,
|
|
334
|
+
theme.mark?.svg_dark,
|
|
335
|
+
theme.mark?.png,
|
|
336
|
+
theme.mark?.png_dark,
|
|
337
|
+
theme.mark?.webp,
|
|
338
|
+
theme.mark?.webp_dark,
|
|
339
|
+
theme.favicon,
|
|
340
|
+
...(theme.fonts ?? []).flatMap((f) => f.files.map((x) => x.path)),
|
|
341
|
+
].filter((p): p is string => !!p);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const SPDX_MESSAGE =
|
|
345
|
+
"license must be an SPDX expression (e.g. MIT, OFL-1.1, LicenseRef-x); UNLICENSED, NONE and NOASSERTION are refused";
|
|
346
|
+
const CSS_MESSAGE =
|
|
347
|
+
"must be a plain CSS value: no ; { } < > or backslash, url(), @import or expression()";
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Say what a rule means where the schema error would not: a `not` clause
|
|
351
|
+
* reports only "must NOT be valid", and the SPDX pattern is unreadable.
|
|
352
|
+
*/
|
|
353
|
+
export function friendlyMessage(e: {
|
|
354
|
+
instancePath: string;
|
|
355
|
+
keyword: string;
|
|
356
|
+
}): string | null {
|
|
357
|
+
const license = /\/license$/.test(e.instancePath);
|
|
358
|
+
if (license && (e.keyword === "not" || e.keyword === "pattern"))
|
|
359
|
+
return SPDX_MESSAGE;
|
|
360
|
+
if (e.keyword === "not") return CSS_MESSAGE;
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
type SchemaError = {
|
|
365
|
+
instancePath: string;
|
|
366
|
+
keyword: string;
|
|
367
|
+
message?: string;
|
|
368
|
+
params?: Record<string, unknown>;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Schema errors as issues, with the friendly messages above. `extra` says
|
|
373
|
+
* what another schema's own rules mean (den.plugin/v1's capabilities); the
|
|
374
|
+
* plugin schema shares the SPDX rule, so it shares this too.
|
|
375
|
+
*/
|
|
376
|
+
export function schemaIssues(
|
|
377
|
+
errors: SchemaError[] | null | undefined,
|
|
378
|
+
extra: (e: SchemaError) => string | null = () => null,
|
|
379
|
+
): ThemeIssue[] {
|
|
380
|
+
return (errors ?? []).map((e) => ({
|
|
381
|
+
at: e.instancePath,
|
|
382
|
+
message:
|
|
383
|
+
extra(e) ??
|
|
384
|
+
friendlyMessage(e) ??
|
|
385
|
+
`${e.message ?? "invalid"}${
|
|
386
|
+
e.params && "additionalProperty" in e.params
|
|
387
|
+
? ` (${String(e.params.additionalProperty)})`
|
|
388
|
+
: ""
|
|
389
|
+
}`,
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Why a manifest's `den` range excludes the running Den, or null. A range
|
|
395
|
+
* that is not valid semver is an error too. Prereleases count: 0.3.0-dev.7
|
|
396
|
+
* satisfies ">=0.3.0-0 <0.4.0" but not ">=0.3.0", which gets a hint
|
|
397
|
+
* (PL-SPEC d4: validate fails, load skips).
|
|
398
|
+
*/
|
|
399
|
+
export function denRangeIssue(
|
|
400
|
+
range: string | undefined,
|
|
401
|
+
version: string | undefined = engineVersion(),
|
|
402
|
+
): string | null {
|
|
403
|
+
if (range === undefined) return null;
|
|
404
|
+
if (!semver.validRange(range))
|
|
405
|
+
return `den range "${range}" is not a semver range`;
|
|
406
|
+
if (!version) return null;
|
|
407
|
+
if (semver.satisfies(version, range, { includePrerelease: true }))
|
|
408
|
+
return null;
|
|
409
|
+
// A prerelease Den that misses only for being a prerelease (>=0.3.0 on
|
|
410
|
+
// 0.3.0-dev.9): say how to write the range, from its own lower bound.
|
|
411
|
+
const release = semver.prerelease(version)
|
|
412
|
+
? `${semver.major(version)}.${semver.minor(version)}.${semver.patch(version)}`
|
|
413
|
+
: null;
|
|
414
|
+
const hint =
|
|
415
|
+
release && semver.satisfies(release, range)
|
|
416
|
+
? ` (prerelease builds need a -0 range, e.g. >=${semver.minVersion(range)?.version ?? release}-0)`
|
|
417
|
+
: "";
|
|
418
|
+
return `needs Den ${range}; this is ${version}${hint}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Validate the theme in `dir`. With `expectId`, the manifest's id must match
|
|
423
|
+
* (a repo of several themes keeps each under themes/<id>/).
|
|
424
|
+
*/
|
|
425
|
+
export function validateThemeDir(
|
|
426
|
+
dir: string,
|
|
427
|
+
opts: { expectId?: string; denVersion?: string } = {},
|
|
428
|
+
): ThemeValidation {
|
|
429
|
+
const file = path.join(dir, THEME_FILE);
|
|
430
|
+
const fail = (message: string): ThemeValidation => ({
|
|
431
|
+
ok: false,
|
|
432
|
+
issues: [{ at: "", message }],
|
|
433
|
+
contrast: [],
|
|
434
|
+
});
|
|
435
|
+
let raw: unknown;
|
|
436
|
+
try {
|
|
437
|
+
raw = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
438
|
+
} catch (e) {
|
|
439
|
+
return fail(
|
|
440
|
+
fs.existsSync(file)
|
|
441
|
+
? `${THEME_FILE} is not valid JSON: ${(e as Error).message}`
|
|
442
|
+
: `no ${THEME_FILE} in ${dir}`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
const validate = schemaValidator();
|
|
446
|
+
if (!validate(raw)) {
|
|
447
|
+
return {
|
|
448
|
+
ok: false,
|
|
449
|
+
issues: schemaIssues(validate.errors),
|
|
450
|
+
contrast: [],
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
const theme = raw as ThemeManifest;
|
|
454
|
+
const issues: ThemeIssue[] = [];
|
|
455
|
+
if (opts.expectId && theme.id !== opts.expectId)
|
|
456
|
+
issues.push({
|
|
457
|
+
at: "/id",
|
|
458
|
+
message: `id "${theme.id}" does not match its directory "${opts.expectId}"`,
|
|
459
|
+
});
|
|
460
|
+
const kinds = markImageKinds(theme.mark);
|
|
461
|
+
if (kinds.length > 1)
|
|
462
|
+
issues.push({
|
|
463
|
+
at: "/mark",
|
|
464
|
+
message: `mark gives ${kinds.join(" and ")}: a mark is one image (svg, png or webp), or text`,
|
|
465
|
+
});
|
|
466
|
+
for (const k of ["png", "webp"] as const)
|
|
467
|
+
if (theme.mark?.[`${k}_dark`] && !theme.mark[k])
|
|
468
|
+
issues.push({
|
|
469
|
+
at: `/mark/${k}_dark`,
|
|
470
|
+
message: `mark.${k}_dark needs mark.${k} (the base file, for light mode)`,
|
|
471
|
+
});
|
|
472
|
+
const rasterMarks = new Set(
|
|
473
|
+
[
|
|
474
|
+
theme.mark?.png,
|
|
475
|
+
theme.mark?.png_dark,
|
|
476
|
+
theme.mark?.webp,
|
|
477
|
+
theme.mark?.webp_dark,
|
|
478
|
+
].filter(Boolean),
|
|
479
|
+
);
|
|
480
|
+
const root = path.resolve(dir);
|
|
481
|
+
for (const rel of themeFiles(theme)) {
|
|
482
|
+
const abs = path.resolve(root, rel);
|
|
483
|
+
if (!abs.startsWith(root + path.sep))
|
|
484
|
+
issues.push({ at: "", message: `${rel} is outside the theme` });
|
|
485
|
+
else if (!fs.existsSync(abs))
|
|
486
|
+
issues.push({ at: "", message: `${rel} is missing` });
|
|
487
|
+
else if (rel.endsWith(".svg")) {
|
|
488
|
+
const bad = unsafeSvg(fs.readFileSync(abs, "utf-8"));
|
|
489
|
+
if (bad)
|
|
490
|
+
issues.push({
|
|
491
|
+
at: "",
|
|
492
|
+
message: `${rel} ${bad}: a mark is an image only`,
|
|
493
|
+
});
|
|
494
|
+
if (rel === theme.favicon && fs.statSync(abs).size > IMAGE_MAX_BYTES)
|
|
495
|
+
issues.push(
|
|
496
|
+
...imageFileIssues(rel, fs.readFileSync(abs)).map((message) => ({
|
|
497
|
+
at: "",
|
|
498
|
+
message,
|
|
499
|
+
})),
|
|
500
|
+
);
|
|
501
|
+
} else if (/\.(png|webp|ico)$/.test(rel)) {
|
|
502
|
+
// A raster mark or favicon: its format by its first bytes, its size,
|
|
503
|
+
// and (a mark) its pixels, read from its own header.
|
|
504
|
+
for (const message of imageFileIssues(rel, fs.readFileSync(abs), {
|
|
505
|
+
minPx: rasterMarks.has(rel) ? RASTER_MIN_PX : 0,
|
|
506
|
+
}))
|
|
507
|
+
issues.push({ at: "", message });
|
|
508
|
+
} else if (rel.endsWith(".woff2")) {
|
|
509
|
+
const head = Buffer.alloc(4);
|
|
510
|
+
const fd = fs.openSync(abs, "r");
|
|
511
|
+
fs.readSync(fd, head, 0, 4, 0);
|
|
512
|
+
fs.closeSync(fd);
|
|
513
|
+
if (head.toString("latin1") !== "wOF2")
|
|
514
|
+
issues.push({ at: "", message: `${rel} is not a woff2 file` });
|
|
515
|
+
if (
|
|
516
|
+
!fs
|
|
517
|
+
.readdirSync(path.dirname(abs))
|
|
518
|
+
.some((f) => /^(OFL|LICEN[CS]E|COPYING)(\.(txt|md))?$/i.test(f))
|
|
519
|
+
)
|
|
520
|
+
issues.push({
|
|
521
|
+
at: "",
|
|
522
|
+
message: `${rel}: no OFL.txt or LICENSE beside it`,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
for (const f of theme.fonts ?? [])
|
|
527
|
+
if (!f.license.trim())
|
|
528
|
+
issues.push({ at: "/fonts", message: `${f.family}: no license` });
|
|
529
|
+
const range = denRangeIssue(theme.den, opts.denVersion);
|
|
530
|
+
if (range) issues.push({ at: "/den", message: range });
|
|
531
|
+
const contrast = checkContrast(theme);
|
|
532
|
+
return {
|
|
533
|
+
ok: issues.length === 0 && contrast.length === 0,
|
|
534
|
+
theme,
|
|
535
|
+
issues,
|
|
536
|
+
contrast,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
@@ -228,10 +228,12 @@ export type ServiceHealthConfig =
|
|
|
228
228
|
));
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
|
-
* A command can be a plain shell string
|
|
232
|
-
*
|
|
231
|
+
* A command can be a plain shell string or an object that explicitly
|
|
232
|
+
* controls behaviour. up and restart detach by default; every other command
|
|
233
|
+
* runs to completion unless it says `detachable: true` (0.3.0-dev.40):
|
|
233
234
|
*
|
|
234
|
-
* up: make up # string — detached
|
|
235
|
+
* up: make up # string — detached (up and restart)
|
|
236
|
+
* preflight: make check # string — runs to completion
|
|
235
237
|
* seed:
|
|
236
238
|
* cmd: make db-seed
|
|
237
239
|
* detachable: false # wait for exit, stream output until done
|
|
@@ -703,6 +705,13 @@ export interface WorkspaceConfig {
|
|
|
703
705
|
* resolved org is computed on read (see workspace-org.ts).
|
|
704
706
|
*/
|
|
705
707
|
org?: string;
|
|
708
|
+
/**
|
|
709
|
+
* The Compose project suffix this workspace's stacks run under, in place of
|
|
710
|
+
* its own name (`default`: `main`). Workspaces that set the same value
|
|
711
|
+
* share their stacks' projects, and so their volumes and data. Wins over
|
|
712
|
+
* the blueprint's `stack_namespace`. See stacks/namespace.ts.
|
|
713
|
+
*/
|
|
714
|
+
stack_namespace?: string;
|
|
706
715
|
}
|
|
707
716
|
|
|
708
717
|
export interface WorkspacesFile {
|