@hostr/cli 0.1.1 → 0.1.3

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.
Files changed (2) hide show
  1. package/dist/cli.js +367 -96
  2. package/package.json +22 -8
package/dist/cli.js CHANGED
@@ -12,6 +12,14 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
12
12
  if (typeof require !== "undefined") return require.apply(this, arguments);
13
13
  throw Error('Dynamic require of "' + x + '" is not supported');
14
14
  });
15
+ var __esm = (fn, res, err) => function __init() {
16
+ if (err) throw err[0];
17
+ try {
18
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
19
+ } catch (e) {
20
+ throw err = [e], e;
21
+ }
22
+ };
15
23
  var __commonJS = (cb, mod) => function __require2() {
16
24
  try {
17
25
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
@@ -19,6 +27,10 @@ var __commonJS = (cb, mod) => function __require2() {
19
27
  throw mod = 0, e;
20
28
  }
21
29
  };
30
+ var __export = (target, all) => {
31
+ for (var name in all)
32
+ __defProp(target, name, { get: all[name], enumerable: true });
33
+ };
22
34
  var __copyProps = (to, from, except, desc) => {
23
35
  if (from && typeof from === "object" || typeof from === "function") {
24
36
  for (let key of __getOwnPropNames(from))
@@ -3462,6 +3474,304 @@ var require_commander = __commonJS({
3462
3474
  }
3463
3475
  });
3464
3476
 
3477
+ // ../upload/src/api/client.ts
3478
+ async function req(fetchFn, endpoint, bearer, path, init2 = {}) {
3479
+ const url = `${trimEndpoint(endpoint)}${path}`;
3480
+ const headers = { authorization: `Bearer ${bearer}` };
3481
+ let body;
3482
+ if (init2.body !== void 0) {
3483
+ headers["content-type"] = "application/json";
3484
+ body = JSON.stringify(init2.body);
3485
+ }
3486
+ let res;
3487
+ try {
3488
+ res = await fetchFn(url, { method: init2.method ?? "GET", headers, body });
3489
+ } catch {
3490
+ throw new NetworkError();
3491
+ }
3492
+ const text = await res.text();
3493
+ const data = text ? JSON.parse(text) : {};
3494
+ if (!res.ok) {
3495
+ const code = data && typeof data === "object" && typeof data.error === "string" ? data.error : void 0;
3496
+ throw new ApiError2(res.status, code);
3497
+ }
3498
+ return data;
3499
+ }
3500
+ async function createUploadSession(opts, fetchFn = fetch) {
3501
+ const { endpoint, apiKey, ...body } = opts;
3502
+ return req(fetchFn, endpoint, apiKey, "/v1/uploads", { method: "POST", body });
3503
+ }
3504
+ function mintUploadToken(opts, fetchFn = fetch) {
3505
+ const { endpoint, apiKey, assetId, tokenTtlSec } = opts;
3506
+ return req(fetchFn, endpoint, apiKey, `/v1/uploads/${assetId}/token`, {
3507
+ method: "POST",
3508
+ body: tokenTtlSec === void 0 ? {} : { tokenTtlSec }
3509
+ });
3510
+ }
3511
+ var ApiError2, NetworkError, trimEndpoint, HostrApi;
3512
+ var init_client = __esm({
3513
+ "../upload/src/api/client.ts"() {
3514
+ "use strict";
3515
+ ApiError2 = class extends Error {
3516
+ status;
3517
+ code;
3518
+ constructor(status, code) {
3519
+ super(code ? `hostr api error ${status}: ${code}` : `hostr api error ${status}`);
3520
+ this.name = "ApiError";
3521
+ this.status = status;
3522
+ this.code = code;
3523
+ }
3524
+ };
3525
+ NetworkError = class extends Error {
3526
+ constructor(message = "network error") {
3527
+ super(message);
3528
+ this.name = "NetworkError";
3529
+ }
3530
+ };
3531
+ trimEndpoint = (endpoint) => endpoint.replace(/\/$/, "");
3532
+ HostrApi = class {
3533
+ #endpoint;
3534
+ #bearer;
3535
+ #fetch;
3536
+ constructor(endpoint, bearer, fetchFn = fetch) {
3537
+ this.#endpoint = endpoint;
3538
+ this.#bearer = bearer;
3539
+ this.#fetch = fetchFn;
3540
+ }
3541
+ setBearer(bearer) {
3542
+ this.#bearer = bearer;
3543
+ }
3544
+ getUpload(assetId) {
3545
+ return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}`);
3546
+ }
3547
+ presignParts(assetId, partNumbers, expiresIn) {
3548
+ return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}/parts`, {
3549
+ method: "POST",
3550
+ body: expiresIn === void 0 ? { partNumbers } : { partNumbers, expiresIn }
3551
+ });
3552
+ }
3553
+ complete(assetId, body) {
3554
+ return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}/complete`, {
3555
+ method: "POST",
3556
+ body
3557
+ });
3558
+ }
3559
+ async abort(assetId) {
3560
+ await req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}`, { method: "DELETE" });
3561
+ }
3562
+ };
3563
+ }
3564
+ });
3565
+
3566
+ // src/gui.ts
3567
+ var gui_exports = {};
3568
+ __export(gui_exports, {
3569
+ uploadGui: () => uploadGui
3570
+ });
3571
+ import { createServer } from "node:http";
3572
+ import { randomBytes } from "node:crypto";
3573
+ import { spawn } from "node:child_process";
3574
+ async function uploadGui(ctx) {
3575
+ const secret = Array.from(randomBytes(16), (b) => b.toString(16).padStart(2, "0")).join("");
3576
+ const server = createServer(async (req2, res) => {
3577
+ const url = new URL(req2.url ?? "/", "http://localhost");
3578
+ const send = (status, body, type = "application/json") => {
3579
+ res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
3580
+ res.end(body);
3581
+ };
3582
+ try {
3583
+ if (url.pathname === "/" && url.searchParams.get("s") === secret)
3584
+ return send(200, page(ctx.apiBase, secret), "text/html; charset=utf-8");
3585
+ if (url.searchParams.get("s") !== secret) return send(403, '{"error":"forbidden"}');
3586
+ if (url.pathname === "/api/session" && req2.method === "POST") {
3587
+ const chunks = [];
3588
+ for await (const c of req2) chunks.push(c);
3589
+ const { filename, size, contentType } = JSON.parse(Buffer.concat(chunks).toString());
3590
+ const s = await createUploadSession({ endpoint: ctx.apiBase, apiKey: ctx.key, filename, size, contentType });
3591
+ return send(200, JSON.stringify(s));
3592
+ }
3593
+ if (url.pathname.startsWith("/api/status/") && req2.method === "GET") {
3594
+ const a = await ctx.client.asset(url.pathname.slice("/api/status/".length));
3595
+ return send(200, JSON.stringify(a));
3596
+ }
3597
+ if (url.pathname.startsWith("/api/playback/") && req2.method === "POST") {
3598
+ try {
3599
+ const t = await ctx.client.assetTokens(url.pathname.slice("/api/playback/".length));
3600
+ return send(200, JSON.stringify(t));
3601
+ } catch {
3602
+ return send(200, JSON.stringify({ unavailable: true }));
3603
+ }
3604
+ }
3605
+ return send(404, '{"error":"not_found"}');
3606
+ } catch (e) {
3607
+ return send(500, JSON.stringify({ error: String(e.message).slice(0, 300) }));
3608
+ }
3609
+ });
3610
+ const server6 = createServer((req2, res) => server.emit("request", req2, res));
3611
+ const listen = (srv, host, port2) => new Promise((resolve) => {
3612
+ const onErr = (e) => {
3613
+ srv.removeAllListeners("listening");
3614
+ resolve(e.code ?? "ERR");
3615
+ };
3616
+ srv.once("error", onErr);
3617
+ srv.listen(port2, host, () => {
3618
+ srv.removeListener("error", onErr);
3619
+ resolve(null);
3620
+ });
3621
+ });
3622
+ let port = 0;
3623
+ for (const candidate of CORS_PORTS) {
3624
+ const e4 = await listen(server, "127.0.0.1", candidate);
3625
+ if (e4) continue;
3626
+ const e6 = await listen(server6, "::1", candidate);
3627
+ if (e6 && e6 !== "EADDRNOTAVAIL") {
3628
+ await new Promise((r) => server.close(() => r(null)));
3629
+ continue;
3630
+ }
3631
+ port = candidate;
3632
+ break;
3633
+ }
3634
+ if (!port) throw new Error("no free port among the CORS-allowed set (3000-3005, 5173, 8899)");
3635
+ const addr = `http://localhost:${port}/?s=${secret}`;
3636
+ process.stderr.write(`upload page ready: ${addr}
3637
+ (drop a video in the browser; Ctrl+C here when done)
3638
+ `);
3639
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
3640
+ spawn(opener, [addr], { stdio: "ignore", detached: true }).unref();
3641
+ await new Promise(() => {
3642
+ });
3643
+ }
3644
+ var CORS_PORTS, page;
3645
+ var init_gui = __esm({
3646
+ "src/gui.ts"() {
3647
+ "use strict";
3648
+ init_client();
3649
+ CORS_PORTS = [3005, 3004, 3003, 3002, 3001, 5173, 3e3, 8899];
3650
+ page = (apiBase, secret) => `<!doctype html>
3651
+ <meta charset="utf-8"><title>hostr upload</title>
3652
+ <meta name="viewport" content="width=device-width,initial-scale=1">
3653
+ <style>
3654
+ :root { --bg:#fdf6e3; --ink:#111; --surface:#fff; --accent:#2b4cff; --ok:#0a8f4d; --danger:#e0301e; }
3655
+ * { box-sizing:border-box; }
3656
+ body { background:var(--bg); color:var(--ink); font:15px/1.5 system-ui, sans-serif; margin:0; padding:40px 20px; }
3657
+ main { max-width:640px; margin:0 auto; }
3658
+ h1 { font-size:22px; font-weight:800; margin:0 0 4px; }
3659
+ .sub { color:#5a5a5a; font-size:13px; margin:0 0 24px; }
3660
+ #drop { background:var(--surface); border:2px dashed var(--ink); border-radius:6px; padding:56px 24px;
3661
+ text-align:center; font-weight:700; cursor:pointer; }
3662
+ #drop.over { background:#eef1ff; border-style:solid; }
3663
+ .card { background:var(--surface); border:2px solid var(--ink); border-radius:6px;
3664
+ box-shadow:3px 3px 0 var(--ink); padding:16px; margin-top:16px; }
3665
+ .bar { height:14px; border:2px solid var(--ink); border-radius:4px; overflow:hidden; margin:8px 0; }
3666
+ .bar > div { height:100%; background:var(--accent); width:0%; transition:width .2s; }
3667
+ .bar.enc > div { background:var(--ok); }
3668
+ .mono { font-family:ui-monospace,monospace; font-size:12px; word-break:break-all; }
3669
+ .err { color:var(--danger); font-weight:700; }
3670
+ video { width:100%; border:2px solid var(--ink); border-radius:6px; margin-top:10px; background:#000; }
3671
+ .hint { font-size:12px; color:#5a5a5a; }
3672
+ </style>
3673
+ <main>
3674
+ <h1>hostr upload</h1>
3675
+ <p class="sub">drop a video \u2014 it uploads straight to storage, transcodes, and plays back below</p>
3676
+ <div id="drop">drop a video here<br><span class="hint">or click to choose</span></div>
3677
+ <input id="file" type="file" accept="video/*,.mkv,.mov,.mp4,.webm" style="display:none">
3678
+ <div id="jobs"></div>
3679
+ </main>
3680
+ <script>
3681
+ const API = ${JSON.stringify(apiBase)}, S = ${JSON.stringify(secret)};
3682
+ const drop = document.getElementById('drop'), input = document.getElementById('file');
3683
+ drop.onclick = () => input.click();
3684
+ input.onchange = () => [...input.files].forEach(go);
3685
+ ['dragover','dragenter'].forEach(e => drop.addEventListener(e, ev => { ev.preventDefault(); drop.classList.add('over'); }));
3686
+ ['dragleave','drop'].forEach(e => drop.addEventListener(e, ev => { ev.preventDefault(); drop.classList.remove('over'); }));
3687
+ drop.addEventListener('drop', ev => [...ev.dataTransfer.files].forEach(go));
3688
+
3689
+ const local = (p, o) => fetch(p + '?s=' + S, o).then(r => r.json());
3690
+ const api = (p, tok, body) => fetch(API + p, { method:'POST',
3691
+ headers:{ authorization:'Bearer ' + tok, 'content-type':'application/json' },
3692
+ body: JSON.stringify(body ?? {}) }).then(async r => { const j = await r.json(); if (!r.ok) throw new Error(j.error || r.status); return j; });
3693
+
3694
+ async function go(file) {
3695
+ const el = document.createElement('div'); el.className = 'card'; document.getElementById('jobs').prepend(el);
3696
+ el.innerHTML = '<b>' + file.name + '</b><div class="bar"><div></div></div><div class="st">creating session\u2026</div>';
3697
+ const bar = el.querySelector('.bar > div'), st = el.querySelector('.st');
3698
+ try {
3699
+ const sess = await local('/api/session', { method:'POST', body: JSON.stringify({
3700
+ filename: file.name, size: file.size, contentType: file.type || 'application/octet-stream' }) });
3701
+ if (sess.error) throw new Error(sess.error);
3702
+ const { assetId, uploadToken, partSize, expectedParts } = sess;
3703
+
3704
+ // Part URLs in batches of 100 (the API's cap), uploads 4 at a time via
3705
+ // XHR \u2014 fetch has no upload progress.
3706
+ const done = new Array(expectedParts).fill(0);
3707
+ const paint = () => { bar.style.width = (done.reduce((a,b)=>a+b,0) / file.size * 100).toFixed(1) + '%'; };
3708
+ let urls = [];
3709
+ for (let b = 1; b <= expectedParts; b += 100) {
3710
+ const nums = []; for (let n = b; n < b + 100 && n <= expectedParts; n++) nums.push(n);
3711
+ urls = urls.concat((await api('/v1/uploads/' + assetId + '/parts', uploadToken, { partNumbers: nums })).urls);
3712
+ }
3713
+ st.textContent = 'uploading\u2026';
3714
+ let idx = 0;
3715
+ await Promise.all(Array.from({ length: 4 }, async function worker() {
3716
+ while (idx < urls.length) {
3717
+ const u = urls[idx++];
3718
+ const start = (u.partNumber - 1) * partSize;
3719
+ const blob = file.slice(start, Math.min(start + partSize, file.size));
3720
+ await new Promise((res, rej) => {
3721
+ const x = new XMLHttpRequest();
3722
+ x.open('PUT', u.url);
3723
+ x.upload.onprogress = e => { done[u.partNumber - 1] = e.loaded; paint(); };
3724
+ x.onload = () => x.status < 300 ? (done[u.partNumber - 1] = blob.size, paint(), res()) : rej(new Error('part ' + u.partNumber + ' -> ' + x.status));
3725
+ x.onerror = () => rej(new Error('part ' + u.partNumber + ' network error'));
3726
+ x.send(blob);
3727
+ });
3728
+ }
3729
+ }));
3730
+ st.textContent = 'finalizing\u2026';
3731
+ await api('/v1/uploads/' + assetId + '/complete', uploadToken);
3732
+
3733
+ el.insertAdjacentHTML('beforeend', '<div class="bar enc"><div></div></div>');
3734
+ const enc = el.querySelectorAll('.bar > div')[1];
3735
+ st.textContent = 'transcoding\u2026';
3736
+ for (;;) {
3737
+ await new Promise(r => setTimeout(r, 2500));
3738
+ const a = await local('/api/status/' + assetId);
3739
+ if (a.progress?.totalSec) enc.style.width = (a.progress.encodedSec / a.progress.totalSec * 100).toFixed(1) + '%';
3740
+ if (a.status === 'ready') break;
3741
+ if (a.status === 'failed') throw new Error('transcode failed: ' + (a.error || 'unknown'));
3742
+ st.textContent = 'transcoding\u2026 (' + a.status + ')';
3743
+ }
3744
+ enc.style.width = '100%';
3745
+
3746
+ const t = await local('/api/playback/' + assetId, { method: 'POST' });
3747
+ const mediaHost = API.replace('api.', 'stream.');
3748
+ if (t.unavailable) {
3749
+ st.innerHTML = 'ready \u2014 asset <span class="mono">' + assetId + '</span><br><span class="hint">key lacks full scope, so no preview token \u2014 mint one server-side to play</span>';
3750
+ return;
3751
+ }
3752
+ const src = mediaHost + '/' + assetId + '.m3u8?token=' + encodeURIComponent(t.playback);
3753
+ st.innerHTML = 'ready \u2014 asset <span class="mono">' + assetId + '</span>';
3754
+ const v = document.createElement('video');
3755
+ v.controls = true; v.playsInline = true;
3756
+ v.poster = mediaHost + '/' + assetId + '/thumbnail.jpg?token=' + encodeURIComponent(t.thumbnail);
3757
+ el.appendChild(v);
3758
+ if (v.canPlayType('application/vnd.apple.mpegurl')) v.src = src;
3759
+ else {
3760
+ const s = document.createElement('script');
3761
+ s.src = 'https://cdn.jsdelivr.net/npm/hls.js@1';
3762
+ s.onload = () => { const h = new Hls(); h.loadSource(src); h.attachMedia(v); };
3763
+ document.head.appendChild(s);
3764
+ }
3765
+ el.insertAdjacentHTML('beforeend',
3766
+ '<p class="hint">playback URL (token expires in 1h \u2014 mint per view in production):</p><p class="mono">' + src + '</p>');
3767
+ } catch (e) {
3768
+ st.innerHTML = '<span class="err">' + e.message + '</span>';
3769
+ }
3770
+ }
3771
+ </script>`;
3772
+ }
3773
+ });
3774
+
3465
3775
  // ../../node_modules/.pnpm/commander@14.0.3/node_modules/commander/esm.mjs
