appduct 0.10.0 → 0.11.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.
Files changed (230) hide show
  1. package/README.md +121 -51
  2. package/dist/artifact-inspect.d.ts +0 -1
  3. package/dist/bin.d.ts +0 -1
  4. package/dist/bin.js +1250 -2
  5. package/dist/cli/command-options.d.ts +8 -1
  6. package/dist/cli/create-cli.d.ts +0 -1
  7. package/dist/cli/dispatch.d.ts +10 -1
  8. package/dist/cli/envelope.d.ts +19 -0
  9. package/dist/cli/global-flags.d.ts +30 -0
  10. package/dist/cli/open-target.d.ts +25 -8
  11. package/dist/cli/result-types.d.ts +30 -5
  12. package/dist/cli/router.d.ts +78 -0
  13. package/dist/cli/routes/daemon/index.d.ts +9 -0
  14. package/dist/cli/routes/daemon/index.js +848 -0
  15. package/dist/cli/routes/daemon/run.d.ts +3 -0
  16. package/dist/cli/routes/daemon/run.js +3246 -0
  17. package/dist/cli/routes/daemon/start.d.ts +3 -0
  18. package/dist/cli/routes/daemon/start.js +1668 -0
  19. package/dist/cli/routes/daemon/status.d.ts +4 -0
  20. package/dist/cli/routes/daemon/status.js +1676 -0
  21. package/dist/cli/routes/daemon/stop.d.ts +4 -0
  22. package/dist/cli/routes/daemon/stop.js +1646 -0
  23. package/dist/cli/routes/doctor.d.ts +3 -0
  24. package/dist/cli/routes/doctor.js +1075 -0
  25. package/dist/cli/routes/events.d.ts +3 -0
  26. package/dist/cli/routes/events.js +1906 -0
  27. package/dist/cli/routes/init.d.ts +3 -0
  28. package/dist/cli/routes/init.js +1486 -0
  29. package/dist/cli/routes/invoke.d.ts +3 -0
  30. package/dist/cli/routes/invoke.js +1884 -0
  31. package/dist/cli/routes/keygen.d.ts +3 -0
  32. package/dist/cli/routes/keygen.js +928 -0
  33. package/dist/cli/routes/link.d.ts +3 -0
  34. package/dist/cli/routes/link.js +2705 -0
  35. package/dist/cli/routes/ls.d.ts +3 -0
  36. package/dist/cli/routes/ls.js +1659 -0
  37. package/dist/cli/routes/mcp.d.ts +5 -0
  38. package/dist/cli/routes/mcp.js +3917 -0
  39. package/dist/cli/routes/revoke.d.ts +3 -0
  40. package/dist/cli/routes/revoke.js +1675 -0
  41. package/dist/cli/routes/tools.d.ts +3 -0
  42. package/dist/cli/routes/tools.js +1851 -0
  43. package/dist/cli/runner.d.ts +3 -11
  44. package/dist/cli/types.d.ts +9 -6
  45. package/dist/cli/version-guard.d.ts +20 -0
  46. package/dist/cli.d.ts +0 -1
  47. package/dist/client/app-client.d.ts +0 -1
  48. package/dist/client/bootstrap.d.ts +4 -4
  49. package/dist/client/connect.d.ts +0 -1
  50. package/dist/client/errors.d.ts +0 -1
  51. package/dist/client/index.d.ts +0 -1
  52. package/dist/client/index.js +2379 -16
  53. package/dist/commands/daemon/context.d.ts +14 -0
  54. package/dist/commands/daemon/run.d.ts +11 -0
  55. package/dist/commands/daemon/start.d.ts +5 -0
  56. package/dist/commands/daemon/status.d.ts +5 -0
  57. package/dist/commands/daemon/stop.d.ts +5 -0
  58. package/dist/commands/doctor.d.ts +0 -1
  59. package/dist/commands/events.d.ts +0 -1
  60. package/dist/commands/init.d.ts +17 -4
  61. package/dist/commands/invoke.d.ts +0 -1
  62. package/dist/commands/keygen.d.ts +0 -1
  63. package/dist/commands/link.d.ts +1 -2
  64. package/dist/commands/ls.d.ts +0 -1
  65. package/dist/commands/mcp.d.ts +2 -4
  66. package/dist/commands/revoke.d.ts +0 -1
  67. package/dist/commands/tools.d.ts +19 -3
  68. package/dist/daemon/address.d.ts +0 -1
  69. package/dist/daemon/audit.d.ts +6 -11
  70. package/dist/daemon/calls.d.ts +0 -1
  71. package/dist/daemon/config.d.ts +13 -16
  72. package/dist/daemon/daemon.d.ts +0 -1
  73. package/dist/daemon/event-bus.d.ts +0 -1
  74. package/dist/daemon/links.d.ts +0 -1
  75. package/dist/daemon/listener.d.ts +0 -1
  76. package/dist/daemon/log-rotation.d.ts +0 -1
  77. package/dist/daemon/pidfile.d.ts +14 -8
  78. package/dist/daemon/policy.d.ts +0 -1
  79. package/dist/daemon/registry.d.ts +0 -1
  80. package/dist/daemon/rpc-errors.d.ts +16 -0
  81. package/dist/daemon/rpc-server.d.ts +0 -11
  82. package/dist/daemon/sessions.d.ts +0 -1
  83. package/dist/daemon/socket-probe.d.ts +0 -1
  84. package/dist/daemon/state-dir.d.ts +0 -1
  85. package/dist/daemon/timers.d.ts +0 -1
  86. package/dist/daemon/tls.d.ts +0 -1
  87. package/dist/errors.d.ts +0 -1
  88. package/dist/host-certificate.d.ts +0 -1
  89. package/dist/index.d.ts +0 -1
  90. package/dist/index.js +1307 -3
  91. package/dist/key-material.d.ts +0 -1
  92. package/dist/link.d.ts +8 -7
  93. package/dist/mcp/app-tools.d.ts +153 -0
  94. package/dist/mcp/connect-tool.d.ts +14 -8
  95. package/dist/mcp/daemon-tools.d.ts +2 -11
  96. package/dist/mcp/events-tool.d.ts +0 -1
  97. package/dist/mcp/server.d.ts +33 -14
  98. package/dist/native-scheme.d.ts +0 -1
  99. package/dist/output.d.ts +8 -11
  100. package/dist/package-root.d.ts +11 -0
  101. package/dist/package-version.d.ts +0 -1
  102. package/dist/qr-terminal.d.ts +0 -1
  103. package/dist/rpc/client.d.ts +0 -1
  104. package/dist/scheme.d.ts +60 -2
  105. package/dist/spki-pin.d.ts +0 -1
  106. package/package.json +4 -3
  107. package/dist/artifact-inspect.d.ts.map +0 -1
  108. package/dist/artifact-inspect.js +0 -334
  109. package/dist/bin.d.ts.map +0 -1
  110. package/dist/cli/command-options.d.ts.map +0 -1
  111. package/dist/cli/command-options.js +0 -95
  112. package/dist/cli/create-cli.d.ts.map +0 -1
  113. package/dist/cli/create-cli.js +0 -56
  114. package/dist/cli/dispatch.d.ts.map +0 -1
  115. package/dist/cli/dispatch.js +0 -275
  116. package/dist/cli/open-target.d.ts.map +0 -1
  117. package/dist/cli/open-target.js +0 -438
  118. package/dist/cli/result-types.d.ts.map +0 -1
  119. package/dist/cli/result-types.js +0 -6
  120. package/dist/cli/runner.d.ts.map +0 -1
  121. package/dist/cli/runner.js +0 -111
  122. package/dist/cli/types.d.ts.map +0 -1
  123. package/dist/cli/types.js +0 -10
  124. package/dist/cli.d.ts.map +0 -1
  125. package/dist/cli.js +0 -2
  126. package/dist/client/app-client.d.ts.map +0 -1
  127. package/dist/client/app-client.js +0 -223
  128. package/dist/client/bootstrap.d.ts.map +0 -1
  129. package/dist/client/bootstrap.js +0 -108
  130. package/dist/client/connect.d.ts.map +0 -1
  131. package/dist/client/connect.js +0 -38
  132. package/dist/client/errors.d.ts.map +0 -1
  133. package/dist/client/errors.js +0 -40
  134. package/dist/client/index.d.ts.map +0 -1
  135. package/dist/commands/daemon.d.ts +0 -22
  136. package/dist/commands/daemon.d.ts.map +0 -1
  137. package/dist/commands/daemon.js +0 -128
  138. package/dist/commands/doctor.d.ts.map +0 -1
  139. package/dist/commands/doctor.js +0 -42
  140. package/dist/commands/events.d.ts.map +0 -1
  141. package/dist/commands/events.js +0 -76
  142. package/dist/commands/init.d.ts.map +0 -1
  143. package/dist/commands/init.js +0 -231
  144. package/dist/commands/invoke.d.ts.map +0 -1
  145. package/dist/commands/invoke.js +0 -54
  146. package/dist/commands/keygen.d.ts.map +0 -1
  147. package/dist/commands/keygen.js +0 -46
  148. package/dist/commands/link.d.ts.map +0 -1
  149. package/dist/commands/link.js +0 -43
  150. package/dist/commands/ls.d.ts.map +0 -1
  151. package/dist/commands/ls.js +0 -7
  152. package/dist/commands/mcp.d.ts.map +0 -1
  153. package/dist/commands/mcp.js +0 -98
  154. package/dist/commands/revoke.d.ts.map +0 -1
  155. package/dist/commands/revoke.js +0 -7
  156. package/dist/commands/tools.d.ts.map +0 -1
  157. package/dist/commands/tools.js +0 -52
  158. package/dist/daemon/address.d.ts.map +0 -1
  159. package/dist/daemon/address.js +0 -71
  160. package/dist/daemon/audit.d.ts.map +0 -1
  161. package/dist/daemon/audit.js +0 -237
  162. package/dist/daemon/calls.d.ts.map +0 -1
  163. package/dist/daemon/calls.js +0 -158
  164. package/dist/daemon/config.d.ts.map +0 -1
  165. package/dist/daemon/config.js +0 -170
  166. package/dist/daemon/daemon.d.ts.map +0 -1
  167. package/dist/daemon/daemon.js +0 -560
  168. package/dist/daemon/event-bus.d.ts.map +0 -1
  169. package/dist/daemon/event-bus.js +0 -111
  170. package/dist/daemon/links.d.ts.map +0 -1
  171. package/dist/daemon/links.js +0 -118
  172. package/dist/daemon/listener.d.ts.map +0 -1
  173. package/dist/daemon/listener.js +0 -132
  174. package/dist/daemon/log-rotation.d.ts.map +0 -1
  175. package/dist/daemon/log-rotation.js +0 -136
  176. package/dist/daemon/pidfile.d.ts.map +0 -1
  177. package/dist/daemon/pidfile.js +0 -90
  178. package/dist/daemon/policy.d.ts.map +0 -1
  179. package/dist/daemon/policy.js +0 -24
  180. package/dist/daemon/registry.d.ts.map +0 -1
  181. package/dist/daemon/registry.js +0 -31
  182. package/dist/daemon/rpc-server.d.ts.map +0 -1
  183. package/dist/daemon/rpc-server.js +0 -203
  184. package/dist/daemon/sessions.d.ts.map +0 -1
  185. package/dist/daemon/sessions.js +0 -429
  186. package/dist/daemon/socket-probe.d.ts.map +0 -1
  187. package/dist/daemon/socket-probe.js +0 -28
  188. package/dist/daemon/state-dir.d.ts.map +0 -1
  189. package/dist/daemon/state-dir.js +0 -43
  190. package/dist/daemon/timers.d.ts.map +0 -1
  191. package/dist/daemon/timers.js +0 -12
  192. package/dist/daemon/tls.d.ts.map +0 -1
  193. package/dist/daemon/tls.js +0 -79
  194. package/dist/errors.d.ts.map +0 -1
  195. package/dist/errors.js +0 -162
  196. package/dist/host-certificate.d.ts.map +0 -1
  197. package/dist/host-certificate.js +0 -111
  198. package/dist/index.d.ts.map +0 -1
  199. package/dist/key-material.d.ts.map +0 -1
  200. package/dist/key-material.js +0 -42
  201. package/dist/link.d.ts.map +0 -1
  202. package/dist/link.js +0 -117
  203. package/dist/mcp/connect-tool.d.ts.map +0 -1
  204. package/dist/mcp/connect-tool.js +0 -396
  205. package/dist/mcp/daemon-tools.d.ts.map +0 -1
  206. package/dist/mcp/daemon-tools.js +0 -29
  207. package/dist/mcp/events-tool.d.ts.map +0 -1
  208. package/dist/mcp/events-tool.js +0 -322
  209. package/dist/mcp/server.d.ts.map +0 -1
  210. package/dist/mcp/server.js +0 -538
  211. package/dist/mcp/tool-mapping.d.ts +0 -58
  212. package/dist/mcp/tool-mapping.d.ts.map +0 -1
  213. package/dist/mcp/tool-mapping.js +0 -143
  214. package/dist/mcp/tool-namespace.d.ts +0 -40
  215. package/dist/mcp/tool-namespace.d.ts.map +0 -1
  216. package/dist/mcp/tool-namespace.js +0 -65
  217. package/dist/native-scheme.d.ts.map +0 -1
  218. package/dist/native-scheme.js +0 -465
  219. package/dist/output.d.ts.map +0 -1
  220. package/dist/output.js +0 -342
  221. package/dist/package-version.d.ts.map +0 -1
  222. package/dist/package-version.js +0 -47
  223. package/dist/qr-terminal.d.ts.map +0 -1
  224. package/dist/qr-terminal.js +0 -45
  225. package/dist/rpc/client.d.ts.map +0 -1
  226. package/dist/rpc/client.js +0 -819
  227. package/dist/scheme.d.ts.map +0 -1
  228. package/dist/scheme.js +0 -321
  229. package/dist/spki-pin.d.ts.map +0 -1
  230. package/dist/spki-pin.js +0 -14
