Skip to content
Merged
57 changes: 29 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,47 +22,48 @@ using Node.js's native environment file support. Use `--env <path>` 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
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.

# Remove the files we dont need here
$ git rm -rf .
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`:

# Create a .nojekyll file, this turns off pesky github pages stuff
$ touch .nojekyll
$ git commit --am "github pages initial commit"
```sh
npx @pkgjs/statusboard create my-statusboard --yes --orgs pkgjs,nodejs \
--repositories expressjs/express --labels "help wanted,bug" --github-actions --no-git
```

# Now we setup the branch as a working tree on the master branch
$ git checkout master
$ mkdir build
$ git worktree add build gh-pages
These options also work interactively: only unspecified settings are prompted.
Use `--labels ""` for no issue labels or `--no-github-actions` to skip the workflow.

# Now we can run the index and build
$ npm run build
Set `GITHUB_TOKEN` in your environment or in a local `.env` file, then run:

# 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
```sh
npm run build
```

The site is generated in `build/`. Edit `config.js` to customize the board.

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)
Expand Down
15 changes: 13 additions & 2 deletions lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -81,8 +82,18 @@ 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', (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, githubActions: argv['github-actions'] })
})

.command('build', 'Index and build board', SHARED_OPTIONS, async (argv) => {
Expand Down
163 changes: 163 additions & 0 deletions lib/commands/create.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
'use strict'
const inquirer = require('inquirer').default
const path = require('node:path')
const fs = require('node:fs/promises')
const { gitInit } = require('../git')
const packageJson = require('../../package.json')

async function create (opts = {}) {
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 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 })

const config = {
path: appPath,
name: path.basename(appPath),
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)
await createConfigFile(config)
await fs.copyFile(path.join(__dirname, 'template', 'gitignore'), path.join(appPath, '.gitignore'))

// 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',
private: true,
type: 'commonjs',
license: 'MIT',
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: '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' })

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.`)
if (config.githubActions) {
console.log('Enable GitHub Pages with GitHub Actions as the source in your repository settings.')
}
}

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 }),
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
}
}
`, { flag: 'wx' })
}

function transformUserInput (input = '') {
return [input].flat().flatMap(value => value.split(',')).map(value => value.trim()).filter(Boolean)
}

async function createBuildAction (config) {
const workflowDir = path.join(config.path, '.github', 'workflows')
await fs.mkdir(workflowDir, { recursive: true })
await fs.copyFile(path.join(__dirname, 'template', 'build.yml'), path.join(workflowDir, 'build.yml'))
}

module.exports = create
56 changes: 56 additions & 0 deletions lib/commands/template/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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
id: pages
uses: actions/configure-pages@v5
- uses: actions/setup-node@v4
with:
node-version: 22
- 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 }}
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
14 changes: 14 additions & 0 deletions lib/commands/template/gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# dependencies
node_modules
.pnp
.pnp.js

# production
build

# local env files
.env*.local
.env

# local database
data.db
15 changes: 15 additions & 0 deletions lib/git.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use strict'
const { execFileSync } = require('node:child_process')

module.exports.gitInit = function gitInit (directory) {
const options = { cwd: directory, stdio: 'ignore' }
try {
execFileSync('git', ['init', '-b', 'main'], options)
execFileSync('git', ['add', '-A'], options)
execFileSync('git', ['commit', '-m', 'Initial commit from @pkgjs/statusboard'], options)
return true
} catch {
// Keep the repository and generated files if Git identity is not configured.
return false
}
}
Loading
Loading