decklight 0.1.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/LICENSE +21 -0
- package/README.md +257 -0
- package/SPEC.md +372 -0
- package/cli/bundle.mjs +388 -0
- package/cli/decklight.mjs +95 -0
- package/cli/edit.mjs +142 -0
- package/cli/init.mjs +215 -0
- package/cli/rec.mjs +538 -0
- package/dist/decklight.css +1068 -0
- package/dist/decklight.js +239 -0
- package/dist/decklight.js.map +7 -0
- package/docs/architecture.svg +94 -0
- package/docs/demo.svg +124 -0
- package/package.json +54 -0
- package/themes/README.md +61 -0
- package/themes/aliens.css +84 -0
- package/themes/apple2.css +126 -0
- package/themes/aurora.css +84 -0
- package/themes/berry.css +80 -0
- package/themes/blade-runner.css +83 -0
- package/themes/c64.css +85 -0
- package/themes/carbon.css +82 -0
- package/themes/citrus.css +80 -0
- package/themes/coastal.css +80 -0
- package/themes/cosmos.css +86 -0
- package/themes/dune.css +80 -0
- package/themes/eclipse.css +82 -0
- package/themes/ember.css +80 -0
- package/themes/fjord.css +80 -0
- package/themes/friends.css +82 -0
- package/themes/gallery.html +203 -0
- package/themes/gameboy.css +118 -0
- package/themes/genesis.css +84 -0
- package/themes/glacier.css +82 -0
- package/themes/godfather.css +82 -0
- package/themes/graphite.css +80 -0
- package/themes/harvest.css +80 -0
- package/themes/ibm-modern.css +83 -0
- package/themes/ibm-oldschool.css +84 -0
- package/themes/ink.css +82 -0
- package/themes/latte.css +80 -0
- package/themes/linen.css +80 -0
- package/themes/meadow.css +80 -0
- package/themes/metropolis.css +85 -0
- package/themes/miami-vice.css +84 -0
- package/themes/midnight.css +80 -0
- package/themes/mint.css +82 -0
- package/themes/moss.css +82 -0
- package/themes/obsidian.css +82 -0
- package/themes/orchid.css +82 -0
- package/themes/packs.json +89 -0
- package/themes/paper.css +83 -0
- package/themes/peony.css +84 -0
- package/themes/porcelain.css +82 -0
- package/themes/pulp-fiction.css +84 -0
- package/themes/reveal-beige.css +87 -0
- package/themes/reveal-black.css +83 -0
- package/themes/reveal-blood.css +84 -0
- package/themes/reveal-dracula.css +83 -0
- package/themes/reveal-league.css +88 -0
- package/themes/reveal-moon.css +83 -0
- package/themes/reveal-night.css +86 -0
- package/themes/reveal-serif.css +82 -0
- package/themes/reveal-simple.css +84 -0
- package/themes/reveal-sky.css +85 -0
- package/themes/reveal-solarized.css +83 -0
- package/themes/reveal-white.css +82 -0
- package/themes/sepia.css +81 -0
- package/themes/seriph.css +82 -0
- package/themes/severance.css +82 -0
- package/themes/slate.css +80 -0
- package/themes/snes.css +82 -0
- package/themes/star-wars.css +85 -0
- package/themes/storm.css +80 -0
- package/themes/stranger-things.css +84 -0
- package/themes/synthwave.css +90 -0
- package/themes/terminator.css +84 -0
- package/themes/velvet.css +80 -0
- package/tools/gemini-tts.mjs +140 -0
- package/tools/publish-site-voices.mjs +71 -0
- package/tools/voiceover-server.mjs +0 -0
- package/tools/voiceover.mjs +155 -0
package/cli/rec.mjs
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* decklight-rec — record truthful terminal casts for Decklight decks (SPEC §7.1/§7.2).
|
|
4
|
+
*
|
|
5
|
+
* decklight rec <script.term.yaml> [-o out.cast.json] [--allow-fail] [--quiet]
|
|
6
|
+
* decklight refresh <dir | cast.json…> [--allow-fail]
|
|
7
|
+
*
|
|
8
|
+
* How a step boundary is detected (the sentinel technique):
|
|
9
|
+
* All commands run in ONE persistent PTY shell session. For each step we
|
|
10
|
+
* write two lines to the PTY: the command itself, then
|
|
11
|
+
* `printf '<GS>DECKLIGHT:%d:<GS>\n' $?`. The shell doesn't read the second
|
|
12
|
+
* line until the foreground command has finished, so the moment the
|
|
13
|
+
* GS-delimited marker shows up in the output stream *is* the step boundary,
|
|
14
|
+
* and it carries the command's exit code. Terminal echo is switched off
|
|
15
|
+
* (`stty -echo`) during session setup, so neither the typed command nor the
|
|
16
|
+
* sentinel line pollutes the captured output — the player re-types commands
|
|
17
|
+
* itself from the cast. Setup noise (shell banner, stty) is discarded by
|
|
18
|
+
* waiting for a READY marker before recording begins.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
|
|
29
|
+
// ASCII group separator delimits sentinels — vanishingly unlikely in real
|
|
30
|
+
// output. The input we write to the PTY stays pure ASCII (raw control bytes
|
|
31
|
+
// would be eaten by ZLE keybindings before setup lands): printf's octal
|
|
32
|
+
// escape `\035` generates the GS byte shell-side.
|
|
33
|
+
const GS = '\x1d';
|
|
34
|
+
const GS_OCTAL = '\\035';
|
|
35
|
+
const STEP_MARK = new RegExp(`${GS}DECKLIGHT:(-?\\d+):${GS}`);
|
|
36
|
+
const READY_MARK = `${GS}DECKLIGHT-READY${GS}`;
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------- utilities
|
|
39
|
+
|
|
40
|
+
function fail(msg) {
|
|
41
|
+
process.stderr.write(`decklight rec: ${msg}\n`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadDeps() {
|
|
46
|
+
let yaml, pty;
|
|
47
|
+
try { yaml = require('js-yaml'); } catch { fail('js-yaml is not installed — run: npm install js-yaml'); }
|
|
48
|
+
try {
|
|
49
|
+
// npm strips the exec bit from node-pty's prebuilt spawn-helper on some
|
|
50
|
+
// installs, which surfaces as a cryptic "posix_spawnp failed". Self-heal.
|
|
51
|
+
const helperDir = path.dirname(require.resolve('node-pty/package.json'));
|
|
52
|
+
const helper = path.join(helperDir, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper');
|
|
53
|
+
if (fs.existsSync(helper)) {
|
|
54
|
+
const mode = fs.statSync(helper).mode;
|
|
55
|
+
if (!(mode & 0o111)) fs.chmodSync(helper, mode | 0o755);
|
|
56
|
+
}
|
|
57
|
+
pty = require('node-pty');
|
|
58
|
+
} catch (e) { fail(`node-pty is not installed or failed to load (${e.message}) — run: npm install node-pty`); }
|
|
59
|
+
return { yaml, pty };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function expandHome(p) {
|
|
63
|
+
if (!p) return p;
|
|
64
|
+
return p === '~' ? os.homedir() : p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
68
|
+
|
|
69
|
+
/** `$NAME` (optionally with a suffix like "\n") resolves against the session
|
|
70
|
+
* env; anything else is a literal. */
|
|
71
|
+
function resolveSecret(v, env) {
|
|
72
|
+
const m = v.match(/^\$([A-Za-z_][A-Za-z0-9_]*)/);
|
|
73
|
+
if (!m) return v;
|
|
74
|
+
const val = env[m[1]];
|
|
75
|
+
if (val === undefined) throw new Error(`secret env var ${m[1]} is not set`);
|
|
76
|
+
return val + v.slice(m[0].length);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Clamp inter-event gaps to `maxIdle` seconds across a merged, time-sorted
|
|
81
|
+
* event list (outputs and inputs share one clock, so they are clamped
|
|
82
|
+
* together). Events: [{t, …}] — returns the same objects with adjusted `t`.
|
|
83
|
+
*/
|
|
84
|
+
function clampIdle(events, maxIdle) {
|
|
85
|
+
if (!(maxIdle > 0)) return events;
|
|
86
|
+
let shift = 0;
|
|
87
|
+
let prev = 0;
|
|
88
|
+
for (const ev of events.sort((a, b) => a.t - b.t)) {
|
|
89
|
+
const gap = ev.t - prev;
|
|
90
|
+
if (gap > maxIdle) shift += gap - maxIdle;
|
|
91
|
+
prev = ev.t;
|
|
92
|
+
ev.t = round3(ev.t - shift);
|
|
93
|
+
}
|
|
94
|
+
return events;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function shellArgs(shellPath) {
|
|
98
|
+
const base = path.basename(shellPath);
|
|
99
|
+
if (base === 'zsh') return ['-f']; // no rc files
|
|
100
|
+
if (base === 'bash') return ['--norc', '--noprofile'];
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function setupLine(shellBase) {
|
|
105
|
+
const common = `stty -echo; printf '${GS_OCTAL}DECKLIGHT-READY${GS_OCTAL}\\n'`;
|
|
106
|
+
if (shellBase === 'zsh') return `unsetopt zle prompt_cr prompt_sp banghist 2>/dev/null; PS1=''; PROMPT=''; PS2=''; PROMPT2=''; ${common}\n`;
|
|
107
|
+
return `PS1=''; PS2=''; ${common}\n`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------- recording
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Execute every step of a parsed script in one PTY session.
|
|
114
|
+
* Resolves to the cast's `steps` array; rejects on timeout / shell death /
|
|
115
|
+
* (without allowFail) non-zero exits.
|
|
116
|
+
*/
|
|
117
|
+
export async function recordScript(script, { scriptDir, allowFail = false, log = () => {} }) {
|
|
118
|
+
const { pty } = loadDeps();
|
|
119
|
+
const shellName = script.shell || path.basename(process.env.SHELL || 'zsh');
|
|
120
|
+
const shellPath = shellName.includes('/') ? shellName : `/bin/${shellName}`;
|
|
121
|
+
if (!fs.existsSync(shellPath)) throw new Error(`shell not found: ${shellPath}`);
|
|
122
|
+
const cols = script.cols ?? 100;
|
|
123
|
+
const rows = script.rows ?? 28;
|
|
124
|
+
const base = scriptDir || process.cwd();
|
|
125
|
+
const cwd = script.cwd ? path.resolve(base, expandHome(script.cwd)) : base;
|
|
126
|
+
if (!fs.existsSync(cwd)) throw new Error(`cwd not found: ${cwd}`);
|
|
127
|
+
|
|
128
|
+
const redactions = (script.redact || []).map(r => new RegExp(r, 'g'));
|
|
129
|
+
const redact = s => redactions.reduce((acc, re) => acc.replace(re, '▓▓▓'), s);
|
|
130
|
+
|
|
131
|
+
// Scrubbed placeholders in a refreshed cast's env must not shadow the
|
|
132
|
+
// operator's real environment (the secret comes back via process.env).
|
|
133
|
+
const scriptEnv = Object.fromEntries(
|
|
134
|
+
Object.entries(script.env || {}).filter(([, v]) => v !== '▓▓▓'));
|
|
135
|
+
|
|
136
|
+
const term = pty.spawn(shellPath, shellArgs(shellPath), {
|
|
137
|
+
name: 'xterm-256color',
|
|
138
|
+
cols, rows, cwd,
|
|
139
|
+
env: {
|
|
140
|
+
...process.env,
|
|
141
|
+
// Automated recording must never hand control to a pager.
|
|
142
|
+
PAGER: 'cat', GIT_PAGER: 'cat', MANPAGER: 'cat',
|
|
143
|
+
...scriptEnv,
|
|
144
|
+
TERM: 'xterm-256color',
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
let buffer = '';
|
|
149
|
+
let onChunk = null; // (data) => void — active step's collector
|
|
150
|
+
let shellDead = false;
|
|
151
|
+
let onExitCb = null;
|
|
152
|
+
term.onData(d => { buffer += d; if (onChunk) onChunk(d); });
|
|
153
|
+
term.onExit(() => { shellDead = true; if (onExitCb) onExitCb(); });
|
|
154
|
+
|
|
155
|
+
const waitFor = (test, timeoutMs, what, isCancelled) => new Promise((resolve, reject) => {
|
|
156
|
+
const started = Date.now();
|
|
157
|
+
const tick = () => {
|
|
158
|
+
const m = test();
|
|
159
|
+
if (m) return resolve(m);
|
|
160
|
+
if (isCancelled?.()) return reject(new Error('cancelled'));
|
|
161
|
+
if (shellDead) return reject(new Error(`shell died while waiting for ${what}`));
|
|
162
|
+
if (Date.now() - started > timeoutMs) return reject(new Error(`timeout waiting for ${what}`));
|
|
163
|
+
setTimeout(tick, 15);
|
|
164
|
+
};
|
|
165
|
+
tick();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
// --- setup phase: silence echo & prompts, then discard everything so far
|
|
170
|
+
term.write(setupLine(path.basename(shellPath)));
|
|
171
|
+
await waitFor(() => buffer.includes(READY_MARK), 10_000, 'shell setup');
|
|
172
|
+
buffer = '';
|
|
173
|
+
|
|
174
|
+
const sessionEnv = { ...process.env, ...scriptEnv };
|
|
175
|
+
const maxIdle = script.max_idle ?? 2.0;
|
|
176
|
+
const steps = [];
|
|
177
|
+
for (const [idx, step] of (script.steps || []).entries()) {
|
|
178
|
+
// Pure pause step: sleeps for real (session truthfulness — background
|
|
179
|
+
// state may be advancing) and records a timing marker.
|
|
180
|
+
if (step && typeof step.sleep === 'number' && step.cmd === undefined) {
|
|
181
|
+
await new Promise(r => setTimeout(r, step.sleep * 1000));
|
|
182
|
+
steps.push({ sleep: round3(step.sleep) });
|
|
183
|
+
log(` ✓ (sleep ${step.sleep}s)`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// YAML gotcha: `cmd: false` / `cmd: 42` parse as scalars — coerce.
|
|
187
|
+
if (step && (typeof step.cmd === 'boolean' || typeof step.cmd === 'number')) step.cmd = String(step.cmd);
|
|
188
|
+
if (!step || typeof step.cmd !== 'string') throw new Error(`step ${idx + 1}: missing "cmd"`);
|
|
189
|
+
const timeoutMs = (step.timeout ?? 60) * 1000;
|
|
190
|
+
const chunks = [];
|
|
191
|
+
const t0 = process.hrtime.bigint();
|
|
192
|
+
let collected = '';
|
|
193
|
+
onChunk = d => {
|
|
194
|
+
collected += d;
|
|
195
|
+
chunks.push([Number(process.hrtime.bigint() - t0) / 1e9, d]);
|
|
196
|
+
};
|
|
197
|
+
// The command runs as a brace group with the sentinel on the group's
|
|
198
|
+
// closing line. Written as two separate lines, a command that reads
|
|
199
|
+
// stdin (read, logins) would consume the queued sentinel line as its
|
|
200
|
+
// input; inside a brace group the shell's parser consumes the middle
|
|
201
|
+
// lines while parsing, so nothing is buffered for the command's stdin —
|
|
202
|
+
// interact sends are the only input it can see. The lone `}` line also
|
|
203
|
+
// makes trailing `#` comments in cmd harmless.
|
|
204
|
+
term.write(`{\n${step.cmd}\n}; printf '${GS_OCTAL}DECKLIGHT:%d:${GS_OCTAL}\\n' $?\n`);
|
|
205
|
+
|
|
206
|
+
// interact: watch output, answer prompts (expect/send) — runs alongside
|
|
207
|
+
// the sentinel wait; secrets are sent for real but recorded as ▓▓▓ and
|
|
208
|
+
// added to this step's redaction set.
|
|
209
|
+
const interacts = Array.isArray(step.interact) ? step.interact : [];
|
|
210
|
+
const inputs = [];
|
|
211
|
+
const stepRedactions = [];
|
|
212
|
+
let interactState = { done: interacts.length === 0, error: null, pending: null, cancelled: false };
|
|
213
|
+
if (interacts.length) {
|
|
214
|
+
(async () => {
|
|
215
|
+
let searchFrom = 0;
|
|
216
|
+
try {
|
|
217
|
+
for (const it of interacts) {
|
|
218
|
+
interactState.pending = it.expect;
|
|
219
|
+
const re = new RegExp(it.expect);
|
|
220
|
+
const found = await waitFor(() => {
|
|
221
|
+
const mm = collected.slice(searchFrom).match(re);
|
|
222
|
+
return mm ? { end: searchFrom + mm.index + mm[0].length } : null;
|
|
223
|
+
}, timeoutMs, `expect /${it.expect}/ in step ${idx + 1} (${step.cmd})`, () => interactState.cancelled);
|
|
224
|
+
searchFrom = found.end;
|
|
225
|
+
let text, display;
|
|
226
|
+
if (it.send && typeof it.send === 'object' && 'secret' in it.send) {
|
|
227
|
+
text = resolveSecret(String(it.send.secret), sessionEnv);
|
|
228
|
+
display = '▓▓▓';
|
|
229
|
+
const bare = text.replace(/\r?\n$/, '');
|
|
230
|
+
if (bare) stepRedactions.push(new RegExp(escapeRegExp(bare), 'g'));
|
|
231
|
+
} else {
|
|
232
|
+
text = String(it.send ?? '');
|
|
233
|
+
display = text;
|
|
234
|
+
}
|
|
235
|
+
inputs.push([round3(Number(process.hrtime.bigint() - t0) / 1e9), display]);
|
|
236
|
+
term.write(text);
|
|
237
|
+
interactState.pending = null;
|
|
238
|
+
}
|
|
239
|
+
} catch (e) {
|
|
240
|
+
if (e.message !== 'cancelled') interactState.error = e;
|
|
241
|
+
}
|
|
242
|
+
interactState.done = true;
|
|
243
|
+
})();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let m;
|
|
247
|
+
try {
|
|
248
|
+
// wait_for: the step only completes once the output matches (useful
|
|
249
|
+
// to fail fast when an expected banner/state never appears).
|
|
250
|
+
if (step.wait_for) {
|
|
251
|
+
await waitFor(() => collected.match(new RegExp(step.wait_for)), timeoutMs,
|
|
252
|
+
`wait_for /${step.wait_for}/ in step ${idx + 1} (${step.cmd})`);
|
|
253
|
+
}
|
|
254
|
+
m = await waitFor(() => collected.match(STEP_MARK), timeoutMs, `step ${idx + 1} (${step.cmd})`);
|
|
255
|
+
} finally { onChunk = null; interactState.cancelled = true; }
|
|
256
|
+
if (interactState.error) throw interactState.error;
|
|
257
|
+
if (!interactState.done && interactState.pending) {
|
|
258
|
+
const err = `step ${idx + 1} exited before expect /${interactState.pending}/ matched: ${step.cmd}`;
|
|
259
|
+
if (!allowFail) throw new Error(err);
|
|
260
|
+
log(` ⚠ ${err}`);
|
|
261
|
+
}
|
|
262
|
+
const exit = parseInt(m[1], 10);
|
|
263
|
+
const duration = Number(process.hrtime.bigint() - t0) / 1e9;
|
|
264
|
+
|
|
265
|
+
// Trim the sentinel (and anything after it) out of the captured chunks.
|
|
266
|
+
const redactStep = s => stepRedactions.reduce((acc, re) => acc.replace(re, '▓▓▓'), redact(s));
|
|
267
|
+
const cut = collected.search(STEP_MARK);
|
|
268
|
+
const kept = [];
|
|
269
|
+
let seen = 0;
|
|
270
|
+
for (const [t, d] of chunks) {
|
|
271
|
+
if (seen + d.length <= cut) { kept.push([round3(t), redactStep(d)]); seen += d.length; }
|
|
272
|
+
else { const part = d.slice(0, Math.max(0, cut - seen)); if (part) kept.push([round3(t), redactStep(part)]); break; }
|
|
273
|
+
}
|
|
274
|
+
// Drop the trailing prompt-less blank the sentinel's own newline leaves.
|
|
275
|
+
const out = normalizeTail(kept);
|
|
276
|
+
|
|
277
|
+
// Idle clamping: outputs and inputs share one clock; clamp them jointly.
|
|
278
|
+
const merged = [
|
|
279
|
+
...out.map(([t, d]) => ({ t, kind: 'o', d })),
|
|
280
|
+
...inputs.map(([t, d]) => ({ t, kind: 'i', d })),
|
|
281
|
+
];
|
|
282
|
+
clampIdle(merged, maxIdle);
|
|
283
|
+
const outClamped = merged.filter(e => e.kind === 'o').map(e => [e.t, e.d]);
|
|
284
|
+
const inClamped = merged.filter(e => e.kind === 'i').map(e => [e.t, e.d]);
|
|
285
|
+
|
|
286
|
+
steps.push({
|
|
287
|
+
cmd: step.cmd,
|
|
288
|
+
output: outClamped,
|
|
289
|
+
exit,
|
|
290
|
+
duration: round3(duration),
|
|
291
|
+
...(step.note ? { note: step.note } : {}),
|
|
292
|
+
...(step.hide ? { hidden: true } : {}),
|
|
293
|
+
...(step.type_speed ? { typeSpeed: step.type_speed } : {}),
|
|
294
|
+
...(inClamped.length ? { input: inClamped } : {}),
|
|
295
|
+
});
|
|
296
|
+
log(` ✓ ${step.cmd} (exit ${exit}, ${duration.toFixed(2)}s)`);
|
|
297
|
+
if (exit !== 0 && !allowFail) {
|
|
298
|
+
throw new Error(`step ${idx + 1} exited ${exit}: ${step.cmd} (use --allow-fail to record failures as content)`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return steps;
|
|
302
|
+
} finally {
|
|
303
|
+
try { term.write('exit\n'); } catch { /* already dead */ }
|
|
304
|
+
await new Promise(res => { if (shellDead) return res(); onExitCb = res; setTimeout(res, 1500); });
|
|
305
|
+
try { term.kill(); } catch { /* already dead */ }
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function round3(n) { return Math.round(n * 1000) / 1000; }
|
|
310
|
+
|
|
311
|
+
function normalizeTail(chunks) {
|
|
312
|
+
// Strip one trailing "\r\n" (or "\n") that precedes the sentinel line.
|
|
313
|
+
for (let i = chunks.length - 1; i >= 0; i--) {
|
|
314
|
+
const [t, d] = chunks[i];
|
|
315
|
+
if (d === '') { chunks.splice(i, 1); continue; }
|
|
316
|
+
const trimmed = d.replace(/\r?\n$/, '');
|
|
317
|
+
if (trimmed === '') chunks.splice(i, 1); else chunks[i] = [t, trimmed];
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
return chunks;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Literal (non-$ENV) secrets must not leak through the embedded script.
|
|
324
|
+
* $ENV secrets survive verbatim — they resolve at run time, so --refresh
|
|
325
|
+
* keeps working; a scrubbed literal cannot be replayed (documented). */
|
|
326
|
+
function scrubScript(script) {
|
|
327
|
+
const copy = JSON.parse(JSON.stringify(script));
|
|
328
|
+
for (const st of copy.steps || []) {
|
|
329
|
+
for (const it of st.interact || []) {
|
|
330
|
+
if (it.send && typeof it.send === 'object' && 'secret' in it.send) {
|
|
331
|
+
const v = String(it.send.secret);
|
|
332
|
+
const m = v.match(/^\$([A-Za-z_][A-Za-z0-9_]*)/);
|
|
333
|
+
if (!m) it.send.secret = '▓▓▓';
|
|
334
|
+
// …and a $NAME secret whose value was inlined in the script's own env
|
|
335
|
+
// block must not ride along either.
|
|
336
|
+
else if (copy.env && m[1] in copy.env) copy.env[m[1]] = '▓▓▓';
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return copy;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function buildCast(script, steps, shellName) {
|
|
344
|
+
return {
|
|
345
|
+
decklightCast: 1,
|
|
346
|
+
meta: {
|
|
347
|
+
shell: shellName,
|
|
348
|
+
cols: script.cols ?? 100,
|
|
349
|
+
rows: script.rows ?? 28,
|
|
350
|
+
recorded: new Date().toISOString(),
|
|
351
|
+
prompt: script.prompt ?? '$ ',
|
|
352
|
+
},
|
|
353
|
+
script: scrubScript(script),
|
|
354
|
+
steps,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ------------------------------------------------------------------- export
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Flatten a decklight cast into asciicast v2 (NDJSON) for the asciinema
|
|
362
|
+
* ecosystem (asciinema play/upload, agg → GIF, asciinema-player embeds).
|
|
363
|
+
* The prompt and typed command are injected as output events (echo was off
|
|
364
|
+
* during capture); step boundaries become `m` marker events; hidden steps
|
|
365
|
+
* are omitted and sleep steps become pure time gaps.
|
|
366
|
+
*/
|
|
367
|
+
export function exportAsciicast(cast) {
|
|
368
|
+
const lines = [];
|
|
369
|
+
const recorded = Date.parse(cast.meta?.recorded ?? '');
|
|
370
|
+
lines.push(JSON.stringify({
|
|
371
|
+
version: 2,
|
|
372
|
+
width: cast.meta?.cols ?? 100,
|
|
373
|
+
height: cast.meta?.rows ?? 28,
|
|
374
|
+
...(Number.isFinite(recorded) ? { timestamp: Math.floor(recorded / 1000) } : {}),
|
|
375
|
+
env: { SHELL: cast.meta?.shell ?? 'sh', TERM: 'xterm-256color' },
|
|
376
|
+
}));
|
|
377
|
+
let t = 0;
|
|
378
|
+
const ev = (kind, data) => lines.push(JSON.stringify([round3(t), kind, data]));
|
|
379
|
+
const prompt = cast.meta?.prompt ?? '$ ';
|
|
380
|
+
for (const step of cast.steps) {
|
|
381
|
+
if (step.hidden) continue;
|
|
382
|
+
if (step.sleep != null) { t += step.sleep; continue; }
|
|
383
|
+
ev('m', step.cmd);
|
|
384
|
+
ev('o', prompt);
|
|
385
|
+
for (const ch of step.cmd) { t += 0.045; ev('o', ch); } // deterministic typing
|
|
386
|
+
t += 0.12;
|
|
387
|
+
ev('o', '\r\n');
|
|
388
|
+
const events = [
|
|
389
|
+
...(step.output || []).map(([rt, d]) => [rt, d]),
|
|
390
|
+
...(step.input || []).map(([rt, d]) => [rt, d.replace(/\r?\n$/, '')]),
|
|
391
|
+
].sort((a, b) => a[0] - b[0]);
|
|
392
|
+
const base = t;
|
|
393
|
+
for (const [rt, d] of events) { t = base + rt; ev('o', d); }
|
|
394
|
+
ev('o', '\r\n');
|
|
395
|
+
t += 0.5; // breathing room between steps
|
|
396
|
+
}
|
|
397
|
+
return lines.join('\n') + '\n';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ------------------------------------------------------------------ refresh
|
|
401
|
+
|
|
402
|
+
function castOutputSignature(steps) {
|
|
403
|
+
return JSON.stringify(steps.map(s => s.sleep != null
|
|
404
|
+
? ['(sleep)', s.sleep]
|
|
405
|
+
: [s.cmd, s.exit, (s.output || []).map(([, d]) => d).join(''), (s.input || []).map(([, d]) => d).join('')]));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function refresh(targets, { allowFail }) {
|
|
409
|
+
const files = [];
|
|
410
|
+
for (const t of targets) {
|
|
411
|
+
const st = fs.statSync(t, { throwIfNoEntry: false });
|
|
412
|
+
if (!st) fail(`no such file or directory: ${t}`);
|
|
413
|
+
if (st.isDirectory()) {
|
|
414
|
+
for (const f of fs.readdirSync(t, { recursive: true })) {
|
|
415
|
+
if (String(f).endsWith('.cast.json')) files.push(path.join(t, String(f)));
|
|
416
|
+
}
|
|
417
|
+
} else files.push(t);
|
|
418
|
+
}
|
|
419
|
+
if (!files.length) fail('no .cast.json files found');
|
|
420
|
+
|
|
421
|
+
let changed = 0;
|
|
422
|
+
const drifted = [];
|
|
423
|
+
for (const file of files) {
|
|
424
|
+
const cast = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
425
|
+
if (cast.decklightCast !== 1 || !cast.script) { console.log(` – ${file}: not a decklight cast with an embedded script, skipped`); continue; }
|
|
426
|
+
process.stdout.write(`re-running ${path.basename(file)}…\n`);
|
|
427
|
+
const steps = await recordScript(cast.script, {
|
|
428
|
+
scriptDir: path.dirname(file), allowFail,
|
|
429
|
+
log: m => process.stdout.write(m + '\n'),
|
|
430
|
+
});
|
|
431
|
+
if (castOutputSignature(steps) !== castOutputSignature(cast.steps)) {
|
|
432
|
+
const next = buildCast(cast.script, steps, cast.meta.shell);
|
|
433
|
+
fs.writeFileSync(file, JSON.stringify(next, null, 1) + '\n');
|
|
434
|
+
changed += 1; drifted.push(path.basename(file));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
console.log(`\nre-ran ${files.length} script${files.length === 1 ? '' : 's'}, ${changed} cast${changed === 1 ? '' : 's'} changed${changed ? ':' : '.'}`);
|
|
438
|
+
for (const d of drifted) console.log(` ${d} (output drift)`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// --------------------------------------------------------------------- main
|
|
442
|
+
|
|
443
|
+
const HELP = `decklight rec — record terminal casts for Decklight decks
|
|
444
|
+
|
|
445
|
+
Usage:
|
|
446
|
+
decklight rec <script.term.yaml> [-o out.cast.json] [--allow-fail] [--quiet]
|
|
447
|
+
decklight refresh <dir | cast.json…> [--allow-fail]
|
|
448
|
+
decklight export <cast.json> [-o out.cast] # asciicast v2
|
|
449
|
+
|
|
450
|
+
Script format (YAML):
|
|
451
|
+
shell: zsh # default: $SHELL (rc files are skipped: zsh -f / bash --norc)
|
|
452
|
+
cwd: ~/demo # default: the script's directory
|
|
453
|
+
cols: 100 # PTY size (default 100x28)
|
|
454
|
+
rows: 28
|
|
455
|
+
env: { KEY: val } # extra environment
|
|
456
|
+
prompt: "$ " # cosmetic prompt shown by the player
|
|
457
|
+
redact: ["sk-.+"] # regexes scrubbed from captured output (▓▓▓)
|
|
458
|
+
max_idle: 2.0 # clamp recorded pauses to this many seconds (default 2.0)
|
|
459
|
+
steps:
|
|
460
|
+
- cmd: export STAGE=demo
|
|
461
|
+
hide: true # runs in the session, never shown in playback
|
|
462
|
+
- cmd: npx wrangler deploy
|
|
463
|
+
timeout: 120 # seconds (default 60)
|
|
464
|
+
wait_for: "Deployed" # fail fast unless output matches before exit
|
|
465
|
+
note: optional label for the speaker view
|
|
466
|
+
type_speed: 2 # playback typing-speed multiplier for this step
|
|
467
|
+
- sleep: 1.5 # pure pause (really sleeps; timing marker in play mode)
|
|
468
|
+
- cmd: myapp login
|
|
469
|
+
interact: # answer interactive prompts (expect/send)
|
|
470
|
+
- expect: "Email: "
|
|
471
|
+
send: "demo@example.com\\n"
|
|
472
|
+
- expect: "Password: "
|
|
473
|
+
send: { secret: "$APP_PASSWORD\\n" } # sent for real, recorded as ▓▓▓
|
|
474
|
+
|
|
475
|
+
Notes:
|
|
476
|
+
• Commands run sequentially in ONE shell session; state (cd, vars) carries over.
|
|
477
|
+
• Non-zero exits abort unless --allow-fail; timeouts and shell death always abort.
|
|
478
|
+
• Redaction applies to captured output; secret sends auto-redact their value in
|
|
479
|
+
that step's output and are stored as ▓▓▓ in the cast's input record. Prefer the
|
|
480
|
+
$ENV form — a literal secret is scrubbed from the embedded script, which makes
|
|
481
|
+
that cast non-refreshable.
|
|
482
|
+
• --refresh re-executes the script embedded in each cast and rewrites on drift.
|
|
483
|
+
• export writes asciicast v2 for the asciinema ecosystem — e.g. a GIF for docs:
|
|
484
|
+
decklight export demo.cast.json && agg demo.cast demo.gif
|
|
485
|
+
`;
|
|
486
|
+
|
|
487
|
+
export async function recMain(argv = process.argv.slice(2)) {
|
|
488
|
+
if (!argv.length || argv.includes('--help') || argv.includes('-h')) { console.log(HELP); return; }
|
|
489
|
+
const allowFail = argv.includes('--allow-fail');
|
|
490
|
+
const quiet = argv.includes('--quiet');
|
|
491
|
+
const rest = argv.filter(a => !['--allow-fail', '--quiet'].includes(a));
|
|
492
|
+
|
|
493
|
+
if (rest[0] === '--refresh' || rest[0] === 'refresh') {
|
|
494
|
+
if (rest.length < 2) fail('refresh needs a directory or cast files');
|
|
495
|
+
await refresh(rest.slice(1), { allowFail });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (rest[0] === 'export') {
|
|
500
|
+
const src = rest[1];
|
|
501
|
+
if (!src || !fs.existsSync(src)) fail(`cast not found: ${src}`);
|
|
502
|
+
const cast = JSON.parse(fs.readFileSync(src, 'utf8'));
|
|
503
|
+
if (cast.decklightCast !== 1) fail(`not a decklight cast: ${src}`);
|
|
504
|
+
const eIdx = rest.indexOf('-o');
|
|
505
|
+
const out = eIdx !== -1 && rest[eIdx + 1]
|
|
506
|
+
? rest[eIdx + 1]
|
|
507
|
+
: src.replace(/\.cast\.json$/, '') + '.cast';
|
|
508
|
+
fs.writeFileSync(out, exportAsciicast(cast));
|
|
509
|
+
if (!quiet) console.log(`wrote ${out} (asciicast v2 — try: agg ${out} ${out.replace(/\.cast$/, '')}.gif)`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const { yaml } = loadDeps();
|
|
514
|
+
const scriptPath = rest[0];
|
|
515
|
+
if (!scriptPath || !fs.existsSync(scriptPath)) fail(`script not found: ${scriptPath}`);
|
|
516
|
+
const oIdx = rest.indexOf('-o');
|
|
517
|
+
const outPath = oIdx !== -1 && rest[oIdx + 1]
|
|
518
|
+
? rest[oIdx + 1]
|
|
519
|
+
: scriptPath.replace(/\.term\.ya?ml$/, '').replace(/\.ya?ml$/, '') + '.cast.json';
|
|
520
|
+
|
|
521
|
+
const script = yaml.load(fs.readFileSync(scriptPath, 'utf8'));
|
|
522
|
+
if (!script || !Array.isArray(script.steps) || !script.steps.length) fail('script has no steps');
|
|
523
|
+
|
|
524
|
+
const shellName = script.shell || path.basename(process.env.SHELL || 'zsh');
|
|
525
|
+
if (!quiet) console.log(`recording ${script.steps.length} step${script.steps.length === 1 ? '' : 's'} in ${shellName}…`);
|
|
526
|
+
const steps = await recordScript(script, {
|
|
527
|
+
scriptDir: path.dirname(path.resolve(scriptPath)),
|
|
528
|
+
allowFail,
|
|
529
|
+
log: m => { if (!quiet) console.log(m); },
|
|
530
|
+
});
|
|
531
|
+
fs.writeFileSync(outPath, JSON.stringify(buildCast(script, steps, shellName), null, 1) + '\n');
|
|
532
|
+
if (!quiet) console.log(`wrote ${outPath}`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Import-safe: only run the CLI when executed directly (recordScript and
|
|
536
|
+
// exportAsciicast are importable for tests/tooling).
|
|
537
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
538
|
+
if (isMain) recMain().catch(e => fail(e.message));
|