Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { FernToken } from "@fern-api/auth";
import { AbsoluteFilePath } from "@fern-api/fs-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestContext } from "../../../../../__test__/utils/createTestContext.js";
import type { Context } from "../../../../../context/Context.js";
import { DeleteCommand } from "../command.js";

const { listAllDocsUrls, deleteDocsSite } = vi.hoisted(() => ({
listAllDocsUrls: vi.fn(),
deleteDocsSite: vi.fn()
}));

vi.mock("@fern-api/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@fern-api/core")>()),
createFdrService: () => ({
docs: { v2: { read: { listAllDocsUrls }, write: { deleteDocsSite } } }
})
}));

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

async function createContext(): Promise<Context> {
const context = await createTestContext({ cwd: AbsoluteFilePath.of(process.cwd()) });
vi.spyOn(context, "getTokenOrPrompt").mockResolvedValue({ type: "user", value: "token" } satisfies FernToken);
vi.spyOn(context, "loadWorkspaceOrThrow").mockResolvedValue({ org: "acme" } as Awaited<
ReturnType<Context["loadWorkspaceOrThrow"]>
>);
return context;
}

describe("DeleteCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("deletes the preview at the basepath resolved from an id", async () => {
listAllDocsUrls.mockResolvedValue({ urls: [{ domain: HOSTNAME, basePath: "/docs" }] });
const context = await createContext();

await new DeleteCommand().handle(context, { "log-level": "info", id: "mr-2" });

expect(deleteDocsSite).toHaveBeenCalledWith({ url: `${HOSTNAME}/docs` });
});

it("resolves the basepath of a preview passed as a bare hostname", async () => {
listAllDocsUrls.mockResolvedValue({ urls: [{ domain: HOSTNAME, basePath: "/docs" }] });
const context = await createContext();

await new DeleteCommand().handle(context, { "log-level": "info", target: HOSTNAME });

expect(deleteDocsSite).toHaveBeenCalledWith({ url: `${HOSTNAME}/docs` });
});

it("deletes the URL as given when it already includes a basepath", async () => {
const context = await createContext();

await new DeleteCommand().handle(context, { "log-level": "info", url: `https://${HOSTNAME}/docs/` });

expect(listAllDocsUrls).not.toHaveBeenCalled();
expect(deleteDocsSite).toHaveBeenCalledWith({ url: `${HOSTNAME}/docs` });
});

it("fails when the id matches no preview deployment", async () => {
listAllDocsUrls.mockResolvedValue({ urls: [] });
const context = await createContext();

await expect(new DeleteCommand().handle(context, { "log-level": "info", id: "mr-2" })).rejects.toThrow(
"No preview deployment found"
);
expect(deleteDocsSite).not.toHaveBeenCalled();
});

