@hypersocial/decks-cli 0.1.5 → 0.1.8

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
@@ -16,6 +16,7 @@ pnpm dlx @hypersocial/decks-cli whoami
16
16
  pnpm dlx @hypersocial/decks-cli deck list
17
17
  pnpm dlx @hypersocial/decks-cli deck stats netflix
18
18
  pnpm dlx @hypersocial/decks-cli activity --limit 20
19
+ pnpm dlx @hypersocial/decks-cli import ./claude-design-export.zip --slug acme-pitch
19
20
  pnpm dlx @hypersocial/decks-cli skills install
20
21
  ```
21
22
 
@@ -61,6 +62,7 @@ share rm <token>
61
62
  share url <token>
62
63
 
63
64
  recipient archive|unarchive <name>
65
+ import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live|archived]
64
66
  skills install [--agent codex,claude-code] [--all-agents]
65
67
  activity [--deck <slug>] [--limit <n>] [--json]
66
68
  ```
@@ -79,3 +81,37 @@ HSDECKS_PROD=0 HSDECKS_CONVEX_URL=https://your-dev.convex.cloud HSDECKS_BROKER_B
79
81
 
80
82
  Legacy unauthenticated dev access is available with `--admin` or
81
83
  `HSDECKS_ADMIN=1`. It is rejected for production.
84
+
85
+ ## Claude Design Imports
86
+
87
+ Export a Claude Design project as standalone HTML or a ZIP, then import it into
88
+ Hyperdecks:
89
+
90
+ ```bash
91
+ # From a local export…
92
+ decks import ./claude-design-export.zip --slug acme-pitch --title "Acme Pitch"
93
+ # …or directly from a downloadable export URL (no local file needed):
94
+ decks import https://example.com/claude-design-export.zip --slug acme-pitch
95
+ decks share new acme-pitch "Jane at Acme" --email jane@acme.com
96
+ ```
97
+
98
+ The import command uploads bundled assets, rewrites local asset references, and
99
+ stores large generated HTML in Convex file storage so it can be sent through
100
+ per-recipient share links with analytics.
101
+
102
+ The URL form must point at the **downloadable export bytes** (a ZIP or standalone
103
+ HTML). A `claude.ai/design/p/...` project page is not importable on its own — use
104
+ the project's Export/Download to get a direct URL or a local ZIP. Only `http(s)`
105
+ is allowed and loopback/private hosts are refused.
106
+
107
+ ### MCP
108
+
109
+ The same import is exposed to agents via the `hypersocial-decks` MCP server as the
110
+ `import_deck_from_url` tool:
111
+
112
+ ```jsonc
113
+ // import_deck_from_url
114
+ { "url": "https://example.com/claude-design-export.zip", "slug": "acme-pitch", "status": "draft" }
115
+ ```
116
+
117
+ It authenticates with the stored CLI credential — run `decks login --prod` first.
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
7
7
  "exports": {
8
8
  ".": "./src/commands.mjs",
9
9
  "./commands": "./src/commands.mjs",
10
+ "./importer": "./src/importer.mjs",
10
11
  "./ops": "./src/ops.mjs",
11
12
  "./runtime": "./src/runtime.mjs",
12
13
  "./src/commands.mjs": "./src/commands.mjs",
14
+ "./src/importer.mjs": "./src/importer.mjs",
13
15
  "./src/ops.mjs": "./src/ops.mjs",
14
16
  "./src/runtime.mjs": "./src/runtime.mjs",
15
17
  "./package.json": "./package.json"
@@ -28,7 +30,8 @@
28
30
  "node": ">=20"
29
31
  },
30
32
  "dependencies": {
31
- "convex": "^1.41.0"
33
+ "convex": "^1.41.0",
34
+ "fflate": "^0.8.2"
32
35
  },
33
36
  "publishConfig": {
34
37
  "access": "public"
package/src/commands.mjs CHANGED
@@ -1,4 +1,13 @@
1
- import { installHyperdecksSkill, login, logout, openBrowser, parse, target, VERSION } from "./runtime.mjs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import {
3
+ installHyperdecksSkill,
4
+ login,
5
+ logout,
6
+ openBrowser,
7
+ parse,
8
+ target,
9
+ VERSION,
10
+ } from "./runtime.mjs";
2
11
  import * as ops from "./ops.mjs";
3
12
 
4
13
  const fmtMs = (ms) => (ms >= 60000 ? `${(ms / 60000).toFixed(1)}m` : `${Math.round(ms / 1000)}s`);
@@ -61,6 +70,13 @@ function deckOpen(pos, opts) {
61
70
  openBrowser(url);
62
71
  }
63
72
 
73
+ async function deckRemove(pos, opts) {
74
+ const [slug] = pos;
75
+ if (!slug) throw new Error("usage: deck rm <slug>");
76
+ await ops.removeDeck(opts, slug);
77
+ console.log(`OK removed deck ${slug}`);
78
+ }
79
+
64
80
  function shareBase(opts) {
65
81
  return target(opts).shareBase;
66
82
  }
@@ -145,6 +161,57 @@ async function activity(pos, opts) {
145
161
  }
146
162
  }
147
163
 
