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.
@@ -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,5 @@
1
+ /**
2
+ * speqs tester — Manage testers (usually created via `simulation run`).
3
+ */
4
+ import type { Command } from "commander";
5
+ export declare function registerTesterCommands(program: Command): void;
@@ -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,5 @@
1
+ /**
2
+ * speqs workspace — Manage workspaces (API: /products).
3
+ */
4
+ import type { Command } from "commander";
5
+ export declare function registerWorkspaceCommands(program: Command): void;
@@ -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 tools")
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 tokens = await login(getAppUrl());
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
- .option("-t, --token <token>", "Auth token (or set SPEQS_TOKEN, or save via interactive prompt)")
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 apiUrl = options.dev ? "http://localhost:8000" : options.apiUrl;
55
- await runTunnel(portNum, options.token, apiUrl);
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>;
@@ -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
+ }