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 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
+ }