@ishlabs/cli 0.26.1 → 0.27.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 (39) hide show
  1. package/README.md +4 -0
  2. package/dist/commands/doctor.js +21 -11
  3. package/dist/commands/iteration.js +13 -4
  4. package/dist/commands/study-run.js +12 -12
  5. package/dist/commands/study-screenshots.js +15 -12
  6. package/dist/commands/study.js +22 -3
  7. package/dist/lib/api-client.d.ts +1 -0
  8. package/dist/lib/docs.js +139 -7
  9. package/dist/lib/local-sim/adb.d.ts +35 -2
  10. package/dist/lib/local-sim/adb.js +107 -14
  11. package/dist/lib/local-sim/android.d.ts +5 -3
  12. package/dist/lib/local-sim/android.js +29 -11
  13. package/dist/lib/local-sim/device-pool.d.ts +85 -0
  14. package/dist/lib/local-sim/device-pool.js +316 -0
  15. package/dist/lib/local-sim/device.d.ts +29 -0
  16. package/dist/lib/local-sim/device.js +19 -1
  17. package/dist/lib/local-sim/emulator.d.ts +50 -0
  18. package/dist/lib/local-sim/emulator.js +189 -0
  19. package/dist/lib/local-sim/install.js +23 -3
  20. package/dist/lib/local-sim/ios.d.ts +31 -5
  21. package/dist/lib/local-sim/ios.js +80 -21
  22. package/dist/lib/local-sim/loop.js +199 -9
  23. package/dist/lib/local-sim/native-a11y.d.ts +24 -0
  24. package/dist/lib/local-sim/native-a11y.js +76 -14
  25. package/dist/lib/local-sim/screen-signature.d.ts +77 -0
  26. package/dist/lib/local-sim/screen-signature.js +170 -0
  27. package/dist/lib/local-sim/simctl-provision.d.ts +49 -0
  28. package/dist/lib/local-sim/simctl-provision.js +89 -0
  29. package/dist/lib/local-sim/simctl.d.ts +6 -4
  30. package/dist/lib/local-sim/simctl.js +18 -5
  31. package/dist/lib/local-sim/xcuitest.d.ts +22 -1
  32. package/dist/lib/local-sim/xcuitest.js +38 -6
  33. package/dist/lib/modality.js +7 -2
  34. package/dist/lib/paths.d.ts +1 -0
  35. package/dist/lib/paths.js +3 -0
  36. package/dist/lib/skill-content.js +5 -2
  37. package/dist/lib/upload.d.ts +27 -0
  38. package/dist/lib/upload.js +108 -11
  39. package/package.json +2 -2
@@ -96,29 +96,30 @@ export async function validateFile(filePath) {
96
96
  // Core upload: 3-step backend-mediated flow
97
97
  // ---------------------------------------------------------------------------
98
98
  /**
99
- * Upload a local file to Supabase Storage via the backend's signed URL flow.
100
- * Returns the public content_url for use in iteration details.
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 uploadStudyContent(client, studyId, filePath, opts) {
103
- const resolved = resolvePath(filePath);
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 file bytes to the signed URL
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(fileBuffer.byteLength),
116
+ "Content-Length": String(size),
120
117
  },
121
- body: fileBuffer,
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.26.1",
3
+ "version": "0.27.1",
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": "^1.58.2"
50
+ "playwright-core": "1.59.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/http-proxy": "^1.17.17",