@ishlabs/cli 0.27.0 → 0.28.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 +4 -0
- package/dist/commands/doctor.js +21 -11
- package/dist/commands/feedback.d.ts +22 -0
- package/dist/commands/feedback.js +259 -0
- package/dist/commands/iteration.js +13 -4
- package/dist/commands/study-run.js +12 -12
- package/dist/commands/study-screenshots.js +15 -12
- package/dist/commands/study.js +22 -3
- package/dist/commands/workspace.js +1 -1
- package/dist/index.js +2 -0
- package/dist/lib/command-helpers.js +7 -3
- package/dist/lib/docs.js +238 -19
- package/dist/lib/local-sim/actions.d.ts +18 -2
- package/dist/lib/local-sim/actions.js +24 -1
- package/dist/lib/local-sim/adb.d.ts +19 -2
- package/dist/lib/local-sim/adb.js +71 -23
- package/dist/lib/local-sim/android.js +7 -2
- package/dist/lib/local-sim/device-pool.d.ts +85 -0
- package/dist/lib/local-sim/device-pool.js +316 -0
- package/dist/lib/local-sim/device.d.ts +4 -0
- package/dist/lib/local-sim/device.js +19 -1
- package/dist/lib/local-sim/emulator.d.ts +50 -0
- package/dist/lib/local-sim/emulator.js +189 -0
- package/dist/lib/local-sim/install.js +23 -3
- package/dist/lib/local-sim/ios.d.ts +26 -1
- package/dist/lib/local-sim/ios.js +61 -17
- package/dist/lib/local-sim/loop.js +117 -11
- package/dist/lib/local-sim/screen-signature.js +4 -0
- package/dist/lib/local-sim/simctl-provision.d.ts +49 -0
- package/dist/lib/local-sim/simctl-provision.js +89 -0
- package/dist/lib/local-sim/simctl.d.ts +6 -4
- package/dist/lib/local-sim/simctl.js +18 -5
- package/dist/lib/local-sim/types.d.ts +27 -1
- package/dist/lib/local-sim/xcuitest.d.ts +39 -1
- package/dist/lib/local-sim/xcuitest.js +70 -6
- package/dist/lib/output.d.ts +11 -1
- package/dist/lib/output.js +56 -2
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +3 -0
- package/dist/lib/skill-content.js +14 -2
- package/dist/lib/upload.d.ts +27 -0
- package/dist/lib/upload.js +108 -11
- package/package.json +2 -2
|
@@ -27,7 +27,7 @@ export interface LocalSimInitResponse {
|
|
|
27
27
|
participant_background: Record<string, unknown> | null;
|
|
28
28
|
participant_language: string | null;
|
|
29
29
|
config: Record<string, unknown>;
|
|
30
|
-
context_values:
|
|
30
|
+
context_values: WireContextValue[];
|
|
31
31
|
max_interactions: number;
|
|
32
32
|
agent_model: string;
|
|
33
33
|
dom_model: string;
|
|
@@ -40,6 +40,32 @@ export interface LocalSimAssignment {
|
|
|
40
40
|
instructions: string;
|
|
41
41
|
sequence: number;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Wire shape of a context value exactly as the backend serializes it
|
|
45
|
+
* (ish-backend `app/runs/values.py` `ContextValue` → `/simulation/local/init`):
|
|
46
|
+
* the identifier field is `key` (NOT `name`), and `requires_resolution` is the
|
|
47
|
+
* secret-vs-variable discriminator (NO `type` field). The CLI normalizes this
|
|
48
|
+
* into the internal {@link ContextValue} shape at ingestion via
|
|
49
|
+
* {@link normalizeContextValues}.
|
|
50
|
+
*
|
|
51
|
+
* History: the internal shape below has used `name`/`type` since v0.7.0 while the
|
|
52
|
+
* backend has always sent `key`/`requires_resolution`. Because the two were
|
|
53
|
+
* assumed identical and consumed without mapping, every `resolveTextValue`
|
|
54
|
+
* lookup (`find(v => v.name === action.value)`) silently missed and the agent
|
|
55
|
+
* typed the literal key (e.g. `LOGIN_USERNAME`) into the field on every
|
|
56
|
+
* platform. Keep this interface faithful to the wire so the mismatch can't
|
|
57
|
+
* recur unnoticed.
|
|
58
|
+
*/
|
|
59
|
+
export interface WireContextValue {
|
|
60
|
+
key: string;
|
|
61
|
+
description?: string | null;
|
|
62
|
+
value: string | null;
|
|
63
|
+
requires_resolution: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Internal context-value shape used by the executors / {@link resolveTextValue}.
|
|
67
|
+
* Produced from {@link WireContextValue} by {@link normalizeContextValues}.
|
|
68
|
+
*/
|
|
43
69
|
export interface ContextValue {
|
|
44
70
|
name: string;
|
|
45
71
|
type: "var" | "secret";
|
|
@@ -18,6 +18,19 @@
|
|
|
18
18
|
* and reuses an already-running runner.
|
|
19
19
|
*/
|
|
20
20
|
import { type IosScreen } from "./simctl.js";
|
|
21
|
+
/**
|
|
22
|
+
* The Return keystroke we feed to WDA's `/wda/keys` for a submit.
|
|
23
|
+
*
|
|
24
|
+
* NOT the W3C ENTER code (U+E007): WDA's `/wda/keys` does NOT interpret the
|
|
25
|
+
* WebDriver special-key PUA codepoints \u2014 it types them LITERALLY. On the
|
|
26
|
+
* simulator U+E007 renders as a running-shoe emoji, so a submit appended a
|
|
27
|
+
* stray `\uD83D\uDC5F` to the field (e.g. a search for `Photos` became `Photos\uD83D\uDC5F`,
|
|
28
|
+
* returning no results and derailing the run) AND never actually submitted.
|
|
29
|
+
* A plain newline is what WDA's keyboard treats as Return \u2014 it submits
|
|
30
|
+
* single-line fields and inserts a line break in multiline ones, with no glyph.
|
|
31
|
+
* Verified on a booted iOS 18 simulator (Settings search).
|
|
32
|
+
*/
|
|
33
|
+
export declare const WDA_RETURN = "\n";
|
|
21
34
|
interface Session {
|
|
22
35
|
port: number;
|
|
23
36
|
baseUrl: string;
|
|
@@ -38,6 +51,7 @@ export declare function resolveWdaBundle(): Promise<string>;
|
|
|
38
51
|
*/
|
|
39
52
|
export declare function ensureWda(udid: string, opts?: {
|
|
40
53
|
bundleId?: string;
|
|
54
|
+
port?: number;
|
|
41
55
|
}): Promise<Session>;
|
|
42
56
|
/** Tear down the WDA session for `udid` (the runner is left for the next run). */
|
|
43
57
|
export declare function closeWda(udid: string): Promise<void>;
|
|
@@ -59,9 +73,33 @@ export declare function uiSwipe(udid: string, x1: number, y1: number, x2: number
|
|
|
59
73
|
export declare function uiText(udid: string, text: string): Promise<void>;
|
|
60
74
|
/**
|
|
61
75
|
* Press a key. Only the idb HID Return keycode (40) is used by ios.ts today;
|
|
62
|
-
* map it to
|
|
76
|
+
* map it to a newline (see WDA_RETURN). Unknown codes are a no-op-safe error.
|
|
63
77
|
*/
|
|
64
78
|
export declare function uiKey(udid: string, keycode: number): Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* Extract the element id from a WDA active-element payload, accepting both the
|
|
81
|
+
* W3C key and the legacy `ELEMENT` alias. Pure + exported for unit testing the
|
|
82
|
+
* (fiddly) key handling without a live runner. Returns null when the shape has
|
|
83
|
+
* no usable id (nothing focused / non-element response).
|
|
84
|
+
*/
|
|
85
|
+
export declare function parseActiveElementId(value: unknown): string | null;
|
|
86
|
+
/**
|
|
87
|
+
* Clear the currently-focused text field via WDA's native element clear — the
|
|
88
|
+
* same select-all+delete Appium's `clear()` performs, so it handles multiline
|
|
89
|
+
* and arbitrary cursor positions correctly.
|
|
90
|
+
*
|
|
91
|
+
* Why this exists: `uiText` posts to `/wda/keys`, which APPENDS to the focused
|
|
92
|
+
* field. Without a clear, re-typing a field (a retry, or a field the app
|
|
93
|
+
* pre-filled) accumulates text (e.g. `LOGIN_USERNAMELOGIN_USERNAME…`). The
|
|
94
|
+
* coordinate/vision typing path has no element uuid up front, so we resolve the
|
|
95
|
+
* focused element with `GET …/element/active`, then `POST …/element/:uuid/clear`.
|
|
96
|
+
*
|
|
97
|
+
* Best-effort by design: a non-editable focus (or any WDA hiccup) just returns
|
|
98
|
+
* false and the caller falls back to the prior append behavior — clearing is an
|
|
99
|
+
* improvement, never a hard precondition for typing. Returns true when a clear
|
|
100
|
+
* was actually issued.
|
|
101
|
+
*/
|
|
102
|
+
export declare function uiClearActiveField(udid: string): Promise<boolean>;
|
|
65
103
|
/** Re-export so a future ios.ts can drop the simctl HID constant. */
|
|
66
104
|
export declare const HID_KEY_RETURN = 40;
|
|
67
105
|
export {};
|
|
@@ -31,8 +31,19 @@ const WDA_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
|
|
|
31
31
|
const DEFAULT_PORT = Number(process.env.ISH_WDA_PORT) || 8100;
|
|
32
32
|
/** WDA's XCTest runtime cold-starts slowly; poll /status up to this long. */
|
|
33
33
|
const STARTUP_TIMEOUT_MS = 75_000;
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The Return keystroke we feed to WDA's `/wda/keys` for a submit.
|
|
36
|
+
*
|
|
37
|
+
* NOT the W3C ENTER code (U+E007): WDA's `/wda/keys` does NOT interpret the
|
|
38
|
+
* WebDriver special-key PUA codepoints \u2014 it types them LITERALLY. On the
|
|
39
|
+
* simulator U+E007 renders as a running-shoe emoji, so a submit appended a
|
|
40
|
+
* stray `\uD83D\uDC5F` to the field (e.g. a search for `Photos` became `Photos\uD83D\uDC5F`,
|
|
41
|
+
* returning no results and derailing the run) AND never actually submitted.
|
|
42
|
+
* A plain newline is what WDA's keyboard treats as Return \u2014 it submits
|
|
43
|
+
* single-line fields and inserts a line break in multiline ones, with no glyph.
|
|
44
|
+
* Verified on a booted iOS 18 simulator (Settings search).
|
|
45
|
+
*/
|
|
46
|
+
export const WDA_RETURN = "\n";
|
|
36
47
|
const sessions = new Map();
|
|
37
48
|
// ── WDA bundle resolution (fetch is wired in the distribution phase) ──────────
|
|
38
49
|
/** Appium's prebuilt WebDriverAgent simulator release we fetch + pin. */
|
|
@@ -178,7 +189,10 @@ export async function ensureWda(udid, opts = {}) {
|
|
|
178
189
|
const existing = sessions.get(udid);
|
|
179
190
|
if (existing && (await statusOk(existing.port)))
|
|
180
191
|
return existing;
|
|
181
|
-
|
|
192
|
+
// Each device gets its own WDA runner on its own port so N pooled simulators
|
|
193
|
+
// don't collide on 8100. The pool allocates the port and passes it in; the
|
|
194
|
+
// single-device path falls back to DEFAULT_PORT (8100 / $ISH_WDA_PORT).
|
|
195
|
+
const port = opts.port ?? DEFAULT_PORT;
|
|
182
196
|
if (!(await statusOk(port))) {
|
|
183
197
|
const app = await resolveWdaBundle();
|
|
184
198
|
await simctlRun(["install", udid, app]);
|
|
@@ -211,7 +225,9 @@ async function getSession(udid) {
|
|
|
211
225
|
const s = sessions.get(udid);
|
|
212
226
|
if (s && (await statusOk(s.port)))
|
|
213
227
|
return s;
|
|
214
|
-
|
|
228
|
+
// Re-ensure on the SAME port if this device had one (a WDA restart mid-run
|
|
229
|
+
// must not migrate a pooled device back to DEFAULT_PORT).
|
|
230
|
+
return ensureWda(udid, s ? { port: s.port } : {});
|
|
215
231
|
}
|
|
216
232
|
/** Tear down the WDA session for `udid` (the runner is left for the next run). */
|
|
217
233
|
export async function closeWda(udid) {
|
|
@@ -307,13 +323,61 @@ export async function uiText(udid, text) {
|
|
|
307
323
|
}
|
|
308
324
|
/**
|
|
309
325
|
* Press a key. Only the idb HID Return keycode (40) is used by ios.ts today;
|
|
310
|
-
* map it to
|
|
326
|
+
* map it to a newline (see WDA_RETURN). Unknown codes are a no-op-safe error.
|
|
311
327
|
*/
|
|
312
328
|
export async function uiKey(udid, keycode) {
|
|
313
329
|
if (keycode !== 40)
|
|
314
330
|
throw new IosError(`unsupported WDA keycode: ${keycode}`);
|
|
315
331
|
const s = await getSession(udid);
|
|
316
|
-
await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [
|
|
332
|
+
await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [WDA_RETURN] });
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The W3C WebDriver element-reference key. `GET …/element/active` returns the
|
|
336
|
+
* focused element under this key (WDA also historically mirrors it as `ELEMENT`).
|
|
337
|
+
*/
|
|
338
|
+
const W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
|
|
339
|
+
/**
|
|
340
|
+
* Extract the element id from a WDA active-element payload, accepting both the
|
|
341
|
+
* W3C key and the legacy `ELEMENT` alias. Pure + exported for unit testing the
|
|
342
|
+
* (fiddly) key handling without a live runner. Returns null when the shape has
|
|
343
|
+
* no usable id (nothing focused / non-element response).
|
|
344
|
+
*/
|
|
345
|
+
export function parseActiveElementId(value) {
|
|
346
|
+
if (!value || typeof value !== "object")
|
|
347
|
+
return null;
|
|
348
|
+
const obj = value;
|
|
349
|
+
const id = obj[W3C_ELEMENT_KEY] ?? obj.ELEMENT;
|
|
350
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Clear the currently-focused text field via WDA's native element clear — the
|
|
354
|
+
* same select-all+delete Appium's `clear()` performs, so it handles multiline
|
|
355
|
+
* and arbitrary cursor positions correctly.
|
|
356
|
+
*
|
|
357
|
+
* Why this exists: `uiText` posts to `/wda/keys`, which APPENDS to the focused
|
|
358
|
+
* field. Without a clear, re-typing a field (a retry, or a field the app
|
|
359
|
+
* pre-filled) accumulates text (e.g. `LOGIN_USERNAMELOGIN_USERNAME…`). The
|
|
360
|
+
* coordinate/vision typing path has no element uuid up front, so we resolve the
|
|
361
|
+
* focused element with `GET …/element/active`, then `POST …/element/:uuid/clear`.
|
|
362
|
+
*
|
|
363
|
+
* Best-effort by design: a non-editable focus (or any WDA hiccup) just returns
|
|
364
|
+
* false and the caller falls back to the prior append behavior — clearing is an
|
|
365
|
+
* improvement, never a hard precondition for typing. Returns true when a clear
|
|
366
|
+
* was actually issued.
|
|
367
|
+
*/
|
|
368
|
+
export async function uiClearActiveField(udid) {
|
|
369
|
+
try {
|
|
370
|
+
const s = await getSession(udid);
|
|
371
|
+
const active = unwrap(await wdaCall(s.port, "GET", `/session/${s.sessionId}/element/active`));
|
|
372
|
+
const uuid = parseActiveElementId(active);
|
|
373
|
+
if (!uuid)
|
|
374
|
+
return false;
|
|
375
|
+
await wdaCall(s.port, "POST", `/session/${s.sessionId}/element/${uuid}/clear`, {});
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
317
381
|
}
|
|
318
382
|
/** Re-export so a future ios.ts can drop the simctl HID constant. */
|
|
319
383
|
export const HID_KEY_RETURN = 40;
|
package/dist/lib/output.d.ts
CHANGED
|
@@ -46,7 +46,17 @@ export declare class ValidationError extends Error {
|
|
|
46
46
|
hint?: string | undefined;
|
|
47
47
|
constructor(message: string, valid_options: string[], hint?: string | undefined);
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Whether an error is worth nudging the user to report via `ish feedback`.
|
|
51
|
+
* Excludes user-actionable failures — usage/validation, auth (login),
|
|
52
|
+
* not-found, and usage-limit (upgrade) — so the hint stays high-signal and
|
|
53
|
+
* agents don't reflexively report their own input mistakes. Genuine faults
|
|
54
|
+
* (5xx, unexpected client errors, unknown throws) get the nudge.
|
|
55
|
+
*/
|
|
56
|
+
export declare function shouldSuggestReport(err: unknown): boolean;
|
|
57
|
+
export declare function outputError(err: unknown, json: boolean, opts?: {
|
|
58
|
+
suggestReport?: boolean;
|
|
59
|
+
}): void;
|
|
50
60
|
export declare function printTable(headers: string[], rows: string[][]): void;
|
|
51
61
|
export declare function printKeyValue(obj: Record<string, unknown>, indent?: string): void;
|
|
52
62
|
export declare function formatWorkspaceList(workspaces: Record<string, unknown>[], json: boolean): void;
|
package/dist/lib/output.js
CHANGED
|
@@ -562,8 +562,48 @@ function remapEntityName(message) {
|
|
|
562
562
|
.replace(/\bAttachment not found\b/g, "Source not found")
|
|
563
563
|
.replace(/\battachment not found\b/g, "source not found");
|
|
564
564
|
}
|
|
565
|
-
|
|
565
|
+
// Shown after a genuine fault so the operating agent (the CLI's primary user)
|
|
566
|
+
// knows it can hand the failure straight to the ish team. Kept short.
|
|
567
|
+
const REPORT_HINT_HUMAN = 'Looks like a bug? Report it: ish feedback "what you were trying to do" (add --health for setup/sim issues)';
|
|
568
|
+
const REPORT_HINT_JSON = 'ish feedback "<what you were trying to do>" — add --health for setup/sim issues';
|
|
569
|
+
/**
|
|
570
|
+
* Whether an error is worth nudging the user to report via `ish feedback`.
|
|
571
|
+
* Excludes user-actionable failures — usage/validation, auth (login),
|
|
572
|
+
* not-found, and usage-limit (upgrade) — so the hint stays high-signal and
|
|
573
|
+
* agents don't reflexively report their own input mistakes. Genuine faults
|
|
574
|
+
* (5xx, unexpected client errors, unknown throws) get the nudge.
|
|
575
|
+
*/
|
|
576
|
+
export function shouldSuggestReport(err) {
|
|
577
|
+
if (err instanceof ValidationError)
|
|
578
|
+
return false;
|
|
579
|
+
if (err instanceof ApiError) {
|
|
580
|
+
if (err.status === 400 ||
|
|
581
|
+
err.status === 401 ||
|
|
582
|
+
err.status === 402 || // insufficient credits — buy credits, not a bug
|
|
583
|
+
err.status === 403 ||
|
|
584
|
+
err.status === 404 ||
|
|
585
|
+
err.status === 422) {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
if (err.error_code === "usage_limit_reached")
|
|
589
|
+
return false;
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
if (err instanceof Error) {
|
|
593
|
+
const tagged = err;
|
|
594
|
+
if (err.name === "ValidationError")
|
|
595
|
+
return false;
|
|
596
|
+
if (tagged.error_code === "auth_failed")
|
|
597
|
+
return false;
|
|
598
|
+
if (tagged.error_kind === "ConfirmationRequired")
|
|
599
|
+
return false;
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
export function outputError(err, json, opts = {}) {
|
|
566
605
|
const suggestions = suggestionsForError(err);
|
|
606
|
+
const report = opts.suggestReport === true;
|
|
567
607
|
if (err instanceof ApiError) {
|
|
568
608
|
// Surface backend-structured fields when present in the response body
|
|
569
609
|
// (e.g. 422 errors return `errors: [{loc, msg, type, input, allowed_values}]`,
|
|
@@ -621,6 +661,7 @@ export function outputError(err, json) {
|
|
|
621
661
|
...(seededAliases && { seeded_but_not_dispatched_aliases: seededAliases }),
|
|
622
662
|
...(bodyErrors !== undefined && { errors: bodyErrors }),
|
|
623
663
|
...(mergedSuggestions.length > 0 && { suggestions: mergedSuggestions }),
|
|
664
|
+
...(report && { report: REPORT_HINT_JSON }),
|
|
624
665
|
}));
|
|
625
666
|
}
|
|
626
667
|
else {
|
|
@@ -724,6 +765,7 @@ export function outputError(err, json) {
|
|
|
724
765
|
...(seededIds && { seeded_but_not_dispatched_ids: seededIds }),
|
|
725
766
|
...(seededAliases && { seeded_but_not_dispatched_aliases: seededAliases }),
|
|
726
767
|
...(mergedSuggestions.length > 0 && { suggestions: mergedSuggestions }),
|
|
768
|
+
...(report && { report: REPORT_HINT_JSON }),
|
|
727
769
|
}));
|
|
728
770
|
}
|
|
729
771
|
else {
|
|
@@ -736,12 +778,24 @@ export function outputError(err, json) {
|
|
|
736
778
|
}
|
|
737
779
|
else {
|
|
738
780
|
if (json) {
|
|
739
|
-
console.error(JSON.stringify({
|
|
781
|
+
console.error(JSON.stringify({
|
|
782
|
+
error: String(err),
|
|
783
|
+
error_code: "unknown_error",
|
|
784
|
+
retryable: false,
|
|
785
|
+
...(report && { report: REPORT_HINT_JSON }),
|
|
786
|
+
}));
|
|
740
787
|
}
|
|
741
788
|
else {
|
|
742
789
|
console.error(`Error: ${err}`);
|
|
743
790
|
}
|
|
744
791
|
}
|
|
792
|
+
// A single human-mode nudge for genuine faults (gated by the caller via
|
|
793
|
+
// shouldSuggestReport). The JSON branches above carry the same hint as a
|
|
794
|
+
// `report` field. ValidationError's own JSON branch intentionally omits it
|
|
795
|
+
// (usage errors aren't bugs); the caller never sets suggestReport there.
|
|
796
|
+
if (!json && report) {
|
|
797
|
+
console.error(` ${c.dim}→ ${REPORT_HINT_HUMAN}${c.reset}`);
|
|
798
|
+
}
|
|
745
799
|
}
|
|
746
800
|
// --- Entity-specific formatters (human mode) ---
|
|
747
801
|
export function printTable(headers, rows) {
|
package/dist/lib/paths.d.ts
CHANGED
package/dist/lib/paths.js
CHANGED
|
@@ -230,8 +230,11 @@ To hand a study to someone **without an ish account** — a prospect, a stakehol
|
|
|
230
230
|
- **\`ish person create\` accepts inline flags** (mirrors \`person update\`): the file-only API (\`--file <path>\`) is preserved as an escape hatch but the common path is \`ish person create --name "X" --type ai --country US ...\` — \`--type\` defaults to \`ai\` when \`--file\` is omitted. See \`ish person create --help\` for the full inline-flag set including \`--household\` (MECE rule applies) and \`--accessibility-profile\`.
|
|
231
231
|
- **\`ish status\` now surfaces \`chat_endpoint\`** alongside \`workspace\`/\`study\`/\`ask\`. Stale or orphan active refs get a \`warning\` + \`hint\` field on the affected ref (instead of silently dropping the \`name\`). On \`workspace use <other>\`, the CLI cascade-clears \`study\`/\`ask\`/\`chat_endpoint\` (they belong to the previous workspace).
|
|
232
232
|
- **Share link URL host ≠ API host**: \`ish study share\` prints the backend-built \`share_url\` (the web frontend host). Use it verbatim — never reconstruct the URL from the API host or app URL; they differ. \`ish study unshare\` takes the **raw token** (from \`study share\` / \`study share --list\`), not a study id or alias.
|
|
233
|
-
- **Native app iterations (ios/android) name the app, not a URL**: \`ish iteration create --platform ios --app <bundle-id>\` stores the target as \`app_artifact\` (no URL). The iteration remembers it, so \`ish study run --local\` needs **no \`--app\` on reruns** (it defaults from the iteration). Pass \`--app <path-to.app|.apk>\` only to override with a fresh local build. \`--app\` is optional at create time (omit it for "chosen at run time"). Only \`browser\`/\`figma\` iterations require \`--url\`.
|
|
234
|
-
- **
|
|
233
|
+
- **Native app iterations (ios/android) name the app, not a URL**: \`ish iteration create --platform ios --app <bundle-id>\` stores the target as \`app_artifact\` (no URL). \`screen_format\` defaults to **mobile_portrait** for native (vs desktop for browser). The iteration remembers it, so \`ish study run --local\` needs **no \`--app\` on reruns** (it defaults from the iteration). Pass \`--app <path-to.app|.apk>\` only to override with a fresh local build. \`--app\` is optional at create time (omit it for "chosen at run time"). Only \`browser\`/\`figma\` iterations require \`--url\`. Full walkthrough: \`ish docs get-page guides/native-app\`.
|
|
234
|
+
- **Native runs reset state per participant only with a local .app**: with a local \`.app\`/\`.apk\` the runner uninstall+reinstalls before each participant (no state leak). A bare bundle-id / system app (e.g. \`com.apple.reminders\`) can't be reinstalled — it relaunches and warns once that earlier-participant state may persist; pass \`--app <.app>\` or run one participant per study for a clean start.
|
|
235
|
+
- **Parallel native runs**: \`ish study run --local --platform ios|android --parallel N\` drives a **pool of N devices** (iOS: reuses booted simulators + auto-creates the shortfall; Android: reuses online emulators + auto-launches headless emulators from your AVDs), one participant per device, and tears down only what it started. N auto-sizes to host RAM; default 1, max 5 — small machines run fewer + queue, never error. Android needs just **one AVD** (the pool clones it via file-copy — no JDK — and deletes the clones). Browser \`--parallel\` is unchanged.
|
|
236
|
+
- **Local runs still capture per-interaction screenshots**: \`ish study run --local\` (including ios/android) does NOT populate the remote frame-grouped index (\`ish study screenshots list\` reads that and won't show local frames), but per-interaction screenshots ARE captured — read them via \`ish study get <id>\` (each interaction carries \`screenshot_url\`) or the per-step HTML debug report at \`~/.ish/debug/sim-*.html\` (path printed at the end of the run).
|
|
237
|
+
- **\`<entity> use --json\` is capturable**: \`study use\`/\`workspace use\`/\`ask use\` print the human confirmation to stderr and an \`{id, alias, name, active}\` object to stdout under \`--json\`, so \`ish study use s-… --json --get alias\` works.
|
|
235
238
|
|
|
236
239
|
## When in doubt
|
|
237
240
|
|
|
@@ -1140,6 +1143,14 @@ table, projection shapes, and the defensive null-handling rules.
|
|
|
1140
1143
|
confirmed. The orphan-tunnel-on-startup-404 bug is fixed.
|
|
1141
1144
|
- The \`Warning: Could not verify token (network error). Proceeding
|
|
1142
1145
|
anyway.\` stderr line is gone on green runs.
|
|
1146
|
+
- On a **genuine fault** (uncategorized client error, 5xx/\`server\`,
|
|
1147
|
+
network, unknown throw) the error envelope adds a \`report\` field and
|
|
1148
|
+
human mode prints a \`→ Looks like a bug? Report it: …\` line. File it
|
|
1149
|
+
with \`ish feedback "what you were doing"\` (add \`--health\` for
|
|
1150
|
+
local/native-run bugs). The nudge is deliberately silent on
|
|
1151
|
+
user-actionable errors (usage, validation, auth, not-found,
|
|
1152
|
+
usage-limit), so when you see it, it's worth reporting. See
|
|
1153
|
+
guides/feedback.
|
|
1143
1154
|
|
|
1144
1155
|
## Common reshaping → use the CLI, not jq/python
|
|
1145
1156
|
|
|
@@ -1208,6 +1219,7 @@ ish <command> --help
|
|
|
1208
1219
|
| | study, ask, token validity) — alias \`whoami\` | |
|
|
1209
1220
|
| \`connect\` | Cloudflare tunnel exposing localhost | — |
|
|
1210
1221
|
| \`upgrade\` | Self-update | — |
|
|
1222
|
+
| \`feedback\` | Report a bug / feature request / note to the ish team. \`--health\` attaches setup checks + local-sim logs. | guides/feedback |
|
|
1211
1223
|
|
|
1212
1224
|
## Discovering flags safely
|
|
1213
1225
|
|
package/dist/lib/upload.d.ts
CHANGED
|
@@ -16,6 +16,14 @@ export declare function validateFile(filePath: string): Promise<{
|
|
|
16
16
|
size: number;
|
|
17
17
|
mime: string;
|
|
18
18
|
}>;
|
|
19
|
+
/**
|
|
20
|
+
* Upload raw bytes to Supabase Storage via the backend's signed URL flow.
|
|
21
|
+
* Returns the public content_url. The bytes-level core shared by file uploads
|
|
22
|
+
* and HTML image archiving (no temp file needed).
|
|
23
|
+
*/
|
|
24
|
+
export declare function uploadStudyContentBytes(client: ApiClient, studyId: string, data: Buffer, mime: string, name: string, opts?: {
|
|
25
|
+
quiet?: boolean;
|
|
26
|
+
}): Promise<string>;
|
|
19
27
|
/**
|
|
20
28
|
* Upload a local file to Supabase Storage via the backend's signed URL flow.
|
|
21
29
|
* Returns the public content_url for use in iteration details.
|
|
@@ -40,6 +48,25 @@ export declare function resolveContentUrls(client: ApiClient, studyId: string, c
|
|
|
40
48
|
mimeTypeOverride?: string;
|
|
41
49
|
quiet?: boolean;
|
|
42
50
|
}): Promise<string[]>;
|
|
51
|
+
/**
|
|
52
|
+
* Archive every external `<img>` in a text iteration's `content_html` onto the
|
|
53
|
+
* workspace storage origin, rewriting each `src` to the uploaded URL.
|
|
54
|
+
*
|
|
55
|
+
* Why: the render-to-image worker that lets a participant SEE the page
|
|
56
|
+
* default-denies egress to every origin except workspace storage (SSRF guard).
|
|
57
|
+
* An `<img>` pointing at the open web is therefore aborted and renders broken.
|
|
58
|
+
* The frontend's paste pipeline (`cacheHtmlAssets`) already archives images;
|
|
59
|
+
* this is the CLI-side equivalent so `ish iteration create --content-html`
|
|
60
|
+
* produces content whose images actually render. It is a focused subset (just
|
|
61
|
+
* `<img src>` via regex — the CLI has no HTML-parser dependency); inline
|
|
62
|
+
* `data:` images, relative paths, and non-image responses are left untouched.
|
|
63
|
+
*
|
|
64
|
+
* Best-effort: a single image that cannot be fetched/uploaded is left as-is
|
|
65
|
+
* with a warning rather than failing the whole create.
|
|
66
|
+
*/
|
|
67
|
+
export declare function archiveHtmlImages(client: ApiClient, studyId: string, html: string, opts?: {
|
|
68
|
+
quiet?: boolean;
|
|
69
|
+
}): Promise<string>;
|
|
43
70
|
/**
|
|
44
71
|
* Resolve text content. If the value starts with '@', read the file at
|
|
45
72
|
* the path that follows (curl-style convention). Otherwise return as-is.
|
package/dist/lib/upload.js
CHANGED
|
@@ -96,29 +96,30 @@ export async function validateFile(filePath) {
|
|
|
96
96
|
// Core upload: 3-step backend-mediated flow
|
|
97
97
|
// ---------------------------------------------------------------------------
|
|
98
98
|
/**
|
|
99
|
-
* Upload
|
|
100
|
-
* Returns the public content_url
|
|
99
|
+
* Upload raw bytes to Supabase Storage via the backend's signed URL flow.
|
|
100
|
+
* Returns the public content_url. The bytes-level core shared by file uploads
|
|
101
|
+
* and HTML image archiving (no temp file needed).
|
|
101
102
|
*/
|
|
102
|
-
export async function
|
|
103
|
-
const
|
|
104
|
-
const { size, mime: detectedMime } = await validateFile(filePath);
|
|
105
|
-
const mime = opts?.mimeTypeOverride || detectedMime;
|
|
106
|
-
const name = basename(filePath);
|
|
103
|
+
export async function uploadStudyContentBytes(client, studyId, data, mime, name, opts) {
|
|
104
|
+
const size = data.byteLength;
|
|
107
105
|
const sizeMB = (size / (1024 * 1024)).toFixed(1);
|
|
108
106
|
const log = (msg) => { if (!opts?.quiet)
|
|
109
107
|
process.stderr.write(msg); };
|
|
110
108
|
// Step 1: Request a signed upload URL from the backend
|
|
111
109
|
log(`Uploading ${name} (${sizeMB} MB)...`);
|
|
112
110
|
const uploadResp = await client.post(`/studies/${studyId}/content/upload`, { content_type: mime, file_size_bytes: size }, { timeout: 60_000 });
|
|
113
|
-
// Step 2: PUT the raw
|
|
114
|
-
const fileBuffer = await readFile(resolved);
|
|
111
|
+
// Step 2: PUT the raw bytes to the signed URL
|
|
115
112
|
const putResp = await fetch(uploadResp.upload_info.signed_upload_url, {
|
|
116
113
|
method: "PUT",
|
|
117
114
|
headers: {
|
|
118
115
|
"Content-Type": mime,
|
|
119
|
-
"Content-Length": String(
|
|
116
|
+
"Content-Length": String(size),
|
|
120
117
|
},
|
|
121
|
-
|
|
118
|
+
// Zero-copy view as Uint8Array<ArrayBuffer>: a Node Buffer's generic
|
|
119
|
+
// ArrayBufferLike (which includes SharedArrayBuffer) is not a valid fetch
|
|
120
|
+
// BodyInit, but a plain-ArrayBuffer-backed view is. Node buffers are never
|
|
121
|
+
// SharedArrayBuffer-backed, so the cast is safe.
|
|
122
|
+
body: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
|
122
123
|
signal: AbortSignal.timeout(300_000), // 5 min timeout for large files
|
|
123
124
|
});
|
|
124
125
|
if (!putResp.ok) {
|
|
@@ -133,6 +134,17 @@ export async function uploadStudyContent(client, studyId, filePath, opts) {
|
|
|
133
134
|
log(" done.\n");
|
|
134
135
|
return uploadResp.content_url;
|
|
135
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Upload a local file to Supabase Storage via the backend's signed URL flow.
|
|
139
|
+
* Returns the public content_url for use in iteration details.
|
|
140
|
+
*/
|
|
141
|
+
export async function uploadStudyContent(client, studyId, filePath, opts) {
|
|
142
|
+
const resolved = resolvePath(filePath);
|
|
143
|
+
const { mime: detectedMime } = await validateFile(filePath);
|
|
144
|
+
const mime = opts?.mimeTypeOverride || detectedMime;
|
|
145
|
+
const fileBuffer = await readFile(resolved);
|
|
146
|
+
return uploadStudyContentBytes(client, studyId, fileBuffer, mime, basename(filePath), { quiet: opts?.quiet });
|
|
147
|
+
}
|
|
136
148
|
// ---------------------------------------------------------------------------
|
|
137
149
|
// High-level resolvers (URL passthrough or upload)
|
|
138
150
|
// ---------------------------------------------------------------------------
|
|
@@ -157,6 +169,91 @@ export async function resolveContentUrls(client, studyId, commaSeparated, opts)
|
|
|
157
169
|
}
|
|
158
170
|
return results;
|
|
159
171
|
}
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// HTML image archiving (text modality)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
const MAX_ARCHIVED_IMAGE_BYTES = 15 * 1024 * 1024; // 15 MB per image
|
|
176
|
+
const IMAGE_FETCH_TIMEOUT_MS = 15_000;
|
|
177
|
+
/**
|
|
178
|
+
* Archive every external `<img>` in a text iteration's `content_html` onto the
|
|
179
|
+
* workspace storage origin, rewriting each `src` to the uploaded URL.
|
|
180
|
+
*
|
|
181
|
+
* Why: the render-to-image worker that lets a participant SEE the page
|
|
182
|
+
* default-denies egress to every origin except workspace storage (SSRF guard).
|
|
183
|
+
* An `<img>` pointing at the open web is therefore aborted and renders broken.
|
|
184
|
+
* The frontend's paste pipeline (`cacheHtmlAssets`) already archives images;
|
|
185
|
+
* this is the CLI-side equivalent so `ish iteration create --content-html`
|
|
186
|
+
* produces content whose images actually render. It is a focused subset (just
|
|
187
|
+
* `<img src>` via regex — the CLI has no HTML-parser dependency); inline
|
|
188
|
+
* `data:` images, relative paths, and non-image responses are left untouched.
|
|
189
|
+
*
|
|
190
|
+
* Best-effort: a single image that cannot be fetched/uploaded is left as-is
|
|
191
|
+
* with a warning rather than failing the whole create.
|
|
192
|
+
*/
|
|
193
|
+
export async function archiveHtmlImages(client, studyId, html, opts) {
|
|
194
|
+
const log = (msg) => { if (!opts?.quiet)
|
|
195
|
+
process.stderr.write(msg); };
|
|
196
|
+
const imgTags = html.match(/<img\b[^>]*>/gi);
|
|
197
|
+
if (!imgTags)
|
|
198
|
+
return html;
|
|
199
|
+
let result = html;
|
|
200
|
+
let archived = 0;
|
|
201
|
+
let failed = 0;
|
|
202
|
+
const seen = new Map(); // original src -> archived url
|
|
203
|
+
for (const tag of imgTags) {
|
|
204
|
+
const srcMatch = tag.match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)')/i);
|
|
205
|
+
if (!srcMatch)
|
|
206
|
+
continue;
|
|
207
|
+
const src = srcMatch[2] ?? srcMatch[3] ?? "";
|
|
208
|
+
// Only archive remote http(s) images; leave data:/blob:/relative as-is.
|
|
209
|
+
if (!/^https?:\/\//i.test(src))
|
|
210
|
+
continue;
|
|
211
|
+
let archivedUrl = seen.get(src);
|
|
212
|
+
if (archivedUrl === undefined) {
|
|
213
|
+
try {
|
|
214
|
+
archivedUrl = await fetchAndUploadImage(client, studyId, src, archived, opts);
|
|
215
|
+
seen.set(src, archivedUrl);
|
|
216
|
+
archived += 1;
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
failed += 1;
|
|
220
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
221
|
+
log(` ! could not archive image ${src}: ${reason} (it will not render)\n`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Rewrite this tag: point src at the archived URL and drop srcset so the
|
|
226
|
+
// browser uses the archived src, not an un-archived srcset candidate.
|
|
227
|
+
let newTag = tag.replace(srcMatch[0], `src="${archivedUrl}"`);
|
|
228
|
+
newTag = newTag.replace(/\s+srcset\s*=\s*("[^"]*"|'[^']*')/gi, "");
|
|
229
|
+
if (newTag !== tag)
|
|
230
|
+
result = result.replace(tag, newTag);
|
|
231
|
+
}
|
|
232
|
+
if (archived > 0 || failed > 0) {
|
|
233
|
+
log(`Archived ${archived} image(s) to workspace storage${failed ? `, ${failed} failed` : ""}.\n`);
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
async function fetchAndUploadImage(client, studyId, url, index, opts) {
|
|
238
|
+
const resp = await fetch(url, {
|
|
239
|
+
signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS),
|
|
240
|
+
redirect: "follow",
|
|
241
|
+
});
|
|
242
|
+
if (!resp.ok)
|
|
243
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
244
|
+
const ctype = (resp.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
|
|
245
|
+
if (!ctype.startsWith("image/")) {
|
|
246
|
+
throw new Error(`not an image (content-type: ${ctype || "unknown"})`);
|
|
247
|
+
}
|
|
248
|
+
const bytes = Buffer.from(await resp.arrayBuffer());
|
|
249
|
+
if (bytes.byteLength === 0)
|
|
250
|
+
throw new Error("empty response");
|
|
251
|
+
if (bytes.byteLength > MAX_ARCHIVED_IMAGE_BYTES) {
|
|
252
|
+
throw new Error(`image too large (${(bytes.byteLength / (1024 * 1024)).toFixed(1)} MB)`);
|
|
253
|
+
}
|
|
254
|
+
const ext = ctype.split("/")[1]?.split("+")[0] || "img";
|
|
255
|
+
return uploadStudyContentBytes(client, studyId, bytes, ctype, `imported-image-${index}.${ext}`, { quiet: opts?.quiet });
|
|
256
|
+
}
|
|
160
257
|
/**
|
|
161
258
|
* Resolve text content. If the value starts with '@', read the file at
|
|
162
259
|
* the path that follows (curl-style convention). Otherwise return as-is.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ishlabs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "The command-line interface for ish",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"@sentry/node": "^10.13.0",
|
|
48
48
|
"commander": "^13.0.0",
|
|
49
49
|
"http-proxy": "^1.18.1",
|
|
50
|
-
"playwright-core": "
|
|
50
|
+
"playwright-core": "1.59.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/http-proxy": "^1.17.17",
|