From 246ae0bd9698ee4268ea0c780c4bb21b39ad59b9 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Fri, 5 Jun 2026 17:09:55 +0200 Subject: [PATCH 1/3] feat(mcp): auth status --output json; trim mcp serve flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth status now emits a structured JSON object under --output json (profile, keychain, token state, user) — useful for scripts and as a structured MCP tool result. - mcp serve drops the output-shaping global flags it never uses, keeping --profile and --allow-writes. --- src/commands/auth/status.js | 85 ++++++++++++++++++------------- src/commands/mcp/serve.js | 5 +- test/commands/auth/status.test.js | 28 ++++++++++ 3 files changed, 83 insertions(+), 35 deletions(-) diff --git a/src/commands/auth/status.js b/src/commands/auth/status.js index b00e735..afbc12d 100644 --- a/src/commands/auth/status.js +++ b/src/commands/auth/status.js @@ -14,15 +14,42 @@ export default class StatusCommand extends BaseCommand { } async run() { - await this.parse(StatusCommand) + const { flags } = await this.parse(StatusCommand) const tokens = await getTokens(this.activeProfile) - const keychainType = isKeychainAvailable() ? 'OS keychain' : 'unavailable' + const status = { + profile: this.activeProfile, + keychain: isKeychainAvailable() ? 'OS keychain' : 'unavailable', + authenticated: Boolean(tokens), + } + + if (tokens) { + status.authMode = tokens.authMode + status.credentialSource = tokens.credentialSource + const now = Date.now() + const expiresAt = tokens.expiresAt + if (expiresAt <= now) { + status.token = { state: 'expired', expiresAt } + } else { + status.token = { + state: 'valid', + expiresAt, + expiresInMs: expiresAt - now, + } + const user = await this.#fetchUser(tokens.accessToken) + if (user) status.user = user + } + } + + if (flags.output === 'json') { + this.log(JSON.stringify(status, null, 2)) + return + } this.log(chalk.bold('Auth Status')) this.log('') - this.log(` Profile: ${chalk.cyan(this.activeProfile)}`) - this.log(` Keychain: ${keychainType}`) + this.log(` Profile: ${chalk.cyan(status.profile)}`) + this.log(` Keychain: ${status.keychain}`) if (!tokens) { this.log(` Status: ${chalk.red('Not authenticated')}`) @@ -31,55 +58,45 @@ export default class StatusCommand extends BaseCommand { return } - this.log(` Auth mode: ${tokens.authMode}`) - this.log(` Credential: ${tokens.credentialSource}`) + this.log(` Auth mode: ${status.authMode}`) + this.log(` Credential: ${status.credentialSource}`) - const now = Date.now() - const expiresAt = tokens.expiresAt - - if (expiresAt <= now) { + if (status.token.state === 'expired') { this.log(` Token: ${chalk.red('Expired')}`) } else { - const remaining = expiresAt - now - const humanExpiry = formatDuration(remaining) this.log( - ` Token: ${chalk.green('Valid')} (expires in ${humanExpiry})`, + ` Token: ${chalk.green('Valid')} (expires in ${formatDuration(status.token.expiresInMs)})`, ) } - // Try fetching user info if token is still valid - if (expiresAt > now) { - await this.#showUserInfo(tokens.accessToken) + if (status.user) { + this.log('') + this.log(chalk.bold(' Authenticated User')) + if (status.user.name) this.log(` Name: ${status.user.name}`) + if (status.user.email) this.log(` Email: ${status.user.email}`) } } /** - * Fetch and display the authenticated user's identity. + * Fetch the authenticated user's identity (best-effort). * @param {string} accessToken + * @returns {Promise<{name?: string, email?: string} | null>} */ - async #showUserInfo(accessToken) { + async #fetchUser(accessToken) { try { const res = await fetch('https://api.helpscout.net/v2/users/me', { headers: { authorization: `Bearer ${accessToken}` }, }) - - if (!res.ok) return - + if (!res.ok) return null const data = await res.json() - this.log('') - this.log(chalk.bold(' Authenticated User')) - - if (data.firstName || data.lastName) { - this.log( - ` Name: ${[data.firstName, data.lastName].filter(Boolean).join(' ')}`, - ) - } - - if (data.email) { - this.log(` Email: ${data.email}`) - } + const user = {} + const name = [data.firstName, data.lastName].filter(Boolean).join(' ') + if (name) user.name = name + if (data.email) user.email = data.email + return user } catch { - // Silently ignore network errors — user info is best-effort + // Network errors are non-fatal — user info is best-effort. + return null } } } diff --git a/src/commands/mcp/serve.js b/src/commands/mcp/serve.js index a4da845..2dd8da8 100644 --- a/src/commands/mcp/serve.js +++ b/src/commands/mcp/serve.js @@ -49,8 +49,11 @@ export default class MCPServeCommand extends BaseCommand { '<%= config.bin %> mcp serve --allow-writes', ] + // A long-running server has no use for the output-shaping global flags; keep + // only --profile (which auth profile the tools run under). + static baseFlags = { profile: BaseCommand.baseFlags.profile } + static flags = { - ...BaseCommand.baseFlags, 'allow-writes': Flags.boolean({ description: 'Expose mutating tools (create/update/delete/bulk). Off by default — read-only.', diff --git a/test/commands/auth/status.test.js b/test/commands/auth/status.test.js index d5ece8a..eb48c3c 100644 --- a/test/commands/auth/status.test.js +++ b/test/commands/auth/status.test.js @@ -68,6 +68,34 @@ describe('hs auth status', () => { expect(scope.isDone()).toBe(true) }) + it('emits structured JSON with --output json', async () => { + mockGetTokens.mockResolvedValue({ + accessToken: 'test-token', + refreshToken: 'test-refresh', + expiresAt: Date.now() + 86400000, + authMode: 'authorization_code', + credentialSource: 'byo', + }) + const scope = nock('https://api.helpscout.net') + .get('/v2/users/me') + .reply(200, userFixture) + + const out = JSON.parse(await runCmd(StatusCommand, ['--output', 'json'])) + + expect(out.profile).toBe('default') + expect(out.authenticated).toBe(true) + expect(out.token.state).toBe('valid') + expect(out.user.email).toBe('jane@example.com') + expect(scope.isDone()).toBe(true) + }) + + it('emits JSON for an unauthenticated profile', async () => { + mockGetTokens.mockResolvedValue(null) + const out = JSON.parse(await runCmd(StatusCommand, ['--output', 'json'])) + expect(out.authenticated).toBe(false) + expect(out.token).toBeUndefined() + }) + it('shows not authenticated when no tokens exist', async () => { mockGetTokens.mockResolvedValue(null) From 45368f8791540d3e05e62ded42ebcb954e315dfe Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Fri, 5 Jun 2026 17:09:55 +0200 Subject: [PATCH 2/3] feat(dist): Docker image (GHCR) + Homebrew/Scoop generators - Dockerfile (installs from npm) + release-workflow job pushing ghcr.io/wavyx/hscli: and :latest after the npm publish. - scripts/gen-dist.mjs renders the Homebrew formula + Scoop manifest from the published tarball (sha256); outputs live in the tap/bucket repos. - Document brew/scoop/docker install methods (README + installation guide). --- .dockerignore | 2 + .github/workflows/release.yml | 27 ++++++ .gitignore | 4 + .prettierignore | 4 + Dockerfile | 18 ++++ README.md | 8 +- packaging/README.md | 30 ++++++ scripts/gen-dist.mjs | 96 +++++++++++++++++++ test/gen-dist.test.js | 36 +++++++ .../src/content/docs/guides/installation.mdx | 28 ++++++ 10 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 packaging/README.md create mode 100644 scripts/gen-dist.mjs create mode 100644 test/gen-dist.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9e125bf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +# The image installs hscli from npm, so the build needs no repo files. +* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd04b3d..73888d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,7 @@ on: permissions: contents: write # create the GitHub Release id-token: write # OIDC: npm trusted publishing + provenance (no NPM_TOKEN) + packages: write # push the Docker image to GHCR jobs: release: @@ -61,3 +62,29 @@ jobs: gh release create "$GITHUB_REF_NAME" \ --title "$GITHUB_REF_NAME" \ --notes "${NOTES:-Release $GITHUB_REF_NAME}" + + docker: + name: Publish Docker image + needs: release # build from the just-published npm version + runs-on: ubuntu-latest + steps: + - name: Resolve version + id: ver + run: echo "v=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + build-args: HSCLI_VERSION=${{ steps.ver.outputs.v }} + tags: | + ghcr.io/${{ github.repository }}:${{ steps.ver.outputs.v }} + ghcr.io/${{ github.repository }}:latest diff --git a/.gitignore b/.gitignore index 8a613e8..10a6a09 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ docs/superpowers/ website/node_modules/ website/dist/ website/.astro/ + +# Generated dist artifacts (live in the tap/bucket repos) +packaging/homebrew/hscli.rb +packaging/scoop/hscli.json diff --git a/.prettierignore b/.prettierignore index 0bf28e5..418308f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,7 @@ package-lock.json website/ # generated by scripts/gen-commands.mjs docs/commands.md + +# Dockerfiles (no prettier parser) +Dockerfile +.dockerignore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1b9b66 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# Run hscli without a local Node install: +# docker run --rm -e HSCLI_DOCS_API_KEY ghcr.io/wavyx/hscli docs site list +# +# The image installs the published npm package, so build it after publishing +# (the release workflow passes the released version via HSCLI_VERSION). +FROM node:20-slim + +ARG HSCLI_VERSION=latest + +LABEL org.opencontainers.image.source="https://github.com/wavyx/hscli" +LABEL org.opencontainers.image.description="Command-line interface for Help Scout" +LABEL org.opencontainers.image.licenses="MIT" + +RUN npm install -g "@wavyx/hscli@${HSCLI_VERSION}" \ + && npm cache clean --force + +ENTRYPOINT ["hscli"] +CMD ["--help"] diff --git a/README.md b/README.md index 9b36c14..1f252c5 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,14 @@ Codex, and similar) — no SDK glue required. ## Install ```bash -npm install -g @wavyx/hscli +npm install -g @wavyx/hscli # npm (Node.js 20+) +brew tap wavyx/tap && brew install hscli # Homebrew (macOS/Linux) +scoop bucket add hscli https://github.com/wavyx/scoop-hscli && scoop install hscli # Scoop (Windows) +docker run --rm ghcr.io/wavyx/hscli --help # Docker (no local Node) ``` -Requires Node.js 20+. The binary is `hscli`. +The binary is `hscli`. The Docker image has no OS keychain, so use it for Docs +(`HSCLI_DOCS_API_KEY`), `api`, and stateless utilities rather than Mailbox OAuth. > **Credential storage:** hscli stores OAuth tokens only in your operating system > keychain (macOS Keychain, Windows Credential Manager, or libsecret on Linux). diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..0c5033b --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,30 @@ +# Packaging + +hscli ships through several channels. npm is the source of truth; the others +are generated from the published tarball. + +| Channel | Install | Source | +| -------- | ------------------------------------------------------------------------------------ | ------------------------------ | +| npm | `npm install -g @wavyx/hscli` | this repo (`npm publish`) | +| Docker | `docker run --rm ghcr.io/wavyx/hscli --help` | `Dockerfile` (release.yml) | +| Homebrew | `brew tap wavyx/tap && brew install hscli` | `wavyx/homebrew-tap` (formula) | +| Scoop | `scoop bucket add hscli https://github.com/wavyx/scoop-hscli && scoop install hscli` | `wavyx/scoop-hscli` (manifest) | + +## Updating Homebrew + Scoop on release + +After a version is published to npm: + +```bash +node scripts/gen-dist.mjs # e.g. 0.11.0 +``` + +This downloads the npm tarball, computes its sha256, and writes: + +- `packaging/homebrew/hscli.rb` → commit to `wavyx/homebrew-tap` as `Formula/hscli.rb` +- `packaging/scoop/hscli.json` → commit to `wavyx/scoop-hscli` as `bucket/hscli.json` + +Both generated files are git-ignored here — they live in their own repos. + +> Auto-bumping these on every release would need a cross-repo token (PAT); for +> now it's a one-line manual step. The Docker image and npm publish are fully +> automated in `.github/workflows/release.yml`. diff --git a/scripts/gen-dist.mjs b/scripts/gen-dist.mjs new file mode 100644 index 0000000..d03cb74 --- /dev/null +++ b/scripts/gen-dist.mjs @@ -0,0 +1,96 @@ +// Regenerate the Homebrew formula + Scoop manifest for a published version. +// +// node scripts/gen-dist.mjs +// +// Writes packaging/homebrew/hscli.rb and packaging/scoop/hscli.json (both +// git-ignored — they live in the wavyx/homebrew-tap and wavyx/scoop-hscli +// repos). See packaging/README.md. Run AFTER the version is on npm. +import { writeFileSync, mkdirSync } from 'node:fs' +import { createHash } from 'node:crypto' + +const PKG = '@wavyx/hscli' + +/** Render the Homebrew formula (standard node-CLI pattern). */ +export function renderHomebrewFormula({ version, url, sha256 }) { + return `class Hscli < Formula + desc "Command-line interface for Help Scout" + homepage "https://github.com/wavyx/hscli" + url "${url}" + sha256 "${sha256}" + version "${version}" + license "MIT" + + depends_on "node" + + def install + system "npm", "install", *Language::Node.std_npm_install_args(libexec) + bin.install_symlink Dir["#{libexec}/bin/*"] + end + + test do + assert_match "hscli", shell_output("#{bin}/hscli version") + end +end +` +} + +/** Render the Scoop manifest (installs via npm; needs Node). */ +export function renderScoopManifest({ version }) { + return ( + JSON.stringify( + { + version, + description: 'Command-line interface for Help Scout', + homepage: 'https://github.com/wavyx/hscli', + license: 'MIT', + depends: 'nodejs', + installer: { script: [`npm install -g ${PKG}@${version}`] }, + uninstaller: { script: [`npm uninstall -g ${PKG}`] }, + checkver: { + url: `https://registry.npmjs.org/${PKG}`, + jsonpath: "$.['dist-tags'].latest", + }, + autoupdate: { version: '$version' }, + }, + null, + 2, + ) + '\n' + ) +} + +/** Fetch the npm tarball URL + its sha256 for a version. */ +export async function fetchDist(version, fetchFn = fetch) { + const meta = await fetchFn( + `https://registry.npmjs.org/${PKG}/${version}`, + ).then((r) => r.json()) + const url = meta.dist.tarball + const buf = Buffer.from(await fetchFn(url).then((r) => r.arrayBuffer())) + const sha256 = createHash('sha256').update(buf).digest('hex') + return { version, url, sha256 } +} + +if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { + const version = process.argv[2] + if (!version) { + console.error('usage: node scripts/gen-dist.mjs ') + process.exit(1) + } + const dist = await fetchDist(version) + mkdirSync(new URL('../packaging/homebrew/', import.meta.url), { + recursive: true, + }) + mkdirSync(new URL('../packaging/scoop/', import.meta.url), { + recursive: true, + }) + writeFileSync( + new URL('../packaging/homebrew/hscli.rb', import.meta.url), + renderHomebrewFormula(dist), + ) + writeFileSync( + new URL('../packaging/scoop/hscli.json', import.meta.url), + renderScoopManifest(dist), + ) + console.log( + `Wrote packaging/homebrew/hscli.rb + packaging/scoop/hscli.json for ${version} (sha256 ${dist.sha256})`, + ) +} diff --git a/test/gen-dist.test.js b/test/gen-dist.test.js new file mode 100644 index 0000000..af52989 --- /dev/null +++ b/test/gen-dist.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { + renderHomebrewFormula, + renderScoopManifest, +} from '../scripts/gen-dist.mjs' + +describe('renderHomebrewFormula', () => { + const formula = renderHomebrewFormula({ + version: '0.11.0', + url: 'https://registry.npmjs.org/@wavyx/hscli/-/hscli-0.11.0.tgz', + sha256: 'deadbeef', + }) + + it('pins the tarball url, sha256, and node dependency', () => { + expect(formula).toContain('class Hscli < Formula') + expect(formula).toContain( + 'url "https://registry.npmjs.org/@wavyx/hscli/-/hscli-0.11.0.tgz"', + ) + expect(formula).toContain('sha256 "deadbeef"') + expect(formula).toContain('depends_on "node"') + expect(formula).toContain('hscli version') + }) +}) + +describe('renderScoopManifest', () => { + const manifest = JSON.parse(renderScoopManifest({ version: '0.11.0' })) + + it('installs from npm and depends on nodejs', () => { + expect(manifest.version).toBe('0.11.0') + expect(manifest.depends).toBe('nodejs') + expect(manifest.installer.script.join(' ')).toContain( + 'npm install -g @wavyx/hscli@0.11.0', + ) + expect(manifest.uninstaller.script.join(' ')).toContain('npm uninstall -g') + }) +}) diff --git a/website/src/content/docs/guides/installation.mdx b/website/src/content/docs/guides/installation.mdx index f40a7d1..30f86c4 100644 --- a/website/src/content/docs/guides/installation.mdx +++ b/website/src/content/docs/guides/installation.mdx @@ -14,6 +14,34 @@ npm install -g @wavyx/hscli Requires **Node.js 20 or newer**. The installed command is `hscli`. ::: +## Other ways to install + +**Homebrew** (macOS / Linux): + +```bash frame="terminal" +brew tap wavyx/tap +brew install hscli +``` + +**Scoop** (Windows): + +```bash frame="terminal" +scoop bucket add hscli https://github.com/wavyx/scoop-hscli +scoop install hscli +``` + +**Docker** — run without a local Node install: + +```bash frame="terminal" +docker run --rm -e HSCLI_DOCS_API_KEY ghcr.io/wavyx/hscli docs site list +``` + +:::note +The container has no OS keychain, so `hscli auth login` can't store Mailbox +OAuth tokens there. Use the image for Docs (`HSCLI_DOCS_API_KEY`), the `api` +escape hatch, and stateless utilities like `beacon sign`. +::: + ## Verify ```bash frame="terminal" title="verify" From d1a5c294fcb5ce3005af4a33e4014fa812a74c0b Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Fri, 5 Jun 2026 17:09:55 +0200 Subject: [PATCH 3/3] chore(release): 0.11.0 --- CHANGELOG.md | 15 +++++++++++++++ docs/commands.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- website/src/content/docs/reference/commands.mdx | 2 +- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4815778..dfce231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.11.0] - 2026-06-05 + +### Added + +- **Distribution beyond npm:** + - **Docker** — `docker run --rm ghcr.io/wavyx/hscli --help`. Built and pushed to GHCR by the release workflow. + - **Homebrew** — `brew tap wavyx/tap && brew install hscli` (macOS/Linux). + - **Scoop** — `scoop bucket add hscli https://github.com/wavyx/scoop-hscli && scoop install hscli` (Windows). + - npm stays the source of truth; `scripts/gen-dist.mjs` regenerates the Homebrew formula + Scoop manifest from the published tarball (with its sha256). +- `auth status --output json` emits a structured status object (profile, keychain, token state, user) — handy for scripts and as a structured MCP tool result. + +### Changed + +- MCP: `mcp serve` no longer advertises the output-shaping global flags (`--output`/`--jq`/`--fields`/`--timeout`/`--no-retry`/`--verbose`/`--no-color`) it never uses; it keeps `--profile` and `--allow-writes`. + ## [0.10.2] - 2026-06-05 ### Added diff --git a/docs/commands.md b/docs/commands.md index 743d73d..cdb216b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,7 +5,7 @@ description: Full command reference for the hscli command-line interface. -Reference for `hscli` v0.10.2 (89 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--jq`, `--fields`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. +Reference for `hscli` v0.11.0 (89 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--jq`, `--fields`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. ## Top-level diff --git a/package-lock.json b/package-lock.json index 37cbfd5..93817be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wavyx/hscli", - "version": "0.10.2", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wavyx/hscli", - "version": "0.10.2", + "version": "0.11.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "8.5.2", diff --git a/package.json b/package.json index b43a2c8..74ec075 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wavyx/hscli", - "version": "0.10.2", + "version": "0.11.0", "publishConfig": { "access": "public" }, diff --git a/website/src/content/docs/reference/commands.mdx b/website/src/content/docs/reference/commands.mdx index 167f827..7dbc147 100644 --- a/website/src/content/docs/reference/commands.mdx +++ b/website/src/content/docs/reference/commands.mdx @@ -12,7 +12,7 @@ hscli [target] [flags] ``` Run `hscli --help` for the live, self-describing version of any command. -This page lists all 89 commands in `hscli` v0.10.2. +This page lists all 89 commands in `hscli` v0.11.0. ## alias