3.3.0: fix bundled-asset P0, UX polish, and loader regression guards#37
Merged
chenliuyun merged 12 commits intomainfrom Apr 26, 2026
Merged
3.3.0: fix bundled-asset P0, UX polish, and loader regression guards#37chenliuyun merged 12 commits intomainfrom
chenliuyun merged 12 commits intomainfrom
Conversation
added 12 commits
April 26, 2026 11:23
Three loader sites used new URL('<relative>', import.meta.url) to read
policy schema JSON and policy example YAML. Under esbuild bundling,
import.meta.url points at dist/index.js instead of the source file, so
the relative paths resolved to dist/schema/... and <pkg>/policy/... —
neither exists. Symptom: policy new, policy validate, and the MCP
policy_new tool failed at runtime when the CLI was installed from the
packed tarball.
All three call-sites now route through a shared readEmbeddedAsset
helper that probes candidates relative to the caller's import.meta.url,
trying the source-tree path first (dev/tsx) and the bundle-tree path
second (prod). The helper throws with both attempted paths when neither
exists, so future drift is debuggable.
Extend scripts/smoke-pack-install.mjs to run policy new + policy
validate --json against the installed tarball. The exact bug class
that slipped past the 3.2.2 smoke would now fail before publish.
catalog search previously ranked alias-substring matches alongside exact type matches, so short stems like "bulb" surfaced alias-only rows above the actual Bulb type. Rank hits in three tiers now: 0 — type contains keyword OR exact alias match 1 — role or command-name match 2 — alias-substring-only match Within a tier the input order is preserved. Rename the result column from "matched" to "matched_on" and label tier-2 rows alias-only so human-scannable output makes the distinction obvious. Add a --strict flag that restricts hits to type-name matches; when --strict yields nothing, the "No entries match" message suggests retrying without it. JSON mode exposes _matchedOn, _tier, and a top-level strict flag.
status-sync start previously failed with a one-liner that named only the flag and the env var. Replace with a multi-line hint that lists both options (--openclaw-token flag, OPENCLAW_TOKEN env var), explains the token comes from the OpenClaw server admin (same token used by events mqtt-tail --sink openclaw), and recommends verifying with switchbot status-sync status after the start succeeds. Mirror the same treatment for missing-model. Tests assert the new hint includes the flag name, the env-var name, and the status-sync status verify step for both error paths.
batch --skip-offline --dry-run emitted a single "Would POST" block that conflated filtered-offline devices with the ones that would actually have been POSTed. In the human-readable table, split the output into two explicitly labelled sections: "Planned (dry-run):" lists the devices that would have received a command, and "Skipped (offline):" lists the devices the --skip-offline filter removed. Extend the summary line with planned=N, skipped_offline=M alongside existing totals. JSON mode already separated these keys — no schema change. Offline-skipped devices continue to be filtered out of the inner request path before any POST would fire, so no per-device "[dry-run] Would POST" noise is produced for them. Test added to pin that.
devices watch --help previously led with a code block implying the
default format was JSONL, which confused users running without --json
and seeing a human-readable table instead. Update the description to
call out "human table by default; JSONL with --json for agents", move
the seed-tick explanation ("from": null on the first poll) near the
top of the help body, and label the --json example block explicitly
as the agent-friendly form. No behavior changes.
…+ UX polish Groups the preceding commits into a minor release: - P0 fix: resolve embedded assets via dual-path probe (policy/mcp) - feat: catalog search --strict + alias-only demotion - feat: status-sync start multi-line missing-token hint - fix: batch --skip-offline --dry-run output separation - docs: devices watch help text clarifications package.json and package-lock.json are bumped together; README's upgrade-check example and CHANGELOG heading follow.
… module The previous commit shipped the P0 fix as a runtime fallback: the shared readEmbeddedAsset probed a source-tree path first, then a bundle-tree path. That worked but left the codebase with two parallel path conventions and a helper that had to know about both. Root cause of the drift: the loader call-sites (src/policy/schema.ts, src/commands/policy.ts, src/commands/mcp.ts) live at mismatched depths relative to the asset subtree, while the bundle entry (dist/index.js) sits at the top of dist/. No single relative path could resolve the same asset from all three source locations AND from the bundle entry. Fix: introduce src/embedded-assets.ts at the top of src/ — the exact source-tree counterpart of dist/index.js — and funnel all three loader sites through its two exports (readPolicySchemaJson, readPolicyExampleYaml). Because embedded-assets.ts and dist/index.js share the "top of tree" position, `./policy/schema/...` and `./policy/examples/...` resolve identically in dev (via tsx) and prod (via the bundle). The helper's candidate-list parameter collapses to a single relPath; the "runtime fallback" concept is gone. Invariant preserved: no call-site in src/ uses new URL(..., import.meta.url) + fileURLToPath directly. Grep confirms readEmbeddedAsset has exactly one caller (embedded-assets.ts), which is the only module that should. Smoke coverage unchanged — scripts/smoke-pack-install.mjs still exercises both loader paths against the packed tarball. Verified: pack-install smoke runs clean end-to-end on the refactored code.
The first draft of the 3.3.0 Fixed section described the intermediate dual-path probe. The shipped code routes all three loader sites through src/embedded-assets.ts (single path, no runtime fallback), so update the CHANGELOG text to match what users actually install.
…helper The previous refactor left src/utils/embedded-asset.ts exporting a generic readEmbeddedAsset(metaUrl, relPath) whose correctness depended on a JSDoc-only rule — "callers MUST sit at the top of src/". That constraint was enforced by comments, so a future contributor could import the helper from a deep module under src/commands/ or src/policy/ and silently re-introduce the bundle-vs-source path drift the last commit fixed. Make the constraint structural: delete src/utils/embedded-asset.ts and inline the three-line file-read function into src/embedded-assets.ts as a module-private readAsset(relPath). It uses this module's own import.meta.url directly, and it isn't exported — no import path exists for callers outside embedded-assets.ts, so the path-drift trap cannot be re-opened by convention alone. Grep invariant after this change: new URL(..., import.meta.url) has exactly one hit in src/ — inside embedded-assets.ts itself. Two stale readEmbeddedAsset references in scripts/smoke-pack-install.mjs comments are updated to name the new exports.
- embedded-assets-invariant.test.ts greps src/ for new URL(..., import.meta.url) and asserts src/embedded-assets.ts is the sole hit. A deep-module caller that re-introduces the source-vs-bundle depth drift lights this up before it can ship. - dist-assets.test.ts runs scripts/copy-assets.mjs and asserts the files the loader expects (dist/policy/schema/v0.2.json, dist/policy/examples/policy.example.yaml) are present and non-empty. Pins the other side of the contract. Together these reproduce the guardrails that would have caught the 3.2.2 P0 at unit-test time, independent of the packed-install smoke.
Locally verified on Windows via execFileSync + shell: true against the .cmd shim. The actual failure mode from 3.2.2 was path-layout-sensitive, and Windows asset lookups go through different path normalization than POSIX — a packed-tarball smoke that never runs under Win runners is a gap, even with the new dist-assets unit test covering the happy path.
scripts/smoke-pack-install.mjs now spawns the packed binary in MCP stdio mode, completes the initialize/tools/call handshake, and asserts the policy_new tool actually writes the file. This closes the last loader-site gap: CLI policy new and policy validate already exercised readPolicyExampleYaml/readPolicySchemaJson, but the MCP handler can regress independently if the SDK bootstrap or stdio wiring breaks in the packaged tarball. Handles Windows via shell:true on spawn (matches the existing runBin helper). Graceful shutdown on stdin close; 5s hard-kill safety net.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
policy new,policy validate, and the MCPpolicy_newtool all failed in the packed bundle becausenew URL(..., import.meta.url)resolved against the wrong depth. All embedded-asset reads now route through a single top-level module (src/embedded-assets.ts) whoseimport.meta.urlsits at the source-tree counterpart ofdist/index.js.catalog search --strict+ alias demotion, expandedstatus-syncmissing-token hint,batch --skip-offline --dry-runoutput separation,devices watchhelp clarification.P0: what broke and why
esbuild rewrites
import.meta.urlto the bundle entry (dist/index.js). Three loader sites (src/policy/schema.ts,src/commands/policy.ts,src/commands/mcp.ts) wrote their relative paths against the source-tree layout, so in the packed tarball./schema/v0.2.jsonresolved todist/schema/v0.2.jsoninstead ofdist/policy/schema/v0.2.json.Fix evolved through three iterations on this branch:
src/embedded-assets.tsexportingreadPolicySchemaJson/readPolicyExampleYaml.readAsset) and deletedsrc/utils/embedded-asset.tsentirely. No generic helper is exported, so no deep-module caller can import it and re-introduce the depth drift.Invariant (now structural):
grep -rE 'new URL\(.*import\.meta\.url' src/yields exactly one hit —src/embedded-assets.ts.UX changes
catalog search: three-tier ranking (exact type > role/command > alias-only), alias hits labelledalias-only,--strictflag restricts to type-name matches.status-sync start: multi-line hint whenOPENCLAW_TOKENis missing — names the flag, env var, and config path, with a next-step command.batch --skip-offline --dry-run: human output now splitsSkipped (offline)fromPlanned (dry-run); offline-skipped devices no longer emit[dry-run] Would POST ....devices watch --help: description clarifies default is human text (JSONL via--json); seed-tickfrom: nullnote moved up.Regression guards
tests/build/embedded-assets-invariant.test.ts— grepssrc/fornew URL(..., import.meta.url)and asserts the sole hit issrc/embedded-assets.ts.tests/build/dist-assets.test.ts— runsscripts/copy-assets.mjsand assertsdist/policy/schema/v0.2.json+dist/policy/examples/policy.example.yamlexist and parse..github/workflows/ci.yml—pack-install-smokenow runs on a matrix ofubuntu-latestANDwindows-latest.scripts/smoke-pack-install.mjs— extended fromswitchbot --versiononly to coverpolicy new,policy validate --json, and a full MCP stdio handshake callingpolicy_newagainst the packed binary (the third loader site).Test plan
npm test→ 1977 passing, 105 filesnpm run smoke:pack-install→ 4 steps green locally on Windows (--version,policy new,policy validate, MCPpolicy_new)grep -rE 'new URL\(.*import\.meta\.url' src/→ 1 hit (src/embedded-assets.ts)pack-install-smoke (windows-latest)gh release create v3.3.0→publish.yml→npm view @switchbot/openapi-cli@3.3.0 version