@hypersocial/decks-cli 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,25 +5,25 @@ Command-line client for Hypersocial Decks live analytics and share management.
5
5
  Most users should install the friendlier package name:
6
6
 
7
7
  ```bash
8
- pnpm dlx hyperdecks whoami
8
+ npx hyperdecks whoami
9
9
  ```
10
10
 
11
11
  This package targets production by default:
12
12
 
13
13
  ```bash
14
- pnpm dlx @hypersocial/decks-cli login
15
- pnpm dlx @hypersocial/decks-cli whoami
16
- pnpm dlx @hypersocial/decks-cli deck list
17
- pnpm dlx @hypersocial/decks-cli deck stats netflix
18
- pnpm dlx @hypersocial/decks-cli activity --limit 20
19
- pnpm dlx @hypersocial/decks-cli import ./claude-design-export.zip --slug acme-pitch
20
- pnpm dlx @hypersocial/decks-cli skills install
14
+ npx @hypersocial/decks-cli login
15
+ npx @hypersocial/decks-cli whoami
16
+ npx @hypersocial/decks-cli deck list
17
+ npx @hypersocial/decks-cli deck stats netflix
18
+ npx @hypersocial/decks-cli activity --limit 20
19
+ npx @hypersocial/decks-cli import ./claude-design-export.zip --slug acme-pitch
20
+ npx @hypersocial/decks-cli skills install
21
21
  ```
22
22
 
23
23
  Global install:
24
24
 
25
25
  ```bash
26
- pnpm add -g @hypersocial/decks-cli
26
+ npm install -g @hypersocial/decks-cli
27
27
  decks login
28
28
  decks activity
29
29
  ```
@@ -55,9 +55,10 @@ deck list [--json]
55
55
  deck stats <slug> [--json]
56
56
  deck open <slug>
57
57
 
58
- share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n]
58
+ share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify]
59
59
  share list <slug> [--json]
60
60
  share on|off <token>
61
+ share mute|unmute <token>
61
62
  share rm <token>
62
63
  share url <token>
63
64
 
