@hajimism/dek 0.4.0 → 0.5.1

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 (77) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +8 -7
  5. package/src/cli/address.ts +59 -15
  6. package/src/cli/commands.ts +47 -14
  7. package/src/cli/contract.ts +49 -3
  8. package/src/cli/files.ts +1 -0
  9. package/src/cli/format.ts +5 -4
  10. package/src/cli/goto.ts +41 -10
  11. package/src/cli/keep-alive.ts +12 -6
  12. package/src/cli/lint.ts +32 -13
  13. package/src/cli/ls.ts +5 -1
  14. package/src/cli/main.ts +48 -13
  15. package/src/cli/new.ts +14 -1
  16. package/src/cli/ref.ts +1 -0
  17. package/src/cli/result.ts +42 -4
  18. package/src/cli/scope.ts +40 -1
  19. package/src/cli/shot.ts +77 -6
  20. package/src/cli/show.ts +15 -2
  21. package/src/cli/text.ts +61 -9
  22. package/src/cli/theme.ts +50 -16
  23. package/src/cli/tty.ts +16 -2
  24. package/src/cli/voice.ts +11 -6
  25. package/src/core/assets.ts +18 -10
  26. package/src/core/attribute-value.ts +188 -0
  27. package/src/core/build.ts +3 -3
  28. package/src/core/css-scan.ts +173 -39
  29. package/src/core/css-transform.ts +57 -11
  30. package/src/core/css.ts +19 -13
  31. package/src/core/foreign-text.ts +138 -0
  32. package/src/core/github.ts +1 -0
  33. package/src/core/html-entities.ts +318 -0
  34. package/src/core/html-scan.ts +49 -27
  35. package/src/core/html.ts +21 -15
  36. package/src/core/lint/asset-refs.ts +44 -6
  37. package/src/core/lint/context.ts +2 -0
  38. package/src/core/lint/shared-styles.ts +26 -1
  39. package/src/core/lint/slide-html.ts +21 -18
  40. package/src/core/lint/theme-css.ts +64 -3
  41. package/src/core/lint.ts +15 -2
  42. package/src/core/mv.ts +28 -18
  43. package/src/core/order.ts +14 -0
  44. package/src/core/path.ts +8 -2
  45. package/src/core/playwright-module.d.ts +12 -1
  46. package/src/core/playwright-motion.ts +1 -0
  47. package/src/core/playwright-sheet.ts +1 -0
  48. package/src/core/playwright-visual.ts +27 -10
  49. package/src/core/playwright.ts +77 -19
  50. package/src/core/pptx.ts +1 -0
  51. package/src/core/ref-name.ts +8 -3
  52. package/src/core/ref.ts +145 -47
  53. package/src/core/resolve.ts +3 -2
  54. package/src/core/rumdl.ts +51 -25
  55. package/src/core/safe-fs.ts +19 -2
  56. package/src/core/sandbox.ts +217 -0
  57. package/src/core/shot-cache.ts +1 -0
  58. package/src/core/slide-eval-worker.ts +1 -0
  59. package/src/core/slide-measure.ts +1 -0
  60. package/src/core/slide-script-eval.ts +2 -0
  61. package/src/core/spawn.ts +136 -35
  62. package/src/core/sync.ts +3 -12
  63. package/src/core/tar.ts +315 -0
  64. package/src/core/text-contrast.ts +2 -0
  65. package/src/core/toml-keys.ts +116 -51
  66. package/src/core/url-attributes.ts +27 -9
  67. package/src/core/visual.ts +28 -9
  68. package/src/core/voice.ts +2 -1
  69. package/src/server/auth.ts +21 -0
  70. package/src/server/dev.ts +13 -5
  71. package/src/server/lock.ts +43 -13
  72. package/src/server/serial.ts +8 -2
  73. package/src/video/mux.ts +27 -3
  74. package/src/video/recorder.ts +3 -2
  75. package/src/video/worker.ts +1 -0
  76. package/src/voice/engine.ts +81 -18
  77. package/tsconfig.json +1 -1
