feat(CLI): add support for TYPESCRIPT_STARTER_REPO_BRANCH process.env, default to tag of current rel
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<TypescriptStarterUserOptions>
|
||||
> {
|
||||
export async function checkArgs(): Promise<TypescriptStarterArgsOptions> {
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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<TypescriptStarterUserOptions> {
|
||||
export async function inquire(): Promise<TypescriptStarterCLIOptions> {
|
||||
const packageNameQuestion: Question = {
|
||||
filter: (answer: string) => answer.trim(),
|
||||
message: '📦 Enter the new package name:',
|
||||
|
||||
@@ -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<TypescriptStarterOptions> => {
|
||||
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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
console.log();
|
||||
const { commitHash, gitHistoryDir } = await tasks.cloneRepo(
|
||||
repoURL,
|
||||
repoInfo,
|
||||
workingDirectory,
|
||||
projectName
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user