create-expo-module
Advanced tools
| const path = require('path'); | ||
| /** @type {import('jest').Config} */ | ||
| module.exports = { | ||
| testEnvironment: 'node', | ||
| testTimeout: 1000 * 60 * 5, // e2e tests can be slow, default to 5m (module creation includes npm install) | ||
| testRegex: '/__tests__/.*(test|spec)\\.[jt]sx?$', | ||
| watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], | ||
| rootDir: path.resolve(__dirname), | ||
| displayName: require('../package').name, | ||
| roots: ['.'], | ||
| transform: { | ||
| '^.+\\.tsx?$': [ | ||
| 'ts-jest', | ||
| { | ||
| tsconfig: path.resolve(__dirname, 'tsconfig.json'), | ||
| }, | ||
| ], | ||
| }, | ||
| }; |
| { | ||
| "extends": "@tsconfig/node18/tsconfig.json", | ||
| "include": ["./__tests__"], | ||
| "compilerOptions": { | ||
| "outDir": "./build", | ||
| "module": "commonjs", | ||
| "moduleResolution": "node", | ||
| "types": ["node", "jest"], | ||
| "typeRoots": ["../../../node_modules/@types"], | ||
| "sourceMap": true, | ||
| "strictNullChecks": true, | ||
| "skipLibCheck": true, | ||
| "esModuleInterop": true, | ||
| "allowSyntheticDefaultImports": true | ||
| } | ||
| } |
| /** @type {import('jest').Config} */ | ||
| module.exports = { | ||
| ...require('expo-module-scripts/jest-preset-cli'), | ||
| displayName: require('./package').name, | ||
| rootDir: __dirname, | ||
| testPathIgnorePatterns: ['<rootDir>/e2e/', '<rootDir>/node_modules/'], | ||
| }; |
+8
-5
| { | ||
| "name": "create-expo-module", | ||
| "version": "1.0.14-canary-20260119-17896bf", | ||
| "version": "1.0.14", | ||
| "description": "The script to create the Expo module", | ||
@@ -11,2 +11,4 @@ "main": "build/create-expo-module.js", | ||
| "lint": "expo-module lint", | ||
| "test": "expo-module test", | ||
| "test:e2e": "expo-module test --config e2e/jest.config.js --runInBand", | ||
| "expo-module": "expo-module" | ||
@@ -37,4 +39,4 @@ }, | ||
| "dependencies": { | ||
| "@expo/config": "12.0.14-canary-20260119-17896bf", | ||
| "@expo/json-file": "10.0.9-canary-20260119-17896bf", | ||
| "@expo/config": "~12.0.13", | ||
| "@expo/json-file": "^10.0.8", | ||
| "@expo/rudder-sdk-node": "^1.1.1", | ||
@@ -57,4 +59,5 @@ "@expo/spawn-async": "^1.7.2", | ||
| "@types/prompts": "^2.4.9", | ||
| "expo-module-scripts": "5.0.9-canary-20260119-17896bf" | ||
| } | ||
| "expo-module-scripts": "^5.0.8" | ||
| }, | ||
| "gitHead": "fabc6ba2a61c761989817c9656d568bce35bbff9" | ||
| } |
+203
-14
@@ -10,2 +10,3 @@ import spawnAsync from '@expo/spawn-async'; | ||
| import prompts from 'prompts'; | ||
| import validateNpmPackage from 'validate-npm-package-name'; | ||
@@ -27,2 +28,4 @@ import { createExampleApp } from './createExampleApp'; | ||
| import { CommandOptions, LocalSubstitutionData, SubstitutionData } from './types'; | ||
| import { findGitHubEmail, findMyName } from './utils/git'; | ||
| import { findGitHubUserFromEmail, guessRepoUrl } from './utils/github'; | ||
| import { newStep } from './utils/ora'; | ||
@@ -55,2 +58,40 @@ | ||
| /** | ||
| * Determines if we're in an interactive environment. | ||
| * Non-interactive when: CI=1/true or non-TTY stdin. | ||
| */ | ||
| function isInteractive(): boolean { | ||
| // Check for CI environment | ||
| const ci = process.env.CI; | ||
| if (ci === '1' || ci === 'true' || ci?.toLowerCase() === 'true') { | ||
| return false; | ||
| } | ||
| // Check for TTY | ||
| if (!process.stdin.isTTY) { | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| /** | ||
| * Converts a slug to a native module name (PascalCase). | ||
| */ | ||
| function slugToModuleName(slug: string): string { | ||
| return slug | ||
| .replace(/^@/, '') | ||
| .replace(/^./, (match) => match.toUpperCase()) | ||
| .replace(/\W+(\w)/g, (_, p1) => p1.toUpperCase()); | ||
| } | ||
| /** | ||
| * Converts a slug to an Android package name. | ||
| */ | ||
| function slugToAndroidPackage(slug: string): string { | ||
| const namespace = slug | ||
| .replace(/\W/g, '') | ||
| .replace(/^(expo|reactnative)/, '') | ||
| .toLowerCase(); | ||
| return `expo.modules.${namespace}`; | ||
| } | ||
| async function getCorrectLocalDirectory(targetOrSlug: string) { | ||
@@ -88,2 +129,7 @@ let packageJsonPath: string | null = null; | ||
| async function main(target: string | undefined, options: CommandOptions) { | ||
| const interactive = isInteractive(); | ||
| if (!interactive) { | ||
| debug('Running in non-interactive mode'); | ||
| } | ||
| if (options.local) { | ||
@@ -100,3 +146,3 @@ console.log(); | ||
| } | ||
| const slug = await askForPackageSlugAsync(target, options.local); | ||
| const slug = await askForPackageSlugAsync(target, options.local, options); | ||
| const targetDir = options.local | ||
@@ -110,7 +156,7 @@ ? await getCorrectLocalDirectory(target || slug) | ||
| await fs.promises.mkdir(targetDir, { recursive: true }); | ||
| await confirmTargetDirAsync(targetDir); | ||
| await confirmTargetDirAsync(targetDir, options); | ||
| options.target = targetDir; | ||
| const data = await askForSubstitutionDataAsync(slug, options.local); | ||
| const data = await askForSubstitutionDataAsync(slug, options.local, options); | ||
@@ -360,4 +406,22 @@ // Make one line break between prompts and progress logs | ||
| * Asks the user for the package slug (npm package name). | ||
| * In non-interactive mode, uses the target path or 'my-module' as default. | ||
| */ | ||
| async function askForPackageSlugAsync(customTargetPath?: string, isLocal = false): Promise<string> { | ||
| async function askForPackageSlugAsync( | ||
| customTargetPath: string | undefined, | ||
| isLocal: boolean, | ||
| options: CommandOptions | ||
| ): Promise<string> { | ||
| const interactive = isInteractive(); | ||
| // In non-interactive mode, derive slug from target path or use default | ||
| if (!interactive) { | ||
| const targetBasename = customTargetPath && path.basename(customTargetPath); | ||
| const slug = | ||
| targetBasename && validateNpmPackage(targetBasename).validForNewPackages | ||
| ? targetBasename | ||
| : 'my-module'; | ||
| debug(`Non-interactive mode: using slug "${slug}"`); | ||
| return slug; | ||
| } | ||
| const { slug } = await prompts( | ||
@@ -375,7 +439,17 @@ (isLocal ? getLocalFolderNamePrompt : getSlugPrompt)(customTargetPath), | ||
| * Some values may already be provided by command options, the prompt is skipped in that case. | ||
| * In non-interactive mode, uses defaults or CLI-provided values. | ||
| */ | ||
| async function askForSubstitutionDataAsync( | ||
| slug: string, | ||
| isLocal = false | ||
| isLocal: boolean, | ||
| options: CommandOptions | ||
| ): Promise<SubstitutionData | LocalSubstitutionData> { | ||
| const interactive = isInteractive(); | ||
| // Non-interactive mode: use CLI options and defaults | ||
| if (!interactive) { | ||
| return getSubstitutionDataFromOptions(slug, isLocal, options); | ||
| } | ||
| // Interactive mode: prompt for values, but skip prompts for CLI-provided values | ||
| const promptQueries = await ( | ||
@@ -385,2 +459,9 @@ isLocal ? getLocalSubstitutionDataPrompts : getSubstitutionDataPrompts | ||
| // Filter out prompts for values already provided via CLI | ||
| const filteredPrompts = promptQueries.filter((prompt) => { | ||
| const name = prompt.name as string; | ||
| const cliValue = getCliValueForPrompt(name, options); | ||
| return cliValue === undefined; | ||
| }); | ||
| // Stop the process when the user cancels/exits the prompt. | ||
@@ -391,12 +472,87 @@ const onCancel = () => { | ||
| const { | ||
| name, | ||
| description, | ||
| package: projectPackage, | ||
| authorName, | ||
| authorEmail, | ||
| authorUrl, | ||
| // Get values from prompts | ||
| const promptedValues = | ||
| filteredPrompts.length > 0 ? await prompts(filteredPrompts, { onCancel }) : {}; | ||
| // Merge CLI-provided values with prompted values | ||
| const name = options.name ?? promptedValues.name ?? slugToModuleName(slug); | ||
| const projectPackage = options.package ?? promptedValues.package ?? slugToAndroidPackage(slug); | ||
| if (isLocal) { | ||
| return { | ||
| project: { | ||
| slug, | ||
| name, | ||
| package: projectPackage, | ||
| moduleName: handleSuffix(name, 'Module'), | ||
| viewName: handleSuffix(name, 'View'), | ||
| }, | ||
| type: 'local', | ||
| }; | ||
| } | ||
| const description = options.description ?? promptedValues.description ?? 'My new module'; | ||
| const authorName = options.authorName ?? promptedValues.authorName ?? (await findMyName()) ?? ''; | ||
| const authorEmail = | ||
| options.authorEmail ?? promptedValues.authorEmail ?? (await findGitHubEmail()) ?? ''; | ||
| const authorUrl = | ||
| options.authorUrl ?? | ||
| promptedValues.authorUrl ?? | ||
| (authorEmail ? ((await findGitHubUserFromEmail(authorEmail)) ?? '') : ''); | ||
| const repo = options.repo ?? promptedValues.repo ?? (await guessRepoUrl(authorUrl, slug)) ?? ''; | ||
| return { | ||
| project: { | ||
| slug, | ||
| name, | ||
| version: '0.1.0', | ||
| description, | ||
| package: projectPackage, | ||
| moduleName: handleSuffix(name, 'Module'), | ||
| viewName: handleSuffix(name, 'View'), | ||
| }, | ||
| author: `${authorName} <${authorEmail}> (${authorUrl})`, | ||
| license: 'MIT', | ||
| repo, | ||
| } = await prompts(promptQueries, { onCancel }); | ||
| type: 'remote', | ||
| }; | ||
| } | ||
| /** | ||
| * Gets the CLI value for a given prompt name. | ||
| */ | ||
| function getCliValueForPrompt(promptName: string, options: CommandOptions): string | undefined { | ||
| switch (promptName) { | ||
| case 'name': | ||
| return options.name; | ||
| case 'description': | ||
| return options.description; | ||
| case 'package': | ||
| return options.package; | ||
| case 'authorName': | ||
| return options.authorName; | ||
| case 'authorEmail': | ||
| return options.authorEmail; | ||
| case 'authorUrl': | ||
| return options.authorUrl; | ||
| case 'repo': | ||
| return options.repo; | ||
| default: | ||
| return undefined; | ||
| } | ||
| } | ||
| /** | ||
| * Gets substitution data from CLI options and defaults (for non-interactive mode). | ||
| */ | ||
| async function getSubstitutionDataFromOptions( | ||
| slug: string, | ||
| isLocal: boolean, | ||
| options: CommandOptions | ||
| ): Promise<SubstitutionData | LocalSubstitutionData> { | ||
| const name = options.name ?? slugToModuleName(slug); | ||
| const projectPackage = options.package ?? slugToAndroidPackage(slug); | ||
| debug(`Non-interactive mode: name="${name}", package="${projectPackage}"`); | ||
| if (isLocal) { | ||
@@ -415,2 +571,14 @@ return { | ||
| // For remote modules, resolve author info | ||
| const description = options.description ?? 'My new module'; | ||
| const authorName = options.authorName ?? (await findMyName()) ?? ''; | ||
| const authorEmail = options.authorEmail ?? (await findGitHubEmail()) ?? ''; | ||
| const authorUrl = | ||
| options.authorUrl ?? (authorEmail ? ((await findGitHubUserFromEmail(authorEmail)) ?? '') : ''); | ||
| const repo = options.repo ?? (await guessRepoUrl(authorUrl, slug)) ?? ''; | ||
| debug( | ||
| `Non-interactive mode: description="${description}", authorName="${authorName}", authorEmail="${authorEmail}", authorUrl="${authorUrl}", repo="${repo}"` | ||
| ); | ||
| return { | ||
@@ -435,4 +603,5 @@ project: { | ||
| * Checks whether the target directory is empty and if not, asks the user to confirm if he wants to continue. | ||
| * In non-interactive mode, automatically continues (assumes intent to overwrite). | ||
| */ | ||
| async function confirmTargetDirAsync(targetDir: string): Promise<void> { | ||
| async function confirmTargetDirAsync(targetDir: string, options: CommandOptions): Promise<void> { | ||
| const files = await fs.promises.readdir(targetDir); | ||
@@ -442,2 +611,14 @@ if (files.length === 0) { | ||
| } | ||
| // In non-interactive mode, proceed automatically | ||
| if (!isInteractive()) { | ||
| debug(`Non-interactive mode: target directory "${targetDir}" is not empty, continuing anyway`); | ||
| console.log( | ||
| chalk.yellow( | ||
| `Warning: Target directory ${chalk.magenta(targetDir)} is not empty, continuing anyway.` | ||
| ) | ||
| ); | ||
| return; | ||
| } | ||
| const { shouldContinue } = await prompts( | ||
@@ -519,2 +700,10 @@ { | ||
| ) | ||
| // Module configuration options (skip prompts when provided) | ||
| .option('--name <name>', 'Native module name (e.g., MyModule).') | ||
| .option('--description <description>', 'Module description.') | ||
| .option('--package <package>', 'Android package name (e.g., expo.modules.mymodule).') | ||
| .option('--author-name <name>', 'Author name for package.json.') | ||
| .option('--author-email <email>', 'Author email for package.json.') | ||
| .option('--author-url <url>', "URL to the author's profile (e.g., GitHub profile).") | ||
| .option('--repo <url>', 'URL of the repository.') | ||
| .action(main); | ||
@@ -521,0 +710,0 @@ |
+8
-0
@@ -13,2 +13,10 @@ import { PromptObject } from 'prompts'; | ||
| local: boolean; | ||
| // Module configuration options (skip prompts when provided) | ||
| name?: string; | ||
| description?: string; | ||
| package?: string; | ||
| authorName?: string; | ||
| authorEmail?: string; | ||
| authorUrl?: string; | ||
| repo?: string; | ||
| }; | ||
@@ -15,0 +23,0 @@ |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 5 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 5 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
165708
5.08%52
6.12%2475
9.71%0
-100%22
10%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated