@hostr/cli 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +5 -3
  2. package/dist/cli.js +372 -57
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,10 +5,12 @@ infrastructure: resumable upload → transcode → HLS delivery with signed
5
5
  playback URLs.
6
6
 
7
7
  ```bash
8
- npx @hostr/cli login # paste an API key from your hostr dashboard
9
- npx @hostr/cli init # write the integration guide + env template into your repo
8
+ npx @hostr/cli # setup status and the next command to run
9
+ npx @hostr/cli login # opens a browser for a human to approve — no key to paste, ever
10
+ npx @hostr/cli init # write the integration guide + env template into your repo
11
+ npx @hostr/cli keys create --name "app dev" --write-env .env.local
10
12
  npx @hostr/cli upload demo.mp4 --wait --json
11
- npx @hostr/cli webhooks set https://yourapp.example/hostr-webhook
13
+ npx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env.local
12
14
  npx @hostr/cli tail --follow
13
15
  ```
14
16
 
package/dist/cli.js CHANGED
@@ -3515,10 +3515,10 @@ var init_client = __esm({
3515
3515
  ApiError2 = class extends Error {
3516
3516
  status;
3517
3517
  code;
3518
- constructor(status, code) {
3519
- super(code ? `hostr api error ${status}: ${code}` : `hostr api error ${status}`);
3518
+ constructor(status2, code) {
3519
+ super(code ? `hostr api error ${status2}: ${code}` : `hostr api error ${status2}`);
3520
3520
  this.name = "ApiError";
3521
- this.status = status;
3521
+ this.status = status2;
3522
3522
  this.code = code;
3523
3523
  }
3524
3524
  };
@@ -3570,13 +3570,13 @@ __export(gui_exports, {
3570
3570
  });
3571
3571
  import { createServer } from "node:http";
3572
3572
  import { randomBytes } from "node:crypto";
3573
- import { spawn } from "node:child_process";
3573
+ import { spawn as spawn2 } from "node:child_process";
3574
3574
  async function uploadGui(ctx) {
3575
3575
  const secret = Array.from(randomBytes(16), (b) => b.toString(16).padStart(2, "0")).join("");
3576
3576
  const server = createServer(async (req2, res) => {
3577
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" });
3578
+ const send = (status2, body, type = "application/json") => {
3579
+ res.writeHead(status2, { "content-type": type, "cache-control": "no-store" });
3580
3580
  res.end(body);
3581
3581
  };
3582
3582
  try {
@@ -3637,7 +3637,7 @@ async function uploadGui(ctx) {
3637
3637
  (drop a video in the browser; Ctrl+C here when done)
3638
3638
  `);
3639
3639
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
3640
- spawn(opener, [addr], { stdio: "ignore", detached: true }).unref();
3640
+ spawn2(opener, [addr], { stdio: "ignore", detached: true }).unref();
3641
3641
  await new Promise(() => {
3642
3642
  });
3643
3643
  }
@@ -3686,6 +3686,13 @@ input.onchange = () => [...input.files].forEach(go);
3686
3686
  ['dragleave','drop'].forEach(e => drop.addEventListener(e, ev => { ev.preventDefault(); drop.classList.remove('over'); }));
3687
3687
  drop.addEventListener('drop', ev => [...ev.dataTransfer.files].forEach(go));
3688
3688
 
3689
+ // A bar that fills with no numbers beside it is indistinguishable from a hung
3690
+ // one \u2014 which is exactly how a 10-minute encode read before these existed.
3691
+ // Every long phase gets elapsed, rate, and a remaining estimate.
3692
+ const mmss = s => Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');
3693
+ const left = s => s >= 90 ? '~' + Math.round(s / 60) + 'm left' : '~' + Math.max(1, Math.round(s)) + 's left';
3694
+ const size = b => b >= 1e9 ? (b / 1e9).toFixed(2) + ' GB' : (b / 1e6).toFixed(0) + ' MB';
3695
+
3689
3696
  const local = (p, o) => fetch(p + '?s=' + S, o).then(r => r.json());
3690
3697
  const api = (p, tok, body) => fetch(API + p, { method:'POST',
3691
3698
  headers:{ authorization:'Bearer ' + tok, 'content-type':'application/json' },
@@ -3704,13 +3711,32 @@ async function go(file) {
3704
3711
  // Part URLs in batches of 100 (the API's cap), uploads 4 at a time via
3705
3712
  // XHR \u2014 fetch has no upload progress.
3706
3713
  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) + '%'; };
3714
+ // Cumulative average rather than an instantaneous rate: it smooths a
3715
+ // fluctuating connection instead of flickering between wild numbers, and
3716
+ // over a multi-minute upload it converges on the honest figure anyway.
3717
+ let upStart = 0;
3718
+ const paint = () => {
3719
+ const sent = done.reduce((a,b)=>a+b,0);
3720
+ bar.style.width = (sent / file.size * 100).toFixed(1) + '%';
3721
+ // Freeze once every byte is sent. R2 can take a beat to acknowledge the
3722
+ // last part, and paint() keeps firing through it \u2014 with sent pinned at
3723
+ // file.size and elapsed still growing, the rate visibly decays and reads
3724
+ // as a dying connection at the exact moment the upload succeeded.
3725
+ // 'finalizing\u2026' takes over from here anyway.
3726
+ if (!upStart || !sent || sent >= file.size) return;
3727
+ const el = (Date.now() - upStart) / 1000;
3728
+ const rate = sent / el;
3729
+ st.textContent = 'uploading \xB7 ' + size(sent) + ' / ' + size(file.size)
3730
+ + ' \xB7 ' + (rate / 1e6).toFixed(1) + ' MB/s'
3731
+ + (rate > 0 ? ' \xB7 ' + left((file.size - sent) / rate) : '');
3732
+ };
3708
3733
  let urls = [];
3709
3734
  for (let b = 1; b <= expectedParts; b += 100) {
3710
3735
  const nums = []; for (let n = b; n < b + 100 && n <= expectedParts; n++) nums.push(n);
3711
3736
  urls = urls.concat((await api('/v1/uploads/' + assetId + '/parts', uploadToken, { partNumbers: nums })).urls);
3712
3737
  }
3713
3738
  st.textContent = 'uploading\u2026';
3739
+ upStart = Date.now();
3714
3740
  let idx = 0;
3715
3741
  await Promise.all(Array.from({ length: 4 }, async function worker() {
3716
3742
  while (idx < urls.length) {
@@ -3732,21 +3758,35 @@ async function go(file) {
3732
3758
 
3733
3759
  el.insertAdjacentHTML('beforeend', '<div class="bar enc"><div></div></div>');
3734
3760
  const enc = el.querySelectorAll('.bar > div')[1];
3735
- st.textContent = 'transcoding\u2026';
3761
+ st.textContent = 'queued \xB7 waking the encoder\u2026';
3736
3762
  for (;;) {
3737
3763
  await new Promise(r => setTimeout(r, 2500));
3738
3764
  const a = await local('/api/status/' + assetId);
3739
- if (a.progress?.totalSec) enc.style.width = (a.progress.encodedSec / a.progress.totalSec * 100).toFixed(1) + '%';
3765
+ const p = a.progress;
3766
+ if (p?.totalSec) enc.style.width = (p.encodedSec / p.totalSec * 100).toFixed(1) + '%';
3740
3767
  if (a.status === 'ready') break;
3741
3768
  if (a.status === 'failed') throw new Error('transcode failed: ' + (a.error || 'unknown'));
3742
- st.textContent = 'transcoding\u2026 (' + a.status + ')';
3769
+ // Three distinct waits, and conflating them is what made this read as a
3770
+ // hang: the machine scales to zero so it may be booting; then it pulls
3771
+ // the source out of R2 and probes it; only then does progress exist.
3772
+ // Until the first progress post there is nothing to put in the bar, so
3773
+ // the label has to carry the state instead.
3774
+ st.textContent =
3775
+ p?.totalSec
3776
+ ? 'transcoding \xB7 ' + mmss(p.encodedSec) + ' / ' + mmss(p.totalSec)
3777
+ + (p.speed ? ' \xB7 ' + p.speed.toFixed(2) + '\xD7' : '')
3778
+ // speed is optional on the wire (the API forwards it only when it
3779
+ // is a number), so the estimate goes rather than reading NaN.
3780
+ + (p.speed > 0 ? ' \xB7 ' + left((p.totalSec - p.encodedSec) / p.speed) : '')
3781
+ : a.status === 'processing' ? 'transcoding \xB7 reading the source\u2026'
3782
+ : 'queued \xB7 waking the encoder\u2026';
3743
3783
  }
3744
3784
  enc.style.width = '100%';
3745
3785
 
3746
3786
  const t = await local('/api/playback/' + assetId, { method: 'POST' });
3747
3787
  const mediaHost = API.replace('api.', 'stream.');
3748
3788
  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>';
3789
+ st.innerHTML = 'ready \u2014 asset <span class="mono">' + assetId + '</span><br><span class="hint">key lacks playback scope, so no preview token \u2014 mint one server-side to play</span>';
3750
3790
  return;
3751
3791
  }
3752
3792
  const src = mediaHost + '/' + assetId + '.m3u8?token=' + encodeURIComponent(t.playback);
@@ -3794,6 +3834,7 @@ import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
3794
3834
  import { homedir } from "node:os";
3795
3835
  import { join } from "node:path";
3796
3836
  var DEFAULT_API_BASE = "https://api.hostr.sh";
3837
+ var CLI_VERSION = "0.2.0";
3797
3838
  var configDir = (env = process.env) => env.HOSTR_CONFIG_DIR ?? join(homedir(), ".config", "hostr");
3798
3839
  async function loadConfig(dir = configDir()) {
3799
3840
  try {
@@ -3826,10 +3867,10 @@ function resolveAuth(opts) {
3826
3867
  var ApiError = class extends Error {
3827
3868
  status;
3828
3869
  body;
3829
- constructor(status, body) {
3830
- const msg = body?.error ?? `http_${status}`;
3831
- super(`API error ${status}: ${msg}`);
3832
- this.status = status;
3870
+ constructor(status2, body) {
3871
+ const msg = body?.error ?? `http_${status2}`;
3872
+ super(`API error ${status2}: ${msg}`);
3873
+ this.status = status2;
3833
3874
  this.body = body;
3834
3875
  }
3835
3876
  };
@@ -3878,6 +3919,30 @@ function makeClient(opts) {
3878
3919
  webhookDelete: () => req2("DELETE", "/v1/webhooks")
3879
3920
  };
3880
3921
  }
3922
+ function makeDeviceClient(opts) {
3923
+ const doFetch = opts.fetchFn ?? fetch;
3924
+ const base = opts.apiBase.replace(/\/$/, "");
3925
+ const post = async (path, body) => {
3926
+ const res = await doFetch(`${base}${path}`, {
3927
+ method: "POST",
3928
+ headers: { "content-type": "application/json" },
3929
+ body: JSON.stringify(body)
3930
+ });
3931
+ return { status: res.status, body: await res.json().catch(() => ({})) };
3932
+ };
3933
+ return {
3934
+ async start(b) {
3935
+ const r = await post("/v1/device/code", b);
3936
+ if (r.status !== 201) throw new ApiError(r.status, r.body);
3937
+ return r.body;
3938
+ },
3939
+ async poll(deviceCode) {
3940
+ const r = await post("/v1/device/token", { deviceCode });
3941
+ if (r.status === 200) return { ok: true, value: r.body };
3942
+ return { ok: false, error: String(r.body.error ?? `http_${r.status}`) };
3943
+ }
3944
+ };
3945
+ }
3881
3946
 
3882
3947
  // src/context.ts
3883
3948
  var makeOut = (json) => ({
@@ -3907,7 +3972,7 @@ function fail(out, e) {
3907
3972
  }
3908
3973
 
3909
3974
  // src/commands-account.ts
3910
- import { createInterface } from "node:readline/promises";
3975
+ import { hostname } from "node:os";
3911
3976
 
3912
3977
  // ../control/src/fmt.ts
3913
3978
  function fmtBytes(n) {
@@ -3938,26 +4003,132 @@ function table(rows, cols) {
3938
4003
  ].join("\n");
3939
4004
  }
3940
4005
 
4006
+ // src/device.ts
4007
+ import { spawn } from "node:child_process";
4008
+ function pollAction(errorCode) {
4009
+ switch (errorCode) {
4010
+ case "authorization_pending":
4011
+ return { kind: "wait", addSec: 0 };
4012
+ case "slow_down":
4013
+ return { kind: "wait", addSec: 5 };
4014
+ case "access_denied":
4015
+ return { kind: "stop", error: "authorization was denied in the browser" };
4016
+ case "expired_token":
4017
+ return { kind: "stop", error: "the code expired \u2014 run `hostr login` again" };
4018
+ default:
4019
+ return { kind: "stop", error: `unexpected device-flow error: ${errorCode}` };
4020
+ }
4021
+ }
4022
+ async function awaitApproval(o) {
4023
+ const deadline = o.now() + o.expiresInSec * 1e3;
4024
+ let interval = o.intervalSec;
4025
+ for (; ; ) {
4026
+ const reply = await o.poll();
4027
+ if (reply.ok) return reply.value;
4028
+ const action = pollAction(reply.error);
4029
+ if (action.kind === "stop") throw new Error(action.error);
4030
+ interval += action.addSec;
4031
+ if (o.now() + interval * 1e3 > deadline)
4032
+ throw new Error("the code expired before it was approved \u2014 run `hostr login` again");
4033
+ await o.sleep(interval * 1e3);
4034
+ }
4035
+ }
4036
+ var openBrowser = (addr) => {
4037
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
4038
+ try {
4039
+ spawn(opener, [addr], { stdio: "ignore", detached: true }).unref();
4040
+ } catch {
4041
+ }
4042
+ };
4043
+
4044
+ // src/env-file.ts
4045
+ import { chmod as chmod2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
4046
+ import { basename } from "node:path";
4047
+ var readOrNull = (p) => readFile2(p, "utf8").catch(() => null);
4048
+ function upsertEnvLine(existing, name, value) {
4049
+ const line = `${name}=${value}`;
4050
+ if (existing === null || existing.trim() === "") return `${line}
4051
+ `;
4052
+ const eol = existing.includes("\r\n") ? "\r\n" : "\n";
4053
+ const lines = existing.split("\n");
4054
+ const i = lines.findIndex((l) => l.trimStart().startsWith(`${name}=`));
4055
+ if (i === -1) {
4056
+ const body = existing.replace(/[\r\n]*$/, "");
4057
+ return `${body}${eol}${line}${eol}`;
4058
+ }
4059
+ const hadCR = lines[i].endsWith("\r");
4060
+ lines[i] = hadCR ? `${line}\r` : line;
4061
+ return lines.join("\n");
4062
+ }
4063
+ var patternCovers = (pattern, candidates) => {
4064
+ const body = pattern.replace(/^\//, "");
4065
+ const re = new RegExp(
4066
+ "^" + body.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$"
4067
+ );
4068
+ return candidates.some((c) => re.test(c));
4069
+ };
4070
+ function gitignoreCovers(gitignore, file) {
4071
+ if (gitignore === null) return false;
4072
+ const candidates = [file.replace(/^\.\//, ""), basename(file)];
4073
+ return gitignore.split("\n").map((l) => l.trim()).filter((l) => l !== "" && !l.startsWith("#") && !l.startsWith("!")).some((l) => patternCovers(l, candidates));
4074
+ }
4075
+ async function isGitignored(file) {
4076
+ return gitignoreCovers(await readOrNull(".gitignore"), file);
4077
+ }
4078
+ async function writeEnvVar(file, name, value) {
4079
+ const next = upsertEnvLine(await readOrNull(file), name, value);
4080
+ await writeFile2(file, next, { mode: 384 });
4081
+ await chmod2(file, 384);
4082
+ return { gitignored: await isGitignored(file) };
4083
+ }
4084
+
3941
4085
  // src/commands-account.ts
3942
4086
  async function login(opts) {
3943
4087
  const out = makeOut(!!opts.json);
3944
- let key = opts.key ?? process.env.HOSTR_API_KEY;
3945
- if (!key) {
3946
- const rl = createInterface({ input: process.stdin, output: process.stderr });
3947
- key = (await rl.question("API key (from your hostr dashboard or `hostr keys create`): ")).trim();
3948
- rl.close();
3949
- }
3950
- if (!key) fail(out, new Error("an API key is required"));
3951
4088
  const cfg = await loadConfig(configDir());
3952
4089
  const apiBase = resolveAuth({ flagApi: opts.api, env: process.env, profile: activeProfile(cfg) }).apiBase;
3953
- const client = makeClient({ apiBase, key });
4090
+ const name = opts.profile ?? "default";
4091
+ const explicit = opts.key ?? process.env.HOSTR_API_KEY;
4092
+ if (explicit) {
4093
+ try {
4094
+ const acct = await makeClient({ apiBase, key: explicit }).account();
4095
+ await saveConfig(setProfile(cfg, name, { key: explicit, apiBase }), configDir());
4096
+ out.print(
4097
+ `logged in to ${acct.name} (plan: ${acct.plan}) as profile "${name}"`,
4098
+ { ok: true, accountId: acct.accountId, name: acct.name, plan: acct.plan, profile: name }
4099
+ );
4100
+ } catch (e) {
4101
+ fail(out, e);
4102
+ }
4103
+ return;
4104
+ }
4105
+ const dev = makeDeviceClient({ apiBase });
3954
4106
  try {
3955
- const acct = await client.account();
3956
- const name = opts.profile ?? "default";
3957
- await saveConfig(setProfile(cfg, name, { key, apiBase }), configDir());
4107
+ const start = await dev.start({ client: `hostr-cli/${CLI_VERSION}`, hostname: hostname() });
4108
+ process.stderr.write(
4109
+ `
4110
+ Your code: ${start.userCode}
4111
+ Approve at: ${start.verificationUriComplete}
4112
+
4113
+ Opening your browser\u2026 waiting for approval.
4114
+ (If you are an agent: hand this URL and code to your human. Do not
4115
+ ask them to paste an API key.)
4116
+
4117
+ `
4118
+ );
4119
+ openBrowser(start.verificationUriComplete);
4120
+ const res = await awaitApproval({
4121
+ poll: () => dev.poll(start.deviceCode),
4122
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
4123
+ intervalSec: start.interval,
4124
+ expiresInSec: start.expiresIn,
4125
+ now: () => Date.now()
4126
+ });
4127
+ await saveConfig(setProfile(cfg, name, { key: res.key, apiBase }), configDir());
3958
4128
  out.print(
3959
- `logged in to ${acct.name} (plan: ${acct.plan}) as profile "${name}"`,
3960
- { ok: true, accountId: acct.accountId, name: acct.name, plan: acct.plan, profile: name }
4129
+ `authorized \u2014 ${res.accountName} (plan: ${res.plan}) as profile "${name}"
4130
+ key stored in ${configDir()}/config.json; revoke it on the Keys page any time`,
4131
+ { ok: true, accountId: res.accountId, name: res.accountName, plan: res.plan, profile: name }
3961
4132
  );
3962
4133
  } catch (e) {
3963
4134
  fail(out, e);
@@ -4008,9 +4179,31 @@ async function keysList(ctx) {
4008
4179
  { items }
4009
4180
  );
4010
4181
  }
4182
+ var parseScopes = (csv) => {
4183
+ const parts = (csv ?? "").split(",").map((s) => s.trim()).filter(Boolean);
4184
+ return parts.length ? parts : ["playback"];
4185
+ };
4011
4186
  async function keysCreate(ctx, opts) {
4012
- const scopes = opts.scopes?.split(",").map((s) => s.trim());
4013
- const k = await ctx.client.keysCreate({ ...opts.name ? { name: opts.name } : {}, ...scopes ? { scopes } : {} });
4187
+ const scopes = parseScopes(opts.scopes);
4188
+ const k = await ctx.client.keysCreate({ ...opts.name ? { name: opts.name } : {}, scopes });
4189
+ if (opts.writeEnv) {
4190
+ let gitignored;
4191
+ try {
4192
+ ({ gitignored } = await writeEnvVar(opts.writeEnv, "HOSTR_API_KEY", k.key));
4193
+ } catch (e) {
4194
+ throw new Error(
4195
+ `key ${k.id} was created, but writing it to ${opts.writeEnv} failed: ${e?.message ?? e}. It is live with nobody holding it \u2014 run \`npx hostr keys list\` and \`npx hostr keys revoke ${k.id}\` to clean it up.`
4196
+ );
4197
+ }
4198
+ ctx.out.print(
4199
+ `wrote HOSTR_API_KEY to ${opts.writeEnv} (value not shown)
4200
+ id ${k.id}
4201
+ scopes ${k.scopes.join(",")}` + (gitignored ? "" : `
4202
+ WARNING: ${opts.writeEnv} does not look gitignored \u2014 do not commit it.`),
4203
+ { ok: true, wrote: "HOSTR_API_KEY", file: opts.writeEnv, id: k.id, scopes: k.scopes, gitignored }
4204
+ );
4205
+ return;
4206
+ }
4014
4207
  ctx.out.print(
4015
4208
  `key ${k.key}
4016
4209
  id ${k.id}
@@ -4031,6 +4224,33 @@ created ${fmtWhen(w.createdAt)}`, w);
4031
4224
  }
4032
4225
  async function webhooksSet(ctx, url, opts) {
4033
4226
  const w = await ctx.client.webhookSet({ url, ...opts.secret ? { secret: opts.secret } : {} });
4227
+ if (opts.writeEnv && w.secret) {
4228
+ let gitignored;
4229
+ try {
4230
+ ({ gitignored } = await writeEnvVar(opts.writeEnv, "HOSTR_WEBHOOK_SECRET", w.secret));
4231
+ } catch (e) {
4232
+ throw new Error(
4233
+ `webhook secret for ${w.url} was set, but writing it to ${opts.writeEnv} failed: ${e?.message ?? e}. Nobody has a copy \u2014 re-run \`npx hostr webhooks set ${url}\` to mint a fresh one, and confirm the write succeeds this time.`
4234
+ );
4235
+ }
4236
+ ctx.out.print(
4237
+ `url ${w.url}
4238
+ enabled ${w.enabled}
4239
+ wrote HOSTR_WEBHOOK_SECRET to ${opts.writeEnv} (value not shown)` + (gitignored ? "" : `
4240
+ WARNING: ${opts.writeEnv} does not look gitignored \u2014 do not commit it.`),
4241
+ { ok: true, url: w.url, enabled: w.enabled, wrote: "HOSTR_WEBHOOK_SECRET", file: opts.writeEnv, gitignored }
4242
+ );
4243
+ return;
4244
+ }
4245
+ if (opts.writeEnv && !w.secret) {
4246
+ ctx.out.print(
4247
+ `url ${w.url}
4248
+ enabled ${w.enabled}
4249
+ nothing written to ${opts.writeEnv} \u2014 you supplied --secret, so the server returned none.`,
4250
+ { ok: true, url: w.url, enabled: w.enabled, wrote: null, file: opts.writeEnv }
4251
+ );
4252
+ return;
4253
+ }
4034
4254
  ctx.out.print(
4035
4255
  `url ${w.url}
4036
4256
  enabled ${w.enabled}` + (w.secret ? `
@@ -4054,11 +4274,11 @@ async function webhooksTest(ctx) {
4054
4274
  }
4055
4275
 
4056
4276
  // src/commands-media.ts
4057
- import { basename } from "node:path";
4277
+ import { basename as basename2 } from "node:path";
4058
4278
  import { stat } from "node:fs/promises";
4059
4279
 
4060
4280
  // ../upload/src/node/index.ts
4061
- import { open, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2, unlink } from "node:fs/promises";
4281
+ import { open, mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3, unlink } from "node:fs/promises";
4062
4282
  import { join as join2 } from "node:path";
4063
4283
  import { tmpdir } from "node:os";
4064
4284
 
@@ -4095,9 +4315,9 @@ function classifyPut(res, err) {
4095
4315
  if (err instanceof NetworkError) return "network";
4096
4316
  if (err != null) return "fatal";
4097
4317
  if (res === null) return "network";
4098
- const { status } = res;
4099
- if (status === 403) return "expired";
4100
- if (status === 429 || status >= 500 && status < 600) return "transient";
4318
+ const { status: status2 } = res;
4319
+ if (status2 === 403) return "expired";
4320
+ if (status2 === 429 || status2 >= 500 && status2 < 600) return "transient";
4101
4321
  return "fatal";
4102
4322
  }
4103
4323
  function classifyApi(err) {
@@ -5005,7 +5225,7 @@ function fileStateStore(dir) {
5005
5225
  return {
5006
5226
  async get(key) {
5007
5227
  try {
5008
- return await readFile2(fileFor(key), "utf8");
5228
+ return await readFile3(fileFor(key), "utf8");
5009
5229
  } catch (err) {
5010
5230
  if (err?.code === "ENOENT") return null;
5011
5231
  throw err;
@@ -5013,7 +5233,7 @@ function fileStateStore(dir) {
5013
5233
  },
5014
5234
  async set(key, value) {
5015
5235
  await mkdir2(dir, { recursive: true });
5016
- await writeFile2(fileFor(key), value, "utf8");
5236
+ await writeFile3(fileFor(key), value, "utf8");
5017
5237
  },
5018
5238
  async delete(key) {
5019
5239
  try {
@@ -5150,8 +5370,8 @@ async function subtitlesList(ctx, id) {
5150
5370
  );
5151
5371
  }
5152
5372
  async function subtitlesSet(ctx, id, file, opts) {
5153
- const { readFile: readFile4 } = await import("node:fs/promises");
5154
- const vtt = await readFile4(file, "utf8");
5373
+ const { readFile: readFile6 } = await import("node:fs/promises");
5374
+ const vtt = await readFile6(file, "utf8");
5155
5375
  const r = await ctx.client.subtitleSet(id, opts.lang, vtt, { name: opts.name, default: opts.default });
5156
5376
  ctx.out.print(`subtitle ${r.lang} ("${r.name}") attached to ${r.assetId}${r.default ? " as default" : ""}`, r);
5157
5377
  }
@@ -5182,7 +5402,7 @@ async function upload(ctx, file, opts) {
5182
5402
  const session = await createUploadSession({
5183
5403
  endpoint: ctx.apiBase,
5184
5404
  apiKey,
5185
- filename: basename(file),
5405
+ filename: basename2(file),
5186
5406
  contentType: opts.contentType ?? mimeOf(file),
5187
5407
  size
5188
5408
  });
@@ -5197,7 +5417,7 @@ async function upload(ctx, file, opts) {
5197
5417
  const isTty = process.stderr.isTTY;
5198
5418
  up.on("progress", (p) => {
5199
5419
  const pct = p.totalBytes ? Math.floor(p.bytesConfirmed / p.totalBytes * 100) : 0;
5200
- const line = `uploading ${basename(file)} ${pct}% (${p.partsDone}/${p.partsTotal} parts)`;
5420
+ const line = `uploading ${basename2(file)} ${pct}% (${p.partsDone}/${p.partsTotal} parts)`;
5201
5421
  if (isTty) process.stderr.write(`\r${line} `);
5202
5422
  });
5203
5423
  up.start();
@@ -5223,11 +5443,11 @@ size ${fmtBytes(size)}`,
5223
5443
  }
5224
5444
 
5225
5445
  // src/commands-init.ts
5226
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
5446
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile4 } from "node:fs/promises";
5227
5447
  import { join as join3 } from "node:path";
5228
5448
 
5229
5449
  // ../../docs/integrate.md
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';
5450
+ 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. Getting started\n\nRun `npx @hostr/cli` in your app\'s repo. With no arguments it reports what is\nconfigured and names the single next step \u2014 authorize, prepare the repo, mint\nan application key \u2014 and it is the intended entry point for a coding agent.\nThis document is the *reference*: consult it while implementing, section by\nsection, rather than reading it front to back.\n\nNo Node available? Every flow here has a raw-HTTP form. Start at \xA72b.\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 - `playback` \u2014 upload + minting playback tokens. **This is what an\n application key should be**: it can ingest and it can let viewers watch,\n but it cannot delete assets or manage keys.\n - `full` \u2014 everything, including deletion, key and webhook 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\n`npx @hostr/cli login` opens your browser and mints the CLI\'s own `full`-scoped\nkey after you approve it \u2014 nothing is pasted, and no key passes through a\ncoding agent that runs the command. It appears on the dashboard Keys page named\n`CLI \u2014 <hostname>` and is revocable like any other. `--key` (or `HOSTR_API_KEY`)\nstill works and is the right path for CI and headless automation.\n\nCommands that mint a secret take `--write-env <file>`, which writes the value\nstraight to that file and prints only the variable name \u2014 in `--json` mode too.\nUse it whenever an agent is driving the terminal.\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| `video.input.non_standard` | the source needed a judgement call | `assetId`, `input`, `findings[]` |\n\n`video.input.non_standard` is advisory \u2014 the transcode proceeds normally and\na `video.ready` still follows. It fires at most once per asset, only when\nthere is something to say, and reports what the encoder had to assume:\n\n| finding code | meaning |\n|----------------------|-------------------------------------------------------------------------|\n| `hdr_tags_on_8bit` | source carries HLG/PQ tags but is 8-bit. HDR is a 10-bit format, so the tag is stale \u2014 treated as SDR, pixels untouched |\n| `hdr_source` | genuine 10-bit HDR. The ladder outputs 8-bit SDR; no tone mapping is applied |\n| `wide_gamut_tags` | BT.2020 primaries without an HDR transfer; output is BT.709, colour not converted |\n| `variable_frame_rate`| container rate and measured rate disagree; normalised to CFR |\n| `frame_rate_changed` | source rate was not a broadcast standard and was snapped to the nearest |\n\n`data.input` carries the full probe: `videoCodec`, `pixFmt`, `bitDepth`,\n`width`, `height`, `declaredFps`, `averageFps`, `targetFps`,\n`isVariableFrameRate`, `colorSpace`, `colorTransfer`, `colorPrimaries`. The\nsame block is on the asset record as `input`, so it can be read back at any\ntime without replaying events.\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 `playback` 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| 429 | `rate_limited` | `POST /v1/device/code` only \u2014 it is unauthenticated, so it is capped per IP. A real login makes one call here; if you are seeing this, something is looping. Wait a minute and retry |\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` | playback | 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| POST | `/v1/device/code` | \u2014 | begin browser-authorized CLI login |\n| POST | `/v1/device/token` | \u2014 | poll it; returns the key once approved |\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).\nRun it with **no arguments** for setup status and the next step.\n\nCredentials: `--key` flag > `HOSTR_API_KEY` env > saved profile\n(`hostr login`). API base: `--api` > `HOSTR_API_BASE` > profile > default.\n\n```\n(no args) setup status and the next step\nlogin browser-authorized; --key <k> for CI\nwhoami \xB7 usage \xB7 init\nkeys create [--name] [--scopes read|upload|playback|full] [--write-env <file>] | list | revoke <id>\nwebhooks get | set <url> [--write-env <file>] | delete | test\nassets list|get <id> \xB7 assets token <id> [--ttl <sec>] \xB7 upload [file] [--wait] \xB7 tail [--follow]\nsubtitles list <id> | set <id> <file> --lang <tag> | rm <id> <lang>\n```\n\n`--write-env <file>` writes the minted secret into that file and prints only\nthe variable name. Nothing else in the CLI prints a credential.\n';
5231
5451
 
5232
5452
  // src/init-content.ts
5233
5453
  var BEGIN = "<!-- hostr:begin -->";
@@ -5257,6 +5477,9 @@ function missingEnvLines(existing) {
5257
5477
  );
5258
5478
  return ENV_LINES.filter((l) => !have.has(l.split("=")[0]));
5259
5479
  }
5480
+ var guideStamp = (version) => `<!-- hostr guide \xB7 CLI ${version} -->`;
5481
+ var GUIDE_STAMP_RE = /^<!-- hostr guide · CLI ([0-9]+\.[0-9]+\.[0-9]+) -->/;
5482
+ var readGuideStamp = (doc) => GUIDE_STAMP_RE.exec(doc ?? "")?.[1] ?? null;
5260
5483
  var agentsSection = (apiBase) => `
5261
5484
  ## hostr (video hosting)
5262
5485
 
@@ -5277,7 +5500,7 @@ transcode, HLS playback with signed URLs.
5277
5500
  `;
5278
5501
 
5279
5502
  // src/commands-init.ts
5280
- var readOrNull = (p) => readFile3(p, "utf8").catch(() => null);
5503
+ var readOrNull2 = (p) => readFile4(p, "utf8").catch(() => null);
5281
5504
  async function init(opts) {
5282
5505
  const out = makeOut(!!opts.json);
5283
5506
  const dir = opts.dir ?? process.cwd();
@@ -5285,27 +5508,29 @@ async function init(opts) {
5285
5508
  const actions = {};
5286
5509
  await mkdir3(join3(dir, "docs"), { recursive: true });
5287
5510
  const docPath = join3(dir, "docs/hostr.md");
5288
- const prevDoc = await readOrNull(docPath);
5289
- if (prevDoc === integrate_default) actions["docs/hostr.md"] = "unchanged";
5511
+ const stamped = `${guideStamp(CLI_VERSION)}
5512
+ ${integrate_default}`;
5513
+ const prevDoc = await readOrNull2(docPath);
5514
+ if (prevDoc === stamped) actions["docs/hostr.md"] = "unchanged";
5290
5515
  else {
5291
- await writeFile3(docPath, integrate_default);
5516
+ await writeFile4(docPath, stamped);
5292
5517
  actions["docs/hostr.md"] = prevDoc === null ? "written" : "updated";
5293
5518
  }
5294
5519
  const agentsPath = join3(dir, "AGENTS.md");
5295
- const prevAgents = await readOrNull(agentsPath);
5520
+ const prevAgents = await readOrNull2(agentsPath);
5296
5521
  const nextAgents = spliceMarkers(prevAgents, agentsSection(apiBase));
5297
5522
  if (nextAgents === prevAgents) actions["AGENTS.md"] = "unchanged";
5298
5523
  else {
5299
- await writeFile3(agentsPath, nextAgents);
5524
+ await writeFile4(agentsPath, nextAgents);
5300
5525
  actions["AGENTS.md"] = prevAgents === null ? "written" : "updated";
5301
5526
  }
5302
5527
  const envPath = join3(dir, ".env.example");
5303
- const prevEnv = await readOrNull(envPath);
5528
+ const prevEnv = await readOrNull2(envPath);
5304
5529
  const missing = missingEnvLines(prevEnv);
5305
5530
  if (!missing.length) actions[".env.example"] = "unchanged";
5306
5531
  else {
5307
5532
  const base = prevEnv === null ? "" : prevEnv.replace(/\n*$/, "\n");
5308
- await writeFile3(envPath, `${base}${missing.join("\n")}
5533
+ await writeFile4(envPath, `${base}${missing.join("\n")}
5309
5534
  `);
5310
5535
  actions[".env.example"] = prevEnv === null ? "written" : "updated";
5311
5536
  }
@@ -5315,9 +5540,99 @@ async function init(opts) {
5315
5540
  );
5316
5541
  }
5317
5542
 
5543
+ // src/commands-status.ts
5544
+ import { readFile as readFile5 } from "node:fs/promises";
5545
+ import { join as join4 } from "node:path";
5546
+
5547
+ // src/status.ts
5548
+ var APP_KEY_CMD = 'npx hostr keys create --name "app dev" --write-env .env.local';
5549
+ function computeStatus(i) {
5550
+ const staleGuide = i.guideStampVersion !== null && i.guideStampVersion !== i.cliVersion;
5551
+ const authorized = !i.hasProfileKey ? { mark: "\u2717", label: "not authorized", detail: "" } : i.account ? { mark: "\u2713", label: "authorized", detail: `account "${i.account.name}" (plan: ${i.account.plan})` } : { mark: "?", label: "authorized", detail: "key saved; could not reach the API to confirm" };
5552
+ const prepared = i.guideStampVersion === null ? { mark: "\u2717", label: "repo not prepared", detail: "" } : {
5553
+ mark: "\u2713",
5554
+ label: "repo prepared",
5555
+ detail: staleGuide ? `docs/hostr.md written by CLI ${i.guideStampVersion}; you are running ${i.cliVersion} \u2014 re-run npx hostr init` : "docs/hostr.md, AGENTS.md, .env.example"
5556
+ };
5557
+ const appKey = i.envKeyFile === null ? { mark: "\u2717", label: "no app key", detail: "" } : { mark: "\u2713", label: "app key", detail: `HOSTR_API_KEY in ${i.envKeyFile}` };
5558
+ const rows = [authorized, prepared, appKey];
5559
+ let nextCommand = null;
5560
+ let nextNote = null;
5561
+ if (authorized.mark === "\u2717") {
5562
+ nextCommand = "npx hostr login";
5563
+ nextNote = "Opens a browser for your human to approve. Do NOT ask them to paste an\n API key, and do not put a key in your context.";
5564
+ } else if (prepared.mark === "\u2717") {
5565
+ nextCommand = "npx hostr init";
5566
+ nextNote = "Writes docs/hostr.md, an AGENTS.md section, and .env.example entries. No network.";
5567
+ } else if (appKey.mark === "\u2717") {
5568
+ nextCommand = APP_KEY_CMD;
5569
+ nextNote = "Writes the key straight to the file \u2014 the value is never printed.";
5570
+ }
5571
+ const activity = !i.online || !i.account ? "account: not checked (offline)" : `account: ${i.account.assetCount} assets \xB7 ${i.webhookUrl ? `webhook ${i.webhookUrl}` : "no webhook configured"}`;
5572
+ return { rows, nextCommand, nextNote, activity, staleGuide };
5573
+ }
5574
+ function renderStatus(r) {
5575
+ const body = r.rows.map((x) => ` ${x.mark} ${x.label.padEnd(18)}${x.detail}`.trimEnd()).join("\n");
5576
+ const next = r.nextCommand ? `
5577
+
5578
+ Next: ${r.nextCommand}${r.nextNote ? `
5579
+ ${r.nextNote}` : ""}` : "\n\nSetup complete. Implement from docs/hostr.md \u2014 \xA75 webhooks, \xA76 playback.\nRegister your webhook once the endpoint is deployed:\n npx hostr webhooks set <url> --write-env .env.local";
5580
+ return `hostr \u2014 video for this app.
5581
+
5582
+ ${body}${next}
5583
+
5584
+ ${r.activity}
5585
+ `;
5586
+ }
5587
+
5588
+ // src/commands-status.ts
5589
+ var readOrNull3 = (p) => readFile5(p, "utf8").catch(() => null);
5590
+ async function findEnvKey(dir) {
5591
+ for (const f of [".env.local", ".env"]) {
5592
+ const body = await readOrNull3(join4(dir, f));
5593
+ if (body && /^\s*HOSTR_API_KEY=\S/m.test(body)) return f;
5594
+ }
5595
+ return null;
5596
+ }
5597
+ async function status(opts) {
5598
+ const out = makeOut(!!opts.json);
5599
+ const dir = process.cwd();
5600
+ const cfg = await loadConfig(configDir());
5601
+ const { key, apiBase } = resolveAuth({
5602
+ flagKey: opts.key,
5603
+ flagApi: opts.api,
5604
+ env: process.env,
5605
+ profile: activeProfile(cfg)
5606
+ });
5607
+ const input = {
5608
+ hasProfileKey: !!key,
5609
+ guideStampVersion: readGuideStamp(await readOrNull3(join4(dir, "docs/hostr.md"))),
5610
+ cliVersion: CLI_VERSION,
5611
+ envKeyFile: await findEnvKey(dir),
5612
+ account: null,
5613
+ webhookUrl: null,
5614
+ online: false
5615
+ };
5616
+ if (key) {
5617
+ const client = makeClient({ apiBase, key });
5618
+ try {
5619
+ const a = await client.account();
5620
+ input.account = { name: a.name, plan: a.plan, assetCount: a.usage.assetCount };
5621
+ input.online = true;
5622
+ input.webhookUrl = await client.webhookGet().then((w) => w.url).catch(() => null);
5623
+ } catch {
5624
+ }
5625
+ }
5626
+ const report = computeStatus(input);
5627
+ out.print(renderStatus(report), { ...report, cliVersion: CLI_VERSION });
5628
+ }
5629
+
5318
5630
  // src/cli.ts
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)");
5631
+ var program2 = new Command("hostr").description("hostr \u2014 self-hosted video infrastructure (upload, transcode, HLS delivery)\nRun `npx hostr` with no arguments for setup status and the next step.").version(CLI_VERSION).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)");
5320
5632
  var globals = () => program2.opts();
5633
+ program2.action(async () => {
5634
+ await status(globals());
5635
+ });
5321
5636
  var run = (fn) => async (...args) => {
5322
5637
  const g = globals();
5323
5638
  const ctx = await makeCtx(g);
@@ -5338,11 +5653,11 @@ program2.command("whoami").description("account, plan, limits, live usage").acti
5338
5653
  program2.command("usage").description("daily usage rows").option("--from <date>", "YYYY-MM-DD (default: 30 days ago)").option("--to <date>", "YYYY-MM-DD (default: today)").action(run(usage));
5339
5654
  var keys = program2.command("keys").description("API key management");
5340
5655
  keys.command("list").action(run(keysList));
5341
- keys.command("create").option("--name <name>").option("--scopes <scopes>", "comma-separated: read,upload,full").action(run(keysCreate));
5656
+ keys.command("create").option("--name <name>").option("--scopes <scopes>", "comma-separated: read,upload,playback,full (default: playback)").option("--write-env <file>", "write HOSTR_API_KEY into <file> instead of printing it").action(run(keysCreate));
5342
5657
  keys.command("revoke <id>").action(run(keysRevoke));
5343
5658
  var webhooks = program2.command("webhooks").description("webhook endpoint config");
5344
5659
  webhooks.command("get").action(run(webhooksGet));
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)));
5660
+ webhooks.command("set <url>").option("--secret <secret>", "bring your own whsec (default: generated, shown once)").option("--write-env <file>", "write HOSTR_WEBHOOK_SECRET into <file> instead of printing it").action(run((ctx, url, opts) => webhooksSet(ctx, url, opts)));
5346
5661
  webhooks.command("delete").action(run(webhooksDelete));
5347
5662
  webhooks.command("test").description("redeliver the newest stored event to your endpoint").action(run(webhooksTest));
5348
5663
  var subtitles = program2.command("subtitles").description("subtitle tracks (WebVTT)");
@@ -5353,7 +5668,7 @@ var assets = program2.command("assets").description("asset listing and status");
5353
5668
  assets.command("list").option("--status <status>").option("--limit <n>").option("--cursor <cursor>").action(run(assetsList));
5354
5669
  assets.command("get <id>").action(run((ctx, id) => assetsGet(ctx, id)));
5355
5670
  assets.command("delete <id>").description("erase an asset: R2 objects purged, playback ends (requires full scope)").action(run((ctx, id) => assetsDelete(ctx, id)));
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)));
5671
+ assets.command("token <id>").description("mint playback credentials (requires playback scope)").option("--ttl <sec>").option("--viewer-id <id>").action(run((ctx, id, opts) => assetsToken(ctx, id, opts)));
5357
5672
  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));
5358
5673
  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) => {
5359
5674
  await init({ ...globals(), ...opts });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hostr/cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for hostr \u2014 self-hosted video infrastructure (upload, transcode, HLS delivery)",
5
5
  "license": "MIT",
6
6
  "type": "module",