From ffc1e6c09a9be24c59b4dec0d71e47bb4f53a7be Mon Sep 17 00:00:00 2001 From: Howard Lewis Ship Date: Mon, 31 Aug 2026 20:29:24 -0700 Subject: [PATCH] feat: add dgbuild bundle command Headless equivalent of the extension's "Export Web Page..." - builds out/web/ + a release zip for a dialog.json export configuration, so a GitHub Action can publish a project release. Wraps the existing vscode-free bundleWebExport pipeline. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RTppFtoRZ3ZmWYF14xDiXX --- CHANGELOG.md | 2 + README.md | 10 ++ .../ROOT/pages/command-line-dgbuild.adoc | 42 ++++++- src/cli.ts | 2 + src/cli/commands/bundle-integration.spec.ts | 90 ++++++++++++++ src/cli/commands/bundle.spec.ts | 61 ++++++++++ src/cli/commands/bundle.ts | 112 ++++++++++++++++++ src/cli/context.ts | 10 ++ 8 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 src/cli/commands/bundle-integration.spec.ts create mode 100644 src/cli/commands/bundle.spec.ts create mode 100644 src/cli/commands/bundle.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f7ce6..ef4a476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.5.2 - Unreleased +- Add `dgbuild bundle [export-name]` - builds the web page (`out/web/` + zip) for a `dialog.json` export configuration headlessly, so a GitHub Action can publish a release + ## 0.5.1 - 28 Aug 2026 - Fix the Skein and Trace views staying in light mode when VS Code is in a dark theme diff --git a/README.md b/README.md index d1a1248..640e369 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ A status bar item on the left also shows the current session (or lets you start - **`dgbuild test`** - runs the project's unit tests (`dgdebug --unit-test`) and exits non-zero on any failure, *or if `dialog.json` declares no `test` sources at all* (nothing to run is treated as a failure, not a silent pass). `--no-debug` excludes debug sources (included by default, matching **Dialog IDE: Run Tests**); extra arguments after the options are passed through to `dgdebug`. - **`dgbuild run-skein [names...]`** - replays one or more saved skeins (default: `default`, matching `default.skein`) against a fresh `dgdebug` process each, exits non-zero if any knot's live response no longer matches its blessed response across any of them, and prints a `valid/new/error` count summary per skein plus a `total` line when running more than one, e.g. `default: 200/0/1 (valid/new/error)`. Errored knots are printed above the summary. Add `-v/--verbose` to see the underlying `dgdebug` process commands/lifecycle logging (suppressed by default - busy otherwise, especially with multiple skeins). - **`dgbuild sources`** - prints the project's expanded source file list (`-d/--debug`, `-t/--test` to include those categories, `-T/--target ` to filter by target suffix, `-1/--single-line` for a colon-joined line instead of one path per line). +- **`dgbuild bundle [export-name]`** - builds the web page (`out/web/` plus a zip at `out/-.zip`) for one of `dialog.json`'s named export configurations, the headless equivalent of **Dialog IDE: Export Web Page...** - so a GitHub Action can publish a release. Pass the configuration name, or omit it when exactly one is defined. Needs `dialogc`, `dgdebug` and `aambundle`. Add `-v/--verbose` for the underlying `dgdebug` lifecycle logging. A minimal release-gating step in a GitHub Action: @@ -137,6 +138,15 @@ A minimal release-gating step in a GitHub Action: - run: npx -p dialog-ide dgbuild test && npx -p dialog-ide dgbuild run-skein ``` +To also publish a web page (e.g. to GitHub Pages) once the checks pass: + +```yaml +- run: npx -p dialog-ide dgbuild bundle +- uses: actions/upload-pages-artifact@v3 + with: + path: out/web +``` + ## Using the Skein The Skein panel opens beside your editor, split into a **nav graph** (left) and a **transcript** (right), with a command field at the bottom. diff --git a/docs/modules/ROOT/pages/command-line-dgbuild.adoc b/docs/modules/ROOT/pages/command-line-dgbuild.adoc index 3e4878e..998c94b 100644 --- a/docs/modules/ROOT/pages/command-line-dgbuild.adoc +++ b/docs/modules/ROOT/pages/command-line-dgbuild.adoc @@ -1,9 +1,10 @@ = Command-Line Testing with dgbuild :navtitle: Command-Line Testing with dgbuild -`dgbuild` runs the same project checks the extension does — unit tests, skein replay, source -listing — from a terminal, with no editor involved. Its purpose is scripting and continuous -integration: gating a merge on a green test run, or rebuilding a release automatically. +`dgbuild` runs the same project operations the extension does — unit tests, skein replay, source +listing, web-page bundling — from a terminal, with no editor involved. Its purpose is scripting and +continuous integration: gating a merge on a green test run, or building and publishing a release +automatically. == What it is, and installing it @@ -90,6 +91,28 @@ dgbuild sources -d dgbuild sources -1 -T zblorb ---- +== dgbuild bundle + +Builds a complete web page for one of `dialog.json` 's named export configurations into `out/web/`, +plus a zip at `out/-.zip` — the command-line equivalent of the extension's *Export +Web Page...*. The page carries the downloadable story file (compiled with that configuration's own +format, debug and `dialogc`-option settings), an in-browser https://github.com/dialog-if/aamachine[AAmachine] +player, the cover thumbnail, the project's configured feelies, and — if `default.skein` has a knot +labeled `WALKTHROUGH` — a walkthrough transcript. Title, author, blurb, release and IFID come from +the project's own `(story ...)` directives, queried live via `dgdebug`. + +Name the configuration to build, or omit the name when `dialog.json` defines exactly one: + +[source,console] +---- +dgbuild bundle +dgbuild bundle Web -p ./my-project +---- + +Unlike the other commands, `bundle` needs all three of `dialogc`, `dgdebug` and `aambundle` +available. `-v` / `--verbose` adds the underlying `dgdebug` lifecycle logging. It exits non-zero if +no matching export configuration is found, a required binary is missing, or any build step fails. + == In continuous integration A minimal release gate in a GitHub Actions workflow: @@ -99,8 +122,19 @@ A minimal release gate in a GitHub Actions workflow: - run: npx -p dialog-ide dgbuild test && npx -p dialog-ide dgbuild run-skein ---- +To build and publish the web page once the checks pass — here to GitHub Pages: + +[source,yaml] +---- +- run: npx -p dialog-ide dgbuild bundle +- uses: actions/upload-pages-artifact@v3 + with: + path: out/web +---- + The runner also needs the Dialog toolchain available — install `dgdebug` onto its `PATH`, or point -`dialog.json`'s `binDir` at a copy you provide. +`dialog.json` 's `binDir` at a copy you provide. `dgbuild bundle` additionally needs `dialogc` and +`aambundle`. == What's next diff --git a/src/cli.ts b/src/cli.ts index 7fb2214..22378fd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { Command, CommanderError } from 'commander'; import { DialogCompileError } from './dialoged/skein'; import { CliError, cliVersion } from './cli/context'; +import { registerBundleCommand } from './cli/commands/bundle'; import { registerRunSkeinCommand } from './cli/commands/run-skein'; import { registerSourcesCommand } from './cli/commands/sources'; import { registerTestCommand } from './cli/commands/test'; @@ -22,6 +23,7 @@ program registerTestCommand(program); registerRunSkeinCommand(program); registerSourcesCommand(program); +registerBundleCommand(program); // Prevent commander's own process.exit() calls so the catch below is the single place that // decides the final exit code. diff --git a/src/cli/commands/bundle-integration.spec.ts b/src/cli/commands/bundle-integration.spec.ts new file mode 100644 index 0000000..4b7a80a --- /dev/null +++ b/src/cli/commands/bundle-integration.spec.ts @@ -0,0 +1,90 @@ +/** + * Real (non-mocked) integration test for `dgbuild bundle` - exercises the actual + * bundleWebExport pipeline (dialogc compile -> dgdebug story-info query -> aambundle -> assemble + * out/web/ -> zip) that bundle.spec.ts's pure/validation tests can't cover. Kept unmocked in its + * own file, matching run-skein-integration.spec.ts's convention; skips itself (rather than + * failing) when the full dialogc/dgdebug/aambundle toolchain isn't on PATH. + */ + +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { bundleCommand } from './bundle'; + +function toolsAvailable(names: string[]): boolean { + return names.every((name) => { + try { + execFileSync(name, ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } + }); +} + +const describeIfFullToolchain = toolsAvailable(['dialogc', 'dgdebug', 'aambundle']) ? describe : describe.skip; +const FIXTURE_ROOT = path.join(__dirname, '..', '..', 'dialoged', 'skein', '__fixtures__', 'project', 'dgsample'); + +describeIfFullToolchain('bundleCommand (real dialogc/dgdebug/aambundle)', () => { + jest.setTimeout(90000); + + let tempRoot: string; + let logSpy: jest.SpyInstance; + + function writeDialogJson(exports: unknown): void { + fs.writeFileSync( + path.join(tempRoot, 'dialog.json'), + JSON.stringify({ + name: 'The Orb', + sources: { main: ['src'], debug: ['lib/dialog/debug'], library: ['lib/dialog'] }, + exports + }) + ); + } + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dgbuild-bundle-integration-')); + fs.cpSync(FIXTURE_ROOT, tempRoot, { recursive: true }); + logSpy = jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + logSpy.mockRestore(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('builds out/web/ and a release zip for the sole export configuration when no name is given', async () => { + writeDialogJson([{ name: 'Web', format: 'aa', includeDebug: false, output: 'out/web.aastory' }]); + + const code = await bundleCommand(undefined, { project: tempRoot }); + + expect(code).toBe(0); + expect(fs.existsSync(path.join(tempRoot, 'out', 'web', 'index.html'))).toBe(true); + expect(fs.existsSync(path.join(tempRoot, 'out', 'web', 'play.html'))).toBe(true); + expect(fs.existsSync(path.join(tempRoot, 'out', 'The Orb-0.zip'))).toBe(true); + }); + + it('selects a configuration by name when several are defined', async () => { + writeDialogJson([ + { name: 'Story', format: 'zblorb', includeDebug: false, output: 'out/story.zblorb' }, + { name: 'Web', format: 'aa', includeDebug: false, output: 'out/web.aastory' } + ]); + + const code = await bundleCommand('Web', { project: tempRoot }); + + expect(code).toBe(0); + const html = fs.readFileSync(path.join(tempRoot, 'out', 'web', 'index.html'), 'utf8'); + expect(html).toContain('The Orb.aastory'); + }); + + it('refuses an ambiguous bundle (several configs, no name) without running dialogc', async () => { + writeDialogJson([ + { name: 'Story', format: 'zblorb', includeDebug: false, output: 'out/story.zblorb' }, + { name: 'Web', format: 'aa', includeDebug: false, output: 'out/web.aastory' } + ]); + + await expect(bundleCommand(undefined, { project: tempRoot })).rejects.toThrow('Story, Web'); + expect(fs.existsSync(path.join(tempRoot, 'out', 'web'))).toBe(false); + }); +}); diff --git a/src/cli/commands/bundle.spec.ts b/src/cli/commands/bundle.spec.ts new file mode 100644 index 0000000..f32253e --- /dev/null +++ b/src/cli/commands/bundle.spec.ts @@ -0,0 +1,61 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ExportConfig } from '../../dialoged/skein'; +import { bundleCommand, resolveExportConfig } from './bundle'; + +function config(name: string, format: ExportConfig['format'] = 'zblorb'): ExportConfig { + return { name, format, includeDebug: false, output: `out/${name}.${format}` }; +} + +describe('resolveExportConfig', () => { + it('returns the sole configuration when no name is given and exactly one is defined', () => { + const only = config('Release'); + expect(resolveExportConfig([only], undefined)).toBe(only); + }); + + it('returns the named configuration by exact name match', () => { + const web = config('Web', 'aa'); + expect(resolveExportConfig([config('Release'), web], 'Web')).toBe(web); + }); + + it('throws listing every defined name when no name is given but several exist', () => { + expect(() => resolveExportConfig([config('Release'), config('Web', 'aa')], undefined)).toThrow( + 'Release, Web' + ); + }); + + it('throws listing every defined name when the given name matches none', () => { + expect(() => resolveExportConfig([config('Release'), config('Web', 'aa')], 'Nope')).toThrow( + 'No export configuration named "Nope"' + ); + }); + + it('throws a "add one under exports" hint when none are defined at all', () => { + expect(() => resolveExportConfig([], undefined)).toThrow('No export configurations defined'); + expect(() => resolveExportConfig([], 'Whatever')).toThrow('No export configurations defined'); + }); +}); + +describe('bundleCommand validation', () => { + it('rejects when the project directory has no dialog.json at all', async () => { + const noProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dgbuild-bundle-noproj-')); + try { + await expect(bundleCommand(undefined, { project: noProjectDir })).rejects.toThrow('does not exist'); + } finally { + fs.rmSync(noProjectDir, { recursive: true, force: true }); + } + }); + + it('rejects before any toolchain lookup when dialog.json defines no exports', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dgbuild-bundle-noexports-')); + fs.writeFileSync(path.join(tempRoot, 'dialog.json'), JSON.stringify({ sources: { main: [] } })); + try { + await expect(bundleCommand(undefined, { project: tempRoot })).rejects.toThrow( + 'No export configurations defined' + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cli/commands/bundle.ts b/src/cli/commands/bundle.ts new file mode 100644 index 0000000..020529e --- /dev/null +++ b/src/cli/commands/bundle.ts @@ -0,0 +1,112 @@ +/** + * `dgbuild bundle [export-name]` - builds a self-contained web page (out/web/, plus a zip at + * out/-.zip) for one of dialog.json's named export configurations: a downloadable + * story file, an AAmachine in-browser player, the project's configured feelies, cover art and + * (if present) a walkthrough. Headless equivalent of the extension's "Export Web Page..." + * command - both call the same vscode-free bundleWebExport pipeline (src/dialog-web-export.ts). + * Meant to let a GitHub Action publish a new release of a project without the extension host. + */ + +import { Command } from 'commander'; +import { ExportConfig, readProject, resolveCommandPath } from '../../dialoged/skein'; +import { isAambundleAvailable, isDgdebugAvailable, isDialogcAvailable } from '../../session-runner'; +import { WebExportPaths, bundleWebExport } from '../../dialog-web-export'; +import { + CliError, + resolveCliBundleAssetsDir, + resolveCliBundledBinDir, + resolveCliProjectRoot, + withQuietLogging +} from '../context'; + +export interface BundleOptions { + project?: string; + verbose?: boolean; + cwd?: string; +} + +/** + * Picks which dialog.json export configuration drives the downloadable story file, mirroring the + * extension's QuickPick (extension.ts's exportWebPage) but resolved from a CLI argument: an + * explicit name must match exactly; an omitted name auto-selects the sole configuration when + * there's exactly one, and is otherwise ambiguous. Pure - no I/O, directly unit-testable. + */ +export function resolveExportConfig(exports: ExportConfig[], name: string | undefined): ExportConfig { + if (exports.length === 0) { + throw new CliError( + 'No export configurations defined in dialog.json - add one under "exports" (or use "Configure Exports..." in the IDE) before bundling.' + ); + } + + if (name === undefined) { + if (exports.length === 1) { + return exports[0]; + } + throw new CliError( + `Multiple export configurations defined - name the one to bundle: ${exports.map((c) => c.name).join(', ')}.` + ); + } + + const match = exports.find((config) => config.name === name); + if (!match) { + throw new CliError( + `No export configuration named "${name}" in dialog.json. Defined: ${exports.map((c) => c.name).join(', ')}.` + ); + } + return match; +} + +export async function bundleCommand(name: string | undefined, options: BundleOptions): Promise { + const projectRoot = resolveCliProjectRoot(options.cwd ?? process.cwd(), options.project); + const project = readProject(projectRoot); // validates dialog.json exists, same as every other command + const config = resolveExportConfig(project.exports, name); + + const bundledBinDir = resolveCliBundledBinDir(); + const [dialogcOk, dgdebugOk, aambundleOk] = await Promise.all([ + isDialogcAvailable(project.binDir, bundledBinDir), + isDgdebugAvailable(project.binDir, bundledBinDir), + isAambundleAvailable(project.binDir, bundledBinDir) + ]); + const missing = [ + !dialogcOk && 'dialogc', + !dgdebugOk && 'dgdebug', + !aambundleOk && 'aambundle' + ].filter((n): n is string => Boolean(n)); + if (missing.length > 0) { + throw new CliError( + `${missing.join(', ')} not found - install the Dialog toolchain (and AAmachine, for aambundle), or set dialog.json's binDir.` + ); + } + + const paths: WebExportPaths = { + dialogcPath: resolveCommandPath(project.binDir, 'dialogc', bundledBinDir), + aambundlePath: resolveCommandPath(project.binDir, 'aambundle', bundledBinDir), + binDir: project.binDir, + bundledBinDir + }; + + const result = await withQuietLogging(!options.verbose, () => + bundleWebExport(project, config, paths, resolveCliBundleAssetsDir()) + ); + + if (result.ok !== true) { + throw new CliError(`Bundle failed (${result.step}): ${result.message}`); + } + + console.log(`Bundled "${project.name}" (export "${config.name}"):`); + console.log(` web page: ${result.outDir}`); + console.log(` zip: ${result.zipPath}`); + return 0; +} + +export function registerBundleCommand(program: Command): void { + program + .command('bundle') + .description('Build a web page (out/web/ + zip) for a dialog.json export configuration') + .argument('[export-name]', 'export configuration name (default: the only one, if exactly one is defined)') + .option('-p, --project ', 'project directory (default: current directory)') + .option('-v, --verbose', 'print the underlying dgdebug process commands/lifecycle logging') + .action(async (exportName: string | undefined, options: BundleOptions) => { + process.exitCode = await bundleCommand(exportName, options); + }); +} diff --git a/src/cli/context.ts b/src/cli/context.ts index 75db268..1377b21 100644 --- a/src/cli/context.ts +++ b/src/cli/context.ts @@ -35,6 +35,16 @@ export function resolveCliPatchSourcePath(): string { return path.join(cliPackageRoot(), 'resources', 'dfrotz-skein-patch.dg'); } +/** + * This package's vendored web-bundle assets directory (resources/bundle/ - style.css, play.css, + * default-cover.png, the "how to play IF" PDFs) - the headless equivalent of extension.ts passing + * `path.join(context.extensionPath, 'resources', 'bundle')` into bundleWebExport. Shipped to npm + * via package.json's `resources` files-allowlist glob, same as resolveCliPatchSourcePath's. + */ +export function resolveCliBundleAssetsDir(): string { + return path.join(cliPackageRoot(), 'resources', 'bundle'); +} + /** * Temporarily silences console.log (but not console.error) for the duration of fn() - the * shared session/process/persistence layer (session.ts, process.ts, persistence.ts) logs its own