@voxli/cli 0.3.2 → 0.5.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
@@ -14,6 +14,7 @@ program
14
14
  .command("auth")
15
15
  .description("Authenticate with your Voxli API key")
16
16
  .option("--manual", "Enter API key manually instead of browser auth")
17
+ .option("--local", "Save credentials in the current directory")
17
18
  .action(authCommand);
18
19
  program
19
20
  .command("listen")
@@ -1,3 +1,4 @@
1
1
  export declare function authCommand(opts: {
2
2
  manual?: boolean;
3
+ local?: boolean;
3
4
  }): Promise<void>;
@@ -4,54 +4,63 @@ 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, opts) {
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
+ const target = opts?.local ? "local" : "global";
41
+ const savedPath = await writeConfig({
42
+ accessToken: token,
43
+ refreshToken: extra?.refreshToken,
44
+ clientId: extra?.clientId,
45
+ }, { target });
46
+ console.log(`${label} saved to ${savedPath}`);
41
47
  }
42
48
  export async function authCommand(opts) {
43
49
  if (!opts.manual) {
44
50
  try {
45
51
  const result = await browserAuth();
46
- await validateAndSave(result.apiKey, result.userId);
52
+ await validateAndSave(result.accessToken, {
53
+ refreshToken: result.refreshToken,
54
+ clientId: result.clientId,
55
+ }, { local: opts.local });
47
56
  return;
48
57
  }
49
58
  catch (err) {
50
59
  const msg = err instanceof Error ? err.message : String(err);
51
60
  console.log(`\nBrowser auth failed: ${msg}`);
52
- console.log("Falling back to manual key entry.\n");
61
+ console.log("Falling back to manual token entry.\n");
53
62
  }
54
63
  }
55
- const key = await promptForKey();
56
- await validateAndSave(key);
64
+ const token = await promptForToken();
65
+ await validateAndSave(token, undefined, { local: opts.local });
57
66
  }
@@ -1,12 +1,27 @@
1
+ import { join } from "node:path";
1
2
  import { spawn } from "node:child_process";
2
- import { resolveApiKeyAsync } from "../lib/config.js";
3
+ import { resolveApiKey, resolveConfig, attemptTokenRefresh, } from "../lib/config.js";
3
4
  import { getStableHostname } from "../lib/hostname.js";
4
5
  import { register, ApiError } from "../lib/api.js";
5
6
  const POLL_INTERVAL = 5_000;
