androdex 1.1.3 → 1.1.6

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
@@ -1,8 +1,8 @@
1
1
  # androdex
2
2
 
3
- `androdex` is the host-side CLI bridge for the Androdex project.
3
+ `androdex` is the macOS host bridge for the Androdex Android client.
4
4
 
5
- It is published separately from the Android app. The daemon keeps a stable host identity alive on the host machine and lets the Android client control local Codex remotely.
5
+ It keeps Codex running locally on the Mac, exposes a relay-backed encrypted session for Android, and uses a launchd-managed background service so pairing and reconnect survive terminal closes.
6
6
 
7
7
  ## Install
8
8
 
@@ -13,59 +13,84 @@ npm install -g androdex
13
13
  ## Usage
14
14
 
15
15
  ```sh
16
- androdex pair
17
16
  androdex up
18
- androdex daemon status
17
+ androdex start
18
+ androdex restart
19
+ androdex stop
20
+ androdex status
19
21
  androdex reset-pairing
20
22
  androdex resume
21
- androdex watch
23
+ androdex watch [threadId]
22
24
  ```
23
25
 
26
+ ## Command Model
27
+
28
+ - `androdex up`
29
+ Starts or refreshes the macOS bridge service, waits for a fresh pairing QR, prints it, and binds the current working directory as the active workspace.
30
+ - `androdex start`
31
+ Starts the launchd-managed macOS bridge service without changing the active workspace.
32
+ - `androdex restart`
33
+ Restarts the launchd-managed macOS bridge service without changing the active workspace.
34
+ - `androdex stop`
35
+ Stops the macOS bridge service and clears stale in-memory runtime state.
36
+ - `androdex status`
37
+ Prints launchd status, persisted bridge status, and the stdout/stderr log paths under `~/.androdex`.
38
+ - `androdex run`
39
+ Runs the bridge in the foreground.
40
+ - `androdex run-service`
41
+ Internal launchd entrypoint used by the installed plist.
42
+ - `androdex reset-pairing`
43
+ Stops the service and clears the saved trusted-device state so the next `androdex up` starts fresh.
44
+ - `androdex resume`
45
+ Reopens the last active thread in the local Codex desktop app if available.
46
+ - `androdex watch [threadId]`
47
+ Tails the rollout log for the selected thread in real time.
48
+
24
49
  ## What it does
25
50
 
26
- - keeps a durable relay presence keyed by a stable host id
27
- - prints a pairing QR code and raw pairing payload on demand
28
- - activates local `codex app-server` for the current workspace
51
+ - runs a launchd-managed macOS bridge service with label `io.androdex.bridge`
52
+ - prints a pairing QR for the current relay session
53
+ - restores the last active workspace on service start when possible
54
+ - lets the Android client browse host folders and switch workspaces remotely
29
55
  - forwards JSON-RPC traffic between the host and the Android client
30
- - handles git and workspace actions on the host machine
31
-
32
- ## Commands
33
-
34
- ### `androdex up`
35
-
36
- Activates the current workspace in the daemon and launches `codex app-server` locally if needed.
37
-
38
- ### `androdex pair`
56
+ - handles git and workspace actions on the host machine, including branch/worktree management and reverse-patch safety checks
57
+ - keeps the local Codex desktop app aligned with phone-authored thread activity when desktop refresh is enabled
39
58
 
40
- Prints a fresh pairing QR and raw pairing payload for the already-running daemon identity.
41
-
42
- ### `androdex daemon [start|stop|status]`
43
-
44
- Manages the background daemon that owns the stable host identity and relay presence.
59
+ ## Environment variables
45
60
 
46
- ### `androdex reset-pairing`
61
+ `androdex` accepts `ANDRODEX_*` variables.
47
62
 
48
- Clears the saved trusted-device state so the next `androdex pair` starts a fresh pairing flow.
63
+ Useful variables:
49
64
 
