androdex 1.1.3

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,218 @@
1
+ // FILE: codex-transport.js
2
+ // Purpose: Abstracts the Codex-side transport so the bridge can talk to either a spawned app-server or an existing WebSocket endpoint.
3
+ // Layer: CLI helper
4
+ // Exports: createCodexTransport
5
+ // Depends on: child_process, ws
6
+
7
+ const { spawn } = require("child_process");
8
+ const WebSocket = require("ws");
9
+
10
+ function createCodexTransport({ endpoint = "", env = process.env, cwd = "" } = {}) {
11
+ if (endpoint) {
12
+ return createWebSocketTransport({ endpoint });
13
+ }
14
+
15
+ return createSpawnTransport({ env, cwd });
16
+ }
17
+
18
+ function createSpawnTransport({ env, cwd }) {
19
+ const launch = createCodexLaunchPlan({ env, cwd });
20
+ const codex = spawn(launch.command, launch.args, launch.options);
21
+
22
+ let stdoutBuffer = "";
23
+ let stderrBuffer = "";
24
+ let didRequestShutdown = false;
25
+ let didReportError = false;
26
+ const listeners = createListenerBag();
27
+
28
+ codex.on("error", (error) => {
29
+ didReportError = true;
30
+ listeners.emitError(error);
31
+ });
32
+ codex.on("close", (code, signal) => {
33
+ if (!didRequestShutdown && !didReportError && code !== 0) {
34
+ didReportError = true;
35
+ listeners.emitError(createCodexCloseError({
36
+ code,
37
+ signal,
38
+ stderrBuffer,
39
+ launchDescription: launch.description,
40
+ }));
41
+ return;
42
+ }
43
+
44
+ listeners.emitClose(code, signal);
45
+ });
46
+ // Keep stderr muted during normal operation, but preserve enough output to
47
+ // explain launch failures when the child exits before the bridge can use it.
48
+ codex.stderr.on("data", (chunk) => {
49
+ stderrBuffer = appendOutputBuffer(stderrBuffer, chunk.toString("utf8"));
50
+ });
51
+
52
+ codex.stdout.on("data", (chunk) => {
53
+ stdoutBuffer += chunk.toString("utf8");
54
+ const lines = stdoutBuffer.split("\n");
55
+ stdoutBuffer = lines.pop() || "";
56
+
57
+ for (const line of lines) {
58
+ const trimmedLine = line.trim();
59
+ if (trimmedLine) {
60
+ listeners.emitMessage(trimmedLine);
61
+ }
62
+ }
63
+ });
64
+
65
+ return {
66
+ mode: "spawn",
67
+ describe() {
68
+ return launch.description;
69
+ },
70
+ send(message) {
71
+ if (!codex.stdin.writable) {
72
+ return;
73
+ }
74
+
75
+ codex.stdin.write(message.endsWith("\n") ? message : `${message}\n`);
76
+ },
77
+ onMessage(handler) {
78
+ listeners.onMessage = handler;
79
+ },
80
+ onClose(handler) {
81
+ listeners.onClose = handler;
82
+ },
83
+ onError(handler) {
84
+ listeners.onError = handler;
85
+ },
86
+ shutdown() {
87
+ didRequestShutdown = true;
88
+ shutdownCodexProcess(codex);
89
+ },
90
+ };
91
+ }
92
+
93
+ // Builds a single, platform-aware launch path so the bridge never "guesses"
94
+ // between multiple commands and accidentally starts duplicate runtimes.
95
+ function createCodexLaunchPlan({ env, cwd }) {
96
+ const sharedOptions = {
97
+ stdio: ["pipe", "pipe", "pipe"],
98
+ env: { ...env },
99
+ cwd: cwd || process.cwd(),
100
+ };
101
+
102
+ if (process.platform === "win32") {
103
+ return {
104
+ command: env.ComSpec || "cmd.exe",
105
+ args: ["/d", "/c", "codex app-server"],
106
+ options: {
107
+ ...sharedOptions,
108
+ windowsHide: true,
109
+ },
110
+ description: "`cmd.exe /d /c codex app-server`",
111
+ };
112
+ }
113
+
114
+ return {
115
+ command: "codex",
116
+ args: ["app-server"],
117
+ options: sharedOptions,
118
+ description: "`codex app-server`",
119
+ };
120
+ }
121
+
122
+ // Stops the exact process tree we launched on Windows so the shell wrapper
123
+ // does not leave a child Codex process running in the background.
124
+ function shutdownCodexProcess(codex) {
125
+ if (codex.killed || codex.exitCode !== null) {
126
+ return;
127
+ }
128
+
129
+ if (process.platform === "win32" && codex.pid) {
130
+ const killer = spawn("taskkill", ["/pid", String(codex.pid), "/t", "/f"], {
131
+ stdio: "ignore",
132
+ windowsHide: true,
133
+ });
134
+ killer.on("error", () => {
135
+ codex.kill();
136
+ });
137
+ return;
138
+ }
139
+
140
+ codex.kill("SIGTERM");
141
+ }
142
+
143
+ function createCodexCloseError({ code, signal, stderrBuffer, launchDescription }) {
144
+ const details = stderrBuffer.trim();
145
+ const reason = details || `Process exited with code ${code}${signal ? ` (signal: ${signal})` : ""}.`;
146
+ return new Error(`Codex launcher ${launchDescription} failed: ${reason}`);
147
+ }
148
+
149
+ function appendOutputBuffer(buffer, chunk) {
150
+ const next = `${buffer}${chunk}`;
151
+ return next.slice(-4_096);
152
+ }
153
+
154
+ function createWebSocketTransport({ endpoint }) {
155
+ const socket = new WebSocket(endpoint);
156
+ const listeners = createListenerBag();
157
+
158
+ socket.on("message", (chunk) => {
159
+ const message = typeof chunk === "string" ? chunk : chunk.toString("utf8");
160
+ if (message.trim()) {
161
+ listeners.emitMessage(message);
162
+ }
163
+ });
164
+
165
+ socket.on("close", (code, reason) => {
166
+ const safeReason = reason ? reason.toString("utf8") : "no reason";
167
+ listeners.emitClose(code, safeReason);
168
+ });
169
+
170
+ socket.on("error", (error) => listeners.emitError(error));
171
+
172
+ return {
173
+ mode: "websocket",
174
+ describe() {
175
+ return endpoint;
176
+ },
177
+ send(message) {
178
+ if (socket.readyState !== WebSocket.OPEN) {
179
+ return;
180
+ }
181
+
182
+ socket.send(message);
183
+ },
184
+ onMessage(handler) {
185
+ listeners.onMessage = handler;
186
+ },
187
+ onClose(handler) {
188
+ listeners.onClose = handler;
189
+ },
190
+ onError(handler) {
191
+ listeners.onError = handler;
192
+ },
193
+ shutdown() {
194
+ if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
195
+ socket.close();
196
+ }
197
+ },
198
+ };
199
+ }
200
+
201
+ function createListenerBag() {
202
+ return {
203
+ onMessage: null,
204
+ onClose: null,
205
+ onError: null,
206
+ emitMessage(message) {
207
+ this.onMessage?.(message);
208
+ },
209
+ emitClose(...args) {
210
+ this.onClose?.(...args);
211
+ },
212
+ emitError(error) {
213
+ this.onError?.(error);
214
+ },
215
+ };
216
+ }
217
+
218
+ module.exports = { createCodexTransport };
@@ -0,0 +1,191 @@
1
+ // FILE: daemon-control.js
2
+ // Purpose: CLI-facing helpers for starting, stopping, and talking to the background daemon.
3
+ // Layer: CLI helper
4
+ // Exports: daemon control actions used by the androdex CLI.
5
+ // Depends on: child_process, http, path, ./daemon-store
6
+
7
+ const fs = require("fs");
8
+ const http = require("http");
9
+ const path = require("path");
10
+ const { spawn } = require("child_process");
11
+ const {
12
+ clearDaemonControlState,
13
+ getDaemonLogPath,
14
+ readDaemonControlState,
15
+ } = require("./daemon-store");
16
+
17
+ const CONTROL_HOST = "127.0.0.1";
18
+ const DAEMON_START_TIMEOUT_MS = 8_000;
19
+
20
+ async function startBridge() {
21
+ const response = await activateWorkspace(process.cwd());
22
+ return response.status;
23
+ }
24
+
25
+ async function activateWorkspace(cwd) {
26
+ await ensureDaemonRunning();
27
+ return requestDaemon("POST", "/activate", { cwd });
28
+ }
29
+
30
+ async function createPairing() {
31
+ await ensureDaemonRunning();
32
+ return requestDaemon("POST", "/pair", {});
33
+ }
34
+
35
+ async function getDaemonStatus() {
36
+ const control = readDaemonControlState();
37
+ if (!control) {
38
+ return {
39
+ ok: true,
40
+ status: {
41
+ relayStatus: "stopped",
42
+ workspaceActive: false,
43
+ currentCwd: null,
44
+ },
45
+ };
46
+ }
47
+
48
+ try {
49
+ return await requestDaemon("GET", "/status");
50
+ } catch {
51
+ clearDaemonControlState();
52
+ return {
53
+ ok: true,
54
+ status: {
55
+ relayStatus: "stopped",
56
+ workspaceActive: false,
57
+ currentCwd: null,
58
+ },
59
+ };
60
+ }
61
+ }
62
+
63
+ async function startDaemonCli() {
64
+ await ensureDaemonRunning();
65
+ return getDaemonStatus();
66
+ }
67
+
68
+ async function stopDaemonCli() {
69
+ const control = readDaemonControlState();
70
+ if (!control) {
71
+ return {
72
+ ok: true,
73
+ status: {
74
+ relayStatus: "stopped",
75
+ },
76
+ };
77
+ }
78
+
79
+ try {
80
+ return await requestDaemon("POST", "/stop", {});
81
+ } finally {
82
+ clearDaemonControlState();
83
+ }
84
+ }
85
+
86
+ async function ensureDaemonRunning() {
87
+ const existingState = readDaemonControlState();
88
+ if (existingState) {
89
+ try {
90
+ await requestDaemon("GET", "/status");
91
+ return;
92
+ } catch {
93
+ clearDaemonControlState();
94
+ }
95
+ }
96
+
97
+ const daemonScriptPath = path.join(__dirname, "..", "bin", "androdex.js");
98
+ const logPath = getDaemonLogPath();
99
+ const stdoutFd = fs.openSync(logPath, "a");
100
+ const stderrFd = fs.openSync(logPath, "a");
101
+ const child = spawn(
102
+ process.execPath,
103
+ [daemonScriptPath, "__daemon-run"],
104
+ {
105
+ detached: true,
106
+ stdio: ["ignore", stdoutFd, stderrFd],
107
+ windowsHide: true,
108
+ }
109
+ );
110
+ child.unref();
111
+ fs.closeSync(stdoutFd);
112
+ fs.closeSync(stderrFd);
113
+
114
+ const startedAt = Date.now();
115
+ while (Date.now() - startedAt < DAEMON_START_TIMEOUT_MS) {
116
+ const controlState = readDaemonControlState();
117
+ if (controlState) {
118
+ try {
119
+ await requestDaemon("GET", "/status");
120
+ return;
121
+ } catch {
122
+ // Keep polling until the daemon finishes booting or times out.
123
+ }
124
+ }
125
+ await sleep(200);
126
+ }
127
+
128
+ throw new Error("Timed out while starting the Androdex daemon.");
129
+ }
130
+
131
+ function requestDaemon(method, requestPath, body = null) {
132
+ const controlState = readDaemonControlState();
133
+ if (!controlState?.port || !controlState?.token) {
134
+ return Promise.reject(new Error("The Androdex daemon is not running."));
135
+ }
136
+
137
+ return new Promise((resolve, reject) => {
138
+ const payload = body == null ? "" : JSON.stringify(body);
139
+ const request = http.request(
140
+ {
141
+ hostname: CONTROL_HOST,
142
+ port: controlState.port,
143
+ path: requestPath,
144
+ method,
145
+ headers: {
146
+ "x-androdex-token": controlState.token,
147
+ "content-type": "application/json; charset=utf-8",
148
+ "content-length": Buffer.byteLength(payload),
149
+ },
150
+ },
151
+ (response) => {
152
+ let raw = "";
153
+ response.setEncoding("utf8");
154
+ response.on("data", (chunk) => {
155
+ raw += chunk;
156
+ });
157
+ response.on("end", () => {
158
+ try {
159
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
160
+ if (response.statusCode >= 400) {
161
+ reject(new Error(parsed.error || `Daemon request failed with status ${response.statusCode}.`));
162
+ return;
163
+ }
164
+ resolve(parsed);
165
+ } catch (error) {
166
+ reject(error);
167
+ }
168
+ });
169
+ }
170
+ );
171
+
172
+ request.on("error", reject);
173
+ if (payload) {
174
+ request.write(payload);
175
+ }
176
+ request.end();
177
+ });
178
+ }
179
+
180
+ function sleep(timeoutMs) {
181
+ return new Promise((resolve) => setTimeout(resolve, timeoutMs));
182
+ }
183
+
184
+ module.exports = {
185
+ activateWorkspace,
186
+ createPairing,
187
+ getDaemonStatus,
188
+ startBridge,
189
+ startDaemonCli,
190
+ stopDaemonCli,
191
+ };
@@ -0,0 +1,135 @@
1
+ // FILE: daemon-runtime.js
2
+ // Purpose: Runs the persistent host daemon and exposes a localhost control API for CLI commands.
3
+ // Layer: CLI service
4
+ // Exports: runDaemonProcess
5
+ // Depends on: http, ./daemon-store, ./host-runtime
6
+
7
+ const http = require("http");
8
+ const { clearDaemonControlState, createDaemonControlToken, writeDaemonControlState } = require("./daemon-store");
9
+ const { HostRuntime } = require("./host-runtime");
10
+
11
+ async function runDaemonProcess() {
12
+ const runtime = new HostRuntime();
13
+ const controlToken = createDaemonControlToken();
14
+ let isShuttingDown = false;
15
+
16
+ runtime.start();
17
+
18
+ const server = http.createServer(async (req, res) => {
19
+ try {
20
+ if (!authorizeRequest(req, controlToken)) {
21
+ writeJson(res, 401, { error: "Unauthorized" });
22
+ return;
23
+ }
24
+
25
+ const url = new URL(req.url || "/", "http://127.0.0.1");
26
+ if (req.method === "GET" && url.pathname === "/status") {
27
+ writeJson(res, 200, { ok: true, status: runtime.getStatus() });
28
+ return;
29
+ }
30
+
31
+ if (req.method === "POST" && url.pathname === "/activate") {
32
+ const body = await readJsonBody(req);
33
+ const status = await runtime.activateWorkspace({ cwd: body?.cwd });
34
+ writeJson(res, 200, { ok: true, status });
35
+ return;
36
+ }
37
+
38
+ if (req.method === "POST" && url.pathname === "/pair") {
39
+ writeJson(res, 200, {
40
+ ok: true,
41
+ pairingPayload: runtime.getPairingPayload(),
42
+ status: runtime.getStatus(),
43
+ });
44
+ return;
45
+ }
46
+
47
+ if (req.method === "POST" && url.pathname === "/stop") {
48
+ writeJson(res, 200, { ok: true });
49
+ setTimeout(() => {
50
+ void shutdown();
51
+ }, 25);
52
+ return;
53
+ }
54
+
55
+ writeJson(res, 404, { error: "Not found" });
56
+ } catch (error) {
57
+ writeJson(res, 500, { error: error.message || "Daemon request failed." });
58
+ }
59
+ });
60
+
61
+ server.on("clientError", (error, socket) => {
62
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
63
+ if (error?.message) {
64
+ console.error(`[androdex] Daemon control client error: ${error.message}`);
65
+ }
66
+ });
67
+
68
+ await new Promise((resolve) => {
69
+ server.listen(0, "127.0.0.1", resolve);
70
+ });
71
+
72
+ const address = server.address();
73
+ writeDaemonControlState({
74
+ pid: process.pid,
75
+ port: typeof address === "object" && address ? address.port : 0,
76
+ token: controlToken,
77
+ startedAt: Date.now(),
78
+ });
79
+
80
+ async function shutdown() {
81
+ if (isShuttingDown) {
82
+ return;
83
+ }
84
+ isShuttingDown = true;
85
+ clearDaemonControlState();
86
+ await runtime.stop();
87
+ await new Promise((resolve) => server.close(resolve));
88
+ process.exit(0);
89
+ }
90
+
91
+ process.on("SIGINT", () => {
92
+ void shutdown();
93
+ });
94
+ process.on("SIGTERM", () => {
95
+ void shutdown();
96
+ });
97
+ }
98
+
99
+ function authorizeRequest(req, controlToken) {
100
+ return req.headers["x-androdex-token"] === controlToken;
101
+ }
102
+
103
+ function readJsonBody(req) {
104
+ return new Promise((resolve, reject) => {
105
+ let raw = "";
106
+ req.setEncoding("utf8");
107
+ req.on("data", (chunk) => {
108
+ raw += chunk;
109
+ if (raw.length > 1024 * 1024) {
110
+ reject(new Error("Request body too large."));
111
+ }
112
+ });
113
+ req.on("end", () => {
114
+ if (!raw.trim()) {
115
+ resolve({});
116
+ return;
117
+ }
118
+ try {
119
+ resolve(JSON.parse(raw));
120
+ } catch {
121
+ reject(new Error("Invalid JSON request body."));
122
+ }
123
+ });
124
+ req.on("error", reject);
125
+ });
126
+ }
127
+
128
+ function writeJson(res, statusCode, body) {
129
+ res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
130
+ res.end(JSON.stringify(body));
131
+ }
132
+
133
+ module.exports = {
134
+ runDaemonProcess,
135
+ };
@@ -0,0 +1,92 @@
1
+ // FILE: daemon-store.js
2
+ // Purpose: Persists daemon control metadata and stable runtime preferences under ~/.androdex.
3
+ // Layer: CLI helper
4
+ // Exports: read/write helpers for daemon control and runtime state.
5
+ // Depends on: fs, os, path, crypto
6
+
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+ const { randomBytes } = require("crypto");
11
+
12
+ const STORE_DIR = path.join(os.homedir(), ".androdex");
13
+ const CONTROL_FILE = path.join(STORE_DIR, "daemon-control.json");
14
+ const RUNTIME_FILE = path.join(STORE_DIR, "daemon-runtime.json");
15
+ const LOG_FILE = path.join(STORE_DIR, "daemon.log");
16
+
17
+ function ensureStoreDir() {
18
+ fs.mkdirSync(STORE_DIR, { recursive: true });
19
+ }
20
+
21
+ function readDaemonControlState() {
22
+ return readJsonFile(CONTROL_FILE);
23
+ }
24
+
25
+ function writeDaemonControlState(state) {
26
+ writeJsonFile(CONTROL_FILE, state);
27
+ }
28
+
29
+ function clearDaemonControlState() {
30
+ try {
31
+ fs.rmSync(CONTROL_FILE, { force: true });
32
+ } catch {
33
+ // Ignore cleanup failures for stale daemon metadata.
34
+ }
35
+ }
36
+
37
+ function readDaemonRuntimeState() {
38
+ const runtimeState = readJsonFile(RUNTIME_FILE);
39
+ return {
40
+ lastActiveCwd: normalizeNonEmptyString(runtimeState?.lastActiveCwd),
41
+ };
42
+ }
43
+
44
+ function writeDaemonRuntimeState(state) {
45
+ writeJsonFile(RUNTIME_FILE, {
46
+ lastActiveCwd: normalizeNonEmptyString(state?.lastActiveCwd),
47
+ });
48
+ }
49
+
50
+ function createDaemonControlToken() {
51
+ return randomBytes(24).toString("hex");
52
+ }
53
+
54
+ function getDaemonLogPath() {
55
+ ensureStoreDir();
56
+ return LOG_FILE;
57
+ }
58
+
59
+ function readJsonFile(filePath) {
60
+ try {
61
+ if (!fs.existsSync(filePath)) {
62
+ return null;
63
+ }
64
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function writeJsonFile(filePath, value) {
71
+ ensureStoreDir();
72
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2), { mode: 0o600 });
73
+ try {
74
+ fs.chmodSync(filePath, 0o600);
75
+ } catch {
76
+ // Best effort only.
77
+ }
78
+ }
79
+
80
+ function normalizeNonEmptyString(value) {
81
+ return typeof value === "string" ? value.trim() : "";
82
+ }
83
+
84
+ module.exports = {
85
+ clearDaemonControlState,
86
+ createDaemonControlToken,
87
+ getDaemonLogPath,
88
+ readDaemonControlState,
89
+ readDaemonRuntimeState,
90
+ writeDaemonControlState,
91
+ writeDaemonRuntimeState,
92
+ };