@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 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
@@ -10,6 +10,7 @@ program
10
10
  program
11
11
  .command("auth")
12
12
  .description("Authenticate with your Voxli API key")
13
+ .option("--manual", "Enter API key manually instead of browser auth")
13
14
  .action(authCommand);
14
15
  program
15
16
  .command("listen")
@@ -1 +1,3 @@
1
- export declare function authCommand(): Promise<void>;
1
+ export declare function authCommand(opts: {
2
+ manual?: boolean;
3
+ }): Promise<void>;
@@ -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
- export async function authCommand() {
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
- const key = apiKey.trim();
15
- // Validate by calling /agents/register
16
- console.log("Validating...");
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 hostname = getStableHostname();
19
- await register(key, {
20
- name: hostname,
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
- if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
27
- console.error(`Authentication failed (${err.status}). Check your API key.`);
28
- process.exit(1);
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
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxli/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "CLI agent for running Voxli test scenarios locally",
5
5
  "type": "module",
6
6
  "bin": {