@voxli/cli 0.1.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 +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +19 -0
- package/dist/commands/auth.d.ts +1 -0
- package/dist/commands/auth.js +39 -0
- package/dist/commands/listen.d.ts +3 -0
- package/dist/commands/listen.js +76 -0
- package/dist/lib/api.d.ts +7 -0
- package/dist/lib/api.js +29 -0
- package/dist/lib/config.d.ts +5 -0
- package/dist/lib/config.js +35 -0
- package/dist/lib/hostname.d.ts +1 -0
- package/dist/lib/hostname.js +16 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.js +1 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# voxli
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { authCommand } from "./commands/auth.js";
|
|
4
|
+
import { listenCommand } from "./commands/listen.js";
|
|
5
|
+
const program = new Command();
|
|
6
|
+
program
|
|
7
|
+
.name("voxli")
|
|
8
|
+
.description("CLI agent for running Voxli test scenarios locally")
|
|
9
|
+
.version("0.1.0");
|
|
10
|
+
program
|
|
11
|
+
.command("auth")
|
|
12
|
+
.description("Authenticate with your Voxli API key")
|
|
13
|
+
.action(authCommand);
|
|
14
|
+
program
|
|
15
|
+
.command("listen")
|
|
16
|
+
.description("Poll for pending test work and run it locally")
|
|
17
|
+
.requiredOption("--command <cmd>", "Shell command to run per batch")
|
|
18
|
+
.action(listenCommand);
|
|
19
|
+
program.parse();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function authCommand(): Promise<void>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
import { writeConfig } from "../lib/config.js";
|
|
4
|
+
import { register, ApiError } from "../lib/api.js";
|
|
5
|
+
import { getStableHostname } from "../lib/hostname.js";
|
|
6
|
+
export async function authCommand() {
|
|
7
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
8
|
+
try {
|
|
9
|
+
const apiKey = await rl.question("Enter your Voxli API key: ");
|
|
10
|
+
if (!apiKey.trim()) {
|
|
11
|
+
console.error("API key cannot be empty.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const key = apiKey.trim();
|
|
15
|
+
// Validate by calling /agents/register
|
|
16
|
+
console.log("Validating...");
|
|
17
|
+
try {
|
|
18
|
+
const hostname = getStableHostname();
|
|
19
|
+
await register(key, {
|
|
20
|
+
name: hostname,
|
|
21
|
+
unique_identifier: hostname,
|
|
22
|
+
});
|
|
23
|
+
console.log("API key is valid.");
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
27
|
+
console.error(`Authentication failed (${err.status}). Check your API key.`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
// Network error or other — warn but still save
|
|
31
|
+
console.warn("Warning: could not validate key (network error). Saving anyway.");
|
|
32
|
+
}
|
|
33
|
+
await writeConfig({ apiKey: key });
|
|
34
|
+
console.log("API key saved to ~/.voxli/config.json");
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
rl.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { resolveApiKeyAsync } from "../lib/config.js";
|
|
3
|
+
import { getStableHostname } from "../lib/hostname.js";
|
|
4
|
+
import { register, ApiError } from "../lib/api.js";
|
|
5
|
+
const POLL_INTERVAL = 5_000;
|
|
6
|
+
export async function listenCommand(options) {
|
|
7
|
+
const apiKey = await resolveApiKeyAsync();
|
|
8
|
+
if (!apiKey) {
|
|
9
|
+
console.error("Error: No API key found. Set VOXLI_API_KEY or run `voxli auth`.");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
const hostname = getStableHostname();
|
|
13
|
+
const children = new Set();
|
|
14
|
+
// Graceful shutdown
|
|
15
|
+
const shutdown = () => {
|
|
16
|
+
console.log("\nShutting down...");
|
|
17
|
+
for (const child of children) {
|
|
18
|
+
child.kill("SIGTERM");
|
|
19
|
+
}
|
|
20
|
+
process.exit(0);
|
|
21
|
+
};
|
|
22
|
+
process.on("SIGINT", shutdown);
|
|
23
|
+
process.on("SIGTERM", shutdown);
|
|
24
|
+
console.log(`Listening as ${hostname}...`);
|
|
25
|
+
while (true) {
|
|
26
|
+
try {
|
|
27
|
+
const data = await register(apiKey, {
|
|
28
|
+
name: hostname,
|
|
29
|
+
unique_identifier: hostname,
|
|
30
|
+
});
|
|
31
|
+
const testResultIds = data.test_result_ids ?? [];
|
|
32
|
+
if (testResultIds.length > 0) {
|
|
33
|
+
const runId = data.run_id;
|
|
34
|
+
const label = runId ? `run ${runId}` : "standalone";
|
|
35
|
+
console.log(`Spawning subprocess for ${testResultIds.length} test(s) (${label})`);
|
|
36
|
+
const env = {
|
|
37
|
+
...process.env,
|
|
38
|
+
TEST_RESULT_IDS: JSON.stringify(testResultIds),
|
|
39
|
+
};
|
|
40
|
+
if (runId) {
|
|
41
|
+
env.RUN_ID = runId;
|
|
42
|
+
}
|
|
43
|
+
const child = spawn(options.command, {
|
|
44
|
+
stdio: "inherit",
|
|
45
|
+
env,
|
|
46
|
+
shell: true,
|
|
47
|
+
});
|
|
48
|
+
children.add(child);
|
|
49
|
+
child.on("error", (err) => {
|
|
50
|
+
children.delete(child);
|
|
51
|
+
console.error(`Subprocess (${label}) failed to start: ${err.message}`);
|
|
52
|
+
});
|
|
53
|
+
child.on("close", (code) => {
|
|
54
|
+
children.delete(child);
|
|
55
|
+
if (code !== 0 && code !== null) {
|
|
56
|
+
console.log(`Subprocess (${label}) exited with code ${code}`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
// Don't sleep when work was received — poll again immediately
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (err instanceof ApiError) {
|
|
65
|
+
console.error(`Poll error: API ${err.status}`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
console.error(`Poll error: ${err}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
await sleep(POLL_INTERVAL);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function sleep(ms) {
|
|
75
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RegisterPayload, RegisterResponse } from "../types.js";
|
|
2
|
+
export declare function register(apiKey: string, payload: RegisterPayload): Promise<RegisterResponse>;
|
|
3
|
+
export declare class ApiError extends Error {
|
|
4
|
+
status: number;
|
|
5
|
+
body: string;
|
|
6
|
+
constructor(status: number, body: string);
|
|
7
|
+
}
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const DEFAULT_BASE_URL = "https://api.voxli.io";
|
|
2
|
+
function getBaseUrl() {
|
|
3
|
+
return process.env.VOXLI_API_URL || DEFAULT_BASE_URL;
|
|
4
|
+
}
|
|
5
|
+
export async function register(apiKey, payload) {
|
|
6
|
+
const url = `${getBaseUrl()}/agents/register`;
|
|
7
|
+
const res = await fetch(url, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: {
|
|
10
|
+
Authorization: `Bearer ${apiKey}`,
|
|
11
|
+
"Content-Type": "application/json",
|
|
12
|
+
},
|
|
13
|
+
body: JSON.stringify(payload),
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
throw new ApiError(res.status, await res.text());
|
|
17
|
+
}
|
|
18
|
+
return (await res.json());
|
|
19
|
+
}
|
|
20
|
+
export class ApiError extends Error {
|
|
21
|
+
status;
|
|
22
|
+
body;
|
|
23
|
+
constructor(status, body) {
|
|
24
|
+
super(`API error ${status}: ${body}`);
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.body = body;
|
|
27
|
+
this.name = "ApiError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { VoxliConfig } from "../types.js";
|
|
2
|
+
export declare function readConfig(): Promise<VoxliConfig | null>;
|
|
3
|
+
export declare function writeConfig(config: VoxliConfig): Promise<void>;
|
|
4
|
+
export declare function resolveApiKey(): string | null;
|
|
5
|
+
export declare function resolveApiKeyAsync(): Promise<string | null>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
const CONFIG_DIR = join(homedir(), ".voxli");
|
|
5
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
6
|
+
export async function readConfig() {
|
|
7
|
+
try {
|
|
8
|
+
const raw = await readFile(CONFIG_PATH, "utf-8");
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function writeConfig(config) {
|
|
16
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
17
|
+
await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", {
|
|
18
|
+
mode: 0o600,
|
|
19
|
+
});
|
|
20
|
+
await chmod(CONFIG_PATH, 0o600);
|
|
21
|
+
}
|
|
22
|
+
export function resolveApiKey() {
|
|
23
|
+
const envKey = process.env.VOXLI_API_KEY;
|
|
24
|
+
if (envKey)
|
|
25
|
+
return envKey;
|
|
26
|
+
// Caller should await readConfig() for the file-based key
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export async function resolveApiKeyAsync() {
|
|
30
|
+
const envKey = resolveApiKey();
|
|
31
|
+
if (envKey)
|
|
32
|
+
return envKey;
|
|
33
|
+
const config = await readConfig();
|
|
34
|
+
return config?.apiKey ?? null;
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getStableHostname(): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
export function getStableHostname() {
|
|
4
|
+
if (process.platform === "darwin") {
|
|
5
|
+
try {
|
|
6
|
+
const result = execFileSync("scutil", ["--get", "LocalHostName"], {
|
|
7
|
+
encoding: "utf-8",
|
|
8
|
+
});
|
|
9
|
+
return result.trim();
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// fall through to os.hostname()
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return hostname();
|
|
16
|
+
}
|
package/dist/types.d.ts
ADDED
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voxli/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI agent for running Voxli test scenarios locally",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"voxli": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsc --watch"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"commander": "^13.1.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.0.0",
|
|
24
|
+
"typescript": "^5.7.0"
|
|
25
|
+
}
|
|
26
|
+
}
|