@voxli/cli 0.2.1 → 0.3.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 +44 -1
- package/dist/cli.js +1 -0
- package/dist/commands/auth.d.ts +3 -1
- package/dist/commands/auth.js +39 -21
- package/dist/commands/listen.js +2 -0
- package/dist/lib/browser-auth.d.ts +1 -0
- package/dist/lib/browser-auth.js +99 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1 +1,44 @@
|
|
|
1
|
-
# voxli
|
|
1
|
+
# @voxli/cli
|
|
2
|
+
|
|
3
|
+
CLI agent for running [Voxli](https://voxli.io) test scenarios locally.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install -g @voxli/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Node.js 18+.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
Authenticate with your Voxli API key:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
voxli auth
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This saves your key to `~/.voxli/config.json`. You can also set the `VOXLI_API_KEY` environment variable instead.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Start listening for test work:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
voxli listen --command "<your test command>"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The CLI polls the Voxli API for pending test batches. When work arrives, it spawns your command as a subprocess with these environment variables:
|
|
32
|
+
|
|
33
|
+
| Variable | Description |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `VOXLI_API_KEY` | Your API key |
|
|
36
|
+
| `TEST_RESULT_IDS` | JSON array of test result IDs to run |
|
|
37
|
+
| `RUN_ID` | The run ID (if part of a run) |
|
|
38
|
+
|
|
39
|
+
## Commands
|
|
40
|
+
|
|
41
|
+
| Command | Description |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `voxli auth` | Authenticate with your API key |
|
|
44
|
+
| `voxli listen --command <cmd>` | Poll for pending test work and run it locally |
|
package/dist/cli.js
CHANGED
package/dist/commands/auth.d.ts
CHANGED
package/dist/commands/auth.js
CHANGED
|
@@ -3,7 +3,8 @@ import { stdin, stdout } from "node:process";
|
|
|
3
3
|
import { writeConfig } from "../lib/config.js";
|
|
4
4
|
import { register, ApiError } from "../lib/api.js";
|
|
5
5
|
import { getStableHostname } from "../lib/hostname.js";
|
|
6
|
-
|
|
6
|
+
import { browserAuth } from "../lib/browser-auth.js";
|
|
7
|
+
async function promptForKey() {
|
|
7
8
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
8
9
|
try {
|
|
9
10
|
const apiKey = await rl.question("Enter your Voxli API key: ");
|
|
@@ -11,29 +12,46 @@ export async function authCommand() {
|
|
|
11
12
|
console.error("API key cannot be empty.");
|
|
12
13
|
process.exit(1);
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
return apiKey.trim();
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
rl.close();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function validateAndSave(key) {
|
|
22
|
+
console.log("Validating...");
|
|
23
|
+
try {
|
|
24
|
+
const hostname = getStableHostname();
|
|
25
|
+
await register(key, {
|
|
26
|
+
name: hostname,
|
|
27
|
+
unique_identifier: hostname,
|
|
28
|
+
});
|
|
29
|
+
console.log("API key is valid.");
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
33
|
+
console.error(`Authentication failed (${err.status}). Check your API key.`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
// Network error or other — warn but still save
|
|
37
|
+
console.warn("Warning: could not validate key (network error). Saving anyway.");
|
|
38
|
+
}
|
|
39
|
+
await writeConfig({ apiKey: key });
|
|
40
|
+
console.log("API key saved to ~/.voxli/config.json");
|
|
41
|
+
}
|
|
42
|
+
export async function authCommand(opts) {
|
|
43
|
+
if (!opts.manual) {
|
|
17
44
|
try {
|
|
18
|
-
const
|
|
19
|
-
await
|
|
20
|
-
|
|
21
|
-
unique_identifier: hostname,
|
|
22
|
-
});
|
|
23
|
-
console.log("API key is valid.");
|
|
45
|
+
const key = await browserAuth();
|
|
46
|
+
await validateAndSave(key);
|
|
47
|
+
return;
|
|
24
48
|
}
|
|
25
49
|
catch (err) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
// Network error or other — warn but still save
|
|
31
|
-
console.warn("Warning: could not validate key (network error). Saving anyway.");
|
|
50
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
51
|
+
console.log(`\nBrowser auth failed: ${msg}`);
|
|
52
|
+
console.log("Falling back to manual key entry.\n");
|
|
32
53
|
}
|
|
33
|
-
await writeConfig({ apiKey: key });
|
|
34
|
-
console.log("API key saved to ~/.voxli/config.json");
|
|
35
|
-
}
|
|
36
|
-
finally {
|
|
37
|
-
rl.close();
|
|
38
54
|
}
|
|
55
|
+
const key = await promptForKey();
|
|
56
|
+
await validateAndSave(key);
|
|
39
57
|
}
|
package/dist/commands/listen.js
CHANGED
|
@@ -36,6 +36,8 @@ export async function listenCommand(options) {
|
|
|
36
36
|
const env = {
|
|
37
37
|
...process.env,
|
|
38
38
|
VOXLI_API_KEY: apiKey,
|
|
39
|
+
VOXLI_API_URL: process.env.VOXLI_API_URL,
|
|
40
|
+
VOXLI_APP_URL: process.env.VOXLI_APP_URL,
|
|
39
41
|
TEST_RESULT_IDS: JSON.stringify(testResultIds),
|
|
40
42
|
};
|
|
41
43
|
if (runId) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function browserAuth(): Promise<string>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { stdin, stdout } from "node:process";
|
|
6
|
+
import { getStableHostname } from "./hostname.js";
|
|
7
|
+
const AUTH_TIMEOUT_MS = 120_000;
|
|
8
|
+
const DEFAULT_APP_URL = "https://app.voxli.io";
|
|
9
|
+
function getAppUrl() {
|
|
10
|
+
return process.env.VOXLI_APP_URL || DEFAULT_APP_URL;
|
|
11
|
+
}
|
|
12
|
+
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
13
|
+
<html>
|
|
14
|
+
<head><meta charset="utf-8"><title>Voxli CLI</title>
|
|
15
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f9fafb}
|
|
16
|
+
.card{text-align:center;padding:2rem;border-radius:12px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.1)}
|
|
17
|
+
h1{color:#0a3b29;margin:0 0 .5rem}p{color:#6b7280;margin:0}</style></head>
|
|
18
|
+
<body><div class="card"><h1>Authenticated!</h1><p>You can close this tab and return to the terminal.</p></div></body>
|
|
19
|
+
</html>`;
|
|
20
|
+
const ERROR_HTML = `<!DOCTYPE html>
|
|
21
|
+
<html>
|
|
22
|
+
<head><meta charset="utf-8"><title>Voxli CLI</title>
|
|
23
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f9fafb}
|
|
24
|
+
.card{text-align:center;padding:2rem;border-radius:12px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.1)}
|
|
25
|
+
h1{color:#dc2626;margin:0 0 .5rem}p{color:#6b7280;margin:0}</style></head>
|
|
26
|
+
<body><div class="card"><h1>Authentication failed</h1><p>State mismatch. Please try again.</p></div></body>
|
|
27
|
+
</html>`;
|
|
28
|
+
function openBrowser(url) {
|
|
29
|
+
const cmd = process.platform === "darwin"
|
|
30
|
+
? "open"
|
|
31
|
+
: process.platform === "win32"
|
|
32
|
+
? "cmd"
|
|
33
|
+
: "xdg-open";
|
|
34
|
+
const args = process.platform === "win32" ? ["/c", "start", url] : [url];
|
|
35
|
+
execFile(cmd, args, (err) => {
|
|
36
|
+
if (err) {
|
|
37
|
+
console.log(`\nOpen this URL in your browser:\n ${url}\n`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export async function browserAuth() {
|
|
42
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
43
|
+
try {
|
|
44
|
+
await rl.question("Press Enter to open the browser to authenticate...");
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
rl.close();
|
|
48
|
+
}
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const state = randomBytes(32).toString("hex");
|
|
51
|
+
const server = createServer((req, res) => {
|
|
52
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
53
|
+
if (url.pathname !== "/callback") {
|
|
54
|
+
res.writeHead(404);
|
|
55
|
+
res.end("Not found");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const returnedKey = url.searchParams.get("key");
|
|
59
|
+
const returnedState = url.searchParams.get("state");
|
|
60
|
+
if (returnedState !== state) {
|
|
61
|
+
res.writeHead(403, { "Content-Type": "text/html" });
|
|
62
|
+
res.end(ERROR_HTML);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!returnedKey) {
|
|
66
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
67
|
+
res.end(ERROR_HTML);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
71
|
+
res.end(SUCCESS_HTML);
|
|
72
|
+
cleanup();
|
|
73
|
+
resolve(returnedKey);
|
|
74
|
+
});
|
|
75
|
+
const timeout = setTimeout(() => {
|
|
76
|
+
cleanup();
|
|
77
|
+
reject(new Error("Browser authentication timed out after 2 minutes."));
|
|
78
|
+
}, AUTH_TIMEOUT_MS);
|
|
79
|
+
function cleanup() {
|
|
80
|
+
clearTimeout(timeout);
|
|
81
|
+
server.close();
|
|
82
|
+
}
|
|
83
|
+
server.listen(0, "127.0.0.1", () => {
|
|
84
|
+
const addr = server.address();
|
|
85
|
+
if (!addr || typeof addr === "string") {
|
|
86
|
+
cleanup();
|
|
87
|
+
reject(new Error("Failed to start local server."));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const port = addr.port;
|
|
91
|
+
const hostname = encodeURIComponent(getStableHostname());
|
|
92
|
+
const authUrl = `${getAppUrl()}/cli-auth?port=${port}&state=${state}&hostname=${hostname}`;
|
|
93
|
+
console.log("Opening browser to authenticate...");
|
|
94
|
+
openBrowser(authUrl);
|
|
95
|
+
console.log(`Waiting for authentication (timeout: 2 min)...`);
|
|
96
|
+
console.log(`\nIf the browser didn't open, visit:\n ${authUrl}\n`);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|