@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.
- package/CHANGELOG.md +92 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +8 -7
- package/src/cli/address.ts +59 -15
- package/src/cli/commands.ts +47 -14
- package/src/cli/contract.ts +49 -3
- package/src/cli/files.ts +1 -0
- package/src/cli/format.ts +5 -4
- package/src/cli/goto.ts +41 -10
- package/src/cli/keep-alive.ts +12 -6
- package/src/cli/lint.ts +32 -13
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +48 -13
- package/src/cli/new.ts +14 -1
- package/src/cli/ref.ts +1 -0
- package/src/cli/result.ts +42 -4
- package/src/cli/scope.ts +40 -1
- package/src/cli/shot.ts +77 -6
- package/src/cli/show.ts +15 -2
- package/src/cli/text.ts +61 -9
- package/src/cli/theme.ts +50 -16
- package/src/cli/tty.ts +16 -2
- package/src/cli/voice.ts +11 -6
- package/src/core/assets.ts +18 -10
- package/src/core/attribute-value.ts +188 -0
- package/src/core/build.ts +3 -3
- package/src/core/css-scan.ts +173 -39
- package/src/core/css-transform.ts +57 -11
- package/src/core/css.ts +19 -13
- package/src/core/foreign-text.ts +138 -0
- package/src/core/github.ts +1 -0
- package/src/core/html-entities.ts +318 -0
- package/src/core/html-scan.ts +49 -27
- package/src/core/html.ts +21 -15
- package/src/core/lint/asset-refs.ts +44 -6
- package/src/core/lint/context.ts +2 -0
- package/src/core/lint/shared-styles.ts +26 -1
- package/src/core/lint/slide-html.ts +21 -18
- package/src/core/lint/theme-css.ts +64 -3
- package/src/core/lint.ts +15 -2
- package/src/core/mv.ts +28 -18
- package/src/core/order.ts +14 -0
- package/src/core/path.ts +8 -2
- package/src/core/playwright-module.d.ts +12 -1
- package/src/core/playwright-motion.ts +1 -0
- package/src/core/playwright-sheet.ts +1 -0
- package/src/core/playwright-visual.ts +27 -10
- package/src/core/playwright.ts +77 -19
- package/src/core/pptx.ts +1 -0
- package/src/core/ref-name.ts +8 -3
- package/src/core/ref.ts +145 -47
- package/src/core/resolve.ts +3 -2
- package/src/core/rumdl.ts +51 -25
- package/src/core/safe-fs.ts +19 -2
- package/src/core/sandbox.ts +217 -0
- package/src/core/shot-cache.ts +1 -0
- package/src/core/slide-eval-worker.ts +1 -0
- package/src/core/slide-measure.ts +1 -0
- package/src/core/slide-script-eval.ts +2 -0
- package/src/core/spawn.ts +136 -35
- package/src/core/sync.ts +3 -12
- package/src/core/tar.ts +315 -0
- package/src/core/text-contrast.ts +2 -0
- package/src/core/toml-keys.ts +116 -51
- package/src/core/url-attributes.ts +27 -9
- package/src/core/visual.ts +28 -9
- package/src/core/voice.ts +2 -1
- package/src/server/auth.ts +21 -0
- package/src/server/dev.ts +13 -5
- package/src/server/lock.ts +43 -13
- package/src/server/serial.ts +8 -2
- package/src/video/mux.ts +27 -3
- package/src/video/recorder.ts +3 -2
- package/src/video/worker.ts +1 -0
- package/src/voice/engine.ts +81 -18
- 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 {
|
|
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
|
-
*
|
|
240
|
-
*
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
? `
|
|
375
|
-
:
|
|
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 =
|
|
475
|
+
const listed = listCarried(
|
|
381
476
|
paths,
|
|
382
477
|
(path) => path.split("/").every(isRefDeckName),
|
|
383
|
-
(count) => `${count} whose ${count === 1 ? "path" : "paths"}
|
|
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:
|
|
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;
|
package/src/core/resolve.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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 {
|
|
5
|
+
import { OverrunError, runPiped, workerCommand } from "./spawn.ts";
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
29
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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?:
|
|
83
|
+
): Promise<{ diagnostics: Diagnostic[]; sarif?: SarifLog; skipped?: RumdlSkip }> {
|
|
58
84
|
const text = await run(scriptPath);
|
|
59
|
-
if (text
|
|
60
|
-
return { diagnostics: [], skipped:
|
|
85
|
+
if (typeof text !== "string") {
|
|
86
|
+
return { diagnostics: [], skipped: text };
|
|
61
87
|
}
|
|
62
88
|
return parseRumdlSarif(text);
|
|
63
89
|
}
|
|
64
90
|
|
|
65
|
-
|
|
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;
|
package/src/core/safe-fs.ts
CHANGED
|
@@ -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
|
-
...(
|
|
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 })) {
|