@@ -115,3 +116,47 @@ The same import is exposed to agents via the `hypersocial-decks` MCP server as t
115
116
  ```
116
117
 
117
118
  It authenticates with the stored CLI credential — run `decks login --prod` first.
119
+
120
+ ## QA and narration (0.1.10)
121
+
122
+ `share new <slug> "QA" --qa` serves the full deck runtime but is completely
123
+ untracked and always muted. `share new <slug> "Telemetry test" --no-notify`
124
+ creates a tracked muted link; use it only on an isolated test deck for telemetry
125
+ QA. `share mute <token>` and `share unmute <token>` are owner-only; QA links
126
+ cannot be unmuted. Statistics distinguish narration started, narration finished,
127
+ narration not started, and unknown legacy playback. Closing a deck does not
128
+ establish completion. See the main repository README for the runtime event contract.
129
+
130
+ ## Animated email previews (0.1.10+)
131
+
132
+ Published in 0.1.10; the owner-only `previews:get` query is live in production. Local-file export needs no login. Both `hyperdecks` and `decks` accept the same command; the canonical package is `@hypersocial/decks-cli`.
133
+
134
+ ```sh
135
+ hyperdecks preview setup
136
+ hyperdecks preview create --file ./presentation/index.html --out ./preview-local --json
137
+ hyperdecks share list my-deck --json
138
+ hyperdecks preview create my-deck --share EXISTING_TOKEN --out ./preview-email --upload --json
139
+ ```
140
+
141
+ Generate locally, select an existing same-deck recipient share, then explicitly upload into a **new** output directory. The command never creates or visits a share. Upload requires an owned deck slug; `--file` with `--upload` or `--share` is rejected. Disabled, expired, exhausted and mismatched shares fail before upload. QA shares are supported intentionally: their click-through remains untracked and muted, so use a regular recipient share for measured outreach.
142
+
143
+ Capture defaults to the initial slide, 4 seconds, 600px width and 10 fps, with a play/watch badge on GIF, PNG and silent MP4. `--duration` accepts 1–10 seconds, `--width` 240–1200 pixels (odd widths round up for MP4), `--delay` 0–10000 milliseconds (default 1000), and `--label "Watch · 2:41"` supplies an explicit label; no whole-deck narration duration is inferred. Aspect comes from `--aspect-ratio W:H`, `<meta name="preview-aspect-ratio" content="16:9">`, or the first slide/canvas bounds; otherwise 16:9. Output height rounds to an even pixel. Fonts get up to 5 seconds to settle. Inspect readiness warnings/poster for slow modules or media and increase the bounded delay if needed.
144
+
145
+ `--json` prints one line on stdout, identical to `<out>/manifest.json`:
146
+
147
+ ```json
148
+ { "status": "exported | uploaded | failed", "shareUrl": "https://decks.hypersocial.ai/v/<token>",
149
+ "files": [{ "type": "gif | png | mp4", "path": "...", "url": "https://...convex.cloud/api/storage/...", "bytes": 74662, "width": 600, "height": 338 }],
150
+ "email": { "sendable": true, "path": "<out>/email.html", "html": "<a href=\"<shareUrl>\"><img src=\"<gif url>\" ...></a>" },
151
+ "warnings": [] }
152
+ ```
153
+
154
+ `email.html` is the embed: drop it into any HTML email body or API send. `url` and `email` exist only after `--upload`.
155
+
156
+ Errors/progress use stderr and failures exit nonzero. `manifest.json` records source, status, absolute artifact paths, MIME types, bytes, dimensions, clip durations/fps, share URL, warnings and each completed upload. Files larger than 5 MiB warn: reduce duration/width for email. On upload failure local artifacts survive; inspect the manifest's URLs and `upload` records (an uploaded storage blob can exist even if saving its asset failed). Retry in a fresh directory; never overwrite an image already used in an email.
157
+
158
+ Only successful explicit uploads produce `email.html` and `email.sendable: true`, with the GIF wrapped in the exact recipient link. Use that HTML/GIF in your draft manually. Some email clients show only the first GIF frame: the PNG poster and the visible watch badge provide a static fallback. MP4 is for other channels; email video support varies. Local exports provide files to insert manually and a separate share URL when selected, with no claim of sendable remote-image HTML. Static image downloads do not open the deck, start narration, record engagement, or notify the owner.
159
+
160
+ Playwright and the platform FFmpeg executable are declared dependencies loaded only for preview capture; normal login/import/stats do not need capture prerequisites. `preview setup` installs the matching Chromium with no personal profile. On Linux, install Playwright's system libraries using `npx playwright@1.64.1 install-deps chromium` (may need administrator access), then run setup. FFmpeg comes from `@ffmpeg-installer/ffmpeg`, never a global/Homebrew executable. npm installs must include optional dependencies and allow install scripts; pnpm users must approve their `@ffmpeg-installer/<platform>` chmod script. Reinstall with `npm install -g hyperdecks --include=optional` if it is missing. Unsupported platforms fail with recovery guidance.
161
+
162
+ The local server exposes only supported static files within the explicit HTML file's directory; dotfiles, traversal and outside symlinks are denied. Keep capture inputs in a dedicated asset directory. Relative modules/fonts/video and external `<base>` URLs are retained. A fresh browser context receives no CLI credentials, cookies, profile or environment secrets. Recipient routes, first-party telemetry, non-GET requests, sockets and unrelated local services are blocked by URL and DNS checks that fail closed on lookup errors. Chromium resolves independently, so DNS rebinding is not fully prevented; use OS/network isolation for untrusted content. Video stays muted; the exporter does not click controls or start narration. Capture is bounded and temporary browser/server/frame files are removed on success/failure. Use only trusted deck content; third-party static resources may be fetched while rendering. HTTP redirects are refused before the browser follows them; use direct final asset URLs if a resource warns or fails to load.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -30,8 +30,10 @@
30
30
  "node": ">=20"
31
31
  },
32
32
  "dependencies": {
33
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
33
34
  "convex": "^1.41.0",
34
- "fflate": "^0.8.2"
35
+ "fflate": "^0.8.2",
36
+ "playwright": "^1.64.1"
35
37
  },
36
38
  "publishConfig": {
37
39
  "access": "public"
@@ -53,3 +53,37 @@ hyperdecks login
53
53
  live changes, state the action and target clearly before executing when user
54
54
  intent is ambiguous.
55
55
  - Avoid printing full share tokens unless the user needs the exact URL/token.
56
+
57
+ ## Animated email previews (0.1.10+)
58
+
59
+ Published in 0.1.10; the owner-only `previews:get` query is live in production. Local-file export needs no login. Both `hyperdecks` and `decks` accept the same command; the canonical package is `@hypersocial/decks-cli`.
60
+
61
+ ```sh
62
+ hyperdecks preview setup
63
+ hyperdecks preview create --file ./presentation/index.html --out ./preview-local --json
64
+ hyperdecks share list my-deck --json
65
+ hyperdecks preview create my-deck --share EXISTING_TOKEN --out ./preview-email --upload --json
66
+ ```
67
+
68
+ Generate locally, select an existing same-deck recipient share, then explicitly upload into a **new** output directory. The command never creates or visits a share. Upload requires an owned deck slug; `--file` with `--upload` or `--share` is rejected. Disabled, expired, exhausted and mismatched shares fail before upload. QA shares are supported intentionally: their click-through remains untracked and muted, so use a regular recipient share for measured outreach.
69
+
70
+ Capture defaults to the initial slide, 4 seconds, 600px width and 10 fps, with a play/watch badge on GIF, PNG and silent MP4. `--duration` accepts 1–10 seconds, `--width` 240–1200 pixels (odd widths round up for MP4), `--delay` 0–10000 milliseconds (default 1000), and `--label "Watch · 2:41"` supplies an explicit label; no whole-deck narration duration is inferred. Aspect comes from `--aspect-ratio W:H`, `<meta name="preview-aspect-ratio" content="16:9">`, or the first slide/canvas bounds; otherwise 16:9. Output height rounds to an even pixel. Fonts get up to 5 seconds to settle. Inspect readiness warnings/poster for slow modules or media and increase the bounded delay if needed.
71
+
72
+ `--json` prints one line on stdout, identical to `<out>/manifest.json`:
73
+
74
+ ```json
75
+ { "status": "exported | uploaded | failed", "shareUrl": "https://decks.hypersocial.ai/v/<token>",
76
+ "files": [{ "type": "gif | png | mp4", "path": "...", "url": "https://...convex.cloud/api/storage/...", "bytes": 74662, "width": 600, "height": 338 }],
77
+ "email": { "sendable": true, "path": "<out>/email.html", "html": "<a href=\"<shareUrl>\"><img src=\"<gif url>\" ...></a>" },
78
+ "warnings": [] }
79
+ ```
80
+
81
+ `email.html` is the embed: drop it into any HTML email body or API send. `url` and `email` exist only after `--upload`.
82
+
83
+ Errors/progress use stderr and failures exit nonzero. `manifest.json` records source, status, absolute artifact paths, MIME types, bytes, dimensions, clip durations/fps, share URL, warnings and each completed upload. Files larger than 5 MiB warn: reduce duration/width for email. On upload failure local artifacts survive; inspect the manifest's URLs and `upload` records (an uploaded storage blob can exist even if saving its asset failed). Retry in a fresh directory; never overwrite an image already used in an email.
84
+
85
+ Only successful explicit uploads produce `email.html` and `email.sendable: true`, with the GIF wrapped in the exact recipient link. Use that HTML/GIF in your draft manually. Some email clients show only the first GIF frame: the PNG poster and the visible watch badge provide a static fallback. MP4 is for other channels; email video support varies. Local exports provide files to insert manually and a separate share URL when selected, with no claim of sendable remote-image HTML. Static image downloads do not open the deck, start narration, record engagement, or notify the owner.
86
+
87
+ Playwright and the platform FFmpeg executable are declared dependencies loaded only for preview capture; normal login/import/stats do not need capture prerequisites. `preview setup` installs the matching Chromium with no personal profile. On Linux, install Playwright's system libraries using `npx playwright@1.64.1 install-deps chromium` (may need administrator access), then run setup. FFmpeg comes from `@ffmpeg-installer/ffmpeg`, never a global/Homebrew executable. npm installs must include optional dependencies and allow install scripts; pnpm users must approve their `@ffmpeg-installer/<platform>` chmod script. Reinstall with `npm install -g hyperdecks --include=optional` if it is missing. Unsupported platforms fail with recovery guidance.
88
+
89
+ The local server exposes only supported static files within the explicit HTML file's directory; dotfiles, traversal and outside symlinks are denied. Keep capture inputs in a dedicated asset directory. Relative modules/fonts/video and external `<base>` URLs are retained. A fresh browser context receives no CLI credentials, cookies, profile or environment secrets. Recipient routes, first-party telemetry, non-GET requests, sockets and unrelated local services are blocked by URL and DNS checks that fail closed on lookup errors. Chromium resolves independently, so DNS rebinding is not fully prevented; use OS/network isolation for untrusted content. Video stays muted; the exporter does not click controls or start narration. Capture is bounded and temporary browser/server/frame files are removed on success/failure. Use only trusted deck content; third-party static resources may be fetched while rendering. HTTP redirects are refused before the browser follows them; use direct final asset URLs if a resource warns or fails to load.
package/src/commands.mjs CHANGED
@@ -10,6 +10,10 @@ import {
10
10
  } from "./runtime.mjs";
11
11
  import * as ops from "./ops.mjs";
12
12
 
13
+ export const playbackLabel = (p) => p.narrationFinished ? "narration finished"
14
+ : p.narrationStarted ? "narration started"
15
+ : p.telemetryVersion === 2 ? "narration not started" : "playback unknown (legacy)";
16
+
13
17
  const fmtMs = (ms) => (ms >= 60000 ? `${(ms / 60000).toFixed(1)}m` : `${Math.round(ms / 1000)}s`);
14
18
 
15
19
  async function whoamiCmd(opts) {
@@ -57,7 +61,7 @@ async function deckStats(pos, opts) {
57
61
  console.log(" recent:");
58
62
  for (const r of s.recent) {
59
63
  const where = [r.city, r.country].filter(Boolean).join(", ");
60
- console.log(` ${new Date(r.lastSeenAt).toISOString()}\t${r.recipient}\t${fmtMs(r.totalMs)}\tslide ${r.maxSlide}${r.completed ? " complete" : ""}\t${where}`);
64
+ console.log(` ${new Date(r.lastSeenAt).toISOString()}\t${r.recipient}\t${fmtMs(r.totalMs)}\tslide ${r.maxSlide} · ${playbackLabel(r)}\t${where}`);
61
65
  }
62
66
  }
63
67
  }
@@ -83,13 +87,15 @@ function shareBase(opts) {
83
87
 
84
88
  async function shareNew(pos, opts) {
85
89
  const [slug, recipient] = pos;
86
- if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n]");
90
+ if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify]");
87
91
  const { token } = await ops.createShare(opts, {
88
92
  slug,
89
93
  recipient,
90
94
  email: opts.email,
91
95
  note: opts.note,
92
96
  requireEmail: opts["require-email"],
97
+ qa: opts.qa || undefined,
98
+ notify: opts["no-notify"] ? false : undefined,
93
99
  expires: opts.expires,
94
100
  limit: opts.limit,
95
101
  });
@@ -106,7 +112,7 @@ async function shareList(pos, opts) {
106
112
  console.log(JSON.stringify(rows, null, 2));
107
113
  return;
108
114
  }
109
- for (const r of rows) console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${r.recipient}\topens:${r.opens}${r.archived ? "\t(archived)" : ""}`);
115
+ for (const r of rows) console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${r.recipient}\topens:${r.opens}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.archived ? "\t(archived)" : ""}`);
110
116
  if (!rows.length) console.log("(no shares)");
111
117
  }
112
118
 
@@ -156,11 +162,26 @@ async function activity(pos, opts) {
156
162
  console.log(`${when}\tsent\t${f.recipient}\t${f.deckTitle}`);
157
163
  } else {
158
164
  const where = [f.city, f.country].filter(Boolean).join(", ");
159
- console.log(`${when}\topened\t${f.recipient}\t${f.deckTitle}\tslide ${f.maxSlide}\t${fmtMs(f.totalMs)}${f.completed ? " complete" : ""}\t${where}`);
165
+ console.log(`${when}\topened\t${f.recipient}\t${f.deckTitle}\tslide ${f.maxSlide}\t${fmtMs(f.totalMs)} · ${playbackLabel(f)}\t${where}`);
160
166
  }
161
167
  }
162
168
  }
163
169
 
170
+ async function credits(pos, opts) {
171
+ const bal = await ops.creditsBalance(opts);
172
+ const ledger = await ops.creditsLedger(opts, opts.limit || 10);
173
+ if (opts.json) {
174
+ console.log(JSON.stringify({ ...bal, ledger }, null, 2));
175
+ return;
176
+ }
177
+ console.log(`credits: ${bal.balance}`);
178
+ for (const e of ledger) {
179
+ const when = new Date(e.at).toISOString().slice(0, 16).replace("T", " ");
180
+ console.log(` ${when} ${e.delta > 0 ? "+" : ""}${e.delta}\t${e.kind}\t${e.reason}`);
181
+ }
182
+ if (!ledger.length) console.log(" (no history yet)");
183
+ }
184
+
164
185
  async function importDeck(pos, opts) {
165
186
  const [src] = pos;
166
187
  if (!src) {
@@ -220,7 +241,8 @@ commands:
220
241
  logout [--dev]
221
242
  whoami [--json] [--dev]
222
243
  deck list|stats|open|rm
223
- share new|list|on|off|rm|url
244
+ share new|list|on|off|mute|unmute|rm|url
245
+ share new <slug> <recipient> [--qa] [--no-notify]
224
246
  recipient archive|unarchive
225
247
  import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live]
226
248
  import-design <project-id> --serve-token <token> --entry <file.html> --slug <slug>
@@ -229,6 +251,11 @@ commands:
229
251
  activity [--deck <slug>] [--limit <n>] [--json]
230
252
  gen-image "<prompt>" [--transparent] [--image <url>] [--size <s>] [--quality <q>] [--count <n>] [--slug <deck>] [--download <file>]
231
253
  image-job <jobId> [--json]
254
+ credits [--limit <n>] [--json] show your credit balance and recent history
255
+
256
+ preview create <slug> --out <directory> [--share <token>] [--upload] [--json]
257
+ preview create --file <index.html> --out <directory> [--duration 4] [--width 600] [--delay 1000] [--label "Watch"] [--aspect-ratio 16:9]
258
+ preview setup install isolated Chromium (Linux: see README for system dependencies)
232
259
 
233
260
  global:
234
261
  --json
@@ -282,6 +309,7 @@ async function genImage(pos, opts) {
282
309
  for (const im of images) if (im.url) console.log(im.url);
283
310
  if (opts.download) console.log(`\nSaved ${images.length} file(s) to ${opts.download}`);
284
311
  }
312
+ if (out.creditsRemaining != null) process.stderr.write(`credits left: ${out.creditsRemaining}\n`);
285
313
  }
286
314
 
287
315
  async function imageJob(pos, opts) {
@@ -312,6 +340,15 @@ export async function runCli(argv = process.argv.slice(2)) {
312
340
  const [group, cmd] = pos;
313
341
  const rest = pos.slice(2);
314
342
  const handlers = {
343
+ "preview create": async () => {
344
+ const { createPreview } = await import("./preview.mjs");
345
+ const result = await createPreview(rest, opts);
346
+ console.log(opts.json ? JSON.stringify(result) : `Preview saved: ${result.manifestPath}\n${result.email.sendable ? result.email.path : "Insert the GIF or PNG manually; upload with an existing share for linked email HTML."}`);
347
+ },
348
+ "preview setup": async () => {
349
+ const { setupCapture } = await import("./preview-capture.mjs");
350
+ await setupCapture(rest, opts);
351
+ },
315
352
  login: () => login(opts),
316
353
  logout: () => logout(opts),
317
354
  whoami: () => whoamiCmd(opts),
@@ -323,6 +360,8 @@ export async function runCli(argv = process.argv.slice(2)) {
323
360
  "share list": () => shareList(rest, opts),
324
361
  "share on": () => shareToggle(rest, opts, true),
325
362
  "share off": () => shareToggle(rest, opts, false),
363
+ "share mute": async () => { if (!rest[0]) throw new Error("usage: share mute <token>"); await ops.setShareNotify(opts, rest[0], false); console.log(`OK muted ${rest[0]}`); },
364
+ "share unmute": async () => { if (!rest[0]) throw new Error("usage: share unmute <token>"); await ops.setShareNotify(opts, rest[0], true); console.log(`OK notification preference requested for ${rest[0]} (QA links remain muted)`); },
326
365
  "share rm": () => shareRm(rest, opts),
327
366
  "share url": () => shareUrl(rest, opts),
328
367
  "recipient archive": () => recipientArchive(rest, opts, true),
@@ -334,6 +373,7 @@ export async function runCli(argv = process.argv.slice(2)) {
334
373
  activity: () => activity(pos.slice(1), opts),
335
374
  "gen-image": () => genImage(pos.slice(1), opts),
336
375
  "image-job": () => imageJob(pos.slice(1), opts),
376
+ credits: () => credits(pos.slice(1), opts),
337
377
  };
338
378
 
339
379
  const handler = handlers[`${group} ${cmd}`] || handlers[group];
package/src/ops.mjs CHANGED
@@ -64,6 +64,17 @@ export async function checkImageJob(opts, jobId) {
64
64
  return (await client(opts)).query(api.imagegen.checkImageJob, { jobId: String(jobId) });
65
65
  }
66
66
 
67
+ // Read the signed-in user's credit balance.
68
+ export async function creditsBalance(opts = {}) {
69
+ return (await client(opts)).query(api.credits.getBalance, {});
70
+ }
71
+
72
+ // Read the user's recent credit ledger (most recent first).
73
+ export async function creditsLedger(opts = {}, limit) {
74
+ const args = limit ? { limit: Number(limit) } : {};
75
+ return (await client(opts)).query(api.credits.getLedger, args);
76
+ }
77
+
67
78
  // Poll an async image job until it finishes. Returns { images, slug } on success
68
79
  // (results are the same shape as the inline { images }); throws on job error or
69
80
  // timeout. `onProgress(job)` is called each tick. Used by the CLI so gen-image stays
@@ -73,7 +84,7 @@ export async function pollImageJob(opts, jobId, { intervalMs = 3000, timeoutMs =
73
84
  const start = Date.now();
74
85
  for (;;) {
75
86
  const job = await c.query(api.imagegen.checkImageJob, { jobId: String(jobId) });
76
- if (job.status === "done") return { images: job.results || [], slug: job.slug };
87
+ if (job.status === "done") return { images: job.results || [], slug: job.slug, creditsRemaining: job.creditsRemaining };
77
88
  if (job.status === "error") {
78
89
  // A batch isn't atomic: earlier images may have been generated + saved before
79
90
  // the failure. Surface that so the user knows assets/spend exist despite "error".
@@ -95,6 +106,8 @@ export async function listShares(opts, slug) {
95
106
  enabled: r.enabled,
96
107
  opens: r.opens,
97
108
  archived: !!r.archived,
109
+ qa: !!r.qa,
110
+ notify: r.qa ? false : r.notify !== false,
98
111
  lastOpenedAt: r.lastOpenedAt ?? null,
99
112
  requireEmail: r.requireEmail,
100
113
  recipientEmail: r.recipientEmail ?? null,
@@ -107,6 +120,8 @@ export async function createShare(opts, fields) {
107
120
  const { slug, recipient } = fields;
108
121
  if (!slug || !recipient) throw new Error("slug and recipient are required");
109
122
  const args = { deckSlug: slug, recipient };
123
+ if (fields.qa !== undefined) args.qa = !!fields.qa;
124
+ if (fields.notify !== undefined) args.notify = !!fields.notify;
110
125
  if (fields.email) args.recipientEmail = fields.email;
111
126
  if (fields.note) args.note = fields.note;
112
127
  if (fields.requireEmail) args.requireEmail = true;
@@ -129,6 +144,11 @@ export async function setShareEnabled(opts, token, enabled) {
129
144
  return { token, enabled: !!enabled };
130
145
  }
131
146
 
147
+ export async function setShareNotify(opts, token, notify) {
148
+ if (!token) throw new Error("token is required");
149
+ await (await client(opts)).mutation(api.shares.setNotify, { token, notify });
150
+ }
151
+
132
152
  export async function removeShare(opts, token) {
133
153
  if (!token) throw new Error("token is required");
134
154
  await (await client(opts)).mutation(api.shares.remove, { token });
@@ -0,0 +1,194 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { isIP } from 'node:net';
3
+ import { createServer } from 'node:http';
4
+ import { readFile, realpath, mkdtemp, rm, stat } from 'node:fs/promises';
5
+ import { resolve, dirname, extname, sep, join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { execFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ import { createRequire } from 'node:module';
11
+ const run = promisify(execFile);
12
+ const require = createRequire(import.meta.url);
13
+ const MIME = { '.html':'text/html', '.htm':'text/html', '.js':'text/javascript', '.mjs':'text/javascript', '.css':'text/css', '.json':'application/json', '.png':'image/png', '.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.gif':'image/gif', '.webp':'image/webp', '.svg':'image/svg+xml', '.woff':'font/woff', '.woff2':'font/woff2', '.ttf':'font/ttf', '.otf':'font/otf', '.mp4':'video/mp4', '.webm':'video/webm', '.mp3':'audio/mpeg', '.wav':'audio/wav', '.wasm':'application/wasm', '.glb':'model/gltf-binary', '.gltf':'model/gltf+json', '.bin':'application/octet-stream' };
14
+ const SETUP = 'Run `hyperdecks preview setup` (or `decks preview setup`) to install the matching isolated Chromium. On Linux install system libraries with `npx playwright install-deps chromium` using the Playwright version in your installed CLI.';
15
+ export async function setupCapture(pos, opts) {
16
+ if (pos.length || Object.keys(opts).some(k => !['json', 'dev', 'convex', 'prod'].includes(k))) throw new Error('usage: preview setup');
17
+ await run(process.execPath, [join(dirname(require.resolve('playwright/package.json')), 'cli.js'), 'install', 'chromium'], { timeout: 240000, maxBuffer: 4 * 1024 * 1024 });
18
+ console.log(opts.json ? JSON.stringify({ installed: 'chromium' }) : 'Chromium installed. Preview capture is ready.');
19
+ }
20
+
21
+ // Capability URL + realpath containment; captured code gets no general file server.
22
+ export async function serveSource({ file, html }) {
23
+ const root = file ? await realpath(dirname(file)) : null;
24
+ const prefix = '/' + randomUUID() + '/';
25
+ const entry = prefix + (file ? encodeURIComponent(resolve(file).split(sep).pop()) : 'index.html');
26
+ const server = createServer(async (req, res) => {
27
+ try {
28
+ const path = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
29
+ if (req.method !== 'GET' || !path.startsWith(prefix)) { res.writeHead(404).end(); return; }
30
+ if (!file && path === entry) { res.writeHead(200, { 'Content-Type':'text/html; charset=utf-8', 'Cache-Control':'no-store' }).end(html); return; }
31
+ const rel = path.slice(prefix.length);
32
+ if (!root || rel.split('/').some(s => s.startsWith('.') || s.includes('\\')) || !MIME[extname(rel).toLowerCase()]) { res.writeHead(404).end(); return; }
33
+ const full = await realpath(resolve(root, rel));
34
+ if (!full.startsWith(root + sep) || !(await stat(full)).isFile()) { res.writeHead(404).end(); return; }
35
+ res.writeHead(200, { 'Content-Type': MIME[extname(full).toLowerCase()] + (MIME[extname(full).toLowerCase()].startsWith('text/') ? '; charset=utf-8' : ''), 'Cache-Control':'no-store' });
36
+ res.end(await readFile(full));
37
+ } catch { res.writeHead(404).end(); }
38
+ });
39
+ await new Promise((ok, fail) => { server.once('error', fail); server.listen(0, '127.0.0.1', ok); });
40
+ const url = `http://127.0.0.1:${server.address().port}${entry}`;
41
+ return { url, close: () => new Promise((ok, fail) => { server.close(e => e ? fail(e) : ok()); server.closeAllConnections(); }) };
42
+ }
43
+ export function allowCaptureRequest(url, method, type, entryUrl, firstPartyOrigins = []) {
44
+ let u, path;
45
+ try { u = new URL(url); path = decodeURIComponent(u.pathname); } catch { return false; }
46
+ if (!['http:', 'https:', 'data:', 'blob:'].includes(u.protocol) || method !== 'GET') return false;
47
+ if (/(^|\/)(v|track|telemetry|analytics|beacon)(\/|$)/i.test(path)) return false;
48
+ if (type === 'document') return url === entryUrl;
49
+ if (['websocket', 'eventsource', 'ping', 'other'].includes(type)) return false;
50
+ if (firstPartyOrigins.some(v => new URL(v).origin === u.origin) || /\.convex\.(cloud|site)$/.test(u.hostname)) {
51
+ return /^\/api\/storage\//.test(path);
52
+ }
53
+ // Prevent page code from reaching local services or cloud metadata. Only the
54
+ // capability file server is available on loopback; redirects are checked too.
55
+ if (u.origin !== new URL(entryUrl).origin && /^(localhost|127\.|0\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[|.*\.localhost$)/i.test(u.hostname.replace(/\.+$/, ''))) return false;
56
+ return true;
57
+ }
58
+
59
+ // DNS is checked per request; HTTP redirects are refused, without trusting host spelling.
60
+ // This is defense in depth for trusted capture inputs, not a network sandbox:
61
+ // Chromium resolves independently, so DNS rebinding requires OS/network isolation.
62
+ export function isPublicAddress(address) {
63
+ if (isIP(address) === 4) {
64
+ const [a,b] = address.split('.').map(Number);
65
+ return !(a === 0 || a === 10 || a === 127 || a >= 224 ||
66
+ (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
67
+ (a === 172 && b >= 16 && b <= 31) || (a === 192 && [0,168].includes(b)) ||
68
+ (a === 198 && [18,19].includes(b)));
69
+ }
70
+ // Global unicast only; mapped IPv4, unique-local, multicast and link-local fail.
71
+ return isIP(address) === 6 && /^[23][0-9a-f]{3}:/i.test(address) && !/^2001:(db8|0):/i.test(address);
72
+ }
73
+ export async function allowCaptureDestination(url, entryUrl, resolver = lookup) {
74
+ try {
75
+ const u = new URL(url);
76
+ if (u.origin === new URL(entryUrl).origin) return true;
77
+ if (['data:', 'blob:'].includes(u.protocol)) return true;
78
+ const host = u.hostname.replace(/\.+$/, '').replace(/^\[|\]$/g, '');
79
+ if (isIP(host)) return isPublicAddress(host);
80
+ // Bound DNS latency; lookup itself is OS-owned and may finish after timeout.
81
+ let timer;
82
+ try {
83
+ const addresses = await Promise.race([
84
+ resolver(host, { all:true, verbatim:true }),
85
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('DNS timeout')), 3000); }),
86
+ ]);
87
+ return addresses.length > 0 && addresses.every(({address}) => isPublicAddress(address));
88
+ } finally { clearTimeout(timer); }
89
+ } catch { return false; }
90
+ }
91
+
92
+ // Playwright's continue() follows redirects without routing each hop. Fetch
93
+ // without following redirects and never fulfill a 3xx response into Chromium.
94
+ export async function routeCaptureRequest(route, entryUrl, origins = []) {
95
+ const r = route.request();
96
+ try {
97
+ if (!allowCaptureRequest(r.url(), r.method(), r.resourceType(), entryUrl, origins) || !await allowCaptureDestination(r.url(), entryUrl)) {
98
+ await route.abort(); return false;
99
+ }
100
+ const response = await route.fetch({ maxRedirects:0, timeout:15000 });
101
+ if (response.status() >= 300 && response.status() < 400) {
102
+ await response.dispose(); await route.abort(); return false;
103
+ }
104
+ await route.fulfill({ response });
105
+ await response.dispose();
106
+ return true;
107
+ } catch { await route.abort().catch(() => {}); return false; }
108
+ }
109
+
110
+ async function encode(ffmpeg, args) {
111
+ try { await run(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-nostdin', ...args], { timeout: 90000, maxBuffer: 1024 * 1024, env: { PATH: dirname(ffmpeg), ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}) } }); }
112
+ catch (e) { throw new Error(`FFmpeg encoding failed (${e.killed ? '90s timeout' : 'check portable encoder installation'}); no global FFmpeg is used`); }
113
+ }
114
+ export async function capturePreview(config) {
115
+ const { chromium } = await import('playwright');
116
+ let ffmpeg;
117
+ try { ffmpeg = (await import('@ffmpeg-installer/ffmpeg')).default.path; await run(ffmpeg, ['-version'], { timeout:10000 }); }
118
+ catch { throw new Error('Portable FFmpeg missing. Reinstall the CLI with optional dependencies and install scripts enabled (`npm install -g hyperdecks --include=optional`); pnpm users must approve @ffmpeg-installer platform build scripts.'); }
119
+ let browser, server, temp, deadline, timedOut = false;
120
+ const warnings = [];
121
+ try {
122
+ temp = await mkdtemp(join(tmpdir(), 'hyperdecks-preview-'));
123
+ try {
124
+ browser = await chromium.launch({ headless: true, timeout:30000, chromiumSandbox: process.platform === 'linux', args: ['--mute-audio', '--enable-unsafe-swiftshader'], env: { ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}), PATH: process.env.PATH, TMPDIR: temp, HOME: temp } });
125
+ } catch { throw new Error(`Could not launch isolated Chromium. ${SETUP}`); }
126
+ deadline = setTimeout(() => { timedOut = true; browser.close().catch(() => {}); }, 60000);
127
+ server = await serveSource(config);
128
+ const context = await browser.newContext({ viewport: { width:1280, height:720 }, deviceScaleFactor:1, serviceWorkers:'block', acceptDownloads:false });
129
+ let blocked = 0, failed = 0;
130
+ await context.route('**/*', async route => {
131
+ if (!await routeCaptureRequest(route, server.url, config.firstPartyOrigins)) blocked++;
132
+ });
133
+ await context.routeWebSocket('**/*', ws => ws.close());
134
+ // Defense in depth for unload beacons and popup attempts. No credentials,
135
+ // storage state, permission grants or personal profile enter this context.
136
+ await context.addInitScript(() => {
137
+ navigator.sendBeacon = () => false;
138
+ window.open = () => null;
139
+ const play = HTMLMediaElement.prototype.play;
140
+ HTMLMediaElement.prototype.play = function() { this.muted = true; return this instanceof HTMLAudioElement ? Promise.resolve() : play.call(this); };
141
+ });
142
+ const page = await context.newPage();
143
+ page.setDefaultTimeout(15000);
144
+ page.on('pageerror', () => failed++);
145
+ page.on('requestfailed', request => { if (request.failure()?.errorText !== 'net::ERR_FAILED') failed++; });
146
+ await page.goto(server.url, { waitUntil:'domcontentloaded', timeout:30000 });
147
+ const fontsReady = await page.evaluate(async () => Promise.race([document.fonts.ready.then(() => true), new Promise(ok => setTimeout(() => ok(false), 5000))]));
148
+ if (!fontsReady) warnings.push('Fonts did not settle within 5s; inspect the poster or increase --delay.');
149
+ await page.waitForTimeout(config.delay);
150
+ const detected = await page.evaluate(() => {
151
+ const meta = document.querySelector('meta[name="preview-aspect-ratio"]')?.getAttribute('content');
152
+ if (meta && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(meta)) { const [w,h] = meta.split(':').map(Number); return w/h; }
153
+ const slide = document.querySelector('.slide, [data-slide], canvas');
154
+ const rect = slide?.getBoundingClientRect();
155
+ return rect && rect.width > 100 && rect.height > 100 ? rect.width/rect.height : null;
156
+ });
157
+ const aspect = config.aspect ?? (detected >= 0.5 && detected <= 3 ? detected : 16/9);
158
+ if (!config.aspect && !detected) warnings.push('Using 16:9 aspect ratio; use --aspect-ratio W:H or preview-aspect-ratio metadata for a different deck shape.');
159
+ const width = config.width % 2 ? config.width + 1 : config.width;
160
+ const height = Math.round(width / aspect / 2) * 2;
161
+ await page.setViewportSize({ width:1280, height:Math.round(1280/aspect) });
162
+ await page.evaluate(label => {
163
+ const badge = document.createElement('div');
164
+ badge.textContent = '▶ ' + label;
165
+ badge.setAttribute('style', 'all:initial;position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.85);color:white;font:600 25px sans-serif;padding:14px 24px;border-radius:40px;z-index:2147483647;white-space:nowrap;max-width:90%;overflow:hidden;text-overflow:ellipsis;pointer-events:none');
166
+ document.documentElement.appendChild(badge);
167
+ }, config.label);
168
+ const fps = 10, frames = Math.round(config.duration * fps), duration = frames/fps;
169
+ // Screenshots capture the live WebGL compositor and CSS motion without
170
+ // starting a deck narration tour or waiting forever on looped media.
171
+ for (let i = 0; i < frames; i++) {
172
+ const start = Date.now();
173
+ await page.screenshot({ path: join(temp, `frame-${String(i).padStart(4,'0')}.png`), timeout:10000 });
174
+ if (i < frames-1) await page.waitForTimeout(Math.max(0, 1000/fps - (Date.now()-start)));
175
+ }
176
+ await context.close();
177
+ clearTimeout(deadline);
178
+ const input = ['-framerate', String(fps), '-i', join(temp, 'frame-%04d.png')];
179
+ const paths = Object.fromEntries(['png','gif','mp4'].map(t => [t, resolve(config.out, `preview.${t}`)]));
180
+ await encode(ffmpeg, ['-i', join(temp,'frame-0000.png'), '-vf', `scale=${width}:${height}`, '-frames:v','1', paths.png]);
181
+ await encode(ffmpeg, [...input, '-filter_complex', `scale=${width}:${height}:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse`, '-loop','0', paths.gif]);
182
+ await encode(ffmpeg, [...input, '-vf',`scale=${width}:${height}`, '-an','-c:v','libx264','-pix_fmt','yuv420p','-movflags','+faststart',paths.mp4]);
183
+ if (blocked) warnings.push(`${blocked} navigation/telemetry/unsafe request(s) blocked; no recipient page was visited.`);
184
+ if (failed) warnings.push(`${failed} page/resource error(s); inspect the poster and increase --delay if necessary.`);
185
+ const files = await Promise.all(['gif','png','mp4'].map(async type => ({ type, path: paths[type], contentType: type === 'mp4' ? 'video/mp4' : `image/${type}`, bytes: (await stat(paths[type])).size, width, height, ...(type === 'png' ? {} : { duration, fps, silent:true, ...(type === 'gif' ? { loop:0 } : {}) }) })));
186
+ return { files, warnings };
187
+ } catch (e) { throw new Error(`Capture failed: ${timedOut ? '60s capture timeout; the renderer did not respond' : /Timeout/i.test(e.name) ? 'bounded capture timeout; inspect the deck or increase --delay' : e.message}`); }
188
+ finally {
189
+ clearTimeout(deadline);
190
+ if (browser) await browser.close().catch(() => {});
191
+ if (server) await server.close();
192
+ if (temp) await rm(temp, { recursive:true, force:true });
193
+ }
194
+ }
@@ -0,0 +1,117 @@
1
+ import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
2
+ import { resolve, extname } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { api, client, target, uploadBytes } from "./runtime.mjs";
5
+
6
+ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
7
+ export function httpUrl(value) {
8
+ const u = new URL(value);
9
+ if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password) throw new Error("Expected an HTTP(S) URL without credentials");
10
+ return value;
11
+ }
12
+ function bounded(value, fallback, min, max, name, integer = false) {
13
+ const n = value === undefined ? fallback : Number(value);
14
+ if (value === '' || !Number.isFinite(n) || n < min || n > max || (integer && !Number.isInteger(n))) throw new Error(`${name} must be ${integer ? 'an integer ' : ''}between ${min} and ${max}`);
15
+ return n;
16
+ }
17
+ export function previewOptions(pos, opts) {
18
+ const allowed = new Set(['file', 'out', 'duration', 'width', 'delay', 'label', 'aspect-ratio', 'share', 'upload', 'json', 'prod', 'dev', 'admin', 'broker', 'convex', 'token', 'client']);
19
+ for (const k of Object.keys(opts)) if (!allowed.has(k)) throw new Error(`Unknown preview option --${k}`);
20
+ for (const [k, v] of Object.entries(opts)) if (v === undefined) throw new Error(`--${k} requires a value`);
21
+ if (pos.length > 1 || (opts.file ? pos.length !== 0 : pos.length !== 1)) throw new Error("usage: preview create <slug> --out <new-directory> OR preview create --file <index.html> --out <new-directory>");
22
+ if (!opts.out || typeof opts.out !== 'string') throw new Error('--out <new-directory> is required');
23
+ if (opts.file && (opts.upload || opts.share)) throw new Error('Local-file capture is export-only; use an owned deck slug for --share/--upload');
24
+ if (opts.upload && !opts.share) throw new Error('--upload requires --share <existing-token>');
25
+ if (pos[0] && !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,199}$/.test(pos[0])) throw new Error('Invalid deck slug');
26
+ if (opts.share !== undefined && !/^[a-zA-Z0-9_-]{1,200}$/.test(opts.share)) throw new Error('Invalid share token');
27
+ if (opts.file && !['.html', '.htm'].includes(extname(opts.file).toLowerCase())) throw new Error('--file must be an HTML file');
28
+ const label = opts.label ?? 'Watch presentation';
29
+ if (typeof label !== 'string' || !label.trim() || label.length > 120 || /[\x00-\x1f]/.test(label)) throw new Error('--label must be 1–120 printable characters');
30
+ let aspect;
31
+ if (opts['aspect-ratio'] !== undefined) {
32
+ const m = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(opts['aspect-ratio']);
33
+ aspect = m ? Number(m[1]) / Number(m[2]) : NaN;
34
+ bounded(aspect, null, 0.5, 3, '--aspect-ratio');
35
+ }
36
+ return { slug: pos[0], file: opts.file && resolve(opts.file), out: resolve(opts.out), duration: bounded(opts.duration, 4, 1, 10, '--duration'), width: bounded(opts.width, 600, 240, 1200, '--width', true), delay: bounded(opts.delay, 1000, 0, 10000, '--delay', true), aspect, label };
37
+ }
38
+ export function validateShare(rows, slug, token, now = Date.now()) {
39
+ const row = rows.find(s => s.token === token && s.deckSlug === slug);
40
+ if (!row || row.enabled !== true || (row.expiresAt != null && row.expiresAt <= now) || (row.viewLimit != null && row.opens >= row.viewLimit)) throw new Error('Share is missing, mismatched, disabled, expired, or at its open limit; select another existing share');
41
+ return row;
42
+ }
43
+ export function emailHtml(image, url, label, width, height) {
44
+ httpUrl(image); httpUrl(url);
45
+ return `<a href="${escapeHtml(url)}" target="_blank"><img src="${escapeHtml(image)}" alt="${escapeHtml(label)}" width="${width}" height="${height}" style="display:block;max-width:100%;height:auto;border:0"></a>`;
46
+ }
47
+
48
+ // Capture is injected by tests; production loads the browser/encoder only here.
49
+ export async function createPreview(pos, opts, adapters = {}) {
50
+ const config = previewOptions(pos, opts);
51
+ let c, deck, html, shareUrl = null;
52
+ const warnings = [];
53
+ if (config.file) {
54
+ if (!(await stat(config.file)).isFile()) throw new Error('--file must be a regular HTML file');
55
+ } else {
56
+ c = await client(opts);
57
+ deck = await c.query(api.previews.get, { slug: config.slug });
58
+ if (opts.share) {
59
+ if (deck.status === 'archived') throw new Error('Archived deck cannot provide a sendable share preview');
60
+ validateShare(await c.query(api.shares.listForDeck, { deckSlug: config.slug }), config.slug, opts.share);
61
+ shareUrl = httpUrl(`${target(opts).shareBase}/v/${opts.share}`);
62
+ }
63
+ html = deck.html;
64
+ if (!html && deck.htmlUrl) {
65
+ const res = await (adapters.fetch || fetch)(httpUrl(deck.htmlUrl), { signal: AbortSignal.timeout(30000), redirect: 'error' });
66
+ if (!res.ok) throw new Error(`Could not read stored HTML (${res.status})`);
67
+ html = await res.text();
68
+ }
69
+ if (!html) throw new Error('Deck has no HTML to capture');
70
+ }
71
+ // A new directory prevents replacing artifacts referenced by an earlier manifest.
72
+ await mkdir(config.out, { recursive: false });
73
+ const manifestPath = resolve(config.out, 'manifest.json');
74
+ const result = { version: 1, status: 'capturing', source: config.file ? { file: config.file } : { slug: config.slug, title: deck.title }, shareUrl, manifestPath, files: [], email: { sendable: false, path: null, html: null }, warnings };
75
+ const save = () => writeFile(manifestPath, JSON.stringify(result, null, 2) + '\n');
76
+ await save();
77
+ try {
78
+ const capture = adapters.capture || (await import('./preview-capture.mjs')).capturePreview;
79
+ const captured = await capture({ ...config, html, firstPartyOrigins: [targetForCapture(opts), 'https://decks.hypersocial.ai', 'https://dashing-snail-874.convex.cloud'] });
80
+ result.files = captured.files;
81
+ warnings.push(...captured.warnings);
82
+ for (const f of result.files) if (f.bytes > 5 * 1024 * 1024) warnings.push(`${f.type} exceeds 5 MiB; shorten --duration or reduce --width for email`);
83
+ result.status = 'exported';
84
+ await save();
85
+ if (opts.upload) {
86
+ // Recheck after potentially slow capture and before any upload side effect.
87
+ validateShare(await c.query(api.shares.listForDeck, { deckSlug: config.slug }), config.slug, opts.share);
88
+ const id = randomUUID();
89
+ for (const f of result.files) {
90
+ const name = `preview-${id}-${f.type}`;
91
+ const storageId = await (adapters.uploadBytes || uploadBytes)(c, await readFile(f.path), f.contentType);
92
+ f.upload = { name, storageId, saved: false };
93
+ await save();
94
+ const saved = await c.mutation(api.assets.saveAsset, { name, storageId, contentType: f.contentType, deckSlug: config.slug });
95
+ f.upload.saved = true;
96
+ f.url = httpUrl(saved.url);
97
+ await save();
98
+ }
99
+ const gif = result.files.find(f => f.type === 'gif');
100
+ result.email = { sendable: true, path: resolve(config.out, 'email.html'), html: emailHtml(gif.url, shareUrl, config.label, gif.width, gif.height) };
101
+ await writeFile(result.email.path, result.email.html + '\n', { flag: 'wx' });
102
+ result.status = 'uploaded';
103
+ } else warnings.push('Local export: insert GIF/PNG into an email client manually. No sendable remote-image HTML exists until explicit upload with an existing share.');
104
+ await save();
105
+ return result;
106
+ } catch (e) {
107
+ result.status = 'failed';
108
+ // Do not persist raw HTTP/broker errors which may contain signed URLs or tokens.
109
+ result.error = 'Preview failed; local artifacts and any recorded partial uploads are preserved.';
110
+ await save();
111
+ throw new Error(`Preview failed. Artifacts and partial upload records: ${manifestPath}. ${e.message}`);
112
+ }
113
+ }
114
+ function targetForCapture(opts) {
115
+ // Offline local-file capture must not require deployment configuration/login.
116
+ return opts.file ? 'https://decks.hypersocial.ai' : target(opts).brokerBase;
117
+ }
package/src/runtime.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { ConvexHttpClient } from "convex/browser";
9
9
  import { makeFunctionReference } from "convex/server";
