htmlhost-cli 2.10.0 → 2.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.10.0",
3
+ "version": "2.11.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.11.0";
9
9
 
10
10
  const HELP = `
11
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -22,7 +22,7 @@ const HELP = `
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
24
  ${cyan("htmlhost delete")} <slug> Delete a site
25
- ${cyan("htmlhost login")} Authenticate with an API token
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,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
  }