import {randomBytes} from "node:crypto"; import path from "node:path"; import {Option, type Command} from "commander"; import {Redacted} from "effect"; import {z} from "zod"; import {resolveCliServerConnection} from "./cli-server-connection.js"; interface LinkOptions { readonly data: string; readonly name?: string; readonly profile?: string; readonly profileData: string; readonly project?: string; readonly server?: string; readonly tokenFile?: string; } /** User-local configuration shared by auth, publish, and link commands. */ export interface LinkCommandOptions { readonly defaultProfileDirectory: string; } const linkedPublicationSchema = z.object({ artifact: z.object({ id: z.string().min(1), name: z.string(), projectId: z.string().min(1), }).loose(), links: z.object({ artifact: z.url(), review: z.url(), version: z.url(), }) .loose(), replayed: z.boolean(), sourceBinding: z.object({ lastVerifiedAt: z.string(), path: z.string(), status: z.enum(["in-sync", "modified", "missing", "unreadable"]), }).loose(), version: z.object({ id: z.string().min(1), number: z.number().int().positive(), }).loose(), }).loose(); const failureSchema = z.object({ error: z.object({code: z.string(), message: z.string()}).loose(), }).loose(); const capabilityUnavailableExplanation = "This server does not link files. Linking works only on a local Artifact Server started with ARTIFACT_SERVER_LINKED_FILES=on and reached on its loopback address."; /** Add the linked-file command to the Artifact Server CLI. */ export function configureLinkCommand( program: Command, commandOptions: LinkCommandOptions, ): void { program .command("link") .description( "Link one file on this machine as an artifact that tracks the file.", ) .argument("", "file on this machine to link") .addOption(new Option("--project ", "project ID; optional when one active project exists")) .addOption(new Option("--name ", "name for the linked artifact")) .addOption( new Option("--server ", "Artifact Server origin") .env("ARTIFACT_SERVER_URL"), ) .addOption( new Option("--data ", "local Artifact Server data directory") .default(".artifact-server"), ) .addOption( new Option("--token-file ", "file containing an Artifact Server API token"), ) .addOption(new Option("--profile ", "saved Artifact Server profile")) .addOption( new Option("--profile-data ", "user-local CLI profile directory") .default(commandOptions.defaultProfileDirectory) .env("ARTIFACT_SERVER_HOME"), ) .action(async (inputPath: string, options: LinkOptions) => { // The server reads the file itself, so the path it receives must be // absolute on this machine; linking is a same-machine operation. const absolutePath = path.resolve(inputPath); const connection = await resolveCliServerConnection(options, "link"); const response = await fetch( new URL("/api/v1/artifacts/link", connection.origin), { body: JSON.stringify(linkRequestBody(absolutePath, options)), headers: { Authorization: `Bearer ${Redacted.value(connection.apiToken)}`, "Content-Type": "application/json", "Idempotency-Key": randomBytes(24).toString("base64url"), }, method: "POST", redirect: "error", }, ); if (response.status !== 201) throw await linkFailure(response); console.log(JSON.stringify( linkedPublicationSchema.parse(await response.json()), null, 2, )); }); } function linkRequestBody(absolutePath: string, options: LinkOptions) { const base = options.name === undefined ? {path: absolutePath} : {name: options.name, path: absolutePath}; return options.project === undefined ? base : {...base, projectId: options.project}; } async function linkFailure(response: Response): Promise { const failure = await readFailure(response); if (failure === null) { return new Error(`The link request failed (HTTP ${response.status}).`); } if (failure.error.code === "CAPABILITY_UNAVAILABLE") { return new Error(capabilityUnavailableExplanation); } return new Error( `${failure.error.code} (HTTP ${response.status}): ${failure.error.message}`, ); } async function readFailure( response: Response, ): Promise | null> { try { return failureSchema.parse(await response.json()); } catch { return null; } }