htmlhost-cli 2.2.1 → 2.4.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.4.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,6 +112,57 @@ export async function uploadFile(path, filePath, fileName, mimeType) {
112
112
  }, `upload ${fileName}`);
113
113
  }
114
114
 
115
+ const DIRECT_UPLOAD_THRESHOLD = 4 * 1024 * 1024; // 4 MB
116
+
117
+ /**
118
+ * Upload a media file. Automatically switches between multipart POST (<=4MB)
119
+ * and direct-to-R2 presigned upload (>4MB) to stay within Vercel body limits.
120
+ */
121
+ export async function uploadMediaFile(filePath, fileName, mimeType) {
122
+ const { statSync, readFileSync } = await import("node:fs");
123
+ const stats = statSync(filePath);
124
+ const size = stats.size;
125
+
126
+ if (size <= DIRECT_UPLOAD_THRESHOLD) {
127
+ const data = await uploadFile("/api/media", filePath, fileName, mimeType);
128
+ return {
129
+ ...data,
130
+ fullUrl: `${getApi()}${data.url}`,
131
+ };
132
+ }
133
+
134
+ // Large file direct-to-R2 flow
135
+ return withRetry(async () => {
136
+ // 1. Get presigned upload URL
137
+ const presign = await post("/api/media/presign", {
138
+ filename: fileName,
139
+ mimeType,
140
+ size,
141
+ });
142
+
143
+ // 2. Direct PUT to R2
144
+ const fileBuffer = readFileSync(filePath);
145
+ const putRes = await fetch(presign.uploadUrl, {
146
+ method: "PUT",
147
+ headers: { "Content-Type": mimeType },
148
+ body: fileBuffer,
149
+ });
150
+ if (!putRes.ok) {
151
+ throw new ApiError(`Direct upload failed: HTTP ${putRes.status}`, putRes.status);
152
+ }
153
+
154
+ // 3. Confirm upload
155
+ const confirmed = await post("/api/media/confirm", {
156
+ key: presign.key,
157
+ });
158
+
159
+ return {
160
+ ...confirmed,
161
+ fullUrl: `${getApi()}${confirmed.url}`,
162
+ };
163
+ }, `upload ${fileName} (direct)`);
164
+ }
165
+
115
166
  /**
116
167
  * Download a file from a URL as a Buffer.
117
168
  * Used for fetching binary assets via presigned R2 download URLs.
@@ -124,3 +175,4 @@ export async function downloadBuffer(url) {
124
175
  return Buffer.from(arrayBuffer);
125
176
  }
126
177
 
178
+
package/src/cli.mjs CHANGED
@@ -3,14 +3,16 @@
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.4.0";
8
9
 
9
10
  const HELP = `
10
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
11
12
 
12
13
  ${bold("Usage:")}
13
14
  ${cyan("htmlhost deploy")} [file|dir] [opts] Deploy file or directory
15
+ ${cyan("htmlhost upload")} <file|dir...> [opts] Upload media file or directory
14
16
  ${cyan("htmlhost pull")} [slug] [opts] Pull remote changes to local
15
17
  ${cyan("htmlhost clone")} <slug> [dir] Clone a site to a local directory
16
18
  ${cyan("htmlhost list")} List your sites
@@ -28,6 +30,11 @@ const HELP = `
28
30
  --no-assets Skip auto-uploading local assets (single-file only)
29
31
  --no-pull-check Skip remote change detection before deploy
30
32
 
33
+ ${bold("Upload options:")}
34
+ --open Open the uploaded media URL in browser
35
+ --no-copy Skip copying the URL to clipboard
36
+ --json Output response as JSON
37
+
31
38
  ${bold("Pull options:")}
32
39
  --force, -f Pull without confirmation prompt
33
40
  --dry-run Show what would change without writing
@@ -46,13 +53,11 @@ const HELP = `
46
53
  ${dim("$")} htmlhost deploy ${dim("# deploys cwd as a site")}
47
54
  ${dim("$")} htmlhost deploy ./my-project ${dim("# deploys a specific folder")}
48
55
  ${dim("$")} htmlhost deploy page.html ${dim("# deploys a single file")}
49
- ${dim("$")} htmlhost deploy --ttl 30d ${dim("# deploy with 30-day TTL")}
50
- ${dim("$")} htmlhost deploy --new ${dim("# force new site")}
51
- ${dim("$")} htmlhost deploy --json ${dim("# JSON output for CI/CD")}
56
+ ${dim("$")} htmlhost upload assets/hero.png ${dim("# upload image, copy URL to clipboard")}
57
+ ${dim("$")} htmlhost upload assets/ ${dim("# upload all files in directory")}
58
+ ${dim("$")} htmlhost upload photo.jpg --open
52
59
  ${dim("$")} htmlhost pull ${dim("# pull remote changes")}
53
- ${dim("$")} htmlhost pull my-site --force ${dim("# pull without prompting")}
54
60
  ${dim("$")} htmlhost clone my-site ${dim("# clone into ./my-site/")}
55
- ${dim("$")} htmlhost clone my-site ./proj ${dim("# clone into ./proj/")}
56
61
  ${dim("$")} htmlhost delete old-project --force
57
62
 
58
63
  ${bold("Update:")}
@@ -83,6 +88,9 @@ export async function run(argv) {
83
88
  return;
84
89
  }
85
90
 
91
+ // Start update check in parallel (non-blocking, won't slow the command)
92
+ const updatePromise = jsonMode ? Promise.resolve(null) : startUpdateCheck(VERSION);
93
+
86
94
  try {
87
95
  switch (command) {
88
96
  case "login": {
@@ -105,6 +113,11 @@ export async function run(argv) {
105
113
  await deploy(args, { json: jsonMode });
106
114
  break;
107
115
  }
116
+ case "upload": {
117
+ const { upload } = await import("./commands/upload.mjs");
118
+ await upload(args, { json: jsonMode });
119
+ break;
120
+ }
108
121
  case "pull": {
109
122
  const { pull } = await import("./commands/pull.mjs");
110
123
  await pull(args);
@@ -132,10 +145,18 @@ export async function run(argv) {
132
145
  await open(args);
133
146
  break;
134
147
  }
135
- default:
148
+ default: {
149
+ // Handle common flag misspellings
150
+ if (command === "-version" || command === "version") {
151
+ console.log(VERSION);
152
+ console.log(dim(` (Tip: use ${cyan("htmlhost -v")} or ${cyan("htmlhost --version")})`));
153
+ return;
154
+ }
155
+
136
156
  err(`Unknown command: ${command}`);
137
157
  console.log(` Run ${cyan("htmlhost --help")} for usage.`);
138
158
  process.exit(1);
159
+ }
139
160
  }
140
161
  } catch (e) {
141
162
  if (e instanceof ApiError) {
@@ -144,4 +165,8 @@ export async function run(argv) {
144
165
  }
145
166
  throw e;
146
167
  }
168
+
169
+ // After command finishes, check if an update is available
170
+ const updateInfo = await updatePromise;
171
+ await applyUpdate(updateInfo);
147
172
  }
@@ -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) {
@@ -0,0 +1,170 @@
1
+ import { resolve, basename, relative } from "node:path";
2
+ import { statSync, existsSync, readdirSync } from "node:fs";
3
+ import { exec } from "node:child_process";
4
+ import { uploadMediaFile } from "../api.mjs";
5
+ import { ok, err, info, dim, cyan, bold, formatBytes, mimeFromExt } from "../ui.mjs";
6
+
7
+ /**
8
+ * Copy text to system clipboard (cross-platform, fail-safe).
9
+ */
10
+ async function copyToClipboard(text) {
11
+ try {
12
+ const { spawn } = await import("node:child_process");
13
+ let cmd, args;
14
+ if (process.platform === "darwin") {
15
+ cmd = "pbcopy";
16
+ args = [];
17
+ } else if (process.platform === "win32") {
18
+ cmd = "clip";
19
+ args = [];
20
+ } else if (process.env.WAYLAND_DISPLAY) {
21
+ cmd = "wl-copy";
22
+ args = [];
23
+ } else {
24
+ cmd = "xclip";
25
+ args = ["-selection", "clipboard"];
26
+ }
27
+
28
+ return await new Promise((res) => {
29
+ const proc = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
30
+ proc.on("error", () => res(false));
31
+ proc.on("close", (code) => res(code === 0));
32
+ proc.stdin.write(text);
33
+ proc.stdin.end();
34
+ });
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Recursively collect all files in a directory, ignoring hidden files/folders.
42
+ */
43
+ function collectFiles(dir) {
44
+ const files = [];
45
+ const entries = readdirSync(dir, { withFileTypes: true });
46
+
47
+ for (const entry of entries) {
48
+ if (entry.name.startsWith(".")) continue;
49
+ const full = resolve(dir, entry.name);
50
+ if (entry.isDirectory()) {
51
+ files.push(...collectFiles(full));
52
+ } else if (entry.isFile()) {
53
+ files.push(full);
54
+ }
55
+ }
56
+
57
+ return files;
58
+ }
59
+
60
+ /**
61
+ * htmlhost upload <file|dir...> — upload media files and get hosted URLs.
62
+ */
63
+ export async function upload(args, { json = false } = {}) {
64
+ const flags = new Set(args.filter((a) => a.startsWith("--")));
65
+ const rawPaths = args.filter((a) => !a.startsWith("--"));
66
+
67
+ const noCopy = flags.has("--no-copy");
68
+ const openInBrowser = flags.has("--open");
69
+
70
+ if (rawPaths.length === 0) {
71
+ err("Usage: htmlhost upload <file|dir...> [options]");
72
+ console.log(`\n ${bold("Options:")}`);
73
+ console.log(` --open Open the uploaded media URL in your browser`);
74
+ console.log(` --no-copy Do not copy the URL to clipboard`);
75
+ console.log(` --json Output response as JSON\n`);
76
+ console.log(` ${bold("Examples:")}`);
77
+ console.log(` ${dim("$")} htmlhost upload assets/og-image-ambient.jpg`);
78
+ console.log(` ${dim("$")} htmlhost upload photo.jpg logo.svg`);
79
+ console.log(` ${dim("$")} htmlhost upload assets/`);
80
+ process.exit(1);
81
+ }
82
+
83
+ // Resolve all target files
84
+ const filePaths = [];
85
+ for (const raw of rawPaths) {
86
+ const absPath = resolve(process.cwd(), raw);
87
+ if (!existsSync(absPath)) {
88
+ err(`File or directory not found: ${raw}`);
89
+ process.exit(1);
90
+ }
91
+
92
+ const stat = statSync(absPath);
93
+ if (stat.isDirectory()) {
94
+ const inside = collectFiles(absPath);
95
+ if (inside.length === 0) {
96
+ if (!json) err(`No files found in directory: ${raw}`);
97
+ process.exit(1);
98
+ }
99
+ filePaths.push(...inside);
100
+ } else if (stat.isFile()) {
101
+ filePaths.push(absPath);
102
+ }
103
+ }
104
+
105
+ if (filePaths.length === 0) {
106
+ err("No files to upload.");
107
+ process.exit(1);
108
+ }
109
+
110
+ const results = [];
111
+
112
+ if (!json && filePaths.length > 1) {
113
+ console.log("");
114
+ info(`Uploading ${bold(String(filePaths.length))} files…`);
115
+ }
116
+
117
+ for (const filePath of filePaths) {
118
+ const fileName = basename(filePath);
119
+ const relPath = relative(process.cwd(), filePath) || fileName;
120
+ const stat = statSync(filePath);
121
+ const mime = mimeFromExt(fileName);
122
+
123
+ if (!json && filePaths.length === 1) {
124
+ info(`Uploading ${cyan(relPath)} ${dim(`(${formatBytes(stat.size)})`)}…`);
125
+ }
126
+
127
+ const uploaded = await uploadMediaFile(filePath, fileName, mime);
128
+ results.push({
129
+ ...uploaded,
130
+ localPath: relPath,
131
+ });
132
+
133
+ if (!json && filePaths.length > 1) {
134
+ console.log(` ${cyan(relPath)} ${dim(`(${formatBytes(stat.size)})`)} → ${uploaded.fullUrl}`);
135
+ }
136
+ }
137
+
138
+ // JSON output
139
+ if (json) {
140
+ console.log(JSON.stringify(results.length === 1 ? results[0] : results, null, 2));
141
+ return;
142
+ }
143
+
144
+ // Single file completion UX
145
+ if (results.length === 1) {
146
+ const item = results[0];
147
+ let copied = false;
148
+ if (!noCopy) {
149
+ copied = await copyToClipboard(item.fullUrl);
150
+ }
151
+
152
+ ok(`Uploaded ${cyan(item.localPath)} ${dim(`(${formatBytes(item.size)})`)}`);
153
+ console.log(` ${bold(item.fullUrl)}${copied ? dim(" (copied to clipboard)") : ""}`);
154
+ } else {
155
+ console.log("");
156
+ ok(`All ${bold(String(results.length))} files uploaded successfully`);
157
+ }
158
+
159
+ // Open in browser if requested
160
+ if (openInBrowser) {
161
+ const cmd =
162
+ process.platform === "darwin" ? "open" :
163
+ process.platform === "win32" ? "start" :
164
+ "xdg-open";
165
+
166
+ for (const r of results) {
167
+ exec(`${cmd} ${r.fullUrl}`);
168
+ }
169
+ }
170
+ }
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 && isNewer(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
+ }