package/src/core/ref.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
2
+ // biome-ignore lint/style/noRestrictedImports: mkdir, writeFile, rename, and rm, which write a snapshot into a fresh folder in refs/, which outputDir checked, and swap it in whole.
2
3
  import { mkdir, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { dirname, join, relative, resolve, sep } from "node:path";
4
5
  import { z } from "zod";
5
6
  import { loadConfig } from "./config.ts";
6
7
  import { DekError } from "./error.ts";
7
8
  import { shellQuote } from "./escape.ts";
8
- import { listShown } from "./prose.ts";
9
+ import { foreignList } from "./foreign-text.ts";
9
10
  import {
10
11
  isPlainRefName,
11
12
  isRefDeckName,
@@ -16,6 +17,7 @@ import {
16
17
  } from "./ref-name.ts";
17
18
  import { listSlides, type Project, resolveProject } from "./resolve.ts";
18
19
  import { followInside, outputDir } from "./safe-fs.ts";
20
+ import { readTar } from "./tar.ts";
19
21
 
20
22
  export {
21
23
  isPinnedRev,
@@ -132,7 +134,7 @@ function refAround(dir: string): { dir: string; name: string } | undefined {
132
134
  * The ref `dir` holds, when it is a snapshot as dek writes one: a dek.toml beside a marker that
133
135
  * names a ref, at that ref's own place under a project's refs/.
134
136
  */
135
- function snapshotName(dir: string): string | undefined {
137
+ export function snapshotName(dir: string): string | undefined {
136
138
  if (!isThere(join(dir, "dek.toml"))) {
137
139
  return undefined;
138
140
  }
@@ -233,42 +235,84 @@ export const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024;
233
235
  /** A pack's tar, unzipped, may hold its files and their headers, not much more. */
234
236
  const MAX_PACK_TAR_BYTES = 2 * MAX_SNAPSHOT_BYTES;
235
237
 
236
- const PACK_HINT = "ask for a pack its author made with `dekc pack <deck>`";
237
-
238
238
  /**
239
- * Reads a GitHub tarball. Bun leaves out links; a path that is absolute, has
240
- * an empty segment, or climbs with `..` is dropped here, so nothing written
241
- * later can land outside the snapshot.
239
+ * A repository's tar, unzipped. A repository of text inflates some 3.5 times, so about five
240
+ * times the 200 MB dek downloads of it: 1 GB.
242
241
  */
243
- export async function readTarball(bytes: Uint8Array): Promise<RepoFiles> {
244
- const files: RepoFiles = new Map();
245
- for (const [path, blob] of await new Bun.Archive(bytes).files()) {
246
- const [, ...segments] = path.split("/");
247
- const safe =
248
- segments.length > 0 &&
249
- !path.includes("\\") &&
250
- segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
251
- if (safe && !path.startsWith("/")) {
252
- files.set(segments.join("/"), blob);
253
- }
254
- }
255
- return files;
242
+ const MAX_TARBALL_TAR_BYTES = 1024 * 1024 * 1024;
243
+
244
+ /** A deck is a few hundred files; a pack of more is not one `dekc pack` made. */
245
+ const MAX_PACK_ENTRIES = 10_000;
246
+
247
+ /** A repository's files, folders, and the headers before them, as many as a large one holds. */
248
+ const MAX_TARBALL_ENTRIES = 200_000;
249
+
250
+ const PACK_HINT = "ask for a pack its author made with `dekc pack <deck>`";
251
+
252
+ /** Reads a GitHub tarball, as `readGzippedTar` reads any. */
253
+ export async function readTarball(
254
+ bytes: Uint8Array,
255
+ limits: { maxTarBytes?: number; maxEntries?: number } = {},
256
+ ): Promise<RepoFiles> {
257
+ const maxTarBytes = limits.maxTarBytes ?? MAX_TARBALL_TAR_BYTES;
258
+ const maxEntries = limits.maxEntries ?? MAX_TARBALL_ENTRIES;
259
+ return readGzippedTar(
260
+ bytes,
261
+ { maxTarBytes, maxEntries },
262
+ {
263
+ tooLarge: () =>
264
+ new DekError(`the repository unpacks to more than ${formatMb(maxTarBytes)}`, {
265
+ hint: "dekc ref downloads and unpacks the whole repository, so keep decks in a smaller repository, or ask the deck's author for a pack made with `dekc pack <deck>` and add it with `dekc ref <file>.tgz`",
266
+ }),
267
+ notTar: () =>
268
+ new DekError("GitHub's tarball of the repository is not a gzipped tar", {
269
+ hint: "run the command again",
270
+ }),
271
+ holds: (what) =>
272
+ new DekError(`the repository's tarball holds ${what}, which dek does not read`, {
273
+ hint: "the repository holds something dek cannot read; ask the deck's author for a pack made with `dekc pack <deck>` and pin the deck from it with `dekc ref <file>.tgz`",
274
+ }),
275
+ },
276
+ );
256
277
  }
257
278
 
258
- /**
259
- * Reads a pack from someone else. It is gunzipped through a count that stops at `maxTarBytes`
260
- * before Bun takes any of it apart, so a small file that inflates without end is refused rather
261
- * than held in memory; the tar is then read as a GitHub tarball is.
262
- */
279
+ /** Reads a pack from someone else, no larger than `maxBytes`, as `readGzippedTar` reads any. */
263
280
  export async function readPack(
264
281
  bytes: Uint8Array,
265
- limits: { maxBytes?: number; maxTarBytes?: number } = {},
282
+ limits: { maxBytes?: number; maxTarBytes?: number; maxEntries?: number } = {},
266
283
  ): Promise<RepoFiles> {
267
284
  const maxBytes = limits.maxBytes ?? MAX_SNAPSHOT_BYTES;
268
285
  const maxTarBytes = limits.maxTarBytes ?? MAX_PACK_TAR_BYTES;
286
+ const maxEntries = limits.maxEntries ?? MAX_PACK_ENTRIES;
269
287
  if (bytes.byteLength > maxBytes) {
270
288
  throw new DekError(`the pack is larger than ${formatMb(maxBytes)}`, { hint: PACK_HINT });
271
289
  }
290
+ return readGzippedTar(
291
+ bytes,
292
+ { maxTarBytes, maxEntries },
293
+ {
294
+ tooLarge: () =>
295
+ new DekError(`the pack unpacks to more than ${formatMb(maxTarBytes)}`, { hint: PACK_HINT }),
296
+ notTar: () =>
297
+ new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT }),
298
+ holds: (what) =>
299
+ new DekError(`the pack holds ${what}, which dek does not read`, { hint: PACK_HINT }),
300
+ },
301
+ );
302
+ }
303
+
304
+ /**
305
+ * The files of a gzipped tar from elsewhere. It is gunzipped through a count that stops at
306
+ * `maxTarBytes`, so a small file that inflates without end is refused rather than held in
307
+ * memory, and the tar is then read by dek's own reader (`readTar`), whose files are the bytes
308
+ * the tar stores and no more. The top folder is dropped, and so is a path that is absolute, has
309
+ * an empty segment, or climbs with `..`, so nothing written later can land outside the snapshot.
310
+ */
311
+ async function readGzippedTar(
312
+ bytes: Uint8Array,
313
+ { maxTarBytes, maxEntries }: { maxTarBytes: number; maxEntries: number },
314
+ errors: { tooLarge: () => DekError; notTar: () => DekError; holds: (what: string) => DekError },
315
+ ): Promise<RepoFiles> {
272
316
  const chunks: Uint8Array[] = [];
273
317
  let size = 0;
274
318
  try {
@@ -284,9 +328,7 @@ export async function readPack(
284
328
  size += value.byteLength;
285
329
  if (size > maxTarBytes) {
286
330
  await reader.cancel();
287
- throw new DekError(`the pack unpacks to more than ${formatMb(maxTarBytes)}`, {
288
- hint: PACK_HINT,
289
- });
331
+ throw errors.tooLarge();
290
332
  }
291
333
  chunks.push(value);
292
334
  }
@@ -294,7 +336,7 @@ export async function readPack(
294
336
  if (error instanceof DekError) {
295
337
  throw error;
296
338
  }
297
- throw new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT });
339
+ throw errors.notTar();
298
340
  }
299
341
  const tar = new Uint8Array(size);
300
342
  let offset = 0;
@@ -302,15 +344,36 @@ export async function readPack(
302
344
  tar.set(chunk, offset);
303
345
  offset += chunk.byteLength;
304
346
  }
305
- try {
306
- return await readTarball(tar);
307
- } catch {
308
- throw new DekError("the file is not a pack: a pack is a gzipped tar", { hint: PACK_HINT });
347
+ // The tar holds them all now; let them go before its files are made.
348
+ chunks.length = 0;
349
+ const entries = readTar(tar, { maxBytes: maxTarBytes, maxEntries });
350
+ if (!(entries instanceof Map)) {
351
+ throw entries.kind === "too large"
352
+ ? errors.tooLarge()
353
+ : entries.kind === "not a tar"
354
+ ? errors.notTar()
355
+ : errors.holds(entries.what);
356
+ }
357
+ const files: RepoFiles = new Map();
358
+ for (const [path, blob] of entries) {
359
+ const [, ...segments] = path.split("/");
360
+ const safe =
361
+ segments.length > 0 &&
362
+ !path.includes("\\") &&
363
+ segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
364
+ if (safe && !path.startsWith("/")) {
365
+ files.set(segments.join("/"), blob);
366
+ }
309
367
  }
368
+ return files;
310
369
  }
311
370
 
312
371
  function formatMb(bytes: number): string {
313
- return bytes >= 1024 * 1024 ? `${bytes / 1024 / 1024} MB` : `${bytes} bytes`;
372
+ const mb = 1024 * 1024;
373
+ if (bytes >= 1024 * mb && bytes % (1024 * mb) === 0) {
374
+ return `${bytes / 1024 / mb} GB`;
375
+ }
376
+ return bytes >= mb ? `${bytes / mb} MB` : `${bytes} bytes`;
314
377
  }
315
378
 
316
379
  const DECK_SCRIPT_RE = /^(?:(.*)\/)?decks\/([^/]+)\/script\.md$/;
@@ -324,16 +387,37 @@ function repoDecks(files: RepoFiles): Array<{ name: string; prefix: string }> {
324
387
  });
325
388
  }
326
389
 
327
- /** Deck names as a hint lists them: only those a ref can carry. */
390
+ /**
391
+ * Names from someone else's repository or pack as a message lists them: those `shows` passes,
392
+ * as foreignList shows them, then how many others there are, in the words of `others`.
393
+ */
394
+ function listCarried(
395
+ names: string[],
396
+ shows: (name: string) => boolean,
397
+ others: (count: number) => string,
398
+ ): string {
399
+ const shown = names.filter(shows);
400
+ const left = names.length - shown.length;
401
+ if (left === 0) {
402
+ return foreignList(shown);
403
+ }
404
+ return shown.length === 0 ? others(left) : `${foreignList(shown)}, and ${others(left)}`;
405
+ }
406
+
407
+ /** Deck names as a message lists them: only those a ref can carry. */
328
408
  function listDecks(names: string[]): string {
329
- return listShown(
409
+ return listCarried(
330
410
  names,
331
411
  isRefDeckName,
332
412
  (count) => `${count} whose ${count === 1 ? "name" : "names"} a ref cannot carry`,
333
413
  );
334
414
  }
335
415
 
336
- /** The name of the one deck a pack holds; a pack of none, of more, or of one no ref can name is refused. */
416
+ /**
417
+ * The name of the one deck a pack holds; a pack of none, of more, or of one no ref can name is
418
+ * refused. The names are the pack author's, so the message lists them as listDecks does, and the
419
+ * hint is dek's.
420
+ */
337
421
  export function deckOfPack(files: RepoFiles): string {
338
422
  const names = [...new Set(repoDecks(files).map((deck) => deck.name))].sort();
339
423
  const [name, ...others] = names;
@@ -355,7 +439,11 @@ export function deckOfPack(files: RepoFiles): string {
355
439
  return name;
356
440
  }
357
441
 
358
- /** The directory of the project that holds the deck, as a prefix: "" or "sample/". */
442
+ /**
443
+ * The directory of the project that holds the deck, as a prefix: "" or "sample/". The folder
444
+ * names the source holds are its author's: an error lists them in its message, as foreignList
445
+ * does, and its hint is dek's words alone.
446
+ */
359
447
  export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
360
448
  const repo = source.kind === "file" ? "the pack" : `${source.owner}/${source.repo}`;
361
449
  const path = source.kind === "github" ? source.path : undefined;
@@ -368,22 +456,32 @@ export function findDeckInRepo(files: RepoFiles, source: RefSource): string {
368
456
  const [match, ...others] = matches;
369
457
  if (!match) {
370
458
  const names = [...new Set(decks.map((deck) => deck.name))].sort();
371
- throw new DekError(`${repo} has no deck "${source.deck}"`, {
459
+ if (names.length === 0) {
460
+ throw new DekError(`${repo} has no deck "${source.deck}"`, {
461
+ hint: `${repo} holds no dek project (decks/<name>/script.md beside a dek.toml)`,
462
+ });
463
+ }
464
+ throw new DekError(`${repo} has no deck "${source.deck}", only ${listDecks(names)}`, {
372
465
  hint:
373
- names.length > 0
374
- ? `decks in ${repo}: ${listDecks(names)}`
375
- : `${repo} holds no dek project (decks/<name>/script.md beside a dek.toml)`,
466
+ source.kind === "file"
467
+ ? `add the pack you were sent with \`dekc ref <file>.tgz\`, which pins the deck it holds`
468
+ : names.some(isRefDeckName)
469
+ ? `pin one of them: \`dekc ref ${repo}/<deck>\``
470
+ : `${REF_DECK_NAME_RULE}; ask its author to rename the deck's folder`,
376
471
  });
377
472
  }
378
473
  if (others.length > 0) {
379
474
  const paths = matches.map((deck) => `${deck.prefix}decks/${deck.name}`).sort();
380
- const listed = listShown(
475
+ const listed = listCarried(
381
476
  paths,
382
477
  (path) => path.split("/").every(isRefDeckName),
383
- (count) => `${count} whose ${count === 1 ? "path" : "paths"} a hint cannot show`,
478
+ (count) => `${count} whose ${count === 1 ? "path" : "paths"} dek does not print`,
384
479
  );
385
- throw new DekError(`${repo} has more than one deck named "${source.deck}"`, {
386
- hint: `pass the GitHub link to the one you mean; they are at ${listed}`,
480
+ throw new DekError(`${repo} has more than one deck named "${source.deck}", at ${listed}`, {
481
+ hint:
482
+ source.kind === "file"
483
+ ? `a pack is one deck; ${PACK_HINT}`
484
+ : `pass the GitHub link to the folder of the one you mean, https://github.com/${repo}/tree/<rev>/<path>`,
387
485
  });
388
486
  }
389
487
  return match.prefix;
@@ -3,6 +3,7 @@ import { join, relative, resolve, sep } from "node:path";
3
3
  import { deckPaths } from "./deck-paths.ts";
4
4
  import { DekError } from "./error.ts";
5
5
  import { walkUp } from "./optional.ts";
6
+ import { compareCodeUnits } from "./order.ts";
6
7
  import { parseScript } from "./parse.ts";
7
8
  import { deckProjectRoot, isInside } from "./path.ts";
8
9
  import { readSourceIfExists, requireInside } from "./safe-fs.ts";
@@ -115,7 +116,7 @@ export function listSlideFiles(
115
116
  readdirSync(dir)
116
117
  // A declaration file carries types for the editor, not a slide.
117
118
  .filter((name) => name.endsWith(ext) && !name.endsWith(".d.ts"))
118
- .sort((a, b) => a.localeCompare(b))
119
+ .sort(compareCodeUnits)
119
120
  .flatMap((name) => {
120
121
  const path = join(dir, name);
121
122
  if (!statSync(requireInside(path, root)).isFile()) {
@@ -170,7 +171,7 @@ function loadDecks(root: string): {
170
171
 
171
172
  for (const entry of readdirSync(realDecks, { withFileTypes: true })
172
173
  .filter((item) => item.isDirectory())
173
- .sort((a, b) => a.name.localeCompare(b.name))) {
174
+ .sort((a, b) => compareCodeUnits(a.name, b.name))) {
174
175
  const loaded = readDeck(root, entry.name);
175
176
  if (loaded.ok) {
176
177
  decks.push(loaded.deck);
package/src/core/rumdl.ts CHANGED
@@ -2,9 +2,16 @@ import { dirname } from "node:path";
2
2
  import { type Diagnostic, errorDiagnostic } from "./diagnostic.ts";
3
3
  import { resolveBinFromAncestors } from "./optional.ts";
4
4
  import type { SarifLog } from "./sarif.ts";
5
- import { awaitPiped, workerCommand } from "./spawn.ts";
5
+ import { OverrunError, runPiped, workerCommand } from "./spawn.ts";
6
6
 
7
- export type RumdlRunner = (scriptPath: string) => Promise<string | null>;
7
+ /** Why lint has no findings from rumdl for a script: the `reason` when dek knows it, and `hung`. */
8
+ export type RumdlSkip = { reason?: string; hung?: true };
9
+
10
+ /** rumdl's SARIF for a script, or why there is none. */
11
+ export type RumdlRunner = (scriptPath: string) => Promise<string | RumdlSkip>;
12
+
13
+ /** rumdl checks a script in well under a second; a run this long is one that hangs. */
14
+ const RUMDL_TIMEOUT_MS = 30 * 1000;
8
15
 
9
16
  export function resolveRumdlBin(): string | undefined {
10
17
  if (process.env.DEK_RUMDL) {
@@ -17,30 +24,45 @@ export function resolveRumdlBin(): string | undefined {
17
24
  return onPath ?? resolveBinFromAncestors("rumdl", import.meta.dir);
18
25
  }
19
26
 
20
- export async function defaultRumdlRunner(scriptPath: string): Promise<string | null> {
27
+ /** Runs rumdl beside the script; one that cannot start says nothing, one that hangs is stopped. */
28
+ export async function defaultRumdlRunner(
29
+ scriptPath: string,
30
+ options: { timeoutMs?: number } = {},
31
+ ): Promise<string | RumdlSkip> {
21
32
  const bin = resolveRumdlBin();
22
33
  if (!bin) {
23
- return null;
34
+ return {};
24
35
  }
25
36
 
37
+ const timeoutMs = options.timeoutMs ?? RUMDL_TIMEOUT_MS;
26
38
  try {
27
39
  const args = ["check", scriptPath, "--output-format", "sarif"];
28
- const cmd = workerCommand(bin, args);
29
- const proc = Bun.spawn(cmd, {
40
+ // A rumdl that cannot start is skipped below, so its label and hint never reach the user.
41
+ const { stdout, exitCode, cutShort } = await runPiped(workerCommand(bin, args), {
42
+ label: "rumdl failed",
43
+ hint: "",
44
+ // runPiped passes the environment as it is now, like the PATH rumdl was found on.
30
45
  cwd: dirname(scriptPath),
31
- // The environment as it is now, like the PATH rumdl was found on; left out, Bun passes
32
- // the one the process started with.
33
- env: process.env,
34
- stdout: "pipe",
35
- stderr: "pipe",
46
+ timeoutMs,
36
47
  });
37
- const { stdout, exitCode } = await awaitPiped(proc);
38
48
  if (exitCode === 2) {
39
- return null;
49
+ return {};
50
+ }
51
+ if (cutShort) {
52
+ return {
53
+ reason:
54
+ "rumdl exited while a process it started still held its output, so it was cut short",
55
+ };
40
56
  }
41
57
  return stdout;
42
- } catch {
43
- return null;
58
+ } catch (error) {
59
+ if (error instanceof OverrunError) {
60
+ return {
61
+ reason: `rumdl did not finish within ${timeoutMs / 1000}s and was stopped`,
62
+ hung: true,
63
+ };
64
+ }
65
+ return {};
44
66
  }
45
67
  }
46
68
 
@@ -51,34 +73,38 @@ export async function rumdlDiagnostics(
51
73
  return (await runRumdl(scriptPath, run)).diagnostics;
52
74
  }
53
75
 
76
+ /**
77
+ * rumdl's findings on the script, or why there are none (`skipped`): the deck is not what is
78
+ * wrong, so lint goes on without them and says why.
79
+ */
54
80
  export async function runRumdl(
55
81
  scriptPath: string,
56
82
  run: RumdlRunner = defaultRumdlRunner,
57
- ): Promise<{ diagnostics: Diagnostic[]; sarif?: SarifLog; skipped?: boolean }> {
83
+ ): Promise<{ diagnostics: Diagnostic[]; sarif?: SarifLog; skipped?: RumdlSkip }> {
58
84
  const text = await run(scriptPath);
59
- if (text === null) {
60
- return { diagnostics: [], skipped: true };
85
+ if (typeof text !== "string") {
86
+ return { diagnostics: [], skipped: text };
61
87
  }
62
88
  return parseRumdlSarif(text);
63
89
  }
64
90
 
65
- function parseRumdlSarif(text: string | null): {
91
+ /** rumdl prints SARIF even for a clean script, so anything else is a check that did not happen. */
92
+ const NOT_SARIF: RumdlSkip = { reason: "rumdl printed something other than SARIF" };
93
+
94
+ function parseRumdlSarif(text: string): {
66
95
  diagnostics: Diagnostic[];
67
96
  sarif?: SarifLog;
97
+ skipped?: RumdlSkip;
68
98
  } {
69
- if (!text?.trim()) {
70
- return { diagnostics: [] };
71
- }
72
-
73
99
  let parsed: unknown;
74
100
  try {
75
101
  parsed = JSON.parse(text);
76
102
  } catch {
77
- return { diagnostics: [] };
103
+ return { diagnostics: [], skipped: NOT_SARIF };
78
104
  }
79
105
 
80
106
  if (!parsed || typeof parsed !== "object" || !("runs" in parsed) || !Array.isArray(parsed.runs)) {
81
- return { diagnostics: [] };
107
+ return { diagnostics: [], skipped: NOT_SARIF };
82
108
  }
83
109
 
84
110
  const sarif = parsed as SarifLog;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import {
3
+ chmodSync,
3
4
  existsSync,
4
5
  lstatSync,
5
6
  mkdirSync,
@@ -146,7 +147,8 @@ export function writeInside(
146
147
  /**
147
148
  * Write `data` as a new file at `path`, in a folder already known to be inside the project: a
148
149
  * link at `path`, such as one planted at a cache entry's predictable name, is replaced, never
149
- * written through.
150
+ * written through. The new file takes `mode`, else the mode of the file it replaces, as a write
151
+ * in place keeps it, so a script.md only its owner may read stays that way.
150
152
  */
151
153
  export function replaceFile(
152
154
  path: string,
@@ -154,11 +156,16 @@ export function replaceFile(
154
156
  options: { mode?: number } = {},
155
157
  ): void {
156
158
  const temp = join(dirname(path), `.${basename(path)}.${randomUUID().slice(0, 8)}.tmp`);
159
+ const mode = options.mode ?? modeOf(path);
157
160
  writeFileSync(temp, data, {
158
161
  flag: "wx",
159
- ...(options.mode !== undefined ? { mode: options.mode } : {}),
162
+ ...(mode !== undefined ? { mode } : {}),
160
163
  });
161
164
  try {
165
+ // The mode as it was, not as the umask leaves it on a new file.
166
+ if (options.mode === undefined && mode !== undefined) {
167
+ chmodSync(temp, mode);
168
+ }
162
169
  renameSync(temp, path);
163
170
  } catch (error) {
164
171
  rmSync(temp, { force: true });
@@ -166,6 +173,16 @@ export function replaceFile(
166
173
  }
167
174
  }
168
175
 
176
+ /** The permission bits of the plain file at `path`, if one is there; a link's target is not asked. */
177
+ function modeOf(path: string): number | undefined {
178
+ try {
179
+ const stat = lstatSync(path);
180
+ return stat.isFile() ? stat.mode & 0o777 : undefined;
181
+ } catch {
182
+ return undefined;
183
+ }
184
+ }
185
+
169
186
  /** Remove every link directly in `dir`, a folder of dek's own output that a writer fills by name. */
170
187
  export function dropLinks(dir: string): void {
171
188
  for (const entry of readdirSync(dir, { withFileTypes: true })) {