From 24ef06e1fe09d715129ad5be9e07c7dfaf7b4f48 Mon Sep 17 00:00:00 2001 From: Jason Dreyzehner Date: Thu, 15 Mar 2018 21:12:59 -0400 Subject: [PATCH] feat(CLI): add support for TYPESCRIPT_STARTER_REPO_BRANCH process.env, default to tag of current rel --- README.md | 6 +- src/cli/args.ts | 10 ++-- src/cli/cli.ts | 16 +++--- src/cli/inquire.ts | 4 +- src/cli/tasks.ts | 81 +++++++++++++++++++++------ src/cli/tests/cli.integration.spec.ts | 26 +++++---- src/cli/tests/cli.unit.spec.ts | 51 +++++++++++++---- src/cli/typescript-starter.ts | 4 +- src/cli/utils.ts | 31 ++++++++-- 9 files changed, 166 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 4ce7b3e..678603e 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,8 @@ This project is tooled for [conventional changelog](https://github.com/conventio npm run changelog ``` +You may find a tool like [**`wip`**](https://github.com/bitjson/wip) helpful for managing work in progress before you're ready to create a meaningful commit. + ## One-step publish preparation script Bringing together many of the steps above, this repo includes a one-step release preparation command. @@ -330,12 +332,14 @@ cd typescript-starter-testing TYPESCRIPT_STARTER_REPO_URL='/local/path/to/typescript-starter' typescript-starter ``` -You can also `TYPESCRIPT_STARTER_REPO_URL` to any valid Git URL, such as your fork of this repo: +You can also set `TYPESCRIPT_STARTER_REPO_URL` to any valid Git URL, such as your fork of this repo: ``` TYPESCRIPT_STARTER_REPO_URL='https://github.com/YOUR_USERNAME/typescript-starter.git' typescript-starter ``` +If `TYPESCRIPT_STARTER_REPO_BRANCH` is not provided, it will default to `master`. + If you're using [VS Code](https://code.visualstudio.com/), the `Debug CLI` launch configuration also allows you to immediately build and step through execution of the CLI. # In the wild diff --git a/src/cli/args.ts b/src/cli/args.ts index 496290d..052b80c 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -2,11 +2,9 @@ import meow from 'meow'; import { Package, UpdateInfo, UpdateNotifier } from 'update-notifier'; -import { Runner, TypescriptStarterUserOptions, validateName } from './utils'; +import { Runner, TypescriptStarterArgsOptions, validateName } from './utils'; -export async function checkArgs(): Promise< - Partial -> { +export async function checkArgs(): Promise { const cli = meow( ` Usage @@ -90,7 +88,8 @@ export async function checkArgs(): Promise< // note: we always return `install`, so --no-install always works // (important for test performance) return { - install: cli.flags.install + install: cli.flags.install, + starterVersion: cli.pkg.version }; } const validOrMsg = await validateName(input); @@ -106,6 +105,7 @@ export async function checkArgs(): Promise< nodeDefinitions: cli.flags.node, projectName: input, runner: cli.flags.yarn ? Runner.Yarn : Runner.Npm, + starterVersion: cli.pkg.version, strict: cli.flags.strict, vscode: cli.flags.vscode }; diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 06a5b25..34bd46d 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -2,23 +2,23 @@ import chalk from 'chalk'; import { checkArgs } from './args'; import { inquire } from './inquire'; -import { getInferredOptions, LiveTasks } from './tasks'; +import { addInferredOptions, LiveTasks } from './tasks'; import { typescriptStarter } from './typescript-starter'; -import { getIntro, TypescriptStarterUserOptions } from './utils'; +import { getIntro, hasCLIOptions, TypescriptStarterUserOptions } from './utils'; (async () => { - const cliOptions = await checkArgs(); - const userOptions = cliOptions.projectName - ? (cliOptions as TypescriptStarterUserOptions) + const argInfo = await checkArgs(); + const userOptions: TypescriptStarterUserOptions = hasCLIOptions(argInfo) + ? argInfo : { ...(await (async () => { console.log(getIntro(process.stdout.columns)); return inquire(); })()), - ...cliOptions // merge in cliOptions.install + ...argInfo }; - const inferredOptions = await getInferredOptions(); - return typescriptStarter({ ...inferredOptions, ...userOptions }, LiveTasks); + const options = await addInferredOptions(userOptions); + return typescriptStarter(options, LiveTasks); })().catch((err: Error) => { console.error(` ${chalk.red(err.message)} diff --git a/src/cli/inquire.ts b/src/cli/inquire.ts index 4b6e48e..f4b51f5 100644 --- a/src/cli/inquire.ts +++ b/src/cli/inquire.ts @@ -1,7 +1,7 @@ import { prompt, Question } from 'inquirer'; -import { Runner, TypescriptStarterUserOptions, validateName } from './utils'; +import { Runner, TypescriptStarterCLIOptions, validateName } from './utils'; -export async function inquire(): Promise { +export async function inquire(): Promise { const packageNameQuestion: Question = { filter: (answer: string) => answer.trim(), message: '📦 Enter the new package name:', diff --git a/src/cli/tasks.ts b/src/cli/tasks.ts index 3e5ecaf..e14138b 100644 --- a/src/cli/tasks.ts +++ b/src/cli/tasks.ts @@ -2,7 +2,12 @@ import execa, { ExecaStatic, Options, StdIOOption } from 'execa'; import githubUsername from 'github-username'; import { join } from 'path'; -import { Runner, TypescriptStarterInferredOptions } from './utils'; +import { + Runner, + TypescriptStarterInferredOptions, + TypescriptStarterOptions, + TypescriptStarterUserOptions +} from './utils'; // TODO: await https://github.com/DefinitelyTyped/DefinitelyTyped/pull/24209 const inherit = 'inherit' as StdIOOption; @@ -18,14 +23,25 @@ export enum Placeholders { export const cloneRepo = ( spawner: ExecaStatic, suppressOutput = false -) => async (repoURL: string, workingDirectory: string, dir: string) => { +) => async ( + repoInfo: { + readonly branch: string; + readonly repo: string; + }, + workingDirectory: string, + dir: string +) => { const projectDir = join(workingDirectory, dir); const gitHistoryDir = join(projectDir, '.git'); try { - await spawner('git', ['clone', '--depth=1', repoURL, dir], { - cwd: workingDirectory, - stdio: suppressOutput ? 'pipe' : 'inherit' - }); + await spawner( + 'git', + ['clone', '--depth=1', `--branch=${repoInfo.branch}`, repoInfo.repo, dir], + { + cwd: workingDirectory, + stdio: suppressOutput ? 'pipe' : 'inherit' + } + ); } catch (err) { if (err.code === 'ENOENT') { throw new Error(` @@ -97,7 +113,7 @@ export const initialCommit = (spawner: ExecaStatic) => async ( [ 'commit', '-m', - `Initial commit\n\nCreated with typescript-starter@${hash}` + `Initial commit\n\nCreated with bitjson/typescript-starter@${hash}` ], opts ); @@ -121,16 +137,33 @@ export const install = (spawner: ExecaStatic) => async ( } }; -export const getRepoUrl = () => { - return ( - process.env.TYPESCRIPT_STARTER_REPO_URL || - 'https://github.com/bitjson/typescript-starter.git' - ); +/** + * Returns the URL and branch to clone. We clone the branch (tag) at the current + * release rather than `master`. This ensures we get the exact files expected by + * this version of the CLI. (If we cloned master, changes merged to master, but + * not yet released, may cause unexpected results.) + * @param starterVersion the current version of this CLI + */ +export const getRepoInfo = (starterVersion: string) => { + return process.env.TYPESCRIPT_STARTER_REPO_URL + ? { + branch: process.env.TYPESCRIPT_STARTER_REPO_BRANCH + ? process.env.TYPESCRIPT_STARTER_REPO_BRANCH + : 'master', + repo: process.env.TYPESCRIPT_STARTER_REPO_URL + } + : { + branch: `v${starterVersion}`, + repo: 'https://github.com/bitjson/typescript-starter.git' + }; }; export interface Tasks { readonly cloneRepo: ( - repoURL: string, + repoInfo: { + readonly branch: string; + readonly repo: string; + }, workingDirectory: string, dir: string ) => Promise<{ readonly commitHash: string; readonly gitHistoryDir: string }>; @@ -147,16 +180,28 @@ export const LiveTasks: Tasks = { initialCommit: initialCommit(execa), install: install(execa) }; -export const getInferredOptions = async (): Promise< - TypescriptStarterInferredOptions -> => { +export const addInferredOptions = async ( + userOptions: TypescriptStarterUserOptions +): Promise => { const { gitName, gitEmail } = await getUserInfo(execa)(); const username = await getGithubUsername(githubUsername)(gitEmail); - return { + const inferredOptions: TypescriptStarterInferredOptions = { email: gitEmail, fullName: gitName, githubUsername: username, - repoURL: getRepoUrl(), + repoInfo: getRepoInfo(userOptions.starterVersion), workingDirectory: process.cwd() }; + return { + ...inferredOptions, + description: userOptions.description, + domDefinitions: userOptions.domDefinitions, + immutable: userOptions.immutable, + install: userOptions.install, + nodeDefinitions: userOptions.nodeDefinitions, + projectName: userOptions.projectName, + runner: userOptions.runner, + strict: userOptions.strict, + vscode: userOptions.vscode + }; }; diff --git a/src/cli/tests/cli.integration.spec.ts b/src/cli/tests/cli.integration.spec.ts index 3511c7b..a8996b8 100644 --- a/src/cli/tests/cli.integration.spec.ts +++ b/src/cli/tests/cli.integration.spec.ts @@ -33,8 +33,18 @@ import { Runner } from '../utils'; * directory for easier clean up. */ -const repoURL = process.cwd(); +const branch = execa.sync('git', [ + 'rev-parse', + '--symbolic-full-name', + '--abbrev-ref', + 'HEAD' +]).stdout; +const repoInfo = { repo: process.cwd(), branch }; const buildDir = join(process.cwd(), 'build'); +const env = { + TYPESCRIPT_STARTER_REPO_BRANCH: repoInfo.branch, + TYPESCRIPT_STARTER_REPO_URL: repoInfo.repo +}; enum TestDirectories { one = 'test-1', @@ -134,9 +144,7 @@ test(`${ ], { cwd: buildDir, - env: { - TYPESCRIPT_STARTER_REPO_URL: repoURL - } + env } ); t.regex(stdout, new RegExp(`Created ${TestDirectories.one} 🎉`)); @@ -172,9 +180,7 @@ test(`${ ], { cwd: buildDir, - env: { - TYPESCRIPT_STARTER_REPO_URL: repoURL - } + env } ); t.regex(stdout, new RegExp(`Created ${TestDirectories.two} 🎉`)); @@ -210,9 +216,7 @@ async function testInteractive( const typeDefs = entry[3] !== ''; const proc = execa(`../bin/typescript-starter`, ['--no-install'], { cwd: buildDir, - env: { - TYPESCRIPT_STARTER_REPO_URL: repoURL - } + env }); // TODO: missing in Node.js type definition's ChildProcess.stdin? @@ -351,7 +355,7 @@ const sandboxTasks = ( const sandboxOptions = { description: 'this is an example description', githubUsername: 'SOME_GITHUB_USERNAME', - repoURL, + repoInfo, workingDirectory: buildDir }; diff --git a/src/cli/tests/cli.unit.spec.ts b/src/cli/tests/cli.unit.spec.ts index 5a719f7..21fe0c7 100644 --- a/src/cli/tests/cli.unit.spec.ts +++ b/src/cli/tests/cli.unit.spec.ts @@ -7,7 +7,7 @@ import { checkArgs } from '../args'; import { cloneRepo, getGithubUsername, - getRepoUrl, + getRepoInfo, getUserInfo, initialCommit, install, @@ -32,7 +32,7 @@ test('errors if outdated', async t => { t.regex(error.message, /is outdated/); }); -const passUpdateNotifier = (version: string) => { +const pretendLatestVersionIs = (version: string) => { nock.disableNetConnect(); nock('https://registry.npmjs.org:443') .get('/typescript-starter') @@ -50,7 +50,7 @@ const passUpdateNotifier = (version: string) => { test("doesn't error if not outdated", async t => { const currentVersion = meow('').pkg.version; t.truthy(typeof currentVersion === 'string'); - passUpdateNotifier(currentVersion); + pretendLatestVersionIs(currentVersion); await t.notThrows(checkArgs); }); @@ -64,7 +64,7 @@ test('errors if update-notifier fails', async t => { }); test('checkArgs returns the right options', async t => { - passUpdateNotifier('1.0.0'); + pretendLatestVersionIs('1.0.0'); // tslint:disable-next-line:no-object-mutation process.argv = [ 'path/to/node', @@ -80,6 +80,7 @@ test('checkArgs returns the right options', async t => { '--no-vscode' ]; const opts = await checkArgs(); + const currentVersion = meow('').pkg.version; t.deepEqual(opts, { description: '', domDefinitions: true, @@ -88,17 +89,19 @@ test('checkArgs returns the right options', async t => { nodeDefinitions: true, projectName: 'example-project', runner: Runner.Yarn, + starterVersion: currentVersion, strict: true, vscode: false }); }); -test('checkArgs always returns { install } (so --no-install works in interactive mode)', async t => { - passUpdateNotifier('1.0.0'); +test('checkArgs always returns a TypescriptStarterRequiredConfig, even in interactive mode', async t => { + pretendLatestVersionIs('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 }); + t.true(typeof opts.install === 'boolean'); + t.true(typeof opts.starterVersion === 'string'); }); test('only accepts valid package names', async t => { @@ -128,12 +131,16 @@ 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'))('r', 'd', 'p')); + const error = await t.throws( + cloneRepo(mockErr('ENOENT'))({ repo: 'r', branch: 'b' }, '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))('r', 'd', 'p')); + const error = await t.throws( + cloneRepo(mockErr(128))({ repo: 'r', branch: 'b' }, 'd', 'p') + ); t.regex(error.message, /Git clone failed./); }); @@ -144,7 +151,9 @@ test('cloneRepo: throws when rev-parse fails', async t => { calls++; return calls === 1 ? {} : (mockErr(128) as any)(); }) as any) as ExecaStatic; - const error = await t.throws(cloneRepo(mock)('r', 'd', 'p')); + const error = await t.throws( + cloneRepo(mock)({ repo: 'r', branch: 'b' }, 'd', 'p') + ); t.regex(error.message, /Git rev-parse failed./); }); @@ -220,6 +229,24 @@ test('install: throws pretty error on failure', async t => { t.is(error.message, "Installation failed. You'll need to install manually."); }); -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'); +test("getRepoInfo: returns defaults when TYPESCRIPT_STARTER_REPO_URL/BRANCH aren't set", async t => { + const thisRelease = '9000.0.1'; + t.deepEqual(getRepoInfo(thisRelease), { + branch: `v${thisRelease}`, + repo: 'https://github.com/bitjson/typescript-starter.git' + }); + const url = 'https://another/repo'; + // tslint:disable-next-line:no-object-mutation + process.env.TYPESCRIPT_STARTER_REPO_URL = url; + t.deepEqual(getRepoInfo(thisRelease), { + branch: `master`, + repo: url + }); + const branch = 'test'; + // tslint:disable-next-line:no-object-mutation + process.env.TYPESCRIPT_STARTER_REPO_BRANCH = branch; + t.deepEqual(getRepoInfo(thisRelease), { + branch, + repo: url + }); }); diff --git a/src/cli/typescript-starter.ts b/src/cli/typescript-starter.ts index 800a01a..48bede6 100644 --- a/src/cli/typescript-starter.ts +++ b/src/cli/typescript-starter.ts @@ -19,7 +19,7 @@ export async function typescriptStarter( install, nodeDefinitions, projectName, - repoURL, + repoInfo, runner, strict, vscode, @@ -29,7 +29,7 @@ export async function typescriptStarter( ): Promise { console.log(); const { commitHash, gitHistoryDir } = await tasks.cloneRepo( - repoURL, + repoInfo, workingDirectory, projectName ); diff --git a/src/cli/utils.ts b/src/cli/utils.ts index 558cf8f..316d10f 100644 --- a/src/cli/utils.ts +++ b/src/cli/utils.ts @@ -7,7 +7,7 @@ export enum Runner { Yarn = 'yarn' } -export interface TypescriptStarterUserOptions { +export interface TypescriptStarterCLIOptions { readonly description: string; readonly domDefinitions: boolean; readonly immutable: boolean; @@ -19,17 +19,40 @@ export interface TypescriptStarterUserOptions { readonly vscode: boolean; } +export interface TypescriptStarterRequiredConfig { + readonly starterVersion: string; + readonly install: boolean; +} + +export type TypescriptStarterUserOptions = TypescriptStarterCLIOptions & + TypescriptStarterRequiredConfig; + +export type TypescriptStarterArgsOptions = + | TypescriptStarterUserOptions + | TypescriptStarterRequiredConfig; + export interface TypescriptStarterInferredOptions { readonly githubUsername: string; readonly fullName: string; readonly email: string; - readonly repoURL: string; + readonly repoInfo: { + readonly repo: string; + readonly branch: string; + }; readonly workingDirectory: string; } export interface TypescriptStarterOptions - extends TypescriptStarterUserOptions, - TypescriptStarterInferredOptions {} + extends TypescriptStarterCLIOptions, + TypescriptStarterInferredOptions { + // readonly starterVersion?: string; +} + +export function hasCLIOptions( + opts: TypescriptStarterArgsOptions +): opts is TypescriptStarterUserOptions { + return (opts as TypescriptStarterUserOptions).projectName !== undefined; +} export function validateName(input: string): true | string { return !validateNpmPackageName(input).validForNewPackages