Uh oh!
There was an error while loading. Please reload this page.
fix(cli): os package publish prints the server's reason, not [object Object] - #10940
Conversation
…Object]
Both request helpers in package/publish.ts built their failure text with
String(parsed?.error ?? response.statusText ?? `HTTP ${status}`). In the
declared envelope `error` is an OBJECT — { code, message } — so String()
stringified it, and the ?? chain never reached statusText because an object is
not nullish. All three call sites (package registration, version publish, icon
upload) printed the same literal regardless of what the control plane refused.
Both sites now read through a new readErrorMessage in
packages/cli/src/utils/response-envelope.ts: the declared envelope's
error.message, degrading to error.code, then a non-blank statusText, then the
status line. A blank statusText counts as absent — HTTP/2 carries no reason
phrase, and the old ?? chain kept the empty string.
The reader also accepts the flat `error: '<sentence>'` dialect. That is
measured, not assumed: /api/v1/cloud/** is served by the sibling cloud repo,
and objectui's readApiError records that the same service-cloud family answers
failures in both shapes while cloud#944 converts it. A strict envelope-only
read (#10675's readEnvelope, measured against the in-repo
/api/v1/datasources/** routes) would have replaced today's live flat dialect
with a different unreadable failure, so it is not reused here.
The two sibling readings are deliberately untouched: plugin/publish.ts already
reads correctly on both measured arms, and package/install.ts targets the
runtime's /api/v1/marketplace/install-local, a different route family.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 041fa3c5262059b001e047efaf53e695902c9d25 && git checkout 041fa3c5262059b001e047efaf53e695902c9d25
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d101d28424fcdb067277709bbbb9097a3bc2fd9 1016233cb77cd8afe5ba256d1be328cf63c20a32 && git checkout -B drift-repro 9d101d28424fcdb067277709bbbb9097a3bc2fd9 && git merge --no-ff 1016233cb77cd8afe5ba256d1be328cf63c20a32
node scripts/docs-audit/affected-docs.mjs --json 9d101d28424fcdb067277709bbbb9097a3bc2fd9
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32522541067 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10763
The defect, re-derived at head
Both request helpers in
packages/cli/src/commands/package/publish.tsbuilt their failure text the same way — at:646(postJson) and:691(postBinary), the anchors the card gives, which still land exactly there oneee2b65018:In the declared envelope (
packages/types/src/response-envelope.ts)erroris an object —{ code, message }— soString(errMsg)stringified the object. The??chain never reachedstatusText, because an object is not nullish; there was no useful fallback left to reach. Every failed publish printed the same literal regardless of what the control plane had refused, at all three call sites: package registration, version publish, and the icon upload.The premise-first measurement, and what it decided
Triage made this a dispatch condition: measure the publish routes' real failure envelope(s) before picking the read shape, and do not assume either arm. What was measured:
The three routes are
POST /api/v1/cloud/packages,POST /api/v1/cloud/packages/:id/icon, andPOST /api/v1/cloud/packages/:id/versions— all/api/v1/cloud/**.That family is served by the sibling
cloudrepo, and this repo's dispatcher explicitly refuses those paths, so no in-repo ledger can vouch for them — stated indocs/audits/2026-07-dispatcher-client-route-coverage.md§10. Two direct measurements were attempted and both are genuinely unavailable from an agent session: thecloudrepo is not readable, and the live host is refused by egress policy. Neither is a substitute for evidence, so neither was treated as one.The evidence that does exist is first-hand and from the same
service-cloudfamily:readApiErrorin objectui,packages/app-shell/src/console/marketplace/marketplaceApi.ts. Its docblock records that those routes answer failures in two shapes, mid-conversion from one to the other, tracked as cloud#944:{ success: false, error: 'a sentence' }— today, via the cloud'sfail()helper{ success: false, error: { code, message } }— the declared envelopeBoth arms are live. That is the finding, and it decides the fork the card left open.
Why the strict #10675 reader is not reused
packages/cli/src/utils/response-envelope.ts'sreadEnvelopeis deliberately strict, and its docblock is explicit that it does not also accept the legacy flat shape, because a consumer-side fallback would re-create the divergence as a second de-facto contract (Prime Directive #12). That reasoning is sound for the routes it was measured against — the in-repo/api/v1/datasources/**family, where this repo owns the producer and can fix it.It does not transfer here. Against the dialect the control plane still emits today, a strict envelope-only read would print "not the declared envelope" in place of the server's actual sentence — trading one unreadable failure for another, on what is currently the common path. The ablation below shows this directly: the flat-dialect case is the one case that already passed before the fix.
The strictness difference follows the job, not the file:
readEnvelopedecides whether a request succeeded and yields its payload. Tolerance there lets an off-spec body be read as data — that is how a second contract grows, and why it refuses.readErrorMessagedecides nothing. The caller has already seen!response.ok. The failure is reported either way; the only question is whether the operator sees the server's sentence or a placeholder.Both now live in the same file, with the distinction written out, so the next author finds one home for "reading the platform's response envelope" rather than two files inviting a copy.
The change
Both sites now call a new
readErrorMessage: the declared envelope'serror.message, degrading toerror.codewhen a refusal carries no message, then a non-blankstatusText, then the status line. Every branch yields a checked non-empty string, so no input can produce the stringified-object output again.A blank
statusTextcounts as absent, which is the second half of "there is no useful fallback": HTTP/2 carries no reason phrase, sofetchreports'', and the old??chain kept the empty string and printed nothing after the status code. The tests supplystatusText: ''exactly as an HTTP/2 response would, so no case can pass on a reason phrase the real transport would not send.The flat branch is written to be deletable on its own and its removal trigger is recorded on the function and filed as #10938.
Deliberately not accepted: a top-level
body.message. objectui tolerates one because a few other routes in that family put text there; no publish route was measured doing it, and inventing a third dialect to read is the accretion #12 forbids.The two siblings are deliberately untouched
The card notes that one package carries three readings of one envelope, and asked that siblings be touched only with per-site evidence that they are also wrong. Measured per site, they are not:
plugin/publish.ts:179— hits the same two cloud routes. Readsparsed?.error?.message ?? parsed?.error ?? response.statusText, which is correct on both measured arms. Its only weakness is on an unmeasured malformed body (anerrorobject carrying nomessage), and inventing that to justify an edit would be exactly the harmonising the card forbids.package/install.ts:275— targets the runtime's/api/v1/marketplace/install-local, a different route family that cloud#944 does not govern. It is also already immune to this defect class: itstypeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg)guard means a non-string never becomes the stringified object.So the three readings are not one bug in three places, and they are not consolidated on the assumption that they should be one. #10938 records the conditions under which they converge.
Tests
New end-to-end coverage drives the real command — the defect was at the call site, in the value handed to
printError— across all three sites, plus unit coverage for the reader.packages/clisuite: 149 files / 1650 tests passed; the two touched files verbosely: 18 passed.Reverse verification, with the direction predicted before running: reverting only the two call sites turns it red, reproducing the card's symptom verbatim —
—
4 failed | 1 passed. The one that stayed green is the flat-fail()case, which is the measurement confirming itself from the other side: the old code already handled that arm, and a strict reader would have broken it. Restoring the fix returns 18/18. The ablation subject resolves from source (the test imports relatively intosrc/), not through a dependency'sdist/, so no rebuild gates either leg; the fixture writersendErrordoes resolve through@objectstack/types'dist/, and that was built by the dependency-closure build below.Gates
Dependency closure built first (
pnpm --filter '@objectstack/cli^...' build,VERDICT command-exit 0), thenpnpm --filter @objectstack/cli typecheck(tsc --noEmit,VERDICT command-exit 0).The gate union was derived at the final commit —
node scripts/pm/dispatch-gates.mjswith no paths, letting it take its own change set from the merge base — and re-run there. All green at1016233cb7, exit codes captured before any pipe:check:changeset-gate-self-tests·check:objectui-changeset·check-empty-changeset·check-changeset-no-major·check-adr-0087-registration·check:cross-package-test-inputs·check:test-source-alias·check:type-source-resolution·check:slot-lookup·check:type-check-coverage·check:engine-double-contract·check:where-matcher·check:query-options-erasure·check-plugin-teardown-shape·check-ci-filter-parity·check:nul-bytes·docs-audit/check-affected-docsQuoting the gates' own verdict lines rather than a bare exit status:
One declared narrowing:
check:type-check-debt --re-measurewas not run locally — it needs the whole workspace closure built. It has no count this change can move:packages/clicarries notest-typecheck-debt.jsonentry, its tests are inside the tsc program its owntypecheckruns (which compiled the new files and passed), and the structural halfcheck:type-check-coverageis green above. CI runs the full ratchet.Scope
Fixes #10763because the card's subject — the read inpackage/publish.ts— is fully addressed. Out-of-scope finding filed as #10938 (the scheduled retirement of the flat accommodation once cloud#944 lands), unassigned and labelledfinding. #9559 covers the same defect class forpackages/rest's own flat dialect, a different in-repo producer; it is not addressed here and remains open. Nothing undercontent/docs/releases/**was touched, and no gate was weakened.Clause-② is no: this changes only how the CLI reads a failure response. The server sends exactly what it sent before, and no request the CLI sends changes.
Generated by Claude Code