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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 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
- Add `dgbuild new-skein [name]` / `dgbuild open-skein [name]` - create or open a skein and drive its full interactive browser UI with no VS Code; auto-opens a browser (`--no-open` to skip), with an in-UI Quit button that prompts to save when there are unsaved changes

## 0.5.1 - 28 Aug 2026

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,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 new-skein [name]`** / **`dgbuild open-skein [name]`** - create a new skein (default `default`), or open an existing one, and run its full interactive browser UI headlessly - the same Skein panel the extension shows, served on `http://localhost:<port>`. Prints the URL and opens your browser (`--no-open` to skip); the in-UI **Quit** button (or Ctrl+C) stops the server, prompting to save first if there are unsaved changes. `new-skein` takes `--seed <n>`; `open-skein` replays every branch on load to pick up source edits. Both take `--port <n>` (default: an OS-assigned free port), `--theme <light|dark>`, `-p/--project` and `-v/--verbose`. `dgdebug` only for now. Tracing opens in a second browser tab.
- **`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.

Expand Down
40 changes: 37 additions & 3 deletions docs/modules/ROOT/pages/command-line-dgbuild.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
:navtitle: Command-Line Testing with dgbuild

`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.
listing, web-page bundling, and the full interactive Skein UI — 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 — plus editing a skein on a machine that has no
VS Code.

== What it is, and installing it

Expand Down Expand Up @@ -78,6 +79,39 @@ $ dgbuild run-skein combat parser endings

This is the command-line equivalent of the panel's *Replay All*.

== dgbuild new-skein and open-skein

These run the *full interactive Skein UI* — the same transcript, navigation graph, trace panel and
keyboard shortcuts the extension's Skein panel provides — served as a local web page, with no editor
involved. `dgbuild` starts an HTTP server on `localhost`, prints its URL, and opens your default
browser at it (pass `--no-open` to just print the URL, e.g. on a headless box).

`new-skein [name]` creates a new skein file (default `default.skein`) and refuses to overwrite an
existing one. `open-skein [name]` opens an existing one, refuses a missing file, and replays every
branch on load so live source edits are picked up — exactly as the extension does when it opens a
skein. Only `dgdebug` skeins are supported for now.

[source,console]
----
dgbuild new-skein
dgbuild new-skein combat --seed 42
dgbuild open-skein --port 8600 --theme dark
----

Options: `--seed <n>` (`new-skein` only; default a random seed), `--port <n>` (default an
OS-assigned free port), `--theme <light|dark>`, `--no-open`, `-p` / `--project`, and `-v` /
`--verbose`.

*Stopping it.* The navigation bar carries a *Quit* button. If the skein has unsaved changes it opens
a confirmation dialog offering *Save and Quit*, *Quit Without Saving*, or *Cancel*; a clean skein
quits immediately. Either way the browser tab is left showing a "you may close this window" screen
while `dgbuild` shuts the server and the `dgdebug` process down and exits. Pressing kbd:[Ctrl+C] in
the terminal also shuts down cleanly, but cannot save — it warns if there are unsaved changes.

*Tracing.* Choosing *Trace* on a knot opens the trace in a second browser tab (the extension shows
it in a docked panel instead). Hovering a trace row still previews the source; clicking it does
nothing here, since there is no editor to open.

== dgbuild sources

Prints the project's fully expanded source list — exactly the files the compiler would receive.
Expand Down
152 changes: 151 additions & 1 deletion media/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,142 @@ window.sk = {
document.getElementById('insert-parent-modal')?.remove();
},

// ---------------------------------------------------------------------------
// Standalone (`dgbuild new-skein`/`open-skein`) mode: served to a plain browser, not a VS Code
// webview. `data-standalone="true"` on <html> (render.ts's renderPage / traceRender.ts) is the
// signal. Adds the Trace-in-a-second-tab behaviour and the Quit / shutdown flow that the
// extension handles natively.

isStandalone() {
return document.documentElement.dataset.standalone === 'true';
},

// Opens (or refocuses) the Trace page in a second browser tab. The named target means every
// Trace... (knot menu or ⌥T) reuses the one tab rather than piling up new ones; the /trace page
// live-updates over its own /trace/events SSE stream.
openTrace() {
const theme = document.documentElement.dataset.theme || 'light';
window.open('/trace?theme=' + encodeURIComponent(theme), 'skein-trace');
},

