termcut 0.2.2 → 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/README.md CHANGED
@@ -21,6 +21,8 @@ tcut rec -o demo.gif # opens a shell, records until you `exit
21
21
  tcut rec -o demo.mp4 -- npm create vite # or just one command
22
22
  ```
23
23
 
24
+ You get `demo.gif`, the exact recording (`demo.cast`) and an editable script (`demo.video.ts`) of what you typed.
25
+
24
26
  Or script it:
25
27
 
26
28
  ```ts
@@ -38,10 +40,17 @@ export default defineVideo({ output: "demo.gif" }, async (t) => {
38
40
  tcut demo.video.ts
39
41
  ```
40
42
 
41
- Re-render any recording without re-running it:
43
+ Re-render any recording without re-running it — ~600 themes ([Ghostty's collection](https://github.com/mbadolato/iTerm2-Color-Schemes)), `tcut themes` lists them:
44
+
45
+ ```sh
46
+ tcut render demo.cast --theme "Gruvbox Dark" -o demo.svg
47
+ ```
48
+
49
+ Share it:
42
50
 
43
51
  ```sh
44
- tcut render demo.cast --theme dracula -o demo.svg
52
+ tcut publish --setup # once: your S3-compatible bucket (RustFS, MinIO, R2, S3)
53
+ tcut publish demo.gif # → https://…/3f9a1c2b7d4e/demo.gif
45
54
  ```
46
55
 
47
56
  ## More
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
5
5
  "license": "MIT",
6
6
  "author": "Aman Varshney",
@@ -33,7 +33,7 @@
33
33
  ".": "./src/index.ts"
34
34
  },
35
35
  "bin": {
36
- "tcut": "./bin/tcut.mjs"
36
+ "tcut": "bin/tcut.mjs"
37
37
  },
38
38
  "files": [
39
39
  "bin",
@@ -0,0 +1,86 @@
1
+ // Generates src/themes.generated.json from the Ghostty-format themes in mbadolato/iTerm2-Color-Schemes (MIT) —
2
+ // the same collection Ghostty bundles. Run: `bun scripts/build-themes.ts` (network). Output is committed.
3
+ import path from "node:path";
4
+ import { mkdtemp, readdir, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+
7
+ const SOURCE = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/refs/heads/master.tar.gz";
8
+ const out = path.resolve(import.meta.dir, "..", "src", "themes.generated.json");
9
+
10
+ const KEYS = [
11
+ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
12
+ "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite",
13
+ ] as const;
14
+
15
+ const slug = (name: string) =>
16
+ name
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9]+/g, "-")
19
+ .replace(/^-+|-+$/g, "");
20
+
21
+ const hex = (v: string) => {
22
+ const h = v.trim().replace(/^#/, "");
23
+ return /^[0-9a-f]{6}$/i.test(h) ? `#${h.toLowerCase()}` : null;
24
+ };
25
+
26
+ function parse(name: string, text: string): Record<string, string> | null {
27
+ const palette: Record<number, string> = {};
28
+ const props: Record<string, string> = {};
29
+ for (const raw of text.split("\n")) {
30
+ const line = raw.trim();
31
+ if (!line || line.startsWith("#")) continue;
32
+ const eq = line.indexOf("=");
33
+ if (eq < 0) continue;
34
+ const key = line.slice(0, eq).trim();
35
+ const value = line.slice(eq + 1).trim();
36
+ if (key === "palette") {
37
+ const m = /^(\d+)\s*=\s*(.+)$/.exec(value);
38
+ if (m) {
39
+ const c = hex(m[2]!);
40
+ if (c) palette[Number(m[1])] = c;
41
+ }
42
+ } else {
43
+ props[key] = value;
44
+ }
45
+ }
46
+ const background = hex(props.background ?? "");
47
+ const foreground = hex(props.foreground ?? "");
48
+ if (!background || !foreground) return null;
49
+ for (let i = 0; i < 16; i++) if (!palette[i]) return null;
50
+ const theme: Record<string, string> = { name, background, foreground };
51
+ const cursor = hex(props["cursor-color"] ?? "");
52
+ if (cursor) theme.cursor = cursor;
53
+ const cursorText = hex(props["cursor-text"] ?? "");
54
+ if (cursorText) theme.cursorAccent = cursorText;
55
+ const selection = hex(props["selection-background"] ?? "");
56
+ if (selection) theme.selectionBackground = selection;
57
+ KEYS.forEach((k, i) => (theme[k] = palette[i]!));
58
+ return theme;
59
+ }
60
+
61
+ const tmp = await mkdtemp(path.join(tmpdir(), "tcut-themes-"));
62
+ const tgz = path.join(tmp, "schemes.tgz");
63
+ const res = await fetch(SOURCE);
64
+ if (!res.ok) throw new Error(`download failed: ${res.status}`);
65
+ await Bun.write(tgz, await res.arrayBuffer());
66
+ const untar = Bun.spawn(["tar", "-xzf", tgz, "-C", tmp], { stdout: "ignore", stderr: "pipe" });
67
+ if ((await untar.exited) !== 0) throw new Error(await new Response(untar.stderr).text());
68
+ const root = (await readdir(tmp)).find((d) => d.startsWith("iTerm2-Color-Schemes"));
69
+ if (!root) throw new Error("unexpected archive layout");
70
+ const dir = path.join(tmp, root, "ghostty");
71
+
72
+ const themes: Record<string, Record<string, string>> = {};
73
+ let skipped = 0;
74
+ for (const file of (await readdir(dir)).sort()) {
75
+ const text = await Bun.file(path.join(dir, file)).text();
76
+ const theme = parse(file, text);
77
+ if (!theme) {
78
+ skipped++;
79
+ continue;
80
+ }
81
+ themes[slug(file)] = theme;
82
+ }
83
+ await rm(tmp, { recursive: true, force: true });
84
+
85
+ await Bun.write(out, JSON.stringify(themes) + "\n");
86
+ console.log(`wrote ${path.relative(process.cwd(), out)}: ${Object.keys(themes).length} themes (${skipped} skipped), ${(Bun.file(out).size / 1024).toFixed(0)} KB`);
package/src/cli.ts CHANGED
@@ -6,9 +6,11 @@ import { writeCast } from "./cast";
6
6
  import { resolveConfig } from "./config";
7
7
  import * as api from "./index";
8
8
  import { recordLive } from "./live";
9
+ import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig } from "./publish";
9
10
  import { renderOutputs } from "./render";
