create-expo-module 1.0.13 → 1.0.14

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.
@@ -0,0 +1,20 @@
1
+ const path = require('path');
2
+
3
+ /** @type {import('jest').Config} */
4
+ module.exports = {
5
+ testEnvironment: 'node',
6
+ testTimeout: 1000 * 60 * 5, // e2e tests can be slow, default to 5m (module creation includes npm install)
7
+ testRegex: '/__tests__/.*(test|spec)\\.[jt]sx?$',
8
+ watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
9
+ rootDir: path.resolve(__dirname),
10
+ displayName: require('../package').name,
11
+ roots: ['.'],
12
+ transform: {
13
+ '^.+\\.tsx?$': [
14
+ 'ts-jest',
15
+ {
16
+ tsconfig: path.resolve(__dirname, 'tsconfig.json'),
17
+ },
18
+ ],
19
+ },
20
+ };
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "@tsconfig/node18/tsconfig.json",
3
+ "include": ["./__tests__"],
4
+ "compilerOptions": {
5
+ "outDir": "./build",
6
+ "module": "commonjs",
7
+ "moduleResolution": "node",
8
+ "types": ["node", "jest"],
9
+ "typeRoots": ["../../../node_modules/@types"],
10
+ "sourceMap": true,
11
+ "strictNullChecks": true,
12
+ "skipLibCheck": true,
13
+ "esModuleInterop": true,
14
+ "allowSyntheticDefaultImports": true
15
+ }
16
+ }
package/jest.config.js ADDED
@@ -0,0 +1,7 @@
1
+ /** @type {import('jest').Config} */
2
+ module.exports = {
3
+ ...require('expo-module-scripts/jest-preset-cli'),
4
+ displayName: require('./package').name,
5
+ rootDir: __dirname,
6
+ testPathIgnorePatterns: ['<rootDir>/e2e/', '<rootDir>/node_modules/'],
7
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-expo-module",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "The script to create the Expo module",
5
5
  "main": "build/create-expo-module.js",
6
6
  "types": "build/create-expo-module.d.ts",
@@ -8,6 +8,8 @@
8
8
  "build": "expo-module build",
9
9
  "clean": "expo-module clean",
10
10
  "lint": "expo-module lint",
11
+ "test": "expo-module test",
12
+ "test:e2e": "expo-module test --config e2e/jest.config.js --runInBand",
11
13
  "expo-module": "expo-module"
12
14
  },
13
15
  "bin": {
@@ -33,7 +35,7 @@
33
35
  },
34
36
  "homepage": "https://github.com/expo/expo/tree/main/packages/expo",
35
37
  "dependencies": {
36
- "@expo/config": "~12.0.12",
38
+ "@expo/config": "~12.0.13",
37
39
  "@expo/json-file": "^10.0.8",
38
40
  "@expo/rudder-sdk-node": "^1.1.1",
39
41
  "@expo/spawn-async": "^1.7.2",
@@ -54,5 +56,5 @@
54
56
  "@types/prompts": "^2.4.9",
55
57
  "expo-module-scripts": "^5.0.8"
56
58
  },
57
- "gitHead": "599ebc94db5c972f961641db9aa33e2964498c42"
59
+ "gitHead": "fabc6ba2a61c761989817c9656d568bce35bbff9"
58
60
  }
@@ -7,6 +7,7 @@ import fs from 'fs';
7
7
  import { boolish } from 'getenv';
8
8
  import path from 'path';
9
9
  import prompts from 'prompts';
10
+ import validateNpmPackage from 'validate-npm-package-name';
10
11
 
11
12
  import { createExampleApp } from './createExampleApp';
12
13
  import { installDependencies } from './packageManager';
@@ -23,6 +24,8 @@ import {
23
24
  } from './resolvePackageManager';
24
25
  import { eventCreateExpoModule, getTelemetryClient, logEventAsync } from './telemetry';
25
26
  import { CommandOptions, LocalSubstitutionData, SubstitutionData } from './types';
27
+ import { findGitHubEmail, findMyName } from './utils/git';
28
+ import { findGitHubUserFromEmail, guessRepoUrl } from './utils/github';
26
29
  import { newStep } from './utils/ora';
27
30
 
28
31
  const debug = require('debug')('create-expo-module:main') as typeof console.log;
@@ -50,6 +53,44 @@ const DOCS_URL = 'https://docs.expo.dev/modules';
50
53
 
