eoas 3.0.3 → 3.0.5

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.
@@ -7,6 +7,7 @@ const fs_extra_1 = require("fs-extra");
7
7
  const path_1 = tslib_1.__importDefault(require("path"));
8
8
  const log_1 = tslib_1.__importDefault(require("../lib/log"));
9
9
  const prompts_1 = require("../lib/prompts");
10
+ const utils_1 = require("../lib/utils");
10
11
  class GenerateCerts extends core_1.Command {
11
12
  static args = {};
12
13
  static description = 'Generate private & public certificates for code signing';
@@ -78,6 +79,9 @@ class GenerateCerts extends core_1.Command {
78
79
  });
79
80
  const keyPairPEM = (0, code_signing_certificates_1.convertKeyPairToPEM)(keyPair);
80
81
  const certificatePEM = (0, code_signing_certificates_1.convertCertificateToCertificatePEM)(certificate);
82
+ // Before the key touches the disk, so there is no window where it exists
83
+ // uncovered by the ignore rule.
84
+ (0, utils_1.ensurePrivateKeyIgnored)(process.cwd());
81
85
  await Promise.all([
82
86
  (0, fs_extra_1.writeFile)(path_1.default.join(keyOutput, 'public-key.pem'), keyPairPEM.publicKeyPEM),
83
87
  (0, fs_extra_1.writeFile)(path_1.default.join(keyOutput, 'private-key.pem'), keyPairPEM.privateKeyPEM),
@@ -85,6 +89,8 @@ class GenerateCerts extends core_1.Command {
85
89
  ]);
86
90
  log_1.default.succeed(`Generated public and private keys output in ${keyOutputDir}. Please follow the documentation to securely store them and do not commit them to your repository.`);
87
91
  log_1.default.succeed(`Generated code signing certificate output in ${certificateOutputDir}.`);
92
+ log_1.default.warn('⚠️ private-key.pem is used by your OTA server to sign updates. Never commit it and do not keep it inside your app project: configure it on your server (or in a secret store), then remove it from this machine.');
93
+ log_1.default.warn('Your team does not need this key for local development: run the dev server with DISABLE_CODE_SIGNING=true. See the "Local development" section of the documentation.');
88
94
  }
89
95
  }
90
96
  exports.default = GenerateCerts;
@@ -41,7 +41,7 @@ class Init extends core_1.Command {
41
41
  message: 'Enter the URL of your update server (ex: https://customota.com)',
42
42
  name: 'updateUrl',
43
43
  type: 'text',
44
- initial: (0, expoConfig_1.getExpoConfigUpdateUrl)(config),
44
+ initial: ((0, expoConfig_1.getExpoConfigUpdateUrl)(config) || '').replace(/\/manifest$/, ''),
45
45
  validate: v => {
46
46
  return !!v && (0, utils_1.isValidUpdateUrl)(v);
47
47
  },
@@ -95,13 +95,15 @@ class Init extends core_1.Command {
95
95
  }
96
96
  },
97
97
  });
98
+ // The code signing fields are guarded so the dev server can run without the
99
+ // private key: DISABLE_CODE_SIGNING=true expo start --dev-client. The strings
100
+ // are emitted as raw expressions by createOrModifyExpoConfigAsync.
98
101
  const newUpdateConfig = {
99
102
  url: manifestEndpoint,
100
- codeSigningMetadata: {
101
- keyid: 'main',
102
- alg: 'rsa-v1_5-sha256',
103
- },
104
- codeSigningCertificate: codeSigningCertificatePath,
103
+ codeSigningMetadata: "process.env.DISABLE_CODE_SIGNING ? undefined : { keyid: 'main', alg: 'rsa-v1_5-sha256' }",
104
+ codeSigningCertificate: `process.env.DISABLE_CODE_SIGNING ? undefined : '${codeSigningCertificatePath
105
+ .replace(/\\/g, '\\\\')
106
+ .replace(/'/g, "\\'")}'`,
105
107
  enabled: true,
106
108
  requestHeaders: {
107
109
  'expo-channel-name': 'process.env.RELEASE_CHANNEL',
@@ -119,6 +121,7 @@ class Init extends core_1.Command {
119
121
  updateConfigSpinner.fail('Failed to update Expo config');
120
122
  log_1.default.error(e);
121
123
  }
124
+ (0, utils_1.ensurePrivateKeyIgnored)(projectDir);
122
125
  }
123
126
  }
124
127
  exports.default = Init;
