@voxli/cli 0.3.2 → 0.5.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/README.md +14 -4
- package/dist/cli.js +1 -0
- package/dist/commands/auth.d.ts +1 -0
- package/dist/commands/auth.js +24 -15
- package/dist/commands/listen.js +37 -6
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.js +4 -1
- package/dist/lib/browser-auth.d.ts +3 -2
- package/dist/lib/browser-auth.js +40 -22
- package/dist/lib/config.d.ts +22 -1
- package/dist/lib/config.js +100 -14
- package/dist/lib/oauth.d.ts +21 -0
- package/dist/lib/oauth.js +78 -0
- package/dist/types.d.ts +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,13 +12,21 @@ Requires Node.js 18+.
|
|
|
12
12
|
|
|
13
13
|
## Setup
|
|
14
14
|
|
|
15
|
-
Authenticate with your Voxli
|
|
15
|
+
Authenticate with your Voxli account:
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
18
|
voxli auth
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
This
|
|
21
|
+
This opens your browser to `app.voxli.io` where you log in and approve access. An API key is created automatically and saved to `~/.voxli/config.json`.
|
|
22
|
+
|
|
23
|
+
To enter an API key manually instead:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
voxli auth --manual
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
You can also set the `VOXLI_API_TOKEN` environment variable instead.
|
|
22
30
|
|
|
23
31
|
## Usage
|
|
24
32
|
|
|
@@ -32,7 +40,7 @@ The CLI polls the Voxli API for pending test batches. When work arrives, it spaw
|
|
|
32
40
|
|
|
33
41
|
| Variable | Description |
|
|
34
42
|
|---|---|
|
|
35
|
-
| `
|
|
43
|
+
| `VOXLI_API_TOKEN` | Your API key |
|
|
36
44
|
| `TEST_RESULT_IDS` | JSON array of test result IDs to run |
|
|
37
45
|
| `RUN_ID` | The run ID (if part of a run) |
|
|
38
46
|
|
|
@@ -40,5 +48,7 @@ The CLI polls the Voxli API for pending test batches. When work arrives, it spaw
|
|
|
40
48
|
|
|
41
49
|
| Command | Description |
|
|
42
50
|
|---|---|
|
|
43
|
-
| `voxli auth` | Authenticate
|
|
51
|
+
| `voxli auth` | Authenticate via browser |
|
|
52
|
+
| `voxli auth --manual` | Authenticate by entering an API key manually |
|
|
44
53
|
| `voxli listen --command <cmd>` | Poll for pending test work and run it locally |
|
|
54
|
+
|
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")
|
package/dist/commands/auth.d.ts
CHANGED
package/dist/commands/auth.js
CHANGED
|
@@ -4,54 +4,63 @@ import { writeConfig } from "../lib/config.js";
|
|
|
4
4
|
import { register, ApiError } from "../lib/api.js";
|
|
5
5
|
import { getStableHostname } from "../lib/hostname.js";
|
|
6
6
|
import { browserAuth } from "../lib/browser-auth.js";
|
|
7
|
-
async function
|
|
7
|
+
async function promptForToken() {
|
|
8
8
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
9
9
|
try {
|
|
10
|
-
const
|
|
11
|
-
if (!
|
|
10
|
+
const token = await rl.question("Enter your Voxli API key: ");
|
|
11
|
+
if (!token.trim()) {
|
|
12
12
|
console.error("API key cannot be empty.");
|
|
13
13
|
process.exit(1);
|
|
14
14
|
}
|
|
15
|
-
return
|
|
15
|
+
return token.trim();
|
|
16
16
|
}
|
|
17
17
|
finally {
|
|
18
18
|
rl.close();
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
-
async function validateAndSave(
|
|
21
|
+
async function validateAndSave(token, extra, opts) {
|
|
22
|
+
const label = extra ? "Access token" : "API key";
|
|
22
23
|
console.log("Validating...");
|
|
23
24
|
try {
|
|
24
25
|
const hostname = getStableHostname();
|
|
25
|
-
await register(
|
|
26
|
+
await register(token, {
|
|
26
27
|
name: hostname,
|
|
27
28
|
unique_identifier: hostname,
|
|
28
29
|
});
|
|
29
|
-
console.log(
|
|
30
|
+
console.log(`${label} is valid.`);
|
|
30
31
|
}
|
|
31
32
|
catch (err) {
|
|
32
33
|
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
33
|
-
console.error(`Authentication failed (${err.status}). Check your
|
|
34
|
+
console.error(`Authentication failed (${err.status}). Check your ${label.toLowerCase()}.`);
|
|
34
35
|
process.exit(1);
|
|
35
36
|
}
|
|
36
37
|
// Network error or other — warn but still save
|
|
37
|
-
console.warn("Warning: could not validate
|
|
38
|
+
console.warn("Warning: could not validate token (network error). Saving anyway.");
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
const target = opts?.local ? "local" : "global";
|
|
41
|
+
const savedPath = await writeConfig({
|
|
42
|
+
accessToken: token,
|
|
43
|
+
refreshToken: extra?.refreshToken,
|
|
44
|
+
clientId: extra?.clientId,
|
|
45
|
+
}, { target });
|
|
46
|
+
console.log(`${label} saved to ${savedPath}`);
|
|
41
47
|
}
|
|
42
48
|
export async function authCommand(opts) {
|
|
43
49
|
if (!opts.manual) {
|
|
44
50
|
try {
|
|
45
51
|
const result = await browserAuth();
|
|
46
|
-
await validateAndSave(result.
|
|
52
|
+
await validateAndSave(result.accessToken, {
|
|
53
|
+
refreshToken: result.refreshToken,
|
|
54
|
+
clientId: result.clientId,
|
|
55
|
+
}, { local: opts.local });
|
|
47
56
|
return;
|
|
48
57
|
}
|
|
49
58
|
catch (err) {
|
|
50
59
|
const msg = err instanceof Error ? err.message : String(err);
|
|
51
60
|
console.log(`\nBrowser auth failed: ${msg}`);
|
|
52
|
-
console.log("Falling back to manual
|
|
61
|
+
console.log("Falling back to manual token entry.\n");
|
|
53
62
|
}
|
|
54
63
|
}
|
|
55
|
-
const
|
|
56
|
-
await validateAndSave(
|
|
64
|
+
const token = await promptForToken();
|
|
65
|
+
await validateAndSave(token, undefined, { local: opts.local });
|
|
57
66
|
}
|
package/dist/commands/listen.js
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
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
|
-
const
|
|
8
|
+
const isEnvToken = !!resolveApiKey();
|
|
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
|
+
}
|
|
8
23
|
if (!apiKey) {
|
|
9
|
-
console.error("Error: No API key found. Set
|
|
24
|
+
console.error("Error: No API key found. Set VOXLI_API_TOKEN or run `voxli auth`.");
|
|
10
25
|
process.exit(1);
|
|
11
26
|
}
|
|
12
27
|
const hostname = getStableHostname();
|
|
@@ -21,7 +36,7 @@ export async function listenCommand(options) {
|
|
|
21
36
|
};
|
|
22
37
|
process.on("SIGINT", shutdown);
|
|
23
38
|
process.on("SIGTERM", shutdown);
|
|
24
|
-
console.log(`Listening as ${hostname}
|
|
39
|
+
console.log(`Listening as ${hostname} using credentials from ${credentialSource}`);
|
|
25
40
|
while (true) {
|
|
26
41
|
try {
|
|
27
42
|
const data = await register(apiKey, {
|
|
@@ -35,7 +50,7 @@ export async function listenCommand(options) {
|
|
|
35
50
|
console.log(`Spawning subprocess for ${testResultIds.length} test(s) (${label})`);
|
|
36
51
|
const env = {
|
|
37
52
|
...process.env,
|
|
38
|
-
|
|
53
|
+
VOXLI_API_TOKEN: apiKey,
|
|
39
54
|
VOXLI_API_URL: process.env.VOXLI_API_URL,
|
|
40
55
|
VOXLI_APP_URL: process.env.VOXLI_APP_URL,
|
|
41
56
|
TEST_RESULT_IDS: JSON.stringify(testResultIds),
|
|
@@ -64,7 +79,23 @@ export async function listenCommand(options) {
|
|
|
64
79
|
}
|
|
65
80
|
}
|
|
66
81
|
catch (err) {
|
|
67
|
-
if (err instanceof ApiError
|
|
82
|
+
if (err instanceof ApiError &&
|
|
83
|
+
(err.status === 401 || err.status === 403)) {
|
|
84
|
+
if (isEnvToken) {
|
|
85
|
+
console.error(`Error: Authentication failed (${err.status}). Your VOXLI_API_TOKEN environment variable may be expired or invalid.`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
console.log("Access token expired, attempting refresh...");
|
|
89
|
+
const newToken = await attemptTokenRefresh();
|
|
90
|
+
if (newToken) {
|
|
91
|
+
apiKey = newToken;
|
|
92
|
+
console.log("Token refreshed successfully.");
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
console.error("Error: Could not refresh access token. Please re-authenticate with `voxli auth`.");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
else if (err instanceof ApiError) {
|
|
68
99
|
console.error(`Poll error: API ${err.status}`);
|
|
69
100
|
}
|
|
70
101
|
else {
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RegisterPayload, RegisterResponse } from "../types.js";
|
|
2
|
+
export declare function getApiBaseUrl(): string;
|
|
2
3
|
export declare function register(apiKey: string, payload: RegisterPayload): Promise<RegisterResponse>;
|
|
3
4
|
export declare class ApiError extends Error {
|
|
4
5
|
status: number;
|
package/dist/lib/api.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const DEFAULT_BASE_URL = "https://api.voxli.io";
|
|
2
|
-
function
|
|
2
|
+
export function getApiBaseUrl() {
|
|
3
3
|
return process.env.VOXLI_API_URL || DEFAULT_BASE_URL;
|
|
4
4
|
}
|
|
5
|
+
function getBaseUrl() {
|
|
6
|
+
return getApiBaseUrl();
|
|
7
|
+
}
|
|
5
8
|
export async function register(apiKey, payload) {
|
|
6
9
|
const url = `${getBaseUrl()}/agents/register`;
|
|
7
10
|
const res = await fetch(url, {
|
package/dist/lib/browser-auth.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
1
|
+
import { createServer, } from "node:http";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import { stdin, stdout } from "node:process";
|
|
6
|
-
import {
|
|
6
|
+
import { getApiBaseUrl } from "./api.js";
|
|
7
|
+
import { generatePkceChallenge, registerOAuthClient, exchangeCodeForToken, buildAuthorizeUrl, } from "./oauth.js";
|
|
7
8
|
const AUTH_TIMEOUT_MS = 120_000;
|
|
8
|
-
const DEFAULT_APP_URL = "https://app.voxli.io";
|
|
9
|
-
function getAppUrl() {
|
|
10
|
-
return process.env.VOXLI_APP_URL || DEFAULT_APP_URL;
|
|
11
|
-
}
|
|
12
9
|
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
13
10
|
<html>
|
|
14
11
|
<head><meta charset="utf-8"><title>Voxli CLI</title>
|
|
@@ -46,6 +43,7 @@ export async function browserAuth() {
|
|
|
46
43
|
finally {
|
|
47
44
|
rl.close();
|
|
48
45
|
}
|
|
46
|
+
const baseUrl = getApiBaseUrl();
|
|
49
47
|
return new Promise((resolve, reject) => {
|
|
50
48
|
const state = randomBytes(32).toString("hex");
|
|
51
49
|
const server = createServer((req, res) => {
|
|
@@ -55,15 +53,14 @@ export async function browserAuth() {
|
|
|
55
53
|
res.end("Not found");
|
|
56
54
|
return;
|
|
57
55
|
}
|
|
58
|
-
const
|
|
56
|
+
const returnedCode = url.searchParams.get("code");
|
|
59
57
|
const returnedState = url.searchParams.get("state");
|
|
60
|
-
const returnedUserId = url.searchParams.get("user_id");
|
|
61
58
|
if (returnedState !== state) {
|
|
62
59
|
res.writeHead(403, { "Content-Type": "text/html" });
|
|
63
60
|
res.end(ERROR_HTML);
|
|
64
61
|
return;
|
|
65
62
|
}
|
|
66
|
-
if (!
|
|
63
|
+
if (!returnedCode) {
|
|
67
64
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
68
65
|
res.end(ERROR_HTML);
|
|
69
66
|
return;
|
|
@@ -71,30 +68,51 @@ export async function browserAuth() {
|
|
|
71
68
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
72
69
|
res.end(SUCCESS_HTML);
|
|
73
70
|
cleanup();
|
|
74
|
-
|
|
71
|
+
exchangeCodeForToken(baseUrl, returnedCode, codeVerifier, clientId, redirectUri)
|
|
72
|
+
.then(({ accessToken, refreshToken }) => resolve({ accessToken, refreshToken, clientId }))
|
|
73
|
+
.catch(reject);
|
|
75
74
|
});
|
|
75
|
+
let codeVerifier;
|
|
76
|
+
let clientId;
|
|
77
|
+
let redirectUri;
|
|
76
78
|
const timeout = setTimeout(() => {
|
|
77
79
|
cleanup();
|
|
78
80
|
reject(new Error("Browser authentication timed out after 2 minutes."));
|
|
79
81
|
}, AUTH_TIMEOUT_MS);
|
|
80
82
|
function cleanup() {
|
|
81
83
|
clearTimeout(timeout);
|
|
84
|
+
server.closeAllConnections();
|
|
82
85
|
server.close();
|
|
83
86
|
}
|
|
84
|
-
server.listen(0, "127.0.0.1", () => {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
server.listen(0, "127.0.0.1", async () => {
|
|
88
|
+
try {
|
|
89
|
+
const addr = server.address();
|
|
90
|
+
if (!addr || typeof addr === "string") {
|
|
91
|
+
cleanup();
|
|
92
|
+
reject(new Error("Failed to start local server."));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const port = addr.port;
|
|
96
|
+
redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
97
|
+
const registration = await registerOAuthClient(baseUrl, redirectUri);
|
|
98
|
+
clientId = registration.clientId;
|
|
99
|
+
const pkce = generatePkceChallenge();
|
|
100
|
+
codeVerifier = pkce.codeVerifier;
|
|
101
|
+
const authUrl = buildAuthorizeUrl(baseUrl, {
|
|
102
|
+
clientId,
|
|
103
|
+
redirectUri,
|
|
104
|
+
codeChallenge: pkce.codeChallenge,
|
|
105
|
+
state,
|
|
106
|
+
});
|
|
107
|
+
console.log("Opening browser to authenticate...");
|
|
108
|
+
openBrowser(authUrl);
|
|
109
|
+
console.log("Waiting for authentication (timeout: 2 min)...");
|
|
110
|
+
console.log(`\nIf the browser didn't open, visit:\n ${authUrl}\n`);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
87
113
|
cleanup();
|
|
88
|
-
reject(
|
|
89
|
-
return;
|
|
114
|
+
reject(err);
|
|
90
115
|
}
|
|
91
|
-
const port = addr.port;
|
|
92
|
-
const hostname = encodeURIComponent(getStableHostname());
|
|
93
|
-
const authUrl = `${getAppUrl()}/cli-auth?port=${port}&state=${state}&hostname=${hostname}`;
|
|
94
|
-
console.log("Opening browser to authenticate...");
|
|
95
|
-
openBrowser(authUrl);
|
|
96
|
-
console.log(`Waiting for authentication (timeout: 2 min)...`);
|
|
97
|
-
console.log(`\nIf the browser didn't open, visit:\n ${authUrl}\n`);
|
|
98
116
|
});
|
|
99
117
|
});
|
|
100
118
|
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -1,5 +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>;
|
|
3
|
-
|
|
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>;
|
|
16
|
+
export declare function writeConfig(config: {
|
|
17
|
+
accessToken: string;
|
|
18
|
+
refreshToken?: string;
|
|
19
|
+
clientId?: string;
|
|
20
|
+
}, opts?: {
|
|
21
|
+
target?: "global" | "local";
|
|
22
|
+
configDir?: string;
|
|
23
|
+
}): Promise<string>;
|
|
4
24
|
export declare function resolveApiKey(): string | null;
|
|
5
25
|
export declare function resolveApiKeyAsync(): Promise<string | null>;
|
|
26
|
+
export declare function attemptTokenRefresh(): Promise<string | null>;
|
package/dist/lib/config.js
CHANGED
|
@@ -1,35 +1,121 @@
|
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { refreshAccessToken } from "./oauth.js";
|
|
5
|
+
import { getApiBaseUrl } from "./api.js";
|
|
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) {
|
|
7
36
|
try {
|
|
8
|
-
const raw = await readFile(
|
|
37
|
+
const raw = await readFile(configPath, "utf-8");
|
|
9
38
|
return JSON.parse(raw);
|
|
10
39
|
}
|
|
11
40
|
catch {
|
|
12
41
|
return null;
|
|
13
42
|
}
|
|
14
43
|
}
|
|
15
|
-
export async function
|
|
16
|
-
|
|
17
|
-
|
|
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");
|
|
75
|
+
const data = { accessToken: config.accessToken };
|
|
76
|
+
if (config.refreshToken)
|
|
77
|
+
data.refreshToken = config.refreshToken;
|
|
78
|
+
if (config.clientId)
|
|
79
|
+
data.clientId = config.clientId;
|
|
80
|
+
await mkdir(targetDir, { recursive: true });
|
|
81
|
+
await writeFile(targetPath, JSON.stringify(data, null, 2) + "\n", {
|
|
18
82
|
mode: 0o600,
|
|
19
83
|
});
|
|
20
|
-
await chmod(
|
|
84
|
+
await chmod(targetPath, 0o600);
|
|
85
|
+
return targetPath;
|
|
21
86
|
}
|
|
22
87
|
export function resolveApiKey() {
|
|
23
|
-
const envKey = process.env.
|
|
88
|
+
const envKey = process.env.VOXLI_API_TOKEN;
|
|
24
89
|
if (envKey)
|
|
25
90
|
return envKey;
|
|
26
|
-
// Caller should await readConfig() for the file-based key
|
|
27
91
|
return null;
|
|
28
92
|
}
|
|
29
93
|
export async function resolveApiKeyAsync() {
|
|
30
94
|
const envKey = resolveApiKey();
|
|
31
95
|
if (envKey)
|
|
32
96
|
return envKey;
|
|
33
|
-
const
|
|
34
|
-
|
|
97
|
+
const resolved = await resolveConfig();
|
|
98
|
+
const config = resolved?.config;
|
|
99
|
+
return config?.accessToken ?? config?.apiKey ?? null;
|
|
100
|
+
}
|
|
101
|
+
export async function attemptTokenRefresh() {
|
|
102
|
+
try {
|
|
103
|
+
const resolved = await resolveConfig();
|
|
104
|
+
if (!resolved)
|
|
105
|
+
return null;
|
|
106
|
+
const { config, configDir } = resolved;
|
|
107
|
+
if (!config.refreshToken || !config.clientId)
|
|
108
|
+
return null;
|
|
109
|
+
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;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
35
121
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare function generatePkceChallenge(): {
|
|
2
|
+
codeVerifier: string;
|
|
3
|
+
codeChallenge: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function registerOAuthClient(baseUrl: string, redirectUri: string): Promise<{
|
|
6
|
+
clientId: string;
|
|
7
|
+
}>;
|
|
8
|
+
export declare function exchangeCodeForToken(baseUrl: string, code: string, codeVerifier: string, clientId: string, redirectUri: string): Promise<{
|
|
9
|
+
accessToken: string;
|
|
10
|
+
refreshToken?: string;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function refreshAccessToken(baseUrl: string, refreshToken: string, clientId: string): Promise<{
|
|
13
|
+
accessToken: string;
|
|
14
|
+
refreshToken?: string;
|
|
15
|
+
}>;
|
|
16
|
+
export declare function buildAuthorizeUrl(baseUrl: string, params: {
|
|
17
|
+
clientId: string;
|
|
18
|
+
redirectUri: string;
|
|
19
|
+
codeChallenge: string;
|
|
20
|
+
state: string;
|
|
21
|
+
}): string;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
2
|
+
export function generatePkceChallenge() {
|
|
3
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
4
|
+
const codeChallenge = createHash("sha256")
|
|
5
|
+
.update(codeVerifier)
|
|
6
|
+
.digest("base64url");
|
|
7
|
+
return { codeVerifier, codeChallenge };
|
|
8
|
+
}
|
|
9
|
+
export async function registerOAuthClient(baseUrl, redirectUri) {
|
|
10
|
+
const res = await fetch(`${baseUrl}/oauth/register`, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "Content-Type": "application/json" },
|
|
13
|
+
body: JSON.stringify({
|
|
14
|
+
client_name: "Voxli CLI",
|
|
15
|
+
redirect_uris: [redirectUri],
|
|
16
|
+
grant_types: ["authorization_code"],
|
|
17
|
+
response_types: ["code"],
|
|
18
|
+
token_endpoint_auth_method: "none",
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
throw new Error(`OAuth client registration failed (${res.status})`);
|
|
23
|
+
}
|
|
24
|
+
const data = (await res.json());
|
|
25
|
+
return { clientId: data.client_id };
|
|
26
|
+
}
|
|
27
|
+
export async function exchangeCodeForToken(baseUrl, code, codeVerifier, clientId, redirectUri) {
|
|
28
|
+
const body = new URLSearchParams({
|
|
29
|
+
grant_type: "authorization_code",
|
|
30
|
+
code,
|
|
31
|
+
code_verifier: codeVerifier,
|
|
32
|
+
client_id: clientId,
|
|
33
|
+
redirect_uri: redirectUri,
|
|
34
|
+
});
|
|
35
|
+
const res = await fetch(`${baseUrl}/oauth/token`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
38
|
+
body: body.toString(),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
throw new Error(`Token exchange failed (${res.status})`);
|
|
42
|
+
}
|
|
43
|
+
const data = (await res.json());
|
|
44
|
+
return {
|
|
45
|
+
accessToken: data.access_token,
|
|
46
|
+
refreshToken: data.refresh_token,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export async function refreshAccessToken(baseUrl, refreshToken, clientId) {
|
|
50
|
+
const body = new URLSearchParams({
|
|
51
|
+
grant_type: "refresh_token",
|
|
52
|
+
refresh_token: refreshToken,
|
|
53
|
+
client_id: clientId,
|
|
54
|
+
});
|
|
55
|
+
const res = await fetch(`${baseUrl}/oauth/token`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
58
|
+
body: body.toString(),
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
throw new Error(`Token refresh failed (${res.status})`);
|
|
62
|
+
}
|
|
63
|
+
const data = (await res.json());
|
|
64
|
+
return {
|
|
65
|
+
accessToken: data.access_token,
|
|
66
|
+
refreshToken: data.refresh_token,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function buildAuthorizeUrl(baseUrl, params) {
|
|
70
|
+
const url = new URL("/oauth/authorize", baseUrl);
|
|
71
|
+
url.searchParams.set("response_type", "code");
|
|
72
|
+
url.searchParams.set("client_id", params.clientId);
|
|
73
|
+
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
74
|
+
url.searchParams.set("code_challenge", params.codeChallenge);
|
|
75
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
76
|
+
url.searchParams.set("state", params.state);
|
|
77
|
+
return url.toString();
|
|
78
|
+
}
|
package/dist/types.d.ts
CHANGED