Merge branch 'main' of https://github.com/openclaw/openclaw
* 'main' of https://github.com/openclaw/openclaw: Plugins: reserve context engine ownership (#47595) fix(release): block oversized npm packs that regress low-memory startup (#46850) Scripts: rebuild on extension and tsdown config changes (#47571) Docs: move release runbook to maintainer repo (#47532) docs(zalo): document current Marketplace bot behavior (openclaw#47552)
This commit is contained in:
commit
b795ba1d02
44
AGENTS.md
44
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 <file.ts>` / `bunx <tool>`.
|
||||
- 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="<otp>"` (run from the package dir).
|
||||
- Verify without local npmrc side effects: `npm view <pkg> 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 <name> version`
|
||||
- only run `npm publish --access public --otp="<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/<name> 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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -64,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
|
||||
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -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": "了解详情"
|
||||
|
||||
@ -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.<id>.*` 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.<id>.enabled`: enable/disable account.
|
||||
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.
|
||||
- `channels.zalo.accounts.<id>.allowFrom`: per-account allowlist.
|
||||
- `channels.zalo.accounts.<id>.groupPolicy`: per-account group policy.
|
||||
- `channels.zalo.accounts.<id>.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.<id>.groupAllowFrom`: per-account group sender allowlist.
|
||||
- `channels.zalo.accounts.<id>.webhookUrl`: per-account webhook URL.
|
||||
- `channels.zalo.accounts.<id>.webhookSecret`: per-account webhook secret.
|
||||
|
||||
@ -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"]
|
||||
},
|
||||
{
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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: <Developer Name> (<TEAMID>)`).
|
||||
- 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: <Developer Name> (<TEAMID>)" \
|
||||
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 "<apple-id>" --team-id "<team-id>" --password "<app-specific-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: <Developer Name> (<TEAMID>)" \
|
||||
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 <enclosure url>` 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.
|
||||
@ -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=<last-good-version>` 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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 间距和术语一致性。
|
||||
|
||||
|
||||
@ -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: <Developer Name> (<TEAMID>)`)。
|
||||
- 环境变量 `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: <Developer Name> (<TEAMID>)" \
|
||||
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 "<apple-id>" --team-id "<team-id>" --password "<app-specific-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: <Developer Name> (<TEAMID>)" \
|
||||
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 <enclosure url>` 在资源上传后返回 200。
|
||||
- 在之前的公开构建版本上,从 About 选项卡运行"Check for Updates…",验证 Sparkle 能正常安装新构建。
|
||||
|
||||
完成定义:已签名的应用 + appcast 已发布,从旧版本的更新流程正常工作,且发布资源已附加到 GitHub 发布。
|
||||
@ -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=<last-good-version>` 或 `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)
|
||||
中的私有发布文档作为实际操作手册。
|
||||
|
||||
@ -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
|
||||
---
|
||||
|
||||
# 文档导航中心
|
||||
|
||||
使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们不一定出现在左侧导航栏中。
|
||||
<Note>
|
||||
如果你是 OpenClaw 新用户,请从[入门指南](/start/getting-started)开始。
|
||||
</Note>
|
||||
|
||||
使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们可能不会出现在左侧导航栏中。
|
||||
|
||||
## 从这里开始
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
237
scripts/check-docs-i18n-glossary.mjs
Normal file
237
scripts/check-docs-i18n-glossary.mjs
Normal file
@ -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<string, TermMatch>}
|
||||
*/
|
||||
function extractTerms(file, text) {
|
||||
/** @type {Map<string, TermMatch>} */
|
||||
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();
|
||||
@ -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
|
||||
|
||||
@ -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>): 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<PackResult>): 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);
|
||||
}
|
||||
|
||||
|
||||
@ -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?: (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -9,11 +9,17 @@ import type { ContextEngine } from "./types.js";
|
||||
export type ContextEngineFactory = () => ContextEngine | Promise<ContextEngine>;
|
||||
export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string };
|
||||
|
||||
type RegisterContextEngineForOwnerOptions = {
|
||||
allowSameOwnerRefresh?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry (module-level singleton)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistryState");
|
||||
const CORE_CONTEXT_ENGINE_OWNER = "core";
|
||||
const PUBLIC_CONTEXT_ENGINE_OWNER = "public-sdk";
|
||||
|
||||
type ContextEngineRegistryState = {
|
||||
engines: Map<
|
||||
@ -39,22 +45,54 @@ function getContextEngineRegistryState(): ContextEngineRegistryState {
|
||||
return globalState[CONTEXT_ENGINE_REGISTRY_STATE];
|
||||
}
|
||||
|
||||
function requireContextEngineOwner(owner: string): string {
|
||||
const normalizedOwner = owner.trim();
|
||||
if (!normalizedOwner) {
|
||||
throw new Error(`registerContextEngineForOwner: owner must be a non-empty string, got ${JSON.stringify(owner)}`);
|
||||
}
|
||||
return normalizedOwner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a context engine implementation under the given id.
|
||||
* Register a context engine implementation under an explicit trusted owner.
|
||||
*/
|
||||
export function registerContextEngineForOwner(
|
||||
id: string,
|
||||
factory: ContextEngineFactory,
|
||||
owner: string,
|
||||
opts?: RegisterContextEngineForOwnerOptions,
|
||||
): ContextEngineRegistrationResult {
|
||||
const normalizedOwner = requireContextEngineOwner(owner);
|
||||
const registry = getContextEngineRegistryState().engines;
|
||||
const existing = registry.get(id);
|
||||
if (
|
||||
id === defaultSlotIdForKey("contextEngine") &&
|
||||
normalizedOwner !== CORE_CONTEXT_ENGINE_OWNER
|
||||
) {
|
||||
return { ok: false, existingOwner: CORE_CONTEXT_ENGINE_OWNER };
|
||||
}
|
||||
if (existing && existing.owner !== normalizedOwner) {
|
||||
return { ok: false, existingOwner: existing.owner };
|
||||
}
|
||||
if (existing && opts?.allowSameOwnerRefresh !== true) {
|
||||
return { ok: false, existingOwner: existing.owner };
|
||||
}
|
||||
registry.set(id, { factory, owner: normalizedOwner });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Public SDK entry point for third-party registrations.
|
||||
*
|
||||
* This path is intentionally unprivileged: it cannot claim core-owned ids and
|
||||
* it cannot safely refresh an existing registration because the caller's
|
||||
* identity is not authenticated.
|
||||
*/
|
||||
export function registerContextEngine(
|
||||
id: string,
|
||||
factory: ContextEngineFactory,
|
||||
opts?: { owner?: string },
|
||||
): ContextEngineRegistrationResult {
|
||||
const owner = opts?.owner?.trim() || "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 registerContextEngineForOwner(id, factory, PUBLIC_CONTEXT_ENGINE_OWNER);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -4,12 +4,17 @@ import {
|
||||
collectBundledExtensionManifestErrors,
|
||||
collectBundledExtensionRootDependencyGapErrors,
|
||||
collectForbiddenPackPaths,
|
||||
collectPackUnpackedSizeErrors,
|
||||
} from "../scripts/release-check.ts";
|
||||
|
||||
function makeItem(shortVersion: string, sparkleVersion: string): string {
|
||||
return `<item><title>${shortVersion}</title><sparkle:shortVersionString>${shortVersion}</sparkle:shortVersionString><sparkle:version>${sparkleVersion}</sparkle:version></item>`;
|
||||
}
|
||||
|
||||
function makePackResult(filename: string, unpackedSize: number) {
|
||||
return { filename, unpackedSize };
|
||||
}
|
||||
|
||||
describe("collectAppcastSparkleVersionErrors", () => {
|
||||
it("accepts legacy 9-digit calver builds before lane-floor cutover", () => {
|
||||
const xml = `<rss><channel>${makeItem("2026.2.26", "202602260")}</channel></rss>`;
|
||||
@ -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.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user