@houwert/conductor 0.21.0 → 0.22.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.
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.HELP = void 0;
7
+ exports.webTargets = webTargets;
8
+ /**
9
+ * List the CDP page targets exposed by an external browser (e.g. an Electron app
10
+ * launched with `--remote-debugging-port`). Each target is a controllable page —
11
+ * for the Lightning emulator, one per tile plus its control/remote chrome.
12
+ *
13
+ * Reads the DevTools HTTP endpoint (`/json/list`) directly, so it needs no
14
+ * Playwright browser and works before any daemon session exists. Use the printed
15
+ * target IDs with `--cdp-url` / `--cdp-target` to bind a session to a tile.
16
+ */
17
+ const http_1 = __importDefault(require("http"));
18
+ const output_js_1 = require("../output.js");
19
+ /** Derive the `http://host:port` base from a CDP URL (which may be ws:// or include a path). */
20
+ function httpBase(cdpUrl) {
21
+ const u = new URL(cdpUrl);
22
+ const proto = u.protocol === 'https:' || u.protocol === 'wss:' ? 'https:' : 'http:';
23
+ return `${proto}//${u.host}`;
24
+ }
25
+ function fetchTargets(cdpUrl) {
26
+ const url = `${httpBase(cdpUrl)}/json/list`;
27
+ return new Promise((resolve, reject) => {
28
+ const req = http_1.default.get(url, (res) => {
29
+ const chunks = [];
30
+ res.on('data', (c) => chunks.push(c));
31
+ res.on('end', () => {
32
+ if ((res.statusCode ?? 0) >= 300) {
33
+ reject(new Error(`HTTP ${res.statusCode} from ${url}`));
34
+ return;
35
+ }
36
+ try {
37
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
38
+ }
39
+ catch (err) {
40
+ reject(err);
41
+ }
42
+ });
43
+ });
44
+ req.setTimeout(5000, () => req.destroy(new Error(`Timed out fetching ${url}`)));
45
+ req.on('error', reject);
46
+ });
47
+ }
48
+ async function webTargets(cdpUrl, opts) {
49
+ if (!cdpUrl) {
50
+ console.error('web-targets requires --cdp-url <url> (e.g. --cdp-url http://127.0.0.1:9222).\n' +
51
+ 'Launch the browser/Electron app with --remote-debugging-port to expose it.');
52
+ return 1;
53
+ }
54
+ let targets;
55
+ try {
56
+ targets = await fetchTargets(cdpUrl);
57
+ }
58
+ catch (err) {
59
+ console.error(`Could not reach CDP endpoint at ${cdpUrl}: ${err instanceof Error ? err.message : String(err)}`);
60
+ return 1;
61
+ }
62
+ // Only type="page" targets are controllable as Playwright Pages.
63
+ const pages = targets.filter((t) => t.type === 'page');
64
+ if (opts.json) {
65
+ (0, output_js_1.printData)(pages.map((t) => ({ id: t.id, title: t.title, url: t.url })), opts);
66
+ return 0;
67
+ }
68
+ if (pages.length === 0) {
69
+ console.log('No page targets found. Is the app loaded and started with --remote-debugging-port?');
70
+ return 0;
71
+ }
72
+ console.log(`Found ${pages.length} page target(s) at ${cdpUrl}:\n`);
73
+ pages.forEach((t, i) => {
74
+ console.log(` [${i}] ${t.title || '(untitled)'}`);
75
+ console.log(` url: ${t.url}`);
76
+ console.log(` target: ${t.id}`);
77
+ console.log(` bind: conductor --device web:chromium:t${i} --cdp-url ${cdpUrl} --cdp-target ${t.id} inspect`);
78
+ console.log('');
79
+ });
80
+ console.log('Bind a session to a target once (any command), then drop the --cdp-* flags on later\n' +
81
+ 'commands for that --device — the attachment is remembered per session.');
82
+ return 0;
83
+ }
84
+ exports.HELP = ' web-targets --cdp-url <url> List controllable CDP page targets (one per Electron webview/tile)';
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isMetroPort = isMetroPort;
4
4
  exports.discoverMetroPortForDevice = discoverMetroPortForDevice;
