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.
Files changed (3) hide show
  1. package/README.md +13 -1
  2. package/dist/cli.js +649 -50
  3. 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 three tools: `publish_html` (with optional `password`), `fetch_html`, `update_html`.
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.1.5";
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"] ?? join(homedir(), ".htmlship");
336
- const file = join(dir, "keys.json");
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 (!existsSync(file)) return {};
835
+ if (!existsSync2(file)) return {};
339
836
  try {
340
- const text = readFileSync(file, "utf8");
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 (!existsSync(dir)) mkdirSync(dir, { recursive: true });
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
- writeFileSync(file, JSON.stringify(sorted, null, 2), "utf8");
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.noClipboard !== true) {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlship",
3
- "version": "0.1.5",
3
+ "version": "0.3.0",
4
4
  "description": "Host and share HTML pages from LLMs and coding agents in one line. CLI + MCP server.",
5
5
  "keywords": [
6
6
  "html",