@voxli/cli 0.4.0 → 0.5.1

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/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>;
@@ -18,7 +18,7 @@ async function promptForToken() {
18
18
  rl.close();
19
19
  }
20
20
  }
21
- async function validateAndSave(token, extra) {
21
+ async function validateAndSave(token, extra, opts) {
22
22
  const label = extra ? "Access token" : "API key";
23
23
  console.log("Validating...");
24
24
  try {
@@ -37,12 +37,13 @@ async function validateAndSave(token, extra) {
37
37
  // Network error or other — warn but still save
38
38
  console.warn("Warning: could not validate token (network error). Saving anyway.");
39
39
  }
40
- await writeConfig({
40
+ const target = opts?.local ? "local" : "global";
41
+ const savedPath = await writeConfig({
41
42
  accessToken: token,
42
43
  refreshToken: extra?.refreshToken,
43
44
  clientId: extra?.clientId,
44
- });
45
- console.log(`${label} saved to ~/.voxli/config.json`);
45
+ }, { target });
46
+ console.log(`${label} saved to ${savedPath}`);
46
47
  }
47
48
  export async function authCommand(opts) {
48
49
  if (!opts.manual) {
@@ -51,7 +52,7 @@ export async function authCommand(opts) {
51
52
  await validateAndSave(result.accessToken, {
52
53
  refreshToken: result.refreshToken,
53
54
  clientId: result.clientId,
54
- });
55
+ }, { local: opts.local });
55
56
  return;
56
57
  }
57
58
  catch (err) {
@@ -61,5 +62,5 @@ export async function authCommand(opts) {
61
62
  }
62
63
  }
63
64
  const token = await promptForToken();
64
- await validateAndSave(token);
65
+ await validateAndSave(token, undefined, { local: opts.local });
65
66
  }
@@ -1,11 +1,25 @@
1
+ import { join } from "node:path";
1
2
  import { spawn } from "node:child_process";
2
- import { resolveApiKeyAsync, resolveApiKey, attemptTokenRefresh, } 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
8
  const isEnvToken = !!resolveApiKey();
8
- let apiKey = await resolveApiKeyAsync();
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
+ }
9
23
  if (!apiKey) {
10
24
  console.error("Error: No API key found. Set VOXLI_API_TOKEN or run `voxli auth`.");
11
25
  process.exit(1);
@@ -22,7 +36,7 @@ export async function listenCommand(options) {
22
36
  };
23
37
  process.on("SIGINT", shutdown);
24
38
  process.on("SIGTERM", shutdown);
25
- console.log(`Listening as ${hostname}...`);
39
+ console.log(`Listening as ${hostname} using credentials from ${credentialSource}`);
26
40
  while (true) {
27
41
  try {
28
42
  const data = await register(apiKey, {
@@ -0,0 +1,12 @@
1
+ export interface Credentials {
2
+ apiKey: string;
3
+ source: string;
4
+ isEnvToken: boolean;
5
+ }
6
+ export declare function isTokenExpiringSoon(token: string): boolean;
7
+ export declare function resolveCredentials(): Promise<Credentials>;
8
+ /**
9
+ * Wraps an API call with automatic token refresh on 401/403.
10
+ * Proactively refreshes the token if it expires within 15 minutes.
11
+ */
12
+ export declare function withAuth<T>(fn: (apiKey: string) => Promise<T>): Promise<T>;
@@ -0,0 +1,73 @@
1
+ import { join } from "node:path";
2
+ import { resolveApiKey, resolveConfig, attemptTokenRefresh, } from "./config.js";
3
+ import { ApiError } from "./api.js";
4
+ const REFRESH_THRESHOLD_SECONDS = 15 * 60;
5
+ export function isTokenExpiringSoon(token) {
6
+ try {
7
+ const parts = token.split(".");
8
+ if (parts.length !== 3)
9
+ return false;
10
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
11
+ if (!payload.exp)
12
+ return false;
13
+ return payload.exp - Date.now() / 1000 < REFRESH_THRESHOLD_SECONDS;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ export async function resolveCredentials() {
20
+ const isEnvToken = !!resolveApiKey();
21
+ if (isEnvToken) {
22
+ const apiKey = resolveApiKey();
23
+ if (apiKey) {
24
+ return { apiKey, source: "VOXLI_API_TOKEN", isEnvToken: true };
25
+ }
26
+ }
27
+ const resolved = await resolveConfig();
28
+ if (resolved) {
29
+ const { config, configDir } = resolved;
30
+ const apiKey = config.accessToken ?? config.apiKey ?? null;
31
+ if (apiKey) {
32
+ return {
33
+ apiKey,
34
+ source: join(configDir, "config.json"),
35
+ isEnvToken: false,
36
+ };
37
+ }
38
+ }
39
+ console.error("Error: No API key found. Set VOXLI_API_TOKEN or run `voxli auth`.");
40
+ process.exit(1);
41
+ }
42
+ /**
43
+ * Wraps an API call with automatic token refresh on 401/403.
44
+ * Proactively refreshes the token if it expires within 15 minutes.
45
+ */
46
+ export async function withAuth(fn) {
47
+ let credentials = await resolveCredentials();
48
+ if (!credentials.isEnvToken && isTokenExpiringSoon(credentials.apiKey)) {
49
+ const newToken = await attemptTokenRefresh();
50
+ if (newToken) {
51
+ credentials = { ...credentials, apiKey: newToken };
52
+ }
53
+ }
54
+ try {
55
+ return await fn(credentials.apiKey);
56
+ }
57
+ catch (err) {
58
+ if (!(err instanceof ApiError))
59
+ throw err;
60
+ if (err.status !== 401 && err.status !== 403)
61
+ throw err;
62
+ if (credentials.isEnvToken) {
63
+ console.error(`Error: Authentication failed (${err.status}). Your VOXLI_API_TOKEN may be expired or invalid.`);
64
+ process.exit(1);
65
+ }
66
+ const newToken = await attemptTokenRefresh();
67
+ if (newToken) {
68
+ return await fn(newToken);
69
+ }
70
+ console.error("Error: Could not refresh access token. Please re-authenticate with `voxli auth`.");
71
+ process.exit(1);
72
+ }
73
+ }
@@ -81,6 +81,7 @@ export async function browserAuth() {
81
81
  }, AUTH_TIMEOUT_MS);
82
82
  function cleanup() {
83
83
  clearTimeout(timeout);
84
+ server.closeAllConnections();
84
85
  server.close();
85
86
  }
86
87
  server.listen(0, "127.0.0.1", async () => {
@@ -1,10 +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>;
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>;
3
16
  export declare function writeConfig(config: {
4
17
  accessToken: string;
5
18
  refreshToken?: string;
6
19
  clientId?: string;
7
- }): Promise<void>;
20
+ }, opts?: {
21
+ target?: "global" | "local";
22
+ configDir?: string;
23
+ }): Promise<string>;
8
24
  export declare function resolveApiKey(): string | null;
9
25
  export declare function resolveApiKeyAsync(): Promise<string | null>;
10
26
  export declare function attemptTokenRefresh(): Promise<string | null>;
@@ -1,47 +1,110 @@
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
4
  import { refreshAccessToken } from "./oauth.js";
5
5
  import { getApiBaseUrl } from "./api.js";
6
- const CONFIG_DIR = join(homedir(), ".voxli");
7
- const CONFIG_PATH = join(CONFIG_DIR, "config.json");
8
- export async function readConfig() {
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) {
9
36
  try {
10
- const raw = await readFile(CONFIG_PATH, "utf-8");
37
+ const raw = await readFile(configPath, "utf-8");
11
38
  return JSON.parse(raw);
12
39
  }
13
40
  catch {
14
41
  return null;
15
42
  }
16
43
  }
17
- export async function writeConfig(config) {
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");
18
75
  const data = { accessToken: config.accessToken };
19
76
  if (config.refreshToken)
20
77
  data.refreshToken = config.refreshToken;
21
78
  if (config.clientId)
22
79
  data.clientId = config.clientId;
23
- await mkdir(CONFIG_DIR, { recursive: true });
24
- await writeFile(CONFIG_PATH, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
25
- await chmod(CONFIG_PATH, 0o600);
80
+ await mkdir(targetDir, { recursive: true });
81
+ await writeFile(targetPath, JSON.stringify(data, null, 2) + "\n", {
82
+ mode: 0o600,
83
+ });
84
+ await chmod(targetPath, 0o600);
85
+ return targetPath;
26
86
  }
27
87
  export function resolveApiKey() {
28
88
  const envKey = process.env.VOXLI_API_TOKEN;
29
89
  if (envKey)
30
90
  return envKey;
31
- // Caller should await readConfig() for the file-based key
32
91
  return null;
33
92
  }
34
93
  export async function resolveApiKeyAsync() {
35
94
  const envKey = resolveApiKey();
36
95
  if (envKey)
37
96
  return envKey;
38
- const config = await readConfig();
97
+ const resolved = await resolveConfig();
98
+ const config = resolved?.config;
39
99
  return config?.accessToken ?? config?.apiKey ?? null;
40
100
  }
41
101
  export async function attemptTokenRefresh() {
42
102
  try {
43
- const config = await readConfig();
44
- if (!config?.refreshToken || !config?.clientId)
103
+ const resolved = await resolveConfig();
104
+ if (!resolved)
105
+ return null;
106
+ const { config, configDir } = resolved;
107
+ if (!config.refreshToken || !config.clientId)
45
108
  return null;
46
109
  const baseUrl = getApiBaseUrl();
47
110
  const result = await refreshAccessToken(baseUrl, config.refreshToken, config.clientId);
@@ -49,7 +112,7 @@ export async function attemptTokenRefresh() {
49
112
  accessToken: result.accessToken,
50
113
  refreshToken: result.refreshToken ?? config.refreshToken,
51
114
  clientId: config.clientId,
52
- });
115
+ }, { configDir });
53
116
  return result.accessToken;
54
117
  }
55
118
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxli/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "CLI agent for running Voxli test scenarios locally",
5
5
  "type": "module",
6
6
  "bin": {