Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,23 @@ 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 <suffix>` 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/<name>-<release>.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:

```yaml
- 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.
Expand Down
42 changes: 38 additions & 4 deletions docs/modules/ROOT/pages/command-line-dgbuild.adoc
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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/<name>-<release>.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:
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down
90 changes: 90 additions & 0 deletions src/cli/commands/bundle-integration.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
61 changes: 61 additions & 0 deletions src/cli/commands/bundle.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
112 changes: 112 additions & 0 deletions src/cli/commands/bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* `dgbuild bundle [export-name]` - builds a self-contained web page (out/web/, plus a zip at
* out/<name>-<release>.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<number> {
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 <dir>', '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);
});
}
10 changes: 10 additions & 0 deletions src/cli/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading