@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.
Files changed (158) hide show
  1. package/CHANGELOG.md +1731 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +730 -6
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +12 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/agents/default-pack/be-dev.md +20 -0
  22. package/src/shared/agents/default-pack/devops.md +14 -0
  23. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  24. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  25. package/src/shared/agents/default-pack/planner.md +17 -0
  26. package/src/shared/agents/default-pack/reviewer.md +14 -0
  27. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  28. package/src/shared/agents/den-claude.ts +173 -0
  29. package/src/shared/agents/generic.ts +52 -0
  30. package/src/shared/agents/personas.ts +218 -0
  31. package/src/shared/agents/records.ts +118 -0
  32. package/src/shared/agents/runtime.ts +192 -0
  33. package/src/shared/agents/runtimes.ts +47 -0
  34. package/src/shared/auth/headless-login.ts +1 -1
  35. package/src/shared/auth/sudo-vault.ts +93 -31
  36. package/src/shared/auth/token-providers/index.ts +7 -3
  37. package/src/shared/brand.ts +7 -0
  38. package/src/shared/config-error.ts +33 -3
  39. package/src/shared/constants.ts +3 -24
  40. package/src/shared/den-config.ts +257 -5
  41. package/src/shared/edge/config.ts +147 -21
  42. package/src/shared/edge/exec.ts +31 -0
  43. package/src/shared/edge/included.ts +169 -0
  44. package/src/shared/edge/render.ts +69 -14
  45. package/src/shared/edge/runtime.ts +128 -121
  46. package/src/shared/edge/status.ts +68 -8
  47. package/src/shared/edge/tls.ts +328 -0
  48. package/src/shared/edge/types.ts +25 -9
  49. package/src/shared/engine.ts +23 -0
  50. package/src/shared/environment-manager/default-workspace.ts +16 -1
  51. package/src/shared/environment-manager/health.ts +73 -38
  52. package/src/shared/environment-manager/scope.ts +168 -0
  53. package/src/shared/environment-manager/service-runner.ts +789 -43
  54. package/src/shared/environment-manager/up-targets.ts +132 -0
  55. package/src/shared/environment-manager/workspaces.ts +19 -0
  56. package/src/shared/health/rollup.ts +48 -0
  57. package/src/shared/open-url.ts +21 -2
  58. package/src/shared/org-sources.ts +78 -59
  59. package/src/shared/org.ts +53 -5
  60. package/src/shared/plugins/manifest.ts +406 -0
  61. package/src/shared/plugins/store.ts +358 -0
  62. package/src/shared/process/legacy-adopt.ts +285 -0
  63. package/src/shared/process/process-group.ts +202 -0
  64. package/src/shared/process/spawn-streaming.ts +7 -0
  65. package/src/shared/secret-env.ts +70 -0
  66. package/src/shared/secrets/doppler.ts +1 -1
  67. package/src/shared/secrets/index.ts +279 -50
  68. package/src/shared/secrets/none.ts +56 -0
  69. package/src/shared/secrets/strategies.ts +202 -0
  70. package/src/shared/secrets/types.ts +1 -1
  71. package/src/shared/sessions/client.ts +277 -0
  72. package/src/shared/sessions/den-pty.ts +97 -0
  73. package/src/shared/sessions/env.ts +79 -0
  74. package/src/shared/sessions/host.ts +667 -0
  75. package/src/shared/sessions/names.ts +85 -0
  76. package/src/shared/sessions/paths.ts +27 -0
  77. package/src/shared/sessions/protocol.ts +72 -0
  78. package/src/shared/sessions/pty.ts +141 -0
  79. package/src/shared/sessions/ring-buffer.ts +160 -0
  80. package/src/shared/sessions/types.ts +81 -0
  81. package/src/shared/smoke/git-remote.ts +40 -0
  82. package/src/shared/stacks/manifest-commands.ts +1 -1
  83. package/src/shared/stacks/namespace.ts +178 -0
  84. package/src/shared/stacks/owners.ts +303 -0
  85. package/src/shared/stacks/render.ts +8 -3
  86. package/src/shared/stacks/runtime.ts +100 -16
  87. package/src/shared/stacks/status.ts +63 -12
  88. package/src/shared/strategies/registry.ts +89 -0
  89. package/src/shared/theme/contract.ts +538 -0
  90. package/src/shared/types/index.ts +12 -3
  91. package/src/ui/cli/agents-command.ts +812 -0
  92. package/src/ui/cli/create.ts +24 -7
  93. package/src/ui/cli/doctor-command.ts +260 -0
  94. package/src/ui/cli/edge-command.ts +56 -10
  95. package/src/ui/cli/env-probe.ts +266 -0
  96. package/src/ui/cli/flag-parser.ts +18 -5
  97. package/src/ui/cli/health-display.ts +25 -0
  98. package/src/ui/cli/index.ts +580 -60
  99. package/src/ui/cli/notice.ts +20 -0
  100. package/src/ui/cli/plugin-command.ts +849 -0
  101. package/src/ui/cli/sessions-command.ts +718 -0
  102. package/src/ui/cli/stack-command.ts +63 -2
  103. package/src/ui/cli/test-runner.ts +11 -9
  104. package/src/ui/cli/theme-command.ts +136 -0
  105. package/src/ui/cli/web-command.ts +47 -23
  106. package/src/ui/mcp/api.ts +28 -2
  107. package/src/ui/mcp/scope.ts +145 -20
  108. package/src/ui/mcp/server.ts +27 -2
  109. package/src/ui/mcp/tools.ts +94 -12
  110. package/src/ui/server/appearance-prefs.ts +95 -0
  111. package/src/ui/server/audit.ts +35 -0
  112. package/src/ui/server/daemon-reaper.ts +29 -1
  113. package/src/ui/server/engine-stamp.ts +83 -0
  114. package/src/ui/server/github-service.ts +23 -44
  115. package/src/ui/server/graph-page.ts +20 -0
  116. package/src/ui/server/graph-prefs.ts +44 -1
  117. package/src/ui/server/http-errors.ts +293 -0
  118. package/src/ui/server/http-helpers.ts +22 -3
  119. package/src/ui/server/index.ts +539 -51
  120. package/src/ui/server/public/appearance.js +531 -0
  121. package/src/ui/server/public/edge-page.js +248 -0
  122. package/src/ui/server/public/edge.html +251 -0
  123. package/src/ui/server/public/environments.html +65 -83
  124. package/src/ui/server/public/error-page.js +247 -0
  125. package/src/ui/server/public/error.html +260 -0
  126. package/src/ui/server/public/graph-model.js +332 -14
  127. package/src/ui/server/public/graph.css +141 -47
  128. package/src/ui/server/public/graph.js +223 -44
  129. package/src/ui/server/public/header.js +396 -600
  130. package/src/ui/server/public/health-fit.js +248 -0
  131. package/src/ui/server/public/health-rollup.js +128 -0
  132. package/src/ui/server/public/i18n/es.js +287 -0
  133. package/src/ui/server/public/i18n/ja.js +276 -0
  134. package/src/ui/server/public/index.html +139 -438
  135. package/src/ui/server/public/local-dev.html +3468 -4044
  136. package/src/ui/server/public/logs.html +71 -208
  137. package/src/ui/server/public/panel.js +14 -2
  138. package/src/ui/server/public/release.html +175 -389
  139. package/src/ui/server/public/services.html +596 -770
  140. package/src/ui/server/public/settings-page.js +759 -0
  141. package/src/ui/server/public/settings-registry.js +521 -0
  142. package/src/ui/server/public/settings.html +590 -0
  143. package/src/ui/server/public/shell.js +3869 -0
  144. package/src/ui/server/public/topbar.js +1413 -0
  145. package/src/ui/server/public/version-label.js +12 -0
  146. package/src/ui/server/public/workspace-picker.js +74 -34
  147. package/src/ui/server/readonly-routes.ts +37 -3
  148. package/src/ui/server/services-list.ts +94 -17
  149. package/src/ui/server/settings-distribution.ts +74 -0
  150. package/src/ui/server/shell-prefs.ts +192 -0
  151. package/src/ui/server/theme-routes.ts +292 -0
  152. package/src/ui/server/theme-runtime.ts +407 -0
  153. package/src/ui/server/ui-assets.ts +16 -1
  154. package/src/ui/server/ui-language.ts +50 -0
  155. package/src/ui/server/workspace-routes.ts +48 -18
  156. package/themes/den/den-theme.json +72 -0
  157. package/themes/den/favicon.ico +0 -0
  158. package/themes/den/mark.svg +6 -0
