@ishlabs/cli 0.25.0 → 0.26.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.
Files changed (36) hide show
  1. package/dist/commands/doctor.d.ts +42 -0
  2. package/dist/commands/doctor.js +359 -0
  3. package/dist/commands/iteration.js +23 -5
  4. package/dist/commands/study-participant.js +1 -1
  5. package/dist/commands/study-run.js +26 -1
  6. package/dist/commands/study-screenshots.js +38 -5
  7. package/dist/index.js +2 -0
  8. package/dist/lib/api-client.d.ts +3 -0
  9. package/dist/lib/api-client.js +6 -1
  10. package/dist/lib/docs.js +15 -3
  11. package/dist/lib/local-sim/actions.d.ts +18 -0
  12. package/dist/lib/local-sim/actions.js +32 -0
  13. package/dist/lib/local-sim/adb.d.ts +33 -0
  14. package/dist/lib/local-sim/adb.js +121 -17
  15. package/dist/lib/local-sim/android.d.ts +7 -1
  16. package/dist/lib/local-sim/android.js +21 -1
  17. package/dist/lib/local-sim/coordinates.d.ts +4 -4
  18. package/dist/lib/local-sim/coordinates.js +4 -4
  19. package/dist/lib/local-sim/device.d.ts +21 -2
  20. package/dist/lib/local-sim/device.js +1 -1
  21. package/dist/lib/local-sim/ios.d.ts +33 -10
  22. package/dist/lib/local-sim/ios.js +88 -20
  23. package/dist/lib/local-sim/loop.js +134 -25
  24. package/dist/lib/local-sim/native-a11y.d.ts +21 -7
  25. package/dist/lib/local-sim/native-a11y.js +82 -47
  26. package/dist/lib/local-sim/simctl.d.ts +28 -43
  27. package/dist/lib/local-sim/simctl.js +53 -142
  28. package/dist/lib/local-sim/types.d.ts +13 -2
  29. package/dist/lib/local-sim/xcuitest.d.ts +60 -0
  30. package/dist/lib/local-sim/xcuitest.js +303 -0
  31. package/dist/lib/paths.d.ts +14 -0
  32. package/dist/lib/paths.js +21 -0
  33. package/dist/lib/report-readiness.d.ts +44 -0
  34. package/dist/lib/report-readiness.js +74 -0
  35. package/dist/lib/skill-content.js +2 -0
  36. package/package.json +1 -1
