diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36e74ff..ef0632a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,3 +82,54 @@ jobs: run: pnpm test env: TEST_TOKEN: ${{ secrets.TEST_TOKEN }} + + # Installs the packed tarball the way a user does — with npm, resolving + # dependency ranges fresh instead of replaying pnpm-lock.yaml. Every other + # job installs from the lockfile, which pins transitive versions and so + # cannot see a consumer-facing resolution break. That gap shipped a CLI + # that crashed on startup for anyone running `npx @formo/cli`: incur asked + # for `^2.0.0-alpha.2` of @modelcontextprotocol/server, the 2.0.0 stable + # dropped an export it imports, and no job installed in a way that noticed. + smoke: + runs-on: blacksmith-4vcpu-ubuntu-2404 + needs: [build, lint] + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + + - name: Setup Node + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "24.15.0" + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build CLI + run: pnpm build + + - name: Pack the tarball + run: npm pack --pack-destination "$RUNNER_TEMP" + + - name: Install it with npm, as a user would + working-directory: ${{ runner.temp }} + run: | + mkdir -p smoke && cd smoke + npm init -y > /dev/null + npm install "$RUNNER_TEMP"/formo-cli-*.tgz + + - name: The binary must start and report its version + working-directory: ${{ runner.temp }}/smoke + run: | + version=$(./node_modules/.bin/formo --version) + echo "formo --version -> $version" + expected=$(node -p "require('./node_modules/@formo/cli/package.json').version") + test "$version" = "$expected" + + - name: Help must render for a representative command + working-directory: ${{ runner.temp }}/smoke + run: ./node_modules/.bin/formo profiles search --help > /dev/null diff --git a/package.json b/package.json index cb1c505..a6749b9 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "@formo/cli", - "version": "1.2.0", + "version": "1.2.1", "packageManager": "pnpm@11.1.2", "engines": { "node": ">=22.12" }, - "description": "Formo API CLI — query profiles and analytics data", + "description": "Formo API CLI \u2014 query profiles and analytics data", "license": "MIT", "repository": { "type": "git", @@ -36,7 +36,7 @@ }, "dependencies": { "axios": "^1.18.0", - "incur": "0.3.25" + "incur": "0.4.26" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/patches/incur.patch b/patches/incur@0.4.26.patch similarity index 72% rename from patches/incur.patch rename to patches/incur@0.4.26.patch index ea3a385..a662434 100644 --- a/patches/incur.patch +++ b/patches/incur@0.4.26.patch @@ -1,17 +1,8 @@ diff --git a/dist/Cli.js b/dist/Cli.js -index 1467ccf7a6eb6c2bf1e7a5172631032ea3c342e7..fc5913148ee2ae0423d90d30b276390129a222c6 100644 +index 4b80e74d959f6508ea4de6cc79e372f111b2b6b6..5afa03b80fc5bd998b9e7d0568084b616d71bf51 100644 --- a/dist/Cli.js +++ b/dist/Cli.js -@@ -1951,7 +1951,7 @@ function formatCta(name, cta) { - cmd += value === true ? ` <${key}>` : ` ${value}`; - if (cta.options) - for (const [key, value] of Object.entries(cta.options)) -- cmd += value === true ? ` --${key} <${key}>` : ` --${key} ${value}`; -+ cmd += value === true ? ` --${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} <${key}>` : ` --${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} ${value}`; - return { command: cmd, ...(cta.description ? { description: cta.description } : undefined) }; - } - /** @internal Builds the `--llms` index manifest (name + description only) from the command tree. */ -@@ -2113,7 +2113,7 @@ export function formatExamples(examples) { +@@ -2723,7 +2723,7 @@ export function formatExamples(examples) { parts.push(String(value)); if (ex.options) for (const [key, value] of Object.entries(ex.options)) @@ -21,7 +12,7 @@ index 1467ccf7a6eb6c2bf1e7a5172631032ea3c342e7..fc5913148ee2ae0423d90d30b2763901 if (ex.description) result.description = ex.description; diff --git a/dist/Help.js b/dist/Help.js -index a0545f3eda9b12d8e33a46c02941926e72dab995..ea3caac698c27daa05d219addb202c66428337cd 100644 +index 3cc2b2f3bf6f86d1be94fd71696c77a1e34c5697..c5fcbaf1191fa75d60156927c571b873523dd250 100644 --- a/dist/Help.js +++ b/dist/Help.js @@ -52,7 +52,7 @@ export function formatCommand(name, options = {}) { @@ -29,15 +20,15 @@ index a0545f3eda9b12d8e33a46c02941926e72dab995..ea3caac698c27daa05d219addb202c66 if (u.options) for (const key of Object.keys(u.options)) - parts.push(`--${key} <${key}>`); -+ parts.push(`--${toKebab(key)} <${key}>`); ++ parts.push(`--${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} <${key}>`); if (u.suffix) parts.push(u.suffix); return parts.join(' '); diff --git a/dist/Skill.js b/dist/Skill.js -index d5b5bfb74d2b6f20c4a089e2315fe7f52181a3bd..1295e5a46e5b1f323e855d5c062feb4b95aca765 100644 +index 97c991bf7fc368d45a8b7530dba4134835b1e7f5..8cc8f91d2580170a1860d7e73d505526aa3f4a3a 100644 --- a/dist/Skill.js +++ b/dist/Skill.js -@@ -143,7 +143,7 @@ function renderCommandBody(cli, cmd, level = 1) { +@@ -148,7 +148,7 @@ function renderCommandBody(cli, cmd, level = 1) { const def = prop?.default !== undefined ? String(prop.default) : ''; const rawDesc = field.description ?? ''; const desc = prop?.deprecated ? `**Deprecated.** ${rawDesc}` : rawDesc; @@ -46,3 +37,16 @@ index d5b5bfb74d2b6f20c4a089e2315fe7f52181a3bd..1295e5a46e5b1f323e855d5c062feb4b }); sections.push(`${sub} Options\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n${rows.join('\n')}`); } +diff --git a/dist/internal/cta.js b/dist/internal/cta.js +index 0ac7926ecf3796cc86636cc5740c398a8a8a38c7..9680b1ceae8a3bdff2f7e7c5233f8686479897bb 100644 +--- a/dist/internal/cta.js ++++ b/dist/internal/cta.js +@@ -26,7 +26,7 @@ function formatCta(name, cta) { + cmd += value === true ? ` <${key}>` : ` ${value}`; + if (cta.options) + for (const [key, value] of Object.entries(cta.options)) +- cmd += value === true ? ` --${key} <${key}>` : ` --${key} ${value}`; ++ cmd += value === true ? ` --${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} <${key}>` : ` --${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} ${value}`; + return { command: cmd, ...(cta.description ? { description: cta.description } : undefined) }; + } + //# sourceMappingURL=cta.js.map diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fd5a9b..ee5a7d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ overrides: serialize-javascript: '>=7.0.5' patchedDependencies: - incur: 59ec45aa48f7686d1061ad0b42aaf3a47e84080dfce7d2baf3c763a1b2d214fc + incur@0.4.26: ae18879675f35e842cb43134909e7abd7d02f4268e1d733be25fd99a79bb78d1 importers: @@ -18,8 +18,8 @@ importers: specifier: ^1.18.0 version: 1.18.0 incur: - specifier: 0.3.25 - version: 0.3.25(patch_hash=59ec45aa48f7686d1061ad0b42aaf3a47e84080dfce7d2baf3c763a1b2d214fc) + specifier: 0.4.26 + version: 0.4.26(patch_hash=ae18879675f35e842cb43134909e7abd7d02f4268e1d733be25fd99a79bb78d1) devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -278,19 +278,18 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@modelcontextprotocol/server@2.0.0-alpha.2': - resolution: {integrity: sha512-gmLgdHzlYM8L7Aw/+VE0kxjT25WKamtUSLNhdOgrJq5CrESvqVSoAfWSJJeNPUXNTluQ+dYDGFbKVitdsJtbPA==} + '@modelcontextprotocol/server@2.0.0-alpha.4': + resolution: {integrity: sha512-/KEo3ZJ50HlagHp0lz2vPgfBZFtXHu6zTBXT9XqPc+4O9i4+IbBVWKq/a9yAfv/ifp0u3+mRo8SUk5kMKzhN8A==} engines: {node: '>=20'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@scalar/openapi-types@0.8.0': + resolution: {integrity: sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g==} + engines: {node: '>=22'} + '@toon-format/toon@2.1.0': resolution: {integrity: sha512-JwWptdF5eOA0HaQxbKAzkpQtR4wSWTEfDlEy/y3/4okmOAX1qwnpLZMmtEWr+ncAhTTY1raCKH0kteHhSXnQqg==} @@ -718,8 +717,8 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - incur@0.3.25: - resolution: {integrity: sha512-jrSkzauM42ilbQJ6THVkAY6dTulkyVW0sZpVHdA8gfiBwrLrLnLUf8U3bAOegAKBIMSOFgk1idchgu9xm9HMng==} + incur@0.4.26: + resolution: {integrity: sha512-63lwOc8+o1VeFkIW3sWWMrDSP5Dd1NsvucUWyxLUk+DoNRiNl7cj481nlLdlboXkq+tqYPBSlV/7XbylS2S4BA==} engines: {node: '>=22'} hasBin: true @@ -1149,15 +1148,15 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@modelcontextprotocol/server@2.0.0-alpha.2(@cfworker/json-schema@4.1.1)': + '@modelcontextprotocol/server@2.0.0-alpha.4': dependencies: zod: 4.3.6 - optionalDependencies: - '@cfworker/json-schema': 4.1.1 '@pkgjs/parseargs@0.11.0': optional: true + '@scalar/openapi-types@0.8.0': {} + '@toon-format/toon@2.1.0': {} '@types/chai@5.2.3': @@ -1625,10 +1624,11 @@ snapshots: imurmurhash@0.1.4: {} - incur@0.3.25(patch_hash=59ec45aa48f7686d1061ad0b42aaf3a47e84080dfce7d2baf3c763a1b2d214fc): + incur@0.4.26(patch_hash=ae18879675f35e842cb43134909e7abd7d02f4268e1d733be25fd99a79bb78d1): dependencies: '@cfworker/json-schema': 4.1.1 - '@modelcontextprotocol/server': 2.0.0-alpha.2(@cfworker/json-schema@4.1.1) + '@modelcontextprotocol/server': 2.0.0-alpha.4 + '@scalar/openapi-types': 0.8.0 '@toon-format/toon': 2.1.0 tokenx: 1.3.0 yaml: 2.8.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 08b263a..200351c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,9 +7,11 @@ overrides: allowBuilds: esbuild: true -# Kebab-cases camelCase flag names in incur's help/skill/example output. +# Kebab-cases camelCase flag names in incur's help/skill/example/CTA output. # incur is pinned to an exact version in package.json while this patch is # applied — upstream is converging on the same fix, so re-check (and ideally -# drop the patch) on every incur bump. +# drop the patch) on every incur bump. As of 0.4.26 the option *definitions* +# render kebab-case upstream but examples, usage lines, CTAs and skill tables +# still emit the raw camelCase key, so all four sites remain patched. patchedDependencies: - incur: patches/incur.patch + incur@0.4.26: patches/incur@0.4.26.patch diff --git a/test/helpers/liveApi.ts b/test/helpers/liveApi.ts index fa8a155..4e7f5d6 100644 --- a/test/helpers/liveApi.ts +++ b/test/helpers/liveApi.ts @@ -1,10 +1,18 @@ /** - * Probes the live API once with the configured key. If it returns 401, - * integration tests in this run are skipped with a clear message rather - * than each test individually failing on auth. + * Probes the live API once with the configured key, then decides whether the + * integration tests in this run can proceed. + * + * The distinction that matters: + * + * - **No TEST_TOKEN at all** — skip. Fork PRs cannot read repo secrets, and + * failing them would make every outside contribution red. + * - **TEST_TOKEN present but rejected** — FAIL. A skip here is indistinguishable + * from a healthy run, and that is exactly how an expired credential hid for a + * week while CI reported green over the whole canonical-filter migration. + * - **Host unreachable** — skip. Network flakiness is not a code defect. * * Tests that hit the network call `requiresLiveApi(this)` in a `before` - * (or directly in the test) to opt into the skip. + * (or directly in the test) to opt in. */ import type { Context } from 'mocha'; import { getApiBaseUrl } from '../../src/lib/client'; @@ -12,13 +20,19 @@ import { getApiBaseUrl } from '../../src/lib/client'; // Honor FORMO_API_BASE_URL so the probe hits the same host the client uses. const API_BASE_URL = getApiBaseUrl(); -let probeStatus: 'unknown' | 'ok' | 'unauthorized' | 'unreachable' = 'unknown'; +type ProbeStatus = 'unknown' | 'ok' | 'absent' | 'rejected' | 'unreachable'; + +let probeStatus: ProbeStatus = 'unknown'; let probePromise: Promise | undefined; +// preload.cjs substitutes a dummy key when TEST_TOKEN is unset, so the token's +// own presence — not FORMO_API_KEY — tells us whether a credential was supplied. +const tokenWasSupplied = () => Boolean(process.env.TEST_TOKEN); + async function probe(): Promise { const apiKey = process.env.FORMO_API_KEY; if (!apiKey) { - probeStatus = 'unauthorized'; + probeStatus = 'absent'; return; } @@ -29,11 +43,13 @@ async function probe(): Promise { body: JSON.stringify({ apiKey }), }); if (res.status === 401 || res.status === 403) { - probeStatus = 'unauthorized'; - process.stderr.write( - `\n ⚠ Integration tests skipped: TEST_TOKEN was rejected by ${API_BASE_URL} (HTTP ${res.status}).\n` + - ` Refresh the FORMO test API key and update the TEST_TOKEN secret.\n\n`, - ); + probeStatus = tokenWasSupplied() ? 'rejected' : 'absent'; + if (probeStatus === 'rejected') { + process.stderr.write( + `\n ✖ TEST_TOKEN was rejected by ${API_BASE_URL} (HTTP ${res.status}).\n` + + ` Refresh the FORMO test API key and update the TEST_TOKEN secret.\n\n`, + ); + } return; } if (!res.ok) { @@ -58,5 +74,15 @@ export async function requiresLiveApi(ctx: Context): Promise { if (!probePromise) probePromise = probe(); await probePromise; } - if (probeStatus !== 'ok') ctx.skip(); + if (probeStatus === 'ok') return; + // A supplied-but-rejected credential is a broken setup, not an absent one — + // surface it as a failure so the suite can never be green while silently + // covering nothing. + if (probeStatus === 'rejected') { + throw new Error( + `TEST_TOKEN was rejected by ${API_BASE_URL}. The live-API tests cannot run. ` + + 'Mint a read-scoped Formo API key and update both .env and the TEST_TOKEN repo secret.', + ); + } + ctx.skip(); }