Skip to content
This repository has been archived by the owner on Jan 18, 2024. It is now read-only.

feature(create-expo-app): add --example flag to initialize from example #4644

Merged
merged 4 commits into from
Feb 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/create-expo-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"node-fetch": "^2.6.7",
"ora": "3.4.0",
"prompts": "^2.4.2",
"tar": "^6.1.11",
"tar": "^6.1.13",
"update-check": "^1.5.4"
},
"publishConfig": {
Expand Down
138 changes: 138 additions & 0 deletions packages/create-expo-app/src/Examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import JsonFile from '@expo/json-file';
import chalk from 'chalk';
import fs from 'fs';
import fetch from 'node-fetch';
import path from 'path';
import prompts from 'prompts';
import { Stream } from 'stream';
import tar from 'tar';
import { promisify } from 'util';

import { sanitizeTemplateAsync } from './Template';
import { createEntryResolver, createFileTransform } from './createFileTransform';
import { env } from './utils/env';

const debug = require('debug')('expo:init:template') as typeof console.log;
const pipeline = promisify(Stream.pipeline);

/**
* The partial GitHub content type, used to filter out examples.
* @see https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28
*/
export type GithubContent = {
name: string;
path: string;
type: 'file' | 'dir';
};

/** List all existing examples directory from https://github.com/expo/examples. */
async function listExamplesAsync() {
const response = await fetch('https://api.github.com/repos/expo/examples/contents');
if (!response.ok) {
throw new Error('Unexpected GitHub API response: https://github.com/expo/examples');
}

const data: GithubContent[] = await response.json();
return data.filter(item => item.type === 'dir' && !item.name.startsWith('.'));
}

/** Determine if an example exists, using only its name */
async function hasExampleAsync(name: string) {
const response = await fetch(
`https://api.github.com/repos/expo/examples/contents/${encodeURIComponent(name)}/package.json`
);

// Either ok or 404 responses are expected
if (response.status === 404 || response.ok) {
return response.ok;
}

throw new Error(`Unexpected GitHub API response: ${response.status} - ${response.statusText}`);
}

export async function ensureExampleExists(name: string) {
if (!(await hasExampleAsync(name))) {
throw new Error(`Example "${name}" does not exist, see https://github.com/expo/examples`);
}
}

/** Ask the user which example to create */
export async function promptExamplesAsync() {
if (env.CI) {
throw new Error('Cannot prompt for examples in CI');
}

const examples = await listExamplesAsync();
const { answer } = await prompts({
type: 'select',
name: 'answer',
message: 'Choose an example:',
choices: examples.map(example => ({
title: example.name,
value: example.path,
})),
});

if (!answer) {
console.log();
console.log(chalk`Please specify the example, example: {cyan --example with-router}`);
console.log();
process.exit(1);
}

return answer;
}

/** Download and move the selected example from https://github.com/expo/examples. */
export async function downloadAndExtractExampleAsync(root: string, name: string) {
const projectName = path.basename(root);
const response = await fetch('https://codeload.github.com/expo/examples/tar.gz/master');
if (!response.ok) {
debug(`Failed to fetch the examples code, received status "${response.status}"`);
throw new Error('Failed to fetch the examples code from https://github.com/expo/examples');
}

await pipeline(
response.body,
tar.extract(
{
cwd: root,
transform: createFileTransform(projectName),
onentry: createEntryResolver(projectName),
strip: 2,
},
[`examples-master/${name}`]
)
);

await sanitizeTemplateAsync(root);
await sanitizeScriptsAsync(root);
}

function exampleHasNativeCode(root: string): boolean {
return [path.join(root, 'android'), path.join(root, 'ios')].some(folder => fs.existsSync(folder));
}

export async function sanitizeScriptsAsync(root: string) {
const defaultScripts = exampleHasNativeCode(root)
? {
start: 'expo start --dev-client',
android: 'expo run:android',
ios: 'expo run:ios',
web: 'expo start --web',
}
: {
start: 'expo start',
android: 'expo start --android',
ios: 'expo start --ios',
web: 'expo start --web',
};

const packageFile = new JsonFile(path.join(root, 'package.json'));
const packageJson = await packageFile.readAsync();

const scripts = (packageJson.scripts ?? {}) as Record<string, string>;
packageJson.scripts = { ...defaultScripts, ...scripts };

await packageFile.writeAsync(packageJson);
}
22 changes: 20 additions & 2 deletions packages/create-expo-app/src/Template.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import JsonFile from '@expo/json-file';
import * as PackageManager from '@expo/package-manager';
import chalk from 'chalk';
import fs from 'fs';
import ora from 'ora';
import path from 'path';

Expand Down Expand Up @@ -83,6 +84,25 @@ export async function extractAndPrepareTemplateAppAsync(
disableCache: type === 'file',
});