50
- ### `androdex resume`
65
+ - `ANDRODEX_RELAY`: set the relay URL explicitly and override any packaged default
66
+ - `ANDRODEX_DEFAULT_RELAY_URL`: override the built-in default relay URL when no explicit override is present
67
+ - `ANDRODEX_CODEX_ENDPOINT`: connect to an existing Codex WebSocket instead of spawning a local runtime
68
+ - `ANDRODEX_REFRESH_ENABLED`: enable or disable desktop refresh explicitly
69
+ - `ANDRODEX_REFRESH_DEBOUNCE_MS`: adjust refresh debounce timing
70
+ - `ANDRODEX_REFRESH_COMMAND`: override desktop refresh with a custom command
71
+ - `ANDRODEX_CODEX_BUNDLE_ID`: override the Codex desktop bundle ID on macOS
72
+ - `ANDRODEX_PUSH_SERVICE_URL`: optional Android push service endpoint for device registration and completion notifications
51
73
 
52
- Reopens the last active thread in the local Codex desktop app if available.
74
+ The bridge resolves relay configuration in this order:
53
75
 
54
- ### `androdex watch [threadId]`
76
+ 1. `ANDRODEX_RELAY`
77
+ 2. `ANDRODEX_DEFAULT_RELAY_URL`
78
+ 3. `wss://relay.androdex.xyz/relay`
55
79
 
56
- Tails the rollout log for the selected thread in real time.
80
+ Common relay patterns:
57
81
 
58
- ## Environment variables
82
+ ```sh
83
+ # Built-in public relay
84
+ androdex up
59
85
 
60
- `androdex` accepts `ANDRODEX_*` variables.
86
+ # Local relay on your own network
87
+ ANDRODEX_RELAY=ws://192.168.x.x:8787/relay androdex up
61
88
 
62
- Useful variables:
89
+ # Public relay you control
90
+ ANDRODEX_RELAY=wss://relay.example.com/relay androdex up
91
+ ```
63
92
 
64
- - `ANDRODEX_RELAY`: set the relay URL explicitly
65
- - `ANDRODEX_CODEX_ENDPOINT`: connect to an existing Codex WebSocket instead of spawning a local runtime
66
- - `ANDRODEX_REFRESH_ENABLED`: enable the macOS desktop refresh workaround explicitly
67
- - `ANDRODEX_REFRESH_DEBOUNCE_MS`: adjust refresh debounce timing
68
- - `ANDRODEX_REFRESH_COMMAND`: override desktop refresh with a custom command
93
+ If you change relay URLs after pairing, run `androdex reset-pairing` and pair again with `androdex up` so Android gets a fresh payload for the new relay session.
69
94
 
70
95
  ## Source builds
71
96
 
@@ -77,8 +102,36 @@ npm install
77
102
  npm start
