diff --git a/README.md b/README.md index 45ff628..34a2f6c 100644 --- a/README.md +++ b/README.md @@ -22,47 +22,48 @@ 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 +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) diff --git a/lib/cli.js b/lib/cli.js index 1b53b26..438f876 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,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) => { diff --git a/lib/commands/create.js b/lib/commands/create.js new file mode 100644 index 0000000..9382778 --- /dev/null +++ b/lib/commands/create.js @@ -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 diff --git a/lib/commands/template/build.yml b/lib/commands/template/build.yml new file mode 100644 index 0000000..fdf148b --- /dev/null +++ b/lib/commands/template/build.yml @@ -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 diff --git a/lib/commands/template/gitignore b/lib/commands/template/gitignore new file mode 100644 index 0000000..8088f34 --- /dev/null +++ b/lib/commands/template/gitignore @@ -0,0 +1,14 @@ +# dependencies +node_modules +.pnp +.pnp.js + +# production +build + +# local env files +.env*.local +.env + +# local database +data.db diff --git a/lib/git.js b/lib/git.js new file mode 100644 index 0000000..3efa51f --- /dev/null +++ b/lib/git.js @@ -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 + } +} diff --git a/test/create.js b/test/create.js new file mode 100644 index 0000000..8f199a5 --- /dev/null +++ b/test/create.js @@ -0,0 +1,269 @@ +'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') + 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']) + }) + + test('rejects an empty directory answer', async () => { + answers.path = ' ' + 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('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') + 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')) + }) +})