Skip to content
Draft
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ lfc pods logs <build-uuid> # interactive pod/container picke
lfc pods logs <build-uuid> <pod-name> -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 2 additions & 3 deletions src/commands/pods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/commands/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 34 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,6 +11,7 @@ import type {
BuildListItem,
DeployJobInfo,
LogStreamInfo,
LogStreamTicket,
PaginationMetadata,
PodInfo,
ServiceOverrideState,
Expand All @@ -29,6 +32,13 @@ export class ApiError extends Error {
}
}

const LOG_STREAM_TICKET_MESSAGES: Record<string, string> = {
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<T> {
items: T[];
pagination?: PaginationMetadata;
Expand Down Expand Up @@ -62,7 +72,7 @@ export class ApiClient {
if (v !== undefined) url.searchParams.set(k, String(v));
}

const headers: Record<string, string> = {};
const headers: Record<string, string> = { 'User-Agent': `lifecycle-cli/${pkg.version}` };
const token = await getAccessToken(this.profileName, this.profile, { forceRefresh: retried });
if (token) headers.Authorization = `Bearer ${token}`;

Expand Down Expand Up @@ -237,6 +247,28 @@ export class ApiClient {
return env.data as LogStreamInfo;
}

async createLogStreamTicket(buildUuid: string, podName: string, containerName: string): Promise<LogStreamTicket> {
const body: CreateLogStreamTicketBody = { buildUuid, podName, containerName };
let env: ApiEnvelope<LogStreamTicket>;
try {
env = await this.request<LogStreamTicket>('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<WebhookInvocation[]> {
const env = await this.request<WebhookInvocation[]>('GET', `/api/v2/builds/${encodeURIComponent(uuid)}/webhooks`);
return env.data ?? [];
Expand Down
4 changes: 3 additions & 1 deletion src/lib/generated/agentMcpConnection.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
}
12 changes: 12 additions & 0 deletions src/lib/generated/createLogStreamTicket200AllOfData.ts
Original file line number Diff line number Diff line change
@@ -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;
};
13 changes: 13 additions & 0 deletions src/lib/generated/createLogStreamTicketBody.ts
Original file line number Diff line number Diff line change
@@ -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;
};
2 changes: 2 additions & 0 deletions src/lib/generated/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 2 additions & 4 deletions src/lib/logs.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import pc from 'picocolors';

export interface LogStreamParams {
ticket: string;
podName: string;
namespace: string;
containerName: string;
follow: boolean;
tailLines?: number;
Expand All @@ -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));
Expand Down
3 changes: 3 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
CreateLogStreamTicket200AllOfData,
DeploymentJobInfo,
DeploymentPodContainerInfo,
EnvironmentPodInfo,
Expand Down Expand Up @@ -145,6 +146,8 @@ export type DeployJobInfo = Omit<DeploymentJobInfo, 'status' | 'deploymentType'

export type LogStreamInfo = Omit<LogStreamResponse, 'podName'> & { 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']);
113 changes: 111 additions & 2 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -96,3 +100,108 @@ describe('ApiClient.resolveBuild', () => {
expect(truncated).toBe(false);
});
});

function stubFetch(status: number, body: unknown) {
const fetchMock = vi.fn<typeof fetch>(
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('<html>Not found</html>', { 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');
}
});
});
Loading
Loading