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 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
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../src/cli.mjs";
3
+ run(process.argv.slice(2));
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,7 @@
1
+ import { clearConfig } from "../config.mjs";
2
+ import { ok } from "../ui.mjs";
3
+
4
+ export function logout() {
5
+ clearConfig();
6
+ ok("Logged out. Token removed from ~/.htmlhostrc");
7
+ }
@@ -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
+ }