appduct 0.9.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 (190) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/bin.js +2 -0
  4. package/dist/artifact-inspect.d.ts +110 -0
  5. package/dist/artifact-inspect.d.ts.map +1 -0
  6. package/dist/artifact-inspect.js +315 -0
  7. package/dist/bin.d.ts +3 -0
  8. package/dist/bin.d.ts.map +1 -0
  9. package/dist/bin.js +4 -0
  10. package/dist/cli/command-options.d.ts +44 -0
  11. package/dist/cli/command-options.d.ts.map +1 -0
  12. package/dist/cli/command-options.js +95 -0
  13. package/dist/cli/create-cli.d.ts +2 -0
  14. package/dist/cli/create-cli.d.ts.map +1 -0
  15. package/dist/cli/create-cli.js +56 -0
  16. package/dist/cli/dispatch.d.ts +3 -0
  17. package/dist/cli/dispatch.d.ts.map +1 -0
  18. package/dist/cli/dispatch.js +275 -0
  19. package/dist/cli/open-target.d.ts +112 -0
  20. package/dist/cli/open-target.d.ts.map +1 -0
  21. package/dist/cli/open-target.js +438 -0
  22. package/dist/cli/result-types.d.ts +194 -0
  23. package/dist/cli/result-types.d.ts.map +1 -0
  24. package/dist/cli/result-types.js +6 -0
  25. package/dist/cli/runner.d.ts +35 -0
  26. package/dist/cli/runner.d.ts.map +1 -0
  27. package/dist/cli/runner.js +111 -0
  28. package/dist/cli/types.d.ts +20 -0
  29. package/dist/cli/types.d.ts.map +1 -0
  30. package/dist/cli/types.js +10 -0
  31. package/dist/cli.d.ts +4 -0
  32. package/dist/cli.d.ts.map +1 -0
  33. package/dist/cli.js +2 -0
  34. package/dist/client/app-client.d.ts +126 -0
  35. package/dist/client/app-client.d.ts.map +1 -0
  36. package/dist/client/app-client.js +223 -0
  37. package/dist/client/bootstrap.d.ts +48 -0
  38. package/dist/client/bootstrap.d.ts.map +1 -0
  39. package/dist/client/bootstrap.js +108 -0
  40. package/dist/client/connect.d.ts +17 -0
  41. package/dist/client/connect.d.ts.map +1 -0
  42. package/dist/client/connect.js +38 -0
  43. package/dist/client/errors.d.ts +26 -0
  44. package/dist/client/errors.d.ts.map +1 -0
  45. package/dist/client/errors.js +40 -0
  46. package/dist/client/index.d.ts +19 -0
  47. package/dist/client/index.d.ts.map +1 -0
  48. package/dist/client/index.js +16 -0
  49. package/dist/commands/daemon.d.ts +22 -0
  50. package/dist/commands/daemon.d.ts.map +1 -0
  51. package/dist/commands/daemon.js +128 -0
  52. package/dist/commands/doctor.d.ts +23 -0
  53. package/dist/commands/doctor.d.ts.map +1 -0
  54. package/dist/commands/doctor.js +42 -0
  55. package/dist/commands/events.d.ts +37 -0
  56. package/dist/commands/events.d.ts.map +1 -0
  57. package/dist/commands/events.js +76 -0
  58. package/dist/commands/init.d.ts +51 -0
  59. package/dist/commands/init.d.ts.map +1 -0
  60. package/dist/commands/init.js +231 -0
  61. package/dist/commands/invoke.d.ts +22 -0
  62. package/dist/commands/invoke.d.ts.map +1 -0
  63. package/dist/commands/invoke.js +54 -0
  64. package/dist/commands/keygen.d.ts +23 -0
  65. package/dist/commands/keygen.d.ts.map +1 -0
  66. package/dist/commands/keygen.js +46 -0
  67. package/dist/commands/link.d.ts +30 -0
  68. package/dist/commands/link.d.ts.map +1 -0
  69. package/dist/commands/link.js +43 -0
  70. package/dist/commands/ls.d.ts +9 -0
  71. package/dist/commands/ls.d.ts.map +1 -0
  72. package/dist/commands/ls.js +7 -0
  73. package/dist/commands/mcp.d.ts +49 -0
  74. package/dist/commands/mcp.d.ts.map +1 -0
  75. package/dist/commands/mcp.js +98 -0
  76. package/dist/commands/revoke.d.ts +12 -0
  77. package/dist/commands/revoke.d.ts.map +1 -0
  78. package/dist/commands/revoke.js +7 -0
  79. package/dist/commands/tools.d.ts +23 -0
  80. package/dist/commands/tools.d.ts.map +1 -0
  81. package/dist/commands/tools.js +52 -0
  82. package/dist/daemon/address.d.ts +30 -0
  83. package/dist/daemon/address.d.ts.map +1 -0
  84. package/dist/daemon/address.js +71 -0
  85. package/dist/daemon/audit.d.ts +107 -0
  86. package/dist/daemon/audit.d.ts.map +1 -0
  87. package/dist/daemon/audit.js +237 -0
  88. package/dist/daemon/calls.d.ts +84 -0
  89. package/dist/daemon/calls.d.ts.map +1 -0
  90. package/dist/daemon/calls.js +158 -0
  91. package/dist/daemon/config.d.ts +79 -0
  92. package/dist/daemon/config.d.ts.map +1 -0
  93. package/dist/daemon/config.js +170 -0
  94. package/dist/daemon/daemon.d.ts +43 -0
  95. package/dist/daemon/daemon.d.ts.map +1 -0
  96. package/dist/daemon/daemon.js +560 -0
  97. package/dist/daemon/event-bus.d.ts +49 -0
  98. package/dist/daemon/event-bus.d.ts.map +1 -0
  99. package/dist/daemon/event-bus.js +111 -0
  100. package/dist/daemon/links.d.ts +54 -0
  101. package/dist/daemon/links.d.ts.map +1 -0
  102. package/dist/daemon/links.js +118 -0
  103. package/dist/daemon/listener.d.ts +34 -0
  104. package/dist/daemon/listener.d.ts.map +1 -0
  105. package/dist/daemon/listener.js +132 -0
  106. package/dist/daemon/log-rotation.d.ts +82 -0
  107. package/dist/daemon/log-rotation.d.ts.map +1 -0
  108. package/dist/daemon/log-rotation.js +136 -0
  109. package/dist/daemon/pidfile.d.ts +38 -0
  110. package/dist/daemon/pidfile.d.ts.map +1 -0
  111. package/dist/daemon/pidfile.js +90 -0
  112. package/dist/daemon/policy.d.ts +22 -0
  113. package/dist/daemon/policy.d.ts.map +1 -0
  114. package/dist/daemon/policy.js +24 -0
  115. package/dist/daemon/registry.d.ts +19 -0
  116. package/dist/daemon/registry.d.ts.map +1 -0
  117. package/dist/daemon/registry.js +31 -0
  118. package/dist/daemon/rpc-server.d.ts +54 -0
  119. package/dist/daemon/rpc-server.d.ts.map +1 -0
  120. package/dist/daemon/rpc-server.js +203 -0
  121. package/dist/daemon/sessions.d.ts +86 -0
  122. package/dist/daemon/sessions.d.ts.map +1 -0
  123. package/dist/daemon/sessions.js +429 -0
  124. package/dist/daemon/socket-probe.d.ts +16 -0
  125. package/dist/daemon/socket-probe.d.ts.map +1 -0
  126. package/dist/daemon/socket-probe.js +28 -0
  127. package/dist/daemon/state-dir.d.ts +31 -0
  128. package/dist/daemon/state-dir.d.ts.map +1 -0
  129. package/dist/daemon/state-dir.js +43 -0
  130. package/dist/daemon/timers.d.ts +16 -0
  131. package/dist/daemon/timers.d.ts.map +1 -0
  132. package/dist/daemon/timers.js +12 -0
  133. package/dist/daemon/tls.d.ts +58 -0
  134. package/dist/daemon/tls.d.ts.map +1 -0
  135. package/dist/daemon/tls.js +79 -0
  136. package/dist/errors.d.ts +28 -0
  137. package/dist/errors.d.ts.map +1 -0
  138. package/dist/errors.js +162 -0
  139. package/dist/host-certificate.d.ts +8 -0
  140. package/dist/host-certificate.d.ts.map +1 -0
  141. package/dist/host-certificate.js +111 -0
  142. package/dist/index.d.ts +4 -0
  143. package/dist/index.d.ts.map +1 -0
  144. package/dist/index.js +3 -0
  145. package/dist/key-material.d.ts +14 -0
  146. package/dist/key-material.d.ts.map +1 -0
  147. package/dist/key-material.js +42 -0
  148. package/dist/link.d.ts +76 -0
  149. package/dist/link.d.ts.map +1 -0
  150. package/dist/link.js +117 -0
  151. package/dist/mcp/connect-tool.d.ts +115 -0
  152. package/dist/mcp/connect-tool.d.ts.map +1 -0
  153. package/dist/mcp/connect-tool.js +396 -0
  154. package/dist/mcp/daemon-tools.d.ts +13 -0
  155. package/dist/mcp/daemon-tools.d.ts.map +1 -0
  156. package/dist/mcp/daemon-tools.js +29 -0
  157. package/dist/mcp/events-tool.d.ts +111 -0
  158. package/dist/mcp/events-tool.d.ts.map +1 -0
  159. package/dist/mcp/events-tool.js +322 -0
  160. package/dist/mcp/server.d.ts +58 -0
  161. package/dist/mcp/server.d.ts.map +1 -0
  162. package/dist/mcp/server.js +538 -0
  163. package/dist/mcp/tool-mapping.d.ts +58 -0
  164. package/dist/mcp/tool-mapping.d.ts.map +1 -0
  165. package/dist/mcp/tool-mapping.js +143 -0
  166. package/dist/mcp/tool-namespace.d.ts +40 -0
  167. package/dist/mcp/tool-namespace.d.ts.map +1 -0
  168. package/dist/mcp/tool-namespace.js +65 -0
  169. package/dist/native-scheme.d.ts +59 -0
  170. package/dist/native-scheme.d.ts.map +1 -0
  171. package/dist/native-scheme.js +465 -0
  172. package/dist/output.d.ts +28 -0
  173. package/dist/output.d.ts.map +1 -0
  174. package/dist/output.js +342 -0
  175. package/dist/package-version.d.ts +30 -0
  176. package/dist/package-version.d.ts.map +1 -0
  177. package/dist/package-version.js +47 -0
  178. package/dist/qr-terminal.d.ts +13 -0
  179. package/dist/qr-terminal.d.ts.map +1 -0
  180. package/dist/qr-terminal.js +45 -0
  181. package/dist/rpc/client.d.ts +137 -0
  182. package/dist/rpc/client.d.ts.map +1 -0
  183. package/dist/rpc/client.js +819 -0
  184. package/dist/scheme.d.ts +181 -0
  185. package/dist/scheme.d.ts.map +1 -0
  186. package/dist/scheme.js +321 -0
  187. package/dist/spki-pin.d.ts +3 -0
  188. package/dist/spki-pin.d.ts.map +1 -0
  189. package/dist/spki-pin.js +14 -0
  190. package/package.json +65 -0
