modular_cli_sdk 0.7.0 copy "modular_cli_sdk: ^0.7.0" to clipboard
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 a CliPluginManifest (id, version, hostApiVersion, requires) and a setup(CliPluginHost host) that registers routes and reads or contributes to extension points. ModularCli.plugin(...) queues one; nothing runs until run() (or buildPlugins(), for a test that wants the routes without running) is called, once, ever, per ModularCli
  • Dependency ordering, not registration order. Plugins are topologically sorted by requires before any setup() 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 an id, or a hostApiVersion constraint this host's plugin API (cliPluginHostApiVersion, currently 1.0.0) does not satisfy all fail the whole build before any plugin's setup() 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 wrong T, is a build-time CliPluginError: an extension point is typed, not a bag of Object?
  • 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), carrying code, message, pluginId and, where relevant, resourceId
  • ModularCli(name:, version:): a CLI's own identity, read back by plugins through CliPluginHost.metadata(). Both or neither: a name without a version (or vice versa) is an ArgumentError, not a half identity a plugin might rely on
  • VersionPlugin: registers version, printing the host's name and version. Reads metadata() at setup(), 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 runs version
  • DoctorPlugin: registers doctor, running every CliDoctorCheck contributed to DoctorPlugin.extensionPoint and reporting them together. A check answers ok, warning or error; doctor's exit code is ExitCode.configError (78) if any check errored, ExitCode.ok otherwise, a warning is visible but never fails the run
  • InstallationPlugin: configured with a CliInstallationConfig (repository, tagPrefix, executable, alias, assets), it registers upgrade and uninstall as Commands (so both are subject to --plan/--apply/--autoapprove like any other command in this SDK) and contributes three checks (binary, alias, release) to DoctorPlugin.extensionPoint: it requires: ['modular_cli.doctor']. upgrade looks up this repository's GitHub releases, keeps only tags starting with tagPrefix (so an application's own v* tags and the CLI's cli-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 whatever executable currently resolves to on PATH. uninstall removes that binary, and the alias too 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 a Http*/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.writeExecutable now 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's ETXTBSY), 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. resolveOnPath skips a file that exists but is not executable: the POSIX execute bit on Linux and macOS, PATHEXT-driven candidate extensions (.EXE, .CMD, .BAT, .COM by default) on Windows
  • upgrade --apply and uninstall --apply no longer hit the interactive approval prompt. Both commands now implement SkipsInteractiveApproval; an explicit --apply on these routes is itself the authorization issue #28 asks for, so ModuleBuilder no 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's alias check and uninstall both canonicalize before comparing, so a valid symlink alias no longer reports as an error, and uninstall deletes 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 --json shape is now {"checks": [{"name", "status", "detail"}, ...]}, per the issue
  • A check that throws no longer stops the rest. DoctorQuery.execute catches a failing check, records it as an error naming the reason, and continues with the checks after it; doctor still exits ExitCode.configError (78) when that happens
  • Release lookup follows pagination. HttpCliReleaseSource.listReleases keeps following the response's Link header (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 a tagPrefix search might still need
  • A tag matching tagPrefix that does not parse as semver is surfaced, not skipped. latestTaggedRelease now throws CliInvalidReleaseTag naming the tag; upgrade fails with release-lookup-failed and exits 1, and doctor's release check reports it as a warning naming the tag, instead of silently treating the release as absent
  • ModularCli.buildPlugins() tracks success and failure separately. A second run() (or buildPlugins()) 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 throws StateError, 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 throws CliPluginError on any second declaration of the same id, whether or not the second T matches 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: version is now required, not a fallback.)
  • The newer-release doctor warning names the corrective command. The release check's warning text now reads A 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. writeExecutable used 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 as file-access-denied naming the reason
  • uninstall --apply of 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>.old and 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 with file-access-denied naming the file to delete by hand
  • uninstall no longer deletes a target before its symlinked alias. The alias step is now queued before the executable's, and delete checks each path's type without following links (FileSystemEntity.typeSync(..., followLinks: false)), removing a link with Link.delete() rather than File.delete(), which fails on a dangling symlink on Linux
  • upgrade through a symlinked executable now replaces the binary, not the link. The install target is resolved (canonicalize) before planning, and the resolved path, not the symlinked PATH entry, is what gets written and what the plan reports; a resolution failure is reported as file-access-denied instead of silently installing over the symlink
  • VersionPlugin's version is no longer a fallback. version is now a required constructor parameter, not an optional one read from host metadata when absent. A VersionPlugin version that disagrees with ModularCli's own now fails at build time with CliPluginError (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 requires list, 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. orderCliPlugins now 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. Registering a (requires: ['c']), b (independent) and c, in that order, now runs setup() as b, c, a
  • POSIX execute checks now ask "can the calling user run this," not "does any execute bit exist." resolveOnPath used 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 (via id -u, id -G and stat) and checks only the bit that actually governs this process: owner, group or other. Mode 0641 (owner rw-, group r--, 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. canonicalize only resolves symlinks, so two hard-linked paths compared that way still read as different files. CliFileSystem gained sameFile, which falls back to FileSystemEntity.identicalSync after canonicalize disagrees, and doctor's alias check and uninstall both use it in place of a bare canonicalize comparison

Fixed (third review round) #

A third pass over #30, against the same install/uninstall code.

  • canonicalize no longer swallows a resolution failure. IoCliFileSystem.canonicalize used to catch every FileSystemException from 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. canonicalize is now abstract with no such default, and lets the exception through; UninstallCommand.steps() (which reaches it indirectly through sameFile) now catches that failure explicitly and reports file-access-denied instead of crashing as an unhandled exception
  • Executability is now asked of the platform, not guessed from stat bits. resolveOnPath and writeExecutable's permission verification used to read and interpret POSIX mode bits and uid/gid by hand; they now run a fixed, non-searched test -x (/bin/test on macOS, /usr/bin/test on 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, is CliExecutableCheckFailure rather than a guess, and is reported as executable-check-failed from upgrade, uninstall and doctor rather 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 (sameFile true, the raw paths unequal, canonicalize disagreeing) and reported as alias-hard-link-unsupported in doctor's alias check, at upgrade plan time, and again inside InstallExecutableStep immediately before the write
  • upgrade --apply revalidates its install target immediately before replacing it, not only when the plan was built. --apply computes 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.perform now re-resolves config.executable on 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 as install-target-changed and nothing is written; --plan already 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 the DETACHED_PROCESS creation flag both .detached and .detachedWithStdio use, and exits within milliseconds before doing anything (confirmed with Get-Process never finding the reported pid), separately from the long-standing detachedWithStdio I/O bug (dart-lang/sdk#35809). The worker is now launched as `cmd.exe /d /c start "" /min <powershell.exe> -EncodedCommand
1
likes
160
points
200
downloads

Documentation

API reference

Publisher

verified publisherccisne.dev

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#cli #command-line #macss

License

MIT (license)

Dependencies

cli_router, http, preview_executor, pub_semver

More

Packages that depend on modular_cli_sdk