@@ -0,0 +1,42 @@
1
+ /**
2
+ * ish check / ish setup — environment diagnostics + dependency setup for
3
+ * local web and native (iOS/Android) simulations. (`doctor` is a kept alias.)
4
+ *
5
+ * `check [platform]` runs read-only checks and prints a flutter-doctor-style
6
+ * checklist (or `--json`), scoped to one platform's group + Account when a
7
+ * platform is given (ios|android|web), else everything. `setup`
8
+ * fetches/installs what it can (Chromium today; the iOS XCUITest runner once
9
+ * the artifact pipeline lands) and prints guidance for the rest, then re-runs
10
+ * the checks.
11
+ *
12
+ * Design notes:
13
+ * - Device probes are light, NON-throwing `execFile` calls reimplemented here
14
+ * (not imported from the local-sim drivers): doctor must never throw on a
15
+ * missing tool, and must not pull the playwright/local-sim module graph.
16
+ * - The Chromium check/setup is a DYNAMIC import of `install.ts` — that module
17
+ * deep-imports a non-exported playwright path that crashes at module load on
18
+ * the npm-installed CLI, so it can only be loaded lazily (same reason
19
+ * `study run --local` imports it dynamically).
20
+ * - iOS UI automation is migrating from idb to Apple's XCUITest (a prebuilt
21
+ * WebDriverAgent runner fetched into `wdaDir()`); the runner row reflects
22
+ * that target. Until the artifact ships, point `ISH_WDA_PATH` at a local
23
+ * build.
24
+ */
25
+ import type { Command } from "commander";
26
+ export type CheckStatus = "pass" | "warn" | "fail" | "skip";
27
+ export interface Check {
28
+ /** Stable machine key (for --json). */
29
+ key: string;
30
+ /** Human label. */
31
+ name: string;
32
+ /** Coarse grouping for the human checklist. */
33
+ group: "iOS" | "Android" | "Web" | "Account";
34
+ status: CheckStatus;
35
+ message?: string;
36
+ /** One-line remediation hint shown on warn/fail. */
37
+ fix?: string;
38
+ }
39
+ export declare function runChecks(): Promise<Check[]>;
40
+ export declare function scopeChecks(checks: Check[], platform?: string): Check[];
41
+ export declare function overall(checks: Check[]): CheckStatus;
42
+ export declare function registerDoctorCommands(program: Command): void;
@@ -0,0 +1,359 @@
1
+ /**
2
+ * ish check / ish setup — environment diagnostics + dependency setup for
3
+ * local web and native (iOS/Android) simulations. (`doctor` is a kept alias.)
4
+ *
5
+ * `check [platform]` runs read-only checks and prints a flutter-doctor-style
6
+ * checklist (or `--json`), scoped to one platform's group + Account when a
7
+ * platform is given (ios|android|web), else everything. `setup`
8
+ * fetches/installs what it can (Chromium today; the iOS XCUITest runner once
9
+ * the artifact pipeline lands) and prints guidance for the rest, then re-runs
10
+ * the checks.
11
+ *
12
+ * Design notes:
13
+ * - Device probes are light, NON-throwing `execFile` calls reimplemented here
14
+ * (not imported from the local-sim drivers): doctor must never throw on a
15
+ * missing tool, and must not pull the playwright/local-sim module graph.
16
+ * - The Chromium check/setup is a DYNAMIC import of `install.ts` — that module
17
+ * deep-imports a non-exported playwright path that crashes at module load on
18
+ * the npm-installed CLI, so it can only be loaded lazily (same reason
19
+ * `study run --local` imports it dynamically).
20
+ * - iOS UI automation is migrating from idb to Apple's XCUITest (a prebuilt
21
+ * WebDriverAgent runner fetched into `wdaDir()`); the runner row reflects
22
+ * that target. Until the artifact ships, point `ISH_WDA_PATH` at a local
23
+ * build.
24
+ */
25
+ import { execFile } from "node:child_process";
26
+ import { promisify } from "node:util";
27
+ import { existsSync, readdirSync } from "node:fs";
28
+ import * as path from "node:path";
29
+ import { runInline } from "../lib/command-helpers.js";
30
+ import { output } from "../lib/output.js";
31
+ import { reportReadiness } from "../lib/report-readiness.js";
32
+ import { loadConfig } from "../config.js";
33
+ import { decodeJwtClaims, isTokenExpired } from "../auth.js";
34
+ import { wdaDir, adbBin } from "../lib/paths.js";
35
+ import pkg from "../../package.json" with { type: "json" };
36
+ const execFileAsync = promisify(execFile);
37
+ const XCRUN = "/usr/bin/xcrun";
38
+ const isMac = process.platform === "darwin";
39
+ async function tryExec(cmd, args, timeoutMs = 8000) {
40
+ try {
41
+ const { stdout, stderr } = await execFileAsync(cmd, args, {
42
+ timeout: timeoutMs,
43
+ maxBuffer: 8 * 1024 * 1024,
44
+ });
45
+ return { ok: true, stdout, stderr };
46
+ }
47
+ catch (e) {
48
+ const err = e;
49
+ return {
50
+ ok: false,
51
+ stdout: err.stdout ?? "",
52
+ stderr: err.stderr ?? err.message ?? String(e),
53
+ };
54
+ }
55
+ }
56
+ /** adb path, mirroring the device layer's resolution (ISH_ADB → SDK → Homebrew → our download cache → PATH). */
57
+ function resolveAdb() {
58
+ for (const v of [process.env.ISH_ADB, process.env.ADB]) {
59
+ if (v && existsSync(v))
60
+ return v;
61
+ }
62
+ for (const home of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT]) {
63
+ if (home) {
64
+ const p = path.join(home, "platform-tools", "adb");
65
+ if (existsSync(p))
66
+ return p;
67
+ }
68
+ }
69
+ if (existsSync("/opt/homebrew/bin/adb"))
70
+ return "/opt/homebrew/bin/adb";
71
+ if (existsSync(adbBin()))
72
+ return adbBin();
73
+ return "adb";
74
+ }
75
+ // ── Individual checks ──────────────────────────────────────────────────────
76
+ function checkAuth() {
77
+ const cfg = loadConfig();
78
+ const token = cfg.access_token || cfg.token;
79
+ if (!token) {
80
+ return { key: "auth", name: "Account", group: "Account", status: "fail", message: "not logged in", fix: "ish login" };
81
+ }
82
+ const claims = decodeJwtClaims(token);
83
+ const email = claims?.email ?? "authenticated";
84
+ if (isTokenExpired(token)) {
85
+ // Expired access tokens auto-refresh via the refresh_token on the next API
86
+ // call, so this is a warn, not a hard fail.
87
+ return { key: "auth", name: "Account", group: "Account", status: "warn", message: `${email} (token expired — refreshes on next call)`, fix: "ish login (if refresh fails)" };
88
+ }
89
+ return { key: "auth", name: "Account", group: "Account", status: "pass", message: email };
90
+ }
91
+ function checkWorkspace() {
92
+ const cfg = loadConfig();
93
+ if (cfg.workspace) {
94
+ return { key: "workspace", name: "Active workspace", group: "Account", status: "pass", message: cfg.workspace };
95
+ }
96
+ return { key: "workspace", name: "Active workspace", group: "Account", status: "warn", message: "none set", fix: "ish workspace use <id>" };
97
+ }
98
+ async function checkXcode() {
99
+ if (!isMac) {
100
+ return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "skip", message: "iOS simulations require macOS" };
101
+ }
102
+ const r = await tryExec(XCRUN, ["--version"]);
103
+ if (r.ok) {
104
+ const ver = r.stdout.trim().split("\n")[0] || "xcrun present";
105
+ return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "pass", message: ver };
106
+ }
107
+ return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "fail", message: "xcrun not found", fix: "Install Xcode from the App Store, then `xcodebuild -runFirstLaunch`" };
108
+ }
109
+ async function checkSimulator() {
110
+ if (!isMac) {
111
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "skip" };
112
+ }
113
+ const r = await tryExec(XCRUN, ["simctl", "list", "devices", "booted", "-j"]);
114
+ if (!r.ok) {
115
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "fail", message: "could not query simctl", fix: "Install Xcode" };
116
+ }
117
+ let booted = [];
118
+ try {
119
+ const parsed = JSON.parse(r.stdout);
120
+ booted = Object.values(parsed.devices ?? {})
121
+ .flat()
122
+ .filter((d) => d.state === "Booted")
123
+ .map((d) => ({ name: d.name, udid: d.udid }));
124
+ }
125
+ catch {
126
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "fail", message: "could not parse simctl output" };
127
+ }
128
+ if (booted.length === 1) {
129
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "pass", message: `${booted[0].name} (booted)` };
130
+ }
131
+ if (booted.length === 0) {
132
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "warn", message: "none booted", fix: "Open Simulator.app (Xcode ships simulators) or `xcrun simctl boot <udid>`" };
133
+ }
134
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "warn", message: `${booted.length} booted — native runs drive exactly one`, fix: "Shut down the extras" };
135
+ }
136
+ /** True when the prebuilt XCUITest runner (WebDriverAgent `.app`) is present. */
137
+ function wdaBundlePresent() {
138
+ const override = process.env.ISH_WDA_PATH;
139
+ if (override)
140
+ return existsSync(override);
141
+ const dir = wdaDir();
142
+ if (!existsSync(dir))
143
+ return false;
144
+ try {
145
+ return readdirSync(dir).some((e) => e.endsWith(".app"));
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ }
151
+ function checkXcuitestRunner() {
152
+ if (!isMac) {
153
+ return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "skip" };
154
+ }
155
+ if (wdaBundlePresent()) {
156
+ return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "pass", message: process.env.ISH_WDA_PATH ? "ISH_WDA_PATH" : wdaDir() };
157
+ }
158
+ return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "warn", message: "not installed", fix: "ish setup (or set ISH_WDA_PATH to a local WebDriverAgent build)" };
159
+ }
160
+ async function checkAdb() {
161
+ const adbPath = resolveAdb();
162
+ const r = await tryExec(adbPath, ["devices"]);
163
+ if (!r.ok) {
164
+ const adb = { key: "adb", name: "Android adb", group: "Android", status: "warn", message: "not found (only needed for Android)", fix: "ish setup (fetches adb), or install Android platform-tools" };
165
+ const emulator = { key: "android_emulator", name: "Android emulator", group: "Android", status: "skip" };
166
+ return { adb, emulator };
167
+ }
168
+ const adb = { key: "adb", name: "Android adb", group: "Android", status: "pass", message: adbPath };
169
+ // Lines after the "List of devices attached" header, in "device" state.
170
+ const devices = r.stdout
171
+ .split("\n")
172
+ .slice(1)
173
+ .map((l) => l.trim())
174
+ .filter((l) => l.endsWith("\tdevice"));
175
+ const emulator = devices.length === 1
176
+ ? { key: "android_emulator", name: "Android emulator", group: "Android", status: "pass", message: devices[0].split("\t")[0] }
177
+ : devices.length === 0
178
+ ? { key: "android_emulator", name: "Android emulator", group: "Android", status: "warn", message: "none online", fix: "Create + boot an AVD in Android Studio > Device Manager (or `emulator -avd <name>`)" }
179
+ : { key: "android_emulator", name: "Android emulator", group: "Android", status: "warn", message: `${devices.length} online — native runs drive exactly one`, fix: "Stop the extras" };
180
+ return { adb, emulator };
181
+ }
182
+ async function checkChromium() {
183
+ try {
184
+ const { isBrowserInstalled } = await import("../lib/local-sim/install.js");
185
+ return isBrowserInstalled()
186
+ ? { key: "chromium", name: "Chromium (web local)", group: "Web", status: "pass", message: "installed" }
187
+ : { key: "chromium", name: "Chromium (web local)", group: "Web", status: "warn", message: "not installed", fix: "ish setup" };
188
+ }
189
+ catch {
190
+ return { key: "chromium", name: "Chromium (web local)", group: "Web", status: "skip", message: "unavailable in this build" };
191
+ }
192
+ }
193
+ function checkCli() {
194
+ return { key: "cli", name: "ish CLI", group: "Account", status: "pass", message: `v${pkg.version}` };
195
+ }
196
+ export async function runChecks() {
197
+ const { adb, emulator } = await checkAdb();
198
+ return [
199
+ await checkXcode(),
200
+ await checkSimulator(),
201
+ checkXcuitestRunner(),
202
+ adb,
203
+ emulator,
204
+ await checkChromium(),
205
+ checkAuth(),
206
+ checkWorkspace(),
207
+ checkCli(),
208
+ ];
209
+ }
210
+ // ── Rendering ───────────────────────────────────────────────────────────────
211
+ const MARK = { pass: "[ OK ]", warn: "[WARN]", fail: "[FAIL]", skip: "[ -- ]" };
212
+ const GROUP_ORDER = ["iOS", "Android", "Web", "Account"];
213
+ const GROUP_LABEL = {
214
+ iOS: "iOS (native simulations)",
215
+ Android: "Android (native simulations)",
216
+ Web: "Web (local browser simulations)",
217
+ Account: "Account",
218
+ };
219
+ // `ish check <platform>` scopes the report to one platform's group + Account,
220
+ // so an Android study never nags about Xcode and an iOS study never about adb.
221
+ const PLATFORM_GROUPS = {
222
+ ios: ["iOS", "Account"],
223
+ android: ["Android", "Account"],
224
+ web: ["Web", "Account"],
225
+ };
226
+ export function scopeChecks(checks, platform) {
227
+ if (!platform)
228
+ return checks;
229
+ const groups = PLATFORM_GROUPS[platform];
230
+ return groups ? checks.filter((c) => groups.includes(c.group)) : checks;
231
+ }
232
+ export function overall(checks) {
233
+ if (checks.some((c) => c.status === "fail"))
234
+ return "fail";
235
+ if (checks.some((c) => c.status === "warn"))
236
+ return "warn";
237
+ return "pass";
238
+ }
239
+ function renderHuman(checks) {
240
+ const lines = ["", "ish check — local & native simulation environment", ""];
241
+ const width = Math.max(...checks.map((c) => c.name.length)) + 2;
242
+ for (const group of GROUP_ORDER) {
243
+ const inGroup = checks.filter((c) => c.group === group);
244
+ if (inGroup.length === 0)
245
+ continue;
246
+ lines.push(` ${GROUP_LABEL[group]}`);
247
+ for (const c of inGroup) {
248
+ const label = c.name.padEnd(width, " ");
249
+ let line = ` ${MARK[c.status]} ${label}${c.message ?? ""}`;
250
+ lines.push(line.trimEnd());
251
+ if ((c.status === "warn" || c.status === "fail") && c.fix) {
252
+ lines.push(` ↳ ${c.fix}`);
253
+ }
254
+ }
255
+ lines.push("");
256
+ }
257
+ const o = overall(checks);
258
+ const fails = checks.filter((c) => c.status === "fail").length;
259
+ const warns = checks.filter((c) => c.status === "warn").length;
260
+ if (o === "pass") {
261
+ lines.push("Everything checks out.");
262
+ }
263
+ else {
264
+ const parts = [fails ? `${fails} blocking` : "", warns ? `${warns} optional` : ""].filter(Boolean);
265
+ lines.push(`${parts.join(", ")} issue(s). Run \`ish setup\` to fetch what's fetchable.`);
266
+ }
267
+ console.log(lines.join("\n"));
268
+ }
269
+ // ── Commands ──────────────────────────────────────────────────────────────
270
+ export function registerDoctorCommands(program) {
271
+ program
272
+ .command("check [platform]")
273
+ .alias("doctor")
274
+ .description("Check your local simulation setup for a platform (ios | android | web), or everything")
275
+ .action(async (platform, _opts, cmd) => {
276
+ await runInline(cmd, async (globals) => {
277
+ const scope = platform?.toLowerCase();
278
+ if (scope && !PLATFORM_GROUPS[scope]) {
279
+ throw new Error(`Unknown platform "${platform}". Use one of: ios, android, web.`);
280
+ }
281
+ const checks = scopeChecks(await runChecks(), scope);
282
+ const aggregate = overall(checks);
283
+ if (globals.json) {
284
+ output({ checks, overall: aggregate }, true);
285
+ }
286
+ else {
287
+ renderHuman(checks);
288
+ }
289
+ // Best-effort: when scoped to a native platform, report readiness to
290
+ // the backend so the web app can render a live native-readiness panel
291
+ // (the native analog of `ish connect` registering a tunnel). Never for
292
+ // the no-arg `ish check` (no single platform) or `web`. Awaited so a
293
+ // short-lived `ish check` doesn't drop the in-flight request before
294
+ // exit — `reportReadiness` is fully best-effort and never throws or
295
+ // blocks meaningfully (5s ceiling).
296
+ if (scope === "ios" || scope === "android") {
297
+ await reportReadiness(scope, checks, aggregate, globals);
298
+ }
299
+ });
300
+ });
301
+ program
302
+ .command("setup")
303
+ .description("Fetch/install local-simulation dependencies (Chromium, iOS runner, adb) and show what's left to do")
304
+ .action(async (_opts, cmd) => {
305
+ await runInline(cmd, async (globals) => {
306
+ const quiet = globals.json;
307
+ const log = (m) => { if (!quiet)
308
+ console.error(m); };
309
+ // 1. Chromium (web local) — auto-fetchable.
310
+ try {
311
+ const { isBrowserInstalled, ensureBrowser } = await import("../lib/local-sim/install.js");
312
+ if (isBrowserInstalled()) {
313
+ log("Chromium: already installed.");
314
+ }
315
+ else {
316
+ log("Chromium: fetching for local web simulations...");
317
+ await ensureBrowser({ quiet, skipPrompt: true });
318
+ }
319
+ }
320
+ catch (e) {
321
+ log(`Chromium: could not install (${e instanceof Error ? e.message : String(e)}).`);
322
+ }
323
+ // 2. iOS XCUITest runner (WebDriverAgent) — auto-fetchable on macOS.
324
+ if (isMac) {
325
+ try {
326
+ if (wdaBundlePresent()) {
327
+ log("iOS runner: already installed.");
328
+ }
329
+ else {
330
+ log("iOS runner: fetching WebDriverAgent...");
331
+ const { resolveWdaBundle } = await import("../lib/local-sim/xcuitest.js");
332
+ await resolveWdaBundle();
333
+ }
334
+ }
335
+ catch (e) {
336
+ log(`iOS runner: could not fetch (${e instanceof Error ? e.message : String(e)}).`);
337
+ }
338
+ }
339
+ // 3. Android adb — auto-fetchable (Google's standalone platform-tools).
340
+ try {
341
+ const { ensureAdb } = await import("../lib/local-sim/adb.js");
342
+ await ensureAdb();
343
+ log("Android adb: ready.");
344
+ }
345
+ catch (e) {
346
+ log(`Android adb: could not fetch (${e instanceof Error ? e.message : String(e)}).`);
347
+ }
348
+ // 4. Re-run checks so the user sees the resulting state, and hand back
349
+ // the structured result on --json.
350
+ const checks = await runChecks();
351
+ if (globals.json) {
352
+ output({ checks, overall: overall(checks) }, true);
353
+ }
354
+ else {
355
+ renderHuman(checks);
356
+ }
357
+ });
358
+ });
359
+ }
@@ -23,6 +23,15 @@ function readTextOrAtFile(value) {
23
23
  }
