From 4a7fbe090af47f63d59bceb8f54cb6106c04a397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dinh?= <82420070+No898@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:40:35 +0100 Subject: [PATCH 1/5] docs(zalo): document current Marketplace bot behavior (openclaw#47552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified: - pnpm check:docs Co-authored-by: Tomáš Dinh <82420070+No898@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/channels/zalo.md | 89 ++++++++++++++++++++++++++++++------------- 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc9aa9435ae..2b85cd40bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280. +- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. ### Fixes diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md index 77b288b0ab7..cf53b574e42 100644 --- a/docs/channels/zalo.md +++ b/docs/channels/zalo.md @@ -7,7 +7,7 @@ title: "Zalo" # Zalo (Bot API) -Status: experimental. DMs are supported; group handling is available with explicit group policy controls. +Status: experimental. DMs are supported. The [Capabilities](#capabilities) section below reflects current Marketplace-bot behavior. ## Plugin required @@ -25,7 +25,7 @@ Zalo ships as a plugin and is not bundled with the core install. - Or pick **Zalo** in onboarding and confirm the install prompt 2. Set the token: - Env: `ZALO_BOT_TOKEN=...` - - Or config: `channels.zalo.botToken: "..."`. + - Or config: `channels.zalo.accounts.default.botToken: "..."`. 3. Restart the gateway (or finish onboarding). 4. DM access is pairing by default; approve the pairing code on first contact. @@ -36,8 +36,12 @@ Minimal config: channels: { zalo: { enabled: true, - botToken: "12345689:abc-xyz", - dmPolicy: "pairing", + accounts: { + default: { + botToken: "12345689:abc-xyz", + dmPolicy: "pairing", + }, + }, }, }, } @@ -48,10 +52,13 @@ Minimal config: Zalo is a Vietnam-focused messaging app; its Bot API lets the Gateway run a bot for 1:1 conversations. It is a good fit for support or notifications where you want deterministic routing back to Zalo. +This page reflects current OpenClaw behavior for **Zalo Bot Creator / Marketplace bots**. +**Zalo Official Account (OA) bots** are a different Zalo product surface and may behave differently. + - A Zalo Bot API channel owned by the Gateway. - Deterministic routing: replies go back to Zalo; the model never chooses channels. - DMs share the agent's main session. -- Groups are supported with policy controls (`groupPolicy` + `groupAllowFrom`) and default to fail-closed allowlist behavior. +- The [Capabilities](#capabilities) section below shows current Marketplace-bot support. ## Setup (fast path) @@ -59,7 +66,7 @@ It is a good fit for support or notifications where you want deterministic routi 1. Go to [https://bot.zaloplatforms.com](https://bot.zaloplatforms.com) and sign in. 2. Create a new bot and configure its settings. -3. Copy the bot token (format: `12345689:abc-xyz`). +3. Copy the full bot token (typically `numeric_id:secret`). For Marketplace bots, the usable runtime token may appear in the bot's welcome message after creation. ### 2) Configure the token (env or config) @@ -70,13 +77,19 @@ Example: channels: { zalo: { enabled: true, - botToken: "12345689:abc-xyz", - dmPolicy: "pairing", + accounts: { + default: { + botToken: "12345689:abc-xyz", + dmPolicy: "pairing", + }, + }, }, }, } ``` +If you later move to a Zalo bot surface where groups are available, you can add group-specific config such as `groupPolicy` and `groupAllowFrom` explicitly. For current Marketplace-bot behavior, see [Capabilities](#capabilities). + Env option: `ZALO_BOT_TOKEN=...` (works for the default account only). Multi-account support: use `channels.zalo.accounts` with per-account tokens and optional `name`. @@ -109,14 +122,23 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and ## Access control (Groups) +For **Zalo Bot Creator / Marketplace bots**, group support was not available in practice because the bot could not be added to a group at all. + +That means the group-related config keys below exist in the schema, but were not usable for Marketplace bots: + - `channels.zalo.groupPolicy` controls group inbound handling: `open | allowlist | disabled`. -- Default behavior is fail-closed: `allowlist`. - `channels.zalo.groupAllowFrom` restricts which sender IDs can trigger the bot in groups. - If `groupAllowFrom` is unset, Zalo falls back to `allowFrom` for sender checks. -- `groupPolicy: "disabled"` blocks all group messages. -- `groupPolicy: "open"` allows any group member (mention-gated). - Runtime note: if `channels.zalo` is missing entirely, runtime still falls back to `groupPolicy="allowlist"` for safety. +The group policy values (when group access is available on your bot surface) are: + +- `groupPolicy: "disabled"` — blocks all group messages. +- `groupPolicy: "open"` — allows any group member (mention-gated). +- `groupPolicy: "allowlist"` — fail-closed default; only allowed senders are accepted. + +If you are using a different Zalo bot product surface and have verified working group behavior, document that separately rather than assuming it matches the Marketplace-bot flow. + ## Long-polling vs webhook - Default: long-polling (no public URL required). @@ -133,23 +155,36 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and ## Supported message types +For a quick support snapshot, see [Capabilities](#capabilities). The notes below add detail where the behavior needs extra context. + - **Text messages**: Full support with 2000 character chunking. -- **Image messages**: Download and process inbound images; send images via `sendPhoto`. -- **Stickers**: Logged but not fully processed (no agent response). -- **Unsupported types**: Logged (e.g., messages from protected users). +- **Plain URLs in text**: Behave like normal text input. +- **Link previews / rich link cards**: See the Marketplace-bot status in [Capabilities](#capabilities); they did not reliably trigger a reply. +- **Image messages**: See the Marketplace-bot status in [Capabilities](#capabilities); inbound image handling was unreliable (typing indicator without a final reply). +- **Stickers**: See the Marketplace-bot status in [Capabilities](#capabilities). +- **Voice notes / audio files / video / generic file attachments**: See the Marketplace-bot status in [Capabilities](#capabilities). +- **Unsupported types**: Logged (for example, messages from protected users). ## Capabilities -| Feature | Status | -| --------------- | -------------------------------------------------------- | -| Direct messages | ✅ Supported | -| Groups | ⚠️ Supported with policy controls (allowlist by default) | -| Media (images) | ✅ Supported | -| Reactions | ❌ Not supported | -| Threads | ❌ Not supported | -| Polls | ❌ Not supported | -| Native commands | ❌ Not supported | -| Streaming | ⚠️ Blocked (2000 char limit) | +This table summarizes current **Zalo Bot Creator / Marketplace bot** behavior in OpenClaw. + +| Feature | Status | +| --------------------------- | --------------------------------------- | +| Direct messages | ✅ Supported | +| Groups | ❌ Not available for Marketplace bots | +| Media (inbound images) | ⚠️ Limited / verify in your environment | +| Media (outbound images) | ⚠️ Not re-tested for Marketplace bots | +| Plain URLs in text | ✅ Supported | +| Link previews | ⚠️ Unreliable for Marketplace bots | +| Reactions | ❌ Not supported | +| Stickers | ⚠️ No agent reply for Marketplace bots | +| Voice notes / audio / video | ⚠️ No agent reply for Marketplace bots | +| File attachments | ⚠️ No agent reply for Marketplace bots | +| Threads | ❌ Not supported | +| Polls | ❌ Not supported | +| Native commands | ❌ Not supported | +| Streaming | ⚠️ Blocked (2000 char limit) | ## Delivery targets (CLI/cron) @@ -175,6 +210,8 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and Full configuration: [Configuration](/gateway/configuration) +The flat top-level keys (`channels.zalo.botToken`, `channels.zalo.dmPolicy`, and similar) are a legacy single-account shorthand. Prefer `channels.zalo.accounts..*` for new configs. Both forms are still documented here because they exist in the schema. + Provider options: - `channels.zalo.enabled`: enable/disable channel startup. @@ -182,7 +219,7 @@ Provider options: - `channels.zalo.tokenFile`: read token from a regular file path. Symlinks are rejected. - `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs. -- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). +- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). Present in config; see [Capabilities](#capabilities) and [Access control (Groups)](#access-control-groups) for current Marketplace-bot behavior. - `channels.zalo.groupAllowFrom`: group sender allowlist (user IDs). Falls back to `allowFrom` when unset. - `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5). - `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required). @@ -198,7 +235,7 @@ Multi-account options: - `channels.zalo.accounts..enabled`: enable/disable account. - `channels.zalo.accounts..dmPolicy`: per-account DM policy. - `channels.zalo.accounts..allowFrom`: per-account allowlist. -- `channels.zalo.accounts..groupPolicy`: per-account group policy. +- `channels.zalo.accounts..groupPolicy`: per-account group policy. Present in config; see [Capabilities](#capabilities) and [Access control (Groups)](#access-control-groups) for current Marketplace-bot behavior. - `channels.zalo.accounts..groupAllowFrom`: per-account group sender allowlist. - `channels.zalo.accounts..webhookUrl`: per-account webhook URL. - `channels.zalo.accounts..webhookSecret`: per-account webhook secret. From a2080421a115d3289dbce88f562b03e6e34c6680 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:42:39 +0100 Subject: [PATCH 2/5] Docs: move release runbook to maintainer repo (#47532) * Docs: redact private release setup * Docs: tighten release order * Docs: move release runbook to maintainer repo * Docs: delete public mac release page * Docs: remove zh-CN mac release page * Docs: turn release checklist into release policy * Docs: point release policy to private docs * Docs: regenerate zh-CN release policy pages * Docs: preserve Doctor in zh-CN hubs * Docs: fix zh-CN polls label * Docs: tighten docs i18n term guardrails * Docs: enforce zh-CN glossary coverage --- AGENTS.md | 44 ++--- docs/.i18n/glossary.zh-CN.json | 16 ++ docs/docs.json | 8 +- docs/platforms/mac/release.md | 90 ---------- docs/reference/RELEASING.md | 169 +++---------------- docs/start/hubs.md | 3 +- docs/zh-CN/AGENTS.md | 4 +- docs/zh-CN/platforms/mac/release.md | 92 ----------- docs/zh-CN/reference/RELEASING.md | 137 ++++------------ docs/zh-CN/start/hubs.md | 25 +-- package.json | 3 +- scripts/check-docs-i18n-glossary.mjs | 237 +++++++++++++++++++++++++++ scripts/docs-i18n/prompt.go | 17 +- 13 files changed, 358 insertions(+), 487 deletions(-) delete mode 100644 docs/platforms/mac/release.md delete mode 100644 docs/zh-CN/platforms/mac/release.md create mode 100644 scripts/check-docs-i18n-glossary.mjs diff --git a/AGENTS.md b/AGENTS.md index 245eedf3d4b..1197f6fb48f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,8 @@ - `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks. - Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed. +- Before rerunning `scripts/docs-i18n`, add glossary entries for any new technical terms, page titles, or short nav labels that must stay in English or use a fixed translation (for example `Doctor` or `Polls`). +- `pnpm docs:check-i18n-glossary` enforces glossary coverage for changed English doc titles and short internal doc labels before translation reruns. - Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated). - See `docs/.i18n/README.md`. - The pipeline can be slow/inefficient; if it’s dragging, ping @jospalmbier on Discord instead of hacking around it. @@ -97,7 +99,7 @@ - Prefer Bun for TypeScript execution (scripts, dev, tests): `bun ` / `bunx `. - Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`. - Node remains supported for running built output (`dist/*`) and production installs. -- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`. +- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. - Type-check/build: `pnpm build` - TypeScript checks: `pnpm tsgo` - Lint/format: `pnpm check` @@ -179,7 +181,7 @@ - Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable. - Environment variables: see `~/.profile`. - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. -- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them. +- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook; use `docs/reference/RELEASING.md` for the public release policy. ## GHSA (Repo Advisory) Patch/Publish @@ -256,14 +258,13 @@ - If shared guardrails are available locally, review them; otherwise follow this repo's guidance. - SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code. - Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync. -- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION). +- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), and Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION). - "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release). - **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch. - **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators. - iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`. - A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit. -- Release signing/notary keys are managed outside the repo; follow internal release docs. -- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs). +- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release). - **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes. - **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks. - **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested. @@ -290,35 +291,12 @@ - Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step. - Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked. -## NPM + 1Password (publish/verify) +## Release Auth -- Use the 1password skill; all `op` commands must run inside a fresh tmux session. -- Correct 1Password path for npm release auth: `op://Private/Npmjs` (use that item; OTP stays `op://Private/Npmjs/one-time password?attribute=otp`). -- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on). -- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`. -- Publish: `npm publish --access public --otp=""` (run from the package dir). -- Verify without local npmrc side effects: `npm view version --userconfig "$(mktemp)"`. -- Kill the tmux session after publish. - -## Plugin Release Fast Path (no core `openclaw` publish) - -- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list". -- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption: - - `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)` - - `eval "$(op signin --account my.1password.com)"` -- 1Password helpers: - - password used by `npm login`: - `op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'` - - OTP: - `op read 'op://Private/Npmjs/one-time password?attribute=otp'` -- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean): - - compare local plugin `version` to `npm view version` - - only run `npm publish --access public --otp=""` when versions differ - - skip if package is missing on npm or version already matches. -- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested. -- Post-check for each release: - - per-plugin: `npm view @openclaw/ version --userconfig "$(mktemp)"` should be `2026.2.17` - - core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested. +- Core `openclaw` publish uses GitHub trusted publishing; do not use `NPM_TOKEN` or the plugin OTP flow for core releases. +- Separate `@openclaw/*` plugin publishes use a different maintainer-only auth flow. +- Plugin scope: only publish already-on-npm `@openclaw/*` plugins. Bundled disk-tree-only plugins stay out. +- Maintainers: private 1Password item names, tmux rules, plugin publish helpers, and local mac signing/notary setup live in the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md). ## Changelog Release Notes diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index bde108074c2..f8941862b94 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -123,6 +123,22 @@ "source": "Network model", "target": "网络模型" }, + { + "source": "Doctor", + "target": "Doctor" + }, + { + "source": "Polls", + "target": "投票" + }, + { + "source": "Release Policy", + "target": "发布策略" + }, + { + "source": "Release policy", + "target": "发布策略" + }, { "source": "for full details", "target": "了解详情" diff --git a/docs/docs.json b/docs/docs.json index 98c88e0177c..8855a7335d6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -469,7 +469,7 @@ }, { "source": "/mac/release", - "destination": "/platforms/mac/release" + "destination": "/reference/RELEASING" }, { "source": "/mac/remote", @@ -1166,7 +1166,6 @@ "platforms/mac/permissions", "platforms/mac/remote", "platforms/mac/signing", - "platforms/mac/release", "platforms/mac/bundled-gateway", "platforms/mac/xpc", "platforms/mac/skills", @@ -1351,7 +1350,7 @@ "pages": ["reference/credits"] }, { - "group": "Release notes", + "group": "Release policy", "pages": ["reference/RELEASING", "reference/test"] }, { @@ -1750,7 +1749,6 @@ "zh-CN/platforms/mac/permissions", "zh-CN/platforms/mac/remote", "zh-CN/platforms/mac/signing", - "zh-CN/platforms/mac/release", "zh-CN/platforms/mac/bundled-gateway", "zh-CN/platforms/mac/xpc", "zh-CN/platforms/mac/skills", @@ -1933,7 +1931,7 @@ "pages": ["zh-CN/reference/credits"] }, { - "group": "发布说明", + "group": "发布策略", "pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"] }, { diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md deleted file mode 100644 index 5276d46848e..00000000000 --- a/docs/platforms/mac/release.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -summary: "OpenClaw macOS release checklist (Sparkle feed, packaging, signing)" -read_when: - - Cutting or validating a OpenClaw macOS release - - Updating the Sparkle appcast or feed assets -title: "macOS Release" ---- - -# OpenClaw macOS release (Sparkle) - -This app now ships Sparkle auto-updates. Release builds must be Developer ID–signed, zipped, and published with a signed appcast entry. - -## Prereqs - -- Developer ID Application cert installed (example: `Developer ID Application: ()`). -- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist). If it is missing, check `~/.profile`. -- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution. - - We use a Keychain profile named `openclaw-notary`, created from App Store Connect API key env vars in your shell profile: - - `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID` - - `echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/openclaw-notary.p8` - - `xcrun notarytool store-credentials "openclaw-notary" --key /tmp/openclaw-notary.p8 --key-id "$APP_STORE_CONNECT_KEY_ID" --issuer "$APP_STORE_CONNECT_ISSUER_ID"` -- `pnpm` deps installed (`pnpm install --config.node-linker=hoisted`). -- Sparkle tools are fetched automatically via SwiftPM at `apps/macos/.build/artifacts/sparkle/Sparkle/bin/` (`sign_update`, `generate_appcast`, etc.). - -## Build & package - -Notes: - -- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal. -- If `APP_BUILD` is omitted, `scripts/package-mac-app.sh` derives a Sparkle-safe default from `APP_VERSION` (`YYYYMMDDNN`: stable defaults to `90`, prereleases use a suffix-derived lane) and uses the higher of that value and git commit count. -- You can still override `APP_BUILD` explicitly when release engineering needs a specific monotonic value. -- For `BUILD_CONFIG=release`, `scripts/package-mac-app.sh` now defaults to universal (`arm64 x86_64`) automatically. You can still override with `BUILD_ARCHS=arm64` or `BUILD_ARCHS=x86_64`. For local/dev builds (`BUILD_CONFIG=debug`), it defaults to the current architecture (`$(uname -m)`). -- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging. - -```bash -# From repo root; set release IDs so Sparkle feed is enabled. -# This command builds release artifacts without notarization. -# APP_BUILD must be numeric + monotonic for Sparkle compare. -# Default is auto-derived from APP_VERSION when omitted. -SKIP_NOTARIZE=1 \ -BUNDLE_ID=ai.openclaw.mac \ -APP_VERSION=2026.3.13 \ -BUILD_CONFIG=release \ -SIGN_IDENTITY="Developer ID Application: ()" \ -scripts/package-mac-dist.sh - -# `package-mac-dist.sh` already creates the zip + DMG. -# If you used `package-mac-app.sh` directly instead, create them manually: -# If you want notarization/stapling in this step, use the NOTARIZE command below. -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.13.zip - -# Optional: build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.13.dmg - -# Recommended: build + notarize/staple zip + DMG -# First, create a keychain profile once: -# xcrun notarytool store-credentials "openclaw-notary" \ -# --apple-id "" --team-id "" --password "" -NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ -BUNDLE_ID=ai.openclaw.mac \ -APP_VERSION=2026.3.13 \ -BUILD_CONFIG=release \ -SIGN_IDENTITY="Developer ID Application: ()" \ -scripts/package-mac-dist.sh - -# Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.13.dSYM.zip -``` - -## Appcast entry - -Use the release note generator so Sparkle renders formatted HTML notes: - -```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.13.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml -``` - -Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. -Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. - -## Publish & verify - -- Upload `OpenClaw-2026.3.13.zip` (and `OpenClaw-2026.3.13.dSYM.zip`) to the GitHub release for tag `v2026.3.13`. -- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`. -- Sanity checks: - - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200. - - `curl -I ` returns 200 after assets upload. - - On a previous public build, run “Check for Updates…” from the About tab and verify Sparkle installs the new build cleanly. - -Definition of done: signed app + appcast are published, update flow works from an older installed version, and release assets are attached to the GitHub release. diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index d94f3866c83..275675c7dba 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -1,161 +1,42 @@ --- -title: "Release Checklist" -summary: "Step-by-step release checklist for npm + macOS app" +title: "Release Policy" +summary: "Public release channels, version naming, and cadence" read_when: - - Cutting a new npm release - - Cutting a new macOS app release - - Verifying metadata before publishing + - Looking for public release channel definitions + - Looking for version naming and cadence --- -# Release Checklist (npm + macOS) +# Release Policy -Use `pnpm` from the repo root with Node 24 by default. Node 22 LTS, currently `22.16+`, remains supported for compatibility. Keep the working tree clean before tagging/publishing. +OpenClaw has three public release lanes: -## Operator trigger +- stable: tagged releases that publish to npm `latest` +- beta: prerelease tags that publish to npm `beta` +- dev: the moving head of `main` -When the operator says “release”, immediately do this preflight (no extra questions unless blocked): - -- Read this doc and `docs/platforms/mac/release.md`. -- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`). -- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed. - -## Versioning - -Current OpenClaw releases use date-based versioning. +## Version naming - Stable release version: `YYYY.M.D` - Git tag: `vYYYY.M.D` - - Examples from repo history: `v2026.2.26`, `v2026.3.8` - Beta prerelease version: `YYYY.M.D-beta.N` - Git tag: `vYYYY.M.D-beta.N` - - Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1` -- Fallback correction tag: `vYYYY.M.D-N` - - Use only as a last-resort recovery tag when a published immutable release burned the original stable tag and you cannot reuse it. - - The npm package version stays `YYYY.M.D`; the `-N` suffix is only for the git tag and GitHub release. - - Prefer betas for normal pre-release iteration, then cut a clean stable tag once ready. -- Use the same version string everywhere, minus the leading `v` where Git tags are not used: - - `package.json`: `2026.3.8` - - Git tag: `v2026.3.8` - - GitHub release title: `openclaw 2026.3.8` -- Do not zero-pad month or day. Use `2026.3.8`, not `2026.03.08`. -- Stable and beta are npm dist-tags, not separate release lines: - - `latest` = stable - - `beta` = prerelease/testing -- Dev is the moving head of `main`, not a normal git-tagged release. -- The tag-triggered preview run accepts stable, beta, and fallback correction tags, and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date. +- Do not zero-pad month or day +- `latest` means the current stable npm release +- `beta` means the current prerelease npm release +- Beta releases may ship before the macOS app catches up -Historical note: +## Release cadence -- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history. -- Treat correction tags as a fallback-only escape hatch. New releases should still use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta. +- Releases move beta-first +- Stable follows only after the latest beta is validated +- Detailed release procedure, approvals, credentials, and recovery notes are + maintainer-only -1. **Version & metadata** +## Public references -- [ ] Bump `package.json` version (e.g., `2026.1.29`). -- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs. -- [ ] Update CLI/version strings in [`src/version.ts`](https://github.com/openclaw/openclaw/blob/main/src/version.ts) and the Baileys user agent in [`src/web/session.ts`](https://github.com/openclaw/openclaw/blob/main/src/web/session.ts). -- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) for `openclaw`. -- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current. +- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml) +- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts) -2. **Build & artifacts** - -- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/openclaw/openclaw/blob/main/src/canvas-host/a2ui/a2ui.bundle.js). -- [ ] `pnpm run build` (regenerates `dist/`). -- [ ] Verify npm package `files` includes all required `dist/*` folders (notably `dist/node-host/**` and `dist/acp/**` for headless node + ACP CLI). -- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs). -- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it). - -3. **Changelog & docs** - -- [ ] Update `CHANGELOG.md` with user-facing highlights (create the file if missing); keep entries strictly descending by version. -- [ ] Ensure README examples/flags match current CLI behavior (notably new commands or options). - -4. **Validation** - -- [ ] `pnpm build` -- [ ] `pnpm check` -- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output) -- [ ] `pnpm release:check` (verifies npm pack contents) -- [ ] If `pnpm config:docs:check` fails as part of release validation and the config-surface change is intentional, run `pnpm config:docs:gen`, review `docs/.generated/config-baseline.json` and `docs/.generated/config-baseline.jsonl`, commit the updated baselines, then rerun `pnpm release:check`. -- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release) - - If the immediate previous npm release is known broken, set `OPENCLAW_INSTALL_SMOKE_PREVIOUS=` or `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step. -- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke` -- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://openclaw.ai/install.sh | bash`, onboards, then runs real tool calls): - - `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`) - - `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`) - - `pnpm test:install:e2e` (requires both keys; runs both providers) -- [ ] (Optional) Spot-check the web gateway if your changes affect send/receive paths. - -5. **macOS app (Sparkle)** - -- [ ] Build + sign the macOS app, then zip it for distribution. -- [ ] Generate the Sparkle appcast (HTML notes via [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh)) and update `appcast.xml`. -- [ ] Keep the app zip (and optional dSYM zip) ready to attach to the GitHub release. -- [ ] Follow [macOS release](/platforms/mac/release) for the exact commands and required env vars. - - `APP_BUILD` must be numeric + monotonic (no `-beta`) so Sparkle compares versions correctly. - - If notarizing, use the `openclaw-notary` keychain profile created from App Store Connect API env vars (see [macOS release](/platforms/mac/release)). - -6. **Publish (npm)** - -- [ ] Confirm git status is clean; commit and push as needed. -- [ ] Confirm npm trusted publishing is configured for the `openclaw` package. -- [ ] Do not rely on an `NPM_TOKEN` secret for this workflow; the publish job uses GitHub OIDC trusted publishing. -- [ ] Push the matching git tag to trigger the preview run in `.github/workflows/openclaw-npm-release.yml`. -- [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval. - - Stable tags publish to npm `latest`. - - Beta tags publish to npm `beta`. - - Fallback correction tags like `v2026.3.13-1` map to npm version `2026.3.13`. - - Both the preview run and the manual publish run reject tags that do not map back to `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date. - - If `openclaw@YYYY.M.D` is already published, a fallback correction tag is still useful for GitHub release and Docker recovery, but npm publish will not republish that version. -- [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`). - -### Troubleshooting (notes from 2.0.0-beta2 release) - -- **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/OpenClaw.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/OpenClaw.app` is not listed. -- **npm auth web loop for dist-tags**: use legacy auth to get an OTP prompt: - - `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest` -- **`npx` verification fails with `ECOMPROMISED: Lock compromised`**: retry with a fresh cache: - - `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version` -- **Tag needs recovery after a late fix**: if the original stable tag is tied to an immutable GitHub release, mint a fallback correction tag like `vX.Y.Z-1` instead of trying to force-update `vX.Y.Z`. - - Keep the npm package version at `X.Y.Z`; the correction suffix is for the git tag and GitHub release only. - - Use this only as a last resort. For normal iteration, prefer beta tags and then cut a clean stable release. - -7. **GitHub release + appcast** - -- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`). - - Pushing the tag also triggers the npm release workflow. -- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `openclaw X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**. -- [ ] Attach artifacts: `npm pack` tarball (optional), `OpenClaw-X.Y.Z.zip`, and `OpenClaw-X.Y.Z.dSYM.zip` (if generated). -- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main). -- [ ] From a clean temp directory (no `package.json`), run `npx -y openclaw@X.Y.Z send --help` to confirm install/CLI entrypoints work. -- [ ] Announce/share release notes. - -## Plugin publish scope (npm) - -We only publish **existing npm plugins** under the `@openclaw/*` scope. Bundled -plugins that are not on npm stay **disk-tree only** (still shipped in -`extensions/**`). - -Process to derive the list: - -1. `npm search @openclaw --json` and capture the package names. -2. Compare with `extensions/*/package.json` names. -3. Publish only the **intersection** (already on npm). - -Current npm plugin list (update as needed): - -- @openclaw/bluebubbles -- @openclaw/diagnostics-otel -- @openclaw/discord -- @openclaw/feishu -- @openclaw/lobster -- @openclaw/matrix -- @openclaw/msteams -- @openclaw/nextcloud-talk -- @openclaw/nostr -- @openclaw/voice-call -- @openclaw/zalo -- @openclaw/zalouser - -Release notes must also call out **new optional bundled plugins** that are **not -on by default** (example: `tlon`). +Maintainers use the private release docs in +[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md) +for the actual runbook. diff --git a/docs/start/hubs.md b/docs/start/hubs.md index cad1e41e114..9833b467378 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -157,7 +157,6 @@ Use these hubs to discover every page, including deep dives and reference docs t - [macOS permissions](/platforms/mac/permissions) - [macOS remote](/platforms/mac/remote) - [macOS signing](/platforms/mac/signing) -- [macOS release](/platforms/mac/release) - [macOS gateway (launchd)](/platforms/mac/bundled-gateway) - [macOS XPC](/platforms/mac/xpc) - [macOS skills](/platforms/mac/skills) @@ -190,5 +189,5 @@ Use these hubs to discover every page, including deep dives and reference docs t ## Testing + release - [Testing](/reference/test) -- [Release checklist](/reference/RELEASING) +- [Release policy](/reference/RELEASING) - [Device models](/reference/device-models) diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index cbf46cc310f..719a3576480 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -12,7 +12,7 @@ - 目标文档:`docs/zh-CN/**/*.md` - 术语表:`docs/.i18n/glossary.zh-CN.json` - 翻译记忆库:`docs/.i18n/zh-CN.tm.jsonl` -- 提示词规则:`scripts/docs-i18n/translator.go` +- 提示词规则:`scripts/docs-i18n/prompt.go` 常用运行方式: @@ -31,6 +31,8 @@ go run scripts/docs-i18n/main.go -mode segment docs/channels/matrix.md 注意事项: - doc 模式用于整页翻译;segment 模式用于小范围修补(依赖 TM)。 +- 新增技术术语、页面标题或短导航标签时,先更新 `docs/.i18n/glossary.zh-CN.json`,再跑 `doc` 模式;不要指望模型自行保留英文术语或固定译名。 +- `pnpm docs:check-i18n-glossary` 会检查变更过的英文文档标题和短内部链接标签是否已写入 glossary。 - 超大文件若超时,优先做**定点替换**或拆分后再跑。 - 翻译后检查中文引号、CJK-Latin 间距和术语一致性。 diff --git a/docs/zh-CN/platforms/mac/release.md b/docs/zh-CN/platforms/mac/release.md deleted file mode 100644 index d087a2bcb8c..00000000000 --- a/docs/zh-CN/platforms/mac/release.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -read_when: - - 制作或验证 OpenClaw macOS 发布版本 - - 更新 Sparkle appcast 或订阅源资源 -summary: OpenClaw macOS 发布清单(Sparkle 订阅源、打包、签名) -title: macOS 发布 -x-i18n: - generated_at: "2026-02-01T21:33:17Z" - model: claude-opus-4-5 - provider: pi - source_hash: 703c08c13793cd8c96bd4c31fb4904cdf4ffff35576e7ea48a362560d371cb30 - source_path: platforms/mac/release.md - workflow: 15 ---- - -# OpenClaw macOS 发布(Sparkle) - -本应用现已支持 Sparkle 自动更新。发布构建必须经过 Developer ID 签名、压缩,并发布包含签名的 appcast 条目。 - -## 前提条件 - -- 已安装 Developer ID Application 证书(示例:`Developer ID Application: ()`)。 -- 环境变量 `SPARKLE_PRIVATE_KEY_FILE` 已设置为 Sparkle ed25519 私钥路径(公钥已嵌入 Info.plist)。如果缺失,请检查 `~/.profile`。 -- 用于 `xcrun notarytool` 的公证凭据(钥匙串配置文件或 API 密钥),以实现通过 Gatekeeper 安全分发的 DMG/zip。 - - 我们使用名为 `openclaw-notary` 的钥匙串配置文件,由 shell 配置文件中的 App Store Connect API 密钥环境变量创建: - - `APP_STORE_CONNECT_API_KEY_P8`、`APP_STORE_CONNECT_KEY_ID`、`APP_STORE_CONNECT_ISSUER_ID` - - `echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/openclaw-notary.p8` - - `xcrun notarytool store-credentials "openclaw-notary" --key /tmp/openclaw-notary.p8 --key-id "$APP_STORE_CONNECT_KEY_ID" --issuer "$APP_STORE_CONNECT_ISSUER_ID"` -- 已安装 `pnpm` 依赖(`pnpm install --config.node-linker=hoisted`)。 -- Sparkle 工具通过 SwiftPM 自动获取,位于 `apps/macos/.build/artifacts/sparkle/Sparkle/bin/`(`sign_update`、`generate_appcast` 等)。 - -## 构建与打包 - -注意事项: - -- `APP_BUILD` 映射到 `CFBundleVersion`/`sparkle:version`;保持纯数字且单调递增(不含 `-beta`),否则 Sparkle 会将其视为相同版本。 -- 默认为当前架构(`$(uname -m)`)。对于发布/通用构建,设置 `BUILD_ARCHS="arm64 x86_64"`(或 `BUILD_ARCHS=all`)。 -- 使用 `scripts/package-mac-dist.sh` 生成发布产物(zip + DMG + 公证)。使用 `scripts/package-mac-app.sh` 进行本地/开发打包。 - -```bash -# 从仓库根目录运行;设置发布 ID 以启用 Sparkle 订阅源。 -# APP_BUILD 必须为纯数字且单调递增,以便 Sparkle 正确比较。 -BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.1.27-beta.1 \ -APP_BUILD="$(git rev-list --count HEAD)" \ -BUILD_CONFIG=release \ -SIGN_IDENTITY="Developer ID Application: ()" \ -scripts/package-mac-app.sh - -# 打包用于分发的 zip(包含资源分支以支持 Sparkle 增量更新) -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.1.27-beta.1.zip - -# 可选:同时构建适合用户使用的样式化 DMG(拖拽到 /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.1.27-beta.1.dmg - -# 推荐:构建 + 公证/装订 zip + DMG -# 首先,创建一次钥匙串配置文件: -# xcrun notarytool store-credentials "openclaw-notary" \ -# --apple-id "" --team-id "" --password "" -NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ -BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.1.27-beta.1 \ -APP_BUILD="$(git rev-list --count HEAD)" \ -BUILD_CONFIG=release \ -SIGN_IDENTITY="Developer ID Application: ()" \ -scripts/package-mac-dist.sh - -# 可选:随发布一起提供 dSYM -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.1.27-beta.1.dSYM.zip -``` - -## Appcast 条目 - -使用发布说明生成器,以便 Sparkle 渲染格式化的 HTML 说明: - -```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.1.27-beta.1.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml -``` - -从 `CHANGELOG.md`(通过 [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh))生成 HTML 发布说明,并将其嵌入 appcast 条目。 -发布时,将更新后的 `appcast.xml` 与发布资源(zip + dSYM)一起提交。 - -## 发布与验证 - -- 将 `OpenClaw-2026.1.27-beta.1.zip`(和 `OpenClaw-2026.1.27-beta.1.dSYM.zip`)上传到标签 `v2026.1.27-beta.1` 对应的 GitHub 发布。 -- 确保原始 appcast URL 与内置的订阅源匹配:`https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`。 -- 完整性检查: - - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` 返回 200。 - - `curl -I ` 在资源上传后返回 200。 - - 在之前的公开构建版本上,从 About 选项卡运行"Check for Updates…",验证 Sparkle 能正常安装新构建。 - -完成定义:已签名的应用 + appcast 已发布,从旧版本的更新流程正常工作,且发布资源已附加到 GitHub 发布。 diff --git a/docs/zh-CN/reference/RELEASING.md b/docs/zh-CN/reference/RELEASING.md index 81b0832f11c..cb1d02f60e8 100644 --- a/docs/zh-CN/reference/RELEASING.md +++ b/docs/zh-CN/reference/RELEASING.md @@ -1,123 +1,48 @@ --- read_when: - - 发布新的 npm 版本 - - 发布新的 macOS 应用版本 - - 发布前验证元数据 -summary: npm + macOS 应用的逐步发布清单 + - 查找公开发布渠道的定义 + - 查找版本命名与发布节奏 +summary: 公开发布渠道、版本命名与发布节奏 +title: 发布策略 x-i18n: - generated_at: "2026-02-03T10:09:28Z" - model: claude-opus-4-5 + generated_at: "2026-03-15T19:23:11Z" + model: claude-opus-4-6 provider: pi - source_hash: 1a684bc26665966eb3c9c816d58d18eead008fd710041181ece38c21c5ff1c62 + source_hash: df332d3169de7099661725d9266955456e80fc3d3ff95cb7aaf9997a02f0baaf source_path: reference/RELEASING.md workflow: 15 --- -# 发布清单(npm + macOS) +# 发布策略 -从仓库根目录使用 `pnpm`(Node 22+)。在打标签/发布前保持工作树干净。 +OpenClaw 有三个公开发布渠道: -## 操作员触发 +- stable:带标签的正式发布,发布到 npm `latest` +- beta:预发布标签,发布到 npm `beta` +- dev:`main` 分支的最新提交 -当操作员说"release"时,立即执行此预检(除非遇到阻碍否则不要额外提问): +## 版本命名 -- 阅读本文档和 `docs/platforms/mac/release.md`。 -- 从 `~/.profile` 加载环境变量并确认 `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect 变量已设置(SPARKLE_PRIVATE_KEY_FILE 应位于 `~/.profile` 中)。 -- 如需要,使用 `~/Library/CloudStorage/Dropbox/Backup/Sparkle` 中的 Sparkle 密钥。 +- 正式发布版本号:`YYYY.M.D` + - Git 标签:`vYYYY.M.D` +- Beta 预发布版本号:`YYYY.M.D-beta.N` + - Git 标签:`vYYYY.M.D-beta.N` +- 月份和日期不补零 +- `latest` 表示当前 npm 正式发布版本 +- `beta` 表示当前 npm 预发布版本 +- Beta 版本可能会在 macOS 应用跟进之前发布 -1. **版本和元数据** +## 发布节奏 -- [ ] 更新 `package.json` 版本(例如 `2026.1.29`)。 -- [ ] 运行 `pnpm plugins:sync` 以对齐扩展包版本和变更日志。 -- [ ] 更新 CLI/版本字符串:[`src/cli/program.ts`](https://github.com/openclaw/openclaw/blob/main/src/cli/program.ts) 和 [`src/provider-web.ts`](https://github.com/openclaw/openclaw/blob/main/src/provider-web.ts) 中的 Baileys user agent。 -- [ ] 确认包元数据(name、description、repository、keywords、license)以及 `bin` 映射指向 [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) 作为 `openclaw`。 -- [ ] 如果依赖项有变化,运行 `pnpm install` 确保 `pnpm-lock.yaml` 是最新的。 +- 发布遵循 beta 优先原则 +- 仅在最新的 beta 版本验证通过后才会发布正式版本 +- 详细的发布流程、审批、凭证和恢复说明仅限维护者查阅 -2. **构建和产物** +## 公开参考 -- [ ] 如果 A2UI 输入有变化,运行 `pnpm canvas:a2ui:bundle` 并提交更新后的 [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/openclaw/openclaw/blob/main/src/canvas-host/a2ui/a2ui.bundle.js)。 -- [ ] `pnpm run build`(重新生成 `dist/`)。 -- [ ] 验证 npm 包的 `files` 包含所有必需的 `dist/*` 文件夹(特别是用于 headless node + ACP CLI 的 `dist/node-host/**` 和 `dist/acp/**`)。 -- [ ] 确认 `dist/build-info.json` 存在并包含预期的 `commit` 哈希(CLI 横幅在 npm 安装时使用此信息)。 -- [ ] 可选:构建后运行 `npm pack --pack-destination /tmp`;检查 tarball 内容并保留以备 GitHub 发布使用(**不要**提交它)。 +- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml) +- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts) -3. **变更日志和文档** - -- [ ] 更新 `CHANGELOG.md`,添加面向用户的亮点(如果文件不存在则创建);按版本严格降序排列条目。 -- [ ] 确保 README 示例/标志与当前 CLI 行为匹配(特别是新命令或选项)。 - -4. **验证** - -- [ ] `pnpm build` -- [ ] `pnpm check` -- [ ] `pnpm test`(如需覆盖率输出则使用 `pnpm test:coverage`) -- [ ] `pnpm release:check`(验证 npm pack 内容) -- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`(Docker 安装冒烟测试,快速路径;发布前必需) - - 如果已知上一个 npm 发布版本有问题,为预安装步骤设置 `OPENCLAW_INSTALL_SMOKE_PREVIOUS=` 或 `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1`。 -- [ ](可选)完整安装程序冒烟测试(添加非 root + CLI 覆盖):`pnpm test:install:smoke` -- [ ](可选)安装程序 E2E(Docker,运行 `curl -fsSL https://openclaw.ai/install.sh | bash`,新手引导,然后运行真实工具调用): - - `pnpm test:install:e2e:openai`(需要 `OPENAI_API_KEY`) - - `pnpm test:install:e2e:anthropic`(需要 `ANTHROPIC_API_KEY`) - - `pnpm test:install:e2e`(需要两个密钥;运行两个提供商) -- [ ](可选)如果你的更改影响发送/接收路径,抽查 Web Gateway 网关。 - -5. **macOS 应用(Sparkle)** - -- [ ] 构建并签名 macOS 应用,然后压缩以供分发。 -- [ ] 生成 Sparkle appcast(通过 [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh) 生成 HTML 注释)并更新 `appcast.xml`。 -- [ ] 保留应用 zip(和可选的 dSYM zip)以便附加到 GitHub 发布。 -- [ ] 按照 [macOS 发布](/platforms/mac/release) 获取确切命令和所需环境变量。 - - `APP_BUILD` 必须是数字且单调递增(不带 `-beta`),以便 Sparkle 正确比较版本。 - - 如果进行公证,使用从 App Store Connect API 环境变量创建的 `openclaw-notary` 钥匙串配置文件(参见 [macOS 发布](/platforms/mac/release))。 - -6. **发布(npm)** - -- [ ] 确认 git 状态干净;根据需要提交并推送。 -- [ ] 如需要,`npm login`(验证 2FA)。 -- [ ] `npm publish --access public`(预发布版本使用 `--tag beta`)。 -- [ ] 验证注册表:`npm view openclaw version`、`npm view openclaw dist-tags` 和 `npx -y openclaw@X.Y.Z --version`(或 `--help`)。 - -### 故障排除(来自 2.0.0-beta2 发布的笔记) - -- **npm pack/publish 挂起或产生巨大 tarball**:`dist/OpenClaw.app` 中的 macOS 应用包(和发布 zip)被扫入包中。通过 `package.json` 的 `files` 白名单发布内容来修复(包含 dist 子目录、docs、skills;排除应用包)。用 `npm pack --dry-run` 确认 `dist/OpenClaw.app` 未列出。 -- **npm auth dist-tags 的 Web 循环**:使用旧版认证以获取 OTP 提示: - - `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest` -- **`npx` 验证失败并显示 `ECOMPROMISED: Lock compromised`**:使用新缓存重试: - - `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version` -- **延迟修复后需要重新指向标签**:强制更新并推送标签,然后确保 GitHub 发布资产仍然匹配: - - `git tag -f vX.Y.Z && git push -f origin vX.Y.Z` - -7. **GitHub 发布 + appcast** - -- [ ] 打标签并推送:`git tag vX.Y.Z && git push origin vX.Y.Z`(或 `git push --tags`)。 -- [ ] 为 `vX.Y.Z` 创建/刷新 GitHub 发布,**标题为 `openclaw X.Y.Z`**(不仅仅是标签);正文应包含该版本的**完整**变更日志部分(亮点 + 更改 + 修复),内联显示(无裸链接),且**不得在正文中重复标题**。 -- [ ] 附加产物:`npm pack` tarball(可选)、`OpenClaw-X.Y.Z.zip` 和 `OpenClaw-X.Y.Z.dSYM.zip`(如果生成)。 -- [ ] 提交更新后的 `appcast.xml` 并推送(Sparkle 从 main 获取源)。 -- [ ] 从干净的临时目录(无 `package.json`),运行 `npx -y openclaw@X.Y.Z send --help` 确认安装/CLI 入口点正常工作。 -- [ ] 宣布/分享发布说明。 - -## 插件发布范围(npm) - -我们只发布 `@openclaw/*` 范围下的**现有 npm 插件**。不在 npm 上的内置插件保持**仅磁盘树**(仍在 `extensions/**` 中发布)。 - -获取列表的流程: - -1. `npm search @openclaw --json` 并捕获包名。 -2. 与 `extensions/*/package.json` 名称比较。 -3. 只发布**交集**(已在 npm 上)。 - -当前 npm 插件列表(根据需要更新): - -- @openclaw/bluebubbles -- @openclaw/diagnostics-otel -- @openclaw/discord -- @openclaw/lobster -- @openclaw/matrix -- @openclaw/msteams -- @openclaw/nextcloud-talk -- @openclaw/nostr -- @openclaw/voice-call -- @openclaw/zalo -- @openclaw/zalouser - -发布说明还必须标注**默认未启用**的**新可选内置插件**(例如:`tlon`)。 +维护者使用 +[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md) +中的私有发布文档作为实际操作手册。 diff --git a/docs/zh-CN/start/hubs.md b/docs/zh-CN/start/hubs.md index a2e6260fdf2..b303102dcc0 100644 --- a/docs/zh-CN/start/hubs.md +++ b/docs/zh-CN/start/hubs.md @@ -1,20 +1,24 @@ --- read_when: - 你想要一份完整的文档地图 -summary: 链接到每篇 OpenClaw 文档的导航中心 +summary: 链接到所有 OpenClaw 文档的导航中心 title: 文档导航中心 x-i18n: - generated_at: "2026-02-04T17:55:29Z" - model: claude-opus-4-5 + generated_at: "2026-03-15T19:29:16Z" + model: claude-opus-4-6 provider: pi - source_hash: c4b4572b64d36c9690988b8f964b0712f551ee6491b18a493701a17d2d352cb4 + source_hash: e12e8b7881311fdaf08cd297392911dfa30dc46031a7038b6bb9011d166b1669 source_path: start/hubs.md workflow: 15 --- # 文档导航中心 -使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们不一定出现在左侧导航栏中。 + +如果你是 OpenClaw 新用户,请从[入门指南](/start/getting-started)开始。 + + +使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们可能不会出现在左侧导航栏中。 ## 从这里开始 @@ -75,7 +79,6 @@ x-i18n: - [模型提供商中心](/providers/models) - [WhatsApp](/channels/whatsapp) - [Telegram](/channels/telegram) -- [Telegram(grammY 注意事项)](/channels/grammy) - [Slack](/channels/slack) - [Discord](/channels/discord) - [Mattermost](/channels/mattermost)(插件) @@ -113,17 +116,18 @@ x-i18n: - [OpenProse](/prose) - [CLI 参考](/cli) - [Exec 工具](/tools/exec) +- [PDF 工具](/tools/pdf) - [提权模式](/tools/elevated) - [定时任务](/automation/cron-jobs) - [定时任务 vs 心跳](/automation/cron-vs-heartbeat) - [思考 + 详细输出](/tools/thinking) - [模型](/concepts/models) - [子智能体](/tools/subagents) -- [Agent send CLI](/tools/agent-send) +- [智能体发送 CLI](/tools/agent-send) - [终端界面](/web/tui) - [浏览器控制](/tools/browser) - [浏览器(Linux 故障排除)](/tools/browser-linux-troubleshooting) -- [轮询](/automation/poll) +- [投票](/automation/poll) ## 节点、媒体、语音 @@ -160,7 +164,6 @@ x-i18n: - [macOS 权限](/platforms/mac/permissions) - [macOS 远程](/platforms/mac/remote) - [macOS 签名](/platforms/mac/signing) -- [macOS 发布](/platforms/mac/release) - [macOS Gateway 网关 (launchd)](/platforms/mac/bundled-gateway) - [macOS XPC](/platforms/mac/xpc) - [macOS Skills](/platforms/mac/skills) @@ -183,8 +186,6 @@ x-i18n: ## 实验(探索性) - [新手引导配置协议](/experiments/onboarding-config-protocol) -- [定时任务加固笔记](/experiments/plans/cron-add-hardening) -- [群组策略加固笔记](/experiments/plans/group-policy-hardening) - [研究:记忆](/experiments/research/memory) - [模型配置探索](/experiments/proposals/model-config) @@ -195,5 +196,5 @@ x-i18n: ## 测试 + 发布 - [测试](/reference/test) -- [发布检查清单](/reference/RELEASING) +- [发布策略](/reference/RELEASING) - [设备型号](/reference/device-models) diff --git a/package.json b/package.json index 2d880e80fe7..a839cdd3ec1 100644 --- a/package.json +++ b/package.json @@ -231,7 +231,7 @@ "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", - "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", + "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", "config:docs:check": "node --import tsx scripts/generate-config-doc-baseline.ts --check", @@ -246,6 +246,7 @@ "deadcode:ts-unused": "pnpm dlx ts-unused-exports tsconfig.json --ignoreTestFiles --exitWithCount", "dev": "node scripts/run-node.mjs", "docs:bin": "node scripts/build-docs-list.mjs", + "docs:check-i18n-glossary": "node scripts/check-docs-i18n-glossary.mjs", "docs:check-links": "node scripts/docs-link-audit.mjs", "docs:dev": "cd docs && mint dev", "docs:list": "node scripts/docs-list.js", diff --git a/scripts/check-docs-i18n-glossary.mjs b/scripts/check-docs-i18n-glossary.mjs new file mode 100644 index 00000000000..96f890bc4ff --- /dev/null +++ b/scripts/check-docs-i18n-glossary.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = process.cwd(); +const GLOSSARY_PATH = path.join(ROOT, "docs", ".i18n", "glossary.zh-CN.json"); +const DOC_FILE_RE = /^docs\/(?!zh-CN\/).+\.(md|mdx)$/i; +const LIST_ITEM_LINK_RE = /^\s*(?:[-*]|\d+\.)\s+\[([^\]]+)\]\((\/[^)]+)\)/; +const MAX_TITLE_WORDS = 8; +const MAX_LABEL_WORDS = 6; +const MAX_TERM_LENGTH = 80; + +/** + * @typedef {{ + * file: string; + * line: number; + * kind: "title" | "link label"; + * term: string; + * }} TermMatch + */ + +function parseArgs(argv) { + /** @type {{ base: string; head: string }} */ + const args = { base: "", head: "" }; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === "--base") { + args.base = argv[i + 1] ?? ""; + i += 1; + continue; + } + if (argv[i] === "--head") { + args.head = argv[i + 1] ?? ""; + i += 1; + } + } + return args; +} + +function runGit(args) { + return execFileSync("git", args, { + cwd: ROOT, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }).trim(); +} + +function resolveBase(explicitBase) { + if (explicitBase) { + return explicitBase; + } + + const envBase = process.env.DOCS_I18N_GLOSSARY_BASE?.trim(); + if (envBase) { + return envBase; + } + + for (const candidate of ["origin/main", "fork/main", "main"]) { + try { + return runGit(["merge-base", candidate, "HEAD"]); + } catch { + // Try the next candidate. + } + } + + return ""; +} + +function listChangedDocs(base, head) { + const args = ["diff", "--name-only", "--diff-filter=ACMR", base]; + if (head) { + args.push(head); + } + args.push("--", "docs"); + + return runGit(args) + .split("\n") + .map((line) => line.trim()) + .filter((line) => DOC_FILE_RE.test(line)); +} + +function loadGlossarySources() { + const data = fs.readFileSync(GLOSSARY_PATH, "utf8"); + const entries = JSON.parse(data); + return new Set(entries.map((entry) => String(entry.source || "").trim()).filter(Boolean)); +} + +function containsLatin(text) { + return /[A-Za-z]/.test(text); +} + +function wordCount(text) { + return text.trim().split(/\s+/).filter(Boolean).length; +} + +function unquoteScalar(raw) { + const value = raw.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1).trim(); + } + return value; +} + +function isGlossaryCandidate(term, maxWords) { + if (!term) { + return false; + } + if (!containsLatin(term)) { + return false; + } + if (term.includes("`")) { + return false; + } + if (term.length > MAX_TERM_LENGTH) { + return false; + } + return wordCount(term) <= maxWords; +} + +function readGitFile(base, relPath) { + try { + return runGit(["show", `${base}:${relPath}`]); + } catch { + return ""; + } +} + +/** + * @param {string} file + * @param {string} text + * @returns {Map} + */ +function extractTerms(file, text) { + /** @type {Map} */ + const terms = new Map(); + const lines = text.split("\n"); + + if (lines[0]?.trim() === "---") { + for (let index = 1; index < lines.length; index += 1) { + const line = lines[index]; + if (line.trim() === "---") { + break; + } + + const match = line.match(/^title:\s*(.+)\s*$/); + if (!match) { + continue; + } + + const title = unquoteScalar(match[1]); + if (isGlossaryCandidate(title, MAX_TITLE_WORDS)) { + terms.set(title, { file, line: index + 1, kind: "title", term: title }); + } + break; + } + } + + for (let index = 0; index < lines.length; index += 1) { + const match = lines[index].match(LIST_ITEM_LINK_RE); + if (!match) { + continue; + } + + const label = match[1].trim(); + if (!isGlossaryCandidate(label, MAX_LABEL_WORDS)) { + continue; + } + + if (!terms.has(label)) { + terms.set(label, { file, line: index + 1, kind: "link label", term: label }); + } + } + + return terms; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const base = resolveBase(args.base); + + if (!base) { + console.warn( + "docs:check-i18n-glossary: no merge base found; skipping glossary coverage check.", + ); + process.exit(0); + } + + const changedDocs = listChangedDocs(base, args.head); + if (changedDocs.length === 0) { + process.exit(0); + } + + const glossary = loadGlossarySources(); + /** @type {TermMatch[]} */ + const missing = []; + + for (const relPath of changedDocs) { + const absPath = path.join(ROOT, relPath); + if (!fs.existsSync(absPath)) { + continue; + } + + const currentTerms = extractTerms(relPath, fs.readFileSync(absPath, "utf8")); + const baseTerms = extractTerms(relPath, readGitFile(base, relPath)); + + for (const [term, match] of currentTerms) { + if (baseTerms.has(term)) { + continue; + } + if (glossary.has(term)) { + continue; + } + missing.push(match); + } + } + + if (missing.length === 0) { + process.exit(0); + } + + console.error("docs:check-i18n-glossary: missing zh-CN glossary entries for changed doc labels:"); + for (const match of missing) { + console.error(`- ${match.file}:${match.line} ${match.kind} "${match.term}"`); + } + console.error(""); + console.error( + "Add exact source terms to docs/.i18n/glossary.zh-CN.json before rerunning docs-i18n.", + ); + console.error(`Checked changed English docs relative to ${base}.`); + process.exit(1); +} + +main(); diff --git a/scripts/docs-i18n/prompt.go b/scripts/docs-i18n/prompt.go index 8ecf8688140..773dfd8fcfd 100644 --- a/scripts/docs-i18n/prompt.go +++ b/scripts/docs-i18n/prompt.go @@ -58,6 +58,11 @@ Rules: - Do not remove, reorder, or summarize content. - Use fluent, idiomatic technical Chinese; avoid slang or jokes. - Use neutral documentation tone; prefer “你/你的”, avoid “您/您的”. +- Glossary terms are mandatory. When a source term matches a glossary entry, use + the glossary target exactly, including headings, link labels, and short + UI-style labels. +- If a glossary target is identical to the source text, preserve that term in + English exactly as written. - Insert a space between Latin characters and CJK text (W3C CLREQ), e.g., “Gateway 网关”, “Skills 配置”. - Use Chinese quotation marks “ and ” for Chinese prose; keep ASCII quotes inside code spans/blocks or literal CLI/keys. - Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. @@ -90,6 +95,11 @@ Rules: - Do not remove, reorder, or summarize content. - Use fluent, idiomatic technical Japanese; avoid slang or jokes. - Use neutral documentation tone; avoid overly formal honorifics (e.g., avoid “〜でございます”). +- Glossary terms are mandatory. When a source term matches a glossary entry, use + the glossary target exactly, including headings, link labels, and short + UI-style labels. +- If a glossary target is identical to the source text, preserve that term in + English exactly as written. - Use Japanese quotation marks 「 and 」 for Japanese prose; keep ASCII quotes inside code spans/blocks or literal CLI/keys. - Do not add or remove spacing around Latin text just because it borders Japanese; keep spacing stable unless required by Japanese grammar. - Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. @@ -121,6 +131,11 @@ Rules: - Do not remove, reorder, or summarize content. - Use fluent, idiomatic technical language in the target language; avoid slang or jokes. - Use neutral documentation tone. +- Glossary terms are mandatory. When a source term matches a glossary entry, use + the glossary target exactly, including headings, link labels, and short + UI-style labels. +- If a glossary target is identical to the source text, preserve that term in + English exactly as written. - Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. - Keep these terms in English: Skills, local loopback, Tailscale. - Never output an empty response; if unsure, return the source text unchanged. @@ -135,7 +150,7 @@ func buildGlossaryPrompt(glossary []GlossaryEntry) string { return "" } var lines []string - lines = append(lines, "Preferred translations (use when natural):") + lines = append(lines, "Required terminology (use exactly when the source term matches):") for _, entry := range glossary { if entry.Source == "" || entry.Target == "" { continue From 594920f8cc9693e4b2f8bba2512711ab0f2f201f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 16:19:27 -0400 Subject: [PATCH 3/5] Scripts: rebuild on extension and tsdown config changes (#47571) Merged via squash. Prepared head SHA: edd8ed825469128bbe85f86e2e1341f6c57687d7 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + README.md | 2 +- docs/help/debugging.md | 12 +- docs/start/setup.md | 3 +- scripts/run-node.d.mts | 2 + scripts/run-node.mjs | 127 ++++++++++-- scripts/watch-node.mjs | 28 +-- src/infra/run-node.test.ts | 362 +++++++++++++++++++++++++++++++++++ src/infra/watch-node.test.ts | 41 +++- 9 files changed, 539 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b85cd40bb3..0f77551f4f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai - CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc. - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. - Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. +- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras. ## 2026.3.13 diff --git a/README.md b/README.md index d5a22313f27..fee53d83065 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ pnpm build pnpm openclaw onboard --install-daemon -# Dev loop (auto-reload on TS changes) +# Dev loop (auto-reload on source/config changes) pnpm gateway:watch ``` diff --git a/docs/help/debugging.md b/docs/help/debugging.md index 61539ec39a3..04fd150ef20 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -40,11 +40,17 @@ pnpm gateway:watch This maps to: ```bash -node --watch-path src --watch-path tsconfig.json --watch-path package.json --watch-preserve-output scripts/run-node.mjs gateway --force +node scripts/watch-node.mjs gateway --force ``` -Add any gateway CLI flags after `gateway:watch` and they will be passed through -on each restart. +The watcher restarts on build-relevant files under `src/`, extension source files, +extension `package.json` and `openclaw.plugin.json` metadata, `tsconfig.json`, +`package.json`, and `tsdown.config.ts`. Extension metadata changes restart the +gateway without forcing a `tsdown` rebuild; source and config changes still +rebuild `dist` first. + +Add any gateway CLI flags after `gateway:watch` and they will be passed through on +each restart. ## Dev profile + dev gateway (--dev) diff --git a/docs/start/setup.md b/docs/start/setup.md index 205f14d20a5..bf127cc0ad0 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -96,7 +96,8 @@ pnpm install pnpm gateway:watch ``` -`gateway:watch` runs the gateway in watch mode and reloads on TypeScript changes. +`gateway:watch` runs the gateway in watch mode and reloads on relevant source, +config, and bundled-plugin metadata changes. ### 2) Point the macOS app at your running Gateway diff --git a/scripts/run-node.d.mts b/scripts/run-node.d.mts index 1fc9a1437e0..e86c269d4d3 100644 --- a/scripts/run-node.d.mts +++ b/scripts/run-node.d.mts @@ -1,4 +1,6 @@ export const runNodeWatchedPaths: string[]; +export function isBuildRelevantRunNodePath(repoPath: string): boolean; +export function isRestartRelevantRunNodePath(repoPath: string): boolean; export function runNodeMain(params?: { spawn?: ( diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 90e7c137209..0e3acd763b9 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -8,7 +8,63 @@ import { pathToFileURL } from "node:url"; const compiler = "tsdown"; const compilerArgs = ["exec", compiler, "--no-clean"]; -export const runNodeWatchedPaths = ["src", "tsconfig.json", "package.json"]; +const runNodeSourceRoots = ["src", "extensions"]; +const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]; +export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles]; +const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/; +const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]); + +const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); + +const isIgnoredSourcePath = (relativePath) => { + const normalizedPath = normalizePath(relativePath); + return ( + normalizedPath.endsWith(".test.ts") || + normalizedPath.endsWith(".test.tsx") || + normalizedPath.endsWith("test-helpers.ts") + ); +}; + +const isBuildRelevantSourcePath = (relativePath) => { + const normalizedPath = normalizePath(relativePath); + return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath); +}; + +export const isBuildRelevantRunNodePath = (repoPath) => { + const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, ""); + if (runNodeConfigFiles.includes(normalizedPath)) { + return true; + } + if (normalizedPath.startsWith("src/")) { + return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); + } + if (normalizedPath.startsWith("extensions/")) { + return isBuildRelevantSourcePath(normalizedPath.slice("extensions/".length)); + } + return false; +}; + +const isRestartRelevantExtensionPath = (relativePath) => { + const normalizedPath = normalizePath(relativePath); + if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) { + return true; + } + return isBuildRelevantSourcePath(normalizedPath); +}; + +export const isRestartRelevantRunNodePath = (repoPath) => { + const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, ""); + if (runNodeConfigFiles.includes(normalizedPath)) { + return true; + } + if (normalizedPath.startsWith("src/")) { + return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); + } + if (normalizedPath.startsWith("extensions/")) { + return isRestartRelevantExtensionPath(normalizedPath.slice("extensions/".length)); + } + return false; +}; const statMtime = (filePath, fsImpl = fs) => { try { @@ -18,16 +74,12 @@ const statMtime = (filePath, fsImpl = fs) => { } }; -const isExcludedSource = (filePath, srcRoot) => { - const relativePath = path.relative(srcRoot, filePath); +const isExcludedSource = (filePath, sourceRoot, sourceRootName) => { + const relativePath = normalizePath(path.relative(sourceRoot, filePath)); if (relativePath.startsWith("..")) { return false; } - return ( - relativePath.endsWith(".test.ts") || - relativePath.endsWith(".test.tsx") || - relativePath.endsWith(`test-helpers.ts`) - ); + return !isBuildRelevantRunNodePath(path.posix.join(sourceRootName, relativePath)); }; const findLatestMtime = (dirPath, shouldSkip, deps) => { @@ -89,15 +141,39 @@ const resolveGitHead = (deps) => { return head || null; }; +const readGitStatus = (deps) => { + try { + const result = deps.spawnSync( + "git", + ["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths], + { + cwd: deps.cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ); + if (result.status !== 0) { + return null; + } + return result.stdout ?? ""; + } catch { + return null; + } +}; + +const parseGitStatusPaths = (output) => + output + .split("\n") + .flatMap((line) => line.slice(3).split(" -> ")) + .map((entry) => normalizePath(entry.trim())) + .filter(Boolean); + const hasDirtySourceTree = (deps) => { - const output = runGit( - ["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths], - deps, - ); + const output = readGitStatus(deps); if (output === null) { return null; } - return output.length > 0; + return parseGitStatusPaths(output).some((repoPath) => isBuildRelevantRunNodePath(repoPath)); }; const readBuildStamp = (deps) => { @@ -119,12 +195,18 @@ const readBuildStamp = (deps) => { }; const hasSourceMtimeChanged = (stampMtime, deps) => { - const srcMtime = findLatestMtime( - deps.srcRoot, - (candidate) => isExcludedSource(candidate, deps.srcRoot), - deps, - ); - return srcMtime != null && srcMtime > stampMtime; + let latestSourceMtime = null; + for (const sourceRoot of deps.sourceRoots) { + const sourceMtime = findLatestMtime( + sourceRoot.path, + (candidate) => isExcludedSource(candidate, sourceRoot.path, sourceRoot.name), + deps, + ); + if (sourceMtime != null && (latestSourceMtime == null || sourceMtime > latestSourceMtime)) { + latestSourceMtime = sourceMtime; + } + } + return latestSourceMtime != null && latestSourceMtime > stampMtime; }; const shouldBuild = (deps) => { @@ -223,8 +305,11 @@ export async function runNodeMain(params = {}) { deps.distRoot = path.join(deps.cwd, "dist"); deps.distEntry = path.join(deps.distRoot, "/entry.js"); deps.buildStampPath = path.join(deps.distRoot, ".buildstamp"); - deps.srcRoot = path.join(deps.cwd, "src"); - deps.configFiles = [path.join(deps.cwd, "tsconfig.json"), path.join(deps.cwd, "package.json")]; + deps.sourceRoots = runNodeSourceRoots.map((sourceRoot) => ({ + name: sourceRoot, + path: path.join(deps.cwd, sourceRoot), + })); + deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath)); if (!shouldBuild(deps)) { return await runOpenClaw(deps); diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index 891e07439a1..e4598ae79fe 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -1,26 +1,32 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; +import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; import chokidar from "chokidar"; -import { runNodeWatchedPaths } from "./run-node.mjs"; +import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node.mjs"; const WATCH_NODE_RUNNER = "scripts/run-node.mjs"; const WATCH_RESTART_SIGNAL = "SIGTERM"; const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args]; -const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); +const normalizePath = (filePath) => + String(filePath ?? "") + .replaceAll("\\", "/") + .replace(/^\.\/+/, ""); -const isIgnoredWatchPath = (filePath) => { - const normalizedPath = normalizePath(filePath); - return ( - normalizedPath.endsWith(".test.ts") || - normalizedPath.endsWith(".test.tsx") || - normalizedPath.endsWith("test-helpers.ts") - ); +const resolveRepoPath = (filePath, cwd) => { + const rawPath = String(filePath ?? ""); + if (path.isAbsolute(rawPath)) { + return normalizePath(path.relative(cwd, rawPath)); + } + return normalizePath(rawPath); }; +const isIgnoredWatchPath = (filePath, cwd) => + !isRestartRelevantRunNodePath(resolveRepoPath(filePath, cwd)); + export async function runWatchMain(params = {}) { const deps = { spawn: params.spawn ?? spawn, @@ -52,7 +58,7 @@ export async function runWatchMain(params = {}) { const watcher = deps.createWatcher(deps.watchPaths, { ignoreInitial: true, - ignored: (watchPath) => isIgnoredWatchPath(watchPath), + ignored: (watchPath) => isIgnoredWatchPath(watchPath, deps.cwd), }); const settle = (code) => { @@ -89,7 +95,7 @@ export async function runWatchMain(params = {}) { }; const requestRestart = (changedPath) => { - if (shuttingDown || isIgnoredWatchPath(changedPath)) { + if (shuttingDown || isIgnoredWatchPath(changedPath, deps.cwd)) { return; } if (!watchProcess) { diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 0b8cf1090bc..7ba07fdaf2d 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -24,6 +24,12 @@ function createExitedProcess(code: number | null, signal: string | null = null) }; } +function expectedBuildSpawn(platform: NodeJS.Platform = process.platform) { + return platform === "win32" + ? ["cmd.exe", "/d", "/s", "/c", "pnpm", "exec", "tsdown", "--no-clean"] + : ["pnpm", "exec", "tsdown", "--no-clean"]; +} + describe("run-node script", () => { it.runIf(process.platform !== "win32")( "preserves control-ui assets by building with tsdown --no-clean", @@ -161,4 +167,360 @@ describe("run-node script", () => { expect(exitCode).toBe(23); }); }); + + it("rebuilds when extension sources are newer than the build stamp", async () => { + await withTempDir(async (tmp) => { + const extensionPath = path.join(tmp, "extensions", "demo", "src", "index.ts"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + await fs.mkdir(path.dirname(extensionPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(extensionPath, "export const extensionValue = 1;\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + await fs.utimes(extensionPath, newTime, newTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = () => ({ status: 1, stdout: "" }); + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBuildSpawn(), + [process.execPath, "openclaw.mjs", "status"], + ]); + }); + }); + + it("skips rebuilding when extension package metadata is newer than the build stamp", async () => { + await withTempDir(async (tmp) => { + const packagePath = path.join(tmp, "extensions", "demo", "package.json"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(packagePath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile( + packagePath, + '{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n', + "utf-8", + ); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const oldTime = new Date("2026-03-13T10:00:00.000Z"); + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(tsconfigPath, oldTime, oldTime); + await fs.utimes(packageJsonPath, oldTime, oldTime); + await fs.utimes(tsdownConfigPath, oldTime, oldTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + await fs.utimes(packagePath, newTime, newTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = () => ({ status: 1, stdout: "" }); + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + }); + }); + + it("skips rebuilding for dirty non-source files under extensions", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const readmePath = path.join(tmp, "extensions", "demo", "README.md"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(readmePath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(readmePath, "# demo\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + await fs.utimes(srcPath, stampTime, stampTime); + await fs.utimes(readmePath, stampTime, stampTime); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: " M extensions/demo/README.md\n" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + }); + }); + + it("skips rebuilding for dirty extension manifests that only affect runtime reload", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(manifestPath, '{"id":"demo"}\n', "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + await fs.utimes(srcPath, stampTime, stampTime); + await fs.utimes(manifestPath, stampTime, stampTime); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: " M extensions/demo/openclaw.plugin.json\n" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + }); + }); + + it("skips rebuilding when only non-source extension files are newer than the build stamp", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const readmePath = path.join(tmp, "extensions", "demo", "README.md"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(readmePath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(readmePath, "# demo\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const oldTime = new Date("2026-03-13T10:00:00.000Z"); + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(srcPath, oldTime, oldTime); + await fs.utimes(tsconfigPath, oldTime, oldTime); + await fs.utimes(packageJsonPath, oldTime, oldTime); + await fs.utimes(tsdownConfigPath, oldTime, oldTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + await fs.utimes(readmePath, newTime, newTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = () => ({ status: 1, stdout: "" }); + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + }); + }); + + it("rebuilds when tsdown config is newer than the build stamp", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const oldTime = new Date("2026-03-13T10:00:00.000Z"); + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(srcPath, oldTime, oldTime); + await fs.utimes(tsconfigPath, oldTime, oldTime); + await fs.utimes(packageJsonPath, oldTime, oldTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, newTime, newTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: "" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBuildSpawn(), + [process.execPath, "openclaw.mjs", "status"], + ]); + }); + }); }); diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index 89ec4b79ef2..8fa92bae1df 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -44,10 +44,17 @@ describe("watch-node script", () => { { ignoreInitial: boolean; ignored: (watchPath: string) => boolean }, ]; expect(watchPaths).toEqual(runNodeWatchedPaths); + expect(watchPaths).toContain("extensions"); + expect(watchPaths).toContain("tsdown.config.ts"); expect(watchOptions.ignoreInitial).toBe(true); expect(watchOptions.ignored("src/infra/watch-node.test.ts")).toBe(true); expect(watchOptions.ignored("src/infra/watch-node.test.tsx")).toBe(true); expect(watchOptions.ignored("src/infra/watch-node-test-helpers.ts")).toBe(true); + expect(watchOptions.ignored("extensions/voice-call/README.md")).toBe(true); + expect(watchOptions.ignored("extensions/voice-call/openclaw.plugin.json")).toBe(false); + expect(watchOptions.ignored("extensions/voice-call/package.json")).toBe(false); + expect(watchOptions.ignored("extensions/voice-call/index.ts")).toBe(false); + expect(watchOptions.ignored("extensions/voice-call/src/runtime.ts")).toBe(false); expect(watchOptions.ignored("src/infra/watch-node.ts")).toBe(false); expect(watchOptions.ignored("tsconfig.json")).toBe(false); @@ -120,9 +127,24 @@ describe("watch-node script", () => { }), }); const childB = Object.assign(new EventEmitter(), { + kill: vi.fn(function () { + queueMicrotask(() => childB.emit("exit", 0, null)); + }), + }); + const childC = Object.assign(new EventEmitter(), { + kill: vi.fn(function () { + queueMicrotask(() => childC.emit("exit", 0, null)); + }), + }); + const childD = Object.assign(new EventEmitter(), { kill: vi.fn(() => {}), }); - const spawn = vi.fn().mockReturnValueOnce(childA).mockReturnValueOnce(childB); + const spawn = vi + .fn() + .mockReturnValueOnce(childA) + .mockReturnValueOnce(childB) + .mockReturnValueOnce(childC) + .mockReturnValueOnce(childD); const watcher = Object.assign(new EventEmitter(), { close: vi.fn(async () => {}), }); @@ -151,11 +173,26 @@ describe("watch-node script", () => { expect(spawn).toHaveBeenCalledTimes(1); expect(childA.kill).not.toHaveBeenCalled(); - watcher.emit("change", "src/infra/watch-node.ts"); + watcher.emit("change", "extensions/voice-call/README.md"); + await new Promise((resolve) => setImmediate(resolve)); + expect(spawn).toHaveBeenCalledTimes(1); + expect(childA.kill).not.toHaveBeenCalled(); + + watcher.emit("change", "extensions/voice-call/openclaw.plugin.json"); await new Promise((resolve) => setImmediate(resolve)); expect(childA.kill).toHaveBeenCalledWith("SIGTERM"); expect(spawn).toHaveBeenCalledTimes(2); + watcher.emit("change", "extensions/voice-call/package.json"); + await new Promise((resolve) => setImmediate(resolve)); + expect(childB.kill).toHaveBeenCalledWith("SIGTERM"); + expect(spawn).toHaveBeenCalledTimes(3); + + watcher.emit("change", "src/infra/watch-node.ts"); + await new Promise((resolve) => setImmediate(resolve)); + expect(childC.kill).toHaveBeenCalledWith("SIGTERM"); + expect(spawn).toHaveBeenCalledTimes(4); + fakeProcess.emit("SIGINT"); const exitCode = await runPromise; expect(exitCode).toBe(130); From 07f890fa45bc782fab594fdfc77505aca6ea4c4b Mon Sep 17 00:00:00 2001 From: Ted Li Date: Sun, 15 Mar 2026 13:31:30 -0700 Subject: [PATCH 4/5] fix(release): block oversized npm packs that regress low-memory startup (#46850) * fix(release): guard npm pack size regressions * fix(release): fail closed when npm omits pack size --- scripts/release-check.ts | 59 ++++++++++++++++++++++++++++++++++++-- test/release-check.test.ts | 32 +++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 6f621cef2d5..34d37634d6f 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -15,7 +15,7 @@ import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./s export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts"; type PackFile = { path: string }; -type PackResult = { files?: PackFile[] }; +type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number }; const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], @@ -112,6 +112,10 @@ const requiredPathGroups = [ "dist/build-info.json", ]; const forbiddenPrefixes = ["dist/OpenClaw.app/"]; +// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory +// startup/doctor OOM reports. Keep enough headroom for the current pack while +// failing fast if duplicate/shim content sneaks back into the release artifact. +const npmPackUnpackedSizeBudgetBytes = 160 * 1024 * 1024; const appcastPath = resolve("appcast.xml"); const laneBuildMin = 1_000_000_000; const laneFloorAdoptionDateKey = 20260227; @@ -228,6 +232,50 @@ export function collectForbiddenPackPaths(paths: Iterable): string[] { .toSorted(); } +function formatMiB(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; +} + +function resolvePackResultLabel(entry: PackResult, index: number): string { + return entry.filename?.trim() || `pack result #${index + 1}`; +} + +function formatPackUnpackedSizeBudgetError(params: { + label: string; + unpackedSize: number; +}): string { + return [ + `${params.label} unpackedSize ${params.unpackedSize} bytes (${formatMiB(params.unpackedSize)}) exceeds budget ${npmPackUnpackedSizeBudgetBytes} bytes (${formatMiB(npmPackUnpackedSizeBudgetBytes)}).`, + "Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.", + ].join(" "); +} + +export function collectPackUnpackedSizeErrors(results: Iterable): string[] { + const entries = Array.from(results); + const errors: string[] = []; + let checkedCount = 0; + + for (const [index, entry] of entries.entries()) { + if (typeof entry.unpackedSize !== "number" || !Number.isFinite(entry.unpackedSize)) { + continue; + } + checkedCount += 1; + if (entry.unpackedSize <= npmPackUnpackedSizeBudgetBytes) { + continue; + } + const label = resolvePackResultLabel(entry, index); + errors.push(formatPackUnpackedSizeBudgetError({ label, unpackedSize: entry.unpackedSize })); + } + + if (entries.length > 0 && checkedCount === 0) { + errors.push( + "npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.", + ); + } + + return errors; +} + function checkPluginVersions() { const rootPackagePath = resolve("package.json"); const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson; @@ -433,8 +481,9 @@ function main() { }) .toSorted(); const forbidden = collectForbiddenPackPaths(paths); + const sizeErrors = collectPackUnpackedSizeErrors(results); - if (missing.length > 0 || forbidden.length > 0) { + if (missing.length > 0 || forbidden.length > 0 || sizeErrors.length > 0) { if (missing.length > 0) { console.error("release-check: missing files in npm pack:"); for (const path of missing) { @@ -447,6 +496,12 @@ function main() { console.error(` - ${path}`); } } + if (sizeErrors.length > 0) { + console.error("release-check: npm pack unpacked size budget exceeded:"); + for (const error of sizeErrors) { + console.error(` - ${error}`); + } + } process.exit(1); } diff --git a/test/release-check.test.ts b/test/release-check.test.ts index a399407aa98..5f0bcf65192 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -4,12 +4,17 @@ import { collectBundledExtensionManifestErrors, collectBundledExtensionRootDependencyGapErrors, collectForbiddenPackPaths, + collectPackUnpackedSizeErrors, } from "../scripts/release-check.ts"; function makeItem(shortVersion: string, sparkleVersion: string): string { return `${shortVersion}${shortVersion}${sparkleVersion}`; } +function makePackResult(filename: string, unpackedSize: number) { + return { filename, unpackedSize }; +} + describe("collectAppcastSparkleVersionErrors", () => { it("accepts legacy 9-digit calver builds before lane-floor cutover", () => { const xml = `${makeItem("2026.2.26", "202602260")}`; @@ -163,3 +168,30 @@ describe("collectForbiddenPackPaths", () => { ).toEqual(["extensions/tlon/node_modules/.bin/tlon", "node_modules/.bin/openclaw"]); }); }); + +describe("collectPackUnpackedSizeErrors", () => { + it("accepts pack results within the unpacked size budget", () => { + expect( + collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.14.tgz", 120_354_302)]), + ).toEqual([]); + }); + + it("flags oversized pack results that risk low-memory startup failures", () => { + expect( + collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.12.tgz", 224_002_564)]), + ).toEqual([ + "openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 167772160 bytes (160.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.", + ]); + }); + + it("fails closed when npm pack output omits unpackedSize for every result", () => { + expect( + collectPackUnpackedSizeErrors([ + { filename: "openclaw-2026.3.14.tgz" }, + { filename: "openclaw-extra.tgz", unpackedSize: Number.NaN }, + ]), + ).toEqual([ + "npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.", + ]); + }); +}); From 85dd0ab2f8472932a886734cb2520e1f091e0d52 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 13:33:37 -0700 Subject: [PATCH 5/5] Plugins: reserve context engine ownership (#47595) * Plugins: reserve context engine ownership * Update src/context-engine/registry.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/context-engine/context-engine.test.ts | 24 +++++++-- src/context-engine/registry.ts | 38 ++++++++++--- src/plugins/loader.test.ts | 65 +++++++++++++++++++++++ src/plugins/registry.ts | 22 +++++++- 4 files changed, 137 insertions(+), 12 deletions(-) diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index cd0f2f50439..5cdc03a7114 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -231,18 +231,36 @@ describe("Registry tests", () => { expect(Array.isArray(ids)).toBe(true); }); - it("registering the same id overwrites the previous factory", () => { + it("registering the same id with the same owner refreshes the factory", () => { const factory1 = () => new MockContextEngine(); const factory2 = () => new MockContextEngine(); - registerContextEngine("reg-overwrite", factory1); + expect(registerContextEngine("reg-overwrite", factory1, { owner: "owner-a" })).toEqual({ + ok: true, + }); expect(getContextEngineFactory("reg-overwrite")).toBe(factory1); - registerContextEngine("reg-overwrite", factory2); + expect(registerContextEngine("reg-overwrite", factory2, { owner: "owner-a" })).toEqual({ + ok: true, + }); expect(getContextEngineFactory("reg-overwrite")).toBe(factory2); expect(getContextEngineFactory("reg-overwrite")).not.toBe(factory1); }); + it("rejects context engine registrations from a different owner", () => { + const factory1 = () => new MockContextEngine(); + const factory2 = () => new MockContextEngine(); + + expect(registerContextEngine("reg-owner-guard", factory1, { owner: "owner-a" })).toEqual({ + ok: true, + }); + expect(registerContextEngine("reg-owner-guard", factory2, { owner: "owner-b" })).toEqual({ + ok: false, + existingOwner: "owner-a", + }); + expect(getContextEngineFactory("reg-owner-guard")).toBe(factory1); + }); + it("shares registered engines across duplicate module copies", async () => { const registryUrl = new URL("./registry.ts", import.meta.url).href; const suffix = Date.now().toString(36); diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index d73266c62de..ba04da7c51d 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -7,6 +7,7 @@ import type { ContextEngine } from "./types.js"; * Supports async creation for engines that need DB connections etc. */ export type ContextEngineFactory = () => ContextEngine | Promise; +export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string }; // --------------------------------------------------------------------------- // Registry (module-level singleton) @@ -15,7 +16,13 @@ export type ContextEngineFactory = () => ContextEngine | Promise; const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistryState"); type ContextEngineRegistryState = { - engines: Map; + engines: Map< + string, + { + factory: ContextEngineFactory; + owner: string; + } + >; }; // Keep context-engine registrations process-global so duplicated dist chunks @@ -26,7 +33,7 @@ function getContextEngineRegistryState(): ContextEngineRegistryState { }; if (!globalState[CONTEXT_ENGINE_REGISTRY_STATE]) { globalState[CONTEXT_ENGINE_REGISTRY_STATE] = { - engines: new Map(), + engines: new Map(), }; } return globalState[CONTEXT_ENGINE_REGISTRY_STATE]; @@ -35,15 +42,30 @@ function getContextEngineRegistryState(): ContextEngineRegistryState { /** * Register a context engine implementation under the given id. */ -export function registerContextEngine(id: string, factory: ContextEngineFactory): void { - getContextEngineRegistryState().engines.set(id, factory); +export function registerContextEngine( + id: string, + factory: ContextEngineFactory, + opts?: { owner?: string }, +): ContextEngineRegistrationResult { + const rawOwner = opts?.owner?.trim(); + if (opts?.owner !== undefined && !rawOwner) { + throw new Error(`registerContextEngine: owner must be a non-empty string, got ${JSON.stringify(opts.owner)}`); + } + const owner = rawOwner || "core"; + const registry = getContextEngineRegistryState().engines; + const existing = registry.get(id); + if (existing && existing.owner !== owner) { + return { ok: false, existingOwner: existing.owner }; + } + registry.set(id, { factory, owner }); + return { ok: true }; } /** * Return the factory for a registered engine, or undefined. */ export function getContextEngineFactory(id: string): ContextEngineFactory | undefined { - return getContextEngineRegistryState().engines.get(id); + return getContextEngineRegistryState().engines.get(id)?.factory; } /** @@ -73,13 +95,13 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise { ).toBe(true); }); + it("rejects plugin context engine ids reserved by core", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "context-engine-core-collision", + filename: "context-engine-core-collision.cjs", + body: `module.exports = { id: "context-engine-core-collision", register(api) { + api.registerContextEngine("legacy", () => ({})); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["context-engine-core-collision"], + }, + }); + + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "context-engine-core-collision" && + diag.message === "context engine id reserved by core: legacy", + ), + ).toBe(true); + }); + + it("rejects duplicate plugin context engine ids", () => { + useNoBundledPlugins(); + const first = writePlugin({ + id: "context-engine-owner-a", + filename: "context-engine-owner-a.cjs", + body: `module.exports = { id: "context-engine-owner-a", register(api) { + api.registerContextEngine("shared-context-engine-loader-test", () => ({})); +} };`, + }); + const second = writePlugin({ + id: "context-engine-owner-b", + filename: "context-engine-owner-b.cjs", + body: `module.exports = { id: "context-engine-owner-b", register(api) { + api.registerContextEngine("shared-context-engine-loader-test", () => ({})); +} };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [first.file, second.file] }, + allow: ["context-engine-owner-a", "context-engine-owner-b"], + }, + }, + }); + + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "context-engine-owner-b" && + diag.message === + "context engine already registered: shared-context-engine-loader-test (plugin:context-engine-owner-a)", + ), + ).toBe(true); + }); + it("requires plugin CLI registrars to declare explicit command roots", () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index c1c63cc96cb..952c8d7744b 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -15,6 +15,7 @@ import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { normalizeRegisteredProvider } from "./provider-validation.js"; import type { PluginRuntime } from "./runtime/types.js"; +import { defaultSlotIdForKey } from "./slots.js"; import { isPluginHookName, isPromptInjectionHookName, @@ -653,7 +654,26 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), registerCommand: (command) => registerCommand(record, command), - registerContextEngine: (id, factory) => registerContextEngine(id, factory), + registerContextEngine: (id, factory) => { + if (id === defaultSlotIdForKey("contextEngine")) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `context engine id reserved by core: ${id}`, + }); + return; + } + const result = registerContextEngine(id, factory, { owner: `plugin:${record.id}` }); + if (!result.ok) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `context engine already registered: ${id} (${result.existingOwner})`, + }); + } + }, resolvePath: (input: string) => resolveUserPath(input), on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts, params.hookPolicy),