diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c02e19e4..0cdf84c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,17 @@ jobs: go-version-file: go.mod cache-dependency-path: go.sum + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22.19.0 + - name: Run deterministic test suite run: make test + - name: Test npm distribution tooling + run: npm test --prefix npm/cli + windows-memory: runs-on: windows-latest timeout-minutes: 15 @@ -49,8 +57,16 @@ jobs: go-version-file: go.mod cache-dependency-path: go.sum + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22.19.0 + - name: Build Windows Memory product run: go build -o mnemon.exe . - name: Test Windows command and Memory storage boundaries run: go test ./cmd ./cmd/agency ./cmd/memory ./internal/memory/store -count=1 + + - name: Test Windows npm tooling + run: npm test --prefix npm/cli diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cec0eccc..33084557 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: - "v*" permissions: - contents: write + contents: read concurrency: group: release-${{ github.ref }} @@ -23,12 +23,22 @@ jobs: with: go-version-file: go.mod + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22.19.0 + - name: Run deterministic test suite run: make test + - name: Test npm distribution tooling + run: npm test --prefix npm/cli + release: needs: test runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v4 with: @@ -48,3 +58,50 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + - name: Preserve native binaries for npm packaging + uses: actions/upload-artifact@v4 + with: + name: npm-release-binaries + path: | + dist/artifacts.json + dist/mnemon_*/* + if-no-files-found: error + retention-days: 1 + + npm-release: + needs: release + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Node and npm registry authentication + uses: actions/setup-node@v6 + with: + node-version: 24.20.0 + registry-url: https://registry.npmjs.org + + - name: Download native release binaries + uses: actions/download-artifact@v4 + with: + name: npm-release-binaries + path: dist + + - name: Stage npm packages + run: >- + node scripts/build-npm-packages.mjs + --version "${{ github.ref_name }}" + --dist dist + --output dist/npm + + - name: Verify npm packages and launcher + run: node scripts/verify-npm-packages.mjs dist/npm/packages.json + + - name: Publish platform artifacts, then the CLI package + run: node scripts/publish-npm-packages.mjs dist/npm/packages.json + env: + # Used only to bootstrap the package; trusted publishing uses OIDC. + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f291e13..ac722ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- `npm install --global @mnemon-dev/mnemon` is now the canonical CLI install + path. Tagged releases publish pinned native artifacts for macOS, Linux, and + Windows before advancing the npm `latest` or `next` channel. +- `mnemon update` upgrades an npm-managed installation through its owning npm + prefix. The npm launcher performs replacement without keeping the native + process running, including on Windows. Installations from another source fail + closed with a one-time npm migration command instead of silently creating a + shadowed executable. - `mnemon recall --brief` and `mnemon search --brief` now provide a bounded, unindented JSON discovery projection. `--excerpt-chars` controls the per-item excerpt limit, and `mnemon show ` retrieves one selected insight in full. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d9bfd55..b877ad68 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,7 +79,8 @@ git push origin v0.2.0 This triggers GitHub Actions → runs tests → builds platform artifacts for the single `mnemon` executable via GoReleaser → publishes a GitHub Release → -updates the Homebrew tap. +updates the Homebrew tap → verifies and publishes the npm platform artifacts → +publishes `@mnemon-dev/mnemon` last. ## License diff --git a/README.md b/README.md index 96ff213e..a6301dd5 100644 --- a/README.md +++ b/README.md @@ -59,18 +59,35 @@ See [Design & Architecture](docs/DESIGN.md) for details. ### Install -**Homebrew Cask** (macOS): +**npm** (recommended; macOS / Linux / Windows, Node.js 22+): ```bash -brew install --cask mnemon-dev/tap/mnemon +npm install --global @mnemon-dev/mnemon +``` + +Upgrade the npm-managed CLI at any time: + +```bash +mnemon update ``` -**Go install** (macOS / Linux / Windows): +The npm package installs the matching native Go executable for the host OS and +CPU. Mnemon's engine remains a single native binary; Node.js is used only by +the npm launcher and package manager. + +**Alternative installers**: ```bash +brew install --cask mnemon-dev/tap/mnemon go install github.com/mnemon-dev/mnemon@latest ``` +Homebrew, `go install`, source builds, and other Node package managers must +continue to use their original installation method. To migrate one of these +installations, run the npm install command once and ensure the npm global bin +directory precedes the old executable on `PATH`; subsequent `mnemon update` +calls are npm-managed. + Windows supports the core Memory commands. Agency remains unavailable on Windows until its local authority boundary has native Windows security. diff --git a/cmd/root.go b/cmd/root.go index cd4e22c3..c6eefcb0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,10 +22,12 @@ func Execute(ctx context.Context, args []string, stdin io.Reader, stdout, stderr } root := productRoot() agencyRequest := false + quietProductRequest := false command, _, findErr := root.Find(args) if findErr == nil { agencyRequest = belongsToAgency(command) - if agencyRequest { + quietProductRequest = belongsToCommand(command, "update") + if agencyRequest || quietProductRequest { root.SilenceErrors = true } } @@ -41,7 +43,8 @@ func Execute(ctx context.Context, args []string, stdin io.Reader, stdout, stderr if err == nil { return 0 } - if findErr == nil && !agencyRequest && !belongsToAgency(executed) && executed != nil { + if findErr == nil && !agencyRequest && !quietProductRequest && + !belongsToAgency(executed) && executed != nil { _, _ = fmt.Fprintln(stderr, executed.UsageString()) } if err.Error() != "" { @@ -57,8 +60,12 @@ func Execute(ctx context.Context, args []string, stdin io.Reader, stdout, stderr } func belongsToAgency(command *cobra.Command) bool { + return belongsToCommand(command, "agency") +} + +func belongsToCommand(command *cobra.Command, name string) bool { for current := command; current != nil; current = current.Parent() { - if current.Name() == "agency" { + if current.Name() == name { return true } } @@ -71,14 +78,14 @@ func productRoot() *cobra.Command { root.Long = "Mnemon gives LLM agents persistent memory and a local authority for durable, peer-to-peer work." root.SilenceErrors = false root.SilenceUsage = false - // Memory's current command tree is process-global. Remove a prior command - // so focused tests can construct the product root more than once without + // Memory's current command tree is process-global. Remove prior product + // commands so focused tests can construct the root more than once without // changing the production command set. for _, child := range root.Commands() { - if child.Name() == "agency" { + if child.Name() == "agency" || child.Name() == "update" { root.RemoveCommand(child) } } - root.AddCommand(agency.New(version)) + root.AddCommand(agency.New(version), updateCommand()) return root } diff --git a/cmd/root_test.go b/cmd/root_test.go index df5c4b57..f66d0323 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -9,7 +9,7 @@ import ( func TestRootComposesMemoryAndAgency(t *testing.T) { root := productRoot() - for _, name := range []string{"remember", "recall", "setup", "agency"} { + for _, name := range []string{"remember", "recall", "setup", "agency", "update"} { child, _, err := root.Find([]string{name}) if err != nil || child == root { t.Fatalf("root command %q is not registered", name) @@ -21,6 +21,18 @@ func TestRootComposesMemoryAndAgency(t *testing.T) { } } +func TestUnmanagedUpdateExplainsTheOneTimeNPMMigration(t *testing.T) { + var stdout, stderr bytes.Buffer + exitCode := Execute(context.Background(), []string{"update"}, strings.NewReader(""), + &stdout, &stderr) + if exitCode != 1 || stdout.Len() != 0 || + !strings.Contains(stderr.String(), "npm install --global @mnemon-dev/mnemon@latest") || + strings.Contains(stderr.String(), "Usage:") { + t.Fatalf("unmanaged update: exit=%d stdout=%q stderr=%q", + exitCode, stdout.String(), stderr.String()) + } +} + func TestExecuteRoutesAgencyWithoutChangingItsExitCode(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := Execute(context.Background(), []string{"agency", "--version"}, diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 00000000..dd841c89 --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "errors" + + "github.com/spf13/cobra" +) + +func updateCommand() *cobra.Command { + return &cobra.Command{ + Use: "update", + Short: "Update the npm-managed Mnemon CLI", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return errors.New("this Mnemon installation is not managed by npm; " + + "migrate once with: npm install --global @mnemon-dev/mnemon@latest") + }, + } +} diff --git a/cmd/update_test.go b/cmd/update_test.go new file mode 100644 index 00000000..d84d5959 --- /dev/null +++ b/cmd/update_test.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "context" + "io" + "strings" + "testing" +) + +func TestNativeUpdateCommandRequiresNPMLauncher(t *testing.T) { + t.Parallel() + command := updateCommand() + command.SetOut(io.Discard) + command.SetErr(io.Discard) + command.SetArgs(nil) + err := command.ExecuteContext(context.Background()) + if err == nil || + !strings.Contains(err.Error(), "npm install --global @mnemon-dev/mnemon@latest") { + t.Fatalf("error = %v", err) + } +} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b0419ef7..ac36cd55 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -5,6 +5,7 @@ Prerequisites: - Go 1.24.6 or newer in the 1.24 series +- Node.js 22 or newer for npm package tests - `make` - `jq` only when running the opt-in CLI E2E/integration suite @@ -102,14 +103,50 @@ server is not on a trusted local network. ## Release Deployment -Tagged releases are handled by GoReleaser through `.github/workflows/release.yml`. +Tagged releases are handled through `.github/workflows/release.yml`. GoReleaser +publishes the native GitHub artifacts and Homebrew cask first. A dependent job +then stages those exact binaries as npm artifacts, verifies the host launcher, +and publishes the canonical `@mnemon-dev/mnemon` package. -Required repository secret: +Long-lived repository secret: - `HOMEBREW_TAP_TOKEN`, only needed for publishing the Homebrew tap +Before the first npm release, reserve the `@mnemon-dev` npm scope and add a +granular `NPM_TOKEN` repository secret that can bootstrap the public +`@mnemon-dev/mnemon` package. After that first tagged release: + +1. Configure `mnemon-dev/mnemon` and `release.yml` as the package's GitHub + Actions [trusted publisher](https://docs.npmjs.com/trusted-publishers/) on + npm, allowing direct `npm publish`. +2. Run the next tagged release and confirm that npm records GitHub Actions as + its trusted publisher. +3. Delete the `NPM_TOKEN` repository secret and revoke the bootstrap token. + +The npm release job uses Node.js 24 and requests only the OIDC permission needed +for token-free trusted publishing. npm automatically binds each publish to the +workflow and records provenance; `--provenance` also covers the one-time token +bootstrap release. + +One tag is the only version source. For `v0.3.0`, the workflow publishes six +platform versions such as `0.3.0-darwin-arm64`, then publishes the `0.3.0` CLI +meta-package last. The meta-package pins each platform version through npm +aliases. Stable tags advance `latest`; prerelease tags advance `next`. Publishing +the meta-package last prevents npm users from observing an incomplete release. + +Publishing is retry-safe: already published immutable versions are skipped, so +the failed `npm-release` job can be rerun without rebuilding or republishing the +GitHub release. + Create a local snapshot build without publishing: ```bash make release-snapshot ``` + +The npm staging tools consume GoReleaser's `dist/artifacts.json`; they do not +maintain a second build matrix. They are exercised independently with: + +```bash +npm test --prefix npm/cli +``` diff --git a/docs/USAGE.md b/docs/USAGE.md index d2a70bd0..f7e7040d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -26,6 +26,28 @@ the read-only SQLite file URI internally; do not prepend `file:` yourself. --- +## CLI Updates + +The canonical npm installation can update itself to the package tagged +`latest`: + +```bash +mnemon update +``` + +The npm launcher proves that the active package belongs to the same global npm +prefix before invoking npm. It fails closed when `mnemon` came from Homebrew, +`go install`, a source build, another Node package manager, or a different npm +prefix, preventing a second installation from being created silently. Migrate +once with `npm install --global @mnemon-dev/mnemon@latest`, then make sure that +npm's global bin directory is the first `mnemon` on `PATH`. + +Updating replaces only the CLI package. It does not modify Memory data or +silently rewrite installed host integrations. Review release notes and rerun +`mnemon setup` when an integration release explicitly requires a refresh. + +--- + ## Memory Setup Deploy mnemon into LLM CLI environments. This is the first command to run after installation. diff --git a/docs/zh/README.md b/docs/zh/README.md index da1b0d67..35737e60 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -59,18 +59,32 @@ Mnemon 同时填补了协议栈中的空白。MCP 标准化了 LLM 如何发现 ### 安装 -**Homebrew Cask**(macOS): +**npm**(推荐;macOS / Linux / Windows,需要 Node.js 22+): ```bash -brew install --cask mnemon-dev/tap/mnemon +npm install --global @mnemon-dev/mnemon +``` + +之后可随时升级 npm 管理的 CLI: + +```bash +mnemon update ``` -**Go install**(macOS / Linux / Windows): +npm 包会按宿主操作系统和 CPU 安装对应的原生 Go 可执行文件。Mnemon 引擎 +仍然是单一原生二进制;Node.js 只用于 npm 启动器和包管理。 + +**其他安装方式**: ```bash +brew install --cask mnemon-dev/tap/mnemon go install github.com/mnemon-dev/mnemon@latest ``` +Homebrew、`go install`、源码构建及其他 Node 包管理器安装的版本,必须继续使用 +各自原来的安装方式。迁移时请先执行一次 npm 安装命令,并确保 npm 全局 bin 目录 +在 `PATH` 中排在旧可执行文件之前;此后的 `mnemon update` 将由 npm 管理。 + Windows 支持核心 Memory 命令。Agency 的本地权威边界完成原生 Windows 安全实现前,在 Windows 上保持不可用。 diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index eb3e1f76..83bd5f9d 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -24,6 +24,25 @@ Mnemon 会在内部解析并编码只读 SQLite 文件 URI,无需手动添加 --- +## CLI 升级 + +通过推荐的 npm 方式安装后,可以升级到 npm `latest` 指向的版本: + +```bash +mnemon update +``` + +在调用 npm 前,npm 启动器会确认当前软件包确实属于同一个全局 npm +prefix。若 `mnemon` 来自 Homebrew、`go install`、源码构建、其他 Node 包管理器 +或另一个 npm prefix,命令会以 fail-closed 方式退出,避免静默产生第二份安装。 +首次迁移请执行 `npm install --global @mnemon-dev/mnemon@latest`,并确保 npm +全局 bin 目录中的 `mnemon` 在 `PATH` 中优先。 + +升级只替换 CLI 包,不会修改 Memory 数据,也不会静默改写已安装的宿主集成。 +当某个版本的 release notes 明确要求刷新集成时,再重新运行 `mnemon setup`。 + +--- + ## Memory 设置 将 mnemon 部署到 LLM CLI 环境中。安装后首先运行此命令。 diff --git a/internal/memory/setup/assets/nanoclaw/SKILL.md b/internal/memory/setup/assets/nanoclaw/SKILL.md index 5e207766..38394c77 100644 --- a/internal/memory/setup/assets/nanoclaw/SKILL.md +++ b/internal/memory/setup/assets/nanoclaw/SKILL.md @@ -26,8 +26,8 @@ Each group gets its own isolated mnemon store. An optional global store provides mnemon --version ``` If not installed: - - **macOS / Linux (Homebrew)**: `brew install mnemon-dev/tap/mnemon` - - **Go install**: `go install github.com/mnemon-dev/mnemon@latest` + - **Recommended (npm)**: `npm install --global @mnemon-dev/mnemon` + - **Alternative (Homebrew)**: `brew install --cask mnemon-dev/tap/mnemon` 2. Verify the container image exists: ```bash diff --git a/internal/memory/setup/assets/openclaw/SKILL.md b/internal/memory/setup/assets/openclaw/SKILL.md index 3044f7b6..2913ba97 100644 --- a/internal/memory/setup/assets/openclaw/SKILL.md +++ b/internal/memory/setup/assets/openclaw/SKILL.md @@ -7,16 +7,11 @@ metadata: requires: bins: ["mnemon"] install: - - id: "brew" - kind: "brew" - formula: "mnemon-dev/tap/mnemon" + - id: "node" + kind: "node" + package: "@mnemon-dev/mnemon@latest" bins: ["mnemon"] - label: "Install mnemon (Homebrew)" - - id: "go" - kind: "go" - package: "github.com/mnemon-dev/mnemon@latest" - bins: ["mnemon"] - label: "Install mnemon (go install)" + label: "Install mnemon (npm)" --- # mnemon @@ -25,16 +20,16 @@ metadata: ### 1. Install the binary -**Homebrew** (macOS / Linux): +**npm** (macOS / Linux / Windows, Node.js 22+): ```bash -brew install mnemon-dev/tap/mnemon +npm install --global @mnemon-dev/mnemon ``` -**Go install**: +Upgrade an npm-managed installation with: ```bash -go install github.com/mnemon-dev/mnemon@latest +mnemon update ``` ### 2. Set up OpenClaw integration diff --git a/npm/cli/README.md b/npm/cli/README.md new file mode 100644 index 00000000..d7cc0518 --- /dev/null +++ b/npm/cli/README.md @@ -0,0 +1,18 @@ +# Mnemon CLI + +Mnemon gives LLM agents persistent memory and a local authority for durable, +peer-to-peer work. This npm package installs the native Mnemon executable for +the current operating system and CPU architecture. + +```bash +npm install --global @mnemon-dev/mnemon +mnemon --version +mnemon update +``` + +The launcher supports 64-bit Intel and ARM systems on macOS, Linux, and +Windows. Windows currently supports the Memory commands; Agency remains +unavailable until its local authority boundary has native Windows security. + +Project documentation and source code are available at +. diff --git a/npm/cli/bin/mnemon.js b/npm/cli/bin/mnemon.js new file mode 100755 index 00000000..2110cc8a --- /dev/null +++ b/npm/cli/bin/mnemon.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +import { existsSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { runChild, settle } from "../lib/child.js"; +import { selectTarget } from "../lib/targets.js"; +import { updateNpmInstall } from "../lib/update.js"; + +const require = createRequire(import.meta.url); + +async function main() { + const packageRoot = realpathSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..")); + const args = process.argv.slice(2); + if (args.length === 1 && args[0] === "update") { + settle(await updateNpmInstall({ packageRoot })); + return; + } + + const target = selectTarget(process.platform, process.arch); + let platformRoot; + try { + platformRoot = path.dirname(require.resolve(`${target.alias}/package.json`)); + } catch { + throw new Error( + `Missing optional dependency ${target.alias}. Reinstall Mnemon with: ` + + "npm install --global --include=optional @mnemon-dev/mnemon@latest", + ); + } + + const binary = path.join(platformRoot, target.binary); + if (!existsSync(binary)) { + throw new Error( + `Mnemon native binary is missing for ${target.id}. Reinstall with: ` + + "npm install --global --include=optional @mnemon-dev/mnemon@latest", + ); + } + settle(await runChild(binary, args)); +} + +try { + await main(); +} catch (error) { + process.stderr.write(`mnemon: ${error.message}\n`); + process.exitCode = 1; +} diff --git a/npm/cli/lib/child.js b/npm/cli/lib/child.js new file mode 100644 index 00000000..c0c0f571 --- /dev/null +++ b/npm/cli/lib/child.js @@ -0,0 +1,38 @@ +import { spawn } from "node:child_process"; + +export async function runChild(command, args, options = {}) { + const child = spawn(command, args, { stdio: "inherit", ...options }); + const forward = (signal) => { + if (!child.killed) { + try { + child.kill(signal); + } catch { + // The child may have settled between the check and signal delivery. + } + } + }; + const handlers = new Map(); + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + const handler = () => forward(signal); + handlers.set(signal, handler); + process.on(signal, handler); + } + try { + return await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + } finally { + for (const [signal, handler] of handlers) { + process.off(signal, handler); + } + } +} + +export function settle(result) { + if (result.signal) { + process.kill(process.pid, result.signal); + return; + } + process.exitCode = result.code ?? 1; +} diff --git a/npm/cli/lib/targets.js b/npm/cli/lib/targets.js new file mode 100644 index 00000000..97fd9310 --- /dev/null +++ b/npm/cli/lib/targets.js @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; + +const targetFile = new URL("../targets.json", import.meta.url); +const parsedTargets = JSON.parse(readFileSync(targetFile, "utf8")); + +export const targets = validateTargets(parsedTargets); + +export function selectTarget(platform, arch) { + const target = targets.find( + (candidate) => candidate.platform === platform && candidate.arch === arch, + ); + if (!target) { + throw new Error(`Unsupported platform: ${platform} (${arch})`); + } + return target; +} + +function validateTargets(value) { + if (!Array.isArray(value) || value.length === 0) { + throw new Error("Mnemon target registry is empty"); + } + const ids = new Set(); + const runtimes = new Set(); + const aliases = new Set(); + return Object.freeze( + value.map((target) => { + for (const field of ["id", "platform", "arch", "goos", "goarch", "alias", "binary"]) { + if (typeof target[field] !== "string" || target[field].length === 0) { + throw new Error(`Mnemon target has invalid ${field}`); + } + } + const runtime = `${target.platform}/${target.arch}`; + if (ids.has(target.id) || runtimes.has(runtime) || aliases.has(target.alias)) { + throw new Error(`Mnemon target registry contains a duplicate: ${target.id}`); + } + ids.add(target.id); + runtimes.add(runtime); + aliases.add(target.alias); + return Object.freeze({ ...target }); + }), + ); +} diff --git a/npm/cli/lib/update.js b/npm/cli/lib/update.js new file mode 100644 index 00000000..076a2a8a --- /dev/null +++ b/npm/cli/lib/update.js @@ -0,0 +1,178 @@ +import { constants } from "node:fs"; +import { access, readFile, realpath } from "node:fs/promises"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +import { runChild } from "./child.js"; + +const packageName = "@mnemon-dev/mnemon"; + +export async function updateNpmInstall({ + packageRoot, + runner = productionRunner(), + stdout = process.stdout, +}) { + const currentRoot = await canonicalPath(packageRoot); + const current = await readPackage(currentRoot); + const npm = await resolveNpmInvocation(); + + const globalRoot = await npmPath(runner, npm, ["root", "--global"]); + let expectedRoot; + try { + expectedRoot = await canonicalPath(path.join(globalRoot, ...packageName.split("/"))); + } catch (error) { + throw notManagedError(`cannot resolve the package in npm's global root: ${error.message}`); + } + if (!samePath(expectedRoot, currentRoot)) { + throw notManagedError("npm on PATH owns a different global installation"); + } + + const prefix = await npmPath(runner, npm, ["prefix", "--global"]); + if (!pathWithin(prefix, globalRoot)) { + throw notManagedError("npm's global package root is outside its prefix"); + } + + const result = await runner.run(npm.command, [ + ...npm.args, + "install", + "--global", + "--prefix", + prefix, + "--include=optional", + `${packageName}@latest`, + ]); + if (result.signal) { + return result; + } + if (result.code !== 0) { + throw new Error(`npm install exited with status ${result.code ?? "unknown"}`); + } + + const updated = await readPackage(currentRoot); + if (current.version === updated.version) { + stdout.write(`Mnemon is already up to date (${updated.version}).\n`); + } else { + stdout.write(`Updated Mnemon ${current.version} -> ${updated.version}.\n`); + } + return { code: 0, signal: null }; +} + +function productionRunner() { + return { + output(command, args) { + const result = spawnSync(command, args, { encoding: "utf8" }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} exited with status ${result.status ?? "unknown"}: ` + + (result.stderr ?? "").trim(), + ); + } + return result.stdout; + }, + run(command, args) { + return runChild(command, args); + }, + }; +} + +async function npmPath(runner, npm, args) { + try { + const value = runner.output(npm.command, [...npm.args, ...args]).trim(); + return await canonicalPath(value); + } catch (error) { + throw new Error(`cannot inspect npm ${args[0]}: ${error.message}`); + } +} + +async function resolveNpmInvocation() { + if (process.platform !== "win32") { + return { command: "npm", args: [] }; + } + + const pathValue = Object.entries(process.env).find( + ([name]) => name.toLowerCase() === "path", + )?.[1]; + for (let entry of pathValue?.split(path.delimiter) ?? []) { + if (entry.startsWith('"') && entry.endsWith('"')) { + entry = entry.slice(1, -1); + } + if (entry === "") { + continue; + } + const npmExecutable = path.join(entry, "npm.exe"); + try { + await access(npmExecutable, constants.F_OK); + return { command: npmExecutable, args: [] }; + } catch { + // Standard Node.js installations expose npm through npm.cmd instead. + } + const npmCommand = path.join(entry, "npm.cmd"); + const npmCLI = path.join(entry, "node_modules", "npm", "bin", "npm-cli.js"); + try { + await Promise.all([ + access(npmCommand, constants.F_OK), + access(npmCLI, constants.F_OK), + ]); + // A .cmd file cannot be spawned directly without a shell. Invoke npm's + // JavaScript entry point with the already-running Node executable. + return { command: process.execPath, args: [npmCLI] }; + } catch { + // Keep searching PATH for a complete Node.js/npm installation. + } + } + throw new Error("npm is required to update Mnemon but was not found on PATH"); +} + +async function canonicalPath(value) { + if (typeof value !== "string" || value.trim() !== value || !path.isAbsolute(value)) { + throw new Error("path is not absolute and clean"); + } + const cleaned = path.normalize(value); + if (cleaned !== value) { + throw new Error("path is not absolute and clean"); + } + return realpath(value); +} + +async function readPackage(root) { + let metadata; + try { + metadata = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")); + } catch (error) { + throw notManagedError(`cannot read package metadata: ${error.message}`); + } + if ( + metadata.name !== packageName || + typeof metadata.version !== "string" || + metadata.version.trim() === "" + ) { + throw notManagedError("package name or version is invalid"); + } + return metadata; +} + +function pathWithin(root, candidate) { + const relative = path.relative(root, candidate); + return ( + relative !== "" && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +function samePath(left, right) { + return process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +function notManagedError(reason) { + return new Error( + `this Mnemon installation is not managed by npm: ${reason}; ` + + `migrate once with: npm install --global ${packageName}@latest`, + ); +} diff --git a/npm/cli/package.json b/npm/cli/package.json new file mode 100644 index 00000000..07a23911 --- /dev/null +++ b/npm/cli/package.json @@ -0,0 +1,42 @@ +{ + "name": "@mnemon-dev/mnemon", + "version": "0.0.0", + "private": true, + "description": "Persistent memory and durable agency for LLM agents", + "type": "module", + "bin": { + "mnemon": "bin/mnemon.js" + }, + "files": [ + "bin", + "lib", + "targets.json", + "README.md", + "LICENSE" + ], + "scripts": { + "test": "node --test test/*.test.mjs" + }, + "engines": { + "node": ">=22" + }, + "keywords": [ + "mnemon", + "agent-memory", + "llm", + "cli" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/mnemon-dev/mnemon.git" + }, + "homepage": "https://github.com/mnemon-dev/mnemon", + "bugs": { + "url": "https://github.com/mnemon-dev/mnemon/issues" + }, + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "optionalDependencies": {} +} diff --git a/npm/cli/targets.json b/npm/cli/targets.json new file mode 100644 index 00000000..441ecbd7 --- /dev/null +++ b/npm/cli/targets.json @@ -0,0 +1,56 @@ +[ + { + "id": "darwin-x64", + "platform": "darwin", + "arch": "x64", + "goos": "darwin", + "goarch": "amd64", + "alias": "@mnemon-dev/mnemon-darwin-x64", + "binary": "bin/mnemon" + }, + { + "id": "darwin-arm64", + "platform": "darwin", + "arch": "arm64", + "goos": "darwin", + "goarch": "arm64", + "alias": "@mnemon-dev/mnemon-darwin-arm64", + "binary": "bin/mnemon" + }, + { + "id": "linux-x64", + "platform": "linux", + "arch": "x64", + "goos": "linux", + "goarch": "amd64", + "alias": "@mnemon-dev/mnemon-linux-x64", + "binary": "bin/mnemon" + }, + { + "id": "linux-arm64", + "platform": "linux", + "arch": "arm64", + "goos": "linux", + "goarch": "arm64", + "alias": "@mnemon-dev/mnemon-linux-arm64", + "binary": "bin/mnemon" + }, + { + "id": "win32-x64", + "platform": "win32", + "arch": "x64", + "goos": "windows", + "goarch": "amd64", + "alias": "@mnemon-dev/mnemon-win32-x64", + "binary": "bin/mnemon.exe" + }, + { + "id": "win32-arm64", + "platform": "win32", + "arch": "arm64", + "goos": "windows", + "goarch": "arm64", + "alias": "@mnemon-dev/mnemon-win32-arm64", + "binary": "bin/mnemon.exe" + } +] diff --git a/npm/cli/test/packages.test.mjs b/npm/cli/test/packages.test.mjs new file mode 100644 index 00000000..3ea5673c --- /dev/null +++ b/npm/cli/test/packages.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + normalizeVersion, + releaseTag, + stageNpmPackages, +} from "../../../scripts/build-npm-packages.mjs"; +import { targets } from "../lib/targets.js"; + +test("release versions and dist tags are deterministic", () => { + assert.equal(normalizeVersion("v1.2.3"), "1.2.3"); + assert.equal(normalizeVersion("1.2.3-rc.1"), "1.2.3-rc.1"); + assert.equal(releaseTag("1.2.3"), "latest"); + assert.equal(releaseTag("1.2.3-rc.1"), "next"); + assert.throws(() => normalizeVersion("latest"), /Invalid npm release version/); +}); + +test("GoReleaser binaries become pinned npm platform aliases", async (t) => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "mnemon-npm-packages-")); + t.after(() => rm(temporary, { recursive: true, force: true })); + const dist = path.join(temporary, "dist"); + const artifacts = []; + await mkdir(dist, { recursive: true }); + for (const target of targets) { + const binary = path.join(dist, target.id, path.basename(target.binary)); + await mkdir(path.dirname(binary), { recursive: true }); + await writeFile(binary, `binary:${target.id}`, { mode: 0o755 }); + artifacts.push({ + type: "Binary", + goos: target.goos, + goarch: target.goarch, + path: binary, + extra: { ID: "mnemon" }, + }); + } + await writeFile(path.join(dist, "artifacts.json"), JSON.stringify(artifacts), "utf8"); + const output = path.join(dist, "npm"); + const manifest = await stageNpmPackages({ + version: "v1.2.3", + distDirectory: dist, + outputDirectory: output, + }); + + assert.equal(manifest.packages.length, 7); + assert.equal(manifest.packages.at(-1).kind, "cli"); + const cli = JSON.parse(await readFile(path.join(output, "cli", "package.json"), "utf8")); + assert.equal(cli.version, "1.2.3"); + assert.equal(cli.private, undefined); + assert.equal( + cli.optionalDependencies["@mnemon-dev/mnemon-darwin-arm64"], + "npm:@mnemon-dev/mnemon@1.2.3-darwin-arm64", + ); + const platform = JSON.parse( + await readFile(path.join(output, "linux-x64", "package.json"), "utf8"), + ); + assert.equal(platform.name, "@mnemon-dev/mnemon"); + assert.equal(platform.version, "1.2.3-linux-x64"); + assert.deepEqual(platform.os, ["linux"]); + assert.deepEqual(platform.cpu, ["x64"]); + assert.equal( + await readFile(path.join(output, "linux-x64", "bin", "mnemon"), "utf8"), + "binary:linux-x64", + ); +}); + +test("artifact paths outside GoReleaser dist fail closed", async (t) => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "mnemon-npm-escape-")); + t.after(() => rm(temporary, { recursive: true, force: true })); + const dist = path.join(temporary, "dist"); + const outside = path.join(temporary, "mnemon"); + await mkdir(dist, { recursive: true }); + await writeFile(outside, "binary", { mode: 0o755 }); + const artifacts = targets.map((target) => ({ + type: "Binary", + goos: target.goos, + goarch: target.goarch, + path: outside, + extra: { ID: "mnemon" }, + })); + await writeFile(path.join(dist, "artifacts.json"), JSON.stringify(artifacts), "utf8"); + await assert.rejects( + stageNpmPackages({ + version: "1.2.3", + distDirectory: dist, + outputDirectory: path.join(dist, "npm"), + }), + /GoReleaser artifact must be inside/, + ); +}); diff --git a/npm/cli/test/publish.test.mjs b/npm/cli/test/publish.test.mjs new file mode 100644 index 00000000..87f5ac31 --- /dev/null +++ b/npm/cli/test/publish.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { publishNpmPackages } from "../../../scripts/publish-npm-packages.mjs"; +import { targets } from "../lib/targets.js"; + +test("publish skips immutable versions and keeps the CLI package last", async (t) => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "mnemon-publish-test-")); + t.after(() => rm(temporary, { recursive: true, force: true })); + const packages = targets.map((target) => ({ + kind: "platform", + id: target.id, + name: "@mnemon-dev/mnemon", + version: `1.2.3-${target.id}`, + tag: target.id, + directory: path.join(temporary, target.id), + })); + packages.push({ + kind: "cli", + name: "@mnemon-dev/mnemon", + version: "1.2.3", + tag: "latest", + directory: path.join(temporary, "cli"), + }); + await mkdir(temporary, { recursive: true }); + const manifestPath = path.join(temporary, "packages.json"); + await writeFile( + manifestPath, + JSON.stringify({ schemaVersion: 1, version: "1.2.3", packages }), + "utf8", + ); + + const calls = []; + const output = []; + const run = (_command, args) => { + calls.push(args); + if (args[0] === "view") { + const version = args[1].slice(args[1].lastIndexOf("@") + 1); + return version.endsWith("darwin-x64") + ? { status: 0, stdout: JSON.stringify(version), stderr: "" } + : { status: 1, stdout: "", stderr: "npm error code E404" }; + } + return { status: 0 }; + }; + + await publishNpmPackages(manifestPath, { + run, + stdout: { write: (value) => output.push(value) }, + }); + + const publishCalls = calls.filter((args) => args[0] === "publish"); + assert.equal(publishCalls.length, 6); + assert.equal(publishCalls.at(-1)[1], packages.at(-1).directory); + assert.match(output.join(""), /1\.2\.3-darwin-x64; skipping/); +}); + +test("publish rejects a manifest whose CLI package is not last", async (t) => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "mnemon-publish-order-")); + t.after(() => rm(temporary, { recursive: true, force: true })); + const manifestPath = path.join(temporary, "packages.json"); + await writeFile( + manifestPath, + JSON.stringify({ schemaVersion: 1, packages: [{ kind: "cli" }, { kind: "platform" }] }), + "utf8", + ); + await assert.rejects(publishNpmPackages(manifestPath), /Invalid or unordered/); +}); diff --git a/npm/cli/test/targets.test.mjs b/npm/cli/test/targets.test.mjs new file mode 100644 index 00000000..8fd802bd --- /dev/null +++ b/npm/cli/test/targets.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { selectTarget, targets } from "../lib/targets.js"; + +test("target registry covers the complete GoReleaser matrix", () => { + assert.equal(targets.length, 6); + assert.deepEqual( + targets.map(({ goos, goarch }) => `${goos}/${goarch}`).sort(), + [ + "darwin/amd64", + "darwin/arm64", + "linux/amd64", + "linux/arm64", + "windows/amd64", + "windows/arm64", + ], + ); + assert.equal(selectTarget("darwin", "arm64").id, "darwin-arm64"); + assert.equal(selectTarget("win32", "x64").goarch, "amd64"); +}); + +test("unsupported runtimes fail closed", () => { + assert.throws(() => selectTarget("freebsd", "x64"), /Unsupported platform/); + assert.throws(() => selectTarget("linux", "ia32"), /Unsupported platform/); +}); diff --git a/npm/cli/test/update.test.mjs b/npm/cli/test/update.test.mjs new file mode 100644 index 00000000..f73e9bc3 --- /dev/null +++ b/npm/cli/test/update.test.mjs @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + chmod, + copyFile, + cp, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { updateNpmInstall } from "../lib/update.js"; + +test("update replaces the package through its owning npm prefix", async (t) => { + const fixture = await npmFixture(t, "0.2.8"); + fixture.runner.updatedVersion = "0.2.9"; + const output = []; + + const result = await updateNpmInstall({ + packageRoot: fixture.packageRoot, + runner: fixture.runner, + stdout: { write: (value) => output.push(value) }, + }); + + assert.deepEqual(result, { code: 0, signal: null }); + assert.deepEqual(fixture.runner.runArgs.slice(-6), [ + "install", + "--global", + "--prefix", + fixture.prefix, + "--include=optional", + "@mnemon-dev/mnemon@latest", + ]); + assert.deepEqual(output, ["Updated Mnemon 0.2.8 -> 0.2.9.\n"]); +}); + +test("update reports an already-current installation", async (t) => { + const fixture = await npmFixture(t, "0.2.8"); + const output = []; + await updateNpmInstall({ + packageRoot: fixture.packageRoot, + runner: fixture.runner, + stdout: { write: (value) => output.push(value) }, + }); + assert.deepEqual(output, ["Mnemon is already up to date (0.2.8).\n"]); +}); + +test("update rejects npm from a different global installation", async (t) => { + const fixture = await npmFixture(t, "0.2.8"); + const otherRoot = path.join(fixture.base, "other", "lib", "node_modules"); + await mkdir(path.join(otherRoot, "@mnemon-dev", "mnemon"), { recursive: true }); + fixture.runner.globalRoot = otherRoot; + + await assert.rejects( + updateNpmInstall({ packageRoot: fixture.packageRoot, runner: fixture.runner }), + /not managed by npm.*npm install --global @mnemon-dev\/mnemon@latest/, + ); + assert.equal(fixture.runner.runArgs, undefined); +}); + +test("launcher invokes npm without keeping the native binary open", async (t) => { + const fixture = await npmFixture(t, "0.2.8"); + const fakeBin = path.join(fixture.base, "fake-bin"); + const npmCLI = path.join(fakeBin, "node_modules", "npm", "bin", "npm-cli.js"); + await mkdir(path.dirname(npmCLI), { recursive: true }); + const implementation = [ + "#!/usr/bin/env node", + 'const {readFileSync,writeFileSync}=require("node:fs");', + 'const path=require("node:path");', + "const args=process.argv.slice(2);", + 'if(args.join(" ")==="root --global"){console.log(process.env.MNEMON_TEST_GLOBAL);}', + 'else if(args.join(" ")==="prefix --global"){console.log(process.env.MNEMON_TEST_PREFIX);}', + 'else if(args.includes("install")){', + ' const file=path.join(process.env.MNEMON_TEST_PACKAGE,"package.json");', + ' const data=JSON.parse(readFileSync(file,"utf8"));', + ' data.version="0.2.9";', + ' writeFileSync(file,JSON.stringify(data)+"\\n");', + "}else{process.exitCode=2;}", + "", + ].join("\n"); + await writeFile(npmCLI, implementation, "utf8"); + const npm = path.join(fakeBin, "npm"); + await writeFile(npm, implementation, "utf8"); + await chmod(npm, 0o755); + await writeFile(path.join(fakeBin, "npm.cmd"), "@echo off\r\n", "utf8"); + + const pathKey = Object.keys(process.env).find((name) => name.toLowerCase() === "path") ?? "PATH"; + const sourceRoot = fileURLToPath(new URL("..", import.meta.url)); + await cp(path.join(sourceRoot, "bin"), path.join(fixture.packageRoot, "bin"), { + recursive: true, + }); + await cp(path.join(sourceRoot, "lib"), path.join(fixture.packageRoot, "lib"), { + recursive: true, + }); + await copyFile( + path.join(sourceRoot, "targets.json"), + path.join(fixture.packageRoot, "targets.json"), + ); + + const env = { + ...process.env, + [pathKey]: `${fakeBin}${path.delimiter}${process.env[pathKey] ?? ""}`, + MNEMON_TEST_GLOBAL: fixture.globalRoot, + MNEMON_TEST_PREFIX: fixture.prefix, + MNEMON_TEST_PACKAGE: fixture.packageRoot, + }; + const launched = spawnSync( + process.execPath, + [path.join(fixture.packageRoot, "bin", "mnemon.js"), "update"], + { encoding: "utf8", env }, + ); + assert.deepEqual( + { status: launched.status, stdout: launched.stdout, stderr: launched.stderr }, + { + status: 0, + stdout: "Updated Mnemon 0.2.8 -> 0.2.9.\n", + stderr: "", + }, + ); +}); + +async function npmFixture(t, version) { + let base = await mkdtemp(path.join(os.tmpdir(), "mnemon-update-test-")); + base = await realpath(base); + t.after(() => rm(base, { recursive: true, force: true })); + const prefix = path.join(base, "prefix"); + const globalRoot = path.join(prefix, "lib", "node_modules"); + const packageRoot = path.join(globalRoot, "@mnemon-dev", "mnemon"); + await writePackage(packageRoot, version); + const runner = { + globalRoot, + prefix, + updatedVersion: "", + output(_command, args) { + if (args.slice(-2).join(" ") === "root --global") { + return `${this.globalRoot}\n`; + } + if (args.slice(-2).join(" ") === "prefix --global") { + return `${this.prefix}\n`; + } + throw new Error(`unexpected query: ${args.join(" ")}`); + }, + async run(_command, args) { + this.runArgs = [...args]; + if (this.updatedVersion !== "") { + await writePackage(packageRoot, this.updatedVersion); + } + return { code: 0, signal: null }; + }, + }; + return { base, prefix, globalRoot, packageRoot, runner }; +} + +async function writePackage(root, version) { + await mkdir(root, { recursive: true }); + const existing = await readFile(path.join(root, "package.json"), "utf8").catch(() => "{}"); + const metadata = { ...JSON.parse(existing), name: "@mnemon-dev/mnemon", version }; + await writeFile(path.join(root, "package.json"), `${JSON.stringify(metadata)}\n`, "utf8"); +} diff --git a/scripts/build-npm-packages.mjs b/scripts/build-npm-packages.mjs new file mode 100755 index 00000000..2d344b68 --- /dev/null +++ b/scripts/build-npm-packages.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node + +import { chmod, copyFile, cp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { targets } from "../npm/cli/lib/targets.js"; + +const packageName = "@mnemon-dev/mnemon"; +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const defaultRepositoryRoot = path.resolve(scriptDirectory, ".."); + +export async function stageNpmPackages({ + version, + distDirectory, + outputDirectory, + repositoryRoot = defaultRepositoryRoot, +}) { + const normalizedVersion = normalizeVersion(version); + const distRoot = await realpath(path.resolve(distDirectory)); + const requestedOutput = path.resolve(outputDirectory); + const outputRoot = path.join( + await realpath(path.dirname(requestedOutput)), + path.basename(requestedOutput), + ); + requireChildPath(distRoot, outputRoot, "npm output directory"); + + const artifacts = JSON.parse(await readFile(path.join(distRoot, "artifacts.json"), "utf8")); + if (!Array.isArray(artifacts)) { + throw new Error("GoReleaser artifacts.json must contain an array"); + } + + await rm(outputRoot, { recursive: true, force: true }); + await mkdir(outputRoot, { recursive: true }); + + const staged = []; + for (const target of targets) { + const artifact = selectBinaryArtifact(artifacts, target); + const source = await resolveArtifactPath(distRoot, artifact.path); + const platformVersion = `${normalizedVersion}-${target.id}`; + const directory = path.join(outputRoot, target.id); + await mkdir(path.join(directory, "bin"), { recursive: true }); + await copyFile(source, path.join(directory, target.binary)); + if (target.platform !== "win32") { + await chmod(path.join(directory, target.binary), 0o755); + } + await copyFile(path.join(repositoryRoot, "LICENSE"), path.join(directory, "LICENSE")); + await writeJSON(path.join(directory, "package.json"), platformPackage(target, platformVersion)); + await writeFile( + path.join(directory, "README.md"), + platformReadme(target, platformVersion), + "utf8", + ); + staged.push({ + kind: "platform", + id: target.id, + alias: target.alias, + name: packageName, + version: platformVersion, + tag: platformTag(normalizedVersion, target.id), + directory, + }); + } + + const cliDirectory = path.join(outputRoot, "cli"); + await copyCliSource(repositoryRoot, cliDirectory); + const cliPackagePath = path.join(cliDirectory, "package.json"); + const cliPackage = JSON.parse(await readFile(cliPackagePath, "utf8")); + delete cliPackage.private; + cliPackage.version = normalizedVersion; + cliPackage.optionalDependencies = Object.fromEntries( + targets.map((target) => [ + target.alias, + `npm:${packageName}@${normalizedVersion}-${target.id}`, + ]), + ); + await writeJSON(cliPackagePath, cliPackage); + await copyFile(path.join(repositoryRoot, "LICENSE"), path.join(cliDirectory, "LICENSE")); + staged.push({ + kind: "cli", + name: packageName, + version: normalizedVersion, + tag: releaseTag(normalizedVersion), + directory: cliDirectory, + }); + + const manifest = { schemaVersion: 1, version: normalizedVersion, packages: staged }; + await writeJSON(path.join(outputRoot, "packages.json"), manifest); + return manifest; +} + +export function normalizeVersion(version) { + const normalized = String(version ?? "").replace(/^v/, ""); + const semanticVersion = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + if (!semanticVersion.test(normalized)) { + throw new Error(`Invalid npm release version: ${version}`); + } + return normalized; +} + +export function releaseTag(version) { + return version.includes("-") ? "next" : "latest"; +} + +function platformTag(version, id) { + const channel = releaseTag(version); + return channel === "latest" ? id : `${channel}-${id}`; +} + +function selectBinaryArtifact(artifacts, target) { + const matches = artifacts.filter( + (artifact) => + artifact?.type === "Binary" && + artifact?.extra?.ID === "mnemon" && + artifact?.goos === target.goos && + artifact?.goarch === target.goarch, + ); + if (matches.length !== 1) { + throw new Error( + `Expected one Mnemon binary for ${target.goos}/${target.goarch}, found ${matches.length}`, + ); + } + return matches[0]; +} + +async function resolveArtifactPath(distRoot, artifactPath) { + if (typeof artifactPath !== "string" || artifactPath.length === 0) { + throw new Error("GoReleaser binary artifact has no path"); + } + let candidate = artifactPath; + if (!path.isAbsolute(candidate)) { + candidate = candidate.split(path.sep)[0] === path.basename(distRoot) + ? path.resolve(path.dirname(distRoot), candidate) + : path.resolve(distRoot, candidate); + } + let resolved; + try { + resolved = await realpath(candidate); + } catch (error) { + if (error.code === "ENOENT") { + throw new Error(`GoReleaser binary does not exist: ${artifactPath}`); + } + throw error; + } + requireChildPath(distRoot, resolved, "GoReleaser artifact"); + return resolved; +} + +function requireChildPath(root, candidate, label) { + const relative = path.relative(root, candidate); + if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`)) { + throw new Error(`${label} must be inside ${root}`); + } +} + +function platformPackage(target, version) { + return { + name: packageName, + version, + description: `Mnemon native binary for ${target.platform}/${target.arch}`, + os: [target.platform], + cpu: [target.arch], + files: ["bin", "README.md", "LICENSE"], + repository: { + type: "git", + url: "git+https://github.com/mnemon-dev/mnemon.git", + }, + homepage: "https://github.com/mnemon-dev/mnemon", + license: "Apache-2.0", + publishConfig: { access: "public" }, + }; +} + +function platformReadme(target, version) { + return `# Mnemon ${target.id}\n\n` + + `Native ${target.platform}/${target.arch} artifact for ` + + `\`${packageName}@${version}\`. Install \`${packageName}\` instead of this ` + + `platform artifact directly.\n`; +} + +async function copyCliSource(repositoryRoot, destination) { + const source = path.join(repositoryRoot, "npm", "cli"); + await mkdir(destination, { recursive: true }); + for (const entry of ["bin", "lib"]) { + await cp(path.join(source, entry), path.join(destination, entry), { recursive: true }); + } + for (const entry of ["package.json", "targets.json", "README.md"]) { + await copyFile(path.join(source, entry), path.join(destination, entry)); + } +} + +async function writeJSON(file, value) { + await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function parseArguments(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 2) { + const flag = argv[index]; + const value = argv[index + 1]; + if (!["--version", "--dist", "--output"].includes(flag) || value === undefined) { + throw new Error("Usage: build-npm-packages --version --dist --output "); + } + values[flag.slice(2)] = value; + } + if (!values.version || !values.dist || !values.output) { + throw new Error("Usage: build-npm-packages --version --dist --output "); + } + return values; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + try { + const args = parseArguments(process.argv.slice(2)); + const manifest = await stageNpmPackages({ + version: args.version, + distDirectory: args.dist, + outputDirectory: args.output, + }); + process.stdout.write( + `Staged ${manifest.packages.length} npm artifacts for ${manifest.version}.\n`, + ); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/publish-npm-packages.mjs b/scripts/publish-npm-packages.mjs new file mode 100755 index 00000000..773d8927 --- /dev/null +++ b/scripts/publish-npm-packages.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { targets } from "../npm/cli/lib/targets.js"; + +export async function publishNpmPackages( + manifestPath, + { run = spawnSync, stdout = process.stdout } = {}, +) { + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + validateManifest(manifest); + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + for (const artifact of manifest.packages) { + if (packageVersionExists(run, npm, artifact.name, artifact.version)) { + stdout.write(`Already published ${artifact.name}@${artifact.version}; skipping.\n`); + continue; + } + const published = run( + npm, + [ + "publish", + artifact.directory, + "--access", + "public", + "--tag", + artifact.tag, + "--provenance", + ], + { stdio: "inherit" }, + ); + if (published.status !== 0) { + const diagnostic = published.error?.message ?? `status ${published.status ?? "unknown"}`; + throw new Error( + `npm publish failed for ${artifact.name}@${artifact.version}: ${diagnostic}`, + ); + } + } +} + +function validateManifest(manifest) { + const packages = Array.isArray(manifest?.packages) ? manifest.packages : []; + const cli = packages.filter((artifact) => artifact.kind === "cli"); + const platforms = packages.filter((artifact) => artifact.kind === "platform"); + const platformIds = new Set(platforms.map((artifact) => artifact.id)); + if ( + manifest?.schemaVersion !== 1 || + packages.length !== targets.length + 1 || + cli.length !== 1 || + platforms.length !== targets.length || + packages.at(-1)?.kind !== "cli" || + targets.some((target) => !platformIds.has(target.id)) + ) { + throw new Error("Invalid or unordered npm package manifest"); + } +} + +function packageVersionExists(run, npm, name, version) { + const inspected = run(npm, ["view", `${name}@${version}`, "version", "--json"], { + encoding: "utf8", + }); + if (inspected.status === 0) { + return JSON.parse(inspected.stdout) === version; + } + const diagnostic = `${inspected.stdout}\n${inspected.stderr}\n${inspected.error?.message ?? ""}`; + if (/\bE404\b|code E404/i.test(diagnostic)) { + return false; + } + throw new Error(`Could not inspect ${name}@${version}: ${diagnostic.trim()}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); + const defaultManifest = path.resolve(scriptDirectory, "..", "dist", "npm", "packages.json"); + try { + await publishNpmPackages(path.resolve(process.argv[2] ?? defaultManifest)); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/verify-npm-packages.mjs b/scripts/verify-npm-packages.mjs new file mode 100755 index 00000000..40ab74cf --- /dev/null +++ b/scripts/verify-npm-packages.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +import { cp, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { selectTarget } from "../npm/cli/lib/targets.js"; + +export async function verifyNpmPackages(manifestPath) { + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + validateManifest(manifest); + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + for (const artifact of manifest.packages) { + const packed = spawnSync(npm, ["pack", artifact.directory, "--dry-run", "--json"], { + encoding: "utf8", + }); + if (packed.status !== 0) { + throw new Error(`npm pack failed for ${artifact.version}: ${packed.stderr.trim()}`); + } + const report = JSON.parse(packed.stdout); + const files = new Set(report[0]?.files?.map((entry) => entry.path)); + const expected = + artifact.kind === "cli" ? "bin/mnemon.js" : nativePackageBinary(artifact.id); + if (!files.has(expected) || !files.has("LICENSE") || !files.has("package.json")) { + throw new Error(`npm package ${artifact.version} is missing required files`); + } + } + await verifyHostLauncher(manifest); +} + +function validateManifest(manifest) { + if (manifest?.schemaVersion !== 1 || !Array.isArray(manifest.packages)) { + throw new Error("Invalid npm package manifest"); + } + const cli = manifest.packages.filter((artifact) => artifact.kind === "cli"); + const platforms = manifest.packages.filter((artifact) => artifact.kind === "platform"); + if (cli.length !== 1 || platforms.length !== 6 || manifest.packages.at(-1)?.kind !== "cli") { + throw new Error("npm package manifest is incomplete or out of publish order"); + } +} + +async function verifyHostLauncher(manifest) { + const target = selectTarget(process.platform, process.arch); + const cli = manifest.packages.find((artifact) => artifact.kind === "cli"); + const platform = manifest.packages.find( + (artifact) => artifact.kind === "platform" && artifact.id === target.id, + ); + if (!platform) { + throw new Error(`npm package manifest has no host target ${target.id}`); + } + const temporary = await mkdtemp(path.join(os.tmpdir(), "mnemon-npm-verify-")); + try { + const packageRoot = path.join(temporary, "mnemon"); + await cp(cli.directory, packageRoot, { recursive: true }); + const aliasRoot = path.join(packageRoot, "node_modules", ...target.alias.split("/")); + await mkdir(path.dirname(aliasRoot), { recursive: true }); + await cp(platform.directory, aliasRoot, { recursive: true }); + const launched = spawnSync( + process.execPath, + [path.join(packageRoot, "bin", "mnemon.js"), "--version"], + { encoding: "utf8" }, + ); + if ( + launched.status !== 0 || + launched.stderr !== "" || + launched.stdout.trim() !== `mnemon version ${manifest.version}` + ) { + throw new Error( + `host launcher failed: status=${launched.status} stdout=${JSON.stringify(launched.stdout)} ` + + `stderr=${JSON.stringify(launched.stderr)}`, + ); + } + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} + +function nativePackageBinary(id) { + return id.startsWith("win32-") ? "bin/mnemon.exe" : "bin/mnemon"; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); + const defaultManifest = path.resolve(scriptDirectory, "..", "dist", "npm", "packages.json"); + try { + await verifyNpmPackages(path.resolve(process.argv[2] ?? defaultManifest)); + process.stdout.write("Verified npm package contents and native launcher.\n"); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/test/mnemond/architecture/release_boundary_test.go b/test/mnemond/architecture/release_boundary_test.go index 06cc1f60..f42b0af1 100644 --- a/test/mnemond/architecture/release_boundary_test.go +++ b/test/mnemond/architecture/release_boundary_test.go @@ -200,7 +200,7 @@ func assertCommandHelpSeparation(t *testing.T, root string) { wantMnemon := []string{ "agency", "completion", "embed", "forget", "gc", "help", "import", "link", "log", "recall", "receipt", "related", "remember", "search", "setup", "show", "status", - "store", "viz", + "store", "update", "viz", } if got := cobraTopLevelCommands(mnemon); !slices.Equal(got, wantMnemon) { t.Errorf("mnemon top-level commands = %v, want %v", got, wantMnemon) diff --git a/test/mnemond/architecture/repository_hygiene_test.go b/test/mnemond/architecture/repository_hygiene_test.go index ab394d76..74bb1274 100644 --- a/test/mnemond/architecture/repository_hygiene_test.go +++ b/test/mnemond/architecture/repository_hygiene_test.go @@ -93,6 +93,8 @@ func TestRepositoryHygieneRulesRejectGeneratedFiles(t *testing.T) { func TestRepositoryHygieneRulesAcceptDurableJSONCategories(t *testing.T) { for _, trackedPath := range []string{ "package.json", + "npm/cli/package.json", + "npm/cli/targets.json", "internal/memory/setup/assets/openclaw/plugin/openclaw.plugin.json", "internal/memory/setup/assets/openclaw/plugin/package.json", } { @@ -238,6 +240,8 @@ func durableJSONCategory(trackedPath string) string { switch { case trackedPath == "package.json": return "DSH package manifest" + case trackedPath == "npm/cli/package.json" || trackedPath == "npm/cli/targets.json": + return "npm CLI manifest" case strings.HasPrefix(trackedPath, "internal/memory/setup/assets/"): return "managed asset" default: