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 +1 -1
- package/src/api.mjs +52 -0
- package/src/cli.mjs +15 -6
- package/src/commands/upload.mjs +170 -0
- package/src/update.mjs +1 -1
package/package.json
CHANGED
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.
|
|
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
|
|
51
|
-
${dim("$")} htmlhost
|
|
52
|
-
${dim("$")} htmlhost
|
|
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
|
|
32
|
+
if (config.latestVersion && isNewer(config.latestVersion, currentVersion)) {
|
|
33
33
|
return Promise.resolve({
|
|
34
34
|
latest: config.latestVersion,
|
|
35
35
|
current: currentVersion,
|