Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` | Project name (lower case letters, numbers, hyphens) |
| `-a, --author <name>` | Your name |
| `-e, --email <email>` | Your email address |
| `-r, --remote <url>` | 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.
Expand Down
172 changes: 131 additions & 41 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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-]/;
Expand All @@ -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 <name> Project name (lower case letters, numbers, hyphens)
-a, --author <name> Your name
-e, --email <email> Your email address
-r, --remote <url> 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`);
Expand All @@ -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();
Expand Down Expand Up @@ -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);
}
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tcardoso@globeandmail.com>",
"license": "MIT",
"engines": {
"node": ">=22"
},
"dependencies": {
"fs-extra": "^11.3.0",
"kleur": "^4.1.5",
Expand Down
66 changes: 66 additions & 0 deletions test/cli.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading