feat(CLI): v2
This commit is contained in:
93
src/cli/args.ts
Normal file
93
src/cli/args.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import meow from 'meow';
|
||||
import { Package, UpdateInfo, UpdateNotifier } from 'update-notifier';
|
||||
import { Runner, TypescriptStarterOptions, validateName } from './primitives';
|
||||
|
||||
export async function checkArgs(): Promise<
|
||||
TypescriptStarterOptions | undefined
|
||||
> {
|
||||
const cli = meow(
|
||||
`
|
||||
Usage
|
||||
$ typescript-starter
|
||||
|
||||
Non-Interactive Usage
|
||||
$ typescript-starter <project-name> [options]
|
||||
|
||||
Options
|
||||
--description, -d package.json description
|
||||
--yarn use yarn (default: npm)
|
||||
--node include node.js type definitions
|
||||
--dom include DOM type definitions
|
||||
--noinstall skip yarn/npm install
|
||||
|
||||
Non-Interactive Example
|
||||
$ typescript-starter my-library -d 'do something, better'
|
||||
`,
|
||||
{
|
||||
flags: {
|
||||
description: {
|
||||
alias: 'd',
|
||||
default: 'a typescript-starter project',
|
||||
type: 'string'
|
||||
},
|
||||
dom: {
|
||||
default: false,
|
||||
type: 'boolean'
|
||||
},
|
||||
node: {
|
||||
default: false,
|
||||
type: 'boolean'
|
||||
},
|
||||
noinstall: {
|
||||
default: false,
|
||||
type: 'boolean'
|
||||
},
|
||||
yarn: {
|
||||
default: false,
|
||||
type: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// immediately check for updates every time we run typescript-starter
|
||||
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.
|
||||
Consider using 'npx typescript-starter' to always get the latest version.
|
||||
`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const validOrMsg = await validateName(input);
|
||||
// tslint:disable-next-line:no-if-statement
|
||||
if (typeof validOrMsg === 'string') {
|
||||
throw new Error(validOrMsg);
|
||||
}
|
||||
|
||||
return {
|
||||
description: cli.flags.description,
|
||||
domDefinitions: cli.flags.dom,
|
||||
install: !cli.flags.noinstall,
|
||||
name: input,
|
||||
nodeDefinitions: cli.flags.node,
|
||||
runner: cli.flags.yarn ? Runner.Yarn : Runner.Npm
|
||||
};
|
||||
}
|
||||
23
src/cli/cli.ts
Normal file
23
src/cli/cli.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// tslint:disable:no-expression-statement no-console
|
||||
import chalk from 'chalk';
|
||||
import { checkArgs } from './args';
|
||||
import { inquire } from './inquire';
|
||||
import { getIntro } from './primitives';
|
||||
import { LiveTasks } from './tasks';
|
||||
import { typescriptStarter } from './typescript-starter';
|
||||
|
||||
(async () => {
|
||||
const cliOptions = await checkArgs();
|
||||
const options = cliOptions
|
||||
? cliOptions
|
||||
: await (async () => {
|
||||
console.log(getIntro(process.stdout.columns));
|
||||
return inquire();
|
||||
})();
|
||||
return typescriptStarter(options, LiveTasks);
|
||||
})().catch((err: Error) => {
|
||||
console.error(`
|
||||
${chalk.red(err.message)}
|
||||
`);
|
||||
process.exit(1);
|
||||
});
|
||||
107
src/cli/inquire.ts
Normal file
107
src/cli/inquire.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { prompt, Question } from 'inquirer';
|
||||
import { Runner, TypescriptStarterOptions, validateName } from './primitives';
|
||||
|
||||
export async function inquire(): Promise<TypescriptStarterOptions> {
|
||||
const packageNameQuestion: Question = {
|
||||
filter: (answer: string) => answer.trim(),
|
||||
message: 'Enter the new package name:',
|
||||
name: 'name',
|
||||
type: 'input',
|
||||
validate: validateName
|
||||
};
|
||||
|
||||
enum ProjectType {
|
||||
Node = 'node',
|
||||
Library = 'lib'
|
||||
}
|
||||
const projectTypeQuestion: Question = {
|
||||
choices: [
|
||||
{ name: 'Node.js application', value: ProjectType.Node },
|
||||
{ name: 'Javascript library', value: ProjectType.Library }
|
||||
],
|
||||
message: 'What are you making?',
|
||||
name: 'type',
|
||||
type: 'list'
|
||||
};
|
||||
|
||||
const packageDescriptionQuestion: Question = {
|
||||
filter: (answer: string) => answer.trim(),
|
||||
message: 'Enter the package description:',
|
||||
name: 'description',
|
||||
type: 'input',
|
||||
validate: (answer: string) => answer.length > 0
|
||||
};
|
||||
|
||||
const runnerQuestion: Question = {
|
||||
choices: [
|
||||
{ name: 'npm', value: Runner.Npm },
|
||||
{ name: 'yarn', value: Runner.Yarn }
|
||||
],
|
||||
message: 'Will this project use npm or yarn?',
|
||||
name: 'runner',
|
||||
type: 'list'
|
||||
};
|
||||
|
||||
enum TypeDefinitions {
|
||||
none = 'none',
|
||||
Node = 'node',
|
||||
DOM = 'dom',
|
||||
NodeAndDOM = 'both'
|
||||
}
|
||||
|
||||
const typeDefsQuestion: Question = {
|
||||
choices: [
|
||||
{
|
||||
name: `None — the library won't use any globals or modules from Node.js or the DOM`,
|
||||
value: '0'
|
||||
},
|
||||
{
|
||||
name: `Node.js — parts of the library require access to Node.js globals or built-in modules`,
|
||||
value: '1'
|
||||
},
|
||||
{
|
||||
name: `DOM — parts of the library require access to the Document Object Model (DOM)`,
|
||||
value: '2'
|
||||
},
|
||||
{
|
||||
name: `Both Node.js and DOM — some parts of the library require Node.js, other parts require DOM access`,
|
||||
value: '3'
|
||||
}
|
||||
],
|
||||
message: 'Which global type definitions do you want to include?',
|
||||
name: 'definitions',
|
||||
type: 'list',
|
||||
when: (answers: any) => answers.type === ProjectType.Library
|
||||
};
|
||||
|
||||
return prompt([
|
||||
packageNameQuestion,
|
||||
projectTypeQuestion,
|
||||
packageDescriptionQuestion,
|
||||
runnerQuestion,
|
||||
typeDefsQuestion
|
||||
]).then(answers => {
|
||||
const { definitions, description, name, runner } = answers as {
|
||||
readonly definitions?: TypeDefinitions;
|
||||
readonly description: string;
|
||||
readonly name: string;
|
||||
readonly runner: Runner;
|
||||
};
|
||||
return {
|
||||
description,
|
||||
domDefinitions: definitions
|
||||
? [TypeDefinitions.DOM, TypeDefinitions.NodeAndDOM].includes(
|
||||
definitions
|
||||
)
|
||||
: false,
|
||||
install: true,
|
||||
name,
|
||||
nodeDefinitions: definitions
|
||||
? [TypeDefinitions.Node, TypeDefinitions.NodeAndDOM].includes(
|
||||
definitions
|
||||
)
|
||||
: false,
|
||||
runner
|
||||
};
|
||||
});
|
||||
}
|
||||
39
src/cli/primitives.ts
Normal file
39
src/cli/primitives.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import chalk from 'chalk';
|
||||
import { existsSync } from 'fs';
|
||||
import gradient from 'gradient-string';
|
||||
export enum Runner {
|
||||
Npm = 'npm',
|
||||
Yarn = 'yarn'
|
||||
}
|
||||
|
||||
export interface TypescriptStarterOptions {
|
||||
readonly description: string;
|
||||
readonly domDefinitions: boolean;
|
||||
readonly install: boolean;
|
||||
readonly nodeDefinitions: boolean;
|
||||
readonly name: string;
|
||||
readonly runner: Runner;
|
||||
}
|
||||
|
||||
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'
|
||||
: existsSync(input)
|
||||
? `The "${input}" path already exists in this directory.`
|
||||
: true;
|
||||
}
|
||||
|
||||
export function getIntro(columns: number | undefined): string {
|
||||
const ascii = `
|
||||
_ _ _ _ _
|
||||
| |_ _ _ _ __ ___ ___ ___ _ __(_)_ __ | |_ ___| |_ __ _ _ __| |_ ___ _ __
|
||||
| __| | | | '_ \\ / _ \\/ __|/ __| '__| | '_ \\| __|____/ __| __/ _\` | '__| __/ _ \\ '__|
|
||||
| |_| |_| | |_) | __/\\__ \\ (__| | | | |_) | ||_____\\__ \\ || (_| | | | || __/ |
|
||||
\\__|\\__, | .__/ \\___||___/\\___|_| |_| .__/ \\__| |___/\\__\\__,_|_| \\__\\___|_|
|
||||
|___/|_| |_|
|
||||
`;
|
||||
|
||||
return columns && columns >= 85
|
||||
? chalk.bold(gradient.mind(ascii))
|
||||
: `\n${chalk.cyan.bold.underline('typescript-starter')}\n`;
|
||||
}
|
||||
146
src/cli/tasks.ts
Normal file
146
src/cli/tasks.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// tslint:disable:no-console no-if-statement no-expression-statement
|
||||
import execa, { ExecaStatic, Options } from 'execa';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import githubUsername from 'github-username';
|
||||
import { join } from 'path';
|
||||
import { Runner } from './primitives';
|
||||
|
||||
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) => Promise<void>;
|
||||
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);
|
||||
const gitHistoryDir = join(projectDir, '.git');
|
||||
try {
|
||||
await spawner('git', ['clone', '--depth=1', repo, dir], {
|
||||
cwd,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
throw new Error(`
|
||||
Git is not installed on your PATH. Please install Git and try again.
|
||||
|
||||
For more information, visit: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git
|
||||
`);
|
||||
} else {
|
||||
throw new Error(`Git clone failed.`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const revParseResult = await spawner('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'inherit']
|
||||
});
|
||||
const commitHash = revParseResult.stdout;
|
||||
return { commitHash, gitHistoryDir };
|
||||
} catch (err) {
|
||||
throw new Error(`Git rev-parse failed.`);
|
||||
}
|
||||
};
|
||||
|
||||
export const getGithubUsername = (fetcher: any) => async (email: string) =>
|
||||
fetcher(email).catch(() => {
|
||||
// if username isn't found, just return a placeholder
|
||||
return 'YOUR_USER_NAME';
|
||||
});
|
||||
|
||||
export const getUserInfo = (spawner: ExecaStatic) => async () => {
|
||||
const opts: Options = {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'inherit']
|
||||
};
|
||||
const nameResult = await spawner('git', ['config', 'user.name'], opts);
|
||||
const emailResult = await spawner('git', ['config', 'user.email'], opts);
|
||||
return {
|
||||
gitEmail: emailResult.stdout,
|
||||
gitName: nameResult.stdout
|
||||
};
|
||||
};
|
||||
|
||||
export const initialCommit = (spawner: ExecaStatic) => async (
|
||||
hash: string,
|
||||
projectDir: string
|
||||
) => {
|
||||
const opts: Options = {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe'
|
||||
};
|
||||
await spawner('git', ['init'], opts);
|
||||
await spawner('git', ['add', '-A'], opts);
|
||||
await spawner(
|
||||
'git',
|
||||
[
|
||||
'commit',
|
||||
'-m',
|
||||
`Initial commit\n\nCreated with typescript-starter@${hash}`
|
||||
],
|
||||
opts
|
||||
);
|
||||
};
|
||||
|
||||
export const install = (spawner: ExecaStatic) => async (
|
||||
shouldInstall: boolean,
|
||||
runner: Runner,
|
||||
projectDir: string
|
||||
) => {
|
||||
const opts: Options = {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
stdio: 'inherit'
|
||||
};
|
||||
if (!shouldInstall) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
runner === Runner.Npm
|
||||
? spawner('npm', ['install'], opts)
|
||||
: spawner('yarn', opts);
|
||||
} catch (err) {
|
||||
throw new Error(`Installation failed. You'll need to install manually.`);
|
||||
}
|
||||
};
|
||||
|
||||
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 LiveTasks: Tasks = {
|
||||
cloneRepo: cloneRepo(execa),
|
||||
getGithubUsername: getGithubUsername(githubUsername),
|
||||
getUserInfo: getUserInfo(execa),
|
||||
initialCommit: initialCommit(execa),
|
||||
install: install(execa),
|
||||
readPackageJson,
|
||||
writePackageJson
|
||||
};
|
||||
192
src/cli/tests/cli.integration.spec.ts
Normal file
192
src/cli/tests/cli.integration.spec.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// 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.
|
||||
|
||||
// tslint:disable:no-expression-statement
|
||||
import test, { ExecutionContext } from 'ava';
|
||||
import del from 'del';
|
||||
import execa from 'execa';
|
||||
import meow from 'meow';
|
||||
import { join } from 'path';
|
||||
|
||||
test('returns version', async t => {
|
||||
const expected = meow('').pkg.version;
|
||||
t.truthy(typeof expected === 'string');
|
||||
const { stdout } = await execa(`./bin/typescript-starter`, ['--version']);
|
||||
t.is(stdout, expected);
|
||||
});
|
||||
|
||||
test('returns help/usage', async t => {
|
||||
const { stdout } = await execa(`./bin/typescript-starter`, ['--help']);
|
||||
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(
|
||||
execa(`./bin/typescript-starter`, [existingDir])
|
||||
);
|
||||
t.regex(error.stderr, /"build" path already exists/);
|
||||
});
|
||||
|
||||
test('errors if project name is not in kebab-case', async t => {
|
||||
const error = await t.throws(
|
||||
execa(`./bin/typescript-starter`, ['name with spaces'])
|
||||
);
|
||||
t.regex(error.stderr, /should be in-kebab-case/);
|
||||
});
|
||||
|
||||
test('integration test 1: parses CLI arguments, handles options properly', async t => {
|
||||
const { stdout } = await execa(
|
||||
`../bin/typescript-starter`,
|
||||
[
|
||||
`${testDirectories.one}`,
|
||||
'-description "example description 1"',
|
||||
'--noinstall'
|
||||
],
|
||||
{
|
||||
cwd: join(process.cwd(), 'build'),
|
||||
env: {
|
||||
TYPESCRIPT_STARTER_REPO_URL: process.cwd()
|
||||
}
|
||||
}
|
||||
);
|
||||
t.regex(stdout, new RegExp(`Created ${testDirectories.one} 🎉`));
|
||||
// TODO: validate contents of testDirectories.one
|
||||
});
|
||||
|
||||
test('integration test 2: parses CLI arguments, handles options properly', async t => {
|
||||
const { stdout } = await execa(
|
||||
`../bin/typescript-starter`,
|
||||
[
|
||||
`${testDirectories.two}`,
|
||||
'-description "example description 2"',
|
||||
'--yarn',
|
||||
'--node',
|
||||
'--dom',
|
||||
'--noinstall'
|
||||
],
|
||||
{
|
||||
cwd: join(process.cwd(), 'build'),
|
||||
env: {
|
||||
TYPESCRIPT_STARTER_REPO_URL: process.cwd()
|
||||
}
|
||||
}
|
||||
);
|
||||
t.regex(stdout, new RegExp(`Created ${testDirectories.two} 🎉`));
|
||||
// TODO: validate contents of testDirectories.two
|
||||
});
|
||||
|
||||
const down = '\x1B\x5B\x42';
|
||||
const up = '\x1B\x5B\x41';
|
||||
const enter = '\x0D';
|
||||
const ms = (milliseconds: number) =>
|
||||
new Promise<void>(resolve => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function testInteractive(
|
||||
t: ExecutionContext<{}>,
|
||||
projectName: string,
|
||||
entry: ReadonlyArray<string | ReadonlyArray<string>>
|
||||
): Promise<void> {
|
||||
const lastCheck = entry[3] !== undefined;
|
||||
t.plan(lastCheck ? 6 : 5);
|
||||
|
||||
const proc = execa(`../bin/typescript-starter`, ['--noinstall'], {
|
||||
cwd: join(process.cwd(), 'build'),
|
||||
env: {
|
||||
TYPESCRIPT_STARTER_REPO_URL: process.cwd()
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: missing in Node.js type definition's ChildProcess.stdin?
|
||||
// https://nodejs.org/api/process.html#process_process_stdin
|
||||
// proc.stdin.setEncoding('utf8');
|
||||
|
||||
// tslint:disable-next-line:prefer-const no-let
|
||||
let buffer = '';
|
||||
const checkBuffer = (regex: RegExp) => t.regex(buffer, regex);
|
||||
const type = (input: string) => proc.stdin.write(input);
|
||||
const clearBuffer = () => (buffer = '');
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
buffer += chunk.toString();
|
||||
});
|
||||
|
||||
// wait for first chunk to be emitted
|
||||
await new Promise(resolve => {
|
||||
proc.stdout.once('data', resolve);
|
||||
});
|
||||
await ms(200);
|
||||
checkBuffer(new RegExp(`typescript-starter.|s*Enter the new package name`));
|
||||
clearBuffer();
|
||||
type(`${projectName}${enter}`);
|
||||
await ms(200);
|
||||
checkBuffer(new RegExp(`${projectName}.|s*What are you making?`));
|
||||
clearBuffer();
|
||||
type(`${entry[0][0]}${enter}`);
|
||||
await ms(200);
|
||||
checkBuffer(new RegExp(`${entry[0][1]}.|s*Enter the package description`));
|
||||
clearBuffer();
|
||||
type(`${entry[1]}${enter}`);
|
||||
await ms(200);
|
||||
checkBuffer(new RegExp(`${entry[1]}.|s*npm or yarn?`));
|
||||
clearBuffer();
|
||||
type(`${entry[2][0]}${enter}`);
|
||||
await ms(200);
|
||||
|
||||
const search = `${entry[2][1]}.|s*global type definitions`;
|
||||
const exp = lastCheck
|
||||
? new RegExp(`${search}`) // should match
|
||||
: new RegExp(`(?!${search})`); // should not match
|
||||
checkBuffer(exp);
|
||||
// tslint:disable-next-line:no-if-statement
|
||||
if (lastCheck) {
|
||||
clearBuffer();
|
||||
type(`${entry[3][0]}${enter}`);
|
||||
await ms(200);
|
||||
checkBuffer(new RegExp(`${entry[3][1]}`));
|
||||
}
|
||||
|
||||
// TODO: validate contents?
|
||||
}
|
||||
|
||||
test('integration test 3: interactive mode, javascript library', async t => {
|
||||
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`]
|
||||
]);
|
||||
});
|
||||
|
||||
test('integration test 4: interactive mode, node.js application', async t => {
|
||||
await testInteractive(t, `${testDirectories.four}`, [
|
||||
[`${down}${up}`, `Node.js application`],
|
||||
`integration test 4 description${enter}`,
|
||||
[`${down}${up}${enter}`, `npm`]
|
||||
]);
|
||||
});
|
||||
133
src/cli/tests/cli.unit.spec.ts
Normal file
133
src/cli/tests/cli.unit.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// tslint:disable:no-expression-statement
|
||||
import test from 'ava';
|
||||
import { ExecaStatic } from 'execa';
|
||||
import meow from 'meow';
|
||||
import nock from 'nock';
|
||||
import { checkArgs } from '../args';
|
||||
import { getIntro, Runner } from '../primitives';
|
||||
import {
|
||||
cloneRepo,
|
||||
getGithubUsername,
|
||||
getUserInfo,
|
||||
initialCommit,
|
||||
install
|
||||
} from '../tasks';
|
||||
|
||||
test('errors if outdated', async t => {
|
||||
nock.disableNetConnect();
|
||||
nock('https://registry.npmjs.org:443')
|
||||
.get('/typescript-starter')
|
||||
.reply(200, {
|
||||
'dist-tags': { latest: '9000.0.1' },
|
||||
name: 'typescript-starter',
|
||||
versions: {
|
||||
'9000.0.1': {
|
||||
version: '9000.0.1'
|
||||
}
|
||||
}
|
||||
});
|
||||
const error = await t.throws(checkArgs);
|
||||
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');
|
||||
nock.disableNetConnect();
|
||||
nock('https://registry.npmjs.org:443')
|
||||
.get('/typescript-starter')
|
||||
.reply(200, {
|
||||
'dist-tags': { latest: currentVersion },
|
||||
name: 'typescript-starter',
|
||||
versions: {
|
||||
[currentVersion]: {
|
||||
version: currentVersion
|
||||
}
|
||||
}
|
||||
});
|
||||
await t.notThrows(checkArgs);
|
||||
});
|
||||
|
||||
test(`errors if update-notifier fails`, async t => {
|
||||
nock.disableNetConnect();
|
||||
nock('https://registry.npmjs.org:443')
|
||||
.get('/typescript-starter')
|
||||
.reply(404, {});
|
||||
const error = await t.throws(checkArgs);
|
||||
t.regex(error.message, /doesn\'t exist/);
|
||||
});
|
||||
|
||||
test('ascii art shows if stdout has 85+ columns', async t => {
|
||||
const jumbo = getIntro(100);
|
||||
const snippet = `| __| | | | '_ \\ / _ \\/ __|/ __| '__| | '_ \\|`;
|
||||
t.regex(jumbo, new RegExp(snippet));
|
||||
});
|
||||
|
||||
const mockErr = (code?: string | number) =>
|
||||
((() => {
|
||||
const err = new Error();
|
||||
// tslint:disable-next-line:no-object-mutation
|
||||
(err as any).code = code;
|
||||
throw err;
|
||||
}) 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'));
|
||||
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'));
|
||||
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 = () => {
|
||||
calls++;
|
||||
return calls === 1 ? {} : (mockErr(128) as any)();
|
||||
};
|
||||
const error = await t.throws(cloneRepo((mock as any) as ExecaStatic)('fail'));
|
||||
t.regex(error.message, /Git rev-parse failed./);
|
||||
});
|
||||
|
||||
test('getGithubUsername: returns found users', async t => {
|
||||
const mockFetcher = async (email: string) => email.split('@')[0];
|
||||
const username: string = await getGithubUsername(mockFetcher)(
|
||||
'bitjson@github.com'
|
||||
);
|
||||
t.is(username, 'bitjson');
|
||||
});
|
||||
|
||||
test('getGithubUsername: returns placeholder if not found', async t => {
|
||||
const mockFetcher = async () => {
|
||||
throw new Error();
|
||||
};
|
||||
const username: string = await getGithubUsername(mockFetcher)(
|
||||
'bitjson@github.com'
|
||||
);
|
||||
t.is(username, 'YOUR_USER_NAME');
|
||||
});
|
||||
|
||||
test('getUserInfo: throws generated errors', async t => {
|
||||
const error = await t.throws(getUserInfo(mockErr(1))());
|
||||
t.is(error.code, 1);
|
||||
});
|
||||
|
||||
test('initialCommit: throws generated errors', async t => {
|
||||
const error = await t.throws(initialCommit(mockErr(1))('deadbeef', 'fail'));
|
||||
t.is(error.code, 1);
|
||||
});
|
||||
|
||||
test('install: uses the correct runner', async t => {
|
||||
const mock = (((runner: Runner) => {
|
||||
runner === Runner.Yarn ? t.pass() : t.fail();
|
||||
}) as any) as ExecaStatic;
|
||||
await install(mock)(true, Runner.Yarn, 'pass');
|
||||
});
|
||||
|
||||
test('install: throws pretty error on failure', async t => {
|
||||
const error = await t.throws(install(mockErr())(true, Runner.Npm, 'fail'));
|
||||
t.is(error.message, "Installation failed. You'll need to install manually.");
|
||||
});
|
||||
151
src/cli/typescript-starter.ts
Normal file
151
src/cli/typescript-starter.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
// tslint:disable:no-console no-if-statement no-expression-statement
|
||||
import chalk from 'chalk';
|
||||
import del from 'del';
|
||||
import { renameSync } 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';
|
||||
|
||||
export async function typescriptStarter(
|
||||
{
|
||||
description,
|
||||
domDefinitions,
|
||||
install,
|
||||
name,
|
||||
nodeDefinitions,
|
||||
runner
|
||||
}: TypescriptStarterOptions,
|
||||
tasks: Tasks
|
||||
): Promise<void> {
|
||||
console.log();
|
||||
const { commitHash, gitHistoryDir } = await tasks.cloneRepo(name);
|
||||
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 pkgPath = join(projectPath, 'package.json');
|
||||
|
||||
// dependencies to retain for Node.js applications
|
||||
const nodeKeptDeps: ReadonlyArray<any> = ['sha.js'];
|
||||
|
||||
const pkg = tasks.readPackageJson(pkgPath);
|
||||
const newPkg = {
|
||||
...pkg,
|
||||
bin: {},
|
||||
dependencies: nodeDefinitions
|
||||
? nodeKeptDeps.reduce((all, dep) => {
|
||||
return { ...all, [dep]: pkg.dependencies[dep] };
|
||||
}, {})
|
||||
: {},
|
||||
description,
|
||||
keywords: [],
|
||||
name,
|
||||
repository: `https:// github.com/${username}/${name}`,
|
||||
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/')\"`
|
||||
}
|
||||
: { ...pkg.scripts },
|
||||
version: '1.0.0'
|
||||
};
|
||||
|
||||
tasks.writePackageJson(pkgPath, newPkg);
|
||||
spinner1.succeed();
|
||||
|
||||
const spinner2 = ora('Updating .gitignore').start();
|
||||
if (runner === Runner.Yarn) {
|
||||
await replace({
|
||||
files: join(projectPath, '.gitignore'),
|
||||
from: 'yarn.lock',
|
||||
to: 'package-lock.json'
|
||||
});
|
||||
}
|
||||
spinner2.succeed();
|
||||
|
||||
const spinner3 = ora('Updating .npmignore').start();
|
||||
await replace({
|
||||
files: join(projectPath, '.npmignore'),
|
||||
from: 'examples\n',
|
||||
to: ''
|
||||
});
|
||||
spinner3.succeed();
|
||||
|
||||
const spinner4 = ora('Updating LICENSE').start();
|
||||
await replace({
|
||||
files: join(projectPath, 'LICENSE'),
|
||||
from: 'Jason Dreyzehner',
|
||||
to: gitName
|
||||
});
|
||||
spinner4.succeed();
|
||||
|
||||
const spinner5 = ora('Deleting unnecessary files').start();
|
||||
await del([
|
||||
join(projectPath, 'examples'),
|
||||
join(projectPath, 'CHANGELOG.md'),
|
||||
join(projectPath, 'README.md'),
|
||||
join(projectPath, 'package-lock.json'),
|
||||
join(projectPath, 'src', 'typescript-starter.ts')
|
||||
]);
|
||||
spinner5.succeed();
|
||||
|
||||
const spinner6 = ora('Updating README.md').start();
|
||||
renameSync(
|
||||
join(projectPath, 'README-starter.md'),
|
||||
join(projectPath, 'README.md')
|
||||
);
|
||||
await replace({
|
||||
files: join(projectPath, 'README.md'),
|
||||
from: 'package-name',
|
||||
to: name
|
||||
});
|
||||
spinner6.succeed();
|
||||
|
||||
if (!domDefinitions) {
|
||||
const spinner6A = ora(`tsconfig: don't include "dom" lib`).start();
|
||||
await replace({
|
||||
files: join(projectPath, 'tsconfig.json'),
|
||||
from: '"lib": ["es2017", "dom"]',
|
||||
to: '"lib": ["es2017"]'
|
||||
});
|
||||
spinner6A.succeed();
|
||||
}
|
||||
|
||||
if (!nodeDefinitions) {
|
||||
const spinner6B = ora(`tsconfig: don't include "node" types`).start();
|
||||
await replace({
|
||||
files: join(projectPath, 'tsconfig.json'),
|
||||
from: '"types": ["node"]',
|
||||
to: '"types": []'
|
||||
});
|
||||
await replace({
|
||||
files: join(projectPath, 'src', 'index.ts'),
|
||||
from: `export * from './lib/hash';\n`,
|
||||
to: ''
|
||||
});
|
||||
await del([
|
||||
join(projectPath, 'src', 'lib', 'hash.ts'),
|
||||
join(projectPath, 'src', 'lib', 'hash.spec.ts'),
|
||||
join(projectPath, 'src', 'lib', 'async.ts'),
|
||||
join(projectPath, 'src', 'lib', 'async.spec.ts')
|
||||
]);
|
||||
spinner6B.succeed();
|
||||
}
|
||||
|
||||
await tasks.install(install, runner, projectPath);
|
||||
|
||||
const spinner7 = ora(`Initializing git repository`).start();
|
||||
await tasks.initialCommit(commitHash, projectPath);
|
||||
spinner7.succeed();
|
||||
|
||||
console.log(`\n${chalk.blue.bold(`Created ${name} 🎉`)}\n`);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// tslint:disable:no-expression-statement
|
||||
import { test } from 'ava';
|
||||
import { asyncABC } from './async';
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
*
|
||||
* @returns a Promise which should contain `['a','b','c']`
|
||||
*/
|
||||
export async function asyncABC() {
|
||||
function somethingSlow(index: 0 | 1 | 2) {
|
||||
export async function asyncABC(): Promise<ReadonlyArray<string>> {
|
||||
function somethingSlow(index: 0 | 1 | 2): Promise<string> {
|
||||
const storage = 'abc'.charAt(index);
|
||||
return new Promise<string>(resolve => {
|
||||
// here we pretend to wait on the network
|
||||
setTimeout(() => resolve(storage), 0);
|
||||
});
|
||||
return new Promise<string>(resolve =>
|
||||
// later...
|
||||
resolve(storage)
|
||||
);
|
||||
}
|
||||
const a = await somethingSlow(0);
|
||||
const b = await somethingSlow(1);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// tslint:disable:no-expression-statement no-object-mutation
|
||||
import { Macro, test } from 'ava';
|
||||
import { sha256, sha256Native } from './hash';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import shaJs from 'sha.js';
|
||||
*
|
||||
* @returns sha256 message digest
|
||||
*/
|
||||
export function sha256(message: string) {
|
||||
export function sha256(message: string): string {
|
||||
return shaJs('sha256')
|
||||
.update(message)
|
||||
.digest('hex');
|
||||
@@ -22,9 +22,16 @@ export function sha256(message: string) {
|
||||
/**
|
||||
* A faster implementation of [[sha256]] which requires the native Node.js module. Browser consumers should use [[sha256]], instead.
|
||||
*
|
||||
* ### Example (es imports)
|
||||
* ```js
|
||||
* import { sha256Native as sha256 } from 'typescript-starter'
|
||||
* sha256('test')
|
||||
* // => '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'
|
||||
* ```
|
||||
*
|
||||
* @returns sha256 message digest
|
||||
*/
|
||||
export function sha256Native(message: string) {
|
||||
export function sha256Native(message: string): string {
|
||||
return createHash('sha256')
|
||||
.update(message)
|
||||
.digest('hex');
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// tslint:disable:no-expression-statement
|
||||
import { test } from 'ava';
|
||||
import { double, power } from './number';
|
||||
|
||||
test('double', t => {
|
||||
t.deepEqual(double(2), 4);
|
||||
t.is(double(2), 4);
|
||||
});
|
||||
|
||||
test('power', t => {
|
||||
t.deepEqual(power(2, 4), 16);
|
||||
t.is(power(2, 4), 16);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* @returns Comment describing the return type.
|
||||
* @anotherNote Some other value.
|
||||
*/
|
||||
export function double(value: number) {
|
||||
export function double(value: number): number {
|
||||
return value * 2;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function double(value: number) {
|
||||
* // => 8
|
||||
* ```
|
||||
*/
|
||||
export function power(base: number, exponent: number) {
|
||||
export function power(base: number, exponent: number): number {
|
||||
// This is a proposed es7 operator, which should be transpiled by Typescript
|
||||
return base ** exponent;
|
||||
}
|
||||
|
||||
8
src/types/cli.d.ts
vendored
Normal file
8
src/types/cli.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// We develop typescript-starter with the `strict` compiler option to ensure it
|
||||
// works out of the box for downstream users. This file is deleted by the CLI,
|
||||
// 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 'replace-in-file';
|
||||
34
src/types/example.d.ts
vendored
Normal file
34
src/types/example.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* If you import a dependency which does not include its own type definitions,
|
||||
* TypeScript will try to find a definition for it by following the `typeRoots`
|
||||
* compiler option in tsconfig.json. For this project, we've configured it to
|
||||
* fall back to this folder if nothing is found in node_modules/@types.
|
||||
*
|
||||
* Often, you can install the DefinitelyTyped
|
||||
* (https://github.com/DefinitelyTyped/DefinitelyTyped) type definition for the
|
||||
* dependency in question. However, if no one has yet contributed definitions
|
||||
* for the package, you may want to declare your own. (If you're using the
|
||||
* `noImplicitAny` compiler options, you'll be required to declare it.)
|
||||
*
|
||||
* This is an example type definition for the `sha.js` package, used in hash.ts.
|
||||
*
|
||||
* (This definition was primarily extracted from:
|
||||
* https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/v8/index.d.ts
|
||||
*/
|
||||
declare module 'sha.js' {
|
||||
export default function shaJs(algorithm: string): Hash;
|
||||
|
||||
type Utf8AsciiLatin1Encoding = 'utf8' | 'ascii' | 'latin1';
|
||||
type HexBase64Latin1Encoding = 'latin1' | 'hex' | 'base64';
|
||||
|
||||
export interface Hash extends NodeJS.ReadWriteStream {
|
||||
// tslint:disable:no-method-signature
|
||||
update(
|
||||
data: string | Buffer | DataView,
|
||||
inputEncoding?: Utf8AsciiLatin1Encoding
|
||||
): Hash;
|
||||
digest(): Buffer;
|
||||
digest(encoding: HexBase64Latin1Encoding): string;
|
||||
// tslint:enable:no-method-signature
|
||||
}
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// tslint:disable:no-console
|
||||
import chalk from 'chalk';
|
||||
import spawn from 'cross-spawn';
|
||||
import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
||||
import githubUsername from 'github-username';
|
||||
import gradient from 'gradient-string';
|
||||
import { prompt } from 'inquirer';
|
||||
import ora from 'ora';
|
||||
import { join } from 'path';
|
||||
import replace from 'replace-in-file';
|
||||
import sortedObject from 'sorted-object';
|
||||
import trash from 'trash';
|
||||
|
||||
enum ProjectType {
|
||||
Node,
|
||||
Library
|
||||
}
|
||||
|
||||
enum Runner {
|
||||
Npm,
|
||||
Yarn
|
||||
}
|
||||
|
||||
enum TypeDefinitions {
|
||||
none,
|
||||
Node,
|
||||
DOM,
|
||||
NodeAndDOM
|
||||
}
|
||||
|
||||
const ascii = `
|
||||
_ _ _ _ _
|
||||
| |_ _ _ _ __ ___ ___ ___ _ __(_)_ __ | |_ ___| |_ __ _ _ __| |_ ___ _ __
|
||||
| __| | | | '_ \\ / _ \\/ __|/ __| '__| | '_ \\| __|____/ __| __/ _\` | '__| __/ _ \\ '__|
|
||||
| |_| |_| | |_) | __/\\__ \\ (__| | | | |_) | ||_____\\__ \\ || (_| | | | || __/ |
|
||||
\\__|\\__, | .__/ \\___||___/\\___|_| |_| .__/ \\__| |___/\\__\\__,_|_| \\__\\___|_|
|
||||
|___/|_| |_|
|
||||
`;
|
||||
|
||||
const repo =
|
||||
process.env.TYPESCRIPT_STARTER_REPO_URL ||
|
||||
'https://github.com/bitjson/typescript-starter.git';
|
||||
|
||||
(async () => {
|
||||
if (process.argv.some(a => a === '-v' || a === '--version')) {
|
||||
console.log(
|
||||
JSON.parse(readFileSync(`${__dirname}/../../package.json`, 'utf8'))
|
||||
.version
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.stdout.columns && process.stdout.columns >= 85) {
|
||||
console.log(chalk.bold(gradient.mind(ascii)));
|
||||
} else {
|
||||
console.log(`\n${chalk.cyan.bold.underline('typescript-starter')}\n`);
|
||||
}
|
||||
const { definitions, description, name, runner } = await collectOptions();
|
||||
const commitHash = await cloneRepo(name);
|
||||
const nodeDefinitions = [
|
||||
TypeDefinitions.Node,
|
||||
TypeDefinitions.NodeAndDOM
|
||||
].includes(definitions);
|
||||
const domDefinitions = [
|
||||
TypeDefinitions.DOM,
|
||||
TypeDefinitions.NodeAndDOM
|
||||
].includes(definitions);
|
||||
console.log(`${chalk.dim(`Cloned at commit:${commitHash}`)}\n`);
|
||||
|
||||
const { gitName, gitEmail } = await getUserInfo();
|
||||
const username = await githubUsername(gitEmail).catch(err => {
|
||||
// if username isn't found, just return a placeholder
|
||||
return 'YOUR_USER_NAME';
|
||||
});
|
||||
|
||||
const spinner1 = ora('Updating package.json').start();
|
||||
const projectPath = join(process.cwd(), name);
|
||||
const pkgPath = join(projectPath, 'package.json');
|
||||
const pkg = readPackageJson(pkgPath);
|
||||
pkg.name = name;
|
||||
pkg.version = '1.0.0';
|
||||
pkg.description = description;
|
||||
delete pkg.bin;
|
||||
pkg.repository = `https://github.com/${username}/${name}`;
|
||||
pkg.keywords = [];
|
||||
|
||||
// dependencies to retain for Node.js applications
|
||||
const nodeKeptDeps = ['sha.js'];
|
||||
pkg.dependencies = nodeDefinitions
|
||||
? nodeKeptDeps.reduce((all, dep) => {
|
||||
all[dep] = pkg.dependencies[dep];
|
||||
return all;
|
||||
}, {})
|
||||
: {};
|
||||
|
||||
if (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/')\"`;
|
||||
}
|
||||
|
||||
writePackageJson(pkgPath, pkg);
|
||||
spinner1.succeed();
|
||||
|
||||
const spinner2 = ora('Updating .gitignore').start();
|
||||
if (runner === Runner.Yarn) {
|
||||
await replace({
|
||||
files: join(projectPath, '.gitignore'),
|
||||
from: 'yarn.lock',
|
||||
to: 'package-lock.json'
|
||||
});
|
||||
}
|
||||
spinner2.succeed();
|
||||
|
||||
const spinner3 = ora('Updating .npmignore').start();
|
||||
await replace({
|
||||
files: join(projectPath, '.npmignore'),
|
||||
from: 'examples\n',
|
||||
to: ''
|
||||
});
|
||||
spinner3.succeed();
|
||||
|
||||
const spinner4 = ora('Updating LICENSE').start();
|
||||
await replace({
|
||||
files: join(projectPath, 'LICENSE'),
|
||||
from: 'Jason Dreyzehner',
|
||||
to: gitName
|
||||
});
|
||||
spinner4.succeed();
|
||||
|
||||
const spinner5 = ora('Deleting unnecessary files').start();
|
||||
await trash([
|
||||
join(projectPath, 'examples'),
|
||||
join(projectPath, 'CHANGELOG.md'),
|
||||
join(projectPath, 'README.md'),
|
||||
join(projectPath, 'package-lock.json'),
|
||||
join(projectPath, 'src', 'typescript-starter.ts')
|
||||
]);
|
||||
spinner5.succeed();
|
||||
|
||||
const spinner6 = ora('Updating README.md').start();
|
||||
renameSync(
|
||||
join(projectPath, 'README-starter.md'),
|
||||
join(projectPath, 'README.md')
|
||||
);
|
||||
await replace({
|
||||
files: join(projectPath, 'README.md'),
|
||||
from: 'package-name',
|
||||
to: name
|
||||
});
|
||||
spinner6.succeed();
|
||||
|
||||
if (!domDefinitions) {
|
||||
const spinner6A = ora(`tsconfig: don't include "dom" lib`).start();
|
||||
await replace({
|
||||
files: join(projectPath, 'tsconfig.json'),
|
||||
from: '"lib": ["es2017", "dom"]',
|
||||
to: '"lib": ["es2017"]'
|
||||
});
|
||||
spinner6A.succeed();
|
||||
}
|
||||
|
||||
if (!nodeDefinitions) {
|
||||
const spinner6B = ora(`tsconfig: don't include "node" types`).start();
|
||||
await replace({
|
||||
files: join(projectPath, 'tsconfig.json'),
|
||||
from: '"types": ["node"]',
|
||||
to: '"types": []'
|
||||
});
|
||||
await replace({
|
||||
files: join(projectPath, 'src', 'index.ts'),
|
||||
from: `export * from './lib/hash';\n`,
|
||||
to: ''
|
||||
});
|
||||
await trash([
|
||||
join(projectPath, 'src', 'lib', 'hash.ts'),
|
||||
join(projectPath, 'src', 'lib', 'hash.spec.ts'),
|
||||
join(projectPath, 'src', 'lib', 'async.ts'),
|
||||
join(projectPath, 'src', 'lib', 'async.spec.ts')
|
||||
]);
|
||||
spinner6B.succeed();
|
||||
}
|
||||
|
||||
console.log(`\n${chalk.green.bold('Installing dependencies...')}\n`);
|
||||
await install(runner, projectPath);
|
||||
console.log();
|
||||
|
||||
const spinner7 = ora(`Initializing git repository`).start();
|
||||
await initialCommit(commitHash, projectPath);
|
||||
spinner7.succeed();
|
||||
|
||||
console.log(`\n${chalk.blue.bold(`Created ${name} 🎉`)}\n`);
|
||||
// TODO:
|
||||
// readme: add how to work on this file
|
||||
// `npm link`, `npm run watch`, and in a test directory `TYPESCRIPT_STARTER_REPO_URL='/local/path/to/typescript-starter' typescript-starter`
|
||||
})();
|
||||
|
||||
async function collectOptions() {
|
||||
const packageName = {
|
||||
filter: (answer: string) => answer.trim(),
|
||||
message: 'Enter the new package name:',
|
||||
name: 'name',
|
||||
type: 'input',
|
||||
validate: (answer: string) =>
|
||||
!/^\s*[a-zA-Z]+(-[a-zA-Z]+)*\s*$/.test(answer)
|
||||
? 'Name should be in-kebab-case'
|
||||
: existsSync(answer)
|
||||
? `The ${answer} path already exists in this directory.`
|
||||
: true
|
||||
};
|
||||
|
||||
const node = 'Node.js application';
|
||||
const lib = 'Javascript library';
|
||||
const projectType = {
|
||||
choices: [node, lib],
|
||||
filter: val => (val === node ? ProjectType.Node : ProjectType.Library),
|
||||
message: 'What are you making?',
|
||||
name: 'type',
|
||||
type: 'list'
|
||||
};
|
||||
|
||||
const packageDescription = {
|
||||
filter: answer => answer.trim(),
|
||||
message: 'Enter the package description:',
|
||||
name: 'description',
|
||||
type: 'input',
|
||||
validate: (answer: string) => answer.length > 0
|
||||
};
|
||||
|
||||
const runnerChoices = ['npm', 'yarn'];
|
||||
const runner = {
|
||||
choices: runnerChoices,
|
||||
filter: val => runnerChoices.indexOf(val),
|
||||
message: 'Will this project use npm or yarn?',
|
||||
name: 'runner',
|
||||
type: 'list'
|
||||
};
|
||||
|
||||
const typeDefChoices = [
|
||||
`None — the library won't use any globals or modules from Node.js or the DOM`,
|
||||
`Node.js — parts of the library require access to Node.js globals or built-in modules`,
|
||||
`DOM — parts of the library require access to the Document Object Model (DOM)`,
|
||||
`Both Node.js and DOM — some parts of the library require Node.js, other parts require DOM access`
|
||||
];
|
||||
const typeDefs = {
|
||||
choices: typeDefChoices,
|
||||
filter: val => typeDefChoices.indexOf(val),
|
||||
message: 'Which global type definitions do you want to include?',
|
||||
name: 'definitions',
|
||||
type: 'list',
|
||||
when: answers => answers.type === ProjectType.Library
|
||||
};
|
||||
|
||||
return (prompt([
|
||||
packageName,
|
||||
projectType,
|
||||
packageDescription,
|
||||
runner,
|
||||
typeDefs
|
||||
]) as Promise<{
|
||||
name: string;
|
||||
type: ProjectType;
|
||||
description: string;
|
||||
runner: Runner;
|
||||
definitions?: TypeDefinitions;
|
||||
}>).then(answers => {
|
||||
return {
|
||||
definitions:
|
||||
answers.definitions === undefined
|
||||
? TypeDefinitions.Node
|
||||
: answers.definitions,
|
||||
description: answers.description,
|
||||
name: answers.name,
|
||||
runner: answers.runner,
|
||||
type: answers.type
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function cloneRepo(dir: string) {
|
||||
console.log();
|
||||
const cwd = process.cwd();
|
||||
const projectDir = join(cwd, dir);
|
||||
const gitHistoryDir = join(projectDir, '.git');
|
||||
const clone = spawn.sync('git', ['clone', '--depth=1', repo, dir], {
|
||||
cwd,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
if (clone.error && clone.error.code === 'ENOENT') {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`\nGit is not installed on your PATH. Please install Git and try again.`
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`\nFor more information, visit: ${chalk.bold.underline(
|
||||
'https://git-scm.com/book/en/v2/Getting-Started-Installing-Git'
|
||||
)}\n`
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
} else if (clone.status !== 0) {
|
||||
abort(chalk.red(`Git clone failed. Correct the issue and try again.`));
|
||||
}
|
||||
console.log();
|
||||
const revParse = spawn.sync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', process.stderr]
|
||||
});
|
||||
if (revParse.status !== 0) {
|
||||
abort(chalk.red(`Git rev-parse failed.`));
|
||||
}
|
||||
const commitHash = revParse.stdout.trim();
|
||||
await trash([gitHistoryDir]);
|
||||
return commitHash;
|
||||
}
|
||||
|
||||
async function getUserInfo() {
|
||||
const gitNameProc = spawn.sync('git', ['config', 'user.name'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', process.stderr]
|
||||
});
|
||||
if (gitNameProc.status !== 0) {
|
||||
abort(chalk.red(`Couldn't get name from Git config.`));
|
||||
}
|
||||
const gitName = gitNameProc.stdout.trim();
|
||||
const gitEmailProc = spawn.sync('git', ['config', 'user.email'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', process.stderr]
|
||||
});
|
||||
if (gitEmailProc.status !== 0) {
|
||||
abort(chalk.red(`Couldn't get email from Git config.`));
|
||||
}
|
||||
const gitEmail = gitEmailProc.stdout.trim();
|
||||
return {
|
||||
gitEmail,
|
||||
gitName
|
||||
};
|
||||
}
|
||||
|
||||
function readPackageJson(path: string) {
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
}
|
||||
|
||||
function 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';
|
||||
writeFileSync(path, stringified);
|
||||
}
|
||||
|
||||
async function install(runner: Runner, projectDir: string) {
|
||||
const opts = {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
stdio: ['inherit', 'inherit', process.stderr]
|
||||
};
|
||||
const runnerProc =
|
||||
runner === Runner.Npm
|
||||
? spawn.sync('npm', ['install'], opts)
|
||||
: spawn.sync('yarn', opts);
|
||||
|
||||
if (runnerProc.status !== 0) {
|
||||
abort(chalk.red(`Installation failed. You'll need to install manually.`));
|
||||
}
|
||||
}
|
||||
|
||||
async function initialCommit(hash: string, projectDir: string) {
|
||||
const opts = {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'ignore', process.stderr]
|
||||
};
|
||||
const init = spawn.sync('git', ['init'], opts);
|
||||
if (init.status !== 0) {
|
||||
abort(chalk.red(`Git repo initialization failed.`));
|
||||
}
|
||||
const add = spawn.sync('git', ['add', '-A'], opts);
|
||||
if (add.status !== 0) {
|
||||
abort(chalk.red(`Could not stage initial commit.`));
|
||||
}
|
||||
const commit = spawn.sync(
|
||||
'git',
|
||||
[
|
||||
'commit',
|
||||
'-m',
|
||||
`Initial commit\n\nCreated with typescript-starter@${hash}`
|
||||
],
|
||||
opts
|
||||
);
|
||||
if (commit.status !== 0) {
|
||||
abort(chalk.red(`Initial commit failed.`));
|
||||
}
|
||||
}
|
||||
|
||||
function abort(msg: string) {
|
||||
console.error(`\n${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user