Skip to content

Commit

Permalink
feat: add init command (#1358) (#1366)
Browse files Browse the repository at this point in the history
* feat: add init command (#1358)

* feat: add init command

* refactor: validation for module types and package managers

* feat: add default comman discovery

* feat: allow existing oclif config to override new

* feat: keep existing bin entries

* feat: command description and examples

* fix: remove colons from prompts

* fix: default bin value to empty string if dir not found

* fix: path permissions to clone repo and ensure bin dir exists

* fix: mkdir recursive to ensure bin directory exists

* fix: update package command in init

* feat: switch repo cloning to ejs template

* feat: tests and ux improvements

* fix: more ux improvements

* fix: return type on getFlagOrPrompt

* chore: apply suggestions from code review

Co-authored-by: Josh Cunningham <josh@joshcanhelp.com>

* feat: add back module-type flag

---------

Co-authored-by: Josh Cunningham <josh@joshcanhelp.com>
  • Loading branch information
mdonnalley and joshcanhelp committed Apr 10, 2024
1 parent c8c9c70 commit c1ece19
Show file tree
Hide file tree
Showing 12 changed files with 517 additions and 33 deletions.
28 changes: 28 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,34 @@ jobs:
OCLIF_INTEGRATION_MODULE_TYPE: ${{ matrix.module_type }}
OCLIF_INTEGRATION_PACKAGE_MANAGER: ${{ matrix.package_manager }}

init-integration:
needs: [linux-unit-tests]
strategy:
matrix:
node_version: [lts/*, latest]
package_manager: [yarn, npm, pnpm]
module_type: [CommonJS, ESM]
os: [ubuntu-latest, windows-latest]
exclude:
- node_version: lts/*
os: windows-latest
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node_version }}
cache: yarn
- if: matrix.package_manager == 'pnpm'
run: 'npm install -g pnpm'
- uses: salesforcecli/github-workflows/.github/actions/yarnInstallWithRetries@main
- run: yarn build
- run: yarn test:integration:init
env:
OCLIF_INTEGRATION_MODULE_TYPE: ${{ matrix.module_type }}
OCLIF_INTEGRATION_PACKAGE_MANAGER: ${{ matrix.package_manager }}

win-build:
needs: [linux-unit-tests]
strategy:
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,10 @@
},
"topics": {
"pack": {
"description": "package an oclif CLI into installable artifacts"
"description": "Package an oclif CLI into installable artifacts."
},
"upload": {
"description": "upload installable CLI artifacts to AWS S3"
"description": "Upload installable CLI artifacts to AWS S3."
}
}
},
Expand All @@ -134,6 +134,7 @@
"prepare": "husky",
"test:integration:cli": "mocha test/integration/cli.test.ts --timeout 600000",
"test:integration:deb": "mocha test/integration/deb.test.ts --timeout 900000",
"test:integration:init": "mocha test/integration/init.test.ts --timeout 600000",
"test:integration:macos": "mocha test/integration/macos.test.ts --timeout 900000",
"test:integration:publish": "mocha test/integration/publish.test.ts --timeout 900000",
"test:integration:sf": "mocha test/integration/sf.test.ts --timeout 600000",
Expand Down
18 changes: 9 additions & 9 deletions src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,41 +61,41 @@ async function clone(repo: string, location: string): Promise<void> {
const FLAGGABLE_PROMPTS = {
author: {
message: 'Author',
validate: (d: string) => d.length > 0 || 'Author cannot be empty',
validate: (d) => d.length > 0 || 'Author cannot be empty',
},
bin: {
message: 'Command bin name the CLI will export',
validate: (d: string) => validateBin(d) || 'Invalid bin name',
validate: (d) => validateBin(d) || 'Invalid bin name',
},
description: {
message: 'Description',
validate: (d: string) => d.length > 0 || 'Description cannot be empty',
validate: (d) => d.length > 0 || 'Description cannot be empty',
},
license: {
message: 'License',
validate: (d: string) => d.length > 0 || 'License cannot be empty',
validate: (d) => 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',
validate: (d) => ['CommonJS', 'ESM'].includes(d) || 'Invalid module type',
},
name: {
message: 'NPM package name',
validate: (d: string) => validatePkgName(d).validForNewPackages || 'Invalid package name',
validate: (d) => 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',
validate: (d) => d.length > 0 || 'Owner cannot be empty',
},
'package-manager': {
message: 'Select a package manager',
options: ['npm', 'yarn', 'pnpm'],
validate: (d: string) => ['npm', 'pnpm', 'yarn'].includes(d) || 'Invalid package manager',
validate: (d) => ['npm', 'pnpm', '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',
validate: (d) => d.length > 0 || 'Repo cannot be empty',
},
} satisfies Record<string, FlaggablePrompt>

Expand Down
202 changes: 202 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import {Errors, Flags} from '@oclif/core'
import chalk from 'chalk'
import {readdir, writeFile} from 'node:fs/promises'
import {join, resolve, sep} from 'node:path'

import {FlaggablePrompt, GeneratorCommand, exec, makeFlags, readPJSON} from '../generator'
import {validateBin} from '../util'

const VALID_MODULE_TYPES = ['ESM', 'CommonJS'] as const
type ModuleType = (typeof VALID_MODULE_TYPES)[number]

const VALID_PACKAGE_MANAGERS = ['npm', 'yarn', 'pnpm'] as const
type PackageManager = (typeof VALID_PACKAGE_MANAGERS)[number]

function isPackageManager(d: string): d is PackageManager {
return VALID_PACKAGE_MANAGERS.includes(d as PackageManager)
}

function isModuleType(d: string): d is ModuleType {
return VALID_MODULE_TYPES.includes(d as ModuleType)
}

const FLAGGABLE_PROMPTS = {
bin: {
message: 'Command bin name the CLI will export',
validate: (d) => validateBin(d) || 'Invalid bin name',
},
'module-type': {
message: 'Select a module type',
options: VALID_MODULE_TYPES,
validate: (d) => isModuleType(d) || 'Invalid module type',
},
'package-manager': {
message: 'Select a package manager',
options: VALID_PACKAGE_MANAGERS,
validate: (d) => isPackageManager(d) || 'Invalid package manager',
},
'topic-separator': {
message: 'Select a topic separator',
options: ['colons', 'spaces'],
validate: (d) => d === 'colons' || d === 'spaces' || 'Invalid topic separator',
},
} satisfies Record<string, FlaggablePrompt>

export default class Generate extends GeneratorCommand<typeof Generate> {
static description =
'This will add the necessary oclif bin files, add oclif config to package.json, and install @oclif/core and ts-node.'

static examples = [
{
command: '<%= config.bin %> <%= command.id %>',
description: 'Initialize a new CLI in the current directory',
},
{
command: '<%= config.bin %> <%= command.id %> --output-dir "/path/to/existing/project"',
description: 'Initialize a new CLI in a different directory',
},
{
command: '<%= config.bin %> <%= command.id %> --topic-separator colons --bin mycli',
description: 'Supply answers for specific prompts',
},
]

static flaggablePrompts = FLAGGABLE_PROMPTS

static flags = {
...makeFlags(FLAGGABLE_PROMPTS),
'output-dir': Flags.directory({
char: 'd',
description: 'Directory to initialize the CLI in.',
exists: true,
}),
yes: Flags.boolean({
aliases: ['defaults'],
char: 'y',
description: 'Use defaults for all prompts. Individual flags will override defaults.',
}),
}

static summary = 'Initialize a new oclif CLI'

async run(): Promise<void> {
const outputDir = this.flags['output-dir'] ?? process.cwd()
const location = resolve(outputDir)

this.log(`Initializing oclif in ${chalk.green(location)}`)

const packageJSON = (await readPJSON(location))!
if (!packageJSON) {
throw new Errors.CLIError(`Could not find a package.json file in ${location}`)
}

const bin = await this.getFlagOrPrompt({
defaultValue: location.split(sep).at(-1) || '',
name: 'bin',
type: 'input',
})

const topicSeparator = await this.getFlagOrPrompt({
defaultValue: 'spaces',
name: 'topic-separator',
type: 'select',
})

const moduleType = await this.getFlagOrPrompt({
defaultValue: packageJSON.type === 'module' ? 'ESM' : 'CommonJS',
async maybeOtherValue() {
return packageJSON.type === 'module' ? 'ESM' : packageJSON.type === 'commonjs' ? 'CommonJS' : undefined
},
name: 'module-type',
type: 'select',
})

const packageManager = await this.getFlagOrPrompt({
defaultValue: 'npm',
async maybeOtherValue() {
const rootFiles = await readdir(location)
if (rootFiles.includes('package-lock.json')) {
return 'npm'
}

if (rootFiles.includes('yarn.lock')) {
return 'yarn'
}

if (rootFiles.includes('pnpm-lock.yaml')) {
return 'pnpm'
}
},
name: 'package-manager',
type: 'select',
})

this.log(`Using module type ${chalk.green(moduleType)}`)
this.log(`Using package manager ${chalk.green(packageManager)}`)

const templateOptions = {moduleType}
const projectBinPath = join(location, 'bin')
await this.template(
join(this.templatesDir, 'src', 'init', 'dev.cmd.ejs'),
join(projectBinPath, 'dev.cmd'),
templateOptions,
)
await this.template(
join(this.templatesDir, 'src', 'init', 'dev.js.ejs'),
join(projectBinPath, 'dev.js'),
templateOptions,
)
await this.template(
join(this.templatesDir, 'src', 'init', 'run.cmd.ejs'),
join(projectBinPath, 'run.cmd'),
templateOptions,
)
await this.template(
join(this.templatesDir, 'src', 'init', 'run.js.ejs'),
join(projectBinPath, 'run.js'),
templateOptions,
)

if (process.platform !== 'win32') {
await exec(`chmod +x ${join(projectBinPath, 'run.js')}`)
await exec(`chmod +x ${join(projectBinPath, 'dev.js')}`)
}

const updatedPackageJSON = {
...packageJSON,
bin: {
...packageJSON.bin,
[bin]: './bin/run.js',
},
oclif: {
bin,
commands: './dist/commands',
dirname: bin,
topicSeparator: topicSeparator === 'colons' ? ':' : ' ',
...packageJSON.oclif,
},
}

await writeFile(join(location, 'package.json'), JSON.stringify(updatedPackageJSON, null, 2))

const installedDeps = Object.keys(packageJSON.dependencies ?? {})
if (!installedDeps.includes('@oclif/core')) {
this.log('Installing @oclif/core')
await exec(`${packageManager} ${packageManager === 'yarn' ? 'add' : 'install'} @oclif/core`, {
cwd: location,
silent: false,
})
}

const allInstalledDeps = [...installedDeps, ...Object.keys(packageJSON.devDependencies ?? {})]
if (!allInstalledDeps.includes('ts-node')) {
this.log('Installing ts-node')
await exec(`${packageManager} ${packageManager === 'yarn' ? 'add --dev' : 'install --save-dev'} ts-node`, {
cwd: location,
silent: false,
})
}

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

0 comments on commit c1ece19

Please sign in to comment.