@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
package/README.md
CHANGED
|
@@ -45,6 +45,10 @@ The CLI resolves your auth token in this order:
|
|
|
45
45
|
|
|
46
46
|
Test plan is available at `/Users/felixweiland/ish-cli-test-plan.md`.
|
|
47
47
|
|
|
48
|
+
## Experiments
|
|
49
|
+
|
|
50
|
+
Durable records of engineering experiments (including reverted ones, so we don't re-run them) live in [`docs/experiments/`](docs/experiments/README.md).
|
|
51
|
+
|
|
48
52
|
---
|
|
49
53
|
|
|
50
54
|
## Concepts
|
package/dist/commands/doctor.js
CHANGED
|
@@ -125,13 +125,16 @@ async function checkSimulator() {
|
|
|
125
125
|
catch {
|
|
126
126
|
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "fail", message: "could not parse simctl output" };
|
|
127
127
|
}
|
|
128
|
-
if (booted.length
|
|
129
|
-
|
|
128
|
+
if (booted.length >= 1) {
|
|
129
|
+
// >1 booted is fine now: a parallel run (`--parallel N`) reuses booted
|
|
130
|
+
// simulators and clones the shortfall, so extras are a head start, not a
|
|
131
|
+
// problem. A single-device (non-parallel) run still needs exactly one.
|
|
132
|
+
const extra = booted.length > 1
|
|
133
|
+
? ` (+${booted.length - 1} more — parallel runs pool them; a single-device run needs exactly one)`
|
|
134
|
+
: "";
|
|
135
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "pass", message: `${booted[0].name} (booted)${extra}` };
|
|
130
136
|
}
|
|
131
|
-
|
|
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" };
|
|
137
|
+
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>`" };
|
|
135
138
|
}
|
|
136
139
|
/** True when the prebuilt XCUITest runner (WebDriverAgent `.app`) is present. */
|
|
137
140
|
function wdaBundlePresent() {
|
|
@@ -172,11 +175,18 @@ async function checkAdb() {
|
|
|
172
175
|
.slice(1)
|
|
173
176
|
.map((l) => l.trim())
|
|
174
177
|
.filter((l) => l.endsWith("\tdevice"));
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
// >1 online is fine now: `--parallel` pools emulators (and auto-launches more
|
|
179
|
+
// from your AVDs). A single-device (non-parallel) run still uses exactly one.
|
|
180
|
+
const emulator = devices.length >= 1
|
|
181
|
+
? {
|
|
182
|
+
key: "android_emulator",
|
|
183
|
+
name: "Android emulator",
|
|
184
|
+
group: "Android",
|
|
185
|
+
status: "pass",
|
|
186
|
+
message: devices[0].split("\t")[0] +
|
|
187
|
+
(devices.length > 1 ? ` (+${devices.length - 1} more — parallel runs pool them)` : ""),
|
|
188
|
+
}
|
|
189
|
+
: { key: "android_emulator", name: "Android emulator", group: "Android", status: "warn", message: "none online (parallel runs auto-launch your AVDs)", fix: "Create an AVD in Android Studio > Device Manager (or `emulator -avd <name>`)" };
|
|
180
190
|
return { adb, emulator };
|
|
181
191
|
}
|
|
182
192
|
async function checkChromium() {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish feedback — report a bug, request a feature, or send any other feedback
|
|
3
|
+
* to the ish team straight from the terminal.
|
|
4
|
+
*
|
|
5
|
+
* Transport: posts to the ish backend (`POST /api/v1/product-feedback` via
|
|
6
|
+
* ApiClient) with the user's bearer token. The backend inserts into the
|
|
7
|
+
* `product_feedback` table — the same table the web app's "Send feedback"
|
|
8
|
+
* dialog writes to — using its privileged connection, so CLI reports show up
|
|
9
|
+
* alongside web feedback. Per ADR-0029 (ish-frontend), clients submit through
|
|
10
|
+
* api.ishlabs.io, NEVER via Supabase PostgREST directly: the `sb_publishable_*`
|
|
11
|
+
* key shipped in this binary is deliberately powerless against the database.
|
|
12
|
+
* (The web flow additionally mirrors to Notion + captures PostHog server-side;
|
|
13
|
+
* those are deferred for the CLI.)
|
|
14
|
+
*
|
|
15
|
+
* Diagnostics: to help whoever triages the report, the user's message is
|
|
16
|
+
* augmented with an environment + active-config block (opt out with
|
|
17
|
+
* --no-diagnostics), and optionally (--health) the `ish check` setup checklist
|
|
18
|
+
* plus the tail of ~/.ish/local-sim.log. The reporter's identity (user_id /
|
|
19
|
+
* user_email) is attached server-side from the JWT, not here.
|
|
20
|
+
*/
|
|
21
|
+
import type { Command } from "commander";
|
|
22
|
+
export declare function registerFeedbackCommands(program: Command): void;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish feedback — report a bug, request a feature, or send any other feedback
|
|
3
|
+
* to the ish team straight from the terminal.
|
|
4
|
+
*
|
|
5
|
+
* Transport: posts to the ish backend (`POST /api/v1/product-feedback` via
|
|
6
|
+
* ApiClient) with the user's bearer token. The backend inserts into the
|
|
7
|
+
* `product_feedback` table — the same table the web app's "Send feedback"
|
|
8
|
+
* dialog writes to — using its privileged connection, so CLI reports show up
|
|
9
|
+
* alongside web feedback. Per ADR-0029 (ish-frontend), clients submit through
|
|
10
|
+
* api.ishlabs.io, NEVER via Supabase PostgREST directly: the `sb_publishable_*`
|
|
11
|
+
* key shipped in this binary is deliberately powerless against the database.
|
|
12
|
+
* (The web flow additionally mirrors to Notion + captures PostHog server-side;
|
|
13
|
+
* those are deferred for the CLI.)
|
|
14
|
+
*
|
|
15
|
+
* Diagnostics: to help whoever triages the report, the user's message is
|
|
16
|
+
* augmented with an environment + active-config block (opt out with
|
|
17
|
+
* --no-diagnostics), and optionally (--health) the `ish check` setup checklist
|
|
18
|
+
* plus the tail of ~/.ish/local-sim.log. The reporter's identity (user_id /
|
|
19
|
+
* user_email) is attached server-side from the JWT, not here.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
25
|
+
import { loadConfig } from "../config.js";
|
|
26
|
+
import { withClient } from "../lib/command-helpers.js";
|
|
27
|
+
import { resolveApiUrl } from "../lib/auth.js";
|
|
28
|
+
import { output } from "../lib/output.js";
|
|
29
|
+
import { c } from "../lib/colors.js";
|
|
30
|
+
const { version: CLI_VERSION } = pkg;
|
|
31
|
+
const FEEDBACK_TYPES = ["bug", "feature", "other"];
|
|
32
|
+
const DESCRIPTION_MIN = 10;
|
|
33
|
+
const DESCRIPTION_MAX = 4000;
|
|
34
|
+
const TITLE_MIN = 3;
|
|
35
|
+
const TITLE_MAX = 120;
|
|
36
|
+
const TYPE_NOUN = {
|
|
37
|
+
bug: "Bug report",
|
|
38
|
+
feature: "Feature request",
|
|
39
|
+
other: "Feedback",
|
|
40
|
+
};
|
|
41
|
+
function validationError(message) {
|
|
42
|
+
// `name === "ValidationError"` maps to exit code 2 in exitCodeFromError.
|
|
43
|
+
const err = new Error(message);
|
|
44
|
+
err.name = "ValidationError";
|
|
45
|
+
return err;
|
|
46
|
+
}
|
|
47
|
+
function readStdin() {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
let data = "";
|
|
50
|
+
process.stdin.setEncoding("utf-8");
|
|
51
|
+
process.stdin.on("data", (chunk) => {
|
|
52
|
+
data += chunk;
|
|
53
|
+
});
|
|
54
|
+
process.stdin.on("end", () => resolve(data));
|
|
55
|
+
process.stdin.on("error", reject);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the feedback body from the positional argument, an `@file`
|
|
60
|
+
* reference, or piped stdin — the same input idioms the rest of the CLI uses
|
|
61
|
+
* for text fields (no interactive prompt; the CLI is for autonomous agents).
|
|
62
|
+
*/
|
|
63
|
+
async function resolveDescription(message) {
|
|
64
|
+
if (message && message.startsWith("@")) {
|
|
65
|
+
const path = message.slice(1);
|
|
66
|
+
try {
|
|
67
|
+
return readFileSync(path, "utf-8");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error(`Cannot read file: ${path}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Explicit stdin sentinel, or no argument with something piped in.
|
|
74
|
+
if (message === "-" || (message === undefined && !process.stdin.isTTY)) {
|
|
75
|
+
return readStdin();
|
|
76
|
+
}
|
|
77
|
+
if (message === undefined) {
|
|
78
|
+
throw validationError('No feedback message. Pass it as an argument, pipe it on stdin, or use @file:\n' +
|
|
79
|
+
' ish feedback "the save button on the profile page does nothing"\n' +
|
|
80
|
+
' ish feedback --type feature "let me export interactions as CSV"\n' +
|
|
81
|
+
" ish feedback @bug-report.md\n" +
|
|
82
|
+
' echo "..." | ish feedback -');
|
|
83
|
+
}
|
|
84
|
+
return message;
|
|
85
|
+
}
|
|
86
|
+
function deriveTitle(explicit, description, type) {
|
|
87
|
+
if (explicit !== undefined) {
|
|
88
|
+
const t = explicit.trim();
|
|
89
|
+
if (t.length < TITLE_MIN) {
|
|
90
|
+
throw validationError(`--title must be at least ${TITLE_MIN} characters.`);
|
|
91
|
+
}
|
|
92
|
+
return t.slice(0, TITLE_MAX);
|
|
93
|
+
}
|
|
94
|
+
// Mirror the web dialog: first non-empty line of the message becomes the
|
|
95
|
+
// title, capped to TITLE_MAX. Fall back to a generic title so the table's
|
|
96
|
+
// NOT NULL + min-length contract is always satisfied even for terse bodies.
|
|
97
|
+
const firstLine = description
|
|
98
|
+
.split("\n")
|
|
99
|
+
.map((l) => l.trim())
|
|
100
|
+
.find((l) => l.length > 0) ?? "";
|
|
101
|
+
const candidate = firstLine.slice(0, TITLE_MAX).trim();
|
|
102
|
+
return candidate.length >= TITLE_MIN ? candidate : `${TYPE_NOUN[type]} from CLI`;
|
|
103
|
+
}
|
|
104
|
+
// ── Diagnostics ────────────────────────────────────────────────────────────
|
|
105
|
+
//
|
|
106
|
+
// Appended to the message so whoever debugs the report has grounded context.
|
|
107
|
+
// None of this needs the JWT — the backend attaches the reporter's identity
|
|
108
|
+
// from the token. The aim is maximal useful signal without a schema change.
|
|
109
|
+
/** Always-on, instant one-liner: the bare minimum a debugger needs. */
|
|
110
|
+
function platformLine(dev) {
|
|
111
|
+
return `Sent via ish CLI v${CLI_VERSION} · ${process.platform} ${process.arch} · node ${process.versions.node}${dev ? " · dev" : ""}`;
|
|
112
|
+
}
|
|
113
|
+
/** Active-config context (instant; no subprocesses). Default-on. */
|
|
114
|
+
function diagnosticsBlock(apiUrl) {
|
|
115
|
+
const cfg = loadConfig();
|
|
116
|
+
const lines = [
|
|
117
|
+
"Diagnostics (auto-attached; pass --no-diagnostics to omit)",
|
|
118
|
+
`- Active workspace: ${cfg.workspace ?? "none"}`,
|
|
119
|
+
`- Active study: ${cfg.study ?? "none"}`,
|
|
120
|
+
`- Active ask: ${cfg.ask ?? "none"}`,
|
|
121
|
+
`- API: ${apiUrl}`,
|
|
122
|
+
];
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
125
|
+
/** Setup/health checklist, reusing `ish check`'s probes. Heavier (subprocess
|
|
126
|
+
* probes for xcrun/adb), so gated behind --health. */
|
|
127
|
+
async function healthBlock() {
|
|
128
|
+
try {
|
|
129
|
+
const { runChecks } = await import("./doctor.js");
|
|
130
|
+
const checks = await runChecks();
|
|
131
|
+
const MARK = {
|
|
132
|
+
pass: "OK",
|
|
133
|
+
warn: "WARN",
|
|
134
|
+
fail: "FAIL",
|
|
135
|
+
skip: "--",
|
|
136
|
+
};
|
|
137
|
+
const lines = checks.map((ch) => {
|
|
138
|
+
const fix = (ch.status === "warn" || ch.status === "fail") && ch.fix
|
|
139
|
+
? ` (fix: ${ch.fix})`
|
|
140
|
+
: "";
|
|
141
|
+
return `- [${MARK[ch.status] ?? ch.status}] ${ch.name}: ${ch.message ?? ""}${fix}`.trimEnd();
|
|
142
|
+
});
|
|
143
|
+
return ["Setup checks (ish check):", ...lines].join("\n");
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
return `Setup checks: could not run (${e instanceof Error ? e.message : String(e)}).`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Tail of the local-simulation debug log, when present. Written to
|
|
150
|
+
* ~/.ish/local-sim.log by `study run --local --debug`. */
|
|
151
|
+
function localSimLogTail(maxLines = 80) {
|
|
152
|
+
// Match the literal path debug.ts writes to (it uses homedir()/.ish, not the
|
|
153
|
+
// ISH_HOME-aware paths helper), so we read exactly where the log lands.
|
|
154
|
+
const logFile = join(homedir(), ".ish", "local-sim.log");
|
|
155
|
+
if (!existsSync(logFile))
|
|
156
|
+
return undefined;
|
|
157
|
+
try {
|
|
158
|
+
const tail = readFileSync(logFile, "utf-8").split("\n").slice(-maxLines).join("\n").trim();
|
|
159
|
+
if (!tail)
|
|
160
|
+
return undefined;
|
|
161
|
+
return [
|
|
162
|
+
`Recent local-sim log (last ${maxLines} lines of ~/.ish/local-sim.log):`,
|
|
163
|
+
"```",
|
|
164
|
+
tail,
|
|
165
|
+
"```",
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Assemble everything appended to the user's message. */
|
|
173
|
+
async function buildAppendix(opts) {
|
|
174
|
+
const sections = [platformLine(opts.dev)];
|
|
175
|
+
if (opts.diagnostics)
|
|
176
|
+
sections.push(diagnosticsBlock(opts.apiUrl));
|
|
177
|
+
if (opts.health) {
|
|
178
|
+
sections.push(await healthBlock());
|
|
179
|
+
const log = localSimLogTail();
|
|
180
|
+
if (log)
|
|
181
|
+
sections.push(log);
|
|
182
|
+
}
|
|
183
|
+
return "\n\n—\n" + sections.join("\n\n");
|
|
184
|
+
}
|
|
185
|
+
export function registerFeedbackCommands(program) {
|
|
186
|
+
program
|
|
187
|
+
.command("feedback")
|
|
188
|
+
.description("Report a bug or send feedback to the ish team")
|
|
189
|
+
.argument("[message]", "Feedback text. Use @path to read from a file, or - to read from stdin.")
|
|
190
|
+
.option("--type <type>", "Feedback type: bug | feature | other", "bug")
|
|
191
|
+
.option("--title <title>", "Short title (default: first line of the message)")
|
|
192
|
+
.option("--no-diagnostics", "Don't attach environment/account diagnostics to the report")
|
|
193
|
+
.option("--health", "Also run setup checks (ish check) and attach recent local-sim logs — useful for simulation/setup bugs")
|
|
194
|
+
.option("--dry-run", "Print exactly what would be sent (including diagnostics) without submitting")
|
|
195
|
+
.addHelpText("after", `\nExamples:
|
|
196
|
+
$ ish feedback "the save button on the profile page does nothing"
|
|
197
|
+
$ ish feedback --type feature "let me export interactions as CSV"
|
|
198
|
+
$ ish feedback --type other "the guided setup was great"
|
|
199
|
+
$ ish feedback @bug-report.md
|
|
200
|
+
$ ish study run … --local --debug 2>err.txt; ish feedback @err.txt --health
|
|
201
|
+
$ git log -1 --oneline | ish feedback -
|
|
202
|
+
|
|
203
|
+
Reports go to the ish team and show up alongside web feedback. Sends as the
|
|
204
|
+
logged-in user (run \`ish login\` first). bug is the default type. To give
|
|
205
|
+
whoever debugs it the most signal, attach the failing command's output via
|
|
206
|
+
@file or stdin; environment + active-config diagnostics are attached
|
|
207
|
+
automatically (opt out with --no-diagnostics), and --health adds setup
|
|
208
|
+
checks + local-sim logs.`)
|
|
209
|
+
.action(async (message, opts, cmd) => {
|
|
210
|
+
await withClient(cmd, async (client, globals) => {
|
|
211
|
+
const type = opts.type;
|
|
212
|
+
if (!FEEDBACK_TYPES.includes(type)) {
|
|
213
|
+
throw validationError(`Invalid --type "${opts.type}". Must be one of: ${FEEDBACK_TYPES.join(", ")}.`);
|
|
214
|
+
}
|
|
215
|
+
const rawDescription = await resolveDescription(message);
|
|
216
|
+
const description = rawDescription.trim();
|
|
217
|
+
if (description.length === 0) {
|
|
218
|
+
throw validationError('No feedback message. Pass it as an argument, pipe it on stdin, or use @file:\n' +
|
|
219
|
+
' ish feedback "the save button on the profile page does nothing"\n' +
|
|
220
|
+
' ish feedback --type feature "let me export interactions as CSV"\n' +
|
|
221
|
+
" ish feedback @bug-report.md");
|
|
222
|
+
}
|
|
223
|
+
if (description.length < DESCRIPTION_MIN) {
|
|
224
|
+
throw validationError(`Please add a bit more detail (at least ${DESCRIPTION_MIN} characters).`);
|
|
225
|
+
}
|
|
226
|
+
if (description.length > DESCRIPTION_MAX) {
|
|
227
|
+
throw validationError(`Feedback is too long (${description.length} chars; max ${DESCRIPTION_MAX}).`);
|
|
228
|
+
}
|
|
229
|
+
const title = deriveTitle(opts.title, description, type);
|
|
230
|
+
const apiUrl = resolveApiUrl(globals.apiUrl, globals.dev);
|
|
231
|
+
const appendix = await buildAppendix({
|
|
232
|
+
apiUrl,
|
|
233
|
+
dev: globals.dev,
|
|
234
|
+
diagnostics: opts.diagnostics,
|
|
235
|
+
health: opts.health ?? false,
|
|
236
|
+
});
|
|
237
|
+
const body = {
|
|
238
|
+
type,
|
|
239
|
+
title,
|
|
240
|
+
description: description + appendix,
|
|
241
|
+
};
|
|
242
|
+
if (opts.dryRun) {
|
|
243
|
+
if (!globals.quiet) {
|
|
244
|
+
console.error(`${c.dim}Dry run — nothing sent. This is what would be submitted:${c.reset}`);
|
|
245
|
+
}
|
|
246
|
+
output(body, globals.json);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!globals.quiet) {
|
|
250
|
+
console.error(`Sending ${type} report…`);
|
|
251
|
+
}
|
|
252
|
+
const data = await client.post("/product-feedback", body);
|
|
253
|
+
if (!globals.quiet) {
|
|
254
|
+
console.error(`${c.green}✓${c.reset} Thanks! Your ${type} report was sent to the ish team.`);
|
|
255
|
+
}
|
|
256
|
+
output(data, globals.json, { writePath: true });
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
@@ -9,7 +9,7 @@ import { readFileSync } from "node:fs";
|
|
|
9
9
|
import { withClient, resolveStudy, resolveWorkspace, readFileOrStdin, collectIds } from "../lib/command-helpers.js";
|
|
10
10
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
11
11
|
import { output, formatIterationList, ValidationError } from "../lib/output.js";
|
|
12
|
-
import { resolveContentUrl, resolveContentUrls, resolveTextContent } from "../lib/upload.js";
|
|
12
|
+
import { resolveContentUrl, resolveContentUrls, resolveTextContent, archiveHtmlImages } from "../lib/upload.js";
|
|
13
13
|
import { isMediaModality, validateIterationDetails, normalizeChatMode, validateRoleCriteria } from "../lib/modality.js";
|
|
14
14
|
import { validateSegmentation, warnIfOverSegmented } from "../lib/segmentation.js";
|
|
15
15
|
import { normalizeEnumValue, SCREEN_FORMATS } from "../lib/enums.js";
|
|
@@ -297,7 +297,9 @@ function buildIterationDetails(modality, opts) {
|
|
|
297
297
|
if (opts.platform === "figma" && (!opts.fileKey || !opts.startNodeId)) {
|
|
298
298
|
throw new Error("Figma interactive iterations require both --file-key and --start-node-id.");
|
|
299
299
|
}
|
|
300
|
-
|
|
300
|
+
// Native (ios/android) targets are phones — default to mobile_portrait
|
|
301
|
+
// rather than desktop. An explicit --screen-format still wins below.
|
|
302
|
+
let screenFormat = isNativePlatform(opts.platform) ? "mobile_portrait" : "desktop";
|
|
301
303
|
if (opts.screenFormat !== undefined) {
|
|
302
304
|
const normalized = normalizeEnumValue(opts.screenFormat, SCREEN_FORMATS);
|
|
303
305
|
if (normalized === null) {
|
|
@@ -385,7 +387,7 @@ Concept pages: ish docs get-page concepts/iteration
|
|
|
385
387
|
.option("--platform <platform>", "Platform (browser, android, ios, figma, code) — interactive only")
|
|
386
388
|
.option("--url <url>", "URL to test — interactive only (optional for ios/android native apps)")
|
|
387
389
|
.option("--app <id>", "Native app bundle id (or .app/.apk path) — ios/android; supplies the iteration target so --url isn't required")
|
|
388
|
-
.option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only; hyphen/underscore variants accepted")
|
|
390
|
+
.option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only; hyphen/underscore variants accepted. Default: desktop, or mobile_portrait for native ios/android")
|
|
389
391
|
.option("--locale <locale>", "Locale code (e.g. en-US) — interactive only")
|
|
390
392
|
.option("--file-key <key>", "Figma file key — required when --platform=figma")
|
|
391
393
|
.option("--start-node-id <id>", "Figma start node id — required when --platform=figma")
|
|
@@ -622,8 +624,15 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
|
622
624
|
if (isMedia) {
|
|
623
625
|
if (resolved.contentText)
|
|
624
626
|
resolved.contentText = resolveTextContent(resolved.contentText);
|
|
625
|
-
if (resolved.contentHtml)
|
|
627
|
+
if (resolved.contentHtml) {
|
|
626
628
|
resolved.contentHtml = resolveTextContent(resolved.contentHtml);
|
|
629
|
+
// Archive external <img> images onto workspace storage so the
|
|
630
|
+
// render-to-image worker (egress-denied to other origins) can
|
|
631
|
+
// fetch them. Mirrors the FE paste pipeline; text modality only.
|
|
632
|
+
if (modality === "text") {
|
|
633
|
+
resolved.contentHtml = await archiveHtmlImages(client, studyId, resolved.contentHtml, { quiet: globals.quiet });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
627
636
|
if (resolved.copyText)
|
|
628
637
|
resolved.copyText = resolveTextContent(resolved.copyText);
|
|
629
638
|
if (resolved.copyHtml)
|
|
@@ -16,13 +16,10 @@ import { fetchStudyParticipants } from "../lib/study-participants.js";
|
|
|
16
16
|
import { streamStudyEvents } from "../lib/study-events.js";
|
|
17
17
|
import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readParticipantPairConfig, summarizeRoleCriteria, toModality, } from "../lib/modality.js";
|
|
18
18
|
// NOTE: local-sim modules are loaded via dynamic import at the `--local`
|
|
19
|
-
// branch below, NOT statically here
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// would crash *every* `ish` invocation on the npm-installed CLI, not
|
|
24
|
-
// just `study run --local`. The bun-compiled binary bundles the deep
|
|
25
|
-
// path so it doesn't hit Node's resolver; only the npm path is sensitive.
|
|
19
|
+
// branch below, NOT statically here, so that plain API commands never pay
|
|
20
|
+
// for (or crash on) playwright-core. The registry deep import inside
|
|
21
|
+
// `local-sim/install.ts` is itself lazy for the same reason — see the
|
|
22
|
+
// comment in `installBrowser()`.
|
|
26
23
|
import { estimateChatPair, estimateChatSolo, estimateMediaRun } from "../lib/billing.js";
|
|
27
24
|
import { reportReadiness } from "../lib/report-readiness.js";
|
|
28
25
|
import { runChecks, scopeChecks, overall } from "./doctor.js";
|
|
@@ -324,7 +321,7 @@ export function attachStudyRunCommands(study) {
|
|
|
324
321
|
.option("--slow-mo <ms>", "Slow down actions by ms (local mode only)")
|
|
325
322
|
.option("--devtools", "Open Chrome DevTools (local mode only)")
|
|
326
323
|
.option("--debug", "Enable detailed debug logging to stderr and ~/.ish/local-sim.log")
|
|
327
|
-
.option("--parallel <n>", "Run N participants in parallel (local mode only
|
|
324
|
+
.option("--parallel <n>", "Run N participants in parallel (local mode only). Browser: default all. Native iOS/Android: pools N auto-provisioned devices — simulators (iOS) / headless emulators from your AVDs (Android) — default 1, capped at 5, auto-sized to host RAM (and AVD count).")
|
|
328
325
|
.option("--platform <platform>", "Local target platform: 'web' (Playwright), 'android' (adb emulator), or 'ios' (simctl+idb simulator). Defaults to the iteration's platform.")
|
|
329
326
|
.option("--app <path>", "Native local mode: path to an .apk (android) / .app (ios) to install, or an installed package/bundle id to launch. The extension implies --platform.")
|
|
330
327
|
.addHelpText("after", `
|
|
@@ -737,10 +734,6 @@ Examples:
|
|
|
737
734
|
}
|
|
738
735
|
log("");
|
|
739
736
|
}
|
|
740
|
-
if (opts.local) {
|
|
741
|
-
const { ensureBrowser } = await import("../lib/local-sim/install.js");
|
|
742
|
-
await ensureBrowser({ quiet: globals.quiet, skipPrompt: globals.json });
|
|
743
|
-
}
|
|
744
737
|
// Step 5: Either reuse the iteration's participants or batch-create new ones
|
|
745
738
|
let createdParticipants;
|
|
746
739
|
// Pair-mode bookkeeping: the dispatch endpoint takes
|
|
@@ -763,6 +756,13 @@ Examples:
|
|
|
763
756
|
?? platformFromApp
|
|
764
757
|
?? detailsView.platform
|
|
765
758
|
?? "browser";
|
|
759
|
+
// Chromium is only needed for the browser local path. iOS/Android
|
|
760
|
+
// local runs drive a simulator/emulator and must not block on (or
|
|
761
|
+
// prompt for) a browser download.
|
|
762
|
+
if (opts.local && normalizePlatform(resolvedPlatform) === "browser") {
|
|
763
|
+
const { ensureBrowser } = await import("../lib/local-sim/install.js");
|
|
764
|
+
await ensureBrowser({ quiet: globals.quiet, skipPrompt: globals.json });
|
|
765
|
+
}
|
|
766
766
|
// Best-effort native-readiness report. When this is a LOCAL native run
|
|
767
767
|
// (iOS/Android driven on this developer's machine), fire-and-forget a
|
|
768
768
|
// fresh, platform-scoped `runChecks()` to the backend so the web app
|
|
@@ -23,14 +23,16 @@ import { resolveId } from "../lib/alias-store.js";
|
|
|
23
23
|
import { output, printTable } from "../lib/output.js";
|
|
24
24
|
import { ApiError } from "../lib/api-client.js";
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* The frame-grouped screenshot INDEX (`/screenshots/grouped`) is a remote-run
|
|
27
|
+
* artifact — it groups by frame_version_id, which local runs don't create — and
|
|
28
|
+
* the endpoint currently 500s for a local-only study instead of returning an
|
|
29
|
+
* empty index. Local (`--local`) runs DO still capture per-interaction
|
|
30
|
+
* screenshots; they just live on the participant rows, not in this index. Tag
|
|
31
|
+
* this hint onto the error so the bare 500 points the user at where they ARE.
|
|
30
32
|
*/
|
|
31
33
|
const LOCAL_RUN_SCREENSHOT_HINT = [
|
|
32
|
-
"
|
|
33
|
-
"Ran this study locally (--local)?
|
|
34
|
+
"The frame-grouped screenshot index is a remote-run artifact (this endpoint may 500 for local-only studies).",
|
|
35
|
+
"Ran this study locally (--local)? Per-interaction screenshots ARE captured — read them via `ish study get <id>` (each interaction carries a screenshot_url), or open the per-step HTML debug report under ~/.ish/debug/ (path printed at the end of each local run).",
|
|
34
36
|
];
|
|
35
37
|
/**
|
|
36
38
|
* GET the frame-grouped screenshot index, tagging the local-run hint onto any
|
|
@@ -136,12 +138,13 @@ Examples:
|
|
|
136
138
|
$ ish study screenshots download <study-id> --id <scid> --out shot.png
|
|
137
139
|
$ ish study screenshots download <study-id> --all --out ./shots/
|
|
138
140
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
This frame-grouped index is built by remote interactive runs — chat / video /
|
|
142
|
+
text studies don't populate it, and neither do local runs (\`ish study run
|
|
143
|
+
--local\`). Local runs still CAPTURE per-interaction screenshots: read them via
|
|
144
|
+
\`ish study get <id>\` (each interaction carries a screenshot_url) or the per-step
|
|
145
|
+
HTML debug report under ~/.ish/debug/ (path printed at the end of each local
|
|
146
|
+
run). Each row's storage URL is self-credentialed, so the CLI fetches bytes
|
|
147
|
+
without forwarding your bearer.`);
|
|
145
148
|
screenshots
|
|
146
149
|
.command("list", { isDefault: true })
|
|
147
150
|
.description("List screenshots for a study (frame-grouped).")
|
package/dist/commands/study.js
CHANGED
|
@@ -350,8 +350,20 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
350
350
|
validateSegmentation(inlineMediaExtras.segmentation);
|
|
351
351
|
warnIfOverSegmented(inlineMediaExtras.segmentation, { quiet: globals.quietExplicit });
|
|
352
352
|
}
|
|
353
|
+
let inlineContentHtml;
|
|
354
|
+
if (opts.contentHtml) {
|
|
355
|
+
inlineContentHtml = opts.contentHtml.startsWith("@")
|
|
356
|
+
? readFileSync(opts.contentHtml.slice(1), "utf8")
|
|
357
|
+
: opts.contentHtml;
|
|
358
|
+
// The study does not exist yet here, so we cannot archive remote
|
|
359
|
+
// images onto workspace storage (the render worker egress-denies
|
|
360
|
+
// other origins). Point the operator at the archive-capable flow.
|
|
361
|
+
if (/<img\b[^>]*\bsrc\s*=\s*["']https?:\/\//i.test(inlineContentHtml) && !globals.quietExplicit) {
|
|
362
|
+
process.stderr.write("Note: --content-html has remote <img> images, which `study create` cannot archive (the study does not exist yet) — they will not render. To archive them, run `ish study create` without content, then `ish iteration create --content-html ...`.\n");
|
|
363
|
+
}
|
|
364
|
+
}
|
|
353
365
|
const inlineEmailExtras = {
|
|
354
|
-
...(
|
|
366
|
+
...(inlineContentHtml !== undefined && { content_html: inlineContentHtml }),
|
|
355
367
|
...(opts.senderName && { sender_name: opts.senderName }),
|
|
356
368
|
...(opts.senderEmail && { sender_email: opts.senderEmail }),
|
|
357
369
|
...(opts.featuredImageUrl && { featured_image_url: opts.featuredImageUrl }),
|
|
@@ -1244,13 +1256,20 @@ checklists ("steps") ride along when present in the JSON forms
|
|
|
1244
1256
|
if (!id) {
|
|
1245
1257
|
throw new Error("Provide a study alias or UUID, or use --clear.");
|
|
1246
1258
|
}
|
|
1247
|
-
await withClient(cmd, async (client) => {
|
|
1259
|
+
await withClient(cmd, async (client, globals) => {
|
|
1248
1260
|
const rid = resolveId(id);
|
|
1249
1261
|
const data = await client.get(`/studies/${rid}`);
|
|
1250
1262
|
const config = loadConfig();
|
|
1251
1263
|
config.study = rid;
|
|
1252
1264
|
saveConfig(config);
|
|
1253
|
-
|
|
1265
|
+
// stdout = data: emit a JSON object so `study use --json` is capturable
|
|
1266
|
+
// (e.g. `--get alias`); the human confirmation stays on stderr.
|
|
1267
|
+
if (globals.json) {
|
|
1268
|
+
output({ id: rid, alias: tagAlias(ALIAS_PREFIX.study, rid), name: data.name ?? null, active: true }, true, { writePath: true });
|
|
1269
|
+
}
|
|
1270
|
+
else {
|
|
1271
|
+
console.error(`Active study set to "${data.name || rid}".`);
|
|
1272
|
+
}
|
|
1254
1273
|
});
|
|
1255
1274
|
});
|
|
1256
1275
|
attachStudyRunCommands(study);
|
|
@@ -296,7 +296,7 @@ async function collectWorkspaceUsage(client, workspaceId) {
|
|
|
296
296
|
people_used: typeof participants.total === "number" ? participants.total : 0,
|
|
297
297
|
people_max: lookupLimit("maxCustomPersons"),
|
|
298
298
|
concurrent_participants_max: lookupLimit("maxConcurrentParticipants"),
|
|
299
|
-
workspace_members_max: lookupLimit("
|
|
299
|
+
workspace_members_max: lookupLimit("maxSeats"),
|
|
300
300
|
};
|
|
301
301
|
}
|
|
302
302
|
// ---------------------------------------------------------------------------
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { registerInitCommands } from "./commands/init.js";
|
|
|
20
20
|
import { registerMcpCommands } from "./commands/mcp.js";
|
|
21
21
|
import { registerSecretCommands } from "./commands/secret.js";
|
|
22
22
|
import { registerDoctorCommands } from "./commands/doctor.js";
|
|
23
|
+
import { registerFeedbackCommands } from "./commands/feedback.js";
|
|
23
24
|
import { AGENT_HELP_FOOTER } from "./lib/docs.js";
|
|
24
25
|
import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
|
|
25
26
|
import { resolveApiUrl, resolveToken, verifyToken } from "./lib/auth.js";
|
|
@@ -512,6 +513,7 @@ registerInitCommands(program);
|
|
|
512
513
|
registerMcpCommands(program);
|
|
513
514
|
registerSecretCommands(program);
|
|
514
515
|
registerDoctorCommands(program);
|
|
516
|
+
registerFeedbackCommands(program);
|
|
515
517
|
program
|
|
516
518
|
.command("upgrade")
|
|
517
519
|
.description("Update ish to the latest version")
|
|
@@ -6,7 +6,7 @@ import * as fs from "node:fs";
|
|
|
6
6
|
import { resolveApiUrl, resolveToken } from "./auth.js";
|
|
7
7
|
import { getAppUrl } from "../auth.js";
|
|
8
8
|
import { ApiClient, ApiError } from "./api-client.js";
|
|
9
|
-
import { outputError, setVerbose, setFields, setGetField } from "./output.js";
|
|
9
|
+
import { outputError, shouldSuggestReport, setVerbose, setFields, setGetField } from "./output.js";
|
|
10
10
|
import { setColorsEnabled, colorsEnabled } from "./colors.js";
|
|
11
11
|
import { loadConfig } from "../config.js";
|
|
12
12
|
import { resolveId } from "./alias-store.js";
|
|
@@ -513,7 +513,10 @@ export async function withClient(cmd, fn) {
|
|
|
513
513
|
await fn(client, globals);
|
|
514
514
|
}
|
|
515
515
|
catch (err) {
|
|
516
|
-
|
|
516
|
+
// Nudge to report genuine faults — but never when the failing command
|
|
517
|
+
// IS `ish feedback` (don't suggest reporting via the thing that broke).
|
|
518
|
+
const suggestReport = shouldSuggestReport(err) && commandPath(cmd).split(".")[0] !== "feedback";
|
|
519
|
+
outputError(err, globals.json, { suggestReport });
|
|
517
520
|
await exitWithFlush(exitCodeFromError(err));
|
|
518
521
|
}
|
|
519
522
|
});
|
|
@@ -530,7 +533,8 @@ export async function runInline(cmd, fn) {
|
|
|
530
533
|
await fn(globals);
|
|
531
534
|
}
|
|
532
535
|
catch (err) {
|
|
533
|
-
|
|
536
|
+
const suggestReport = shouldSuggestReport(err) && commandPath(cmd).split(".")[0] !== "feedback";
|
|
537
|
+
outputError(err, globals.json, { suggestReport });
|
|
534
538
|
await exitWithFlush(exitCodeFromError(err));
|
|
535
539
|
}
|
|
536
540
|
});
|