htmlhost-cli 2.7.1 → 2.9.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
@@ -87,6 +91,17 @@ checks if the remote site was modified (e.g. via the web editor) since your last
87
91
  or pull. If changes are detected, you'll be prompted to pull first, deploy anyway, or
88
92
  cancel. Use `--no-pull-check` to skip this check (useful in CI/CD).
89
93
 
94
+ ### `htmlhost status [dir] [--json]`
95
+
96
+ Like `git status` for a linked folder: the site it deploys to, whether the live
97
+ site changed since your last deploy/pull, and what the next `htmlhost deploy`
98
+ would add, modify, or remove. Read-only.
99
+
100
+ ```bash
101
+ htmlhost status
102
+ htmlhost status --json # machine-readable, for scripts and AI agents
103
+ ```
104
+
90
105
  ### `htmlhost pull [slug] [options]`
91
106
  Pull remote changes from an htmlhost.co site to the local project.
92
107
 
@@ -151,6 +166,18 @@ Open a deployed site in the default browser.
151
166
  ### `htmlhost delete <slug>`
152
167
  Delete a site. Use `--force` to skip confirmation.
153
168
 
169
+ ### `htmlhost versions [slug]`
170
+ 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.
171
+
172
+ ### `htmlhost rollback [slug] [version]`
173
+ 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.
174
+
175
+ ```bash
176
+ htmlhost versions # linked site
177
+ htmlhost rollback # back one version
178
+ htmlhost rollback my-site 12 # make v12 live again
179
+ ```
180
+
154
181
  ### `htmlhost whoami`
155
182
  Show current authenticated user and plan.
156
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.7.1",
3
+ "version": "2.9.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.7.1";
8
+ const VERSION = "2.9.0";
9
9
 
10
10
  const HELP = `
11
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -13,11 +13,14 @@ const HELP = `
13
13
  ${bold("Usage:")}
14
14
  ${cyan("htmlhost deploy")} [file|dir] [opts] Deploy file or directory
15
15
  ${cyan("htmlhost upload")} <file|dir...> [opts] Upload media file or directory
16
+ ${cyan("htmlhost status")} [dir] Show what deploy would change (like git status)
16
17
  ${cyan("htmlhost pull")} [slug] [opts] Pull remote changes to local
17
18
  ${cyan("htmlhost clone")} <slug> [dir] Clone a site to a local directory
18
19
  ${cyan("htmlhost agents")} [dir] Teach your AI coding tool htmlhost (AGENTS.md)
19
20
  ${cyan("htmlhost recipients")} [slug] [cmd] Manage form notification recipients
20
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)
21
24
  ${cyan("htmlhost delete")} <slug> Delete a site
22
25
  ${cyan("htmlhost login")} Authenticate with an API token
23
26
  ${cyan("htmlhost whoami")} Show current user
@@ -37,6 +40,9 @@ const HELP = `
37
40
  --no-copy Skip copying the URL to clipboard
38
41
  --json Output response as JSON
39
42
 
43
+ ${bold("Status options:")}
44
+ --json Output the status as JSON
45
+
40
46
  ${bold("Pull options:")}
41
47
  --force, -f Pull without confirmation prompt
42
48
  --dry-run Show what would change without writing
@@ -55,6 +61,11 @@ const HELP = `
55
61
  ${bold("Delete options:")}
56
62
  --force, -f Skip confirmation prompt
57
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
+
58
69
  ${bold("How deploy works:")}
59
70
  No args or dir: deploys entire folder as a multi-page site
60
71
  Single file: deploys just that file (assets auto-inlined)
