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
3 changes: 3 additions & 0 deletions __mocks__/fs-extra.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export default {
DONT_MOCK_PATTERNS = [];
},
...memfs.promises,
pathExists(path) {
return this.exists(path);
},
exists(path) {
if (dontMock(path)) {
return fse.exists(path);
Expand Down
17 changes: 15 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ export default function runCli() {
.description('Install and configure Jest and Testing Library')
.action(buildAction(import('./commands/testingLibrary')));

program
const add = program
.command('add')
.description('Add a new feature to your project')
.description('Add a new feature to your project');

add
.command('notifications')
.description(
'Install and configure React Native Firebase with Notifications',
Expand All @@ -62,6 +64,17 @@ export default function runCli() {
)
.action(buildAction(import('./commands/notifications')));

add
.command('env')
.description(
'Set up environment variable management with expo-constants and dotenv',
)
.option(
'--no-interactive',
'Pass true to skip all prompts and use default values',
)
.action(buildAction(import('./commands/env')));

printWelcome();
program.parse();
}
134 changes: 134 additions & 0 deletions src/commands/__tests__/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { confirm } from '@inquirer/prompts';
import { fs, vol } from 'memfs';
import { Mock, afterEach, expect, test, vi } from 'vitest';
import exec from '../../util/exec';
import { addEnv } from '../env';

vi.mock('../../util/print', () => ({ default: vi.fn() }));
vi.mock('@inquirer/prompts', () => ({ confirm: vi.fn() }));
vi.mock('../../util/exec');

const baseFiles = {
'package.json': JSON.stringify({
scripts: {},
dependencies: {},
devDependencies: {},
}),
'yarn.lock': '',
};

afterEach(() => {
vol.reset();
vi.clearAllMocks();
});

test('copies all template files to correct destinations', async () => {
(confirm as Mock).mockResolvedValueOnce(true);
vol.fromJSON(baseFiles, './');

await addEnv();

expect(fs.existsSync('.env.example')).toBe(true);
expect(fs.existsSync('.env')).toBe(true);
expect(fs.existsSync('.env.test')).toBe(true);
expect(fs.existsSync('jest.setup.env.js')).toBe(true);
expect(fs.existsSync('src/config/index.ts')).toBe(true);
});

test('creates .env from .env.example when .env does not exist', async () => {
(confirm as Mock).mockResolvedValueOnce(true);
vol.fromJSON(baseFiles, './');

await addEnv();

const envContent = fs.readFileSync('.env', 'utf8');
const envExampleContent = fs.readFileSync('.env.example', 'utf8');
expect(envContent).toBe(envExampleContent);
});

test('does not overwrite .env when it already exists', async () => {
(confirm as Mock).mockResolvedValueOnce(true);
vol.fromJSON({ ...baseFiles, '.env': 'EXISTING_VAR=value' }, './');

await addEnv();

const envContent = fs.readFileSync('.env', 'utf8');
expect(envContent).toBe('EXISTING_VAR=value');
});

test('adds .env to .gitignore', async () => {
(confirm as Mock).mockResolvedValueOnce(true);
vol.fromJSON({ ...baseFiles, '.gitignore': 'node_modules\n' }, './');

await addEnv();

const gitignore = fs.readFileSync('.gitignore', 'utf8');
expect(gitignore).toMatch('.env');
});

test('installs dotenv as a dev dependency', async () => {
(confirm as Mock).mockResolvedValueOnce(true);
vol.fromJSON(baseFiles, './');

await addEnv();

expect(exec).toHaveBeenCalledWith('yarn add --dev dotenv');
});

test('patches API file when it contains hardcoded URL', async () => {
(confirm as Mock).mockResolvedValueOnce(true);
vol.fromJSON(
{
...baseFiles,
'src/util/api/api.ts': `const url = 'https://api.github.com/orgs/thoughtbot/repos';`,
},
'./',
);

await addEnv();

const apiContent = fs.readFileSync('src/util/api/api.ts', 'utf8');
expect(apiContent).toMatch('EXPO_PUBLIC_API_BASE_URL');
expect(apiContent).not.toMatch(
"'https://api.github.com/orgs/thoughtbot/repos'",
);
});

test('does not error when API file does not exist', async () => {
(confirm as Mock).mockResolvedValueOnce(true);
vol.fromJSON(baseFiles, './');

await expect(addEnv()).resolves.toBeUndefined();
});

test('patches Jest config when it contains setupFilesAfterEnv', async () => {
(confirm as Mock).mockResolvedValueOnce(true);
vol.fromJSON(
{
...baseFiles,
'jest.config.js': `module.exports = {\n setupFilesAfterEnv: [\n './jest.setup.js'\n ]\n};`,
},
'./',
);

await addEnv();

const jestConfig = fs.readFileSync('jest.config.js', 'utf8');
expect(jestConfig).toMatch("setupFiles: ['./jest.setup.env.js']");
expect(jestConfig).toMatch('setupFilesAfterEnv');
});

test('does not error when Jest config does not exist', async () => {
(confirm as Mock).mockResolvedValueOnce(true);
vol.fromJSON(baseFiles, './');

await expect(addEnv()).resolves.toBeUndefined();
});

test('skips confirmation prompt in non-interactive mode', async () => {
vol.fromJSON(baseFiles, './');

await addEnv({ interactive: false });

expect(confirm).not.toHaveBeenCalled();
});
156 changes: 156 additions & 0 deletions src/commands/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { confirm } from '@inquirer/prompts';
import fs from 'fs-extra';
import ora from 'ora';
import path from 'path';
import { globals } from '../constants';
import addDependency from '../util/addDependency';
import addToGitignore from '../util/addToGitignore';
import commit, { handleCommitError } from '../util/commit';
import copyTemplate from '../util/copyTemplate';
import getProjectDir from '../util/getProjectDir';
import print from '../util/print';
import writeFile from '../util/writeFile';

type Options = {
interactive?: boolean;
};

const API_PATH = 'src/util/api/api.ts';
const HARDCODED_API_URL = "'https://api.github.com/orgs/thoughtbot/repos'";
const REPLACED_API_URL =
// eslint-disable-next-line no-template-curly-in-string
"`${process.env.EXPO_PUBLIC_API_BASE_URL ?? ''}/orgs/thoughtbot/repos`";

const JEST_CONFIG_PATH = 'jest.config.js';
const JEST_SETUP_FILES_BEFORE = ' setupFilesAfterEnv: [';
const JEST_SETUP_FILES_AFTER =
" setupFiles: ['./jest.setup.env.js'],\n setupFilesAfterEnv: [";

async function patchFile(
filePath: string,
search: string,
replacement: string,
): Promise<boolean> {
if (!(await fs.pathExists(filePath))) return false;
const contents = (await fs.readFile(filePath)).toString();
const updated = contents.replace(search, replacement);
if (updated === contents) return false;
await writeFile(filePath, updated, { format: true });
return true;
}

export async function addEnv(options: Options = {}) {
const { interactive = true } = options;

globals.interactive = interactive;

await printIntro();

const spinner = ora().start('Setting up environment configuration');

const projectDir = await getProjectDir();

await addDependency('dotenv', { dev: true });

await copyTemplate({
templateDir: 'environments',
templateFile: 'env.example',
destination: '.env.example',
});
await copyTemplate({
templateDir: 'environments',
templateFile: 'src/config/index.ts',
});
await copyTemplate({
templateDir: 'environments',
templateFile: 'jest.setup.env.js',
});
await copyTemplate({
templateDir: 'environments',
templateFile: 'env.test',
destination: '.env.test',
});

const envPath = path.join(projectDir, '.env');
if (!(await fs.pathExists(envPath))) {
await fs.copy(path.join(projectDir, '.env.example'), envPath);
}

await addToGitignore('.env');

const patchedApi = await patchFile(
path.join(projectDir, API_PATH),
HARDCODED_API_URL,
REPLACED_API_URL,
);
const patchedJest = await patchFile(
path.join(projectDir, JEST_CONFIG_PATH),
JEST_SETUP_FILES_BEFORE,
JEST_SETUP_FILES_AFTER,
);

await commit('Add environment variable management support.').catch(
handleCommitError,
);

spinner.succeed(`Successfully set up environment variable management!

What was added:
- .env.example: Template of environment variables (committed to git)
- .env: Your local environment variables (gitignored)
- .env.test: Environment variables for Jest (committed to git)
- jest.setup.env.js: Loads .env.test before tests run
- src/config/index.ts: Typed helper to access config values in your app${
patchedApi
? `\n - ${API_PATH}: Updated to use EXPO_PUBLIC_API_BASE_URL`
: ''
}${
patchedJest
? `\n - ${JEST_CONFIG_PATH}: Added setupFiles to load jest.setup.env.js`
: ''
}

Usage in your app:
import getConfig from 'src/config';
const { apiBaseUrl } = getConfig();

Variables prefixed with EXPO_PUBLIC_ are automatically loaded by the Expo CLI
and inlined into your app bundle — no dotenv or extra config required.

These values are visible in plain text in the compiled app.
Never store secrets as EXPO_PUBLIC_ variables.
`);
}

async function printIntro() {
print("Let's set up environment variable management!");
print(`
We will configure your Expo app to handle environment variables using the
built-in EXPO_PUBLIC_ mechanism. This includes:

- .env.example: Template showing your environment variables (committed)
- .env: Your local values (gitignored)
- .env.test: Environment variables for Jest tests (committed)
- jest.setup.env.js: Loads .env.test before each test run
- src/config/index.ts: Typed helper for safe config access

Variables prefixed with EXPO_PUBLIC_ are automatically loaded by the Expo CLI
and inlined into the app bundle at build time. No extra packages needed.
`);

if (!globals.interactive) {
return;
}

const proceed = await confirm({ message: 'Ready to proceed?' });
if (!proceed) {
process.exit(0);
}

print('');
}

export default function addEnvAction(...args: unknown[]) {
const options = (args[0] as unknown[])[0] as Options;
return addEnv(options);
}
8 changes: 1 addition & 7 deletions src/commands/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { confirm, input } from '@inquirer/prompts';
import ora from 'ora';
import { globals } from '../constants';
import addExpoConfig from '../util/addExpoConfig';
import commit from '../util/commit';
import commit, { handleCommitError } from '../util/commit';
import copyTemplateDirectory from '../util/copyTemplateDirectory';
import exec from '../util/exec';
import injectHooks from '../util/injectHooks';
Expand All @@ -14,12 +14,6 @@ type Options = {
interactive?: boolean;
};

const handleCommitError = (error: { stdout: string }) => {
if (!error.stdout.includes('nothing to commit')) {
throw error;
}
};

export async function addNotifications(options: Options = {}) {
const { interactive = true } = options;

Expand Down
6 changes: 6 additions & 0 deletions src/util/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@ export default async function commit(message: string) {
await exec('git add .');
await exec(`git commit -m "${message}"`);
}

export function handleCommitError(error: { stdout: string }) {
if (!error.stdout.includes('nothing to commit')) {
throw error;
}
}
8 changes: 8 additions & 0 deletions templates/environments/env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Environment Variables
# Copy this file to .env and fill in your values.
# Only .env.example is committed to version control — never commit .env.

# Variables prefixed with EXPO_PUBLIC_ are automatically loaded by the Expo CLI
# and inlined into your app bundle at build time. They are visible in plain text
# in the compiled app — never store secrets here.
EXPO_PUBLIC_API_BASE_URL=https://api.github.com
1 change: 1 addition & 0 deletions templates/environments/env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
EXPO_PUBLIC_API_BASE_URL=https://api.github.com
2 changes: 2 additions & 0 deletions templates/environments/jest.setup.env.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* eslint-disable @typescript-eslint/no-var-requires */
require('dotenv').config({ path: '.env.test' });
11 changes: 11 additions & 0 deletions templates/environments/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
interface Config {
apiBaseUrl: string;
}

export default function getConfig(): Config {
return {
// process.env.EXPO_PUBLIC_* variables are inlined at build time by the Expo bundler.
// Always use dot notation — bracket notation and destructuring are not supported.
apiBaseUrl: process.env.EXPO_PUBLIC_API_BASE_URL ?? '',
};
}
Loading