termcut 0.2.2 → 0.4.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
@@ -1,6 +1,6 @@
1
1
  import { MARKER } from "./cast";
2
2
  import { formatMs, toMs } from "./duration";
3
- import { altSequence, ctrlSequence, keySequence } from "./keys";
3
+ import { altSequence, ctrlSequence, keySequence, shiftSequence, wheelSequence } from "./keys";
4
4
  import { Screen } from "./screen";
5
5
  import type {
6
6
  CastEvent,
@@ -191,6 +191,19 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
191
191
 
192
192
  const key = (name: KeyName, times = 1): Promise<void> => pressKey(keySequence(name), times);
193
193
 
194
+ const scroll = async (direction: "up" | "down", times: number): Promise<void> => {
195
+ await screen.settle();
196
+ if (screen.mouseTracking() === 0) {
197
+ log(`scroll${direction === "up" ? "Up" : "Down"}: the program has not enabled mouse tracking, so there is nothing to scroll — skipped`);
198
+ return;
199
+ }
200
+ const { x, y } = screen.cursor();
201
+ for (let i = 0; i < times; i++) {
202
+ await raw(wheelSequence(direction, x + 1, y + 1));
203
+ if (!fast && times > 1) await Bun.sleep(40);
204
+ }
205
+ };
206
+
194
207
  const matches = (pattern: RegExp, scope: "line" | "screen"): boolean =>
195
208
  pattern.test(scope === "screen" ? screen.screen() : screen.line());
196
209
 
@@ -290,6 +303,9 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
290
303
  pageDown: (n) => key("pageDown", n),
291
304
  ctrl: (letter, n) => pressKey(ctrlSequence(letter), n),
292
305
  alt: (k, n) => pressKey(altSequence(k), n),
306
+ shift: (k, n) => pressKey(shiftSequence(k), n),
307
+ scrollUp: (n) => scroll("up", n ?? 1),
308
+ scrollDown: (n) => scroll("down", n ?? 1),
293
309
  raw,
294
310
  sleep,
295
311
  wait,
@@ -327,7 +343,10 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
327
343
  try {
328
344
  // Everything that happens before the first prompt is stamped at t=0.
329
345
  log(`starting ${Array.isArray(config.shell) ? config.shell.join(" ") : config.shell}`);
330
- await waitFor(`initial prompt ${promptPattern}`, () => promptPattern.test(screen.line()), config.waitTimeout);
346
+ // Named shells get a known prompt; for an arbitrary command there is nothing to wait for — start at once.
347
+ if (!Array.isArray(config.shell)) {
348
+ await waitFor(`initial prompt ${promptPattern}`, () => promptPattern.test(screen.line()), config.waitTimeout);
349
+ }
331
350
  startedAt = performance.now();
332
351
  log("recording");
333
352
  await script(session);
@@ -6,6 +6,8 @@ export interface FrameSink {
6
6
  frame(png: Uint8Array): Promise<void>;
7
7
  finish(): Promise<void>;
8
8
  readonly target: string;
9
+ /** True for outputs that loop (GIF, WebP) — `loopOffset` applies to these. */
10
+ readonly loops?: boolean;
9
11
  }
10
12
 
11
13
  type Format = "mp4" | "webm" | "gif" | "webp" | "png-sequence" | "png" | "jpeg";
@@ -174,6 +176,7 @@ class FfmpegSink implements FrameSink {
174
176
  private proc: Subprocess<"pipe", "ignore", "pipe">;
175
177
  private stdin: FileSink;
176
178
  private stderr: Promise<string>;
179
+ readonly loops: boolean;
177
180
 
178
181
  constructor(
179
182
  readonly target: string,
@@ -181,6 +184,7 @@ class FfmpegSink implements FrameSink {
181
184
  fps: number,
182
185
  match: EncoderMatch,
183
186
  ) {
187
+ this.loops = format === "gif" || format === "webp";
184
188
  this.proc = Bun.spawn([match.binary, ...ffmpegArgs(format, fps, target, match.encoder)], { stdin: "pipe", stdout: "ignore", stderr: "pipe" });
185
189
  this.stdin = this.proc.stdin;
186
190
  this.stderr = new Response(this.proc.stderr).text();