@@ -69,6 +80,7 @@ const HELP = `
69
80
  ${dim("$")} htmlhost upload assets/hero.png ${dim("# upload image, copy URL to clipboard")}
70
81
  ${dim("$")} htmlhost upload assets/ ${dim("# upload all files in directory")}
71
82
  ${dim("$")} htmlhost upload photo.jpg --open
83
+ ${dim("$")} htmlhost status ${dim("# what would deploy change?")}
72
84
  ${dim("$")} htmlhost pull ${dim("# pull remote changes")}
73
85
  ${dim("$")} htmlhost clone my-site ${dim("# clone into ./my-site/")}
74
86
  ${dim("$")} htmlhost delete old-project --force
@@ -134,6 +146,12 @@ export async function run(argv) {
134
146
  await upload(args, { json: jsonMode });
135
147
  break;
136
148
  }
149
+ case "status":
150
+ case "st": {
151
+ const { status } = await import("./commands/status.mjs");
152
+ await status(args, { json: jsonMode });
153
+ break;
154
+ }
137
155
  case "pull": {
138
156
  const { pull } = await import("./commands/pull.mjs");
139
157
  await pull(args);
@@ -166,6 +184,18 @@ export async function run(argv) {
166
184
  await deleteSite(args);
167
185
  break;
168
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
+ }
169
199
  case "open": {
170
200
  const { open } = await import("./commands/open.mjs");
171
201
  await open(args);
@@ -411,16 +411,7 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
411
411
  for (const file of htmlFiles) {
412
412
  const html = readFileSync(file.filePath, "utf8");
413
413
 
414
- // Derive page path
415
- let pagePath;
416
- if (file.relativePath === "index.html") {
417
- pagePath = "/";
418
- } else if (basename(file.relativePath) === "index.html") {
419
- const dirRel = dirname(file.relativePath);
420
- pagePath = `/${dirRel}`;
421
- } else {
422
- pagePath = "/" + file.relativePath.replace(/\.html$/, "");
423
- }
414
+ const pagePath = pagePathForFile(file.relativePath);
424
415
 
425
416
  if (seenPaths.has(pagePath)) {
426
417
  info(`Skipping duplicate route: ${cyan(file.relativePath)} (conflicts with existing ${pagePath})`);
@@ -621,7 +612,17 @@ async function preDeployPullCheck(slug, projectDir) {
621
612
  * Skips ignored files/directories.
622
613
  * Returns [{ filePath, relativePath, size }]
623
614
  */
624
- function findAllFiles(baseDir, currentDir) {
615
+ /**
616
+ * The route a local HTML file deploys to: index.html → "/",
617
+ * blog/index.html → "/blog", about.html → "/about".
618
+ */
619
+ export function pagePathForFile(relativePath) {
620
+ if (relativePath === "index.html") return "/";
621
+ if (basename(relativePath) === "index.html") return `/${dirname(relativePath)}`;
622
+ return "/" + relativePath.replace(/\.html$/, "");
623
+ }
624
+
625
+ export function findAllFiles(baseDir, currentDir) {
625
626
  const entries = readdirSync(currentDir, { withFileTypes: true });
626
627
  const results = [];
627
628
 
@@ -0,0 +1,150 @@
1
+ /**
2
+ * htmlhost status [--json]
3
+ *
4
+ * Like `git status` for a linked folder: which site it deploys to, whether the
5
+ * live site changed since your last deploy/pull, and what the next
6
+ * `htmlhost deploy` would add, change or remove. Read-only — nothing is
7
+ * written, locally or remotely.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { createHash } from "node:crypto";
12
+ import { get } from "../api.mjs";
13
+ import { readLink } from "../link.mjs";
14
+ import { findAllFiles, pagePathForFile } from "./deploy.mjs";
15
+ import { ok, err, info, warn, cyan, dim, bold, green, yellow, red } from "../ui.mjs";
16
+
17
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
18
+
19
+ /**
20
+ * Compare a local folder with the live site. Pure apart from reading local
21
+ * files: `remote` is the /api/sites/pull payload.
22
+ *
23
+ * - added: local only — the next deploy uploads it
24
+ * - modified: on both, contents differ
25
+ * - removed: live only — the next deploy deletes it (a deploy replaces the
26
+ * whole site with the folder)
27
+ */
28
+ export function compareWithRemote(dir, remote) {
29
+ const local = findAllFiles(dir, dir).map((f) => ({ ...f, relativePath: f.relativePath.split("\\").join("/") }));
30
+ const remotePages = new Map((remote.pages || []).map((p) => [p.path, p]));
31
+ const remoteAssets = new Map((remote.assets || []).map((a) => [a.path, a]));
32
+ const added = [];
33
+ const modified = [];
34
+ const seenRoutes = new Set();
35
+
36
+ for (const f of local) {
37
+ if (f.relativePath.endsWith(".html")) {
38
+ const route = pagePathForFile(f.relativePath);
39
+ if (seenRoutes.has(route)) continue; // deploy skips duplicate routes too
40
+ seenRoutes.add(route);
41
+ const page = remotePages.get(route);
42
+ if (!page) added.push(f.relativePath);
43
+ else if (readFileSync(f.filePath, "utf8") !== page.html) modified.push(f.relativePath);
44
+ remotePages.delete(route);
45
+ continue;
46
+ }
47
+ const asset = remoteAssets.get(f.relativePath);
48
+ remoteAssets.delete(f.relativePath);
49
+ if (!asset) {
50
+ added.push(f.relativePath);
51
+ } else if (asset.content !== null && asset.content !== undefined) {
52
+ if (readFileSync(f.filePath, "utf8") !== asset.content) modified.push(f.relativePath);
53
+ } else if (asset.hash) {
54
+ if (sha256(readFileSync(f.filePath)) !== asset.hash) modified.push(f.relativePath);
55
+ } else if (asset.sizeBytes != null && f.size !== asset.sizeBytes) {
56
+ modified.push(f.relativePath);
57
+ }
58
+ }
59
+
60
+ const removed = [
61
+ ...[...remotePages.keys()].map((route) => (route === "/" ? "index.html" : `${route.slice(1)}.html`)),
62
+ ...remoteAssets.keys(),
63
+ ];
64
+ return { added, modified, removed };
65
+ }
66
+
67
+ export async function status(args, { json = false } = {}) {
68
+ const dir = resolve(args.find((a) => !a.startsWith("-")) || ".");
69
+ const link = readLink(dir);
70
+ const slug = link?.site?.slug;
71
+
72
+ if (!slug) {
73
+ if (json) {
74
+ console.log(JSON.stringify({ linked: false }));
75
+ return;
76
+ }
77
+ err("This folder isn't linked to a site (no .htmlhost file).");
78
+ console.log(` ${cyan("htmlhost deploy")} ${dim("to publish it, or")} ${cyan("htmlhost clone <slug>")} ${dim("to start from a live site.")}`);
79
+ process.exit(1);
80
+ }
81
+
82
+ const mode = link.site.mode;
83
+ let remote;
84
+ try {
85
+ remote = await get(`/api/sites/pull?slug=${encodeURIComponent(slug)}${mode === "file" ? "&meta=true" : ""}`);
86
+ } catch (e) {
87
+ err(`Couldn't reach ${slug}.htmlhost.co: ${e.message}`);
88
+ process.exit(1);
89
+ }
90
+
91
+ const localVersion = link.sync?.version ?? null;
92
+ const liveVersion = remote.version ?? null;
93
+ const remoteChanged = localVersion != null && liveVersion != null && liveVersion > localVersion;
94
+ // Single-file deploys rewrite and inline assets, so file contents can't be
95
+ // compared one-to-one; report the version state only.
96
+ const changes = mode === "directory" ? compareWithRemote(dir, remote) : null;
97
+ const clean = changes && !changes.added.length && !changes.modified.length && !changes.removed.length;
98
+
99
+ if (json) {
100
+ console.log(JSON.stringify({
101
+ linked: true,
102
+ slug,
103
+ url: `https://${slug}.htmlhost.co`,
104
+ mode,
105
+ localVersion,
106
+ liveVersion,
107
+ remoteChanged,
108
+ changes,
109
+ }));
110
+ return;
111
+ }
112
+
113
+ console.log("");
114
+ info(`Linked to ${cyan(bold(`${slug}.htmlhost.co`))} ${dim(`(${mode === "file" ? "single file" : "folder"})`)}`);
115
+
116
+ if (localVersion == null) {
117
+ warn(`Live version ${liveVersion}. This folder has no sync record yet — ${cyan("htmlhost pull")} to sync.`);
118
+ } else if (remoteChanged) {
119
+ warn(`The live site changed since your last sync ${dim(`(v${localVersion} → v${liveVersion})`)}. Run ${cyan("htmlhost pull")} before deploying.`);
120
+ } else {
121
+ ok(`Live site unchanged since your last sync ${dim(`(v${liveVersion})`)}`);
122
+ }
123
+
124
+ if (!changes) {
125
+ console.log(` ${dim(`Single-file site — deploy with`)} ${cyan("htmlhost deploy <file>")}`);
126
+ console.log("");
127
+ return;
128
+ }
129
+
130
+ console.log("");
131
+ if (clean) {
132
+ ok("Nothing to deploy — the folder matches the live site.");
133
+ console.log("");
134
+ return;
135
+ }
136
+
137
+ // With the live site unchanged, every difference is a local edit; after a
138
+ // remote change a difference may be theirs, so say so.
139
+ console.log(` ${bold(remoteChanged ? "Differences from the live site:" : "Changes to deploy:")}`);
140
+ for (const f of changes.added) console.log(` ${green("+ new: ")} ${f}`);
141
+ for (const f of changes.modified) console.log(` ${yellow("~ modified:")} ${f}`);
142
+ for (const f of changes.removed) console.log(` ${red("- removed: ")} ${f} ${dim("(only on the live site; deploy deletes it)")}`);
143
+ console.log("");
144
+ console.log(
145
+ ` ${dim("Run")} ${cyan(remoteChanged ? "htmlhost pull" : "htmlhost deploy")} ${dim(
146
+ remoteChanged ? "to bring in the live changes first." : "to publish these changes."
147
+ )}`
148
+ );
149
+ console.log("");
150
+ }
@@ -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
+ }