@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
|
@@ -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
|
+
}
|