@@ -0,0 +1,315 @@
1
+ /**
2
+ * `appduct doctor` (docs/tasks/08-appduct-doctor.md): the artifact-level replacement for the
3
+ * runtime `debuggable`/`#if DEBUG` gate removed elsewhere in opt-in hardening. Given a built
4
+ * `.app`/`.ipa`/`.apk`/`.aab`, decide whether Appduct's native code actually shipped inside it —
5
+ * not whether the config that's supposed to have produced it looks right.
6
+ *
7
+ * The one property everything here is built around: **never report "absent" because a tool was
8
+ * missing or the artifact couldn't be read.** That failure mode is strictly worse than not having
9
+ * this check at all, because it turns a release gate into a rubber stamp that always exits 0 once
10
+ * `unzip` happens to be missing from a runner's image. Every path that can't produce a truthful
11
+ * answer throws {@link inspectionError} — a distinct, non-zero exit — instead of falling through to
12
+ * `present: false`.
13
+ *
14
+ * Detection strategy, and why no single signal is authoritative on either platform:
15
+ *
16
+ * - iOS: `present` is decided by real-code-only symbols alone, exactly like Android below —
17
+ * `AppductCoreMarker` (`packages/native/ios/Sources/AppductCore/Real/AppductCoreMarker.swift`,
18
+ * docs/tasks/14-native-core-extraction.md), an `@objc` class compiled only into the real
19
+ * implementation, never into the SwiftPM package's `Stub/` branch, so its presence cannot be
20
+ * confused with a stub build. `RCTNativeAppduct` (`RCT_EXPORT_MODULE`, see
21
+ * `packages/react-native/ios/RCTNativeAppduct.mm`) is kept as a second real-code signal for
22
+ * artifacts built before the marker existed. Both are Objective-C runtime metadata — they live in
23
+ * `__objc_classname`/`__objc_data`, and `strip`/release optimization leaves them alone because
24
+ * removing them would break `+[NSObject class]`-based dispatch. (A build that dead-strips the whole
25
+ * translation unit for lack of `-ObjC`/`-force_load` could still drop them — these signals assume
26
+ * the module actually links into the binary, which is the thing being checked in the first
27
+ * place.) The plugin-authored `Info.plist` keys
28
+ * (`AppductCliPins`/`AppductTrust`/`AppductAllowPrivateLanOnly`, docs/tasks/00-overview.md
29
+ * "Native config keys the plugin writes") are reported as a corroborating signal only and can no
30
+ * longer flip `present` to `true` on their own — an app author can write these keys by hand (or a
31
+ * future stub could ship them) without the real implementation being present, exactly the reason
32
+ * the Android signals below aren't all treated as equally authoritative either.
33
+ * - Android: the primary signal is `AppductNativeMarker`
34
+ * (`packages/native/android/core/src/main/java/com/callstackincubator/appduct/AppductNativeMarker.kt`,
35
+ * vendored into `@appduct/react-native` at `android/core/src/main/java/...`), a marker class with no
36
+ * other purpose. Its fully-qualified name is kept unminified and unremoved by a `-keep` rule in
37
+ * `consumer-rules.pro` (same vendoring path), shipped to every consuming app via `consumerProguardFiles`
38
+ * (see `../build.gradle`) — R8 applies it regardless of whether the app used the Expo config plugin or
39
+ * bare-RN autolinking, and regardless of whether the app authored any keep rules of its own
40
+ * (docs/tasks/10-android-detection-keep-rule.md). This is the only Android signal guaranteed to survive
41
+ * minification in every supported consumer setup.
42
+ *
43
+ * Two more signals are kept as fallbacks for artifacts built before this marker existed: the
44
+ * `com.callstackincubator.appduct` package string in the dex string pool (survives as long as
45
+ * R8/ProGuard minification+obfuscation isn't applied to it — an aggressive release config with no keep
46
+ * rule for this package can rename it away) and the plugin-authored meta-data key names in
47
+ * `AndroidManifest.xml` (`com.callstackincubator.appduct.CLI_PINS`/`TRUST`/`ALLOW_PRIVATE_LAN_ONLY`):
48
+ * those are XML attribute string values written by the config plugin at prebuild time, not compiled
49
+ * identifiers, so R8 never touches them, but they only exist at all if the config plugin ran. Both
50
+ * encodings AAPT2 can choose for the manifest string pool (UTF-8 or UTF-16LE) are checked.
51
+ *
52
+ * On iOS, `present` is an OR across the two real-code-only signals (`ios-core-marker-symbol`,
53
+ * `ios-objc-class-symbol`) — specifically so a stripped binary that dropped one literal doesn't flip
54
+ * a real inclusion to "absent" just because one check missed — but the Info.plist keys signal cannot
55
+ * flip it to `true` by itself, matching Android's marker-only rule below (a stub that ships the same
56
+ * plist keys without the real implementation is exactly the failure mode this guards against, even
57
+ * though no such stub exists yet as of Phase 1 of docs/tasks/14-native-core-extraction.md).
58
+ *
59
+ * On Android, `present` is decided by the keep-rule marker alone, not an OR across all three
60
+ * signals. The other two are reported in `signals` for corroboration/debugging but cannot flip
61
+ * `present` to `true` on their own: `android/build.gradle`'s `APPDUCT_ENABLED`-gated vendored
62
+ * source-directory swap (`core` vs `core-noop`, docs/tasks/14-native-core-extraction.md; previously a
63
+ * `src/debug`/`src/release-stub` swap, see docs/tasks/00-overview.md's "Revisited
64
+ * post-implementation") compiles a genuine no-op `AppductPackage` into a default release build at
65
+ * the *same* fully-qualified name the real one uses (required so the shared, non-variant-aware
66
+ * `PackageList.java` still resolves), and the config plugin writes the same manifest meta-data
67
+ * regardless of variant (Expo mods edit the single merged manifest, not a per-variant one). Both
68
+ * fallback signals therefore fire on that harmless stub exactly as they would on the real module, and
69
+ * can no longer prove inclusion by themselves.
70
+ *
71
+ * `appduct doctor` is strictly better than the runtime check it replaces (docs/tasks/00-overview.md
72
+ * "What we give up, deliberately"). The keep-rule marker closes the previously-documented gap where a
73
+ * bare-RN app with no config plugin and no keep rule could evade both older Android signals under R8
74
+ * minification (docs/tasks/10-android-detection-keep-rule.md) — that gap applied to builds produced before
75
+ * this library shipped the marker/keep rule; an app that pins an older `@appduct/react-native` version
76
+ * still lacks it, and doctor cannot detect inclusion on such a build at all.
77
+ */
78
+ import { execFile } from "node:child_process";
79
+ import { readdir, readFile, stat } from "node:fs/promises";
80
+ import path from "node:path";
81
+ import { inspectionError, usageError } from "./errors.js";
82
+ // --- markers ---
83
+ // `@objc(AppductCoreMarker)` -- packages/native/ios/Sources/AppductCore/Real/AppductCoreMarker.swift.
84
+ // Compiled only into the real implementation, never the SwiftPM package's Stub/ branch, so unlike
85
+ // the Info.plist keys below it cannot exist without the real implementation also being present.
86
+ const IOS_CORE_MARKER = "AppductCoreMarker";
87
+ const IOS_OBJC_CLASS_MARKER = "RCTNativeAppduct";
88
+ const IOS_INFO_PLIST_KEY_MARKERS = ["AppductCliPins", "AppductTrust", "AppductAllowPrivateLanOnly"];
89
+ // Fully-qualified name of `AppductNativeMarker`
90
+ // (packages/native/android/core/src/main/java/com/callstackincubator/appduct/AppductNativeMarker.kt,
91
+ // vendored into packages/react-native/android/core/src/main/java/com/callstackincubator/appduct/AppductNativeMarker.kt
92
+ // by scripts/sync-native-core.mjs), kept unminified by packages/native/android/core/consumer-rules.pro
93
+ // (vendored to packages/react-native/android/core/consumer-rules.pro the same way). Checked as a dex
94
+ // type descriptor (`Lcom/.../AppductNativeMarker;`) — see detectAndroidSignals — which is how the
95
+ // class's fully-qualified name is actually encoded in classes.dex.
96
+ const ANDROID_KEEP_RULE_MARKER_CLASS = "com/callstackincubator/appduct/AppductNativeMarker";
97
+ const ANDROID_DEX_PACKAGE_MARKERS = ["com/callstackincubator/appduct", "com.callstackincubator.appduct"];
98
+ const ANDROID_MANIFEST_KEY_MARKER = "com.callstackincubator.appduct.";
99
+ const bufferIncludesAscii = (haystack, needle) => {
100
+ return haystack.includes(Buffer.from(needle, "utf8"));
101
+ };
102
+ const bufferIncludesUtf16le = (haystack, needle) => {
103
+ return haystack.includes(Buffer.from(needle, "utf16le"));
104
+ };
105
+ /** Real subprocess execution via `execFile`, buffered as raw bytes. `maxBuffer` is generous (an
106
+ * artifact's full decompressed contents are read into memory at once — see {@link readZipEntries})
107
+ * since real `.ipa`/`.apk`/`.aab` files can run into the hundreds of MB. */
108
+ export const defaultExecBuffer = (command, args) => {
109
+ return new Promise((resolve, reject) => {
110
+ execFile(command, args, { encoding: "buffer", maxBuffer: 512 * 1024 * 1024 }, (error, stdout, stderr) => {
111
+ if (error) {
112
+ reject(Object.assign(error, { stdout, stderr }));
113
+ return;
114
+ }
115
+ resolve({ stdout, stderr });
116
+ });
117
+ });
118
+ };
119
+ const TOOL_INSTALL_HINT = {
120
+ unzip: "Info-ZIP's unzip (present by default on macOS and most Linux CI images)",
121
+ };
122
+ /** Runs an external tool, translating "not found on PATH" and "ran but failed" into a single
123
+ * {@link inspectionError} class — both mean the same thing to a caller: this artifact could not be
124
+ * inspected, so no presence/absence claim can be trusted. Never resolves to a result that looks
125
+ * like a clean "not present" answer. */
126
+ const runToolOrInspectionError = async (exec, command, args, context) => {
127
+ try {
128
+ const result = await exec(command, args);
129
+ return result.stdout;
130
+ }
131
+ catch (error) {
132
+ const err = error;
133
+ if (err.code === "ENOENT") {
134
+ throw inspectionError(`"${command}" was not found on PATH; cannot inspect ${context}. Install ${TOOL_INSTALL_HINT[command] ?? command} and retry.`);
135
+ }
136
+ if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
137
+ throw inspectionError(`"${command} ${args.join(" ")}" produced more output than this command will buffer while ` +
138
+ `inspecting ${context}. The artifact is unusually large, not necessarily corrupt — this is ` +
139
+ `a tooling limit, not a presence/absence answer.`);
140
+ }
141
+ const stderr = err.stderr ? err.stderr.toString("utf8").trim() : "";
142
+ throw inspectionError(`"${command} ${args.join(" ")}" failed while inspecting ${context}${stderr ? `: ${stderr}` : `: ${err.message}`}. The artifact may be corrupt or not a valid archive.`);
143
+ }
144
+ };
145
+ /** The container-shape check every zip-format artifact must pass before its contents are trusted
146
+ * for a presence/absence answer (see {@link readZipEntries}): an empty, wrong-file-renamed, or
147
+ * otherwise not-actually-that-kind-of-artifact zip must never silently read back as "no signals
148
+ * found" — it isn't an answer, it's evidence the input wasn't what its extension claimed. */
149
+ const EXPECTED_ENTRY_PATTERN = {
150
+ ipa: /\.app\//iu,
151
+ apk: /(^|\/)AndroidManifest\.xml$/u,
152
+ aab: /(^|\/)AndroidManifest\.xml$/u,
153
+ };
154
+ const assertExpectedZipShape = (format, entries, context) => {
155
+ if (entries.length === 0) {
156
+ throw inspectionError(`${context} contains no entries — it is not a valid archive.`);
157
+ }
158
+ if (!entries.some((entry) => EXPECTED_ENTRY_PATTERN[format].test(entry))) {
159
+ throw inspectionError(`${context} does not look like a valid .${format}: none of its ${entries.length} entries match ` +
160
+ `the expected ${format === "ipa" ? '"*.app/" bundle' : '"AndroidManifest.xml"'} shape. Refusing ` +
161
+ `to report absence for an artifact that may simply be the wrong file.`);
162
+ }
163
+ };
164
+ /** Lists a zip archive's entry names via `unzip -Z1` (zipinfo mode: one path per line, nothing
165
+ * else) — used only for the {@link assertExpectedZipShape} sanity check, not for detection itself. */
166
+ const listZipEntries = async (exec, archivePath, context) => {
167
+ const stdout = await runToolOrInspectionError(exec, "unzip", ["-Z1", archivePath], context);
168
+ return stdout
169
+ .toString("utf8")
170
+ .split("\n")
171
+ .map((line) => line.trim())
172
+ .filter((line) => line.length > 0);
173
+ };
174
+ /** Streams every entry of a zip-format archive (`.ipa`/`.apk`/`.aab` are all zip containers)
175
+ * decompressed and concatenated to one buffer, via `unzip -p`. Deliberately does not attempt to
176
+ * enumerate entries and extract selectively first — a single whole-archive pass is simpler, cannot
177
+ * miss a signal hiding in an unexpected internal path (embedded framework, AAB's `base/dex/`
178
+ * nesting, ...), and correctness matters far more than the marginal speed of a narrower read for a
179
+ * command that runs once per release. Validates the archive actually has the expected internal
180
+ * shape first ({@link assertExpectedZipShape}) so an empty or mislabeled zip can't read back as a
181
+ * clean "no signals found" instead of the "this wasn't a real artifact" it actually is. */
182
+ const readZipEntries = async (exec, archivePath, format, context) => {
183
+ const entries = await listZipEntries(exec, archivePath, context);
184
+ assertExpectedZipShape(format, entries, context);
185
+ return runToolOrInspectionError(exec, "unzip", ["-p", archivePath], context);
186
+ };
187
+ /** Recursively reads every regular file under a `.app` bundle directory into one concatenated
188
+ * buffer. Symlinks are skipped (never followed) to avoid loops; `.app` bundles don't legitimately
189
+ * need them for this check. */
190
+ const readAppDirectory = async (appDirPath, context) => {
191
+ const chunks = [];
192
+ const walk = async (dir) => {
193
+ let entries;
194
+ try {
195
+ entries = await readdir(dir, { withFileTypes: true });
196
+ }
197
+ catch (error) {
198
+ throw inspectionError(`Could not read "${dir}" while inspecting ${context}: ${error.message}.`);
199
+ }
200
+ for (const entry of entries) {
201
+ if (entry.isSymbolicLink()) {
202
+ continue;
203
+ }
204
+ const entryPath = path.join(dir, entry.name);
205
+ if (entry.isDirectory()) {
206
+ await walk(entryPath);
207
+ continue;
208
+ }
209
+ if (entry.isFile()) {
210
+ try {
211
+ chunks.push(await readFile(entryPath));
212
+ }
213
+ catch (error) {
214
+ throw inspectionError(`Could not read "${entryPath}" while inspecting ${context}: ${error.message}.`);
215
+ }
216
+ }
217
+ }
218
+ };
219
+ await walk(appDirPath);
220
+ return Buffer.concat(chunks);
221
+ };
222
+ // --- format resolution ---
223
+ const resolveFormat = (artifactPath) => {
224
+ const extension = path.extname(artifactPath).toLowerCase();
225
+ switch (extension) {
226
+ case ".app":
227
+ return { platform: "ios", format: "app" };
228
+ case ".ipa":
229
+ return { platform: "ios", format: "ipa" };
230
+ case ".apk":
231
+ return { platform: "android", format: "apk" };
232
+ case ".aab":
233
+ return { platform: "android", format: "aab" };
234
+ default:
235
+ throw usageError(`Unrecognized artifact extension "${extension || artifactPath}". Expected one of: .app, .ipa, .apk, .aab.`);
236
+ }
237
+ };
238
+ // --- signal detection ---
239
+ const detectIosSignals = (bytes) => {
240
+ const signals = [];
241
+ // Primary signal: checked first because, unlike the Info.plist keys below, it cannot exist
242
+ // without the real implementation (see the file-level doc comment).
243
+ if (bufferIncludesAscii(bytes, IOS_CORE_MARKER)) {
244
+ signals.push("ios-core-marker-symbol");
245
+ }
246
+ if (bufferIncludesAscii(bytes, IOS_OBJC_CLASS_MARKER)) {
247
+ signals.push("ios-objc-class-symbol");
248
+ }
249
+ if (IOS_INFO_PLIST_KEY_MARKERS.some((marker) => bufferIncludesAscii(bytes, marker))) {
250
+ signals.push("ios-info-plist-keys");
251
+ }
252
+ return signals;
253
+ };
254
+ const detectAndroidSignals = (bytes) => {
255
+ const signals = [];
256
+ // Primary signal: checked first because it's the only one guaranteed to survive R8 minification
257
+ // in every supported consumer setup (see the file-level doc comment). Matched as a dex type
258
+ // descriptor (`L` + fully-qualified-name-with-slashes + `;`) since that's the actual encoding of
259
+ // a class name inside classes.dex, not just a loose substring check.
260
+ if (bufferIncludesAscii(bytes, `L${ANDROID_KEEP_RULE_MARKER_CLASS};`)) {
261
+ signals.push("android-keep-rule-marker");
262
+ }
263
+ if (ANDROID_DEX_PACKAGE_MARKERS.some((marker) => bufferIncludesAscii(bytes, marker))) {
264
+ signals.push("android-dex-package-symbol");
265
+ }
266
+ if (bufferIncludesAscii(bytes, ANDROID_MANIFEST_KEY_MARKER) ||
267
+ bufferIncludesUtf16le(bytes, ANDROID_MANIFEST_KEY_MARKER)) {
268
+ signals.push("android-manifest-meta-data-keys");
269
+ }
270
+ return signals;
271
+ };
272
+ /** Inspects a built artifact for Appduct's native inclusion. Throws {@link usageError} for an
273
+ * unrecognized path/extension (a CLI-usage mistake) and {@link inspectionError} for anything that
274
+ * prevents a truthful presence/absence answer (missing tool, unreadable/corrupt artifact) — it
275
+ * never resolves successfully with a guessed or default answer. */
276
+ export const inspectArtifact = async (artifactPath, options = {}) => {
277
+ const exec = options.exec ?? defaultExecBuffer;
278
+ const { platform, format } = resolveFormat(artifactPath);
279
+ const context = `"${artifactPath}"`;
280
+ let stats;
281
+ try {
282
+ stats = await stat(artifactPath);
283
+ }
284
+ catch (error) {
285
+ throw inspectionError(`Artifact not found at ${context}: ${error.message}.`);
286
+ }
287
+ let bytes;
288
+ if (format === "app") {
289
+ if (!stats.isDirectory()) {
290
+ throw inspectionError(`Expected ${context} (a ".app" bundle) to be a directory.`);
291
+ }
292
+ bytes = await readAppDirectory(artifactPath, context);
293
+ }
294
+ else {
295
+ if (!stats.isFile()) {
296
+ throw inspectionError(`Expected ${context} to be a file.`);
297
+ }
298
+ bytes = await readZipEntries(exec, artifactPath, format, context);
299
+ }
300
+ const signals = platform === "ios" ? detectIosSignals(bytes) : detectAndroidSignals(bytes);
301
+ // Android: only the keep-rule marker proves inclusion (see the file-level doc comment) -- the
302
+ // other two Android signals are reported but can't flip `present` on their own. iOS mirrors
303
+ // this: only the two real-code-only symbols prove inclusion (OR'd together so a stripped binary
304
+ // that dropped one doesn't read as absent) -- the Info.plist keys are corroborating only and
305
+ // can't flip `present` on their own either.
306
+ const present = platform === "android"
307
+ ? signals.includes("android-keep-rule-marker")
308
+ : signals.includes("ios-core-marker-symbol") || signals.includes("ios-objc-class-symbol");
309
+ return {
310
+ platform,
311
+ format,
312
+ present,
313
+ signals,
314
+ };
315
+ };
package/dist/bin.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=bin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../src/bin.ts"],"names":[],"mappings":""}
package/dist/bin.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./cli.js";
3
+ const exitCode = await runCli(process.argv.slice(2));
4
+ process.exit(exitCode);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Small option-parsing helpers shared across the v2 command handlers wired in `dispatch.ts`. Kept
3
+ * separate from `dispatch.ts` itself so each parsing rule (numeric flags, the selector/target
4
+ * positional split) has one obvious place to test in isolation.
5
+ */
6
+ /** Parses a `cac`-provided option value (already a number, a numeric string, or `undefined`) into a
7
+ * positive integer, or throws a clear `usage_error`. */
8
+ export declare const parsePositiveIntegerOption: (value: unknown, flagName: string) => number | undefined;
9
+ /** Parses a `cac`-provided option value into a non-negative integer (0 allowed, unlike
10
+ * {@link parsePositiveIntegerOption}), or throws a clear `usage_error`. Used by `events --since`,
11
+ * whose cursor `0` is a meaningful "everything retained" value, not an omission. */
12
+ export declare const parseNonNegativeIntegerOption: (value: unknown, flagName: string) => number | undefined;
13
+ /**
14
+ * Splits the positional args of a command shaped `<command> [selector] <target>` (e.g. `invoke
15
+ * [selector] <tool>`, `tools [selector] <name>`): the last positional is always the required
16
+ * target, everything before it (zero or one args) is the optional selector.
17
+ */
18
+ export declare const splitSelectorAndRequiredTarget: (args: readonly string[], commandUsage: string) => {
19
+ selector?: string;
20
+ target: string;
21
+ };
22
+ /** Splits the positional args of a command shaped `<command> [selector]`: at most one positional,
23
+ * the optional selector. */
24
+ export declare const splitOptionalSelector: (args: readonly string[], commandUsage: string) => {
25
+ selector?: string;
26
+ };
27
+ /**
28
+ * Splits the positional args of a command shaped `<command> [selector] [target]` (`tools [selector]
29
+ * [name]`): with 2 args, `(selector, target)`; with 1, the single arg is ambiguous between "the
30
+ * selector" and "the target with the selector omitted" — the caller resolves that (see
31
+ * `commands/tools.ts`), so it comes back unlabeled here as `selectorOrTarget`.
32
+ */
33
+ export declare const splitOptionalSelectorAndTarget: (args: readonly string[], commandUsage: string) => {
34
+ selector?: string;
35
+ target?: string;
36
+ selectorOrTarget?: string;
37
+ };
38
+ /**
39
+ * Parses the JSON payload for `invoke --input`; never throws a raw `SyntaxError` at the CLI
40
+ * boundary. A missing flag is a usage error (nothing was given); a present-but-unparseable or
41
+ * wrong-shaped value is a validation error (something was given, and it's invalid).
42
+ */
43
+ export declare const parseJsonInputOption: (value: string | undefined) => Record<string, unknown>;
44
+ //# sourceMappingURL=command-options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command-options.d.ts","sourceRoot":"","sources":["../../src/cli/command-options.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH;wDACwD;AACxD,eAAO,MAAM,0BAA0B,GAAI,OAAO,OAAO,EAAE,UAAU,MAAM,KAAG,MAAM,GAAG,SAYtF,CAAC;AAEF;;oFAEoF;AACpF,eAAO,MAAM,6BAA6B,GAAI,OAAO,OAAO,EAAE,UAAU,MAAM,KAAG,MAAM,GAAG,SAYzF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,GACzC,MAAM,SAAS,MAAM,EAAE,EACvB,cAAc,MAAM,KACnB;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAcrC,CAAC;AAEF;4BAC4B;AAC5B,eAAO,MAAM,qBAAqB,GAAI,MAAM,SAAS,MAAM,EAAE,EAAE,cAAc,MAAM,KAAG;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAMxG,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,GACzC,MAAM,SAAS,MAAM,EAAE,EACvB,cAAc,MAAM,KACnB;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAcjE,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,OAAO,MAAM,GAAG,SAAS,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAkBtF,CAAC"}
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Small option-parsing helpers shared across the v2 command handlers wired in `dispatch.ts`. Kept
3
+ * separate from `dispatch.ts` itself so each parsing rule (numeric flags, the selector/target
4
+ * positional split) has one obvious place to test in isolation.
5
+ */
6
+ import { usageError, validationError } from "../errors.js";
7
+ /** Parses a `cac`-provided option value (already a number, a numeric string, or `undefined`) into a
8
+ * positive integer, or throws a clear `usage_error`. */
9
+ export const parsePositiveIntegerOption = (value, flagName) => {
10
+ if (value === undefined) {
11
+ return undefined;
12
+ }
13
+ const parsed = typeof value === "number" ? value : Number(value);
14
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) {
15
+ throw usageError(`"${flagName}" must be a positive integer.`);
16
+ }
17
+ return parsed;
18
+ };
19
+ /** Parses a `cac`-provided option value into a non-negative integer (0 allowed, unlike
20
+ * {@link parsePositiveIntegerOption}), or throws a clear `usage_error`. Used by `events --since`,
21
+ * whose cursor `0` is a meaningful "everything retained" value, not an omission. */
22
+ export const parseNonNegativeIntegerOption = (value, flagName) => {
23
+ if (value === undefined) {
24
+ return undefined;
25
+ }
26
+ const parsed = typeof value === "number" ? value : Number(value);
27
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
28
+ throw usageError(`"${flagName}" must be a non-negative integer.`);
29
+ }
30
+ return parsed;
31
+ };
32
+ /**
33
+ * Splits the positional args of a command shaped `<command> [selector] <target>` (e.g. `invoke
34
+ * [selector] <tool>`, `tools [selector] <name>`): the last positional is always the required
35
+ * target, everything before it (zero or one args) is the optional selector.
36
+ */
37
+ export const splitSelectorAndRequiredTarget = (args, commandUsage) => {
38
+ if (args.length === 0) {
39
+ throw usageError(`Usage: ${commandUsage}`);
40
+ }
41
+ if (args.length > 2) {
42
+ throw usageError(`Usage: ${commandUsage} (too many arguments).`);
43
+ }
44
+ if (args.length === 1) {
45
+ return { target: args[0] };
46
+ }
47
+ return { selector: args[0], target: args[1] };
48
+ };
49
+ /** Splits the positional args of a command shaped `<command> [selector]`: at most one positional,
50
+ * the optional selector. */
51
+ export const splitOptionalSelector = (args, commandUsage) => {
52
+ if (args.length > 1) {
53
+ throw usageError(`Usage: ${commandUsage} (too many arguments).`);
54
+ }
55
+ return { selector: args[0] };
56
+ };
57
+ /**
58
+ * Splits the positional args of a command shaped `<command> [selector] [target]` (`tools [selector]
59
+ * [name]`): with 2 args, `(selector, target)`; with 1, the single arg is ambiguous between "the
60
+ * selector" and "the target with the selector omitted" — the caller resolves that (see
61
+ * `commands/tools.ts`), so it comes back unlabeled here as `selectorOrTarget`.
62
+ */
63
+ export const splitOptionalSelectorAndTarget = (args, commandUsage) => {
64
+ if (args.length > 2) {
65
+ throw usageError(`Usage: ${commandUsage} (too many arguments).`);
66
+ }
67
+ if (args.length === 2) {
68
+ return { selector: args[0], target: args[1] };
69
+ }
70
+ if (args.length === 1) {
71
+ return { selectorOrTarget: args[0] };
72
+ }
73
+ return {};
74
+ };
75
+ /**
76
+ * Parses the JSON payload for `invoke --input`; never throws a raw `SyntaxError` at the CLI
77
+ * boundary. A missing flag is a usage error (nothing was given); a present-but-unparseable or
78
+ * wrong-shaped value is a validation error (something was given, and it's invalid).
79
+ */
80
+ export const parseJsonInputOption = (value) => {
81
+ if (value === undefined) {
82
+ throw usageError('"--input" is required.');
83
+ }
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(value);
87
+ }
88
+ catch (error) {
89
+ throw validationError(`"--input" must be valid JSON: ${error.message}`);
90
+ }
91
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
92
+ throw validationError('"--input" must be a JSON object.');
93
+ }
94
+ return parsed;
95
+ };
@@ -0,0 +1,2 @@
1
+ export declare const createCli: () => import("cac").CAC;
2
+ //# sourceMappingURL=create-cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-cli.d.ts","sourceRoot":"","sources":["../../src/cli/create-cli.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,SAAS,yBA2FrB,CAAC"}
@@ -0,0 +1,56 @@
1
+ import { cac } from "cac";
2
+ import { getPackageVersion } from "../package-version.js";
3
+ export const createCli = () => {
4
+ const version = getPackageVersion();
5
+ const cli = cac("appduct");
6
+ cli.option("--json", "Print machine-readable JSON (NDJSON for streaming commands).");
7
+ cli.option("--no-color", "Disable terminal color in human-readable output.");
8
+ cli.option("--state-dir <path>", "Override the Appduct state directory (default: ~/.appduct).");
9
+ cli.option("--daemon-restart", "On a daemon/CLI version mismatch, restart the daemon even though that drops live sessions and unclaimed links.");
10
+ cli
11
+ .command("init", "Set up the current app directory: write .appduct/config.json and print the MCP snippet.")
12
+ .option("--scheme <scheme>", "Deep-link URI scheme to write. Only this flag and <cwd>/app.json are consulted — not " +
13
+ "APPDUCT_SCHEME, and no walk-up: init decides what to write here, so it never bakes " +
14
+ "an ambient value into a committed file.")
15
+ .option("--force", "Replace the scheme already recorded in the project config.");
16
+ cli
17
+ .command("keygen", "Generate a Appduct host private key and print its app fingerprint.")
18
+ .option("--out <path>", "Destination path (default: <state-dir>/key.pem).")
19
+ .option("--force", "Overwrite an existing key at the destination path.");
20
+ cli
21
+ .command("link", "Mint a pending session and print its deep link.")
22
+ .option("--ttl <seconds>", "Link time-to-live in seconds (default: from config.json).")
23
+ .option("--qr", "Also render the deep link as a terminal QR code.")
24
+ .option("--scheme <scheme>", "Deep-link URI scheme (also: APPDUCT_SCHEME; default: app.json's \"expo.scheme\").")
25
+ .option("--open <target>", "Deliver the link automatically via adb/simctl/devicectl (android|ios-sim|ios-device; ios-device is experimental).")
26
+ .option("--device <id>", "adb serial, simulator udid or paired-device udid to target when --open is ambiguous.")
27
+ .option("--bundle-id <id>", "App bundle id for --open ios-device (default: config.json's \"iosBundleId\").")
28
+ .option("--relaunch", "With --open ios-device, terminate a running instance first (try this if delivery to an already-running app does nothing).");
29
+ cli.command("ls", "List Appduct sessions.");
30
+ cli
31
+ .command("tools [selector] [name]", "List a session's tools, or show one tool's full schema.")
32
+ .option("--full", "Render full schemas/annotations for every listed tool.");
33
+ cli
34
+ .command("invoke [selector] [tool]", "Call a tool on a session.")
35
+ .option("--input <json>", "Tool input arguments as a JSON object.")
36
+ .option("--timeout <ms>", "Call timeout in milliseconds. Shortens the deadline; it cannot extend one past the app's " +
37
+ "own timer, which is the tool's declared timeoutMs (else 10000).");
38
+ cli
39
+ .command("events [selector]", "Stream session/tool events until interrupted.")
40
+ .option("--follow", "Accepted for script readability; the default behavior already follows.")
41
+ .option("--since <cursor>", "One-shot: print events retained since this cursor instead of streaming live.");
42
+ cli.command("revoke [selector]", "Revoke a session.");
43
+ cli
44
+ .command("mcp", "Start a stdio MCP server that proxies connected apps' tools to MCP clients.")
45
+ .option("--scheme <scheme>", "Deep-link URI scheme for appduct_connect (also: APPDUCT_SCHEME).");
46
+ cli
47
+ .command("doctor <artifact>", "Report (or assert) whether a built .app/.ipa/.apk/.aab contains Appduct.")
48
+ .option("--assert-present", "Exit non-zero (and report) if Appduct is not present in the artifact.")
49
+ .option("--assert-absent", "Exit non-zero (and report) if Appduct is present in the artifact.");
50
+ // cac only matches a command's first word against argv[0], so "run"/"start"/"stop"/"status"
51
+ // are handled as a sub-action of the single "daemon" command rather than four cac commands.
52
+ cli.command("daemon [action]", "Manage the Appduct daemon: run, start, stop, or status.");
53
+ cli.help();
54
+ cli.version(version);
55
+ return cli;
56
+ };
@@ -0,0 +1,3 @@
1
+ import type { RunCliOptions } from "./types.js";
2
+ export declare const runCli: (argv: string[], options?: RunCliOptions) => Promise<number>;
3
+ //# sourceMappingURL=dispatch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../../src/cli/dispatch.ts"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAUhD,eAAO,MAAM,MAAM,GAAU,MAAM,MAAM,EAAE,EAAE,UAAS,aAAkB,KAAG,OAAO,CAAC,MAAM,CAmYxF,CAAC"}