Skip to content

feat: ship the CLI inside every installer and manage it from settings - #177

Merged
YoungSx merged 18 commits into
mainfrom
cli-integration-issue-145
Sep 4, 2026
Merged

feat: ship the CLI inside every installer and manage it from settings#177
YoungSx merged 18 commits into
mainfrom
cli-integration-issue-145

Conversation

@YoungSx

@YoungSx YoungSx commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What

Closes #145. Installing SalmonEgg now puts the salmon-egg command on PATH on every platform, because the command ships inside the app's own installer:

Platform Installer PATH mechanism
Windows MSIX windows.appExecutionAlias%LOCALAPPDATA%\Microsoft\WindowsApps
Windows Desktop MSI WiX Environment table row appending [CLIFOLDER] to machine PATH
macOS .app + .pkg Contents/MacOS/cli/salmon-egg; pkg postinstall symlinks /usr/local/bin/salmon-egg
Linux .deb /usr/bin/salmon-egg../../opt/salmon-egg/cli/salmon-egg, dpkg-owned

One publish (scripts/release/publish-cli-binary.sh) produces the single self-contained binary all four installers embed, and each release job smokes that exact binary before sealing it.

The standalone CLI distribution is retired: deb/MSI/Homebrew/tar packages and their workflow steps are deleted, with Conflicts/Replaces: salmon-egg-cli on the new deb so upgrades cleanly take over. BREAKING CHANGE: headless servers that installed only the CLI must now install the desktop package.

Settings

A new "命令行工具 / Command line" settings section shows the live registration state (registered / missing / version mismatch / unreadable, resolved path, link target, versions). On macOS — the only platform whose package format has no install hooks — it also offers one-click link/unlink through an admin-privileged shell script, whose quoting is proven injection-safe by tests that run a real /bin/sh.

Rule change

AGENTS.md gains the rule this work embodies: installers own PATH registration; app-owned registration is only permitted where no install hook exists, and then only with the same path, the same shape, and a removable, script-testable implementation.

Verified locally

  • Linux chain end-to-end on this host: apt install resolves Depends → symlink resolves (readlink -f) → clean login shell salmon-egg --version matches the package version → desktop entry Exec/Icon resolve → purge leaves nothing (14-check smoke gate, reverse-verified by breaking the symlink depth)
  • macOS postinstall against fake roots in both bundle areas, dangling-link and occupied-file replacement, idempotency (15-check contract gate); shell quoting proven against real /bin/sh including an injection assertion
  • MSIX/MSI contract gates read the actual package tables (19 and 29 self-test cases); MSI SQL dialect constraints replayed against a fake database
  • New unit tests: 34 (shell script) + 17 (PATH inspector) + 14 (settings view model) — all green; two existing settings suites updated and green
  • Workflow contract tests pin that every installer publishes and smokes the CLI (4 mechanisms each)

Not verifiable on this host (CI covers these)

Windows alias runtime behavior and MSI install/uninstall PATH mutation need an interactive session; macOS pkgbuild/signing and fresh-login-shell link resolution need a mac; the WASM head compile needs the browserwasm workload (UNOWA0001 locally).

🤖 Generated with Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
salmonegg Ready Ready Preview Sep 4, 2026 12:40pm UTC

YoungSx and others added 10 commits September 4, 2026 08:32
The self-contained single-file publish is about to be consumed by four packaging
chains -- Windows MSIX, the Windows desktop MSI, the macOS app bundle and a Linux
deb -- and not only by the standalone archive it was written for. Leaving the
publish inside the archive script would mean each chain grew its own copy, and four
independently produced binaries that all build successfully is exactly the shape in
which they drift apart.

publish-cli-binary.sh now owns the publish, the runtime-identifier allow-list check
and the single-file assertion; build-cli-artifacts.sh owns only staging, archiving
and the checksum sidecar. The publish reports its executable path and MinVer-derived
version through the same key=value protocol GitHub Actions uses for step outputs, so
the wrapper reads them back from a temporary file instead of parsing log lines, and
its own GITHUB_OUTPUT is untouched because the assignment is scoped to the child.

