@voxli/cli 0.3.1 → 0.4.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
@@ -12,13 +12,21 @@ Requires Node.js 18+.
12
12
 
13
13
  ## Setup
14
14
 
15
- Authenticate with your Voxli API key:
15
+ Authenticate with your Voxli account:
16
16
 
17
17
  ```sh
18
18
  voxli auth
19
19
  ```
20
20
 
21
- This saves your key to `~/.voxli/config.json`. You can also set the `VOXLI_API_KEY` environment variable instead.
21
+ This opens your browser to `app.voxli.io` where you log in and approve access. An API key is created automatically and saved to `~/.voxli/config.json`.
22
+
23
+ To enter an API key manually instead:
24
+
25
+ ```sh
26
+ voxli auth --manual
27
+ ```
28
+
29
+ You can also set the `VOXLI_API_TOKEN` environment variable instead.
22
30
 
23
31
  ## Usage
24
32
 
@@ -32,7 +40,7 @@ The CLI polls the Voxli API for pending test batches. When work arrives, it spaw
32
40
 
33
41
  | Variable | Description |
34
42
  |---|---|
35
- | `VOXLI_API_KEY` | Your API key |
43
+ | `VOXLI_API_TOKEN` | Your API key |
36
44
  | `TEST_RESULT_IDS` | JSON array of test result IDs to run |
37
45
  | `RUN_ID` | The run ID (if part of a run) |
38
46
 
@@ -40,5 +48,7 @@ The CLI polls the Voxli API for pending test batches. When work arrives, it spaw
40
48
 
41
49
  | Command | Description |
42
50
  |---|---|
43
- | `voxli auth` | Authenticate with your API key |
51
+ | `voxli auth` | Authenticate via browser |
52
+ | `voxli auth --manual` | Authenticate by entering an API key manually |
44
53
  | `voxli listen --command <cmd>` | Poll for pending test work and run it locally |
54
+
package/dist/cli.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { Command } from "commander";
3
4
  import { authCommand } from "./commands/auth.js";
4
5
  import { listenCommand } from "./commands/listen.js";
6
+ const require = createRequire(import.meta.url);
7
+ const { version } = require("../package.json");
5
8
  const program = new Command();
6
9
  program
7
10
  .name("voxli")
8
11
  .description("CLI agent for running Voxli test scenarios locally")
9
- .version("0.1.0");
12
+ .version(version);
10
13
  program
11
14
  .command("auth")
12
15
  .description("Authenticate with your Voxli API key")
@@ -4,54 +4,62 @@ 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
+ async function promptForToken() {
8
8
  const rl = createInterface({ input: stdin, output: stdout });
9
9
  try {
10
- const apiKey = await rl.question("Enter your Voxli API key: ");
11
- if (!apiKey.trim()) {
10
+ const token = await rl.question("Enter your Voxli API key: ");
11
+ if (!token.trim()) {
12
12
  console.error("API key cannot be empty.");
13
13
  process.exit(1);
14
14
  }
15
- return apiKey.trim();
15
+ return token.trim();
16
16
  }
17
17
  finally {
18
18
  rl.close();
19
19
  }
20
20
  }
21
- async function validateAndSave(key, userId) {
21
+ async function validateAndSave(token, extra) {
22
+ const label = extra ? "Access token" : "API key";
22
23
  console.log("Validating...");
23
24
  try {
24
25
  const hostname = getStableHostname();
25
- await register(key, {
26
+ await register(token, {
26
27
  name: hostname,
27
28
  unique_identifier: hostname,
28
29
  });
29
- console.log("API key is valid.");
30
+ console.log(`${label} is valid.`);
30
31
  }
31
32
  catch (err) {
32
33
  if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
33
- console.error(`Authentication failed (${err.status}). Check your API key.`);
34
+ console.error(`Authentication failed (${err.status}). Check your ${label.toLowerCase()}.`);
34
35
  process.exit(1);
35
36
  }
36
37
  // Network error or other — warn but still save
37
- console.warn("Warning: could not validate key (network error). Saving anyway.");
38
+ console.warn("Warning: could not validate token (network error). Saving anyway.");
38
39
  }
39
- await writeConfig({ apiKey: key, userId });
40
- console.log("API key saved to ~/.voxli/config.json");
40
+ await writeConfig({
41
+ accessToken: token,
42
+ refreshToken: extra?.refreshToken,
43
+ clientId: extra?.clientId,
44
+ });
45
+ console.log(`${label} saved to ~/.voxli/config.json`);
41
46
  }
42
47
  export async function authCommand(opts) {
43
48
  if (!opts.manual) {
44
49
  try {
45
50
  const result = await browserAuth();
46
- await validateAndSave(result.apiKey, result.userId);
51
+ await validateAndSave(result.accessToken, {
52
+ refreshToken: result.refreshToken,
53
+ clientId: result.clientId,
54
+ });
47
55
  return;
48
56
  }
49
57
  catch (err) {
50
58
  const msg = err instanceof Error ? err.message : String(err);
51
59
  console.log(`\nBrowser auth failed: ${msg}`);
52
- console.log("Falling back to manual key entry.\n");
60
+ console.log("Falling back to manual token entry.\n");
53
61
  }
54
62
  }
55
- const key = await promptForKey();
56
- await validateAndSave(key);
63
+ const token = await promptForToken();
64
+ await validateAndSave(token);
57
65
  }
@@ -1,12 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
- import { resolveApiKeyAsync } from "../lib/config.js";
2
+ import { resolveApiKeyAsync, resolveApiKey, attemptTokenRefresh, } from "../lib/config.js";
3
3
  import { getStableHostname } from "../lib/hostname.js";
4
4
  import { register, ApiError } from "../lib/api.js";
5
5
  const POLL_INTERVAL = 5_000;
6
6
  export async function listenCommand(options) {
7
- const apiKey = await resolveApiKeyAsync();
7
+ const isEnvToken = !!resolveApiKey();
8
+ let apiKey = await resolveApiKeyAsync();
8
9
  if (!apiKey) {
9
- console.error("Error: No API key found. Set VOXLI_API_KEY or run `voxli auth`.");
10
+ console.error("Error: No API key found. Set VOXLI_API_TOKEN or run `voxli auth`.");
10
11
  process.exit(1);
11
12
  }
12
13
  const hostname = getStableHostname();
@@ -35,7 +36,7 @@ export async function listenCommand(options) {
35
36
  console.log(`Spawning subprocess for ${testResultIds.length} test(s) (${label})`);
36
37
  const env = {
37
38
  ...process.env,
38
- VOXLI_API_KEY: apiKey,
39
+ VOXLI_API_TOKEN: apiKey,
39
40
  VOXLI_API_URL: process.env.VOXLI_API_URL,
40
41
  VOXLI_APP_URL: process.env.VOXLI_APP_URL,
41
42
  TEST_RESULT_IDS: JSON.stringify(testResultIds),
@@ -64,7 +65,23 @@ export async function listenCommand(options) {
64
65
  }
65
66
  }
66
67
  catch (err) {
67
- if (err instanceof ApiError) {
68
+ if (err instanceof ApiError &&
69
+ (err.status === 401 || err.status === 403)) {
70
+ if (isEnvToken) {
71
+ console.error(`Error: Authentication failed (${err.status}). Your VOXLI_API_TOKEN environment variable may be expired or invalid.`);
72
+ process.exit(1);
73
+ }
74
+ console.log("Access token expired, attempting refresh...");
75
+ const newToken = await attemptTokenRefresh();
76
+ if (newToken) {
77
+ apiKey = newToken;
78
+ console.log("Token refreshed successfully.");
79
+ continue;
80
+ }
81
+ console.error("Error: Could not refresh access token. Please re-authenticate with `voxli auth`.");
82
+ process.exit(1);
83
+ }
84
+ else if (err instanceof ApiError) {
68
85
  console.error(`Poll error: API ${err.status}`);
69
86
  }
70
87
  else {
package/dist/lib/api.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { RegisterPayload, RegisterResponse } from "../types.js";
2
+ export declare function getApiBaseUrl(): string;
2
3
  export declare function register(apiKey: string, payload: RegisterPayload): Promise<RegisterResponse>;
3
4
  export declare class ApiError extends Error {
4
5
  status: number;
package/dist/lib/api.js CHANGED
@@ -1,7 +1,10 @@
1
1
  const DEFAULT_BASE_URL = "https://api.voxli.io";
2
- function getBaseUrl() {
2
+ export function getApiBaseUrl() {
3
3
  return process.env.VOXLI_API_URL || DEFAULT_BASE_URL;
4
4
  }
5
+ function getBaseUrl() {
6
+ return getApiBaseUrl();
7
+ }
5
8
  export async function register(apiKey, payload) {
6
9
  const url = `${getBaseUrl()}/agents/register`;
7
10
  const res = await fetch(url, {
@@ -1,5 +1,6 @@
1
1
  export interface BrowserAuthResult {
2
- apiKey: string;
3
- userId?: string;
2
+ accessToken: string;
3
+ refreshToken?: string;
4
+ clientId: string;
4
5
  }
5
6
  export declare function browserAuth(): Promise<BrowserAuthResult>;
@@ -1,14 +1,11 @@
1
- import { createServer } from "node:http";
1
+ import { createServer, } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { execFile } from "node:child_process";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { stdin, stdout } from "node:process";
6
- import { getStableHostname } from "./hostname.js";
6
+ import { getApiBaseUrl } from "./api.js";
7
+ import { generatePkceChallenge, registerOAuthClient, exchangeCodeForToken, buildAuthorizeUrl, } from "./oauth.js";
7
8
  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
9
  const SUCCESS_HTML = `<!DOCTYPE html>
13
10
  <html>
14
11
  <head><meta charset="utf-8"><title>Voxli CLI</title>
@@ -46,6 +43,7 @@ export async function browserAuth() {
46
43
  finally {
47
44
  rl.close();
48
45
  }
46
+ const baseUrl = getApiBaseUrl();
49
47
  return new Promise((resolve, reject) => {
50
48
  const state = randomBytes(32).toString("hex");
51
49
  const server = createServer((req, res) => {
@@ -55,15 +53,14 @@ export async function browserAuth() {
55
53
  res.end("Not found");
56
54
  return;
57
55
  }
58
- const returnedKey = url.searchParams.get("key");
56
+ const returnedCode = url.searchParams.get("code");
59
57
  const returnedState = url.searchParams.get("state");
60
- const returnedUserId = url.searchParams.get("user_id");
61
58
  if (returnedState !== state) {
62
59
  res.writeHead(403, { "Content-Type": "text/html" });
63
60
  res.end(ERROR_HTML);
64
61
  return;
65
62
  }
66
- if (!returnedKey) {
63
+ if (!returnedCode) {
67
64
  res.writeHead(400, { "Content-Type": "text/html" });
68
65
  res.end(ERROR_HTML);
69
66
  return;
@@ -71,8 +68,13 @@ export async function browserAuth() {
71
68
  res.writeHead(200, { "Content-Type": "text/html" });
72
69
  res.end(SUCCESS_HTML);
73
70
  cleanup();
74
- resolve({ apiKey: returnedKey, userId: returnedUserId || undefined });
71
+ exchangeCodeForToken(baseUrl, returnedCode, codeVerifier, clientId, redirectUri)
72
+ .then(({ accessToken, refreshToken }) => resolve({ accessToken, refreshToken, clientId }))
73
+ .catch(reject);
75
74
  });
75
+ let codeVerifier;
76
+ let clientId;
77
+ let redirectUri;
76
78
  const timeout = setTimeout(() => {
77
79
  cleanup();
78
80
  reject(new Error("Browser authentication timed out after 2 minutes."));
@@ -81,20 +83,35 @@ export async function browserAuth() {
81
83
  clearTimeout(timeout);
82
84
  server.close();
83
85
  }
84
- server.listen(0, "127.0.0.1", () => {
85
- const addr = server.address();
86
- if (!addr || typeof addr === "string") {
86
+ server.listen(0, "127.0.0.1", async () => {
87
+ try {
88
+ const addr = server.address();
89
+ if (!addr || typeof addr === "string") {
90
+ cleanup();
91
+ reject(new Error("Failed to start local server."));
92
+ return;
93
+ }
94
+ const port = addr.port;
95
+ redirectUri = `http://127.0.0.1:${port}/callback`;
96
+ const registration = await registerOAuthClient(baseUrl, redirectUri);
97
+ clientId = registration.clientId;
98
+ const pkce = generatePkceChallenge();
99
+ codeVerifier = pkce.codeVerifier;
100
+ const authUrl = buildAuthorizeUrl(baseUrl, {
101
+ clientId,
102
+ redirectUri,
103
+ codeChallenge: pkce.codeChallenge,
104
+ state,
105
+ });
106
+ console.log("Opening browser to authenticate...");
107
+ openBrowser(authUrl);
108
+ console.log("Waiting for authentication (timeout: 2 min)...");
109
+ console.log(`\nIf the browser didn't open, visit:\n ${authUrl}\n`);
110
+ }
111
+ catch (err) {
87
112
  cleanup();
88
- reject(new Error("Failed to start local server."));
89
- return;
113
+ reject(err);
90
114
  }
91
- const port = addr.port;
92
- const hostname = encodeURIComponent(getStableHostname());
93
- const authUrl = `${getAppUrl()}/cli-auth?port=${port}&state=${state}&hostname=${hostname}`;
94
- console.log("Opening browser to authenticate...");
95
- openBrowser(authUrl);
96
- console.log(`Waiting for authentication (timeout: 2 min)...`);
97
- console.log(`\nIf the browser didn't open, visit:\n ${authUrl}\n`);
98
115
  });
99
116
  });
100
117
  }
@@ -1,5 +1,10 @@
1
1
  import type { VoxliConfig } from "../types.js";
2
2
  export declare function readConfig(): Promise<VoxliConfig | null>;
3
- export declare function writeConfig(config: VoxliConfig): Promise<void>;
3
+ export declare function writeConfig(config: {
4
+ accessToken: string;
5
+ refreshToken?: string;
6
+ clientId?: string;
7
+ }): Promise<void>;
4
8
  export declare function resolveApiKey(): string | null;
5
9
  export declare function resolveApiKeyAsync(): Promise<string | null>;
10
+ export declare function attemptTokenRefresh(): Promise<string | null>;
@@ -1,6 +1,8 @@
1
1
  import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ import { refreshAccessToken } from "./oauth.js";
5
+ import { getApiBaseUrl } from "./api.js";
4
6
  const CONFIG_DIR = join(homedir(), ".voxli");
5
7
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
6
8
  export async function readConfig() {
@@ -13,14 +15,17 @@ export async function readConfig() {
13
15
  }
14
16
  }
15
17
  export async function writeConfig(config) {
18
+ const data = { accessToken: config.accessToken };
19
+ if (config.refreshToken)
20
+ data.refreshToken = config.refreshToken;
21
+ if (config.clientId)
22
+ data.clientId = config.clientId;
16
23
  await mkdir(CONFIG_DIR, { recursive: true });
17
- await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", {
18
- mode: 0o600,
19
- });
24
+ await writeFile(CONFIG_PATH, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
20
25
  await chmod(CONFIG_PATH, 0o600);
21
26
  }
22
27
  export function resolveApiKey() {
23
- const envKey = process.env.VOXLI_API_KEY;
28
+ const envKey = process.env.VOXLI_API_TOKEN;
24
29
  if (envKey)
25
30
  return envKey;
26
31
  // Caller should await readConfig() for the file-based key
@@ -31,5 +36,23 @@ export async function resolveApiKeyAsync() {
31
36
  if (envKey)
32
37
  return envKey;
33
38
  const config = await readConfig();
34
- return config?.apiKey ?? null;
39
+ return config?.accessToken ?? config?.apiKey ?? null;
40
+ }
41
+ export async function attemptTokenRefresh() {
42
+ try {
43
+ const config = await readConfig();
44
+ if (!config?.refreshToken || !config?.clientId)
45
+ return null;
46
+ const baseUrl = getApiBaseUrl();
47
+ const result = await refreshAccessToken(baseUrl, config.refreshToken, config.clientId);
48
+ await writeConfig({
49
+ accessToken: result.accessToken,
50
+ refreshToken: result.refreshToken ?? config.refreshToken,
51
+ clientId: config.clientId,
52
+ });
53
+ return result.accessToken;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
35
58
  }
@@ -0,0 +1,21 @@
1
+ export declare function generatePkceChallenge(): {
2
+ codeVerifier: string;
3
+ codeChallenge: string;
4
+ };
5
+ export declare function registerOAuthClient(baseUrl: string, redirectUri: string): Promise<{
6
+ clientId: string;
7
+ }>;
8
+ export declare function exchangeCodeForToken(baseUrl: string, code: string, codeVerifier: string, clientId: string, redirectUri: string): Promise<{
9
+ accessToken: string;
10
+ refreshToken?: string;
11
+ }>;
12
+ export declare function refreshAccessToken(baseUrl: string, refreshToken: string, clientId: string): Promise<{
13
+ accessToken: string;
14
+ refreshToken?: string;
15
+ }>;
16
+ export declare function buildAuthorizeUrl(baseUrl: string, params: {
17
+ clientId: string;
18
+ redirectUri: string;
19
+ codeChallenge: string;
20
+ state: string;
21
+ }): string;
@@ -0,0 +1,78 @@
1
+ import { randomBytes, createHash } from "node:crypto";
2
+ export function generatePkceChallenge() {
3
+ const codeVerifier = randomBytes(32).toString("base64url");
4
+ const codeChallenge = createHash("sha256")
5
+ .update(codeVerifier)
6
+ .digest("base64url");
7
+ return { codeVerifier, codeChallenge };
8
+ }
9
+ export async function registerOAuthClient(baseUrl, redirectUri) {
10
+ const res = await fetch(`${baseUrl}/oauth/register`, {
11
+ method: "POST",
12
+ headers: { "Content-Type": "application/json" },
13
+ body: JSON.stringify({
14
+ client_name: "Voxli CLI",
15
+ redirect_uris: [redirectUri],
16
+ grant_types: ["authorization_code"],
17
+ response_types: ["code"],
18
+ token_endpoint_auth_method: "none",
19
+ }),
20
+ });
21
+ if (!res.ok) {
22
+ throw new Error(`OAuth client registration failed (${res.status})`);
23
+ }
24
+ const data = (await res.json());
25
+ return { clientId: data.client_id };
26
+ }
27
+ export async function exchangeCodeForToken(baseUrl, code, codeVerifier, clientId, redirectUri) {
28
+ const body = new URLSearchParams({
29
+ grant_type: "authorization_code",
30
+ code,
31
+ code_verifier: codeVerifier,
32
+ client_id: clientId,
33
+ redirect_uri: redirectUri,
34
+ });
35
+ const res = await fetch(`${baseUrl}/oauth/token`, {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
38
+ body: body.toString(),
39
+ });
40
+ if (!res.ok) {
41
+ throw new Error(`Token exchange failed (${res.status})`);
42
+ }
43
+ const data = (await res.json());
44
+ return {
45
+ accessToken: data.access_token,
46
+ refreshToken: data.refresh_token,
47
+ };
48
+ }
49
+ export async function refreshAccessToken(baseUrl, refreshToken, clientId) {
50
+ const body = new URLSearchParams({
51
+ grant_type: "refresh_token",
52
+ refresh_token: refreshToken,
53
+ client_id: clientId,
54
+ });
55
+ const res = await fetch(`${baseUrl}/oauth/token`, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
58
+ body: body.toString(),
59
+ });
60
+ if (!res.ok) {
61
+ throw new Error(`Token refresh failed (${res.status})`);
62
+ }
63
+ const data = (await res.json());
64
+ return {
65
+ accessToken: data.access_token,
66
+ refreshToken: data.refresh_token,
67
+ };
68
+ }
69
+ export function buildAuthorizeUrl(baseUrl, params) {
70
+ const url = new URL("/oauth/authorize", baseUrl);
71
+ url.searchParams.set("response_type", "code");
72
+ url.searchParams.set("client_id", params.clientId);
73
+ url.searchParams.set("redirect_uri", params.redirectUri);
74
+ url.searchParams.set("code_challenge", params.codeChallenge);
75
+ url.searchParams.set("code_challenge_method", "S256");
76
+ url.searchParams.set("state", params.state);
77
+ return url.toString();
78
+ }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export interface VoxliConfig {
2
- apiKey: string;
3
- userId?: string;
2
+ accessToken: string;
3
+ apiKey?: string;
4
+ refreshToken?: string;
5
+ clientId?: string;
4
6
  }
5
7
  export interface RegisterPayload {
6
8
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxli/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "CLI agent for running Voxli test scenarios locally",
5
5
  "type": "module",
6
6
  "bin": {