51
54
  const FYI_LOCAL_DIR = 'https://expo.fyi/expo-module-local-autolinking.md';
52
55
 
56
+ /**
57
+ * Determines if we're in an interactive environment.
58
+ * Non-interactive when: CI=1/true or non-TTY stdin.
59
+ */
60
+ function isInteractive(): boolean {
61
+ // Check for CI environment
62
+ const ci = process.env.CI;
63
+ if (ci === '1' || ci === 'true' || ci?.toLowerCase() === 'true') {
64
+ return false;
65
+ }
66
+ // Check for TTY
67
+ if (!process.stdin.isTTY) {
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+
73
+ /**
74
+ * Converts a slug to a native module name (PascalCase).
75
+ */
76
+ function slugToModuleName(slug: string): string {
77
+ return slug
78
+ .replace(/^@/, '')
79
+ .replace(/^./, (match) => match.toUpperCase())
80
+ .replace(/\W+(\w)/g, (_, p1) => p1.toUpperCase());
81
+ }
82
+
83
+ /**
84
+ * Converts a slug to an Android package name.
85
+ */
86
+ function slugToAndroidPackage(slug: string): string {
87
+ const namespace = slug
88
+ .replace(/\W/g, '')
89
+ .replace(/^(expo|reactnative)/, '')
90
+ .toLowerCase();
91
+ return `expo.modules.${namespace}`;
92
+ }
93
+
53
94
  async function getCorrectLocalDirectory(targetOrSlug: string) {
54
95
  let packageJsonPath: string | null = null;
55
96
  for (let dir = CWD; path.dirname(dir) !== dir; dir = path.dirname(dir)) {
@@ -82,6 +123,11 @@ async function getCorrectLocalDirectory(targetOrSlug: string) {
82
123
  * @param options An options object for `commander`.
83
124
  */
84
125
  async function main(target: string | undefined, options: CommandOptions) {
126
+ const interactive = isInteractive();
127
+ if (!interactive) {
128
+ debug('Running in non-interactive mode');
129
+ }
130
+
85
131
  if (options.local) {
86
132
  console.log();
87
133
  console.log(
@@ -93,7 +139,7 @@ async function main(target: string | undefined, options: CommandOptions) {
93
139
  );
94
140
  console.log();
95
141
  }
96
- const slug = await askForPackageSlugAsync(target, options.local);
142
+ const slug = await askForPackageSlugAsync(target, options.local, options);
97
143
  const targetDir = options.local
98
144
  ? await getCorrectLocalDirectory(target || slug)
99
145
  : path.join(CWD, target || slug);
@@ -102,11 +148,11 @@ async function main(target: string | undefined, options: CommandOptions) {
102
148
  return;
103
149
  }
104
150
  await fs.promises.mkdir(targetDir, { recursive: true });
105
- await confirmTargetDirAsync(targetDir);
151
+ await confirmTargetDirAsync(targetDir, options);
106
152
 
107
153
  options.target = targetDir;
108
154
 
109
- const data = await askForSubstitutionDataAsync(slug, options.local);
155
+ const data = await askForSubstitutionDataAsync(slug, options.local, options);
110
156
 
111
157
  // Make one line break between prompts and progress logs
112
158
  console.log();
@@ -351,8 +397,26 @@ async function createGitRepositoryAsync(targetDir: string) {
351
397
 
352
398
  /**
353
399
  * Asks the user for the package slug (npm package name).
400
+ * In non-interactive mode, uses the target path or 'my-module' as default.
354
401
  */
355
- async function askForPackageSlugAsync(customTargetPath?: string, isLocal = false): Promise<string> {
402
+ async function askForPackageSlugAsync(
403
+ customTargetPath: string | undefined,
404
+ isLocal: boolean,
405
+ options: CommandOptions
406
+ ): Promise<string> {
407
+ const interactive = isInteractive();
408
+
409
+ // In non-interactive mode, derive slug from target path or use default
410
+ if (!interactive) {
411
+ const targetBasename = customTargetPath && path.basename(customTargetPath);
412
+ const slug =
413
+ targetBasename && validateNpmPackage(targetBasename).validForNewPackages
414
+ ? targetBasename
415
+ : 'my-module';
416
+ debug(`Non-interactive mode: using slug "${slug}"`);
417
+ return slug;
418
+ }
419
+
356
420
  const { slug } = await prompts(
357
421
  (isLocal ? getLocalFolderNamePrompt : getSlugPrompt)(customTargetPath),
358
422
  {
@@ -365,29 +429,121 @@ async function askForPackageSlugAsync(customTargetPath?: string, isLocal = false
365
429
  /**
366
430
  * Asks the user for some data necessary to render the template.
367
431
  * Some values may already be provided by command options, the prompt is skipped in that case.
432
+ * In non-interactive mode, uses defaults or CLI-provided values.
368
433
  */
369
434
  async function askForSubstitutionDataAsync(
370
435
  slug: string,
371
- isLocal = false
436
+ isLocal: boolean,
437
+ options: CommandOptions
372
438
  ): Promise<SubstitutionData | LocalSubstitutionData> {
439
+ const interactive = isInteractive();
440
+
441
+ // Non-interactive mode: use CLI options and defaults
442
+ if (!interactive) {
443
+ return getSubstitutionDataFromOptions(slug, isLocal, options);
444
+ }
445
+
446
+ // Interactive mode: prompt for values, but skip prompts for CLI-provided values
373
447
  const promptQueries = await (
374
448
  isLocal ? getLocalSubstitutionDataPrompts : getSubstitutionDataPrompts
375
449
  )(slug);
376
450
 
451
+ // Filter out prompts for values already provided via CLI
452
+ const filteredPrompts = promptQueries.filter((prompt) => {
453
+ const name = prompt.name as string;
454
+ const cliValue = getCliValueForPrompt(name, options);
455
+ return cliValue === undefined;
456
+ });
457
+
377
458
  // Stop the process when the user cancels/exits the prompt.
378
459
  const onCancel = () => {
379
460
  process.exit(0);
380
461
  };
381
462
 
382
- const {
383
- name,
384
- description,
385
- package: projectPackage,
386
- authorName,
387
- authorEmail,
388
- authorUrl,
463
+ // Get values from prompts
464
+ const promptedValues =
465
+ filteredPrompts.length > 0 ? await prompts(filteredPrompts, { onCancel }) : {};
466
+
467
+ // Merge CLI-provided values with prompted values
468
+ const name = options.name ?? promptedValues.name ?? slugToModuleName(slug);
469
+ const projectPackage = options.package ?? promptedValues.package ?? slugToAndroidPackage(slug);
470
+
471
+ if (isLocal) {
472
+ return {
473
+ project: {
474
+ slug,
475
+ name,
476
+ package: projectPackage,
477
+ moduleName: handleSuffix(name, 'Module'),
478
+ viewName: handleSuffix(name, 'View'),
479
+ },
480
+ type: 'local',
481
+ };
482
+ }
483
+
484
+ const description = options.description ?? promptedValues.description ?? 'My new module';
485
+ const authorName = options.authorName ?? promptedValues.authorName ?? (await findMyName()) ?? '';
486
+ const authorEmail =
487
+ options.authorEmail ?? promptedValues.authorEmail ?? (await findGitHubEmail()) ?? '';
488
+ const authorUrl =
489
+ options.authorUrl ??
490
+ promptedValues.authorUrl ??
491
+ (authorEmail ? ((await findGitHubUserFromEmail(authorEmail)) ?? '') : '');
492
+ const repo = options.repo ?? promptedValues.repo ?? (await guessRepoUrl(authorUrl, slug)) ?? '';
493
+
494
+ return {
495
+ project: {
496
+ slug,
497
+ name,
498
+ version: '0.1.0',
499
+ description,
500
+ package: projectPackage,
501
+ moduleName: handleSuffix(name, 'Module'),
502
+ viewName: handleSuffix(name, 'View'),
503
+ },
504
+ author: `${authorName} <${authorEmail}> (${authorUrl})`,
505
+ license: 'MIT',
389
506
  repo,
390
- } = await prompts(promptQueries, { onCancel });
507
+ type: 'remote',
508
+ };
509
+ }
510
+
511
+ /**
512
+ * Gets the CLI value for a given prompt name.
513
+ */
514
+ function getCliValueForPrompt(promptName: string, options: CommandOptions): string | undefined {
515
+ switch (promptName) {
516
+ case 'name':
517
+ return options.name;
518
+ case 'description':
519
+ return options.description;
520
+ case 'package':
521
+ return options.package;
522
+ case 'authorName':
523
+ return options.authorName;
524
+ case 'authorEmail':
525
+ return options.authorEmail;
526
+ case 'authorUrl':
527
+ return options.authorUrl;
528
+ case 'repo':
529
+ return options.repo;
530
+ default:
531
+ return undefined;
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Gets substitution data from CLI options and defaults (for non-interactive mode).
537
+ */
538
+ async function getSubstitutionDataFromOptions(
539
+ slug: string,
540
+ isLocal: boolean,
541
+ options: CommandOptions
542
+ ): Promise<SubstitutionData | LocalSubstitutionData> {
543
+ const name = options.name ?? slugToModuleName(slug);
544
+ const projectPackage = options.package ?? slugToAndroidPackage(slug);
545
+
546
+ debug(`Non-interactive mode: name="${name}", package="${projectPackage}"`);
391
547
 
392
548
  if (isLocal) {
393
549
  return {
@@ -402,6 +558,18 @@ async function askForSubstitutionDataAsync(
402
558
  };
403
559
  }
404
560
 
561
+ // For remote modules, resolve author info
562
+ const description = options.description ?? 'My new module';
563
+ const authorName = options.authorName ?? (await findMyName()) ?? '';
564
+ const authorEmail = options.authorEmail ?? (await findGitHubEmail()) ?? '';
565
+ const authorUrl =
566
+ options.authorUrl ?? (authorEmail ? ((await findGitHubUserFromEmail(authorEmail)) ?? '') : '');
567
+ const repo = options.repo ?? (await guessRepoUrl(authorUrl, slug)) ?? '';
568
+
569
+ debug(
570
+ `Non-interactive mode: description="${description}", authorName="${authorName}", authorEmail="${authorEmail}", authorUrl="${authorUrl}", repo="${repo}"`
571
+ );
572
+
405
573
  return {
406
574
  project: {
407
575
  slug,
@@ -421,12 +589,25 @@ async function askForSubstitutionDataAsync(
421
589
 
422
590
  /**
423
591
  * Checks whether the target directory is empty and if not, asks the user to confirm if he wants to continue.
592
+ * In non-interactive mode, automatically continues (assumes intent to overwrite).
424
593
  */
425
- async function confirmTargetDirAsync(targetDir: string): Promise<void> {
594
+ async function confirmTargetDirAsync(targetDir: string, options: CommandOptions): Promise<void> {
426
595
  const files = await fs.promises.readdir(targetDir);
427
596
  if (files.length === 0) {
428
597
  return;
429
598
  }
599
+
600
+ // In non-interactive mode, proceed automatically
601
+ if (!isInteractive()) {
602
+ debug(`Non-interactive mode: target directory "${targetDir}" is not empty, continuing anyway`);
603
+ console.log(
604
+ chalk.yellow(
605
+ `Warning: Target directory ${chalk.magenta(targetDir)} is not empty, continuing anyway.`
606
+ )
607
+ );
608
+ return;
609
+ }
610
+
430
611
  const { shouldContinue } = await prompts(
431
612
  {
432
613
  type: 'confirm',
@@ -503,6 +684,14 @@ program
503
684
  'Whether to create a local module in the current project, skipping installing node_modules and creating the example directory.',
504
685
  false
505
686
  )
687
+ // Module configuration options (skip prompts when provided)
688
+ .option('--name <name>', 'Native module name (e.g., MyModule).')
689
+ .option('--description <description>', 'Module description.')
690
+ .option('--package <package>', 'Android package name (e.g., expo.modules.mymodule).')
691
+ .option('--author-name <name>', 'Author name for package.json.')
692
+ .option('--author-email <email>', 'Author email for package.json.')
693
+ .option('--author-url <url>', "URL to the author's profile (e.g., GitHub profile).")
694
+ .option('--repo <url>', 'URL of the repository.')
506
695
  .action(main);
507
696
 
508
697
  program
package/src/types.ts CHANGED
@@ -10,6 +10,14 @@ export type CommandOptions = {
10
10
  withChangelog: boolean;
11
11
  example: boolean;
12
12
  local: boolean;
13
+ // Module configuration options (skip prompts when provided)
14
+ name?: string;
15
+ description?: string;
16
+ package?: string;
17
+ authorName?: string;
18
+ authorEmail?: string;
19
+ authorUrl?: string;
20
+ repo?: string;
13
21
  };
14
22
 
15
23
  /**