htmlship 0.2.0 → 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 (2) hide show
  1. package/dist/cli.js +261 -41
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -53,8 +53,16 @@ var init_errors = __esm({
53
53
 
54
54
  // src/build.ts
55
55
  import { spawnSync } from "child_process";
56
- import { existsSync, readFileSync, statSync } from "fs";
57
- import { dirname, extname, isAbsolute, join, relative, resolve } from "path";
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";
58
66
  function detectPackageManager(dir) {
59
67
  for (const [file, pm] of LOCKFILES) {
60
68
  if (existsSync(join(dir, file))) return pm;
@@ -121,16 +129,61 @@ function exec(command, cwd, timeout, log) {
121
129
  throw new HTMLShipError(`build exited with code ${r.status}: \`${command}\``);
122
130
  }
123
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
+ }
124
146
  function resolveOutputDir(dir, override) {
125
- const candidates = override ? [override] : ["dist", "build"];
147
+ const candidates = override ? [override] : OUTPUT_DIR_CANDIDATES;
126
148
  for (const c of candidates) {
127
149
  const p = isAbsolute(c) ? c : join(dir, c);
128
150
  if (existsSync(p) && statSync(p).isDirectory()) return p;
129
151
  }
130
152
  throw new HTMLShipError(
131
- override ? `build output dir not found: ${override}` : "no build output found (looked for dist/ and build/; use --out to specify)"
153
+ override ? `build output dir not found: ${override}` : noOutputMessage(dir)
132
154
  );
133
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
+ }
134
187
  function mimeFor(p) {
135
188
  return MIME[extname(p).toLowerCase()] ?? "application/octet-stream";
136
189
  }
@@ -232,6 +285,108 @@ function formatBytes(n) {
232
285
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
233
286
  return `${(n / (1024 * 1024)).toFixed(2)} MB`;
234
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
+ }
235
390
  function buildAndInline(dir, opts = {}) {
236
391
  const log = opts.log ?? (() => {
237
392
  });
@@ -246,18 +401,20 @@ function buildAndInline(dir, opts = {}) {
246
401
  log
247
402
  });
248
403
  const outDir = resolveOutputDir(dir, opts.out);
249
- const entry = join(outDir, opts.entry ?? "index.html");
404
+ const entry = resolveEntry(outDir, opts.entry);
250
405
  const result = inlineHtml(outDir, entry);
251
406
  log(`output: ${outDir}`);
252
407
  log(`inlined: ${formatBytes(result.bytes)} single HTML`);
253
408
  for (const w of result.warnings) log(`warning: ${w}`);
254
409
  return result;
255
410
  }
256
- var LOCKFILES, DEFAULT_BUILD_TIMEOUT_MS, MIME;
411
+ var SITE_BASE_PLACEHOLDER, NEXT_CONFIGS, LOCKFILES, DEFAULT_BUILD_TIMEOUT_MS, OUTPUT_DIR_CANDIDATES, MIME;
257
412
  var init_build = __esm({
258
413
  "src/build.ts"() {
259
414
  "use strict";
260
415
  init_errors();
416
+ SITE_BASE_PLACEHOLDER = "/__htmlship_base__";
417
+ NEXT_CONFIGS = ["next.config.mjs", "next.config.js", "next.config.cjs", "next.config.ts"];
261
418
  LOCKFILES = [
262
419
  ["pnpm-lock.yaml", "pnpm"],
263
420
  ["yarn.lock", "yarn"],
@@ -266,6 +423,7 @@ var init_build = __esm({
266
423
  ["package-lock.json", "npm"]
267
424
  ];
268
425
  DEFAULT_BUILD_TIMEOUT_MS = 5 * 6e4;
426
+ OUTPUT_DIR_CANDIDATES = ["dist", "build", "out"];
269
427
  MIME = {
270
428
  ".png": "image/png",
271
429
  ".jpg": "image/jpeg",
@@ -292,7 +450,7 @@ var VERSION;
292
450
  var init_version = __esm({
293
451
  "src/version.ts"() {
294
452
  "use strict";
295
- VERSION = "0.2.0";
453
+ VERSION = "0.3.0";
296
454
  }
297
455
  });
298
456
 
@@ -352,6 +510,37 @@ var init_client = __esm({
352
510
  sandboxMode: "relaxed"
353
511
  });
354
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
+ }
355
544
  async get(slug) {
356
545
  return await this.request("GET", `/api/v1/pages/${encodeURIComponent(slug)}`);
357
546
  }
@@ -503,22 +692,24 @@ function buildMcpServer(client) {
503
692
  server.registerTool(
504
693
  "deploy_project",
505
694
  {
506
- description: "Build a local frontend project (npm/pnpm/yarn/bun) and publish the compiled app as one self-contained, script-enabled page. Runs the project's build script ON THIS MACHINE, inlines the output (dist/ or build/) into a single HTML file, and publishes it with relaxed sandboxing so its JavaScript runs in an isolated origin. The owner_key returned is the only credential to update or delete this page later \u2014 save it.",
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.",
507
696
  inputSchema: {
508
697
  dir: z.string().describe("Path to the project directory (must contain package.json)."),
509
698
  build_cmd: z.string().optional().describe("Override the build command (default: detected build/build:prod script)."),
510
- out: z.string().optional().describe("Build output directory (default: auto-detect dist/ or build/)."),
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)."),
511
701
  install: z.boolean().optional().describe("Run dependency install before building."),
512
702
  title: z.string().optional().describe("Optional human-readable title."),
513
703
  password: z.string().optional().describe("Optional password required before viewing."),
514
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).")
515
705
  }
516
706
  },
517
- async ({ dir, build_cmd, out, install, title, password, expires_in }) => {
707
+ async ({ dir, build_cmd, out, site, install, title, password, expires_in }) => {
518
708
  try {
519
- const page = await c.deploy(dir, {
709
+ const page = await c.deployProject(dir, {
520
710
  buildCmd: build_cmd ?? void 0,
521
711
  out: out ?? void 0,
712
+ site: site ?? void 0,
522
713
  install: install ?? void 0,
523
714
  title: title ?? null,
524
715
  password: password ?? null,
@@ -634,7 +825,7 @@ init_errors();
634
825
  import { createInterface } from "readline/promises";
635
826
 
636
827
  // src/keystore.ts
637
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync, chmodSync } from "fs";
828
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, chmodSync } from "fs";
638
829
  import { homedir } from "os";
639
830
  import { join as join2 } from "path";
640
831
  function createKeyStore(overrideDir) {
@@ -657,7 +848,7 @@ function createKeyStore(overrideDir) {
657
848
  for (const key of Object.keys(data).sort()) {
658
849
  sorted[key] = data[key];
659
850
  }
660
- writeFileSync(file, JSON.stringify(sorted, null, 2), "utf8");
851
+ writeFileSync2(file, JSON.stringify(sorted, null, 2), "utf8");
661
852
  try {
662
853
  chmodSync(file, 384);
663
854
  } catch {
@@ -770,43 +961,69 @@ async function tryClipboardCopy(text) {
770
961
  }
771
962
 
772
963
  // src/commands/deploy.ts
773
- var MAX_BYTES = 10 * 1024 * 1024;
964
+ var MAX_INLINE_BYTES = 10 * 1024 * 1024;
774
965
  function registerDeploy(program) {
775
966
  program.command("deploy").description(
776
- "Build a frontend project and publish the compiled app as one self-contained page."
777
- ).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/ or build/)").option("--entry <file>", "Entry HTML within the output dir (default: index.html)").option("--install", "Run dependency install before building").option("--dry-run", "Build and inline, 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) {
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) {
778
969
  const projectDir = resolve2(dir);
779
970
  const log = opts.quiet ? () => {
780
971
  } : (m) => process.stderr.write(`${m}
781
972
  `);
782
- const { html, bytes } = buildAndInline(projectDir, {
783
- buildCmd: opts.buildCmd,
784
- out: opts.out,
785
- entry: opts.entry,
786
- install: opts.install,
787
- log
788
- });
789
- if (bytes > MAX_BYTES) {
790
- throw new HTMLShipError(
791
- `inlined page is ${formatBytes(bytes)}, exceeds the ${formatBytes(MAX_BYTES)} limit`
792
- );
793
- }
794
- if (opts.dryRun) {
795
- log("dry-run: built and inlined OK; not published");
796
- return;
797
- }
798
973
  const apiUrl = this.parent?.opts()?.apiUrl;
799
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);
800
977
  let page;
801
- try {
802
- page = await client.publish(html, {
803
- title: opts.title ?? null,
804
- password: opts.password ?? null,
805
- expiresIn: opts.expiresIn ? Number.parseInt(opts.expiresIn, 10) : null,
806
- sandboxMode: "relaxed"
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
807
985
  });
808
- } catch (err) {
809
- throw new HTMLShipError(`deploy failed: ${err.message}`);
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
+ }
810
1027
  }
811
1028
  const keys = createKeyStore();
812
1029
  keys.remember(page.slug, {
@@ -825,7 +1042,10 @@ function registerDeploy(program) {
825
1042
  `);
826
1043
  process.stderr.write(`owner_key: ${page.owner_key} (saved to ${keys.file})
827
1044
  `);
828
- process.stderr.write("sandbox: relaxed (scripts run in an isolated origin)\n");
1045
+ process.stderr.write(
1046
+ `sandbox: relaxed${useSite ? " \xB7 multi-file site" : ""} (scripts run in an isolated origin)
1047
+ `
1048
+ );
829
1049
  if (page.expires_at) {
830
1050
  process.stderr.write(`expires: ${page.expires_at}
831
1051
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlship",
3
- "version": "0.2.0",
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",