Skip to content

✨ Upgrade the standalone xmd binary with xmd upgrade - #688

Merged
taras merged 3 commits into
mainfrom
issue-681-upgrade-command
Sep 1, 2026
Merged

✨ Upgrade the standalone xmd binary with xmd upgrade#688
taras merged 3 commits into
mainfrom
issue-681-upgrade-command

Conversation

@taras

@taras taras commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Closes #681.

Why

Somebody who installed xmd from a release asset has no package manager to ask
for 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
Installed xmd 0.11.0 (replaced 0.10.2).
Binary: /usr/local/bin/xmd
Release notes: https://github.com/taras/executable.md/releases/tag/v0.11.0

$ xmd upgrade --status
Installed version: 0.10.2
Selected release: v0.11.0 (newer)
Release notes: https://github.com/taras/executable.md/releases/tag/v0.11.0
No files were changed.

xmd upgrade [<tag>] [--status] [--allow-downgrade] [--allow-prerelease]. With
no tag it selects the latest published stable release. --status reports the
comparison and changes nothing. Installing an older release or a prerelease
needs explicit consent.

Every other installation refuses, and says who to ask instead:

Running xmd macOS or Linux Windows
Compiled binary Self-upgrade Installer or release asset
npm or Node Update with npm Update with npm
Bun Update with Bun Update with Bun
Deno or repository source Package version or checkout Package version or checkout

How it works

fixed argv grammar
  → the entrypoint states what this xmd is
  → the packaged Markdown decides everything
  → an eligible compiled host performs the two acts it is allowed

The policy is Markdown. packages/cli/src/documents/upgrade-command.md runs as a
value root under <upgrade-command> and owns the tag grammar, release
selection, 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 the
product.

Then review:

  1. specs/upgrade-command-spec.md — the complete contract.
  2. packages/cli/src/upgrade.ts — the runtime-neutral host and UpgradeAssembly.
  3. packages/cli/src/compiled-upgrade.ts — the lock, the transport, the digest,
    the candidate gate and the one rename.
  4. packages/cli/src/cli.ts and the four entrypoints.

Look carefully at:

  • openInstallation and renamesInPlace — what is owned before cleanup may
    remove 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 the
    rename.

What must stay true

  • Only an eligible compiled host has installation authority — enforced by
    UpgradeAssembly.authority being absent everywhere else and runXmd's
    assembly being a required parameter, checked by UH2, UC4–UC5 and UG12.
  • The document cannot name a release it was not shown — enforced by the
    private admission map, checked by UH25.
  • Before the rename the installed bytes are unchanged — enforced by staging
    beside the destination and never opening the installed file for writing,
    checked by UH11–UH12, UH16–UH17, UH22 and UH34 reading the bytes back.
  • After the rename the candidate is authoritative — cleanup removes only
    remaining scratch, checked by UH23.
  • One attempt owns one installation — a non-blocking advisory lock on a
    stable sidecar, checked by UH21 against a real Deno child.
  • A refusal compiles no eval block — checked by UG's compile counter, which
    requires zero on every refusing path.
  • The release and a self-upgrade choose the same artifact — one shared
    table, checked by scripts/tests/release-targets.test.ts against
    release.yml.

How to verify it

  • UG1–UG20 run the exact packaged document with deterministic components and
    host tripwires, and fail if any branch, consent rule or message drifts.
  • UG16 fails if version precedence is not exact — including two identifiers that
    differ only past Number.MAX_SAFE_INTEGER, in both directions.
  • UH27–UH28 fail if a redirect that stays inside this repository can name
    another tag or another platform's asset, or if a release's page URL need not
    name its own tag.
  • UH29–UH31, UH34 fail if a transport, lock, candidate process or candidate
    write can raise past the components instead of becoming the approved refusal.
  • UH35 fails if the topology probe can delete or overwrite a name it does not
    own; UH37 fails if a cancelled download unlinks a path without closing its
    descriptor.
  • scripts/tests/adapter-distribution.test.ts ADD6 fails if semver is pruned
    from the compiled binary.

Manually, from a build of this branch:

deno task build
cp dist/xmd /tmp/xmd-probe
/tmp/xmd-probe upgrade v0.9.1 --allow-downgrade   # real download, checksum, candidate, rename
/tmp/xmd-probe --version                          # 0.9.1

Scope

Included

  • The xmd upgrade command, its packaged policy and its compiled host.
  • One shared release-target table, now owned by the shipped CLI.
  • specs/upgrade-command-spec.md, plus the amendments the change untrues in
    specs/executable-mdx-spec.md, specs/release-process-spec.md and
    architecture.md.
  • Installation documentation on the site.

Intentionally unchanged

  • No Windows self-upgrade. A running Windows binary cannot be replaced in place.
  • No package-manager detection. The command states how this xmd is running,
    never how its files arrived.
  • No background helper and no rollback. Before the rename the installed binary
    is authoritative; after it, the new one is.
  • scripts/build-npm.ts is untouched — it already copies src/documents/.

New abstractions

  • UpgradeAssembly exists because each of the four entrypoints must state what
    it is, and only one of them may carry installation authority.
  • packages/cli/src/release-targets.ts exists because the release build and a
    self-upgrade must choose the same artifact from one table.
  • CompiledUpgradeDependencies exists so transport, bounds, the candidate
    process, the commit, the staging paths and the candidate file can be faulted
    deterministically; production supplies all six itself.
  • Each new abstraction has multiple concrete uses or a clear justification.
  • No speculative functionality is included.

