@voxli/cli 0.5.1 → 0.6.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
@@ -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,9 +1,11 @@
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
+ import { getJwtExpiry } from "../lib/oauth.js";
6
7
  const POLL_INTERVAL = 5_000;
8
+ const REFRESH_BUFFER_SECONDS = 30 * 60;
7
9
  export async function listenCommand(options) {
8
10
  const isEnvToken = !!resolveApiKey();
9
11
  let apiKey = null;
@@ -24,7 +26,8 @@ export async function listenCommand(options) {
24
26
  console.error("Error: No API key found. Set VOXLI_API_TOKEN or run `voxli auth`.");
25
27
  process.exit(1);
26
28
  }
27
- const hostname = getStableHostname();
29
+ const displayName = options.name ?? getStableHostname();
30
+ const uniqueIdentifier = buildAgentIdentifier(options.name);
28
31
  const children = new Set();
29
32
  // Graceful shutdown
30
33
  const shutdown = () => {
@@ -36,12 +39,25 @@ export async function listenCommand(options) {
36
39
  };
37
40
  process.on("SIGINT", shutdown);
38
41
  process.on("SIGTERM", shutdown);
39
- console.log(`Listening as ${hostname} using credentials from ${credentialSource}`);
42
+ console.log(`Listening as ${displayName} (${uniqueIdentifier}) using credentials from ${credentialSource}`);
40
43
  while (true) {
41
44
  try {
45
+ // Proactively refresh if the token expires within the buffer window so
46
+ // newly-spawned subprocesses inherit a token that will outlast the test.
47
+ if (!isEnvToken) {
48
+ const exp = getJwtExpiry(apiKey);
49
+ const nowSec = Math.floor(Date.now() / 1000);
50
+ if (exp !== null && exp - nowSec < REFRESH_BUFFER_SECONDS) {
51
+ const newToken = await attemptTokenRefresh(apiKey);
52
+ if (newToken && newToken !== apiKey) {
53
+ apiKey = newToken;
54
+ console.log("Access token refreshed proactively.");
55
+ }
56
+ }
57
+ }
42
58
  const data = await register(apiKey, {
43
- name: hostname,
44
- unique_identifier: hostname,
59
+ name: displayName,
60
+ unique_identifier: uniqueIdentifier,
45
61
  });
46
62
  const testResultIds = data.test_result_ids ?? [];
47
63
  if (testResultIds.length > 0) {
@@ -86,7 +102,7 @@ export async function listenCommand(options) {
86
102
  process.exit(1);
87
103
  }
88
104
  console.log("Access token expired, attempting refresh...");
89
- const newToken = await attemptTokenRefresh();
105
+ const newToken = await attemptTokenRefresh(apiKey ?? undefined);
90
106
  if (newToken) {
91
107
  apiKey = newToken;
92
108
  console.log("Token refreshed successfully.");
@@ -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
+ }
@@ -13,6 +13,11 @@ export declare function refreshAccessToken(baseUrl: string, refreshToken: string
13
13
  accessToken: string;
14
14
  refreshToken?: string;
15
15
  }>;
16
+ /**
17
+ * Read the `exp` claim (seconds since epoch) from a JWT access token.
18
+ * Returns null if the token isn't a parseable JWT or has no `exp`.
19
+ */
20
+ export declare function getJwtExpiry(token: string): number | null;
16
21
  export declare function buildAuthorizeUrl(baseUrl: string, params: {
17
22
  clientId: string;
18
23
  redirectUri: string;
package/dist/lib/oauth.js CHANGED
@@ -66,6 +66,22 @@ export async function refreshAccessToken(baseUrl, refreshToken, clientId) {
66
66
  refreshToken: data.refresh_token,
67
67
  };
68
68
  }
69
+ /**
70
+ * Read the `exp` claim (seconds since epoch) from a JWT access token.
71
+ * Returns null if the token isn't a parseable JWT or has no `exp`.
72
+ */
73
+ export function getJwtExpiry(token) {
74
+ const parts = token.split(".");
75
+ if (parts.length !== 3)
76
+ return null;
77
+ try {
78
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
79
+ return typeof payload.exp === "number" ? payload.exp : null;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
69
85
  export function buildAuthorizeUrl(baseUrl, params) {
70
86
  const url = new URL("/oauth/authorize", baseUrl);
71
87
  url.searchParams.set("response_type", "code");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxli/cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "CLI agent for running Voxli test scenarios locally",
5
5
  "type": "module",
6
6
  "bin": {