@voxli/cli 0.5.0 → 0.6.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/dist/cli.js CHANGED
@@ -20,5 +20,6 @@ program
20
20
  .command("listen")
21
21
  .description("Poll for pending test work and run it locally")
22
22
  .requiredOption("--command <cmd>", "Shell command to run per batch")
23
+ .option("--name <name>", "Display name for this agent (defaults to hostname)")
23
24
  .action(listenCommand);
24
25
  program.parse();
@@ -1,3 +1,4 @@
1
1
  export declare function listenCommand(options: {
2
2
  command: string;
3
+ name?: string;
3
4
  }): Promise<void>;
@@ -1,7 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { spawn } from "node:child_process";
3
3
  import { resolveApiKey, resolveConfig, attemptTokenRefresh, } from "../lib/config.js";
4
- import { getStableHostname } from "../lib/hostname.js";
4
+ import { buildAgentIdentifier, getStableHostname } from "../lib/hostname.js";
5
5
  import { register, ApiError } from "../lib/api.js";
6
6
  const POLL_INTERVAL = 5_000;
7
7
  export async function listenCommand(options) {
@@ -24,7 +24,8 @@ export async function listenCommand(options) {
24
24
  console.error("Error: No API key found. Set VOXLI_API_TOKEN or run `voxli auth`.");
25
25
  process.exit(1);
26
26
  }
27
- const hostname = getStableHostname();
27
+ const displayName = options.name ?? getStableHostname();
28
+ const uniqueIdentifier = buildAgentIdentifier(options.name);
28
29
  const children = new Set();
29
30
  // Graceful shutdown
30
31
  const shutdown = () => {
@@ -36,12 +37,12 @@ export async function listenCommand(options) {
36
37
  };
37
38
  process.on("SIGINT", shutdown);
38
39
  process.on("SIGTERM", shutdown);
39
- console.log(`Listening as ${hostname} using credentials from ${credentialSource}`);
40
+ console.log(`Listening as ${displayName} (${uniqueIdentifier}) using credentials from ${credentialSource}`);
40
41
  while (true) {
41
42
  try {
42
43
  const data = await register(apiKey, {
43
- name: hostname,
44
- unique_identifier: hostname,
44
+ name: displayName,
45
+ unique_identifier: uniqueIdentifier,
45
46
  });
46
47
  const testResultIds = data.test_result_ids ?? [];
47
48
  if (testResultIds.length > 0) {
@@ -86,7 +87,7 @@ export async function listenCommand(options) {
86
87
  process.exit(1);
87
88
  }
88
89
  console.log("Access token expired, attempting refresh...");
89
- const newToken = await attemptTokenRefresh();
90
+ const newToken = await attemptTokenRefresh(apiKey ?? undefined);
90
91
  if (newToken) {
91
92
  apiKey = newToken;
92
93
  console.log("Token refreshed successfully.");
@@ -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
+ }
@@ -23,4 +23,4 @@ export declare function writeConfig(config: {
23
23
  }): Promise<string>;
24
24
  export declare function resolveApiKey(): string | null;
25
25
  export declare function resolveApiKeyAsync(): Promise<string | null>;
26
- export declare function attemptTokenRefresh(): Promise<string | null>;
26
+ export declare function attemptTokenRefresh(expiredToken?: string): Promise<string | null>;
@@ -98,22 +98,45 @@ export async function resolveApiKeyAsync() {
98
98
  const config = resolved?.config;
99
99
  return config?.accessToken ?? config?.apiKey ?? null;
100
100
  }
101
- export async function attemptTokenRefresh() {
101
+ export async function attemptTokenRefresh(expiredToken) {
102
102
  try {
103
103
  const resolved = await resolveConfig();
104
104
  if (!resolved)
105
105
  return null;
106
106
  const { config, configDir } = resolved;
107
+ // Another listener may have already refreshed — adopt that token.
108
+ if (expiredToken &&
109
+ config.accessToken &&
110
+ config.accessToken !== expiredToken) {
111
+ return config.accessToken;
112
+ }
107
113
  if (!config.refreshToken || !config.clientId)
108
114
  return null;
109
115
  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;
116
+ try {
117
+ const result = await refreshAccessToken(baseUrl, config.refreshToken, config.clientId);
118
+ await writeConfig({
119
+ accessToken: result.accessToken,
120
+ refreshToken: result.refreshToken ?? config.refreshToken,
121
+ clientId: config.clientId,
122
+ }, { configDir });
123
+ return result.accessToken;
124
+ }
125
+ catch {
126
+ // Refresh failed (likely because a sibling listener already used the
127
+ // refresh token). Retry-read the config briefly in case the winner is
128
+ // still flushing its write to disk.
129
+ if (!expiredToken)
130
+ return null;
131
+ for (let i = 0; i < 5; i++) {
132
+ await new Promise((r) => setTimeout(r, 100));
133
+ const recheck = await resolveConfig();
134
+ const fresh = recheck?.config.accessToken;
135
+ if (fresh && fresh !== expiredToken)
136
+ return fresh;
137
+ }
138
+ return null;
139
+ }
117
140
  }
118
141
  catch {
119
142
  return null;
@@ -1 +1,2 @@
1
1
  export declare function getStableHostname(): string;
2
+ export declare function buildAgentIdentifier(name: string | undefined): string;
@@ -14,3 +14,10 @@ export function getStableHostname() {
14
14
  }
15
15
  return hostname();
16
16
  }
17
+ export function buildAgentIdentifier(name) {
18
+ const host = getStableHostname();
19
+ if (!name)
20
+ return host;
21
+ const trimmed = name.replace(/\s+/g, "");
22
+ return trimmed ? `${trimmed}-${host}` : host;
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxli/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "CLI agent for running Voxli test scenarios locally",
5
5
  "type": "module",
6
6
  "bin": {