Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- summary: |
`fern docs preview delete --id <id>` now resolves the preview's full URL, including its basepath.
Previously the ID was resolved to the hostname alone, so deleting a preview published under a
basepath reported success without deleting the site. The command now fails with a clear message
when the ID doesn't match a preview deployment.
type: fix
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";

import { findPreviewsForHostname } from "../deleteDocsPreview.js";
import { toPreviewUrl } from "../listDocsPreview.js";

function item(domain: string, basePath?: string) {
return { domain, basePath, organizationId: "acme", updatedAt: "2026-07-17T00:00:00.000Z" };
}

const HOSTNAME = "acme-preview-mr-2.docs.buildwithfern.com";

describe("findPreviewsForHostname", () => {
it("preserves the basepath of a preview published under one", async () => {
const listPreviewUrls = vi.fn().mockResolvedValue({
urls: [item("acme-preview-mr-1.docs.buildwithfern.com"), item(HOSTNAME, "/docs")]
});

const matches = await findPreviewsForHostname({ listPreviewUrls, hostname: HOSTNAME });

expect(matches.map(toPreviewUrl)).toEqual([`${HOSTNAME}/docs`]);
});

it("returns the bare hostname for a root preview", async () => {
const listPreviewUrls = vi.fn().mockResolvedValue({ urls: [item(HOSTNAME)] });

const matches = await findPreviewsForHostname({ listPreviewUrls, hostname: HOSTNAME });

expect(matches.map(toPreviewUrl)).toEqual([HOSTNAME]);
});

it("returns every site on the host when it serves more than one basepath", async () => {
const listPreviewUrls = vi.fn().mockResolvedValue({
urls: [item(HOSTNAME, "/docs"), item(HOSTNAME, "/api")]
});

const matches = await findPreviewsForHostname({ listPreviewUrls, hostname: HOSTNAME });

expect(matches.map(toPreviewUrl)).toEqual([`${HOSTNAME}/docs`, `${HOSTNAME}/api`]);
});

it("returns nothing when no preview matches the hostname", async () => {
const listPreviewUrls = vi
.fn()
.mockResolvedValue({ urls: [item("other-preview-mr-2.docs.buildwithfern.com", "/docs")] });

expect(await findPreviewsForHostname({ listPreviewUrls, hostname: HOSTNAME })).toEqual([]);
});

it("matches case-insensitively", async () => {
const listPreviewUrls = vi.fn().mockResolvedValue({ urls: [item(HOSTNAME.toUpperCase(), "/docs")] });

const matches = await findPreviewsForHostname({ listPreviewUrls, hostname: HOSTNAME });

expect(matches).toHaveLength(1);
});

it("keeps paging while pages come back full", async () => {
const firstPage = Array.from({ length: 1000 }, (_, i) =>
item(`acme-preview-filler-${i}.docs.buildwithfern.com`)
);
const listPreviewUrls = vi
.fn()
.mockResolvedValueOnce({ urls: firstPage })
.mockResolvedValueOnce({ urls: [item(HOSTNAME, "/docs")] });

const matches = await findPreviewsForHostname({ listPreviewUrls, hostname: HOSTNAME });

expect(matches.map(toPreviewUrl)).toEqual([`${HOSTNAME}/docs`]);
expect(listPreviewUrls).toHaveBeenCalledTimes(2);
expect(listPreviewUrls).toHaveBeenLastCalledWith({ page: 2, limit: 1000, preview: true });
});
});
107 changes: 93 additions & 14 deletions packages/cli/cli/src/commands/docs-preview/deleteDocsPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,53 @@ import { askToLogin } from "@fern-api/login";
import { CliError } from "@fern-api/task-context";
import chalk from "chalk";
import { CliContext } from "../../cli-context/CliContext.js";
import { DocsUrlItem, toPreviewUrl } from "./listDocsPreview.js";

/** FDR caps `limit` on the docs-url listing at 1000. */
const PREVIEW_PAGE_SIZE = 1000;
/** Guards against paging forever if the listing never shrinks below a full page. */
const MAX_PREVIEW_PAGES = 20;

type ListPreviewUrls = (args: { page: number; limit: number; preview: true }) => Promise<{
urls: readonly DocsUrlItem[];
}>;

/**
* Finds the preview deployments served from `hostname`. There can be more than
* one: a preview host can serve several sites, each under its own basepath.
*/
export async function findPreviewsForHostname({
listPreviewUrls,
hostname
}: {
listPreviewUrls: ListPreviewUrls;
hostname: string;
}): Promise<DocsUrlItem[]> {
const normalizedHostname = hostname.toLowerCase();
const matches: DocsUrlItem[] = [];
for (let page = 1; page <= MAX_PREVIEW_PAGES; page++) {
const { urls } = await listPreviewUrls({ page, limit: PREVIEW_PAGE_SIZE, preview: true });
matches.push(...urls.filter((item) => item.domain.toLowerCase() === normalizedHostname));
if (urls.length < PREVIEW_PAGE_SIZE) {
break;
}
}
return matches;
}

