expo-variant-check 0.1.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/README.md +70 -0
- package/package.json +33 -0
- package/src/checks/channel-environment-mismatch.js +40 -0
- package/src/checks/identity-collision.js +54 -0
- package/src/checks/scheme-collision.js +43 -0
- package/src/checks/stale-native-project.js +67 -0
- package/src/cli.js +62 -0
- package/src/index.js +6 -0
- package/src/report.js +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# expo-variant-check
|
|
2
|
+
|
|
3
|
+
Catches the ways an Expo/EAS app-variant setup (development / preview /
|
|
4
|
+
production, each installable side by side) silently drifts out of sync.
|
|
5
|
+
Read-only — it never writes to your project, so it's safe to run in CI, a
|
|
6
|
+
pre-commit hook, or just by hand before a build.
|
|
7
|
+
|
|
8
|
+
## What it checks
|
|
9
|
+
|
|
10
|
+
For every profile in `eas.json`, the resolved config is evaluated the same
|
|
11
|
+
way EAS Build would evaluate it (by setting that profile's env vars and
|
|
12
|
+
running `expo config --json`), then cross-checked for:
|
|
13
|
+
|
|
14
|
+
- **`identity-collision`** — two or more profiles resolving to the same iOS
|
|
15
|
+
`bundleIdentifier` or Android `package`. Variants sharing an identity
|
|
16
|
+
overwrite each other on install instead of coexisting.
|
|
17
|
+
- **`scheme-collision`** — more than one profile registering the
|
|
18
|
+
`expo-dev-client` generated deep link scheme. When two variants answer the
|
|
19
|
+
same scheme, the QR code from `expo start` can open the wrong installed
|
|
20
|
+
app.
|
|
21
|
+
- **`channel-environment-mismatch`** — an EAS Update `channel` and
|
|
22
|
+
`environment` that are missing or named differently on the same profile.
|
|
23
|
+
This doesn't error at publish time — it silently ships the wrong
|
|
24
|
+
environment's values to a channel real users may already be on.
|
|
25
|
+
- **`stale-native-project`** *(local only)* — a locally prebuilt
|
|
26
|
+
`android/`/`ios/` project whose identity doesn't match what the
|
|
27
|
+
currently-set `APP_VARIANT` resolves to, meaning `expo start` is pointing
|
|
28
|
+
at the wrong native project.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
npx expo-variant-check
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Run it from your Expo project root, wherever `eas.json` lives. It exits `1`
|
|
37
|
+
if any check reports an error, `0` otherwise (warnings alone don't fail the
|
|
38
|
+
run).
|
|
39
|
+
|
|
40
|
+
### In CI
|
|
41
|
+
|
|
42
|
+
Add this to an Expo project's own repo (not this one — this repo has no
|
|
43
|
+
`eas.json` of its own, since it *is* the tool):
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
# .github/workflows/variant-check.yml
|
|
47
|
+
on: [pull_request]
|
|
48
|
+
jobs:
|
|
49
|
+
check:
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/checkout@v4
|
|
53
|
+
- uses: actions/setup-node@v4
|
|
54
|
+
with:
|
|
55
|
+
node-version: 20
|
|
56
|
+
- run: npm ci
|
|
57
|
+
- run: npx expo-variant-check
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## What it doesn't do
|
|
61
|
+
|
|
62
|
+
It can't register your app's identifiers with external services — Firebase,
|
|
63
|
+
Google Maps, push certificates, and similar still need manual per-variant
|
|
64
|
+
setup. It also can't validate EAS-hosted environment variables (`eas env:list`)
|
|
65
|
+
yet, since that needs an authenticated API call; today it only checks the
|
|
66
|
+
inline `env` block and the `channel`/`environment` fields in `eas.json`.
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "expo-variant-check",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Catches silent drift between Expo/EAS app variants before it ships: colliding deep link schemes, mismatched EAS Update channel/environment pairs, stale local native projects, and duplicate bundle identifiers. Read-only — safe for CI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"expo-variant-check": "./src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test test/*.test.js"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@expo-variant-tools/core": "0.1.0"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"expo",
|
|
21
|
+
"eas",
|
|
22
|
+
"eas-build",
|
|
23
|
+
"eas-update",
|
|
24
|
+
"app-variants",
|
|
25
|
+
"lint",
|
|
26
|
+
"doctor",
|
|
27
|
+
"ci"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags build profiles where `channel` and `environment` are inconsistent:
|
|
3
|
+
* set to different names, or only one of the two is present. A mismatch
|
|
4
|
+
* here doesn't error at build or publish time — it silently ships an
|
|
5
|
+
* update carrying the wrong environment's values to a channel real users
|
|
6
|
+
* may already be on.
|
|
7
|
+
*
|
|
8
|
+
* @param {{ name: string, channel: string|null, environment: string|null }[]} profiles
|
|
9
|
+
* @returns {Array<{level: string, check: string, message: string}>}
|
|
10
|
+
*/
|
|
11
|
+
export function checkChannelEnvironmentMismatch(profiles) {
|
|
12
|
+
const issues = [];
|
|
13
|
+
|
|
14
|
+
for (const profile of profiles) {
|
|
15
|
+
const { name, channel, environment } = profile;
|
|
16
|
+
if (!channel && !environment) continue; // nothing to cross-check
|
|
17
|
+
|
|
18
|
+
if (channel && !environment) {
|
|
19
|
+
issues.push({
|
|
20
|
+
level: "warning",
|
|
21
|
+
check: "channel-environment-mismatch",
|
|
22
|
+
message: `Profile "${name}" sets a channel ("${channel}") but no environment. EAS Update publishes to this channel will use whatever --environment flag is passed at the command line, which is easy to get wrong.`,
|
|
23
|
+
});
|
|
24
|
+
} else if (!channel && environment) {
|
|
25
|
+
issues.push({
|
|
26
|
+
level: "warning",
|
|
27
|
+
check: "channel-environment-mismatch",
|
|
28
|
+
message: `Profile "${name}" sets an environment ("${environment}") but no channel. Builds from this profile won't receive EAS Updates automatically.`,
|
|
29
|
+
});
|
|
30
|
+
} else if (channel !== environment) {
|
|
31
|
+
issues.push({
|
|
32
|
+
level: "warning",
|
|
33
|
+
check: "channel-environment-mismatch",
|
|
34
|
+
message: `Profile "${name}" has channel "${channel}" but environment "${environment}" — different names. If intentional, ignore this; if not, publishing with --environment ${environment} will land on the "${channel}" channel carrying the wrong values.`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return issues;
|
|
40
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags build profiles that resolve to the same iOS bundle identifier or
|
|
3
|
+
* Android package name. Variants sharing an identity can't be installed
|
|
4
|
+
* side by side — installing one silently overwrites the other on device.
|
|
5
|
+
*
|
|
6
|
+
* @param {{ name: string, config: object }[]} resolvedProfiles
|
|
7
|
+
* @returns {Array<{level: string, check: string, message: string}>}
|
|
8
|
+
*/
|
|
9
|
+
export function checkIdentityCollisions(resolvedProfiles) {
|
|
10
|
+
const issues = [];
|
|
11
|
+
const byBundleId = new Map();
|
|
12
|
+
const byPackage = new Map();
|
|
13
|
+
|
|
14
|
+
for (const { name, config } of resolvedProfiles) {
|
|
15
|
+
const bundleId = config?.ios?.bundleIdentifier;
|
|
16
|
+
const pkg = config?.android?.package;
|
|
17
|
+
|
|
18
|
+
if (bundleId) {
|
|
19
|
+
const existing = byBundleId.get(bundleId) ?? [];
|
|
20
|
+
existing.push(name);
|
|
21
|
+
byBundleId.set(bundleId, existing);
|
|
22
|
+
}
|
|
23
|
+
if (pkg) {
|
|
24
|
+
const existing = byPackage.get(pkg) ?? [];
|
|
25
|
+
existing.push(name);
|
|
26
|
+
byPackage.set(pkg, existing);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const [bundleId, profiles] of byBundleId) {
|
|
31
|
+
if (profiles.length > 1) {
|
|
32
|
+
issues.push({
|
|
33
|
+
level: "error",
|
|
34
|
+
check: "identity-collision",
|
|
35
|
+
message: `iOS bundleIdentifier "${bundleId}" is shared by profiles: ${profiles.join(
|
|
36
|
+
", "
|
|
37
|
+
)}. These builds will overwrite each other on install.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const [pkg, profiles] of byPackage) {
|
|
42
|
+
if (profiles.length > 1) {
|
|
43
|
+
issues.push({
|
|
44
|
+
level: "error",
|
|
45
|
+
check: "identity-collision",
|
|
46
|
+
message: `Android package "${pkg}" is shared by profiles: ${profiles.join(
|
|
47
|
+
", "
|
|
48
|
+
)}. These builds will overwrite each other on install.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return issues;
|
|
54
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags when more than one build profile's resolved config would register
|
|
3
|
+
* the generated `exp+<slug>` deep link scheme via the expo-dev-client
|
|
4
|
+
* plugin. The plugin adds this scheme by default whenever it's present,
|
|
5
|
+
* unless `addGeneratedScheme: false` is set. When two or more variants
|
|
6
|
+
* answer the same scheme, the QR code from `expo start` (and any deep link
|
|
7
|
+
* using it) may open the wrong installed app.
|
|
8
|
+
*
|
|
9
|
+
* @param {{ name: string, config: object }[]} resolvedProfiles
|
|
10
|
+
* @returns {Array<{level: string, check: string, message: string}>}
|
|
11
|
+
*/
|
|
12
|
+
export function checkSchemeCollisions(resolvedProfiles) {
|
|
13
|
+
const issues = [];
|
|
14
|
+
const claimants = [];
|
|
15
|
+
|
|
16
|
+
for (const { name, config } of resolvedProfiles) {
|
|
17
|
+
const plugins = config?.plugins ?? [];
|
|
18
|
+
const devClientEntry = plugins.find(
|
|
19
|
+
(p) => p === "expo-dev-client" || (Array.isArray(p) && p[0] === "expo-dev-client")
|
|
20
|
+
);
|
|
21
|
+
if (!devClientEntry) continue;
|
|
22
|
+
|
|
23
|
+
const options = Array.isArray(devClientEntry) ? devClientEntry[1] ?? {} : {};
|
|
24
|
+
// addGeneratedScheme defaults to true whenever the plugin is present
|
|
25
|
+
// and the option isn't explicitly set to false.
|
|
26
|
+
const claimsScheme = options.addGeneratedScheme !== false;
|
|
27
|
+
if (claimsScheme) {
|
|
28
|
+
claimants.push(name);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (claimants.length > 1) {
|
|
33
|
+
issues.push({
|
|
34
|
+
level: "error",
|
|
35
|
+
check: "scheme-collision",
|
|
36
|
+
message: `More than one profile registers the generated deep link scheme: ${claimants.join(
|
|
37
|
+
", "
|
|
38
|
+
)}. Only one variant should set addGeneratedScheme to true (typically development) — set it to false for the others.`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return issues;
|
|
43
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Flags when the locally prebuilt native project's identity doesn't match
|
|
6
|
+
* what the currently-set APP_VARIANT resolves to. This catches "I built
|
|
7
|
+
* preview locally, then forgot to prebuild --clean back to dev" — expo
|
|
8
|
+
* start silently keeps pointing at whichever native project is on disk.
|
|
9
|
+
*
|
|
10
|
+
* Skips gracefully if android/ or ios/ directories aren't present (the
|
|
11
|
+
* common case for Continuous Native Generation projects that gitignore
|
|
12
|
+
* them).
|
|
13
|
+
*
|
|
14
|
+
* @param {string} projectRoot
|
|
15
|
+
* @param {object} currentConfig - resolved config for the *currently set*
|
|
16
|
+
* APP_VARIANT (e.g. from .env.local or the shell).
|
|
17
|
+
* @returns {Array<{level: string, check: string, message: string}>}
|
|
18
|
+
*/
|
|
19
|
+
export function checkStaleNativeProject(projectRoot, currentConfig) {
|
|
20
|
+
const issues = [];
|
|
21
|
+
|
|
22
|
+
const gradlePath = path.join(projectRoot, "android", "app", "build.gradle");
|
|
23
|
+
if (fs.existsSync(gradlePath)) {
|
|
24
|
+
const gradle = fs.readFileSync(gradlePath, "utf8");
|
|
25
|
+
const match = gradle.match(/applicationId\s+["']([^"']+)["']/);
|
|
26
|
+
const nativePackage = match?.[1];
|
|
27
|
+
const expectedPackage = currentConfig?.android?.package;
|
|
28
|
+
if (nativePackage && expectedPackage && nativePackage !== expectedPackage) {
|
|
29
|
+
issues.push({
|
|
30
|
+
level: "warning",
|
|
31
|
+
check: "stale-native-project",
|
|
32
|
+
message: `android/app/build.gradle has applicationId "${nativePackage}", but the currently resolved config expects "${expectedPackage}". Run APP_VARIANT=<variant> npx expo prebuild --clean to regenerate it.`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const iosDir = path.join(projectRoot, "ios");
|
|
38
|
+
if (fs.existsSync(iosDir)) {
|
|
39
|
+
const pbxprojPath = findPbxproj(iosDir);
|
|
40
|
+
if (pbxprojPath) {
|
|
41
|
+
const pbxproj = fs.readFileSync(pbxprojPath, "utf8");
|
|
42
|
+
const match = pbxproj.match(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([^\s;]+);/);
|
|
43
|
+
const nativeBundleId = match?.[1];
|
|
44
|
+
const expectedBundleId = currentConfig?.ios?.bundleIdentifier;
|
|
45
|
+
if (nativeBundleId && expectedBundleId && nativeBundleId !== expectedBundleId) {
|
|
46
|
+
issues.push({
|
|
47
|
+
level: "warning",
|
|
48
|
+
check: "stale-native-project",
|
|
49
|
+
message: `ios project has PRODUCT_BUNDLE_IDENTIFIER "${nativeBundleId}", but the currently resolved config expects "${expectedBundleId}". Run APP_VARIANT=<variant> npx expo prebuild --clean to regenerate it.`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return issues;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findPbxproj(iosDir) {
|
|
59
|
+
const entries = fs.readdirSync(iosDir, { withFileTypes: true });
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (entry.isDirectory() && entry.name.endsWith(".xcodeproj")) {
|
|
62
|
+
const candidate = path.join(iosDir, entry.name, "project.pbxproj");
|
|
63
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readEasJson, getBuildProfiles, resolveExpoConfig } from "@expo-variant-tools/core";
|
|
3
|
+
import { checkIdentityCollisions } from "./checks/identity-collision.js";
|
|
4
|
+
import { checkSchemeCollisions } from "./checks/scheme-collision.js";
|
|
5
|
+
import { checkChannelEnvironmentMismatch } from "./checks/channel-environment-mismatch.js";
|
|
6
|
+
import { checkStaleNativeProject } from "./checks/stale-native-project.js";
|
|
7
|
+
import { printReport } from "./report.js";
|
|
8
|
+
|
|
9
|
+
function main() {
|
|
10
|
+
const projectRoot = process.cwd();
|
|
11
|
+
|
|
12
|
+
let easJson;
|
|
13
|
+
try {
|
|
14
|
+
easJson = readEasJson(projectRoot);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.error(err.message);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const profiles = getBuildProfiles(easJson);
|
|
21
|
+
if (profiles.length === 0) {
|
|
22
|
+
console.error("No build profiles found in eas.json.");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Resolve the config as EAS Build would evaluate it for each profile,
|
|
27
|
+
// by setting that profile's env vars and shelling out to `expo config`.
|
|
28
|
+
const resolvedProfiles = [];
|
|
29
|
+
for (const profile of profiles) {
|
|
30
|
+
const envVars = profile.appVariant ? { APP_VARIANT: profile.appVariant } : {};
|
|
31
|
+
try {
|
|
32
|
+
const config = resolveExpoConfig(projectRoot, envVars);
|
|
33
|
+
resolvedProfiles.push({ name: profile.name, config });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.warn(
|
|
36
|
+
`Could not resolve config for profile "${profile.name}" (skipping): ${err.message}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const issues = [
|
|
42
|
+
...checkIdentityCollisions(resolvedProfiles),
|
|
43
|
+
...checkSchemeCollisions(resolvedProfiles),
|
|
44
|
+
...checkChannelEnvironmentMismatch(profiles),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// The stale-native-project check needs the config for whatever
|
|
48
|
+
// APP_VARIANT is *currently* set locally, not a specific EAS profile.
|
|
49
|
+
const currentAppVariant = process.env.APP_VARIANT;
|
|
50
|
+
const currentProfile = profiles.find((p) => p.appVariant === currentAppVariant);
|
|
51
|
+
const currentResolved = currentProfile
|
|
52
|
+
? resolvedProfiles.find((p) => p.name === currentProfile.name)
|
|
53
|
+
: resolvedProfiles[0];
|
|
54
|
+
if (currentResolved) {
|
|
55
|
+
issues.push(...checkStaleNativeProject(projectRoot, currentResolved.config));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
printReport(issues);
|
|
59
|
+
process.exit(issues.some((i) => i.level === "error") ? 1 : 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
main();
|
package/src/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Programmatic entry point, for anyone who wants to run checks from a
|
|
2
|
+
// script instead of the CLI (e.g. a custom CI step or another tool).
|
|
3
|
+
export { checkIdentityCollisions } from "./checks/identity-collision.js";
|
|
4
|
+
export { checkSchemeCollisions } from "./checks/scheme-collision.js";
|
|
5
|
+
export { checkChannelEnvironmentMismatch } from "./checks/channel-environment-mismatch.js";
|
|
6
|
+
export { checkStaleNativeProject } from "./checks/stale-native-project.js";
|
package/src/report.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const SYMBOLS = { error: "\u2717", warning: "\u26A0" };
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prints a plain-text report of issues to stdout.
|
|
5
|
+
* @param {Array<{level: string, check: string, message: string}>} issues
|
|
6
|
+
*/
|
|
7
|
+
export function printReport(issues) {
|
|
8
|
+
if (issues.length === 0) {
|
|
9
|
+
console.log("\u2713 No variant issues found.");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
for (const issue of issues) {
|
|
14
|
+
console.log(`${SYMBOLS[issue.level] ?? "-"} [${issue.check}] ${issue.message}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const errors = issues.filter((i) => i.level === "error").length;
|
|
18
|
+
const warnings = issues.filter((i) => i.level === "warning").length;
|
|
19
|
+
console.log("");
|
|
20
|
+
console.log(`${errors} error(s), ${warnings} warning(s).`);
|
|
21
|
+
}
|