Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
37fdd13
docs(webui): design and implementation plan for the WebUI packaging type
SugarFatFree Jun 2, 2026
04cd1d4
ci(webui): cross-platform WebUI build workflow with optional GitHub R…
SugarFatFree Jun 2, 2026
d3ecf15
feat(tools-pack): add cross-platform WebUI (no-Electron) packaging type
SugarFatFree Jun 2, 2026
4112f46
fix(webui): make stop/status target the config-driven runtime and nor…
SugarFatFree Jun 3, 2026
f1a01a2
docs(webui): document config fields in the example, strip doc keys wh…
SugarFatFree Jun 3, 2026
1ba8765
docs(webui): translate shipped resources and source comments to English
SugarFatFree Jun 3, 2026
a27f533
docs(webui): translate design and implementation plan specs to English
SugarFatFree Jun 3, 2026
747aa0f
fix(webui): bracket IPv6 hosts in URLs and persist full config for ex…
SugarFatFree Jun 3, 2026
e23f8a1
fix(webui): make a repeated start idempotent instead of failing obscu…
SugarFatFree Jun 3, 2026
6093d91
fix(webui): make Linux .desktop executable and stop hiding real IPC f…
SugarFatFree Jun 3, 2026
c605e5b
fix(webui): stop the STATUS probe from hiding real IPC failures
SugarFatFree Jun 3, 2026
3c15c07
fix(webui): treat a url-less STATUS reply as a protocol error; publis…
SugarFatFree Jun 3, 2026
017bc77
Merge upstream/main into pr/webui-packaging-type
SugarFatFree Jun 3, 2026
2b8fa94
fix(webui): fail fast when an explicit --config path is missing
SugarFatFree Jun 3, 2026
c946ea2
refactor(webui): make WebUI a build-only lane fenced off from the rel…
SugarFatFree Jun 4, 2026
81f6c06
fix(webui): restore the --app-version build flag on the webui CLI lane
SugarFatFree Jun 4, 2026
075f446
Merge remote-tracking branch 'upstream/main' into pr/webui-packaging-…
SugarFatFree Jun 8, 2026
5427252
fix(webui): invalidate the win version-family workspace-build alias w…
SugarFatFree Jun 8, 2026
4cab7a7
Merge remote-tracking branch 'upstream/main' into pr/webui-packaging-…
SugarFatFree Jun 8, 2026
ec42493
Merge remote-tracking branch 'upstream/main' into pr/webui-packaging-…
SugarFatFree Jun 8, 2026
e2673ae
Merge remote-tracking branch 'upstream/main' into pr/webui-packaging-…
SugarFatFree Jun 9, 2026
91636f9
Merge remote-tracking branch 'upstream/main' into pr/webui-packaging-…
SugarFatFree Jun 9, 2026
265d414
Merge upstream/main into pr/webui-packaging-type
SugarFatFree Jun 11, 2026
6b80103
fix(webui): normalize %k before cd in the Linux .desktop launcher
SugarFatFree Jun 11, 2026
3b9fccf
fix(webui): URI-decode %k in the Linux .desktop launcher
SugarFatFree Jun 11, 2026
f15dd6e
fix(webui): normalize the file:// authority in the Linux .desktop lau…
SugarFatFree Jun 11, 2026
cd80ec4
fix(webui): match the file:// localhost authority case-insensitively
SugarFatFree Jun 11, 2026
dde0020
fix(webui): match the file:// URI scheme case-insensitively
SugarFatFree Jun 11, 2026
e88aab2
fix(webui): proxy /api to the daemon's real host when bound non-loopback
SugarFatFree Jun 12, 2026
4e6ddd7
fix(webui): report the detached worker pid from `start --json`
SugarFatFree Jun 12, 2026
a6ccc96
Merge upstream/main into pr/webui-packaging-type
SugarFatFree Jun 15, 2026
6b7c107
fix(webui): make launcher import side-effect-free and canonicalize re…
SugarFatFree Jun 16, 2026
c029cfe
fix(webui): pass the .desktop %k location as a positional arg, not in…
SugarFatFree Jun 16, 2026
48560f0
fix(webui): enforce the absolute OD_DATA_DIR contract in resolveWebui…
SugarFatFree Jun 16, 2026
5f58e71
fix(webui): fast-fail on signal-killed sidecar startup and bracket a …
SugarFatFree Jun 16, 2026
503c359
fix(webui): advertise the real bound web port for --port 0 and namesp…
SugarFatFree Jun 16, 2026
ecefad9
Merge upstream/main into pr/webui-packaging-type
SugarFatFree Jun 17, 2026
a93720a
Merge upstream/main into pr/webui-packaging-type
SugarFatFree Jun 22, 2026
c2baa5e
Merge upstream/main into pr/webui-packaging-type
SugarFatFree Jun 22, 2026
6769af1
Merge upstream/main into pr/webui-packaging-type
SugarFatFree Jun 22, 2026
a013263
fix(tools-pack): add required arch field to win-removal-plan test config
SugarFatFree Jun 23, 2026
2ad8596
fix(packaged): correct IPv6 any-host launcher wiring and reject relat…
SugarFatFree Jun 23, 2026
f80c9fe
Merge upstream/main into pr/webui-packaging-type
SugarFatFree Jun 23, 2026
baa82e8
Merge remote-tracking branch 'upstream/main' into pr/webui-packaging-…
SugarFatFree Jun 30, 2026
eda06e0
Merge remote-tracking branch 'upstream/main' into pr/webui-packaging-…
SugarFatFree Jul 6, 2026
32a5592
fix(tools-pack): set required arch on new win test fixtures after ups…
SugarFatFree Jul 6, 2026
40ab3e0
fix(packaged): forward OD_WEB_PORT to daemon so split-port WebUI pass…
SugarFatFree Jul 6, 2026
630f534
fix(packaged): reject negative daemonPort so only 0 means dynamic loo…
SugarFatFree Jul 6, 2026
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
122 changes: 122 additions & 0 deletions .github/workflows/webui-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
name: Build WebUI Packages

