diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4719894..434d965 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -63,10 +63,21 @@ jobs: "$native_root/bin/stack" check "$smoke_directory/diagram.stack" "$native_root/bin/stack" render "$smoke_directory/diagram.stack" -o "$smoke_directory/diagram.svg" test -s "$smoke_directory/diagram.svg" - "$native_root/bin/stack" completions bash | cmp distribution/generated/share/bash-completion/completions/stack - - "$native_root/bin/stack" completions zsh | cmp distribution/generated/share/zsh/site-functions/_stack - - "$native_root/bin/stack" completions fish | cmp distribution/generated/share/fish/vendor_completions.d/stack.fish - - "$native_root/bin/stack" manpage | cmp distribution/generated/share/man/man1/stack.1 - + release_target=x86_64-unknown-linux-gnu + archive_name="stack-v${expected_version}-${release_target}.tar.gz" + archive_id="github_release/github.com/stack-sh/cli/v${expected_version}/${archive_name}" + release_archive="$RUNNER_TEMP/$archive_name" + release_root="$RUNNER_TEMP/aqua-release" + expected_archive_checksum=$(ARCHIVE_ID="$archive_id" node -e 'const lock = JSON.parse(require("fs").readFileSync("tests/aqua/aqua-checksums.json", "utf8")); const entry = lock.checksums.find(({ id }) => id === process.env.ARCHIVE_ID); if (!entry || entry.algorithm !== "sha256") process.exit(1); process.stdout.write(entry.checksum)') + curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error --output "$release_archive" "https://github.com/stack-sh/cli/releases/download/v${expected_version}/${archive_name}" + test "$(sha256sum "$release_archive" | cut -d ' ' -f 1 | tr '[:lower:]' '[:upper:]')" = "$expected_archive_checksum" + mkdir -p "$release_root" + tar -xzf "$release_archive" -C "$release_root" + released_share="$release_root/stack-v${expected_version}-${release_target}/share" + "$native_root/bin/stack" completions bash | cmp "$released_share/bash-completion/completions/stack" - + "$native_root/bin/stack" completions zsh | cmp "$released_share/zsh/site-functions/_stack" - + "$native_root/bin/stack" completions fish | cmp "$released_share/fish/vendor_completions.d/stack.fish" - + "$native_root/bin/stack" manpage | cmp "$released_share/man/man1/stack.1" - - name: Validate release supply chain run: | node --check scripts/create-supply-chain-smoke-fixture.mjs @@ -105,6 +116,14 @@ jobs: COMP_CWORD=3 _stack_completion test "${COMPREPLY[*]}" = "aws azure" + COMP_WORDS=(stack config g) + COMP_CWORD=2 + _stack_completion + test "${COMPREPLY[*]}" = get + COMP_WORDS=(stack config get d) + COMP_CWORD=3 + _stack_completion + test "${COMPREPLY[*]}" = default_icons_path - name: Run canonical formatter suite env: STACK_SPECIFICATION_DIR: ${{ github.workspace }}/.stack-specification @@ -149,6 +168,10 @@ jobs: ./target/release/stack render --help ./target/release/stack update --help ./target/release/stack lsp --help + ./target/release/stack doctor --help + ./target/release/stack config --help + ./target/release/stack config path --help + ./target/release/stack config get --help ./target/release/stack icons --help ./target/release/stack completions --help ./target/release/stack completions bash | cmp distribution/generated/share/bash-completion/completions/stack - @@ -158,6 +181,11 @@ jobs: ./target/release/stack icons list ./target/release/stack icons list aws s3 ./target/release/stack icons import --help + config_smoke_root="$RUNNER_TEMP/stack-config-smoke" + test "$(XDG_CONFIG_HOME="$config_smoke_root" ./target/release/stack config path)" = "$config_smoke_root/stack/config.yaml" + test "$(XDG_CONFIG_HOME="$config_smoke_root" ./target/release/stack config get default_icons_path)" = "$config_smoke_root/stack/icons" + XDG_CONFIG_HOME="$config_smoke_root" ./target/release/stack doctor + test ! -e "$config_smoke_root" - name: Verify repository files run: | test -s README.md @@ -168,6 +196,7 @@ jobs: test -s CONTRIBUTING.md test -s SECURITY.md test -s docs/language-server.md + test -s docs/configuration.md test -s docs/completions.md test -s docs/self-update.md test -s docs/publication-audit.md @@ -232,6 +261,11 @@ jobs: test -s tests/specification-revision test -s tests/fixtures/render.stack test -s tests/snapshots/lsp-help.txt + test -s tests/snapshots/doctor-help.txt + test -s tests/snapshots/doctor-report.txt + test -s tests/snapshots/config-help.txt + test -s tests/snapshots/config-path-help.txt + test -s tests/snapshots/config-get-help.txt test -s tests/snapshots/update-help.txt test -s tests/update.rs diff --git a/README.md b/README.md index 15d307c..af31231 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ For a direct installation, download the archive for your target and its verifica ## Commands +The command inventory below follows the current source tree. `stack doctor` and `stack config` were added after 0.4.0 and are not present in the published 0.4.0 binaries; they will become available through installation channels in a later release. + ```text stack help stack help render @@ -40,6 +42,10 @@ stack render arch.stack stack render arch.stack -o arch.svg stack update --check stack lsp +stack doctor +stack doctor --provider-pack .stack-icons +stack config path +stack config get default_icons_path stack icons list stack icons list aws s3 stack icons import gcp --accept-terms @@ -49,7 +55,7 @@ stack completions zsh stack manpage ``` -`stack help`, `stack -h`, and `stack --help` print top-level help. Use `stack help ` or ` -h` / ` --help` for command-specific usage and examples; nested icon help is available through `stack help icons `. `stack version`, `stack -v`, `stack -V`, and `stack --version` print the same Cargo package version. Help and version output use standard output and exit with status `0`. Invalid arguments and unknown commands use standard error and status `2`; close command typos include a suggested command and the relevant help invocation. +`stack help`, `stack -h`, and `stack --help` print top-level help. Use `stack help ` or ` -h` / ` --help` for command-specific usage and examples; nested help is available through `stack help config ` and `stack help icons `. `stack version`, `stack -v`, `stack -V`, and `stack --version` print the same Cargo package version. Help and version output use standard output and exit with status `0`. Invalid arguments and unknown commands use standard error and status `2`; close command typos include a suggested command and the relevant help invocation. `stack init` creates `diagram.stack` from the versioned `hello-stack` template without prompting. Use `--template ` to select any of the nine curated examples shared with the public Stack specification and Web gallery, and `-o` / `--output` to choose another file. Existing paths are never replaced unless `--force` is explicit; forced writes use the same atomic output behavior as rendering. Provider templates print the exact `stack icons import` commands needed for branded rendering and remain valid with deterministic fallback icons when packs are absent. The embedded catalog and source bytes are pinned by `tests/specification-revision`, and CI rejects drift from that public specification commit. @@ -61,13 +67,15 @@ stack manpage `stack lsp` runs a native [Language Server Protocol 3.18 adapter](./docs/language-server.md) over standard input and output. It provides incremental document synchronization, versioned diagnostics, completion, hover, hierarchical document symbols, and whole-document formatting for `.stack` files. The adapter negotiates UTF-8, UTF-16, or UTF-32 positions and delegates language semantics and formatting to the pinned compiler and engine rather than reimplementing them. Standard output is reserved for framed JSON-RPC messages. +`stack config path` prints the selected `config.yaml` path without creating or reading the file. `stack config get default_icons_path` strictly reads the supported configuration and prints the effective icon-store path. `stack doctor` reports the CLI version, configuration path and source, configuration validity, effective icon-store source, and installed known-provider packs. It is read-only, emits actionable categories instead of configuration contents, exits `0` for healthy and warning-only reports, and exits `2` when it finds an operational problem. See the [configuration discovery and doctor contract](./docs/configuration.md). + `stack update` is included in 0.4.0 for future receipted direct installations, with `--check`, exact-version selection, authenticated release-manifest and archive verification, and rollback-aware atomic replacement. It refuses Homebrew, Aqua, Cargo, and unknown ownership. The 0.4.0 release manifest does not activate `self-update`, and the documented manual installation creates no receipt, so the channel remains planned. See the [self-update contract](./docs/self-update.md). `stack completions ` and `stack manpage` generate deterministic shell integration and an offline roff manual from the CLI command metadata. The 0.4.0 release archives carry the exact generated files; Homebrew installs them into its managed completion and manual paths, while direct, Aqua, and future Cargo users can generate them into user-owned locations without modifying shell startup files. See the [completion and manual guide](./docs/completions.md). `stack icons list [PROVIDER] [QUERY]` searches the asset-free catalog by ID, product name, or category. The catalog currently contains 1,051 IDs: 305 AWS, 45 Google Cloud, 639 Azure, and 62 curated developer and collaboration tool icons. This command reads only metadata embedded in the CLI. -`stack icons import --accept-terms` downloads the audited official archive set, verifies every complete SHA-256 before ZIP processing, reads allowlisted SVG entries with fixed size limits, sanitizes active and external content, preserves official colors and geometry, and writes the manifest, notice, and processed SVGs atomically. The default store is `$XDG_CONFIG_HOME/stack/icons`, falling back to `$HOME/.config/stack/icons`. `$XDG_CONFIG_HOME/stack/config.yaml` can set an absolute `default_icons_path`. Use `-o ` to put provider child directories below a project-local root. See [the provider icon guide](./docs/provider-icon-import.md) for configuration, project-local usage, sources, hashes, and rights. +`stack icons import --accept-terms` downloads the audited official archive set, verifies every complete SHA-256 before ZIP processing, reads allowlisted SVG entries with fixed size limits, sanitizes active and external content, preserves official colors and geometry, and writes the manifest, notice, and processed SVGs atomically. The default store is `$XDG_CONFIG_HOME/stack/icons`, falling back to `$HOME/.config/stack/icons`. `$XDG_CONFIG_HOME/stack/config.yaml` can set an absolute `default_icons_path`. Use `-o ` to put provider child directories below a project-local root. See the [configuration contract](./docs/configuration.md) for discovery and diagnosis, and [the provider icon guide](./docs/provider-icon-import.md) for project-local usage, sources, hashes, and rights. | Result | Exit status | | --- | ---: | diff --git a/distribution/generated/share/bash-completion/completions/stack b/distribution/generated/share/bash-completion/completions/stack index fb2268b..ab70638 100644 --- a/distribution/generated/share/bash-completion/completions/stack +++ b/distribution/generated/share/bash-completion/completions/stack @@ -4,8 +4,8 @@ local current previous context words current="${COMP_WORDS[COMP_CWORD]-}" previous="${COMP_WORDS[COMP_CWORD-1]-}" context="${COMP_WORDS[1]-}" -if [[ "$context" == icons && $COMP_CWORD -ge 3 ]]; then -context="icons ${COMP_WORDS[2]-}" +if [[ ( "$context" == icons || "$context" == config ) && $COMP_CWORD -ge 3 ]]; then +context="$context ${COMP_WORDS[2]-}" fi case "$previous" in --template) words="hello-stack application-and-data groups-and-layout commerce-platform aws-serverless-checkout gcp-data-service azure-event-platform github-delivery-workflow mixed-provider-platform" ;; @@ -15,7 +15,7 @@ case "$previous" in esac if [[ -z "$words" ]]; then if (( COMP_CWORD == 1 )); then -words="init check fmt render update lsp icons completions manpage help version -h --help -v -V --version" +words="init check fmt render update lsp doctor config icons completions manpage help version -h --help -v -V --version" else case "$context" in "init") words="--template -o --output --force -h --help" ;; @@ -24,12 +24,16 @@ words="init check fmt render update lsp icons completions manpage help version - "render") words="--provider-pack -o --notice -h --help" ;; "update") words="--check --version -h --help" ;; "lsp") words="-h --help" ;; + "doctor") words="--provider-pack -h --help" ;; + "config") words="path get help -h --help" ;; + "config path") words="-h --help" ;; + "config get") words="default_icons_path -h --help" ;; "icons") words="list import help -h --help" ;; "icons list") words="aws gcp azure simple-icons -h --help" ;; "icons import") words="aws gcp azure simple-icons --accept-terms -o -h --help" ;; "completions") words="bash zsh fish -h --help" ;; "manpage") words="-h --help" ;; - "help") words="init check fmt render update lsp icons completions manpage help version -h --help" ;; + "help") words="init check fmt render update lsp doctor config icons completions manpage help version -h --help" ;; "version") words="-h --help" ;; *) words="" ;; esac diff --git a/distribution/generated/share/fish/vendor_completions.d/stack.fish b/distribution/generated/share/fish/vendor_completions.d/stack.fish index 6dac9fc..5e3d9a7 100644 --- a/distribution/generated/share/fish/vendor_completions.d/stack.fish +++ b/distribution/generated/share/fish/vendor_completions.d/stack.fish @@ -9,6 +9,8 @@ complete -c stack -n __stack_needs_command -a 'fmt' -d 'Format a file in place o complete -c stack -n __stack_needs_command -a 'render' -d 'Render standalone SVG to standard output or a file' complete -c stack -n __stack_needs_command -a 'update' -d 'Check for or install a verified direct-install update' complete -c stack -n __stack_needs_command -a 'lsp' -d 'Run the Stack language server over standard input and output' +complete -c stack -n __stack_needs_command -a 'doctor' -d 'Diagnose CLI configuration and provider icon packs' +complete -c stack -n __stack_needs_command -a 'config' -d 'Inspect effective read-only configuration' complete -c stack -n __stack_needs_command -a 'icons' -d 'List catalogs and import audited provider icon archives' complete -c stack -n __stack_needs_command -a 'completions' -d 'Generate bash, zsh, or fish completion source' complete -c stack -n __stack_needs_command -a 'manpage' -d 'Print the offline Stack CLI manual page' @@ -42,6 +44,19 @@ complete -c stack -n '__fish_seen_subcommand_from update' -s 'h' complete -c stack -n '__fish_seen_subcommand_from update' -l 'help' complete -c stack -n '__fish_seen_subcommand_from lsp' -s 'h' complete -c stack -n '__fish_seen_subcommand_from lsp' -l 'help' +complete -c stack -n '__fish_seen_subcommand_from doctor' -l 'provider-pack' -r +complete -c stack -n '__fish_seen_subcommand_from doctor' -s 'h' +complete -c stack -n '__fish_seen_subcommand_from doctor' -l 'help' +complete -c stack -n '__fish_seen_subcommand_from config' -a 'path' +complete -c stack -n '__fish_seen_subcommand_from config' -a 'get' +complete -c stack -n '__fish_seen_subcommand_from config' -a 'help' +complete -c stack -n '__fish_seen_subcommand_from config' -s 'h' +complete -c stack -n '__fish_seen_subcommand_from config' -l 'help' +complete -c stack -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from path' -s 'h' +complete -c stack -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from path' -l 'help' +complete -c stack -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get' -a 'default_icons_path' +complete -c stack -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get' -s 'h' +complete -c stack -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get' -l 'help' complete -c stack -n '__fish_seen_subcommand_from icons' -a 'list' complete -c stack -n '__fish_seen_subcommand_from icons' -a 'import' complete -c stack -n '__fish_seen_subcommand_from icons' -a 'help' @@ -74,6 +89,8 @@ complete -c stack -n '__fish_seen_subcommand_from help' -a 'fmt' complete -c stack -n '__fish_seen_subcommand_from help' -a 'render' complete -c stack -n '__fish_seen_subcommand_from help' -a 'update' complete -c stack -n '__fish_seen_subcommand_from help' -a 'lsp' +complete -c stack -n '__fish_seen_subcommand_from help' -a 'doctor' +complete -c stack -n '__fish_seen_subcommand_from help' -a 'config' complete -c stack -n '__fish_seen_subcommand_from help' -a 'icons' complete -c stack -n '__fish_seen_subcommand_from help' -a 'completions' complete -c stack -n '__fish_seen_subcommand_from help' -a 'manpage' diff --git a/distribution/generated/share/man/man1/stack.1 b/distribution/generated/share/man/man1/stack.1 index 62f409c..230dd03 100644 --- a/distribution/generated/share/man/man1/stack.1 +++ b/distribution/generated/share/man/man1/stack.1 @@ -22,6 +22,8 @@ Commands: render Render standalone SVG to standard output or a file update Check for or install a verified direct\-install update lsp Run the Stack language server over standard input and output + doctor Diagnose CLI configuration and provider icon packs + config Inspect effective read\-only configuration icons List catalogs and import audited provider icon archives completions Generate bash, zsh, or fish completion source manpage Print the offline Stack CLI manual page @@ -40,6 +42,8 @@ Examples: stack render arch.stack \-o arch.svg stack update \-\-check stack lsp + stack doctor + stack config get default_icons_path stack icons list aws s3 stack completions zsh > _stack stack manpage > stack.1 @@ -183,6 +187,80 @@ Protocol: Examples: stack lsp .fi +.SS "stack doctor" +.nf +Diagnose Stack CLI configuration and provider icon packs + +Usage: + stack doctor [\-\-provider\-pack ] + +Options: + \-\-provider\-pack Diagnose this icon\-store root instead of the effective default + \-h, \-\-help Print help + +Checks: + CLI version, config path and source, config validity, effective icon\-store + source, and installed known\-provider packs. The report never prints config + contents or unrelated environment variables. + +Examples: + stack doctor + stack doctor \-\-provider\-pack .stack\-icons +.fi +.SS "stack config" +.nf +Inspect effective read\-only Stack configuration + +Usage: + stack config + +Commands: + path Print the resolved config.yaml path without requiring the file to exist + get Print one effective configuration value + help Print this message or the help of a config subcommand + +Options: + \-h, \-\-help Print help + +Examples: + stack config path + stack config get default_icons_path +.fi +.SS "stack config path" +.nf +Print the resolved Stack config file path + +Usage: + stack config path + +Options: + \-h, \-\-help Print help + +The path is selected from absolute XDG_CONFIG_HOME first, then absolute HOME. +The config file does not need to exist and is never created. + +Examples: + stack config path +.fi +.SS "stack config get" +.nf +Print an effective Stack configuration value + +Usage: + stack config get + +Arguments: + default_icons_path + +Options: + \-h, \-\-help Print help + +The value is resolved from config.yaml or the default icon\-store path. No file +is created or changed. + +Examples: + stack config get default_icons_path +.fi .SS "stack icons" .nf Manage local provider icon packs @@ -289,11 +367,12 @@ Print top\-level or subcommand help Usage: stack help stack help + stack help config stack help icons Arguments: - init, check, fmt, render, update, lsp, icons, completions, - manpage, help, or version + init, check, fmt, render, update, lsp, doctor, config, icons, + completions, manpage, help, or version Options: \-h, \-\-help Print help @@ -301,6 +380,7 @@ Options: Examples: stack help stack help render + stack help config get stack help icons import .fi .SS "stack version" diff --git a/distribution/generated/share/zsh/site-functions/_stack b/distribution/generated/share/zsh/site-functions/_stack index 9badb58..88cbfcf 100644 --- a/distribution/generated/share/zsh/site-functions/_stack +++ b/distribution/generated/share/zsh/site-functions/_stack @@ -2,8 +2,8 @@ # Generated by `stack completions zsh`; do not edit. _stack() { local context="${words[2]-}" -if [[ "$context" == icons && $CURRENT -ge 4 ]]; then -context="icons ${words[3]-}" +if [[ ( "$context" == icons || "$context" == config ) && $CURRENT -ge 4 ]]; then +context="$context ${words[3]-}" fi if (( CURRENT == 2 )); then local -a commands @@ -14,6 +14,8 @@ commands=( 'render:Render standalone SVG to standard output or a file' 'update:Check for or install a verified direct-install update' 'lsp:Run the Stack language server over standard input and output' + 'doctor:Diagnose CLI configuration and provider icon packs' + 'config:Inspect effective read-only configuration' 'icons:List catalogs and import audited provider icon archives' 'completions:Generate bash, zsh, or fish completion source' 'manpage:Print the offline Stack CLI manual page' @@ -36,12 +38,16 @@ case "$context" in "render") candidates=('--provider-pack' '-o' '--notice' '-h' '--help') ;; "update") candidates=('--check' '--version' '-h' '--help') ;; "lsp") candidates=('-h' '--help') ;; + "doctor") candidates=('--provider-pack' '-h' '--help') ;; + "config") candidates=('path' 'get' 'help' '-h' '--help') ;; + "config path") candidates=('-h' '--help') ;; + "config get") candidates=('default_icons_path' '-h' '--help') ;; "icons") candidates=('list' 'import' 'help' '-h' '--help') ;; "icons list") candidates=('aws' 'gcp' 'azure' 'simple-icons' '-h' '--help') ;; "icons import") candidates=('aws' 'gcp' 'azure' 'simple-icons' '--accept-terms' '-o' '-h' '--help') ;; "completions") candidates=('bash' 'zsh' 'fish' '-h' '--help') ;; "manpage") candidates=('-h' '--help') ;; - "help") candidates=('init' 'check' 'fmt' 'render' 'update' 'lsp' 'icons' 'completions' 'manpage' 'help' 'version' '-h' '--help') ;; + "help") candidates=('init' 'check' 'fmt' 'render' 'update' 'lsp' 'doctor' 'config' 'icons' 'completions' 'manpage' 'help' 'version' '-h' '--help') ;; "version") candidates=('-h' '--help') ;; *) candidates=() ;; esac diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..d23a18e --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,70 @@ +# Configuration discovery and doctor + +`stack config` exposes the paths already used by rendering and provider import, while `stack doctor` diagnoses that configuration and its known-provider packs. Every command on this page is read-only: it does not create a configuration directory, config file, icon store, provider pack, receipt, or temporary file. + +These commands were added to the source tree after Stack CLI 0.4.0. The published 0.4.0 GitHub, Homebrew, and Aqua binaries do not contain them. They will become available through those channels in a later release. + +## Discovery order + +Stack selects one configuration file path: + +1. An absolute, non-empty `XDG_CONFIG_HOME` selects `$XDG_CONFIG_HOME/stack/config.yaml`. +2. Otherwise, an absolute, non-empty `HOME` selects `$HOME/.config/stack/config.yaml`. +3. If neither value supplies an absolute path, discovery fails with exit status `2`. + +A relative `XDG_CONFIG_HOME` is ignored rather than resolved against the current directory. `stack config path` prints the selected path even when the file does not exist, and never reads or creates it: + +```sh +stack config path +``` + +The config file is optional. A missing or whitespace-only file uses the default icon store beside it at `stack/icons`. A non-empty file accepts exactly one optional key: + +```yaml +default_icons_path: /absolute/path/to/stack-icons +``` + +Unknown keys, malformed YAML, relative `default_icons_path` values, symlinks, non-regular files, unreadable files, and files larger than 64 KiB fail closed. `stack config get` applies those checks and prints the one effective value: + +```sh +stack config get default_icons_path +``` + +Command output is a single path followed by a newline. It is intended for inspection and shell composition, not as a stable structured-data protocol. + +## Doctor report + +Run the default diagnosis with: + +```sh +stack doctor +``` + +The report includes: + +- the executable's package version; +- the selected config path and whether `XDG_CONFIG_HOME` or the `HOME` fallback selected it; +- whether the config file is missing, loaded, or invalid; +- the effective icon-store path and whether it came from the default or `default_icons_path`; +- the number of valid installed `aws`, `gcp`, `azure`, and `simple-icons` packs. + +Use an explicit project-local icon-store root without changing config resolution: + +```sh +stack doctor --provider-pack .stack-icons +``` + +An absent implicit default store is healthy and means that zero packs are installed. An absent configured store is a warning because the user selected it explicitly. An absent `--provider-pack` directory is an error. Existing stores must be real readable directories, and each known-provider directory must contain a bounded, valid manifest and all of its declared regular SVG assets. + +| Result | Exit status | +| --- | ---: | +| Healthy, including a missing implicit store | `0` | +| Warning only, including a missing configured store | `0` | +| Invalid or unreadable config, unresolved config path, or invalid/unreadable/absent explicit provider store | `2` | +| Invalid command arguments | `2` | + +## Information boundary + +Doctor prints paths because path discovery is the behavior being diagnosed. It does not print config file contents, provider manifest contents, unrelated environment variables, tokens, credentials, or raw parser errors. Invalid and permission-denied inputs are reduced to stable categories with a corrective action. If a path itself contains sensitive text, do not publish or paste the report; paths are user-controlled values and are intentionally visible. + +For provider source, rights, import, and notice behavior, see the [provider icon guide](./provider-icon-import.md). diff --git a/docs/distribution.md b/docs/distribution.md index f29b8fe..8cee89d 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -134,7 +134,7 @@ Commit `aqua-checksums.json` with the configuration. To upgrade after a new stab Aqua installs the executable declared by its registry mapping and does not own shell startup files or a global manual database. Stack CLI 0.4.0 includes the generators; use `stack completions` and `stack manpage` to write the desired user-owned files as documented in the [completion guide](./completions.md). -The Aqua v0.4.0 pin was activated after the immutable release assets were published. CI verifies all four target mappings without executing foreign binaries, then installs the native archive and compares all completion and manual generator bytes. The release manifest remains the publication-time record; the pinned registry commit, generated checksum lock, contract, and CI runs are the later verification evidence. No release asset is replaced. +The Aqua v0.4.0 pin was activated after the immutable release assets were published. CI verifies all four target mappings without executing foreign binaries, then installs the native archive and compares all completion and manual generator bytes with the matching checksum-locked canonical release archive. This keeps the published release check independent from later source-tree command additions. The release manifest remains the publication-time record; the pinned registry commit, generated checksum lock, contract, and CI runs are the later verification evidence. No release asset is replaced. ## Direct installation diff --git a/docs/provider-icon-import.md b/docs/provider-icon-import.md index dd9bb3a..d685490 100644 --- a/docs/provider-icon-import.md +++ b/docs/provider-icon-import.md @@ -50,6 +50,7 @@ default_icons_path: /absolute/path/to/stack-icons ``` The configured path is used by both `stack icons import` and `stack render`. +Use `stack config get default_icons_path` to inspect the effective value and `stack doctor` to validate the store without changing it. These commands were added after the published 0.4.0 release; see the [configuration discovery and doctor contract](./configuration.md) for availability and failure behavior. ## Keep icons with a project diff --git a/scripts/verify_release_binary.py b/scripts/verify_release_binary.py index a70ec15..01b77b9 100644 --- a/scripts/verify_release_binary.py +++ b/scripts/verify_release_binary.py @@ -104,6 +104,14 @@ def verify_commands(binary, version): b"stack update" in command([binary, "update", "--help"]), "update help output is missing usage", ) + require( + b"stack doctor" in command([binary, "doctor", "--help"]), + "doctor help output is missing usage", + ) + require( + b"stack config" in command([binary, "config", "--help"]), + "config help output is missing usage", + ) for relative_path, arguments in GENERATED_COMMANDS.items(): expected = (ROOT / "distribution/generated" / relative_path).read_bytes() require( @@ -115,6 +123,22 @@ def verify_commands(binary, version): working_directory = Path(temporary) environment = os.environ.copy() environment["XDG_CONFIG_HOME"] = str(working_directory / "config") + expected_config_path = working_directory / "config/stack/config.yaml" + require( + command([binary, "config", "path"], working_directory, environment) + == f"{expected_config_path}\n".encode(), + "config path does not match isolated XDG_CONFIG_HOME", + ) + require( + command([binary, "config", "get", "default_icons_path"], working_directory, environment) + == f"{working_directory / 'config/stack/icons'}\n".encode(), + "effective default_icons_path does not match isolated XDG_CONFIG_HOME", + ) + require( + b"Result: healthy\n" in command([binary, "doctor"], working_directory, environment), + "doctor does not report a healthy missing default store", + ) + require(not (working_directory / "config").exists(), "read-only config commands created files") command([binary, "init"], working_directory, environment) source = working_directory / "diagram.stack" require(source.is_file() and source.stat().st_size > 0, "stack init did not create diagram.stack") diff --git a/src/command_docs.rs b/src/command_docs.rs index 61cbbe4..94753be 100644 --- a/src/command_docs.rs +++ b/src/command_docs.rs @@ -3,9 +3,9 @@ use std::fmt::Write as _; use super::{ - CHECK_HELP, COMPLETIONS_HELP, FORMAT_HELP, GENERAL_HELP, HELP_HELP, ICONS_HELP, - ICONS_IMPORT_HELP, ICONS_LIST_HELP, INIT_HELP, LSP_HELP, MANPAGE_HELP, RENDER_HELP, - UPDATE_HELP, VERSION_HELP, + CHECK_HELP, COMPLETIONS_HELP, CONFIG_GET_HELP, CONFIG_HELP, CONFIG_PATH_HELP, DOCTOR_HELP, + FORMAT_HELP, GENERAL_HELP, HELP_HELP, ICONS_HELP, ICONS_IMPORT_HELP, ICONS_LIST_HELP, + INIT_HELP, LSP_HELP, MANPAGE_HELP, RENDER_HELP, UPDATE_HELP, VERSION_HELP, }; pub(crate) const TOP_LEVEL_NAMES: &[&str] = &[ @@ -15,6 +15,8 @@ pub(crate) const TOP_LEVEL_NAMES: &[&str] = &[ "render", "update", "lsp", + "doctor", + "config", "icons", "completions", "manpage", @@ -82,6 +84,30 @@ const COMMANDS: &[CommandSpec] = &[ options: &["-h", "--help"], values: &[], }, + CommandSpec { + context: "doctor", + description: "Diagnose CLI configuration and provider icon packs", + options: &["--provider-pack", "-h", "--help"], + values: &[], + }, + CommandSpec { + context: "config", + description: "Inspect effective read-only configuration", + options: &["-h", "--help"], + values: &["path", "get", "help"], + }, + CommandSpec { + context: "config path", + description: "Print the resolved config.yaml path", + options: &["-h", "--help"], + values: &[], + }, + CommandSpec { + context: "config get", + description: "Print one effective configuration value", + options: &["-h", "--help"], + values: &["default_icons_path"], + }, CommandSpec { context: "icons", description: "List catalogs and import audited provider icon archives", @@ -150,8 +176,8 @@ _stack_completion() {\n\ current=\"${COMP_WORDS[COMP_CWORD]-}\"\n\ previous=\"${COMP_WORDS[COMP_CWORD-1]-}\"\n\ context=\"${COMP_WORDS[1]-}\"\n\ - if [[ \"$context\" == icons && $COMP_CWORD -ge 3 ]]; then\n\ - context=\"icons ${COMP_WORDS[2]-}\"\n\ + if [[ ( \"$context\" == icons || \"$context\" == config ) && $COMP_CWORD -ge 3 ]]; then\n\ + context=\"$context ${COMP_WORDS[2]-}\"\n\ fi\n\ case \"$previous\" in\n\ --template) words=\"", @@ -208,8 +234,8 @@ fn zsh_completion() -> String { "#compdef stack\n# Generated by `stack completions zsh`; do not edit.\n\ _stack() {\n\ local context=\"${words[2]-}\"\n\ - if [[ \"$context\" == icons && $CURRENT -ge 4 ]]; then\n\ - context=\"icons ${words[3]-}\"\n\ + if [[ ( \"$context\" == icons || \"$context\" == config ) && $CURRENT -ge 4 ]]; then\n\ + context=\"$context ${words[3]-}\"\n\ fi\n\ if (( CURRENT == 2 )); then\n\ local -a commands\n\ @@ -367,6 +393,10 @@ Stack validates, formats, renders, and develops Stack architecture diagrams.\n\ ("stack render", RENDER_HELP), ("stack update", UPDATE_HELP), ("stack lsp", LSP_HELP), + ("stack doctor", DOCTOR_HELP), + ("stack config", CONFIG_HELP), + ("stack config path", CONFIG_PATH_HELP), + ("stack config get", CONFIG_GET_HELP), ("stack icons", ICONS_HELP), ("stack icons list", ICONS_LIST_HELP), ("stack icons import", ICONS_IMPORT_HELP), @@ -404,6 +434,10 @@ mod tests { "render" => Some(RENDER_HELP), "update" => Some(UPDATE_HELP), "lsp" => Some(LSP_HELP), + "doctor" => Some(DOCTOR_HELP), + "config" => Some(CONFIG_HELP), + "config path" => Some(CONFIG_PATH_HELP), + "config get" => Some(CONFIG_GET_HELP), "icons" => Some(ICONS_HELP), "icons list" => Some(ICONS_LIST_HELP), "icons import" => Some(ICONS_IMPORT_HELP), @@ -473,6 +507,8 @@ mod tests { assert_eq!(manpage(), manpage()); let manual = manpage(); assert!(manual.starts_with(".TH STACK 1")); + assert!(manual.contains(".SS \"stack doctor\"")); + assert!(manual.contains(".SS \"stack config get\"")); assert!(manual.contains(".SS \"stack icons import\"")); assert!(manual.contains("Stack CLI Manual")); } diff --git a/src/config.rs b/src/config.rs index 8c37280..74827af 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,6 +39,81 @@ struct StackConfig { default_icons_path: Option, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ConfigRootSource { + XdgConfigHome, + Home, +} + +impl ConfigRootSource { + pub(crate) const fn label(self) -> &'static str { + match self { + Self::XdgConfigHome => "XDG_CONFIG_HOME", + Self::Home => "HOME fallback", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ConfigFileState { + Missing, + Loaded, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum IconStoreSource { + ConfigFile, + Default, +} + +impl IconStoreSource { + pub(crate) const fn label(self) -> &'static str { + match self { + Self::ConfigFile => "config default_icons_path", + Self::Default => "default", + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Discovery { + pub(crate) config_file_state: ConfigFileState, + pub(crate) icon_store_root: PathBuf, + pub(crate) icon_store_source: IconStoreSource, +} + +pub(crate) fn config_file_path( + environment: &Environment, +) -> Result<(PathBuf, ConfigRootSource), String> { + let (root, source) = config_root(environment)?; + Ok((root.join("stack/config.yaml"), source)) +} + +pub(crate) fn discover(environment: &Environment) -> Result { + let (config_path, _) = config_file_path(environment)?; + let (config, config_file_state) = read_config(&config_path)?; + let (icon_store_root, icon_store_source) = + if let Some(default_icons_path) = config.default_icons_path { + if !default_icons_path.is_absolute() { + return Err(format!( + "config '{}' must set 'default_icons_path' to an absolute path", + config_path.display() + )); + } + (default_icons_path, IconStoreSource::ConfigFile) + } else { + ( + config_path.with_file_name("icons"), + IconStoreSource::Default, + ) + }; + Ok(Discovery { + config_file_state, + icon_store_root, + icon_store_source, + }) +} + pub(crate) fn icon_store_root( explicit_root: Option<&Path>, environment: &Environment, @@ -46,33 +121,21 @@ pub(crate) fn icon_store_root( if let Some(root) = explicit_root { return Ok(root.to_owned()); } - - let config_root = config_root(environment)?; - let stack_root = config_root.join("stack"); - let config_path = stack_root.join("config.yaml"); - let config = read_config(&config_path)?; - if let Some(default_icons_path) = config.default_icons_path { - if !default_icons_path.is_absolute() { - return Err(format!( - "config '{}' must set 'default_icons_path' to an absolute path", - config_path.display() - )); - } - return Ok(default_icons_path); - } - Ok(stack_root.join("icons")) + discover(environment).map(|discovery| discovery.icon_store_root) } pub(crate) fn installation_receipt_path(environment: &Environment) -> Result { - Ok(config_root(environment)?.join("stack/install-receipt.json")) + Ok(config_root(environment)? + .0 + .join("stack/install-receipt.json")) } -fn config_root(environment: &Environment) -> Result { +fn config_root(environment: &Environment) -> Result<(PathBuf, ConfigRootSource), String> { if let Some(value) = &environment.xdg_config_home { if !value.is_empty() { let path = PathBuf::from(value); if path.is_absolute() { - return Ok(path); + return Ok((path, ConfigRootSource::XdgConfigHome)); } } } @@ -81,7 +144,7 @@ fn config_root(environment: &Environment) -> Result { if !value.is_empty() { let home = PathBuf::from(value); if home.is_absolute() { - return Ok(home.join(".config")); + return Ok((home.join(".config"), ConfigRootSource::Home)); } } } @@ -91,11 +154,11 @@ fn config_root(environment: &Environment) -> Result { ) } -fn read_config(path: &Path) -> Result { +fn read_config(path: &Path) -> Result<(StackConfig, ConfigFileState), String> { let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(StackConfig::default()); + return Ok((StackConfig::default(), ConfigFileState::Missing)); } Err(error) => { return Err(format!( @@ -134,10 +197,10 @@ fn read_config(path: &Path) -> Result { )); } if bytes.iter().all(u8::is_ascii_whitespace) { - return Ok(StackConfig::default()); + return Ok((StackConfig::default(), ConfigFileState::Loaded)); } match serde_yaml_ng::from_slice(&bytes) { - Ok(config) => Ok(config), + Ok(config) => Ok((config, ConfigFileState::Loaded)), Err(_) => Err(format!("config '{}' is invalid YAML", path.display())), } } @@ -203,6 +266,14 @@ mod tests { Ok(path) if path == home.join(".config/stack/icons") )); assert!(icon_store_root(None, &Environment::new(None, None)).is_err()); + assert!(matches!( + config_file_path(&Environment::new(Some(&xdg), Some(&home))), + Ok((path, ConfigRootSource::XdgConfigHome)) if path == xdg.join("stack/config.yaml") + )); + assert!(matches!( + config_file_path(&Environment::new(None, Some(&home))), + Ok((path, ConfigRootSource::Home)) if path == home.join(".config/stack/config.yaml") + )); assert!(matches!( installation_receipt_path(&Environment::new(Some(&xdg), Some(&home))), Ok(path) if path == xdg.join("stack/install-receipt.json") @@ -228,8 +299,12 @@ mod tests { .is_ok() ); assert!(matches!( - icon_store_root(None, &Environment::new(Some(&xdg), None)), - Ok(path) if path == custom + discover(&Environment::new(Some(&xdg), None)), + Ok(Discovery { + config_file_state: ConfigFileState::Loaded, + icon_store_root, + icon_store_source: IconStoreSource::ConfigFile, + }) if icon_store_root == custom )); assert!( diff --git a/src/lib.rs b/src/lib.rs index 06fe292..e6280f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,8 @@ Commands: render Render standalone SVG to standard output or a file update Check for or install a verified direct-install update lsp Run the Stack language server over standard input and output + doctor Diagnose CLI configuration and provider icon packs + config Inspect effective read-only configuration icons List catalogs and import audited provider icon archives completions Generate bash, zsh, or fish completion source manpage Print the offline Stack CLI manual page @@ -61,6 +63,8 @@ Examples: stack render arch.stack -o arch.svg stack update --check stack lsp + stack doctor + stack config get default_icons_path stack icons list aws s3 stack completions zsh > _stack stack manpage > stack.1 @@ -173,6 +177,76 @@ Protocol: Examples: stack lsp "; +const DOCTOR_HELP: &str = "\ +Diagnose Stack CLI configuration and provider icon packs + +Usage: + stack doctor [--provider-pack ] + +Options: + --provider-pack Diagnose this icon-store root instead of the effective default + -h, --help Print help + +Checks: + CLI version, config path and source, config validity, effective icon-store + source, and installed known-provider packs. The report never prints config + contents or unrelated environment variables. + +Examples: + stack doctor + stack doctor --provider-pack .stack-icons +"; +const CONFIG_HELP: &str = "\ +Inspect effective read-only Stack configuration + +Usage: + stack config + +Commands: + path Print the resolved config.yaml path without requiring the file to exist + get Print one effective configuration value + help Print this message or the help of a config subcommand + +Options: + -h, --help Print help + +Examples: + stack config path + stack config get default_icons_path +"; +const CONFIG_PATH_HELP: &str = "\ +Print the resolved Stack config file path + +Usage: + stack config path + +Options: + -h, --help Print help + +The path is selected from absolute XDG_CONFIG_HOME first, then absolute HOME. +The config file does not need to exist and is never created. + +Examples: + stack config path +"; +const CONFIG_GET_HELP: &str = "\ +Print an effective Stack configuration value + +Usage: + stack config get + +Arguments: + default_icons_path + +Options: + -h, --help Print help + +The value is resolved from config.yaml or the default icon-store path. No file +is created or changed. + +Examples: + stack config get default_icons_path +"; const UPDATE_HELP: &str = "\ Check for or install a verified direct-install update @@ -298,11 +372,12 @@ Print top-level or subcommand help Usage: stack help stack help + stack help config stack help icons Arguments: - init, check, fmt, render, update, lsp, icons, completions, - manpage, help, or version + init, check, fmt, render, update, lsp, doctor, config, icons, + completions, manpage, help, or version Options: -h, --help Print help @@ -310,6 +385,7 @@ Options: Examples: stack help stack help render + stack help config get stack help icons import "; const VERSION_HELP: &str = "\ @@ -395,6 +471,12 @@ pub fn run( if command == OsStr::new("lsp") { return run_lsp(arguments, stdin, stdout, stderr); } + if command == OsStr::new("doctor") { + return run_doctor(arguments, stdout, stderr); + } + if command == OsStr::new("config") { + return run_config(&mut arguments, stdout, stderr); + } if command == OsStr::new("icons") { return run_icons(&mut arguments, stdout, stderr); } @@ -434,6 +516,9 @@ fn run_help( if command == OsStr::new("icons") { return run_icons_help(&mut arguments, stdout, stderr); } + if command == OsStr::new("config") { + return run_config_help(&mut arguments, stdout, stderr); + } let help = if command == OsStr::new("init") { INIT_HELP @@ -447,6 +532,8 @@ fn run_help( UPDATE_HELP } else if command == OsStr::new("lsp") { LSP_HELP + } else if command == OsStr::new("doctor") { + DOCTOR_HELP } else if command == OsStr::new("completions") { COMPLETIONS_HELP } else if command == OsStr::new("manpage") { @@ -772,6 +859,352 @@ fn run_lsp( ) } +fn run_config( + arguments: &mut dyn Iterator, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let Some(command) = arguments.next() else { + return argument_error("missing command for 'stack config'", stderr); + }; + if is_help_flag(&command) { + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + return write_stdout(CONFIG_HELP, stdout, stderr); + } + if command == OsStr::new("help") { + return run_config_help(arguments, stdout, stderr); + } + if command == OsStr::new("path") { + return run_config_path(arguments, stdout, stderr); + } + if command == OsStr::new("get") { + return run_config_get(arguments, stdout, stderr); + } + unknown_command_error( + "stack config", + &command, + &["path", "get", "help"], + "stack help config", + stderr, + ) +} + +fn run_config_help( + arguments: &mut dyn Iterator, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let Some(command) = arguments.next() else { + return write_stdout(CONFIG_HELP, stdout, stderr); + }; + let help = if command == OsStr::new("path") { + CONFIG_PATH_HELP + } else if command == OsStr::new("get") { + CONFIG_GET_HELP + } else { + return unknown_command_error( + "stack config help", + &command, + &["path", "get"], + "stack help config", + stderr, + ); + }; + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + write_stdout(help, stdout, stderr) +} + +fn run_config_path( + arguments: &mut dyn Iterator, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + if let Some(argument) = arguments.next() { + if is_help_flag(&argument) { + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + return write_stdout(CONFIG_PATH_HELP, stdout, stderr); + } + return argument_error( + &format!("unexpected argument '{}'", argument.to_string_lossy()), + stderr, + ); + } + match config::config_file_path(&config::Environment::capture()) { + Ok((path, _)) => write_stdout(&format!("{}\n", path.display()), stdout, stderr), + Err(error) => write_stderr_error(&error, stderr), + } +} + +fn run_config_get( + arguments: &mut dyn Iterator, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let Some(key) = arguments.next() else { + return argument_error("missing key for 'stack config get'", stderr); + }; + if is_help_flag(&key) { + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + return write_stdout(CONFIG_GET_HELP, stdout, stderr); + } + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + if key != OsStr::new("default_icons_path") { + return argument_error( + &format!( + "unknown config key '{}'; supported keys: default_icons_path", + key.to_string_lossy() + ), + stderr, + ); + } + match config::discover(&config::Environment::capture()) { + Ok(discovery) => write_stdout( + &format!("{}\n", discovery.icon_store_root.display()), + stdout, + stderr, + ), + Err(error) => write_stderr_error(&error, stderr), + } +} + +#[derive(Clone, Copy)] +enum MissingStoreDiagnostic { + Healthy, + Warning, + Error, +} + +fn run_doctor( + mut arguments: impl Iterator, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let first = arguments.next(); + if first + .as_ref() + .is_some_and(|argument| is_help_flag(argument)) + { + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + return write_stdout(DOCTOR_HELP, stdout, stderr); + } + + let mut explicit_provider_pack_root = None; + let mut remaining = first.into_iter().chain(arguments); + while let Some(option) = remaining.next() { + if option == OsStr::new("--provider-pack") { + if explicit_provider_pack_root.is_some() { + return argument_error("duplicate '--provider-pack' option", stderr); + } + let Some(value) = remaining.next() else { + return argument_error("missing provider icon-store directory", stderr); + }; + if value.to_string_lossy().starts_with('-') { + return argument_error("missing provider icon-store directory", stderr); + } + explicit_provider_pack_root = Some(PathBuf::from(value)); + } else { + return argument_error( + &format!("unexpected argument '{}'", option.to_string_lossy()), + stderr, + ); + } + } + + let environment = config::Environment::capture(); + let mut report = String::from("Stack CLI doctor\n\n"); + let _ = writeln!(report, "[ok] version: {}", env!("CARGO_PKG_VERSION")); + let mut problems = 0_usize; + let mut warnings = 0_usize; + let mut discovery = None; + + match config::config_file_path(&environment) { + Ok((path, source)) => { + let _ = writeln!( + report, + "[ok] config path: {} (source: {})", + doctor_path(&path), + source.label() + ); + match config::discover(&environment) { + Ok(resolved) => { + let config_state = match resolved.config_file_state { + config::ConfigFileState::Missing => "missing; defaults apply", + config::ConfigFileState::Loaded => "loaded", + }; + let _ = writeln!(report, "[ok] config file: {config_state}"); + discovery = Some(resolved); + } + Err(error) => { + let _ = writeln!(report, "[error] config file: {}", config_problem(&error)); + problems += 1; + } + } + } + Err(_) => { + report.push_str( + "[error] config path: unavailable; set XDG_CONFIG_HOME or HOME to an absolute path\n", + ); + problems += 1; + } + } + + let effective_store = if let Some(root) = explicit_provider_pack_root { + Some((root, "--provider-pack", MissingStoreDiagnostic::Error)) + } else if let Some(resolved) = &discovery { + let missing_diagnostic = match resolved.icon_store_source { + config::IconStoreSource::ConfigFile => MissingStoreDiagnostic::Warning, + config::IconStoreSource::Default => MissingStoreDiagnostic::Healthy, + }; + Some(( + resolved.icon_store_root.clone(), + resolved.icon_store_source.label(), + missing_diagnostic, + )) + } else { + None + }; + + if let Some((root, source, missing_diagnostic)) = effective_store { + let _ = writeln!( + report, + "[ok] icon store: {} (source: {source})", + doctor_path(&root) + ); + match fs::symlink_metadata(&root) { + Err(error) if error.kind() == io::ErrorKind::NotFound => match missing_diagnostic { + MissingStoreDiagnostic::Healthy => { + report.push_str("[ok] provider packs: store is missing; 0 packs installed\n"); + } + MissingStoreDiagnostic::Warning => { + report.push_str( + "[warn] provider packs: configured store is missing; import a pack or update default_icons_path\n", + ); + warnings += 1; + } + MissingStoreDiagnostic::Error => { + report.push_str( + "[error] provider packs: explicit store is missing; create it or choose another directory\n", + ); + problems += 1; + } + }, + Err(_) => { + report.push_str( + "[error] provider packs: store is unreadable; check directory permissions\n", + ); + problems += 1; + } + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + report.push_str( + "[error] provider packs: store must be a real directory, not a file or symlink\n", + ); + problems += 1; + } + Ok(_) => match load_provider_store(&root, false) { + Ok(packs) => { + let _ = writeln!( + report, + "[ok] provider packs: {} valid known-provider pack{}", + packs.len(), + if packs.len() == 1 { "" } else { "s" } + ); + } + Err(error) => { + let reason = if error.contains("permission denied") { + "store or pack is unreadable; check directory and file permissions" + } else { + "a known-provider pack is invalid; remove and import it again" + }; + let _ = writeln!(report, "[error] provider packs: {reason}"); + problems += 1; + } + }, + } + } else { + report.push_str("[blocked] icon store: unresolved until the config problem is fixed\n"); + report.push_str("[blocked] provider packs: not checked\n"); + } + + report.push('\n'); + if problems > 0 { + let _ = writeln!( + report, + "Result: {problems} problem{} found", + if problems == 1 { "" } else { "s" } + ); + } else if warnings > 0 { + let _ = writeln!( + report, + "Result: healthy with {warnings} warning{}", + if warnings == 1 { "" } else { "s" } + ); + } else { + report.push_str("Result: healthy\n"); + } + + if stdout.write_all(report.as_bytes()).is_err() { + return write_stderr_error("cannot write command output", stderr); + } + if problems > 0 { + EXIT_USAGE_OR_IO + } else { + EXIT_SUCCESS + } +} + +fn doctor_path(path: &Path) -> String { + path.to_string_lossy() + .chars() + .flat_map(char::escape_debug) + .collect() +} + +fn config_problem(error: &str) -> &'static str { + if error.contains("permission denied") { + "permission denied; make config.yaml readable" + } else if error.contains("invalid YAML") { + "invalid YAML; keep only the supported default_icons_path key" + } else if error.contains("absolute path") { + "default_icons_path must be an absolute path" + } else if error.contains("64 KiB") { + "file exceeds the 64 KiB limit" + } else if error.contains("regular file") { + "path must be a regular file, not a directory or symlink" + } else { + "unreadable; inspect config.yaml permissions and file type" + } +} + fn run_icons( arguments: &mut dyn Iterator, stdout: &mut dyn Write, @@ -2088,6 +2521,66 @@ mod tests { OsString::from("--notice"), OsString::from("artifact"), ], + vec![ + OsString::from("doctor"), + OsString::from("--help"), + OsString::from("extra"), + ], + vec![OsString::from("doctor"), OsString::from("--provider-pack")], + vec![ + OsString::from("doctor"), + OsString::from("--provider-pack"), + OsString::from("--help"), + ], + vec![ + OsString::from("doctor"), + OsString::from("--provider-pack"), + OsString::from("first"), + OsString::from("--provider-pack"), + OsString::from("second"), + ], + vec![OsString::from("doctor"), OsString::from("unexpected")], + vec![OsString::from("config")], + vec![OsString::from("config"), OsString::from("unknown")], + vec![ + OsString::from("config"), + OsString::from("--help"), + OsString::from("extra"), + ], + vec![ + OsString::from("config"), + OsString::from("path"), + OsString::from("extra"), + ], + vec![ + OsString::from("config"), + OsString::from("path"), + OsString::from("--help"), + OsString::from("extra"), + ], + vec![OsString::from("config"), OsString::from("get")], + vec![ + OsString::from("config"), + OsString::from("get"), + OsString::from("unknown"), + ], + vec![ + OsString::from("config"), + OsString::from("get"), + OsString::from("default_icons_path"), + OsString::from("extra"), + ], + vec![ + OsString::from("config"), + OsString::from("help"), + OsString::from("unknown"), + ], + vec![ + OsString::from("config"), + OsString::from("help"), + OsString::from("path"), + OsString::from("extra"), + ], vec![OsString::from("icons")], vec![OsString::from("icons"), OsString::from("unknown")], vec![ diff --git a/tests/cli.rs b/tests/cli.rs index 7ff0961..2c7d9de 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -61,6 +61,41 @@ fn stack_in( .output()?) } +fn stack_with_config_environment( + xdg_root: Option<&Path>, + home_root: Option<&Path>, + arguments: impl IntoIterator>, +) -> Result> { + let mut command = Command::new(env!("CARGO_BIN_EXE_stack")); + command + .args(arguments) + .env_remove("XDG_CONFIG_HOME") + .env_remove("HOME"); + if let Some(root) = xdg_root { + command.env("XDG_CONFIG_HOME", root); + } + if let Some(root) = home_root { + command.env("HOME", root); + } + Ok(command.output()?) +} + +fn create_provider_store(root: &Path) -> Result<(), Box> { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/provider-pack"); + let pack = root.join("simple-icons"); + fs::create_dir_all(pack.join("assets"))?; + let manifest = fs::read_to_string(fixture.join("manifest.json"))? + .replace("\"example\"", "\"simple-icons\"") + .replace("example:storage", "simple-icons:storage") + .replace("Example Cloud", "Simple Icons Fixture"); + fs::write(pack.join("manifest.json"), manifest)?; + fs::copy( + fixture.join("assets/storage.svg"), + pack.join("assets/storage.svg"), + )?; + Ok(()) +} + fn stack_with_input( arguments: impl IntoIterator>, input: &[u8], @@ -247,6 +282,50 @@ fn help_snapshots_and_aliases_are_stdout_only() -> Result<(), Box> { ), (&["lsp", "--help"], include_bytes!("snapshots/lsp-help.txt")), (&["help", "lsp"], include_bytes!("snapshots/lsp-help.txt")), + ( + &["doctor", "--help"], + include_bytes!("snapshots/doctor-help.txt"), + ), + ( + &["help", "doctor"], + include_bytes!("snapshots/doctor-help.txt"), + ), + ( + &["config", "--help"], + include_bytes!("snapshots/config-help.txt"), + ), + ( + &["config", "help"], + include_bytes!("snapshots/config-help.txt"), + ), + ( + &["help", "config"], + include_bytes!("snapshots/config-help.txt"), + ), + ( + &["config", "path", "--help"], + include_bytes!("snapshots/config-path-help.txt"), + ), + ( + &["config", "help", "path"], + include_bytes!("snapshots/config-path-help.txt"), + ), + ( + &["help", "config", "path"], + include_bytes!("snapshots/config-path-help.txt"), + ), + ( + &["config", "get", "--help"], + include_bytes!("snapshots/config-get-help.txt"), + ), + ( + &["config", "help", "get"], + include_bytes!("snapshots/config-get-help.txt"), + ), + ( + &["help", "config", "get"], + include_bytes!("snapshots/config-get-help.txt"), + ), ( &["icons", "--help"], include_bytes!("snapshots/icons-help.txt"), @@ -325,6 +404,289 @@ fn help_snapshots_and_aliases_are_stdout_only() -> Result<(), Box> { Ok(()) } +#[test] +fn config_discovery_and_doctor_are_read_only_and_report_sources() -> Result<(), Box> { + let directory = TestDirectory::new("config-discovery")?; + let xdg_root = directory.path.join("xdg"); + let config_path = xdg_root.join("stack/config.yaml"); + let default_store = xdg_root.join("stack/icons"); + + let output = stack_with_config_environment(Some(&xdg_root), None, ["config", "path"])?; + assert_eq!(output.status.code(), Some(0)); + assert_eq!( + output.stdout, + format!("{}\n", config_path.display()).as_bytes() + ); + assert!(output.stderr.is_empty()); + assert!(!xdg_root.exists()); + + let output = stack_with_config_environment( + Some(&xdg_root), + None, + ["config", "get", "default_icons_path"], + )?; + assert_eq!(output.status.code(), Some(0)); + assert_eq!( + output.stdout, + format!("{}\n", default_store.display()).as_bytes() + ); + assert!(output.stderr.is_empty()); + assert!(!xdg_root.exists()); + + let output = stack_with_config_environment(Some(&xdg_root), None, ["doctor"])?; + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let report = String::from_utf8(output.stdout)?; + assert!(report.contains(&format!( + "[ok] config path: {} (source: XDG_CONFIG_HOME)", + config_path.display() + ))); + assert!(report.contains("[ok] config file: missing; defaults apply")); + assert!(report.contains(&format!( + "[ok] icon store: {} (source: default)", + default_store.display() + ))); + assert!(report.contains("[ok] provider packs: store is missing; 0 packs installed")); + assert!(report.ends_with("Result: healthy\n")); + let normalized_report = report + .replace(env!("CARGO_PKG_VERSION"), "") + .replace( + directory.path.to_str().ok_or("non-UTF-8 test path")?, + "", + ); + assert_eq!( + normalized_report.as_bytes(), + include_bytes!("snapshots/doctor-report.txt") + ); + assert!(!xdg_root.exists()); + + let home_root = directory.path.join("home"); + let output = stack_with_config_environment( + Some(Path::new("relative-xdg")), + Some(&home_root), + ["config", "path"], + )?; + assert_eq!(output.status.code(), Some(0)); + assert_eq!( + output.stdout, + format!( + "{}\n", + home_root.join(".config/stack/config.yaml").display() + ) + .as_bytes() + ); + assert!(output.stderr.is_empty()); + assert!(!home_root.exists()); + + fs::create_dir_all(config_path.parent().ok_or("missing config parent")?)?; + let custom_store = directory.path.join("shared-icons"); + create_provider_store(&custom_store)?; + let config = format!("default_icons_path: {}\n", custom_store.display()); + fs::write(&config_path, &config)?; + + let output = stack_with_config_environment( + Some(&xdg_root), + None, + ["config", "get", "default_icons_path"], + )?; + assert_eq!(output.status.code(), Some(0)); + assert_eq!( + output.stdout, + format!("{}\n", custom_store.display()).as_bytes() + ); + assert!(output.stderr.is_empty()); + + let output = stack_with_config_environment(Some(&xdg_root), None, ["doctor"])?; + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let report = String::from_utf8(output.stdout)?; + assert!(report.contains("[ok] config file: loaded")); + assert!(report.contains("(source: config default_icons_path)")); + assert!(report.contains("[ok] provider packs: 1 valid known-provider pack")); + assert!(report.ends_with("Result: healthy\n")); + assert_eq!(fs::read_to_string(&config_path)?, config); + + let output = stack_with_config_environment(None, None, ["doctor"])?; + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report = String::from_utf8(output.stdout)?; + assert!(report.contains("[error] config path: unavailable")); + assert!(report.contains("[blocked] provider packs: not checked")); + Ok(()) +} + +#[test] +fn doctor_distinguishes_warnings_errors_and_redacts_untrusted_values() -> Result<(), Box> +{ + const SECRET: &str = "stack-test-secret-do-not-print"; + + let directory = TestDirectory::new("doctor-diagnostics")?; + let xdg_root = directory.path.join("xdg"); + let stack_root = xdg_root.join("stack"); + let config_path = stack_root.join("config.yaml"); + let missing_store = directory.path.join("configured-but-missing"); + fs::create_dir_all(&stack_root)?; + fs::write( + &config_path, + format!("default_icons_path: {}\n", missing_store.display()), + )?; + + let output = stack_with_config_environment(Some(&xdg_root), None, ["doctor"])?; + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let report = String::from_utf8(output.stdout)?; + assert!(report.contains("[warn] provider packs: configured store is missing")); + assert!(report.ends_with("Result: healthy with 1 warning\n")); + + let output = stack_with_config_environment( + Some(&xdg_root), + None, + [ + "doctor", + "--provider-pack", + missing_store.to_str().ok_or("non-UTF-8 test path")?, + ], + )?; + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report = String::from_utf8(output.stdout)?; + assert!(report.contains("[error] provider packs: explicit store is missing")); + assert!(report.ends_with("Result: 1 problem found\n")); + + let invalid_config = format!("default_icons_path: [\nprivate_token: {SECRET}\n"); + fs::write(&config_path, &invalid_config)?; + let output = stack_with_config_environment( + Some(&xdg_root), + None, + ["config", "get", "default_icons_path"], + )?; + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let diagnostic = String::from_utf8(output.stderr)?; + assert!(diagnostic.contains("invalid YAML")); + assert!(!diagnostic.contains(SECRET)); + + let output = Command::new(env!("CARGO_BIN_EXE_stack")) + .arg("doctor") + .env("XDG_CONFIG_HOME", &xdg_root) + .env("STACK_PRIVATE_TOKEN", SECRET) + .output()?; + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report = String::from_utf8(output.stdout)?; + assert!(report.contains("[error] config file: invalid YAML")); + assert!(report.contains("[blocked] icon store")); + assert!(!report.contains(SECRET)); + assert_eq!(fs::read_to_string(&config_path)?, invalid_config); + + fs::remove_file(&config_path)?; + let invalid_store = directory.path.join("invalid-store"); + fs::create_dir_all(invalid_store.join("aws"))?; + fs::write( + invalid_store.join("aws/manifest.json"), + format!("{{\"private_token\":\"{SECRET}\"}}"), + )?; + let output = stack_with_config_environment( + Some(&xdg_root), + None, + [ + "doctor", + "--provider-pack", + invalid_store.to_str().ok_or("non-UTF-8 test path")?, + ], + )?; + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report = String::from_utf8(output.stdout)?; + assert!(report.contains("[error] provider packs: a known-provider pack is invalid")); + assert!(!report.contains(SECRET)); + + let invalid_store_path = directory.file("provider-store-file", SECRET.as_bytes())?; + let output = stack_with_config_environment( + Some(&xdg_root), + None, + [ + "doctor", + "--provider-pack", + invalid_store_path.to_str().ok_or("non-UTF-8 test path")?, + ], + )?; + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report = String::from_utf8(output.stdout)?; + assert!(report.contains("store must be a real directory, not a file or symlink")); + assert!(!report.contains(SECRET)); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn doctor_reports_permission_failures_without_exposing_file_contents() -> Result<(), Box> +{ + use std::os::unix::fs::PermissionsExt; + use std::os::unix::fs::symlink; + + const SECRET: &str = "permission-test-secret-do-not-print"; + + let directory = TestDirectory::new("doctor-permissions")?; + let xdg_root = directory.path.join("xdg"); + let stack_root = xdg_root.join("stack"); + let config_path = stack_root.join("config.yaml"); + fs::create_dir_all(&stack_root)?; + fs::write(&config_path, format!("private_token: {SECRET}\n"))?; + fs::set_permissions(&config_path, fs::Permissions::from_mode(0o000))?; + + if fs::read(&config_path).is_err() { + let output = stack_with_config_environment(Some(&xdg_root), None, ["doctor"])?; + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report = String::from_utf8(output.stdout)?; + assert!(report.contains("[error] config file: permission denied")); + assert!(!report.contains(SECRET)); + } + fs::set_permissions(&config_path, fs::Permissions::from_mode(0o600))?; + fs::remove_file(&config_path)?; + + let provider_store = directory.path.join("provider-store"); + create_provider_store(&provider_store)?; + let manifest = provider_store.join("simple-icons/manifest.json"); + fs::set_permissions(&manifest, fs::Permissions::from_mode(0o000))?; + if fs::read(&manifest).is_err() { + let output = stack_with_config_environment( + Some(&xdg_root), + None, + [ + "doctor", + "--provider-pack", + provider_store.to_str().ok_or("non-UTF-8 test path")?, + ], + )?; + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report = String::from_utf8(output.stdout)?; + assert!(report.contains("store or pack is unreadable")); + assert!(!report.contains(SECRET)); + } + fs::set_permissions(&manifest, fs::Permissions::from_mode(0o600))?; + + let provider_store_link = directory.path.join("provider-store-link"); + symlink(&provider_store, &provider_store_link)?; + let output = stack_with_config_environment( + Some(&xdg_root), + None, + [ + "doctor", + "--provider-pack", + provider_store_link.to_str().ok_or("non-UTF-8 test path")?, + ], + )?; + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report = String::from_utf8(output.stdout)?; + assert!(report.contains("store must be a real directory, not a file or symlink")); + Ok(()) +} + #[test] fn generated_shell_and_manual_assets_are_exact_and_usable() -> Result<(), Box> { for (shell, expected) in [ diff --git a/tests/snapshots/config-get-help.txt b/tests/snapshots/config-get-help.txt new file mode 100644 index 0000000..d0073a6 --- /dev/null +++ b/tests/snapshots/config-get-help.txt @@ -0,0 +1,16 @@ +Print an effective Stack configuration value + +Usage: + stack config get + +Arguments: + default_icons_path + +Options: + -h, --help Print help + +The value is resolved from config.yaml or the default icon-store path. No file +is created or changed. + +Examples: + stack config get default_icons_path diff --git a/tests/snapshots/config-help.txt b/tests/snapshots/config-help.txt new file mode 100644 index 0000000..6b03c7c --- /dev/null +++ b/tests/snapshots/config-help.txt @@ -0,0 +1,16 @@ +Inspect effective read-only Stack configuration + +Usage: + stack config + +Commands: + path Print the resolved config.yaml path without requiring the file to exist + get Print one effective configuration value + help Print this message or the help of a config subcommand + +Options: + -h, --help Print help + +Examples: + stack config path + stack config get default_icons_path diff --git a/tests/snapshots/config-path-help.txt b/tests/snapshots/config-path-help.txt new file mode 100644 index 0000000..4a067d6 --- /dev/null +++ b/tests/snapshots/config-path-help.txt @@ -0,0 +1,13 @@ +Print the resolved Stack config file path + +Usage: + stack config path + +Options: + -h, --help Print help + +The path is selected from absolute XDG_CONFIG_HOME first, then absolute HOME. +The config file does not need to exist and is never created. + +Examples: + stack config path diff --git a/tests/snapshots/doctor-help.txt b/tests/snapshots/doctor-help.txt new file mode 100644 index 0000000..dc7a08f --- /dev/null +++ b/tests/snapshots/doctor-help.txt @@ -0,0 +1,17 @@ +Diagnose Stack CLI configuration and provider icon packs + +Usage: + stack doctor [--provider-pack ] + +Options: + --provider-pack Diagnose this icon-store root instead of the effective default + -h, --help Print help + +Checks: + CLI version, config path and source, config validity, effective icon-store + source, and installed known-provider packs. The report never prints config + contents or unrelated environment variables. + +Examples: + stack doctor + stack doctor --provider-pack .stack-icons diff --git a/tests/snapshots/doctor-report.txt b/tests/snapshots/doctor-report.txt new file mode 100644 index 0000000..7a5cdc6 --- /dev/null +++ b/tests/snapshots/doctor-report.txt @@ -0,0 +1,9 @@ +Stack CLI doctor + +[ok] version: +[ok] config path: /xdg/stack/config.yaml (source: XDG_CONFIG_HOME) +[ok] config file: missing; defaults apply +[ok] icon store: /xdg/stack/icons (source: default) +[ok] provider packs: store is missing; 0 packs installed + +Result: healthy diff --git a/tests/snapshots/help-help.txt b/tests/snapshots/help-help.txt index b56ffc3..2caeca0 100644 --- a/tests/snapshots/help-help.txt +++ b/tests/snapshots/help-help.txt @@ -3,11 +3,12 @@ Print top-level or subcommand help Usage: stack help stack help + stack help config stack help icons Arguments: - init, check, fmt, render, update, lsp, icons, completions, - manpage, help, or version + init, check, fmt, render, update, lsp, doctor, config, icons, + completions, manpage, help, or version Options: -h, --help Print help @@ -15,4 +16,5 @@ Options: Examples: stack help stack help render + stack help config get stack help icons import diff --git a/tests/snapshots/help.txt b/tests/snapshots/help.txt index b664e11..7b66456 100644 --- a/tests/snapshots/help.txt +++ b/tests/snapshots/help.txt @@ -11,6 +11,8 @@ Commands: render Render standalone SVG to standard output or a file update Check for or install a verified direct-install update lsp Run the Stack language server over standard input and output + doctor Diagnose CLI configuration and provider icon packs + config Inspect effective read-only configuration icons List catalogs and import audited provider icon archives completions Generate bash, zsh, or fish completion source manpage Print the offline Stack CLI manual page @@ -29,6 +31,8 @@ Examples: stack render arch.stack -o arch.svg stack update --check stack lsp + stack doctor + stack config get default_icons_path stack icons list aws s3 stack completions zsh > _stack stack manpage > stack.1