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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.1.8",
3
+ "version": "2.2.1",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
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";
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
+ }
@@ -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 aIsIndex = a.relativePath === "index.html";
535
- const bIsIndex = b.relativePath === "index.html";
536
- if (aIsIndex) return -1;
537
- if (bIsIndex) return 1;
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
+ }