From 645187f95363ed8d9e70c6aa6bb6eb7ae1f48901 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Wed, 4 Nov 2026 14:11:10 -0800 Subject: [PATCH 1/3] feat: authenticate with LFC_API_KEY for non-interactive use --- README.md | 2 ++ src/commands/auth.ts | 17 ++++++++++++++++- src/lib/api.ts | 17 +++++++++++++++-- src/lib/auth.ts | 14 ++++++++++++++ src/lib/doctor.ts | 10 ++++++++++ tests/auth.test.ts | 30 ++++++++++++++++++++++++++++-- 6 files changed, 85 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index eb58d88..88e5020 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ lfc --profile local builds list # one-off profile override Config lives at `~/.config/lifecycle-cli/config.json`. Environment overrides: `LFC_PROFILE`, `LIFECYCLE_API_URL`, `LFC_JSON=1`, `LFC_CONFIG_DIR`. +For CI and other non-interactive use, create an API key in the Lifecycle UI (Settings → API keys) and export it as `LFC_API_KEY` (an `lfc_svc_…` or `lfc_pat_…` key). It takes precedence over any cached SSO login and needs no `lfc login` or config file when `LIFECYCLE_API_URL` is also set. The key's scopes apply: `lfc builds env set` needs `env:write`. `lfc whoami` reports the key's principal and scopes. + The CLI reports anonymous usage telemetry to your own deployment's API (command and flag names, duration, and outcome — never arguments, values, error messages, or identity). Set `LFC_TELEMETRY_DISABLED=1` to turn it off. ## Builds (preview environments) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 4290d4a..63961ae 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -2,7 +2,7 @@ import * as clack from '@clack/prompts'; import { Command } from 'commander'; import pc from 'picocolors'; -import { AuthError, decodeJwt, endSession, loginDevice, loginPkce } from '../lib/auth.js'; +import { apiKeyFromEnv, AuthError, decodeJwt, endSession, loginDevice, loginPkce } from '../lib/auth.js'; import { clearTokens, loadTokens, saveTokens } from '../lib/config.js'; import { runAction, type Ctx } from '../lib/context.js'; import { link, printJson } from '../lib/output.js'; @@ -79,6 +79,21 @@ export function registerAuthCommands(program: Command): void { .description('Show the logged-in user and token details') .action( runAction(async ctx => { + if (apiKeyFromEnv()) { + const info = { profile: ctx.profileName, apiUrl: ctx.api.baseUrl, ...(await ctx.api.getAuthContext()) }; + if (ctx.json) printJson(info); + else + process.stdout.write( + [ + ` principal ${info.principal.kind} ${info.principal.id}`, + ` auth ${info.authMethod} (LFC_API_KEY)`, + ` scopes ${info.scopes?.join(', ') ?? 'unscoped'}`, + ` repos ${info.repositories.mode === 'all' ? 'all' : info.repositories.repositoryNames.join(', ')}`, + ` api ${info.apiUrl}`, + ].join('\n') + '\n', + ); + return; + } if (!ctx.profile.authEnabled) { if (ctx.json) printJson({ authEnabled: false, profile: ctx.profileName, apiUrl: ctx.api.baseUrl }); else process.stdout.write(`Auth is disabled for profile "${ctx.profileName}" (API: ${ctx.api.baseUrl})\n`); diff --git a/src/lib/api.ts b/src/lib/api.ts index 2e81d67..a54f85e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -import { getAccessToken } from './auth.js'; +import { apiKeyFromEnv, getAccessToken } from './auth.js'; import type { Profile } from './config.js'; import type { ListSitesParams, RenameSiteBody } from './generated/index.js'; import { matchBuilds, type Selector } from './resolve.js'; @@ -29,6 +29,14 @@ export class ApiError extends Error { } } +export interface AuthContext { + principal: { kind: 'user' | 'personal_key' | 'service_key'; id: string }; + authMethod: 'session' | 'api_key'; + scopes: string[] | null; + repositories: { mode: 'all' | 'selected'; repositoryIds: number[]; repositoryNames: string[] }; + tokenId: number | null; +} + export interface ListResult { items: T[]; pagination?: PaginationMetadata; @@ -77,7 +85,7 @@ export class ApiClient { const res = await fetch(url, { method, headers, body }); // a 401 with a token that looked valid usually means clock skew or revocation — force one refresh - if (res.status === 401 && token && !retried) { + if (res.status === 401 && token && !retried && !apiKeyFromEnv()) { return this.request(method, path, opts, true); } @@ -152,6 +160,11 @@ export class ApiClient { return { matches, truncated: false }; } + async getAuthContext(): Promise { + const env = await this.request('GET', '/api/v2/auth/context'); + return env.data as AuthContext; + } + async getBuild(uuid: string): Promise { const env = await this.request('GET', `/api/v2/builds/${encodeURIComponent(uuid)}`); return env.data as Build; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 409fede..b00e9e2 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -7,6 +7,18 @@ import { loadTokens, saveTokens, type KeycloakSettings, type Profile, type Token export class AuthError extends Error {} +const API_KEY_PATTERN = /^lfc_(pat_|svc_)?[a-f0-9]{40}$/; + +/** CI credential: LFC_API_KEY takes precedence over any cached SSO login and needs no profile or `lfc login`. */ +export function apiKeyFromEnv(): string | null { + const key = process.env.LFC_API_KEY?.trim(); + if (!key) return null; + if (!API_KEY_PATTERN.test(key)) { + throw new AuthError('LFC_API_KEY is not a valid Lifecycle API key (expected lfc_pat_… or lfc_svc_…)'); + } + return key; +} + interface OidcDiscovery { authorization_endpoint: string; token_endpoint: string; @@ -253,6 +265,8 @@ export async function getAccessToken( profile: Profile, { forceRefresh = false }: { forceRefresh?: boolean } = {}, ): Promise { + const apiKey = apiKeyFromEnv(); + if (apiKey) return apiKey; if (!profile.authEnabled) return null; if (!profile.keycloak) throw new AuthError('Profile has authEnabled but no keycloak settings'); const tokens = loadTokens(profileName); diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index 47abf10..f57cae2 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -185,6 +185,16 @@ function configChecks(): DoctorCheck[] { }); } + if (process.env.LFC_API_KEY?.trim()) { + checks.push({ + id: 'token_presence', + label: 'token', + status: 'ok', + message: 'LFC_API_KEY is set; it overrides cached SSO tokens', + }); + return checks; + } + if (active.authEnabled) { const tokens = loadTokens(config.currentProfile); if (!tokens) { diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 5440b97..3213d11 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -1,8 +1,8 @@ import { createHash } from 'node:crypto'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; -import { decodeJwt, pkcePair } from '../src/lib/auth.js'; +import { apiKeyFromEnv, AuthError, decodeJwt, getAccessToken, pkcePair } from '../src/lib/auth.js'; describe('pkcePair', () => { it('generates an S256 challenge for the verifier', () => { @@ -28,3 +28,29 @@ describe('decodeJwt', () => { expect(() => decodeJwt('garbage')).toThrow(); }); }); + +describe('LFC_API_KEY', () => { + const key = `lfc_svc_${'a'.repeat(40)}`; + const profile = { apiUrl: 'https://lfc.example', authEnabled: false } as const; + + afterEach(() => { + delete process.env.LFC_API_KEY; + }); + + it('is returned as the access token without a profile login', async () => { + process.env.LFC_API_KEY = ` ${key}\n`; + expect(apiKeyFromEnv()).toBe(key); + await expect(getAccessToken('nope', { ...profile })).resolves.toBe(key); + }); + + it('is absent when unset or blank', () => { + expect(apiKeyFromEnv()).toBeNull(); + process.env.LFC_API_KEY = ' '; + expect(apiKeyFromEnv()).toBeNull(); + }); + + it('rejects a malformed key', () => { + process.env.LFC_API_KEY = 'not-a-key'; + expect(() => apiKeyFromEnv()).toThrow(AuthError); + }); +}); From d339427db5d3ca3f379c295e03ae186f16f85a63 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Wed, 4 Nov 2026 14:13:19 -0800 Subject: [PATCH 2/3] fix: use LFC_API_KEY for telemetry, validate it in doctor, warn on logout --- src/commands/auth.ts | 5 +++++ src/lib/doctor.ts | 19 ++++++++++++------- src/lib/telemetry.ts | 8 +++++++- tests/auth.test.ts | 12 +++++++++++- tests/doctor.test.ts | 16 ++++++++++++++++ tests/telemetry.test.ts | 20 ++++++++++++++++++++ 6 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 63961ae..7babcbe 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -66,6 +66,11 @@ export function registerAuthCommands(program: Command): void { const tokens = loadTokens(ctx.profileName); if (tokens && ctx.profile.keycloak) await endSession(ctx.profile.keycloak, tokens); const removed = clearTokens(ctx.profileName); + if (process.env.LFC_API_KEY?.trim()) { + process.stderr.write( + `${pc.yellow('!')} LFC_API_KEY is still set; commands will keep authenticating with it\n`, + ); + } if (ctx.json) printJson({ loggedOut: removed, profile: ctx.profileName }); else process.stderr.write( diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index f57cae2..4389b66 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { decodeJwt } from './auth.js'; +import { apiKeyFromEnv, decodeJwt } from './auth.js'; import { configDir, configPath, @@ -186,12 +186,17 @@ function configChecks(): DoctorCheck[] { } if (process.env.LFC_API_KEY?.trim()) { - checks.push({ - id: 'token_presence', - label: 'token', - status: 'ok', - message: 'LFC_API_KEY is set; it overrides cached SSO tokens', - }); + try { + apiKeyFromEnv(); + checks.push({ + id: 'token_presence', + label: 'token', + status: 'ok', + message: 'LFC_API_KEY is set; cached SSO tokens are ignored', + }); + } catch (err) { + checks.push({ id: 'token_presence', label: 'token', status: 'error', message: (err as Error).message }); + } return checks; } diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index f971a71..8f0ca73 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -4,6 +4,7 @@ import type { Command } from 'commander'; import pkg from '../../package.json' with { type: 'json' }; +import { apiKeyFromEnv } from './auth.js'; import { loadConfig, loadTokens, saveConfig } from './config.js'; import type { Ctx } from './context.js'; import type { CreateTelemetryEventBody } from './generated/index.js'; @@ -84,7 +85,12 @@ export async function reportInvocation( // On auth-required deployments, only report when a valid token is already cached. // Refreshing here could hit Keycloak with no timeout, escaping the fetch bound below. let token: string | null = null; - if (ctx.profile.authEnabled) { + try { + token = apiKeyFromEnv(); + } catch { + return; + } + if (!token && ctx.profile.authEnabled) { token = cachedAccessToken(ctx); if (!token) return; } diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 3213d11..8f059b9 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { apiKeyFromEnv, AuthError, decodeJwt, getAccessToken, pkcePair } from '../src/lib/auth.js'; @@ -33,10 +33,20 @@ describe('LFC_API_KEY', () => { const key = `lfc_svc_${'a'.repeat(40)}`; const profile = { apiUrl: 'https://lfc.example', authEnabled: false } as const; + beforeEach(() => { + delete process.env.LFC_API_KEY; + }); + afterEach(() => { delete process.env.LFC_API_KEY; }); + it('takes precedence over cached SSO tokens', async () => { + process.env.LFC_API_KEY = key; + const sso = { ...profile, authEnabled: true, keycloak: { issuer: 'https://kc.example', clientId: 'cli' } }; + await expect(getAccessToken('default', sso)).resolves.toBe(key); + }); + it('is returned as the access token without a profile login', async () => { process.env.LFC_API_KEY = ` ${key}\n`; expect(apiKeyFromEnv()).toBe(key); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 1423315..57c8c12 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -21,11 +21,27 @@ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lfc-doctor-')); process.env.LFC_CONFIG_DIR = dir; delete process.env.LFC_PROFILE; + delete process.env.LFC_API_KEY; }); afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); delete process.env.LFC_CONFIG_DIR; + delete process.env.LFC_API_KEY; +}); + +describe('runDoctor with LFC_API_KEY', () => { + it('accepts a well-formed key and flags a malformed one', () => { + saveConfig({ + currentProfile: 'default', + profiles: { default: { apiUrl: 'https://lifecycle.example.com', authEnabled: false } }, + }); + process.env.LFC_API_KEY = `lfc_svc_${'a'.repeat(40)}`; + expect(runDoctor().checks.find(c => c.id === 'token_presence')?.status).toBe('ok'); + + process.env.LFC_API_KEY = 'Bearer lfc_oops'; + expect(runDoctor().checks.find(c => c.id === 'token_presence')?.status).toBe('error'); + }); }); describe('runDoctor', () => { diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index 1836c74..5749935 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -21,12 +21,14 @@ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lfc-telemetry-test-')); process.env.LFC_CONFIG_DIR = dir; delete process.env.LFC_TELEMETRY_DISABLED; + delete process.env.LFC_API_KEY; }); afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); delete process.env.LFC_CONFIG_DIR; delete process.env.LFC_TELEMETRY_DISABLED; + delete process.env.LFC_API_KEY; vi.unstubAllGlobals(); }); @@ -211,6 +213,24 @@ describe('reportInvocation', () => { expect((init.headers as Record).Authorization).toBe('Bearer cached-token'); }); + it('prefers LFC_API_KEY over a cached SSO token', async () => { + const key = `lfc_svc_${'a'.repeat(40)}`; + process.env.LFC_API_KEY = key; + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 201 })); + vi.stubGlobal('fetch', fetchMock); + saveTokens('default', { accessToken: 'cached-token', expiresAt: Date.now() + 60_000 }); + const cmd = await parsedCommand(['builds', 'list']); + const ctx = fakeCtx({ + authEnabled: true, + keycloak: { issuer: 'https://kc.example.com/realms/lifecycle', clientId: 'lifecycle-cli' }, + }); + + await reportInvocation(ctx, cmd, 10, { status: 'success', exitCode: 0 }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).Authorization).toBe(`Bearer ${key}`); + }); + it('skips silently when the only cached token is expired (never refreshes)', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); From 7696ad5cc60e13339aed88bc04dab01682dea4ca Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Wed, 4 Nov 2026 14:18:43 -0800 Subject: [PATCH 3/3] fix: exit 4 when LFC_API_KEY is rejected or empty, document it in llms --- src/commands/llms.ts | 8 ++++++-- src/lib/auth.ts | 6 ++++-- src/lib/context.ts | 5 ++++- tests/api.test.ts | 21 +++++++++++++++++++++ tests/auth.test.ts | 29 +++++++++++++++++++++++------ tests/telemetry.test.ts | 28 +++++++++++++++++++++++++--- 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/src/commands/llms.ts b/src/commands/llms.ts index af6ec72..b261c0e 100644 --- a/src/commands/llms.ts +++ b/src/commands/llms.ts @@ -40,9 +40,11 @@ and gets public URLs. The lfc CLI lets you: 1 = failure (API error, failed build, invalid schema) 2 = watch timeout (--watch did not converge in time) 3 = not found (unknown uuid, no config file) - 4 = authentication error (not logged in, expired session) + 4 = authentication error (not logged in, expired session, rejected LFC_API_KEY) 4. On exit code 4, run "lfc login" (or "lfc login --device" when there is no browser), then retry once. If it still fails, surface the error to the user. + When LFC_API_KEY is set, exit 4 means the key is missing, malformed, or + rejected: logging in will not help, so report it to the user instead. 5. Destructive commands (builds destroy, sites delete) require --yes when stdin is not a TTY. Confirm with the user before tearing anything down. 6. "lfc --help" works on every command and subcommand and lists all flags. @@ -79,7 +81,8 @@ profiles (one per deployment): lfc --profile builds list # one-off profile override Environment overrides: LFC_PROFILE (profile name), LIFECYCLE_API_URL (API base -URL), LFC_JSON=1 (global JSON output), LFC_CONFIG_DIR (config location). +URL), LFC_JSON=1 (global JSON output), LFC_CONFIG_DIR (config location), +LFC_API_KEY (lfc_pat_/lfc_svc_ API key for CI; overrides any cached SSO login). ## Authentication @@ -88,6 +91,7 @@ Deployments with SSO use Keycloak (Authorization Code + PKCE, or device code): lfc login # opens a browser for SSO lfc login --device # headless: prints a verification URL and a short code lfc whoami # logged-in user, roles, token expiry — best first diagnostic + # (with LFC_API_KEY set: the key's principal and scopes) lfc logout For --device (and "lfc init --device"), relay the printed URL and code to the user diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b00e9e2..bdc30dd 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -11,8 +11,10 @@ const API_KEY_PATTERN = /^lfc_(pat_|svc_)?[a-f0-9]{40}$/; /** CI credential: LFC_API_KEY takes precedence over any cached SSO login and needs no profile or `lfc login`. */ export function apiKeyFromEnv(): string | null { - const key = process.env.LFC_API_KEY?.trim(); - if (!key) return null; + const raw = process.env.LFC_API_KEY; + if (raw === undefined) return null; + const key = raw.trim(); + if (!key) throw new AuthError('LFC_API_KEY is set but empty (is the CI secret missing?)'); if (!API_KEY_PATTERN.test(key)) { throw new AuthError('LFC_API_KEY is not a valid Lifecycle API key (expected lfc_pat_… or lfc_svc_…)'); } diff --git a/src/lib/context.ts b/src/lib/context.ts index 06f19d5..3676814 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -55,7 +55,10 @@ export function runAction( } else if (err instanceof ApiError) { const reqId = err.requestId ? pc.dim(` (request_id: ${err.requestId})`) : ''; process.stderr.write(`${pc.red(`api error (${err.status}):`)} ${err.message}${reqId}\n`); - const exitCode = err.status === 404 ? 3 : 1; + const keyRejected = err.status === 401 && Boolean(process.env.LFC_API_KEY?.trim()); + if (keyRejected) + process.stderr.write(`${pc.dim('LFC_API_KEY was rejected: it may be invalid, expired, or revoked.')}\n`); + const exitCode = keyRejected ? 4 : err.status === 404 ? 3 : 1; process.exitCode = exitCode; outcome = { status: 'error', diff --git a/tests/api.test.ts b/tests/api.test.ts index 49f5261..a761992 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -125,3 +125,24 @@ describe('ApiClient webhooks', () => { expect(result).toEqual({ status: 'success' }); }); }); + +describe('ApiClient with LFC_API_KEY', () => { + const key = `lfc_svc_${'a'.repeat(40)}`; + + afterEach(() => { + delete process.env.LFC_API_KEY; + vi.unstubAllGlobals(); + }); + + it('sends the key as a Bearer token and does not retry a 401', async () => { + process.env.LFC_API_KEY = key; + const fetchMock = vi.fn().mockResolvedValue(new Response('{"error":{"message":"nope"}}', { status: 401 })); + vi.stubGlobal('fetch', fetchMock); + + await expect(new ApiClient('default', PROFILE).getBuild('abc')).rejects.toMatchObject({ status: 401 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect((init.headers as Record).Authorization).toBe(`Bearer ${key}`); + }); +}); diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 8f059b9..4b23fb4 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -1,8 +1,12 @@ import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { apiKeyFromEnv, AuthError, decodeJwt, getAccessToken, pkcePair } from '../src/lib/auth.js'; +import { saveTokens } from '../src/lib/config.js'; describe('pkcePair', () => { it('generates an S256 challenge for the verifier', () => { @@ -42,9 +46,24 @@ describe('LFC_API_KEY', () => { }); it('takes precedence over cached SSO tokens', async () => { - process.env.LFC_API_KEY = key; - const sso = { ...profile, authEnabled: true, keycloak: { issuer: 'https://kc.example', clientId: 'cli' } }; - await expect(getAccessToken('default', sso)).resolves.toBe(key); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lfc-auth-')); + process.env.LFC_CONFIG_DIR = dir; + try { + saveTokens('default', { accessToken: 'sso-token', expiresAt: Date.now() + 3_600_000 }); + const sso = { ...profile, authEnabled: true, keycloak: { issuer: 'https://kc.example', clientId: 'cli' } }; + await expect(getAccessToken('default', sso)).resolves.toBe('sso-token'); + + process.env.LFC_API_KEY = key; + await expect(getAccessToken('default', sso)).resolves.toBe(key); + } finally { + delete process.env.LFC_CONFIG_DIR; + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('rejects a set-but-empty key instead of silently going unauthenticated', () => { + process.env.LFC_API_KEY = ' '; + expect(() => apiKeyFromEnv()).toThrow(AuthError); }); it('is returned as the access token without a profile login', async () => { @@ -53,9 +72,7 @@ describe('LFC_API_KEY', () => { await expect(getAccessToken('nope', { ...profile })).resolves.toBe(key); }); - it('is absent when unset or blank', () => { - expect(apiKeyFromEnv()).toBeNull(); - process.env.LFC_API_KEY = ' '; + it('is absent when unset', () => { expect(apiKeyFromEnv()).toBeNull(); }); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index 5749935..14990ae 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -255,18 +255,24 @@ describe('reportInvocation', () => { }); describe('runAction telemetry outcome', () => { - async function runWrapped(handler: (ctx: Ctx) => Promise) { + async function runWrapped(handler: (ctx: Ctx) => Promise, response?: Response) { saveConfig({ currentProfile: 'default', profiles: { default: { apiUrl: 'https://lifecycle.example.com', authEnabled: false } }, }); - const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 201 })); + const fetchMock = vi + .fn() + .mockImplementation(async (url: URL | string) => + response && String(url).includes('/api/v2/builds') ? response : new Response('{}', { status: 201 }), + ); vi.stubGlobal('fetch', fetchMock); process.exitCode = 0; const program = new Command().name('lfc').option('--json').option('--profile '); program.command('demo').action(runAction(handler)); await program.parseAsync(['demo'], { from: 'user' }); - return fetchMock.mock.calls.map(([, init]) => JSON.parse((init as RequestInit).body as string)); + return fetchMock.mock.calls + .filter(([, init]) => (init as RequestInit).body) + .map(([, init]) => JSON.parse((init as RequestInit).body as string)); } it('reports error when a command sets process.exitCode without throwing', async () => { @@ -282,6 +288,22 @@ describe('runAction telemetry outcome', () => { } }); + it('exits 4 when the API rejects LFC_API_KEY', async () => { + const prev = process.exitCode; + process.env.LFC_API_KEY = `lfc_svc_${'a'.repeat(40)}`; + try { + await runWrapped( + async ctx => { + await ctx.api.getBuild('abc'); + }, + new Response('{"error":{"message":"bad key"}}', { status: 401 }), + ); + expect(process.exitCode).toBe(4); + } finally { + process.exitCode = prev; + } + }); + it('reports success with exit 0 for a clean command', async () => { const prev = process.exitCode; try {