Skip to content

3.3.0: fix bundled-asset P0, UX polish, and loader regression guards#37

Merged
chenliuyun merged 12 commits intomainfrom
fix/3.3.0-bundle-assets
Apr 26, 2026
Merged

3.3.0: fix bundled-asset P0, UX polish, and loader regression guards#37
chenliuyun merged 12 commits intomainfrom
fix/3.3.0-bundle-assets

Conversation

@chenliuyun
Copy link
Copy Markdown
Collaborator

Summary

  • Fix the 3.2.2 P0 where policy new, policy validate, and the MCP policy_new tool all failed in the packed bundle because new 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) whose import.meta.url sits at the source-tree counterpart of dist/index.js.
  • Land 4 UX items from the real-account smoke: catalog search --strict + alias demotion, expanded status-sync missing-token hint, batch --skip-offline --dry-run output separation, devices watch help clarification.
  • Add four regression guards so this class of bug is caught before publish, not after.

P0: what broke and why

esbuild rewrites import.meta.url to 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.json resolved to dist/schema/v0.2.json instead of dist/policy/schema/v0.2.json.

Fix evolved through three iterations on this branch:

  1. Dual-path runtime probe (works, but runtime fallback, not a real layout fix).
  2. Unified via a top-level src/embedded-assets.ts exporting readPolicySchemaJson / readPolicyExampleYaml.
  3. Inlined the read helper as module-private (readAsset) and deleted src/utils/embedded-asset.ts entirely. 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 labelled alias-only, --strict flag restricts to type-name matches.
  • status-sync start: multi-line hint when OPENCLAW_TOKEN is missing — names the flag, env var, and config path, with a next-step command.
  • batch --skip-offline --dry-run: human output now splits Skipped (offline) from Planned (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-tick from: null note moved up.

Regression guards

  • tests/build/embedded-assets-invariant.test.ts — greps src/ for new URL(..., import.meta.url) and asserts the sole hit is src/embedded-assets.ts.
  • tests/build/dist-assets.test.ts — runs scripts/copy-assets.mjs and asserts dist/policy/schema/v0.2.json + dist/policy/examples/policy.example.yaml exist and parse.
  • .github/workflows/ci.ymlpack-install-smoke now runs on a matrix of ubuntu-latest AND windows-latest.
  • scripts/smoke-pack-install.mjs — extended from switchbot --version only to cover policy new, policy validate --json, and a full MCP stdio handshake calling policy_new against the packed binary (the third loader site).

Test plan

  • npm test → 1977 passing, 105 files
  • npm run smoke:pack-install → 4 steps green locally on Windows (--version, policy new, policy validate, MCP policy_new)
  • grep -rE 'new URL\(.*import\.meta\.url' src/ → 1 hit (src/embedded-assets.ts)
  • CI: 10 jobs including new pack-install-smoke (windows-latest)
  • After merge: gh release create v3.3.0publish.ymlnpm view @switchbot/openapi-cli@3.3.0 version

chenliuyun 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.
@chenliuyun chenliuyun merged commit 40ec757 into main Apr 26, 2026
11 checks passed
@chenliuyun chenliuyun deleted the fix/3.3.0-bundle-assets branch April 26, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant