htmlship 0.1.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/dist/cli.js +649 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,9 +16,21 @@ npx htmlship delete <slug>
|
|
|
16
16
|
npx htmlship list-mine
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
## Deploy built apps
|
|
20
|
+
|
|
21
|
+
Build a modern frontend project (React/Vite/etc.) and publish the compiled app as one self-contained page:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx htmlship deploy ./my-app # detect build script, build, inline, publish
|
|
25
|
+
npx htmlship deploy --dry-run # build + inline without publishing
|
|
26
|
+
npx htmlship deploy --build-cmd "vite build" --out dist
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The build runs **locally on your machine** (npm/pnpm/yarn/bun, auto-detected) — never on the server. The output is inlined into a single HTML file (≤ 10 MB) and published with relaxed sandboxing, so the app's JavaScript runs in an isolated, opaque origin (no cookies, no same-origin access, no network egress, no `eval`). The same flow is available to agents through the `deploy_project` MCP tool.
|
|
30
|
+
|
|
19
31
|
## MCP server
|
|
20
32
|
|
|
21
|
-
`htmlship mcp` starts a stdio MCP server with
|
|
33
|
+
`htmlship mcp` starts a stdio MCP server with four tools: `publish_html` (with optional `password`), `deploy_project` (build a local project and publish the compiled app), `fetch_html`, `update_html`.
|
|
22
34
|
|
|
23
35
|
### Claude Desktop / Claude Code / Cursor
|
|
24
36
|
|
package/dist/cli.js
CHANGED
|
@@ -51,22 +51,418 @@ var init_errors = __esm({
|
|
|
51
51
|
}
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
// src/build.ts
|
|
55
|
+
import { spawnSync } from "child_process";
|
|
56
|
+
import {
|
|
57
|
+
existsSync,
|
|
58
|
+
readdirSync,
|
|
59
|
+
readFileSync,
|
|
60
|
+
renameSync,
|
|
61
|
+
statSync,
|
|
62
|
+
unlinkSync,
|
|
63
|
+
writeFileSync
|
|
64
|
+
} from "fs";
|
|
65
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "path";
|
|
66
|
+
function detectPackageManager(dir) {
|
|
67
|
+
for (const [file, pm] of LOCKFILES) {
|
|
68
|
+
if (existsSync(join(dir, file))) return pm;
|
|
69
|
+
}
|
|
70
|
+
return "npm";
|
|
71
|
+
}
|
|
72
|
+
function detectProject(dir, buildCmdOverride) {
|
|
73
|
+
const pkgPath = join(dir, "package.json");
|
|
74
|
+
if (!existsSync(pkgPath)) {
|
|
75
|
+
throw new HTMLShipError(`no package.json found in ${dir}`);
|
|
76
|
+
}
|
|
77
|
+
let scripts = {};
|
|
78
|
+
try {
|
|
79
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
80
|
+
if (pkg && typeof pkg === "object" && "scripts" in pkg) {
|
|
81
|
+
const s = pkg.scripts;
|
|
82
|
+
if (s && typeof s === "object") scripts = s;
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
throw new HTMLShipError(`failed to read package.json: ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
const pm = detectPackageManager(dir);
|
|
88
|
+
if (buildCmdOverride) {
|
|
89
|
+
return { dir, buildScript: "", packageManager: pm };
|
|
90
|
+
}
|
|
91
|
+
const buildScript = scripts["build"] ? "build" : scripts["build:prod"] ? "build:prod" : null;
|
|
92
|
+
if (!buildScript) {
|
|
93
|
+
throw new HTMLShipError(
|
|
94
|
+
'no "build" or "build:prod" script in package.json (use --build-cmd to specify one)'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return { dir, buildScript, packageManager: pm };
|
|
98
|
+
}
|
|
99
|
+
function buildCommand(pm, script) {
|
|
100
|
+
return pm === "yarn" ? `yarn ${script}` : `${pm} run ${script}`;
|
|
101
|
+
}
|
|
102
|
+
function installCommand(pm) {
|
|
103
|
+
return `${pm} install`;
|
|
104
|
+
}
|
|
105
|
+
function runBuild(project, opts = {}) {
|
|
106
|
+
const log = opts.log ?? (() => {
|
|
107
|
+
});
|
|
108
|
+
const timeout = opts.timeoutMs ?? DEFAULT_BUILD_TIMEOUT_MS;
|
|
109
|
+
if (opts.install) {
|
|
110
|
+
exec(installCommand(project.packageManager), project.dir, timeout, log);
|
|
111
|
+
}
|
|
112
|
+
const cmd = opts.buildCmd ?? buildCommand(project.packageManager, project.buildScript);
|
|
113
|
+
exec(cmd, project.dir, timeout, log);
|
|
114
|
+
}
|
|
115
|
+
function exec(command, cwd, timeout, log) {
|
|
116
|
+
log(`$ ${command}`);
|
|
117
|
+
const r = spawnSync(command, { cwd, stdio: "inherit", timeout, shell: true });
|
|
118
|
+
if (r.error) {
|
|
119
|
+
const code = r.error.code;
|
|
120
|
+
if (code === "ENOENT") {
|
|
121
|
+
throw new HTMLShipError(`build failed: \`${command}\` \u2014 command not found`);
|
|
122
|
+
}
|
|
123
|
+
throw new HTMLShipError(`build failed: ${r.error.message}`);
|
|
124
|
+
}
|
|
125
|
+
if (r.signal === "SIGTERM") {
|
|
126
|
+
throw new HTMLShipError(`build timed out after ${Math.round(timeout / 1e3)}s: \`${command}\``);
|
|
127
|
+
}
|
|
128
|
+
if (typeof r.status === "number" && r.status !== 0) {
|
|
129
|
+
throw new HTMLShipError(`build exited with code ${r.status}: \`${command}\``);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function looksLikeNextJs(dir) {
|
|
133
|
+
if (existsSync(join(dir, ".next"))) return true;
|
|
134
|
+
return ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"].some(
|
|
135
|
+
(f) => existsSync(join(dir, f))
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
function noOutputMessage(dir) {
|
|
139
|
+
const base = `no build output found (looked for ${OUTPUT_DIR_CANDIDATES.map((c) => `${c}/`).join(", ")}; use --out to specify)`;
|
|
140
|
+
if (looksLikeNextJs(dir)) {
|
|
141
|
+
return `${base}.
|
|
142
|
+
This looks like a Next.js app: \`next build\` writes a server build to .next/, which is not statically hostable. Add \`output: "export"\` to your next.config to emit a static out/ folder, then re-run. Note: deploy inlines a single-page static app \u2014 server-rendered or multi-route Next.js sites are not supported.`;
|
|
143
|
+
}
|
|
144
|
+
return base;
|
|
145
|
+
}
|
|
146
|
+
function resolveOutputDir(dir, override) {
|
|
147
|
+
const candidates = override ? [override] : OUTPUT_DIR_CANDIDATES;
|
|
148
|
+
for (const c of candidates) {
|
|
149
|
+
const p = isAbsolute(c) ? c : join(dir, c);
|
|
150
|
+
if (existsSync(p) && statSync(p).isDirectory()) return p;
|
|
151
|
+
}
|
|
152
|
+
throw new HTMLShipError(
|
|
153
|
+
override ? `build output dir not found: ${override}` : noOutputMessage(dir)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
function isNextServerBuild(dir) {
|
|
157
|
+
return existsSync(join(dir, "BUILD_ID")) || existsSync(join(dir, "server")) && existsSync(join(dir, "static"));
|
|
158
|
+
}
|
|
159
|
+
function noEntryMessage(outDir, htmls) {
|
|
160
|
+
if (isNextServerBuild(outDir)) {
|
|
161
|
+
return `no static HTML entry in ${outDir} \u2014 this looks like a Next.js server build, not a static site. Set \`output: "export"\` in your next.config (this emits a fully static out/ folder); changing distDir alone only renames the server build. Note: apps that use middleware or server rendering cannot be statically exported.`;
|
|
162
|
+
}
|
|
163
|
+
if (htmls.length > 1) {
|
|
164
|
+
return `no index.html in ${outDir}, but found ${htmls.length} HTML files (${htmls.slice(0, 4).join(", ")}${htmls.length > 4 ? ", \u2026" : ""}). deploy ships a single page \u2014 pass --entry <file> to choose one.`;
|
|
165
|
+
}
|
|
166
|
+
return `entry HTML not found in ${outDir} (looked for index.html); pass --entry <file> to specify it.`;
|
|
167
|
+
}
|
|
168
|
+
function resolveEntry(outDir, entryOverride) {
|
|
169
|
+
if (entryOverride) {
|
|
170
|
+
const p = join(outDir, entryOverride);
|
|
171
|
+
if (existsSync(p)) return p;
|
|
172
|
+
throw new HTMLShipError(`entry HTML not found: ${p}`);
|
|
173
|
+
}
|
|
174
|
+
for (const name of ["index.html", "200.html", "index.htm"]) {
|
|
175
|
+
const p = join(outDir, name);
|
|
176
|
+
if (existsSync(p)) return p;
|
|
177
|
+
}
|
|
178
|
+
let htmls = [];
|
|
179
|
+
try {
|
|
180
|
+
htmls = readdirSync(outDir).filter((f) => f.toLowerCase().endsWith(".html"));
|
|
181
|
+
} catch {
|
|
182
|
+
htmls = [];
|
|
183
|
+
}
|
|
184
|
+
if (htmls.length === 1) return join(outDir, htmls[0]);
|
|
185
|
+
throw new HTMLShipError(noEntryMessage(outDir, htmls));
|
|
186
|
+
}
|
|
187
|
+
function mimeFor(p) {
|
|
188
|
+
return MIME[extname(p).toLowerCase()] ?? "application/octet-stream";
|
|
189
|
+
}
|
|
190
|
+
function isLocalRef(ref) {
|
|
191
|
+
return ref.length > 0 && !/^(https?:)?\/\//i.test(ref) && !ref.startsWith("data:") && !ref.startsWith("#") && !ref.startsWith("mailto:") && !ref.startsWith("blob:");
|
|
192
|
+
}
|
|
193
|
+
function attr(tag, name) {
|
|
194
|
+
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
|
|
195
|
+
if (!m) return null;
|
|
196
|
+
return m[2] ?? m[3] ?? m[4] ?? null;
|
|
197
|
+
}
|
|
198
|
+
function resolveLocal(root, baseDir, ref) {
|
|
199
|
+
let clean = ref.split("#")[0].split("?")[0];
|
|
200
|
+
if (!clean) return null;
|
|
201
|
+
let base = baseDir;
|
|
202
|
+
if (clean.startsWith("/")) {
|
|
203
|
+
base = root;
|
|
204
|
+
clean = clean.replace(/^\/+/, "");
|
|
205
|
+
}
|
|
206
|
+
let abs;
|
|
207
|
+
try {
|
|
208
|
+
abs = resolve(base, decodeURIComponent(clean));
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const rel = relative(root, abs);
|
|
213
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
214
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) return null;
|
|
215
|
+
return abs;
|
|
216
|
+
}
|
|
217
|
+
function dataUri(file) {
|
|
218
|
+
return `data:${mimeFor(file)};base64,${readFileSync(file).toString("base64")}`;
|
|
219
|
+
}
|
|
220
|
+
function inlineCssUrls(root, cssDir, css, warnings) {
|
|
221
|
+
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (whole, _q, ref) => {
|
|
222
|
+
if (!isLocalRef(ref)) return whole;
|
|
223
|
+
const file = resolveLocal(root, cssDir, ref);
|
|
224
|
+
if (!file) {
|
|
225
|
+
warnings.push(`unresolved css asset: ${ref}`);
|
|
226
|
+
return whole;
|
|
227
|
+
}
|
|
228
|
+
return `url(${dataUri(file)})`;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function inlineHtml(root, htmlFile) {
|
|
232
|
+
if (!existsSync(htmlFile)) {
|
|
233
|
+
throw new HTMLShipError(`entry HTML not found: ${htmlFile}`);
|
|
234
|
+
}
|
|
235
|
+
const htmlDir = dirname(htmlFile);
|
|
236
|
+
const warnings = [];
|
|
237
|
+
let html = readFileSync(htmlFile, "utf8");
|
|
238
|
+
const localFile = (ref) => {
|
|
239
|
+
if (!isLocalRef(ref)) return null;
|
|
240
|
+
const file = resolveLocal(root, htmlDir, ref);
|
|
241
|
+
if (!file) {
|
|
242
|
+
warnings.push(`unresolved asset: ${ref}`);
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
return file;
|
|
246
|
+
};
|
|
247
|
+
html = html.replace(/<link\b[^>]*>/gi, (tag) => {
|
|
248
|
+
const rel = attr(tag, "rel")?.toLowerCase() ?? "";
|
|
249
|
+
const href = attr(tag, "href");
|
|
250
|
+
if (!href) return tag;
|
|
251
|
+
if (rel === "stylesheet") {
|
|
252
|
+
const file = localFile(href);
|
|
253
|
+
if (!file) return tag;
|
|
254
|
+
const css = inlineCssUrls(root, dirname(file), readFileSync(file, "utf8"), warnings);
|
|
255
|
+
return `<style>${css}</style>`;
|
|
256
|
+
}
|
|
257
|
+
if (rel === "modulepreload" || rel === "preload" || rel === "prefetch") {
|
|
258
|
+
return isLocalRef(href) ? "" : tag;
|
|
259
|
+
}
|
|
260
|
+
if (rel === "icon" || rel === "shortcut icon" || rel === "apple-touch-icon") {
|
|
261
|
+
const file = localFile(href);
|
|
262
|
+
return file ? tag.replace(href, dataUri(file)) : tag;
|
|
263
|
+
}
|
|
264
|
+
return tag;
|
|
265
|
+
});
|
|
266
|
+
html = html.replace(/<script\b([^>]*)>\s*<\/script>/gi, (tag, attrs) => {
|
|
267
|
+
const src = attr(tag, "src");
|
|
268
|
+
if (!src) return tag;
|
|
269
|
+
const file = localFile(src);
|
|
270
|
+
if (!file) return tag;
|
|
271
|
+
const js = readFileSync(file, "utf8").replace(/<\/script/gi, "<\\/script");
|
|
272
|
+
const typeAttr = /\btype\s*=\s*["']module["']/i.test(attrs) ? ' type="module"' : "";
|
|
273
|
+
return `<script${typeAttr}>${js}</script>`;
|
|
274
|
+
});
|
|
275
|
+
html = html.replace(/<img\b[^>]*>/gi, (tag) => {
|
|
276
|
+
const src = attr(tag, "src");
|
|
277
|
+
if (!src || !isLocalRef(src)) return tag;
|
|
278
|
+
const file = localFile(src);
|
|
279
|
+
return file ? tag.replace(src, dataUri(file)) : tag;
|
|
280
|
+
});
|
|
281
|
+
return { html, warnings, bytes: Buffer.byteLength(html, "utf8") };
|
|
282
|
+
}
|
|
283
|
+
function formatBytes(n) {
|
|
284
|
+
if (n < 1024) return `${n} B`;
|
|
285
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
286
|
+
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
|
287
|
+
}
|
|
288
|
+
function isNextProject(dir) {
|
|
289
|
+
if (NEXT_CONFIGS.some((f) => existsSync(join(dir, f)))) return true;
|
|
290
|
+
try {
|
|
291
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
292
|
+
return Boolean(pkg.dependencies?.["next"] || pkg.devDependencies?.["next"]);
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function injectNextBase(dir) {
|
|
298
|
+
const existing = NEXT_CONFIGS.find((f) => existsSync(join(dir, f)));
|
|
299
|
+
if (existing && existing.endsWith(".ts")) {
|
|
300
|
+
throw new HTMLShipError(
|
|
301
|
+
`Next .ts config isn't auto-configured yet. Temporarily set basePath and assetPrefix to "${SITE_BASE_PLACEHOLDER}" and output: "export" in next.config.ts, or convert it to next.config.mjs, then re-run.`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const wrapper = join(dir, "next.config.mjs");
|
|
305
|
+
const base = SITE_BASE_PLACEHOLDER;
|
|
306
|
+
if (!existing) {
|
|
307
|
+
writeFileSync(
|
|
308
|
+
wrapper,
|
|
309
|
+
`export default { output: 'export', basePath: '${base}', assetPrefix: '${base}', images: { unoptimized: true }, trailingSlash: true };
|
|
310
|
+
`
|
|
311
|
+
);
|
|
312
|
+
return () => {
|
|
313
|
+
try {
|
|
314
|
+
unlinkSync(wrapper);
|
|
315
|
+
} catch {
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const ext = extname(existing);
|
|
320
|
+
const backupName = `next.config.__hsorig__${ext}`;
|
|
321
|
+
renameSync(join(dir, existing), join(dir, backupName));
|
|
322
|
+
writeFileSync(
|
|
323
|
+
wrapper,
|
|
324
|
+
`import orig from './${backupName}';
|
|
325
|
+
const base = '${base}';
|
|
326
|
+
const over = { output: 'export', basePath: base, assetPrefix: base, images: { ...(typeof orig === 'object' && orig ? orig.images : {}), unoptimized: true }, trailingSlash: true };
|
|
327
|
+
export default typeof orig === 'function' ? ((...a) => ({ ...orig(...a), ...over })) : { ...orig, ...over };
|
|
328
|
+
`
|
|
329
|
+
);
|
|
330
|
+
return () => {
|
|
331
|
+
try {
|
|
332
|
+
unlinkSync(wrapper);
|
|
333
|
+
} catch {
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
renameSync(join(dir, backupName), join(dir, existing));
|
|
337
|
+
} catch {
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function packageSite(outDir, maxBytes = 10 * 1024 * 1024) {
|
|
342
|
+
const files = [];
|
|
343
|
+
let total = 0;
|
|
344
|
+
const walk = (d) => {
|
|
345
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
346
|
+
const full = join(d, e.name);
|
|
347
|
+
if (e.isDirectory()) walk(full);
|
|
348
|
+
else if (e.isFile()) {
|
|
349
|
+
const buf = readFileSync(full);
|
|
350
|
+
total += buf.length;
|
|
351
|
+
if (total > maxBytes) {
|
|
352
|
+
throw new HTMLShipError(`site exceeds the ${maxBytes / (1024 * 1024)} MB limit`);
|
|
353
|
+
}
|
|
354
|
+
files.push({ path: relative(outDir, full).split(sep).join("/"), content: buf.toString("base64") });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
walk(outDir);
|
|
359
|
+
return { files, bytes: total };
|
|
360
|
+
}
|
|
361
|
+
function buildAndPackageSite(dir, opts = {}) {
|
|
362
|
+
const log = opts.log ?? (() => {
|
|
363
|
+
});
|
|
364
|
+
const project = detectProject(dir, opts.buildCmd);
|
|
365
|
+
const next = !opts.buildCmd && isNextProject(dir);
|
|
366
|
+
log(`project: ${dir}`);
|
|
367
|
+
log(`pkg mgr: ${project.packageManager}`);
|
|
368
|
+
log(`framework: ${next ? "next.js (static export)" : "multi-file"}`);
|
|
369
|
+
const cleanup = next ? injectNextBase(dir) : () => {
|
|
370
|
+
};
|
|
371
|
+
try {
|
|
372
|
+
if (next) log(`base: ${SITE_BASE_PLACEHOLDER} (injected for path hosting)`);
|
|
373
|
+
runBuild(project, {
|
|
374
|
+
install: opts.install,
|
|
375
|
+
buildCmd: opts.buildCmd,
|
|
376
|
+
timeoutMs: opts.timeoutMs,
|
|
377
|
+
log
|
|
378
|
+
});
|
|
379
|
+
} finally {
|
|
380
|
+
cleanup();
|
|
381
|
+
}
|
|
382
|
+
const outDir = resolveOutputDir(dir, opts.out);
|
|
383
|
+
const entryAbs = resolveEntry(outDir, opts.entry);
|
|
384
|
+
const entry = relative(outDir, entryAbs).split(sep).join("/");
|
|
385
|
+
const { files, bytes } = packageSite(outDir);
|
|
386
|
+
log(`output: ${outDir}`);
|
|
387
|
+
log(`packaged: ${files.length} files, ${formatBytes(bytes)}`);
|
|
388
|
+
return { files, bytes, entry, framework: next ? "next" : "multi-file" };
|
|
389
|
+
}
|
|
390
|
+
function buildAndInline(dir, opts = {}) {
|
|
391
|
+
const log = opts.log ?? (() => {
|
|
392
|
+
});
|
|
393
|
+
const project = detectProject(dir, opts.buildCmd);
|
|
394
|
+
log(`project: ${dir}`);
|
|
395
|
+
log(`pkg mgr: ${project.packageManager}`);
|
|
396
|
+
log(`build: ${opts.buildCmd ?? buildCommand(project.packageManager, project.buildScript)}`);
|
|
397
|
+
runBuild(project, {
|
|
398
|
+
install: opts.install,
|
|
399
|
+
buildCmd: opts.buildCmd,
|
|
400
|
+
timeoutMs: opts.timeoutMs,
|
|
401
|
+
log
|
|
402
|
+
});
|
|
403
|
+
const outDir = resolveOutputDir(dir, opts.out);
|
|
404
|
+
const entry = resolveEntry(outDir, opts.entry);
|
|
405
|
+
const result = inlineHtml(outDir, entry);
|
|
406
|
+
log(`output: ${outDir}`);
|
|
407
|
+
log(`inlined: ${formatBytes(result.bytes)} single HTML`);
|
|
408
|
+
for (const w of result.warnings) log(`warning: ${w}`);
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
var SITE_BASE_PLACEHOLDER, NEXT_CONFIGS, LOCKFILES, DEFAULT_BUILD_TIMEOUT_MS, OUTPUT_DIR_CANDIDATES, MIME;
|
|
412
|
+
var init_build = __esm({
|
|
413
|
+
"src/build.ts"() {
|
|
414
|
+
"use strict";
|
|
415
|
+
init_errors();
|
|
416
|
+
SITE_BASE_PLACEHOLDER = "/__htmlship_base__";
|
|
417
|
+
NEXT_CONFIGS = ["next.config.mjs", "next.config.js", "next.config.cjs", "next.config.ts"];
|
|
418
|
+
LOCKFILES = [
|
|
419
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
420
|
+
["yarn.lock", "yarn"],
|
|
421
|
+
["bun.lockb", "bun"],
|
|
422
|
+
["bun.lock", "bun"],
|
|
423
|
+
["package-lock.json", "npm"]
|
|
424
|
+
];
|
|
425
|
+
DEFAULT_BUILD_TIMEOUT_MS = 5 * 6e4;
|
|
426
|
+
OUTPUT_DIR_CANDIDATES = ["dist", "build", "out"];
|
|
427
|
+
MIME = {
|
|
428
|
+
".png": "image/png",
|
|
429
|
+
".jpg": "image/jpeg",
|
|
430
|
+
".jpeg": "image/jpeg",
|
|
431
|
+
".gif": "image/gif",
|
|
432
|
+
".svg": "image/svg+xml",
|
|
433
|
+
".webp": "image/webp",
|
|
434
|
+
".avif": "image/avif",
|
|
435
|
+
".ico": "image/x-icon",
|
|
436
|
+
".woff": "font/woff",
|
|
437
|
+
".woff2": "font/woff2",
|
|
438
|
+
".ttf": "font/ttf",
|
|
439
|
+
".otf": "font/otf",
|
|
440
|
+
".css": "text/css",
|
|
441
|
+
".js": "text/javascript",
|
|
442
|
+
".mjs": "text/javascript",
|
|
443
|
+
".json": "application/json"
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
54
448
|
// src/version.ts
|
|
55
449
|
var VERSION;
|
|
56
450
|
var init_version = __esm({
|
|
57
451
|
"src/version.ts"() {
|
|
58
452
|
"use strict";
|
|
59
|
-
VERSION = "0.
|
|
453
|
+
VERSION = "0.3.0";
|
|
60
454
|
}
|
|
61
455
|
});
|
|
62
456
|
|
|
63
457
|
// src/client.ts
|
|
64
|
-
var DEFAULT_API_URL, HTMLShipClient;
|
|
458
|
+
var MAX_PAYLOAD_BYTES, DEFAULT_API_URL, HTMLShipClient;
|
|
65
459
|
var init_client = __esm({
|
|
66
460
|
"src/client.ts"() {
|
|
67
461
|
"use strict";
|
|
462
|
+
init_build();
|
|
68
463
|
init_errors();
|
|
69
464
|
init_version();
|
|
465
|
+
MAX_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
|
70
466
|
DEFAULT_API_URL = "https://api.htmlship.com";
|
|
71
467
|
HTMLShipClient = class {
|
|
72
468
|
baseUrl;
|
|
@@ -92,6 +488,59 @@ var init_client = __esm({
|
|
|
92
488
|
if (options.parentSlug != null) body["parent_slug"] = options.parentSlug;
|
|
93
489
|
return await this.request("POST", "/api/v1/pages", { body });
|
|
94
490
|
}
|
|
491
|
+
/**
|
|
492
|
+
* Build a local frontend project, inline its output into one self-contained
|
|
493
|
+
* HTML document, and publish it as a relaxed (sandboxed-script) page. The
|
|
494
|
+
* build runs on this machine — never on the server.
|
|
495
|
+
*/
|
|
496
|
+
async deploy(projectDir, options = {}) {
|
|
497
|
+
const { html, bytes } = buildAndInline(projectDir, {
|
|
498
|
+
buildCmd: options.buildCmd,
|
|
499
|
+
out: options.out,
|
|
500
|
+
entry: options.entry,
|
|
501
|
+
install: options.install
|
|
502
|
+
});
|
|
503
|
+
if (bytes > MAX_PAYLOAD_BYTES) {
|
|
504
|
+
throw new HTMLShipError(`inlined page is ${bytes} bytes, exceeds the 10 MB limit`);
|
|
505
|
+
}
|
|
506
|
+
return await this.publish(html, {
|
|
507
|
+
title: options.title ?? null,
|
|
508
|
+
password: options.password ?? null,
|
|
509
|
+
expiresIn: options.expiresIn ?? null,
|
|
510
|
+
sandboxMode: "relaxed"
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
/** Upload a multi-file static site (built locally) and get its URL. */
|
|
514
|
+
async deploySite(files, options = {}) {
|
|
515
|
+
const body = { files };
|
|
516
|
+
if (options.entry) body["entry"] = options.entry;
|
|
517
|
+
if (options.title != null) body["title"] = options.title;
|
|
518
|
+
if (options.password != null) body["password"] = options.password;
|
|
519
|
+
if (options.expiresIn != null) body["expires_in"] = options.expiresIn;
|
|
520
|
+
return await this.request("POST", "/api/v1/sites", { body });
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Build a local project and deploy it, auto-choosing single-file inlining
|
|
524
|
+
* (SPA) vs. multi-file site hosting (Next.js, or when options.site is set).
|
|
525
|
+
*/
|
|
526
|
+
async deployProject(projectDir, options = {}) {
|
|
527
|
+
const useSite = options.singleFile ? false : options.site || isNextProject(projectDir);
|
|
528
|
+
if (useSite) {
|
|
529
|
+
const { files, entry } = buildAndPackageSite(projectDir, {
|
|
530
|
+
buildCmd: options.buildCmd,
|
|
531
|
+
out: options.out,
|
|
532
|
+
entry: options.entry,
|
|
533
|
+
install: options.install
|
|
534
|
+
});
|
|
535
|
+
return await this.deploySite(files, {
|
|
536
|
+
entry,
|
|
537
|
+
title: options.title ?? null,
|
|
538
|
+
password: options.password ?? null,
|
|
539
|
+
expiresIn: options.expiresIn ?? null
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return await this.deploy(projectDir, options);
|
|
543
|
+
}
|
|
95
544
|
async get(slug) {
|
|
96
545
|
return await this.request("GET", `/api/v1/pages/${encodeURIComponent(slug)}`);
|
|
97
546
|
}
|
|
@@ -240,6 +689,54 @@ function buildMcpServer(client) {
|
|
|
240
689
|
}
|
|
241
690
|
}
|
|
242
691
|
);
|
|
692
|
+
server.registerTool(
|
|
693
|
+
"deploy_project",
|
|
694
|
+
{
|
|
695
|
+
description: "Build a local frontend project (npm/pnpm/yarn/bun) and deploy the compiled app. Runs the project's build script ON THIS MACHINE. Single-page apps (Vite/CRA) are inlined into one self-contained, script-enabled page; multi-file sites (Next.js static export, auto-detected) are hosted at view.htmlship.com/{slug}/. Both run with relaxed sandboxing so their JS runs in an isolated origin. The owner_key returned is the only credential to update or delete this later \u2014 save it.",
|
|
696
|
+
inputSchema: {
|
|
697
|
+
dir: z.string().describe("Path to the project directory (must contain package.json)."),
|
|
698
|
+
build_cmd: z.string().optional().describe("Override the build command (default: detected build/build:prod script)."),
|
|
699
|
+
out: z.string().optional().describe("Build output directory (default: auto-detect dist/, build/, out/)."),
|
|
700
|
+
site: z.boolean().optional().describe("Force multi-file site hosting (auto-detected for Next.js)."),
|
|
701
|
+
install: z.boolean().optional().describe("Run dependency install before building."),
|
|
702
|
+
title: z.string().optional().describe("Optional human-readable title."),
|
|
703
|
+
password: z.string().optional().describe("Optional password required before viewing."),
|
|
704
|
+
expires_in: z.number().int().min(1).max(60 * 24 * 7).optional().describe("Optional TTL in minutes (1\u201310080, i.e. up to 7 days).")
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
async ({ dir, build_cmd, out, site, install, title, password, expires_in }) => {
|
|
708
|
+
try {
|
|
709
|
+
const page = await c.deployProject(dir, {
|
|
710
|
+
buildCmd: build_cmd ?? void 0,
|
|
711
|
+
out: out ?? void 0,
|
|
712
|
+
site: site ?? void 0,
|
|
713
|
+
install: install ?? void 0,
|
|
714
|
+
title: title ?? null,
|
|
715
|
+
password: password ?? null,
|
|
716
|
+
expiresIn: expires_in ?? null
|
|
717
|
+
});
|
|
718
|
+
return {
|
|
719
|
+
content: [
|
|
720
|
+
{
|
|
721
|
+
type: "text",
|
|
722
|
+
text: JSON.stringify(
|
|
723
|
+
{
|
|
724
|
+
url: page.url,
|
|
725
|
+
slug: page.slug,
|
|
726
|
+
owner_key: page.owner_key,
|
|
727
|
+
expires_at: page.expires_at
|
|
728
|
+
},
|
|
729
|
+
null,
|
|
730
|
+
2
|
|
731
|
+
)
|
|
732
|
+
}
|
|
733
|
+
]
|
|
734
|
+
};
|
|
735
|
+
} catch (err) {
|
|
736
|
+
return errorResult("deploy_project", err);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
);
|
|
243
740
|
server.registerTool(
|
|
244
741
|
"fetch_html",
|
|
245
742
|
{
|
|
@@ -328,16 +825,16 @@ init_errors();
|
|
|
328
825
|
import { createInterface } from "readline/promises";
|
|
329
826
|
|
|
330
827
|
// src/keystore.ts
|
|
331
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "fs";
|
|
828
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, chmodSync } from "fs";
|
|
332
829
|
import { homedir } from "os";
|
|
333
|
-
import { join } from "path";
|
|
830
|
+
import { join as join2 } from "path";
|
|
334
831
|
function createKeyStore(overrideDir) {
|
|
335
|
-
const dir = overrideDir ?? process.env["HTMLSHIP_KEYS_DIR"] ??
|
|
336
|
-
const file =
|
|
832
|
+
const dir = overrideDir ?? process.env["HTMLSHIP_KEYS_DIR"] ?? join2(homedir(), ".htmlship");
|
|
833
|
+
const file = join2(dir, "keys.json");
|
|
337
834
|
function load() {
|
|
338
|
-
if (!
|
|
835
|
+
if (!existsSync2(file)) return {};
|
|
339
836
|
try {
|
|
340
|
-
const text =
|
|
837
|
+
const text = readFileSync2(file, "utf8");
|
|
341
838
|
const parsed = JSON.parse(text);
|
|
342
839
|
if (parsed && typeof parsed === "object") return parsed;
|
|
343
840
|
return {};
|
|
@@ -346,12 +843,12 @@ function createKeyStore(overrideDir) {
|
|
|
346
843
|
}
|
|
347
844
|
}
|
|
348
845
|
function save(data) {
|
|
349
|
-
if (!
|
|
846
|
+
if (!existsSync2(dir)) mkdirSync(dir, { recursive: true });
|
|
350
847
|
const sorted = {};
|
|
351
848
|
for (const key of Object.keys(data).sort()) {
|
|
352
849
|
sorted[key] = data[key];
|
|
353
850
|
}
|
|
354
|
-
|
|
851
|
+
writeFileSync2(file, JSON.stringify(sorted, null, 2), "utf8");
|
|
355
852
|
try {
|
|
356
853
|
chmodSync(file, 384);
|
|
357
854
|
} catch {
|
|
@@ -420,6 +917,146 @@ function registerDelete(program) {
|
|
|
420
917
|
});
|
|
421
918
|
}
|
|
422
919
|
|
|
920
|
+
// src/commands/deploy.ts
|
|
921
|
+
init_build();
|
|
922
|
+
init_client();
|
|
923
|
+
init_errors();
|
|
924
|
+
import { resolve as resolve2 } from "path";
|
|
925
|
+
|
|
926
|
+
// src/io.ts
|
|
927
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
928
|
+
function readHtmlFromSource(source, fileFlag) {
|
|
929
|
+
if (fileFlag) {
|
|
930
|
+
return readFileSync3(fileFlag, "utf8");
|
|
931
|
+
}
|
|
932
|
+
if (source === "-" || source === void 0) {
|
|
933
|
+
if (source === void 0 && process.stdin.isTTY) {
|
|
934
|
+
throw new CliUsageError("Provide a file path or pipe HTML on stdin.");
|
|
935
|
+
}
|
|
936
|
+
return readStdinSync();
|
|
937
|
+
}
|
|
938
|
+
return readFileSync3(source, "utf8");
|
|
939
|
+
}
|
|
940
|
+
var CliUsageError = class extends Error {
|
|
941
|
+
constructor(message) {
|
|
942
|
+
super(message);
|
|
943
|
+
this.name = "CliUsageError";
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
function readStdinSync() {
|
|
947
|
+
try {
|
|
948
|
+
return readFileSync3(0, "utf8");
|
|
949
|
+
} catch (err) {
|
|
950
|
+
throw new CliUsageError(`failed to read stdin: ${err.message}`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
async function tryClipboardCopy(text) {
|
|
954
|
+
try {
|
|
955
|
+
const mod = await import("clipboardy");
|
|
956
|
+
await mod.default.write(text);
|
|
957
|
+
return true;
|
|
958
|
+
} catch {
|
|
959
|
+
return false;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// src/commands/deploy.ts
|
|
964
|
+
var MAX_INLINE_BYTES = 10 * 1024 * 1024;
|
|
965
|
+
function registerDeploy(program) {
|
|
966
|
+
program.command("deploy").description(
|
|
967
|
+
"Build a frontend project and deploy it \u2014 a single-page app as one inlined page, or a multi-file site (Next.js, etc.) at view.htmlship.com/{slug}/."
|
|
968
|
+
).argument("[dir]", "project directory (default: current directory)", ".").option("--build-cmd <cmd>", "Override the build command (default: detected from package.json)").option("--out <dir>", "Build output directory (default: auto-detect dist/, build/, out/)").option("--entry <file>", "Entry HTML within the output dir (default: index.html)").option("--site", "Force multi-file site hosting (auto-detected for Next.js)").option("--single-file", "Force single-file inlining (one self-contained page)").option("--install", "Run dependency install before building").option("--dry-run", "Build, but report a summary instead of publishing").option("--title <title>", "Optional title").option("--password <password>", "Password-protect the page").option("--expires-in <minutes>", "Minutes until expiry (1\u201310080, i.e. up to 7 days)").option("--no-clipboard", "Don't copy URL to clipboard").option("-q, --quiet", "Print only the URL").action(async function(dir, opts) {
|
|
969
|
+
const projectDir = resolve2(dir);
|
|
970
|
+
const log = opts.quiet ? () => {
|
|
971
|
+
} : (m) => process.stderr.write(`${m}
|
|
972
|
+
`);
|
|
973
|
+
const apiUrl = this.parent?.opts()?.apiUrl;
|
|
974
|
+
const client = new HTMLShipClient({ baseUrl: apiUrl });
|
|
975
|
+
const expiresIn = opts.expiresIn ? Number.parseInt(opts.expiresIn, 10) : null;
|
|
976
|
+
const useSite = opts.singleFile ? false : opts.site || isNextProject(projectDir);
|
|
977
|
+
let page;
|
|
978
|
+
if (useSite) {
|
|
979
|
+
const { files, entry } = buildAndPackageSite(projectDir, {
|
|
980
|
+
buildCmd: opts.buildCmd,
|
|
981
|
+
out: opts.out,
|
|
982
|
+
entry: opts.entry,
|
|
983
|
+
install: opts.install,
|
|
984
|
+
log
|
|
985
|
+
});
|
|
986
|
+
if (opts.dryRun) {
|
|
987
|
+
log("dry-run: built and packaged OK; not published");
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
try {
|
|
991
|
+
page = await client.deploySite(files, {
|
|
992
|
+
entry,
|
|
993
|
+
title: opts.title ?? null,
|
|
994
|
+
password: opts.password ?? null,
|
|
995
|
+
expiresIn
|
|
996
|
+
});
|
|
997
|
+
} catch (err) {
|
|
998
|
+
throw new HTMLShipError(`deploy failed: ${err.message}`);
|
|
999
|
+
}
|
|
1000
|
+
} else {
|
|
1001
|
+
const { html, bytes } = buildAndInline(projectDir, {
|
|
1002
|
+
buildCmd: opts.buildCmd,
|
|
1003
|
+
out: opts.out,
|
|
1004
|
+
entry: opts.entry,
|
|
1005
|
+
install: opts.install,
|
|
1006
|
+
log
|
|
1007
|
+
});
|
|
1008
|
+
if (bytes > MAX_INLINE_BYTES) {
|
|
1009
|
+
throw new HTMLShipError(
|
|
1010
|
+
`inlined page is ${formatBytes(bytes)}, exceeds the ${formatBytes(MAX_INLINE_BYTES)} limit \u2014 try --site for multi-file hosting`
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
if (opts.dryRun) {
|
|
1014
|
+
log("dry-run: built and inlined OK; not published");
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
try {
|
|
1018
|
+
page = await client.publish(html, {
|
|
1019
|
+
title: opts.title ?? null,
|
|
1020
|
+
password: opts.password ?? null,
|
|
1021
|
+
expiresIn,
|
|
1022
|
+
sandboxMode: "relaxed"
|
|
1023
|
+
});
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
throw new HTMLShipError(`deploy failed: ${err.message}`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
const keys = createKeyStore();
|
|
1029
|
+
keys.remember(page.slug, {
|
|
1030
|
+
owner_key: page.owner_key,
|
|
1031
|
+
url: page.url,
|
|
1032
|
+
title: opts.title ?? null
|
|
1033
|
+
});
|
|
1034
|
+
if (opts.quiet) {
|
|
1035
|
+
process.stdout.write(`${page.url}
|
|
1036
|
+
`);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
process.stdout.write(`${page.url}
|
|
1040
|
+
`);
|
|
1041
|
+
process.stderr.write(`slug: ${page.slug}
|
|
1042
|
+
`);
|
|
1043
|
+
process.stderr.write(`owner_key: ${page.owner_key} (saved to ${keys.file})
|
|
1044
|
+
`);
|
|
1045
|
+
process.stderr.write(
|
|
1046
|
+
`sandbox: relaxed${useSite ? " \xB7 multi-file site" : ""} (scripts run in an isolated origin)
|
|
1047
|
+
`
|
|
1048
|
+
);
|
|
1049
|
+
if (page.expires_at) {
|
|
1050
|
+
process.stderr.write(`expires: ${page.expires_at}
|
|
1051
|
+
`);
|
|
1052
|
+
}
|
|
1053
|
+
if (opts.clipboard !== false) {
|
|
1054
|
+
const copied = await tryClipboardCopy(page.url);
|
|
1055
|
+
if (copied) process.stderr.write("(URL copied to clipboard)\n");
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
423
1060
|
// src/commands/get.ts
|
|
424
1061
|
init_client();
|
|
425
1062
|
init_errors();
|
|
@@ -498,45 +1135,6 @@ function registerListMine(program) {
|
|
|
498
1135
|
// src/commands/publish.ts
|
|
499
1136
|
init_client();
|
|
500
1137
|
init_errors();
|
|
501
|
-
|
|
502
|
-
// src/io.ts
|
|
503
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
504
|
-
function readHtmlFromSource(source, fileFlag) {
|
|
505
|
-
if (fileFlag) {
|
|
506
|
-
return readFileSync2(fileFlag, "utf8");
|
|
507
|
-
}
|
|
508
|
-
if (source === "-" || source === void 0) {
|
|
509
|
-
if (source === void 0 && process.stdin.isTTY) {
|
|
510
|
-
throw new CliUsageError("Provide a file path or pipe HTML on stdin.");
|
|
511
|
-
}
|
|
512
|
-
return readStdinSync();
|
|
513
|
-
}
|
|
514
|
-
return readFileSync2(source, "utf8");
|
|
515
|
-
}
|
|
516
|
-
var CliUsageError = class extends Error {
|
|
517
|
-
constructor(message) {
|
|
518
|
-
super(message);
|
|
519
|
-
this.name = "CliUsageError";
|
|
520
|
-
}
|
|
521
|
-
};
|
|
522
|
-
function readStdinSync() {
|
|
523
|
-
try {
|
|
524
|
-
return readFileSync2(0, "utf8");
|
|
525
|
-
} catch (err) {
|
|
526
|
-
throw new CliUsageError(`failed to read stdin: ${err.message}`);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
async function tryClipboardCopy(text) {
|
|
530
|
-
try {
|
|
531
|
-
const mod = await import("clipboardy");
|
|
532
|
-
await mod.default.write(text);
|
|
533
|
-
return true;
|
|
534
|
-
} catch {
|
|
535
|
-
return false;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// src/commands/publish.ts
|
|
540
1138
|
function registerPublish(program) {
|
|
541
1139
|
program.command("publish").description("Publish an HTML document and get a URL.").argument("[source]", "file path, '-' for stdin (default: stdin if piped)").option("-f, --file <path>", "HTML file path").option("--title <title>", "Optional title").option("--password <password>", "Password-protect the page").option("--expires-in <minutes>", "Minutes until expiry (1\u201310080, i.e. up to 7 days)").option("--no-clipboard", "Don't copy URL to clipboard").option("-q, --quiet", "Print only the URL").action(async function(source, opts) {
|
|
542
1140
|
const html = readHtmlFromSource(source, opts.file);
|
|
@@ -573,7 +1171,7 @@ function registerPublish(program) {
|
|
|
573
1171
|
process.stderr.write(`expires: ${page.expires_at}
|
|
574
1172
|
`);
|
|
575
1173
|
}
|
|
576
|
-
if (opts.
|
|
1174
|
+
if (opts.clipboard !== false) {
|
|
577
1175
|
const copied = await tryClipboardCopy(page.url);
|
|
578
1176
|
if (copied) process.stderr.write("(URL copied to clipboard)\n");
|
|
579
1177
|
}
|
|
@@ -618,6 +1216,7 @@ function buildProgram() {
|
|
|
618
1216
|
"API base URL (default: https://api.htmlship.com or $HTMLSHIP_API_URL)"
|
|
619
1217
|
);
|
|
620
1218
|
registerPublish(program);
|
|
1219
|
+
registerDeploy(program);
|
|
621
1220
|
registerGet(program);
|
|
622
1221
|
registerUpdate(program);
|
|
623
1222
|
registerDelete(program);
|