24
24
  return value;
25
25
  }
26
+ /**
27
+ * Native (ios/android) interactive platforms. For these the real target app is
28
+ * supplied at run time via `ish study run --app <bundle>`, so the iteration's
29
+ * `url` is just a placeholder — we don't force the user to invent one.
30
+ * Mirrors `isNativePlatform` in `lib/local-sim/loop.ts`.
31
+ */
32
+ function isNativePlatform(platform) {
33
+ return platform === "ios" || platform === "android";
34
+ }
26
35
  /**
27
36
  * Parse a JSON-blob flag that also supports `@filepath` (read from disk).
28
37
  * Used for participant_pair `--role-criteria-a/-b` and any future blob inputs.
@@ -282,7 +291,7 @@ function buildIterationDetails(modality, opts) {
282
291
  };
283
292
  }
284
293
  default:
285
- if (!opts.url) {
294
+ if (!opts.url && !isNativePlatform(opts.platform)) {
286
295
  throw new Error("Interactive iterations require --url. Provide the URL to test.");
287
296
  }
288
297
  if (opts.platform === "figma" && (!opts.fileKey || !opts.startNodeId)) {
@@ -296,10 +305,18 @@ function buildIterationDetails(modality, opts) {
296
305
  }
297
306
  screenFormat = normalized;
298
307
  }
308
+ // Native (ios/android) names its target via app_artifact (a bundle id /
309
+ // package name, or a local .app/.apk path) and carries no url. We accept
310
+ // the target from --app (preferred) or a --url passed out of habit. When
311
+ // neither is given, app_artifact is omitted = "chosen at run time".
312
+ const isNative = isNativePlatform(opts.platform);
313
+ const nativeTarget = isNative ? (opts.app ?? opts.url)?.trim() : undefined;
299
314
  return {
300
315
  type: "interactive",
301
316
  platform: opts.platform || "browser",
302
- url: opts.url,
317
+ ...(isNative
318
+ ? (nativeTarget ? { app_artifact: nativeTarget } : {})
319
+ : { url: opts.url }),
303
320
  screen_format: screenFormat,
304
321
  ...(opts.locale && { locale: opts.locale }),
305
322
  ...(opts.fileKey && { file_key: opts.fileKey }),
@@ -365,8 +382,9 @@ Concept pages: ish docs get-page concepts/iteration
365
382
  .option("--name <name>", "Iteration name (defaults to the next position letter A/B/C… if omitted)")
366
383
  .option("--description <description>", "Iteration description")
367
384
  // Interactive
368
- .option("--platform <platform>", "Platform (browser, android, figma, code) — interactive only")
369
- .option("--url <url>", "URL to test — interactive only")
385
+ .option("--platform <platform>", "Platform (browser, android, ios, figma, code) — interactive only")
386
+ .option("--url <url>", "URL to test — interactive only (optional for ios/android native apps)")
387
+ .option("--app <id>", "Native app bundle id (or .app/.apk path) — ios/android; supplies the iteration target so --url isn't required")
370
388
  .option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only; hyphen/underscore variants accepted")
371
389
  .option("--locale <locale>", "Locale code (e.g. en-US) — interactive only")
372
390
  .option("--file-key <key>", "Figma file key — required when --platform=figma")
@@ -596,7 +614,7 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
596
614
  break;
597
615
  }
598
616
  default:
599
- if (!opts.url) {
617
+ if (!opts.url && !isNativePlatform(opts.platform)) {
600
618
  throw new Error("Interactive iterations require --url. Provide the URL to test.");
601
619
  }
602
620
  }
@@ -109,7 +109,7 @@ Tips:
109
109
  .option("--study <id>", "Study ID; resolves to the latest iteration on that study (alternative to --iteration)")
110
110
  .requiredOption("--person <id>", "Participant profile ID")
111
111
  .option("--language <lang>", "Language code (e.g. en, sv)")
112
- .option("--platform <platform>", "Platform (browser, android, figma, code)")
112
+ .option("--platform <platform>", "Platform (browser, android, ios, figma, code)")
113
113
  .option("--participant-type <type>", "Participant type (ai, human)", "ai")
114
114
  .addHelpText("after", "\nExamples:\n $ ish study participant create --iteration <id> --person <id>\n $ ish study participant create --study s-XXX --person p-XXX\n $ ish study participant create --iteration <id> --person <id> --platform android --json")
115
115
  .action(async (opts, cmd) => {
@@ -24,6 +24,8 @@ import { isMediaModality, isChatModality, iterationHasContent, describeRequiredC
24
24
  // just `study run --local`. The bun-compiled binary bundles the deep
25
25
  // path so it doesn't hit Node's resolver; only the npm path is sensitive.
26
26
  import { estimateChatPair, estimateChatSolo, estimateMediaRun } from "../lib/billing.js";
27
+ import { reportReadiness } from "../lib/report-readiness.js";
28
+ import { runChecks, scopeChecks, overall } from "./doctor.js";
27
29
  function parseMaxInteractions(value) {
28
30
  const n = parseInt(value, 10);
29
31
  if (isNaN(n) || n < 1)
@@ -262,6 +264,7 @@ function readIterationDetails(details) {
262
264
  ...(screenFormat && { screenFormat }),
263
265
  ...(typeof details.locale === "string" && { locale: details.locale }),
264
266
  ...(typeof details.title === "string" && { title: details.title }),
267
+ ...(typeof details.app_artifact === "string" && { appArtifact: details.app_artifact }),
265
268
  };
266
269
  }
267
270
  /**
@@ -760,6 +763,24 @@ Examples:
760
763
  ?? platformFromApp
761
764
  ?? detailsView.platform
762
765
  ?? "browser";
766
+ // Best-effort native-readiness report. When this is a LOCAL native run
767
+ // (iOS/Android driven on this developer's machine), fire-and-forget a
768
+ // fresh, platform-scoped `runChecks()` to the backend so the web app
769
+ // can render a live native-readiness panel — the native analog of how
770
+ // `ish connect` registers a tunnel. Fully decoupled from the run:
771
+ // never awaited (must not delay dispatch), never blocks, never throws.
772
+ // Browser local runs and all remote runs are skipped (remote runs use
773
+ // Browserbase, so this machine's device readiness is irrelevant there).
774
+ if (opts.local && (resolvedPlatform === "ios" || resolvedPlatform === "android")) {
775
+ const readinessPlatform = resolvedPlatform;
776
+ void (async () => {
777
+ const scoped = scopeChecks(await runChecks(), readinessPlatform);
778
+ await reportReadiness(readinessPlatform, scoped, overall(scoped), globals, { debug: !!opts.debug });
779
+ })().catch(() => {
780
+ // Belt-and-suspenders: reportReadiness already swallows everything,
781
+ // but runChecks() / scopeChecks() must not bubble either.
782
+ });
783
+ }
763
784
  if (isPair && pairConfig) {
764
785
  // Pair-mode flow mirrors the MCP (`ish-mcp` `_run_pair_mode`):
765
786
  // 1. If the iteration already carries `conversations[]` from a
@@ -884,7 +905,11 @@ Examples:
884
905
  debug: opts.debug,
885
906
  parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
886
907
  platform: resolvedPlatform,
887
- ...(opts.app && { appPath: opts.app }),
908
+ // Native target: --app overrides; otherwise default from the
909
+ // iteration's remembered app_artifact so reruns need no --app.
910
+ ...((opts.app ?? detailsView.appArtifact) && {
911
+ appPath: opts.app ?? detailsView.appArtifact,
912
+ }),
888
913
  quiet: globals.quiet,
889
914
  json: globals.json,
890
915
  });
@@ -21,6 +21,36 @@ import { dirname, extname, join } from "node:path";
21
21
  import { withClient, resolveStudy } from "../lib/command-helpers.js";
22
22
  import { resolveId } from "../lib/alias-store.js";
23
23
  import { output, printTable } from "../lib/output.js";
24
+ import { ApiError } from "../lib/api-client.js";
25
+ /**
26
+ * Server-side screenshots are produced by remote interactive runs only. A
27
+ * study whose only runs were local (`ish study run --local`) has none — and the
28
+ * grouped endpoint currently 500s instead of returning an empty index. Tag this
29
+ * hint onto the error so the bare 500 points the user at the local debug report.
30
+ */
31
+ const LOCAL_RUN_SCREENSHOT_HINT = [
32
+ "Screenshots are produced by remote runs only.",
33
+ "Ran this study locally (--local)? The per-step screenshots are in the HTML debug report under ~/.ish/debug/ (path printed at the end of each local run).",
34
+ ];
35
+ /**
36
+ * GET the frame-grouped screenshot index, tagging the local-run hint onto any
37
+ * ApiError before it propagates to `outputError` (which merges `.suggestions`).
38
+ */
39
+ async function getGroupedScreenshots(client, studyId) {
40
+ try {
41
+ return await client.get(`/studies/${studyId}/screenshots/grouped`);
42
+ }
43
+ catch (err) {
44
+ if (err instanceof ApiError) {
45
+ const tagged = err;
46
+ tagged.suggestions = [
47
+ ...(Array.isArray(tagged.suggestions) ? tagged.suggestions : []),
48
+ ...LOCAL_RUN_SCREENSHOT_HINT,
49
+ ];
50
+ }
51
+ throw err;
52
+ }
53
+ }
24
54
  function projectScreenshot(s) {
25
55
  return {
26
56
  id: s.id,
@@ -106,9 +136,12 @@ Examples:
106
136
  $ ish study screenshots download <study-id> --id <scid> --out shot.png
107
137
  $ ish study screenshots download <study-id> --all --out ./shots/
108
138
 
109
- Screenshots are produced server-side by interactive runs only — chat / video /
110
- text studies don't have them. Each row's storage URL is self-credentialed,
111
- so the CLI fetches bytes without forwarding your bearer.`);
139
+ Screenshots are produced server-side by remote interactive runs only — chat /
140
+ video / text studies don't have them, and neither do local runs
141
+ (\`ish study run --local\`), which instead write a per-step HTML debug report to
142
+ ~/.ish/debug/ (the path is printed at the end of each local run). Each row's
143
+ storage URL is self-credentialed, so the CLI fetches bytes without forwarding
144
+ your bearer.`);
112
145
  screenshots
113
146
  .command("list", { isDefault: true })
114
147
  .description("List screenshots for a study (frame-grouped).")
@@ -117,7 +150,7 @@ so the CLI fetches bytes without forwarding your bearer.`);
117
150
  .action(async (id, _opts, cmd) => {
118
151
  await withClient(cmd, async (client, globals) => {
119
152
  const studyId = resolveStudy(id);
120
- const raw = await client.get(`/studies/${studyId}/screenshots/grouped`);
153
+ const raw = await getGroupedScreenshots(client, studyId);
121
154
  const listing = projectListing(studyId, raw);
122
155
  if (globals.json) {
123
156
  output(listing, true, { preProjected: true });
@@ -172,7 +205,7 @@ so the CLI fetches bytes without forwarding your bearer.`);
172
205
  }
