termcut 0.2.0 → 0.3.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/src/publish.ts ADDED
@@ -0,0 +1,226 @@
1
+ import { chmod, mkdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { readCast } from "./cast";
5
+ import { applyOverrides, resolveConfig } from "./config";
6
+ import { buildHtml } from "./export/html";
7
+
8
+ /** Any S3-compatible target: RustFS, MinIO, Cloudflare R2, AWS S3, … */
9
+ export interface PublishConfig {
10
+ /** e.g. https://s3.amanv.cloud */
11
+ endpoint: string;
12
+ bucket: string;
13
+ accessKeyId: string;
14
+ secretAccessKey: string;
15
+ /** Default "us-east-1" (what MinIO/RustFS expect). */
16
+ region?: string;
17
+ /** Base URL objects are served from. Default `${endpoint}/${bucket}` (path-style). */
18
+ publicUrl?: string;
19
+ /** Key prefix inside the bucket, e.g. "casts/". */
20
+ prefix?: string;
21
+ }
22
+
23
+ export const configDir = (): string => process.env.TCUT_CONFIG_DIR ?? path.join(homedir(), ".config", "tcut");
24
+ export const configPath = (): string => path.join(configDir(), "publish.json");
25
+
26
+ /** Env (TCUT_S3_*) overrides the config file; either source may provide each field. */
27
+ export async function loadPublishConfig(): Promise<PublishConfig | null> {
28
+ let fromFile: Partial<PublishConfig> = {};
29
+ const file = Bun.file(configPath());
30
+ if (await file.exists()) {
31
+ try {
32
+ fromFile = (await file.json()) as Partial<PublishConfig>;
33
+ } catch {
34
+ throw new Error(`${configPath()} is not valid JSON`);
35
+ }
36
+ }
37
+ const env = process.env;
38
+ const merged: Partial<PublishConfig> = {
39
+ ...fromFile,
40
+ ...(env.TCUT_S3_ENDPOINT && { endpoint: env.TCUT_S3_ENDPOINT }),
41
+ ...(env.TCUT_S3_BUCKET && { bucket: env.TCUT_S3_BUCKET }),
42
+ ...(env.TCUT_S3_ACCESS_KEY && { accessKeyId: env.TCUT_S3_ACCESS_KEY }),
43
+ ...(env.TCUT_S3_SECRET_KEY && { secretAccessKey: env.TCUT_S3_SECRET_KEY }),
44
+ ...(env.TCUT_S3_REGION && { region: env.TCUT_S3_REGION }),
45
+ ...(env.TCUT_PUBLIC_URL && { publicUrl: env.TCUT_PUBLIC_URL }),
46
+ ...(env.TCUT_S3_PREFIX !== undefined && { prefix: env.TCUT_S3_PREFIX }),
47
+ };
48
+ if (!merged.endpoint || !merged.bucket || !merged.accessKeyId || !merged.secretAccessKey) return null;
49
+ return merged as PublishConfig;
50
+ }
51
+
52
+ export async function savePublishConfig(cfg: PublishConfig): Promise<string> {
53
+ await mkdir(configDir(), { recursive: true });
54
+ await Bun.write(configPath(), JSON.stringify(cfg, null, 2) + "\n");
55
+ await chmod(configPath(), 0o600);
56
+ return configPath();
57
+ }
58
+
59
+ export function publicUrlFor(cfg: PublishConfig, key: string): string {
60
+ const base = (cfg.publicUrl ?? `${cfg.endpoint.replace(/\/$/, "")}/${cfg.bucket}`).replace(/\/$/, "");
61
+ return `${base}/${key.split("/").map(encodeURIComponent).join("/")}`;
62
+ }
63
+
64
+ const MIME: Record<string, string> = {
65
+ ".mp4": "video/mp4",
66
+ ".webm": "video/webm",
67
+ ".gif": "image/gif",
68
+ ".webp": "image/webp",
69
+ ".png": "image/png",
70
+ ".jpg": "image/jpeg",
71
+ ".jpeg": "image/jpeg",
72
+ ".svg": "image/svg+xml",
73
+ ".html": "text/html; charset=utf-8",
74
+ ".cast": "application/x-asciicast",
75
+ ".txt": "text/plain; charset=utf-8",
76
+ };
77
+
78
+ export async function contentHash(file: string): Promise<string> {
79
+ const hasher = new Bun.CryptoHasher("sha256");
80
+ hasher.update(await Bun.file(file).arrayBuffer());
81
+ return hasher.digest("hex");
82
+ }
83
+
84
+ /** Keys are content-addressed: publishing the same bytes twice yields the same URL. */
85
+ export async function keyFor(cfg: PublishConfig, file: string, name?: string): Promise<string> {
86
+ const hash = (await contentHash(file)).slice(0, 12);
87
+ return `${cfg.prefix ?? ""}${hash}/${name ?? path.basename(file)}`;
88
+ }
89
+
90
+ function client(cfg: PublishConfig): Bun.S3Client {
91
+ return new Bun.S3Client({
92
+ endpoint: cfg.endpoint,
93
+ bucket: cfg.bucket,
94
+ region: cfg.region ?? "us-east-1",
95
+ accessKeyId: cfg.accessKeyId,
96
+ secretAccessKey: cfg.secretAccessKey,
97
+ });
98
+ }
99
+
100
+ export interface Published {
101
+ file: string;
102
+ key: string;
103
+ url: string;
104
+ }
105
+
106
+ export interface PublishOptions {
107
+ /** Override the object name (defaults to the file's basename). */
108
+ name?: string;
109
+ log?: (message: string) => void;
110
+ }
111
+
112
+ async function upload(cfg: PublishConfig, file: string, key: string): Promise<void> {
113
+ const s3 = client(cfg);
114
+ const type = MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
115
+ try {
116
+ await s3.write(key, Bun.file(file), { type, acl: "public-read" });
117
+ } catch (err) {
118
+ // Some S3 clones reject ACL headers; public access then comes from the bucket policy (see ensurePublicBucket).
119
+ if (!/acl|NotImplemented|InvalidArgument/i.test(String(err))) throw err;
120
+ await s3.write(key, Bun.file(file), { type });
121
+ }
122
+ }
123
+
124
+ /** Upload files and return their public URLs. A `.cast` is also rendered to a playable `.html` next to it. */
125
+ export async function publishFiles(files: string[], cfg: PublishConfig, opts: PublishOptions = {}): Promise<Published[]> {
126
+ const results: Published[] = [];
127
+ for (const file of files) {
128
+ if (!(await Bun.file(file).exists())) throw new Error(`File not found: ${file}`);
129
+ const key = await keyFor(cfg, file, opts.name);
130
+ await upload(cfg, file, key);
131
+ results.push({ file, key, url: publicUrlFor(cfg, key) });
132
+
133
+ if (file.endsWith(".cast")) {
134
+ const rec = await readCast(file);
135
+ const base = rec.header.bunVideo ?? resolveConfig({ output: "x.html", cols: rec.header.width, rows: rec.header.height });
136
+ const html = await buildHtml(rec, applyOverrides(base, {}));
137
+ const htmlFile = path.join(path.dirname(file), `${path.basename(file, ".cast")}.html`);
138
+ await Bun.write(htmlFile, html);
139
+ const htmlKey = `${key.replace(/\/[^/]+$/, "")}/${path.basename(htmlFile)}`;
140
+ await upload(cfg, htmlFile, htmlKey);
141
+ results.push({ file: htmlFile, key: htmlKey, url: publicUrlFor(cfg, htmlKey) });
142
+ }
143
+ }
144
+ return results;
145
+ }
146
+
147
+ // ---- Bucket bootstrap (create + public-read policy) with a minimal SigV4 signer; Bun.S3Client has no admin calls.
148
+
149
+ function hmac(key: Uint8Array | string, data: string): Uint8Array {
150
+ return new Uint8Array(new Bun.CryptoHasher("sha256", key).update(data).digest());
151
+ }
152
+ const sha256Hex = (data: string | Uint8Array) => new Bun.CryptoHasher("sha256").update(data).digest("hex");
153
+
154
+ export function signV4(cfg: PublishConfig, method: string, url: URL, body: string, now = new Date()): Record<string, string> {
155
+ const region = cfg.region ?? "us-east-1";
156
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
157
+ const date = amzDate.slice(0, 8);
158
+ const payloadHash = sha256Hex(body);
159
+ const headers: Record<string, string> = {
160
+ host: url.host,
161
+ "x-amz-content-sha256": payloadHash,
162
+ "x-amz-date": amzDate,
163
+ };
164
+ const signedHeaders = Object.keys(headers).sort().join(";");
165
+ const canonicalHeaders = Object.keys(headers)
166
+ .sort()
167
+ .map((h) => `${h}:${headers[h]!.trim()}\n`)
168
+ .join("");
169
+ const canonicalQuery = [...url.searchParams.entries()]
170
+ .sort(([a], [b]) => (a < b ? -1 : 1))
171
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
172
+ .join("&");
173
+ const canonicalRequest = [method, url.pathname || "/", canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n");
174
+ const scope = `${date}/${region}/s3/aws4_request`;
175
+ const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256Hex(canonicalRequest)].join("\n");
176
+ const kDate = hmac(`AWS4${cfg.secretAccessKey}`, date);
177
+ const kRegion = hmac(kDate, region);
178
+ const kService = hmac(kRegion, "s3");
179
+ const kSigning = hmac(kService, "aws4_request");
180
+ const signature = Buffer.from(hmac(kSigning, stringToSign)).toString("hex");
181
+ return {
182
+ ...headers,
183
+ authorization: `AWS4-HMAC-SHA256 Credential=${cfg.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
184
+ };
185
+ }
186
+
187
+ async function s3Request(cfg: PublishConfig, method: string, pathname: string, query = "", body = ""): Promise<Response> {
188
+ const url = new URL(`${cfg.endpoint.replace(/\/$/, "")}${pathname}${query}`);
189
+ const headers = signV4(cfg, method, url, body);
190
+ return fetch(url, { method, headers, body: body || undefined });
191
+ }
192
+
193
+ export interface BootstrapResult {
194
+ bucketCreated: boolean;
195
+ policyApplied: boolean;
196
+ publicReadOk: boolean;
197
+ }
198
+
199
+ /** Make sure the bucket exists and is publicly readable, then prove it with an anonymous GET. */
200
+ export async function ensurePublicBucket(cfg: PublishConfig, log: (m: string) => void = () => {}): Promise<BootstrapResult> {
201
+ let bucketCreated = false;
202
+ const head = await s3Request(cfg, "HEAD", `/${cfg.bucket}`);
203
+ if (head.status === 404) {
204
+ const create = await s3Request(cfg, "PUT", `/${cfg.bucket}`);
205
+ if (!create.ok && create.status !== 409) throw new Error(`Creating bucket ${cfg.bucket} failed: ${create.status} ${await create.text()}`);
206
+ bucketCreated = create.ok;
207
+ log(`created bucket ${cfg.bucket}`);
208
+ } else if (head.status === 403) {
209
+ throw new Error(`Access denied to bucket ${cfg.bucket} — check the access key / secret.`);
210
+ }
211
+
212
+ const policy = JSON.stringify({
213
+ Version: "2012-10-17",
214
+ Statement: [{ Effect: "Allow", Principal: { AWS: ["*"] }, Action: ["s3:GetObject"], Resource: [`arn:aws:s3:::${cfg.bucket}/*`] }],
215
+ });
216
+ const put = await s3Request(cfg, "PUT", `/${cfg.bucket}`, "?policy", policy);
217
+ const policyApplied = put.ok;
218
+ if (!put.ok) log(`could not set a public-read bucket policy (${put.status}); objects may need a public URL configured differently`);
219
+
220
+ const probeKey = `${cfg.prefix ?? ""}.tcut-probe.txt`;
221
+ await client(cfg).write(probeKey, "tcut publish probe", { type: "text/plain" });
222
+ const anon = await fetch(publicUrlFor(cfg, probeKey));
223
+ const publicReadOk = anon.ok && (await anon.text()).includes("tcut publish probe");
224
+ await client(cfg).delete(probeKey).catch(() => undefined);
225
+ return { bucketCreated, policyApplied, publicReadOk };
226
+ }
package/src/recorder.ts CHANGED
@@ -327,7 +327,10 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
327
327
  try {
328
328
  // Everything that happens before the first prompt is stamped at t=0.
329
329
  log(`starting ${Array.isArray(config.shell) ? config.shell.join(" ") : config.shell}`);
330
- await waitFor(`initial prompt ${promptPattern}`, () => promptPattern.test(screen.line()), config.waitTimeout);
330
+ // Named shells get a known prompt; for an arbitrary command there is nothing to wait for — start at once.
331
+ if (!Array.isArray(config.shell)) {
332
+ await waitFor(`initial prompt ${promptPattern}`, () => promptPattern.test(screen.line()), config.waitTimeout);
333
+ }
331
334
  startedAt = performance.now();
332
335
  log("recording");
333
336
  await script(session);
@@ -0,0 +1,230 @@
1
+ import path from "node:path";
2
+ import type { Recording } from "./types";
3
+
4
+ export interface ScriptGenOptions {
5
+ /** Output paths to put in the generated config. */
6
+ output: string[];
7
+ /** True when the recording drove the clean shell (so "text + Enter" can become `run()`). */
8
+ cleanShell: boolean;
9
+ /** The command that was recorded in `-- command` mode (becomes `shell: [...]`). */
10
+ command?: string[];
11
+ /** Gaps between keystrokes longer than this become `sleep()` calls. Default 400 ms. */
12
+ pauseThresholdMs?: number;
13
+ /** Where the cast lives, for the header comment. */
14
+ castPath?: string;
15
+ }
16
+
17
+ type Op =
18
+ | { kind: "type"; text: string }
19
+ | { kind: "run"; command: string }
20
+ | { kind: "key"; name: string; times: number }
21
+ | { kind: "ctrl"; letter: string; times: number }
22
+ | { kind: "alt"; key: string; times: number }
23
+ | { kind: "raw"; data: string }
24
+ | { kind: "sleep"; ms: number };
25
+
26
+ const NAMED: Record<string, string> = {
27
+ "\r": "enter",
28
+ "\n": "enter",
29
+ "\t": "tab",
30
+ "\x7f": "backspace",
31
+ "\x1b": "escape",
32
+ "\x1b[A": "up",
33
+ "\x1b[B": "down",
34
+ "\x1b[C": "right",
35
+ "\x1b[D": "left",
36
+ "\x1bOA": "up",
37
+ "\x1bOB": "down",
38
+ "\x1bOC": "right",
39
+ "\x1bOD": "left",
40
+ "\x1b[H": "home",
41
+ "\x1b[F": "end",
42
+ "\x1b[1~": "home",
43
+ "\x1b[4~": "end",
44
+ "\x1b[3~": "delete",
45
+ "\x1b[5~": "pageUp",
46
+ "\x1b[6~": "pageDown",
47
+ };
48
+
49
+ /** Split a raw input chunk into individual key tokens (escape sequences, control chars, printable runs). */
50
+ export function tokenize(input: string): string[] {
51
+ const tokens: string[] = [];
52
+ let i = 0;
53
+ while (i < input.length) {
54
+ const ch = input[i]!;
55
+ if (ch === "\x1b") {
56
+ // CSI: ESC [ params final | SS3: ESC O x | Alt+key: ESC x
57
+ const csi = /^\x1b\[[0-9;?]*[A-Za-z~]/.exec(input.slice(i));
58
+ const ss3 = /^\x1bO[A-Za-z]/.exec(input.slice(i));
59
+ if (csi) {
60
+ tokens.push(csi[0]);
61
+ i += csi[0].length;
62
+ } else if (ss3) {
63
+ tokens.push(ss3[0]);
64
+ i += ss3[0].length;
65
+ } else if (i + 1 < input.length) {
66
+ tokens.push(input.slice(i, i + 2));
67
+ i += 2;
68
+ } else {
69
+ tokens.push(ch);
70
+ i += 1;
71
+ }
72
+ continue;
73
+ }
74
+ if (ch < " " || ch === "\x7f") {
75
+ tokens.push(ch);
76
+ i += 1;
77
+ continue;
78
+ }
79
+ let j = i;
80
+ while (j < input.length && input[j]! >= " " && input[j] !== "\x7f") j++;
81
+ tokens.push(input.slice(i, j));
82
+ i = j;
83
+ }
84
+ return tokens;
85
+ }
86
+
87
+ function roundMs(ms: number): number {
88
+ if (ms < 1000) return Math.round(ms / 100) * 100;
89
+ return Math.round(ms / 250) * 250;
90
+ }
91
+
92
+ function formatMs(ms: number): string {
93
+ return ms % 1000 === 0 ? `"${ms / 1000}s"` : ms >= 1000 ? `"${(ms / 1000).toFixed(2).replace(/0+$/, "")}s"` : `"${ms}ms"`;
94
+ }
95
+
96
+ const q = (s: string) => JSON.stringify(s);
97
+
98
+ /** Turn the `i` (input) events of a recording into a list of script operations. */
99
+ export function eventsToOps(rec: Recording, opts: ScriptGenOptions): Op[] {
100
+ const threshold = opts.pauseThresholdMs ?? 400;
101
+ const ops: Op[] = [];
102
+ let pendingText = "";
103
+ let lastTime: number | null = null;
104
+
105
+ const flushText = () => {
106
+ if (pendingText) ops.push({ kind: "type", text: pendingText });
107
+ pendingText = "";
108
+ };
109
+ const pushKey = (op: Op) => {
110
+ const last = ops[ops.length - 1];
111
+ if (last && last.kind === op.kind && op.kind !== "type" && op.kind !== "sleep" && op.kind !== "raw" && op.kind !== "run") {
112
+ const a = last as { name?: string; letter?: string; key?: string; times: number };
113
+ const b = op as { name?: string; letter?: string; key?: string; times: number };
114
+ if (a.name === b.name && a.letter === b.letter && a.key === b.key) {
115
+ a.times += b.times;
116
+ return;
117
+ }
118
+ }
119
+ ops.push(op);
120
+ };
121
+
122
+ for (const [time, type, data] of rec.events) {
123
+ if (type !== "i") continue;
124
+ if (lastTime !== null) {
125
+ const gap = (time - lastTime) * 1000;
126
+ if (gap > threshold) {
127
+ flushText();
128
+ ops.push({ kind: "sleep", ms: roundMs(gap) });
129
+ }
130
+ }
131
+ lastTime = time;
132
+
133
+ for (const token of tokenize(data)) {
134
+ if (token.length > 1 && token[0]! >= " ") {
135
+ pendingText += token;
136
+ continue;
137
+ }
138
+ if (token.length === 1 && token >= " " && token !== "\x7f") {
139
+ pendingText += token;
140
+ continue;
141
+ }
142
+ const named = NAMED[token];
143
+ if (named === "enter") {
144
+ if (opts.cleanShell && pendingText.trim()) {
145
+ const command = pendingText;
146
+ pendingText = "";
147
+ ops.push({ kind: "run", command });
148
+ } else {
149
+ flushText();
150
+ pushKey({ kind: "key", name: "enter", times: 1 });
151
+ }
152
+ continue;
153
+ }
154
+ flushText();
155
+ if (named) {
156
+ pushKey({ kind: "key", name: named, times: 1 });
157
+ } else if (token.length === 1 && token.charCodeAt(0) < 32) {
158
+ const letter = String.fromCharCode(token.charCodeAt(0) + 96);
159
+ pushKey({ kind: "ctrl", letter, times: 1 });
160
+ } else if (token.length === 2 && token[0] === "\x1b") {
161
+ pushKey({ kind: "alt", key: token[1]!, times: 1 });
162
+ } else {
163
+ ops.push({ kind: "raw", data: token });
164
+ }
165
+ }
166
+ }
167
+ flushText();
168
+
169
+ // Drop a trailing `run("exit")` from clean-shell sessions: the recorder ends the shell itself.
170
+ const last = ops[ops.length - 1];
171
+ if (opts.cleanShell && last?.kind === "run" && /^\s*exit\s*$/.test(last.command)) ops.pop();
172
+ while (ops.length && ops[ops.length - 1]!.kind === "sleep") ops.pop();
173
+ return ops;
174
+ }
175
+
176
+ function opToLine(op: Op): string {
177
+ switch (op.kind) {
178
+ case "type":
179
+ return `await t.type(${q(op.text)});`;
180
+ case "run":
181
+ return `await t.run(${q(op.command)});`;
182
+ case "key":
183
+ return op.times > 1 ? `await t.${op.name}(${op.times});` : `await t.${op.name}();`;
184
+ case "ctrl":
185
+ return op.times > 1 ? `await t.ctrl(${q(op.letter)}, ${op.times});` : `await t.ctrl(${q(op.letter)});`;
186
+ case "alt":
187
+ return op.times > 1 ? `await t.alt(${q(op.key)}, ${op.times});` : `await t.alt(${q(op.key)});`;
188
+ case "raw":
189
+ return `await t.raw(${q(op.data)});`;
190
+ case "sleep":
191
+ return `await t.sleep(${formatMs(op.ms)});`;
192
+ }
193
+ }
194
+
195
+ /** Generate an editable TypeScript script that replays the input side of a recording. */
196
+ export function generateScript(rec: Recording, opts: ScriptGenOptions): string {
197
+ const ops = eventsToOps(rec, opts);
198
+ const cfg = rec.header.bunVideo;
199
+ const config: string[] = [`output: ${JSON.stringify(opts.output)}`];
200
+ if (opts.command) config.push(`shell: ${JSON.stringify(opts.command)}`);
201
+ else if (cfg && cfg.shell !== "bash") config.push(`shell: ${JSON.stringify(cfg.shell)}`);
202
+ config.push(`cols: ${rec.header.width}`, `rows: ${rec.header.height}`);
203
+ if (cfg) {
204
+ if (cfg.theme?.name) config.push(`theme: ${q(cfg.theme.name)}`);
205
+ if (cfg.fps !== 60) config.push(`fps: ${cfg.fps}`);
206
+ if (cfg.windowBar !== "none") config.push(`windowBar: ${q(cfg.windowBar)}`);
207
+ if (cfg.title) config.push(`title: ${q(cfg.title)}`);
208
+ }
209
+
210
+ const body = ops.length ? ops.map((op) => ` ${opToLine(op)}`).join("\n") : " // (no input was recorded)";
211
+ const castNote = opts.castPath ? ` The exact recording is in ${path.basename(opts.castPath)}.` : "";
212
+ const modeNote = opts.command
213
+ ? "It runs the same command and replays your keys; waits are the pauses you took, so adjust them if the program is slower elsewhere."
214
+ : "Typed commands became run(), which waits for the prompt instead of guessing.";
215
+
216
+ return `import { defineVideo } from "tcut";
217
+
218
+ // Generated by \`tcut rec\` from what you typed — edit freely, then re-run with \`tcut <this file>\`.
219
+ // ${modeNote}${castNote}
220
+ export default defineVideo(
221
+ {
222
+ ${config.map((c) => ` ${c},`).join("\n")}
223
+ },
224
+ async (t) => {
225
+ ${body}
226
+ await t.sleep("1s");
227
+ },
228
+ );
229
+ `;
230
+ }