@@ -23,5 +23,5 @@ export declare function getPublicExpoConfigAsync(projectDir: string, opts?: Expo
23
23
  export declare function getExpoConfigUpdateUrl(config: ExpoConfig): string | undefined;
24
24
  export declare function getExpoAppId(config: ExpoConfig): string | undefined;
25
25
  export declare function requireExpoAppId(config: ExpoConfig): string;
26
- export declare function createOrModifyExpoConfigAsync(projectDir: string, exp: Partial<ExpoConfig>): Promise<void>;
26
+ export declare function createOrModifyExpoConfigAsync(projectDir: string, exp: Record<string, any>): Promise<void>;
27
27
  export declare function resolveServerUrl(config: ExpoConfig): Promise<string>;
@@ -31,7 +31,13 @@ async function getExpoConfigInternalAsync(projectDir, opts = {}) {
31
31
  const runner = (0, packageRunner_1.resolvePackageRunner)(opts.packageRunner, projectDir);
32
32
  const [runnerCommand, runnerArgs] = (0, packageRunner_1.splitPackageRunner)(runner);
33
33
  try {
34
- const { stdout } = await (0, spawn_async_1.default)(runnerCommand, [...runnerArgs, 'expo', 'config', '--json', ...(opts.isPublicConfig ? ['--type', 'public'] : [])], {
34
+ const { stdout } = await (0, spawn_async_1.default)(runnerCommand, [
35
+ ...runnerArgs,
36
+ 'expo',
37
+ 'config',
38
+ '--json',
39
+ ...(opts.isPublicConfig ? ['--type', 'public'] : []),
40
+ ], {
35
41
  cwd: projectDir,
36
42
  env: {
37
43
  ...process.env,
@@ -132,6 +138,10 @@ function requireExpoAppId(config) {
132
138
  return appId;
133
139
  }
134
140
  exports.requireExpoAppId = requireExpoAppId;
141
+ // exp is a config fragment. String values starting with 'process.env.' are
142
+ // emitted as raw JavaScript expressions rather than string literals, so callers
143
+ // can write env-dependent values like
144
+ // "process.env.DISABLE_CODE_SIGNING ? undefined : './certs/certificate.pem'".
135
145
  async function createOrModifyExpoConfigAsync(projectDir, exp) {
136
146
  try {
137
147
  ensureExpoConfigExists(projectDir);
@@ -148,37 +158,22 @@ async function createOrModifyExpoConfigAsync(projectDir, exp) {
148
158
  // eslint-disable-next-line node/no-sync
149
159
  fs_extra_1.default.writeFileSync(configPathJS, newConfigContent);
150
160
  }
151
- else if (hasJsConfig) {
161
+ else {
162
+ const configPath = hasJsConfig ? configPathJS : configPathTS;
152
163
  // eslint-disable-next-line node/no-sync
153
- const existingCode = fs_extra_1.default.readFileSync(configPathJS, 'utf8');
154
- const j = jscodeshift_1.default;
164
+ const existingCode = fs_extra_1.default.readFileSync(configPath, 'utf8');
165
+ const j = configPath.endsWith('.ts') ? jscodeshift_1.default.withParser('ts') : jscodeshift_1.default;
155
166
  const ast = j(existingCode);
156
- ast.find(j.ArrowFunctionExpression).forEach(path => {
157
- if (path.value.body &&
158
- j.BlockStatement.check(path.value.body) &&
159
- path.value.body.body.length > 0) {
160
- const returnStatement = path.value.body.body.find(node => j.ReturnStatement.check(node));
161
- if (returnStatement &&
162
- j.ReturnStatement.check(returnStatement) &&
163
- returnStatement.argument) {
164
- const configObject = returnStatement.argument;
165
- if (j.ObjectExpression.check(configObject)) {
166
- updateObjectExpression(j, configObject, exp);
167
- }
168
- }
169
- }
170
- });
167
+ if (!updateExportedConfigObject(j, ast, exp)) {
168
+ throw new Error(`Could not find the exported config object in ${path_1.default.basename(configPath)}.`);
169
+ }
171
170
  const updatedCode = ast.toSource({
172
171
  quote: 'auto',
173
172
  trailingComma: true,
174
173
  reuseWhitespace: true,
175
174
  });
176
175
  // eslint-disable-next-line node/no-sync
177
- fs_extra_1.default.writeFileSync(configPathJS, updatedCode);
178
- }
179
- else if (configPathTS) {
180
- log_1.default.warn('TypeScript support is not yet implemented.');
181
- throw new Error('TypeScript support is not yet implemented.');
176
+ fs_extra_1.default.writeFileSync(configPath, updatedCode);
182
177
  }
183
178
  }
184
179
  catch (e) {
@@ -192,12 +187,65 @@ async function createOrModifyExpoConfigAsync(projectDir, exp) {
192
187
  }
193
188
  }
194
189
  exports.createOrModifyExpoConfigAsync = createOrModifyExpoConfigAsync;
190
+ // Finds the object literal the dynamic config exports (export default or
191
+ // module.exports; a function returning it, with expression or block body;
192
+ // optional 'as' casts) and merges exp into it. Returns false when no
193
+ // recognizable shape is found, so the caller can fail loudly instead of
194
+ // writing the file back unchanged.
195
+ function updateExportedConfigObject(j, ast, exp) {
196
+ const exportedNodes = [];
197
+ ast.find(j.ExportDefaultDeclaration).forEach(p => exportedNodes.push(p.value.declaration));
198
+ ast
199
+ .find(j.AssignmentExpression, {
200
+ left: { object: { name: 'module' }, property: { name: 'exports' } },
201
+ })
202
+ .forEach(p => exportedNodes.push(p.value.right));
203
+ for (const exportedNode of exportedNodes) {
204
+ const configObject = resolveConfigObject(j, exportedNode);
205
+ if (configObject) {
206
+ updateObjectExpression(j, configObject, exp);
207
+ return true;
208
+ }
209
+ }
210
+ return false;
211
+ }
212
+ function resolveConfigObject(j, exportedNode) {
213
+ let node = unwrapExpression(exportedNode);
214
+ if (j.ArrowFunctionExpression.check(node) ||
215
+ j.FunctionExpression.check(node) ||
216
+ j.FunctionDeclaration.check(node)) {
217
+ if (j.BlockStatement.check(node.body)) {
218
+ const returnStatement = node.body.body.find((statement) => j.ReturnStatement.check(statement));
219
+ node = returnStatement?.argument ?? null;
220
+ }
221
+ else {
222
+ node = node.body;
223
+ }
224
+ node = node && unwrapExpression(node);
225
+ }
226
+ return node && j.ObjectExpression.check(node) ? node : null;
227
+ }
228
+ // Strips TS casts and parentheses: `({ ... } as ExpoConfig)` -> the object.
229
+ function unwrapExpression(node) {
230
+ let current = node;
231
+ while (current &&
232
+ (current.type === 'TSAsExpression' ||
233
+ current.type === 'TSSatisfiesExpression' ||
234
+ current.type === 'ParenthesizedExpression')) {
235
+ current = current.expression;
236
+ }
237
+ return current;
238
+ }
195
239
  function updateObjectExpression(j, configObject, updates) {
196
240
  Object.entries(updates).forEach(([key, value]) => {
197
- const existingProperty = configObject.properties.find(prop => {
198
- return (prop.type === 'Property' &&
199
- ((prop.key.type === 'Identifier' && prop.key.name === key) ||
200
- (prop.key.type === 'StringLiteral' && prop.key.value === key)));
241
+ // The default parser produces 'Property' nodes, the ts parser 'ObjectProperty'.
242
+ const existingProperty = configObject.properties.find((prop) => {
243
+ if (prop.type !== 'Property' && prop.type !== 'ObjectProperty') {
244
+ return false;
245
+ }
246
+ return ((prop.key.type === 'Identifier' && prop.key.name === key) ||
247
+ ((prop.key.type === 'StringLiteral' || prop.key.type === 'Literal') &&
248
+ prop.key.value === key));
201
249
  });
202
250
  if (existingProperty) {
203
251
  configObject.properties = configObject.properties.filter(prop => prop !== existingProperty);
@@ -208,7 +256,10 @@ function updateObjectExpression(j, configObject, updates) {
208
256
  }
209
257
  function createValueNode(j, value) {
210
258
  if (typeof value === 'string' && value.startsWith('process.env.')) {
211
- return j.memberExpression(j.memberExpression(j.identifier('process'), j.identifier('env')), j.identifier(value.split('.')[2]));
259
+ if (/^process\.env\.\w+$/.test(value)) {
260
+ return j.memberExpression(j.memberExpression(j.identifier('process'), j.identifier('env')), j.identifier(value.split('.')[2]));
261
+ }
262
+ return parseExpressionNode(j, value);
212
263
  }
213
264
  if (typeof value === 'object' && value !== null) {
214
265
  return j.objectExpression(Object.entries(value).map(([key, val]) => j.objectProperty(j.stringLiteral(key), createValueNode(j, val)) // Force stringLiteral pour garder les guillemets
@@ -216,8 +267,19 @@ function createValueNode(j, value) {
216
267
  }
217
268
  return j.literal(value);
218
269
  }
270
+ function parseExpressionNode(j, code) {
271
+ const statement = j(`(${code});`).find(j.ExpressionStatement).nodes()[0];
272
+ return statement.expression;
273
+ }
274
+ // Raw expressions are swapped for placeholders before JSON.stringify and
275
+ // spliced back verbatim afterwards, so JSON escaping never mangles their
276
+ // contents (backslashes in Windows paths, quotes, ...).
219
277
  function stringifyWithEnv(obj) {
220
- return JSON.stringify(obj, null, 2).replace(/"process\.env\.(\w+)"/g, 'process.env.$1');
278
+ const rawExpressions = [];
279
+ const json = JSON.stringify(obj, (_key, value) => typeof value === 'string' && value.startsWith('process.env.')
280
+ ? `__RAW_EXPR_${rawExpressions.push(value) - 1}__`
281
+ : value, 2);
282
+ return json.replace(/"__RAW_EXPR_(\d+)__"/g, (_match, index) => rawExpressions[Number(index)]);
221
283
  }
222
284
  async function resolveServerUrl(config) {
223
285
  const updateUrl = config.updates?.url;
@@ -58,7 +58,9 @@ function detectRunnerFromPackageJson(startDir) {
58
58
  while (dir !== root) {
59
59
  const pkgPath = path_1.default.join(dir, 'package.json');
60
60
  try {
61
+ // eslint-disable-next-line node/no-sync
61
62
  if (fs_extra_1.default.existsSync(pkgPath)) {
63
+ // eslint-disable-next-line node/no-sync
62
64
  const pkg = fs_extra_1.default.readJsonSync(pkgPath);
63
65
  if (pkg.packageManager) {
64
66
  const name = pkg.packageManager.split('@')[0];
@@ -1 +1,2 @@
1
1
  export declare function isValidUpdateUrl(updateUrl: string): boolean;
2
+ export declare function ensurePrivateKeyIgnored(projectDir: string): void;
package/dist/lib/utils.js CHANGED
@@ -1,7 +1,38 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isValidUpdateUrl = void 0;
3
+ exports.ensurePrivateKeyIgnored = exports.isValidUpdateUrl = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
6
+ const path_1 = tslib_1.__importDefault(require("path"));
7
+ const log_1 = tslib_1.__importDefault(require("./log"));
4
8
  function isValidUpdateUrl(updateUrl) {
5
9
  return updateUrl.match(/^https?:\/\/[^/]+$/) !== null;
6
10
  }
7
11
  exports.isValidUpdateUrl = isValidUpdateUrl;
12
+ // Keeps the code signing private key out of the app repository: appends a bare
13
+ // 'private-key.pem' pattern to the project .gitignore (a pattern without a
14
+ // slash matches at every directory level). Only an existing bare rule counts:
15
+ // comments, negated entries and path-specific rules like certs/private-key.pem
16
+ // do not guarantee project-wide protection. Appending at the end also wins over
17
+ // an earlier negated entry, since the last matching gitignore rule prevails.
18
+ function ensurePrivateKeyIgnored(projectDir) {
19
+ const gitignorePath = path_1.default.join(projectDir, '.gitignore');
20
+ try {
21
+ // eslint-disable-next-line node/no-sync
22
+ const gitignore = fs_extra_1.default.existsSync(gitignorePath) ? fs_extra_1.default.readFileSync(gitignorePath, 'utf8') : '';
23
+ const lines = gitignore.split(/\r?\n/).map(line => line.trim());
24
+ const lastBareRule = lines.lastIndexOf('private-key.pem');
25
+ const lastNegation = lines.lastIndexOf('!private-key.pem');
26
+ if (lastBareRule !== -1 && lastBareRule > lastNegation) {
27
+ return;
28
+ }
29
+ const separator = gitignore === '' ? '' : gitignore.endsWith('\n') ? '\n' : '\n\n';
30
+ // eslint-disable-next-line node/no-sync
31
+ fs_extra_1.default.appendFileSync(gitignorePath, `${separator}# Code signing private key (server-side secret, never commit it)\nprivate-key.pem\n`);
32
+ log_1.default.succeed('Added private-key.pem to .gitignore');
33
+ }
34
+ catch {
35
+ log_1.default.warn('Could not update .gitignore. Make sure private-key.pem is never committed to your repository.');
36
+ }
37
+ }
38
+ exports.ensurePrivateKeyIgnored = ensurePrivateKeyIgnored;
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "eoas",
3
- "version": "3.0.3",
3
+ "version": "3.0.5",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "build": "tsc --project tsconfig.json",
7
7
  "watch": "tsc --project tsconfig.json --watch",
8
- "lint": "eslint ."
8
+ "lint": "eslint .",
9
+ "test": "vitest run"
9
10
  },
10
11
  "engines": {
11
12
  "node": ">=18.0.0"
@@ -82,7 +83,8 @@
82
83
  "eslint-plugin-async-protect": "^3.1.0",
83
84
  "eslint-plugin-node": "^11.1.0",
84
85
  "ts-node": "10.9.2",
85
- "typescript": "5.3.3"
86
+ "typescript": "5.3.3",
87
+ "vitest": "^2.1.9"
86
88
  },
87
89
  "bin": {
88
90
  "eoas": "bin/run.js"