Merge branch 'main' into improve/edge-tts-followup
This commit is contained in:
commit
37e71c0176
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@ -0,0 +1 @@
|
||||
docs/.generated/
|
||||
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,8 @@ 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.
|
||||
- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs.
|
||||
|
||||
### Fixes
|
||||
|
||||
@ -30,6 +32,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup.
|
||||
- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc.
|
||||
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc.
|
||||
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.
|
||||
@ -62,6 +65,11 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc.
|
||||
- CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc.
|
||||
- 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.
|
||||
- Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras.
|
||||
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
@ -97,6 +105,7 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered `system.run` requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens.
|
||||
- Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.
|
||||
- Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images.
|
||||
- Commands/onboarding: split static auth-choice help from the plugin-backed onboarding catalog so `openclaw onboard` registration no longer pulls provider-wizard imports just to describe `--auth-choice`. (#47545) Thanks @vincentkoc.
|
||||
- Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups.
|
||||
- Windows/gateway stop: resolve Startup-folder fallback listeners from the installed `gateway.cmd` port, so `openclaw gateway stop` now actually kills fallback-launched gateway processes before restart.
|
||||
- Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -21,6 +21,7 @@ openclaw update wizard
|
||||
openclaw update --channel beta
|
||||
openclaw update --channel dev
|
||||
openclaw update --tag beta
|
||||
openclaw update --tag main
|
||||
openclaw update --dry-run
|
||||
openclaw update --no-restart
|
||||
openclaw update --json
|
||||
@ -31,7 +32,7 @@ openclaw --update
|
||||
|
||||
- `--no-restart`: skip restarting the Gateway service after a successful update.
|
||||
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||
- `--tag <dist-tag|version|spec>`: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`.
|
||||
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -102,6 +102,16 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Want the current GitHub `main` head with a package-manager install?
|
||||
|
||||
```bash
|
||||
npm install -g github:openclaw/openclaw#main
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm add -g github:openclaw/openclaw#main
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="From source" icon="github">
|
||||
|
||||
@ -116,6 +116,11 @@ The script exits with code `2` for invalid method selection or invalid `--instal
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="GitHub main via npm">
|
||||
```bash
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --version main
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Dry run">
|
||||
```bash
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --dry-run
|
||||
@ -126,39 +131,39 @@ The script exits with code `2` for invalid method selection or invalid `--instal
|
||||
<AccordionGroup>
|
||||
<Accordion title="Flags reference">
|
||||
|
||||
| Flag | Description |
|
||||
| ------------------------------- | ---------------------------------------------------------- |
|
||||
| `--install-method npm\|git` | Choose install method (default: `npm`). Alias: `--method` |
|
||||
| `--npm` | Shortcut for npm method |
|
||||
| `--git` | Shortcut for git method. Alias: `--github` |
|
||||
| `--version <version\|dist-tag>` | npm version or dist-tag (default: `latest`) |
|
||||
| `--beta` | Use beta dist-tag if available, else fallback to `latest` |
|
||||
| `--git-dir <path>` | Checkout directory (default: `~/openclaw`). Alias: `--dir` |
|
||||
| `--no-git-update` | Skip `git pull` for existing checkout |
|
||||
| `--no-prompt` | Disable prompts |
|
||||
| `--no-onboard` | Skip onboarding |
|
||||
| `--onboard` | Enable onboarding |
|
||||
| `--dry-run` | Print actions without applying changes |
|
||||
| `--verbose` | Enable debug output (`set -x`, npm notice-level logs) |
|
||||
| `--help` | Show usage (`-h`) |
|
||||
| Flag | Description |
|
||||
| ------------------------------------- | ---------------------------------------------------------- |
|
||||
| `--install-method npm\|git` | Choose install method (default: `npm`). Alias: `--method` |
|
||||
| `--npm` | Shortcut for npm method |
|
||||
| `--git` | Shortcut for git method. Alias: `--github` |
|
||||
| `--version <version\|dist-tag\|spec>` | npm version, dist-tag, or package spec (default: `latest`) |
|
||||
| `--beta` | Use beta dist-tag if available, else fallback to `latest` |
|
||||
| `--git-dir <path>` | Checkout directory (default: `~/openclaw`). Alias: `--dir` |
|
||||
| `--no-git-update` | Skip `git pull` for existing checkout |
|
||||
| `--no-prompt` | Disable prompts |
|
||||
| `--no-onboard` | Skip onboarding |
|
||||
| `--onboard` | Enable onboarding |
|
||||
| `--dry-run` | Print actions without applying changes |
|
||||
| `--verbose` | Enable debug output (`set -x`, npm notice-level logs) |
|
||||
| `--help` | Show usage (`-h`) |
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Environment variables reference">
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------------- | --------------------------------------------- |
|
||||
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
|
||||
| `OPENCLAW_VERSION=latest\|next\|<semver>` | npm version or dist-tag |
|
||||
| `OPENCLAW_BETA=0\|1` | Use beta if available |
|
||||
| `OPENCLAW_GIT_DIR=<path>` | Checkout directory |
|
||||
| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
|
||||
| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
|
||||
| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
|
||||
| `OPENCLAW_DRY_RUN=1` | Dry run mode |
|
||||
| `OPENCLAW_VERBOSE=1` | Debug mode |
|
||||
| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
|
||||
| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
|
||||
| Variable | Description |
|
||||
| ------------------------------------------------------- | --------------------------------------------- |
|
||||
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
|
||||
| `OPENCLAW_VERSION=latest\|next\|main\|<semver>\|<spec>` | npm version, dist-tag, or package spec |
|
||||
| `OPENCLAW_BETA=0\|1` | Use beta if available |
|
||||
| `OPENCLAW_GIT_DIR=<path>` | Checkout directory |
|
||||
| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
|
||||
| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
|
||||
| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
|
||||
| `OPENCLAW_DRY_RUN=1` | Dry run mode |
|
||||
| `OPENCLAW_VERBOSE=1` | Debug mode |
|
||||
| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
|
||||
| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@ -276,6 +281,11 @@ Designed for environments where you want everything under a local prefix (defaul
|
||||
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="GitHub main via npm">
|
||||
```powershell
|
||||
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -Tag main
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Custom git directory">
|
||||
```powershell
|
||||
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git -GitDir "C:\openclaw"
|
||||
@ -299,14 +309,14 @@ Designed for environments where you want everything under a local prefix (defaul
|
||||
<AccordionGroup>
|
||||
<Accordion title="Flags reference">
|
||||
|
||||
| Flag | Description |
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| `-InstallMethod npm\|git` | Install method (default: `npm`) |
|
||||
| `-Tag <tag>` | npm dist-tag (default: `latest`) |
|
||||
| `-GitDir <path>` | Checkout directory (default: `%USERPROFILE%\openclaw`) |
|
||||
| `-NoOnboard` | Skip onboarding |
|
||||
| `-NoGitUpdate` | Skip `git pull` |
|
||||
| `-DryRun` | Print actions only |
|
||||
| Flag | Description |
|
||||
| --------------------------- | ---------------------------------------------------------- |
|
||||
| `-InstallMethod npm\|git` | Install method (default: `npm`) |
|
||||
| `-Tag <tag\|version\|spec>` | npm dist-tag, version, or package spec (default: `latest`) |
|
||||
| `-GitDir <path>` | Checkout directory (default: `%USERPROFILE%\openclaw`) |
|
||||
| `-NoOnboard` | Skip onboarding |
|
||||
| `-NoGitUpdate` | Skip `git pull` |
|
||||
| `-DryRun` | Print actions only |
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@ -65,7 +65,25 @@ openclaw update --channel dev
|
||||
openclaw update --channel stable
|
||||
```
|
||||
|
||||
Use `--tag <dist-tag|version>` for a one-off install tag/version.
|
||||
Use `--tag <dist-tag|version|spec>` for a one-off package target override.
|
||||
|
||||
For the current GitHub `main` head via a package-manager install:
|
||||
|
||||
```bash
|
||||
openclaw update --tag main
|
||||
```
|
||||
|
||||
Manual equivalents:
|
||||
|
||||
```bash
|
||||
npm i -g github:openclaw/openclaw#main
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm add -g github:openclaw/openclaw#main
|
||||
```
|
||||
|
||||
You can also pass an explicit package spec to `--tag` for one-off updates (for example a GitHub ref or tarball URL).
|
||||
|
||||
See [Development channels](/install/development-channels) for channel semantics and release notes.
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,13 +1,44 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACPX_BUNDLED_BIN,
|
||||
ACPX_PINNED_VERSION,
|
||||
createAcpxPluginConfigSchema,
|
||||
resolveAcpxPluginRoot,
|
||||
resolveAcpxPluginConfig,
|
||||
} from "./config.js";
|
||||
|
||||
describe("acpx plugin config parsing", () => {
|
||||
it("resolves source-layout plugin root from a file under src", () => {
|
||||
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-source-"));
|
||||
try {
|
||||
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||
|
||||
const moduleUrl = pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href;
|
||||
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot);
|
||||
} finally {
|
||||
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves bundled-layout plugin root from the dist entry file", () => {
|
||||
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-dist-"));
|
||||
try {
|
||||
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||
|
||||
const moduleUrl = pathToFileURL(path.join(pluginRoot, "index.js")).href;
|
||||
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot);
|
||||
} finally {
|
||||
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves bundled acpx with pinned version by default", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/acpx";
|
||||
@ -11,7 +12,27 @@ export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_PO
|
||||
export const ACPX_PINNED_VERSION = "0.1.16";
|
||||
export const ACPX_VERSION_ANY = "any";
|
||||
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
|
||||
export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
|
||||
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
// Bundled entries live at the plugin root while source files still live under src/.
|
||||
if (
|
||||
fs.existsSync(path.join(cursor, "openclaw.plugin.json")) &&
|
||||
fs.existsSync(path.join(cursor, "package.json"))
|
||||
) {
|
||||
return cursor;
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
|
||||
}
|
||||
|
||||
export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot();
|
||||
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
|
||||
export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string {
|
||||
return `npm install --omit=dev --no-save acpx@${version}`;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../../src/agents/pi-embedded-runner.js", () => {
|
||||
vi.mock("openclaw/extension-api", () => {
|
||||
return {
|
||||
runEmbeddedPiAgent: vi.fn(async () => ({
|
||||
meta: { startedAt: Date.now() },
|
||||
@ -9,7 +9,7 @@ vi.mock("../../../src/agents/pi-embedded-runner.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { runEmbeddedPiAgent } from "../../../src/agents/pi-embedded-runner.js";
|
||||
import { runEmbeddedPiAgent } from "openclaw/extension-api";
|
||||
import { createLlmTaskTool } from "./llm-task-tool.js";
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
|
||||
@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import Ajv from "ajv";
|
||||
import { runEmbeddedPiAgent } from "openclaw/extension-api";
|
||||
import {
|
||||
formatThinkingLevels,
|
||||
formatXHighModelHint,
|
||||
@ -9,39 +10,8 @@ import {
|
||||
resolvePreferredOpenClawTmpDir,
|
||||
supportsXHighThinking,
|
||||
} from "openclaw/plugin-sdk/llm-task";
|
||||
// NOTE: This extension is intended to be bundled with OpenClaw.
|
||||
// When running from source (tests/dev), OpenClaw internals live under src/.
|
||||
// When running from a built install, internals live under dist/ (no src/ tree).
|
||||
// So we resolve internal imports dynamically with src-first, dist-fallback.
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/llm-task";
|
||||
|
||||
type RunEmbeddedPiAgentFn = (params: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
|
||||
// Source checkout (tests/dev)
|
||||
try {
|
||||
const mod = await import("../../../src/agents/pi-embedded-runner.js");
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
if (typeof (mod as any).runEmbeddedPiAgent === "function") {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
return (mod as any).runEmbeddedPiAgent;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Bundled install (built)
|
||||
// NOTE: there is no src/ tree in a packaged install. Prefer a stable internal entrypoint.
|
||||
const distExtensionApi = "../../../dist/extensionAPI.js";
|
||||
const mod = (await import(distExtensionApi)) as { runEmbeddedPiAgent?: unknown };
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const fn = (mod as any).runEmbeddedPiAgent;
|
||||
if (typeof fn !== "function") {
|
||||
throw new Error("Internal error: runEmbeddedPiAgent not available");
|
||||
}
|
||||
return fn as RunEmbeddedPiAgentFn;
|
||||
}
|
||||
|
||||
function stripCodeFences(s: string): string {
|
||||
const trimmed = s.trim();
|
||||
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||
@ -209,8 +179,6 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
|
||||
const sessionId = `llm-task-${Date.now()}`;
|
||||
const sessionFile = path.join(tmpDir, "session.json");
|
||||
|
||||
const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent();
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId,
|
||||
sessionFile,
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectAllowlistProviderGroupPolicyWarnings,
|
||||
collectOpenGroupPolicyRouteAllowlistWarnings,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectAllowlistProviderGroupPolicyWarnings,
|
||||
collectOpenGroupPolicyRouteAllowlistWarnings,
|
||||
createActionGate,
|
||||
createWhatsAppOutboundBase,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
|
||||
import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
|
||||
|
||||
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized");
|
||||
|
||||
10
package.json
10
package.json
@ -212,6 +212,7 @@
|
||||
"types": "./dist/plugin-sdk/keyed-async-queue.d.ts",
|
||||
"default": "./dist/plugin-sdk/keyed-async-queue.js"
|
||||
},
|
||||
"./extension-api": "./dist/extensionAPI.js",
|
||||
"./cli-entry": "./openclaw.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
@ -224,13 +225,13 @@
|
||||
"android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.app/.MainActivity",
|
||||
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
|
||||
"android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts",
|
||||
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
|
||||
"build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
|
||||
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
|
||||
"build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
|
||||
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true",
|
||||
"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",
|
||||
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.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",
|
||||
@ -245,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();
|
||||
3
scripts/copy-bundled-plugin-metadata.d.mts
Normal file
3
scripts/copy-bundled-plugin-metadata.d.mts
Normal file
@ -0,0 +1,3 @@
|
||||
export function rewritePackageExtensions(entries: unknown): string[] | undefined;
|
||||
|
||||
export function copyBundledPluginMetadata(params?: { repoRoot?: string }): void;
|
||||
129
scripts/copy-bundled-plugin-metadata.mjs
Normal file
129
scripts/copy-bundled-plugin-metadata.mjs
Normal file
@ -0,0 +1,129 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { removeFileIfExists, writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
|
||||
|
||||
export function rewritePackageExtensions(entries) {
|
||||
if (!Array.isArray(entries)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
||||
.map((entry) => {
|
||||
const normalized = entry.replace(/^\.\//, "");
|
||||
const rewritten = normalized.replace(/\.[^.]+$/u, ".js");
|
||||
return `./${rewritten}`;
|
||||
});
|
||||
}
|
||||
|
||||
function ensurePathInsideRoot(rootDir, rawPath) {
|
||||
const resolved = path.resolve(rootDir, rawPath);
|
||||
const relative = path.relative(rootDir, resolved);
|
||||
if (
|
||||
relative === "" ||
|
||||
relative === "." ||
|
||||
(!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
|
||||
) {
|
||||
return resolved;
|
||||
}
|
||||
throw new Error(`path escapes plugin root: ${rawPath}`);
|
||||
}
|
||||
|
||||
function copyDeclaredPluginSkillPaths(params) {
|
||||
const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : [];
|
||||
const copiedSkills = [];
|
||||
for (const raw of skills) {
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
const normalized = raw.replace(/^\.\//u, "");
|
||||
const sourcePath = ensurePathInsideRoot(params.pluginDir, raw);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
// Some Docker/lightweight builds intentionally omit optional plugin-local
|
||||
// dependencies. Only advertise skill paths that were actually bundled.
|
||||
console.warn(
|
||||
`[bundled-plugin-metadata] skipping missing skill path ${sourcePath} (plugin ${params.manifest.id ?? path.basename(params.pluginDir)})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const targetPath = ensurePathInsideRoot(params.distPluginDir, normalized);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.cpSync(sourcePath, targetPath, {
|
||||
dereference: true,
|
||||
force: true,
|
||||
recursive: true,
|
||||
});
|
||||
copiedSkills.push(raw);
|
||||
}
|
||||
return copiedSkills;
|
||||
}
|
||||
|
||||
export function copyBundledPluginMetadata(params = {}) {
|
||||
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
||||
const extensionsRoot = path.join(repoRoot, "extensions");
|
||||
const distExtensionsRoot = path.join(repoRoot, "dist", "extensions");
|
||||
if (!fs.existsSync(extensionsRoot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourcePluginDirs = new Set();
|
||||
|
||||
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
|
||||
if (!dirent.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
sourcePluginDirs.add(dirent.name);
|
||||
|
||||
const pluginDir = path.join(extensionsRoot, dirent.name);
|
||||
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
|
||||
const distPluginDir = path.join(distExtensionsRoot, dirent.name);
|
||||
const distManifestPath = path.join(distPluginDir, "openclaw.plugin.json");
|
||||
const distPackageJsonPath = path.join(distPluginDir, "package.json");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
removeFileIfExists(distManifestPath);
|
||||
removeFileIfExists(distPackageJsonPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const copiedSkills = copyDeclaredPluginSkillPaths({ manifest, pluginDir, distPluginDir });
|
||||
const bundledManifest = Array.isArray(manifest.skills)
|
||||
? { ...manifest, skills: copiedSkills }
|
||||
: manifest;
|
||||
writeTextFileIfChanged(distManifestPath, `${JSON.stringify(bundledManifest, null, 2)}\n`);
|
||||
|
||||
const packageJsonPath = path.join(pluginDir, "package.json");
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
removeFileIfExists(distPackageJsonPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
if (packageJson.openclaw && "extensions" in packageJson.openclaw) {
|
||||
packageJson.openclaw = {
|
||||
...packageJson.openclaw,
|
||||
extensions: rewritePackageExtensions(packageJson.openclaw.extensions),
|
||||
};
|
||||
}
|
||||
|
||||
writeTextFileIfChanged(distPackageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(distExtensionsRoot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) {
|
||||
if (!dirent.isDirectory() || sourcePluginDirs.has(dirent.name)) {
|
||||
continue;
|
||||
}
|
||||
const distPluginDir = path.join(distExtensionsRoot, dirent.name);
|
||||
removeFileIfExists(path.join(distPluginDir, "openclaw.plugin.json"));
|
||||
removeFileIfExists(path.join(distPluginDir, "package.json"));
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
copyBundledPluginMetadata();
|
||||
}
|
||||
@ -1,10 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
|
||||
|
||||
import { copyFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
export function copyPluginSdkRootAlias(params = {}) {
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const source = resolve(cwd, "src/plugin-sdk/root-alias.cjs");
|
||||
const target = resolve(cwd, "dist/plugin-sdk/root-alias.cjs");
|
||||
|
||||
const source = resolve("src/plugin-sdk/root-alias.cjs");
|
||||
const target = resolve("dist/plugin-sdk/root-alias.cjs");
|
||||
writeTextFileIfChanged(target, readFileSync(source, "utf8"));
|
||||
}
|
||||
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
copyFileSync(source, target);
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
copyPluginSdkRootAlias();
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -200,13 +200,15 @@ function Ensure-Git {
|
||||
}
|
||||
|
||||
function Install-OpenClawNpm {
|
||||
param([string]$Version = "latest")
|
||||
param([string]$Target = "latest")
|
||||
|
||||
$installSpec = Resolve-PackageInstallSpec -Target $Target
|
||||
|
||||
Write-Host "Installing OpenClaw (openclaw@$Version)..." -Level info
|
||||
Write-Host "Installing OpenClaw ($installSpec)..." -Level info
|
||||
|
||||
try {
|
||||
# Use -ExecutionPolicy Bypass to handle restricted execution policy
|
||||
npm install -g openclaw@$Version --no-fund --no-audit 2>&1
|
||||
npm install -g $installSpec --no-fund --no-audit 2>&1
|
||||
Write-Host "OpenClaw installed" -Level success
|
||||
return $true
|
||||
} catch {
|
||||
@ -257,6 +259,34 @@ node "%~dp0..\openclaw\dist\entry.js" %*
|
||||
return $true
|
||||
}
|
||||
|
||||
function Test-ExplicitPackageInstallSpec {
|
||||
param([string]$Target)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Target)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return $Target.Contains("://") -or
|
||||
$Target.Contains("#") -or
|
||||
$Target -match '^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm):'
|
||||
}
|
||||
|
||||
function Resolve-PackageInstallSpec {
|
||||
param([string]$Target = "latest")
|
||||
|
||||
$trimmed = $Target.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
||||
return "openclaw@latest"
|
||||
}
|
||||
if ($trimmed.ToLowerInvariant() -eq "main") {
|
||||
return "github:openclaw/openclaw#main"
|
||||
}
|
||||
if (Test-ExplicitPackageInstallSpec -Target $trimmed) {
|
||||
return $trimmed
|
||||
}
|
||||
return "openclaw@$trimmed"
|
||||
}
|
||||
|
||||
function Add-ToPath {
|
||||
param([string]$Path)
|
||||
|
||||
@ -301,9 +331,9 @@ function Main {
|
||||
}
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host "[DRY RUN] Would install OpenClaw via npm (tag: $Tag)" -Level info
|
||||
Write-Host "[DRY RUN] Would install OpenClaw via npm ($((Resolve-PackageInstallSpec -Target $Tag)))" -Level info
|
||||
} else {
|
||||
if (!(Install-OpenClawNpm -Version $Tag)) {
|
||||
if (!(Install-OpenClawNpm -Target $Tag)) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
@ -1011,7 +1011,7 @@ Options:
|
||||
--install-method, --method npm|git Install via npm (default) or from a git checkout
|
||||
--npm Shortcut for --install-method npm
|
||||
--git, --github Shortcut for --install-method git
|
||||
--version <version|dist-tag> npm install: version (default: latest)
|
||||
--version <version|dist-tag|spec> npm install target (default: latest; use "main" for GitHub main)
|
||||
--beta Use beta if available, else latest
|
||||
--git-dir, --dir <path> Checkout directory (default: ~/openclaw)
|
||||
--no-git-update Skip git pull for existing checkout
|
||||
@ -1024,7 +1024,7 @@ Options:
|
||||
|
||||
Environment variables:
|
||||
OPENCLAW_INSTALL_METHOD=git|npm
|
||||
OPENCLAW_VERSION=latest|next|<semver>
|
||||
OPENCLAW_VERSION=latest|next|main|<semver>|<spec>
|
||||
OPENCLAW_BETA=0|1
|
||||
OPENCLAW_GIT_DIR=...
|
||||
OPENCLAW_GIT_UPDATE=0|1
|
||||
@ -1040,6 +1040,7 @@ Examples:
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard --verify
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --version main
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git --no-onboard
|
||||
EOF
|
||||
}
|
||||
@ -1963,6 +1964,43 @@ resolve_beta_version() {
|
||||
echo "$beta"
|
||||
}
|
||||
|
||||
is_explicit_package_install_spec() {
|
||||
local value="${1:-}"
|
||||
[[ "$value" == *"://"* || "$value" == *"#"* || "$value" =~ ^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm): ]]
|
||||
}
|
||||
|
||||
can_resolve_registry_package_version() {
|
||||
local value="${1:-}"
|
||||
if [[ -z "$value" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "${value,,}" == "main" ]]; then
|
||||
return 1
|
||||
fi
|
||||
if is_explicit_package_install_spec "$value"; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
resolve_package_install_spec() {
|
||||
local package_name="$1"
|
||||
local value="$2"
|
||||
if [[ "${value,,}" == "main" ]]; then
|
||||
echo "github:openclaw/openclaw#main"
|
||||
return 0
|
||||
fi
|
||||
if is_explicit_package_install_spec "$value"; then
|
||||
echo "$value"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$value" == "latest" ]]; then
|
||||
echo "${package_name}@latest"
|
||||
return 0
|
||||
fi
|
||||
echo "${package_name}@${value}"
|
||||
}
|
||||
|
||||
install_openclaw() {
|
||||
local package_name="openclaw"
|
||||
if [[ "$USE_BETA" == "1" ]]; then
|
||||
@ -1983,18 +2021,16 @@ install_openclaw() {
|
||||
fi
|
||||
|
||||
local resolved_version=""
|
||||
resolved_version="$(npm view "${package_name}@${OPENCLAW_VERSION}" version 2>/dev/null || true)"
|
||||
if can_resolve_registry_package_version "${OPENCLAW_VERSION}"; then
|
||||
resolved_version="$(npm view "${package_name}@${OPENCLAW_VERSION}" version 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -n "$resolved_version" ]]; then
|
||||
ui_info "Installing OpenClaw v${resolved_version}"
|
||||
else
|
||||
ui_info "Installing OpenClaw (${OPENCLAW_VERSION})"
|
||||
fi
|
||||
local install_spec=""
|
||||
if [[ "${OPENCLAW_VERSION}" == "latest" ]]; then
|
||||
install_spec="${package_name}@latest"
|
||||
else
|
||||
install_spec="${package_name}@${OPENCLAW_VERSION}"
|
||||
fi
|
||||
install_spec="$(resolve_package_install_spec "${package_name}" "${OPENCLAW_VERSION}")"
|
||||
|
||||
if ! install_openclaw_npm "${install_spec}"; then
|
||||
ui_warn "npm install failed; retrying"
|
||||
|
||||
@ -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?: (
|
||||
|
||||
@ -4,11 +4,68 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { runRuntimePostBuild } from "./runtime-postbuild.mjs";
|
||||
|
||||
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 +75,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 +142,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 +196,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) => {
|
||||
@ -193,6 +276,19 @@ const runOpenClaw = async (deps) => {
|
||||
return res.exitCode ?? 1;
|
||||
};
|
||||
|
||||
const syncRuntimeArtifacts = (deps) => {
|
||||
try {
|
||||
runRuntimePostBuild({ cwd: deps.cwd });
|
||||
} catch (error) {
|
||||
logRunner(
|
||||
`Failed to write runtime build artifacts: ${error?.message ?? "unknown error"}`,
|
||||
deps,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const writeBuildStamp = (deps) => {
|
||||
try {
|
||||
deps.fs.mkdirSync(deps.distRoot, { recursive: true });
|
||||
@ -223,10 +319,16 @@ 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)) {
|
||||
if (!syncRuntimeArtifacts(deps)) {
|
||||
return 1;
|
||||
}
|
||||
return await runOpenClaw(deps);
|
||||
}
|
||||
|
||||
@ -249,6 +351,9 @@ export async function runNodeMain(params = {}) {
|
||||
if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) {
|
||||
return buildRes.exitCode;
|
||||
}
|
||||
if (!syncRuntimeArtifacts(deps)) {
|
||||
return 1;
|
||||
}
|
||||
writeBuildStamp(deps);
|
||||
return await runOpenClaw(deps);
|
||||
}
|
||||
|
||||
26
scripts/runtime-postbuild-shared.mjs
Normal file
26
scripts/runtime-postbuild-shared.mjs
Normal file
@ -0,0 +1,26 @@
|
||||
import fs from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
export function writeTextFileIfChanged(filePath, contents) {
|
||||
const next = String(contents);
|
||||
try {
|
||||
const current = fs.readFileSync(filePath, "utf8");
|
||||
if (current === next) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// Write the file when it does not exist or cannot be read.
|
||||
}
|
||||
fs.mkdirSync(dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, next, "utf8");
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeFileIfExists(filePath) {
|
||||
try {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
12
scripts/runtime-postbuild.mjs
Normal file
12
scripts/runtime-postbuild.mjs
Normal file
@ -0,0 +1,12 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs";
|
||||
import { copyPluginSdkRootAlias } from "./copy-plugin-sdk-root-alias.mjs";
|
||||
|
||||
export function runRuntimePostBuild(params = {}) {
|
||||
copyPluginSdkRootAlias(params);
|
||||
copyBundledPluginMetadata(params);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
runRuntimePostBuild();
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -295,6 +295,17 @@ describe("normalizeModelCompat", () => {
|
||||
expect(supportsUsageInStreaming(normalized)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves explicit supportsUsageInStreaming false on non-native endpoints", () => {
|
||||
const model = {
|
||||
...baseModel(),
|
||||
provider: "custom-cpa",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
compat: { supportsUsageInStreaming: false },
|
||||
};
|
||||
const normalized = normalizeModelCompat(model);
|
||||
expect(supportsUsageInStreaming(normalized)).toBe(false);
|
||||
});
|
||||
|
||||
it("still forces flags off when not explicitly set by user", () => {
|
||||
const model = {
|
||||
...baseModel(),
|
||||
@ -348,6 +359,32 @@ describe("normalizeModelCompat", () => {
|
||||
expect(supportsUsageInStreaming(normalized)).toBe(false);
|
||||
expect(supportsStrictMode(normalized)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves fully explicit non-native compat untouched", () => {
|
||||
const model = baseModel();
|
||||
model.baseUrl = "https://proxy.example.com/v1";
|
||||
model.compat = {
|
||||
supportsDeveloperRole: false,
|
||||
supportsUsageInStreaming: true,
|
||||
supportsStrictMode: true,
|
||||
};
|
||||
const normalized = normalizeModelCompat(model);
|
||||
expect(normalized).toBe(model);
|
||||
});
|
||||
|
||||
it("preserves explicit usage compat when developer role is explicitly enabled", () => {
|
||||
const model = baseModel();
|
||||
model.baseUrl = "https://proxy.example.com/v1";
|
||||
model.compat = {
|
||||
supportsDeveloperRole: true,
|
||||
supportsUsageInStreaming: true,
|
||||
supportsStrictMode: true,
|
||||
};
|
||||
const normalized = normalizeModelCompat(model);
|
||||
expect(supportsDeveloperRole(normalized)).toBe(true);
|
||||
expect(supportsUsageInStreaming(normalized)).toBe(true);
|
||||
expect(supportsStrictMode(normalized)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isModernModelRef", () => {
|
||||
|
||||
@ -66,11 +66,11 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
||||
return model;
|
||||
}
|
||||
const forcedDeveloperRole = compat?.supportsDeveloperRole === true;
|
||||
const forcedUsageStreaming = compat?.supportsUsageInStreaming === true;
|
||||
const hasStreamingUsageOverride = compat?.supportsUsageInStreaming !== undefined;
|
||||
const targetStrictMode = compat?.supportsStrictMode ?? false;
|
||||
if (
|
||||
compat?.supportsDeveloperRole !== undefined &&
|
||||
compat?.supportsUsageInStreaming !== undefined &&
|
||||
hasStreamingUsageOverride &&
|
||||
compat?.supportsStrictMode !== undefined
|
||||
) {
|
||||
return model;
|
||||
@ -83,7 +83,7 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
||||
? {
|
||||
...compat,
|
||||
supportsDeveloperRole: forcedDeveloperRole || false,
|
||||
supportsUsageInStreaming: forcedUsageStreaming || false,
|
||||
...(hasStreamingUsageOverride ? {} : { supportsUsageInStreaming: false }),
|
||||
supportsStrictMode: targetStrictMode,
|
||||
}
|
||||
: {
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
type ExistingProviderConfig,
|
||||
} from "./models-config.merge.js";
|
||||
import {
|
||||
applyNativeStreamingUsageCompat,
|
||||
enforceSourceManagedProviderSecrets,
|
||||
normalizeProviders,
|
||||
resolveImplicitProviders,
|
||||
@ -126,7 +127,8 @@ export async function planOpenClawModelsJson(params: {
|
||||
sourceSecretDefaults: params.sourceConfigForSecrets?.secrets?.defaults,
|
||||
secretRefManagedProviders,
|
||||
}) ?? mergedProviders;
|
||||
const nextContents = `${JSON.stringify({ providers: secretEnforcedProviders }, null, 2)}\n`;
|
||||
const finalProviders = applyNativeStreamingUsageCompat(secretEnforcedProviders);
|
||||
const nextContents = `${JSON.stringify({ providers: finalProviders }, null, 2)}\n`;
|
||||
|
||||
if (params.existingRaw === nextContents) {
|
||||
return { action: "noop" };
|
||||
|
||||
@ -1,32 +1,36 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
import { buildModelStudioProvider } from "./models-config.providers.js";
|
||||
|
||||
const modelStudioApiKeyEnv = ["MODELSTUDIO_API", "KEY"].join("_");
|
||||
import {
|
||||
applyNativeStreamingUsageCompat,
|
||||
buildModelStudioProvider,
|
||||
} from "./models-config.providers.js";
|
||||
|
||||
describe("Model Studio implicit provider", () => {
|
||||
it("should include modelstudio when MODELSTUDIO_API_KEY is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const modelStudioApiKey = "test-key"; // pragma: allowlist secret
|
||||
await withEnvAsync({ [modelStudioApiKeyEnv]: modelStudioApiKey }, async () => {
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.modelstudio).toBeDefined();
|
||||
expect(providers?.modelstudio?.apiKey).toBe("MODELSTUDIO_API_KEY");
|
||||
expect(providers?.modelstudio?.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1");
|
||||
it("should opt native Model Studio baseUrls into streaming usage", () => {
|
||||
const providers = applyNativeStreamingUsageCompat({
|
||||
modelstudio: buildModelStudioProvider(),
|
||||
});
|
||||
expect(providers?.modelstudio).toBeDefined();
|
||||
expect(providers?.modelstudio?.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1");
|
||||
expect(
|
||||
providers?.modelstudio?.models?.every(
|
||||
(model) => model.compat?.supportsUsageInStreaming === true,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should build the static Model Studio provider catalog", () => {
|
||||
const provider = buildModelStudioProvider();
|
||||
const modelIds = provider.models.map((model) => model.id);
|
||||
expect(provider.api).toBe("openai-completions");
|
||||
expect(provider.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1");
|
||||
expect(modelIds).toContain("qwen3.5-plus");
|
||||
expect(modelIds).toContain("qwen3-coder-plus");
|
||||
expect(modelIds).toContain("kimi-k2.5");
|
||||
it("should keep streaming usage opt-in disabled for custom Model Studio-compatible baseUrls", () => {
|
||||
const providers = applyNativeStreamingUsageCompat({
|
||||
modelstudio: {
|
||||
...buildModelStudioProvider(),
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
},
|
||||
});
|
||||
expect(providers?.modelstudio).toBeDefined();
|
||||
expect(providers?.modelstudio?.baseUrl).toBe("https://proxy.example.com/v1");
|
||||
expect(
|
||||
providers?.modelstudio?.models?.some(
|
||||
(model) => model.compat?.supportsUsageInStreaming === true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,7 +7,11 @@ import {
|
||||
MOONSHOT_CN_BASE_URL,
|
||||
} from "../commands/onboard-auth.models.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import {
|
||||
applyNativeStreamingUsageCompat,
|
||||
resolveImplicitProviders,
|
||||
} from "./models-config.providers.js";
|
||||
import { buildMoonshotProvider } from "./models-config.providers.static.js";
|
||||
|
||||
describe("moonshot implicit provider (#33637)", () => {
|
||||
it("uses explicit CN baseUrl when provided", async () => {
|
||||
@ -39,6 +43,31 @@ describe("moonshot implicit provider (#33637)", () => {
|
||||
expect(providers?.moonshot).toBeDefined();
|
||||
expect(providers?.moonshot?.baseUrl).toBe(MOONSHOT_CN_BASE_URL);
|
||||
expect(providers?.moonshot?.apiKey).toBeDefined();
|
||||
expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined();
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps streaming usage opt-in unset before the final compat pass", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const envSnapshot = captureEnv(["MOONSHOT_API_KEY"]);
|
||||
process.env.MOONSHOT_API_KEY = "sk-test-custom";
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
moonshot: {
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(providers?.moonshot).toBeDefined();
|
||||
expect(providers?.moonshot?.baseUrl).toBe("https://proxy.example.com/v1");
|
||||
expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined();
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
}
|
||||
@ -53,8 +82,32 @@ describe("moonshot implicit provider (#33637)", () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
expect(providers?.moonshot).toBeDefined();
|
||||
expect(providers?.moonshot?.baseUrl).toBe(MOONSHOT_AI_BASE_URL);
|
||||
expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined();
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("opts native Moonshot baseUrls into streaming usage only after the final compat pass", () => {
|
||||
const defaultProviders = applyNativeStreamingUsageCompat({
|
||||
moonshot: buildMoonshotProvider(),
|
||||
});
|
||||
expect(defaultProviders.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true);
|
||||
|
||||
const cnProviders = applyNativeStreamingUsageCompat({
|
||||
moonshot: {
|
||||
...buildMoonshotProvider(),
|
||||
baseUrl: MOONSHOT_CN_BASE_URL,
|
||||
},
|
||||
});
|
||||
expect(cnProviders.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true);
|
||||
|
||||
const customProviders = applyNativeStreamingUsageCompat({
|
||||
moonshot: {
|
||||
...buildMoonshotProvider(),
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
},
|
||||
});
|
||||
expect(customProviders.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -81,6 +81,15 @@ type SecretDefaults = {
|
||||
exec?: string;
|
||||
};
|
||||
|
||||
const MOONSHOT_NATIVE_BASE_URLS = new Set([
|
||||
"https://api.moonshot.ai/v1",
|
||||
"https://api.moonshot.cn/v1",
|
||||
]);
|
||||
const MODELSTUDIO_NATIVE_BASE_URLS = new Set([
|
||||
"https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
"https://coding.dashscope.aliyuncs.com/v1",
|
||||
]);
|
||||
|
||||
const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
|
||||
|
||||
function normalizeApiKeyConfig(value: string): string {
|
||||
@ -89,6 +98,65 @@ function normalizeApiKeyConfig(value: string): string {
|
||||
return match?.[1] ?? trimmed;
|
||||
}
|
||||
|
||||
function normalizeProviderBaseUrl(baseUrl: string | undefined): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
return url.toString().replace(/\/+$/, "").toLowerCase();
|
||||
} catch {
|
||||
return trimmed.replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function withStreamingUsageCompat(provider: ProviderConfig): ProviderConfig {
|
||||
if (!Array.isArray(provider.models) || provider.models.length === 0) {
|
||||
return provider;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const models = provider.models.map((model) => {
|
||||
if (model.compat?.supportsUsageInStreaming !== undefined) {
|
||||
return model;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...model,
|
||||
compat: {
|
||||
...model.compat,
|
||||
supportsUsageInStreaming: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return changed ? { ...provider, models } : provider;
|
||||
}
|
||||
|
||||
export function applyNativeStreamingUsageCompat(
|
||||
providers: Record<string, ProviderConfig>,
|
||||
): Record<string, ProviderConfig> {
|
||||
let changed = false;
|
||||
const nextProviders: Record<string, ProviderConfig> = {};
|
||||
|
||||
for (const [providerKey, provider] of Object.entries(providers)) {
|
||||
const normalizedBaseUrl = normalizeProviderBaseUrl(provider.baseUrl);
|
||||
const isNativeMoonshot =
|
||||
providerKey === "moonshot" && MOONSHOT_NATIVE_BASE_URLS.has(normalizedBaseUrl);
|
||||
const isNativeModelStudio =
|
||||
providerKey === "modelstudio" && MODELSTUDIO_NATIVE_BASE_URLS.has(normalizedBaseUrl);
|
||||
const nextProvider =
|
||||
isNativeMoonshot || isNativeModelStudio ? withStreamingUsageCompat(provider) : provider;
|
||||
nextProviders[providerKey] = nextProvider;
|
||||
changed ||= nextProvider !== provider;
|
||||
}
|
||||
|
||||
return changed ? nextProviders : providers;
|
||||
}
|
||||
|
||||
function resolveEnvApiKeyVarName(
|
||||
provider: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
@ -684,7 +752,16 @@ const SIMPLE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [
|
||||
apiKey,
|
||||
})),
|
||||
withApiKey("qianfan", async ({ apiKey }) => ({ ...buildQianfanProvider(), apiKey })),
|
||||
withApiKey("modelstudio", async ({ apiKey }) => ({ ...buildModelStudioProvider(), apiKey })),
|
||||
withApiKey("modelstudio", async ({ apiKey, explicitProvider }) => {
|
||||
const explicitBaseUrl = explicitProvider?.baseUrl;
|
||||
return {
|
||||
...buildModelStudioProvider(),
|
||||
...(typeof explicitBaseUrl === "string" && explicitBaseUrl.trim()
|
||||
? { baseUrl: explicitBaseUrl.trim() }
|
||||
: {}),
|
||||
apiKey,
|
||||
};
|
||||
}),
|
||||
withApiKey("openrouter", async ({ apiKey }) => ({ ...buildOpenrouterProvider(), apiKey })),
|
||||
withApiKey("nvidia", async ({ apiKey }) => ({ ...buildNvidiaProvider(), apiKey })),
|
||||
withApiKey("kilocode", async ({ apiKey }) => ({
|
||||
|
||||
52
src/cli/completion-cli.test.ts
Normal file
52
src/cli/completion-cli.test.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getCompletionScript } from "./completion-cli.js";
|
||||
|
||||
function createCompletionProgram(): Command {
|
||||
const program = new Command();
|
||||
program.name("openclaw");
|
||||
program.description("CLI root");
|
||||
program.option("-v, --verbose", "Verbose output");
|
||||
|
||||
const gateway = program.command("gateway").description("Gateway commands");
|
||||
gateway.option("--force", "Force the action");
|
||||
|
||||
gateway.command("status").description("Show gateway status").option("--json", "JSON output");
|
||||
gateway.command("restart").description("Restart gateway");
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("completion-cli", () => {
|
||||
it("generates zsh functions for nested subcommands", () => {
|
||||
const script = getCompletionScript("zsh", createCompletionProgram());
|
||||
|
||||
expect(script).toContain("_openclaw_gateway()");
|
||||
expect(script).toContain("(status) _openclaw_gateway_status ;;");
|
||||
expect(script).toContain("(restart) _openclaw_gateway_restart ;;");
|
||||
expect(script).toContain("--force[Force the action]");
|
||||
});
|
||||
|
||||
it("generates PowerShell command paths without the executable prefix", () => {
|
||||
const script = getCompletionScript("powershell", createCompletionProgram());
|
||||
|
||||
expect(script).toContain("if ($commandPath -eq 'gateway') {");
|
||||
expect(script).toContain("if ($commandPath -eq 'gateway status') {");
|
||||
expect(script).not.toContain("if ($commandPath -eq 'openclaw gateway') {");
|
||||
expect(script).toContain("$completions = @('status','restart','--force')");
|
||||
});
|
||||
|
||||
it("generates fish completions for root and nested command contexts", () => {
|
||||
const script = getCompletionScript("fish", createCompletionProgram());
|
||||
|
||||
expect(script).toContain(
|
||||
'complete -c openclaw -n "__fish_use_subcommand" -a "gateway" -d \'Gateway commands\'',
|
||||
);
|
||||
expect(script).toContain(
|
||||
'complete -c openclaw -n "__fish_seen_subcommand_from gateway" -a "status" -d \'Show gateway status\'',
|
||||
);
|
||||
expect(script).toContain(
|
||||
"complete -c openclaw -n \"__fish_seen_subcommand_from gateway\" -l force -d 'Force the action'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -69,7 +69,7 @@ export async function completionCacheExists(
|
||||
return pathExists(cachePath);
|
||||
}
|
||||
|
||||
function getCompletionScript(shell: CompletionShell, program: Command): string {
|
||||
export function getCompletionScript(shell: CompletionShell, program: Command): string {
|
||||
if (shell === "zsh") {
|
||||
return generateZshCompletion(program);
|
||||
}
|
||||
@ -442,17 +442,19 @@ function generateZshSubcmdList(cmd: Command): string {
|
||||
}
|
||||
|
||||
function generateZshSubcommands(program: Command, prefix: string): string {
|
||||
let script = "";
|
||||
for (const cmd of program.commands) {
|
||||
const cmdName = cmd.name();
|
||||
const funcName = `_${prefix}_${cmdName.replace(/-/g, "_")}`;
|
||||
const segments: string[] = [];
|
||||
|
||||
// Recurse first
|
||||
script += generateZshSubcommands(cmd, `${prefix}_${cmdName.replace(/-/g, "_")}`);
|
||||
const visit = (current: Command, currentPrefix: string) => {
|
||||
for (const cmd of current.commands) {
|
||||
const cmdName = cmd.name();
|
||||
const nextPrefix = `${currentPrefix}_${cmdName.replace(/-/g, "_")}`;
|
||||
const funcName = `_${nextPrefix}`;
|
||||
|
||||
const subCommands = cmd.commands;
|
||||
if (subCommands.length > 0) {
|
||||
script += `
|
||||
visit(cmd, nextPrefix);
|
||||
|
||||
const subCommands = cmd.commands;
|
||||
if (subCommands.length > 0) {
|
||||
segments.push(`
|
||||
${funcName}() {
|
||||
local -a commands
|
||||
local -a options
|
||||
@ -470,17 +472,21 @@ ${funcName}() {
|
||||
;;
|
||||
esac
|
||||
}
|
||||
`;
|
||||
} else {
|
||||
script += `
|
||||
`);
|
||||
continue;
|
||||
}
|
||||
|
||||
segments.push(`
|
||||
${funcName}() {
|
||||
_arguments -C \\
|
||||
${generateZshArgs(cmd)}
|
||||
}
|
||||
`;
|
||||
`);
|
||||
}
|
||||
}
|
||||
return script;
|
||||
};
|
||||
|
||||
visit(program, prefix);
|
||||
return segments.join("");
|
||||
}
|
||||
|
||||
function generateBashCompletion(program: Command): string {
|
||||
@ -528,38 +534,34 @@ function generateBashSubcommand(cmd: Command): string {
|
||||
|
||||
function generatePowerShellCompletion(program: Command): string {
|
||||
const rootCmd = program.name();
|
||||
const segments: string[] = [];
|
||||
|
||||
const visit = (cmd: Command, parents: string[]): string => {
|
||||
const cmdName = cmd.name();
|
||||
const fullPath = [...parents, cmdName].join(" ");
|
||||
|
||||
let script = "";
|
||||
const visit = (cmd: Command, pathSegments: string[]) => {
|
||||
const fullPath = pathSegments.join(" ");
|
||||
|
||||
// Command completion for this level
|
||||
const subCommands = cmd.commands.map((c) => c.name());
|
||||
const options = cmd.options.map((o) => o.flags.split(/[ ,|]+/)[0]); // Take first flag
|
||||
const allCompletions = [...subCommands, ...options].map((s) => `'${s}'`).join(",");
|
||||
|
||||
if (allCompletions.length > 0) {
|
||||
script += `
|
||||
if (fullPath.length > 0 && allCompletions.length > 0) {
|
||||
segments.push(`
|
||||
if ($commandPath -eq '${fullPath}') {
|
||||
$completions = @(${allCompletions})
|
||||
$completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
|
||||
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_)
|
||||
}
|
||||
}
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
// Recurse
|
||||
for (const sub of cmd.commands) {
|
||||
script += visit(sub, [...parents, cmdName]);
|
||||
visit(sub, [...pathSegments, sub.name()]);
|
||||
}
|
||||
|
||||
return script;
|
||||
};
|
||||
|
||||
const rootBody = visit(program, []);
|
||||
visit(program, []);
|
||||
const rootBody = segments.join("");
|
||||
|
||||
return `
|
||||
Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
|
||||
@ -593,65 +595,57 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
|
||||
|
||||
function generateFishCompletion(program: Command): string {
|
||||
const rootCmd = program.name();
|
||||
let script = "";
|
||||
const segments: string[] = [];
|
||||
|
||||
const visit = (cmd: Command, parents: string[]) => {
|
||||
const cmdName = cmd.name();
|
||||
const fullPath = [...parents];
|
||||
if (parents.length > 0) {
|
||||
fullPath.push(cmdName);
|
||||
} // Only push if not root, or consistent root handling
|
||||
|
||||
// Fish uses 'seen_subcommand_from' to determine context.
|
||||
// For root: complete -c openclaw -n "__fish_use_subcommand" -a "subcmd" -d "desc"
|
||||
|
||||
// Root logic
|
||||
if (parents.length === 0) {
|
||||
// Subcommands of root
|
||||
for (const sub of cmd.commands) {
|
||||
script += buildFishSubcommandCompletionLine({
|
||||
rootCmd,
|
||||
condition: "__fish_use_subcommand",
|
||||
name: sub.name(),
|
||||
description: sub.description(),
|
||||
});
|
||||
segments.push(
|
||||
buildFishSubcommandCompletionLine({
|
||||
rootCmd,
|
||||
condition: "__fish_use_subcommand",
|
||||
name: sub.name(),
|
||||
description: sub.description(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Options of root
|
||||
for (const opt of cmd.options) {
|
||||
script += buildFishOptionCompletionLine({
|
||||
rootCmd,
|
||||
condition: "__fish_use_subcommand",
|
||||
flags: opt.flags,
|
||||
description: opt.description,
|
||||
});
|
||||
segments.push(
|
||||
buildFishOptionCompletionLine({
|
||||
rootCmd,
|
||||
condition: "__fish_use_subcommand",
|
||||
flags: opt.flags,
|
||||
description: opt.description,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Nested commands
|
||||
// Logic: if seen subcommand matches parents...
|
||||
// But fish completion logic is simpler if we just say "if we haven't seen THIS command yet but seen parent"
|
||||
// Actually, a robust fish completion often requires defining a function to check current line.
|
||||
// For simplicity, we'll assume standard fish helper __fish_seen_subcommand_from.
|
||||
|
||||
// To properly scope to 'openclaw gateway' and not 'openclaw other gateway', we need to check the sequence.
|
||||
// A simplified approach:
|
||||
|
||||
// Subcommands
|
||||
for (const sub of cmd.commands) {
|
||||
script += buildFishSubcommandCompletionLine({
|
||||
rootCmd,
|
||||
condition: `__fish_seen_subcommand_from ${cmdName}`,
|
||||
name: sub.name(),
|
||||
description: sub.description(),
|
||||
});
|
||||
segments.push(
|
||||
buildFishSubcommandCompletionLine({
|
||||
rootCmd,
|
||||
condition: `__fish_seen_subcommand_from ${cmdName}`,
|
||||
name: sub.name(),
|
||||
description: sub.description(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Options
|
||||
for (const opt of cmd.options) {
|
||||
script += buildFishOptionCompletionLine({
|
||||
rootCmd,
|
||||
condition: `__fish_seen_subcommand_from ${cmdName}`,
|
||||
flags: opt.flags,
|
||||
description: opt.description,
|
||||
});
|
||||
segments.push(
|
||||
buildFishOptionCompletionLine({
|
||||
rootCmd,
|
||||
condition: `__fish_seen_subcommand_from ${cmdName}`,
|
||||
flags: opt.flags,
|
||||
description: opt.description,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -661,5 +655,5 @@ function generateFishCompletion(program: Command): string {
|
||||
};
|
||||
|
||||
visit(program, []);
|
||||
return script;
|
||||
return segments.join("");
|
||||
}
|
||||
|
||||
@ -9,8 +9,8 @@ const runtime = {
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("../../commands/auth-choice-options.js", () => ({
|
||||
formatAuthChoiceChoicesForCli: () => "token|oauth",
|
||||
vi.mock("../../commands/auth-choice-options.static.js", () => ({
|
||||
formatStaticAuthChoiceChoicesForCli: () => "token|oauth",
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/onboard-provider-auth-flags.js", () => ({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatAuthChoiceChoicesForCli } from "../../commands/auth-choice-options.js";
|
||||
import { formatStaticAuthChoiceChoicesForCli } from "../../commands/auth-choice-options.static.js";
|
||||
import type { GatewayDaemonRuntime } from "../../commands/daemon-runtime.js";
|
||||
import { ONBOARD_PROVIDER_AUTH_FLAGS } from "../../commands/onboard-provider-auth-flags.js";
|
||||
import type {
|
||||
@ -41,7 +41,7 @@ function resolveInstallDaemonFlag(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const AUTH_CHOICE_HELP = formatAuthChoiceChoicesForCli({
|
||||
const AUTH_CHOICE_HELP = formatStaticAuthChoiceChoicesForCli({
|
||||
includeLegacyAliases: true,
|
||||
includeSkip: true,
|
||||
});
|
||||
|
||||
@ -549,6 +549,48 @@ describe("update-cli", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("maps --tag main to the GitHub main package spec for package updates", async () => {
|
||||
const tempDir = createCaseDir("openclaw-update");
|
||||
mockPackageInstallStatus(tempDir);
|
||||
|
||||
await updateCommand({ yes: true, tag: "main" });
|
||||
|
||||
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
||||
expect(runCommandWithTimeout).toHaveBeenCalledWith(
|
||||
[
|
||||
"npm",
|
||||
"i",
|
||||
"-g",
|
||||
"github:openclaw/openclaw#main",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--loglevel=error",
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes explicit git package specs through for package updates", async () => {
|
||||
const tempDir = createCaseDir("openclaw-update");
|
||||
mockPackageInstallStatus(tempDir);
|
||||
|
||||
await updateCommand({ yes: true, tag: "github:openclaw/openclaw#main" });
|
||||
|
||||
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
||||
expect(runCommandWithTimeout).toHaveBeenCalledWith(
|
||||
[
|
||||
"npm",
|
||||
"i",
|
||||
"-g",
|
||||
"github:openclaw/openclaw#main",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--loglevel=error",
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("updateCommand outputs JSON when --json is set", async () => {
|
||||
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
||||
vi.mocked(defaultRuntime.log).mockClear();
|
||||
|
||||
@ -39,7 +39,10 @@ export function registerUpdateCli(program: Command) {
|
||||
.option("--no-restart", "Skip restarting the gateway service after a successful update")
|
||||
.option("--dry-run", "Preview update actions without making changes", false)
|
||||
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
|
||||
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
|
||||
.option(
|
||||
"--tag <dist-tag|version|spec>",
|
||||
"Override the package target for this update (dist-tag, version, or package spec)",
|
||||
)
|
||||
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
|
||||
.option("--yes", "Skip confirmation prompts (non-interactive)", false)
|
||||
.addHelpText("after", () => {
|
||||
@ -48,6 +51,7 @@ export function registerUpdateCli(program: Command) {
|
||||
["openclaw update --channel beta", "Switch to beta channel (git + npm)"],
|
||||
["openclaw update --channel dev", "Switch to dev channel (git + npm)"],
|
||||
["openclaw update --tag beta", "One-off update to a dist-tag or version"],
|
||||
["openclaw update --tag main", "One-off package install from GitHub main"],
|
||||
["openclaw update --dry-run", "Preview actions without changing anything"],
|
||||
["openclaw update --no-restart", "Update without restarting the service"],
|
||||
["openclaw update --json", "Output result as JSON"],
|
||||
@ -66,7 +70,7 @@ ${theme.heading("What this does:")}
|
||||
${theme.heading("Switch channels:")}
|
||||
- Use --channel stable|beta|dev to persist the update channel in config
|
||||
- Run openclaw update status to see the active channel and source
|
||||
- Use --tag <dist-tag|version> for a one-off npm update without persisting
|
||||
- Use --tag <dist-tag|version|spec> for a one-off package update without persisting
|
||||
|
||||
${theme.heading("Non-interactive:")}
|
||||
- Use --yes to accept downgrade prompts
|
||||
|
||||
@ -10,6 +10,7 @@ import { trimLogTail } from "../../infra/restart-sentinel.js";
|
||||
import { parseSemver } from "../../infra/runtime-guard.js";
|
||||
import { fetchNpmTagVersion } from "../../infra/update-check.js";
|
||||
import {
|
||||
canResolveRegistryVersionForPackageTarget,
|
||||
detectGlobalInstallManagerByPresence,
|
||||
detectGlobalInstallManagerForRoot,
|
||||
type CommandRunner,
|
||||
@ -77,6 +78,9 @@ export async function resolveTargetVersion(
|
||||
tag: string,
|
||||
timeoutMs?: number,
|
||||
): Promise<string | null> {
|
||||
if (!canResolveRegistryVersionForPackageTarget(tag)) {
|
||||
return null;
|
||||
}
|
||||
const direct = normalizeVersionTag(tag);
|
||||
if (direct) {
|
||||
return direct;
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
checkUpdateStatus,
|
||||
} from "../../infra/update-check.js";
|
||||
import {
|
||||
canResolveRegistryVersionForPackageTarget,
|
||||
createGlobalInstallEnv,
|
||||
cleanupGlobalRenameDirs,
|
||||
globalInstallArgs,
|
||||
@ -731,22 +732,31 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
let targetVersion: string | null = null;
|
||||
let downgradeRisk = false;
|
||||
let fallbackToLatest = false;
|
||||
let packageInstallSpec: string | null = null;
|
||||
|
||||
if (updateInstallKind !== "git") {
|
||||
currentVersion = switchToPackage ? null : await readPackageVersion(root);
|
||||
targetVersion = explicitTag
|
||||
? await resolveTargetVersion(tag, timeoutMs)
|
||||
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
|
||||
tag = resolved.tag;
|
||||
fallbackToLatest = channel === "beta" && resolved.tag === "latest";
|
||||
return resolved.version;
|
||||
});
|
||||
if (explicitTag) {
|
||||
targetVersion = await resolveTargetVersion(tag, timeoutMs);
|
||||
} else {
|
||||
targetVersion = await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
|
||||
tag = resolved.tag;
|
||||
fallbackToLatest = channel === "beta" && resolved.tag === "latest";
|
||||
return resolved.version;
|
||||
});
|
||||
}
|
||||
const cmp =
|
||||
currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null;
|
||||
downgradeRisk =
|
||||
canResolveRegistryVersionForPackageTarget(tag) &&
|
||||
!fallbackToLatest &&
|
||||
currentVersion != null &&
|
||||
(targetVersion == null || (cmp != null && cmp > 0));
|
||||
packageInstallSpec = resolveGlobalInstallSpec({
|
||||
packageName: DEFAULT_PACKAGE_NAME,
|
||||
tag,
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
@ -772,7 +782,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
} else if (updateInstallKind === "git") {
|
||||
actions.push(`Run git update flow on channel ${channel} (fetch/rebase/build/doctor)`);
|
||||
} else {
|
||||
actions.push(`Run global package manager update with spec openclaw@${tag}`);
|
||||
actions.push(`Run global package manager update with spec ${packageInstallSpec ?? tag}`);
|
||||
}
|
||||
actions.push("Run plugin update sync after core update");
|
||||
actions.push("Refresh shell completion cache (if needed)");
|
||||
@ -789,6 +799,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
if (fallbackToLatest) {
|
||||
notes.push("Beta channel resolves to latest for this run (fallback).");
|
||||
}
|
||||
if (explicitTag && !canResolveRegistryVersionForPackageTarget(tag)) {
|
||||
notes.push("Non-registry package specs skip npm version lookup and downgrade previews.");
|
||||
}
|
||||
|
||||
printDryRunPreview(
|
||||
{
|
||||
@ -803,7 +816,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
requestedChannel,
|
||||
storedChannel,
|
||||
effectiveChannel: channel,
|
||||
tag,
|
||||
tag: packageInstallSpec ?? tag,
|
||||
currentVersion,
|
||||
targetVersion,
|
||||
downgradeRisk,
|
||||
|
||||
332
src/commands/auth-choice-options.static.ts
Normal file
332
src/commands/auth-choice-options.static.ts
Normal file
@ -0,0 +1,332 @@
|
||||
import { AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI } from "./auth-choice-legacy.js";
|
||||
import { ONBOARD_PROVIDER_AUTH_FLAGS } from "./onboard-provider-auth-flags.js";
|
||||
import type { AuthChoice, AuthChoiceGroupId } from "./onboard-types.js";
|
||||
|
||||
export type { AuthChoiceGroupId };
|
||||
|
||||
export type AuthChoiceOption = {
|
||||
value: AuthChoice;
|
||||
label: string;
|
||||
hint?: string;
|
||||
};
|
||||
export type AuthChoiceGroup = {
|
||||
value: AuthChoiceGroupId;
|
||||
label: string;
|
||||
hint?: string;
|
||||
options: AuthChoiceOption[];
|
||||
};
|
||||
|
||||
export const AUTH_CHOICE_GROUP_DEFS: {
|
||||
value: AuthChoiceGroupId;
|
||||
label: string;
|
||||
hint?: string;
|
||||
choices: AuthChoice[];
|
||||
}[] = [
|
||||
{
|
||||
value: "openai",
|
||||
label: "OpenAI",
|
||||
hint: "Codex OAuth + API key",
|
||||
choices: ["openai-codex", "openai-api-key"],
|
||||
},
|
||||
{
|
||||
value: "anthropic",
|
||||
label: "Anthropic",
|
||||
hint: "setup-token + API key",
|
||||
choices: ["token", "apiKey"],
|
||||
},
|
||||
{
|
||||
value: "chutes",
|
||||
label: "Chutes",
|
||||
hint: "OAuth",
|
||||
choices: ["chutes"],
|
||||
},
|
||||
{
|
||||
value: "minimax",
|
||||
label: "MiniMax",
|
||||
hint: "M2.5 (recommended)",
|
||||
choices: ["minimax-global-oauth", "minimax-global-api", "minimax-cn-oauth", "minimax-cn-api"],
|
||||
},
|
||||
{
|
||||
value: "moonshot",
|
||||
label: "Moonshot AI (Kimi K2.5)",
|
||||
hint: "Kimi K2.5 + Kimi Coding",
|
||||
choices: ["moonshot-api-key", "moonshot-api-key-cn", "kimi-code-api-key"],
|
||||
},
|
||||
{
|
||||
value: "google",
|
||||
label: "Google",
|
||||
hint: "Gemini API key + OAuth",
|
||||
choices: ["gemini-api-key", "google-gemini-cli"],
|
||||
},
|
||||
{
|
||||
value: "xai",
|
||||
label: "xAI (Grok)",
|
||||
hint: "API key",
|
||||
choices: ["xai-api-key"],
|
||||
},
|
||||
{
|
||||
value: "mistral",
|
||||
label: "Mistral AI",
|
||||
hint: "API key",
|
||||
choices: ["mistral-api-key"],
|
||||
},
|
||||
{
|
||||
value: "volcengine",
|
||||
label: "Volcano Engine",
|
||||
hint: "API key",
|
||||
choices: ["volcengine-api-key"],
|
||||
},
|
||||
{
|
||||
value: "byteplus",
|
||||
label: "BytePlus",
|
||||
hint: "API key",
|
||||
choices: ["byteplus-api-key"],
|
||||
},
|
||||
{
|
||||
value: "openrouter",
|
||||
label: "OpenRouter",
|
||||
hint: "API key",
|
||||
choices: ["openrouter-api-key"],
|
||||
},
|
||||
{
|
||||
value: "kilocode",
|
||||
label: "Kilo Gateway",
|
||||
hint: "API key (OpenRouter-compatible)",
|
||||
choices: ["kilocode-api-key"],
|
||||
},
|
||||
{
|
||||
value: "qwen",
|
||||
label: "Qwen",
|
||||
hint: "OAuth",
|
||||
choices: ["qwen-portal"],
|
||||
},
|
||||
{
|
||||
value: "zai",
|
||||
label: "Z.AI",
|
||||
hint: "GLM Coding Plan / Global / CN",
|
||||
choices: ["zai-coding-global", "zai-coding-cn", "zai-global", "zai-cn"],
|
||||
},
|
||||
{
|
||||
value: "qianfan",
|
||||
label: "Qianfan",
|
||||
hint: "API key",
|
||||
choices: ["qianfan-api-key"],
|
||||
},
|
||||
{
|
||||
value: "modelstudio",
|
||||
label: "Alibaba Cloud Model Studio",
|
||||
hint: "Coding Plan API key (CN / Global)",
|
||||
choices: ["modelstudio-api-key-cn", "modelstudio-api-key"],
|
||||
},
|
||||
{
|
||||
value: "copilot",
|
||||
label: "Copilot",
|
||||
hint: "GitHub + local proxy",
|
||||
choices: ["github-copilot", "copilot-proxy"],
|
||||
},
|
||||
{
|
||||
value: "ai-gateway",
|
||||
label: "Vercel AI Gateway",
|
||||
hint: "API key",
|
||||
choices: ["ai-gateway-api-key"],
|
||||
},
|
||||
{
|
||||
value: "opencode",
|
||||
label: "OpenCode",
|
||||
hint: "Shared API key for Zen + Go catalogs",
|
||||
choices: ["opencode-zen", "opencode-go"],
|
||||
},
|
||||
{
|
||||
value: "xiaomi",
|
||||
label: "Xiaomi",
|
||||
hint: "API key",
|
||||
choices: ["xiaomi-api-key"],
|
||||
},
|
||||
{
|
||||
value: "synthetic",
|
||||
label: "Synthetic",
|
||||
hint: "Anthropic-compatible (multi-model)",
|
||||
choices: ["synthetic-api-key"],
|
||||
},
|
||||
{
|
||||
value: "together",
|
||||
label: "Together AI",
|
||||
hint: "API key",
|
||||
choices: ["together-api-key"],
|
||||
},
|
||||
{
|
||||
value: "huggingface",
|
||||
label: "Hugging Face",
|
||||
hint: "Inference API (HF token)",
|
||||
choices: ["huggingface-api-key"],
|
||||
},
|
||||
{
|
||||
value: "venice",
|
||||
label: "Venice AI",
|
||||
hint: "Privacy-focused (uncensored models)",
|
||||
choices: ["venice-api-key"],
|
||||
},
|
||||
{
|
||||
value: "litellm",
|
||||
label: "LiteLLM",
|
||||
hint: "Unified LLM gateway (100+ providers)",
|
||||
choices: ["litellm-api-key"],
|
||||
},
|
||||
{
|
||||
value: "cloudflare-ai-gateway",
|
||||
label: "Cloudflare AI Gateway",
|
||||
hint: "Account ID + Gateway ID + API key",
|
||||
choices: ["cloudflare-ai-gateway-api-key"],
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: "Custom Provider",
|
||||
hint: "Any OpenAI or Anthropic compatible endpoint",
|
||||
choices: ["custom-api-key"],
|
||||
},
|
||||
];
|
||||
|
||||
const PROVIDER_AUTH_CHOICE_OPTION_HINTS: Partial<Record<AuthChoice, string>> = {
|
||||
"litellm-api-key": "Unified gateway for 100+ LLM providers",
|
||||
"cloudflare-ai-gateway-api-key": "Account ID + Gateway ID + API key",
|
||||
"venice-api-key": "Privacy-focused inference (uncensored models)",
|
||||
"together-api-key": "Access to Llama, DeepSeek, Qwen, and more open models",
|
||||
"huggingface-api-key": "Inference Providers — OpenAI-compatible chat",
|
||||
"opencode-zen": "Shared OpenCode key; curated Zen catalog",
|
||||
"opencode-go": "Shared OpenCode key; Kimi/GLM/MiniMax Go catalog",
|
||||
};
|
||||
|
||||
const PROVIDER_AUTH_CHOICE_OPTION_LABELS: Partial<Record<AuthChoice, string>> = {
|
||||
"moonshot-api-key": "Kimi API key (.ai)",
|
||||
"moonshot-api-key-cn": "Kimi API key (.cn)",
|
||||
"kimi-code-api-key": "Kimi Code API key (subscription)",
|
||||
"cloudflare-ai-gateway-api-key": "Cloudflare AI Gateway",
|
||||
"opencode-zen": "OpenCode Zen catalog",
|
||||
"opencode-go": "OpenCode Go catalog",
|
||||
};
|
||||
|
||||
function buildProviderAuthChoiceOptions(): AuthChoiceOption[] {
|
||||
return ONBOARD_PROVIDER_AUTH_FLAGS.map((flag) => ({
|
||||
value: flag.authChoice,
|
||||
label: PROVIDER_AUTH_CHOICE_OPTION_LABELS[flag.authChoice] ?? flag.description,
|
||||
...(PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice]
|
||||
? { hint: PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice] }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
export const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray<AuthChoiceOption> = [
|
||||
{
|
||||
value: "token",
|
||||
label: "Anthropic token (paste setup-token)",
|
||||
hint: "run `claude setup-token` elsewhere, then paste the token here",
|
||||
},
|
||||
{
|
||||
value: "openai-codex",
|
||||
label: "OpenAI Codex (ChatGPT OAuth)",
|
||||
},
|
||||
{ value: "chutes", label: "Chutes (OAuth)" },
|
||||
...buildProviderAuthChoiceOptions(),
|
||||
{
|
||||
value: "moonshot-api-key-cn",
|
||||
label: "Kimi API key (.cn)",
|
||||
},
|
||||
{
|
||||
value: "github-copilot",
|
||||
label: "GitHub Copilot (GitHub device login)",
|
||||
hint: "Uses GitHub device flow",
|
||||
},
|
||||
{ value: "gemini-api-key", label: "Google Gemini API key" },
|
||||
{
|
||||
value: "google-gemini-cli",
|
||||
label: "Google Gemini CLI OAuth",
|
||||
hint: "Unofficial flow; review account-risk warning before use",
|
||||
},
|
||||
{ value: "zai-api-key", label: "Z.AI API key" },
|
||||
{
|
||||
value: "zai-coding-global",
|
||||
label: "Coding-Plan-Global",
|
||||
hint: "GLM Coding Plan Global (api.z.ai)",
|
||||
},
|
||||
{
|
||||
value: "zai-coding-cn",
|
||||
label: "Coding-Plan-CN",
|
||||
hint: "GLM Coding Plan CN (open.bigmodel.cn)",
|
||||
},
|
||||
{
|
||||
value: "zai-global",
|
||||
label: "Global",
|
||||
hint: "Z.AI Global (api.z.ai)",
|
||||
},
|
||||
{
|
||||
value: "zai-cn",
|
||||
label: "CN",
|
||||
hint: "Z.AI CN (open.bigmodel.cn)",
|
||||
},
|
||||
{
|
||||
value: "xiaomi-api-key",
|
||||
label: "Xiaomi API key",
|
||||
},
|
||||
{
|
||||
value: "minimax-global-oauth",
|
||||
label: "MiniMax Global — OAuth (minimax.io)",
|
||||
hint: "Only supports OAuth for the coding plan",
|
||||
},
|
||||
{
|
||||
value: "minimax-global-api",
|
||||
label: "MiniMax Global — API Key (minimax.io)",
|
||||
hint: "sk-api- or sk-cp- keys supported",
|
||||
},
|
||||
{
|
||||
value: "minimax-cn-oauth",
|
||||
label: "MiniMax CN — OAuth (minimaxi.com)",
|
||||
hint: "Only supports OAuth for the coding plan",
|
||||
},
|
||||
{
|
||||
value: "minimax-cn-api",
|
||||
label: "MiniMax CN — API Key (minimaxi.com)",
|
||||
hint: "sk-api- or sk-cp- keys supported",
|
||||
},
|
||||
{ value: "qwen-portal", label: "Qwen OAuth" },
|
||||
{
|
||||
value: "copilot-proxy",
|
||||
label: "Copilot Proxy (local)",
|
||||
hint: "Local proxy for VS Code Copilot models",
|
||||
},
|
||||
{ value: "apiKey", label: "Anthropic API key" },
|
||||
{
|
||||
value: "opencode-zen",
|
||||
label: "OpenCode Zen catalog",
|
||||
hint: "Claude, GPT, Gemini via opencode.ai/zen",
|
||||
},
|
||||
{ value: "qianfan-api-key", label: "Qianfan API key" },
|
||||
{
|
||||
value: "modelstudio-api-key-cn",
|
||||
label: "Coding Plan API Key for China (subscription)",
|
||||
hint: "Endpoint: coding.dashscope.aliyuncs.com",
|
||||
},
|
||||
{
|
||||
value: "modelstudio-api-key",
|
||||
label: "Coding Plan API Key for Global/Intl (subscription)",
|
||||
hint: "Endpoint: coding-intl.dashscope.aliyuncs.com",
|
||||
},
|
||||
{ value: "custom-api-key", label: "Custom Provider" },
|
||||
];
|
||||
|
||||
export function formatStaticAuthChoiceChoicesForCli(params?: {
|
||||
includeSkip?: boolean;
|
||||
includeLegacyAliases?: boolean;
|
||||
}): string {
|
||||
const includeSkip = params?.includeSkip ?? true;
|
||||
const includeLegacyAliases = params?.includeLegacyAliases ?? false;
|
||||
const values = BASE_AUTH_CHOICE_OPTIONS.map((opt) => opt.value);
|
||||
|
||||
if (includeSkip) {
|
||||
values.push("skip");
|
||||
}
|
||||
if (includeLegacyAliases) {
|
||||
values.push(...AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI);
|
||||
}
|
||||
|
||||
return values.join("|");
|
||||
}
|
||||
@ -6,6 +6,7 @@ import {
|
||||
buildAuthChoiceOptions,
|
||||
formatAuthChoiceChoicesForCli,
|
||||
} from "./auth-choice-options.js";
|
||||
import { formatStaticAuthChoiceChoicesForCli } from "./auth-choice-options.static.js";
|
||||
|
||||
const resolveProviderWizardOptions = vi.hoisted(() =>
|
||||
vi.fn<() => ProviderWizardOption[]>(() => []),
|
||||
@ -104,6 +105,26 @@ describe("buildAuthChoiceOptions", () => {
|
||||
expect(cliChoices).toContain("codex-cli");
|
||||
});
|
||||
|
||||
it("keeps static cli help choices off the plugin-backed catalog", () => {
|
||||
resolveProviderWizardOptions.mockReturnValue([
|
||||
{
|
||||
value: "ollama",
|
||||
label: "Ollama",
|
||||
hint: "Cloud and local open models",
|
||||
groupId: "ollama",
|
||||
groupLabel: "Ollama",
|
||||
},
|
||||
]);
|
||||
|
||||
const cliChoices = formatStaticAuthChoiceChoicesForCli({
|
||||
includeLegacyAliases: false,
|
||||
includeSkip: true,
|
||||
}).split("|");
|
||||
|
||||
expect(cliChoices).not.toContain("ollama");
|
||||
expect(cliChoices).toContain("skip");
|
||||
});
|
||||
|
||||
it("shows Chutes in grouped provider selection", () => {
|
||||
const { groups } = buildAuthChoiceGroups({
|
||||
store: EMPTY_STORE,
|
||||
|
||||
@ -1,321 +1,15 @@
|
||||
import type { AuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveProviderWizardOptions } from "../plugins/provider-wizard.js";
|
||||
import { AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI } from "./auth-choice-legacy.js";
|
||||
import { ONBOARD_PROVIDER_AUTH_FLAGS } from "./onboard-provider-auth-flags.js";
|
||||
import {
|
||||
AUTH_CHOICE_GROUP_DEFS,
|
||||
BASE_AUTH_CHOICE_OPTIONS,
|
||||
type AuthChoiceGroup,
|
||||
type AuthChoiceOption,
|
||||
formatStaticAuthChoiceChoicesForCli,
|
||||
} from "./auth-choice-options.static.js";
|
||||
import type { AuthChoice, AuthChoiceGroupId } from "./onboard-types.js";
|
||||
|
||||
export type { AuthChoiceGroupId };
|
||||
|
||||
export type AuthChoiceOption = {
|
||||
value: AuthChoice;
|
||||
label: string;
|
||||
hint?: string;
|
||||
};
|
||||
export type AuthChoiceGroup = {
|
||||
value: AuthChoiceGroupId;
|
||||
label: string;
|
||||
hint?: string;
|
||||
options: AuthChoiceOption[];
|
||||
};
|
||||
|
||||
const AUTH_CHOICE_GROUP_DEFS: {
|
||||
value: AuthChoiceGroupId;
|
||||
label: string;
|
||||
hint?: string;
|
||||
choices: AuthChoice[];
|
||||
}[] = [
|
||||
{
|
||||
value: "openai",
|
||||
label: "OpenAI",
|
||||
hint: "Codex OAuth + API key",
|
||||
choices: ["openai-codex", "openai-api-key"],
|
||||
},
|
||||
{
|
||||
value: "anthropic",
|
||||
label: "Anthropic",
|
||||
hint: "setup-token + API key",
|
||||
choices: ["token", "apiKey"],
|
||||
},
|
||||
{
|
||||
value: "chutes",
|
||||
label: "Chutes",
|
||||
hint: "OAuth",
|
||||
choices: ["chutes"],
|
||||
},
|
||||
{
|
||||
value: "minimax",
|
||||
label: "MiniMax",
|
||||
hint: "M2.5 (recommended)",
|
||||
choices: ["minimax-global-oauth", "minimax-global-api", "minimax-cn-oauth", "minimax-cn-api"],
|
||||
},
|
||||
{
|
||||
value: "moonshot",
|
||||
label: "Moonshot AI (Kimi K2.5)",
|
||||
hint: "Kimi K2.5 + Kimi Coding",
|
||||
choices: ["moonshot-api-key", "moonshot-api-key-cn", "kimi-code-api-key"],
|
||||
},
|
||||
{
|
||||
value: "google",
|
||||
label: "Google",
|
||||
hint: "Gemini API key + OAuth",
|
||||
choices: ["gemini-api-key", "google-gemini-cli"],
|
||||
},
|
||||
{
|
||||
value: "xai",
|
||||
label: "xAI (Grok)",
|
||||
hint: "API key",
|
||||
choices: ["xai-api-key"],
|
||||
},
|
||||
{
|
||||
value: "mistral",
|
||||
label: "Mistral AI",
|
||||
hint: "API key",
|
||||
choices: ["mistral-api-key"],
|
||||
},
|
||||
{
|
||||
value: "volcengine",
|
||||
label: "Volcano Engine",
|
||||
hint: "API key",
|
||||
choices: ["volcengine-api-key"],
|
||||
},
|
||||
{
|
||||
value: "byteplus",
|
||||
label: "BytePlus",
|
||||
hint: "API key",
|
||||
choices: ["byteplus-api-key"],
|
||||
},
|
||||
{
|
||||
value: "openrouter",
|
||||
label: "OpenRouter",
|
||||
hint: "API key",
|
||||
choices: ["openrouter-api-key"],
|
||||
},
|
||||
{
|
||||
value: "kilocode",
|
||||
label: "Kilo Gateway",
|
||||
hint: "API key (OpenRouter-compatible)",
|
||||
choices: ["kilocode-api-key"],
|
||||
},
|
||||
{
|
||||
value: "qwen",
|
||||
label: "Qwen",
|
||||
hint: "OAuth",
|
||||
choices: ["qwen-portal"],
|
||||
},
|
||||
{
|
||||
value: "zai",
|
||||
label: "Z.AI",
|
||||
hint: "GLM Coding Plan / Global / CN",
|
||||
choices: ["zai-coding-global", "zai-coding-cn", "zai-global", "zai-cn"],
|
||||
},
|
||||
{
|
||||
value: "qianfan",
|
||||
label: "Qianfan",
|
||||
hint: "API key",
|
||||
choices: ["qianfan-api-key"],
|
||||
},
|
||||
{
|
||||
value: "modelstudio",
|
||||
label: "Alibaba Cloud Model Studio",
|
||||
hint: "Coding Plan API key (CN / Global)",
|
||||
choices: ["modelstudio-api-key-cn", "modelstudio-api-key"],
|
||||
},
|
||||
{
|
||||
value: "copilot",
|
||||
label: "Copilot",
|
||||
hint: "GitHub + local proxy",
|
||||
choices: ["github-copilot", "copilot-proxy"],
|
||||
},
|
||||
{
|
||||
value: "ai-gateway",
|
||||
label: "Vercel AI Gateway",
|
||||
hint: "API key",
|
||||
choices: ["ai-gateway-api-key"],
|
||||
},
|
||||
{
|
||||
value: "opencode",
|
||||
label: "OpenCode",
|
||||
hint: "Shared API key for Zen + Go catalogs",
|
||||
choices: ["opencode-zen", "opencode-go"],
|
||||
},
|
||||
{
|
||||
value: "xiaomi",
|
||||
label: "Xiaomi",
|
||||
hint: "API key",
|
||||
choices: ["xiaomi-api-key"],
|
||||
},
|
||||
{
|
||||
value: "synthetic",
|
||||
label: "Synthetic",
|
||||
hint: "Anthropic-compatible (multi-model)",
|
||||
choices: ["synthetic-api-key"],
|
||||
},
|
||||
{
|
||||
value: "together",
|
||||
label: "Together AI",
|
||||
hint: "API key",
|
||||
choices: ["together-api-key"],
|
||||
},
|
||||
{
|
||||
value: "huggingface",
|
||||
label: "Hugging Face",
|
||||
hint: "Inference API (HF token)",
|
||||
choices: ["huggingface-api-key"],
|
||||
},
|
||||
{
|
||||
value: "venice",
|
||||
label: "Venice AI",
|
||||
hint: "Privacy-focused (uncensored models)",
|
||||
choices: ["venice-api-key"],
|
||||
},
|
||||
{
|
||||
value: "litellm",
|
||||
label: "LiteLLM",
|
||||
hint: "Unified LLM gateway (100+ providers)",
|
||||
choices: ["litellm-api-key"],
|
||||
},
|
||||
{
|
||||
value: "cloudflare-ai-gateway",
|
||||
label: "Cloudflare AI Gateway",
|
||||
hint: "Account ID + Gateway ID + API key",
|
||||
choices: ["cloudflare-ai-gateway-api-key"],
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: "Custom Provider",
|
||||
hint: "Any OpenAI or Anthropic compatible endpoint",
|
||||
choices: ["custom-api-key"],
|
||||
},
|
||||
];
|
||||
|
||||
const PROVIDER_AUTH_CHOICE_OPTION_HINTS: Partial<Record<AuthChoice, string>> = {
|
||||
"litellm-api-key": "Unified gateway for 100+ LLM providers",
|
||||
"cloudflare-ai-gateway-api-key": "Account ID + Gateway ID + API key",
|
||||
"venice-api-key": "Privacy-focused inference (uncensored models)",
|
||||
"together-api-key": "Access to Llama, DeepSeek, Qwen, and more open models",
|
||||
"huggingface-api-key": "Inference Providers — OpenAI-compatible chat",
|
||||
"opencode-zen": "Shared OpenCode key; curated Zen catalog",
|
||||
"opencode-go": "Shared OpenCode key; Kimi/GLM/MiniMax Go catalog",
|
||||
};
|
||||
|
||||
const PROVIDER_AUTH_CHOICE_OPTION_LABELS: Partial<Record<AuthChoice, string>> = {
|
||||
"moonshot-api-key": "Kimi API key (.ai)",
|
||||
"moonshot-api-key-cn": "Kimi API key (.cn)",
|
||||
"kimi-code-api-key": "Kimi Code API key (subscription)",
|
||||
"cloudflare-ai-gateway-api-key": "Cloudflare AI Gateway",
|
||||
"opencode-zen": "OpenCode Zen catalog",
|
||||
"opencode-go": "OpenCode Go catalog",
|
||||
};
|
||||
|
||||
function buildProviderAuthChoiceOptions(): AuthChoiceOption[] {
|
||||
return ONBOARD_PROVIDER_AUTH_FLAGS.map((flag) => ({
|
||||
value: flag.authChoice,
|
||||
label: PROVIDER_AUTH_CHOICE_OPTION_LABELS[flag.authChoice] ?? flag.description,
|
||||
...(PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice]
|
||||
? { hint: PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice] }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray<AuthChoiceOption> = [
|
||||
{
|
||||
value: "token",
|
||||
label: "Anthropic token (paste setup-token)",
|
||||
hint: "run `claude setup-token` elsewhere, then paste the token here",
|
||||
},
|
||||
{
|
||||
value: "openai-codex",
|
||||
label: "OpenAI Codex (ChatGPT OAuth)",
|
||||
},
|
||||
{ value: "chutes", label: "Chutes (OAuth)" },
|
||||
...buildProviderAuthChoiceOptions(),
|
||||
{
|
||||
value: "moonshot-api-key-cn",
|
||||
label: "Kimi API key (.cn)",
|
||||
},
|
||||
{
|
||||
value: "github-copilot",
|
||||
label: "GitHub Copilot (GitHub device login)",
|
||||
hint: "Uses GitHub device flow",
|
||||
},
|
||||
{ value: "gemini-api-key", label: "Google Gemini API key" },
|
||||
{
|
||||
value: "google-gemini-cli",
|
||||
label: "Google Gemini CLI OAuth",
|
||||
hint: "Unofficial flow; review account-risk warning before use",
|
||||
},
|
||||
{ value: "zai-api-key", label: "Z.AI API key" },
|
||||
{
|
||||
value: "zai-coding-global",
|
||||
label: "Coding-Plan-Global",
|
||||
hint: "GLM Coding Plan Global (api.z.ai)",
|
||||
},
|
||||
{
|
||||
value: "zai-coding-cn",
|
||||
label: "Coding-Plan-CN",
|
||||
hint: "GLM Coding Plan CN (open.bigmodel.cn)",
|
||||
},
|
||||
{
|
||||
value: "zai-global",
|
||||
label: "Global",
|
||||
hint: "Z.AI Global (api.z.ai)",
|
||||
},
|
||||
{
|
||||
value: "zai-cn",
|
||||
label: "CN",
|
||||
hint: "Z.AI CN (open.bigmodel.cn)",
|
||||
},
|
||||
{
|
||||
value: "xiaomi-api-key",
|
||||
label: "Xiaomi API key",
|
||||
},
|
||||
{
|
||||
value: "minimax-global-oauth",
|
||||
label: "MiniMax Global — OAuth (minimax.io)",
|
||||
hint: "Only supports OAuth for the coding plan",
|
||||
},
|
||||
{
|
||||
value: "minimax-global-api",
|
||||
label: "MiniMax Global — API Key (minimax.io)",
|
||||
hint: "sk-api- or sk-cp- keys supported",
|
||||
},
|
||||
{
|
||||
value: "minimax-cn-oauth",
|
||||
label: "MiniMax CN — OAuth (minimaxi.com)",
|
||||
hint: "Only supports OAuth for the coding plan",
|
||||
},
|
||||
{
|
||||
value: "minimax-cn-api",
|
||||
label: "MiniMax CN — API Key (minimaxi.com)",
|
||||
hint: "sk-api- or sk-cp- keys supported",
|
||||
},
|
||||
{ value: "qwen-portal", label: "Qwen OAuth" },
|
||||
{
|
||||
value: "copilot-proxy",
|
||||
label: "Copilot Proxy (local)",
|
||||
hint: "Local proxy for VS Code Copilot models",
|
||||
},
|
||||
{ value: "apiKey", label: "Anthropic API key" },
|
||||
{
|
||||
value: "opencode-zen",
|
||||
label: "OpenCode Zen catalog",
|
||||
hint: "Claude, GPT, Gemini via opencode.ai/zen",
|
||||
},
|
||||
{ value: "qianfan-api-key", label: "Qianfan API key" },
|
||||
{
|
||||
value: "modelstudio-api-key-cn",
|
||||
label: "Coding Plan API Key for China (subscription)",
|
||||
hint: "Endpoint: coding.dashscope.aliyuncs.com",
|
||||
},
|
||||
{
|
||||
value: "modelstudio-api-key",
|
||||
label: "Coding Plan API Key for Global/Intl (subscription)",
|
||||
hint: "Endpoint: coding-intl.dashscope.aliyuncs.com",
|
||||
},
|
||||
{ value: "custom-api-key", label: "Custom Provider" },
|
||||
];
|
||||
|
||||
function resolveDynamicProviderCliChoices(params?: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
@ -331,20 +25,11 @@ export function formatAuthChoiceChoicesForCli(params?: {
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string {
|
||||
const includeSkip = params?.includeSkip ?? true;
|
||||
const includeLegacyAliases = params?.includeLegacyAliases ?? false;
|
||||
const values = [
|
||||
...BASE_AUTH_CHOICE_OPTIONS.map((opt) => opt.value),
|
||||
...formatStaticAuthChoiceChoicesForCli(params).split("|"),
|
||||
...resolveDynamicProviderCliChoices(params),
|
||||
];
|
||||
|
||||
if (includeSkip) {
|
||||
values.push("skip");
|
||||
}
|
||||
if (includeLegacyAliases) {
|
||||
values.push(...AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI);
|
||||
}
|
||||
|
||||
return values.join("|");
|
||||
}
|
||||
|
||||
|
||||
@ -8,10 +8,12 @@ import { compactEmbeddedPiSessionDirect } from "../agents/pi-embedded-runner/com
|
||||
import { LegacyContextEngine, registerLegacyContextEngine } from "./legacy.js";
|
||||
import {
|
||||
registerContextEngine,
|
||||
registerContextEngineForOwner,
|
||||
getContextEngineFactory,
|
||||
listContextEngineIds,
|
||||
resolveContextEngine,
|
||||
} from "./registry.js";
|
||||
import type { ContextEngineFactory, ContextEngineRegistrationResult } from "./registry.js";
|
||||
import type {
|
||||
ContextEngine,
|
||||
ContextEngineInfo,
|
||||
@ -231,18 +233,80 @@ describe("Registry tests", () => {
|
||||
expect(Array.isArray(ids)).toBe(true);
|
||||
});
|
||||
|
||||
it("registering the same id overwrites the previous factory", () => {
|
||||
it("registering the same id with the same owner refreshes the factory", () => {
|
||||
const factory1 = () => new MockContextEngine();
|
||||
const factory2 = () => new MockContextEngine();
|
||||
|
||||
registerContextEngine("reg-overwrite", factory1);
|
||||
expect(
|
||||
registerContextEngineForOwner("reg-overwrite", factory1, "owner-a", {
|
||||
allowSameOwnerRefresh: true,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
expect(getContextEngineFactory("reg-overwrite")).toBe(factory1);
|
||||
|
||||
registerContextEngine("reg-overwrite", factory2);
|
||||
expect(
|
||||
registerContextEngineForOwner("reg-overwrite", factory2, "owner-a", {
|
||||
allowSameOwnerRefresh: true,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
expect(getContextEngineFactory("reg-overwrite")).toBe(factory2);
|
||||
expect(getContextEngineFactory("reg-overwrite")).not.toBe(factory1);
|
||||
});
|
||||
|
||||
it("rejects context engine registrations from a different owner", () => {
|
||||
const factory1 = () => new MockContextEngine();
|
||||
const factory2 = () => new MockContextEngine();
|
||||
|
||||
expect(
|
||||
registerContextEngineForOwner("reg-owner-guard", factory1, "owner-a", {
|
||||
allowSameOwnerRefresh: true,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
expect(registerContextEngineForOwner("reg-owner-guard", factory2, "owner-b")).toEqual({
|
||||
ok: false,
|
||||
existingOwner: "owner-a",
|
||||
});
|
||||
expect(getContextEngineFactory("reg-owner-guard")).toBe(factory1);
|
||||
});
|
||||
|
||||
it("public registerContextEngine cannot spoof owner or refresh existing ids", () => {
|
||||
const ownedFactory = () => new MockContextEngine();
|
||||
expect(
|
||||
registerContextEngineForOwner("public-owner-guard", ownedFactory, "owner-a", {
|
||||
allowSameOwnerRefresh: true,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
|
||||
const spoofAttempt = (
|
||||
registerContextEngine as unknown as (
|
||||
id: string,
|
||||
factory: ContextEngineFactory,
|
||||
opts?: { owner?: string },
|
||||
) => ContextEngineRegistrationResult
|
||||
)("public-owner-guard", () => new MockContextEngine(), { owner: "owner-a" });
|
||||
|
||||
expect(spoofAttempt).toEqual({
|
||||
ok: false,
|
||||
existingOwner: "owner-a",
|
||||
});
|
||||
expect(getContextEngineFactory("public-owner-guard")).toBe(ownedFactory);
|
||||
});
|
||||
|
||||
it("public registerContextEngine reserves the default legacy id", () => {
|
||||
const legacyAttempt = (
|
||||
registerContextEngine as unknown as (
|
||||
id: string,
|
||||
factory: ContextEngineFactory,
|
||||
opts?: { owner?: string },
|
||||
) => ContextEngineRegistrationResult
|
||||
)("legacy", () => new MockContextEngine(), { owner: "core" });
|
||||
|
||||
expect(legacyAttempt).toEqual({
|
||||
ok: false,
|
||||
existingOwner: "core",
|
||||
});
|
||||
});
|
||||
|
||||
it("shares registered engines across duplicate module copies", async () => {
|
||||
const registryUrl = new URL("./registry.ts", import.meta.url).href;
|
||||
const suffix = Date.now().toString(36);
|
||||
@ -474,6 +538,33 @@ describe("Bundle chunk isolation (#40096)", () => {
|
||||
expect(getContextEngineFactory(sdkEngineId)).toBeDefined();
|
||||
});
|
||||
|
||||
it("plugin-sdk registerContextEngine cannot spoof privileged ownership", async () => {
|
||||
const ts = Date.now().toString(36);
|
||||
const engineId = `sdk-spoof-guard-${ts}`;
|
||||
const ownedFactory = () => new MockContextEngine();
|
||||
expect(
|
||||
registerContextEngineForOwner(engineId, ownedFactory, "plugin:owner-a", {
|
||||
allowSameOwnerRefresh: true,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
|
||||
const sdkUrl = new URL("../plugin-sdk/index.ts", import.meta.url).href;
|
||||
const sdk = await import(/* @vite-ignore */ `${sdkUrl}?sdk-spoof-${ts}`);
|
||||
const spoofAttempt = (
|
||||
sdk.registerContextEngine as unknown as (
|
||||
id: string,
|
||||
factory: ContextEngineFactory,
|
||||
opts?: { owner?: string },
|
||||
) => ContextEngineRegistrationResult
|
||||
)(engineId, () => new MockContextEngine(), { owner: "plugin:owner-a" });
|
||||
|
||||
expect(spoofAttempt).toEqual({
|
||||
ok: false,
|
||||
existingOwner: "plugin:owner-a",
|
||||
});
|
||||
expect(getContextEngineFactory(engineId)).toBe(ownedFactory);
|
||||
});
|
||||
|
||||
it("concurrent registration from multiple chunks does not lose entries", async () => {
|
||||
const ts = Date.now().toString(36);
|
||||
const registryUrl = new URL("./registry.ts", import.meta.url).href;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { registerContextEngine } from "./registry.js";
|
||||
import { registerContextEngineForOwner } from "./registry.js";
|
||||
import type {
|
||||
ContextEngine,
|
||||
ContextEngineInfo,
|
||||
@ -124,5 +124,7 @@ export class LegacyContextEngine implements ContextEngine {
|
||||
}
|
||||
|
||||
export function registerLegacyContextEngine(): void {
|
||||
registerContextEngine("legacy", () => new LegacyContextEngine());
|
||||
registerContextEngineForOwner("legacy", () => new LegacyContextEngine(), "core", {
|
||||
allowSameOwnerRefresh: true,
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,15 +7,28 @@ import type { ContextEngine } from "./types.js";
|
||||
* Supports async creation for engines that need DB connections etc.
|
||||
*/
|
||||
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<string, ContextEngineFactory>;
|
||||
engines: Map<
|
||||
string,
|
||||
{
|
||||
factory: ContextEngineFactory;
|
||||
owner: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
// Keep context-engine registrations process-global so duplicated dist chunks
|
||||
@ -26,24 +39,69 @@ function getContextEngineRegistryState(): ContextEngineRegistryState {
|
||||
};
|
||||
if (!globalState[CONTEXT_ENGINE_REGISTRY_STATE]) {
|
||||
globalState[CONTEXT_ENGINE_REGISTRY_STATE] = {
|
||||
engines: new Map<string, ContextEngineFactory>(),
|
||||
engines: new Map(),
|
||||
};
|
||||
}
|
||||
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 registerContextEngine(id: string, factory: ContextEngineFactory): void {
|
||||
getContextEngineRegistryState().engines.set(id, factory);
|
||||
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,
|
||||
): ContextEngineRegistrationResult {
|
||||
return registerContextEngineForOwner(id, factory, PUBLIC_CONTEXT_ENGINE_OWNER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the factory for a registered engine, or undefined.
|
||||
*/
|
||||
export function getContextEngineFactory(id: string): ContextEngineFactory | undefined {
|
||||
return getContextEngineRegistryState().engines.get(id);
|
||||
return getContextEngineRegistryState().engines.get(id)?.factory;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -73,13 +131,13 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
|
||||
? slotValue.trim()
|
||||
: defaultSlotIdForKey("contextEngine");
|
||||
|
||||
const factory = getContextEngineRegistryState().engines.get(engineId);
|
||||
if (!factory) {
|
||||
const entry = getContextEngineRegistryState().engines.get(engineId);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Context engine "${engineId}" is not registered. ` +
|
||||
`Available engines: ${listContextEngineIds().join(", ") || "(none)"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return factory();
|
||||
return entry.factory();
|
||||
}
|
||||
|
||||
@ -63,11 +63,11 @@ async function issuePairingScopedOperator(name: string): Promise<{
|
||||
role: "operator",
|
||||
scopes: ["operator.pairing"],
|
||||
});
|
||||
expect(rotated?.token).toBeTruthy();
|
||||
expect(rotated.ok ? rotated.entry.token : "").toBeTruthy();
|
||||
return {
|
||||
identityPath: loaded.identityPath,
|
||||
deviceId: loaded.identity.deviceId,
|
||||
token: String(rotated?.token ?? ""),
|
||||
token: rotated.ok ? rotated.entry.token : "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -85,6 +85,11 @@ export type ApproveDevicePairingResult =
|
||||
| { status: "forbidden"; missingScope: string }
|
||||
| null;
|
||||
|
||||
type ApprovedDevicePairingResult = Extract<
|
||||
NonNullable<ApproveDevicePairingResult>,
|
||||
{ status: "approved" }
|
||||
>;
|
||||
|
||||
type DevicePairingStateFile = {
|
||||
pendingById: Record<string, DevicePairingPendingRequest>;
|
||||
pairedByDeviceId: Record<string, PairedDevice>;
|
||||
@ -343,6 +348,15 @@ export async function requestDevicePairing(
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveDevicePairing(
|
||||
requestId: string,
|
||||
baseDir?: string,
|
||||
): Promise<ApprovedDevicePairingResult | null>;
|
||||
export async function approveDevicePairing(
|
||||
requestId: string,
|
||||
options: { callerScopes?: readonly string[] },
|
||||
baseDir?: string,
|
||||
): Promise<ApproveDevicePairingResult>;
|
||||
export async function approveDevicePairing(
|
||||
requestId: string,
|
||||
optionsOrBaseDir?: { callerScopes?: readonly string[] } | string,
|
||||
|
||||
@ -24,6 +24,21 @@ function createExitedProcess(code: number | null, signal: string | null = null)
|
||||
};
|
||||
}
|
||||
|
||||
async function writeRuntimePostBuildScaffold(tmp: string): Promise<void> {
|
||||
const pluginSdkAliasPath = path.join(tmp, "src", "plugin-sdk", "root-alias.cjs");
|
||||
await fs.mkdir(path.dirname(pluginSdkAliasPath), { recursive: true });
|
||||
await fs.mkdir(path.join(tmp, "extensions"), { recursive: true });
|
||||
await fs.writeFile(pluginSdkAliasPath, "module.exports = {};\n", "utf-8");
|
||||
const baselineTime = new Date("2026-03-13T09:00:00.000Z");
|
||||
await fs.utimes(pluginSdkAliasPath, baselineTime, baselineTime);
|
||||
}
|
||||
|
||||
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",
|
||||
@ -32,6 +47,7 @@ describe("run-node script", () => {
|
||||
const argsPath = path.join(tmp, ".pnpm-args.txt");
|
||||
const indexPath = path.join(tmp, "dist", "control-ui", "index.html");
|
||||
|
||||
await writeRuntimePostBuildScaffold(tmp);
|
||||
await fs.mkdir(path.dirname(indexPath), { recursive: true });
|
||||
await fs.writeFile(indexPath, "<html>sentinel</html>\n", "utf-8");
|
||||
|
||||
@ -78,6 +94,73 @@ describe("run-node script", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("copies bundled plugin metadata after rebuilding from a clean dist", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
const extensionManifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json");
|
||||
const extensionPackagePath = path.join(tmp, "extensions", "demo", "package.json");
|
||||
|
||||
await writeRuntimePostBuildScaffold(tmp);
|
||||
await fs.mkdir(path.dirname(extensionManifestPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
extensionManifestPath,
|
||||
'{"id":"demo","configSchema":{"type":"object"}}\n',
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
extensionPackagePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "demo",
|
||||
openclaw: {
|
||||
extensions: ["./src/index.ts", "./nested/entry.mts"],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const spawnCalls: string[][] = [];
|
||||
const spawn = (cmd: string, args: string[]) => {
|
||||
spawnCalls.push([cmd, ...args]);
|
||||
return createExitedProcess(0);
|
||||
};
|
||||
|
||||
const { runNodeMain } = await import("../../scripts/run-node.mjs");
|
||||
const exitCode = await runNodeMain({
|
||||
cwd: tmp,
|
||||
args: ["status"],
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_FORCE_BUILD: "1",
|
||||
OPENCLAW_RUNNER_LOG: "0",
|
||||
},
|
||||
spawn,
|
||||
execPath: process.execPath,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawnCalls).toEqual([
|
||||
expectedBuildSpawn(),
|
||||
[process.execPath, "openclaw.mjs", "status"],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(tmp, "dist", "plugin-sdk", "root-alias.cjs"), "utf-8"),
|
||||
).resolves.toContain("module.exports = {};");
|
||||
await expect(
|
||||
fs.readFile(path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"), "utf-8"),
|
||||
).resolves.toContain('"id":"demo"');
|
||||
await expect(
|
||||
fs.readFile(path.join(tmp, "dist", "extensions", "demo", "package.json"), "utf-8"),
|
||||
).resolves.toContain(
|
||||
'"extensions": [\n "./src/index.js",\n "./nested/entry.js"\n ]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips rebuilding when dist is current and the source tree is clean", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
const srcPath = path.join(tmp, "src", "index.ts");
|
||||
@ -85,6 +168,7 @@ describe("run-node script", () => {
|
||||
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
|
||||
const tsconfigPath = path.join(tmp, "tsconfig.json");
|
||||
const packageJsonPath = path.join(tmp, "package.json");
|
||||
await writeRuntimePostBuildScaffold(tmp);
|
||||
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");
|
||||
@ -161,4 +245,525 @@ 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 writeRuntimePostBuildScaffold(tmp);
|
||||
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 manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json");
|
||||
const packagePath = path.join(tmp, "extensions", "demo", "package.json");
|
||||
const distPackagePath = path.join(tmp, "dist", "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 writeRuntimePostBuildScaffold(tmp);
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(packagePath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(distPackagePath), { recursive: true });
|
||||
await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8");
|
||||
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(
|
||||
distPackagePath,
|
||||
'{"name":"demo","openclaw":{"extensions":["./stale.js"]}}\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(manifestPath, 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(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"]]);
|
||||
await expect(fs.readFile(distPackagePath, "utf-8")).resolves.toContain('"./index.js"');
|
||||
});
|
||||
});
|
||||
|
||||
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 writeRuntimePostBuildScaffold(tmp);
|
||||
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 distManifestPath = path.join(tmp, "dist", "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 writeRuntimePostBuildScaffold(tmp);
|
||||
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.mkdir(path.dirname(distManifestPath), { recursive: true });
|
||||
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
|
||||
await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\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(
|
||||
distManifestPath,
|
||||
'{"id":"stale","configSchema":{"type":"object"}}\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"]]);
|
||||
await expect(fs.readFile(distManifestPath, "utf-8")).resolves.toContain('"id":"demo"');
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs missing bundled plugin metadata without rerunning tsdown", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
const srcPath = path.join(tmp, "src", "index.ts");
|
||||
const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json");
|
||||
const distManifestPath = path.join(tmp, "dist", "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 writeRuntimePostBuildScaffold(tmp);
|
||||
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","configSchema":{"type":"object"}}\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: "" };
|
||||
}
|
||||
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"]]);
|
||||
await expect(fs.readFile(distManifestPath, "utf-8")).resolves.toContain('"id":"demo"');
|
||||
});
|
||||
});
|
||||
|
||||
it("removes stale bundled plugin metadata when the source manifest is gone", async () => {
|
||||
await withTempDir(async (tmp) => {
|
||||
const srcPath = path.join(tmp, "src", "index.ts");
|
||||
const extensionDir = path.join(tmp, "extensions", "demo");
|
||||
const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json");
|
||||
const distPackagePath = path.join(tmp, "dist", "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 writeRuntimePostBuildScaffold(tmp);
|
||||
await fs.mkdir(path.dirname(srcPath), { recursive: true });
|
||||
await fs.mkdir(extensionDir, { recursive: true });
|
||||
await fs.mkdir(path.dirname(distManifestPath), { 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");
|
||||
await fs.writeFile(
|
||||
distManifestPath,
|
||||
'{"id":"stale","configSchema":{"type":"object"}}\n',
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(distPackagePath, '{"name":"stale"}\n', "utf-8");
|
||||
|
||||
const stampTime = new Date("2026-03-13T12:00:00.000Z");
|
||||
await fs.utimes(srcPath, 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: "" };
|
||||
}
|
||||
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"]]);
|
||||
await expect(fs.access(distManifestPath)).rejects.toThrow();
|
||||
await expect(fs.access(distPackagePath)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
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 writeRuntimePostBuildScaffold(tmp);
|
||||
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 writeRuntimePostBuildScaffold(tmp);
|
||||
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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,11 +4,15 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
canResolveRegistryVersionForPackageTarget,
|
||||
cleanupGlobalRenameDirs,
|
||||
detectGlobalInstallManagerByPresence,
|
||||
detectGlobalInstallManagerForRoot,
|
||||
globalInstallArgs,
|
||||
globalInstallFallbackArgs,
|
||||
isExplicitPackageInstallSpec,
|
||||
isMainPackageTarget,
|
||||
OPENCLAW_MAIN_PACKAGE_SPEC,
|
||||
resolveGlobalPackageRoot,
|
||||
resolveGlobalInstallSpec,
|
||||
resolveGlobalRoot,
|
||||
@ -60,6 +64,40 @@ describe("update global helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("maps main and explicit install specs for global installs", () => {
|
||||
expect(resolveGlobalInstallSpec({ packageName: "openclaw", tag: "main" })).toBe(
|
||||
OPENCLAW_MAIN_PACKAGE_SPEC,
|
||||
);
|
||||
expect(
|
||||
resolveGlobalInstallSpec({
|
||||
packageName: "openclaw",
|
||||
tag: "github:openclaw/openclaw#feature/my-branch",
|
||||
}),
|
||||
).toBe("github:openclaw/openclaw#feature/my-branch");
|
||||
expect(
|
||||
resolveGlobalInstallSpec({
|
||||
packageName: "openclaw",
|
||||
tag: "https://example.com/openclaw-main.tgz",
|
||||
}),
|
||||
).toBe("https://example.com/openclaw-main.tgz");
|
||||
});
|
||||
|
||||
it("classifies main and raw install specs separately from registry selectors", () => {
|
||||
expect(isMainPackageTarget("main")).toBe(true);
|
||||
expect(isMainPackageTarget(" MAIN ")).toBe(true);
|
||||
expect(isMainPackageTarget("beta")).toBe(false);
|
||||
|
||||
expect(isExplicitPackageInstallSpec("github:openclaw/openclaw#main")).toBe(true);
|
||||
expect(isExplicitPackageInstallSpec("https://example.com/openclaw-main.tgz")).toBe(true);
|
||||
expect(isExplicitPackageInstallSpec("file:/tmp/openclaw-main.tgz")).toBe(true);
|
||||
expect(isExplicitPackageInstallSpec("beta")).toBe(false);
|
||||
|
||||
expect(canResolveRegistryVersionForPackageTarget("latest")).toBe(true);
|
||||
expect(canResolveRegistryVersionForPackageTarget("2026.3.14")).toBe(true);
|
||||
expect(canResolveRegistryVersionForPackageTarget("main")).toBe(false);
|
||||
expect(canResolveRegistryVersionForPackageTarget("github:openclaw/openclaw#main")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects install managers from resolved roots and on-disk presence", async () => {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-global-"));
|
||||
const npmRoot = path.join(base, "npm-root");
|
||||
|
||||
@ -14,12 +14,41 @@ export type CommandRunner = (
|
||||
const PRIMARY_PACKAGE_NAME = "openclaw";
|
||||
const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const;
|
||||
const GLOBAL_RENAME_PREFIX = ".";
|
||||
export const OPENCLAW_MAIN_PACKAGE_SPEC = "github:openclaw/openclaw#main";
|
||||
const NPM_GLOBAL_INSTALL_QUIET_FLAGS = ["--no-fund", "--no-audit", "--loglevel=error"] as const;
|
||||
const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [
|
||||
"--omit=optional",
|
||||
...NPM_GLOBAL_INSTALL_QUIET_FLAGS,
|
||||
] as const;
|
||||
|
||||
function normalizePackageTarget(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function isMainPackageTarget(value: string): boolean {
|
||||
return normalizePackageTarget(value).toLowerCase() === "main";
|
||||
}
|
||||
|
||||
export function isExplicitPackageInstallSpec(value: string): boolean {
|
||||
const trimmed = normalizePackageTarget(value);
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
trimmed.includes("://") ||
|
||||
trimmed.includes("#") ||
|
||||
/^(?:file|github|git\+ssh|git\+https|git\+http|git\+file|npm):/i.test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
export function canResolveRegistryVersionForPackageTarget(value: string): boolean {
|
||||
const trimmed = normalizePackageTarget(value);
|
||||
if (!trimmed) {
|
||||
return true;
|
||||
}
|
||||
return !isMainPackageTarget(trimmed) && !isExplicitPackageInstallSpec(trimmed);
|
||||
}
|
||||
|
||||
async function resolvePortableGitPathPrepend(
|
||||
env: NodeJS.ProcessEnv | undefined,
|
||||
): Promise<string[]> {
|
||||
@ -68,7 +97,14 @@ export function resolveGlobalInstallSpec(params: {
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
return `${params.packageName}@${params.tag}`;
|
||||
const target = normalizePackageTarget(params.tag);
|
||||
if (isMainPackageTarget(target)) {
|
||||
return OPENCLAW_MAIN_PACKAGE_SPEC;
|
||||
}
|
||||
if (isExplicitPackageInstallSpec(target)) {
|
||||
return target;
|
||||
}
|
||||
return `${params.packageName}@${target}`;
|
||||
}
|
||||
|
||||
export async function createGlobalInstallEnv(
|
||||
|
||||
@ -441,6 +441,20 @@ describe("runGatewayUpdate", () => {
|
||||
expect(calls.some((call) => call === expectedInstallCommand)).toBe(true);
|
||||
});
|
||||
|
||||
it("updates global npm installs from the GitHub main package spec", async () => {
|
||||
const { calls, result } = await runNpmGlobalUpdateCase({
|
||||
expectedInstallCommand:
|
||||
"npm i -g github:openclaw/openclaw#main --no-fund --no-audit --loglevel=error",
|
||||
tag: "main",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.mode).toBe("npm");
|
||||
expect(calls).toContain(
|
||||
"npm i -g github:openclaw/openclaw#main --no-fund --no-audit --loglevel=error",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to global npm update when git is missing from PATH", async () => {
|
||||
const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir);
|
||||
const { calls, runCommand } = createGlobalInstallHarness({
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import * as extensionApi from "openclaw/extension-api";
|
||||
import * as compatSdk from "openclaw/plugin-sdk/compat";
|
||||
import * as discordSdk from "openclaw/plugin-sdk/discord";
|
||||
import * as imessageSdk from "openclaw/plugin-sdk/imessage";
|
||||
@ -132,4 +133,8 @@ describe("plugin-sdk subpath exports", () => {
|
||||
const zalo = await import("openclaw/plugin-sdk/zalo");
|
||||
expect(typeof zalo.resolveClientIp).toBe("function");
|
||||
});
|
||||
|
||||
it("exports the extension api bridge", () => {
|
||||
expect(typeof extensionApi.runEmbeddedPiAgent).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@ -25,6 +25,11 @@ export {
|
||||
listWhatsAppDirectoryGroupsFromConfig,
|
||||
listWhatsAppDirectoryPeersFromConfig,
|
||||
} from "../channels/plugins/directory-config.js";
|
||||
export {
|
||||
collectAllowlistProviderGroupPolicyWarnings,
|
||||
collectOpenGroupPolicyRouteAllowlistWarnings,
|
||||
} from "../channels/plugins/group-policy-warnings.js";
|
||||
export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js";
|
||||
export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js";
|
||||
|
||||
export {
|
||||
@ -44,5 +49,6 @@ export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp
|
||||
export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js";
|
||||
|
||||
export { createActionGate, readStringParam } from "../agents/tools/common.js";
|
||||
export { createPluginRuntimeStore } from "./runtime-store.js";
|
||||
|
||||
export { normalizeE164 } from "../utils.js";
|
||||
|
||||
144
src/plugins/copy-bundled-plugin-metadata.test.ts
Normal file
144
src/plugins/copy-bundled-plugin-metadata.test.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
copyBundledPluginMetadata,
|
||||
rewritePackageExtensions,
|
||||
} from "../../scripts/copy-bundled-plugin-metadata.mjs";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeRepoRoot(prefix: string): string {
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(repoRoot);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, value: unknown): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0, tempDirs.length)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("rewritePackageExtensions", () => {
|
||||
it("rewrites TypeScript extension entries to built JS paths", () => {
|
||||
expect(rewritePackageExtensions(["./index.ts", "./nested/entry.mts"])).toEqual([
|
||||
"./index.js",
|
||||
"./nested/entry.js",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copyBundledPluginMetadata", () => {
|
||||
it("copies plugin manifests, package metadata, and local skill directories", () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-meta-");
|
||||
const pluginDir = path.join(repoRoot, "extensions", "acpx");
|
||||
fs.mkdirSync(path.join(pluginDir, "skills", "acp-router"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "skills", "acp-router", "SKILL.md"),
|
||||
"# ACP Router\n",
|
||||
"utf8",
|
||||
);
|
||||
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
|
||||
id: "acpx",
|
||||
configSchema: { type: "object" },
|
||||
skills: ["./skills"],
|
||||
});
|
||||
writeJson(path.join(pluginDir, "package.json"), {
|
||||
name: "@openclaw/acpx",
|
||||
openclaw: { extensions: ["./index.ts"] },
|
||||
});
|
||||
|
||||
copyBundledPluginMetadata({ repoRoot });
|
||||
|
||||
expect(
|
||||
fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx", "openclaw.plugin.json")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(repoRoot, "dist", "extensions", "acpx", "skills", "acp-router", "SKILL.md"),
|
||||
"utf8",
|
||||
),
|
||||
).toContain("ACP Router");
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, "dist", "extensions", "acpx", "package.json"), "utf8"),
|
||||
) as { openclaw?: { extensions?: string[] } };
|
||||
expect(packageJson.openclaw?.extensions).toEqual(["./index.js"]);
|
||||
});
|
||||
|
||||
it("dereferences node_modules-backed skill paths into the bundled dist tree", () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-node-modules-");
|
||||
const pluginDir = path.join(repoRoot, "extensions", "tlon");
|
||||
const storeSkillDir = path.join(
|
||||
repoRoot,
|
||||
"node_modules",
|
||||
".pnpm",
|
||||
"@tloncorp+tlon-skill@0.2.2",
|
||||
"node_modules",
|
||||
"@tloncorp",
|
||||
"tlon-skill",
|
||||
);
|
||||
fs.mkdirSync(storeSkillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(storeSkillDir, "SKILL.md"), "# Tlon Skill\n", "utf8");
|
||||
fs.mkdirSync(path.join(pluginDir, "node_modules", "@tloncorp"), { recursive: true });
|
||||
fs.symlinkSync(
|
||||
storeSkillDir,
|
||||
path.join(pluginDir, "node_modules", "@tloncorp", "tlon-skill"),
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
|
||||
id: "tlon",
|
||||
configSchema: { type: "object" },
|
||||
skills: ["node_modules/@tloncorp/tlon-skill"],
|
||||
});
|
||||
writeJson(path.join(pluginDir, "package.json"), {
|
||||
name: "@openclaw/tlon",
|
||||
openclaw: { extensions: ["./index.ts"] },
|
||||
});
|
||||
|
||||
copyBundledPluginMetadata({ repoRoot });
|
||||
|
||||
const copiedSkillDir = path.join(
|
||||
repoRoot,
|
||||
"dist",
|
||||
"extensions",
|
||||
"tlon",
|
||||
"node_modules",
|
||||
"@tloncorp",
|
||||
"tlon-skill",
|
||||
);
|
||||
expect(fs.existsSync(path.join(copiedSkillDir, "SKILL.md"))).toBe(true);
|
||||
expect(fs.lstatSync(copiedSkillDir).isSymbolicLink()).toBe(false);
|
||||
});
|
||||
|
||||
it("omits missing declared skill paths from the bundled manifest", () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-missing-skill-");
|
||||
const pluginDir = path.join(repoRoot, "extensions", "tlon");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
|
||||
id: "tlon",
|
||||
configSchema: { type: "object" },
|
||||
skills: ["node_modules/@tloncorp/tlon-skill"],
|
||||
});
|
||||
writeJson(path.join(pluginDir, "package.json"), {
|
||||
name: "@openclaw/tlon",
|
||||
openclaw: { extensions: ["./index.ts"] },
|
||||
});
|
||||
|
||||
copyBundledPluginMetadata({ repoRoot });
|
||||
|
||||
const bundledManifest = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { skills?: string[] };
|
||||
expect(bundledManifest.skills).toEqual([]);
|
||||
});
|
||||
});
|
||||
@ -284,6 +284,22 @@ function createPluginSdkAliasFixture(params?: {
|
||||
return { root, srcFile, distFile };
|
||||
}
|
||||
|
||||
function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?: string }) {
|
||||
const root = makeTempDir();
|
||||
const srcFile = path.join(root, "src", "extensionAPI.ts");
|
||||
const distFile = path.join(root, "dist", "extensionAPI.js");
|
||||
mkdirSafe(path.dirname(srcFile));
|
||||
mkdirSafe(path.dirname(distFile));
|
||||
fs.writeFileSync(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8");
|
||||
fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8");
|
||||
return { root, srcFile, distFile };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginLoaderCache();
|
||||
if (prevBundledDir === undefined) {
|
||||
@ -986,6 +1002,218 @@ describe("loadOpenClawPlugins", () => {
|
||||
expect(httpPlugin?.httpRoutes).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects duplicate plugin-visible hook names", () => {
|
||||
useNoBundledPlugins();
|
||||
const first = writePlugin({
|
||||
id: "hook-owner-a",
|
||||
filename: "hook-owner-a.cjs",
|
||||
body: `module.exports = { id: "hook-owner-a", register(api) {
|
||||
api.registerHook("gateway:startup", () => {}, { name: "shared-hook" });
|
||||
} };`,
|
||||
});
|
||||
const second = writePlugin({
|
||||
id: "hook-owner-b",
|
||||
filename: "hook-owner-b.cjs",
|
||||
body: `module.exports = { id: "hook-owner-b", register(api) {
|
||||
api.registerHook("gateway:startup", () => {}, { name: "shared-hook" });
|
||||
} };`,
|
||||
});
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [first.file, second.file] },
|
||||
allow: ["hook-owner-a", "hook-owner-b"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.hooks.filter((entry) => entry.entry.hook.name === "shared-hook")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
registry.diagnostics.some(
|
||||
(diag) =>
|
||||
diag.level === "error" &&
|
||||
diag.pluginId === "hook-owner-b" &&
|
||||
diag.message === "hook already registered: shared-hook (hook-owner-a)",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate plugin service ids", () => {
|
||||
useNoBundledPlugins();
|
||||
const first = writePlugin({
|
||||
id: "service-owner-a",
|
||||
filename: "service-owner-a.cjs",
|
||||
body: `module.exports = { id: "service-owner-a", register(api) {
|
||||
api.registerService({ id: "shared-service", start() {} });
|
||||
} };`,
|
||||
});
|
||||
const second = writePlugin({
|
||||
id: "service-owner-b",
|
||||
filename: "service-owner-b.cjs",
|
||||
body: `module.exports = { id: "service-owner-b", register(api) {
|
||||
api.registerService({ id: "shared-service", start() {} });
|
||||
} };`,
|
||||
});
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [first.file, second.file] },
|
||||
allow: ["service-owner-a", "service-owner-b"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.services.filter((entry) => entry.service.id === "shared-service")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
registry.diagnostics.some(
|
||||
(diag) =>
|
||||
diag.level === "error" &&
|
||||
diag.pluginId === "service-owner-b" &&
|
||||
diag.message === "service already registered: shared-service (service-owner-a)",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects plugin context engine ids reserved by core", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
id: "context-engine-core-collision",
|
||||
filename: "context-engine-core-collision.cjs",
|
||||
body: `module.exports = { id: "context-engine-core-collision", register(api) {
|
||||
api.registerContextEngine("legacy", () => ({}));
|
||||
} };`,
|
||||
});
|
||||
|
||||
const registry = loadRegistryFromSinglePlugin({
|
||||
plugin,
|
||||
pluginConfig: {
|
||||
allow: ["context-engine-core-collision"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
registry.diagnostics.some(
|
||||
(diag) =>
|
||||
diag.level === "error" &&
|
||||
diag.pluginId === "context-engine-core-collision" &&
|
||||
diag.message === "context engine id reserved by core: legacy",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate plugin context engine ids", () => {
|
||||
useNoBundledPlugins();
|
||||
const first = writePlugin({
|
||||
id: "context-engine-owner-a",
|
||||
filename: "context-engine-owner-a.cjs",
|
||||
body: `module.exports = { id: "context-engine-owner-a", register(api) {
|
||||
api.registerContextEngine("shared-context-engine-loader-test", () => ({}));
|
||||
} };`,
|
||||
});
|
||||
const second = writePlugin({
|
||||
id: "context-engine-owner-b",
|
||||
filename: "context-engine-owner-b.cjs",
|
||||
body: `module.exports = { id: "context-engine-owner-b", register(api) {
|
||||
api.registerContextEngine("shared-context-engine-loader-test", () => ({}));
|
||||
} };`,
|
||||
});
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [first.file, second.file] },
|
||||
allow: ["context-engine-owner-a", "context-engine-owner-b"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
registry.diagnostics.some(
|
||||
(diag) =>
|
||||
diag.level === "error" &&
|
||||
diag.pluginId === "context-engine-owner-b" &&
|
||||
diag.message ===
|
||||
"context engine already registered: shared-context-engine-loader-test (plugin:context-engine-owner-a)",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires plugin CLI registrars to declare explicit command roots", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
id: "cli-missing-metadata",
|
||||
filename: "cli-missing-metadata.cjs",
|
||||
body: `module.exports = { id: "cli-missing-metadata", register(api) {
|
||||
api.registerCli(() => {});
|
||||
} };`,
|
||||
});
|
||||
|
||||
const registry = loadRegistryFromSinglePlugin({
|
||||
plugin,
|
||||
pluginConfig: {
|
||||
allow: ["cli-missing-metadata"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.cliRegistrars).toHaveLength(0);
|
||||
expect(
|
||||
registry.diagnostics.some(
|
||||
(diag) =>
|
||||
diag.level === "error" &&
|
||||
diag.pluginId === "cli-missing-metadata" &&
|
||||
diag.message === "cli registration missing explicit commands metadata",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate plugin CLI command roots", () => {
|
||||
useNoBundledPlugins();
|
||||
const first = writePlugin({
|
||||
id: "cli-owner-a",
|
||||
filename: "cli-owner-a.cjs",
|
||||
body: `module.exports = { id: "cli-owner-a", register(api) {
|
||||
api.registerCli(() => {}, { commands: ["shared-cli"] });
|
||||
} };`,
|
||||
});
|
||||
const second = writePlugin({
|
||||
id: "cli-owner-b",
|
||||
filename: "cli-owner-b.cjs",
|
||||
body: `module.exports = { id: "cli-owner-b", register(api) {
|
||||
api.registerCli(() => {}, { commands: ["shared-cli"] });
|
||||
} };`,
|
||||
});
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [first.file, second.file] },
|
||||
allow: ["cli-owner-a", "cli-owner-b"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.cliRegistrars).toHaveLength(1);
|
||||
expect(registry.cliRegistrars[0]?.pluginId).toBe("cli-owner-a");
|
||||
expect(
|
||||
registry.diagnostics.some(
|
||||
(diag) =>
|
||||
diag.level === "error" &&
|
||||
diag.pluginId === "cli-owner-b" &&
|
||||
diag.message === "cli command already registered: shared-cli (cli-owner-a)",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("registers http routes", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
@ -2187,4 +2415,24 @@ describe("loadOpenClawPlugins", () => {
|
||||
);
|
||||
expect(resolved).toBe(srcFile);
|
||||
});
|
||||
|
||||
it("prefers dist extension-api alias when loader runs from dist", () => {
|
||||
const { root, distFile } = createExtensionApiAliasFixture();
|
||||
|
||||
const resolved = __testing.resolveExtensionApiAlias({
|
||||
modulePath: path.join(root, "dist", "plugins", "loader.js"),
|
||||
});
|
||||
expect(resolved).toBe(distFile);
|
||||
});
|
||||
|
||||
it("prefers src extension-api alias when loader runs from src in non-production", () => {
|
||||
const { root, srcFile } = createExtensionApiAliasFixture();
|
||||
|
||||
const resolved = withEnv({ NODE_ENV: undefined }, () =>
|
||||
__testing.resolveExtensionApiAlias({
|
||||
modulePath: path.join(root, "src", "plugins", "loader.ts"),
|
||||
}),
|
||||
);
|
||||
expect(resolved).toBe(srcFile);
|
||||
});
|
||||
});
|
||||
|
||||
@ -124,6 +124,36 @@ const resolvePluginSdkAliasFile = (params: {
|
||||
const resolvePluginSdkAlias = (): string | null =>
|
||||
resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" });
|
||||
|
||||
const resolveExtensionApiAlias = (params: { modulePath?: string } = {}): string | null => {
|
||||
try {
|
||||
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
|
||||
const packageRoot = resolveOpenClawPackageRootSync({
|
||||
cwd: path.dirname(modulePath),
|
||||
});
|
||||
if (!packageRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
|
||||
modulePath,
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
});
|
||||
const candidateMap = {
|
||||
src: path.join(packageRoot, "src", "extensionAPI.ts"),
|
||||
dist: path.join(packageRoot, "dist", "extensionAPI.js"),
|
||||
} as const;
|
||||
for (const kind of orderedKinds) {
|
||||
const candidate = candidateMap[kind];
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const cachedPluginSdkExportedSubpaths = new Map<string, string[]>();
|
||||
|
||||
function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] {
|
||||
@ -172,6 +202,7 @@ const resolvePluginSdkScopedAliasMap = (): Record<string, string> => {
|
||||
export const __testing = {
|
||||
listPluginSdkAliasCandidates,
|
||||
listPluginSdkExportedSubpaths,
|
||||
resolveExtensionApiAlias,
|
||||
resolvePluginSdkAliasCandidateOrder,
|
||||
resolvePluginSdkAliasFile,
|
||||
maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES,
|
||||
@ -701,7 +732,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
return jitiLoader;
|
||||
}
|
||||
const pluginSdkAlias = resolvePluginSdkAlias();
|
||||
const extensionApiAlias = resolveExtensionApiAlias();
|
||||
const aliasMap = {
|
||||
...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}),
|
||||
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
|
||||
...resolvePluginSdkScopedAliasMap(),
|
||||
};
|
||||
|
||||
@ -2,7 +2,7 @@ import path from "node:path";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { ChannelDock } from "../channels/dock.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { registerContextEngine } from "../context-engine/registry.js";
|
||||
import { registerContextEngineForOwner } from "../context-engine/registry.js";
|
||||
import type {
|
||||
GatewayRequestHandler,
|
||||
GatewayRequestHandlers,
|
||||
@ -15,6 +15,7 @@ import { normalizePluginHttpPath } from "./http-path.js";
|
||||
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
|
||||
import { normalizeRegisteredProvider } from "./provider-validation.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import { defaultSlotIdForKey } from "./slots.js";
|
||||
import {
|
||||
isPluginHookName,
|
||||
isPromptInjectionHookName,
|
||||
@ -238,6 +239,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const existingHook = registry.hooks.find((entry) => entry.entry.hook.name === name);
|
||||
if (existingHook) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `hook already registered: ${name} (${existingHook.pluginId})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const description = entry?.hook.description ?? opts?.description ?? "";
|
||||
const hookEntry: HookEntry = entry
|
||||
@ -473,6 +484,28 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
opts?: { commands?: string[] },
|
||||
) => {
|
||||
const commands = (opts?.commands ?? []).map((cmd) => cmd.trim()).filter(Boolean);
|
||||
if (commands.length === 0) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "cli registration missing explicit commands metadata",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const existing = registry.cliRegistrars.find((entry) =>
|
||||
entry.commands.some((command) => commands.includes(command)),
|
||||
);
|
||||
if (existing) {
|
||||
const overlap = commands.find((command) => existing.commands.includes(command));
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `cli command already registered: ${overlap ?? commands[0]} (${existing.pluginId})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
record.cliCommands.push(...commands);
|
||||
registry.cliRegistrars.push({
|
||||
pluginId: record.id,
|
||||
@ -487,6 +520,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const existing = registry.services.find((entry) => entry.service.id === id);
|
||||
if (existing) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `service already registered: ${id} (${existing.pluginId})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
record.services.push(id);
|
||||
registry.services.push({
|
||||
pluginId: record.id,
|
||||
@ -611,7 +654,28 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
||||
registerService: (service) => registerService(record, service),
|
||||
registerCommand: (command) => registerCommand(record, command),
|
||||
registerContextEngine: (id, factory) => registerContextEngine(id, factory),
|
||||
registerContextEngine: (id, factory) => {
|
||||
if (id === defaultSlotIdForKey("contextEngine")) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `context engine id reserved by core: ${id}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = registerContextEngineForOwner(id, factory, `plugin:${record.id}`, {
|
||||
allowSameOwnerRefresh: true,
|
||||
});
|
||||
if (!result.ok) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `context engine already registered: ${id} (${result.existingOwner})`,
|
||||
});
|
||||
}
|
||||
},
|
||||
resolvePath: (input: string) => resolveUserPath(input),
|
||||
on: (hookName, handler, opts) =>
|
||||
registerTypedHook(record, hookName, handler, opts, params.hookPolicy),
|
||||
|
||||
@ -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.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
"target": "es2023",
|
||||
"useDefineForClassFields": false,
|
||||
"paths": {
|
||||
"openclaw/extension-api": ["./src/extensionAPI.ts"],
|
||||
"openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"],
|
||||
"openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"],
|
||||
"openclaw/plugin-sdk/account-id": ["./src/plugin-sdk/account-id.ts"]
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
const env = {
|
||||
@ -87,6 +89,51 @@ const pluginSdkEntrypoints = [
|
||||
"keyed-async-queue",
|
||||
] as const;
|
||||
|
||||
function listBundledPluginBuildEntries(): Record<string, string> {
|
||||
const extensionsRoot = path.join(process.cwd(), "extensions");
|
||||
const entries: Record<string, string> = {};
|
||||
|
||||
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
|
||||
if (!dirent.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginDir = path.join(extensionsRoot, dirent.name);
|
||||
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(pluginDir, "package.json");
|
||||
let packageEntries: string[] = [];
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
openclaw?: { extensions?: unknown };
|
||||
};
|
||||
packageEntries = Array.isArray(packageJson.openclaw?.extensions)
|
||||
? packageJson.openclaw.extensions.filter(
|
||||
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
||||
)
|
||||
: [];
|
||||
} catch {
|
||||
packageEntries = [];
|
||||
}
|
||||
}
|
||||
|
||||
const sourceEntries = packageEntries.length > 0 ? packageEntries : ["./index.ts"];
|
||||
for (const entry of sourceEntries) {
|
||||
const normalizedEntry = entry.replace(/^\.\//, "");
|
||||
const entryKey = `extensions/${dirent.name}/${normalizedEntry.replace(/\.[^.]+$/u, "")}`;
|
||||
entries[entryKey] = path.join("extensions", dirent.name, normalizedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
const bundledPluginBuildEntries = listBundledPluginBuildEntries();
|
||||
|
||||
export default defineConfig([
|
||||
nodeBuildConfig({
|
||||
entry: "src/index.ts",
|
||||
@ -122,6 +169,12 @@ export default defineConfig([
|
||||
entry: Object.fromEntries(pluginSdkEntrypoints.map((e) => [e, `src/plugin-sdk/${e}.ts`])),
|
||||
outDir: "dist/plugin-sdk",
|
||||
}),
|
||||
nodeBuildConfig({
|
||||
// Bundle bundled plugin entrypoints so built gateway startup can load JS
|
||||
// directly from dist/extensions instead of transpiling extensions/*.ts via Jiti.
|
||||
entry: bundledPluginBuildEntries,
|
||||
outDir: "dist",
|
||||
}),
|
||||
nodeBuildConfig({
|
||||
entry: "src/extensionAPI.ts",
|
||||
}),
|
||||
|
||||
@ -58,6 +58,10 @@ export default defineConfig({
|
||||
resolve: {
|
||||
// Keep this ordered: the base `openclaw/plugin-sdk` alias is a prefix match.
|
||||
alias: [
|
||||
{
|
||||
find: "openclaw/extension-api",
|
||||
replacement: path.join(repoRoot, "src", "extensionAPI.ts"),
|
||||
},
|
||||
...pluginSdkSubpaths.map((subpath) => ({
|
||||
find: `openclaw/plugin-sdk/${subpath}`,
|
||||
replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user