1
0
mirror of synced 2025-11-08 12:57:47 +00:00

feat(CLI): v2

This commit is contained in:
Jason Dreyzehner
2018-03-10 14:01:34 -05:00
parent 335a988dd8
commit 260a7d37bb
27 changed files with 3967 additions and 1550 deletions

93
src/cli/args.ts Normal file
View 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
View 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
View 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
View 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
View 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
};

View 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`]
]);
});

View 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.");
});

View 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`);
}

View File

@@ -1,3 +1,4 @@
// tslint:disable:no-expression-statement
import { test } from 'ava';
import { asyncABC } from './async';

View File

@@ -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);

View File

@@ -1,3 +1,4 @@
// tslint:disable:no-expression-statement no-object-mutation
import { Macro, test } from 'ava';
import { sha256, sha256Native } from './hash';

View File

@@ -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');

View File

@@ -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);
});

View File

@@ -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
View 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
View 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
}
}

View File

@@ -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);
}