✨ Upgrade the standalone xmd binary with xmd upgrade - #688
Conversation
| import { constants } from "node:fs"; | ||
| // `@effectionx/fs` covers the reads and the removal; it offers no `open`, | ||
| // `rename`, `chmod` or writable-mode `access`, so those four stay adapted from | ||
| // the runtime's own asynchronous primitives. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // the runtime's own asynchronous primitives. |
| } | ||
| // Committed. The candidate is the installed binary now, so it leaves the list | ||
| // of things this invocation still has to remove — nothing after this point | ||
| // deletes it or puts the old bytes back. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // deletes it or puts the old bytes back. |
PR #688: ✨ Upgrade the standalone xmd binary with
|
A person who installed `xmd` from a release asset has no package manager to ask for a new version. `xmd upgrade` selects the latest published stable release, verifies its checksum and the candidate's own reported version, and replaces the running binary with one atomic rename. The policy is Markdown, and so is the transcript. `upgrade-command.md` is an ordinary streaming text root: it owns the exact-tag grammar, release selection, semantic-version comparison, consent, the status, already-current and installation branches, and the wording of every refusal and report — and it renders them as the work happens, so a person deciding whether to let a program replace its own binary reads the rules beside the step they govern rather than a report assembled after the fact. Download, verification and replacement are three sibling top-level segments for that reason: one enclosing segment would hold the whole installation back until it ended. Only a compiled macOS or Linux binary whose platform the release publishes for declares the four phases that policy may reach. Every other entrypoint states its provenance and no authority at all, so an npm, Bun, Deno-source or compiled Windows invocation has no phase to reach and refuses in Markdown alone, compiling nothing. Release identity is the boundary: `Upgrade.Releases` mints one per admitted release and keeps the release itself, and the later phases refuse any value that private map does not hold. The compiled host alone owns the exact `process.execPath` spelling it will replace — never a link it resolved — one non-blocking advisory lock beside that file, the bounded and cancellable anonymous GitHub reads, the bytes, the digest, the staged candidate it runs for its version, and the rename. `--journal` records the run as evidence and grants it nothing. Claude-Session: https://claude.ai/code/session_01TNJwcFmnt3kYSn9gGsx9u7
Status, already-current and installation are mutually exclusive, and the document was spelling that out three times — twice by re-deriving `props.status` and `comparison` inside a condition, and once through a separately named `authorized`. One `<Switch>` over a named `outcome` says it once and lets a reader see the three branches as alternatives rather than as three independent tests that happen never to agree. Download, verification and replacement stay three sibling top-level segments guarded on the same name. They are not nested into the install case: a segment reaches the reader only when it ends, so enclosing them would withhold `Downloaded binary` and `Verified` until replacement finished. Claude-Session: https://claude.ai/code/session_01TNJwcFmnt3kYSn9gGsx9u7
3007f4e to
7f57a44
Compare
| // phases inside the install case, or inside one further `<If>`, would make the | ||
| // whole branch a single segment whose content reaches the reader only when it | ||
| // ends — which would turn the transcript back into a report written after the | ||
| // fact. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // fact. |
| try { | ||
| yield* until(reader.cancel()); | ||
| } catch { | ||
| // A stream that already ended has nothing left to cancel. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // A stream that already ended has nothing left to cancel. |
| try { | ||
| yield* until(body.cancel()); | ||
| } catch { | ||
| // The body is already released. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // The body is already released. |
| yield* response.cancel(); | ||
| } catch { | ||
| // Already released, or a response that refuses to be. Either way there is | ||
| // nothing further this command can do about it and nothing to report. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // nothing further this command can do about it and nothing to report. |
| try { | ||
| yield* until(file.close()); | ||
| } catch { | ||
| // Already closed, or closed by the operation that failed. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // Already closed, or closed by the operation that failed. |
|
|
||
| // Status reads and reports. It opens nothing, locks nothing and leaves | ||
| // no probe behind, which is what lets it be run while another upgrade | ||
| // is in progress. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // is in progress. |
|
|
||
| // Drains while the document is still producing. A consumer that fails | ||
| // raises here, and leaving this scope cancels the execution and waits for | ||
| // its teardown before anything is reported. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // its teardown before anything is reported. |
| yield* rendered.finish(); | ||
|
|
||
| // The completion value is discarded on purpose: for a text root it is the | ||
| // rendered text, and the consumer already has it. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // rendered text, and the consumer already has it. |
Six of this document's decisions compare one value against a fixed set of alternatives, and only one of them said so. The rest were spelled as parallel `<If>` elements that re-derived the same two props, or as TypeScript objects keyed by an error code — which put the policy where a reader of the Markdown could not see it, and let an unlisted key be answered by an `??` at the end of a map rather than by a branch someone wrote. Provenance, command mode, the release-read failure and each phase's failure are now `<Switch>` elements whose `<Case>` matchers are the alternatives. The `releaseReadFailures` map and the shared `installationFailure` classifier are gone, and with them two eval blocks: an authorized install now compiles three rather than seven. Splitting one shared classifier into three per-phase switches also makes each phase answer only for the codes it can actually produce. The compiled host's call graph decides that set, and the messages for every reachable pair are unchanged; what changed is that a code a phase cannot return now reaches the general fallback instead of borrowing another phase's words. Simple boolean gates, consent checks and per-phase authorization stay `<If>`. Download, verification and replacement stay three sibling top-level segments, so the milestones still reach a reader as each phase ends. Claude-Session: https://claude.ai/code/session_01TNJwcFmnt3kYSn9gGsx9u7
| // A terminal shows the transcript as it is made; a pipe receives it in | ||
| // one piece. Both drain the same stream — the difference is only when the | ||
| // bytes are handed on, which is this process's business and not the | ||
| // document's. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // document's. |
| // phases inside the install case, or inside one further `<If>`, would make the | ||
| // whole branch a single segment whose content reaches the reader only when it | ||
| // ends — which would turn the transcript back into a report written after the | ||
| // fact. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // fact. |
| try { | ||
| yield* until(file.close()); | ||
| } catch { | ||
| // Already closed, or closed by the operation that failed. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // Already closed, or closed by the operation that failed. |
| yield* response.cancel(); | ||
| } catch { | ||
| // Already released, or a response that refuses to be. Either way there is | ||
| // nothing further this command can do about it and nothing to report. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // nothing further this command can do about it and nothing to report. |
| try { | ||
| yield* until(reader.cancel()); | ||
| } catch { | ||
| // A stream that already ended has nothing left to cancel. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // A stream that already ended has nothing left to cancel. |
| try { | ||
| yield* until(body.cancel()); | ||
| } catch { | ||
| // The body is already released. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // The body is already released. |
Closes #681.
Why
Somebody who installed
xmdfrom a release asset has no package manager to askfor a new version. Today their only route is to find the Releases page, work out
which of five artifacts matches their machine, download it, check a checksum by
hand, and move it over a binary that is currently running.
What changes
Before: no
xmd upgrade. A standalone binary is updated by hand or not at all.After:
xmd upgrade [<tag>] [--status] [--allow-downgrade] [--allow-prerelease]. Withno tag it selects the latest published stable release.
--statusreports thecomparison and changes nothing. Installing an older release or a prerelease
needs explicit consent.
Every other installation refuses, and says who to ask instead:
xmdHow it works
The policy is Markdown.
packages/cli/src/documents/upgrade-command.mdruns as avalue root under
<upgrade-command>and owns the tag grammar, releaseselection, version comparison, consent, the three endings, and the wording of
every refusal and report — so somebody deciding whether to let a program replace
its own binary can read the rules rather than infer them.
Only a compiled macOS or Linux binary whose platform the release publishes
declares the two components that policy may reach. Every other entrypoint states
its provenance and no authority at all, so an npm, Bun, Deno-source or compiled
Windows invocation has no component to reach and refuses in Markdown alone,
compiling nothing.
Release identity is the boundary between the two halves.
<Upgrade.Releases>mints an invocation-local identity per admitted release and keeps the release
itself privately;
<Upgrade.Install>refuses any value that map does not hold.The document chooses among the releases it was shown and can name no other
release, target, asset or destination.
Review guide
Start with:
packages/cli/src/documents/upgrade-command.md— it is theproduct.
Then review:
specs/upgrade-command-spec.md— the complete contract.packages/cli/src/upgrade.ts— the runtime-neutral host andUpgradeAssembly.packages/cli/src/compiled-upgrade.ts— the lock, the transport, the digest,the candidate gate and the one rename.
packages/cli/src/cli.tsand the four entrypoints.Look carefully at:
openInstallationandrenamesInPlace— what is owned before cleanup mayremove it.
download— every hop is bound to the exact tag and asset, not just the host.installRelease— the order of the gates, and what is true either side of therename.
What must stay true
UpgradeAssembly.authoritybeing absent everywhere else andrunXmd'sassembly being a required parameter, checked by UH2, UC4–UC5 and UG12.
private admission map, checked by UH25.
beside the destination and never opening the installed file for writing,
checked by UH11–UH12, UH16–UH17, UH22 and UH34 reading the bytes back.
remaining scratch, checked by UH23.
stable sidecar, checked by UH21 against a real Deno child.
requires zero on every refusing path.
table, checked by
scripts/tests/release-targets.test.tsagainstrelease.yml.How to verify it
host tripwires, and fail if any branch, consent rule or message drifts.
differ only past
Number.MAX_SAFE_INTEGER, in both directions.another tag or another platform's asset, or if a release's page URL need not
name its own tag.
write can raise past the components instead of becoming the approved refusal.
own; UH37 fails if a cancelled download unlinks a path without closing its
descriptor.
scripts/tests/adapter-distribution.test.tsADD6 fails ifsemveris prunedfrom the compiled binary.
Manually, from a build of this branch:
Scope
Included
xmd upgradecommand, its packaged policy and its compiled host.specs/upgrade-command-spec.md, plus the amendments the change untrues inspecs/executable-mdx-spec.md,specs/release-process-spec.mdandarchitecture.md.Intentionally unchanged
xmdis running,never how its files arrived.
is authoritative; after it, the new one is.
scripts/build-npm.tsis untouched — it already copiessrc/documents/.New abstractions
UpgradeAssemblyexists because each of the four entrypoints must state whatit is, and only one of them may carry installation authority.
packages/cli/src/release-targets.tsexists because the release build and aself-upgrade must choose the same artifact from one table.
CompiledUpgradeDependenciesexists so transport, bounds, the candidateprocess, the commit, the staging paths and the candidate file can be faulted
deterministically; production supplies all six itself.
New dependencies
semver@^7.8.5(and@types/semver@^7.7.0for development)versions, and the draft's hand-written numeric parser was rejected during
planning. The document keeps the exact tag grammar and resolves the one false
equality the package reports past the safe-integer range; the package decides
validity and ordinary precedence.
Risks and limitations
d1155015andmainhas since moved threecommits, including ✨ Let a trusted host declare exact Markdown to one execution #684, which reworks
execute.ts,expand.tsandinvocation-identity.ts. It merges cleanly andIdentityComponent's shape isunchanged, but the two suites that execute a real document should be re-run
after a rebase before merge. The three commits are deliberately not rebased
yet: the Planner's review contract froze them.
process.execPathspelling and refuses asymbolic link rather than resolving it. Somebody who installed
xmdbehind alink updates the link's target instead.
after it,
xmd upgrade --statusreports the new version.Scope confirmation
https://claude.ai/code/session_01TNJwcFmnt3kYSn9gGsx9u7