it("rejects a non-preview URL without authenticating", async () => {
const context = await createContext();

await expect(
new DeleteCommand().handle(context, { "log-level": "info", url: "docs.acme.com" })
).rejects.toThrow("Invalid preview URL");
expect(context.getTokenOrPrompt).not.toHaveBeenCalled();
});
});
97 changes: 88 additions & 9 deletions packages/cli/cli-v2/src/commands/docs/preview/delete/command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { createFdrService } from "@fern-api/core";
import { buildPreviewDomain, isPreviewUrl as isPreviewUrlUtil } from "@fern-api/docs-preview";
import { assertNever } from "@fern-api/core-utils";
import {
buildPreviewDomain,
isPreviewUrl as isPreviewUrlUtil,
lookupPreviewSiteUrl,
PreviewSiteLookup,
splitPreviewUrl
} from "@fern-api/docs-preview";
import { CliError } from "@fern-api/task-context";
import chalk from "chalk";
import type { Argv } from "yargs";
Expand All @@ -17,12 +24,13 @@ export declare namespace DeleteCommand {

export class DeleteCommand {
public async handle(context: Context, args: DeleteCommand.Args): Promise<void> {
const resolvedUrl = await this.resolveUrl(context, args);
const resolved = this.resolveTarget(args);

if (!isPreviewUrlUtil(resolvedUrl)) {
// Validate the URL before asking the user to log in
if (resolved.type === "url" && !isPreviewUrlUtil(resolved.value)) {
throw new CliError({
message:
`Invalid preview URL: ${resolvedUrl}\n` +
`Invalid preview URL: ${resolved.value}\n` +
` Preview URLs follow the pattern: {org}-preview-{hash}.docs.buildwithfern.com`,
code: CliError.Code.ConfigError
});
Expand All @@ -31,6 +39,8 @@ export class DeleteCommand {
const token = await context.getTokenOrPrompt();
const fdr = createFdrService({ token: token.value, headers: context.headers });

const resolvedUrl = await this.resolveUrl(context, resolved, fdr);

context.stderr.debug(`Deleting preview site: ${resolvedUrl}`);

try {
Expand Down Expand Up @@ -74,17 +84,86 @@ export class DeleteCommand {
return { type: "id", value: args.target };
}

private async resolveUrl(context: Context, args: DeleteCommand.Args): Promise<string> {
const resolved = this.resolveTarget(args);

/**
* Resolves the target to the URL FDR stores for the site, which includes the
* basepath the preview was published under. A preview ID — and likewise a
* bare hostname — only determines the hostname, so the deployment has to be
* looked up rather than assumed to live at the root.
*/
private async resolveUrl(
context: Context,
resolved: { type: "url"; value: string } | { type: "id"; value: string },
fdr: ReturnType<typeof createFdrService>
): Promise<string> {
if (resolved.type === "id") {
const workspace = await context.loadWorkspaceOrThrow();
const url = buildPreviewDomain({ orgId: workspace.org, previewId: resolved.value });
const hostname = buildPreviewDomain({ orgId: workspace.org, previewId: resolved.value });
const url = await this.lookupSiteUrl({ fdr, hostname, target: `preview ID "${resolved.value}"` });
context.stderr.debug(`Resolved preview ID "${resolved.value}" to URL: ${url}`);
return url;
}

return resolved.value;
const { hostname, path } = splitPreviewUrl(resolved.value);
if (path !== "") {
return `${hostname}${path}`;
}
return this.lookupSiteUrl({ fdr, hostname, target: hostname });
}

private async lookupSiteUrl({
fdr,
hostname,
target
}: {
fdr: ReturnType<typeof createFdrService>;
hostname: string;
/** How to refer to what the user asked to delete, e.g. `preview ID "mr-2"`. */
target: string;
}): Promise<string> {
let lookup: PreviewSiteLookup;
try {
lookup = await lookupPreviewSiteUrl({
listPreviewUrls: (listArgs) => fdr.docs.v2.read.listAllDocsUrls(listArgs),
hostname
});
} catch (error) {
if ((error as Record<string, unknown>)?.error === "UnauthorizedError") {
throw CliError.unauthorized(
"You do not have permissions to list preview deployments. Reach out to support@buildwithfern.com"
);
}
throw new CliError({
message: `Failed to look up the preview deployment for ${target}.`,
code: CliError.Code.InternalError
});
}

switch (lookup.type) {
case "found":
return lookup.url;
case "notFound":
throw CliError.notFound(
`No preview deployment found for ${target} (${hostname}).\n` +
" Run 'fern docs preview list' to see the preview deployments you can delete."
);
case "ambiguous":
throw new CliError({
message:
`${hostname} serves more than one preview site:\n` +
`${lookup.urls.map((url) => ` ${url}`).join("\n")}\n` +
" Pass the full URL of the one you want to delete.",
code: CliError.Code.ConfigError
});
case "scanLimitReached":
throw new CliError({
message:
`Could not find ${target} (${hostname}) in the first ${lookup.pagesScanned} pages of preview deployments.\n` +
" Pass the full preview URL, including its basepath, instead.",
code: CliError.Code.ConfigError
});
default:
assertNever(lookup);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- summary: |
`fern docs preview delete` now resolves the preview's full URL, including its basepath, when given
a preview ID or a bare hostname. Previously the target 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 target doesn't match exactly one preview deployment.
type: fix
117 changes: 103 additions & 14 deletions packages/cli/cli/src/commands/docs-preview/deleteDocsPreview.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { FernToken } from "@fern-api/auth";
import { getFernDirectory, loadProjectConfig } from "@fern-api/configuration-loader";
import { createFdrService } from "@fern-api/core";
import { buildPreviewDomain, isPreviewUrl } from "@fern-api/docs-preview";
import { assertNever } from "@fern-api/core-utils";
import {
buildPreviewDomain,
isPreviewUrl,
lookupPreviewSiteUrl,
PreviewSiteLookup,
splitPreviewUrl
} from "@fern-api/docs-preview";
import { askToLogin } from "@fern-api/login";
import { CliError } from "@fern-api/task-context";
import chalk from "chalk";
import { CliContext } from "../../cli-context/CliContext.js";

async function resolvePreviewUrlFromId({
/** The hostname a preview ID maps to, per the org of the local Fern project. */
async function resolveHostnameFromId({
cliContext,
previewId
}: {
Expand All @@ -31,6 +39,74 @@ async function resolvePreviewUrlFromId({
return buildPreviewDomain({ orgId: projectConfig.organization, previewId });
}

/**
* Resolves the URL FDR stores for the preview served from `hostname` — including
* the basepath it was published under, which a hostname alone does not identify.
*/
async function resolveSiteUrlForHostname({
cliContext,
hostname,
target,
token
}: {
cliContext: CliContext;
hostname: string;
/** How to refer to what the user asked to delete, e.g. `preview ID "mr-2"`. */
target: string;
token: FernToken;
}): Promise<string> {
const fdr = createFdrService({ token: token.value });

let lookup: PreviewSiteLookup;
try {
lookup = await lookupPreviewSiteUrl({
listPreviewUrls: (args) => fdr.docs.v2.read.listAllDocsUrls(args),
hostname
});
} catch (error) {
const errorType = (error as Record<string, unknown>)?.error;
if (errorType === "UnauthorizedError") {
return cliContext.failAndThrow(
"Unauthorized to list preview deployments. Please run 'fern login' to refresh your credentials, or set the FERN_TOKEN environment variable.",
undefined,
{ code: CliError.Code.NetworkError }
);
}
return cliContext.failAndThrow(`Failed to look up the preview deployment for ${target}.`, error, {
code: CliError.Code.NetworkError
});
}

switch (lookup.type) {
case "found":
return lookup.url;
case "notFound":
return cliContext.failAndThrow(
`No preview deployment found for ${target} (${hostname}).\n` +
"Run 'fern docs preview list' to see the preview deployments you can delete.",
undefined,
{ code: CliError.Code.ConfigError }
);
case "ambiguous":
return cliContext.failAndThrow(
`${hostname} serves more than one preview site:\n` +
`${lookup.urls.map((url) => ` ${url}`).join("\n")}\n` +
"Pass the full URL of the one you want to delete.",
undefined,
{ code: CliError.Code.ConfigError }
);
case "scanLimitReached":
return cliContext.failAndThrow(
`Could not find ${target} (${hostname}) in the first ${lookup.pagesScanned} pages of preview deployments.\n` +
"Pass the full preview URL, including its basepath, instead.",
undefined,
{ code: CliError.Code.ConfigError }
);
default:
assertNever(lookup);
}
}

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

let resolvedUrl: string;

if (resolved.type === "id") {
resolvedUrl = await resolvePreviewUrlFromId({ cliContext, previewId: resolved.value });
cliContext.logger.debug(`Resolved preview ID "${resolved.value}" to URL: ${resolvedUrl}`);
} else {
resolvedUrl = resolved.value;
}

// Validate that the URL is a preview URL before proceeding
if (!isPreviewUrl(resolvedUrl)) {
// Validate that the URL is a preview URL before asking the user to log in
if (resolved.type === "url" && !isPreviewUrl(resolved.value)) {
cliContext.failAndThrow(
`Invalid preview URL: ${resolvedUrl}\n` +
`Invalid preview URL: ${resolved.value}\n` +
"Only preview sites can be deleted with this command.\n" +
"Preview URLs follow the pattern: {org}-preview-{hash}.docs.buildwithfern.com\n" +
"Example: acme-preview-abc123.docs.buildwithfern.com",
Expand All @@ -104,6 +171,28 @@ export async function deleteDocsPreview({
return;
}

let resolvedUrl: string;

if (resolved.type === "id") {
const hostname = await resolveHostnameFromId({ cliContext, previewId: resolved.value });
resolvedUrl = await resolveSiteUrlForHostname({
cliContext,
hostname,
target: `preview ID "${resolved.value}"`,
token
});
cliContext.logger.debug(`Resolved preview ID "${resolved.value}" to URL: ${resolvedUrl}`);
} else {
const { hostname, path } = splitPreviewUrl(resolved.value);
// A bare hostname doesn't identify a preview published under a basepath —
// FDR keys the site on hostname + basepath — so look the site up unless
// the user already told us which basepath they mean.
resolvedUrl =
path === ""
? await resolveSiteUrlForHostname({ cliContext, hostname, target: hostname, token })
: `${hostname}${path}`;
}

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

Expand Down
5 changes: 3 additions & 2 deletions packages/cli/cli/src/commands/docs-preview/listDocsPreview.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FernToken } from "@fern-api/auth";
import { createFdrService } from "@fern-api/core";
import { toPreviewSiteUrl } from "@fern-api/docs-preview";
import { askToLogin } from "@fern-api/login";
import { CliError } from "@fern-api/task-context";
import chalk from "chalk";
Expand All @@ -12,7 +13,7 @@ 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];

Expand All @@ -26,7 +27,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: toPreviewSiteUrl(item),
organizationId: item.organizationId,
updatedAt: item.updatedAt
}));
Expand Down
Loading
Loading