@hypersocial/decks-cli 0.1.6 → 0.1.9

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.6",
3
+ "version": "0.1.9",
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,12 +219,16 @@ 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]
230
+ gen-image "<prompt>" [--transparent] [--image <url>] [--size <s>] [--quality <q>] [--count <n>] [--slug <deck>] [--download <file>]
231
+ image-job <jobId> [--json]
161
232
 
162
233
  global:
163
234
  --json
@@ -168,6 +239,65 @@ global:
168
239
  `);
169
240
  }
170
241
 
242
+ async function genImage(pos, opts) {
243
+ const prompt = pos.join(" ").trim() || opts.prompt;
244
+ if (!prompt) {
245
+ throw new Error(
246
+ 'usage: gen-image "<prompt>" [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--slug <deck>] [--name <name>] [--download <file>]',
247
+ );
248
+ }
249
+ let out = await ops.generateImage(opts, {
250
+ prompt,
251
+ image: opts.image,
252
+ transparent: opts.transparent,
253
+ size: opts.size,
254
+ quality: opts.quality,
255
+ count: opts.count,
256
+ format: opts.format,
257
+ slug: opts.slug,
258
+ name: opts.name,
259
+ });
260
+ // Slow requests (high/auto quality, transparent, count>1) come back as a job id;
261
+ // poll it so gen-image stays one blocking command. Progress prints to stderr so
262
+ // it doesn't pollute the URL output (or --json).
263
+ if (out.jobId) {
264
+ process.stderr.write(`queued ${out.count} image(s) — job ${out.jobId}\n`);
265
+ out = await ops.pollImageJob(opts, out.jobId, {
266
+ onProgress: (job) => process.stderr.write(` ${job.progress} (${job.status})…\r`),
267
+ });
268
+ process.stderr.write("\n");
269
+ }
270
+ const images = out.images || [];
271
+ if (opts.download && images.length) {
272
+ for (let i = 0; i < images.length; i++) {
273
+ if (!images[i].url) continue;
274
+ const file = images.length > 1 ? opts.download.replace(/(\.[^./]+)?$/, `-${i + 1}$1`) : opts.download;
275
+ const res = await fetch(images[i].url);
276
+ writeFileSync(file, Buffer.from(await res.arrayBuffer()));
277
+ }
278
+ }
279
+ if (opts.json) {
280
+ console.log(JSON.stringify(out, null, 2));
281
+ } else {
282
+ for (const im of images) if (im.url) console.log(im.url);
283
+ if (opts.download) console.log(`\nSaved ${images.length} file(s) to ${opts.download}`);
284
+ }
285
+ }
286
+
287
+ async function imageJob(pos, opts) {
288
+ const [jobId] = pos;
289
+ if (!jobId) throw new Error("usage: image-job <jobId>");
290
+ const job = await ops.checkImageJob(opts, jobId);
291
+ if (opts.json) {
292
+ console.log(JSON.stringify(job, null, 2));
293
+ return;
294
+ }
295
+ console.log(`status: ${job.status}`);
296
+ console.log(`progress: ${job.progress}`);
297
+ if (job.error) console.log(`error: ${job.error}`);
298
+ for (const im of job.results || []) if (im.url) console.log(im.url);
299
+ }
300
+
171
301
  export async function runCli(argv = process.argv.slice(2)) {
172
302
  const { pos, opts } = parse(argv);
173
303
  if (opts.version) {
@@ -188,6 +318,7 @@ export async function runCli(argv = process.argv.slice(2)) {
188
318
  "deck list": () => deckList(opts),
189
319
  "deck stats": () => deckStats(rest, opts),
190
320
  "deck open": () => deckOpen(rest, opts),
321
+ "deck rm": () => deckRemove(rest, opts),
191
322
  "share new": () => shareNew(rest, opts),
192
323
  "share list": () => shareList(rest, opts),
193
324
  "share on": () => shareToggle(rest, opts, true),
@@ -196,9 +327,13 @@ export async function runCli(argv = process.argv.slice(2)) {
196
327
  "share url": () => shareUrl(rest, opts),
197
328
  "recipient archive": () => recipientArchive(rest, opts, true),
198
329
  "recipient unarchive": () => recipientArchive(rest, opts, false),
330
+ import: () => importDeck(pos.slice(1), opts),
331
+ "import-design": () => importDesignDeck(pos.slice(1), opts),
199
332
  "skill install": () => installHyperdecksSkill(opts, rest),
200
333
  "skills install": () => installHyperdecksSkill(opts, rest),
201
334
  activity: () => activity(pos.slice(1), opts),
335
+ "gen-image": () => genImage(pos.slice(1), opts),
336
+ "image-job": () => imageJob(pos.slice(1), opts),
202
337
  };
203
338
 
204
339
  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,56 @@ 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
+ // Returns { images } for fast requests, or { jobId, status, count } for slow ones
55
+ // (high/auto quality, transparent, count>1). The MCP tool returns this as-is so it
56
+ // never blocks; the CLI polls with pollImageJob to stay one-command (see commands).
57
+ return (await client(opts)).action(api.imagegen.generateImage, args);
58
+ }
59
+
60
+ // Read one async image job's status/results. Shared by the MCP check_image_job tool
61
+ // and the CLI poller.
62
+ export async function checkImageJob(opts, jobId) {
63
+ if (!jobId) throw new Error("jobId is required");
64
+ return (await client(opts)).query(api.imagegen.checkImageJob, { jobId: String(jobId) });
65
+ }
66
+
67
+ // Poll an async image job until it finishes. Returns { images, slug } on success
68
+ // (results are the same shape as the inline { images }); throws on job error or
69
+ // timeout. `onProgress(job)` is called each tick. Used by the CLI so gen-image stays
70
+ // a single blocking command even when it routes to a background job.
71
+ export async function pollImageJob(opts, jobId, { intervalMs = 3000, timeoutMs = 900_000, onProgress } = {}) {
72
+ const c = await client(opts);
73
+ const start = Date.now();
74
+ for (;;) {
75
+ const job = await c.query(api.imagegen.checkImageJob, { jobId: String(jobId) });
76
+ if (job.status === "done") return { images: job.results || [], slug: job.slug };
77
+ if (job.status === "error") {
78
+ // A batch isn't atomic: earlier images may have been generated + saved before
79
+ // the failure. Surface that so the user knows assets/spend exist despite "error".
80
+ const ok = (job.results || []).filter((r) => r.url).length;
81
+ throw new Error(`${job.error || "image job failed"}${ok ? ` (${ok} image(s) completed before the failure)` : ""}`);
82
+ }
83
+ if (Date.now() - start > timeoutMs) throw new Error(`image job ${jobId} timed out after ${Math.round(timeoutMs / 1000)}s`);
84
+ if (onProgress) onProgress(job);
85
+ await new Promise((r) => setTimeout(r, intervalMs));
86
+ }
87
+ }
88
+
27
89
  export async function listShares(opts, slug) {
28
90
  if (!slug) throw new Error("slug is required");
29
91
  const rows = await (await client(opts)).query(api.shares.listForDeck, { deckSlug: slug });
@@ -73,8 +135,387 @@ export async function removeShare(opts, token) {
73
135
  return { token, removed: true };
74
136
  }
75
137
 
138
+ export async function removeDeck(opts, slug) {
139
+ if (!slug) throw new Error("slug is required");
140
+ // Cascade delete (deck + its assets/shares/sessions + stored HTML) runs in
141
+ // decks:remove; this just authorizes as the owner and fires it.
142
+ await (await client(opts)).mutation(api.decks.remove, { slug });
143
+ return { slug, removed: true };
144
+ }
145
+
76
146
  export async function setRecipientArchived(opts, recipient, archived) {
77
147
  if (!recipient) throw new Error("recipient is required");
78
148
  await (await client(opts)).mutation(api.activity.setArchived, { recipient, archived });
79
149
  return { recipient, archived: !!archived };
80
150
  }
151
+
152
+ // ── Deck import ─────────────────────────────────────────────────────────────
153
+ // IMPORTANT: `app/api/decks/import/route.js` implements the SAME pipeline for the
154
+ // dashboard's browser/multipart path (Clerk session client + per-asset progress
155
+ // logging). The two are intentionally NOT yet merged: importing this module into
156
+ // the Next route would pull the whole CLI runtime (OAuth login flow, node:http
157
+ // server, child_process) into that serverless bundle. `importDeckFromBytes`
158
+ // accepts an `adapter` so a future pass can extract a runtime-free shared core
159
+ // and have both call it. Until then, keep the two pipelines in sync by hand.
160
+
161
+ const IMPORT_STATUSES = new Set(["draft", "live", "archived"]);
162
+ const DEFAULT_IMPORT_CONCURRENCY = 3;
163
+ const URL_FETCH_TIMEOUT_MS = 120_000;
164
+
165
+ const deckHasHtml = (deck) => !!(deck?.hasHtml || deck?.html || deck?.htmlUrl || deck?.htmlStorageId);
166
+
167
+ // Bounded-concurrency map that fails fast and surfaces the first error. Used so a
168
+ // media-heavy import doesn't upload assets one-at-a-time.
169
+ async function mapWithConcurrency(items, limit, worker) {
170
+ if (!items.length) return;
171
+ let next = 0;
172
+ let failure = null;
173
+ const size = Math.max(1, Math.min(limit || 1, items.length));
174
+ const runners = Array.from({ length: size }, async () => {
175
+ while (!failure) {
176
+ const index = next;
177
+ next += 1;
178
+ if (index >= items.length) return;
179
+ try {
180
+ await worker(items[index], index);
181
+ } catch (e) {
182
+ failure = e;
183
+ }
184
+ }
185
+ });
186
+ await Promise.all(runners);
187
+ if (failure) throw failure;
188
+ }
189
+
190
+ // Import a Claude Design export (ZIP or single HTML) into Hyperdecks: upload its
191
+ // assets to Convex storage, rewrite refs to the stored URLs, store the HTML, and
192
+ // upsert the deck. Rollback is owned here (single layer): a deck created for this
193
+ // import — or an existing record that had no HTML yet — is removed on failure; an
194
+ // existing deck that already had valid HTML is left untouched.
195
+ //
196
+ // `adapter` lets a caller inject its own Convex client / upload helper / progress
197
+ // sink (the dashboard route would pass its Clerk session); defaults use the CLI
198
+ // runtime so CLI + MCP share this exact path.
199
+ export async function importDeckFromBytes(opts = {}, fields = {}, adapter = {}) {
200
+ const { bytes, filename = "deck.html", htmlFile } = fields;
201
+ if (!bytes) throw new Error("import needs file bytes");
202
+
203
+ const parsed = prepareDeckImport({ bytes, filename, htmlFile });
204
+
205
+ // Derive the slug from an explicit slug, else the caller-provided title, else the
206
+ // document <title>, and only fall back to the filename last — so a deck imported
207
+ // as "Strudel" becomes /strudel, not /bundled-page (the bundle's generic filename).
208
+ const slug = slugify(fields.slug || fields.title || parsed.title || titleFromFilename(filename));
209
+ if (!slug) throw new Error("import needs --slug <slug>");
210
+ const title = String(fields.title || parsed.title || titleFromFilename(filename) || slug);
211
+ const status = String(fields.status || "draft");
212
+ if (!IMPORT_STATUSES.has(status)) {
213
+ throw new Error("status must be draft, live, or archived");
214
+ }
215
+
216
+ const c = adapter.client || (await client(opts));
217
+ const upload = adapter.uploadBytes || runtimeUploadBytes;
218
+ const concurrency = adapter.concurrency || DEFAULT_IMPORT_CONCURRENCY;
219
+ const onStage = typeof adapter.onStage === "function" ? adapter.onStage : () => {};
220
+
221
+ onStage("checking existing deck");
222
+ const existingDeck = await c.query(api.decks.get, { slug });
223
+ const shouldRollbackDeck = !existingDeck;
224
+ const shouldRollbackPartial = !!existingDeck && !deckHasHtml(existingDeck);
225
+ let deckCreated = false;
226
+
227
+ const uploadedByName = new Map();
228
+ const saveDeckAsset = async (asset, data = asset.data, contentType = asset.contentType) => {
229
+ const storageId = await upload(c, data, contentType, asset.name);
230
+ const saved = await c.mutation(api.assets.saveAsset, {
231
+ name: asset.name,
232
+ storageId,
233
+ contentType,
234
+ deckSlug: slug,
235
+ });
236
+ uploadedByName.set(asset.name, saved.url);
237
+ return saved.url;
238
+ };
239
+
240
+ try {
241
+ if (shouldRollbackDeck) {
242
+ onStage("creating deck");
243
+ // assets:saveAsset checks deck ownership, so the deck must exist first.
244
+ await c.mutation(api.decks.upsert, { slug, title, status: "draft" });
245
+ deckCreated = true;
246
+ }
247
+
248
+ // Phase 1: upload every asset and fully populate uploadedByName.
249
+ await mapWithConcurrency(parsed.assets, concurrency, async (asset, index) => {
250
+ onStage(`uploading asset ${index + 1}/${parsed.assets.length}: ${asset.name}`);
251
+ await saveDeckAsset(asset);
252
+ });
253
+
254
+ // Phase 2 (hard barrier after phase 1): rewrite text assets against the now-
255
+ // complete URL map and re-upload them under the same name. Must not interleave
256
+ // with phase 1 or refs could resolve against a half-built map.
257
+ const textAssets = parsed.assets.filter(isTextAsset);
258
+ await mapWithConcurrency(textAssets, concurrency, async (asset, index) => {
259
+ onStage(`rewriting asset ${index + 1}/${textAssets.length}: ${asset.name}`);
260
+ const text = asset.contentType === "text/css"
261
+ ? rewriteCssAssetUrls(textFromBytes(asset.data), asset.sourcePath, parsed.assets, uploadedByName)
262
+ : rewriteTextAssetUrls(textFromBytes(asset.data), asset.sourcePath, parsed.assets, uploadedByName);
263
+ await saveDeckAsset(asset, bytesFromText(text), asset.contentType);
264
+ });
265
+
266
+ onStage("storing deck HTML");
267
+ const html = rewriteHtmlAssetUrls(parsed.html, parsed.htmlEntry, parsed.assets, uploadedByName);
268
+ const htmlBytes = bytesFromText(html);
269
+ const htmlStorageId = await upload(c, htmlBytes, "text/html; charset=utf-8", "deck HTML");
270
+ onStage("saving deck");
271
+ await c.mutation(api.decks.upsert, { slug, title, status });
272
+ onStage("saving deck HTML");
273
+ await c.mutation(api.decks.setHtmlStorage, { slug, storageId: htmlStorageId, bytes: htmlBytes.byteLength });
274
+
275
+ const t = target(opts);
276
+ return {
277
+ slug,
278
+ title,
279
+ status,
280
+ assets: parsed.assets.length,
281
+ bytes: htmlBytes.byteLength,
282
+ deployment: t.name,
283
+ // The owner-facing dashboard view (Preview / Stats / Share / Present), not
284
+ // the raw /d/<slug> storage redirect.
285
+ deckUrl: `${t.brokerBase}/dashboard/d/${slug}`,
286
+ };
287
+ } catch (e) {
288
+ if ((shouldRollbackDeck && deckCreated) || shouldRollbackPartial) {
289
+ await c.mutation(api.decks.remove, { slug }).catch(() => {});
290
+ }
291
+ throw e;
292
+ }
293
+ }
294
+
295
+ // SSRF guard: only http(s), and refuse obvious loopback / private / link-local
296
+ // targets. This blocks literal hosts/IPs; it does NOT resolve DNS, so a public
297
+ // hostname that resolves to a private address is not caught here (documented
298
+ // limitation — acceptable since the fetch runs on the caller's own machine with
299
+ // their credentials).
300
+ function isBlockedHost(host) {
301
+ if (!host) return true;
302
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
303
+ if (host === "::1") return true;
304
+ if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true; // link-local + ULA
305
+ const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\d{1,3}$/);
306
+ if (m) {
307
+ const a = Number(m[1]);
308
+ const b = Number(m[2]);
309
+ if (a === 0 || a === 127 || a === 10) return true; // 0/8, loopback, RFC1918 10/8
310
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
311
+ if (a === 192 && b === 168) return true; // 192.168/16
312
+ if (a === 169 && b === 254) return true; // link-local + metadata 169.254.169.254
313
+ }
314
+ return false;
315
+ }
316
+
317
+ function assertImportableUrl(rawUrl) {
318
+ let url;
319
+ try {
320
+ url = new URL(rawUrl);
321
+ } catch {
322
+ throw new Error(`invalid url: ${rawUrl}`);
323
+ }
324
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
325
+ throw new Error(`url must be http(s); got "${url.protocol.replace(/:$/, "")}"`);
326
+ }
327
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
328
+ if (isBlockedHost(host)) {
329
+ throw new Error(`refusing to fetch private/loopback host: ${url.hostname}`);
330
+ }
331
+ return url;
332
+ }
333
+
334
+ function filenameFromResponse(url, res) {
335
+ const cd = res.headers.get("content-disposition") || "";
336
+ const m = cd.match(/filename\*?=(?:UTF-8'')?["']?([^"';]+)/i);
337
+ if (m && m[1]) {
338
+ try {
339
+ return decodeURIComponent(m[1].trim());
340
+ } catch {
341
+ return m[1].trim();
342
+ }
343
+ }
344
+ const base = url.pathname.split("/").filter(Boolean).pop();
345
+ if (base && /\.[a-z0-9]+$/i.test(base)) return base;
346
+ const ct = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
347
+ if (ct.includes("zip")) return "deck.zip";
348
+ if (ct.includes("html")) return "deck.html";
349
+ return "deck.zip";
350
+ }
351
+
352
+ // Fetch a direct Claude Design HTML/ZIP export URL server-side (no 256 KiB /
353
+ // 10 MB caps apply here) and import it via importDeckFromBytes. `adapter` is
354
+ // forwarded so tests can inject a fake client/upload helper.
355
+ export async function importDeckFromUrl(opts = {}, fields = {}, adapter = {}) {
356
+ if (!fields.url) throw new Error("import needs a url");
357
+ const url = assertImportableUrl(fields.url);
358
+
359
+ let res;
360
+ try {
361
+ // Browser-ish headers: some hosts (e.g. Cloudflare-fronted claudeusercontent.com)
362
+ // bot-challenge requests with a default Node/undici user-agent. This helps with
363
+ // simple UA rules but will NOT pass a managed JS challenge from a datacenter IP.
364
+ res = await fetch(url, {
365
+ redirect: "follow",
366
+ headers: {
367
+ "user-agent":
368
+ "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",
369
+ accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
370
+ "accept-language": "en-US,en;q=0.9",
371
+ },
372
+ signal: AbortSignal.timeout(URL_FETCH_TIMEOUT_MS),
373
+ });
374
+ } catch (e) {
375
+ if (e?.name === "TimeoutError") {
376
+ throw new Error(`fetch timed out after ${URL_FETCH_TIMEOUT_MS / 1000}s: ${url}`);
377
+ }
378
+ throw new Error(`could not fetch ${url}: ${e?.message || e}`);
379
+ }
380
+
381
+ // A redirect could land on a private host even when the first URL was public.
382
+ if (res.url && res.url !== String(url)) assertImportableUrl(res.url);
383
+
384
+ if (!res.ok) {
385
+ const body = (await res.text().catch(() => "")).slice(0, 200);
386
+ throw new Error(`fetch failed (${res.status} ${res.statusText || ""}) for ${url}${body ? `: ${body}` : ""}`);
387
+ }
388
+
389
+ const bytes = new Uint8Array(await res.arrayBuffer());
390
+ const filename = fields.filename || filenameFromResponse(url, res);
391
+ return importDeckFromBytes(opts, { ...fields, bytes, filename }, adapter);
392
+ }
393
+
394
+ // ── Import straight from a Claude Design project ─────────────────────────────
395
+ // Claude Design serves every project file UNCAPPED from a per-project CDN host:
396
+ // https://<projectId>.claudeusercontent.com/v1/design/projects/<projectId>/serve/<path>?t=<token>
397
+ // The `?t=` token is project-scoped (one token fetches every file) and the only
398
+ // credential needed — no cookie. This is the path the design preview itself uses
399
+ // for images, and unlike the MCP `get_file` (256 KiB cap) it streams full bytes,
400
+ // so it's how we pull a media-heavy deck without an export.
401
+ //
402
+ // Caveat: this is an undocumented internal endpoint and may change. The supported
403
+ // alternative remains an Export ZIP fed to importDeckFromBytes.
404
+ const DESIGN_HOST_SUFFIX = ".claudeusercontent.com";
405
+ const MAX_DESIGN_FILES = 500;
406
+
407
+ function designServeUrl(projectId, token, path) {
408
+ const clean = String(path).replace(/^\/+/, "");
409
+ const encoded = clean.split("/").map(encodeURIComponent).join("/");
410
+ return `https://${projectId}${DESIGN_HOST_SUFFIX}/v1/design/projects/${projectId}/serve/${encoded}?t=${encodeURIComponent(token)}`;
411
+ }
412
+
413
+ // Pull every local asset path a deck file references (HTML or CSS): the
414
+ // ext-resource-dependency manifest, src/href/poster, srcset, <script>/<link>,
415
+ // and CSS url(). Mirrors the importer's rewrite surface so we fetch the same set
416
+ // it will later rewrite. External/data/blob refs are skipped.
417
+ function extractDesignRefs(text) {
418
+ const out = new Set();
419
+ const add = (raw) => {
420
+ const v = String(raw || "").trim();
421
+ if (!v || /^(?:https?:|data:|blob:|mailto:|tel:|javascript:|#|\/\/)/i.test(v)) return;
422
+ const clean = v.split("#")[0].split("?")[0].replace(/^\.?\//, "");
423
+ if (clean) out.add(clean);
424
+ };
425
+ for (const m of text.matchAll(/ext-resource-dependency"\s+content="([^"]+)"/gi)) add(m[1]);
426
+ for (const m of text.matchAll(/\b(?:src|href|poster)\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
427
+ for (const m of text.matchAll(/\bsrcset\s*=\s*["']([^"']+)["']/gi)) {
428
+ for (const part of m[1].split(",")) add(part.trim().split(/\s+/)[0]);
429
+ }
430
+ for (const m of text.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/gi)) add(m[1]);
431
+ return [...out];
432
+ }
433
+
434
+ function looksTextual(contentType, path) {
435
+ const ct = String(contentType || "").toLowerCase();
436
+ if (/text\/|javascript|json|svg|css/.test(ct)) return true;
437
+ return /\.(html?|css|m?js|json|svg)$/i.test(path);
438
+ }
439
+
440
+ export async function importDeckFromDesign(opts = {}, fields = {}, adapter = {}) {
441
+ const project = fields.project;
442
+ const token = fields.token;
443
+ if (!project) throw new Error("import-design needs --project <id>");
444
+ if (!token) throw new Error("import-design needs --token <serve-token>");
445
+ const entry = String(fields.entry || "index.html").replace(/^\/+/, "");
446
+ const concurrency = adapter.concurrency || DEFAULT_IMPORT_CONCURRENCY;
447
+ const log = typeof adapter.onStage === "function" ? adapter.onStage : () => {};
448
+
449
+ const fetchServe = async (path) => {
450
+ const res = await fetch(designServeUrl(project, token, path), {
451
+ signal: AbortSignal.timeout(URL_FETCH_TIMEOUT_MS),
452
+ });
453
+ if (!res.ok) return { path, ok: false, status: res.status };
454
+ const ct = res.headers.get("content-type") || "";
455
+ return { path, ok: true, bytes: new Uint8Array(await res.arrayBuffer()), contentType: ct };
456
+ };
457
+
458
+ // The entry HTML is required — its refs seed the crawl.
459
+ log(`fetching ${entry}`);
460
+ const root = await fetchServe(entry);
461
+ if (!root.ok) {
462
+ throw new Error(
463
+ `could not fetch entry "${entry}" (status ${root.status}). Pass --entry <file.html> and check --token hasn't expired.`,
464
+ );
465
+ }
466
+
467
+ // Breadth-first over refs: HTML/CSS/JS we fetch get re-scanned for nested refs
468
+ // (e.g. CSS url(), a deck script that pulls more assets). Binary files are leaves.
469
+ const files = new Map([[entry, root.bytes]]);
470
+ const queue = extractDesignRefs(textFromBytes(root.bytes));
471
+ const seen = new Set([entry, ...queue]);
472
+ const missing = [];
473
+
474
+ while (queue.length && files.size < MAX_DESIGN_FILES) {
475
+ const batch = queue.splice(0, concurrency);
476
+ const results = await Promise.all(
477
+ batch.map(async (path) => {
478
+ log(`fetching ${path}`);
479
+ try {
480
+ return await fetchServe(path);
481
+ } catch (e) {
482
+ return { path, ok: false, status: e?.message || "fetch error" };
483
+ }
484
+ }),
485
+ );
486
+ for (const r of results) {
487
+ if (!r.ok) {
488
+ missing.push(`${r.path} (${r.status})`);
489
+ continue;
490
+ }
491
+ files.set(r.path, r.bytes);
492
+ if (looksTextual(r.contentType, r.path)) {
493
+ for (const ref of extractDesignRefs(textFromBytes(r.bytes))) {
494
+ if (!seen.has(ref)) {
495
+ seen.add(ref);
496
+ queue.push(ref);
497
+ }
498
+ }
499
+ }
500
+ }
501
+ }
502
+
503
+ // Zip the crawl and hand it to the shared importer (asset upload + ref rewrite).
504
+ const zipInput = {};
505
+ for (const [path, bytes] of files) zipInput[path] = bytes;
506
+ const zip = zipSync(zipInput);
507
+
508
+ const result = await importDeckFromBytes(
509
+ opts,
510
+ {
511
+ bytes: zip,
512
+ filename: `${slugify(fields.slug || titleFromFilename(entry)) || "deck"}.zip`,
513
+ htmlFile: entry,
514
+ slug: fields.slug,
515
+ title: fields.title,
516
+ status: fields.status,
517
+ },
518
+ adapter,
519
+ );
520
+ return { ...result, fetched: files.size, missing };
521
+ }
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.6";
11
+ export const VERSION = "0.1.9";
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,13 @@ 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
+ checkImageJob: makeFunctionReference("imagegen:checkImageJob"),
51
+ },
39
52
  };
40
53
 
41
- const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "help", "version", "all-agents", "global", "project"]);
54
+ const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "help", "version", "all-agents", "global", "project", "transparent"]);
42
55
 
