diff --git a/deno.jsonc b/deno.jsonc index 7b6b69472b1..d25c7c8855c 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -651,5 +651,8 @@ "src/resources/projects/website/search/fuse.min.js", "src/resources/projects/website/search/quarto-search.js" ] + }, + "imports": { + "clipanion": "npm:clipanion@^4.0.0-rc.4" } } diff --git a/package/src/bld.ts b/package/src/bld.ts index 3e70da82dba..a230961fa9e 100644 --- a/package/src/bld.ts +++ b/package/src/bld.ts @@ -1,131 +1,74 @@ /* * package.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/mod.ts"; -import { packageCommand } from "./cmd/pkg-cmd.ts"; -import { configure } from "./common/configure.ts"; +import { Builtins, Cli } from "npm:clipanion"; +import { ConfigureCommand } from "./common/configure.ts"; import { mainRunner } from "../../src/core/main.ts"; +import { PackageCommand } from "./cmd/pkg-cmd.ts"; -import { prepareDist } from "./common/prepare-dist.ts"; -import { updateHtmlDependencies } from "./common/update-html-dependencies.ts"; -import { makeInstallerDeb } from "./linux/installer.ts"; -import { makeInstallerMac } from "./macos/installer.ts"; +import { PrepareDistCommand } from "./common/prepare-dist.ts"; +import { UpdateHTMLDependenciesCommand } from "./common/update-html-dependencies.ts"; +import { MakeInstallerDebCommand } from "./linux/installer.ts"; +import { MakeInstallerMacCommand } from "./macos/installer.ts"; import { - compileQuartoLatexmkCommand, + CompileQuartoLatexmkCommand, } from "./common/compile-quarto-latexmk.ts"; -import { makeInstallerWindows } from "./windows/installer.ts"; +import { MakeInstallerWindowsCommand } from "./windows/installer.ts"; -import { appendLogOptions } from "../../src/core/log.ts"; +import { addLoggingOptions } from "../../src/core/log.ts"; import { - cycleDependenciesCommand, - parseSwcLogCommand, + CycleDependenciesCommand, + ParseSwcLogCommand, } from "./common/cyclic-dependencies.ts"; import { - archiveBinaryDependencies, - checkBinaryDependencies, + ArchiveBinaryDependenciesCommand, + CheckBinaryDependenciesCommand, } from "./common/archive-binary-dependencies.ts"; -import { updatePandoc } from "./common/update-pandoc.ts"; -import { validateBundle } from "./common/validate-bundle.ts"; -import { makeInstallerExternal } from "./ext/installer.ts"; +import { UpdatePandocCommand } from "./common/update-pandoc.ts"; +import { ValidateBundleCommand } from "./common/validate-bundle.ts"; +import { MakeInstallerExternalCommand } from "./ext/installer.ts"; -// Core command dispatch -export async function quartoBld(args: string[]) { - const rootCommand = new Command() - .name("quarto-bld [command]") - .version("0.1") - .description( - "Utility that implements packaging and distribution of quarto cli", - ) - .option( - "-s, --signing-identity [id:string]", - "Signing identity to use when signing any files.", - { global: true }, - ) - .throwErrors(); +const commands: (typeof PackageCommand)[] = [ + ArchiveBinaryDependenciesCommand, + CheckBinaryDependenciesCommand, + CompileQuartoLatexmkCommand, + ConfigureCommand, + CycleDependenciesCommand, + MakeInstallerDebCommand, + MakeInstallerExternalCommand, + MakeInstallerMacCommand, + MakeInstallerWindowsCommand, + ParseSwcLogCommand, + PrepareDistCommand, + UpdateHTMLDependenciesCommand, + UpdatePandocCommand, + ValidateBundleCommand, +] - getCommands().forEach((command) => { - rootCommand.command(command.getName(), appendLogOptions(command)); - }); - await rootCommand.parse(args); -} +class QuartoBld extends Cli { + constructor() { + super({ + binaryLabel: "Utility that implements packaging and distribution of quarto cli", + binaryName: 'quarto-bld', + binaryVersion: "0.1", + }); -if (import.meta.main) { - await mainRunner(() => quartoBld(Deno.args)); + [ + ...commands, + Builtins.HelpCommand + ].forEach((command) => { + addLoggingOptions(command); + this.register(command); + }); + } } -// Supported package commands -function getCommands() { - // deno-lint-ignore no-explicit-any - const commands: Command[] = []; - commands.push( - packageCommand(configure) - .name("configure") - .description( - "Configures this machine for running developer version of Quarto", - ), - ); - commands.push( - packageCommand(updateHtmlDependencies) - .name("update-html-dependencies") - .description( - "Updates Bootstrap, themes, and JS/CSS dependencies based upon the version in configuration", - ), - ); - commands.push( - packageCommand(archiveBinaryDependencies) - .name("archive-bin-deps") - .description("Downloads and archives our binary dependencies."), - ); - commands.push( - packageCommand(checkBinaryDependencies) - .name("check-bin-deps") - .description("Checks the paths and URLs of our binary dependencies."), - ); - commands.push( - packageCommand(prepareDist) - .name("prepare-dist") - .description("Prepares the distribution directory for packaging."), - ); - commands.push( - packageCommand(validateBundle) - .name("validate-bundle") - .description("Validate a JS bundle built using prepare-dist") - ); - commands.push( - packageCommand(makeInstallerMac) - .name("make-installer-mac") - .description("Builds Mac OS installer"), - ); - commands.push( - packageCommand(makeInstallerDeb) - .name("make-installer-deb") - .description("Builds Linux deb installer"), - ); - commands.push( - packageCommand(makeInstallerWindows) - .name("make-installer-win") - .description("Builds Windows installer"), - ); - commands.push( - packageCommand(makeInstallerExternal) - .name("make-installer-dir") - .description("Copies Quarto-only files, omitting dependencies, to specified location (for use in third party packaging)"), - ); - commands.push( - compileQuartoLatexmkCommand(), - ); - commands.push( - cycleDependenciesCommand(), - ); - commands.push( - parseSwcLogCommand(), - ); - commands.push( - updatePandoc(), - ); - - return commands; +if (import.meta.main) { + await mainRunner(async () => { + const quartoBld = new QuartoBld(); + await quartoBld.runExit(Deno.args); + }); } diff --git a/package/src/cmd/pkg-cmd.ts b/package/src/cmd/pkg-cmd.ts index da91df980e6..77f6c87d164 100644 --- a/package/src/cmd/pkg-cmd.ts +++ b/package/src/cmd/pkg-cmd.ts @@ -1,57 +1,40 @@ /* * pkg-cmd.ts * -* Copyright (C) 2020-2022 Posit Software, PBC +* Copyright (C) 2020-2024 Posit Software, PBC * */ -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { join } from "../../../src/deno_ral/path.ts"; import { printConfiguration } from "../common/config.ts"; import { - Configuration, kValidArch, kValidOS, readConfiguration, } from "../common/config.ts"; -export const kLogLevel = "logLevel"; -export const kVersion = "setVersion"; +export abstract class PackageCommand extends Command { + arch = Option.String("-a,--arch", {description: "Architecture for this command (" + kValidArch.join(", ") + ")"}); + os = Option.String("-o,--os", {description: "Operating system for this command (" + kValidOS.join(", ") + ")"}); + version = Option.String("-sv,--set-version", {description: "Version to set when preparing this distribution"}); -export function packageCommand(run: (config: Configuration) => Promise) { - return new Command().option( - "-sv, --set-version [version:string]", - "Version to set when preparing this distribution", - ).option( - "-o, --os [os:string]", - "Operating system for this command (" + kValidOS.join(", ") + ")", - ) - .option( - "-a, --arch [arch:string]", - "Architecture for this command (" + kValidArch.join(", ") + ")", - ) - // deno-lint-ignore no-explicit-any - .action(async (args: Record) => { - const version = args[kVersion]; - const os = args["os"]; - const arch = args["arch"]; + get config() { + return readConfiguration(this.version, this.os, this.arch); + } - // Read the version and configuration - const config = readConfiguration(version, os, arch); - - // Set up the bin and share environment for any downstream code - Deno.env.set("QUARTO_BIN_PATH", config.directoryInfo.bin); - Deno.env.set( + async execute() { + // Set up the bin and share environment for any downstream code + const { directoryInfo } = this.config; + Deno.env.set("QUARTO_BIN_PATH", directoryInfo.bin); + Deno.env.set( "QUARTO_SHARE_PATH", - join(config.directoryInfo.src, "resources"), - ); - Deno.env.set("QUARTO_DEBUG", "true"); - - // Print the configuration - printConfiguration(config); + join(directoryInfo.src, "resources"), + ); + Deno.env.set("QUARTO_DEBUG", "true"); - // Run the command - await run(config); - }); + // Print the configuration + printConfiguration(this.config); + } } diff --git a/package/src/common/archive-binary-dependencies.ts b/package/src/common/archive-binary-dependencies.ts index 8082e6e41b3..70b8cf6cfe2 100644 --- a/package/src/common/archive-binary-dependencies.ts +++ b/package/src/common/archive-binary-dependencies.ts @@ -1,10 +1,11 @@ /* * archive-binary-dependencies.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ import { join } from "../../../src/deno_ral/path.ts"; import { info } from "../../../src/deno_ral/log.ts"; +import { Command } from "npm:clipanion"; import { execProcess } from "../../../src/core/process.ts"; import { Configuration, withWorkingDir } from "./config.ts"; @@ -14,6 +15,7 @@ import { kDependencies, PlatformDependency, } from "./dependencies/dependencies.ts"; +import { PackageCommand } from "../cmd/pkg-cmd.ts"; const kBucket = "s3://rstudio-buildtools/quarto"; const kBucketBaseUrl = "https://s3.amazonaws.com/rstudio-buildtools/quarto"; @@ -197,3 +199,29 @@ async function download( ); } } + +export class ArchiveBinaryDependenciesCommand extends PackageCommand { + static paths = [["archive-bin-deps"]]; + + static usage = Command.Usage({ + description: "Downloads and archives our binary dependencies.", + }); + + async execute() { + await super.execute(); + await archiveBinaryDependencies(this.config) + } +} + +export class CheckBinaryDependenciesCommand extends PackageCommand { + static paths = [["check-bin-deps"]]; + + static usage = Command.Usage({ + description: "Checks the paths and URLs of our binary dependencies.", + }); + + async execute() { + await super.execute(); + await checkBinaryDependencies(this.config) + } +} diff --git a/package/src/common/compile-quarto-latexmk.ts b/package/src/common/compile-quarto-latexmk.ts index 7fb7d052d2f..92d06e6057f 100644 --- a/package/src/common/compile-quarto-latexmk.ts +++ b/package/src/common/compile-quarto-latexmk.ts @@ -1,71 +1,56 @@ /* * compile-quarto-latexmk.ts * -* Copyright (C) 2020-2022 Posit Software, PBC +* Copyright (C) 2020-2024 Posit Software, PBC * */ -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { basename, join } from "../../../src/deno_ral/path.ts"; import { ensureDirSync } from "../../../src/deno_ral/fs.ts"; import { info } from "../../../src/deno_ral/log.ts"; -import { Configuration, readConfiguration } from "../common/config.ts"; +import { Configuration, readConfiguration } from "./config.ts"; import { compile, install, updateDenoPath } from "../util/deno.ts"; -export function compileQuartoLatexmkCommand() { - return new Command() - .name("compile-quarto-latexmk") - .description("Builds binary for quarto-latexmk") - .option( - "-d, --development", - "Install for local development", - ) - .option( - "-t, --target ", - "The target architecture for the binary (e.g. x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc, x86_64-apple-darwin, aarch64-apple-darwin)", - { - collect: true, - }, - ) - .option( - "-v, --version ", - "The version number of the compiled executable", - ) - .option( - "-n, --name ", - "The name of the compiled executable", - ) - .option( - "--description ", - "The description of the compiled executable", - ) - // deno-lint-ignore no-explicit-any - .action(async (args: Record) => { - const configuration = readConfiguration(); - info("Using configuration:"); - info(configuration); - info(""); - - if (args.development) { - await installQuartoLatexmk(configuration); - } else { - const description = Array.isArray(args.description) - ? args.description.join(" ") - : args.description || "Quarto Latexmk Engine"; - - const version = args.version || configuration.version; - const name = args.name || "quarto-latexmk"; - const targets = (args.targets || [Deno.build.target]) as string[]; - - await compileQuartoLatexmk( - configuration, - targets, - version, - name, - description, - ); - } - }); +export class CompileQuartoLatexmkCommand extends Command { + static paths = [["compile-quarto-latexmk"]]; + + static usage = Command.Usage({ + description: "Builds binary for quarto-latexmk", + }); + + description = Option.String("--description", {description: "The description of the compiled executable"}); + development = Option.Boolean("-d,--development", {description: "Install for local development"}); + name = Option.String("-n,--name", {description: "The name of the compiled executable"}); + targets = Option.Array("-t,--target", {description: "The target architecture for the binary (e.g. x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc, x86_64-apple-darwin, aarch64-apple-darwin)"}); + version = Option.String("-v,--version", {description: "The version number of the compiled executable"}); + + async execute() { + const configuration = readConfiguration(); + info("Using configuration:"); + info(configuration); + info(""); + + if (this.development) { + await installQuartoLatexmk(configuration); + } else { + const description = Array.isArray(this.description) + ? this.description.join(" ") + : this.description || "Quarto Latexmk Engine"; + + const version = this.version || configuration.version; + const name = this.name || "quarto-latexmk"; + const targets = (this.targets || [Deno.build.target]) as string[]; + + await compileQuartoLatexmk( + configuration, + targets, + version, + name, + description, + ); + } + } } const kFlags = [ diff --git a/package/src/common/configure.ts b/package/src/common/configure.ts index 8e2b4fa7e74..28e8445b769 100644 --- a/package/src/common/configure.ts +++ b/package/src/common/configure.ts @@ -1,11 +1,12 @@ /* * dependencies.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ import { dirname, join, SEP } from "../../../src/deno_ral/path.ts"; import { existsSync, ensureDirSync } from "../../../src/deno_ral/fs.ts"; import { info, warning } from "../../../src/deno_ral/log.ts"; +import { Command } from "npm:clipanion"; import { expandPath } from "../../../src/core/path.ts"; import { @@ -20,6 +21,7 @@ import { } from "./dependencies/dependencies.ts"; import { suggestUserBinPaths } from "../../../src/core/path.ts"; import { buildQuartoPreviewJs } from "../../../src/core/previewjs.ts"; +import { PackageCommand } from "../cmd/pkg-cmd.ts"; export async function configure( config: Configuration, @@ -169,21 +171,15 @@ export function copyPandocScript(config: Configuration, targetDir: string) { } } -export function copyPandocAliasScript(config: Configuration, toolsDir: string) { - // Move the quarto script into place - if (config.os === "darwin") { - const out = join(toolsDir, "pandoc"); - Deno.copyFileSync( - join(config.directoryInfo.pkg, "scripts", "macos", "pandoc"), - out, - ); - Deno.chmodSync(out, 0o755); - } else if (config.os === "linux") { - const out = join(toolsDir, "pandoc"); - Deno.copyFileSync( - join(config.directoryInfo.pkg, "scripts", "linux", "pandoc"), - out, - ); - Deno.chmodSync(out, 0o755); - } +export class ConfigureCommand extends PackageCommand { + static paths = [["configure"]]; + + static usage = Command.Usage({ + description: "Configures this machine for running developer version of Quarto", + }); + + async execute() { + await super.execute(); + await configure(this.config) + } } diff --git a/package/src/common/cyclic-dependencies.ts b/package/src/common/cyclic-dependencies.ts index 321cc72e595..afe46eb2063 100644 --- a/package/src/common/cyclic-dependencies.ts +++ b/package/src/common/cyclic-dependencies.ts @@ -1,11 +1,11 @@ /* * cyclic-dependencies.ts * -* Copyright (C) 2020-2022 Posit Software, PBC +* Copyright (C) 2020-2024 Posit Software, PBC * */ import { basename, isAbsolute, join } from "../../../src/deno_ral/path.ts"; -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { runCmd } from "../util/cmd.ts"; import { Configuration, readConfiguration } from "./config.ts"; @@ -13,48 +13,41 @@ import { error, info } from "../../../src/deno_ral/log.ts"; import { progressBar } from "../../../src/core/console.ts"; import { md5Hash } from "../../../src/core/hash.ts"; -export function cycleDependenciesCommand() { - return new Command() - .name("cycle-dependencies") - .description( - "Debugging tool for helping discover cyclic dependencies in quarto", - ) - .option( - "-o, --output", - "Path to write json output", - ) - // deno-lint-ignore no-explicit-any - .action(async (args: Record) => { - const configuration = readConfiguration(); - info("Using configuration:"); - info(configuration); - info(""); - await cyclicDependencies(args.output as string, configuration); - }); +export class CycleDependenciesCommand extends Command { + static paths = [["cycle-dependencies"]]; + + static usage = Command.Usage({ + description: "Debugging tool for helping discover cyclic dependencies in quarto", + }); + + output = Option.String("-o,--output", {description: "Path to write json output"}); + + async execute() { + const configuration = readConfiguration(); + info("Using configuration:"); + info(configuration); + info(""); + await cyclicDependencies(this.output, configuration); + } } -export function parseSwcLogCommand() { - return new Command() - .name("parse-swc-log") - .description( - "Parses SWC bundler debug log to discover cyclic dependencies in quarto", - ) - .option( - "-i, --input", - "Path to text file containing swc bundler debug output", - ) - .option( - "-o, --output", - "Path to write json output", - ) - // deno-lint-ignore no-explicit-any - .action((args: Record) => { - const configuration = readConfiguration(); - info("Using configuration:"); - info(configuration); - info(""); - parseSwcBundlerLog(args.input, args.output, configuration); - }); +export class ParseSwcLogCommand extends Command { + static paths = [["parse-swc-log"]]; + + static usage = Command.Usage({ + description: "Parses SWC bundler debug log to discover cyclic dependencies in quarto", + }); + + input = Option.String("-i,--input", {description: "Path to text file containing swc bundler debug output"}); + output = Option.String("-o,--output", {description: "Path to write json output"}); + + async execute() { + const configuration = readConfiguration(); + info("Using configuration:"); + info(configuration); + info(""); + parseSwcBundlerLog(this.input, this.output, configuration); + } } export async function cyclicDependencies( diff --git a/package/src/common/prepare-dist.ts b/package/src/common/prepare-dist.ts index 1a1fef70ea4..c4d661aa6de 100755 --- a/package/src/common/prepare-dist.ts +++ b/package/src/common/prepare-dist.ts @@ -1,14 +1,15 @@ /* * prepare-dist.ts * -* Copyright (C) 2020-2022 Posit Software, PBC +* Copyright (C) 2020-2024 Posit Software, PBC * */ import { dirname, join } from "../../../src/deno_ral/path.ts"; import { copySync, ensureDirSync, existsSync } from "../../../src/deno_ral/fs.ts"; +import { Command } from "npm:clipanion"; -import { Configuration } from "../common/config.ts"; +import { Configuration } from "./config.ts"; import { buildFilter } from "./package-filters.ts"; import { bundle } from "../util/deno.ts"; import { info } from "../../../src/deno_ral/log.ts"; @@ -22,6 +23,7 @@ Dependency, import { copyQuartoScript } from "./configure.ts"; import { deno } from "./dependencies/deno.ts"; import { buildQuartoPreviewJs } from "../../../src/core/previewjs.ts"; +import { PackageCommand } from "../cmd/pkg-cmd.ts"; export async function prepareDist( config: Configuration, @@ -266,3 +268,16 @@ function inlineFilters(config: Configuration) { } + +export class PrepareDistCommand extends PackageCommand { + static paths = [["prepare-dist"]]; + + static usage = Command.Usage({ + description: "Prepares the distribution directory for packaging.", + }); + + async execute() { + await super.execute(); + await prepareDist(this.config) + } +} diff --git a/package/src/common/update-html-dependencies.ts b/package/src/common/update-html-dependencies.ts index 49dcc148877..ec20a3eb0a8 100644 --- a/package/src/common/update-html-dependencies.ts +++ b/package/src/common/update-html-dependencies.ts @@ -1,13 +1,13 @@ /* * bootstrap.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ import { copySync, ensureDir, ensureDirSync, existsSync, walkSync } from "../../../src/deno_ral/fs.ts"; import { info } from "../../../src/deno_ral/log.ts"; import { dirname, basename, extname, join } from "../../../src/deno_ral/path.ts"; import { lines } from "../../../src/core/text.ts"; -import * as ld from "../../../src/core/lodash.ts"; +import { Command } from "npm:clipanion"; import { runCmd } from "../util/cmd.ts"; import { applyGitPatches, Repo, withRepo } from "../util/git.ts"; @@ -18,6 +18,7 @@ import { visitLines } from "../../../src/core/file.ts"; import { copyTo } from "../../../src/core/copy.ts"; import { kSourceMappingRegexes } from "../../../src/config/constants.ts"; import { unzip } from "../../../src/core/zip.ts"; +import { PackageCommand } from "../cmd/pkg-cmd.ts"; export async function updateHtmlDependencies(config: Configuration) { info("Updating Bootstrap with version info:"); @@ -224,7 +225,7 @@ export async function updateHtmlDependencies(config: Configuration) { // Glightbox const glightboxDir = join(formatDir, "glightbox"); - const glightBoxVersion = Deno.env.get("GLIGHTBOX_JS");; + const glightBoxVersion = Deno.env.get("GLIGHTBOX_JS"); info("Updating glightbox"); const fileName = `glightbox-master.zip`; @@ -660,8 +661,8 @@ async function updateDatatables( // pdfmake // https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/pdfmake.min.js // https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/vfs_fonts.js - const datatablesConfig = Deno.env.get("DATATABLES_CONFIG");; - const pdfMakeVersion = Deno.env.get("PDF_MAKE");; + const datatablesConfig = Deno.env.get("DATATABLES_CONFIG"); + const pdfMakeVersion = Deno.env.get("PDF_MAKE"); const dtFiles = ["datatables.min.css", "datatables.min.js"]; const targetDir = join( config.directoryInfo.src, @@ -1365,3 +1366,16 @@ revealjsThemePatches["solarized"] = createRevealjsThemePatches(["mainColor", "he revealjsThemePatches["white-contrast"] = createRevealjsThemePatches(["backgroundColor", "mainColor", "headingColor", "mainFontSize", "mainFont", "headingFont", "headingTextShadow", "headingLetterSpacing", "headingTextTransform", "headingFontWeight", "linkColor", "linkColorHover", "selectionBackgroundColor", "heading1Size", "heading2Size", "heading3Size", "heading4Size"]) revealjsThemePatches["white"] = createRevealjsThemePatches(["backgroundColor", "mainColor", "headingColor", "mainFontSize", "mainFont", "headingFont", "headingTextShadow", "headingLetterSpacing", "headingTextTransform", "headingFontWeight", "linkColor", "linkColorHover", "selectionBackgroundColor", "heading1Size", "heading2Size", "heading3Size", "heading4Size"]) revealjsThemePatches["settings"] = createRevealjsThemePatches(["backgroundColor", "mainFont", "mainFontSize", "mainColor", "blockMargin", "headingFont", "headingColor", "headingLineHeight", "headingLetterSpacing", "headingTextTransform", "headingTextShadow", "headingFontWeight", "heading1TextShadow", "heading1Size", "heading2Size", "heading3Size", "heading4Size", "codeFont", "linkColor", "linkColorHover", "selectionBackgroundColor", "selectionColor"]) + +export class UpdateHTMLDependenciesCommand extends PackageCommand { + static paths = [["update-html-dependencies"]]; + + static usage = Command.Usage({ + description: "Updates Bootstrap, themes, and JS/CSS dependencies based upon the version in configuration", + }); + + async execute() { + await super.execute(); + await updateHtmlDependencies(this.config) + } +} diff --git a/package/src/common/update-pandoc.ts b/package/src/common/update-pandoc.ts index 1447e01058c..82e3650505b 100644 --- a/package/src/common/update-pandoc.ts +++ b/package/src/common/update-pandoc.ts @@ -1,10 +1,10 @@ /* * update-pandoc.ts * -* Copyright (C) 2020-2022 Posit Software, PBC +* Copyright (C) 2020-2024 Posit Software, PBC * */ -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { join } from "../../../src/deno_ral/path.ts"; import { ensureDirSync } from "../../../src/deno_ral/fs.ts"; import { info } from "../../../src/deno_ral/log.ts"; @@ -13,7 +13,7 @@ import { Configuration, readConfiguration, withWorkingDir, -} from "../common/config.ts"; +} from "./config.ts"; import { lines } from "../../../src/core/text.ts"; import { pandoc } from "./dependencies/pandoc.ts"; import { archiveBinaryDependency } from "./archive-binary-dependencies.ts"; @@ -33,12 +33,17 @@ import { import * as ld from "../../../src/core/lodash.ts"; -export function updatePandoc() { - return new Command() - .name("update-pandoc") - .arguments("") - .description("Updates Pandoc to the specified version") - .action(async (_args, version: string) => { +export class UpdatePandocCommand extends Command { + static paths = [["update-pandoc"]]; + + static usage = Command.Usage({ + description: "Updates Pandoc to the specified version", + }); + + version = Option.String(); + + async execute() { + const { version } = this; info(`Updating Pandoc to ${version}`); const configuration = readConfiguration(); @@ -85,7 +90,7 @@ export function updatePandoc() { console.log(bgBlack(brightWhite(bold( "\n** Remember to complete the checklist in /dev-docs/update-pandoc-checklist.md! **", )))); - }); + } } // Starting in Pandoc 3, we saw a number of variants that appear to be supported diff --git a/package/src/common/validate-bundle.ts b/package/src/common/validate-bundle.ts index 8c757c532de..b9e1a949c3e 100644 --- a/package/src/common/validate-bundle.ts +++ b/package/src/common/validate-bundle.ts @@ -1,15 +1,15 @@ /* * prepare-dist.ts * -* Copyright (C) 2020-2022 Posit Software, PBC +* Copyright (C) 2020-2024 Posit Software, PBC * */ - - import { join } from "../../../src/deno_ral/path.ts"; import { info } from "../../../src/deno_ral/log.ts"; -import { Configuration } from "../common/config.ts"; +import { Configuration } from "./config.ts"; import { execProcess } from "../../../src/core/process.ts"; +import { PackageCommand } from "../cmd/pkg-cmd.ts"; +import { Command } from "npm:clipanion"; export async function validateBundle( config: Configuration, @@ -69,3 +69,16 @@ export async function validateBundle( } } + +export class ValidateBundleCommand extends PackageCommand { + static paths = [["validate-bundle"]]; + + static usage = Command.Usage({ + description: "Validate a JS bundle built using prepare-dist", + }); + + async execute() { + await super.execute(); + await validateBundle(this.config) + } +} diff --git a/package/src/ext/installer.ts b/package/src/ext/installer.ts index c3649d7267a..0fce7057255 100644 --- a/package/src/ext/installer.ts +++ b/package/src/ext/installer.ts @@ -1,15 +1,17 @@ /* * installer.ts * -* Copyright (C) 2020-2022 Posit Software, PBC +* Copyright (C) 2020-2024 Posit Software, PBC * */ import { join } from "../../../src/deno_ral/path.ts"; import { copySync } from "../../../src/deno_ral/fs.ts"; import { info } from "../../../src/deno_ral/log.ts"; +import { Command } from "npm:clipanion"; import { Configuration } from "../common/config.ts"; +import { PackageCommand } from "../cmd/pkg-cmd.ts"; export async function makeInstallerExternal( configuration: Configuration, @@ -29,3 +31,16 @@ export async function makeInstallerExternal( overwrite: true, }); } + +export class MakeInstallerExternalCommand extends PackageCommand { + static paths = [["make-installer-dir"]]; + + static usage = Command.Usage({ + description: "Copies Quarto-only files, omitting dependencies, to specified location (for use in third party packaging)", + }); + + async execute() { + await super.execute(); + await makeInstallerExternal(this.config) + } +} diff --git a/package/src/linux/installer.ts b/package/src/linux/installer.ts index fa0e6e0d90c..9a4f72269da 100644 --- a/package/src/linux/installer.ts +++ b/package/src/linux/installer.ts @@ -1,15 +1,17 @@ /* * installer.ts * -* Copyright (C) 2020-2022 Posit Software, PBC +* Copyright (C) 2020-2024 Posit Software, PBC * */ import { join } from "../../../src/deno_ral/path.ts"; import { copySync, emptyDirSync, ensureDirSync, walk } from "../../../src/deno_ral/fs.ts"; import { info } from "../../../src/deno_ral/log.ts"; +import { Command } from "npm:clipanion"; import { Configuration } from "../common/config.ts"; import { runCmd } from "../util/cmd.ts"; +import { PackageCommand } from "../cmd/pkg-cmd.ts"; export async function makeInstallerDeb( configuration: Configuration, @@ -136,3 +138,16 @@ export async function makeInstallerDeb( // Remove the working directory // Deno.removeSync(workingDir, { recursive: true }); } + +export class MakeInstallerDebCommand extends PackageCommand { + static paths = [["make-installer-deb"]]; + + static usage = Command.Usage({ + description: "Builds Linux deb installer", + }); + + async execute() { + await super.execute(); + await makeInstallerDeb(this.config) + } +} diff --git a/package/src/macos/installer.ts b/package/src/macos/installer.ts index ca80ced0056..8f4ccc24737 100644 --- a/package/src/macos/installer.ts +++ b/package/src/macos/installer.ts @@ -1,7 +1,7 @@ /* * installer.ts * -* Copyright (C) 2020-2022 Posit Software, PBC +* Copyright (C) 2020-2024 Posit Software, PBC * */ @@ -13,19 +13,13 @@ import { dirname, join } from "../../../src/deno_ral/path.ts"; import { ensureDirSync, existsSync } from "../../../src/deno_ral/fs.ts"; import { error, info, warning } from "../../../src/deno_ral/log.ts"; +import { Command } from "npm:clipanion"; import { Configuration } from "../common/config.ts"; import { runCmd } from "../util/cmd.ts"; import { getEnv } from "../util/utils.ts"; import { makeTarball } from "../util/tar.ts"; - -// Packaging specific configuration -// (Some things are global others may be platform specific) -export interface PackageInfo { - name: string; - identifier: string; - packageArgs: () => string[]; -} +import { PackageCommand } from "../cmd/pkg-cmd.ts"; export async function makeInstallerMac(config: Configuration) { // Core package @@ -262,13 +256,6 @@ export async function makeInstallerMac(config: Configuration) { } } -// https://deno.com/blog/v1.23#remove-unstable-denosleepsync-api -function sleepSync(timeout: number) { - const sab = new SharedArrayBuffer(1024); - const int32 = new Int32Array(sab); - Atomics.wait(int32, 0, 0, timeout); -} - async function signPackage( developerId: string, input: string, @@ -347,71 +334,22 @@ async function notarizeAndWait( } } -async function waitForNotaryStatus( - requestId: string, - username: string, - password: string, -) { - const starttime = Date.now(); - - // 20 minutes - const msToWait = 1200000; - - const pollIntervalSeconds = 15; - - let errorCount = 0; - let notaryResult = undefined; - while (notaryResult == undefined) { - const result = await runCmd( - "xcrun", - [ - "altool", - "--notarization-info", - requestId, - "--username", - username, - "--password", - password, - ], - ); - - const match = result.stdout.match(/Status: (.*)\n/); - if (match) { - const status = match[1]; - if (status === "in progress") { - // Successful status means reset error counter - errorCount = 0; - - // Sleep for 15 seconds between checks - await new Promise((resolve) => - setTimeout(resolve, pollIntervalSeconds * 1000) - ); - } else if (status === "success") { - notaryResult = "Success"; - } else { - if (errorCount > 5) { - error(result.stderr); - throw new Error("Failed to Notarize - " + status); - } - - //increment error counter - errorCount = errorCount + 1; - } - } - if (Date.now() - starttime > msToWait) { - throw new Error( - `Failed to Notarize - timed out after ${ - msToWait / 1000 - } seconds when awaiting notarization`, - ); - } - } - return notaryResult; -} - async function stapleNotary(input: string) { await runCmd( "xcrun", ["stapler", "staple", input], ); } + +export class MakeInstallerMacCommand extends PackageCommand { + static paths = [["make-installer-mac"]]; + + static usage = Command.Usage({ + description: "Builds Mac OS installer", + }); + + async execute() { + await super.execute(); + await makeInstallerMac(this.config) + } +} diff --git a/package/src/windows/installer.ts b/package/src/windows/installer.ts index 33aa941b3e8..40d815431c0 100644 --- a/package/src/windows/installer.ts +++ b/package/src/windows/installer.ts @@ -1,11 +1,19 @@ +/* +* installer.ts +* +* Copyright (C) 2020-2024 Posit Software, PBC +* +*/ import { info } from "../../../src/deno_ral/log.ts"; import { basename, dirname, join } from "../../../src/deno_ral/path.ts"; +import { Command } from "npm:clipanion"; import { Configuration } from "../common/config.ts"; import { runCmd } from "../util/cmd.ts"; import { download, unzip } from "../util/utils.ts"; import { execProcess } from "../../../src/core/process.ts"; import { emptyDirSync, ensureDirSync, existsSync, moveSync, copySync } from "../../../src/deno_ral/fs.ts"; +import { PackageCommand } from "../cmd/pkg-cmd.ts"; export async function makeInstallerWindows(configuration: Configuration) { const packageName = `quarto-${configuration.version}-win.msi`; @@ -174,3 +182,16 @@ export function zip(input: string, output: string) { }, ); } + +export class MakeInstallerWindowsCommand extends PackageCommand { + static paths = [["make-installer-mac"]]; + + static usage = Command.Usage({ + description: "Builds Mac OS installer", + }); + + async execute() { + await super.execute(); + await makeInstallerWindows(this.config) + } +} diff --git a/src/command/add/cmd.ts b/src/command/add/cmd.ts index ab21697b48a..251ca273386 100644 --- a/src/command/add/cmd.ts +++ b/src/command/add/cmd.ts @@ -1,62 +1,57 @@ /* * cmd.ts * - * Copyright (C) 2021-2022 Posit Software, PBC + * Copyright (C) 2021-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; + import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/utils.ts"; import { createTempContext } from "../../core/temp.ts"; import { installExtension } from "../../extension/install.ts"; import { info } from "../../deno_ral/log.ts"; -export const addCommand = new Command() - .name("add") - .arguments("") - .option( - "--no-prompt", - "Do not prompt to confirm actions", - ) - .option( - "--embed ", - "Embed this extension within another extension (used when authoring extensions).", - ) - .description( - "Add an extension to this folder or project", - ) - .example( - "Install extension (Github)", - "quarto add /", - ) - .example( - "Install extension (file)", - "quarto add ", - ) - .example( - "Install extension (url)", - "quarto add ", - ) - .action( - async ( - options: { prompt?: boolean; embed?: string; updatePath?: boolean }, - extension: string, - ) => { - await initYamlIntelligenceResourcesFromFilesystem(); - const temp = createTempContext(); - try { - // Install an extension - if (extension) { - await installExtension( - extension, +export class AddCommand extends Command { + static name = 'add'; + static paths = [[AddCommand.name]]; + + static usage = Command.Usage({ + description: "Add an extension to this folder or project", + examples: [ + [ + "Install extension (Github)", + `$0 ${AddCommand.name} /`, + ], [ + "Install extension (file)", + `$0 ${AddCommand.name} `, + ], [ + "Install extension (url)", + `$0 ${AddCommand.name} `, + ] + ] + }); + + extension = Option.String({ required: true }); + + embed = Option.String('--embed', {description: "Embed this extension within another extension (used when authoring extensions)."}); + noPrompt = Option.Boolean('--no-prompt', {description: "Do not prompt to confirm actions"}); + + async execute() { + await initYamlIntelligenceResourcesFromFilesystem(); + const temp = createTempContext(); + try { + if (this.extension) { + await installExtension( + this.extension, temp, - options.prompt !== false, - options.embed, - ); - } else { - info("Please provide an extension name, url, or path."); - } - } finally { - temp.cleanup(); + !this.noPrompt, + this.embed, + ); + } else { + info("Please provide an extension name, url, or path."); } - }, - ); + } finally { + temp.cleanup(); + } + } +} diff --git a/src/command/build-js/cmd.ts b/src/command/build-js/cmd.ts index 726f6b4b740..3b3ac843252 100644 --- a/src/command/build-js/cmd.ts +++ b/src/command/build-js/cmd.ts @@ -1,10 +1,10 @@ /* * cmd.ts * - * Copyright (C) 2021-2022 Posit Software, PBC + * Copyright (C) 2021-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/mod.ts"; +import { Command } from "npm:clipanion"; import { esbuildCompile } from "../../core/esbuild.ts"; import { buildIntelligenceResources } from "../../core/schema/build-schema-file.ts"; @@ -133,13 +133,17 @@ export async function buildAssets() { await buildYAMLJS(); } -export const buildJsCommand = new Command() - .name("build-js") - .hidden() - .description( - "Builds all the javascript assets necessary for IDE support.\n\n", - ) - .action(async () => { +export class BuildJsCommand extends Command { + static name = 'build-js'; + static paths = [[BuildJsCommand.name]]; + + static usage = Command.Usage({ + category: 'internal', + description: "Builds all the javascript assets necessary for IDE support." + }) + + async execute() { await initYamlIntelligenceResourcesFromFilesystem(); await buildAssets(); - }); + } +} diff --git a/src/command/capabilities/cmd.ts b/src/command/capabilities/cmd.ts index 18ce25e1c14..45cd53d6f96 100644 --- a/src/command/capabilities/cmd.ts +++ b/src/command/capabilities/cmd.ts @@ -1,23 +1,27 @@ /* * cmd.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ import { writeAllSync } from "io/write-all"; -import { Command } from "cliffy/command/mod.ts"; +import { Command } from "npm:clipanion"; import { capabilities } from "./capabilities.ts"; -export const capabilitiesCommand = new Command() - .name("capabilities") - .description( - "Query for current capabilities (formats, engines, kernels etc.)", - ) - .hidden() - .action(async () => { +export class CapabilitiesCommand extends Command { + static name = 'capabilities'; + static paths = [[CapabilitiesCommand.name]]; + + static usage = Command.Usage({ + category: 'internal', + description: "Query for current capabilities (formats, engines, kernels etc.)", + }) + + async execute() { const capsJSON = JSON.stringify(await capabilities(), undefined, 2); writeAllSync( - Deno.stdout, - new TextEncoder().encode(capsJSON), + Deno.stdout, + new TextEncoder().encode(capsJSON), ); - }); + } +} diff --git a/src/command/check/cmd.ts b/src/command/check/cmd.ts index 0c8fdb06ae3..7f28f653afe 100644 --- a/src/command/check/cmd.ts +++ b/src/command/check/cmd.ts @@ -1,34 +1,50 @@ /* * cmd.ts * - * Copyright (C) 2021-2022 Posit Software, PBC + * Copyright (C) 2021-2024 Posit Software, PBC */ import { error } from "../../deno_ral/log.ts"; -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { check, Target } from "./check.ts"; const kTargets = ["install", "jupyter", "knitr", "versions", "all"]; -export const checkCommand = new Command() - .name("check") - .arguments("[target:string]") - .description( - "Verify correct functioning of Quarto installation.\n\n" + - "Check specific functionality with argument install, jupyter, knitr, or all.", - ) - .example("Check Quarto installation", "quarto check install") - .example("Check Jupyter engine", "quarto check jupyter") - .example("Check Knitr engine", "quarto check knitr") - .example("Check installation and all engines", "quarto check all") - .action(async (_options: unknown, target?: string) => { - target = target || "all"; +export class CheckCommand extends Command { + static name = 'check'; + static paths = [[CheckCommand.name]]; + + static usage = Command.Usage({ + description: "Verify correct functioning of Quarto installation.\n\n" + + "Check specific functionality with argument install, jupyter, knitr, or all.", + examples: [ + [ + "Check Quarto installation", + `$0 ${CheckCommand.name} install`, + ], [ + "Check Jupyter engine", + `$0 ${CheckCommand.name} jupyter`, + ], [ + "Check Knitr engine", + `$0 ${CheckCommand.name} knitr`, + ], [ + "Check installation and all engines", + `$0 ${CheckCommand.name} all`, + ] + ] + }) + + target = Option.String({ required: false }); + + async execute() { + const target = this.target || "all"; if (!kTargets.includes(target)) { error( - "Invalid target '" + target + "' (valid targets are " + + "Invalid target '" + target + "' (valid targets are " + kTargets.join(", ") + ").", ); } await check(target as Target); - }); + } +} diff --git a/src/command/command.ts b/src/command/command.ts index 478b63dccd4..2f9f36a0e9d 100644 --- a/src/command/command.ts +++ b/src/command/command.ts @@ -1,61 +1,58 @@ /* * command.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ -import type { Command } from "cliffy/command/mod.ts"; +import type { CommandClass } from "npm:clipanion"; -import { renderCommand } from "./render/cmd.ts"; -import { serveCommand } from "./serve/cmd.ts"; -import { createProjectCommand } from "./create-project/cmd.ts"; -import { toolsCommand } from "./tools/cmd.ts"; -import { previewCommand } from "./preview/cmd.ts"; -import { convertCommand } from "./convert/cmd.ts"; -import { runCommand } from "./run/run.ts"; -import { pandocCommand } from "./pandoc/cmd.ts"; -import { typstCommand } from "./typst/cmd.ts"; -import { capabilitiesCommand } from "./capabilities/cmd.ts"; -import { checkCommand } from "./check/cmd.ts"; -import { inspectCommand } from "./inspect/cmd.ts"; -import { buildJsCommand } from "./build-js/cmd.ts"; -import { installCommand } from "./install/cmd.ts"; -import { updateCommand } from "./update/cmd.ts"; -import { publishCommand } from "./publish/cmd.ts"; -import { removeCommand } from "./remove/cmd.ts"; -import { listCommand } from "./list/cmd.ts"; -import { makeUseCommand } from "./use/cmd.ts"; -import { addCommand } from "./add/cmd.ts"; -import { uninstallCommand } from "./uninstall/cmd.ts"; -import { createCommand } from "./create/cmd.ts"; -import { editorSupportCommand } from "./editor-support/cmd.ts"; +import { RenderCommand } from "./render/cmd.ts"; +import { ServeCommand } from "./serve/cmd.ts"; +import { CreateProjectCommand } from "./create-project/cmd.ts"; +import { toolsCommands } from "./tools/cmd.ts"; +import { PreviewCommand } from "./preview/cmd.ts"; +import { ConvertCommand } from "./convert/cmd.ts"; +import { RunCommand } from "./run/run.ts"; +import { PandocCommand, GeneratePandocWrapperCommand } from "./pandoc/cmd.ts"; +import { TypstCommand } from "./typst/cmd.ts"; +import { CapabilitiesCommand } from "./capabilities/cmd.ts"; +import { CheckCommand } from "./check/cmd.ts"; +import { InspectCommand } from "./inspect/cmd.ts"; +import { BuildJsCommand } from "./build-js/cmd.ts"; +import { InstallCommand } from "./install/cmd.ts"; +import { UpdateCommand } from "./update/cmd.ts"; +import { PublishCommand } from "./publish/cmd.ts"; +import { RemoveCommand } from "./remove/cmd.ts"; +import { listCommands } from "./list/cmd.ts"; +import { useCommands } from "./use/cmd.ts"; +import { AddCommand } from "./add/cmd.ts"; +import { UninstallCommand } from "./uninstall/cmd.ts"; +import { CreateCommand } from "./create/cmd.ts"; +import { editorSupportCommands } from "./editor-support/cmd.ts"; -// deno-lint-ignore no-explicit-any -export function commands(): Command[] { - return [ - // deno-lint-ignore no-explicit-any - renderCommand as any, - previewCommand, - serveCommand, - createCommand, - makeUseCommand(), - addCommand, - updateCommand, - removeCommand, - createProjectCommand, - convertCommand, - pandocCommand, - typstCommand, - runCommand, - listCommand, - installCommand, - uninstallCommand, - toolsCommand, - publishCommand, - capabilitiesCommand, - inspectCommand, - checkCommand, - buildJsCommand, - editorSupportCommand, - ]; -} +export const commands: CommandClass[] = [ + AddCommand, + BuildJsCommand, + CapabilitiesCommand, + CheckCommand, + ConvertCommand, + CreateCommand, + CreateProjectCommand, + ...editorSupportCommands, + GeneratePandocWrapperCommand, + InspectCommand, + InstallCommand, + ...listCommands, + PandocCommand, + PreviewCommand, + PublishCommand, + RemoveCommand, + RenderCommand, + RunCommand, + ServeCommand, + ...toolsCommands, + TypstCommand, + UninstallCommand, + UpdateCommand, + ...useCommands, +]; diff --git a/src/command/convert/cmd.ts b/src/command/convert/cmd.ts index 845f26b3f32..71a9ebe20f5 100644 --- a/src/command/convert/cmd.ts +++ b/src/command/convert/cmd.ts @@ -1,14 +1,14 @@ /* * cmd.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ import { existsSync } from "../../deno_ral/fs.ts"; import { join } from "../../deno_ral/path.ts"; import { info } from "../../deno_ral/log.ts"; -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { isJupyterNotebook } from "../../core/jupyter/jupyter.ts"; import { dirAndStem } from "../../core/path.ts"; import { @@ -20,34 +20,33 @@ import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/u const kNotebookFormat = "notebook"; const kMarkdownFormat = "markdown"; -export const convertCommand = new Command() - .name("convert") - .arguments("") - .description( - "Convert documents to alternate representations.", - ) - .option( - "-o, --output [path:string]", - "Write output to PATH.", - ) - .option( - "--with-ids", - "Include ids in conversion", - ) - .example( - "Convert notebook to markdown", - "quarto convert mydocument.ipynb ", - ) - .example( - "Convert markdown to notebook", - "quarto convert mydocument.qmd", - ) - .example( - "Convert notebook to markdown, writing to file", - "quarto convert mydocument.ipynb --output mydoc.qmd", - ) - // deno-lint-ignore no-explicit-any - .action(async (options: any, input: string) => { +export class ConvertCommand extends Command { + static name = 'convert'; + static paths = [[ConvertCommand.name]]; + + static usage = Command.Usage({ + description: "Convert documents to alternate representations.", + examples: [ + [ + "Convert notebook to markdown", + `$0 ${ConvertCommand.name} mydocument.ipynb`, + ], [ + "Convert markdown to notebook", + `$0 ${ConvertCommand.name} mydocument.qmd`, + ], [ + "Convert notebook to markdown, writing to file", + `$0 ${ConvertCommand.name} mydocument.ipynb --output mydoc.qmd`, + ] + ] + }) + + input = Option.String({ required: true }); + + output = Option.String("-o,--output", { description: "Write output to PATH." }); + withIds = Option.Boolean("--with-ids", { description: "Include ids in conversion" }); + + async execute() { + const { input } = this; await initYamlIntelligenceResourcesFromFilesystem(); if (!existsSync(input)) { @@ -60,7 +59,7 @@ export const convertCommand = new Command() : kMarkdownFormat; // are we converting ids? - const withIds = options.withIds === undefined ? false : !!options.withIds; + const withIds = this.withIds === undefined ? false : !!this.withIds; // perform conversion const converted = srcFormat === kNotebookFormat @@ -69,7 +68,7 @@ export const convertCommand = new Command() // write output const [dir, stem] = dirAndStem(input); - let output = options.output; + let output = this.output; if (!output) { output = join( dir, @@ -78,4 +77,5 @@ export const convertCommand = new Command() } Deno.writeTextFileSync(output, converted); info(`Converted to ${output}`); - }); + } +} diff --git a/src/command/create-project/cmd.ts b/src/command/create-project/cmd.ts index 163fbc67112..8acb2e71338 100644 --- a/src/command/create-project/cmd.ts +++ b/src/command/create-project/cmd.ts @@ -1,12 +1,12 @@ /* * cmd.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ import { basename } from "../../deno_ral/path.ts"; -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { executionEngine, executionEngines } from "../../execute/engine.ts"; @@ -29,124 +29,94 @@ const kProjectTypesAndAliases = [...kProjectTypes, ...kProjectTypeAliases]; const kExecutionEngines = executionEngines().reverse(); const kEditorTypes = ["source", "visual"]; -export const createProjectCommand = new Command() - .name("create-project") - .description("Create a project for rendering multiple documents") - .arguments("[dir:string]") - .hidden() - .option( - "--title ", - "Project title", - ) - .option( - "--type ", - `Project type (${kProjectTypes.join(", ")})`, - ) - .option( - "--template ", - `Use a specific project template`, - ) - .option( - "--engine ", - `Use execution engine (${kExecutionEngines.join(", ")})`, - { - value: (value: string): string[] => { - value = value || kMarkdownEngine; - const engine = executionEngine(value.split(":")[0]); - if (!engine) { - throw new Error(`Unknown --engine: ${value}`); - } - // check for kernel - const match = value.match(/(\w+)(:(.+))?$/); - if (match) { - return [match[1], match[3]]; - } else { - return [value]; - } - }, - }, - ) - .option( - "--editor ", - "Default editor for project ('source' or 'visual')", - ) - .option( - "--with-venv [packages:string]", - "Create a virtualenv for this project", - ) - .option( - "--with-condaenv [packages:string]", - "Create a condaenv for this project", - ) - .option( - "--no-scaffold", - "Don't create initial project file(s)", - ) - .example( - "Create a project in the current directory", - "quarto create-project", - ) - .example( - "Create a project in the 'myproject' directory", - "quarto create-project myproject", - ) - .example( - "Create a website project", - "quarto create-project mysite --type website", - ) - .example( - "Create a blog project", - "quarto create-project mysite --type website --template blog", - ) - .example( - "Create a book project", - "quarto create-project mybook --type book", - ) - .example( - "Create a website project with jupyter", - "quarto create-project mysite --type website --engine jupyter", - ) - .example( - "Create a website project with jupyter + kernel", - "quarto create-project mysite --type website --engine jupyter:python3", - ) - .example( - "Create a book project with knitr", - "quarto create-project mybook --type book --engine knitr", - ) - .example( - "Create jupyter project with virtualenv", - "quarto create-project myproject --engine jupyter --with-venv", - ) - .example( - "Create jupyter project with virtualenv + packages", - "quarto create-project myproject --engine jupyter --with-venv pandas,matplotlib", - ) - .example( - "Create jupyter project with condaenv ", - "quarto create-project myproject --engine jupyter --with-condaenv", - ) - .example( - "Create jupyter project with condaenv + packages", - "quarto create-project myproject --engine jupyter --with-condaenv pandas,matplotlib", - ) - // deno-lint-ignore no-explicit-any - .action(async (options: any, dir?: string) => { - if (dir === undefined || dir === ".") { - dir = Deno.cwd(); +// TODO: can this be part of the option definition? +const parseEngine = (value: string): string[] => { + value = value || kMarkdownEngine; + const engine = executionEngine(value.split(":")[0]); + if (!engine) { + throw new Error(`Unknown --engine: ${value}`); } + // check for kernel + const match = value.match(/(\w+)(:(.+))?$/); + if (match) { + return [match[1], match[3]]; + } else { + return [value]; + } +}; + +export class CreateProjectCommand extends Command { + static name = 'create-project'; + static paths = [[CreateProjectCommand.name]]; + + static usage = Command.Usage({ + category: 'internal', + description: 'Create a project for rendering multiple documents', + examples: [ + [ + "Create a project in the current directory", + `$0 ${CreateProjectCommand.name}`, + ], [ + "Create a project in the 'myproject' directory", + `$0 ${CreateProjectCommand.name} myproject`, + ], [ + "Create a website project", + `$0 ${CreateProjectCommand.name} mysite --type website`, + ], [ + "Create a blog project", + `$0 ${CreateProjectCommand.name} mysite --type website --template blog`, + ], [ + "Create a book project", + `$0 ${CreateProjectCommand.name} mybook --type book`, + ], [ + "Create a website project with jupyter", + `$0 ${CreateProjectCommand.name} mysite --type website --engine jupyter`, + ], [ + "Create a website project with jupyter + kernel", + `$0 ${CreateProjectCommand.name} mysite --type website --engine jupyter:python3`, + ], [ + "Create a book project with knitr", + `$0 ${CreateProjectCommand.name} mybook --type book --engine knitr`, + ], [ + "Create jupyter project with virtualenv", + `$0 ${CreateProjectCommand.name} myproject --engine jupyter --with-venv`, + ], [ + "Create jupyter project with virtualenv + packages", + `$0 ${CreateProjectCommand.name} myproject --engine jupyter --with-venv pandas,matplotlib`, + ], [ + "Create jupyter project with condaenv", + `$0 ${CreateProjectCommand.name} myproject --engine jupyter --with-condaenv`, + ], [ + "Create jupyter project with condaenv + packages", + `$0 ${CreateProjectCommand.name} myproject --engine jupyter --with-condaenv pandas,matplotlib`, + ] + ], + }); - const engine = options.engine || []; + dir = Option.String({ required: false }); - const envPackages = typeof (options.withVenv) === "string" - ? options.withVenv.split(",").map((pkg: string) => pkg.trim()) - : typeof (options.withCondaenv) === "string" - ? options.withCondaenv.split(",").map((pkg: string) => pkg.trim()) + editor = Option.String('--editor', { description: "Default editor for project ('source' or 'visual')" }); + engine = Option.String('--engine', { description: `Use execution engine (${kExecutionEngines.join(", ")})` }); + noScaffold = Option.Boolean('--no-scaffold', { description: "Don't create initial project file(s)" }); + template = Option.String('--template', { description: "Use a specific project template" }); + title = Option.String('--title', { description: "Project title" }); + type = Option.String('--type', { description: `Project type (${kProjectTypes.join(", ")})` }); + withCondaenv = Option.String('--with-condaenv', { description: "Create a condaenv for this project", tolerateBoolean: true }); + withVenv = Option.String('--with-venv', { description: "Create a virtualenv for this project", tolerateBoolean: true }); + + async execute() { + const dir = (this.dir === undefined || this.dir === ".") ? Deno.cwd() : this.dir; + const engine = this.engine ? parseEngine(this.engine) : []; + + const envPackages = typeof (this.withVenv) === "string" + ? this.withVenv.split(",").map((pkg: string) => pkg.trim()) + : typeof (this.withCondaenv) === "string" + ? this.withCondaenv.split(",").map((pkg: string) => pkg.trim()) : undefined; // Parse the project type and template - const { type, template } = parseProjectType(options.type); - const projectTemplate = options.template || template; + const { type, template } = parseProjectType(this.type); + const projectTemplate = this.template || template; // Validate the type if (kProjectTypesAndAliases.indexOf(type) === -1) { @@ -158,7 +128,7 @@ export const createProjectCommand = new Command() } // Validate the editor - const editorType = options.editor; + const editorType = this.editor; if (editorType && !kEditorTypes.includes(editorType)) { throw new Error( `Editor type must be one of ${ @@ -185,15 +155,16 @@ export const createProjectCommand = new Command() await projectCreate({ dir, - type: type, - title: options.title || basename(dir), - scaffold: !!options.scaffold, + type, + title: this.title || basename(dir), + scaffold: !this.noScaffold, engine: engine[0] || kMarkdownEngine, kernel: engine[1], editor: editorType, - venv: !!options.withVenv, - condaenv: !!options.withCondaenv, + venv: !!this.withVenv, + condaenv: !!this.withCondaenv, envPackages, template: projectTemplate, }); - }); + } +} diff --git a/src/command/create/cmd.ts b/src/command/create/cmd.ts index 79447d525fa..14f535e00fa 100644 --- a/src/command/create/cmd.ts +++ b/src/command/create/cmd.ts @@ -1,7 +1,7 @@ /* * cmd.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ import { extensionArtifactCreator } from "./artifacts/extension.ts"; @@ -11,7 +11,7 @@ import { kEditorInfos, scanForEditors } from "./editor.ts"; import { isInteractiveTerminal } from "../../core/platform.ts"; import { runningInCI } from "../../core/ci-info.ts"; -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { prompt, Select, SelectValueOptions } from "cliffy/prompt/mod.ts"; import { readLines } from "../../deno_ral/io.ts"; import { info } from "../../deno_ral/log.ts"; @@ -24,98 +24,95 @@ const kArtifactCreators: ArtifactCreator[] = [ // documentArtifactCreator, CT: Disabled for 1.2 as it arrived too late on the scene ]; -export const createCommand = new Command() - .name("create") - .description("Create a Quarto project or extension") - .option( - "--open [editor:string]", - `Open new artifact in this editor (${ - kEditorInfos.map((info) => info.id).join(", ") - })`, - ) - .option("--no-open", "Do not open in an editor") - .option("--no-prompt", "Do not prompt to confirm actions") - .option("--json", "Pass serialized creation options via stdin", { - hidden: true, - }) - .arguments("[type] [commands...]") - .action( - async ( - options: { - prompt: boolean; - json?: boolean; - open?: string | boolean; - }, - type?: string, - ...commands: string[] - ) => { - if (options.json) { - await createFromStdin(); - } else { - // Compute a sane default for prompting - const isInteractive = isInteractiveTerminal() && !runningInCI(); - const allowPrompt = isInteractive && !!options.prompt && !options.json; - - // Resolve the type into an artifact - const resolved = await resolveArtifact( - type, - options.prompt, - ); - const resolvedArtifact = resolved.artifact; - if (resolvedArtifact) { - // Resolve the arguments that the user provided into options - // for the artifact provider - - // If we aliased the type, shift the args (including what was - // the type alias in the list of args for the artifact creator - // to resolve) - const args = commands; - - const commandOpts = resolvedArtifact.resolveOptions(args); - const createOptions = { - cwd: Deno.cwd(), - options: commandOpts, - }; - - if (allowPrompt) { - // Prompt the user until the options have been fully realized - let nextPrompt = resolvedArtifact.nextPrompt(createOptions); - while (nextPrompt !== undefined) { - if (nextPrompt) { - const result = await prompt([nextPrompt]); - createOptions.options = { - ...createOptions.options, - ...result, - }; - } - nextPrompt = resolvedArtifact.nextPrompt(createOptions); +export class CreateCommand extends Command { + static name = 'create'; + static paths = [[CreateCommand.name]]; + + static usage = Command.Usage({ + description: "Create a Quarto project or extension", + }); + + type = Option.String({required: false}); + commands = Option.Rest(); + + json = Option.Boolean('--json', {description: "Pass serialized creation options via stdin", hidden: true}); + noOpen = Option.Boolean('--no-open', {description: "Do not open in an editor"}); + noPrompt = Option.Boolean('--no-prompt', {description: "Do not prompt to confirm actions"}); + + open = Option.String('--open', { + description: `Open new artifact in this editor (${ + kEditorInfos.map((info) => info.id).join(", ") + })` + }); + + async execute() { + if (this.json) { + await createFromStdin(); + } else { + // Compute a sane default for prompting + const isInteractive = isInteractiveTerminal() && !runningInCI(); + const allowPrompt = isInteractive && !this.noPrompt && !this.json; + + // Resolve the type into an artifact + const resolved = await resolveArtifact( + this.type, + !this.noPrompt, + ); + const resolvedArtifact = resolved.artifact; + if (resolvedArtifact) { + // Resolve the arguments that the user provided into options + // for the artifact provider + + // If we aliased the type, shift the args (including what was + // the type alias in the list of args for the artifact creator + // to resolve) + const args = this.commands; + + const commandOpts = resolvedArtifact.resolveOptions(args); + const createOptions = { + cwd: Deno.cwd(), + options: commandOpts, + }; + + if (allowPrompt) { + // Prompt the user until the options have been fully realized + let nextPrompt = resolvedArtifact.nextPrompt(createOptions); + while (nextPrompt !== undefined) { + if (nextPrompt) { + const result = await prompt([nextPrompt]); + createOptions.options = { + ...createOptions.options, + ...result, + }; } + nextPrompt = resolvedArtifact.nextPrompt(createOptions); } + } - // Complete the defaults - const createDirective = resolvedArtifact.finalizeOptions( + // Complete the defaults + const createDirective = resolvedArtifact.finalizeOptions( createOptions, - ); + ); - // Create the artifact using the options - const createResult = await resolvedArtifact.createArtifact( + // Create the artifact using the options + const createResult = await resolvedArtifact.createArtifact( createDirective, - ); + ); // Now that the article was created, offer to open the item - if (allowPrompt && options.open !== false) { + if (allowPrompt && !this.noOpen) { const resolvedEditor = await resolveEditor( createResult, - typeof (options.open) === "string" ? options.open : undefined, - ); - if (resolvedEditor) { - resolvedEditor.open(); - } + typeof (this.open) === "string" ? this.open : undefined, + ); + if (resolvedEditor) { + await resolvedEditor.open(); } } } - }, - ); + } + } +} // Resolves the artifact string (or undefined) into an // Artifact interface which will provide the functions diff --git a/src/command/editor-support/cmd.ts b/src/command/editor-support/cmd.ts index 8d81f93c735..93248ead284 100644 --- a/src/command/editor-support/cmd.ts +++ b/src/command/editor-support/cmd.ts @@ -1,16 +1,11 @@ /* * cmd.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/mod.ts"; -import { crossrefCommand } from "./crossref.ts"; +import { CrossrefCommand } from "./crossref.ts"; -export const editorSupportCommand = new Command() - .name("editor-support") - .description( - "Miscellaneous tools to support Quarto editor modes", - ) - .hidden() - .command("crossref", crossrefCommand); +export const editorSupportCommands = [ + CrossrefCommand, +] diff --git a/src/command/editor-support/crossref.ts b/src/command/editor-support/crossref.ts index f028cd292f4..89d112e8abc 100644 --- a/src/command/editor-support/crossref.ts +++ b/src/command/editor-support/crossref.ts @@ -1,7 +1,7 @@ /* * command.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ import { join } from "../../deno_ral/path.ts"; @@ -9,143 +9,101 @@ import { readAll } from "../../deno_ral/io.ts"; import { error } from "../../deno_ral/log.ts"; import { encodeBase64 } from "../../deno_ral/encoding.ts"; -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { execProcess } from "../../core/process.ts"; import { pandocBinaryPath, resourcePath } from "../../core/resources.ts"; import { globalTempContext } from "../../core/temp.ts"; +import { namespace } from "./namespace.ts"; -function parseCrossrefFlags(options: any, args: string[]): { - input?: string; - output?: string; -} { - let input: string | undefined, output: string | undefined; - - // stop early with no input seems wonky in Cliffy so we need to undo the damage here - // by inspecting partially-parsed input... - if (options.input && args[0]) { - input = args.shift(); - } else if (options.output && args[0]) { - output = args.shift(); - } - const argsStack = [...args]; - let arg = argsStack.shift(); - while (arg !== undefined) { - switch (arg) { - case "-i": - case "--input": - arg = argsStack.shift(); - if (arg) { - input = arg; - } - break; - - case "-o": - case "--output": - arg = argsStack.shift(); - if (arg) { - output = arg; - } - break; - default: - arg = argsStack.shift(); - break; - } - } - return { input, output }; -} +export class CrossrefCommand extends Command { + static paths = [[namespace, 'crossref']]; -const makeCrossrefCommand = () => { - return new Command() - .description("Index cross references for content") - .stopEarly() - .arguments("[...args]") - .option( - "-i, --input", - "Use FILE as input (default: stdin).", - ) - .option( - "-o, --output", - "Write output to FILE (default: stdout).", - ) - .action(async (options, ...args: string[]) => { - const flags = parseCrossrefFlags(options, args); - const getInput = async () => { - if (flags.input) { - return Deno.readTextFileSync(flags.input); - } else { - // read input - const stdinContent = await readAll(Deno.stdin); - return new TextDecoder().decode(stdinContent); - } - }; - const getOutputFile: () => string = () => (flags.output || "stdout"); - - const input = await getInput(); - - // create directory for indexing and write input into it - const indexingDir = globalTempContext().createDir(); - - // setup index file and input type - const indexFile = join(indexingDir, "index.json"); - Deno.env.set("QUARTO_CROSSREF_INDEX_PATH", indexFile); - Deno.env.set("QUARTO_CROSSREF_INPUT_TYPE", "qmd"); - - // build command - const cmd = [pandocBinaryPath(), "+RTS", "-K512m", "-RTS"]; - cmd.push(...[ - "--from", - resourcePath("filters/qmd-reader.lua"), - "--to", - "native", - "--data-dir", - resourcePath("pandoc/datadir"), - "--lua-filter", - resourcePath("filters/quarto-init/quarto-init.lua"), - "--lua-filter", - resourcePath("filters/crossref/crossref.lua"), - ]); - - // create filter params - const filterParams = encodeBase64( - JSON.stringify({ - ["crossref-index-file"]: "index.json", - ["crossref-input-type"]: "qmd", - }), - ); - - // run pandoc - const result = await execProcess( - { - cmd, - cwd: indexingDir, - env: { - "QUARTO_FILTER_PARAMS": filterParams, - "QUARTO_SHARE_PATH": resourcePath(), - }, - stdout: "piped", - }, - input, - undefined, // mergeOutput?: "stderr>stdout" | "stdout>stderr", - undefined, // stderrFilter?: (output: string) => string, - undefined, // respectStreams?: boolean, - 5000, - ); - - // check for error - if (!result.success) { - error("Error running Pandoc: " + result.stderr); - throw new Error(result.stderr); - } + static usage = Command.Usage({ + category: 'internal', + description: "Index cross references for content" + }) + + input = Option.String("-i,--input", { description: "Use FILE as input (default: stdin)." }); + output = Option.String("-o,--output", { description: "Write output to FILE (default: stdout)." }); - const outputFile = getOutputFile(); - if (outputFile === "stdout") { - // write back the index - Deno.stdout.writeSync(Deno.readFileSync(indexFile)); + args = Option.Rest(); + + async execute() { + const getInput = async () => { + if (this.input) { + return Deno.readTextFileSync(this.input); } else { - Deno.writeTextFileSync(outputFile, Deno.readTextFileSync(indexFile)); + // read input + const stdinContent = await readAll(Deno.stdin); + return new TextDecoder().decode(stdinContent); } - }); -}; + }; + const getOutputFile: () => string = () => (this.output || "stdout"); + + const input = await getInput(); + + // create directory for indexing and write input into it + const indexingDir = globalTempContext().createDir(); + + // setup index file and input type + const indexFile = join(indexingDir, "index.json"); + Deno.env.set("QUARTO_CROSSREF_INDEX_PATH", indexFile); + Deno.env.set("QUARTO_CROSSREF_INPUT_TYPE", "qmd"); + + // build command + const cmd = [pandocBinaryPath(), "+RTS", "-K512m", "-RTS"]; + cmd.push(...[ + "--from", + resourcePath("filters/qmd-reader.lua"), + "--to", + "native", + "--data-dir", + resourcePath("pandoc/datadir"), + "--lua-filter", + resourcePath("filters/quarto-init/quarto-init.lua"), + "--lua-filter", + resourcePath("filters/crossref/crossref.lua"), + ]); + + // create filter params + const filterParams = encodeBase64( + JSON.stringify({ + ["crossref-index-file"]: "index.json", + ["crossref-input-type"]: "qmd", + }), + ); + + // run pandoc + const result = await execProcess( + { + cmd, + cwd: indexingDir, + env: { + "QUARTO_FILTER_PARAMS": filterParams, + "QUARTO_SHARE_PATH": resourcePath(), + }, + stdout: "piped", + }, + input, + undefined, // mergeOutput?: "stderr>stdout" | "stdout>stderr", + undefined, // stderrFilter?: (output: string) => string, + undefined, // respectStreams?: boolean, + 5000, + ); + + // check for error + if (!result.success) { + error("Error running Pandoc: " + result.stderr); + throw new Error(result.stderr); + } -export const crossrefCommand = makeCrossrefCommand(); + const outputFile = getOutputFile(); + if (outputFile === "stdout") { + // write back the index + Deno.stdout.writeSync(Deno.readFileSync(indexFile)); + } else { + Deno.writeTextFileSync(outputFile, Deno.readTextFileSync(indexFile)); + } + } +} diff --git a/src/command/editor-support/namespace.ts b/src/command/editor-support/namespace.ts new file mode 100644 index 00000000000..d4ee8df643f --- /dev/null +++ b/src/command/editor-support/namespace.ts @@ -0,0 +1 @@ +export const namespace = 'editor-support'; diff --git a/src/command/inspect/cmd.ts b/src/command/inspect/cmd.ts index 0a7204d02a0..3d35652e960 100644 --- a/src/command/inspect/cmd.ts +++ b/src/command/inspect/cmd.ts @@ -1,68 +1,63 @@ /* * cmd.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; -import { - initState, - setInitializer, -} from "../../core/lib/yaml-validation/state.ts"; +import { initState, setInitializer, } from "../../core/lib/yaml-validation/state.ts"; import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/utils.ts"; import { inspectConfig } from "../../quarto-core/inspect.ts"; -export const inspectCommand = new Command() - .name("inspect") - .arguments("[path] [output]") - .description( - "Inspect a Quarto project or input path.\n\nInspecting a project returns its config and engines.\n" + - "Inspecting an input path return its formats, engine, and dependent resources.\n\n" + - "Emits results of inspection as JSON to output (or stdout if not provided).", - ) - .hidden() - .example( - "Inspect project in current directory", - "quarto inspect", - ) - .example( - "Inspect project in directory", - "quarto inspect myproject", - ) - .example( - "Inspect input path", - "quarto inspect document.md", - ) - .example( - "Inspect input path and write to file", - "quarto inspect document.md output.json", - ) - .action( - async ( - // deno-lint-ignore no-explicit-any - _options: any, - path?: string, - output?: string, - ) => { - // one-time initialization of yaml validation modules - setInitializer(initYamlIntelligenceResourcesFromFilesystem); - await initState(); +export class InspectCommand extends Command { + static name = 'inspect'; + static paths = [[InspectCommand.name]]; - path = path || Deno.cwd(); + static usage = Command.Usage({ + category: 'internal', + description: "Inspect a Quarto project or input path.\n\nInspecting a project returns its config and engines.\n" + + "Inspecting an input path return its formats, engine, and dependent resources.\n\n" + + "Emits results of inspection as JSON to output (or stdout if not provided).", + examples: [ + [ + "Inspect project in current directory", + `$0 ${InspectCommand.name}`, + ], [ + "Inspect project in directory", + `$0 ${InspectCommand.name} myproject`, + ], [ + "Inspect input path", + `$0 ${InspectCommand.name} document.md`, + ], [ + "Inspect input path and write to file", + `$0 ${InspectCommand.name} document.md output.json`, + ] + ] + }) - // get the config - const config = await inspectConfig(path); + path_ = Option.String({ required: false }); + output = Option.String({ required: false }); - // write using the requisite format - const outputJson = JSON.stringify(config, undefined, 2); + async execute() { + // one-time initialization of yaml validation modules + setInitializer(initYamlIntelligenceResourcesFromFilesystem); + await initState(); - if (!output) { - Deno.stdout.writeSync( + this.path_ = this.path_ || Deno.cwd(); + + // get the config + const config = await inspectConfig(this.path_); + + // write using the requisite format + const outputJson = JSON.stringify(config, undefined, 2); + + if (!this.output) { + Deno.stdout.writeSync( new TextEncoder().encode(outputJson + "\n"), - ); - } else { - Deno.writeTextFileSync(output, outputJson + "\n"); - } - }, - ); + ); + } else { + Deno.writeTextFileSync(this.output, outputJson + "\n"); + } + } +} diff --git a/src/command/install/cmd.ts b/src/command/install/cmd.ts index 8c9166b2d1d..ce7dfe8660b 100644 --- a/src/command/install/cmd.ts +++ b/src/command/install/cmd.ts @@ -1,110 +1,100 @@ /* * cmd.ts * - * Copyright (C) 2021-2022 Posit Software, PBC + * Copyright (C) 2021-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/utils.ts"; import { createTempContext } from "../../core/temp.ts"; import { installExtension } from "../../extension/install.ts"; import { info } from "../../deno_ral/log.ts"; -import { - loadTools, - selectTool, - updateOrInstallTool, -} from "../../tools/tools-console.ts"; +import { loadTools, selectTool, updateOrInstallTool, } from "../../tools/tools-console.ts"; import { installTool } from "../../tools/tools.ts"; import { resolveCompatibleArgs } from "../remove/cmd.ts"; -export const installCommand = new Command() - .name("install") - .arguments("[target...]") - .option( - "--no-prompt", - "Do not prompt to confirm actions", - ) - .option( - "--embed ", - "Embed this extension within another extension (used when authoring extensions).", - { - hidden: true, - }, - ) - .option( - "--update-path", - "Update system path when a tool is installed", - ) - .description( - "Installs a global dependency (TinyTex or Chromium).", - ) - .example( - "Install TinyTeX", - "quarto install tinytex", - ) - .example( - "Install Chromium", - "quarto install chromium", - ) - .example( - "Choose tool to install", - "quarto install", - ) - .action( - async ( - options: { prompt?: boolean; embed?: string; updatePath?: boolean }, - ...target: string[] - ) => { - await initYamlIntelligenceResourcesFromFilesystem(); - const temp = createTempContext(); - const resolved = resolveCompatibleArgs(target || [], "tool"); +export class InstallCommand extends Command { + static name = 'install'; + static paths = [[InstallCommand.name]]; - try { - if (resolved.action === "extension") { - // Install an extension - if (resolved.name) { - await installExtension( + static usage = Command.Usage({ + description: "Installs a global dependency (TinyTex or Chromium).", + examples: [ + [ + "Install TinyTeX", + `$0 ${InstallCommand.name} tinytex`, + ], [ + "Install Chromium", + `$0 ${InstallCommand.name} chromium`, + ], [ + "Choose tool to install", + `$0 ${InstallCommand.name}`, + ] + ] + }); + + embed = Option.String('--embed', { + description: "Embed this extension within another extension (used when authoring extensions).", + hidden: true, + }); + + noPrompt = Option.Boolean('--no-prompt', {description: "Do not prompt to confirm actions"}); + updatePath = Option.Boolean('--update-path', {description: "Update system path when a tool is installed"}); + + targets = Option.Rest(); + +async execute() { + await initYamlIntelligenceResourcesFromFilesystem(); + const temp = createTempContext(); + + const resolved = resolveCompatibleArgs(this.targets || [], "tool"); + + try { + if (resolved.action === "extension") { + // Install an extension + if (resolved.name) { + await installExtension( resolved.name, temp, - options.prompt !== false, - options.embed, - ); - } else { - info("Please provide an extension name, url, or path."); - } - } else if (resolved.action === "tool") { - // Install a tool - if (resolved.name) { - // Use the tool name - await updateOrInstallTool( + !this.noPrompt, + this.embed, + ); + } else { + info("Please provide an extension name, url, or path."); + } + } else if (resolved.action === "tool") { + // Install a tool + if (resolved.name) { + // Use the tool name + await updateOrInstallTool( resolved.name, "install", - options.prompt, - options.updatePath, - ); + !this.noPrompt, + this.updatePath, + ); + } else { + // Not provided, give the user a list to choose from + const allTools = await loadTools(); + if (allTools.filter((tool) => !tool.installed).length === 0) { + info("All tools are already installed."); } else { - // Not provided, give the user a list to choose from - const allTools = await loadTools(); - if (allTools.filter((tool) => !tool.installed).length === 0) { - info("All tools are already installed."); - } else { - // Select which tool should be installed - const toolTarget = await selectTool(allTools, "install"); - if (toolTarget) { - info(""); - await installTool(toolTarget, options.updatePath); - } + // Select which tool should be installed + const toolTarget = await selectTool(allTools, "install"); + if (toolTarget) { + info(""); + await installTool(toolTarget, this.updatePath); } } - } else { - // This is an unrecognized type option - info( - `Unrecognized option '${resolved.action}' - please choose 'tool' or 'extension'.`, - ); } - } finally { - temp.cleanup(); + } else { + // This is an unrecognized type option + info( + `Unrecognized option '${resolved.action}' - please choose 'tool' or 'extension'.`, + ); } - }, - ); + } finally { + temp.cleanup(); + } + } +} diff --git a/src/command/list/cmd.ts b/src/command/list/cmd.ts index 01bc38728c1..57ff6cb486c 100644 --- a/src/command/list/cmd.ts +++ b/src/command/list/cmd.ts @@ -1,116 +1,12 @@ /* * cmd.ts * - * Copyright (C) 2021-2022 Posit Software, PBC + * Copyright (C) 2021-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/mod.ts"; -import { Table } from "cliffy/table/mod.ts"; -import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/utils.ts"; -import { createTempContext } from "../../core/temp.ts"; +import { ListExtensionsCommand } from "./list-extensions.ts"; +import { ListToolsCommand } from "./list-tools.ts"; -import { info } from "../../deno_ral/log.ts"; -import { createExtensionContext } from "../../extension/extension.ts"; -import { extensionIdString } from "../../extension/extension-shared.ts"; -import { Extension, ExtensionContext } from "../../extension/types.ts"; -import { projectContext } from "../../project/project-context.ts"; -import { outputTools } from "../../tools/tools-console.ts"; -import { notebookContext } from "../../render/notebook/notebook-context.ts"; - -export const listCommand = new Command() - .hidden() - .name("list") - .arguments("") - .description( - "Lists an extension or global dependency.", - ) - .example( - "List installed extensions", - "quarto list extensions", - ) - .example( - "List global tools", - "quarto list tools", - ) - .action( - async (_options: unknown, type: string) => { - await initYamlIntelligenceResourcesFromFilesystem(); - const temp = createTempContext(); - const extensionContext = createExtensionContext(); - try { - if (type.toLowerCase() === "extensions") { - await outputExtensions(Deno.cwd(), extensionContext); - } else if (type.toLowerCase() === "tools") { - await outputTools(); - } else { - // This is an unrecognized type option - info( - `Unrecognized option '${type}' - please choose 'tools' or 'extensions'.`, - ); - } - } finally { - temp.cleanup(); - } - }, - ); - -async function outputExtensions( - path: string, - extensionContext: ExtensionContext, -) { - const nbContext = notebookContext(); - // Provide the with with a list - const project = await projectContext(path, nbContext); - const extensions = await extensionContext.extensions( - path, - project?.config, - project?.dir, - { builtIn: false }, - ); - if (extensions.length === 0) { - info( - `No extensions are installed in this ${ - project ? "project" : "directory" - }.`, - ); - } else { - const extensionEntries: string[][] = []; - const provides = (extension: Extension) => { - const contribs: string[] = []; - if ( - extension.contributes.filters && - extension.contributes.filters?.length > 0 - ) { - contribs.push("filters"); - } - - if ( - extension.contributes.shortcodes && - extension.contributes.shortcodes?.length > 0 - ) { - contribs.push("shortcodes"); - } - - if ( - extension.contributes.formats && - Object.keys(extension.contributes.formats).length > 0 - ) { - contribs.push("formats"); - } - return contribs.join(", "); - }; - - extensions.forEach((ext) => { - const row = [ - extensionIdString(ext.id), - ext.version?.toString() || "(none)", - `${provides(ext)}`, - ]; - extensionEntries.push(row); - }); - - const table = new Table().header(["Id", "Version", "Contributes"]).body( - extensionEntries, - ).padding(4); - info(table.toString()); - } -} +export const listCommands = [ + ListExtensionsCommand, + ListToolsCommand, +] diff --git a/src/command/list/list-extensions.ts b/src/command/list/list-extensions.ts new file mode 100644 index 00000000000..b1ceceb3afb --- /dev/null +++ b/src/command/list/list-extensions.ts @@ -0,0 +1,99 @@ +/* + * cmd.ts + * + * Copyright (C) 2021-2024 Posit Software, PBC + */ +import { Command } from "npm:clipanion"; +import { Table } from "cliffy/table/mod.ts"; +import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/utils.ts"; +import { createTempContext } from "../../core/temp.ts"; + +import { info } from "../../deno_ral/log.ts"; +import { createExtensionContext } from "../../extension/extension.ts"; +import { extensionIdString } from "../../extension/extension-shared.ts"; +import { Extension, ExtensionContext } from "../../extension/types.ts"; +import { projectContext } from "../../project/project-context.ts"; +import { notebookContext } from "../../render/notebook/notebook-context.ts"; +import { namespace } from "./namespace.ts"; + +export class ListExtensionsCommand extends Command { + static paths = [[namespace, 'extensions']]; + + static usage = Command.Usage({ + category: 'internal', + description: "List installed extensions" + }) + + async execute() { + await initYamlIntelligenceResourcesFromFilesystem(); + const temp = createTempContext(); + const extensionContext = createExtensionContext(); + try { + await outputExtensions(Deno.cwd(), extensionContext); + } finally { + temp.cleanup(); + } + } +} + +async function outputExtensions( + path: string, + extensionContext: ExtensionContext, +) { + const nbContext = notebookContext(); + // Provide the with with a list + const project = await projectContext(path, nbContext); + const extensions = await extensionContext.extensions( + path, + project?.config, + project?.dir, + { builtIn: false }, + ); + if (extensions.length === 0) { + info( + `No extensions are installed in this ${ + project ? "project" : "directory" + }.`, + ); + } else { + const extensionEntries: string[][] = []; + const provides = (extension: Extension) => { + const contribs: string[] = []; + if ( + extension.contributes.filters && + extension.contributes.filters?.length > 0 + ) { + contribs.push("filters"); + } + + if ( + extension.contributes.shortcodes && + extension.contributes.shortcodes?.length > 0 + ) { + contribs.push("shortcodes"); + } + + if ( + extension.contributes.formats && + Object.keys(extension.contributes.formats).length > 0 + ) { + contribs.push("formats"); + } + return contribs.join(", "); + }; + + extensions.forEach((ext) => { + const row = [ + extensionIdString(ext.id), + ext.version?.toString() || "(none)", + `${provides(ext)}`, + ]; + extensionEntries.push(row); + }); + + const table = new Table().header(["Id", "Version", "Contributes"]).body( + extensionEntries, + ).padding(4); + info(table.toString()); + } +} diff --git a/src/command/list/list-tools.ts b/src/command/list/list-tools.ts new file mode 100644 index 00000000000..d8ee6a0a375 --- /dev/null +++ b/src/command/list/list-tools.ts @@ -0,0 +1,30 @@ +/* + * cmd.ts + * + * Copyright (C) 2021-2024 Posit Software, PBC + */ +import { Command } from "npm:clipanion"; +import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/utils.ts"; +import { createTempContext } from "../../core/temp.ts"; + +import { outputTools } from "../../tools/tools-console.ts"; +import { namespace } from "./namespace.ts"; + +export class ListToolsCommand extends Command { + static paths = [[namespace, 'tools']]; + + static usage = Command.Usage({ + category: 'internal', + description: "List global tools" + }) + + async execute() { + await initYamlIntelligenceResourcesFromFilesystem(); + const temp = createTempContext(); + try { + await outputTools(); + } finally { + temp.cleanup(); + } + } +} diff --git a/src/command/list/namespace.ts b/src/command/list/namespace.ts new file mode 100644 index 00000000000..d2754e8a33d --- /dev/null +++ b/src/command/list/namespace.ts @@ -0,0 +1 @@ +export const namespace = 'list'; diff --git a/src/command/options.ts b/src/command/options.ts new file mode 100644 index 00000000000..1c0f329dd5f --- /dev/null +++ b/src/command/options.ts @@ -0,0 +1,21 @@ +/* + * options.ts + * + * Copyright (C) 2024 Posit Software, PBC + */ +import type { BaseContext, Command, CommandClass, Option } from "npm:clipanion"; + +export const addCommandOptions = >( + options: Options, + callback: (commandWithOptions: Command & Options) => Promise +) => + (commandClass: CommandClass) => { + Object.assign(commandClass.prototype, options); + + const wrappedExecute = commandClass.prototype.execute; + commandClass.prototype.execute = async function () { + const commandWithOptions = this as unknown as (Command & Options); + await callback(commandWithOptions); + return await wrappedExecute.call(this); + } + } diff --git a/src/command/pandoc/cmd.ts b/src/command/pandoc/cmd.ts index 4a6af08fa87..3989843b880 100644 --- a/src/command/pandoc/cmd.ts +++ b/src/command/pandoc/cmd.ts @@ -1,28 +1,174 @@ /* * pandoc.ts * -* Copyright (C) 2020-2022 Posit Software, PBC +* Copyright (C) 2020-2024 Posit Software, PBC * */ +import { join } from "../../deno_ral/path.ts"; -import { Command } from "cliffy/command/command.ts"; - -// pandoc 'command' (this is a fake command that is here just for docs, -// the actual processing of 'run' bypasses cliffy entirely) -export const pandocCommand = new Command() - .name("pandoc") - .stopEarly() - .arguments("[...args]") - .description( - "Run the version of Pandoc embedded within Quarto.\n\n" + - "You can pass arbitrary command line arguments to quarto pandoc (they will\n" + - "be passed through unmodified to Pandoc)", - ) - .example( - "Render markdown to HTML", - "quarto pandoc document.md --to html --output document.html", - ) - .example( - "List Pandoc output formats", - "quarto pandoc --list-output-formats", - ); +import { Command, Option } from "npm:clipanion"; +import { execProcess } from "../../core/process.ts"; +import { pandocBinaryPath } from "../../core/resources.ts"; + +export class PandocCommand extends Command { + static name = 'pandoc'; + static paths = [[PandocCommand.name]]; + + static usage = Command.Usage({ + description: "Run the version of Pandoc embedded within Quarto.\n" + + "You can pass arbitrary command line arguments to quarto pandoc\n" + + "(they will be passed through unmodified to Pandoc)", + }) + + args = Option.Proxy(); + + async runPandoc(options = {}) { + const { env } = this.context; + return await execProcess( + { + cmd: [pandocBinaryPath(), ...this.args], + env: (env as Record), + ...options, + }, + undefined, + undefined, + undefined, + true, + ); + } + + async execute() { + const result = await this.runPandoc(); + Deno.exit(result.code); + } +} + +interface WrappedProperty { + property: string; + formatter: string; +} + +const ignoredPandocOptions = [ + // help and version flags are provided by clipanion + '--help', + '--version', + + // parsed by Quarto and injected later + "--metadata", + "--metadata-file", + + // handled by addLoggingOptions() + "--log", + + // injected from RenderFlags + "--quiet", +]; + +const createWrapperProperty = (pandocArgLine: string[]): WrappedProperty | null => { + // short options such as -s[true|false] cannot be parsed by clipanion + const supportedOptions = pandocArgLine.filter(option => !option.includes('[true|false]')); + if (supportedOptions.length === 0) { + return null; + } + + const optionNames = supportedOptions.map(flag => flag.split(/[ =[]/)[0]); + const longOptionName = optionNames.find(option => option.startsWith('--'))!; + + if (ignoredPandocOptions.includes(longOptionName)) { + return null; + } + + const propertyName = longOptionName.replace(/^--/, ''); + + const withoutParameter = !supportedOptions.some(option => option.includes('=')); + if (withoutParameter) { + return { + property: `['${propertyName}'] = Option.Boolean('${optionNames.join(',')}', { hidden: true });`, + formatter: `formatBooleanOption('${longOptionName}', this['${propertyName}'])`, + } + } + + const optionConfig = ['hidden: true']; + + // handle special cases first + if (propertyName === 'number-offset') { + optionConfig.push('validator: isArray(isNumber())'); + return { + property: `['${propertyName}'] = Option.Array('${optionNames.join(',')}', { ${optionConfig.join(', ')} });`, + formatter: `formatArrayOption('${longOptionName}', this['${propertyName}'])`, + } + } + + if (propertyName === 'pdf-engine-opt') { + return { + property: `['${propertyName}'] = Option.Array('${optionNames.join(',')}', { ${optionConfig.join(', ')} });`, + formatter: `formatArrayOption('${longOptionName}', this['${propertyName}'])`, + } + } + + const booleanParameter = supportedOptions.some(option => option.includes('[=true|false]')); + const numberParameter = supportedOptions.some(option => option.includes('=NUMBER')); + const optionalParameter = supportedOptions.some(option => option.includes('[=')); + if (booleanParameter) { + optionConfig.push('tolerateBoolean: true'); + optionConfig.push('validator: isBoolean()'); + } else if (numberParameter) { + optionConfig.push('validator: isNumber()'); + } + else if (optionalParameter) { + optionConfig.push('tolerateBoolean: true'); + } + + return { + property: `['${propertyName}'] = Option.String('${optionNames.join(',')}', { ${optionConfig.join(', ')} });`, + formatter: `formatStringOption('${longOptionName}', this['${propertyName}'])`, + }; +} + +export class GeneratePandocWrapperCommand extends PandocCommand { + static paths = [['generate-pandoc-wrapper']]; + + static usage = Command.Usage({ + category: 'internal', + description: "Produce class wrapping the Pandoc command line arguments.", + }) + + args = ['--help']; + + async execute() { + const {stdout} = await this.runPandoc({ stdout: "null" }); + + const pandocArgs = stdout!.trimEnd() + .split('\n') + .splice(1) + .map(line => + line.trim() + .replaceAll(', ', ' ') + .split(/ {2,}/) + ); + + const wrappedProperties = pandocArgs.map(createWrapperProperty).filter(Boolean) as WrappedProperty[]; + const output = ` +// generated by quarto generate-pandoc-wrapper + +import { Command, Option } from "npm:clipanion"; +import { isArray, isBoolean, isNumber } from "npm:typanion"; +import { formatArrayOption, formatBooleanOption, formatStringOption } from "./formatters.ts"; + +export abstract class PandocWrapperCommand extends Command { + ${wrappedProperties.map(({ property }) => property).join('\n ')} + + get formattedPandocArgs(): string[] { + return [ + ${wrappedProperties.map(({ formatter }) => formatter).join(',\n ')} + ].filter(Boolean) as string[]; + } +} + `; + const tempFilePath = await Deno.makeTempFile(); + await Deno.writeTextFile(tempFilePath, output.trim() + '\n'); + + const outputFilePath = join(import.meta.dirname!, 'wrapper.ts'); + await Deno.rename(tempFilePath, outputFilePath); + } +} \ No newline at end of file diff --git a/src/command/pandoc/formatters.ts b/src/command/pandoc/formatters.ts new file mode 100644 index 00000000000..0078d49a566 --- /dev/null +++ b/src/command/pandoc/formatters.ts @@ -0,0 +1,8 @@ +export const formatBooleanOption = (optionName: string, value: boolean | undefined) => + value === undefined ? undefined : optionName; + +export const formatStringOption = (optionName: string, value: string | boolean | number | undefined) => + value === undefined ? undefined : `${optionName}=${value}`; + +export const formatArrayOption = (optionName: string, values: number[] | string[] | undefined) => + values === undefined ? undefined : values.map(value => formatStringOption(optionName, value)).join(' '); diff --git a/src/command/pandoc/wrapper.ts b/src/command/pandoc/wrapper.ts new file mode 100644 index 00000000000..ccba378d800 --- /dev/null +++ b/src/command/pandoc/wrapper.ts @@ -0,0 +1,205 @@ +// generated by quarto generate-pandoc-wrapper + +import { Command, Option } from "npm:clipanion"; +import { isArray, isBoolean, isNumber } from "npm:typanion"; +import { formatArrayOption, formatBooleanOption, formatStringOption } from "./formatters.ts"; + +export abstract class PandocWrapperCommand extends Command { + ['from'] = Option.String('-f,-r,--from,--read', { hidden: true }); + ['to'] = Option.String('-t,-w,--to,--write', { hidden: true }); + ['output'] = Option.String('-o,--output', { hidden: true }); + ['data-dir'] = Option.String('--data-dir', { hidden: true }); + ['defaults'] = Option.String('-d,--defaults', { hidden: true }); + ['file-scope'] = Option.String('--file-scope', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['sandbox'] = Option.String('--sandbox', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['standalone'] = Option.String('--standalone', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['template'] = Option.String('--template', { hidden: true }); + ['variable'] = Option.String('-V,--variable', { hidden: true }); + ['wrap'] = Option.String('--wrap', { hidden: true }); + ['ascii'] = Option.String('--ascii', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['toc'] = Option.String('--toc,--table-of-contents', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['toc-depth'] = Option.String('--toc-depth', { hidden: true, validator: isNumber() }); + ['number-sections'] = Option.String('--number-sections', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['number-offset'] = Option.Array('--number-offset', { hidden: true, validator: isArray(isNumber()) }); + ['top-level-division'] = Option.String('--top-level-division', { hidden: true }); + ['extract-media'] = Option.String('--extract-media', { hidden: true }); + ['resource-path'] = Option.String('--resource-path', { hidden: true }); + ['include-in-header'] = Option.String('-H,--include-in-header', { hidden: true }); + ['include-before-body'] = Option.String('-B,--include-before-body', { hidden: true }); + ['include-after-body'] = Option.String('-A,--include-after-body', { hidden: true }); + ['no-highlight'] = Option.Boolean('--no-highlight', { hidden: true }); + ['highlight-style'] = Option.String('--highlight-style', { hidden: true }); + ['syntax-definition'] = Option.String('--syntax-definition', { hidden: true }); + ['dpi'] = Option.String('--dpi', { hidden: true, validator: isNumber() }); + ['eol'] = Option.String('--eol', { hidden: true }); + ['columns'] = Option.String('--columns', { hidden: true, validator: isNumber() }); + ['preserve-tabs'] = Option.String('--preserve-tabs', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['tab-stop'] = Option.String('--tab-stop', { hidden: true, validator: isNumber() }); + ['pdf-engine'] = Option.String('--pdf-engine', { hidden: true }); + ['pdf-engine-opt'] = Option.Array('--pdf-engine-opt', { hidden: true }); + ['reference-doc'] = Option.String('--reference-doc', { hidden: true }); + ['self-contained'] = Option.String('--self-contained', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['embed-resources'] = Option.String('--embed-resources', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['link-images'] = Option.String('--link-images', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['request-header'] = Option.String('--request-header', { hidden: true }); + ['no-check-certificate'] = Option.String('--no-check-certificate', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['abbreviations'] = Option.String('--abbreviations', { hidden: true }); + ['indented-code-classes'] = Option.String('--indented-code-classes', { hidden: true }); + ['default-image-extension'] = Option.String('--default-image-extension', { hidden: true }); + ['filter'] = Option.String('-F,--filter', { hidden: true }); + ['lua-filter'] = Option.String('-L,--lua-filter', { hidden: true }); + ['shift-heading-level-by'] = Option.String('--shift-heading-level-by', { hidden: true, validator: isNumber() }); + ['base-header-level'] = Option.String('--base-header-level', { hidden: true, validator: isNumber() }); + ['track-changes'] = Option.String('--track-changes', { hidden: true }); + ['strip-comments'] = Option.String('--strip-comments', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['reference-links'] = Option.String('--reference-links', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['reference-location'] = Option.String('--reference-location', { hidden: true }); + ['figure-caption-position'] = Option.String('--figure-caption-position', { hidden: true }); + ['table-caption-position'] = Option.String('--table-caption-position', { hidden: true }); + ['markdown-headings'] = Option.String('--markdown-headings', { hidden: true }); + ['list-tables'] = Option.String('--list-tables', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['listings'] = Option.String('--listings', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['incremental'] = Option.String('--incremental', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['slide-level'] = Option.String('--slide-level', { hidden: true, validator: isNumber() }); + ['section-divs'] = Option.String('--section-divs', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['html-q-tags'] = Option.String('--html-q-tags', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['email-obfuscation'] = Option.String('--email-obfuscation', { hidden: true }); + ['id-prefix'] = Option.String('--id-prefix', { hidden: true }); + ['title-prefix'] = Option.String('-T,--title-prefix', { hidden: true }); + ['css'] = Option.String('-c,--css', { hidden: true }); + ['epub-subdirectory'] = Option.String('--epub-subdirectory', { hidden: true }); + ['epub-cover-image'] = Option.String('--epub-cover-image', { hidden: true }); + ['epub-title-page'] = Option.String('--epub-title-page', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['epub-metadata'] = Option.String('--epub-metadata', { hidden: true }); + ['epub-embed-font'] = Option.String('--epub-embed-font', { hidden: true }); + ['split-level'] = Option.String('--split-level', { hidden: true, validator: isNumber() }); + ['chunk-template'] = Option.String('--chunk-template', { hidden: true }); + ['epub-chapter-level'] = Option.String('--epub-chapter-level', { hidden: true, validator: isNumber() }); + ['ipynb-output'] = Option.String('--ipynb-output', { hidden: true }); + ['citeproc'] = Option.Boolean('-C,--citeproc', { hidden: true }); + ['bibliography'] = Option.String('--bibliography', { hidden: true }); + ['csl'] = Option.String('--csl', { hidden: true }); + ['citation-abbreviations'] = Option.String('--citation-abbreviations', { hidden: true }); + ['natbib'] = Option.Boolean('--natbib', { hidden: true }); + ['biblatex'] = Option.Boolean('--biblatex', { hidden: true }); + ['mathml'] = Option.Boolean('--mathml', { hidden: true }); + ['webtex'] = Option.String('--webtex', { hidden: true, tolerateBoolean: true }); + ['mathjax'] = Option.String('--mathjax', { hidden: true, tolerateBoolean: true }); + ['katex'] = Option.String('--katex', { hidden: true, tolerateBoolean: true }); + ['gladtex'] = Option.Boolean('--gladtex', { hidden: true }); + ['trace'] = Option.String('--trace', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['dump-args'] = Option.String('--dump-args', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['ignore-args'] = Option.String('--ignore-args', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['verbose'] = Option.Boolean('--verbose', { hidden: true }); + ['fail-if-warnings'] = Option.String('--fail-if-warnings', { hidden: true, tolerateBoolean: true, validator: isBoolean() }); + ['bash-completion'] = Option.Boolean('--bash-completion', { hidden: true }); + ['list-input-formats'] = Option.Boolean('--list-input-formats', { hidden: true }); + ['list-output-formats'] = Option.Boolean('--list-output-formats', { hidden: true }); + ['list-extensions'] = Option.String('--list-extensions', { hidden: true, tolerateBoolean: true }); + ['list-highlight-languages'] = Option.Boolean('--list-highlight-languages', { hidden: true }); + ['list-highlight-styles'] = Option.Boolean('--list-highlight-styles', { hidden: true }); + ['print-default-template'] = Option.String('-D,--print-default-template', { hidden: true }); + ['print-default-data-file'] = Option.String('--print-default-data-file', { hidden: true }); + ['print-highlight-style'] = Option.String('--print-highlight-style', { hidden: true }); + + get formattedPandocArgs(): string[] { + return [ + formatStringOption('--from', this['from']), + formatStringOption('--to', this['to']), + formatStringOption('--output', this['output']), + formatStringOption('--data-dir', this['data-dir']), + formatStringOption('--defaults', this['defaults']), + formatStringOption('--file-scope', this['file-scope']), + formatStringOption('--sandbox', this['sandbox']), + formatStringOption('--standalone', this['standalone']), + formatStringOption('--template', this['template']), + formatStringOption('--variable', this['variable']), + formatStringOption('--wrap', this['wrap']), + formatStringOption('--ascii', this['ascii']), + formatStringOption('--toc', this['toc']), + formatStringOption('--toc-depth', this['toc-depth']), + formatStringOption('--number-sections', this['number-sections']), + formatArrayOption('--number-offset', this['number-offset']), + formatStringOption('--top-level-division', this['top-level-division']), + formatStringOption('--extract-media', this['extract-media']), + formatStringOption('--resource-path', this['resource-path']), + formatStringOption('--include-in-header', this['include-in-header']), + formatStringOption('--include-before-body', this['include-before-body']), + formatStringOption('--include-after-body', this['include-after-body']), + formatBooleanOption('--no-highlight', this['no-highlight']), + formatStringOption('--highlight-style', this['highlight-style']), + formatStringOption('--syntax-definition', this['syntax-definition']), + formatStringOption('--dpi', this['dpi']), + formatStringOption('--eol', this['eol']), + formatStringOption('--columns', this['columns']), + formatStringOption('--preserve-tabs', this['preserve-tabs']), + formatStringOption('--tab-stop', this['tab-stop']), + formatStringOption('--pdf-engine', this['pdf-engine']), + formatArrayOption('--pdf-engine-opt', this['pdf-engine-opt']), + formatStringOption('--reference-doc', this['reference-doc']), + formatStringOption('--self-contained', this['self-contained']), + formatStringOption('--embed-resources', this['embed-resources']), + formatStringOption('--link-images', this['link-images']), + formatStringOption('--request-header', this['request-header']), + formatStringOption('--no-check-certificate', this['no-check-certificate']), + formatStringOption('--abbreviations', this['abbreviations']), + formatStringOption('--indented-code-classes', this['indented-code-classes']), + formatStringOption('--default-image-extension', this['default-image-extension']), + formatStringOption('--filter', this['filter']), + formatStringOption('--lua-filter', this['lua-filter']), + formatStringOption('--shift-heading-level-by', this['shift-heading-level-by']), + formatStringOption('--base-header-level', this['base-header-level']), + formatStringOption('--track-changes', this['track-changes']), + formatStringOption('--strip-comments', this['strip-comments']), + formatStringOption('--reference-links', this['reference-links']), + formatStringOption('--reference-location', this['reference-location']), + formatStringOption('--figure-caption-position', this['figure-caption-position']), + formatStringOption('--table-caption-position', this['table-caption-position']), + formatStringOption('--markdown-headings', this['markdown-headings']), + formatStringOption('--list-tables', this['list-tables']), + formatStringOption('--listings', this['listings']), + formatStringOption('--incremental', this['incremental']), + formatStringOption('--slide-level', this['slide-level']), + formatStringOption('--section-divs', this['section-divs']), + formatStringOption('--html-q-tags', this['html-q-tags']), + formatStringOption('--email-obfuscation', this['email-obfuscation']), + formatStringOption('--id-prefix', this['id-prefix']), + formatStringOption('--title-prefix', this['title-prefix']), + formatStringOption('--css', this['css']), + formatStringOption('--epub-subdirectory', this['epub-subdirectory']), + formatStringOption('--epub-cover-image', this['epub-cover-image']), + formatStringOption('--epub-title-page', this['epub-title-page']), + formatStringOption('--epub-metadata', this['epub-metadata']), + formatStringOption('--epub-embed-font', this['epub-embed-font']), + formatStringOption('--split-level', this['split-level']), + formatStringOption('--chunk-template', this['chunk-template']), + formatStringOption('--epub-chapter-level', this['epub-chapter-level']), + formatStringOption('--ipynb-output', this['ipynb-output']), + formatBooleanOption('--citeproc', this['citeproc']), + formatStringOption('--bibliography', this['bibliography']), + formatStringOption('--csl', this['csl']), + formatStringOption('--citation-abbreviations', this['citation-abbreviations']), + formatBooleanOption('--natbib', this['natbib']), + formatBooleanOption('--biblatex', this['biblatex']), + formatBooleanOption('--mathml', this['mathml']), + formatStringOption('--webtex', this['webtex']), + formatStringOption('--mathjax', this['mathjax']), + formatStringOption('--katex', this['katex']), + formatBooleanOption('--gladtex', this['gladtex']), + formatStringOption('--trace', this['trace']), + formatStringOption('--dump-args', this['dump-args']), + formatStringOption('--ignore-args', this['ignore-args']), + formatBooleanOption('--verbose', this['verbose']), + formatStringOption('--fail-if-warnings', this['fail-if-warnings']), + formatBooleanOption('--bash-completion', this['bash-completion']), + formatBooleanOption('--list-input-formats', this['list-input-formats']), + formatBooleanOption('--list-output-formats', this['list-output-formats']), + formatStringOption('--list-extensions', this['list-extensions']), + formatBooleanOption('--list-highlight-languages', this['list-highlight-languages']), + formatBooleanOption('--list-highlight-styles', this['list-highlight-styles']), + formatStringOption('--print-default-template', this['print-default-template']), + formatStringOption('--print-default-data-file', this['print-default-data-file']), + formatStringOption('--print-highlight-style', this['print-highlight-style']) + ].filter(Boolean) as string[]; + } +} diff --git a/src/command/preview/cmd.ts b/src/command/preview/cmd.ts index 830050caa1c..035a0741658 100644 --- a/src/command/preview/cmd.ts +++ b/src/command/preview/cmd.ts @@ -1,50 +1,31 @@ /* * cmd.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ import { existsSync } from "../../deno_ral/fs.ts"; import { dirname, extname, join, relative } from "../../deno_ral/path.ts"; -import * as colors from "fmt/colors"; - -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; +import { applyCascade, isInInclusiveRange, isInteger, isNumber, isPositive } from "npm:typanion"; import { kLocalhost } from "../../core/port-consts.ts"; import { waitForPort } from "../../core/port.ts"; -import { fixupPandocArgs, parseRenderFlags } from "../render/flags.ts"; -import { - handleRenderResult, - preview, - previewFormat, - setPreviewFormat, -} from "./preview.ts"; -import { - kRenderDefault, - kRenderNone, - serveProject, -} from "../../project/serve/serve.ts"; +import { handleRenderResult, preview, previewFormat, setPreviewFormat, } from "./preview.ts"; +import { kRenderDefault, kRenderNone, serveProject, } from "../../project/serve/serve.ts"; -import { - initState, - setInitializer, -} from "../../core/lib/yaml-validation/state.ts"; +import { initState, setInitializer, } from "../../core/lib/yaml-validation/state.ts"; import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/utils.ts"; import { kProjectWatchInputs, ProjectContext } from "../../project/types.ts"; import { projectContext } from "../../project/project-context.ts"; -import { - projectIsServeable, - projectPreviewServe, -} from "../../project/project-shared.ts"; +import { projectIsServeable, projectPreviewServe, } from "../../project/project-shared.ts"; import { isHtmlOutput } from "../../config/format.ts"; import { renderProject } from "../render/project.ts"; import { renderServices } from "../render/render-services.ts"; import { parseFormatString } from "../../core/pandoc/pandoc-formats.ts"; import { normalizePath } from "../../core/path.ts"; -import { kCliffyImplicitCwd } from "../../config/constants.ts"; -import { warning } from "../../deno_ral/log.ts"; import { renderFormats } from "../render/render-contexts.ts"; import { Format } from "../../config/types.ts"; import { isServerShiny, isServerShinyPython } from "../../core/render.ts"; @@ -54,220 +35,143 @@ import { fileExecutionEngine } from "../../execute/engine.ts"; import { notebookContext } from "../../render/notebook/notebook-context.ts"; import { singleFileProjectContext } from "../../project/types/single-file/single-file.ts"; import { exitWithCleanup } from "../../core/cleanup.ts"; +import { RenderCommand } from "../render/cmd.ts"; + +const isPort = applyCascade(isNumber(), [ + isInteger(), + isInInclusiveRange(1, 65535), +]); + +export class PreviewCommand extends RenderCommand { + static name = 'preview'; + static paths = [[PreviewCommand.name]]; + + static usage = Command.Usage({ + description: + "Render and preview a document or website project.\n\nAutomatically reloads the browser when " + + "input files or document resources (e.g. CSS) change.\n\n" + + "For website preview, the most recent execution results of computational documents are used to render\n" + + "the site (this is to optimize startup time). If you want to perform a full render prior to\n" + + 'previewing pass the --render option with "all" or a comma-separated list of formats to render.\n\n' + + "For document preview, input file changes will result in a re-render (pass --no-watch to prevent).\n\n" + + "You can also include arbitrary command line arguments to be forwarded to quarto render.", + examples: [ + [ + "Preview document", + `$0 ${PreviewCommand.name} doc.qmd`, + ], + [ + "Preview document with render command line args", + `$0 ${PreviewCommand.name} doc.qmd --toc`, + ], + [ + "Preview document (don't watch for input changes)", + `$0 ${PreviewCommand.name} doc.qmd --no-watch-inputs`, + ], + [ + "Preview website with most recent execution results", + `$0 ${PreviewCommand.name}`, + ], + [ + "Previewing website using a specific port", + `$0 ${PreviewCommand.name} --port 4444`, + ], + [ + "Preview website (don't open a browser)", + `$0 ${PreviewCommand.name} --no-browser`, + ], + [ + "Fully render all website/book formats then preview", + `$0 ${PreviewCommand.name} --render all`, + ], + [ + "Fully render the html format then preview", + `$0 ${PreviewCommand.name} --render html`, + ] + ] + }) + + browser = Option.Boolean('--browser,--browse', { + description: "Open a browser to preview the site. (default true)" + }); + + browserPath = Option.String('--browser-path'); + + host = Option.String('--host', { + description: "Hostname to bind to (defaults to 127.0.0.1)", + }); + + navigate = Option.Boolean('--navigate', { + description: "Navigate the browser automatically when outputs are updated. (default true)" + }); + + noRender = Option.Boolean('--no-render', { + description: "Alias for --no-watch-inputs (used by older versions of rstudio)", + hidden: true, + }); + + noWatch = Option.Boolean('--no-watch', { + description: "Alias for --no-watch-inputs (used by older versions of quarto r package)", + hidden: true, + }); + + presentation = Option.Boolean('--presentation'); + + port = Option.String('--port', { + description: "Suggested port to listen on (defaults to random value between 3000 and 8000).\n" + + "If the port is not available then a random port between 3000 and 8000 will be selected.", + validator: isPort + }) + + render = Option.String('--render', kRenderNone, { + description: "Render to the specified format(s) before previewing", + tolerateBoolean: true, + }); + + serve = Option.Boolean('--serve', { + description: "Run a local preview web server\n" + + "(default true; if false: just monitor and re-render input files)" + }); + + timeoutInSeconds = Option.String('--timeout', { + description: "Time (in seconds) after which to exit if there are no active clients.", + validator: applyCascade(isNumber(), [ + isInteger(), + isPositive(), + ]) + }); + + watchInputs = Option.Boolean('--watch-inputs', { + description: "Re-render input files when they change. (default true)" + }); + + async execute() { + // --no-watch: alias for --no-watch-inputs (used by older versions of quarto r package) + // --no-render: alias for --no-watch-inputs (used by older versions of rstudio) + if (this.noWatch || this.noRender) { + this.watchInputs = false; + } -export const previewCommand = new Command() - .name("preview") - .stopEarly() - .option( - "--port [port:number]", - "Suggested port to listen on (defaults to random value between 3000 and 8000).\n" + - "If the port is not available then a random port between 3000 and 8000 will be selected.", - ) - .option( - "--host [host:string]", - "Hostname to bind to (defaults to 127.0.0.1)", - ) - .option( - "--render [format:string]", - "Render to the specified format(s) before previewing", - { - default: kRenderNone, - }, - ) - .option( - "--no-serve", - "Don't run a local preview web server (just monitor and re-render input files)", - ) - .option( - "--no-navigate", - "Don't navigate the browser automatically when outputs are updated.", - ) - .option( - "--no-browser", - "Don't open a browser to preview the site.", - ) - .option( - "--no-watch-inputs", - "Do not re-render input files when they change.", - ) - .option( - "--timeout", - "Time (in seconds) after which to exit if there are no active clients.", - ) - .arguments("[file:string] [...args:string]") - .description( - "Render and preview a document or website project.\n\nAutomatically reloads the browser when " + - "input files or document resources (e.g. CSS) change.\n\n" + - "For website preview, the most recent execution results of computational documents are used to render\n" + - "the site (this is to optimize startup time). If you want to perform a full render prior to\n" + - 'previewing pass the --render option with "all" or a comma-separated list of formats to render.\n\n' + - "For document preview, input file changes will result in a re-render (pass --no-watch to prevent).\n\n" + - "You can also include arbitrary command line arguments to be forwarded to " + - colors.bold("quarto render") + ".", - ) - .example( - "Preview document", - "quarto preview doc.qmd", - ) - .example( - "Preview document with render command line args", - "quarto preview doc.qmd --toc", - ) - .example( - "Preview document (don't watch for input changes)", - "quarto preview doc.qmd --no-watch-inputs", - ) - .example( - "Preview website with most recent execution results", - "quarto preview", - ) - .example( - "Previewing website using a specific port", - "quarto preview --port 4444", - ) - .example( - "Preview website (don't open a browser)", - "quarto preview --no-browser", - ) - .example( - "Fully render all website/book formats then preview", - "quarto preview --render all", - ) - .example( - "Fully render the html format then preview", - "quarto preview --render html", - ) - // deno-lint-ignore no-explicit-any - .action(async (options: any, file?: string, ...args: string[]) => { // one-time initialization of yaml validation modules setInitializer(initYamlIntelligenceResourcesFromFilesystem); await initState(); - // if input is missing but there exists an args parameter which is a .qmd or .ipynb file, - // issue a warning. - if (!file || file === kCliffyImplicitCwd) { - file = Deno.cwd(); - const firstArg = args.find((arg) => - arg.endsWith(".qmd") || arg.endsWith(".ipynb") - ); - if (firstArg) { - warning( - "`quarto preview` invoked with no input file specified (the parameter order matters).\nQuarto will preview the current directory by default.\n" + - `Did you mean to run \`quarto preview ${firstArg} ${ - args.filter((arg) => arg !== firstArg).join(" ") - }\`?\n` + - "Use `quarto preview --help` for more information.", - ); + if (this.port) { + if (!await waitForPort({ port: this.port, hostname: kLocalhost })) { + throw new Error(`Requested port ${this.port} is already in use.`); } } - file = file || Deno.cwd(); + // interpret first input as format if --render is used without parameter + const render = typeof (this.render) === "boolean" ? (this.inputs.shift() || kRenderDefault) : this.render; + + let file = this.inputs[0] || Deno.cwd(); if (!existsSync(file)) { throw new Error(`${file} not found`); } - // show help if requested - if (args.length > 0 && args[0] === "--help") { - previewCommand.showHelp(); - return; - } - - // pull out our command line args - const portPos = args.indexOf("--port"); - if (portPos !== -1) { - options.port = parseInt(args[portPos + 1]); - args.splice(portPos, 2); - } - const hostPos = args.indexOf("--host"); - if (hostPos !== -1) { - options.host = String(args[hostPos + 1]); - args.splice(hostPos, 2); - } - const renderPos = args.indexOf("--render"); - if (renderPos !== -1) { - options.render = String(args[renderPos + 1]); - args.splice(renderPos, 2); - } - const presentationPos = args.indexOf("--presentation"); - if (presentationPos !== -1) { - options.presentation = true; - args.splice(presentationPos, 1); - } else { - options.presentation = false; - } - const browserPathPos = args.indexOf("--browser-path"); - if (browserPathPos !== -1) { - options.browserPath = String(args[browserPathPos + 1]); - args.splice(browserPathPos, 2); - } - const noServePos = args.indexOf("--no-serve"); - if (noServePos !== -1) { - options.noServe = true; - args.splice(noServePos, 1); - } - const noBrowsePos = args.indexOf("--no-browse"); - if (noBrowsePos !== -1) { - options.browse = false; - args.splice(noBrowsePos, 1); - } - const noBrowserPos = args.indexOf("--no-browser"); - if (noBrowserPos !== -1) { - options.browser = false; - args.splice(noBrowserPos, 1); - } - const navigatePos = args.indexOf("--navigate"); - if (navigatePos !== -1) { - options.navigate = true; - args.splice(navigatePos, 1); - } - const noNavigatePos = args.indexOf("--no-navigate"); - if (noNavigatePos !== -1) { - options.navigate = false; - args.splice(noNavigatePos, 1); - } - const watchInputsPos = args.indexOf("--watch-inputs"); - if (watchInputsPos !== -1) { - options.watchInputs = true; - args.splice(watchInputsPos, 1); - } - const noWatchInputsPos = args.indexOf("--no-watch-inputs"); - if (noWatchInputsPos !== -1) { - options.watchInputs = false; - args.splice(noWatchInputsPos, 1); - } - const timeoutPos = args.indexOf("--timeout"); - if (timeoutPos !== -1) { - options.timeout = parseInt(args[timeoutPos + 1]); - args.splice(timeoutPos, 2); - } - - // alias for --no-watch-inputs (used by older versions of quarto r package) - const noWatchPos = args.indexOf("--no-watch"); - if (noWatchPos !== -1) { - options.watchInputs = false; - args.splice(noWatchPos, 1); - } - // alias for --no-watch-inputs (used by older versions of rstudio) - const noRenderPos = args.indexOf("--no-render"); - if (noRenderPos !== -1) { - options.watchInputs = false; - args.splice(noRenderPos, 1); - } - - if (options.port) { - // try to bind to requested port (error if its in use) - const port = parseInt(options.port); - if (await waitForPort({ port, hostname: kLocalhost })) { - options.port = port; - } else { - throw new Error(`Requested port ${options.port} is already in use.`); - } - } - - // extract pandoc flag values we know/care about, then fixup args as - // necessary (remove our flags that pandoc doesn't know about) - const flags = await parseRenderFlags(args); - args = fixupPandocArgs(args, flags); + const flags = await this.parseRenderFlags(); + const args = this.formattedPandocArgs; // if this is a single-file preview within a 'serveable' project // without a specific render directive then render the file @@ -278,15 +182,15 @@ export const previewCommand = new Command() // get project and preview format const nbContext = notebookContext(); const project = (await projectContext(dirname(file), nbContext)) || - singleFileProjectContext(file, nbContext); + singleFileProjectContext(file, nbContext); const formats = await (async () => { const services = renderServices(nbContext); try { return await renderFormats( - file!, - services, - undefined, - project, + file!, + services, + undefined, + project, ); } finally { services.cleanup(); @@ -303,30 +207,26 @@ export const previewCommand = new Command() if (isServerShinyPython(renderFormat, engine?.name)) { const result = await previewShiny({ input: file, - render: !!options.render, - port: typeof (options.port) === "string" - ? parseInt(options.port) - : options.port, - host: options.host, - browser: options.browser, + render: render !== kRenderNone, + port: this.port, + host: this.host, + browser: this.browser !== false, projectDir: project?.dir, tempDir: Deno.makeTempDirSync(), format, pandocArgs: args, - watchInputs: options.watchInputs!, + watchInputs: this.watchInputs!, }); exitWithCleanup(result.code); throw new Error(); // unreachable } else { const result = await serve({ input: file, - render: !!options.render, - port: typeof (options.port) === "string" - ? parseInt(options.port) - : options.port, - host: options.host, + render: render !== kRenderNone, + port: this.port, + host: this.host, format: format, - browser: options.browser, + browser: this.browser !== false, projectDir: project?.dir, tempDir: Deno.makeTempDirSync(), }); @@ -345,14 +245,14 @@ export const previewCommand = new Command() if (extname(file) === ".md" && projectPreviewServe(project)) { setPreviewFormat(format, flags, args); touchPath = filePath; - options.browserPath = ""; + this.browserPath = ""; file = project.dir; projectTarget = project; } } else { if ( - isHtmlOutput(parseFormatString(format).baseFormat, true) || - projectPreviewServe(project) + isHtmlOutput(parseFormatString(format).baseFormat, true) || + projectPreviewServe(project) ) { setPreviewFormat(format, flags, args); const services = renderServices(notebookContext()); @@ -371,8 +271,8 @@ export const previewCommand = new Command() handleRenderResult(file, renderResult); if (projectPreviewServe(project) && renderResult.baseDir) { touchPath = join( - renderResult.baseDir, - renderResult.files[0].file, + renderResult.baseDir, + renderResult.files[0].file, ); } } finally { @@ -380,9 +280,9 @@ export const previewCommand = new Command() } // re-write various targets to redirect to project preview if (projectPreviewServe(project)) { - options.browserPath = ""; + this.browserPath = ""; } else { - options.browserPath = relative(project.dir, file); + this.browserPath = relative(project.dir, file); } file = project.dir; projectTarget = project; @@ -399,37 +299,34 @@ export const previewCommand = new Command() flags, }; await serveProject(projectTarget, renderOptions, args, { - port: options.port, - host: options.host, - browser: (options.browser === false || options.browse === false) - ? false - : undefined, - [kProjectWatchInputs]: options.watchInputs, - timeout: options.timeout, - render: options.render, + port: this.port, + host: this.host, + browser: (this.browser !== false) || undefined, + [kProjectWatchInputs]: this.watchInputs, + timeout: this.timeoutInSeconds, + render, touchPath, - browserPath: options.browserPath, - navigate: options.navigate, - }, options.noServe === true); + browserPath: this.browserPath, + navigate: this.navigate !== false, + }, this.serve === false); } else { // single file preview if ( - options.render !== kRenderNone && - options.render !== kRenderDefault && - args.indexOf("--to") === -1 + render !== kRenderNone && + render !== kRenderDefault && + this.to === undefined ) { - args.push("--to", options.render); + args.push(`--to=${render}`); } await preview(relative(Deno.cwd(), file), flags, args, { - port: options.port, - host: options.host, - browser: (options.browser === false || options.browse === false) - ? false - : undefined, - [kProjectWatchInputs]: options.watchInputs, - timeout: options.timeout, - presentation: options.presentation, + port: this.port, + host: this.host, + browser: (this.browser !== false) || undefined, + [kProjectWatchInputs]: this.watchInputs, + timeout: this.timeoutInSeconds, + presentation: !!this.presentation, }); } - }); + } +} diff --git a/src/command/publish/cmd.ts b/src/command/publish/cmd.ts index d182d58e168..2db3cd92017 100644 --- a/src/command/publish/cmd.ts +++ b/src/command/publish/cmd.ts @@ -1,12 +1,15 @@ /* * cmd.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ import { existsSync } from "../../deno_ral/fs.ts"; -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; + +// TODO: replace cliffy +// see https://github.com/quarto-dev/quarto-cli/issues/10878 import { Select } from "cliffy/prompt/select.ts"; import { prompt } from "cliffy/prompt/mod.ts"; @@ -22,7 +25,6 @@ import { } from "../../core/lib/yaml-validation/state.ts"; import { projectContext, - projectInputFiles, } from "../../project/project-context.ts"; import { @@ -44,12 +46,14 @@ import { handleUnauthorized } from "../../publish/account.ts"; import { notebookContext } from "../../render/notebook/notebook-context.ts"; import { singleFileProjectContext } from "../../project/types/single-file/single-file.ts"; -export const publishCommand = - // deno-lint-ignore no-explicit-any - new Command() - .name("publish") - .description( - "Publish a document or project to a provider.\n\nAvailable providers include:\n\n" + + +export class PublishCommand extends Command { + static name = 'publish'; + static paths = [[PublishCommand.name]]; + + static usage = Command.Usage({ + description: + "Publish a document or project to a provider.\n\nAvailable providers include:\n\n" + " - Quarto Pub (quarto-pub)\n" + " - GitHub Pages (gh-pages)\n" + " - Posit Connect (connect)\n" + @@ -59,111 +63,106 @@ export const publishCommand = " - Hugging Face Spaces (huggingface)\n\n" + "Accounts are configured interactively during publishing.\n" + "Manage/remove accounts with: quarto publish accounts", - ) - .arguments("[provider] [path]") - .option( - "--id ", - "Identifier of content to publish", - ) - .option( - "--server ", - "Server to publish to", - ) - .option( - "--token ", - "Access token for publising provider", - ) - .option( - "--no-render", - "Do not render before publishing.", - ) - .option( - "--no-prompt", - "Do not prompt to confirm publishing destination", - ) - .option( - "--no-browser", - "Do not open a browser to the site after publishing", - ) - .example( - "Publish project (prompt for provider)", - "quarto publish", - ) - .example( - "Publish document (prompt for provider)", - "quarto publish document.qmd", - ) - .example( - "Publish project to Hugging Face Spaces", - "quarto publish huggingface", - ) - .example( - "Publish project to Netlify", - "quarto publish netlify", - ) - .example( - "Publish with explicit target", - "quarto publish netlify --id DA36416-F950-4647-815C-01A24233E294", - ) - .example( - "Publish project to GitHub Pages", - "quarto publish gh-pages", - ) - .example( - "Publish project to Posit Connect", - "quarto publish connect", - ) - .example( - "Publish with explicit credentials", - "quarto publish connect --server example.com --token 01A24233E294", - ) - .example( - "Publish project to Posit Cloud", - "quarto publish posit-cloud", - ) - .example( - "Publish without confirmation prompt", - "quarto publish --no-prompt", - ) - .example( - "Publish without rendering", - "quarto publish --no-render", - ) - .example( - "Publish without opening browser", - "quarto publish --no-browser", - ) - .example( - "Manage/remove publishing accounts", - "quarto publish accounts", - ) - .action( - async ( - options: PublishCommandOptions, - provider?: string, - path?: string, - ) => { - // if provider is a path and no path is specified then swap - if (provider && !path && existsSync(provider)) { - path = provider; - provider = undefined; - } + examples: [ + [ + "Publish project (prompt for provider)", + `$0 ${PublishCommand.name}`, + ], + [ + "Publish document (prompt for provider)", + `$0 ${PublishCommand.name} document.qmd`, + ], + [ + "Publish project to Hugging Face Spaces", + `$0 ${PublishCommand.name} huggingface`, + ], + [ + "Publish project to Netlify", + `$0 ${PublishCommand.name} netlify`, + ], + [ + "Publish with explicit target", + `$0 ${PublishCommand.name} netlify --id DA36416-F950-4647-815C-01A24233E294`, + ], + [ + "Publish project to GitHub Pages", + `$0 ${PublishCommand.name} gh-pages`, + ], + [ + "Publish project to Posit Connect", + `$0 ${PublishCommand.name} connect`, + ], + [ + "Publish with explicit credentials", + `$0 ${PublishCommand.name} connect --server example.com --token 01A24233E294`, + ], + [ + "Publish project to Posit Cloud", + `$0 ${PublishCommand.name} posit-cloud`, + ], + [ + "Publish without confirmation prompt", + `$0 ${PublishCommand.name} --no-prompt`, + ], + [ + "Publish without rendering", + `$0 ${PublishCommand.name} --no-render`, + ], + [ + "Publish without opening browser", + `$0 ${PublishCommand.name} --no-browser`, + ], + [ + "Manage/remove publishing accounts", + `$0 ${PublishCommand.name} accounts`, + ], + ] + }) + + provider = Option.String({ required: false }); + path_ = Option.String({ required: false }); + + id = Option.String('--id', { description: "Identifier of content to publish" }); + noBrowser = Option.Boolean('--no-browser', { description: "Do not open a browser to the site after publishing" }); + noPrompt = Option.Boolean('--no-prompt', { description: "Do not prompt to confirm publishing destination" }); + noRender = Option.Boolean('--no-render', { description: "Do not render before publishing." }); + server = Option.String('--server', { description: "Server to publish to" }); + token = Option.String('--token', { description: "Access token for publising provider" }); + + async execute() { + let provider = this.provider; + let path = this.path_; + + // if provider is a path and no path is specified then swap + if (provider && !path && existsSync(provider)) { + path = provider; + provider = undefined; + } - // if provider is 'accounts' then invoke account management ui - if (provider === "accounts") { - await manageAccounts(); - } else { - let providerInterface: PublishProvider | undefined; - if (provider) { - providerInterface = findProvider(provider); - if (!providerInterface) { - throw new Error(`Publishing source '${provider}' not found`); - } - } - await publishAction(options, providerInterface, path); + // if provider is 'accounts' then invoke account management ui + if (provider === "accounts") { + await manageAccounts(); + } else { + let providerInterface: PublishProvider | undefined; + if (provider) { + providerInterface = findProvider(provider); + if (!providerInterface) { + throw new Error(`Publishing source '${provider}' not found`); } - }, - ); + } + + const options : PublishCommandOptions = { + browser: !this.noBrowser, + id: this.id, + render: !this.noRender, + prompt: !this.noPrompt, + server: this.server, + token: this.token, + }; + await publishAction(options, providerInterface, path); + } + } +} async function publishAction( options: PublishCommandOptions, diff --git a/src/command/remove/cmd.ts b/src/command/remove/cmd.ts index da3e82bb5b1..037b983d9e4 100644 --- a/src/command/remove/cmd.ts +++ b/src/command/remove/cmd.ts @@ -1,11 +1,15 @@ /* * cmd.ts * - * Copyright (C) 2021-2022 Posit Software, PBC + * Copyright (C) 2021-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; + +// TODO: replace cliffy +// see https://github.com/quarto-dev/quarto-cli/issues/10878 import { Checkbox } from "cliffy/prompt/mod.ts"; + import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/utils.ts"; import { createTempContext } from "../../core/temp.ts"; @@ -15,137 +19,127 @@ import { createExtensionContext } from "../../extension/extension.ts"; import { extensionIdString } from "../../extension/extension-shared.ts"; import { Extension } from "../../extension/types.ts"; import { projectContext } from "../../project/project-context.ts"; -import { - afterConfirm, - loadTools, - removeTool, - selectTool, -} from "../../tools/tools-console.ts"; +import { afterConfirm, loadTools, removeTool, selectTool, } from "../../tools/tools-console.ts"; import { notebookContext } from "../../render/notebook/notebook-context.ts"; -export const removeCommand = new Command() - .name("remove") - .arguments("[target...]") - .option( - "--no-prompt", - "Do not prompt to confirm actions", - ) - .option( - "--embed ", - "Remove this extension from within another extension (used when authoring extensions).", - ) - .option( - "--update-path", - "Update system path when a tool is installed", - { - hidden: true, - }, - ) - .description( - "Removes an extension.", - ) - .example( - "Remove extension using name", - "quarto remove ", - ) - .action( - async ( - options: { prompt?: boolean; embed?: string; updatePath?: boolean }, - ...target: string[] - ) => { - await initYamlIntelligenceResourcesFromFilesystem(); - const temp = createTempContext(); - const extensionContext = createExtensionContext(); - // -- update path - try { - const resolved = resolveCompatibleArgs(target || [], "extension"); - if (resolved.action === "tool") { - if (resolved.name) { - // Explicitly provided - await removeTool(resolved.name, options.prompt, options.updatePath); +export class RemoveCommand extends Command { + static name = 'remove'; + static paths = [[RemoveCommand.name]]; + + static usage = Command.Usage({ + description: "Removes an extension.", + examples: [ + [ + "Remove extension using name", + `$0 ${RemoveCommand.name} `, + ] + ] + }); + + targets = Option.Rest(); + + embed = Option.String('--embed', {description: "Remove this extension from within another extension (used when authoring extensions)." }); + noPrompt = Option.Boolean('--no-prompt', { description: "Do not prompt to confirm actions" }); + updatePath = Option.Boolean('--update-path', { description: "Update system path when a tool is installed", hidden: true }); + + async execute() { + const prompt = !this.noPrompt; + const embed = this.embed; + const updatePath = this.updatePath; + await initYamlIntelligenceResourcesFromFilesystem(); + const temp = createTempContext(); + const extensionContext = createExtensionContext(); + + // -- update path + try { + const resolved = resolveCompatibleArgs(this.targets || [], "extension"); + if (resolved.action === "tool") { + if (resolved.name) { + // Explicitly provided + await removeTool(resolved.name, prompt, updatePath); + } else { + // Not provided, give the user a list to choose from + const allTools = await loadTools(); + if (allTools.filter((tool) => tool.installed).length === 0) { + info("No tools are installed."); } else { - // Not provided, give the user a list to choose from - const allTools = await loadTools(); - if (allTools.filter((tool) => tool.installed).length === 0) { - info("No tools are installed."); - } else { - // Select which tool should be installed - const toolTarget = await selectTool(allTools, "remove"); - if (toolTarget) { - info(""); - await removeTool(toolTarget); - } + // Select which tool should be installed + const toolTarget = await selectTool(allTools, "remove"); + if (toolTarget) { + info(""); + await removeTool(toolTarget); } } - } else { - // Not provided, give the user a list to select from - const workingDir = Deno.cwd(); + } + } else { + // Not provided, give the user a list to select from + const workingDir = Deno.cwd(); - const resolveTargetDir = async () => { - if (options.embed) { - // We're removing an embedded extension, lookup the extension - // and use its path - const context = createExtensionContext(); - const extension = await context.extension( - options.embed, + const resolveTargetDir = async () => { + if (embed) { + // We're removing an embedded extension, lookup the extension + // and use its path + const context = createExtensionContext(); + const extension = await context.extension( + embed, workingDir, - ); - if (extension) { - return extension?.path; - } else { - throw new Error(`Unable to find extension '${options.embed}.`); - } + ); + if (extension) { + return extension?.path; } else { - // Just use the current directory - return workingDir; + throw new Error(`Unable to find extension '${embed}.`); } - }; - const targetDir = await resolveTargetDir(); + } else { + // Just use the current directory + return workingDir; + } + }; + const targetDir = await resolveTargetDir(); - // Process extension - if (resolved.name) { - // explicitly provided - const extensions = await extensionContext.find( + // Process extension + if (resolved.name) { + // explicitly provided + const extensions = await extensionContext.find( resolved.name, targetDir, undefined, undefined, undefined, { builtIn: false }, - ); - if (extensions.length > 0) { - await removeExtensions(extensions.slice(), options.prompt); - } else { - info("No matching extension found."); - } + ); + if (extensions.length > 0) { + await removeExtensions(extensions.slice(), prompt); } else { - const nbContext = notebookContext(); - // Provide the with with a list - const project = await projectContext(targetDir, nbContext); - const extensions = await extensionContext.extensions( + info("No matching extension found."); + } + } else { + const nbContext = notebookContext(); + // Provide the with with a list + const project = await projectContext(targetDir, nbContext); + const extensions = await extensionContext.extensions( targetDir, project?.config, project?.dir, { builtIn: false }, - ); + ); - // Show a list - if (extensions.length > 0) { - const extensionsToRemove = await selectExtensions(extensions); - if (extensionsToRemove.length > 0) { - await removeExtensions(extensionsToRemove); - } - } else { - info("No extensions installed."); + // Show a list + if (extensions.length > 0) { + const extensionsToRemove = await selectExtensions(extensions); + if (extensionsToRemove.length > 0) { + await removeExtensions(extensionsToRemove); } + } else { + info("No extensions installed."); } } - } finally { - temp.cleanup(); } - }, - ); + } finally { + temp.cleanup(); + } + } +} // note that we're using variadic arguments here to preserve backware compatibility. export const resolveCompatibleArgs = ( diff --git a/src/command/render/cmd.ts b/src/command/render/cmd.ts index fafc3139969..bc63455c088 100644 --- a/src/command/render/cmd.ts +++ b/src/command/render/cmd.ts @@ -1,246 +1,247 @@ /* * cmd.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ -import { dirname, relative } from "../../deno_ral/path.ts"; -import { expandGlobSync } from "../../deno_ral/fs.ts"; -import { Command } from "cliffy/command/mod.ts"; -import { debug, info, warning } from "../../deno_ral/log.ts"; +import { applyCascade, isArray, isNumber, isString, makeValidator } from "npm:typanion"; -import { fixupPandocArgs, kStdOut, parseRenderFlags } from "./flags.ts"; +import { dirname, relative, isAbsolute, SEP_PATTERN } from "../../deno_ral/path.ts"; +import { expandGlobSync, existsSync } from "../../deno_ral/fs.ts"; +import { Command, Option } from "npm:clipanion"; +import { debug, info } from "../../deno_ral/log.ts"; + +import { kStdOut, parseMetadataFlagValue } from "./flags.ts"; import { renderResultFinalOutput } from "./render.ts"; import { render } from "./render-shared.ts"; import { renderServices } from "./render-services.ts"; +import { normalizePath } from "../../core/path.ts"; +import { readYaml } from "../../core/yaml.ts"; -import { RenderResult } from "./types.ts"; -import { kCliffyImplicitCwd } from "../../config/constants.ts"; -import { InternalError } from "../../core/lib/error.ts"; +import { RenderFlags, RenderResult } from "./types.ts"; +import { + kEmbedResources, + kListings, + kNumberOffset, + kNumberSections, + kReferenceLocation, + kSelfContained, + kShiftHeadingLevelBy, + kStandalone, + kTopLevelDivision, +} from "../../config/constants.ts"; import { notebookContext } from "../../render/notebook/notebook-context.ts"; +import { PandocWrapperCommand } from "../pandoc/wrapper.ts"; +import { isQuartoMetadata } from "../../config/metadata.ts"; -export const renderCommand = new Command() - .name("render") - .stopEarly() - .arguments("[input:string] [...args]") - .description( - "Render files or projects to various document types.", - ) - .option( - "-t, --to", - "Specify output format(s).", - ) - .option( - "-o, --output", - "Write output to FILE (use '--output -' for stdout).", - ) - .option( - "--output-dir", - "Write output to DIR (path is input/project relative)", - ) - .option( - "-M, --metadata", - "Metadata value (KEY:VALUE).", - ) - .option( - "--site-url", - "Override site-url for website or book output", - ) - .option( - "--execute", - "Execute code (--no-execute to skip execution).", - ) - .option( - "-P, --execute-param", - "Execution parameter (KEY:VALUE).", - ) - .option( - "--execute-params", - "YAML file with execution parameters.", - ) - .option( - "--execute-dir", - "Working directory for code execution.", - ) - .option( - "--execute-daemon", - "Keep Jupyter kernel alive (defaults to 300 seconds).", - ) - .option( - "--execute-daemon-restart", - "Restart keepalive Jupyter kernel before render.", - ) - .option( - "--execute-debug", - "Show debug output when executing computations.", - ) - .option( - "--use-freezer", - "Force use of frozen computations for an incremental file render.", - ) - .option( - "--cache", - "Cache execution output (--no-cache to prevent cache).", - ) - .option( - "--cache-refresh", - "Force refresh of execution cache.", - ) - .option( - "--no-clean", - "Do not clean project output-dir prior to render", - ) - .option( - "--debug", - "Leave intermediate files in place after render.", - ) - .option( - "pandoc-args...", - "Additional pandoc command line arguments.", - ) - .example( - "Render Markdown", - "quarto render document.qmd\n" + - "quarto render document.qmd --to html\n" + - "quarto render document.qmd --to pdf --toc", - ) - .example( - "Render Notebook", - "quarto render notebook.ipynb\n" + - "quarto render notebook.ipynb --to docx\n" + - "quarto render notebook.ipynb --to pdf --toc", - ) - .example( - "Render Project", - "quarto render\n" + - "quarto render projdir", - ) - .example( - "Render w/ Metadata", - "quarto render document.qmd -M echo:false\n" + - "quarto render document.qmd -M code-fold:true", - ) - .example( - "Render to Stdout", - "quarto render document.qmd --output -", - ) - // deno-lint-ignore no-explicit-any - .action(async (options: any, input?: string, ...args: string[]) => { - // remove implicit clean argument (re-injected based on what the user - // actually passes in flags.ts) - if (options === undefined) { - throw new InternalError("Expected `options` to be an object"); - } - delete options.clean; - - // if an option got defined then this was mis-parsed as an 'option' - // rather than an 'arg' because no input was passed. reshuffle - // things to make them work - if (Object.keys(options).length === 1) { - const option = Object.keys(options)[0]; - const optionArg = option.replaceAll( - /([A-Z])/g, - (_match: string, p1: string) => `-${p1.toLowerCase()}`, - ); - if (input) { - args.unshift(input); - input = undefined; +const isValidOutput = applyCascade( + isString(), [ + // https://github.com/quarto-dev/quarto-cli/issues/2440 + makeValidator({ + test: (value, { errors, p } = {}) => { + if (value.match(SEP_PATTERN)) { + errors?.push(`${p ?? `.`}: option cannot specify a relative or absolute path`); + return false + } + + return true; + }, + }) + ] +); + +export class RenderCommand extends PandocWrapperCommand { + static name = 'render'; + static paths = [[RenderCommand.name]]; + + static usage = Command.Usage({ + description: "Render files or projects to various document types.", + examples: [ + [ + "Render Markdown", + [ + `$0 ${RenderCommand.name} document.qmd`, + `$0 ${RenderCommand.name} document.qmd --to html`, + `$0 ${RenderCommand.name} document.qmd --to pdf --toc`, + ].join("\n") + ], + [ + "Render Notebook", + [ + `$0 ${RenderCommand.name} notebook.ipynb`, + `$0 ${RenderCommand.name} notebook.ipynb --to docx`, + `$0 ${RenderCommand.name} notebook.ipynb --to pdf --toc`, + ].join("\n") + ], + [ + "Render Project", + [ + `$0 ${RenderCommand.name}`, + `$0 ${RenderCommand.name} projdir`, + ].join("\n") + ], + [ + "Render with Metadata", + [ + `$0 ${RenderCommand.name} document.qmd -M echo:false`, + `$0 ${RenderCommand.name} document.qmd -M code-fold:true`, + ].join("\n") + ], + [ + "Render to Stdout", + `$0 ${RenderCommand.name} document.qmd --output -`, + ] + ] + }); + + inputs = Option.Rest(); + + executeCacheRefresh = Option.Boolean("--cache-refresh", { description: "Force refresh of execution cache." }); + clean = Option.Boolean("--clean", true, { description: "Clean project output-dir prior to render" }); + debug = Option.Boolean("--debug", { description: "Leave intermediate files in place after render." }); + execute_ = Option.Boolean("--execute", { description: "Execute code (--no-execute to skip execution)." }); + executeCache = Option.Boolean("--cache", { description: "Cache execution output (--no-cache to prevent cache)." }); + executeDaemon = Option.String("--execute-daemon", { + description: "Keep Jupyter kernel alive (defaults to 300 seconds).", + validator: isNumber(), + }); + noExecuteDaemon = Option.Boolean("--no-execute-daemon"); + executeDaemonRestart = Option.Boolean("--execute-daemon-restart", { description: "Restart keepalive Jupyter kernel before render." }); + executeDebug = Option.Boolean("--execute-debug", { description: "Show debug output when executing computations." }); + executeDir = Option.String("--execute-dir", { description: "Working directory for code execution." }); + executeParam = Option.String("-P,--execute-param", { description: "Execution parameter (KEY:VALUE)." }); + metadata = Option.Array("-M,--metadata", { description: "Metadata value (KEY:VALUE).", validator: isArray(isString()) }); + metadataFiles = Option.Array("--metadata-files", { validator: isArray(isString()) }); + output = Option.String("-o,--output", { + description: "Write output to FILE (use '--output -' for stdout).", + validator: isValidOutput + }); + outputDir = Option.String("--output-dir", { description: "Write output to DIR (path is input/project relative)" }); + paramsFile = Option.String("--execute-params", { description: "YAML file with execution parameters." }); + siteUrl = Option.String("--site-url", { description: "Override site-url for website or book output" }); + to = Option.String("-t,--to", { description: "Specify output format(s)." }); + useFreezer = Option.Boolean("--use-freezer", { description: "Force use of frozen computations for an incremental file render." }); + + // TODO: should the following be documented? + makeIndexOpts = Option.Array("--latex-makeindex-opt", { hidden: true }); + tlmgrOpts = Option.Array("--latex-tlmgr-opt", { hidden: true }); + + async parseMetadata() { + type Metadata = Record< string, unknown >; + const metadata = {} as Metadata; + + for (const file in this.metadataFiles || []) { + if (!existsSync(file)) { + debug(`Ignoring missing metadata file: ${file}`); + continue; } - args.unshift("--" + optionArg); - delete options[option]; - } - // show help if requested - if (args.length > 0 && args[0] === "--help" || args[0] === "-h") { - renderCommand.showHelp(); - return; + const metadataFile = (await readYaml(file)) as Metadata; + + // TODO: merge instead of overwriting + // see https://github.com/quarto-dev/quarto-cli/issues/11139 + Object.assign(metadata, metadataFile); } - // if input is missing but there exists an args parameter which is a .qmd or .ipynb file, - // issue a warning. - if (!input || input === kCliffyImplicitCwd) { - input = Deno.cwd(); - debug(`Render: Using current directory (${input}) as implicit input`); - const firstArg = args.find((arg) => - arg.endsWith(".qmd") || arg.endsWith(".ipynb") - ); - if (firstArg) { - warning( - "`quarto render` invoked with no input file specified (the parameter order matters).\nQuarto will render the current directory by default.\n" + - `Did you mean to run \`quarto render ${firstArg} ${ - args.filter((arg) => arg !== firstArg).join(" ") - }\`?\n` + - "Use `quarto render --help` for more information.", - ); + for (const metadataValue of this.metadata || []) { + const { name, value } = parseMetadataFlagValue(metadataValue) || {}; + if (name === undefined || value === undefined) { + continue; } + + metadata[name] = value; } - const inputs = [input!]; - const firstPandocArg = args.findIndex((arg) => arg.startsWith("-")); - if (firstPandocArg !== -1) { - inputs.push(...args.slice(0, firstPandocArg)); - args = args.slice(firstPandocArg); - } - // found by - // $ pandoc --help | grep '\[=' - // cf https://github.com/jgm/pandoc/issues/8013#issuecomment-1094162866 - - const pandocArgsWithOptionalValues = [ - "--file-scope", - "--sandbox", - "--standalone", - "--ascii", - "--toc", - "--preserve-tabs", - "--self-contained", - "--embed-resources", - "--no-check-certificate", - "--strip-comments", - "--reference-links", - "--list-tables", - "--listings", - "--incremental", - "--section-divs", - "--html-q-tags", - "--epub-title-page", - "--webtex", - "--mathjax", - "--katex", - "--trace", - "--dump-args", - "--ignore-args", - "--fail-if-warnings", - "--list-extensions", - ]; - - // normalize args (to deal with args like --foo=bar) - const normalizedArgs = []; - for (const arg of args) { - const equalSignIndex = arg.indexOf("="); - if ( - equalSignIndex > 0 && arg.startsWith("-") && - !pandocArgsWithOptionalValues.includes(arg.slice(0, equalSignIndex)) - ) { - // Split the arg at the first equal sign - normalizedArgs.push(arg.slice(0, equalSignIndex)); - normalizedArgs.push(arg.slice(equalSignIndex + 1)); + const quartoMetadata = {} as Metadata; + const pandocMetadata = {} as Metadata; + + Object.entries(metadata).forEach(([key, value]) => { + if (isQuartoMetadata(key)) { + quartoMetadata[key] = value; } else { - normalizedArgs.push(arg); + pandocMetadata[key] = value; + } + }); + + return { quartoMetadata, pandocMetadata } + } + + // TODO: this can be simplified by making the Command inherit RenderFlags and naming attributes consistently + async parseRenderFlags() { + const flags: RenderFlags = {}; + + // converts any value to `true` but keeps `undefined` + const isSet = (value: any): true | undefined => !(typeof value === "undefined") || value; + + const pandocWrapper = this as PandocWrapperCommand; + flags.biblatex = pandocWrapper["biblatex"]; + flags.gladtex = pandocWrapper["gladtex"]; + flags["include-after-body"] = pandocWrapper["include-after-body"]; + flags["include-before-body"] = pandocWrapper["include-before-body"]; + flags["include-in-header"] = pandocWrapper["include-in-header"]; + flags.katex = isSet(pandocWrapper["katex"]); + flags.mathjax = isSet(pandocWrapper["mathjax"]); + flags.mathml = pandocWrapper["mathml"]; + flags.natbib = pandocWrapper["natbib"]; + flags.output = pandocWrapper.output; + flags.pdfEngine = pandocWrapper["pdf-engine"]; + flags.pdfEngineOpts = pandocWrapper["pdf-engine-opt"]; + flags.to = pandocWrapper.to; + flags.toc = pandocWrapper["toc"]; + flags.webtex = isSet(pandocWrapper["webtex"]); + flags[kEmbedResources] = pandocWrapper["embed-resources"]; + flags[kListings] = pandocWrapper["listings"]; + flags[kNumberSections] = pandocWrapper["number-sections"] || isSet(pandocWrapper["number-offset"]); + flags[kNumberOffset] = pandocWrapper["number-offset"]; + flags[kReferenceLocation] = pandocWrapper["reference-location"]; + flags[kSelfContained] = pandocWrapper["self-contained"]; + flags[kShiftHeadingLevelBy] = pandocWrapper["shift-heading-level-by"]; + flags[kStandalone] = pandocWrapper["standalone"]; + flags[kTopLevelDivision] = pandocWrapper["top-level-division"]; + + const { quartoMetadata, pandocMetadata } = await this.parseMetadata(); + flags.metadata = quartoMetadata; + flags.pandocMetadata = pandocMetadata; + + flags.clean = this.clean; + flags.debug = this.debug; + flags.execute = this.execute_; + flags.executeCache = this.executeCacheRefresh ? "refresh" : this.executeCache; + flags.executeDaemon = this.noExecuteDaemon ? 0 : this.executeDaemon; + flags.executeDaemonRestart = this.executeDaemonRestart; + flags.executeDebug = this.executeDebug; + flags.executeDir = (!this.executeDir || isAbsolute(this.executeDir)) ? this.executeDir : normalizePath(this.executeDir); + flags.makeIndexOpts = this.makeIndexOpts; + flags.outputDir = this.outputDir; + flags.paramsFile = this.paramsFile; + flags.siteUrl = this.siteUrl; + flags.tlmgrOpts = this.tlmgrOpts; + flags.useFreezer = this.useFreezer; + + const param = this.executeParam && parseMetadataFlagValue(this.executeParam); + if (param) { + if (param.value !== undefined) { + flags.params = flags.params || {}; + flags.params[param.name] = param.value; } } - args = normalizedArgs; - // extract pandoc flag values we know/care about, then fixup args as - // necessary (remove our flags that pandoc doesn't know about) - const flags = await parseRenderFlags(args); - args = fixupPandocArgs(args, flags); + return flags; + } - // run render on input files + async execute() { + if (this.inputs.length === 0) { + this.inputs = [Deno.cwd()]; + debug(`Render: Using current directory (${this.inputs[0]}) as implicit input`); + } + const flags = await this.parseRenderFlags(); + + // run render on input files let renderResult: RenderResult | undefined; let renderResultInput: string | undefined; - for (const input of inputs) { + for (const input of this.inputs) { for (const walk of expandGlobSync(input)) { const services = renderServices(notebookContext()); try { @@ -248,12 +249,11 @@ export const renderCommand = new Command() renderResult = await render(renderResultInput, { services, flags, - pandocArgs: args, + pandocArgs: this.formattedPandocArgs, useFreezer: flags.useFreezer === true, setProjectDir: true, }); - // check for error if (renderResult.error) { throw renderResult.error; } @@ -262,14 +262,15 @@ export const renderCommand = new Command() } } } + if (renderResult && renderResultInput) { // report output created - if (!options.flags?.quiet && options.flags?.output !== kStdOut) { + if (!flags.quiet && flags.output !== kStdOut) { const finalOutput = renderResultFinalOutput( - renderResult, - Deno.statSync(renderResultInput).isDirectory - ? renderResultInput - : dirname(renderResultInput), + renderResult, + Deno.statSync(renderResultInput).isDirectory + ? renderResultInput + : dirname(renderResultInput), ); if (finalOutput) { @@ -279,4 +280,5 @@ export const renderCommand = new Command() } else { throw new Error(`No valid input files passed to render`); } - }); + } +} diff --git a/src/command/render/flags.ts b/src/command/render/flags.ts index a2fa17bfcc7..22346a7217b 100644 --- a/src/command/render/flags.ts +++ b/src/command/render/flags.ts @@ -1,10 +1,8 @@ /* * flags.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ -import { existsSync } from "../../deno_ral/fs.ts"; - import { readYaml, readYamlFromString } from "../../core/yaml.ts"; import { mergeConfigs } from "../../core/config.ts"; @@ -12,353 +10,15 @@ import { mergeConfigs } from "../../core/config.ts"; import { kAuthor, kDate, - kEmbedResources, - kListings, - kNumberOffset, - kNumberSections, - kReferenceLocation, - kSelfContained, - kShiftHeadingLevelBy, - kStandalone, - kTableOfContents, - kToc, - kTopLevelDivision, } from "../../config/constants.ts"; -import { isQuartoMetadata } from "../../config/metadata.ts"; -import { RenderFlags, RenderOptions } from "./types.ts"; +import { RenderOptions } from "./types.ts"; import * as ld from "../../core/lodash.ts"; -import { isAbsolute, SEP_PATTERN } from "../../deno_ral/path.ts"; -import { normalizePath } from "../../core/path.ts"; import { removeFlags } from "../../core/flags.ts"; export const kStdOut = "-"; -export async function parseRenderFlags(args: string[]) { - const flags: RenderFlags = {}; - - const argsStack = [...args]; - let arg = argsStack.shift(); - while (arg !== undefined) { - // we need to handle equals signs here, - // because of the way pandoc handles optional arguments - // see #7868 and #7908. - const equalSignIndex = arg.indexOf("="); - if (arg.startsWith("--") && equalSignIndex > 0) { - argsStack.unshift(arg.slice(equalSignIndex + 1)); - arg = arg.slice(0, equalSignIndex); - } - switch (arg) { - case "-t": - case "--to": - arg = argsStack.shift(); - if (arg && !arg.startsWith("-")) { - flags.to = arg; - } - break; - - case "-o": - case "--output": - arg = argsStack.shift(); - if (!arg || arg.startsWith("-")) { - flags.output = kStdOut; - } else { - // https://github.com/quarto-dev/quarto-cli/issues/2440 - if (arg.match(SEP_PATTERN)) { - throw new Error( - "--output option cannot specify a relative or absolute path", - ); - } - flags.output = arg; - } - break; - - case "--output-dir": - arg = argsStack.shift(); - flags.outputDir = arg; - break; - - case "--site-url": - arg = argsStack.shift(); - flags.siteUrl = arg; - break; - - case "--standalone": - flags[kStandalone] = true; - arg = argsStack.shift(); - break; - - case "--self-contained": - flags[kSelfContained] = true; - arg = argsStack.shift(); - break; - - case "--embed-resources": - flags[kEmbedResources] = true; - arg = argsStack.shift(); - break; - - case "--pdf-engine": - arg = argsStack.shift(); - flags.pdfEngine = arg; - break; - - case "--pdf-engine-opt": - arg = argsStack.shift(); - if (arg) { - flags.pdfEngineOpts = flags.pdfEngineOpts || []; - flags.pdfEngineOpts.push(arg); - } - break; - - case "--latex-makeindex-opt": - arg = argsStack.shift(); - if (arg) { - flags.makeIndexOpts = flags.makeIndexOpts || []; - flags.makeIndexOpts.push(arg); - } - break; - - case "--latex-tlmgr-opt": - arg = argsStack.shift(); - if (arg) { - flags.tlmgrOpts = flags.tlmgrOpts || []; - flags.tlmgrOpts.push(arg); - } - break; - - case "--natbib": - arg = argsStack.shift(); - flags.natbib = true; - break; - - case "--biblatex": - arg = argsStack.shift(); - flags.biblatex = true; - break; - - case `--${kToc}`: - case `--${kTableOfContents}`: - arg = argsStack.shift(); - flags.toc = true; - break; - - case "--listings": - arg = argsStack.shift(); - flags[kListings] = true; - break; - - case "--number-sections": - arg = argsStack.shift(); - flags[kNumberSections] = true; - break; - - case "--number-offset": - arg = argsStack.shift(); - flags[kNumberSections] = true; - flags[kNumberOffset] = parseNumbers("--number-offset", arg); - break; - - case "--top-level-division": - arg = argsStack.shift(); - flags[kTopLevelDivision] = arg; - break; - - case "--shift-heading-level-by": - arg = argsStack.shift(); - flags[kShiftHeadingLevelBy] = arg; - break; - - case "--include-in-header": - case "--include-before-body": - case "--include-after-body": { - const include = arg.replace("^--", ""); - const includeFlags = flags as { [key: string]: string[] }; - includeFlags[include] = includeFlags[include] || []; - arg = argsStack.shift() as string; - includeFlags[include].push(arg); - break; - } - - case "--mathjax": - flags.mathjax = true; - arg = argsStack.shift(); - break; - - case "--katex": - flags.katex = true; - arg = argsStack.shift(); - break; - - case "--mathml": - flags.mathml = true; - arg = argsStack.shift(); - break; - - case "--gladtex": - flags.gladtex = true; - arg = argsStack.shift(); - break; - - case "--webtex": - flags.webtex = true; - arg = argsStack.shift(); - break; - - case "--execute": - flags.execute = true; - arg = argsStack.shift(); - break; - - case "--no-execute": - flags.execute = false; - arg = argsStack.shift(); - break; - - case "--execute-params": - arg = argsStack.shift(); - flags.paramsFile = arg; - break; - - case "--execute-dir": - arg = argsStack.shift(); - if (arg) { - if (isAbsolute(arg)) { - flags.executeDir = arg; - } else { - flags.executeDir = normalizePath(arg); - } - } - - break; - - case "--execute-daemon": - arg = argsStack.shift(); - flags.executeDaemon = parseInt(arg!, 10); - if (isNaN(flags.executeDaemon)) { - delete flags.executeDaemon; - } - break; - - case "--no-execute-daemon": - arg = argsStack.shift(); - flags.executeDaemon = 0; - break; - - case "--execute-daemon-restart": - arg = argsStack.shift(); - flags.executeDaemonRestart = true; - break; - - case "--execute-debug": - arg = argsStack.shift(); - flags.executeDebug = true; - break; - - case "--use-freezer": - arg = argsStack.shift(); - flags.useFreezer = true; - break; - - case "--cache": - arg = argsStack.shift(); - flags.executeCache = true; - break; - - case "--no-cache": - arg = argsStack.shift(); - flags.executeCache = false; - break; - - case "--cache-refresh": - arg = argsStack.shift(); - flags.executeCache = "refresh"; - break; - - case "--clean": - arg = argsStack.shift(); - flags.clean = true; - break; - - case "--no-clean": - arg = argsStack.shift(); - flags.clean = false; - break; - - case "--debug": - flags.debug = true; - arg = argsStack.shift(); - break; - - case "-P": - case "--execute-param": - arg = argsStack.shift(); - if (arg) { - const param = parseMetadataFlagValue(arg); - if (param) { - if (param.value !== undefined) { - flags.params = flags.params || {}; - flags.params[param.name] = param.value; - } - } - } - break; - - case "-M": - case "--metadata": - arg = argsStack.shift(); - if (arg) { - const metadata = parseMetadataFlagValue(arg); - if (metadata) { - if (metadata.value !== undefined) { - if (isQuartoMetadata(metadata.name)) { - flags.metadata = flags.metadata || {}; - flags.metadata[metadata.name] = metadata.value; - } else { - flags.pandocMetadata = flags.pandocMetadata || {}; - flags.pandocMetadata[metadata.name] = metadata.value; - } - } - } - } - break; - - case "--metadata-file": - arg = argsStack.shift(); - if (arg) { - if (existsSync(arg)) { - const metadata = - (await readYamlFromString(Deno.readTextFileSync(arg))) as Record< - string, - unknown - >; - flags.metadata = { ...flags.metadata, ...metadata }; - // flags.metadata = mergeConfigs(flags.metadata, metadata); // { ...flags.metadata, ...metadata }; - } - } - break; - - case "--reference-location": - arg = argsStack.shift(); - if (arg) { - flags[kReferenceLocation] = arg; - } - break; - - default: - arg = argsStack.shift(); - break; - } - } - - // re-inject implicit true args (e.g. clean) - if (flags.clean === undefined) { - flags.clean = true; - } - - return flags; -} export function havePandocArg(pandocArgs: string[], arg: string) { return pandocArgs.indexOf(arg) !== -1; @@ -402,60 +62,6 @@ export function replacePandocOutputArg(pandocArgs: string[], output: string) { } } -// repair 'damage' done to pandoc args by cliffy (e.g. the - after --output is dropped) -export function fixupPandocArgs(pandocArgs: string[], flags: RenderFlags) { - // --output - gets eaten by cliffy, re-inject it if necessary - pandocArgs = pandocArgs.reduce((args, arg, index) => { - args.push(arg); - if ( - flags.output === kStdOut && - pandocArgs[index + 1] !== kStdOut && - (arg === "-o" || arg === "--output") - ) { - args.push(kStdOut); - } - return args; - }, new Array()); - - // remove other args as needed - const removeArgs = new Map(); - removeArgs.set("--output-dir", true); - removeArgs.set("--site-url", true); - removeArgs.set("--execute", false); - removeArgs.set("--no-execute", false); - removeArgs.set("-P", true); - removeArgs.set("--execute-param", true); - removeArgs.set("--execute-params", true); - removeArgs.set("--execute-dir", true); - removeArgs.set("--execute-daemon", true); - removeArgs.set("--no-execute-daemon", false); - removeArgs.set("--execute-daemon-restart", false); - removeArgs.set("--execute-debug", false); - removeArgs.set("--use-freezer", false); - removeArgs.set("--cache", false); - removeArgs.set("--no-cache", false); - removeArgs.set("--cache-refresh", false); - removeArgs.set("--clean", false); - removeArgs.set("--no-clean", false); - removeArgs.set("--debug", false); - removeArgs.set("--metadata-file", true); - removeArgs.set("--latex-makeindex-opt", true); - removeArgs.set("--latex-tlmgr-opt", true); - removeArgs.set("--log", true); - removeArgs.set("--l", true); - removeArgs.set("--log-level", true); - removeArgs.set("--ll", true); - removeArgs.set("--log-format", true); - removeArgs.set("--lf", true); - removeArgs.set("--quiet", false); - removeArgs.set("--q", false); - removeArgs.set("--profile", true); - - // Remove un-needed pandoc args (including -M/--metadata as appropriate) - pandocArgs = removePandocArgs(pandocArgs, removeArgs); - return removeQuartoMetadataFlags(pandocArgs); -} - export function removePandocArgs( pandocArgs: string[], removeArgs: Map, @@ -479,29 +85,9 @@ export function removePandocTo(renderOptions: RenderOptions) { return renderOptions; } -function removeQuartoMetadataFlags(pandocArgs: string[]) { - const args: string[] = []; - for (let i = 0; i < pandocArgs.length; i++) { - const arg = pandocArgs[i]; - if (arg === "--metadata" || arg === "-M") { - const flagValue = parseMetadataFlagValue(pandocArgs[i + 1] || ""); - if ( - flagValue !== undefined && (isQuartoMetadata(flagValue.name) || - kQuartoForwardedMetadataFields.includes(flagValue.name)) - ) { - i++; - } else { - args.push(arg); - } - } else { - args.push(arg); - } - } - return args; -} export const kQuartoForwardedMetadataFields = [kAuthor, kDate]; -function parseMetadataFlagValue( +export function parseMetadataFlagValue( arg: string, ): { name: string; value: unknown } | undefined { const match = arg.match(/^([^=:]+)[=:](.*)$/); @@ -529,19 +115,3 @@ export function resolveParams( return undefined; } } - -function parseNumbers(flag: string, value?: string): number[] { - if (value) { - const numbers = value.split(/,/) - .map((number) => parseInt(number.trim(), 10)) - .filter((number) => !isNaN(number)); - if (numbers.length > 0) { - return numbers; - } - } - - // didn't parse the numbers - throw new Error( - `Invalid value for ${flag} (should be a comma separated list of numbers)`, - ); -} diff --git a/src/command/render/latexmk/quarto-latexmk.ts b/src/command/render/latexmk/quarto-latexmk.ts index 3f317e1301a..f988d94e8c6 100644 --- a/src/command/render/latexmk/quarto-latexmk.ts +++ b/src/command/render/latexmk/quarto-latexmk.ts @@ -1,17 +1,14 @@ +/* + * quarto-latexmk.ts + * + * Copyright (C) 2021-2024 Posit Software, PBC + */ import { debug } from "../../../deno_ral/log.ts"; -import { - Command, - CompletionsCommand, - HelpCommand, -} from "cliffy/command/mod.ts"; -import { parse } from "flags"; +import { Command, Option } from "npm:clipanion"; +import { isNumber } from "npm:typanion"; import { - appendLogOptions, - cleanupLogger, - initializeLogger, - logError, - logOptions, + addLoggingOptions, } from "../../../core/log.ts"; import { LatexmkOptions } from "./types.ts"; import { generatePdf } from "./pdf.ts"; @@ -20,7 +17,6 @@ import { kExeName, kExeVersion, } from "./quarto-latexmk-metadata.ts"; -import { exitWithCleanup } from "../../../core/cleanup.ts"; import { mainRunner } from "../../../core/main.ts"; interface EngineOpts { @@ -29,105 +25,92 @@ interface EngineOpts { tlmgr: string[]; } -function parseOpts(args: string[]): [string[], EngineOpts] { - const pdfOpts = parseEngineFlags("pdf-engine-opt", args); - const indexOpts = parseEngineFlags("index-engine-opt", pdfOpts.resultArgs); - const tlmgrOpts = parseEngineFlags("tlmgr-opt", indexOpts.resultArgs); - return [ - tlmgrOpts.resultArgs, - { - pdf: pdfOpts.values, - index: indexOpts.values, - tlmgr: tlmgrOpts.values, - }, - ]; -} +export abstract class PDFCommand extends Command { + input = Option.String(); + + ['bib-engine'] = Option.String('--bib-engine', {description: "The bibliography engine to use"}); + + ['index-engine'] = Option.String('--index-engine', {description: "The index engine to use"}); + ['index-engine-opt'] = Option.Array('--index-engine', { + description: "Options passed to the index engine." + + "Can be used multiple times - values will be passed in the order they appear in the command." + + "These must be specified using an '='." + }); + + max = Option.String("--max", { + description: "The maximum number of iterations", + validator: isNumber(), + }); + + min = Option.String("--min", { + description: "The minimum number of iterations", + validator: isNumber(), + }); + + ['no-auto-install'] = Option.Boolean('--no-auto-install', {description: "Disable automatic package installation"}); + ['no-auto-mk'] = Option.Boolean('--no-auto-mk', {description: "Disable the pdf generation loop"}); + ['no-clean'] = Option.Boolean('--no-clean', {description: "Don't clean intermediaries"}); + + outputDir = Option.String("--output-dir", { description: "The output directory" }); -function parseEngineFlags(optFlag: string, args: string[]) { - const values = []; - const resultArgs = []; - - for (const arg of args) { - if (arg.startsWith(`--${optFlag}=`)) { - const value = arg.split("=")[1]; - values.push(value); - } else { - resultArgs.push(arg); - } + ['pdf-engine'] = Option.String('--pdf-engine', {description: "The PDF engine to use"}); + ['pdf-engine-opt'] = Option.Array('--pdf-engine-opt', { + description: "Options passed to the pdf engine." + + "Can be used multiple times - values will be passed in the order they appear in the command." + + "These must be specified using an '='." + }); + + ['tlmgr-opt'] = Option.Array('--tlmgr-opt', { + description: "Options passed to the tlmgr engine." + + "Can be used multiple times - values will be passed in the order they appear in the command." + + "These must be specified using an '='." + }); + + async execute() { + const engineOpts: EngineOpts = { + index: this['index-engine-opt'], + pdf: this['pdf-engine-opt'], + tlmgr: this['tlmgr-opt'], + }; + const latexmkOptions = mkOptions( + this.input, + this, + engineOpts, + ); + await generatePdf(latexmkOptions); } - return { values, resultArgs }; } -export async function pdf(args: string[]) { - // Parse any of the option flags - const [parsedArgs, engineOpts] = parseOpts(args); - - const pdfCommand = new Command() - .name(kExeName) - .arguments("") - .version(kExeVersion + "\n") - .description(kExeDescription) - .option( - "--pdf-engine ", - "The PDF engine to use", - ) - .option( - "--pdf-engine-opt=", - "Options passed to the pdf engine. Can be used multiple times - values will be passed in the order they appear in the command. These must be specified using an '='.", - ) - .option( - "--index-engine ", - "The index engine to use", - ) - .option( - "--index-engine-opt=", - "Options passed to the index engine. Can be used multiple times - values will be passed in the order they appear in the command. These must be specified using an '='.", - ) - .option( - "--bib-engine ", - "The bibliography engine to use", - ) - .option( - "--no-auto-install", - "Disable automatic package installation", - ) - .option( - "--tlmgr-opt=", - "Options passed to the tlmgr engine. Can be used multiple times - values will be passed in the order they appear in the command. These must be specified using an '='.", - ) - .option( - "--no-auto-mk", - "Disable the pdf generation loop", - ) - .option( - "--min ", - "The minimum number of iterations", - ) - .option( - "--max ", - "The maximum number of iterations", - ) - .option("--output-dir ", "The output directory") - .option("--no-clean", "Don't clean intermediaries") - .throwErrors() - .action(async (options: unknown, input: string) => { - const latexmkOptions = mkOptions( - input, - options as Record, - engineOpts, - ); - await generatePdf(latexmkOptions); +const commands = [ + PDFCommand, +]; + +class PDFCli extends Cli { + constructor() { + super({ + binaryLabel: kExeDescription, + binaryName: kExeName, + binaryVersion: kExeVersion, }); - await appendLogOptions(pdfCommand) - .command("help", new HelpCommand().global()) - .command("completions", new CompletionsCommand()).hidden() - .parse(parsedArgs); + [ + ...commands, + Builtins.HelpCommand + + // TODO: shell completion is not yet supported by clipanion + // see https://github.com/arcanis/clipanion/pull/89 + // Builtins.CompletionsCommand + ].forEach((command) => { + addLoggingOptions(command); + this.register(command); + }); + } } if (import.meta.main) { await mainRunner(async () => { - await pdf(Deno.args); + const pdf = new PDFCli(); + await pdf.runExit(Deno.args); }); } diff --git a/src/command/run/run.ts b/src/command/run/run.ts index 8e355441e33..9f9c2159f16 100644 --- a/src/command/run/run.ts +++ b/src/command/run/run.ts @@ -1,10 +1,10 @@ /* * run.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/command.ts"; +import { Builtins, Command, Option } from "npm:clipanion"; import { existsSync } from "../../deno_ral/fs.ts"; import { error } from "../../deno_ral/log.ts"; @@ -32,14 +32,31 @@ export async function runScript(args: string[], env?: Record) { return await handler.run(script, args.slice(1), undefined, { env }); } -// run 'command' (this is a fake command that is here just for docs, -// the actual processing of 'run' bypasses cliffy entirely) -export const runCommand = new Command() - .name("run") - .stopEarly() - .arguments("[script:string] [...args]") - .description( - "Run a TypeScript, R, Python, or Lua script.\n\n" + - "Run a utility script written in a variety of languages. For details, see:\n" + - "https://quarto.org/docs/projects/scripts.html#periodic-scripts", - ); +export class RunCommand extends Command { + static name = 'run'; + static paths = [[RunCommand.name]]; + + static usage = Command.Usage({ + description: + "Run a TypeScript, R, Python, or Lua script.\n\n" + + "Run a utility script written in a variety of languages. For details, see:\n" + + "https://quarto.org/docs/projects/scripts.html#periodic-scripts", + }); + + script = Option.String(); + args = Option.Proxy(); + + async execute() { + // help command is consumed by Option.Proxy + // see https://github.com/arcanis/clipanion/issues/88 + const helpFlags = new Set(Builtins.HelpCommand.paths.map(path => path[0])); + const helpFlagIndex = this.args.findIndex(flag => helpFlags.has(flag)); + if (-1 < helpFlagIndex && helpFlagIndex < [...this.args, '--'].indexOf('--')) { + this.context.stdout.write(this.cli.usage(RunCommand, {detailed: true})); + return; + } + + const result = await runScript([this.script, ...this.args], this.context.env as Record); + Deno.exit(result.code); + } +} diff --git a/src/command/serve/cmd.ts b/src/command/serve/cmd.ts index 8edc80215ea..15cbfa46a64 100644 --- a/src/command/serve/cmd.ts +++ b/src/command/serve/cmd.ts @@ -1,13 +1,12 @@ /* * cmd.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; +import { applyCascade, isInInclusiveRange, isInteger, isNumber } from "npm:typanion"; -import * as colors from "fmt/colors"; -import { error } from "../../deno_ral/log.ts"; import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/utils.ts"; import { projectContext } from "../../project/project-context.ts"; @@ -21,69 +20,70 @@ import { RenderServices } from "../render/types.ts"; import { singleFileProjectContext } from "../../project/types/single-file/single-file.ts"; import { exitWithCleanup } from "../../core/cleanup.ts"; -export const serveCommand = new Command() - .name("serve") - .arguments("[input:string]") - .option( - "--no-render", - "Do not render the document before serving.", - ) - .option( - "-p, --port [port:number]", - "The TCP port that the application should listen on.", - ) - .option( - "--host [host:string]", - "Hostname to bind to (defaults to 127.0.0.1)", - ) - .option( - "--browser", - "Open a browser to preview the site.", - ) - .description( - "Serve a Shiny interactive document.\n\nBy default, the document will be rendered first and then served. " + - "If you have previously rendered the document, pass --no-render to skip the rendering step.", - ) - .example( - "Serve an interactive Shiny document", - "quarto serve dashboard.qmd", - ) - .example( - "Serve a document without rendering", - "quarto serve dashboard.qmd --no-render", - ) - // deno-lint-ignore no-explicit-any - .action(async (options: any, input?: string) => { - await initYamlIntelligenceResourcesFromFilesystem(); - if (!input) { - error( - "No input passed to serve.\n" + - "If you are attempting to preview a website or book use the " + - colors.bold("quarto preview") + " command instead.", - ); - exitWithCleanup(1); - throw new Error(); // we never reach this point but the Deno analyzer doesn't see it. - } +const isPort = applyCascade(isNumber(), [ + isInteger(), + isInInclusiveRange(1, 65535), +]); + +export class ServeCommand extends Command { + static name = 'serve'; + static paths = [[ServeCommand.name]]; + + static usage = Command.Usage({ + description: + "Serve a Shiny interactive document.\n\nBy default, the document will be rendered first and then served. " + + "If you have previously rendered the document, pass --no-render to skip the rendering step.", + examples: [ + [ + "Serve an interactive Shiny document", + `$0 ${ServeCommand.name} dashboard.qmd`, + ], + [ + "Serve a document without rendering", + `$0 ${ServeCommand.name} dashboard.qmd --no-render`, + ] + ] + }); + + input = Option.String(); + + browser = Option.Boolean('--browser', true, { + description: "Open a browser to preview the site." + }); + + host = Option.String('--host', { + description: "Hostname to bind to (defaults to 127.0.0.1)", + }); + + render = Option.Boolean('--render', true, { description: "Render before serving." }); - const { host, port } = await resolveHostAndPort(options); + port = Option.String('-p,--port', { + description: "The TCP port that the application should listen on.", + validator: isPort + }) + + async execute() { + const { browser, input, render } = this; + await initYamlIntelligenceResourcesFromFilesystem(); + const { host, port } = await resolveHostAndPort({ host: this.host, port: this.port }); const nbContext = notebookContext(); const context = (await projectContext(input, nbContext)) || - singleFileProjectContext(input, nbContext); + singleFileProjectContext(input, nbContext); const formats = await withRenderServices( - nbContext, - (services: RenderServices) => - renderFormats(input, services, undefined, context), + nbContext, + (services: RenderServices) => + renderFormats(input, services, undefined, context), ); const format = await previewFormat(input, undefined, formats, context); const result = await serve({ input, - render: options.render, + render, format, port, host, - browser: options.browser, + browser, projectDir: context?.dir, tempDir: Deno.makeTempDirSync(), }); @@ -92,4 +92,5 @@ export const serveCommand = new Command() // error diagnostics already written to stderr exitWithCleanup(result.code); } - }); + } +} diff --git a/src/command/tools/cmd.ts b/src/command/tools/cmd.ts index 1287c9126c7..842505db55a 100644 --- a/src/command/tools/cmd.ts +++ b/src/command/tools/cmd.ts @@ -1,10 +1,10 @@ /* * cmd.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { outputTools, removeTool, @@ -13,58 +13,123 @@ import { import { printToolInfo } from "../../tools/tools.ts"; import { info } from "../../deno_ral/log.ts"; -// The quarto install command -export const toolsCommand = new Command() - .name("tools") - .description( - `Display the status of Quarto installed dependencies`, - ) - .example( - "Show tool status", - "quarto tools", - ) - // deno-lint-ignore no-explicit-any - .action(async (_options: any) => { - await outputTools(); - }) - .command("install ").hidden().action( - async (_options: any, tool: string) => { - info( - "This command has been superseded. Please use `quarto install` instead.\n", - ); - await updateOrInstallTool( - tool, - "install", - ); - }, - ) - .command("info ").hidden().action( - async (_options: any, tool: string) => { - await printToolInfo(tool); - }, - ) - .command("uninstall ").hidden().action( - async (_options: any, tool: string) => { - info( - "This command has been superseded. Please use `quarto uninstall` instead.\n", - ); - await removeTool(tool); - }, - ) - .command("update ").hidden().action( - async (_options: any, tool: string) => { - info( - "This command has been superseded. Please use `quarto update` instead.\n", - ); - await updateOrInstallTool( - tool, - "update", - ); - }, - ) - .command("list").hidden().action(async () => { - info( - "This command has been superseded. Please use `quarto tools` with no arguments to list tools and status.\n", - ); - await outputTools(); - }); +export class ToolsCommand extends Command { + static name = 'tools'; + static paths = [[ToolsCommand.name]]; + + static usage = Command.Usage({ + description: "Display the status of Quarto installed dependencies", + examples: [ + [ + "Show tool status", + `$0 ${ToolsCommand.name}`, + ] + ] + }); + + async execute() { + await outputTools(); + } +} + +export class InstallToolCommand extends Command { + static paths = [[ToolsCommand.name, "install"]]; + + static usage = Command.Usage({ + category: 'internal', + description: "Display the status of Quarto installed dependencies", + examples: [ + [ + "Show tool status", + `$0 ${ToolsCommand.name}`, + ] + ] + }); + + tool = Option.String(); + + async execute() { + info( + "This command has been superseded. Please use `quarto install` instead.\n", + ); + await updateOrInstallTool( + this.tool, + "install", + ); + } +} + +export class ToolInfoCommand extends Command { + static paths = [[ToolsCommand.name, "info"]]; + + static usage = Command.Usage({ + category: 'internal', + }); + + tool = Option.String(); + + async execute() { + await printToolInfo(this.tool); + } +} + +export class UninstallToolCommand extends Command { + static paths = [[ToolsCommand.name, "uninstall"]]; + + static usage = Command.Usage({ + category: 'internal', + }); + + tool = Option.String(); + + async execute() { + info( + "This command has been superseded. Please use `quarto uninstall` instead.\n", + ); + await removeTool(this.tool); + } +} + +export class UpdateToolCommand extends Command { + static paths = [[ToolsCommand.name, "update"]]; + + static usage = Command.Usage({ + category: 'internal', + }); + + tool = Option.String(); + + async execute() { + info( + "This command has been superseded. Please use `quarto update` instead.\n", + ); + await updateOrInstallTool( + this.tool, + "update", + ); + } +} + +export class ListToolsCommand extends Command { + static paths = [[ToolsCommand.name, "list"]]; + + static usage = Command.Usage({ + category: 'internal', + }); + + async execute() { + info( + "This command has been superseded. Please use `quarto tools` with no arguments to list tools and status.\n", + ); + await outputTools(); + } +} + +export const toolsCommands = [ + ToolsCommand, + InstallToolCommand, + ToolInfoCommand, + UninstallToolCommand, + UpdateToolCommand, + ListToolsCommand, +]; diff --git a/src/command/typst/cmd.ts b/src/command/typst/cmd.ts index 33222d15f0e..29509d5655a 100644 --- a/src/command/typst/cmd.ts +++ b/src/command/typst/cmd.ts @@ -1,27 +1,59 @@ /* * typst.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/command.ts"; +import { Builtins, Command, Option } from "npm:clipanion"; +import { error } from "../../deno_ral/log.ts"; +import { execProcess } from "../../core/process.ts"; +import { typstBinaryPath } from "../../core/typst.ts"; -// typst 'command' (this is a fake command that is here just for docs, -// the actual processing of 'run' bypasses cliffy entirely) -export const typstCommand = new Command() - .name("typst") - .stopEarly() - .arguments("[...args]") - .description( - "Run the version of Typst embedded within Quarto.\n\n" + - "You can pass arbitrary command line arguments to quarto typst (they will\n" + - "be passed through unmodified to Typst)", - ) - .example( - "Compile Typst to PDF", - "quarto typst compile document.typ", - ) - .example( - "List all discovered fonts in system and custom font paths", - "quarto typst fonts", - ); +export class TypstCommand extends Command { + static name = 'typst'; + static paths = [[TypstCommand.name]]; + + static usage = Command.Usage({ + description: + "Run the version of Typst embedded within Quarto.\n\n" + + "You can pass arbitrary command line arguments to quarto typst (they will\n" + + "be passed through unmodified to Typst)", + examples: [ + [ + "Compile Typst to PDF", + `$0 ${TypstCommand.name} compile document.typ`, + ], + [ + "List all discovered fonts in system and custom font paths", + `$0 ${TypstCommand.name} fonts`, + ] + ] + }); + + args = Option.Proxy(); + + async execute() { + // help command is consumed by Option.Proxy + // see https://github.com/arcanis/clipanion/issues/88 + const helpFlags = new Set(Builtins.HelpCommand.paths.map(path => path[0])); + const helpFlagIndex = this.args.findIndex(flag => helpFlags.has(flag)); + if (-1 < helpFlagIndex && helpFlagIndex < [...this.args, '--'].indexOf('--')) { + this.context.stdout.write(this.cli.usage(TypstCommand, {detailed: true})); + return; + } + + if (this.args[0] === "update") { + error( + "The 'typst update' command is not supported.\n" + + "Please install the latest version of Quarto from http://quarto.org to get the latest supported typst features.", + ); + Deno.exit(1); + } + const { env } = this.context; + const result = await execProcess({ + cmd: [typstBinaryPath(), ...this.args], + env: env as Record, + }); + Deno.exit(result.code); + } +} diff --git a/src/command/uninstall/cmd.ts b/src/command/uninstall/cmd.ts index 23ca95653c8..9a5b02b507d 100644 --- a/src/command/uninstall/cmd.ts +++ b/src/command/uninstall/cmd.ts @@ -1,10 +1,10 @@ /* * cmd.ts * - * Copyright (C) 2021-2022 Posit Software, PBC + * Copyright (C) 2021-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/utils.ts"; import { info } from "../../deno_ral/log.ts"; @@ -14,48 +14,49 @@ import { selectTool, } from "../../tools/tools-console.ts"; -export const uninstallCommand = new Command() - .name("uninstall") - .arguments("[tool]") - .option( - "--no-prompt", - "Do not prompt to confirm actions", - ) - .option( - "--update-path", - "Update system path when a tool is installed", - ) - .description( - "Removes an extension.", - ) - .example( - "Remove extension using name", - "quarto remove ", - ) - .action( - async ( - options: { prompt?: boolean; updatePath?: boolean }, - tool?: string, - ) => { - await initYamlIntelligenceResourcesFromFilesystem(); - - // -- update path - if (tool) { - // Explicitly provided - await removeTool(tool, options.prompt, options.updatePath); + +export class UninstallCommand extends Command { + static name = 'uninstall'; + static paths = [[UninstallCommand.name]]; + + static usage = Command.Usage({ + description: "Removes an extension.", + examples: [ + [ + "Remove extension using name", + `$0 ${UninstallCommand.name} `, + ] + ] + }); + + tool = Option.String({ required: false }); + + noPrompt = Option.Boolean('--no-prompt', {description: "Do not prompt to confirm actions"}); + updatePath = Option.Boolean('--update-path', {description: "Update system path when a tool is installed"}); + + async execute() { + const { tool, updatePath } = this; + const prompt = !this.noPrompt + + await initYamlIntelligenceResourcesFromFilesystem(); + + // -- update path + if (tool) { + // Explicitly provided + await removeTool(tool, prompt, updatePath); + } else { + // Not provided, give the user a list to choose from + const allTools = await loadTools(); + if (allTools.filter((tool) => tool.installed).length === 0) { + info("No tools are installed."); } else { - // Not provided, give the user a list to choose from - const allTools = await loadTools(); - if (allTools.filter((tool) => tool.installed).length === 0) { - info("No tools are installed."); - } else { - // Select which tool should be installed - const toolTarget = await selectTool(allTools, "remove"); - if (toolTarget) { - info(""); - await removeTool(toolTarget); - } + // Select which tool should be installed + const toolTarget = await selectTool(allTools, "remove"); + if (toolTarget) { + info(""); + await removeTool(toolTarget); } } - }, - ); + } + } +} diff --git a/src/command/update/cmd.ts b/src/command/update/cmd.ts index e4cd42cb638..365d59bdddc 100644 --- a/src/command/update/cmd.ts +++ b/src/command/update/cmd.ts @@ -1,108 +1,104 @@ /* * cmd.ts * - * Copyright (C) 2021-2022 Posit Software, PBC + * Copyright (C) 2021-2024 Posit Software, PBC */ -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/utils.ts"; import { createTempContext } from "../../core/temp.ts"; import { installExtension } from "../../extension/install.ts"; import { info } from "../../deno_ral/log.ts"; -import { - loadTools, - selectTool, - updateOrInstallTool, -} from "../../tools/tools-console.ts"; +import { loadTools, selectTool, updateOrInstallTool, } from "../../tools/tools-console.ts"; import { resolveCompatibleArgs } from "../remove/cmd.ts"; -export const updateCommand = new Command() - .name("update") - .arguments("[target...]") - .option( - "--no-prompt", - "Do not prompt to confirm actions", - ) - .option( - "--embed ", - "Embed this extension within another extension (used when authoring extensions).", - ) - .description( - "Updates an extension or global dependency.", - ) - .example( - "Update extension (Github)", - "quarto update extension /", - ) - .example( - "Update extension (file)", - "quarto update extension ", - ) - .example( - "Update extension (url)", - "quarto update extension ", - ) - .example( - "Update TinyTeX", - "quarto update tool tinytex", - ) - .example( - "Update Chromium", - "quarto update tool chromium", - ) - .example( - "Choose tool to update", - "quarto update tool", - ) - .action( - async ( - options: { prompt?: boolean; embed?: string }, - ...target: string[] - ) => { - await initYamlIntelligenceResourcesFromFilesystem(); - const temp = createTempContext(); - try { - const resolved = resolveCompatibleArgs(target, "extension"); - if (resolved.action === "extension") { - // Install an extension - if (resolved.name) { - await installExtension( +export class UpdateCommand extends Command { + static name = 'update'; + static paths = [[UpdateCommand.name]]; + + static usage = Command.Usage({ + description: "Updates an extension or global dependency.", + examples: [ + [ + "Update extension (Github)", + `$0 ${UpdateCommand.name} extension /`, + ], + [ + "Update extension (file)", + `$0 ${UpdateCommand.name} extension `, + ], + [ + "Update extension (url)", + `$0 ${UpdateCommand.name} extension `, + ], + [ + "Update TinyTeX", + `$0 ${UpdateCommand.name} tool tinytex`, + ], + [ + "Update Chromium", + `$0 ${UpdateCommand.name} tool chromium`, + ], + [ + "Choose tool to update", + `$0 ${UpdateCommand.name} tool`, + ] + ] + }); + + targets = Option.Rest(); + + embed = Option.String('--embed', {description: "Embed this extension within another extension (used when authoring extensions)." }); + noPrompt = Option.Boolean('--no-prompt', { description: "Do not prompt to confirm actions" }); + + async execute() { + const { embed, targets } = this; + const prompt = !this.noPrompt + await initYamlIntelligenceResourcesFromFilesystem(); + const temp = createTempContext(); + try { + const resolved = resolveCompatibleArgs(targets, "extension"); + + if (resolved.action === "extension") { + // Install an extension + if (resolved.name) { + await installExtension( resolved.name, temp, - options.prompt !== false, - options.embed, - ); - } else { - info("Please provide an extension name, url, or path."); - } - } else if (resolved.action === "tool") { - // Install a tool - if (resolved.name) { - // Use the tool name - await updateOrInstallTool(resolved.name, "update", options.prompt); + prompt, + embed, + ); + } else { + info("Please provide an extension name, url, or path."); + } + } else if (resolved.action === "tool") { + // Install a tool + if (resolved.name) { + // Use the tool name + await updateOrInstallTool(resolved.name, "update", prompt); + } else { + // Not provided, give the user a list to choose from + const allTools = await loadTools(); + if (allTools.filter((tool) => !tool.installed).length === 0) { + info("All tools are already installed."); } else { - // Not provided, give the user a list to choose from - const allTools = await loadTools(); - if (allTools.filter((tool) => !tool.installed).length === 0) { - info("All tools are already installed."); - } else { - // Select which tool should be installed - const toolTarget = await selectTool(allTools, "update"); - if (toolTarget) { - info(""); - await updateOrInstallTool(toolTarget, "update"); - } + // Select which tool should be installed + const toolTarget = await selectTool(allTools, "update"); + if (toolTarget) { + info(""); + await updateOrInstallTool(toolTarget, "update"); } } - } else { - // This is an unrecognized type option - info( - `Unrecognized option '${resolved.action}' - please choose 'tool' or 'extension'.`, - ); } - } finally { - temp.cleanup(); + } else { + // This is an unrecognized type option + info( + `Unrecognized option '${resolved.action}' - please choose 'tool' or 'extension'.`, + ); } - }, - ); + } finally { + temp.cleanup(); + } + } +} diff --git a/src/command/use/cmd.ts b/src/command/use/cmd.ts index c7089b15de8..98a34a91087 100644 --- a/src/command/use/cmd.ts +++ b/src/command/use/cmd.ts @@ -1,40 +1,9 @@ /* * cmd.ts * - * Copyright (C) 2021-2022 Posit Software, PBC + * Copyright (C) 2021-2024 Posit Software, PBC */ -import { Command, ValidationError } from "cliffy/command/mod.ts"; +import { TemplateCommand } from "./commands/template.ts"; +import { BinderCommand } from "./commands/binder/binder.ts"; -import { useTemplateCommand } from "./commands/template.ts"; -import { useBinderCommand } from "./commands/binder/binder.ts"; - -const kUseCommands = [useTemplateCommand, useBinderCommand]; - -export const makeUseCommand = () => { - const theCommand = new Command() - .name("use") - .arguments(" [target:string]") - .option( - "--no-prompt", - "Do not prompt to confirm actions", - ) - .description( - "Automate document or project setup tasks.", - ) - .action((_options, type, _target) => { - const useCommand = kUseCommands.find((command) => { - return command.getName() === type; - }); - - if (!useCommand) { - throw new ValidationError( - `Unknown type '${type}'- did you mean 'template'?`, - ); - } - }); - - kUseCommands.forEach((command) => { - theCommand.command(command.getName(), command); - }); - return theCommand; -}; +export const useCommands = [TemplateCommand, BinderCommand]; diff --git a/src/command/use/commands/binder/binder.ts b/src/command/use/commands/binder/binder.ts index f060f5131bd..bef6a4d39a2 100644 --- a/src/command/use/commands/binder/binder.ts +++ b/src/command/use/commands/binder/binder.ts @@ -1,7 +1,7 @@ /* * binder.ts * - * Copyright (C) 2021-2022 Posit Software, PBC + * Copyright (C) 2021-2024 Posit Software, PBC */ import { initYamlIntelligenceResourcesFromFilesystem } from "../../../../core/schema/utils.ts"; @@ -32,26 +32,34 @@ import { ProjectContext, } from "../../../../project/types.ts"; -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; + +// TODO: replace cliffy +// see https://github.com/quarto-dev/quarto-cli/issues/10878 import { Table } from "cliffy/table/mod.ts"; import { Confirm } from "cliffy/prompt/mod.ts"; + import { notebookContext } from "../../../../render/notebook/notebook-context.ts"; import { asArray } from "../../../../core/array.ts"; +import { namespace } from "../../namespace.ts"; + +export class BinderCommand extends Command { + static name = 'binder'; + static paths = [[namespace, BinderCommand.name]]; + + static usage = Command.Usage({ + description: "Configure the current project with Binder support.", + examples: [ + [ + "Configure project to use Binder", + `$0 ${namespace} ${BinderCommand.name}`, + ], + ] + }); -export const useBinderCommand = new Command() - .name("binder") - .description( - "Configure the current project with Binder support.", - ) - .option( - "--no-prompt", - "Do not prompt to confirm actions", - ) - .example( - "Configure project to use Binder", - "quarto use binder", - ) - .action(async (options: { prompt?: boolean }) => { + noPrompt = Option.Boolean('--no-prompt', {description: "Do not prompt to confirm actions"}); + + async execute() { await initYamlIntelligenceResourcesFromFilesystem(); const temp = createTempContext(); try { @@ -61,19 +69,19 @@ export const useBinderCommand = new Command() const context = await projectContext(Deno.cwd(), nbContext); if (!context) { throw new Error( - "You must be in a Quarto project in order to configure Binder support.", + "You must be in a Quarto project in order to configure Binder support.", ); } // Read the project environment const projEnv = await withSpinner( - { - message: "Inspecting project configuration:", - doneMessage: "Detected Project configuration:\n", - }, - () => { - return context.environment(); - }, + { + message: "Inspecting project configuration:", + doneMessage: "Detected Project configuration:\n", + }, + () => { + return context.environment(); + }, ); const jupyterLab4 = jupyterLabVersion(context, projEnv); @@ -81,19 +89,19 @@ export const useBinderCommand = new Command() const rConfig: RConfiguration = {}; if (projectHasR(context, projEnv)) { const result = await execProcess( - { - cmd: [ - await rBinaryPath("R"), - "--version", - ], - stdout: "piped", - stderr: "piped", - }, + { + cmd: [ + await rBinaryPath("R"), + "--version", + ], + stdout: "piped", + stderr: "piped", + }, ); if (result.success) { const output = result.stdout; const verMatch = output?.match( - /R version (\d+\.\d+\.\d+) \((\d\d\d\d-\d\d-\d\d)\)/m, + /R version (\d+\.\d+\.\d+) \((\d\d\d\d-\d\d-\d\d)\)/m, ); if (verMatch) { const version = verMatch[1]; @@ -106,10 +114,10 @@ export const useBinderCommand = new Command() } const quartoVersion = typeof (projEnv.quarto) === "string" - ? projEnv.quarto === "prerelease" - ? "most recent prerelease" - : "most recent release" - : projEnv.quarto.toString(); + ? projEnv.quarto === "prerelease" + ? "most recent prerelease" + : "most recent release" + : projEnv.quarto.toString(); const table = new Table(); table.push(["Quarto", quartoVersion]); @@ -151,13 +159,13 @@ export const useBinderCommand = new Command() return engines.length === 1 && engines.includes("markdown"); }; if ( - projEnv.environments.length === 0 && - !isMarkdownEngineOnly(projEnv.engines) + projEnv.environments.length === 0 && + !isMarkdownEngineOnly(projEnv.engines) ) { info( - "\nNo files which provide dependencies were discovered. If you continue, no dependencies will be restored when running this project with Binder.\n\nLearn more at:\nhttps://www.quarto.org/docs/prerelease/1.4/binder.html#dependencies\n", + "\nNo files which provide dependencies were discovered. If you continue, no dependencies will be restored when running this project with Binder.\n\nLearn more at:\nhttps://www.quarto.org/docs/prerelease/1.4/binder.html#dependencies\n", ); - const proceed = !options.prompt || await Confirm.prompt({ + const proceed = this.noPrompt || await Confirm.prompt({ message: "Do you want to continue?", default: true, }); @@ -168,14 +176,14 @@ export const useBinderCommand = new Command() // Get the list of operations that need to be performed const fileOperations = await binderFileOperations( - projEnv, - jupyterLab4, - context, - options, - rConfig, + projEnv, + jupyterLab4, + context, + !this.noPrompt, + rConfig, ); info( - "\nThe following files will be written:", + "\nThe following files will be written:", ); const changeTable = new Table(); fileOperations.forEach((op) => { @@ -184,7 +192,7 @@ export const useBinderCommand = new Command() changeTable.border(true).render(); info(""); - const writeFiles = !options.prompt || await Confirm.prompt({ + const writeFiles = this.noPrompt || await Confirm.prompt({ message: "Continue?", default: true, }); @@ -198,7 +206,8 @@ export const useBinderCommand = new Command() } finally { temp.cleanup(); } - }); + } +} const createPostBuild = ( quartoConfig: QuartoConfiguration, @@ -359,7 +368,7 @@ async function binderFileOperations( projEnv: ProjectEnvironment, jupyterLab4: boolean, context: ProjectContext, - options: { prompt?: boolean | undefined }, + allowPrompt: boolean, rConfig: RConfiguration, ) { const operations: Array< @@ -408,7 +417,7 @@ async function binderFileOperations( }; // Get a file writer - const writeFile = safeFileWriter(context.dir, options.prompt); + const writeFile = safeFileWriter(context.dir, allowPrompt); // Look for an renv.lock file const renvPath = join(context.dir, "renv.lock"); diff --git a/src/command/use/commands/template.ts b/src/command/use/commands/template.ts index 3bd1c40b6b8..c37eebcab7c 100644 --- a/src/command/use/commands/template.ts +++ b/src/command/use/commands/template.ts @@ -1,7 +1,7 @@ /* * template.ts * - * Copyright (C) 2021-2022 Posit Software, PBC + * Copyright (C) 2021-2024 Posit Software, PBC */ import { @@ -9,7 +9,11 @@ import { extensionSource, } from "../../../extension/extension-host.ts"; import { info } from "../../../deno_ral/log.ts"; + +// TODO: replace cliffy +// see https://github.com/quarto-dev/quarto-cli/issues/10878 import { Confirm, Input } from "cliffy/prompt/mod.ts"; + import { basename, dirname, join, relative } from "../../../deno_ral/path.ts"; import { ensureDir, ensureDirSync, existsSync } from "../../../deno_ral/fs.ts"; import { TempContext } from "../../../core/temp-types.ts"; @@ -17,7 +21,7 @@ import { downloadWithProgress } from "../../../core/download.ts"; import { withSpinner } from "../../../core/console.ts"; import { unzip } from "../../../core/zip.ts"; import { templateFiles } from "../../../extension/template.ts"; -import { Command } from "cliffy/command/mod.ts"; +import { Command, Option } from "npm:clipanion"; import { initYamlIntelligenceResourcesFromFilesystem } from "../../../core/schema/utils.ts"; import { createTempContext } from "../../../core/temp.ts"; import { @@ -28,35 +32,41 @@ import { import { kExtensionDir } from "../../../extension/constants.ts"; import { InternalError } from "../../../core/lib/error.ts"; import { readExtensions } from "../../../extension/extension.ts"; +import { namespace } from "../namespace.ts"; const kRootTemplateName = "template.qmd"; -export const useTemplateCommand = new Command() - .name("template") - .arguments("") - .description( - "Use a Quarto template for this directory or project.", - ) - .option( - "--no-prompt", - "Do not prompt to confirm actions", - ) - .example( - "Use a template from Github", - "quarto use template /", - ) - .action(async (options: { prompt?: boolean }, target: string) => { +export class TemplateCommand extends Command { + static name = 'template'; + static paths = [[namespace, TemplateCommand.name]]; + + static usage = Command.Usage({ + description: "Use a Quarto template for this directory or project.", + examples: [ + [ + "Use a template from Github", + `$0 ${namespace} ${TemplateCommand.name} /`, + ], + ] + }); + + target = Option.String(); + noPrompt = Option.Boolean('--no-prompt', {description: "Do not prompt to confirm actions"}); + + async execute() { + const allowPrompt = !this.noPrompt; await initYamlIntelligenceResourcesFromFilesystem(); const temp = createTempContext(); try { - await useTemplate(options, target, temp); + await useTemplate(allowPrompt, this.target, temp); } finally { temp.cleanup(); } - }); + } +} async function useTemplate( - options: { prompt?: boolean }, + allowPrompt: boolean, target: string, tempContext: TempContext, ) { @@ -69,10 +79,10 @@ async function useTemplate( ); return; } - const trusted = await isTrusted(source, options.prompt !== false); + const trusted = await isTrusted(source, allowPrompt); if (trusted) { // Resolve target directory - const outputDirectory = await determineDirectory(options.prompt !== false); + const outputDirectory = await determineDirectory(allowPrompt); // Extract and move the template into place const stagedDir = await stageTemplate(source, tempContext); @@ -93,7 +103,7 @@ async function useTemplate( templateExtensions, outputDirectory, { - allowPrompt: options.prompt !== false, + allowPrompt, throw: true, message: "The template requires the following changes to extensions:", }, @@ -133,7 +143,7 @@ async function useTemplate( }; if (existsSync(target)) { - if (options.prompt) { + if (allowPrompt) { const proceed = await Confirm.prompt({ message: `Overwrite file ${displayName}?`, }); diff --git a/src/command/use/namespace.ts b/src/command/use/namespace.ts new file mode 100644 index 00000000000..b8bc14ed444 --- /dev/null +++ b/src/command/use/namespace.ts @@ -0,0 +1 @@ +export const namespace = 'use'; diff --git a/src/config/constants.ts b/src/config/constants.ts index 82495797d5e..738cbaa1989 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -777,9 +777,6 @@ export const kLayoutNcol = "layout-ncol"; export const kLayoutNrow = "layout-nrow"; export const kLayout = "layout"; -// https://github.com/quarto-dev/quarto-cli/issues/3581 -export const kCliffyImplicitCwd = "5a6d2e4f-f9a2-43bc-8019-8149fbb76c85"; - export const kSourceMappingRegexes = [ /^\/\/#\s*sourceMappingURL\=.*\.map$/gm, /\/\*\# sourceMappingURL=.* \*\//g, diff --git a/src/config/types.ts b/src/config/types.ts index 309b9878378..caa6ee2b89b 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -594,7 +594,7 @@ export interface PandocFlags { [kNumberSections]?: boolean; [kNumberOffset]?: number[]; [kTopLevelDivision]?: string; - [kShiftHeadingLevelBy]?: string; + [kShiftHeadingLevelBy]?: number; [kIncludeInHeader]?: string; [kIncludeBeforeBody]?: string; [kIncludeAfterBody]?: string; diff --git a/src/core/log.ts b/src/core/log.ts index bfc10bca5e8..d41a6ec2420 100644 --- a/src/core/log.ts +++ b/src/core/log.ts @@ -1,7 +1,7 @@ /* * log.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ import { ensureDirSync } from "../deno_ral/fs.ts"; @@ -11,16 +11,18 @@ import * as log from "../deno_ral/log.ts"; import { LogRecord } from "log/logger"; import { BaseHandler } from "log/base-handler"; import { FileHandler } from "log/file-handler"; -import { Command } from "cliffy/command/mod.ts"; +import { Option } from "npm:clipanion"; +import { applyCascade, isLiteral, isOneOf, isString, matchesRegExp } from "npm:typanion"; +import { addCommandOptions } from "../command/options.ts"; import { getenv } from "./env.ts"; -import { Args } from "flags"; import { lines } from "./text.ts"; import { debug, error, getLogger, setup, warning } from "../deno_ral/log.ts"; import { asErrorEx, InternalError } from "./lib/error.ts"; import { onCleanup } from "./cleanup.ts"; export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"; +const defaultLevel: LogLevel = "INFO"; export interface LogOptions { log?: string; @@ -39,71 +41,49 @@ export interface LogMessageOptions { colorize?: boolean; } -// deno-lint-ignore no-explicit-any -export function appendLogOptions(cmd: Command): Command { - // deno-lint-ignore no-explicit-any - const addLogOptions = (cmd: Command) => { - return cmd.option( - "--log ", - "Path to log file", - { - global: true, - }, - ).option( - "--log-level ", - "Log level (info, warning, error, critical)", - { - global: true, - }, +const loggingOptions: LogOptions = { + format: Option.String('-lf,--log-format', { + description: "Log format (plain, json-stream)", + validator: isOneOf([isLiteral("plain"), isLiteral("json-stream")]) + }), + + level: Option.String('-ll,--log-level', { + description: "Log level (info, warning, error, critical)", + validator: applyCascade( + isString(), + [matchesRegExp(/(DEBUG|ERROR|INFO|WARN)/i)] ) - .option( - "--log-format ", - "Log format (plain, json-stream)", - { - global: true, - }, - ) - .option( - "--quiet", - "Suppress console output.", - { - global: true, - }, - ); - }; - - // If there are subcommands, forward the log options - // directly to the subcommands. Otherwise, just attach - // to the outer command - // - // Fixes https://github.com/quarto-dev/quarto-cli/issues/8438 - const subCommands = cmd.getCommands(); - if (subCommands.length > 0) { - subCommands.forEach((command) => { - addLogOptions(command); - }); - return cmd; - } else { - return addLogOptions(cmd); - } + }), + + log: Option.String('-l,--log', { description: "Path to log file" }), + quiet: Option.Boolean('-q,--quiet', { description: "Suppress console output." }), } -export function logOptions(args: Args) { - const logOptions: LogOptions = {}; - logOptions.log = args.l || args.log || Deno.env.get("QUARTO_LOG"); - if (logOptions.log) { - ensureDirSync(dirname(logOptions.log)); +export const addLoggingOptions = addCommandOptions(loggingOptions, async (commandWithOptions) => { + const log = commandWithOptions.log || Deno.env.get("QUARTO_LOG"); + if (log) { + ensureDirSync(dirname(log)); } - logOptions.level = args.ll || args["log-level"] || - Deno.env.get("QUARTO_LOG_LEVEL"); - logOptions.quiet = args.q || args.quiet; - logOptions.format = parseFormat( - args.lf || args["log-format"] || Deno.env.get("QUARTO_LOG_FORMAT"), + + const format = parseFormat( + commandWithOptions.format || Deno.env.get("QUARTO_LOG_FORMAT") ); - return logOptions; -} -let currentLogLevel: LogLevel = "INFO"; + const level = parseLevel( + commandWithOptions.level || Deno.env.get("QUARTO_LOG_LEVEL") || defaultLevel + ); + + const { quiet } = commandWithOptions; + + await initializeLogger({ + format, + log, + level, + quiet, + }); +}); + +let currentLogLevel: LogLevel = defaultLevel; export function logLevel() { return currentLogLevel; } diff --git a/src/core/main.ts b/src/core/main.ts index a37cc49d319..a9b161fdfd5 100644 --- a/src/core/main.ts +++ b/src/core/main.ts @@ -3,25 +3,19 @@ * * Utilities for main() functions (setup, cleanup, etc) * - * Copyright (C) 2022 Posit Software, PBC + * Copyright (C) 2022-2024 Posit Software, PBC */ -import { initializeLogger, logError, logOptions } from "../../src/core/log.ts"; -import { Args } from "flags"; -import { parse } from "flags"; +import { logError } from "./log.ts"; import { exitWithCleanup } from "./cleanup.ts"; import { captureFileReads, reportPeformanceMetrics, } from "./performance/metrics.ts"; -type Runner = (args: Args) => Promise; +type Runner = () => Promise; export async function mainRunner(runner: Runner) { try { - // Parse the raw args to read globals and initialize logging - const args = parse(Deno.args); - await initializeLogger(logOptions(args)); - // install termination signal handlers if (Deno.build.os !== "windows") { Deno.addSignalListener("SIGINT", abend); @@ -32,7 +26,7 @@ export async function mainRunner(runner: Runner) { captureFileReads(); } - await runner(args); + await runner(); // if profiling, wait for 10 seconds before quitting if (Deno.env.get("QUARTO_TS_PROFILE") !== undefined) { diff --git a/src/quarto-core/profile.ts b/src/quarto-core/profile.ts index 7f2df2c795c..c821f6cd37f 100644 --- a/src/quarto-core/profile.ts +++ b/src/quarto-core/profile.ts @@ -1,11 +1,11 @@ /* * profile.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ -import { Args } from "flags"; -import { Command } from "cliffy/command/mod.ts"; +import { Option } from "npm:clipanion"; +import { addCommandOptions } from "../command/options.ts"; export const kQuartoProfile = "QUARTO_PROFILE"; @@ -21,23 +21,13 @@ export function readProfile(profile?: string) { } } -export function setProfileFromArg(args: Args) { - // set profile if specified - if (args.profile) { - Deno.env.set(kQuartoProfile, args.profile); - return true; - } else { - return false; - } -} +const profileOptions = { + profile: Option.String('--profile', { description: "Active project profile(s)" }), +}; -// deno-lint-ignore no-explicit-any -export function appendProfileArg(cmd: Command): Command { - return cmd.option( - "--profile", - "Active project profile(s)", - { - global: true, - }, - ); -} +export const addProfileOptions = addCommandOptions(profileOptions, async (commandWithOptions) => { + const { profile } = commandWithOptions; + if (profile) { + Deno.env.set(kQuartoProfile, profile); + } +}); diff --git a/src/quarto.ts b/src/quarto.ts index 54f4b3f00da..30c2d63ac89 100644 --- a/src/quarto.ts +++ b/src/quarto.ts @@ -1,29 +1,20 @@ /* * quarto.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2024 Posit Software, PBC */ import "./core/deno/monkey-patch.ts"; -import { - Command, - CompletionsCommand, - HelpCommand, -} from "cliffy/command/mod.ts"; +import { Builtins, CommandClass, Cli } from "npm:clipanion"; import { commands } from "./command/command.ts"; -import { appendLogOptions } from "./core/log.ts"; +import { addLoggingOptions } from "./core/log.ts"; import { debug, error } from "./deno_ral/log.ts"; import { cleanupSessionTempDir, initSessionTempDir } from "./core/temp.ts"; -import { removeFlags } from "./core/flags.ts"; import { quartoConfig } from "./core/quarto.ts"; -import { execProcess } from "./core/process.ts"; -import { pandocBinaryPath } from "./core/resources.ts"; -import { appendProfileArg, setProfileFromArg } from "./quarto-core/profile.ts"; -import { logError } from "./core/log.ts"; -import { CommandError } from "cliffy/command/_errors.ts"; +import { addProfileOptions } from "./quarto-core/profile.ts"; import { satisfies } from "semver/mod.ts"; import { @@ -32,10 +23,7 @@ import { readSourceDevConfig, reconfigureQuarto, } from "./core/devconfig.ts"; -import { typstBinaryPath } from "./core/typst.ts"; -import { exitWithCleanup, onCleanup } from "./core/cleanup.ts"; - -import { runScript } from "./command/run/run.ts"; +import { onCleanup } from "./core/cleanup.ts"; // ensures run handlers are registered import "./core/run/register.ts"; @@ -49,9 +37,60 @@ import "./project/types/register.ts"; // ensures writer formats are registered import "./format/imports.ts"; -import { kCliffyImplicitCwd } from "./config/constants.ts"; import { mainRunner } from "./core/main.ts"; +class QuartoCli extends Cli { + constructor() { + super({ + binaryLabel: 'Quarto CLI', + binaryName: 'quarto', + binaryVersion: quartoConfig.version(), + enableColors: false, + }); + + [ + ...commands, + Builtins.HelpCommand + + // TODO: shell completion is not yet supported by clipanion + // see https://github.com/arcanis/clipanion/pull/89 + // Builtins.CompletionsCommand + ].forEach((command) => { + addLoggingOptions(command); + addProfileOptions(command); + this.register(command); + }); + } + + // value type of registrations is not public, so we have to use any here + replaceCommands(commandEntries: [CommandClass, any][]) { + this.registrations.clear(); + commandEntries.forEach(([key, value]) => { + this.registrations.set(key, value) + }); + } + + // overridden to hide internal commands in help output + usage(command: any, opts?: any) { + if (command) { + return super.usage(command, opts); + } + + const allCommands = [...this.registrations.entries()]; + const filteredCommands = allCommands.filter(([{usage}, ]) => usage?.category !== 'internal'); + + let helpText; + try { + this.replaceCommands(filteredCommands); + helpText = super.usage(); + } finally { + this.replaceCommands(allCommands); + } + + return helpText; + } +} + const checkVersionRequirement = () => { const versionReq = Deno.env.get("QUARTO_VERSION_REQUIREMENT"); if (versionReq) { @@ -76,79 +115,12 @@ const checkReconfiguration = async () => { } }; -const passThroughPandoc = async ( - args: string[], - env?: Record, -) => { - const result = await execProcess( - { - cmd: [pandocBinaryPath(), ...args.slice(1)], - env, - }, - undefined, - undefined, - undefined, - true, - ); - Deno.exit(result.code); -}; - -const passThroughTypst = async ( - args: string[], - env?: Record, -) => { - if (args[1] === "update") { - error( - "The 'typst update' command is not supported.\n" + - "Please install the latest version of Quarto from http://quarto.org to get the latest supported typst features.", - ); - Deno.exit(1); - } - const result = await execProcess({ - cmd: [typstBinaryPath(), ...args.slice(1)], - env, - }); - Deno.exit(result.code); -}; - export async function quarto( args: string[], - cmdHandler?: (command: Command) => Command, env?: Record, ) { await checkReconfiguration(); checkVersionRequirement(); - if (args[0] === "pandoc" && args[1] !== "help") { - await passThroughPandoc(args.slice(1), env); - } - if (args[0] === "typst") { - await passThroughTypst(args, env); - } - - // passthrough to run handlers - if (args[0] === "run" && args[1] !== "help" && args[1] !== "--help") { - const result = await runScript(args.slice(1), env); - Deno.exit(result.code); - } - - // inject implicit cwd arg for quarto preview/render whose - // first argument is a command line parmaeter. this allows - // us to evade a cliffy cli parsing issue where it requires - // at least one defined argument to be parsed before it can - // access undefined arguments. - // - // we do this via a UUID so that we can detect this happened - // and issue a warning in the case where the user might - // be calling render with parameters in incorrect order. - // - // see https://github.com/quarto-dev/quarto-cli/issues/3581 - if ( - args.length > 1 && - (args[0] === "render" || args[0] === "preview") && - args[1].startsWith("-") - ) { - args = [args[0], kCliffyImplicitCwd, ...args.slice(1)]; - } debug("Quarto version: " + quartoConfig.version()); @@ -159,22 +131,6 @@ export async function quarto( Deno.env.set(key, value); } - const quartoCommand = new Command() - .name("quarto") - .help({ colors: false }) - .version(quartoConfig.version() + "\n") - .description("Quarto CLI") - .throwErrors(); - - commands().forEach((command) => { - // turn off colors - command.help({ colors: false }); - quartoCommand.command( - command.getName(), - cmdHandler !== undefined ? cmdHandler(command) : command, - ); - }); - // From here on, we have a temp dir that we need to clean up. // The calls to Deno.exit() above are fine, but no further // ones should be made @@ -183,42 +139,20 @@ export async function quarto( initSessionTempDir(); onCleanup(cleanupSessionTempDir); - const promise = quartoCommand.command("help", new HelpCommand().global()) - .command("completions", new CompletionsCommand()).hidden().parse(args); - - try { - await promise; - for (const [key, value] of Object.entries(oldEnv)) { - if (value === undefined) { - Deno.env.delete(key); - } else { - Deno.env.set(key, value); - } - } - } catch (e) { - if (e instanceof CommandError) { - logError(e, false); - exitWithCleanup(1); + const quartoCommand = new QuartoCli(); + await quartoCommand.runExit(args, { env }); + + for (const [key, value] of Object.entries(oldEnv)) { + if (value === undefined) { + Deno.env.delete(key); } else { - throw e; + Deno.env.set(key, value); } } } if (import.meta.main) { - await mainRunner(async (args) => { - // initialize profile (remove from args) - let quartoArgs = [...Deno.args]; - if (setProfileFromArg(args)) { - const removeArgs = new Map(); - removeArgs.set("--profile", true); - quartoArgs = removeFlags(quartoArgs, removeArgs); - } - - // run quarto - await quarto(quartoArgs, (cmd) => { - cmd = appendLogOptions(cmd); - return appendProfileArg(cmd); - }); + await mainRunner(async () => { + await quarto(Deno.args); }); } diff --git a/src/tools/tools-console.ts b/src/tools/tools-console.ts index 78f4b634ab4..360f5d73701 100644 --- a/src/tools/tools-console.ts +++ b/src/tools/tools-console.ts @@ -4,8 +4,12 @@ * Copyright (C) 2021-2022 Posit Software, PBC */ import * as colors from "fmt/colors"; + +// TODO: replace cliffy +// see https://github.com/quarto-dev/quarto-cli/issues/10878 import { Confirm, Select } from "cliffy/prompt/mod.ts"; import { Table } from "cliffy/table/mod.ts"; + import { info, warning } from "../deno_ral/log.ts"; import { diff --git a/tests/smoke/extensions/extension-render-journals.test.ts b/tests/smoke/extensions/extension-render-journals.test.ts index 0327048fb64..e01c0cd2205 100644 --- a/tests/smoke/extensions/extension-render-journals.test.ts +++ b/tests/smoke/extensions/extension-render-journals.test.ts @@ -45,10 +45,10 @@ for (const journalRepo of journalRepos) { const wd = Deno.cwd(); Deno.chdir(workingDir); await quarto([ - "use", - "template", - `quarto-journals/${journalRepo.repo}`, - "--no-prompt", + "use", + "template", + `quarto-journals/${journalRepo.repo}`, + "--no-prompt", ]); Deno.chdir(wd); }, diff --git a/tests/test.ts b/tests/test.ts index e7eadbbed8f..06aaffe621f 100644 --- a/tests/test.ts +++ b/tests/test.ts @@ -76,7 +76,7 @@ export function testQuartoCmd( setTimeout(reject, 600000, "timed out after 10 minutes"); }); await Promise.race([ - quarto([cmd, ...args], undefined, context?.env), + quarto([cmd, ...args], context?.env), timeout, ]); },