htmlhost-cli 2.2.1 → 2.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -3,8 +3,9 @@
3
3
  */
4
4
  import { bold, dim, cyan, err } from "./ui.mjs";
5
5
  import { ApiError } from "./api.mjs";
6
+ import { startUpdateCheck, applyUpdate } from "./update.mjs";
6
7
 
7
- const VERSION = "2.2.1";
8
+ const VERSION = "2.3.0";
8
9
 
9
10
  const HELP = `
10
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -83,6 +84,9 @@ export async function run(argv) {
83
84
  return;
84
85
  }
85
86
 
87
+ // Start update check in parallel (non-blocking, won't slow the command)
88
+ const updatePromise = jsonMode ? Promise.resolve(null) : startUpdateCheck(VERSION);
89
+
86
90
  try {
87
91
  switch (command) {
88
92
  case "login": {
@@ -132,10 +136,18 @@ export async function run(argv) {
132
136
  await open(args);
133
137
  break;
134
138
  }
135
- default:
139
+ default: {
140
+ // Handle common flag misspellings
141
+ if (command === "-version" || command === "version") {
142
+ console.log(VERSION);
143
+ console.log(dim(` (Tip: use ${cyan("htmlhost -v")} or ${cyan("htmlhost --version")})`));
144
+ return;
145
+ }
146
+
136
147
  err(`Unknown command: ${command}`);
137
148
  console.log(` Run ${cyan("htmlhost --help")} for usage.`);
138
149
  process.exit(1);
150
+ }
139
151
  }
140
152
  } catch (e) {
141
153
  if (e instanceof ApiError) {
@@ -144,4 +156,8 @@ export async function run(argv) {
144
156
  }
145
157
  throw e;
146
158
  }
159
+
160
+ // After command finishes, check if an update is available
161
+ const updateInfo = await updatePromise;
162
+ await applyUpdate(updateInfo);
147
163
  }
@@ -3,11 +3,50 @@ import { basename, resolve, dirname, join, extname } from "node:path";
3
3
  import { createInterface } from "node:readline";
4
4
  import { createHash } from "node:crypto";
5
5
  import { post } from "../api.mjs";
6
- import { ok, err, info, cyan, dim, bold, yellow, green, formatBytes, mimeFromExt } from "../ui.mjs";
6
+ import { ok, err, info, warn, cyan, dim, bold, yellow, green, formatBytes, mimeFromExt } from "../ui.mjs";
7
7
  import { checkRemoteChanges } from "./pull.mjs";
8
8
 
9
9
  const LINK_FILE = ".htmlhost";
10
10
 
11
+ /**
12
+ * Upload a file to a presigned URL with automatic retry.
13
+ * Retries up to 3 times with exponential backoff on network errors.
14
+ */
15
+ const MAX_RETRIES = 3;
16
+
17
+ async function uploadWithRetry(url, contentType, buffer, filePath, fileSize) {
18
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
19
+ try {
20
+ const res = await fetch(url, {
21
+ method: "PUT",
22
+ headers: { "Content-Type": contentType },
23
+ body: buffer,
24
+ });
25
+
26
+ if (!res.ok) {
27
+ throw new Error(`Server returned ${res.status}`);
28
+ }
29
+
30
+ ok(`Uploaded ${cyan(filePath)} ${dim(`(${formatBytes(fileSize)})`)}`);
31
+ return;
32
+ } catch (e) {
33
+ if (attempt < MAX_RETRIES) {
34
+ const delay = attempt * 2; // 2s, 4s
35
+ warn(
36
+ `Upload failed for ${cyan(filePath)} (${e.message}). ` +
37
+ `Retrying in ${delay}s… (${attempt}/${MAX_RETRIES})`
38
+ );
39
+ await new Promise((r) => setTimeout(r, delay * 1000));
40
+ } else {
41
+ err(
42
+ `Failed to upload ${cyan(filePath)} after ${MAX_RETRIES} attempts: ${e.message}`
43
+ );
44
+ process.exit(1);
45
+ }
46
+ }
47
+ }
48
+ }
49
+
11
50
  /**
12
51
  * Default ignore patterns for directory deploys.
13
52
  * Matches directory names and file names/patterns.
@@ -434,24 +473,26 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
434
473
  let uploadedCount = 0;
435
474
  let skippedCount = 0;
436
475
 
476
+ // Write .htmlhost early so a crash mid-upload doesn't orphan the site
477
+ if (generatedSlug && !existingSlug) {
478
+ existingSlug = generatedSlug;
479
+ writeLink(dirPath, {
480
+ multipage: {
481
+ slug: existingSlug,
482
+ url: `${existingSlug}.htmlhost.co`,
483
+ pageCount: pagePayloads.length,
484
+ },
485
+ });
486
+ }
487
+
437
488
  for (const u of uploadUrls) {
438
489
  const file = assetFiles.find(f => f.relativePath === u.path);
439
490
 
440
491
  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)})`)}`);
492
+ await uploadWithRetry(u.uploadUrl, u.mimeType, file.buffer, u.path, file.size);
452
493
  uploadedCount++;
453
494
  } else {
454
- ok(`● Skipped (unchanged) ${cyan(u.path)} ${dim(`(${formatBytes(file.size)})`)}`);
495
+ ok(`Skipped (unchanged) ${cyan(u.path)} ${dim(`(${formatBytes(file.size)})`)}`)
455
496
  skippedCount++;
456
497
  }
457
498
 
@@ -463,10 +504,6 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
463
504
  hash: u.hash
464
505
  });
465
506
  }
466
-
467
- if (!existingSlug) {
468
- existingSlug = generatedSlug;
469
- }
470
507
  }
471
508
 
472
509
  // --- Deploy ---
@@ -506,10 +543,12 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
506
543
  return;
507
544
  }
508
545
 
546
+ const pc = data.pageCount || pagePayloads.length;
547
+ const ac = assetPayloads.length;
509
548
  console.log("");
510
549
  console.log(` ${green("━".repeat(40))}`);
511
550
  ok(`${bold("Live")} at ${cyan(`https://${data.url}`)}`);
512
- console.log(` ${dim(`${data.pageCount || pagePayloads.length} pages · ${assetPayloads.length} assets · ${data.ttl} TTL`)}`);
551
+ console.log(` ${dim(`${pc} page${pc !== 1 ? "s" : ""} · ${ac} asset${ac !== 1 ? "s" : ""} · ${data.ttl} TTL`)}`);
513
552
  console.log("");
514
553
 
515
554
  for (const { path: pagePath } of pagePayloads) {
package/src/update.mjs ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Auto-update checker for the htmlhost CLI.
3
+ *
4
+ * - Checks the npm registry for a newer version (non-blocking).
5
+ * - Throttles to at most once per 24 hours via ~/.htmlhostrc cache.
6
+ * - After the command finishes, prints a notice and auto-updates.
7
+ * - Skipped entirely in --json mode or CI environments.
8
+ */
9
+ import { execSync } from "node:child_process";
10
+ import { readConfig, writeConfig } from "./config.mjs";
11
+ import { bold, dim, cyan, green, yellow, err as errMsg } from "./ui.mjs";
12
+
13
+ const PACKAGE_NAME = "htmlhost-cli";
14
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
15
+
16
+ /**
17
+ * Start a non-blocking version check. Returns a promise that resolves
18
+ * to { latest, current, needsUpdate } or null if the check was skipped/failed.
19
+ */
20
+ export function startUpdateCheck(currentVersion) {
21
+ // Skip in CI or if explicitly disabled
22
+ if (process.env.CI || process.env.HTMLHOST_NO_UPDATE_CHECK) {
23
+ return Promise.resolve(null);
24
+ }
25
+
26
+ // Throttle: check at most once per 24 hours
27
+ const config = readConfig();
28
+ const lastCheck = config.lastUpdateCheck || 0;
29
+ if (Date.now() - lastCheck < CHECK_INTERVAL_MS) {
30
+ // Still within throttle window — but if we know a newer version
31
+ // from the last check, surface it without re-fetching
32
+ if (config.latestVersion && config.latestVersion !== currentVersion) {
33
+ return Promise.resolve({
34
+ latest: config.latestVersion,
35
+ current: currentVersion,
36
+ needsUpdate: true,
37
+ });
38
+ }
39
+ return Promise.resolve(null);
40
+ }
41
+
42
+ // Non-blocking fetch to npm registry
43
+ const controller = new AbortController();
44
+ const timeout = setTimeout(() => controller.abort(), 5000); // 5s max
45
+
46
+ return fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
47
+ signal: controller.signal,
48
+ headers: { Accept: "application/json" },
49
+ })
50
+ .then((res) => {
51
+ clearTimeout(timeout);
52
+ if (!res.ok) return null;
53
+ return res.json();
54
+ })
55
+ .then((data) => {
56
+ if (!data?.version) return null;
57
+
58
+ // Cache the result
59
+ writeConfig({
60
+ lastUpdateCheck: Date.now(),
61
+ latestVersion: data.version,
62
+ });
63
+
64
+ if (data.version === currentVersion) return null;
65
+
66
+ // Compare semver: only update if registry version is newer
67
+ if (!isNewer(data.version, currentVersion)) return null;
68
+
69
+ return {
70
+ latest: data.version,
71
+ current: currentVersion,
72
+ needsUpdate: true,
73
+ };
74
+ })
75
+ .catch(() => {
76
+ clearTimeout(timeout);
77
+ return null; // Silently ignore network errors
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Simple semver comparison: returns true if a > b.
83
+ */
84
+ function isNewer(a, b) {
85
+ const pa = a.split(".").map(Number);
86
+ const pb = b.split(".").map(Number);
87
+ for (let i = 0; i < 3; i++) {
88
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
89
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ /**
95
+ * Print an update banner and attempt auto-update.
96
+ * Called after the main command finishes.
97
+ */
98
+ export async function applyUpdate(updateInfo) {
99
+ if (!updateInfo?.needsUpdate) return;
100
+
101
+ const { latest, current } = updateInfo;
102
+
103
+ console.log("");
104
+ console.log(
105
+ ` ${yellow("⬆")} Update available: ${dim(current)} → ${green(bold(latest))}`
106
+ );
107
+ console.log(` Updating ${cyan(PACKAGE_NAME)}…`);
108
+
109
+ try {
110
+ execSync(`npm update -g ${PACKAGE_NAME}`, {
111
+ stdio: "pipe",
112
+ timeout: 30000, // 30s max
113
+ });
114
+ console.log(` ${green("✓")} Updated to v${latest}`);
115
+
116
+ // Clear cached version so we don't show the banner again
117
+ writeConfig({ latestVersion: latest });
118
+ } catch (e) {
119
+ // Permission error or other failure — show manual command
120
+ console.log(
121
+ ` ${yellow("!")} Auto-update failed. Run manually:`
122
+ );
123
+ console.log(` ${cyan(`sudo npm update -g ${PACKAGE_NAME}`)}`);
124
+ }
125
+ }