From 238f42e6603eab5147252848402605b62073eb82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sat, 6 Dec 2025 12:16:07 +0100 Subject: [PATCH 1/6] feat: add source field to extension metadata --- src/command/use/commands/template.ts | 4 +-- src/extension/extension.ts | 2 ++ src/extension/install.ts | 42 ++++++++++++++++++++++++++-- src/extension/types.ts | 1 + src/resources/schema/extension.yml | 4 +++ 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/command/use/commands/template.ts b/src/command/use/commands/template.ts index 3bd1c40b6b8..6cb529f6ef2 100644 --- a/src/command/use/commands/template.ts +++ b/src/command/use/commands/template.ts @@ -159,8 +159,8 @@ async function useTemplate( const subStagedDir = tempContext.createDir(); await copyExtensions(source, stagedDir, subStagedDir); - // Now complete installation from this sub-staged directory - await completeInstallation(subStagedDir, outputDirectory); + // Now complete installation from this sub-staged directory with source information + await completeInstallation(subStagedDir, outputDirectory, target); }); } diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 4a390ad26e3..ecaa3fbe209 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -687,6 +687,7 @@ async function readExtension( const author = yaml[kAuthor] as string; const versionRaw = yaml[kVersion] as string | undefined; const quartoVersionRaw = yaml[kQuartoRequired] as string | undefined; + const source = yaml.source as string | undefined; const versionParsed = versionRaw ? coerce(versionRaw) : undefined; const quartoVersion = quartoVersionRaw ? readVersionRange(quartoVersionRaw) @@ -843,6 +844,7 @@ async function readExtension( author, version, quartoVersion, + source, id: extensionId, path: extensionDir, contributes: { diff --git a/src/extension/install.ts b/src/extension/install.ts index 24171dc41df..76d7a49b6ae 100644 --- a/src/extension/install.ts +++ b/src/extension/install.ts @@ -17,17 +17,19 @@ import { Extension } from "./types.ts"; import { kExtensionDir } from "./constants.ts"; import { withSpinner } from "../core/console.ts"; import { downloadWithProgress } from "../core/download.ts"; -import { createExtensionContext, readExtensions } from "./extension.ts"; +import { createExtensionContext, readExtensions, extensionFile } from "./extension.ts"; import { info } from "../deno_ral/log.ts"; import { ExtensionSource, extensionSource } from "./extension-host.ts"; import { safeExistsSync } from "../core/path.ts"; import { InternalError } from "../core/lib/error.ts"; import { notebookContext } from "../render/notebook/notebook-context.ts"; import { openUrl } from "../core/shell.ts"; +import { readYaml, stringify } from "../core/yaml.ts"; const kUnversionedFrom = " (?)"; const kUnversionedTo = "(?) "; + // Core Installation export async function installExtension( target: string, @@ -81,8 +83,8 @@ export async function installExtension( return false; } - // Complete the installation - await completeInstallation(extensionDir, installDir); + // Complete the installation with source information + await completeInstallation(extensionDir, installDir, target); await withSpinner( { message: "Extension installation complete" }, @@ -556,6 +558,7 @@ export async function confirmInstallation( export async function completeInstallation( downloadDir: string, installDir: string, + sourceString?: string, ) { info(""); @@ -596,6 +599,39 @@ export async function completeInstallation( // Ensure the parent directory exists ensureDirSync(dirname(installPath)); Deno.renameSync(stagingPath, installPath); + + // Write source information to the manifest if provided + if (sourceString) { + const manifestFile = extensionFile(installPath); + if (manifestFile && existsSync(manifestFile)) { + try { + let yamlContent = Deno.readTextFileSync(manifestFile); + // Append source field at the end if it doesn't already exist + if (!yamlContent.includes("source:")) { + if (!yamlContent.endsWith("\n")) { + yamlContent += "\n"; + } + yamlContent += `source: ${sourceString}\n`; + } else { + // If source already exists, update it + const manifestData = readYaml(manifestFile) as Record< + string, + unknown + >; + manifestData.source = sourceString; + yamlContent = stringify(manifestData); + } + Deno.writeTextFileSync(manifestFile, yamlContent); + } catch (e) { + // Log warning but don't fail the installation if we can't write source + console.warn( + `Warning: Could not update source field in extension manifest at ${manifestFile}: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + } + } }); } finally { // Clean up the staging directory diff --git a/src/extension/types.ts b/src/extension/types.ts index 4b8f0cb9e98..d40f7e59177 100644 --- a/src/extension/types.ts +++ b/src/extension/types.ts @@ -32,6 +32,7 @@ export interface Extension extends Record { author: string; version?: SemVer; quartoVersion?: Range; + source?: string; path: string; contributes: { metadata?: Metadata; diff --git a/src/resources/schema/extension.yml b/src/resources/schema/extension.yml index 25c3ec5e50b..93ee261508e 100644 --- a/src/resources/schema/extension.yml +++ b/src/resources/schema/extension.yml @@ -15,6 +15,10 @@ description: Quarto version range. See https://docs.npmjs.com/cli/v6/using-npm/semver for syntax details. schema: string +- name: source + description: Extension installation source (automatically managed). + schema: string + - name: contributes schema: object: From 2325539e336093478bb8d2f9a70097e19d1ab211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sat, 6 Dec 2025 12:47:24 +0100 Subject: [PATCH 2/6] feat: enhange update extension command to support installed directory names --- src/command/update/cmd.ts | 33 +++++++++++++++++++++++++++++++-- src/extension/extension.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/command/update/cmd.ts b/src/command/update/cmd.ts index e4cd42cb638..fc6d3960432 100644 --- a/src/command/update/cmd.ts +++ b/src/command/update/cmd.ts @@ -7,6 +7,8 @@ import { Command } from "cliffy/command/mod.ts"; import { initYamlIntelligenceResourcesFromFilesystem } from "../../core/schema/utils.ts"; import { createTempContext } from "../../core/temp.ts"; import { installExtension } from "../../extension/install.ts"; +import { findExtensionSource } from "../../extension/extension.ts"; +import { join } from "../../deno_ral/path.ts"; import { info } from "../../deno_ral/log.ts"; import { @@ -30,6 +32,10 @@ export const updateCommand = new Command() .description( "Updates an extension or global dependency.", ) + .example( + "Update extension (by installed directory)", + "quarto update extension /", + ) .example( "Update extension (Github)", "quarto update extension /", @@ -65,10 +71,33 @@ export const updateCommand = new Command() const resolved = resolveCompatibleArgs(target, "extension"); if (resolved.action === "extension") { - // Install an extension + // Update an extension if (resolved.name) { + let sourceToUpdate = resolved.name; + + // Try to find the extension in the current directory's _extensions + // by treating the name as a directory path (e.g., "org/extension-name") + const extensionPath = join(Deno.cwd(), "_extensions", resolved.name); + const source = await findExtensionSource(extensionPath); + if (source) { + // Found an installed extension, use its source for update + sourceToUpdate = source; + info( + `Found installed extension at _extensions/${resolved.name}`, + ); + info( + `Using installation source: ${source}`, + ); + } else { + // Extension not found locally, use the provided target + // This works for GitHub repos (org/repo), URLs, and file paths + info( + `Using provided target: ${resolved.name}`, + ); + } + await installExtension( - resolved.name, + sourceToUpdate, temp, options.prompt !== false, options.embed, diff --git a/src/extension/extension.ts b/src/extension/extension.ts index ecaa3fbe209..e85e65bf8ea 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -461,6 +461,33 @@ export async function readExtensions( return extensions; } +// Find the source of an installed extension by its directory name +// For example, given "org/extension-name", search for the extension in _extensions +// and return its source field if found +export async function findExtensionSource( + extensionPath: string, +): Promise { + // Check if the extension directory exists + if (!safeExistsSync(extensionPath)) { + return undefined; + } + + // Find the manifest file + const manifestFile = extensionFile(extensionPath); + if (!manifestFile) { + return undefined; + } + + // Read the manifest and extract the source field + try { + const yaml = await import("../core/yaml.ts"); + const manifest = yaml.readYaml(manifestFile) as Record; + return manifest.source as string | undefined; + } catch { + return undefined; + } +} + export function projectExtensionDirs(project: ProjectContext) { const extensionDirs: string[] = []; for ( From fd74ab8d01d5151d31f793873d583bad8eab4056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sat, 6 Dec 2025 12:47:38 +0100 Subject: [PATCH 3/6] test: add verification for extension source field in installation tests --- tests/smoke/extensions/install.test.ts | 51 +++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/tests/smoke/extensions/install.test.ts b/tests/smoke/extensions/install.test.ts index d770a0bfdad..e71924408a6 100644 --- a/tests/smoke/extensions/install.test.ts +++ b/tests/smoke/extensions/install.test.ts @@ -1,10 +1,11 @@ import { noErrorsOrWarnings } from "../../verify.ts"; import { join } from "../../../src/deno_ral/path.ts"; import { ExecuteOutput, testQuartoCmd, Verify } from "../../test.ts"; -import { assert } from "testing/asserts"; +import { assert, assertEquals } from "testing/asserts"; import { ensureDirSync, existsSync } from "../../../src/deno_ral/fs.ts"; import { docs } from "../../utils.ts"; import { isLinux } from "../../../src/deno_ral/platform.ts"; +import { readYaml } from "../../../src/core/yaml.ts"; const verifySubDirCount = (dir: string, count: number): Verify => { return { @@ -38,6 +39,46 @@ const verifySubDirName = (dir: string, name: string): Verify => { }; }; +const verifyExtensionSourceField = ( + extDir: string, + expectedSourcePattern?: string, +): Verify => { + return { + name: "Verify source field is written to extension manifest", + verify: (_outputs: ExecuteOutput[]) => { + // Find the _extension.yml file in the extension directory + let manifestFile: string | undefined; + for (const item of Deno.readDirSync(extDir)) { + if (item.isFile && (item.name === "_extension.yml" || item.name === "_extension.yaml")) { + manifestFile = join(extDir, item.name); + break; + } + } + + assert( + manifestFile !== undefined, + "Extension manifest file not found", + ); + + const manifest = readYaml(manifestFile!) as Record; + assert( + manifest.source !== undefined, + "Source field not found in extension manifest", + ); + + if (expectedSourcePattern) { + const sourceStr = String(manifest.source); + assert( + sourceStr.includes(expectedSourcePattern), + `Source field does not contain expected pattern. Got: ${sourceStr}, expected to contain: ${expectedSourcePattern}`, + ); + } + + return Promise.resolve(); + }, + }; +}; + const workingDir = Deno.makeTempDirSync(); // Verify installation using a remote github repo @@ -48,6 +89,10 @@ testQuartoCmd( noErrorsOrWarnings, verifySubDirCount("_extensions", 1), verifySubDirName("_extensions", "quarto-ext"), + verifyExtensionSourceField( + join("_extensions", "quarto-ext", "lightbox"), + "quarto-ext/lightbox", + ), ], { cwd: () => { @@ -116,6 +161,10 @@ testQuartoCmd( noErrorsOrWarnings, verifySubDirCount("_extensions", 1), verifySubDirName("_extensions", "quarto-journals"), + verifyExtensionSourceField( + join("_extensions", "quarto-journals", "jss"), + "quarto-journals/jss", + ), ], { cwd: () => { From edd3b95badcf1b5d16e8171a83688213b9d82240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:04:18 +0100 Subject: [PATCH 4/6] chore: add changelog entry --- news/changelog-1.9.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 4027d871323..27ab79d359e 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -74,3 +74,4 @@ All changes included in 1.9: - ([#13528](https://github.com/quarto-dev/quarto-cli/pull/13528)): Adds support for table specification using nested lists and the `list-table` class. - ([#13575](https://github.com/quarto-dev/quarto-cli/pull/13575)): Improve CPU architecture detection/reporting in macOS to allow quarto to run in virtualized environments such as OpenAI's `codex`. - ([#13656](https://github.com/quarto-dev/quarto-cli/issues/13656)): Fix R code cells with empty `lang: ""` option producing invalid markdown class attributes. +- ([#13764](https://github.com/quarto-dev/quarto-cli/issues/13764)): Add `source` field to extension metadata to track installation source. Extension updates now automatically detect and reuse the original installation source when available, improving the update experience for locally installed extensions. (author: @mcanouil) From c0e5c0cec935ab338e361179853302f37c77195f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:26:34 +0100 Subject: [PATCH 5/6] fix: quarto update should use github-style source without tag suffix --- src/command/update/cmd.ts | 13 +++- tests/smoke/extensions/install.test.ts | 96 ++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/command/update/cmd.ts b/src/command/update/cmd.ts index fc6d3960432..dc7f68150ad 100644 --- a/src/command/update/cmd.ts +++ b/src/command/update/cmd.ts @@ -81,12 +81,21 @@ export const updateCommand = new Command() const source = await findExtensionSource(extensionPath); if (source) { // Found an installed extension, use its source for update - sourceToUpdate = source; info( `Found installed extension at _extensions/${resolved.name}`, ); + // Normalize the source by trimming @TAG for GitHub-style sources + // (GitHub refs are like "org/repo" or "org/repo/subdir" with optional @tag) + const githubExtensionRegex = /^[a-zA-Z0-9-_.]+\/[a-zA-Z0-9-_.]+(?:\/[^@]*)?(?:@.+)?$/; + if (source.match(githubExtensionRegex)) { + // GitHub-style source, trim @TAG if present + sourceToUpdate = source.replace(/@.+$/, ""); + } else { + // URL or local path, use as-is + sourceToUpdate = source; + } info( - `Using installation source: ${source}`, + `Using installation source: ${sourceToUpdate}`, ); } else { // Extension not found locally, use the provided target diff --git a/tests/smoke/extensions/install.test.ts b/tests/smoke/extensions/install.test.ts index e71924408a6..783027ca2a8 100644 --- a/tests/smoke/extensions/install.test.ts +++ b/tests/smoke/extensions/install.test.ts @@ -105,6 +105,102 @@ testQuartoCmd( }, ); +// Verify that @TAG is trimmed from source field for GitHub extensions +testQuartoCmd( + "install", + ["extension", "quarto-ext/lightbox@v0.1.4", "--no-prompt"], + [ + noErrorsOrWarnings, + verifySubDirCount("_extensions", 1), + verifySubDirName("_extensions", "quarto-ext"), + verifyExtensionSourceField( + join("_extensions", "quarto-ext", "lightbox"), + "quarto-ext/lightbox", + ), + ], + { + cwd: () => { + return workingDir; + }, + teardown: () => { + Deno.removeSync("_extensions", { recursive: true }); + return Promise.resolve(); + }, + }, +); + +// Verify that @TAG with slash (branch name) is trimmed from source field +testQuartoCmd( + "install", + ["extension", "quarto-ext/lightbox@test/use-in-quarto-cli", "--no-prompt"], + [ + noErrorsOrWarnings, + verifySubDirCount("_extensions", 1), + verifySubDirName("_extensions", "quarto-ext"), + verifyExtensionSourceField( + join("_extensions", "quarto-ext", "lightbox"), + "quarto-ext/lightbox", + ), + ], + { + cwd: () => { + return workingDir; + }, + teardown: () => { + Deno.removeSync("_extensions", { recursive: true }); + return Promise.resolve(); + }, + }, +); + +// Verify that updating an extension uses the normalized source +testQuartoCmd( + "install", + ["extension", "quarto-ext/lightbox@v0.1.4", "--no-prompt"], + [ + noErrorsOrWarnings, + verifySubDirCount("_extensions", 1), + verifySubDirName("_extensions", "quarto-ext"), + verifyExtensionSourceField( + join("_extensions", "quarto-ext", "lightbox"), + "quarto-ext/lightbox", + ), + ], + { + cwd: () => { + return workingDir; + }, + teardown: () => { + Deno.removeSync("_extensions", { recursive: true }); + return Promise.resolve(); + }, + }, +); + +// Verify that 'quarto update extension' uses the normalized source from manifest +testQuartoCmd( + "update", + ["extension", "quarto-ext/lightbox", "--no-prompt"], + [ + noErrorsOrWarnings, + verifySubDirCount("_extensions", 1), + verifySubDirName("_extensions", "quarto-ext"), + verifyExtensionSourceField( + join("_extensions", "quarto-ext", "lightbox"), + "quarto-ext/lightbox", + ), + ], + { + cwd: () => { + return workingDir; + }, + teardown: () => { + Deno.removeSync("_extensions", { recursive: true }); + return Promise.resolve(); + }, + }, +); + // Verify install using urls const extUrls = [ "quarto-ext/lightbox", From 9d4a4213fbea6a06f8b984453728c81b36a9ee18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 7 Dec 2025 17:24:57 +0100 Subject: [PATCH 6/6] chore: rm duplicated test --- tests/smoke/extensions/install.test.ts | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/tests/smoke/extensions/install.test.ts b/tests/smoke/extensions/install.test.ts index 783027ca2a8..006b4059123 100644 --- a/tests/smoke/extensions/install.test.ts +++ b/tests/smoke/extensions/install.test.ts @@ -153,30 +153,6 @@ testQuartoCmd( }, ); -// Verify that updating an extension uses the normalized source -testQuartoCmd( - "install", - ["extension", "quarto-ext/lightbox@v0.1.4", "--no-prompt"], - [ - noErrorsOrWarnings, - verifySubDirCount("_extensions", 1), - verifySubDirName("_extensions", "quarto-ext"), - verifyExtensionSourceField( - join("_extensions", "quarto-ext", "lightbox"), - "quarto-ext/lightbox", - ), - ], - { - cwd: () => { - return workingDir; - }, - teardown: () => { - Deno.removeSync("_extensions", { recursive: true }); - return Promise.resolve(); - }, - }, -); - // Verify that 'quarto update extension' uses the normalized source from manifest testQuartoCmd( "update",