Skip to content

Commit

Permalink
feat: no yeoman (#1321)
Browse files Browse the repository at this point in the history
* feat: no yeoman for CLI generator

* feat: no yeoman for command generator

* feat: no yeoman for hook generator

* chore: get rid of yeoman dependency

* chore: add @types/ejs

* fix: standardize help content

* chore: linting errors

* chore: downgrade got because node engine

* test: separate ESM and CJS integration tests

* test: unique test dirs

* chore: set NODE_ENV in development

* chore: code review

---------

Co-authored-by: Cristian Dominguez <cdominguez@salesforce.com>
  • Loading branch information
mdonnalley and cristiand391 committed Mar 19, 2024
1 parent bbdf522 commit 30a8a53
Show file tree
Hide file tree
Showing 31 changed files with 918 additions and 3,024 deletions.
15 changes: 9 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,27 @@
"dependencies": {
"@aws-sdk/client-cloudfront": "^3.535.0",
"@aws-sdk/client-s3": "^3.535.0",
"@inquirer/confirm": "^3.0.0",
"@inquirer/input": "^2.0.0",
"@inquirer/select": "^2.0.0",
"@oclif/core": "^3.21.0",
"@oclif/plugin-help": "^6.0.18",
"@oclif/plugin-not-found": "^3.0.14",
"@oclif/plugin-warn-if-update-available": "^3.0.12",
"async-retry": "^1.3.3",
"chalk": "^4",
"change-case": "^4",
"debug": "^4.3.3",
"ejs": "^3.1.9",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^8.1",
"github-slugger": "^1.5.0",
"got": "^11",
"got": "^13",
"lodash.template": "^4.5.0",
"normalize-package-data": "^3.0.3",
"semver": "^7.6.0",
"sort-package-json": "^2.8.0",
"validate-npm-package-name": "^5.0.0",
"yeoman-environment": "^3.15.1",
"yeoman-generator": "^5.8.0"
"validate-npm-package-name": "^5.0.0"
},
"devDependencies": {
"@commitlint/config-conventional": "^18",
Expand All @@ -37,6 +40,7 @@
"@types/async-retry": "^1.4.5",
"@types/chai": "^4.3.4",
"@types/cli-progress": "^3.11.0",
"@types/ejs": "^3.1.5",
"@types/fs-extra": "^9.0",
"@types/lodash": "^4.14.191",
"@types/lodash.template": "^4.5.0",
Expand All @@ -45,7 +49,6 @@
"@types/semver": "^7.5.8",
"@types/shelljs": "^0.8.11",
"@types/validate-npm-package-name": "^4.0.2",
"@types/yeoman-generator": "^5.2.11",
"chai": "^4.4.1",
"commitlint": "^18",
"eslint": "^8.57.0",
Expand Down Expand Up @@ -129,7 +132,7 @@
"posttest": "yarn run lint",
"prepack": "yarn build && bin/run.js manifest && bin/run.js readme --multi",
"prepare": "husky",
"test:integration:cli": "mocha test/integration/cli.test.ts --timeout 600000",
"test:integration:cli": "mocha test/integration/cli.*.test.ts --timeout 600000 --parallel",
"test:integration:deb": "mocha test/integration/deb.test.ts --timeout 900000",
"test:integration:macos": "mocha test/integration/macos.test.ts --timeout 900000",
"test:integration:publish": "mocha test/integration/publish.test.ts --timeout 900000",
Expand Down
12 changes: 0 additions & 12 deletions src/command-base.ts

This file was deleted.

264 changes: 252 additions & 12 deletions src/commands/generate.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,266 @@
import {Args, Flags} from '@oclif/core'
/* eslint-disable unicorn/no-await-expression-member */
import {Args, Errors, Flags} from '@oclif/core'
import chalk from 'chalk'
import {readFile, rm, writeFile} from 'node:fs/promises'
import {join, resolve, sep} from 'node:path'
import validatePkgName from 'validate-npm-package-name'

import CommandBase from './../command-base'
import {FlaggablePrompt, GeneratorCommand, exec, makeFlags, readPJSON} from '../generator'
import {compact, uniq, validateBin} from '../util'

export default class Generate extends CommandBase {
async function fetchGithubUserFromAPI(): Promise<{login: string; name: string} | undefined> {
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
if (!token) return

const {default: got} = await import('got')
const headers = {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
}

try {
const {login, name} = await got('https://api.github.com/user', {headers}).json<{login: string; name: string}>()
return {login, name}
} catch {}
}

async function fetchGithubUserFromGit(): Promise<string | undefined> {
try {
const result = await exec('git config --get user.name')
return result.stdout.trim()
} catch {}
}

async function fetchGithubUser(): Promise<{login?: string; name: string | undefined} | undefined> {
return (await fetchGithubUserFromAPI()) ?? {name: await fetchGithubUserFromGit()}
}

function determineDefaultAuthor(
user: {login?: string; name: string | undefined} | undefined,
defaultValue: string,
): string {
const {login, name} = user ?? {login: undefined, name: undefined}
if (name && login) return `${name} @${login}`
if (name) return name
if (login) return `@${login}`
return defaultValue
}

async function clone(repo: string, location: string): Promise<void> {
try {
await exec(`git clone https://github.com/oclif/${repo}.git "${location}" --depth=1`)
} catch (error) {
const err =
error instanceof Error
? new Errors.CLIError(error)
: new Errors.CLIError('An error occurred while cloning the template repo')
throw err
}
}

const FLAGGABLE_PROMPTS = {
author: {
message: 'Author',
validate: (d: string) => d.length > 0 || 'Author cannot be empty',
},
bin: {
message: 'Command bin name the CLI will export',
validate: (d: string) => validateBin(d) || 'Invalid bin name',
},
description: {
message: 'Description',
validate: (d: string) => d.length > 0 || 'Description cannot be empty',
},
license: {
message: 'License',
validate: (d: string) => d.length > 0 || 'License cannot be empty',
},
'module-type': {
message: 'Select a module type',
options: ['CommonJS', 'ESM'],
validate: (d: string) => ['CommonJS', 'ESM'].includes(d) || 'Invalid module type',
},
name: {
message: 'NPM package name',
validate: (d: string) => validatePkgName(d).validForNewPackages || 'Invalid package name',
},
owner: {
message: 'Who is the GitHub owner of repository (https://github.com/OWNER/repo)',
validate: (d: string) => d.length > 0 || 'Owner cannot be empty',
},
'package-manager': {
message: 'Select a package manager',
options: ['npm', 'yarn'],
validate: (d: string) => ['npm', 'yarn'].includes(d) || 'Invalid package manager',
},
repository: {
message: 'What is the GitHub name of repository (https://github.com/owner/REPO)',
validate: (d: string) => d.length > 0 || 'Repo cannot be empty',
},
} satisfies Record<string, FlaggablePrompt>

export default class Generate extends GeneratorCommand<typeof Generate> {
static args = {
name: Args.string({description: 'directory name of new project', required: true}),
name: Args.string({description: 'Directory name of new project.', required: true}),
}

static description = `generate a new CLI
This will clone the template repo 'oclif/hello-world' and update package properties`
static description = `This will clone the template repo and update package properties. For CommonJS, the 'oclif/hello-world' template will be used and for ESM, the 'oclif/hello-world-esm' template will be used.`

static examples = [
{
command: '<%= config.bin %> <%= command.id %> my-cli',
description: 'Generate a new CLI with prompts for all properties',
},
{
command: '<%= config.bin %> <%= command.id %> my-cli --yes',
description: 'Automatically accept default values for all prompts',
},
{
command: '<%= config.bin %> <%= command.id %> my-cli --module-type CommonJS --author "John Doe"',
description: 'Supply answers for specific prompts',
},
{
command: '<%= config.bin %> <%= command.id %> my-cli --module-type CommonJS --author "John Doe" --yes',
description: 'Supply answers for specific prompts and accept default values for the rest',
},
]

static flaggablePrompts = FLAGGABLE_PROMPTS

static flags = {
defaults: Flags.boolean({hidden: true}),
...makeFlags(FLAGGABLE_PROMPTS),
'output-dir': Flags.directory({
char: 'd',
description: 'Directory to build the CLI in.',
}),
}

static summary = 'Generate a new CLI'

async run(): Promise<void> {
const {args, flags} = await this.parse(Generate)
const location = this.flags['output-dir'] ? join(this.flags['output-dir'], this.args.name) : resolve(this.args.name)
this.log(`Generating ${this.args.name} in ${chalk.green(location)}`)

await super.generate('cli', {
defaults: flags.defaults,
force: true,
name: args.name,
const moduleType = await this.getFlagOrPrompt({
defaultValue: 'ESM',
name: 'module-type',
type: 'select',
})

const template = moduleType === 'ESM' ? 'hello-world-esm' : 'hello-world'
await clone(template, location)
await rm(join(location, '.git'), {force: true, recursive: true})
// We just cloned the template repo so we're sure it has a package.json
const packageJSON = (await readPJSON(location))!

const githubUser = await fetchGithubUser()

const name = await this.getFlagOrPrompt({defaultValue: this.args.name, name: 'name', type: 'input'})
const bin = await this.getFlagOrPrompt({defaultValue: name, name: 'bin', type: 'input'})
const description = await this.getFlagOrPrompt({
defaultValue: packageJSON.description,
name: 'description',
type: 'input',
})
const author = await this.getFlagOrPrompt({
defaultValue: determineDefaultAuthor(githubUser, packageJSON.author),
name: 'author',
type: 'input',
})

const license = await this.getFlagOrPrompt({
defaultValue: packageJSON.license,
name: 'license',
type: 'input',
})

const owner = await this.getFlagOrPrompt({
defaultValue: githubUser?.login ?? location.split(sep).at(-2) ?? packageJSON.author,
name: 'owner',
type: 'input',
})

const repository = await this.getFlagOrPrompt({
defaultValue: (name ?? packageJSON.repository ?? packageJSON.name).split('/').at(-1) ?? name,
name: 'repository',
type: 'input',
})

const packageManager = await this.getFlagOrPrompt({
defaultValue: 'npm',
name: 'package-manager',
type: 'select',
})

const updatedPackageJSON = {
...packageJSON,
author,
bin: {[bin]: './bin/run.js'},
bugs: `https://github.com/${owner}/${repository}/issues`,
description,
homepage: `https://github.com/${owner}/${repository}`,
license,
name,
oclif: {
...packageJSON.oclif,
bin,
dirname: bin,
},
repository: `${owner}/${repository}`,
}

if (packageManager === 'npm') {
const scripts = (updatedPackageJSON.scripts || {}) as Record<string, string>
updatedPackageJSON.scripts = Object.fromEntries(
Object.entries(scripts).map(([k, v]) => [k, v.replace('yarn', 'npm run')]),
)
}

const {default: sortPackageJson} = await import('sort-package-json')
await writeFile(join(location, 'package.json'), JSON.stringify(sortPackageJson(updatedPackageJSON), null, 2))
await rm(join(location, 'LICENSE'))

const existing = (await readFile(join(location, '.gitignore'), 'utf8')).split('\n')
const updated =
uniq(
compact([
'*-debug.log',
'*-error.log',
'node_modules',
'/tmp',
'/dist',
'/lib',
...(packageManager === 'yarn'
? [
'/package-lock.json',
'.pnp.*',
'.yarn/*',
'!.yarn/patches',
'!.yarn/plugins',
'!.yarn/releases',
'!.yarn/sdks',
'!.yarn/versions',
]
: ['/yarn.lock']),
...existing,
]),
)
.sort()
.join('\n') + '\n'

await writeFile(join(location, '.gitignore'), updated)

await exec(`${packageManager} install`, {cwd: location, silent: false})
await exec(`${join(location, 'node_modules', '.bin', 'oclif')} readme`, {
cwd: location,
// When testing this command in development, you get noisy compilation errors as a result of running
// this in a spawned process. Setting the NODE_ENV to production will silence these warnings. This
// doesn't affect the behavior of the command in production since the NODE_ENV is already set to production
// in that scenario.
env: {...process.env, NODE_ENV: 'production'},
silent: false,
})

this.log(`\nCreated ${chalk.green(name)}`)
}
}

0 comments on commit 30a8a53

Please sign in to comment.