5
5
  exports.getDeviceDisplayName = getDeviceDisplayName;
6
+ exports.deviceNameMatches = deviceNameMatches;
6
7
  exports.selectTargetForDevice = selectTargetForDevice;
7
8
  exports.targetsForDevice = targetsForDevice;
8
9
  /**
@@ -200,6 +201,16 @@ async function getDeviceDisplayName(platform, deviceId) {
200
201
  }
201
202
  return null;
202
203
  }
204
+ /**
205
+ * Whether a Metro target's `deviceName` refers to the same device as `displayName`.
206
+ * Tolerant of the suffixes Metro appends to the bare model name — e.g. Android's
207
+ * `ro.product.model` is `Chromecast` while Metro reports `Chromecast - 14 - API 34`.
208
+ */
209
+ function deviceNameMatches(targetDeviceName, displayName) {
210
+ if (!targetDeviceName)
211
+ return false;
212
+ return targetDeviceName === displayName || targetDeviceName.startsWith(`${displayName} `);
213
+ }
203
214
  /**
204
215
  * Filter Metro /json targets to those belonging to this device (by display
205
216
  * name), preferring the fusebox runtime if present. Returns undefined when
@@ -208,7 +219,7 @@ async function getDeviceDisplayName(platform, deviceId) {
208
219
  */
209
220
  function selectTargetForDevice(targets, displayName) {
210
221
  const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
211
- const matches = withWs.filter((t) => t.deviceName === displayName);
222
+ const matches = withWs.filter((t) => deviceNameMatches(t.deviceName, displayName));
212
223
  if (matches.length === 0)
213
224
  return undefined;
214
225
  const fusebox = matches.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend);
@@ -216,5 +227,5 @@ function selectTargetForDevice(targets, displayName) {
216
227
  }
217
228
  /** Convenience: all /json targets belonging to this device. */
218
229
  function targetsForDevice(targets, displayName) {
219
- return targets.filter((t) => t.webSocketDebuggerUrl && t.deviceName === displayName);
230
+ return targets.filter((t) => t.webSocketDebuggerUrl && deviceNameMatches(t.deviceName, displayName));
220
231
  }
@@ -40,12 +40,20 @@ function selectDebuggerUrl(targets, opts, displayName) {
40
40
  }
41
41
  return withWs[opts.targetIndex].webSocketDebuggerUrl;
42
42
  }
43
- if (displayName) {
44
- const target = (0, metro_discovery_js_1.selectTargetForDevice)(withWs, displayName);
43
+ // Device-scoped: must resolve to that device's own target. Never silently
44
+ // fall back to another device — that reloads the wrong app and reports success.
45
+ if (opts.deviceId) {
46
+ const target = displayName ? (0, metro_discovery_js_1.selectTargetForDevice)(withWs, displayName) : undefined;
45
47
  if (target)
46
48
  return target.webSocketDebuggerUrl;
49
+ const available = withWs
50
+ .map((t, i) => ` [${i}] ${t.deviceName ?? t.title ?? '(unnamed)'}`)
51
+ .join('\n');
52
+ throw new Error(`No Metro debugger target for device ${opts.deviceId}` +
53
+ (displayName ? ` (${displayName})` : '') +
54
+ `.\nAvailable targets:\n${available}\nPass --target <index> to pick one explicitly.`);
47
55
  }
48
- // Prefer the Hermes/React target by title, otherwise first.
56
+ // No device requested: prefer the Hermes/React target by title, otherwise first.
49
57
  const target = withWs.find((t) => t.title && /hermes|react/i.test(t.title)) ?? withWs[0];
50
58
  return target.webSocketDebuggerUrl;
51
59
  }