Verified on this host with --rid linux-arm64 --allow-unsupported-rid: the archive and
sidecar land where they did before and the executable path is unchanged, so the
release workflow's step outputs still resolve. Reverse-verified that an unsupported
RID rejected inside the publish script fails the wrapper too and leaves no archive
behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Installing SalmonEgg now installs the salmon-egg command. A packaged app cannot
append to PATH -- the OS is the installer and writes no environment variables on the
app's behalf -- so the MSIX registers a windows.appExecutionAlias instead. Windows
materializes a stub under %LOCALAPPDATA%\Microsoft\WindowsApps, a directory it
already keeps on the per-user PATH, and launching the stub starts the executable the
extension names. EntryPoint="Windows.FullTrustApplication" is what makes that a
classic process launch: as a UWP activation the command would lose the caller's
console, arguments and exit code, which reads as a CLI that does nothing.

The payload is passed in as SalmonEggBundledCliExecutable rather than added as a
ProjectReference. It is a self-contained single-file publish for one runtime
identifier; a ProjectReference would merge the CLI's assemblies into the app's own
output, where they collide with the app's copies of the same shared projects and
cannot be launched as a separate command at all. TargetPath uses a forward slash
while Link uses a backslash, matching the font items above it: a backslash is an
ordinary file-name character on Linux and macOS, so the same item would otherwise
publish one file literally named "cli\salmon-egg" once the other packaging chains
start using it.

Three independent things have to hold for the command to work, and each fails
silently on its own, so each is checked where it can be:

- The manifest declaring the alias means MakeAppx rejects a package built without the
  payload, which is why the declaration is static rather than generated.
- ValidateSalmonEggBundledCli fails at the front of packaging with the command to run,
  instead of leaving MakeAppx to report a missing package file minutes later. It also
  rejects a payload under any other file name, since the alias names a literal one,
  and an MSYS path that MSBuild cannot open.
- The MSIX contract gate now asserts, on the built package, that exactly one alias
  extension exists, under the product's command name, with the full-trust entry point,
  pointing at an executable the package really carries.

Verified: the gate's self-test covers 19 package cases, six of them new and each
reverse-verified to reject exactly one of these defects (payload absent, alias absent,
alias renamed, entry point changed, two alias extensions, alias path disagreeing with
where the payload landed). Cross-verified against the real Package.appxmanifest by
synthesizing a package from it -- accepted -- and the same manifest with the alias
block deleted -- rejected as AppExecutionAliasMissing -- so the gate and the manifest
agree rather than each being self-consistent. The content item's TargetPath was read
back from MSBuild evaluation.

Not verified on this host: installing the package and running salmon-egg from a
Windows shell needs Windows. The PR-level MSIX gate builds the package on every
change, so the payload and alias assertions run there; the alias resolving through
WindowsApps at runtime is a release-time manual check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Windows desktop MSI now installs the salmon-egg command alongside the app and
appends its directory to the user PATH, so the two Windows installers behave the same
way. Windows Installer owns that value: written on install, removed on uninstall,
which a script calling setx or editing the registry cannot guarantee.

The command goes into a `cli` subdirectory of the install folder and the PATH row
names that subdirectory, not the install folder. Registering the install folder would
put every DLL shipped beside the app on PATH to expose one executable. That also means
the CLI cannot be harvested: heat generates directory identifiers that change with the
tree, and the PATH row has to name one, so the file and its Environment element are
authored explicitly and the step now fails if a cli directory reaches the harvest at
all -- two components declaring the same file is a duplicate light rejects, and an ICE
error is a worse way to learn about it.

The PATH encoding rule was already written down and rehearsed for the CLI-only MSI, so
it moves rather than being restated: CliMsiPathContract.ps1 becomes MsiPathContract.ps1
with the command directory as a parameter, since that is the only part that differs
between the two packages. Everything else -- which prefix characters mean set and
remove, where the [~] marker has to sit, why a trailing marker prepends -- is one rule
with one set of violation identifiers.

