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 +11 -0
- package/package.json +1 -1
- package/src/cli.mjs +12 -1
- package/src/commands/deploy.mjs +12 -11
- package/src/commands/status.mjs +150 -0
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
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
|
+
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);
|
package/src/commands/deploy.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|