mobile-automator 0.29.0 → 0.30.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 CHANGED
@@ -8,6 +8,7 @@ Claude Code, Cursor, Gemini CLI, GitHub Copilot, and OpenAI Agents all have one-
8
8
 
9
9
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
10
10
  [![Platform](https://img.shields.io/badge/Platform-Android%20%7C%20iOS%20%7C%20Flutter%20%7C%20RN%20%7C%20KMP%20%7C%20CMP-green.svg)](#)
11
+ [![Host](https://img.shields.io/badge/Host-macOS%20%7C%20Linux-lightgrey.svg)](#)
11
12
  [![Node](https://img.shields.io/badge/Node-%E2%89%A522.12-brightgreen.svg)](#)
12
13
 
13
14
  ---
@@ -24,6 +25,8 @@ Claude Code, Cursor, Gemini CLI, GitHub Copilot, and OpenAI Agents all have one-
24
25
  npm i -g mobile-automator # exposes `mauto` globally
25
26
  ```
26
27
 
28
+ > **Supported hosts:** macOS and Linux. Windows is not supported natively (npm refuses the install) — run `mauto` inside WSL2. See [TROUBLESHOOTING.md](TROUBLESHOOTING.md#-mauto-refuses-to-run-on-windows).
29
+
27
30
  <details>
28
31
  <summary>Or run it without installing / build from source</summary>
29
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-automator",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Host-agnostic mauto CLI for AI-driven mobile QA automation — analyzes a mobile project and drives devices through mobile-mcp.",
5
5
  "homepage": "https://github.com/sh3lan93/mobile-automator#readme",
6
6
  "repository": {
@@ -45,5 +45,9 @@
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22.12.0"
48
- }
48
+ },
49
+ "os": [
50
+ "darwin",
51
+ "linux"
52
+ ]
49
53
  }
package/src/cli.js CHANGED
@@ -26,6 +26,7 @@ const guideEmitter = require('./guide/emitter');
26
26
  const { ADAPTERS } = require('./init/adapters');
27
27
  const { isSemanticAction, ACTION_METHOD, selectResolver } = require('./device/semantic-press');
28
28
  const connection = require('./device/connection');
29
+ const { isSupportedHost, unsupportedHostError } = require('./device/host-support');
29
30
  const { record } = require('./observe/recorder');
30
31
  const { resolveRunId } = require('./observe/settings');
31
32
  const { runTracePath } = require('./observe/paths');
@@ -267,6 +268,19 @@ function handleValidate({ validator }, file, opts = {}) {
267
268
  return { envelope: env, exitKind: 'invalid_input' };
268
269
  }
269
270
 
271
+ // The unsupported-host gate (#165), shared by every site that reaches the
272
+ // session layer: connectBridge (all device verbs) and the `session` verbs,
273
+ // which talk to connection.* directly. Null on a supported host; otherwise an
274
+ // `environment` failure (exit 5). Deliberately NOT routed through deviceFail —
275
+ // a `device` kind is exactly the misdiagnosis this exists to prevent: on
276
+ // Windows the session layer's Unix socket, `ps` and process-group kill fail in
277
+ // ways that read as "no device connected".
278
+ function hostGate(platform) {
279
+ if (isSupportedHost(platform)) return null;
280
+ const err = unsupportedHostError(platform);
281
+ return { envelope: fail(err.kind, err.message, err.hint), exitKind: err.kind };
282
+ }
283
+
270
284
  function deviceFail(err) {
271
285
  // Respect a typed error's kind (e.g. the daemon's 'timeout') so the envelope
272
286
  // and exit code reflect the real failure class; anything else defaults to
@@ -1576,6 +1590,8 @@ function buildProgram(deps = {}) {
1576
1590
  emitters = makeEmitters({ projectRoot }),
1577
1591
  // Sink used to emit the rendered envelope + drive the exit code.
1578
1592
  emit = emitters.emit,
1593
+ // Host OS, for the unsupported-host gate (#165). Overridable in tests.
1594
+ platform = process.platform,
1579
1595
  } = deps;
1580
1596
 
1581
1597
  const program = new Command();
@@ -1672,6 +1688,18 @@ function buildProgram(deps = {}) {
1672
1688
  return action(...args);
1673
1689
  };
1674
1690
 
1691
+ // Gate for verbs that reach the device session layer (#165). Runs before
1692
+ // anything is spawned or connected; see hostGate. Emits and returns true when
1693
+ // the host is unsupported, so a caller can bail out.
1694
+ const refuseUnsupportedHost = () => {
1695
+ const refusal = hostGate(platform);
1696
+ if (!refusal) return false;
1697
+ emit(refusal, humanFlag());
1698
+ return true;
1699
+ };
1700
+ const requireSupportedHost = (action) => (...args) =>
1701
+ (refuseUnsupportedHost() ? undefined : action(...args));
1702
+
1675
1703
  // Resolve which device a verb targets: explicit --device wins, else the
1676
1704
  // persisted selection, else null (mobile-mcp auto-selects a single device).
1677
1705
  // Keeping the no-selection fast path returning null preserves zero-config use.
@@ -1687,6 +1715,9 @@ function buildProgram(deps = {}) {
1687
1715
  // (the single connection seam), so this keeps both the envelope boundary and
1688
1716
  // the acquire-seam refactor.
1689
1717
  const connectBridge = async (device, fn) => {
1718
+ // Before the factory: on an unsupported host there is nothing to connect,
1719
+ // and a connect failure would land below as a misleading `device` kind.
1720
+ if (refuseUnsupportedHost()) return;
1690
1721
  let bridge;
1691
1722
  let close;
1692
1723
  try {
@@ -2106,26 +2137,26 @@ function buildProgram(deps = {}) {
2106
2137
  .description('Start the device session daemon (subsequent verbs reuse its connection)')
2107
2138
  .option('--device <id>', 'pin the daemon to a target device id')
2108
2139
  .option('--idle <ms>', 'idle timeout in milliseconds before the daemon self-reaps')
2109
- .action(withEnvelope(requireWorkspace(async (opts) => {
2140
+ .action(withEnvelope(requireSupportedHost(requireWorkspace(async (opts) => {
2110
2141
  const r = await handleSessionStart({ projectRoot }, { device: opts.device, idle: opts.idle });
2111
2142
  emit(r, humanFlag());
2112
- })));
2143
+ }))));
2113
2144
 
2114
2145
  session
2115
2146
  .command('status')
2116
2147
  .description('Report whether a device session daemon is running')
2117
- .action(withEnvelope(async () => {
2148
+ .action(withEnvelope(requireSupportedHost(async () => {
2118
2149
  const r = await handleSessionStatus({ projectRoot });
2119
2150
  emit(r, humanFlag());
2120
- }));
2151
+ })));
2121
2152
 
2122
2153
  session
2123
2154
  .command('end')
2124
2155
  .description('Stop the device session daemon and remove its socket/pidfile')
2125
- .action(withEnvelope(async () => {
2156
+ .action(withEnvelope(requireSupportedHost(async () => {
2126
2157
  const r = await handleSessionEnd({ projectRoot });
2127
2158
  emit(r, humanFlag());
2128
- }));
2159
+ })));
2129
2160
 
2130
2161
  // --- Issue #92: device discovery + persisted selection -------------------
2131
2162
 
@@ -2150,6 +2181,12 @@ function buildProgram(deps = {}) {
2150
2181
  devices
2151
2182
  .command('clear')
2152
2183
  .description('Remove the persisted device selection')
2184
+ // Deliberately OUTSIDE the host gate (#165): its only session-layer probe,
2185
+ // sessionClient.isAlive, is a socket ping — it never runs `ps` or signals a
2186
+ // process, so it answers safely on an unsupported host too. That invariant
2187
+ // is executable, not just prose: the canary in
2188
+ // tests/unit/cli-host-support.test.js ("devices clear stays process-free")
2189
+ // fails if this verb ever starts spawning child processes.
2153
2190
  .action(withEnvelope(async () => {
2154
2191
  const r = await handleDevicesClear({ projectRoot });
2155
2192
  emit(r, humanFlag());
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ // Which hosts can run the device session, and the error for the ones that
4
+ // cannot (#165). The session daemon binds a Unix domain socket, and the
5
+ // session layer identifies and reaps its daemon with `ps`, process-group kill
6
+ // (`kill(-pid)`) and POSIX signals. None of that exists on Windows, where it
7
+ // used to fail as a confusing `device` error — as if no phone were connected —
8
+ // rather than saying the host itself is unsupported.
9
+ //
10
+ // package.json `os` is the single source of truth: npm refuses the install on
11
+ // any other host, and this guard catches what slips past it (`--force`,
12
+ // `npm link`, a vendored copy). Pure — no I/O; the caller decides how to emit.
13
+
14
+ const pkg = require('../../package.json');
15
+
16
+ const SUPPORTED_HOSTS = Object.freeze([...(pkg.os || [])]);
17
+
18
+ // Human names for the error text — the ONE copy; the docs-agreement lint test
19
+ // (tests/lint/host-support-agreement.test.js) consumes it rather than
20
+ // restating it. Deliberately no fallback: a host added to package.json `os`
21
+ // without a name here is a test failure and a crash at render time, not a raw
22
+ // platform id leaking into the user-facing error.
23
+ const HOST_NAMES = Object.freeze({ darwin: 'macOS', linux: 'Linux' });
24
+
25
+ const TROUBLESHOOTING_REF = 'see TROUBLESHOOTING.md ("mauto refuses to run on Windows")';
26
+
27
+ // Per-host guidance. Unknown hosts get the generic pointer instead of a
28
+ // Windows-specific instruction — a freebsd user must not be told about WSL2.
29
+ const HOST_HINTS = Object.freeze({
30
+ win32: `On Windows, install and run mauto inside WSL2 (a Linux distribution); ${TROUBLESHOOTING_REF}.`,
31
+ });
32
+
33
+ // For hosts with no specific hint: point at the troubleshooting entry without
34
+ // naming an OS-specific route.
35
+ const GENERIC_HINT = `See TROUBLESHOOTING.md for where mauto runs and how to get there.`;
36
+
37
+ function isSupportedHost(platform = process.platform) {
38
+ return SUPPORTED_HOSTS.includes(platform);
39
+ }
40
+
41
+ function unsupportedHostError(platform = process.platform) {
42
+ const names = SUPPORTED_HOSTS.map((p) => HOST_NAMES[p]).join(' or ');
43
+ const err = new Error(
44
+ `mauto's device session needs a POSIX host (${names}); "${platform}" is not supported`
45
+ );
46
+ err.kind = 'environment';
47
+ err.hint = HOST_HINTS[platform] || GENERIC_HINT;
48
+ return err;
49
+ }
50
+
51
+ module.exports = { SUPPORTED_HOSTS, HOST_NAMES, isSupportedHost, unsupportedHostError };