speqs 0.4.0 → 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/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +72 -0
- package/dist/commands/iteration.d.ts +5 -0
- package/dist/commands/iteration.js +82 -0
- package/dist/commands/simulation.d.ts +10 -0
- package/dist/commands/simulation.js +306 -0
- package/dist/commands/study.d.ts +5 -0
- package/dist/commands/study.js +131 -0
- package/dist/commands/tester-profile.d.ts +5 -0
- package/dist/commands/tester-profile.js +95 -0
- package/dist/commands/tester.d.ts +5 -0
- package/dist/commands/tester.js +63 -0
- package/dist/commands/workspace.d.ts +5 -0
- package/dist/commands/workspace.js +86 -0
- package/dist/index.js +32 -9
- package/dist/lib/api-client.d.ts +27 -0
- package/dist/lib/api-client.js +121 -0
- package/dist/lib/auth.d.ts +8 -0
- package/dist/lib/auth.js +70 -0
- package/dist/lib/command-helpers.d.ts +24 -0
- package/dist/lib/command-helpers.js +94 -0
- package/dist/lib/output.d.ts +19 -0
- package/dist/lib/output.js +454 -0
- package/dist/lib/types.d.ts +202 -0
- package/dist/lib/types.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speqs tester-profile — Manage tester profiles.
|
|
3
|
+
*/
|
|
4
|
+
import { withClient, readJsonFileOrStdin } from "../lib/command-helpers.js";
|
|
5
|
+
import { formatTesterProfileList, output } from "../lib/output.js";
|
|
6
|
+
export function registerTesterProfileCommands(program) {
|
|
7
|
+
const profile = program
|
|
8
|
+
.command("tester-profile")
|
|
9
|
+
.description("Manage tester profiles");
|
|
10
|
+
profile
|
|
11
|
+
.command("list")
|
|
12
|
+
.description("List tester profiles (defaults to simulatable AI profiles)")
|
|
13
|
+
.option("--workspace <id>", "Filter by workspace ID")
|
|
14
|
+
.option("--search <query>", "Search by name or bio")
|
|
15
|
+
.option("--type <type>", "Profile type: ai, human, all (default: ai)", "ai")
|
|
16
|
+
.option("--gender <gender>", "Filter by gender")
|
|
17
|
+
.option("--country <country>", "Filter by country code (e.g. US, GB, SE)")
|
|
18
|
+
.option("--min-age <n>", "Minimum age")
|
|
19
|
+
.option("--max-age <n>", "Maximum age")
|
|
20
|
+
.option("--limit <n>", "Max results (default 50)", "50")
|
|
21
|
+
.option("--offset <n>", "Offset for pagination", "0")
|
|
22
|
+
.addHelpText("after", `
|
|
23
|
+
Examples:
|
|
24
|
+
$ speqs tester-profile list
|
|
25
|
+
$ speqs tester-profile list --search "engineer" --country US
|
|
26
|
+
$ speqs tester-profile list --gender female --min-age 25 --max-age 40
|
|
27
|
+
$ speqs tester-profile list --type all --json`)
|
|
28
|
+
.action(async (opts, cmd) => {
|
|
29
|
+
await withClient(cmd, async (client, globals) => {
|
|
30
|
+
const params = {
|
|
31
|
+
limit: opts.limit,
|
|
32
|
+
offset: opts.offset,
|
|
33
|
+
};
|
|
34
|
+
if (opts.workspace)
|
|
35
|
+
params.product_id = opts.workspace;
|
|
36
|
+
if (opts.search)
|
|
37
|
+
params.search = opts.search;
|
|
38
|
+
if (opts.type !== "all")
|
|
39
|
+
params.type = opts.type;
|
|
40
|
+
if (opts.gender)
|
|
41
|
+
params.gender = opts.gender;
|
|
42
|
+
if (opts.country)
|
|
43
|
+
params.country = opts.country;
|
|
44
|
+
if (opts.minAge)
|
|
45
|
+
params.min_age = opts.minAge;
|
|
46
|
+
if (opts.maxAge)
|
|
47
|
+
params.max_age = opts.maxAge;
|
|
48
|
+
const data = await client.get("/tester-profiles", params);
|
|
49
|
+
formatTesterProfileList(data, globals.json, parseInt(opts.limit, 10));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
profile
|
|
53
|
+
.command("create")
|
|
54
|
+
.description("Create a tester profile")
|
|
55
|
+
.requiredOption("--file <path>", "JSON file with profile data")
|
|
56
|
+
.action(async (opts, cmd) => {
|
|
57
|
+
await withClient(cmd, async (client, globals) => {
|
|
58
|
+
const body = await readJsonFileOrStdin(opts.file);
|
|
59
|
+
const data = await client.post("/tester-profiles", body);
|
|
60
|
+
output(data, globals.json);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
profile
|
|
64
|
+
.command("get")
|
|
65
|
+
.description("Get tester profile details")
|
|
66
|
+
.argument("<id>", "Tester profile ID")
|
|
67
|
+
.action(async (id, _opts, cmd) => {
|
|
68
|
+
await withClient(cmd, async (client, globals) => {
|
|
69
|
+
const data = await client.get(`/tester-profiles/${id}`);
|
|
70
|
+
output(data, globals.json);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
profile
|
|
74
|
+
.command("update")
|
|
75
|
+
.description("Update a tester profile")
|
|
76
|
+
.argument("<id>", "Tester profile ID")
|
|
77
|
+
.requiredOption("--file <path>", "JSON file with update data")
|
|
78
|
+
.action(async (id, opts, cmd) => {
|
|
79
|
+
await withClient(cmd, async (client, globals) => {
|
|
80
|
+
const body = await readJsonFileOrStdin(opts.file);
|
|
81
|
+
const data = await client.put(`/tester-profiles/${id}`, body);
|
|
82
|
+
output(data, globals.json);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
profile
|
|
86
|
+
.command("delete")
|
|
87
|
+
.description("Delete a tester profile")
|
|
88
|
+
.argument("<id>", "Tester profile ID")
|
|
89
|
+
.action(async (id, _opts, cmd) => {
|
|
90
|
+
await withClient(cmd, async (client, globals) => {
|
|
91
|
+
await client.del(`/tester-profiles/${id}`);
|
|
92
|
+
output({ message: "Tester profile deleted" }, globals.json);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speqs tester — Manage testers (usually created via `simulation run`).
|
|
3
|
+
*/
|
|
4
|
+
import { withClient, readJsonFileOrStdin } from "../lib/command-helpers.js";
|
|
5
|
+
import { formatTesterDetail, output } from "../lib/output.js";
|
|
6
|
+
export function registerTesterCommands(program) {
|
|
7
|
+
const tester = program
|
|
8
|
+
.command("tester")
|
|
9
|
+
.description("Manage testers (usually created via `simulation run`)");
|
|
10
|
+
tester
|
|
11
|
+
.command("create")
|
|
12
|
+
.description("Create a tester (low-level)")
|
|
13
|
+
.requiredOption("--iteration <id>", "Iteration ID")
|
|
14
|
+
.requiredOption("--profile <id>", "Tester profile ID")
|
|
15
|
+
.option("--language <lang>", "Language code (e.g. en, sv)")
|
|
16
|
+
.option("--platform <platform>", "Platform (browser, android, figma, code)")
|
|
17
|
+
.option("--tester-type <type>", "Tester type (ai, human)", "ai")
|
|
18
|
+
.action(async (opts, cmd) => {
|
|
19
|
+
await withClient(cmd, async (client, globals) => {
|
|
20
|
+
const body = {
|
|
21
|
+
tester_profile_id: opts.profile,
|
|
22
|
+
tester_type: opts.testerType,
|
|
23
|
+
...(opts.language && { language: opts.language }),
|
|
24
|
+
...(opts.platform && { platform: opts.platform }),
|
|
25
|
+
};
|
|
26
|
+
const data = await client.post(`/iterations/${opts.iteration}/testers`, body);
|
|
27
|
+
output(data, globals.json);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
tester
|
|
31
|
+
.command("batch-create")
|
|
32
|
+
.description("Create multiple testers from a JSON file (low-level)")
|
|
33
|
+
.requiredOption("--iteration <id>", "Iteration ID")
|
|
34
|
+
.requiredOption("--file <path>", "JSON file with testers array")
|
|
35
|
+
.action(async (opts, cmd) => {
|
|
36
|
+
await withClient(cmd, async (client, globals) => {
|
|
37
|
+
const body = await readJsonFileOrStdin(opts.file);
|
|
38
|
+
const data = await client.post(`/iterations/${opts.iteration}/testers/batch`, body);
|
|
39
|
+
output(data, globals.json);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
tester
|
|
43
|
+
.command("get")
|
|
44
|
+
.description("Get tester details and results")
|
|
45
|
+
.argument("<id>", "Tester ID")
|
|
46
|
+
.addHelpText("after", "\nExamples:\n $ speqs tester get <id>\n $ speqs tester get <id> --json")
|
|
47
|
+
.action(async (id, _opts, cmd) => {
|
|
48
|
+
await withClient(cmd, async (client, globals) => {
|
|
49
|
+
const data = await client.get(`/testers/${id}`);
|
|
50
|
+
formatTesterDetail(data, globals.json);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
tester
|
|
54
|
+
.command("delete")
|
|
55
|
+
.description("Delete a tester")
|
|
56
|
+
.argument("<id>", "Tester ID")
|
|
57
|
+
.action(async (id, _opts, cmd) => {
|
|
58
|
+
await withClient(cmd, async (client, globals) => {
|
|
59
|
+
await client.del(`/testers/${id}`);
|
|
60
|
+
output({ message: "Tester deleted" }, globals.json);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speqs workspace — Manage workspaces (API: /products).
|
|
3
|
+
*/
|
|
4
|
+
import { withClient, getWebUrl, terminalLink } from "../lib/command-helpers.js";
|
|
5
|
+
import { formatWorkspaceList, formatWorkspaceDetail, output } from "../lib/output.js";
|
|
6
|
+
export function registerWorkspaceCommands(program) {
|
|
7
|
+
const workspace = program
|
|
8
|
+
.command("workspace")
|
|
9
|
+
.description("Manage workspaces");
|
|
10
|
+
workspace
|
|
11
|
+
.command("list")
|
|
12
|
+
.description("List all workspaces")
|
|
13
|
+
.addHelpText("after", "\nExamples:\n $ speqs workspace list\n $ speqs workspace list --json")
|
|
14
|
+
.action(async (_opts, cmd) => {
|
|
15
|
+
await withClient(cmd, async (client, globals) => {
|
|
16
|
+
const data = await client.get("/products");
|
|
17
|
+
formatWorkspaceList(data, globals.json);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
workspace
|
|
21
|
+
.command("create")
|
|
22
|
+
.description("Create a new workspace")
|
|
23
|
+
.requiredOption("--name <name>", "Workspace name")
|
|
24
|
+
.option("--description <description>", "Workspace description")
|
|
25
|
+
.option("--base-url <url>", "Default base URL")
|
|
26
|
+
.addHelpText("after", "\nExamples:\n $ speqs workspace create --name \"My App\" --base-url https://example.com\n $ speqs workspace create --name \"My App\" --json")
|
|
27
|
+
.action(async (opts, cmd) => {
|
|
28
|
+
await withClient(cmd, async (client, globals) => {
|
|
29
|
+
const body = {
|
|
30
|
+
name: opts.name,
|
|
31
|
+
...(opts.description && { description: opts.description }),
|
|
32
|
+
...(opts.baseUrl && { base_url: opts.baseUrl }),
|
|
33
|
+
};
|
|
34
|
+
const data = await client.post("/products", body);
|
|
35
|
+
formatWorkspaceDetail(data, globals.json);
|
|
36
|
+
if (!globals.json && data.id) {
|
|
37
|
+
const url = getWebUrl(globals, `/${data.id}`);
|
|
38
|
+
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
workspace
|
|
43
|
+
.command("get")
|
|
44
|
+
.description("Get workspace details")
|
|
45
|
+
.argument("<id>", "Workspace ID")
|
|
46
|
+
.action(async (id, _opts, cmd) => {
|
|
47
|
+
await withClient(cmd, async (client, globals) => {
|
|
48
|
+
const data = await client.get(`/products/${id}`);
|
|
49
|
+
formatWorkspaceDetail(data, globals.json);
|
|
50
|
+
if (!globals.json) {
|
|
51
|
+
const url = getWebUrl(globals, `/${id}`);
|
|
52
|
+
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
workspace
|
|
57
|
+
.command("update")
|
|
58
|
+
.description("Update a workspace")
|
|
59
|
+
.argument("<id>", "Workspace ID")
|
|
60
|
+
.option("--name <name>", "Workspace name")
|
|
61
|
+
.option("--description <description>", "Workspace description")
|
|
62
|
+
.option("--base-url <url>", "Default base URL")
|
|
63
|
+
.action(async (id, opts, cmd) => {
|
|
64
|
+
await withClient(cmd, async (client, globals) => {
|
|
65
|
+
const body = {};
|
|
66
|
+
if (opts.name !== undefined)
|
|
67
|
+
body.name = opts.name;
|
|
68
|
+
if (opts.description !== undefined)
|
|
69
|
+
body.description = opts.description;
|
|
70
|
+
if (opts.baseUrl !== undefined)
|
|
71
|
+
body.base_url = opts.baseUrl;
|
|
72
|
+
const data = await client.put(`/products/${id}`, body);
|
|
73
|
+
formatWorkspaceDetail(data, globals.json);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
workspace
|
|
77
|
+
.command("delete")
|
|
78
|
+
.description("Delete a workspace")
|
|
79
|
+
.argument("<id>", "Workspace ID")
|
|
80
|
+
.action(async (id, _opts, cmd) => {
|
|
81
|
+
await withClient(cmd, async (client, globals) => {
|
|
82
|
+
await client.del(`/products/${id}`);
|
|
83
|
+
output({ message: "Workspace deleted" }, globals.json);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,18 +4,35 @@ import { runTunnel } from "./connect.js";
|
|
|
4
4
|
import { login, getAppUrl } from "./auth.js";
|
|
5
5
|
import { loadConfig, saveConfig } from "./config.js";
|
|
6
6
|
import { upgrade } from "./upgrade.js";
|
|
7
|
+
import { registerWorkspaceCommands } from "./commands/workspace.js";
|
|
8
|
+
import { registerStudyCommands } from "./commands/study.js";
|
|
9
|
+
import { registerIterationCommands } from "./commands/iteration.js";
|
|
10
|
+
import { registerTesterProfileCommands } from "./commands/tester-profile.js";
|
|
11
|
+
import { registerTesterCommands } from "./commands/tester.js";
|
|
12
|
+
import { registerSimulationCommands } from "./commands/simulation.js";
|
|
13
|
+
import { registerConfigCommands } from "./commands/config.js";
|
|
7
14
|
import pkg from "../package.json" with { type: "json" };
|
|
8
15
|
const { version } = pkg;
|
|
9
16
|
program
|
|
10
17
|
.name("speqs")
|
|
11
|
-
.description("Speqs CLI
|
|
18
|
+
.description("Speqs CLI — manage workspaces, studies, simulations, and more")
|
|
12
19
|
.version(version);
|
|
20
|
+
// Global options
|
|
21
|
+
program
|
|
22
|
+
.option("-t, --token <token>", "Auth token (or set SPEQS_TOKEN env var)")
|
|
23
|
+
.option("--api-url <url>", "Backend API URL (default: SPEQS_API_URL or https://api.speqs.io)")
|
|
24
|
+
.addOption(new Option("--dev", "Use local dev API (http://localhost:8000)").hideHelp())
|
|
25
|
+
.option("--json", "Output as JSON (for programmatic use)")
|
|
26
|
+
.option("-q, --quiet", "Suppress progress messages on stderr");
|
|
27
|
+
// --- Inline commands (from upstream) ---
|
|
13
28
|
program
|
|
14
29
|
.command("login")
|
|
15
30
|
.description("Authenticate with Speqs via your browser")
|
|
16
|
-
.action(async () => {
|
|
31
|
+
.action(async (_opts, cmd) => {
|
|
17
32
|
try {
|
|
18
|
-
const
|
|
33
|
+
const globals = cmd.optsWithGlobals();
|
|
34
|
+
const appUrl = globals.dev ? "http://localhost:3000" : getAppUrl();
|
|
35
|
+
const tokens = await login(appUrl);
|
|
19
36
|
const config = loadConfig();
|
|
20
37
|
config.access_token = tokens.accessToken;
|
|
21
38
|
config.refresh_token = tokens.refreshToken;
|
|
@@ -42,18 +59,24 @@ program
|
|
|
42
59
|
.command("connect")
|
|
43
60
|
.description("Expose your localhost to Speqs via a Cloudflare tunnel")
|
|
44
61
|
.argument("<port>", "Local port to connect (e.g. 3000)")
|
|
45
|
-
.
|
|
46
|
-
.option("--api-url <url>", "Backend API URL (default: SPEQS_API_URL or https://api.speqs.io)")
|
|
47
|
-
.addOption(new Option("--dev", "Use local dev API (http://localhost:8000)").hideHelp())
|
|
48
|
-
.action(async (port, options) => {
|
|
62
|
+
.action(async (port, _opts, cmd) => {
|
|
49
63
|
const portNum = parseInt(port, 10);
|
|
50
64
|
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
51
65
|
console.error(`Invalid port: ${port}`);
|
|
52
66
|
process.exit(1);
|
|
53
67
|
}
|
|
54
|
-
const
|
|
55
|
-
|
|
68
|
+
const globals = cmd.optsWithGlobals();
|
|
69
|
+
const apiUrl = globals.dev ? "http://localhost:8000" : globals.apiUrl;
|
|
70
|
+
await runTunnel(portNum, globals.token, apiUrl);
|
|
56
71
|
});
|
|
72
|
+
// --- Modular command groups ---
|
|
73
|
+
registerWorkspaceCommands(program);
|
|
74
|
+
registerStudyCommands(program);
|
|
75
|
+
registerIterationCommands(program);
|
|
76
|
+
registerTesterProfileCommands(program);
|
|
77
|
+
registerTesterCommands(program);
|
|
78
|
+
registerSimulationCommands(program);
|
|
79
|
+
registerConfigCommands(program);
|
|
57
80
|
program
|
|
58
81
|
.command("upgrade")
|
|
59
82
|
.description("Update speqs to the latest version")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP API client for the Speqs backend.
|
|
3
|
+
*/
|
|
4
|
+
export declare class ApiError extends Error {
|
|
5
|
+
status: number;
|
|
6
|
+
statusText: string;
|
|
7
|
+
body: unknown;
|
|
8
|
+
error_code: string;
|
|
9
|
+
retryable: boolean;
|
|
10
|
+
constructor(status: number, statusText: string, body: unknown);
|
|
11
|
+
}
|
|
12
|
+
export declare class ApiClient {
|
|
13
|
+
private baseUrl;
|
|
14
|
+
private token;
|
|
15
|
+
constructor(opts: {
|
|
16
|
+
apiUrl: string;
|
|
17
|
+
token: string;
|
|
18
|
+
});
|
|
19
|
+
private headers;
|
|
20
|
+
get<T = unknown>(path: string, params?: Record<string, string>): Promise<T>;
|
|
21
|
+
post<T = unknown>(path: string, body?: unknown, opts?: {
|
|
22
|
+
timeout?: number;
|
|
23
|
+
}): Promise<T>;
|
|
24
|
+
put<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
25
|
+
del(path: string): Promise<void>;
|
|
26
|
+
private handleResponse;
|
|
27
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP API client for the Speqs backend.
|
|
3
|
+
*/
|
|
4
|
+
import { API_BASE } from "./auth.js";
|
|
5
|
+
function mapErrorCode(status) {
|
|
6
|
+
if (status === 401)
|
|
7
|
+
return "auth_failed";
|
|
8
|
+
if (status === 403)
|
|
9
|
+
return "forbidden";
|
|
10
|
+
if (status === 404)
|
|
11
|
+
return "not_found";
|
|
12
|
+
if (status === 402)
|
|
13
|
+
return "insufficient_credits";
|
|
14
|
+
if (status === 422)
|
|
15
|
+
return "validation_error";
|
|
16
|
+
if (status === 429)
|
|
17
|
+
return "rate_limited";
|
|
18
|
+
if (status >= 500)
|
|
19
|
+
return "server_error";
|
|
20
|
+
return "request_failed";
|
|
21
|
+
}
|
|
22
|
+
function isRetryable(status) {
|
|
23
|
+
return status === 429 || status >= 500;
|
|
24
|
+
}
|
|
25
|
+
export class ApiError extends Error {
|
|
26
|
+
status;
|
|
27
|
+
statusText;
|
|
28
|
+
body;
|
|
29
|
+
error_code;
|
|
30
|
+
retryable;
|
|
31
|
+
constructor(status, statusText, body) {
|
|
32
|
+
const msg = typeof body === "object" && body !== null && "detail" in body
|
|
33
|
+
? String(body.detail)
|
|
34
|
+
: `HTTP ${status} ${statusText}`;
|
|
35
|
+
super(msg);
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.statusText = statusText;
|
|
38
|
+
this.body = body;
|
|
39
|
+
this.name = "ApiError";
|
|
40
|
+
this.error_code = mapErrorCode(status);
|
|
41
|
+
this.retryable = isRetryable(status);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export class ApiClient {
|
|
45
|
+
baseUrl;
|
|
46
|
+
token;
|
|
47
|
+
constructor(opts) {
|
|
48
|
+
this.baseUrl = `${opts.apiUrl}${API_BASE}`;
|
|
49
|
+
this.token = opts.token;
|
|
50
|
+
}
|
|
51
|
+
headers() {
|
|
52
|
+
return {
|
|
53
|
+
Authorization: `Bearer ${this.token}`,
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async get(path, params) {
|
|
58
|
+
let url = `${this.baseUrl}${path}`;
|
|
59
|
+
if (params) {
|
|
60
|
+
const filtered = Object.entries(params).filter(([, v]) => v !== undefined && v !== "");
|
|
61
|
+
if (filtered.length > 0) {
|
|
62
|
+
url += "?" + new URLSearchParams(filtered).toString();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const resp = await fetch(url, {
|
|
66
|
+
headers: this.headers(),
|
|
67
|
+
signal: AbortSignal.timeout(15_000),
|
|
68
|
+
});
|
|
69
|
+
return this.handleResponse(resp);
|
|
70
|
+
}
|
|
71
|
+
async post(path, body, opts) {
|
|
72
|
+
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: this.headers(),
|
|
75
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
76
|
+
signal: AbortSignal.timeout(opts?.timeout ?? 15_000),
|
|
77
|
+
});
|
|
78
|
+
return this.handleResponse(resp);
|
|
79
|
+
}
|
|
80
|
+
async put(path, body) {
|
|
81
|
+
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
82
|
+
method: "PUT",
|
|
83
|
+
headers: this.headers(),
|
|
84
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
85
|
+
signal: AbortSignal.timeout(15_000),
|
|
86
|
+
});
|
|
87
|
+
return this.handleResponse(resp);
|
|
88
|
+
}
|
|
89
|
+
async del(path) {
|
|
90
|
+
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
91
|
+
method: "DELETE",
|
|
92
|
+
headers: this.headers(),
|
|
93
|
+
signal: AbortSignal.timeout(15_000),
|
|
94
|
+
});
|
|
95
|
+
if (!resp.ok) {
|
|
96
|
+
let body;
|
|
97
|
+
try {
|
|
98
|
+
body = await resp.json();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
body = null;
|
|
102
|
+
}
|
|
103
|
+
throw new ApiError(resp.status, resp.statusText, body);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async handleResponse(resp) {
|
|
107
|
+
if (!resp.ok) {
|
|
108
|
+
let body;
|
|
109
|
+
try {
|
|
110
|
+
body = await resp.json();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
body = null;
|
|
114
|
+
}
|
|
115
|
+
throw new ApiError(resp.status, resp.statusText, body);
|
|
116
|
+
}
|
|
117
|
+
if (resp.status === 204)
|
|
118
|
+
return undefined;
|
|
119
|
+
return await resp.json();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared authentication and configuration utilities for CLI commands.
|
|
3
|
+
* Uses the canonical config from src/config.ts and OAuth refresh from src/auth.ts.
|
|
4
|
+
*/
|
|
5
|
+
export declare const DEFAULT_API_URL = "https://api.speqs.io";
|
|
6
|
+
export declare const API_BASE = "/api/v1";
|
|
7
|
+
export declare function resolveApiUrl(apiUrlArg?: string, dev?: boolean): string;
|
|
8
|
+
export declare function resolveToken(tokenArg: string | undefined, apiUrl: string): Promise<string>;
|
package/dist/lib/auth.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared authentication and configuration utilities for CLI commands.
|
|
3
|
+
* Uses the canonical config from src/config.ts and OAuth refresh from src/auth.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { loadConfig, saveConfig } from "../config.js";
|
|
6
|
+
import { refreshTokens, isTokenExpired } from "../auth.js";
|
|
7
|
+
export const DEFAULT_API_URL = "https://api.speqs.io";
|
|
8
|
+
export const API_BASE = "/api/v1";
|
|
9
|
+
export function resolveApiUrl(apiUrlArg, dev) {
|
|
10
|
+
if (dev)
|
|
11
|
+
return "http://localhost:8000";
|
|
12
|
+
if (apiUrlArg)
|
|
13
|
+
return apiUrlArg;
|
|
14
|
+
return process.env.SPEQS_API_URL ?? DEFAULT_API_URL;
|
|
15
|
+
}
|
|
16
|
+
async function verifyToken(token, apiUrl) {
|
|
17
|
+
try {
|
|
18
|
+
const resp = await fetch(`${apiUrl}${API_BASE}/connect/active`, {
|
|
19
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
20
|
+
signal: AbortSignal.timeout(10_000),
|
|
21
|
+
});
|
|
22
|
+
// 404 = valid token, no connection (expected). 401/403 = bad token.
|
|
23
|
+
return resp.status !== 401 && resp.status !== 403;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Network error — can't verify, assume ok
|
|
27
|
+
console.error("Warning: Could not verify token (network error). Proceeding anyway.");
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function resolveToken(tokenArg, apiUrl) {
|
|
32
|
+
// 1. Explicit token argument
|
|
33
|
+
if (tokenArg)
|
|
34
|
+
return tokenArg;
|
|
35
|
+
// 2. Environment variable
|
|
36
|
+
const envToken = process.env.SPEQS_TOKEN;
|
|
37
|
+
if (envToken)
|
|
38
|
+
return envToken;
|
|
39
|
+
// 3. Saved config with OAuth tokens (access_token + refresh_token)
|
|
40
|
+
const config = loadConfig();
|
|
41
|
+
if (config.access_token && config.refresh_token) {
|
|
42
|
+
let accessToken = config.access_token;
|
|
43
|
+
// Refresh if expired or close to expiry
|
|
44
|
+
if (isTokenExpired(accessToken)) {
|
|
45
|
+
try {
|
|
46
|
+
const tokens = await refreshTokens(config.refresh_token);
|
|
47
|
+
accessToken = tokens.accessToken;
|
|
48
|
+
config.access_token = tokens.accessToken;
|
|
49
|
+
config.refresh_token = tokens.refreshToken;
|
|
50
|
+
saveConfig(config);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error('Session expired. Run "speqs login" to re-authenticate.');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (await verifyToken(accessToken, apiUrl)) {
|
|
57
|
+
return accessToken;
|
|
58
|
+
}
|
|
59
|
+
throw new Error('Saved token is invalid. Run "speqs login" to re-authenticate.');
|
|
60
|
+
}
|
|
61
|
+
// 4. Legacy saved token (no refresh token)
|
|
62
|
+
if (config.token) {
|
|
63
|
+
if (await verifyToken(config.token, apiUrl)) {
|
|
64
|
+
return config.token;
|
|
65
|
+
}
|
|
66
|
+
throw new Error('Saved token is invalid. Run "speqs login" to re-authenticate.');
|
|
67
|
+
}
|
|
68
|
+
// 5. No token found
|
|
69
|
+
throw new Error('No auth token found. Run "speqs login" or set SPEQS_TOKEN environment variable or pass --token <token>.');
|
|
70
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for command actions.
|
|
3
|
+
* Resolves global options, creates API client, handles errors.
|
|
4
|
+
*/
|
|
5
|
+
import type { Command } from "commander";
|
|
6
|
+
import { ApiClient } from "./api-client.js";
|
|
7
|
+
export interface GlobalOpts {
|
|
8
|
+
token?: string;
|
|
9
|
+
apiUrl?: string;
|
|
10
|
+
dev?: boolean;
|
|
11
|
+
json: boolean;
|
|
12
|
+
quiet: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function getGlobals(cmd: Command): GlobalOpts;
|
|
15
|
+
/**
|
|
16
|
+
* Map errors to semantic exit codes.
|
|
17
|
+
* 0=success, 1=general, 2=usage/validation, 3=auth, 4=not found, 5=transient
|
|
18
|
+
*/
|
|
19
|
+
export declare function exitCodeFromError(err: unknown): number;
|
|
20
|
+
export declare function createClient(globals: GlobalOpts): Promise<ApiClient>;
|
|
21
|
+
export declare function withClient(cmd: Command, fn: (client: ApiClient, globals: GlobalOpts) => Promise<void>): Promise<void>;
|
|
22
|
+
export declare function getWebUrl(globals: GlobalOpts, path: string): string;
|
|
23
|
+
export declare function terminalLink(url: string, text: string): string;
|
|
24
|
+
export declare function readJsonFileOrStdin(filePath?: string): Promise<unknown>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for command actions.
|
|
3
|
+
* Resolves global options, creates API client, handles errors.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import { resolveApiUrl, resolveToken } from "./auth.js";
|
|
7
|
+
import { getAppUrl } from "../auth.js";
|
|
8
|
+
import { ApiClient, ApiError } from "./api-client.js";
|
|
9
|
+
import { outputError } from "./output.js";
|
|
10
|
+
export function getGlobals(cmd) {
|
|
11
|
+
const opts = cmd.optsWithGlobals();
|
|
12
|
+
return {
|
|
13
|
+
token: opts.token,
|
|
14
|
+
apiUrl: opts.apiUrl,
|
|
15
|
+
dev: opts.dev,
|
|
16
|
+
json: opts.json ?? false,
|
|
17
|
+
quiet: opts.quiet ?? false,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Map errors to semantic exit codes.
|
|
22
|
+
* 0=success, 1=general, 2=usage/validation, 3=auth, 4=not found, 5=transient
|
|
23
|
+
*/
|
|
24
|
+
export function exitCodeFromError(err) {
|
|
25
|
+
if (err instanceof ApiError) {
|
|
26
|
+
if (err.status === 401 || err.status === 403)
|
|
27
|
+
return 3;
|
|
28
|
+
if (err.status === 404)
|
|
29
|
+
return 4;
|
|
30
|
+
if (err.status === 400 || err.status === 422)
|
|
31
|
+
return 2;
|
|
32
|
+
if (err.retryable)
|
|
33
|
+
return 5;
|
|
34
|
+
}
|
|
35
|
+
// Auth-related client errors (e.g. missing token)
|
|
36
|
+
if (err instanceof Error && /token|auth/i.test(err.message))
|
|
37
|
+
return 3;
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
export async function createClient(globals) {
|
|
41
|
+
const apiUrl = resolveApiUrl(globals.apiUrl, globals.dev);
|
|
42
|
+
const token = await resolveToken(globals.token, apiUrl);
|
|
43
|
+
return new ApiClient({ apiUrl, token });
|
|
44
|
+
}
|
|
45
|
+
export async function withClient(cmd, fn) {
|
|
46
|
+
const globals = getGlobals(cmd);
|
|
47
|
+
try {
|
|
48
|
+
const client = await createClient(globals);
|
|
49
|
+
await fn(client, globals);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
outputError(err, globals.json);
|
|
53
|
+
process.exit(exitCodeFromError(err));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function getWebUrl(globals, path) {
|
|
57
|
+
const base = globals.dev ? "http://localhost:3000" : getAppUrl();
|
|
58
|
+
return `${base}${path}`;
|
|
59
|
+
}
|
|
60
|
+
export function terminalLink(url, text) {
|
|
61
|
+
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
62
|
+
}
|
|
63
|
+
export function readJsonFileOrStdin(filePath) {
|
|
64
|
+
if (filePath) {
|
|
65
|
+
let content;
|
|
66
|
+
try {
|
|
67
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return Promise.reject(new Error(`Cannot read file: ${filePath}`));
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
return Promise.resolve(JSON.parse(content));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return Promise.reject(new Error(`Invalid JSON in file: ${filePath}`));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Read from stdin
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
let data = "";
|
|
82
|
+
process.stdin.setEncoding("utf-8");
|
|
83
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
84
|
+
process.stdin.on("end", () => {
|
|
85
|
+
try {
|
|
86
|
+
resolve(JSON.parse(data));
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
reject(new Error("Invalid JSON from stdin"));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
process.stdin.on("error", reject);
|
|
93
|
+
});
|
|
94
|
+
}
|