@hajimism/dek 0.4.0 → 0.5.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 +73 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +4 -3
- package/src/cli/address.ts +11 -4
- 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/lint.ts +32 -13
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +46 -11
- 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 +49 -15
- 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/slide-html.ts +21 -18
- 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-script-eval.ts +2 -0
- package/src/core/spawn.ts +131 -32
- 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 +11 -3
- package/src/server/lock.ts +4 -4
- 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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { DekError } from "./error.ts";
|
|
2
|
+
import { runJsonWorker } from "./spawn.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Set to 1, the user accepts that Chromium draws without its sandbox where the sandbox cannot
|
|
6
|
+
* start, as in a container running as root: a ref's slides too, which are refused otherwise, and
|
|
7
|
+
* the project's own with no warning. Read only from the environment dek starts in (ADR 12).
|
|
8
|
+
*/
|
|
9
|
+
const ALLOW_UNSANDBOXED = "DEK_ALLOW_UNSANDBOXED";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a Playwright worker is told of Chromium's sandbox: draw only inside it, or inside it where
|
|
13
|
+
* it starts and without it where it does not. A worker told anything else requires it.
|
|
14
|
+
*/
|
|
15
|
+
export type SandboxMode = "required" | "preferred";
|
|
16
|
+
|
|
17
|
+
/** Chromium drew without its sandbox: why, in Chromium's words, and how to have it back. */
|
|
18
|
+
type Unsandboxed = { reason: string; hint: string };
|
|
19
|
+
|
|
20
|
+
function accepted(): boolean {
|
|
21
|
+
return process.env[ALLOW_UNSANDBOXED] === "1";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How a worker may draw: a ref's slide scripts are someone else's, so only in the sandbox unless
|
|
26
|
+
* the user accepted otherwise; the project's own wherever Chromium starts (ADR 29).
|
|
27
|
+
*/
|
|
28
|
+
export function sandboxMode(ref: boolean): SandboxMode {
|
|
29
|
+
return ref && !accepted() ? "required" : "preferred";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What Chromium says when its sandbox cannot start: as root, without user namespaces, or with its
|
|
34
|
+
* SUID helper set up wrong; then what Playwright 1.64 puts in place of Chromium's log when it holds
|
|
35
|
+
* one of those, which the call log after it still holds.
|
|
36
|
+
*/
|
|
37
|
+
const SANDBOX_SAYS = [
|
|
38
|
+
/No usable sandbox!|Running as root without --no-sandbox|SUID sandbox helper|Failed to move to new namespace|Received invalid process ID for zygote/,
|
|
39
|
+
/Chromium sandboxing failed!/,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** A line Chromium wrote to stderr, as Playwright's call log marks it, or one it logged as FATAL. */
|
|
43
|
+
const CHROMIUM_SAYS = /\[err\]|FATAL/;
|
|
44
|
+
|
|
45
|
+
/** The most of a line a reason keeps, in whole sentences; it keeps the first however long. */
|
|
46
|
+
const REASON_CHARS = 280;
|
|
47
|
+
|
|
48
|
+
/** A sentence: up to a `.`, `!`, or `?` that ends a word, outside parentheses, as `(0 vs. 1)` is. */
|
|
49
|
+
const SENTENCE = /(?:\([^)]*\)|\((?!\)|$)|[^()!?\.]|[.!?](?!\s|$))+(?:[.!?]+|$)/g;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What Chromium said when it failed: its words for a sandbox that cannot start, else its first
|
|
53
|
+
* line of error, past the call log's `- [pid=…][err]` and its own `[…:FATAL:…]`. Undefined when
|
|
54
|
+
* it said nothing, as when Playwright found no Chromium to start.
|
|
55
|
+
*/
|
|
56
|
+
export function chromiumFailure(error: unknown): string | undefined {
|
|
57
|
+
const lines = (error instanceof Error ? error.message : String(error)).split("\n");
|
|
58
|
+
for (const pattern of [...SANDBOX_SAYS, CHROMIUM_SAYS]) {
|
|
59
|
+
const line = lines.find((text) => pattern.test(text));
|
|
60
|
+
if (line !== undefined) {
|
|
61
|
+
return sentences(line.replace(/^[\s-]*(?:\[[^\]]*\]\s*)*/, ""));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Why a launch failed, in a line: what Chromium said, else the first line of Playwright's error.
|
|
69
|
+
* Never the command line Playwright's call log opens with, or its advice to launch without the
|
|
70
|
+
* sandbox.
|
|
71
|
+
*/
|
|
72
|
+
export function launchFailure(error: unknown): string {
|
|
73
|
+
const first = (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
74
|
+
return chromiumFailure(error) ?? sentences(first);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sentences(text: string): string {
|
|
78
|
+
let kept = "";
|
|
79
|
+
for (const [found] of text.trim().matchAll(SENTENCE)) {
|
|
80
|
+
const sentence = found.trim();
|
|
81
|
+
const next = kept ? `${kept} ${sentence}` : sentence;
|
|
82
|
+
if (kept && next.length > REASON_CHARS) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
kept = next;
|
|
86
|
+
}
|
|
87
|
+
return kept || text.trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const GENERAL_HELP =
|
|
91
|
+
"run dekc where Chromium's sandbox can start: as a user other than root, with user namespaces allowed (on Ubuntu 23.10 and later, `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`)";
|
|
92
|
+
|
|
93
|
+
/** What lets the sandbox start, for what Chromium said. */
|
|
94
|
+
function sandboxHelp(reason: string): string {
|
|
95
|
+
if (/Running as root/.test(reason)) {
|
|
96
|
+
return "run dekc as a user other than root, where Chromium's sandbox can start";
|
|
97
|
+
}
|
|
98
|
+
if (/No usable sandbox|namespace|zygote|AppArmor/i.test(reason)) {
|
|
99
|
+
return "let Chromium make user namespaces: on Ubuntu 23.10 and later, `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`, or an AppArmor profile for it; in a container, a seccomp profile that allows them";
|
|
100
|
+
}
|
|
101
|
+
return GENERAL_HELP;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** How to have the sandbox back, and while the user has not, how to accept going without it. */
|
|
105
|
+
function unsandboxedHint(reason: string): string {
|
|
106
|
+
const help = sandboxHelp(reason);
|
|
107
|
+
return accepted() ? help : `${help}; or set ${ALLOW_UNSANDBOXED}=1 to accept drawing without it`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Why Chromium drew without its sandbox in this run, once it has; see `takeUnsandboxed`. */
|
|
111
|
+
let drewUnsandboxed: string | undefined;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Says on stderr that Chromium drew without its sandbox, once a run, as lint --visual starts a
|
|
115
|
+
* worker or two per deck and the dev server one per pass; and keeps why, for the result. The
|
|
116
|
+
* project's own pages go unsaid once the user accepted it, a ref's never: it is someone else's code.
|
|
117
|
+
*/
|
|
118
|
+
function noteUnsandboxed(reason: string, ref: boolean): void {
|
|
119
|
+
if (drewUnsandboxed === undefined && (ref || !accepted())) {
|
|
120
|
+
process.stderr.write(
|
|
121
|
+
ref
|
|
122
|
+
? `warning: Chromium drew a ref without its sandbox, as ${ALLOW_UNSANDBOXED}=1 lets it: ${reason}\n help: ${sandboxHelp(reason)}\n`
|
|
123
|
+
: `warning: Chromium drew without its sandbox, which did not start here: ${reason}\n help: ${unsandboxedHint(reason)}\n`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
drewUnsandboxed = reason;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `{ unsandboxed }` when Chromium drew without its sandbox since the last call, for the result's
|
|
131
|
+
* `--json`, and nothing otherwise, as `skipped` is absent when every check ran. Each call starts
|
|
132
|
+
* the next run afresh.
|
|
133
|
+
*/
|
|
134
|
+
export function takeUnsandboxed(): { unsandboxed?: Unsandboxed } {
|
|
135
|
+
const reason = drewUnsandboxed;
|
|
136
|
+
drewUnsandboxed = undefined;
|
|
137
|
+
return reason === undefined ? {} : { unsandboxed: { reason, hint: unsandboxedHint(reason) } };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A worker's answer and why it drew without the sandbox; or, having drawn nothing, why the
|
|
142
|
+
* sandbox did not start, and why Chromium did not start without it either when it tried.
|
|
143
|
+
*/
|
|
144
|
+
type Drawn<T> =
|
|
145
|
+
| { answer: T; unsandboxed?: string }
|
|
146
|
+
| { refused: string }
|
|
147
|
+
| { failed: string; unsandboxed: string };
|
|
148
|
+
|
|
149
|
+
/** The worker's JSON as `parse` reads it, with what it says of the sandbox. */
|
|
150
|
+
function readDrawn<T>(
|
|
151
|
+
text: string,
|
|
152
|
+
sandbox: SandboxMode,
|
|
153
|
+
parse: (text: string) => T | null,
|
|
154
|
+
): Drawn<T> | null {
|
|
155
|
+
let said: { unsandboxed?: unknown; failed?: unknown } | null;
|
|
156
|
+
try {
|
|
157
|
+
said = JSON.parse(text);
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const { unsandboxed, failed } = said ?? {};
|
|
162
|
+
if (unsandboxed !== undefined && typeof unsandboxed !== "string") {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (typeof unsandboxed === "string" && typeof failed === "string") {
|
|
166
|
+
return { failed, unsandboxed };
|
|
167
|
+
}
|
|
168
|
+
if (unsandboxed !== undefined && sandbox === "required") {
|
|
169
|
+
return { refused: unsandboxed };
|
|
170
|
+
}
|
|
171
|
+
const answer = parse(text);
|
|
172
|
+
return answer === null ? null : { answer, ...(unsandboxed === undefined ? {} : { unsandboxed }) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Runs a Playwright worker as `runJsonWorker` does, telling it how it may draw: `ref` pages only in
|
|
177
|
+
* Chromium's sandbox unless the user accepted otherwise. A draw without the sandbox is said and
|
|
178
|
+
* kept for the result; a ref's pages the sandbox could not hold are refused, undrawn.
|
|
179
|
+
*/
|
|
180
|
+
export async function runDrawingWorker<T>(
|
|
181
|
+
cmd: string[],
|
|
182
|
+
request: object,
|
|
183
|
+
parse: (text: string) => T | null,
|
|
184
|
+
options: Parameters<typeof runJsonWorker>[3] & { ref?: boolean },
|
|
185
|
+
): Promise<T> {
|
|
186
|
+
const { ref = false, ...worker } = options;
|
|
187
|
+
const sandbox = sandboxMode(ref);
|
|
188
|
+
const drawn = await runJsonWorker(
|
|
189
|
+
cmd,
|
|
190
|
+
{ ...request, sandbox },
|
|
191
|
+
(text) => readDrawn(text, sandbox, parse),
|
|
192
|
+
worker,
|
|
193
|
+
);
|
|
194
|
+
if ("failed" in drawn) {
|
|
195
|
+
const first =
|
|
196
|
+
drawn.failed === drawn.unsandboxed ? "" : `; with it, Chromium said: ${drawn.unsandboxed}`;
|
|
197
|
+
throw new DekError(`Chromium did not start, with its sandbox or without: ${drawn.failed}`, {
|
|
198
|
+
hint: `${worker.hint}${first}`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if ("refused" in drawn) {
|
|
202
|
+
// A failure that names no sandbox may be Chromium missing as well.
|
|
203
|
+
const missing = /sandbox|namespace|zygote|as root/i.test(drawn.refused)
|
|
204
|
+
? ""
|
|
205
|
+
: `${worker.hint}; or `;
|
|
206
|
+
throw new DekError(
|
|
207
|
+
`Chromium did not start with its sandbox here, and a ref is drawn only inside it: ${drawn.refused}`,
|
|
208
|
+
{
|
|
209
|
+
hint: `${missing}${sandboxHelp(drawn.refused)}; if you accept running this ref's slide scripts without the sandbox, set ${ALLOW_UNSANDBOXED}=1 and run again`,
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (drawn.unsandboxed !== undefined) {
|
|
214
|
+
noteUnsandboxed(drawn.unsandboxed, ref);
|
|
215
|
+
}
|
|
216
|
+
return drawn.answer;
|
|
217
|
+
}
|
package/src/core/shot-cache.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
// biome-ignore lint/style/noRestrictedImports: rmSync, which prunes old entries by name in the folder outputDir returned, and takes a link, not its target.
|
|
2
3
|
import { readdirSync, rmSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { escapeRegExp } from "./escape.ts";
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* that never ends is cut off; the parent's timeout covers anything that
|
|
7
7
|
* escapes into the microtask queue.
|
|
8
8
|
*/
|
|
9
|
+
// biome-ignore lint/style/noRestrictedImports: writeSync, to stdout (fd 1), so each answer is out before the worker is killed or exits.
|
|
9
10
|
import { readFileSync, writeSync } from "node:fs";
|
|
10
11
|
import vm from "node:vm";
|
|
11
12
|
|
|
@@ -28,6 +28,7 @@ export function slideScriptsProblems(scripts: ScriptInput[]): SlideScriptProblem
|
|
|
28
28
|
const { results, pending } = prepareScripts(scripts);
|
|
29
29
|
const codes = uncached(pending);
|
|
30
30
|
if (codes.length > 0) {
|
|
31
|
+
// biome-ignore lint/nursery/noJsRestrictedProperties: lint's path is synchronous, the scripts get an empty environment, and a killed worker's lines still count; runPiped does none of these.
|
|
31
32
|
const stdout = Bun.spawnSync(evaluatorCommand(), {
|
|
32
33
|
...EVALUATOR_OPTIONS,
|
|
33
34
|
stdin: Buffer.from(JSON.stringify(codes)),
|
|
@@ -48,6 +49,7 @@ export async function evaluateSlideScripts(
|
|
|
48
49
|
const { results, pending } = prepareScripts(scripts);
|
|
49
50
|
const codes = uncached(pending);
|
|
50
51
|
if (codes.length > 0) {
|
|
52
|
+
// biome-ignore lint/nursery/noJsRestrictedProperties: as for spawnSync above; SIGTERM ends the worker, as no host object reaches its scripts.
|
|
51
53
|
const child = Bun.spawn(evaluatorCommand(), {
|
|
52
54
|
...EVALUATOR_OPTIONS,
|
|
53
55
|
stdin: Buffer.from(JSON.stringify(codes)),
|
package/src/core/spawn.ts
CHANGED
|
@@ -1,18 +1,5 @@
|
|
|
1
1
|
import { DekError } from "./error.ts";
|
|
2
2
|
|
|
3
|
-
export async function awaitPiped(proc: {
|
|
4
|
-
stdout: ReadableStream<Uint8Array> | number;
|
|
5
|
-
stderr: ReadableStream<Uint8Array> | number;
|
|
6
|
-
exited: Promise<number>;
|
|
7
|
-
}): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
8
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
9
|
-
new Response(proc.stdout as ReadableStream<Uint8Array>).text(),
|
|
10
|
-
new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
|
|
11
|
-
proc.exited,
|
|
12
|
-
]);
|
|
13
|
-
return { stdout, stderr, exitCode };
|
|
14
|
-
}
|
|
15
|
-
|
|
16
3
|
/**
|
|
17
4
|
* How dek runs bun: with no auto install, and without the working directory's `.env` or
|
|
18
5
|
* `bunfig.toml`, which a repository someone else wrote can commit to set variables or preload code.
|
|
@@ -28,6 +15,15 @@ export function workerCommand(bin: string, args: string[] = []): string[] {
|
|
|
28
15
|
/** A browser that hangs must not hang dek; a whole visual run or capture fits well inside this. */
|
|
29
16
|
const DEFAULT_WORKER_TIMEOUT_MS = 10 * 60 * 1000;
|
|
30
17
|
|
|
18
|
+
/** How long a command stopped at its timeout has to exit on SIGTERM before it gets SIGKILL. */
|
|
19
|
+
const KILL_GRACE_MS = 2000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How long the output is still read once the command has exited. A process it started and left
|
|
23
|
+
* running holds the pipes open as long as it lives, so what came by then is the output.
|
|
24
|
+
*/
|
|
25
|
+
const DRAIN_MS = 1000;
|
|
26
|
+
|
|
31
27
|
type PipedOptions = {
|
|
32
28
|
/** The error message when the command cannot start or overruns, such as "ffmpeg failed". */
|
|
33
29
|
label: string;
|
|
@@ -37,9 +33,20 @@ type PipedOptions = {
|
|
|
37
33
|
stdin?: string;
|
|
38
34
|
/** Covers the whole run, from spawn to exit. */
|
|
39
35
|
timeoutMs: number;
|
|
36
|
+
/** Where the command runs; dek's working directory when left out. */
|
|
37
|
+
cwd?: string;
|
|
40
38
|
};
|
|
41
39
|
|
|
42
|
-
type Piped = {
|
|
40
|
+
type Piped = {
|
|
41
|
+
stdout: string;
|
|
42
|
+
stderr: string;
|
|
43
|
+
exitCode: number;
|
|
44
|
+
/** Set when the output was let go before it ended, `DRAIN_MS` after the command exited. */
|
|
45
|
+
cutShort?: true;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** What `runPiped` throws for a command it stopped at its timeout, unlike one that cannot start. */
|
|
49
|
+
export class OverrunError extends DekError {}
|
|
43
50
|
|
|
44
51
|
/**
|
|
45
52
|
* Runs `cmd` to its exit with its output piped back. A command that cannot start, or runs past
|
|
@@ -48,36 +55,123 @@ type Piped = { stdout: string; stderr: string; exitCode: number };
|
|
|
48
55
|
export async function runPiped(cmd: string[], options: PipedOptions): Promise<Piped> {
|
|
49
56
|
let proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
|
|
50
57
|
try {
|
|
51
|
-
proc = Bun.spawn(cmd, {
|
|
58
|
+
proc = Bun.spawn(cmd, {
|
|
59
|
+
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
60
|
+
// The environment as it is now: left out, Bun passes the one dek started with, which holds
|
|
61
|
+
// what a `.env` set before dek took it out (see dropEnvFileVariables).
|
|
62
|
+
env: process.env,
|
|
63
|
+
stdin: "pipe",
|
|
64
|
+
stdout: "pipe",
|
|
65
|
+
stderr: "pipe",
|
|
66
|
+
});
|
|
52
67
|
} catch (error) {
|
|
53
68
|
throw new DekError(options.label, { hint: options.hint, cause: error });
|
|
54
69
|
}
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
// Read before writing: a command that answers while still reading must not block on a full pipe.
|
|
71
|
+
const output = readOutput(proc);
|
|
72
|
+
const stop = stopAfter(proc, options.timeoutMs);
|
|
73
|
+
const overrun = () =>
|
|
74
|
+
new OverrunError(options.label, {
|
|
75
|
+
hint: `it did not finish within ${Math.round(options.timeoutMs / 1000)}s and was stopped`,
|
|
76
|
+
});
|
|
60
77
|
try {
|
|
61
|
-
// Read before writing: a command that answers while still reading must not block on a full pipe.
|
|
62
|
-
const piped = awaitPiped(proc);
|
|
63
78
|
if (options.stdin !== undefined) {
|
|
64
79
|
proc.stdin.write(options.stdin);
|
|
65
80
|
}
|
|
66
81
|
await proc.stdin.end();
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
const exitCode = await proc.exited;
|
|
83
|
+
// It finished in time, however long what it left behind keeps the pipes open.
|
|
84
|
+
stop.clear();
|
|
85
|
+
const { stdout, stderr, cutShort } = await output.drain();
|
|
86
|
+
if (stop.fired()) {
|
|
87
|
+
throw overrun();
|
|
72
88
|
}
|
|
73
|
-
return
|
|
89
|
+
return { stdout, stderr, exitCode, ...(cutShort ? { cutShort } : {}) };
|
|
74
90
|
} catch (error) {
|
|
75
91
|
if (error instanceof DekError) {
|
|
76
92
|
throw error;
|
|
77
93
|
}
|
|
94
|
+
// Killing the command breaks the pipe a write to its stdin was waiting on.
|
|
95
|
+
if (stop.fired()) {
|
|
96
|
+
throw overrun();
|
|
97
|
+
}
|
|
78
98
|
throw new DekError(options.label, { hint: options.hint, cause: error });
|
|
79
99
|
} finally {
|
|
80
|
-
|
|
100
|
+
stop.clear();
|
|
101
|
+
output.cancel();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Stops `proc` once `timeoutMs` passes: SIGTERM, then SIGKILL if it is still running
|
|
107
|
+
* `KILL_GRACE_MS` later. `clear` calls off the timeout, never a SIGKILL already on its way.
|
|
108
|
+
*
|
|
109
|
+
* Only the command is signalled, not what it started: a command that forks a child and is killed
|
|
110
|
+
* leaves the child running. `bun add -d rumdl` puts a Node wrapper in front of rumdl that runs the
|
|
111
|
+
* binary with spawnSync, so a rumdl that hangs is stopped and its binary lives on as an orphan;
|
|
112
|
+
* the drain keeps it from holding dek. Killing the whole group would need the command in a session
|
|
113
|
+
* of its own (`detached`), where the terminal's Ctrl-C no longer reaches it.
|
|
114
|
+
*/
|
|
115
|
+
function stopAfter(
|
|
116
|
+
proc: Bun.Subprocess,
|
|
117
|
+
timeoutMs: number,
|
|
118
|
+
): { fired: () => boolean; clear: () => void } {
|
|
119
|
+
let fired = false;
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
fired = true;
|
|
122
|
+
proc.kill();
|
|
123
|
+
const kill = setTimeout(() => proc.kill("SIGKILL"), KILL_GRACE_MS);
|
|
124
|
+
void proc.exited.finally(() => clearTimeout(kill));
|
|
125
|
+
}, timeoutMs);
|
|
126
|
+
return { fired: () => fired, clear: () => clearTimeout(timer) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The command's stdout and stderr, read as they come. `drain`, once it has exited, waits
|
|
131
|
+
* `DRAIN_MS` at most for both to end, and says when it had to let go first (`cutShort`); `cancel`
|
|
132
|
+
* lets go of both, which an open pipe needs before dek can exit.
|
|
133
|
+
*/
|
|
134
|
+
function readOutput(proc: Bun.Subprocess<"pipe", "pipe", "pipe">): {
|
|
135
|
+
drain: () => Promise<{ stdout: string; stderr: string; cutShort: boolean }>;
|
|
136
|
+
cancel: () => void;
|
|
137
|
+
} {
|
|
138
|
+
const readers = [proc.stdout.getReader(), proc.stderr.getReader()];
|
|
139
|
+
const texts = Promise.all(readers.map(readToEnd));
|
|
140
|
+
// Awaited by `drain`; a read that fails before then must not go unhandled.
|
|
141
|
+
texts.catch(() => {});
|
|
142
|
+
const cancel = () => {
|
|
143
|
+
for (const reader of readers) {
|
|
144
|
+
reader.cancel().catch(() => {});
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
drain: async () => {
|
|
149
|
+
let cutShort = false;
|
|
150
|
+
const timer = setTimeout(() => {
|
|
151
|
+
cutShort = true;
|
|
152
|
+
cancel();
|
|
153
|
+
}, DRAIN_MS);
|
|
154
|
+
try {
|
|
155
|
+
const [stdout = "", stderr = ""] = await texts;
|
|
156
|
+
return { stdout, stderr, cutShort };
|
|
157
|
+
} finally {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
cancel,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The text a reader reads until its stream ends or is cancelled. */
|
|
166
|
+
async function readToEnd(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
|
|
167
|
+
const decoder = new TextDecoder();
|
|
168
|
+
let text = "";
|
|
169
|
+
for (;;) {
|
|
170
|
+
const { done, value } = await reader.read();
|
|
171
|
+
if (done) {
|
|
172
|
+
return text + decoder.decode();
|
|
173
|
+
}
|
|
174
|
+
text += decoder.decode(value, { stream: true });
|
|
81
175
|
}
|
|
82
176
|
}
|
|
83
177
|
|
|
@@ -94,7 +188,8 @@ type JsonWorkerOptions = {
|
|
|
94
188
|
* Runs a worker that takes one JSON request on stdin and answers with JSON on
|
|
95
189
|
* stdout. Every failure is a DekError named `label`: a worker that exits
|
|
96
190
|
* non-zero contributes its stderr as the hint, one that overruns the timeout
|
|
97
|
-
* is killed and says so, and output `parse` rejects is invalid JSON
|
|
191
|
+
* is killed and says so, and output `parse` rejects is invalid JSON, or cut
|
|
192
|
+
* short when a process the worker left behind held it open.
|
|
98
193
|
*/
|
|
99
194
|
export async function runJsonWorker<T>(
|
|
100
195
|
cmd: string[],
|
|
@@ -102,7 +197,7 @@ export async function runJsonWorker<T>(
|
|
|
102
197
|
parse: (stdout: string) => T | null,
|
|
103
198
|
options: JsonWorkerOptions,
|
|
104
199
|
): Promise<T> {
|
|
105
|
-
const { stdout, stderr, exitCode } = await runPiped(cmd, {
|
|
200
|
+
const { stdout, stderr, exitCode, cutShort } = await runPiped(cmd, {
|
|
106
201
|
label: options.label,
|
|
107
202
|
hint: options.hint,
|
|
108
203
|
stdin: JSON.stringify(request),
|
|
@@ -113,7 +208,11 @@ export async function runJsonWorker<T>(
|
|
|
113
208
|
}
|
|
114
209
|
const parsed = parse(stdout);
|
|
115
210
|
if (parsed === null) {
|
|
116
|
-
throw new DekError(options.label, {
|
|
211
|
+
throw new DekError(options.label, {
|
|
212
|
+
hint: cutShort
|
|
213
|
+
? "the worker exited while a process it started still held its output, so its answer was cut short; run the command again"
|
|
214
|
+
: "worker returned invalid JSON",
|
|
215
|
+
});
|
|
117
216
|
}
|
|
118
217
|
return parsed;
|
|
119
218
|
}
|
package/src/core/sync.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { loadConfig } from "./config.ts";
|
|
4
4
|
import { type DeckPaths, deckPaths } from "./deck-paths.ts";
|
|
5
|
+
import { foreignLine, TITLE_MAX } from "./foreign-text.ts";
|
|
5
6
|
import { parseRefSource, refDir, refTitle } from "./ref.ts";
|
|
6
7
|
import { asResolvedDeck, listSlides, type ResolvedDeck, SLIDE_SIDECARS } from "./resolve.ts";
|
|
7
8
|
import { outputDir, readSourceIfExists, removeInside, writeInside } from "./safe-fs.ts";
|
|
@@ -293,23 +294,13 @@ For commands, run \`dekc help --agent\`.
|
|
|
293
294
|
`;
|
|
294
295
|
}
|
|
295
296
|
|
|
296
|
-
/** The longest ref title AGENTS.md shows: a title names a deck, it does not describe one. */
|
|
297
|
-
const REF_TITLE_MAX = 80;
|
|
298
|
-
|
|
299
297
|
/**
|
|
300
298
|
* A ref's title as it goes into AGENTS.md. Someone else wrote it, and agents read dek's block as
|
|
301
299
|
* dek's own guidance, so a title must not start a line of its own there, such as a heading or an
|
|
302
|
-
* instruction
|
|
303
|
-
* REF_TITLE_MAX characters.
|
|
300
|
+
* instruction, nor hold what a person cannot see: it is one foreignLine of TITLE_MAX.
|
|
304
301
|
*/
|
|
305
302
|
function refTitleLine(title: string | undefined): string | undefined {
|
|
306
|
-
|
|
307
|
-
const line = title?.replace(/[\s\x00-\x1f\x7f-\x9f]+/gu, " ").trim();
|
|
308
|
-
if (!line) {
|
|
309
|
-
return undefined;
|
|
310
|
-
}
|
|
311
|
-
const chars = [...line];
|
|
312
|
-
return chars.length > REF_TITLE_MAX ? `${chars.slice(0, REF_TITLE_MAX - 1).join("")}…` : line;
|
|
303
|
+
return title === undefined ? undefined : foreignLine(title, TITLE_MAX) || undefined;
|
|
313
304
|
}
|
|
314
305
|
|
|
315
306
|
/**
|