@voxli/cli 0.6.1 → 0.7.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 +27 -7
- package/dist/cli.js +1 -2
- package/dist/commands/auth.d.ts +3 -3
- package/dist/commands/auth.js +43 -44
- package/dist/lib/browser-auth.d.ts +51 -1
- package/dist/lib/browser-auth.js +310 -91
- package/package.json +14 -4
package/README.md
CHANGED
|
@@ -18,15 +18,19 @@ Authenticate with your Voxli account:
|
|
|
18
18
|
voxli auth
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
This opens your browser to `app.voxli.io` where you log in and approve access.
|
|
21
|
+
This opens your browser to `app.voxli.io` where you log in and approve access. A user-scoped access token (with a refresh token) is saved to `~/.voxli/config.json`, or to an existing `.voxli/config.json` found in the current directory or a parent. Use `--local` to force `./.voxli/config.json`.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
### Headless machines, VMs, and SSH
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
The browser doesn't have to be on the same machine. Run `voxli auth`, open the printed URL anywhere, and log in. The browser is then sent to `http://127.0.0.1:<port>/callback`, which it can't reach from another machine and shows a "can't connect" page. Copy the full URL from the address bar and paste it into the terminal prompt; the CLI completes the login from there.
|
|
26
|
+
|
|
27
|
+
The CLI only tries to open a browser when one is likely in front of you (not over SSH, in CI, or without a display). Set `BROWSER=none` to never open one, or `BROWSER=<command>` to choose which.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Pasting requires an interactive terminal (`ssh -t` if needed).
|
|
30
|
+
|
|
31
|
+
### CI and service accounts
|
|
32
|
+
|
|
33
|
+
Set the `VOXLI_API_TOKEN` environment variable to skip the login entirely; it takes precedence over any config file. Env-var tokens are never refreshed.
|
|
30
34
|
|
|
31
35
|
## Usage
|
|
32
36
|
|
|
@@ -44,11 +48,27 @@ The CLI polls the Voxli API for pending test batches. When work arrives, it spaw
|
|
|
44
48
|
| `TEST_RESULT_IDS` | JSON array of test result IDs to run |
|
|
45
49
|
| `RUN_ID` | The run ID (if part of a run) |
|
|
46
50
|
|
|
51
|
+
## Credential lookup order
|
|
52
|
+
|
|
53
|
+
1. `VOXLI_API_TOKEN` environment variable
|
|
54
|
+
2. Nearest `.voxli/config.json` walking up from the current directory
|
|
55
|
+
3. `~/.voxli/config.json`
|
|
56
|
+
|
|
47
57
|
## Commands
|
|
48
58
|
|
|
49
59
|
| Command | Description |
|
|
50
60
|
|---|---|
|
|
51
61
|
| `voxli auth` | Authenticate via browser |
|
|
52
|
-
| `voxli auth --
|
|
62
|
+
| `voxli auth --local` | Save credentials to `./.voxli/config.json` |
|
|
53
63
|
| `voxli listen --command <cmd>` | Poll for pending test work and run it locally |
|
|
54
64
|
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npm run build # compile to dist/
|
|
69
|
+
npm test # run the test suite
|
|
70
|
+
npm run lint # eslint
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Requires Node 20 or newer (see `engines` in `package.json`). CI runs lint, build,
|
|
74
|
+
and tests on every pull request against Node 20, 22, and 24.
|
package/dist/cli.js
CHANGED
|
@@ -12,8 +12,7 @@ program
|
|
|
12
12
|
.version(version);
|
|
13
13
|
program
|
|
14
14
|
.command("auth")
|
|
15
|
-
.description("Authenticate with your Voxli
|
|
16
|
-
.option("--manual", "Enter API key manually instead of browser auth")
|
|
15
|
+
.description("Authenticate with your Voxli account")
|
|
17
16
|
.option("--local", "Save credentials in the current directory")
|
|
18
17
|
.action(authCommand);
|
|
19
18
|
program
|
package/dist/commands/auth.d.ts
CHANGED
package/dist/commands/auth.js
CHANGED
|
@@ -1,66 +1,65 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { writeConfig } from "../lib/config.js";
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { findLocalConfigDir, writeConfig } from "../lib/config.js";
|
|
4
3
|
import { register, ApiError } from "../lib/api.js";
|
|
5
4
|
import { getStableHostname } from "../lib/hostname.js";
|
|
6
|
-
import { browserAuth } from "../lib/browser-auth.js";
|
|
7
|
-
async function
|
|
8
|
-
const rl = createInterface({ input: stdin, output: stdout });
|
|
9
|
-
try {
|
|
10
|
-
const token = await rl.question("Enter your Voxli API key: ");
|
|
11
|
-
if (!token.trim()) {
|
|
12
|
-
console.error("API key cannot be empty.");
|
|
13
|
-
process.exit(1);
|
|
14
|
-
}
|
|
15
|
-
return token.trim();
|
|
16
|
-
}
|
|
17
|
-
finally {
|
|
18
|
-
rl.close();
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
async function validateAndSave(token, extra, opts) {
|
|
22
|
-
const label = extra ? "Access token" : "API key";
|
|
5
|
+
import { browserAuth, AuthCancelledError, } from "../lib/browser-auth.js";
|
|
6
|
+
async function validateAndSave(result, opts) {
|
|
23
7
|
console.log("Validating...");
|
|
24
8
|
try {
|
|
25
9
|
const hostname = getStableHostname();
|
|
26
|
-
await register(
|
|
10
|
+
await register(result.accessToken, {
|
|
27
11
|
name: hostname,
|
|
28
12
|
unique_identifier: hostname,
|
|
29
13
|
});
|
|
30
|
-
console.log(
|
|
14
|
+
console.log("Access token is valid.");
|
|
31
15
|
}
|
|
32
16
|
catch (err) {
|
|
33
17
|
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
34
|
-
console.error(`Authentication failed (${err.status}).
|
|
18
|
+
console.error(`Authentication failed (${err.status}). Please try \`voxli auth\` again.`);
|
|
35
19
|
process.exit(1);
|
|
36
20
|
}
|
|
37
21
|
// Network error or other — warn but still save
|
|
38
22
|
console.warn("Warning: could not validate token (network error). Saving anyway.");
|
|
39
23
|
}
|
|
40
|
-
|
|
24
|
+
// Target selection:
|
|
25
|
+
// - --local: force cwd/.voxli (creates if missing).
|
|
26
|
+
// - default: reuse an existing local config in the cwd ancestor chain so
|
|
27
|
+
// the listener (which reads local first) picks up the new credentials.
|
|
28
|
+
// Falls back to the global config if no local one exists.
|
|
29
|
+
let writeOpts;
|
|
30
|
+
if (opts?.local) {
|
|
31
|
+
writeOpts = { configDir: join(process.cwd(), ".voxli") };
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
const existingLocal = await findLocalConfigDir();
|
|
35
|
+
if (existingLocal) {
|
|
36
|
+
console.log(`Detected local config at ${existingLocal}; saving there.`);
|
|
37
|
+
writeOpts = { configDir: existingLocal };
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
writeOpts = { target: "global" };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
41
43
|
const savedPath = await writeConfig({
|
|
42
|
-
accessToken:
|
|
43
|
-
refreshToken:
|
|
44
|
-
clientId:
|
|
45
|
-
},
|
|
46
|
-
console.log(
|
|
44
|
+
accessToken: result.accessToken,
|
|
45
|
+
refreshToken: result.refreshToken,
|
|
46
|
+
clientId: result.clientId,
|
|
47
|
+
}, writeOpts);
|
|
48
|
+
console.log(`Access token saved to ${savedPath}`);
|
|
47
49
|
}
|
|
48
50
|
export async function authCommand(opts) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
catch (err) {
|
|
59
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
60
|
-
console.log(`\nBrowser auth failed: ${msg}`);
|
|
61
|
-
console.log("Falling back to manual token entry.\n");
|
|
51
|
+
let result;
|
|
52
|
+
try {
|
|
53
|
+
result = await browserAuth();
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (err instanceof AuthCancelledError) {
|
|
57
|
+
console.log("\nCancelled.");
|
|
58
|
+
process.exit(130);
|
|
62
59
|
}
|
|
60
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
61
|
+
console.error(`\nAuthentication failed: ${msg}`);
|
|
62
|
+
process.exit(1);
|
|
63
63
|
}
|
|
64
|
-
|
|
65
|
-
await validateAndSave(token, undefined, { local: opts.local });
|
|
64
|
+
await validateAndSave(result, { local: opts.local });
|
|
66
65
|
}
|
|
@@ -1,6 +1,56 @@
|
|
|
1
|
+
export declare class AuthCancelledError extends Error {
|
|
2
|
+
constructor();
|
|
3
|
+
}
|
|
4
|
+
export type CallbackParse = {
|
|
5
|
+
ok: true;
|
|
6
|
+
code: string;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
reason: "not-a-redirect" | "login-url" | "state-mismatch" | "missing-code" | "denied";
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Extract the authorization code from a redirect, which may arrive as:
|
|
14
|
+
* - the full URL the browser was sent to (`http://127.0.0.1:PORT/callback?code=…&state=…`)
|
|
15
|
+
* - just the path + query (`/callback?code=…&state=…`)
|
|
16
|
+
* - just the query string (`code=…&state=…`)
|
|
17
|
+
*
|
|
18
|
+
* The `state` must match the one generated for this attempt; without it we
|
|
19
|
+
* can't tell the redirect belongs to us, so it's rejected.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseCallbackInput(raw: string, expectedState: string): CallbackParse;
|
|
22
|
+
export interface LaunchEnv {
|
|
23
|
+
platform: NodeJS.Platform;
|
|
24
|
+
env: NodeJS.ProcessEnv;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Best-effort guess at whether launching a browser here would put it in front
|
|
28
|
+
* of the user. We can only detect the clear "no" cases; the URL is always
|
|
29
|
+
* printed as well, so a wrong guess costs nothing.
|
|
30
|
+
*/
|
|
31
|
+
export declare function shouldLaunchBrowser({ platform, env }: LaunchEnv): boolean;
|
|
32
|
+
export declare function browserLaunchCommand(url: string, { platform, env }: LaunchEnv): {
|
|
33
|
+
cmd: string;
|
|
34
|
+
args: string[];
|
|
35
|
+
};
|
|
36
|
+
type Input = NodeJS.ReadableStream & {
|
|
37
|
+
isTTY?: boolean;
|
|
38
|
+
};
|
|
39
|
+
type Output = NodeJS.WritableStream;
|
|
1
40
|
export interface BrowserAuthResult {
|
|
2
41
|
accessToken: string;
|
|
3
42
|
refreshToken?: string;
|
|
4
43
|
clientId: string;
|
|
5
44
|
}
|
|
6
|
-
export
|
|
45
|
+
export interface BrowserAuthOptions {
|
|
46
|
+
/** Try to launch a browser. Default: auto-detect via shouldLaunchBrowser(). */
|
|
47
|
+
launchBrowser?: boolean;
|
|
48
|
+
/** Give up after this long. Default: 10 minutes. */
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
/** Offer the paste-the-redirect-URL prompt. Default: input.isTTY. */
|
|
51
|
+
interactive?: boolean;
|
|
52
|
+
input?: Input;
|
|
53
|
+
output?: Output;
|
|
54
|
+
}
|
|
55
|
+
export declare function browserAuth(opts?: BrowserAuthOptions): Promise<BrowserAuthResult>;
|
|
56
|
+
export {};
|
package/dist/lib/browser-auth.js
CHANGED
|
@@ -1,118 +1,337 @@
|
|
|
1
1
|
import { createServer, } from "node:http";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
|
-
import { createInterface } from "node:readline
|
|
5
|
-
import { stdin, stdout } from "node:process";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
6
5
|
import { getApiBaseUrl } from "./api.js";
|
|
7
6
|
import { generatePkceChallenge, registerOAuthClient, exchangeCodeForToken, buildAuthorizeUrl, } from "./oauth.js";
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
7
|
+
const DEFAULT_AUTH_TIMEOUT_MS = 10 * 60_000;
|
|
8
|
+
const CALLBACK_PATH = "/callback";
|
|
9
|
+
const PASTE_PROMPT = "> ";
|
|
10
|
+
export class AuthCancelledError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super("Authentication cancelled.");
|
|
13
|
+
this.name = "AuthCancelledError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// HTML pages served on the loopback callback
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
function escapeHtml(s) {
|
|
20
|
+
return s
|
|
21
|
+
.replace(/&/g, "&")
|
|
22
|
+
.replace(/</g, "<")
|
|
23
|
+
.replace(/>/g, ">")
|
|
24
|
+
.replace(/"/g, """);
|
|
25
|
+
}
|
|
26
|
+
function page(heading, message, color) {
|
|
27
|
+
return `<!DOCTYPE html>
|
|
18
28
|
<html>
|
|
19
29
|
<head><meta charset="utf-8"><title>Voxli CLI</title>
|
|
20
30
|
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f9fafb}
|
|
21
31
|
.card{text-align:center;padding:2rem;border-radius:12px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.1)}
|
|
22
|
-
h1{color
|
|
23
|
-
<body><div class="card"><h1
|
|
32
|
+
h1{color:${color};margin:0 0 .5rem}p{color:#6b7280;margin:0}</style></head>
|
|
33
|
+
<body><div class="card"><h1>${escapeHtml(heading)}</h1><p>${escapeHtml(message)}</p></div></body>
|
|
24
34
|
</html>`;
|
|
25
|
-
function openBrowser(url) {
|
|
26
|
-
const cmd = process.platform === "darwin"
|
|
27
|
-
? "open"
|
|
28
|
-
: process.platform === "win32"
|
|
29
|
-
? "cmd"
|
|
30
|
-
: "xdg-open";
|
|
31
|
-
const args = process.platform === "win32" ? ["/c", "start", url] : [url];
|
|
32
|
-
execFile(cmd, args, (err) => {
|
|
33
|
-
if (err) {
|
|
34
|
-
console.log(`\nOpen this URL in your browser:\n ${url}\n`);
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
35
|
}
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
const SUCCESS_HTML = page("Authenticated!", "You can close this tab and return to the terminal.", "#0a3b29");
|
|
37
|
+
function errorHtml(message) {
|
|
38
|
+
return page("Authentication failed", message, "#dc2626");
|
|
39
|
+
}
|
|
40
|
+
const NOT_A_REDIRECT_MSG = "That doesn't look like the page URL. Copy the full address from the browser's address bar and paste it here.";
|
|
41
|
+
/**
|
|
42
|
+
* Extract the authorization code from a redirect, which may arrive as:
|
|
43
|
+
* - the full URL the browser was sent to (`http://127.0.0.1:PORT/callback?code=…&state=…`)
|
|
44
|
+
* - just the path + query (`/callback?code=…&state=…`)
|
|
45
|
+
* - just the query string (`code=…&state=…`)
|
|
46
|
+
*
|
|
47
|
+
* The `state` must match the one generated for this attempt; without it we
|
|
48
|
+
* can't tell the redirect belongs to us, so it's rejected.
|
|
49
|
+
*/
|
|
50
|
+
export function parseCallbackInput(raw, expectedState) {
|
|
51
|
+
const text = raw.trim().replace(/^["'<]+|[>"']+$/g, "");
|
|
52
|
+
let params;
|
|
40
53
|
try {
|
|
41
|
-
|
|
54
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) {
|
|
55
|
+
params = new URL(text).searchParams;
|
|
56
|
+
}
|
|
57
|
+
else if (text.startsWith("/")) {
|
|
58
|
+
params = new URL(text, "http://127.0.0.1").searchParams;
|
|
59
|
+
}
|
|
60
|
+
else if (/(^|[?&])(code|state|error)=/.test(text)) {
|
|
61
|
+
params = new URLSearchParams(text.replace(/^\?/, ""));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
return { ok: false, reason: "not-a-redirect", message: NOT_A_REDIRECT_MSG };
|
|
65
|
+
}
|
|
42
66
|
}
|
|
43
|
-
|
|
44
|
-
|
|
67
|
+
catch {
|
|
68
|
+
return { ok: false, reason: "not-a-redirect", message: NOT_A_REDIRECT_MSG };
|
|
45
69
|
}
|
|
46
|
-
|
|
70
|
+
if (params.has("response_type")) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: "login-url",
|
|
74
|
+
message: "That's the login link itself. Open it in a browser, log in, then paste the URL of the page you end up on.",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (params.get("state") !== expectedState) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason: "state-mismatch",
|
|
81
|
+
message: "That URL is from a different login attempt. Paste the full URL, including everything after '?', from this one.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const error = params.get("error");
|
|
85
|
+
if (error) {
|
|
86
|
+
const description = params.get("error_description");
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
reason: "denied",
|
|
90
|
+
message: `Authorization failed: ${error}${description ? ` (${description})` : ""}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const code = params.get("code");
|
|
94
|
+
if (!code) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
reason: "missing-code",
|
|
98
|
+
message: "That URL has no login code in it. Paste the full URL, including everything after '?'.",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, code };
|
|
102
|
+
}
|
|
103
|
+
function browserOverride(env) {
|
|
104
|
+
const value = env.BROWSER?.trim();
|
|
105
|
+
if (!value || value.toLowerCase() === "none")
|
|
106
|
+
return null;
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Best-effort guess at whether launching a browser here would put it in front
|
|
111
|
+
* of the user. We can only detect the clear "no" cases; the URL is always
|
|
112
|
+
* printed as well, so a wrong guess costs nothing.
|
|
113
|
+
*/
|
|
114
|
+
export function shouldLaunchBrowser({ platform, env }) {
|
|
115
|
+
if (env.BROWSER?.trim())
|
|
116
|
+
return browserOverride(env) !== null;
|
|
117
|
+
if (env.CI)
|
|
118
|
+
return false;
|
|
119
|
+
// Over SSH the browser would open on the remote desktop, not the user's.
|
|
120
|
+
// (VS Code Remote and similar set BROWSER, handled above.)
|
|
121
|
+
if (env.SSH_CONNECTION || env.SSH_TTY)
|
|
122
|
+
return false;
|
|
123
|
+
if (platform === "linux") {
|
|
124
|
+
if (env.WSL_DISTRO_NAME)
|
|
125
|
+
return true;
|
|
126
|
+
if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
export function browserLaunchCommand(url, { platform, env }) {
|
|
132
|
+
const override = browserOverride(env);
|
|
133
|
+
if (override) {
|
|
134
|
+
// Same convention as xdg-open: whitespace-separated, optional %s placeholder.
|
|
135
|
+
const [cmd, ...rest] = override.split(/\s+/);
|
|
136
|
+
if (rest.some((a) => a.includes("%s"))) {
|
|
137
|
+
return { cmd, args: rest.map((a) => a.replaceAll("%s", url)) };
|
|
138
|
+
}
|
|
139
|
+
return { cmd, args: [...rest, url] };
|
|
140
|
+
}
|
|
141
|
+
if (platform === "darwin")
|
|
142
|
+
return { cmd: "open", args: [url] };
|
|
143
|
+
// rundll32 takes the URL as a plain argument, so `&` in the query string is
|
|
144
|
+
// safe (unlike `cmd /c start`, which treats it as a command separator).
|
|
145
|
+
if (platform === "win32") {
|
|
146
|
+
return { cmd: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
|
|
147
|
+
}
|
|
148
|
+
if (env.WSL_DISTRO_NAME) {
|
|
149
|
+
return { cmd: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
150
|
+
}
|
|
151
|
+
return { cmd: "xdg-open", args: [url] };
|
|
152
|
+
}
|
|
153
|
+
function openBrowser(url, onError) {
|
|
154
|
+
const { cmd, args } = browserLaunchCommand(url, {
|
|
155
|
+
platform: process.platform,
|
|
156
|
+
env: process.env,
|
|
157
|
+
});
|
|
158
|
+
execFile(cmd, args, (err) => {
|
|
159
|
+
if (err)
|
|
160
|
+
onError();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Loopback server
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
/** Listen on a free loopback port and return it. */
|
|
167
|
+
function listen(server) {
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const onError = (err) => reject(err);
|
|
170
|
+
server.once("error", onError);
|
|
171
|
+
server.listen(0, "127.0.0.1", () => {
|
|
172
|
+
server.off("error", onError);
|
|
173
|
+
const addr = server.address();
|
|
174
|
+
if (!addr || typeof addr === "string") {
|
|
175
|
+
reject(new Error("Failed to start local callback server."));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
resolve(addr.port);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function confirm(input, output, prompt) {
|
|
47
183
|
return new Promise((resolve, reject) => {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
184
|
+
const rl = createInterface({ input, output });
|
|
185
|
+
rl.on("SIGINT", () => {
|
|
186
|
+
// Reject first: rl.close() emits "close" synchronously, and the close
|
|
187
|
+
// listener below would otherwise resolve before this reject runs.
|
|
188
|
+
reject(new AuthCancelledError());
|
|
189
|
+
rl.close();
|
|
190
|
+
});
|
|
191
|
+
// EOF on stdin: nothing to wait for, just carry on.
|
|
192
|
+
rl.on("close", () => resolve());
|
|
193
|
+
rl.question(prompt, () => {
|
|
194
|
+
rl.close();
|
|
195
|
+
resolve();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Wait for the authorization code to arrive over either channel:
|
|
201
|
+
* - the browser hitting the loopback callback, or
|
|
202
|
+
* - the user pasting the redirect URL into the terminal (when the browser
|
|
203
|
+
* is on another machine and can't reach this one).
|
|
204
|
+
* Whichever happens first wins.
|
|
205
|
+
*/
|
|
206
|
+
function waitForCode(opts) {
|
|
207
|
+
const { server, state, input, output, interactive, timeoutMs } = opts;
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
let settled = false;
|
|
210
|
+
// `timer` and `rl` are declared below; finish() only runs from async
|
|
211
|
+
// callbacks, so they're always initialised by the time it's called.
|
|
212
|
+
const finish = (done) => {
|
|
213
|
+
if (settled)
|
|
214
|
+
return;
|
|
215
|
+
settled = true;
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
server.removeListener("request", onRequest);
|
|
218
|
+
if (rl) {
|
|
219
|
+
rl.close();
|
|
220
|
+
output.write("\n");
|
|
221
|
+
}
|
|
222
|
+
done();
|
|
223
|
+
};
|
|
224
|
+
const onRequest = (req, res) => {
|
|
225
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
226
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
227
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
53
228
|
res.end("Not found");
|
|
54
229
|
return;
|
|
55
230
|
}
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
res.
|
|
60
|
-
res.end(ERROR_HTML);
|
|
231
|
+
const parsed = parseCallbackInput(url.href, state);
|
|
232
|
+
if (parsed.ok) {
|
|
233
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
234
|
+
res.end(SUCCESS_HTML, () => finish(() => resolve(parsed.code)));
|
|
61
235
|
return;
|
|
62
236
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
237
|
+
const status = parsed.reason === "state-mismatch" ? 403 : 400;
|
|
238
|
+
res.writeHead(status, { "Content-Type": "text/html" });
|
|
239
|
+
// A denial with a matching state is the real user saying no; stop waiting.
|
|
240
|
+
// Anything else (stray requests, bad state) is ignored and we keep waiting.
|
|
241
|
+
const onFlushed = parsed.reason === "denied"
|
|
242
|
+
? () => finish(() => reject(new Error(parsed.message)))
|
|
243
|
+
: undefined;
|
|
244
|
+
res.end(errorHtml(parsed.message), onFlushed);
|
|
245
|
+
};
|
|
246
|
+
server.on("request", onRequest);
|
|
247
|
+
const timer = setTimeout(() => {
|
|
248
|
+
finish(() => reject(new Error(`Timed out waiting for authentication after ${Math.round(timeoutMs / 60_000)} minutes.`)));
|
|
249
|
+
}, timeoutMs);
|
|
250
|
+
const rl = interactive
|
|
251
|
+
? createInterface({ input, output, prompt: PASTE_PROMPT })
|
|
252
|
+
: undefined;
|
|
253
|
+
if (!rl)
|
|
254
|
+
return;
|
|
255
|
+
rl.on("SIGINT", () => finish(() => reject(new AuthCancelledError())));
|
|
256
|
+
rl.on("line", (line) => {
|
|
257
|
+
if (settled)
|
|
258
|
+
return;
|
|
259
|
+
if (!line.trim()) {
|
|
260
|
+
rl.prompt();
|
|
66
261
|
return;
|
|
67
262
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
exchangeCodeForToken(baseUrl, returnedCode, codeVerifier, clientId, redirectUri)
|
|
72
|
-
.then(({ accessToken, refreshToken }) => resolve({ accessToken, refreshToken, clientId }))
|
|
73
|
-
.catch(reject);
|
|
74
|
-
});
|
|
75
|
-
let codeVerifier;
|
|
76
|
-
let clientId;
|
|
77
|
-
let redirectUri;
|
|
78
|
-
const timeout = setTimeout(() => {
|
|
79
|
-
cleanup();
|
|
80
|
-
reject(new Error("Browser authentication timed out after 2 minutes."));
|
|
81
|
-
}, AUTH_TIMEOUT_MS);
|
|
82
|
-
function cleanup() {
|
|
83
|
-
clearTimeout(timeout);
|
|
84
|
-
server.closeAllConnections();
|
|
85
|
-
server.close();
|
|
86
|
-
}
|
|
87
|
-
server.listen(0, "127.0.0.1", async () => {
|
|
88
|
-
try {
|
|
89
|
-
const addr = server.address();
|
|
90
|
-
if (!addr || typeof addr === "string") {
|
|
91
|
-
cleanup();
|
|
92
|
-
reject(new Error("Failed to start local server."));
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
const port = addr.port;
|
|
96
|
-
redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
97
|
-
const registration = await registerOAuthClient(baseUrl, redirectUri);
|
|
98
|
-
clientId = registration.clientId;
|
|
99
|
-
const pkce = generatePkceChallenge();
|
|
100
|
-
codeVerifier = pkce.codeVerifier;
|
|
101
|
-
const authUrl = buildAuthorizeUrl(baseUrl, {
|
|
102
|
-
clientId,
|
|
103
|
-
redirectUri,
|
|
104
|
-
codeChallenge: pkce.codeChallenge,
|
|
105
|
-
state,
|
|
106
|
-
});
|
|
107
|
-
console.log("Opening browser to authenticate...");
|
|
108
|
-
openBrowser(authUrl);
|
|
109
|
-
console.log("Waiting for authentication (timeout: 2 min)...");
|
|
110
|
-
console.log(`\nIf the browser didn't open, visit:\n ${authUrl}\n`);
|
|
263
|
+
const parsed = parseCallbackInput(line, state);
|
|
264
|
+
if (parsed.ok) {
|
|
265
|
+
finish(() => resolve(parsed.code));
|
|
111
266
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
267
|
+
else if (parsed.reason === "denied") {
|
|
268
|
+
finish(() => reject(new Error(parsed.message)));
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
output.write(parsed.message + "\n");
|
|
272
|
+
rl.prompt();
|
|
115
273
|
}
|
|
116
274
|
});
|
|
275
|
+
rl.prompt();
|
|
117
276
|
});
|
|
118
277
|
}
|
|
278
|
+
export async function browserAuth(opts = {}) {
|
|
279
|
+
const input = opts.input ?? process.stdin;
|
|
280
|
+
const output = opts.output ?? process.stdout;
|
|
281
|
+
const interactive = opts.interactive ?? !!input.isTTY;
|
|
282
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS;
|
|
283
|
+
const launch = opts.launchBrowser ??
|
|
284
|
+
shouldLaunchBrowser({ platform: process.platform, env: process.env });
|
|
285
|
+
const log = (line = "") => output.write(line + "\n");
|
|
286
|
+
if (launch) {
|
|
287
|
+
await confirm(input, output, "Press Enter to open your browser and log in...");
|
|
288
|
+
}
|
|
289
|
+
const baseUrl = getApiBaseUrl();
|
|
290
|
+
const state = randomBytes(32).toString("hex");
|
|
291
|
+
const server = createServer();
|
|
292
|
+
const port = await listen(server);
|
|
293
|
+
const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
|
|
294
|
+
try {
|
|
295
|
+
const { clientId } = await registerOAuthClient(baseUrl, redirectUri);
|
|
296
|
+
const pkce = generatePkceChallenge();
|
|
297
|
+
const authUrl = buildAuthorizeUrl(baseUrl, {
|
|
298
|
+
clientId,
|
|
299
|
+
redirectUri,
|
|
300
|
+
codeChallenge: pkce.codeChallenge,
|
|
301
|
+
state,
|
|
302
|
+
});
|
|
303
|
+
if (launch) {
|
|
304
|
+
log("Opening your browser...");
|
|
305
|
+
openBrowser(authUrl, () => log("Couldn't open a browser. Use the link above."));
|
|
306
|
+
log();
|
|
307
|
+
log("If it didn't open, use this link:");
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
log("Open this link in a browser on any device and log in:");
|
|
311
|
+
}
|
|
312
|
+
log(` ${authUrl}`);
|
|
313
|
+
log();
|
|
314
|
+
log("Waiting for you to finish logging in... (Ctrl-C to cancel)");
|
|
315
|
+
if (interactive) {
|
|
316
|
+
log("If the redirect page shows a connection error, paste the URL from your browser's address bar here:");
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
log("Can't paste here: stdin isn't a terminal. If the browser can't reach this machine,");
|
|
320
|
+
log("re-run in an interactive terminal (e.g. `ssh -t`).");
|
|
321
|
+
}
|
|
322
|
+
const code = await waitForCode({
|
|
323
|
+
server,
|
|
324
|
+
state,
|
|
325
|
+
input,
|
|
326
|
+
output,
|
|
327
|
+
interactive,
|
|
328
|
+
timeoutMs,
|
|
329
|
+
});
|
|
330
|
+
const { accessToken, refreshToken } = await exchangeCodeForToken(baseUrl, code, pkce.codeVerifier, clientId, redirectUri);
|
|
331
|
+
return { accessToken, refreshToken, clientId };
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
server.closeAllConnections();
|
|
335
|
+
server.close();
|
|
336
|
+
}
|
|
337
|
+
}
|
package/package.json
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voxli/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "CLI agent for running Voxli test scenarios locally",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/voxli-io/voxli-cli.git"
|
|
8
|
+
},
|
|
5
9
|
"type": "module",
|
|
6
10
|
"bin": {
|
|
7
11
|
"voxli": "./dist/cli.js"
|
|
8
12
|
},
|
|
9
13
|
"scripts": {
|
|
10
14
|
"build": "tsc",
|
|
11
|
-
"dev": "tsc --watch"
|
|
15
|
+
"dev": "tsc --watch",
|
|
16
|
+
"lint": "eslint src tests",
|
|
17
|
+
"test": "tsx --test tests/*.test.ts"
|
|
12
18
|
},
|
|
13
19
|
"files": [
|
|
14
20
|
"dist"
|
|
15
21
|
],
|
|
16
22
|
"engines": {
|
|
17
|
-
"node": ">=
|
|
23
|
+
"node": ">=20"
|
|
18
24
|
},
|
|
19
25
|
"dependencies": {
|
|
20
26
|
"commander": "^13.1.0"
|
|
21
27
|
},
|
|
22
28
|
"devDependencies": {
|
|
29
|
+
"@eslint/js": "^10.0.1",
|
|
23
30
|
"@types/node": "^22.0.0",
|
|
24
|
-
"
|
|
31
|
+
"eslint": "^10.9.1",
|
|
32
|
+
"tsx": "^4.23.13",
|
|
33
|
+
"typescript": "^5.7.0",
|
|
34
|
+
"typescript-eslint": "^8.69.0"
|
|
25
35
|
}
|
|
26
36
|
}
|