DesktopMsiContract.ps1 now also asserts, on the built package, that the File table
carries salmon-egg.exe and that the Environment table holds exactly one row which
satisfies that rule for [CLIFOLDER]. Both halves fail silently on their own: a package
that lost the CLI still installs a working app whose PATH entry points at an empty
directory, and a package that ships the CLI without the row leaves the user a file they
cannot invoke. The Environment table is read as Name/Value pairs rather than two column
reads, because which value belongs to which variable is the whole question.

Verified: the PATH gate covers 12 rows (two new, for the desktop shape and for
registering the app folder by mistake) and the desktop gate 17 packages (six new:
missing command payload, no PATH row, a second unreviewed row, prepending, surviving
uninstall, wrong directory), each reverse-verified to the exact violation identifier.
The Environment query is now part of the set the gate replays through a fake OpenView
that rejects out-of-grammar SQL, which is what kept the last two release builds from
dying inside OpenView. The authored WiX was parsed out of the workflow and checked as
XML: CLIFOLDER nests inside INSTALLFOLDER, the component group targets it, the feature
references it, and the Environment element is the one WiX compiles to the row the gate
calls conforming.

Not verified on this host: building the MSI needs Windows and WiX, and installing it to
watch PATH change needs an interactive session. That stays a release-time manual check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Linux had no installer for the app at all -- only an unpackaged publish directory --
so there was nothing to register a command with. This adds the package, and installing
it installs both halves: the app under /opt/salmon-egg and salmon-egg on PATH.

Layout follows the FHS. /opt is where an add-on application that ships its own runtime
belongs, which is what a self-contained .NET publish is. /usr/bin/salmon-egg is a
symlink into that tree, so nothing edits a shell profile: dpkg owns the link and takes
it away on purge, while an edited .bashrc is not reversible. The link is relative and
two levels deep, because one level resolves to /usr/opt and dangles -- which the
package's file list cannot show, since it records only the link text.

Also shipped: a desktop entry, so the GUI is launchable from the shell's menu and
registered for the same s8p scheme the Windows package declares as a protocol, and the
app icon at all five sizes Uno's Resizetizer produced for this build. Those are used
rather than the source artwork because the source is 200x200, which is not a size the
hicolor theme defines -- an icon in a non-theme directory gets rescaled twice or
ignored. StartupWMClass is deliberately absent: a wrong value silently breaks the
taskbar's window-to-launcher association.

The dependency list could not be derived from the build. dpkg-shlibdeps and readelf
see only link-time NEEDED entries, and everything above libstdc++ is loaded at runtime
by P/Invoke or by the graphics stack, so the publish output declares none of it. The
list was read out of /proc/<pid>/maps while the published app ran headless under Xvfb:
ICU, OpenSSL, brotli, the X11 family, GL/EGL, GLib, freetype and GStreamer are all
mapped at startup. Alternative dependencies rather than pinned ones, because Microsoft's
own .NET debs name a single libicu only by building one deb per distribution, and
Ubuntu 24.04's t64 transition renamed several of these. Conflicts/Replaces on the old
salmon-egg-cli package, whose /usr/bin/salmon-egg this one now owns.

Verified end to end on this host, which is the point of doing Linux first: the package
installs through apt (so every declared dependency name is real, not merely present),
the symlink resolves to the CLI, `salmon-egg --version` runs in a clean login shell and
matches the package version, the desktop entry's Exec and Icon both resolve, and purge
removes all of it -- 16 checks. Reverse-verified by rebuilding the package with the
symlink one level too shallow (4 checks fail, including the resolution assertion) and
with the desktop entry naming a renamed executable (exactly that check fails), and by
running the build script against a publish output with no bundled CLI (exit 1 with the
command to fix it). The smoke gate now runs on every push through platform-build-gates,
where the Linux publish switched from framework-dependent to self-contained: that is the
shape the package ships, and the old one proved something about a layout nobody installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t on PATH

