Initial import: open-design source for helix-mind.ai distribution
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
This repository contains the open-design daemon CLI source code, built and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use by the HelixMind /design slash command. Licenses: Apache-2.0 (root) + MIT (skills/*)
This commit is contained in:
30
tools/pack/AGENTS.md
Normal file
30
tools/pack/AGENTS.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# tools/pack
|
||||
|
||||
Follow the root `AGENTS.md` and `tools/AGENTS.md` first. This tool owns the repo-external packaged build/start/stop/logs command surface.
|
||||
|
||||
## Owns
|
||||
|
||||
- Local packaging orchestration for packaged Open Design artifacts.
|
||||
- mac build/install/start/stop/logs/uninstall/cleanup smoke commands.
|
||||
- Windows NSIS build/install/start/stop/logs/uninstall/cleanup/list/reset smoke commands.
|
||||
- Windows registry observation/cleanup must go through `reg.exe` and stay scoped to entries matching the namespace install/uninstaller paths.
|
||||
- Windows lifecycle logs must expose NSIS automation logs/markers/timings in addition to app runtime logs.
|
||||
- Linux AppImage build/install/start/stop/logs/uninstall/cleanup smoke commands.
|
||||
- Linux containerized builds via `electronuserland/builder` Docker image for distro-agnostic glibc compat.
|
||||
- Consuming sidecar/process/path primitives from `@open-design/sidecar-proto`, `@open-design/sidecar`, and `@open-design/platform`.
|
||||
|
||||
## Does not own
|
||||
|
||||
- Product business logic.
|
||||
- Sidecar protocol definitions.
|
||||
- A second process identity model.
|
||||
- Product/business update runtime integration.
|
||||
|
||||
## Rules
|
||||
|
||||
- Do not hand-build `--od-stamp-*` args; use `createProcessStampArgs` with `OPEN_DESIGN_SIDECAR_CONTRACT`.
|
||||
- Do not use port numbers in data/log/runtime/cache path decisions. Namespace decides paths; ports are only transient transports.
|
||||
- Release artifacts keep canonical app identity (`Open Design.app` on mac, `Open Design.exe` inside the Windows installer); local tools-pack installs may use namespace-scoped install paths only as a developer multi-instance validation convention.
|
||||
- Do not let namespace-named `.app` installs change data/log/runtime/cache path conventions.
|
||||
- Use `--portable` for public/release artifacts so packaged config does not bake local tools-pack runtime roots from the build machine.
|
||||
- Pack resource files used by electron-builder belong under `tools/pack/resources/`; do not point pack logic at Downloads, web public assets, docs assets, or other app-owned resource paths.
|
||||
125
tools/pack/README.md
Normal file
125
tools/pack/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# tools/pack
|
||||
|
||||
Local packaging control plane for Open Design.
|
||||
|
||||
The active slice is mac-first local packaging and smoke lifecycle control:
|
||||
|
||||
- `tools-pack mac build --to all`
|
||||
- `tools-pack mac build --to app|dmg|zip`
|
||||
- `tools-pack mac build --to all --signed`
|
||||
- `tools-pack mac build --to all --portable` for release artifacts that must not bake local tools-pack runtime paths
|
||||
- `tools-pack mac install`
|
||||
- `tools-pack mac start`
|
||||
- `tools-pack mac stop`
|
||||
- `tools-pack mac logs`
|
||||
- `tools-pack mac uninstall`
|
||||
- `tools-pack mac cleanup`
|
||||
|
||||
Build artifacts are namespace-scoped under `.tmp/tools-pack/out/mac/namespaces/<namespace>/`.
|
||||
Release artifacts keep the canonical `Open Design.app` bundle shape; local `tools-pack install` copies it as
|
||||
`Open Design.<namespace>.app` so developer namespaces can coexist without affecting runtime data/log/cache paths.
|
||||
|
||||
Packaged runtime state is namespace-scoped under `.tmp/tools-pack/runtime/mac/namespaces/<namespace>/`:
|
||||
|
||||
- `data/` is the daemon-managed data root passed to the daemon through the packaged sidecar launch environment.
|
||||
- `logs/` contains packaged process logs for `desktop`, `web`, and `daemon`.
|
||||
- `runtime/` is the sidecar runtime base used by the packaged desktop/web/daemon process group.
|
||||
- `cache/` is reserved for namespace-local packaged cache state.
|
||||
- `user-data/` is the Electron/Chromium `userData` root, with `user-data/session/` used for `sessionData`.
|
||||
|
||||
Finder/manual launches cannot carry argv stamps on the root desktop process. To keep process fallback safe,
|
||||
`apps/packaged` writes `runtime/desktop-root.json` with the desktop stamp, PID, executable path, app path, and log path.
|
||||
`tools-pack mac stop` trusts that marker only when namespace/stamp/PID/command validation passes; otherwise it reports the
|
||||
unmanaged/not-owned reason instead of killing unknown processes.
|
||||
|
||||
### `tools-pack mac stop` validation
|
||||
|
||||
- If the marker is absent, stop reports `not-running`.
|
||||
- If the marker PID is gone, stop reports `not-running` and clears the stale marker.
|
||||
- If the marker PID was reused by an unrelated process, stop reports `unmanaged`.
|
||||
- If the marker namespace, stamp, runtime root, or command does not match the current namespace, stop reports `unmanaged`.
|
||||
|
||||
This keeps `stop` from killing processes outside the current namespace.
|
||||
|
||||
Packaged desktop also writes main-process lifecycle logs to `logs/desktop/latest.log` so Finder/manual launches are
|
||||
diagnosable. This log is intentionally scoped to packaged desktop startup/shutdown/process errors and does not capture
|
||||
web/renderer console output.
|
||||
|
||||
The packaged daemon path contract is explicit: `tools-pack` writes namespace/base config, `apps/packaged` resolves
|
||||
namespace paths, and the packaged sidecar launcher passes daemon managed paths via launch env. The daemon may keep its
|
||||
own default fallback for non-packaged launches, but packaged runtime must not rely on fallback inference from Electron
|
||||
`userData`, app bundle names, or ports.
|
||||
|
||||
The current release slice is mac beta publication. Runtime updater integration and Windows packaging remain later phases.
|
||||
|
||||
Electron-builder resources live under `tools/pack/resources/mac/`. The current logo is staged there as the mac icon/DMG
|
||||
placeholder so future design-provided assets can replace the resource files without changing packaging code.
|
||||
|
||||
Local developer artifacts bake the tools-pack namespace runtime root so `tools-pack mac start/stop/logs/cleanup` can manage
|
||||
them from the repo. Release artifacts use `--portable` so the installed app resolves namespace data/log/runtime/user-data
|
||||
from the user's Electron `userData` root instead of the build machine's `.tmp` path.
|
||||
|
||||
## Linux
|
||||
|
||||
Local lifecycle commands:
|
||||
|
||||
- `tools-pack linux build --to all` (default; produces AppImage)
|
||||
- `tools-pack linux build --to appimage` (explicit AppImage)
|
||||
- `tools-pack linux build --to dir` (unpacked output for fast iteration)
|
||||
- `tools-pack linux build --containerized` (run electron-builder inside `electronuserland/builder:base` Docker for distro-agnostic glibc compat — requires Docker)
|
||||
- `tools-pack linux build --to all --portable` (release artifacts that must not bake local tools-pack runtime paths)
|
||||
- `tools-pack linux install`
|
||||
- `tools-pack linux start`
|
||||
- `tools-pack linux stop`
|
||||
- `tools-pack linux logs`
|
||||
- `tools-pack linux uninstall`
|
||||
- `tools-pack linux cleanup`
|
||||
|
||||
Build artifacts are namespace-scoped under `.tmp/tools-pack/out/linux/namespaces/<namespace>/`. Packaged runtime state is namespace-scoped under `.tmp/tools-pack/runtime/linux/namespaces/<namespace>/{data,logs,runtime,cache,user-data}/`. Containerized build cache lives under `.tmp/tools-pack/.docker-cache/{electron,electron-builder}/`.
|
||||
|
||||
Local installs use XDG paths:
|
||||
|
||||
- AppImage: `~/.local/bin/Open-Design.<namespace>.AppImage`
|
||||
- Menu entry: `~/.local/share/applications/open-design-<namespace>.desktop`
|
||||
- Icon: `~/.local/share/icons/hicolor/512x512/apps/open-design-<namespace>.png`
|
||||
|
||||
The `<namespace>` suffix is unconditional so multiple developer namespaces can coexist on the same desktop. The `.desktop` file registers the `od://` scheme via `MimeType=x-scheme-handler/od;` and pre-sets `OD_PACKAGED_NAMESPACE` on the `Exec=` line so menu launches identify the correct namespace.
|
||||
|
||||
### AppImage launch mode (FUSE caveat)
|
||||
|
||||
`tools-pack linux start` always spawns the AppImage with `--appimage-extract-and-run`. Smoke testing on Ubuntu 24.04 and Arch Linux showed that direct FUSE-mounted AppImage launches make Node module loads (Express, better-sqlite3, etc.) slow enough that the daemon sidecar consistently failed to clear `apps/packaged`'s 35-second startup timeout. Extract-and-run unpacks the AppImage into `/tmp/appimage_extracted_<hex>/` and exec's the inner Electron from there, bypassing FUSE and getting daemon boot in under 5 seconds — roughly an order-of-magnitude improvement.
|
||||
|
||||
**Implication for end-users:** if launching the installed AppImage manually (not via `tools-pack linux start`), pass `--appimage-extract-and-run` yourself, or rely on a desktop launcher / `appimage-launcher` daemon that handles extract-and-run automatically.
|
||||
|
||||
### Optional system tools
|
||||
|
||||
`tools-pack linux install` and `tools-pack linux uninstall` invoke `update-desktop-database` and `gtk-update-icon-cache` as best-effort post-hooks. Either tool being absent (`iconCache: "missing"` in the output) is harmless — the icon and menu entry still work, the cache just isn't refreshed. Install via your distro:
|
||||
|
||||
- Arch / CachyOS: `sudo pacman -S desktop-file-utils gtk-update-icon-cache`
|
||||
- Debian / Ubuntu: `sudo apt install desktop-file-utils gtk-update-icon-cache`
|
||||
- Fedora: `sudo dnf install desktop-file-utils gtk-update-icon-cache`
|
||||
|
||||
`libfuse2` is needed for FUSE-mounted AppImage launch (the default mode when running an AppImage directly without `--appimage-extract-and-run`). `tools-pack linux start` always uses extract-and-run and bypasses FUSE entirely, so it does not need `libfuse2`. Most modern distros ship `libfuse2` by default; older Ubuntu LTS hosts may need `sudo apt install libfuse2t64` (or `libfuse2` on pre-24.04).
|
||||
|
||||
### Sandbox / chrome-sandbox
|
||||
|
||||
Electron 41 on Linux requires `kernel.unprivileged_userns_clone=1` (default on Arch, Ubuntu 24+, Debian 12+) or AppImage's `--no-sandbox` fallback. Most modern distros need no extra setup.
|
||||
|
||||
### Distro-agnostic guarantee
|
||||
|
||||
AppImages built natively on a rolling distro (e.g., Arch / CachyOS) link against recent glibc and may not run on stable distros (Ubuntu 22.04, Debian 12). Use `--containerized` to build against the wide-compat `electronuserland/builder:base` baseline (Ubuntu 18.04 / glibc 2.27).
|
||||
|
||||
### Format choice: why AppImage first
|
||||
|
||||
Linux desktop apps in this space split across formats: VS Code ships `.deb` + `.rpm` + Snap; Discord ships AppImage + `.deb`; Slack ships `.deb` + `.rpm`; Cursor and Obsidian ship AppImage. We start with AppImage because it is universal (one artifact runs on any glibc-compatible distro), needs no repo plumbing, and integrates cleanly with the namespace-scoped install layout. `.deb` / `.rpm` / Snap / Flatpak can land incrementally if user demand surfaces.
|
||||
|
||||
### Out of scope (later phases)
|
||||
|
||||
- AppImage signing (`--signed`) — deferred pending a GPG key infrastructure decision and a user-facing verification flow design (no ETA).
|
||||
- AppImage auto-update feed (`latest-linux.yml`) — the linux electron-builder config has no `publish` block wired, so a generated feed would point users at a feed that never updates. Tracked alongside signing.
|
||||
- Additional package formats: `.deb`, `.rpm`, Snap, Flatpak.
|
||||
- Linux entry in `ci.yml` (release lanes only build linux; PR validation does not yet).
|
||||
|
||||
`--to dmg` is manual-install DMG output only. Any builder-generated updater metadata such as `latest-mac.yml` or
|
||||
`.blockmap` files is treated as scratch and cleaned from the builder directory; release-beta generates the authoritative
|
||||
`latest-mac.yml` feed during release asset preparation, pointing at the update ZIP.
|
||||
16
tools/pack/bin/tools-pack.mjs
Executable file
16
tools/pack/bin/tools-pack.mjs
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const entryDir = dirname(fileURLToPath(import.meta.url));
|
||||
const distEntry = resolve(entryDir, "../dist/index.mjs");
|
||||
|
||||
if (!existsSync(distEntry)) {
|
||||
throw new Error(
|
||||
`tools-pack dist entry not found at ${distEntry}. Run "pnpm --filter @open-design/tools-pack build" first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await import(pathToFileURL(distEntry).href);
|
||||
14
tools/pack/esbuild.config.mjs
Normal file
14
tools/pack/esbuild.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import { build } from "esbuild";
|
||||
|
||||
await build({
|
||||
banner: {
|
||||
js: "#!/usr/bin/env node",
|
||||
},
|
||||
bundle: true,
|
||||
entryPoints: ["./src/index.ts"],
|
||||
format: "esm",
|
||||
outfile: "./dist/index.mjs",
|
||||
packages: "external",
|
||||
platform: "node",
|
||||
target: "node24",
|
||||
});
|
||||
33
tools/pack/package.json
Normal file
33
tools/pack/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@open-design/tools-pack",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"tools-pack": "./bin/tools-pack.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly",
|
||||
"dev": "tsx ./src/index.ts",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-design/platform": "workspace:*",
|
||||
"@open-design/sidecar": "workspace:*",
|
||||
"@open-design/sidecar-proto": "workspace:*",
|
||||
"@electron/notarize": "3.1.0",
|
||||
"cac": "6.7.14",
|
||||
"electron-builder": "26.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.12.2",
|
||||
"esbuild": "0.27.7",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "~24"
|
||||
}
|
||||
}
|
||||
BIN
tools/pack/resources/linux/icon.png
Normal file
BIN
tools/pack/resources/linux/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
12
tools/pack/resources/linux/open-design.desktop.template
Normal file
12
tools/pack/resources/linux/open-design.desktop.template
Normal file
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Open Design (@@NAMESPACE@@)
|
||||
GenericName=Open Design
|
||||
Comment=Open Design packaged build (@@NAMESPACE@@)
|
||||
Exec=env OD_PACKAGED_NAMESPACE=@@NAMESPACE@@ @@EXEC_PATH@@ --appimage-extract-and-run %U
|
||||
Icon=@@ICON_PATH@@
|
||||
Categories=Development;Utility;
|
||||
StartupWMClass=Open Design
|
||||
StartupNotify=true
|
||||
Terminal=false
|
||||
MimeType=x-scheme-handler/od;
|
||||
12
tools/pack/resources/mac/entitlements.mac.inherit.plist
Normal file
12
tools/pack/resources/mac/entitlements.mac.inherit.plist
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
12
tools/pack/resources/mac/entitlements.mac.plist
Normal file
12
tools/pack/resources/mac/entitlements.mac.plist
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tools/pack/resources/mac/icon.icns
Normal file
BIN
tools/pack/resources/mac/icon.icns
Normal file
Binary file not shown.
BIN
tools/pack/resources/mac/icon.png
Normal file
BIN
tools/pack/resources/mac/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
35
tools/pack/resources/mac/notarize.cjs
Normal file
35
tools/pack/resources/mac/notarize.cjs
Normal file
@@ -0,0 +1,35 @@
|
||||
const path = require("node:path");
|
||||
|
||||
module.exports = async function notarize(context) {
|
||||
if (context.electronPlatformName !== "darwin") {
|
||||
return;
|
||||
}
|
||||
|
||||
const appleId = process.env.APPLE_ID;
|
||||
const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD;
|
||||
const teamId = process.env.APPLE_TEAM_ID;
|
||||
const missing = [
|
||||
["APPLE_ID", appleId],
|
||||
["APPLE_APP_SPECIFIC_PASSWORD", appleIdPassword],
|
||||
["APPLE_TEAM_ID", teamId],
|
||||
]
|
||||
.filter(([, value]) => !value)
|
||||
.map(([name]) => name);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`[tools-pack notarize] missing required Apple notarization env: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const productFilename = context.packager.appInfo.productFilename;
|
||||
const appPath = path.join(context.appOutDir, `${productFilename}.app`);
|
||||
const { notarize } = await import("@electron/notarize");
|
||||
|
||||
await notarize({
|
||||
appPath,
|
||||
appleId,
|
||||
appleIdPassword,
|
||||
teamId,
|
||||
});
|
||||
};
|
||||
514
tools/pack/resources/mac/web-standalone-after-pack.cjs
Normal file
514
tools/pack/resources/mac/web-standalone-after-pack.cjs
Normal file
@@ -0,0 +1,514 @@
|
||||
const { access, cp, lstat, mkdir, readFile, readdir, rm, stat, symlink, writeFile } = require("node:fs/promises");
|
||||
const { createRequire } = require("node:module");
|
||||
const path = require("node:path");
|
||||
|
||||
const CONFIG_ENV = "OD_TOOLS_PACK_WEB_STANDALONE_HOOK_CONFIG";
|
||||
const STANDALONE_RESOURCE_NAME = "open-design-web-standalone";
|
||||
const REQUIRED_MODULES = ["next/package.json", "react/package.json", "react-dom/package.json", "styled-jsx/package.json"];
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requireString(record, key) {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`[tools-pack web-standalone] config.${key} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireBoolean(record, key) {
|
||||
const value = record[key];
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`[tools-pack web-standalone] config.${key} must be a boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireAbsolutePath(record, key) {
|
||||
const value = requireString(record, key);
|
||||
if (!path.isAbsolute(value)) {
|
||||
throw new Error(`[tools-pack web-standalone] config.${key} must be absolute: ${value}`);
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
function isWithin(parent, child) {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative.length === 0 || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathLstatExists(filePath) {
|
||||
try {
|
||||
await lstat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readHookConfig() {
|
||||
const configPath = process.env[CONFIG_ENV];
|
||||
if (configPath == null || configPath.length === 0) {
|
||||
throw new Error(`[tools-pack web-standalone] missing ${CONFIG_ENV}`);
|
||||
}
|
||||
if (!path.isAbsolute(configPath)) {
|
||||
throw new Error(`[tools-pack web-standalone] ${CONFIG_ENV} must be absolute: ${configPath}`);
|
||||
}
|
||||
|
||||
const raw = JSON.parse(await readFile(configPath, "utf8"));
|
||||
if (!isRecord(raw) || raw.version !== 1) {
|
||||
throw new Error("[tools-pack web-standalone] hook config must be an object with version=1");
|
||||
}
|
||||
|
||||
const workspaceRoot = requireAbsolutePath(raw, "workspaceRoot");
|
||||
const standaloneSourceRoot = requireAbsolutePath(raw, "standaloneSourceRoot");
|
||||
const webStaticSourceRoot = requireAbsolutePath(raw, "webStaticSourceRoot");
|
||||
const webPublicSourceRoot = requireAbsolutePath(raw, "webPublicSourceRoot");
|
||||
const auditReportPath = requireAbsolutePath(raw, "auditReportPath");
|
||||
const resourceName = requireString(raw, "resourceName");
|
||||
if (resourceName !== STANDALONE_RESOURCE_NAME) {
|
||||
throw new Error(`[tools-pack web-standalone] unsupported resourceName: ${resourceName}`);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries({ standaloneSourceRoot, webStaticSourceRoot, webPublicSourceRoot })) {
|
||||
if (!isWithin(workspaceRoot, value)) {
|
||||
throw new Error(`[tools-pack web-standalone] config.${key} must stay under workspaceRoot: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
auditReportPath,
|
||||
pruneCopiedSharp: requireBoolean(raw, "pruneCopiedSharp"),
|
||||
pruneRootNext: requireBoolean(raw, "pruneRootNext"),
|
||||
pruneRootSharp: requireBoolean(raw, "pruneRootSharp"),
|
||||
resourceName,
|
||||
standaloneSourceRoot,
|
||||
webPublicSourceRoot,
|
||||
webStaticSourceRoot,
|
||||
workspaceRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAppPath(context) {
|
||||
if (context == null || typeof context.appOutDir !== "string" || context.appOutDir.length === 0) {
|
||||
throw new Error("[tools-pack web-standalone] electron-builder context.appOutDir is missing");
|
||||
}
|
||||
const productFilename = context.packager?.appInfo?.productFilename;
|
||||
if (typeof productFilename !== "string" || productFilename.length === 0) {
|
||||
throw new Error("[tools-pack web-standalone] electron-builder productFilename is missing");
|
||||
}
|
||||
return path.join(context.appOutDir, `${productFilename}.app`);
|
||||
}
|
||||
|
||||
async function sizePathBytes(filePath) {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await lstat(filePath);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!metadata.isDirectory()) return metadata.size;
|
||||
|
||||
const entries = await readdir(filePath, { withFileTypes: true }).catch(() => []);
|
||||
let total = 0;
|
||||
for (const entry of entries) {
|
||||
total += await sizePathBytes(path.join(filePath, entry.name));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
async function copyRequired(sourcePath, destinationPath) {
|
||||
if (!(await pathExists(sourcePath))) {
|
||||
throw new Error(`[tools-pack web-standalone] required source missing: ${sourcePath}`);
|
||||
}
|
||||
await rm(destinationPath, { force: true, recursive: true });
|
||||
await mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
await cp(sourcePath, destinationPath, {
|
||||
dereference: false,
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function copyOptional(sourcePath, destinationPath) {
|
||||
if (!(await pathExists(sourcePath))) return false;
|
||||
await copyRequired(sourcePath, destinationPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function linkRelative(sourcePath, destinationPath) {
|
||||
if (!(await pathExists(sourcePath))) return false;
|
||||
if (await pathLstatExists(destinationPath)) return false;
|
||||
await mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
const relativeTarget = path.relative(path.dirname(destinationPath), sourcePath);
|
||||
await symlink(relativeTarget.length === 0 ? "." : relativeTarget, destinationPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function linkPnpmPublicHoist(destinationRoot) {
|
||||
const nodeModulesRoot = path.join(destinationRoot, "node_modules");
|
||||
const hoistRoot = path.join(nodeModulesRoot, ".pnpm", "node_modules");
|
||||
const entries = await readdir(hoistRoot, { withFileTypes: true }).catch(() => []);
|
||||
const linked = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(hoistRoot, entry.name);
|
||||
if (entry.name.startsWith("@") && entry.isDirectory()) {
|
||||
const scopedEntries = await readdir(sourcePath).catch(() => []);
|
||||
for (const scopedEntry of scopedEntries) {
|
||||
const scopedSource = path.join(sourcePath, scopedEntry);
|
||||
const scopedDestination = path.join(nodeModulesRoot, entry.name, scopedEntry);
|
||||
if (await linkRelative(scopedSource, scopedDestination)) linked.push(scopedDestination);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const destinationPath = path.join(nodeModulesRoot, entry.name);
|
||||
if (await linkRelative(sourcePath, destinationPath)) linked.push(destinationPath);
|
||||
}
|
||||
|
||||
return linked;
|
||||
}
|
||||
|
||||
async function resolveStandaloneSourceWebRoot(standaloneSourceRoot) {
|
||||
const candidates = [
|
||||
path.join(standaloneSourceRoot, "apps", "web"),
|
||||
standaloneSourceRoot,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExists(path.join(candidate, "server.js"))) return candidate;
|
||||
}
|
||||
|
||||
throw new Error(`[tools-pack web-standalone] standalone server.js not found under ${standaloneSourceRoot}`);
|
||||
}
|
||||
|
||||
async function installStandaloneResource(config, appPath) {
|
||||
const sourceWebRoot = await resolveStandaloneSourceWebRoot(config.standaloneSourceRoot);
|
||||
const destinationRoot = path.join(appPath, "Contents", "Resources", config.resourceName);
|
||||
const destinationWebRoot = path.join(destinationRoot, "apps", "web");
|
||||
|
||||
await rm(destinationRoot, { force: true, recursive: true });
|
||||
await mkdir(destinationWebRoot, { recursive: true });
|
||||
|
||||
await copyRequired(path.join(config.standaloneSourceRoot, "node_modules"), path.join(destinationRoot, "node_modules"));
|
||||
await copyRequired(path.join(sourceWebRoot, "server.js"), path.join(destinationWebRoot, "server.js"));
|
||||
await copyOptional(path.join(sourceWebRoot, "package.json"), path.join(destinationWebRoot, "package.json"));
|
||||
const copiedNestedNodeModules = await copyOptional(path.join(sourceWebRoot, "node_modules"), path.join(destinationWebRoot, "node_modules"));
|
||||
const linkedHoistEntries = await linkPnpmPublicHoist(destinationRoot);
|
||||
await copyRequired(path.join(sourceWebRoot, ".next"), path.join(destinationWebRoot, ".next"));
|
||||
const copiedStatic = await copyOptional(config.webStaticSourceRoot, path.join(destinationWebRoot, ".next", "static"));
|
||||
const copiedPublic = await copyOptional(config.webPublicSourceRoot, path.join(destinationWebRoot, "public"));
|
||||
|
||||
return {
|
||||
copiedNestedNodeModules,
|
||||
copiedPublic,
|
||||
copiedStatic,
|
||||
destinationRoot,
|
||||
destinationWebRoot,
|
||||
linkedHoistEntries,
|
||||
sourceWebRoot,
|
||||
};
|
||||
}
|
||||
|
||||
async function removePathAndRecord(targetPath, reason, removedPaths) {
|
||||
const existed = await pathExists(targetPath);
|
||||
const bytes = await sizePathBytes(targetPath);
|
||||
await rm(targetPath, { force: true, recursive: true });
|
||||
if (existed || bytes > 0) {
|
||||
removedPaths.push({ bytes, path: targetPath, reason });
|
||||
}
|
||||
}
|
||||
|
||||
function isPrunablePnpmSharpEntry(name) {
|
||||
return name.startsWith("sharp@") || name.startsWith("@img+colour@") || name.startsWith("@img+sharp-");
|
||||
}
|
||||
|
||||
function isPrunableImgEntry(name) {
|
||||
return name === "colour" || name.startsWith("sharp-");
|
||||
}
|
||||
|
||||
async function pruneImgScope(scopePath, reason, removedPaths) {
|
||||
const entries = await readdir(scopePath).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (isPrunableImgEntry(entry)) {
|
||||
await removePathAndRecord(path.join(scopePath, entry), reason, removedPaths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneCopiedSharp(destinationRoot) {
|
||||
const nodeModulesRoot = path.join(destinationRoot, "node_modules");
|
||||
const pnpmRoot = path.join(nodeModulesRoot, ".pnpm");
|
||||
const removedPaths = [];
|
||||
|
||||
await removePathAndRecord(path.join(nodeModulesRoot, "sharp"), "copied top-level sharp symlink", removedPaths);
|
||||
await pruneImgScope(path.join(nodeModulesRoot, "@img"), "copied top-level @img sharp symlink", removedPaths);
|
||||
await removePathAndRecord(path.join(pnpmRoot, "node_modules", "sharp"), "copied pnpm sharp symlink", removedPaths);
|
||||
await pruneImgScope(path.join(pnpmRoot, "node_modules", "@img"), "copied pnpm @img sharp symlink", removedPaths);
|
||||
|
||||
const pnpmEntries = await readdir(pnpmRoot).catch(() => []);
|
||||
for (const entry of pnpmEntries) {
|
||||
if (isPrunablePnpmSharpEntry(entry)) {
|
||||
await removePathAndRecord(path.join(pnpmRoot, entry), "copied pnpm sharp package", removedPaths);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.startsWith("next@")) {
|
||||
await removePathAndRecord(path.join(pnpmRoot, entry, "node_modules", "sharp"), "copied next sharp symlink", removedPaths);
|
||||
}
|
||||
}
|
||||
|
||||
return removedPaths;
|
||||
}
|
||||
|
||||
async function pruneBrokenSymlinks(root, current = root, removedPaths = [], reason = "broken symlink") {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await lstat(current);
|
||||
} catch {
|
||||
return removedPaths;
|
||||
}
|
||||
|
||||
if (metadata.isSymbolicLink()) {
|
||||
try {
|
||||
await stat(current);
|
||||
} catch {
|
||||
await removePathAndRecord(current, reason, removedPaths);
|
||||
}
|
||||
return removedPaths;
|
||||
}
|
||||
|
||||
if (!metadata.isDirectory()) return removedPaths;
|
||||
|
||||
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
await pruneBrokenSymlinks(root, path.join(current, entry.name), removedPaths, reason);
|
||||
}
|
||||
return removedPaths;
|
||||
}
|
||||
|
||||
function isForbiddenCopiedEntry(relativePath) {
|
||||
const normalized = relativePath.split(path.sep).join("/");
|
||||
const withRootSlash = `/${normalized}`;
|
||||
return (
|
||||
withRootSlash.includes("/node_modules/.pnpm/sharp@") ||
|
||||
withRootSlash.includes("/node_modules/.pnpm/@img+colour@") ||
|
||||
withRootSlash.includes("/node_modules/.pnpm/@img+sharp-") ||
|
||||
withRootSlash.includes("/node_modules/sharp") ||
|
||||
withRootSlash.includes("/node_modules/@img/colour") ||
|
||||
withRootSlash.includes("/node_modules/@img/sharp-") ||
|
||||
withRootSlash.includes("sharp-libvips") ||
|
||||
withRootSlash.includes("swc-darwin")
|
||||
);
|
||||
}
|
||||
|
||||
async function collectClosureStats(root, current = root, stats = { brokenSymlinks: [], forbiddenEntries: [], symlinks: 0 }) {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await lstat(current);
|
||||
} catch {
|
||||
return stats;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(root, current);
|
||||
if (relativePath.length > 0 && isForbiddenCopiedEntry(relativePath)) {
|
||||
stats.forbiddenEntries.push(relativePath.split(path.sep).join("/"));
|
||||
}
|
||||
|
||||
if (metadata.isSymbolicLink()) {
|
||||
stats.symlinks += 1;
|
||||
try {
|
||||
await stat(current);
|
||||
} catch {
|
||||
stats.brokenSymlinks.push(relativePath.split(path.sep).join("/"));
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
if (!metadata.isDirectory()) return stats;
|
||||
|
||||
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
await collectClosureStats(root, path.join(current, entry.name), stats);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function assertResolvedInside(root, moduleName, resolvedPath) {
|
||||
if (!isWithin(root, resolvedPath)) {
|
||||
throw new Error(`[tools-pack web-standalone] ${moduleName} resolved outside copied standalone: ${resolvedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function auditCopiedStandalone(config, installResult) {
|
||||
const serverPath = path.join(installResult.destinationWebRoot, "server.js");
|
||||
const staticRoot = path.join(installResult.destinationWebRoot, ".next", "static");
|
||||
const publicRoot = path.join(installResult.destinationWebRoot, "public");
|
||||
const nodeModulesRoot = path.join(installResult.destinationRoot, "node_modules");
|
||||
const requiredPaths = [serverPath, staticRoot, nodeModulesRoot];
|
||||
if (await pathExists(config.webPublicSourceRoot)) requiredPaths.push(publicRoot);
|
||||
|
||||
for (const requiredPath of requiredPaths) {
|
||||
if (!(await pathExists(requiredPath))) {
|
||||
throw new Error(`[tools-pack web-standalone] copied standalone audit missing: ${requiredPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const localRequire = createRequire(serverPath);
|
||||
const resolvedModules = {};
|
||||
for (const moduleName of REQUIRED_MODULES) {
|
||||
const resolvedPath = localRequire.resolve(moduleName);
|
||||
assertResolvedInside(installResult.destinationRoot, moduleName, resolvedPath);
|
||||
resolvedModules[moduleName] = resolvedPath;
|
||||
}
|
||||
|
||||
const closureStats = await collectClosureStats(installResult.destinationRoot);
|
||||
if (closureStats.brokenSymlinks.length > 0) {
|
||||
throw new Error(`[tools-pack web-standalone] copied standalone has broken symlinks: ${closureStats.brokenSymlinks.join(", ")}`);
|
||||
}
|
||||
if (closureStats.forbiddenEntries.length > 0) {
|
||||
throw new Error(`[tools-pack web-standalone] copied standalone has forbidden entries: ${closureStats.forbiddenEntries.join(", ")}`);
|
||||
}
|
||||
|
||||
return {
|
||||
brokenSymlinks: closureStats.brokenSymlinks,
|
||||
bytes: await sizePathBytes(installResult.destinationRoot),
|
||||
destinationRoot: installResult.destinationRoot,
|
||||
destinationWebRoot: installResult.destinationWebRoot,
|
||||
forbiddenEntries: closureStats.forbiddenEntries,
|
||||
nodeModulesBytes: await sizePathBytes(nodeModulesRoot),
|
||||
resolvedModules,
|
||||
serverPath,
|
||||
symlinks: closureStats.symlinks,
|
||||
};
|
||||
}
|
||||
|
||||
async function pruneRootNext(appPath) {
|
||||
const appNodeModulesRoot = path.join(appPath, "Contents", "Resources", "app", "node_modules");
|
||||
const removedPaths = [];
|
||||
|
||||
const nextScopeRoot = path.join(appNodeModulesRoot, "@next");
|
||||
const nextScopeEntries = await readdir(nextScopeRoot).catch(() => []);
|
||||
for (const entry of nextScopeEntries) {
|
||||
if (entry.startsWith("swc-darwin-")) {
|
||||
await removePathAndRecord(path.join(nextScopeRoot, entry), "root next darwin swc package", removedPaths);
|
||||
}
|
||||
}
|
||||
|
||||
await removePathAndRecord(
|
||||
path.join(appNodeModulesRoot, "@open-design", "web", ".next", "standalone"),
|
||||
"root @open-design/web standalone output",
|
||||
removedPaths,
|
||||
);
|
||||
|
||||
return removedPaths;
|
||||
}
|
||||
|
||||
async function pruneRootSharp(appPath) {
|
||||
const appNodeModulesRoot = path.join(appPath, "Contents", "Resources", "app", "node_modules");
|
||||
const pnpmRoot = path.join(appNodeModulesRoot, ".pnpm");
|
||||
const removedPaths = [];
|
||||
|
||||
await removePathAndRecord(path.join(appNodeModulesRoot, "sharp"), "root sharp package", removedPaths);
|
||||
await pruneImgScope(path.join(appNodeModulesRoot, "@img"), "root @img sharp package", removedPaths);
|
||||
await removePathAndRecord(path.join(pnpmRoot, "node_modules", "sharp"), "root pnpm sharp symlink", removedPaths);
|
||||
await pruneImgScope(path.join(pnpmRoot, "node_modules", "@img"), "root pnpm @img sharp symlink", removedPaths);
|
||||
|
||||
const pnpmEntries = await readdir(pnpmRoot).catch(() => []);
|
||||
for (const entry of pnpmEntries) {
|
||||
if (isPrunablePnpmSharpEntry(entry)) {
|
||||
await removePathAndRecord(path.join(pnpmRoot, entry), "root pnpm sharp package", removedPaths);
|
||||
}
|
||||
}
|
||||
|
||||
return removedPaths;
|
||||
}
|
||||
|
||||
async function auditNoBrokenSymlinks(root, label) {
|
||||
const stats = await collectClosureStats(root);
|
||||
if (stats.brokenSymlinks.length > 0) {
|
||||
throw new Error(`[tools-pack web-standalone] ${label} has broken symlinks: ${stats.brokenSymlinks.join(", ")}`);
|
||||
}
|
||||
return {
|
||||
brokenSymlinks: stats.brokenSymlinks,
|
||||
symlinks: stats.symlinks,
|
||||
};
|
||||
}
|
||||
|
||||
async function runWebStandaloneAfterPack(context) {
|
||||
if (context?.electronPlatformName != null && context.electronPlatformName !== "darwin") return;
|
||||
|
||||
const config = await readHookConfig();
|
||||
const appPath = resolveAppPath(context);
|
||||
if (!(await pathExists(appPath))) {
|
||||
throw new Error(`[tools-pack web-standalone] app bundle not found: ${appPath}`);
|
||||
}
|
||||
|
||||
const installResult = await installStandaloneResource(config, appPath);
|
||||
const copiedPrune = config.pruneCopiedSharp ? await pruneCopiedSharp(installResult.destinationRoot) : [];
|
||||
const brokenSymlinkPrune = await pruneBrokenSymlinks(
|
||||
installResult.destinationRoot,
|
||||
installResult.destinationRoot,
|
||||
[],
|
||||
"copied broken symlink",
|
||||
);
|
||||
const copiedAudit = await auditCopiedStandalone(config, installResult);
|
||||
const rootPrune = config.pruneRootNext ? await pruneRootNext(appPath) : [];
|
||||
const rootSharpPrune = config.pruneRootSharp ? await pruneRootSharp(appPath) : [];
|
||||
const rootBrokenSymlinkPrune = await pruneBrokenSymlinks(
|
||||
path.join(appPath, "Contents", "Resources", "app", "node_modules"),
|
||||
path.join(appPath, "Contents", "Resources", "app", "node_modules"),
|
||||
[],
|
||||
"root broken symlink",
|
||||
);
|
||||
const rootSymlinkAudit = await auditNoBrokenSymlinks(
|
||||
path.join(appPath, "Contents", "Resources", "app", "node_modules"),
|
||||
"root app node_modules",
|
||||
);
|
||||
const report = {
|
||||
appPath,
|
||||
brokenSymlinkPrune,
|
||||
copiedAudit,
|
||||
copiedPrune,
|
||||
generatedAt: new Date().toISOString(),
|
||||
rootBrokenSymlinkPrune,
|
||||
rootPrune,
|
||||
rootSharpPrune,
|
||||
rootSymlinkAudit,
|
||||
sourceWebRoot: installResult.sourceWebRoot,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
await mkdir(path.dirname(config.auditReportPath), { recursive: true });
|
||||
await writeFile(config.auditReportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
module.exports = async function webStandaloneAfterPack(context) {
|
||||
try {
|
||||
await runWebStandaloneAfterPack(context);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[tools-pack web-standalone] after-pack hook failed:",
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
console.error("[tools-pack web-standalone] electron-builder context:", {
|
||||
appOutDir: context?.appOutDir,
|
||||
electronPlatformName: context?.electronPlatformName,
|
||||
productFilename: context?.packager?.appInfo?.productFilename,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
BIN
tools/pack/resources/win/icon.ico
Normal file
BIN
tools/pack/resources/win/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
167
tools/pack/src/config.ts
Normal file
167
tools/pack/src/config.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
SIDECAR_DEFAULTS,
|
||||
} from "@open-design/sidecar-proto";
|
||||
import { resolveNamespace } from "@open-design/sidecar";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ENTRY_DIR_NAME = path.basename(__dirname);
|
||||
|
||||
export const WORKSPACE_ROOT = resolve(__dirname, ENTRY_DIR_NAME === "dist" ? "../../.." : "../../..");
|
||||
|
||||
export type ToolPackPlatform = "mac" | "win" | "linux";
|
||||
export type ToolPackBuildOutput = "all" | "app" | "appimage" | "dir" | "dmg" | "nsis" | "zip";
|
||||
export type ToolPackMacCompression = "store" | "normal" | "maximum";
|
||||
export type ToolPackWebOutputMode = "server" | "standalone";
|
||||
|
||||
export type ToolPackCliOptions = {
|
||||
containerized?: boolean;
|
||||
dir?: string;
|
||||
expr?: string;
|
||||
json?: boolean;
|
||||
macCompression?: string;
|
||||
namespace?: string;
|
||||
path?: string;
|
||||
portable?: boolean;
|
||||
removeData?: boolean;
|
||||
removeLogs?: boolean;
|
||||
removeProductUserData?: boolean;
|
||||
removeSidecars?: boolean;
|
||||
signed?: boolean;
|
||||
silent?: boolean;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type ToolPackRoots = {
|
||||
output: {
|
||||
appBuilderRoot: string;
|
||||
namespaceRoot: string;
|
||||
platformRoot: string;
|
||||
root: string;
|
||||
};
|
||||
runtime: {
|
||||
namespaceBaseRoot: string;
|
||||
namespaceRoot: string;
|
||||
};
|
||||
toolPackRoot: string;
|
||||
};
|
||||
|
||||
export type ToolPackConfig = {
|
||||
containerized: boolean;
|
||||
electronBuilderCliPath: string;
|
||||
electronDistPath: string;
|
||||
electronVersion: string;
|
||||
macCompression: ToolPackMacCompression;
|
||||
namespace: string;
|
||||
platform: ToolPackPlatform;
|
||||
portable: boolean;
|
||||
removeData: boolean;
|
||||
removeLogs: boolean;
|
||||
removeProductUserData: boolean;
|
||||
removeSidecars: boolean;
|
||||
roots: ToolPackRoots;
|
||||
silent: boolean;
|
||||
signed: boolean;
|
||||
to: ToolPackBuildOutput;
|
||||
webOutputMode: ToolPackWebOutputMode;
|
||||
workspaceRoot: string;
|
||||
};
|
||||
|
||||
function resolveToolPackBuildOutput(platform: ToolPackPlatform, value: string | undefined): ToolPackBuildOutput {
|
||||
if (value == null || value.length === 0) return platform === "win" ? "nsis" : "all";
|
||||
if (platform === "mac" && (value === "all" || value === "app" || value === "dmg" || value === "zip")) return value;
|
||||
if (platform === "win" && (value === "all" || value === "dir" || value === "nsis")) return value;
|
||||
if (platform === "linux" && (value === "all" || value === "appimage" || value === "dir")) return value;
|
||||
throw new Error(`unsupported ${platform} --to target: ${value}`);
|
||||
}
|
||||
|
||||
function resolveToolPackMacCompression(value: string | undefined): ToolPackMacCompression {
|
||||
if (value == null || value.length === 0) return "normal";
|
||||
if (value === "store" || value === "normal" || value === "maximum") return value;
|
||||
throw new Error(`unsupported mac --mac-compression value: ${value}`);
|
||||
}
|
||||
|
||||
function resolveToolPackWebOutputMode(platform: ToolPackPlatform, value: string | undefined): ToolPackWebOutputMode {
|
||||
// Standalone web output is wired for mac first; other platforms fall back to server mode until their paths are enabled.
|
||||
if (platform !== "mac") return "server";
|
||||
if (value == null || value.length === 0) return "standalone";
|
||||
if (value === "server" || value === "standalone") return value;
|
||||
throw new Error(`unsupported OD_WEB_OUTPUT_MODE value: ${value}`);
|
||||
}
|
||||
|
||||
function resolveElectronVersion(workspaceRoot: string): string {
|
||||
const require = createRequire(join(workspaceRoot, "apps/desktop/package.json"));
|
||||
const desktopPackage = require(join(workspaceRoot, "apps/desktop/package.json")) as {
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
const version = desktopPackage.devDependencies?.electron;
|
||||
if (version == null || version.length === 0) {
|
||||
throw new Error("apps/desktop/package.json must declare electron");
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function resolveElectronDistPath(workspaceRoot: string): string {
|
||||
const require = createRequire(join(workspaceRoot, "apps/desktop/package.json"));
|
||||
const electronEntry = require.resolve("electron");
|
||||
return join(path.dirname(electronEntry), "dist");
|
||||
}
|
||||
|
||||
function resolveElectronBuilderCliPath(): string {
|
||||
const require = createRequire(import.meta.url);
|
||||
return require.resolve("electron-builder/out/cli/cli.js");
|
||||
}
|
||||
|
||||
export function resolveToolPackConfig(
|
||||
platform: ToolPackPlatform,
|
||||
options: ToolPackCliOptions = {},
|
||||
): ToolPackConfig {
|
||||
const namespace = resolveNamespace({
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
env: process.env,
|
||||
namespace: options.namespace ?? SIDECAR_DEFAULTS.namespace,
|
||||
});
|
||||
const toolPackRoot = resolve(options.dir ?? join(WORKSPACE_ROOT, ".tmp", "tools-pack"));
|
||||
const outputRoot = join(toolPackRoot, "out");
|
||||
const outputPlatformRoot = join(outputRoot, platform);
|
||||
const outputNamespaceRoot = join(outputPlatformRoot, "namespaces", namespace);
|
||||
const runtimeNamespaceBaseRoot = join(toolPackRoot, "runtime", platform, "namespaces");
|
||||
|
||||
return {
|
||||
containerized: options.containerized === true,
|
||||
electronBuilderCliPath: resolveElectronBuilderCliPath(),
|
||||
electronDistPath: resolveElectronDistPath(WORKSPACE_ROOT),
|
||||
electronVersion: resolveElectronVersion(WORKSPACE_ROOT),
|
||||
macCompression: resolveToolPackMacCompression(options.macCompression),
|
||||
namespace,
|
||||
platform,
|
||||
portable: options.portable === true,
|
||||
roots: {
|
||||
output: {
|
||||
appBuilderRoot: join(outputNamespaceRoot, "builder"),
|
||||
namespaceRoot: outputNamespaceRoot,
|
||||
platformRoot: outputPlatformRoot,
|
||||
root: outputRoot,
|
||||
},
|
||||
runtime: {
|
||||
namespaceBaseRoot: runtimeNamespaceBaseRoot,
|
||||
namespaceRoot: join(runtimeNamespaceBaseRoot, namespace),
|
||||
},
|
||||
toolPackRoot,
|
||||
},
|
||||
removeData: options.removeData === true,
|
||||
removeLogs: options.removeLogs === true,
|
||||
removeProductUserData: options.removeProductUserData === true,
|
||||
removeSidecars: options.removeSidecars === true,
|
||||
silent: options.silent !== false,
|
||||
signed: options.signed === true,
|
||||
to: resolveToolPackBuildOutput(platform, options.to),
|
||||
webOutputMode: resolveToolPackWebOutputMode(platform, process.env.OD_WEB_OUTPUT_MODE),
|
||||
workspaceRoot: WORKSPACE_ROOT,
|
||||
};
|
||||
}
|
||||
212
tools/pack/src/index.ts
Normal file
212
tools/pack/src/index.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { cac } from "cac";
|
||||
import type { CAC } from "cac";
|
||||
|
||||
import { resolveToolPackConfig, type ToolPackCliOptions, type ToolPackPlatform } from "./config.js";
|
||||
import {
|
||||
cleanupPackedMacNamespace,
|
||||
installPackedMacDmg,
|
||||
inspectPackedMacApp,
|
||||
packMac,
|
||||
readPackedMacLogs,
|
||||
startPackedMacApp,
|
||||
stopPackedMacApp,
|
||||
uninstallPackedMacApp,
|
||||
} from "./mac.js";
|
||||
import {
|
||||
cleanupPackedWinNamespace,
|
||||
installPackedWinApp,
|
||||
inspectPackedWinApp,
|
||||
listPackedWinNamespaces,
|
||||
packWin,
|
||||
readPackedWinLogs,
|
||||
resetPackedWinNamespaces,
|
||||
startPackedWinApp,
|
||||
stopPackedWinApp,
|
||||
uninstallPackedWinApp,
|
||||
} from "./win.js";
|
||||
import {
|
||||
cleanupPackedLinuxNamespace,
|
||||
installPackedLinuxApp,
|
||||
packLinux,
|
||||
readPackedLinuxLogs,
|
||||
startPackedLinuxApp,
|
||||
stopPackedLinuxApp,
|
||||
uninstallPackedLinuxApp,
|
||||
} from "./linux.js";
|
||||
|
||||
type CliOptions = ToolPackCliOptions;
|
||||
|
||||
function printJson(payload: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function printLogs(result: { logs: Record<string, { lines: string[]; logPath: string }>; namespace: string }, options: CliOptions): void {
|
||||
if (options.json === true) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [app, entry] of Object.entries(result.logs)) {
|
||||
process.stdout.write(`[${app}] ${entry.logPath}\n`);
|
||||
process.stdout.write(entry.lines.length > 0 ? `${entry.lines.join("\n")}\n` : "(no log lines)\n");
|
||||
}
|
||||
}
|
||||
|
||||
type CacCommand = ReturnType<CAC["command"]>;
|
||||
|
||||
function addSharedOptions(command: CacCommand) {
|
||||
return command
|
||||
.option("--dir <path>", "tools-pack root directory")
|
||||
.option("--json", "print JSON")
|
||||
.option("--namespace <name>", "runtime namespace")
|
||||
.option("--expr <expression>", "desktop inspect eval expression")
|
||||
.option("--path <path>", "desktop inspect screenshot path");
|
||||
}
|
||||
|
||||
// Per-platform `--to` help text mirroring resolveToolPackBuildOutput in
|
||||
// config.ts. Keep these in sync: the resolver throws on any value not listed
|
||||
// here for the given platform.
|
||||
const TO_HELP_BY_PLATFORM: Record<ToolPackPlatform, string> = {
|
||||
linux: "build target: all|appimage|dir (default: all)",
|
||||
mac: "build target: all|app|dmg|zip (default: all)",
|
||||
win: "build target: all|dir|nsis (default: nsis)",
|
||||
};
|
||||
|
||||
function addBuildOptions(command: CacCommand, platform: ToolPackPlatform) {
|
||||
return command
|
||||
.option("--portable", "do not bake local tools-pack runtime roots into the packaged config")
|
||||
.option("--signed", "build a signed/notarized mac artifact")
|
||||
.option("--to <target>", TO_HELP_BY_PLATFORM[platform]);
|
||||
}
|
||||
|
||||
function addMacBuildOptions(command: CacCommand) {
|
||||
return addBuildOptions(command, "mac")
|
||||
.option("--mac-compression <mode>", "mac artifact compression: normal|maximum|store (default: normal)");
|
||||
}
|
||||
|
||||
function addWinLifecycleOptions(command: CacCommand) {
|
||||
return command
|
||||
.option("--remove-data", "remove packaged data during uninstall/reset/cleanup")
|
||||
.option("--remove-logs", "remove packaged logs during uninstall/reset/cleanup")
|
||||
.option("--remove-product-user-data", "remove the public Electron app userData root during Windows uninstall/reset/cleanup")
|
||||
.option("--remove-sidecars", "remove packaged sidecar runtime during uninstall/reset/cleanup")
|
||||
.option("--silent", "run installer/uninstaller silently", { default: true });
|
||||
}
|
||||
|
||||
const cli = cac("tools-pack");
|
||||
|
||||
addMacBuildOptions(addSharedOptions(cli.command("mac <action>", "Mac packaging commands: build|install|start|stop|logs|uninstall|cleanup|inspect"))).action(
|
||||
async (action: string, options: CliOptions) => {
|
||||
const config = resolveToolPackConfig("mac", options);
|
||||
switch (action) {
|
||||
case "build":
|
||||
printJson(await packMac(config));
|
||||
return;
|
||||
case "install":
|
||||
printJson(await installPackedMacDmg(config));
|
||||
return;
|
||||
case "start":
|
||||
printJson(await startPackedMacApp(config));
|
||||
return;
|
||||
case "stop":
|
||||
printJson(await stopPackedMacApp(config));
|
||||
return;
|
||||
case "logs":
|
||||
printLogs(await readPackedMacLogs(config), options);
|
||||
return;
|
||||
case "inspect":
|
||||
printJson(await inspectPackedMacApp(config, options));
|
||||
return;
|
||||
case "uninstall":
|
||||
printJson(await uninstallPackedMacApp(config));
|
||||
return;
|
||||
case "cleanup":
|
||||
printJson(await cleanupPackedMacNamespace(config));
|
||||
return;
|
||||
default:
|
||||
throw new Error(`unsupported mac action: ${action}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
addWinLifecycleOptions(
|
||||
addBuildOptions(
|
||||
addSharedOptions(
|
||||
cli.command(
|
||||
"win <action>",
|
||||
"Windows packaging commands: build|install|start|stop|logs|uninstall|cleanup|list|reset|inspect",
|
||||
),
|
||||
),
|
||||
"win",
|
||||
),
|
||||
).action(async (action: string, options: CliOptions) => {
|
||||
const config = resolveToolPackConfig("win", options);
|
||||
switch (action) {
|
||||
case "build":
|
||||
printJson(await packWin(config));
|
||||
return;
|
||||
case "install":
|
||||
printJson(await installPackedWinApp(config));
|
||||
return;
|
||||
case "start":
|
||||
printJson(await startPackedWinApp(config));
|
||||
return;
|
||||
case "stop":
|
||||
printJson(await stopPackedWinApp(config));
|
||||
return;
|
||||
case "logs":
|
||||
printLogs(await readPackedWinLogs(config), options);
|
||||
return;
|
||||
case "uninstall":
|
||||
printJson(await uninstallPackedWinApp(config));
|
||||
return;
|
||||
case "cleanup":
|
||||
printJson(await cleanupPackedWinNamespace(config));
|
||||
return;
|
||||
case "list":
|
||||
printJson(await listPackedWinNamespaces(config));
|
||||
return;
|
||||
case "reset":
|
||||
printJson(await resetPackedWinNamespaces(config));
|
||||
return;
|
||||
case "inspect":
|
||||
printJson(await inspectPackedWinApp(config, options));
|
||||
return;
|
||||
default:
|
||||
throw new Error(`unsupported win action: ${action}`);
|
||||
}
|
||||
});
|
||||
|
||||
addBuildOptions(addSharedOptions(cli.command("linux <action>", "Linux packaging commands: build|install|start|stop|logs|uninstall|cleanup")), "linux")
|
||||
.option("--containerized", "build inside electronuserland/builder Docker for distro-agnostic glibc compat")
|
||||
.action(async (action: string, options: CliOptions) => {
|
||||
const config = resolveToolPackConfig("linux", options);
|
||||
switch (action) {
|
||||
case "build":
|
||||
printJson(await packLinux(config));
|
||||
return;
|
||||
case "install":
|
||||
printJson(await installPackedLinuxApp(config));
|
||||
return;
|
||||
case "start":
|
||||
printJson(await startPackedLinuxApp(config));
|
||||
return;
|
||||
case "stop":
|
||||
printJson(await stopPackedLinuxApp(config));
|
||||
return;
|
||||
case "logs":
|
||||
printLogs(await readPackedLinuxLogs(config), options);
|
||||
return;
|
||||
case "uninstall":
|
||||
printJson(await uninstallPackedLinuxApp(config));
|
||||
return;
|
||||
case "cleanup":
|
||||
printJson(await cleanupPackedLinuxNamespace(config));
|
||||
return;
|
||||
default:
|
||||
throw new Error(`unsupported linux action: ${action}`);
|
||||
}
|
||||
});
|
||||
|
||||
cli.help();
|
||||
cli.parse();
|
||||
1088
tools/pack/src/linux.ts
Normal file
1088
tools/pack/src/linux.ts
Normal file
File diff suppressed because it is too large
Load Diff
1270
tools/pack/src/mac.ts
Normal file
1270
tools/pack/src/mac.ts
Normal file
File diff suppressed because it is too large
Load Diff
71
tools/pack/src/resources.ts
Normal file
71
tools/pack/src/resources.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { cp } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function resolveToolsPackRoot(startDir: string): string {
|
||||
const maxDepth = 6;
|
||||
let current = startDir;
|
||||
|
||||
for (let depth = 0; depth < maxDepth; depth += 1) {
|
||||
try {
|
||||
const raw = readFileSync(join(current, "package.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
if (parsed.name === "@open-design/tools-pack") {
|
||||
return current;
|
||||
}
|
||||
} catch {
|
||||
// Keep walking until we find the tools-pack package root.
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
throw new Error(`tools-pack: unable to resolve package root from ${startDir}`);
|
||||
}
|
||||
|
||||
export const toolsPackRoot = resolveToolsPackRoot(dirname(fileURLToPath(import.meta.url)));
|
||||
export const resourcesRoot = join(toolsPackRoot, "resources");
|
||||
|
||||
export const macResources = {
|
||||
entitlements: join(resourcesRoot, "mac", "entitlements.mac.plist"),
|
||||
entitlementsInherit: join(resourcesRoot, "mac", "entitlements.mac.inherit.plist"),
|
||||
icon: join(resourcesRoot, "mac", "icon.icns"),
|
||||
iconPng: join(resourcesRoot, "mac", "icon.png"),
|
||||
notarizeHook: join(resourcesRoot, "mac", "notarize.cjs"),
|
||||
webStandaloneAfterPackHook: join(resourcesRoot, "mac", "web-standalone-after-pack.cjs"),
|
||||
} as const;
|
||||
|
||||
export const winResources = {
|
||||
icon: join(resourcesRoot, "win", "icon.ico"),
|
||||
} as const;
|
||||
|
||||
export const linuxResources = {
|
||||
icon: join(resourcesRoot, "linux", "icon.png"),
|
||||
desktopTemplate: join(resourcesRoot, "linux", "open-design.desktop.template"),
|
||||
} as const;
|
||||
|
||||
const BUNDLED_RESOURCE_TREES = [
|
||||
{ from: "skills", to: "skills" },
|
||||
{ from: "design-systems", to: "design-systems" },
|
||||
{ from: "craft", to: "craft" },
|
||||
{ from: join("assets", "frames"), to: "frames" },
|
||||
{ from: join("assets", "community-pets"), to: "community-pets" },
|
||||
{ from: "prompt-templates", to: "prompt-templates" },
|
||||
] as const;
|
||||
|
||||
export async function copyBundledResourceTrees({
|
||||
workspaceRoot,
|
||||
resourceRoot,
|
||||
}: {
|
||||
workspaceRoot: string;
|
||||
resourceRoot: string;
|
||||
}): Promise<void> {
|
||||
for (const entry of BUNDLED_RESOURCE_TREES) {
|
||||
await cp(join(workspaceRoot, entry.from), join(resourceRoot, entry.to), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
1202
tools/pack/src/win.ts
Normal file
1202
tools/pack/src/win.ts
Normal file
File diff suppressed because it is too large
Load Diff
270
tools/pack/tests/linux.test.ts
Normal file
270
tools/pack/tests/linux.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ToolPackConfig } from "../src/config.js";
|
||||
import {
|
||||
buildDockerArgs,
|
||||
matchesAppImageProcess,
|
||||
renderDesktopTemplate,
|
||||
sanitizeNamespace,
|
||||
} from "../src/linux.js";
|
||||
|
||||
function makeConfig(): ToolPackConfig {
|
||||
return {
|
||||
containerized: true,
|
||||
electronBuilderCliPath: "/x/electron-builder/cli.js",
|
||||
electronDistPath: "/x/electron/dist",
|
||||
electronVersion: "41.3.0",
|
||||
macCompression: "normal",
|
||||
namespace: "default",
|
||||
platform: "linux",
|
||||
portable: false,
|
||||
removeData: false,
|
||||
removeLogs: false,
|
||||
removeProductUserData: false,
|
||||
removeSidecars: false,
|
||||
roots: {
|
||||
output: {
|
||||
appBuilderRoot: "/work/.tmp/tools-pack/out/linux/namespaces/default/builder",
|
||||
namespaceRoot: "/work/.tmp/tools-pack/out/linux/namespaces/default",
|
||||
platformRoot: "/work/.tmp/tools-pack/out/linux",
|
||||
root: "/work/.tmp/tools-pack/out",
|
||||
},
|
||||
runtime: {
|
||||
namespaceBaseRoot: "/work/.tmp/tools-pack/runtime/linux/namespaces",
|
||||
namespaceRoot: "/work/.tmp/tools-pack/runtime/linux/namespaces/default",
|
||||
},
|
||||
toolPackRoot: "/work/.tmp/tools-pack",
|
||||
},
|
||||
silent: true,
|
||||
signed: false,
|
||||
to: "all",
|
||||
webOutputMode: "server",
|
||||
workspaceRoot: "/work",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildDockerArgs", () => {
|
||||
it("returns the expected docker argv array", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
expect(args[0]).toBe("run");
|
||||
expect(args).toContain("--rm");
|
||||
expect(args).toContain("--user");
|
||||
expect(args).toContain("1000:1000");
|
||||
expect(args).toContain("electronuserland/builder:base");
|
||||
});
|
||||
|
||||
it("mounts the workspace at /project", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
expect(args).toContain("-v");
|
||||
expect(args).toContain("/work:/project");
|
||||
});
|
||||
|
||||
it("mounts docker home and electron caches under .tmp/tools-pack/.docker-*", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
expect(args).toContain(`${join("/work/.tmp/tools-pack", ".docker-home")}:/home/builder`);
|
||||
expect(args).toContain(`${join("/work/.tmp/tools-pack", ".docker-cache", "electron")}:/home/builder/.cache/electron`);
|
||||
expect(args).toContain(
|
||||
`${join("/work/.tmp/tools-pack", ".docker-cache", "electron-builder")}:/home/builder/.cache/electron-builder`,
|
||||
);
|
||||
});
|
||||
|
||||
it("mounts the tool-pack root at /tools-pack so inner build writes to host-visible output dir", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
expect(args).toContain("/work/.tmp/tools-pack:/tools-pack");
|
||||
});
|
||||
|
||||
it("sets HOME and ELECTRON_CACHE env vars", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
expect(args).toContain("HOME=/home/builder");
|
||||
expect(args).toContain("ELECTRON_CACHE=/home/builder/.cache/electron");
|
||||
expect(args).toContain("ELECTRON_BUILDER_CACHE=/home/builder/.cache/electron-builder");
|
||||
});
|
||||
|
||||
it("re-invokes pnpm tools-pack linux build inside the container without --containerized", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).toMatch(/npx --yes pnpm@\d+\.\d+\.\d+ install --frozen-lockfile/);
|
||||
expect(last).toMatch(/npx --yes pnpm@\d+\.\d+\.\d+ tools-pack linux build --to all --namespace default/);
|
||||
expect(last).not.toMatch(/--containerized/);
|
||||
});
|
||||
|
||||
it("invokes pnpm via `npx --yes pnpm@<version>` (electronuserland/builder:base strips corepack, and the non-root container can't write Node shim dir)", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).not.toMatch(/corepack/);
|
||||
expect(last).toMatch(/npx --yes pnpm@/);
|
||||
});
|
||||
|
||||
it("hardcoded pnpm version stays in lockstep with root package.json `packageManager`", () => {
|
||||
// Guard against silent drift: if someone bumps packageManager in the
|
||||
// root package.json but forgets to update PNPM_VERSION in linux.ts,
|
||||
// the Linux container build would silently keep using the old pnpm.
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const rootPkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf-8")) as {
|
||||
packageManager?: string;
|
||||
};
|
||||
const match = String(rootPkg.packageManager ?? "").match(/^pnpm@(\d+\.\d+\.\d+)$/);
|
||||
expect(match, `expected root packageManager "pnpm@x.y.z", got ${rootPkg.packageManager}`).not.toBeNull();
|
||||
const expectedVersion = match![1];
|
||||
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).toContain(`npx --yes pnpm@${expectedVersion}`);
|
||||
});
|
||||
|
||||
it("forwards --dir /tools-pack so inner build output lands under the mounted host dir", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).toMatch(/--dir \/tools-pack/);
|
||||
});
|
||||
|
||||
it("forwards --portable when config.portable is true", () => {
|
||||
const args = buildDockerArgs({ ...makeConfig(), portable: true }, { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).toMatch(/--portable/);
|
||||
});
|
||||
|
||||
it("omits --portable when config.portable is false", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).not.toMatch(/--portable/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDesktopTemplate", () => {
|
||||
const template = `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Open Design (@@NAMESPACE@@)
|
||||
Exec=env OD_PACKAGED_NAMESPACE=@@NAMESPACE@@ @@EXEC_PATH@@ --appimage-extract-and-run %U
|
||||
Icon=@@ICON_PATH@@
|
||||
MimeType=x-scheme-handler/od;
|
||||
`;
|
||||
|
||||
it("substitutes all @@TOKEN@@ placeholders", () => {
|
||||
const out = renderDesktopTemplate(template, {
|
||||
namespace: "default",
|
||||
execPath: "/home/u/.local/bin/Open-Design.default.AppImage",
|
||||
iconName: "open-design-default",
|
||||
});
|
||||
expect(out).toContain("Name=Open Design (default)");
|
||||
expect(out).toContain(
|
||||
"Exec=env OD_PACKAGED_NAMESPACE=default /home/u/.local/bin/Open-Design.default.AppImage --appimage-extract-and-run %U",
|
||||
);
|
||||
expect(out).toContain("Icon=open-design-default");
|
||||
});
|
||||
|
||||
it("uses OD_PACKAGED_NAMESPACE (not OD_NAMESPACE) so apps/packaged actually picks up the namespace override", () => {
|
||||
const out = renderDesktopTemplate(template, {
|
||||
namespace: "ns",
|
||||
execPath: "/x",
|
||||
iconName: "open-design-ns",
|
||||
});
|
||||
expect(out).toMatch(/^Exec=env OD_PACKAGED_NAMESPACE=ns /m);
|
||||
expect(out).not.toMatch(/OD_NAMESPACE=/);
|
||||
});
|
||||
|
||||
it("preserves --appimage-extract-and-run on the Exec= line so menu launches bypass FUSE", () => {
|
||||
const out = renderDesktopTemplate(template, {
|
||||
namespace: "ns",
|
||||
execPath: "/x",
|
||||
iconName: "open-design-ns",
|
||||
});
|
||||
expect(out).toMatch(/^Exec=.*--appimage-extract-and-run .*%U$/m);
|
||||
});
|
||||
|
||||
it("leaves no @@...@@ tokens unsubstituted", () => {
|
||||
const out = renderDesktopTemplate(template, {
|
||||
namespace: "ns",
|
||||
execPath: "/x",
|
||||
iconName: "open-design-ns",
|
||||
});
|
||||
expect(out).not.toMatch(/@@[A-Z_]+@@/);
|
||||
});
|
||||
|
||||
it("preserves the MimeType=x-scheme-handler/od; line", () => {
|
||||
const out = renderDesktopTemplate(template, {
|
||||
namespace: "ns",
|
||||
execPath: "/x",
|
||||
iconName: "open-design-ns",
|
||||
});
|
||||
expect(out).toContain("MimeType=x-scheme-handler/od;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeNamespace", () => {
|
||||
it("replaces non-alphanumeric chars with hyphens", () => {
|
||||
expect(sanitizeNamespace("a/b c")).toBe("a-b-c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesAppImageProcess", () => {
|
||||
const installPath = "/home/u/.local/bin/Open-Design.default.AppImage";
|
||||
|
||||
it("matches FUSE-mode (executable === installPath)", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{ pid: 1234, executable: installPath, env: {} },
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("matches extracted-mode (env.APPIMAGE === installPath, executable matches /tmp/.mount_*/AppRun)", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{ pid: 1234, executable: "/tmp/.mount_abc123/AppRun", env: { APPIMAGE: installPath } },
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unrelated processes", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{ pid: 9999, executable: "/usr/bin/node", env: {} },
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects extracted-mode with mismatched APPIMAGE env", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{ pid: 1234, executable: "/tmp/.mount_abc/AppRun", env: { APPIMAGE: "/other/path.AppImage" } },
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects extracted-mode when APPIMAGE env is missing", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{ pid: 1234, executable: "/tmp/.mount_abc123/AppRun", env: {} },
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it("matches --appimage-extract-and-run mode (executable in /tmp/appimage_extracted_*/<binary>)", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{
|
||||
pid: 1234,
|
||||
executable: "/tmp/appimage_extracted_fe548e54/Open Design",
|
||||
env: { APPIMAGE: installPath },
|
||||
},
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects extract-and-run mode with mismatched APPIMAGE env", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{
|
||||
pid: 1234,
|
||||
executable: "/tmp/appimage_extracted_fe548e54/Open Design",
|
||||
env: { APPIMAGE: "/elsewhere/Other.AppImage" },
|
||||
},
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
61
tools/pack/tests/resources.test.ts
Normal file
61
tools/pack/tests/resources.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { copyBundledResourceTrees } from "../src/resources.js";
|
||||
|
||||
describe("copyBundledResourceTrees", () => {
|
||||
it("includes prompt templates", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-"));
|
||||
const workspaceRoot = join(root, "workspace");
|
||||
const resourceRoot = join(root, "resources");
|
||||
|
||||
try {
|
||||
const promptTemplatePath = join(
|
||||
workspaceRoot,
|
||||
"prompt-templates",
|
||||
"image",
|
||||
"sample.json",
|
||||
);
|
||||
const communityPetPath = join(
|
||||
workspaceRoot,
|
||||
"assets",
|
||||
"community-pets",
|
||||
"sample",
|
||||
"pet.json",
|
||||
);
|
||||
await mkdir(join(workspaceRoot, "skills", "sample"), { recursive: true });
|
||||
await mkdir(join(workspaceRoot, "design-systems", "sample"), {
|
||||
recursive: true,
|
||||
});
|
||||
await mkdir(join(workspaceRoot, "craft", "sample"), { recursive: true });
|
||||
await mkdir(join(workspaceRoot, "assets", "frames"), { recursive: true });
|
||||
await mkdir(join(workspaceRoot, "assets", "community-pets", "sample"), {
|
||||
recursive: true,
|
||||
});
|
||||
await mkdir(join(workspaceRoot, "prompt-templates", "image"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(promptTemplatePath, "{\"id\":\"sample\"}\n", "utf8");
|
||||
await writeFile(communityPetPath, "{\"name\":\"sample\"}\n", "utf8");
|
||||
|
||||
await copyBundledResourceTrees({ workspaceRoot, resourceRoot });
|
||||
|
||||
await expect(
|
||||
readFile(
|
||||
join(resourceRoot, "prompt-templates", "image", "sample.json"),
|
||||
"utf8",
|
||||
),
|
||||
).resolves.toBe("{\"id\":\"sample\"}\n");
|
||||
await expect(
|
||||
readFile(
|
||||
join(resourceRoot, "community-pets", "sample", "pet.json"),
|
||||
"utf8",
|
||||
),
|
||||
).resolves.toBe("{\"name\":\"sample\"}\n");
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
20
tools/pack/tsconfig.json
Normal file
20
tools/pack/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2024"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2024",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
9
tools/pack/tsconfig.tests.json
Normal file
9
tools/pack/tsconfig.tests.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user