Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@
"@babel/helper-plugin-utils": "^7.24.8",
"yaml": "^2.5.1"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependencies": {},
"scripts": {
"build": "tsc",
"test": "npm run test:unit && EXPO_SDK_TARGET=51 ./test/run-integration.sh",
Expand Down
61 changes: 60 additions & 1 deletion src/api/BuildFlags.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
import { writeFile } from "fs/promises";
import { writeFile, unlink } from "fs/promises";
import { existsSync } from "fs";
import { FlagMap } from "./types";
import { resolve } from "path";
import { printAsTs } from "./tsPrinter";
import { getCIBranch } from "./ciHelpers";
import { hasMatch } from "./globUtil";

export const platformPaths = (basePath: string) => {
const ext = basePath.endsWith(".ts") ? ".ts" : ".json";
const stem = basePath.slice(0, -ext.length);
return {
ios: `${stem}.ios${ext}`,
android: `${stem}.android${ext}`,
};
};

const cleanupStaleFiles = async (
basePath: string,
mode: "single" | "platform"
) => {
const paths = platformPaths(basePath);
const toDelete =
mode === "single"
? [paths.ios, paths.android]
: [basePath];

for (const p of toDelete) {
const resolved = resolve(p);
if (existsSync(resolved)) {
await unlink(resolved);
}
}
};

export class BuildFlags {
flags: FlagMap;

Expand Down Expand Up @@ -55,6 +83,8 @@ export class BuildFlags {
}

async save(path: string) {
await cleanupStaleFiles(path, "single");

if (path.endsWith(".json")) {
const flags = JSON.stringify(this.flags, null, 2);
await writeFile(resolve(path), flags);
Expand All @@ -71,4 +101,33 @@ export class BuildFlags {
"Invalid file extension in flags file for mergePath: expected .json or .ts"
);
}

async savePlatformSpecific(
basePath: string,
iosFlags: FlagMap,
androidFlags: FlagMap
) {
await cleanupStaleFiles(basePath, "platform");

const paths = platformPaths(basePath);

if (basePath.endsWith(".ts")) {
await writeFile(resolve(paths.ios), printAsTs(iosFlags));
await writeFile(resolve(paths.android), printAsTs(androidFlags));
return;
}

if (basePath.endsWith(".json")) {
await writeFile(resolve(paths.ios), JSON.stringify(iosFlags, null, 2));
await writeFile(
resolve(paths.android),
JSON.stringify(androidFlags, null, 2)
);
return;
}

throw new Error(
"Invalid file extension in flags file for mergePath: expected .json or .ts"
);
}
}
12 changes: 12 additions & 0 deletions src/api/fixtures/flags.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,15 @@ flags:
bundleId:
- com.my.app.apple
- com.my.app.android
platformInvertableFeature:
value: false
invertFor:
platform:
- ios
combinedInvertableFeature:
value: false
invertFor:
bundleId:
- com.my.app.special
platform:
- android
88 changes: 88 additions & 0 deletions src/api/generateOverrides.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { ExpoConfig } from "@expo/config-types";
import { BuildFlags } from "./BuildFlags";
import { readConfig } from "./readConfig";
import { resolveFlagsToInvert } from "./resolveFlagsToInvert";
import { FlagMap } from "./types";

export const resolveEnabledFlagNames = async ({
flagsToEnable,
Expand All @@ -21,6 +24,10 @@ export const resolveEnabledFlagNames = async ({
.map(([name]) => name);
};

/**
* Apply explicit flag overrides and write a single runtime module.
* Used by the CLI when the user provides +flag / -flag arguments.
*/
export const generateOverrides = async ({
flagsToEnable,
flagsToDisable,
Expand All @@ -43,3 +50,84 @@ export const generateOverrides = async ({
}
await flags.save(mergePath);
};

const hasPlatformInversions = (flags: FlagMap) =>
Object.values(flags).some(
(config) => config.invertFor?.platform?.length
);

const resolveForPlatform = async (
defaultFlags: FlagMap,
platform: "ios" | "android",
expoConfig?: ExpoConfig
): Promise<BuildFlags> => {
const flags = new BuildFlags(structuredClone(defaultFlags));
const invertable = await resolveFlagsToInvert(expoConfig, platform);
if (invertable.flagsToEnable.size > 0) {
flags.enable(invertable.flagsToEnable);
}
if (invertable.flagsToDisable.size > 0) {
flags.disable(invertable.flagsToDisable);
}
return flags;
};

const flagMapsEqual = (a: FlagMap, b: FlagMap) => {
const keysA = Object.keys(a).sort();
const keysB = Object.keys(b).sort();
if (keysA.length !== keysB.length) return false;
return keysA.every(
(key, i) => key === keysB[i] && a[key].value === b[key].value
);
};

/**
* Resolve the source-of-truth flags from flags.yml, including platform
* inversions and bundleId inversions. Writes platform-specific files
* (.ios.ts / .android.ts) when flags differ between platforms, otherwise
* writes a single file. Used by the CLI with no override args and by the
* config plugin.
*/
export const generateSourceOfTruth = async ({
expoConfig,
enableBranchFlags,
envFlagsToEnable,
}: {
expoConfig?: ExpoConfig;
enableBranchFlags?: boolean;
envFlagsToEnable?: Set<string>;
} = {}) => {
const { mergePath, flags: defaultFlags } = await readConfig();

const iosFlags = await resolveForPlatform(defaultFlags, "ios", expoConfig);
const androidFlags = await resolveForPlatform(
defaultFlags,
"android",
expoConfig
);

if (enableBranchFlags) {
iosFlags.enableBranchFlags();
androidFlags.enableBranchFlags();
}

if (envFlagsToEnable && envFlagsToEnable.size > 0) {
iosFlags.enable(envFlagsToEnable);
androidFlags.enable(envFlagsToEnable);
}

if (
hasPlatformInversions(defaultFlags) &&
!flagMapsEqual(iosFlags.flags, androidFlags.flags)
) {
await iosFlags.savePlatformSpecific(
mergePath,
iosFlags.flags,
androidFlags.flags
);
return;
}

// No platform differences — write single file (use ios, they're equal)
await iosFlags.save(mergePath);
};
2 changes: 2 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {
generateOverrides,
generateSourceOfTruth,
resolveEnabledFlagNames,
} from "./generateOverrides";
import { resolveFlagsToInvert } from "./resolveFlagsToInvert";
import { readConfig } from "./readConfig";

export {
generateOverrides,
generateSourceOfTruth,
resolveEnabledFlagNames,
resolveFlagsToInvert,
readConfig,
Expand Down
58 changes: 58 additions & 0 deletions src/api/resolveFlagsToInvert.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,62 @@ describe("resolveFlagsToInvert", () => {

expect(result.flagsToEnable.has("invertableFeature")).toBe(true);
});

describe("platform inversion", () => {
it("should invert when platform matches", async () => {
const result = await resolveFlagsToInvert(undefined, "ios");

expect(result.flagsToEnable.has("platformInvertableFeature")).toBe(true);
});

it("should not invert when platform does not match", async () => {
const result = await resolveFlagsToInvert(undefined, "android");

expect(result.flagsToEnable.has("platformInvertableFeature")).toBe(false);
});

it("should not invert when no platform is provided", async () => {
const result = await resolveFlagsToInvert();

expect(result.flagsToEnable.has("platformInvertableFeature")).toBe(false);
});
});

describe("OR composition (bundleId + platform)", () => {
it("should invert when bundleId matches but platform does not", async () => {
const result = await resolveFlagsToInvert(
{ ios: { bundleIdentifier: "com.my.app.special" } } as any,
"ios"
);

expect(result.flagsToEnable.has("combinedInvertableFeature")).toBe(true);
});

it("should invert when platform matches but bundleId does not", async () => {
const result = await resolveFlagsToInvert(
{ ios: { bundleIdentifier: "com.other.app" } } as any,
"android"
);

expect(result.flagsToEnable.has("combinedInvertableFeature")).toBe(true);
});

it("should invert when both bundleId and platform match", async () => {
const result = await resolveFlagsToInvert(
{ android: { package: "com.my.app.special" } } as any,
"android"
);

expect(result.flagsToEnable.has("combinedInvertableFeature")).toBe(true);
});

it("should not invert when neither bundleId nor platform matches", async () => {
const result = await resolveFlagsToInvert(
{ ios: { bundleIdentifier: "com.other.app" } } as any,
"ios"
);

expect(result.flagsToEnable.has("combinedInvertableFeature")).toBe(false);
});
});
});
48 changes: 19 additions & 29 deletions src/api/resolveFlagsToInvert.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,12 @@
import type { ExpoConfig } from "@expo/config-types";

import { BuildFlags } from "./BuildFlags";
import { readConfig } from "./readConfig";
import { InvertableFlagTuple } from "./types";

export const generateOverrides = async ({
flagsToEnable,
flagsToDisable,
enableBranchFlags,
}: {
flagsToEnable?: Set<string>;
flagsToDisable?: Set<string>;
enableBranchFlags?: boolean;
}) => {
const { mergePath, flags: defaultFlags } = await readConfig();
const flags = new BuildFlags(defaultFlags);
if (enableBranchFlags) {
flags.enableBranchFlags();
}
if (flagsToEnable) {
flags.enable(flagsToEnable);
}
if (flagsToDisable) {
flags.disable(flagsToDisable);
}
await flags.save(mergePath);
};

export const resolveFlagsToInvert = async (expoConfig: ExpoConfig) => {
export const resolveFlagsToInvert = async (
expoConfig?: ExpoConfig,
platform?: "ios" | "android"
) => {
const { flags } = await readConfig();
const invertable = Object.entries(flags).filter(
(tuple): tuple is InvertableFlagTuple => !!tuple[1].invertFor
Expand All @@ -42,20 +21,31 @@ export const resolveFlagsToInvert = async (expoConfig: ExpoConfig) => {

invertable.forEach(([flagName, flagConfig]) => {
const invertFor = flagConfig.invertFor;
let shouldInvert = false;

if (invertFor.bundleId) {
if (invertFor.bundleId && expoConfig) {
const bundleIds = [
expoConfig.ios?.bundleIdentifier,
expoConfig.android?.package,
].filter(Boolean);
if (
!bundleIds.length ||
!invertFor.bundleId.find((bundleId) => bundleIds.includes(bundleId))
bundleIds.length &&
invertFor.bundleId.some((id) => bundleIds.includes(id))
) {
return;
shouldInvert = true;
}
}

if (invertFor.platform && platform) {
if (invertFor.platform.includes(platform)) {
shouldInvert = true;
}
}

if (!shouldInvert) {
return;
}

if (flagConfig.value) {
flagsToDisable.add(flagName);
} else {
Expand Down
Loading
Loading