@ishlabs/cli 0.8.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/LICENSE +6 -0
- package/README.md +69 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +102 -0
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +82 -0
- package/dist/commands/iteration.d.ts +5 -0
- package/dist/commands/iteration.js +134 -0
- package/dist/commands/simulation.d.ts +10 -0
- package/dist/commands/simulation.js +647 -0
- package/dist/commands/study.d.ts +5 -0
- package/dist/commands/study.js +283 -0
- package/dist/commands/tester-profile.d.ts +5 -0
- package/dist/commands/tester-profile.js +109 -0
- package/dist/commands/tester.d.ts +5 -0
- package/dist/commands/tester.js +73 -0
- package/dist/commands/workspace.d.ts +5 -0
- package/dist/commands/workspace.js +133 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +25 -0
- package/dist/connect.d.ts +4 -0
- package/dist/connect.js +573 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +89 -0
- package/dist/lib/alias-store.d.ts +49 -0
- package/dist/lib/alias-store.js +138 -0
- package/dist/lib/api-client.d.ts +58 -0
- package/dist/lib/api-client.js +177 -0
- package/dist/lib/auth.d.ts +8 -0
- package/dist/lib/auth.js +73 -0
- package/dist/lib/command-helpers.d.ts +28 -0
- package/dist/lib/command-helpers.js +131 -0
- package/dist/lib/local-sim/actions.d.ts +22 -0
- package/dist/lib/local-sim/actions.js +379 -0
- package/dist/lib/local-sim/browser.d.ts +63 -0
- package/dist/lib/local-sim/browser.js +332 -0
- package/dist/lib/local-sim/debug-report.d.ts +21 -0
- package/dist/lib/local-sim/debug-report.js +186 -0
- package/dist/lib/local-sim/debug.d.ts +44 -0
- package/dist/lib/local-sim/debug.js +103 -0
- package/dist/lib/local-sim/install.d.ts +25 -0
- package/dist/lib/local-sim/install.js +72 -0
- package/dist/lib/local-sim/loop.d.ts +60 -0
- package/dist/lib/local-sim/loop.js +526 -0
- package/dist/lib/local-sim/types.d.ts +232 -0
- package/dist/lib/local-sim/types.js +8 -0
- package/dist/lib/local-sim/upload.d.ts +6 -0
- package/dist/lib/local-sim/upload.js +24 -0
- package/dist/lib/output.d.ts +34 -0
- package/dist/lib/output.js +675 -0
- package/dist/lib/types.d.ts +179 -0
- package/dist/lib/types.js +12 -0
- package/dist/lib/upload.d.ts +47 -0
- package/dist/lib/upload.js +178 -0
- package/dist/upgrade.d.ts +1 -0
- package/dist/upgrade.js +94 -0
- package/package.json +43 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short alias system for entity IDs.
|
|
3
|
+
*
|
|
4
|
+
* Generates deterministic aliases from UUID prefixes (e.g. w-6ec, s-b2c, tp-d4e).
|
|
5
|
+
* Same UUID always produces the same alias — stable across commands and terminals.
|
|
6
|
+
* Aliases are persisted to ~/.ish/aliases.json for resolution.
|
|
7
|
+
*/
|
|
8
|
+
/** Entity type → alias prefix */
|
|
9
|
+
export declare const ALIAS_PREFIX: {
|
|
10
|
+
readonly workspace: "w";
|
|
11
|
+
readonly study: "s";
|
|
12
|
+
readonly iteration: "i";
|
|
13
|
+
readonly testerProfile: "tp";
|
|
14
|
+
readonly tester: "t";
|
|
15
|
+
readonly config: "c";
|
|
16
|
+
readonly job: "j";
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Save aliases for a list of IDs under the given prefix.
|
|
20
|
+
* Clears all existing aliases for that prefix before saving.
|
|
21
|
+
* Numbers start at `startIndex` (default 1) to support pagination.
|
|
22
|
+
*/
|
|
23
|
+
export declare function saveAliases(prefix: string, ids: string[], startIndex?: number): void;
|
|
24
|
+
/**
|
|
25
|
+
* Build a uuid→alias map for a given prefix (used by formatters to display aliases).
|
|
26
|
+
*/
|
|
27
|
+
export declare function getAliasMap(prefix: string): Map<string, string>;
|
|
28
|
+
/**
|
|
29
|
+
* Register a single entity ID under the given prefix.
|
|
30
|
+
* If the ID already has an alias, returns the existing one.
|
|
31
|
+
* Otherwise assigns the next available number.
|
|
32
|
+
*/
|
|
33
|
+
export declare function tagAlias(prefix: string, id: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* Generate a deterministic alias from a UUID prefix (like git short hashes).
|
|
36
|
+
* Format: {prefix}-{first 3 hex chars}, extended on collision.
|
|
37
|
+
* Same UUID always produces the same alias — stable across commands and terminals.
|
|
38
|
+
*/
|
|
39
|
+
export declare function deterministicAlias(prefix: string, uuid: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a short alias to a full UUID, or validate and pass through a full UUID.
|
|
42
|
+
*
|
|
43
|
+
* Accepted formats:
|
|
44
|
+
* - Short alias: w-6ec, s-b2c, tp-d4e (resolved from ~/.ish/aliases.json)
|
|
45
|
+
* - Full UUID: 6ecf2857-1d7a-4f9c-85da-c2ac6c5c5346
|
|
46
|
+
*
|
|
47
|
+
* Everything else throws with guidance toward the correct usage.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveId(input: string): string;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short alias system for entity IDs.
|
|
3
|
+
*
|
|
4
|
+
* Generates deterministic aliases from UUID prefixes (e.g. w-6ec, s-b2c, tp-d4e).
|
|
5
|
+
* Same UUID always produces the same alias — stable across commands and terminals.
|
|
6
|
+
* Aliases are persisted to ~/.ish/aliases.json for resolution.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
const ALIASES_FILE = path.join(os.homedir(), ".ish", "aliases.json");
|
|
12
|
+
/** Entity type → alias prefix */
|
|
13
|
+
export const ALIAS_PREFIX = {
|
|
14
|
+
workspace: "w",
|
|
15
|
+
study: "s",
|
|
16
|
+
iteration: "i",
|
|
17
|
+
testerProfile: "tp",
|
|
18
|
+
tester: "t",
|
|
19
|
+
config: "c",
|
|
20
|
+
job: "j",
|
|
21
|
+
};
|
|
22
|
+
/** Format a number with zero-padding (minimum 2 digits). */
|
|
23
|
+
function padNum(n) {
|
|
24
|
+
return String(n).padStart(2, "0");
|
|
25
|
+
}
|
|
26
|
+
function loadAliases() {
|
|
27
|
+
try {
|
|
28
|
+
if (fs.existsSync(ALIASES_FILE)) {
|
|
29
|
+
return JSON.parse(fs.readFileSync(ALIASES_FILE, "utf-8"));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Corrupted — start fresh
|
|
34
|
+
}
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
function persistAliases(aliases) {
|
|
38
|
+
const dir = path.dirname(ALIASES_FILE);
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
40
|
+
const tmp = ALIASES_FILE + ".tmp";
|
|
41
|
+
fs.writeFileSync(tmp, JSON.stringify(aliases, null, 2) + "\n", { mode: 0o600 });
|
|
42
|
+
fs.renameSync(tmp, ALIASES_FILE);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Save aliases for a list of IDs under the given prefix.
|
|
46
|
+
* Clears all existing aliases for that prefix before saving.
|
|
47
|
+
* Numbers start at `startIndex` (default 1) to support pagination.
|
|
48
|
+
*/
|
|
49
|
+
export function saveAliases(prefix, ids, startIndex = 1) {
|
|
50
|
+
const aliases = loadAliases();
|
|
51
|
+
// Remove existing entries for this prefix (both old and new format)
|
|
52
|
+
const prefixPattern = new RegExp(`^${prefix}\\d+$`);
|
|
53
|
+
for (const key of Object.keys(aliases)) {
|
|
54
|
+
if (prefixPattern.test(key)) {
|
|
55
|
+
delete aliases[key];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Save new mappings with zero-padded numbers
|
|
59
|
+
for (let i = 0; i < ids.length; i++) {
|
|
60
|
+
aliases[`${prefix}${padNum(startIndex + i)}`] = ids[i];
|
|
61
|
+
}
|
|
62
|
+
persistAliases(aliases);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build a uuid→alias map for a given prefix (used by formatters to display aliases).
|
|
66
|
+
*/
|
|
67
|
+
export function getAliasMap(prefix) {
|
|
68
|
+
const aliases = loadAliases();
|
|
69
|
+
const map = new Map();
|
|
70
|
+
const prefixPattern = new RegExp(`^${prefix}(\\d{2,}|-[0-9a-f]{3,})$`);
|
|
71
|
+
for (const [alias, uuid] of Object.entries(aliases)) {
|
|
72
|
+
if (prefixPattern.test(alias)) {
|
|
73
|
+
map.set(uuid, alias);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return map;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Register a single entity ID under the given prefix.
|
|
80
|
+
* If the ID already has an alias, returns the existing one.
|
|
81
|
+
* Otherwise assigns the next available number.
|
|
82
|
+
*/
|
|
83
|
+
export function tagAlias(prefix, id) {
|
|
84
|
+
// Delegate to deterministic alias for consistency
|
|
85
|
+
return deterministicAlias(prefix, id);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Generate a deterministic alias from a UUID prefix (like git short hashes).
|
|
89
|
+
* Format: {prefix}-{first 3 hex chars}, extended on collision.
|
|
90
|
+
* Same UUID always produces the same alias — stable across commands and terminals.
|
|
91
|
+
*/
|
|
92
|
+
export function deterministicAlias(prefix, uuid) {
|
|
93
|
+
const clean = uuid.replace(/-/g, "");
|
|
94
|
+
let len = 3;
|
|
95
|
+
let alias = `${prefix}-${clean.slice(0, len)}`;
|
|
96
|
+
const aliases = loadAliases();
|
|
97
|
+
// Adaptive extension: if this alias maps to a DIFFERENT UUID, extend
|
|
98
|
+
while (aliases[alias] && aliases[alias] !== uuid) {
|
|
99
|
+
len++;
|
|
100
|
+
alias = `${prefix}-${clean.slice(0, len)}`;
|
|
101
|
+
}
|
|
102
|
+
// Persist if new
|
|
103
|
+
if (!aliases[alias]) {
|
|
104
|
+
aliases[alias] = uuid;
|
|
105
|
+
persistAliases(aliases);
|
|
106
|
+
}
|
|
107
|
+
return alias;
|
|
108
|
+
}
|
|
109
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
110
|
+
const ALIAS_RE = /^[a-z]+-[0-9a-f]{3,}$|^[a-z]+\d{2,}$/;
|
|
111
|
+
/**
|
|
112
|
+
* Resolve a short alias to a full UUID, or validate and pass through a full UUID.
|
|
113
|
+
*
|
|
114
|
+
* Accepted formats:
|
|
115
|
+
* - Short alias: w-6ec, s-b2c, tp-d4e (resolved from ~/.ish/aliases.json)
|
|
116
|
+
* - Full UUID: 6ecf2857-1d7a-4f9c-85da-c2ac6c5c5346
|
|
117
|
+
*
|
|
118
|
+
* Everything else throws with guidance toward the correct usage.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveId(input) {
|
|
121
|
+
// 1. Full UUID — validate format and pass through
|
|
122
|
+
if (UUID_RE.test(input)) {
|
|
123
|
+
return input;
|
|
124
|
+
}
|
|
125
|
+
// 2. Known alias pattern — resolve from store
|
|
126
|
+
if (ALIAS_RE.test(input)) {
|
|
127
|
+
const aliases = loadAliases();
|
|
128
|
+
const uuid = aliases[input];
|
|
129
|
+
if (uuid)
|
|
130
|
+
return uuid;
|
|
131
|
+
throw new Error(`Unknown alias "${input}". Run a list command first to generate aliases.`);
|
|
132
|
+
}
|
|
133
|
+
// 3. Anything else — fail with helpful guidance
|
|
134
|
+
throw new Error(`Invalid ID "${input}". Use a short alias (e.g. w-a3f, s-b2c) or a full UUID.\n` +
|
|
135
|
+
"Run a list command first to see available aliases:\n" +
|
|
136
|
+
" ish workspace list\n" +
|
|
137
|
+
" ish study list --workspace <id>");
|
|
138
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP API client for the Ish 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
|
+
get accessToken(): string;
|
|
20
|
+
private headers;
|
|
21
|
+
get<T = unknown>(path: string, params?: Record<string, string>): Promise<T>;
|
|
22
|
+
post<T = unknown>(path: string, body?: unknown, opts?: {
|
|
23
|
+
timeout?: number;
|
|
24
|
+
}): Promise<T>;
|
|
25
|
+
put<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
26
|
+
del(path: string): Promise<void>;
|
|
27
|
+
localSimInit(body: {
|
|
28
|
+
tester_id: string;
|
|
29
|
+
study_id: string;
|
|
30
|
+
product_id: string;
|
|
31
|
+
iteration_id: string;
|
|
32
|
+
}): Promise<import("./local-sim/types.js").LocalSimInitResponse>;
|
|
33
|
+
localSimStep(body: import("./local-sim/types.js").LocalSimStepRequest): Promise<import("./local-sim/types.js").LocalSimStepResponseRaw>;
|
|
34
|
+
localSimRecord(body: import("./local-sim/types.js").LocalSimRecordRequest): Promise<import("./local-sim/types.js").LocalSimRecordResponse>;
|
|
35
|
+
localSimMatchFrame(body: {
|
|
36
|
+
product_id: string;
|
|
37
|
+
study_id: string;
|
|
38
|
+
screenshot_base64: string;
|
|
39
|
+
screenshot_url?: string;
|
|
40
|
+
location_name: string;
|
|
41
|
+
screen_format?: string;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
frame_version_id: string;
|
|
44
|
+
}>;
|
|
45
|
+
localSimScreenshotUpload(body: {
|
|
46
|
+
product_id: string;
|
|
47
|
+
screenshot_id: string;
|
|
48
|
+
content_type: string;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
upload_info: {
|
|
51
|
+
signed_upload_url: string;
|
|
52
|
+
file_path: string;
|
|
53
|
+
expires_in_seconds: number;
|
|
54
|
+
};
|
|
55
|
+
screenshot_url: string;
|
|
56
|
+
}>;
|
|
57
|
+
private handleResponse;
|
|
58
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP API client for the Ish 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
|
+
get accessToken() {
|
|
52
|
+
return this.token;
|
|
53
|
+
}
|
|
54
|
+
headers() {
|
|
55
|
+
return {
|
|
56
|
+
Authorization: `Bearer ${this.token}`,
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async get(path, params) {
|
|
61
|
+
let url = `${this.baseUrl}${path}`;
|
|
62
|
+
if (params) {
|
|
63
|
+
const filtered = Object.entries(params).filter(([, v]) => v !== undefined && v !== "");
|
|
64
|
+
if (filtered.length > 0) {
|
|
65
|
+
url += "?" + new URLSearchParams(filtered).toString();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
let res;
|
|
69
|
+
try {
|
|
70
|
+
res = await fetch(url, {
|
|
71
|
+
headers: this.headers(),
|
|
72
|
+
signal: AbortSignal.timeout(15_000),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
77
|
+
throw new Error('Request timed out after 15s. The server may be slow — try again.');
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
return this.handleResponse(res);
|
|
82
|
+
}
|
|
83
|
+
async post(path, body, opts) {
|
|
84
|
+
const timeout = opts?.timeout ?? 15_000;
|
|
85
|
+
let res;
|
|
86
|
+
try {
|
|
87
|
+
res = await fetch(`${this.baseUrl}${path}`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: this.headers(),
|
|
90
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
91
|
+
signal: AbortSignal.timeout(timeout),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
96
|
+
throw new Error(`Request timed out after ${timeout / 1000}s. The server may be slow — try again.`);
|
|
97
|
+
}
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
return this.handleResponse(res);
|
|
101
|
+
}
|
|
102
|
+
async put(path, body) {
|
|
103
|
+
let res;
|
|
104
|
+
try {
|
|
105
|
+
res = await fetch(`${this.baseUrl}${path}`, {
|
|
106
|
+
method: "PUT",
|
|
107
|
+
headers: this.headers(),
|
|
108
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
109
|
+
signal: AbortSignal.timeout(15_000),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
114
|
+
throw new Error('Request timed out after 15s. The server may be slow — try again.');
|
|
115
|
+
}
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
return this.handleResponse(res);
|
|
119
|
+
}
|
|
120
|
+
async del(path) {
|
|
121
|
+
let res;
|
|
122
|
+
try {
|
|
123
|
+
res = await fetch(`${this.baseUrl}${path}`, {
|
|
124
|
+
method: "DELETE",
|
|
125
|
+
headers: this.headers(),
|
|
126
|
+
signal: AbortSignal.timeout(15_000),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
if (err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
131
|
+
throw new Error('Request timed out after 15s. The server may be slow — try again.');
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
let body;
|
|
137
|
+
try {
|
|
138
|
+
body = await res.json();
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
body = null;
|
|
142
|
+
}
|
|
143
|
+
throw new ApiError(res.status, res.statusText, body);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// --- Local simulation endpoints ---
|
|
147
|
+
async localSimInit(body) {
|
|
148
|
+
return this.post("/simulation/local/init", body, { timeout: 30_000 });
|
|
149
|
+
}
|
|
150
|
+
async localSimStep(body) {
|
|
151
|
+
return this.post("/simulation/local/step", body, { timeout: 60_000 });
|
|
152
|
+
}
|
|
153
|
+
async localSimRecord(body) {
|
|
154
|
+
return this.post("/simulation/local/record", body, { timeout: 60_000 });
|
|
155
|
+
}
|
|
156
|
+
async localSimMatchFrame(body) {
|
|
157
|
+
return this.post("/simulation/local/match-frame", body, { timeout: 30_000 });
|
|
158
|
+
}
|
|
159
|
+
async localSimScreenshotUpload(body) {
|
|
160
|
+
return this.post("/simulation/local/screenshot/upload", body);
|
|
161
|
+
}
|
|
162
|
+
async handleResponse(resp) {
|
|
163
|
+
if (!resp.ok) {
|
|
164
|
+
let body;
|
|
165
|
+
try {
|
|
166
|
+
body = await resp.json();
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
body = null;
|
|
170
|
+
}
|
|
171
|
+
throw new ApiError(resp.status, resp.statusText, body);
|
|
172
|
+
}
|
|
173
|
+
if (resp.status === 204)
|
|
174
|
+
return undefined;
|
|
175
|
+
return await resp.json();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -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.ishlabs.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,73 @@
|
|
|
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.ishlabs.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.ISH_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.ISH_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 (err) {
|
|
53
|
+
if (err instanceof TypeError || (err instanceof Error && err.message.includes('fetch'))) {
|
|
54
|
+
throw new Error('Could not refresh session (network error). Check your connection or run "ish login".');
|
|
55
|
+
}
|
|
56
|
+
throw new Error('Session expired. Run "ish login" to re-authenticate.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (await verifyToken(accessToken, apiUrl)) {
|
|
60
|
+
return accessToken;
|
|
61
|
+
}
|
|
62
|
+
throw new Error('Saved token is invalid. Run "ish login" to re-authenticate.');
|
|
63
|
+
}
|
|
64
|
+
// 4. Legacy saved token (no refresh token)
|
|
65
|
+
if (config.token) {
|
|
66
|
+
if (await verifyToken(config.token, apiUrl)) {
|
|
67
|
+
return config.token;
|
|
68
|
+
}
|
|
69
|
+
throw new Error('Saved token is invalid. Run "ish login" to re-authenticate.');
|
|
70
|
+
}
|
|
71
|
+
// 5. No token found
|
|
72
|
+
throw new Error('No auth token found. Run "ish login" or set ISH_TOKEN environment variable or pass --token <token>.');
|
|
73
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
verbose: boolean;
|
|
13
|
+
quiet: boolean;
|
|
14
|
+
fields?: string[];
|
|
15
|
+
}
|
|
16
|
+
export declare function getGlobals(cmd: Command): GlobalOpts;
|
|
17
|
+
/**
|
|
18
|
+
* Map errors to semantic exit codes.
|
|
19
|
+
* 0=success, 1=general, 2=usage/validation, 3=auth, 4=not found, 5=transient
|
|
20
|
+
*/
|
|
21
|
+
export declare function exitCodeFromError(err: unknown): number;
|
|
22
|
+
export declare function createClient(globals: GlobalOpts): Promise<ApiClient>;
|
|
23
|
+
export declare function withClient(cmd: Command, fn: (client: ApiClient, globals: GlobalOpts) => Promise<void>): Promise<void>;
|
|
24
|
+
export declare function getWebUrl(globals: GlobalOpts, path: string): string;
|
|
25
|
+
export declare function terminalLink(url: string, text: string): string;
|
|
26
|
+
export declare function readJsonFileOrStdin(filePath?: string): Promise<unknown>;
|
|
27
|
+
export declare function resolveWorkspace(explicit?: string): string;
|
|
28
|
+
export declare function resolveStudy(explicit?: string): string;
|
|
@@ -0,0 +1,131 @@
|
|
|
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, setVerbose, setFields } from "./output.js";
|
|
10
|
+
import { loadConfig } from "../config.js";
|
|
11
|
+
import { resolveId } from "./alias-store.js";
|
|
12
|
+
export function getGlobals(cmd) {
|
|
13
|
+
const opts = cmd.optsWithGlobals();
|
|
14
|
+
// Auto-switch to JSON when stdout is piped (non-TTY)
|
|
15
|
+
const json = opts.json ?? !process.stdout.isTTY;
|
|
16
|
+
// Parse --fields into an array
|
|
17
|
+
const fields = opts.fields
|
|
18
|
+
? String(opts.fields).split(",").map((f) => f.trim()).filter(Boolean)
|
|
19
|
+
: undefined;
|
|
20
|
+
return {
|
|
21
|
+
token: opts.token,
|
|
22
|
+
apiUrl: opts.apiUrl,
|
|
23
|
+
dev: opts.dev,
|
|
24
|
+
json,
|
|
25
|
+
verbose: opts.verbose ?? false,
|
|
26
|
+
quiet: opts.quiet ?? (json && !opts.json), // auto-quiet when auto-json via pipe
|
|
27
|
+
fields,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Map errors to semantic exit codes.
|
|
32
|
+
* 0=success, 1=general, 2=usage/validation, 3=auth, 4=not found, 5=transient
|
|
33
|
+
*/
|
|
34
|
+
export function exitCodeFromError(err) {
|
|
35
|
+
if (err instanceof ApiError) {
|
|
36
|
+
if (err.status === 401 || err.status === 403)
|
|
37
|
+
return 3;
|
|
38
|
+
if (err.status === 404)
|
|
39
|
+
return 4;
|
|
40
|
+
if (err.status === 400 || err.status === 422)
|
|
41
|
+
return 2;
|
|
42
|
+
if (err.retryable)
|
|
43
|
+
return 5;
|
|
44
|
+
}
|
|
45
|
+
// Auth-related client errors (e.g. missing token)
|
|
46
|
+
if (err instanceof Error && /no auth token found|run "ish login"|saved token is invalid|session expired/i.test(err.message))
|
|
47
|
+
return 3;
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
export async function createClient(globals) {
|
|
51
|
+
const apiUrl = resolveApiUrl(globals.apiUrl, globals.dev);
|
|
52
|
+
const token = await resolveToken(globals.token, apiUrl);
|
|
53
|
+
return new ApiClient({ apiUrl, token });
|
|
54
|
+
}
|
|
55
|
+
export async function withClient(cmd, fn) {
|
|
56
|
+
const globals = getGlobals(cmd);
|
|
57
|
+
setVerbose(globals.verbose);
|
|
58
|
+
setFields(globals.fields);
|
|
59
|
+
try {
|
|
60
|
+
const client = await createClient(globals);
|
|
61
|
+
await fn(client, globals);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
outputError(err, globals.json);
|
|
65
|
+
process.exit(exitCodeFromError(err));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function getWebUrl(globals, path) {
|
|
69
|
+
const base = globals.dev ? "http://localhost:3000" : getAppUrl();
|
|
70
|
+
return `${base}${path}`;
|
|
71
|
+
}
|
|
72
|
+
export function terminalLink(url, text) {
|
|
73
|
+
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
74
|
+
}
|
|
75
|
+
export function readJsonFileOrStdin(filePath) {
|
|
76
|
+
if (filePath) {
|
|
77
|
+
let content;
|
|
78
|
+
try {
|
|
79
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
return Promise.reject(new Error(`Cannot read file: ${filePath}`));
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return Promise.resolve(JSON.parse(content));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return Promise.reject(new Error(`Invalid JSON in file: ${filePath}`));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Read from stdin
|
|
92
|
+
if (process.stdin.isTTY) {
|
|
93
|
+
return Promise.reject(new Error("No input provided. Pipe JSON to stdin or use --file <path>."));
|
|
94
|
+
}
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
let data = "";
|
|
97
|
+
process.stdin.setEncoding("utf-8");
|
|
98
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
99
|
+
process.stdin.on("end", () => {
|
|
100
|
+
try {
|
|
101
|
+
resolve(JSON.parse(data));
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
reject(new Error("Invalid JSON from stdin"));
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
process.stdin.on("error", reject);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
export function resolveWorkspace(explicit) {
|
|
111
|
+
if (explicit)
|
|
112
|
+
return resolveId(explicit);
|
|
113
|
+
const env = process.env.ISH_WORKSPACE;
|
|
114
|
+
if (env)
|
|
115
|
+
return resolveId(env);
|
|
116
|
+
const config = loadConfig();
|
|
117
|
+
if (config.workspace)
|
|
118
|
+
return config.workspace;
|
|
119
|
+
throw new Error('No workspace set. Use `ish workspace use <alias>` or pass --workspace.');
|
|
120
|
+
}
|
|
121
|
+
export function resolveStudy(explicit) {
|
|
122
|
+
if (explicit)
|
|
123
|
+
return resolveId(explicit);
|
|
124
|
+
const env = process.env.ISH_STUDY;
|
|
125
|
+
if (env)
|
|
126
|
+
return resolveId(env);
|
|
127
|
+
const config = loadConfig();
|
|
128
|
+
if (config.study)
|
|
129
|
+
return config.study;
|
|
130
|
+
throw new Error('No study set. Use `ish study use <alias>` or pass --study.');
|
|
131
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action executor — resolves elements and executes Playwright actions.
|
|
3
|
+
*
|
|
4
|
+
* Resolution strategy:
|
|
5
|
+
* 1. CDP node resolution (using node_id from tree data)
|
|
6
|
+
* 2. Playwright locator fallback (using element_name + element_type)
|
|
7
|
+
* 3. Coordinate fallback (if returned by backend)
|
|
8
|
+
*/
|
|
9
|
+
import type { Page } from "playwright-core";
|
|
10
|
+
import type { LocalStepAction, ActionResult, ContextValue, TreeData } from "./types.js";
|
|
11
|
+
/**
|
|
12
|
+
* Execute a single action on the page.
|
|
13
|
+
*/
|
|
14
|
+
export declare function executeAction(page: Page, action: LocalStepAction, treeData: TreeData, contextValues: ContextValue[]): Promise<ActionResult>;
|
|
15
|
+
/**
|
|
16
|
+
* Compare two base64 screenshots to detect visible change.
|
|
17
|
+
*/
|
|
18
|
+
export declare function detectNoVisibleChange(before: string, after: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Build a human-readable action description matching backend's format_action_detail().
|
|
21
|
+
*/
|
|
22
|
+
export declare function describeAction(action: LocalStepAction): string;
|