164
+ async function importDeck(pos, opts) {
165
+ const [src] = pos;
166
+ if (!src) {
167
+ throw new Error("usage: import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live]");
168
+ }
169
+ const fields = {
170
+ slug: opts.slug,
171
+ title: opts.title,
172
+ status: opts.status,
173
+ htmlFile: opts["html-file"],
174
+ };
175
+ // Shared import core lives in ops.mjs (so the MCP server runs the exact same
176
+ // path). A URL source streams server-side; a path reads bytes off disk.
177
+ const out = /^https?:\/\//i.test(src)
178
+ ? await ops.importDeckFromUrl(opts, { ...fields, url: src })
179
+ : await ops.importDeckFromBytes(opts, { ...fields, bytes: readFileSync(src), filename: src });
180
+
181
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
182
+ else {
183
+ console.log(`OK imported "${out.title}" as ${out.slug}`);
184
+ console.log(` assets: ${out.assets}`);
185
+ console.log(` html: ${(out.bytes / 1024).toFixed(1)} KB`);
186
+ console.log(` deck: ${out.deckUrl}`);
187
+ }
188
+ }
189
+
190
+ async function importDesignDeck(pos, opts) {
191
+ const [project] = pos;
192
+ if (!project) {
193
+ throw new Error("usage: import-design <project-id> --serve-token <token> --entry <file.html> --slug <slug>");
194
+ }
195
+ const out = await ops.importDeckFromDesign(opts, {
196
+ project,
197
+ token: opts["serve-token"],
198
+ entry: opts.entry,
199
+ slug: opts.slug,
200
+ title: opts.title,
201
+ status: opts.status,
202
+ });
203
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
204
+ else {
205
+ console.log(`OK imported "${out.title}" as ${out.slug}`);
206
+ console.log(` fetched: ${out.fetched} files, ${out.assets} assets`);
207
+ console.log(` html: ${(out.bytes / 1024).toFixed(1)} KB`);
208
+ if (out.missing?.length) {
209
+ console.log(` missing: ${out.missing.length} (${out.missing.slice(0, 5).join(", ")}${out.missing.length > 5 ? ", …" : ""})`);
210
+ }
211
+ console.log(` deck: ${out.deckUrl}`);
212
+ }
213
+ }
214
+
148
215
  export function help() {
149
216
  console.log(`decks ${VERSION}
150
217
 
@@ -152,9 +219,11 @@ commands:
152
219
  login [--token <pat>] [--broker <url>] [--dev]
153
220
  logout [--dev]
154
221
  whoami [--json] [--dev]
155
- deck list|stats|open
222
+ deck list|stats|open|rm
156
223
  share new|list|on|off|rm|url
157
224
  recipient archive|unarchive
225
+ import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live]
226
+ import-design <project-id> --serve-token <token> --entry <file.html> --slug <slug>
158
227
  skill install [--agent codex,claude-code] [--all-agents]
159
228
  skills install [--agent codex,claude-code] [--all-agents]
160
229
  activity [--deck <slug>] [--limit <n>] [--json]
@@ -168,6 +237,41 @@ global:
168
237
  `);
169
238
  }
170
239
 