/**
* Resolves a preview ID to the URL FDR stores for it. The ID only determines the
* hostname, but a preview published under a basepath is keyed on hostname +
* basepath, so the deployment is looked up rather than assumed to be at the root.
*/
async function resolvePreviewUrlFromId({
cliContext,
previewId
previewId,
token
}: {
cliContext: CliContext;
previewId: string;
token: FernToken;
}): Promise<string> {
const fernDirectory = await getFernDirectory();
if (fernDirectory == null) {
Expand All @@ -28,7 +68,46 @@ async function resolvePreviewUrlFromId({
loadProjectConfig({ directory: fernDirectory, context })
);

return buildPreviewDomain({ orgId: projectConfig.organization, previewId });
const hostname = buildPreviewDomain({ orgId: projectConfig.organization, previewId });
const fdr = createFdrService({ token: token.value });

let matches: DocsUrlItem[];
try {
matches = await findPreviewsForHostname({
listPreviewUrls: (args) => fdr.docs.v2.read.listAllDocsUrls(args),
hostname
});
} catch (error) {
return cliContext.failAndThrow(
`Failed to look up the preview deployment for ID "${previewId}".\n` +
`Pass the full preview URL instead, e.g. fern docs preview delete ${hostname}`,
error,
{ code: CliError.Code.NetworkError }
);
}

const [match, ...ambiguous] = matches;

if (match == null) {
return cliContext.failAndThrow(
`No preview deployment found for ID "${previewId}" (${hostname}).\n` +
"Run 'fern docs preview list' to see the preview deployments you can delete.",
undefined,
{ code: CliError.Code.ConfigError }
);
}

if (ambiguous.length > 0) {
const urls = matches.map((item) => ` ${toPreviewUrl(item)}`).join("\n");
return cliContext.failAndThrow(
`Preview ID "${previewId}" matches more than one deployment:\n${urls}\n` +
"Pass the full preview URL of the one you want to delete.",
undefined,
{ code: CliError.Code.ConfigError }
);
}

return toPreviewUrl(match);
}

function resolveTarget({
Expand Down Expand Up @@ -71,10 +150,21 @@ export async function deleteDocsPreview({
}): Promise<void> {
const resolved = resolveTarget({ target, url: previewUrl, id: previewId });

const token: FernToken | null = await cliContext.runTask(async (context) => {
return askToLogin(context);
});

if (token == null) {
cliContext.failAndThrow("Failed to authenticate. Please run 'fern login' first.", undefined, {
code: CliError.Code.AuthError
});
return;
}

let resolvedUrl: string;

if (resolved.type === "id") {
resolvedUrl = await resolvePreviewUrlFromId({ cliContext, previewId: resolved.value });
resolvedUrl = await resolvePreviewUrlFromId({ cliContext, previewId: resolved.value, token });
cliContext.logger.debug(`Resolved preview ID "${resolved.value}" to URL: ${resolvedUrl}`);
} else {
resolvedUrl = resolved.value;
Expand All @@ -93,17 +183,6 @@ export async function deleteDocsPreview({
return;
}

const token: FernToken | null = await cliContext.runTask(async (context) => {
return askToLogin(context);
});

if (token == null) {
cliContext.failAndThrow("Failed to authenticate. Please run 'fern login' first.", undefined, {
code: CliError.Code.AuthError
});
return;
}

await cliContext.runTask(async (context) => {
context.logger.info(`Deleting preview site: ${resolvedUrl}`);

Expand Down
13 changes: 11 additions & 2 deletions packages/cli/cli/src/commands/docs-preview/listDocsPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,19 @@ interface PreviewDeployment {
}

/** A single docs-url entry as returned by FDR's `listAllDocsUrls`. */
type DocsUrlItem = Awaited<
export type DocsUrlItem = Awaited<
ReturnType<ReturnType<typeof createFdrService>["docs"]["v2"]["read"]["listAllDocsUrls"]>
>["urls"][number];

/**
* The URL that identifies a docs site to FDR: the hostname plus its basepath, if
* it has one. Deletion and metadata lookups are keyed on this full URL, not on
* the hostname alone.
*/
export function toPreviewUrl(item: DocsUrlItem): string {
return item.basePath != null ? `${item.domain}${item.basePath}` : item.domain;
}

/**
* Maps FDR docs-url items to preview deployments. Preview deployments are
* filtered entirely server-side (preview: true -> the isPreview column in FDR),
Expand All @@ -26,7 +35,7 @@ type DocsUrlItem = Awaited<
*/
export function toPreviewDeployments(urls: readonly DocsUrlItem[]): PreviewDeployment[] {
return urls.map((item) => ({
url: item.basePath != null ? `${item.domain}${item.basePath}` : item.domain,
url: toPreviewUrl(item),
organizationId: item.organizationId,
updatedAt: item.updatedAt
}));
Expand Down