htmlhost-cli 2.7.1 → 2.8.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
@@ -87,6 +87,17 @@ checks if the remote site was modified (e.g. via the web editor) since your last
87
87
  or pull. If changes are detected, you'll be prompted to pull first, deploy anyway, or
88
88
  cancel. Use `--no-pull-check` to skip this check (useful in CI/CD).
89
89
 
90
+ ### `htmlhost status [dir] [--json]`
91
+
92
+ Like `git status` for a linked folder: the site it deploys to, whether the live
93
+ site changed since your last deploy/pull, and what the next `htmlhost deploy`
94
+ would add, modify, or remove. Read-only.
95
+
96
+ ```bash
97
+ htmlhost status
98
+ htmlhost status --json # machine-readable, for scripts and AI agents
99
+ ```
100
+
90
101
  ### `htmlhost pull [slug] [options]`
91
102
  Pull remote changes from an htmlhost.co site to the local project.
92
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.7.1",
3
+ "version": "2.8.0",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
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.8.0";
9
9
 
10
10
  const HELP = `
11
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -13,6 +13,7 @@ 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)
@@ -37,6 +38,9 @@ const HELP = `
37
38
  --no-copy Skip copying the URL to clipboard
38
39
  --json Output response as JSON
39
40
 
41
+ ${bold("Status options:")}
42
+ --json Output the status as JSON
43
+
40
44
  ${bold("Pull options:")}
41
45
  --force, -f Pull without confirmation prompt
42
46
  --dry-run Show what would change without writing
@@ -69,6 +73,7 @@ const HELP = `
69
73
  ${dim("$")} htmlhost upload assets/hero.png ${dim("# upload image, copy URL to clipboard")}
70
74
  ${dim("$")} htmlhost upload assets/ ${dim("# upload all files in directory")}
71
75
  ${dim("$")} htmlhost upload photo.jpg --open
76
+ ${dim("$")} htmlhost status ${dim("# what would deploy change?")}
72
77
  ${dim("$")} htmlhost pull ${dim("# pull remote changes")}
73
78
  ${dim("$")} htmlhost clone my-site ${dim("# clone into ./my-site/")}
74
79
  ${dim("$")} htmlhost delete old-project --force
@@ -134,6 +139,12 @@ export async function run(argv) {
134
139
  await upload(args, { json: jsonMode });
135
140
  break;
136
141
  }
142
+ case "status":
143
+ case "st": {
144
+ const { status } = await import("./commands/status.mjs");
145
+ await status(args, { json: jsonMode });
146
+ break;
147
+ }
137
148
  case "pull": {
138
149
  const { pull } = await import("./commands/pull.mjs");
139
150
  await pull(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
+ }