173
206
  // --all: walk the index, fetch each row, save under --out dir.
174
207
  const outDir = opts.out ?? "./screenshots";
175
- const grouped = await client.get(`/studies/${studyId}/screenshots/grouped`);
208
+ const grouped = await getGroupedScreenshots(client, studyId);
176
209
  const all = [
177
210
  ...(grouped.groups ?? []).flatMap((g) => g.screenshots ?? []),
178
211
  ...(grouped.uncategorized ?? []),
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { registerDocsCommands } from "./commands/docs.js";
19
19
  import { registerInitCommands } from "./commands/init.js";
20
20
  import { registerMcpCommands } from "./commands/mcp.js";
21
21
  import { registerSecretCommands } from "./commands/secret.js";
22
+ import { registerDoctorCommands } from "./commands/doctor.js";
22
23
  import { AGENT_HELP_FOOTER } from "./lib/docs.js";
23
24
  import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
24
25
  import { resolveApiUrl, resolveToken, verifyToken } from "./lib/auth.js";
@@ -510,6 +511,7 @@ registerDocsCommands(program);
510
511
  registerInitCommands(program);
511
512
  registerMcpCommands(program);
512
513
  registerSecretCommands(program);
514
+ registerDoctorCommands(program);
513
515
  program
514
516
  .command("upgrade")
515
517
  .description("Update ish to the latest version")
@@ -40,6 +40,7 @@ export declare class ApiClient {
40
40
  iteration_id: string;
41
41
  }): Promise<import("./local-sim/types.js").LocalSimInitResponse>;