240
+ async function genImage(pos, opts) {
241
+ const prompt = pos.join(" ").trim() || opts.prompt;
242
+ if (!prompt) {
243
+ throw new Error(
244
+ 'usage: gen-image "<prompt>" [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--slug <deck>] [--name <name>] [--download <file>]',
245
+ );
246
+ }
247
+ const out = await ops.generateImage(opts, {
248
+ prompt,
249
+ image: opts.image,
250
+ transparent: opts.transparent,
251
+ size: opts.size,
252
+ quality: opts.quality,
253
+ count: opts.count,
254
+ format: opts.format,
255
+ slug: opts.slug,
256
+ name: opts.name,
257
+ });
258
+ const images = out.images || [];
259
+ if (opts.download && images.length) {
260
+ for (let i = 0; i < images.length; i++) {
261
+ if (!images[i].url) continue;
262
+ const file = images.length > 1 ? opts.download.replace(/(\.[^./]+)?$/, `-${i + 1}$1`) : opts.download;
263
+ const res = await fetch(images[i].url);
264
+ writeFileSync(file, Buffer.from(await res.arrayBuffer()));
265
+ }
266
+ }
267
+ if (opts.json) {
268
+ console.log(JSON.stringify(out, null, 2));
269
+ } else {
270
+ for (const im of images) console.log(im.url);
271
+ if (opts.download) console.log(`saved ${images.length} file(s)`);
272
+ }
273
+ }
274
+
171
275
  export async function runCli(argv = process.argv.slice(2)) {
172
276
  const { pos, opts } = parse(argv);
173
277
  if (opts.version) {
@@ -188,6 +292,7 @@ export async function runCli(argv = process.argv.slice(2)) {
188
292
  "deck list": () => deckList(opts),
189
293
  "deck stats": () => deckStats(rest, opts),
190
294
  "deck open": () => deckOpen(rest, opts),
295
+ "deck rm": () => deckRemove(rest, opts),
191
296
  "share new": () => shareNew(rest, opts),
192
297
  "share list": () => shareList(rest, opts),
193
298
  "share on": () => shareToggle(rest, opts, true),
@@ -196,9 +301,12 @@ export async function runCli(argv = process.argv.slice(2)) {
196
301
  "share url": () => shareUrl(rest, opts),
197
302
  "recipient archive": () => recipientArchive(rest, opts, true),
198
303
  "recipient unarchive": () => recipientArchive(rest, opts, false),
304
+ import: () => importDeck(pos.slice(1), opts),
305
+ "import-design": () => importDesignDeck(pos.slice(1), opts),
199
306
  "skill install": () => installHyperdecksSkill(opts, rest),
200
307
  "skills install": () => installHyperdecksSkill(opts, rest),
201
308
  activity: () => activity(pos.slice(1), opts),
309
+ "gen-image": () => genImage(pos.slice(1), opts),
202
310
  };
203
311
 
204
312
  const handler = handlers[`${group} ${cmd}`] || handlers[group];
@@ -0,0 +1,330 @@
1
+ import { strFromU8, strToU8, unzipSync } from "fflate";
2
+
3
+ const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04];
4
+ const TEXT_TYPES = new Set([
5
+ "text/css",
6
+ "application/javascript",
7
+ "text/javascript",
8
+ "application/json",
9
+ "image/svg+xml",
10
+ ]);
11
+
12
+ export function slugify(input) {
13
+ return String(input || "")
14
+ .toLowerCase()
15
+ .normalize("NFKD")
16
+ .replace(/[\u0300-\u036f]/g, "")
17
+ .replace(/[^a-z0-9]+/g, "-")
18
+ .replace(/^-+|-+$/g, "")
19
+ .slice(0, 72);
20
+ }
21
+
22
+ export function titleFromFilename(filename) {
23
+ const base = basename(filename).replace(/\.(html?|zip)$/i, "");
24
+ return base
25
+ .replace(/[-_]+/g, " ")
26
+ .replace(/\s+/g, " ")
27
+ .trim()
28
+ .replace(/\b\w/g, (m) => m.toUpperCase());
29
+ }
30
+
31
+ export function inferContentType(path) {
32
+ const ext = path.toLowerCase().split(".").pop() || "";
33
+ switch (ext) {
34
+ case "html":
35
+ case "htm":
36
+ return "text/html; charset=utf-8";
37
+ case "css":
38
+ return "text/css";
39
+ case "js":
40
+ case "mjs":
41
+ return "application/javascript";
42
+ case "json":
43
+ return "application/json";
44
+ case "png":
45
+ return "image/png";
46
+ case "jpg":
47
+ case "jpeg":
48
+ return "image/jpeg";
49
+ case "gif":
50
+ return "image/gif";
51
+ case "webp":
52
+ return "image/webp";
53
+ case "svg":
54
+ return "image/svg+xml";
55
+ case "ico":
56
+ return "image/x-icon";
57
+ case "mp4":
58
+ return "video/mp4";
59
+ case "webm":
60
+ return "video/webm";
61
+ case "mp3":
62
+ return "audio/mpeg";
63
+ case "wav":
64
+ return "audio/wav";
65
+ case "woff":
66
+ return "font/woff";
67
+ case "woff2":
68
+ return "font/woff2";
69
+ case "ttf":
70
+ return "font/ttf";
71
+ case "otf":
72
+ return "font/otf";
73
+ default:
74
+ return "application/octet-stream";
75
+ }
76
+ }
77
+
78
+ export function bytesFromText(text) {
79
+ return strToU8(text);
80
+ }
81
+
82
+ export function textFromBytes(bytes) {
83
+ return strFromU8(bytes);
84
+ }
85
+
86
+ export function prepareDeckImport({ bytes, filename = "deck.html", htmlFile } = {}) {
87
+ if (!bytes) throw new Error("missing file bytes");
88
+ const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
89
+ const isZip = /\.zip$/i.test(filename) || ZIP_MAGIC.every((b, i) => input[i] === b);
90
+ if (!isZip) {
91
+ const html = textFromBytes(input);
92
+ return {
93
+ html,
94
+ htmlEntry: normalizePath(filename),
95
+ title: titleFromHtml(html) || titleFromFilename(filename),
96
+ assets: [],
97
+ warnings: [],
98
+ };
99
+ }
100
+
101
+ const unzipped = unzipSync(input);
102
+ const files = Object.entries(unzipped)
103
+ .map(([path, data]) => ({ path: normalizePath(path), data }))
104
+ .filter((f) => isUsableArchiveFile(f.path));
105
+ const htmlEntry = chooseHtmlEntry(files.map((f) => f.path), htmlFile);
106
+ if (!htmlEntry) throw new Error("zip does not contain an HTML file");
107
+
108
+ const htmlFileRow = files.find((f) => f.path === htmlEntry);
109
+ if (!htmlFileRow) throw new Error(`HTML file not found: ${htmlEntry}`);
110
+ const html = textFromBytes(htmlFileRow.data);
111
+ const baseDir = dirname(htmlEntry);
112
+ const seenNames = new Set();
113
+ const assets = files
114
+ .filter((f) => f.path !== htmlEntry && !/\.html?$/i.test(f.path))
115
+ .map((f) => {
116
+ const relative = stripBaseDir(f.path, baseDir);
117
+ const name = uniqueAssetName(relative || basename(f.path), seenNames);
118
+ return {
119
+ sourcePath: f.path,
120
+ relativePath: relative,
121
+ name,
122
+ contentType: inferContentType(f.path),
123
+ data: f.data,
124
+ };
125
+ });
126
+
127
+ return {
128
+ html,
129
+ htmlEntry,
130
+ title: titleFromHtml(html) || titleFromFilename(filename),
131
+ assets,
132
+ warnings: [],
133
+ };
134
+ }
135
+
136
+ export function buildAssetUrlMap(assets, uploadedByName, fromSourcePath) {
137
+ const map = new Map();
138
+ for (const asset of assets) {
139
+ const url = uploadedByName.get(asset.name);
140
+ if (!url) continue;
141
+ for (const key of candidateKeys(asset, fromSourcePath)) {
142
+ if (key) map.set(key, url);
143
+ }
144
+ }
145
+ return map;
146
+ }
147
+
148
+ export function rewriteCssAssetUrls(css, cssSourcePath, assets, uploadedByName) {
149
+ const assetMap = buildAssetUrlMap(assets, uploadedByName, cssSourcePath);
150
+ return rewriteTextRefs(rewriteCssUrls(css, assetMap), assetMap);
151
+ }
152
+
153
+ export function rewriteTextAssetUrls(text, sourcePath, assets, uploadedByName) {
154
+ const assetMap = buildAssetUrlMap(assets, uploadedByName, sourcePath);
155
+ return rewriteTextRefs(rewriteCssUrls(text, assetMap), assetMap);
156
+ }
157
+
158
+ export function rewriteHtmlAssetUrls(html, htmlEntry, assets, uploadedByName) {
159
+ const assetMap = buildAssetUrlMap(assets, uploadedByName, htmlEntry);
160
+ let out = html.replace(
161
+ /\b(src|href|poster)\s*=\s*(["'])([^"']+)\2/gi,
162
+ (match, attr, quote, raw) => {
163
+ const url = resolveLocalUrl(raw, assetMap);
164
+ return url ? `${attr}=${quote}${url}${quote}` : match;
165
+ },
166
+ );
167
+ out = out.replace(/\bsrcset\s*=\s*(["'])([^"']+)\1/gi, (match, quote, raw) => {
168
+ const rewritten = raw
169
+ .split(",")
170
+ .map((part) => {
171
+ const trimmed = part.trim();
172
+ if (!trimmed) return trimmed;
173
+ const [urlPart, ...descriptor] = trimmed.split(/\s+/);
174
+ const url = resolveLocalUrl(urlPart, assetMap) || urlPart;
175
+ return [url, ...descriptor].join(" ");
176
+ })
177
+ .join(", ");
178
+ return `srcset=${quote}${rewritten}${quote}`;
179
+ });
180
+ return rewriteTextRefs(rewriteCssUrls(out, assetMap), assetMap);
181
+ }
182
+
183
+ export function isTextAsset(asset) {
184
+ const type = String(asset.contentType || "").split(";")[0].trim().toLowerCase();
185
+ return type.startsWith("text/") || TEXT_TYPES.has(type);
186
+ }
187
+
188
+ function titleFromHtml(html) {
189
+ return html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() || "";
190
+ }
191
+
192
+ function chooseHtmlEntry(paths, requested) {
193
+ const normalized = requested ? normalizePath(requested) : "";
194
+ if (normalized && paths.includes(normalized)) return normalized;
195
+ const htmls = paths.filter((p) => /\.html?$/i.test(p));
196
+ const index = htmls
197
+ .filter((p) => /(^|\/)index\.html?$/i.test(p))
198
+ .sort((a, b) => pathDepth(a) - pathDepth(b) || a.length - b.length)[0];
199
+ return index || htmls.sort((a, b) => pathDepth(a) - pathDepth(b) || a.localeCompare(b))[0] || "";
200
+ }
201
+
202
+ function rewriteCssUrls(input, assetMap) {
203
+ return input.replace(/url\(\s*(["']?)([^"')]+)\1\s*\)/gi, (match, quote, raw) => {
204
+ const url = resolveLocalUrl(raw, assetMap);
205
+ return url ? `url(${quote}${url}${quote})` : match;
206
+ });
207
+ }
208
+
209
+ function rewriteTextRefs(input, assetMap) {
210
+ return rewriteEscapedTextRefs(input, assetMap).replace(/(["'`])([^"'`]+)\1/g, (match, quote, raw) => {
211
+ if (quote === "`" && raw.includes("${")) return match;
212
+ const url = resolveLocalUrl(raw, assetMap);
213
+ return url ? `${quote}${url}${quote}` : match;
214
+ });
215
+ }
216
+
217
+ function rewriteEscapedTextRefs(input, assetMap) {
218
+ return input.replace(/\\(["'])([^"'`]*?)\\\1/g, (match, quote, raw) => {
219
+ const unescapedRaw = raw.replace(/\\\//g, "/");
220
+ const url = resolveLocalUrl(unescapedRaw, assetMap);
221
+ return url ? `\\${quote}${url}\\${quote}` : match;
222
+ });
223
+ }
224
+
225
+ function resolveLocalUrl(raw, assetMap) {
226
+ const value = String(raw || "").trim();
227
+ if (!value || isExternalOrSpecial(value)) return "";
228
+ const clean = normalizeRefPath(value);
229
+ return assetMap.get(clean) || assetMap.get(clean.replace(/^\//, "")) || fallbackLocalUrl(clean);
230
+ }
231
+
232
+ function fallbackLocalUrl(clean) {
233
+ return clean === ".image-slots.state.json" ? "data:application/json,%7B%7D" : "";
234
+ }
235
+
236
+ function isExternalOrSpecial(value) {
237
+ return (
238
+ value.startsWith("#") ||
239
+ value.startsWith("//") ||
240
+ value.startsWith("data:") ||
241
+ value.startsWith("blob:") ||
242
+ value.startsWith("mailto:") ||
243
+ value.startsWith("tel:") ||
244
+ value.startsWith("javascript:") ||
245
+ /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
246
+ );
247
+ }
248
+
249
+ function candidateKeys(asset, fromSourcePath) {
250
+ const fromDir = dirname(fromSourcePath || "");
251
+ return [
252
+ asset.sourcePath,
253
+ asset.relativePath,
254
+ asset.name,
255
+ `/${asset.relativePath}`,
256
+ `/${asset.name}`,
257
+ normalizePath(relativePath(fromDir, asset.sourcePath)),
258
+ basename(asset.sourcePath),
259
+ ].map(normalizePath);
260
+ }
261
+
262
+ function normalizeRefPath(value) {
263
+ const withoutHash = value.split("#")[0];
264
+ const withoutQuery = withoutHash.split("?")[0];
265
+ try {
266
+ return normalizePath(decodeURIComponent(withoutQuery));
267
+ } catch {
268
+ return normalizePath(withoutQuery);
269
+ }
270
+ }
271
+
272
+ function normalizePath(path) {
273
+ return String(path || "")
274
+ .replace(/\\/g, "/")
275
+ .replace(/^\.?\//, "")
276
+ .replace(/\/+/g, "/");
277
+ }
278
+
279
+ function isUsableArchiveFile(path) {
280
+ if (!path || path.endsWith("/")) return false;
281
+ if (path.includes("__MACOSX/")) return false;
282
+ if (basename(path) === ".DS_Store") return false;
283
+ return true;
284
+ }
285
+
286
+ function stripBaseDir(path, baseDir) {
287
+ if (!baseDir) return path;
288
+ return path.startsWith(`${baseDir}/`) ? path.slice(baseDir.length + 1) : path;
289
+ }
290
+
291
+ function uniqueAssetName(name, seen) {
292
+ const clean = normalizePath(name).replace(/^\/+/, "") || "asset";
293
+ if (!seen.has(clean)) {
294
+ seen.add(clean);
295
+ return clean;
296
+ }
297
+ const dot = clean.lastIndexOf(".");
298
+ const stem = dot === -1 ? clean : clean.slice(0, dot);
299
+ const ext = dot === -1 ? "" : clean.slice(dot);
300
+ let i = 2;
301
+ while (seen.has(`${stem}-${i}${ext}`)) i++;
302
+ const out = `${stem}-${i}${ext}`;
303
+ seen.add(out);
304
+ return out;
305
+ }
306
+
307
+ function dirname(path) {
308
+ const clean = normalizePath(path);
309
+ const i = clean.lastIndexOf("/");
310
+ return i === -1 ? "" : clean.slice(0, i);
311
+ }
312
+
313
+ function basename(path) {
314
+ const clean = normalizePath(path);
315
+ return clean.slice(clean.lastIndexOf("/") + 1);
316
+ }
317
+
318
+ function pathDepth(path) {
319
+ return normalizePath(path).split("/").filter(Boolean).length;
320
+ }
321
+
322
+ function relativePath(fromDir, toPath) {
323
+ const from = normalizePath(fromDir).split("/").filter(Boolean);
324
+ const to = normalizePath(toPath).split("/").filter(Boolean);
325
+ while (from.length && to.length && from[0] === to[0]) {
326
+ from.shift();
327
+ to.shift();
328
+ }
329
+ return `${"../".repeat(from.length)}${to.join("/")}`;
330
+ }
package/src/ops.mjs CHANGED
@@ -1,4 +1,16 @@
1
- import { api, client } from "./runtime.mjs";
1
+ import { zipSync } from "fflate";
2
+ import { api, client, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
3
+ import {
4
+ bytesFromText,
5
+ isTextAsset,
6
+ prepareDeckImport,
7
+ rewriteCssAssetUrls,
8
+ rewriteHtmlAssetUrls,
9
+ rewriteTextAssetUrls,
10
+ slugify,
11
+ textFromBytes,
12
+ titleFromFilename,
13
+ } from "./importer.mjs";
2
14
 
3
15
  export async function whoami(opts = {}) {
4
16
  return (await client(opts)).query(api.decks.whoami, {});
@@ -11,7 +23,7 @@ export async function listDecks(opts = {}) {
11
23
  title: d.title,
12
24
  status: d.status,
13
25
  ownerEmail: d.ownerEmail ?? null,
14
- hasHtml: !!d.html,
26
+ hasHtml: !!(d.html || d.htmlStorageId || d.hasHtml),
15
27
  }));
16
28
  }
17
29
 
@@ -24,6 +36,24 @@ export async function activityOverview(opts = {}) {
24
36
  return (await client(opts)).query(api.activity.overview, {});
25
37
  }
26
38
 
39
+ // Generate an image via fal (GPT Image 2 t2i/i2i, optional BiRefNet transparency),
40
+ // store it in Convex, and return the hosted URL. Runs the imagegen action as the
41
+ // authenticated user (injected client supported via opts.client).
42
+ export async function generateImage(opts, fields = {}) {
43
+ if (!fields.prompt) throw new Error("prompt is required");
44
+ const args = { prompt: String(fields.prompt) };
45
+ const img = fields.image || fields.imageUrl;
46
+ if (img) args.imageUrl = String(img);
47
+ if (fields.transparent) args.transparent = true;
48
+ if (fields.slug) args.slug = String(fields.slug);
49
+ if (fields.name) args.name = String(fields.name);
50
+ if (fields.size) args.size = String(fields.size);
51
+ if (fields.quality) args.quality = String(fields.quality);
52
+ if (fields.count != null && fields.count !== "") args.count = Number(fields.count);
53
+ if (fields.format) args.format = String(fields.format);
54
+ return (await client(opts)).action(api.imagegen.generateImage, args);
55
+ }
56
+
27
57
  export async function listShares(opts, slug) {
28
58
  if (!slug) throw new Error("slug is required");
29
59
  const rows = await (await client(opts)).query(api.shares.listForDeck, { deckSlug: slug });
@@ -73,8 +103,387 @@ export async function removeShare(opts, token) {
73
103
  return { token, removed: true };
74
104
  }
75
105
 
106
+ export async function removeDeck(opts, slug) {
107
+ if (!slug) throw new Error("slug is required");
108
+ // Cascade delete (deck + its assets/shares/sessions + stored HTML) runs in
109
+ // decks:remove; this just authorizes as the owner and fires it.
110
+ await (await client(opts)).mutation(api.decks.remove, { slug });
111
+ return { slug, removed: true };
112
+ }
113
+
76
114
  export async function setRecipientArchived(opts, recipient, archived) {
77
115
  if (!recipient) throw new Error("recipient is required");
78
116
  await (await client(opts)).mutation(api.activity.setArchived, { recipient, archived });
79
117
  return { recipient, archived: !!archived };
80
118
  }
119
+
120
+ // ── Deck import ─────────────────────────────────────────────────────────────
121
+ // IMPORTANT: `app/api/decks/import/route.js` implements the SAME pipeline for the
122
+ // dashboard's browser/multipart path (Clerk session client + per-asset progress
123
+ // logging). The two are intentionally NOT yet merged: importing this module into
124
+ // the Next route would pull the whole CLI runtime (OAuth login flow, node:http
125
+ // server, child_process) into that serverless bundle. `importDeckFromBytes`
126
+ // accepts an `adapter` so a future pass can extract a runtime-free shared core
127
+ // and have both call it. Until then, keep the two pipelines in sync by hand.
128
+
129
+ const IMPORT_STATUSES = new Set(["draft", "live", "archived"]);
130
+ const DEFAULT_IMPORT_CONCURRENCY = 3;
131
+ const URL_FETCH_TIMEOUT_MS = 120_000;
132
+
133
+ const deckHasHtml = (deck) => !!(deck?.hasHtml || deck?.html || deck?.htmlUrl || deck?.htmlStorageId);
134
+
135
+ // Bounded-concurrency map that fails fast and surfaces the first error. Used so a
136
+ // media-heavy import doesn't upload assets one-at-a-time.
137
+ async function mapWithConcurrency(items, limit, worker) {
138
+ if (!items.length) return;
139
+ let next = 0;
140
+ let failure = null;
141
+ const size = Math.max(1, Math.min(limit || 1, items.length));
142
+ const runners = Array.from({ length: size }, async () => {
143
+ while (!failure) {
144
+ const index = next;
145
+ next += 1;
146
+ if (index >= items.length) return;
147
+ try {
148
+ await worker(items[index], index);
149
+ } catch (e) {
150
+ failure = e;
151
+ }
152
+ }
153
+ });
154
+ await Promise.all(runners);
155
+ if (failure) throw failure;
156
+ }
157
+
158
+ // Import a Claude Design export (ZIP or single HTML) into Hyperdecks: upload its
159
+ // assets to Convex storage, rewrite refs to the stored URLs, store the HTML, and
160
+ // upsert the deck. Rollback is owned here (single layer): a deck created for this
161
+ // import — or an existing record that had no HTML yet — is removed on failure; an
162
+ // existing deck that already had valid HTML is left untouched.
163
+ //
164
+ // `adapter` lets a caller inject its own Convex client / upload helper / progress
165
+ // sink (the dashboard route would pass its Clerk session); defaults use the CLI
166
+ // runtime so CLI + MCP share this exact path.
167
+ export async function importDeckFromBytes(opts = {}, fields = {}, adapter = {}) {
168
+ const { bytes, filename = "deck.html", htmlFile } = fields;
169
+ if (!bytes) throw new Error("import needs file bytes");
170
+
171
+ const parsed = prepareDeckImport({ bytes, filename, htmlFile });
172
+
173
+ // Derive the slug from an explicit slug, else the caller-provided title, else the
174
+ // document <title>, and only fall back to the filename last — so a deck imported
175
+ // as "Strudel" becomes /strudel, not /bundled-page (the bundle's generic filename).
176
+ const slug = slugify(fields.slug || fields.title || parsed.title || titleFromFilename(filename));
177
+ if (!slug) throw new Error("import needs --slug <slug>");
178
+ const title = String(fields.title || parsed.title || titleFromFilename(filename) || slug);
179
+ const status = String(fields.status || "draft");
180
+ if (!IMPORT_STATUSES.has(status)) {
181
+ throw new Error("status must be draft, live, or archived");
182
+ }
183
+
184
+ const c = adapter.client || (await client(opts));
185
+ const upload = adapter.uploadBytes || runtimeUploadBytes;
186
+ const concurrency = adapter.concurrency || DEFAULT_IMPORT_CONCURRENCY;
187
+ const onStage = typeof adapter.onStage === "function" ? adapter.onStage : () => {};
188
+
189
+ onStage("checking existing deck");
190
+ const existingDeck = await c.query(api.decks.get, { slug });
191
+ const shouldRollbackDeck = !existingDeck;
192
+ const shouldRollbackPartial = !!existingDeck && !deckHasHtml(existingDeck);
193
+ let deckCreated = false;
194
+
195
+ const uploadedByName = new Map();
196
+ const saveDeckAsset = async (asset, data = asset.data, contentType = asset.contentType) => {
197
+ const storageId = await upload(c, data, contentType, asset.name);
198
+ const saved = await c.mutation(api.assets.saveAsset, {
199
+ name: asset.name,
200
+ storageId,
201
+ contentType,
202
+ deckSlug: slug,
203
+ });
204
+ uploadedByName.set(asset.name, saved.url);
205
+ return saved.url;
206
+ };
207
+
208
+ try {
209
+ if (shouldRollbackDeck) {
210
+ onStage("creating deck");
211
+ // assets:saveAsset checks deck ownership, so the deck must exist first.
212
+ await c.mutation(api.decks.upsert, { slug, title, status: "draft" });
213
+ deckCreated = true;
214
+ }
215
+
216
+ // Phase 1: upload every asset and fully populate uploadedByName.
217
+ await mapWithConcurrency(parsed.assets, concurrency, async (asset, index) => {
218
+ onStage(`uploading asset ${index + 1}/${parsed.assets.length}: ${asset.name}`);
219
+ await saveDeckAsset(asset);
220
+ });
221
+
222
+ // Phase 2 (hard barrier after phase 1): rewrite text assets against the now-
223
+ // complete URL map and re-upload them under the same name. Must not interleave
224
+ // with phase 1 or refs could resolve against a half-built map.
225
+ const textAssets = parsed.assets.filter(isTextAsset);
226
+ await mapWithConcurrency(textAssets, concurrency, async (asset, index) => {
227
+ onStage(`rewriting asset ${index + 1}/${textAssets.length}: ${asset.name}`);
228
+ const text = asset.contentType === "text/css"
229
+ ? rewriteCssAssetUrls(textFromBytes(asset.data), asset.sourcePath, parsed.assets, uploadedByName)
230
+ : rewriteTextAssetUrls(textFromBytes(asset.data), asset.sourcePath, parsed.assets, uploadedByName);
231
+ await saveDeckAsset(asset, bytesFromText(text), asset.contentType);
232
+ });
233
+
234
+ onStage("storing deck HTML");
235
+ const html = rewriteHtmlAssetUrls(parsed.html, parsed.htmlEntry, parsed.assets, uploadedByName);
236
+ const htmlBytes = bytesFromText(html);
237
+ const htmlStorageId = await upload(c, htmlBytes, "text/html; charset=utf-8", "deck HTML");
238
+ onStage("saving deck");
239
+ await c.mutation(api.decks.upsert, { slug, title, status });
240
+ onStage("saving deck HTML");
241
+ await c.mutation(api.decks.setHtmlStorage, { slug, storageId: htmlStorageId, bytes: htmlBytes.byteLength });
242
+
243
+ const t = target(opts);
244
+ return {
245
+ slug,
246
+ title,
247
+ status,
248
+ assets: parsed.assets.length,
249
+ bytes: htmlBytes.byteLength,
250
+ deployment: t.name,
251
+ // The owner-facing dashboard view (Preview / Stats / Share / Present), not
252
+ // the raw /d/<slug> storage redirect.
253
+ deckUrl: `${t.brokerBase}/dashboard/d/${slug}`,
254
+ };
255
+ } catch (e) {
256
+ if ((shouldRollbackDeck && deckCreated) || shouldRollbackPartial) {
257
+ await c.mutation(api.decks.remove, { slug }).catch(() => {});
258
+ }
259
+ throw e;
260
+ }
261
+ }
262
+
263
+ // SSRF guard: only http(s), and refuse obvious loopback / private / link-local
264
+ // targets. This blocks literal hosts/IPs; it does NOT resolve DNS, so a public
265
+ // hostname that resolves to a private address is not caught here (documented
266
+ // limitation — acceptable since the fetch runs on the caller's own machine with
267
+ // their credentials).
268
+ function isBlockedHost(host) {
269
+ if (!host) return true;
270
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
271
+ if (host === "::1") return true;
272
+ if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true; // link-local + ULA
273
+ const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\d{1,3}$/);
274
+ if (m) {
275
+ const a = Number(m[1]);
276
+ const b = Number(m[2]);
277
+ if (a === 0 || a === 127 || a === 10) return true; // 0/8, loopback, RFC1918 10/8
278
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
279
+ if (a === 192 && b === 168) return true; // 192.168/16
280
+ if (a === 169 && b === 254) return true; // link-local + metadata 169.254.169.254
281
+ }
282
+ return false;
283
+ }
284
+
285
+ function assertImportableUrl(rawUrl) {
286
+ let url;
287
+ try {
288
+ url = new URL(rawUrl);
289
+ } catch {
290
+ throw new Error(`invalid url: ${rawUrl}`);
291
+ }
292
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
293
+ throw new Error(`url must be http(s); got "${url.protocol.replace(/:$/, "")}"`);
294
+ }
295
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
296
+ if (isBlockedHost(host)) {
297
+ throw new Error(`refusing to fetch private/loopback host: ${url.hostname}`);
298
+ }
299
+ return url;
300
+ }
301
+
302
+ function filenameFromResponse(url, res) {
303
+ const cd = res.headers.get("content-disposition") || "";
304
+ const m = cd.match(/filename\*?=(?:UTF-8'')?["']?([^"';]+)/i);
305
+ if (m && m[1]) {
306
+ try {
307
+ return decodeURIComponent(m[1].trim());
308
+ } catch {
309
+ return m[1].trim();
310
+ }
311
+ }
312
+ const base = url.pathname.split("/").filter(Boolean).pop();
313
+ if (base && /\.[a-z0-9]+$/i.test(base)) return base;
314
+ const ct = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
315
+ if (ct.includes("zip")) return "deck.zip";
316
+ if (ct.includes("html")) return "deck.html";
317
+ return "deck.zip";
318
+ }
319
+
320
+ // Fetch a direct Claude Design HTML/ZIP export URL server-side (no 256 KiB /
321
+ // 10 MB caps apply here) and import it via importDeckFromBytes. `adapter` is
322
+ // forwarded so tests can inject a fake client/upload helper.
323
+ export async function importDeckFromUrl(opts = {}, fields = {}, adapter = {}) {
324
+ if (!fields.url) throw new Error("import needs a url");
325
+ const url = assertImportableUrl(fields.url);
326
+
327
+ let res;
328
+ try {
329
+ // Browser-ish headers: some hosts (e.g. Cloudflare-fronted claudeusercontent.com)
330
+ // bot-challenge requests with a default Node/undici user-agent. This helps with
331
+ // simple UA rules but will NOT pass a managed JS challenge from a datacenter IP.
332
+ res = await fetch(url, {
333
+ redirect: "follow",
334
+ headers: {
335
+ "user-agent":
336
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
337
+ accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
338
+ "accept-language": "en-US,en;q=0.9",
339
+ },
340
+ signal: AbortSignal.timeout(URL_FETCH_TIMEOUT_MS),
341
+ });
342
+ } catch (e) {
343
+ if (e?.name === "TimeoutError") {
344
+ throw new Error(`fetch timed out after ${URL_FETCH_TIMEOUT_MS / 1000}s: ${url}`);
345
+ }
346
+ throw new Error(`could not fetch ${url}: ${e?.message || e}`);
347
+ }
348
+
349
+ // A redirect could land on a private host even when the first URL was public.
350
+ if (res.url && res.url !== String(url)) assertImportableUrl(res.url);
351
+
352
+ if (!res.ok) {
353
+ const body = (await res.text().catch(() => "")).slice(0, 200);
354
+ throw new Error(`fetch failed (${res.status} ${res.statusText || ""}) for ${url}${body ? `: ${body}` : ""}`);
355
+ }
356
+
357
+ const bytes = new Uint8Array(await res.arrayBuffer());
358
+ const filename = fields.filename || filenameFromResponse(url, res);
359
+ return importDeckFromBytes(opts, { ...fields, bytes, filename }, adapter);
360
+ }
361
+
362
+ // ── Import straight from a Claude Design project ─────────────────────────────
363
+ // Claude Design serves every project file UNCAPPED from a per-project CDN host:
364
+ // https://<projectId>.claudeusercontent.com/v1/design/projects/<projectId>/serve/<path>?t=<token>
365
+ // The `?t=` token is project-scoped (one token fetches every file) and the only
366
+ // credential needed — no cookie. This is the path the design preview itself uses
367
+ // for images, and unlike the MCP `get_file` (256 KiB cap) it streams full bytes,
368
+ // so it's how we pull a media-heavy deck without an export.
369
+ //
370
+ // Caveat: this is an undocumented internal endpoint and may change. The supported
371
+ // alternative remains an Export ZIP fed to importDeckFromBytes.
372
+ const DESIGN_HOST_SUFFIX = ".claudeusercontent.com";
373
+ const MAX_DESIGN_FILES = 500;
374
+
375
+ function designServeUrl(projectId, token, path) {
376
+ const clean = String(path).replace(/^\/+/, "");
377
+ const encoded = clean.split("/").map(encodeURIComponent).join("/");
378
+ return `https://${projectId}${DESIGN_HOST_SUFFIX}/v1/design/projects/${projectId}/serve/${encoded}?t=${encodeURIComponent(token)}`;
379
+ }
380
+
381
+ // Pull every local asset path a deck file references (HTML or CSS): the
382
+ // ext-resource-dependency manifest, src/href/poster, srcset, <script>/<link>,
383
+ // and CSS url(). Mirrors the importer's rewrite surface so we fetch the same set
384
+ // it will later rewrite. External/data/blob refs are skipped.
385
+ function extractDesignRefs(text) {
386
+ const out = new Set();
387
+ const add = (raw) => {
388
+ const v = String(raw || "").trim();
389
+ if (!v || /^(?:https?:|data:|blob:|mailto:|tel:|javascript:|#|\/\/)/i.test(v)) return;
390
+ const clean = v.split("#")[0].split("?")[0].replace(/^\.?\//, "");
391
+ if (clean) out.add(clean);
392
+ };
393
+ for (const m of text.matchAll(/ext-resource-dependency"\s+content="([^"]+)"/gi)) add(m[1]);
394
+ for (const m of text.matchAll(/\b(?:src|href|poster)\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
395
+ for (const m of text.matchAll(/\bsrcset\s*=\s*["']([^"']+)["']/gi)) {
396
+ for (const part of m[1].split(",")) add(part.trim().split(/\s+/)[0]);
397
+ }
398
+ for (const m of text.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/gi)) add(m[1]);
399
+ return [...out];
400
+ }
401
+
402
+ function looksTextual(contentType, path) {
403
+ const ct = String(contentType || "").toLowerCase();
404
+ if (/text\/|javascript|json|svg|css/.test(ct)) return true;
405
+ return /\.(html?|css|m?js|json|svg)$/i.test(path);
406
+ }
407
+
408
+ export async function importDeckFromDesign(opts = {}, fields = {}, adapter = {}) {
409
+ const project = fields.project;
410
+ const token = fields.token;
411
+ if (!project) throw new Error("import-design needs --project <id>");
412
+ if (!token) throw new Error("import-design needs --token <serve-token>");
413
+ const entry = String(fields.entry || "index.html").replace(/^\/+/, "");
414
+ const concurrency = adapter.concurrency || DEFAULT_IMPORT_CONCURRENCY;
415
+ const log = typeof adapter.onStage === "function" ? adapter.onStage : () => {};
416
+
417
+ const fetchServe = async (path) => {
418
+ const res = await fetch(designServeUrl(project, token, path), {
419
+ signal: AbortSignal.timeout(URL_FETCH_TIMEOUT_MS),
420
+ });
421
+ if (!res.ok) return { path, ok: false, status: res.status };
422
+ const ct = res.headers.get("content-type") || "";
423
+ return { path, ok: true, bytes: new Uint8Array(await res.arrayBuffer()), contentType: ct };
424
+ };
425
+
426
+ // The entry HTML is required — its refs seed the crawl.
427
+ log(`fetching ${entry}`);
428
+ const root = await fetchServe(entry);
429
+ if (!root.ok) {
430
+ throw new Error(
431
+ `could not fetch entry "${entry}" (status ${root.status}). Pass --entry <file.html> and check --token hasn't expired.`,
432
+ );
433
+ }
434
+
435
+ // Breadth-first over refs: HTML/CSS/JS we fetch get re-scanned for nested refs
436
+ // (e.g. CSS url(), a deck script that pulls more assets). Binary files are leaves.
437
+ const files = new Map([[entry, root.bytes]]);
438
+ const queue = extractDesignRefs(textFromBytes(root.bytes));
439
+ const seen = new Set([entry, ...queue]);
440
+ const missing = [];
441
+
442
+ while (queue.length && files.size < MAX_DESIGN_FILES) {
443
+ const batch = queue.splice(0, concurrency);
444
+ const results = await Promise.all(
445
+ batch.map(async (path) => {
446
+ log(`fetching ${path}`);
447
+ try {
448
+ return await fetchServe(path);
449
+ } catch (e) {
450
+ return { path, ok: false, status: e?.message || "fetch error" };
451
+ }
452
+ }),
453
+ );
454
+ for (const r of results) {
455
+ if (!r.ok) {
456
+ missing.push(`${r.path} (${r.status})`);
457
+ continue;
458
+ }
459
+ files.set(r.path, r.bytes);
460
+ if (looksTextual(r.contentType, r.path)) {
461
+ for (const ref of extractDesignRefs(textFromBytes(r.bytes))) {
462
+ if (!seen.has(ref)) {
463
+ seen.add(ref);
464
+ queue.push(ref);
465
+ }
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ // Zip the crawl and hand it to the shared importer (asset upload + ref rewrite).
472
+ const zipInput = {};
473
+ for (const [path, bytes] of files) zipInput[path] = bytes;
474
+ const zip = zipSync(zipInput);
475
+
476
+ const result = await importDeckFromBytes(
477
+ opts,
478
+ {
479
+ bytes: zip,
480
+ filename: `${slugify(fields.slug || titleFromFilename(entry)) || "deck"}.zip`,
481
+ htmlFile: entry,
482
+ slug: fields.slug,
483
+ title: fields.title,
484
+ status: fields.status,
485
+ },
486
+ adapter,
487
+ );
488
+ return { ...result, fetched: files.size, missing };
489
+ }
package/src/runtime.mjs CHANGED
@@ -8,7 +8,8 @@ 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.5";
11
+ export const VERSION = "0.1.7";
12
+ const UPLOAD_TIMEOUT_MS = 125_000;
12
13
 
13
14
  export const PROD = {
14
15
  name: "prod",
@@ -18,6 +19,10 @@ export const PROD = {
18
19
  };
19
20
 
20
21
  export const api = {
22
+ assets: {
23
+ generateUploadUrl: makeFunctionReference("assets:generateUploadUrl"),
24
+ saveAsset: makeFunctionReference("assets:saveAsset"),
25
+ },
21
26
  activity: {
22
27
  overview: makeFunctionReference("activity:overview"),
23
28
  setArchived: makeFunctionReference("activity:setArchived"),
@@ -26,7 +31,11 @@ export const api = {
26
31
  revokeByHash: makeFunctionReference("cliCredentials:revokeByHash"),
27
32
  },
28
33
  decks: {
34
+ get: makeFunctionReference("decks:get"),
29
35
  listForViewer: makeFunctionReference("decks:listForViewer"),
36
+ remove: makeFunctionReference("decks:remove"),
37
+ setHtmlStorage: makeFunctionReference("decks:setHtmlStorage"),
38
+ upsert: makeFunctionReference("decks:upsert"),
30
39
  whoami: makeFunctionReference("decks:whoami"),
31
40
  },
32
41
  shares: {
@@ -36,9 +45,12 @@ export const api = {
36
45
  remove: makeFunctionReference("shares:remove"),
37
46
  setEnabled: makeFunctionReference("shares:setEnabled"),
38
47
  },
48
+ imagegen: {
49
+ generateImage: makeFunctionReference("imagegen:generateImage"),
50
+ },
39
51
  };
40
52
 
41
- const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "help", "version", "all-agents", "global", "project"]);
53
+ const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "help", "version", "all-agents", "global", "project", "transparent"]);
42
54
 
43
55
  export const CRED_DIR = join(homedir(), ".config", "hypersocial-decks");
44
56
  export const CRED_FILE = join(CRED_DIR, "credentials.json");
@@ -158,6 +170,9 @@ async function jwtFor(brokerBase, refreshToken) {
158
170
  }
159
171
 
160
172
  export async function client(opts = {}) {
173
+ // Allow a pre-authenticated client to be injected (e.g. the remote MCP OAuth
174
+ // route, which already holds a Convex client authed as the requesting user).
175
+ if (opts.client) return opts.client;
161
176
  const t = target(opts);
162
177
  const c = new ConvexHttpClient(t.convexUrl);
163
178
  const admin = opts.admin || process.env.HSDECKS_ADMIN === "1";
@@ -182,6 +197,27 @@ export function openBrowser(url) {
182
197
  execFile(cmd, args, () => {});
183
198
  }
184
199
 
200
+ export async function uploadBytes(c, bytes, contentType) {
201
+ const uploadUrl = await c.mutation(api.assets.generateUploadUrl, {});
202
+ let res;
203
+ try {
204
+ res = await fetch(uploadUrl, {
205
+ method: "POST",
206
+ headers: { "Content-Type": contentType },
207
+ body: bytes,
208
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
209
+ });
210
+ } catch (e) {
211
+ const message = e?.name === "TimeoutError"
212
+ ? `upload exceeded ${Math.round(UPLOAD_TIMEOUT_MS / 1000)}s`
213
+ : `upload failed: ${e?.message || String(e)}`;
214
+ throw new Error(message);
215
+ }
216
+ if (!res.ok) throw new Error(`upload failed: ${res.status} ${await res.text()}`);
217
+ const json = await res.json();
218
+ return json.storageId;
219
+ }
220
+
185
221
  function splitAgents(value) {
186
222
  return String(value || "").split(/[,\s]+/).filter(Boolean);
187
223
  }
@@ -253,7 +289,8 @@ export async function login(opts) {
253
289
  done = true;
254
290
  clearTimeout(timer);
255
291
  server.close();
256
- err ? reject(err) : resolve();
292
+ if (err) reject(err);
293
+ else resolve();
257
294
  };
258
295
 
259
296
  server = createServer((req, res) => {