htmlhost-cli 2.3.0 → 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.3.0",
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
@@ -5,13 +5,14 @@ import { bold, dim, cyan, err } from "./ui.mjs";
5
5
  import { ApiError } from "./api.mjs";
6
6
  import { startUpdateCheck, applyUpdate } from "./update.mjs";
7
7
 
8
- const VERSION = "2.3.0";
8
+ const VERSION = "2.4.0";
9
9
 
10
10
  const HELP = `
11
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
12
12
 
13
13
  ${bold("Usage:")}
14
14
  ${cyan("htmlhost deploy")} [file|dir] [opts] Deploy file or directory
15
+ ${cyan("htmlhost upload")} <file|dir...> [opts] Upload media file or directory
15
16
  ${cyan("htmlhost pull")} [slug] [opts] Pull remote changes to local
16
17
  ${cyan("htmlhost clone")} <slug> [dir] Clone a site to a local directory
17
18
  ${cyan("htmlhost list")} List your sites
@@ -29,6 +30,11 @@ const HELP = `
29
30
  --no-assets Skip auto-uploading local assets (single-file only)
30
31
  --no-pull-check Skip remote change detection before deploy
31
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
+
32
38
  ${bold("Pull options:")}
33
39
  --force, -f Pull without confirmation prompt
34
40
  --dry-run Show what would change without writing
@@ -47,13 +53,11 @@ const HELP = `
47
53
  ${dim("$")} htmlhost deploy ${dim("# deploys cwd as a site")}
48
54
  ${dim("$")} htmlhost deploy ./my-project ${dim("# deploys a specific folder")}
49
55
  ${dim("$")} htmlhost deploy page.html ${dim("# deploys a single file")}
50
- ${dim("$")} htmlhost deploy --ttl 30d ${dim("# deploy with 30-day TTL")}
51
- ${dim("$")} htmlhost deploy --new ${dim("# force new site")}
52
- ${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
53
59
  ${dim("$")} htmlhost pull ${dim("# pull remote changes")}
54
- ${dim("$")} htmlhost pull my-site --force ${dim("# pull without prompting")}
55
60
  ${dim("$")} htmlhost clone my-site ${dim("# clone into ./my-site/")}
56
- ${dim("$")} htmlhost clone my-site ./proj ${dim("# clone into ./proj/")}
57
61
  ${dim("$")} htmlhost delete old-project --force
58
62
 
59
63
  ${bold("Update:")}
@@ -109,6 +113,11 @@ export async function run(argv) {
109
113
  await deploy(args, { json: jsonMode });
110
114
  break;
111
115
  }
116
+ case "upload": {
117
+ const { upload } = await import("./commands/upload.mjs");
118
+ await upload(args, { json: jsonMode });
119
+ break;
120
+ }
112
121
  case "pull": {
113
122
  const { pull } = await import("./commands/pull.mjs");
114
123
  await pull(args);
@@ -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 CHANGED
@@ -29,7 +29,7 @@ export function startUpdateCheck(currentVersion) {
29
29
  if (Date.now() - lastCheck < CHECK_INTERVAL_MS) {
30
30
  // Still within throttle window — but if we know a newer version
31
31
  // from the last check, surface it without re-fetching
32
- if (config.latestVersion && config.latestVersion !== currentVersion) {
32
+ if (config.latestVersion && isNewer(config.latestVersion, currentVersion)) {
33
33
  return Promise.resolve({
34
34
  latest: config.latestVersion,
35
35
  current: currentVersion,