@hajimism/dek 0.5.1 → 0.6.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 (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +10 -3
  3. package/src/cli/build.ts +3 -1
  4. package/src/cli/commands.ts +24 -10
  5. package/src/cli/contract.ts +28 -2
  6. package/src/cli/flags.ts +5 -0
  7. package/src/cli/lint.ts +53 -1
  8. package/src/cli/sync.ts +21 -6
  9. package/src/cli/text.ts +20 -2
  10. package/src/core/build.ts +4 -0
  11. package/src/core/config-keys.ts +7 -2
  12. package/src/core/config.ts +63 -0
  13. package/src/core/diagnostic.ts +9 -1
  14. package/src/core/document.ts +28 -3
  15. package/src/core/page.ts +1 -0
  16. package/src/core/plugin-host.ts +669 -0
  17. package/src/core/plugin.ts +436 -0
  18. package/src/core/sarif.ts +47 -5
  19. package/src/core/sync.ts +3 -1
  20. package/src/plugin-api.ts +126 -0
  21. package/src/plugins/study/check.ts +128 -0
  22. package/src/plugins/study/main.ts +47 -0
  23. package/src/plugins/study/manifest.ts +32 -0
  24. package/src/plugins/study/page.ts +295 -0
  25. package/src/plugins/study/place.ts +88 -0
  26. package/src/plugins/study/sarif.ts +30 -0
  27. package/src/plugins/study/study.ts +137 -0
  28. package/src/plugins/study/text.ts +55 -0
  29. package/src/plugins/study/yaml.ts +226 -0
  30. package/src/plugins/words/check.ts +109 -0
  31. package/src/plugins/words/lines.ts +26 -0
  32. package/src/plugins/words/list.ts +288 -0
  33. package/src/plugins/words/main.ts +50 -0
  34. package/src/plugins/words/manifest.ts +36 -0
  35. package/src/plugins/words/match.ts +60 -0
  36. package/src/plugins/words/sarif.ts +37 -0
  37. package/src/plugins/words/text.ts +352 -0
  38. package/src/plugins/words/yaml.ts +225 -0
  39. package/src/runtime/player.ts +18 -2
  40. package/src/runtime/plugins.ts +236 -0
  41. package/src/runtime/start.ts +16 -1
  42. package/src/runtime/window.ts +3 -0
  43. package/src/server/deck-snapshot.ts +32 -1
  44. package/src/server/dev.ts +7 -1
  45. package/src/server/pages.ts +9 -1
@@ -0,0 +1,669 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import pkg from "../../package.json";
5
+ import type { CheckInput } from "../plugin-api.ts";
6
+ import { loadConfig } from "./config.ts";
7
+ import { type Diagnostic, diag, type SkippedCheck } from "./diagnostic.ts";
8
+ import { DekError } from "./error.ts";
9
+ import { foreignLine } from "./foreign-text.ts";
10
+ import {
11
+ CHECK_PROTOCOL,
12
+ lockText,
13
+ type ManifestReading,
14
+ manifestChanges,
15
+ type PinnedPlugin,
16
+ type PluginManifest,
17
+ type PluginPage,
18
+ readLock,
19
+ readManifest,
20
+ } from "./plugin.ts";
21
+ import type { Project, ProjectDeck } from "./resolve.ts";
22
+ import { readSourceIfExists, writeInside } from "./safe-fs.ts";
23
+ import { OverrunError, runPiped, workerCommand } from "./spawn.ts";
24
+
25
+ /**
26
+ * Running the plugins a project names: finding each `dek-<name>`, asking it for its manifest,
27
+ * pinning what it said, and having it check a deck. A plugin runs in a process of its own with the
28
+ * user's privileges; that keeps a crash out of dek, not what the plugin may do. See ADR 32.
29
+ */
30
+
31
+ /** A manifest is a constant to print; a run this long is one that hangs. */
32
+ const MANIFEST_TIMEOUT_MS = 10 * 1000;
33
+ /** As long as rumdl gets: a check reads one deck. */
34
+ const CHECK_TIMEOUT_MS = 30 * 1000;
35
+ /** The longest a plugin's message or hint shows: its own words, kept to one line of dek's. */
36
+ const PLUGIN_TEXT_MAX = 300;
37
+
38
+ /** A plugin dek can run: where it is and the manifest it is held to. */
39
+ export type ReadyPlugin = { name: string; bin: string; manifest: PluginManifest };
40
+
41
+ /** What `dek-<name> manifest` printed: a manifest dek can use, or why there is none. */
42
+ export type Described =
43
+ | ({ state: "ready" } & ReadyPlugin)
44
+ | { state: "missing"; name: string }
45
+ | { state: "broken"; name: string; bin: string; reason: string; unsupported?: true };
46
+
47
+ function binName(name: string): string {
48
+ return `dek-${name}`;
49
+ }
50
+
51
+ /**
52
+ * The plugins dek ships, by name (ADR 34). Each runs from dek's own files, in a bun of its own as
53
+ * any plugin does, and never from PATH: its code is the dek the project installed, which the
54
+ * project's lockfile pins.
55
+ */
56
+ const BUNDLED: Record<string, { main: string; page?: string }> = {
57
+ study: bundled("study", { page: true }),
58
+ words: bundled("words", { page: false }),
59
+ };
60
+
61
+ function bundled(name: string, { page }: { page: boolean }): { main: string; page?: string } {
62
+ const dir = join(import.meta.dir, "..", "plugins", name);
63
+ return { main: join(dir, "main.ts"), ...(page ? { page: join(dir, "page.ts") } : {}) };
64
+ }
65
+
66
+ /** The page of each plugin dek ships with one, as a file to compile for the browser (ADR 34). */
67
+ export function bundledPageEntries(): Record<string, string> {
68
+ return Object.fromEntries(
69
+ Object.entries(BUNDLED).flatMap(([name, { page }]) => (page ? [[name, page]] : [])),
70
+ );
71
+ }
72
+
73
+ /**
74
+ * A plugin dek ships, from dek's own files; otherwise
75
+ * `dek-<name>` on the user's PATH, as an absolute path to start it by. A PATH entry that is
76
+ * relative, or whose real path is in the project, is passed over: bunx, direnv, and mise put a
77
+ * project's own folders there, and a project may choose among the plugins the user installed but
78
+ * never add one. On Windows only an `.exe` is taken, so no script runs through a shell.
79
+ */
80
+ export function resolvePluginBin(name: string, root: string): string | undefined {
81
+ const own = BUNDLED[name];
82
+ if (own !== undefined) {
83
+ return own.main;
84
+ }
85
+ const project = realOrUndefined(root) ?? resolve(root);
86
+ const entries = pathEntries().filter((entry) => !passedOver(entry, project));
87
+ const found = Bun.which(binName(name), { PATH: entries.join(delimiter) }) ?? undefined;
88
+ if (found && process.platform === "win32" && extname(found).toLowerCase() !== ".exe") {
89
+ return undefined;
90
+ }
91
+ return found;
92
+ }
93
+
94
+ function pathEntries(): string[] {
95
+ return (process.env.PATH ?? "").split(delimiter).filter((entry) => entry !== "");
96
+ }
97
+
98
+ /** A PATH entry dek never takes a plugin from: a relative one, or one whose real path is in the project. */
99
+ function passedOver(entry: string, project: string): boolean {
100
+ if (!isAbsolute(entry)) {
101
+ return true;
102
+ }
103
+ const real = realOrUndefined(entry);
104
+ return real === undefined || real === project || real.startsWith(`${project}${sep}`);
105
+ }
106
+
107
+ /**
108
+ * Why a plugin is missing, and what to do: one installed inside the project, in its
109
+ * `node_modules/.bin` or a folder on PATH there, is named where it is, since that is where an
110
+ * install for a JavaScript project puts it, and dek's not finding it would otherwise make no sense.
111
+ */
112
+ function missingPlugin(
113
+ name: string,
114
+ root: string,
115
+ version?: string,
116
+ ): { reason: string; hint: string; inside?: true } {
117
+ const project = realOrUndefined(root) ?? resolve(root);
118
+ const inside = [
119
+ join(project, "node_modules", ".bin"),
120
+ ...pathEntries()
121
+ .filter((entry) => passedOver(entry, project))
122
+ .map((entry) => resolve(entry)),
123
+ ];
124
+ const found = Bun.which(binName(name), { PATH: inside.join(delimiter) });
125
+ if (found) {
126
+ const where = relative(project, dirname(realOrUndefined(found) ?? found))
127
+ .split(sep)
128
+ .join("/");
129
+ return {
130
+ reason: `${binName(name)} is in ${where || "."}, inside the project, where dek never runs a plugin from`,
131
+ hint: "install it outside the project, as `bun add -g` does, so that it is on PATH",
132
+ inside: true,
133
+ };
134
+ }
135
+ return {
136
+ reason: `${binName(name)} is not installed`,
137
+ hint: `install ${binName(name)}${version ? ` ${version}` : ""} on PATH, outside this project`,
138
+ };
139
+ }
140
+
141
+ function reasonAndHint({ reason, hint }: { reason: string; hint: string }) {
142
+ return { reason, hint };
143
+ }
144
+
145
+ function realOrUndefined(path: string): string | undefined {
146
+ try {
147
+ return realpathSync(path);
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Runs `dek-<name> manifest` and checks what it prints. Nothing caches it: each command that
155
+ * needs a manifest asks once per plugin, and a manifest is a constant.
156
+ */
157
+ export async function installedManifest(
158
+ name: string,
159
+ root: string,
160
+ options: { timeoutMs?: number } = {},
161
+ ): Promise<Described> {
162
+ const bin = resolvePluginBin(name, root);
163
+ if (!bin) {
164
+ return { state: "missing", name };
165
+ }
166
+ const broken = (reason: string, unsupported?: true): Described => ({
167
+ state: "broken",
168
+ name,
169
+ bin,
170
+ reason: `manifest ${reason}`,
171
+ ...(unsupported ? { unsupported } : {}),
172
+ });
173
+ const timeoutMs = options.timeoutMs ?? MANIFEST_TIMEOUT_MS;
174
+ let printed: string;
175
+ try {
176
+ const run = await runPiped(workerCommand(bin, ["manifest"]), {
177
+ label: `${binName(name)} manifest failed`,
178
+ hint: "",
179
+ cwd: root,
180
+ timeoutMs,
181
+ });
182
+ if (run.exitCode !== 0) {
183
+ return broken(`exited ${run.exitCode}${stderrLine(run.stderr)}`);
184
+ }
185
+ printed = run.stdout;
186
+ } catch (error) {
187
+ return broken(
188
+ error instanceof OverrunError
189
+ ? `did not finish within ${timeoutMs / 1000}s and was stopped`
190
+ : "could not start",
191
+ );
192
+ }
193
+ let value: unknown;
194
+ try {
195
+ value = JSON.parse(printed);
196
+ } catch {
197
+ return broken("printed something other than JSON");
198
+ }
199
+ const reading = readManifest(value, name);
200
+ if (!reading.ok) {
201
+ return broken(`printed a manifest dek cannot use: ${reading.reason}`, reading.unsupported);
202
+ }
203
+ return { state: "ready", name, bin, manifest: reading.manifest };
204
+ }
205
+
206
+ function stderrLine(stderr: string): string {
207
+ const line = stderr.split("\n").find((text) => text.trim() !== "");
208
+ return line === undefined ? "" : `: ${foreignLine(line, PLUGIN_TEXT_MAX)}`;
209
+ }
210
+
211
+ /** The data files a manifest declares in the deck, leaving out the project's (ADR 35). */
212
+ export function deckData(manifest: PluginManifest): Array<{ path: string }> {
213
+ return (manifest.data ?? []).filter((entry) => entry.in === undefined);
214
+ }
215
+
216
+ /** `plugins/<name>/` in the project: the lock, and the data the plugin keeps for the whole project. */
217
+ function pluginDir(root: string, name: string): string {
218
+ return join(root, "plugins", name);
219
+ }
220
+
221
+ /**
222
+ * Whether a finding is on a file in the plugin's own `plugins/<name>/`, which every deck's check
223
+ * reads alike, so lint reports it once (ADR 35). A finding on a deck, or on none, is each deck's.
224
+ */
225
+ export function onProjectData(finding: Diagnostic, root: string, name: string): boolean {
226
+ return finding.path !== undefined && inside(pluginDir(root, name), finding.path) !== undefined;
227
+ }
228
+
229
+ /** `path` relative to `dir` when it is inside it, as `location` reads a finding's place. */
230
+ function inside(dir: string, path: string): string | undefined {
231
+ const within = relative(dir, path);
232
+ return within === "" || within.startsWith("..") || isAbsolute(within) ? undefined : within;
233
+ }
234
+
235
+ /** What the plugin reads on stdin: the deck, as the published schema says, not dek's own types. */
236
+ function checkInput(deck: ProjectDeck, dir: string): string {
237
+ const relativeIfThere = (path: string) =>
238
+ existsSync(path) ? relative(deck.dir, path).split(sep).join("/") : null;
239
+ // Typed as published, so the shape a plugin reads cannot drift from @hajimism/dek/plugin.
240
+ const input: CheckInput = {
241
+ protocol: CHECK_PROTOCOL,
242
+ dek: pkg.version,
243
+ deck: {
244
+ name: deck.name,
245
+ lang: deck.deck.lang,
246
+ slides: deck.deck.sections.map((section) => ({
247
+ slug: section.slug,
248
+ title: section.title,
249
+ html: relativeIfThere(join(deck.dir, "slides", `${section.slug}.html`)),
250
+ css: relativeIfThere(join(deck.dir, "slides", `${section.slug}.css`)),
251
+ ts: relativeIfThere(join(deck.dir, "slides", `${section.slug}.ts`)),
252
+ })),
253
+ },
254
+ // Absolute: a deck reached through a link would lead a relative path elsewhere (ADR 35).
255
+ pluginDir: dir,
256
+ };
257
+ return JSON.stringify(input);
258
+ }
259
+
260
+ /** A check's findings on one deck; `hung` when it was stopped, so lint runs it on no deck after. */
261
+ export type CheckOutcome = { diagnostics: Diagnostic[]; hung?: true };
262
+
263
+ /**
264
+ * Runs `dek-<name> check` on one deck and reads its SARIF as dek's diagnostics: the rule id it
265
+ * declared, the severity its manifest gives that id, and a location inside the deck. A plugin that
266
+ * fails is DEK046, never an empty pass.
267
+ */
268
+ export async function checkDeck(
269
+ plugin: ReadyPlugin,
270
+ project: Project,
271
+ deck: ProjectDeck,
272
+ options: { timeoutMs?: number } = {},
273
+ ): Promise<CheckOutcome> {
274
+ const failed = (what: string) =>
275
+ pluginFailure(
276
+ plugin.name,
277
+ plugin.manifest.version,
278
+ `check on ${deck.name} ${what}`,
279
+ project.configPath,
280
+ );
281
+ const timeoutMs = options.timeoutMs ?? CHECK_TIMEOUT_MS;
282
+ let stdout: string;
283
+ try {
284
+ const run = await runPiped(
285
+ workerCommand(plugin.bin, ["check", "--protocol", String(CHECK_PROTOCOL)]),
286
+ {
287
+ label: `${binName(plugin.name)} check failed`,
288
+ hint: "",
289
+ stdin: checkInput(deck, pluginDir(project.root, plugin.name)),
290
+ cwd: deck.dir,
291
+ timeoutMs,
292
+ },
293
+ );
294
+ if (run.exitCode !== 0) {
295
+ return { diagnostics: [failed(`exited ${run.exitCode}${stderrLine(run.stderr)}`)] };
296
+ }
297
+ stdout = run.stdout;
298
+ } catch (error) {
299
+ if (error instanceof OverrunError) {
300
+ return {
301
+ diagnostics: [failed(`did not finish within ${timeoutMs / 1000}s and was stopped`)],
302
+ hung: true,
303
+ };
304
+ }
305
+ return { diagnostics: [failed("could not start")] };
306
+ }
307
+ const read = readSarif(stdout, plugin, [deck.dir, pluginDir(project.root, plugin.name)]);
308
+ return typeof read === "string" ? { diagnostics: [failed(read)] } : { diagnostics: read };
309
+ }
310
+
311
+ /**
312
+ * DEK046: a plugin the project pinned failed, named with the version it was pinned at. The deck is
313
+ * not at fault, so the hint is to tell the user; the finding still fails lint, since the checks the
314
+ * plugin makes did not run.
315
+ */
316
+ function pluginFailure(
317
+ name: string,
318
+ version: string,
319
+ what: string,
320
+ configPath: string,
321
+ ): Diagnostic {
322
+ return diag("DEK046", {
323
+ message: `${binName(name)} ${version} ${what}`,
324
+ path: configPath,
325
+ hint: `the plugin failed, not the deck: tell the user, who can update ${binName(name)} or report it to its author`,
326
+ data: { plugin: name, version },
327
+ });
328
+ }
329
+
330
+ type SarifResult = {
331
+ ruleId?: unknown;
332
+ message?: { text?: unknown };
333
+ locations?: Array<{
334
+ physicalLocation?: {
335
+ artifactLocation?: { uri?: unknown };
336
+ region?: { startLine?: unknown; startColumn?: unknown };
337
+ };
338
+ }>;
339
+ properties?: { hint?: unknown };
340
+ };
341
+
342
+ /**
343
+ * The findings in a check's SARIF, or what is wrong with it, as the end of a DEK046 message.
344
+ * `dirs` are the deck's folder, which a relative location is read against, then the plugin's own.
345
+ */
346
+ function readSarif(
347
+ stdout: string,
348
+ plugin: ReadyPlugin,
349
+ dirs: [deck: string, plugin: string],
350
+ ): Diagnostic[] | string {
351
+ let log: unknown;
352
+ try {
353
+ log = JSON.parse(stdout);
354
+ } catch {
355
+ return "printed something other than SARIF";
356
+ }
357
+ const runs = (log as { runs?: unknown } | null)?.runs;
358
+ if (!Array.isArray(runs)) {
359
+ return "printed SARIF with no runs";
360
+ }
361
+ const rules = plugin.manifest.check?.rules ?? {};
362
+ const diagnostics: Diagnostic[] = [];
363
+ for (const run of runs) {
364
+ const results = (run as { results?: unknown } | null)?.results ?? [];
365
+ if (!Array.isArray(results)) {
366
+ return "printed SARIF whose results are not a list";
367
+ }
368
+ for (const result of results as SarifResult[]) {
369
+ const id = typeof result?.ruleId === "string" ? result.ruleId : undefined;
370
+ const rule = id === undefined ? undefined : rules[id];
371
+ if (id === undefined || rule === undefined) {
372
+ const named = id === undefined ? "a finding with no rule id" : foreignLine(id, 40);
373
+ return `reported ${named}, which its manifest does not declare`;
374
+ }
375
+ const text = result.message?.text;
376
+ if (typeof text !== "string" || text.trim() === "") {
377
+ return `reported ${id} with no message`;
378
+ }
379
+ const hint = result.properties?.hint;
380
+ diagnostics.push({
381
+ id,
382
+ severity: rule.severity,
383
+ message: foreignLine(text, PLUGIN_TEXT_MAX),
384
+ ...location(result, dirs),
385
+ ...(typeof hint === "string" && hint.trim() !== ""
386
+ ? { hint: foreignLine(hint, PLUGIN_TEXT_MAX) }
387
+ : {}),
388
+ });
389
+ }
390
+ }
391
+ return diagnostics;
392
+ }
393
+
394
+ /**
395
+ * Where a finding is, from its first location: a path relative to the deck, or a file URL, inside
396
+ * the deck or the plugin's own `plugins/<name>/` (ADR 35). A location anywhere else is dropped,
397
+ * never shown as a file to change.
398
+ */
399
+ function location(
400
+ result: SarifResult,
401
+ [deckDir, ownDir]: [string, string],
402
+ ): Pick<Diagnostic, "path" | "line" | "column" | "slug"> {
403
+ const physical = result.locations?.[0]?.physicalLocation;
404
+ const uri = physical?.artifactLocation?.uri;
405
+ if (typeof uri !== "string") {
406
+ return {};
407
+ }
408
+ let path: string;
409
+ try {
410
+ path = uri.startsWith("file:") ? fileURLToPath(uri) : resolve(deckDir, decodeURI(uri));
411
+ } catch {
412
+ return {};
413
+ }
414
+ const inDeck = inside(deckDir, path);
415
+ if (inDeck === undefined && inside(ownDir, path) === undefined) {
416
+ return {};
417
+ }
418
+ const line = positive(physical?.region?.startLine);
419
+ const column = line === undefined ? undefined : positive(physical?.region?.startColumn);
420
+ const slug = inDeck
421
+ ?.split(sep)
422
+ .join("/")
423
+ .match(/^slides\/([^/]+)\.(?:html|css|ts)$/)?.[1];
424
+ return {
425
+ path,
426
+ ...(line === undefined ? {} : { line }),
427
+ ...(column === undefined ? {} : { column }),
428
+ ...(slug === undefined ? {} : { slug }),
429
+ };
430
+ }
431
+
432
+ function positive(value: unknown): number | undefined {
433
+ return Number.isInteger(value) && (value as number) >= 1 ? (value as number) : undefined;
434
+ }
435
+
436
+ /** The plugins dek.toml names; none when it names none or does not parse, which lint reports. */
437
+ function namedPlugins(project: { configPath: string }): string[] {
438
+ try {
439
+ return loadConfig(project.configPath).plugins ?? [];
440
+ } catch {
441
+ return [];
442
+ }
443
+ }
444
+
445
+ function lockPath(root: string, name: string): string {
446
+ return join(root, "plugins", name, "plugin.json");
447
+ }
448
+
449
+ function lockRelative(name: string): string {
450
+ return `plugins/${name}/plugin.json`;
451
+ }
452
+
453
+ /** The lock's text and what it pins, or undefined when the project has none. */
454
+ function readPluginLock(
455
+ root: string,
456
+ name: string,
457
+ ): { text: string; reading: ManifestReading } | undefined {
458
+ const text = readSourceIfExists(lockPath(root, name), root);
459
+ return text === undefined ? undefined : { text, reading: readLock(text, name) };
460
+ }
461
+
462
+ /**
463
+ * The plugins `dekc lint` runs, each as the project accepted it, and the ones it cannot run with
464
+ * why. A plugin whose manifest differs from its lock is skipped, so a check nobody accepted never
465
+ * runs, and CI and a laptop run the same checks or say they did not.
466
+ */
467
+ export async function pluginChecks(
468
+ project: Project,
469
+ ): Promise<{ ready: ReadyPlugin[]; skipped: SkippedCheck[]; diagnostics: Diagnostic[] }> {
470
+ const ready: ReadyPlugin[] = [];
471
+ const skipped: SkippedCheck[] = [];
472
+ const diagnostics: Diagnostic[] = [];
473
+ for (const name of namedPlugins(project)) {
474
+ const skip = (reason: string, hint: string) =>
475
+ skipped.push({ check: "plugin", plugin: name, reason, hint });
476
+ const lock = readPluginLock(project.root, name);
477
+ if (lock === undefined) {
478
+ const installed = resolvePluginBin(name, project.root) !== undefined;
479
+ const missing = missingPlugin(name, project.root);
480
+ if (!installed && missing.inside) {
481
+ skip(missing.reason, missing.hint);
482
+ continue;
483
+ }
484
+ skip(
485
+ `${lockRelative(name)} is missing, so nothing pins what ${binName(name)} may check`,
486
+ installed
487
+ ? "run `dekc sync` to pin it"
488
+ : `install ${binName(name)}, then run \`dekc sync\` to pin it`,
489
+ );
490
+ continue;
491
+ }
492
+ if (!lock.reading.ok) {
493
+ skip(
494
+ `${lockRelative(name)} is not one this dek can read: ${lock.reading.reason}`,
495
+ lock.reading.unsupported
496
+ ? "update dek"
497
+ : `run \`dekc sync --accept ${name}\` to pin the installed ${binName(name)} again`,
498
+ );
499
+ continue;
500
+ }
501
+ const pinned = lock.reading.manifest;
502
+ if (!pinned.check) {
503
+ continue;
504
+ }
505
+ const installed = await installedManifest(name, project.root);
506
+ if (installed.state === "missing") {
507
+ const missing = missingPlugin(name, project.root, pinned.version);
508
+ skip(missing.reason, missing.hint);
509
+ continue;
510
+ }
511
+ if (installed.state === "broken") {
512
+ // One that needs a newer dek is not broken; this dek cannot read it.
513
+ if (installed.unsupported) {
514
+ skip(`${binName(name)} ${installed.reason}`, "update dek");
515
+ } else {
516
+ diagnostics.push(pluginFailure(name, pinned.version, installed.reason, project.configPath));
517
+ }
518
+ continue;
519
+ }
520
+ const changes = manifestChanges(pinned, installed.manifest);
521
+ if (changes.length > 0) {
522
+ skip(
523
+ `the installed ${binName(name)} is not the one ${lockRelative(name)} pins: ${changes.join("; ")}`,
524
+ `run \`dekc sync\` to see what changed; the user accepts it with \`dekc sync --accept ${name}\``,
525
+ );
526
+ continue;
527
+ }
528
+ ready.push({ name, bin: installed.bin, manifest: pinned });
529
+ }
530
+ return { ready, skipped, diagnostics };
531
+ }
532
+
533
+ /** What `dekc sync` did with one plugin, for its `--json` and its notes. */
534
+ export type PluginSync = {
535
+ name: string;
536
+ status: "accepted" | "current" | "changed" | "missing" | "invalid" | "unsupported";
537
+ /** The installed plugin's version, when it printed a manifest dek can use. */
538
+ version?: string;
539
+ /** For `changed`: what differs from the lock, in dek's words. */
540
+ changes?: string[];
541
+ reason?: string;
542
+ hint?: string;
543
+ };
544
+
545
+ /**
546
+ * Pins each plugin dek.toml names: a plugin with no lock is accepted, since naming it in
547
+ * `[plugins]` was the choice, and one whose manifest changed is shown and left as it was pinned
548
+ * unless `accept` names it. A plugin dek cannot find or read is reported and nothing is written for
549
+ * it. Only `dekc sync` runs this: the dev server's sync on save runs no plugin.
550
+ */
551
+ export async function syncPlugins(
552
+ project: Project,
553
+ options: { accept?: string } = {},
554
+ ): Promise<{ plugins: PluginSync[]; created: string[]; updated: string[] }> {
555
+ const names = namedPlugins(project);
556
+ if (options.accept !== undefined && !names.includes(options.accept)) {
557
+ throw new DekError(`dek.toml names no plugin \`${foreignLine(options.accept, 40)}\``, {
558
+ hint:
559
+ names.length > 0
560
+ ? `accept one of ${names.map((name) => `\`${name}\``).join(", ")}`
561
+ : `add \`[plugins.<name>]\` to dek.toml first`,
562
+ });
563
+ }
564
+ const plugins: PluginSync[] = [];
565
+ const created: string[] = [];
566
+ const updated: string[] = [];
567
+ for (const name of names) {
568
+ const installed = await installedManifest(name, project.root);
569
+ if (installed.state === "missing") {
570
+ const pinned = readPluginLock(project.root, name)?.reading;
571
+ plugins.push({
572
+ name,
573
+ status: "missing",
574
+ ...reasonAndHint(
575
+ missingPlugin(name, project.root, pinned?.ok ? pinned.manifest.version : undefined),
576
+ ),
577
+ });
578
+ continue;
579
+ }
580
+ if (installed.state === "broken") {
581
+ plugins.push({
582
+ name,
583
+ status: installed.unsupported ? "unsupported" : "invalid",
584
+ reason: `${binName(name)} ${installed.reason}`,
585
+ hint: installed.unsupported
586
+ ? "update dek"
587
+ : `tell the user: ${binName(name)} is broken, and its author can fix it`,
588
+ });
589
+ continue;
590
+ }
591
+ const path = lockPath(project.root, name);
592
+ const lock = readPluginLock(project.root, name);
593
+ const pinned = lock?.reading.ok ? lock.reading.manifest : undefined;
594
+ const changes = pinned ? manifestChanges(pinned, installed.manifest) : [];
595
+ const version = installed.manifest.version;
596
+ if (pinned && changes.length === 0) {
597
+ plugins.push({ name, status: "current", version });
598
+ continue;
599
+ }
600
+ if (lock && options.accept !== name) {
601
+ plugins.push({
602
+ name,
603
+ status: "changed",
604
+ version,
605
+ ...(pinned
606
+ ? { changes }
607
+ : { reason: `${lockRelative(name)} is not one this dek can read` }),
608
+ hint: `the user accepts it with \`dekc sync --accept ${name}\``,
609
+ });
610
+ continue;
611
+ }
612
+ writeInside(path, lockText(installed.manifest), project.root);
613
+ (lock ? updated : created).push(path);
614
+ plugins.push({ name, status: "accepted", version });
615
+ }
616
+ return { plugins, created, updated };
617
+ }
618
+
619
+ /**
620
+ * Each plugin dek.toml names, with what its lock pins, read without running anything: AGENTS.md is
621
+ * written from this on every sync, the dev server's included. Empty when dek.toml does not parse.
622
+ */
623
+ export function pinnedPlugins(root: string): PinnedPlugin[] {
624
+ return namedPlugins({ configPath: join(root, "dek.toml") }).map((name) => {
625
+ let reading: ManifestReading | undefined;
626
+ try {
627
+ reading = readPluginLock(root, name)?.reading;
628
+ } catch {
629
+ reading = undefined;
630
+ }
631
+ return { name, manifest: reading?.ok ? reading.manifest : undefined };
632
+ });
633
+ }
634
+
635
+ /**
636
+ * The page of each plugin the project pinned with one, for the player and the build: its compiled
637
+ * `script`, the keys its lock declares, and each declared data file the deck has. Only a plugin
638
+ * `scripts` has a page for gets one, so a page dek was not given never reaches a deck (ADR 33). The
639
+ * scripts are those of the plugins dek ships (ADR 34).
640
+ */
641
+ export function pluginPages(
642
+ root: string,
643
+ deckDir: string,
644
+ scripts: Record<string, string>,
645
+ ): PluginPage[] {
646
+ return pinnedPlugins(root).flatMap(({ name, manifest }) => {
647
+ const script = scripts[name];
648
+ if (!manifest?.page || script === undefined) {
649
+ return [];
650
+ }
651
+ const data: PluginPage["data"] = {};
652
+ // A page gets the deck's data alone; the project's is the check's (ADR 35).
653
+ for (const { path } of deckData(manifest)) {
654
+ let text: string | undefined;
655
+ try {
656
+ text = readSourceIfExists(join(deckDir, path), root);
657
+ } catch {
658
+ text = undefined;
659
+ }
660
+ if (text !== undefined) {
661
+ data[path] = {
662
+ text,
663
+ version: new Bun.CryptoHasher("sha256").update(text).digest("hex").slice(0, 16),
664
+ };
665
+ }
666
+ }
667
+ return [{ name, keys: manifest.page.keys ?? [], script, data }];
668
+ });
669
+ }