@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 +1 -0
- package/dist/commands/listen.d.ts +1 -0
- package/dist/commands/listen.js +7 -6
- package/dist/lib/auth.d.ts +12 -0
- package/dist/lib/auth.js +73 -0
- package/dist/lib/config.d.ts +1 -1
- package/dist/lib/config.js +31 -8
- package/dist/lib/hostname.d.ts +1 -0
- package/dist/lib/hostname.js +7 -0
- package/package.json +1 -1
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();
|
package/dist/commands/listen.js
CHANGED
|
@@ -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
|
|
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 ${
|
|
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:
|
|
44
|
-
unique_identifier:
|
|
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>;
|
package/dist/lib/auth.js
ADDED
|
@@ -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
|
+
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -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>;
|
package/dist/lib/config.js
CHANGED
|
@@ -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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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;
|
package/dist/lib/hostname.d.ts
CHANGED
package/dist/lib/hostname.js
CHANGED
|
@@ -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
|
+
}
|