From 9a70dcb659c7fdfb16293e5ed5b88003683ecb37 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 04:54:20 +0000 Subject: [PATCH] feat: Release completions as a tarball and validate loaded scripts Release the shell completion loaders as one seam-completions-v.tar.gz asset instead of three bare files, and source the seam-bin AUR package from the tarball. The versioned name also removes the need to rename the sources in the PKGBUILD. Harden the loaders: evaluate the output of 'seam completion' only when it starts with the exact first line the script generator produces. A seam without the completion command prints other things, e.g., 'Not logged in. Please run "seam login"', which the fish loader piped into source and fish reported as 'Unknown command: Not'. Nothing the CLI prints other than a completion script is ever evaluated now, and a test pins the sentinel lines to the generator output so they cannot drift apart. Document what each shell needs before installed completions appear: bash reads them via the bash-completion package, and zsh caches its completion functions in ~/.zcompdump, which frameworks calling compinit -C never rebuild on their own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016eYkGybhEJJE3FkaqXdwLv --- .github/workflows/_build.yml | 6 ++-- .github/workflows/publish.yml | 14 ++++----- README.md | 21 ++++++++++---- src/lib/completion/completion.test.ts | 14 +++++++++ src/lib/completion/index.ts | 42 ++++++++++++++++++++++----- 5 files changed, 75 insertions(+), 22 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 2e0f66c8..a061017d 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -48,10 +48,12 @@ jobs: done - name: Add shell completion loaders # Generated by prepack during npm pack. - run: cp completions/seam.bash completions/seam.fish completions/seam.zsh release/ + run: | + version=$(jq --raw-output '.version' package.json) + tar -czf "release/seam-completions-v${version}.tar.gz" -C completions seam.bash seam.fish seam.zsh - name: Generate checksums working-directory: release - run: sha256sum seam* > checksums.txt + run: sha256sum seam-* > checksums.txt - name: Upload artifact uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0fa456c2..8b25d438 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,7 +43,7 @@ jobs: prerelease: ${{ contains(github.ref_name, '-') }} files: | *.tgz - release/seam* + release/seam-* release/checksums.txt body_path: ${{ github.workspace }}/${{ steps.changelog.outputs.outfile }} npm: @@ -113,20 +113,18 @@ jobs: conflicts=('seam') options=('!strip' '!debug') source=("\${url}/raw/v\${pkgver}/LICENSE.txt" - "seam-\${pkgver}.bash::\${url}/releases/download/v\${pkgver}/seam.bash" - "seam-\${pkgver}.fish::\${url}/releases/download/v\${pkgver}/seam.fish" - "seam-\${pkgver}.zsh::\${url}/releases/download/v\${pkgver}/seam.zsh") + "\${url}/releases/download/v\${pkgver}/seam-completions-v\${pkgver}.tar.gz") source_x86_64=("\${pkgname}-\${pkgver}-x86_64::\${url}/releases/download/v\${pkgver}/seam-v\${pkgver}-linux-x64") source_aarch64=("\${pkgname}-\${pkgver}-aarch64::\${url}/releases/download/v\${pkgver}/seam-v\${pkgver}-linux-arm64") - sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP') + sha256sums=('SKIP' 'SKIP') sha256sums_x86_64=('SKIP') sha256sums_aarch64=('SKIP') package() { install -Dm755 "\${pkgname}-\${pkgver}-\${CARCH}" "\${pkgdir}/usr/bin/seam" - install -Dm644 "seam-\${pkgver}.bash" "\${pkgdir}/usr/share/bash-completion/completions/seam" - install -Dm644 "seam-\${pkgver}.fish" "\${pkgdir}/usr/share/fish/vendor_completions.d/seam.fish" - install -Dm644 "seam-\${pkgver}.zsh" "\${pkgdir}/usr/share/zsh/site-functions/_seam" + install -Dm644 seam.bash "\${pkgdir}/usr/share/bash-completion/completions/seam" + install -Dm644 seam.fish "\${pkgdir}/usr/share/fish/vendor_completions.d/seam.fish" + install -Dm644 seam.zsh "\${pkgdir}/usr/share/zsh/site-functions/_seam" install -Dm644 LICENSE.txt "\${pkgdir}/usr/share/licenses/\${pkgname}/LICENSE" } PKGBUILD diff --git a/README.md b/README.md index a096e884..64520119 100644 --- a/README.md +++ b/README.md @@ -202,17 +202,28 @@ seam completion zsh > "${fpath[1]}/_seam" ``` System packages install completion loaders instead: small scripts packaged -under `completions/` in the published package and attached to each -[GitHub release]. A loader runs `seam completion` the first time the shell -completes a seam command, so installed completions always match the CLI's -current Seam API definitions and never go stale between package updates. The -`seam-bin` AUR package installs the loaders for all three shells. +under `completions/` in the published package, and released as +`seam-completions-v.tar.gz` on each [GitHub release]. A loader runs +`seam completion` the first time the shell completes a seam command, so +installed completions always match the CLI's current Seam API definitions and +never go stale between package updates. The `seam-bin` AUR package installs +the loaders for all three shells. Completions are generated from the cached Seam API definitions, so they may briefly lag a newly released API. Pass `--update` to refresh the cache first, e.g., `seam completion bash --update`. They do not reflect definitions served by another Seam API server when `seam config use-remote-api-defs` is enabled. +If completions do not appear after installing them system wide: + +- Bash reads them via the [bash-completion] package, + so it must be installed and sourced by the shell. +- Zsh caches the completion functions it found at startup: after installing, + rebuild the cache with `rm -f ~/.zcompdump*` and start a new shell. + This applies to frameworks that call `compinit -C`, e.g., oh-my-zsh. +- Fish needs nothing extra: completions load on demand in new sessions. + +[bash-completion]: https://github.com/scop/bash-completion [GitHub release]: https://github.com/seamapi/cli/releases/latest ## Development and Testing diff --git a/src/lib/completion/completion.test.ts b/src/lib/completion/completion.test.ts index f02df7d0..f35cd478 100644 --- a/src/lib/completion/completion.test.ts +++ b/src/lib/completion/completion.test.ts @@ -3,6 +3,7 @@ import { expect, test } from 'vitest' import { testBlueprint } from '../../../test/fixtures/blueprint.js' import { describeForShell } from './describe.js' import { + completionScriptSentinels, completionShells, isCompletionShell, renderCompletion, @@ -76,6 +77,19 @@ test.each(completionShells)( }, ) +test.each(completionShells)( + '%s completion stub: evaluates only what the script generator produces', + (shell) => { + const sentinel = completionScriptSentinels[shell] + // The stub requires the sentinel, and the generated script provides it + // as its exact first line, so the two cannot drift apart. + expect(renderCompletionStub(shell)).toContain(sentinel) + expect( + renderCompletion(shell, testBlueprint).startsWith(`${sentinel}\n`), + ).toBe(true) + }, +) + test('zsh completion stub: is an autoloadable completion function', () => { expect(renderCompletionStub('zsh').startsWith('#compdef seam\n')).toBe(true) }) diff --git a/src/lib/completion/index.ts b/src/lib/completion/index.ts index 43bc3c70..171a49fd 100644 --- a/src/lib/completion/index.ts +++ b/src/lib/completion/index.ts @@ -40,11 +40,24 @@ export const renderCompletion = ( * at first completion, never at shell startup. * * The loader degrades to no completions when the seam command is missing or - * cannot produce a script, e.g., offline before the definitions are cached. + * does not produce a completion script: a script is only evaluated when it + * starts with the exact first line 'seam completion' generates, so nothing + * else the CLI may print, e.g., 'Not logged in' from a version without the + * completion command, is ever evaluated as shell code. */ export const renderCompletionStub = (shell: CompletionShell): string => stubs[shell] +/** + * First line of each generated completion script, which the loaders require + * before evaluating one. Must match the output of {@link renderCompletion}. + */ +export const completionScriptSentinels: Record = { + bash: '# bash completion for the seam command.', + fish: '# fish completion for the seam command.', + zsh: '#compdef seam', +} + const stubHeader = (shell: CompletionShell): string => `# ${shell} completion loader for the seam command. # @@ -58,7 +71,12 @@ const stubs: Record = { # Install to /usr/share/bash-completion/completions/seam if command -v seam > /dev/null 2>&1; then - eval "$(seam completion bash 2> /dev/null)" + __seam_completion_script="$(seam completion bash 2> /dev/null)" + # Evaluate only a completion script, never anything else the CLI printed. + case "$__seam_completion_script" in + '${completionScriptSentinels.bash}'*) eval "$__seam_completion_script" ;; + esac + unset __seam_completion_script fi `, fish: `${stubHeader('fish')} @@ -66,7 +84,11 @@ fi # Install to /usr/share/fish/vendor_completions.d/seam.fish if command --query seam - seam completion fish 2> /dev/null | source + set -l __seam_completion_script (seam completion fish 2> /dev/null | string collect) + # Source only a completion script, never anything else the CLI printed. + if string match --quiet '${completionScriptSentinels.fish}*' -- $__seam_completion_script + printf '%s\\n' $__seam_completion_script | source + end end `, zsh: `#compdef seam @@ -74,9 +96,15 @@ ${stubHeader('zsh')} # # Install to a directory in fpath as _seam -# The generated script ends by dispatching on funcstack, so evaluating it -# while this autoloaded _seam runs both redefines _seam and completes the -# in-flight request. -eval "$(seam completion zsh 2> /dev/null)" +local __seam_completion_script +__seam_completion_script="$(seam completion zsh 2> /dev/null)" + +# Evaluate only a completion script, never anything else the CLI printed. +# The script ends by dispatching on funcstack, so evaluating it while this +# autoloaded _seam runs both redefines _seam and completes the in-flight +# request. +if [[ "$__seam_completion_script" == '${completionScriptSentinels.zsh}'* ]]; then + eval "$__seam_completion_script" +fi `, }