await sanitizeTemplateAsync(projectRoot);

return projectRoot;
}

/**
* Sanitize a template (or example) with expected `package.json` properties and files.
*/
export async function sanitizeTemplateAsync(projectRoot: string) {
const projectName = path.basename(projectRoot);

debug(`Sanitizing template or example app (projectName: ${projectName})`);

const templatePath = path.join(__dirname, '../template/gitignore');
const ignorePath = path.join(projectRoot, '.gitignore');
if (!fs.existsSync(ignorePath)) {
await fs.promises.copyFile(templatePath, ignorePath);
}

const config: Record<string, any> = {
expo: {
name: projectName,
Expand Down Expand Up @@ -112,8 +132,6 @@ export async function extractAndPrepareTemplateAppAsync(
delete packageJson.repository;

await packageFile.writeAsync(packageJson);

return projectRoot;
}

export function validateName(name?: string): string | true {
Expand Down
3 changes: 3 additions & 0 deletions packages/create-expo-app/src/__mocks__/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { fs } from 'memfs';

module.exports = fs;
13 changes: 13 additions & 0 deletions packages/create-expo-app/src/__mocks__/ora.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const ora = jest.fn(() => {
return {
start: jest.fn(() => {
return { stop: jest.fn(), succeed: jest.fn(), fail: jest.fn() };
}),
stop: jest.fn(),
stopAndPersist: jest.fn(),
succeed: jest.fn(),
fail: jest.fn(),
};
});

module.exports = ora;
135 changes: 135 additions & 0 deletions packages/create-expo-app/src/__tests__/Examples.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { vol } from 'memfs';
import typedFetch from 'node-fetch';
import typedPrompts from 'prompts';

import {
ensureExampleExists,
GithubContent,
promptExamplesAsync,
sanitizeScriptsAsync,
} from '../Examples';
import { env } from '../utils/env';

jest.mock('fs');
jest.mock('node-fetch');
jest.mock('prompts');

const fetch = typedFetch as jest.MockedFunction<typeof typedFetch>;
const prompts = typedPrompts as jest.MockedFunction<typeof typedPrompts>;

describe(ensureExampleExists, () => {
it('resolves when example exists', async () => {
fetch.mockResolvedValue({ ok: true, status: 200 } as any);
await expect(ensureExampleExists('test')).resolves.not.toThrow();
});

it('rejects when example does note exists', async () => {
fetch.mockResolvedValue({ ok: false, status: 404 } as any);
await expect(() => ensureExampleExists('test')).rejects.toThrow(/example.*does not exist/i);
});

it('throws when running into rate limits', async () => {
fetch.mockResolvedValue({ ok: false, status: 403 } as any);
await expect(() => ensureExampleExists('test')).rejects.toThrow(
/unexpected GitHub API response/i
);
});
});

describe(promptExamplesAsync, () => {
it('throws when in CI mode', async () => {
const spy = jest.spyOn(env, 'CI', 'get').mockReturnValue(true);
await expect(() => promptExamplesAsync()).rejects.toThrowError(/cannot prompt/i);
spy.mockRestore();
});

it('prompts examples and return selected example', async () => {
// Make this test run in CI
const spy = jest.spyOn(env, 'CI', 'get').mockReturnValue(false);
const examples: GithubContent[] = [
{ name: 'test-1', path: 'test-1', type: 'dir' },
{ name: 'test-2', path: 'test-2', type: 'dir' },
];

fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(examples) } as any);
prompts.mockResolvedValue({ answer: 'test-1' });

await expect(promptExamplesAsync()).resolves.toBe('test-1');
expect(prompts).toHaveBeenCalledWith(
expect.objectContaining({
choices: expect.arrayContaining([
{ title: 'test-1', value: 'test-1' },
{ title: 'test-2', value: 'test-2' },
]),
})
);

spy.mockRestore();
});
});

describe(sanitizeScriptsAsync, () => {
afterEach(() => vol.reset());

it('adds default scripts for managed apps', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
name: 'project',
version: '0.0.0',
}),
});

await sanitizeScriptsAsync('/project');
const packageJson = JSON.parse(String(vol.readFileSync('/project/package.json')));

expect(packageJson.scripts).toMatchObject({
start: 'expo start',
android: 'expo start --android',
ios: 'expo start --ios',
web: 'expo start --web',
});
});