6
7
  export async function listenCommand(options) {
7
- const apiKey = await resolveApiKeyAsync();
8
+ const isEnvToken = !!resolveApiKey();
9
+ let apiKey = null;
10
+ let credentialSource = null;
11
+ if (isEnvToken) {
12
+ apiKey = resolveApiKey();
13
+ credentialSource = "VOXLI_API_TOKEN";
14
+ }
15
+ else {
16
+ const resolved = await resolveConfig();
17
+ if (resolved) {
18
+ const { config, configDir } = resolved;
19
+ apiKey = config.accessToken ?? config.apiKey ?? null;
20
+ credentialSource = join(configDir, "config.json");
21
+ }
22
+ }
8
23
  if (!apiKey) {
9
- console.error("Error: No API key found. Set VOXLI_API_KEY or run `voxli auth`.");
24
+ console.error("Error: No API key found. Set VOXLI_API_TOKEN or run `voxli auth`.");
10
25
  process.exit(1);
11
26
  }
12
27
  const hostname = getStableHostname();
@@ -21,7 +36,7 @@ export async function listenCommand(options) {
21
36
  };
22
37
  process.on("SIGINT", shutdown);
23
38
  process.on("SIGTERM", shutdown);
24
- console.log(`Listening as ${hostname}...`);
39
+ console.log(`Listening as ${hostname} using credentials from ${credentialSource}`);
25
40
  while (true) {
26
41
  try {
27
42
  const data = await register(apiKey, {
@@ -35,7 +50,7 @@ export async function listenCommand(options) {
35
50
  console.log(`Spawning subprocess for ${testResultIds.length} test(s) (${label})`);
36
51
  const env = {
37
52
  ...process.env,
38
- VOXLI_API_KEY: apiKey,
53
+ VOXLI_API_TOKEN: apiKey,
39
54
  VOXLI_API_URL: process.env.VOXLI_API_URL,
40
55
  VOXLI_APP_URL: process.env.VOXLI_APP_URL,
41
56
  TEST_RESULT_IDS: JSON.stringify(testResultIds),
@@ -64,7 +79,23 @@ export async function listenCommand(options) {
64
79
  }
65
80
  }
66
81
  catch (err) {
67
- if (err instanceof ApiError) {
82
+ if (err instanceof ApiError &&
83
+ (err.status === 401 || err.status === 403)) {
84
+ if (isEnvToken) {
85
+ console.error(`Error: Authentication failed (${err.status}). Your VOXLI_API_TOKEN environment variable may be expired or invalid.`);
86
+ process.exit(1);
87
+ }
88
+ console.log("Access token expired, attempting refresh...");
89
+ const newToken = await attemptTokenRefresh();
90
+ if (newToken) {
91
+ apiKey = newToken;
92
+ console.log("Token refreshed successfully.");
93
+ continue;
94
+ }
95
+ console.error("Error: Could not refresh access token. Please re-authenticate with `voxli auth`.");
96
+ process.exit(1);
97
+ }
98
+ else if (err instanceof ApiError) {
68
99
  console.error(`Poll error: API ${err.status}`);
69
100
  }
70
101
  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,30 +68,51 @@ 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."));
79
81
  }, AUTH_TIMEOUT_MS);
80
82
  function cleanup() {
81
83
  clearTimeout(timeout);
84
+ server.closeAllConnections();
82
85
  server.close();
83
86
  }
84
- server.listen(0, "127.0.0.1", () => {
85
- const addr = server.address();
86
- if (!addr || typeof addr === "string") {
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`);
111
+ }
112
+ catch (err) {
87
113
  cleanup();
88
- reject(new Error("Failed to start local server."));
89
- return;
114
+ reject(err);
90
115
  }
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
116
  });
99
117
  });
100
118
  }
@@ -1,5 +1,26 @@
1
1
  import type { VoxliConfig } from "../types.js";
2
+ /**
3
+ * Walk up from CWD looking for a `.voxli/config.json` file.
4
+ * Returns the `.voxli` directory path if found, or null.
5
+ */
6
+ export declare function findLocalConfigDir(): Promise<string | null>;
2
7
  export declare function readConfig(): Promise<VoxliConfig | null>;
3
- export declare function writeConfig(config: VoxliConfig): Promise<void>;
8
+ /**
9
+ * Resolve config checking local first, then global.
10
+ * Returns the config and the directory it was found in.
11
+ */
12
+ export declare function resolveConfig(): Promise<{
13
+ config: VoxliConfig;
14
+ configDir: string;
15
+ } | null>;
16
+ export declare function writeConfig(config: {
17
+ accessToken: string;
18
+ refreshToken?: string;
19
+ clientId?: string;
20
+ }, opts?: {
21
+ target?: "global" | "local";
22
+ configDir?: string;
23
+ }): Promise<string>;
4
24
  export declare function resolveApiKey(): string | null;
5
25
  export declare function resolveApiKeyAsync(): Promise<string | null>;
26
+ export declare function attemptTokenRefresh(): Promise<string | null>;
@@ -1,35 +1,121 @@
1
- import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { readFile, writeFile, mkdir, chmod, access } from "node:fs/promises";
2
+ import { join, dirname } from "node:path";
3
3
  import { homedir } from "node:os";
4
- const CONFIG_DIR = join(homedir(), ".voxli");
5
- const CONFIG_PATH = join(CONFIG_DIR, "config.json");
6
- export async function readConfig() {
4
+ import { refreshAccessToken } from "./oauth.js";
5
+ import { getApiBaseUrl } from "./api.js";
6
+ const GLOBAL_CONFIG_DIR = join(homedir(), ".voxli");
7
+ const GLOBAL_CONFIG_PATH = join(GLOBAL_CONFIG_DIR, "config.json");
8
+ async function fileExists(path) {
9
+ try {
10
+ await access(path);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ /**
18
+ * Walk up from CWD looking for a `.voxli/config.json` file.
19
+ * Returns the `.voxli` directory path if found, or null.
20
+ */
21
+ export async function findLocalConfigDir() {
22
+ let dir = process.cwd();
23
+ while (true) {
24
+ const candidate = join(dir, ".voxli", "config.json");
25
+ if (await fileExists(candidate)) {
26
+ return join(dir, ".voxli");
27
+ }
28
+ const parent = dirname(dir);
29
+ if (parent === dir)
30
+ break;
31
+ dir = parent;
32
+ }
33
+ return null;
34
+ }
35
+ async function readConfigFrom(configPath) {
7
36
  try {
8
- const raw = await readFile(CONFIG_PATH, "utf-8");
37
+ const raw = await readFile(configPath, "utf-8");
9
38
  return JSON.parse(raw);
10
39
  }
11
40
  catch {
12
41
  return null;
13
42
  }
14
43
  }
15
- export async function writeConfig(config) {
16
- await mkdir(CONFIG_DIR, { recursive: true });
17
- await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", {
44
+ export async function readConfig() {
45
+ return readConfigFrom(GLOBAL_CONFIG_PATH);
46
+ }
47
+ /**
48
+ * Resolve config checking local first, then global.
49
+ * Returns the config and the directory it was found in.
50
+ */
51
+ export async function resolveConfig() {
52
+ const localDir = await findLocalConfigDir();
53
+ if (localDir) {
54
+ const config = await readConfigFrom(join(localDir, "config.json"));
55
+ if (config)
56
+ return { config, configDir: localDir };
57
+ }
58
+ const config = await readConfigFrom(GLOBAL_CONFIG_PATH);
59
+ if (config)
60
+ return { config, configDir: GLOBAL_CONFIG_DIR };
61
+ return null;
62
+ }
63
+ export async function writeConfig(config, opts) {
64
+ let targetDir;
65
+ if (opts?.configDir) {
66
+ targetDir = opts.configDir;
67
+ }
68
+ else if (opts?.target === "local") {
69
+ targetDir = join(process.cwd(), ".voxli");
70
+ }
71
+ else {
72
+ targetDir = GLOBAL_CONFIG_DIR;
73
+ }
74
+ const targetPath = join(targetDir, "config.json");
75
+ const data = { accessToken: config.accessToken };
76
+ if (config.refreshToken)
77
+ data.refreshToken = config.refreshToken;
78
+ if (config.clientId)
79
+ data.clientId = config.clientId;
80
+ await mkdir(targetDir, { recursive: true });
81
+ await writeFile(targetPath, JSON.stringify(data, null, 2) + "\n", {
18
82
  mode: 0o600,
19
83
  });
20
- await chmod(CONFIG_PATH, 0o600);
84
+ await chmod(targetPath, 0o600);
85
+ return targetPath;
21
86
  }
22
87
  export function resolveApiKey() {
23
- const envKey = process.env.VOXLI_API_KEY;
88
+ const envKey = process.env.VOXLI_API_TOKEN;
24
89
  if (envKey)
25
90
  return envKey;
26
- // Caller should await readConfig() for the file-based key
27
91
  return null;
28
92
  }
29
93
  export async function resolveApiKeyAsync() {
30
94
  const envKey = resolveApiKey();
31
95
  if (envKey)
32
96
  return envKey;
33
- const config = await readConfig();
34
- return config?.apiKey ?? null;
97
+ const resolved = await resolveConfig();
98
+ const config = resolved?.config;
99
+ return config?.accessToken ?? config?.apiKey ?? null;
100
+ }
101
+ export async function attemptTokenRefresh() {
102
+ try {
103
+ const resolved = await resolveConfig();
104
+ if (!resolved)
105
+ return null;
106
+ const { config, configDir } = resolved;
107
+ if (!config.refreshToken || !config.clientId)
108
+ return null;
109
+ const baseUrl = getApiBaseUrl();
110
+ const result = await refreshAccessToken(baseUrl, config.refreshToken, config.clientId);
111
+ await writeConfig({
112
+ accessToken: result.accessToken,
113
+ refreshToken: result.refreshToken ?? config.refreshToken,
114
+ clientId: config.clientId,
115
+ }, { configDir });
116
+ return result.accessToken;
117
+ }
118
+ catch {
119
+ return null;
120
+ }
35
121
  }
@@ -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.2",
3
+ "version": "0.5.0",
4
4
  "description": "CLI agent for running Voxli test scenarios locally",
5
5
  "type": "module",
6
6
  "bin": {