42
42
  localSimStep(body: import("./local-sim/types.js").LocalSimStepRequest): Promise<import("./local-sim/types.js").LocalSimStepResponseRaw>;
43
+ localSimRecordInteraction(body: import("./local-sim/types.js").LocalSimInteractionRequest): Promise<import("./local-sim/types.js").LocalSimInteractionResponse>;
43
44
  localSimRecord(body: import("./local-sim/types.js").LocalSimRecordRequest): Promise<import("./local-sim/types.js").LocalSimRecordResponse>;
44
45
  localSimMatchFrame(body: {
45
46
  product_id: string;
@@ -50,6 +51,8 @@ export declare class ApiClient {
50
51
  screen_format?: string;
51
52
  full_page_screenshot_base64?: string;
52
53
  platform?: string;
54
+ previous_frame_version_id?: string;
55
+ same_screen_continuation?: boolean;
53
56
  }): Promise<{
54
57
  frame_version_id: string;
55
58
  }>;
@@ -260,8 +260,13 @@ export class ApiClient {
260
260
  async localSimStep(body) {
261
261
  return this.post("/simulation/local/step", body, { timeout: 60_000 });
262
262
  }
263
+ async localSimRecordInteraction(body) {
264
+ return this.post("/simulation/local/interaction", body, { timeout: 30_000 });
265
+ }
263
266
  async localSimRecord(body) {
264
- return this.post("/simulation/local/record", body, { timeout: 60_000 });
267
+ // Finalize now runs the pre/post survey + participant summary inline
268
+ // server-side, so it can take materially longer than a plain DB write.
269
+ return this.post("/simulation/local/record", body, { timeout: 180_000 });
265
270
  }
266
271
  async localSimMatchFrame(body) {
267
272
  return this.post("/simulation/local/match-frame", body, { timeout: 30_000 });