htmlhost-cli 2.1.8 → 2.2.1
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 +54 -0
- package/package.json +1 -1
- package/src/api.mjs +12 -0
- package/src/cli.mjs +23 -1
- package/src/commands/clone.mjs +178 -0
- package/src/commands/deploy.mjs +94 -9
- package/src/commands/pull.mjs +319 -0
package/README.md
CHANGED
|
@@ -79,8 +79,58 @@ automatically inlined or uploaded and rewritten to hosted URLs.
|
|
|
79
79
|
| `--title <title>` | Set the site title |
|
|
80
80
|
| `--new` | Force new site (ignore .htmlhost link) |
|
|
81
81
|
| `--no-assets` | Skip automatic asset processing (single-file only) |
|
|
82
|
+
| `--no-pull-check` | Skip remote change detection before deploy |
|
|
82
83
|
| `--json` | Output result as JSON (for CI/CD) |
|
|
83
84
|
|
|
85
|
+
**Sync check on re-deploy:** When re-deploying to a linked site, the CLI automatically
|
|
86
|
+
checks if the remote site was modified (e.g. via the web editor) since your last deploy
|
|
87
|
+
or pull. If changes are detected, you'll be prompted to pull first, deploy anyway, or
|
|
88
|
+
cancel. Use `--no-pull-check` to skip this check (useful in CI/CD).
|
|
89
|
+
|
|
90
|
+
### `htmlhost pull [slug] [options]`
|
|
91
|
+
Pull remote changes from an htmlhost.co site to the local project.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Pull changes for the linked site
|
|
95
|
+
htmlhost pull
|
|
96
|
+
|
|
97
|
+
# Pull a specific site
|
|
98
|
+
htmlhost pull my-site
|
|
99
|
+
|
|
100
|
+
# Pull without confirmation
|
|
101
|
+
htmlhost pull --force
|
|
102
|
+
|
|
103
|
+
# Show what would change without writing
|
|
104
|
+
htmlhost pull --dry-run
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
When you (or a collaborator) edit a site through the web editor, those changes live on
|
|
108
|
+
the server. `htmlhost pull` downloads them to your local project so you can continue
|
|
109
|
+
working locally.
|
|
110
|
+
|
|
111
|
+
| Option | Description |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `--force`, `-f` | Pull without confirmation prompt |
|
|
114
|
+
| `--dry-run` | Show what would change without writing files |
|
|
115
|
+
|
|
116
|
+
### `htmlhost clone <slug> [directory]`
|
|
117
|
+
Clone a deployed site into a local directory for continued development.
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
# Clone into a new directory named after the slug
|
|
121
|
+
htmlhost clone my-site
|
|
122
|
+
# → creates ./my-site/ with all files + .htmlhost link
|
|
123
|
+
|
|
124
|
+
# Clone into a specific directory
|
|
125
|
+
htmlhost clone my-site ./my-project
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
This is useful for sites that were originally created through the web editor and
|
|
129
|
+
haven't been deployed from the CLI before. After cloning, use `htmlhost deploy` to
|
|
130
|
+
push changes and `htmlhost pull` to sync remote edits.
|
|
131
|
+
|
|
132
|
+
Use `--force` to overwrite an existing non-empty directory.
|
|
133
|
+
|
|
84
134
|
### `htmlhost list`
|
|
85
135
|
List all your sites with titles, sizes, TTL, and expiry. Multi-page sites are marked with 📄.
|
|
86
136
|
|
|
@@ -115,3 +165,7 @@ Credentials are stored in `~/.htmlhostrc`:
|
|
|
115
165
|
## License
|
|
116
166
|
|
|
117
167
|
MIT
|
|
168
|
+
|
|
169
|
+
### Free publishing allowance
|
|
170
|
+
|
|
171
|
+
Free publishing is limited. Subscribe to Pro to continue creating unlimited sites at https://htmlhost.co/upgrade. See Pro benefits at https://htmlhost.co/pricing. Before your first free CLI deployment, sign in at https://htmlhost.co/new to complete browser verification. If verification is unavailable, contact hey@htmlhost.co for review.
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -112,3 +112,15 @@ export async function uploadFile(path, filePath, fileName, mimeType) {
|
|
|
112
112
|
}, `upload ${fileName}`);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Download a file from a URL as a Buffer.
|
|
117
|
+
* Used for fetching binary assets via presigned R2 download URLs.
|
|
118
|
+
* No auth header needed — presigned URLs are self-authenticating.
|
|
119
|
+
*/
|
|
120
|
+
export async function downloadBuffer(url) {
|
|
121
|
+
const res = await fetch(url);
|
|
122
|
+
if (!res.ok) throw new ApiError(`Download failed: HTTP ${res.status}`, res.status);
|
|
123
|
+
const arrayBuffer = await res.arrayBuffer();
|
|
124
|
+
return Buffer.from(arrayBuffer);
|
|
125
|
+
}
|
|
126
|
+
|
package/src/cli.mjs
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
import { bold, dim, cyan, err } from "./ui.mjs";
|
|
5
5
|
import { ApiError } from "./api.mjs";
|
|
6
6
|
|
|
7
|
-
const VERSION = "2.1
|
|
7
|
+
const VERSION = "2.2.1";
|
|
8
8
|
|
|
9
9
|
const HELP = `
|
|
10
10
|
${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
|
|
11
11
|
|
|
12
12
|
${bold("Usage:")}
|
|
13
13
|
${cyan("htmlhost deploy")} [file|dir] [opts] Deploy file or directory
|
|
14
|
+
${cyan("htmlhost pull")} [slug] [opts] Pull remote changes to local
|
|
15
|
+
${cyan("htmlhost clone")} <slug> [dir] Clone a site to a local directory
|
|
14
16
|
${cyan("htmlhost list")} List your sites
|
|
15
17
|
${cyan("htmlhost delete")} <slug> Delete a site
|
|
16
18
|
${cyan("htmlhost login")} Authenticate with an API token
|
|
@@ -24,6 +26,11 @@ const HELP = `
|
|
|
24
26
|
--title <title> Set the site title
|
|
25
27
|
--new Force a new site (ignore .htmlhost link)
|
|
26
28
|
--no-assets Skip auto-uploading local assets (single-file only)
|
|
29
|
+
--no-pull-check Skip remote change detection before deploy
|
|
30
|
+
|
|
31
|
+
${bold("Pull options:")}
|
|
32
|
+
--force, -f Pull without confirmation prompt
|
|
33
|
+
--dry-run Show what would change without writing
|
|
27
34
|
|
|
28
35
|
${bold("Delete options:")}
|
|
29
36
|
--force, -f Skip confirmation prompt
|
|
@@ -33,6 +40,7 @@ const HELP = `
|
|
|
33
40
|
Single file: deploys just that file (assets auto-inlined)
|
|
34
41
|
Linked: reads ${dim(".htmlhost")}, updates the same site
|
|
35
42
|
Local CSS, JS, images, and fonts are auto-uploaded
|
|
43
|
+
On re-deploy, checks for remote changes and offers to pull first
|
|
36
44
|
|
|
37
45
|
${bold("Examples:")}
|
|
38
46
|
${dim("$")} htmlhost deploy ${dim("# deploys cwd as a site")}
|
|
@@ -41,6 +49,10 @@ const HELP = `
|
|
|
41
49
|
${dim("$")} htmlhost deploy --ttl 30d ${dim("# deploy with 30-day TTL")}
|
|
42
50
|
${dim("$")} htmlhost deploy --new ${dim("# force new site")}
|
|
43
51
|
${dim("$")} htmlhost deploy --json ${dim("# JSON output for CI/CD")}
|
|
52
|
+
${dim("$")} htmlhost pull ${dim("# pull remote changes")}
|
|
53
|
+
${dim("$")} htmlhost pull my-site --force ${dim("# pull without prompting")}
|
|
54
|
+
${dim("$")} htmlhost clone my-site ${dim("# clone into ./my-site/")}
|
|
55
|
+
${dim("$")} htmlhost clone my-site ./proj ${dim("# clone into ./proj/")}
|
|
44
56
|
${dim("$")} htmlhost delete old-project --force
|
|
45
57
|
|
|
46
58
|
${bold("Update:")}
|
|
@@ -93,6 +105,16 @@ export async function run(argv) {
|
|
|
93
105
|
await deploy(args, { json: jsonMode });
|
|
94
106
|
break;
|
|
95
107
|
}
|
|
108
|
+
case "pull": {
|
|
109
|
+
const { pull } = await import("./commands/pull.mjs");
|
|
110
|
+
await pull(args);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case "clone": {
|
|
114
|
+
const { clone } = await import("./commands/clone.mjs");
|
|
115
|
+
await clone(args);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
96
118
|
case "list":
|
|
97
119
|
case "ls": {
|
|
98
120
|
const { list } = await import("./commands/list.mjs");
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* htmlhost clone <slug> [dir] [--force]
|
|
3
|
+
*
|
|
4
|
+
* Clone a deployed htmlhost.co site into a local directory.
|
|
5
|
+
* Creates a new folder (named after the slug by default), downloads all
|
|
6
|
+
* pages and assets, and sets up the .htmlhost link file so future deploys
|
|
7
|
+
* update the same site.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
10
|
+
import { join, dirname, resolve } from "node:path";
|
|
11
|
+
import { get, downloadBuffer } from "../api.mjs";
|
|
12
|
+
import {
|
|
13
|
+
ok,
|
|
14
|
+
err,
|
|
15
|
+
info,
|
|
16
|
+
warn,
|
|
17
|
+
cyan,
|
|
18
|
+
dim,
|
|
19
|
+
bold,
|
|
20
|
+
green,
|
|
21
|
+
yellow,
|
|
22
|
+
confirm,
|
|
23
|
+
formatBytes,
|
|
24
|
+
} from "../ui.mjs";
|
|
25
|
+
|
|
26
|
+
const LINK_FILE = ".htmlhost";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Map a page path to a local file path.
|
|
30
|
+
* "/" → "index.html", "/about" → "about.html"
|
|
31
|
+
*/
|
|
32
|
+
function pagePathToFile(pagePath) {
|
|
33
|
+
if (pagePath === "/") return "index.html";
|
|
34
|
+
const clean = pagePath.replace(/^\//, "");
|
|
35
|
+
if (!clean.includes(".")) return `${clean}.html`;
|
|
36
|
+
return clean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Main clone command.
|
|
41
|
+
*/
|
|
42
|
+
export async function clone(args) {
|
|
43
|
+
const positionals = args.filter((a) => !a.startsWith("--"));
|
|
44
|
+
const slug = positionals[0];
|
|
45
|
+
const targetDir = positionals[1]; // optional — defaults to slug name
|
|
46
|
+
const force = args.includes("--force") || args.includes("-f");
|
|
47
|
+
|
|
48
|
+
if (!slug) {
|
|
49
|
+
err(
|
|
50
|
+
"Usage: htmlhost clone <slug> [directory]\n" +
|
|
51
|
+
` ${cyan("htmlhost clone my-site")} ${dim("# clones into ./my-site/")}\n` +
|
|
52
|
+
` ${cyan("htmlhost clone my-site ./proj")} ${dim("# clones into ./proj/")}`
|
|
53
|
+
);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Strip .htmlhost.co suffix if provided
|
|
58
|
+
const cleanSlug = slug.endsWith(".htmlhost.co")
|
|
59
|
+
? slug.replace(".htmlhost.co", "")
|
|
60
|
+
: slug;
|
|
61
|
+
|
|
62
|
+
const destDir = resolve(targetDir || cleanSlug);
|
|
63
|
+
|
|
64
|
+
// Check if directory already exists and is non-empty
|
|
65
|
+
if (existsSync(destDir)) {
|
|
66
|
+
try {
|
|
67
|
+
const entries = readdirSync(destDir);
|
|
68
|
+
if (entries.length > 0 && !force) {
|
|
69
|
+
err(
|
|
70
|
+
`Directory ${cyan(destDir)} already exists and is not empty.\n` +
|
|
71
|
+
` Use ${cyan("--force")} to overwrite, or ${cyan("htmlhost pull")} to sync an existing project.`
|
|
72
|
+
);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Not a directory — error
|
|
77
|
+
err(`${destDir} exists but is not a directory.`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log("");
|
|
83
|
+
info(`Cloning ${cyan(bold(cleanSlug + ".htmlhost.co"))}…`);
|
|
84
|
+
|
|
85
|
+
// Fetch remote content
|
|
86
|
+
let data;
|
|
87
|
+
try {
|
|
88
|
+
data = await get(`/api/sites/pull?slug=${encodeURIComponent(cleanSlug)}`);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
err(`Failed to fetch site: ${e.message}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const pageCount = (data.pages || []).length;
|
|
95
|
+
const assetCount = (data.assets || []).length;
|
|
96
|
+
const totalFiles = pageCount + assetCount;
|
|
97
|
+
|
|
98
|
+
console.log(
|
|
99
|
+
` ${dim(`Version ${data.version} · ${pageCount} page${pageCount !== 1 ? "s" : ""} · ${assetCount} asset${assetCount !== 1 ? "s" : ""}`)}`
|
|
100
|
+
);
|
|
101
|
+
console.log("");
|
|
102
|
+
|
|
103
|
+
// Create destination directory
|
|
104
|
+
mkdirSync(destDir, { recursive: true });
|
|
105
|
+
|
|
106
|
+
let written = 0;
|
|
107
|
+
|
|
108
|
+
// --- Write HTML pages ---
|
|
109
|
+
for (const page of data.pages || []) {
|
|
110
|
+
const localFile = pagePathToFile(page.path);
|
|
111
|
+
const localPath = join(destDir, localFile);
|
|
112
|
+
mkdirSync(dirname(localPath), { recursive: true });
|
|
113
|
+
writeFileSync(localPath, page.html, "utf8");
|
|
114
|
+
written++;
|
|
115
|
+
ok(`${cyan(localFile)} ${dim(`(${formatBytes(page.sizeBytes || Buffer.byteLength(page.html))})`)}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- Write assets ---
|
|
119
|
+
for (const asset of data.assets || []) {
|
|
120
|
+
const localPath = join(destDir, asset.path);
|
|
121
|
+
mkdirSync(dirname(localPath), { recursive: true });
|
|
122
|
+
|
|
123
|
+
if (asset.content !== null && asset.content !== undefined) {
|
|
124
|
+
// Text asset — write inline content
|
|
125
|
+
writeFileSync(localPath, asset.content, "utf8");
|
|
126
|
+
} else if (asset.downloadUrl) {
|
|
127
|
+
// Binary asset — download
|
|
128
|
+
const buf = await downloadBuffer(asset.downloadUrl);
|
|
129
|
+
writeFileSync(localPath, buf);
|
|
130
|
+
} else {
|
|
131
|
+
warn(`Skipping ${cyan(asset.path)} — no content available`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
written++;
|
|
136
|
+
ok(`${cyan(asset.path)} ${dim(`(${formatBytes(asset.sizeBytes)})`)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Write .htmlhost link file ---
|
|
140
|
+
const isMultiPage = pageCount > 1;
|
|
141
|
+
const linkData = isMultiPage
|
|
142
|
+
? {
|
|
143
|
+
multipage: {
|
|
144
|
+
slug: cleanSlug,
|
|
145
|
+
url: `${cleanSlug}.htmlhost.co`,
|
|
146
|
+
pageCount,
|
|
147
|
+
},
|
|
148
|
+
sync: {
|
|
149
|
+
version: data.version,
|
|
150
|
+
updatedAt: data.updatedAt,
|
|
151
|
+
pulledAt: new Date().toISOString(),
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
: {
|
|
155
|
+
slug: cleanSlug,
|
|
156
|
+
url: `${cleanSlug}.htmlhost.co`,
|
|
157
|
+
sync: {
|
|
158
|
+
version: data.version,
|
|
159
|
+
updatedAt: data.updatedAt,
|
|
160
|
+
pulledAt: new Date().toISOString(),
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const linkPath = join(destDir, LINK_FILE);
|
|
165
|
+
writeFileSync(linkPath, JSON.stringify(linkData, null, 2) + "\n", "utf8");
|
|
166
|
+
|
|
167
|
+
// --- Done ---
|
|
168
|
+
console.log("");
|
|
169
|
+
console.log(` ${green("━".repeat(40))}`);
|
|
170
|
+
ok(
|
|
171
|
+
`Cloned ${bold(String(written))} file${written === 1 ? "" : "s"} into ${cyan(destDir)}`
|
|
172
|
+
);
|
|
173
|
+
console.log(` ${dim(`Linked → .htmlhost · ${cleanSlug}.htmlhost.co`)}`);
|
|
174
|
+
console.log("");
|
|
175
|
+
console.log(` ${dim("Deploy changes:")} ${cyan("htmlhost deploy")}`);
|
|
176
|
+
console.log(` ${dim("Pull updates:")} ${cyan("htmlhost pull")}`);
|
|
177
|
+
console.log("");
|
|
178
|
+
}
|
package/src/commands/deploy.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { createInterface } from "node:readline";
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { post } from "../api.mjs";
|
|
6
6
|
import { ok, err, info, cyan, dim, bold, yellow, green, formatBytes, mimeFromExt } from "../ui.mjs";
|
|
7
|
+
import { checkRemoteChanges } from "./pull.mjs";
|
|
7
8
|
|
|
8
9
|
const LINK_FILE = ".htmlhost";
|
|
9
10
|
|
|
@@ -115,6 +116,7 @@ export async function deploy(args, { json = false } = {}) {
|
|
|
115
116
|
const slug = getFlag(args, "--slug");
|
|
116
117
|
const forceNew = args.includes("--new");
|
|
117
118
|
const skipAssets = args.includes("--no-assets");
|
|
119
|
+
const skipPullCheck = args.includes("--no-pull-check");
|
|
118
120
|
|
|
119
121
|
// Resolve target
|
|
120
122
|
const targetPath = resolve(target || ".");
|
|
@@ -129,17 +131,17 @@ export async function deploy(args, { json = false } = {}) {
|
|
|
129
131
|
|
|
130
132
|
// --- Directory deploy (default) ---
|
|
131
133
|
if (stat.isDirectory()) {
|
|
132
|
-
return deployDirectory(targetPath, { ttl, title, slug, forceNew, json });
|
|
134
|
+
return deployDirectory(targetPath, { ttl, title, slug, forceNew, json, skipPullCheck });
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
// --- Single-file deploy ---
|
|
136
|
-
await deploySingleFile(target || "index.html", { ttl, title, forceNew, skipAssets, slug, json });
|
|
138
|
+
await deploySingleFile(target || "index.html", { ttl, title, forceNew, skipAssets, slug, json, skipPullCheck });
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
/**
|
|
140
142
|
* Deploy a single HTML file (existing behavior preserved).
|
|
141
143
|
*/
|
|
142
|
-
async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug, json }) {
|
|
144
|
+
async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug, json, skipPullCheck }) {
|
|
143
145
|
const filePath = resolve(file);
|
|
144
146
|
const projectDir = dirname(filePath);
|
|
145
147
|
const fileName = basename(filePath);
|
|
@@ -228,6 +230,12 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
|
|
|
228
230
|
|
|
229
231
|
info(slug ? `Re-deploying to ${cyan(slug)}…` : "Deploying new site…");
|
|
230
232
|
|
|
233
|
+
// --- Pre-deploy remote change check (single-file) ---
|
|
234
|
+
if (slug && !skipPullCheck) {
|
|
235
|
+
const aborted = await preDeployPullCheck(slug, projectDir);
|
|
236
|
+
if (aborted) return;
|
|
237
|
+
}
|
|
238
|
+
|
|
231
239
|
const body = { html };
|
|
232
240
|
if (ttl) body.ttl = ttl;
|
|
233
241
|
if (slug) body.slug = slug;
|
|
@@ -241,6 +249,11 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
|
|
|
241
249
|
slug: data.slug,
|
|
242
250
|
url: data.url,
|
|
243
251
|
...(existingLink?.onRedeploy ? { onRedeploy: existingLink.onRedeploy } : {}),
|
|
252
|
+
sync: {
|
|
253
|
+
version: data.version,
|
|
254
|
+
updatedAt: new Date().toISOString(),
|
|
255
|
+
deployedAt: new Date().toISOString(),
|
|
256
|
+
},
|
|
244
257
|
});
|
|
245
258
|
|
|
246
259
|
if (json) {
|
|
@@ -271,7 +284,7 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
|
|
|
271
284
|
* Non-HTML files (CSS, JS, images, fonts) are uploaded via the media endpoint
|
|
272
285
|
* and included as pages with their hosted URLs.
|
|
273
286
|
*/
|
|
274
|
-
async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceNew, json }) {
|
|
287
|
+
async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceNew, json, skipPullCheck }) {
|
|
275
288
|
// Find all files recursively
|
|
276
289
|
const allFiles = findAllFiles(dirPath, dirPath);
|
|
277
290
|
|
|
@@ -303,6 +316,12 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
303
316
|
console.log("");
|
|
304
317
|
if (existingSlug) {
|
|
305
318
|
info(`Re-deploying to ${cyan(bold(existingSlug + ".htmlhost.co"))}`);
|
|
319
|
+
|
|
320
|
+
// --- Pre-deploy remote change check ---
|
|
321
|
+
if (!skipPullCheck) {
|
|
322
|
+
const aborted = await preDeployPullCheck(existingSlug, dirPath);
|
|
323
|
+
if (aborted) return;
|
|
324
|
+
}
|
|
306
325
|
} else {
|
|
307
326
|
info(`Deploying ${cyan(bold(basename(dirPath) || "."))} as a new site`);
|
|
308
327
|
}
|
|
@@ -475,6 +494,11 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
475
494
|
url: data.url,
|
|
476
495
|
pageCount: data.pageCount || pagePayloads.length,
|
|
477
496
|
},
|
|
497
|
+
sync: {
|
|
498
|
+
version: data.version,
|
|
499
|
+
updatedAt: new Date().toISOString(),
|
|
500
|
+
deployedAt: new Date().toISOString(),
|
|
501
|
+
},
|
|
478
502
|
});
|
|
479
503
|
|
|
480
504
|
if (json) {
|
|
@@ -501,6 +525,62 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
501
525
|
}
|
|
502
526
|
}
|
|
503
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Pre-deploy check: compare local sync version with remote version.
|
|
530
|
+
* If remote is newer, prompt the user to pull, deploy anyway, or cancel.
|
|
531
|
+
*
|
|
532
|
+
* Returns true if the deploy should be aborted (user cancelled or pulled).
|
|
533
|
+
* Returns false to continue the deploy.
|
|
534
|
+
*/
|
|
535
|
+
async function preDeployPullCheck(slug, projectDir) {
|
|
536
|
+
const link = readLink(projectDir);
|
|
537
|
+
const localVersion = link?.sync?.version;
|
|
538
|
+
|
|
539
|
+
// If we've never synced, skip the check — nothing to compare against
|
|
540
|
+
if (!localVersion) return false;
|
|
541
|
+
|
|
542
|
+
const remote = await checkRemoteChanges(slug);
|
|
543
|
+
if (!remote) return false; // network error — don't block the deploy
|
|
544
|
+
|
|
545
|
+
if (remote.version <= localVersion) return false; // up to date
|
|
546
|
+
|
|
547
|
+
// Remote has changes!
|
|
548
|
+
console.log("");
|
|
549
|
+
console.log(` ${yellow("!")} ${bold("Remote changes detected")} — ${cyan(slug + ".htmlhost.co")} was modified since your last deploy.`);
|
|
550
|
+
console.log(` ${dim(`Remote version: ${remote.version} · Local version: ${localVersion}`)}`);
|
|
551
|
+
console.log("");
|
|
552
|
+
|
|
553
|
+
const choice = await promptChoice(
|
|
554
|
+
"What would you like to do?",
|
|
555
|
+
[
|
|
556
|
+
"Pull changes first, then deploy",
|
|
557
|
+
`Deploy anyway ${dim("(overwrites remote changes)")}`,
|
|
558
|
+
"Cancel",
|
|
559
|
+
]
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
switch (choice) {
|
|
563
|
+
case 0: {
|
|
564
|
+
// Pull first
|
|
565
|
+
const { pull } = await import("./pull.mjs");
|
|
566
|
+
await pull([slug, "--force"]);
|
|
567
|
+
// After pulling, the user needs to re-run deploy to pick up the merged content
|
|
568
|
+
console.log("");
|
|
569
|
+
info(`${dim("Run")} ${cyan("htmlhost deploy")} ${dim("again to deploy with the pulled changes.")}`);
|
|
570
|
+
console.log("");
|
|
571
|
+
return true; // abort current deploy
|
|
572
|
+
}
|
|
573
|
+
case 1:
|
|
574
|
+
// Deploy anyway — continue
|
|
575
|
+
return false;
|
|
576
|
+
case 2:
|
|
577
|
+
case -1:
|
|
578
|
+
default:
|
|
579
|
+
console.log(" Cancelled.");
|
|
580
|
+
process.exit(0);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
504
584
|
/**
|
|
505
585
|
* Recursively find all files in a directory.
|
|
506
586
|
* Skips ignored files/directories.
|
|
@@ -529,12 +609,17 @@ function findAllFiles(baseDir, currentDir) {
|
|
|
529
609
|
}
|
|
530
610
|
}
|
|
531
611
|
|
|
532
|
-
// Sort: index.html first, then HTML files, then others alphabetically
|
|
612
|
+
// Sort: root index.html first, then any nested index.html, then HTML files, then others alphabetically
|
|
533
613
|
results.sort((a, b) => {
|
|
534
|
-
const
|
|
535
|
-
const
|
|
536
|
-
if (
|
|
537
|
-
if (
|
|
614
|
+
const aIsRootIndex = a.relativePath === "index.html";
|
|
615
|
+
const bIsRootIndex = b.relativePath === "index.html";
|
|
616
|
+
if (aIsRootIndex) return -1;
|
|
617
|
+
if (bIsRootIndex) return 1;
|
|
618
|
+
|
|
619
|
+
const aIsIndex = basename(a.relativePath) === "index.html";
|
|
620
|
+
const bIsIndex = basename(b.relativePath) === "index.html";
|
|
621
|
+
if (aIsIndex && !bIsIndex) return -1;
|
|
622
|
+
if (!aIsIndex && bIsIndex) return 1;
|
|
538
623
|
|
|
539
624
|
const aIsHtml = a.relativePath.endsWith(".html");
|
|
540
625
|
const bIsHtml = b.relativePath.endsWith(".html");
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* htmlhost pull [slug] [--force] [--dry-run]
|
|
3
|
+
*
|
|
4
|
+
* Pull remote changes from an htmlhost.co site to the local project.
|
|
5
|
+
* Reads .htmlhost to find the linked slug, or accepts a slug argument.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "node:fs";
|
|
8
|
+
import { join, dirname, resolve, basename } from "node:path";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { get, downloadBuffer } from "../api.mjs";
|
|
11
|
+
import {
|
|
12
|
+
ok,
|
|
13
|
+
err,
|
|
14
|
+
info,
|
|
15
|
+
warn,
|
|
16
|
+
cyan,
|
|
17
|
+
dim,
|
|
18
|
+
bold,
|
|
19
|
+
green,
|
|
20
|
+
yellow,
|
|
21
|
+
confirm,
|
|
22
|
+
formatBytes,
|
|
23
|
+
} from "../ui.mjs";
|
|
24
|
+
|
|
25
|
+
const LINK_FILE = ".htmlhost";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read the project link from .htmlhost
|
|
29
|
+
*/
|
|
30
|
+
function readLink(dir) {
|
|
31
|
+
const linkPath = join(dir, LINK_FILE);
|
|
32
|
+
if (!existsSync(linkPath)) return null;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(readFileSync(linkPath, "utf8"));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Save project link to .htmlhost
|
|
42
|
+
*/
|
|
43
|
+
function writeLink(dir, data) {
|
|
44
|
+
const linkPath = join(dir, LINK_FILE);
|
|
45
|
+
writeFileSync(linkPath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Map a page path back to a local file path.
|
|
50
|
+
* "/" → "index.html", "/about" → "about.html", "/blog" → "blog/index.html" or "blog.html"
|
|
51
|
+
*/
|
|
52
|
+
function pagePathToFile(pagePath) {
|
|
53
|
+
if (pagePath === "/") return "index.html";
|
|
54
|
+
const clean = pagePath.replace(/^\//, "");
|
|
55
|
+
// If it looks like a directory (no extension), append .html
|
|
56
|
+
if (!clean.includes(".")) return `${clean}.html`;
|
|
57
|
+
return clean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compare local file content with remote content.
|
|
62
|
+
* Returns null if identical, or an object describing the diff.
|
|
63
|
+
*/
|
|
64
|
+
function diffFile(localPath, remoteContent) {
|
|
65
|
+
if (!existsSync(localPath)) {
|
|
66
|
+
return { type: "new", sizeBytes: Buffer.byteLength(remoteContent) };
|
|
67
|
+
}
|
|
68
|
+
const localContent = readFileSync(localPath, "utf8");
|
|
69
|
+
if (localContent === remoteContent) return null;
|
|
70
|
+
return {
|
|
71
|
+
type: "modified",
|
|
72
|
+
localSize: Buffer.byteLength(localContent),
|
|
73
|
+
remoteSize: Buffer.byteLength(remoteContent),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Main pull command.
|
|
80
|
+
*/
|
|
81
|
+
export async function pull(args) {
|
|
82
|
+
const slugArg = args.find((a) => !a.startsWith("--"));
|
|
83
|
+
const force = args.includes("--force") || args.includes("-f");
|
|
84
|
+
const dryRun = args.includes("--dry-run");
|
|
85
|
+
|
|
86
|
+
const projectDir = resolve(".");
|
|
87
|
+
const link = readLink(projectDir);
|
|
88
|
+
|
|
89
|
+
// Resolve slug
|
|
90
|
+
let slug = slugArg;
|
|
91
|
+
if (!slug) {
|
|
92
|
+
if (link?.multipage?.slug) {
|
|
93
|
+
slug = link.multipage.slug;
|
|
94
|
+
} else if (link?.slug) {
|
|
95
|
+
slug = link.slug;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!slug) {
|
|
100
|
+
err(
|
|
101
|
+
"No site linked. Provide a slug or deploy first:\n" +
|
|
102
|
+
` ${cyan("htmlhost pull <slug>")}\n` +
|
|
103
|
+
` ${cyan("htmlhost deploy")} ${dim("(creates .htmlhost link)")}`
|
|
104
|
+
);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Strip .htmlhost.co suffix if provided
|
|
109
|
+
if (slug.endsWith(".htmlhost.co")) {
|
|
110
|
+
slug = slug.replace(".htmlhost.co", "");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log("");
|
|
114
|
+
info(`Pulling from ${cyan(bold(slug + ".htmlhost.co"))}…`);
|
|
115
|
+
|
|
116
|
+
// Fetch remote content
|
|
117
|
+
let data;
|
|
118
|
+
try {
|
|
119
|
+
data = await get(`/api/sites/pull?slug=${encodeURIComponent(slug)}`);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
err(`Failed to fetch site: ${e.message}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log(` ${dim(`Version ${data.version} · Updated ${relTime(data.updatedAt)}`)}`);
|
|
126
|
+
console.log("");
|
|
127
|
+
|
|
128
|
+
// Build list of changes
|
|
129
|
+
const changes = [];
|
|
130
|
+
|
|
131
|
+
// --- Pages (HTML) ---
|
|
132
|
+
for (const page of data.pages || []) {
|
|
133
|
+
const localFile = pagePathToFile(page.path);
|
|
134
|
+
const localPath = join(projectDir, localFile);
|
|
135
|
+
const diff = diffFile(localPath, page.html);
|
|
136
|
+
|
|
137
|
+
if (diff) {
|
|
138
|
+
changes.push({
|
|
139
|
+
file: localFile,
|
|
140
|
+
...diff,
|
|
141
|
+
write: () => {
|
|
142
|
+
mkdirSync(dirname(localPath), { recursive: true });
|
|
143
|
+
writeFileSync(localPath, page.html, "utf8");
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- Assets (CSS/JS/binary) ---
|
|
150
|
+
for (const asset of data.assets || []) {
|
|
151
|
+
const localPath = join(projectDir, asset.path);
|
|
152
|
+
|
|
153
|
+
if (asset.content !== null && asset.content !== undefined) {
|
|
154
|
+
// Text asset — inline content
|
|
155
|
+
const diff = diffFile(localPath, asset.content);
|
|
156
|
+
if (diff) {
|
|
157
|
+
changes.push({
|
|
158
|
+
file: asset.path,
|
|
159
|
+
...diff,
|
|
160
|
+
write: () => {
|
|
161
|
+
mkdirSync(dirname(localPath), { recursive: true });
|
|
162
|
+
writeFileSync(localPath, asset.content, "utf8");
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
} else if (asset.downloadUrl) {
|
|
167
|
+
// Binary asset — need to download
|
|
168
|
+
// Check if local file exists and compare hash/size
|
|
169
|
+
let needsDownload = false;
|
|
170
|
+
let changeType = "new";
|
|
171
|
+
|
|
172
|
+
if (!existsSync(localPath)) {
|
|
173
|
+
needsDownload = true;
|
|
174
|
+
changeType = "new";
|
|
175
|
+
} else if (asset.hash) {
|
|
176
|
+
// Compare hash
|
|
177
|
+
const localBuffer = readFileSync(localPath);
|
|
178
|
+
const localHash = createHash("sha256")
|
|
179
|
+
.update(localBuffer)
|
|
180
|
+
.digest("hex");
|
|
181
|
+
if (localHash !== asset.hash) {
|
|
182
|
+
needsDownload = true;
|
|
183
|
+
changeType = "modified";
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
// Fallback: compare size
|
|
187
|
+
try {
|
|
188
|
+
const localSize = statSync(localPath).size;
|
|
189
|
+
if (localSize !== asset.sizeBytes) {
|
|
190
|
+
needsDownload = true;
|
|
191
|
+
changeType = "modified";
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
needsDownload = true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (needsDownload) {
|
|
199
|
+
changes.push({
|
|
200
|
+
file: asset.path,
|
|
201
|
+
type: changeType,
|
|
202
|
+
sizeBytes: asset.sizeBytes,
|
|
203
|
+
write: async () => {
|
|
204
|
+
const buf = await downloadBuffer(asset.downloadUrl);
|
|
205
|
+
mkdirSync(dirname(localPath), { recursive: true });
|
|
206
|
+
writeFileSync(localPath, buf);
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Show results ---
|
|
214
|
+
if (changes.length === 0) {
|
|
215
|
+
ok("Already up to date — no changes to pull.");
|
|
216
|
+
console.log("");
|
|
217
|
+
// Update sync state even if nothing changed
|
|
218
|
+
updateSyncState(projectDir, link, slug, data.version, data.updatedAt);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Group changes by type
|
|
223
|
+
const newFiles = changes.filter((c) => c.type === "new");
|
|
224
|
+
const modifiedFiles = changes.filter((c) => c.type === "modified");
|
|
225
|
+
|
|
226
|
+
for (const c of newFiles) {
|
|
227
|
+
console.log(` ${green("+")} ${bold("New:")} ${cyan(c.file)} ${dim(`(${formatBytes(c.sizeBytes)})`)}`);
|
|
228
|
+
}
|
|
229
|
+
for (const c of modifiedFiles) {
|
|
230
|
+
console.log(` ${yellow("~")} ${bold("Modified:")} ${cyan(c.file)}`);
|
|
231
|
+
}
|
|
232
|
+
console.log("");
|
|
233
|
+
|
|
234
|
+
const summary = [];
|
|
235
|
+
if (newFiles.length > 0) summary.push(`${newFiles.length} new`);
|
|
236
|
+
if (modifiedFiles.length > 0) summary.push(`${modifiedFiles.length} modified`);
|
|
237
|
+
info(`${summary.join(", ")} file${changes.length === 1 ? "" : "s"} to pull`);
|
|
238
|
+
console.log("");
|
|
239
|
+
|
|
240
|
+
if (dryRun) {
|
|
241
|
+
info(`${dim("Dry run — no files were written.")}`);
|
|
242
|
+
console.log("");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Confirm
|
|
247
|
+
if (!force) {
|
|
248
|
+
const confirmed = await confirm("Pull these changes?");
|
|
249
|
+
if (!confirmed) {
|
|
250
|
+
console.log(" Cancelled.");
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
console.log("");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Write files
|
|
257
|
+
let written = 0;
|
|
258
|
+
for (const c of changes) {
|
|
259
|
+
await c.write();
|
|
260
|
+
written++;
|
|
261
|
+
ok(`${cyan(c.file)}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Update sync state
|
|
265
|
+
updateSyncState(projectDir, link, slug, data.version, data.updatedAt);
|
|
266
|
+
|
|
267
|
+
console.log("");
|
|
268
|
+
ok(
|
|
269
|
+
`Pulled ${bold(String(written))} file${written === 1 ? "" : "s"} from ${cyan(slug + ".htmlhost.co")}`
|
|
270
|
+
);
|
|
271
|
+
console.log("");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Update the .htmlhost sync state after a pull or deploy.
|
|
276
|
+
*/
|
|
277
|
+
function updateSyncState(projectDir, existingLink, slug, version, updatedAt) {
|
|
278
|
+
const link = existingLink || {};
|
|
279
|
+
link.sync = {
|
|
280
|
+
version,
|
|
281
|
+
updatedAt,
|
|
282
|
+
pulledAt: new Date().toISOString(),
|
|
283
|
+
};
|
|
284
|
+
writeLink(projectDir, link);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Check if the remote site has been modified since the last deploy/pull.
|
|
289
|
+
* Used by the deploy command for pre-deploy diff checking.
|
|
290
|
+
*
|
|
291
|
+
* Returns null if up-to-date, or the remote metadata if changes exist.
|
|
292
|
+
*/
|
|
293
|
+
export async function checkRemoteChanges(slug) {
|
|
294
|
+
try {
|
|
295
|
+
const data = await get(
|
|
296
|
+
`/api/sites/pull?slug=${encodeURIComponent(slug)}&meta=true`
|
|
297
|
+
);
|
|
298
|
+
return data;
|
|
299
|
+
} catch {
|
|
300
|
+
// Network error or auth issue — don't block deploy
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Format relative time for display.
|
|
307
|
+
*/
|
|
308
|
+
function relTime(iso) {
|
|
309
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
310
|
+
if (diff < 0) return "just now";
|
|
311
|
+
const secs = Math.floor(diff / 1000);
|
|
312
|
+
if (secs < 60) return "just now";
|
|
313
|
+
const mins = Math.floor(secs / 60);
|
|
314
|
+
if (mins < 60) return `${mins}m ago`;
|
|
315
|
+
const hours = Math.floor(mins / 60);
|
|
316
|
+
if (hours < 24) return `${hours}h ago`;
|
|
317
|
+
const days = Math.floor(hours / 24);
|
|
318
|
+
return `${days}d ago`;
|
|
319
|
+
}
|