Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add init command #1358

Merged
merged 12 commits into from
Apr 9, 2024
170 changes: 170 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/* eslint-disable unicorn/no-await-expression-member */
import {Errors, Flags} from '@oclif/core'
import chalk from 'chalk'
import {accessSync} from 'node:fs'
import {constants, 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 validModuleTypes = ['ESM', 'CommonJS']
const validPackageManagers = ['npm', 'yarn', 'pnpm']

const FLAGGABLE_PROMPTS = {
bin: {
message: 'Command bin name the CLI will export',
validate: (d: string) => validateBin(d) || 'Invalid bin name',
},
'output-dir': {
message: 'Directory to build the CLI in',
validate(d: string) {
try {
accessSync(resolve(d), constants.X_OK)
return true
} catch {
return false
}
},
},
'package-manager': {
message: 'Select a package manager',
options: validPackageManagers,
validate: (d: string) => validPackageManagers.includes(d) || 'Invalid package manager',
},
} 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 if needed.'

static examples = [
joshcanhelp marked this conversation as resolved.
Show resolved Hide resolved
{
command: '<%= config.bin %> <%= command.id %>',
description: 'Initialize a new CLI in the current directory with auto-detected module type and package manager',
},
{
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 %> --module-type "ESM" --package-manager "npm"',
description: 'Supply answers for specific prompts',
},
]

static flaggablePrompts = FLAGGABLE_PROMPTS

static flags = {
...makeFlags(FLAGGABLE_PROMPTS),
'module-type': Flags.option({
options: validModuleTypes,
})({}),
}

static summary = 'Initialize a new oclif CLI'

async run(): Promise<void> {
const outputDir = await this.getFlagOrPrompt({defaultValue: './', name: 'output-dir', type: 'input'})
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went back and forth on whether this should be a flag or an arg. I landed on flag to better match how generate works but I don't feel strongly either way.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think an output-dir flag or arg is really helpful in this scenario? Wondering if you should just drop it altogether

If you keep it, I like it as a flag

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's good to allow tools like this to operate in other directories, like npm --prefix /path/to/project or git -C /path/to/project

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) || '',
joshcanhelp marked this conversation as resolved.
Show resolved Hide resolved
name: 'bin',
type: 'input',
})

let moduleType = this.flags['module-type']
joshcanhelp marked this conversation as resolved.
Show resolved Hide resolved
if (!moduleType || !validModuleTypes.includes(moduleType)) {
if (packageJSON.type === 'module') {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is about as good as we can do with module auto-detection here. We could parse tsconfig.json (module seemed to be the strongest signal) but I 'm not sure we want to go that far with something that doesn't require TS.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think checking type in package.json is good enough. You could also use get-package-type which we use in @oclif/core. The only benefit to that package is that it caches the result. But probably not worth in this scenario since we're only ever going to check once

moduleType = 'ESM'
} else if (packageJSON.type === 'commonjs') {
moduleType = 'CommonJS'
} else {
moduleType = await (
joshcanhelp marked this conversation as resolved.
Show resolved Hide resolved
await import('@inquirer/select')
).default({
choices: validModuleTypes.map((type) => ({name: type, value: type})),
message: 'Select a module type',
})
}
}

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

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,
)

const updatedPackageJSON = {
...packageJSON,
bin: {
...packageJSON.bin,
[bin]: './bin/run.js',
},
oclif: {
bin,
commands: './dist/commands',
dirname: bin,
...packageJSON.oclif,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have this at the bottom to override anything that the init command is doing. This seems like the right thing to do in an existing project.

},
}

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

const installedDeps = Object.keys(packageJSON.dependencies || {})
if (!installedDeps.includes('@oclif/core')) {
let packageManager = this.flags['package-manager']
if (!packageManager || !validPackageManagers.includes(packageManager)) {
joshcanhelp marked this conversation as resolved.
Show resolved Hide resolved
const rootFiles = await readdir(location)
if (rootFiles.includes('package-lock.json')) {
packageManager = 'npm'
} else if (rootFiles.includes('yarn.lock')) {
packageManager = 'yarn'
} else if (rootFiles.includes('pnpm-lock.yaml')) {
packageManager = 'pnpm'
} else {
packageManager = await (
await import('@inquirer/select')
).default({
choices: validPackageManagers.map((manager) => ({name: manager, value: manager})),
message: 'Select a package manager',
})
}
}

await exec(`${packageManager} ${packageManager === 'npm' ? 'install' : 'add'} @oclif/core`, {
cwd: location,
silent: false,
})
}

this.log(`\nCreated CLI ${chalk.green(bin)}`)
}
}
12 changes: 12 additions & 0 deletions templates/src/init/dev.cmd.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<% switch (moduleType) {
case 'ESM' : _%>
@echo off

node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %*
<%_ break;
case 'CommonJS' : _%>
@echo off

node "%~dp0\dev" %*
<%_ break;
} _%>
17 changes: 17 additions & 0 deletions templates/src/init/dev.js.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<% switch (moduleType) {
case 'ESM' : _%>
#!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning

import {execute} from '@oclif/core'
await execute({development: true, dir: import.meta.url})
<%_ break;
case 'CommonJS' : _%>
#!/usr/bin/env node_modules/.bin/ts-node

// eslint-disable-next-line node/shebang, unicorn/prefer-top-level-await
(async () => {
const oclif = await import('@oclif/core')
await oclif.execute({development: true, dir: __dirname})
})()
<%_ break;
} _%>
3 changes: 3 additions & 0 deletions templates/src/init/run.cmd.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@echo off

node "%~dp0\run" %*
17 changes: 17 additions & 0 deletions templates/src/init/run.js.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<% switch (moduleType) {
case 'ESM' : _%>
#!/usr/bin/env node

import {execute} from '@oclif/core'
await execute({dir: import.meta.url})
<%_ break;
case 'CommonJS' : _%>
#!/usr/bin/env node

// eslint-disable-next-line unicorn/prefer-top-level-await
(async () => {
const oclif = await import('@oclif/core')
await oclif.execute({development: false, dir: __dirname})
})()
<%_ break;
} _%>