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.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/bin.js +2 -0
- package/dist/artifact-inspect.d.ts +110 -0
- package/dist/artifact-inspect.d.ts.map +1 -0
- package/dist/artifact-inspect.js +315 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/cli/command-options.d.ts +44 -0
- package/dist/cli/command-options.d.ts.map +1 -0
- package/dist/cli/command-options.js +95 -0
- package/dist/cli/create-cli.d.ts +2 -0
- package/dist/cli/create-cli.d.ts.map +1 -0
- package/dist/cli/create-cli.js +56 -0
- package/dist/cli/dispatch.d.ts +3 -0
- package/dist/cli/dispatch.d.ts.map +1 -0
- package/dist/cli/dispatch.js +275 -0
- package/dist/cli/open-target.d.ts +112 -0
- package/dist/cli/open-target.d.ts.map +1 -0
- package/dist/cli/open-target.js +438 -0
- package/dist/cli/result-types.d.ts +194 -0
- package/dist/cli/result-types.d.ts.map +1 -0
- package/dist/cli/result-types.js +6 -0
- package/dist/cli/runner.d.ts +35 -0
- package/dist/cli/runner.d.ts.map +1 -0
- package/dist/cli/runner.js +111 -0
- package/dist/cli/types.d.ts +20 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +10 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2 -0
- package/dist/client/app-client.d.ts +126 -0
- package/dist/client/app-client.d.ts.map +1 -0
- package/dist/client/app-client.js +223 -0
- package/dist/client/bootstrap.d.ts +48 -0
- package/dist/client/bootstrap.d.ts.map +1 -0
- package/dist/client/bootstrap.js +108 -0
- package/dist/client/connect.d.ts +17 -0
- package/dist/client/connect.d.ts.map +1 -0
- package/dist/client/connect.js +38 -0
- package/dist/client/errors.d.ts +26 -0
- package/dist/client/errors.d.ts.map +1 -0
- package/dist/client/errors.js +40 -0
- package/dist/client/index.d.ts +19 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +16 -0
- package/dist/commands/daemon.d.ts +22 -0
- package/dist/commands/daemon.d.ts.map +1 -0
- package/dist/commands/daemon.js +128 -0
- package/dist/commands/doctor.d.ts +23 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +42 -0
- package/dist/commands/events.d.ts +37 -0
- package/dist/commands/events.d.ts.map +1 -0
- package/dist/commands/events.js +76 -0
- package/dist/commands/init.d.ts +51 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +231 -0
- package/dist/commands/invoke.d.ts +22 -0
- package/dist/commands/invoke.d.ts.map +1 -0
- package/dist/commands/invoke.js +54 -0
- package/dist/commands/keygen.d.ts +23 -0
- package/dist/commands/keygen.d.ts.map +1 -0
- package/dist/commands/keygen.js +46 -0
- package/dist/commands/link.d.ts +30 -0
- package/dist/commands/link.d.ts.map +1 -0
- package/dist/commands/link.js +43 -0
- package/dist/commands/ls.d.ts +9 -0
- package/dist/commands/ls.d.ts.map +1 -0
- package/dist/commands/ls.js +7 -0
- package/dist/commands/mcp.d.ts +49 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +98 -0
- package/dist/commands/revoke.d.ts +12 -0
- package/dist/commands/revoke.d.ts.map +1 -0
- package/dist/commands/revoke.js +7 -0
- package/dist/commands/tools.d.ts +23 -0
- package/dist/commands/tools.d.ts.map +1 -0
- package/dist/commands/tools.js +52 -0
- package/dist/daemon/address.d.ts +30 -0
- package/dist/daemon/address.d.ts.map +1 -0
- package/dist/daemon/address.js +71 -0
- package/dist/daemon/audit.d.ts +107 -0
- package/dist/daemon/audit.d.ts.map +1 -0
- package/dist/daemon/audit.js +237 -0
- package/dist/daemon/calls.d.ts +84 -0
- package/dist/daemon/calls.d.ts.map +1 -0
- package/dist/daemon/calls.js +158 -0
- package/dist/daemon/config.d.ts +79 -0
- package/dist/daemon/config.d.ts.map +1 -0
- package/dist/daemon/config.js +170 -0
- package/dist/daemon/daemon.d.ts +43 -0
- package/dist/daemon/daemon.d.ts.map +1 -0
- package/dist/daemon/daemon.js +560 -0
- package/dist/daemon/event-bus.d.ts +49 -0
- package/dist/daemon/event-bus.d.ts.map +1 -0
- package/dist/daemon/event-bus.js +111 -0
- package/dist/daemon/links.d.ts +54 -0
- package/dist/daemon/links.d.ts.map +1 -0
- package/dist/daemon/links.js +118 -0
- package/dist/daemon/listener.d.ts +34 -0
- package/dist/daemon/listener.d.ts.map +1 -0
- package/dist/daemon/listener.js +132 -0
- package/dist/daemon/log-rotation.d.ts +82 -0
- package/dist/daemon/log-rotation.d.ts.map +1 -0
- package/dist/daemon/log-rotation.js +136 -0
- package/dist/daemon/pidfile.d.ts +38 -0
- package/dist/daemon/pidfile.d.ts.map +1 -0
- package/dist/daemon/pidfile.js +90 -0
- package/dist/daemon/policy.d.ts +22 -0
- package/dist/daemon/policy.d.ts.map +1 -0
- package/dist/daemon/policy.js +24 -0
- package/dist/daemon/registry.d.ts +19 -0
- package/dist/daemon/registry.d.ts.map +1 -0
- package/dist/daemon/registry.js +31 -0
- package/dist/daemon/rpc-server.d.ts +54 -0
- package/dist/daemon/rpc-server.d.ts.map +1 -0
- package/dist/daemon/rpc-server.js +203 -0
- package/dist/daemon/sessions.d.ts +86 -0
- package/dist/daemon/sessions.d.ts.map +1 -0
- package/dist/daemon/sessions.js +429 -0
- package/dist/daemon/socket-probe.d.ts +16 -0
- package/dist/daemon/socket-probe.d.ts.map +1 -0
- package/dist/daemon/socket-probe.js +28 -0
- package/dist/daemon/state-dir.d.ts +31 -0
- package/dist/daemon/state-dir.d.ts.map +1 -0
- package/dist/daemon/state-dir.js +43 -0
- package/dist/daemon/timers.d.ts +16 -0
- package/dist/daemon/timers.d.ts.map +1 -0
- package/dist/daemon/timers.js +12 -0
- package/dist/daemon/tls.d.ts +58 -0
- package/dist/daemon/tls.d.ts.map +1 -0
- package/dist/daemon/tls.js +79 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +162 -0
- package/dist/host-certificate.d.ts +8 -0
- package/dist/host-certificate.d.ts.map +1 -0
- package/dist/host-certificate.js +111 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/key-material.d.ts +14 -0
- package/dist/key-material.d.ts.map +1 -0
- package/dist/key-material.js +42 -0
- package/dist/link.d.ts +76 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +117 -0
- package/dist/mcp/connect-tool.d.ts +115 -0
- package/dist/mcp/connect-tool.d.ts.map +1 -0
- package/dist/mcp/connect-tool.js +396 -0
- package/dist/mcp/daemon-tools.d.ts +13 -0
- package/dist/mcp/daemon-tools.d.ts.map +1 -0
- package/dist/mcp/daemon-tools.js +29 -0
- package/dist/mcp/events-tool.d.ts +111 -0
- package/dist/mcp/events-tool.d.ts.map +1 -0
- package/dist/mcp/events-tool.js +322 -0
- package/dist/mcp/server.d.ts +58 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +538 -0
- package/dist/mcp/tool-mapping.d.ts +58 -0
- package/dist/mcp/tool-mapping.d.ts.map +1 -0
- package/dist/mcp/tool-mapping.js +143 -0
- package/dist/mcp/tool-namespace.d.ts +40 -0
- package/dist/mcp/tool-namespace.d.ts.map +1 -0
- package/dist/mcp/tool-namespace.js +65 -0
- package/dist/native-scheme.d.ts +59 -0
- package/dist/native-scheme.d.ts.map +1 -0
- package/dist/native-scheme.js +465 -0
- package/dist/output.d.ts +28 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +342 -0
- package/dist/package-version.d.ts +30 -0
- package/dist/package-version.d.ts.map +1 -0
- package/dist/package-version.js +47 -0
- package/dist/qr-terminal.d.ts +13 -0
- package/dist/qr-terminal.d.ts.map +1 -0
- package/dist/qr-terminal.js +45 -0
- package/dist/rpc/client.d.ts +137 -0
- package/dist/rpc/client.d.ts.map +1 -0
- package/dist/rpc/client.js +819 -0
- package/dist/scheme.d.ts +181 -0
- package/dist/scheme.d.ts.map +1 -0
- package/dist/scheme.js +321 -0
- package/dist/spki-pin.d.ts +3 -0
- package/dist/spki-pin.d.ts.map +1 -0
- package/dist/spki-pin.js +14 -0
- package/package.json +65 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static-file scheme discovery for plain iOS (Xcode) and Android (Gradle) apps
|
|
3
|
+
* (docs/tasks/20-cli-native-scheme-discovery.md, phase 3 of issue #48).
|
|
4
|
+
*
|
|
5
|
+
* `scheme.ts`'s `resolveScheme` treats this module as one more thing to try after `<cwd>/app.json`
|
|
6
|
+
* — it never walks up (an app root is where these commands run, same rule as `app.json`), and,
|
|
7
|
+
* like every other source in that file, **nothing here executes project code**: no `plutil`, no
|
|
8
|
+
* `xcodebuild`, no Gradle evaluation. Every probe is a plain read of a well-known project file,
|
|
9
|
+
* parsed defensively:
|
|
10
|
+
*
|
|
11
|
+
* - Android: `app/build.gradle.kts` / `app/build.gradle` for a `manifestPlaceholders["appductScheme"]`
|
|
12
|
+
* (or `.appductScheme =`) assignment, then `app/src/main/AndroidManifest.xml` for the first
|
|
13
|
+
* `<data android:scheme>` inside an intent filter that also declares
|
|
14
|
+
* `android.intent.action.VIEW`.
|
|
15
|
+
* - iOS: any `Info.plist` up to two levels below the app root (excluding `Pods`, `build`,
|
|
16
|
+
* `node_modules` and `DerivedData` — vendored/build-output trees that are not the project's own
|
|
17
|
+
* config, and can be enormous) for the first `CFBundleURLSchemes` entry, then `project.yml`
|
|
18
|
+
* (xcodegen) for the same key under `info.properties.CFBundleURLTypes`.
|
|
19
|
+
*
|
|
20
|
+
* A binary-encoded `Info.plist` (Xcode can write either encoding) is reported as unreadable rather
|
|
21
|
+
* than decoded — `plutil -convert xml1` would work, but that is exactly the "run a tool against
|
|
22
|
+
* project state" this module exists to avoid, so a binary plist just falls through to `--scheme`
|
|
23
|
+
* like any other file this can't make sense of.
|
|
24
|
+
*
|
|
25
|
+
* **Disagreement is not guessed away.** If more than one probe below resolves to a *different*
|
|
26
|
+
* scheme (e.g. a monorepo whose `android/` and `ios/` declare different values), discovery throws
|
|
27
|
+
* a usage error naming every conflicting source instead of picking one — silently choosing would
|
|
28
|
+
* mean `appduct link` opens the wrong app on whichever platform lost.
|
|
29
|
+
*/
|
|
30
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { firstConfiguredScheme, isValidScheme } from "./scheme.js";
|
|
33
|
+
import { usageError } from "./errors.js";
|
|
34
|
+
// --- shared file-reading caps ---
|
|
35
|
+
/** A `build.gradle`/`AndroidManifest.xml`/`Info.plist`/`project.yml` this large is not a file
|
|
36
|
+
* these probes are meant to read — treated the same as "not found" rather than buffering an
|
|
37
|
+
* arbitrarily large file into memory for a single regex/parse pass. */
|
|
38
|
+
const MAX_PROBE_FILE_BYTES = 2 * 1024 * 1024;
|
|
39
|
+
/** Depth-limited walk cap for the `Info.plist` search: 0 = the app root itself, 1 = its immediate
|
|
40
|
+
* subdirectories, 2 = one level below that (where `MyApp.xcodeproj`'s sibling `Info.plist` and a
|
|
41
|
+
* typical `MyApp/Info.plist` both live). */
|
|
42
|
+
const MAX_PLIST_WALK_DEPTH = 2;
|
|
43
|
+
/** Defensive cap on directory entries visited by the `Info.plist` walk, so a pathological tree
|
|
44
|
+
* (a huge `node_modules` that slipped past the exclusion, a symlink farm) can't turn a static-file
|
|
45
|
+
* probe into an unbounded scan. */
|
|
46
|
+
const MAX_WALK_ENTRIES = 5000;
|
|
47
|
+
/** Directories the `Info.plist` walk never descends into: vendored/build-output trees, not the
|
|
48
|
+
* project's own config, and often enormous. */
|
|
49
|
+
const EXCLUDED_WALK_DIRS = new Set(["Pods", "build", "node_modules", "DerivedData"]);
|
|
50
|
+
/** Reads a file as text, capped at {@link MAX_PROBE_FILE_BYTES}. A missing file, a directory where
|
|
51
|
+
* a file was expected, or a permission error are all "nothing usable here" — a caller falls
|
|
52
|
+
* through to the next probe rather than treating any of these as a hard failure. */
|
|
53
|
+
const readCappedText = async (path) => {
|
|
54
|
+
const buffer = await readCappedBuffer(path);
|
|
55
|
+
if (!buffer.ok) {
|
|
56
|
+
return buffer;
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, text: buffer.data.toString("utf8") };
|
|
59
|
+
};
|
|
60
|
+
const readCappedBuffer = async (path) => {
|
|
61
|
+
let size;
|
|
62
|
+
try {
|
|
63
|
+
const stats = await stat(path);
|
|
64
|
+
if (!stats.isFile()) {
|
|
65
|
+
return { ok: false, reason: "not-found" };
|
|
66
|
+
}
|
|
67
|
+
size = stats.size;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { ok: false, reason: "not-found" };
|
|
71
|
+
}
|
|
72
|
+
if (size > MAX_PROBE_FILE_BYTES) {
|
|
73
|
+
return { ok: false, reason: "too-large" };
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return { ok: true, data: await readFile(path) };
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { ok: false, reason: "unreadable" };
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/** A probe-reported scheme still has to be a legal URI scheme — the same rule every other source
|
|
83
|
+
* in `scheme.ts` enforces, so a typo'd placeholder fails with a clear message instead of composing
|
|
84
|
+
* an unopenable deep link three commands later. */
|
|
85
|
+
const requireValidNativeScheme = (value, origin) => {
|
|
86
|
+
if (!isValidScheme(value)) {
|
|
87
|
+
throw usageError(`Invalid deep-link scheme ${JSON.stringify(value)} from ${origin}: a scheme must start with ` +
|
|
88
|
+
'a letter and contain only letters, digits, "+", "-" or "." (for example "myapp") — do not ' +
|
|
89
|
+
'include "://".');
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
};
|
|
93
|
+
// --- Android: build.gradle(.kts) "appductScheme" placeholder ---
|
|
94
|
+
/**
|
|
95
|
+
* Matches both Groovy DSL (`manifestPlaceholders["appductScheme"] = "myapp"`) and Kotlin DSL
|
|
96
|
+
* (`manifestPlaceholders["appductScheme"] = "myapp"` or the property form
|
|
97
|
+
* `manifestPlaceholders.appductScheme = "myapp"`) — issue #48's sketch shows the bracket form
|
|
98
|
+
* for both, but the property form is common enough in real `build.gradle.kts` files to accept too.
|
|
99
|
+
*/
|
|
100
|
+
const GRADLE_PLACEHOLDER_PATTERN = /manifestPlaceholders\s*(?:\[\s*["']appductScheme["']\s*\]|\.\s*appductScheme)\s*=\s*["']([^"']*)["']/u;
|
|
101
|
+
const extractGradleScheme = (text) => {
|
|
102
|
+
const match = GRADLE_PLACEHOLDER_PATTERN.exec(text);
|
|
103
|
+
const value = match?.[1];
|
|
104
|
+
return value !== undefined && value.length > 0 ? value : undefined;
|
|
105
|
+
};
|
|
106
|
+
const probeAndroidGradle = async (root) => {
|
|
107
|
+
const candidates = [join(root, "app", "build.gradle.kts"), join(root, "app", "build.gradle")];
|
|
108
|
+
const notes = [];
|
|
109
|
+
for (const candidate of candidates) {
|
|
110
|
+
const read = await readCappedText(candidate);
|
|
111
|
+
if (!read.ok) {
|
|
112
|
+
if (read.reason === "too-large") {
|
|
113
|
+
notes.push(`${candidate} (too large, skipped)`);
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const scheme = extractGradleScheme(read.text);
|
|
118
|
+
if (scheme !== undefined) {
|
|
119
|
+
requireValidNativeScheme(scheme, `"appductScheme" in ${candidate}`);
|
|
120
|
+
return {
|
|
121
|
+
result: {
|
|
122
|
+
source: "android-gradle",
|
|
123
|
+
scheme,
|
|
124
|
+
origin: `"appductScheme" in ${candidate}`,
|
|
125
|
+
},
|
|
126
|
+
tried: [describeAndroidGradleTried(candidates, notes)],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { tried: [describeAndroidGradleTried(candidates, notes)] };
|
|
131
|
+
};
|
|
132
|
+
const describeAndroidGradleTried = (candidates, notes) => {
|
|
133
|
+
const base = `${candidates.join(", ")} ("appductScheme" manifest placeholder)`;
|
|
134
|
+
return notes.length === 0 ? base : `${base} — ${notes.join(", ")}`;
|
|
135
|
+
};
|
|
136
|
+
// --- Android: AndroidManifest.xml <data android:scheme> in a VIEW intent filter ---
|
|
137
|
+
const INTENT_FILTER_PATTERN = /<intent-filter\b[^>]*>([\s\S]*?)<\/intent-filter>/giu;
|
|
138
|
+
const VIEW_ACTION_PATTERN = /<action\b[^>]*\bandroid:name\s*=\s*["']android\.intent\.action\.VIEW["']/iu;
|
|
139
|
+
const DATA_SCHEME_PATTERN = /<data\b[^>]*\bandroid:scheme\s*=\s*["']([^"']*)["']/iu;
|
|
140
|
+
/** The first `<data android:scheme>` inside an intent filter that also declares
|
|
141
|
+
* `android.intent.action.VIEW` — a filter for some other action (`SEND`, `MAIN`, ...) is not a
|
|
142
|
+
* deep-link scheme declaration and is skipped even if it happens to carry a `<data>` tag. */
|
|
143
|
+
const extractManifestScheme = (xml) => {
|
|
144
|
+
for (const match of xml.matchAll(INTENT_FILTER_PATTERN)) {
|
|
145
|
+
const block = match[1] ?? "";
|
|
146
|
+
if (!VIEW_ACTION_PATTERN.test(block)) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const dataMatch = DATA_SCHEME_PATTERN.exec(block);
|
|
150
|
+
const scheme = dataMatch?.[1];
|
|
151
|
+
if (scheme !== undefined && scheme.length > 0) {
|
|
152
|
+
return scheme;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
};
|
|
157
|
+
const probeAndroidManifest = async (root) => {
|
|
158
|
+
const manifestPath = join(root, "app", "src", "main", "AndroidManifest.xml");
|
|
159
|
+
const read = await readCappedText(manifestPath);
|
|
160
|
+
const description = `${manifestPath} (first <data android:scheme> in a VIEW intent-filter)`;
|
|
161
|
+
if (!read.ok) {
|
|
162
|
+
return {
|
|
163
|
+
tried: [read.reason === "too-large" ? `${description} — too large, skipped` : description],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const scheme = extractManifestScheme(read.text);
|
|
167
|
+
if (scheme === undefined) {
|
|
168
|
+
return { tried: [description] };
|
|
169
|
+
}
|
|
170
|
+
requireValidNativeScheme(scheme, `"android:scheme" in ${manifestPath}`);
|
|
171
|
+
return {
|
|
172
|
+
result: {
|
|
173
|
+
source: "android-manifest",
|
|
174
|
+
scheme,
|
|
175
|
+
origin: `"android:scheme" in ${manifestPath}`,
|
|
176
|
+
},
|
|
177
|
+
tried: [description],
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
const XML_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
181
|
+
const decodeXmlEntities = (text) => {
|
|
182
|
+
return text.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/gu, (whole, entity) => {
|
|
183
|
+
if (entity.startsWith("#x")) {
|
|
184
|
+
const code = Number.parseInt(entity.slice(2), 16);
|
|
185
|
+
return Number.isNaN(code) ? whole : String.fromCodePoint(code);
|
|
186
|
+
}
|
|
187
|
+
if (entity.startsWith("#")) {
|
|
188
|
+
const code = Number.parseInt(entity.slice(1), 10);
|
|
189
|
+
return Number.isNaN(code) ? whole : String.fromCodePoint(code);
|
|
190
|
+
}
|
|
191
|
+
return XML_ENTITIES[entity] ?? whole;
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* Turns an XML plist into a tree of tags — deliberately not a general/validating XML parser (no
|
|
196
|
+
* DTD, no namespaces, no attribute parsing beyond stripping them): plist's grammar is small and
|
|
197
|
+
* fixed (`dict`/`array`/`key`/`string`/`integer`/`real`/`true`/`false`/`date`/`data`), and being
|
|
198
|
+
* tolerant of the ways a hand-written or generated `Info.plist` can still deviate slightly (a
|
|
199
|
+
* stray unescaped `&`, mismatched nesting) matters more here than rejecting a technically-invalid
|
|
200
|
+
* document.
|
|
201
|
+
*/
|
|
202
|
+
const tokenizePlistXml = (xml) => {
|
|
203
|
+
const cleaned = xml
|
|
204
|
+
.replace(/<\?[\s\S]*?\?>/gu, "")
|
|
205
|
+
.replace(/<!DOCTYPE[\s\S]*?>/giu, "")
|
|
206
|
+
.replace(/<!--[\s\S]*?-->/gu, "");
|
|
207
|
+
const root = { tag: "#root", children: [], text: "" };
|
|
208
|
+
const stack = [root];
|
|
209
|
+
const tagOrTextPattern = /<(\/?)([a-zA-Z][\w.:-]*)\b[^>]*?(\/?)>|([^<]+)/gu;
|
|
210
|
+
for (const match of cleaned.matchAll(tagOrTextPattern)) {
|
|
211
|
+
const [, closing, name, selfClosing, text] = match;
|
|
212
|
+
// `stack` always has `root` at index 0 and is only ever popped down to length 1, so the top
|
|
213
|
+
// is never actually undefined — the non-null assertion just satisfies noUncheckedIndexedAccess.
|
|
214
|
+
const top = stack[stack.length - 1];
|
|
215
|
+
if (text !== undefined) {
|
|
216
|
+
top.text += decodeXmlEntities(text);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (closing) {
|
|
220
|
+
if (stack.length > 1) {
|
|
221
|
+
stack.pop();
|
|
222
|
+
}
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const node = { tag: name ?? "", children: [], text: "" };
|
|
226
|
+
top.children.push(node);
|
|
227
|
+
if (!selfClosing) {
|
|
228
|
+
stack.push(node);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return root;
|
|
232
|
+
};
|
|
233
|
+
const plistNodeToValue = (node) => {
|
|
234
|
+
switch (node.tag) {
|
|
235
|
+
case "true":
|
|
236
|
+
return true;
|
|
237
|
+
case "false":
|
|
238
|
+
return false;
|
|
239
|
+
case "integer": {
|
|
240
|
+
const value = Number.parseInt(node.text.trim(), 10);
|
|
241
|
+
return Number.isNaN(value) ? node.text.trim() : value;
|
|
242
|
+
}
|
|
243
|
+
case "real": {
|
|
244
|
+
const value = Number.parseFloat(node.text.trim());
|
|
245
|
+
return Number.isNaN(value) ? node.text.trim() : value;
|
|
246
|
+
}
|
|
247
|
+
case "array":
|
|
248
|
+
return node.children.map(plistNodeToValue);
|
|
249
|
+
case "dict": {
|
|
250
|
+
const result = {};
|
|
251
|
+
for (let index = 0; index < node.children.length; index += 2) {
|
|
252
|
+
const keyNode = node.children[index];
|
|
253
|
+
const valueNode = node.children[index + 1];
|
|
254
|
+
if (keyNode?.tag === "key" && valueNode !== undefined) {
|
|
255
|
+
result[keyNode.text.trim()] = plistNodeToValue(valueNode);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
default:
|
|
261
|
+
// "string", and every leaf type this probe has no specific use for (data, date): the text
|
|
262
|
+
// content is the closest thing to a value, and is simply never read for those tags.
|
|
263
|
+
return node.text.trim();
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
/** `undefined` for anything that isn't a well-formed `<plist>…</plist>` document (including a
|
|
267
|
+
* binary plist, whose bytes tokenize into garbage tag names rather than throwing). */
|
|
268
|
+
const parsePlistXml = (xml) => {
|
|
269
|
+
const root = tokenizePlistXml(xml);
|
|
270
|
+
const plistNode = root.children.find((child) => child.tag === "plist");
|
|
271
|
+
const valueNode = plistNode?.children[0];
|
|
272
|
+
return valueNode ? plistNodeToValue(valueNode) : undefined;
|
|
273
|
+
};
|
|
274
|
+
/** Apple's magic number for a binary-encoded plist (`bplist00`/`bplist01`/...) — checked on the
|
|
275
|
+
* raw bytes before any text decoding is attempted, since a binary plist's bytes are not valid
|
|
276
|
+
* UTF-8 and decoding them first would just produce mangled tag soup instead of a clean signal. */
|
|
277
|
+
const isBinaryPlist = (buffer) => {
|
|
278
|
+
return buffer.length >= 6 && buffer.subarray(0, 6).toString("latin1") === "bplist";
|
|
279
|
+
};
|
|
280
|
+
/** The first non-empty `CFBundleURLSchemes` entry across every `CFBundleURLTypes` dict, in array
|
|
281
|
+
* order — mirrors `firstConfiguredScheme`'s "first entry wins" rule, extended to "first dict that
|
|
282
|
+
* actually has one wins" since an app can declare more than one `CFBundleURLTypes` entry (custom
|
|
283
|
+
* scheme plus a universal-link-only entry with no `CFBundleURLSchemes` at all, say). */
|
|
284
|
+
const firstPlistUrlScheme = (cfBundleURLTypes) => {
|
|
285
|
+
if (!Array.isArray(cfBundleURLTypes)) {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
for (const entry of cfBundleURLTypes) {
|
|
289
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const scheme = firstConfiguredScheme(entry.CFBundleURLSchemes);
|
|
293
|
+
if (scheme !== undefined) {
|
|
294
|
+
return scheme;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return undefined;
|
|
298
|
+
};
|
|
299
|
+
/**
|
|
300
|
+
* Every file named `filename` from `root` down to `maxDepth` levels, nearest first (ties broken by
|
|
301
|
+
* path) — the deterministic order that decides which `Info.plist` is "the first one" when several
|
|
302
|
+
* exist. Symlinks are never followed (matches `artifact-inspect.ts`'s `readAppDirectory`, same
|
|
303
|
+
* reasoning: avoids loops, and a plist worth discovering is never reached only through one).
|
|
304
|
+
*/
|
|
305
|
+
const walkForFilename = async (root, filename, maxDepth) => {
|
|
306
|
+
const hits = [];
|
|
307
|
+
let visited = 0;
|
|
308
|
+
const walk = async (dir, depth) => {
|
|
309
|
+
let entries;
|
|
310
|
+
try {
|
|
311
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
for (const entry of entries) {
|
|
317
|
+
if (visited >= MAX_WALK_ENTRIES) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
visited += 1;
|
|
321
|
+
if (entry.isSymbolicLink()) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (entry.isDirectory()) {
|
|
325
|
+
if (EXCLUDED_WALK_DIRS.has(entry.name)) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (depth < maxDepth) {
|
|
329
|
+
await walk(join(dir, entry.name), depth + 1);
|
|
330
|
+
}
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (entry.isFile() && entry.name === filename) {
|
|
334
|
+
hits.push({ path: join(dir, entry.name), depth });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
await walk(root, 0);
|
|
339
|
+
hits.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path));
|
|
340
|
+
return hits;
|
|
341
|
+
};
|
|
342
|
+
const probeIosInfoPlist = async (root) => {
|
|
343
|
+
const files = await walkForFilename(root, "Info.plist", MAX_PLIST_WALK_DEPTH);
|
|
344
|
+
const describeSearch = (notes) => {
|
|
345
|
+
const base = files.length === 0
|
|
346
|
+
? `Info.plist up to ${MAX_PLIST_WALK_DEPTH} levels below ${root} (excluding Pods/build/` +
|
|
347
|
+
"node_modules/DerivedData) — none found"
|
|
348
|
+
: `${files.map((file) => file.path).join(", ")} (first "CFBundleURLSchemes" entry)`;
|
|
349
|
+
return notes.length === 0 ? base : `${base} — ${notes.join(", ")}`;
|
|
350
|
+
};
|
|
351
|
+
const notes = [];
|
|
352
|
+
for (const file of files) {
|
|
353
|
+
const read = await readCappedBuffer(file.path);
|
|
354
|
+
if (!read.ok) {
|
|
355
|
+
notes.push(`${file.path} (${read.reason === "too-large" ? "too large, skipped" : "unreadable"})`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (isBinaryPlist(read.data)) {
|
|
359
|
+
notes.push(`${file.path} (binary plist — unreadable, use --scheme)`);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const value = parsePlistXml(read.data.toString("utf8"));
|
|
363
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const scheme = firstPlistUrlScheme(value.CFBundleURLTypes);
|
|
367
|
+
if (scheme === undefined) {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
requireValidNativeScheme(scheme, `"CFBundleURLSchemes" in ${file.path}`);
|
|
371
|
+
return {
|
|
372
|
+
result: {
|
|
373
|
+
source: "ios-info-plist",
|
|
374
|
+
scheme,
|
|
375
|
+
origin: `"CFBundleURLSchemes" in ${file.path}`,
|
|
376
|
+
},
|
|
377
|
+
tried: [describeSearch(notes)],
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return { tried: [describeSearch(notes)] };
|
|
381
|
+
};
|
|
382
|
+
// --- iOS: xcodegen's project.yml (info.properties.CFBundleURLTypes) ---
|
|
383
|
+
/**
|
|
384
|
+
* Regex-based rather than a general YAML parser: `project.yml`'s `CFBundleURLTypes` shape is
|
|
385
|
+
* exactly the plist one (an array of dicts, each with a `CFBundleURLSchemes` array or scalar), and
|
|
386
|
+
* a full YAML grammar (anchors, flow vs. block collections, multiline scalars) is far more than
|
|
387
|
+
* this one key needs. Matches both the inline flow form (`CFBundleURLSchemes: [myapp]`) and the
|
|
388
|
+
* block sequence form (`CFBundleURLSchemes:\n - myapp`), quoted or not.
|
|
389
|
+
*
|
|
390
|
+
* Deliberately *not* restricted to the legal scheme character set here (unlike the plist/Expo
|
|
391
|
+
* path, which parses the value as a generic string first): capturing everything up to the
|
|
392
|
+
* unambiguous YAML boundary (`]`, `,`, a quote, or the end of the line) and letting
|
|
393
|
+
* {@link requireValidNativeScheme} reject it is what lets a genuinely malformed value (say,
|
|
394
|
+
* `myapp://`) surface the same "invalid deep-link scheme" error the other three probes give,
|
|
395
|
+
* instead of silently truncating it into something that looks valid.
|
|
396
|
+
*/
|
|
397
|
+
const PROJECT_YML_SCHEME_PATTERN = /CFBundleURLSchemes\s*:\s*(?:\[\s*["']?([^"'\],\r\n]*)["']?|\r?\n[ \t]*-[ \t]*["']?([^"'\r\n]*)["']?)/u;
|
|
398
|
+
const extractProjectYmlScheme = (text) => {
|
|
399
|
+
const match = PROJECT_YML_SCHEME_PATTERN.exec(text);
|
|
400
|
+
const raw = (match?.[1] ?? match?.[2])?.trim();
|
|
401
|
+
return raw !== undefined && raw.length > 0 ? raw : undefined;
|
|
402
|
+
};
|
|
403
|
+
const probeIosProjectYml = async (root) => {
|
|
404
|
+
const path = join(root, "project.yml");
|
|
405
|
+
const description = `${path} ("info.properties.CFBundleURLTypes" > "CFBundleURLSchemes")`;
|
|
406
|
+
const read = await readCappedText(path);
|
|
407
|
+
if (!read.ok) {
|
|
408
|
+
return {
|
|
409
|
+
tried: [read.reason === "too-large" ? `${description} — too large, skipped` : description],
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const scheme = extractProjectYmlScheme(read.text);
|
|
413
|
+
if (scheme === undefined) {
|
|
414
|
+
return { tried: [description] };
|
|
415
|
+
}
|
|
416
|
+
requireValidNativeScheme(scheme, `"CFBundleURLSchemes" in ${path}`);
|
|
417
|
+
return {
|
|
418
|
+
result: { source: "ios-project-yml", scheme, origin: `"CFBundleURLSchemes" in ${path}` },
|
|
419
|
+
tried: [description],
|
|
420
|
+
};
|
|
421
|
+
};
|
|
422
|
+
// --- orchestration ---
|
|
423
|
+
/** In the order the file-level doc comment (and issue #48) describes: Android before iOS, and
|
|
424
|
+
* within each platform the build config before the manifest/project file that mirrors it. */
|
|
425
|
+
const PROBES = [
|
|
426
|
+
probeAndroidGradle,
|
|
427
|
+
probeAndroidManifest,
|
|
428
|
+
probeIosInfoPlist,
|
|
429
|
+
probeIosProjectYml,
|
|
430
|
+
];
|
|
431
|
+
/**
|
|
432
|
+
* Runs every static-file probe against `root` (never walking up — same rule as `app.json`) and
|
|
433
|
+
* decides the result:
|
|
434
|
+
*
|
|
435
|
+
* - No probe resolves a scheme: `result` is `undefined`.
|
|
436
|
+
* - Every probe that resolves one agrees: that scheme wins, attributed to whichever probe ran
|
|
437
|
+
* first (the order in {@link PROBES}).
|
|
438
|
+
* - Two probes resolve *different* schemes: throws a usage error naming both sources and their
|
|
439
|
+
* values, since guessing which one is right would silently target the wrong app on one
|
|
440
|
+
* platform.
|
|
441
|
+
*
|
|
442
|
+
* `tried` always has one entry per probe, hit or miss, so a "no scheme anywhere" error can list
|
|
443
|
+
* every location this looked without the caller needing to know which probes exist.
|
|
444
|
+
*/
|
|
445
|
+
export const discoverNativeScheme = async (root) => {
|
|
446
|
+
const tried = [];
|
|
447
|
+
const results = [];
|
|
448
|
+
for (const probe of PROBES) {
|
|
449
|
+
const outcome = await probe(root);
|
|
450
|
+
tried.push(...outcome.tried);
|
|
451
|
+
if (outcome.result !== undefined) {
|
|
452
|
+
results.push(outcome.result);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (results.length === 0) {
|
|
456
|
+
return { tried };
|
|
457
|
+
}
|
|
458
|
+
const distinctSchemes = new Set(results.map((entry) => entry.scheme));
|
|
459
|
+
if (distinctSchemes.size > 1) {
|
|
460
|
+
throw usageError("Conflicting deep-link schemes found in this project: " +
|
|
461
|
+
results.map((entry) => `${entry.origin} says ${JSON.stringify(entry.scheme)}`).join(", but ") +
|
|
462
|
+
". Appduct will not guess which one is right — pass --scheme <scheme> to pick one.");
|
|
463
|
+
}
|
|
464
|
+
return { result: results[0], tried };
|
|
465
|
+
};
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type EventNotification } from "@appduct/shared";
|
|
2
|
+
import type { CliResult } from "./cli/result-types.js";
|
|
3
|
+
export type RenderOptions = {
|
|
4
|
+
command: string;
|
|
5
|
+
json: boolean;
|
|
6
|
+
color: boolean;
|
|
7
|
+
/** `link`-only: also render the deep link as terminal QR art (never affects `--json` output). */
|
|
8
|
+
qr?: boolean;
|
|
9
|
+
/** `tools`-only: render full schemas/annotations for every listed tool, not just name+description. */
|
|
10
|
+
full?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export declare const renderResult: (result: CliResult<unknown>, options: RenderOptions) => {
|
|
13
|
+
stdout?: string;
|
|
14
|
+
stderr?: string;
|
|
15
|
+
};
|
|
16
|
+
/** Renders one `appduct events` line: NDJSON under `--json`, a compact human line otherwise. */
|
|
17
|
+
export declare const renderEventLine: (event: EventNotification, options: {
|
|
18
|
+
json: boolean;
|
|
19
|
+
color: boolean;
|
|
20
|
+
}) => string;
|
|
21
|
+
/** Renders the trailing cursor line for `appduct events --since` (issue #6): NDJSON under
|
|
22
|
+
* `--json` so a scripted caller can parse the resume point without maxing `seq` over the printed
|
|
23
|
+
* events (impossible when the response is empty), a human note otherwise. */
|
|
24
|
+
export declare const renderEventsCursorLine: (cursor: number, options: {
|
|
25
|
+
json: boolean;
|
|
26
|
+
color: boolean;
|
|
27
|
+
}) => string;
|
|
28
|
+
//# sourceMappingURL=output.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AACA,OAAO,EAA2B,KAAK,iBAAiB,EAA4C,MAAM,iBAAiB,CAAC;AAE5H,OAAO,KAAK,EAEV,SAAS,EAcV,MAAM,uBAAuB,CAAC;AAK/B,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,iGAAiG;IACjG,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,sGAAsG;IACtG,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,CAAC;AAmWF,eAAO,MAAM,YAAY,GACvB,QAAQ,SAAS,CAAC,OAAO,CAAC,EAC1B,SAAS,aAAa,KACrB;IACD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CA+BjB,CAAC;AAEF,gGAAgG;AAChG,eAAO,MAAM,eAAe,GAC1B,OAAO,iBAAiB,EACxB,SAAS;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,KACzC,MAWF,CAAC;AAEF;;6EAE6E;AAC7E,eAAO,MAAM,sBAAsB,GAAI,QAAQ,MAAM,EAAE,SAAS;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,KAAG,MAOnG,CAAC"}
|