1
0
mirror of synced 2025-11-08 04:48:04 +00:00

refactor(CLI): modularize more, test more

This commit is contained in:
Jason Dreyzehner
2018-03-11 00:00:24 -05:00
parent e406917ff8
commit a0541f9f9b
14 changed files with 877 additions and 445 deletions

2
.vscode/launch.json vendored
View File

@@ -5,6 +5,8 @@
"type": "node",
"request": "launch",
"name": "Debug CLI",
// we test in `build` to make cleanup fast and easy
"cwd": "${workspaceFolder}/build",
"program": "${workspaceFolder}/src/cli/cli.ts",
"outFiles": ["${workspaceFolder}/build/main/**/*.js"],
"skipFiles": [

View File

@@ -1,5 +1,5 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.implementationsCodeLens.enabled": true,
"typescript.referencesCodeLens.enabled": true
"typescript.tsdk": "node_modules/typescript/lib"
// "typescript.implementationsCodeLens.enabled": true
// "typescript.referencesCodeLens.enabled": true
}

View File

@@ -4,6 +4,7 @@
[![Build Status](https://travis-ci.org/bitjson/typescript-starter.svg?branch=master)](https://travis-ci.org/bitjson/typescript-starter)
[![Codecov](https://img.shields.io/codecov/c/github/bitjson/typescript-starter.svg)](https://codecov.io/gh/bitjson/typescript-starter)
[![Standard Version](https://img.shields.io/badge/release-standard%20version-brightgreen.svg)](https://github.com/conventional-changelog/standard-version)
[![GitHub stars](https://img.shields.io/github/stars/bitjson/typescript-starter.svg?style=social&logo=github&label=Stars)](https://github.com/bitjson/typescript-starter)
# typescript-starter
@@ -21,7 +22,9 @@ Run one simple command to install and use the interactive project generator. You
npx typescript-starter
```
The interactive CLI will help you automatically create and configure your project.
The interactive CLI will help you create and configure your project automatically.
> Since this repo includes [the CLI and it's tests](./src/cli), you'll only need to fork or clone this project if you want to contribute. If you find this project useful, please consider [leaving a star](https://github.com/bitjson/typescript-starter/stargazers) so others can find it. Thanks!
# Features

653
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -73,11 +73,14 @@
"engines": {
"node": ">=8.9"
},
"NOTE": "These dependencies are for the CLI, and will be removed automatically.",
"dependencies": {
"@types/globby": "^6.1.0",
"chalk": "^2.3.1",
"del": "^3.0.0",
"execa": "^0.9.0",
"github-username": "^4.1.0",
"globby": "^8.0.1",
"gradient-string": "^1.0.0",
"inquirer": "^5.1.0",
"meow": "^4.0.0",
@@ -100,6 +103,7 @@
"codecov": "^3.0.0",
"cz-conventional-changelog": "^2.1.0",
"gh-pages": "^1.0.0",
"md5-file": "^3.2.3",
"nock": "^9.2.3",
"npm-run-all": "^4.1.2",
"npm-scripts-info": "^0.3.6",

View File

@@ -1,9 +1,11 @@
// tslint:disable:no-console no-if-statement no-expression-statement
import meow from 'meow';
import { Package, UpdateInfo, UpdateNotifier } from 'update-notifier';
import { Runner, TypescriptStarterOptions, validateName } from './primitives';
import { Runner, TypescriptStarterUserOptions, validateName } from './utils';
export async function checkArgs(): Promise<
TypescriptStarterOptions | undefined
Partial<TypescriptStarterUserOptions>
> {
const cli = meow(
`
@@ -54,15 +56,12 @@ export async function checkArgs(): Promise<
const updateInfo = await new Promise<UpdateInfo>((resolve, reject) => {
const notifier = new UpdateNotifier({
callback: (error, update) => {
// tslint:disable-next-line:no-expression-statement
error ? reject(error) : resolve(update);
},
pkg: cli.pkg as Package
});
// tslint:disable-next-line:no-expression-statement
notifier.check();
});
// tslint:disable-next-line:no-if-statement
if (updateInfo.type !== 'latest') {
throw new Error(`
Your version of typescript-starter is outdated.
@@ -71,13 +70,15 @@ export async function checkArgs(): Promise<
}
const input = cli.input[0];
// tslint:disable-next-line:no-if-statement
if (!input) {
// no project-name provided, return to collect options in interactive mode
return undefined;
// note: we always return `install`, so --noinstall always works
// (important for test performance)
return {
install: !cli.flags.noinstall
};
}
const validOrMsg = await validateName(input);
// tslint:disable-next-line:no-if-statement
if (typeof validOrMsg === 'string') {
throw new Error(validOrMsg);
}
@@ -86,8 +87,8 @@ export async function checkArgs(): Promise<
description: cli.flags.description,
domDefinitions: cli.flags.dom,
install: !cli.flags.noinstall,
name: input,
nodeDefinitions: cli.flags.node,
projectName: input,
runner: cli.flags.yarn ? Runner.Yarn : Runner.Npm
};
}

View File

@@ -2,19 +2,23 @@
import chalk from 'chalk';
import { checkArgs } from './args';
import { inquire } from './inquire';
import { getIntro } from './primitives';
import { LiveTasks } from './tasks';
import { getInferredOptions, LiveTasks } from './tasks';
import { typescriptStarter } from './typescript-starter';
import { getIntro, TypescriptStarterUserOptions } from './utils';
(async () => {
const cliOptions = await checkArgs();
const options = cliOptions
? cliOptions
: await (async () => {
console.log(getIntro(process.stdout.columns));
return inquire();
})();
return typescriptStarter(options, LiveTasks);
const userOptions = cliOptions.projectName
? (cliOptions as TypescriptStarterUserOptions)
: {
...(await (async () => {
console.log(getIntro(process.stdout.columns));
return inquire();
})()),
...cliOptions // merge in cliOptions.install
};
const inferredOptions = await getInferredOptions();
return typescriptStarter({ ...inferredOptions, ...userOptions }, LiveTasks);
})().catch((err: Error) => {
console.error(`
${chalk.red(err.message)}

View File

@@ -1,11 +1,11 @@
import { prompt, Question } from 'inquirer';
import { Runner, TypescriptStarterOptions, validateName } from './primitives';
import { Runner, TypescriptStarterUserOptions, validateName } from './utils';
export async function inquire(): Promise<TypescriptStarterOptions> {
export async function inquire(): Promise<TypescriptStarterUserOptions> {
const packageNameQuestion: Question = {
filter: (answer: string) => answer.trim(),
message: 'Enter the new package name:',
name: 'name',
name: 'projectName',
type: 'input',
validate: validateName
};
@@ -81,10 +81,10 @@ export async function inquire(): Promise<TypescriptStarterOptions> {
runnerQuestion,
typeDefsQuestion
]).then(answers => {
const { definitions, description, name, runner } = answers as {
const { definitions, description, projectName, runner } = answers as {
readonly definitions?: TypeDefinitions;
readonly description: string;
readonly name: string;
readonly projectName: string;
readonly runner: Runner;
};
return {
@@ -95,12 +95,12 @@ export async function inquire(): Promise<TypescriptStarterOptions> {
)
: false,
install: true,
name,
nodeDefinitions: definitions
? [TypeDefinitions.Node, TypeDefinitions.NodeAndDOM].includes(
definitions
)
: false,
projectName,
runner
};
});

View File

@@ -1,9 +1,8 @@
// tslint:disable:no-console no-if-statement no-expression-statement
import execa, { ExecaStatic, Options, StdIOOption } from 'execa';
import { readFileSync, writeFileSync } from 'fs';
import githubUsername from 'github-username';
import { join } from 'path';
import { Runner } from './primitives';
import { Runner, TypescriptStarterInferredOptions } from './utils';
// TODO: await https://github.com/DefinitelyTyped/DefinitelyTyped/pull/24209
const inherit = 'inherit' as StdIOOption;
@@ -14,43 +13,18 @@ export enum Placeholders {
username = 'YOUR_GITHUB_USER_NAME'
}
const repo =
process.env.TYPESCRIPT_STARTER_REPO_URL ||
'https://github.com/bitjson/typescript-starter.git';
export interface Tasks {
readonly cloneRepo: (
dir: string
) => Promise<{ readonly commitHash: string; readonly gitHistoryDir: string }>;
readonly getGithubUsername: (email: string) => Promise<string>;
readonly getUserInfo: () => Promise<{
readonly gitEmail: string;
readonly gitName: string;
}>;
readonly initialCommit: (
hash: string,
projectDir: string,
name: string,
email: string
) => Promise<boolean>;
readonly install: (
shouldInstall: boolean,
runner: Runner,
projectDir: string
) => Promise<void>;
readonly readPackageJson: (path: string) => any;
readonly writePackageJson: (path: string, pkg: any) => void;
}
// We implement these as function factories to make unit testing easier.
export const cloneRepo = (spawner: ExecaStatic) => async (dir: string) => {
const cwd = process.cwd();
const projectDir = join(cwd, dir);
export const cloneRepo = (
spawner: ExecaStatic,
suppressOutput = false
) => async (repoURL: string, workingDirectory: string, dir: string) => {
const projectDir = join(workingDirectory, dir);
const gitHistoryDir = join(projectDir, '.git');
try {
await spawner('git', ['clone', '--depth=1', repo, dir], {
cwd,
stdio: 'inherit'
await spawner('git', ['clone', '--depth=1', repoURL, dir], {
cwd: workingDirectory,
stdio: suppressOutput ? 'pipe' : 'inherit'
});
} catch (err) {
if (err.code === 'ENOENT') {
@@ -78,7 +52,7 @@ export const cloneRepo = (spawner: ExecaStatic) => async (dir: string) => {
export const getGithubUsername = (fetcher: any) => async (
email: string | undefined
) => {
): Promise<string> => {
if (email === Placeholders.email) {
return Placeholders.username;
}
@@ -109,10 +83,8 @@ export const getUserInfo = (spawner: ExecaStatic) => async () => {
export const initialCommit = (spawner: ExecaStatic) => async (
hash: string,
projectDir: string,
name: string,
email: string
) => {
projectDir: string
): Promise<void> => {
const opts: Options = {
cwd: projectDir,
encoding: 'utf8',
@@ -120,9 +92,6 @@ export const initialCommit = (spawner: ExecaStatic) => async (
};
await spawner('git', ['init'], opts);
await spawner('git', ['add', '-A'], opts);
if (name === Placeholders.name || email === Placeholders.email) {
return false;
}
await spawner(
'git',
[
@@ -132,11 +101,9 @@ export const initialCommit = (spawner: ExecaStatic) => async (
],
opts
);
return true;
};
export const install = (spawner: ExecaStatic) => async (
shouldInstall: boolean,
runner: Runner,
projectDir: string
) => {
@@ -145,9 +112,6 @@ export const install = (spawner: ExecaStatic) => async (
encoding: 'utf8',
stdio: 'inherit'
};
if (!shouldInstall) {
return;
}
try {
runner === Runner.Npm
? spawner('npm', ['install'], opts)
@@ -157,22 +121,42 @@ export const install = (spawner: ExecaStatic) => async (
}
};
const readPackageJson = (path: string) =>
JSON.parse(readFileSync(path, 'utf8'));
const writePackageJson = (path: string, pkg: any) => {
// write using the same format as npm:
// https://github.com/npm/npm/blob/latest/lib/install/update-package-json.js#L48
const stringified = JSON.stringify(pkg, null, 2) + '\n';
return writeFileSync(path, stringified);
export const getRepoUrl = () => {
return (
process.env.TYPESCRIPT_STARTER_REPO_URL ||
'https://github.com/bitjson/typescript-starter.git'
);
};
export interface Tasks {
readonly cloneRepo: (
repoURL: string,
workingDirectory: string,
dir: string
) => Promise<{ readonly commitHash: string; readonly gitHistoryDir: string }>;
readonly initialCommit: (
hash: string,
projectDir: string,
name: string
) => Promise<void>;
readonly install: (runner: Runner, projectDir: string) => Promise<void>;
}
export const LiveTasks: Tasks = {
cloneRepo: cloneRepo(execa),
getGithubUsername: getGithubUsername(githubUsername),
getUserInfo: getUserInfo(execa),
initialCommit: initialCommit(execa),
install: install(execa),
readPackageJson,
writePackageJson
install: install(execa)
};
export const getInferredOptions = async (): Promise<
TypescriptStarterInferredOptions
> => {
const { gitName, gitEmail } = await getUserInfo(execa)();
const username = await getGithubUsername(githubUsername)(gitEmail);
return {
email: gitEmail,
fullName: gitName,
githubUsername: username,
repoURL: getRepoUrl(),
workingDirectory: process.cwd()
};
};

View File

@@ -1,12 +1,60 @@
// Tests in this file actually run the CLI and attempt to validate its behavior.
// Git must be installed on the PATH of the testing machine.
/**
* Tests in this file actually run the CLI and attempt to validate its behavior.
* Git must be installed on the PATH of the testing machine.
*
* We hash every file in the directories after each test, and compare the hashes
* to the "approved" hashes in this file.
*
* When making a change to this project, run the tests and note which files have
* been modified. After manually reviewing the file for accuracy, simply update
* the relevant hash below. You may find it helpful to view the differences
* between a certain file in each test project. E.g.:
*
* `diff build/test-one/package.json build/test-two/package.json`
*/
// tslint:disable:no-expression-statement
import test, { ExecutionContext } from 'ava';
import del from 'del';
import execa from 'execa';
import globby from 'globby';
import md5File from 'md5-file';
import meow from 'meow';
import { join } from 'path';
import { join, relative } from 'path';
import { cloneRepo, Placeholders, Tasks } from '../tasks';
import { typescriptStarter } from '../typescript-starter';
import { Runner } from '../utils';
// import { Runner, TypescriptStarterOptions } from '../primitives';
/**
* NOTE: many of the tests below validate file modification. The filesystem is
* not mocked, and these tests make real changes. Proceed with caution.
*
* Filesystem changes made by these tests should be contained in the `build`
* directory for easier clean up.
*/
const repoURL = process.cwd();
const buildDir = join(process.cwd(), 'build');
enum TestDirectories {
one = 'test-one',
two = 'test-two',
three = 'test-three',
four = 'test-four',
five = 'test-five'
}
// If the tests all pass, the TestDirectories will automatically be cleaned up.
test.after(async () => {
await del([
`./build/${TestDirectories.one}`,
`./build/${TestDirectories.two}`,
`./build/${TestDirectories.three}`,
`./build/${TestDirectories.four}`,
`./build/${TestDirectories.five}`
]);
});
test('returns version', async t => {
const expected = meow('').pkg.version;
@@ -20,32 +68,6 @@ test('returns help/usage', async t => {
t.regex(stdout, /Usage/);
});
/**
* NOTE: many of the tests below validate file modification. The filesystem is
* not mocked, and these tests make real changes. Proceed with caution.
*
* TODO: mock the filesystem - https://github.com/avajs/ava/issues/665
*
* Until the filesystem is mocked, filesystem changes made by these tests should
* be contained in the `build` directory for easier clean up.
*/
enum testDirectories {
one = 'test-one',
two = 'test-two',
three = 'test-three',
four = 'test-four'
}
test.after(async () => {
await del([
`./build/${testDirectories.one}`,
`./build/${testDirectories.two}`,
`./build/${testDirectories.three}`,
`./build/${testDirectories.four}`
]);
});
test('errors if project name collides with an existing path', async t => {
const existingDir = 'build';
const error = await t.throws(
@@ -61,45 +83,103 @@ test('errors if project name is not in kebab-case', async t => {
t.regex(error.stderr, /should be in-kebab-case/);
});
test('integration test 1: parses CLI arguments, handles options properly', async t => {
async function hashAllTheThings(
projectName: string
): Promise<{ readonly [filename: string]: string }> {
const projectDir = join(buildDir, projectName);
const filePaths: ReadonlyArray<string> = await globby(projectDir);
const hashAll = filePaths.map<Promise<string>>(
path =>
new Promise<string>((resolve, reject) => {
md5File(path, (err: Error, result: string) => {
err ? reject(err) : resolve(result);
});
})
);
const hashes = await Promise.all(hashAll);
return hashes.reduce<{ readonly [filename: string]: string }>(
(acc, hash, i) => {
const trimmedFilePath = relative(buildDir, filePaths[i]);
return {
...acc,
[trimmedFilePath]: hash
};
},
{}
);
}
test(`${
TestDirectories.one
}: parses CLI arguments, handles default options`, async t => {
const description = 'example description 1';
const { stdout } = await execa(
`../bin/typescript-starter`,
[
`${testDirectories.one}`,
'-description "example description 1"',
'--noinstall'
],
[`${TestDirectories.one}`, `-description "${description}"`, '--noinstall'],
{
cwd: join(process.cwd(), 'build'),
cwd: buildDir,
env: {
TYPESCRIPT_STARTER_REPO_URL: process.cwd()
TYPESCRIPT_STARTER_REPO_URL: repoURL
}
}
);
t.regex(stdout, new RegExp(`Created ${testDirectories.one} 🎉`));
// TODO: validate contents of testDirectories.one
t.regex(stdout, new RegExp(`Created ${TestDirectories.one} 🎉`));
const map = await hashAllTheThings(TestDirectories.one);
t.deepEqual(map, {
'test-one/LICENSE': 'd814c164ff6999405ccc7bf14dcdb50a',
'test-one/README.md': '2ab1b6b3e434be0cef6c2b947954198e',
'test-one/bin/typescript-starter': 'a4ad3923f37f50df986b43b1adb9f6b3',
'test-one/package.json': 'f8eb20e261b3af91e122f85d8abc6b8d',
'test-one/src/index.ts': '5991bedc40ac87a01d880c6db16fe349',
'test-one/src/lib/number.spec.ts': '40ebb014eb7871d1f810c618aba1d589',
'test-one/src/lib/number.ts': '43756f90e6ac0b1c4ee6c81d8ab969c7',
'test-one/src/types/example.d.ts': '4221812f6f0434eec77ccb1fba1e3759',
'test-one/tsconfig.json': 'f36dc6407fc898f41a23cb620b2f4884',
'test-one/tsconfig.module.json': 'e452fd6ff2580347077ae3fff2443e34',
'test-one/tslint.json': '7ac167ffbcb724a6c270e8dc4e747067'
});
});
test('integration test 2: parses CLI arguments, handles options properly', async t => {
test(`${
TestDirectories.two
}: parses CLI arguments, handles all options`, async t => {
const description = 'example description 2';
const { stdout } = await execa(
`../bin/typescript-starter`,
[
`${testDirectories.two}`,
'-description "example description 2"',
`${TestDirectories.two}`,
`-description "${description}"`,
'--yarn',
'--node',
'--dom',
'--noinstall'
],
{
cwd: join(process.cwd(), 'build'),
cwd: buildDir,
env: {
TYPESCRIPT_STARTER_REPO_URL: process.cwd()
TYPESCRIPT_STARTER_REPO_URL: repoURL
}
}
);
t.regex(stdout, new RegExp(`Created ${testDirectories.two} 🎉`));
// TODO: validate contents of testDirectories.two
t.regex(stdout, new RegExp(`Created ${TestDirectories.two} 🎉`));
const map = await hashAllTheThings(TestDirectories.two);
t.deepEqual(map, {
'test-two/LICENSE': 'd814c164ff6999405ccc7bf14dcdb50a',
'test-two/README.md': '90745077106bf0554dd02bc967e7e80a',
'test-two/bin/typescript-starter': 'a4ad3923f37f50df986b43b1adb9f6b3',
'test-two/package.json': 'e0c7654aa5edcf1ee7a998df3f0f672f',
'test-two/src/index.ts': 'fbc67c2cbf3a7d37e4e02583bf06eec9',
'test-two/src/lib/async.spec.ts': '1e83b84de3f3b068244885219acb42bd',
'test-two/src/lib/async.ts': '9012c267bb25fa98ad2561929de3d4e2',
'test-two/src/lib/hash.spec.ts': '87bfca3c0116fd86a353750fcf585ecf',
'test-two/src/lib/hash.ts': 'a4c552897f25da5963f410e375264bd1',
'test-two/src/lib/number.spec.ts': '40ebb014eb7871d1f810c618aba1d589',
'test-two/src/lib/number.ts': '43756f90e6ac0b1c4ee6c81d8ab969c7',
'test-two/src/types/example.d.ts': '4221812f6f0434eec77ccb1fba1e3759',
'test-two/tsconfig.json': '43817952d399db9e44977b3703edd7cf',
'test-two/tsconfig.module.json': 'e452fd6ff2580347077ae3fff2443e34',
'test-two/tslint.json': '7ac167ffbcb724a6c270e8dc4e747067'
});
});
const down = '\x1B\x5B\x42';
@@ -112,14 +192,12 @@ async function testInteractive(
t: ExecutionContext<{}>,
projectName: string,
entry: ReadonlyArray<string | ReadonlyArray<string>>
): Promise<void> {
): Promise<execa.ExecaReturns> {
const lastCheck = entry[3] !== undefined;
t.plan(lastCheck ? 6 : 5);
const proc = execa(`../bin/typescript-starter`, {
cwd: join(process.cwd(), 'build'),
const proc = execa(`../bin/typescript-starter`, ['--noinstall'], {
cwd: buildDir,
env: {
TYPESCRIPT_STARTER_REPO_URL: process.cwd()
TYPESCRIPT_STARTER_REPO_URL: repoURL
}
});
@@ -169,21 +247,110 @@ async function testInteractive(
await ms(200);
checkBuffer(new RegExp(`${entry[3][1]}`));
}
return proc;
}
test('integration test 3: interactive mode, javascript library', async t => {
await testInteractive(t, `${testDirectories.three}`, [
test(`${
TestDirectories.three
}: interactive mode: javascript library`, async t => {
t.plan(7);
const proc = await testInteractive(t, `${TestDirectories.three}`, [
[`${down}${up}${down}`, `Javascript library`],
`integration test 3 description${enter}`,
[`${down}${up}${down}${enter}`, `yarn`],
[`${down}${down}${down}${enter}`, `Both Node.js and DOM`]
]);
await proc;
const map = await hashAllTheThings(TestDirectories.three);
t.deepEqual(map, {
'test-three/LICENSE': 'd814c164ff6999405ccc7bf14dcdb50a',
'test-three/README.md': 'cd140f7a5ea693fd265807374efab219',
'test-three/bin/typescript-starter': 'a4ad3923f37f50df986b43b1adb9f6b3',
'test-three/package.json': 'b86d8c4e9827a2c72597a36ea5e1a2d6',
'test-three/src/index.ts': '5991bedc40ac87a01d880c6db16fe349',
'test-three/src/lib/number.spec.ts': '40ebb014eb7871d1f810c618aba1d589',
'test-three/src/lib/number.ts': '43756f90e6ac0b1c4ee6c81d8ab969c7',
'test-three/src/types/example.d.ts': '4221812f6f0434eec77ccb1fba1e3759',
'test-three/tsconfig.json': 'f36dc6407fc898f41a23cb620b2f4884',
'test-three/tsconfig.module.json': 'e452fd6ff2580347077ae3fff2443e34',
'test-three/tslint.json': '7ac167ffbcb724a6c270e8dc4e747067'
});
});
test('integration test 4: interactive mode, node.js application', async t => {
await testInteractive(t, `${testDirectories.four}`, [
test(`${
TestDirectories.four
}: interactive mode: node.js application`, async t => {
t.plan(6);
const proc = await testInteractive(t, `${TestDirectories.four}`, [
[`${down}${up}`, `Node.js application`],
`integration test 4 description${enter}`,
[`${down}${up}${enter}`, `npm`]
]);
await proc;
const map = await hashAllTheThings(TestDirectories.four);
t.deepEqual(map, {
'test-four/LICENSE': 'd814c164ff6999405ccc7bf14dcdb50a',
'test-four/README.md': 'c321a7d2ad331e74ce394c819181a96e',
'test-four/bin/typescript-starter': 'a4ad3923f37f50df986b43b1adb9f6b3',
'test-four/package.json': '01393ce262160df70dc2610cd8ff0a81',
'test-four/src/index.ts': '5991bedc40ac87a01d880c6db16fe349',
'test-four/src/lib/number.spec.ts': '40ebb014eb7871d1f810c618aba1d589',
'test-four/src/lib/number.ts': '43756f90e6ac0b1c4ee6c81d8ab969c7',
'test-four/src/types/example.d.ts': '4221812f6f0434eec77ccb1fba1e3759',
'test-four/tsconfig.json': 'f36dc6407fc898f41a23cb620b2f4884',
'test-four/tsconfig.module.json': 'e452fd6ff2580347077ae3fff2443e34',
'test-four/tslint.json': '7ac167ffbcb724a6c270e8dc4e747067'
});
});
test(`${
TestDirectories.five
}: Bare API: pretend to npm install, should never commit`, async t => {
t.plan(2);
const tasks: Tasks = {
cloneRepo: cloneRepo(execa, true),
initialCommit: async () => {
t.fail();
},
install: async () => {
t.pass();
}
};
const options = {
description: 'this is an example description',
domDefinitions: false,
email: Placeholders.email,
fullName: Placeholders.name,
githubUsername: 'REDACTED',
install: true,
nodeDefinitions: false,
projectName: TestDirectories.five,
repoURL,
runner: Runner.Npm,
workingDirectory: buildDir
};
const log = console.log;
// tslint:disable-next-line:no-object-mutation
console.log = () => {
// mock console.log to silence it
return;
};
await typescriptStarter(options, tasks);
// tslint:disable-next-line:no-object-mutation
console.log = log; // and put it back
const map = await hashAllTheThings(TestDirectories.five);
t.deepEqual(map, {
'test-five/LICENSE': '1dfe8c78c6af40fc14ea3b40133f1fa5',
'test-five/README.md': '07783e7d4d30b9d57a907854700f1e59',
'test-five/bin/typescript-starter': 'a4ad3923f37f50df986b43b1adb9f6b3',
'test-five/package.json': '3d7a95598a98ba956e47ccfde8590689',
'test-five/src/index.ts': '5991bedc40ac87a01d880c6db16fe349',
'test-five/src/lib/number.spec.ts': '40ebb014eb7871d1f810c618aba1d589',
'test-five/src/lib/number.ts': '43756f90e6ac0b1c4ee6c81d8ab969c7',
'test-five/src/types/example.d.ts': '4221812f6f0434eec77ccb1fba1e3759',
'test-five/tsconfig.json': 'f36dc6407fc898f41a23cb620b2f4884',
'test-five/tsconfig.module.json': 'e452fd6ff2580347077ae3fff2443e34',
'test-five/tslint.json': '7ac167ffbcb724a6c270e8dc4e747067'
});
});

View File

@@ -4,16 +4,16 @@ import { ExecaStatic } from 'execa';
import meow from 'meow';
import nock from 'nock';
import { checkArgs } from '../args';
import { getIntro, Runner } from '../primitives';
import {
cloneRepo,
getGithubUsername,
getRepoUrl,
getUserInfo,
initialCommit,
install,
Placeholders
} from '../tasks';
import { completeSpinner } from '../typescript-starter';
import { getIntro, Runner } from '../utils';
test('errors if outdated', async t => {
nock.disableNetConnect();
@@ -32,25 +32,29 @@ test('errors if outdated', async t => {
t.regex(error.message, /is outdated/);
});
test(`doesn't error if not outdated`, async t => {
const currentVersion = meow('').pkg.version;
t.truthy(typeof currentVersion === 'string');
const passUpdateNotifier = (version: string) => {
nock.disableNetConnect();
nock('https://registry.npmjs.org:443')
.get('/typescript-starter')
.reply(200, {
'dist-tags': { latest: currentVersion },
'dist-tags': { latest: version },
name: 'typescript-starter',
versions: {
[currentVersion]: {
version: currentVersion
[version]: {
version
}
}
});
};
test("doesn't error if not outdated", async t => {
const currentVersion = meow('').pkg.version;
t.truthy(typeof currentVersion === 'string');
passUpdateNotifier(currentVersion);
await t.notThrows(checkArgs);
});
test(`errors if update-notifier fails`, async t => {
test('errors if update-notifier fails', async t => {
nock.disableNetConnect();
nock('https://registry.npmjs.org:443')
.get('/typescript-starter')
@@ -59,6 +63,38 @@ test(`errors if update-notifier fails`, async t => {
t.regex(error.message, /doesn\'t exist/);
});
test('checkArgs returns the right options', async t => {
passUpdateNotifier('1.0.0');
// tslint:disable-next-line:no-object-mutation
process.argv = [
'path/to/node',
'path/to/typescript-starter',
'example-project',
`-description "example description"`,
'--yarn',
'--node',
'--dom',
'--noinstall'
];
const opts = await checkArgs();
t.deepEqual(opts, {
description: '',
domDefinitions: true,
install: false,
nodeDefinitions: true,
projectName: 'example-project',
runner: Runner.Yarn
});
});
test('checkArgs always returns { install } (so --noinstall works in interactive mode)', async t => {
passUpdateNotifier('1.0.0');
// tslint:disable-next-line:no-object-mutation
process.argv = ['path/to/node', 'path/to/typescript-starter'];
const opts = await checkArgs();
t.deepEqual(opts, { install: true });
});
test('ascii art shows if stdout has 85+ columns', async t => {
const jumbo = getIntro(100);
const snippet = `| __| | | | '_ \\ / _ \\/ __|/ __| '__| | '_ \\|`;
@@ -74,23 +110,23 @@ const mockErr = (code?: string | number) =>
}) as any) as ExecaStatic;
test('cloneRepo: errors when Git is not installed on PATH', async t => {
const error = await t.throws(cloneRepo(mockErr('ENOENT'))('fail'));
const error = await t.throws(cloneRepo(mockErr('ENOENT'))('r', 'd', 'p'));
t.regex(error.message, /Git is not installed on your PATH/);
});
test('cloneRepo: throws when clone fails', async t => {
const error = await t.throws(cloneRepo(mockErr(128))('fail'));
const error = await t.throws(cloneRepo(mockErr(128))('r', 'd', 'p'));
t.regex(error.message, /Git clone failed./);
});
test('cloneRepo: throws when rev-parse fails', async t => {
// tslint:disable-next-line:prefer-const no-let
let calls = 0;
const mock = () => {
const mock = ((async () => {
calls++;
return calls === 1 ? {} : (mockErr(128) as any)();
};
const error = await t.throws(cloneRepo((mock as any) as ExecaStatic)('fail'));
}) as any) as ExecaStatic;
const error = await t.throws(cloneRepo(mock)('r', 'd', 'p'));
t.regex(error.message, /Git rev-parse failed./);
});
@@ -102,6 +138,14 @@ test('getGithubUsername: returns found users', async t => {
t.is(username, 'bitjson');
});
test("getGithubUsername: returns placeholder if user doesn't have Git user.email set", async t => {
const mockFetcher = async () => t.fail();
const username: string = await getGithubUsername(mockFetcher)(
Placeholders.email
);
t.is(username, Placeholders.username);
});
test('getGithubUsername: returns placeholder if not found', async t => {
const mockFetcher = async () => {
throw new Error();
@@ -134,60 +178,30 @@ test('getUserInfo: returns results properly', async t => {
});
test('initialCommit: throws generated errors', async t => {
const error = await t.throws(
initialCommit(mockErr(1))('deadbeef', 'fail', 'name', 'bitjson@github.com')
);
const error = await t.throws(initialCommit(mockErr(1))('deadbeef', 'fail'));
t.is(error.code, 1);
});
test('initialCommit: attempts to commit', async t => {
// tslint:disable-next-line:no-let
test('initialCommit: spawns 3 times', async t => {
t.plan(4);
const mock = ((async () => {
t.pass();
}) as any) as ExecaStatic;
t.true(
await initialCommit(mock)('commit', 'dir', 'name', 'valid@example.com')
);
});
test("initialCommit: don't attempt to commit if user.name/email is not set", async t => {
// tslint:disable-next-line:no-let
let calls = 0;
const errorIf3 = ((async () => {
calls++;
calls === 1 ? t.pass() : calls === 2 ? t.pass() : t.fail();
}) as any) as ExecaStatic;
t.false(
await initialCommit(errorIf3)(
'deadbeef',
'fail',
Placeholders.name,
Placeholders.email
)
);
await t.notThrows(initialCommit(mock)('commit', 'dir'));
});
test('install: uses the correct runner', async t => {
const mock = ((async (runner: Runner) => {
runner === Runner.Yarn ? t.pass() : t.fail();
}) as any) as ExecaStatic;
await install(mock)(true, Runner.Yarn, 'pass');
await install(mock)(Runner.Yarn, 'pass');
});
test('install: throws pretty error on failure', async t => {
const error = await t.throws(install(mockErr())(true, Runner.Npm, 'fail'));
const error = await t.throws(install(mockErr())(Runner.Npm, 'fail'));
t.is(error.message, "Installation failed. You'll need to install manually.");
});
test('completeSpinner: resolves spinners properly', async t => {
t.plan(2);
const never = () => {
t.fail();
};
const check = (confirm?: string) => (result?: string) => {
confirm ? t.is(confirm, result) : t.pass();
};
completeSpinner({ succeed: check(), fail: never }, true);
completeSpinner({ succeed: never, fail: check('message') }, false, 'message');
test("getRepoUrl: returns GitHub repo when TYPESCRIPT_STARTER_REPO_URL isn't set", async t => {
t.is(getRepoUrl(), 'https://github.com/bitjson/typescript-starter.git');
});

View File

@@ -1,42 +1,48 @@
// tslint:disable:no-console no-if-statement no-expression-statement
import chalk from 'chalk';
import del from 'del';
import { renameSync } from 'fs';
import { readFileSync, renameSync, writeFileSync } from 'fs';
import ora from 'ora';
import { join } from 'path';
import replace from 'replace-in-file';
import { Runner, TypescriptStarterOptions } from './primitives';
import { Tasks } from './tasks';
import { Placeholders, Tasks } from './tasks';
import { Runner, TypescriptStarterOptions } from './utils';
export async function typescriptStarter(
{
description,
domDefinitions,
email,
install,
name,
projectName,
nodeDefinitions,
runner
runner,
fullName,
githubUsername,
repoURL,
workingDirectory
}: TypescriptStarterOptions,
tasks: Tasks
): Promise<void> {
console.log();
const { commitHash, gitHistoryDir } = await tasks.cloneRepo(name);
const { commitHash, gitHistoryDir } = await tasks.cloneRepo(
repoURL,
workingDirectory,
projectName
);
await del([gitHistoryDir]);
console.log(`
${chalk.dim(`Cloned at commit: ${commitHash}`)}
`);
const { gitName, gitEmail } = await tasks.getUserInfo();
const username = await tasks.getGithubUsername(gitEmail);
const spinner1 = ora('Updating package.json').start();
const projectPath = join(process.cwd(), name);
const projectPath = join(workingDirectory, projectName);
const pkgPath = join(projectPath, 'package.json');
// dependencies to retain for Node.js applications
const nodeKeptDeps: ReadonlyArray<any> = ['sha.js'];
const pkg = tasks.readPackageJson(pkgPath);
const pkg = readPackageJson(pkgPath);
const newPkg = {
...pkg,
bin: {},
@@ -47,19 +53,19 @@ export async function typescriptStarter(
: {},
description,
keywords: [],
name,
repository: `https:// github.com/${username}/${name}`,
projectName,
repository: `https:// github.com/${githubUsername}/${projectName}`,
scripts:
runner === Runner.Yarn
? {
...pkg.scripts,
preinstall: `node -e \"if(process.env.npm_execpath.indexOf('yarn') === -1) throw new Error('${name} must be installed with Yarn: https://yarnpkg.com/')\"`
preinstall: `node -e \"if(process.env.npm_execpath.indexOf('yarn') === -1) throw new Error('${projectName} must be installed with Yarn: https://yarnpkg.com/')\"`
}
: { ...pkg.scripts },
version: '1.0.0'
};
tasks.writePackageJson(pkgPath, newPkg);
writePackageJson(pkgPath, newPkg);
spinner1.succeed();
const spinner2 = ora('Updating .gitignore').start();
@@ -84,7 +90,7 @@ export async function typescriptStarter(
await replace({
files: join(projectPath, 'LICENSE'),
from: 'Jason Dreyzehner',
to: gitName
to: fullName
});
spinner4.succeed();
@@ -94,7 +100,8 @@ export async function typescriptStarter(
join(projectPath, 'CHANGELOG.md'),
join(projectPath, 'README.md'),
join(projectPath, 'package-lock.json'),
join(projectPath, 'src', 'typescript-starter.ts')
join(projectPath, 'src', 'cli'),
join(projectPath, 'src', 'types', 'cli.d.ts')
]);
spinner5.succeed();
@@ -106,7 +113,7 @@ export async function typescriptStarter(
await replace({
files: join(projectPath, 'README.md'),
from: 'package-name',
to: name
to: projectName
});
spinner6.succeed();
@@ -141,25 +148,25 @@ export async function typescriptStarter(
spinner6B.succeed();
}
await tasks.install(install, runner, projectPath);
if (install) {
await tasks.install(runner, projectPath);
}
const spinner7 = ora(`Initializing git repository`).start();
completeSpinner(
spinner7,
await tasks.initialCommit(commitHash, projectPath, gitName, gitEmail),
"Git config user.name and user.email are not configured. You'll need to `git commit` yourself."
);
if (fullName !== Placeholders.name && email !== Placeholders.email) {
const spinner7 = ora(`Initializing git repository...`).start();
await tasks.initialCommit(commitHash, projectPath, fullName);
spinner7.succeed();
}
console.log(`\n${chalk.blue.bold(`Created ${name} 🎉`)}\n`);
console.log(`\n${chalk.blue.bold(`Created ${projectName} 🎉`)}\n`);
}
export function completeSpinner(
spinner: {
readonly succeed: (text?: string) => any;
readonly fail: (text?: string) => any;
},
success: boolean,
message?: string
): void {
success ? spinner.succeed() : spinner.fail(message);
}
const readPackageJson = (path: string) =>
JSON.parse(readFileSync(path, 'utf8'));
const writePackageJson = (path: string, pkg: any) => {
// write using the same format as npm:
// https://github.com/npm/npm/blob/latest/lib/install/update-package-json.js#L48
const stringified = JSON.stringify(pkg, null, 2) + '\n';
return writeFileSync(path, stringified);
};

View File

@@ -6,15 +6,27 @@ export enum Runner {
Yarn = 'yarn'
}
export interface TypescriptStarterOptions {
export interface TypescriptStarterUserOptions {
readonly description: string;
readonly domDefinitions: boolean;
readonly install: boolean;
readonly nodeDefinitions: boolean;
readonly name: string;
readonly projectName: string;
readonly runner: Runner;
}
export interface TypescriptStarterInferredOptions {
readonly githubUsername: string;
readonly fullName: string;
readonly email: string;
readonly repoURL: string;
readonly workingDirectory: string;
}
export interface TypescriptStarterOptions
extends TypescriptStarterUserOptions,
TypescriptStarterInferredOptions {}
export function validateName(input: string): true | string {
return !/^\s*[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\s*$/.test(input)
? 'Name should be in-kebab-case'

3
src/types/cli.d.ts vendored
View File

@@ -3,6 +3,5 @@
// so its purpose is just to squelch noImplicitAny errors.
declare module 'github-username';
declare module 'gradient-string';
declare module 'has-ansi';
declare module 'mock-spawn';
declare module 'md5-file';
declare module 'replace-in-file';