macOS had no way to register a command at all. A .dmg is dragged, so it has no install
hook, and Uno can build a .pkg but its PackageAppBundle task accepts no scripts
parameter, so that package cannot carry a postinstall either. This adds a pkgbuild step
whose postinstall symlinks the bundled command into /usr/local/bin, which macOS keeps on
the default PATH through /etc/paths -- the smallest thing that makes `salmon-egg` resolve
without editing anyone's shell profile.

The command reaches the bundle by being part of the publish Uno builds the bundle from,
and it has to be there before signing: adding a Mach-O to a signed bundle invalidates the
signature, so the .dmg gets it too and its users can link it by hand.

Which directory inside the bundle it lands in is Uno's decision, so it is probed rather
than assumed. Dissecting the shipped v1.4.2 bundle shows the split GenerateAppBundle
performs: the apphost, its deps.json and runtimeconfig.json, and every .dylib go to
Contents/MacOS (19 files); managed assemblies, satellite resource directories and asset
subdirectories go to Contents/Resources with relative paths intact (589 files). A cli/
subdirectory holding one extension-less Mach-O matches neither pattern exactly, and the
split is not documented, so the postinstall, the pkg builder and the bundle contract all
accept either location and report which one they found. MacOS is preferred where present,
because that is where Apple expects auxiliary executables.

The pkg identifier is read from the bundle's own CFBundleIdentifier rather than repeated,
since two identifiers for one product would let the installer treat an upgrade as a second
independent install. Installer signing needs a Developer ID Installer certificate, which
is a different one from the app and disk-image identities, so it is optional the same way
the .dmg's is: MACOS_PKG_CODESIGN_KEY when configured, an unsigned package otherwise.

Verified: the PATH gate drives the real postinstall against fake roots -- 15 checks, both
bundle layouts, and reverse-verified for a bundle with no command (refused, no link left
behind), a dangling link from an earlier version (replaced, which is why the script tests
-L and not just -e), a regular file occupying the link path (replaced), and a second run
(idempotent, as a reinstall is). The bundle contract gate gained the command assertion
with cases for both layouts, for an absent command and for one whose mode bit was dropped.
Both run on every push through ci-core.

Not verified on this host: pkgbuild, productsign and installing the package need macOS.
Two release-time checks remain manual: that notarization accepts a Mach-O in whichever
directory Uno placed it, and that the link resolves in a fresh login shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The command now ships inside every SalmonEgg installer, so publishing it a second time
as its own package would mean two delivery paths for one binary, two sets of install
instructions, and two versions a user could end up with. Removed: the package-cli job
and its three-runner matrix, the CLI MSI, the CLI deb, the Homebrew formula, the release
archives and their checksum sidecars, and the CLI install smoke gate whose job the
desktop package's own smoke now does.

BREAKING CHANGE: `salmon-egg` is no longer published on its own. Every platform gets it
by installing SalmonEgg. On Linux that means the desktop package pulls in the graphics
stack the GUI needs, so a headless server can no longer install only the command -- if
that use case matters, the deb would have to be split into a CLI package the GUI depends
on, which is a decision this change deliberately does not make on its own.

What stayed, because both are now load-bearing for four packaging chains instead of one:
publish-cli-binary.sh, which produces the binary each installer embeds, and
run-cli-release-artifact-smoke.sh, which proves that binary starts with no .NET on PATH
and holds its exit-code and credential contracts. The PATH-encoding rule stayed too, now
serving the desktop MSI. The support matrix in SalmonEgg.Cli.csproj kept its three
runtime identifiers and changed meaning: they are the platforms whose installer embeds a
command, so a RID outside the list has no delivery path at all.

The publish job lost its checkout and SDK setup along with the CLI asset step that needed
them -- it now works purely on downloaded artifacts and gh. That also retires the ordering
hazard those two carried: `Setup .NET` ahead of the checkout resolved global.json against
an empty workspace and once killed a tag build after all seven packaging jobs had passed.