// Called first thing from the knot menu's Trace item and the ⌥T accelerator, synchronously in
// the click/keydown so window.open counts as a user gesture and isn't popup-blocked. A no-op
// outside standalone mode (the extension shows trace in a docked panel instead). It must live
// in the button's own data-on:click, not a bubbled document listener: the menu popover's
// data-on:click__stop (knot-menu.ts) stops the event before it reaches document.
maybeOpenTrace() {
if (this.isStandalone()) this.openTrace();
},

// The navbar Quit button POSTs /actions/quit; if the skein is dirty the server answers by
// broadcasting this instead of shutting down. Three choices, modelled on showLabelModal's shell.
showQuitModal() {
this.hideQuitModal();
closeAllDropdowns();

const overlay = document.createElement('div');
overlay.id = 'quit-modal';
overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-grayscale';

const panel = document.createElement('div');
panel.className = 'bg-base-100 rounded-lg shadow-xl max-w-full min-w-md mx-4';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'true');
panel.setAttribute('tabindex', '-1');

const header = document.createElement('div');
header.className = 'px-6 py-4 border-b border-base-200';
const heading = document.createElement('h3');
heading.className = 'text-lg font-medium text-base-content';
heading.textContent = 'Unsaved Changes';
header.appendChild(heading);

const body = document.createElement('div');
body.className = 'px-6 py-4';

const errorEl = document.createElement('div');
errorEl.className = 'alert alert-error text-sm mb-3 hidden';
errorEl.setAttribute('role', 'alert');
errorEl.setAttribute('aria-live', 'assertive');
body.appendChild(errorEl);

const prompt = document.createElement('p');
prompt.className = 'text-sm text-base-content mb-4';
prompt.textContent = 'You have unsaved changes. What would you like to do?';
body.appendChild(prompt);

const buttonCol = document.createElement('div');
buttonCol.className = 'flex flex-col gap-2';

const quitVia = async (query, failMsg) => {
errorEl.classList.add('hidden');
let res;
try {
res = await fetch('/actions/quit' + query, { method: 'POST' });
} catch {
errorEl.textContent = 'Failed to reach the server.';
errorEl.classList.remove('hidden');
return;
}
if (res.status === 204) {
// The server broadcasts sk.showShutdownScreen() over SSE; nothing more to do here.
return;
}
errorEl.textContent = failMsg;
errorEl.classList.remove('hidden');
};

const saveQuitBtn = document.createElement('button');
saveQuitBtn.type = 'button';
saveQuitBtn.className = 'btn btn-primary';
saveQuitBtn.textContent = 'Save and Quit';
saveQuitBtn.addEventListener('click', () => quitVia('?save=1', 'Failed to save - not quitting.'));

const forceQuitBtn = document.createElement('button');
forceQuitBtn.type = 'button';
forceQuitBtn.className = 'btn btn-warning';
forceQuitBtn.textContent = 'Quit Without Saving';
forceQuitBtn.addEventListener('click', () => quitVia('?force=1', 'Failed to quit.'));

const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'btn btn-neutral';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => this.hideQuitModal());

buttonCol.append(saveQuitBtn, forceQuitBtn, cancelBtn);
body.appendChild(buttonCol);

panel.append(header, body);
overlay.appendChild(panel);
document.body.appendChild(overlay);

panel.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.hideQuitModal();
}
});

panel.focus();
},

hideQuitModal() {
document.getElementById('quit-modal')?.remove();
},

// The server broadcasts this over both /events and /trace/events just before it tears the
// process down (POST /actions/quit). Browsers won't let a page close a user/OS-opened tab, so -
// like dialog-tool's #skein-shutdown screen - we just tell the user they can close it, and stop
// the now-pointless SSE reconnect churn against the port that's about to disappear.
showShutdownScreen() {
try { window.stop(); } catch (e) { /* not supported everywhere; harmless */ }
document.body.innerHTML =
'<div class="h-screen flex items-center justify-center text-center">' +
'<div><h2 class="text-2xl font-semibold text-base-content mb-4">Skein Shutdown</h2>' +
'<p class="text-base-content opacity-70">You may close this window now.</p></div></div>';
},

