100 Commits
Author SHA1 Message Date
fkysly 4ceda5d2b7 release 1.66.3: a release-age exclusion is never widened to a bare name
1.66.2 rewrote `name@a || b` release-age exclusions into bare package names.
That exempts every version of the package from the pnpm release-age cooldown,
which is wider than what the profile's file declares — and the market pins
exact versions precisely so a fresh install cannot silently land on an older
release (#594). The review on #733 had settled that the union spelling is a
documented pnpm form and that the 80 GiB abort reported there is pnpm's to
fix, and the reporter's own follow-up could no longer reproduce it.

This release restores merging: several rules for one name become one union of
the versions the file already lists, a union that is already the only rule for
its name is left alone, and a bare name stays bare. The merge still runs
before the command rather than after a failure, which is what keeps #732's
every-command-fails state from arising at all.
2026-09-28 00:27:14 +08:00
fkysly f81e7df3aa revert(install): never widen a release-age exclusion to a bare package name (#733)
1.66.2 replaced the #732 duplicate merge with a rewrite of `name@a || b` into
a BARE package name. That was wrong, and the review on #733 had already said
so before it shipped:

- `name@a || b` is a documented pnpm spelling (the validator's "Use exact
  versions only" is about ranges and name patterns, not exact-version unions);
- a bare name exempts EVERY version of that package from the release-age
  cooldown, which is wider than what the file declares. The market pins exact
  versions precisely so a fresh install cannot silently land on an older
  release (#594), so widening the exclusion list works against that;
- the 80 GiB abort it was meant to avoid could not be reproduced by review or
  by the reporter's own follow-up, and pnpm's maintainers took it upstream
  (pnpm/pnpm#15867).

Back to merging: several rules for one name become one union of the versions
the file already lists, a union that is already the only rule for its name is
left exactly as written, and a bare name stays bare. Nothing is widened, and
nothing pnpm wrote is rewritten into a form it did not ask for.

Kept from 1.66.2: the merge runs BEFORE the command instead of after a
failure, and covers every verb that reads the key (add, remove, install,
update). That ordering is what keeps the #732 failure from happening at all,
and it costs one small file read.
2026-09-28 00:26:27 +08:00
fkysly 1c563e52db fix(desktop): an explicit profile: desktop cannot fall back to the forbidden CLI (#744)
The dsh CLI refuses a profile named `desktop` outright, so the branch the
market takes matters more than which name it uses. An operator who names that
profile explicitly in cordis.yml — which is the workaround a host that hides
`profileContext` leaves them (#744) — was routed to the CLI branch anyway,
where every install, update and uninstall fails for certain.

The official branch now also covers a configured `desktop`, and the
configuration still wins for the NAME; only the branch is no longer chosen by
a rule that is guaranteed to fail. The launcher's directory rides along only
when the launcher is the one that named this profile, so a configured profile
never inherits another one's path — the existing #639 rule is unchanged, and
its test still passes.

`createOfficialDesktopRuntime`'s third parameter becomes optional: the
manager bridge takes the profile by NAME and never used it, and with a
configured profile there is no launcher directory to pass.

Verified by mutation: restoring the old condition turns the new case red
(`officialFactoryArgs` empty — the CLI branch).
2026-09-28 00:22:36 +08:00
fkysly 6fe861567d fix(pnpm): classify a patch pnpm refuses to use, and name it (#740)
`patchedDependencies` keys on `pkg@exactVersion`. When the installed version
moves past that key, pnpm 12 answers the stale patch with
ERR_PNPM_UNUSED_PATCH — a HARD error that writes NOTHING at all. So an update
of that plugin is refused in full: the version stays where it was, the
market's log holds only exit=1, and the user reads "the update did not apply"
for what is really a patch that no longer matches.

`ERR_PNPM_PATCH_FAILED` was already classified (it installs the package
UNPATCHED, and the damage shows at the next boot). This is the other half of
the same story and the more confusing one, because there is no unpatched
install to discover later and no second error to follow.

The new branch names the entries pnpm listed and says where to fix them: retarget
the patch and its pnpm.patchedDependencies key to the new version, or drop both
when the release already carries the fix. It stays the user's patch — the market
does not guess a retarget — so nothing is repaired automatically.

The extraction the report proposed matched to the end of the line, which in the
ndjson stream (where the same sentence arrives inside a JSON string) captured
`"}}` as part of the package name; the test for that shape caught it, and the
match now stops at the quote.
2026-09-28 00:19:22 +08:00
fkysly 9bc6120ba5 fix(check): a host peer's version is not read from another installation (#726)
A Desktop host that cannot locate its own installation falls back to the
profile anchor's parent search paths, and `<profiles>/node_modules` is shared
by EVERY profile. On a machine that also runs a global npm CLI — the
reporter's, with the Desktop app at 0.1.7-rc.2 and the CLI at 0.1.5-rc.2 —
that tree is the other installation's closure, so the profile-visibility
fallback read 0.1.5-rc.2 as "the host" and the market reported a healthy
install as "introduced host-compatibility risks — @deepseek-ai/dsh@>=0.1.7-rc.2
vs 0.1.5-rc.2", with a one-click rollback of a working plugin.

For a host-plane peer (`@deepseek-ai/*`) the version now comes from the plugin
itself (a nested copy), then the profile's own node_modules, then the located
installation — never from the shared workspace root. With none of those the
version is UNKNOWN rather than wrong, which is the rule #676 already settled
for bundles; the market's pre-flight compatibility check is unaffected, since
it asks the host detector (`dshHostInfo`) rather than this report.

Verified by mutation: reverting the guard makes both new tests fail, and the
failure IS the report — 0.1.5-rc.2 leaks back in as the resolved peer.

Not covered: the other route to a wrong version — an auto-detected install dir
that itself resolves to the global CLI (`findDshInstallDir()` when the host
cannot be located). The reply asks for the diagnostics datum that separates
the two: the path the version was read from.
2026-09-27 00:24:58 +08:00
fkysly 6450b0a567 release 1.66.2: a release-age exclusion is never written as a version union (#733)
A machine-killer, and one this market had started producing itself.

pnpm writes `minimumReleaseAgeExclude` in forms it then cannot read back. One
is a shadowed duplicate rule (#732). The other, reported separately as #733 on
pnpm 12.4.1, is `name@v1 || v2 || …` — a union its own validator rejects, and
one that aborts the process on a single 80 GiB allocation when the resolver
evaluates it. The reporter watched the machine lock up for about 200 seconds,
the page file grow to 22 GB, and pnpm exit on 0xC0000409 with no error text of
its own. Their A/B: the list as written aborts in 209s, deleting the block
takes 3.2s, and writing bare package names instead takes 3.9s.

1.66.1's repair for #732 merged the two rules into a union, so it produced the
second shape. It now writes ONE BARE NAME per affected package — the spelling
that cannot be shadowed, carries no union, and stops pnpm's auto-collect — and
only for entries pnpm already wrote in one of those two broken forms. The
rewrite runs before the pnpm invocation rather than after a failure, because
the abort leaves nothing to classify, and covers every verb that resolves the
dependency graph.
2026-09-27 00:16:47 +08:00
fkysly f063c3b8cd fix(install): a release-age exclusion is never written as a version union (#732, #733)
pnpm writes this key in forms it then cannot read back, and 1.66.1's repair
adopted one of them. Two reports, one shape:

- #732: pnpm 11.7.0 appends a second rule for a package that already has one
  and honours only the FIRST per name, so its own entry is dead and every
  later command in that profile fails lockfile verification.
- #733: pnpm 12.4.1 folds the versions it approves into `name@v1 || v2 || …`,
  a form its own validator rejects ("Use exact versions only"), and evaluating
  one aborts the process on a SINGLE 80 GiB ALLOCATION — the machine locks up
  for minutes and pnpm prints no error at all. Measured there by A/B: the list
  as written aborts in 209s, deleting the block takes 3.2s, and replacing it
  with bare package names takes 3.9s.

1.66.1 merged the duplicates into a version union, which is the second shape:
on pnpm 12.4.1 that turned a benign pair (first rule exact and live, second
rule dead) into one live union for the resolver to evaluate. So the repair no
longer produces it.

Each package whose entry is a union, or has several rules, is now written as
ONE BARE NAME: it cannot be shadowed, it stops pnpm's auto-collect for that
package outright, and it carries no union. It is a wider statement than a
version list — that package stops being age-gated — so it is spent only on
entries already in one of those two broken forms, never on a package the file
lists as a single exact version, and a pure duplicate of one exact version
collapses to that one line, which changes no policy at all.

The rewrite also moved BEFORE the run instead of after a failure. The union
shape aborts with no output to classify, so a repair waiting on a failure
would never fire; and it now covers every verb that resolves the graph
(add, remove, install, update), not just add/remove.

Self-healing for the profiles already carrying unions: the next plugin
operation rewrites them before pnpm reaches the key.
2026-09-27 00:16:31 +08:00
fkysly d8b53972e6 fix(install): repair the shadowed release-age rules on the declined-bypass path too (#732)
Follow-up to the fix released in 1.66.1, from the third independent
reproduction on the issue.

- The duplicate-rule merge now runs for every release-age violation on an
  add/remove, not only where the caller allowed the one-shot bypass. The
  merge restores what the file already declares, which is not the same act as
  relaxing the profile's age policy, so a caller that declined the bypass
  (#594) still gets the file repaired — and the command itself is re-run
  unchanged, with no option involved. A rule the user made order-independent
  (a bare package name) is kept bare: the merge never narrows it back to the
  versions it happened to list.
- The rollback-unavailable message for an inconsistent npm capture now names
  all three versions — what was on disk, what pnpm-lock.yaml records, and what
  a rollback would have reinstalled. "pnpm-lock.yaml does not match it" left
  the user nothing to act on; the reproducer's own way out was to align
  package.json with the installed build, and the message now says so.
2026-09-26 09:57:18 +08:00
fkysly 297bb009fa release 1.66.1: the desktop profile's plugin operations work again (#732)
The official desktop bridge takes exactly `add <target>` or `remove <target>`,
and every recovery step this market added to a command — the #39 one-shot
release-age bypass, --force on the update rollback, --config.* and
--no-frozen-lockfile generally — was therefore refused with exit 127. The
panel reported that refusal as "this desktop operation is not supported" for
operations that are supported, and the rollback's failure read as "restoration
of the previous build could not be verified" while node_modules kept the bad
build.

Behind it, pnpm 11.7.0 appends a second `minimumReleaseAgeExclude` rule for a
package that already has one and then honours only the first, so its own entry
is shadowed and every later command in that profile fails lockfile
verification. The market's self-update plants that pair, which is why the
reporter lost install, update and uninstall on that profile for two days.

Both are fixed in the commit before this one: the runtime now declares whether
it accepts the market's options and the recovery steps are left out where it
does not, the rollback sends the bare exact target its manager pipeline
materializes, and a release-age violation first merges the shadowed duplicate
rules — a repair of the file, so it needs no option and works on that host
too. The bridge names the option and the way out instead of calling the
operation unsupported.
2026-09-26 09:28:54 +08:00
fkysly 78de8d3d94 fix(desktop): never hand a market option to a host that refuses options (#732)
The official Desktop bridge takes exactly `add <target>` or `remove <target>`
and answers any other argv with exit 127 — which the panel reported as "this
desktop operation is not supported" for operations that ARE supported,
dressed in one of the market's own options. Two consequences on that host:
every recovery step that decorates a command with an option was impossible,
and the update rollback's `add --force <override> <target>` failed as well,
so a failed update reported "restoration of the previous build could not be
verified" while node_modules kept the build that failed.

- `PluginCommandRuntime.acceptsMarketPnpmFlags` declares the capability; the
  official desktop runtime sets it false, and the route layer reads it once.
- `withHoistRecovery` skips its four option-carrying recoveries there, and
  says so, because three of their classifier messages state that the market
  retried — a retry that could not happen.
- The rollback paths send what that host does accept: the bare exact target,
  which its own manager pipeline materializes. `install` is not expressible
  there at all, so the manifest rematerialization uses the restored pin's
  exact version, and says which package it could not rebuild when the pin is
  a range.
- The bridge's refusal now names the option and the way out.

Root cause A of the same report is fixed too. pnpm 11.7.0 APPENDS a second
`minimumReleaseAgeExclude` rule for a package that already has one, and then
honours only the FIRST per name — so its own new entry is shadowed, the young
version stays unexcluded, and EVERY later command in that profile fails
lockfile verification. The market's own self-update plants exactly that pair,
which is why every plugin operation on that profile stopped working. A
release-age violation now first merges the same-name rules. That is a repair
of the file rather than a bypass of the policy (the union of what the file
already declares is unchanged), so it needs no option and works on the host
that refuses them. Verified against the report's own measurement: the merged
file passes `Lockfile passes supply-chain policies`.
2026-09-26 09:28:11 +08:00
fkysly 1f1ed901a6 docs(client): the settings tab did not replace the bundle page seat (#722)
The #722 comment claimed the `plugins.bundle.config` registration never
fires because no host declares that slot. 0.1.7 does declare it
(`ui-plugin-manager`: a bundle's own configuration, keyed by the bundle's
package name, rendered on the bundle's page), and the market renders there
on its bundle's page in the Plugins section. What actually went silent on
that line was the settings-list seat, when `settingsScope` was renamed.

Both seats are deliberate and stay.
2026-09-26 09:04:07 +08:00
fkysly 63a6467c70 ci: fail loudly when the site falls behind the catalog
The 2026-09-09 -> 2026-09-25 freeze was invisible. Twelve consecutive
scheduled build-site runs failed and dshmarket.com kept serving the
2026-09-08 catalog: 3,363 plugin pages live against 4,347 in the
catalog, so 1,082 plugins answered 404 — and nothing outside the
repository changed to say so.

This adds a daily check that reads the deployed site and compares its
plugin-page count against the catalog the build consumes, failing when
the site is more than 300 entries behind. Measured 2026-09-03..09-25 the
catalog grew 62.5 entries/day, 205 on its busiest day, so a burst rides
through while a freeze is caught inside its first week rather than its
third.

It measures the deployed artifact, not CI's own report, so it also
covers a build that never runs at all: GitHub disables a scheduled
workflow after 60 days of repository inactivity, which no failure log
can show.
2026-09-26 02:36:13 +08:00
fkysly 1b1fff56b6 release 1.66.0: the market's seat in the Plugins section, and four reported defects
Three of these are fixes for defects that reached users on 0.1.7, and one is
the entry point the Plugins section was missing.

- #719 — a restart could deadlock: the helper declared the replacement dead at
  28s, the recovery surface took the port, and the replacement — which binds at
  ~42-45s on a source-run host — died on EADDRINUSE. Every later start hit the
  surface still holding the port. The helper now watches for 45s (a replacement
  that exits still ends the wait early), carries the pid through the handoff,
  and the surface waits for that process to be gone before binding, because the
  port alone cannot tell "dead" from "still starting".
- #722 — the settings card vanished on 0.1.7. `settingsScope` and
  `settings.plugin.item` were removed by that release, and `plugins.bundle.config`
  — the seat the code assumed — is not in its slot contract at all. The card
  now follows `settings.plugins.tab`, which is also the market's entry point in
  the Plugins section.
- #731 — the diagnostics badges were white on white in dark mode:
  `--dsw-alias-brand-primary` is an ink token (near-white in dark), and the
  market used it as a background with a hardcoded #fff. Four more surfaces had
  the same shape, including an avatar letter that was invisible in LIGHT mode.
  A stylesheet guard now refuses a themed background with a literal text colour.
- #723 — a tag-pinned git install reported an update forever: the smart-HTTP
  path resolved the annotated tag's OBJECT instead of the peeled `^{}` commit
  the lockfile records. #597 fixed the GitHub half; this is the other half.

Carried from the unreleased work: the scan result moved off the cards into the
install dialog behind a disclosure (only the install-time script is shown up
front, because 73% of all red lines describe what plugins normally do), and 120
strings were rewritten against the ux-writing standards in both languages.

77 files, 1842 tests. AGENTS.md records the product design principles these
decisions were made against.
2026-09-26 01:22:53 +08:00
fkysly f2971fae13 feat(client): the market gets its seat in the host's Plugins section (#722)
0.1.7 removed the `settingsScope` service and the `settings.plugin.item` slot
that hung off it, and the replacement this code assumed — `plugins.bundle.config`
— is not in that release's slot contract at all: it appears nowhere in the
packed 0.1.7 client, while `settings.plugins.tab` is declared in its types, its
docs and its runtime registrations. A registration into a slot nobody declares
is silent by construction, so the market's card simply disappeared on that line,
which is what the report said.

The host's own contract describes `settings.plugins.tab` as "one page inside the
Plugins settings section … the section owner renders localized entry labels as
tabs and mounts each contribution inside its corresponding tab panel" — the
official seat for what the market wants, and the entry point that was asked for.
The card follows the slot, as it does everywhere else: `settings.plugin.item` on
the older line, `plugins.bundle.config` where a host declares it, and now the tab.

Verified on a real 0.1.7-rc.2 host: the Plugins section renders `Plugin list`
and `Plugin Market` as sibling tabs, and the market's card is on the second.
2026-09-26 01:22:07 +08:00
fkysly 466732c54c fix(recovery): do not take the port from a replacement that is still starting (#719)
The restart helper declared the replacement dead 20s + 8s settle after spawning
it and handed the port to the recovery surface. A source-run host with a large
plugin tree reaches `webserver.listen` at ~42-45s, so on that timeline the
surface bound the port first and the replacement died on EADDRINUSE — and the
process just killed was the real host, so every later start hit the recovery
surface still holding the port. A forgotten-page timeout meant it never came
back on its own.

Both sides of the handoff lost the race by design, so both are fixed:

- the helper watches for 45s + settle, matching the budget src/recovery.ts
  allows a boot. A replacement that EXITS still ends the wait early, which is
  the common failure, so a genuinely broken composition is not made slower;
- the helper passes the replacement's pid through the handoff, and the recovery
  surface waits for that process to be gone before it binds — the port alone
  cannot tell "dead" from "still starting", and that is exactly what it was
  being asked to decide. The wait is bounded by the same 30s a released port
  gets, and it says which of the two it saw.

Verified by running the real helper and the real surface against a live
stand-in for a slow boot: with the process alive the port stays unanswered
(the assertion goes red without the wait), and the surface comes up once it
exits. The two helper tests that shrank the old literal to keep the suite fast
now shrink the new one.
2026-09-26 01:15:58 +08:00
fkysly 1887bd0015 fix(updates): read the peeled commit of an annotated tag (#723)
A tag-pinned git install advertises twice: the tag OBJECT on `refs/tags/<t>`
and the COMMIT it points at on `refs/tags/<t>^{}`. `resolveGitRemoteHead` ran
the same regex over `heads` then `tags` and stopped at the first hit, so a tag
resolved to the tag object — which a pnpm lockfile never records. `current` was
the commit, `latest` was the tag object, the two could not be equal, and the row
claimed an update forever; applying it reinstalled the same commit and reported
"version did not change". #597 fixed exactly this on the GitHub path
(`resolveHeadCommit`); the smart-HTTP path self-hosted git uses kept the bug.

The tag namespace now checks `^{}` first and falls back to the direct ref, so
lightweight tags — which advertise no peeled line — resolve as before.

Two cases added, both in the gitea harness the #525 work built: the annotated
tag resolves to the commit and reports no update, and the lightweight tag still
resolves. Removing the peeled branch turns the first red and leaves the second
green, which is the pair the fix has to satisfy.
2026-09-26 01:01:37 +08:00
fkysly 451fbd3bc3 fix(client): the diagnostics badges were painted with an ink token (#731)
`--dsw-alias-brand-primary` is an INK colour: the host resolves it to #0f1115
in the light theme and to #f9fafb in the dark one, and uses it for text and
strokes while the surface underneath is a different token. The market had it as
a badge *background* with a hardcoded `#fff` label, so in dark mode on 0.1.7 the
fill and the text were both #f9fafb — white on white. The bundle "official"
badge and every row of the override list rendered as empty white pills, which is
what the report showed; the numbers were still there, invisible.

Verified on a real 0.1.7-rc.2 host, both themes, computed styles:

- before: `ovByTag` / `diagBadgeOfficial` bg rgb(249,250,251), colour
  rgb(255,255,255) — identical channels, nothing to read
- after: dark bg rgb(249,250,251) colour rgb(15,17,21); light bg
  rgb(15,17,21) colour rgb(255,255,255)

The host's own filled pair is `button-primary-fill` + `label-primary-foreground`
(its primary Button and Pill use exactly that) — both with the old pair as the
fallback, so hosts that predate those tokens are unchanged. The same sweep found
four more surfaces hardcoding white on a themed fill, and the avatar letter that
was white on `bg-layer-2` — which is white in the light theme.

`tests/style-tokens.spec.ts` keeps it from coming back: no rule may set a
themed background and a literal text colour together. Deliberately not checked:
an ink token *as* a background — that is the correct fill for a progress bar and
for the 9% tints the market already uses.
2026-09-26 00:57:42 +08:00
fkysly 1e80b3a929 feat(client): the scan result moves into the detail dialog, and 120 strings get read as copy
Two changes to what a reader of the market actually sees.

**The capability disclosure leaves the cards.** It sat on both card renderers
as a row of chips plus amber red lines. It moved into the install dialog: the
card now says nothing about the scan, the dialog leads with the one line that
needs a decision before the install runs — a script that executes AT install
time, where there is no afterwards to inspect — and everything else is behind
a disclosure the reader opens on purpose, next to the blind-spot sentence and
the scan date.

The reason is the amber. 354 of the catalog's 483 red lines are the same one
("reads credentials/secrets AND has network access"), which is what a plugin
that calls a model normally does; flagging that as urgent on every card is how
a warning gets trained away, and the reader who has learned to skip the amber
line will skip the install-time script too. So urgency is now a rule rather
than a rendering: `redLineIsUrgent` names the two families that fire during
the install, and the other three are stated as facts.

**Every scanner sentence family is now translated.** Two of the five shapes
fell through to English on a Chinese card — the reported "uses literal IP
198.18.0.0 for network access" was one of them — and `tampers with a core
bundle` had no copy at all. They are matched by their stable prefix so the
parenthesised detail can change without falling back.

**The copy sweep.** 643 keys, both languages, reviewed against the ux-writing
standards: jargon that had reached the surface (bundle / profile / 凭据 /
解析依赖 / 幂等 / 实例 / disable-carrier), error messages with no next step,
the same concept named two ways, and Chinese and English that had drifted
apart. 120 strings changed; "未检出 / 未被判定" became "没有扫描到。这不等于
安全。" so the absence of a finding stops reading as a finding of absence.

Tests: 76 files, 1837 pass. Two assertions that quoted the copy verbatim now
compare against the copy's own template, so a wording change fails only a
wiring change, and the #485 case moved off a prefix match that the rewritten
hint had made ambiguous.
2026-09-26 00:42:41 +08:00
fkysly 092c3ace39 revert: keep the download mark focusable — the reviewer was wrong
b43798f removed `tabIndex={0}` from the rolling-window mark to match the other
byline spans. That was an aesthetic argument against a deliberate, tested
accessibility affordance: the author asserted the focusability on purpose
(tests/client/market-section.client.spec.tsx:4899, inside the case that covers
this disclosure), and the tooltip's paragraph — the sentence that says the
number is not lifetime downloads or unique users — is otherwise hover-only.

It also shipped broken. The commit went out with that case failing, because a
`| tail` swallowed vitest's exit status before the `&&` chain that pushed it.
Reverting restores both the source and the rebuilt client artifact.

The rest of #721 still stands as reviewed in b43798f's message: source fields
present for every entry that reports a count, artifact matches a fresh build,
and the assertions are load-bearing under mutation.
2026-09-25 23:57:34 +08:00
fkysly b43798f5f0 fix(client): the download disclosure is a byline mark, not a tab stop
#721 made the rolling-window mark focusable (`tabIndex={0}`) so the tooltip is
reachable without a mouse. It is the only focusable byline mark in the file —
`↓`, `★`, the catalog version and the capability title all sit in plain spans
inside the host's Tooltip — and it would add one tab stop per card plus a
paragraph of screen-reader output per stop, on a grid that renders dozens.

Nothing is lost by dropping it: the same text is rendered as body copy in the
detail dialog, which is where a keyboard user is heading anyway, and the
`aria-label` and the visible `· ↓ 1.2k / 30d` both stay.

Everything else in #721 stands as reviewed: the three source fields exist for
all 2153 entries that report a count (no entry has a count without them), the
committed client artifact matches a fresh build, and the new assertions are
load-bearing — relaxing `dateOnly` to `typeof value === 'string'`, dropping the
integer guard, fabricating a window from missing dates, and dropping the
reversed-range check each turn their own cases red.
2026-09-25 23:56:44 +08:00
fkysly 7b67f9679d release 1.65.4: 1.65.3 claimed to fix #729 and did not
The fix was right about everything except WHEN the market asks. On a web host
`HostConnectionService` is constructed behind `await BrowserAuth.create(...)`
(`dsh-client-connection` 0.1.5+; its `apply` is async and injects
`credentials`), while the market mounts on `webServer` + `loader`, which are
ready first. So the one read `useTrustedHosts` took at mount saw no service,
and `Array.isArray(connection?.trustedHosts) ? ... : []` turned "not asked
yet" into "none declared" — an empty list handed to the fence for the life of
the process. Reached by a name: every mutating route answered 403 while reads
and loopback kept working, which is exactly the report #729 made against
1.65.2. In other words 1.65.3 fixed a fence nobody was failing to feed.

Verified this time against a real 0.1.7-rc.2 host, not the unit lane:

- 1.65.3, `--trusted-host dsh.example.org`, POST /dsh-market/restore with a
  matching Host/Origin -> 403 `untrusted origin`; loopback -> 400 (reached).
- after #730: declared name -> 400 (reached), loopback -> 400, undeclared
  name -> 403, `sec-fetch-site: cross-site` -> 403. The one-time warning never
  fired, so the service was there to be read.

1821 tests, 75 files, typecheck clean. Thanks to the report for taking the
time to test the shipped artifact rather than the branch.
2026-09-25 23:52:16 +08:00
fkysly 2b8dbc8f10 fix(index): read the host's authorities per request, not once at mount (#729)
The read itself was right; it landed too early. `HostConnectionService` is
constructed behind `await BrowserAuth.create(...)`, so the `connection` service
does not exist yet when the market mounts on `inject(['webServer', 'loader'])`
-- both of those are ready first. The one read the market took therefore saw
`undefined`, and `Array.isArray(connection?.trustedHosts) ? … : []` turned "not
asked yet" into "none declared": that empty list was handed to
setTrustedHostsSource for the life of the process.

So 2b67f66's own subject is still true on a web host -- a deployment reached by
a name answers 403 `untrusted origin` to every mutation while reads and loopback
keep working, which is #729.

Measured on a real host (market 1.65.3): the mount-time read sees `undefined`,
and the same ctx read while handling a request sees `['<name>']`.

The fix is the idiom `agentsLookupOf` already uses two functions up -- resolve
at request time -- plus a one-time warn when the service is absent, because the
silent fallback is what made "we could not ask" and "nothing is declared" the
same fence and hid this for a release.

`useTrustedHosts` is exported for the spec that covers the wiring; the fence it
feeds stays covered through `sameOrigin` in tests/http.spec.ts, and the flows
case sets the source directly, so the two of them together could not see when
the market reads it.

Verified: 1821 unit tests over 75 files green, including the new
tests/trusted-hosts.spec.ts -- red against a mount-time read, green here;
typecheck (src, client, tests) clean; and end to end on a real DSH web host,
where a declared name reaches the handler (400 from the route's own body
validation), an undeclared name is still refused (403), loopback still works,
and a real mutation from the declared name answers {"ok":true,...}.
2026-09-25 23:51:30 +08:00
fkysly d832e434b5 release 1.65.3: a deployment reached by name stays writable
The fence fix from #729, on the released line. 1.65.2 was tagged from the wrong
branch in my working copy — it carries the staged-toggle work that is still on
`wip/toggle-transaction` (verified at 4 of its 5 real-host e2e scenarios) along
with this fix. This release is main plus the fix and nothing else, so `latest`
names content that has been verified end to end; 1.65.2 should be deprecated or
unpublished, which needs an interactive credential and is the maintainer's call.

It answers a total functional break: on any deployment reached by a name —
reverse proxy, tunnel, LAN hostname — every mutating route answered
403 untrusted origin while every read worked, so it presented as "the install
button does nothing". The fence now accepts the authorities the host declares
(`--trusted-host` and the LAN literals DSH derives), which is the half DSH's
own /api routes already had and the market could not inherit; an undeclared
name is still refused.
2026-09-25 22:20:45 +08:00
fkysly 2b67f668f6 fix(http): accept the authorities the host declares, not loopback only (#729)
Reached by a name — a reverse proxy, a tunnel, a LAN hostname — every mutating
route answered `403 {"error":"untrusted origin"}` while every read kept
working, so it read as "the install button does nothing": install, update,
uninstall, toggle, snapshot restore and preset save all refused, for every
deployment that does not use `http://127.0.0.1:<port>`.

The fence's rule is right and stays — Host is the one header DNS rebinding
cannot forge, so the authority has to be ours. What it lacked is the other
half of "ours": DSH's own /api fence accepts loopback OR an authority the
operator declared (`dsh web --trusted-host <name>`, plus the LAN literals the
CLI derives when bound to 0.0.0.0), and the market's routes are `exact`
registrations on the bare webServer, so that fence never sees them and the
market had to decide for itself.

It now reads the same list back from the host's `connection` service
(`trustedHosts`, public on `HostConnectionService`) once at mount, and:

- mirrors the host's matching rule, because the two fences have to agree: an
  entry with an explicit port matches that exact authority, a port-less entry
  matches the hostname on any port (the shape the CLI derives for IP-literal
  LAN serving, where the port may be OS-assigned);
- refuses an entry that does not survive WHATWG parsing unchanged, so
  `dsh.example.org/path` cannot authorize `dsh.example.org` — the rule the
  host's own loader enforces loudly for the same reason;
- refuses `sec-fetch-site: cross-site` outright, which the host's fence also
  does and this one did not: it covers what the Origin equality cannot, a
  cross-site request whose Origin happens to match.

An undeclared name is still refused, which is the security property: the list
only ever contains what an operator wrote into the deployment's configuration.

The restart and download fences deliberately keep the stricter posture they
have (loopback PEER only, no forwarding headers): process control is for the
machine itself, not for whoever can reach the GUI.

Verified: 1851 unit tests, including a new tests/http.spec.ts (8 cases: the
reported repro refused undeclared and accepted declared, exact-port vs
port-less matching, cross-site refusal, malformed entry, restore) and a flows
test driving a real mutating route from a declared name and from an undeclared
one. Three mutations each turn exactly one of them red. npm run check clean.
2026-09-25 22:20:08 +08:00
fkysly c9705fd9cd refactor(client): the host's Checkbox where its string label fits
Two of the market's five checkboxes are a plain box plus a short label
(Advanced → automatic backup; the export dialog's "include configuration"), and
those now render the host's `Checkbox` — matching the settings page around
them. Older hosts keep the market's label+input.

The other three deliberately do not, and the reasons are written down where the
next person will ask:

- the export picker's per-plugin rows carry rich label content (name, spec
  kind, resolved spec), and `Checkbox` takes a STRING label — routing them
  through it would either drop that content or print the name twice;
- the recovery panel's checkbox has no visible label at all;
- the gist mode picker is a pair of RADIOS, and the host ships no radio
  primitive (0.1.7-rc.2: Checkbox, Switch, SegmentedControl, Tag — no radio).

Verified: 1808 unit tests, npm run check clean, and a mutation that ignores the
host component turns the new test red.
2026-09-25 18:37:35 +08:00
fkysly f4294b4912 refactor(client): one on/off control, the host's Switch when the host has one
The market rendered the same hand-rolled `role="switch"` markup in three
places (the installed row, a group member row, the detail view) and none of
them had a test: `Switch` is one of the components 0.1.7-rc.2 added, and the
host's own plugin list uses it, so the market's rows now take it too and match
the list they sit in. Older hosts get the market's switch, unchanged.

The group header's switch is deliberately NOT routed through the helper, and
now says so: a group's state is three-valued (all on / all off / mixed) and the
host's Switch is boolean — adopting it would flatten "mixed" into one of the
two, which is the one thing that control must not say.

Both halves are asserted: the host path in
tests/client/optional-primitives.client.spec.tsx (`Switch` mocked in, checking
the installed row renders it with the right label), the fallback in the main
spec, which runs against a host without `Switch` — the first test that control
has ever had either way.

Also found while wiring this: `SegmentedControl` (the List/Groups switch) is
NOT adopted. Its contract is a real tablist whose every tab carries
`aria-controls="<id>-<value>-panel"`, so the panels have to be rendered — and
the installed view's JSX has no container to hang them on, which makes the
change structural for a sub-switch that already looks deliberate. Reported
rather than forced.

Verified: 1807 unit tests, npm run check clean.
2026-09-25 18:34:31 +08:00
fkysly 3106fa8565 fix(client): the same disclosure row on both cards, and a resolver that survives strict shims
Found by looking at the running market rather than at the source: the Themes
gallery draws a different card from Discover's masonry, and the capability row
had only been added to one of them — so the same plugin showed "What it
touches  writes files / reads files / network" on Discover and nothing at all
on Themes. A fact on one card and not the other reads as a difference between
the PLUGINS, which is the one thing this feature must not imply.

The row is now one function used by both renderers, with a test scoped to each
card (the earlier version of that test passed with the row removed: both tabs
stay mounted, so an unscoped query found the Discover card's chips and never
looked at the theme card at all — caught by mutating the change and watching
it stay green).

`optionalComponent` also survives a shim that THROWS on an unknown export
rather than answering undefined — vitest's module mock does that, and a host's
could. "This host does not have the component" and "asking threw" are the same
answer, and both call for the fallback.

Verified: 1805 unit tests, npm run check clean, and the theme cards confirmed
by screenshot against a real 0.1.7-rc.1 host.
2026-09-25 17:14:57 +08:00
fkysly a5d840b663 refactor(client): use the host's Tag for disclosure chips, and keep the fallback honest
A review pass over the market's UI against the host's ui-primitives: what the
running host can offer (0.1.0-rc.7 exports 25 groups, 0.1.7-rc.2 exports 51)
versus what this package hand-rolls.

`src/client/optional-primitives.ts` is the new seam, and it is deliberately the
same shape as the icon aliases: resolve the host's component when it is there,
fall back to the market's own markup when it is not, and make the fallback
mandatory by running the tests against an old host — a call site that forgets
it fails loudly in this suite instead of silently in production. It is NOT
`REQUIRED_PRIMITIVES` (src/client/index.ts): that one decides whether the
market can render at all; this one decides how a chip looks.

First use: the capability chips. On a host that ships `Tag` they are the
host's own tags (`tone="outline"` for detected capabilities, `tone="quiet"`
for 未检出/未扫描), so they match the settings page they sit in; on an older
host they stay the market's spans. Both halves are asserted — the fallback in
the main spec (which runs against 0.1.0-rc.7), the host path in
tests/client/optional-primitives.client.spec.tsx with `Tag` mocked in.

Also: `.specTagGit` / `.specTagFile` were the last two colours not taken from
the host palette; they now read `--dsw-alias-state-business-primary` and
`--dsw-alias-state-warn-primary` with the previous values as fallbacks.

Reported, not done in this pass: the installed list's list/groups switch is a
hand-rolled segmented control (`SegmentedControl` exists on 0.1.7 and needs its
`<id>-<value>-panel` panels rendered to be honest about `aria-controls`), the
enable/disable rows could be `Switch`, the settings inputs `Input`/`SettingsForm`,
the lightbox `ImageLightbox`, and the card avatars `PluginArtwork*`.

Verified: 1804 unit tests (348 of them client), npm run check clean.
2026-09-25 16:29:56 +08:00
fkysly 0370c55841 fix(toggle): a strict entry enable that restores what it touched (#582)
The bundle-layer toggle drove loader entries best-effort: a rejected
`entry.update` was a log line, and the toggle carried on and reported the
state it had asked for. Two things follow from that, both of them
user-visible — a successful-looking enable over a fiber that is not there, and
a partly-applied enable whose earlier entries stay flipped while the later one
failed.

`setEntryDisabled(name, flag, requireLive)` is the strict mode, ported from
@bulingbuling688's branch together with its four scenarios:

- every entry this call touches is remembered, and a failure restores them in
  reverse to the disabled flag AND the live state they had — the previous live
  state is part of the record, because an entry that had been enabled before
  the failure must not come back disabled;
- an update that fulfils without producing a live fiber is a failure, not a
  success: `did not become live`;
- a rollback that cannot complete says so (`entry restoration failed: …`)
  rather than reporting an error from the original operation alone;
- a hung update is bounded at 10s (`waitForLifecycle`, which also refuses a
  second mutation on a loader object whose first one is still pending), and a
  later enable of an entry whose last update never settled is refused until a
  disable has been seen through.

The legacy callers keep the best-effort semantics they were written against —
the boot replay and the theme paths must not start rejecting — so strictness is
opt-in, and the hung-update bound keeps them from waiting forever.

Verified: four scenarios (ported from #582) assert each behaviour, and three
mutations of the port turn exactly the matching one red (rollback loop removed,
live-state verification removed, rollback failures swallowed); 1803 unit tests,
npm run check clean.
2026-09-25 10:02:33 +08:00
fkysly cc07be2e7c fix(themes): put the previous theme back when a switch fails (#582)
Switching themes stops every other theme first — they are mutually exclusive
by construction — so the interesting case is the failure: if the new theme
cannot start, the user was left with NO theme at all. The old one stopped, the
new one never started, and the Themes tab listed the plugin as enabled while
the interface had lost its skin.

What the switch stopped is now remembered and restored on failure, live fiber
and disable flag together: a theme that was live before the attempt is live
again and out of the disable set, while the theme that failed keeps neither —
it never came up, and staying out of that set is what lets the next attempt
try it again.

Measured, not guessed: @bulingbuling688's branch (#582) ships a 39-scenario
spec for this and the surrounding consistency work, and running it against
main is what showed the gap is real — 34 of the 39 fail today, the reported
one being simply "undefined" where the previous theme's fiber should be. This
lands the theme half with a flows test naming the same behaviour; the rest (a
single staged write path for the toggle, strict entry enable with rollback,
group partial success) is still open in the PR.

Verified: the new test fails with the restore removed, and again with only the
disable-flag restore removed; 1799 unit tests, npm run check clean.
2026-09-25 09:59:38 +08:00
fkyslyandOct1AtJoe b136602029 feat(client): prioritize updatable plugins at the top of the installed list (#631)
An installed row that has an update pending now sorts above the rows that do
not, so the thing a user came to the tab to do is at the top. The order
settles once and then HOLDS: `/installed` is a local read and `/updates` is a
network probe over every package, so the list is always painted before the
answer exists, and a live sort would reshuffle rows under a pointer already
aiming at one. `updatesLoaded` — a boolean, not the map — is what lets the one
reorder happen when the check lands.

Maintainer pass on top of @Oct1AtJoe's branch, which was verified and reviewed
but not updated since:

- **One predicate for "has a pending update".** It had three copies that
  disagreed — the reminder count, the card's pill, and this ordering — the
  shape this repository already paid for once (`src/entry-identity.ts`: one
  assumption, several copies, each fixed at a different time). All three now
  call `isPluginUpdatable`; the two things that are genuinely per-caller
  policy are arguments with their reasons written down: `ignored` (the notice
  surfaces pass it, the row's pill does not — dismissing an update does not
  change whether one exists) and whether a disabled plugin counts (the
  reminder says no, the list says yes).
- **Ignored updates no longer jump to the top.** "Ignore this update" is the
  user answering this question themselves, so the ordering passes
  `ignoredUpdateSet` and a dismissed row keeps its place.
- **The freeze now has an assertion for its second half.** The existing test
  proved a filter round trip does not reshuffle, but a filter touches neither
  `updates` nor `ignoredUpdateSet` — it could not catch either being added
  back to the ordering's dependencies. Dismissing the update (the row's own
  affordance, the one input that exists without a refetch) is asserted to
  leave the order alone.

Verified: 1798 unit tests; both mutations of the new assertions turn exactly
one test red (the ignored set added to the dependencies; the ordering
predicate neutered). npm run check clean, client bundle rebuilt.

Co-authored-by: Oct1AtJoe <Oct1AtJoe@users.noreply.github.com>
2026-09-25 00:55:24 +08:00
fkysly 1d1afdc349 feat(client): say what a plugin touches, as facts, on the card (#401)
The catalog now scans each plugin at build time (awesome-dsh-plugin
probe-capabilities.mjs) and publishes what it found: `capabilities` and
`capabilityRedLines`. The card renders them, and the shape of that rendering
is the whole feature:

- **Disclosure, never a badge.** Three states, because they are three
  different sentences: chips when something was detected, 未检出 when the
  scan ran and found nothing, and 未扫描 when nobody has looked at this entry.
  Merging the last two — the tempting shortcut — is exactly the misreading
  this design exists to prevent: one of them is a statement about the plugin
  and the other is a statement about us.
- **The blind spots sit next to the chips**, in the title of the heading that
  labels them: a static scan of the package, which skips node_modules and
  cannot see runtime-assembled URLs, dynamic imports or obfuscated code, and
  whose "nothing detected" is not "safe".
- **The scanner's ranking is not carried.** `score` and `band` are dropped at
  the catalog boundary, so no surface can render a plugin as green — the
  badge shape that made #209 a "no" the first time.
- **A capability this build has no label for shows as itself** rather than
  disappearing: `dynamic-code` arrived after the first integration, and a chip
  that vanished would read as "does not do that".
- **A red line we cannot translate stays in the scanner's words.** A
  mistranslation of a security fact is worse than a foreign word; the two
  sentences this build knows are translated.

No interception: nothing here blocks, warns before install, or changes what a
click does. Facts on the card, judgement left to the reader — the position
#209 settled and this keeps.

Verified: five client tests (detected / nothing-detected / never-scanned /
unlabelled name / untranslatable red line), each mutation-checked — merging
the two absence states, dropping unlabelled names, and removing the red line
each turn exactly one of them red. 1785 unit tests, npm run check clean,
client bundle rebuilt.
2026-09-25 00:10:34 +08:00
fkysly e7e4198981 review(#527): the third config site, the refresh list, and a body cap that fits
Everything the review asked for, on top of @ZhaoGY-N's two commits (rebased
onto main; the third, `pnpm-lock.yaml`, was dropped — this repo has not
carried that file since it moved to npm, and the commit's whole content was
keeping a file main does not have).

- **The official desktop host now gets `buildEnv`.** `src/index.ts` builds
  `MarketConfig` in three places and only two were wired, so a `buildEnv` set
  in cordis.yml was silently dropped on the one host the feature exists for:
  a GUI launch inherits no shell environment.
- **`refreshMarketState` carries the field.** It is the list #435 left behind,
  and its rule is that any field with an independent writer must be re-read
  there — otherwise the next save from the stale object puts the boot-time
  value back.
- **The route has its own body limit (256 KiB).** The default is 4 KiB and
  `MAX_ENV_VALUE` is also 4 KiB, so a map holding a single maximum-length
  value plus its JSON wrapper could never be sent: the sanitizer's ceiling
  has to be smaller than the transport's for either to mean anything.
- **`GIT_ASKPASS` / `SSH_ASKPASS` stay allowed, and now say so.** They are the
  two names that can re-open a credential prompt, but pointing them at a
  program is the supported non-interactive way to answer one, and #587/#596
  closed the *terminal* fallback rather than the program one. A user who pins
  them has said where the answer comes from; a user who does not still gets
  the closed prompt. Documented at the sanitizer, which is where the next
  person will ask.
- **The card's hint tells the truth about scope**: these variables reach
  every process the market starts — installs, builds, git — not only the
  compiler, and the button says "Save variables" so the page has two Save
  buttons it can tell apart.

Test gaps the review named, closed: PATH/CI are posted at the route and
asserted absent from what comes back (a clean round-trip passed whatever the
sanitizer did); the spawner is handed a LIVE source (freezing the copy at
mount now turns a test red); and a 4096-byte value survives the request body.
The flows harness now spreads the REAL `src/hot.ts` and overrides only what it
needs — its hand-written copy of `buildEnvFromUnknown` had already drifted
(missing the value cap), so that test was asserting the mock's behaviour.

Verified: 1780 unit tests, npm run check clean, client bundle rebuilt.
2026-09-24 22:50:44 +08:00
fkysly 1e85051e6c test(web): install a selected compatible release on a real host (#718)
The fix in 310197b is covered by unit tests for `lookupVersion` and two
flows tests, but neither of those runs the thing the user does: a real host,
a real catalog fixture whose 1.0.0 declares `engines.dsh: >=0.0.0` and whose
2.0.0 declares `>=99.0.0`, and a POST with `version: '1.0.0'`. This is that
path, from @yaojin3616's #718 — the same fix, independently arrived at, and
one assertion the unit and flows layers cannot make.

Verified on both host generations: 6 tests pass on dsh 0.1.2-alpha.2 and 6
on 0.1.7-rc.1.
2026-09-24 22:30:47 +08:00
fkysly bcb124f9e8 release 1.65.1: the version we recommend is the version we install
Shipped now rather than batched, because 1.65.0 ships an affordance that
cannot work: "install the newest release this host supports" resolved a
compatible version, offered it, and then the pre-flight check judged it by
`latest`'s manifest and refused it. The user is left with a button that
always fails and a dialog that keeps offering the same version — a loop with
no way out but `force`. Fixed in 310197b, and no longer the only thing that
version can do wrong: a pinned release that IS incompatible is now refused,
which nothing checked before.

Also in this release, both batched:

- The four search fields have a clear control (#524) — a × that empties the
  box and the filter together, in the 250ms before the query commits as well
  as after, with spellcheck off on all four. Design and the browser-level
  suite from @bulingbuling688 (#541).
- Disabling a bundle plugin writes BOTH layers, or neither (#696 B): the
  patch rows and `dsh.profile.bundles`, so the official plugins page's
  package switch agrees with the market. Bundles that speak for a neighbour
  (fixture-cross, #147) keep the narrower behaviour, and a failed enable
  withdraws both.
2026-09-24 22:25:17 +08:00
fkysly 310197bd8f fix(install): judge a pinned install on that release, not on latest
The compatibility dialog resolves a release this host can run and the
install route pins it (#581) — but the pre-flight check read `latest`'s
manifest anyway. So the newest release's declaration decided the fate of the
older, compatible one: the market offered 1.0.0 for this host, the user
accepted, and the check refused the install over 2.0.0's requirement. The
version argument was carried into the log line and nowhere else; the comment
above the call even claimed the opposite ("judged on the release being
installed"). Reported from the field after 1.65.0 shipped.

It was wrong in the other direction too, which nothing covered: a pinned
release that IS incompatible went through whenever `latest` happened to suit
this host — the plugin then breaks the host on the next boot, which is the
exact failure the guard exists to prevent.

`DiscoveryManifestIndex.lookupVersion` asks the registry for one named
release (`registry/<name>/<version>`). It stays out of the index and its
cache on purpose: the index is keyed by package name, a version-keyed cache
would grow with every release anyone pinned, and a pre-flight verdict must
not decide what the diagnostics panel sees next (#619). A manifest that
cannot be read is still `null`, i.e. no verdict — absence of a claim is not
a claim.

The pinned version is also not a second request in the common case: the
index answer already says whether the pin IS `latest`, and then it is the
answer.

Verified: four unit tests on `lookupVersion` (the URL it asks for, encoding,
the index/cache/failure bookkeeping it must not touch, and the null-not-a-
verdict answers) and two flows tests, one per direction — the first reported
a 400 for a compatible pinned release before the change, the second installed
an incompatible one with a 200. Five mutations, one per behaviour, each turn
a named test red. 1764 unit tests pass, npm run check clean.
2026-09-24 22:25:02 +08:00
fkysly f506bcc18c fix(toggle): write both layers, or neither, when a bundle plugin is switched (#696)
Disabling an ordinary bundle plugin wrote the market's own layers — the
patch rows and state.json — and left `dsh.profile.bundles` alone. That array
is the only thing the official plugins page's package switch reads, so the
market said off while the official page said on. #707 covered the two
directions of DRIFT (an enable made on the official page is followed, an
unbundled package reads as off); this is the write itself.

The stack now moves with the rows: a community bundle the market turns off
leaves `dsh.profile.bundles`, and turning it back on puts it back. Two shapes
stay out of it, both about not taking more than the user asked for:

- an IN-BOX bundle is not the market's to drop from the stack (order.ts
  refuses to reorder them for the same reason);
- a bundle whose patch names rows it does NOT insert speaks for a neighbour
  too. `foreignRowIds` is the new predicate: fixture-cross configures a
  neighbour, and leaving the stack would take that configuration away with
  it, which is what #147 and the fixture-cross spec exist to prevent. Note
  that a foreign CONFIG counts, not just `disabled: true` — leaving the stack
  stops the whole patch, which is what makes a carrier worth removing and a
  neighbour-tweaker worth keeping.

The two layers also move together in the failure direction. An ENABLE that
cannot write its patch rows goes all the way back — the rows it flipped,
then the stack entry it added — instead of leaving behind an entry that
composes a package the row layer calls off. Each row is restored the way it
was: one that was disabled gets its block back, one that was not loses the
`disabled: false` block the enable added, rather than keeping a force-enable
in the user's own layer. The DISABLE direction keeps whatever it got: the
user asked for off, and a row the patch layer refuses to flip does not make
the plugin live again.

The market's displayed state already followed the runtime truth
(`disabled || patchDisabled || unbundled`, MarketSection.tsx), which is the
second half of the report; no change was needed there.

Verified: 1758 unit tests; the install e2e lane on real hosts — 13 tests on
dsh 0.1.2-alpha.2 and 13 on 0.1.7-rc.1, the lane that carries fixture-cross;
five mutations (stack toggle narrowed back to carriers, foreign-row guard
dropped, stack withdraw removed, row rollback removed, state-aware restore
replaced by "always disable") each turn a named test red. npm run check and
typecheck clean.
2026-09-24 21:31:25 +08:00
fkyslyandwang shi zhuo e542e560e5 feat(client): a clear control in every search box, and no red squiggles under plugin names (#524)
The four search fields — Discover, Themes, Favorites, Installed — had no way
back to the full list except selecting the text and deleting it. Each now
shows a × while there is something on screen, and one press empties the box
and the filter together.

It lives inside SearchInput rather than at the four call sites. #541 by
@bulingbuling688 reached the same four fields from outside this component; the
two reasons it had to move in only show up in the 250ms between a keystroke
and its commit:

- the control has to clear the DRAFT. `value` is the committed query, and
  between keystroke and commit the box holds text the list has not heard
  about — a button that only reset `value` would leave the old text on screen
  until the effect rewrote the draft, and would schedule its own debounce on
  the way out.
- it has to cancel the pending query. Without that, clearing mid-debounce is
  followed by the old query landing anyway, and the list re-filters itself
  after the reader has just emptied the box.

The 34px of padding that keeps a long query off the button is the same
argument in CSS, and belongs to whoever renders the button: a rule four rows
have to remember is a rule a fifth will forget. The host's Input renders the
native element itself and forwards no ref, so focus is returned through the
component's own container — clearing puts the reader back in the field they
were typing in.

spellCheck is off on all four: a plugin name is not prose, and red squiggles
under `dsh-session-manager` are noise on a field that only takes names. The
group-add dialog's search gets both behaviours for free, being the same
component.

Verified: 1750 unit tests; the search e2e lane on real hosts and Chromium —
7 tests on dsh 0.1.2-alpha.2 and 7 on 0.1.7-rc.1, covering hit-testing the
control at every scroll step of the sticky row, native Enter/Space
activation, the focus-visible outline, and long queries at 1200px and 640px;
every new assertion mutation-checked (control keyed on `value`, control
missing, clear without commit, spellcheck left on, focus not restored — each
turns exactly one test red). npm run check clean, client bundle rebuilt.

Closes #524.

Co-authored-by: wang shi zhuo <147490150+bulingbuling688@users.noreply.github.com>
2026-09-24 20:25:43 +08:00
fkysly 8a44c02b31 release 1.65.0: a way out of two dead ends, and a host that owns activation
Two refusals that used to end with the user stuck now name their way out:

- A release the profile's own `minimumReleaseAge` held back is reported as
  what it is — installed, working, and not the newest — with the version named
  and a button to take it anyway (#635).
- A release this host cannot run is no longer the end of the line: the dialog
  searches the plugin's history for the newest release that still declares
  this host and offers THAT, beside the existing "install it anyway" (#581).

Also in this release: a host that owns activation can now say so, and the
market asks it to replay instead of creating a second loader entry (#551);
the market's settings card sits where dsh 0.1.7 says a bundle's configuration
belongs (#677) and 0.1.7 joins the CI matrix; the diagnostics page names the
directories an interrupted update leaves behind (#663); and the store-mismatch
advice stops sending people in a circle — the durable fix is a `storeDir:` line
in the profile, not a one-shot flag (#715).

Shipped now rather than batched because two of them are instructions the
current release gives users and they are wrong: one sends them to a command
that cannot help, the other offers a gamble where a compatible version exists.
2026-09-24 19:04:11 +08:00
fkysly 27a2d3bd69 feat(routes): offer the newest release this host supports, instead of a dead end (#581)
A refused install or update used to end there: the dialog said the release
needs a newer DSH and offered one action — install it anyway, which is a
decision about breaking the host that most users reaching that dialog cannot
weigh. @yaojin3616 built the missing half (find the newest release that still
declares this host, and install THAT), and this lands it with the review's one
correction.

- **`POST /dsh-market/find-compatible`** searches the package's version
  history through `deriveHostCompatibility` and returns the newest release
  whose own declaration this host satisfies. Only a CONFIRMED `compatible`
  passes: `unknown` is "nobody said", and pinning that as compatible is the
  guess the check exists to avoid. Prereleases are included and ordered
  properly — in this ecosystem the host line is often a prerelease and plugins
  declare against it by name — and an update searches only NEWER releases.
  Catalog membership is required, so the route is not an open packument proxy.
- **The refusal dialog asks on open** and shows the answer: the version, a
  button naming it (primary when found), and the plain statement when nothing
  declares this host. `install anyway` keeps the ghost seat.
- **Both routes take the pinned release**: `version` on install,
  `compatVersion` on update. The one judgement kept is direction (#64) — a
  compatible release can legitimately be older than what is installed, and an
  update must never be a downgrade.

`force` is kept, which is what the review asked for and what the first draft
removed: reading a declaration and refusing on it is not the same as being
right about it, and the two roads out are different decisions — one pins a
release that declares support, the other accepts that the declaration is
wrong. The dialog offers both, in that order of emphasis.

Not taken from the PR: its live-npm-version byline refresh, which is a
separate product change to what the cards display.

Twelve tests, mutation-checked at every joint: dropping the install pin,
ignoring `compatVersion`, removing the downgrade guard, dropping the catalog
check, not passing the found version on, and swallowing the not-found answer
each fail exactly the test that covers them. 1739 unit tests green, typecheck,
build, restart smoke, bundle in sync.
2026-09-24 18:59:31 +08:00
fkysly f4ebc675b8 docs(assets): refresh the screenshots the README and the site both show
Both were captured on 2026-08-28 from a v1.33.0 build, and they had drifted
into saying things that are no longer true of the app:

- the version in the header read v1.33.0 (now 1.64.0);
- the catalog chip read 2.2k (measured 4.3k today);
- the tab row had no Favorites — the feature shipped since;
- cards carried no host-requirement badge, which is most of what the byline
  was built to say.

Recaptured against a real host with the REAL catalog (no fixtures), in both
languages: the Chinese one is the same page after switching the host's own
language, which is also how it ends up saying 插件市场 / 收藏 / 全部 (4.3k).

These files are the README images, the docs homepage, and the og:image every
plugin page shares, so one refresh covers all four places.
2026-09-24 18:47:49 +08:00
fkysly b51839efbb feat(routes): let a host own activation, and keep the cleanup the market can still do (#551)
@sampx's seam, rebased and finished. A host that watches the profile and
replays it the moment the manifest lands makes the market's own hot mount a
SECOND loader entry for an id the live composition already serves — duplicate
prefix routes, and "restart required" about a plugin that is already up. Where
the host publishes `desktopProfiles.pluginActivation`, the market now asks it
to replay and reports what it answers, instead of mounting on its own.

The seam is optional by construction (`pluginActivation?` on the service the
market already reads, not a new hard gate): a host that only knows `current`
keeps working unchanged, and `ctx.inject` is not asked to demand a capability
no user asked for.

Two things changed from the first draft, both from the review:

- **`setEntryDisabled` still runs when the host owns activation.** The draft
  skipped it (`entryDisabled = false`), which is #213's exact shape: a package
  with two activation sources losing half its cleanup because the other source
  reported success. The host owns the entry it made; the market owns any entry
  it can still see — a machine that ran plain `dsh web` before the host took
  over can have one — and success from one is not evidence about the other. It
  costs a name scan when there is nothing to disable, and the test pins the
  scenario rather than the call.
- The install path keeps the author's adoption logic on the plain-host side: a
  loader entry whose fiber is already up is ADOPTED rather than re-mounted,
  which is the same collision one step earlier, for hosts that self-activate
  without publishing the bridge.

Four tests, each mutation-checked: ignoring the bridge on install fails two,
ignoring it on uninstall fails one, and restoring the draft's skipped cleanup
fails one — the last of which is the review's decision, now load-bearing.
1729 unit tests green, typecheck, build, restart smoke, bundle in sync.
2026-09-24 18:40:31 +08:00
fkysly 964e137f0d feat(install): say when a release was held back, and let the user ask for it (#635)
A fresh install pins to the registry's latest so pnpm's fresh-release hold
cannot substitute an older version silently (#621), and when the profile set
`minimumReleaseAge` on purpose the market declines the one-shot bypass and
falls back to the bare name (#594) — which installs the mature version and
reports plain success. The plugin is on disk and works, and the user is never
told that a newer release exists or why it is not the one they got.

@FuRongJun-1999 found this and wrote the detection; the shape is theirs. What
this lands is that shape with the two corrections from the review:

- The hold is a SUCCESS with a caveat, not a failure. Reporting it as
  `ok: false` — as the first draft did — shows "install failed" beside a
  plugin that is installed and working, which is the one thing this project
  treats as worse than saying nothing. The row keeps its ✓ and gains a reason.
- The escape has an entry. The bypass the fresh path refuses to assume it has
  is exactly what an explicit click gives it: `force: true` runs the PINNED
  add with the one-shot override, and the row offers that as a button naming
  the version. Detection without a way out would only have made the hold
  louder.

`heldRelease` travels in the install response, the record carries it as
`warned`, and the Tasks panel renders the notice plus "install <version>
anyway" — beside the sentence describing the problem, the same place the
build-approval action already sits.

Four tests, each mutation-checked: dropping the response field fails one,
ignoring `force` fails another, taking the branch away fails the client test,
and forgetting the flag on the button fails it too. 1725 unit tests green,
typecheck, build, restart smoke, bundle in sync.
2026-09-24 18:33:03 +08:00
fkysly 1b7505dee2 fix(pnpm): the store-mismatch advice was a loop, and #244's conclusion was wrong
Every install and update fails with ERR_PNPM_UNEXPECTED_STORE after $DSH_HOME
moves to another mount point: pnpm 11 re-selects a store for the new mount
while the node_modules built before the move keep the old one recorded, and
pnpm then refuses add/remove/install for that profile (#715).

The message we showed told the user to run `pnpm install --store-dir <the
linked path>` once. Measured on pnpm 11.7.0: that command succeeds and does
NOT rewrite the store recorded in node_modules/.modules.yaml, so the very
next market-spawned command — which passes no flag — fails identically. The
advice sent people in a circle, on a failure that already refuses every
operation.

What works, and is what the message now says: write the RECORDED store into
the profile's pnpm-workspace.yaml as a top-level `storeDir:` (camelCase).
Verified both directions on a profile-shaped tree: with the line, market-
shaped `pnpm add -w` commands pass one after another with nothing
reinstalled; without it, they fail. This also falsifies the reasoning this
code carried from #244 ("on pnpm 11 the store can only be set by CLI flag") —
a conclusion drawn from `pnpm config get store-dir`, which is not a valid
probe (it answers the value on 11.7 and undefined on 11.22 while the setting
works on both).

`recoverable` stays false, for a reason that survives the correction: either
choice is the user's. Pinning the recorded store keeps a path that may be on
a disk they just moved away from; adopting the store pnpm now resolves makes
pnpm purge and re-download the entire node_modules (it stops for confirmation
and aborts without a TTY — ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY,
measured).

Three tests, mutation-checked: removing `storeDir` from the message fails all
three that assert it.
2026-09-24 18:24:15 +08:00
fkysly 43d42a1712 test(e2e): count mounts in the liveness marker, and put 0.1.7 in the matrix
The marker every web spec reads as ground truth was wrong in one shape, and
that shape was already in the same file: a carrier bundle (#156) inserts an
entry naming ANOTHER package, so `dshm-e2e-fixture-b` is mounted by two
entries at once. They resolve to the same module URL, the instance is shared,
and `apply` runs once per entry — so one entry's dispose deleted the file
while the other entry kept running the plugin.

I read that as a bug in dsh 0.1.7, wrote it up as #717 with a reproduction,
and was wrong: the dispose/apply ORDER differs between 0.1.7 and
0.1.2-alpha.2, and only the ordering decides whether the shared file survives.
Asking the host itself settled it — its Plugins page reports the plugin
Running while the file was absent, and the market's own verdict agreed. So the
fixtures now count mounts and delete the file only when the last one leaves,
which is what every spec already assumed the file meant; the host and the
marker agree on 0.1.7, and the full lane is 44/44 on both lines.

With that, the last blocker for 0.1.7 in CI is gone. It joins the matrix on
Linux (the newer lines are where host-API breakage lands, and that is
platform-independent), and it installs AS PUBLISHED: the #684 pins exist for
the old CLIs, whose cordis plugins float past what their boot can use, while
0.1.7 declares its own ranges and boots on them — measured, full lane green
unpinned. Pinning it would have had CI testing a composition no user gets, on
the line where that matters most.
2026-09-24 18:12:45 +08:00
fkysly 86d35cbd78 docs(readme): the restart switch has no home on 0.1.7, say so
0.1.7 derives a plugin's settings from its own Config schema and serves no
plugin-registered namespace, so the market's settings surface is absent there
by both sides' design — not a gap this project should close by relocating the
market's own card (which now lives where the host says a bundle's
configuration belongs, #677). `allowRestart` stays what the card's own note
has always said it is: a config option, whose audience is the person who
wrote the deployment. The profile patch works on every host, so the README
now names it as the route on 0.1.7 instead of pointing at a switch that is
not there.
2026-09-24 17:57:34 +08:00
fkysly 163cf769f0 fix(client): put the settings card where a 0.1.7 host says it belongs (#677)
0.1.7 moved a plugin's own configuration onto its bundle's page in the
sidebar's Plugins page, and its slot contract states where a THIRD-PARTY
bundle's configuration goes: `plugins.item` is "OCCUPIED by the official
settings pages", and "a bundle's configuration belongs in
`plugins.bundle.config` or `plugins.row.config` instead".

That is the same seat `settings.plugin.item` was on the older line, under its
new name — not a location chosen here. My first attempt registered into
`settings.plugins.tab`, which is the section for the plugins a deployment
SHIPS ("Built-in plugins"), and was wrong; a probe of a real 0.1.7 host is
what showed it, and this is what the host's own documentation names instead.

Detection is the slot itself: `slots.inject` fires only when a host declares
the slot, so every release before 0.1.7 never runs this and no version string
is consulted. The summary view returns null — the market's description is
already the one-liner there — and `page` renders the card.

Verified against real hosts, both directions: on 0.1.7-rc.1 the card renders
on the bundle page (`Update channel` + `Download region` asserted by the
spec), and on 0.1.2-alpha.2 the old plugin-configuration flow still passes
untouched (2 files / 14 tests green there).

The existing guard test earned its keep: it failed on the new registration,
which is exactly what it is for, so it now says what it was checking — a host
declaring neither card seat still gets the market, and a host declaring the
new one gets the card — with the fake `slots.inject` firing only for declared
slots, as a real host does.
2026-09-24 17:33:11 +08:00
fkysly 34658ad963 test(e2e): ask each host generation what it can actually do (#677)
0.1.7 changed the settings contract under the market, and two specs were
asserting one generation's shape against both. Neither was the market's bug:
the market's plugin-configuration card cannot be dispatched on a host that
serves no third-party namespace, and that is the host's model rather than a
gap to close by moving the card somewhere else.

The difference is now REPORTED, by the side that knows it: the market offers
its namespace and sees what the service answers, so `/status` carries
`settingsNamespace: pending | registered | unsupported-by-host`, and both
specs branch on that instead of on a version string. Each branch asserts
something real — a namespace on a host that takes one, the absence of the
plugin-configuration page on the host that replaced it, and the market's own
`settings.section` on both.

Also fixed in the harness, found while trying to verify the old line
locally: `scripts/install-e2e-host.mjs` pinned its cordis overrides in
`pnpm.overrides`, which pnpm 11 no longer reads — measured, the install
landed loader@1.0.5 + hmr@1.0.19 and the host died with the very #684 error
the script exists to prevent, while `pnpm-workspace.yaml` held nothing but an
`allowBuilds` stub pnpm wrote for itself. The overrides now go in the
workspace file too (CI pins pnpm 10, where the package.json form still works,
so both are written), plus `strictDepBuilds: false`, which is the other half
of that stub.

Verified: full lane green on 0.1.2-alpha.2 (14/14 in the two files); on
0.1.7-rc.1 the same files pass with 12/13 in install.e2e.ts — the remaining
failure is a real behaviour difference, not a spec shape, and is reported
separately.
2026-09-24 17:13:07 +08:00
fkysly 6b93028b9f docs(readme): document what shipped since the last pass, in both languages
Five features were reachable in the app and absent from the README, which is
the first thing a new user reads and the npm page's own copy:

- **Groups** and **Notes** — the two organising features, beside Favorites.
  Both are local state in the profile's `state.json`, and the notes bullet
  says the part that is not obvious: a note REPLACES the author's description
  on that row.
- **Updates** gains the two halves of acting on an update: the "What changed"
  link on a row with one pending (release notes, or the commits when the
  version cannot be aligned), and ignoring a notice for the rest of the boot.
  The scope is the point — a restart brings it back, and ignoring is never
  the same as turning it off.
- **Diagnostics** gains the leftover-directory listing from #663.
- **Data source** said nothing about the China route, so a reader there was
  told to pick a mirror by hand while the market was already reading the
  catalog from npm first. The origin is still last in that list, which the
  paragraph now says rather than implying the host is never used.

Also refreshed the catalog count: 2300+ → 4200+ (measured: 4275 today). It is
a number that only ever moves one way, and a stale one is the kind of detail
that makes a reader doubt the rest.
2026-09-24 16:18:02 +08:00
fkysly 7db213ab37 feat(diagnostics): name the directories an interrupted update left behind (#663)
A leftover is invisible from the profile: nothing declares it, `pnpm install`
will not repair an empty shell ("Already up to date"), and the only thing a
user who goes looking finds is an empty directory with no explanation. Two
shapes — a package directory without a readable `package.json` (what a
lock-blocked update leaves) and pnpm's `<name>_tmp_<pid>_<n>` staging
directory — and neither can be cleaned from in here in the case that produces
them, because the plugin's own process holds the directory open. Naming them,
and saying which is merely junk, is the part this process can do.

- `findResidualDirectories` in src/check.ts, pure filesystem analysis like the
  rest of the report. Bounded: the top level in full, plus one level inside
  the virtual store (where a dependency's temp directory appears), not the
  whole store — a page the user opens by hand should not walk thousands of
  directories on Windows, and the store's own temp directories already belong
  to `cleanOrphanedStoreTmp`.
- DECLARED packages with a broken directory are deliberately not listed: the
  bundle layers above already say they cannot load, and calling one of those
  junk would tell the user to clear a package the profile is asking for.
  pnpm's own dot-directories (`.pnpm`, `.bin`, `.modules.yaml`) are not
  packages either, and neither are the symlinks it links every package with.
- Reported structurally, NOT as warnings: a leftover harms nothing today, and
  a warning on every profile that ever had an interrupted install is how a
  list stops being read. The section renders without the alarm style.

Three tests, each checked against its own mutation: not excluding the dot
directories fails both server tests, returning no results fails the listing
test, and renaming the section fails the render test. 1719 unit tests,
typecheck, build, restart smoke, bundle committed in sync.

Held for the next release: informational, nothing is broken without it.
2026-09-24 15:57:10 +08:00
fkysly 8adcc83762 release 1.64.0: a failed update no longer costs you the app
- A plugin whose update was blocked by open files, and whose previous build
  was already incomplete, is no longer left DECLARED. That combination is
  what made the next start die while composing the profile — on Desktop the
  window never opened, and the only way back was uninstalling the plugin by
  hand and losing the pin. The declaration is dropped instead, the directory
  is left exactly where it is, and the market says what happened and how to
  get the plugin back (#663).

  It ships now rather than in the next batch because the failure is not a
  wrong message: it is an app that will not start.
2026-09-24 15:08:31 +08:00
fkysly 99ba7a2eca fix(update): stop declaring a plugin the next boot cannot compose (#663)
The failure this closes: an update blocked by open files leaves the target
directory incomplete, `keepLockedBuild` cannot keep the previous build, and
the profile goes on declaring a package whose `package.json` is missing. The
next start dies in composition — on Desktop the window never opens — and the
reporter's only way out was uninstalling the plugin by hand, losing the pin.

Both remedies the issue proposed are impossible in the case that produces it.
The lock is on the directory itself, not on a file inside it (measured:
`EBUSY` renaming the already-emptied directory), so moving it into a
quarantine or deleting it is the same rename and delete pnpm was just
refused. What the market can do, and what is actually enough, is drop the
DECLARATION: composition stats declared packages, so no longer declaring it
removes the failure outright, touches nothing the user owns, and leaves the
directory where the reinstall can reach it after DSH quits.

- `dropFromManifest` (deps + `dsh.profile.bundles`) on the branch that had
  nothing left to keep, keyed on `hasLoadableEntry` — the market's own answer
  to "can this build load", and the one the message we replace already
  asserted. A build worth keeping is still kept, which is the boundary test.
- `MarketState.brokenPlugins` records what was dropped and the spec to
  reinstall, because the plugin is then gone from the installed list and
  nothing else can explain the absence. Optional on the way in, like `notes`,
  and refreshed by `refreshMarketState` so a read-back cannot eat it (#435).
- `/installed` carries it rather than `/status`: that is the refresh every
  install and update already triggers, so the notice appears on the failure
  and leaves on the reinstall without a page load. Cleared explicitly on a
  successful install/update — a predicate that hid the entry when the package
  reappeared could not tell "reinstalled" from "declared again by hand and
  still broken".
- The installed tab shows it with the reason and a button to the catalog,
  `xN`-free copy in both languages (#663 asked for the next action in the UI,
  not only in the log).

Five tests, each verified against its own mutation: dropping the declaration
fails two, never clearing the notice fails only the third, and the client
notice never appearing fails the first two. 1716 unit tests green, typecheck,
build, restart smoke, bundle committed in sync.
2026-09-24 15:08:10 +08:00
fkysly 05ceb78972 release 1.63.0: a fatal verdict on a bundle that is running
- A Desktop build's own bundles are no longer called missing (#676). The
  check knew two official names — one bundle, one loader — and everything
  else a Desktop ships from inside `app.asar` came back as "the profile will
  fail to boot". Three reporters, one rule: what makes a probe blind is the
  LAYOUT (an archive) and what makes the name in question the host's is the
  scope (`@deepseek-ai/`), and both lists are now gone rather than extended.

  This one costs more than a wrong warning. The suggested remedy is to delete
  the bundle from `dsh.profile.bundles`, and following it turns Agent Teams
  off for real — which is why it ships now instead of in the next batch.

- Unchanged, deliberately: a community bundle that resolves nowhere is still
  fatal, on Desktop as anywhere else. The profile's own node_modules is
  probeable, so that verdict is true.
2026-09-24 14:49:17 +08:00
fkysly 98a182cc04 fix(check): an official bundle is unknown on a Desktop install, whoever made it (#676)
Three reporters, one rule stated three ways, and two of the ways were fixed
name lists. `DESKTOP_HOST_BUNDLES` held exactly one bundle
(`@deepseek-ai/dsh-experimental-agent-team-profile`) and
`DESKTOP_HOST_LOADERS` exactly one loader (`@deepseek-ai/dsh-mcp-client`);
anything else a Desktop build ships and this process cannot probe got
"bundle package is not installed — the profile will fail to boot". yuj-029 hit
it with `-profile` AND `-web-profile` in the same 7-bundle profile, with the
same reason each time: the name was not on the list. KannaKuron supplied the
part that makes it mechanical — inside `resources/app.asar` a filesystem
probe answers "absent" for every name, the host's own included, so the list
could only ever be a list of the names someone had already reported.

What the rule actually is: the LAYOUT is unreadable (an archive — tested by
`isPackagedDesktopInstall`, now any `app.asar` segment rather than only the
`app.asar/dsh` anchor shape, which is not the shape `dshHostInfo` discovers),
and the name is DeepSeek's (`@deepseek-ai/` — only they publish the host), so
the host may be supplying it from somewhere this process cannot see. That
reads as unknown. Both lists are gone; nothing replaced them.

Kept: a community bundle on a packaged Desktop is still fatal, and so is an
official bundle on a readable installation — there the profile's own
node_modules ancestry and the installation's are both probeable, so "in
neither" is a true statement about the next boot. Three tests fail if the
scope bound is dropped, including two that predate this change.

Also pinned, from qikairo7's counterexample: `@deepseek-ai/dsh-agent-preset`
existing ONLY in the installation, referenced by a user-patch row, is
accepted — because the installation's own node_modules inventory is the
authority. Nulling that inventory (falling back to the curated seed) fails
the test, which is the point: the seed would not have known the name.
2026-09-24 14:49:02 +08:00
fkysly 8fd03535e8 release 1.62.0: the ssh half of the git hang, and a version label that tells the truth
- A git install that falls back to ssh no longer waits on a passphrase
  prompt nobody can answer (#587's ssh half, from @JINITAIMEI121's #596,
  finished in #713). pnpm tries https first — measured — so this covers
  the second attempt: a private repository, where https fails and ssh
  asks a question on a terminal a spawned child does not have.
  `BatchMode=yes` turns that into a fast failure, and ONLY when the user
  has expressed no ssh identity anywhere (GIT_SSH_COMMAND, GIT_SSH, or
  core.sshCommand in git config — GIT_SSH_COMMAND silently overrides all
  three). The failure message names ssh-agent, because git's own words
  ("Permission denied (publickey)") send the reader to check a key that is
  usually fine.
- The version on a plugin card says where it comes from (#712). It is the
  catalog's copy, refreshed daily, and the tooltip called it "npm latest"
  — beside a number that is not npm's latest. It now names the catalog's
  own build date, so a plugin published since the last refresh reads as
  data age rather than as a bug.
2026-09-24 12:36:37 +08:00
fkysly 144d9343ed fix(client): say where the version on a card comes from, and how old it can be (#712) (#714)
Reported by @ziduup: the card showed v0.3.4 for a plugin npm had at
0.3.6. The number is the CATALOG's `version`, refreshed once a day — not a
live npm lookup — so a release published since the last refresh shows the
older number until the next one. That part is by design (one catalog fetch
instead of one npm request per card).

What was not by design is the tooltip, which said "npm latest" beside a
number that is not npm's latest. That is how a question about data age
becomes a bug report, and the fix is to answer it in place: the tooltip
now says the version is the catalog's copy as of its last refresh, with
the catalog's own build date — `<catalog>/plugins.json` carries
`updated`, the client already read it to detect a changed catalog, it was
just never shown.

Falls back to "the catalog updates daily, so this can lag npm" when the
date is absent, which is also what an empty string counts as.

1708 tests; full e2e, 9 files / 44.
2026-09-24 12:35:46 +08:00
fkyslyandJINITAIMEI121 795b4f0b4c fix(cli): close the ssh prompt too, without taking over the user's identity (#596) (#713)
Takes over #596 by @JINITAIMEI121, whose half of #587 is right and whose
branch has not compiled since 09-15 (conflict markers committed) and rests
on a premise I measured to be backwards. Two days after I said so on the
PR, and nine after their last commit, so it is finished here rather than
left as a PR nobody can merge.

What survives from theirs: `GIT_SSH_COMMAND` with `BatchMode=yes`, so an
ssh passphrase or host-key question fails fast instead of waiting on a
terminal nobody is watching.

What changes:

- The premise. They wrote that hosted-git-info resolves the ssh form, so
  pnpm clones `git@github.com:…`. Measured with a `GIT_SSH_COMMAND`
  sentinel that always fails: pnpm's clone of `github:o/r#path:/sub`
  completes without ever calling it, so https is FIRST and ssh is the
  fallback after https fails — the private-repository case. That matters
  because #593's `GIT_TERMINAL_PROMPT` already covers the path that
  actually runs first; this covers the one that runs second.

- The regression they did not handle. `GIT_SSH_COMMAND` overrides
  `core.sshCommand` and `GIT_SSH` — measured, with core.sshCommand set the
  environment wins and the configured command never runs — so setting it
  unconditionally would replace the identity of every user who chose one,
  and BatchMode would then break the passphrase installs that work today.
  It is now set only when the user has expressed no preference anywhere:
  both variables, blank counting as unset, and `core.sshCommand` read from
  git config (memoized for the process, injectable for tests).

- The message. git's own words are `Permission denied (publickey)`, which
  reads as "your key is wrong" — the key is usually fine, it wants a
  passphrase, and the channel that would have asked is what was shut. That
  failure now names ssh-agent and GIT_SSH_COMMAND.

Every guard mutation-checked: not checking the user's identity, not setting
the variable, not classifying the failure, and dropping it from spawnEnv
each turn a test red. 1706 tests; full e2e, 9 files / 44.

Co-authored-by: JINITAIMEI121 <JINITAIMEI121@users.noreply.github.com>
2026-09-24 12:21:37 +08:00
fkysly ef4192a7ca release 1.61.0: no dangling links after uninstall, and failures that explain themselves
- Uninstalling a plugin on a desktop build no longer leaves a dangling link
  in the host deployment's node_modules (#662, fixed by @qikairo7 in #708).
  The boot projection creates those links and never removes them, so after
  an uninstall they point at a package directory that is gone — and any
  tool that walks node_modules with lstat trips over them (`rg` exits 2
  with `os error 2`). Only a link whose target is exactly THIS profile's
  removed copy is unlinked: never a real directory, never another
  profile's link, never a live bridge.
- A failure the market cannot classify now shows the diagnostics file the
  dsh CLI pointed at, instead of the single line it prints (#672). That
  line — `dsh: pnpm failed; diagnostics: <path>` — is all the CLI leaves on
  stderr; pnpm's actual output is in the file. Bounded to the last 8 KB of
  an absolute path that is a regular file.
2026-09-24 10:06:01 +08:00
fkysly d300235fa2 fix(install): show the diagnostics file the dsh CLI points at (#672) (#711)
Reported by @shenhuanageshei (and the shape recurs in #244, #192, #138):
`dsh plugin` redirects pnpm's whole output into a file and prints only

    dsh: pnpm failed; diagnostics: <path>

so every failure the market cannot classify reads as one unhelpful line —
whatever the cause, including the causes pnpm wrote down in full.

The market now reads the tail of that file and shows it under a labelled
heading. Bounded on purpose: absolute paths only, a regular file, at most
the last 8 KB. The path comes from our own child, but the market only ever
wants the end of a log, and reading an arbitrary amount of an arbitrary
file is not worth what it could add.

Two guards, each with a test that fails without it: a relative path is
refused even when such a file exists (the child's cwd is not this
process's), and a large file yields only its end.

The report's other two suggestions are not taken here. A blanket retry is
what withHoistRecovery already does for the classes it can identify; a
third, "install by calling pnpm directly", would bypass the host's own
plugin pipeline — the boundary #672's own discussion and #702 both argue
to keep.

1703 tests.
2026-09-24 10:05:14 +08:00
fkysly 7713812fc7 release 1.60.0: the market and DSH's own plugin page stop contradicting each other
- A plugin enabled on DSH's own Settings → Plugins page is no longer
  switched back off by the market (#696, reported by @KannaKuron). The
  page enables a row by flipping it to `disabled: false` in the shared
  patch file; the market kept its own disable list and its self-heal guard
  put the plugin down again within a second, and again on every boot. A
  row explicitly enabled there, with none disabled, is now read as the
  newer decision and the market's list follows it.
- A plugin the page turned off by removing it from `dsh.profile.bundles`
  shows as off in the market, and enabling it there puts it back. Before,
  the market showed it enabled while nothing loaded it, and toggling it in
  the market could not bring it back.
- The desktop app's own bundles are no longer reported as "not installed —
  the profile will fail to boot" while they are running (#676, reported by
  @yuj-029 and @KannaKuron). The warning told users to reinstall or remove
  a bundle that was working. While the DSH installation cannot be located,
  an official bundle is now unknown rather than missing.
- pnpm 12's native engine running out of memory is named, with the way
  around it, instead of shown as a raw Rust abort (#701, reported by
  @ChongCyrus); and a fresh install that fails half-way now restores the
  lockfile as well as the manifest.
2026-09-24 01:55:43 +08:00
fkysly 97cd106cdd fix(toggle): stop fighting DSH's own plugin page over what is enabled (#696) (#707)
Reported by @KannaKuron with both managers' mechanisms traced in source:
DSH's Settings → Plugins page and the market share one layer, the user
patch file, and each keeps another the other cannot see — the page
switches a package by adding it to or removing it from
`dsh.profile.bundles`; the market keeps a disable list in state.json that
its self-heal guard and boot replay enforce. Verified on the market side:
the guard pushes down any fiber whose name is on that list,
unconditionally, and the installed listing never reads dsh.profile.bundles.

Two of the three reported scenarios are fixed here:

- An enable made on the official page is followed, not undone (C). The page
  enables a row by flipping it in place to `disabled: false`. The market
  removes a name from its list BEFORE writing its own enable, so a package
  on the list whose rows are explicitly enabled — with none disabled — can
  only reflect a newer decision made elsewhere. The guard and the boot
  replay now drop it from the list instead of switching it back off.

- A package the official page turned off by removing it from
  dsh.profile.bundles reads as off, and enabling it in the market puts it
  back (A). Before, the market showed it enabled while nothing composed
  it, and toggling it flipped patch rows and left it out for good. The
  listing reports it under a new `unbundled` field that the client folds
  into the switch state; enabling re-adds it for the next boot and brings
  it up now, so a restart is asked for only if it is not live afterwards.

Scenario B — the official page showing a market-disabled package as
enabled at the package level — is a question of which layer the market
should write, and is left for the issue.

The testbed's host `on` was a no-op, so the guard had never been exercised
by a test. It now records listeners. Writing these cases also caught two
tests of my own that could not fail: a fixture whose entry was already
disabled, and an assertion that compared update()'s arguments exactly
when it is called with three. Each fix is now mutation-checked against the
test aimed at it. 1684 unit tests; full e2e, 9 files / 44 tests.
2026-09-24 01:54:58 +08:00
fkysly 6cd1247e74 fix(install): name pnpm 12's native out-of-memory abort, and roll back the lockfile a failed install leaves (#701) (#706)
Reported by @ChongCyrus with an A/B on pnpm 12.5.1 (Windows, 8 GB): every
run with `autoInstallPeers: false` in the workspace file — DSH writes it
into every profile — aborted in pnpm-native at ~5 GB peak RSS with
"memory allocation of 5368709120 bytes failed", exit 3221226505
(0xC0000409, how a Rust abort ends on Windows); every run without it
passed at ~80 MB. The reporter has taken it to pnpm. Not reproduced on
macOS here (both configurations exit 0 on a small profile).

The market showed the raw abort. It is now classified `native-oom` with a
message that says the two things a user needs: the plugin is not the
cause, and pnpm 11 is the way around it until pnpm fixes it. No automatic
retry: on the reporter's own data every run with the key fails, so a retry
only doubles the wait.

Also from the report, and confirmed in the code: a failed FRESH install
restored the manifest but not pnpm-lock.yaml. pnpm writes the lockfile
before it links, so a run that dies in between left a lock naming a
package package.json never got. The update route has always restored
both; the install route now does too, reusing its helpers.

Both mutation-checked. 1678 tests.
2026-09-24 01:40:16 +08:00
fkysly e4ba7dc95f fix(check): an official bundle is unknown, not missing, while the installation is out of sight (#676) (#705)
Reported by @yuj-029 and @KannaKuron on #676, against both a packaged
desktop build and the official desktop client:
`@deepseek-ai/dsh-experimental-agent-team-profile` was reported as
"bundle package is not installed — the profile will fail to boot" while
its three entries were active in the running host. On the official
client that warning surfaced beside every failed install.

A desktop build ships more in-box bundles than the three in INBOX_BUNDLES,
and #703 added this one only behind `isPackagedDesktopInstall`, an
app.asar path-shape test — which cannot pass when the installation is not
locatable at all, the state yuj-029's log shows ("dsh host: not locatable
from this process").

The principle is #369's: when the installation cannot be seen, what it
may supply is unknown, not missing. Only DeepSeek publishes under
`@deepseek-ai/`, so while the installation is out of sight such a bundle
is now marked unresolved rather than fatal. What stays fatal is pinned by
tests: a community bundle absent from the profile, and an official bundle
absent from an installation that IS located.

Reverting the condition turns the new case red. 1677 unit tests; full
e2e, 9 files / 44 tests.
2026-09-24 01:40:05 +08:00
fkysly 20d9245080 release 1.59.0: the official desktop app can install again, and one approval no longer breaks pnpm 10
- On the official desktop app every install, update and uninstall failed
  with `profile "desktop" is managed exclusively by the Electron
  application` (#702, reported by @KannaKuron). #644 had correctly aimed
  them at the `desktop` profile — through `dsh plugin`, which refuses that
  profile by NAME. They now go through the app's own `pluginManager`
  service (built by @svptk87tx8-dotcom in #687, taken over in #703), with
  detection following the CLI's rule rather than an install-anchor shape
  that could not be verified. A failed enablement is no longer reported as
  a successful install; an operation the manager cannot express points to
  Settings → Plugins.

- On pnpm 10.26 through the latest 10.x, and 11.0 through 11.5, approving
  build scripts for a git plugin broke every later package operation in
  the profile (#704). pnpm reads an allowBuilds key as a version range,
  and the git-source keys the market writes fail the whole workspace file
  with "Invalid versions union". The market now recognises that failure,
  removes only the source-form keys — bare names authorize a git
  dependency on those versions — and retries once, which also repairs
  profiles an earlier approval already broke. Measured across pnpm 9.15
  to 12.4.

- "Allow build scripts and retry" works for a TRANSITIVE git dependency
  (#698, reported by @Takeoff0518). The refused package was in neither
  node_modules, the manifest nor the catalog, so the route answered
  "no installed packages given" and looped. pnpm's own refusal in this
  process is now an anchor: the key written is the one pnpm printed.

- An update whose new commit renamed the package is rolled back instead of
  leaving a profile the desktop app refuses to start (#694, by @po-et in
  #699). pnpm installs the renamed package under the old dependency key
  and exits 0 — measured.

- On dsh 0.1.7, the market no longer throws inside the settings service,
  which lost `register` (#677, stop-gap in #693); and a self-hosted git
  remote's build-approval key is kept as spelled, with or without `.git`
  (#695).

Also: the private vulnerability reporting form is actually enabled now
(#700). It was not, despite SECURITY.md saying so.
2026-09-24 01:12:45 +08:00
fkysly 491f67a868 fix(approve-builds): approve a refused transitive git dependency, and stop git keys breaking pnpm 10 (#698) (#704)
Reported by @Takeoff0518: "Allow build scripts and retry" answered
`no installed packages given` and the install looped. Reproduced against
the reporter's plugin with real pnpm. Two bugs, one found while measuring
the other.

1. The refused package was a TRANSITIVE git dependency
   (`@dsh-external/dsh-super-injector` inside dsh-routing-suite). The
   approve route only allows names it can anchor to node_modules, the
   profile manifest or the curated catalog, and a transitive dependency of
   a failed install is in none of them. pnpm's own refusal in this process
   is now an anchor too: the client names the package, and what is written
   is the bare name plus the key pnpm PRINTED for it — never request text.
   pnpm 11.8 prints `name@https://codeload…/tar.gz/<sha>`; pnpm 10 prints
   an onlyBuiltDependencies example with the bare name, which is what it
   needs. The record supplements the catalog anchor, never replaces it: for
   a catalog plugin the derived keys are what pnpm 11.21 matches.

2. Measured while doing that: pnpm reads an allowBuilds key as
   `name@<version union>`, and on 10.26 through the latest 10.x and on 11.0
   through 11.5 a git or archive source there fails the WHOLE workspace
   file — `Invalid versions union … Use exact versions only` — so every
   later pnpm command in the profile fails, including an install of an
   unrelated npm package. Those are exactly the keys the market writes for
   a git source (#68, #285, #637). Matrix, bare / git+https / codeload:

       9.15, 10.0–10.25   ok / ok / ok     (allowBuilds not read)
       10.26–10.29        ok / BROKEN / BROKEN
       11.0–11.5          ok / BROKEN / BROKEN
       11.6–12.4          ok / ok / ok

   pnpm 10's latest release is in the broken range. Rather than guess the
   version `dsh plugin` will run, the market now recognises the failure
   (`unparseable-build-key`), removes only the source-form keys from
   allowBuilds — bare names and explicit `false` stay — and retries once.
   On those versions the bare name is what authorizes a git dependency
   (measured on 10.29). This also repairs profiles an earlier approval
   already broke. Verified end to end against real pnpm 10.29.3: first run
   INVALID_VERSION_UNION, keys removed, retry exit 0.

setAllowBuilds' YAML read/write is factored into two helpers so the
remover shares its CRLF and duplicate-block handling instead of copying it.

Every piece mutation-checked. 1662 unit tests; full e2e, 9 files / 44.
2026-09-24 01:11:51 +08:00
fa8722a07d fix(desktop): route the official desktop profile to its plugin manager, detected by name (#702) (#703)
* fix: use official desktop plugin manager

* fix(desktop): route the official desktop profile to its plugin manager, detected by name (#702)

Takes over #687 by @svptk87tx8-dotcom, whose routing through the official
`pluginManager` service is kept; it had not moved since review, and #702
shows every install, update and uninstall failing on the official desktop
app — a regression from #644, which fixed the profile detection and so
sent those operations to `dsh plugin --profile desktop`, which the CLI
refuses.

Three changes to the original:

- Detection follows the CLI's own rule. `@deepseek-ai/dsh` 0.1.7-alpha.2
  refuses a profile by NAME, `profile.toLowerCase() === "desktop"`, so a
  profile with that name can only come from the Electron app. #687 keyed on
  an install anchor ending in app.asar/dsh/package.json; the official
  desktop host is not published, that layout could not be checked, and a
  host that missed the test fell back to the CLI and failed every install —
  the #702 reporter's host is `@deepseek-ai/dsh-desktop-host`, a different
  package. A third-party shell still announces itself through
  `desktopProfiles`, and this block runs only when that is absent.
- A failed application is never exit 0. The official ChangeResult says
  "successful installation can proceed to enablement", so stage `enable` +
  application `failed` follows a pnpm run that exited 0; mapping
  packageResult.exitCode there reported a failed enablement as a
  successful install. `overridden` — a change the manager KEPT, whose
  live state a user patch decides — is success with a note, not a failure
  that sends the update route into a rollback.
- `update` is refused with a pointer to Settings → Plugins instead of
  being rewritten to `name@latest`, which crossed the installed range and
  ignored the release channel. So is an absent manager: the user gets
  something to do, not "no CLI fallback is allowed".

Existing cases that used a launcher profile NAMED `desktop` to mean "any
launcher profile" (#639) or "a profile whose own package manager the CLI
path uses" (#653) now name it `work`: under the CLI's rule a `desktop`
profile never reaches the CLI path, so those cases described something
that cannot happen. The #702 case — `desktop`, no anchor — is added, and
matched case-insensitively as the CLI does.

Each change mutation-checked. 1666 unit tests; full e2e on a real host,
9 files / 44 tests.

Co-authored-by: svptk87tx8-dotcom <svptk87tx8-dotcom@users.noreply.github.com>

---------

Co-authored-by: Alex Lin <alexlin215@icloud.com>
Co-authored-by: svptk87tx8-dotcom <svptk87tx8-dotcom@users.noreply.github.com>
2026-09-24 01:11:39 +08:00
fkysly e2c53bcb5f fix(settings): stop throwing on dsh 0.1.7's settings service, which has no register (#677) (#693)
Reported by @dzwalker. Verified against the published packages:
@deepseek-ai/dsh-settings@0.1.7-alpha.2's SettingsService has `describe`
and `update` and no `register` — 0.1.5-rc.3's has all three. Namespaces
are now derived from a plugin's Config schema. The `settings` service still
exists, so the market's inject callback ran and `register` threw a
TypeError that cordis swallowed.

Both entry points (web and Desktop) now check for the method. Without it
the composed entry stands, and the host log says why the allowRestart
switch is absent — once, instead of a swallowed TypeError on every boot.

This is a stop-gap and says so in the file header. The migration to the
0.1.7 model — Config-schema-derived settings on the host, `configForms`
and the `settings.plugins.tab` slot on the client, where `settingsScope`
is also gone — needs a real 0.1.7 host to verify against, and is tracked
on #677.

A pre-0.1.7 case pins that the guard does not swallow a working register.
Reverting the guard turns the 0.1.7 case red. 1653 tests.
2026-09-23 10:54:55 +08:00
fkysly 4d441ee3e5 release 1.58.0: updates work on dsh 0.1.7, and off-and-on stops pretending
- On dsh 0.1.7-alpha.1 EVERY plugin update was refused and rolled back
  with "bundle declares no dsh.bundle.patch — the profile will fail to
  boot" (#688, reported by @eddiedon with the three target manifests
  checked). The message was not about the package being updated. The
  update route's pre-boot trial builds every bundle layer, and the in-box
  @deepseek-ai/dsh-web-app declares its patch as a LIST of five files; the
  analyzer required a string (#676, fixed in #686), and the route reported
  the first error without its layer name, so it read as blame for whatever
  the user had just updated. The trial now composes every declared file,
  not only the first, and the failure text names the layer.
- The diagnostics page no longer calls the official 0.1.7 layout
  unbootable for the same reason (#676, reported by @KannaKuron). The
  report's second finding — that a user patch may load a package only the
  dsh installation has — was checked against the loader and left as it
  is: user-patch entries resolve from the profile unless the host passes
  bareModuleBaseUrl, and the CLI does not.
- Turning a plugin off and on after an update no longer reports it as
  live while the process still serves the old build (#685, reported by
  @HorusJiang with a module-scope probe). Re-enabling re-creates the fiber
  around the SAME module URL, which Node serves from its cache — the
  profile layout is hoisted, so an update rewrites files in place. The
  toggle reply now gives the listing's verdict (restart), and a hot mount
  no longer clears the "replaced while live" flag, whose comment claimed
  such a mount imports the module as it is on disk now. Measured end to
  end: after update → off → on the fixture still runs 1.0.0.

Also: the web-e2e lane boots again (#684, #690). Two upstream releases
(cordis-plugin-loader >= 1.0.4, cordis-plugin-hmr >= 1.0.18) stopped the
pinned CLIs booting; the lane now installs its host with those two pinned.
2026-09-23 10:38:22 +08:00
fkysly 042754e542 fix(toggle): stop calling an off-and-on "live" while the old module runs (#685) (#691)
Reported by @HorusJiang with a module-scope probe and a positive control:
after an update, turning a bundle-layer plugin off and on destroys and
re-creates its fiber (its route 404s, then 200s), but the new fiber
evaluates the SAME module URL, and Node's ESM cache serves the old one. The
profile layout is hoisted, so an update rewrites files in place and the URL
never changes. The toggle reply said `activation: live, hot: true,
restart: false` regardless — it asked the loader's inventory, where the
name is present, not whether the code was new.

Reproduced on a real host before changing anything, in
tests/web/update.e2e.ts: the fixture reads its version at module scope and
writes it from apply(), so after update → off → on it reported 1.0.0 while
the reply said `live`.

Three changes:

- The toggle reply applies activationAfterReplace, the verdict the
  /installed listing already gave. The reply and a refresh of the listing
  told different stories about the same moment.
- `restart` is true when enabling a plugin this process replaced while its
  host half was live.
- setPluginEnabled no longer clears the replaced-while-live flag after a
  successful hot mount. Its comment said such a mount "imported the module
  as it is on disk NOW"; that is false exactly when the flag is set, since
  the flag is only set when this process already evaluated that URL, and
  MarketHotTree.import is a plain super.import that busts no cache.

The flows case that asserted the notice clears after an off-and-on encoded
that unmeasured belief. It is inverted, not deleted, with the measurement
cited.

1650 unit tests; the full e2e suite passes on a real host, 9 files / 44
tests, including the new case and the restart that follows it (2.0.0,
live).
2026-09-23 10:37:43 +08:00
fkysly 6073eb698d fix(update): stop rejecting every update on dsh 0.1.7 over the in-box bundle's patch list (#688) (#689)
Reported by @eddiedon with the three target manifests checked against the
registry — each declares `dsh.bundle.patch: "./cordis.patch.yml"`, so the
message "bundle declares no dsh.bundle.patch" was not about them. It was
about `@deepseek-ai/dsh-web-app@0.1.7-alpha.1`, whose patch is a LIST
(#676), and the update route's pre-boot trial reported only the first
error's MESSAGE, dropping the layer name. Every plugin update on that host
was therefore refused and rolled back, blaming whichever package the user
had just updated.

Two things, on top of #686's list handling in the analyzer:

- The trial's composition now parses EVERY declared file into the layer's
  patches, not the first. #686 collected the entry ids from all files but
  `layers[].patches` — what composeLayers actually replays — still read one,
  so a duplicate id or an orphan in a preset file was invisible to the
  trial. Caught by the new test's row assertion, which failed on #686 alone.
- The update route's failure text names the layer: `bundle
  @deepseek-ai/dsh-web-app: …` instead of a bare message that reads as an
  accusation of the wrong package.

The regression test models the in-box bundle where the real loader gets it
— under the dsh installation, not the profile, whose copy is a stale shadow
the trial ignores (an earlier draft put it in the profile and passed
vacuously). Reverting the list handling turns it red.

1651 tests.
2026-09-23 10:37:34 +08:00
fkysly a15fa5ab7d ci: pin the e2e host's cordis plugins, which stopped booting (#684) (#690)
* ci: pin the e2e host's cordis plugins, which stopped booting (#684)

Since 2026-09-22 every web-e2e job — main and every PR — failed before the
market was reached:

    dsh: user patch-layer watching requires the Cordis HMR service

Reported by @liuwenji007 with the timeline. The CLI was pinned
(`npm install -g @deepseek-ai/dsh@<matrix>`), but its cordis plugins are
caret ranges, so a fresh install took whatever was newest.

Bisected on a fresh dsh@0.1.0-rc.8 install by swapping one package at a
time and booting:

- cordis-plugin-loader >= 1.0.4: the HMR service is never registered,
  which is the error above;
- cordis-plugin-hmr >= 1.0.18: fails later, on hmr.registerConfig, against
  these CLIs' dsh-app-boot;
- loader 1.0.2 + hmr 1.0.16 with everything else newest: boots, on both
  matrix versions. Without the pin 0.1.2-alpha.2 crashes the same way.

A global install ignores npm `overrides` (root project only), so the host
is now installed as the dependency of a throwaway project that carries
them, and its bin directory goes on PATH. The full e2e suite passes
locally against it: 9 files, 43 tests. Nothing in the market changes.

Remove the overrides once the pinned CLIs resolve to a set that boots
again; the job going green without them is the signal.

* ci: install the pinned e2e host with pnpm, not npm

npm resolved the dsh@0.1.0-rc.8 tree under the two overrides in 403s
locally and 15 minutes on the ubuntu runner (0.1.2-alpha.2 was quick, so
it is arborist backtracking on that tree, not the network). The Windows
job was still inside the install step when this was written.

pnpm with node-linker=hoisted — the same tree shape as the global npm
install this replaces — installs the same versions (loader 1.0.2,
hmr 1.0.16) in about 8s, on pnpm 10 as CI uses. Measured against it:
both matrix versions boot, and the full e2e suite passes, 9 files and
43 tests.
2026-09-23 10:32:11 +08:00
fkysly a10f17a2d9 fix(check): accept a bundle that declares several patch files (#676) (#686)
Reported by @KannaKuron against dsh 0.1.7-alpha.1, with the official
manifest as evidence. `@deepseek-ai/dsh-web-app@0.1.7-alpha.1` declares

    "patch": ["./cordis.patch.yml", "./presets/standard.patch.yml",
              "./presets/ptc.patch.yml", "./presets/minimal.patch.yml",
              "./presets/cordis.patch.yml"]

— verified straight from the registry — and the official headless template
includes that bundle. The analyzer required a string, so the DEFAULT
profile layout was reported as "the profile will fail to boot" while
`dsh --profile web --dump-config` composed it with exit 0. A diagnostic
that calls a working profile broken is worse than no diagnostic: the
reporter saw users delete an official bundle on its advice.

Every declared file now contributes entries, not just the first — a patch
list is a list because the composer applies all of them, and parsing one
would leave the other files' rows looking like unknown ids downstream.

The report's SECOND finding is NOT fixed here, deliberately. It asks for a
user-patch row naming an install-level package to stop being an error,
citing `resolveBundleDir`'s "installation anchor first" contract. That
contract is for BUNDLES. A user patch's entries are imported through the
root Include, which resolves bare specifiers against `ctx.baseUrl` — the
PROFILE directory — unless the host passes `bareModuleBaseUrl`, and the dsh
CLI's `boot()` call does not pass it (read in
@deepseek-ai/dsh-app-boot 0.1.0-rc.8 and the CLI's profile-boot bundle).
So the existing #205 case is right and its test stays: a package only the
installation has is one the profile's Loader cannot see. Answered on the
issue with the source reading rather than silently ignored.

1650 tests. Reverting the list handling turns two of them red.
2026-09-23 01:26:48 +08:00
fkysly eaf57d79a3 release 1.57.0: the market renders on dsh 0.1.7-alpha.1 again
- Every market page crashed with React #130 on host 0.1.7-alpha.1 (#671,
  #673, reported with the component stack and the host's export list; fixed
  by @liuwenji007 in #681). That release renamed the ui-primitives icons
  from a size suffix to a weight one — IconSearchOutline16 became
  IconSearchOutlineRegular (1px) and …Medium (1.3px) — and dropped the old
  names outright, so all 17 icons this package imported were undefined and
  the first one rendered threw.

  The fix resolves each glyph by name at call time: the size-neutral name
  first, the pre-0.1.7 name second, and an empty glyph when a host has
  neither. A missing icon is now a cosmetic gap that cannot blank the page
  again. The alias table is checked against a fixture of the real 0.1.7
  export list, so the table cannot agree with a test that is wrong in the
  same way — a corrupted alias name turns it red.

  `latest` is still 0.1.5-rc.2 and exports only the old names, which is why
  both spellings have to stay.
2026-09-23 00:47:05 +08:00
fkysly 36b8c23e0c release 1.56.0: security — a loopback Host is what stops DNS rebinding
- Every mutating /dsh-market/* route, and the restart and log-export
  fences, decided trust by comparing Origin with Host (#678, reported by
  @Ho-J with a working repro). A DNS-rebinding page defeats that by
  construction: served from evil.com, that name resolves to 127.0.0.1, so
  the browser connects to the loopback listener while sending BOTH headers
  as evil.com — the equality holds for the attacker, and the page is
  same-origin with its target, so it can read responses and drive every
  route, including installing a plugin. Measured before the fix:

      sameOrigin({ host: 'evil.com', origin: 'http://evil.com' })  →  true

  Host is the one header the attack cannot forge, so it now has to name a
  loopback authority (127.0.0.1, localhost, [::1]; localhost.evil.com is a
  subdomain and does not match). An ABSENT Host is still allowed, because
  browsers always send one — a request without it is not from a page — and
  the Desktop build's proxy strips headers before forwarding (#648).

- The installed-groups view is tightened (#668, by @bulingbuling688): group
  rename and add-member moved into modals, and the layout with them.

Upgrade promptly if your dsh web is reachable by anything other than the
machine it runs on; on a loopback-only setup the practical exposure was a
page the user had open.
2026-09-23 00:14:14 +08:00
fkysly 9be13bf45f fix(security): require a loopback Host, which is what stops DNS rebinding (#678) (#682)
Reported by @Ho-J with a working repro and the correct diagnosis. The three
fences — sameOrigin() and restart.ts's trustedRestartRequest() /
trustedDownloadRequest() — all decided trust by comparing Origin with Host.
A DNS-rebinding page defeats that completely: it is served from evil.com,
that name resolves to 127.0.0.1, and the browser then connects to the
loopback listener while sending `Origin: http://evil.com` AND
`Host: evil.com`. The equality holds FOR THE ATTACKER, the peer address is
loopback (so restart.ts's address check passes too), and the page is
same-origin with its own target — it can read responses and drive every
mutating route. Measured before the fix:

    sameOrigin({ host: 'evil.com', origin: 'http://evil.com' })  →  true

Host is the one header the attack cannot forge, so it is what has to name a
loopback authority: 127.0.0.1, localhost, or [::1], with the port dropped
and `localhost.evil.com` not matching.

An ABSENT Host is still allowed, deliberately: every browser sends Host, so
a request without one is not from a page, and the Desktop build's proxy
strips headers before forwarding (#648). A rebinding page can never reach
that branch.

This is not a regression from #648 — that change concerned requests with no
Origin, and the rebinding request carries one. But #648's PR body called
the same-origin check "the CSRF case", and this is the case it was not
covering.

1634 tests. Reverting the Host check turns two of them red.
2026-09-23 00:13:10 +08:00
fkysly 981ce6e0fe release 1.55.0: build approvals on every host, and badges that stop lying
- "Allow build scripts and retry" now writes a key pnpm accepts for a
  GitLab, Bitbucket or self-hosted install (#665, by @po-et). It wrote the
  bare package name, which is the one form pnpm does NOT match (#68/#69),
  so the button looked like it worked, the YAML gained a line, and the
  retry failed identically. Only reachable since #637 stopped these
  installs from being replaced by a same-named npm package first.
- The Favorites tab loads host requirements for its cards (#669, by
  @liuwenji007). It reuses the Discover card, whose badge read "Reading
  host requirement…" forever there, because only Discover ever triggered
  the fetch. And the badge is quiet unless it has something to say (#666):
  a card that meets its requirement, or cannot be checked, no longer
  carries a mark.
- The host package manager's env is documented as REPLACING the process
  env rather than filling its gaps (#653, by @Yur0918) — deliberately the
  opposite of the GIT_*/PNPM_CONFIG_* rule, because PATH is a capability
  (where node is), not a preference.
2026-09-22 15:20:54 +08:00
fkysly 35d06ee19f release 1.54.0: installs that would break the host, and uninstalls that lied
- A fresh install is now refused when the release itself declares it needs a
  NEWER host (#588, by @Yur0918 in #560). The update route has had this
  guard since #404/#473; installs did not, so the market would happily put a
  version into the profile that breaks `dsh web` on the next boot. Only a
  declaration that was READ and is not SATISFIED stops anything —
  undeclared, unreadable and an unknown host version all pass, because the
  absence of a claim is not a verdict. Both directions are covered by tests:
  treating `unknown` as incompatible fails two, never refusing fails two.
- Uninstalling a plugin whose native addon is declared in
  `optionalDependencies` now asks for a restart instead of a page refresh
  (#441, by @liuwenji007 in #660). `SinglePlayer` ships `node-hid` exactly
  that way — `dependencies` is empty — so the uninstall was treated as plain
  JavaScript, and a refresh is precisely what cannot release a loaded
  `.node`.
- A plugin installed from a URL — the shape a China-region install produces —
  now carries an identity, so its catalog card reads as installed (#432,
  fixed in #659). The spec names its own source and the market was answering
  "nothing"; it now derives the repo from the URL, while still NOT reading
  the package's own manifest, which for a fork names upstream (#580).
- The repository now has a SECURITY.md and a private reporting channel
  (#603, in #661). A security report had nowhere to go but a public issue.
2026-09-22 00:56:06 +08:00
fkysly 0c9ba883ff docs: add SECURITY.md, and a channel that is actually private (#661)
Reported in #603, which could not find one: the repository had no
SECURITY.md and private vulnerability reporting was not enabled, so a
security report had nowhere to go but a public issue — where the reporter
correctly withheld the request paths.

GitHub's private vulnerability reporting is the channel. The file also
states what this project is, because severity depends on it: the market
holds no credentials, registers its routes with the host's web server
rather than implementing a login, and runs shell commands against the
user's own profile by design.
2026-09-22 00:03:43 +08:00
fkysly 777bcc17fe fix: read a URL install's identity off its URL, instead of nothing (#432) (#659)
A plugin installed from a URL had no identity, so its catalog card never
read as installed. Reported as #432 with a China-region trigger, and the
symptom is real; the proposed mechanism is not — this measures what the
current dsh CLI actually leaves behind.

    $ dsh plugin --profile t1 add \
        "https://gh-proxy.com/https://codeload.github.com/o/r/tar.gz/<sha>"
    { "dependencies": { "is-plain-obj": "https://gh-proxy.com/https://codeload.github.com/…" } }

The spec keeps the https URL. There is no `file:` rewrite, no
`bundled-plugins/`, and no sibling `url-<hash>.json` sidecar — the layout
#432 reads. On that spec, before this commit:

    repoOfTarget(spec)                → "sindresorhus/is-plain-obj"
    readInstalledRepoEvidence(spec)   → { identities: [], hints: [] }

`readInstalledRepoEvidence` returned nothing for any spec that names its
own source, which was the right instinct for the WRONG half of the
question: it must not resolve such a spec through the package's own
manifest (#580 — a fork's manifest names upstream), but it must not resolve
it to nothing either, because the spec IS the identity.

The existing #544/#548 test asserted the empty list as a proxy for "does
not read the manifest". It now asserts the sharper thing: for
`github:myfork/dsh-plug` the identity is `myfork/dsh-plug` and never
`upstream/dsh-plug`. Reverting the two-line behaviour turns it and the new
proxy-URL case red.

1614 tests.
2026-09-21 23:46:11 +08:00
fkysly 798867ea12 release 1.52.0: the Desktop build can change things again
- Every mutating market route answered 403 "untrusted origin" in the
  Desktop build (#648, reported by @mimidor with the root cause traced on
  both sides, fixed here). Its proxy strips `origin`, `host`, `cookie` and
  `sec-fetch-site` before forwarding to the in-process host and injects a
  cookie only that host can sign, so "no Origin + authenticated cookie" is
  the trusted shape there — and `sameOrigin()` refused any request whose
  Origin was absent. Install, update, uninstall, restore and snapshots were
  all unusable. A browser sends Origin on every POST, so an absent one means
  the caller is not a page; and a process client can forge one anyway, so
  refusing the absence was never the protection it read as. A cross-site
  page, `Origin: null` and an empty Origin are all still refused.
- A dependency library stops reading as a broken plugin (#634, by @po-et in
  #641): pnpm's auto-install-peers writes a plugin's peers into the profile
  manifest, so a native binding looked exactly like a plugin the user chose
  and a plugin with no dsh surface of its own that another installed plugin
  declares is now labelled "{0}'s library" instead. A real plugin that
  happens to be a dependency of another stays a plugin.
- Typing in the market's search boxes no longer re-renders the whole list
  per keystroke (#549, by @booooodv). The input keeps its own draft — so
  characters still appear immediately — and commits after a 250 ms pause,
  on Enter, on blur, or at once when cleared. IME composition is excluded
  from all of it, including the Enter that confirms a candidate.
- Discovery cards can show the catalog's npm latest version on the byline,
  when the catalog provides one (#348, by @liuwenji007 in #515).
- Release-notes dialogs scroll, strip pasted HTML images, and render links,
  fences and quotes (#547); CJK and bilingual strings follow the active UI
  language (#534); the market's nav entry wears the block mark (#583); the
  host profile comes from the launcher rather than argv alone (#639, by
  @po-et in #644).
2026-09-21 01:17:46 +08:00
fkysly 722eaa7c06 fix: allow a POST that carries no Origin at all, so the Desktop build can mutate (#648) (#651)
Reported by @mimidor with the root cause traced through both sides. The
Desktop app's proxy (forwardWebRequest) strips `origin`, `host`, `cookie`
and `sec-fetch-site` before forwarding to the in-process host, and injects
a cookie only that host can sign. `sameOrigin()` refused any request whose
Origin was absent, so EVERY mutating route answered 403 `untrusted origin`
in the Desktop build — install, update, uninstall, restore, snapshots.

The check exists to stop another web page from driving this loopback API,
and the Fetch spec has browsers send `Origin` on every POST, same-origin
included. A request that arrives without it therefore did not come from a
page — and a non-browser client can set any Origin it likes, so refusing
the absence was never the protection it read as.

What has to survive, and does:

- a cross-site page is still refused (its Origin is present and different);
- `Origin: null` from a sandboxed iframe or a data: document is still
  refused — present-but-unparseable is not the same statement as absent;
- an EMPTY Origin is still refused, for the same reason;
- a present Origin with no Host to compare against is still refused.

Two tests are INVERTED rather than deleted, so the old rule cannot come
back by accident: the unit case that asserted "a mutating POST must carry
Origin", and an `it.each` over every mutating route. Reverting the one-line
behaviour turns seven of them red.

1519 tests.
2026-09-21 01:16:55 +08:00
fkysly d675534a89 release 1.50.0: an entry is not its package's name
The through-line is identity: a plugin's name is not its identity, and the
market had been treating the loader entry's name as one.

- A bundle patch whose row names a SUBPATH can be enabled again (#646, from
  @xiaohan13's report, in #647). `dsh-chat-import`, `meow-memory` and
  `dsh-context` name themselves, so `name === packageName` held for years;
  `aegis` mounts `aegis/extensions/dsh/index.js` and every site comparing
  the two concluded the plugin had no entry. Three sites restated the rule
  independently — verify, themes, routes — and now one module holds it. The
  two user-visible symptoms: "no bundle patch … nothing to hot-mount" for a
  package that plainly has one (because hotMount read only the package-root
  cordis.patch.yml while profile.ts resolved the manifest's declared path),
  and a restart demanded for a plugin that was already running.
- The same shape, for the disable side (#619, by @cuddly-guacamole in #620):
  a subpath entry, and a CARRIER bundle whose rows name another package
  entirely. The second has no name relation to fall back on, so it is
  recognised by the entry id its own patch inserts.
- An update to a floating git install now re-resolves instead of no-op'ing
  (#562, by @po-et in #564). `pnpm add` with a target byte-identical to the
  specifier already in the manifest answers "Lockfile is up to date,
  resolution step is skipped" — measured on 12.4.1 — so the install never
  moved. `pnpm update <name>` re-resolves inside the same specifier, and
  keeps the spec floating (verified on 10, 11 and 12).
- A git install rolls back to the commit it came from, not just GitHub ones
  (#632, by @po-et in #636). Two packages of one monorepo resolve from the
  same remote and differ only by pnpm's `path:` selector; when the spec
  names no subpath and several entries share that remote there is no answer
  rather than the first sibling's commit — which would have pinned the
  WRONG package on rollback.
- The market's settings card comes back in Desktop (#516, by
  @bulingbuling688 in #552). The namespace is registered with an EMPTY
  schema: enough for the host to dispatch the card, and deliberately not
  enough to offer the restart setting, which the Desktop shell owns. Stored
  values are neither deleted nor read.

Also: the local-match memo moved out of the client into the module both the
host restore route and the client share (#592 follow-up, by @JINITAIMEI121
in #618) — a cache kept on one side of a shared implementation is how the
two sides drift apart.

Thanks to @xiaohan13 for the #646 report: root cause, all three call sites,
a verification table and the edge cases. It was complete enough to
implement from directly.
2026-09-20 10:44:39 +08:00
fkysly aeede78238 fix: match a subpath loader entry to its package, in all three places (#646) (#647)
A bundle patch's row `name:` need not be the package name. `dsh-chat-import`,
`meow-memory` and `dsh-context` all name themselves, so `name ===
packageName` held for years; `aegis` mounts
`aegis/extensions/dsh/index.js` and every site that compared the two
directly concluded the plugin had no entry at all. Reported by @xiaohan13
with the root cause and all three call sites.

Three sites, one assumption, three independent copies of the rule:

- `src/verify.ts` `liveIncludes` — prefix-matched, so this one already
  worked for subpaths; now it calls the shared rule instead of restating it.
- `src/themes.ts` `ownsLoaderEntry` — fixed for the toggle by #620, which
  landed after 1.48.0 and named this same plugin. Folded into the shared
  rule so there is one implementation, not two.
- `src/routes.ts` `liveNames` — did NOT work: the live set held only the raw
  entry name, so `liveNames().has('aegis')` was false. After a successful
  enable the route computed `restart = !liveAfter` and told the user to
  restart for a plugin that was already running. Now the subpath name's
  implied package joins the set.

`src/entry-identity.ts` holds the rule once, with the `/` bound that keeps
`toolshrink` from matching `toolshrink-extra`, and the inverse mapping that
returns null rather than a guess for `./relative`, `/abs`, `file://` and
`cordis:` names. The carrier case (#156, an entry that names ANOTHER
package) is unaffected: it is recognised by the entry id its own patch
inserts, and the two rules compose.

Separately, `hotMount` read `node_modules/<pkg>/cordis.patch.yml` and
nothing else, while `profile.ts` resolves the manifest's `dsh.bundle.patch`
for every other purpose. For `aegis` — patch at `./extensions/dsh/` — the
root file does not exist, so the enable path fell through to the
`dsh.client` check and told the user their package had "no bundle patch …
nothing to hot-mount". Both now resolve the patch path through
`declaredBundlePatchFile`, with the root file kept as the fallback.

Every assertion mutation-checked: reverting the patch resolution, the live
set, or the shared rule turns its test red. 1484 tests.
2026-09-20 10:37:58 +08:00
fkysly 8083b416c1 release 1.49.0: pnpm 12, and windows that should not exist
Two of these are about a pnpm major the market had not been tested
against, and two are about the Windows consoles a console-less host
should never show the user.

- The `--config` overrides the retries depend on are now carried as
  `PNPM_CONFIG_*` variables as well (#615, by @po-et in #626). pnpm 12's
  native CLI silently ignores some `--config.<key>` overrides in either
  spelling — `fetchTimeout` on 12.2.1/12.3.0/12.4.1, `auto-install-peers`
  on 12.4.1 — so those retries ran exactly like the first attempt. The
  argument stays for the versions that read it; the variable covers the
  ones that do not, and nothing is set on a run that carries neither.
- The release-age bypass is spelled the way pnpm 12.3+ reads it (#600, by
  @po-et in #616). `--config.minimumReleaseAge=0` is ignored there WITHOUT
  an error, so the retry failed exactly like the first attempt.
  `--config.minimum-release-age=0` is honoured by pnpm 10, 11 and 12 —
  measured here on all three before merging.
- The restart helper no longer hands the replacement a visible console
  (#624, by @po-et in #625). `-WindowStyle Hidden` could not help: it
  governs the window PowerShell itself would create, not the console the
  spawn allocated for a console program from a console-less parent. That
  window OWNS the replacement, so closing it takes the new host down —
  the same class as #530, one step nastier.
- The unit lane no longer sees the developer's proxy (#627, by @po-et).
  A setup file strips those variables globally, which is why two
  contributors saw failures on their machines that CI could not
  reproduce: a suite that is green or red depending on who runs it.

Also:

- "Update all" skips disabled plugins (#537, by @axdlee in #642, landed
  with the bundle rebuild his PR needed). A plugin the user turned off
  should not be dragged into a batch that can migrate the whole profile
  and then fail on ITS stale peer declarations, taking everyone else's
  updates with it.
- The restart banner counts restart-pending changes rather than every
  completed one (#558, by @JINITAIMEI121 in #570): a client-only plugin
  goes live on refresh, so it never belonged in that number.
- The CI step that guards the committed bundle now says what to run
  (#643). Its old output was a diff of a 560KB generated file — nothing
  about it told the contributor the command, which is why four of them
  needed a person to say the same sentence.
2026-09-19 20:51:54 +08:00
fkysly 66692ceb0e ci: the artifact guard now says what to run (#643)
* ci: the artifact guard now says what to run

`git diff --exit-code client/` output is a diff of a 560KB generated file:
it shows WHAT changed and never what to do about it. Four contributors hit
it in one month — #518, #563, #570, #538 — and each needed a person to
tell them the same sentence.

The step now fails with an annotation naming the command, on the file the
contributor would have to touch. Verified both branches locally: a dirty
client/ prints the message and exits 1, a clean one passes.

This is the second half of #586. That commit fixed one reason the artifact
could differ across machines (a doubled `src/` path segment surviving
normalization) and its message already said the failure "names the
artefact rather than the build environment that produced it" — without
doing anything about the naming. Now it does.

* fix(ci): run the artifact guard under bash, not PowerShell

The step failed on windows-latest with a ParserError: `run:` defaults to
PowerShell there, and `if ! cmd` is a syntax error rather than a test. The
step that exists to explain a failure died with a syntax error instead of
explaining one — which is the same mistake one level up: a gate nobody can
act on.

shell: bash, which the Windows runners have via Git Bash. Caught by CI on
the PR itself, which is the cheapest possible place for it.
2026-09-19 12:36:23 +08:00
d8c31e2a17 fix: skip disabled plugins in update-all, and rebuild the bundle (#537) (#642)
* fix: skip disabled plugins in update-all

* test: cover disabled plugins in update-all

* chore: rebuild client/client.js for the disabled-plugin batch skip

@axdlee's #538 changed src/client/MarketSection.tsx and did not rebuild
the committed bundle, so the artifact guard failed on both platform jobs
— the fourth contributor to hit that this month (#563, #570, and #518
before them).

His change and his test are kept verbatim, rebased onto current main
(his branch predates the removal of client/client.js.map, which turned
the rebase into a one-off modify/delete).

Co-authored-by: axdlee <axdlee@users.noreply.github.com>

---------

Co-authored-by: Sheldon.li <xdlee110@gmail.com>
Co-authored-by: axdlee <axdlee@users.noreply.github.com>
2026-09-19 12:25:52 +08:00
fkysly 9ecd3bc69b test: pin every proxy variable the #148 case reads, not just the one it sets (#613)
@po-et hit this while working on #593: the case exports HTTPS_PROXY and
asserts both npm_config_https_proxy and npm_config_proxy, but restores
only HTTPS_PROXY and clears nothing else. On a machine that exports
HTTP_PROXY — common, and true of theirs — proxyEnvForPnpm derives
npm_config_proxy from the real value and the assertion fails for a reason
that has nothing to do with the code under test.

A test that passes only on machines shaped like its author's is worse
than no test: it fails on someone else's contribution, and the failure
points at their change.

Reproduced before fixing (`HTTP_PROXY=… npx vitest run
tests/dsh-cli.spec.ts` → 1 failed), and both with and without the
variable after (27 passed).

Found by @po-et, who reported it in #593 and deliberately did not fix it
there — it is not that PR's business.
2026-09-19 11:49:08 +08:00
fkysly 6e4b8041a6 release 1.48.0: failures that described themselves wrong
Every fix here is about the market telling a user something untrue about
their own machine, or doing work that could not possibly succeed.

- A fresh install no longer lands one version behind. A bare registry name
  hands the version choice to pnpm, and pnpm 11's fresh-release hold makes
  that choice silently: a release younger than minimumReleaseAge is skipped
  for the newest mature one, exit 0, and the `^0.x` written into the
  profile never floats to the next minor (#594, fixed by @po-et in #621).
  A fresh npm install is now pinned to the registry's latest. Where the
  profile sets minimumReleaseAge ON PURPOSE, that policy is kept: the pin
  fails, the market falls back to the bare name, and says so — unlike an
  update, where the user explicitly asked for the newest and the one-shot
  bypass serves their intent rather than overriding the profile's.
- The release-age bypass now works on pnpm 12.3+. `--config.minimumReleaseAge=0`
  is ignored there without an error — the retry ran without the bypass and
  failed exactly like the first attempt (#600, by @po-et in #616). Spelled
  `--config.minimum-release-age=0`, which pnpm 10, 11 and 12 all honour.
- An update that cannot replace files the running host holds open no longer
  tries to roll back through the same rename. pnpm retried for about three
  minutes and the user was then told their profile might be broken (#608, by
  @po-et in #617). package.json and pnpm-lock.yaml are restored and the
  previous build's entry is CHECKED rather than assumed — pnpm clears as
  much of the target directory as it can before retrying, so content beside
  the locked file may already be gone.
- ...and the message that failure shows no longer promises "what was already
  installed is intact" (#630). That promise was false, and @po-et was right
  to leave the wording to me: it was mine, written when the route still
  assumed the previous build survived.
- git can no longer block an install on a credential prompt nobody can
  answer (#587, by @po-et in #593). It reads CI on pnpm's behalf; git does
  not, and its prompt opens the controlling terminal rather than stdin — the
  reporter caught a `git.exe` alive for eight minutes with 0.05s of CPU.
- Flat Desktop hosts are recognised (#553, by @bulingbuling688 in #574): the
  layout with the shell manifest at `resources/app/package.json` and split
  runtime packages beside it, which the locator could not see at all. A
  Desktop version is reported only when the shell and four in-box witnesses
  agree; anything less retains a confirmed directory with `unknown`.
- A host shell can render the market's own panel (#602, by @hairyf from the
  Tauri desktop). `ctx.reflect.get('market').render()` returns the panel as
  an element for the shell's own container, and `setSettingsVisible(false)`
  retracts the duplicate settings entry. Also a new
  `GET /dsh-market/api/v1/updates/summary` for a badge: `updatable`, with
  `checked` as the denominator so "nothing to update" and "nothing was
  looked at" are different answers.

Thanks to @po-et, @bulingbuling688, @hairyf and everyone else who reported
these with enough detail to act on.
2026-09-18 21:53:03 +08:00
fkysly 53f793e775 feat: let a host render the market's own panel (#602) (#633)
The Tauri desktop shell's answer to the last round was that status queries
alone do not help them — their purpose is to consume the market's PANEL,
which they render inside their own container alongside their own chrome.
That is a smaller thing than the slot inversion I declined, and it is the
fallback their issue had already proposed.

`market.render(props?)` returns the market's panel, error boundary
included, as a React element. Same page and same React instance: this
package's client bundle resolves react through the host's module table, so
the element mounts anywhere in that tree.

The builder moved into `src/client/market-element.ts`, taking its
dependencies as arguments rather than closing over the cordis context, for
two reasons:

- the settings section and `render()` must not drift. They pass different
  `preferredSubsectionId`s and the same everything else, and a mix-up would
  show up as a panel opening on the wrong tab in one of the two places, on
  somebody's machine;
- wiring that only a running host can reveal is wiring a test cannot check.
  The spec asserts which translate function, locale, theme store and log
  exporter reach the panel, and that the recovery panel's log button calls
  the exporter it was handed.

`ctx.provide` and `ctx.reflect.provide` are the same call — cordis's
Service.provide delegates to the reflect layer — so the reporter's own
idiom reaches this service. Documented in UPDATE-API-V1.md.

Deliberately still not done: host-fillable slots inside the market. One
host asking does not yet say what the second one would need.
2026-09-18 10:20:40 +08:00
fkysly 355c1d4894 fix: stop promising the locked build is intact when it may not be (#608) (#630)
@po-et noticed this while fixing #608, and left the wording to me because
it is not that PR's business. He is right that it is false.

The message said "what was already installed is intact". It is not always:
pnpm's renameOverwrite clears as much of the target directory as it can
BEFORE retrying the rename, so files beside the one it cannot remove may
already be deleted. He measured it on macOS with only the directory inode
locked — `perf/*.js` gone, `index.js` survived.

Two things made that worse than an ordinary imprecision. It was a promise,
in the one message a user reads when something has gone wrong, and the
route now checks whether the entry survived instead of assuming (#608) —
so the classifier and the route were telling the user two different
things about the same event.

The wording now names the risk and points at the check the route actually
performs. The assertion is inverted rather than deleted: it fails if the
promise comes back.

Deliberately free of the word 更新/"update": #441 established that this
message must not describe itself as an update, because a reinstall meets
it too.
2026-09-17 00:37:46 +08:00
fkysly ff9eb25b3d feat: an update-count aggregate, and a host control for the settings entry (#602) (#629)
Two of the four things the Tauri desktop shell asked for, chosen because
they are the two that block an integration today and neither invents an
architecture.

1. GET /dsh-market/api/v1/updates/summary — the aggregate a badge needs.

   The single-package endpoint requires `name`, so a host showing "3
   updates" had to enumerate the profile and call it once per plugin, or
   read the market's private listing, which has no schema and no
   capability bit. `checked` is the denominator, so "nothing to update"
   and "nothing was looked at" are different answers; `packages` carries
   the same objects the single endpoint returns, so one parser serves both.

   Both endpoints now build their inputs through one helper
   (updateCheckInputs), which is what makes the aggregate and the
   single check unable to disagree.

2. ctx.provide('market', { version, setSettingsVisible, settingsVisible })
   — for a host that renders the market itself and does not want a
   duplicate nav entry.

   The mechanism had to be in-page: the client registers the section
   synchronously during apply(), so it cannot consult the host's
   environment, and routing it through the server would cost every user a
   boot request for one host's preference. cordis's `provide` is already
   used by the host's own client plugins, so this is the existing shape
   rather than a new one.

   The visibility rules are a small state machine (section-gate.ts)
   because the orderings are the work: a host that hides before the slots
   service arrives must not get a flash of the entry, a removal must not
   be undone by a later "show", and hide-then-show must not produce two
   entries. Verified by mutating the removal rule and watching exactly the
   two tests that describe it fail.

Not here: the injectable UI slots (`market.title`/`tabs`/`body`). That
inverts this package from slot consumer to slot provider, and designing it
for one host before a second has asked is how an interface ends up usable
by nobody else. Worth a conversation once someone actually needs it.
2026-09-17 00:01:57 +08:00
fkysly a8401c46fb release 1.47.0: a prompt that was never true, and a list that got slow
Two user-facing fixes lead, both reported by people who could see exactly
what was wrong and could not do anything about it.

- "Update available" that could never be satisfied (#597, by
  @JINITAIMEI121). An annotated tag is advertised as two refs — the tag
  object and the commit it peels to — and the market compared the wrong
  one. pnpm records the peeled commit, so the installed sha never matched
  and the prompt never went away, however many times it was accepted.
- The catalog no longer costs 300ms a keystroke (#589, by @po-et). Catalog
  matching for `link:`/`file:` installs was uncached while the version-spec
  path has been memoized since #262, and it runs once per rendered card —
  measured at ~300ms per repaint with one local dependency against the
  3627-entry catalog.

Also:

- The update-notes dialog stops reading a same-named stranger's repository
  (#598, by @JINITAIMEI121). It took the first catalog entry matching by
  name, so a plugin whose notes exist was told there were none.
- A monorepo subpackage reads as installed at last (#605, by
  @junmingcode). The catalog states `owner/repo#path:/pkg` while an
  npm-installed manifest usually states the bare `owner/repo`, and the
  asymmetry was being read as a source conflict.
- Hot-mount rows resolve against the profile rather than the loader's own
  location (#550, by @sampx), so a closure-hosted loader can reach the
  package it just installed instead of answering "restart required" — and
  a failed mount no longer leaves a file behind for every later boot to
  re-import and re-fail.
- "Update all" appears when one plugin is updatable (#555, by @po-et). It
  was gated at two, which left the header offering "ignore all update
  reminders" with nothing to actually click.
- dsh desktop joins the Friends list (by @MochiNek0), and the #385 compat
  probe accepts the lock shapes current pnpm writes (by @liuwenji007),
  which had been failing unrelated PRs.
2026-09-15 12:44:47 +08:00
fkysly 1860262d29 release 1.46.1: a failed enable can no longer loop the host at boot
Released on its own, because the failure mode costs the user their
profile until they edit YAML by hand.

Enabling a plugin that crashes deterministically on import persisted
"enabled" anyway — into cordis.patch.yml, into the market's state.json,
and into the running disable set. The loader re-applied it on every
start: @JINITAIMEI121's reporter measured 24 restarts before restoring
the disable by hand.

The fix keeps their asymmetry, which is the hard part of it: a failed
ENABLE must not flip the durable layer, a failed DISABLE must — the user
asked for OFF, and a failed unmount leaves the plugin live only for this
session. Their patch-layer gate is in verbatim (#584, co-authored); this
adds the market's own store and its in-memory set, which matters twice:
the two persisted views were disagreeing, so which one won at the next
boot depended on load order, and a CLIENT-ONLY plugin has no bundle rows
at all — its only durable state is state.json, so for that plugin kind
the loop was untouched.

Also here: a doubled `src/src/` path segment in the committed bundle's
CSS ids is normalized away. It made two contributor PRs unmergeable with
a one-line diff neither of them wrote, and a failure message that named
the artefact rather than the build environment that produced it.
2026-09-13 15:57:18 +08:00
fkysly 2945592e86 fix: normalize a doubled path segment in the committed bundle's CSS ids (#586)
Two contributor PRs (#563, #570) were red on `git diff --exit-code
client/` with a one-line difference neither of them wrote:

  -//#region \0dsh-css:src/src/client/Market.module.css.mjs
  +//#region \0dsh-css:src/client/Market.module.css.mjs

Their build emitted a doubled `src/` segment. normalize-client-banner
exists to make these ids machine-independent, and it stripped absolute
prefixes correctly — but its prefix match was lazy, so it anchored on the
FIRST `src/` and walked past the duplicate. Greedy anchors on the last
one, which is the canonical tail.

The cost of the gap was not a wrong bundle: it was a contributor staring
at a failure that says only "the artefact does not match", with nothing
pointing at their build environment as the variable. This file exists
because environments differ; a rule that only handles the difference it
was written for is half a rule.

Tested by lifting the live rule out of the script by source rather than
copying it — a copy keeps passing after the original changes, which is
the exact failure this guards. Verified by reverting the rule to lazy and
watching two of the five cases go red, including idempotence.
2026-09-13 15:55:55 +08:00
310ac4a58c fix: a failed enable leaves every view of the state as it was (#575) (#585)
* fix(routes): a failed enable must not flip the durable patch layer (#575)

The toggle route wrote the patch rows unconditionally, so a failed enable (deterministic import crash) flipped the durable layer to enabled and turned a transient in-session error into a boot crash loop. Gate the enable-direction patch write on ok; disables keep their unconditional write (a failed unmount still means the user asked for OFF).

* fix(routes): a failed enable must not flip the durable patch layer (#575)

The toggle route wrote the patch rows unconditionally, so a failed enable (deterministic import crash) flipped the durable layer to enabled and turned a transient in-session error into a boot crash loop. Gate the enable-direction patch write on ok; disables keep their unconditional write (a failed unmount still means the user asked for OFF).

* fix(routes): a failed enable must not flip the durable patch layer (#575)

The toggle route wrote the patch rows unconditionally, so a failed enable (deterministic import crash) flipped the durable layer to enabled and turned a transient in-session error into a boot crash loop. Gate the enable-direction patch write on ok; disables keep their unconditional write (a failed unmount still means the user asked for OFF).

* test: pin the toggle patch-layer gate (#575)

Bundle-plugin flow: a failed enable leaves the user patch untouched (still disabled) instead of flipping the durable layer into a boot crash loop.

* fix: a failed enable leaves every view of the state as it was (#575)

Completes @JINITAIMEI121's fix in #584, whose diagnosis and asymmetry are
kept verbatim: a failed ENABLE must not flip the durable layer, a failed
DISABLE must — the user asked for OFF, and a failed unmount leaves the
plugin live only for this session.

Their patch-layer gate closed the hole in cordis.patch.yml. Two of the
three views the report described were still flipped: setPluginEnabled
recorded the choice BEFORE attempting the mount and persisted state.json
whatever happened, so after a failed enable the market's own store and
its in-memory set both said "enabled" while the patch layer said
"disabled".

That left two problems. The persisted views disagreed, so which one won
at the next boot depended on load order — harder to diagnose than the
original bug. And a CLIENT-ONLY plugin has no bundle rows at all, so
`patchRows` is empty, the patch gate never runs, and state.json is its
only durable state: for that plugin kind the crash loop was untouched.
The reporter measured 24 restarts.

The in-memory set is now restored before persisting, so the reply, the
store and the patch layer tell one story.

Tests: the bundle-plugin case also asserts the market's own answer, and a
new client-only case covers the path the patch gate cannot reach. Both
failed before this change with `expected [] to include …`.

Co-authored-by: JINITAIMEI121 <JINITAIMEI121@users.noreply.github.com>

---------

Co-authored-by: eeeeeeaaaa12 <1303576427@qq.com>
Co-authored-by: JINITAIMEI121 <JINITAIMEI121@users.noreply.github.com>
2026-09-13 14:54:19 +08:00
fkysly 1ecdd8eaa0 release 1.46.0: stop treating a plugin's name as its identity
Same-named catalog entries are legitimate by design, and three bugs in a
row came from forgetting it. This release closes the last of them and
fixes the fix.

- An npm-installed plugin is matched to the right catalog entry (#544, by
  @QinYupan's diagnosis). Two entries named dsh-mermaid, an ordinary npm
  install, and Discover kept offering Install on a plugin that was
  running: the evidence reader bailed out for anything not link:/file:,
  so the client fell back to the name, found two candidates, and matched
  NEITHER. The installed manifest's `repository` is read for registry
  specs now, which is the one fact that says which of the two is on disk.
- ...and a spec that names its own source is never second-guessed (#548,
  boundary by @bulingbuling688). My first version of the fix above read
  the manifest for EVERY spec kind, which broke forks: a plugin installed
  as `github:myfork/plugin` usually still declares the upstream
  repository, and the upstream's card then read as installed. Their PR
  had drawn the line correctly — registry specs yes, explicit Git/URL
  sources no — and this adopts it.
- Generations-installed plugins report their newer release (#497, by
  @po-et). The desktop host installs through `link:` into
  `.generations/live/`, which checkUpdates read as a developer checkout
  and answered "no update" for — including for the market itself.
  Reported, never offered: @lin-1259 established from the host's own code
  that its reconciler restores the generation at startup, so an applied
  update would appear to work and silently revert.
- A host requirement declared as `dsh.engines.dsh` is finally read (#577,
  by @JINITAIMEI121). The market only read the top-level `engines.dsh`;
  measured across 120 sampled catalog packages, 2 of the 11 that declare
  a requirement use the nested position only, and for those authors the
  install-time preflight and the Discover filter did not exist.
- ERR_PNPM_NO_MATCHING_VERSION gets its own explanation (#569, by
  @JINITAIMEI121), split by whether the package is a host peer — the
  runtime provides those and the market retries automatically, while an
  ordinary dependency's missing version needs someone to act. It shared
  fetch-404's wording before, which told users to delete a ghost entry
  that was not there.
- The category row no longer forces a second commit when the header
  pins (#518, by @liuwenji007), which is the hitch behind the settings
  dialog stuttering after a tab collapse.
- The repository stops carrying a copy of the plugin catalog (#545, by
  @Icstick). It was 2.1MB of 11-day-old data this repository does not
  own, gating merges while looking enough like the source of truth that
  people tried to add plugins to it. The merge gate now validates the
  live catalog, and skips rather than fails when the origin is
  unreachable.
2026-09-13 11:09:44 +08:00
fkysly f334456056 fix: do not read the manifest for a spec that names its own source (#548) (#580)
The #544 fix I merged in #559 widened readInstalledRepoEvidence to every
spec kind. @bulingbuling688 had drawn the boundary differently in #548 —
registry specs only, explicit Git/URL sources excluded — and that
boundary is the correct one. Mine had a hole.

Measured: a fork installed as `github:myfork/dsh-plug` whose package.json
still declares `upstream/dsh-plug` — which is the normal state of a fork,
since almost nobody edits that field. depRepoIds unions the spec-derived
ids with the manifest identities, so the upstream id entered the set,
sameSourceConflict stopped excluding the upstream's card, and the
UPSTREAM card read as installed. A weaker signal outvoting a definite
one: #485's mistake, reintroduced three days after closing it.

A spec that names its own source is the authority on it. Local specs
still read the manifest (that is where #141 and #429 get their identity),
registry specs now do too (that is #544), and `github:`, `git+`, ssh,
scp, codeload and any http(s) spec do not.

Tests on both sides: the server returns no evidence for a fork spec, a
Release archive, and a raw git+https remote; and the client keeps
matching the fork while leaving the upstream unmatched, so if evidence
for a git spec ever reappears the consequence is pinned.
2026-09-12 07:42:39 +08:00
fkysly f33c7fbe7d fix: read the repository of an ordinary npm install (#544) (#559)
@QinYupan installed dsh-mermaid from the market; the Discover card kept
saying Install. Two same-named catalog entries exist (MrmoLabs with an
npm field, AKS1st without), and the manifest of the installed package
declares `repository: git+https://github.com/MrmoLabs/dsh-mermaid.git` —
the one fact that says which of the two is on disk.

readInstalledRepoEvidence bailed out for any spec that was not
`link:`/`file:`, so an npm install carried no repo identity. The client
then fell back to name matching, found two candidates, and matched
NEITHER — the reporter's own diagnosis, confirmed against the code.

The manifest's repository is read for every spec kind now. What stays
local-only, deliberately: the git-origin hint (there is no checkout to
read for an npm install) and the local source directory walk.

Tests pin the exact reported shape: the MrmoLabs entry matches through
manifest evidence, the AKS1st entry does not, entryForDep agrees, and —
with no evidence, the pre-fix answer for an npm install — neither
matches, which is the symptom as it looked from the card.
2026-09-12 02:00:18 +08:00
fkysly 88c8ce33ad chore: validate the live catalog, and stop committing a copy of it (#545) (#557)
* chore: validate the live catalog, and stop committing a copy of it (#545)

@Icstick asked why `data/registry-snapshot.json` was eleven days behind
the catalog and how to request a refresh. The answer turned out to be
that nothing depends on it being fresh, and that the file's main effect
was to mislead.

What it was not:

- Not what the market reads. The bundled fallback was removed on purpose
  (see loadRegistry: for a catalog, stale is not a degraded answer, it is
  a wrong one). A build-site.yml comment still called it "the plugin's
  runtime fallback"; that was left over.
- Not shipped. `data/` is not in package.json `files`.
- Not what the site serves. The site build downloads its own copy into a
  checkout it throws away.

What it was: a 2.1MB file that gated merges on an 11-day-old copy of data
this repository does not own — 2452 entries against 3408 live — while
looking enough like the source of truth that people tried to add plugins
to it. The CI guard refusing hand edits exists because that had already
happened.

So the gate now reads what users actually get: validate-registry fetches
the live catalog, prefers a local file when one is present (offline runs
still work by dropping one in), and SKIPS with a notice when the origin
is unreachable — failing unrelated PRs on someone else's outage is how a
gate gets ignored.

The guard stays, retargeted: adding the file back is now the mistake it
was always trying to prevent.

One test read the snapshot as a corpus, cross-checking pluginSlug against
the site builder's slugOf so two plugins can never share a comment
thread. It now reads a committed corpus of every URL SHAPE the catalog
publishes — 108 of them across 3408 entries, sampled with real URLs, 24KB
instead of 2.1MB. Whether the catalog only ever publishes those shapes is
gated where it belongs: against the live catalog, by validate-registry's
E5.

@Icstick's three plugins were already visible in the market; verified
against the catalog package the market actually reads.

* ci: let the catalog-copy guard tell an addition from a removal

The guard fired on the PR that deletes the file it was written to
protect: it matched any diff touching that path, and a removal is a diff.
--diff-filter=AM narrows it to what it actually means — adding the file
back, or editing it in place.
2026-09-08 23:16:10 +08:00
fkysly 1664caec99 release 1.45.1: the market stops gating itself on seams it never used
A single fix, released on its own because of what it does to OTHER
plugins.

`dsh.client.inject` is a hard gate — the host holds the client entry back
until every listed seam exists. The market listed five and used three.
One of the two dead entries, `@deepseek-ai/dsh-client-runtime`, is a seam
dsh 0.1.2-rc.1 ships incompletely and which had already hard-failed
another plugin's boot. @MarchLiu found (#554) that with the market
enabled, dsh-context's /context command silently stopped registering —
no host error, no console output, no failed boot entry — and isolated it
by binary search to the market's entry alone.

Both entries had been in the manifest since 0.1.0, never removed when the
code that might have needed them was not written. This is not a 1.45.0
regression; it is as old as the plugin, and only visible on a host that
does not ship those seams. rc.8 ships both, which is why the e2e lane
never saw it.

What the market can settle, it has: a seam it never used is a host
version it refused to run on, bought for nothing. Whether that fully
explains the other plugin's silence depends on how the host propagates a
never-satisfied inject to later client modules, which is upstream.

A test now keeps the declaration and its use next to each other, reading
the client source rather than a list, so it fails both ways: a seam
nothing uses, and a `ctx.` service nothing declares.
2026-09-08 22:25:23 +08:00