GitHubWorkflowContractTests replaces the MSYS-path test, which pinned a PowerShell regex
at the CLI MSI call site, with one asserting that the translation lives in the publish
script and that every Windows consumer takes its native-path output. A new test pins the
mechanism each of the four installers uses to register the command, so any one of them
silently ceasing to do it fails here. Verified: 16 workflow contract tests pass, and the
smoke-script count assertion caught its own off-by-one first, which is how I know it reads
the workflow rather than passing vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release guide said the opposite of what the code now does: "GUI installers do not
modify PATH; the global salmon-egg command comes only from the CLI packages." Every
installer registers it now, and the CLI packages are gone, so the CLI section is rewritten
around the four mechanisms and who verifies each -- and the manual checks that remain,
since only the Linux chain can be installed by a runner.

Two sections are new. Linux had none at all, because there was no installer to document:
it now covers the package layout with the reasoning per path, why the dependency list came
out of /proc/<pid>/maps instead of the build graph, and the known gap that a headless
server can no longer install just the command. macOS gained the .pkg, why it exists
alongside the .dmg, and the bundle-layout evidence from dissecting v1.4.2 that made both
candidate locations for the command worth probing.

The release checklist replaces "CLI artifacts for three RIDs are built" with the four
installers embedding this build's CLI, and adds the Windows and macOS manual PATH checks.
README and README.en lose the standalone install tables in favour of how each installer
registers the command.

AGENTS section 11 gains one rule, since this is the class of defect that repeats: the
installer owns PATH registration, one publish script owns the binary every installer
embeds, and the assertions must read the artifact this build produced with the rule split
from the platform API that reads it -- so its positive and negative cases run on every
push rather than on a tag. It also records the two things that only surfaced by doing
this: a symlink must be asserted by resolution rather than by its text, because a wrong
relative depth is invisible in the package's file list; and when the platform's placement
of an auxiliary executable is not a documented contract, consumers probe and report rather
than bet on one location.

Verified: five gates pass locally (MSI PATH, desktop MSI, MSIX self-test, artifact
contract self-test, macOS installer PATH), every workflow parses, every shell script
passes syntax check including the postinstall under POSIX sh, and 37 contract tests plus
the Application (120) and CLI (95) suites pass. The guide is CRLF and stayed CRLF: the
diff is 108 lines added and 56 removed rather than the whole file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Groundwork for managing the CLI from the app's settings. Two contracts, both about the
machine rather than about anything the app persists: nothing it could store would describe
an MSIX alias, an MSI PATH entry, a dpkg-owned symlink and a link left by a macOS
installer, and any of those can be absent, stale, or pointing at another installation.

The inspector resolves PATH the way a shell does and asks whatever it found which version
it is. PATH order is the whole point: with two installations present the earlier one is
what typing the name reaches, so returning any other match would describe a command the
user cannot invoke -- and that shadowing is the case most worth reporting. Four states a
user can act on differently: not registered, registered, version mismatch (PATH reaches
another installation), and unreadable (the file is there but would not say what it is,
which is a broken install rather than a stale one). Versions are compared on release
identity alone, because the CLI prints its informational version while the app knows its
assembly version and a matched pair never compares equal verbatim.

Linking is macOS-only and deliberately narrow. Everywhere else the installer owns the
entry and removes it on uninstall; a second owner writing the same path means whichever
uninstall runs second leaves a command pointing at a deleted app. macOS is the exception
because a dragged .app has no install hook at all, so without this the .dmg's users have
no path to the command. It reuses the pkg postinstall's exact shape -- remove then link,
probing both bundle areas -- so an install and an in-app link leave the same result, and it
treats a dismissed authorization dialog as a normal outcome rather than a failure.

The shell escaping is a security boundary, not formatting: the text is parsed as an
AppleScript literal and then by /bin/sh, both as root, and a bundle can sit under a
directory containing a quote. So it is built apart from the code that executes it and
tested with a real shell -- 34 cases over ten hostile paths, including one that asserts an
injected `touch` does not create its file. Reverse-verified by removing the single-quote
escape: seven tests fail, and the injection case fails because the file appears.

