htmlhost-cli 2.8.0 → 2.10.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 CHANGED
@@ -1,7 +1,11 @@
1
1
  # htmlhost
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/htmlhost-cli)](https://www.npmjs.com/package/htmlhost-cli) [![MCP compatible](https://img.shields.io/badge/MCP-compatible-8A2BE2)](https://htmlhost.co/docs/mcp)
4
+
3
5
  Deploy HTML projects from the terminal. [htmlhost.co](https://htmlhost.co)
4
6
 
7
+ Want your AI assistant to deploy for you? The [htmlhost MCP server](https://www.npmjs.com/package/htmlhost-mcp) uses the same login as this CLI and works with Claude, Cursor, VS Code, Windsurf and Codex. [Setup guide →](https://htmlhost.co/docs/mcp)
8
+
5
9
  ## Install
6
10
 
7
11
  ```bash
@@ -68,6 +72,22 @@ blog/post-1.html → /blog/post-1
68
72
 
69
73
  Add a `404.html` file and it will be served automatically for missing pages.
70
74
 
75
+ **Redirects:** A `_redirects` file at the folder's root sets the site's
76
+ redirects, one per line in the Netlify style:
77
+
78
+ ```
79
+ # old address new address (optional) 301 or 302
80
+ /about-us /about
81
+ /blog/* /news/*
82
+ /sale /offers 302
83
+ /shop https://shop.example.com
84
+ ```
85
+
86
+ The file is sent with the deploy and isn't published. Each deploy with the file
87
+ replaces the redirects it set before; redirects added in the dashboard stay (and
88
+ win when both cover the same path). Without the file, a deploy leaves redirects
89
+ as they are. `htmlhost pull` and `htmlhost clone` write the file back.
90
+
71
91
  **Single-file deploy:** When you pass a specific file (`htmlhost deploy page.html`),
72
92
  only that file is deployed. Local CSS, JS, and images referenced in the HTML are
73
93
  automatically inlined or uploaded and rewritten to hosted URLs.
@@ -162,6 +182,18 @@ Open a deployed site in the default browser.
162
182
  ### `htmlhost delete <slug>`
163
183
  Delete a site. Use `--force` to skip confirmation.
164
184
 
185
+ ### `htmlhost versions [slug]`
186
+ List a site's published versions, newest first: when, who, how it was published, and what changed. Every deploy and every save in the web editor is a version. Without a slug, uses the linked site. Add `--json` for machine-readable output.
187
+
188
+ ### `htmlhost rollback [slug] [version]`
189
+ Make an earlier version live again. Without a version number, goes back to the version before the live one. The old version is published again as a new version, so you can switch back the same way. Needs the site owner to be on Pro. Use `--force` to skip confirmation, then `htmlhost pull` so your local files match.
190
+
191
+ ```bash
192
+ htmlhost versions # linked site
193
+ htmlhost rollback # back one version
194
+ htmlhost rollback my-site 12 # make v12 live again
195
+ ```
196
+
165
197
  ### `htmlhost whoami`
166
198
  Show current authenticated user and plan.
167
199
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -49,6 +49,7 @@ function headers(extra = {}) {
49
49
  if (!token) throw new ApiError("Not logged in. Run: htmlhost login", 0);
50
50
  return {
51
51
  Authorization: `Bearer ${token}`,
52
+ "X-HTMLHost-Client": "cli",
52
53
  ...extra,
53
54
  };
54
55
  }
package/src/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { bold, dim, cyan, err } from "./ui.mjs";
5
5
  import { ApiError } from "./api.mjs";
6
6
  import { startUpdateCheck, applyUpdate } from "./update.mjs";
7
7
 
8
- const VERSION = "2.8.0";
8
+ const VERSION = "2.10.0";
9
9
 
10
10
  const HELP = `
11
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -19,6 +19,8 @@ const HELP = `
19
19
  ${cyan("htmlhost agents")} [dir] Teach your AI coding tool htmlhost (AGENTS.md)
20
20
  ${cyan("htmlhost recipients")} [slug] [cmd] Manage form notification recipients
21
21
  ${cyan("htmlhost list")} List your sites
22
+ ${cyan("htmlhost versions")} [slug] List a site's published versions
23
+ ${cyan("htmlhost rollback")} [slug] [ver] Make an earlier version live again (Pro)
22
24
  ${cyan("htmlhost delete")} <slug> Delete a site
23
25
  ${cyan("htmlhost login")} Authenticate with an API token
24
26
  ${cyan("htmlhost whoami")} Show current user
@@ -59,6 +61,11 @@ const HELP = `
59
61
  ${bold("Delete options:")}
60
62
  --force, -f Skip confirmation prompt
61
63
 
64
+ ${bold("Rollback:")}
65
+ No version: goes back to the version before the live one
66
+ --force, -f Skip confirmation prompt
67
+ Restoring publishes the old version again as a new version
68
+
62
69
  ${bold("How deploy works:")}
63
70
  No args or dir: deploys entire folder as a multi-page site
64
71
  Single file: deploys just that file (assets auto-inlined)
@@ -177,6 +184,18 @@ export async function run(argv) {
177
184
  await deleteSite(args);
178
185
  break;
179
186
  }
187
+ case "versions":
188
+ case "history": {
189
+ const { versions } = await import("./commands/versions.mjs");
190
+ await versions(args, { json: jsonMode });
191
+ break;
192
+ }
193
+ case "rollback":
194
+ case "restore": {
195
+ const { rollback } = await import("./commands/versions.mjs");
196
+ await rollback(args, { json: jsonMode });
197
+ break;
198
+ }
180
199
  case "open": {
181
200
  const { open } = await import("./commands/open.mjs");
182
201
  await open(args);
@@ -137,6 +137,13 @@ export async function clone(args) {
137
137
  ok(`${cyan(asset.path)} ${dim(`(${formatBytes(asset.sizeBytes)})`)}`);
138
138
  }
139
139
 
140
+ // --- Redirects from a deployed _redirects file ---
141
+ if (data.redirectsFile) {
142
+ writeFileSync(join(destDir, "_redirects"), data.redirectsFile, "utf8");
143
+ written++;
144
+ ok(`${cyan("_redirects")} ${dim(`(${data.redirectsFile.trim().split("\n").length} redirects)`)}`);
145
+ }
146
+
140
147
  // --- Write .htmlhost link file ---
141
148
  // A clone is always the whole site as a folder, so it links in directory
142
149
  // mode whatever the page count — `deploy` then updates this same site.
@@ -1,4 +1,4 @@
1
- import { readFileSync, statSync, readdirSync } from "node:fs";
1
+ import { readFileSync, statSync, readdirSync, existsSync } from "node:fs";
2
2
  import { basename, resolve, dirname, join } from "node:path";
3
3
  import { createInterface } from "node:readline";
4
4
  import { createHash } from "node:crypto";
@@ -7,6 +7,7 @@ import { ok, err, info, warn, cyan, dim, bold, yellow, green, formatBytes, mimeF
7
7
  import { checkRemoteChanges } from "./pull.mjs";
8
8
  import { fetchAgentGuide, hasAgentGuide, writeAgentGuide } from "./agents.mjs";
9
9
  import { readLink, writeLink, directorySite, directoryDeploySlug, fileDeploySlug } from "../link.mjs";
10
+ import { sharedDrift } from "../shared-blocks.mjs";
10
11
 
11
12
  /**
12
13
  * Upload a file to a presigned URL with automatic retry.
@@ -77,6 +78,8 @@ const DEFAULT_IGNORE = [
77
78
  ".cursorrules",
78
79
  ".windsurfrules",
79
80
  ".github",
81
+ // Redirect rules: sent with the deploy and applied by htmlhost, never published as a file.
82
+ "_redirects",
80
83
  ];
81
84
 
82
85
  /**
@@ -388,6 +391,27 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
388
391
  console.log("");
389
392
  }
390
393
 
394
+ // Shared header/footer copies that drifted apart (edited on one page only).
395
+ const drift = sharedDrift(htmlFiles.map((f) => ({ path: pagePathForFile(f.relativePath), html: readFileSync(f.filePath, "utf8") })));
396
+ for (const d of drift) {
397
+ const [main, ...others] = d.groups;
398
+ const odd = others.flat();
399
+ warn(`The shared ${bold(d.name)} differs on ${odd.length} page${odd.length === 1 ? "" : "s"}: ${odd.join(", ")} (vs ${main.length} matching).`);
400
+ console.log(` ${dim("Make the same change on every page that has it, or open the site in the htmlhost editor to apply one version everywhere.")}`);
401
+ }
402
+ if (drift.length) console.log("");
403
+
404
+ // Redirect rules (Netlify-style `_redirects`): sent with the deploy, where
405
+ // they replace the site's rules from this file. Rules added in the dashboard
406
+ // stay. No file, no change.
407
+ const redirectsPath = join(dirPath, "_redirects");
408
+ const redirectsText = existsSync(redirectsPath) ? readFileSync(redirectsPath, "utf8") : null;
409
+ if (redirectsText !== null) {
410
+ const lines = redirectsText.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith("#")).length;
411
+ console.log(` ${dim("↪")} ${bold("_redirects")} ${dim(`(${lines} rule${lines === 1 ? "" : "s"})`)}`);
412
+ console.log("");
413
+ }
414
+
391
415
  // Confirmation prompt
392
416
  const confirmed = await promptConfirm(
393
417
  existingSlug
@@ -496,6 +520,7 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
496
520
  info(existingSlug ? `Deploying to ${cyan(existingSlug)}…` : "Deploying new site…");
497
521
 
498
522
  const body = { pages: pagePayloads, assets: assetPayloads };
523
+ if (redirectsText !== null) body.redirects = redirectsText;
499
524
  if (ttl) body.ttl = ttl;
500
525
  if (existingSlug) {
501
526
  body.slug = existingSlug;
@@ -538,6 +563,13 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
538
563
  console.log(` ${cyan(`https://${data.url}${urlPath}`)}`);
539
564
  }
540
565
  console.log("");
566
+ if (data.redirects) {
567
+ const { applied, problems } = data.redirects;
568
+ ok(`${applied} redirect${applied === 1 ? "" : "s"} from ${bold("_redirects")} applied`);
569
+ for (const p of problems.slice(0, 10)) warn(p);
570
+ if (problems.length > 10) console.log(` ${dim(`… and ${problems.length - 10} more`)}`);
571
+ console.log("");
572
+ }
541
573
  console.log(` ${dim("Linked → .htmlhost")}`);
542
574
  const guide = await agentGuide;
543
575
  if (guide) writeAgentGuide(dirPath, guide, { claude: false });
@@ -184,6 +184,19 @@ export async function pull(args) {
184
184
  }
185
185
  }
186
186
 
187
+ // --- Redirects from a deployed _redirects file ---
188
+ if (data.redirectsFile) {
189
+ const localPath = join(projectDir, "_redirects");
190
+ const diff = diffFile(localPath, data.redirectsFile);
191
+ if (diff) {
192
+ changes.push({
193
+ file: "_redirects",
194
+ ...diff,
195
+ write: () => writeFileSync(localPath, data.redirectsFile, "utf8"),
196
+ });
197
+ }
198
+ }
199
+
187
200
  // --- Show results ---
188
201
  if (changes.length === 0) {
189
202
  ok("Already up to date — no changes to pull.");
@@ -0,0 +1,130 @@
1
+ import { get, post } from "../api.mjs";
2
+ import { ok, err, info, warn, dim, cyan, bold, confirm, formatBytes } from "../ui.mjs";
3
+ import { readLink } from "../link.mjs";
4
+
5
+ /** Explicit slug argument first, then the folder's .htmlhost link. */
6
+ function resolveSlug(positional) {
7
+ return positional[0] || readLink(process.cwd())?.site?.slug || null;
8
+ }
9
+
10
+ function when(iso) {
11
+ const d = new Date(iso);
12
+ return d.toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
13
+ }
14
+
15
+ function pad(str, width) {
16
+ const s = String(str);
17
+ const visible = s.replace(/\x1b\[[0-9;]*m/g, "");
18
+ return s + " ".repeat(Math.max(0, width - visible.length));
19
+ }
20
+
21
+ /**
22
+ * htmlhost versions [slug] [--json]
23
+ * Lists a site's published versions, newest first.
24
+ */
25
+ export async function versions(args, { json = false } = {}) {
26
+ const positional = args.filter((a) => !a.startsWith("-"));
27
+ const slug = resolveSlug(positional);
28
+ if (!slug) {
29
+ err("No site given and no .htmlhost link here.");
30
+ console.log(` Usage: ${cyan("htmlhost versions")} [slug]`);
31
+ process.exit(1);
32
+ }
33
+
34
+ const data = await get(`/api/sites/${encodeURIComponent(slug)}/versions`);
35
+ if (json) {
36
+ console.log(JSON.stringify(data, null, 2));
37
+ return;
38
+ }
39
+
40
+ console.log();
41
+ console.log(` ${bold("Versions of")} ${cyan(`${data.slug}.htmlhost.co`)}`);
42
+ console.log();
43
+ if (!data.versions.length) {
44
+ console.log(dim(" No versions yet. Your next deploy will be the first."));
45
+ return;
46
+ }
47
+ for (const v of data.versions) {
48
+ const tag = v.live ? cyan(" live") : "";
49
+ const name = v.label ? bold(v.label) : v.summary;
50
+ const by = v.createdBy ? ` · ${v.createdBy}` : "";
51
+ console.log(` ${pad(bold(`v${v.version}`), 6)}${tag} ${name}`);
52
+ console.log(dim(` ${when(v.createdAt)} · ${v.source}${by} · ${v.pageCount} page${v.pageCount === 1 ? "" : "s"}, ${formatBytes(v.totalBytes)}`));
53
+ }
54
+ console.log();
55
+ if (data.usable) {
56
+ console.log(dim(` Restore one with: htmlhost rollback ${data.slug} <version>`));
57
+ } else {
58
+ warn("Restoring earlier versions needs Pro: https://htmlhost.co/pricing");
59
+ }
60
+ }
61
+
62
+ /**
63
+ * htmlhost rollback [slug] [version] [--force]
64
+ * Publishes an earlier version again. Without a version, goes back to the one
65
+ * before the live version.
66
+ */
67
+ export async function rollback(args, { json = false } = {}) {
68
+ const positional = args.filter((a) => !a.startsWith("-"));
69
+ const force = args.includes("--force") || args.includes("-f");
70
+ const isVersion = (s) => /^v?\d+$/i.test(s ?? "");
71
+
72
+ // `rollback 4`, `rollback my-site`, `rollback my-site 4`
73
+ let slugArg = null;
74
+ let versionArg = null;
75
+ if (positional.length >= 2) [slugArg, versionArg] = positional;
76
+ else if (isVersion(positional[0])) versionArg = positional[0];
77
+ else slugArg = positional[0] ?? null;
78
+
79
+ const slug = resolveSlug(slugArg ? [slugArg] : []);
80
+ if (!slug) {
81
+ err("No site given and no .htmlhost link here.");
82
+ console.log(` Usage: ${cyan("htmlhost rollback")} [slug] [version]`);
83
+ process.exit(1);
84
+ }
85
+
86
+ const data = await get(`/api/sites/${encodeURIComponent(slug)}/versions`);
87
+ if (!data.usable) {
88
+ err("Restoring earlier versions needs Pro: https://htmlhost.co/pricing");
89
+ process.exit(1);
90
+ }
91
+ const live = data.versions.find((v) => v.live);
92
+ let target;
93
+ if (versionArg) {
94
+ const n = Number(String(versionArg).replace(/^v/i, ""));
95
+ target = data.versions.find((v) => v.version === n);
96
+ if (!target) {
97
+ err(`Version ${n} isn't in this site's history.`);
98
+ process.exit(1);
99
+ }
100
+ } else {
101
+ target = data.versions.find((v) => !v.live && (!live || v.version < live.version));
102
+ if (!target) {
103
+ err("There's no earlier version to go back to.");
104
+ process.exit(1);
105
+ }
106
+ }
107
+ if (target.live) {
108
+ info(`v${target.version} is already live.`);
109
+ return;
110
+ }
111
+
112
+ if (!force && !json) {
113
+ const yes = await confirm(` Make v${target.version} (${when(target.createdAt)}) live again on ${cyan(bold(`${data.slug}.htmlhost.co`))}?`);
114
+ if (!yes) {
115
+ console.log(" Cancelled.");
116
+ return;
117
+ }
118
+ }
119
+
120
+ const result = await post(`/api/sites/${encodeURIComponent(data.slug)}/versions/${target.version}/restore`, {});
121
+ if (json) {
122
+ console.log(JSON.stringify(result, null, 2));
123
+ return;
124
+ }
125
+ const url = result.url ? (String(result.url).startsWith("http") ? result.url : `https://${result.url}`) : `https://${data.slug}.htmlhost.co`;
126
+ ok(`Restored v${target.version} → ${cyan(url)}`);
127
+ if (readLink(process.cwd())?.site?.slug === data.slug) {
128
+ console.log(dim(` Your local files still have the newer version. Run ${cyan("htmlhost pull")} to match the site.`));
129
+ }
130
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared header/footer check for deploys.
3
+ * Embedded styles, font links and data-URL scripts are part of each copy and
4
+ * must be compared and deployed intact, just like linked shared assets.
5
+ *
6
+ * Pages can share a header and footer by wrapping them in marker comments
7
+ * (`<!-- hh:shared header … -->` … `<!-- /hh:shared header -->`); the htmlhost
8
+ * editor keeps the copies identical. When a project is edited elsewhere (an
9
+ * editor, an AI agent), copies can drift apart. `deploy` warns about that
10
+ * before uploading. This mirrors the editor's comparison (src/lib/shared-blocks.ts
11
+ * in the app): copies may differ only in the current page's menu link and in
12
+ * relative vs root paths.
13
+ */
14
+
15
+ const OPEN = /<!--\s*hh:shared\s+([a-z][a-z0-9-]*)((?:\s+[a-z][a-z0-9-]*="[^"]*")*)\s*-->/gi;
16
+
17
+ /** The first well-formed block of each name on a page: { name, attrs, content }. */
18
+ export function findSharedBlocks(html) {
19
+ const blocks = [];
20
+ for (const open of html.matchAll(OPEN)) {
21
+ const name = open[1].toLowerCase();
22
+ if (blocks.some((b) => b.name === name)) continue;
23
+ const start = open.index + open[0].length;
24
+ const close = new RegExp(`<!--\\s*/hh:shared\\s+${name}\\s*-->`, "i").exec(html.slice(start));
25
+ if (!close) continue;
26
+ const attrs = {};
27
+ for (const m of open[2].matchAll(/([a-z][a-z0-9-]*)="([^"]*)"/gi)) attrs[m[1].toLowerCase()] = m[2];
28
+ blocks.push({ name, attrs, content: html.slice(start, start + close.index) });
29
+ }
30
+ return blocks;
31
+ }
32
+
33
+ /** Class names sites use for "you are here" (used when a marker doesn't name its own). */
34
+ const ACTIVE_WORD = /^(is-|nav-|menu-|link-)?(active|current|selected)(-page|-item|-link)?$|^current[-_](menu|page)[-_](item|ancestor|parent)$|^router-link-(exact-)?active$|^uk-active$/i;
35
+
36
+ /** A block reduced to what must match across pages. */
37
+ export function comparable(content, attrs, pagePath) {
38
+ const named = (attrs["active-class"] ?? "").split(/\s+/).filter(Boolean);
39
+ const isActive = "active-class" in attrs ? (c) => named.includes(c) : (c) => ACTIVE_WORD.test(c);
40
+ let out = content.replace(/\s+aria-current\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
41
+ out = out.replace(/\sclass\s*=\s*("([^"]*)"|'([^']*)')/gi, (m, _q, dq, sq) => {
42
+ const kept = (dq ?? sq).split(/\s+/).filter((c) => c && !isActive(c));
43
+ return kept.length ? ` class="${kept.join(" ")}"` : "";
44
+ });
45
+ // Relative URLs as root paths, so a page in a subfolder compares equal.
46
+ const base = new URL(pagePath.startsWith("/") ? pagePath : `/${pagePath}`, "http://site.invalid");
47
+ out = out.replace(/\s(href|src|poster)\s*=\s*("([^"]*)"|'([^']*)')/gi, (m, name, _q, dq, sq) => {
48
+ const ref = (dq ?? sq).trim();
49
+ if (!ref || /^([a-z][a-z0-9+.-]*:|\/|#)/i.test(ref)) return ` ${name}="${ref}"`;
50
+ try {
51
+ const u = new URL(ref, base);
52
+ return ` ${name}="${u.pathname}${u.search}${u.hash}"`;
53
+ } catch {
54
+ return ` ${name}="${ref}"`;
55
+ }
56
+ });
57
+ return out.replace(/\s+/g, " ").trim();
58
+ }
59
+
60
+ /**
61
+ * Shared blocks whose copies differ between pages.
62
+ * pages: [{ path: "/about", html }] → [{ name, groups: [["/", "/about"], ["/blog"]] }] (largest group first).
63
+ */
64
+ export function sharedDrift(pages) {
65
+ const byName = new Map();
66
+ for (const page of pages) {
67
+ for (const block of findSharedBlocks(page.html)) {
68
+ const key = comparable(block.content, block.attrs, page.path);
69
+ const groups = byName.get(block.name) ?? new Map();
70
+ groups.set(key, [...(groups.get(key) ?? []), page.path]);
71
+ byName.set(block.name, groups);
72
+ }
73
+ }
74
+ return [...byName]
75
+ .map(([name, groups]) => ({ name, groups: [...groups.values()].sort((a, b) => b.length - a.length) }))
76
+ .filter((d) => d.groups.length > 1);
77
+ }