tars-renderer 0.0.0-stage → 1.1.1
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 +187 -2
- package/package.json +32 -4
- package/skill/SKILL.md +111 -0
- package/tars-renderer.mjs +1943 -0
|
@@ -0,0 +1,1943 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tars-renderer: command-line client for the Tars Renderer (Remotion render farm). Contract: CLI_API.md; docs: cli/README.md.
|
|
3
|
+
// One file, no dependencies, Node >= 18. Served at <portal>/cli/tars-renderer.mjs.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import fsp from "node:fs/promises";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import readline from "node:readline";
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import { setMaxListeners } from "node:events";
|
|
12
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
export const VERSION = "1.1.1";
|
|
16
|
+
|
|
17
|
+
/** The Tars Renderer portal. `login` signs in here unless another portal is passed (development, staging). */
|
|
18
|
+
export const DEFAULT_PORTAL = "https://tars-renderer.vercel.app";
|
|
19
|
+
|
|
20
|
+
// The package was first published under an older name. These are read only to carry existing sign-ins over.
|
|
21
|
+
const LEGACY = { dir: ".tars-" + "render", keyring: "tars-" + "render", env: "TARS_" + "RENDER_" };
|
|
22
|
+
for (const k of ["API", "TOKEN", "TOKEN_STORE"]) {
|
|
23
|
+
if (process.env[LEGACY.env + k] && !process.env[`TARS_RENDERER_${k}`]) process.env[`TARS_RENDERER_${k}`] = process.env[LEGACY.env + k];
|
|
24
|
+
}
|
|
25
|
+
export const EXIT = { OK: 0, ERROR: 1, USAGE: 2, CONFIRM: 3, AUTH: 4, CANCELLED: 130 };
|
|
26
|
+
|
|
27
|
+
// Transfers mirror src/upload/uploader.ts and src/lib/transfer.ts (the browser's versions). The 64 MB multipart
|
|
28
|
+
// threshold and 16 MB part size are decided by the server in prepare.
|
|
29
|
+
const SKIP = new Set([".DS_Store", "Thumbs.db"]);
|
|
30
|
+
const HASH_AT_ONCE = 4;
|
|
31
|
+
// Node's fetch has no per-host connection cap (browsers stop at 6), so the CLI keeps more requests in flight: that is
|
|
32
|
+
// what fills a long, high-latency link. SMALL_SLOTS of them keep sending small files alongside the big parts.
|
|
33
|
+
const UPLOAD_AT_ONCE = 16;
|
|
34
|
+
const SMALL_SLOTS = 4;
|
|
35
|
+
const SMALL_BYTES = 1 << 20;
|
|
36
|
+
const DOWNLOAD_AT_ONCE = 8;
|
|
37
|
+
const RANGE_BYTES = 16 << 20;
|
|
38
|
+
const TRIES = 5;
|
|
39
|
+
const STALL_MS = 60_000; // a request that moves no bytes this long is dropped and retried
|
|
40
|
+
const HASH_CACHE_MAX = 50_000;
|
|
41
|
+
const POLL_MS = 2000;
|
|
42
|
+
|
|
43
|
+
// Mirrors convex/lib/presets.ts.
|
|
44
|
+
const RESOLUTIONS = ["360p", "720p", "1080p", "4k"];
|
|
45
|
+
const QUALITIES = ["draft", "standard", "high"];
|
|
46
|
+
const RETENTIONS = ["1-day", "3-days", "7-days", "30-days", "forever"];
|
|
47
|
+
const SITE_RETENTIONS = ["7-days", "30-days", "90-days", "never"];
|
|
48
|
+
const ACTIVE = new Set(["queued", "rendering"]);
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------- errors
|
|
51
|
+
|
|
52
|
+
export class CliError extends Error {
|
|
53
|
+
constructor(message, exitCode = EXIT.ERROR, extra = undefined) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.exitCode = exitCode;
|
|
56
|
+
this.extra = extra;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
class ApiError extends CliError {
|
|
60
|
+
constructor(status, body, fallback) {
|
|
61
|
+
const msg = body?.error || fallback || `The API answered HTTP ${status}`;
|
|
62
|
+
const hint = status === 401 && !/login/i.test(msg) ? ` (${msg.replace(/\.$/, "")}). Your login expired or was revoked; run: tars-renderer login` : "";
|
|
63
|
+
super(hint ? `Not authorized${hint}` : msg, status === 401 ? EXIT.AUTH : EXIT.ERROR);
|
|
64
|
+
this.status = status;
|
|
65
|
+
this.code = body?.code;
|
|
66
|
+
this.body = body;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const usage = (msg) => new CliError(msg, EXIT.USAGE);
|
|
70
|
+
const notLoggedIn = () =>
|
|
71
|
+
new CliError("Not logged in. Run: tars-renderer login", EXIT.AUTH);
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------- pure helpers (exported for tests)
|
|
74
|
+
|
|
75
|
+
const BOOLEAN = new Set(["json", "yes", "help", "watch", "mine", "muted", "rehash", "all", "local", "purge", "repo"]);
|
|
76
|
+
const ALIASES = { h: "help", y: "yes" };
|
|
77
|
+
|
|
78
|
+
/** `--x value`, `--x=value`, boolean `--x`, `-h`/`-y`, `--` ends options. A non-boolean flag without a value is `true`. */
|
|
79
|
+
export function parseArgs(argv) {
|
|
80
|
+
const out = { _: [], flags: {} };
|
|
81
|
+
for (let i = 0; i < argv.length; i++) {
|
|
82
|
+
const a = argv[i];
|
|
83
|
+
if (a === "--") {
|
|
84
|
+
out._.push(...argv.slice(i + 1));
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
if (a.startsWith("--") && a.length > 2) {
|
|
88
|
+
const eq = a.indexOf("=");
|
|
89
|
+
const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
|
|
90
|
+
if (eq > 0) {
|
|
91
|
+
const v = a.slice(eq + 1);
|
|
92
|
+
out.flags[name] = BOOLEAN.has(name) ? !/^(false|no|0)$/i.test(v) : v;
|
|
93
|
+
} else if (BOOLEAN.has(name)) out.flags[name] = true;
|
|
94
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) out.flags[name] = argv[++i];
|
|
95
|
+
else out.flags[name] = true;
|
|
96
|
+
} else if (/^-[a-z]$/i.test(a) && ALIASES[a[1]]) out.flags[ALIASES[a[1]]] = true;
|
|
97
|
+
else out._.push(a);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Same rule as src/upload/detectVersion.ts: the most frequent `4.0.N` within `window` chars of "remotion" in bundle.js. */
|
|
103
|
+
export function detectRemotionVersion(bundleJs, window = 200) {
|
|
104
|
+
const lower = bundleJs.toLowerCase();
|
|
105
|
+
const counts = new Map();
|
|
106
|
+
for (const m of bundleJs.matchAll(/(?<![\d.])4\.0\.\d+(?![\d.])/g)) {
|
|
107
|
+
const at = m.index;
|
|
108
|
+
const near = lower.slice(Math.max(0, at - window), at + m[0].length + window);
|
|
109
|
+
if (near.includes("remotion")) counts.set(m[0], (counts.get(m[0]) ?? 0) + 1);
|
|
110
|
+
}
|
|
111
|
+
let best = null;
|
|
112
|
+
for (const [v, n] of counts) if (best === null || n > counts.get(best)) best = v;
|
|
113
|
+
return best;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Same messages as src/pages/upload/folderProblem.ts, worded for a path argument. Null when index.html and bundle.js are at the root. */
|
|
117
|
+
// Same limit as the server (convex/lib/uploadUtils.ts UPLOAD_LIMITS.files), checked before hashing.
|
|
118
|
+
const MAX_FILES = 8000;
|
|
119
|
+
|
|
120
|
+
export function folderProblem(paths) {
|
|
121
|
+
if (!paths.length) return "That folder is empty. Pass the build folder that `npx remotion bundle` created.";
|
|
122
|
+
if (paths.length > MAX_FILES) {
|
|
123
|
+
return `This build has ${paths.length.toLocaleString("en-US")} files; the limit is ${MAX_FILES.toLocaleString("en-US")}. Move unused assets out of public/ (or turn image sequences into a video) and rebuild, then upload it again.`;
|
|
124
|
+
}
|
|
125
|
+
const has = new Set(paths);
|
|
126
|
+
if (has.has("index.html") && has.has("bundle.js")) return null;
|
|
127
|
+
const inner = paths
|
|
128
|
+
.filter((p) => p.endsWith("/index.html") && has.has(p.slice(0, -"index.html".length) + "bundle.js"))
|
|
129
|
+
.sort((a, b) => a.length - b.length)[0];
|
|
130
|
+
if (inner) {
|
|
131
|
+
const dir = inner.slice(0, -"/index.html".length);
|
|
132
|
+
const root = has.has("package.json") ? "the project root" : "the folder around the build";
|
|
133
|
+
return `You passed ${root}. Pass the "${dir}" folder inside it.`;
|
|
134
|
+
}
|
|
135
|
+
if (has.has("package.json")) {
|
|
136
|
+
return "You passed the project root, and it has no build yet. Run `npx remotion bundle src/index.ts --out-dir build`, then pass the build folder.";
|
|
137
|
+
}
|
|
138
|
+
const missing = ["index.html", "bundle.js"].filter((p) => !has.has(p));
|
|
139
|
+
return `${missing.join(" and ")} not found at the top of this folder. Pass the build folder that \`npx remotion bundle\` created (the one containing index.html and bundle.js).`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A path relative to the build root, with `/` separators (Windows `\` becomes `/`). */
|
|
143
|
+
export const toPosixPath = (rel, sep = path.sep) => rel.split(sep).join("/");
|
|
144
|
+
|
|
145
|
+
/** Byte ranges (inclusive `end`) for the server's part URLs. */
|
|
146
|
+
export function partRanges(size, partSize, count) {
|
|
147
|
+
const n = Math.ceil(size / partSize);
|
|
148
|
+
if (n !== count) throw new Error(`The server sent ${count} part URLs for a file that needs ${n}`);
|
|
149
|
+
return Array.from({ length: n }, (_, i) => ({ start: i * partSize, end: Math.min(size, (i + 1) * partSize) - 1 }));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** "a-b" → [a, b]; whole numbers only when `integers`. */
|
|
153
|
+
export function parseRange(s, flag, integers) {
|
|
154
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*$/.exec(String(s));
|
|
155
|
+
if (!m) throw usage(`${flag} must look like ${integers ? "0-299" : "0-10 or 2.5-8"}`);
|
|
156
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
157
|
+
if (integers && (!Number.isInteger(a) || !Number.isInteger(b))) throw usage(`${flag} takes whole frame numbers`);
|
|
158
|
+
if (b < a) throw usage(`${flag}: the end must not be before the start`);
|
|
159
|
+
return [a, b];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Seconds [a, b) → inclusive frame range at `fps`: 0-10 at 30 fps is frames 0-299. */
|
|
163
|
+
export function secondsToFrames([a, b], fps, durationInFrames) {
|
|
164
|
+
const start = Math.round(a * fps);
|
|
165
|
+
const end = Math.round(b * fps) - 1;
|
|
166
|
+
const total = +(durationInFrames / fps).toFixed(2);
|
|
167
|
+
if (end < start) throw usage(`--seconds ${a}-${b} is shorter than one frame at ${fps} fps`);
|
|
168
|
+
if (end > durationInFrames - 1) throw usage(`--seconds must be within 0-${total} (the composition is ${total} s long)`);
|
|
169
|
+
return [start, end];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Frames → hh:mm:ss:ff. */
|
|
173
|
+
export function timecode(frames, fps) {
|
|
174
|
+
const s = Math.floor(frames / fps);
|
|
175
|
+
const ff = Math.floor(frames - s * fps);
|
|
176
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
177
|
+
return `${p(Math.floor(s / 3600))}:${p(Math.floor((s % 3600) / 60))}:${p(s % 60)}:${p(ff)}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const fmtInt = (n) => Math.round(n ?? 0).toLocaleString("en-US");
|
|
181
|
+
export const fmtUsd = (n) => `$${(n ?? 0).toFixed(2)}`;
|
|
182
|
+
export function fmtBytes(n) {
|
|
183
|
+
if (n == null) return "—";
|
|
184
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
185
|
+
let i = 0;
|
|
186
|
+
while (n >= 1024 && i < units.length - 1) (n /= 1024), i++;
|
|
187
|
+
return i ? `${n.toFixed(1)} ${units[i]}` : `${n} B`;
|
|
188
|
+
}
|
|
189
|
+
export function fmtDuration(ms) {
|
|
190
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
191
|
+
const h = Math.floor(s / 3600);
|
|
192
|
+
const m = Math.floor((s % 3600) / 60);
|
|
193
|
+
return h ? `${h}h ${m}m` : m ? `${m}m ${s % 60}s` : `${s}s`;
|
|
194
|
+
}
|
|
195
|
+
const when = (t) => (!t ? "—" : Date.now() - t < 86_400_000 ? `${fmtDuration(Date.now() - t)} ago` : new Date(t).toISOString().slice(0, 10));
|
|
196
|
+
|
|
197
|
+
/** ▮▮▮▮▮▮▯▯▯▯▯▯ for progress 0..1. */
|
|
198
|
+
export function chunkStrip(progress, cells = 24) {
|
|
199
|
+
const filled = Math.max(0, Math.min(cells, Math.round((progress ?? 0) * cells)));
|
|
200
|
+
return "▮".repeat(filled) + "▯".repeat(cells - filled);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** One status line for a render row: strip, percent, frames, cost, elapsed, status. */
|
|
204
|
+
export function progressLine(r, now = Date.now()) {
|
|
205
|
+
const progress = r.status === "done" ? 1 : r.progress ?? 0;
|
|
206
|
+
const pct = `${Math.floor(progress * 100)}%`.padStart(4);
|
|
207
|
+
const cost = r.costUsd ? fmtUsd(r.costUsd) : `~${fmtUsd(r.estimatedCostUsd)}`;
|
|
208
|
+
const elapsed = fmtDuration((r.finishedAt ?? now) - r.startedAt);
|
|
209
|
+
return `${chunkStrip(progress)} ${pct} ${fmtInt(r.framesRendered)} frames ${cost} ${elapsed} ${r.status}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const maskToken = (t) => (t ? `${t.slice(0, 8)}…` : "(none)");
|
|
213
|
+
export const normalizeApi = (u) => (u ? String(u).trim().replace(/\/+$/, "").replace(/\/api\/v1$/, "") : u);
|
|
214
|
+
|
|
215
|
+
/** RenderParams from flags; only the fields given (the server fills DEFAULT_PARAMS). `comp` is needed for --seconds. */
|
|
216
|
+
export function buildParams(f, comp) {
|
|
217
|
+
const pick = (flag, v, allowed) => {
|
|
218
|
+
const x = String(v).toLowerCase();
|
|
219
|
+
if (!allowed.includes(x)) throw usage(`--${flag} must be one of ${allowed.join(", ")}`);
|
|
220
|
+
return x;
|
|
221
|
+
};
|
|
222
|
+
const p = {};
|
|
223
|
+
if (f.res !== undefined) p.resolution = pick("res", f.res, RESOLUTIONS);
|
|
224
|
+
if (f.quality !== undefined) p.quality = pick("quality", f.quality, QUALITIES);
|
|
225
|
+
if (f.keep !== undefined) p.retention = pick("keep", f.keep, RETENTIONS);
|
|
226
|
+
if (f.muted) p.muted = true;
|
|
227
|
+
if (f.frames !== undefined && f.seconds !== undefined) throw usage("Use --frames or --seconds, not both");
|
|
228
|
+
if (f.frames !== undefined) p.frameRange = parseRange(f.frames, "--frames", true);
|
|
229
|
+
if (f.seconds !== undefined) p.frameRange = secondsToFrames(parseRange(f.seconds, "--seconds", false), comp.fps, comp.durationInFrames);
|
|
230
|
+
return p;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------- transfer helpers (same as src/lib/transfer.ts)
|
|
234
|
+
|
|
235
|
+
/** An HTTP failure. `status` 0 means no answer arrived (network error, stall). */
|
|
236
|
+
class TransferError extends Error {
|
|
237
|
+
constructor(message, status, retryable = isRetryableStatus(status)) {
|
|
238
|
+
super(message);
|
|
239
|
+
this.status = status;
|
|
240
|
+
this.retryable = retryable;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 5xx, 408, 429 and no answer are worth retrying. 403 (expired or bad signature) and other 4xx are not. */
|
|
245
|
+
export const isRetryableStatus = (status) => status === 0 || status === 408 || status === 429 || status >= 500;
|
|
246
|
+
|
|
247
|
+
/** Runs `fn` until it succeeds, fails with a non-retryable TransferError, runs out of tries, or `signal` aborts. */
|
|
248
|
+
async function withRetry(fn, signal, tries = TRIES) {
|
|
249
|
+
for (let attempt = 1; ; attempt++) {
|
|
250
|
+
try {
|
|
251
|
+
return await fn();
|
|
252
|
+
} catch (e) {
|
|
253
|
+
if (signal.aborted || attempt >= tries || (e instanceof TransferError && !e.retryable)) throw e;
|
|
254
|
+
// Exponential backoff with full jitter, so retrying connections don't stampede together.
|
|
255
|
+
await delay(Math.random() * Math.min(15_000, 500 * 2 ** attempt), undefined, { signal });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Keeps up to `limit` jobs running; each free slot asks `next()` for its next job. The first failure aborts `ctrl`
|
|
262
|
+
* (stopping the jobs in flight) and is thrown once every slot has settled.
|
|
263
|
+
*/
|
|
264
|
+
export async function runPool(limit, next, ctrl) {
|
|
265
|
+
const first = {};
|
|
266
|
+
const slot = async () => {
|
|
267
|
+
while (!ctrl.signal.aborted) {
|
|
268
|
+
const job = next();
|
|
269
|
+
if (!job) return;
|
|
270
|
+
try {
|
|
271
|
+
await job();
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (!first.failed) Object.assign(first, { failed: true, error });
|
|
274
|
+
ctrl.abort();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
await Promise.all(Array.from({ length: Math.max(1, limit) }, slot));
|
|
279
|
+
if (first.failed) throw first.error;
|
|
280
|
+
ctrl.signal.throwIfAborted();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Picks the largest job first (the longest transfer never starts last) while up to `smallSlots` slots take the
|
|
285
|
+
* smallest jobs: small files are bound by round trips, not bandwidth, so they ride along with the big parts.
|
|
286
|
+
*/
|
|
287
|
+
export function largestFirst(items, smallSlots, smallBytes = SMALL_BYTES) {
|
|
288
|
+
const order = [...items].sort((a, b) => b.size - a.size);
|
|
289
|
+
let head = 0;
|
|
290
|
+
let tail = order.length - 1;
|
|
291
|
+
let small = 0;
|
|
292
|
+
return () => {
|
|
293
|
+
if (head > tail) return undefined;
|
|
294
|
+
if (small >= smallSlots || order[tail].size >= smallBytes) return order[head++].run;
|
|
295
|
+
const job = order[tail--].run;
|
|
296
|
+
small++;
|
|
297
|
+
return () => job().finally(() => small--);
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Inclusive byte ranges of about `rangeBytes`, a multiple of `connections` of them so the last round has no straggler. */
|
|
302
|
+
export function splitRanges(total, rangeBytes, connections) {
|
|
303
|
+
const count = Math.max(1, Math.round(total / rangeBytes / connections)) * connections;
|
|
304
|
+
const size = Math.ceil(total / count);
|
|
305
|
+
const out = [];
|
|
306
|
+
for (let pos = 0; pos < total; pos += size) out.push({ pos, end: Math.min(total, pos + size) - 1 });
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Throughput over the last `windowMs` (bytes/s) and the time left at that rate; null until there's a second of data. */
|
|
311
|
+
export function rateMeter(windowMs = 10_000) {
|
|
312
|
+
const samples = [];
|
|
313
|
+
let latest = [0, 0];
|
|
314
|
+
const rate = () => (samples.length && latest[0] - samples[0][0] >= 1000 ? ((latest[1] - samples[0][1]) / (latest[0] - samples[0][0])) * 1000 : null);
|
|
315
|
+
return {
|
|
316
|
+
update(total, now = Date.now()) {
|
|
317
|
+
latest = [now, total];
|
|
318
|
+
if (!samples.length || now - samples[samples.length - 1][0] >= 200) samples.push(latest);
|
|
319
|
+
while (samples.length > 1 && now - samples[0][0] > windowMs) samples.shift();
|
|
320
|
+
},
|
|
321
|
+
rate,
|
|
322
|
+
etaMs: (remaining) => (rate() > 0 ? (remaining / rate()) * 1000 : null),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Aborts `ctrl` with a retryable error when `alive()` isn't called for STALL_MS: a connection that silently stalled. */
|
|
327
|
+
function stallTimer(ctrl, what) {
|
|
328
|
+
let t;
|
|
329
|
+
const alive = () => {
|
|
330
|
+
clearTimeout(t);
|
|
331
|
+
t = setTimeout(() => ctrl.abort(new TransferError(`No ${what} for ${STALL_MS / 1000} s`, 0)), STALL_MS);
|
|
332
|
+
};
|
|
333
|
+
alive();
|
|
334
|
+
return { alive, stop: () => clearTimeout(t) };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** A controller for one request that also aborts when `signal` does. Call `unlink()` when the request is over. */
|
|
338
|
+
function linked(signal) {
|
|
339
|
+
const ctrl = new AbortController();
|
|
340
|
+
const onAbort = () => ctrl.abort(signal.reason);
|
|
341
|
+
setMaxListeners(0, signal); // one listener per request in flight; not a leak, so no warning
|
|
342
|
+
if (signal.aborted) onAbort();
|
|
343
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
344
|
+
return { ctrl, unlink: () => signal.removeEventListener("abort", onAbort) };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------- output
|
|
348
|
+
|
|
349
|
+
const ctx = { json: false };
|
|
350
|
+
const COLOR = !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
351
|
+
const paint = (code) => (s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
352
|
+
const bold = paint(1);
|
|
353
|
+
const dim = paint(2);
|
|
354
|
+
const red = paint(31);
|
|
355
|
+
const green = paint(32);
|
|
356
|
+
const yellow = paint(33);
|
|
357
|
+
const cyan = paint(36);
|
|
358
|
+
const visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
359
|
+
|
|
360
|
+
/** Human output on stdout; silent in --json mode. */
|
|
361
|
+
const say = (s = "") => !ctx.json && process.stdout.write(s + "\n");
|
|
362
|
+
/** Logs and progress, always on stderr. */
|
|
363
|
+
const info = (s = "") => {
|
|
364
|
+
live.clear();
|
|
365
|
+
process.stderr.write(s + "\n");
|
|
366
|
+
};
|
|
367
|
+
const warn = (s) => info(yellow("! ") + s);
|
|
368
|
+
/** The command's result: one JSON document in --json mode, otherwise the human rendering. */
|
|
369
|
+
const done = (data, human) => (ctx.json ? process.stdout.write(JSON.stringify(data, null, 2) + "\n") : human?.(data));
|
|
370
|
+
|
|
371
|
+
/** One redrawn status line on a TTY stderr, throttled; line logs otherwise. */
|
|
372
|
+
const live = {
|
|
373
|
+
last: 0,
|
|
374
|
+
shown: false,
|
|
375
|
+
update(text, force = false) {
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
const tty = process.stderr.isTTY;
|
|
378
|
+
if (!force && now - this.last < (tty ? 1000 : 5000)) return;
|
|
379
|
+
this.last = now;
|
|
380
|
+
if (!tty) return void process.stderr.write(text + "\n");
|
|
381
|
+
process.stderr.write(`\r\x1b[2K${text.slice(0, (process.stderr.columns || 100) - 1)}`);
|
|
382
|
+
this.shown = true;
|
|
383
|
+
},
|
|
384
|
+
clear() {
|
|
385
|
+
if (this.shown) process.stderr.write("\r\x1b[2K");
|
|
386
|
+
this.shown = false;
|
|
387
|
+
this.last = 0;
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
function spinner(text) {
|
|
392
|
+
if (!process.stderr.isTTY) {
|
|
393
|
+
info(text + "…");
|
|
394
|
+
return { stop() {} };
|
|
395
|
+
}
|
|
396
|
+
const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
397
|
+
let i = 0;
|
|
398
|
+
const t = setInterval(() => live.update(`${cyan(frames[i++ % frames.length])} ${text}`, true), 100);
|
|
399
|
+
return {
|
|
400
|
+
stop() {
|
|
401
|
+
clearInterval(t);
|
|
402
|
+
live.clear();
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Aligned table on stdout. `cols`: [header, cell(row), "right"?]. */
|
|
408
|
+
function table(rows, cols) {
|
|
409
|
+
const cells = rows.map((r) => cols.map(([, f]) => String(f(r) ?? "")));
|
|
410
|
+
const head = cols.map(([h]) => h);
|
|
411
|
+
const w = head.map((h, i) => Math.max(h.length, ...cells.map((c) => visible(c[i]))));
|
|
412
|
+
const pad = (s, i) => {
|
|
413
|
+
const gap = " ".repeat(w[i] - visible(s));
|
|
414
|
+
return cols[i][2] === "right" ? gap + s : i === cols.length - 1 ? s : s + gap;
|
|
415
|
+
};
|
|
416
|
+
say(dim(head.map(pad).join(" ")));
|
|
417
|
+
for (const c of cells) say(c.map(pad).join(" "));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const statusColor = (s) => ({ done: green, ready: green, failed: red, rendering: cyan, uploading: cyan, queued: yellow }[s] ?? ((x) => x))(s);
|
|
421
|
+
|
|
422
|
+
// ---------------------------------------------------------------- config + API
|
|
423
|
+
|
|
424
|
+
const configDir = () => path.join(os.homedir(), ".tars-renderer");
|
|
425
|
+
const configFile = () => path.join(configDir(), "config.json");
|
|
426
|
+
|
|
427
|
+
/** Moves the settings folder from the old package name, once, so an existing sign-in keeps working. */
|
|
428
|
+
export function migrateLegacyConfigDir() {
|
|
429
|
+
const old = path.join(os.homedir(), LEGACY.dir);
|
|
430
|
+
try {
|
|
431
|
+
if (!fs.existsSync(old) || fs.existsSync(configDir())) return;
|
|
432
|
+
fs.renameSync(old, configDir());
|
|
433
|
+
const cfg = readConfigFile();
|
|
434
|
+
// Keychain / Secret Service entries were saved under the old service name; remember that for reads and deletes.
|
|
435
|
+
if (cfg && (cfg.tokenStore === "keychain" || cfg.tokenStore === "secret-tool") && !cfg.tokenService) saveConfig({ ...cfg, tokenService: LEGACY.keyring });
|
|
436
|
+
} catch {}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function readConfigFile() {
|
|
440
|
+
try {
|
|
441
|
+
return JSON.parse(fs.readFileSync(configFile(), "utf8"));
|
|
442
|
+
} catch (e) {
|
|
443
|
+
if (e.code === "ENOENT") return null;
|
|
444
|
+
throw new CliError(`Can't read ${configFile()}: ${e.message}. Delete it and run tars-renderer login.`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Config file overridden by TARS_RENDERER_API / TARS_RENDERER_TOKEN. */
|
|
449
|
+
function loadConfig() {
|
|
450
|
+
const f = readConfigFile() ?? {};
|
|
451
|
+
return {
|
|
452
|
+
...f,
|
|
453
|
+
api: normalizeApi(process.env.TARS_RENDERER_API || f.api),
|
|
454
|
+
token: process.env.TARS_RENDERER_TOKEN || readToken(f),
|
|
455
|
+
tokenFromEnv: !!process.env.TARS_RENDERER_TOKEN,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function saveConfig(c) {
|
|
460
|
+
fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
461
|
+
fs.writeFileSync(configFile(), JSON.stringify(c, null, 2) + "\n", { mode: 0o600 });
|
|
462
|
+
try {
|
|
463
|
+
fs.chmodSync(configFile(), 0o600); // writeFile's mode only applies when creating; best effort on Windows
|
|
464
|
+
} catch {}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------------------------------------------------------------- token storage
|
|
468
|
+
//
|
|
469
|
+
// The token goes to the OS credential store when there is one, so config.json (which agents may read) holds no secret:
|
|
470
|
+
// the macOS Keychain, Windows DPAPI (encrypted for this user; the blob is kept in config.json as `tokenProtected`) or
|
|
471
|
+
// the Secret Service through secret-tool on Linux. Otherwise it stays in config.json (mode 0600). The token is always
|
|
472
|
+
// written to the helper's stdin, never put on a command line where `ps` would show it.
|
|
473
|
+
// TARS_RENDERER_TOKEN_STORE=file forces the file (CI, or a keyring that misbehaves).
|
|
474
|
+
|
|
475
|
+
const KEYRING_SERVICE = "tars-renderer";
|
|
476
|
+
const PS_ENCRYPT = "$t=[Console]::In.ReadLine(); ConvertTo-SecureString -String $t -AsPlainText -Force | ConvertFrom-SecureString";
|
|
477
|
+
const PS_DECRYPT =
|
|
478
|
+
"$b=[Console]::In.ReadLine(); $s=ConvertTo-SecureString -String $b; [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))";
|
|
479
|
+
|
|
480
|
+
/** The OS helpers, replaceable in tests. `run(cmd, args, stdin)` resolves to `{ status, stdout }` (status null: couldn't run). */
|
|
481
|
+
export const credentialStore = {
|
|
482
|
+
platform: process.platform,
|
|
483
|
+
run(cmd, args, input) {
|
|
484
|
+
const r = spawnSync(cmd, args, { input, encoding: "utf8", windowsHide: true, timeout: 20_000 });
|
|
485
|
+
return { status: r.error ? null : r.status, stdout: r.stdout ?? "" };
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
const powershell = (script, input) => credentialStore.run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], input);
|
|
489
|
+
|
|
490
|
+
/** The token config `f` points at, or undefined. */
|
|
491
|
+
export function readToken(f) {
|
|
492
|
+
const { run } = credentialStore;
|
|
493
|
+
const out = (r) => (r.status === 0 && r.stdout.trim()) || undefined;
|
|
494
|
+
if (typeof f.tokenProtected === "string" && f.tokenProtected.startsWith("dpapi:")) return out(powershell(PS_DECRYPT, f.tokenProtected.slice(6) + "\n"));
|
|
495
|
+
if (f.tokenStore === "keychain") return out(run("security", ["find-generic-password", "-s", f.tokenService ?? KEYRING_SERVICE, "-a", f.api, "-w"], ""));
|
|
496
|
+
if (f.tokenStore === "secret-tool") return out(run("secret-tool", ["lookup", "service", f.tokenService ?? KEYRING_SERVICE, "api", f.api], ""));
|
|
497
|
+
return f.token;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Stores the token for `api` in the best store that works (it's read back to check); returns the config fields to save. */
|
|
501
|
+
export function storeToken(api, token) {
|
|
502
|
+
const { platform, run } = credentialStore;
|
|
503
|
+
// Both go into a `security -i` command line, so only plain characters (tokens are base64url, the API a URL).
|
|
504
|
+
if (process.env.TARS_RENDERER_TOKEN_STORE !== "file" && /^[\w-]+$/.test(token) && /^[\w.:/-]+$/.test(api)) {
|
|
505
|
+
const works = (fields) => (readToken({ api, ...fields }) === token ? fields : null);
|
|
506
|
+
let fields = null;
|
|
507
|
+
if (platform === "win32") {
|
|
508
|
+
const r = powershell(PS_ENCRYPT, token + "\n");
|
|
509
|
+
if (r.status === 0 && /^[0-9a-f]+$/i.test(r.stdout.trim())) fields = works({ tokenProtected: `dpapi:${r.stdout.trim()}` });
|
|
510
|
+
} else if (platform === "darwin") {
|
|
511
|
+
// `security add-generic-password -w <token>` would put the token in argv; `security -i` reads the command from stdin.
|
|
512
|
+
const r = run("security", ["-i"], `add-generic-password -U -s ${KEYRING_SERVICE} -a ${api} -w ${token}\n`);
|
|
513
|
+
if (r.status === 0) fields = works({ tokenStore: "keychain" });
|
|
514
|
+
} else if (run("secret-tool", ["store", "--label=tars-renderer", "service", KEYRING_SERVICE, "api", api], token).status === 0) {
|
|
515
|
+
fields = works({ tokenStore: "secret-tool" });
|
|
516
|
+
}
|
|
517
|
+
if (fields) return fields;
|
|
518
|
+
}
|
|
519
|
+
return { token, tokenStore: "file" }; // "file" also stops the next commands from retrying the migration
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Removes the stored token; returns `f` without its token fields. */
|
|
523
|
+
export function forgetToken(f) {
|
|
524
|
+
const { token, tokenProtected, tokenStore, tokenService, ...rest } = f;
|
|
525
|
+
const service = tokenService ?? KEYRING_SERVICE;
|
|
526
|
+
if (tokenStore === "keychain") credentialStore.run("security", ["delete-generic-password", "-s", service, "-a", f.api], "");
|
|
527
|
+
if (tokenStore === "secret-tool") credentialStore.run("secret-tool", ["clear", "service", service, "api", f.api], "");
|
|
528
|
+
return rest;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export const tokenLocation = (f) =>
|
|
532
|
+
f.tokenProtected
|
|
533
|
+
? "Windows DPAPI (encrypted for your user account, in config.json)"
|
|
534
|
+
: f.tokenStore === "keychain"
|
|
535
|
+
? "macOS Keychain"
|
|
536
|
+
: f.tokenStore === "secret-tool"
|
|
537
|
+
? "Secret Service keyring (secret-tool)"
|
|
538
|
+
: f.token
|
|
539
|
+
? `${configFile()} (plain text, mode 0600)`
|
|
540
|
+
: "none";
|
|
541
|
+
|
|
542
|
+
/** A config from before token storage keeps its token in plain text: move it to the credential store. */
|
|
543
|
+
function migrateToken() {
|
|
544
|
+
try {
|
|
545
|
+
const f = readConfigFile();
|
|
546
|
+
if (!f?.token || !f.api || f.tokenStore || process.env.TARS_RENDERER_TOKEN_STORE === "file") return;
|
|
547
|
+
const { token, ...rest } = f;
|
|
548
|
+
saveConfig({ ...rest, ...storeToken(f.api, token) });
|
|
549
|
+
} catch {}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** JSON request to `<api>/api/v1<p>`. Throws ApiError with the server's `{ error }` as the message. */
|
|
553
|
+
async function request(cfg, method, p, body, { auth = true } = {}) {
|
|
554
|
+
if (!cfg.api || (auth && !cfg.token)) throw notLoggedIn();
|
|
555
|
+
// Checked on every call (config and TARS_RENDERER_API can change after login): tokens only over https or localhost.
|
|
556
|
+
if (!isSafeUrl(cfg.api)) throw new CliError(`Refusing to contact ${cfg.api}: the API address must use https://. Run tars-renderer login again.`);
|
|
557
|
+
let res;
|
|
558
|
+
try {
|
|
559
|
+
res = await fetch(`${cfg.api}/api/v1${p}`, {
|
|
560
|
+
method,
|
|
561
|
+
headers: {
|
|
562
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
563
|
+
...(auth ? { Authorization: `Bearer ${cfg.token}` } : {}),
|
|
564
|
+
},
|
|
565
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
566
|
+
});
|
|
567
|
+
} catch (e) {
|
|
568
|
+
throw new CliError(`Can't reach the API at ${cfg.api} (${e.cause?.code || e.cause?.message || e.message}). Check your connection and the API URL.`);
|
|
569
|
+
}
|
|
570
|
+
const text = await res.text();
|
|
571
|
+
let data = null;
|
|
572
|
+
try {
|
|
573
|
+
data = text ? JSON.parse(text) : null;
|
|
574
|
+
} catch {}
|
|
575
|
+
if (!res.ok) throw new ApiError(res.status, data, text && !data ? `The API answered HTTP ${res.status}: ${text.slice(0, 200)}` : undefined);
|
|
576
|
+
return data;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function needLogin() {
|
|
580
|
+
const cfg = loadConfig();
|
|
581
|
+
if (!cfg.api || !cfg.token) throw notLoggedIn();
|
|
582
|
+
return cfg;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const enc = encodeURIComponent;
|
|
586
|
+
const idOf = (row) => row.id ?? row._id;
|
|
587
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
588
|
+
|
|
589
|
+
// ---------------------------------------------------------------- confirm + Ctrl-C
|
|
590
|
+
|
|
591
|
+
let onCancel = null;
|
|
592
|
+
let cancelling = false;
|
|
593
|
+
async function interrupt() {
|
|
594
|
+
if (cancelling) process.exit(EXIT.CANCELLED); // second Ctrl-C: leave now
|
|
595
|
+
cancelling = true;
|
|
596
|
+
live.clear();
|
|
597
|
+
process.stderr.write("\nCancelled.\n");
|
|
598
|
+
try {
|
|
599
|
+
await onCancel?.();
|
|
600
|
+
} catch {}
|
|
601
|
+
process.exit(EXIT.CANCELLED);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Asks y/N on stderr. --yes skips; --json or no TTY without --yes exits 3 with `payload` on stdout. */
|
|
605
|
+
async function confirm(question, flags, payload) {
|
|
606
|
+
if (flags.yes) return true;
|
|
607
|
+
if (ctx.json || !process.stdin.isTTY) {
|
|
608
|
+
throw new CliError("Confirmation needed. Re-run with --yes to go ahead.", EXIT.CONFIRM, { needsConfirm: true, ...payload });
|
|
609
|
+
}
|
|
610
|
+
live.clear();
|
|
611
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
612
|
+
rl.on("SIGINT", () => {
|
|
613
|
+
rl.close();
|
|
614
|
+
interrupt();
|
|
615
|
+
});
|
|
616
|
+
const answer = await new Promise((r) => rl.question(`${question} ${dim("[y/N]")} `, r));
|
|
617
|
+
rl.close();
|
|
618
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Only plain http(s) URLs made of URL-safe characters are ever handed to the OS; anything else is just printed. */
|
|
622
|
+
export const isOpenableUrl = (href) =>
|
|
623
|
+
/^https?:\/\//.test(href) && /^[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+$/.test(href);
|
|
624
|
+
|
|
625
|
+
function openBrowser(url) {
|
|
626
|
+
let href;
|
|
627
|
+
try {
|
|
628
|
+
href = new URL(url).href;
|
|
629
|
+
} catch {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
// Only plain web URLs with URL-safe characters; anything else is printed, never opened.
|
|
633
|
+
if (!isOpenableUrl(href)) return;
|
|
634
|
+
// No shell anywhere: cmd.exe would expand %VAR% and & inside the URL (command injection).
|
|
635
|
+
const [cmd, args] =
|
|
636
|
+
process.platform === "win32"
|
|
637
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", href]]
|
|
638
|
+
: [process.platform === "darwin" ? "open" : "xdg-open", [href]];
|
|
639
|
+
try {
|
|
640
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true, shell: false });
|
|
641
|
+
child.on("error", () => {});
|
|
642
|
+
child.unref();
|
|
643
|
+
} catch {}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ---------------------------------------------------------------- commands: auth
|
|
647
|
+
|
|
648
|
+
// Tokens travel in headers, so only talk to https (or a local dev server over http).
|
|
649
|
+
export const isSafeUrl = (u) => /^https:\/\/[^\s/]+/.test(u) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/.test(u);
|
|
650
|
+
|
|
651
|
+
/** The portal publishes its API address at /cli/config.json, so users only ever need the portal URL. */
|
|
652
|
+
async function apiFromPortal(portal) {
|
|
653
|
+
let res;
|
|
654
|
+
try {
|
|
655
|
+
res = await fetch(`${portal}/cli/config.json`, { headers: { accept: "application/json" } });
|
|
656
|
+
} catch (e) {
|
|
657
|
+
throw new CliError(`Can't reach ${portal}: ${e.message}`);
|
|
658
|
+
}
|
|
659
|
+
const body = res.ok && /json/.test(res.headers.get("content-type") ?? "") ? await res.json().catch(() => null) : null;
|
|
660
|
+
if (!body?.api) throw new CliError(`${portal} doesn't look like a Tars Renderer (no /cli/config.json). Check the address.`);
|
|
661
|
+
return normalizeApi(body.api);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function cmdLogin(_, f) {
|
|
665
|
+
const prev = readConfigFile() ?? {};
|
|
666
|
+
// An address is only needed for another deployment (development, staging); normally the built-in portal is used.
|
|
667
|
+
let portal = (typeof _[0] === "string" ? _[0] : typeof f.portal === "string" ? f.portal : undefined)?.replace(/\/+$/, "");
|
|
668
|
+
let api = normalizeApi(typeof f.api === "string" ? f.api : process.env.TARS_RENDERER_API || "");
|
|
669
|
+
if (!portal && !api) portal = prev.portal || DEFAULT_PORTAL;
|
|
670
|
+
if (portal && !isSafeUrl(portal)) throw usage(`The portal address must start with https://, got "${portal}"`);
|
|
671
|
+
if (!api && portal) api = await apiFromPortal(portal);
|
|
672
|
+
api ||= normalizeApi(prev.api || "");
|
|
673
|
+
if (!api) throw usage(`Couldn't find the Tars Renderer API. Check your connection to ${DEFAULT_PORTAL}.`);
|
|
674
|
+
if (!isSafeUrl(api)) throw usage(`The API address must use https://, got "${api}"`);
|
|
675
|
+
const cfg = { api };
|
|
676
|
+
|
|
677
|
+
const start = await request(cfg, "POST", "/device/start", { clientName: `tars-renderer on ${os.hostname()}` }, { auth: false });
|
|
678
|
+
if (!/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(String(start.userCode))) throw new CliError("The API returned an invalid login code.");
|
|
679
|
+
// The approval link is built from the portal the user typed (or saved), not taken from the API response,
|
|
680
|
+
// so a wrong or hostile API can't send the browser somewhere else. A server link is only shown, never opened.
|
|
681
|
+
const knownPortal = portal ?? (prev.api === api ? prev.portal : undefined);
|
|
682
|
+
if (!portal && knownPortal) portal = knownPortal;
|
|
683
|
+
const trusted = portal && isSafeUrl(portal) ? `${portal}/cli/authorize?code=${encodeURIComponent(start.userCode)}` : null;
|
|
684
|
+
const shown = trusted ?? (typeof start.verifyUrl === "string" && isSafeUrl(start.verifyUrl) ? start.verifyUrl : null);
|
|
685
|
+
const url = shown;
|
|
686
|
+
|
|
687
|
+
info("");
|
|
688
|
+
info(` Your login code: ${bold(cyan(start.userCode))}`);
|
|
689
|
+
info("");
|
|
690
|
+
if (url) {
|
|
691
|
+
info(` Opening ${url}`);
|
|
692
|
+
info(" Check that the code matches, then click Approve.");
|
|
693
|
+
if (trusted) openBrowser(trusted);
|
|
694
|
+
} else {
|
|
695
|
+
info(` Open ${bold(start.verifyPath)} on Tars Renderer (https://<portal>${start.verifyPath}),`);
|
|
696
|
+
info(" sign in, check that the code matches and click Approve.");
|
|
697
|
+
info(dim(" (The server has no PORTAL_URL set. Pass --portal https://<portal> for a clickable link.)"));
|
|
698
|
+
}
|
|
699
|
+
info("");
|
|
700
|
+
|
|
701
|
+
const deadline = Date.now() + (start.expiresIn ?? 600) * 1000;
|
|
702
|
+
let interval = (start.interval ?? 3) * 1000;
|
|
703
|
+
const spin = spinner("Waiting for approval in the browser (Ctrl-C to cancel)");
|
|
704
|
+
let got;
|
|
705
|
+
try {
|
|
706
|
+
while (!got) {
|
|
707
|
+
await sleep(interval);
|
|
708
|
+
if (Date.now() > deadline) throw new CliError("The code expired before it was approved. Run tars-renderer login again.");
|
|
709
|
+
try {
|
|
710
|
+
got = await request(cfg, "POST", "/device/poll", { deviceCode: start.deviceCode }, { auth: false });
|
|
711
|
+
} catch (e) {
|
|
712
|
+
if (!(e instanceof ApiError)) throw e;
|
|
713
|
+
if (e.status === 428 || e.code === "authorization_pending") continue;
|
|
714
|
+
if (e.status === 429 || e.code === "slow_down") {
|
|
715
|
+
interval = Number.isFinite(e.body?.interval) ? e.body.interval * 1000 : interval + 5000;
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (e.code === "expired_token") throw new CliError("The code expired before it was approved. Run tars-renderer login again.");
|
|
719
|
+
if (e.code === "access_denied") throw new CliError("The login was denied in the browser.");
|
|
720
|
+
throw e;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
} finally {
|
|
724
|
+
spin.stop();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
forgetToken(prev); // an older login's keyring entry
|
|
728
|
+
const stored = storeToken(api, got.token);
|
|
729
|
+
saveConfig({ api, portal, email: got.email, ...stored });
|
|
730
|
+
const where = tokenLocation(stored);
|
|
731
|
+
const out = { email: got.email, name: got.name, api, portal: portal ?? null, token: maskToken(got.token), tokenExpiresAt: got.expiresAt, config: configFile(), tokenStorage: where };
|
|
732
|
+
done(out, () => {
|
|
733
|
+
say(`${green("✔")} Logged in as ${bold(got.email)}${got.name ? ` (${got.name})` : ""}`);
|
|
734
|
+
say(dim(` Token ${maskToken(got.token)} saved in ${where}${got.expiresAt ? `, valid until ${new Date(got.expiresAt).toISOString().slice(0, 10)}` : ""}`));
|
|
735
|
+
say(dim(" Next: tars-renderer projects"));
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Signs this device out: revokes the token on the server (all of the user's devices with --all), then removes it from
|
|
741
|
+
* the OS credential store and the config. --local skips the server (offline); --purge also deletes ~/.tars-renderer.
|
|
742
|
+
*/
|
|
743
|
+
async function cmdLogout(_, f) {
|
|
744
|
+
if (f.all && f.local) throw usage("--all needs the server, so it can't be combined with --local");
|
|
745
|
+
let cfg = null;
|
|
746
|
+
try {
|
|
747
|
+
cfg = loadConfig();
|
|
748
|
+
} catch {} // an unreadable config can still be purged
|
|
749
|
+
const signedIn = !!cfg?.token;
|
|
750
|
+
if (f.all && !signedIn) throw notLoggedIn();
|
|
751
|
+
|
|
752
|
+
let revoked = 0;
|
|
753
|
+
let serverError = null;
|
|
754
|
+
if (signedIn && !f.local) {
|
|
755
|
+
try {
|
|
756
|
+
revoked = (await request(cfg, "POST", "/logout", f.all ? { all: true } : {}))?.revoked ?? 1;
|
|
757
|
+
} catch (e) {
|
|
758
|
+
// A token that is already expired or revoked is fine: there's nothing left to revoke.
|
|
759
|
+
if (e.status !== 401) serverError = e.message;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (f.all && serverError) throw new CliError(`Couldn't sign out your other devices: ${serverError}. Nothing was changed; try again, or use "Sign out all devices" on the portal.`);
|
|
763
|
+
|
|
764
|
+
const file = readConfigFile();
|
|
765
|
+
if (file && (file.token || file.tokenProtected || file.tokenStore)) {
|
|
766
|
+
const { email, ...rest } = forgetToken(file);
|
|
767
|
+
saveConfig(rest);
|
|
768
|
+
}
|
|
769
|
+
if (f.purge) fs.rmSync(path.dirname(configFile()), { recursive: true, force: true });
|
|
770
|
+
|
|
771
|
+
const out = { ok: true, wasSignedIn: signedIn, revokedOnServer: revoked, localOnly: !!f.local, purged: !!f.purge, serverError };
|
|
772
|
+
done(out, () => {
|
|
773
|
+
if (!signedIn && !f.purge) return say("You're already signed out.");
|
|
774
|
+
if (f.all) say(`${green("✔")} Signed out ${plural(revoked, "device")} (all of yours, this one included).`);
|
|
775
|
+
else if (signedIn) say(`${green("✔")} Signed out.`);
|
|
776
|
+
if (f.purge) say(`${green("✔")} Removed ${path.dirname(configFile())} (settings and upload cache).`);
|
|
777
|
+
if (serverError) say(yellow(` Couldn't reach the server (${serverError}), so the sign-in is only removed here. Sign this device out on the portal's CLI & Agents page.`));
|
|
778
|
+
if (f.local && signedIn) say(dim(" The sign-in still works on the server until it expires; sign this device out on the portal to revoke it."));
|
|
779
|
+
if (cfg?.tokenFromEnv) say(yellow(" TARS_RENDERER_TOKEN is still set in your environment; unset it too."));
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async function cmdWhoami() {
|
|
784
|
+
const cfg = needLogin();
|
|
785
|
+
const me = await request(cfg, "GET", "/me");
|
|
786
|
+
const out = { ...me, api: cfg.api, portal: cfg.portal ?? null, token: maskToken(cfg.token), tokenFromEnv: cfg.tokenFromEnv };
|
|
787
|
+
done(out, () => {
|
|
788
|
+
say(`${bold(me.email)}${me.name ? ` (${me.name})` : ""}${me.isAdmin ? cyan(" · admin") : ""}`);
|
|
789
|
+
say(`${dim("API ")} ${cfg.api}`);
|
|
790
|
+
if (cfg.portal) say(`${dim("Portal ")} ${cfg.portal}`);
|
|
791
|
+
const exp = me.tokenExpiresAt ? `, valid until ${new Date(me.tokenExpiresAt).toISOString().slice(0, 10)}` : "";
|
|
792
|
+
say(`${dim("Token ")} ${maskToken(cfg.token)}${exp}${cfg.tokenFromEnv ? " (from TARS_RENDERER_TOKEN)" : ""}`);
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function cmdDoctor() {
|
|
797
|
+
const checks = [];
|
|
798
|
+
const add = (name, ok, detail) => checks.push({ name, ok, detail });
|
|
799
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
800
|
+
add("node", major >= 18, `Node ${process.versions.node}${major >= 18 ? "" : " (needs 18 or newer)"}`);
|
|
801
|
+
|
|
802
|
+
let file = null;
|
|
803
|
+
try {
|
|
804
|
+
file = readConfigFile();
|
|
805
|
+
const fromEnv = process.env.TARS_RENDERER_TOKEN ? "using TARS_RENDERER_TOKEN from the environment" : "";
|
|
806
|
+
add("config", !!file || !!fromEnv, file ? configFile() + (fromEnv ? ` (${fromEnv})` : "") : fromEnv || `No ${configFile()} (run tars-renderer login)`);
|
|
807
|
+
} catch (e) {
|
|
808
|
+
add("config", false, e.message);
|
|
809
|
+
}
|
|
810
|
+
const cfg = { ...(file ?? {}), api: normalizeApi(process.env.TARS_RENDERER_API || file?.api), token: process.env.TARS_RENDERER_TOKEN || (file ? readToken(file) : undefined) };
|
|
811
|
+
if (process.env.TARS_RENDERER_TOKEN) add("token store", true, "TARS_RENDERER_TOKEN environment variable");
|
|
812
|
+
else if (file && tokenLocation(file) !== "none") {
|
|
813
|
+
add("token store", !!cfg.token, cfg.token ? tokenLocation(file) : `${tokenLocation(file)}, but the token couldn't be read from it (run tars-renderer login)`);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
let exit = EXIT.OK;
|
|
817
|
+
if (!cfg.api) {
|
|
818
|
+
add("api", false, "Not logged in (run: tars-renderer login)");
|
|
819
|
+
exit = EXIT.AUTH;
|
|
820
|
+
} else {
|
|
821
|
+
try {
|
|
822
|
+
const r = await fetch(`${cfg.api}/api/v1/me`);
|
|
823
|
+
await r.text();
|
|
824
|
+
// Any answer but 404 means the routes exist (401 without a token is expected).
|
|
825
|
+
add("api", r.status !== 404, r.status === 404 ? `${cfg.api} has no /api/v1 routes (wrong URL, or the backend isn't deployed)` : `${cfg.api} answered`);
|
|
826
|
+
} catch (e) {
|
|
827
|
+
add("api", false, `Can't reach ${cfg.api} (${e.cause?.code || e.message})`);
|
|
828
|
+
}
|
|
829
|
+
if (!cfg.token) {
|
|
830
|
+
add("token", false, "Not logged in (run tars-renderer login)");
|
|
831
|
+
exit = EXIT.AUTH;
|
|
832
|
+
} else {
|
|
833
|
+
try {
|
|
834
|
+
const me = await request(cfg, "GET", "/me");
|
|
835
|
+
add("token", true, `${maskToken(cfg.token)} is valid${me.tokenExpiresAt ? ` until ${new Date(me.tokenExpiresAt).toISOString().slice(0, 10)}` : ""}`);
|
|
836
|
+
add("allowlist", true, `${me.email} is allowed${me.isAdmin ? " (admin)" : ""}`);
|
|
837
|
+
} catch (e) {
|
|
838
|
+
if (e.status === 403) {
|
|
839
|
+
add("token", true, `${maskToken(cfg.token)} is valid`);
|
|
840
|
+
add("allowlist", false, e.message);
|
|
841
|
+
} else {
|
|
842
|
+
add("token", false, e.message);
|
|
843
|
+
if (e.status === 401) exit = EXIT.AUTH;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const ok = checks.every((c) => c.ok);
|
|
849
|
+
done({ ok, checks }, () => {
|
|
850
|
+
const w = Math.max(...checks.map((c) => c.name.length));
|
|
851
|
+
for (const c of checks) say(`${c.ok ? green("✔") : red("✖")} ${c.name.padEnd(w)} ${c.detail}`);
|
|
852
|
+
});
|
|
853
|
+
if (!ok) info(red("Some checks failed."));
|
|
854
|
+
return ok ? EXIT.OK : exit || EXIT.ERROR;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ---------------------------------------------------------------- commands: projects
|
|
858
|
+
|
|
859
|
+
async function cmdProjects(_, f) {
|
|
860
|
+
const cfg = needLogin();
|
|
861
|
+
const rows = await request(cfg, "GET", `/projects${f.mine ? "?mine=1" : ""}`);
|
|
862
|
+
done(rows, () => {
|
|
863
|
+
if (!rows.length) return say("No projects yet. Create one: tars-renderer create <name>");
|
|
864
|
+
table(rows, [
|
|
865
|
+
["SLUG", (p) => bold(p.slug)],
|
|
866
|
+
["NAME", (p) => p.name],
|
|
867
|
+
["OWNER", (p) => p.ownerName],
|
|
868
|
+
["STATUS", (p) => statusColor(p.status) + (p.locked ? yellow(" (locked)") : "")],
|
|
869
|
+
["REMOTION", (p) => p.remotionVersion || "—"],
|
|
870
|
+
["FILES", (p) => fmtInt(p.fileCount), "right"],
|
|
871
|
+
["SIZE", (p) => fmtBytes(p.totalBytes), "right"],
|
|
872
|
+
["UPLOADED", (p) => when(p.lastUploadedAt)],
|
|
873
|
+
]);
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function cmdCreate([name], f) {
|
|
878
|
+
const cfg = needLogin();
|
|
879
|
+
if (f.keep !== undefined && !SITE_RETENTIONS.includes(f.keep)) throw usage(`--keep must be one of ${SITE_RETENTIONS.join(", ")}`);
|
|
880
|
+
const r = await request(cfg, "POST", "/projects", { name, ...(f.keep ? { siteRetention: f.keep } : {}) });
|
|
881
|
+
done(r, () => {
|
|
882
|
+
say(`${green("✔")} Created project ${bold(r.slug)} ${dim(`(${r.id})`)}`);
|
|
883
|
+
say(dim(` Next: npx remotion bundle src/index.ts --out-dir build && tars-renderer upload build --project ${r.slug}`));
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function getProject(cfg, ref) {
|
|
888
|
+
return request(cfg, "GET", `/projects/${enc(ref)}`);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function printComps(comps) {
|
|
892
|
+
if (!comps.length) return say("No compositions found.");
|
|
893
|
+
table(comps, [
|
|
894
|
+
["COMPOSITION", (c) => bold(c.compId)],
|
|
895
|
+
["SIZE", (c) => `${c.width}×${c.height}`, "right"],
|
|
896
|
+
["FPS", (c) => c.fps, "right"],
|
|
897
|
+
["DURATION", (c) => timecode(c.durationInFrames, c.fps), "right"],
|
|
898
|
+
["FRAMES", (c) => fmtInt(c.durationInFrames), "right"],
|
|
899
|
+
]);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function cmdComps([ref]) {
|
|
903
|
+
const cfg = needLogin();
|
|
904
|
+
const { project, compositions } = await getProject(cfg, ref);
|
|
905
|
+
done({ project, compositions }, () => {
|
|
906
|
+
say(dim(`${project.name} (${project.slug}) · Remotion ${project.remotionVersion || "?"}`));
|
|
907
|
+
printComps(compositions);
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async function cmdDeleteProject([ref], f) {
|
|
912
|
+
const cfg = needLogin();
|
|
913
|
+
if (!(await confirm(`Delete project ${bold(ref)} and all its uploaded files?`, f, { project: ref }))) throw new CliError("Cancelled; nothing was deleted.");
|
|
914
|
+
await request(cfg, "DELETE", `/projects/${enc(ref)}`);
|
|
915
|
+
done({ ok: true }, () => say(`${green("✔")} Deleted project ${ref}`));
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ---------------------------------------------------------------- upload
|
|
919
|
+
|
|
920
|
+
/** Every regular file under `root` (symlinks and .DS_Store/Thumbs.db skipped), with POSIX relative paths and the stat fields the hash cache checks. */
|
|
921
|
+
async function walk(root) {
|
|
922
|
+
const out = [];
|
|
923
|
+
async function go(dir) {
|
|
924
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
925
|
+
await Promise.all(
|
|
926
|
+
entries.map(async (d) => {
|
|
927
|
+
if (d.isSymbolicLink() || SKIP.has(d.name)) return;
|
|
928
|
+
const abs = path.join(dir, d.name);
|
|
929
|
+
if (d.isDirectory()) return go(abs);
|
|
930
|
+
if (!d.isFile()) return;
|
|
931
|
+
const st = await fsp.stat(abs);
|
|
932
|
+
out.push({ path: toPosixPath(path.relative(root, abs)), abs, size: st.size, mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs });
|
|
933
|
+
}),
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
await go(root);
|
|
937
|
+
return out.sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function hashFile(abs, onBytes) {
|
|
941
|
+
return new Promise((resolve, reject) => {
|
|
942
|
+
const h = crypto.createHash("sha256");
|
|
943
|
+
fs.createReadStream(abs, { highWaterMark: 1 << 20 })
|
|
944
|
+
.on("data", (c) => {
|
|
945
|
+
h.update(c);
|
|
946
|
+
onBytes(c.length);
|
|
947
|
+
})
|
|
948
|
+
.on("end", () => resolve(h.digest("hex")))
|
|
949
|
+
.on("error", reject);
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// SHA-256 cache in ~/.tars-renderer/hash-cache.json, so re-uploading an unchanged build skips hashing. Keyed by absolute
|
|
954
|
+
// path; an entry counts only when size, mtime and ctime all match. That is rsync's quick check plus ctime, which tools
|
|
955
|
+
// that restore mtimes can't set back. `upload --rehash` ignores it.
|
|
956
|
+
const hashCacheFile = () => path.join(configDir(), "hash-cache.json");
|
|
957
|
+
export const cacheHit = (entry, st) => !!entry && entry.size === st.size && entry.mtimeMs === st.mtimeMs && entry.ctimeMs === st.ctimeMs;
|
|
958
|
+
|
|
959
|
+
function readHashCache() {
|
|
960
|
+
try {
|
|
961
|
+
const c = JSON.parse(fs.readFileSync(hashCacheFile(), "utf8"));
|
|
962
|
+
return c?.v === 1 && c.entries && typeof c.entries === "object" ? c.entries : {};
|
|
963
|
+
} catch {
|
|
964
|
+
return {};
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Keeps the HASH_CACHE_MAX most recently used entries (about 10 MB) and replaces the file atomically. */
|
|
969
|
+
function writeHashCache(entries) {
|
|
970
|
+
const keys = Object.keys(entries);
|
|
971
|
+
if (keys.length > HASH_CACHE_MAX) {
|
|
972
|
+
keys.sort((a, b) => entries[a].used - entries[b].used);
|
|
973
|
+
for (const k of keys.slice(0, keys.length - HASH_CACHE_MAX)) delete entries[k];
|
|
974
|
+
}
|
|
975
|
+
const tmp = `${hashCacheFile()}.${process.pid}.tmp`;
|
|
976
|
+
try {
|
|
977
|
+
fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
978
|
+
fs.writeFileSync(tmp, JSON.stringify({ v: 1, entries }), { mode: 0o600 });
|
|
979
|
+
fs.renameSync(tmp, hashCacheFile()); // a second CLI or a crash never leaves half a file
|
|
980
|
+
} catch {
|
|
981
|
+
fs.rmSync(tmp, { force: true }); // a cache that can't be saved only costs a re-hash next time
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** PUTs bytes [start, start+length) of a file with an exact Content-Length (S3 rejects chunked bodies). Resolves with the ETag. */
|
|
986
|
+
async function putRange(url, abs, start, length, headers, signal, onBytes) {
|
|
987
|
+
const { ctrl, unlink } = linked(signal);
|
|
988
|
+
const stall = stallTimer(ctrl, "upload progress");
|
|
989
|
+
async function* body() {
|
|
990
|
+
for await (const chunk of fs.createReadStream(abs, { start, end: start + length - 1, highWaterMark: 1 << 20 })) {
|
|
991
|
+
onBytes(chunk.length);
|
|
992
|
+
stall.alive();
|
|
993
|
+
yield chunk;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
const res = await fetch(url, {
|
|
998
|
+
method: "PUT",
|
|
999
|
+
body: length ? body() : "",
|
|
1000
|
+
duplex: "half",
|
|
1001
|
+
headers: { ...headers, "Content-Length": String(length) },
|
|
1002
|
+
signal: ctrl.signal,
|
|
1003
|
+
});
|
|
1004
|
+
const text = await res.text();
|
|
1005
|
+
// S3 answers a socket that went quiet with 400 RequestTimeout, which is safe to retry.
|
|
1006
|
+
if (!res.ok) throw new TransferError(`S3 returned ${res.status}: ${text.slice(0, 300)}`, res.status, isRetryableStatus(res.status) || /RequestTimeout/.test(text));
|
|
1007
|
+
return res.headers.get("etag");
|
|
1008
|
+
} finally {
|
|
1009
|
+
stall.stop();
|
|
1010
|
+
unlink();
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Port of uploadAll in src/upload/uploader.ts: one queue for whole files and multipart parts, the largest requests
|
|
1016
|
+
* first while SMALL_SLOTS slots send small files, each request retried on its own with backoff. The first fatal
|
|
1017
|
+
* failure (or `deps.signal`) stops everything in flight, and multipart uploads that didn't complete are aborted.
|
|
1018
|
+
*/
|
|
1019
|
+
async function uploadAll(items, localByPath, deps) {
|
|
1020
|
+
const ctrl = new AbortController();
|
|
1021
|
+
const cancel = () => ctrl.abort(deps.signal.reason);
|
|
1022
|
+
if (deps.signal.aborted) cancel();
|
|
1023
|
+
deps.signal.addEventListener("abort", cancel);
|
|
1024
|
+
const bytesTotal = sum(items);
|
|
1025
|
+
const meter = rateMeter();
|
|
1026
|
+
let bytesDone = 0;
|
|
1027
|
+
let filesDone = 0;
|
|
1028
|
+
let lastReport = 0;
|
|
1029
|
+
let current = "";
|
|
1030
|
+
const tick = (delta) => {
|
|
1031
|
+
bytesDone += delta;
|
|
1032
|
+
meter.update(bytesDone);
|
|
1033
|
+
deps.onProgress({ filesDone, bytesDone, bytesTotal, speed: meter.rate(), etaMs: meter.etaMs(bytesTotal - bytesDone), current });
|
|
1034
|
+
const now = Date.now();
|
|
1035
|
+
if (now - lastReport >= 1000) {
|
|
1036
|
+
lastReport = now;
|
|
1037
|
+
deps.report({ filesDone, bytesDone });
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
// The current link of every request: "<path>" for a whole file, "<path>#<part index>" for a multipart part.
|
|
1041
|
+
const urls = new Map();
|
|
1042
|
+
for (const i of items) {
|
|
1043
|
+
if ("url" in i) urls.set(i.path, i.url);
|
|
1044
|
+
else i.multipart.partUrls.forEach((u, n) => urls.set(`${i.path}#${n}`, u));
|
|
1045
|
+
}
|
|
1046
|
+
const finished = new Set();
|
|
1047
|
+
// Links last 1 h. The first "expired" 403 renews the links of every unfinished file at once; requests that fail on
|
|
1048
|
+
// the old links meanwhile wait for that same renewal (generation tells them it already happened).
|
|
1049
|
+
let generation = 0;
|
|
1050
|
+
let renewing = null;
|
|
1051
|
+
const renew = (seen) =>
|
|
1052
|
+
seen !== generation
|
|
1053
|
+
? Promise.resolve()
|
|
1054
|
+
: (renewing ??= (async () => {
|
|
1055
|
+
const paths = items.map((i) => i.path).filter((p) => !finished.has(p));
|
|
1056
|
+
for (let i = 0; i < paths.length; i += 500) {
|
|
1057
|
+
for (const r of await deps.refreshUrls(paths.slice(i, i + 500))) {
|
|
1058
|
+
if ("url" in r) urls.set(r.path, r.url);
|
|
1059
|
+
else r.partUrls.forEach((u, n) => urls.set(`${r.path}#${n}`, u));
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
generation++;
|
|
1063
|
+
})().finally(() => (renewing = null)));
|
|
1064
|
+
// One request with retries. A failed try takes its bytes back off the bar, since the retry sends them again.
|
|
1065
|
+
const put = (key, local, start, length, headers) =>
|
|
1066
|
+
withRetry(async () => {
|
|
1067
|
+
const seen = generation;
|
|
1068
|
+
let sent = 0;
|
|
1069
|
+
try {
|
|
1070
|
+
const etag = await putRange(urls.get(key), local.abs, start, length, headers, ctrl.signal, (d) => {
|
|
1071
|
+
sent += d;
|
|
1072
|
+
tick(d);
|
|
1073
|
+
});
|
|
1074
|
+
tick(length - sent);
|
|
1075
|
+
return etag;
|
|
1076
|
+
} catch (e) {
|
|
1077
|
+
tick(-sent);
|
|
1078
|
+
if (!deps.refreshUrls || !(e instanceof TransferError && e.status === 403 && /expired/i.test(e.message))) throw e;
|
|
1079
|
+
await renew(seen);
|
|
1080
|
+
throw new TransferError("The upload link expired; retrying with a new one", 0);
|
|
1081
|
+
}
|
|
1082
|
+
}, ctrl.signal);
|
|
1083
|
+
const headers = (contentType) => (deps.acl ? { "Content-Type": contentType, "x-amz-acl": deps.acl } : { "Content-Type": contentType });
|
|
1084
|
+
// The hash describes the file as it was hashed: never send other bytes under it (e.g. a rebuild mid-upload).
|
|
1085
|
+
const unchanged = async (path, local) => {
|
|
1086
|
+
const st = await fsp.stat(local.abs);
|
|
1087
|
+
if (st.size !== local.size || st.mtimeMs !== local.mtimeMs) throw new TransferError(`${path} changed since it was hashed`, 0, false);
|
|
1088
|
+
current = path;
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
// prepare already created these multipart uploads; any that don't complete must be aborted.
|
|
1092
|
+
const open = new Map(items.filter((i) => i.multipart).map((i) => [i.path, i.multipart.s3UploadId]));
|
|
1093
|
+
try {
|
|
1094
|
+
const jobs = [];
|
|
1095
|
+
for (const item of items) {
|
|
1096
|
+
const local = localByPath.get(item.path);
|
|
1097
|
+
if (!local) throw new Error(`File missing locally: ${item.path}`);
|
|
1098
|
+
if ("url" in item) {
|
|
1099
|
+
jobs.push({
|
|
1100
|
+
size: item.size,
|
|
1101
|
+
run: async () => {
|
|
1102
|
+
await unchanged(item.path, local);
|
|
1103
|
+
await put(item.path, local, 0, item.size, headers(item.contentType));
|
|
1104
|
+
finished.add(item.path);
|
|
1105
|
+
filesDone++;
|
|
1106
|
+
tick(0);
|
|
1107
|
+
},
|
|
1108
|
+
});
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
const { s3UploadId, partSize, partUrls } = item.multipart;
|
|
1112
|
+
const ranges = partRanges(item.size, partSize, partUrls.length);
|
|
1113
|
+
const parts = new Array(ranges.length);
|
|
1114
|
+
let left = ranges.length;
|
|
1115
|
+
ranges.forEach((r, i) =>
|
|
1116
|
+
jobs.push({
|
|
1117
|
+
size: r.end - r.start + 1,
|
|
1118
|
+
run: async () => {
|
|
1119
|
+
await unchanged(item.path, local);
|
|
1120
|
+
// Parts carry no Content-Type/ACL: those were set when the multipart upload was created.
|
|
1121
|
+
const etag = await put(`${item.path}#${i}`, local, r.start, r.end - r.start + 1, {});
|
|
1122
|
+
if (!etag) throw new TransferError(`S3 returned no ETag for part ${i + 1} of ${item.path}`, 0, false);
|
|
1123
|
+
parts[i] = { PartNumber: i + 1, ETag: etag };
|
|
1124
|
+
if (--left) return;
|
|
1125
|
+
await deps.completeMultipart({ path: item.path, s3UploadId, parts });
|
|
1126
|
+
open.delete(item.path);
|
|
1127
|
+
finished.add(item.path);
|
|
1128
|
+
filesDone++;
|
|
1129
|
+
tick(0);
|
|
1130
|
+
},
|
|
1131
|
+
}),
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
await runPool(UPLOAD_AT_ONCE, largestFirst(jobs, SMALL_SLOTS), ctrl);
|
|
1135
|
+
} catch (e) {
|
|
1136
|
+
ctrl.abort();
|
|
1137
|
+
await Promise.all([...open].map(([path, s3UploadId]) => deps.abortMultipart({ path, s3UploadId }).catch(() => {})));
|
|
1138
|
+
throw e;
|
|
1139
|
+
} finally {
|
|
1140
|
+
deps.signal.removeEventListener("abort", cancel);
|
|
1141
|
+
}
|
|
1142
|
+
deps.report({ filesDone, bytesDone });
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const sum = (xs) => xs.reduce((n, x) => n + x.size, 0);
|
|
1146
|
+
const plural = (n, word) => `${fmtInt(n)} ${word}${n === 1 ? "" : "s"}`;
|
|
1147
|
+
|
|
1148
|
+
async function cmdUpload([dir], f) {
|
|
1149
|
+
if (typeof f.project !== "string") throw usage("Pass the project: tars-renderer upload <buildDir> --project <slug or id>");
|
|
1150
|
+
const cfg = needLogin();
|
|
1151
|
+
const root = path.resolve(dir);
|
|
1152
|
+
const st = await fsp.stat(root).catch(() => null);
|
|
1153
|
+
if (!st?.isDirectory()) throw new CliError(`${root} is not a folder. Pass the build folder that \`npx remotion bundle\` created.`);
|
|
1154
|
+
|
|
1155
|
+
const files = await walk(root);
|
|
1156
|
+
const problem = folderProblem(files.map((x) => x.path));
|
|
1157
|
+
if (problem) throw new CliError(problem);
|
|
1158
|
+
|
|
1159
|
+
const version =
|
|
1160
|
+
typeof f.version === "string" ? f.version.trim() : detectRemotionVersion(await fsp.readFile(path.join(root, "bundle.js"), "utf8"));
|
|
1161
|
+
if (!version) throw new CliError("Couldn't find the Remotion version in bundle.js. Pass --version 4.0.x (it's next to remotion in your package.json).");
|
|
1162
|
+
const total = sum(files);
|
|
1163
|
+
info(`${bold(path.basename(root))}: ${plural(files.length, "file")}, ${fmtBytes(total)} · Remotion ${version}${f.version ? " (from --version)" : ""}`);
|
|
1164
|
+
|
|
1165
|
+
// Hash: streaming SHA-256, files the cache already knows are skipped, the rest biggest first so a 1 GB file never
|
|
1166
|
+
// starts last. Hashing finishes before prepare on purpose: the server diffs the whole manifest (that's how it finds
|
|
1167
|
+
// removed files) and the plan is confirmed before anything is sent; at ~1 GB/s it costs seconds, not minutes.
|
|
1168
|
+
const cache = readHashCache();
|
|
1169
|
+
const now = Date.now();
|
|
1170
|
+
const manifest = new Array(files.length);
|
|
1171
|
+
const todo = [];
|
|
1172
|
+
files.forEach((x, i) => {
|
|
1173
|
+
const hit = !f.rehash && cacheHit(cache[x.abs], x);
|
|
1174
|
+
if (hit) cache[x.abs].used = now;
|
|
1175
|
+
if (hit) manifest[i] = { path: x.path, size: x.size, sha256: cache[x.abs].sha256 };
|
|
1176
|
+
else todo.push(i);
|
|
1177
|
+
});
|
|
1178
|
+
todo.sort((a, b) => files[b].size - files[a].size);
|
|
1179
|
+
const toHash = todo.reduce((n, i) => n + files[i].size, 0);
|
|
1180
|
+
let hashedBytes = 0;
|
|
1181
|
+
let hashedFiles = 0;
|
|
1182
|
+
let next = 0;
|
|
1183
|
+
const hashStarted = Date.now();
|
|
1184
|
+
await runPool(
|
|
1185
|
+
HASH_AT_ONCE,
|
|
1186
|
+
() => {
|
|
1187
|
+
if (next >= todo.length) return undefined;
|
|
1188
|
+
const i = todo[next++];
|
|
1189
|
+
const x = files[i];
|
|
1190
|
+
return async () => {
|
|
1191
|
+
const sha256 = await hashFile(x.abs, (n) => {
|
|
1192
|
+
hashedBytes += n;
|
|
1193
|
+
live.update(`Hashing ${fmtInt(hashedFiles)}/${fmtInt(todo.length)} files ${fmtBytes(hashedBytes)} / ${fmtBytes(toHash)}`);
|
|
1194
|
+
});
|
|
1195
|
+
manifest[i] = { path: x.path, size: x.size, sha256 };
|
|
1196
|
+
cache[x.abs] = { size: x.size, mtimeMs: x.mtimeMs, ctimeMs: x.ctimeMs, sha256, used: now };
|
|
1197
|
+
hashedFiles++;
|
|
1198
|
+
};
|
|
1199
|
+
},
|
|
1200
|
+
new AbortController(),
|
|
1201
|
+
);
|
|
1202
|
+
live.clear();
|
|
1203
|
+
writeHashCache(cache);
|
|
1204
|
+
info(dim(` Hashed ${plural(todo.length, "file")} (${fmtBytes(toHash)}) in ${fmtDuration(Date.now() - hashStarted)}; ${fmtInt(files.length - todo.length)} unchanged since the last hash`));
|
|
1205
|
+
|
|
1206
|
+
const prep = await request(cfg, "POST", `/projects/${enc(f.project)}/upload/prepare`, {
|
|
1207
|
+
remotionVersion: version,
|
|
1208
|
+
...(typeof f.entry === "string" ? { entryHint: f.entry } : {}),
|
|
1209
|
+
files: manifest,
|
|
1210
|
+
});
|
|
1211
|
+
// prepare locks the project; release it if we stop before finishing.
|
|
1212
|
+
let failed = false;
|
|
1213
|
+
const fail = (error) => (failed ? Promise.resolve() : ((failed = true), request(cfg, "POST", `/uploads/${enc(prep.uploadId)}/fail`, { error }).catch(() => {})));
|
|
1214
|
+
onCancel = () => fail("Cancelled before upload");
|
|
1215
|
+
|
|
1216
|
+
const up = { files: prep.toUpload.length, bytes: sum(prep.toUpload) };
|
|
1217
|
+
const same = { files: prep.unchanged.length, bytes: sum(prep.unchanged) };
|
|
1218
|
+
const plan = { project: f.project, remotionVersion: version, toUpload: up, unchanged: same, removed: prep.removed.length };
|
|
1219
|
+
const changed = up.files > 0 || prep.removed.length > 0;
|
|
1220
|
+
info(` New or changed ${plural(up.files, "file").padEnd(12)} ${fmtBytes(up.bytes)}`);
|
|
1221
|
+
info(` Unchanged ${plural(same.files, "file").padEnd(12)} skipped`);
|
|
1222
|
+
info(` Removed ${plural(prep.removed.length, "file")}`);
|
|
1223
|
+
|
|
1224
|
+
if (!changed) info(green("Everything is already uploaded — 0 bytes to send"));
|
|
1225
|
+
else {
|
|
1226
|
+
let ok;
|
|
1227
|
+
try {
|
|
1228
|
+
ok = await confirm(up.files ? `Upload ${plural(up.files, "file")} (${fmtBytes(up.bytes)}) to ${f.project}?` : `Remove ${plural(prep.removed.length, "old file")} from ${f.project}?`, f, { plan });
|
|
1229
|
+
} catch (e) {
|
|
1230
|
+
await fail("Cancelled before upload");
|
|
1231
|
+
throw e;
|
|
1232
|
+
}
|
|
1233
|
+
if (!ok) {
|
|
1234
|
+
await fail("Cancelled before upload");
|
|
1235
|
+
throw new CliError("Cancelled; nothing was uploaded.");
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const ctrl = new AbortController();
|
|
1240
|
+
const started = Date.now();
|
|
1241
|
+
const flow = (async () => {
|
|
1242
|
+
try {
|
|
1243
|
+
await uploadAll(prep.toUpload, new Map(files.map((x) => [x.path, x])), {
|
|
1244
|
+
acl: prep.acl,
|
|
1245
|
+
signal: ctrl.signal,
|
|
1246
|
+
completeMultipart: (a) => request(cfg, "POST", `/projects/${enc(f.project)}/upload/complete-multipart`, a),
|
|
1247
|
+
abortMultipart: (a) => request(cfg, "POST", `/projects/${enc(f.project)}/upload/abort-multipart`, a),
|
|
1248
|
+
refreshUrls: async (paths) => (await request(cfg, "POST", `/uploads/${enc(prep.uploadId)}/refresh-urls`, { paths })).items,
|
|
1249
|
+
report: (x) => void request(cfg, "POST", `/uploads/${enc(prep.uploadId)}/progress`, x).catch(() => {}),
|
|
1250
|
+
onProgress: ({ filesDone, bytesDone, speed, etaMs, current }) => {
|
|
1251
|
+
const pct = up.bytes ? Math.floor((bytesDone / up.bytes) * 100) : 100;
|
|
1252
|
+
const rate = speed ? ` ${fmtBytes(speed)}/s` : "";
|
|
1253
|
+
const left = etaMs != null ? ` ${fmtDuration(etaMs)} left` : "";
|
|
1254
|
+
live.update(`Uploading ${filesDone}/${up.files} files ${fmtBytes(bytesDone)} / ${fmtBytes(up.bytes)} ${pct}%${rate}${left} ${current}`);
|
|
1255
|
+
},
|
|
1256
|
+
});
|
|
1257
|
+
live.clear();
|
|
1258
|
+
if (changed) info("Finishing (reading compositions)…");
|
|
1259
|
+
return await request(cfg, "POST", `/uploads/${enc(prep.uploadId)}/finish`, { files: manifest });
|
|
1260
|
+
} catch (e) {
|
|
1261
|
+
live.clear();
|
|
1262
|
+
const msg = ctrl.signal.aborted ? "Upload cancelled" : e.message;
|
|
1263
|
+
await fail(msg);
|
|
1264
|
+
throw e instanceof CliError ? e : new CliError(`${msg}. Run the same command again to retry: unchanged files, and big files that finished, are skipped.`);
|
|
1265
|
+
}
|
|
1266
|
+
})();
|
|
1267
|
+
onCancel = async () => {
|
|
1268
|
+
ctrl.abort();
|
|
1269
|
+
await flow.catch(() => {});
|
|
1270
|
+
};
|
|
1271
|
+
const fin = await flow;
|
|
1272
|
+
onCancel = null;
|
|
1273
|
+
|
|
1274
|
+
let compositions = [];
|
|
1275
|
+
try {
|
|
1276
|
+
compositions = (await getProject(cfg, f.project)).compositions ?? [];
|
|
1277
|
+
} catch (e) {
|
|
1278
|
+
warn(`Uploaded, but couldn't list the compositions: ${e.message}`);
|
|
1279
|
+
}
|
|
1280
|
+
done({ ...plan, uploadId: prep.uploadId, compositionCount: fin.compositions, compositionsError: fin.compositionsError ?? null, compositions }, () => {
|
|
1281
|
+
say(`${green("✔")} Uploaded to ${bold(f.project)}${changed ? ` in ${fmtDuration(Date.now() - started)}` : ""} · ${plural(fin.compositions, "composition")}`);
|
|
1282
|
+
if (fin.compositionsError) say(yellow(` Couldn't read compositions: ${fin.compositionsError}`));
|
|
1283
|
+
printComps(compositions);
|
|
1284
|
+
if (compositions[0]) say(dim(`\n Next: tars-renderer render ${f.project} ${compositions[0].compId} --res 720p --quality draft`));
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// ---------------------------------------------------------------- render, watch, download
|
|
1289
|
+
|
|
1290
|
+
const RENDER_FLAGS = ["res=", "quality=", "frames=", "seconds=", "muted", "keep="];
|
|
1291
|
+
|
|
1292
|
+
async function estimateFor(cfg, ref, compId, f) {
|
|
1293
|
+
let comp;
|
|
1294
|
+
if (f.seconds !== undefined) {
|
|
1295
|
+
const { compositions } = await getProject(cfg, ref);
|
|
1296
|
+
comp = compositions.find((c) => c.compId === compId);
|
|
1297
|
+
if (!comp) throw new CliError(`No composition "${compId}" in ${ref}. Available: ${compositions.map((c) => c.compId).join(", ") || "none"}`);
|
|
1298
|
+
}
|
|
1299
|
+
const params = buildParams(f, comp);
|
|
1300
|
+
const est = await request(cfg, "POST", "/renders/estimate", { projectRef: ref, compId, params });
|
|
1301
|
+
return { params, est };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function printEstimate(est, ref, compId) {
|
|
1305
|
+
const b = est.budget ?? {};
|
|
1306
|
+
info(`${bold(compId)} in ${ref}`);
|
|
1307
|
+
info(` Output ${est.width}×${est.height} · ${fmtInt(est.frames)} frames`);
|
|
1308
|
+
info(` Estimate ${bold(fmtUsd(est.estimateUsd))}`);
|
|
1309
|
+
if (b.spentThisMonthUsd !== undefined) {
|
|
1310
|
+
info(` Budget ${fmtUsd(b.spentThisMonthUsd)}${b.budgetUsd != null ? ` of ${fmtUsd(b.budgetUsd)}` : ""} spent this month · ${plural(b.activeCount ?? 0, "active render")} of yours`);
|
|
1311
|
+
}
|
|
1312
|
+
if (est.blockedReason) info(red(` Blocked ${est.blockedReason}`));
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
async function cmdEstimate([ref, compId], f) {
|
|
1316
|
+
const cfg = needLogin();
|
|
1317
|
+
const { params, est } = await estimateFor(cfg, ref, compId, f);
|
|
1318
|
+
done({ ...est, params }, () => {
|
|
1319
|
+
printEstimate(est, ref, compId);
|
|
1320
|
+
if (!est.blockedReason) say(dim(` Render it: tars-renderer render ${ref} ${compId} [same options]`));
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
async function cmdRender([ref, compId], f) {
|
|
1325
|
+
const cfg = needLogin();
|
|
1326
|
+
const { params, est } = await estimateFor(cfg, ref, compId, f);
|
|
1327
|
+
printEstimate(est, ref, compId);
|
|
1328
|
+
if (est.blockedReason) throw new CliError(est.blockedReason, EXIT.ERROR, { estimate: est });
|
|
1329
|
+
const big = est.needsConfirm ? yellow(" (above the $5 confirm limit)") : "";
|
|
1330
|
+
if (!(await confirm(`Start this render for ${fmtUsd(est.estimateUsd)}${big}?`, f, { estimate: est, params }))) throw new CliError("Cancelled; nothing was rendered.");
|
|
1331
|
+
|
|
1332
|
+
let id;
|
|
1333
|
+
try {
|
|
1334
|
+
({ id } = await request(cfg, "POST", "/renders", { projectRef: ref, compId, params, confirmed: true }));
|
|
1335
|
+
} catch (e) {
|
|
1336
|
+
if (e.code !== "confirm_required") throw e;
|
|
1337
|
+
// The server's estimate moved past the confirm limit: ask again.
|
|
1338
|
+
const usd = e.body?.estimateUsd ?? est.estimateUsd;
|
|
1339
|
+
if (!(await confirm(`The server needs confirmation: this render costs ${fmtUsd(usd)}. Start it anyway?`, f, { estimate: { ...est, estimateUsd: usd }, params })))
|
|
1340
|
+
throw new CliError("Cancelled; nothing was rendered.");
|
|
1341
|
+
({ id } = await request(cfg, "POST", "/renders", { projectRef: ref, compId, params, confirmed: true }));
|
|
1342
|
+
}
|
|
1343
|
+
info(`${green("✔")} Render started: ${bold(id)}`);
|
|
1344
|
+
|
|
1345
|
+
const out = { id, estimate: est, params };
|
|
1346
|
+
if (f.watch || f.download) {
|
|
1347
|
+
out.render = await watchRender(cfg, id);
|
|
1348
|
+
if (f.download) out.download = await downloadRender(cfg, id, f.download === true ? undefined : f.download);
|
|
1349
|
+
}
|
|
1350
|
+
done(out, () => {
|
|
1351
|
+
if (out.download) say(`${green("✔")} Saved ${out.download.path} (${fmtBytes(out.download.bytes)})`);
|
|
1352
|
+
else if (out.render) say(dim(` Download: tars-renderer download ${id}`));
|
|
1353
|
+
else say(dim(` Follow it: tars-renderer watch ${id}`));
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/** Polls every 2 s, drawing the chunk strip. Returns the final row when done; throws when it failed. */
|
|
1358
|
+
async function watchRender(cfg, id) {
|
|
1359
|
+
let lastKey = "";
|
|
1360
|
+
let misses = 0;
|
|
1361
|
+
for (;;) {
|
|
1362
|
+
let r;
|
|
1363
|
+
try {
|
|
1364
|
+
r = await request(cfg, "GET", `/renders/${enc(id)}`);
|
|
1365
|
+
misses = 0;
|
|
1366
|
+
} catch (e) {
|
|
1367
|
+
if (e instanceof ApiError || ++misses >= 8) throw e;
|
|
1368
|
+
warn(`${e.message} (retrying)`);
|
|
1369
|
+
await sleep(Math.min(30_000, POLL_MS * 2 ** misses)); // back off while the network is down: ~2.5 min in total
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
const line = progressLine(r);
|
|
1373
|
+
const key = `${r.status}|${Math.floor((r.progress ?? 0) * 100)}`;
|
|
1374
|
+
if (process.stderr.isTTY) live.update(line, true);
|
|
1375
|
+
else if (key !== lastKey) info(line);
|
|
1376
|
+
lastKey = key;
|
|
1377
|
+
if (!ACTIVE.has(r.status)) {
|
|
1378
|
+
live.clear();
|
|
1379
|
+
if (process.stderr.isTTY) info(line);
|
|
1380
|
+
if (r.status === "done") return r;
|
|
1381
|
+
const why = r.status === "failed" ? r.error || "no error message" : `the render is ${r.status}`;
|
|
1382
|
+
throw new CliError(`Render ${r.status}: ${why}`, EXIT.ERROR, { render: r });
|
|
1383
|
+
}
|
|
1384
|
+
await sleep(POLL_MS);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function describeRender(r) {
|
|
1389
|
+
say(`${bold(idOf(r))} ${statusColor(r.status)}`);
|
|
1390
|
+
say(` ${dim("Project ")} ${r.projectName ?? ""}${r.projectSlug ? ` (${r.projectSlug})` : ""} · ${r.compId}`);
|
|
1391
|
+
say(` ${dim("Output ")} ${r.preset ?? ""} · ${r.width}×${r.height}${r.outputBytes ? ` · ${fmtBytes(r.outputBytes)}` : ""}`);
|
|
1392
|
+
say(` ${dim("Progress")} ${progressLine(r)}`);
|
|
1393
|
+
if (r.expiresAt) say(` ${dim("Expires ")} ${new Date(r.expiresAt).toISOString().slice(0, 16).replace("T", " ")} UTC`);
|
|
1394
|
+
if (r.error) say(` ${dim("Error ")} ${red(r.error)}`);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
async function cmdStatus([id]) {
|
|
1398
|
+
const cfg = needLogin();
|
|
1399
|
+
const r = await request(cfg, "GET", `/renders/${enc(id)}`);
|
|
1400
|
+
done(r, () => {
|
|
1401
|
+
describeRender(r);
|
|
1402
|
+
if (ACTIVE.has(r.status)) say(dim(` Follow it: tars-renderer watch ${id}`));
|
|
1403
|
+
if (r.status === "done") say(dim(` Download: tars-renderer download ${id}`));
|
|
1404
|
+
});
|
|
1405
|
+
return r.status === "failed" ? EXIT.ERROR : EXIT.OK;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
async function cmdWatch([id]) {
|
|
1409
|
+
const cfg = needLogin();
|
|
1410
|
+
const r = await watchRender(cfg, id);
|
|
1411
|
+
done(r, () => {
|
|
1412
|
+
say(`${green("✔")} Done in ${fmtDuration(r.finishedAt - r.startedAt)} · ${fmtUsd(r.costUsd)}${r.outputBytes ? ` · ${fmtBytes(r.outputBytes)}` : ""}`);
|
|
1413
|
+
say(dim(` Download: tars-renderer download ${id}`));
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* Downloads a render with up to DOWNLOAD_AT_ONCE ranged GETs written at their offsets into a preallocated `.part` file.
|
|
1419
|
+
* One stream from far away (India -> us-east-1) is capped by a single TCP window; several connections fill the link.
|
|
1420
|
+
* Each range is retried on its own from where it stopped, an expired link is renewed, and a server that ignores
|
|
1421
|
+
* Range gets one plain stream. Checks the size, and the MD5 when the ETag is one (single-PUT objects; multipart
|
|
1422
|
+
* ETags are an MD5 of part MD5s, so those are only size-checked).
|
|
1423
|
+
*/
|
|
1424
|
+
async function downloadRender(cfg, id, out) {
|
|
1425
|
+
const link = () => request(cfg, "POST", `/renders/${enc(id)}/download-url`, {});
|
|
1426
|
+
let { url, fileName } = await link();
|
|
1427
|
+
const safeName = path.basename(String(fileName || `${id}.mp4`)); // never let the server pick a directory
|
|
1428
|
+
let dest = path.resolve(out ?? safeName);
|
|
1429
|
+
if (out && fs.statSync(dest, { throwIfNoEntry: false })?.isDirectory()) dest = path.join(dest, safeName);
|
|
1430
|
+
// An unpredictable name, created exclusively below: a file or symlink planted at a guessable path is never written through.
|
|
1431
|
+
const tmp = path.join(path.dirname(dest), `.${path.basename(dest)}.${crypto.randomBytes(8).toString("hex")}.part`);
|
|
1432
|
+
const ctrl = new AbortController();
|
|
1433
|
+
const started = Date.now();
|
|
1434
|
+
const meter = rateMeter();
|
|
1435
|
+
let total = 0;
|
|
1436
|
+
let got = 0;
|
|
1437
|
+
const progress = (n) => {
|
|
1438
|
+
got += n;
|
|
1439
|
+
meter.update(got);
|
|
1440
|
+
const eta = meter.etaMs(total - got);
|
|
1441
|
+
const pct = total ? ` ${Math.floor((got / total) * 100)}%` : "";
|
|
1442
|
+
const rate = meter.rate() ? ` ${fmtBytes(meter.rate())}/s` : "";
|
|
1443
|
+
live.update(`Downloading ${fmtBytes(got)}${total ? ` / ${fmtBytes(total)}` : ""}${pct}${rate}${eta != null ? ` ${fmtDuration(eta)} left` : ""} ${path.basename(dest)}`);
|
|
1444
|
+
};
|
|
1445
|
+
// A link that expired mid-download (1 h) answers 403: get a new one, once for all the ranges that hit it.
|
|
1446
|
+
let refreshing = null;
|
|
1447
|
+
const renewed = async (used) => {
|
|
1448
|
+
if (url === used) await (refreshing ??= link().then((l) => void (url = l.url)).finally(() => (refreshing = null)));
|
|
1449
|
+
return url !== used;
|
|
1450
|
+
};
|
|
1451
|
+
/** One GET (inclusive `range`, or the whole file); the response body still has to be read with save(). */
|
|
1452
|
+
const get = async (range) => {
|
|
1453
|
+
const req = linked(ctrl.signal);
|
|
1454
|
+
const used = url;
|
|
1455
|
+
const res = await fetch(url, { headers: range ? { Range: `bytes=${range.pos}-${range.end}` } : {}, signal: req.ctrl.signal }).catch((e) => {
|
|
1456
|
+
req.unlink();
|
|
1457
|
+
throw e;
|
|
1458
|
+
});
|
|
1459
|
+
if (res.ok) return { res, req };
|
|
1460
|
+
req.unlink();
|
|
1461
|
+
await res.body?.cancel();
|
|
1462
|
+
if (res.status === 403 && (await renewed(used))) throw new TransferError("The download link expired; retrying with a new one", 0);
|
|
1463
|
+
throw new TransferError(`HTTP ${res.status}`, res.status);
|
|
1464
|
+
};
|
|
1465
|
+
/** Writes a response body at `pos` (positioned writes, so ranges land in any order); returns the next position. */
|
|
1466
|
+
const save = async ({ res, req }, fh, pos) => {
|
|
1467
|
+
const stall = stallTimer(req.ctrl, "data");
|
|
1468
|
+
try {
|
|
1469
|
+
for await (const chunk of res.body) {
|
|
1470
|
+
stall.alive();
|
|
1471
|
+
await fh.write(chunk, 0, chunk.length, pos);
|
|
1472
|
+
pos += chunk.length;
|
|
1473
|
+
progress(chunk.length);
|
|
1474
|
+
}
|
|
1475
|
+
return pos;
|
|
1476
|
+
} finally {
|
|
1477
|
+
stall.stop();
|
|
1478
|
+
req.unlink();
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
onCancel = async () => {
|
|
1483
|
+
ctrl.abort();
|
|
1484
|
+
await fsp.rm(tmp, { force: true });
|
|
1485
|
+
};
|
|
1486
|
+
const fh = await fsp.open(tmp, "wx", 0o600);
|
|
1487
|
+
let etag = null;
|
|
1488
|
+
let connections = 1;
|
|
1489
|
+
try {
|
|
1490
|
+
const probe = await withRetry(() => get({ pos: 0, end: 0 }), ctrl.signal);
|
|
1491
|
+
total = Number(probe.res.headers.get("content-range")?.split("/")[1]);
|
|
1492
|
+
etag = probe.res.headers.get("etag");
|
|
1493
|
+
if (probe.res.status !== 206 || !Number.isFinite(total)) {
|
|
1494
|
+
// No range support: a 200 is already the whole file; anything else is fetched again in one piece.
|
|
1495
|
+
let whole = probe;
|
|
1496
|
+
if (probe.res.status !== 200) {
|
|
1497
|
+
probe.req.unlink();
|
|
1498
|
+
await probe.res.body?.cancel();
|
|
1499
|
+
whole = await withRetry(() => get(), ctrl.signal);
|
|
1500
|
+
}
|
|
1501
|
+
total = Number(whole.res.headers.get("content-length")) || 0;
|
|
1502
|
+
await save(whole, fh, 0);
|
|
1503
|
+
} else {
|
|
1504
|
+
await probe.res.body?.cancel();
|
|
1505
|
+
probe.req.unlink();
|
|
1506
|
+
connections = total >= 2 * RANGE_BYTES ? DOWNLOAD_AT_ONCE : 1;
|
|
1507
|
+
await fh.truncate(total);
|
|
1508
|
+
const ranges = splitRanges(total, RANGE_BYTES, connections);
|
|
1509
|
+
let next = 0;
|
|
1510
|
+
await runPool(
|
|
1511
|
+
connections,
|
|
1512
|
+
() => {
|
|
1513
|
+
const r = ranges[next++];
|
|
1514
|
+
if (!r) return undefined;
|
|
1515
|
+
return () =>
|
|
1516
|
+
withRetry(async () => {
|
|
1517
|
+
const part = await get(r);
|
|
1518
|
+
if (part.res.status !== 206) {
|
|
1519
|
+
part.req.unlink();
|
|
1520
|
+
await part.res.body?.cancel();
|
|
1521
|
+
throw new TransferError(`Expected part of the file, got HTTP ${part.res.status}`, part.res.status, false);
|
|
1522
|
+
}
|
|
1523
|
+
const tag = part.res.headers.get("etag");
|
|
1524
|
+
if (etag && tag && tag !== etag) {
|
|
1525
|
+
part.req.unlink();
|
|
1526
|
+
await part.res.body?.cancel();
|
|
1527
|
+
throw new TransferError("The file changed during the download", 0, false);
|
|
1528
|
+
}
|
|
1529
|
+
r.pos = await save(part, fh, r.pos);
|
|
1530
|
+
if (r.pos !== r.end + 1) throw new TransferError("The connection closed early", 0); // the retry resumes at r.pos
|
|
1531
|
+
}, ctrl.signal);
|
|
1532
|
+
},
|
|
1533
|
+
ctrl,
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
await fh.close();
|
|
1537
|
+
const size = (await fsp.stat(tmp)).size;
|
|
1538
|
+
if (total && size !== total) throw new Error(`got ${fmtBytes(size)} of ${fmtBytes(total)}`);
|
|
1539
|
+
if (/^"[0-9a-f]{32}"$/.test(etag ?? "")) {
|
|
1540
|
+
const md5 = await new Promise((resolve, reject) => {
|
|
1541
|
+
const h = crypto.createHash("md5");
|
|
1542
|
+
fs.createReadStream(tmp, { highWaterMark: 1 << 20 }).on("data", (c) => h.update(c)).on("end", () => resolve(h.digest("hex"))).on("error", reject);
|
|
1543
|
+
});
|
|
1544
|
+
if (`"${md5}"` !== etag) throw new Error("the file doesn't match its checksum (MD5); run the download again");
|
|
1545
|
+
}
|
|
1546
|
+
await fsp.rename(tmp, dest);
|
|
1547
|
+
} catch (e) {
|
|
1548
|
+
await fh.close().catch(() => {});
|
|
1549
|
+
await fsp.rm(tmp, { force: true });
|
|
1550
|
+
throw e instanceof CliError ? e : new CliError(`Download failed: ${e.cause?.code || e.message}`);
|
|
1551
|
+
} finally {
|
|
1552
|
+
onCancel = null;
|
|
1553
|
+
live.clear();
|
|
1554
|
+
}
|
|
1555
|
+
return { path: dest, bytes: got, fileName: safeName, connections, ms: Date.now() - started };
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
async function cmdDownload([id], f) {
|
|
1559
|
+
const cfg = needLogin();
|
|
1560
|
+
const r = await downloadRender(cfg, id, typeof f.out === "string" ? f.out : undefined);
|
|
1561
|
+
done(r, () => say(`${green("✔")} Saved ${r.path} (${fmtBytes(r.bytes)})`));
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
async function cmdLink([id]) {
|
|
1565
|
+
const cfg = needLogin();
|
|
1566
|
+
const r = await request(cfg, "POST", `/renders/${enc(id)}/download-url`, { share: true });
|
|
1567
|
+
done(r, () => {
|
|
1568
|
+
say(r.url);
|
|
1569
|
+
info(dim(`Anyone with this link can download ${r.fileName ?? "the file"} for ${fmtDuration((r.expiresIn ?? 86400) * 1000)}.`));
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
async function cmdRenders(_, f) {
|
|
1574
|
+
const cfg = needLogin();
|
|
1575
|
+
const q = new URLSearchParams();
|
|
1576
|
+
if (f.mine) q.set("mine", "1");
|
|
1577
|
+
if (typeof f.project === "string") q.set("projectRef", f.project);
|
|
1578
|
+
if (typeof f.status === "string") q.set("status", f.status);
|
|
1579
|
+
if (typeof f.limit === "string") q.set("limit", f.limit);
|
|
1580
|
+
const rows = await request(cfg, "GET", `/renders${q.toString() ? `?${q}` : ""}`);
|
|
1581
|
+
done(rows, () => {
|
|
1582
|
+
if (!rows.length) return say("No renders.");
|
|
1583
|
+
table(rows, [
|
|
1584
|
+
["ID", (r) => idOf(r)],
|
|
1585
|
+
["PROJECT", (r) => r.projectSlug || r.projectName],
|
|
1586
|
+
["COMPOSITION", (r) => r.compId],
|
|
1587
|
+
["PRESET", (r) => r.preset],
|
|
1588
|
+
["STATUS", (r) => statusColor(r.status) + (ACTIVE.has(r.status) ? ` ${Math.floor((r.progress ?? 0) * 100)}%` : "")],
|
|
1589
|
+
["COST", (r) => (r.costUsd ? fmtUsd(r.costUsd) : `~${fmtUsd(r.estimatedCostUsd)}`), "right"],
|
|
1590
|
+
["SIZE", (r) => (r.outputBytes ? fmtBytes(r.outputBytes) : "—"), "right"],
|
|
1591
|
+
["STARTED", (r) => when(r.startedAt)],
|
|
1592
|
+
["BY", (r) => r.userName],
|
|
1593
|
+
]);
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
async function cmdDeleteRender([id], f) {
|
|
1598
|
+
const cfg = needLogin();
|
|
1599
|
+
if (!(await confirm(`Delete render ${bold(id)} and its video file?`, f, { render: id }))) throw new CliError("Cancelled; nothing was deleted.");
|
|
1600
|
+
await request(cfg, "DELETE", `/renders/${enc(id)}`);
|
|
1601
|
+
done({ ok: true }, () => say(`${green("✔")} Deleted render ${id}`));
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// ---------------------------------------------------------------- agent guide
|
|
1605
|
+
|
|
1606
|
+
const BUILTIN_GUIDE = `# Rendering Remotion videos with tars-renderer
|
|
1607
|
+
|
|
1608
|
+
You can upload a Remotion build to Tars Renderer ({{PORTAL_URL}}) and render it in the cloud with the \`tars-renderer\` CLI (install: \`npm i -g tars-renderer\`). Sign-in happens in the user's browser; never ask them for keys or passwords. Always pass \`--json\`: one JSON document goes to stdout, progress to stderr.
|
|
1609
|
+
|
|
1610
|
+
Setup (once): \`tars-renderer doctor --json\`. If it exits 4, ask the human to run \`tars-renderer login\` and approve the code in their browser.
|
|
1611
|
+
|
|
1612
|
+
Workflow:
|
|
1613
|
+
1. \`npx remotion bundle src/index.ts --out-dir build\`
|
|
1614
|
+
2. \`tars-renderer projects --json\` (or \`tars-renderer create "<name>" --json\`)
|
|
1615
|
+
3. \`tars-renderer upload build --project <slug> --json --yes\` (only changed files are sent)
|
|
1616
|
+
4. \`tars-renderer comps <slug> --json\`
|
|
1617
|
+
5. \`tars-renderer estimate <slug> <compId> --res 1080p --quality standard --json\`
|
|
1618
|
+
6. Show the human the estimate (\`estimateUsd\`) and get a yes before spending money, then:
|
|
1619
|
+
\`tars-renderer render <slug> <compId> --res 1080p --quality standard --yes --watch --json\`
|
|
1620
|
+
7. \`tars-renderer download <renderId> --out out.mp4 --json\`, or \`tars-renderer link <renderId> --json\` for a 24 h share link.
|
|
1621
|
+
|
|
1622
|
+
Render flags: --res 360p|720p|1080p|4k, --quality draft|standard|high, --frames 0-299 or --seconds 0-10, --muted, --keep 1-day|3-days|7-days|30-days|forever. Use --res 720p --quality draft for test renders.
|
|
1623
|
+
|
|
1624
|
+
Exit codes: 0 ok, 1 error (read "error" in the JSON), 2 bad usage, 3 confirmation needed (re-run with --yes after asking the human), 4 not logged in.
|
|
1625
|
+
Never print or log the CLI token (kept in the OS credential store, or ~/.tars-renderer/config.json).
|
|
1626
|
+
`;
|
|
1627
|
+
|
|
1628
|
+
// ---------------------------------------------------------------- agent skill
|
|
1629
|
+
|
|
1630
|
+
/** SKILL.md ships inside this package (cli/skill/SKILL.md), so the skill always matches the CLI version. */
|
|
1631
|
+
export function readSkill() {
|
|
1632
|
+
return fs.readFileSync(new URL("./skill/SKILL.md", import.meta.url), "utf8");
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** Where each agent looks for skills: user-wide under ~, or per repo under the current folder (--repo). */
|
|
1636
|
+
export function skillTargets(forAgent, project, home = os.homedir(), cwd = process.cwd()) {
|
|
1637
|
+
const base = project ? cwd : home;
|
|
1638
|
+
const all = { claude: path.join(base, ".claude", "skills", "tars-renderer"), codex: path.join(base, ".codex", "skills", "tars-renderer") };
|
|
1639
|
+
if (forAgent === "all") return all;
|
|
1640
|
+
if (!(forAgent in all)) throw usage(`--for must be claude, codex or all, got "${forAgent}"`);
|
|
1641
|
+
return { [forAgent]: all[forAgent] };
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
async function cmdSkill(_, f) {
|
|
1645
|
+
const action = _[0] ?? "show";
|
|
1646
|
+
if (action === "show") return done({ skill: readSkill() }, (d) => process.stdout.write(d.skill));
|
|
1647
|
+
if (action !== "install") throw usage(`Unknown action "${action}". Use: tars-renderer skill install | show`);
|
|
1648
|
+
const targets = skillTargets(typeof f.for === "string" ? f.for : "all", !!f.repo);
|
|
1649
|
+
const skill = readSkill();
|
|
1650
|
+
const installed = Object.entries(targets).map(([agent, dir]) => {
|
|
1651
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1652
|
+
const file = path.join(dir, "SKILL.md");
|
|
1653
|
+
fs.writeFileSync(file, skill);
|
|
1654
|
+
return { agent, file };
|
|
1655
|
+
});
|
|
1656
|
+
done({ ok: true, installed }, () => {
|
|
1657
|
+
for (const i of installed) say(`${green("✔")} ${i.agent === "claude" ? "Claude Code" : "Codex"}: ${i.file}`);
|
|
1658
|
+
say(dim(" Start a new agent session, then ask it to render your Remotion project."));
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
async function cmdAgentGuide(_, f) {
|
|
1663
|
+
const cfg = loadConfig();
|
|
1664
|
+
const portal = (typeof f.portal === "string" ? f.portal : cfg.portal)?.replace(/\/+$/, "");
|
|
1665
|
+
let text = null;
|
|
1666
|
+
let source = "built-in";
|
|
1667
|
+
if (portal) {
|
|
1668
|
+
try {
|
|
1669
|
+
const res = await fetch(`${portal}/cli/AGENT_GUIDE.md`);
|
|
1670
|
+
const body = await res.text();
|
|
1671
|
+
// The SPA rewrite answers unknown paths with index.html and a 200, so check it isn't HTML.
|
|
1672
|
+
if (res.ok && !/^\s*</.test(body) && !(res.headers.get("content-type") ?? "").includes("text/html")) {
|
|
1673
|
+
text = body;
|
|
1674
|
+
source = `${portal}/cli/AGENT_GUIDE.md`;
|
|
1675
|
+
}
|
|
1676
|
+
} catch {}
|
|
1677
|
+
if (!text) warn(`Couldn't fetch ${portal}/cli/AGENT_GUIDE.md; printing the built-in guide.`);
|
|
1678
|
+
}
|
|
1679
|
+
const guide = (text ?? BUILTIN_GUIDE)
|
|
1680
|
+
.replaceAll("{{LOGIN_COMMAND}}", !portal || portal === DEFAULT_PORTAL ? "tars-renderer login" : `tars-renderer login ${portal}`)
|
|
1681
|
+
.replaceAll("{{PORTAL_URL}}", portal || DEFAULT_PORTAL)
|
|
1682
|
+
.replaceAll("{{API_URL}}", cfg.api || "<https://<deployment>.convex.site>");
|
|
1683
|
+
done({ source, guide }, () => process.stdout.write(guide.endsWith("\n") ? guide : guide + "\n"));
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// ---------------------------------------------------------------- command table + main
|
|
1687
|
+
|
|
1688
|
+
const COMMANDS = {
|
|
1689
|
+
login: {
|
|
1690
|
+
args: [],
|
|
1691
|
+
optArgs: ["portal"],
|
|
1692
|
+
flags: ["api=", "portal="],
|
|
1693
|
+
usage: "login [portal-url]",
|
|
1694
|
+
summary: "Log in through the browser with a one-time code and save the token",
|
|
1695
|
+
help: `Prints a code and opens the portal's approval page. Check the code matches, click Approve,
|
|
1696
|
+
and the token is saved in the OS credential store (macOS Keychain, Windows DPAPI, Linux secret-tool),
|
|
1697
|
+
or in ~/.tars-renderer/config.json (mode 0600) when there is none. TARS_RENDERER_TOKEN_STORE=file forces the file.
|
|
1698
|
+
|
|
1699
|
+
[portal-url] only for another deployment (development, staging); default ${DEFAULT_PORTAL}
|
|
1700
|
+
--api <url> advanced: the API address, if the portal can't be reached`,
|
|
1701
|
+
examples: ["tars-renderer login"],
|
|
1702
|
+
run: cmdLogin,
|
|
1703
|
+
},
|
|
1704
|
+
logout: {
|
|
1705
|
+
args: [],
|
|
1706
|
+
flags: ["all", "local", "purge"],
|
|
1707
|
+
usage: "logout [--all] [--local] [--purge]",
|
|
1708
|
+
summary: "Sign out (this device, or --all of your devices)",
|
|
1709
|
+
help: `Revokes this device's sign-in on the server and removes it from your OS keychain / config.
|
|
1710
|
+
|
|
1711
|
+
--all sign out every device and agent on your account (e.g. a lost laptop)
|
|
1712
|
+
--local only forget the sign-in here, without contacting the server (offline)
|
|
1713
|
+
--purge also delete ~/.tars-renderer (settings and upload hash cache)`,
|
|
1714
|
+
examples: ["tars-renderer logout", "tars-renderer logout --all", "tars-renderer logout --purge"],
|
|
1715
|
+
run: cmdLogout,
|
|
1716
|
+
},
|
|
1717
|
+
whoami: { args: [], flags: [], usage: "whoami", summary: "Show who you're logged in as", run: cmdWhoami },
|
|
1718
|
+
doctor: {
|
|
1719
|
+
args: [],
|
|
1720
|
+
flags: [],
|
|
1721
|
+
usage: "doctor",
|
|
1722
|
+
summary: "Check Node, config, API reachability, token and allowlist",
|
|
1723
|
+
help: "Exits 0 when everything is fine, 4 when not logged in, 1 otherwise.",
|
|
1724
|
+
run: cmdDoctor,
|
|
1725
|
+
},
|
|
1726
|
+
projects: {
|
|
1727
|
+
args: [],
|
|
1728
|
+
flags: ["mine"],
|
|
1729
|
+
usage: "projects [--mine]",
|
|
1730
|
+
summary: "List projects",
|
|
1731
|
+
help: " --mine only projects you own",
|
|
1732
|
+
run: cmdProjects,
|
|
1733
|
+
},
|
|
1734
|
+
create: {
|
|
1735
|
+
args: ["name"],
|
|
1736
|
+
flags: ["keep="],
|
|
1737
|
+
usage: "create <name> [--keep 7-days|30-days|90-days|never]",
|
|
1738
|
+
summary: "Create a project",
|
|
1739
|
+
help: " --keep delete the uploaded site after it's unused this long (default: the server's)",
|
|
1740
|
+
examples: ['tars-renderer create "Launch video" --keep 30-days'],
|
|
1741
|
+
run: cmdCreate,
|
|
1742
|
+
},
|
|
1743
|
+
upload: {
|
|
1744
|
+
args: ["buildDir"],
|
|
1745
|
+
flags: ["project=", "entry=", "version=", "yes", "rehash"],
|
|
1746
|
+
usage: "upload <buildDir> --project <ref> [--entry src/index.ts] [--version 4.0.535] [--yes] [--rehash]",
|
|
1747
|
+
summary: "Upload a `npx remotion bundle` build (only changed files are sent)",
|
|
1748
|
+
help: `Checks index.html and bundle.js are at the root, detects the Remotion version from bundle.js,
|
|
1749
|
+
hashes every file (SHA-256), shows what changed and asks before uploading. Hashes are cached in
|
|
1750
|
+
~/.tars-renderer/hash-cache.json, so files whose size and modified time haven't changed aren't read again.
|
|
1751
|
+
|
|
1752
|
+
--project <ref> project slug or id (required)
|
|
1753
|
+
--entry <file> the entry file you bundled, saved with the project
|
|
1754
|
+
--version <v> Remotion version, when it can't be detected from bundle.js
|
|
1755
|
+
--yes don't ask before uploading
|
|
1756
|
+
--rehash ignore the hash cache and read every file again`,
|
|
1757
|
+
examples: ["npx remotion bundle src/index.ts --out-dir build", "tars-renderer upload build --project launch-video --yes"],
|
|
1758
|
+
run: cmdUpload,
|
|
1759
|
+
},
|
|
1760
|
+
comps: { args: ["project"], flags: [], usage: "comps <project>", summary: "List a project's compositions", run: cmdComps },
|
|
1761
|
+
estimate: {
|
|
1762
|
+
args: ["project", "compId"],
|
|
1763
|
+
flags: RENDER_FLAGS,
|
|
1764
|
+
usage: "estimate <project> <compId> [render options]",
|
|
1765
|
+
summary: "Show the cost, output size and frame count of a render",
|
|
1766
|
+
help: "Takes the same render options as `render` (see tars-renderer render --help).",
|
|
1767
|
+
run: cmdEstimate,
|
|
1768
|
+
},
|
|
1769
|
+
render: {
|
|
1770
|
+
args: ["project", "compId"],
|
|
1771
|
+
flags: [...RENDER_FLAGS, "yes", "watch", "download?"],
|
|
1772
|
+
usage: "render <project> <compId> [options] [--yes] [--watch] [--download [path]]",
|
|
1773
|
+
summary: "Start a render (shows the estimate and asks first)",
|
|
1774
|
+
help: ` --res 360p|720p|1080p|4k output height (default 1080p)
|
|
1775
|
+
--quality draft|standard|high default standard
|
|
1776
|
+
--frames 0-299 | --seconds 0-10 render part of the composition
|
|
1777
|
+
--muted no audio
|
|
1778
|
+
--keep 1-day|3-days|7-days|30-days|forever how long the video is kept (default 7-days)
|
|
1779
|
+
--yes don't ask (with --json, without it exits 3)
|
|
1780
|
+
--watch follow progress until done
|
|
1781
|
+
--download [path] watch, then download the video`,
|
|
1782
|
+
examples: ["tars-renderer render launch-video Main --res 720p --quality draft --seconds 0-10", "tars-renderer render launch-video Main --yes --download out.mp4"],
|
|
1783
|
+
run: cmdRender,
|
|
1784
|
+
},
|
|
1785
|
+
renders: {
|
|
1786
|
+
args: [],
|
|
1787
|
+
flags: ["mine", "project=", "status=", "limit="],
|
|
1788
|
+
usage: "renders [--mine] [--project <ref>] [--status queued|rendering|done|failed|expired] [--limit n]",
|
|
1789
|
+
summary: "List renders",
|
|
1790
|
+
run: cmdRenders,
|
|
1791
|
+
},
|
|
1792
|
+
status: { args: ["renderId"], flags: [], usage: "status <renderId>", summary: "Show a render's current state once", run: cmdStatus },
|
|
1793
|
+
watch: {
|
|
1794
|
+
args: ["renderId"],
|
|
1795
|
+
flags: [],
|
|
1796
|
+
usage: "watch <renderId>",
|
|
1797
|
+
summary: "Follow a render live until it's done (exit 0) or failed (exit 1)",
|
|
1798
|
+
run: cmdWatch,
|
|
1799
|
+
},
|
|
1800
|
+
download: {
|
|
1801
|
+
args: ["renderId"],
|
|
1802
|
+
flags: ["out="],
|
|
1803
|
+
usage: "download <renderId> [--out <path>]",
|
|
1804
|
+
summary: "Download a finished render (default: its file name, in this folder)",
|
|
1805
|
+
run: cmdDownload,
|
|
1806
|
+
},
|
|
1807
|
+
link: { args: ["renderId"], flags: [], usage: "link <renderId>", summary: "Print a 24-hour share link", run: cmdLink },
|
|
1808
|
+
"delete-render": { args: ["renderId"], flags: ["yes"], usage: "delete-render <renderId> [--yes]", summary: "Delete a render and its video", run: cmdDeleteRender },
|
|
1809
|
+
"delete-project": {
|
|
1810
|
+
args: ["ref"],
|
|
1811
|
+
flags: ["yes"],
|
|
1812
|
+
usage: "delete-project <ref> [--yes]",
|
|
1813
|
+
summary: "Delete a project and its uploaded files",
|
|
1814
|
+
run: cmdDeleteProject,
|
|
1815
|
+
},
|
|
1816
|
+
skill: {
|
|
1817
|
+
args: [],
|
|
1818
|
+
optArgs: ["action"],
|
|
1819
|
+
flags: ["for=", "repo"],
|
|
1820
|
+
usage: "skill [install|show] [--for claude|codex|all] [--repo]",
|
|
1821
|
+
summary: "Add the Tars Renderer skill to Claude Code / Codex",
|
|
1822
|
+
help: `Installs the agent skill, so your coding agent knows how to use Tars Renderer in every session.
|
|
1823
|
+
|
|
1824
|
+
install write SKILL.md for your agents (default: Claude Code and Codex)
|
|
1825
|
+
show print the skill (default)
|
|
1826
|
+
--for <agent> claude, codex or all (default all)
|
|
1827
|
+
--repo install into this repo (.claude/skills, .codex/skills) instead of your home folder`,
|
|
1828
|
+
examples: ["npx tars-renderer skill install", "tars-renderer skill install --for claude --repo"],
|
|
1829
|
+
run: cmdSkill,
|
|
1830
|
+
},
|
|
1831
|
+
"agent-guide": {
|
|
1832
|
+
args: [],
|
|
1833
|
+
flags: ["portal="],
|
|
1834
|
+
usage: "agent-guide",
|
|
1835
|
+
summary: "Print instructions for AI coding agents",
|
|
1836
|
+
run: cmdAgentGuide,
|
|
1837
|
+
},
|
|
1838
|
+
};
|
|
1839
|
+
|
|
1840
|
+
function mainHelp() {
|
|
1841
|
+
const w = Math.max(...Object.keys(COMMANDS).map((k) => k.length));
|
|
1842
|
+
return `tars-renderer ${VERSION}: render Remotion videos on Tars Renderer
|
|
1843
|
+
|
|
1844
|
+
Usage: tars-renderer <command> [options]
|
|
1845
|
+
|
|
1846
|
+
Commands:
|
|
1847
|
+
${Object.entries(COMMANDS)
|
|
1848
|
+
.map(([k, c]) => ` ${k.padEnd(w)} ${c.summary}`)
|
|
1849
|
+
.join("\n")}
|
|
1850
|
+
|
|
1851
|
+
Options for every command:
|
|
1852
|
+
--json print one JSON document on stdout; progress and logs go to stderr
|
|
1853
|
+
--help help for a command (tars-renderer <command> --help)
|
|
1854
|
+
|
|
1855
|
+
Exit codes: 0 ok · 1 error · 2 bad usage · 3 confirmation needed · 4 not logged in
|
|
1856
|
+
Config: ~/.tars-renderer/config.json (override with TARS_RENDERER_API and TARS_RENDERER_TOKEN)
|
|
1857
|
+
`;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
function commandHelp(name) {
|
|
1861
|
+
const c = COMMANDS[name];
|
|
1862
|
+
return [`Usage: tars-renderer ${c.usage}`, `\n${c.summary}.`, c.help && `\n${c.help}`, c.examples && `\nExamples:\n${c.examples.map((e) => ` ${e}`).join("\n")}`]
|
|
1863
|
+
.filter(Boolean)
|
|
1864
|
+
.join("\n") + "\n";
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function checkUsage(c, { _, flags }) {
|
|
1868
|
+
const defs = new Map(c.flags.map((d) => [d.replace(/[=?]$/, ""), d]));
|
|
1869
|
+
for (const [k, v] of Object.entries(flags)) {
|
|
1870
|
+
if (k === "json" || k === "help") continue;
|
|
1871
|
+
const d = defs.get(k);
|
|
1872
|
+
if (!d) throw usage(`Unknown option --${k}`);
|
|
1873
|
+
if (d.endsWith("=") && typeof v !== "string") throw usage(`--${k} needs a value`);
|
|
1874
|
+
}
|
|
1875
|
+
if (_.length < c.args.length) throw usage(`Missing <${c.args[_.length]}>`);
|
|
1876
|
+
if (_.length > c.args.length + (c.optArgs?.length ?? 0)) throw usage(`Unexpected argument "${_[c.args.length]}"`);
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/** Runs the CLI; resolves with the exit code. */
|
|
1880
|
+
/** Masks anything token-shaped. Applied to every byte the CLI writes, so no code path (or server message) can leak one. */
|
|
1881
|
+
export const redactTokens = (s) => s.replace(/\btrp_[A-Za-z0-9_-]{8,}/g, (t) => maskToken(t));
|
|
1882
|
+
|
|
1883
|
+
function redactOutput() {
|
|
1884
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
1885
|
+
if (stream.__redacted) continue;
|
|
1886
|
+
const write = stream.write.bind(stream);
|
|
1887
|
+
stream.write = (chunk, ...rest) => write(typeof chunk === "string" ? redactTokens(chunk) : Buffer.isBuffer(chunk) ? redactTokens(chunk.toString("utf8")) : chunk, ...rest);
|
|
1888
|
+
stream.__redacted = true;
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
1893
|
+
redactOutput();
|
|
1894
|
+
migrateLegacyConfigDir();
|
|
1895
|
+
let name;
|
|
1896
|
+
try {
|
|
1897
|
+
const parsed = parseArgs(argv);
|
|
1898
|
+
ctx.json = !!parsed.flags.json;
|
|
1899
|
+
name = parsed._.shift();
|
|
1900
|
+
if (!name || name === "help") {
|
|
1901
|
+
if (!name && parsed.flags.version === true) {
|
|
1902
|
+
process.stdout.write(VERSION + "\n");
|
|
1903
|
+
return EXIT.OK;
|
|
1904
|
+
}
|
|
1905
|
+
const topic = parsed._[0];
|
|
1906
|
+
process.stdout.write(topic && COMMANDS[topic] ? commandHelp(topic) : mainHelp());
|
|
1907
|
+
return name || parsed.flags.help ? EXIT.OK : EXIT.USAGE;
|
|
1908
|
+
}
|
|
1909
|
+
const cmd = COMMANDS[name];
|
|
1910
|
+
if (!cmd) throw usage(`Unknown command "${name}". Run tars-renderer --help for the list.`);
|
|
1911
|
+
if (parsed.flags.help) {
|
|
1912
|
+
process.stdout.write(commandHelp(name));
|
|
1913
|
+
return EXIT.OK;
|
|
1914
|
+
}
|
|
1915
|
+
checkUsage(cmd, parsed);
|
|
1916
|
+
const code = (await cmd.run(parsed._, parsed.flags)) ?? EXIT.OK;
|
|
1917
|
+
if (code === EXIT.OK) migrateToken();
|
|
1918
|
+
return code;
|
|
1919
|
+
} catch (e) {
|
|
1920
|
+
live.clear();
|
|
1921
|
+
const exitCode = e instanceof CliError ? e.exitCode : EXIT.ERROR;
|
|
1922
|
+
const message = e?.message || String(e);
|
|
1923
|
+
if (ctx.json) process.stdout.write(JSON.stringify({ ok: false, error: message, ...(e?.code ? { code: e.code } : {}), exitCode, ...e?.extra }, null, 2) + "\n");
|
|
1924
|
+
process.stderr.write(`${red("Error:")} ${message}\n`);
|
|
1925
|
+
if (exitCode === EXIT.USAGE && name && COMMANDS[name]) process.stderr.write(dim(`Usage: tars-renderer ${COMMANDS[name].usage}\n`));
|
|
1926
|
+
if (!(e instanceof CliError) && process.env.DEBUG) process.stderr.write(`${e?.stack}\n`);
|
|
1927
|
+
return exitCode;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const isEntry = (() => {
|
|
1932
|
+
try {
|
|
1933
|
+
const a = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
1934
|
+
const b = fs.realpathSync(process.argv[1] ?? "");
|
|
1935
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
1936
|
+
} catch {
|
|
1937
|
+
return false;
|
|
1938
|
+
}
|
|
1939
|
+
})();
|
|
1940
|
+
if (isEntry) {
|
|
1941
|
+
process.on("SIGINT", interrupt);
|
|
1942
|
+
main().then((code) => (process.exitCode = code));
|
|
1943
|
+
}
|