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 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.6",
3
+ "version": "2.2.0",
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.6";
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
+ }
@@ -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
- path: f.relativePath,
389
- sizeBytes: f.size,
390
- mimeType: mimeFromExt(basename(f.filePath))
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 (!res.ok) {
418
- err(`Failed to upload ${u.path}`);
419
- process.exit(1);
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
+ }