Skip to content

Commit 0a9c2c5

Browse files
feat(create-app): download template from npm (#10)
1 parent 76ddbd1 commit 0a9c2c5

File tree

19 files changed

+907
-264
lines changed

19 files changed

+907
-264
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,7 @@ Thumbs.db
4040

4141
.nx/cache
4242
.nx/workspace-data
43+
44+
# Local Templates
45+
*.tgz
46+
*.tar

package.json

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
"license": "MIT",
55
"type": "module",
66
"scripts": {
7-
"build": "nx run-many -t build",
8-
"test": "nx run-many --target=test --watch=false",
9-
"lint": "nx run-many --target=lint"
7+
"build": "nx run-many --target build",
8+
"test": "nx run-many --target test",
9+
"lint": "nx run-many --target lint",
10+
"typecheck": "nx run-many --target typecheck"
1011
},
1112
"private": true,
1213
"dependencies": {
@@ -23,8 +24,8 @@
2324
"@swc/core": "~1.5.7",
2425
"@swc/helpers": "~0.5.11",
2526
"@types/node": "18.16.9",
26-
"@vitest/coverage-v8": "^1.0.4",
27-
"@vitest/ui": "^1.3.1",
27+
"@vitest/coverage-v8": "^2.1.2",
28+
"@vitest/ui": "^2.1.2",
2829
"eslint": "^9.8.0",
2930
"eslint-config-prettier": "^9.0.0",
3031
"nx": "19.8.0",
@@ -34,7 +35,7 @@
3435
"typescript-eslint": "^8.0.0",
3536
"verdaccio": "^5.0.4",
3637
"vite": "^5.0.0",
37-
"vitest": "^2.1.1"
38+
"vitest": "^2.1.2"
3839
},
3940
"nx": {
4041
"includedScripts": []

packages/create-app/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
"@clack/prompts": "^0.7.0",
1212
"gradient-string": "^3.0.0",
1313
"minimist": "^1.2.8",
14+
"package-json": "^10.0.1",
15+
"tar": "^7.4.3",
1416
"tslib": "^2.3.0"
1517
},
1618
"devDependencies": {
19+
"@callstack/rnef-test-helpers": "workspace:*",
1720
"@types/gradient-string": "^1.1.6",
1821
"@types/minimist": "^1.2.5"
1922
}

packages/create-app/project.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
"lintFilePatterns": ["{projectRoot}/src/*.{ts,js,tsx,jsx}"]
2828
}
2929
},
30+
"test": {
31+
"dependsOn": ["build"]
32+
},
3033
"nx-release-publish": {
3134
"options": {
3235
"packageRoot": "dist/{projectRoot}"

packages/create-app/src/bin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
#!/usr/bin/env node
2-
export * from './lib/create-app.js';
2+
3+
export * from './lib/bin.js';

packages/create-app/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export * from './lib/create-app.js';
1+
export * from './lib/bin.js';
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
3+
import path from 'node:path';
4+
import {
5+
execAsync,
6+
getRandomString,
7+
getTempDirectory,
8+
} from '@callstack/rnef-test-helpers';
9+
10+
const CREATE_APP_PATH = path.resolve(__dirname, '../../../dist/src/bin.js');
11+
const TEMPLATES_DIR = path.resolve(__dirname, '../../../../../templates');
12+
const TEMP_DIR = getTempDirectory('e2e-deploys');
13+
14+
beforeEach(() => {
15+
mkdirSync(TEMP_DIR, { recursive: true });
16+
});
17+
18+
describe('create-app command', { timeout: 30_000 }, () => {
19+
it('should create a new project from npm template', async () => {
20+
const projectName = `test-npm-template-${getRandomString(6)}`;
21+
const projectPath = path.resolve(TEMP_DIR, projectName);
22+
23+
if (existsSync(projectPath)) {
24+
rmSync(projectPath, { recursive: true, force: true });
25+
}
26+
27+
await execAsync(
28+
`node ${CREATE_APP_PATH} ${projectName} --template=@testing-library/react-native`,
29+
{ cwd: TEMP_DIR }
30+
);
31+
32+
const packageJsonPath = path.join(projectPath, 'package.json');
33+
expect(existsSync(packageJsonPath)).toBe(true);
34+
35+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
36+
expect(packageJson.name).toBe(projectName);
37+
expect(packageJson.version).toBe('1.0.0');
38+
expect(packageJson.private).toBe(true);
39+
expect(packageJson.description).not.toBeDefined();
40+
expect(packageJson.author).not.toBeDefined();
41+
expect(packageJson.license).not.toBeDefined();
42+
expect(packageJson.repository).not.toBeDefined();
43+
expect(packageJson.bugs).not.toBeDefined();
44+
expect(packageJson.homepage).not.toBeDefined();
45+
expect(packageJson.keywords).not.toBeDefined();
46+
expect(packageJson.packageManager).not.toBeDefined();
47+
});
48+
49+
it(
50+
'should create a new project from local directory template',
51+
{ timeout: 30_000 },
52+
async () => {
53+
const projectName = `test-local-dir-template-${getRandomString(6)}`;
54+
const projectPath = path.resolve(TEMP_DIR, projectName);
55+
56+
if (existsSync(projectPath)) {
57+
rmSync(projectPath, { recursive: true, force: true });
58+
}
59+
60+
const templatePath = `${TEMPLATES_DIR}/rnef-template-default`;
61+
await execAsync(
62+
`node ${CREATE_APP_PATH} ${projectName} --template="${templatePath}"`,
63+
{ cwd: TEMP_DIR }
64+
);
65+
66+
const packageJsonPath = path.join(projectPath, 'package.json');
67+
expect(existsSync(packageJsonPath)).toBe(true);
68+
69+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
70+
expect(packageJson.name).toBe(projectName);
71+
expect(packageJson.version).toBe('1.0.0');
72+
expect(packageJson.private).toBe(true);
73+
expect(packageJson.description).not.toBeDefined();
74+
expect(packageJson.author).not.toBeDefined();
75+
expect(packageJson.license).not.toBeDefined();
76+
expect(packageJson.repository).not.toBeDefined();
77+
expect(packageJson.bugs).not.toBeDefined();
78+
expect(packageJson.homepage).not.toBeDefined();
79+
expect(packageJson.keywords).not.toBeDefined();
80+
expect(packageJson.packageManager).not.toBeDefined();
81+
}
82+
);
83+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { expect, test } from 'vitest';
2+
import path from 'node:path';
3+
import { resolveTemplateName } from '../templates';
4+
5+
test('resolveTemplateName with built-in templates', () => {
6+
expect(resolveTemplateName('default')).toEqual({
7+
name: 'default',
8+
version: 'latest',
9+
// TODO: update to @callstack/repack-template-default when published
10+
packageName: '@callstack/repack',
11+
});
12+
});
13+
14+
test('resolveTemplateName with local paths', () => {
15+
expect(resolveTemplateName('./directory/template-1')).toEqual({
16+
name: 'template-1',
17+
localPath: path.resolve('./directory/template-1'),
18+
});
19+
20+
expect(resolveTemplateName('../../up/up/away/template-2')).toEqual({
21+
name: 'template-2',
22+
localPath: path.resolve('../../up/up/away/template-2'),
23+
});
24+
25+
expect(resolveTemplateName('/absolute/path/template-3')).toEqual({
26+
name: 'template-3',
27+
localPath: '/absolute/path/template-3',
28+
});
29+
30+
expect(resolveTemplateName('file:///url-based/path/template-4')).toEqual({
31+
name: 'template-4',
32+
localPath: '/url-based/path/template-4',
33+
});
34+
35+
expect(resolveTemplateName('./directory/template-5.tgz')).toEqual({
36+
name: 'template-5',
37+
localPath: path.resolve('./directory/template-5.tgz'),
38+
});
39+
40+
expect(resolveTemplateName('../up/template-6.tar')).toEqual({
41+
name: 'template-6',
42+
localPath: path.resolve('../up/template-6.tar'),
43+
});
44+
45+
expect(resolveTemplateName('/root/directory/template-7.tgz')).toEqual({
46+
name: 'template-7',
47+
localPath: '/root/directory/template-7.tgz',
48+
});
49+
});

packages/create-app/src/lib/bin.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import fs from 'node:fs';
2+
import { spinner } from '@clack/prompts';
3+
import {
4+
renameFiles,
5+
renamePlaceholder,
6+
rewritePackageJson,
7+
} from './edit-template.js';
8+
import {
9+
copyDirSync,
10+
isEmptyDirSync,
11+
removeDir,
12+
resolveAbsolutePath,
13+
} from './fs.js';
14+
import { printLogo } from './logo.js';
15+
import { parseCliOptions } from './parse-cli-options.js';
16+
import { parsePackageInfo } from './parsers.js';
17+
import {
18+
cancelAndExit,
19+
printHelpMessage,
20+
printVersionMessage,
21+
confirmOverrideFiles,
22+
promptProjectName,
23+
printWelcomeMessage,
24+
printByeMessage,
25+
promptTemplate,
26+
} from './prompts.js';
27+
import {
28+
downloadTarballFromNpm,
29+
extractTarballFile,
30+
resolveTemplateName,
31+
TEMPLATES,
32+
} from './templates.js';
33+
34+
async function create() {
35+
const options = parseCliOptions(process.argv.slice(2));
36+
37+
if (options.help) {
38+
printHelpMessage(TEMPLATES);
39+
return;
40+
}
41+
42+
if (options.version) {
43+
printVersionMessage();
44+
return;
45+
}
46+
47+
printLogo();
48+
printWelcomeMessage();
49+
50+
const projectName =
51+
(options.dir || options.name) ?? (await promptProjectName());
52+
const { targetDir } = parsePackageInfo(projectName);
53+
const absoluteTargetDir = resolveAbsolutePath(targetDir);
54+
55+
if (
56+
!options.override &&
57+
fs.existsSync(absoluteTargetDir) &&
58+
!isEmptyDirSync(absoluteTargetDir)
59+
) {
60+
const confirmOverride = await confirmOverrideFiles(absoluteTargetDir);
61+
if (!confirmOverride) {
62+
cancelAndExit();
63+
}
64+
}
65+
66+
removeDir(absoluteTargetDir);
67+
fs.mkdirSync(absoluteTargetDir, { recursive: true });
68+
69+
const template =
70+
resolveTemplateName(options.template) ?? (await promptTemplate(TEMPLATES));
71+
72+
const loader = spinner();
73+
let tarballPath: string | null = null;
74+
75+
// NPM package: download tarball file
76+
if (template.packageName) {
77+
loader.start(
78+
`Downloading package ${template.packageName}@${template.version}...`
79+
);
80+
tarballPath = await downloadTarballFromNpm(
81+
template.packageName,
82+
template.version,
83+
absoluteTargetDir
84+
);
85+
}
86+
// Local tarball file
87+
else if (
88+
template.localPath?.endsWith('.tgz') ||
89+
template.localPath?.endsWith('.tar.gz') ||
90+
template.localPath?.endsWith('.tar')
91+
) {
92+
tarballPath = template.localPath;
93+
}
94+
95+
// Extract tarball file: either from NPM or local one
96+
if (tarballPath) {
97+
if (template.packageName) {
98+
loader.message(`Extracting package ${template.name}.`);
99+
} else {
100+
loader.start(`Extracting package ${template.name}...`);
101+
}
102+
103+
await extractTarballFile(tarballPath, absoluteTargetDir);
104+
105+
if (template.packageName) {
106+
fs.unlinkSync(tarballPath);
107+
loader.stop(`Downloaded and extracted package ${template.packageName}.`);
108+
} else {
109+
loader.stop(`Extracted package ${template.name}.`);
110+
}
111+
} else if (template.localPath) {
112+
loader.start(`Copying local directory ${template.localPath}`);
113+
copyDirSync(template.localPath, absoluteTargetDir);
114+
loader.stop(`Copied local directory ${template.localPath}.`);
115+
} else {
116+
// This should never happen as we have either NPM package or local path (tarball or directory).
117+
throw new Error(
118+
`Invalid state: template not found: ${JSON.stringify(template, null, 2)}`
119+
);
120+
}
121+
122+
rewritePackageJson(absoluteTargetDir, projectName);
123+
renameFiles(absoluteTargetDir);
124+
renamePlaceholder(absoluteTargetDir, projectName);
125+
126+
printByeMessage(absoluteTargetDir);
127+
}
128+
129+
create();

0 commit comments

Comments
 (0)