it('adds default scripts for bare apps', async () => {
vol.fromJSON({
'/project/android/build.gradle': 'fake-gradle',
'/project/ios/Podfile': 'fake-podfile',
'/project/package.json': JSON.stringify({
name: 'project',
version: '0.0.0',
}),
});

await sanitizeScriptsAsync('/project');
const packageJson = JSON.parse(String(vol.readFileSync('/project/package.json')));

expect(packageJson.scripts).toMatchObject({
start: 'expo start --dev-client',
android: 'expo run:android',
ios: 'expo run:ios',
web: 'expo start --web',
});
});

it('does not overwrite existing scripts', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
name: 'project',
version: '0.0.0',
scripts: {
start: 'node start.js',
},
}),
});

await sanitizeScriptsAsync('/project');
const packageJson = JSON.parse(String(vol.readFileSync('/project/package.json')));

expect(packageJson.scripts).toMatchObject({
start: 'node start.js',
android: 'expo start --android',
ios: 'expo start --ios',
web: 'expo start --web',
});
});
});
15 changes: 12 additions & 3 deletions packages/create-expo-app/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,25 @@ async function run() {
chalk`npx create-expo-app {cyan <path>} [options]`,
[
`-y, --yes Use the default options for creating a project`,
`--no-install Skip installing npm packages or CocoaPods`,
` --no-install Skip installing npm packages or CocoaPods`,
chalk`-t, --template {gray [pkg]} NPM template to use: blank, tabs, bare-minimum. Default: blank`,
chalk`-e, --example {gray [name]} Example name from {underline https://github.com/expo/examples}.`,
`-v, --version Version number`,
`-h, --help Usage info`,
].join('\n'),
chalk`
{gray To choose a template pass in the {bold --template} arg:}

{gray $} npm create expo-app {cyan --template}

{gray To choose an Expo example pass in the {bold --example} arg:}

{gray $} npm create expo-app {cyan --example}
{gray $} npm create expo-app {cyan --example with-router}

{gray The package manager used for installing}
{gray node modules is based on how you invoke the CLI:}

{bold npm:} {cyan npm create expo-app}
{bold yarn:} {cyan yarn create expo-app}
{bold pnpm:} {cyan pnpm create expo-app}
Expand All @@ -62,7 +68,9 @@ async function run() {
try {
const parsed = await resolveStringOrBooleanArgsAsync(argv, rawArgsMap, {
'--template': Boolean,
'--example': Boolean,
'-t': '--template',
'-e': '--example',
});

debug(`Default args:\n%O`, args);
Expand All @@ -72,6 +80,7 @@ async function run() {
await createAsync(parsed.projectRoot, {
yes: !!args['--yes'],
template: parsed.args['--template'],
example: parsed.args['--example'],
install: !args['--no-install'],
});

Expand Down