New dependencies

  • Package: semver@^7.8.5 (and @types/semver@^7.7.0 for development)
  • Used for: SemVer validity and precedence in the command document.
  • Why existing dependencies are insufficient: nothing here parses semantic
    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

  • This branch is based on d1155015 and main has since moved three
    commits
    , including ✨ Let a trusted host declare exact Markdown to one execution #684, which reworks execute.ts, expand.ts and
    invocation-identity.ts. It merges cleanly and IdentityComponent's shape is
    unchanged, 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.
  • The command replaces the exact process.execPath spelling and refuses a
    symbolic link rather than resolving it. Somebody who installed xmd behind a
    link updates the link's target instead.
  • Recovery: none needed. Before the rename the installed binary is untouched;
    after it, xmd upgrade --status reports the new version.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

https://claude.ai/code/session_01TNJwcFmnt3kYSn9gGsx9u7

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 redundant comments. Inline suggestions to remove them below.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// deletes it or puts the old bytes back.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

PR #688: ✨ Upgrade the standalone xmd binary with xmd upgrade

34 files, +7146 / -50

Scope

🔴 PR has 7196 lines changed. Split into focused PRs.

🟡 7196 lines changed. PRs under 400 receive more thorough review.

🟡 34 files changed. Are all changes related?

🟡 Changes span 9 directories.

🟡 PR mixes config and source changes.

Structural

Oxlint structural signals:

  • no-empty-function ×3: packages/cli/src/compiled-upgrade.ts, packages/cli/src/cli.ts
  • no-unused-vars ×2: packages/cli/src/cli.ts

Slop

  • packages/cli/src/cli.ts:2305// document's.
  • packages/cli/src/documents/upgrade-command.md:283// fact.
  • packages/cli/src/compiled-upgrade.ts:781// Already closed, or closed by the operation that failed.
  • packages/cli/src/compiled-upgrade.ts:1608// nothing further this command can do about it and nothing to report.
  • packages/cli/src/compiled-upgrade.ts:1669// A stream that already ended has nothing left to cancel.
  • packages/cli/src/compiled-upgrade.ts:1720// The body is already released.

Oxlint slop signals:

  • no-console ×3: packages/cli/src/cli.ts

Static Analysis

Oxlint: 15 diagnostics across 4 files (9 rules)
Density: 0.002 violations/added-line

no-empty-function (3): packages/cli/src/compiled-upgrade.ts, packages/cli/src/cli.ts
no-console (3): packages/cli/src/cli.ts
no-unused-vars (2): packages/cli/src/cli.ts
no-unsafe-type-assertion (2): packages/cli/src/deno.ts, packages/cli/src/cli.ts
no-unassigned-import (1): packages/cli/src/upgrade.ts
no-shadow (1): packages/cli/src/compiled-upgrade.ts
consistent-function-scoping (1): packages/cli/src/compiled-upgrade.ts
no-floating-promises (1): packages/cli/src/cli.ts
consistent-return (1): packages/cli/src/cli.ts

Correctness

FILE: packages/cli/src/compiled-upgrade.ts, PATTERN: no-empty-function, CONCERN: Empty function may indicate unimplemented or placeholder code, QUESTION: Is this function intended to be implemented or is it extraneous?
FILE: packages/cli/src/cli.ts, PATTERN: no-console, CONCERN: Console logs may indicate debugging or unnecessary output, QUESTION: Are these logs required for functionality or can they be removed?
FILE: packages/cli/src/cli.ts, PATTERN: no-unused-vars, CONCERN: Unused variables may indicate dead code, QUESTION: Are these variables referenced elsewhere or can they be safely removed?
FILE: packages/cli/src/cli.ts, PATTERN: no-unsafe-type-assertion, CONCERN: Unsafe type assertions may hide missing properties, QUESTION: Can type safety be improved without assertions?
FILE: packages/cli/src/upgrade.ts, PATTERN: no-unassigned-import, CONCERN: Unassigned imports may indicate dead code, QUESTION: Is this import necessary or can it be removed?
FILE: packages/cli/src/cli.ts, PATTERN: no-floating-promises, CONCERN: Unhandled promises may lead to runtime issues, QUESTION: Are these promises properly handled or should they be awaited?
FILE: packages/cli/src/compiled-upgrade.ts, PATTERN: no-shadow, CONCERN: Variable shadowing may cause confusion, QUESTION: Is the shadowed variable intended or a mistake?
FILE: packages/cli/src/cli.ts, PATTERN: consistent-return, CONCERN: Inconsistent return types may lead to bugs, QUESTION: Are all code paths guaranteed to return expected types?

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
@taras
taras force-pushed the issue-681-upgrade-command branch from 3007f4e to 7f57a44 Compare September 1, 2026 13:46

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 8 redundant comments. Inline suggestions to remove them below.

// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// fact.

try {
yield* until(reader.cancel());
} catch {
// A stream that already ended has nothing left to cancel.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// A stream that already ended has nothing left to cancel.

try {
yield* until(body.cancel());
} catch {
// The body is already released.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 6 redundant comments. Inline suggestions to remove them below.

Comment thread packages/cli/src/cli.ts
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// fact.

try {
yield* until(file.close());
} catch {
// Already closed, or closed by the operation that failed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// A stream that already ended has nothing left to cancel.

try {
yield* until(body.cancel());
} catch {
// The body is already released.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// The body is already released.

@taras
taras enabled auto-merge (squash) September 1, 2026 14:25
@taras
taras disabled auto-merge September 1, 2026 14:29
@taras
taras merged commit 24573ad into main Sep 1, 2026
30 checks passed
@taras
taras deleted the issue-681-upgrade-command branch September 1, 2026 14:30
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.

Add the xmd upgrade command

1 participant