17 inspector cases cover the states, PATH order in both directions, empty and quoted PATH
segments, link targets reported only when they differ, and the release-identity comparison.
Verified locally: 34 + 17 pass, and the four capability fakes gained the two new members.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Installing SalmonEgg registers the salmon-egg command, but nothing in the app said whether
that actually worked on this machine -- and on macOS, where a dragged .app has no install
step, nothing could fix it. This adds the settings section that answers both.

Everything shown is one observation, re-read on arrival rather than cached: PATH is a
machine fact an installer or another installation can change while the page is open. Four
states read differently because the next step differs -- available, a different version on
PATH (another install is shadowing this one), nothing on PATH, and present but unable to
say what it is. The resolution details name the path PATH produced and, separately, where a
symlink leads, which is how a stale link into a replaced app bundle becomes visible.

Which actions exist is a capability, not a preference. On Windows and Linux the page is
read-only and says the installer owns the registration; offering a fix there would create a
second owner for one path, and whichever uninstall ran second would leave a command
pointing at a deleted app. macOS gets Link and Remove, going through the same
remove-then-link shape as the pkg's postinstall so an install and an in-app link leave
identical results. A dismissed authorization dialog is reported as a decision rather than a
failure, and the status is always re-read from the machine afterwards instead of assuming
the operation's own claim of success.

Platforms with no PATH and no process host get an inspector that says so, so the view model
never learns which platform it is on and one page serves every target.

Verified locally: 14 view model cases (each state's projection, both readings of an absent
command, the detail carried verbatim on an unreadable one, link/unlink re-inspecting,
cancellation deliberately not re-inspecting, CanExecute refusing where the installer owns
the entry, and an inspection failure reported rather than thrown); 534 settings, contract,
XAML-compliance and localization cases pass, including the x:Uid gate over the new page's
resource keys and the three shell tests that pin the section list -- they failed first, as
they should have, and now cover the new entry. Application (120) and Infrastructure (785)
suites pass. Desktop head compiles clean.

Not verified on this host: the WebAssembly head, whose wasm-tools workload does not match
this SDK. The #else branch references only types in Infrastructure, which every target
references unconditionally, and its usings are present; CI's wasm job is the check. The
macOS authorization prompt and the resulting link in a fresh login shell need a Mac.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the CLI integration work surfaced three loose ends, each fixed
here:

- The settings view model kept the previous action's message on screen
  after a fresh inspection, pairing a new status with an old outcome.
  RefreshAsync now clears it before re-probing.
- The installer-owns-PATH rule in AGENTS.md had no escape hatch for
  platforms whose package format has no install hooks (macOS drag-drop).
  The rule now permits app-owned registration there, but only with the
  same path and shape as an installer would write, a removal entry, and
  logic testable against fake roots -- otherwise it is the same
  two-owner defect in a different costume.
- The release guide and both READMEs never mentioned the new settings
  section where users can see the live command status (and on macOS,
  link or unlink it).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app-execution-alias guard called [System.IO.Path]::GetFileName()
inside an <Error> condition string. A condition cannot nest a quoted
property expansion inside another quoted string -- the inner quote ends
the outer one -- so every MSIX publish died at parse time with MSB4092
before any guard could fire. The Linux and macOS gates never evaluate
this target, which is why only the Windows job saw it.

The file name is now resolved into a property (property functions allow
the quoting a condition does not) and the condition reads that property.
Reproduced MSB4092 standalone before the fix and exercised all three
guard branches after it: missing path, wrong file name (reports the
resolved name), and valid input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every pull_request trigger was develop-only, and ci-core plus
code-quality have no workflow_dispatch trigger -- so a PR to main shipped
with no CI at all, because their push triggers only fire after the merge,
when the verdict can no longer block anything. The other five gate
workflows can be dispatched by hand for a main PR, but that relies on
someone remembering to do it.

