htmlhost-cli 2.10.0 → 2.12.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 CHANGED
@@ -33,7 +33,7 @@ htmlhost deploy page.html
33
33
  ## Commands
34
34
 
35
35
  ### `htmlhost login`
36
- Authenticate with an API token. Generate one at [htmlhost.co/settings#keys](https://htmlhost.co/settings#keys).
36
+ Sign in by approving in your browser: it opens htmlhost.co, you check the code matches and click Approve. Nothing to paste, so an AI agent can run it for you (without a terminal attached it waits 90 seconds, then the next `htmlhost login` finishes). For CI, save a token from [htmlhost.co/settings#keys](https://htmlhost.co/settings#keys) with `htmlhost login --token <token>`.
37
37
 
38
38
  ### `htmlhost deploy [file|dir] [options]`
39
39
  Deploy an HTML project and get a live URL.
@@ -174,7 +174,7 @@ htmlhost agents --no-claude # don't touch CLAUDE.md
174
174
  The same guide is at https://htmlhost.co/agents.md.
175
175
 
176
176
  ### `htmlhost list`
177
- List all your sites with titles, sizes, TTL, and expiry. Multi-page sites are marked with 📄.
177
+ List all your sites with titles, sizes, TTL, and expiry. Multi-page sites are marked with 📄. Sites shared with you as a collaborator follow under **Shared with you**, with their owner: `clone`, `deploy` and `versions` work on them with your own sign-in; only the owner can delete them.
178
178
 
179
179
  ### `htmlhost open <slug>`
180
180
  Open a deployed site in the default browser.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -5,7 +5,7 @@ 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.10.0";
8
+ const VERSION = "2.12.0";
9
9
 
10
10
  const HELP = `
11
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -17,12 +17,12 @@ const HELP = `
17
17
  ${cyan("htmlhost pull")} [slug] [opts] Pull remote changes to local
18
18
  ${cyan("htmlhost clone")} <slug> [dir] Clone a site to a local directory
19
19
  ${cyan("htmlhost agents")} [dir] Teach your AI coding tool htmlhost (AGENTS.md)
20
- ${cyan("htmlhost recipients")} [slug] [cmd] Manage form notification recipients
21
- ${cyan("htmlhost list")} List your sites
20
+ ${cyan("htmlhost recipients")} [slug] [cmd] Manage form notification recipients (owner)
21
+ ${cyan("htmlhost list")} List your sites and ones shared with you
22
22
  ${cyan("htmlhost versions")} [slug] List a site's published versions
23
23
  ${cyan("htmlhost rollback")} [slug] [ver] Make an earlier version live again (Pro)
24
- ${cyan("htmlhost delete")} <slug> Delete a site
25
- ${cyan("htmlhost login")} Authenticate with an API token
24
+ ${cyan("htmlhost delete")} <slug> Delete a site (owner)
25
+ ${cyan("htmlhost login")} Sign in (approve in your browser)
26
26
  ${cyan("htmlhost whoami")} Show current user
27
27
  ${cyan("htmlhost logout")} Remove saved token
28
28
  ${cyan("htmlhost open")} <slug> Open a site in the browser
@@ -58,6 +58,9 @@ const HELP = `
58
58
  ${cyan("recipients resend")} [slug] <ref> Resend a pending invitation
59
59
  --force, -f Skip confirmation on remove
60
60
 
61
+ ${bold("Login options:")}
62
+ --token <token> Save an API token from Settings → API keys (CI)
63
+
61
64
  ${bold("Delete options:")}
62
65
  --force, -f Skip confirmation prompt
63
66
 
@@ -123,7 +126,7 @@ export async function run(argv) {
123
126
  switch (command) {
124
127
  case "login": {
125
128
  const { login } = await import("./commands/login.mjs");
126
- await login();
129
+ await login(args);
127
130
  break;
128
131
  }
129
132
  case "logout": {
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, statSync, readdirSync, existsSync } from "node:fs";
2
- import { basename, resolve, dirname, join } from "node:path";
2
+ import { basename, resolve, dirname, join, sep } from "node:path";
3
3
  import { createInterface } from "node:readline";
4
4
  import { createHash } from "node:crypto";
5
5
  import { post } from "../api.mjs";
@@ -666,7 +666,9 @@ export function findAllFiles(baseDir, currentDir) {
666
666
  if (entry.isDirectory()) {
667
667
  results.push(...findAllFiles(baseDir, fullPath));
668
668
  } else if (entry.isFile()) {
669
- const relativePath = fullPath.slice(baseDir.length + 1);
669
+ // Always with "/": on Windows the path has "\", which would publish
670
+ // assets\logo.png, an address no browser can ask for.
671
+ const relativePath = fullPath.slice(baseDir.length + 1).split(sep).join("/");
670
672
  let size;
671
673
  try {
672
674
  size = statSync(fullPath).size;
@@ -2,7 +2,8 @@ import { get } from "../api.mjs";
2
2
  import { dim, cyan, bold, formatBytes, yellow } from "../ui.mjs";
3
3
 
4
4
  /**
5
- * htmlhost list — show all sites.
5
+ * htmlhost list — show all sites: your own, then the ones shared with you
6
+ * (you can clone, deploy and restore those, not delete them).
6
7
  */
7
8
  export async function list({ json = false } = {}) {
8
9
  const data = await get("/api/sites");
@@ -13,8 +14,9 @@ export async function list({ json = false } = {}) {
13
14
  }
14
15
 
15
16
  const sites = data.sites || [];
17
+ const shared = data.shared || [];
16
18
 
17
- if (sites.length === 0) {
19
+ if (sites.length === 0 && shared.length === 0) {
18
20
  console.log("");
19
21
  console.log(` ${dim("No sites yet.")} Deploy one with: ${cyan("htmlhost deploy index.html")}`);
20
22
  console.log("");
@@ -48,6 +50,17 @@ export async function list({ json = false } = {}) {
48
50
  );
49
51
  }
50
52
 
53
+ if (shared.length) {
54
+ const sharedSlugW = Math.max(14, ...shared.map((s) => s.slug.length)) + 2;
55
+ console.log("");
56
+ console.log(` ${bold("Shared with you")} ${dim("(clone, deploy and restore; only the owner can delete)")}`);
57
+ console.log(` ${dim(pad("SLUG", sharedSlugW) + pad("TITLE", 30) + "OWNER")}`);
58
+ for (const s of shared) {
59
+ const title = truncate(s.title && s.title !== s.slug ? s.title : "", 28);
60
+ console.log(` ${pad(cyan(s.slug), sharedSlugW)}${pad(dim(title), 30)}${dim(s.owner || "")}`);
61
+ }
62
+ }
63
+
51
64
  console.log("");
52
65
  console.log(
53
66
  ` ${bold(String(sites.length))} sites · ${formatBytes(data.usage.totalBytes)} used` +
@@ -1,61 +1,152 @@
1
- import { writeConfig, getApi } from "../config.mjs";
2
- import { ok, err, info, dim, cyan, bold } from "../ui.mjs";
1
+ /**
2
+ * htmlhost login [--token <token>]
3
+ *
4
+ * Signs in by browser approval: asks htmlhost.co for a sign-in, opens the
5
+ * approval page and waits until the owner approves, then saves the new API
6
+ * token to ~/.htmlhostrc. Nothing to type, so an AI agent can run it: without a
7
+ * terminal attached it waits 90 seconds, then keeps the sign-in so the next
8
+ * `htmlhost login` picks it up. `--token` saves a token from Settings → API keys
9
+ * (CI). A server without browser sign-in falls back to pasting a token.
10
+ */
11
+ import { hostname } from "node:os";
3
12
  import { createInterface } from "node:readline";
4
13
  import { exec } from "node:child_process";
14
+ import { readConfig, writeConfig, getApi } from "../config.mjs";
15
+ import { ok, err, info, dim, cyan, bold } from "../ui.mjs";
5
16
 
6
- export async function login() {
7
- const settingsUrl = `${getApi()}/settings#keys`;
8
-
9
- console.log("");
10
- info(`Log in to ${cyan(bold("htmlhost.co"))}`);
11
-
12
- // Auto-open the browser; fall back to showing the URL if it fails
13
- const browserCmd =
14
- process.platform === "darwin" ? "open" :
15
- process.platform === "win32" ? "start" :
16
- "xdg-open";
17
+ /** How long a run without a terminal (an agent) waits before leaving the sign-in pending. */
18
+ const AGENT_WAIT_MS = 90_000;
17
19
 
18
- exec(`${browserCmd} ${settingsUrl}`, (error) => {
19
- if (error) {
20
- console.log(`${dim(" Generate a token at")} ${settingsUrl}`);
21
- }
22
- });
20
+ function openBrowser(url) {
21
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? 'start ""' : "xdg-open";
22
+ exec(`${cmd} "${url}"`, () => {});
23
+ }
23
24
 
24
- console.log(`${dim(" Opening")} ${cyan(settingsUrl)}${dim(" in your browser…")}`);
25
- console.log("");
25
+ async function saveToken(token) {
26
+ info("Verifying…");
27
+ const res = await fetch(`${getApi()}/api/tokens/me`, { headers: { Authorization: `Bearer ${token}` } });
28
+ if (!res.ok) {
29
+ err("Invalid or expired token.");
30
+ process.exit(1);
31
+ }
32
+ const data = await res.json();
33
+ writeConfig({ token, api: getApi(), pendingLogin: undefined });
34
+ ok(`Logged in as ${cyan(data.email)} (${data.plan})`);
35
+ console.log(`${dim(" Token saved to ~/.htmlhostrc")}`);
36
+ }
26
37
 
38
+ /** The old way, for servers without browser sign-in: paste a token from Settings → API keys. */
39
+ async function pasteLogin() {
40
+ const settingsUrl = `${getApi()}/settings#keys`;
41
+ openBrowser(settingsUrl);
42
+ console.log(`${dim(" Opening")} ${cyan(settingsUrl)}${dim(" in your browser…")}\n`);
27
43
  const token = await new Promise((resolve) => {
28
44
  const rl = createInterface({ input: process.stdin, output: process.stdout });
29
- rl.question(` Paste your API token: `, (answer) => {
45
+ rl.question(" Paste your API token: ", (answer) => {
30
46
  rl.close();
31
47
  resolve(answer.trim());
32
48
  });
33
49
  });
34
-
35
50
  if (!token) {
36
51
  err("No token provided.");
37
52
  process.exit(1);
38
53
  }
54
+ await saveToken(token);
55
+ }
39
56
 
40
- // Validate the token
41
- info("Verifying…");
42
-
57
+ /** A new sign-in, or null when the server doesn't offer browser sign-in. */
58
+ async function startLogin() {
59
+ let res;
43
60
  try {
44
- const res = await fetch(`${getApi()}/api/tokens/me`, {
45
- headers: { Authorization: `Bearer ${token}` },
61
+ res = await fetch(`${getApi()}/api/cli-login`, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json", "X-HTMLHost-Client": "cli" },
64
+ body: JSON.stringify({ client: "cli", deviceName: hostname() }),
46
65
  });
66
+ } catch (e) {
67
+ err(`Connection failed: ${e.message}`);
68
+ process.exit(1);
69
+ }
70
+ if (res.status === 404 || res.status === 405) return null;
71
+ const data = await res.json().catch(() => ({}));
72
+ if (!res.ok) {
73
+ err(data.error || `Couldn't start sign-in (HTTP ${res.status}).`);
74
+ process.exit(1);
75
+ }
76
+ const pending = {
77
+ deviceCode: data.deviceCode,
78
+ userCode: data.userCode,
79
+ verifyUrl: data.verifyUrl,
80
+ interval: data.interval || 3,
81
+ expiresAt: Date.now() + (data.expiresIn || 600) * 1000,
82
+ api: getApi(),
83
+ };
84
+ writeConfig({ pendingLogin: pending });
85
+ openBrowser(pending.verifyUrl);
86
+ return pending;
87
+ }
47
88
 
48
- if (!res.ok) {
49
- err("Invalid or expired token.");
89
+ export async function login(args = []) {
90
+ const at = args.indexOf("--token");
91
+ if (at >= 0) {
92
+ if (!args[at + 1]) {
93
+ err("Give the token after --token.");
50
94
  process.exit(1);
51
95
  }
96
+ return saveToken(args[at + 1]);
97
+ }
52
98
 
53
- const data = await res.json();
54
- writeConfig({ token, api: getApi() });
55
- ok(`Logged in as ${cyan(data.email)} (${data.plan})`);
56
- console.log(`${dim(" Token saved to ~/.htmlhostrc")}`);
57
- } catch (e) {
58
- err(`Connection failed: ${e.message}`);
59
- process.exit(1);
99
+ console.log("");
100
+ info(`Log in to ${cyan(bold("htmlhost.co"))}`);
101
+
102
+ const saved = readConfig().pendingLogin;
103
+ let pending = saved && saved.api === getApi() && saved.expiresAt > Date.now() + 5_000 ? saved : null;
104
+ if (!pending) {
105
+ pending = await startLogin();
106
+ if (!pending) return pasteLogin();
107
+ }
108
+
109
+ console.log(`${dim(" Approve this sign-in in your browser:")} ${cyan(pending.verifyUrl)}`);
110
+ console.log(`${dim(" Check it shows the code")} ${bold(pending.userCode)}\n`);
111
+
112
+ const until = Math.min(pending.expiresAt, process.stdin.isTTY ? Infinity : Date.now() + AGENT_WAIT_MS);
113
+ let interval = pending.interval * 1000;
114
+ while (Date.now() < until) {
115
+ await new Promise((r) => setTimeout(r, interval));
116
+ let data;
117
+ try {
118
+ const res = await fetch(`${getApi()}/api/cli-login/poll`, {
119
+ method: "POST",
120
+ headers: { "Content-Type": "application/json" },
121
+ body: JSON.stringify({ deviceCode: pending.deviceCode }),
122
+ });
123
+ data = await res.json();
124
+ } catch {
125
+ continue; // a dropped connection: try again next round
126
+ }
127
+ if (data.status === "approved") {
128
+ writeConfig({ token: data.token, api: getApi(), pendingLogin: undefined });
129
+ ok(`Logged in as ${cyan(data.email || "your account")}`);
130
+ console.log(`${dim(" Token saved to ~/.htmlhostrc")}`);
131
+ return;
132
+ }
133
+ if (data.status === "slow_down") interval += 1000;
134
+ else if (data.status === "denied" || data.status === "expired") {
135
+ writeConfig({ pendingLogin: undefined });
136
+ err(data.status === "denied" ? "Sign-in cancelled in the browser." : "This sign-in expired. Run `htmlhost login` again.");
137
+ process.exit(1);
138
+ } else if (data.status === "too_many_tokens") {
139
+ writeConfig({ pendingLogin: undefined });
140
+ err(`Your account has 10 API keys. Remove one at ${getApi()}/settings#keys, then run \`htmlhost login\` again.`);
141
+ process.exit(1);
142
+ }
143
+ }
144
+
145
+ if (pending.expiresAt <= Date.now()) {
146
+ writeConfig({ pendingLogin: undefined });
147
+ err("This sign-in expired. Run `htmlhost login` again.");
148
+ } else {
149
+ err(`Not signed in yet. Approve at ${pending.verifyUrl} (code ${pending.userCode}), then run \`htmlhost login\` again to finish.`);
60
150
  }
151
+ process.exit(1);
61
152
  }