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 +1 -1
- package/src/cli.mjs +18 -2
- package/src/commands/deploy.mjs +57 -18
- package/src/update.mjs +125 -0
package/package.json
CHANGED
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.
|
|
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
|
}
|
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) {
|
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
|
+
}
|