78
103
  ```
79
104
 
105
+ ## Release
106
+
107
+ Publish the npm package from this directory, not from the repository root:
108
+
109
+ ```sh
110
+ cd androdex-bridge
111
+ npm test
112
+ npm pack --dry-run
113
+ npm version patch --no-git-tag-version
114
+ npm publish --access public
115
+ ```
116
+
117
+ Verify the published version after the release:
118
+
119
+ ```sh
120
+ npm view androdex version
121
+ ```
122
+
123
+ If your npm account requires write-time 2FA, rerun the publish command with `--otp=<code>`.
124
+
125
+ ## Manual Smoke Checklist
126
+
127
+ 1. Run `androdex up` and confirm the Android app can pair successfully.
128
+ 2. Run `androdex up` inside a workspace and confirm the host keeps Codex bound to that local project.
129
+ 3. From Android, open an existing thread and create a new one to confirm the remote client flow still works end to end.
130
+ 4. If desktop refresh is enabled, verify phone-authored thread activity updates the host Codex desktop via the Settings-bounce remount workaround.
131
+ 5. Restart the launchd service or reconnect the phone and confirm the saved pairing and active workspace recover without losing host-local state.
132
+
80
133
  ## Project status
81
134
 
82
- This package is part of Androdex, a local-first project focused on the host-machine-plus-Android workflow today.
135
+ This package is part of Androdex, a macOS host-local Codex plus Android remote-access workflow.
83
136
 
84
137
  Credit for the upstream fork chain remains with [relaydex](https://github.com/Ranats/relaydex) and [Remodex](https://github.com/Emanuele-web04/remodex).
package/bin/androdex.js CHANGED
@@ -5,4 +5,6 @@
5
5
  // Exports: none
6
6
  // Depends on: ./cli
7
7
 
8
- require("./cli");
8
+ const { main } = require("./cli");
9
+
10
+ void main();
package/bin/cli.js CHANGED
@@ -1,105 +1,208 @@
1
1
  #!/usr/bin/env node
2
2
  // FILE: cli.js
3
- // Purpose: CLI surface for daemon start, workspace activation, pairing, thread resume, and rollout tailing.
3
+ // Purpose: CLI surface for foreground bridge runs, pairing reset, thread resume, and macOS service control.
4
4
  // Layer: CLI binary
5
5
  // Exports: none
6
6
  // Depends on: ../src
7
7
 
8
8
  const {
9
- createPairing,
10
- getDaemonStatus,
11
- runDaemonProcess,
9
+ printMacOSBridgePairingQr,
10
+ printMacOSBridgeServiceStatus,
11
+ readBridgeConfig,
12
+ resetMacOSBridgePairing,
13
+ runMacOSBridgeService,
12
14
  startBridge,
13
- startDaemonCli,
14
- stopDaemonCli,
15
+ startMacOSBridgeService,
16
+ stopMacOSBridgeService,
15
17
  resetBridgePairing,
16
18
  openLastActiveThread,
17
19
  watchThreadRollout,
18
20
  } = require("../src");
19
- const { printQR } = require("../src/qr");
20
-
21
- const command = process.argv[2] || "up";
22
- const CLI_NAME = "androdex";
23
- const CLI_PREFIX = `[${CLI_NAME}]`;
21
+ const { version } = require("../package.json");
22
+
23
+ const defaultDeps = {
24
+ printMacOSBridgePairingQr,
25
+ printMacOSBridgeServiceStatus,
26
+ readBridgeConfig,
27
+ resetMacOSBridgePairing,
28
+ runMacOSBridgeService,
29
+ startBridge,
30
+ startMacOSBridgeService,
31
+ stopMacOSBridgeService,
32
+ resetBridgePairing,
33
+ openLastActiveThread,
34
+ watchThreadRollout,
35
+ };
24
36
 
25
- void main().catch((error) => {
26
- console.error(`${CLI_PREFIX} ${(error && error.message) || "Command failed."}`);
27
- process.exit(1);
28
- });
37
+ if (require.main === module) {
38
+ void main();
39
+ }
29
40
 
30
- async function main() {
31
- if (command === "__daemon-run") {
32
- await runDaemonProcess();
41
+ async function main({
42
+ argv = process.argv,
43
+ platform = process.platform,
44
+ consoleImpl = console,
45
+ exitImpl = process.exit,
46
+ deps = defaultDeps,
47
+ } = {}) {
48
+ const command = argv[2] || "up";
49
+
50
+ if (isVersionCommand(command)) {
51
+ consoleImpl.log(version);
33
52
  return;
34
53
  }
35
54
 
36
55
  if (command === "up") {
37
- const status = await startBridge();
38
- console.log(`${CLI_PREFIX} Active workspace: ${status.currentCwd || process.cwd()}`);
39
- console.log(`${CLI_PREFIX} Relay: ${status.relayStatus}`);
56
+ assertMacOSCommand(command, {
57
+ platform,
58
+ consoleImpl,
59
+ exitImpl,
60
+ });
61
+ deps.readBridgeConfig();
62
+ const result = await deps.startMacOSBridgeService({
63
+ waitForPairing: true,
64
+ activeCwd: process.cwd(),
65
+ });
66
+ deps.printMacOSBridgePairingQr({
67
+ pairingSession: result.pairingSession,
68
+ });
40
69
  return;
41
70
  }
42
71
 
43
- if (command === "pair") {
44
- const response = await createPairing();
45
- printQR(response.pairingPayload);
72
+ if (command === "run") {
73
+ assertMacOSCommand(command, {
74
+ platform,
75
+ consoleImpl,
76
+ exitImpl,
77
+ });
78
+ deps.startBridge();
46
79
  return;
47
80
  }
48
81
 
49
- if (command === "daemon") {
50
- await handleDaemonCommand(process.argv[3] || "status");
82
+ if (command === "run-service") {
83
+ assertMacOSCommand(command, {
84
+ platform,
85
+ consoleImpl,
86
+ exitImpl,
87
+ });
88
+ deps.runMacOSBridgeService();
51
89
  return;
52
90
  }
53
91
 
54
- if (command === "reset-pairing") {
55
- await resetBridgePairing();
56
- console.log(`${CLI_PREFIX} Cleared the saved pairing state. Run \`${CLI_NAME} pair\` to create a fresh pairing QR.`);
92
+ if (command === "start") {
93
+ assertMacOSCommand(command, {
94
+ platform,
95
+ consoleImpl,
96
+ exitImpl,
97
+ });
98
+ deps.readBridgeConfig();
99
+ await deps.startMacOSBridgeService({
100
+ waitForPairing: false,
101
+ });
102
+ consoleImpl.log("[androdex] macOS bridge service is running.");
57
103
  return;
58
104
  }
