Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -36,7 +36,7 @@
},
"dependencies": {
"axios": "^1.18.0",
"incur": "0.3.25"
"incur": "0.4.26"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
Expand Down
34 changes: 19 additions & 15 deletions patches/incur.patch → patches/incur@0.4.26.patch
Original file line number Diff line number Diff line change
@@ -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))
Expand All @@ -21,23 +12,23 @@ 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 = {}) {
parts.push(`<${key}>`);
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;
Expand All @@ -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
34 changes: 17 additions & 17 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 38 additions & 12 deletions test/helpers/liveApi.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
/**
* 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';

// 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<void> | 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<void> {
const apiKey = process.env.FORMO_API_KEY;
if (!apiKey) {
probeStatus = 'unauthorized';
probeStatus = 'absent';
return;
}

Expand All @@ -29,11 +43,13 @@ async function probe(): Promise<void> {
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) {
Expand All @@ -58,5 +74,15 @@ export async function requiresLiveApi(ctx: Context): Promise<void> {
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();
}