From 2f0ddab6e61664a9cd677743a8fab86664a17474 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 11:20:38 -0700 Subject: [PATCH 1/7] Add command-specific CLI parsing and help --- lib/cli/args.js | 115 +++++++++++++++++++++++++++++++++++++ lib/cli/args.test.js | 117 +++++++++++++++++++++++++++++++++++++ lib/cli/help.js | 37 ++++++++++++ lib/cli/options.js | 133 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 402 insertions(+) create mode 100644 lib/cli/args.js create mode 100644 lib/cli/args.test.js create mode 100644 lib/cli/help.js create mode 100644 lib/cli/options.js diff --git a/lib/cli/args.js b/lib/cli/args.js new file mode 100644 index 00000000..7bffc1f5 --- /dev/null +++ b/lib/cli/args.js @@ -0,0 +1,115 @@ +/** + * @import { ArgscloptsParseArgsOptionsConfig } from 'argsclopts' + * @import { CommandName } from './options.js' + * @typedef {Record} CliValues + * @typedef {{ command: CommandName, values: CliValues, helpCommand: CommandName | null }} CliArgs + */ + +import { parseArgs } from 'node:util' +import { commands, isCommand, legacyOptions } from './options.js' + +export class CliUsageError extends Error { + /** + * @param {string} message + * @param {CommandName | null} [command] + */ + constructor (message, command = null) { + super(message) + this.name = 'CliUsageError' + this.command = command + } +} + +/** @type {ArgscloptsParseArgsOptionsConfig} */ +const compatibilityOptions = { + ...commands.build.options, + ...commands.watch.options, + ...commands.serve.options, + ...commands.eject.options, + ...legacyOptions, +} + +/** + * Resolve only a leading command; option values may themselves be command names. + * @param {string[]} args + * @returns {CliArgs} + */ +export function parseCliArgs (args) { + /** @type {CommandName | null} */ + let helpCommand = null + /** @type {CommandName} */ + let command = 'build' + let commandArgs = args + + try { + const first = args[0] + if (first === 'help') { + const target = args[1] + if (target !== undefined && !isCommand(target)) { + throw new CliUsageError(`Unknown command: ${target}`) + } + helpCommand = target ?? null + if (args.length > 2) throw new CliUsageError('Usage: domstack help [command]', helpCommand) + return { command: target ?? 'build', values: { help: true }, helpCommand } + } + + if (first !== undefined && !first.startsWith('-')) { + if (!isCommand(first)) throw new CliUsageError(`Unknown command: ${first}`) + command = first + helpCommand = command + commandArgs = args.slice(1) + } else { + // Tokenize with the legacy vocabulary, then reparse only explicit options + // against the selected command. Defaults from other commands must not leak. + const { tokens } = parseArgs({ args, options: compatibilityOptions, tokens: true, strict: true, allowPositionals: false }) + const modes = new Set(tokens.flatMap(token => token.kind === 'option' && Object.hasOwn(legacyOptions, token.name) ? [token.name] : [])) + if (modes.size > 1) { + throw new CliUsageError(`Conflicting modes: ${[...modes].map(mode => `--${mode}`).join(', ')}`) + } + const mode = [...modes][0] + if (mode !== undefined) { + command = mode === 'watch-only' ? 'watch' : /** @type {CommandName} */ (mode) + helpCommand = command + } + commandArgs = [] + for (const token of tokens) { + if (token.kind === 'option' && !Object.hasOwn(legacyOptions, token.name)) { + // Inline values preserve short groups, repeated options, and values + // beginning with '-' without confusing them with another option. + commandArgs.push(token.value === undefined ? `--${token.name}` : `--${token.name}=${token.value}`) + } else if (token.kind === 'option-terminator') { + commandArgs.push('--') + } + } + if (mode === 'watch-only') commandArgs.unshift('--no-serve') + } + + /** @type {ArgscloptsParseArgsOptionsConfig} */ + const options = commands[command].options + const { values } = parseArgs({ args: commandArgs, options, strict: true, allowPositionals: false }) + if (!values['help'] && !values['version']) validateValues(command, values) + return { command, values, helpCommand } + } catch (error) { + if (error instanceof CliUsageError) throw error + if (error instanceof Error) throw new CliUsageError(error.message, helpCommand) + throw error + } +} + +/** + * @param {CommandName} command + * @param {CliValues} values + */ +function validateValues (command, values) { + if (!values['src']) throw new Error('The src flag is required') + if (command !== 'eject' && !values['dest']) throw new Error('The dest flag is required') + if (command === 'eject' && values['language'] !== 'js' && values['language'] !== 'ts') { + throw new Error('--language must be ts or js') + } + if (values['port'] !== undefined) { + const port = Number(values['port']) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('--port must be an integer between 1 and 65535') + } + } +} diff --git a/lib/cli/args.test.js b/lib/cli/args.test.js new file mode 100644 index 00000000..b0db7338 --- /dev/null +++ b/lib/cli/args.test.js @@ -0,0 +1,117 @@ +/** @import { CommandName } from './options.js' */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { CliUsageError, parseCliArgs } from './args.js' +import { commands } from './options.js' +import { formatCliHelp } from './help.js' + +for (const args of [[], ['build']]) { + test(`default build options: ${JSON.stringify(args)}`, () => { + const parsed = parseCliArgs(args) + assert.equal(parsed.command, 'build') + assert.deepEqual({ ...parsed.values }, { src: 'src', dest: 'public', drafts: false }) + assert.equal(parsed.helpCommand, args.length ? 'build' : null) + }) +} + +/** @type {Array<[string[], CommandName, boolean | undefined]>} */ +const commandCases = [ + [['watch'], 'watch', undefined], + [['watch', '--no-serve'], 'watch', true], + [['serve'], 'serve', undefined], + [['eject'], 'eject', undefined], + [['--watch'], 'watch', undefined], + [['-w'], 'watch', undefined], + [['--watch-only'], 'watch', true], + [['--serve'], 'serve', undefined], + [['--eject'], 'eject', undefined], + [['-e'], 'eject', undefined], +] +for (const [args, command, noServe] of commandCases) { + test(`select command: ${JSON.stringify(args)}`, () => { + const result = parseCliArgs(args) + assert.equal(result.command, command) + assert.equal(result.helpCommand, command) + assert.equal(result.values['no-serve'], noServe) + assert.equal(result.values['eject'], undefined) + assert.equal(result.values['watch'], undefined) + assert.equal(result.values['serve'], undefined) + if (command === 'eject') { + assert.equal(result.values['language'], 'js') + assert.equal(result.values['dest'], undefined) + assert.equal(result.values['drafts'], undefined) + } else { + assert.equal(result.values['language'], undefined) + } + }) +} + +test('legacy normalization preserves values, short groups, order and repeated copies', () => { + const result = parseCliArgs(['--src', 'watch', '-wh', '--copy=--serve', '--copy', 'eject', '-dpublic', '--src=help']) + assert.equal(result.command, 'watch') + assert.equal(result.values['src'], 'help') + assert.equal(result.values['dest'], 'public') + assert.equal(result.values['help'], true) + assert.deepEqual(result.values['copy'], ['--serve', 'eject']) + assert.equal(parseCliArgs(['--src', 'eject']).command, 'build') + assert.equal(parseCliArgs(['--copy=--watch']).command, 'build') + assert.equal(parseCliArgs(['--watch', '--watch']).command, 'watch') + assert.equal(parseCliArgs(['-eh']).command, 'eject') + assert.equal(parseCliArgs(['--']).command, 'build') +}) + +for (const args of [ + ['watc'], ['toString'], ['__proto__'], ['help', 'watc'], ['help', 'build', 'extra'], + ['--src', 'site', 'watch'], ['build', 'extra'], ['--', 'watch'], ['--', '--watch'], + ['build', '--language', 'ts'], ['build', '--yes'], ['build', '--watch'], + ['eject', '--dest', 'public'], ['eject', '--port', '3000'], ['eject', '--verbose'], + ['watch', '--port', '3000'], ['serve', '--no-serve'], ['--no-serve'], + ['--language', 'ts'], ['--yes'], ['--eject', '--dest', 'public'], + ['--serve', '--watch'], ['--eject', '--watch'], ['--watch', '--watch-only'], ['-ew'], + ['--watch-only', '--serve'], ['--port', '3000'], ['--serve', '--port'], + ['--unknown'], ['build', '--src'], ['build', '--src='], ['build', '--dest='], + ['eject', '--language', 'tsx'], ['--eject', '--language', 'tsx'], + ['serve', '--port='], ['serve', '--port=0'], ['serve', '--port=65536'], + ['serve', '--port=3.5'], ['serve', '--port=abc'], +]) { + test(`reject invalid arguments: ${args.join(' ')}`, () => { + assert.throws(() => parseCliArgs(args), CliUsageError) + }) +} + +test('accept port boundaries and legacy serve options', () => { + for (const port of ['1', '3000', '65535']) { + assert.equal(parseCliArgs(['serve', '--port', port]).values['port'], port) + assert.equal(parseCliArgs(['--port', port, '--serve']).values['port'], port) + } +}) + +test('usage errors point to the selected command', () => { + for (const args of [['eject', '--dest', 'public'], ['--eject', '--dest', 'public']]) { + assert.throws(() => parseCliArgs(args), error => error instanceof CliUsageError && error.command === 'eject') + } +}) + +test('root and command help routes share the same renderer', async () => { + assert.deepEqual(parseCliArgs(['help']), { command: 'build', values: { help: true }, helpCommand: null }) + assert.equal(parseCliArgs(['--help']).helpCommand, null) + const root = await formatCliHelp(null, '1.2.3') + assert.match(root, /Usage: domstack \[command\] \[options\]/) + assert.match(root, /Commands:/) + assert.match(root, /--dest/) + assert.doesNotMatch(root, /--language|--yes|--port|--no-serve|@domstack\/static/) + for (const name of Object.keys(commands)) { + const direct = parseCliArgs([name, '--help']) + const help = parseCliArgs(['help', name]) + assert.equal(direct.helpCommand, help.helpCommand) + assert.equal(direct.values['help'], true) + assert.equal(await formatCliHelp(direct.helpCommand, '1.2.3'), await formatCliHelp(help.helpCommand, '1.2.3')) + assert.equal(parseCliArgs([name, '--version']).values['version'], true) + } + const eject = await formatCliHelp('eject', '1.2.3') + assert.match(eject, /Warning: overwrites/) + assert.match(eject, /--language/) + assert.doesNotMatch(eject, /--dest|--port|--verbose/) + assert.equal((eject.match(/default: "js"/g) ?? []).length, 1) +}) diff --git a/lib/cli/help.js b/lib/cli/help.js new file mode 100644 index 00000000..ea1d519d --- /dev/null +++ b/lib/cli/help.js @@ -0,0 +1,37 @@ +/** @import { CommandName } from './options.js' */ + +import { formatHelpText } from 'argsclopts' +import { commands } from './options.js' + +/** + * @param {CommandName | null} command + * @param {string} version + */ +export async function formatCliHelp (command, version) { + const definition = commands[command ?? 'build'] + return formatHelpText({ + name: 'domstack', + version, + options: definition.options, + headerFn: () => command + ? `Usage: domstack ${command} [options]\n\n${definition.description}\n` + : [ + 'Usage: domstack [command] [options]', + '', + 'Build the site once when no command is given.', + '', + 'Commands:', + ...Object.entries(commands).map(([name, entry]) => ` ${name.padEnd(10)}${entry.description.split('\n')[0]}`), + ' help Show help for a command.', + '', + 'Default build options are listed below.', + '', + ].join('\n'), + exampleFn: () => ` Example: ${command ? definition.example : 'domstack --src website --dest public'}\n`, + footerFn: () => [ + command ? 'Run "domstack help" to see all commands.' : 'Run "domstack --help" or "domstack help " for command help.', + '', + `domstack (v${version})`, + ].join('\n'), + }) +} diff --git a/lib/cli/options.js b/lib/cli/options.js new file mode 100644 index 00000000..c26feb13 --- /dev/null +++ b/lib/cli/options.js @@ -0,0 +1,133 @@ +/** + * @import { ArgscloptsParseArgsOptionsConfig } from 'argsclopts' + * @typedef {{ description: string, example: string, options: ArgscloptsParseArgsOptionsConfig }} CommandDefinition + */ + +/** @satisfies {ArgscloptsParseArgsOptionsConfig} */ +const commonOptions = { + help: { + type: 'boolean', + short: 'h', + help: 'show help', + }, + version: { + type: 'boolean', + short: 'v', + help: 'show version information', + }, +} + +/** @satisfies {ArgscloptsParseArgsOptionsConfig} */ +const sourceOptions = { + src: { + type: 'string', + short: 's', + default: 'src', + help: 'path to source directory', + }, +} + +/** @satisfies {ArgscloptsParseArgsOptionsConfig} */ +const buildOptions = { + ...sourceOptions, + dest: { + type: 'string', + short: 'd', + default: 'public', + help: 'path to build destination directory', + }, + ignore: { + type: 'string', + short: 'i', + help: 'comma separated gitignore style ignore string', + }, + drafts: { + type: 'boolean', + default: false, + help: 'build draft pages with the `.draft.{md,js,ts,html}` page suffix', + }, + noEsbuildMeta: { + type: 'boolean', + help: 'skip writing the esbuild metafile to disk', + }, + domstackManifest: { + type: 'boolean', + help: 'write the domstack manifest to disk', + }, + copy: { + type: 'string', + multiple: true, + help: 'path to directories to copy into the destination; can be used multiple times', + }, + verbose: { + type: 'boolean', + help: 'show debug logs, including the build tree and individual copy operations', + }, +} + +/** @satisfies {Record} */ +export const commands = { + build: { + description: 'Build the site once (the default command).', + example: 'domstack build --src website --dest public', + options: { ...buildOptions, ...commonOptions }, + }, + watch: { + description: 'Build, watch, and serve the site with live reload.', + example: 'domstack watch --src website --no-serve', + options: { + ...buildOptions, + 'no-serve': { + type: 'boolean', + help: 'watch and build without serving', + }, + ...commonOptions, + }, + }, + serve: { + description: 'Build once, then serve without watching or live reload.', + example: 'domstack serve --port 8080', + options: { + ...buildOptions, + port: { + type: 'string', + help: 'server port, between 1 and 65535 (default: 3000)', + }, + ...commonOptions, + }, + }, + eject: { + description: 'Extract the default layout, styles, and client, and add their dependencies.\nWarning: overwrites the target files.', + example: 'domstack eject --language ts --src src', + options: { + ...sourceOptions, + language: { + type: 'string', + default: 'js', + help: 'language for ejected files: js or ts', + }, + yes: { + type: 'boolean', + help: 'skip confirmation before ejecting', + }, + ...commonOptions, + }, + }, +} + +/** @typedef {keyof typeof commands} CommandName */ + +/** @satisfies {ArgscloptsParseArgsOptionsConfig} */ +export const legacyOptions = { + eject: { type: 'boolean', short: 'e', help: 'alias for domstack eject' }, + watch: { type: 'boolean', short: 'w', help: 'alias for domstack watch' }, + 'watch-only': { type: 'boolean', help: 'alias for domstack watch --no-serve' }, + serve: { type: 'boolean', help: 'alias for domstack serve' }, +} + +/** @param {string} name + * @returns {name is CommandName} + */ +export function isCommand (name) { + return Object.hasOwn(commands, name) +} From 8775ac1d5de61e400b6b940863c8ad15f63e0c43 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 11:22:46 -0700 Subject: [PATCH 2/7] Wire CLI subcommands with integration coverage and documentation --- README.md | 6 +- bin.js | 147 ++--------- docs/cli/README.md | 114 ++++++--- docs/implementation/README.md | 7 +- docs/layouts/README.md | 2 +- test-cases/cli-errors/commands.test.js | 342 +++++++++++++++++++++++++ test-cases/cli-errors/eject.test.js | 70 ++--- 7 files changed, 485 insertions(+), 203 deletions(-) create mode 100644 test-cases/cli-errors/commands.test.js diff --git a/README.md b/README.md index 01b78a52..9a894e3d 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,10 @@ npx domstack ``` The generated page is `public/index.html`, rendered with the bundled default layout and stylesheet. -Run `npx domstack --watch` to rebuild on changes, then open the local development server's URL. -Use `npx domstack --serve` to preview a production build. +`npx domstack build` is an explicit alias for the default build. +Run `npx domstack watch` to rebuild on changes, then open the local development server's URL for live reload. +Use `npx domstack watch --no-serve` to watch without a server, or `npx domstack serve` to build once and preview production output without watching or live reload. +See the [CLI reference](docs/cli/) for command options and ejecting the defaults. ## Links diff --git a/bin.js b/bin.js index 45e4be92..7d8ac506 100755 --- a/bin.js +++ b/bin.js @@ -2,15 +2,14 @@ /** * @import { BuildStepWarnings, DomStackOpts as DomStackOpts } from './lib/builder.js' - * @import { ArgscloptsParseArgsOptionsConfig } from 'argsclopts' + * @import { Logger as PinoLogger } from 'pino' * @import { BsInstance } from '@domstack/sync' */ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { basename, resolve, join, relative } from 'node:path' -import { parseArgs } from 'node:util' -import { printHelpText } from 'argsclopts' + import readline from 'node:readline' import process from 'process' // @ts-expect-error @@ -26,6 +25,8 @@ import { DomStackAggregateError } from './lib/helpers/domstack-aggregate-error.j import { generateTreeData } from './lib/helpers/generate-tree-data.js' import { askYesNo } from './lib/helpers/cli-prompt.js' import { createDomStackLogger } from './lib/logger.js' +import { CliUsageError, parseCliArgs } from './lib/cli/args.js' +import { formatCliHelp } from './lib/cli/help.js' const __dirname = import.meta.dirname @@ -36,94 +37,8 @@ async function getPkg (pkgPath = resolve(__dirname, './package.json')) { return pkg } -/** @type {ArgscloptsParseArgsOptionsConfig} */ -const options = { - src: { - type: 'string', - short: 's', - default: 'src', - help: 'path to source directory', - }, - dest: { - type: 'string', - short: 'd', - default: 'public', - help: 'path to build destination directory', - }, - ignore: { - type: 'string', - short: 'i', - help: 'comma separated gitignore style ignore string', - }, - drafts: { - type: 'boolean', - help: 'Build draft pages with the `.draft.{md,js,html}` page suffix.', - default: false - }, - noEsbuildMeta: { - type: 'boolean', - help: 'skip writing the esbuild metafile to disk', - }, - domstackManifest: { - type: 'boolean', - help: 'write the domstack manifest to disk', - }, - - eject: { - type: 'boolean', - short: 'e', - help: 'eject the DOMStack default layout, style and client into the src flag directory', - }, - language: { - type: 'string', - default: 'js', - help: 'language for --eject: ts or js (default: js)', - }, - yes: { - type: 'boolean', - help: 'skip confirmation for --eject', - }, - watch: { - type: 'boolean', - short: 'w', - help: 'build, watch and serve the site build', - }, - 'watch-only': { - type: 'boolean', - help: 'watch and build the src folder without serving', - }, - verbose: { - type: 'boolean', - help: 'show debug logs, including the build tree and individual copy operations', - }, - serve: { - type: 'boolean', - help: 'build once and serve the destination directory without watching', - }, - port: { - type: 'string', - help: 'port for --serve (default: 3000)', - }, - copy: { - type: 'string', - help: 'path to directories to copy into dist; can be used multiple times', - multiple: true - }, - help: { - type: 'boolean', - short: 'h', - help: 'show help', - }, - version: { - type: 'boolean', - short: 'v', - help: 'show version information', - }, -} - -const { values: argv } = parseArgs({ options }) - async function run () { + const { command, values: argv, helpCommand } = parseCliArgs(process.argv.slice(2)) if (argv['version']) { const pkg = await getPkg() console.log(pkg.version) @@ -132,30 +47,15 @@ async function run () { if (argv['help']) { const pkg = await getPkg() - await printHelpText({ - options, - name: pkg.name, - version: pkg.version, - exampleFn: ({ name }) => ' ' + `Example: ${name} --src website --dest public\n`, - }) + console.log(await formatCliHelp(helpCommand, pkg.version)) process.exit(0) } const cwd = process.cwd() - const srcFlag = String(argv['src']) - const destFlag = String(argv['dest']) - if (!srcFlag) throw new Error('The src flag is required') - if (!destFlag) throw new Error('The dest flag is required') + const src = resolve(join(cwd, String(argv['src']))) - const src = resolve(join(cwd, srcFlag)) - const dest = resolve(join(cwd, destFlag)) - - // Eject task - if (argv['eject']) { + if (command === 'eject') { const language = argv['language'] - if (language !== 'ts' && language !== 'js') { - throw new Error('--language must be ts or js') - } const localPkg = await packageDirectory({ cwd: src }) @@ -236,6 +136,7 @@ domstack eject actions: process.exit(0) } + const dest = resolve(join(cwd, String(argv['dest']))) /** @type {DomStackOpts} */ const opts = {} @@ -255,13 +156,7 @@ domstack eject actions: /** @type {BsInstance | null} */ let buildServer = null - if (argv['serve'] && (argv['watch'] || argv['watch-only'])) { - throw new Error('--serve cannot be combined with --watch or --watch-only') - } - if (argv['port'] && !argv['serve']) { - throw new Error('--port can only be combined with --serve') - } - const servePort = argv['port'] ? parsePort(String(argv['port'])) : undefined + const servePort = argv['port'] ? Number(argv['port']) : undefined process.once('SIGINT', quit) process.once('SIGTERM', quit) @@ -280,14 +175,14 @@ domstack eject actions: process.exit(0) } - if (!argv['watch'] && !argv['watch-only']) { + if (command !== 'watch') { try { const results = await domStack.build() logger.debug(tree(generateTreeData(cwd, src, dest, results))) logWarnings(logger, results?.warnings) logger.info(`Built ${relative(cwd, src) || '.'} → ${relative(cwd, dest) || '.'}`) logger.info('Build Success!') - if (argv['serve']) { + if (command === 'serve') { buildServer = await createServer({ server: dest, files: basename(dest), @@ -311,7 +206,7 @@ domstack eject actions: } } else { await domStack.watch({ - serve: !argv['watch-only'], + serve: !argv['no-serve'], onInitialBuild: (initialResults) => { logger.debug(tree(generateTreeData(cwd, src, dest, initialResults))) logWarnings(logger, initialResults?.warnings) @@ -320,17 +215,6 @@ domstack eject actions: } } -/** - * @param {string} value - */ -function parsePort (value) { - const port = Number(value) - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error('--port must be an integer between 1 and 65535') - } - return port -} - /** * @param {PinoLogger} logger * @param {BuildStepWarnings | undefined} warnings @@ -363,6 +247,11 @@ function formatDiagnostic (value, colors) { } run().catch(err => { + if (err instanceof CliUsageError) { + console.error(`domstack: ${err.message}`) + console.error(`Run "domstack${err.command ? ` ${err.command}` : ''} --help" for usage.`) + process.exit(1) + } console.error(formatDiagnostic( new Error('Unhandled domstack error', { cause: err }), Boolean(process.stderr.isTTY) diff --git a/docs/cli/README.md b/docs/cli/README.md index 5c7311fe..487550ec 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -7,7 +7,7 @@ handlebars: false # CLI Use `domstack` (or its shorter alias, `dom`) to build a site, watch for changes, or preview production output. -The options below control source and destination directories, asset copying, and the development server. +Use `domstack eject` to extract the bundled defaults for customization. ## Table of Contents @@ -15,42 +15,88 @@ The options below control source and destination directories, asset copying, and ## Usage -```console -$ domstack --help -Usage: domstack [options] - - Example: domstack --src website --dest public - - --src, -s path to source directory (default: "src") - --dest, -d path to build destination directory (default: "public") - --ignore, -i comma separated gitignore style ignore string - --drafts Build draft pages with the `.draft.{md,js,ts,html}` page suffix. - --noEsbuildMeta skip writing the esbuild metafile to disk - --domstackManifest write the domstack manifest to disk - --eject, -e eject the DOMStack default layout, style and client into the src flag directory - --language language for --eject: ts or js (default: js) - --yes skip confirmation for --eject - --watch, -w build, watch and serve the site build - --watch-only watch and build the src folder without serving - --verbose show debug logs, including the build tree and individual copy operations - --serve build once and serve the destination directory without watching - --port port for --serve (default: 3000) - --copy path to directories to copy into dist; can be used multiple times - --help, -h show help - --version, -v show version information -domstack (v12.0.0) +```sh +domstack +domstack build --src website --dest public +domstack watch --src site +domstack watch --no-serve +domstack serve --port 3000 +domstack eject --language ts ``` -`domstack` builds a `src` directory into a `dest` directory (default: `public`). +`domstack` builds `src` into `public` by default; `domstack build` is an explicit alias for the same one-shot build. +When using an explicit command, put the command before its options, as in `domstack watch --src site`, not `domstack --src site watch`. + +| Command | Behavior | +| --- | --- | +| `domstack` or `domstack build` | Build once and exit. | +| `domstack watch` | Build, watch for changes, and serve with live reload using [`@domstack/sync`][domstack-sync]. | +| `domstack watch --no-serve` | Build and watch without starting a server. | +| `domstack serve` | Build once, then serve production output without watching or live reload. | +| `domstack eject` | Extract the default layout, global styles, and client files into the source directory and update dependencies in `package.json`. | + +Use `watch` for development and `serve` to preview production output, including manifest-driven service-worker caching. +`serve` always builds first; it is not a server-only command for an existing destination. +There is no `dev` alias. + +### Shared build, watch, and serve options + +These options are available on the default build and on the explicit `build`, `watch`, and `serve` commands. + +| Option | Description | +| --- | --- | +| `--src `, `-s ` | Source directory (default: `src`). | +| `--dest `, `-d ` | Build destination directory (default: `public`). | +| `--ignore `, `-i ` | Comma-separated gitignore-style ignore patterns. | +| `--drafts` | Include draft pages with the `.draft.{md,js,ts,html}` page suffix. | +| `--noEsbuildMeta` | Skip writing the esbuild metafile to disk. | +| `--domstackManifest` | Write the DOMStack manifest to disk for a one-shot build; watch mode does not finalize or write the manifest. | +| `--copy ` | Copy an additional directory into the destination; repeat for multiple directories. | +| `--verbose` | Show debug logs, including the build tree and individual copy operations. | + +For example, `domstack build --copy images --copy downloads` copies both additional directories. + +### Command-specific options + +| Command | Option | Description | +| --- | --- | --- | +| `watch` | `--no-serve` | Watch and rebuild without a server, for example when another process serves the output. | +| `serve` | `--port ` | Server port, an integer from `1` to `65535` (default: `3000`). | +| `eject` | `--src `, `-s ` | Source directory to receive the defaults (default: `src`). | +| `eject` | `--language ` | Eject `js` (default) or `ts` files. | +| `eject` | `--yes` | Skip confirmation before writing files and updating dependencies. | + +`--port` is available only for `serve`, not `watch` or `build`. +Apart from help and version, `eject` accepts only `--src` / `-s`, `--language`, and `--yes`; shared build options such as `--dest` and `--verbose` are not accepted. + +### Help and version + +All commands support `--help` / `-h` and `--version` / `-v`. +`domstack --help` and `domstack help` show the command list and the default build options. +`domstack help ` is equivalent to `domstack --help`, for example `domstack help watch` and `domstack watch --help`. + +### Legacy shortcuts + +Root-level mode flags remain supported for existing scripts, but prefer commands for new usage. + +| Legacy shortcut | Preferred command | +| --- | --- | +| `domstack --watch` or `domstack -w` | `domstack watch` | +| `domstack --watch-only` | `domstack watch --no-serve` | +| `domstack --serve` | `domstack serve` | +| `domstack --eject` or `domstack -e` | `domstack eject` | + +Legacy shortcuts use the same strict option validation as their target commands. +For example, `domstack --serve --port 4000` is valid, but `domstack --watch --port 4000` and `domstack --eject --dest public` are rejected. +Mode flags are mutually exclusive, including `--watch` together with `--watch-only`. +Legacy mode flags are not accepted on explicit commands, so use `domstack watch`, not `domstack build --watch`. + +### Build output Normal output summarizes builds, static asset startup, and server URLs. Use `--verbose` to include the build tree and individual copy operations. Build failures retain their full diagnostics at either verbosity level. -- Running `domstack` will result in a `build` by default. -- Running `domstack --watch` or `domstack -w` will build the site and start an auto-reloading development web-server that watches for changes (provided by [`@domstack/sync`][domstack-sync]). - -- Running `domstack --eject` or `domstack -e` will extract the default layout, global styles, and client-side JavaScript into your source directory and add the necessary dependencies to your package.json. `domstack` is a devtool. It's primarily a unix `bin` written for the [Node.js](https://nodejs.org) runtime that is intended to be installed from `npm` as a `devDependency` inside a `package.json` committed to a `git` repository. @@ -58,10 +104,10 @@ It can be used outside of this context, but it works best within it. ## Ejecting the defaults -The `--eject` (or `-e`) flag extracts DOMStack's default layout, global CSS, and client-side JavaScript into your source directory. +The `domstack eject` command extracts DOMStack's default layout, global CSS, and client-side JavaScript into your source directory. This allows you to fully customize these files while maintaining the same functionality. -When you run `domstack --eject`, it will: +When you run `domstack eject`, it will: 1. Create a default root layout file at `layouts/root.layout.js` (or `.mjs` depending on your package.json type) @@ -75,7 +121,7 @@ When you run `domstack --eject`, it will: - fragtml - highlight.js -Use `domstack --eject --language ts` to write `layouts/root.layout.ts` and `globals/global.client.ts` instead. +Use `domstack eject --language ts` to write `layouts/root.layout.ts` and `globals/global.client.ts` instead. For packages without `"type": "module"`, the TypeScript layout uses `.mts` so Node loads it as ESM without changing your package type. The CSS and added dependencies are the same for both languages. JavaScript remains the default (`--language js`), with `.js` files in module packages and `.mjs` files otherwise. @@ -87,7 +133,7 @@ The TypeScript output uses the public type-only `@domstack/static/types.js` entr Keep `@domstack/static` installed for those types; no runtime type import or separate TypeScript compilation step is needed. The client is currently comment-only, but receives a `.ts` extension when TypeScript is selected. -For automation, run `domstack --eject --language ts --yes --src src` to skip the confirmation prompt. +For automation, run `domstack eject --language ts --yes --src src` to skip the confirmation prompt. Without `--yes`, eject asks for confirmation before writing files or updating dependencies. Eject overwrites its target files, so review or back up existing customizations before proceeding. diff --git a/docs/implementation/README.md b/docs/implementation/README.md index 0a789f50..6514d50e 100644 --- a/docs/implementation/README.md +++ b/docs/implementation/README.md @@ -28,6 +28,7 @@ The idea is that they can be swapped out for better tools in the future if they ## Build process flow +`domstack` and `domstack build` run a one-shot build; `domstack serve` runs the same production build before serving the output without watching or live reload. The one-shot builder discovers inputs using the shared file conventions, then records outputs from each build phase. The service worker is built last so manifest hooks can provide its build-time constants. Side-by-side blocks run in parallel; their arrows join before the next phase starts. @@ -180,8 +181,8 @@ It is resolved separately and projected into each consumer's `data` argument acc ## Watch mode -Running `domstack --watch` or `domstack -w` performs an initial build, watches the source inputs, and serves `dest` with live reload. -Use `domstack --watch-only` when another process serves the output. +Running `domstack watch` performs an initial build, watches the source inputs, and serves `dest` with live reload. +Use `domstack watch --no-serve` when another process serves the output. Watch mode coordinates three independent watchers: @@ -324,7 +325,7 @@ Page HTML points to stable entry files during watch mode. esbuild can update an Watch mode builds and rebundles the site service worker, but it does not finalize, return, or write the [DOMStack manifest](../../docs/workers/#domstack-manifest). Editing `domstack-manifest.settings.ts` does not trigger a watch rebuild unless it is also imported by server-side code. -Use `domstack --serve` when testing manifest-driven cache behavior. +Use `domstack serve` when testing manifest-driven cache behavior. It runs a one-shot build and serves the result without watch-mode filenames or live-reload HTML injection. Add `--domstackManifest` only when the service worker or test needs the public `domstack-manifest.json` file. diff --git a/docs/layouts/README.md b/docs/layouts/README.md index 47e21f83..e2a2c445 100644 --- a/docs/layouts/README.md +++ b/docs/layouts/README.md @@ -261,7 +261,7 @@ If your `src` folder doesn't have a root layout in any supported JavaScript or T Both files ship in the package, but the runtime loads `default.root.layout.js` directly without TypeScript stripping or a custom loader. To wrap or reuse the upstream layout, import `@domstack/static/lib/defaults/default.root.layout.js`, not the `.ts` source; Node does not strip TypeScript inside `node_modules`. The JavaScript has no runtime imports of DOMStack's private types. -Use [`domstack --eject --language ts` or `--language js`](../cli/README.md#ejecting-the-defaults) to customize it in your preferred language. +Use [`domstack eject --language ts` or `domstack eject --language js`](../cli/README.md#ejecting-the-defaults) to customize it in your preferred language. The default `root` layout includes a special boolean variable called `defaultStyle` that lets you disable a default page style (provided by [mine.css](http://github.com/bcomnes/mine.css)) that it ships with. ## Layout styles diff --git a/test-cases/cli-errors/commands.test.js b/test-cases/cli-errors/commands.test.js new file mode 100644 index 00000000..ef5012f8 --- /dev/null +++ b/test-cases/cli-errors/commands.test.js @@ -0,0 +1,342 @@ +/** + * @import { TestContext } from 'node:test' + */ +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { get } from 'node:http' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import test from 'node:test' +import { setTimeout as delay } from 'node:timers/promises' + +const bin = resolve(import.meta.dirname, '../../bin.js') +const commands = ['build', 'watch', 'serve', 'eject'] +/** @type {Array<[string, string]>} */ +const legacyModes = [ + ['--eject', 'eject'], ['-e', 'eject'], + ['--watch', 'watch'], ['-w', 'watch'], + ['--watch-only', 'watch'], ['--serve', 'serve'], +] + +/** @param {TestContext} t */ +async function workspace (t) { + const cwd = await mkdtemp(join(tmpdir(), 'domstack-commands-')) + t.after(() => rm(cwd, { recursive: true, force: true })) + return cwd +} + +/** + * @param {string} cwd + * @param {string[]} args + */ +function cli (cwd, args) { + const result = spawnSync(process.execPath, [bin, ...args], { + cwd, encoding: 'utf8', input: '', timeout: 15_000, killSignal: 'SIGKILL', + }) + assert.ifError(result.error) + assert.equal(result.signal, null, result.stdout + result.stderr) + return result +} + +/** + * @param {string} cwd + * @param {string[]} args + */ +function help (cwd, args) { + const result = cli(cwd, args) + assert.equal(result.status, 0, result.stdout + result.stderr) + assert.equal(result.stderr, '') + assert.match(result.stdout, /\bdomstack\b/) + assert.doesNotMatch(result.stdout, /@domstack\/static/) + return result.stdout +} + +/** @param {string} text */ +function optionListing (text) { + return text.split('\n').filter(line => /^\s+-{1,2}[a-z]/i.test(line)).join('\n') +} + +/** + * @param {string} cwd + * @param {string[]} args + */ +function invalid (cwd, args) { + const result = cli(cwd, args) + assert.notEqual(result.status, 0, `accepted ${args.join(' ')}`) + assert.match(result.stderr, /\S/) + assert.doesNotMatch(result.stdout + result.stderr, /Unhandled|\n\s+at\s|node:internal/) + return result +} + +test('root help lists commands and default build options without requiring or writing a project', async t => { + const cwd = await workspace(t) + const rootHelp = help(cwd, ['--help']) + assert.equal(rootHelp, help(cwd, ['help'])) + assert.match(rootHelp, /commands/i) + assert.match(rootHelp, /default/i) + for (const command of commands) assert.match(rootHelp, new RegExp(`\\b${command}\\b`)) + const options = optionListing(rootHelp) + for (const option of ['src', 'dest', 'copy', 'drafts', 'noEsbuildMeta', 'domstackManifest', 'verbose']) { + assert.ok(options.includes(`--${option}`), `missing default build option --${option}`) + } + assert.doesNotMatch(options, /--(?:language|yes|eject|no-serve|port)\b/) + assert.deepEqual(await readdir(cwd), []) +}) + +test('command help aliases and legacy help use the target command options without side effects', async t => { + const cwd = await workspace(t) + const commandHelp = new Map() + for (const command of commands) { + const text = help(cwd, [command, '--help']) + commandHelp.set(command, text) + assert.equal(text, help(cwd, ['help', command])) + assert.equal(text, help(cwd, [command, '-h'])) + const options = optionListing(text) + assert.match(options, /--src\b/) + if (command === 'eject') { + assert.match(options, /--language\b/) + assert.match(options, /--yes\b/) + assert.doesNotMatch(options, /--(?:dest|copy|verbose|drafts|port|no-serve)\b/) + } else { + assert.match(options, /--dest\b/) + assert.doesNotMatch(options, /--(?:language|yes|eject)\b/) + } + if (command === 'watch') assert.match(options, /--no-serve\b/) + else assert.doesNotMatch(options, /--no-serve\b/) + if (command === 'serve') assert.match(options, /--port\b/) + else assert.doesNotMatch(options, /--port\b/) + } + for (const [flag, command] of legacyModes) { + assert.equal(help(cwd, [flag, '--help']), commandHelp.get(command), flag) + assert.equal(help(cwd, ['--help', flag]), commandHelp.get(command), `help before ${flag}`) + } + assert.deepEqual(await readdir(cwd), []) +}) + +test('root and every command expose the package version without a project', async t => { + const cwd = await workspace(t) + const { version } = JSON.parse(await readFile(resolve(import.meta.dirname, '../../package.json'), 'utf8')) + for (const args of [[], ...commands.map(command => [command]), ...legacyModes.map(([flag]) => [flag])]) { + const result = cli(cwd, [...args, '--version']) + assert.equal(result.status, 0, result.stdout + result.stderr) + assert.equal(result.stdout.trim(), version) + assert.equal(result.stderr, '') + } + assert.deepEqual(await readdir(cwd), []) +}) + +for (const args of [['dev'], ['buid'], ['help', 'unknown']]) { + test(`unknown command ${args.join(' ')} has a concise error and help hint`, async t => { + const cwd = await workspace(t) + const result = invalid(cwd, args) + assert.match(result.stderr, /Unknown command/i) + assert.match(result.stderr, /domstack\s+(?:--help|help)/) + assert.ok(result.stderr.trim().split('\n').length <= 5, result.stderr) + assert.deepEqual(await readdir(cwd), []) + }) +} + +test('options are strict, command-specific, and commands must come first', async t => { + const cwd = await workspace(t) + const cases = [ + ['--unknown'], ['build', '--unknown'], + ['--language', 'js'], ['--yes'], ['--port', '3000'], ['--no-serve'], + ['build', '--language', 'js'], ['watch', '--yes'], ['serve', '--language', 'ts'], + ['build', '--port', '3000'], ['watch', '--port', '3000'], + ['build', '--no-serve'], ['serve', '--no-serve'], + ['build', '--src'], ['serve', '--port'], + ['serve', '--port', '0'], ['serve', '--port', '65536'], ['serve', '--port', 'abc'], + ['--src', 'src', 'build'], ['build', 'watch'], + ['build', '--watch'], ['watch', '--watch-only'], ['serve', '--serve'], ['eject', '--eject'], + ['--watch', '--port', '3000'], ['-w', '--yes'], + ['--watch-only', '--language', 'js'], ['--serve', '--no-serve'], + ] + for (const args of cases) { + await t.test(args.join(' '), () => { invalid(cwd, args) }) + } + assert.deepEqual(await readdir(cwd), []) +}) + +test('legacy modes conflict instead of silently choosing a command', async t => { + const cwd = await workspace(t) + const modes = ['--eject', '--watch', '--watch-only', '--serve'] + for (const [index, mode] of modes.entries()) { + for (const other of modes.slice(index + 1)) { + await t.test(`${mode} ${other}`, () => { invalid(cwd, [mode, other]) }) + } + } + invalid(cwd, ['-e', '-w']) + assert.deepEqual(await readdir(cwd), []) +}) + +test('invalid eject options are rejected before prompting, writing files, or changing dependencies', async t => { + const cwd = await workspace(t) + const pkg = '{"type":"module","dependencies":{"retained":"1.0.0"}}\n' + await writeFile(join(cwd, 'package.json'), pkg) + await mkdir(join(cwd, 'src')) + await writeFile(join(cwd, 'src', 'page.html'), '

Keep this page

') + for (const mode of ['eject', '--eject', '-e']) { + for (const options of [['--dest', 'output'], ['--verbose'], ['--language', 'tsx']]) { + const result = invalid(cwd, [mode, '--yes', ...options]) + assert.doesNotMatch(result.stdout + result.stderr, /Continue\?|Done ejecting/) + assert.equal(await readFile(join(cwd, 'package.json'), 'utf8'), pkg) + assert.deepEqual((await readdir(cwd)).sort(), ['package.json', 'src']) + assert.deepEqual(await readdir(join(cwd, 'src')), ['page.html']) + assert.equal(await readFile(join(cwd, 'src', 'page.html'), 'utf8'), '

Keep this page

') + } + } +}) + +for (const explicit of [false, true]) { + test(`${explicit ? 'explicit build' : 'default command'} builds real HTML and exits`, async t => { + const cwd = await workspace(t) + const src = explicit ? 'website' : 'src' + const dest = explicit ? 'output' : 'public' + await mkdir(join(cwd, src)) + await writeFile(join(cwd, src, 'page.html'), '

Command build fixture

') + const result = cli(cwd, explicit ? ['build', '-s', src, '-d', dest] : []) + assert.equal(result.status, 0, result.stdout + result.stderr) + assert.match(await readFile(join(cwd, dest, 'index.html'), 'utf8'), /

Command build fixture<\/h1>/) + }) +} + +/** + * @param {string} cwd + * @param {string[]} args + */ +function startCli (cwd, args) { + const child = spawn(process.execPath, [bin, ...args], { + cwd, stdio: ['ignore', 'pipe', 'pipe'], timeout: 25_000, killSignal: 'SIGKILL', + }) + let output = '' + let exited = false + child.stdout.setEncoding('utf8').on('data', chunk => { output += chunk }) + child.stderr.setEncoding('utf8').on('data', chunk => { output += chunk }) + child.on('error', error => { output += String(error) }) + const closed = new Promise(resolve => child.once('close', (code, signal) => { + exited = true + resolve({ code, signal }) + })) + return { child, closed, output: () => output, exited: () => exited } +} + +/** @param {ReturnType} running */ +async function stopCli (running) { + if (running.exited()) return running.closed + running.child.kill('SIGTERM') + const fallback = setTimeout(() => running.child.kill('SIGKILL'), 5000) + try { + return await running.closed + } finally { + clearTimeout(fallback) + } +} + +/** + * @param {ReturnType} running + * @param {() => Promise} check + * @param {string} description + */ +async function until (running, check, description) { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + assert.equal(running.exited(), false, running.output()) + if (await check()) return + await delay(50) + } + assert.fail(`Timed out waiting for ${description}\n${running.output()}`) +} + +/** @param {string} path */ +async function outputHtml (path) { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return '' + throw error + } +} + +/** @param {number} [port] */ +async function availablePort (port = 0) { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(port, '127.0.0.1', () => resolve(undefined)) + }) + try { + const address = server.address() + assert.ok(address && typeof address !== 'string') + return address.port + } finally { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve(undefined))) + } +} + +/** @param {number} port */ +function requestHtml (port) { + return new Promise((resolve, reject) => { + const request = get(`http://127.0.0.1:${port}/`, { agent: false }, response => { + let body = '' + response.setEncoding('utf8') + response.on('data', chunk => { body += chunk }) + response.on('error', reject) + response.on('end', () => resolve({ status: response.statusCode, body })) + }) + request.on('error', reject) + request.setTimeout(1000, () => request.destroy(new Error('HTTP request timed out'))) + }) +} + +test('watch --no-serve rebuilds and cleans up on SIGTERM', { timeout: 30_000 }, async t => { + const cwd = await workspace(t) + await mkdir(join(cwd, 'src')) + const source = join(cwd, 'src', 'page.html') + const output = join(cwd, 'public', 'index.html') + await writeFile(source, '

Before watch edit

') + const running = startCli(cwd, ['watch', '--no-serve']) + try { + await until(running, async () => (await outputHtml(output)).includes('Before watch edit'), 'initial watch build') + await writeFile(source, '

After watch edit

') + await until(running, async () => (await outputHtml(output)).includes('After watch edit'), 'watch rebuild') + assert.doesNotMatch(running.output(), /https?:\/\/(?:localhost|127\.0\.0\.1):|\[domstack-sync\]/) + assert.deepEqual(await stopCli(running), { code: 0, signal: null }, running.output()) + assert.match(running.output(), /Watching stopped/) + } finally { + await stopCli(running) + } +}) + +test('serve builds once, serves production HTML on the requested port, and cleans up on SIGTERM', { timeout: 30_000 }, async t => { + const cwd = await workspace(t) + await mkdir(join(cwd, 'src')) + const source = join(cwd, 'src', 'page.html') + const output = join(cwd, 'public', 'index.html') + await writeFile(source, '

Production serve fixture

') + const port = await availablePort() + const running = startCli(cwd, ['serve', '--port', String(port)]) + try { + await until(running, async () => { + try { + return (await requestHtml(port)).status === 200 + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ECONNREFUSED') return false + throw error + } + }, 'HTTP server') + const html = await readFile(output, 'utf8') + assert.match(html, /

Production serve fixture<\/h1>/) + assert.deepEqual(await requestHtml(port), { status: 200, body: html }, 'production responses must not inject live reload') + await writeFile(source, '

Source changed after serving

') + // Give an accidental watcher time to rebuild; serve must preserve its one-shot output. + await delay(750) + assert.equal(await readFile(output, 'utf8'), html) + assert.deepEqual(await requestHtml(port), { status: 200, body: html }) + assert.deepEqual(await stopCli(running), { code: 0, signal: null }, running.output()) + assert.equal(await availablePort(port), port, 'SIGTERM releases the listening port') + } finally { + await stopCli(running) + } +}) diff --git a/test-cases/cli-errors/eject.test.js b/test-cases/cli-errors/eject.test.js index b6a751ee..2a7391cd 100644 --- a/test-cases/cli-errors/eject.test.js +++ b/test-cases/cli-errors/eject.test.js @@ -10,38 +10,40 @@ const exec = promisify(execFile) const project = resolve(import.meta.dirname, '../..') const bin = join(project, 'bin.js') -for (const type of ['module', 'commonjs']) { - for (const language of [undefined, 'js', 'ts']) { - test(`eject ${language ?? 'default'} into ${type} package and build`, async t => { - const cwd = await mkdtemp(join(tmpdir(), 'domstack-eject-')) - t.after(() => rm(cwd, { recursive: true, force: true })) - await writeFile(join(cwd, 'package.json'), JSON.stringify({ type, dependencies: { retained: '1.0.0' } })) - await mkdir(join(cwd, 'src')) - await writeFile(join(cwd, 'src/page.html'), '

Ejected site

') - await symlink(join(project, 'node_modules'), join(cwd, 'node_modules'), 'dir') - const args = [bin, '--eject', '--yes', ...(language ? ['--language', language] : [])] - const { stdout } = await exec(process.execPath, args, { cwd, timeout: 30000 }) - assert.match(stdout, /Done ejecting files!/) - assert.doesNotMatch(stdout, /Continue\?/) - const extension = language === 'ts' ? (type === 'module' ? 'ts' : 'mts') : type === 'module' ? 'js' : 'mjs' - const layout = await readFile(join(cwd, `src/layouts/root.layout.${extension}`), 'utf8') - const canonical = await readFile(join(project, 'lib/defaults/default.root.layout.ts'), 'utf8') - assert.equal(layout, language === 'ts' - ? canonical.replace("from '#types'", "from '@domstack/static/types.js'") - : await readFile(join(project, 'lib/defaults/default.root.layout.js'), 'utf8')) - assert.doesNotMatch(layout, /#types/) - assert.equal(await readFile(join(cwd, `src/globals/global.client.${language === 'ts' ? 'ts' : extension}`), 'utf8'), - await readFile(join(project, 'lib/defaults/default.client.js'), 'utf8')) - assert.equal(await readFile(join(cwd, 'src/globals/global.css'), 'utf8'), - await readFile(join(project, 'lib/defaults/default.style.css'), 'utf8')) - const pkg = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) - assert.equal(pkg.dependencies.retained, '1.0.0') - for (const dependency of ['mine.css', 'fragtml', 'highlight.js']) { - assert.ok(pkg.dependencies[dependency]) - } - await exec(process.execPath, [bin], { cwd, timeout: 30000 }) - assert.match(await readFile(join(cwd, 'public/index.html'), 'utf8'), /

Ejected site<\/h1>/) - }) +for (const mode of ['eject', '--eject']) { + for (const type of ['module', 'commonjs']) { + for (const language of [undefined, 'js', 'ts']) { + test(`${mode} ${language ?? 'default'} into ${type} package and build`, async t => { + const cwd = await mkdtemp(join(tmpdir(), 'domstack-eject-')) + t.after(() => rm(cwd, { recursive: true, force: true })) + await writeFile(join(cwd, 'package.json'), JSON.stringify({ type, dependencies: { retained: '1.0.0' } })) + await mkdir(join(cwd, 'src')) + await writeFile(join(cwd, 'src/page.html'), '

Ejected site

') + await symlink(join(project, 'node_modules'), join(cwd, 'node_modules'), 'dir') + const args = [bin, mode, '--yes', ...(language ? ['--language', language] : [])] + const { stdout } = await exec(process.execPath, args, { cwd, timeout: 30000 }) + assert.match(stdout, /Done ejecting files!/) + assert.doesNotMatch(stdout, /Continue\?/) + const extension = language === 'ts' ? (type === 'module' ? 'ts' : 'mts') : type === 'module' ? 'js' : 'mjs' + const layout = await readFile(join(cwd, `src/layouts/root.layout.${extension}`), 'utf8') + const canonical = await readFile(join(project, 'lib/defaults/default.root.layout.ts'), 'utf8') + assert.equal(layout, language === 'ts' + ? canonical.replace("from '#types'", "from '@domstack/static/types.js'") + : await readFile(join(project, 'lib/defaults/default.root.layout.js'), 'utf8')) + assert.doesNotMatch(layout, /#types/) + assert.equal(await readFile(join(cwd, `src/globals/global.client.${language === 'ts' ? 'ts' : extension}`), 'utf8'), + await readFile(join(project, 'lib/defaults/default.client.js'), 'utf8')) + assert.equal(await readFile(join(cwd, 'src/globals/global.css'), 'utf8'), + await readFile(join(project, 'lib/defaults/default.style.css'), 'utf8')) + const pkg = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) + assert.equal(pkg.dependencies.retained, '1.0.0') + for (const dependency of ['mine.css', 'fragtml', 'highlight.js']) { + assert.ok(pkg.dependencies[dependency]) + } + await exec(process.execPath, [bin], { cwd, timeout: 30000 }) + assert.match(await readFile(join(cwd, 'public/index.html'), 'utf8'), /

Ejected site<\/h1>/) + }) + } } } @@ -51,7 +53,7 @@ test('eject still asks for confirmation and respects a declined prompt', async t const pkg = '{"type":"module"}' await writeFile(join(cwd, 'package.json'), pkg) await mkdir(join(cwd, 'src')) - const result = spawnSync(process.execPath, [bin, '--eject'], { + const result = spawnSync(process.execPath, [bin, 'eject'], { cwd, input: 'n\n', encoding: 'utf8', timeout: 30000, }) assert.ifError(result.error) @@ -67,7 +69,7 @@ test('invalid eject language fails without changing the project', async t => { t.after(() => rm(cwd, { recursive: true, force: true })) const pkg = '{"type":"module"}' await writeFile(join(cwd, 'package.json'), pkg) - await assert.rejects(exec(process.execPath, [bin, '--eject', '--yes', '--language', 'tsx'], { cwd, timeout: 30000 }), /--language must be ts or js/) + await assert.rejects(exec(process.execPath, [bin, 'eject', '--yes', '--language', 'tsx'], { cwd, timeout: 30000 }), /--language must be ts or js/) assert.equal(await readFile(join(cwd, 'package.json'), 'utf8'), pkg) await assert.rejects(readFile(join(cwd, 'src/layouts/root.layout.js')), { code: 'ENOENT' }) }) From 35bafa643fabfdd31af2d6f13f241f6f528fd2b3 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 13:29:42 -0700 Subject: [PATCH 3/7] Remove conversation-specific CLI documentation --- docs/cli/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/cli/README.md b/docs/cli/README.md index 487550ec..4a1be822 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -37,7 +37,7 @@ When using an explicit command, put the command before its options, as in `domst Use `watch` for development and `serve` to preview production output, including manifest-driven service-worker caching. `serve` always builds first; it is not a server-only command for an existing destination. -There is no `dev` alias. + ### Shared build, watch, and serve options @@ -97,7 +97,6 @@ Normal output summarizes builds, static asset startup, and server URLs. Use `--verbose` to include the build tree and individual copy operations. Build failures retain their full diagnostics at either verbosity level. - `domstack` is a devtool. It's primarily a unix `bin` written for the [Node.js](https://nodejs.org) runtime that is intended to be installed from `npm` as a `devDependency` inside a `package.json` committed to a `git` repository. It can be used outside of this context, but it works best within it. From c5f540bb27a9506db297f7a4ab34331a9904d258 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 13:38:40 -0700 Subject: [PATCH 4/7] Document complete flag lists for each CLI command --- docs/cli/README.md | 180 ++++++++++++++++++++++++++++++--------------- 1 file changed, 121 insertions(+), 59 deletions(-) diff --git a/docs/cli/README.md b/docs/cli/README.md index 4a1be822..3b91c352 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -9,39 +9,36 @@ handlebars: false Use `domstack` (or its shorter alias, `dom`) to build a site, watch for changes, or preview production output. Use `domstack eject` to extract the bundled defaults for customization. +`domstack` is a devtool. +It's primarily a unix `bin` written for the [Node.js](https://nodejs.org) runtime that is intended to be installed from `npm` as a `devDependency` inside a `package.json` committed to a `git` repository. +It can be used outside of this context, but it works best within it. + ## Table of Contents [[toc]] ## Usage -```sh -domstack -domstack build --src website --dest public -domstack watch --src site -domstack watch --no-serve -domstack serve --port 3000 -domstack eject --language ts -``` - -`domstack` builds `src` into `public` by default; `domstack build` is an explicit alias for the same one-shot build. -When using an explicit command, put the command before its options, as in `domstack watch --src site`, not `domstack --src site watch`. - | Command | Behavior | | --- | --- | | `domstack` or `domstack build` | Build once and exit. | -| `domstack watch` | Build, watch for changes, and serve with live reload using [`@domstack/sync`][domstack-sync]. | -| `domstack watch --no-serve` | Build and watch without starting a server. | +| `domstack watch` | Build, watch for changes, and serve with live reload. | | `domstack serve` | Build once, then serve production output without watching or live reload. | | `domstack eject` | Extract the default layout, global styles, and client files into the source directory and update dependencies in `package.json`. | +| `domstack help [command]` | Show root or command-specific help. | -Use `watch` for development and `serve` to preview production output, including manifest-driven service-worker caching. -`serve` always builds first; it is not a server-only command for an existing destination. +When using an explicit command, put the command before its options, as in `domstack watch --src site`, not `domstack --src site watch`. +## `domstack build` -### Shared build, watch, and serve options +Build the site once and exit. +Running `domstack` without a command performs the same build, with `src` as the source directory and `public` as the destination by default. -These options are available on the default build and on the explicit `build`, `watch`, and `serve` commands. +```sh +domstack +domstack build --src website --dest public +domstack build --copy images --copy downloads +``` | Option | Description | | --- | --- | @@ -50,61 +47,98 @@ These options are available on the default build and on the explicit `build`, `w | `--ignore `, `-i ` | Comma-separated gitignore-style ignore patterns. | | `--drafts` | Include draft pages with the `.draft.{md,js,ts,html}` page suffix. | | `--noEsbuildMeta` | Skip writing the esbuild metafile to disk. | -| `--domstackManifest` | Write the DOMStack manifest to disk for a one-shot build; watch mode does not finalize or write the manifest. | +| `--domstackManifest` | Write the DOMStack manifest to disk. | | `--copy ` | Copy an additional directory into the destination; repeat for multiple directories. | | `--verbose` | Show debug logs, including the build tree and individual copy operations. | +| `--help`, `-h` | Show build help; on bare `domstack`, show the command list and default build options. | +| `--version`, `-v` | Show the installed version. | -For example, `domstack build --copy images --copy downloads` copies both additional directories. +Normal output summarizes the build. +Use `--verbose` to include the build tree and individual copy operations. +Build failures retain their full diagnostics at either verbosity level. -### Command-specific options +## `domstack watch` -| Command | Option | Description | -| --- | --- | --- | -| `watch` | `--no-serve` | Watch and rebuild without a server, for example when another process serves the output. | -| `serve` | `--port ` | Server port, an integer from `1` to `65535` (default: `3000`). | -| `eject` | `--src `, `-s ` | Source directory to receive the defaults (default: `src`). | -| `eject` | `--language ` | Eject `js` (default) or `ts` files. | -| `eject` | `--yes` | Skip confirmation before writing files and updating dependencies. | +Build the site, watch for changes, and serve with live reload using [`@domstack/sync`][domstack-sync]. +Use `--no-serve` when another process serves the output. +Watch mode uses stable bundle filenames rather than production hashes. -`--port` is available only for `serve`, not `watch` or `build`. -Apart from help and version, `eject` accepts only `--src` / `-s`, `--language`, and `--yes`; shared build options such as `--dest` and `--verbose` are not accepted. +```sh +domstack watch +domstack watch --src site --dest public +domstack watch --no-serve +``` -### Help and version +| Option | Description | +| --- | --- | +| `--src `, `-s ` | Source directory (default: `src`). | +| `--dest `, `-d ` | Build destination directory (default: `public`). | +| `--ignore `, `-i ` | Comma-separated gitignore-style ignore patterns. | +| `--drafts` | Include draft pages with the `.draft.{md,js,ts,html}` page suffix. | +| `--noEsbuildMeta` | Skip writing the esbuild metafile to disk. | +| `--domstackManifest` | Accepted, but has no effect: watch mode does not finalize or write the DOMStack manifest. | +| `--copy ` | Copy and watch an additional directory in the destination; repeat for multiple directories. | +| `--verbose` | Show debug logs, including the build tree and individual copy operations. | +| `--no-serve` | Watch and rebuild without starting a server. | +| `--help`, `-h` | Show watch help. | +| `--version`, `-v` | Show the installed version. | -All commands support `--help` / `-h` and `--version` / `-v`. -`domstack --help` and `domstack help` show the command list and the default build options. -`domstack help ` is equivalent to `domstack --help`, for example `domstack help watch` and `domstack watch --help`. +Normal output summarizes builds, static asset startup, and server URLs. +Build failures retain their full diagnostics at either verbosity level. +`--port` is not available on this command. -### Legacy shortcuts +## `domstack serve` -Root-level mode flags remain supported for existing scripts, but prefer commands for new usage. +Build the site once, then serve production output without watching or live reload. +Use this command to preview the production build, including manifest-driven service-worker caching. +It always builds first; it is not a server-only command for an existing destination. -| Legacy shortcut | Preferred command | +```sh +domstack serve +domstack serve --src website --dest public --port 8080 +domstack serve --domstackManifest +``` + +| Option | Description | | --- | --- | -| `domstack --watch` or `domstack -w` | `domstack watch` | -| `domstack --watch-only` | `domstack watch --no-serve` | -| `domstack --serve` | `domstack serve` | -| `domstack --eject` or `domstack -e` | `domstack eject` | +| `--src `, `-s ` | Source directory (default: `src`). | +| `--dest `, `-d ` | Build destination directory (default: `public`). | +| `--ignore `, `-i ` | Comma-separated gitignore-style ignore patterns. | +| `--drafts` | Include draft pages with the `.draft.{md,js,ts,html}` page suffix. | +| `--noEsbuildMeta` | Skip writing the esbuild metafile to disk. | +| `--domstackManifest` | Write the DOMStack manifest to disk. | +| `--copy ` | Copy an additional directory into the destination; repeat for multiple directories. | +| `--verbose` | Show debug logs, including the build tree and individual copy operations. | +| `--port ` | Server port, an integer from `1` to `65535` (default: `3000`). | +| `--help`, `-h` | Show serve help. | +| `--version`, `-v` | Show the installed version. | -Legacy shortcuts use the same strict option validation as their target commands. -For example, `domstack --serve --port 4000` is valid, but `domstack --watch --port 4000` and `domstack --eject --dest public` are rejected. -Mode flags are mutually exclusive, including `--watch` together with `--watch-only`. -Legacy mode flags are not accepted on explicit commands, so use `domstack watch`, not `domstack build --watch`. +Normal output summarizes the build and server URLs. +Build failures retain their full diagnostics at either verbosity level. -### Build output +## `domstack eject` -Normal output summarizes builds, static asset startup, and server URLs. -Use `--verbose` to include the build tree and individual copy operations. -Build failures retain their full diagnostics at either verbosity level. +Extract DOMStack's default layout, global CSS, and client-side JavaScript into your source directory and add their dependencies to `package.json`. +This allows you to fully customize these files while maintaining the same functionality. -`domstack` is a devtool. -It's primarily a unix `bin` written for the [Node.js](https://nodejs.org) runtime that is intended to be installed from `npm` as a `devDependency` inside a `package.json` committed to a `git` repository. -It can be used outside of this context, but it works best within it. +```sh +domstack eject +domstack eject --language ts +domstack eject --language ts --yes --src src +``` + +| Option | Description | +| --- | --- | +| `--src `, `-s ` | Source directory to receive the defaults (default: `src`). | +| `--language ` | Eject `js` (default) or `ts` files. | +| `--yes` | Skip confirmation before writing files and updating dependencies. | +| `--help`, `-h` | Show eject help. | +| `--version`, `-v` | Show the installed version. | -## Ejecting the defaults +Without `--yes`, eject asks for confirmation before writing files or updating dependencies. +Eject overwrites its target files, so review or back up existing customizations before proceeding. -The `domstack eject` command extracts DOMStack's default layout, global CSS, and client-side JavaScript into your source directory. -This allows you to fully customize these files while maintaining the same functionality. +### Ejecting the defaults When you run `domstack eject`, it will: @@ -132,10 +166,38 @@ The TypeScript output uses the public type-only `@domstack/static/types.js` entr Keep `@domstack/static` installed for those types; no runtime type import or separate TypeScript compilation step is needed. The client is currently comment-only, but receives a `.ts` extension when TypeScript is selected. -For automation, run `domstack eject --language ts --yes --src src` to skip the confirmation prompt. -Without `--yes`, eject asks for confirmation before writing files or updating dependencies. -Eject overwrites its target files, so review or back up existing customizations before proceeding. - It is recommended to eject early in your project so that you can customize the root layout as you see fit, and decouple yourself from potential unwanted changes in the default layout as new versions of DOMStack are released. +## `domstack help` + +Show the command list and default build options, or pass a command name to show its full help. +This command takes an optional command name and no flags. + +```sh +domstack help +domstack help build +domstack help watch +domstack help serve +domstack help eject +``` + +`domstack help` is equivalent to `domstack --help`. +`domstack help ` is equivalent to `domstack --help`, for example `domstack help watch` and `domstack watch --help`. + +## Legacy shortcuts + +Root-level mode flags remain supported for existing scripts, but prefer commands for new usage. + +| Legacy shortcut | Preferred command | +| --- | --- | +| `domstack --watch` or `domstack -w` | `domstack watch` | +| `domstack --watch-only` | `domstack watch --no-serve` | +| `domstack --serve` | `domstack serve` | +| `domstack --eject` or `domstack -e` | `domstack eject` | + +Legacy shortcuts use the same strict option validation as their target commands. +For example, `domstack --serve --port 4000` is valid, but `domstack --watch --port 4000` and `domstack --eject --dest public` are rejected. +Mode flags are mutually exclusive, including `--watch` together with `--watch-only`. +Legacy mode flags are not accepted on explicit commands, so use `domstack watch`, not `domstack build --watch`. + [domstack-sync]: https://www.npmjs.com/package/@domstack/sync From 4cd5ebb38d46c76c5f76e30b1eb3de106d887260 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 13:43:16 -0700 Subject: [PATCH 5/7] Print the CLI build tree by default --- bin.js | 4 ++-- docs/cli/README.md | 14 +++++++------- lib/cli/options.js | 2 +- test-cases/cli-errors/commands.test.js | 2 ++ test-cases/cli-errors/logging.test.js | 11 ++++------- 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/bin.js b/bin.js index 7d8ac506..5b461048 100755 --- a/bin.js +++ b/bin.js @@ -178,7 +178,7 @@ domstack eject actions: if (command !== 'watch') { try { const results = await domStack.build() - logger.debug(tree(generateTreeData(cwd, src, dest, results))) + logger.info(tree(generateTreeData(cwd, src, dest, results))) logWarnings(logger, results?.warnings) logger.info(`Built ${relative(cwd, src) || '.'} → ${relative(cwd, dest) || '.'}`) logger.info('Build Success!') @@ -208,7 +208,7 @@ domstack eject actions: await domStack.watch({ serve: !argv['no-serve'], onInitialBuild: (initialResults) => { - logger.debug(tree(generateTreeData(cwd, src, dest, initialResults))) + logger.info(tree(generateTreeData(cwd, src, dest, initialResults))) logWarnings(logger, initialResults?.warnings) }, }) diff --git a/docs/cli/README.md b/docs/cli/README.md index 3b91c352..d38151c0 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -49,12 +49,12 @@ domstack build --copy images --copy downloads | `--noEsbuildMeta` | Skip writing the esbuild metafile to disk. | | `--domstackManifest` | Write the DOMStack manifest to disk. | | `--copy ` | Copy an additional directory into the destination; repeat for multiple directories. | -| `--verbose` | Show debug logs, including the build tree and individual copy operations. | +| `--verbose` | Show debug logs, including individual copy operations. | | `--help`, `-h` | Show build help; on bare `domstack`, show the command list and default build options. | | `--version`, `-v` | Show the installed version. | -Normal output summarizes the build. -Use `--verbose` to include the build tree and individual copy operations. +Normal output includes the build tree and a build summary. +Use `--verbose` to include debug logs and individual copy operations. Build failures retain their full diagnostics at either verbosity level. ## `domstack watch` @@ -78,12 +78,12 @@ domstack watch --no-serve | `--noEsbuildMeta` | Skip writing the esbuild metafile to disk. | | `--domstackManifest` | Accepted, but has no effect: watch mode does not finalize or write the DOMStack manifest. | | `--copy ` | Copy and watch an additional directory in the destination; repeat for multiple directories. | -| `--verbose` | Show debug logs, including the build tree and individual copy operations. | +| `--verbose` | Show debug logs, including individual copy operations. | | `--no-serve` | Watch and rebuild without starting a server. | | `--help`, `-h` | Show watch help. | | `--version`, `-v` | Show the installed version. | -Normal output summarizes builds, static asset startup, and server URLs. +Normal output includes the initial build tree, rebuild summaries, static asset startup, and server URLs. Build failures retain their full diagnostics at either verbosity level. `--port` is not available on this command. @@ -108,12 +108,12 @@ domstack serve --domstackManifest | `--noEsbuildMeta` | Skip writing the esbuild metafile to disk. | | `--domstackManifest` | Write the DOMStack manifest to disk. | | `--copy ` | Copy an additional directory into the destination; repeat for multiple directories. | -| `--verbose` | Show debug logs, including the build tree and individual copy operations. | +| `--verbose` | Show debug logs, including individual copy operations. | | `--port ` | Server port, an integer from `1` to `65535` (default: `3000`). | | `--help`, `-h` | Show serve help. | | `--version`, `-v` | Show the installed version. | -Normal output summarizes the build and server URLs. +Normal output includes the build tree, a build summary, and server URLs. Build failures retain their full diagnostics at either verbosity level. ## `domstack eject` diff --git a/lib/cli/options.js b/lib/cli/options.js index c26feb13..652b0c51 100644 --- a/lib/cli/options.js +++ b/lib/cli/options.js @@ -61,7 +61,7 @@ const buildOptions = { }, verbose: { type: 'boolean', - help: 'show debug logs, including the build tree and individual copy operations', + help: 'show debug logs, including individual copy operations', }, } diff --git a/test-cases/cli-errors/commands.test.js b/test-cases/cli-errors/commands.test.js index ef5012f8..75fa6817 100644 --- a/test-cases/cli-errors/commands.test.js +++ b/test-cases/cli-errors/commands.test.js @@ -304,6 +304,7 @@ test('watch --no-serve rebuilds and cleans up on SIGTERM', { timeout: 30_000 }, assert.doesNotMatch(running.output(), /https?:\/\/(?:localhost|127\.0\.0\.1):|\[domstack-sync\]/) assert.deepEqual(await stopCli(running), { code: 0, signal: null }, running.output()) assert.match(running.output(), /Watching stopped/) + assert.match(running.output(), /page.html:/, 'watch prints the initial build tree without --verbose') } finally { await stopCli(running) } @@ -335,6 +336,7 @@ test('serve builds once, serves production HTML on the requested port, and clean assert.equal(await readFile(output, 'utf8'), html) assert.deepEqual(await requestHtml(port), { status: 200, body: html }) assert.deepEqual(await stopCli(running), { code: 0, signal: null }, running.output()) + assert.match(running.output(), /page.html:/, 'serve prints the build tree without --verbose') assert.equal(await availablePort(port), port, 'SIGTERM releases the listening port') } finally { await stopCli(running) diff --git a/test-cases/cli-errors/logging.test.js b/test-cases/cli-errors/logging.test.js index efd170dd..74744c61 100644 --- a/test-cases/cli-errors/logging.test.js +++ b/test-cases/cli-errors/logging.test.js @@ -4,11 +4,12 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { join, resolve } from 'node:path' import test from 'node:test' -test('CLI summaries are concise and --verbose restores the build tree', async t => { +test('CLI prints the build tree with and without --verbose', async t => { const root = await mkdtemp(join(import.meta.dirname, '.tmp-cli-logging-')) t.after(() => rm(root, { recursive: true, force: true })) await mkdir(join(root, 'src')) await writeFile(join(root, 'src', 'page.md'), '# Logging\n') + for (const verbose of [false, true]) { const result = spawnSync(process.execPath, [ resolve(import.meta.dirname, '../../bin.js'), '--src', 'src', '--dest', 'dest', @@ -18,11 +19,7 @@ test('CLI summaries are concise and --verbose restores the build tree', async t assert.equal(result.status, 0, result.stdout + result.stderr) assert.match(result.stdout, /INFO: Built src → dest/) assert.match(result.stdout, /Build Success!/) - if (verbose) { - assert.match(result.stdout, /DEBUG:/) - assert.match(result.stdout, /page.md:/) - } else { - assert.doesNotMatch(result.stdout, /DEBUG:|page.md:/) - } + assert.match(result.stdout, /page.md:/) + if (!verbose) assert.doesNotMatch(result.stdout, /DEBUG:/) } }) From 8542c245bbad964cfffe2b533e9bf9d35dda1c41 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 14:40:07 -0700 Subject: [PATCH 6/7] Add local pretty-tree type declarations --- bin.js | 1 - lib/helpers/generate-tree-data.js | 2 +- types/pretty-tree.d.ts | 15 +++++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 types/pretty-tree.d.ts diff --git a/bin.js b/bin.js index 5b461048..d79b6872 100755 --- a/bin.js +++ b/bin.js @@ -12,7 +12,6 @@ import { basename, resolve, join, relative } from 'node:path' import readline from 'node:readline' import process from 'process' -// @ts-expect-error import tree from 'pretty-tree' import { inspect } from 'util' import { createServer } from '@domstack/sync' diff --git a/lib/helpers/generate-tree-data.js b/lib/helpers/generate-tree-data.js index 2b97c8ac..9409e186 100644 --- a/lib/helpers/generate-tree-data.js +++ b/lib/helpers/generate-tree-data.js @@ -22,7 +22,7 @@ import cleanDeep from 'clean-deep' * @param {string} src string src path of the build * @param {string} dest string dest path of the build * @param {Results} results A big object of data I still need to define - * @return {object} A tree structure ready to print + * @return {Partial} A tree structure ready to print */ export function generateTreeData (cwd, src, dest, results) { const cwdDir = basename(cwd) diff --git a/types/pretty-tree.d.ts b/types/pretty-tree.d.ts new file mode 100644 index 00000000..acfeab67 --- /dev/null +++ b/types/pretty-tree.d.ts @@ -0,0 +1,15 @@ +declare module 'pretty-tree' { + function tree (node: tree.TreeNode | string): string + + namespace tree { + interface TreeNode { + label?: string + nodes?: TreeNode | string | Array + leaf?: unknown + } + + function plain (node: TreeNode | string): string + } + + export = tree +} From 1b5b789f41ce6521076f860e9a1bc89d16e11549 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Wed, 16 Sep 2026 14:58:26 -0700 Subject: [PATCH 7/7] Wait for CLI serve readiness before testing HTTP --- test-cases/cli-errors/commands.test.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/test-cases/cli-errors/commands.test.js b/test-cases/cli-errors/commands.test.js index 75fa6817..4652c5c4 100644 --- a/test-cases/cli-errors/commands.test.js +++ b/test-cases/cli-errors/commands.test.js @@ -319,14 +319,9 @@ test('serve builds once, serves production HTML on the requested port, and clean const port = await availablePort() const running = startCli(cwd, ['serve', '--port', String(port)]) try { - await until(running, async () => { - try { - return (await requestHtml(port)).status === 200 - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ECONNREFUSED') return false - throw error - } - }, 'HTTP server') + // The sync server briefly binds a TCP port probe during startup; HTTP + // requests to that probe can reset before the real server is listening. + await until(running, async () => running.output().includes('Serving public without watching.'), 'serve readiness message') const html = await readFile(output, 'utf8') assert.match(html, /

Production serve fixture<\/h1>/) assert.deepEqual(await requestHtml(port), { status: 200, body: html }, 'production responses must not inject live reload')