43
56
  export const CRED_DIR = join(homedir(), ".config", "hypersocial-decks");
44
57
  export const CRED_FILE = join(CRED_DIR, "credentials.json");
@@ -158,6 +171,9 @@ async function jwtFor(brokerBase, refreshToken) {
158
171
  }
159
172
 
160
173
  export async function client(opts = {}) {
174
+ // Allow a pre-authenticated client to be injected (e.g. the remote MCP OAuth
175
+ // route, which already holds a Convex client authed as the requesting user).
176
+ if (opts.client) return opts.client;
161
177
  const t = target(opts);
162
178
  const c = new ConvexHttpClient(t.convexUrl);
163
179
  const admin = opts.admin || process.env.HSDECKS_ADMIN === "1";
@@ -182,6 +198,27 @@ export function openBrowser(url) {
182
198
  execFile(cmd, args, () => {});
183
199
  }
184
200
 
201
+ export async function uploadBytes(c, bytes, contentType) {
202
+ const uploadUrl = await c.mutation(api.assets.generateUploadUrl, {});
203
+ let res;
204
+ try {
205
+ res = await fetch(uploadUrl, {
206
+ method: "POST",
207
+ headers: { "Content-Type": contentType },
208
+ body: bytes,
209
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
210
+ });
211
+ } catch (e) {
212
+ const message = e?.name === "TimeoutError"
213
+ ? `upload exceeded ${Math.round(UPLOAD_TIMEOUT_MS / 1000)}s`
214
+ : `upload failed: ${e?.message || String(e)}`;
215
+ throw new Error(message);
216
+ }
217
+ if (!res.ok) throw new Error(`upload failed: ${res.status} ${await res.text()}`);
218
+ const json = await res.json();
219
+ return json.storageId;
220
+ }
221
+
185
222
  function splitAgents(value) {
186
223
  return String(value || "").split(/[,\s]+/).filter(Boolean);
187
224
  }