# Cross-platform "WebUI" packaging type: a terminal-launched, no-Electron
# distribution (daemon + web in server mode) driven by `tools-pack webui build`.
# Each matrix entry runs on its target OS so the bundled better-sqlite3 prebuild
# matches the runner platform/arch. Artifacts are uploaded per platform; this is
# the networked end-to-end packaging path that cannot run in the local sandbox.
on:
workflow_dispatch:
inputs:
release_tag:
description: "Publish the archives to a GitHub Release under this tag (e.g. webui-v0.8.0). GitHub Releases serve files as-is, so users download the .tar.gz / .zip directly instead of a zip-wrapped artifact. Leave empty to only upload workflow artifacts."
required: false
default: ""

permissions:
contents: read

jobs:
build:
name: webui (${{ matrix.name }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: mac-arm64
os: macos-14
platform: mac
arch: arm64
- name: mac-x64
os: macos-15-intel
platform: mac
arch: x64
- name: win-x64
os: windows-latest
platform: win
arch: x64
- name: linux-x64
os: ubuntu-latest
platform: linux
arch: x64
steps:
- name: Checkout
uses: actions/checkout@v6.0.2

- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build WebUI package
shell: bash
run: |
set -euo pipefail
pnpm exec tools-pack webui build \
--platform "${{ matrix.platform }}" \
--arch "${{ matrix.arch }}" \
--dir "${{ runner.temp }}/tools-pack" \
--json | tee "${{ runner.temp }}/webui-build-${{ matrix.name }}.json"

- name: Upload WebUI archive
uses: actions/upload-artifact@v7
with:
name: open-design-webui-${{ matrix.name }}
path: ${{ runner.temp }}/tools-pack/**/open-design-webui-*
Comment thread
Siri-Ray marked this conversation as resolved.
Outdated
if-no-files-found: error

- name: Upload build log
if: always()
uses: actions/upload-artifact@v7
with:
name: webui-build-log-${{ matrix.name }}
path: ${{ runner.temp }}/webui-build-${{ matrix.name }}.json
if-no-files-found: ignore

# Optional publish step. GitHub's upload-artifact always re-zips its payload,
# so a downloaded artifact is a zip wrapping our real .tar.gz/.zip. A GitHub
# Release serves uploaded files byte-for-byte, so users get the archive itself.
# Runs only when the dispatcher supplies a release_tag.
release:
name: publish release
needs: build
if: ${{ inputs.release_tag != '' }}
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
VERSION_TAG: ${{ inputs.release_tag }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2

- name: Download all WebUI archives
uses: actions/download-artifact@v8
with:
pattern: open-design-webui-*
path: dist
merge-multiple: true

- name: Publish archives to GitHub Release
run: |
set -euo pipefail
ls -lh dist
if gh release view "$VERSION_TAG" >/dev/null 2>&1; then
gh release upload "$VERSION_TAG" dist/* --clobber
else
gh release create "$VERSION_TAG" \
--target "$GITHUB_SHA" \
--title "Open Design WebUI $VERSION_TAG" \
--notes "Cross-platform terminal-launched WebUI packages (no Electron). Linux ships a .tar.gz (preserves the launcher executable bit); macOS and Windows ship a .zip." \
dist/*
fi
4 changes: 4 additions & 0 deletions apps/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"./sidecar": {
"types": "./dist/sidecar/index.d.ts",
"default": "./dist/sidecar/index.js"
},
"./browser-open": {
"types": "./dist/browser-open.d.ts",
"default": "./dist/browser-open.js"
}
},
"files": [
Expand Down
26 changes: 25 additions & 1 deletion apps/daemon/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,7 +1151,31 @@ function resolveProcessResourcesPath() {
);
}

return null;
// Packaged WebUI (no Electron): the daemon is installed under
// <appRoot>/node_modules/@open-design/daemon and OD_RESOURCE_ROOT points at
// the sibling <appRoot>/resources/open-design. The user's system Node starts
// it, so process.resourcesPath and all the markers above are absent. Derive
// <appRoot>/resources from PROJECT_ROOT so the resource-root guard accepts the
// bundled tree without widening it to arbitrary paths.
return resolveBundledNodeAppResourcesPath(PROJECT_ROOT);
}

/**
* Returns the bundled-resources directory for a packaged no-Electron (WebUI)
* install, or null when the daemon is not running from such a layout.
*
* Invariant: a packaged node-app installs the daemon at
* `<appRoot>/node_modules/@open-design/daemon`, so `resolveProjectRoot` reports
* `<appRoot>/node_modules` as the project root. The bundled resources live at
* the sibling `<appRoot>/resources`. Any other project root (dev/monorepo
* checkout) yields null, keeping the OD_RESOURCE_ROOT guard's safe bases tight.
*/
export function resolveBundledNodeAppResourcesPath(
projectRoot: string,
): string | null {
return path.basename(projectRoot) === 'node_modules'
? path.join(path.dirname(projectRoot), 'resources')
: null;
}

export function resolveDaemonResourceRoot({
Expand Down
36 changes: 35 additions & 1 deletion apps/daemon/tests/server-paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { resolveDaemonCliPath, resolveDaemonResourceRoot, resolveProjectRoot } from '../src/server.js';
import {
resolveBundledNodeAppResourcesPath,
resolveDaemonCliPath,
resolveDaemonResourceRoot,
resolveProjectRoot,
} from '../src/server.js';

describe('resolveProjectRoot', () => {
it('resolves the repository root from the source daemon directory', () => {
Expand Down Expand Up @@ -70,4 +75,33 @@ describe('resolveDaemonResourceRoot', () => {
/OD_RESOURCE_ROOT must be under/,
);
});

// Packaged WebUI (no Electron) layout: the daemon is installed under
// <appRoot>/node_modules/@open-design/daemon and OD_RESOURCE_ROOT points at
// the sibling <appRoot>/resources/open-design. Launched by the user's system
// Node, so process.resourcesPath and the mac/win execPath markers are all
// absent — the resources path must be derivable from the node_modules root.
it('accepts the packaged WebUI resources path derived from a node_modules project root', () => {
const appRoot = path.resolve('/opt/open-design-webui/app');
const projectRoot = path.join(appRoot, 'node_modules');
const configured = path.join(appRoot, 'resources', 'open-design');
const safeBase = resolveBundledNodeAppResourcesPath(projectRoot);

expect(safeBase).toBe(path.join(appRoot, 'resources'));
expect(resolveDaemonResourceRoot({ configured, safeBases: [safeBase] })).toBe(configured);
});
});

describe('resolveBundledNodeAppResourcesPath', () => {
it('derives <appRoot>/resources when the daemon is installed under node_modules', () => {
const appRoot = path.resolve('/opt/app');

expect(resolveBundledNodeAppResourcesPath(path.join(appRoot, 'node_modules'))).toBe(
path.join(appRoot, 'resources'),
);
});

it('returns null for a dev/monorepo project root (no node_modules basename)', () => {
expect(resolveBundledNodeAppResourcesPath(path.resolve('/repo'))).toBeNull();
});
});
6 changes: 6 additions & 0 deletions apps/packaged/esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@ await build({
entryPoints: ["./src/headless.ts"],
outfile: "./dist/headless.mjs",
});

await build({
...sharedOptions,
entryPoints: ["./src/webui-launcher.ts"],
outfile: "./dist/webui-launcher.mjs",
});
3 changes: 3 additions & 0 deletions apps/packaged/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"./headless": {
"default": "./dist/headless.mjs"
},
"./webui-launcher": {
"default": "./dist/webui-launcher.mjs"
},
"./package.json": "./package.json"
},
"files": [
Expand Down
27 changes: 27 additions & 0 deletions apps/packaged/src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,33 @@ function resolvePackagedDataRoot(
return join(config.namespaceBaseRoot, namespace, "data");
}

// WebUI (no-Electron) launcher: derive the `namespaces/` parent directory from
// OD_DATA_DIR — or the XDG/home fallback when it is unset. This MUST follow the
// same scoped-vs-unscoped rule as resolvePackagedDataRoot(): a scoped
// OD_DATA_DIR already points *inside* the tree (`<base>/namespaces/<ns>/data`),
// so its `namespaces/` parent is two levels up; an unscoped base still needs the
// `namespaces` segment appended. Blindly appending `namespaces` to a scoped
// value forks the launcher's runtime/log tree away from the daemon's data dir,
// leaving the started instance pointing at a different path tree. Pure for tests.
export function resolveWebuiNamespacesRoot(input: {
odDataDir?: string | null;
xdgDataHome?: string | null;
home: string;
}): string {
const odDataDir = input.odDataDir?.trim();
if (odDataDir) {
const expanded = expandHomePrefix(odDataDir);
if (getScopedPackagedDataRootNamespace(expanded) != null) {
// `<base>/namespaces/<ns>/data` → `<base>/namespaces`
return join(expanded, "..", "..");
}
return join(expanded, "namespaces");
Comment thread
SugarFatFree marked this conversation as resolved.
}
const xdg = input.xdgDataHome?.trim();
Comment thread
SugarFatFree marked this conversation as resolved.
const dataBase = xdg && xdg.length > 0 ? xdg : join(input.home, ".local", "share");
return join(dataBase, "open-design", "namespaces");
}

export function resolvePackagedNamespacePaths(
config: PackagedConfig,
namespace = config.namespace,
Expand Down
Loading