htmlhost-cli 2.3.0 → 2.5.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 +11 -0
- package/package.json +1 -1
- package/src/api.mjs +52 -0
- package/src/cli.mjs +25 -6
- package/src/commands/agents.mjs +95 -0
- package/src/commands/clone.mjs +8 -0
- package/src/commands/deploy.mjs +19 -0
- package/src/commands/upload.mjs +170 -0
- package/src/update.mjs +1 -1
package/README.md
CHANGED
|
@@ -131,6 +131,17 @@ push changes and `htmlhost pull` to sync remote edits.
|
|
|
131
131
|
|
|
132
132
|
Use `--force` to overwrite an existing non-empty directory.
|
|
133
133
|
|
|
134
|
+
### `htmlhost agents [dir] [options]`
|
|
135
|
+
Teach your AI coding tool (Claude Code, Codex, Cursor, Copilot…) how htmlhost works: static hosting rules, this CLI, and how to build forms that deliver to your inbox. Writes a managed block into `AGENTS.md` (your own content is kept) and adds an `@AGENTS.md` import to `CLAUDE.md`. `clone` adds it automatically, and `deploy` keeps it current. Agent files are never deployed.
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
htmlhost agents # add or refresh
|
|
139
|
+
htmlhost agents --print # print the guide instead
|
|
140
|
+
htmlhost agents --no-claude # don't touch CLAUDE.md
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The same guide is at https://htmlhost.co/agents.md.
|
|
144
|
+
|
|
134
145
|
### `htmlhost list`
|
|
135
146
|
List all your sites with titles, sizes, TTL, and expiry. Multi-page sites are marked with 📄.
|
|
136
147
|
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -112,6 +112,57 @@ export async function uploadFile(path, filePath, fileName, mimeType) {
|
|
|
112
112
|
}, `upload ${fileName}`);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
const DIRECT_UPLOAD_THRESHOLD = 4 * 1024 * 1024; // 4 MB
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Upload a media file. Automatically switches between multipart POST (<=4MB)
|
|
119
|
+
* and direct-to-R2 presigned upload (>4MB) to stay within Vercel body limits.
|
|
120
|
+
*/
|
|
121
|
+
export async function uploadMediaFile(filePath, fileName, mimeType) {
|
|
122
|
+
const { statSync, readFileSync } = await import("node:fs");
|
|
123
|
+
const stats = statSync(filePath);
|
|
124
|
+
const size = stats.size;
|
|
125
|
+
|
|
126
|
+
if (size <= DIRECT_UPLOAD_THRESHOLD) {
|
|
127
|
+
const data = await uploadFile("/api/media", filePath, fileName, mimeType);
|
|
128
|
+
return {
|
|
129
|
+
...data,
|
|
130
|
+
fullUrl: `${getApi()}${data.url}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Large file direct-to-R2 flow
|
|
135
|
+
return withRetry(async () => {
|
|
136
|
+
// 1. Get presigned upload URL
|
|
137
|
+
const presign = await post("/api/media/presign", {
|
|
138
|
+
filename: fileName,
|
|
139
|
+
mimeType,
|
|
140
|
+
size,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// 2. Direct PUT to R2
|
|
144
|
+
const fileBuffer = readFileSync(filePath);
|
|
145
|
+
const putRes = await fetch(presign.uploadUrl, {
|
|
146
|
+
method: "PUT",
|
|
147
|
+
headers: { "Content-Type": mimeType },
|
|
148
|
+
body: fileBuffer,
|
|
149
|
+
});
|
|
150
|
+
if (!putRes.ok) {
|
|
151
|
+
throw new ApiError(`Direct upload failed: HTTP ${putRes.status}`, putRes.status);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 3. Confirm upload
|
|
155
|
+
const confirmed = await post("/api/media/confirm", {
|
|
156
|
+
key: presign.key,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
...confirmed,
|
|
161
|
+
fullUrl: `${getApi()}${confirmed.url}`,
|
|
162
|
+
};
|
|
163
|
+
}, `upload ${fileName} (direct)`);
|
|
164
|
+
}
|
|
165
|
+
|
|
115
166
|
/**
|
|
116
167
|
* Download a file from a URL as a Buffer.
|
|
117
168
|
* Used for fetching binary assets via presigned R2 download URLs.
|
|
@@ -124,3 +175,4 @@ export async function downloadBuffer(url) {
|
|
|
124
175
|
return Buffer.from(arrayBuffer);
|
|
125
176
|
}
|
|
126
177
|
|
|
178
|
+
|
package/src/cli.mjs
CHANGED
|
@@ -5,15 +5,17 @@ import { bold, dim, cyan, err } from "./ui.mjs";
|
|
|
5
5
|
import { ApiError } from "./api.mjs";
|
|
6
6
|
import { startUpdateCheck, applyUpdate } from "./update.mjs";
|
|
7
7
|
|
|
8
|
-
const VERSION = "2.
|
|
8
|
+
const VERSION = "2.5.0";
|
|
9
9
|
|
|
10
10
|
const HELP = `
|
|
11
11
|
${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
|
|
12
12
|
|
|
13
13
|
${bold("Usage:")}
|
|
14
14
|
${cyan("htmlhost deploy")} [file|dir] [opts] Deploy file or directory
|
|
15
|
+
${cyan("htmlhost upload")} <file|dir...> [opts] Upload media file or directory
|
|
15
16
|
${cyan("htmlhost pull")} [slug] [opts] Pull remote changes to local
|
|
16
17
|
${cyan("htmlhost clone")} <slug> [dir] Clone a site to a local directory
|
|
18
|
+
${cyan("htmlhost agents")} [dir] Teach your AI coding tool htmlhost (AGENTS.md)
|
|
17
19
|
${cyan("htmlhost list")} List your sites
|
|
18
20
|
${cyan("htmlhost delete")} <slug> Delete a site
|
|
19
21
|
${cyan("htmlhost login")} Authenticate with an API token
|
|
@@ -29,10 +31,19 @@ const HELP = `
|
|
|
29
31
|
--no-assets Skip auto-uploading local assets (single-file only)
|
|
30
32
|
--no-pull-check Skip remote change detection before deploy
|
|
31
33
|
|
|
34
|
+
${bold("Upload options:")}
|
|
35
|
+
--open Open the uploaded media URL in browser
|
|
36
|
+
--no-copy Skip copying the URL to clipboard
|
|
37
|
+
--json Output response as JSON
|
|
38
|
+
|
|
32
39
|
${bold("Pull options:")}
|
|
33
40
|
--force, -f Pull without confirmation prompt
|
|
34
41
|
--dry-run Show what would change without writing
|
|
35
42
|
|
|
43
|
+
${bold("Agents options:")}
|
|
44
|
+
--print Print the guide instead of writing files
|
|
45
|
+
--no-claude Don't add the @AGENTS.md import to CLAUDE.md
|
|
46
|
+
|
|
36
47
|
${bold("Delete options:")}
|
|
37
48
|
--force, -f Skip confirmation prompt
|
|
38
49
|
|
|
@@ -47,13 +58,11 @@ const HELP = `
|
|
|
47
58
|
${dim("$")} htmlhost deploy ${dim("# deploys cwd as a site")}
|
|
48
59
|
${dim("$")} htmlhost deploy ./my-project ${dim("# deploys a specific folder")}
|
|
49
60
|
${dim("$")} htmlhost deploy page.html ${dim("# deploys a single file")}
|
|
50
|
-
${dim("$")} htmlhost
|
|
51
|
-
${dim("$")} htmlhost
|
|
52
|
-
${dim("$")} htmlhost
|
|
61
|
+
${dim("$")} htmlhost upload assets/hero.png ${dim("# upload image, copy URL to clipboard")}
|
|
62
|
+
${dim("$")} htmlhost upload assets/ ${dim("# upload all files in directory")}
|
|
63
|
+
${dim("$")} htmlhost upload photo.jpg --open
|
|
53
64
|
${dim("$")} htmlhost pull ${dim("# pull remote changes")}
|
|
54
|
-
${dim("$")} htmlhost pull my-site --force ${dim("# pull without prompting")}
|
|
55
65
|
${dim("$")} htmlhost clone my-site ${dim("# clone into ./my-site/")}
|
|
56
|
-
${dim("$")} htmlhost clone my-site ./proj ${dim("# clone into ./proj/")}
|
|
57
66
|
${dim("$")} htmlhost delete old-project --force
|
|
58
67
|
|
|
59
68
|
${bold("Update:")}
|
|
@@ -109,6 +118,11 @@ export async function run(argv) {
|
|
|
109
118
|
await deploy(args, { json: jsonMode });
|
|
110
119
|
break;
|
|
111
120
|
}
|
|
121
|
+
case "upload": {
|
|
122
|
+
const { upload } = await import("./commands/upload.mjs");
|
|
123
|
+
await upload(args, { json: jsonMode });
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
112
126
|
case "pull": {
|
|
113
127
|
const { pull } = await import("./commands/pull.mjs");
|
|
114
128
|
await pull(args);
|
|
@@ -119,6 +133,11 @@ export async function run(argv) {
|
|
|
119
133
|
await clone(args);
|
|
120
134
|
break;
|
|
121
135
|
}
|
|
136
|
+
case "agents": {
|
|
137
|
+
const { agents } = await import("./commands/agents.mjs");
|
|
138
|
+
await agents(args);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
122
141
|
case "list":
|
|
123
142
|
case "ls": {
|
|
124
143
|
const { list } = await import("./commands/list.mjs");
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* htmlhost agents [dir] [--print] [--no-claude]
|
|
3
|
+
*
|
|
4
|
+
* Write htmlhost's instructions for AI coding agents (hosting rules, CLI, forms)
|
|
5
|
+
* into the project's AGENTS.md, so tools like Codex, Cursor, Copilot and Claude
|
|
6
|
+
* Code build features that work on htmlhost. The guide is fetched from
|
|
7
|
+
* https://htmlhost.co/agents.md so it's always current.
|
|
8
|
+
*
|
|
9
|
+
* Only a marked block is managed: anything else in AGENTS.md is left alone,
|
|
10
|
+
* and running the command again replaces just that block. Claude Code reads
|
|
11
|
+
* CLAUDE.md rather than AGENTS.md, so an `@AGENTS.md` import is added there too.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { getApi } from "../config.mjs";
|
|
16
|
+
import { ok, err, dim, cyan } from "../ui.mjs";
|
|
17
|
+
|
|
18
|
+
const START = "<!-- htmlhost:agents:start — managed by `htmlhost agents`; edits inside this block are replaced -->";
|
|
19
|
+
const END = "<!-- htmlhost:agents:end -->";
|
|
20
|
+
const BLOCK_RE = /<!-- htmlhost:agents:start[^>]*-->[\s\S]*?<!-- htmlhost:agents:end -->/;
|
|
21
|
+
|
|
22
|
+
/** Fetch the current guide. Throws on network or HTTP failure. */
|
|
23
|
+
export async function fetchAgentGuide(timeoutMs = 10000) {
|
|
24
|
+
const res = await fetch(`${getApi()}/agents.md`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
25
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
26
|
+
return (await res.text()).trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether a project already carries the managed block. */
|
|
30
|
+
export function hasAgentGuide(dir) {
|
|
31
|
+
const path = join(dir, "AGENTS.md");
|
|
32
|
+
return existsSync(path) && BLOCK_RE.test(readFileSync(path, "utf8"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write (or refresh) the guide in `dir`. Returns what changed, for reporting.
|
|
37
|
+
* `claude: false` leaves CLAUDE.md untouched.
|
|
38
|
+
*/
|
|
39
|
+
export function writeAgentGuide(dir, guide, { claude = true } = {}) {
|
|
40
|
+
const changed = [];
|
|
41
|
+
const block = `${START}\n${guide}\n${END}`;
|
|
42
|
+
|
|
43
|
+
const agentsPath = join(dir, "AGENTS.md");
|
|
44
|
+
const before = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null;
|
|
45
|
+
const after = before === null
|
|
46
|
+
? `${block}\n`
|
|
47
|
+
: BLOCK_RE.test(before)
|
|
48
|
+
? before.replace(BLOCK_RE, () => block)
|
|
49
|
+
: `${before.replace(/\s*$/, "")}\n\n${block}\n`;
|
|
50
|
+
if (after !== before) {
|
|
51
|
+
writeFileSync(agentsPath, after, "utf8");
|
|
52
|
+
changed.push(before === null ? "created AGENTS.md" : "updated AGENTS.md");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (claude) {
|
|
56
|
+
const claudePath = join(dir, "CLAUDE.md");
|
|
57
|
+
const current = existsSync(claudePath) ? readFileSync(claudePath, "utf8") : null;
|
|
58
|
+
if (current === null) {
|
|
59
|
+
writeFileSync(claudePath, "@AGENTS.md\n", "utf8");
|
|
60
|
+
changed.push("created CLAUDE.md (imports AGENTS.md)");
|
|
61
|
+
} else if (!/@AGENTS\.md\b/.test(current)) {
|
|
62
|
+
writeFileSync(claudePath, `${current.replace(/\s*$/, "")}\n\n@AGENTS.md\n`, "utf8");
|
|
63
|
+
changed.push("added @AGENTS.md to CLAUDE.md");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return changed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function agents(args) {
|
|
70
|
+
const positionals = args.filter((a) => !a.startsWith("-"));
|
|
71
|
+
const dir = resolve(positionals[0] || ".");
|
|
72
|
+
const print = args.includes("--print");
|
|
73
|
+
|
|
74
|
+
let guide;
|
|
75
|
+
try {
|
|
76
|
+
guide = await fetchAgentGuide();
|
|
77
|
+
} catch (e) {
|
|
78
|
+
err(`Couldn't download the agent guide from ${getApi()}/agents.md (${e.message}).`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (print) {
|
|
83
|
+
console.log(guide);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const changed = writeAgentGuide(dir, guide, { claude: !args.includes("--no-claude") });
|
|
88
|
+
if (changed.length === 0) {
|
|
89
|
+
ok("AI agent instructions are already up to date.");
|
|
90
|
+
} else {
|
|
91
|
+
ok(`AI agent instructions ${changed.join(", ")}.`);
|
|
92
|
+
}
|
|
93
|
+
console.log(` ${dim("Your AI coding tool now knows how htmlhost hosting and forms work.")}`);
|
|
94
|
+
console.log(` ${dim("Refresh any time with")} ${cyan("htmlhost agents")}${dim(". These files are never deployed.")}`);
|
|
95
|
+
}
|
package/src/commands/clone.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
10
10
|
import { join, dirname, resolve } from "node:path";
|
|
11
11
|
import { get, downloadBuffer } from "../api.mjs";
|
|
12
|
+
import { fetchAgentGuide, writeAgentGuide } from "./agents.mjs";
|
|
12
13
|
import {
|
|
13
14
|
ok,
|
|
14
15
|
err,
|
|
@@ -164,6 +165,12 @@ export async function clone(args) {
|
|
|
164
165
|
const linkPath = join(destDir, LINK_FILE);
|
|
165
166
|
writeFileSync(linkPath, JSON.stringify(linkData, null, 2) + "\n", "utf8");
|
|
166
167
|
|
|
168
|
+
// Teach the user's AI coding tool how htmlhost works. Best-effort: a
|
|
169
|
+
// failed download mustn't fail the clone.
|
|
170
|
+
const agentsWritten = await fetchAgentGuide(5000)
|
|
171
|
+
.then((guide) => writeAgentGuide(destDir, guide).length > 0)
|
|
172
|
+
.catch(() => false);
|
|
173
|
+
|
|
167
174
|
// --- Done ---
|
|
168
175
|
console.log("");
|
|
169
176
|
console.log(` ${green("━".repeat(40))}`);
|
|
@@ -174,5 +181,6 @@ export async function clone(args) {
|
|
|
174
181
|
console.log("");
|
|
175
182
|
console.log(` ${dim("Deploy changes:")} ${cyan("htmlhost deploy")}`);
|
|
176
183
|
console.log(` ${dim("Pull updates:")} ${cyan("htmlhost pull")}`);
|
|
184
|
+
if (agentsWritten) console.log(` ${dim("AI agent guide:")} ${cyan("AGENTS.md")} ${dim("(not deployed)")}`);
|
|
177
185
|
console.log("");
|
|
178
186
|
}
|
package/src/commands/deploy.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
|
|
|
5
5
|
import { post } from "../api.mjs";
|
|
6
6
|
import { ok, err, info, warn, cyan, dim, bold, yellow, green, formatBytes, mimeFromExt } from "../ui.mjs";
|
|
7
7
|
import { checkRemoteChanges } from "./pull.mjs";
|
|
8
|
+
import { fetchAgentGuide, hasAgentGuide, writeAgentGuide } from "./agents.mjs";
|
|
8
9
|
|
|
9
10
|
const LINK_FILE = ".htmlhost";
|
|
10
11
|
|
|
@@ -68,6 +69,15 @@ const DEFAULT_IGNORE = [
|
|
|
68
69
|
"package-lock.json",
|
|
69
70
|
"yarn.lock",
|
|
70
71
|
"pnpm-lock.yaml",
|
|
72
|
+
// Instructions for AI coding tools — private to the project, never published.
|
|
73
|
+
"AGENTS.md",
|
|
74
|
+
"CLAUDE.md",
|
|
75
|
+
"GEMINI.md",
|
|
76
|
+
".claude",
|
|
77
|
+
".cursor",
|
|
78
|
+
".cursorrules",
|
|
79
|
+
".windsurfrules",
|
|
80
|
+
".github",
|
|
71
81
|
];
|
|
72
82
|
|
|
73
83
|
/**
|
|
@@ -342,6 +352,10 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
342
352
|
}
|
|
343
353
|
}
|
|
344
354
|
|
|
355
|
+
// Keep an existing AI agent guide current (downloaded alongside the deploy).
|
|
356
|
+
const hadAgentGuide = hasAgentGuide(dirPath);
|
|
357
|
+
const agentGuide = hadAgentGuide && !json ? fetchAgentGuide(5000).catch(() => null) : Promise.resolve(null);
|
|
358
|
+
|
|
345
359
|
// Read existing link data
|
|
346
360
|
const link = readLink(dirPath);
|
|
347
361
|
let existingSlug = explicitSlug || (!forceNew && link?.multipage?.slug) || null;
|
|
@@ -557,6 +571,11 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
557
571
|
}
|
|
558
572
|
console.log("");
|
|
559
573
|
console.log(` ${dim("Linked → .htmlhost")}`);
|
|
574
|
+
const guide = await agentGuide;
|
|
575
|
+
if (guide) writeAgentGuide(dirPath, guide, { claude: false });
|
|
576
|
+
else if (!hadAgentGuide) {
|
|
577
|
+
console.log(` ${dim("Building with AI? Run")} ${cyan("htmlhost agents")} ${dim("so your coding tool knows how htmlhost forms and hosting work.")}`);
|
|
578
|
+
}
|
|
560
579
|
console.log("");
|
|
561
580
|
} catch (e) {
|
|
562
581
|
err(`Deploy failed: ${e.message}`);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { resolve, basename, relative } from "node:path";
|
|
2
|
+
import { statSync, existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import { uploadMediaFile } from "../api.mjs";
|
|
5
|
+
import { ok, err, info, dim, cyan, bold, formatBytes, mimeFromExt } from "../ui.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Copy text to system clipboard (cross-platform, fail-safe).
|
|
9
|
+
*/
|
|
10
|
+
async function copyToClipboard(text) {
|
|
11
|
+
try {
|
|
12
|
+
const { spawn } = await import("node:child_process");
|
|
13
|
+
let cmd, args;
|
|
14
|
+
if (process.platform === "darwin") {
|
|
15
|
+
cmd = "pbcopy";
|
|
16
|
+
args = [];
|
|
17
|
+
} else if (process.platform === "win32") {
|
|
18
|
+
cmd = "clip";
|
|
19
|
+
args = [];
|
|
20
|
+
} else if (process.env.WAYLAND_DISPLAY) {
|
|
21
|
+
cmd = "wl-copy";
|
|
22
|
+
args = [];
|
|
23
|
+
} else {
|
|
24
|
+
cmd = "xclip";
|
|
25
|
+
args = ["-selection", "clipboard"];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return await new Promise((res) => {
|
|
29
|
+
const proc = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
30
|
+
proc.on("error", () => res(false));
|
|
31
|
+
proc.on("close", (code) => res(code === 0));
|
|
32
|
+
proc.stdin.write(text);
|
|
33
|
+
proc.stdin.end();
|
|
34
|
+
});
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Recursively collect all files in a directory, ignoring hidden files/folders.
|
|
42
|
+
*/
|
|
43
|
+
function collectFiles(dir) {
|
|
44
|
+
const files = [];
|
|
45
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
46
|
+
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.name.startsWith(".")) continue;
|
|
49
|
+
const full = resolve(dir, entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
files.push(...collectFiles(full));
|
|
52
|
+
} else if (entry.isFile()) {
|
|
53
|
+
files.push(full);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return files;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* htmlhost upload <file|dir...> — upload media files and get hosted URLs.
|
|
62
|
+
*/
|
|
63
|
+
export async function upload(args, { json = false } = {}) {
|
|
64
|
+
const flags = new Set(args.filter((a) => a.startsWith("--")));
|
|
65
|
+
const rawPaths = args.filter((a) => !a.startsWith("--"));
|
|
66
|
+
|
|
67
|
+
const noCopy = flags.has("--no-copy");
|
|
68
|
+
const openInBrowser = flags.has("--open");
|
|
69
|
+
|
|
70
|
+
if (rawPaths.length === 0) {
|
|
71
|
+
err("Usage: htmlhost upload <file|dir...> [options]");
|
|
72
|
+
console.log(`\n ${bold("Options:")}`);
|
|
73
|
+
console.log(` --open Open the uploaded media URL in your browser`);
|
|
74
|
+
console.log(` --no-copy Do not copy the URL to clipboard`);
|
|
75
|
+
console.log(` --json Output response as JSON\n`);
|
|
76
|
+
console.log(` ${bold("Examples:")}`);
|
|
77
|
+
console.log(` ${dim("$")} htmlhost upload assets/og-image-ambient.jpg`);
|
|
78
|
+
console.log(` ${dim("$")} htmlhost upload photo.jpg logo.svg`);
|
|
79
|
+
console.log(` ${dim("$")} htmlhost upload assets/`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Resolve all target files
|
|
84
|
+
const filePaths = [];
|
|
85
|
+
for (const raw of rawPaths) {
|
|
86
|
+
const absPath = resolve(process.cwd(), raw);
|
|
87
|
+
if (!existsSync(absPath)) {
|
|
88
|
+
err(`File or directory not found: ${raw}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const stat = statSync(absPath);
|
|
93
|
+
if (stat.isDirectory()) {
|
|
94
|
+
const inside = collectFiles(absPath);
|
|
95
|
+
if (inside.length === 0) {
|
|
96
|
+
if (!json) err(`No files found in directory: ${raw}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
filePaths.push(...inside);
|
|
100
|
+
} else if (stat.isFile()) {
|
|
101
|
+
filePaths.push(absPath);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (filePaths.length === 0) {
|
|
106
|
+
err("No files to upload.");
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const results = [];
|
|
111
|
+
|
|
112
|
+
if (!json && filePaths.length > 1) {
|
|
113
|
+
console.log("");
|
|
114
|
+
info(`Uploading ${bold(String(filePaths.length))} files…`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const filePath of filePaths) {
|
|
118
|
+
const fileName = basename(filePath);
|
|
119
|
+
const relPath = relative(process.cwd(), filePath) || fileName;
|
|
120
|
+
const stat = statSync(filePath);
|
|
121
|
+
const mime = mimeFromExt(fileName);
|
|
122
|
+
|
|
123
|
+
if (!json && filePaths.length === 1) {
|
|
124
|
+
info(`Uploading ${cyan(relPath)} ${dim(`(${formatBytes(stat.size)})`)}…`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const uploaded = await uploadMediaFile(filePath, fileName, mime);
|
|
128
|
+
results.push({
|
|
129
|
+
...uploaded,
|
|
130
|
+
localPath: relPath,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!json && filePaths.length > 1) {
|
|
134
|
+
console.log(` ${cyan(relPath)} ${dim(`(${formatBytes(stat.size)})`)} → ${uploaded.fullUrl}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// JSON output
|
|
139
|
+
if (json) {
|
|
140
|
+
console.log(JSON.stringify(results.length === 1 ? results[0] : results, null, 2));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Single file completion UX
|
|
145
|
+
if (results.length === 1) {
|
|
146
|
+
const item = results[0];
|
|
147
|
+
let copied = false;
|
|
148
|
+
if (!noCopy) {
|
|
149
|
+
copied = await copyToClipboard(item.fullUrl);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
ok(`Uploaded ${cyan(item.localPath)} ${dim(`(${formatBytes(item.size)})`)}`);
|
|
153
|
+
console.log(` ${bold(item.fullUrl)}${copied ? dim(" (copied to clipboard)") : ""}`);
|
|
154
|
+
} else {
|
|
155
|
+
console.log("");
|
|
156
|
+
ok(`All ${bold(String(results.length))} files uploaded successfully`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Open in browser if requested
|
|
160
|
+
if (openInBrowser) {
|
|
161
|
+
const cmd =
|
|
162
|
+
process.platform === "darwin" ? "open" :
|
|
163
|
+
process.platform === "win32" ? "start" :
|
|
164
|
+
"xdg-open";
|
|
165
|
+
|
|
166
|
+
for (const r of results) {
|
|
167
|
+
exec(`${cmd} ${r.fullUrl}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/update.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export function startUpdateCheck(currentVersion) {
|
|
|
29
29
|
if (Date.now() - lastCheck < CHECK_INTERVAL_MS) {
|
|
30
30
|
// Still within throttle window — but if we know a newer version
|
|
31
31
|
// from the last check, surface it without re-fetching
|
|
32
|
-
if (config.latestVersion && config.latestVersion
|
|
32
|
+
if (config.latestVersion && isNewer(config.latestVersion, currentVersion)) {
|
|
33
33
|
return Promise.resolve({
|
|
34
34
|
latest: config.latestVersion,
|
|
35
35
|
current: currentVersion,
|