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/README.md +11 -2
- package/package.json +2 -2
- package/scripts/build-themes.ts +86 -0
- package/src/cli.ts +85 -7
- package/src/config.ts +30 -10
- package/src/export/svg.ts +16 -8
- package/src/index.ts +5 -1
- package/src/keys.ts +28 -0
- package/src/loop.ts +45 -0
- package/src/publish.ts +226 -0
- package/src/recorder.ts +21 -2
- package/src/renderer/encoder.ts +4 -0
- package/src/renderer/generated/page.js +1 -1
- package/src/renderer/page-entry.ts +2 -1
- package/src/renderer/webview.ts +21 -7
- package/src/screen.ts +5 -0
- package/src/scriptgen.ts +230 -0
- package/src/themes.generated.json +1 -0
- package/src/themes.ts +35 -6
- package/src/types.ts +22 -7
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
|
-
|
|
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);
|
package/src/renderer/encoder.ts
CHANGED
|
@@ -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();
|