From bed6d2a00989b2da94536ae5ab9cc8240a53591e Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sun, 25 Oct 2026 09:50:46 -0700 Subject: [PATCH 1/2] fix: require log stream tickets in CLI 0.8.0 --- README.md | 4 +- package.json | 2 +- src/commands/pods.ts | 5 +- src/commands/services.ts | 3 +- src/lib/api.ts | 36 +++- .../createLogStreamTicket200AllOfData.ts | 12 ++ .../generated/createLogStreamTicketBody.ts | 13 ++ src/lib/generated/index.ts | 2 + src/lib/logs.ts | 6 +- src/lib/types.ts | 3 + tests/api.test.ts | 113 +++++++++++- tests/logs-command.test.ts | 174 ++++++++++++++++++ tests/logs.test.ts | 33 +++- tests/sites.test.ts | 6 +- 14 files changed, 388 insertions(+), 24 deletions(-) create mode 100644 src/lib/generated/createLogStreamTicket200AllOfData.ts create mode 100644 src/lib/generated/createLogStreamTicketBody.ts create mode 100644 tests/logs-command.test.ts diff --git a/README.md b/README.md index eb58d88..cc4a601 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,9 @@ lfc pods logs # interactive pod/container picke lfc pods logs -f --tail 500 --timestamps ``` -Log streaming uses the deployment's websocket endpoint (`/api/logs/stream`); `Ctrl-C` stops cleanly. In scripts, pass the pod name explicitly — the picker only appears on a TTY. +Version 0.8.0 requires an API that provides `POST /api/v2/logs/stream-tickets` for live logs. The CLI obtains a ticket before opening `/api/logs/stream`. It does not fall back to anonymous log access. Archived logs use their existing API routes. + +Install this CLI after the matching API release. If a deployment disconnects a live stream, run the command again to obtain a fresh ticket. `Ctrl-C` stops the stream. In scripts, pass the pod name explicitly. The picker only appears on a TTY. ## Schema validation diff --git a/package.json b/package.json index db8ff84..c1ab84c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lfc-cli", - "version": "0.7.1", + "version": "0.8.0", "description": "Command-line interface for Lifecycle — preview environments, services, and static sites", "license": "Apache-2.0", "type": "module", diff --git a/src/commands/pods.ts b/src/commands/pods.ts index 89c30c9..d9fcf7e 100644 --- a/src/commands/pods.ts +++ b/src/commands/pods.ts @@ -117,19 +117,18 @@ export function registerPodsCommands(program: Command): void { podName: string | undefined, opts: { service?: string; container?: string; follow: boolean; tail: string; timestamps: boolean }, ) => { - const build = await ctx.api.getBuild(buildUuid); - const namespace = build.namespace || `env-${buildUuid}`; const list = await fetchPods(ctx, buildUuid, opts.service); if (list.length === 0) throw new Error('No pods found for this build'); const pod = await pickPod(list, podName); const container = await pickContainer(pod, opts.container); const tailLines = Number.parseInt(opts.tail, 10); if (Number.isNaN(tailLines) || tailLines < 0) throw new Error(`Invalid --tail value "${opts.tail}"`); + const { ticket } = await ctx.api.createLogStreamTicket(buildUuid, pod.podName, container); await streamPodLogs( ctx.api.baseUrl, { + ticket, podName: pod.podName, - namespace, containerName: container, follow: opts.follow, tailLines, diff --git a/src/commands/services.ts b/src/commands/services.ts index bac732a..a13ec51 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -215,11 +215,12 @@ async function printJobLogs( const containerName = containers.length > 0 ? containers[containers.length - 1]!.name : 'main'; const tailLines = Number.parseInt(opts.tail, 10); if (Number.isNaN(tailLines) || tailLines < 0) throw new Error(`Invalid --tail value "${opts.tail}"`); + const { ticket } = await ctx.api.createLogStreamTicket(buildUuid, info.podName, containerName); await streamPodLogs( ctx.api.baseUrl, { + ticket, podName: info.podName, - namespace: info.websocket.parameters.namespace, containerName, follow: opts.follow && info.websocket.parameters.follow, tailLines, diff --git a/src/lib/api.ts b/src/lib/api.ts index 80b8657..d5c1c19 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,8 @@ +import pkg from '../../package.json' with { type: 'json' }; + import { getAccessToken } from './auth.js'; import type { Profile } from './config.js'; -import type { ListSitesParams, RenameSiteBody } from './generated/index.js'; +import type { CreateLogStreamTicketBody, ListSitesParams, RenameSiteBody } from './generated/index.js'; import { matchBuilds, type Selector } from './resolve.js'; import type { ApiEnvelope, @@ -9,6 +11,7 @@ import type { BuildListItem, DeployJobInfo, LogStreamInfo, + LogStreamTicket, PaginationMetadata, PodInfo, ServiceOverrideState, @@ -29,6 +32,13 @@ export class ApiError extends Error { } } +const LOG_STREAM_TICKET_MESSAGES: Record = { + forbidden_scope: "You do not have access to this environment's logs.", + forbidden_repository: "You do not have access to this environment's logs.", + log_stream_pod_not_found: 'That pod is no longer running.', + log_stream_container_not_found: 'That container is no longer on the pod.', +}; + export interface ListResult { items: T[]; pagination?: PaginationMetadata; @@ -62,7 +72,7 @@ export class ApiClient { if (v !== undefined) url.searchParams.set(k, String(v)); } - const headers: Record = {}; + const headers: Record = { 'User-Agent': `lifecycle-cli/${pkg.version}` }; const token = await getAccessToken(this.profileName, this.profile, { forceRefresh: retried }); if (token) headers.Authorization = `Bearer ${token}`; @@ -237,6 +247,28 @@ export class ApiClient { return env.data as LogStreamInfo; } + async createLogStreamTicket(buildUuid: string, podName: string, containerName: string): Promise { + const body: CreateLogStreamTicketBody = { buildUuid, podName, containerName }; + let env: ApiEnvelope; + try { + env = await this.request('POST', '/api/v2/logs/stream-tickets', { json: body }); + } catch (err) { + if (!(err instanceof ApiError)) throw err; + const message = err.code ? LOG_STREAM_TICKET_MESSAGES[err.code] : undefined; + if (!err.code && (err.status === 404 || err.status === 405)) { + throw new ApiError( + 'The API log ticket endpoint is unavailable. Ask the administrator to check API routing and install an API version that supports log tickets.', + err.status, + err.requestId, + 'log_stream_api_incompatible', + ); + } + throw message ? new ApiError(message, err.status, err.requestId, err.code) : err; + } + if (!env.data?.ticket) throw new ApiError('Could not start the log stream.', 0, env.request_id); + return env.data; + } + async listWebhookInvocations(uuid: string): Promise { const env = await this.request('GET', `/api/v2/builds/${encodeURIComponent(uuid)}/webhooks`); return env.data ?? []; diff --git a/src/lib/generated/createLogStreamTicket200AllOfData.ts b/src/lib/generated/createLogStreamTicket200AllOfData.ts new file mode 100644 index 0000000..638c1ab --- /dev/null +++ b/src/lib/generated/createLogStreamTicket200AllOfData.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export type CreateLogStreamTicket200AllOfData = { + ticket: string; + maxAgeSeconds: number; +}; diff --git a/src/lib/generated/createLogStreamTicketBody.ts b/src/lib/generated/createLogStreamTicketBody.ts new file mode 100644 index 0000000..e73fd65 --- /dev/null +++ b/src/lib/generated/createLogStreamTicketBody.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export type CreateLogStreamTicketBody = { + buildUuid: string; + podName: string; + containerName: string; +}; diff --git a/src/lib/generated/index.ts b/src/lib/generated/index.ts index e0115e4..553bde8 100644 --- a/src/lib/generated/index.ts +++ b/src/lib/generated/index.ts @@ -837,3 +837,5 @@ export * from './resolvePublicSiteBrowserUrl404ErrorMessage.js'; export * from './mintSitesBrowserTicket200.js'; export * from './mintSitesBrowserTicket200Data.js'; export * from './sitesBrowserMintRequest.js'; +export * from './createLogStreamTicketBody.js'; +export * from './createLogStreamTicket200AllOfData.js'; diff --git a/src/lib/logs.ts b/src/lib/logs.ts index bc76069..df75875 100644 --- a/src/lib/logs.ts +++ b/src/lib/logs.ts @@ -1,8 +1,8 @@ import pc from 'picocolors'; export interface LogStreamParams { + ticket: string; podName: string; - namespace: string; containerName: string; follow: boolean; tailLines?: number; @@ -13,9 +13,7 @@ export function logStreamWsUrl(apiBaseUrl: string, params: LogStreamParams): str const url = new URL(apiBaseUrl); url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; url.pathname = '/api/logs/stream'; - url.searchParams.set('podName', params.podName); - url.searchParams.set('namespace', params.namespace); - url.searchParams.set('containerName', params.containerName); + url.searchParams.set('ticket', params.ticket); url.searchParams.set('follow', String(params.follow)); if (params.tailLines !== undefined) url.searchParams.set('tailLines', String(params.tailLines)); url.searchParams.set('timestamps', String(params.timestamps ?? false)); diff --git a/src/lib/types.ts b/src/lib/types.ts index 6ae9c99..03ea79a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,5 @@ import type { + CreateLogStreamTicket200AllOfData, DeploymentJobInfo, DeploymentPodContainerInfo, EnvironmentPodInfo, @@ -145,6 +146,8 @@ export type DeployJobInfo = Omit & { podName?: string | null }; +export type LogStreamTicket = CreateLogStreamTicket200AllOfData; + export const BUILD_TERMINAL_SUCCESS = new Set(['deployed']); export const BUILD_TERMINAL_FAILURE = new Set(['error', 'config_error', 'torn_down']); export const BUILD_IN_PROGRESS = new Set(['pending', 'queued', 'building', 'deploying', 'built', 'tearing_down']); diff --git a/tests/api.test.ts b/tests/api.test.ts index a2a8edb..3009681 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ApiClient, type ListResult } from '../src/lib/api.js'; +import pkg from '../package.json' with { type: 'json' }; +import { ApiClient, ApiError, type ListResult } from '../src/lib/api.js'; import type { Profile } from '../src/lib/config.js'; import type { BuildListItem } from '../src/lib/types.js'; +vi.mock('../src/lib/auth.js', () => ({ getAccessToken: vi.fn(async () => 'lfc_pat_test') })); + const PROFILE: Profile = { apiUrl: 'http://example.test', authEnabled: false }; +const KEY_PROFILE: Profile = { apiUrl: 'https://api.example.test', authEnabled: true }; function mk( uuid: string, @@ -96,3 +100,108 @@ describe('ApiClient.resolveBuild', () => { expect(truncated).toBe(false); }); }); + +function stubFetch(status: number, body: unknown) { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('ApiClient.createLogStreamTicket', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('posts the build, pod and container with the API key and returns the ticket', async () => { + const fetchMock = stubFetch(200, { + request_id: 'r1', + data: { ticket: 'lfclt_v1.abc', maxAgeSeconds: 300 }, + error: null, + }); + const client = new ApiClient('test', KEY_PROFILE); + await expect(client.createLogStreamTicket('build-1', 'web-abc', '[init] clone')).resolves.toEqual({ + ticket: 'lfclt_v1.abc', + maxAgeSeconds: 300, + }); + const [url, init] = fetchMock.mock.calls[0] as unknown as [URL, RequestInit]; + expect(url.toString()).toBe('https://api.example.test/api/v2/logs/stream-tickets'); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ + Authorization: 'Bearer lfc_pat_test', + 'Content-Type': 'application/json', + 'User-Agent': `lifecycle-cli/${pkg.version}`, + }); + expect(JSON.parse(String(init.body))).toEqual({ + buildUuid: 'build-1', + podName: 'web-abc', + containerName: '[init] clone', + }); + }); + + it.each([ + [403, 'forbidden_scope', "You do not have access to this environment's logs."], + [403, 'forbidden_repository', "You do not have access to this environment's logs."], + [404, 'log_stream_pod_not_found', 'That pod is no longer running.'], + [404, 'log_stream_container_not_found', 'That container is no longer on the pod.'], + ])('explains a %i %s in plain language', async (status, code, message) => { + stubFetch(status, { request_id: 'r2', data: null, error: { message: 'server wording', code } }); + const client = new ApiClient('test', KEY_PROFILE); + const err = await client.createLogStreamTicket('build-1', 'web-abc', 'web').catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err as ApiError).toMatchObject({ status, code, requestId: 'r2' }); + expect((err as ApiError).message).toBe(message); + }); + + it('keeps the server message for other errors', async () => { + stubFetch(404, { + request_id: 'r3', + data: null, + error: { message: 'Environment not found.', code: 'environment_not_found' }, + }); + const client = new ApiClient('test', KEY_PROFILE); + const err = await client.createLogStreamTicket('build-1', 'web-abc', 'web').catch((e: unknown) => e); + expect(err as ApiError).toMatchObject({ status: 404, code: 'environment_not_found' }); + expect((err as ApiError).message).toBe('Environment not found.'); + }); + + it('fails plainly when the response carries no ticket', async () => { + stubFetch(200, { request_id: 'r4', data: {}, error: null }); + const client = new ApiClient('test', KEY_PROFILE); + await expect(client.createLogStreamTicket('build-1', 'web-abc', 'web')).rejects.toThrow( + 'Could not start the log stream.', + ); + }); + + it.each([404, 405])( + 'explains an unavailable ticket route (%i) without falling back to insecure logs', + async status => { + const fetchMock = vi.fn().mockResolvedValue(new Response('Not found', { status })); + vi.stubGlobal('fetch', fetchMock); + const client = new ApiClient('test', KEY_PROFILE); + await expect(client.createLogStreamTicket('build-1', 'web-abc', 'web')).rejects.toMatchObject({ + status, + code: 'log_stream_api_incompatible', + message: expect.stringContaining('install an API version that supports log tickets'), + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }, + ); + + it.each([401, 403])('preserves authentication failure %i without a compatibility fallback', async status => { + const fetchMock = stubFetch(status, { + request_id: 'denied', + data: null, + error: { message: 'Access denied.', code: 'access_denied' }, + }); + const client = new ApiClient('test', KEY_PROFILE); + await expect(client.createLogStreamTicket('build-1', 'web-abc', 'web')).rejects.toMatchObject({ + status, + code: 'access_denied', + message: 'Access denied.', + }); + expect(fetchMock).toHaveBeenCalledTimes(status === 401 ? 2 : 1); + for (const [url] of fetchMock.mock.calls) { + expect(String(url)).toBe('https://api.example.test/api/v2/logs/stream-tickets'); + } + }); +}); diff --git a/tests/logs-command.test.ts b/tests/logs-command.test.ts new file mode 100644 index 0000000..2ae5eb5 --- /dev/null +++ b/tests/logs-command.test.ts @@ -0,0 +1,174 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerPodsCommands } from '../src/commands/pods.js'; +import { registerServicesCommands } from '../src/commands/services.js'; + +const api = vi.hoisted(() => ({ + baseUrl: 'https://api.example.test', + getBuild: vi.fn(), + listEnvironmentPods: vi.fn(), + listServicePods: vi.fn(), + listBuildJobs: vi.fn(), + listDeployJobs: vi.fn(), + getJobLogInfo: vi.fn(), + createLogStreamTicket: vi.fn(), +})); +const streamPodLogs = vi.hoisted(() => vi.fn()); + +vi.mock('../src/lib/context.js', () => ({ + runAction: + (action: (ctx: unknown, ...args: unknown[]) => Promise) => + (...args: unknown[]) => + action({ api, json: false, quiet: true }, ...args.slice(0, -1)), +})); +vi.mock('../src/lib/logs.js', () => ({ streamPodLogs })); + +const TICKET = { ticket: 'lfclt_v1.ticket', maxAgeSeconds: 300 }; +let originalIsTTY: boolean | undefined; + +beforeEach(() => { + vi.clearAllMocks(); + originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + api.createLogStreamTicket.mockResolvedValue(TICKET); + streamPodLogs.mockResolvedValue(undefined); +}); + +afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); +}); + +function mintedBeforeStreaming(): boolean { + return api.createLogStreamTicket.mock.invocationCallOrder[0]! < streamPodLogs.mock.invocationCallOrder[0]!; +} + +describe('pods logs', () => { + async function run(...args: string[]) { + const command = new Command(); + registerPodsCommands(command); + await command.parseAsync(['pods', 'logs', ...args], { from: 'user' }); + } + + beforeEach(() => { + api.listEnvironmentPods.mockResolvedValue([ + { podName: 'web-abc', status: 'Running', containers: [{ name: 'init-db' }, { name: 'web' }] }, + ]); + }); + + it('mints a ticket for the chosen pod and container before streaming', async () => { + await run('build-1', 'web-abc', '-c', 'web', '-f', '--tail', '50', '--timestamps'); + expect(api.createLogStreamTicket).toHaveBeenCalledWith('build-1', 'web-abc', 'web'); + expect(streamPodLogs).toHaveBeenCalledWith( + 'https://api.example.test', + { + ticket: 'lfclt_v1.ticket', + podName: 'web-abc', + containerName: 'web', + follow: true, + tailLines: 50, + timestamps: true, + }, + { quiet: true }, + ); + expect(mintedBeforeStreaming()).toBe(true); + }); + + it('no longer loads the build to learn its namespace', async () => { + await run('build-1', 'web-abc', '-c', 'web'); + expect(api.getBuild).not.toHaveBeenCalled(); + expect(streamPodLogs.mock.calls[0]![1]).not.toHaveProperty('namespace'); + }); + + it('defaults to the main container when none is given', async () => { + await run('build-1', 'web-abc'); + expect(api.createLogStreamTicket).toHaveBeenCalledWith('build-1', 'web-abc', 'web'); + }); + + it('rejects an invalid --tail before asking for a ticket', async () => { + await expect(run('build-1', 'web-abc', '--tail', 'lots')).rejects.toThrow('Invalid --tail value'); + expect(api.createLogStreamTicket).not.toHaveBeenCalled(); + }); + + it('does not open the stream when the ticket is refused', async () => { + api.createLogStreamTicket.mockRejectedValue(new Error("You don't have access to this environment's logs.")); + await expect(run('build-1', 'web-abc', '-c', 'web')).rejects.toThrow("don't have access"); + expect(streamPodLogs).not.toHaveBeenCalled(); + }); +}); + +describe('services logs', () => { + const liveInfo = { + status: 'Running', + streamingRequired: true, + podName: 'web-build-job-xyz', + websocket: { + endpoint: '/api/logs/stream', + parameters: { podName: 'web-build-job-xyz', namespace: 'env-build-1', follow: true, timestamps: false }, + }, + containers: [ + { name: '[init] clone', state: 'Terminated' }, + { name: 'main', state: 'Running' }, + ], + }; + + async function run(...args: string[]) { + const command = new Command(); + registerServicesCommands(command); + await command.parseAsync(['services', 'logs', ...args], { from: 'user' }); + } + + beforeEach(() => { + api.listBuildJobs.mockResolvedValue([ + { jobName: 'web-build-1', startedAt: '2026-01-01T00:00:00Z' }, + { jobName: 'web-build-2', startedAt: '2026-02-01T00:00:00Z' }, + ]); + api.listDeployJobs.mockResolvedValue([{ jobName: 'web-deploy-1', startedAt: '2026-01-01T00:00:00Z' }]); + api.getJobLogInfo.mockResolvedValue(liveInfo); + }); + + it('mints a ticket for the job pod and its main container before streaming', async () => { + await run('build-1', 'web', '-f'); + expect(api.getJobLogInfo).toHaveBeenCalledWith('build-1', 'web', 'web-build-2', 'build'); + expect(api.createLogStreamTicket).toHaveBeenCalledWith('build-1', 'web-build-job-xyz', 'main'); + expect(streamPodLogs).toHaveBeenCalledWith( + 'https://api.example.test', + { + ticket: 'lfclt_v1.ticket', + podName: 'web-build-job-xyz', + containerName: 'main', + follow: true, + tailLines: 500, + timestamps: false, + }, + { quiet: true }, + ); + expect(mintedBeforeStreaming()).toBe(true); + }); + + it('streams the deploy job named with --job', async () => { + await run('build-1', 'web', '--deploy', '--job', 'web-deploy-1'); + expect(api.getJobLogInfo).toHaveBeenCalledWith('build-1', 'web', 'web-deploy-1', 'deploy'); + expect(api.createLogStreamTicket).toHaveBeenCalledWith('build-1', 'web-build-job-xyz', 'main'); + expect(streamPodLogs).toHaveBeenCalledWith( + 'https://api.example.test', + expect.objectContaining({ ticket: 'lfclt_v1.ticket', follow: false }), + { quiet: true }, + ); + }); + + it('prints archived logs without asking for a ticket', async () => { + api.getJobLogInfo.mockResolvedValue({ status: 'Archived', streamingRequired: false, archivedLogs: 'done\n' }); + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await run('build-1', 'web'); + stdout.mockRestore(); + expect(api.createLogStreamTicket).not.toHaveBeenCalled(); + expect(streamPodLogs).not.toHaveBeenCalled(); + }); + + it('does not open the stream when the ticket is refused', async () => { + api.createLogStreamTicket.mockRejectedValue(new Error('That pod is no longer running.')); + await expect(run('build-1', 'web')).rejects.toThrow('no longer running'); + expect(streamPodLogs).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/logs.test.ts b/tests/logs.test.ts index c4867b4..73f703a 100644 --- a/tests/logs.test.ts +++ b/tests/logs.test.ts @@ -4,11 +4,11 @@ import { logStreamWsUrl, parseWsLogMessage } from '../src/lib/logs.js'; import { formatDuration } from '../src/lib/output.js'; describe('logStreamWsUrl', () => { - it('builds a wss URL on the app host with all parameters', () => { + it('builds a wss URL on the app host carrying the ticket and the stream options', () => { const url = new URL( logStreamWsUrl('https://app.lifecycle.example.com', { + ticket: 'lfclt_v1.ticket', podName: 'web-abc', - namespace: 'env-foo-123', containerName: 'web', follow: true, tailLines: 200, @@ -17,19 +17,34 @@ describe('logStreamWsUrl', () => { ); expect(url.protocol).toBe('wss:'); expect(url.pathname).toBe('/api/logs/stream'); - expect(url.searchParams.get('podName')).toBe('web-abc'); - expect(url.searchParams.get('namespace')).toBe('env-foo-123'); - expect(url.searchParams.get('containerName')).toBe('web'); - expect(url.searchParams.get('follow')).toBe('true'); - expect(url.searchParams.get('tailLines')).toBe('200'); - expect(url.searchParams.get('timestamps')).toBe('false'); + expect(url.search).toContain('ticket='); + expect(Object.fromEntries(url.searchParams)).toEqual({ + ticket: 'lfclt_v1.ticket', + follow: 'true', + tailLines: '200', + timestamps: 'false', + }); + }); + + it('sends no namespace or pod coordinates alongside the ticket', () => { + const url = new URL( + logStreamWsUrl('https://app.lifecycle.example.com', { + ticket: 'lfclt_v1.ticket', + podName: 'web-abc', + containerName: 'web', + follow: false, + }), + ); + expect(url.search).not.toContain('namespace='); + expect(url.searchParams.has('podName')).toBe(false); + expect(url.searchParams.has('containerName')).toBe(false); }); it('uses ws: for http deployments and omits tailLines when unset', () => { const url = new URL( logStreamWsUrl('http://localhost:3000', { + ticket: 't', podName: 'p', - namespace: 'n', containerName: 'c', follow: false, }), diff --git a/tests/sites.test.ts b/tests/sites.test.ts index 9a8b175..fcb8ee3 100644 --- a/tests/sites.test.ts +++ b/tests/sites.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import pkg from '../package.json' with { type: 'json' }; import { ApiClient } from '../src/lib/api.js'; import { describeRetention, siteListView, validateSiteVisibility } from '../src/lib/sites.js'; import type { SitesCapabilities } from '../src/lib/types.js'; @@ -32,7 +33,10 @@ describe('Sites V1 contracts', () => { expect(await client.getSitesCapabilities()).toEqual(capabilities); const [url, options] = fetchMock.mock.calls[0] as unknown as [URL, RequestInit]; expect(url.pathname).toBe('/api/v2/sites/capabilities'); - expect(options.headers).toEqual({ Authorization: 'Bearer lfc_test_personal_key' }); + expect(options.headers).toEqual({ + Authorization: 'Bearer lfc_test_personal_key', + 'User-Agent': `lifecycle-cli/${pkg.version}`, + }); }); it('maps mine to server identity instead of requiring email', async () => { const fetchMock = mockResponse({ sites: [] }); From d42f7ae0f548e17d706dddd9adfd8397c15f1576 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sun, 25 Oct 2026 10:02:50 -0700 Subject: [PATCH 2/2] chore: sync MCP discovery state type --- src/lib/generated/agentMcpConnection.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/generated/agentMcpConnection.ts b/src/lib/generated/agentMcpConnection.ts index 5fba955..3741ab3 100644 --- a/src/lib/generated/agentMcpConnection.ts +++ b/src/lib/generated/agentMcpConnection.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v7.4.1 🍺 + * Generated by orval v7.21.0 🍺 * Do not edit manually. * Lifecycle API * API documentation for lifecycle @@ -33,4 +33,6 @@ export interface AgentMcpConnection { updatedAt: string | null; discoveredTools: McpDiscoveredTool[]; sharedDiscoveredTools: McpDiscoveredTool[]; + /** Tools will be discovered when a session workspace first uses this connection. */ + discoveryPending: boolean; }