react-native-config-ultimate 0.0.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/LICENSE +21 -0
- package/README.md +138 -0
- package/android/build.gradle +180 -0
- package/android/rncu.gradle +132 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/reactnativeultimateconfig/UltimateConfigModule.java +56 -0
- package/android/src/main/java/com/reactnativeultimateconfig/UltimateConfigPackage.java +53 -0
- package/bin.js +5 -0
- package/index.js +9 -0
- package/index.ts +18 -0
- package/ios/ConfigValues.h +1 -0
- package/ios/UltimateConfig.h +12 -0
- package/ios/UltimateConfig.mm +27 -0
- package/ios/UltimateConfig.xcodeproj/project.pbxproj +274 -0
- package/override.js +1 -0
- package/package.json +110 -0
- package/react-native-config-ultimate.podspec +41 -0
- package/src/NativeUltimateConfig.js +4 -0
- package/src/NativeUltimateConfig.ts +15 -0
- package/src/bin.spec.ts +36 -0
- package/src/cli.js +177 -0
- package/src/cli.spec.ts +224 -0
- package/src/cli.ts +166 -0
- package/src/flatten.js +22 -0
- package/src/flatten.spec.ts +16 -0
- package/src/flatten.ts +26 -0
- package/src/load-env.js +107 -0
- package/src/load-env.spec.ts +163 -0
- package/src/load-env.ts +84 -0
- package/src/main.js +34 -0
- package/src/main.spec.ts +171 -0
- package/src/main.ts +39 -0
- package/src/render-env.js +110 -0
- package/src/render-env.ts +115 -0
- package/src/resolve-env.js +12 -0
- package/src/resolve-env.spec.ts +25 -0
- package/src/resolve-env.ts +45 -0
- package/src/templates/ConfigValues.h.handlebars +24 -0
- package/src/templates/index.d.ts.handlebars +18 -0
- package/src/templates/index.web.js.handlebars +1 -0
- package/src/templates/override.js.handlebars +16 -0
- package/src/templates/rncu.xcconfig.handlebars +4 -0
- package/src/templates/rncu.yaml.handlebars +7 -0
- package/src/validate-env.js +53 -0
- package/src/validate-env.spec.ts +164 -0
- package/src/validate-env.ts +68 -0
- package/src/write-env.js +99 -0
- package/src/write-env.spec.ts +105 -0
- package/src/write-env.ts +67 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const mockReadFileSync = jest.fn();
|
|
2
|
+
jest.doMock('fs', () => ({ readFileSync: mockReadFileSync }));
|
|
3
|
+
|
|
4
|
+
const mockParse = jest.fn();
|
|
5
|
+
jest.doMock('dotenv', () => ({ parse: mockParse }));
|
|
6
|
+
|
|
7
|
+
const mockExpand = jest.fn();
|
|
8
|
+
jest.doMock('dotenv-expand', () => ({ expand: mockExpand }));
|
|
9
|
+
|
|
10
|
+
const mockYaml = jest.fn();
|
|
11
|
+
jest.doMock('js-yaml', () => ({ load: mockYaml }));
|
|
12
|
+
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
14
|
+
const load_env: (paths: string | string[]) => Record<string, unknown> =
|
|
15
|
+
require('./load-env').default;
|
|
16
|
+
|
|
17
|
+
describe('load-env', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
mockReadFileSync.mockReset();
|
|
20
|
+
mockParse.mockReset();
|
|
21
|
+
mockExpand.mockReset();
|
|
22
|
+
mockYaml.mockReset();
|
|
23
|
+
// Default expand: return parsed as-is (no expansion side effects)
|
|
24
|
+
mockExpand.mockImplementation(
|
|
25
|
+
(input: { parsed: Record<string, string> }) => input
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('dotenv format', () => {
|
|
30
|
+
it('reads a single dotenv file (backward-compatible string arg)', () => {
|
|
31
|
+
mockReadFileSync.mockReturnValueOnce('hello=world');
|
|
32
|
+
mockParse.mockReturnValueOnce({ hello: 'world' });
|
|
33
|
+
const result = load_env('hello');
|
|
34
|
+
expect(mockReadFileSync).toHaveBeenCalledWith('hello', 'utf8');
|
|
35
|
+
expect(mockParse).toHaveBeenCalledWith('hello=world');
|
|
36
|
+
expect(result).toEqual({ hello: 'world' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('reads a single dotenv file when passed as an array', () => {
|
|
40
|
+
mockReadFileSync.mockReturnValueOnce('hello=world');
|
|
41
|
+
mockParse.mockReturnValueOnce({ hello: 'world' });
|
|
42
|
+
const result = load_env(['hello']);
|
|
43
|
+
expect(mockReadFileSync).toHaveBeenCalledWith('hello', 'utf8');
|
|
44
|
+
expect(result).toEqual({ hello: 'world' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('merges multiple dotenv files, last file wins for conflicts', () => {
|
|
48
|
+
mockReadFileSync
|
|
49
|
+
.mockReturnValueOnce('A=base\nB=base')
|
|
50
|
+
.mockReturnValueOnce('B=override\nC=new');
|
|
51
|
+
mockParse
|
|
52
|
+
.mockReturnValueOnce({ A: 'base', B: 'base' })
|
|
53
|
+
.mockReturnValueOnce({ B: 'override', C: 'new' });
|
|
54
|
+
const result = load_env(['.env.base', '.env.staging']);
|
|
55
|
+
expect(mockReadFileSync).toHaveBeenCalledTimes(2);
|
|
56
|
+
// expand is called once with the merged raw object
|
|
57
|
+
expect(mockExpand).toHaveBeenCalledWith({
|
|
58
|
+
parsed: { A: 'base', B: 'override', C: 'new' },
|
|
59
|
+
});
|
|
60
|
+
expect(result).toEqual({ A: 'base', B: 'override', C: 'new' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('expands $VAR references using dotenv-expand', () => {
|
|
64
|
+
mockReadFileSync.mockReturnValueOnce('BASE=https://api.com\nURL=$BASE/v1');
|
|
65
|
+
mockParse.mockReturnValueOnce({ BASE: 'https://api.com', URL: '$BASE/v1' });
|
|
66
|
+
mockExpand.mockReturnValueOnce({
|
|
67
|
+
parsed: { BASE: 'https://api.com', URL: 'https://api.com/v1' },
|
|
68
|
+
});
|
|
69
|
+
const result = load_env('.env');
|
|
70
|
+
expect(result).toEqual({
|
|
71
|
+
BASE: 'https://api.com',
|
|
72
|
+
URL: 'https://api.com/v1',
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('expands cross-file $VAR references when merging multiple files', () => {
|
|
77
|
+
mockReadFileSync
|
|
78
|
+
.mockReturnValueOnce('BASE_URL=https://api.com')
|
|
79
|
+
.mockReturnValueOnce('API_URL=$BASE_URL/v1');
|
|
80
|
+
mockParse
|
|
81
|
+
.mockReturnValueOnce({ BASE_URL: 'https://api.com' })
|
|
82
|
+
.mockReturnValueOnce({ API_URL: '$BASE_URL/v1' });
|
|
83
|
+
// Expand is called with merged raw — so cross-file reference resolves
|
|
84
|
+
mockExpand.mockReturnValueOnce({
|
|
85
|
+
parsed: {
|
|
86
|
+
BASE_URL: 'https://api.com',
|
|
87
|
+
API_URL: 'https://api.com/v1',
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
const result = load_env(['.env.base', '.env.staging']);
|
|
91
|
+
expect(mockExpand).toHaveBeenCalledWith({
|
|
92
|
+
parsed: { BASE_URL: 'https://api.com', API_URL: '$BASE_URL/v1' },
|
|
93
|
+
});
|
|
94
|
+
expect(result).toEqual({
|
|
95
|
+
BASE_URL: 'https://api.com',
|
|
96
|
+
API_URL: 'https://api.com/v1',
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('yaml format', () => {
|
|
102
|
+
it.each`
|
|
103
|
+
extension
|
|
104
|
+
${'yml'}
|
|
105
|
+
${'yaml'}
|
|
106
|
+
`(
|
|
107
|
+
"reads yaml when extension is '.$extension'",
|
|
108
|
+
({ extension }: { extension: string }) => {
|
|
109
|
+
mockReadFileSync.mockReturnValueOnce(Buffer.from('data'));
|
|
110
|
+
mockYaml.mockReturnValueOnce({ hello: 'world' });
|
|
111
|
+
const result = load_env(`hello.${extension}`);
|
|
112
|
+
expect(mockReadFileSync).toHaveBeenCalledWith(`hello.${extension}`);
|
|
113
|
+
expect(mockYaml).toHaveBeenCalledWith('data');
|
|
114
|
+
expect(result).toEqual({ hello: 'world' });
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
it('merges multiple yaml files, last file wins for conflicts', () => {
|
|
119
|
+
mockReadFileSync
|
|
120
|
+
.mockReturnValueOnce(Buffer.from('A: base\nB: base'))
|
|
121
|
+
.mockReturnValueOnce(Buffer.from('B: override\nC: new'));
|
|
122
|
+
mockYaml
|
|
123
|
+
.mockReturnValueOnce({ A: 'base', B: 'base' })
|
|
124
|
+
.mockReturnValueOnce({ B: 'override', C: 'new' });
|
|
125
|
+
const result = load_env(['base.yaml', 'staging.yaml']);
|
|
126
|
+
expect(mockReadFileSync).toHaveBeenCalledTimes(2);
|
|
127
|
+
expect(result).toEqual({ A: 'base', B: 'override', C: 'new' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe.each`
|
|
131
|
+
extension
|
|
132
|
+
${'yml'}
|
|
133
|
+
${'yaml'}
|
|
134
|
+
`(
|
|
135
|
+
"throws when yaml is not an object with extension '.$extension'",
|
|
136
|
+
({ extension }: { extension: string }) => {
|
|
137
|
+
it.each`
|
|
138
|
+
content
|
|
139
|
+
${'abc:def'}
|
|
140
|
+
${false}
|
|
141
|
+
${true}
|
|
142
|
+
${42}
|
|
143
|
+
${null}
|
|
144
|
+
${undefined}
|
|
145
|
+
`("when content is '$content'", ({ content }: { content: unknown }) => {
|
|
146
|
+
mockReadFileSync.mockReturnValueOnce(Buffer.from('data'));
|
|
147
|
+
mockYaml.mockReturnValueOnce(content);
|
|
148
|
+
expect(() => {
|
|
149
|
+
load_env(`hello.${extension}`);
|
|
150
|
+
}).toThrow();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('edge cases', () => {
|
|
157
|
+
it('throws when no files are provided', () => {
|
|
158
|
+
expect(() => load_env([])).toThrow(
|
|
159
|
+
'No env file specified'
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
package/src/load-env.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as dotenv from 'dotenv';
|
|
2
|
+
import { expand } from 'dotenv-expand';
|
|
3
|
+
import * as yaml from 'js-yaml';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
|
|
7
|
+
import type { EnvData } from './resolve-env';
|
|
8
|
+
|
|
9
|
+
type FileFormat = 'dotenv' | 'yaml';
|
|
10
|
+
|
|
11
|
+
function detect_format(config_path: string): FileFormat {
|
|
12
|
+
const { ext } = path.parse(config_path);
|
|
13
|
+
return ext === '.yml' || ext === '.yaml' ? 'yaml' : 'dotenv';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function read_yaml(config_path: string): EnvData {
|
|
17
|
+
const data = yaml.load(fs.readFileSync(config_path).toString());
|
|
18
|
+
if (typeof data === 'undefined' || data === null || typeof data !== 'object') {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Expected to read object from ${config_path}, but got '${data}'`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return data as EnvData;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load one or more env files and merge them (last file wins for conflicting keys).
|
|
28
|
+
*
|
|
29
|
+
* Dotenv files (.env, .env.staging, etc.):
|
|
30
|
+
* - All files are merged first, then variable expansion runs once.
|
|
31
|
+
* - This means cross-file `$VAR` references work:
|
|
32
|
+
* .env.base: BASE_URL=https://api.example.com
|
|
33
|
+
* .env.staging: API_URL=$BASE_URL/v1 → https://api.example.com/v1
|
|
34
|
+
*
|
|
35
|
+
* YAML files (.yml, .yaml):
|
|
36
|
+
* - Each file is loaded and shallow-merged (last wins for top-level keys).
|
|
37
|
+
* - No variable expansion is applied (use YAML anchors instead).
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* // Single file (backward-compatible):
|
|
41
|
+
* load_env('.env')
|
|
42
|
+
*
|
|
43
|
+
* // Multi-file merge:
|
|
44
|
+
* load_env(['.env.base', '.env.staging'])
|
|
45
|
+
*/
|
|
46
|
+
export default function load_env(config_paths: string | string[]): EnvData {
|
|
47
|
+
const paths = Array.isArray(config_paths) ? config_paths : [config_paths];
|
|
48
|
+
|
|
49
|
+
if (paths.length === 0) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
'No env file specified. Usage: rncu <env-file> [env-file2 ...]'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const formats = paths.map(detect_format);
|
|
56
|
+
const allDotenv = formats.every((f) => f === 'dotenv');
|
|
57
|
+
|
|
58
|
+
if (allDotenv) {
|
|
59
|
+
// Merge raw parsed content first, then expand once —
|
|
60
|
+
// so cross-file $VAR references resolve correctly.
|
|
61
|
+
const raw: Record<string, string> = {};
|
|
62
|
+
for (const p of paths) {
|
|
63
|
+
const content = fs.readFileSync(p, 'utf8');
|
|
64
|
+
Object.assign(raw, dotenv.parse(content));
|
|
65
|
+
}
|
|
66
|
+
const result = expand({ parsed: raw });
|
|
67
|
+
return (result.parsed ?? raw) as EnvData;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// YAML or mixed: load each file individually and shallow-merge.
|
|
71
|
+
const merged: EnvData = {};
|
|
72
|
+
for (let i = 0; i < paths.length; i++) {
|
|
73
|
+
const p = paths[i];
|
|
74
|
+
if (formats[i] === 'yaml') {
|
|
75
|
+
Object.assign(merged, read_yaml(p));
|
|
76
|
+
} else {
|
|
77
|
+
const content = fs.readFileSync(p, 'utf8');
|
|
78
|
+
const parsed = dotenv.parse(content);
|
|
79
|
+
const expanded = expand({ parsed });
|
|
80
|
+
Object.assign(merged, expanded.parsed ?? parsed);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return merged;
|
|
84
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.default = main;
|
|
7
|
+
const load_env_1 = __importDefault(require("./load-env"));
|
|
8
|
+
const render_env_1 = __importDefault(require("./render-env"));
|
|
9
|
+
const write_env_1 = __importDefault(require("./write-env"));
|
|
10
|
+
const flatten_1 = __importDefault(require("./flatten"));
|
|
11
|
+
const resolve_env_1 = __importDefault(require("./resolve-env"));
|
|
12
|
+
const validate_env_1 = require("./validate-env");
|
|
13
|
+
/**
|
|
14
|
+
* Main build-time pipeline:
|
|
15
|
+
* load → resolve (on_env hook) → validate (schema) → flatten → render → write
|
|
16
|
+
*
|
|
17
|
+
* @param project_root Root of the React Native project
|
|
18
|
+
* @param lib_root Root of the react-native-config-ultimate install
|
|
19
|
+
* @param env_file Path(s) to env file(s). Multiple files are merged (last wins).
|
|
20
|
+
* @param rc Optional RC config from `.rncurc.js`
|
|
21
|
+
*/
|
|
22
|
+
async function main(project_root, lib_root, env_file, rc) {
|
|
23
|
+
const env = await (0, resolve_env_1.default)((0, load_env_1.default)(env_file), rc);
|
|
24
|
+
if (rc === null || rc === void 0 ? void 0 : rc.schema) {
|
|
25
|
+
(0, validate_env_1.validate_env)(env, rc.schema);
|
|
26
|
+
}
|
|
27
|
+
const flat = {
|
|
28
|
+
ios: (0, flatten_1.default)(env, 'ios'),
|
|
29
|
+
android: (0, flatten_1.default)(env, 'android'),
|
|
30
|
+
web: (0, flatten_1.default)(env, 'web'),
|
|
31
|
+
};
|
|
32
|
+
const files_to_write = (0, render_env_1.default)(project_root, lib_root, flat, rc);
|
|
33
|
+
(0, write_env_1.default)(files_to_write);
|
|
34
|
+
}
|
package/src/main.spec.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
const mock_load_env = jest.fn();
|
|
2
|
+
jest.doMock('./load-env', () => ({ __esModule: true, default: mock_load_env }));
|
|
3
|
+
const mock_render_env = jest.fn();
|
|
4
|
+
jest.doMock('./render-env', () => ({ __esModule: true, default: mock_render_env }));
|
|
5
|
+
const mock_write_env = jest.fn();
|
|
6
|
+
jest.doMock('./write-env', () => ({ __esModule: true, default: mock_write_env }));
|
|
7
|
+
const mock_flatten = jest.fn();
|
|
8
|
+
jest.doMock('./flatten', () => ({ __esModule: true, default: mock_flatten }));
|
|
9
|
+
const mock_validate_env = jest.fn();
|
|
10
|
+
jest.doMock('./validate-env', () => ({ validate_env: mock_validate_env }));
|
|
11
|
+
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
13
|
+
const main: (...args: unknown[]) => Promise<void> = require('./main').default;
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
export const files_to_assert = [
|
|
17
|
+
'ios/rncu.xcconfig',
|
|
18
|
+
'node_modules/react-native-config-ultimate/ios/ConfigValues.h',
|
|
19
|
+
'node_modules/react-native-config-ultimate/android/rncu.yaml',
|
|
20
|
+
'node_modules/react-native-config-ultimate/index.d.ts',
|
|
21
|
+
'node_modules/react-native-config-ultimate/index.web.js',
|
|
22
|
+
'node_modules/react-native-config-ultimate/override.js',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
describe('main', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
mock_load_env.mockReset();
|
|
28
|
+
mock_render_env.mockReset();
|
|
29
|
+
mock_write_env.mockReset();
|
|
30
|
+
mock_flatten.mockReset();
|
|
31
|
+
mock_validate_env.mockReset();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('execute render with paths (string arg — backward-compatible)', async () => {
|
|
35
|
+
mock_load_env.mockReturnValueOnce({ data: true });
|
|
36
|
+
mock_flatten.mockReturnValueOnce({ data: true, ios: true });
|
|
37
|
+
mock_flatten.mockReturnValueOnce({ data: true, android: true });
|
|
38
|
+
mock_flatten.mockReturnValueOnce({ data: true, web: true });
|
|
39
|
+
mock_render_env.mockReturnValueOnce({ hello: 'world' });
|
|
40
|
+
await main(
|
|
41
|
+
'project',
|
|
42
|
+
'project/node_modules/react-native-config-ultimate',
|
|
43
|
+
'file'
|
|
44
|
+
);
|
|
45
|
+
expect(mock_load_env).toHaveBeenCalledWith('file');
|
|
46
|
+
expect(mock_flatten).toHaveBeenCalledWith({ data: true }, 'ios');
|
|
47
|
+
expect(mock_flatten).toHaveBeenCalledWith({ data: true }, 'android');
|
|
48
|
+
expect(mock_flatten).toHaveBeenCalledWith({ data: true }, 'web');
|
|
49
|
+
expect(mock_render_env).toHaveBeenCalledWith(
|
|
50
|
+
'project',
|
|
51
|
+
'project/node_modules/react-native-config-ultimate',
|
|
52
|
+
{
|
|
53
|
+
ios: { data: true, ios: true },
|
|
54
|
+
android: { data: true, android: true },
|
|
55
|
+
web: { data: true, web: true },
|
|
56
|
+
},
|
|
57
|
+
undefined
|
|
58
|
+
);
|
|
59
|
+
expect(mock_write_env).toHaveBeenCalledWith({ hello: 'world' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('passes array of env files to load_env (multi-file merge)', async () => {
|
|
63
|
+
mock_load_env.mockReturnValueOnce({ data: true });
|
|
64
|
+
mock_flatten.mockReturnValue({});
|
|
65
|
+
mock_render_env.mockReturnValueOnce({});
|
|
66
|
+
await main(
|
|
67
|
+
'project',
|
|
68
|
+
'project/node_modules/react-native-config-ultimate',
|
|
69
|
+
['.env.base', '.env.staging']
|
|
70
|
+
);
|
|
71
|
+
expect(mock_load_env).toHaveBeenCalledWith(['.env.base', '.env.staging']);
|
|
72
|
+
});
|
|
73
|
+
describe('rc.on_env', () => {
|
|
74
|
+
it('invoke rc hook with config before flattening', async () => {
|
|
75
|
+
const on_env = jest.fn();
|
|
76
|
+
mock_load_env.mockReturnValueOnce({ data: true });
|
|
77
|
+
await main(
|
|
78
|
+
'project',
|
|
79
|
+
'project/node_modules/react-native-config-ultimate',
|
|
80
|
+
'file',
|
|
81
|
+
{ on_env }
|
|
82
|
+
);
|
|
83
|
+
expect(on_env).toHaveBeenCalledWith({ data: true });
|
|
84
|
+
expect(mock_flatten).toHaveBeenCalledWith({ data: true }, 'ios');
|
|
85
|
+
expect(mock_flatten).toHaveBeenCalledWith({ data: true }, 'android');
|
|
86
|
+
expect(mock_flatten).toHaveBeenCalledWith({ data: true }, 'web');
|
|
87
|
+
});
|
|
88
|
+
it('hook can add or remove values', async () => {
|
|
89
|
+
const on_env = jest.fn();
|
|
90
|
+
on_env.mockImplementation((env: Record<string, unknown>) => {
|
|
91
|
+
const { key1, ...rest } = env;
|
|
92
|
+
void key1;
|
|
93
|
+
return { ...rest, key2: 'hello' };
|
|
94
|
+
});
|
|
95
|
+
mock_load_env.mockReturnValueOnce({ data: true, key1: 'bye' });
|
|
96
|
+
await main(
|
|
97
|
+
'project',
|
|
98
|
+
'project/node_modules/react-native-config-ultimate',
|
|
99
|
+
'file',
|
|
100
|
+
{ on_env }
|
|
101
|
+
);
|
|
102
|
+
expect(on_env).toHaveBeenCalledWith({ data: true, key1: 'bye' });
|
|
103
|
+
expect(mock_flatten).toHaveBeenCalledWith({ data: true, key2: 'hello' }, 'ios');
|
|
104
|
+
expect(mock_flatten).toHaveBeenCalledWith({ data: true, key2: 'hello' }, 'android');
|
|
105
|
+
expect(mock_flatten).toHaveBeenCalledWith({ data: true, key2: 'hello' }, 'web');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('rc.schema', () => {
|
|
110
|
+
it('calls validate_env when schema is provided', async () => {
|
|
111
|
+
const schema = { API_KEY: { type: 'string' as const, required: true } };
|
|
112
|
+
mock_load_env.mockReturnValueOnce({ API_KEY: 'secret' });
|
|
113
|
+
mock_flatten.mockReturnValue({});
|
|
114
|
+
mock_render_env.mockReturnValueOnce({});
|
|
115
|
+
await main(
|
|
116
|
+
'project',
|
|
117
|
+
'project/node_modules/react-native-config-ultimate',
|
|
118
|
+
'file',
|
|
119
|
+
{ schema }
|
|
120
|
+
);
|
|
121
|
+
expect(mock_validate_env).toHaveBeenCalledWith({ API_KEY: 'secret' }, schema);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('does not call validate_env when no schema is provided', async () => {
|
|
125
|
+
mock_load_env.mockReturnValueOnce({ data: true });
|
|
126
|
+
mock_flatten.mockReturnValue({});
|
|
127
|
+
mock_render_env.mockReturnValueOnce({});
|
|
128
|
+
await main(
|
|
129
|
+
'project',
|
|
130
|
+
'project/node_modules/react-native-config-ultimate',
|
|
131
|
+
'file'
|
|
132
|
+
);
|
|
133
|
+
expect(mock_validate_env).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('validates env AFTER on_env hook runs (hook output is validated)', async () => {
|
|
137
|
+
const schema = { INJECTED_KEY: { type: 'string' as const, required: true } };
|
|
138
|
+
const on_env = jest.fn().mockReturnValue({ INJECTED_KEY: 'from-hook' });
|
|
139
|
+
mock_load_env.mockReturnValueOnce({});
|
|
140
|
+
mock_flatten.mockReturnValue({});
|
|
141
|
+
mock_render_env.mockReturnValueOnce({});
|
|
142
|
+
await main(
|
|
143
|
+
'project',
|
|
144
|
+
'project/node_modules/react-native-config-ultimate',
|
|
145
|
+
'file',
|
|
146
|
+
{ on_env, schema }
|
|
147
|
+
);
|
|
148
|
+
// validate_env receives the HOOK output, not the raw env
|
|
149
|
+
expect(mock_validate_env).toHaveBeenCalledWith(
|
|
150
|
+
{ INJECTED_KEY: 'from-hook' },
|
|
151
|
+
schema
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('propagates validation error thrown by validate_env', async () => {
|
|
156
|
+
const schema = { API_KEY: { type: 'string' as const, required: true } };
|
|
157
|
+
mock_load_env.mockReturnValueOnce({});
|
|
158
|
+
mock_validate_env.mockImplementation(() => {
|
|
159
|
+
throw new Error('❌ validation failed: Missing required env var: API_KEY');
|
|
160
|
+
});
|
|
161
|
+
await expect(
|
|
162
|
+
main(
|
|
163
|
+
'project',
|
|
164
|
+
'project/node_modules/react-native-config-ultimate',
|
|
165
|
+
'file',
|
|
166
|
+
{ schema }
|
|
167
|
+
)
|
|
168
|
+
).rejects.toThrow('Missing required env var: API_KEY');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import load_env from './load-env';
|
|
2
|
+
import render_env from './render-env';
|
|
3
|
+
import write_env from './write-env';
|
|
4
|
+
import flatten from './flatten';
|
|
5
|
+
import resolve_env from './resolve-env';
|
|
6
|
+
import { validate_env } from './validate-env';
|
|
7
|
+
|
|
8
|
+
import type { RC, EnvData } from './resolve-env';
|
|
9
|
+
import type { EnvConfig } from './flatten';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Main build-time pipeline:
|
|
13
|
+
* load → resolve (on_env hook) → validate (schema) → flatten → render → write
|
|
14
|
+
*
|
|
15
|
+
* @param project_root Root of the React Native project
|
|
16
|
+
* @param lib_root Root of the react-native-config-ultimate install
|
|
17
|
+
* @param env_file Path(s) to env file(s). Multiple files are merged (last wins).
|
|
18
|
+
* @param rc Optional RC config from `.rncurc.js`
|
|
19
|
+
*/
|
|
20
|
+
export default async function main(
|
|
21
|
+
project_root: string,
|
|
22
|
+
lib_root: string,
|
|
23
|
+
env_file: string | string[],
|
|
24
|
+
rc?: RC
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const env: EnvData = await resolve_env(load_env(env_file), rc);
|
|
27
|
+
|
|
28
|
+
if (rc?.schema) {
|
|
29
|
+
validate_env(env, rc.schema);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const flat = {
|
|
33
|
+
ios: flatten(env as EnvConfig, 'ios'),
|
|
34
|
+
android: flatten(env as EnvConfig, 'android'),
|
|
35
|
+
web: flatten(env as EnvConfig, 'web'),
|
|
36
|
+
};
|
|
37
|
+
const files_to_write = render_env(project_root, lib_root, flat, rc);
|
|
38
|
+
write_env(files_to_write);
|
|
39
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.default = render_env;
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const handlebars_1 = __importDefault(require("handlebars"));
|
|
43
|
+
const code_file_name = 'ConfigValues';
|
|
44
|
+
const config_file_name = 'rncu';
|
|
45
|
+
// ─── Handlebars helpers (registered once at module load, not per-call) ────────
|
|
46
|
+
function is_string(value) {
|
|
47
|
+
return typeof value === 'string';
|
|
48
|
+
}
|
|
49
|
+
function is_number(value) {
|
|
50
|
+
return typeof value === 'number';
|
|
51
|
+
}
|
|
52
|
+
function is_boolean(value) {
|
|
53
|
+
return typeof value === 'boolean';
|
|
54
|
+
}
|
|
55
|
+
function escape(value) {
|
|
56
|
+
if (is_string(value)) {
|
|
57
|
+
return value.replace(/"/gm, '\\"');
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
function xcconfig_format(value) {
|
|
62
|
+
if (is_string(value)) {
|
|
63
|
+
return value.replace(/\/\//gm, '/$()/');
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
function to_json(value) {
|
|
68
|
+
return JSON.stringify(value, null, 2);
|
|
69
|
+
}
|
|
70
|
+
handlebars_1.default.registerHelper('isBoolean', is_boolean);
|
|
71
|
+
handlebars_1.default.registerHelper('isString', is_string);
|
|
72
|
+
handlebars_1.default.registerHelper('isNumber', is_number);
|
|
73
|
+
handlebars_1.default.registerHelper('escape', escape);
|
|
74
|
+
handlebars_1.default.registerHelper('xcconfigFormat', xcconfig_format);
|
|
75
|
+
handlebars_1.default.registerHelper('toJSON', to_json);
|
|
76
|
+
// ─── Template cache (compiled once per template name, not per build) ──────────
|
|
77
|
+
const template_cache = new Map();
|
|
78
|
+
function get_compiled_template(template_name) {
|
|
79
|
+
const cached = template_cache.get(template_name);
|
|
80
|
+
if (cached)
|
|
81
|
+
return cached;
|
|
82
|
+
const template_path = path.join(__dirname, 'templates', `${template_name}.handlebars`);
|
|
83
|
+
const compiled = handlebars_1.default.compile(fs.readFileSync(template_path, 'utf8'));
|
|
84
|
+
template_cache.set(template_name, compiled);
|
|
85
|
+
return compiled;
|
|
86
|
+
}
|
|
87
|
+
function render_template(template_name, data) {
|
|
88
|
+
return get_compiled_template(template_name)(data);
|
|
89
|
+
}
|
|
90
|
+
function render_env(project_root, lib_root, env, rc) {
|
|
91
|
+
const { ios, android, web } = env;
|
|
92
|
+
const map = {
|
|
93
|
+
[path.join(lib_root, 'index.d.ts')]: render_template('index.d.ts', ios),
|
|
94
|
+
[path.join(lib_root, 'index.web.js')]: render_template('index.web.js', web),
|
|
95
|
+
[path.join(lib_root, 'ios', `${code_file_name}.h`)]: render_template('ConfigValues.h', ios),
|
|
96
|
+
[path.join(lib_root, 'android', 'rncu.yaml')]: render_template('rncu.yaml', android),
|
|
97
|
+
};
|
|
98
|
+
// Only write xcconfig if the project has an ios folder.
|
|
99
|
+
// All RN apps have it; some react-native-web apps may not.
|
|
100
|
+
if (fs.existsSync(path.join(project_root, 'ios'))) {
|
|
101
|
+
map[path.join(project_root, 'ios', `${config_file_name}.xcconfig`)] =
|
|
102
|
+
render_template('rncu.xcconfig', ios);
|
|
103
|
+
}
|
|
104
|
+
const js_override = rc && typeof rc.js_override === 'boolean' && rc.js_override;
|
|
105
|
+
map[path.join(lib_root, 'override.js')] = render_template('override.js', {
|
|
106
|
+
ios: js_override ? ios : {},
|
|
107
|
+
android: js_override ? android : {},
|
|
108
|
+
});
|
|
109
|
+
return map;
|
|
110
|
+
}
|