59
105
 
60
- if (command === "resume") {
61
- const state = openLastActiveThread();
62
- console.log(
63
- `${CLI_PREFIX} Opened last active thread: ${state.threadId} (${state.source || "unknown"})`
64
- );
106
+ if (command === "restart") {
107
+ assertMacOSCommand(command, {
108
+ platform,
109
+ consoleImpl,
110
+ exitImpl,
111
+ });
112
+ deps.readBridgeConfig();
113
+ await deps.startMacOSBridgeService({
114
+ waitForPairing: false,
115
+ });
116
+ consoleImpl.log("[androdex] macOS bridge service restarted.");
65
117
  return;
66
118
  }
67
119
 
68
- if (command === "watch") {
69
- watchThreadRollout(process.argv[3] || "");
120
+ if (command === "stop") {
121
+ assertMacOSCommand(command, {
122
+ platform,
123
+ consoleImpl,
124
+ exitImpl,
125
+ });
126
+ deps.stopMacOSBridgeService();
127
+ consoleImpl.log("[androdex] macOS bridge service stopped.");
70
128
  return;
71
129
  }
72
130
 
73
- console.error(`Unknown command: ${command}`);
74
- console.error("Usage: androdex up | androdex pair | androdex daemon [start|stop|status] | androdex reset-pairing | androdex resume | androdex watch [threadId]");
75
- process.exit(1);
76
- }
131
+ if (command === "status") {
132
+ assertMacOSCommand(command, {
133
+ platform,
134
+ consoleImpl,
135
+ exitImpl,
136
+ });
137
+ deps.printMacOSBridgeServiceStatus();
138
+ return;
139
+ }
140
+
141
+ if (command === "reset-pairing") {
142
+ try {
143
+ if (platform === "darwin") {
144
+ deps.resetMacOSBridgePairing();
145
+ consoleImpl.log("[androdex] Stopped the macOS bridge service and cleared the saved pairing state. Run `androdex up` to pair again.");
146
+ } else {
147
+ deps.resetBridgePairing();
148
+ consoleImpl.log("[androdex] Cleared the saved pairing state. Run `androdex up` to pair again.");
149
+ }
150
+ } catch (error) {
151
+ consoleImpl.error(`[androdex] ${(error && error.message) || "Failed to clear the saved pairing state."}`);
152
+ exitImpl(1);
153
+ }
154
+ return;
155
+ }
77
156
 
78
- async function handleDaemonCommand(subcommand) {
79
- if (subcommand === "start") {
80
- const response = await startDaemonCli();
81
- printDaemonStatus(response.status);
157
+ if (command === "resume") {
158
+ try {
159
+ const state = deps.openLastActiveThread();
160
+ consoleImpl.log(
161
+ `[androdex] Opened last active thread: ${state.threadId} (${state.source || "unknown"})`
162
+ );
163
+ } catch (error) {
164
+ consoleImpl.error(`[androdex] ${(error && error.message) || "Failed to reopen the last thread."}`);
165
+ exitImpl(1);
166
+ }
82
167
  return;
83
168
  }
84
169
 
85
- if (subcommand === "stop") {
86
- await stopDaemonCli();
87
- console.log(`${CLI_PREFIX} Daemon stopped.`);
170
+ if (command === "watch") {
171
+ try {
172
+ deps.watchThreadRollout(argv[3] || "");
173
+ } catch (error) {
174
+ consoleImpl.error(`[androdex] ${(error && error.message) || "Failed to watch the thread rollout."}`);
175
+ exitImpl(1);
176
+ }
88
177
  return;
89
178
  }
90
179
 
91
- if (subcommand === "status") {
92
- const response = await getDaemonStatus();
93
- printDaemonStatus(response.status);
180
+ consoleImpl.error(`Unknown command: ${command}`);
181
+ consoleImpl.error(
182
+ "Usage: androdex up | androdex run | androdex start | androdex restart | androdex stop | androdex status | "
183
+ + "androdex reset-pairing | androdex resume | androdex watch [threadId] | androdex --version"
184
+ );
185
+ exitImpl(1);
186
+ }
187
+
188
+ function assertMacOSCommand(name, {
189
+ platform = process.platform,
190
+ consoleImpl = console,
191
+ exitImpl = process.exit,
192
+ } = {}) {
193
+ if (platform === "darwin") {
94
194
  return;
95
195
  }
96
196
 
97
- throw new Error(`Unknown daemon subcommand: ${subcommand}`);
197
+ consoleImpl.error(`[androdex] \`${name}\` is only available on macOS right now.`);
198
+ exitImpl(1);
98
199
  }
99
200
 
100
- function printDaemonStatus(status) {
101
- console.log(`${CLI_PREFIX} Relay: ${status.relayStatus || "unknown"}`);
102
- console.log(`${CLI_PREFIX} Host ID: ${status.hostId || "unavailable"}`);
103
- console.log(`${CLI_PREFIX} Workspace: ${status.currentCwd || "none"}`);
104
- console.log(`${CLI_PREFIX} Workspace active: ${status.workspaceActive ? "yes" : "no"}`);
201
+ function isVersionCommand(value) {
202
+ return value === "-v" || value === "--v" || value === "-V" || value === "--version" || value === "version";
105
203
  }
204
+
205
+ module.exports = {
206
+ isVersionCommand,
207
+ main,
208
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "androdex",
3
- "version": "1.1.3",
4
- "description": "Local bridge between Codex and the Androdex mobile app. Run `androdex up` to start.",
3
+ "version": "1.1.6",
4
+ "description": "macOS host bridge between Codex and the Androdex Android app. Run `androdex up` to start.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "androdex": "bin/androdex.js"
@@ -14,14 +14,15 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "start": "node ./bin/androdex.js up",
17
- "test": "node --test ./test/*.test.js"
17
+ "test": "node --test --test-concurrency=1"
18
18
  },
19
19
  "keywords": [
20
20
  "androdex",
21
21
  "codex",
22
22
  "bridge",
23
23
  "cli",
24
- "android"
24
+ "android",
25
+ "macos"
25
26
  ],
26
27
  "author": "Robert Gordon",
27
28
  "license": "ISC",
@@ -29,6 +30,9 @@
29
30
  "engines": {
30
31
  "node": ">=18"
31
32
  },
33
+ "os": [
34
+ "darwin"
35
+ ],
32
36
  "publishConfig": {
33
37
  "access": "public"
34
38
  },
@@ -0,0 +1,127 @@
1
+ // FILE: account-status.js
2
+ // Purpose: Converts raw Codex account/auth responses into a sanitized host-account snapshot for Android.
3
+ // Layer: CLI helper
4
+ // Exports: composeAccountStatus, composeSanitizedAuthStatusFromSettledResults, redactAuthStatus
5
+ // Depends on: ../package.json
6
+
7
+ const { version: bridgePackageVersion = "" } = require("../package.json");
8
+
9
+ function composeAccountStatus({
10
+ accountRead = null,
11
+ authStatus = null,
12
+ loginInFlight = false,
13
+ bridgeVersionInfo = null,
14
+ } = {}) {
15
+ const account = accountRead?.account || null;
16
+ const authToken = normalizeString(authStatus?.authToken);
17
+ const hasAccountLogin = hasExplicitAccountLogin(account);
18
+ const authMethod = firstNonEmpty([
19
+ normalizeString(authStatus?.authMethod),
20
+ normalizeString(account?.type),
21
+ ]) || null;
22
+ const tokenReady = Boolean(authToken);
23
+ const requiresOpenaiAuth = Boolean(accountRead?.requiresOpenaiAuth || authStatus?.requiresOpenaiAuth);
24
+ const hasPriorLoginContext = hasAccountLogin || Boolean(authMethod);
25
+ const needsReauth = !loginInFlight && requiresOpenaiAuth && hasPriorLoginContext;
26
+ const isAuthenticated = !needsReauth && (tokenReady || hasAccountLogin);
27
+ const status = isAuthenticated
28
+ ? "authenticated"
29
+ : (loginInFlight ? "pending_login" : (needsReauth ? "expired" : "not_logged_in"));
30
+
31
+ return {
32
+ status,
33
+ authMethod,
34
+ email: normalizeString(account?.email) || null,
35
+ planType: normalizeString(account?.planType) || null,
36
+ loginInFlight: Boolean(loginInFlight),
37
+ needsReauth,
38
+ tokenReady,
39
+ expiresAt: null,
40
+ requiresOpenaiAuth,
41
+ bridgeVersion: firstNonEmpty([
42
+ normalizeString(bridgeVersionInfo?.bridgeVersion),
43
+ normalizeString(bridgePackageVersion),
44
+ ]) || null,
45
+ bridgeLatestVersion: normalizeString(bridgeVersionInfo?.bridgeLatestVersion) || null,
46
+ };
47
+ }
48
+
49
+ function redactAuthStatus(authStatus = null, extras = {}) {
50
+ const composed = composeAccountStatus({
51
+ accountRead: extras.accountRead || null,
52
+ authStatus,
53
+ loginInFlight: Boolean(extras.loginInFlight),
54
+ bridgeVersionInfo: extras.bridgeVersionInfo || null,
55
+ });
56
+
57
+ return {
58
+ authMethod: composed.authMethod,
59
+ status: composed.status,
60
+ email: composed.email,
61
+ planType: composed.planType,
62
+ loginInFlight: composed.loginInFlight,
63
+ needsReauth: composed.needsReauth,
64
+ tokenReady: composed.tokenReady,
65
+ expiresAt: composed.expiresAt,
66
+ bridgeVersion: composed.bridgeVersion,
67
+ bridgeLatestVersion: composed.bridgeLatestVersion,
68
+ };
69
+ }
70
+
71
+ function composeSanitizedAuthStatusFromSettledResults({
72
+ accountReadResult = null,
73
+ authStatusResult = null,
74
+ loginInFlight = false,
75
+ bridgeVersionInfo = null,
76
+ } = {}) {
77
+ const accountRead = accountReadResult?.status === "fulfilled" ? accountReadResult.value : null;
78
+ const authStatus = authStatusResult?.status === "fulfilled" ? authStatusResult.value : null;
79
+
80
+ if (!accountRead && !authStatus) {
81
+ const error = new Error("Unable to read host account status from the bridge.");
82
+ error.errorCode = "auth_status_unavailable";
83
+ throw error;
84
+ }
85
+
86
+ return redactAuthStatus(authStatus, {
87
+ accountRead,
88
+ loginInFlight: Boolean(loginInFlight),
89
+ bridgeVersionInfo,
90
+ });
91
+ }
92
+
93
+ function hasExplicitAccountLogin(account) {
94
+ if (!account || typeof account !== "object") {
95
+ return false;
96
+ }
97
+
98
+ if (parseBoolean(account.loggedIn) || parseBoolean(account.logged_in) || parseBoolean(account.isLoggedIn)) {
99
+ return true;
100
+ }
101
+
102
+ return Boolean(normalizeString(account.email));
103
+ }
104
+
105
+ function firstNonEmpty(values) {
106
+ for (const value of values) {
107
+ const normalized = normalizeString(value);
108
+ if (normalized) {
109
+ return normalized;
110
+ }
111
+ }
112
+ return "";
113
+ }
114
+
115
+ function normalizeString(value) {
116
+ return typeof value === "string" ? value.trim() : "";
117
+ }
118
+
119
+ function parseBoolean(value) {
120
+ return value === true;
121
+ }
122
+
123
+ module.exports = {
124
+ composeAccountStatus,
125
+ composeSanitizedAuthStatusFromSettledResults,
126
+ redactAuthStatus,
127
+ };