All seven pull-request gate workflows now declare [develop, main]. The
workflow contract test pinned the develop-only trigger; it now pins the
union, with the reasoning recorded there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
YoungSx and others added 2 commits September 4, 2026 10:39
The inspector tests spell their inventory with forward slashes, but
ResolveOnSearchPath joined candidates with the host's Path.Combine, so on
the Windows runner every candidate came out backslash-separated and
matched nothing: five tests went red. The join is a machine fact like the
PATH text, the file table, and the link targets -- the host's syntax is
not derivable from either input -- so it moves behind
ICliCommandProbeEnvironment.Combine alongside them. Tests supply a
deterministic Unix grammar and keep asserting forward-slash inventory
paths on every host; the real environment keeps Path.Combine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two tests assumed the runner was a Unix machine, went red on the Windows
one, and have blocked every main-targeted CI Core run since 34f10d7:

- StdioCommandResolverTests simulated Unix PATH splitting on ':' but fed
  it a Windows absolute path, whose drive colon the split shredded into
  fragments no File.Exists could hit. The search entry is now a
  colon-free relative directory, which resolves against the process
  current directory identically on every host, so the case still runs
  everywhere.
- StdioTransportConnectionTests pinned the launched agent argument to
  the on-disk spelling, but the Windows resolver echoes the machine's
  PATHEXT casing (".CMD"), and Windows filenames compare
  case-insensitively. The assertion now compares case-insensitively too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
YoungSx and others added 2 commits September 4, 2026 11:35
Three self-test cases came back red on the Windows runner -- every case
whose verdict depends on the executable named by Info.plist -- while
every deliberately-broken case failed as intended. The cause: a Windows
interpreter translates text-mode newlines to \r\n, and the command
substitution capturing read_plist_string's output strips only the \n.
The executable name arrived as "SalmonEgg\r", a file that never exists,
so every bundle that should have verified cleanly could not.

Emitting the value as raw bytes bypasses the text layer entirely.
Reproduced and closed with a CRLF-translating interpreter shim: the old
reader fails exactly those three cases, the byte reader passes all.

Also, a verdict alone ("expected pass but got fail") cost a debugging
session to explain from a CI log, because expect swallowed the check's
own diagnosis. An unexpected outcome now prints what the check said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Linux Desktop gate started failing mid-install: the runner image's
package lists name a webkit2gtk version the archive has since replaced,
and the mirror only serves current ones, so apt 404s while resolving the
package's dependencies. The install itself was never wrong -- the lists
were stale. Refreshing them is part of installing cleanly and belongs
inside the gate, where it cannot be forgotten by a workflow edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the CRLF fix in, the Windows self-test advanced to a new failure
and the new diagnostics named it outright: "cli/salmon-egg is present
but not executable". The fixtures chmod +x their bundled command, but a
Windows host has no mode bits for chmod to set. There, test -x falls
back to reading the file's first bytes and accepts only '#!', ':\n' or
'MZ' -- cygwin path.h has_exec_chars, reached from fstat when the name
carries no executable suffix. A fixture whose content is the three
letters "bin" can never be executable on that host, which is exactly
the three expect-pass bundle cases that failed.

The fixtures now satisfy both grammars without branching on the host:
shebang content for the Windows content heuristic, and the same chmod
+x for the POSIX mode bit. The negative case rewrites the content back
too, since its chmod -x is the no-op there; on POSIX the rewrite cannot
resurrect the cleared bit. Reproduced with a test -x shim modeled on
the cygwin source: the old fixtures fail precisely the three CI cases,
the new ones pass all 22 on both grammars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a Windows runner the gate runs under Git Bash, whose MSYS `ln -s` does
not create symlinks by default: when the target exists it silently copies
it (yielding exactly the regular-file-on-PATH defect the gate exists to
catch), and when it does not it errors -- killing the dangling-link case
under `set -e`. Export MSYS=winsymlinks:nativestrict so `ln -s` either
creates a real symlink (runners have Developer Mode) or fails, and probe
the capability once up front, skipping loudly when it is absent rather
than rehearsing a fiction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@YoungSx
YoungSx merged commit c27eac9 into main Sep 4, 2026
17 checks passed
@YoungSx
YoungSx deleted the cli-integration-issue-145 branch September 4, 2026 12:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CLI 集成进主程序

1 participant