@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 +1 -0
- package/dist/commands/listen.d.ts +1 -0
- package/dist/commands/listen.js +22 -6
- 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/dist/lib/oauth.d.ts +5 -0
- package/dist/lib/oauth.js +16 -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,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
|
|
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 ${
|
|
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:
|
|
44
|
-
unique_identifier:
|
|
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.");
|
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
|
+
}
|
package/dist/lib/oauth.d.ts
CHANGED
|
@@ -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");
|