diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..df300d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + - run: npm test + - run: npm audit --audit-level=high diff --git a/README.md b/README.md index 86ff921..c0b2c29 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,25 @@ npm install -g startr-cli Just run `create-startr` in the directory you want your project in, then follow the prompts. +### Non-interactive use + +`create-startr` can also be run non-interactively, e.g. from a script or an AI coding agent, by passing all required options as flags: + +```bash +create-startr --project my-project --author "Jane Doe" --email jane@example.com --yes +``` + +| Flag | Description | +| --- | --- | +| `-p, --project ` | Project name (lower case letters, numbers, hyphens) | +| `-a, --author ` | Your name | +| `-e, --email ` | Your email address | +| `-r, --remote ` | Git remote to point the new project at (optional) | +| `-y, --yes` | Skip the confirmation prompt | +| `-h, --help` | Show usage | + +If `--project`, `--author` and `--email` are all provided, the interactive prompts are skipped entirely. In a non-interactive shell (no TTY), all three flags plus `--yes` are required — `create-startr` will exit with an error instead of hanging if any are missing. + ### License startr-cli © 2019 The Globe and Mail. It is free software, and may be redistributed under the terms specified in our MIT license. diff --git a/index.js b/index.js index 431041d..508bbbf 100755 --- a/index.js +++ b/index.js @@ -7,6 +7,8 @@ import { replaceInFile } from 'replace-in-file'; import simpleGit from 'simple-git'; import kleur from 'kleur'; import { fileURLToPath } from 'url'; +import { parseArgs } from 'util'; +import { createRequire } from 'module'; const { bold } = kleur; const projRegex = /[^a-z0-9-]/; @@ -15,51 +17,131 @@ const upstreamRepo = 'https://github.com/globeandmail/startr.git'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const currDir = process.cwd(); +const { version } = createRequire(import.meta.url)('./package.json'); function projectPath(project, ...subpaths) { return path.join(currDir, project, ...subpaths); } -const questions = [ - { - type: 'text', - name: 'project', - message: 'What is your project name?', - validate: value => - !value - ? 'Please enter a project name.' - : projRegex.test(value) - ? 'Lower case letters, numbers and hyphens only, please.' - : true, - }, - { - type: 'text', - name: 'author', - message: 'What is your name?', - initial: 'Firstname Lastname', - validate: value => - !value || value === 'Firstname Lastname' ? 'Please enter a name.' : true, - }, - { - type: 'text', - name: 'email', - message: 'What is your email address?', - validate: value => - !value || !emailRegex.test(value) ? 'Please enter a valid email address.' : true, - }, - { - type: 'text', - name: 'remote', - message: 'What git remote should this point to? (optional)', +function validateProject(value) { + return !value + ? 'Please enter a project name.' + : projRegex.test(value) + ? 'Lower case letters, numbers and hyphens only, please.' + : true; +} + +function validateAuthor(value) { + return !value || value === 'Firstname Lastname' ? 'Please enter a name.' : true; +} + +function validateEmail(value) { + return !value || !emailRegex.test(value) ? 'Please enter a valid email address.' : true; +} + +const usage = `Usage: create-startr [options] + +Options: + -p, --project Project name (lower case letters, numbers, hyphens) + -a, --author Your name + -e, --email Your email address + -r, --remote Git remote to point the new project at (optional) + -y, --yes Skip the confirmation prompt + -h, --help Show this help message + -v, --version Show the installed version + +If --project, --author and --email are all provided, prompts are skipped +entirely. This is the supported way to run create-startr non-interactively +(e.g. from a script or an AI coding agent).`; + +const { values: args } = parseArgs({ + options: { + project: { type: 'string', short: 'p' }, + author: { type: 'string', short: 'a' }, + email: { type: 'string', short: 'e' }, + remote: { type: 'string', short: 'r' }, + yes: { type: 'boolean', short: 'y', default: false }, + help: { type: 'boolean', short: 'h', default: false }, + version: { type: 'boolean', short: 'v', default: false }, }, -]; +}); -console.log(`These prompts will help you scaffold a new startr project.\nPress CTRL + C at any point to quit.\n`); +if (args.help) { + console.log(usage); + process.exit(0); +} + +if (args.version) { + console.log(version); + process.exit(0); +} + +const flagFields = { + project: args.project, + author: args.author, + email: args.email, + remote: args.remote, +}; + +const hasAllRequiredFlags = flagFields.project !== undefined && flagFields.author !== undefined && flagFields.email !== undefined; try { - const response = await prompts(questions); + let response; + + if (hasAllRequiredFlags) { + for (const [validate, value, label] of [ + [validateProject, flagFields.project, 'project'], + [validateAuthor, flagFields.author, 'author'], + [validateEmail, flagFields.email, 'email'], + ]) { + const result = validate(value); + if (result !== true) { + console.error(`Invalid --${label}: ${result}`); + process.exit(1); + } + } + response = { ...flagFields, remote: flagFields.remote ?? '' }; + } else if (!process.stdin.isTTY) { + const missing = ['project', 'author', 'email'].filter(field => flagFields[field] === undefined); + console.error( + `Missing required flag(s) for non-interactive use: ${missing.map(f => `--${f}`).join(', ')}\n\n${usage}` + ); + process.exit(1); + } else { + console.log(`These prompts will help you scaffold a new startr project.\nPress CTRL + C at any point to quit.\n`); + + const questions = [ + { + type: 'text', + name: 'project', + message: 'What is your project name?', + validate: validateProject, + }, + { + type: 'text', + name: 'author', + message: 'What is your name?', + initial: 'Firstname Lastname', + validate: validateAuthor, + }, + { + type: 'text', + name: 'email', + message: 'What is your email address?', + validate: validateEmail, + }, + { + type: 'text', + name: 'remote', + message: 'What git remote should this point to? (optional)', + }, + ].filter(q => flagFields[q.name] === undefined); + + response = { ...flagFields, ...(await prompts(questions)) }; + } + for (const key in response) { - response[key] = response[key].trim(); + response[key] = (response[key] ?? '').trim(); } console.log(`\nAbout to create a startr project with these settings:\n`); @@ -69,13 +151,20 @@ try { if (response.remote) console.log(`${bold('Remote:')} ${response.remote}`); console.log(); - const confirm = await prompts({ - type: 'confirm', - name: 'value', - message: 'Does this look right?', - }); + if (!args.yes) { + if (!process.stdin.isTTY) { + console.error('Refusing to wait for confirmation in non-interactive mode. Pass --yes to proceed.'); + process.exit(1); + } - if (!confirm.value) process.exit(0); + const confirm = await prompts({ + type: 'confirm', + name: 'value', + message: 'Does this look right?', + }); + + if (!confirm.value) process.exit(0); + } console.log('\nCloning project…'); const git = simpleGit(); @@ -121,4 +210,5 @@ try { console.log(`\n✔ The startr project ${bold(response.project)} is ready! 💪`); } catch (err) { console.error(`Uh oh, something went wrong:\n`, err); + process.exit(1); } diff --git a/package.json b/package.json index 84a9b59..3e974a6 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,14 @@ "description": "A CLI to scaffold a startr project.", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test" }, "type": "module", "author": "Tom Cardoso ", "license": "MIT", + "engines": { + "node": ">=22" + }, "dependencies": { "fs-extra": "^11.3.0", "kleur": "^4.1.5", diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..7595c82 --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const cliPath = path.join(__dirname, '..', 'index.js'); +const { version } = createRequire(import.meta.url)('../package.json'); + +function run(args) { + return spawnSync(process.execPath, [cliPath, ...args], { + encoding: 'utf8', + input: '', + }); +} + +test('--help prints usage and exits 0', () => { + const result = run(['--help']); + assert.equal(result.status, 0); + assert.match(result.stdout, /Usage: create-startr/); +}); + +test('--version prints the package version and exits 0', () => { + const result = run(['--version']); + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), version); +}); + +test('non-interactive with no flags fails fast instead of hanging', () => { + const result = run([]); + assert.equal(result.status, 1); + assert.match(result.stderr, /Missing required flag\(s\).*--project.*--author.*--email/s); +}); + +test('non-interactive with a partial flag set reports only what is missing', () => { + const result = run(['--project', 'my-project']); + assert.equal(result.status, 1); + const [firstLine] = result.stderr.split('\n'); + assert.equal(firstLine, 'Missing required flag(s) for non-interactive use: --author, --email'); +}); + +test('rejects an invalid --project value', () => { + const result = run(['--project', 'Bad Name', '--author', 'Jane Doe', '--email', 'jane@example.com', '--yes']); + assert.equal(result.status, 1); + assert.match(result.stderr, /Invalid --project/); +}); + +test('rejects an invalid --author value', () => { + const result = run(['--project', 'my-project', '--author', 'Firstname Lastname', '--email', 'jane@example.com', '--yes']); + assert.equal(result.status, 1); + assert.match(result.stderr, /Invalid --author/); +}); + +test('rejects an invalid --email value', () => { + const result = run(['--project', 'my-project', '--author', 'Jane Doe', '--email', 'not-an-email', '--yes']); + assert.equal(result.status, 1); + assert.match(result.stderr, /Invalid --email/); +}); + +test('valid flags without --yes refuse to hang waiting for confirmation', () => { + const result = run(['--project', 'my-project', '--author', 'Jane Doe', '--email', 'jane@example.com']); + assert.equal(result.status, 1); + assert.match(result.stderr, /Refusing to wait for confirmation/); +});