@@ -1,16 +1,2379 @@
1
- /**
2
- * `appduct/client` (issue #8): a first-class programmatic client for test runners — a thin typed
3
- * wrapper over the same daemon RPC the CLI and MCP server use, so a Jest/Vitest/Detox spec can
4
- * `connect()`/`call()` instead of spawning `appduct invoke ... --json` and parsing stdout.
5
- *
6
- * ```ts
7
- * import { connect } from "appduct/client";
8
- *
9
- * const app = await connect();
10
- * const { total } = await app.call("sum", { a: 2, b: 3 });
11
- * await app.close();
12
- * ```
13
- */
14
- export { connect } from "./connect.js";
15
- export { link, waitForSession } from "./bootstrap.js";
16
- export { AppductError } from "./errors.js";
1
+ // src/client/connect.ts
2
+ import { RPC_METHODS as RPC_METHODS3 } from "@appduct/shared";
3
+
4
+ // src/daemon/state-dir.ts
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ var resolveStateDir = (override) => {
8
+ return override ?? process.env.APPDUCT_STATE_DIR ?? join(homedir(), ".appduct");
9
+ };
10
+ var getStateDirPaths = (stateDir) => {
11
+ return {
12
+ root: stateDir,
13
+ socketPath: join(stateDir, "daemon.sock"),
14
+ pidFilePath: join(stateDir, "daemon.pid"),
15
+ logFilePath: join(stateDir, "daemon.log"),
16
+ keyPath: join(stateDir, "key.pem"),
17
+ configPath: join(stateDir, "config.json"),
18
+ auditDir: join(stateDir, "audit"),
19
+ spawnLockPath: join(stateDir, "daemon.spawn.lock")
20
+ };
21
+ };
22
+
23
+ // src/rpc/client.ts
24
+ import { connect as connect2 } from "node:net";
25
+ import { spawn as spawnChildProcess } from "node:child_process";
26
+ import { open as open2, readFile as readFile3, rm as rm2, stat as stat2 } from "node:fs/promises";
27
+ import { join as join3 } from "node:path";
28
+ import { RPC_METHODS } from "@appduct/shared";
29
+
30
+ // src/daemon/log-rotation.ts
31
+ import { chmod, rename, stat } from "node:fs/promises";
32
+
33
+ // src/daemon/config.ts
34
+ import { readFile } from "node:fs/promises";
35
+ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
36
+ "wssPort",
37
+ "keyPath",
38
+ "graceSeconds",
39
+ "linkTtlSeconds",
40
+ "keepaliveIntervalSeconds",
41
+ "eventBufferSize",
42
+ "auditRetentionDays",
43
+ "daemonLogMaxBytes",
44
+ "policy",
45
+ "advertisedIp",
46
+ "scheme",
47
+ "restartDaemonOnVersionMismatch"
48
+ ]);
49
+ var KNOWN_POLICY_KEYS = /* @__PURE__ */ new Set(["default", "destructive", "tools"]);
50
+ var POLICY_DECISIONS = /* @__PURE__ */ new Set(["allow", "deny", "prompt"]);
51
+ var DEFAULT_AUDIT_RETENTION_DAYS = 30;
52
+ var DEFAULT_DAEMON_LOG_MAX_BYTES = 10 * 1024 * 1024;
53
+ var AppductConfigError = class extends Error {
54
+ constructor(key, message) {
55
+ super(message);
56
+ this.key = key;
57
+ this.name = "AppductConfigError";
58
+ }
59
+ };
60
+ var configError = (key, message) => {
61
+ return new AppductConfigError(key, `Invalid Appduct config value for "${key}": ${message}`);
62
+ };
63
+ var requireWssPort = (value, key) => {
64
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 65535) {
65
+ throw configError(key, "must be a port number between 0 and 65535 (0 binds an OS-assigned port).");
66
+ }
67
+ return value;
68
+ };
69
+ var requirePositiveInteger = (value, key) => {
70
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
71
+ throw configError(key, "must be a positive integer.");
72
+ }
73
+ return value;
74
+ };
75
+ var requireNonEmptyString = (value, key) => {
76
+ if (typeof value !== "string" || value.length === 0) {
77
+ throw configError(key, "must be a non-empty string.");
78
+ }
79
+ return value;
80
+ };
81
+ var requireBoolean = (value, key) => {
82
+ if (typeof value !== "boolean") {
83
+ throw configError(key, "must be a boolean.");
84
+ }
85
+ return value;
86
+ };
87
+ var requirePolicyDecision = (value, key) => {
88
+ if (typeof value !== "string" || !POLICY_DECISIONS.has(value)) {
89
+ throw configError(key, 'must be "allow", "deny", or "prompt".');
90
+ }
91
+ return value;
92
+ };
93
+ var defaultConfig = (paths) => {
94
+ return {
95
+ wssPort: 8443,
96
+ keyPath: paths.keyPath,
97
+ graceSeconds: 600,
98
+ linkTtlSeconds: 300,
99
+ keepaliveIntervalSeconds: 15,
100
+ eventBufferSize: 256,
101
+ auditRetentionDays: DEFAULT_AUDIT_RETENTION_DAYS,
102
+ daemonLogMaxBytes: DEFAULT_DAEMON_LOG_MAX_BYTES,
103
+ restartDaemonOnVersionMismatch: false,
104
+ policy: {
105
+ default: "allow",
106
+ destructive: "allow"
107
+ }
108
+ };
109
+ };
110
+ var loadConfig = async (paths, options = {}) => {
111
+ const warn = options.warn ?? (() => {
112
+ });
113
+ const config = defaultConfig(paths);
114
+ let raw;
115
+ try {
116
+ raw = JSON.parse(await readFile(paths.configPath, "utf8"));
117
+ } catch (error) {
118
+ if (error.code === "ENOENT") {
119
+ return config;
120
+ }
121
+ throw configError("config.json", `could not be read/parsed (${error.message}).`);
122
+ }
123
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
124
+ throw configError("config.json", "must be a JSON object.");
125
+ }
126
+ const parsed = raw;
127
+ for (const key of Object.keys(parsed)) {
128
+ if (!KNOWN_TOP_LEVEL_KEYS.has(key)) {
129
+ warn(`Unknown Appduct config key "${key}" is ignored.`);
130
+ }
131
+ }
132
+ if (parsed.wssPort !== void 0) {
133
+ config.wssPort = requireWssPort(parsed.wssPort, "wssPort");
134
+ }
135
+ if (parsed.keyPath !== void 0) {
136
+ config.keyPath = requireNonEmptyString(parsed.keyPath, "keyPath");
137
+ }
138
+ if (parsed.advertisedIp !== void 0) {
139
+ config.advertisedIp = requireNonEmptyString(parsed.advertisedIp, "advertisedIp");
140
+ }
141
+ if (parsed.scheme !== void 0) {
142
+ config.scheme = requireNonEmptyString(parsed.scheme, "scheme");
143
+ }
144
+ if (parsed.graceSeconds !== void 0) {
145
+ config.graceSeconds = requirePositiveInteger(parsed.graceSeconds, "graceSeconds");
146
+ }
147
+ if (parsed.linkTtlSeconds !== void 0) {
148
+ config.linkTtlSeconds = requirePositiveInteger(parsed.linkTtlSeconds, "linkTtlSeconds");
149
+ }
150
+ if (parsed.keepaliveIntervalSeconds !== void 0) {
151
+ config.keepaliveIntervalSeconds = requirePositiveInteger(
152
+ parsed.keepaliveIntervalSeconds,
153
+ "keepaliveIntervalSeconds"
154
+ );
155
+ }
156
+ if (parsed.eventBufferSize !== void 0) {
157
+ config.eventBufferSize = requirePositiveInteger(parsed.eventBufferSize, "eventBufferSize");
158
+ }
159
+ if (parsed.auditRetentionDays !== void 0) {
160
+ config.auditRetentionDays = requirePositiveInteger(parsed.auditRetentionDays, "auditRetentionDays");
161
+ }
162
+ if (parsed.daemonLogMaxBytes !== void 0) {
163
+ config.daemonLogMaxBytes = requirePositiveInteger(parsed.daemonLogMaxBytes, "daemonLogMaxBytes");
164
+ }
165
+ if (parsed.restartDaemonOnVersionMismatch !== void 0) {
166
+ config.restartDaemonOnVersionMismatch = requireBoolean(
167
+ parsed.restartDaemonOnVersionMismatch,
168
+ "restartDaemonOnVersionMismatch"
169
+ );
170
+ }
171
+ if (parsed.policy !== void 0) {
172
+ if (typeof parsed.policy !== "object" || parsed.policy === null || Array.isArray(parsed.policy)) {
173
+ throw configError("policy", "must be an object.");
174
+ }
175
+ const policy = parsed.policy;
176
+ for (const key of Object.keys(policy)) {
177
+ if (!KNOWN_POLICY_KEYS.has(key)) {
178
+ warn(`Unknown Appduct config key "policy.${key}" is ignored.`);
179
+ }
180
+ }
181
+ if (policy.default !== void 0) {
182
+ config.policy.default = requirePolicyDecision(policy.default, "policy.default");
183
+ }
184
+ if (policy.destructive !== void 0) {
185
+ config.policy.destructive = requirePolicyDecision(policy.destructive, "policy.destructive");
186
+ }
187
+ if (policy.tools !== void 0) {
188
+ if (typeof policy.tools !== "object" || policy.tools === null || Array.isArray(policy.tools)) {
189
+ throw configError("policy.tools", "must be an object.");
190
+ }
191
+ const tools = {};
192
+ for (const [toolKey, toolValue] of Object.entries(policy.tools)) {
193
+ tools[toolKey] = requirePolicyDecision(toolValue, `policy.tools["${toolKey}"]`);
194
+ }
195
+ config.policy.tools = tools;
196
+ }
197
+ }
198
+ return config;
199
+ };
200
+
201
+ // src/daemon/pidfile.ts
202
+ import { readFileSync } from "node:fs";
203
+ import { open, readFile as readFile2, rm } from "node:fs/promises";
204
+ var readProcStatus = (pid) => {
205
+ try {
206
+ return readFileSync(`/proc/${pid}/status`, "utf8");
207
+ } catch {
208
+ return void 0;
209
+ }
210
+ };
211
+ var isZombie = (pid, readStatus) => {
212
+ const status = readStatus(pid);
213
+ if (status === void 0) {
214
+ return false;
215
+ }
216
+ return /^State:\s*Z\b/mu.test(status);
217
+ };
218
+ var isProcessAlive = (pid, readStatus = readProcStatus) => {
219
+ try {
220
+ process.kill(pid, 0);
221
+ } catch (error) {
222
+ return error.code === "EPERM";
223
+ }
224
+ return !isZombie(pid, readStatus);
225
+ };
226
+ var readPidFromFile = async (pidFilePath) => {
227
+ let contents;
228
+ try {
229
+ contents = await readFile2(pidFilePath, "utf8");
230
+ } catch (error) {
231
+ if (error.code === "ENOENT") {
232
+ return void 0;
233
+ }
234
+ throw error;
235
+ }
236
+ const pid = Number.parseInt(contents.trim(), 10);
237
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
238
+ };
239
+
240
+ // src/daemon/socket-probe.ts
241
+ import { connect } from "node:net";
242
+ var isSocketConnectable = (socketPath) => {
243
+ return new Promise((resolve2) => {
244
+ const socket = connect(socketPath);
245
+ socket.once("connect", () => {
246
+ socket.destroy();
247
+ resolve2(true);
248
+ });
249
+ socket.once("error", () => {
250
+ socket.destroy();
251
+ resolve2(false);
252
+ });
253
+ });
254
+ };
255
+
256
+ // src/daemon/log-rotation.ts
257
+ var STATE_FILE_MODE = 384;
258
+ var daemonLogBackupPath = (logFilePath) => `${logFilePath}.1`;
259
+ var isDaemonHoldingLog = async (pidFilePath) => {
260
+ try {
261
+ const pid = await readPidFromFile(pidFilePath);
262
+ return pid !== void 0 && isProcessAlive(pid);
263
+ } catch {
264
+ return false;
265
+ }
266
+ };
267
+ var rotateDaemonLogIfNeeded = async (options) => {
268
+ const warn = options.warn ?? ((message) => {
269
+ process.stderr.write(`${message}
270
+ `);
271
+ });
272
+ try {
273
+ const stats = await stat(options.logFilePath);
274
+ if (!stats.isFile() || stats.size <= options.maxBytes) {
275
+ return { rotated: false };
276
+ }
277
+ const held = options.pidFilePath !== void 0 && await isDaemonHoldingLog(options.pidFilePath) || options.socketPath !== void 0 && await isSocketConnectable(options.socketPath);
278
+ if (held) {
279
+ warn(
280
+ `appduct: "${options.logFilePath}" is over its size cap but a daemon still holds it; leaving it in place.`
281
+ );
282
+ return { rotated: false, skipped: "daemon_running" };
283
+ }
284
+ const backupPath = daemonLogBackupPath(options.logFilePath);
285
+ await rename(options.logFilePath, backupPath);
286
+ try {
287
+ await chmod(backupPath, STATE_FILE_MODE);
288
+ } catch (error) {
289
+ warn(`appduct: rotated "${options.logFilePath}" but could not set 0600 on "${backupPath}": ${error.message}`);
290
+ }
291
+ return { rotated: true, rotatedBytes: stats.size };
292
+ } catch (error) {
293
+ if (error.code === "ENOENT") {
294
+ return { rotated: false };
295
+ }
296
+ warn(`appduct: failed to rotate "${options.logFilePath}": ${error.message}`);
297
+ return { rotated: false };
298
+ }
299
+ };
300
+ var ensureDaemonLogMode = async (logFilePath, warn = (message) => process.stderr.write(`${message}
301
+ `)) => {
302
+ try {
303
+ await chmod(logFilePath, STATE_FILE_MODE);
304
+ return true;
305
+ } catch (error) {
306
+ warn(`appduct: could not set 0600 on "${logFilePath}": ${error.message}`);
307
+ return false;
308
+ }
309
+ };
310
+ var resolveDaemonLogMaxBytes = async (stateDir) => {
311
+ try {
312
+ return (await loadConfig(getStateDirPaths(stateDir))).daemonLogMaxBytes;
313
+ } catch {
314
+ return DEFAULT_DAEMON_LOG_MAX_BYTES;
315
+ }
316
+ };
317
+
318
+ // src/package-root.ts
319
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
320
+ import { dirname, join as join2 } from "node:path";
321
+ import { fileURLToPath } from "node:url";
322
+ var PACKAGE_NAME = "appduct";
323
+ var cached;
324
+ var getPackageRoot = () => {
325
+ if (cached !== void 0) {
326
+ return cached;
327
+ }
328
+ let dir = dirname(fileURLToPath(import.meta.url));
329
+ for (; ; ) {
330
+ const candidate = join2(dir, "package.json");
331
+ if (existsSync(candidate)) {
332
+ let name;
333
+ try {
334
+ name = JSON.parse(readFileSync2(candidate, "utf8")).name;
335
+ } catch {
336
+ }
337
+ if (name === PACKAGE_NAME) {
338
+ cached = dir;
339
+ return dir;
340
+ }
341
+ }
342
+ const parent = dirname(dir);
343
+ if (parent === dir) {
344
+ throw new Error(`Could not locate the "${PACKAGE_NAME}" package root above ${import.meta.url}.`);
345
+ }
346
+ dir = parent;
347
+ }
348
+ };
349
+
350
+ // src/package-version.ts
351
+ var DAEMON_VERSION_OVERRIDE_ENV = "APPDUCT_DAEMON_VERSION_OVERRIDE";
352
+
353
+ // src/rpc/client.ts
354
+ var defaultBinPath = join3(getPackageRoot(), "bin.js");
355
+ var DaemonRpcError = class extends Error {
356
+ constructor(code, message, data) {
357
+ super(message);
358
+ this.code = code;
359
+ this.data = data;
360
+ this.name = "DaemonRpcError";
361
+ }
362
+ };
363
+ var DaemonUnavailableError = class extends Error {
364
+ constructor(message, reason = "unreachable") {
365
+ super(message);
366
+ this.reason = reason;
367
+ this.name = "DaemonUnavailableError";
368
+ }
369
+ };
370
+ var isRequestTimeoutError = (error) => {
371
+ return error instanceof DaemonUnavailableError && error.reason === "timeout";
372
+ };
373
+ var describeLiveState = (state) => {
374
+ const parts = [];
375
+ if (state.sessions > 0) {
376
+ parts.push(`${state.sessions} connected session(s)`);
377
+ }
378
+ if (state.pendingLinks > 0) {
379
+ parts.push(`${state.pendingLinks} unclaimed link(s)`);
380
+ }
381
+ return parts.join(" and ");
382
+ };
383
+ var DaemonVersionMismatchError = class extends Error {
384
+ constructor(daemonVersion, clientVersion, live, message) {
385
+ super(
386
+ message ?? `The running Appduct daemon is version ${daemonVersion}, but this client is version ${clientVersion}. It was not restarted automatically because ${describeLiveState(live)} would be lost. Run "appduct daemon stop" once that state is expendable, or force it with "--daemon-restart".`
387
+ );
388
+ this.daemonVersion = daemonVersion;
389
+ this.clientVersion = clientVersion;
390
+ this.name = "DaemonVersionMismatchError";
391
+ this.sessionCount = live.sessions;
392
+ this.pendingLinkCount = live.pendingLinks;
393
+ }
394
+ sessionCount;
395
+ pendingLinkCount;
396
+ };
397
+ var DaemonVersionRestartIneffectiveError = class extends DaemonVersionMismatchError {
398
+ constructor(daemonVersion, clientVersion, live) {
399
+ super(
400
+ daemonVersion,
401
+ clientVersion,
402
+ live,
403
+ `The Appduct daemon was restarted to match this client (version ${clientVersion}), but the daemon now answering still reports version ${daemonVersion}. Either the old daemon never exited (compare "daemon.pid" with what "appduct daemon status" reports), or another process spawned an older daemon in the meantime. Run "appduct daemon stop", then retry.`
404
+ );
405
+ this.name = "DaemonVersionRestartIneffectiveError";
406
+ }
407
+ };
408
+ var parseVersion = (value) => {
409
+ const match = /^(\d+(?:\.\d+)*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value.trim());
410
+ if (!match) {
411
+ return void 0;
412
+ }
413
+ return {
414
+ numbers: match[1].split(".").map(Number),
415
+ // Build metadata (`+…`) is dropped, per semver: it never affects precedence, so two versions
416
+ // differing only there are the same version and neither restarts nor warns about the other.
417
+ prerelease: match[2]?.split(".")
418
+ };
419
+ };
420
+ var isNumericIdentifier = (identifier) => /^\d+$/u.test(identifier);
421
+ var comparePrerelease = (left, right) => {
422
+ const length = Math.max(left.length, right.length);
423
+ for (let index = 0; index < length; index += 1) {
424
+ const a = left[index];
425
+ const b = right[index];
426
+ if (a === void 0) {
427
+ return -1;
428
+ }
429
+ if (b === void 0) {
430
+ return 1;
431
+ }
432
+ if (a === b) {
433
+ continue;
434
+ }
435
+ const aNumeric = isNumericIdentifier(a);
436
+ const bNumeric = isNumericIdentifier(b);
437
+ if (aNumeric && bNumeric) {
438
+ return Number(a) < Number(b) ? -1 : 1;
439
+ }
440
+ if (aNumeric !== bNumeric) {
441
+ return aNumeric ? -1 : 1;
442
+ }
443
+ return a < b ? -1 : 1;
444
+ }
445
+ return 0;
446
+ };
447
+ var compareVersions = (a, b) => {
448
+ const left = parseVersion(a);
449
+ const right = parseVersion(b);
450
+ if (!left || !right) {
451
+ return void 0;
452
+ }
453
+ const length = Math.max(left.numbers.length, right.numbers.length);
454
+ for (let index = 0; index < length; index += 1) {
455
+ const difference = (left.numbers[index] ?? 0) - (right.numbers[index] ?? 0);
456
+ if (difference !== 0) {
457
+ return difference < 0 ? -1 : 1;
458
+ }
459
+ }
460
+ if (left.prerelease === void 0 && right.prerelease === void 0) {
461
+ return 0;
462
+ }
463
+ if (left.prerelease === void 0) {
464
+ return 1;
465
+ }
466
+ if (right.prerelease === void 0) {
467
+ return -1;
468
+ }
469
+ return comparePrerelease(left.prerelease, right.prerelease);
470
+ };
471
+ var isSameVersion = (a, b) => {
472
+ return a === b || compareVersions(a, b) === 0;
473
+ };
474
+ var isConnectionMissingError = (error) => {
475
+ const code = error.code;
476
+ return code === "ENOENT" || code === "ECONNREFUSED";
477
+ };
478
+ var isDaemonUnreachableError = (error) => {
479
+ return error instanceof DaemonUnavailableError || isConnectionMissingError(error);
480
+ };
481
+ var nextRequestId = 1;
482
+ var sendRequest = (socketPath, method, params, timeoutMs) => {
483
+ return new Promise((resolve2, reject) => {
484
+ const id = nextRequestId;
485
+ nextRequestId += 1;
486
+ let socket;
487
+ let settled = false;
488
+ let buffer = "";
489
+ const finish = (fn) => {
490
+ if (settled) {
491
+ return;
492
+ }
493
+ settled = true;
494
+ clearTimeout(timeout);
495
+ socket.destroy();
496
+ fn();
497
+ };
498
+ const timeout = setTimeout(() => {
499
+ finish(
500
+ () => reject(new DaemonUnavailableError(`Timed out waiting for a response to "${method}".`, "timeout"))
501
+ );
502
+ }, timeoutMs);
503
+ socket = connect2(socketPath);
504
+ socket.on("error", (error) => {
505
+ finish(() => reject(error));
506
+ });
507
+ socket.on("connect", () => {
508
+ socket.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
509
+ `);
510
+ });
511
+ socket.on("data", (chunk) => {
512
+ buffer += chunk.toString("utf8");
513
+ let newlineIndex = buffer.indexOf("\n");
514
+ while (newlineIndex !== -1) {
515
+ const line = buffer.slice(0, newlineIndex);
516
+ buffer = buffer.slice(newlineIndex + 1);
517
+ newlineIndex = buffer.indexOf("\n");
518
+ if (line.length === 0) {
519
+ continue;
520
+ }
521
+ let message;
522
+ try {
523
+ message = JSON.parse(line);
524
+ } catch {
525
+ continue;
526
+ }
527
+ if (message.id !== id) {
528
+ continue;
529
+ }
530
+ if (message.error) {
531
+ finish(
532
+ () => reject(new DaemonRpcError(message.error.code, message.error.message, message.error.data))
533
+ );
534
+ return;
535
+ }
536
+ finish(() => resolve2(message.result));
537
+ return;
538
+ }
539
+ });
540
+ socket.on("close", () => {
541
+ finish(
542
+ () => reject(new DaemonUnavailableError("Connection to the Appduct daemon closed.", "closed"))
543
+ );
544
+ });
545
+ });
546
+ };
547
+ var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
548
+ var pollForSocket = async (socketPath, timeoutMs, pollIntervalMs) => {
549
+ const deadline = Date.now() + timeoutMs;
550
+ while (Date.now() < deadline) {
551
+ if (await isSocketConnectable(socketPath)) {
552
+ return true;
553
+ }
554
+ await sleep(pollIntervalMs);
555
+ }
556
+ return isSocketConnectable(socketPath);
557
+ };
558
+ var unlinkStaleSocketIfDaemonDead = async (paths) => {
559
+ let pid;
560
+ try {
561
+ pid = Number.parseInt((await readFile3(paths.pidFilePath, "utf8")).trim(), 10);
562
+ } catch {
563
+ pid = void 0;
564
+ }
565
+ if (pid === void 0 || Number.isNaN(pid) || !isProcessAlive(pid)) {
566
+ await rm2(paths.socketPath, { force: true });
567
+ }
568
+ };
569
+ var defaultSpawn = async (args, context) => {
570
+ const paths = getStateDirPaths(context.stateDir);
571
+ await rotateDaemonLogIfNeeded({
572
+ logFilePath: context.logFilePath,
573
+ maxBytes: await resolveDaemonLogMaxBytes(context.stateDir),
574
+ pidFilePath: paths.pidFilePath,
575
+ socketPath: paths.socketPath
576
+ });
577
+ const logFd = await open2(context.logFilePath, "a", 384);
578
+ try {
579
+ await ensureDaemonLogMode(context.logFilePath);
580
+ const childEnv = { ...process.env, APPDUCT_STATE_DIR: context.stateDir };
581
+ delete childEnv[DAEMON_VERSION_OVERRIDE_ENV];
582
+ const child = spawnChildProcess(process.execPath, [defaultBinPath, ...args], {
583
+ detached: true,
584
+ stdio: ["ignore", logFd.fd, logFd.fd],
585
+ env: childEnv
586
+ });
587
+ child.unref();
588
+ } finally {
589
+ await logFd.close();
590
+ }
591
+ };
592
+ var SPAWN_LOCK_STALE_MS = 3e4;
593
+ var pollUntilLockReleased = async (lockPath, timeoutMs, pollIntervalMs) => {
594
+ const deadline = Date.now() + timeoutMs;
595
+ while (Date.now() < deadline) {
596
+ if (!await pathExists(lockPath)) {
597
+ return true;
598
+ }
599
+ await sleep(pollIntervalMs);
600
+ }
601
+ return !await pathExists(lockPath);
602
+ };
603
+ var readPidfilePid = async (paths) => {
604
+ try {
605
+ const pid = Number.parseInt((await readFile3(paths.pidFilePath, "utf8")).trim(), 10);
606
+ return Number.isNaN(pid) ? void 0 : pid;
607
+ } catch {
608
+ return void 0;
609
+ }
610
+ };
611
+ var pathExists = async (target) => {
612
+ try {
613
+ await stat2(target);
614
+ return true;
615
+ } catch {
616
+ return false;
617
+ }
618
+ };
619
+ var acquireSpawnLock = async (lockPath) => {
620
+ for (let attempt = 0; attempt < 2; attempt += 1) {
621
+ try {
622
+ const lockHandle = await open2(lockPath, "wx", 384);
623
+ await lockHandle.close();
624
+ return true;
625
+ } catch (error) {
626
+ if (error.code !== "EEXIST") {
627
+ throw error;
628
+ }
629
+ }
630
+ let heldForMs;
631
+ try {
632
+ heldForMs = Date.now() - (await stat2(lockPath)).mtimeMs;
633
+ } catch {
634
+ continue;
635
+ }
636
+ if (heldForMs < SPAWN_LOCK_STALE_MS) {
637
+ return false;
638
+ }
639
+ await rm2(lockPath, { force: true });
640
+ }
641
+ return false;
642
+ };
643
+ var spawnDaemonAndWait = async (paths, spawn, waitTimeoutMs, pollIntervalMs, options = {}) => {
644
+ const acquiredLock = await acquireSpawnLock(paths.spawnLockPath);
645
+ let lockWaitTimedOut = false;
646
+ if (acquiredLock) {
647
+ try {
648
+ const shouldSpawn = options.beforeSpawn ? await options.beforeSpawn() : true;
649
+ if (shouldSpawn) {
650
+ await unlinkStaleSocketIfDaemonDead(paths);
651
+ await spawn(["daemon", "run"], { stateDir: paths.root, logFilePath: paths.logFilePath });
652
+ }
653
+ } finally {
654
+ await rm2(paths.spawnLockPath, { force: true });
655
+ }
656
+ } else if (options.awaitLockRelease) {
657
+ lockWaitTimedOut = !await pollUntilLockReleased(paths.spawnLockPath, waitTimeoutMs, pollIntervalMs);
658
+ }
659
+ const ready = await pollForSocket(paths.socketPath, waitTimeoutMs, pollIntervalMs);
660
+ if (!ready) {
661
+ const lockHint = await pathExists(paths.spawnLockPath) ? ` Another process holds the spawn-lock at "${paths.spawnLockPath}"; if no "appduct daemon" is starting, delete it and retry.` : "";
662
+ throw new DaemonUnavailableError(
663
+ `Timed out waiting for the Appduct daemon socket at "${paths.socketPath}".${lockHint}`
664
+ );
665
+ }
666
+ return { ownedLock: acquiredLock, lockWaitTimedOut };
667
+ };
668
+ var versionChecks = /* @__PURE__ */ new Map();
669
+ var RESTART_REQUEST_TIMEOUT_MS = 3e3;
670
+ var readStatusSpawningIfNeeded = async (context) => {
671
+ try {
672
+ return await sendRequest(
673
+ context.paths.socketPath,
674
+ RPC_METHODS.daemonStatus,
675
+ {},
676
+ context.requestTimeoutMs
677
+ );
678
+ } catch (error) {
679
+ if (!isConnectionMissingError(error)) {
680
+ throw error;
681
+ }
682
+ if (!context.autoSpawn) {
683
+ return void 0;
684
+ }
685
+ await spawnDaemonAndWait(context.paths, context.spawn, context.waitTimeoutMs, context.pollIntervalMs);
686
+ return sendRequest(
687
+ context.paths.socketPath,
688
+ RPC_METHODS.daemonStatus,
689
+ {},
690
+ context.requestTimeoutMs
691
+ );
692
+ }
693
+ };
694
+ var readStatusIfReachable = async (context) => {
695
+ try {
696
+ return await sendRequest(
697
+ context.paths.socketPath,
698
+ RPC_METHODS.daemonStatus,
699
+ {},
700
+ RESTART_REQUEST_TIMEOUT_MS
701
+ );
702
+ } catch (error) {
703
+ if (isConnectionMissingError(error)) {
704
+ return void 0;
705
+ }
706
+ if (isRequestTimeoutError(error)) {
707
+ throw new DaemonUnavailableError(
708
+ `The Appduct daemon at "${context.paths.socketPath}" did not answer "daemon.status" within ${RESTART_REQUEST_TIMEOUT_MS}ms, so it was not replaced. It is running but unresponsive \u2014 run "appduct daemon stop" and retry.`,
709
+ "timeout"
710
+ );
711
+ }
712
+ throw error;
713
+ }
714
+ };
715
+ var isDaemonGone = async (paths) => {
716
+ if (await isSocketConnectable(paths.socketPath)) {
717
+ return false;
718
+ }
719
+ let pid;
720
+ try {
721
+ pid = Number.parseInt((await readFile3(paths.pidFilePath, "utf8")).trim(), 10);
722
+ } catch {
723
+ return true;
724
+ }
725
+ return pid === void 0 || Number.isNaN(pid) || !isProcessAlive(pid);
726
+ };
727
+ var readLiveState = (status) => {
728
+ return {
729
+ sessions: Array.isArray(status.sessions) ? status.sessions.length : 0,
730
+ pendingLinks: typeof status.pendingLinks === "number" ? status.pendingLinks : 0
731
+ };
732
+ };
733
+ var isSafeToRestart = (live) => {
734
+ return live.sessions === 0 && live.pendingLinks === 0;
735
+ };
736
+ var pollUntilDaemonGone = async (paths, timeoutMs, pollIntervalMs) => {
737
+ const deadline = Date.now() + timeoutMs;
738
+ while (Date.now() < deadline) {
739
+ if (await isDaemonGone(paths)) {
740
+ return true;
741
+ }
742
+ await sleep(pollIntervalMs);
743
+ }
744
+ return isDaemonGone(paths);
745
+ };
746
+ var restartDaemon = async (context) => {
747
+ const pidBefore = await readPidfilePid(context.paths);
748
+ const outcome = await spawnDaemonAndWait(
749
+ context.paths,
750
+ context.spawn,
751
+ context.waitTimeoutMs,
752
+ context.pollIntervalMs,
753
+ {
754
+ awaitLockRelease: true,
755
+ beforeSpawn: async () => {
756
+ const status = await readStatusIfReachable(context);
757
+ if (status === void 0) {
758
+ return true;
759
+ }
760
+ if (isSameVersion(status.version, context.check.clientVersion)) {
761
+ return false;
762
+ }
763
+ const live = readLiveState(status);
764
+ if (!isSafeToRestart(live) && !context.check.forceRestart) {
765
+ throw new DaemonVersionMismatchError(status.version, context.check.clientVersion, live);
766
+ }
767
+ try {
768
+ await sendRequest(
769
+ context.paths.socketPath,
770
+ RPC_METHODS.daemonShutdown,
771
+ {},
772
+ RESTART_REQUEST_TIMEOUT_MS
773
+ );
774
+ } catch (error) {
775
+ if (isRequestTimeoutError(error)) {
776
+ throw new DaemonUnavailableError(
777
+ `The Appduct daemon at "${context.paths.socketPath}" did not acknowledge "daemon.shutdown" within ${RESTART_REQUEST_TIMEOUT_MS}ms, so it was left running. Run "appduct daemon stop" and retry.`,
778
+ "timeout"
779
+ );
780
+ }
781
+ if (!isDaemonUnreachableError(error)) {
782
+ throw error;
783
+ }
784
+ }
785
+ if (!await pollUntilDaemonGone(context.paths, context.waitTimeoutMs, context.pollIntervalMs)) {
786
+ throw new DaemonUnavailableError(
787
+ `The Appduct daemon at "${context.paths.socketPath}" did not shut down in time; run "appduct daemon stop" and retry.`
788
+ );
789
+ }
790
+ return true;
791
+ }
792
+ }
793
+ );
794
+ if (!outcome.ownedLock && outcome.lockWaitTimedOut && await readPidfilePid(context.paths) === pidBefore) {
795
+ throw new DaemonUnavailableError(
796
+ `The Appduct daemon at "${context.paths.socketPath}" did not shut down in time: another process has held the spawn-lock at "${context.paths.spawnLockPath}" for longer than this command waited. Retry, or run "appduct daemon stop" if nothing is starting a daemon.`
797
+ );
798
+ }
799
+ };
800
+ var warnOlderClient = (context, daemonVersion) => {
801
+ const message = `appduct: the running daemon is version ${daemonVersion}, newer than this client (${context.check.clientVersion}). Continuing without restarting it \u2014 a newer daemon serves an older client, and replacing it would downgrade whatever started it. Run "appduct daemon stop" if you want this client's daemon instead.
802
+ `;
803
+ const warn = context.check.onWarning ?? ((text) => void process.stderr.write(text));
804
+ warn(message);
805
+ };
806
+ var runVersionCheck = async (context) => {
807
+ const status = await readStatusSpawningIfNeeded(context);
808
+ if (status === void 0) {
809
+ return;
810
+ }
811
+ if (isSameVersion(status.version, context.check.clientVersion)) {
812
+ return;
813
+ }
814
+ if ((compareVersions(context.check.clientVersion, status.version) ?? -1) <= 0) {
815
+ warnOlderClient(context, status.version);
816
+ return;
817
+ }
818
+ const live = readLiveState(status);
819
+ if (!isSafeToRestart(live) && !context.check.forceRestart) {
820
+ throw new DaemonVersionMismatchError(status.version, context.check.clientVersion, live);
821
+ }
822
+ await restartDaemon(context);
823
+ const replacement = await readStatusIfReachable(context);
824
+ if (replacement === void 0) {
825
+ throw new DaemonUnavailableError(
826
+ `The Appduct daemon was restarted but nothing is listening at "${context.paths.socketPath}"; check "${context.paths.logFilePath}".`
827
+ );
828
+ }
829
+ if (isSameVersion(replacement.version, context.check.clientVersion)) {
830
+ return;
831
+ }
832
+ if ((compareVersions(context.check.clientVersion, replacement.version) ?? -1) <= 0) {
833
+ warnOlderClient(context, replacement.version);
834
+ return;
835
+ }
836
+ throw new DaemonVersionRestartIneffectiveError(
837
+ replacement.version,
838
+ context.check.clientVersion,
839
+ readLiveState(replacement)
840
+ );
841
+ };
842
+ var ensureDaemonVersion = async (paths, options, spawn) => {
843
+ const check = options.checkVersion;
844
+ if (!check) {
845
+ return;
846
+ }
847
+ const cached2 = versionChecks.get(paths.socketPath);
848
+ if (cached2) {
849
+ return cached2;
850
+ }
851
+ const pending = runVersionCheck({
852
+ paths,
853
+ spawn,
854
+ autoSpawn: options.autoSpawn ?? true,
855
+ requestTimeoutMs: options.requestTimeoutMs ?? 1e4,
856
+ waitTimeoutMs: options.spawnWaitTimeoutMs ?? 5e3,
857
+ pollIntervalMs: options.spawnPollIntervalMs ?? 100,
858
+ check
859
+ }).catch((error) => {
860
+ if (!(error instanceof DaemonVersionMismatchError)) {
861
+ versionChecks.delete(paths.socketPath);
862
+ }
863
+ throw error;
864
+ });
865
+ versionChecks.set(paths.socketPath, pending);
866
+ return pending;
867
+ };
868
+ var callDaemon = async (method, params, options) => {
869
+ const paths = getStateDirPaths(options.stateDir);
870
+ const autoSpawn = options.autoSpawn ?? true;
871
+ const spawn = options.spawn ?? defaultSpawn;
872
+ const requestTimeoutMs = options.requestTimeoutMs ?? 1e4;
873
+ await ensureDaemonVersion(paths, options, spawn);
874
+ try {
875
+ return await sendRequest(paths.socketPath, method, params, requestTimeoutMs);
876
+ } catch (error) {
877
+ if (!autoSpawn || !isConnectionMissingError(error)) {
878
+ throw error;
879
+ }
880
+ await spawnDaemonAndWait(
881
+ paths,
882
+ spawn,
883
+ options.spawnWaitTimeoutMs ?? 5e3,
884
+ options.spawnPollIntervalMs ?? 100
885
+ );
886
+ return sendRequest(paths.socketPath, method, params, requestTimeoutMs);
887
+ }
888
+ };
889
+ var openDaemonStream = async (options) => {
890
+ const paths = getStateDirPaths(options.stateDir);
891
+ const autoSpawn = options.autoSpawn ?? true;
892
+ const spawn = options.spawn ?? defaultSpawn;
893
+ const requestTimeoutMs = options.requestTimeoutMs ?? 1e4;
894
+ const connectSocket = () => {
895
+ return new Promise((resolve2, reject) => {
896
+ const socket2 = connect2(paths.socketPath);
897
+ socket2.once("connect", () => {
898
+ socket2.off("error", onError);
899
+ resolve2(socket2);
900
+ });
901
+ const onError = (error) => {
902
+ reject(error);
903
+ };
904
+ socket2.once("error", onError);
905
+ });
906
+ };
907
+ await ensureDaemonVersion(paths, options, spawn);
908
+ let socket;
909
+ try {
910
+ socket = await connectSocket();
911
+ } catch (error) {
912
+ if (!autoSpawn || !isConnectionMissingError(error)) {
913
+ throw error;
914
+ }
915
+ await spawnDaemonAndWait(
916
+ paths,
917
+ spawn,
918
+ options.spawnWaitTimeoutMs ?? 5e3,
919
+ options.spawnPollIntervalMs ?? 100
920
+ );
921
+ socket = await connectSocket();
922
+ }
923
+ socket.on("error", () => {
924
+ });
925
+ const notificationListeners = /* @__PURE__ */ new Set();
926
+ const closeListeners = /* @__PURE__ */ new Set();
927
+ const pendingCalls = /* @__PURE__ */ new Map();
928
+ let buffer = "";
929
+ socket.on("data", (chunk) => {
930
+ buffer += chunk.toString("utf8");
931
+ let newlineIndex = buffer.indexOf("\n");
932
+ while (newlineIndex !== -1) {
933
+ const line = buffer.slice(0, newlineIndex);
934
+ buffer = buffer.slice(newlineIndex + 1);
935
+ newlineIndex = buffer.indexOf("\n");
936
+ if (line.length === 0) {
937
+ continue;
938
+ }
939
+ let message;
940
+ try {
941
+ message = JSON.parse(line);
942
+ } catch {
943
+ continue;
944
+ }
945
+ if (message.method === "event") {
946
+ for (const listener of notificationListeners) {
947
+ try {
948
+ listener(message.params);
949
+ } catch {
950
+ }
951
+ }
952
+ continue;
953
+ }
954
+ const pending = typeof message.id === "number" ? pendingCalls.get(message.id) : void 0;
955
+ if (!pending) {
956
+ continue;
957
+ }
958
+ pendingCalls.delete(message.id);
959
+ clearTimeout(pending.timeout);
960
+ if (message.error) {
961
+ pending.reject(new DaemonRpcError(message.error.code, message.error.message, message.error.data));
962
+ } else {
963
+ pending.resolve(message.result);
964
+ }
965
+ }
966
+ });
967
+ socket.on("close", () => {
968
+ for (const [, pending] of pendingCalls) {
969
+ clearTimeout(pending.timeout);
970
+ pending.reject(new DaemonUnavailableError("Connection to the Appduct daemon closed.", "closed"));
971
+ }
972
+ pendingCalls.clear();
973
+ for (const listener of closeListeners) {
974
+ listener();
975
+ }
976
+ });
977
+ return {
978
+ call: (method, params, timeoutMs) => {
979
+ return new Promise((resolve2, reject) => {
980
+ const id = nextRequestId;
981
+ nextRequestId += 1;
982
+ const timeout = setTimeout(() => {
983
+ pendingCalls.delete(id);
984
+ reject(new DaemonUnavailableError(`Timed out waiting for a response to "${method}".`, "timeout"));
985
+ }, timeoutMs ?? requestTimeoutMs);
986
+ pendingCalls.set(id, {
987
+ resolve: resolve2,
988
+ reject,
989
+ timeout
990
+ });
991
+ socket.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
992
+ `);
993
+ });
994
+ },
995
+ onNotification: (callback) => {
996
+ notificationListeners.add(callback);
997
+ return () => {
998
+ notificationListeners.delete(callback);
999
+ };
1000
+ },
1001
+ onClose: (callback) => {
1002
+ closeListeners.add(callback);
1003
+ return () => {
1004
+ closeListeners.delete(callback);
1005
+ };
1006
+ },
1007
+ close: () => {
1008
+ socket.destroy();
1009
+ }
1010
+ };
1011
+ };
1012
+
1013
+ // src/client/app-client.ts
1014
+ import {
1015
+ clampToolTimeoutMs,
1016
+ MAX_TOOL_TIMEOUT_MS,
1017
+ RPC_METHODS as RPC_METHODS2
1018
+ } from "@appduct/shared";
1019
+
1020
+ // src/errors.ts
1021
+ var AppductCliError = class extends Error {
1022
+ type;
1023
+ details;
1024
+ constructor(type, message, details) {
1025
+ super(message);
1026
+ this.name = "AppductCliError";
1027
+ this.type = type;
1028
+ this.details = details;
1029
+ }
1030
+ };
1031
+ var usageError = (message, details) => {
1032
+ return new AppductCliError("usage_error", message, details);
1033
+ };
1034
+ var connectionError = (message, details) => {
1035
+ return new AppductCliError("connection_error", message, details);
1036
+ };
1037
+ var isAppductCliError = (value) => {
1038
+ return value instanceof AppductCliError;
1039
+ };
1040
+
1041
+ // src/client/errors.ts
1042
+ var AppductError = class extends Error {
1043
+ type;
1044
+ details;
1045
+ /** The JSON-RPC error code, when this wraps a `DaemonRpcError`. */
1046
+ code;
1047
+ constructor(type, message, details, code) {
1048
+ super(message);
1049
+ this.name = "AppductError";
1050
+ this.type = type;
1051
+ this.details = details;
1052
+ this.code = code;
1053
+ }
1054
+ };
1055
+ var toAppductError = (error) => {
1056
+ if (error instanceof AppductError) {
1057
+ return error;
1058
+ }
1059
+ const withCause = (result) => {
1060
+ result.cause = error;
1061
+ return result;
1062
+ };
1063
+ if (error instanceof DaemonRpcError) {
1064
+ const type = error.data?.type ?? "client_error";
1065
+ return withCause(new AppductError(type, error.message, error.data?.details, error.code));
1066
+ }
1067
+ if (isDaemonUnreachableError(error)) {
1068
+ return withCause(
1069
+ new AppductError(
1070
+ "connection_error",
1071
+ error instanceof Error ? error.message : "The Appduct daemon is unreachable."
1072
+ )
1073
+ );
1074
+ }
1075
+ if (isAppductCliError(error)) {
1076
+ return withCause(new AppductError("client_error", error.message, error.details));
1077
+ }
1078
+ if (error instanceof Error) {
1079
+ return withCause(new AppductError("client_error", error.message));
1080
+ }
1081
+ return new AppductError("client_error", "An unexpected error occurred.", error);
1082
+ };
1083
+
1084
+ // src/client/app-client.ts
1085
+ var DEFAULT_WAIT_FOR_EVENT_TIMEOUT_MS = 3e4;
1086
+ var CALL_TRANSPORT_TIMEOUT_SLACK_MS = 5e3;
1087
+ var transportTimeoutForToolCall = (timeoutMs) => {
1088
+ const clamped = timeoutMs === void 0 || !Number.isFinite(timeoutMs) ? MAX_TOOL_TIMEOUT_MS : clampToolTimeoutMs(timeoutMs);
1089
+ return clamped + CALL_TRANSPORT_TIMEOUT_SLACK_MS;
1090
+ };
1091
+ var makeAppClient = (stream, sessionId) => {
1092
+ const client = {
1093
+ sessionId,
1094
+ tools: async () => {
1095
+ try {
1096
+ const result = await stream.call(RPC_METHODS2.toolsList, {
1097
+ selector: sessionId
1098
+ });
1099
+ return Array.isArray(result) ? result : result.tools;
1100
+ } catch (error) {
1101
+ throw toAppductError(error);
1102
+ }
1103
+ },
1104
+ call: async (name, args, options) => {
1105
+ try {
1106
+ const result = await stream.call(
1107
+ RPC_METHODS2.toolsCall,
1108
+ {
1109
+ selector: sessionId,
1110
+ name,
1111
+ args,
1112
+ timeoutMs: options?.timeoutMs,
1113
+ caller: "client"
1114
+ },
1115
+ transportTimeoutForToolCall(options?.timeoutMs)
1116
+ );
1117
+ return result.result;
1118
+ } catch (error) {
1119
+ throw toAppductError(error);
1120
+ }
1121
+ },
1122
+ events: async (options = {}) => {
1123
+ try {
1124
+ const result = await stream.call(RPC_METHODS2.eventsSince, {
1125
+ selector: sessionId,
1126
+ since: options.since,
1127
+ limit: options.limit,
1128
+ kinds: ["app_event"]
1129
+ });
1130
+ return {
1131
+ events: result.events.map((event) => appEventFrom(event, sessionId)).filter((event) => event !== void 0),
1132
+ cursor: result.cursor
1133
+ };
1134
+ } catch (error) {
1135
+ throw toAppductError(error);
1136
+ }
1137
+ },
1138
+ waitForEvent: (name, options = {}) => {
1139
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_FOR_EVENT_TIMEOUT_MS;
1140
+ const toMatch = (event) => {
1141
+ const appEvent = appEventFrom(event, sessionId);
1142
+ if (!appEvent || appEvent.name !== name) {
1143
+ return void 0;
1144
+ }
1145
+ if (options.match && !options.match(appEvent.payload)) {
1146
+ return void 0;
1147
+ }
1148
+ return appEvent;
1149
+ };
1150
+ return new Promise((resolve2, reject) => {
1151
+ let settled = false;
1152
+ const settle = (fn) => {
1153
+ if (settled) {
1154
+ return;
1155
+ }
1156
+ settled = true;
1157
+ clearTimeout(timer);
1158
+ unsubscribeClose();
1159
+ liveHandler = null;
1160
+ fn();
1161
+ };
1162
+ const timer = setTimeout(() => {
1163
+ settle(
1164
+ () => reject(new AppductError("timeout", `Timed out after ${timeoutMs}ms waiting for event "${name}".`))
1165
+ );
1166
+ }, timeoutMs);
1167
+ let liveHandler = null;
1168
+ const earlyEvents = [];
1169
+ const unsubscribeNotification = stream.onNotification((payload) => {
1170
+ const event = payload;
1171
+ if (liveHandler) {
1172
+ liveHandler(event);
1173
+ } else {
1174
+ earlyEvents.push(event);
1175
+ }
1176
+ });
1177
+ let unsubscribeClose = () => {
1178
+ };
1179
+ const fail = (error) => {
1180
+ settle(() => reject(toAppductError(error)));
1181
+ unsubscribeNotification();
1182
+ };
1183
+ (async () => {
1184
+ await stream.call(RPC_METHODS2.eventsSubscribe, { sessionSelector: sessionId, kinds: ["app_event"] });
1185
+ const sinceResult = await stream.call(RPC_METHODS2.eventsSince, {
1186
+ selector: sessionId,
1187
+ since: options.since,
1188
+ kinds: ["app_event"]
1189
+ });
1190
+ const seenSeqs = /* @__PURE__ */ new Set();
1191
+ const backlog = [];
1192
+ for (const event of [...sinceResult.events, ...earlyEvents]) {
1193
+ if (event.sessionId === sessionId && !seenSeqs.has(event.seq)) {
1194
+ seenSeqs.add(event.seq);
1195
+ backlog.push(event);
1196
+ }
1197
+ }
1198
+ backlog.sort((a, b) => a.seq - b.seq);
1199
+ for (const event of backlog) {
1200
+ try {
1201
+ const matched = toMatch(event);
1202
+ if (matched) {
1203
+ settle(() => resolve2(matched));
1204
+ unsubscribeNotification();
1205
+ return;
1206
+ }
1207
+ } catch (error) {
1208
+ fail(error);
1209
+ return;
1210
+ }
1211
+ }
1212
+ const highestConsideredSeq = backlog.length > 0 ? backlog[backlog.length - 1].seq : sinceResult.cursor;
1213
+ unsubscribeClose = stream.onClose(() => {
1214
+ settle(
1215
+ () => reject(
1216
+ new AppductError(
1217
+ "connection_error",
1218
+ `The connection to the Appduct daemon closed while waiting for event "${name}".`
1219
+ )
1220
+ )
1221
+ );
1222
+ unsubscribeNotification();
1223
+ });
1224
+ liveHandler = (event) => {
1225
+ if (settled) {
1226
+ return;
1227
+ }
1228
+ if (event.sessionId === sessionId && event.seq <= highestConsideredSeq) {
1229
+ return;
1230
+ }
1231
+ try {
1232
+ const matched = toMatch(event);
1233
+ if (matched) {
1234
+ settle(() => resolve2(matched));
1235
+ unsubscribeNotification();
1236
+ }
1237
+ } catch (error) {
1238
+ fail(error);
1239
+ }
1240
+ };
1241
+ })().catch((error) => {
1242
+ fail(error);
1243
+ });
1244
+ });
1245
+ },
1246
+ close: () => {
1247
+ stream.close();
1248
+ }
1249
+ };
1250
+ return client;
1251
+ };
1252
+ var appEventFrom = (event, sessionId) => {
1253
+ if (event.kind !== "app_event" || event.sessionId !== sessionId) {
1254
+ return void 0;
1255
+ }
1256
+ const data = event.data;
1257
+ if (typeof data.name !== "string") {
1258
+ return void 0;
1259
+ }
1260
+ return {
1261
+ name: data.name,
1262
+ payload: data.payload,
1263
+ ts: event.ts,
1264
+ sessionId: event.sessionId,
1265
+ alias: event.alias,
1266
+ seq: event.seq
1267
+ };
1268
+ };
1269
+
1270
+ // src/client/connect.ts
1271
+ var connect3 = async (options = {}) => {
1272
+ const stateDir = resolveStateDir(options.stateDir);
1273
+ let stream;
1274
+ try {
1275
+ stream = await openDaemonStream({
1276
+ stateDir,
1277
+ spawn: options.spawn,
1278
+ autoSpawn: options.autoSpawn,
1279
+ requestTimeoutMs: options.requestTimeoutMs
1280
+ });
1281
+ } catch (error) {
1282
+ throw toAppductError(error);
1283
+ }
1284
+ try {
1285
+ const detail = await stream.call(RPC_METHODS3.sessionsDescribe, {
1286
+ selector: options.selector
1287
+ });
1288
+ return makeAppClient(stream, detail.sessionId);
1289
+ } catch (error) {
1290
+ stream.close();
1291
+ throw toAppductError(error);
1292
+ }
1293
+ };
1294
+
1295
+ // src/client/bootstrap.ts
1296
+ import { RPC_METHODS as RPC_METHODS5 } from "@appduct/shared";
1297
+
1298
+ // src/cli/open-target.ts
1299
+ import { execFile } from "node:child_process";
1300
+ import { mkdtemp, readFile as readFile4, rm as rm3 } from "node:fs/promises";
1301
+ import { tmpdir } from "node:os";
1302
+ import path from "node:path";
1303
+ var isOpenTarget = (value) => {
1304
+ return value === "android" || value === "ios-sim" || value === "ios-device";
1305
+ };
1306
+ var OPEN_TARGETS = ["android", "ios-sim", "ios-device"];
1307
+ var usesLoopbackAddress = (target) => {
1308
+ return target === "android" || target === "ios-sim";
1309
+ };
1310
+ var platformOf = (target) => {
1311
+ if (target === "android") {
1312
+ return "android";
1313
+ }
1314
+ if (target === "ios-device") {
1315
+ return "ios";
1316
+ }
1317
+ return void 0;
1318
+ };
1319
+ var isLoopbackAddress = (address) => {
1320
+ const normalized = address.trim().toLowerCase().replace(/^\[|\]$/gu, "");
1321
+ return normalized === "localhost" || normalized === "::1" || normalized === "0.0.0.0" || normalized === "::" || /^127\./u.test(normalized);
1322
+ };
1323
+ var loopbackAddressMessage = (address) => `The daemon advertised "${address}", which a physical device cannot reach \u2014 it would point the phone at itself and the session would never be claimed. No routable LAN address was detected; set "advertisedIp" in config.json to this machine's address on the network the device is on, and retry.`;
1324
+ var defaultExec = (command, args) => {
1325
+ return new Promise((resolve2, reject) => {
1326
+ execFile(command, args, { encoding: "utf8" }, (error, stdout, stderr) => {
1327
+ if (error) {
1328
+ reject(Object.assign(error, { stdout, stderr }));
1329
+ return;
1330
+ }
1331
+ resolve2({ stdout, stderr });
1332
+ });
1333
+ });
1334
+ };
1335
+ var TOOL_INSTALL_HINT = {
1336
+ adb: "the Android platform-tools (adb)",
1337
+ xcrun: "the Xcode command line tools (xcrun)"
1338
+ };
1339
+ var run = async (exec, command, args) => {
1340
+ try {
1341
+ return await exec(command, args);
1342
+ } catch (error) {
1343
+ const err = error;
1344
+ if (err.code === "ENOENT") {
1345
+ throw connectionError(
1346
+ `"${command}" was not found on PATH. Install ${TOOL_INSTALL_HINT[command] ?? command} and retry.`
1347
+ );
1348
+ }
1349
+ const stderr = (err.stderr ?? "").trim();
1350
+ throw connectionError(
1351
+ `"${command} ${args.join(" ")}" failed${stderr ? `: ${stderr}` : `: ${err.message}`}`
1352
+ );
1353
+ }
1354
+ };
1355
+ var parseBootedSimulators = (stdout) => {
1356
+ let parsed;
1357
+ try {
1358
+ parsed = JSON.parse(stdout);
1359
+ } catch {
1360
+ return [];
1361
+ }
1362
+ return Object.values(parsed.devices ?? {}).flat().filter(
1363
+ (entry) => entry.state === "Booted" && typeof entry.udid === "string" && entry.udid.length > 0
1364
+ ).map((entry) => ({ udid: entry.udid, name: entry.name ?? entry.udid }));
1365
+ };
1366
+ var listBootedSimulators = async (exec) => {
1367
+ const preflight = await run(exec, "xcrun", ["simctl", "list", "devices", "booted", "--json"]);
1368
+ return parseBootedSimulators(preflight.stdout);
1369
+ };
1370
+ var describeSimulator = (simulator) => `${simulator.name} (${simulator.udid})`;
1371
+ var deliverIosSim = async (exec, deepLink, device) => {
1372
+ if (device) {
1373
+ await run(exec, "xcrun", ["simctl", "openurl", device, deepLink]);
1374
+ return;
1375
+ }
1376
+ const booted = await listBootedSimulators(exec);
1377
+ if (booted.length === 0) {
1378
+ throw usageError("No booted iOS simulator found. Boot a simulator (e.g. via Xcode) and retry.");
1379
+ }
1380
+ if (booted.length > 1) {
1381
+ throw usageError(
1382
+ `Multiple iOS simulators are booted (${booted.map(describeSimulator).join(", ")}); specify --device <udid>.`
1383
+ );
1384
+ }
1385
+ await run(exec, "xcrun", ["simctl", "openurl", booted[0].udid, deepLink]);
1386
+ };
1387
+ var runDevicectlJson = async (exec, buildArgs) => {
1388
+ let dir;
1389
+ try {
1390
+ dir = await mkdtemp(path.join(tmpdir(), "appduct-devicectl-"));
1391
+ } catch (error) {
1392
+ throw connectionError(
1393
+ `A temporary directory for devicectl's --json-output could not be created in "${tmpdir()}" (${error.message}). Set TMPDIR to a writable directory and retry.`
1394
+ );
1395
+ }
1396
+ const file = path.join(dir, "devicectl.json");
1397
+ try {
1398
+ await run(exec, "xcrun", buildArgs(file));
1399
+ try {
1400
+ return JSON.parse(await readFile4(file, "utf8"));
1401
+ } catch {
1402
+ return void 0;
1403
+ }
1404
+ } finally {
1405
+ await rm3(dir, { force: true, recursive: true }).catch(() => {
1406
+ });
1407
+ }
1408
+ };
1409
+ var DELIVERABLE_PLATFORM = "ios";
1410
+ var UNREACHABLE_TUNNEL_STATE = "unavailable";
1411
+ var USABLE_PAIRING_STATE = "paired";
1412
+ var parsePairedIosDevices = (payload) => {
1413
+ const devices = payload?.result?.devices;
1414
+ if (!Array.isArray(devices)) {
1415
+ return [];
1416
+ }
1417
+ return devices.map((entry) => {
1418
+ const platform = entry?.hardwareProperties?.platform;
1419
+ if (typeof platform === "string" && platform.toLowerCase() !== DELIVERABLE_PLATFORM) {
1420
+ return void 0;
1421
+ }
1422
+ const tunnelState = entry?.connectionProperties?.tunnelState;
1423
+ if (typeof tunnelState === "string" && tunnelState.toLowerCase() === UNREACHABLE_TUNNEL_STATE) {
1424
+ return void 0;
1425
+ }
1426
+ const pairingState = entry?.connectionProperties?.pairingState;
1427
+ if (typeof pairingState === "string" && pairingState.toLowerCase() !== USABLE_PAIRING_STATE) {
1428
+ return void 0;
1429
+ }
1430
+ const hardwareUdid = entry?.hardwareProperties?.udid;
1431
+ const udid = typeof hardwareUdid === "string" && hardwareUdid.length > 0 ? hardwareUdid : entry?.identifier;
1432
+ return typeof udid === "string" && udid.length > 0 ? { udid, name: entry?.deviceProperties?.name ?? udid } : void 0;
1433
+ }).filter((device) => device !== void 0);
1434
+ };
1435
+ var listPairedIosDevices = async (exec) => {
1436
+ const payload = await runDevicectlJson(exec, (file) => [
1437
+ "devicectl",
1438
+ "list",
1439
+ "devices",
1440
+ "--timeout",
1441
+ "5",
1442
+ "--json-output",
1443
+ file
1444
+ ]);
1445
+ return parsePairedIosDevices(payload);
1446
+ };
1447
+ var describeIosDevice = (device) => `${device.name} (${device.udid})`;
1448
+ var MISSING_APP_ID_MESSAGE = 'Delivering to a device needs the installed app\'s id (the Android package name, or the iOS bundle id): pass "--app-id <id>" (or "appId" over MCP), or record it as "appId.<platform>" in .appduct/config.json \u2014 run `appduct init --android-app-id <id> --ios-app-id <id>` to write it.';
1449
+ var APP_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
1450
+ var isValidAppId = (value) => APP_ID_PATTERN.test(value);
1451
+ var invalidAppIdMessage = (value) => `"${value}" is not a valid app id (letters, digits, ".", "_" and "-", starting with a letter or digit).`;
1452
+ var resolveSingleIosDevice = async (exec) => {
1453
+ const paired = await listPairedIosDevices(exec);
1454
+ if (paired.length === 0) {
1455
+ throw usageError(
1456
+ 'No connected iOS device was found ("xcrun devicectl list devices" listed none, ignoring disconnected devices and non-iOS ones such as a paired Watch or Vision Pro). Connect the device, trust this Mac, and enable Developer Mode on it, then retry.'
1457
+ );
1458
+ }
1459
+ if (paired.length > 1) {
1460
+ throw usageError(
1461
+ `Multiple connected iOS devices were found (${paired.map(describeIosDevice).join(", ")}); specify --device <udid>.`
1462
+ );
1463
+ }
1464
+ return paired[0].udid;
1465
+ };
1466
+ var deliverIosDevice = async (exec, deepLink, device, appId, relaunch) => {
1467
+ if (!appId) {
1468
+ throw usageError(MISSING_APP_ID_MESSAGE);
1469
+ }
1470
+ if (!isValidAppId(appId)) {
1471
+ throw usageError(invalidAppIdMessage(appId));
1472
+ }
1473
+ const udid = device ?? await resolveSingleIosDevice(exec);
1474
+ await run(exec, "xcrun", [
1475
+ "devicectl",
1476
+ "device",
1477
+ "process",
1478
+ "launch",
1479
+ "--device",
1480
+ udid,
1481
+ // Opt-in only (`--relaunch` / `relaunch: true`). A plain `process launch` is what the vendored
1482
+ // Expo CLI does, so it is the better-attested default; what it does when the app is *already
1483
+ // running* is the one thing nobody here can verify without hardware. If that turns out to fail
1484
+ // rather than deliver the URL, `--terminate-existing` is the escape hatch — it kills the
1485
+ // running instance first, which Appduct copes with because a delivered link supersedes a
1486
+ // held session.
1487
+ ...relaunch ? ["--terminate-existing"] : [],
1488
+ "--payload-url",
1489
+ deepLink,
1490
+ appId
1491
+ ]);
1492
+ };
1493
+ var parseAdbDevices = (stdout) => {
1494
+ return stdout.split("\n").slice(1).map((line) => line.trim()).filter((line) => line.length > 0).map((line) => {
1495
+ const [serial = "", state = ""] = line.split(/\s+/u);
1496
+ return { serial, state };
1497
+ }).filter((entry) => entry.serial.length > 0);
1498
+ };
1499
+ var listAttachedAndroidDevices = async (exec) => {
1500
+ const listing = await run(exec, "adb", ["devices"]);
1501
+ return parseAdbDevices(listing.stdout).filter((entry) => entry.state === "device").map((entry) => entry.serial);
1502
+ };
1503
+ var resolveAndroidSerial = async (exec, device, env) => {
1504
+ if (device) {
1505
+ return device;
1506
+ }
1507
+ if (env.ANDROID_SERIAL) {
1508
+ return void 0;
1509
+ }
1510
+ const attached = await listAttachedAndroidDevices(exec);
1511
+ if (attached.length === 0) {
1512
+ throw usageError('No Android device or emulator is attached ("adb devices" listed none).');
1513
+ }
1514
+ if (attached.length > 1) {
1515
+ throw usageError(
1516
+ `Multiple Android devices are attached (${attached.join(
1517
+ ", "
1518
+ )}); specify --device <serial> or set ANDROID_SERIAL.`
1519
+ );
1520
+ }
1521
+ return void 0;
1522
+ };
1523
+ var withSerial = (args, serial) => {
1524
+ return serial ? ["-s", serial, ...args] : args;
1525
+ };
1526
+ var AM_START_ERROR_PATTERN = /^(?:Error:|Exception occurred while executing|Security exception:)/mu;
1527
+ var deliverAndroid = async (exec, deepLink, wssPort, device, env, appId) => {
1528
+ if (!isValidAppId(appId)) {
1529
+ throw usageError(invalidAppIdMessage(appId));
1530
+ }
1531
+ const serial = await resolveAndroidSerial(exec, device, env);
1532
+ await run(exec, "adb", withSerial(["reverse", `tcp:${wssPort}`, `tcp:${wssPort}`], serial));
1533
+ const { stdout, stderr } = await run(
1534
+ exec,
1535
+ "adb",
1536
+ withSerial(
1537
+ [
1538
+ "shell",
1539
+ "am",
1540
+ "start",
1541
+ "-a",
1542
+ "android.intent.action.VIEW",
1543
+ "-d",
1544
+ // `adb shell` reconstructs everything after "shell" into one string and re-parses it on the
1545
+ // device's own shell; single-quoting the URL here (not just for local execFile, which never
1546
+ // re-tokenizes argv) protects against that second, remote parse (ARCHITECTURE.md §8).
1547
+ `'${deepLink}'`,
1548
+ // Names the app explicitly: without this, more than one installed app declaring the same
1549
+ // scheme makes Android show an "Open with" chooser, which `am start` still reports as a
1550
+ // successful launch — see `AM_START_ERROR_PATTERN` below for the other half of the fix.
1551
+ "-p",
1552
+ appId
1553
+ ],
1554
+ serial
1555
+ )
1556
+ );
1557
+ if (AM_START_ERROR_PATTERN.test(stdout) || AM_START_ERROR_PATTERN.test(stderr)) {
1558
+ throw usageError(
1559
+ `"adb shell am start" could not launch "${appId}": ${(stdout + stderr).trim() || "(no output)"} \u2014 the app is likely not installed on this device, or its installed build does not declare a matching deep-link scheme.`
1560
+ );
1561
+ }
1562
+ };
1563
+ var deliverToOpenTarget = async (options) => {
1564
+ const exec = options.exec ?? defaultExec;
1565
+ if (options.target === "ios-sim") {
1566
+ await deliverIosSim(exec, options.deepLink, options.device);
1567
+ return;
1568
+ }
1569
+ if (options.target === "ios-device") {
1570
+ await deliverIosDevice(
1571
+ exec,
1572
+ options.deepLink,
1573
+ options.device,
1574
+ options.appId,
1575
+ options.relaunch ?? false
1576
+ );
1577
+ return;
1578
+ }
1579
+ if (!options.appId) {
1580
+ throw usageError(MISSING_APP_ID_MESSAGE);
1581
+ }
1582
+ await deliverAndroid(
1583
+ exec,
1584
+ options.deepLink,
1585
+ options.wssPort,
1586
+ options.device,
1587
+ options.env ?? process.env,
1588
+ options.appId
1589
+ );
1590
+ };
1591
+
1592
+ // src/link.ts
1593
+ import { RPC_METHODS as RPC_METHODS4 } from "@appduct/shared";
1594
+
1595
+ // src/scheme.ts
1596
+ import { existsSync as existsSync2 } from "node:fs";
1597
+ import { readFile as readFile6 } from "node:fs/promises";
1598
+ import { homedir as homedir2 } from "node:os";
1599
+ import { dirname as dirname2, join as join5, resolve } from "node:path";
1600
+
1601
+ // src/native-scheme.ts
1602
+ import { readdir, readFile as readFile5, stat as stat3 } from "node:fs/promises";
1603
+ import { join as join4 } from "node:path";
1604
+ var MAX_PROBE_FILE_BYTES = 2 * 1024 * 1024;
1605
+ var MAX_PLIST_WALK_DEPTH = 2;
1606
+ var MAX_WALK_ENTRIES = 5e3;
1607
+ var EXCLUDED_WALK_DIRS = /* @__PURE__ */ new Set(["Pods", "build", "node_modules", "DerivedData"]);
1608
+ var readCappedText = async (path2) => {
1609
+ const buffer = await readCappedBuffer(path2);
1610
+ if (!buffer.ok) {
1611
+ return buffer;
1612
+ }
1613
+ return { ok: true, text: buffer.data.toString("utf8") };
1614
+ };
1615
+ var readCappedBuffer = async (path2) => {
1616
+ let size;
1617
+ try {
1618
+ const stats = await stat3(path2);
1619
+ if (!stats.isFile()) {
1620
+ return { ok: false, reason: "not-found" };
1621
+ }
1622
+ size = stats.size;
1623
+ } catch {
1624
+ return { ok: false, reason: "not-found" };
1625
+ }
1626
+ if (size > MAX_PROBE_FILE_BYTES) {
1627
+ return { ok: false, reason: "too-large" };
1628
+ }
1629
+ try {
1630
+ return { ok: true, data: await readFile5(path2) };
1631
+ } catch {
1632
+ return { ok: false, reason: "unreadable" };
1633
+ }
1634
+ };
1635
+ var requireValidNativeScheme = (value, origin) => {
1636
+ if (!isValidScheme(value)) {
1637
+ throw usageError(
1638
+ `Invalid deep-link scheme ${JSON.stringify(value)} from ${origin}: a scheme must start with a letter and contain only letters, digits, "+", "-" or "." (for example "myapp") \u2014 do not include "://".`
1639
+ );
1640
+ }
1641
+ return value;
1642
+ };
1643
+ var GRADLE_PLACEHOLDER_PATTERN = /manifestPlaceholders\s*(?:\[\s*["']appductScheme["']\s*\]|\.\s*appductScheme)\s*=\s*["']([^"']*)["']/u;
1644
+ var extractGradleScheme = (text) => {
1645
+ const match = GRADLE_PLACEHOLDER_PATTERN.exec(text);
1646
+ const value = match?.[1];
1647
+ return value !== void 0 && value.length > 0 ? value : void 0;
1648
+ };
1649
+ var probeAndroidGradle = async (root) => {
1650
+ const candidates = [join4(root, "app", "build.gradle.kts"), join4(root, "app", "build.gradle")];
1651
+ const notes = [];
1652
+ for (const candidate of candidates) {
1653
+ const read = await readCappedText(candidate);
1654
+ if (!read.ok) {
1655
+ if (read.reason === "too-large") {
1656
+ notes.push(`${candidate} (too large, skipped)`);
1657
+ }
1658
+ continue;
1659
+ }
1660
+ const scheme = extractGradleScheme(read.text);
1661
+ if (scheme !== void 0) {
1662
+ requireValidNativeScheme(scheme, `"appductScheme" in ${candidate}`);
1663
+ return {
1664
+ result: {
1665
+ source: "android-gradle",
1666
+ scheme,
1667
+ origin: `"appductScheme" in ${candidate}`
1668
+ },
1669
+ tried: [describeAndroidGradleTried(candidates, notes)]
1670
+ };
1671
+ }
1672
+ }
1673
+ return { tried: [describeAndroidGradleTried(candidates, notes)] };
1674
+ };
1675
+ var describeAndroidGradleTried = (candidates, notes) => {
1676
+ const base = `${candidates.join(", ")} ("appductScheme" manifest placeholder)`;
1677
+ return notes.length === 0 ? base : `${base} \u2014 ${notes.join(", ")}`;
1678
+ };
1679
+ var INTENT_FILTER_PATTERN = /<intent-filter\b[^>]*>([\s\S]*?)<\/intent-filter>/giu;
1680
+ var VIEW_ACTION_PATTERN = /<action\b[^>]*\bandroid:name\s*=\s*["']android\.intent\.action\.VIEW["']/iu;
1681
+ var DATA_SCHEME_PATTERN = /<data\b[^>]*\bandroid:scheme\s*=\s*["']([^"']*)["']/iu;
1682
+ var extractManifestScheme = (xml) => {
1683
+ for (const match of xml.matchAll(INTENT_FILTER_PATTERN)) {
1684
+ const block = match[1] ?? "";
1685
+ if (!VIEW_ACTION_PATTERN.test(block)) {
1686
+ continue;
1687
+ }
1688
+ const dataMatch = DATA_SCHEME_PATTERN.exec(block);
1689
+ const scheme = dataMatch?.[1];
1690
+ if (scheme !== void 0 && scheme.length > 0) {
1691
+ return scheme;
1692
+ }
1693
+ }
1694
+ return void 0;
1695
+ };
1696
+ var probeAndroidManifest = async (root) => {
1697
+ const manifestPath = join4(root, "app", "src", "main", "AndroidManifest.xml");
1698
+ const read = await readCappedText(manifestPath);
1699
+ const description = `${manifestPath} (first <data android:scheme> in a VIEW intent-filter)`;
1700
+ if (!read.ok) {
1701
+ return {
1702
+ tried: [read.reason === "too-large" ? `${description} \u2014 too large, skipped` : description]
1703
+ };
1704
+ }
1705
+ const scheme = extractManifestScheme(read.text);
1706
+ if (scheme === void 0) {
1707
+ return { tried: [description] };
1708
+ }
1709
+ requireValidNativeScheme(scheme, `"android:scheme" in ${manifestPath}`);
1710
+ return {
1711
+ result: {
1712
+ source: "android-manifest",
1713
+ scheme,
1714
+ origin: `"android:scheme" in ${manifestPath}`
1715
+ },
1716
+ tried: [description]
1717
+ };
1718
+ };
1719
+ var XML_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
1720
+ var decodeXmlEntities = (text) => {
1721
+ return text.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/gu, (whole, entity) => {
1722
+ if (entity.startsWith("#x")) {
1723
+ const code = Number.parseInt(entity.slice(2), 16);
1724
+ return Number.isNaN(code) ? whole : String.fromCodePoint(code);
1725
+ }
1726
+ if (entity.startsWith("#")) {
1727
+ const code = Number.parseInt(entity.slice(1), 10);
1728
+ return Number.isNaN(code) ? whole : String.fromCodePoint(code);
1729
+ }
1730
+ return XML_ENTITIES[entity] ?? whole;
1731
+ });
1732
+ };
1733
+ var tokenizePlistXml = (xml) => {
1734
+ const cleaned = xml.replace(/<\?[\s\S]*?\?>/gu, "").replace(/<!DOCTYPE[\s\S]*?>/giu, "").replace(/<!--[\s\S]*?-->/gu, "");
1735
+ const root = { tag: "#root", children: [], text: "" };
1736
+ const stack = [root];
1737
+ const tagOrTextPattern = /<(\/?)([a-zA-Z][\w.:-]*)\b[^>]*?(\/?)>|([^<]+)/gu;
1738
+ for (const match of cleaned.matchAll(tagOrTextPattern)) {
1739
+ const [, closing, name, selfClosing, text] = match;
1740
+ const top = stack[stack.length - 1];
1741
+ if (text !== void 0) {
1742
+ top.text += decodeXmlEntities(text);
1743
+ continue;
1744
+ }
1745
+ if (closing) {
1746
+ if (stack.length > 1) {
1747
+ stack.pop();
1748
+ }
1749
+ continue;
1750
+ }
1751
+ const node = { tag: name ?? "", children: [], text: "" };
1752
+ top.children.push(node);
1753
+ if (!selfClosing) {
1754
+ stack.push(node);
1755
+ }
1756
+ }
1757
+ return root;
1758
+ };
1759
+ var plistNodeToValue = (node) => {
1760
+ switch (node.tag) {
1761
+ case "true":
1762
+ return true;
1763
+ case "false":
1764
+ return false;
1765
+ case "integer": {
1766
+ const value = Number.parseInt(node.text.trim(), 10);
1767
+ return Number.isNaN(value) ? node.text.trim() : value;
1768
+ }
1769
+ case "real": {
1770
+ const value = Number.parseFloat(node.text.trim());
1771
+ return Number.isNaN(value) ? node.text.trim() : value;
1772
+ }
1773
+ case "array":
1774
+ return node.children.map(plistNodeToValue);
1775
+ case "dict": {
1776
+ const result = {};
1777
+ for (let index = 0; index < node.children.length; index += 2) {
1778
+ const keyNode = node.children[index];
1779
+ const valueNode = node.children[index + 1];
1780
+ if (keyNode?.tag === "key" && valueNode !== void 0) {
1781
+ result[keyNode.text.trim()] = plistNodeToValue(valueNode);
1782
+ }
1783
+ }
1784
+ return result;
1785
+ }
1786
+ default:
1787
+ return node.text.trim();
1788
+ }
1789
+ };
1790
+ var parsePlistXml = (xml) => {
1791
+ const root = tokenizePlistXml(xml);
1792
+ const plistNode = root.children.find((child) => child.tag === "plist");
1793
+ const valueNode = plistNode?.children[0];
1794
+ return valueNode ? plistNodeToValue(valueNode) : void 0;
1795
+ };
1796
+ var isBinaryPlist = (buffer) => {
1797
+ return buffer.length >= 6 && buffer.subarray(0, 6).toString("latin1") === "bplist";
1798
+ };
1799
+ var firstPlistUrlScheme = (cfBundleURLTypes) => {
1800
+ if (!Array.isArray(cfBundleURLTypes)) {
1801
+ return void 0;
1802
+ }
1803
+ for (const entry of cfBundleURLTypes) {
1804
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
1805
+ continue;
1806
+ }
1807
+ const scheme = firstConfiguredScheme(entry.CFBundleURLSchemes);
1808
+ if (scheme !== void 0) {
1809
+ return scheme;
1810
+ }
1811
+ }
1812
+ return void 0;
1813
+ };
1814
+ var walkForFilename = async (root, filename, maxDepth) => {
1815
+ const hits = [];
1816
+ let visited = 0;
1817
+ const walk = async (dir, depth) => {
1818
+ let entries;
1819
+ try {
1820
+ entries = await readdir(dir, { withFileTypes: true });
1821
+ } catch {
1822
+ return;
1823
+ }
1824
+ for (const entry of entries) {
1825
+ if (visited >= MAX_WALK_ENTRIES) {
1826
+ return;
1827
+ }
1828
+ visited += 1;
1829
+ if (entry.isSymbolicLink()) {
1830
+ continue;
1831
+ }
1832
+ if (entry.isDirectory()) {
1833
+ if (EXCLUDED_WALK_DIRS.has(entry.name)) {
1834
+ continue;
1835
+ }
1836
+ if (depth < maxDepth) {
1837
+ await walk(join4(dir, entry.name), depth + 1);
1838
+ }
1839
+ continue;
1840
+ }
1841
+ if (entry.isFile() && entry.name === filename) {
1842
+ hits.push({ path: join4(dir, entry.name), depth });
1843
+ }
1844
+ }
1845
+ };
1846
+ await walk(root, 0);
1847
+ hits.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path));
1848
+ return hits;
1849
+ };
1850
+ var probeIosInfoPlist = async (root) => {
1851
+ const files = await walkForFilename(root, "Info.plist", MAX_PLIST_WALK_DEPTH);
1852
+ const describeSearch = (notes2) => {
1853
+ const base = files.length === 0 ? `Info.plist up to ${MAX_PLIST_WALK_DEPTH} levels below ${root} (excluding Pods/build/node_modules/DerivedData) \u2014 none found` : `${files.map((file) => file.path).join(", ")} (first "CFBundleURLSchemes" entry)`;
1854
+ return notes2.length === 0 ? base : `${base} \u2014 ${notes2.join(", ")}`;
1855
+ };
1856
+ const notes = [];
1857
+ for (const file of files) {
1858
+ const read = await readCappedBuffer(file.path);
1859
+ if (!read.ok) {
1860
+ notes.push(
1861
+ `${file.path} (${read.reason === "too-large" ? "too large, skipped" : "unreadable"})`
1862
+ );
1863
+ continue;
1864
+ }
1865
+ if (isBinaryPlist(read.data)) {
1866
+ notes.push(`${file.path} (binary plist \u2014 unreadable, use --scheme)`);
1867
+ continue;
1868
+ }
1869
+ const value = parsePlistXml(read.data.toString("utf8"));
1870
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1871
+ continue;
1872
+ }
1873
+ const scheme = firstPlistUrlScheme(value.CFBundleURLTypes);
1874
+ if (scheme === void 0) {
1875
+ continue;
1876
+ }
1877
+ requireValidNativeScheme(scheme, `"CFBundleURLSchemes" in ${file.path}`);
1878
+ return {
1879
+ result: {
1880
+ source: "ios-info-plist",
1881
+ scheme,
1882
+ origin: `"CFBundleURLSchemes" in ${file.path}`
1883
+ },
1884
+ tried: [describeSearch(notes)]
1885
+ };
1886
+ }
1887
+ return { tried: [describeSearch(notes)] };
1888
+ };
1889
+ var PROJECT_YML_SCHEME_PATTERN = /CFBundleURLSchemes\s*:\s*(?:\[\s*["']?([^"'\],\r\n]*)["']?|\r?\n[ \t]*-[ \t]*["']?([^"'\r\n]*)["']?)/u;
1890
+ var extractProjectYmlScheme = (text) => {
1891
+ const match = PROJECT_YML_SCHEME_PATTERN.exec(text);
1892
+ const raw = (match?.[1] ?? match?.[2])?.trim();
1893
+ return raw !== void 0 && raw.length > 0 ? raw : void 0;
1894
+ };
1895
+ var probeIosProjectYml = async (root) => {
1896
+ const path2 = join4(root, "project.yml");
1897
+ const description = `${path2} ("info.properties.CFBundleURLTypes" > "CFBundleURLSchemes")`;
1898
+ const read = await readCappedText(path2);
1899
+ if (!read.ok) {
1900
+ return {
1901
+ tried: [read.reason === "too-large" ? `${description} \u2014 too large, skipped` : description]
1902
+ };
1903
+ }
1904
+ const scheme = extractProjectYmlScheme(read.text);
1905
+ if (scheme === void 0) {
1906
+ return { tried: [description] };
1907
+ }
1908
+ requireValidNativeScheme(scheme, `"CFBundleURLSchemes" in ${path2}`);
1909
+ return {
1910
+ result: { source: "ios-project-yml", scheme, origin: `"CFBundleURLSchemes" in ${path2}` },
1911
+ tried: [description]
1912
+ };
1913
+ };
1914
+ var PROBES = [
1915
+ probeAndroidGradle,
1916
+ probeAndroidManifest,
1917
+ probeIosInfoPlist,
1918
+ probeIosProjectYml
1919
+ ];
1920
+ var discoverNativeScheme = async (root) => {
1921
+ const tried = [];
1922
+ const results = [];
1923
+ for (const probe of PROBES) {
1924
+ const outcome = await probe(root);
1925
+ tried.push(...outcome.tried);
1926
+ if (outcome.result !== void 0) {
1927
+ results.push(outcome.result);
1928
+ }
1929
+ }
1930
+ if (results.length === 0) {
1931
+ return { tried };
1932
+ }
1933
+ const distinctSchemes = new Set(results.map((entry) => entry.scheme));
1934
+ if (distinctSchemes.size > 1) {
1935
+ throw usageError(
1936
+ "Conflicting deep-link schemes found in this project: " + results.map((entry) => `${entry.origin} says ${JSON.stringify(entry.scheme)}`).join(", but ") + ". Appduct will not guess which one is right \u2014 pass --scheme <scheme> to pick one."
1937
+ );
1938
+ }
1939
+ return { result: results[0], tried };
1940
+ };
1941
+
1942
+ // src/scheme.ts
1943
+ var PROJECT_CONFIG_DIR = ".appduct";
1944
+ var PROJECT_CONFIG_FILENAME = "config.json";
1945
+ var PROJECT_CONFIG_RELATIVE_PATH = join5(PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME);
1946
+ var APP_JSON_FILENAME = "app.json";
1947
+ var SCHEME_ENV_VAR = "APPDUCT_SCHEME";
1948
+ var SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*$/u;
1949
+ var isValidScheme = (value) => SCHEME_PATTERN.test(value);
1950
+ var requireValidScheme = (value, origin) => {
1951
+ if (!isValidScheme(value)) {
1952
+ throw usageError(
1953
+ `Invalid deep-link scheme ${JSON.stringify(value)} from ${origin}: a scheme must start with a letter and contain only letters, digits, "+", "-" or "." (for example "myapp") \u2014 do not include "://".`
1954
+ );
1955
+ }
1956
+ return value;
1957
+ };
1958
+ var isPlainObject = (value) => {
1959
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1960
+ };
1961
+ var readJsonFile = async (path2) => {
1962
+ let text;
1963
+ try {
1964
+ text = await readFile6(path2, "utf8");
1965
+ } catch (error) {
1966
+ const code = error.code;
1967
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR") {
1968
+ return void 0;
1969
+ }
1970
+ throw error;
1971
+ }
1972
+ try {
1973
+ return { raw: JSON.parse(text) };
1974
+ } catch (error) {
1975
+ throw usageError(`Could not parse ${path2}: ${error.message}`);
1976
+ }
1977
+ };
1978
+ var firstConfiguredScheme = (value) => {
1979
+ if (Array.isArray(value)) {
1980
+ return value.find((entry) => typeof entry === "string" && entry.length > 0);
1981
+ }
1982
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1983
+ };
1984
+ var discoverExpoScheme = async (dir) => {
1985
+ const path2 = join5(resolve(dir), APP_JSON_FILENAME);
1986
+ const file = await readJsonFile(path2);
1987
+ if (!file || !isPlainObject(file.raw)) {
1988
+ return void 0;
1989
+ }
1990
+ const expo = file.raw.expo;
1991
+ if (!isPlainObject(expo)) {
1992
+ return void 0;
1993
+ }
1994
+ const scheme = firstConfiguredScheme(expo.scheme);
1995
+ return scheme === void 0 ? void 0 : requireValidScheme(scheme, `"expo.scheme" in ${path2}`);
1996
+ };
1997
+ var discoverStaticProjectScheme = async (cwd) => {
1998
+ const appJsonPath = join5(resolve(cwd), APP_JSON_FILENAME);
1999
+ const tried = [`${appJsonPath} ("expo.scheme")`];
2000
+ const appJsonScheme = await discoverExpoScheme(cwd);
2001
+ if (appJsonScheme !== void 0) {
2002
+ return { scheme: appJsonScheme, source: "app-json", origin: appJsonPath, tried };
2003
+ }
2004
+ const native = await discoverNativeScheme(resolve(cwd));
2005
+ tried.push(...native.tried);
2006
+ if (native.result !== void 0) {
2007
+ return {
2008
+ scheme: native.result.scheme,
2009
+ source: native.result.source,
2010
+ origin: native.result.origin,
2011
+ tried
2012
+ };
2013
+ }
2014
+ return { tried };
2015
+ };
2016
+ var globalConfigDirs = (options = {}) => {
2017
+ const excluded = /* @__PURE__ */ new Set([join5(resolve(options.homeDir ?? homedir2()), PROJECT_CONFIG_DIR)]);
2018
+ if (options.stateDirRoot !== void 0) {
2019
+ excluded.add(resolve(options.stateDirRoot));
2020
+ }
2021
+ return excluded;
2022
+ };
2023
+ var findProjectConfigs = (startDir, options = {}) => {
2024
+ const excluded = globalConfigDirs(options);
2025
+ const found = [];
2026
+ let dir = resolve(startDir);
2027
+ for (; ; ) {
2028
+ const projectDir = join5(dir, PROJECT_CONFIG_DIR);
2029
+ if (!excluded.has(projectDir)) {
2030
+ const candidate = join5(projectDir, PROJECT_CONFIG_FILENAME);
2031
+ if (existsSync2(candidate)) {
2032
+ found.push(candidate);
2033
+ }
2034
+ }
2035
+ const parent = dirname2(dir);
2036
+ if (parent === dir) {
2037
+ return found;
2038
+ }
2039
+ dir = parent;
2040
+ }
2041
+ };
2042
+ var readProjectConfigScheme = async (path2) => {
2043
+ const file = await readJsonFile(path2);
2044
+ if (!file) {
2045
+ return void 0;
2046
+ }
2047
+ if (!isPlainObject(file.raw)) {
2048
+ throw usageError(`Invalid Appduct project config at ${path2}: must be a JSON object.`);
2049
+ }
2050
+ const scheme = file.raw.scheme;
2051
+ if (scheme === void 0) {
2052
+ return void 0;
2053
+ }
2054
+ if (typeof scheme !== "string" || scheme.length === 0) {
2055
+ throw usageError(
2056
+ `Invalid Appduct project config at ${path2}: "scheme" must be a non-empty string.`
2057
+ );
2058
+ }
2059
+ return requireValidScheme(scheme, `"scheme" in ${path2}`);
2060
+ };
2061
+ var APP_ID_PLATFORMS = /* @__PURE__ */ new Set(["ios", "android"]);
2062
+ var readProjectConfigAppId = async (path2, platform) => {
2063
+ const file = await readJsonFile(path2);
2064
+ if (!file) {
2065
+ return void 0;
2066
+ }
2067
+ if (!isPlainObject(file.raw)) {
2068
+ throw usageError(`Invalid Appduct project config at ${path2}: must be a JSON object.`);
2069
+ }
2070
+ const appId = file.raw.appId;
2071
+ if (appId === void 0) {
2072
+ return void 0;
2073
+ }
2074
+ if (!isPlainObject(appId)) {
2075
+ throw usageError(
2076
+ `Invalid Appduct project config at ${path2}: "appId" must be an object with "ios" and/or "android" keys.`
2077
+ );
2078
+ }
2079
+ for (const key of Object.keys(appId)) {
2080
+ if (!APP_ID_PLATFORMS.has(key)) {
2081
+ throw usageError(
2082
+ `Invalid Appduct project config at ${path2}: unknown key "appId.${key}" (expected "ios" and/or "android").`
2083
+ );
2084
+ }
2085
+ }
2086
+ const value = appId[platform];
2087
+ if (value === void 0) {
2088
+ return void 0;
2089
+ }
2090
+ if (typeof value !== "string" || value.length === 0) {
2091
+ throw usageError(
2092
+ `Invalid Appduct project config at ${path2}: "appId.${platform}" must be a non-empty string.`
2093
+ );
2094
+ }
2095
+ return value;
2096
+ };
2097
+ var resolveScheme = async (options = {}) => {
2098
+ const env = options.env ?? process.env;
2099
+ const cwd = resolve(options.cwd ?? process.cwd());
2100
+ const tried = [];
2101
+ tried.push("the --scheme flag");
2102
+ if (options.flagScheme !== void 0 && options.flagScheme.length > 0) {
2103
+ return {
2104
+ scheme: requireValidScheme(options.flagScheme, "the --scheme flag"),
2105
+ source: "flag",
2106
+ tried
2107
+ };
2108
+ }
2109
+ tried.push(`the ${SCHEME_ENV_VAR} environment variable`);
2110
+ const envScheme = env[SCHEME_ENV_VAR];
2111
+ if (typeof envScheme === "string" && envScheme.length > 0) {
2112
+ return {
2113
+ scheme: requireValidScheme(envScheme, `the ${SCHEME_ENV_VAR} environment variable`),
2114
+ source: "env",
2115
+ tried
2116
+ };
2117
+ }
2118
+ const projectConfigPaths = findProjectConfigs(cwd, {
2119
+ stateDirRoot: options.stateDirRoot,
2120
+ homeDir: options.homeDir
2121
+ });
2122
+ tried.push(
2123
+ projectConfigPaths.length === 0 ? `${PROJECT_CONFIG_RELATIVE_PATH} (searched upwards from ${cwd})` : projectConfigPaths.join(", ")
2124
+ );
2125
+ for (const projectConfigPath of projectConfigPaths) {
2126
+ const projectScheme = await readProjectConfigScheme(projectConfigPath);
2127
+ if (projectScheme !== void 0) {
2128
+ return { scheme: projectScheme, source: "project-config", tried };
2129
+ }
2130
+ }
2131
+ tried.push(
2132
+ options.stateConfigPath === void 0 ? `the state directory's config.json ("scheme")` : `${options.stateConfigPath} ("scheme")`
2133
+ );
2134
+ if (options.configScheme !== void 0 && options.configScheme.length > 0) {
2135
+ return {
2136
+ scheme: requireValidScheme(
2137
+ options.configScheme,
2138
+ options.stateConfigPath === void 0 ? `the state directory's config.json "scheme"` : `"scheme" in ${options.stateConfigPath}`
2139
+ ),
2140
+ source: "state-config",
2141
+ tried
2142
+ };
2143
+ }
2144
+ const discovered = await discoverStaticProjectScheme(cwd);
2145
+ tried.push(...discovered.tried);
2146
+ if (discovered.scheme !== void 0) {
2147
+ return { scheme: discovered.scheme, source: discovered.source, tried };
2148
+ }
2149
+ return { tried };
2150
+ };
2151
+ var describeMissingScheme = (tried) => {
2152
+ const locations = tried.length === 0 ? "" : `Looked in, in order:
2153
+ ${tried.map((location, index) => ` ${index + 1}. ${location}`).join("\n")}
2154
+ `;
2155
+ return "A deep-link scheme is required to compose the link, and none was found. " + locations + `Set one with --scheme <scheme>, ${SCHEME_ENV_VAR}=<scheme>, "expo.scheme" in ${APP_JSON_FILENAME}, or run \`appduct init\` in your app root to write ${PROJECT_CONFIG_RELATIVE_PATH}.`;
2156
+ };
2157
+ var resolveSchemeOrThrow = async (options = {}) => {
2158
+ const resolved = await resolveScheme(options);
2159
+ if (resolved.scheme === void 0) {
2160
+ throw usageError(describeMissingScheme(resolved.tried));
2161
+ }
2162
+ return resolved.scheme;
2163
+ };
2164
+ var resolveAppId = async (options) => {
2165
+ const cwd = resolve(options.cwd ?? process.cwd());
2166
+ const tried = [];
2167
+ tried.push("the --app-id flag");
2168
+ if (options.flagAppId !== void 0 && options.flagAppId.length > 0) {
2169
+ return { appId: options.flagAppId, source: "flag", tried };
2170
+ }
2171
+ const projectConfigPaths = findProjectConfigs(cwd, {
2172
+ stateDirRoot: options.stateDirRoot,
2173
+ homeDir: options.homeDir
2174
+ });
2175
+ tried.push(
2176
+ projectConfigPaths.length === 0 ? `${PROJECT_CONFIG_RELATIVE_PATH} (searched upwards from ${cwd})` : projectConfigPaths.join(", ")
2177
+ );
2178
+ for (const projectConfigPath of projectConfigPaths) {
2179
+ const projectAppId = await readProjectConfigAppId(projectConfigPath, options.platform);
2180
+ if (projectAppId !== void 0) {
2181
+ return { appId: projectAppId, source: "project-config", tried };
2182
+ }
2183
+ }
2184
+ return { tried };
2185
+ };
2186
+ var describeMissingAppId = (platform, tried) => {
2187
+ const locations = tried.length === 0 ? "" : `Looked in, in order:
2188
+ ${tried.map((location, index) => ` ${index + 1}. ${location}`).join("\n")}
2189
+ `;
2190
+ const platformLabel = platform === "android" ? "Android" : "a physical iPhone/iPad";
2191
+ const initFlag = platform === "android" ? "--android-app-id" : "--ios-app-id";
2192
+ return `Delivering to ${platformLabel} needs the installed app's id, and none was found. ${locations}Set one with \`appduct init ${initFlag} <id>\`, --app-id <id>, or "appId.${platform}" in ${PROJECT_CONFIG_RELATIVE_PATH}.`;
2193
+ };
2194
+
2195
+ // src/link.ts
2196
+ var OPEN_TARGET_ADDRESS_OVERRIDE = "127.0.0.1";
2197
+ var composeDeepLink = (scheme, result) => `${scheme}:///?appduct=${result.deepLinkPayload}&pin=${encodeURIComponent(result.pin)}`;
2198
+ var mintLink = async (options) => {
2199
+ if (options.target !== void 0 && !isOpenTarget(options.target)) {
2200
+ throw usageError(
2201
+ `"target" must be one of ${OPEN_TARGETS.map((target) => `"${target}"`).join(", ")} (got "${options.target}").`
2202
+ );
2203
+ }
2204
+ if (options.device !== void 0 && options.target === void 0) {
2205
+ throw usageError('"device" only applies alongside a target.');
2206
+ }
2207
+ if (options.appId !== void 0 && options.target !== "android" && options.target !== "ios-device") {
2208
+ throw usageError('"appId" only applies alongside target "android" or target "ios-device".');
2209
+ }
2210
+ if (options.relaunch !== void 0 && options.target !== "ios-device") {
2211
+ throw usageError('"relaunch" only applies alongside target "ios-device".');
2212
+ }
2213
+ const paths = getStateDirPaths(options.stateDir);
2214
+ const config = await loadConfig(paths);
2215
+ const scheme = await resolveSchemeOrThrow({
2216
+ flagScheme: options.scheme,
2217
+ env: options.schemeEnv,
2218
+ cwd: options.cwd,
2219
+ configScheme: config.scheme,
2220
+ stateConfigPath: paths.configPath,
2221
+ stateDirRoot: paths.root
2222
+ });
2223
+ const platform = options.target === void 0 ? void 0 : platformOf(options.target);
2224
+ let appId;
2225
+ if (platform !== void 0) {
2226
+ const resolved = await resolveAppId({
2227
+ platform,
2228
+ flagAppId: options.appId,
2229
+ cwd: options.cwd,
2230
+ stateDirRoot: paths.root
2231
+ });
2232
+ if (resolved.appId === void 0) {
2233
+ throw usageError(describeMissingAppId(platform, resolved.tried));
2234
+ }
2235
+ appId = resolved.appId;
2236
+ }
2237
+ if (appId !== void 0 && !isValidAppId(appId)) {
2238
+ throw usageError(invalidAppIdMessage(appId));
2239
+ }
2240
+ const result = await callDaemon(
2241
+ RPC_METHODS4.linkCreate,
2242
+ {
2243
+ ttlSeconds: options.ttlSeconds,
2244
+ addressOverride: options.target && usesLoopbackAddress(options.target) ? OPEN_TARGET_ADDRESS_OVERRIDE : void 0
2245
+ },
2246
+ { stateDir: options.stateDir, spawn: options.spawn, autoSpawn: options.autoSpawn }
2247
+ );
2248
+ if (options.target === "ios-device" && isLoopbackAddress(result.endpoint.address)) {
2249
+ throw usageError(loopbackAddressMessage(result.endpoint.address));
2250
+ }
2251
+ const deepLink = composeDeepLink(scheme, result);
2252
+ if (options.target) {
2253
+ await deliverToOpenTarget({
2254
+ target: options.target,
2255
+ deepLink,
2256
+ wssPort: result.endpoint.port,
2257
+ device: options.device,
2258
+ appId,
2259
+ relaunch: options.relaunch,
2260
+ exec: options.exec,
2261
+ env: options.env
2262
+ });
2263
+ return {
2264
+ sessionId: result.sessionId,
2265
+ deepLink,
2266
+ endpoint: result.endpoint,
2267
+ expiresAt: result.expiresAt,
2268
+ pin: result.pin,
2269
+ delivered: true,
2270
+ target: options.target
2271
+ };
2272
+ }
2273
+ return {
2274
+ sessionId: result.sessionId,
2275
+ deepLink,
2276
+ endpoint: result.endpoint,
2277
+ expiresAt: result.expiresAt,
2278
+ pin: result.pin
2279
+ };
2280
+ };
2281
+
2282
+ // src/client/bootstrap.ts
2283
+ var DEFAULT_WAIT_FOR_SESSION_TIMEOUT_MS = 12e4;
2284
+ var link = async (options = {}) => {
2285
+ try {
2286
+ return await mintLink({
2287
+ stateDir: resolveStateDir(options.stateDir),
2288
+ spawn: options.spawn,
2289
+ autoSpawn: options.autoSpawn,
2290
+ ttlSeconds: options.ttlSeconds,
2291
+ target: options.target,
2292
+ device: options.device,
2293
+ appId: options.appId,
2294
+ relaunch: options.relaunch,
2295
+ scheme: options.scheme,
2296
+ cwd: options.cwd,
2297
+ exec: options.exec,
2298
+ env: options.env,
2299
+ schemeEnv: options.schemeEnv
2300
+ });
2301
+ } catch (error) {
2302
+ throw toAppductError(error);
2303
+ }
2304
+ };
2305
+ var waitForSession = async (sessionId, options = {}) => {
2306
+ const stateDir = resolveStateDir(options.stateDir);
2307
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_FOR_SESSION_TIMEOUT_MS;
2308
+ let stream;
2309
+ try {
2310
+ stream = await openDaemonStream({ stateDir, spawn: options.spawn, autoSpawn: options.autoSpawn });
2311
+ } catch (error) {
2312
+ throw toAppductError(error);
2313
+ }
2314
+ try {
2315
+ try {
2316
+ await stream.call(RPC_METHODS5.sessionsDescribe, { selector: sessionId });
2317
+ return makeAppClient(stream, sessionId);
2318
+ } catch (error) {
2319
+ if (!(error instanceof DaemonRpcError) || error.data?.type !== "unknown_session") {
2320
+ throw error;
2321
+ }
2322
+ }
2323
+ await new Promise((resolve2, reject) => {
2324
+ let settled = false;
2325
+ const settle = (fn) => {
2326
+ if (settled) {
2327
+ return;
2328
+ }
2329
+ settled = true;
2330
+ clearTimeout(timer);
2331
+ unsubscribeNotification();
2332
+ unsubscribeClose();
2333
+ fn();
2334
+ };
2335
+ const timer = setTimeout(() => {
2336
+ settle(
2337
+ () => reject(
2338
+ new AppductError(
2339
+ "timeout",
2340
+ `Timed out after ${timeoutMs}ms waiting for session "${sessionId}" to be claimed.`
2341
+ )
2342
+ )
2343
+ );
2344
+ }, timeoutMs);
2345
+ const unsubscribeClose = stream.onClose(() => {
2346
+ settle(
2347
+ () => reject(
2348
+ new AppductError(
2349
+ "connection_error",
2350
+ `The connection to the Appduct daemon closed while waiting for session "${sessionId}" to be claimed.`
2351
+ )
2352
+ )
2353
+ );
2354
+ });
2355
+ const unsubscribeNotification = stream.onNotification((payload) => {
2356
+ if (settled) {
2357
+ return;
2358
+ }
2359
+ const event = payload;
2360
+ if (event.kind === "session_claimed" && event.sessionId === sessionId) {
2361
+ settle(resolve2);
2362
+ }
2363
+ });
2364
+ stream.call(RPC_METHODS5.eventsSubscribe, { sessionSelector: sessionId, kinds: ["session_claimed"] }).catch((error) => {
2365
+ settle(() => reject(toAppductError(error)));
2366
+ });
2367
+ });
2368
+ return makeAppClient(stream, sessionId);
2369
+ } catch (error) {
2370
+ stream.close();
2371
+ throw toAppductError(error);
2372
+ }
2373
+ };
2374
+ export {
2375
+ AppductError,
2376
+ connect3 as connect,
2377
+ link,
2378
+ waitForSession
2379
+ };