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 +1 -1
- package/src/api.mjs +52 -0
- package/src/cli.mjs +32 -7
- package/src/commands/deploy.mjs +57 -18
- package/src/commands/upload.mjs +170 -0
- package/src/update.mjs +125 -0
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
|
@@ -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.
|
|
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
|
|
50
|
-
${dim("$")} htmlhost
|
|
51
|
-
${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
|
|
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
|
}
|
package/src/commands/deploy.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(`${
|
|
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
|
+
}
|