@@ -0,0 +1,849 @@
1
+ /**
2
+ * `plugin list | validate | add | update | remove` (PL-1a, PL-2a), and the
3
+ * engine under `theme add | list | validate | remove`, which is the same
4
+ * commands filtered to kind theme (see theme-command.ts).
5
+ *
6
+ * Two ways a plugin gets into a distribution (PL-SPEC §2):
7
+ * local plugins/<id>/ with no lock entry: the distribution's own, of any
8
+ * kind, edited in place. Being present is its approval.
9
+ * added vendored by `add` from a git repo at an exact ref, pinned in
10
+ * plugins.lock.json; `list` fails when its bytes change.
11
+ * Every plugins/<id>/ needs a manifest (den-plugin.json, or den-theme.json
12
+ * for a theme): one with neither is an error, never skipped.
13
+ */
14
+ import * as fs from "fs";
15
+ import * as os from "os";
16
+ import * as path from "path";
17
+ import chalk from "chalk";
18
+ import { getDistRoot, getEngineRoot } from "../../shared/paths.js";
19
+ import { product } from "../../shared/product.js";
20
+ import { THEME_FILE, validateThemeDir } from "../../shared/theme/contract.js";
21
+ import {
22
+ BUILTIN_ID,
23
+ PLUGIN_FILE,
24
+ PLUGIN_ID,
25
+ PLUGIN_ID_RULE,
26
+ PLUGIN_NOT_A_PLUGIN,
27
+ actingCapabilities,
28
+ validatePluginDir,
29
+ type PluginValidation,
30
+ } from "../../shared/plugins/manifest.js";
31
+ import {
32
+ FILE_SOURCE_HINT,
33
+ LOCK_FILE,
34
+ PLUGINS_DIR,
35
+ defaultGit,
36
+ hashFiles,
37
+ installedThemeIds,
38
+ parsePluginSource,
39
+ pluginContent,
40
+ pluginDirFor,
41
+ pluginDirNames,
42
+ pluginSymlinkNames,
43
+ readLock,
44
+ readPluginsAllow,
45
+ refKind,
46
+ sourceAllowed,
47
+ sourceString,
48
+ uncommittedFiles,
49
+ writeLock,
50
+ type GitRunner,
51
+ type LockEntry,
52
+ type PluginSource,
53
+ } from "../../shared/plugins/store.js";
54
+
55
+ export interface PluginDeps {
56
+ distRoot?: string;
57
+ engineRoot?: string;
58
+ git?: GitRunner;
59
+ tmpDir?: string;
60
+ log?: (line: string) => void;
61
+ }
62
+
63
+ /** "plugin" for `plugin *`; "theme" for the `theme *` sugar (kind theme only). */
64
+ export type PluginScope = "plugin" | "theme";
65
+
66
+ /** One line naming what a failed validation found. */
67
+ export function validationSummary(v: {
68
+ issues: { at: string; message: string }[];
69
+ contrast: {
70
+ mode: string;
71
+ fg: string;
72
+ bg: string;
73
+ ratio: number;
74
+ min: number;
75
+ }[];
76
+ }): string {
77
+ return [
78
+ ...v.issues.map((i) => (i.at ? `${i.at}: ${i.message}` : i.message)),
79
+ ...v.contrast.map(
80
+ (c) => `${c.mode}: ${c.fg} on ${c.bg} is ${c.ratio}, needs ${c.min}`,
81
+ ),
82
+ ].join("; ");
83
+ }
84
+
85
+ export function printValidation(
86
+ label: string,
87
+ v: PluginValidation,
88
+ log: (l: string) => void,
89
+ ): void {
90
+ if (v.ok) {
91
+ log(chalk.green(`✓ ${label}: ${v.plugin!.name} ${v.plugin!.version}`));
92
+ return;
93
+ }
94
+ log(chalk.red(`✗ ${label}`));
95
+ for (const i of v.issues) {
96
+ const at = i.at || v.manifestFile;
97
+ log(` ${at ? `${at}: ` : ""}${i.message}`);
98
+ }
99
+ for (const c of v.contrast)
100
+ log(
101
+ ` ${c.mode}: ${c.fg} on ${c.bg} is ${c.ratio.toFixed(2)}:1, needs ${c.min}:1`,
102
+ );
103
+ }
104
+
105
+ /**
106
+ * The git warning for a local plugin (PL-1a). Being present is a local
107
+ * plugin's approval, and Den can't tell committed from uncommitted, so when
108
+ * one that acts (api.actions, secrets, exec, net, send) has files git does
109
+ * not hold as committed, say so: review happens where the distribution's
110
+ * code is reviewed. Files-tier and read-only plugins never get it.
111
+ */
112
+ function gitWarning(
113
+ dir: string,
114
+ shown: string,
115
+ v: PluginValidation,
116
+ log: (l: string) => void,
117
+ ): void {
118
+ const acting = actingCapabilities(v.plugin?.capabilities ?? []);
119
+ if (acting.length === 0) return;
120
+ const files = uncommittedFiles(dir);
121
+ if (!files || files.length === 0) return;
122
+ const list =
123
+ files.slice(0, 5).join(", ") +
124
+ (files.length > 5 ? `, and ${files.length - 5} more` : "");
125
+ log(
126
+ ` ${chalk.yellow("WARNING")} ${shown} has files git has not committed (${list}), and it declares ${acting.join(", ")}: Den loads what is on disk, so they are approved unreviewed`,
127
+ );
128
+ log(
129
+ ` ${chalk.gray("→ commit them, so the distribution's review covers what this plugin may do")}`,
130
+ );
131
+ }
132
+
133
+ const rel = (id: string) => `${PLUGINS_DIR}/${id}`;
134
+
135
+ interface Row {
136
+ id: string;
137
+ version: string;
138
+ kinds: string[];
139
+ ok: boolean;
140
+ state: string;
141
+ }
142
+
143
+ /** Why a plugins/<name>/ is not a plugin, for list and validate. */
144
+ const NOT_AN_ID = `not a plugin id, which is ${PLUGIN_ID_RULE}`;
145
+ const NOT_A_PLUGIN = PLUGIN_NOT_A_PLUGIN;
146
+ const SYMLINK = `a symlink, not a directory: a plugin's files live in ${PLUGINS_DIR}/<id>/ itself (copy them in, or plugin add it)`;
147
+
148
+ const hasManifest = (dir: string) =>
149
+ fs.existsSync(path.join(dir, PLUGIN_FILE)) ||
150
+ fs.existsSync(path.join(dir, THEME_FILE));
151
+
152
+ /** The lock, or {} with a problem line when it cannot be read. */
153
+ function lockOrProblem(
154
+ distRoot: string,
155
+ problems: string[],
156
+ ): Record<string, LockEntry> {
157
+ try {
158
+ return readLock(distRoot);
159
+ } catch (e) {
160
+ problems.push(
161
+ `${LOCK_FILE} could not be read (${(e as Error).message}): fix it, or delete it and add each plugin again`,
162
+ );
163
+ return {};
164
+ }
165
+ }
166
+
167
+ /** A lock key whose entry names another id: the dir and the lock disagree. */
168
+ const misfiled = (key: string, e: LockEntry) =>
169
+ `in ${LOCK_FILE} as "${key}", but its id is "${String(e.id)}": fix the entry, or remove it and add the plugin again`;
170
+
171
+ interface Listing {
172
+ builtin: Row | null;
173
+ problems: string[];
174
+ rows: Row[];
175
+ lock: Record<string, LockEntry>;
176
+ }
177
+
178
+ /**
179
+ * Every plugin in the distribution with its check: the built-in theme, each
180
+ * lock entry against its bytes, then each local plugin. Theme scope keeps
181
+ * only themes, and skips local dirs that are not themes. In plugin scope
182
+ * every directory (and symlink) in plugins/ gets exactly one row: nothing
183
+ * there throws or is skipped.
184
+ */
185
+ function listRows(
186
+ distRoot: string,
187
+ engineRoot: string,
188
+ scope: PluginScope,
189
+ ): Listing {
190
+ const cli = product().cli;
191
+ const rows: Row[] = [];
192
+ const problems: string[] = [];
193
+ const bundled = validateThemeDir(path.join(engineRoot, "themes", "den"));
194
+ const builtin: Row | null = bundled.theme
195
+ ? {
196
+ id: BUILTIN_ID,
197
+ version: bundled.theme.version,
198
+ kinds: ["theme"],
199
+ ok: true,
200
+ state: "built in",
201
+ }
202
+ : null;
203
+ // 0.3.0-dev.0 vendored into themes/<id>/ with themes.lock.json.
204
+ if (fs.existsSync(path.join(distRoot, "themes.lock.json")))
205
+ problems.push(
206
+ `themes.lock.json is from ${cli} 0.3.0-dev.0 and is no longer read: delete it and themes/<id>/, then run theme add again (now ${PLUGINS_DIR}/<id>/, ${LOCK_FILE})`,
207
+ );
208
+ const lock = lockOrProblem(distRoot, problems);
209
+ const bad = (id: string, e: LockEntry | null, state: string): Row => ({
210
+ id,
211
+ version: e?.version ?? "?",
212
+ kinds: e?.kinds ?? [],
213
+ ok: false,
214
+ state: chalk.red(state),
215
+ });
216
+ for (const [key, e] of Object.entries(lock)) {
217
+ if (scope === "theme" && !e.kinds?.includes("theme")) continue;
218
+ if (!PLUGIN_ID.test(key)) {
219
+ rows.push(
220
+ bad(key, e, `in ${LOCK_FILE} under a name that is not a plugin id`),
221
+ );
222
+ continue;
223
+ }
224
+ if (e.id !== key) {
225
+ rows.push(bad(key, e, misfiled(key, e)));
226
+ continue;
227
+ }
228
+ const dir = pluginDirFor(distRoot, key);
229
+ try {
230
+ const v = fs.existsSync(dir)
231
+ ? validatePluginDir(dir, { expectId: key })
232
+ : null;
233
+ const now = v?.ok ? hashFiles(dir, pluginContent(dir, v)) : null;
234
+ // Invalid is not the same as changed: a plugin added under an older
235
+ // Den can be byte-identical and still fail this Den's rules.
236
+ const state = !v
237
+ ? chalk.red(`missing: run ${scope} add again`)
238
+ : !v.ok
239
+ ? chalk.red(
240
+ `no longer valid under this ${cli}: ${validationSummary(v)}`,
241
+ )
242
+ : now !== e.sha256
243
+ ? chalk.red("changed since it was added")
244
+ : chalk.gray(`${e.source}@${e.ref}`);
245
+ rows.push({
246
+ id: key,
247
+ version: e.version ?? "?",
248
+ kinds: e.kinds ?? [],
249
+ ok: !!v?.ok && now === e.sha256,
250
+ state,
251
+ });
252
+ } catch (err) {
253
+ rows.push(bad(key, e, `could not be read: ${(err as Error).message}`));
254
+ }
255
+ }
256
+ // Local plugins: plugins/<id>/ with no lock entry. Nothing to check a hash
257
+ // against; they must still validate, and must have a manifest.
258
+ const themes = new Set(installedThemeIds(distRoot));
259
+ for (const name of pluginDirNames(distRoot)) {
260
+ if (Object.hasOwn(lock, name)) continue;
261
+ if (scope === "theme" && !themes.has(name)) continue;
262
+ const where = `local (${rel(name)})`;
263
+ const dir = path.join(distRoot, PLUGINS_DIR, name);
264
+ if (!PLUGIN_ID.test(name)) {
265
+ rows.push(
266
+ bad(
267
+ name,
268
+ null,
269
+ `${where}: ${NOT_AN_ID}${hasManifest(dir) ? "" : `; ${NOT_A_PLUGIN}`}`,
270
+ ),
271
+ );
272
+ continue;
273
+ }
274
+ if (!hasManifest(dir)) {
275
+ rows.push(bad(name, null, `${where}: ${NOT_A_PLUGIN}`));
276
+ continue;
277
+ }
278
+ try {
279
+ const v = validatePluginDir(dir, { expectId: name });
280
+ const problem =
281
+ name === BUILTIN_ID
282
+ ? `"${BUILTIN_ID}" is the built-in theme's id; rename the plugin`
283
+ : v.ok
284
+ ? null
285
+ : `not valid under this ${cli}: ${validationSummary(v)}`;
286
+ rows.push({
287
+ id: name,
288
+ version: v.plugin?.version ?? "?",
289
+ kinds: v.plugin?.kinds ?? [],
290
+ ok: !problem,
291
+ state: problem ? chalk.red(`${where}, ${problem}`) : chalk.gray(where),
292
+ });
293
+ } catch (err) {
294
+ rows.push(
295
+ bad(
296
+ name,
297
+ null,
298
+ `${where}, could not be read: ${(err as Error).message}`,
299
+ ),
300
+ );
301
+ }
302
+ }
303
+ if (scope === "plugin")
304
+ for (const name of pluginSymlinkNames(distRoot))
305
+ if (!Object.hasOwn(lock, name))
306
+ rows.push(bad(name, null, `local (${rel(name)}): ${SYMLINK}`));
307
+ return { builtin, problems, rows, lock };
308
+ }
309
+
310
+ /** The ids `validate` checks, in order: every lock entry and every dir. */
311
+ function idsToValidate(
312
+ distRoot: string,
313
+ scope: PluginScope,
314
+ lock: Record<string, LockEntry>,
315
+ ): string[] {
316
+ if (scope === "theme") return installedThemeIds(distRoot);
317
+ return [
318
+ ...new Set([
319
+ ...pluginDirNames(distRoot),
320
+ ...pluginSymlinkNames(distRoot),
321
+ ...Object.keys(lock),
322
+ ]),
323
+ ].sort();
324
+ }
325
+
326
+ /** Validate plugins/<id>/ (an installed or listed id); true when it passes. */
327
+ function validateInstalledId(
328
+ distRoot: string,
329
+ scope: PluginScope,
330
+ id: string,
331
+ lock: Record<string, LockEntry>,
332
+ log: (l: string) => void,
333
+ ): boolean {
334
+ const shown = path.join(distRoot, PLUGINS_DIR, id);
335
+ const fail = (...why: string[]) => {
336
+ log(chalk.red(`✗ ${id} (${shown})`));
337
+ for (const w of why) log(` ${w}`);
338
+ return false;
339
+ };
340
+ if (!PLUGIN_ID.test(id))
341
+ return fail(NOT_AN_ID, ...(hasManifest(shown) ? [] : [NOT_A_PLUGIN]));
342
+ if (fs.lstatSync(shown, { throwIfNoEntry: false })?.isSymbolicLink())
343
+ return fail(SYMLINK);
344
+ const entry = Object.hasOwn(lock, id) ? lock[id] : undefined;
345
+ if (entry && entry.id !== id) return fail(misfiled(id, entry));
346
+ const d = pluginDirFor(distRoot, id);
347
+ if (!fs.existsSync(d))
348
+ return fail(
349
+ `missing: in ${LOCK_FILE} but not on disk; run ${scope} add again`,
350
+ );
351
+ if (id === BUILTIN_ID)
352
+ return fail(
353
+ `"${BUILTIN_ID}" is the built-in theme's id; a distribution plugin can't use it`,
354
+ );
355
+ let v: PluginValidation;
356
+ try {
357
+ v = validatePluginDir(d, { expectId: id });
358
+ } catch (err) {
359
+ return fail(`could not be read: ${(err as Error).message}`);
360
+ }
361
+ if (scope === "theme" && v.plugin && !v.plugin.kinds.includes("theme"))
362
+ return fail(
363
+ `provides ${v.plugin.kinds.join(", ")}, not a theme: use plugin validate ${id}`,
364
+ );
365
+ printValidation(`${id} (${d})`, v, log);
366
+ if (!entry) gitWarning(d, `${rel(id)}/`, v, log);
367
+ return v.ok;
368
+ }
369
+
370
+ /** Validate a distribution's plugins (`validate` with no dir). */
371
+ function validateInstalled(
372
+ distRoot: string,
373
+ scope: PluginScope,
374
+ log: (l: string) => void,
375
+ ): number {
376
+ const problems: string[] = [];
377
+ const lock = lockOrProblem(distRoot, problems);
378
+ for (const p of problems) log(chalk.red(`✗ ${p}`));
379
+ const ids = idsToValidate(distRoot, scope, lock);
380
+ if (ids.length === 0) {
381
+ log(
382
+ ` no ${scope}s in ${path.join(distRoot, PLUGINS_DIR)} (den is built in)`,
383
+ );
384
+ return problems.length === 0 ? 0 : 1;
385
+ }
386
+ let ok = problems.length === 0;
387
+ for (const id of ids)
388
+ ok = validateInstalledId(distRoot, scope, id, lock, log) && ok;
389
+ return ok ? 0 : 1;
390
+ }
391
+
392
+ /**
393
+ * The plugins/<id>/ a `validate <arg>` means, or null to treat `arg` as a
394
+ * path. An id that names a dir in plugins/ or a lock entry is never read as
395
+ * a cwd-relative path (consumers ran `plugin validate scratch` from the
396
+ * distribution root and got "no den-plugin.json in ./scratch"). A bare name
397
+ * that is not an id (plugins/x/) means that dir only when no such path
398
+ * exists, so its id error is the one reported.
399
+ */
400
+ function installedIdArg(
401
+ distRoot: string,
402
+ arg: string,
403
+ problems: string[],
404
+ ): { id: string; lock: Record<string, LockEntry> } | null {
405
+ const bare = /^[^/\\]+$/.test(arg) && arg !== "." && arg !== "..";
406
+ if (!bare) return null;
407
+ const there =
408
+ fs.lstatSync(path.join(distRoot, PLUGINS_DIR, arg), {
409
+ throwIfNoEntry: false,
410
+ }) !== undefined;
411
+ if (!PLUGIN_ID.test(arg) && (!there || fs.existsSync(path.resolve(arg))))
412
+ return null;
413
+ const lock = lockOrProblem(distRoot, problems);
414
+ return there || Object.hasOwn(lock, arg) ? { id: arg, lock } : null;
415
+ }
416
+
417
+ /**
418
+ * `validate <dir>`: one plugin, a distribution (its plugins/), or a repo of
419
+ * them (themes/<id>/ and plugins/<id>/ each, id checked against the dir, plus
420
+ * starter/).
421
+ */
422
+ function validateDir(
423
+ dir: string,
424
+ scope: PluginScope,
425
+ log: (l: string) => void,
426
+ ): number {
427
+ if (
428
+ fs.existsSync(path.join(dir, PLUGIN_FILE)) ||
429
+ fs.existsSync(path.join(dir, THEME_FILE))
430
+ ) {
431
+ const v = validatePluginDir(dir);
432
+ printValidation(`${path.basename(dir)} (${dir})`, v, log);
433
+ gitWarning(dir, dir, v, log);
434
+ return v.ok ? 0 : 1;
435
+ }
436
+ if (scope === "plugin" && fs.existsSync(path.join(dir, PLUGINS_DIR)))
437
+ return validateInstalled(dir, scope, log);
438
+ const groups = scope === "theme" ? ["themes"] : ["themes", PLUGINS_DIR];
439
+ const dirs: [string, string | undefined][] = [
440
+ ...groups.flatMap((group) =>
441
+ fs.existsSync(path.join(dir, group))
442
+ ? fs
443
+ .readdirSync(path.join(dir, group))
444
+ .map((d): [string, string] => [path.join(dir, group, d), d])
445
+ : [],
446
+ ),
447
+ ...(fs.existsSync(path.join(dir, "starter", THEME_FILE)) ||
448
+ fs.existsSync(path.join(dir, "starter", PLUGIN_FILE))
449
+ ? [[path.join(dir, "starter"), undefined] as [string, undefined]]
450
+ : []),
451
+ ].filter(([d]) => fs.statSync(d).isDirectory());
452
+ if (dirs.length === 0) {
453
+ log(
454
+ chalk.red(
455
+ scope === "theme"
456
+ ? `✗ no ${THEME_FILE} in ${dir} (or in its themes/*/)`
457
+ : `✗ no ${PLUGIN_FILE} or ${THEME_FILE} in ${dir} (or in its themes/*/ or ${PLUGINS_DIR}/*/)`,
458
+ ),
459
+ );
460
+ return 1;
461
+ }
462
+ let ok = true;
463
+ for (const [d, expectId] of dirs) {
464
+ const v = validatePluginDir(d, expectId ? { expectId } : {});
465
+ printValidation(`${path.basename(d)} (${d})`, v, log);
466
+ ok &&= v.ok;
467
+ }
468
+ return ok ? 0 : 1;
469
+ }
470
+
471
+ interface InstallRequest {
472
+ src: PluginSource;
473
+ track: boolean;
474
+ yes: boolean;
475
+ /** update: the entry being replaced (its id is required to match). */
476
+ updating?: LockEntry;
477
+ }
478
+
479
+ /** Fetch, validate, approve and vendor one plugin: `add` and `update`. */
480
+ function install(
481
+ req: InstallRequest,
482
+ scope: PluginScope,
483
+ distRoot: string,
484
+ deps: PluginDeps,
485
+ log: (l: string) => void,
486
+ ): number {
487
+ const { src, track, yes, updating } = req;
488
+ const label = `${src.repo}${src.subpath ? `//${src.subpath}` : ""}@${src.ref}`;
489
+ // PL-2a: a path on one machine never reaches a committed lock.
490
+ if (src.repo.startsWith("file://")) {
491
+ log(
492
+ chalk.red(`✗ ${src.repo} is a path on this machine: ${FILE_SOURCE_HINT}`),
493
+ );
494
+ return 1;
495
+ }
496
+ let allow: string[] | null;
497
+ try {
498
+ allow = readPluginsAllow(distRoot);
499
+ } catch (e) {
500
+ log(chalk.red(`✗ ${(e as Error).message}; nothing changed`));
501
+ return 1;
502
+ }
503
+ if (!sourceAllowed(src.repo, allow)) {
504
+ log(
505
+ chalk.red(
506
+ `✗ ${src.repo} is not allowed by plugins.allow in den.yml (${allow!.join(", ")}); nothing changed`,
507
+ ),
508
+ );
509
+ return 1;
510
+ }
511
+ const git = deps.git ?? defaultGit;
512
+ const kind = refKind(git, src);
513
+ if (kind === "branch" && !track) {
514
+ log(
515
+ chalk.red(
516
+ `✗ ${src.ref} is a branch: pin a tag or a full commit SHA, so the ${scope} is reproducible` +
517
+ ` (${scope} authors: --track ${src.ref} follows the branch, recorded as tracking)`,
518
+ ),
519
+ );
520
+ return 1;
521
+ }
522
+ if (kind === "none") {
523
+ log(
524
+ chalk.red(
525
+ `✗ ${src.repo} has no tag or branch "${src.ref}" (a commit must be its full 40-character SHA)`,
526
+ ),
527
+ );
528
+ return 1;
529
+ }
530
+ const work = fs.mkdtempSync(
531
+ path.join(deps.tmpDir ?? os.tmpdir(), `den-${scope}-add-`),
532
+ );
533
+ try {
534
+ const clone = path.join(work, "repo");
535
+ if (kind === "commit") {
536
+ fs.mkdirSync(clone);
537
+ git(["init", "--quiet"], clone);
538
+ git(["fetch", "--quiet", "--depth", "1", src.repo, src.ref], clone);
539
+ git(["checkout", "--quiet", "FETCH_HEAD"], clone);
540
+ } else
541
+ git([
542
+ "clone",
543
+ "--quiet",
544
+ "--depth",
545
+ "1",
546
+ "--branch",
547
+ src.ref,
548
+ src.repo,
549
+ clone,
550
+ ]);
551
+ const commit = git(["rev-parse", "HEAD"], clone);
552
+ const pluginDir = path.join(clone, src.subpath);
553
+ const v = validatePluginDir(pluginDir);
554
+ printValidation(label, v, log);
555
+ if (!v.ok) return 1;
556
+ const p = v.plugin!;
557
+ if (scope === "theme" && !p.kinds.includes("theme")) {
558
+ log(
559
+ chalk.red(
560
+ `✗ ${p.id} provides ${p.kinds.join(", ")}, not a theme: use plugin add`,
561
+ ),
562
+ );
563
+ return 1;
564
+ }
565
+ if (p.id === BUILTIN_ID) {
566
+ // Den's own theme ships in the package; a fetched one would shadow it.
567
+ log(chalk.red(`✗ "den" is the built-in theme's id; give yours another`));
568
+ return 1;
569
+ }
570
+ if (updating && p.id !== updating.id) {
571
+ log(
572
+ chalk.red(
573
+ `✗ ${label} is plugin "${p.id}", not "${updating.id}"; nothing changed`,
574
+ ),
575
+ );
576
+ return 1;
577
+ }
578
+ const dest = pluginDirFor(distRoot, p.id);
579
+ const lock = readLock(distRoot);
580
+ const held = lock[p.id];
581
+ // Only ever replace what an earlier add vendored: an unlocked dir is
582
+ // the distribution's own plugin, and theme add leaves other kinds alone.
583
+ if (
584
+ held
585
+ ? scope === "theme" && !held.kinds?.includes("theme")
586
+ : fs.existsSync(dest)
587
+ ) {
588
+ log(
589
+ chalk.red(
590
+ `✗ ${PLUGINS_DIR}/${p.id}/ is already ${held ? `a ${held.kinds.join("/")} plugin` : "a local plugin (not in " + LOCK_FILE + ")"}; nothing changed`,
591
+ ),
592
+ );
593
+ return 1;
594
+ }
595
+ const source = sourceString(src);
596
+ if (scope === "plugin" && held && !updating && held.source !== source) {
597
+ log(
598
+ chalk.red(
599
+ `✗ ${p.id} was added from ${held.source}: remove it first to add it from ${source}; nothing changed`,
600
+ ),
601
+ );
602
+ return 1;
603
+ }
604
+ // Approval: what an added plugin may touch is approved here and kept in
605
+ // the lock. update asks again only when the set grows.
606
+ const asks = p.capabilities;
607
+ const unapproved = updating
608
+ ? asks.filter((c) => !(updating.approved_capabilities ?? []).includes(c))
609
+ : asks;
610
+ if (unapproved.length > 0 && !yes) {
611
+ log(
612
+ chalk.red(
613
+ `✗ ${p.id} asks for ${unapproved.join(", ")}${updating ? " (more than was approved)" : ""}: run again with --yes to approve, recorded in ${LOCK_FILE}; nothing changed`,
614
+ ),
615
+ );
616
+ return 1;
617
+ }
618
+ const files = pluginContent(pluginDir, v);
619
+ const staging = path.join(work, "out");
620
+ for (const f of files) {
621
+ fs.mkdirSync(path.dirname(path.join(staging, f)), { recursive: true });
622
+ fs.copyFileSync(path.join(pluginDir, f), path.join(staging, f));
623
+ }
624
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
625
+ if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
626
+ fs.renameSync(staging, dest);
627
+ lock[p.id] = {
628
+ id: p.id,
629
+ name: p.name,
630
+ version: p.version,
631
+ source,
632
+ ref: src.ref,
633
+ commit,
634
+ sha256: hashFiles(dest, files),
635
+ kinds: p.kinds,
636
+ approved_capabilities: asks,
637
+ ...(kind === "branch" ? { tracking: true } : {}),
638
+ };
639
+ writeLock(distRoot, lock);
640
+ log(
641
+ ` ${p.id} ${p.version} at ${commit.slice(0, 12)} → ${PLUGINS_DIR}/${p.id}/ (${LOCK_FILE}); commit both`,
642
+ );
643
+ if (asks.length > 0) log(` approved: ${asks.join(", ")}`);
644
+ return 0;
645
+ } finally {
646
+ fs.rmSync(work, { recursive: true, force: true });
647
+ }
648
+ }
649
+
650
+ /** Run `plugin <sub>` (or `theme <sub>`, scope "theme"); returns the exit code. */
651
+ export async function runPluginCommand(
652
+ args: string[],
653
+ scope: PluginScope,
654
+ deps: PluginDeps = {},
655
+ ): Promise<number> {
656
+ const log = deps.log ?? ((l: string) => console.log(l));
657
+ const distRoot = deps.distRoot ?? getDistRoot();
658
+ const engineRoot = deps.engineRoot ?? getEngineRoot();
659
+ const [sub, ...rest] = args;
660
+ const cli = product().cli;
661
+ const flags = rest.filter((a) => a.startsWith("-"));
662
+ const positional = rest.filter((a) => !a.startsWith("-"));
663
+ const unknown = flags.filter((f) => !["--track", "--yes"].includes(f));
664
+ if (scope === "plugin" && unknown.length > 0) {
665
+ log(chalk.red(`✗ unknown flag ${unknown.join(", ")}`));
666
+ return 1;
667
+ }
668
+
669
+ switch (sub) {
670
+ case "validate": {
671
+ const arg = positional[0];
672
+ if (!arg) return validateInstalled(distRoot, scope, log);
673
+ // An id means plugins/<id>/, before any cwd-relative path.
674
+ const problems: string[] = [];
675
+ const installed = installedIdArg(distRoot, arg, problems);
676
+ for (const p of problems) log(chalk.red(`✗ ${p}`));
677
+ if (installed) {
678
+ const ok = validateInstalledId(
679
+ distRoot,
680
+ scope,
681
+ installed.id,
682
+ installed.lock,
683
+ log,
684
+ );
685
+ return ok && problems.length === 0 ? 0 : 1;
686
+ }
687
+ // `validate den`: the built-in theme, unless a path of that name exists.
688
+ if (arg === BUILTIN_ID && !fs.existsSync(path.resolve(arg)))
689
+ return validateDir(
690
+ path.join(engineRoot, "themes", BUILTIN_ID),
691
+ scope,
692
+ log,
693
+ );
694
+ return validateDir(path.resolve(arg), scope, log);
695
+ }
696
+
697
+ case "add": {
698
+ const spec = positional[0];
699
+ if (!spec) {
700
+ log(
701
+ `Usage: ${cli} ${scope} add <repo>[/<subpath>]@<tag|commit> [--track]${scope === "plugin" ? " [--yes]" : ""}`,
702
+ );
703
+ return 1;
704
+ }
705
+ return install(
706
+ {
707
+ src: parsePluginSource(spec),
708
+ track: flags.includes("--track"),
709
+ yes: flags.includes("--yes"),
710
+ },
711
+ scope,
712
+ distRoot,
713
+ deps,
714
+ log,
715
+ );
716
+ }
717
+
718
+ case "update": {
719
+ const id = positional[0];
720
+ const lock = readLock(distRoot);
721
+ const entry = id ? lock[id] : undefined;
722
+ if (!id || !entry) {
723
+ log(
724
+ chalk.red(
725
+ id &&
726
+ PLUGIN_ID.test(id) &&
727
+ fs.existsSync(pluginDirFor(distRoot, id))
728
+ ? `✗ ${id} is a local plugin (not in ${LOCK_FILE}): edit it in ${rel(id)}/`
729
+ : `Usage: ${cli} plugin update <id> @<tag|commit> (an added plugin)`,
730
+ ),
731
+ );
732
+ return 1;
733
+ }
734
+ const at = positional[1]?.replace(/^@/, "");
735
+ // A tracking entry may re-pull its branch; anything else names a ref.
736
+ const ref = at || (entry.tracking ? entry.ref : "");
737
+ if (!ref) {
738
+ log(chalk.red(`Usage: ${cli} plugin update ${id} @<tag|commit>`));
739
+ return 1;
740
+ }
741
+ return install(
742
+ {
743
+ src: parsePluginSource(`${entry.source}@${ref}`),
744
+ track:
745
+ flags.includes("--track") ||
746
+ (!!entry.tracking && ref === entry.ref),
747
+ yes: flags.includes("--yes"),
748
+ updating: entry,
749
+ },
750
+ scope,
751
+ distRoot,
752
+ deps,
753
+ log,
754
+ );
755
+ }
756
+
757
+ case "list":
758
+ case undefined: {
759
+ const { builtin, problems, rows, lock } = listRows(
760
+ distRoot,
761
+ engineRoot,
762
+ scope,
763
+ );
764
+ // The id column fits the longest id (up to 40), never less than 14.
765
+ const idWidth = Math.max(
766
+ 14,
767
+ ...[builtin, ...rows].map((r) => r?.id.length ?? 0),
768
+ );
769
+ const line = (r: Row) =>
770
+ scope === "theme"
771
+ ? ` ${r.id.padEnd(idWidth)} ${r.version.padEnd(8)} ${r.state}`
772
+ : ` ${r.id.padEnd(idWidth)} ${r.version.padEnd(8)} ${(r.kinds.join(",") || "?").padEnd(10)} ${r.state}`;
773
+ // The built-in line, any problem with the lock itself, then each plugin.
774
+ if (builtin) log(line(builtin));
775
+ for (const p of problems) log(chalk.red(` ${p}`));
776
+ for (const r of rows) log(line(r));
777
+ // Locks from 0.3.0-dev.2 may hold a file:// source, which nobody else
778
+ // can re-add from. A warning only (PL-SPEC d4): never fails the list.
779
+ for (const e of Object.values(lock))
780
+ if (
781
+ e.source?.startsWith("file://") &&
782
+ (scope === "plugin" || e.kinds?.includes("theme"))
783
+ )
784
+ log(
785
+ chalk.yellow(
786
+ ` ⚠ ${e.id} was added from ${e.source}, a path on one machine: re-add it from a remote, or make it a local plugin (keep ${rel(e.id)}/ and drop its ${LOCK_FILE} entry)`,
787
+ ),
788
+ );
789
+ // A filtered view: plugin dirs it hides never fail it, but every broken
790
+ // one is counted, shown as a row above or not, so none is ever silent.
791
+ if (scope === "theme") {
792
+ const broken = listRows(distRoot, engineRoot, "plugin").rows.filter(
793
+ (r) => !r.ok,
794
+ ).length;
795
+ if (broken > 0)
796
+ log(
797
+ chalk.yellow(
798
+ ` ${broken} plugin ${broken === 1 ? "dir has" : "dirs have"} errors: run \`${cli} plugin list\``,
799
+ ),
800
+ );
801
+ }
802
+ return problems.length === 0 && rows.every((r) => r.ok) ? 0 : 1;
803
+ }
804
+
805
+ case "remove": {
806
+ const id = positional[0];
807
+ const lock = readLock(distRoot);
808
+ const entry = id ? lock[id] : undefined;
809
+ if (
810
+ !id ||
811
+ !entry ||
812
+ (scope === "theme" && !entry.kinds?.includes("theme"))
813
+ ) {
814
+ const local =
815
+ scope === "plugin" &&
816
+ !!id &&
817
+ PLUGIN_ID.test(id) &&
818
+ fs.existsSync(pluginDirFor(distRoot, id));
819
+ log(
820
+ chalk.red(
821
+ local
822
+ ? `✗ ${id} is a local plugin (not in ${LOCK_FILE}): remove it by deleting ${rel(id)}/ in git`
823
+ : `✗ no installed ${scope} "${id ?? ""}"`,
824
+ ),
825
+ );
826
+ return 1;
827
+ }
828
+ fs.rmSync(pluginDirFor(distRoot, id), { recursive: true, force: true });
829
+ delete lock[id];
830
+ writeLock(distRoot, lock);
831
+ log(` removed ${id}`);
832
+ return 0;
833
+ }
834
+
835
+ default:
836
+ log(
837
+ `Usage: ${cli} plugin [list] | validate [dir] | add <repo>[/<subpath>]@<ref> [--track] [--yes] | update <id> @<ref> | remove <id>`,
838
+ );
839
+ return 1;
840
+ }
841
+ }
842
+
843
+ /** Run `plugin <sub>`; returns the exit code. */
844
+ export function cmdPlugin(
845
+ args: string[],
846
+ deps: PluginDeps = {},
847
+ ): Promise<number> {
848
+ return runPluginCommand(args, "plugin", deps);
849
+ }