Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/changelog-1.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,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)
42 changes: 40 additions & 2 deletions src/command/update/cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 <gh-org>/<extension-name>",
)
.example(
"Update extension (Github)",
"quarto update extension <gh-org>/<gh-repo>",
Expand Down Expand Up @@ -65,10 +71,42 @@ 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
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: ${sourceToUpdate}`,
);
} 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,
Expand Down
4 changes: 2 additions & 2 deletions src/command/use/commands/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}

Expand Down
29 changes: 29 additions & 0 deletions src/extension/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
// 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<string, unknown>;
return manifest.source as string | undefined;
} catch {
return undefined;
}
}

export function projectExtensionDirs(project: ProjectContext) {
const extensionDirs: string[] = [];
for (
Expand Down Expand Up @@ -687,6 +714,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)
Expand Down Expand Up @@ -843,6 +871,7 @@ async function readExtension(
author,
version,
quartoVersion,
source,
id: extensionId,
path: extensionDir,
contributes: {
Expand Down
42 changes: 39 additions & 3 deletions src/extension/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -556,6 +558,7 @@ export async function confirmInstallation(
export async function completeInstallation(
downloadDir: string,
installDir: string,
sourceString?: string,
) {
info("");

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/extension/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface Extension extends Record<string, unknown> {
author: string;
version?: SemVer;
quartoVersion?: Range;
source?: string;
path: string;
contributes: {
metadata?: Metadata;
Expand Down
4 changes: 4 additions & 0 deletions src/resources/schema/extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
123 changes: 122 additions & 1 deletion tests/smoke/extensions/install.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string, unknown>;
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
Expand All @@ -48,6 +89,82 @@ testQuartoCmd(
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 is trimmed from source field for GitHub extensions
testQuartoCmd(
"install",
["extension", "quarto-ext/[email protected]", "--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 '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: () => {
Expand Down Expand Up @@ -116,6 +233,10 @@ testQuartoCmd(
noErrorsOrWarnings,
verifySubDirCount("_extensions", 1),
verifySubDirName("_extensions", "quarto-journals"),
verifyExtensionSourceField(
join("_extensions", "quarto-journals", "jss"),
"quarto-journals/jss",
),
],
{
cwd: () => {
Expand Down
Loading