modular_cli_sdk 0.7.0
modular_cli_sdk: ^0.7.0 copied to clipboard
Command-centric SDK for building modular CLIs with Dart — Command/Input/Output contract, structured errors, output formatting, and automatic TTY detection. Built on cli_router.
Changelog #
All notable changes to this project will be documented in this file.
The format loosely follows Keep a Changelog and the project adheres to Semantic Versioning.
0.7.0 #
A CLI built on this SDK had no way to let another package add commands to it.
Issue #28 asked for
a plugin system and three standard plugins (version, doctor and an
installer, upgrade / uninstall) built on it.
Added #
CliPlugin,CliPluginHost,ModularCli.plugin(). A plugin declares aCliPluginManifest(id,version,hostApiVersion,requires) and asetup(CliPluginHost host)that registers routes and reads or contributes to extension points.ModularCli.plugin(...)queues one; nothing runs untilrun()(orbuildPlugins(), for a test that wants the routes without running) is called, once, ever, perModularCli- Dependency ordering, not registration order. Plugins are topologically
sorted by
requiresbefore anysetup()runs, so a plugin that contributes to another plugin's extension point does not have to be registered after it, only declared as requiring it. A cycle, a missing dependency, two plugins sharing anid, or ahostApiVersionconstraint this host's plugin API (cliPluginHostApiVersion, currently1.0.0) does not satisfy all fail the whole build before any plugin'ssetup()runs: there is no partially built plugin set - Extension points.
host.declareExtensionPoint<T>(id),host.contribute<T>(id, value),host.contributions<T>(id). Contributing to an undeclared id, or contributing a value of the wrongT, is a build-timeCliPluginError: an extension point is typed, not a bag ofObject? CliPluginError: one exception for every build-time plugin failure (PLUGIN_DUPLICATE_ID,PLUGIN_DEPENDENCY_MISSING,PLUGIN_DEPENDENCY_CYCLE,PLUGIN_INCOMPATIBLE_HOST_API,PLUGIN_DUPLICATE_ROUTE,PLUGIN_EXTENSION_POINT_UNDECLARED,PLUGIN_EXTENSION_POINT_TYPE_MISMATCH), carryingcode,message,pluginIdand, where relevant,resourceIdModularCli(name:, version:): a CLI's own identity, read back by plugins throughCliPluginHost.metadata(). Both or neither: anamewithout aversion(or vice versa) is anArgumentError, not a half identity a plugin might rely onVersionPlugin: registersversion, printing the host'snameandversion. Readsmetadata()atsetup(), not inside the route handler, so a host missing its identity fails while the plugin set is built rather than on the first person who runsversionDoctorPlugin: registersdoctor, running everyCliDoctorCheckcontributed toDoctorPlugin.extensionPointand reporting them together. A check answersok,warningorerror;doctor's exit code isExitCode.configError(78) if any check errored,ExitCode.okotherwise, a warning is visible but never fails the runInstallationPlugin: configured with aCliInstallationConfig(repository,tagPrefix,executable,alias,assets), it registersupgradeanduninstallasCommands (so both are subject to--plan/--apply/--autoapprovelike any other command in this SDK) and contributes three checks (binary,alias,release) toDoctorPlugin.extensionPoint: itrequires: ['modular_cli.doctor'].upgradelooks up this repository's GitHub releases, keeps only tags starting withtagPrefix(so an application's ownv*tags and the CLI'scli-v*tags coexist in one repository), picks the newest one newer than the host's current version, downloads the asset named for the current platform and installs it over whateverexecutablecurrently resolves to onPATH.uninstallremoves that binary, and thealiastoo but only when it currently resolves to the same path, an alias pointing elsewhere, or missing, is left alone. Every network, filesystem and platform access (CliReleaseSource,CliDownloader,CliFileSystem,CliPlatform) is behind an injectable interface with aHttp*/Io*default, so the test suite never downloads anything or touches a real install path
Fixed #
Review findings against issue #28, found before this release shipped.
- Self-upgrade no longer overwrites the running executable in place.
IoCliFileSystem.writeExecutablenow downloads to a temporary file beside the destination, sets and verifies its permissions, then replaces the destination the way each platform requires:rename(2)on POSIX (atomic, and never disturbs a handle a running process still has open on the old file, so it does not hit Linux'sETXTBSY), or the current target moved aside first on Windows, before the new file takes its name - PATH resolution now checks that a candidate can actually run.
resolveOnPathskips a file that exists but is not executable: the POSIX execute bit on Linux and macOS,PATHEXT-driven candidate extensions (.EXE,.CMD,.BAT,.COMby default) on Windows upgrade --applyanduninstall --applyno longer hit the interactive approval prompt. Both commands now implementSkipsInteractiveApproval; an explicit--applyon these routes is itself the authorization issue #28 asks for, soModuleBuilderno longer prompts, and no longer refuses for lack of a terminal, on either one- A symlinked alias is compared by canonical identity, not by path
string.
doctor'saliascheck anduninstallboth canonicalize before comparing, so a valid symlink alias no longer reports as an error, anduninstalldeletes the binary and a distinct alias path exactly once each, never the same path twice doctor's results are an ordered list, not a map keyed by name. Two checks sharing a name (or contributed by different plugins) are both kept, in the order they ran; the exit code is computed from every result, not from whichever happened to be written last under a shared key. The--jsonshape is now{"checks": [{"name", "status", "detail"}, ...]}, per the issue- A check that throws no longer stops the rest.
DoctorQuery.executecatches a failing check, records it as anerrornaming the reason, and continues with the checks after it;doctorstill exitsExitCode.configError(78) when that happens - Release lookup follows pagination.
HttpCliReleaseSource.listReleaseskeeps following the response'sLinkheader (rel="next") instead of reading only the first page of 30 releases, so a repository with more releases than that no longer loses the older ones atagPrefixsearch might still need - A tag matching
tagPrefixthat does not parse as semver is surfaced, not skipped.latestTaggedReleasenow throwsCliInvalidReleaseTagnaming the tag;upgradefails withrelease-lookup-failedand exits 1, anddoctor'sreleasecheck reports it as a warning naming the tag, instead of silently treating the release as absent ModularCli.buildPlugins()tracks success and failure separately. A secondrun()(orbuildPlugins()) after a failed build rethrows the stored failure instead of silently skipping validation and using a half-built plugin set;.plugin()after either a successful or a failed build throwsStateError, instead of being accepted and never run- The topological sort breaks ties by registration order. (Superseded by the Kahn's-algorithm rewrite below, which this same release also ships.)
- Declaring an extension point a second time is rejected outright.
host.declareExtensionPoint<T>(id)now throwsCliPluginErroron any second declaration of the sameid, whether or not the secondTmatches the first, before touching registry state; the type check on a duplicate id was previously skipped VersionPlugin(version: '0.8.0')now compiles and reports that version. (Superseded below:versionis now required, not a fallback.)- The newer-release doctor warning names the corrective command. The
releasecheck's warning text now readsA newer release is available: <tag> (current: <version>). Run "<alias> upgrade --apply" to install it., matching what issue #28 prescribes, rather than announcing the release without saying what to do about it
Fixed (second review round) #
A second pass over #30 found 8 more issues, all against the same code this release already touched.
- A failed Windows self-replacing write no longer loses the installation.
writeExecutableused to delete the previous executable's backup before the final rename into place, so a rename failure left nothing at the destination. The backup is now kept until that rename succeeds, and restored from if it does not; the failure is still reported asfile-access-deniednaming the reason uninstall --applyof the running executable on Windows no longer fails outright. Windows will not let a running executable delete itself, but it will let one be renamed: the step now moves it to<name>.uninstall-<pid>.oldand starts a detached process that deletes that file once this process exits, reporting explicitly (in both the step's preview and its outcome) that removal is deferred rather than immediate. Starting that detached process is not allowed to fail silently: if it cannot be started, the step fails withfile-access-deniednaming the file to delete by handuninstallno longer deletes a target before its symlinked alias. The alias step is now queued before the executable's, anddeletechecks each path's type without following links (FileSystemEntity.typeSync(..., followLinks: false)), removing a link withLink.delete()rather thanFile.delete(), which fails on a dangling symlink on Linuxupgradethrough a symlinked executable now replaces the binary, not the link. The install target is resolved (canonicalize) before planning, and the resolved path, not the symlinkedPATHentry, is what gets written and what the plan reports; a resolution failure is reported asfile-access-deniedinstead of silently installing over the symlinkVersionPlugin's version is no longer a fallback.versionis now a required constructor parameter, not an optional one read from host metadata when absent. AVersionPluginversion that disagrees withModularCli's own now fails at build time withCliPluginError(PLUGIN_VERSION_MISMATCH), rather than one silently overriding the other: a CLI has exactly one version- The topological sort is Kahn-stable, not just tie-broken. The previous
fix broke ties within a single plugin's own
requireslist, but a depth-first visit can still order two plugins with no relationship to each other out of registration order when they are reached through different paths.orderCliPluginsnow runs Kahn's algorithm directly: on every round, scan every not-yet-ordered plugin, in registration order, and take the first whose dependencies are all already ordered. Registeringa(requires: ['c']),b(independent) andc, in that order, now runssetup()asb,c,a - POSIX execute checks now ask "can the calling user run this," not "does
any execute bit exist."
resolveOnPathused to accept a file with any execute bit set anywhere in its mode; it now looks up the file's owner and the calling process's uid/gid (viaid -u,id -Gandstat) and checks only the bit that actually governs this process: owner, group or other. Mode0641(ownerrw-, groupr--, other--x), owned by the calling user, is now correctly treated as not executable - A hard-linked alias is now recognized as the same file as its target.
canonicalizeonly resolves symlinks, so two hard-linked paths compared that way still read as different files.CliFileSystemgainedsameFile, which falls back toFileSystemEntity.identicalSyncaftercanonicalizedisagrees, anddoctor'saliascheck anduninstallboth use it in place of a barecanonicalizecomparison
Fixed (third review round) #
A third pass over #30, against the same install/uninstall code.
canonicalizeno longer swallows a resolution failure.IoCliFileSystem.canonicalizeused to catch everyFileSystemExceptionfrom resolving symlinks and return the original path unchanged, so a broken resolution looked identical to a successful no-op; a real symlinked upgrade could silently write over the symlink itself instead of its target once resolution started failing partway through.canonicalizeis now abstract with no such default, and lets the exception through;UninstallCommand.steps()(which reaches it indirectly throughsameFile) now catches that failure explicitly and reportsfile-access-deniedinstead of crashing as an unhandled exception- Executability is now asked of the platform, not guessed from stat
bits.
resolveOnPathandwriteExecutable's permission verification used to read and interpret POSIX mode bits and uid/gid by hand; they now run a fixed, non-searchedtest -x(/bin/teston macOS,/usr/bin/teston Linux) against the candidate and trust its exit code. Any exit code other than 0 or 1, or the checker failing to start at all, isCliExecutableCheckFailurerather than a guess, and is reported asexecutable-check-failedfromupgrade,uninstallanddoctorrather than being reported as the file simply not being found - A hard-linked alias is rejected before anything changes, not rewritten.
An alias that is a hard link to the executable, rather than a symlink, is
a shape this plugin will not create or update; it is now detected
(
sameFiletrue, the raw paths unequal,canonicalizedisagreeing) and reported asalias-hard-link-unsupportedindoctor'saliascheck, atupgradeplan time, and again insideInstallExecutableStepimmediately before the write upgrade --applyrevalidates its install target immediately before replacing it, not only when the plan was built.--applycomputes its own plan, asks for approval, then executes within the same run; nothing outside this run stops the target from changing in that window.InstallExecutableStep.performnow re-resolvesconfig.executableon PATH and requires it still resolves to the exact target the plan showed, and that the target is still a regular file, immediately before writing. Any mismatch is reported asinstall-target-changedand nothing is written;--planalready failed with the same typed error whenever the target could not be resolved at all- Windows self-uninstall's cleanup worker is redesigned around a real
process, not a broken Dart API. The previous detached-process approach
(
ProcessStartMode.detachedWithStdio) does not work on Windows: Windows PowerShell's console host cannot run at all under theDETACHED_PROCESScreation flag both.detachedand.detachedWithStdiouse, and exits within milliseconds before doing anything (confirmed withGet-Processnever finding the reported pid), separately from the long-standingdetachedWithStdioI/O bug (dart-lang/sdk#35809). The worker is now launched as `cmd.exe /d /c start "" /min <powershell.exe> -EncodedCommand