htmlhost-cli 1.8.0 → 2.1.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/README.md +31 -30
- package/package.json +1 -1
- package/src/cli.mjs +11 -22
- package/src/commands/deploy.mjs +201 -280
- package/src/commands/upload.mjs +0 -68
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# htmlhost
|
|
2
2
|
|
|
3
|
-
Deploy HTML
|
|
3
|
+
Deploy HTML projects from the terminal. [htmlhost.co](https://htmlhost.co)
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -14,16 +14,16 @@ npm i -g htmlhost-cli
|
|
|
14
14
|
# Authenticate (get a token at htmlhost.co/settings#keys)
|
|
15
15
|
htmlhost login
|
|
16
16
|
|
|
17
|
-
# Deploy
|
|
18
|
-
htmlhost deploy
|
|
17
|
+
# Deploy the current directory
|
|
18
|
+
htmlhost deploy
|
|
19
19
|
# → https://bold-fern-x3k.htmlhost.co
|
|
20
20
|
|
|
21
|
-
# Re-deploy
|
|
22
|
-
htmlhost deploy
|
|
21
|
+
# Re-deploy (auto-detects from .htmlhost)
|
|
22
|
+
htmlhost deploy
|
|
23
|
+
# → updated https://bold-fern-x3k.htmlhost.co
|
|
23
24
|
|
|
24
|
-
#
|
|
25
|
-
htmlhost
|
|
26
|
-
# → https://htmlhost.co/m/a3f91c2b
|
|
25
|
+
# Deploy a single file
|
|
26
|
+
htmlhost deploy page.html
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
## Commands
|
|
@@ -32,30 +32,31 @@ htmlhost upload logo.png
|
|
|
32
32
|
Authenticate with an API token. Generate one at [htmlhost.co/settings#keys](https://htmlhost.co/settings#keys).
|
|
33
33
|
|
|
34
34
|
### `htmlhost deploy [file|dir] [options]`
|
|
35
|
-
Deploy an HTML
|
|
35
|
+
Deploy an HTML project and get a live URL.
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
#
|
|
39
|
-
htmlhost deploy
|
|
38
|
+
# Deploy current directory (default)
|
|
39
|
+
htmlhost deploy
|
|
40
40
|
|
|
41
|
-
#
|
|
42
|
-
htmlhost deploy
|
|
41
|
+
# Deploy a specific directory
|
|
42
|
+
htmlhost deploy ./my-project
|
|
43
43
|
|
|
44
|
-
#
|
|
45
|
-
htmlhost deploy .
|
|
44
|
+
# Deploy a single HTML file
|
|
45
|
+
htmlhost deploy page.html
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
**Directory deploy (default):** When you run `htmlhost deploy` with no arguments
|
|
49
|
+
(or pass a directory), the entire folder is deployed as a multi-page site. All
|
|
50
|
+
files — HTML, CSS, JS, images, fonts — are uploaded preserving their original
|
|
51
|
+
folder structure. The site is saved to `.htmlhost` so subsequent deploys update
|
|
52
|
+
the same site.
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
The following files are automatically excluded:
|
|
55
|
+
- `node_modules/`, `.git/`, `.DS_Store`, `.env*`
|
|
56
|
+
- `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
|
57
|
+
- `.vscode/`, `.idea/`
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
multi-page site. File names map to URL paths:
|
|
59
|
+
File names map to URL paths:
|
|
59
60
|
|
|
60
61
|
```
|
|
61
62
|
index.html → /
|
|
@@ -67,14 +68,17 @@ blog/post-1.html → /blog/post-1
|
|
|
67
68
|
|
|
68
69
|
Add a `404.html` file and it will be served automatically for missing pages.
|
|
69
70
|
|
|
71
|
+
**Single-file deploy:** When you pass a specific file (`htmlhost deploy page.html`),
|
|
72
|
+
only that file is deployed. Local CSS, JS, and images referenced in the HTML are
|
|
73
|
+
automatically inlined or uploaded and rewritten to hosted URLs.
|
|
74
|
+
|
|
70
75
|
| Option | Description |
|
|
71
76
|
|---|---|
|
|
72
77
|
| `--ttl <value>` | Set expiry: `1d`, `7d`, `30d`, `never` |
|
|
73
78
|
| `--slug <slug>` | Re-deploy to an existing site |
|
|
74
79
|
| `--title <title>` | Set the site title |
|
|
75
|
-
| `--new` | Force new
|
|
76
|
-
| `--no-assets` | Skip automatic asset
|
|
77
|
-
| `--pages` | Deploy directory as one multi-page site |
|
|
80
|
+
| `--new` | Force new site (ignore .htmlhost link) |
|
|
81
|
+
| `--no-assets` | Skip automatic asset processing (single-file only) |
|
|
78
82
|
| `--json` | Output result as JSON (for CI/CD) |
|
|
79
83
|
|
|
80
84
|
### `htmlhost list`
|
|
@@ -86,9 +90,6 @@ Open a deployed site in the default browser.
|
|
|
86
90
|
### `htmlhost delete <slug>`
|
|
87
91
|
Delete a site. Use `--force` to skip confirmation.
|
|
88
92
|
|
|
89
|
-
### `htmlhost upload <file|dir>`
|
|
90
|
-
Upload media assets to your library. Returns URLs you can use in your HTML.
|
|
91
|
-
|
|
92
93
|
### `htmlhost whoami`
|
|
93
94
|
Show current authenticated user and plan.
|
|
94
95
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { bold, dim, cyan, err } from "./ui.mjs";
|
|
5
5
|
import { ApiError } from "./api.mjs";
|
|
6
6
|
|
|
7
|
-
const VERSION = "1.
|
|
7
|
+
const VERSION = "2.1.0";
|
|
8
8
|
|
|
9
9
|
const HELP = `
|
|
10
10
|
${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
|
|
@@ -13,7 +13,6 @@ const HELP = `
|
|
|
13
13
|
${cyan("htmlhost deploy")} [file|dir] [opts] Deploy file or directory
|
|
14
14
|
${cyan("htmlhost list")} List your sites
|
|
15
15
|
${cyan("htmlhost delete")} <slug> Delete a site
|
|
16
|
-
${cyan("htmlhost upload")} <file|dir> Upload media assets
|
|
17
16
|
${cyan("htmlhost login")} Authenticate with an API token
|
|
18
17
|
${cyan("htmlhost whoami")} Show current user
|
|
19
18
|
${cyan("htmlhost logout")} Remove saved token
|
|
@@ -24,29 +23,24 @@ const HELP = `
|
|
|
24
23
|
--slug <slug> Re-deploy to a specific site
|
|
25
24
|
--title <title> Set the site title
|
|
26
25
|
--new Force a new site (ignore .htmlhost link)
|
|
27
|
-
--no-assets Skip auto-uploading local assets
|
|
28
|
-
--pages Deploy directory as one multi-page site
|
|
26
|
+
--no-assets Skip auto-uploading local assets (single-file only)
|
|
29
27
|
|
|
30
28
|
${bold("Delete options:")}
|
|
31
29
|
--force, -f Skip confirmation prompt
|
|
32
30
|
|
|
33
31
|
${bold("How deploy works:")}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Local scripts, CSS, and images are auto-uploaded
|
|
39
|
-
and rewritten to hosted URLs (files >10 MB are skipped)
|
|
32
|
+
No args or dir: deploys entire folder as a multi-page site
|
|
33
|
+
Single file: deploys just that file (assets auto-inlined)
|
|
34
|
+
Linked: reads ${dim(".htmlhost")}, updates the same site
|
|
35
|
+
Local CSS, JS, images, and fonts are auto-uploaded
|
|
40
36
|
|
|
41
37
|
${bold("Examples:")}
|
|
42
|
-
${dim("$")} htmlhost deploy ${dim("# deploys
|
|
43
|
-
${dim("$")} htmlhost deploy
|
|
44
|
-
${dim("$")} htmlhost deploy .
|
|
45
|
-
${dim("$")} htmlhost deploy . --pages ${dim("# multi-page site from dir")}
|
|
38
|
+
${dim("$")} htmlhost deploy ${dim("# deploys cwd as a site")}
|
|
39
|
+
${dim("$")} htmlhost deploy ./my-project ${dim("# deploys a specific folder")}
|
|
40
|
+
${dim("$")} htmlhost deploy page.html ${dim("# deploys a single file")}
|
|
46
41
|
${dim("$")} htmlhost deploy --ttl 30d ${dim("# deploy with 30-day TTL")}
|
|
47
|
-
${dim("$")} htmlhost deploy --new ${dim("# force new
|
|
48
|
-
${dim("$")} htmlhost deploy
|
|
49
|
-
${dim("$")} htmlhost upload logo.png
|
|
42
|
+
${dim("$")} htmlhost deploy --new ${dim("# force new site")}
|
|
43
|
+
${dim("$")} htmlhost deploy --json ${dim("# JSON output for CI/CD")}
|
|
50
44
|
${dim("$")} htmlhost delete old-project --force
|
|
51
45
|
|
|
52
46
|
${dim(`Docs: https://htmlhost.co/docs`)}
|
|
@@ -108,11 +102,6 @@ export async function run(argv) {
|
|
|
108
102
|
await deleteSite(args);
|
|
109
103
|
break;
|
|
110
104
|
}
|
|
111
|
-
case "upload": {
|
|
112
|
-
const { upload } = await import("./commands/upload.mjs");
|
|
113
|
-
await upload(args);
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
105
|
case "open": {
|
|
117
106
|
const { open } = await import("./commands/open.mjs");
|
|
118
107
|
await open(args);
|
package/src/commands/deploy.mjs
CHANGED
|
@@ -1,12 +1,46 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, statSync, existsSync, readdirSync } from "node:fs";
|
|
2
|
-
import { basename, resolve, dirname, join } from "node:path";
|
|
2
|
+
import { basename, resolve, dirname, join, extname } from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
4
|
import { post } from "../api.mjs";
|
|
5
|
-
import { ok, err, info, cyan, dim, bold, yellow, green, formatBytes } from "../ui.mjs";
|
|
6
|
-
import { processAssets } from "../assets.mjs";
|
|
5
|
+
import { ok, err, info, cyan, dim, bold, yellow, green, formatBytes, mimeFromExt } from "../ui.mjs";
|
|
7
6
|
|
|
8
7
|
const LINK_FILE = ".htmlhost";
|
|
9
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Default ignore patterns for directory deploys.
|
|
11
|
+
* Matches directory names and file names/patterns.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_IGNORE = [
|
|
14
|
+
"node_modules",
|
|
15
|
+
".git",
|
|
16
|
+
".DS_Store",
|
|
17
|
+
".htmlhost",
|
|
18
|
+
".env",
|
|
19
|
+
".env.local",
|
|
20
|
+
".env.production",
|
|
21
|
+
".env.development",
|
|
22
|
+
".vscode",
|
|
23
|
+
".idea",
|
|
24
|
+
"Thumbs.db",
|
|
25
|
+
".gitignore",
|
|
26
|
+
".npmrc",
|
|
27
|
+
"package-lock.json",
|
|
28
|
+
"yarn.lock",
|
|
29
|
+
"pnpm-lock.yaml",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if a file/directory name should be ignored.
|
|
34
|
+
*/
|
|
35
|
+
function shouldIgnore(name) {
|
|
36
|
+
if (name.startsWith(".")) {
|
|
37
|
+
// Allow dotfiles that aren't in the ignore list explicitly
|
|
38
|
+
// but block common ones
|
|
39
|
+
if (DEFAULT_IGNORE.includes(name)) return true;
|
|
40
|
+
}
|
|
41
|
+
return DEFAULT_IGNORE.includes(name);
|
|
42
|
+
}
|
|
43
|
+
|
|
10
44
|
/**
|
|
11
45
|
* Read the project link from .htmlhost
|
|
12
46
|
*/
|
|
@@ -28,6 +62,20 @@ function writeLink(dir, data) {
|
|
|
28
62
|
writeFileSync(linkPath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
29
63
|
}
|
|
30
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Simple y/N confirmation prompt.
|
|
67
|
+
*/
|
|
68
|
+
function promptConfirm(question) {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
71
|
+
rl.question(` ${question} ${dim("(Y/n)")} `, (answer) => {
|
|
72
|
+
rl.close();
|
|
73
|
+
const a = answer.trim().toLowerCase();
|
|
74
|
+
resolve(a === "" || a === "y" || a === "yes");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
31
79
|
/**
|
|
32
80
|
* Interactive menu prompt — returns the index of the selected option.
|
|
33
81
|
*/
|
|
@@ -50,64 +98,45 @@ function promptChoice(question, options) {
|
|
|
50
98
|
}
|
|
51
99
|
|
|
52
100
|
/**
|
|
53
|
-
* htmlhost deploy [file|dir] [--ttl 7d] [--slug existing-slug] [--title "My Site"] [--new] [--no-assets]
|
|
101
|
+
* htmlhost deploy [file|dir] [--ttl 7d] [--slug existing-slug] [--title "My Site"] [--new] [--no-assets]
|
|
54
102
|
*
|
|
55
|
-
* -
|
|
56
|
-
* -
|
|
57
|
-
* -
|
|
58
|
-
* - Remembers the site
|
|
103
|
+
* - No args or "." → deploys entire cwd as a multi-page site (with confirmation)
|
|
104
|
+
* - A directory → deploys that directory as a multi-page site
|
|
105
|
+
* - A single file → deploys just that file (with asset inlining)
|
|
106
|
+
* - Remembers the site via .htmlhost file (auto re-deploy)
|
|
59
107
|
* - Use --new to force fresh deploys
|
|
60
108
|
*/
|
|
61
109
|
export async function deploy(args, { json = false } = {}) {
|
|
62
|
-
let
|
|
110
|
+
let target = args.find((a) => !a.startsWith("--"));
|
|
63
111
|
|
|
64
112
|
const ttl = getFlag(args, "--ttl");
|
|
65
113
|
const title = getFlag(args, "--title");
|
|
66
114
|
const slug = getFlag(args, "--slug");
|
|
67
115
|
const forceNew = args.includes("--new");
|
|
68
116
|
const skipAssets = args.includes("--no-assets");
|
|
69
|
-
const multiPage = args.includes("--pages");
|
|
70
|
-
|
|
71
|
-
// Check if the target is a directory
|
|
72
|
-
if (file) {
|
|
73
|
-
const filePath = resolve(file);
|
|
74
|
-
try {
|
|
75
|
-
const stat = statSync(filePath);
|
|
76
|
-
if (stat.isDirectory()) {
|
|
77
|
-
if (multiPage) {
|
|
78
|
-
return deployMultiPage(filePath, { ttl, title, slug, forceNew, skipAssets, json });
|
|
79
|
-
}
|
|
80
|
-
return deployDirectory(filePath, { ttl, forceNew, skipAssets });
|
|
81
|
-
}
|
|
82
|
-
} catch {
|
|
83
|
-
// Will be caught below in single-file flow
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
117
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
118
|
+
// Resolve target
|
|
119
|
+
const targetPath = resolve(target || ".");
|
|
120
|
+
|
|
121
|
+
let stat;
|
|
122
|
+
try {
|
|
123
|
+
stat = statSync(targetPath);
|
|
124
|
+
} catch {
|
|
125
|
+
err(`Not found: ${target || "."}`);
|
|
126
|
+
process.exit(1);
|
|
90
127
|
}
|
|
91
128
|
|
|
92
|
-
//
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
if (existsSync(defaultFile)) {
|
|
96
|
-
file = "index.html";
|
|
97
|
-
info(`No file specified, using ${cyan("index.html")}`);
|
|
98
|
-
} else {
|
|
99
|
-
err("No file specified and no index.html found in the current directory.");
|
|
100
|
-
console.log(` ${dim("Usage: htmlhost deploy [file.html|dir] [--ttl 7d]")}`);
|
|
101
|
-
process.exit(1);
|
|
102
|
-
}
|
|
129
|
+
// --- Directory deploy (default) ---
|
|
130
|
+
if (stat.isDirectory()) {
|
|
131
|
+
return deployDirectory(targetPath, { ttl, title, slug, forceNew, json });
|
|
103
132
|
}
|
|
104
133
|
|
|
105
|
-
// Single-file deploy
|
|
106
|
-
await deploySingleFile(
|
|
134
|
+
// --- Single-file deploy ---
|
|
135
|
+
await deploySingleFile(target || "index.html", { ttl, title, forceNew, skipAssets, slug, json });
|
|
107
136
|
}
|
|
108
137
|
|
|
109
138
|
/**
|
|
110
|
-
* Deploy a single HTML file.
|
|
139
|
+
* Deploy a single HTML file (existing behavior preserved).
|
|
111
140
|
*/
|
|
112
141
|
async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug, json }) {
|
|
113
142
|
const filePath = resolve(file);
|
|
@@ -119,24 +148,8 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
|
|
|
119
148
|
if (!slug && !forceNew) {
|
|
120
149
|
const link = readLink(projectDir);
|
|
121
150
|
|
|
122
|
-
if (link?.
|
|
123
|
-
//
|
|
124
|
-
const siteCount = Object.keys(link.sites).length;
|
|
125
|
-
if (siteCount > 1) {
|
|
126
|
-
console.log("");
|
|
127
|
-
console.log(` ${yellow("⚠")} This project has ${bold(String(siteCount))} linked sites but you're deploying only ${cyan(fileName)}.`);
|
|
128
|
-
console.log(` ${dim(`To deploy all files, run: ${bold("htmlhost deploy .")}`)}`);
|
|
129
|
-
console.log("");
|
|
130
|
-
}
|
|
131
|
-
// Look up this file's entry in the sites map
|
|
132
|
-
const entry = link.sites[fileName];
|
|
133
|
-
if (entry?.slug) {
|
|
134
|
-
slug = entry.slug;
|
|
135
|
-
isLinked = true;
|
|
136
|
-
info(`Linked to ${cyan(slug)} ${dim(`(via .htmlhost sites map)`)}`);
|
|
137
|
-
}
|
|
138
|
-
} else if (link?.slug) {
|
|
139
|
-
// Single-file format (has slug at top level, no sites map)
|
|
151
|
+
if (link?.slug && !link?.multipage) {
|
|
152
|
+
// Single-file format
|
|
140
153
|
if (link.onRedeploy === "overwrite") {
|
|
141
154
|
slug = link.slug;
|
|
142
155
|
isLinked = true;
|
|
@@ -203,12 +216,10 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
|
|
|
203
216
|
|
|
204
217
|
// Upload local assets (scripts, css, images) and rewrite references
|
|
205
218
|
if (!skipAssets) {
|
|
206
|
-
// Load persisted asset cache from .htmlhost
|
|
207
219
|
const existingLink = readLink(projectDir);
|
|
208
220
|
const persistedCache = new Map(Object.entries(existingLink?.assets || {}));
|
|
209
221
|
const result = await processAssets(html, projectDir, undefined, persistedCache);
|
|
210
222
|
html = result.html;
|
|
211
|
-
// Persist the asset cache for next deploy
|
|
212
223
|
const linkData = readLink(projectDir) || {};
|
|
213
224
|
linkData.assets = Object.fromEntries(result.resolvedCache);
|
|
214
225
|
writeLink(projectDir, linkData);
|
|
@@ -223,21 +234,13 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
|
|
|
223
234
|
|
|
224
235
|
const data = await post("/api/sites", body);
|
|
225
236
|
|
|
226
|
-
// Save
|
|
237
|
+
// Save link
|
|
227
238
|
const existingLink = readLink(projectDir);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
existingLink
|
|
232
|
-
|
|
233
|
-
} else {
|
|
234
|
-
// Single-file format: write the flat slug/url (preserve onRedeploy preference)
|
|
235
|
-
writeLink(projectDir, {
|
|
236
|
-
slug: data.slug,
|
|
237
|
-
url: data.url,
|
|
238
|
-
...(existingLink?.onRedeploy ? { onRedeploy: existingLink.onRedeploy } : {}),
|
|
239
|
-
});
|
|
240
|
-
}
|
|
239
|
+
writeLink(projectDir, {
|
|
240
|
+
slug: data.slug,
|
|
241
|
+
url: data.url,
|
|
242
|
+
...(existingLink?.onRedeploy ? { onRedeploy: existingLink.onRedeploy } : {}),
|
|
243
|
+
});
|
|
241
244
|
|
|
242
245
|
if (json) {
|
|
243
246
|
console.log(JSON.stringify(data));
|
|
@@ -262,221 +265,137 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
|
|
|
262
265
|
}
|
|
263
266
|
|
|
264
267
|
/**
|
|
265
|
-
* Deploy
|
|
268
|
+
* Deploy a directory as a multi-page site.
|
|
269
|
+
* All files are uploaded preserving their original folder structure.
|
|
270
|
+
* Non-HTML files (CSS, JS, images, fonts) are uploaded via the media endpoint
|
|
271
|
+
* and included as pages with their hosted URLs.
|
|
266
272
|
*/
|
|
267
|
-
async function deployDirectory(dirPath, { ttl, forceNew,
|
|
268
|
-
// Find all
|
|
269
|
-
const
|
|
270
|
-
.filter((f) => f.endsWith(".html") && !f.startsWith("."))
|
|
271
|
-
.sort((a, b) => {
|
|
272
|
-
// index.html first, then alphabetical
|
|
273
|
-
if (a === "index.html") return -1;
|
|
274
|
-
if (b === "index.html") return 1;
|
|
275
|
-
return a.localeCompare(b);
|
|
276
|
-
});
|
|
273
|
+
async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceNew, json }) {
|
|
274
|
+
// Find all files recursively
|
|
275
|
+
const allFiles = findAllFiles(dirPath, dirPath);
|
|
277
276
|
|
|
278
|
-
if (
|
|
279
|
-
err("No
|
|
277
|
+
if (allFiles.length === 0) {
|
|
278
|
+
err("No files found in this directory.");
|
|
280
279
|
process.exit(1);
|
|
281
280
|
}
|
|
282
281
|
|
|
282
|
+
// Check for index.html
|
|
283
|
+
const hasIndex = allFiles.some((f) => f.relativePath === "index.html");
|
|
284
|
+
if (!hasIndex) {
|
|
285
|
+
const htmlFiles = allFiles.filter((f) => f.relativePath.endsWith(".html"));
|
|
286
|
+
if (htmlFiles.length === 0) {
|
|
287
|
+
err("No index.html or HTML files found. Nothing to deploy.");
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
283
292
|
// Read existing link data
|
|
284
293
|
const link = readLink(dirPath);
|
|
285
|
-
const
|
|
286
|
-
const onRedeploy = link?.onRedeploy || null;
|
|
294
|
+
const existingSlug = explicitSlug || (!forceNew && link?.multipage?.slug) || null;
|
|
287
295
|
|
|
288
|
-
|
|
296
|
+
// Calculate total size
|
|
297
|
+
const totalSize = allFiles.reduce((sum, f) => sum + f.size, 0);
|
|
298
|
+
const htmlFiles = allFiles.filter((f) => f.relativePath.endsWith(".html"));
|
|
299
|
+
const assetFiles = allFiles.filter((f) => !f.relativePath.endsWith(".html"));
|
|
289
300
|
|
|
301
|
+
// Show deploy summary
|
|
290
302
|
console.log("");
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const linked = existingSites[f];
|
|
296
|
-
if (linked && !forceNew) {
|
|
297
|
-
console.log(` ${dim("↻")} ${f} → ${dim(linked.slug + ".htmlhost.co")}`);
|
|
298
|
-
} else {
|
|
299
|
-
console.log(` ${dim("+")} ${f} → ${dim("new site")}`);
|
|
300
|
-
}
|
|
303
|
+
if (existingSlug) {
|
|
304
|
+
info(`Re-deploying to ${cyan(bold(existingSlug + ".htmlhost.co"))}`);
|
|
305
|
+
} else {
|
|
306
|
+
info(`Deploying ${cyan(bold(basename(dirPath) || "."))} as a new site`);
|
|
301
307
|
}
|
|
302
308
|
console.log("");
|
|
303
309
|
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const newCount = htmlFiles.length - linkedCount;
|
|
308
|
-
|
|
309
|
-
let desc = `${linkedCount} site${linkedCount > 1 ? "s" : ""} will be overwritten`;
|
|
310
|
-
if (newCount > 0) desc += `, ${newCount} new`;
|
|
311
|
-
|
|
312
|
-
const choice = await promptChoice(
|
|
313
|
-
`${desc}. Continue?`,
|
|
314
|
-
[
|
|
315
|
-
"Yes, deploy all",
|
|
316
|
-
"Cancel",
|
|
317
|
-
`Always overwrite ${dim("(remember for this project)")}`,
|
|
318
|
-
]
|
|
319
|
-
);
|
|
320
|
-
|
|
321
|
-
switch (choice) {
|
|
322
|
-
case 0:
|
|
323
|
-
break;
|
|
324
|
-
case 1:
|
|
325
|
-
case -1:
|
|
326
|
-
console.log(" Cancelled.");
|
|
327
|
-
process.exit(0);
|
|
328
|
-
break;
|
|
329
|
-
case 2:
|
|
330
|
-
// Save preference so it auto-deploys next time
|
|
331
|
-
writeLink(dirPath, { sites: existingSites, onRedeploy: "overwrite" });
|
|
332
|
-
info(`Saved preference: ${dim("always overwrite")}`);
|
|
333
|
-
break;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Deploy each file
|
|
338
|
-
const results = {};
|
|
339
|
-
let successCount = 0;
|
|
340
|
-
|
|
341
|
-
// Shared asset cache across all files — avoids re-uploading the same CSS/JS/image
|
|
342
|
-
/** @type {Map<string, string>} absolute file path → hosted URL */
|
|
343
|
-
const assetCache = new Map();
|
|
344
|
-
// Load persisted asset cache from .htmlhost
|
|
345
|
-
const existingAssets = link?.assets || {};
|
|
346
|
-
const persistedCache = new Map(Object.entries(existingAssets));
|
|
310
|
+
// Show file tree
|
|
311
|
+
console.log(` ${bold("Files to deploy:")} ${dim(`(${allFiles.length} files, ${formatBytes(totalSize)})`)}`);
|
|
312
|
+
console.log("");
|
|
347
313
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
314
|
+
// Group by directory for cleaner display
|
|
315
|
+
const maxShow = 20;
|
|
316
|
+
const shown = allFiles.slice(0, maxShow);
|
|
317
|
+
for (const f of shown) {
|
|
318
|
+
const icon = f.relativePath.endsWith(".html") ? "📄" : "📦";
|
|
319
|
+
console.log(` ${dim(icon)} ${f.relativePath} ${dim(`(${formatBytes(f.size)})`)}`);
|
|
320
|
+
}
|
|
321
|
+
if (allFiles.length > maxShow) {
|
|
322
|
+
console.log(` ${dim(`… and ${allFiles.length - maxShow} more files`)}`);
|
|
323
|
+
}
|
|
324
|
+
console.log("");
|
|
351
325
|
|
|
326
|
+
// Summary line
|
|
327
|
+
if (htmlFiles.length > 0 && assetFiles.length > 0) {
|
|
328
|
+
console.log(` ${dim(`${htmlFiles.length} page${htmlFiles.length > 1 ? "s" : ""} · ${assetFiles.length} asset${assetFiles.length > 1 ? "s" : ""} · ${formatBytes(totalSize)} total`)}`);
|
|
352
329
|
console.log("");
|
|
353
|
-
info(`${bold(fileName)} ${dim(`(${formatBytes(stat.size)})`)}`);
|
|
354
|
-
|
|
355
|
-
let html = readFileSync(filePath, "utf8");
|
|
356
|
-
|
|
357
|
-
// Upload local assets with shared + persisted cache
|
|
358
|
-
if (!skipAssets) {
|
|
359
|
-
const result = await processAssets(html, dirPath, assetCache, persistedCache);
|
|
360
|
-
html = result.html;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Determine slug
|
|
364
|
-
const slug = (!forceNew && existingSites[fileName]?.slug) || null;
|
|
365
|
-
|
|
366
|
-
info(slug ? `Re-deploying to ${cyan(slug)}…` : "Deploying new site…");
|
|
367
|
-
|
|
368
|
-
const body = { html };
|
|
369
|
-
if (ttl) body.ttl = ttl;
|
|
370
|
-
if (slug) body.slug = slug;
|
|
371
|
-
// No --title for batch; each site auto-titles from <title> tag
|
|
372
|
-
|
|
373
|
-
try {
|
|
374
|
-
const data = await post("/api/sites", body);
|
|
375
|
-
results[fileName] = { slug: data.slug, url: data.url };
|
|
376
|
-
successCount++;
|
|
377
|
-
ok(`${cyan(fileName)} → ${cyan(`https://${data.url}`)}`);
|
|
378
|
-
} catch (e) {
|
|
379
|
-
err(`${fileName}: ${e.message}`);
|
|
380
|
-
// Keep the old link if we had one
|
|
381
|
-
if (existingSites[fileName]) {
|
|
382
|
-
results[fileName] = existingSites[fileName];
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
330
|
}
|
|
386
331
|
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
sites: { ...existingSites, ...results },
|
|
394
|
-
assets: Object.fromEntries(persistedCache),
|
|
395
|
-
...(finalOnRedeploy ? { onRedeploy: finalOnRedeploy } : {}),
|
|
396
|
-
});
|
|
332
|
+
// Confirmation prompt
|
|
333
|
+
const confirmed = await promptConfirm(
|
|
334
|
+
existingSlug
|
|
335
|
+
? `Overwrite ${cyan(existingSlug + ".htmlhost.co")}?`
|
|
336
|
+
: `Deploy ${bold(String(allFiles.length))} files to htmlhost?`
|
|
337
|
+
);
|
|
397
338
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
ok(`${bold(String(successCount))}/${htmlFiles.length} sites deployed`);
|
|
402
|
-
console.log("");
|
|
403
|
-
for (const [fileName, site] of Object.entries(results)) {
|
|
404
|
-
if (site.url) {
|
|
405
|
-
console.log(` ${dim(fileName)} → ${cyan(`https://${site.url}`)}`);
|
|
406
|
-
}
|
|
339
|
+
if (!confirmed) {
|
|
340
|
+
console.log(" Cancelled.");
|
|
341
|
+
process.exit(0);
|
|
407
342
|
}
|
|
343
|
+
|
|
408
344
|
console.log("");
|
|
409
|
-
console.log(` ${dim("Linked → .htmlhost")}`);
|
|
410
|
-
console.log("");
|
|
411
|
-
}
|
|
412
345
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
* Uses the --pages flag. File names map to URL paths:
|
|
416
|
-
* index.html → /
|
|
417
|
-
* about.html → /about
|
|
418
|
-
* blog/index.html → /blog
|
|
419
|
-
* blog/post.html → /blog/post
|
|
420
|
-
*/
|
|
421
|
-
async function deployMultiPage(dirPath, { ttl, title, slug: explicitSlug, forceNew, skipAssets, json }) {
|
|
422
|
-
// Find all .html files (recursive) — skip dotfiles
|
|
423
|
-
const htmlFiles = findHtmlFiles(dirPath, dirPath);
|
|
346
|
+
// --- Build page payloads (HTML files — no rewriting needed) ---
|
|
347
|
+
info(`Processing ${cyan(bold(String(htmlFiles.length)))} page${htmlFiles.length > 1 ? "s" : ""}…`);
|
|
424
348
|
|
|
425
|
-
|
|
426
|
-
err("No .html files found in this directory.");
|
|
427
|
-
process.exit(1);
|
|
428
|
-
}
|
|
349
|
+
const pagePayloads = [];
|
|
429
350
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const existingSlug = explicitSlug || (!forceNew && link?.multipage?.slug) || null;
|
|
351
|
+
for (const file of htmlFiles) {
|
|
352
|
+
const html = readFileSync(file.filePath, "utf8");
|
|
433
353
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
354
|
+
// Derive page path
|
|
355
|
+
let pagePath;
|
|
356
|
+
if (file.relativePath === "index.html") {
|
|
357
|
+
pagePath = "/";
|
|
358
|
+
} else if (basename(file.relativePath) === "index.html") {
|
|
359
|
+
const dirRel = dirname(file.relativePath);
|
|
360
|
+
pagePath = `/${dirRel}`;
|
|
361
|
+
} else {
|
|
362
|
+
pagePath = "/" + file.relativePath.replace(/\.html$/, "");
|
|
363
|
+
}
|
|
437
364
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
console.log(` ${dim("📄")} ${relativePath} → ${cyan(pagePath)}`);
|
|
365
|
+
pagePayloads.push({ path: pagePath, html });
|
|
366
|
+
ok(`${cyan(file.relativePath)} → ${cyan(pagePath)}`);
|
|
441
367
|
}
|
|
442
|
-
console.log("");
|
|
443
368
|
|
|
444
|
-
//
|
|
445
|
-
if (
|
|
446
|
-
|
|
369
|
+
// --- Build asset payloads (non-HTML files as base64) ---
|
|
370
|
+
if (assetFiles.length > 0) {
|
|
371
|
+
console.log("");
|
|
372
|
+
info(`Packaging ${cyan(bold(String(assetFiles.length)))} asset${assetFiles.length > 1 ? "s" : ""}…`);
|
|
447
373
|
}
|
|
448
374
|
|
|
449
|
-
|
|
450
|
-
const assetCache = new Map();
|
|
451
|
-
const existingAssets = link?.assets || {};
|
|
452
|
-
const persistedCache = new Map(Object.entries(existingAssets));
|
|
453
|
-
const pagePayloads = [];
|
|
454
|
-
|
|
455
|
-
for (const { filePath, pagePath, relativePath } of htmlFiles) {
|
|
456
|
-
const stat = statSync(filePath);
|
|
457
|
-
info(`${bold(relativePath)} ${dim(`(${formatBytes(stat.size)})`)}`);
|
|
375
|
+
const assetPayloads = [];
|
|
458
376
|
|
|
459
|
-
|
|
377
|
+
for (const file of assetFiles) {
|
|
378
|
+
const content = readFileSync(file.filePath);
|
|
379
|
+
const base64 = content.toString("base64");
|
|
380
|
+
const mime = mimeFromExt(basename(file.filePath));
|
|
460
381
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
pagePayloads.push({
|
|
467
|
-
path: pagePath,
|
|
468
|
-
html,
|
|
382
|
+
assetPayloads.push({
|
|
383
|
+
path: file.relativePath,
|
|
384
|
+
content: base64,
|
|
385
|
+
mimeType: mime,
|
|
469
386
|
});
|
|
387
|
+
ok(`${cyan(file.relativePath)} ${dim(`(${formatBytes(file.size)})`)}`);
|
|
470
388
|
}
|
|
471
389
|
|
|
472
|
-
// Deploy
|
|
473
|
-
|
|
390
|
+
// --- Deploy ---
|
|
391
|
+
console.log("");
|
|
392
|
+
info(existingSlug ? `Deploying to ${cyan(existingSlug)}…` : "Deploying new site…");
|
|
393
|
+
|
|
394
|
+
const body = { pages: pagePayloads, assets: assetPayloads };
|
|
474
395
|
if (ttl) body.ttl = ttl;
|
|
475
396
|
if (existingSlug) body.slug = existingSlug;
|
|
476
397
|
if (title) body.title = title;
|
|
477
398
|
|
|
478
|
-
info(existingSlug ? `Re-deploying to ${cyan(existingSlug)}…` : "Deploying new multi-page site…");
|
|
479
|
-
|
|
480
399
|
try {
|
|
481
400
|
const data = await post("/api/sites", body);
|
|
482
401
|
|
|
@@ -487,7 +406,6 @@ async function deployMultiPage(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
487
406
|
url: data.url,
|
|
488
407
|
pageCount: data.pageCount || pagePayloads.length,
|
|
489
408
|
},
|
|
490
|
-
assets: Object.fromEntries(persistedCache),
|
|
491
409
|
});
|
|
492
410
|
|
|
493
411
|
if (json) {
|
|
@@ -498,15 +416,15 @@ async function deployMultiPage(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
498
416
|
console.log("");
|
|
499
417
|
console.log(` ${green("━".repeat(40))}`);
|
|
500
418
|
ok(`${bold("Live")} at ${cyan(`https://${data.url}`)}`);
|
|
501
|
-
console.log(` ${dim(`${data.pageCount || pagePayloads.length} pages · ${data.ttl} TTL`)}`);
|
|
419
|
+
console.log(` ${dim(`${data.pageCount || pagePayloads.length} pages · ${assetPayloads.length} assets · ${data.ttl} TTL`)}`);
|
|
502
420
|
console.log("");
|
|
503
421
|
|
|
504
|
-
for (const { pagePath } of
|
|
422
|
+
for (const { path: pagePath } of pagePayloads) {
|
|
505
423
|
const urlPath = pagePath === "/" ? "" : pagePath;
|
|
506
424
|
console.log(` ${cyan(`https://${data.url}${urlPath}`)}`);
|
|
507
425
|
}
|
|
508
426
|
console.log("");
|
|
509
|
-
console.log(` ${dim("Linked → .htmlhost
|
|
427
|
+
console.log(` ${dim("Linked → .htmlhost")}`);
|
|
510
428
|
console.log("");
|
|
511
429
|
} catch (e) {
|
|
512
430
|
err(`Deploy failed: ${e.message}`);
|
|
@@ -515,43 +433,46 @@ async function deployMultiPage(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
515
433
|
}
|
|
516
434
|
|
|
517
435
|
/**
|
|
518
|
-
* Recursively find all
|
|
519
|
-
*
|
|
436
|
+
* Recursively find all files in a directory.
|
|
437
|
+
* Skips ignored files/directories.
|
|
438
|
+
* Returns [{ filePath, relativePath, size }]
|
|
520
439
|
*/
|
|
521
|
-
function
|
|
440
|
+
function findAllFiles(baseDir, currentDir) {
|
|
522
441
|
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
523
442
|
const results = [];
|
|
524
443
|
|
|
525
444
|
for (const entry of entries) {
|
|
526
|
-
if (entry.name
|
|
445
|
+
if (shouldIgnore(entry.name)) continue;
|
|
527
446
|
|
|
528
447
|
const fullPath = join(currentDir, entry.name);
|
|
529
448
|
|
|
530
449
|
if (entry.isDirectory()) {
|
|
531
|
-
results.push(...
|
|
532
|
-
} else if (entry.
|
|
533
|
-
const relativePath = fullPath.slice(baseDir.length + 1);
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const dirRel = dirname(relativePath);
|
|
540
|
-
pagePath = dirRel === "." ? "/" : `/${dirRel}`;
|
|
541
|
-
} else {
|
|
542
|
-
// about.html → /about, blog/post.html → /blog/post
|
|
543
|
-
pagePath = "/" + relativePath.replace(/\.html$/, "");
|
|
450
|
+
results.push(...findAllFiles(baseDir, fullPath));
|
|
451
|
+
} else if (entry.isFile()) {
|
|
452
|
+
const relativePath = fullPath.slice(baseDir.length + 1);
|
|
453
|
+
let size;
|
|
454
|
+
try {
|
|
455
|
+
size = statSync(fullPath).size;
|
|
456
|
+
} catch {
|
|
457
|
+
continue;
|
|
544
458
|
}
|
|
545
|
-
|
|
546
|
-
results.push({ filePath: fullPath, relativePath, pagePath });
|
|
459
|
+
results.push({ filePath: fullPath, relativePath, size });
|
|
547
460
|
}
|
|
548
461
|
}
|
|
549
462
|
|
|
550
|
-
// Sort:
|
|
463
|
+
// Sort: index.html first, then HTML files, then others alphabetically
|
|
551
464
|
results.sort((a, b) => {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
465
|
+
const aIsIndex = a.relativePath === "index.html";
|
|
466
|
+
const bIsIndex = b.relativePath === "index.html";
|
|
467
|
+
if (aIsIndex) return -1;
|
|
468
|
+
if (bIsIndex) return 1;
|
|
469
|
+
|
|
470
|
+
const aIsHtml = a.relativePath.endsWith(".html");
|
|
471
|
+
const bIsHtml = b.relativePath.endsWith(".html");
|
|
472
|
+
if (aIsHtml && !bIsHtml) return -1;
|
|
473
|
+
if (!aIsHtml && bIsHtml) return 1;
|
|
474
|
+
|
|
475
|
+
return a.relativePath.localeCompare(b.relativePath);
|
|
555
476
|
});
|
|
556
477
|
|
|
557
478
|
return results;
|
package/src/commands/upload.mjs
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { statSync, readdirSync } from "node:fs";
|
|
2
|
-
import { resolve, basename, join } from "node:path";
|
|
3
|
-
import { uploadFile } from "../api.mjs";
|
|
4
|
-
import { ok, err, info, cyan, dim, bold, formatBytes, mimeFromExt } from "../ui.mjs";
|
|
5
|
-
import { getApi } from "../config.mjs";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* htmlhost upload <file|dir> — upload media assets and get URLs.
|
|
9
|
-
*/
|
|
10
|
-
export async function upload(args) {
|
|
11
|
-
const target = args.find((a) => !a.startsWith("--"));
|
|
12
|
-
if (!target) {
|
|
13
|
-
err("Usage: htmlhost upload <file|directory>");
|
|
14
|
-
process.exit(1);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const targetPath = resolve(target);
|
|
18
|
-
let stat;
|
|
19
|
-
try {
|
|
20
|
-
stat = statSync(targetPath);
|
|
21
|
-
} catch {
|
|
22
|
-
err(`Not found: ${target}`);
|
|
23
|
-
process.exit(1);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const files = stat.isDirectory()
|
|
27
|
-
? readdirSync(targetPath)
|
|
28
|
-
.filter((f) => !f.startsWith("."))
|
|
29
|
-
.map((f) => ({ path: join(targetPath, f), name: f }))
|
|
30
|
-
.filter((f) => statSync(f.path).isFile())
|
|
31
|
-
: [{ path: targetPath, name: basename(targetPath) }];
|
|
32
|
-
|
|
33
|
-
if (files.length === 0) {
|
|
34
|
-
err("No files found.");
|
|
35
|
-
process.exit(1);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
console.log("");
|
|
39
|
-
info(`Uploading ${bold(String(files.length))} file${files.length > 1 ? "s" : ""}…`);
|
|
40
|
-
console.log("");
|
|
41
|
-
|
|
42
|
-
const api = getApi();
|
|
43
|
-
const results = [];
|
|
44
|
-
|
|
45
|
-
for (const file of files) {
|
|
46
|
-
const size = statSync(file.path).size;
|
|
47
|
-
const mime = mimeFromExt(file.name);
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
const data = await uploadFile("/api/media", file.path, file.name, mime);
|
|
51
|
-
const url = `${api}${data.url}`;
|
|
52
|
-
results.push({ name: file.name, url, size });
|
|
53
|
-
ok(`${cyan(file.name)} ${dim(`(${formatBytes(size)})`)} → ${url}`);
|
|
54
|
-
} catch (e) {
|
|
55
|
-
err(`${file.name}: ${e.message}`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (results.length > 0) {
|
|
60
|
-
console.log("");
|
|
61
|
-
console.log(` ${dim("Use these URLs in your HTML:")}`);
|
|
62
|
-
console.log("");
|
|
63
|
-
for (const r of results) {
|
|
64
|
-
console.log(` ${dim(`<img src="${r.url}" />`)}`)
|
|
65
|
-
}
|
|
66
|
-
console.log("");
|
|
67
|
-
}
|
|
68
|
-
}
|