11
+ import { generateScript } from "./scriptgen";
10
12
  import { runScriptTests } from "./testing";
11
- import { themeNames } from "./themes";
13
+ import { findThemes, themeNames } from "./themes";
12
14
  import type { CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
13
15
  import { Video, isVideo, renderCast } from "./video";
14
16
 
@@ -32,8 +34,10 @@ Usage:
32
34
  tcut record <script.ts> [options] record only (writes the .cast)
33
35
  tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
34
36
  tcut test <path...> run scripts in fast mode as tests (no video)
37
+ tcut publish <files...> [--open] upload to your S3-compatible bucket and print share links
38
+ tcut publish --setup configure the bucket (RustFS, MinIO, R2, S3 …) — once
35
39
  tcut init [name] [--template t] scaffold a new script (basic | tour | test)
36
- tcut themes list built-in themes
40
+ tcut themes [query] list the ~600 bundled themes (Ghostty collection)
37
41
 
38
42
  Options (override the script's config):
39
43
  -o, --output <path> .mp4 .webm .gif .webp .svg .html .png .jpg or dir/ for PNG frames — repeatable
@@ -47,7 +51,11 @@ Options (override the script's config):
47
51
  --cols <n> --rows <n> terminal size (rec: defaults to your terminal's size)
48
52
  --cast <path> where to read/write the .cast
49
53
  --record-only stop after writing the cast
54
+ --no-script rec: don't write the editable <name>.video.ts next to the cast
50
55
  --force ignore the cast cache and re-record
56
+ --open publish: open the first link in the browser
57
+ --name <file> publish: object name (default: the file's basename)
58
+ --endpoint --bucket --access-key --secret-key --public-url --region publish --setup values
51
59
  --template <name> for init: basic | tour | test
52
60
  -q, --quiet
53
61
  -h, --help
@@ -77,7 +85,17 @@ const { values, positionals } = parseArgs({
77
85
  rows: { type: "string" },
78
86
  cast: { type: "string" },
79
87
  "record-only": { type: "boolean" },
88
+ "no-script": { type: "boolean" },
80
89
  force: { type: "boolean" },
90
+ setup: { type: "boolean" },
91
+ open: { type: "boolean" },
92
+ name: { type: "string" },
93
+ endpoint: { type: "string" },
94
+ bucket: { type: "string" },
95
+ "access-key": { type: "string" },
96
+ "secret-key": { type: "string" },
97
+ "public-url": { type: "string" },
98
+ region: { type: "string" },
81
99
  template: { type: "string" },
82
100
  quiet: { type: "boolean", short: "q" },
83
101
  help: { type: "boolean", short: "h" },
@@ -281,7 +299,47 @@ async function main(): Promise<void> {
281
299
 
282
300
  switch (first) {
283
301
  case "themes": {
284
- for (const name of themeNames) console.log(name);
302
+ const names = rest[0] ? findThemes(rest[0]) : themeNames;
303
+ if (names.length === 0) fail(`No theme matches "${rest[0]}"`);
304
+ for (const name of names) console.log(name);
305
+ if (!rest[0]) log(dim(`${names.length} themes · use any name with --theme, e.g. --theme "Gruvbox Dark"`));
306
+ return;
307
+ }
308
+ case "publish": {
309
+ if (values.setup) {
310
+ const ask = async (label: string, flag: string | undefined, fallback: string, secret = false): Promise<string> => {
311
+ if (flag) return flag;
312
+ if (!process.stdin.isTTY) return fallback;
313
+ const answer = prompt(`${label}${fallback ? ` [${fallback}]` : ""}:`) ?? "";
314
+ return answer.trim() || fallback;
315
+ };
316
+ const existing = await loadPublishConfig().catch(() => null);
317
+ const cfg: PublishConfig = {
318
+ endpoint: await ask("S3 endpoint", values.endpoint, existing?.endpoint ?? "https://s3.amanv.cloud"),
319
+ bucket: await ask("Bucket", values.bucket, existing?.bucket ?? "tcut"),
320
+ accessKeyId: await ask("Access key", values["access-key"], existing?.accessKeyId ?? ""),
321
+ secretAccessKey: await ask("Secret key", values["secret-key"], existing?.secretAccessKey ?? "", true),
322
+ region: values.region ?? existing?.region ?? "us-east-1",
323
+ ...(values["public-url"] || existing?.publicUrl ? { publicUrl: values["public-url"] ?? existing?.publicUrl } : {}),
324
+ };
325
+ if (!cfg.accessKeyId || !cfg.secretAccessKey) fail("publish --setup needs --access-key and --secret-key (or run it in a terminal to be prompted)");
326
+ const result = await ensurePublicBucket(cfg, log);
327
+ const file = await savePublishConfig(cfg);
328
+ ok(`saved ${file}`, "mode 600");
329
+ ok(`bucket ${cfg.bucket} on ${cfg.endpoint}`, result.bucketCreated ? "created" : "exists");
330
+ if (result.publicReadOk) ok("public read verified", `links will look like ${publicUrlFor(cfg, "x").replace(/\/x$/, "/<hash>/demo.gif")}`);
331
+ else log(`${red("✘")} anonymous read failed — set a public-read policy on the bucket or pass --public-url for a CDN/proxy in front of it`);
332
+ return;
333
+ }
334
+ if (rest.length === 0) fail("publish needs at least one file (or --setup)");
335
+ const cfg = await loadPublishConfig();
336
+ if (!cfg) fail("publish is not configured yet — run `tcut publish --setup` (or set TCUT_S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY)");
337
+ const published = await publishFiles(rest, cfg, { name: values.name, log });
338
+ for (const p of published) ok(p.url, dim(path.basename(p.file)));
339
+ if (values.open && published[0]) {
340
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
341
+ Bun.spawn([opener, published[published.length - 1]!.url], { stdout: "ignore", stderr: "ignore" });
342
+ }
285
343
  return;
286
344
  }
287
345
  case "init": {
@@ -299,7 +357,8 @@ async function main(): Promise<void> {
299
357
  case "rec": {
300
358
  // Live mode: the user (or a pipe) drives the PTY; everything after `--` is the command to run.
301
359
  const overrides = overridesFromFlags();
302
- const outputs = overrides.output ?? ["rec.mp4"];
360
+ const rawOutputs = overrides.output ?? ["rec.mp4"];
361
+ const outputs = Array.isArray(rawOutputs) ? rawOutputs : [rawOutputs];
303
362
  const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
304
363
  const command = rest.length > 0 ? rest : undefined;
305
364
  // Size: --cols/--rows if given, else the terminal tcut runs in.
@@ -308,6 +367,11 @@ async function main(): Promise<void> {
308
367
  await writeCast(config.cast, recording);
309
368
  log("");
310
369
  ok(`wrote ${config.cast}`, `${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s`);
370
+ if (!values["no-script"]) {
371
+ const scriptPath = config.cast.replace(/\.cast$/, "") + ".video.ts";
372
+ await Bun.write(scriptPath, generateScript(recording, { output: outputs, cleanShell: !command, command, castPath: config.cast }));
373
+ ok(`wrote ${scriptPath}`, "editable script — tweak it, then `tcut " + scriptPath + "`");
374
+ }
311
375
  if (values["record-only"]) return;
312
376
  const result = await renderOutputs(recording, config, progressReporter());
313
377
  await reportOutputs(result.outputs, result.screenshots);
package/src/index.ts CHANGED
@@ -9,7 +9,11 @@ export { replayFrames } from "./export/frames";
9
9
  export type { GridFrame, GridCell, GridReplay } from "./export/frames";
10
10
  export { runScriptTests, discoverScripts } from "./testing";
11
11
  export type { TestResult, TestSummary } from "./testing";
12
- export { themes, themeNames, resolveTheme } from "./themes";
12
+ export { themes, themeNames, resolveTheme, findThemes, themeSlug, builtinThemes } from "./themes";
13
+ export { generateScript, eventsToOps, tokenize } from "./scriptgen";
14
+ export type { ScriptGenOptions } from "./scriptgen";
15
+ export { publishFiles, loadPublishConfig, savePublishConfig, ensurePublicBucket, publicUrlFor, keyFor } from "./publish";
16
+ export type { PublishConfig, Published, PublishOptions } from "./publish";
13
17
  export { readCast, writeCast, parseCast, serializeCast } from "./cast";
14
18
  export { buildTimeline } from "./timeline";
15
19
  export { resolveConfig } from "./config";
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);