688 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.
v1.66.3
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 6431fa36e2 Merge pull request #747 from liuwenji007/fix/746-update-notes-catalog-npm
Verified locally before merging: full suite 1867 green on the PR head, typecheck clean, and reverting `src/changelog.ts` to the base turns exactly the five new #746 cases red (a subpath catalog name, a scoped package whose catalog name drops the scope, the `npm`-field preference, subpath commit slicing, and tree-url notes) while the 18 existing cases stay green. The root cause in #746 matches the change: the catalog was matched by `name` against the installed npm package name.
2026-09-28 00:14:39 +08:00
Liu Wenjie aecc3124b0 fix(changelog): enhance catalog handling for npm installs with missing notes
Updated the logic in `updateNotesFor` to check if a catalog is needed when no usable notes are found. This prevents unnecessary catalog queries for GitHub root installs lacking probe data. Added a test to ensure the catalog is not consulted in such cases.
2026-09-27 23:36:14 +08:00
Liu Wenjie 0823aa2fed fix(changelog): an npm install's update notes are not found by the catalog name (#746) 2026-09-27 22:46:17 +08:00
Regulus b83751e614 Merge pull request #737 from zhanghao3693/i18n/zh-locale-meta
i18n: add zh-CN display metadata (locale/*.json)
2026-09-27 22:30:54 +08:00
zhanghao3693 52120f6a50 fix(i18n): drop package name suffix from zh title 2026-09-27 20:45:28 +08:00
Regulus 180c3144da fix(net): carry our own dispatcher on a direct fetch (#742)
Node 22's global fetch reads the legacy undici dispatcher symbol. The
host's first undici 8 import (web_fetch) writes a Dispatcher1Wrapper
there, and gzip catalog bodies then fail JSON.parse for the rest of
the process. A proxied request already passed its own dispatcher; a
direct one now does the same, with a plain Agent.
The Node 25 measurement stays beside it: there, the package's
setGlobalDispatcher does not steer global fetch at all. The two name
different Nodes and different symbols, and both are why every market
request carries a dispatcher this module created.
2026-09-27 14:17:53 +08:00
Liu Wenjie 317acd0537 docs(net): keep the two dispatcher measurements apart (#742)
The Node 25 proxy measurement and the Node 22 symbol clash were written
as one argument, so a later edit could drop the direct-path dispatcher
by trusting only the first. They name different Nodes and different
symbols, and both are why every market request carries its own.
2026-09-27 13:59:49 +08:00
Liu Wenjie a7e3bf9622 fix(net): carry our own dispatcher on a direct fetch (#742)
Node 22's global fetch reads the legacy undici dispatcher. The host's
first undici 8 import (web_fetch) replaces it, and gzip catalog bodies
then fail JSON.parse for the rest of the process. A proxied request
already passed a dispatcher; a direct one now does the same.
2026-09-27 13:45:18 +08:00
zhanghao3693 cf15013df2 fix(i18n): drop upgrade wording and feature list from zh meta 2026-09-27 06:32:50 +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.
v1.66.2
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
Regulus f4478f5301 fix(client): a core-bundle red line still left its verb in English (#401) (#735)
The scanner's detail is itself a sentence — "overrides bundle …" or
"disables bundle …" — and the translation was pasting that detail
through. A reader then had to parse the English verb to learn whether
the install would replace a part of DSH or switch one off. Those two
shapes are the only details the scanner emits.

A detail that is neither of those stays on the generic sentence, so a
future shape is not guessed into an override or a disable.
2026-09-26 22:13:44 +08:00
zhanghao3693 60ac639112 i18n: add DSH plugin locale metadata for zh-CN
The DSH plugin list reads display text through readPluginMeta(), which only
looks at locale/*.json when locale/en.json exists. Without it the entry shows
the English package.json description.

- locale/en.json: empty meta keeps the English fallback unchanged
- locale/zh.json: zh-CN title and one-line description
- package.json: expose ./locale/*.json and ship the locale folder
2026-09-26 18:58:47 +08:00
Liu Wenjie b903de2ff8 test(client): add test for unrecognized core-bundle detail handling in MarketSection
This test verifies that when an unrecognized core-bundle detail is present, it remains on the generic sentence without being incorrectly classified as an override or disable. The test ensures the expected behavior of the MarketSection component in this scenario.
2026-09-26 16:54:32 +08:00
Liu Wenjie 57fe7bfa97 fix(client): a core-bundle red line still left its verb in English (#401)
The scanner's detail is itself a sentence — "overrides bundle …" or
"disables bundle …" — and the translation was pasting that detail
through. A reader then had to parse the English verb to learn whether
the install would replace a part of DSH or switch one off. Those two
shapes are the only details the scanner emits.
2026-09-26 16:14:54 +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.
v1.66.1
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 77fd08212d fix(ci): the site build could not write its catalog into a fresh checkout
data/ is generated and untracked, so a fresh checkout has no such directory:
curl died on its first write with exit code 23 — "Failed writing received
data" — before it ever touched the network. Every build-site run since
88c8ce3 (#545, "stop committing a copy of it") failed, which froze
dshmarket.com at the 2026-09-08 catalog: 3,363 plugin pages live, 4,347 in
the catalog today, so 1,082 plugins had no page at all.

mkdir -p data before the fetch, drop the now-dead data/ push trigger, and
give `npm run snapshot` — the same command by hand — the same guard.
2026-09-26 02:24:41 +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.
v1.66.0
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
DuHu 9b88e68c54 QMCP-2014 feat: disclose rolling npm download windows and source check dates 2026-09-25 23:56:03 +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.
v1.65.4
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.
v1.65.3
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
NONEMIN ff2d3985cf feat: queue install/update/uninstall while agents are running (#523)
Agents-busy 409s no longer fail the operation: install, update and
uninstall refusals become queued records that drain automatically
once agents go idle and the operation lock is free.

- /status exposes runningAgents so the client can drain without an
  extra round trip
- queued records persist in localStorage (dshm-queue-v1) across
  remounts; Tasks panel keeps dequeue + run-now actions
- spec: queue/drain/persist client tests + /status guard test

Maintainer pass on top of @NONEMIN's branch, which had been rebased and
reviewed but not updated for two weeks:

- **A queued row may only run while it still applies.** It drains with no
  confirmation, so a row restored from an old session is a destructive
  operation launched from a decision the user may have taken back: queue an
  uninstall at 10:00, remove the plugin by hand, open the market at 15:00 and
  it would run. `queuedRowApplies` (src/client/market-data.ts) requires the
  install's catalog entry, the package's presence for uninstall, and a
  still-pending update for update; a row that no longer applies is REPORTED
  with its reason rather than executed or silently dropped.
- **The queue was lost on every refresh with a cold catalog.** The persist
  effect ran on the first render — where `records` is empty — and deleted
  `dshm-queue-v1` before the restore, which waits for the catalog, could read
  it. The writer now waits for the restore, and the storage item is consumed
  only once the rows are actually restored.
- The rule is a unit-tested function rather than a restore-path closure: the
  two `update` branches differ by one map lookup and are indistinguishable on
  screen.

Verified: 1796 unit tests (four new in tests/client/queued-rows.client.spec.ts,
three new DOM tests pinning "not executed, and said so"), five mutations each
turning exactly one named test red (installed check, update-available check,
catalog check, stale rows dropped instead of reported, storage consumed before
the catalog arrives). npm run check clean, client bundle rebuilt.

Co-authored-by: NONEMIN <199695791+NONEMIN@users.noreply.github.com>
2026-09-25 00:49:31 +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
zhaogy eb224a03d7 feat: editable build environment on the market's card (issue #336)
The config option alone was invisible: the host's plugin-configuration page
only renders cards a plugin ships itself, so a namespace field would never
show up. Give the market's own card a Build environment editor, persisted
through the market's state.json like channel/region — and deliberately NOT
through the settings namespace, which would become a second writer for a
value that already has an editor (the exact shape of the channel bug).

- src/hot.ts: MarketState.buildEnv with sanitizing reads/writes
  (POSIX names only, PATH/CI dropped, blank values removed)
- src/routes.ts: mount applies a saved state buildEnv over the composition;
  /status reports the effective map; POST /dsh-market/build-env validates,
  persists and applies it live (next install builds under it, no restart)
- src/client/SettingsCard.tsx: KEY=value editor seeded from /status, posts
  the parsed map, echoes the server-applied answer, empty list clears
- src/settings.ts: reverted to allowRestart only (single-writer contract)
- tests: card editor (render/save/clear/refusal), route round-trip +
  remount survival, state persistence/sanitizing; README updated
2026-09-24 22:46:17 +08:00