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 +1 -1
- package/package.json +1 -1
- package/src/cli.mjs +6 -3
- package/src/commands/login.mjs +127 -36
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ htmlhost deploy page.html
|
|
|
33
33
|
## Commands
|
|
34
34
|
|
|
35
35
|
### `htmlhost login`
|
|
36
|
-
|
|
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
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.
|
|
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")}
|
|
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": {
|
package/src/commands/login.mjs
CHANGED
|
@@ -1,61 +1,152 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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(
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
}
|