package/dist/index.js CHANGED
@@ -61,6 +61,8 @@ const memory_js_1 = require("./commands/memory.js");
61
61
  const metro_js_1 = require("./commands/metro.js");
62
62
  const clipboard_js_1 = require("./commands/clipboard.js");
63
63
  const options_js_1 = require("./commands/options.js");
64
+ const web_targets_js_1 = require("./commands/web-targets.js");
65
+ const session_js_2 = require("./session.js");
64
66
  const device_picker_js_1 = require("./device-picker.js");
65
67
  const update_check_js_1 = require("./update-check.js");
66
68
  const pkg_root_js_1 = require("./pkg-root.js");
@@ -123,11 +125,15 @@ const COMMAND_HELP = {
123
125
  clipboard: clipboard_js_1.HELP,
124
126
  paste: ' paste Trigger OS-level paste (or type clipboard on iOS)',
125
127
  'list-options': options_js_1.HELP,
128
+ 'web-targets': web_targets_js_1.HELP,
126
129
  };
127
130
  const OPTIONS_HELP = `Options:
128
131
  --device <id> Target device ID (also keys the session and daemon)
129
132
  --device-name <n> Target a booted device by name (resolved to ID from booted devices)
130
133
  --platform <p> Filter to devices of this platform (ios, android, tvos, web)
134
+ --cdp-url <url> Attach the web driver to an existing browser over CDP (e.g. an
135
+ Electron app started with --remote-debugging-port). Remembered per session.
136
+ --cdp-target <id> Pick which CDP page target to control (see \`conductor web-targets\`)
131
137
  --json Output as machine-readable JSON
132
138
  --options List valid values for a command's enumerated parameters and exit
133
139
  --verbose, -v Log daemon calls, fallbacks, and raw output
@@ -230,6 +236,8 @@ async function main() {
230
236
  'height',
231
237
  'user-agent',
232
238
  'color-scheme',
239
+ 'cdp-url',
240
+ 'cdp-target',
233
241
  ],
234
242
  alias: { h: 'help', v: 'verbose', V: 'version', o: 'output', y: 'yes' },
235
243
  });
@@ -274,6 +282,7 @@ async function main() {
274
282
  'metro',
275
283
  'workspace',
276
284
  'list-options',
285
+ 'web-targets',
277
286
  // `logs --list` and `logs --source metro` only query Metro on localhost — no device needed
278
287
  // `logs` always needs a device session — Metro discovery is device-scoped.
279
288
  // `daemon-stop --all` stops every daemon — no device needed
@@ -309,8 +318,38 @@ async function main() {
309
318
  explicitDevice ?? (await (0, device_picker_js_1.pickDevice)(argv['platform'])) ?? 'default';
310
319
  }
311
320
  }
321
+ // CDP attach settings (web only): --cdp-url/--cdp-target map to the env the daemon
322
+ // reads. Passing them once persists them to the session so later commands for the
323
+ // same --device don't need the flags; absent flags hydrate from the saved session.
324
+ const isWebSession = sessionName === 'web' || sessionName.startsWith('web:');
325
+ if (isWebSession && !NO_DEVICE_COMMANDS.has(command)) {
326
+ const cdpUrlFlag = argv['cdp-url'];
327
+ const cdpTargetFlag = argv['cdp-target'];
328
+ if (cdpUrlFlag || cdpTargetFlag) {
329
+ if (cdpUrlFlag)
330
+ process.env.CONDUCTOR_CDP_URL = cdpUrlFlag;
331
+ if (cdpTargetFlag)
332
+ process.env.CONDUCTOR_CDP_TARGET_ID = cdpTargetFlag;
333
+ await (0, session_js_2.updateSession)({
334
+ cdpUrl: process.env.CONDUCTOR_CDP_URL,
335
+ cdpTargetId: process.env.CONDUCTOR_CDP_TARGET_ID,
336
+ }, sessionName);
337
+ }
338
+ else {
339
+ const saved = await (0, session_js_2.getSession)(sessionName);
340
+ if (saved.cdpUrl && !process.env.CONDUCTOR_CDP_URL) {
341
+ process.env.CONDUCTOR_CDP_URL = saved.cdpUrl;
342
+ }
343
+ if (saved.cdpTargetId && !process.env.CONDUCTOR_CDP_TARGET_ID) {
344
+ process.env.CONDUCTOR_CDP_TARGET_ID = saved.cdpTargetId;
345
+ }
346
+ }
347
+ }
312
348
  let exitCode = 0;
313
349
  switch (command) {
350
+ case 'web-targets':
351
+ exitCode = await (0, web_targets_js_1.webTargets)(argv['cdp-url'], opts);
352
+ break;
314
353
  case 'start-device':
315
354
  exitCode = await (0, start_device_js_1.startDevice)(argv['platform'], opts, {
316
355
  osVersion: argv['os-version'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: conductor-device-setup
3
- description: Boot, list, and manage devices and app installs for the conductor CLI — iOS simulators, Android emulators, tvOS simulators, and Playwright web browsers — plus sessions, the warm-driver daemon, and the parallel device pool. Use when starting or stopping a simulator/emulator/browser, installing or launching an app, setting up the web driver, keeping the driver warm, or coordinating multiple devices for parallel agents.
3
+ description: Boot, list, and manage devices and app installs for the conductor CLI — iOS simulators, Android emulators, tvOS simulators, and Playwright web browsers — plus sessions, the warm-driver daemon, and the parallel device pool. Use when starting or stopping a simulator/emulator/browser, installing or launching an app, setting up the web driver, attaching to an already-running browser over CDP (e.g. an Electron app / its webviews), keeping the driver warm, or coordinating multiple devices for parallel agents.
4
4
  ---
5
5
 
6
6
  # Conductor — device & app setup
@@ -19,27 +19,44 @@ conductor list-apps # installed app ids / package names
19
19
 
20
20
  ## Devices
21
21
 
22
- | Command | Purpose |
23
- |---|---|
24
- | `conductor start-device --platform <ios\|android\|tvos\|web>` | Boot a simulator/emulator or start the web driver |
25
- | `conductor start-device --os-version <n> --device-type <name>` | Pick OS version + device type (creates if needed) |
26
- | `conductor stop-device [<name-or-id>] [--all]` | Shut down device(s) |
27
- | `conductor delete-device <name-or-id> [--all]` | Delete simulator(s)/AVD(s)/web session(s) |
28
- | `conductor set-location --lat <n> --lng <n>` | Set GPS coordinates |
29
- | `conductor set-orientation <portrait\|landscape>` | Set orientation |
30
- | `conductor set-viewport [<w> <h>] [--preset mobile\|tablet\|desktop]` | Resize web viewport (web only) |
31
- | `conductor install-web [--check] [browser]` | Install a Playwright browser (chromium/firefox/webkit); `--check` = status |
22
+ | Command | Purpose |
23
+ | --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
24
+ | `conductor start-device --platform <ios\|android\|tvos\|web>` | Boot a simulator/emulator or start the web driver |
25
+ | `conductor start-device --os-version <n> --device-type <name>` | Pick OS version + device type (creates if needed) |
26
+ | `conductor stop-device [<name-or-id>] [--all]` | Shut down device(s) |
27
+ | `conductor delete-device <name-or-id> [--all]` | Delete simulator(s)/AVD(s)/web session(s) |
28
+ | `conductor set-location --lat <n> --lng <n>` | Set GPS coordinates |
29
+ | `conductor set-orientation <portrait\|landscape>` | Set orientation |
30
+ | `conductor set-viewport [<w> <h>] [--preset mobile\|tablet\|desktop]` | Resize web viewport (web only) |
31
+ | `conductor install-web [--check] [browser]` | Install a Playwright browser (chromium/firefox/webkit); `--check` = status |
32
+
33
+ ### Attach to an existing browser (CDP)
34
+
35
+ Instead of launching its own browser, the web driver can attach to one that's
36
+ already running and exposes CDP over a remote-debugging port — e.g. an Electron
37
+ app started with `--remote-debugging-port`, where each window / webview is a
38
+ separate page target you can drive independently.
39
+
40
+ | Command | Purpose |
41
+ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
42
+ | `conductor web-targets --cdp-url <url>` | List the controllable page targets the browser exposes (id, url, title) + a paste-ready bind command for each |
43
+ | `conductor --device web:<browser>:<label> --cdp-url <url> --cdp-target <id> <cmd>` | Bind a session to one target; the attachment persists so later commands for that `--device` don't need the flags |
44
+
45
+ Use a distinct fully-qualified `--device web:chromium:<label>` per target (a bare
46
+ `web` gets an auto-generated sub-id instead). Each target is its own session, so
47
+ several webviews can be driven concurrently. Only `type=page` targets are
48
+ controllable. See [Web testing → Attaching to an existing browser](../../../docs/web.md).
32
49
 
33
50
  ## App lifecycle
34
51
 
35
- | Command | Purpose |
36
- |---|---|
37
- | `conductor install-app <path>` | Install .app / .ipa / .apk |
38
- | `conductor launch-app <appId>` | Launch app (saved to session); `--no-stop-app`, `--argument key=value` |
39
- | `conductor stop-app [<appId>]` | Stop app |
40
- | `conductor uninstall-app <appId>` | Uninstall app |
41
- | `conductor copy-app <bundleId> --from <id> --to <id>` | Copy an installed app between iOS simulators |
42
- | `conductor download-app <appId> --output <path>` | Download installed app binary |
52
+ | Command | Purpose |
53
+ | ----------------------------------------------------- | ---------------------------------------------------------------------- |
54
+ | `conductor install-app <path>` | Install .app / .ipa / .apk |
55
+ | `conductor launch-app <appId>` | Launch app (saved to session); `--no-stop-app`, `--argument key=value` |
56
+ | `conductor stop-app [<appId>]` | Stop app |
57
+ | `conductor uninstall-app <appId>` | Uninstall app |
58
+ | `conductor copy-app <bundleId> --from <id> --to <id>` | Copy an installed app between iOS simulators |
59
+ | `conductor download-app <appId> --output <path>` | Download installed app binary |
43
60
 
44
61
  ### ⚠️ Destructive flags — ask the user first
45
62
 
@@ -54,15 +71,15 @@ you genuinely need one, ask the human first.
54
71
  A **session** remembers the last device + app so you don't re-specify them.
55
72
  Parallel agents each get their own `--session <name>` so they don't collide.
56
73
 
57
- | Command | Purpose |
58
- |---|---|
59
- | `conductor session [--clear] [--list]` | Show, clear, or list sessions |
60
- | `conductor daemon-start` | Start the per-session background daemon (keeps the driver warm — do this for any multi-step session) |
61
- | `conductor daemon-status` | Show daemon status |
62
- | `conductor daemon-stop [--all]` | Stop this session's daemon (`--all` = every session) |
63
- | `conductor device-pool --list` | List devices + pool status |
64
- | `conductor device-pool --acquire` | Claim a free device (prints id) |
65
- | `conductor device-pool --release <id>` | Release a device back to the pool |
74
+ | Command | Purpose |
75
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------------- |
76
+ | `conductor session [--clear] [--list]` | Show, clear, or list sessions |
77
+ | `conductor daemon-start` | Start the per-session background daemon (keeps the driver warm — do this for any multi-step session) |
78
+ | `conductor daemon-status` | Show daemon status |
79
+ | `conductor daemon-stop [--all]` | Stop this session's daemon (`--all` = every session) |
80
+ | `conductor device-pool --list` | List devices + pool status |
81
+ | `conductor device-pool --acquire` | Claim a free device (prints id) |
82
+ | `conductor device-pool --release <id>` | Release a device back to the pool |
66
83
 
67
84
  Don't leave a daemon running when you're done — `daemon-stop` it.
68
85