refactor(CLI): modularize more, test more
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
106
src/cli/tasks.ts
106
src/cli/tasks.ts
@@ -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()
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user