diff --git a/.github/workflows/standalone.yml b/.github/workflows/standalone.yml new file mode 100644 index 0000000..faf83ba --- /dev/null +++ b/.github/workflows/standalone.yml @@ -0,0 +1,117 @@ +name: Standalone CLI + +"on": + pull_request: + branches: + - main + release: + types: + - published + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + runner: macos-15 + - target: darwin-x64 + runner: macos-15-intel + - target: linux-x64 + runner: ubuntu-24.04 + - target: windows-x64 + runner: windows-2025 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.18.0 + + - name: Install dependencies + run: npm ci + + - name: Build native standalone binary + run: node ./scripts/build-standalone.mjs --target ${{ matrix.target }} --out-dir dist/standalone + + - name: Smoke standalone binary + run: node ./scripts/smoke-standalone.mjs --dir dist/standalone + + - name: Upload native artifact + uses: actions/upload-artifact@v4 + with: + name: standalone-${{ matrix.target }} + path: dist/standalone/opendomain-* + if-no-files-found: error + retention-days: 7 + + aggregate: + name: Verify release asset set + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.18.0 + + - name: Download native artifacts + uses: actions/download-artifact@v4 + with: + pattern: standalone-* + path: dist/native-artifacts + + - name: Validate and assemble native artifacts + run: node ./scripts/assemble-standalone-assets.mjs --artifacts-dir dist/native-artifacts --out-dir dist/standalone + + - name: Verify matrix and write checksums + if: github.event_name != 'release' + run: node ./scripts/write-standalone-checksums.mjs --dir dist/standalone + + - name: Verify release tag, matrix, and checksums + if: github.event_name == 'release' + run: node ./scripts/write-standalone-checksums.mjs --dir dist/standalone --tag "${{ github.event.release.tag_name }}" + + - name: Upload verified release asset set + uses: actions/upload-artifact@v4 + with: + name: standalone-release-assets + path: dist/standalone/ + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish GitHub release assets + if: github.event_name == 'release' + needs: aggregate + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Download verified release asset set + uses: actions/download-artifact@v4 + with: + name: standalone-release-assets + path: dist/standalone + + - name: Upload assets to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$RELEASE_TAG" dist/standalone/opendomain-* dist/standalone/SHA256SUMS.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c88319..1fbefce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Add Node SEA standalone CLI builds for macOS arm64/x64, Linux x64, and + Windows x64 so projects can initialize OpenDomain without installing Node.js + or adding package metadata. +- Add a shared packaged-resource boundary and exact CLI version reporting so + npm and standalone channels use the same schemas, examples, and package + version. +- Add native executable smoke coverage, deterministic SHA-256 manifests, and a + least-privilege four-platform GitHub Actions release workflow. +- Refresh `minimatch`'s transitive `brace-expansion` dependency to the patched + `5.0.9` release. + ## 0.1.0-alpha.7 - 2026-08-03 - Add explicit `required`, `not_required`, and `unclassified` Grounding Request diff --git a/README.md b/README.md index f90807a..033577f 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ This workspace now includes the first MVP slices: grounding, indexing, and demo - Package-manager-neutral Agent bootstrap with managed Codex Skills, repository instructions, updates, and diagnostics +- Standalone macOS, Linux, and Windows CLI binaries that require no Node.js + runtime or host-project package metadata - OpenSpec `affects_domain` grounding - Explicit `required`, `not_required`, and `unclassified` grounding decisions - Advisory and enforced Grounding Assurance for Codex and CI @@ -60,9 +62,56 @@ This workspace now includes the first MVP slices: The source of truth remains Markdown with YAML front matter stored in Git. -## Usage +## Installation And Usage -Install the CLI from npm: +### Standalone Binary (Recommended) + +Download the binary and `SHA256SUMS.txt` for the same version from +[GitHub Releases](https://github.com/echopath-labs/openDomain/releases): + +| Platform | Minimum system | Release asset | +| --- | --- | --- | +| macOS Apple silicon | macOS 13.5 | `opendomain-v-darwin-arm64` | +| macOS Intel | macOS 13.5 | `opendomain-v-darwin-x64` | +| Linux x64 | kernel 4.18, glibc 2.28, libstdc++ 6.0.25 (`GLIBCXX_3.4.25`) | `opendomain-v-linux-x64` | +| Windows x64 | Windows 10 or Server 2016 | `opendomain-v-windows-x64.exe` | + +The standalone executable embeds the official Node.js 24.18.0 runtime and +inherits its operating-system requirements. Alpine/musl is not supported in +the initial matrix. See the +[Node.js 24 platform requirements](https://github.com/nodejs/node/blob/v24.18.0/BUILDING.md#platform-list). + +Verify the downloaded file against its line in `SHA256SUMS.txt` before running +it. Use `shasum -a 256 ` on macOS, `sha256sum ` on Linux, or +`Get-FileHash -Algorithm SHA256` in PowerShell. + +On macOS or Linux, make the file executable, rename it, and place it on `PATH`: + +```bash +chmod +x opendomain-v- +sudo install opendomain-v- /usr/local/bin/opendomain +``` + +On Windows, rename the asset to `opendomain.exe` and place it in a directory on +`PATH`. Then initialize a project without adding Node.js metadata: + +```bash +opendomain --version +opendomain init --tools codex +opendomain doctor +opendomain validate +``` + +Upgrade by downloading, verifying, and replacing the binary with the asset from +a newer release. The initial macOS binaries are ad-hoc signed but not notarized; +Windows binaries are not Authenticode signed. Checksums detect file changes but +do not establish publisher identity. Homebrew distribution is planned as a +separate follow-up and is not yet an installation channel. + +### npm (Alternative) + +Users who already manage a Node.js tool environment can install the same CLI +from npm: ```bash npm install -g @echopath-labs/opendomain @@ -71,7 +120,7 @@ opendomain doctor opendomain validate ``` -The global npm installation is a CLI distribution channel. OpenDomain does not +Both distribution channels run the same CLI. OpenDomain does not create or modify the host project's `package.json`, lockfile, dependency list, or npm scripts. `init --tools codex` adds the canonical `opendomain/` workspace, generated `.codex/skills/opendomain-*` adapters, and one managed OpenDomain @@ -87,7 +136,9 @@ If workspace configuration later deselects an adapter, `doctor` reports any remaining generated Skills and `update` removes only files that still carry OpenDomain generation ownership metadata. -Or try it from a source checkout: +### Source Checkout + +Maintainers can also run it from a source checkout. Common commands: diff --git a/README.zh-CN.md b/README.zh-CN.md index 0611c9b..bf90d25 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -103,6 +103,8 @@ OpenDomain 适合: index、update、doctor、demo; - 不要求宿主 `package.json` 的 Agent bootstrap,以及受管 Codex Skills 与 `AGENTS.md` 指令区块; +- 不要求预装 Node.js、也不向宿主项目引入 package metadata 的 macOS、Linux 和 + Windows 独立 CLI 二进制; - OpenSpec `affects_domain` grounding; - 显式 `required` / `not_required` / `unclassified` Grounding Request; - 面向 Codex 与 CI 的 advisory / enforced Grounding Assurance; @@ -132,7 +134,52 @@ OpenDomain 当前不做: ## 30 秒开始 -OpenDomain 的 npm 包名是 `@echopath-labs/opendomain`,CLI 命令是 `opendomain`。 +### 独立二进制(推荐) + +从 [GitHub Releases](https://github.com/echopath-labs/openDomain/releases) 下载同一 +版本的目标平台文件和 `SHA256SUMS.txt`: + +| 平台 | 最低系统要求 | Release 文件 | +| --- | --- | --- | +| macOS Apple silicon | macOS 13.5 | `opendomain-v-darwin-arm64` | +| macOS Intel | macOS 13.5 | `opendomain-v-darwin-x64` | +| Linux x64 | kernel 4.18、glibc 2.28、libstdc++ 6.0.25(`GLIBCXX_3.4.25`) | `opendomain-v-linux-x64` | +| Windows x64 | Windows 10 或 Server 2016 | `opendomain-v-windows-x64.exe` | + +独立二进制内嵌官方 Node.js 24.18.0 运行时,因此继承其操作系统要求。首批矩阵不 +支持 Alpine/musl。详见 +[Node.js 24 平台要求](https://github.com/nodejs/node/blob/v24.18.0/BUILDING.md#platform-list)。 + +运行前先核对文件 SHA-256 是否与 `SHA256SUMS.txt` 中对应行一致。macOS 可使用 +`shasum -a 256 `,Linux 可使用 `sha256sum `,PowerShell 可使用 +`Get-FileHash -Algorithm SHA256`。 + +macOS 或 Linux 用户需要添加执行权限并放入 `PATH`: + +```bash +chmod +x opendomain-v- +sudo install opendomain-v- /usr/local/bin/opendomain +``` + +Windows 用户可将文件改名为 `opendomain.exe`,再放入 `PATH` 中的目录。随后直接在 +项目中初始化,不需要 Node.js、`package.json` 或 npm scripts: + +```bash +opendomain --version +opendomain init --tools codex +opendomain doctor +opendomain validate +``` + +升级时下载新版本、重新校验 SHA-256,然后替换旧二进制。首批 macOS 产物只做 +ad-hoc signing,尚未 notarize;Windows 产物尚未做 Authenticode 签名。checksum +可以发现文件变化,但不等价于发布者身份证明。Homebrew 将在后续独立阶段提供, +当前还不是可用安装渠道。 + +### npm(可选) + +已经维护 Node.js 工具环境的用户,也可以安装同一套 CLI。OpenDomain 的 npm 包名是 +`@echopath-labs/opendomain`,CLI 命令是 `opendomain`。 全局安装 CLI: @@ -143,7 +190,7 @@ opendomain doctor opendomain validate ``` -npm 在这里仅是 CLI 的全局分发渠道。OpenDomain 不会在宿主项目中创建或修改 +两种分发渠道运行相同 CLI。OpenDomain 不会在宿主项目中创建或修改 `package.json`、lockfile、依赖声明或 npm scripts。`init --tools codex` 会创建 canonical `opendomain/`、生成 `.codex/skills/opendomain-*`,并在 `AGENTS.md` 中维护一个有明确边界的 OpenDomain 区块;区块之外的项目指令保持原样。 @@ -155,7 +202,9 @@ canonical `opendomain/`、生成 `.codex/skills/opendomain-*`,并在 `AGENTS.m 如果 workspace config 后续取消选择某个 adapter,`doctor` 会报告残留的 generated Skills,`update` 只移除仍带 OpenDomain generation ownership metadata 的文件。 -也可以从源码运行。 +### 源码开发 + +维护者也可以从源码运行。 克隆仓库: @@ -273,13 +322,13 @@ OpenSpec 描述这次变更,OpenDomain 描述长期语义。 Codex 在实现非平凡 Feature 前默认执行只读 Assurance: ```bash -npm run opendomain -- assure -npm run opendomain -- assure --mode enforced --json +opendomain assure +opendomain assure --mode enforced --json ``` `assure` 会复用 `prepare` 的解析和 Semantic Closure。只需要查看原始 Grounding Pack、而不需要策略判断时,可以单独运行 -`npm run opendomain -- prepare `。 +`opendomain prepare `。 Grounding Requirement 有三个显式状态: @@ -345,9 +394,9 @@ Assurance 文本输出会保留该 review status,包括最终的 `rejected`、 `opendomain/integrations/profiles/` 中声明 repository-local Profile: ```bash -npm run opendomain -- integrations validate -npm run opendomain -- integrations list -npm run opendomain -- prepare --profile +opendomain integrations validate +opendomain integrations list +opendomain prepare --profile ``` 未显式选择时,只有一个 built-in adapter 或 Profile 匹配才会继续;多重匹配会 @@ -404,25 +453,25 @@ Candidate 不是 accepted truth。它只是待人类审查的提案。 | 初始化 OpenDomain 与 Codex | `opendomain init --tools codex` | | 更新托管 Agent 适配 | `opendomain update` | | 检查 workspace 与 Agent 适配 | `opendomain doctor` | -| 复制 ERP 示例 | `npm run opendomain -- init --example erp` | -| 验证全部 OpenDomain 文件 | `npm run opendomain -- validate` | -| 验证指定目录 | `npm run opendomain -- validate examples/erp` | -| 输出 JSON 验证结果 | `npm run opendomain -- validate examples/erp --json` | -| 为 Feature 准备 grounding | `npm run opendomain -- prepare ` | -| 执行 advisory Assurance | `npm run opendomain -- assure ` | -| 执行 enforced Assurance | `npm run opendomain -- assure --mode enforced --json` | -| 显式使用 OpenSpec integration | `npm run opendomain -- prepare --integration openspec ` | -| 列出 integration | `npm run opendomain -- integrations list` | -| 验证 Integration Profile | `npm run opendomain -- integrations validate` | -| 显式使用本地 Profile | `npm run opendomain -- prepare --profile ` | -| 列出 Candidate | `npm run opendomain -- candidate list examples/erp` | -| 查看 Candidate | `npm run opendomain -- candidate show examples/erp` | -| 记录 Candidate review | `npm run opendomain -- candidate review --decision rejected --reviewed-by --reason examples/erp` | -| 列出 ID | `npm run opendomain -- ids list examples/erp` | -| 检查引用 | `npm run opendomain -- refs check examples/erp` | -| 构建 index | `npm run opendomain -- index build examples/erp --out /tmp/erp-index.json` | -| 查询 ID | `npm run opendomain -- index query sales.order --index /tmp/erp-index.json` | -| 查询 context | `npm run opendomain -- index query --context sales --index /tmp/erp-index.json` | +| 复制 ERP 示例 | `opendomain init --example erp` | +| 验证全部 OpenDomain 文件 | `opendomain validate` | +| 验证指定目录 | `opendomain validate examples/erp` | +| 输出 JSON 验证结果 | `opendomain validate examples/erp --json` | +| 为 Feature 准备 grounding | `opendomain prepare ` | +| 执行 advisory Assurance | `opendomain assure ` | +| 执行 enforced Assurance | `opendomain assure --mode enforced --json` | +| 显式使用 OpenSpec integration | `opendomain prepare --integration openspec ` | +| 列出 integration | `opendomain integrations list` | +| 验证 Integration Profile | `opendomain integrations validate` | +| 显式使用本地 Profile | `opendomain prepare --profile ` | +| 列出 Candidate | `opendomain candidate list examples/erp` | +| 查看 Candidate | `opendomain candidate show examples/erp` | +| 记录 Candidate review | `opendomain candidate review --decision rejected --reviewed-by --reason examples/erp` | +| 列出 ID | `opendomain ids list examples/erp` | +| 检查引用 | `opendomain refs check examples/erp` | +| 构建 index | `opendomain index build examples/erp --out /tmp/erp-index.json` | +| 查询 ID | `opendomain index query sales.order --index /tmp/erp-index.json` | +| 查询 context | `opendomain index query --context sales --index /tmp/erp-index.json` | | 运行 demo | `npm run demo` | | 运行测试 | `npm test` | diff --git a/package-lock.json b/package-lock.json index 480f89c..6b3290a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,456 @@ "bin": { "opendomain": "bin/opendomain.mjs" }, + "devDependencies": { + "esbuild": "0.28.1", + "postject": "1.0.0-alpha.6" + }, "engines": { "node": "20 || >=22" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -64,9 +510,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -75,6 +521,58 @@ "node": "20 || >=22" } }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -118,6 +616,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/package.json b/package.json index 28d083e..247f187 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,9 @@ "validate": "node ./bin/opendomain.mjs validate", "prepare:demo": "cd examples/erp && node ../../bin/opendomain.mjs prepare openspec/changes/order-cancellation/spec.md", "demo": "node ./bin/opendomain.mjs demo order-cancellation", + "build:standalone": "node ./scripts/build-standalone.mjs", "smoke:package": "node ./scripts/smoke-installed-package.mjs", + "smoke:standalone": "node ./scripts/smoke-standalone.mjs", "prepublishOnly": "npm test && npm run opendomain -- validate" }, "engines": { @@ -63,5 +65,9 @@ "ajv-formats": "^3.0.1", "minimatch": "^10.2.5", "yaml": "^2.9.0" + }, + "devDependencies": { + "esbuild": "0.28.1", + "postject": "1.0.0-alpha.6" } } diff --git a/scripts/assemble-standalone-assets.mjs b/scripts/assemble-standalone-assets.mjs new file mode 100644 index 0000000..c4e0e26 --- /dev/null +++ b/scripts/assemble-standalone-assets.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import { + assembleStandaloneReleaseAssets, + assertReleaseTagVersion +} from "./lib/standalone-release.mjs"; +import { getPackageVersion } from "../src/packaged-resources.mjs"; + +async function main() { + const options = parseArguments(process.argv.slice(2)); + const version = getPackageVersion(); + if (options.releaseTag) { + assertReleaseTagVersion(options.releaseTag, version); + } + const assets = await assembleStandaloneReleaseAssets( + options.artifactRoot, + options.outputDirectory, + version + ); + process.stdout.write(`${assets.join("\n")}\n`); +} + +function parseArguments(arguments_) { + const options = { + artifactRoot: "dist/native-artifacts", + outputDirectory: "dist/standalone" + }; + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "--artifacts-dir") { + options.artifactRoot = requiredValue(arguments_, ++index, argument); + } else if (argument === "--out-dir") { + options.outputDirectory = requiredValue(arguments_, ++index, argument); + } else if (argument === "--tag") { + options.releaseTag = requiredValue(arguments_, ++index, argument); + } else { + throw new Error(`Unknown standalone assembly option '${argument}'.`); + } + } + return options; +} + +function requiredValue(arguments_, index, option) { + const value = arguments_[index]; + if (!value || value.startsWith("--")) { + throw new Error(`Standalone assembly option '${option}' requires a value.`); + } + return value; +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/build-standalone.mjs b/scripts/build-standalone.mjs new file mode 100644 index 0000000..d6eb2f2 --- /dev/null +++ b/scripts/build-standalone.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +import { execFile } from "node:child_process"; +import { copyFile, chmod, mkdtemp, mkdir, rename, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { build } from "esbuild"; +import { + assertNativeStandaloneTarget, + assertStandaloneNodeVersion, + hostStandaloneTarget, + standaloneAssetName +} from "./lib/standalone-release.mjs"; +import { + getPackageVersion, + listPackagedFiles +} from "../src/packaged-resources.mjs"; + +const execute = promisify(execFile); +const repositoryRoot = fileURLToPath(new URL("../", import.meta.url)); +const SEA_SENTINEL_FUSE = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"; + +async function main() { + const options = parseArguments(process.argv.slice(2)); + const target = options.target ?? hostStandaloneTarget(); + assertNativeStandaloneTarget(target); + assertStandaloneNodeVersion(); + + const version = getPackageVersion(); + const outputDirectory = path.resolve(repositoryRoot, options.outputDirectory); + const outputPath = path.join(outputDirectory, standaloneAssetName(version, target)); + const temporaryOutputPath = path.join( + outputDirectory, + `.${path.basename(outputPath)}.${process.pid}.tmp${process.platform === "win32" ? ".exe" : ""}` + ); + const workingDirectory = await mkdtemp(path.join(os.tmpdir(), "opendomain-sea-")); + + try { + await mkdir(outputDirectory, { recursive: true }); + const bundlePath = path.join(workingDirectory, "opendomain.cjs"); + const blobPath = path.join(workingDirectory, "opendomain.blob"); + const configPath = path.join(workingDirectory, "sea-config.json"); + + await build({ + entryPoints: [path.join(repositoryRoot, "bin/opendomain.mjs")], + outfile: bundlePath, + bundle: true, + platform: "node", + format: "cjs", + target: "node24", + define: { + __OPENDOMAIN_SEA__: "true" + }, + legalComments: "none", + logLevel: "warning", + logOverride: { + "empty-import-meta": "silent" + }, + minify: false, + sourcemap: false + }); + + await writeFile(configPath, `${JSON.stringify({ + main: bundlePath, + output: blobPath, + disableExperimentalSEAWarning: true, + useCodeCache: false, + useSnapshot: false, + execArgvExtension: "none", + assets: packagedAssetMap() + }, null, 2)}\n`, "utf8"); + + await run(process.execPath, ["--experimental-sea-config", configPath]); + await copyFile(process.execPath, temporaryOutputPath); + + if (process.platform === "darwin") { + await run("codesign", ["--remove-signature", temporaryOutputPath]); + } + + const postjectCli = fileURLToPath(new URL( + "../node_modules/postject/dist/cli.js", + import.meta.url + )); + const postjectArguments = [ + postjectCli, + temporaryOutputPath, + "NODE_SEA_BLOB", + blobPath, + "--sentinel-fuse", + SEA_SENTINEL_FUSE + ]; + if (process.platform === "darwin") { + postjectArguments.push("--macho-segment-name", "NODE_SEA"); + } + await run(process.execPath, postjectArguments); + + if (process.platform === "darwin") { + await run("codesign", ["--sign", "-", "--force", temporaryOutputPath]); + } else if (process.platform !== "win32") { + await chmod(temporaryOutputPath, 0o755); + } + + await rm(outputPath, { force: true }); + await rename(temporaryOutputPath, outputPath); + process.stdout.write(`${outputPath}\n`); + } finally { + await rm(temporaryOutputPath, { force: true }); + await rm(workingDirectory, { recursive: true, force: true }); + } +} + +function packagedAssetMap() { + const resourcePaths = [ + "package.json", + ...listPackagedFiles("schemas"), + ...listPackagedFiles("examples/erp") + ].sort(); + return Object.fromEntries(resourcePaths.map((resourcePath) => [ + resourcePath, + path.join(repositoryRoot, ...resourcePath.split("/")) + ])); +} + +function parseArguments(arguments_) { + const options = { + outputDirectory: "dist/standalone" + }; + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "--target") { + options.target = requiredValue(arguments_, ++index, argument); + } else if (argument === "--out-dir") { + options.outputDirectory = requiredValue(arguments_, ++index, argument); + } else { + throw new Error(`Unknown standalone build option '${argument}'.`); + } + } + return options; +} + +function requiredValue(arguments_, index, option) { + const value = arguments_[index]; + if (!value || value.startsWith("--")) { + throw new Error(`Standalone build option '${option}' requires a value.`); + } + return value; +} + +async function run(command, arguments_) { + try { + return await execute(command, arguments_, { + cwd: repositoryRoot, + maxBuffer: 10 * 1024 * 1024 + }); + } catch (error) { + const detail = error.stderr || error.stdout || error.message; + throw new Error(`Command '${command}' failed: ${String(detail).trim()}`, { cause: error }); + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/lib/standalone-release.mjs b/scripts/lib/standalone-release.mjs new file mode 100644 index 0000000..4f9cd7a --- /dev/null +++ b/scripts/lib/standalone-release.mjs @@ -0,0 +1,186 @@ +import { createHash } from "node:crypto"; +import { copyFile, mkdir, readFile, readdir, realpath } from "node:fs/promises"; +import path from "node:path"; + +export const STANDALONE_NODE_VERSION = "24.18.0"; + +export const STANDALONE_TARGETS = Object.freeze([ + "darwin-arm64", + "darwin-x64", + "linux-x64", + "windows-x64" +]); + +const HOST_TARGETS = Object.freeze({ + "darwin:arm64": "darwin-arm64", + "darwin:x64": "darwin-x64", + "linux:x64": "linux-x64", + "win32:x64": "windows-x64" +}); + +export function hostStandaloneTarget(platform = process.platform, architecture = process.arch) { + const target = HOST_TARGETS[`${platform}:${architecture}`]; + if (!target) { + throw new Error(`Unsupported standalone host '${platform}-${architecture}'.`); + } + return target; +} + +export function assertNativeStandaloneTarget( + requestedTarget, + platform = process.platform, + architecture = process.arch +) { + const nativeTarget = hostStandaloneTarget(platform, architecture); + if (requestedTarget !== nativeTarget) { + throw new Error( + `Requested standalone target '${requestedTarget}' does not match native host '${nativeTarget}'.` + ); + } + return nativeTarget; +} + +export function assertStandaloneNodeVersion(version = process.versions.node) { + if (version !== STANDALONE_NODE_VERSION) { + throw new Error( + `Standalone builds require Node ${STANDALONE_NODE_VERSION}; current runtime is ${version}.` + ); + } + return version; +} + +export function standalonePathsOverlap(left, right, platform = process.platform) { + const implementation = platform === "win32" ? path.win32 : path.posix; + const normalize = (value) => { + const normalized = implementation.resolve(value); + return platform === "win32" ? normalized.toLowerCase() : normalized; + }; + const normalizedLeft = normalize(left); + const normalizedRight = normalize(right); + return isSameOrDescendant(normalizedLeft, normalizedRight, implementation) + || isSameOrDescendant(normalizedRight, normalizedLeft, implementation); +} + +export function standaloneAssetName(version, target) { + assertVersion(version); + if (!STANDALONE_TARGETS.includes(target)) { + throw new Error(`Unsupported standalone target '${target}'.`); + } + const extension = target.startsWith("windows-") ? ".exe" : ""; + return `opendomain-v${version}-${target}${extension}`; +} + +export function assertReleaseTagVersion(tag, version) { + assertVersion(version); + if (typeof tag !== "string" || !tag.startsWith("v")) { + throw new Error(`Release tag '${String(tag)}' must use the form 'v'.`); + } + if (tag !== `v${version}`) { + throw new Error(`Release tag '${tag}' does not match package version '${version}'.`); + } + return version; +} + +export async function assembleStandaloneReleaseAssets(artifactRoot, outputDirectory, version) { + assertVersion(version); + const root = await realpath(path.resolve(artifactRoot)); + const requestedOutput = path.resolve(outputDirectory); + const outputParent = await realpath(path.dirname(requestedOutput)); + const output = path.join(outputParent, path.basename(requestedOutput)); + if (standalonePathsOverlap(root, output)) { + throw new Error("Standalone release output and native artifact root must not overlap."); + } + + const expectedDirectories = STANDALONE_TARGETS + .map((target) => `standalone-${target}`) + .sort(); + const rootEntries = await readdir(root, { withFileTypes: true }); + const actualDirectories = rootEntries.map((entry) => entry.name).sort(); + if ( + actualDirectories.length !== expectedDirectories.length + || actualDirectories.some((name, index) => name !== expectedDirectories[index]) + || rootEntries.some((entry) => !entry.isDirectory()) + ) { + throw new Error( + `Native artifact directories must be exactly: ${expectedDirectories.join(", ")}.` + ); + } + + const sources = []; + for (const target of STANDALONE_TARGETS) { + const artifactDirectory = path.join(root, `standalone-${target}`); + const expectedAsset = standaloneAssetName(version, target); + const entries = await readdir(artifactDirectory, { withFileTypes: true }); + if ( + entries.length !== 1 + || entries[0].name !== expectedAsset + || !entries[0].isFile() + ) { + const names = entries.map((entry) => entry.name).sort(); + throw new Error( + `Found unexpected files in native artifact 'standalone-${target}': ${names.join(", ") || ""}.` + ); + } + sources.push({ source: path.join(artifactDirectory, expectedAsset), name: expectedAsset }); + } + + try { + await mkdir(output); + } catch (error) { + if (error.code === "EEXIST") { + throw new Error(`Standalone release output '${output}' must not already exist.`, { + cause: error + }); + } + throw error; + } + for (const item of sources) { + await copyFile(item.source, path.join(output, item.name)); + } + return sources.map((item) => item.name).sort(); +} + +function isSameOrDescendant(parent, candidate, implementation) { + const relative = implementation.relative(parent, candidate); + return relative === "" + || ( + relative !== ".." + && !relative.startsWith(`..${implementation.sep}`) + && !implementation.isAbsolute(relative) + ); +} + +export async function createStandaloneChecksumManifest(directory, version) { + const expected = STANDALONE_TARGETS + .map((target) => standaloneAssetName(version, target)) + .sort(); + const entries = await readdir(directory, { withFileTypes: true }); + const actual = entries + .filter((entry) => entry.isFile() && entry.name !== "SHA256SUMS.txt") + .map((entry) => entry.name) + .sort(); + const expectedSet = new Set(expected); + const actualSet = new Set(actual); + const unexpected = actual.filter((name) => !expectedSet.has(name)); + const missing = expected.filter((name) => !actualSet.has(name)); + + if (unexpected.length > 0) { + throw new Error(`Found unexpected standalone release assets: ${unexpected.join(", ")}.`); + } + if (missing.length > 0) { + throw new Error(`Found missing standalone release assets: ${missing.join(", ")}.`); + } + const lines = []; + for (const name of expected) { + const content = await readFile(path.join(directory, name)); + const hash = createHash("sha256").update(content).digest("hex"); + lines.push(`${hash} ${name}`); + } + return `${lines.join("\n")}\n`; +} + +function assertVersion(version) { + if (typeof version !== "string" || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid standalone release version '${String(version)}'.`); + } +} diff --git a/scripts/smoke-installed-package.mjs b/scripts/smoke-installed-package.mjs index ce79541..93378f7 100644 --- a/scripts/smoke-installed-package.mjs +++ b/scripts/smoke-installed-package.mjs @@ -59,6 +59,18 @@ try { await access(path.join(installedRoot, "schemas", "assurance-result.schema.json")); await access(path.join(installedRoot, "schemas", "workspace-config.schema.json")); await access(path.join(installedRoot, "scripts", "smoke-installed-package.mjs")); + for (const maintainerScript of [ + "assemble-standalone-assets.mjs", + "build-standalone.mjs", + "lib/standalone-release.mjs", + "smoke-standalone.mjs", + "write-standalone-checksums.mjs" + ]) { + await assert.rejects( + access(path.join(installedRoot, "scripts", ...maintainerScript.split("/"))), + (error) => error?.code === "ENOENT" + ); + } const init = await runJsonCli(cli, [ "init", diff --git a/scripts/smoke-standalone.mjs b/scripts/smoke-standalone.mjs new file mode 100644 index 0000000..028b26f --- /dev/null +++ b/scripts/smoke-standalone.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import { + access, + constants, + mkdtemp, + rm, + stat +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { + hostStandaloneTarget, + standaloneAssetName +} from "./lib/standalone-release.mjs"; +import { getPackageVersion } from "../src/packaged-resources.mjs"; + +const execFile = promisify(execFileCallback); + +async function main() { + const options = parseArguments(process.argv.slice(2)); + const version = getPackageVersion(); + const target = hostStandaloneTarget(); + const binary = options.binary + ? path.resolve(options.binary) + : path.resolve(options.directory, standaloneAssetName(version, target)); + assert.equal(path.basename(binary), standaloneAssetName(version, target)); + await access(binary, constants.R_OK); + if (process.platform !== "win32") { + assert.notEqual((await stat(binary)).mode & 0o111, 0, "Standalone binary must be executable."); + } + + const versionResult = await run(binary, ["--version"], process.cwd()); + assert.equal(versionResult.stdout, `${version}\n`); + assert.equal(versionResult.stderr, ""); + const helpResult = await run(binary, ["--help"], process.cwd()); + assert.match(helpResult.stdout, /opendomain init/); + assert.equal(helpResult.stderr, ""); + + const workspace = await mkdtemp(path.join(os.tmpdir(), "opendomain-standalone-smoke-")); + try { + await assertAbsent(path.join(workspace, "package.json")); + await assertAbsent(path.join(workspace, "package-lock.json")); + + const init = await runJson(binary, [ + "init", + "--tools", + "codex", + "--example", + "erp", + "--json" + ], workspace); + assert.deepEqual(init.errors, []); + await access(path.join(workspace, "opendomain", "README.md")); + await access(path.join(workspace, ".codex", "skills", "opendomain-explore", "SKILL.md")); + + const doctor = await runJson(binary, ["doctor", "--json"], workspace); + assert.equal(doctor.status, "healthy"); + assert.deepEqual(doctor.errors, []); + + const workspaceValidation = await runJson(binary, ["validate", "--json"], workspace); + assert.deepEqual(workspaceValidation.errors, []); + + const exampleRoot = path.join(workspace, "examples", "erp"); + const exampleValidation = await runJson(binary, ["validate", "--json"], exampleRoot); + assert.deepEqual(exampleValidation.errors, []); + + const sourceUnit = "openspec/changes/order-cancellation/spec.md"; + const groundingPack = await runJson(binary, ["prepare", sourceUnit, "--json"], exampleRoot); + assert.deepEqual(groundingPack.errors, []); + assert.ok(groundingPack.read_first.some((item) => item.id === "sales.order")); + assert.ok(groundingPack.candidate_boundaries.length > 0); + + const assurance = await runJson(binary, ["assure", sourceUnit, "--json"], exampleRoot); + assert.equal(assurance.grounding_pack.grounding_request.grounding.status, "required"); + assert.equal(assurance.preparation.state, "prepared"); + assert.notEqual(assurance.policy.outcome, "fail"); + assert.ok(assurance.grounding_pack.read_first.some((item) => item.id === "sales.order")); + + await assertAbsent(path.join(workspace, "package.json")); + await assertAbsent(path.join(workspace, "package-lock.json")); + + process.stdout.write( + `Standalone smoke passed: ${path.basename(binary)}, ` + + `${groundingPack.read_first.length} grounded sources, ` + + `Agent integration ${doctor.status}, ` + + `Assurance ${assurance.policy.outcome}.\n` + ); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +} + +function parseArguments(arguments_) { + if (arguments_.length !== 2 || !arguments_[1]) { + throw new Error("Usage: smoke-standalone (--binary | --dir )"); + } + if (arguments_[0] === "--binary") { + return { binary: arguments_[1] }; + } + if (arguments_[0] === "--dir") { + return { directory: arguments_[1] }; + } + throw new Error("Usage: smoke-standalone (--binary | --dir )"); +} + +async function assertAbsent(file) { + await assert.rejects(access(file), (error) => error?.code === "ENOENT"); +} + +async function runJson(binary, arguments_, cwd) { + const result = await run(binary, arguments_, cwd); + return JSON.parse(result.stdout); +} + +async function run(command, arguments_, cwd) { + try { + return await execFile(command, arguments_, { + cwd, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024 + }); + } catch (error) { + const stdout = error.stdout ? `\nstdout:\n${error.stdout}` : ""; + const stderr = error.stderr ? `\nstderr:\n${error.stderr}` : ""; + throw new Error( + `Command failed: ${command} ${arguments_.join(" ")}${stdout}${stderr}`, + { cause: error } + ); + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/write-standalone-checksums.mjs b/scripts/write-standalone-checksums.mjs new file mode 100644 index 0000000..1682e69 --- /dev/null +++ b/scripts/write-standalone-checksums.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + assertReleaseTagVersion, + createStandaloneChecksumManifest +} from "./lib/standalone-release.mjs"; +import { getPackageVersion } from "../src/packaged-resources.mjs"; + +async function main() { + const options = parseArguments(process.argv.slice(2)); + const version = getPackageVersion(); + if (options.releaseTag) { + assertReleaseTagVersion(options.releaseTag, version); + } + const directory = path.resolve(options.directory); + const manifest = await createStandaloneChecksumManifest(directory, version); + const manifestPath = path.join(directory, "SHA256SUMS.txt"); + await writeFile(manifestPath, manifest, "utf8"); + process.stdout.write(`${manifestPath}\n`); +} + +function parseArguments(arguments_) { + const options = { directory: "dist/standalone" }; + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "--dir") { + options.directory = requiredValue(arguments_, ++index, argument); + } else if (argument === "--tag") { + options.releaseTag = requiredValue(arguments_, ++index, argument); + } else { + throw new Error(`Unknown checksum option '${argument}'.`); + } + } + return options; +} + +function requiredValue(arguments_, index, option) { + const value = arguments_[index]; + if (!value || value.startsWith("--")) { + throw new Error(`Checksum option '${option}' requires a value.`); + } + return value; +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/src/cli.mjs b/src/cli.mjs index 4c51e33..11723d2 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -20,6 +20,7 @@ import { querySemanticIndex, writeSemanticIndex } from "./indexer.mjs"; +import { getPackageVersion } from "./packaged-resources.mjs"; export async function runCli(argv, options = {}) { const io = { @@ -35,6 +36,11 @@ export async function runCli(argv, options = {}) { return 0; } + if (command === "version" || command === "--version" || command === "-v") { + io.stdout.write(`${getPackageVersion()}\n`); + return 0; + } + if (command === "validate") { return runValidate([subcommand, ...rest].filter(Boolean), io); } @@ -104,6 +110,7 @@ function printHelp(stream) { stream.write(`OpenDomain CLI Usage: + opendomain --version opendomain init [--tools codex] [--example erp] [--json] opendomain update [--json] opendomain doctor [--json] diff --git a/src/init.mjs b/src/init.mjs index c5ab923..ab4352a 100644 --- a/src/init.mjs +++ b/src/init.mjs @@ -1,13 +1,11 @@ -import { access, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { access, mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { applyAgentSkills, planAgentSkills } from "./managed-agent-skills.mjs"; import { applyManagedAgents, planManagedAgents } from "./managed-agents.mjs"; +import { listPackagedFiles, readPackagedText } from "./packaged-resources.mjs"; import { inspectWorkspaceRoots } from "./workspace-resolver.mjs"; import { applyWorkspaceConfig, planWorkspaceConfig } from "./workspace-config.mjs"; -const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); - const WORKSPACE_DIRECTORIES = [ "opendomain", "opendomain/contexts", @@ -119,10 +117,11 @@ export async function initializeProject(options = {}) { } async function copyExample(example, cwd, result) { - const source = path.join(packageRoot, "examples", example); + const sourcePrefix = `examples/${example}/`; const target = path.join(cwd, "examples", example); + const files = listPackagedFiles(sourcePrefix); - if (!await exists(source)) { + if (files.length === 0) { result.errors.push({ severity: "error", file: `examples/${example}`, @@ -133,26 +132,35 @@ async function copyExample(example, cwd, result) { return; } - await copyTree(source, target, cwd, result); + await ensureDirectory(target, cwd, result); + for (const relativeDirectory of packagedDirectories(files, sourcePrefix)) { + await ensureDirectory( + path.join(target, ...relativeDirectory.split("/")), + cwd, + result + ); + } + for (const sourceFile of files) { + const relativeFile = sourceFile.slice(sourcePrefix.length); + await writeFileIfMissing( + path.join(target, ...relativeFile.split("/")), + readPackagedText(sourceFile), + cwd, + result + ); + } } -async function copyTree(source, target, cwd, result) { - const sourceStat = await stat(source); - if (sourceStat.isDirectory()) { - await ensureDirectory(target, cwd, result); - const entries = await readdir(source, { withFileTypes: true }); - for (const entry of entries) { - await copyTree(path.join(source, entry.name), path.join(target, entry.name), cwd, result); +function packagedDirectories(files, sourcePrefix) { + const directories = new Set(); + for (const sourceFile of files) { + let directory = path.posix.dirname(sourceFile.slice(sourcePrefix.length)); + while (directory !== ".") { + directories.add(directory); + directory = path.posix.dirname(directory); } - return; } - - if (!sourceStat.isFile()) { - return; - } - - const content = await readFile(source, "utf8"); - await writeFileIfMissing(target, content, cwd, result); + return [...directories].sort(); } async function ensureDirectory(directory, cwd, result) { diff --git a/src/integration-schema-validator.mjs b/src/integration-schema-validator.mjs index 4301da6..1e2ca0b 100644 --- a/src/integration-schema-validator.mjs +++ b/src/integration-schema-validator.mjs @@ -2,8 +2,8 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import Ajv2020 from "ajv/dist/2020.js"; +import { readPackagedText } from "./packaged-resources.mjs"; -const DEFAULT_SCHEMA_DIRECTORY = new URL("../schemas/", import.meta.url); const SCHEMA_ID_PREFIX = "https://opendomain.dev/schemas/"; const SCHEMA_DEFINITIONS = Object.freeze({ @@ -32,9 +32,9 @@ export function getDefaultIntegrationSchemaRegistry() { } export function createIntegrationSchemaRegistry(options = {}) { - const schemaDirectory = normalizeSchemaDirectory( - options.schemaDirectory ?? DEFAULT_SCHEMA_DIRECTORY - ); + const schemaDirectory = options.schemaDirectory === undefined + ? null + : normalizeSchemaDirectory(options.schemaDirectory); const schemas = Object.entries(SCHEMA_DEFINITIONS).map(([kind, file]) => ({ kind, file, @@ -126,7 +126,9 @@ export function validateIntegrationValue(kind, value, registry = getDefaultInteg function readPackagedSchema(schemaDirectory, file) { let source; try { - source = readFileSync(new URL(file, schemaDirectory), "utf8"); + source = schemaDirectory + ? readFileSync(new URL(file, schemaDirectory), "utf8") + : readPackagedText(`schemas/${file}`); } catch (error) { throw new IntegrationSchemaRegistryError( `Packaged schema '${file}' could not be read: ${error.message}`, diff --git a/src/packaged-resources.mjs b/src/packaged-resources.mjs new file mode 100644 index 0000000..6f6f394 --- /dev/null +++ b/src/packaged-resources.mjs @@ -0,0 +1,124 @@ +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SEA_BUILD_ENABLED = typeof __OPENDOMAIN_SEA__ !== "undefined" + && __OPENDOMAIN_SEA__ === true; + +let packageMetadata; + +export function readPackagedText(relativePath) { + const normalized = normalizePackagedPath(relativePath); + const sea = seaRuntime(); + + if (sea) { + try { + return sea.getAsset(normalized, "utf8"); + } catch (error) { + throw new Error(`Packaged resource '${normalized}' could not be read: ${error.message}`, { + cause: error + }); + } + } + + try { + return readFileSync(new URL(normalized, packageRootUrl()), "utf8"); + } catch (error) { + throw new Error(`Packaged resource '${normalized}' could not be read: ${error.message}`, { + cause: error + }); + } +} + +export function listPackagedFiles(relativePrefix) { + const prefix = normalizePackagedPath(relativePrefix, { prefix: true }); + const sea = seaRuntime(); + + if (sea) { + return sea.getAssetKeys() + .filter((key) => key.startsWith(prefix) && !key.endsWith("/")) + .sort(); + } + + const root = fileURLToPath(new URL(prefix, packageRootUrl())); + try { + return listFiles(root) + .map((file) => `${prefix}${file}`) + .sort(); + } catch (error) { + if (error.code === "ENOENT") { + return []; + } + throw new Error(`Packaged resource prefix '${prefix}' could not be listed: ${error.message}`, { + cause: error + }); + } +} + +export function getPackageVersion() { + if (!packageMetadata) { + packageMetadata = JSON.parse(readPackagedText("package.json")); + } + return packageMetadata.version; +} + +function seaRuntime() { + if (!SEA_BUILD_ENABLED) { + return null; + } + + const sea = require("node:sea"); + return sea.isSea() ? sea : null; +} + +function packageRootUrl() { + return new URL("../", import.meta.url); +} + +function normalizePackagedPath(value, options = {}) { + if ( + typeof value !== "string" + || value.length === 0 + || value.includes("\\") + || path.posix.isAbsolute(value) + ) { + throw unsafePathError(value); + } + + const prefix = options.prefix === true; + const normalized = prefix && !value.endsWith("/") ? `${value}/` : value; + const segments = normalized.split("/"); + const pathSegments = prefix ? segments.slice(0, -1) : segments; + if ( + pathSegments.length === 0 + || pathSegments.some((segment) => segment === "" || segment === "." || segment === "..") + || pathSegments.some((segment) => !/^[A-Za-z0-9._-]+$/.test(segment)) + || (!prefix && normalized.endsWith("/")) + ) { + throw unsafePathError(value); + } + return normalized; +} + +function listFiles(root, relativeDirectory = "") { + const directory = path.join(root, relativeDirectory); + const entries = readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + const files = []; + + for (const entry of entries) { + const relative = relativeDirectory + ? path.posix.join(relativeDirectory.split(path.sep).join("/"), entry.name) + : entry.name; + if (entry.isDirectory()) { + files.push(...listFiles(root, relative.split("/").join(path.sep))); + } else if (entry.isFile()) { + files.push(relative); + } + } + return files; +} + +function unsafePathError(value) { + return new Error(`Packaged resource path '${String(value)}' must be a safe package-relative path.`); +} diff --git a/src/schema-validator.mjs b/src/schema-validator.mjs index a5dce7e..ee2a83f 100644 --- a/src/schema-validator.mjs +++ b/src/schema-validator.mjs @@ -3,8 +3,8 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import Ajv2020 from "ajv/dist/2020.js"; import addFormats from "ajv-formats"; +import { readPackagedText } from "./packaged-resources.mjs"; -const DEFAULT_SCHEMA_DIRECTORY = new URL("../schemas/", import.meta.url); const SCHEMA_ID_PREFIX = "https://opendomain.dev/schemas/"; const DOMAIN_SCHEMA_DEFINITIONS = Object.freeze([ @@ -39,9 +39,9 @@ export function getDefaultDomainSchemaRegistry() { } export function createDomainSchemaRegistry(options = {}) { - const schemaDirectory = normalizeSchemaDirectory( - options.schemaDirectory ?? DEFAULT_SCHEMA_DIRECTORY - ); + const schemaDirectory = options.schemaDirectory === undefined + ? null + : normalizeSchemaDirectory(options.schemaDirectory); const definitions = [ ...DOMAIN_SCHEMA_DEFINITIONS, { type: null, file: AGGREGATE_SCHEMA_FILE } @@ -141,7 +141,9 @@ export function validateDomainFrontmatter(frontmatter, type, registry) { function readPackagedSchema(schemaDirectory, file) { let source; try { - source = readFileSync(new URL(file, schemaDirectory), "utf8"); + source = schemaDirectory + ? readFileSync(new URL(file, schemaDirectory), "utf8") + : readPackagedText(`schemas/${file}`); } catch (error) { throw new DomainSchemaRegistryError( `Packaged schema '${file}' could not be read: ${error.message}`, diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index daf893b..af7952f 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -27,6 +27,20 @@ test("help explains canonical and legacy workspace resolution", async () => { assert.match(output, /opendomain doctor/); }); +test("version commands print exact package metadata version", async () => { + const expected = JSON.parse(await readFile("package.json", "utf8")).version; + + for (const args of [["--version"], ["-v"], ["version"]]) { + const stdout = memoryStream(); + const stderr = memoryStream(); + const exitCode = await runCli(args, { stdout, stderr }); + + assert.equal(exitCode, 0); + assert.equal(stdout.toString(), `${expected}\n`); + assert.equal(stderr.toString(), ""); + } +}); + test("validate command returns JSON and zero exit code for valid ERP example", async () => { const stdout = memoryStream(); const stderr = memoryStream(); @@ -211,6 +225,39 @@ test("init command can copy the ERP example", async () => { }); }); +test("init --example preserves nested directory results", async () => { + await withTempCwd(async () => { + const stdout = memoryStream(); + const exitCode = await runCli(["init", "--example", "erp", "--json"], { + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + const exampleDirectories = payload.created + .filter((item) => item.kind === "directory" && item.path.startsWith("examples/erp")) + .map((item) => item.path) + .sort(); + + assert.equal(exitCode, 0); + assert.deepEqual(exampleDirectories, [ + "examples/erp", + "examples/erp/external-features", + "examples/erp/opendomain", + "examples/erp/opendomain/candidates", + "examples/erp/opendomain/concepts", + "examples/erp/opendomain/contexts", + "examples/erp/opendomain/events", + "examples/erp/opendomain/integrations", + "examples/erp/opendomain/integrations/profiles", + "examples/erp/opendomain/lifecycles", + "examples/erp/opendomain/rules", + "examples/erp/openspec", + "examples/erp/openspec/changes", + "examples/erp/openspec/changes/order-cancellation" + ]); + }); +}); + test("integrations commands expose deterministic Profile inspection", async () => { const listStdout = memoryStream(); const listExitCode = await runCli(["integrations", "list", "--json"], { diff --git a/tests/packaged-resources.test.mjs b/tests/packaged-resources.test.mjs new file mode 100644 index 0000000..a58db12 --- /dev/null +++ b/tests/packaged-resources.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +test("packaged resources expose schemas, package metadata, and ERP files", async () => { + let resources; + try { + resources = await import("../src/packaged-resources.mjs"); + } catch (error) { + assert.fail(`Packaged resource boundary is unavailable: ${error.code ?? error.message}`); + } + + const packageMetadata = JSON.parse(resources.readPackagedText("package.json")); + const schema = JSON.parse(resources.readPackagedText("schemas/context.schema.json")); + const exampleFiles = resources.listPackagedFiles("examples/erp/"); + + assert.equal(packageMetadata.name, "@echopath-labs/opendomain"); + assert.equal(schema.$id, "https://opendomain.dev/schemas/context.schema.json"); + assert.ok(exampleFiles.includes("examples/erp/opendomain/contexts/sales.md")); + assert.ok(exampleFiles.includes("examples/erp/openspec/changes/order-cancellation/spec.md")); + assert.deepEqual(exampleFiles, [...exampleFiles].sort()); +}); + +test("packaged resources reject paths outside the declared package boundary", async () => { + let resources; + try { + resources = await import("../src/packaged-resources.mjs"); + } catch (error) { + assert.fail(`Packaged resource boundary is unavailable: ${error.code ?? error.message}`); + } + + for (const unsafePath of [ + "../package.json", + "%2e%2e/package.json", + "/package.json", + "schemas\\context.schema.json", + "schemas/context.schema.json?raw=true", + "schemas/context.schema.json#fragment", + "" + ]) { + assert.throws( + () => resources.readPackagedText(unsafePath), + /safe package-relative path/ + ); + } +}); diff --git a/tests/standalone-release.test.mjs b/tests/standalone-release.test.mjs new file mode 100644 index 0000000..39e3f6f --- /dev/null +++ b/tests/standalone-release.test.mjs @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +async function loadReleaseModule() { + try { + return await import("../scripts/lib/standalone-release.mjs"); + } catch (error) { + assert.fail(`Standalone release contract is unavailable: ${error.code ?? error.message}`); + } +} + +test("standalone release contract maps only declared native targets", async () => { + const release = await loadReleaseModule(); + + assert.equal(release.hostStandaloneTarget("darwin", "arm64"), "darwin-arm64"); + assert.equal(release.hostStandaloneTarget("darwin", "x64"), "darwin-x64"); + assert.equal(release.hostStandaloneTarget("linux", "x64"), "linux-x64"); + assert.equal(release.hostStandaloneTarget("win32", "x64"), "windows-x64"); + assert.throws(() => release.hostStandaloneTarget("linux", "arm64"), /Unsupported standalone host/); + assert.throws( + () => release.assertNativeStandaloneTarget("darwin-x64", "darwin", "arm64"), + /does not match native host 'darwin-arm64'/ + ); + assert.equal(release.assertStandaloneNodeVersion("24.18.0"), "24.18.0"); + assert.throws( + () => release.assertStandaloneNodeVersion("24.15.0"), + /require Node 24\.18\.0/ + ); + assert.equal(release.standalonePathsOverlap("/tmp/artifacts", "/tmp/artifacts", "linux"), true); + assert.equal(release.standalonePathsOverlap("/tmp/artifacts", "/tmp/artifacts/release", "linux"), true); + assert.equal(release.standalonePathsOverlap("/tmp/artifacts/release", "/tmp/artifacts", "linux"), true); + assert.equal(release.standalonePathsOverlap("/tmp/artifacts", "/tmp/release", "linux"), false); + assert.equal( + release.standalonePathsOverlap( + "C:\\Repo\\Artifacts", + "c:\\repo\\artifacts\\release", + "win32" + ), + true + ); +}); + +test("standalone asset names bind package version and target", async () => { + const release = await loadReleaseModule(); + const version = "0.1.0-alpha.7"; + + assert.deepEqual( + release.STANDALONE_TARGETS.map((target) => release.standaloneAssetName(version, target)), + [ + "opendomain-v0.1.0-alpha.7-darwin-arm64", + "opendomain-v0.1.0-alpha.7-darwin-x64", + "opendomain-v0.1.0-alpha.7-linux-x64", + "opendomain-v0.1.0-alpha.7-windows-x64.exe" + ] + ); + + assert.equal(release.assertReleaseTagVersion("v0.1.0-alpha.7", version), version); + assert.throws( + () => release.assertReleaseTagVersion("v0.1.0-alpha.8", version), + /does not match package version/ + ); + assert.throws( + () => release.assertReleaseTagVersion("0.1.0-alpha.7", version), + /must use the form 'v'/ + ); +}); + +test("checksum manifest is deterministic and requires the complete matrix", async () => { + const release = await loadReleaseModule(); + const directory = await mkdtemp(path.join(os.tmpdir(), "opendomain-release-assets-")); + const version = "0.1.0-alpha.7"; + + try { + const names = release.STANDALONE_TARGETS.map((target) => ( + release.standaloneAssetName(version, target) + )); + for (const name of [...names].reverse()) { + await writeFile(path.join(directory, name), `${name}\n`, "utf8"); + } + + const manifest = await release.createStandaloneChecksumManifest(directory, version); + const expected = [...names].sort().map((name) => { + const hash = createHash("sha256").update(`${name}\n`).digest("hex"); + return `${hash} ${name}`; + }).join("\n"); + + assert.equal(manifest, `${expected}\n`); + + await rm(path.join(directory, names[0])); + await assert.rejects( + release.createStandaloneChecksumManifest(directory, version), + /missing standalone release assets/ + ); + + await writeFile(path.join(directory, names[0]), `${names[0]}\n`, "utf8"); + await writeFile(path.join(directory, "unexpected.bin"), "unexpected\n", "utf8"); + await assert.rejects( + release.createStandaloneChecksumManifest(directory, version), + /unexpected standalone release assets/ + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("release assembly preserves artifact boundaries before flattening", async () => { + const release = await loadReleaseModule(); + const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "opendomain-native-artifacts-")); + const root = path.join(temporaryRoot, "artifacts"); + const output = path.join(temporaryRoot, "release"); + const version = "0.1.0-alpha.7"; + + try { + await mkdir(root); + for (const target of release.STANDALONE_TARGETS) { + const artifactDirectory = path.join(root, `standalone-${target}`); + await mkdir(artifactDirectory); + const asset = release.standaloneAssetName(version, target); + await writeFile(path.join(artifactDirectory, asset), `${target}\n`, "utf8"); + } + + await release.assembleStandaloneReleaseAssets(root, output, version); + assert.deepEqual( + (await readdir(output)).sort(), + release.STANDALONE_TARGETS + .map((target) => release.standaloneAssetName(version, target)) + .sort() + ); + + const protectedOutput = path.join(temporaryRoot, "existing-output"); + const sentinel = path.join(protectedOutput, "keep.txt"); + await mkdir(protectedOutput); + await writeFile(sentinel, "keep\n", "utf8"); + await assert.rejects( + release.assembleStandaloneReleaseAssets(root, protectedOutput, version), + /must not already exist/ + ); + assert.equal(await readFile(sentinel, "utf8"), "keep\n"); + + const artifactAlias = path.join(temporaryRoot, "artifact-alias"); + await symlink(root, artifactAlias, "dir"); + await assert.rejects( + release.assembleStandaloneReleaseAssets( + root, + path.join(artifactAlias, "release"), + version + ), + /must not overlap/ + ); + + const wrongDirectory = path.join(root, "standalone-darwin-x64"); + await writeFile( + path.join(wrongDirectory, release.standaloneAssetName(version, "darwin-arm64")), + "duplicate target asset\n", + "utf8" + ); + await assert.rejects( + release.assembleStandaloneReleaseAssets(root, output, version), + /unexpected files in native artifact/ + ); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/tests/standalone-workflow.test.mjs b/tests/standalone-workflow.test.mjs new file mode 100644 index 0000000..a4254b4 --- /dev/null +++ b/tests/standalone-workflow.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import YAML from "yaml"; + +test("standalone workflow builds the declared matrix and isolates release permissions", async () => { + const source = await readFile( + new URL("../.github/workflows/standalone.yml", import.meta.url), + "utf8" + ); + const workflow = YAML.parse(source); + + assert.deepEqual(workflow.permissions, { contents: "read" }); + assert.ok(workflow.on.pull_request); + assert.deepEqual(workflow.on.release.types, ["published"]); + assert.ok(workflow.on.workflow_dispatch !== undefined); + assert.deepEqual(workflow.jobs.build.strategy.matrix.include, [ + { target: "darwin-arm64", runner: "macos-15" }, + { target: "darwin-x64", runner: "macos-15-intel" }, + { target: "linux-x64", runner: "ubuntu-24.04" }, + { target: "windows-x64", runner: "windows-2025" } + ]); + assert.equal(workflow.jobs.build.permissions.contents, "read"); + assert.equal(workflow.jobs.aggregate.permissions.contents, "read"); + assert.equal(workflow.jobs.publish.permissions.contents, "write"); + assert.match(workflow.jobs.publish.if, /release/); + + const serializedBuild = JSON.stringify(workflow.jobs.build.steps); + assert.match(serializedBuild, /24\.18\.0/); + assert.match(serializedBuild, /build-standalone\.mjs/); + assert.match(serializedBuild, /smoke-standalone\.mjs/); + + const serializedAggregate = JSON.stringify(workflow.jobs.aggregate.steps); + assert.match(serializedAggregate, /write-standalone-checksums\.mjs/); + assert.match(serializedAggregate, /assemble-standalone-assets\.mjs/); + assert.doesNotMatch(serializedAggregate, /merge-multiple/); + const serializedPublish = JSON.stringify(workflow.jobs.publish.steps); + assert.match(serializedPublish, /gh release upload/); + assert.match(serializedPublish, /SHA256SUMS\.txt/); + assert.doesNotMatch(serializedPublish, /--clobber/); +});