Skip to content

Commit 277ca54

Browse files
committed
feat: support non-interactive project creation and optional git
1 parent 5e995ce commit 277ca54

4 files changed

Lines changed: 162 additions & 52 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ and optional GitHub Actions deployment. It creates `config.js`, `package.json`,
3535
`.gitignore`, and a local Git repository. Choose a new or empty directory;
3636
existing project files are never overwritten.
3737

38+
For scripts, use `--yes` (`-y`) to skip all prompts. Omitted values default to
39+
the `statusboard` directory, built-in labels, no organizations or repositories,
40+
no GitHub Actions workflow, and Git initialization enabled. Override these with
41+
`--orgs`, `--repositories`, `--labels`, `--github-actions`, and `--no-git`:
42+
43+
```sh
44+
npx @pkgjs/statusboard create my-statusboard --yes --orgs pkgjs,nodejs \
45+
--repositories expressjs/express --labels "help wanted,bug" --github-actions --no-git
46+
```
47+
48+
These options also work interactively: only unspecified settings are prompted.
49+
Use `--labels ""` for no issue labels or `--no-github-actions` to skip the workflow.
50+
3851
Set `GITHUB_TOKEN` in your environment or in a local `.env` file, then run:
3952

4053
```sh

‎lib/cli.js‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,15 @@ module.exports = (create, builder, argv) => {
8585
.command('create [directory]', 'Create a StatusBoard', (cli) => cli.positional('directory', {
8686
description: 'Directory for the new project',
8787
type: 'string'
88+
}).options({
89+
yes: { alias: 'y', type: 'boolean', default: false, description: 'Use defaults without prompting' },
90+
orgs: { type: 'string', description: 'Comma-separated organizations' },
91+
repositories: { type: 'string', description: 'Comma-separated owner/repo names' },
92+
labels: { type: 'string', description: 'Comma-separated issue labels (default: built-in labels)' },
93+
'github-actions': { type: 'boolean', description: 'Generate a GitHub Pages workflow' },
94+
git: { type: 'boolean', default: true, description: 'Initialize Git (disable with --no-git)' }
8895
}), async (argv) => {
89-
await initStatusboard(argv)
96+
await initStatusboard({ ...argv, githubActions: argv['github-actions'] })
9097
})
9198

9299
.command('build', 'Index and build board', SHARED_OPTIONS, async (argv) => {

‎lib/commands/create.js‎

Lines changed: 88 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,61 +6,83 @@ const { gitInit } = require('../git')
66
const packageJson = require('../../package.json')
77

88
async function create (opts = {}) {
9-
const answers = await inquirer.prompt([
10-
{
11-
type: 'input',
12-
name: 'path',
13-
message: 'Where would you like to create your project?',
14-
default: 'statusboard',
15-
when: () => !opts.directory,
16-
validate: value => value.trim().length > 0 || 'Enter a project directory.'
17-
},
18-
{
19-
type: 'confirm',
20-
name: 'defaultLabels',
21-
message: 'Do you want to use the default labels?',
22-
default: true
23-
},
24-
{
25-
type: 'input',
26-
name: 'labels',
27-
message: 'What labels do you want to use (use commas to separate)?',
28-
when: answers => !answers.defaultLabels
29-
},
30-
{
31-
type: 'input',
32-
name: 'orgs',
33-
message: 'What organizations do you want to include (use commas to separate)?'
34-
},
35-
{
36-
type: 'input',
37-
name: 'repositories',
38-
message: 'What repositories do you want to include (owner/repo, separated by commas)?',
39-
validate: value => transformUserInput(value).every(repo => /^[\w.-]+\/[\w.-]+$/.test(repo)) || 'Use owner/repo for each repository.'
40-
},
41-
{
42-
type: 'confirm',
43-
name: 'githubActions',
44-
message: 'Do you want to deploy with GitHub Actions?',
45-
default: false
46-
}
47-
])
9+
let directory = opts.directory
10+
if (directory === undefined) {
11+
directory = opts.yes
12+
? 'statusboard'
13+
: (await inquirer.prompt([{
14+
type: 'input',
15+
name: 'path',
16+
message: 'Where would you like to create your project?',
17+
default: 'statusboard',
18+
validate: async value => {
19+
try {
20+
await validateDirectory(value)
21+
return true
22+
} catch (error) {
23+
return error.message
24+
}
25+
}
26+
}])).path
27+
}
28+
const appPath = await validateDirectory(directory)
29+
if (opts.repositories !== undefined && !validRepositories(opts.repositories)) {
30+
throw new Error('Use owner/repo for each repository.')
31+
}
4832

49-
const directory = (opts.directory || answers.path || '').trim()
50-
if (!directory) throw new Error('Enter a project directory.')
51-
const appPath = path.resolve(directory)
33+
const answers = opts.yes
34+
? {}
35+
: await inquirer.prompt([
36+
{
37+
type: 'confirm',
38+
name: 'defaultLabels',
39+
message: 'Do you want to use the default labels?',
40+
default: true,
41+
when: () => opts.labels === undefined
42+
},
43+
{
44+
type: 'input',
45+
name: 'labels',
46+
message: 'What labels do you want to use (use commas to separate)?',
47+
when: answers => opts.labels === undefined && !answers.defaultLabels
48+
},
49+
{
50+
type: 'input',
51+
name: 'orgs',
52+
message: 'What organizations do you want to include (use commas to separate)?',
53+
when: () => opts.orgs === undefined
54+
},
55+
{
56+
type: 'input',
57+
name: 'repositories',
58+
message: 'What repositories do you want to include (owner/repo, separated by commas)?',
59+
when: () => opts.repositories === undefined,
60+
validate: value => validRepositories(value) || 'Use owner/repo for each repository.'
61+
},
62+
{
63+
type: 'confirm',
64+
name: 'githubActions',
65+
message: 'Do you want to deploy with GitHub Actions?',
66+
default: false,
67+
when: () => opts.githubActions === undefined
68+
}
69+
])
70+
71+
const repositories = opts.repositories ?? answers.repositories
72+
if (!validRepositories(repositories)) throw new Error('Use owner/repo for each repository.')
73+
// Recheck after prompting in case the destination changed while waiting.
74+
await validateDirectory(appPath)
5275
await fs.mkdir(appPath, { recursive: true })
53-
if ((await fs.readdir(appPath)).length) {
54-
throw new Error(`Directory is not empty: ${appPath}`)
55-
}
5676

5777
const config = {
5878
path: appPath,
5979
name: path.basename(appPath),
60-
labels: answers.defaultLabels ? undefined : transformUserInput(answers.labels),
61-
orgs: transformUserInput(answers.orgs),
62-
projects: transformUserInput(answers.repositories),
63-
githubActions: answers.githubActions
80+
labels: opts.labels !== undefined
81+
? transformUserInput(opts.labels)
82+
: answers.defaultLabels === false ? transformUserInput(answers.labels) : undefined,
83+
orgs: transformUserInput(opts.orgs ?? answers.orgs),
84+
projects: transformUserInput(repositories),
85+
githubActions: opts.githubActions ?? answers.githubActions ?? false
6486
}
6587

6688
if (config.githubActions) await createBuildAction(config)
@@ -86,7 +108,7 @@ async function create (opts = {}) {
86108
}
87109
await fs.writeFile(path.join(appPath, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' })
88110

89-
if (!gitInit(appPath)) {
111+
if (opts.git !== false && !gitInit(appPath)) {
90112
console.warn('Could not create the initial Git commit. Project files are ready; check Git installation and user configuration.')
91113
}
92114
console.log(`Created Statusboard in ${appPath}. Run npm install there, set GITHUB_TOKEN, then run npm run build.`)
@@ -95,6 +117,21 @@ async function create (opts = {}) {
95117
}
96118
}
97119

120+
async function validateDirectory (directory) {
121+
if (typeof directory !== 'string' || !directory.trim()) throw new Error('Enter a project directory.')
122+
const appPath = path.resolve(directory.trim())
123+
try {
124+
if ((await fs.readdir(appPath)).length) throw new Error(`Directory is not empty: ${appPath}`)
125+
} catch (error) {
126+
if (error.code !== 'ENOENT') throw error
127+
}
128+
return appPath
129+
}
130+
131+
function validRepositories (input) {
132+
return transformUserInput(input).every(repo => /^[\w.-]+\/[\w.-]+$/.test(repo))
133+
}
134+
98135
async function createConfigFile (config) {
99136
const fields = {
100137
...(config.labels === undefined ? {} : { issueLabels: config.labels }),
@@ -114,7 +151,7 @@ async function createConfigFile (config) {
114151
}
115152

116153
function transformUserInput (input = '') {
117-
return input.split(',').map(value => value.trim()).filter(Boolean)
154+
return [input].flat().flatMap(value => value.split(',')).map(value => value.trim()).filter(Boolean)
118155
}
119156

120157
async function createBuildAction (config) {

‎test/create.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ suite('Create command', () => {
8686

8787
test('preserves an existing nonempty directory', async () => {
8888
await fs.writeFile(path.join(directory, 'config.js'), 'original')
89+
inquirer.prompt = async () => { throw new Error('Must reject the destination before prompting') }
8990
await assert.rejects(create({ directory }), /Directory is not empty/)
9091
assert.equal(await fs.readFile(path.join(directory, 'config.js'), 'utf8'), 'original')
9192
assert.deepEqual(await fs.readdir(directory), ['config.js'])
@@ -96,6 +97,58 @@ suite('Create command', () => {
9697
await assert.rejects(create(), /Enter a project directory/)
9798
})
9899

100+
test('creates without prompts using CLI settings and --no-git', async () => {
101+
inquirer.prompt = async () => { throw new Error('Must not prompt with --yes') }
102+
const app = path.join(directory, 'automated')
103+
await cli(() => {}).parseAsync([
104+
'create', app, '--yes', '--no-git', '--orgs', 'pkgjs,nodejs',
105+
'--repositories', 'expressjs/express, pkgjs/statusboard',
106+
'--labels', 'bug, help wanted', '--github-actions'
107+
])
108+
const config = require(path.join(app, 'config.js'))
109+
assert.deepEqual(config.orgs, ['pkgjs', 'nodejs'])
110+
assert.deepEqual(config.projects, ['expressjs/express', 'pkgjs/statusboard'])
111+
assert.deepEqual(config.issueLabels, ['bug', 'help wanted'])
112+
assert.ok((await fs.stat(path.join(app, '.github', 'workflows', 'build.yml'))).isFile())
113+
await assert.rejects(fs.stat(path.join(app, '.git')), { code: 'ENOENT' })
114+
})
115+
116+
test('uses default values without a terminal or directory argument', async () => {
117+
execFileSync(process.execPath, [require.resolve('../bin/statusboard'), 'create', '-y', '--no-git'], {
118+
cwd: directory,
119+
stdio: ['ignore', 'pipe', 'pipe'],
120+
timeout: 10000
121+
})
122+
const app = path.join(directory, 'statusboard')
123+
const config = require(path.join(app, 'config.js'))
124+
assert.deepEqual(config.orgs, [])
125+
assert.deepEqual(config.projects, [])
126+
assert.equal(Object.hasOwn(config, 'issueLabels'), false)
127+
await assert.rejects(fs.stat(path.join(app, '.github')), { code: 'ENOENT' })
128+
await assert.rejects(fs.stat(path.join(app, '.git')), { code: 'ENOENT' })
129+
})
130+
131+
test('prompts only for missing interactive settings and preserves explicit empty labels', async () => {
132+
inquirer.prompt = async questions => {
133+
const asked = questions.filter(question => !question.when || question.when({})).map(question => question.name)
134+
assert.deepEqual(asked, ['repositories'])
135+
return { repositories: 'pkgjs/statusboard' }
136+
}
137+
const app = path.join(directory, 'interactive')
138+
await cli(() => {}).parseAsync(['create', app, '--no-git', '--orgs', 'pkgjs', '--labels', '', '--no-github-actions'])
139+
const config = require(path.join(app, 'config.js'))
140+
assert.deepEqual(config.issueLabels, [])
141+
assert.deepEqual(config.projects, ['pkgjs/statusboard'])
142+
await assert.rejects(fs.stat(path.join(app, '.github')), { code: 'ENOENT' })
143+
})
144+
145+
test('rejects invalid repository options before prompting or creating files', async () => {
146+
inquirer.prompt = async () => { throw new Error('Must validate supplied options first') }
147+
const app = path.join(directory, 'invalid')
148+
await assert.rejects(create({ directory: app, repositories: 'missing-owner' }), /Use owner\/repo/)
149+
await assert.rejects(fs.stat(app), { code: 'ENOENT' })
150+
})
151+
99152
test('keeps generated files and the repository if the initial commit fails', async () => {
100153
process.env.GIT_AUTHOR_NAME = ''
101154
const app = path.join(directory, 'no-identity')

0 commit comments

Comments
 (0)