htmlhost-cli 2.1.6 → 2.2.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 +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 +119 -23
- 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.
|
|
7
|
+
const VERSION = "2.2.0";
|
|
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
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, statSync, existsSync, readdirSync } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync, statSync, existsSync, readdirSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { basename, resolve, dirname, join, extname } from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import { post } from "../api.mjs";
|
|
5
6
|
import { ok, err, info, cyan, dim, bold, yellow, green, formatBytes, mimeFromExt } from "../ui.mjs";
|
|
7
|
+
import { checkRemoteChanges } from "./pull.mjs";
|
|
6
8
|
|
|
7
9
|
const LINK_FILE = ".htmlhost";
|
|
8
10
|
|
|
@@ -114,6 +116,7 @@ export async function deploy(args, { json = false } = {}) {
|
|
|
114
116
|
const slug = getFlag(args, "--slug");
|
|
115
117
|
const forceNew = args.includes("--new");
|
|
116
118
|
const skipAssets = args.includes("--no-assets");
|
|
119
|
+
const skipPullCheck = args.includes("--no-pull-check");
|
|
117
120
|
|
|
118
121
|
// Resolve target
|
|
119
122
|
const targetPath = resolve(target || ".");
|
|
@@ -128,17 +131,17 @@ export async function deploy(args, { json = false } = {}) {
|
|
|
128
131
|
|
|
129
132
|
// --- Directory deploy (default) ---
|
|
130
133
|
if (stat.isDirectory()) {
|
|
131
|
-
return deployDirectory(targetPath, { ttl, title, slug, forceNew, json });
|
|
134
|
+
return deployDirectory(targetPath, { ttl, title, slug, forceNew, json, skipPullCheck });
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
// --- Single-file deploy ---
|
|
135
|
-
await deploySingleFile(target || "index.html", { ttl, title, forceNew, skipAssets, slug, json });
|
|
138
|
+
await deploySingleFile(target || "index.html", { ttl, title, forceNew, skipAssets, slug, json, skipPullCheck });
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
/**
|
|
139
142
|
* Deploy a single HTML file (existing behavior preserved).
|
|
140
143
|
*/
|
|
141
|
-
async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug, json }) {
|
|
144
|
+
async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug, json, skipPullCheck }) {
|
|
142
145
|
const filePath = resolve(file);
|
|
143
146
|
const projectDir = dirname(filePath);
|
|
144
147
|
const fileName = basename(filePath);
|
|
@@ -227,6 +230,12 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
|
|
|
227
230
|
|
|
228
231
|
info(slug ? `Re-deploying to ${cyan(slug)}…` : "Deploying new site…");
|
|
229
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
|
+
|
|
230
239
|
const body = { html };
|
|
231
240
|
if (ttl) body.ttl = ttl;
|
|
232
241
|
if (slug) body.slug = slug;
|
|
@@ -240,6 +249,11 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
|
|
|
240
249
|
slug: data.slug,
|
|
241
250
|
url: data.url,
|
|
242
251
|
...(existingLink?.onRedeploy ? { onRedeploy: existingLink.onRedeploy } : {}),
|
|
252
|
+
sync: {
|
|
253
|
+
version: data.version,
|
|
254
|
+
updatedAt: new Date().toISOString(),
|
|
255
|
+
deployedAt: new Date().toISOString(),
|
|
256
|
+
},
|
|
243
257
|
});
|
|
244
258
|
|
|
245
259
|
if (json) {
|
|
@@ -270,7 +284,7 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
|
|
|
270
284
|
* Non-HTML files (CSS, JS, images, fonts) are uploaded via the media endpoint
|
|
271
285
|
* and included as pages with their hosted URLs.
|
|
272
286
|
*/
|
|
273
|
-
async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceNew, json }) {
|
|
287
|
+
async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceNew, json, skipPullCheck }) {
|
|
274
288
|
// Find all files recursively
|
|
275
289
|
const allFiles = findAllFiles(dirPath, dirPath);
|
|
276
290
|
|
|
@@ -302,6 +316,12 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
302
316
|
console.log("");
|
|
303
317
|
if (existingSlug) {
|
|
304
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
|
+
}
|
|
305
325
|
} else {
|
|
306
326
|
info(`Deploying ${cyan(bold(basename(dirPath) || "."))} as a new site`);
|
|
307
327
|
}
|
|
@@ -384,11 +404,18 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
384
404
|
slug: existingSlug || undefined,
|
|
385
405
|
isNewSite: !existingSlug,
|
|
386
406
|
html: pagePayloads[0]?.html || "",
|
|
387
|
-
assets: assetFiles.map(f =>
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
407
|
+
assets: assetFiles.map(f => {
|
|
408
|
+
const buffer = readFileSync(f.filePath);
|
|
409
|
+
const hash = createHash('sha256').update(buffer).digest('hex');
|
|
410
|
+
// Store buffer on file object to avoid reading again
|
|
411
|
+
f.buffer = buffer;
|
|
412
|
+
return {
|
|
413
|
+
path: f.relativePath,
|
|
414
|
+
sizeBytes: f.size,
|
|
415
|
+
mimeType: mimeFromExt(basename(f.filePath)),
|
|
416
|
+
hash
|
|
417
|
+
};
|
|
418
|
+
})
|
|
392
419
|
};
|
|
393
420
|
|
|
394
421
|
let urlData;
|
|
@@ -404,29 +431,37 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
404
431
|
console.log("");
|
|
405
432
|
info(`Uploading ${cyan(bold(String(assetFiles.length)))} asset${assetFiles.length > 1 ? "s" : ""}…`);
|
|
406
433
|
|
|
434
|
+
let uploadedCount = 0;
|
|
435
|
+
let skippedCount = 0;
|
|
436
|
+
|
|
407
437
|
for (const u of uploadUrls) {
|
|
408
438
|
const file = assetFiles.find(f => f.relativePath === u.path);
|
|
409
|
-
const buffer = readFileSync(file.filePath);
|
|
410
|
-
|
|
411
|
-
const res = await fetch(u.uploadUrl, {
|
|
412
|
-
method: "PUT",
|
|
413
|
-
headers: { "Content-Type": u.mimeType },
|
|
414
|
-
body: buffer
|
|
415
|
-
});
|
|
416
439
|
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
440
|
+
if (u.uploadUrl) {
|
|
441
|
+
const res = await fetch(u.uploadUrl, {
|
|
442
|
+
method: "PUT",
|
|
443
|
+
headers: { "Content-Type": u.mimeType },
|
|
444
|
+
body: file.buffer
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
if (!res.ok) {
|
|
448
|
+
err(`Failed to upload ${u.path}`);
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
ok(`✓ Uploaded ${cyan(u.path)} ${dim(`(${formatBytes(file.size)})`)}`);
|
|
452
|
+
uploadedCount++;
|
|
453
|
+
} else {
|
|
454
|
+
ok(`● Skipped (unchanged) ${cyan(u.path)} ${dim(`(${formatBytes(file.size)})`)}`);
|
|
455
|
+
skippedCount++;
|
|
420
456
|
}
|
|
421
457
|
|
|
422
458
|
assetPayloads.push({
|
|
423
459
|
path: u.path,
|
|
424
460
|
mimeType: u.mimeType,
|
|
425
461
|
sizeBytes: u.sizeBytes,
|
|
426
|
-
storageKey: u.storageKey
|
|
462
|
+
storageKey: u.storageKey,
|
|
463
|
+
hash: u.hash
|
|
427
464
|
});
|
|
428
|
-
|
|
429
|
-
ok(`✓ Uploaded ${cyan(u.path)} ${dim(`(${formatBytes(file.size)})`)}`);
|
|
430
465
|
}
|
|
431
466
|
|
|
432
467
|
if (!existingSlug) {
|
|
@@ -459,6 +494,11 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
459
494
|
url: data.url,
|
|
460
495
|
pageCount: data.pageCount || pagePayloads.length,
|
|
461
496
|
},
|
|
497
|
+
sync: {
|
|
498
|
+
version: data.version,
|
|
499
|
+
updatedAt: new Date().toISOString(),
|
|
500
|
+
deployedAt: new Date().toISOString(),
|
|
501
|
+
},
|
|
462
502
|
});
|
|
463
503
|
|
|
464
504
|
if (json) {
|
|
@@ -485,6 +525,62 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
485
525
|
}
|
|
486
526
|
}
|
|
487
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
|
+
|
|
488
584
|
/**
|
|
489
585
|
* Recursively find all files in a directory.
|
|
490
586
|
* Skips ignored files/directories.
|
|
@@ -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
|
+
}
|