10
10
 
11
- export const VERSION = "0.1.9";
11
+ export const VERSION = "0.1.11";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -19,6 +19,7 @@ export const PROD = {
19
19
  };
20
20
 
21
21
  export const api = {
22
+ previews: { get: makeFunctionReference("previews:get") },
22
23
  assets: {
23
24
  generateUploadUrl: makeFunctionReference("assets:generateUploadUrl"),
24
25
  saveAsset: makeFunctionReference("assets:saveAsset"),
@@ -44,14 +45,19 @@ export const api = {
44
45
  listForDeck: makeFunctionReference("shares:listForDeck"),
45
46
  remove: makeFunctionReference("shares:remove"),
46
47
  setEnabled: makeFunctionReference("shares:setEnabled"),
48
+ setNotify: makeFunctionReference("shares:setNotify"),
47
49
  },
48
50
  imagegen: {
49
51
  generateImage: makeFunctionReference("imagegen:generateImage"),
50
52
  checkImageJob: makeFunctionReference("imagegen:checkImageJob"),
51
53
  },
54
+ credits: {
55
+ getBalance: makeFunctionReference("credits:getBalance"),
56
+ getLedger: makeFunctionReference("credits:getLedger"),
57
+ },
52
58
  };
53
59
 
54
- const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "help", "version", "all-agents", "global", "project", "transparent"]);
60
+ const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "help", "version", "all-agents", "global", "project", "transparent", "upload"]);
55
61
 
56
62
  export const CRED_DIR = join(homedir(), ".config", "hypersocial-decks");
57
63
  export const CRED_FILE = join(CRED_DIR, "credentials.json");