Path: blob/main/src/command/use/commands/brand.ts
6442 views
/*1* brand.ts2*3* Copyright (C) 2021-2025 Posit Software, PBC4*/56import {7ExtensionSource,8extensionSource,9} from "../../../extension/extension-host.ts";10import { info } from "../../../deno_ral/log.ts";11import { Confirm } from "cliffy/prompt/mod.ts";12import { basename, dirname, join, relative } from "../../../deno_ral/path.ts";13import { ensureDir, ensureDirSync, existsSync } from "../../../deno_ral/fs.ts";14import { TempContext } from "../../../core/temp-types.ts";15import { downloadWithProgress } from "../../../core/download.ts";16import { withSpinner } from "../../../core/console.ts";17import { unzip } from "../../../core/zip.ts";18import { Command } from "cliffy/command/mod.ts";19import { initYamlIntelligenceResourcesFromFilesystem } from "../../../core/schema/utils.ts";20import { createTempContext } from "../../../core/temp.ts";21import { InternalError } from "../../../core/lib/error.ts";22import { notebookContext } from "../../../render/notebook/notebook-context.ts";23import { projectContext } from "../../../project/project-context.ts";24import { afterConfirm } from "../../../tools/tools-console.ts";25import { readYaml } from "../../../core/yaml.ts";26import { Metadata } from "../../../config/types.ts";2728const kRootTemplateName = "template.qmd";2930// Brand extension detection result31interface BrandExtensionInfo {32isBrandExtension: boolean;33extensionDir?: string; // Directory containing the brand extension34brandFileName?: string; // The original brand file name (e.g., "brand.yml")35}3637// Check if a directory contains a brand extension38function checkForBrandExtension(dir: string): BrandExtensionInfo {39const extensionFiles = ["_extension.yml", "_extension.yaml"];4041for (const file of extensionFiles) {42const path = join(dir, file);43if (existsSync(path)) {44try {45const yaml = readYaml(path) as Metadata;46// Check for contributes.metadata.project.brand47const contributes = yaml?.contributes as Metadata | undefined;48const metadata = contributes?.metadata as Metadata | undefined;49const project = metadata?.project as Metadata | undefined;50const brandFile = project?.brand as string | undefined;5152if (brandFile && typeof brandFile === "string") {53return {54isBrandExtension: true,55extensionDir: dir,56brandFileName: brandFile,57};58}59} catch {60// If we can't read/parse the extension file, continue searching61}62}63}6465return { isBrandExtension: false };66}6768// Search for a brand extension in the staged directory69// Searches: root, _extensions/*, _extensions/*/*70function findBrandExtension(stagedDir: string): BrandExtensionInfo {71// First check the root directory72const rootCheck = checkForBrandExtension(stagedDir);73if (rootCheck.isBrandExtension) {74return rootCheck;75}7677// Check _extensions directory78const extensionsDir = join(stagedDir, "_extensions");79if (!existsSync(extensionsDir)) {80return { isBrandExtension: false };81}8283try {84// Check direct children: _extensions/extension-name/85for (const entry of Deno.readDirSync(extensionsDir)) {86if (!entry.isDirectory) continue;8788const extPath = join(extensionsDir, entry.name);89const check = checkForBrandExtension(extPath);90if (check.isBrandExtension) {91return check;92}9394// Check nested: _extensions/org/extension-name/95for (const nested of Deno.readDirSync(extPath)) {96if (!nested.isDirectory) continue;97const nestedPath = join(extPath, nested.name);98const nestedCheck = checkForBrandExtension(nestedPath);99if (nestedCheck.isBrandExtension) {100return nestedCheck;101}102}103}104} catch {105// Directory read error, return not found106}107108return { isBrandExtension: false };109}110111// Extract a path string from various formats:112// - string: "path/to/file"113// - object with path: { path: "path/to/file", alt: "..." }114function extractPath(value: unknown): string | undefined {115if (typeof value === "string") {116return value;117}118if (value && typeof value === "object" && "path" in value) {119const pathValue = (value as Record<string, unknown>).path;120if (typeof pathValue === "string") {121return pathValue;122}123}124return undefined;125}126127// Check if a path is a local file (not a URL)128function isLocalPath(path: string): boolean {129return !path.startsWith("http://") && !path.startsWith("https://");130}131132// Extract all referenced file paths from a brand YAML file133function extractBrandFilePaths(brandYamlPath: string): string[] {134const paths: string[] = [];135136try {137const yaml = readYaml(brandYamlPath) as Metadata;138if (!yaml) return paths;139140// Extract logo paths141const logo = yaml.logo as Metadata | undefined;142if (logo) {143// Handle logo.images (named resources)144// Format: logo.images.<name> can be string or { path, alt }145const images = logo.images as Metadata | undefined;146if (images && typeof images === "object") {147for (const value of Object.values(images)) {148const path = extractPath(value);149if (path && isLocalPath(path)) {150paths.push(path);151}152}153}154155// Handle logo.small, logo.medium, logo.large156// Format: string or { light: string, dark: string }157for (const size of ["small", "medium", "large"]) {158const sizeValue = logo[size];159if (!sizeValue) continue;160161if (typeof sizeValue === "string") {162if (isLocalPath(sizeValue)) {163paths.push(sizeValue);164}165} else if (typeof sizeValue === "object") {166// Handle { light: "...", dark: "..." }167const lightDark = sizeValue as Record<string, unknown>;168if (169typeof lightDark.light === "string" && isLocalPath(lightDark.light)170) {171paths.push(lightDark.light);172}173if (174typeof lightDark.dark === "string" && isLocalPath(lightDark.dark)175) {176paths.push(lightDark.dark);177}178}179}180}181182// Extract typography font file paths183const typography = yaml.typography as Metadata | undefined;184if (typography) {185const fonts = typography.fonts as unknown[] | undefined;186if (Array.isArray(fonts)) {187for (const font of fonts) {188if (!font || typeof font !== "object") continue;189const fontObj = font as Record<string, unknown>;190191// Only process fonts with source: "file"192if (fontObj.source !== "file") continue;193194const files = fontObj.files as unknown[] | undefined;195if (Array.isArray(files)) {196for (const file of files) {197const path = extractPath(file);198if (path && isLocalPath(path)) {199paths.push(path);200}201}202}203}204}205}206} catch {207// If we can't read/parse the brand file, return empty list208}209210return paths;211}212213export const useBrandCommand = new Command()214.name("brand")215.arguments("<target:string>")216.description(217"Use a brand for this project.",218)219.option(220"--force",221"Skip all prompts and confirmations",222)223.option(224"--dry-run",225"Show what would happen without making changes",226)227.example(228"Use a brand from Github",229"quarto use brand <gh-org>/<gh-repo>",230)231.action(232async (233options: { force?: boolean; dryRun?: boolean },234target: string,235) => {236if (options.force && options.dryRun) {237throw new Error("Cannot use --force and --dry-run together");238}239await initYamlIntelligenceResourcesFromFilesystem();240const temp = createTempContext();241try {242await useBrand(options, target, temp);243} finally {244temp.cleanup();245}246},247);248249async function useBrand(250options: { force?: boolean; dryRun?: boolean },251target: string,252tempContext: TempContext,253) {254// Print header for dry-run255if (options.dryRun) {256info("\nDry run - no changes will be made.");257}258259// Resolve brand host and trust260const source = await extensionSource(target);261// Is this source valid?262if (!source) {263info(264`Brand not found in local or remote sources`,265);266return;267}268269// Check trust (skip for dry-run or force)270if (!options.dryRun && !options.force) {271const trusted = await isTrusted(source);272if (!trusted) {273return;274}275}276277// Resolve brand directory278const brandDir = await ensureBrandDirectory(279options.force === true,280options.dryRun === true,281);282283// Extract and move the template into place284const stagedDir = await stageBrand(source, tempContext);285286// Check if this is a brand extension287const brandExtInfo = findBrandExtension(stagedDir);288289// Determine the actual source directory and file mapping290const sourceDir = brandExtInfo.isBrandExtension291? brandExtInfo.extensionDir!292: stagedDir;293294// Find the brand file295const brandFileName = brandExtInfo.isBrandExtension296? brandExtInfo.brandFileName!297: existsSync(join(sourceDir, "_brand.yml"))298? "_brand.yml"299: existsSync(join(sourceDir, "_brand.yaml"))300? "_brand.yaml"301: undefined;302303if (!brandFileName) {304info("No brand file (_brand.yml or _brand.yaml) found in source");305return;306}307308const brandFilePath = join(sourceDir, brandFileName);309// Get the directory containing the brand file (for resolving relative paths)310const brandFileDir = dirname(brandFilePath);311312// Extract referenced file paths from the brand YAML313const referencedPaths = extractBrandFilePaths(brandFilePath);314315// Build list of files to copy: brand file + referenced files316// Referenced paths are relative to the brand file's directory317const filesToCopy: string[] = [brandFilePath];318for (const refPath of referencedPaths) {319const fullPath = join(brandFileDir, refPath);320if (existsSync(fullPath)) {321filesToCopy.push(fullPath);322}323}324325// Confirm changes to brand directory (skip for dry-run or force)326if (!options.dryRun && !options.force) {327const filename = (typeof (source.resolvedTarget) === "string"328? source.resolvedTarget329: source.resolvedFile) || "brand.zip";330331const allowUse = await Confirm.prompt({332message: `Proceed with using brand ${filename}?`,333default: true,334});335if (!allowUse) {336return;337}338}339340if (!options.dryRun) {341info(342`\nPreparing brand files...`,343);344}345346// Build set of source file paths for comparison347// Paths are relative to the brand file's directory348// For brand extensions, the brand file is renamed to _brand.yml349const sourceFiles = new Set(350filesToCopy351.filter((f) => !Deno.statSync(f).isDirectory)352.map((f) => {353// If this is the brand file, it will become _brand.yml354if (f === brandFilePath) {355return "_brand.yml";356}357return relative(brandFileDir, f);358}),359);360361// Find extra files in target that aren't in source362const extraFiles = findExtraFiles(brandDir, sourceFiles);363364// Track files by action type365const wouldOverwrite: string[] = [];366const wouldCreate: string[] = [];367const wouldRemove: string[] = [];368const copyActions: Array<{369file: string;370action: "create" | "overwrite";371copy: () => Promise<void>;372}> = [];373let removed: string[] = [];374375for (const fileToCopy of filesToCopy) {376const isDir = Deno.statSync(fileToCopy).isDirectory;377if (isDir) {378continue;379}380381// Compute target path relative to brand file's directory382// The brand file itself is renamed to _brand.yml383let targetRel: string;384if (fileToCopy === brandFilePath) {385targetRel = "_brand.yml";386} else {387targetRel = relative(brandFileDir, fileToCopy);388}389390// Compute the paths391const targetPath = join(brandDir, targetRel);392const displayName = targetRel;393const targetDir = dirname(targetPath);394const copyAction = {395file: displayName,396copy: async () => {397// Ensure the directory exists398await ensureDir(targetDir);399400// Copy the file into place401await Deno.copyFile(fileToCopy, targetPath);402},403};404405if (existsSync(targetPath)) {406// File exists - will be overwritten407if (options.dryRun) {408wouldOverwrite.push(displayName);409} else if (!options.force) {410// Prompt for overwrite411const proceed = await Confirm.prompt({412message: `Overwrite file ${displayName}?`,413default: true,414});415if (proceed) {416copyActions.push({ ...copyAction, action: "overwrite" });417} else {418throw new Error(419`The file ${displayName} already exists and would be overwritten by this action.`,420);421}422} else {423// Force mode - overwrite without prompting424copyActions.push({ ...copyAction, action: "overwrite" });425}426} else {427// File doesn't exist - will be created428if (options.dryRun) {429wouldCreate.push(displayName);430} else {431copyActions.push({ ...copyAction, action: "create" });432}433}434}435436// Output dry-run summary and return437if (options.dryRun) {438if (wouldOverwrite.length > 0) {439info(`\nWould overwrite:`);440for (const file of wouldOverwrite) {441info(` - ${file}`);442}443}444if (wouldCreate.length > 0) {445info(`\nWould create:`);446for (const file of wouldCreate) {447info(` - ${file}`);448}449}450if (extraFiles.length > 0) {451info(`\nWould remove:`);452for (const file of extraFiles) {453info(` - ${file}`);454}455}456return;457}458459// Copy the files460if (copyActions.length > 0) {461await withSpinner({ message: "Copying files..." }, async () => {462for (const copyAction of copyActions) {463await copyAction.copy();464}465});466}467468// Handle extra files in target (not in source)469if (extraFiles.length > 0) {470const removeExtras = async () => {471for (const file of extraFiles) {472await Deno.remove(join(brandDir, file));473}474// Clean up empty directories475cleanupEmptyDirs(brandDir);476removed = extraFiles;477};478479if (options.force) {480await removeExtras();481} else {482// Show the files that would be removed483info(`\nExtra files not in source brand:`);484for (const file of extraFiles) {485info(` - ${file}`);486}487// Use afterConfirm pattern - declining doesn't cancel command488await afterConfirm(489`Remove these ${extraFiles.length} file(s)?`,490removeExtras,491);492}493}494495// Output summary of changes496const overwritten = copyActions.filter((a) => a.action === "overwrite");497const created = copyActions.filter((a) => a.action === "create");498if (overwritten.length > 0) {499info(`\nOverwritten:`);500for (const a of overwritten) {501info(` - ${a.file}`);502}503}504if (created.length > 0) {505info(`\nCreated:`);506for (const a of created) {507info(` - ${a.file}`);508}509}510if (removed.length > 0) {511info(`\nRemoved:`);512for (const file of removed) {513info(` - ${file}`);514}515}516}517518async function stageBrand(519source: ExtensionSource,520tempContext: TempContext,521) {522if (source.type === "remote") {523// A temporary working directory524const workingDir = tempContext.createDir();525526// Stages a remote file by downloading and unzipping it527const archiveDir = join(workingDir, "archive");528ensureDirSync(archiveDir);529530// The filename531const filename = (typeof (source.resolvedTarget) === "string"532? source.resolvedTarget533: source.resolvedFile) || "brand.zip";534535// The tarball path536const toFile = join(archiveDir, filename);537538// Download the file539await downloadWithProgress(source.resolvedTarget, `Downloading`, toFile);540541// Unzip and remove zip542await unzipInPlace(toFile);543544// Try to find the correct sub directory545if (source.targetSubdir) {546const sourceSubDir = join(archiveDir, source.targetSubdir);547if (existsSync(sourceSubDir)) {548return sourceSubDir;549}550}551552// Couldn't find a source sub dir, see if there is only a single553// subfolder and if so use that554const dirEntries = Deno.readDirSync(archiveDir);555let count = 0;556let name;557let hasFiles = false;558for (const dirEntry of dirEntries) {559// ignore any files560if (dirEntry.isDirectory) {561name = dirEntry.name;562count++;563} else {564hasFiles = true;565}566}567// there is a lone subfolder - use that.568if (!hasFiles && count === 1 && name) {569return join(archiveDir, name);570}571572return archiveDir;573} else {574if (typeof source.resolvedTarget !== "string") {575throw new InternalError(576"Local resolved extension should always have a string target.",577);578}579580if (Deno.statSync(source.resolvedTarget).isDirectory) {581// copy the contents of the directory, filtered by quartoignore582return source.resolvedTarget;583} else {584// A temporary working directory585const workingDir = tempContext.createDir();586const targetFile = join(workingDir, basename(source.resolvedTarget));587588// Copy the zip to the working dir589Deno.copyFileSync(590source.resolvedTarget,591targetFile,592);593594await unzipInPlace(targetFile);595return workingDir;596}597}598}599600// Determines whether the user trusts the brand601async function isTrusted(602source: ExtensionSource,603): Promise<boolean> {604if (source.type === "remote") {605// Write the preamble606const preamble =607`\nIf you do not trust the authors of the brand, we recommend that you do not install or use the brand.`;608info(preamble);609610// Ask for trust611const question = "Do you trust the authors of this brand";612const confirmed: boolean = await Confirm.prompt({613message: question,614default: true,615});616return confirmed;617} else {618return true;619}620}621622async function ensureBrandDirectory(force: boolean, dryRun: boolean) {623const currentDir = Deno.cwd();624const nbContext = notebookContext();625const project = await projectContext(currentDir, nbContext);626// Use project directory if available, otherwise fall back to current directory627// (single-file mode without _quarto.yml)628const baseDir = project?.dir ?? currentDir;629const brandDir = join(baseDir, "_brand");630if (!existsSync(brandDir)) {631if (dryRun) {632info(` Would create directory: _brand/`);633} else if (!force) {634// Prompt for confirmation635if (636!await Confirm.prompt({637message: `Create brand directory ${brandDir}?`,638default: true,639})640) {641throw new Error(`Could not create brand directory ${brandDir}`);642}643ensureDirSync(brandDir);644} else {645// Force mode - create without prompting646ensureDirSync(brandDir);647}648}649return brandDir;650}651652// Unpack and stage a zipped file653async function unzipInPlace(zipFile: string) {654// Unzip the file655await withSpinner(656{ message: "Unzipping" },657async () => {658// Unzip the archive659const result = await unzip(zipFile);660if (!result.success) {661throw new Error("Failed to unzip brand.\n" + result.stderr);662}663664// Remove the tar ball itself665await Deno.remove(zipFile);666667return Promise.resolve();668},669);670}671672// Find files in target directory that aren't in source673function findExtraFiles(674targetDir: string,675sourceFiles: Set<string>,676): string[] {677const extraFiles: string[] = [];678679function walkDir(dir: string, baseRel: string = "") {680if (!existsSync(dir)) return;681for (const entry of Deno.readDirSync(dir)) {682// Use join() for cross-platform path separator compatibility683// This matches the behavior of relative() used to build sourceFiles684const rel = baseRel ? join(baseRel, entry.name) : entry.name;685if (entry.isDirectory) {686walkDir(join(dir, entry.name), rel);687} else if (!sourceFiles.has(rel)) {688extraFiles.push(rel);689}690}691}692693walkDir(targetDir);694return extraFiles;695}696697// Clean up empty directories after file removal698function cleanupEmptyDirs(dir: string) {699if (!existsSync(dir)) return;700for (const entry of Deno.readDirSync(dir)) {701if (entry.isDirectory) {702const subdir = join(dir, entry.name);703cleanupEmptyDirs(subdir);704// Check if now empty705const contents = [...Deno.readDirSync(subdir)];706if (contents.length === 0) {707Deno.removeSync(subdir);708}709}710}711}712713714