@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 +36 -0
- package/package.json +5 -2
- package/src/commands.mjs +137 -2
- package/src/importer.mjs +330 -0
- package/src/ops.mjs +443 -2
- package/src/runtime.mjs +39 -2
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.
|
|
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 {
|
|
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];
|
package/src/importer.mjs
ADDED
|
@@ -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 {
|
|
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.
|
|
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
|
}
|