From 3bf783d813afb3f993fd5acd737b4acad3c81db5 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 29 Nov 2024 09:56:29 -0500 Subject: [PATCH 01/12] init work for create command --- lib/cli.js | 5 ++-- lib/commands/create.js | 54 ++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 lib/commands/create.js diff --git a/lib/cli.js b/lib/cli.js index 1b53b26..579ce44 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -2,6 +2,7 @@ const yargs = require('yargs/yargs') const loadEnv = require('./load-env') const path = require('path') +const initStatusboard = require('./commands/create') const SHARED_OPTIONS = { db: { @@ -81,8 +82,8 @@ module.exports = (create, builder, argv) => { let cli = yargs() - .command('create', 'Create a StatusBoard', {}, async (argv) => { - console.log('Coming soon!') + .command('create [directory]', 'Create a StatusBoard', {}, async (argv) => { + await initStatusboard(argv) }) .command('build', 'Index and build board', SHARED_OPTIONS, async (argv) => { diff --git a/lib/commands/create.js b/lib/commands/create.js new file mode 100644 index 0000000..6f726ab --- /dev/null +++ b/lib/commands/create.js @@ -0,0 +1,54 @@ +const inquirer = require('inquirer') +const path = require('node:path') +const createPackageJson = require('create-package-json') + +async function create (opts) { + const { directory } = opts + let projectPath = directory + + if (!projectPath) { + const res = await inquirer.prompt([ + { + type: 'input', + name: 'path', + message: 'Where would you like to create your project?', + default: 'statusboard' + } + ]) + + projectPath = res.path.trim() + } + + const appPath = path.resolve(projectPath) + let projectName = path.basename(appPath) + + const pkg = await createPackageJson( + { + name: projectName, + description: '"A dashboard for project status', + version: '1.0.0', + dependencies: ['@pkgjs/statusboard'], + author: null, + keywords: null, + repository: null, + type: 'commonjs', + private: true, + cwd: appPath, + license: 'MIT', + main: 'config.js', + devDependencies: null, + scrips: { + build: 'statusboard build -C ./config', + buildsite: 'npm run clean && statusboard site -C ./config', + buildindex: 'statusboard index -C ./config', + clean: 'rm -rf build/css build/js' + } + }, + {} + ) + + console.log(pkg) + console.log(projectPath, projectName) +} + +module.exports = create diff --git a/package.json b/package.json index e870413..49cb365 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@wesleytodd/buildjs": "0.0.8", "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", "cptmpl": "0.0.4", + "create-package-json": "^1.1.0", "es5-lit-element": "^2.2.1", "es5-lit-html": "^1.1.1", "express": "^5.2.1", From dba3d3156236291a6fab5c6ee7a56b71151b5f64 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 29 Nov 2024 10:42:02 -0500 Subject: [PATCH 02/12] feat: add prompts --- lib/commands/create.js | 113 +++++++++++++++++++++++++++-------------- 1 file changed, 74 insertions(+), 39 deletions(-) diff --git a/lib/commands/create.js b/lib/commands/create.js index 6f726ab..879f2a2 100644 --- a/lib/commands/create.js +++ b/lib/commands/create.js @@ -4,51 +4,86 @@ const createPackageJson = require('create-package-json') async function create (opts) { const { directory } = opts - let projectPath = directory - - if (!projectPath) { - const res = await inquirer.prompt([ - { - type: 'input', - name: 'path', - message: 'Where would you like to create your project?', - default: 'statusboard' - } - ]) - - projectPath = res.path.trim() - } - const appPath = path.resolve(projectPath) - let projectName = path.basename(appPath) + const config = { + path: directory, + name: undefined, + labels: [], + orgs: [], + projects: [], + githubActions: false + } - const pkg = await createPackageJson( + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'path', + message: 'Where would you like to create your project?', + default: 'statusboard', + when: () => !config.path + }, + { + type: 'confirm', + name: 'defaultLabels', + message: 'Do you want use default labels?', + default: true + }, + { + type: 'input', + name: 'labels', + message: 'What labels do you want to use (use commas to separate)', + when: (answers) => !answers.defaultLabels + }, + { + type: 'input', + name: 'orgs', + message: 'What organizations do you want to include (use commas to separate)' + }, { - name: projectName, - description: '"A dashboard for project status', - version: '1.0.0', - dependencies: ['@pkgjs/statusboard'], - author: null, - keywords: null, - repository: null, - type: 'commonjs', - private: true, - cwd: appPath, - license: 'MIT', - main: 'config.js', - devDependencies: null, - scrips: { - build: 'statusboard build -C ./config', - buildsite: 'npm run clean && statusboard site -C ./config', - buildindex: 'statusboard index -C ./config', - clean: 'rm -rf build/css build/js' - } + type: 'input', + name: 'repositories', + message: 'What repositories do you want to include (use commas to separate)' }, - {} - ) + { + type: 'confirm', + name: 'githubActions', + message: 'Do you want to use GitHub Pages?', + default: false + } + ]) + + config.path = answers.path.trim() + config.labels = answers.labels.split(',').map((l) => l.trim()) + config.orgs = answers.orgs.split(',').map((l) => l.trim()) + config.projects = answers.repositories.split(',').map((l) => l.trim()) + config.githubActions = answers.githubActions + + const appPath = path.resolve(config.path) + config.name = path.basename(appPath) + + const pkg = await createPackageJson({ + cwd: appPath, + name: config.name, + description: 'A dashboard for project status', + version: '1.0.0', + dependencies: ['@pkgjs/statusboard'], + author: null, + keywords: null, + repository: null, + type: 'commonjs', + private: true, + license: 'MIT', + main: 'config.js', + devDependencies: null, + scrips: { + build: 'statusboard build -C ./config', + buildsite: 'npm run clean && statusboard site -C ./config', + buildindex: 'statusboard index -C ./config', + clean: 'rm -rf build/css build/js' + } + }) console.log(pkg) - console.log(projectPath, projectName) } module.exports = create From 6264cb0bfe89f7836cdc87dcc4e47fb91378776b Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 29 Nov 2024 12:20:54 -0500 Subject: [PATCH 03/12] feat: create directory --- lib/commands/create.js | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/lib/commands/create.js b/lib/commands/create.js index 879f2a2..ac145c3 100644 --- a/lib/commands/create.js +++ b/lib/commands/create.js @@ -1,6 +1,7 @@ const inquirer = require('inquirer') const path = require('node:path') const createPackageJson = require('create-package-json') +const fs = require('fs-extra') async function create (opts) { const { directory } = opts @@ -53,37 +54,37 @@ async function create (opts) { ]) config.path = answers.path.trim() - config.labels = answers.labels.split(',').map((l) => l.trim()) - config.orgs = answers.orgs.split(',').map((l) => l.trim()) - config.projects = answers.repositories.split(',').map((l) => l.trim()) + config.labels = answers.labels?.split(',').map((l) => l.trim()) || [] + config.orgs = answers.orgs?.split(',').map((l) => l.trim()) || [] + config.projects = answers.repositories?.split(',').map((l) => l.trim()) || [] config.githubActions = answers.githubActions const appPath = path.resolve(config.path) config.name = path.basename(appPath) - const pkg = await createPackageJson({ + await fs.ensureDir(appPath) + + await createPackageJson({ cwd: appPath, name: config.name, description: 'A dashboard for project status', version: '1.0.0', dependencies: ['@pkgjs/statusboard'], author: null, - keywords: null, + keywords: ["statusboard", "projects"], repository: null, type: 'commonjs', private: true, license: 'MIT', main: 'config.js', devDependencies: null, - scrips: { - build: 'statusboard build -C ./config', - buildsite: 'npm run clean && statusboard site -C ./config', - buildindex: 'statusboard index -C ./config', + scripts: { + build: 'statusboard build -C ./config.js', + buildsite: 'npm run clean && statusboard site -C ./config.js', + buildindex: 'statusboard index -C ./config.js', clean: 'rm -rf build/css build/js' } }) - - console.log(pkg) } module.exports = create From 5daa007a3e0763514d1015d996004d8ccd9b7366 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 29 Nov 2024 19:56:13 -0500 Subject: [PATCH 04/12] feat: create config and git repository --- lib/commands/create.js | 22 +++++++++++++++++++++- lib/git.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 lib/git.js diff --git a/lib/commands/create.js b/lib/commands/create.js index ac145c3..a25b83a 100644 --- a/lib/commands/create.js +++ b/lib/commands/create.js @@ -2,6 +2,7 @@ const inquirer = require('inquirer') const path = require('node:path') const createPackageJson = require('create-package-json') const fs = require('fs-extra') +const { gitInit } = require('../git.js') async function create (opts) { const { directory } = opts @@ -48,7 +49,7 @@ async function create (opts) { { type: 'confirm', name: 'githubActions', - message: 'Do you want to use GitHub Pages?', + message: 'Do you want deploy with GitHub Actions?', default: false } ]) @@ -85,6 +86,25 @@ async function create (opts) { clean: 'rm -rf build/css build/js' } }) + + await createConfigFile(config) + + gitInit(appPath) +} + +async function createConfigFile (config) { + const configFile = path.join(config.path, 'config.js') + + const configJs = `module.exports = {${config.labels.length >= 1 ? `\nissueLabels: ${JSON.stringify(config.labels)},`: ''} + orgs: ${JSON.stringify(config.orgs)}, + projects: ${JSON.stringify(config.projects)}, + github: { + token: process.env.GITHUB_TOKEN + } +} +` + + await fs.writeFile(configFile, configJs) } module.exports = create diff --git a/lib/git.js b/lib/git.js new file mode 100644 index 0000000..e82da88 --- /dev/null +++ b/lib/git.js @@ -0,0 +1,39 @@ +const { execSync } = require("child_process"); +const { rm } = require("fs"); + +function isGitRepo() { + try { + execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }); + return true; + } catch {} + return false; +} + +module.exports.gitInit = function gitInit(path) { + let didInit = false; + + try { + if(isGitRepo()) { + return false; + } + + execSync("git init", { stdio: "ignore" }); + + didInit = true; + + execSync("git checkout -b main", { stdio: "ignore" }); + execSync("git add -A", { stdio: "ignore" }); + execSync('git commit -m "Initial commit from @pkgjs/statusboard"', { + stdio: "ignore", + }); + + return true; + } catch (e) { + if (didInit) { + try { + rm(join(path, ".git"), { recursive: true, force: true }); + } catch (_) {} + } + return false; + } +} From cf7c115e40008db21bac6df43eadf0c381ac1d27 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 29 Nov 2024 20:50:44 -0500 Subject: [PATCH 05/12] feat: validate user input --- lib/commands/create.js | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/lib/commands/create.js b/lib/commands/create.js index a25b83a..8302351 100644 --- a/lib/commands/create.js +++ b/lib/commands/create.js @@ -34,17 +34,22 @@ async function create (opts) { type: 'input', name: 'labels', message: 'What labels do you want to use (use commas to separate)', - when: (answers) => !answers.defaultLabels + when: (answers) => !answers.defaultLabels, + validate: validateUserInput }, { type: 'input', name: 'orgs', - message: 'What organizations do you want to include (use commas to separate)' + message: + 'What organizations do you want to include (use commas to separate)', + validate: validateUserInput }, { type: 'input', name: 'repositories', - message: 'What repositories do you want to include (use commas to separate)' + message: + 'What repositories do you want to include (use commas to separate)', + validate: validateUserInput }, { type: 'confirm', @@ -56,13 +61,14 @@ async function create (opts) { config.path = answers.path.trim() config.labels = answers.labels?.split(',').map((l) => l.trim()) || [] - config.orgs = answers.orgs?.split(',').map((l) => l.trim()) || [] - config.projects = answers.repositories?.split(',').map((l) => l.trim()) || [] + config.orgs = answers.orgs?.split(',').map((l) => l.trim()) + config.projects = answers.repositories?.split(',').map((l) => l.trim()) config.githubActions = answers.githubActions const appPath = path.resolve(config.path) config.name = path.basename(appPath) - + config.path = appPath +console.log(config) await fs.ensureDir(appPath) await createPackageJson({ @@ -72,7 +78,7 @@ async function create (opts) { version: '1.0.0', dependencies: ['@pkgjs/statusboard'], author: null, - keywords: ["statusboard", "projects"], + keywords: ['statusboard', 'projects'], repository: null, type: 'commonjs', private: true, @@ -95,9 +101,15 @@ async function create (opts) { async function createConfigFile (config) { const configFile = path.join(config.path, 'config.js') - const configJs = `module.exports = {${config.labels.length >= 1 ? `\nissueLabels: ${JSON.stringify(config.labels)},`: ''} - orgs: ${JSON.stringify(config.orgs)}, - projects: ${JSON.stringify(config.projects)}, + const configJs = `module.exports = {${ + config.labels.length >= 1 + ? `\n issueLabels: ${JSON.stringify(config.labels)},` + : '' + }${config.orgs.length >= 1 ? `\n orgs: ${JSON.stringify(config.orgs)},` : ''}${ + config.projects.length >= 1 + ? `\n projects: ${JSON.stringify(config.projects)},` + : '' + } github: { token: process.env.GITHUB_TOKEN } @@ -107,4 +119,8 @@ async function createConfigFile (config) { await fs.writeFile(configFile, configJs) } +function validateUserInput(input) { + return input.trim().length > 0 +} + module.exports = create From a2a55644ed4b70eeb0502f14419ba3228dc8a5e7 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 29 Nov 2024 21:00:38 -0500 Subject: [PATCH 06/12] feat: transform input --- lib/commands/create.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/commands/create.js b/lib/commands/create.js index 8302351..c61fae8 100644 --- a/lib/commands/create.js +++ b/lib/commands/create.js @@ -34,22 +34,19 @@ async function create (opts) { type: 'input', name: 'labels', message: 'What labels do you want to use (use commas to separate)', - when: (answers) => !answers.defaultLabels, - validate: validateUserInput + when: (answers) => !answers.defaultLabels }, { type: 'input', name: 'orgs', message: - 'What organizations do you want to include (use commas to separate)', - validate: validateUserInput + 'What organizations do you want to include (use commas to separate)' }, { type: 'input', name: 'repositories', message: - 'What repositories do you want to include (use commas to separate)', - validate: validateUserInput + 'What repositories do you want to include (use commas to separate)' }, { type: 'confirm', @@ -60,15 +57,16 @@ async function create (opts) { ]) config.path = answers.path.trim() - config.labels = answers.labels?.split(',').map((l) => l.trim()) || [] - config.orgs = answers.orgs?.split(',').map((l) => l.trim()) - config.projects = answers.repositories?.split(',').map((l) => l.trim()) + + config.labels = transformUserInput(answers.labels) + config.orgs = transformUserInput(answers.orgs) + config.projects = transformUserInput(answers.repositories) config.githubActions = answers.githubActions const appPath = path.resolve(config.path) config.name = path.basename(appPath) config.path = appPath -console.log(config) + await fs.ensureDir(appPath) await createPackageJson({ @@ -119,8 +117,12 @@ async function createConfigFile (config) { await fs.writeFile(configFile, configJs) } -function validateUserInput(input) { - return input.trim().length > 0 +function transformUserInput(input) { + if (input?.length === 0) { + return [] + } + + return input?.split(",").map((l) => l.trim()) || [] } module.exports = create From cd45fd5104beff633eef77c0cc3d10d4576e7495 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 29 Nov 2024 21:24:42 -0500 Subject: [PATCH 07/12] feat: create build workflow --- lib/commands/create.js | 74 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/lib/commands/create.js b/lib/commands/create.js index c61fae8..91f5aaf 100644 --- a/lib/commands/create.js +++ b/lib/commands/create.js @@ -68,6 +68,12 @@ async function create (opts) { config.path = appPath await fs.ensureDir(appPath) + + if (config.githubActions) { + createBuildAction(config) + } + + await createConfigFile(config) await createPackageJson({ cwd: appPath, @@ -91,8 +97,6 @@ async function create (opts) { } }) - await createConfigFile(config) - gitInit(appPath) } @@ -107,7 +111,7 @@ async function createConfigFile (config) { config.projects.length >= 1 ? `\n projects: ${JSON.stringify(config.projects)},` : '' - } + }${config.githubActions ? `\n baseUrl: "/${config.name}",` : ''} github: { token: process.env.GITHUB_TOKEN } @@ -125,4 +129,68 @@ function transformUserInput(input) { return input?.split(",").map((l) => l.trim()) || [] } +function createBuildAction(config) { + const workflowDir = path.join(config.path, '.github', 'workflows') + fs.ensureDirSync(workflowDir) + const configFile = path.join(workflowDir, 'build.yml') + + const buildAction = `name: Generate Statusboard + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + index: + environment: + name: github-pages + url: \${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - uses: actions/cache@v4 + id: cache + with: + path: | + ~/.npm + ~/.cache + ./dist + ./node_modules + key: \${{ runner.os }}-build-\${{ github.sha }} + - if: steps.cache.outputs.cache-hit != 'true' + run: npm install + shell: bash + - run: npm run build + env: + GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }} + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './build' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 +` + + fs.writeFileSync(configFile, buildAction) +} + module.exports = create From ccdc11fd3b95e6026e19d348fd1a7518998c7e72 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 29 Nov 2024 21:43:31 -0500 Subject: [PATCH 08/12] feat: copy of template --- lib/commands/create.js | 69 ++++++-------------------------- lib/commands/template/.gitignore | 11 +++++ lib/commands/template/build.yml | 54 +++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 57 deletions(-) create mode 100644 lib/commands/template/.gitignore create mode 100644 lib/commands/template/build.yml diff --git a/lib/commands/create.js b/lib/commands/create.js index 91f5aaf..3e56683 100644 --- a/lib/commands/create.js +++ b/lib/commands/create.js @@ -74,6 +74,7 @@ async function create (opts) { } await createConfigFile(config) + createGitIgnore(config) await createPackageJson({ cwd: appPath, @@ -97,6 +98,7 @@ async function create (opts) { } }) + gitInit(appPath) } @@ -131,66 +133,19 @@ function transformUserInput(input) { function createBuildAction(config) { const workflowDir = path.join(config.path, '.github', 'workflows') - fs.ensureDirSync(workflowDir) + const templateFile = path.join(__dirname, 'template', 'build.yml') const configFile = path.join(workflowDir, 'build.yml') + + fs.ensureDirSync(workflowDir) + fs.copyFileSync(templateFile, configFile) +} - const buildAction = `name: Generate Statusboard - -on: - push: - branches: - - main - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - - -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - index: - environment: - name: github-pages - url: \${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Pages - uses: actions/configure-pages@v5 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - uses: actions/cache@v4 - id: cache - with: - path: | - ~/.npm - ~/.cache - ./dist - ./node_modules - key: \${{ runner.os }}-build-\${{ github.sha }} - - if: steps.cache.outputs.cache-hit != 'true' - run: npm install - shell: bash - - run: npm run build - env: - GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }} - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: './build' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 -` +function createGitIgnore(config) { + const gitIgnore = path.join(config.path, '.gitignore') + const templateFile = path.join(__dirname, 'template', '.gitignore') - fs.writeFileSync(configFile, buildAction) + fs.ensureFileSync(gitIgnore) + fs.copyFileSync(templateFile, gitIgnore) } module.exports = create diff --git a/lib/commands/template/.gitignore b/lib/commands/template/.gitignore new file mode 100644 index 0000000..d596a96 --- /dev/null +++ b/lib/commands/template/.gitignore @@ -0,0 +1,11 @@ +# dependencies +node_modules +.pnp +.pnp.js + +# production +build + +# local env files +.env*.local +.env \ No newline at end of file diff --git a/lib/commands/template/build.yml b/lib/commands/template/build.yml new file mode 100644 index 0000000..3691a49 --- /dev/null +++ b/lib/commands/template/build.yml @@ -0,0 +1,54 @@ +name: Generate Statusboard + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + index: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - uses: actions/cache@v4 + id: cache + with: + path: | + ~/.npm + ~/.cache + ./dist + ./node_modules + key: ${{ runner.os }}-build-${{ github.sha }} + - if: steps.cache.outputs.cache-hit != 'true' + run: npm install + shell: bash + - run: npm run build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './build' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file From 98cb9d1fafca64a9bb65b977d608a12b4c792118 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 26 Jan 2025 15:35:24 -0500 Subject: [PATCH 09/12] fix code styles --- lib/commands/create.js | 13 ++++++------- lib/git.js | 43 +++++++++++++++++++++--------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/lib/commands/create.js b/lib/commands/create.js index 3e56683..553cbfd 100644 --- a/lib/commands/create.js +++ b/lib/commands/create.js @@ -68,7 +68,7 @@ async function create (opts) { config.path = appPath await fs.ensureDir(appPath) - + if (config.githubActions) { createBuildAction(config) } @@ -98,7 +98,6 @@ async function create (opts) { } }) - gitInit(appPath) } @@ -123,24 +122,24 @@ async function createConfigFile (config) { await fs.writeFile(configFile, configJs) } -function transformUserInput(input) { +function transformUserInput (input) { if (input?.length === 0) { return [] } - return input?.split(",").map((l) => l.trim()) || [] + return input?.split(',').map((l) => l.trim()) || [] } -function createBuildAction(config) { +function createBuildAction (config) { const workflowDir = path.join(config.path, '.github', 'workflows') const templateFile = path.join(__dirname, 'template', 'build.yml') const configFile = path.join(workflowDir, 'build.yml') - + fs.ensureDirSync(workflowDir) fs.copyFileSync(templateFile, configFile) } -function createGitIgnore(config) { +function createGitIgnore (config) { const gitIgnore = path.join(config.path, '.gitignore') const templateFile = path.join(__dirname, 'template', '.gitignore') diff --git a/lib/git.js b/lib/git.js index e82da88..91ebf19 100644 --- a/lib/git.js +++ b/lib/git.js @@ -1,39 +1,40 @@ -const { execSync } = require("child_process"); -const { rm } = require("fs"); +const { execSync } = require('child_process') +const { rm } = require('fs') +const { join } = require('path') -function isGitRepo() { - try { - execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }); - return true; - } catch {} - return false; +function isGitRepo () { + try { + execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }) + return true + } catch {} + return false } -module.exports.gitInit = function gitInit(path) { - let didInit = false; +module.exports.gitInit = function gitInit (path) { + let didInit = false try { - if(isGitRepo()) { - return false; + if (isGitRepo()) { + return false } - execSync("git init", { stdio: "ignore" }); + execSync('git init', { stdio: 'ignore' }) - didInit = true; + didInit = true - execSync("git checkout -b main", { stdio: "ignore" }); - execSync("git add -A", { stdio: "ignore" }); + execSync('git checkout -b main', { stdio: 'ignore' }) + execSync('git add -A', { stdio: 'ignore' }) execSync('git commit -m "Initial commit from @pkgjs/statusboard"', { - stdio: "ignore", - }); + stdio: 'ignore' + }) - return true; + return true } catch (e) { if (didInit) { try { - rm(join(path, ".git"), { recursive: true, force: true }); + rm(join(path, '.git'), { recursive: true, force: true }) } catch (_) {} } - return false; + return false } } From 5e995ce89d2efee5d6a87807c8dcc3b2677fc368 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 26 Sep 2026 17:08:22 -0500 Subject: [PATCH 10/12] fix: make create command generate usable projects safely --- README.md | 48 +++--- lib/cli.js | 5 +- lib/commands/create.js | 150 ++++++++---------- lib/commands/template/build.yml | 6 +- .../template/{.gitignore => gitignore} | 5 +- lib/git.js | 43 ++--- package.json | 1 - test/create.js | 107 +++++++++++++ 8 files changed, 209 insertions(+), 156 deletions(-) rename lib/commands/template/{.gitignore => gitignore} (74%) create mode 100644 test/create.js diff --git a/README.md b/README.md index 45ff628..284f46f 100644 --- a/README.md +++ b/README.md @@ -22,47 +22,35 @@ using Node.js's native environment file support. Use `--env ` to load a different file. Existing environment variables take precedence, and the file is optional when variables are provided by the shell or CI. -WARNING: work in process, the following doesn't work yet, but soon! - -The easiest way to create a status board for your project is using Github Pages. To get started, create a new repo for your project and clone it -to your development machine. In the new directory run the following: +Create a project interactively: +```sh +npx @pkgjs/statusboard create my-statusboard +cd my-statusboard +npm install ``` -# Creates a statusboard project -# @TODO make this command actually work as it does not right now -$ npx @pkgjs/statusboard create - -# Setup your config in `index.js -# Then commit your work -$ git commit -am "statusboard setup" - -# Create an orphan branch for our builds -$ git checkout --orphan gh-pages -# Remove the files we dont need here -$ git rm -rf . +The command asks for organizations, repositories (`owner/repo`), issue labels, +and optional GitHub Actions deployment. It creates `config.js`, `package.json`, +`.gitignore`, and a local Git repository. Choose a new or empty directory; +existing project files are never overwritten. -# Create a .nojekyll file, this turns off pesky github pages stuff -$ touch .nojekyll -$ git commit --am "github pages initial commit" +Set `GITHUB_TOKEN` in your environment or in a local `.env` file, then run: -# Now we setup the branch as a working tree on the master branch -$ git checkout master -$ mkdir build -$ git worktree add build gh-pages +```sh +npm run build +``` -# Now we can run the index and build -$ npm run build +The site is generated in `build/`. Edit `config.js` to customize the board. -# Now we should have a site in ./build, we can -# commit and push the branches now -$ cd build && git add . && git commit -m "our new statuspage" && git push -``` +If you selected GitHub Actions, push the generated project to a GitHub repository +with a `main` branch and enable **Settings → Pages → Source → GitHub Actions**. +The workflow builds and deploys the site using the repository's Pages base path. +Without Actions, set `baseUrl` in `config.js` to match your hosting path. ## TODO - Cli logger -- `create` command to setup a new project - Contribution graph like on github - Meetings page (pull tag "meeting") - Typescript support (load typings or if authored in TS) diff --git a/lib/cli.js b/lib/cli.js index 579ce44..505d9a1 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -82,7 +82,10 @@ module.exports = (create, builder, argv) => { let cli = yargs() - .command('create [directory]', 'Create a StatusBoard', {}, async (argv) => { + .command('create [directory]', 'Create a StatusBoard', (cli) => cli.positional('directory', { + description: 'Directory for the new project', + type: 'string' + }), async (argv) => { await initStatusboard(argv) }) diff --git a/lib/commands/create.js b/lib/commands/create.js index 553cbfd..540f911 100644 --- a/lib/commands/create.js +++ b/lib/commands/create.js @@ -1,150 +1,126 @@ -const inquirer = require('inquirer') +'use strict' +const inquirer = require('inquirer').default const path = require('node:path') -const createPackageJson = require('create-package-json') -const fs = require('fs-extra') -const { gitInit } = require('../git.js') - -async function create (opts) { - const { directory } = opts - - const config = { - path: directory, - name: undefined, - labels: [], - orgs: [], - projects: [], - githubActions: false - } +const fs = require('node:fs/promises') +const { gitInit } = require('../git') +const packageJson = require('../../package.json') +async function create (opts = {}) { const answers = await inquirer.prompt([ { type: 'input', name: 'path', message: 'Where would you like to create your project?', default: 'statusboard', - when: () => !config.path + when: () => !opts.directory, + validate: value => value.trim().length > 0 || 'Enter a project directory.' }, { type: 'confirm', name: 'defaultLabels', - message: 'Do you want use default labels?', + message: 'Do you want to use the default labels?', default: true }, { type: 'input', name: 'labels', - message: 'What labels do you want to use (use commas to separate)', - when: (answers) => !answers.defaultLabels + message: 'What labels do you want to use (use commas to separate)?', + when: answers => !answers.defaultLabels }, { type: 'input', name: 'orgs', - message: - 'What organizations do you want to include (use commas to separate)' + message: 'What organizations do you want to include (use commas to separate)?' }, { type: 'input', name: 'repositories', - message: - 'What repositories do you want to include (use commas to separate)' + message: 'What repositories do you want to include (owner/repo, separated by commas)?', + validate: value => transformUserInput(value).every(repo => /^[\w.-]+\/[\w.-]+$/.test(repo)) || 'Use owner/repo for each repository.' }, { type: 'confirm', name: 'githubActions', - message: 'Do you want deploy with GitHub Actions?', + message: 'Do you want to deploy with GitHub Actions?', default: false } ]) - config.path = answers.path.trim() - - config.labels = transformUserInput(answers.labels) - config.orgs = transformUserInput(answers.orgs) - config.projects = transformUserInput(answers.repositories) - config.githubActions = answers.githubActions - - const appPath = path.resolve(config.path) - config.name = path.basename(appPath) - config.path = appPath - - await fs.ensureDir(appPath) + const directory = (opts.directory || answers.path || '').trim() + if (!directory) throw new Error('Enter a project directory.') + const appPath = path.resolve(directory) + await fs.mkdir(appPath, { recursive: true }) + if ((await fs.readdir(appPath)).length) { + throw new Error(`Directory is not empty: ${appPath}`) + } - if (config.githubActions) { - createBuildAction(config) + const config = { + path: appPath, + name: path.basename(appPath), + labels: answers.defaultLabels ? undefined : transformUserInput(answers.labels), + orgs: transformUserInput(answers.orgs), + projects: transformUserInput(answers.repositories), + githubActions: answers.githubActions } + if (config.githubActions) await createBuildAction(config) await createConfigFile(config) - createGitIgnore(config) + await fs.copyFile(path.join(__dirname, 'template', 'gitignore'), path.join(appPath, '.gitignore')) - await createPackageJson({ - cwd: appPath, - name: config.name, + // The generated manifest is fixed apart from the project name and our version. + const manifest = { + name: config.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+/, '') || 'statusboard', description: 'A dashboard for project status', version: '1.0.0', - dependencies: ['@pkgjs/statusboard'], - author: null, - keywords: ['statusboard', 'projects'], - repository: null, - type: 'commonjs', private: true, + type: 'commonjs', license: 'MIT', - main: 'config.js', - devDependencies: null, + engines: packageJson.engines, + dependencies: { '@pkgjs/statusboard': `^${packageJson.version}` }, scripts: { build: 'statusboard build -C ./config.js', buildsite: 'npm run clean && statusboard site -C ./config.js', buildindex: 'statusboard index -C ./config.js', - clean: 'rm -rf build/css build/js' + clean: 'node -e "const fs = require(\'node:fs\'); for (const dir of [\'build/css\', \'build/js\']) fs.rmSync(dir, { recursive: true, force: true })"' } - }) + } + await fs.writeFile(path.join(appPath, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' }) - gitInit(appPath) + if (!gitInit(appPath)) { + console.warn('Could not create the initial Git commit. Project files are ready; check Git installation and user configuration.') + } + console.log(`Created Statusboard in ${appPath}. Run npm install there, set GITHUB_TOKEN, then run npm run build.`) + if (config.githubActions) { + console.log('Enable GitHub Pages with GitHub Actions as the source in your repository settings.') + } } async function createConfigFile (config) { - const configFile = path.join(config.path, 'config.js') - - const configJs = `module.exports = {${ - config.labels.length >= 1 - ? `\n issueLabels: ${JSON.stringify(config.labels)},` - : '' - }${config.orgs.length >= 1 ? `\n orgs: ${JSON.stringify(config.orgs)},` : ''}${ - config.projects.length >= 1 - ? `\n projects: ${JSON.stringify(config.projects)},` - : '' - }${config.githubActions ? `\n baseUrl: "/${config.name}",` : ''} + const fields = { + ...(config.labels === undefined ? {} : { issueLabels: config.labels }), + orgs: config.orgs, + projects: config.projects + } + const content = JSON.stringify(fields, null, 2).slice(0, -2) + const baseUrl = config.githubActions + ? `\n baseUrl: process.env.STATUSBOARD_BASE_URL ?? ${JSON.stringify(`/${config.name}`)},` + : '' + await fs.writeFile(path.join(config.path, 'config.js'), `'use strict'\nmodule.exports = ${content},${baseUrl} github: { token: process.env.GITHUB_TOKEN } } -` - - await fs.writeFile(configFile, configJs) +`, { flag: 'wx' }) } -function transformUserInput (input) { - if (input?.length === 0) { - return [] - } - - return input?.split(',').map((l) => l.trim()) || [] +function transformUserInput (input = '') { + return input.split(',').map(value => value.trim()).filter(Boolean) } -function createBuildAction (config) { +async function createBuildAction (config) { const workflowDir = path.join(config.path, '.github', 'workflows') - const templateFile = path.join(__dirname, 'template', 'build.yml') - const configFile = path.join(workflowDir, 'build.yml') - - fs.ensureDirSync(workflowDir) - fs.copyFileSync(templateFile, configFile) -} - -function createGitIgnore (config) { - const gitIgnore = path.join(config.path, '.gitignore') - const templateFile = path.join(__dirname, 'template', '.gitignore') - - fs.ensureFileSync(gitIgnore) - fs.copyFileSync(templateFile, gitIgnore) + await fs.mkdir(workflowDir, { recursive: true }) + await fs.copyFile(path.join(__dirname, 'template', 'build.yml'), path.join(workflowDir, 'build.yml')) } module.exports = create diff --git a/lib/commands/template/build.yml b/lib/commands/template/build.yml index 3691a49..fdf148b 100644 --- a/lib/commands/template/build.yml +++ b/lib/commands/template/build.yml @@ -26,10 +26,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Setup Pages + id: pages uses: actions/configure-pages@v5 - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 22 - uses: actions/cache@v4 id: cache with: @@ -45,10 +46,11 @@ jobs: - run: npm run build env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STATUSBOARD_BASE_URL: ${{ steps.pages.outputs.base_path }} - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: './build' - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@v4 diff --git a/lib/commands/template/.gitignore b/lib/commands/template/gitignore similarity index 74% rename from lib/commands/template/.gitignore rename to lib/commands/template/gitignore index d596a96..8088f34 100644 --- a/lib/commands/template/.gitignore +++ b/lib/commands/template/gitignore @@ -8,4 +8,7 @@ build # local env files .env*.local -.env \ No newline at end of file +.env + +# local database +data.db diff --git a/lib/git.js b/lib/git.js index 91ebf19..3efa51f 100644 --- a/lib/git.js +++ b/lib/git.js @@ -1,40 +1,15 @@ -const { execSync } = require('child_process') -const { rm } = require('fs') -const { join } = require('path') +'use strict' +const { execFileSync } = require('node:child_process') -function isGitRepo () { +module.exports.gitInit = function gitInit (directory) { + const options = { cwd: directory, stdio: 'ignore' } try { - execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }) + execFileSync('git', ['init', '-b', 'main'], options) + execFileSync('git', ['add', '-A'], options) + execFileSync('git', ['commit', '-m', 'Initial commit from @pkgjs/statusboard'], options) return true - } catch {} - return false -} - -module.exports.gitInit = function gitInit (path) { - let didInit = false - - try { - if (isGitRepo()) { - return false - } - - execSync('git init', { stdio: 'ignore' }) - - didInit = true - - execSync('git checkout -b main', { stdio: 'ignore' }) - execSync('git add -A', { stdio: 'ignore' }) - execSync('git commit -m "Initial commit from @pkgjs/statusboard"', { - stdio: 'ignore' - }) - - return true - } catch (e) { - if (didInit) { - try { - rm(join(path, '.git'), { recursive: true, force: true }) - } catch (_) {} - } + } catch { + // Keep the repository and generated files if Git identity is not configured. return false } } diff --git a/package.json b/package.json index 49cb365..e870413 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "@wesleytodd/buildjs": "0.0.8", "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", "cptmpl": "0.0.4", - "create-package-json": "^1.1.0", "es5-lit-element": "^2.2.1", "es5-lit-html": "^1.1.1", "express": "^5.2.1", diff --git a/test/create.js b/test/create.js new file mode 100644 index 0000000..1fbdf63 --- /dev/null +++ b/test/create.js @@ -0,0 +1,107 @@ +'use strict' +const { suite, test, beforeEach, afterEach } = require('mocha') +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const path = require('node:path') +const os = require('node:os') +const { execFileSync } = require('node:child_process') +const inquirer = require('inquirer').default +const create = require('../lib/commands/create') +const cli = require('../lib/cli') + +suite('Create command', () => { + let directory + let originalPrompt + let environment + let answers + const gitEnv = { + GIT_AUTHOR_NAME: 'Statusboard test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Statusboard test', + GIT_COMMITTER_EMAIL: 'test@example.com', + GIT_CONFIG_GLOBAL: os.devNull, + GIT_CONFIG_NOSYSTEM: '1', + STATUSBOARD_BASE_URL: '' + } + + beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'statusboard-create-')) + environment = Object.fromEntries(Object.keys(gitEnv).map(key => [key, process.env[key]])) + Object.assign(process.env, gitEnv) + answers = { defaultLabels: true, orgs: ' pkgjs, ,nodejs, ', repositories: ' pkgjs/statusboard, , ', githubActions: false } + originalPrompt = inquirer.prompt + inquirer.prompt = async () => answers + }) + + afterEach(async () => { + inquirer.prompt = originalPrompt + for (const [key, value] of Object.entries(environment)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await fs.rm(directory, { recursive: true, force: true }) + }) + + function git (cwd, ...args) { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() + } + + test('creates a project via the CLI positional directory without changing the caller repository', async () => { + const callerHead = git(process.cwd(), 'rev-parse', 'HEAD') + const app = path.join(directory, 'My Board') + await cli(() => { throw new Error('Should not build during create') }).parseAsync(['create', app]) + const config = require(path.join(app, 'config.js')) + assert.deepEqual(config.orgs, ['pkgjs', 'nodejs']) + assert.deepEqual(config.projects, ['pkgjs/statusboard']) + assert.equal(Object.hasOwn(config, 'issueLabels'), false) + const pkg = JSON.parse(await fs.readFile(path.join(app, 'package.json'), 'utf8')) + assert.equal(pkg.name, 'my-board') + assert.equal(pkg.private, true) + assert.equal(pkg.dependencies['@pkgjs/statusboard'], `^${require('../package.json').version}`) + assert.equal(git(app, 'rev-parse', '--show-toplevel'), app) + assert.equal(git(app, 'branch', '--show-current'), 'main') + assert.equal(git(app, 'status', '--porcelain'), '') + assert.equal(git(process.cwd(), 'rev-parse', 'HEAD'), callerHead) + await fs.writeFile(path.join(app, '.env'), 'GITHUB_TOKEN=test\n') + await fs.mkdir(path.join(app, 'data.db')) + await fs.writeFile(path.join(app, 'data.db', 'test'), 'data') + assert.equal(git(app, 'status', '--porcelain'), '') + await fs.mkdir(path.join(app, 'build', 'css'), { recursive: true }) + execFileSync(process.execPath, ['-e', pkg.scripts.clean.slice('node -e "'.length, -1)], { cwd: app }) + await assert.rejects(fs.stat(path.join(app, 'build', 'css')), { code: 'ENOENT' }) + await assert.rejects(fs.stat(path.join(app, '.github')), { code: 'ENOENT' }) + }) + + test('supports a prompted path, custom labels and GitHub Pages base paths', async () => { + answers = { ...answers, path: path.join(directory, 'board'), defaultLabels: false, labels: 'help wanted, , bug, ', githubActions: true } + await create() + const config = require(path.join(answers.path, 'config.js')) + assert.deepEqual(config.issueLabels, ['help wanted', 'bug']) + assert.equal(config.baseUrl, '') + const workflow = await fs.readFile(path.join(answers.path, '.github', 'workflows', 'build.yml'), 'utf8') + assert.match(workflow, /node-version: 22/) + assert.match(workflow, /STATUSBOARD_BASE_URL: \$\{\{ steps.pages.outputs.base_path \}\}/) + assert.match(workflow, /path: '\.\/build'/) + }) + + test('preserves an existing nonempty directory', async () => { + await fs.writeFile(path.join(directory, 'config.js'), 'original') + await assert.rejects(create({ directory }), /Directory is not empty/) + assert.equal(await fs.readFile(path.join(directory, 'config.js'), 'utf8'), 'original') + assert.deepEqual(await fs.readdir(directory), ['config.js']) + }) + + test('rejects an empty directory answer', async () => { + answers.path = ' ' + await assert.rejects(create(), /Enter a project directory/) + }) + + test('keeps generated files and the repository if the initial commit fails', async () => { + process.env.GIT_AUTHOR_NAME = '' + const app = path.join(directory, 'no-identity') + await create({ directory: app }) + assert.ok((await fs.stat(path.join(app, '.git'))).isDirectory()) + assert.ok((await fs.stat(path.join(app, 'config.js'))).isFile()) + assert.throws(() => git(app, 'rev-parse', '--verify', 'HEAD')) + }) +}) From 277ca5464463de46102c95cbc03f22a2db7b138e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 26 Sep 2026 17:12:50 -0500 Subject: [PATCH 11/12] feat: support non-interactive project creation and optional git --- README.md | 13 ++++ lib/cli.js | 9 ++- lib/commands/create.js | 139 ++++++++++++++++++++++++++--------------- test/create.js | 53 ++++++++++++++++ 4 files changed, 162 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 284f46f..34a2f6c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,19 @@ and optional GitHub Actions deployment. It creates `config.js`, `package.json`, `.gitignore`, and a local Git repository. Choose a new or empty directory; existing project files are never overwritten. +For scripts, use `--yes` (`-y`) to skip all prompts. Omitted values default to +the `statusboard` directory, built-in labels, no organizations or repositories, +no GitHub Actions workflow, and Git initialization enabled. Override these with +`--orgs`, `--repositories`, `--labels`, `--github-actions`, and `--no-git`: + +```sh +npx @pkgjs/statusboard create my-statusboard --yes --orgs pkgjs,nodejs \ + --repositories expressjs/express --labels "help wanted,bug" --github-actions --no-git +``` + +These options also work interactively: only unspecified settings are prompted. +Use `--labels ""` for no issue labels or `--no-github-actions` to skip the workflow. + Set `GITHUB_TOKEN` in your environment or in a local `.env` file, then run: ```sh diff --git a/lib/cli.js b/lib/cli.js index 505d9a1..438f876 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -85,8 +85,15 @@ module.exports = (create, builder, argv) => { .command('create [directory]', 'Create a StatusBoard', (cli) => cli.positional('directory', { description: 'Directory for the new project', type: 'string' + }).options({ + yes: { alias: 'y', type: 'boolean', default: false, description: 'Use defaults without prompting' }, + orgs: { type: 'string', description: 'Comma-separated organizations' }, + repositories: { type: 'string', description: 'Comma-separated owner/repo names' }, + labels: { type: 'string', description: 'Comma-separated issue labels (default: built-in labels)' }, + 'github-actions': { type: 'boolean', description: 'Generate a GitHub Pages workflow' }, + git: { type: 'boolean', default: true, description: 'Initialize Git (disable with --no-git)' } }), async (argv) => { - await initStatusboard(argv) + await initStatusboard({ ...argv, githubActions: argv['github-actions'] }) }) .command('build', 'Index and build board', SHARED_OPTIONS, async (argv) => { diff --git a/lib/commands/create.js b/lib/commands/create.js index 540f911..9382778 100644 --- a/lib/commands/create.js +++ b/lib/commands/create.js @@ -6,61 +6,83 @@ const { gitInit } = require('../git') const packageJson = require('../../package.json') async function create (opts = {}) { - const answers = await inquirer.prompt([ - { - type: 'input', - name: 'path', - message: 'Where would you like to create your project?', - default: 'statusboard', - when: () => !opts.directory, - validate: value => value.trim().length > 0 || 'Enter a project directory.' - }, - { - type: 'confirm', - name: 'defaultLabels', - message: 'Do you want to use the default labels?', - default: true - }, - { - type: 'input', - name: 'labels', - message: 'What labels do you want to use (use commas to separate)?', - when: answers => !answers.defaultLabels - }, - { - type: 'input', - name: 'orgs', - message: 'What organizations do you want to include (use commas to separate)?' - }, - { - type: 'input', - name: 'repositories', - message: 'What repositories do you want to include (owner/repo, separated by commas)?', - validate: value => transformUserInput(value).every(repo => /^[\w.-]+\/[\w.-]+$/.test(repo)) || 'Use owner/repo for each repository.' - }, - { - type: 'confirm', - name: 'githubActions', - message: 'Do you want to deploy with GitHub Actions?', - default: false - } - ]) + let directory = opts.directory + if (directory === undefined) { + directory = opts.yes + ? 'statusboard' + : (await inquirer.prompt([{ + type: 'input', + name: 'path', + message: 'Where would you like to create your project?', + default: 'statusboard', + validate: async value => { + try { + await validateDirectory(value) + return true + } catch (error) { + return error.message + } + } + }])).path + } + const appPath = await validateDirectory(directory) + if (opts.repositories !== undefined && !validRepositories(opts.repositories)) { + throw new Error('Use owner/repo for each repository.') + } - const directory = (opts.directory || answers.path || '').trim() - if (!directory) throw new Error('Enter a project directory.') - const appPath = path.resolve(directory) + const answers = opts.yes + ? {} + : await inquirer.prompt([ + { + type: 'confirm', + name: 'defaultLabels', + message: 'Do you want to use the default labels?', + default: true, + when: () => opts.labels === undefined + }, + { + type: 'input', + name: 'labels', + message: 'What labels do you want to use (use commas to separate)?', + when: answers => opts.labels === undefined && !answers.defaultLabels + }, + { + type: 'input', + name: 'orgs', + message: 'What organizations do you want to include (use commas to separate)?', + when: () => opts.orgs === undefined + }, + { + type: 'input', + name: 'repositories', + message: 'What repositories do you want to include (owner/repo, separated by commas)?', + when: () => opts.repositories === undefined, + validate: value => validRepositories(value) || 'Use owner/repo for each repository.' + }, + { + type: 'confirm', + name: 'githubActions', + message: 'Do you want to deploy with GitHub Actions?', + default: false, + when: () => opts.githubActions === undefined + } + ]) + + const repositories = opts.repositories ?? answers.repositories + if (!validRepositories(repositories)) throw new Error('Use owner/repo for each repository.') + // Recheck after prompting in case the destination changed while waiting. + await validateDirectory(appPath) await fs.mkdir(appPath, { recursive: true }) - if ((await fs.readdir(appPath)).length) { - throw new Error(`Directory is not empty: ${appPath}`) - } const config = { path: appPath, name: path.basename(appPath), - labels: answers.defaultLabels ? undefined : transformUserInput(answers.labels), - orgs: transformUserInput(answers.orgs), - projects: transformUserInput(answers.repositories), - githubActions: answers.githubActions + labels: opts.labels !== undefined + ? transformUserInput(opts.labels) + : answers.defaultLabels === false ? transformUserInput(answers.labels) : undefined, + orgs: transformUserInput(opts.orgs ?? answers.orgs), + projects: transformUserInput(repositories), + githubActions: opts.githubActions ?? answers.githubActions ?? false } if (config.githubActions) await createBuildAction(config) @@ -86,7 +108,7 @@ async function create (opts = {}) { } await fs.writeFile(path.join(appPath, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' }) - if (!gitInit(appPath)) { + if (opts.git !== false && !gitInit(appPath)) { console.warn('Could not create the initial Git commit. Project files are ready; check Git installation and user configuration.') } 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 = {}) { } } +async function validateDirectory (directory) { + if (typeof directory !== 'string' || !directory.trim()) throw new Error('Enter a project directory.') + const appPath = path.resolve(directory.trim()) + try { + if ((await fs.readdir(appPath)).length) throw new Error(`Directory is not empty: ${appPath}`) + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + return appPath +} + +function validRepositories (input) { + return transformUserInput(input).every(repo => /^[\w.-]+\/[\w.-]+$/.test(repo)) +} + async function createConfigFile (config) { const fields = { ...(config.labels === undefined ? {} : { issueLabels: config.labels }), @@ -114,7 +151,7 @@ async function createConfigFile (config) { } function transformUserInput (input = '') { - return input.split(',').map(value => value.trim()).filter(Boolean) + return [input].flat().flatMap(value => value.split(',')).map(value => value.trim()).filter(Boolean) } async function createBuildAction (config) { diff --git a/test/create.js b/test/create.js index 1fbdf63..cf4a405 100644 --- a/test/create.js +++ b/test/create.js @@ -86,6 +86,7 @@ suite('Create command', () => { test('preserves an existing nonempty directory', async () => { await fs.writeFile(path.join(directory, 'config.js'), 'original') + inquirer.prompt = async () => { throw new Error('Must reject the destination before prompting') } await assert.rejects(create({ directory }), /Directory is not empty/) assert.equal(await fs.readFile(path.join(directory, 'config.js'), 'utf8'), 'original') assert.deepEqual(await fs.readdir(directory), ['config.js']) @@ -96,6 +97,58 @@ suite('Create command', () => { await assert.rejects(create(), /Enter a project directory/) }) + test('creates without prompts using CLI settings and --no-git', async () => { + inquirer.prompt = async () => { throw new Error('Must not prompt with --yes') } + const app = path.join(directory, 'automated') + await cli(() => {}).parseAsync([ + 'create', app, '--yes', '--no-git', '--orgs', 'pkgjs,nodejs', + '--repositories', 'expressjs/express, pkgjs/statusboard', + '--labels', 'bug, help wanted', '--github-actions' + ]) + const config = require(path.join(app, 'config.js')) + assert.deepEqual(config.orgs, ['pkgjs', 'nodejs']) + assert.deepEqual(config.projects, ['expressjs/express', 'pkgjs/statusboard']) + assert.deepEqual(config.issueLabels, ['bug', 'help wanted']) + assert.ok((await fs.stat(path.join(app, '.github', 'workflows', 'build.yml'))).isFile()) + await assert.rejects(fs.stat(path.join(app, '.git')), { code: 'ENOENT' }) + }) + + test('uses default values without a terminal or directory argument', async () => { + execFileSync(process.execPath, [require.resolve('../bin/statusboard'), 'create', '-y', '--no-git'], { + cwd: directory, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10000 + }) + const app = path.join(directory, 'statusboard') + const config = require(path.join(app, 'config.js')) + assert.deepEqual(config.orgs, []) + assert.deepEqual(config.projects, []) + assert.equal(Object.hasOwn(config, 'issueLabels'), false) + await assert.rejects(fs.stat(path.join(app, '.github')), { code: 'ENOENT' }) + await assert.rejects(fs.stat(path.join(app, '.git')), { code: 'ENOENT' }) + }) + + test('prompts only for missing interactive settings and preserves explicit empty labels', async () => { + inquirer.prompt = async questions => { + const asked = questions.filter(question => !question.when || question.when({})).map(question => question.name) + assert.deepEqual(asked, ['repositories']) + return { repositories: 'pkgjs/statusboard' } + } + const app = path.join(directory, 'interactive') + await cli(() => {}).parseAsync(['create', app, '--no-git', '--orgs', 'pkgjs', '--labels', '', '--no-github-actions']) + const config = require(path.join(app, 'config.js')) + assert.deepEqual(config.issueLabels, []) + assert.deepEqual(config.projects, ['pkgjs/statusboard']) + await assert.rejects(fs.stat(path.join(app, '.github')), { code: 'ENOENT' }) + }) + + test('rejects invalid repository options before prompting or creating files', async () => { + inquirer.prompt = async () => { throw new Error('Must validate supplied options first') } + const app = path.join(directory, 'invalid') + await assert.rejects(create({ directory: app, repositories: 'missing-owner' }), /Use owner\/repo/) + await assert.rejects(fs.stat(app), { code: 'ENOENT' }) + }) + test('keeps generated files and the repository if the initial commit fails', async () => { process.env.GIT_AUTHOR_NAME = '' const app = path.join(directory, 'no-identity') From 8d9b8461a078464a366821b824dc67bce4dc6236 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 26 Sep 2026 17:39:56 -0500 Subject: [PATCH 12/12] test: cover create command edge cases and generated site build --- test/create.js | 109 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/test/create.js b/test/create.js index cf4a405..8f199a5 100644 --- a/test/create.js +++ b/test/create.js @@ -149,6 +149,115 @@ suite('Create command', () => { await assert.rejects(fs.stat(app), { code: 'ENOENT' }) }) + test('rejects a file as the destination without changing it or prompting', async () => { + const target = path.join(directory, 'existing-file') + await fs.writeFile(target, 'keep this file') + inquirer.prompt = async () => { throw new Error('Must reject the destination before prompting') } + await assert.rejects(create({ directory: target }), { code: 'ENOTDIR' }) + assert.equal(await fs.readFile(target, 'utf8'), 'keep this file') + }) + + test('does not create directories when the user cancels the configuration prompts', async () => { + const app = path.join(directory, 'nested', 'cancelled') + const cancelled = new Error('User cancelled') + inquirer.prompt = async () => { throw cancelled } + await assert.rejects(create({ directory: app }), error => error === cancelled) + assert.deepEqual(await fs.readdir(directory), []) + }) + + test('preserves files added to the destination while prompting', async () => { + const app = path.join(directory, 'changed') + inquirer.prompt = async () => { + await fs.mkdir(app) + await fs.writeFile(path.join(app, 'config.js'), 'created while prompting') + return answers + } + await assert.rejects(create({ directory: app }), /Directory is not empty/) + assert.deepEqual(await fs.readdir(app), ['config.js']) + assert.equal(await fs.readFile(path.join(app, 'config.js'), 'utf8'), 'created while prompting') + }) + + test('accepts repeated list options and preserves special characters in labels', async () => { + const app = path.join(directory, 'repeated-options') + const label = 'needs "review" \\ documentation\nnext line' + await cli(() => {}).parseAsync([ + 'create', app, '--yes', '--no-git', + '--orgs', 'pkgjs, nodejs', '--orgs', 'expressjs', + '--repositories', 'pkgjs/statusboard', '--repositories', 'nodejs/node,', + '--labels', label, '--labels', 'bug, ' + ]) + const config = require(path.join(app, 'config.js')) + assert.deepEqual(config.orgs, ['pkgjs', 'nodejs', 'expressjs']) + assert.deepEqual(config.projects, ['pkgjs/statusboard', 'nodejs/node']) + assert.deepEqual(config.issueLabels, [label, 'bug']) + }) + + test('uses the directory name for Pages locally and the configured base path in CI', async () => { + const app = path.join(directory, 'local-board') + await create({ directory: app, yes: true, git: false, githubActions: true }) + const configFile = path.join(app, 'config.js') + for (const [basePath, expected] of [[undefined, '/local-board'], ['/deployed-repo', '/deployed-repo'], ['', '']]) { + if (basePath === undefined) delete process.env.STATUSBOARD_BASE_URL + else process.env.STATUSBOARD_BASE_URL = basePath + delete require.cache[require.resolve(configFile)] + assert.equal(require(configFile).baseUrl, expected) + } + }) + + test('builds a site through the CLI using a generated project and local indexed data', async function () { + this.timeout(20000) + const app = path.join(directory, 'generated-site') + process.env.STATUSBOARD_BASE_URL = '/published-board' + await cli(() => {}).parseAsync([ + 'create', app, '--yes', '--no-git', '--repositories', 'example/project', + '--labels', 'help wanted', '--github-actions' + ]) + // Seed the same records the indexer stores, without making network requests. + const seed = ` + const statusboard = require(${JSON.stringify(require.resolve('../'))}) + ;(async () => { + const board = await statusboard(require('./config.js')) + const project = board.config.projects[0] + try { + await board.db.put('example:project:REPO', { + type: 'REPO', project, detail: { url: 'https://github.com/example/project' } + }) + await board.db.put('example:project:ISSUE:1', { + type: 'ISSUE', project, detail: { + state: 'OPEN', number: 1, title: 'An indexed issue', + url: 'https://github.com/example/project/issues/1', + labels: [{ name: 'help wanted', color: '008800' }] + } + }) + await board.db.put('example:project:COMMIT:1', { + type: 'COMMIT', project, detail: { + date: new Date().toISOString(), + author: { login: 'contributor', avatarUrl: 'https://example.com/avatar.png' } + } + }) + } finally { + await board.close() + } + })().catch(error => { console.error(error); process.exitCode = 1 }) + ` + const options = { cwd: app, stdio: ['ignore', 'pipe', 'pipe'], timeout: 10000 } + execFileSync(process.execPath, ['-e', seed], options) + execFileSync(process.execPath, [require.resolve('../bin/statusboard'), 'site', '-C', './config.js'], options) + + const output = path.join(app, 'build') + const readJson = async name => JSON.parse(await fs.readFile(path.join(output, 'data', `${name}.json`), 'utf8')) + assert.equal((await readJson('projects'))[0].repo, 'example/project') + assert.equal((await readJson('labeledIssues'))['help wanted'][0].issue.title, 'An indexed issue') + assert.equal((await readJson('userActivity')).contributor.activityCount, 1) + assert.ok((await fs.stat(path.join(output, '404.html'))).isFile()) + const html = await fs.readFile(path.join(output, 'index.html'), 'utf8') + const assets = [...html.matchAll(/(?:src|href)="(\/published-board\/[^" ]+\.(?:js|css))"/g)] + assert.ok(assets.length >= 2, 'HTML should reference the generated JavaScript and stylesheet') + for (const [, asset] of assets) { + assert.ok((await fs.stat(path.join(output, asset.slice('/published-board/'.length)))).size > 0) + } + }) + test('keeps generated files and the repository if the initial commit fails', async () => { process.env.GIT_AUTHOR_NAME = '' const app = path.join(directory, 'no-identity')