htmlhost-cli 1.0.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 +76 -0
- package/bin/htmlhost.mjs +3 -0
- package/package.json +30 -0
- package/src/api.mjs +71 -0
- package/src/cli.mjs +105 -0
- package/src/commands/delete.mjs +26 -0
- package/src/commands/deploy.mjs +67 -0
- package/src/commands/list.mjs +61 -0
- package/src/commands/login.mjs +45 -0
- package/src/commands/logout.mjs +7 -0
- package/src/commands/upload.mjs +68 -0
- package/src/commands/whoami.mjs +11 -0
- package/src/config.mjs +41 -0
- package/src/ui.mjs +117 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# htmlhost
|
|
2
|
+
|
|
3
|
+
Deploy HTML files from the terminal. [htmlhost.co](https://htmlhost.co)
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -g htmlhost
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Authenticate (get a token at htmlhost.co/settings#keys)
|
|
15
|
+
htmlhost login
|
|
16
|
+
|
|
17
|
+
# Deploy an HTML file
|
|
18
|
+
htmlhost deploy index.html
|
|
19
|
+
# → https://bold-fern-x3k.htmlhost.co
|
|
20
|
+
|
|
21
|
+
# Re-deploy to the same URL
|
|
22
|
+
htmlhost deploy index.html --slug bold-fern-x3k
|
|
23
|
+
|
|
24
|
+
# Upload assets
|
|
25
|
+
htmlhost upload logo.png
|
|
26
|
+
# → https://htmlhost.co/m/a3f91c2b
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
### `htmlhost login`
|
|
32
|
+
Authenticate with an API token. Generate one at [htmlhost.co/settings#keys](https://htmlhost.co/settings#keys).
|
|
33
|
+
|
|
34
|
+
### `htmlhost deploy <file> [options]`
|
|
35
|
+
Deploy an HTML file and get a live URL.
|
|
36
|
+
|
|
37
|
+
| Option | Description |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `--ttl <value>` | Set expiry: `1d`, `7d`, `30d`, `never` |
|
|
40
|
+
| `--slug <slug>` | Re-deploy to an existing site |
|
|
41
|
+
| `--title <title>` | Set the site title |
|
|
42
|
+
|
|
43
|
+
### `htmlhost list`
|
|
44
|
+
List all your sites with URLs, sizes, and expiry.
|
|
45
|
+
|
|
46
|
+
### `htmlhost delete <slug>`
|
|
47
|
+
Delete a site. Use `--force` to skip confirmation.
|
|
48
|
+
|
|
49
|
+
### `htmlhost upload <file|dir>`
|
|
50
|
+
Upload media assets to your library. Returns URLs you can use in your HTML.
|
|
51
|
+
|
|
52
|
+
### `htmlhost whoami`
|
|
53
|
+
Show current authenticated user and plan.
|
|
54
|
+
|
|
55
|
+
### `htmlhost logout`
|
|
56
|
+
Remove saved credentials.
|
|
57
|
+
|
|
58
|
+
## Config
|
|
59
|
+
|
|
60
|
+
Credentials are stored in `~/.htmlhostrc`:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"token": "hh_live_...",
|
|
65
|
+
"api": "https://htmlhost.co"
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Requirements
|
|
70
|
+
|
|
71
|
+
- Node.js 18+
|
|
72
|
+
- Zero dependencies
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
package/bin/htmlhost.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "htmlhost-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Deploy HTML files from the terminal — htmlhost.co CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"htmlhost": "bin/htmlhost.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"html",
|
|
19
|
+
"deploy",
|
|
20
|
+
"hosting",
|
|
21
|
+
"cli",
|
|
22
|
+
"htmlhost"
|
|
23
|
+
],
|
|
24
|
+
"author": "htmlhost.co",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/htmlhost/cli.git"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client wrapper for the htmlhost API.
|
|
3
|
+
*/
|
|
4
|
+
import { getToken, getApi } from "./config.mjs";
|
|
5
|
+
|
|
6
|
+
export class ApiError extends Error {
|
|
7
|
+
constructor(message, status) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function headers(extra = {}) {
|
|
14
|
+
const token = getToken();
|
|
15
|
+
if (!token) throw new ApiError("Not logged in. Run: htmlhost login", 0);
|
|
16
|
+
return {
|
|
17
|
+
Authorization: `Bearer ${token}`,
|
|
18
|
+
...extra,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function get(path) {
|
|
23
|
+
const res = await fetch(`${getApi()}${path}`, {
|
|
24
|
+
headers: headers(),
|
|
25
|
+
});
|
|
26
|
+
const data = await res.json();
|
|
27
|
+
if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
|
|
28
|
+
return data;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function post(path, body) {
|
|
32
|
+
const res = await fetch(`${getApi()}${path}`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: headers({ "Content-Type": "application/json" }),
|
|
35
|
+
body: JSON.stringify(body),
|
|
36
|
+
});
|
|
37
|
+
const data = await res.json();
|
|
38
|
+
if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
|
|
39
|
+
return data;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function del(path) {
|
|
43
|
+
const res = await fetch(`${getApi()}${path}`, {
|
|
44
|
+
method: "DELETE",
|
|
45
|
+
headers: headers(),
|
|
46
|
+
});
|
|
47
|
+
const data = await res.json();
|
|
48
|
+
if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Upload a file via multipart/form-data.
|
|
54
|
+
*/
|
|
55
|
+
export async function uploadFile(path, filePath, fileName, mimeType) {
|
|
56
|
+
const { readFileSync } = await import("node:fs");
|
|
57
|
+
const fileBuffer = readFileSync(filePath);
|
|
58
|
+
const blob = new Blob([fileBuffer], { type: mimeType });
|
|
59
|
+
|
|
60
|
+
const form = new FormData();
|
|
61
|
+
form.append("file", blob, fileName);
|
|
62
|
+
|
|
63
|
+
const res = await fetch(`${getApi()}${path}`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { Authorization: `Bearer ${getToken()}` },
|
|
66
|
+
body: form,
|
|
67
|
+
});
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
|
|
70
|
+
return data;
|
|
71
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* htmlhost CLI — command router.
|
|
3
|
+
*/
|
|
4
|
+
import { bold, dim, cyan, err } from "./ui.mjs";
|
|
5
|
+
import { ApiError } from "./api.mjs";
|
|
6
|
+
|
|
7
|
+
const VERSION = "1.0.0";
|
|
8
|
+
|
|
9
|
+
const HELP = `
|
|
10
|
+
${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
|
|
11
|
+
|
|
12
|
+
${bold("Usage:")}
|
|
13
|
+
${cyan("htmlhost login")} Authenticate with an API token
|
|
14
|
+
${cyan("htmlhost deploy")} <file> [options] Deploy an HTML file
|
|
15
|
+
${cyan("htmlhost list")} List your sites
|
|
16
|
+
${cyan("htmlhost delete")} <slug> Delete a site
|
|
17
|
+
${cyan("htmlhost upload")} <file|dir> Upload media assets
|
|
18
|
+
${cyan("htmlhost whoami")} Show current user
|
|
19
|
+
${cyan("htmlhost logout")} Remove saved token
|
|
20
|
+
|
|
21
|
+
${bold("Deploy options:")}
|
|
22
|
+
--ttl <value> Set TTL: 1d, 7d, 30d, never
|
|
23
|
+
--slug <slug> Re-deploy to an existing site
|
|
24
|
+
--title <title> Set the site title
|
|
25
|
+
|
|
26
|
+
${bold("Delete options:")}
|
|
27
|
+
--force, -f Skip confirmation prompt
|
|
28
|
+
|
|
29
|
+
${bold("Examples:")}
|
|
30
|
+
${dim("$")} htmlhost deploy index.html
|
|
31
|
+
${dim("$")} htmlhost deploy index.html --ttl 30d --title "My Portfolio"
|
|
32
|
+
${dim("$")} htmlhost deploy build/index.html --slug my-project
|
|
33
|
+
${dim("$")} htmlhost upload logo.png
|
|
34
|
+
${dim("$")} htmlhost upload ./assets/
|
|
35
|
+
${dim("$")} htmlhost delete old-project --force
|
|
36
|
+
|
|
37
|
+
${dim(`Docs: https://htmlhost.co/docs`)}
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
export async function run(argv) {
|
|
41
|
+
const command = argv[0];
|
|
42
|
+
const args = argv.slice(1);
|
|
43
|
+
|
|
44
|
+
if (!command || command === "--help" || command === "-h") {
|
|
45
|
+
console.log(HELP);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (command === "--version" || command === "-v") {
|
|
50
|
+
console.log(VERSION);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
switch (command) {
|
|
56
|
+
case "login": {
|
|
57
|
+
const { login } = await import("./commands/login.mjs");
|
|
58
|
+
await login();
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case "logout": {
|
|
62
|
+
const { logout } = await import("./commands/logout.mjs");
|
|
63
|
+
logout();
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case "whoami": {
|
|
67
|
+
const { whoami } = await import("./commands/whoami.mjs");
|
|
68
|
+
await whoami();
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "deploy": {
|
|
72
|
+
const { deploy } = await import("./commands/deploy.mjs");
|
|
73
|
+
await deploy(args);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case "list":
|
|
77
|
+
case "ls": {
|
|
78
|
+
const { list } = await import("./commands/list.mjs");
|
|
79
|
+
await list();
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case "delete":
|
|
83
|
+
case "rm": {
|
|
84
|
+
const { deleteSite } = await import("./commands/delete.mjs");
|
|
85
|
+
await deleteSite(args);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "upload": {
|
|
89
|
+
const { upload } = await import("./commands/upload.mjs");
|
|
90
|
+
await upload(args);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
default:
|
|
94
|
+
err(`Unknown command: ${command}`);
|
|
95
|
+
console.log(` Run ${cyan("htmlhost --help")} for usage.`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if (e instanceof ApiError) {
|
|
100
|
+
err(e.message);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { del } from "../api.mjs";
|
|
2
|
+
import { ok, err, confirm, cyan, bold } from "../ui.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* htmlhost delete <slug> [--force]
|
|
6
|
+
*/
|
|
7
|
+
export async function deleteSite(args) {
|
|
8
|
+
const slug = args.find((a) => !a.startsWith("--"));
|
|
9
|
+
if (!slug) {
|
|
10
|
+
err("Usage: htmlhost delete <slug>");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const force = args.includes("--force") || args.includes("-f");
|
|
15
|
+
|
|
16
|
+
if (!force) {
|
|
17
|
+
const yes = await confirm(` Delete ${cyan(bold(`${slug}.htmlhost.co`))}?`);
|
|
18
|
+
if (!yes) {
|
|
19
|
+
console.log(" Cancelled.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await del(`/api/sites?slug=${encodeURIComponent(slug)}`);
|
|
25
|
+
ok(`Deleted ${cyan(slug)}`);
|
|
26
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
import { post } from "../api.mjs";
|
|
4
|
+
import { ok, err, info, cyan, dim, bold, formatBytes } from "../ui.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* htmlhost deploy <file> [--ttl 7d] [--slug existing-slug] [--title "My Site"]
|
|
8
|
+
*/
|
|
9
|
+
export async function deploy(args) {
|
|
10
|
+
const file = args.find((a) => !a.startsWith("--"));
|
|
11
|
+
if (!file) {
|
|
12
|
+
err("Usage: htmlhost deploy <file.html> [--ttl 7d] [--slug my-site]");
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ttl = getFlag(args, "--ttl");
|
|
17
|
+
const slug = getFlag(args, "--slug");
|
|
18
|
+
const title = getFlag(args, "--title");
|
|
19
|
+
|
|
20
|
+
const filePath = resolve(file);
|
|
21
|
+
|
|
22
|
+
// Verify file exists
|
|
23
|
+
let stat;
|
|
24
|
+
try {
|
|
25
|
+
stat = statSync(filePath);
|
|
26
|
+
} catch {
|
|
27
|
+
err(`File not found: ${file}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (stat.isDirectory()) {
|
|
32
|
+
err("Directory deploy is not supported yet. Provide an HTML file.");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const html = readFileSync(filePath, "utf8");
|
|
37
|
+
const name = basename(filePath);
|
|
38
|
+
|
|
39
|
+
info(`Reading ${cyan(name)} ${dim(`(${formatBytes(stat.size)})`)}`);
|
|
40
|
+
info("Deploying…");
|
|
41
|
+
|
|
42
|
+
const body = { html };
|
|
43
|
+
if (ttl) body.ttl = ttl;
|
|
44
|
+
if (slug) body.slug = slug;
|
|
45
|
+
if (title) body.title = title;
|
|
46
|
+
|
|
47
|
+
const data = await post("/api/sites", body);
|
|
48
|
+
|
|
49
|
+
console.log("");
|
|
50
|
+
ok(`${bold("Live")} at ${cyan(`https://${data.url}`)}`);
|
|
51
|
+
if (data.version > 1) {
|
|
52
|
+
console.log(` ${dim(`Version ${data.version} · ${data.ttl} TTL`)}`);
|
|
53
|
+
} else {
|
|
54
|
+
console.log(` ${dim(`${data.slug} · ${data.ttl} TTL`)}`);
|
|
55
|
+
}
|
|
56
|
+
if (data.expiresAt) {
|
|
57
|
+
const d = new Date(data.expiresAt);
|
|
58
|
+
console.log(` ${dim(`Expires ${d.toLocaleDateString()}`)}`);
|
|
59
|
+
}
|
|
60
|
+
console.log("");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getFlag(args, flag) {
|
|
64
|
+
const idx = args.indexOf(flag);
|
|
65
|
+
if (idx === -1 || idx >= args.length - 1) return null;
|
|
66
|
+
return args[idx + 1];
|
|
67
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { get } from "../api.mjs";
|
|
2
|
+
import { dim, cyan, bold, formatBytes, yellow } from "../ui.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* htmlhost list — show all sites.
|
|
6
|
+
*/
|
|
7
|
+
export async function list() {
|
|
8
|
+
const data = await get("/api/sites");
|
|
9
|
+
const sites = data.sites || [];
|
|
10
|
+
|
|
11
|
+
if (sites.length === 0) {
|
|
12
|
+
console.log("");
|
|
13
|
+
console.log(` ${dim("No sites yet.")} Deploy one with: ${cyan("htmlhost deploy index.html")}`);
|
|
14
|
+
console.log("");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
console.log("");
|
|
19
|
+
|
|
20
|
+
// Table header
|
|
21
|
+
const slugW = Math.max(20, ...sites.map((s) => s.slug.length)) + 2;
|
|
22
|
+
const hdr = [
|
|
23
|
+
pad("SLUG", slugW),
|
|
24
|
+
pad("URL", 38),
|
|
25
|
+
pad("SIZE", 10),
|
|
26
|
+
pad("TTL", 8),
|
|
27
|
+
pad("EXPIRES", 14),
|
|
28
|
+
].join("");
|
|
29
|
+
console.log(` ${dim(hdr)}`);
|
|
30
|
+
console.log(` ${dim("─".repeat(hdr.length))}`);
|
|
31
|
+
|
|
32
|
+
for (const s of sites) {
|
|
33
|
+
const expires = s.expiresAt ? relTime(s.expiresAt) : "never";
|
|
34
|
+
const expiresColor = s.expiresAt && new Date(s.expiresAt) < new Date() ? yellow : dim;
|
|
35
|
+
console.log(
|
|
36
|
+
` ${pad(cyan(s.slug), slugW)}${pad(`${s.slug}.htmlhost.co`, 38)}${pad(s.size, 10)}${pad(s.ttl, 8)}${expiresColor(expires)}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log("");
|
|
41
|
+
console.log(
|
|
42
|
+
` ${bold(String(sites.length))} sites · ${formatBytes(data.usage.totalBytes)} used` +
|
|
43
|
+
(data.usage.maxBytes ? ` / ${formatBytes(data.usage.maxBytes)}` : "") +
|
|
44
|
+
` · ${dim(data.usage.plan)}`
|
|
45
|
+
);
|
|
46
|
+
console.log("");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pad(str, width) {
|
|
50
|
+
const s = String(str);
|
|
51
|
+
return s + " ".repeat(Math.max(0, width - s.length));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function relTime(iso) {
|
|
55
|
+
const diff = new Date(iso) - Date.now();
|
|
56
|
+
if (diff < 0) return "expired";
|
|
57
|
+
const days = Math.floor(diff / 86400000);
|
|
58
|
+
if (days === 0) return "today";
|
|
59
|
+
if (days === 1) return "tomorrow";
|
|
60
|
+
return `${days}d`;
|
|
61
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { writeConfig, getApi } from "../config.mjs";
|
|
2
|
+
import { ok, err, info, dim, cyan, bold } from "../ui.mjs";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
|
|
5
|
+
export async function login() {
|
|
6
|
+
console.log("");
|
|
7
|
+
info(`Log in to ${cyan(bold("htmlhost.co"))}`);
|
|
8
|
+
console.log(`${dim(" Generate a token at")} ${getApi()}/settings#keys`);
|
|
9
|
+
console.log("");
|
|
10
|
+
|
|
11
|
+
const token = await new Promise((resolve) => {
|
|
12
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
+
rl.question(` Paste your API token: `, (answer) => {
|
|
14
|
+
rl.close();
|
|
15
|
+
resolve(answer.trim());
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (!token) {
|
|
20
|
+
err("No token provided.");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Validate the token
|
|
25
|
+
info("Verifying…");
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`${getApi()}/api/tokens/me`, {
|
|
29
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
err("Invalid or expired token.");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const data = await res.json();
|
|
38
|
+
writeConfig({ token, api: getApi() });
|
|
39
|
+
ok(`Logged in as ${cyan(data.email)} (${data.plan})`);
|
|
40
|
+
console.log(`${dim(" Token saved to ~/.htmlhostrc")}`);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
err(`Connection failed: ${e.message}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { get } from "../api.mjs";
|
|
2
|
+
import { ok, cyan, dim, bold, formatBytes } from "../ui.mjs";
|
|
3
|
+
|
|
4
|
+
export async function whoami() {
|
|
5
|
+
const data = await get("/api/tokens/me");
|
|
6
|
+
console.log("");
|
|
7
|
+
ok(`${cyan(bold(data.email))} ${dim(`(${data.plan})`)}`);
|
|
8
|
+
if (data.name) console.log(` ${dim("Name:")} ${data.name}`);
|
|
9
|
+
if (data.handle) console.log(` ${dim("Handle:")} @${data.handle}`);
|
|
10
|
+
console.log("");
|
|
11
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config file management — reads/writes ~/.htmlhostrc
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
const CONFIG_PATH = join(homedir(), ".htmlhostrc");
|
|
9
|
+
const DEFAULT_API = "https://htmlhost.co";
|
|
10
|
+
|
|
11
|
+
export function readConfig() {
|
|
12
|
+
try {
|
|
13
|
+
const raw = readFileSync(CONFIG_PATH, "utf8");
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function writeConfig(data) {
|
|
21
|
+
const existing = readConfig();
|
|
22
|
+
const merged = { ...existing, ...data };
|
|
23
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
|
24
|
+
try {
|
|
25
|
+
chmodSync(CONFIG_PATH, 0o600); // read/write only by owner
|
|
26
|
+
} catch { /* Windows doesn't support chmod */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function clearConfig() {
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(CONFIG_PATH, "{}\n", "utf8");
|
|
32
|
+
} catch { /* ignore */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getToken() {
|
|
36
|
+
return readConfig().token || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getApi() {
|
|
40
|
+
return readConfig().api || DEFAULT_API;
|
|
41
|
+
}
|
package/src/ui.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal output helpers — colors via ANSI codes, no dependencies.
|
|
3
|
+
*/
|
|
4
|
+
const c = (code) => (s) => `\x1b[${code}m${s}\x1b[0m`;
|
|
5
|
+
|
|
6
|
+
export const bold = c("1");
|
|
7
|
+
export const dim = c("2");
|
|
8
|
+
export const green = c("32");
|
|
9
|
+
export const red = c("31");
|
|
10
|
+
export const cyan = c("36");
|
|
11
|
+
export const yellow = c("33");
|
|
12
|
+
export const gray = c("90");
|
|
13
|
+
export const magenta = c("35");
|
|
14
|
+
|
|
15
|
+
export const ok = (msg) => console.log(`${green("✓")} ${msg}`);
|
|
16
|
+
export const err = (msg) => console.error(`${red("✗")} ${msg}`);
|
|
17
|
+
export const info = (msg) => console.log(`${cyan("●")} ${msg}`);
|
|
18
|
+
export const warn = (msg) => console.log(`${yellow("!")} ${msg}`);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Simple readline prompt (hidden input support).
|
|
22
|
+
*/
|
|
23
|
+
export async function prompt(question, { hidden = false } = {}) {
|
|
24
|
+
const { createInterface } = await import("node:readline");
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const rl = createInterface({
|
|
27
|
+
input: process.stdin,
|
|
28
|
+
output: process.stdout,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (hidden) {
|
|
32
|
+
process.stdout.write(question);
|
|
33
|
+
const stdin = process.stdin;
|
|
34
|
+
const wasRaw = stdin.isRaw;
|
|
35
|
+
if (stdin.setRawMode) stdin.setRawMode(true);
|
|
36
|
+
|
|
37
|
+
let input = "";
|
|
38
|
+
const onData = (ch) => {
|
|
39
|
+
const c = ch.toString();
|
|
40
|
+
if (c === "\n" || c === "\r") {
|
|
41
|
+
if (stdin.setRawMode) stdin.setRawMode(wasRaw);
|
|
42
|
+
stdin.removeListener("data", onData);
|
|
43
|
+
process.stdout.write("\n");
|
|
44
|
+
rl.close();
|
|
45
|
+
resolve(input);
|
|
46
|
+
} else if (c === "\u0003") {
|
|
47
|
+
process.exit(1);
|
|
48
|
+
} else if (c === "\u007f" || c === "\b") {
|
|
49
|
+
input = input.slice(0, -1);
|
|
50
|
+
} else {
|
|
51
|
+
input += c;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
stdin.on("data", onData);
|
|
55
|
+
} else {
|
|
56
|
+
rl.question(question, (answer) => {
|
|
57
|
+
rl.close();
|
|
58
|
+
resolve(answer);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Confirm y/N prompt.
|
|
66
|
+
*/
|
|
67
|
+
export async function confirm(question) {
|
|
68
|
+
const { createInterface } = await import("node:readline");
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
const rl = createInterface({
|
|
71
|
+
input: process.stdin,
|
|
72
|
+
output: process.stdout,
|
|
73
|
+
});
|
|
74
|
+
rl.question(`${question} ${dim("(y/N)")} `, (answer) => {
|
|
75
|
+
rl.close();
|
|
76
|
+
resolve(answer.trim().toLowerCase() === "y");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Format bytes to human readable.
|
|
83
|
+
*/
|
|
84
|
+
export function formatBytes(bytes) {
|
|
85
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
86
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
87
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Detect MIME type from file extension.
|
|
92
|
+
*/
|
|
93
|
+
export function mimeFromExt(filename) {
|
|
94
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
95
|
+
const map = {
|
|
96
|
+
png: "image/png",
|
|
97
|
+
jpg: "image/jpeg",
|
|
98
|
+
jpeg: "image/jpeg",
|
|
99
|
+
gif: "image/gif",
|
|
100
|
+
svg: "image/svg+xml",
|
|
101
|
+
webp: "image/webp",
|
|
102
|
+
ico: "image/x-icon",
|
|
103
|
+
woff: "font/woff",
|
|
104
|
+
woff2: "font/woff2",
|
|
105
|
+
ttf: "font/ttf",
|
|
106
|
+
otf: "font/otf",
|
|
107
|
+
css: "text/css",
|
|
108
|
+
js: "text/javascript",
|
|
109
|
+
json: "application/json",
|
|
110
|
+
mp4: "video/mp4",
|
|
111
|
+
webm: "video/webm",
|
|
112
|
+
mp3: "audio/mpeg",
|
|
113
|
+
wav: "audio/wav",
|
|
114
|
+
pdf: "application/pdf",
|
|
115
|
+
};
|
|
116
|
+
return map[ext] || "application/octet-stream";
|
|
117
|
+
}
|