deeplink-parity 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,16 @@
1
1
  # deeplink-parity
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/deeplink-parity.svg)](https://www.npmjs.com/package/deeplink-parity)
4
+ [![CI](https://github.com/camosss/deeplink-parity/actions/workflows/ci.yml/badge.svg)](https://github.com/camosss/deeplink-parity/actions/workflows/ci.yml)
5
+ ![Node](https://img.shields.io/badge/Node-18%2B-brightgreen.svg)
6
+ ![Platform](https://img.shields.io/badge/Platform-iOS%20%7C%20Android-lightgrey.svg)
7
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
+
3
9
  Checks that what your app **declares** about deep links matches what is actually **hosted** — on both iOS and Android, from one command.
4
10
 
5
11
  ```bash
6
- npx deeplink-parity .
12
+ # iOS and Android usually live in separate repositories — pass both
13
+ npx deeplink-parity ./my-app-ios ./my-app-android
7
14
  ```
8
15
 
9
16
  ```
@@ -70,13 +77,26 @@ No configuration file. Domains are discovered from your app, then the matching w
70
77
  ## Usage
71
78
 
72
79
  ```bash
73
- npx deeplink-parity [path] [options]
80
+ npx deeplink-parity [path...] [options]
74
81
 
75
82
  --sha256 <fingerprint> Android signing fingerprint to look for in assetlinks.json
76
83
  --well-known <dir> Read well-known files from <dir>/<domain>/ instead of the network
77
84
  --json Machine-readable output
78
85
  ```
79
86
 
87
+ Pass one path per checkout. A monorepo holding both platforms works as a single path,
88
+ two repositories work as two paths, and a directory of symlinks to each checkout works
89
+ too — the scan follows them.
90
+
91
+ ```bash
92
+ npx deeplink-parity . # one repo
93
+ npx deeplink-parity ../app-ios ../app-android # two repos
94
+ npx deeplink-parity . --sha256 "AB:CD:…" # include the fingerprint check
95
+ ```
96
+
97
+ The Android signing fingerprint comes from Play Console → App integrity → App signing.
98
+ Without it the fingerprint check is skipped and reported as such.
99
+
80
100
  Exits `1` when there is at least one error, so it drops into CI unchanged.
81
101
 
82
102
  ### Run it on a schedule — this is the point
@@ -139,4 +159,4 @@ values, and unresolvable references.
139
159
 
140
160
  ## License
141
161
 
142
- MIT
162
+ `deeplink-parity` is released under an MIT license. See [License](LICENSE) for more information.
package/dist/cli.js CHANGED
@@ -7,7 +7,12 @@ const USAGE = `deeplink-parity — check that what your app declares about deep
7
7
  matches what is actually hosted, across iOS and Android.
8
8
 
9
9
  Usage
10
- deeplink-parity [path] [options]
10
+ deeplink-parity [path...] [options]
11
+
12
+ Pass one path per checkout. iOS and Android usually live in separate
13
+ repositories, and comparing them is the point:
14
+
15
+ deeplink-parity ./my-app-ios ./my-app-android
11
16
 
12
17
  Options
13
18
  --sha256 <fingerprint> Android signing fingerprint to look for in assetlinks.json
@@ -17,7 +22,7 @@ Options
17
22
  `;
18
23
  function parseArgs(argv) {
19
24
  const args = argv.slice(2);
20
- let root = '.';
25
+ const roots = [];
21
26
  let json = false;
22
27
  let help = false;
23
28
  let sha256;
@@ -33,20 +38,26 @@ function parseArgs(argv) {
33
38
  else if (arg === '--well-known')
34
39
  wellKnown = args[++i];
35
40
  else if (!arg.startsWith('-'))
36
- root = arg;
41
+ roots.push(arg);
37
42
  }
38
- return { root: resolve(root), json, help, sha256, wellKnown };
43
+ return {
44
+ roots: (roots.length ? roots : ['.']).map((r) => resolve(r)),
45
+ json,
46
+ help,
47
+ sha256,
48
+ wellKnown,
49
+ };
39
50
  }
40
51
  async function main() {
41
- const { root, json, help, sha256, wellKnown } = parseArgs(process.argv);
52
+ const { roots, json, help, sha256, wellKnown } = parseArgs(process.argv);
42
53
  if (help) {
43
54
  console.log(USAGE);
44
55
  return;
45
56
  }
46
57
  const source = wellKnown ? localSource(resolve(wellKnown)) : networkSource();
47
- const result = await run({ root, source, sha256 });
58
+ const result = await run({ roots, source, sha256 });
48
59
  if (result.iosApps.length === 0 && result.androidApps.length === 0) {
49
- console.error(`No app configuration declaring deep links was found in ${root}`);
60
+ console.error(`No app configuration declaring deep links was found in ${roots.join(', ')}`);
50
61
  console.error('Expected a .entitlements file with applinks:, or an AndroidManifest.xml with intent-filters.');
51
62
  process.exit(2);
52
63
  }
@@ -1,8 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { relative } from 'node:path';
3
2
  import { XMLParser } from 'fast-xml-parser';
4
3
  import { emptyIndex, indexProperties, indexResValues, indexStrings, resolveRef, } from '../resolve/androidResources.js';
5
- import { walk } from './walk.js';
4
+ import { displayPath, walk } from './walk.js';
6
5
  const VIEW_ACTION = 'android.intent.action.VIEW';
7
6
  const BROWSABLE = 'android.intent.category.BROWSABLE';
8
7
  /** fast-xml-parser hands back a single object when an element occurs once. */
@@ -30,12 +29,26 @@ function collectIntentFilters(node, out) {
30
29
  collectIntentFilters(child, out);
31
30
  }
32
31
  }
32
+ /**
33
+ * A build type or flavor can append to the base id with `applicationIdSuffix`, and the
34
+ * dev variant is usually what a dev domain's assetlinks file names. Suffixes are applied
35
+ * to every base id so that both `com.example` and `com.example.dev` count as ours.
36
+ */
33
37
  function applicationIds(gradleSources) {
34
- const ids = new Set();
38
+ const bases = new Set();
39
+ const suffixes = new Set();
35
40
  for (const src of gradleSources) {
36
- for (const m of src.matchAll(/applicationId\s*(?:=|\s)\s*["']([^"']+)["']/g)) {
37
- ids.add(m[1]);
41
+ for (const m of src.matchAll(/\bapplicationId\s*(?:=|\s)\s*["']([^"']+)["']/g)) {
42
+ bases.add(m[1]);
38
43
  }
44
+ for (const m of src.matchAll(/\bapplicationIdSuffix\s*(?:=|\s)\s*["']([^"']+)["']/g)) {
45
+ suffixes.add(m[1]);
46
+ }
47
+ }
48
+ const ids = new Set(bases);
49
+ for (const base of bases) {
50
+ for (const suffix of suffixes)
51
+ ids.add(`${base}${suffix}`);
39
52
  }
40
53
  return [...ids];
41
54
  }
@@ -123,7 +136,7 @@ export async function discoverAndroid(root) {
123
136
  if (hosts.size === 0 && unresolved.length === 0)
124
137
  continue;
125
138
  apps.push({
126
- manifestPath: relative(root, manifestPath),
139
+ manifestPath: displayPath(manifestPath),
127
140
  packageIds,
128
141
  hosts: [...hosts.values()],
129
142
  unresolved,
@@ -1,7 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { relative } from 'node:path';
3
2
  import plist from 'plist';
4
- import { walk } from './walk.js';
3
+ import { displayPath, walk } from './walk.js';
5
4
  /**
6
5
  * Associated Domains entries look like `applinks:example.com`. A domain may carry
7
6
  * query-parameter options (`?mode=developer`) which are not part of the host.
@@ -52,6 +51,23 @@ function signingByEntitlements(pbxproj) {
52
51
  }
53
52
  return map;
54
53
  }
54
+ /**
55
+ * Pick the target whose recorded entitlements path is the longest tail of this file's
56
+ * path. Longest wins so that `Widget/Widget.entitlements` never matches the app when
57
+ * `App/App.entitlements` is also on file.
58
+ */
59
+ function matchSigning(path, signing) {
60
+ let best = {};
61
+ let bestLength = -1;
62
+ for (const [key, value] of signing) {
63
+ const tail = key.startsWith('/') ? key : `/${key}`;
64
+ if (path.endsWith(tail) && key.length > bestLength) {
65
+ best = value;
66
+ bestLength = key.length;
67
+ }
68
+ }
69
+ return best;
70
+ }
55
71
  export async function discoverIos(root) {
56
72
  const entitlementFiles = await walk(root, (n) => n.endsWith('.entitlements'));
57
73
  if (entitlementFiles.length === 0)
@@ -77,11 +93,12 @@ export async function discoverIos(root) {
77
93
  const domains = parseAssociatedDomains(dict['com.apple.developer.associated-domains']);
78
94
  if (domains.length === 0)
79
95
  continue;
80
- // pbxproj records the entitlements path relative to SOURCE_ROOT
81
- const rel = relative(root, path);
82
- const target = signing.get(rel) ?? {};
96
+ // pbxproj records the entitlements path relative to SOURCE_ROOT, which is not
97
+ // necessarily the directory being scanned — match on the tail so that pointing at
98
+ // a repository root or at a subdirectory both resolve the same target.
99
+ const target = matchSigning(path, signing);
83
100
  apps.push({
84
- entitlementsPath: rel,
101
+ entitlementsPath: displayPath(path),
85
102
  domains,
86
103
  bundleId: target.bundleId,
87
104
  teamId: target.teamId,
@@ -1,5 +1,13 @@
1
- import { readdir } from 'node:fs/promises';
2
- import { join } from 'node:path';
1
+ import { readdir, realpath, stat } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ /**
4
+ * Paths are shown relative to the working directory rather than to the scanned root,
5
+ * so that a run covering two checkouts stays unambiguous.
6
+ */
7
+ export function displayPath(absolute) {
8
+ const rel = relative(process.cwd(), absolute);
9
+ return rel.startsWith('..') ? absolute : rel;
10
+ }
3
11
  const SKIP_DIRS = new Set([
4
12
  'node_modules',
5
13
  'Pods',
@@ -12,8 +20,23 @@ const SKIP_DIRS = new Set([
12
20
  '.gradle',
13
21
  '.idea',
14
22
  ]);
15
- /** Depth-first file scan that skips dependency and build output directories. */
16
- export async function walk(dir, match, out = []) {
23
+ /**
24
+ * Depth-first file scan that skips dependency and build output directories.
25
+ *
26
+ * Symlinked directories are followed, so a folder of links to several checkouts works
27
+ * as a single root. Real paths are tracked to stop a link cycle from recursing forever.
28
+ */
29
+ export async function walk(dir, match, out = [], seen = new Set()) {
30
+ let here;
31
+ try {
32
+ here = await realpath(dir);
33
+ }
34
+ catch {
35
+ return out;
36
+ }
37
+ if (seen.has(here))
38
+ return out;
39
+ seen.add(here);
17
40
  let entries;
18
41
  try {
19
42
  entries = await readdir(dir, { withFileTypes: true });
@@ -22,14 +45,24 @@ export async function walk(dir, match, out = []) {
22
45
  return out;
23
46
  }
24
47
  for (const entry of entries) {
25
- if (entry.isDirectory()) {
48
+ const path = join(dir, entry.name);
49
+ let isDir = entry.isDirectory();
50
+ if (entry.isSymbolicLink()) {
51
+ try {
52
+ isDir = (await stat(path)).isDirectory();
53
+ }
54
+ catch {
55
+ continue; // broken link
56
+ }
57
+ }
58
+ if (isDir) {
26
59
  if (SKIP_DIRS.has(entry.name))
27
60
  continue;
28
61
  // .xcodeproj is a directory but holds project.pbxproj
29
- await walk(join(dir, entry.name), match, out);
62
+ await walk(path, match, out, seen);
30
63
  }
31
64
  else if (match(entry.name)) {
32
- out.push(join(dir, entry.name));
65
+ out.push(path);
33
66
  }
34
67
  }
35
68
  return out;
package/dist/run.js CHANGED
@@ -7,8 +7,10 @@ import { isWildcardDomain, wildcardFinding } from './rules/wildcard.js';
7
7
  function emptyView() {
8
8
  return { domains: new Set(), paths: new Map() };
9
9
  }
10
- export async function run({ root, source, sha256 }) {
11
- const [iosApps, androidApps] = await Promise.all([discoverIos(root), discoverAndroid(root)]);
10
+ export async function run({ roots, source, sha256 }) {
11
+ const discovered = await Promise.all(roots.map(async (root) => Promise.all([discoverIos(root), discoverAndroid(root)])));
12
+ const iosApps = discovered.flatMap(([ios]) => ios);
13
+ const androidApps = discovered.flatMap(([, android]) => android);
12
14
  const findings = [];
13
15
  const ios = emptyView();
14
16
  const android = emptyView();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deeplink-parity",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Checks that what your mobile app declares about deep links matches what is actually hosted — across iOS and Android.",
5
5
  "type": "module",
6
6
  "bin": {