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 +27 -0
- package/package.json +1 -1
- package/src/api.mjs +1 -0
- package/src/cli.mjs +31 -1
- package/src/commands/deploy.mjs +12 -11
- package/src/commands/status.mjs +150 -0
- package/src/commands/versions.mjs +130 -0
package/README.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# htmlhost
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/htmlhost-cli) [](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
package/src/api.mjs
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.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);
|
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
|
+
}
|
|
@@ -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
|
+
}
|