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

Commit

Permalink
feature(create-expo-app): add --example flag to initialize from exa…
Browse files Browse the repository at this point in the history
…mple (#4644)

* feature(create-expo-app): add support for examples

* fix(create-expo-app): update to tar 6.1.13 to avoid premature closed stream errors with node 18+

* test(create-expo-app): explicitly turn off CI even in CI

* fix(create-expo-app): throw when --template and --example are both used
  • Loading branch information
byCedric committed Feb 7, 2023
1 parent 45f5294 commit a8745a2
Show file tree
Hide file tree
Showing 11 changed files with 551 additions and 25 deletions.
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

0 comments on commit a8745a2

Please sign in to comment.