@voxli/cli 0.4.0 → 0.5.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/auth.d.ts +1 -0
- package/dist/commands/auth.js +7 -6
- package/dist/commands/listen.js +17 -3
- package/dist/lib/auth.d.ts +12 -0
- package/dist/lib/auth.js +73 -0
- package/dist/lib/browser-auth.js +1 -0
- package/dist/lib/config.d.ts +17 -1
- package/dist/lib/config.js +78 -15
- package/package.json +1 -1
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
|
@@ -18,7 +18,7 @@ async function promptForToken() {
|
|
|
18
18
|
rl.close();
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
-
async function validateAndSave(token, extra) {
|
|
21
|
+
async function validateAndSave(token, extra, opts) {
|
|
22
22
|
const label = extra ? "Access token" : "API key";
|
|
23
23
|
console.log("Validating...");
|
|
24
24
|
try {
|
|
@@ -37,12 +37,13 @@ async function validateAndSave(token, extra) {
|
|
|
37
37
|
// Network error or other — warn but still save
|
|
38
38
|
console.warn("Warning: could not validate token (network error). Saving anyway.");
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
const target = opts?.local ? "local" : "global";
|
|
41
|
+
const savedPath = await writeConfig({
|
|
41
42
|
accessToken: token,
|
|
42
43
|
refreshToken: extra?.refreshToken,
|
|
43
44
|
clientId: extra?.clientId,
|
|
44
|
-
});
|
|
45
|
-
console.log(`${label} saved to
|
|
45
|
+
}, { target });
|
|
46
|
+
console.log(`${label} saved to ${savedPath}`);
|
|
46
47
|
}
|
|
47
48
|
export async function authCommand(opts) {
|
|
48
49
|
if (!opts.manual) {
|
|
@@ -51,7 +52,7 @@ export async function authCommand(opts) {
|
|
|
51
52
|
await validateAndSave(result.accessToken, {
|
|
52
53
|
refreshToken: result.refreshToken,
|
|
53
54
|
clientId: result.clientId,
|
|
54
|
-
});
|
|
55
|
+
}, { local: opts.local });
|
|
55
56
|
return;
|
|
56
57
|
}
|
|
57
58
|
catch (err) {
|
|
@@ -61,5 +62,5 @@ export async function authCommand(opts) {
|
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
const token = await promptForToken();
|
|
64
|
-
await validateAndSave(token);
|
|
65
|
+
await validateAndSave(token, undefined, { local: opts.local });
|
|
65
66
|
}
|
package/dist/commands/listen.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
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
8
|
const isEnvToken = !!resolveApiKey();
|
|
8
|
-
let apiKey =
|
|
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
|
+
}
|
|
9
23
|
if (!apiKey) {
|
|
10
24
|
console.error("Error: No API key found. Set VOXLI_API_TOKEN or run `voxli auth`.");
|
|
11
25
|
process.exit(1);
|
|
@@ -22,7 +36,7 @@ export async function listenCommand(options) {
|
|
|
22
36
|
};
|
|
23
37
|
process.on("SIGINT", shutdown);
|
|
24
38
|
process.on("SIGTERM", shutdown);
|
|
25
|
-
console.log(`Listening as ${hostname}
|
|
39
|
+
console.log(`Listening as ${hostname} using credentials from ${credentialSource}`);
|
|
26
40
|
while (true) {
|
|
27
41
|
try {
|
|
28
42
|
const data = await register(apiKey, {
|
|
@@ -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/browser-auth.js
CHANGED
package/dist/lib/config.d.ts
CHANGED
|
@@ -1,10 +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>;
|
|
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>;
|
|
3
16
|
export declare function writeConfig(config: {
|
|
4
17
|
accessToken: string;
|
|
5
18
|
refreshToken?: string;
|
|
6
19
|
clientId?: string;
|
|
7
|
-
}
|
|
20
|
+
}, opts?: {
|
|
21
|
+
target?: "global" | "local";
|
|
22
|
+
configDir?: string;
|
|
23
|
+
}): Promise<string>;
|
|
8
24
|
export declare function resolveApiKey(): string | null;
|
|
9
25
|
export declare function resolveApiKeyAsync(): Promise<string | null>;
|
|
10
26
|
export declare function attemptTokenRefresh(): Promise<string | null>;
|
package/dist/lib/config.js
CHANGED
|
@@ -1,47 +1,110 @@
|
|
|
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
4
|
import { refreshAccessToken } from "./oauth.js";
|
|
5
5
|
import { getApiBaseUrl } from "./api.js";
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
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) {
|
|
9
36
|
try {
|
|
10
|
-
const raw = await readFile(
|
|
37
|
+
const raw = await readFile(configPath, "utf-8");
|
|
11
38
|
return JSON.parse(raw);
|
|
12
39
|
}
|
|
13
40
|
catch {
|
|
14
41
|
return null;
|
|
15
42
|
}
|
|
16
43
|
}
|
|
17
|
-
export async function
|
|
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");
|
|
18
75
|
const data = { accessToken: config.accessToken };
|
|
19
76
|
if (config.refreshToken)
|
|
20
77
|
data.refreshToken = config.refreshToken;
|
|
21
78
|
if (config.clientId)
|
|
22
79
|
data.clientId = config.clientId;
|
|
23
|
-
await mkdir(
|
|
24
|
-
await writeFile(
|
|
25
|
-
|
|
80
|
+
await mkdir(targetDir, { recursive: true });
|
|
81
|
+
await writeFile(targetPath, JSON.stringify(data, null, 2) + "\n", {
|
|
82
|
+
mode: 0o600,
|
|
83
|
+
});
|
|
84
|
+
await chmod(targetPath, 0o600);
|
|
85
|
+
return targetPath;
|
|
26
86
|
}
|
|
27
87
|
export function resolveApiKey() {
|
|
28
88
|
const envKey = process.env.VOXLI_API_TOKEN;
|
|
29
89
|
if (envKey)
|
|
30
90
|
return envKey;
|
|
31
|
-
// Caller should await readConfig() for the file-based key
|
|
32
91
|
return null;
|
|
33
92
|
}
|
|
34
93
|
export async function resolveApiKeyAsync() {
|
|
35
94
|
const envKey = resolveApiKey();
|
|
36
95
|
if (envKey)
|
|
37
96
|
return envKey;
|
|
38
|
-
const
|
|
97
|
+
const resolved = await resolveConfig();
|
|
98
|
+
const config = resolved?.config;
|
|
39
99
|
return config?.accessToken ?? config?.apiKey ?? null;
|
|
40
100
|
}
|
|
41
101
|
export async function attemptTokenRefresh() {
|
|
42
102
|
try {
|
|
43
|
-
const
|
|
44
|
-
if (!
|
|
103
|
+
const resolved = await resolveConfig();
|
|
104
|
+
if (!resolved)
|
|
105
|
+
return null;
|
|
106
|
+
const { config, configDir } = resolved;
|
|
107
|
+
if (!config.refreshToken || !config.clientId)
|
|
45
108
|
return null;
|
|
46
109
|
const baseUrl = getApiBaseUrl();
|
|
47
110
|
const result = await refreshAccessToken(baseUrl, config.refreshToken, config.clientId);
|
|
@@ -49,7 +112,7 @@ export async function attemptTokenRefresh() {
|
|
|
49
112
|
accessToken: result.accessToken,
|
|
50
113
|
refreshToken: result.refreshToken ?? config.refreshToken,
|
|
51
114
|
clientId: config.clientId,
|
|
52
|
-
});
|
|
115
|
+
}, { configDir });
|
|
53
116
|
return result.accessToken;
|
|
54
117
|
}
|
|
55
118
|
catch {
|