// ---------------------------------------------------------------------------
// Tree/graph pane: SVG connector lines + drag-to-pan.
// Ported from dialog-tool's own main.js (initTreeGraph/drawTreeArrows) - same element ids
Expand Down Expand Up @@ -940,6 +1076,16 @@ document.addEventListener('click', (evt) => {
if (!evt.target.closest('#skein-search-box')) dismissSearch();
});

// Standalone mode only: warn before an accidental tab close drops unsaved work. renderNavbar
// stamps data-dirty on <nav>; the server keeps running regardless (the CLI printed how to stop
// it), so this only guards against losing tree edits, not against "leaving the server up".
window.addEventListener('beforeunload', (evt) => {
if (window.sk.isStandalone() && document.querySelector('nav[data-dirty="true"]')) {
evt.preventDefault();
evt.returnValue = '';
}
});

// ---------------------------------------------------------------------------
// Keyboard accelerators - mirrors dialog-tool's own Cmd/Option-based scheme (doc/skein.md),
// adapted to dialog-ide's actual routes. One capturing document-level keydown listener,
Expand Down Expand Up @@ -988,7 +1134,8 @@ document.addEventListener('keydown', (evt) => {
document.getElementById('progress-modal') ||
document.getElementById('label-modal') ||
document.getElementById('command-modal') ||
document.getElementById('insert-parent-modal')
document.getElementById('insert-parent-modal') ||
document.getElementById('quit-modal')
) return;

const mod = evt.metaKey || evt.ctrlKey;
Expand Down Expand Up @@ -1083,6 +1230,9 @@ document.addEventListener('keydown', (evt) => {
// ts's traceKnot itself already refuses that case safely, same as every other accelerator
// here that doesn't pre-validate a knot's specific state before firing.
evt.preventDefault();
// In standalone mode the trace result renders on the separate /trace page - open/refocus
// that tab now, on the keydown's own user gesture, so window.open isn't popup-blocked.
window.sk.maybeOpenTrace();
if (knotId === 0) {
postAction('/actions/trace-startup');
} else {
Expand Down
20 changes: 20 additions & 0 deletions media/js/trace.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,32 @@
// rows, plus the postMessage bridge back to the extension host (only extension.ts has the
// vscode API needed to actually open/focus an editor - see extension.ts's TraceViewProvider).
window.sk = window.sk || {};

function traceIsStandalone() {
return document.documentElement.dataset.standalone === 'true';
}

window.sk.trace = {
openSource(file, line) {
// In standalone mode (`dgbuild new-skein`/`open-skein`) there's no extension host to receive
// this, and no editor to open - the row keeps its hover source-preview, but the click is a
// no-op rather than a message into the void.
if (traceIsStandalone()) return;
window.parent.postMessage({ type: 'openSource', file, line }, '*');
}
};

// The server broadcasts this over /trace/events just before it shuts the process down (POST
// /actions/quit). This page loads trace.js, not main.js, so it needs its own copy - keep it in
// step with main.js's showShutdownScreen.
window.sk.showShutdownScreen = function () {
try { window.stop(); } catch (e) { /* not supported everywhere; harmless */ }
document.body.innerHTML =
'<div class="h-screen flex items-center justify-center text-center">' +
'<div><h2 class="text-2xl font-semibold text-base-content mb-4">Skein Shutdown</h2>' +
'<p class="text-base-content opacity-70">You may close this window now.</p></div></div>';
};

let hoverTimer = null;
let currentHoverRow = null;
// Latest known pointer position, used at show time rather than whatever position the mouse was
Expand Down
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { Command, CommanderError } from 'commander';
import { DialogCompileError } from './dialoged/skein';
import { CliError, cliVersion } from './cli/context';
import { registerBundleCommand } from './cli/commands/bundle';
import { registerNewSkeinCommand } from './cli/commands/new-skein';
import { registerOpenSkeinCommand } from './cli/commands/open-skein';
import { registerRunSkeinCommand } from './cli/commands/run-skein';
import { registerSourcesCommand } from './cli/commands/sources';
import { registerTestCommand } from './cli/commands/test';
Expand All @@ -22,6 +24,8 @@ program

registerTestCommand(program);
registerRunSkeinCommand(program);
registerNewSkeinCommand(program);
registerOpenSkeinCommand(program);
registerSourcesCommand(program);
registerBundleCommand(program);

Expand Down
98 changes: 98 additions & 0 deletions src/cli/commands/new-skein.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { parsePortOption, parseThemeOption, resolveSkeinSessionId } from './skein-server';

jest.mock('./skein-server', () => ({
...jest.requireActual('./skein-server'),
runInteractiveSkein: jest.fn().mockResolvedValue(0)
}));

// eslint-disable-next-line @typescript-eslint/no-var-requires
const { runInteractiveSkein } = require('./skein-server') as { runInteractiveSkein: jest.Mock };
import { newSkeinCommand, resolveSeedOption } from './new-skein';

describe('parseThemeOption', () => {
it('defaults to light and accepts light/dark', () => {
expect(parseThemeOption(undefined)).toBe('light');
expect(parseThemeOption('light')).toBe('light');
expect(parseThemeOption('dark')).toBe('dark');
});

it('rejects anything else', () => {
expect(() => parseThemeOption('DARK')).toThrow('not a valid theme');
expect(() => parseThemeOption('blue')).toThrow('not a valid theme');
});
});

describe('parsePortOption', () => {
it('defaults to 0 and accepts a valid port', () => {
expect(parsePortOption(undefined)).toBe(0);
expect(parsePortOption('0')).toBe(0);
expect(parsePortOption('8080')).toBe(8080);
});

it('rejects a non-integer or out-of-range port', () => {
expect(() => parsePortOption('-1')).toThrow('not a valid port');
expect(() => parsePortOption('70000')).toThrow('not a valid port');
expect(() => parsePortOption('abc')).toThrow('not a valid port');
});
});

describe('resolveSkeinSessionId', () => {
it('defaults to "default" and strips a trailing .skein', () => {
expect(resolveSkeinSessionId(undefined)).toBe('default');
expect(resolveSkeinSessionId('combat.skein')).toBe('combat');
expect(resolveSkeinSessionId('combat')).toBe('combat');
});

it('rejects a name that looks like a path', () => {
expect(() => resolveSkeinSessionId('a/b')).toThrow('not a valid skein name');
expect(() => resolveSkeinSessionId('..')).toThrow('not a valid skein name');
});
});

describe('resolveSeedOption', () => {
it('returns a non-negative integer when omitted', () => {
const seed = resolveSeedOption(undefined);
expect(Number.isInteger(seed)).toBe(true);
expect(seed).toBeGreaterThanOrEqual(0);
});

it('parses a given seed and rejects a bad one', () => {
expect(resolveSeedOption('42')).toBe(42);
expect(() => resolveSeedOption('-1')).toThrow();
expect(() => resolveSeedOption('notanumber')).toThrow();
});
});

describe('newSkeinCommand', () => {
let tempRoot: string;

beforeEach(() => {
runInteractiveSkein.mockClear();
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dgbuild-new-skein-'));
fs.writeFileSync(path.join(tempRoot, 'dialog.json'), JSON.stringify({ sources: { main: [] } }));
});

afterEach(() => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});

it('starts an interactive server for a fresh skein, passing the parsed options through', async () => {
const code = await newSkeinCommand('combat', { project: tempRoot, seed: '7', theme: 'dark', port: '5599' });

expect(code).toBe(0);
expect(runInteractiveSkein).toHaveBeenCalledTimes(1);
const [params, banner] = runInteractiveSkein.mock.calls[0];
expect(params).toMatchObject({ sessionId: 'combat', mode: 'new', seed: 7, theme: 'dark', port: 5599 });
expect(banner).toContain('Created combat.skein');
});

it('refuses to overwrite an existing .skein', async () => {
fs.writeFileSync(path.join(tempRoot, 'default.skein'), 'seed: 1\nengine: dgdebug\n');

await expect(newSkeinCommand(undefined, { project: tempRoot })).rejects.toThrow('already exists');
expect(runInteractiveSkein).not.toHaveBeenCalled();
});
});
Loading
Loading