3466
3776
  var import_index = __toESM(require_commander(), 1);
3467
3777
  var {
@@ -3536,9 +3846,9 @@ function makeClient(opts) {
3536
3846
  method,
3537
3847
  headers: {
3538
3848
  authorization: `Bearer ${opts.key}`,
3539
- ...o.body !== void 0 ? { "content-type": "application/json" } : {}
3849
+ ...o.rawBody ? { "content-type": o.rawBody.contentType } : o.body !== void 0 ? { "content-type": "application/json" } : {}
3540
3850
  },
3541
- ...o.body !== void 0 ? { body: JSON.stringify(o.body) } : {}
3851
+ ...o.rawBody ? { body: o.rawBody.text } : o.body !== void 0 ? { body: JSON.stringify(o.body) } : {}
3542
3852
  });
3543
3853
  const body = await res.json().catch(() => ({}));
3544
3854
  if (!res.ok) throw new ApiError(res.status, body);
@@ -3549,6 +3859,14 @@ function makeClient(opts) {
3549
3859
  assets: (params = {}) => req2("GET", "/v1/assets", { params }),
3550
3860
  asset: (id) => req2("GET", `/v1/assets/${id}`),
3551
3861
  assetTokens: (id, body = {}) => req2("POST", `/v1/assets/${id}/tokens`, { body }),
3862
+ subtitles: (id) => req2("GET", `/v1/assets/${id}/subtitles`),
3863
+ subtitleSet: (id, lang, vtt, o = {}) => req2(
3864
+ "PUT",
3865
+ `/v1/assets/${id}/subtitles/${lang}`,
3866
+ { params: { name: o.name, default: o.default ? "true" : void 0 }, rawBody: { text: vtt, contentType: "text/vtt" } }
3867
+ ),
3868
+ subtitleDelete: (id, lang) => req2("DELETE", `/v1/assets/${id}/subtitles/${lang}`),
3869
+ assetDelete: (id) => req2("DELETE", `/v1/assets/${id}`),
3552
3870
  usage: (params = {}) => req2("GET", "/v1/usage", { params }),
3553
3871
  events: (params = {}) => req2("GET", "/v1/events", { params }),
3554
3872
  redeliver: (id) => req2("POST", `/v1/events/${id}/redeliver`, { body: {} }),
@@ -3770,90 +4088,8 @@ function partSizeOf(s, n) {
3770
4088
  return s.partSize;
3771
4089
  }
3772
4090
 
3773
- // ../upload/src/api/client.ts
3774
- var ApiError2 = class extends Error {
3775
- status;
3776
- code;
3777
- constructor(status, code) {
3778
- super(code ? `hostr api error ${status}: ${code}` : `hostr api error ${status}`);
3779
- this.name = "ApiError";
3780
- this.status = status;
3781
- this.code = code;
3782
- }
3783
- };
3784
- var NetworkError = class extends Error {
3785
- constructor(message = "network error") {
3786
- super(message);
3787
- this.name = "NetworkError";
3788
- }
3789
- };
3790
- var trimEndpoint = (endpoint) => endpoint.replace(/\/$/, "");
3791
- async function req(fetchFn, endpoint, bearer, path, init2 = {}) {
3792
- const url = `${trimEndpoint(endpoint)}${path}`;
3793
- const headers = { authorization: `Bearer ${bearer}` };
3794
- let body;
3795
- if (init2.body !== void 0) {
3796
- headers["content-type"] = "application/json";
3797
- body = JSON.stringify(init2.body);
3798
- }
3799
- let res;
3800
- try {
3801
- res = await fetchFn(url, { method: init2.method ?? "GET", headers, body });
3802
- } catch {
3803
- throw new NetworkError();
3804
- }
3805
- const text = await res.text();
3806
- const data = text ? JSON.parse(text) : {};
3807
- if (!res.ok) {
3808
- const code = data && typeof data === "object" && typeof data.error === "string" ? data.error : void 0;
3809
- throw new ApiError2(res.status, code);
3810
- }
3811
- return data;
3812
- }
3813
- var HostrApi = class {
3814
- #endpoint;
3815
- #bearer;
3816
- #fetch;
3817
- constructor(endpoint, bearer, fetchFn = fetch) {
3818
- this.#endpoint = endpoint;
3819
- this.#bearer = bearer;
3820
- this.#fetch = fetchFn;
3821
- }
3822
- setBearer(bearer) {
3823
- this.#bearer = bearer;
3824
- }
3825
- getUpload(assetId) {
3826
- return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}`);
3827
- }
3828
- presignParts(assetId, partNumbers, expiresIn) {
3829
- return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}/parts`, {
3830
- method: "POST",
3831
- body: expiresIn === void 0 ? { partNumbers } : { partNumbers, expiresIn }
3832
- });
3833
- }
3834
- complete(assetId, body) {
3835
- return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}/complete`, {
3836
- method: "POST",
3837
- body
3838
- });
3839
- }
3840
- async abort(assetId) {
3841
- await req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}`, { method: "DELETE" });
3842
- }
3843
- };
3844
- async function createUploadSession(opts, fetchFn = fetch) {
3845
- const { endpoint, apiKey, ...body } = opts;
3846
- return req(fetchFn, endpoint, apiKey, "/v1/uploads", { method: "POST", body });
3847
- }
3848
- function mintUploadToken(opts, fetchFn = fetch) {
3849
- const { endpoint, apiKey, assetId, tokenTtlSec } = opts;
3850
- return req(fetchFn, endpoint, apiKey, `/v1/uploads/${assetId}/token`, {
3851
- method: "POST",
3852
- body: tokenTtlSec === void 0 ? {} : { tokenTtlSec }
3853
- });
3854
- }
3855
-
3856
4091
  // ../upload/src/core/retry.ts
4092
+ init_client();
3857
4093
  var RETRY_BUDGET = 6;
3858
4094
  function classifyPut(res, err) {
3859
4095
  if (err instanceof NetworkError) return "network";
@@ -3923,6 +4159,9 @@ function computeProgress(i) {
3923
4159
  return progress;
3924
4160
  }
3925
4161
 
4162
+ // ../upload/src/core/engine.ts
4163
+ init_client();
4164
+
3926
4165
  // ../upload/src/hash/hex.ts
3927
4166
  var HEX = "0123456789abcdef";
3928
4167
  function bytesToHex(b) {
@@ -4669,6 +4908,7 @@ var UploadEngine = class {
4669
4908
  };
4670
4909
 
4671
4910
  // ../upload/src/core/upload.ts
4911
+ init_client();
4672
4912
  var realClock = { now: () => Date.now(), sleep: (ms) => new Promise((res) => setTimeout(res, ms)) };
4673
4913
  async function buildUpload(opts) {
4674
4914
  const clock = opts.clock ?? realClock;
@@ -4738,6 +4978,7 @@ var NodeMd5 = class {
4738
4978
  var nodeHasher = { md5: () => new NodeMd5() };
4739
4979
 
4740
4980
  // ../upload/src/node/index.ts
4981
+ init_client();
4741
4982
  async function nodeFileSource(path) {
4742
4983
  const handle = await open(path, "r");
4743
4984
  const stat2 = await handle.stat();
@@ -4824,9 +5065,9 @@ async function tailLoop(opts) {
4824
5065
  let cursor = opts.after;
4825
5066
  const poll = async () => {
4826
5067
  try {
4827
- const page = await opts.fetchPage(cursor);
4828
- if (page.items.length) opts.onBatch(page.items);
4829
- cursor = page.nextCursor ?? cursor;
5068
+ const page2 = await opts.fetchPage(cursor);
5069
+ if (page2.items.length) opts.onBatch(page2.items);
5070
+ cursor = page2.nextCursor ?? cursor;
4830
5071
  return true;
4831
5072
  } catch {
4832
5073
  return false;
@@ -4857,14 +5098,14 @@ var MIME = {
4857
5098
  };
4858
5099
  var mimeOf = (file) => MIME[(/\.([A-Za-z0-9]+)$/.exec(file)?.[1] ?? "").toLowerCase()] ?? "application/octet-stream";
4859
5100
  async function assetsList(ctx, opts) {
4860
- const page = await ctx.client.assets({
5101
+ const page2 = await ctx.client.assets({
4861
5102
  status: opts.status,
4862
5103
  cursor: opts.cursor,
4863
5104
  limit: opts.limit ? Number(opts.limit) : void 0
4864
5105
  });
4865
5106
  ctx.out.print(
4866
5107
  table(
4867
- page.items.map((a) => ({ ...a, bytes: fmtBytes(a.bytes), createdAt: fmtWhen(a.createdAt) })),
5108
+ page2.items.map((a) => ({ ...a, bytes: fmtBytes(a.bytes), createdAt: fmtWhen(a.createdAt) })),
4868
5109
  [
4869
5110
  { key: "assetId", label: "ASSET" },
4870
5111
  { key: "status", label: "STATUS" },
@@ -4873,9 +5114,9 @@ async function assetsList(ctx, opts) {
4873
5114
  { key: "durationSec", label: "DURATION" },
4874
5115
  { key: "createdAt", label: "CREATED" }
4875
5116
  ]
4876
- ) + (page.nextCursor ? `
4877
- (more: --cursor ${page.nextCursor})` : ""),
4878
- page
5117
+ ) + (page2.nextCursor ? `
5118
+ (more: --cursor ${page2.nextCursor})` : ""),
5119
+ page2
4879
5120
  );
4880
5121
  }
4881
5122
  async function assetsGet(ctx, id) {
@@ -4897,6 +5138,27 @@ expires ${fmtWhen(new Date(t.expiresAt))}
4897
5138
  t
4898
5139
  );
4899
5140
  }
5141
+ async function assetsDelete(ctx, id) {
5142
+ const r = await ctx.client.assetDelete(id);
5143
+ ctx.out.print(`deleted ${r.assetId} (${r.objectsDeleted} objects purged)`, r);
5144
+ }
5145
+ async function subtitlesList(ctx, id) {
5146
+ const r = await ctx.client.subtitles(id);
5147
+ ctx.out.print(
5148
+ r.tracks.length ? r.tracks.map((t) => `${t.lang.padEnd(8)}${t.name.padEnd(24)}${t.default ? "default" : ""}`).join("\n") : "(no subtitle tracks)",
5149
+ r
5150
+ );
5151
+ }
5152
+ async function subtitlesSet(ctx, id, file, opts) {
5153
+ const { readFile: readFile4 } = await import("node:fs/promises");
5154
+ const vtt = await readFile4(file, "utf8");
5155
+ const r = await ctx.client.subtitleSet(id, opts.lang, vtt, { name: opts.name, default: opts.default });
5156
+ ctx.out.print(`subtitle ${r.lang} ("${r.name}") attached to ${r.assetId}${r.default ? " as default" : ""}`, r);
5157
+ }
5158
+ async function subtitlesRm(ctx, id, lang) {
5159
+ const r = await ctx.client.subtitleDelete(id, lang);
5160
+ ctx.out.print(`subtitle ${lang} removed from ${r.assetId}`, r);
5161
+ }
4900
5162
  async function tail(ctx, opts) {
4901
5163
  const fmt = (e) => `${fmtWhen(e.createdAt)} ${e.type.padEnd(16)} ${e.assetId ?? "-"}`;
4902
5164
  await tailLoop({
@@ -4910,6 +5172,10 @@ async function tail(ctx, opts) {
4910
5172
  }
4911
5173
  async function upload(ctx, file, opts) {
4912
5174
  if (!ctx.key) fail(ctx.out, new Error(NOT_LOGGED_IN));
5175
+ if (!file) {
5176
+ const { uploadGui: uploadGui2 } = await Promise.resolve().then(() => (init_gui(), gui_exports));
5177
+ return uploadGui2({ apiBase: ctx.apiBase, key: ctx.key, client: ctx.client });
5178
+ }
4913
5179
  const size = (await stat(file).catch(() => null))?.size;
4914
5180
  if (!size) fail(ctx.out, new Error(`cannot read ${file}`));
4915
5181
  const apiKey = ctx.key;
@@ -4961,7 +5227,7 @@ import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from
4961
5227
  import { join as join3 } from "node:path";
4962
5228
 
4963
5229
  // ../../docs/integrate.md
4964
- var integrate_default = '# hostr \u2014 integration guide\n\nhostr is self-hosted video infrastructure: resumable upload \u2192 transcode \u2192\nHLS delivery, with signed playback URLs. This document is the complete,\nself-contained integration reference. It is written to be executed by a\ncoding agent; every command has a `--json` mode and every flow is\nverifiable from the terminal.\n\n```\nAPI base https://api.hostr.sh (override: HOSTR_API_BASE)\nMedia host https://stream-dev.hostr.sh (playback URLs are built on this)\nDocs GET {API base}/llms.txt \xB7 GET {API base}/llms-full.txt (this file)\n```\n\n## 0. TL;DR for an integrating agent\n\n```bash\nnpx @hostr/cli login --key $HOSTR_API_KEY # validate + save the key (or export HOSTR_API_KEY)\nnpx @hostr/cli init # write docs/hostr.md + AGENTS.md section + .env.example entries\nnpx @hostr/cli upload demo.mp4 --wait --json # -> { assetId, status: "ready", ... }\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --json # -> { secret: "whsec_..." } ONCE\nnpx @hostr/cli tail --follow # watch the event log live\n```\n\nThen in your app: store `assetId`/`playbackId` per video, mint playback\ntokens server-side (\xA76), and hand players\n`{Media host}/{playbackId}.m3u8?token=\u2026`. Verify webhooks with the snippet\nin \xA75. That is the whole integration.\n\n## 1. Authentication\n\nEvery API call sends `Authorization: Bearer <credential>`. There are two\ncredential kinds an integrator sees:\n\n- **API key** \u2014 `hostr_sk_live_\u2026`, minted per account in the hostr\n dashboard or via `POST /v1/keys` / `npx @hostr/cli keys create`. Scoped:\n - `read` \u2014 GETs only (status, listing, usage, events)\n - `upload` \u2014 read + the upload lifecycle\n - `full` \u2014 everything, including playback-token minting and key management\n Keys are hashed at rest; the plaintext is shown exactly once at mint.\n Rotation = create new + revoke old (`DELETE /v1/keys/:id`); revocation\n takes effect within ~60 s. **Keep keys server-side.** Never ship one in a\n browser or mobile app.\n- **Upload token** \u2014 short-lived HMAC scoped to a single upload\n (`uploadToken` in the create-upload response). This is the only\n credential an untrusted client (browser/mobile) should ever hold: it can\n drive that one upload and nothing else.\n\nAuth errors: `401 {"error":"unauthorized"}` (bad/missing/revoked key, or a\nresource you don\'t own \u2014 hostr does not reveal which),\n`403 {"error":"insufficient_scope","need":"upload"}`,\n`403 {"error":"account_suspended"}`,\n`503 {"error":"control_plane_unavailable"}` (transient \u2014 retry with backoff;\nin-flight uploads with upload tokens are unaffected).\n\n## 2. Upload\n\nThree ways, strongest first:\n\n### 2a. CLI (recommended \u2014 resumable, integrity-checked)\n\n```bash\nnpx @hostr/cli upload path/to/video.mp4 --json # returns when bytes are verified in R2\nnpx @hostr/cli upload path/to/video.mp4 --wait --json # returns when transcode is ready/failed\n```\n\nOutput: `{ "assetId": "\u2026", "status": "queued"|"ready", "bytes": n, "etag": "\u2026", "sha256": "\u2026" }`.\nThe CLI wraps hostr\'s upload SDK: chunked multipart direct to storage,\nresume after crash/restart (server-authoritative \u2014 it asks storage which\nparts landed), retry with backoff, and three-layer integrity verification.\nExit code 1 + JSON on stderr on failure.\n\n### 2b. Raw HTTP (any language, no SDK)\n\n```\nPOST /v1/uploads {"filename":"a.mp4","size":<bytes>,"contentType":"video/mp4"}\n -> 201 { assetId, partSize, expectedParts, uploadToken, tokenExpiresAt, key, uploadId }\n (forward ONLY assetId/uploadToken/partSize/expectedParts/tokenExpiresAt to untrusted clients)\n\nPOST /v1/uploads/:id/parts {"partNumbers":[1,2,...]} (\u2264100 per call)\n -> { urls: [{partNumber, url}], expiresIn }\nPUT <each presigned url> body = that part\'s bytes; capture the ETag response header\n\nGET /v1/uploads/:id -> { status, uploadedParts, parts, bytesUploaded } (resume)\nPOST /v1/uploads/:id/complete {"sha256":"<hex of whole file>"} (sha256 optional)\n -> { assetId, status: "uploaded"|"queued", bytes, etag }\nDELETE /v1/uploads/:id abort\nPOST /v1/uploads/:id/token re-mint the upload token (API key with upload scope)\n```\n\nRules: every part is `partSize` bytes except the last; `size` must be\nexact \u2014 completion verifies both part count and total bytes against what\nactually landed and returns `409 incomplete_upload` (with the missing part\nnumbers) or `409 size_mismatch` otherwise. Completion is idempotent \u2014 a\ncrashed client retries it safely. Uploads are quota-gated:\n`403 {"error":"quota_uploads_daily"|"quota_assets"|"quota_storage","limit":n}`.\n\n### 2c. Browser/mobile clients\n\nCreate the upload server-side (2b), hand the client only\n`{assetId, uploadToken, partSize, expectedParts}`, and let it PUT parts\ndirectly to the presigned URLs \u2014 upload bytes never touch your servers.\n(A browser needs the storage bucket to allow your origin via CORS \u2014 if\npart PUTs stall at 0% with network-looking errors, that\'s the cause.)\n\n## 3. Asset lifecycle\n\n`uploading \u2192 uploaded \u2192 queued \u2192 processing \u2192 ready | failed` (plus\n`aborted`). Poll `GET /v1/assets/:id` (`read` scope) or \u2014 better \u2014 use\nwebhooks (\xA75). `GET /v1/assets?limit=&status=&cursor=` lists your assets\nnewest-first with keyset pagination (`nextCursor`).\n\nWhen `ready`, the asset record and the `video.ready` event both carry\n`playbackId` (equals `assetId` today \u2014 store it as its own field) and\n`durationSec`.\n\n## 4. Account, usage, keys\n\n```\nGET /v1/account -> { accountId, name, plan, limits, usage } (read)\nGET /v1/usage?from&to -> { days: [{day, uploads, bytesUploaded, encodedSeconds}] } (read)\nPOST /v1/keys {"name":"ci","scopes":["upload"]} -> { key: "hostr_sk_live_..." } ONCE (full)\nGET /v1/keys -> metadata only, never hashes (full)\nDELETE /v1/keys/:id -> { revoked: true } (full)\n```\n\n## 5. Webhooks + the event log\n\nConfigure one endpoint per account:\n\n```\nPUT /v1/webhooks {"url":"https://yourapp.example/hostr-webhook"} (full)\n -> { url, enabled, secret: "whsec_..." } # secret returned ONLY when generated, ONLY once\nGET /v1/webhooks -> { url, enabled, createdAt } # never the secret\nDELETE /v1/webhooks -> disables (delivery history retained)\n```\n\nEvents \u2014 body `{ id, type, createdAt, data }`; `id` is the durable event\nrow\'s id (dedupe on it):\n\n| type | fired when | data highlights |\n|------------------|----------------------------------------|------------------------------------------|\n| `upload.created` | multipart session opened | `assetId`, `filename`, `declaredSize` |\n| `video.uploaded` | bytes verified in storage, transcode queued | `assetId`, `bytes`, `etag`, `sha256?` |\n| `video.ready` | HLS renditions live | `assetId`, `playbackId`, `durationSec?` |\n| `video.errored` | transcode failed | `assetId`, `error` (always present) |\n\nEndpoint URLs: both `https` and `http` are accepted, including private\naddresses \u2014 hostr does not currently block internal ranges, so a local\nreceiver works in development exactly like production.\n\nEvery delivery carries `hostr-signature`, a comma-separated list currently\ncontaining `t=<unix-seconds>` and `v1=<hex>` where\n`v1 = HMAC-SHA256(secret, "<t>.<rawBody>")` \u2014 the Stripe scheme. Parse it\nas a list (future fields may be added); verify before trusting\n(constant-time compare, bounded timestamp):\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\n\nexport function verifyHostrWebhook(secret, header, rawBody, toleranceSec = 300) {\n const m = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? \'\');\n if (!m) return false;\n if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSec) return false;\n const expected = createHmac(\'sha256\', secret).update(`${m[1]}.${rawBody}`).digest(\'hex\');\n return expected.length === m[2].length &&\n timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));\n}\n// Use the RAW request body bytes \u2014 parse JSON only after verification.\n```\n\nDelivery is at-least-once (3 attempts with backoff, each recorded). The\nwebhook is the wake-up signal; the **durable event log** is the ledger:\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending; reuse nextCursor verbatim (read)\nGET /v1/events?order=desc&limit=1 newest first (e.g. the latest event) (read)\nPOST /v1/events/:id/redeliver replay any stored event (full)\n```\n\n`npx @hostr/cli tail --follow` polls this for you; `npx @hostr/cli webhooks test`\nredelivers the newest event to prove your endpoint end-to-end. Respond\n`2xx` fast (do the work async) \u2014 anything else is retried.\n\n## 6. Playback\n\nPlayback is token-gated. Mint per view, server-side, with a `full` key:\n\n```\nPOST /v1/assets/:id/tokens {"ttlSec":3600,"viewerId":"user-123"} (optional body)\n -> { playback, thumbnail, storyboard, expiresAt } (expiresAt in ms)\nnpx @hostr/cli assets token <assetId> --json (CLI equivalent)\n```\n\nMinting works any time after the asset exists (it 404s only for unknown\nassets); the stream itself returns 404 until the asset is `ready` \u2014 gate\nyour UI on `video.ready` / asset status, not on whether minting succeeds.\n\nBuild the URL and hand it to any HLS player:\n\n```\nhttps://stream-dev.hostr.sh/{playbackId}.m3u8?token={playback}\n```\n\n- Use hls.js in browsers without native HLS; Safari/iOS play it natively.\n- The token is checked once at the master playlist; the stream then works\n for the whole viewing session (segment credentials are handled\n internally). Default TTL 1 h \u2014 it only needs to be valid when playback\n *starts*.\n- `viewerId` enables per-viewer concurrency observation.\n- **Non-browser user agents are rejected** (`403 blocked_user_agent`):\n curl/wget/ffmpeg/vlc UAs are blocked as a casual-ripping speed bump. For\n server-side smoke tests send a browser-like `User-Agent`.\n- Signed URLs are not DRM: they stop link-sharing and hotlinking, not an\n entitled viewer recording their screen.\n\n## 7. Error handling summary\n\n| status | error | meaning / action |\n|--------|------------------------------|------------------|\n| 401 | `unauthorized` | bad/revoked key, or not your resource \u2014 check key + assetId |\n| 403 | `insufficient_scope` | key scope too low; `need` names the required scope |\n| 403 | `quota_*` | account limit hit; `limit` is the ceiling |\n| 403 | `account_suspended` | contact the operator |\n| 409 | `incomplete_upload` | parts missing (`missing` lists them) \u2014 upload them, re-complete |\n| 409 | `size_mismatch` | declared size \u2260 bytes landed \u2014 abort and restart |\n| 409 | `not_uploading` | upload already finished/aborted |\n| 413 | `file_too_large` | raise `partSize` or split |\n| 503 | `control_plane_unavailable` | transient \u2014 retry with backoff |\n| 500 | `signing_not_configured` | operator-side: the playback signing key isn\'t loaded on this API deployment. Not your integration\'s fault and not retryable from the client \u2014 report it |\n\n## 8. Full route table\n\n| Method | Path | Scope | Purpose |\n|---|---|---|---|\n| POST | `/v1/uploads` | upload | create upload session |\n| GET | `/v1/uploads/:id` | upload* | status + landed parts (resume) |\n| POST | `/v1/uploads/:id/parts` | upload* | presigned part URLs |\n| POST | `/v1/uploads/:id/complete` | upload* | verify + finalize |\n| POST | `/v1/uploads/:id/token` | upload | re-mint upload token |\n| DELETE | `/v1/uploads/:id` | upload* | abort |\n| GET | `/v1/assets` | read | list own assets |\n| GET | `/v1/assets/:id` | read | asset record |\n| POST | `/v1/assets/:id/tokens` | full | playback credentials |\n| GET | `/v1/account` | read | whoami: plan, limits, usage |\n| GET | `/v1/usage` | read | daily usage rows |\n| GET | `/v1/events` | read | durable event log (ascending tail; `order=desc` for newest-first) |\n| POST | `/v1/events/:id/redeliver` | full | replay a webhook |\n| GET/PUT/DELETE | `/v1/webhooks` | full | endpoint config |\n| POST/GET | `/v1/keys` | full | mint / list keys |\n| DELETE | `/v1/keys/:id` | full | revoke key |\n\n\\* also accepted: that upload\'s own `uploadToken`.\n\n## 9. CLI reference\n\n`npx @hostr/cli <cmd>` \u2014 every command takes `--json` (one JSON document on\nstdout; NDJSON for `tail --follow`; errors as JSON on stderr, exit 1).\nCredentials: `--key` flag > `HOSTR_API_KEY` env > saved profile\n(`hostr login`). API base: `--api` > `HOSTR_API_BASE` > profile > default.\n\n```\nlogin \xB7 whoami \xB7 usage \xB7 init\nkeys create|list|revoke\nwebhooks get|set|delete|test\nassets list|get \xB7 assets token <id> [--ttl <sec>] \xB7 upload <file> [--wait] \xB7 tail [--follow]\n```\n';
5230
+ var integrate_default = '# hostr \u2014 integration guide\n\nhostr is self-hosted video infrastructure: resumable upload \u2192 transcode \u2192\nHLS delivery, with signed playback URLs. This document is the complete,\nself-contained integration reference. It is written to be executed by a\ncoding agent; every command has a `--json` mode and every flow is\nverifiable from the terminal.\n\n```\nAPI base https://api.hostr.sh (override: HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\nDocs GET {API base}/llms.txt \xB7 GET {API base}/llms-full.txt (this file)\n```\n\n## 0. TL;DR for an integrating agent\n\n```bash\nnpx @hostr/cli login --key $HOSTR_API_KEY # validate + save the key (or export HOSTR_API_KEY)\nnpx @hostr/cli init # write docs/hostr.md + AGENTS.md section + .env.example entries\nnpx @hostr/cli upload demo.mp4 --wait --json # -> { assetId, status: "ready", ... }\nnpx @hostr/cli upload # no file: opens a local drag-drop page in the browser\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --json # -> { secret: "whsec_..." } ONCE\nnpx @hostr/cli tail --follow # watch the event log live\n```\n\nThen in your app: store `assetId`/`playbackId` per video, mint playback\ntokens server-side (\xA76), and hand players\n`{Media host}/{playbackId}.m3u8?token=\u2026`. Verify webhooks with the snippet\nin \xA75. That is the whole integration.\n\n## 1. Authentication\n\nEvery API call sends `Authorization: Bearer <credential>`. There are two\ncredential kinds an integrator sees:\n\n- **API key** \u2014 `hostr_sk_live_\u2026`, minted per account in the hostr\n dashboard or via `POST /v1/keys` / `npx @hostr/cli keys create`. Scoped:\n - `read` \u2014 GETs only (status, listing, usage, events)\n - `upload` \u2014 read + the upload lifecycle\n - `full` \u2014 everything, including playback-token minting and key management\n Keys are hashed at rest; the plaintext is shown exactly once at mint.\n Rotation = create new + revoke old (`DELETE /v1/keys/:id`); revocation\n takes effect within ~60 s. **Keep keys server-side.** Never ship one in a\n browser or mobile app.\n- **Upload token** \u2014 short-lived HMAC scoped to a single upload\n (`uploadToken` in the create-upload response). This is the only\n credential an untrusted client (browser/mobile) should ever hold: it can\n drive that one upload and nothing else.\n\nAuth errors: `401 {"error":"unauthorized"}` (bad/missing/revoked key, or a\nresource you don\'t own \u2014 hostr does not reveal which),\n`403 {"error":"insufficient_scope","need":"upload"}`,\n`403 {"error":"account_suspended"}`,\n`503 {"error":"control_plane_unavailable"}` (transient \u2014 retry with backoff;\nin-flight uploads with upload tokens are unaffected).\n\n## 2. Upload\n\nThree ways, strongest first:\n\n### 2a. CLI (recommended \u2014 resumable, integrity-checked)\n\n```bash\nnpx @hostr/cli upload path/to/video.mp4 --json # returns when bytes are verified in R2\nnpx @hostr/cli upload path/to/video.mp4 --wait --json # returns when transcode is ready/failed\n```\n\nOutput: `{ "assetId": "\u2026", "status": "queued"|"ready", "bytes": n, "etag": "\u2026", "sha256": "\u2026" }`.\nThe CLI wraps hostr\'s upload SDK: chunked multipart direct to storage,\nresume after crash/restart (server-authoritative \u2014 it asks storage which\nparts landed), retry with backoff, and three-layer integrity verification.\nExit code 1 + JSON on stderr on failure.\n\n### 2b. Raw HTTP (any language, no SDK)\n\n```\nPOST /v1/uploads {"filename":"a.mp4","size":<bytes>,"contentType":"video/mp4"}\n -> 201 { assetId, partSize, expectedParts, uploadToken, tokenExpiresAt, key, uploadId }\n (forward ONLY assetId/uploadToken/partSize/expectedParts/tokenExpiresAt to untrusted clients)\n\nPOST /v1/uploads/:id/parts {"partNumbers":[1,2,...]} (\u2264100 per call)\n -> { urls: [{partNumber, url}], expiresIn }\nPUT <each presigned url> body = that part\'s bytes; capture the ETag response header\n\nGET /v1/uploads/:id -> { status, uploadedParts, parts, bytesUploaded } (resume)\nPOST /v1/uploads/:id/complete {"sha256":"<hex of whole file>"} (sha256 optional)\n -> { assetId, status: "uploaded"|"queued", bytes, etag }\nDELETE /v1/uploads/:id abort\nPOST /v1/uploads/:id/token re-mint the upload token (API key with upload scope)\n```\n\nRules: every part is `partSize` bytes except the last; `size` must be\nexact \u2014 completion verifies both part count and total bytes against what\nactually landed and returns `409 incomplete_upload` (with the missing part\nnumbers) or `409 size_mismatch` otherwise. Completion is idempotent \u2014 a\ncrashed client retries it safely. Uploads are quota-gated:\n`403 {"error":"quota_uploads_daily"|"quota_assets"|"quota_storage","limit":n}`.\n\n### 2c. Browser/mobile clients\n\nCreate the upload server-side (2b), hand the client only\n`{assetId, uploadToken, partSize, expectedParts}`, and let it PUT parts\ndirectly to the presigned URLs \u2014 upload bytes never touch your servers.\n(A browser needs the storage bucket to allow your origin via CORS \u2014 if\npart PUTs stall at 0% with network-looking errors, that\'s the cause.)\n\n## 3. Asset lifecycle\n\n`uploading \u2192 uploaded \u2192 queued \u2192 processing \u2192 ready | failed` (plus\n`aborted`). Poll `GET /v1/assets/:id` (`read` scope) or \u2014 better \u2014 use\nwebhooks (\xA75). While `processing`, the asset carries live transcode\nprogress \u2014 `progress: { encodedSec, totalSec, speed }` (speed is the\nencode multiple of realtime; the field is null/absent outside\n`processing`) \u2014 so a UI can show a real percentage instead of a spinner. `GET /v1/assets?limit=&status=&cursor=` lists your assets\nnewest-first with keyset pagination (`nextCursor`).\n\nWhen `ready`, the asset record and the `video.ready` event both carry\n`playbackId` (equals `assetId` today \u2014 store it as its own field) and\n`durationSec`.\n\n## 4. Account, usage, keys\n\n```\nGET /v1/account -> { accountId, name, plan, limits, usage } (read)\nGET /v1/usage?from&to -> { days: [{day, uploads, bytesUploaded, encodedSeconds}] } (read)\nPOST /v1/keys {"name":"ci","scopes":["upload"]} -> { key: "hostr_sk_live_..." } ONCE (full)\nGET /v1/keys -> metadata only, never hashes (full)\nDELETE /v1/keys/:id -> { revoked: true } (full)\n```\n\n## 5. Webhooks + the event log\n\nConfigure one endpoint per account:\n\n```\nPUT /v1/webhooks {"url":"https://yourapp.example/hostr-webhook"} (full)\n -> { url, enabled, secret: "whsec_..." } # secret returned ONLY when generated, ONLY once\nGET /v1/webhooks -> { url, enabled, createdAt } # never the secret\nDELETE /v1/webhooks -> disables (delivery history retained)\n```\n\nEvents \u2014 body `{ id, type, createdAt, data }`; `id` is the durable event\nrow\'s id (dedupe on it):\n\n| type | fired when | data highlights |\n|------------------|----------------------------------------|------------------------------------------|\n| `upload.created` | multipart session opened | `assetId`, `filename`, `declaredSize` |\n| `video.uploaded` | bytes verified in storage, transcode queued | `assetId`, `bytes`, `etag`, `sha256?` |\n| `video.ready` | HLS renditions live | `assetId`, `playbackId`, `durationSec?` |\n| `video.errored` | transcode failed | `assetId`, `error` (always present) |\n| `video.deleted` | asset erased; playback over | `assetId` |\n\nEndpoint URLs: both `https` and `http` are accepted, including private\naddresses \u2014 hostr does not currently block internal ranges, so a local\nreceiver works in development exactly like production.\n\nEvery delivery carries `hostr-signature`, a comma-separated list currently\ncontaining `t=<unix-seconds>` and `v1=<hex>` where\n`v1 = HMAC-SHA256(secret, "<t>.<rawBody>")` \u2014 the Stripe scheme. Parse it\nas a list (future fields may be added); verify before trusting\n(constant-time compare, bounded timestamp):\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\n\nexport function verifyHostrWebhook(secret, header, rawBody, toleranceSec = 300) {\n const m = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? \'\');\n if (!m) return false;\n if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSec) return false;\n const expected = createHmac(\'sha256\', secret).update(`${m[1]}.${rawBody}`).digest(\'hex\');\n return expected.length === m[2].length &&\n timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));\n}\n// Use the RAW request body bytes \u2014 parse JSON only after verification.\n```\n\nDelivery is at-least-once (3 attempts with backoff, each recorded). The\nwebhook is the wake-up signal; the **durable event log** is the ledger:\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending; reuse nextCursor verbatim (read)\nGET /v1/events?order=desc&limit=1 newest first (e.g. the latest event) (read)\nPOST /v1/events/:id/redeliver replay any stored event (full)\n```\n\n`npx @hostr/cli tail --follow` polls this for you; `npx @hostr/cli webhooks test`\nredelivers the newest event to prove your endpoint end-to-end. Respond\n`2xx` fast (do the work async) \u2014 anything else is retried.\n\n## 6. Playback\n\nPlayback is token-gated. Mint per view, server-side, with a `full` key:\n\n```\nPOST /v1/assets/:id/tokens {"ttlSec":3600,"viewerId":"user-123"} (optional body)\n -> { playback, thumbnail, storyboard, expiresAt } (expiresAt in ms)\nnpx @hostr/cli assets token <assetId> --json (CLI equivalent)\n```\n\nMinting works any time after the asset exists (it 404s only for unknown\nassets); the stream itself returns 404 until the asset is `ready` \u2014 gate\nyour UI on `video.ready` / asset status, not on whether minting succeeds.\n\nBuild the URL and hand it to any HLS player:\n\n```\nhttps://stream.hostr.sh/{playbackId}.m3u8?token={playback}\n```\n\n- Use hls.js in browsers without native HLS; Safari/iOS play it natively.\n- The token is checked once at the master playlist; the stream then works\n for the whole viewing session (segment credentials are handled\n internally). Default TTL 1 h \u2014 it only needs to be valid when playback\n *starts*.\n- `viewerId` enables per-viewer concurrency observation.\n- **Non-browser user agents are rejected** (`403 blocked_user_agent`):\n curl/wget/ffmpeg/vlc UAs are blocked as a casual-ripping speed bump. For\n server-side smoke tests send a browser-like `User-Agent`.\n- Signed URLs are not DRM: they stop link-sharing and hotlinking, not an\n entitled viewer recording their screen.\n- `DELETE /v1/assets/:id` erases an asset for good: source and renditions\n are purged from storage, the storage quota is freed, and `video.deleted`\n is emitted. Deletion is idempotent (repeat calls return `deleted`\n again). New playback tokens stop minting immediately; tokens already\n issued can replay edge-cached segments for at most a few hours until\n they expire. There is no undelete.\n\nThe token bundle also covers the poster image and scrub previews \u2014 same\nhost, same query-token shape, each URL taking its matching token from the\nmint response:\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nThe storyboard is a standard "thumbnails" WebVTT track (cues point into a\ntiled sprite with `#xywh=` fragments) \u2014 hand it to mux-player\'s\n`storyboard-src`, media-chrome\'s preview track, or any player that consumes\nthat convention; the sprite URLs inside the VTT are pre-signed for you.\nPlayers treat the track as optional: for assets encoded before storyboards\nexisted the URL 404s and scrubbing simply shows no preview.\n\n## 6b. Subtitles\n\nHuman-authored WebVTT tracks, attached any time after the asset is `ready`\n(409 `not_ready` before that \u2014 the playlist needs the duration):\n\n```\nPUT /v1/assets/:id/subtitles/:lang?name=English&default=true (body: raw WebVTT, upload scope)\nGET /v1/assets/:id/subtitles -> { tracks: [{ lang, name, default, rev }] }\nDELETE /v1/assets/:id/subtitles/:lang\nnpx @hostr/cli subtitles set <assetId> subs.vtt --lang en --name English --default\n```\n\n- `:lang` is a BCP 47 tag (`en`, `pt-BR`); one track per tag, PUT replaces.\n- Tracks appear in the master playlist automatically (`EXT-X-MEDIA`\n subtitles group) \u2014 the next master fetch after a PUT includes them, no\n re-encode, no new playback URL. Players show them in the captions menu.\n- `default=true` demotes any previous default.\n- Replacements propagate on the next playback session; a viewer mid-session\n keeps the track they started with.\n\n## 7. Error handling summary\n\n| status | error | meaning / action |\n|--------|------------------------------|------------------|\n| 401 | `unauthorized` | bad/revoked key, or not your resource \u2014 check key + assetId |\n| 403 | `insufficient_scope` | key scope too low; `need` names the required scope |\n| 403 | `quota_*` | account limit hit; `limit` is the ceiling |\n| 403 | `account_suspended` | contact the operator |\n| 409 | `incomplete_upload` | parts missing (`missing` lists them) \u2014 upload them, re-complete |\n| 409 | `size_mismatch` | declared size \u2260 bytes landed \u2014 abort and restart |\n| 409 | `not_uploading` | upload already finished/aborted |\n| 413 | `file_too_large` | raise `partSize` or split |\n| 503 | `control_plane_unavailable` | transient \u2014 retry with backoff |\n| 500 | `signing_not_configured` | operator-side: the playback signing key isn\'t loaded on this API deployment. Not your integration\'s fault and not retryable from the client \u2014 report it |\n\n## 8. Full route table\n\n| Method | Path | Scope | Purpose |\n|---|---|---|---|\n| POST | `/v1/uploads` | upload | create upload session |\n| GET | `/v1/uploads/:id` | upload* | status + landed parts (resume) |\n| POST | `/v1/uploads/:id/parts` | upload* | presigned part URLs |\n| POST | `/v1/uploads/:id/complete` | upload* | verify + finalize |\n| POST | `/v1/uploads/:id/token` | upload | re-mint upload token |\n| DELETE | `/v1/uploads/:id` | upload* | abort |\n| GET | `/v1/assets` | read | list own assets |\n| GET | `/v1/assets/:id` | read | asset record |\n| POST | `/v1/assets/:id/tokens` | full | playback credentials |\n| DELETE | `/v1/assets/:id` | full | erase asset: R2 purged, quota freed, `video.deleted` emitted |\n| PUT | `/v1/assets/:id/subtitles/:lang` | upload | attach/replace WebVTT track |\n| GET | `/v1/assets/:id/subtitles` | read | list subtitle tracks |\n| DELETE | `/v1/assets/:id/subtitles/:lang` | upload | remove subtitle track |\n| GET | `/v1/account` | read | whoami: plan, limits, usage |\n| GET | `/v1/usage` | read | daily usage rows |\n| GET | `/v1/events` | read | durable event log (ascending tail; `order=desc` for newest-first) |\n| POST | `/v1/events/:id/redeliver` | full | replay a webhook |\n| GET/PUT/DELETE | `/v1/webhooks` | full | endpoint config |\n| POST/GET | `/v1/keys` | full | mint / list keys |\n| DELETE | `/v1/keys/:id` | full | revoke key |\n\n\\* also accepted: that upload\'s own `uploadToken`.\n\n## 9. CLI reference\n\n`npx @hostr/cli <cmd>` \u2014 every command takes `--json` (one JSON document on\nstdout; NDJSON for `tail --follow`; errors as JSON on stderr, exit 1).\nCredentials: `--key` flag > `HOSTR_API_KEY` env > saved profile\n(`hostr login`). API base: `--api` > `HOSTR_API_BASE` > profile > default.\n\n```\nlogin \xB7 whoami \xB7 usage \xB7 init\nkeys create|list|revoke\nwebhooks get|set|delete|test\nassets list|get \xB7 assets token <id> [--ttl <sec>] \xB7 upload <file> [--wait] \xB7 tail [--follow]\n```\n';
4965
5231
 
4966
5232
  // src/init-content.ts
4967
5233
  var BEGIN = "<!-- hostr:begin -->";
@@ -5050,7 +5316,7 @@ async function init(opts) {
5050
5316
  }
5051
5317
 
5052
5318
  // src/cli.ts
5053
- var program2 = new Command("hostr").description("hostr \u2014 self-hosted video infrastructure (upload, transcode, HLS delivery)").version("0.1.1").option("--key <key>", "API key (overrides HOSTR_API_KEY and the saved profile)").option("--api <url>", "API base URL (overrides HOSTR_API_BASE and the saved profile)").option("--json", "machine-readable output: one JSON document (NDJSON for tail --follow)");
5319
+ var program2 = new Command("hostr").description("hostr \u2014 self-hosted video infrastructure (upload, transcode, HLS delivery)").version("0.1.3").option("--key <key>", "API key (overrides HOSTR_API_KEY and the saved profile)").option("--api <url>", "API base URL (overrides HOSTR_API_BASE and the saved profile)").option("--json", "machine-readable output: one JSON document (NDJSON for tail --follow)");
5054
5320
  var globals = () => program2.opts();
5055
5321
  var run = (fn) => async (...args) => {
5056
5322
  const g = globals();
@@ -5079,15 +5345,20 @@ webhooks.command("get").action(run(webhooksGet));
5079
5345
  webhooks.command("set <url>").option("--secret <secret>", "bring your own whsec (default: generated, shown once)").action(run((ctx, url, opts) => webhooksSet(ctx, url, opts)));
5080
5346
  webhooks.command("delete").action(run(webhooksDelete));
5081
5347
  webhooks.command("test").description("redeliver the newest stored event to your endpoint").action(run(webhooksTest));
5348
+ var subtitles = program2.command("subtitles").description("subtitle tracks (WebVTT)");
5349
+ subtitles.command("list <assetId>").action(run((ctx, id) => subtitlesList(ctx, id)));
5350
+ subtitles.command("set <assetId> <file>").description("attach or replace a WebVTT track (requires upload scope)").requiredOption("--lang <tag>", "BCP 47 language tag, e.g. en or pt-BR").option("--name <label>", "menu label; defaults to the lang tag").option("--default", "make this the default track").action(run((ctx, id, file, opts) => subtitlesSet(ctx, id, file, opts)));
5351
+ subtitles.command("rm <assetId> <lang>").action(run((ctx, id, lang) => subtitlesRm(ctx, id, lang)));
5082
5352
  var assets = program2.command("assets").description("asset listing and status");
5083
5353
  assets.command("list").option("--status <status>").option("--limit <n>").option("--cursor <cursor>").action(run(assetsList));
5084
5354
  assets.command("get <id>").action(run((ctx, id) => assetsGet(ctx, id)));
5355
+ assets.command("delete <id>").description("erase an asset: R2 objects purged, playback ends (requires full scope)").action(run((ctx, id) => assetsDelete(ctx, id)));
5085
5356
  assets.command("token <id>").description("mint playback credentials (requires full scope)").option("--ttl <sec>").option("--viewer-id <id>").action(run((ctx, id, opts) => assetsToken(ctx, id, opts)));
5086
5357
  program2.command("tail").description("event log (durable; ascending). One page by default.").option("-f, --follow", "poll every 2s").option("--type <type>", "filter: upload.created | video.uploaded | video.ready | video.errored").option("--limit <n>").option("--after <cursor>", "start after this event id").action(run(tail));
5087
5358
  program2.command("init").description("make this repo agent-ready: docs/hostr.md + AGENTS.md section + .env.example entries").option("--dir <dir>", "target directory (default: cwd)").action(async (opts) => {
5088
5359
  await init({ ...globals(), ...opts });
5089
5360
  });
5090
- program2.command("upload <file>").description("resumable multipart upload via the hostr SDK").option("--content-type <type>").option("--wait", "block until transcode reaches ready/failed").action(run((ctx, file, opts) => upload(ctx, file, opts)));
5361
+ program2.command("upload [file]").description("upload a video \u2014 with no file, opens a local drag-drop page in the browser").option("--content-type <type>").option("--wait", "block until transcode reaches ready/failed (file mode)").action(run((ctx, file, opts) => upload(ctx, file, opts)));
5091
5362
  program2.parseAsync().catch((e) => {
5092
5363
  console.error(`error: ${e?.message ?? e}`);
5093
5364
  process.exit(1);
package/package.json CHANGED
@@ -1,15 +1,29 @@
1
1
  {
2
2
  "name": "@hostr/cli",
3
- "version": "0.1.1",
4
- "description": "CLI for hostr — self-hosted video infrastructure (upload, transcode, HLS delivery)",
3
+ "version": "0.1.3",
4
+ "description": "CLI for hostr \u2014 self-hosted video infrastructure (upload, transcode, HLS delivery)",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "bin": { "hostr": "dist/cli.js" },
8
- "files": ["dist"],
9
- "engines": { "node": ">=20" },
10
- "publishConfig": { "access": "public" },
11
- "repository": { "type": "git", "url": "git+https://github.com/growbase-org/hostr.git", "directory": "packages/cli" },
12
- "scripts": { "build": "node build.mjs" },
7
+ "bin": {
8
+ "hostr": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/growbase-org/hostr.git",
22
+ "directory": "packages/cli"
23
+ },
24
+ "scripts": {
25
+ "build": "node build.mjs"
26
+ },
13
27
  "devDependencies": {
14
28
  "commander": "^14.0.0",
15
29
  "esbuild": "^0.28.2"