Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .claude/rules/extensions/install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
paths:
- "src/extension/**"
- "src/command/add/**"
- "src/command/update/**"
- "src/command/remove/**"
- "src/command/use/**"
- "tests/smoke/extensions/install.test.ts"
- "tests/smoke/use/**"
---

# Extension installation

For how extensions are staged, namespaced and installed under `_extensions/`, how ids are looked up, and which behaviors the tests pin, see `llm-docs/extension-install-architecture.md`.

The namespace of an installed extension comes from where it is installed from (the GitHub owner, or none for local paths and archives), not from an owner folder in the source. This is deliberate: it keeps a fork or copy from overwriting another owner's extension. Read the doc's "The namespace rule" section before changing install layout or lookup.
68 changes: 68 additions & 0 deletions llm-docs/extension-install-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
main_commit: 4c7b5d25d
analyzed_date: 2026-10-19
key_files:
- src/extension/install.ts
- src/extension/extension.ts
- src/extension/extension-host.ts
- src/extension/remove.ts
- src/command/add/cmd.ts
- src/command/update/cmd.ts
- src/command/remove/cmd.ts
- src/command/use/commands/template.ts
- tests/smoke/extensions/install.test.ts
- tests/smoke/use/template.test.ts
---

# Extension installation and namespaces

How `quarto add`, `quarto update`, `quarto remove` and `quarto use template` place extensions under `_extensions/`, and how a document finds them again. Read this before changing the install layout or the extension lookup.

## The namespace rule

An installed extension lives at `_extensions/<owner>/<name>/` or `_extensions/<name>/`.

- `<owner>` comes from **where the extension is installed from**, never from the source tree. For GitHub sources (`org/repo`, `org/repo/subdir`, `org/repo@ref`, and github.com archive URLs) it is the GitHub owner (`ExtensionSource.owner`, set from `host.organization` in `extension-host.ts`).
- Local directories, local archives and non-GitHub URLs have no owner: their extensions go directly under `_extensions/`. The user docs say archives "share a single namespace" (quarto-web `docs/extensions/distributing.qmd`, "Archive Distribution").
- `<name>` is the extension's folder name in the source.
- An owner folder found in the source (`_extensions/<other-owner>/<name>/`) is **dropped**. Since #15111 the installer prints an INFO note when this happens, naming the id to use.

The documented purpose of the namespace is avoiding name conflicts ("the use of organizations as a 'namespace' for managing name conflicts", `distributing.qmd`). Deriving it from the install origin means a fork or copy installed from another owner lands under that owner and cannot overwrite the original. Do not change install to keep the source owner folder: a fork shipping `_extensions/quarto-ext/x/` would then install into `quarto-ext/`.

### History

- Before June 2022 the install copied the source tree as is, so the namespace was whatever folder the source shipped (`name@org`, later `org/name`).
- June 2022 (`959f25f85`, `dc94a9673`, `e72966d30`): ids became `org/name`, the owner comes from GitHub, local sources are flattened.
- July 2022: author guidance changed to an unowned source layout `_extensions/<name>/` (quarto-web `903cc7ca7`, and `9fafe706a` whose message says sources "used to have owner name space"). quarto-journals repos were moved out of their owner folders. `quarto-journals/acm` got its owner folder back by an accidental merge and still has it.
- `92458565d` (v1.5.15) passed the template root to `copyExtensions`, so templates with an owner folder installed nothing (#14008). Fixed by #14010 (1.11).

## Source layout (what extension authors ship)

`_extensions/<name>/_extension.yml` at the root of the repo or archive, optionally inside a single wrapper directory (GitHub archive tarballs). `quarto create extension` scaffolds this. An extension that needs another owner's extension embeds it with `quarto add <other> --embed <name>`, which places it at `_extensions/<name>/_extensions/<other-owner>/<other>/` inside the host extension and keeps its owner folder there.

## Install pipeline (`src/extension/install.ts`)

1. `installExtension` resolves the source (`extensionSource` in `extension-host.ts`): `{type: "local"}` for an existing path, `{type: "remote", owner, targetSubdir}` for a resolved URL.
2. `stageExtension` builds a staged `_extensions` tree in a temp dir:
- remote or local archive → `unzipAndStage` → `copyExtensions(source, <archive _extensions>, staged)`;
- local directory → `readAndCopyExtensions(<dir>/_extensions, staged/_extensions)` with no owner.
3. `copyExtensions` targets `_extensions/<source.owner>/` (or the root) and calls `readAndCopyExtensions`, which copies each extension to `<target>/<id.name>` and prints the owner note when the source owner folder differs.
4. `confirmInstallation` compares staged extensions with installed ones by **name and organization** and prints the `[Install]`/`[Update]`/`[Revert]`/`[No Change]` table. A flattened `acm` and an owned `quarto-journals/acm` count as different extensions, so a reinstall that changes the owner installs a second copy instead of updating.
5. `completeInstallation` moves the staged tree into the install directory chosen by `determineInstallDir`: the current directory (inside a project, a prompt offers the project root instead; `--no-prompt` keeps the current directory), or, with `--embed <name>`, the folder of that extension, which must contribute a format.

`quarto use template` (`src/command/use/commands/template.ts`) stages the template, reads `<template>/_extensions` for the confirmation list, then runs `copyExtensions(source, <template>/_extensions, …)` and `completeInstallation`. Template files other than `_extensions` are copied separately.

## Reading and lookup (`src/extension/extension.ts`)

- `readExtensions(dir)` reads one owner level: a child folder with `_extension.yml` is an unowned extension; a child without it is treated as an owner folder and read once more. The source owner folder ends up in `extension.id.organization`.
- `discoverExtensionPath` resolves an id from a document. A qualified id `org/name` matches only `<org>/<name>/`. An unqualified `name` matches `name/` and `*/name/`; with several matches it warns "More than one extension is available" and returns the first glob match (`paths[0]`), so an older copy can win.
- Formats are referenced as `<name>-<base>` (`acm-pdf`). User docs only show the unqualified form; `org/name-base` works only when the extension sits under that owner.

## Tests

`tests/smoke/extensions/install.test.ts` and `tests/smoke/use/template.test.ts` are the only tests asserting install layout:

- GitHub installs land under the repo owner (`quarto-ext/lightbox`, `quarto-journals/jss|jasa|acm`); `quarto-ext/manuscript-template-vscode` covers a cross-owner source (`quarto-journals/agu` → `quarto-ext/agu`) and its note.
- Local archives in `tests/docs/extensions/ext-repo/` (all owner-scoped) and the local directory `tests/docs/extensions/owned-local/` install flattened.

Not covered: same-name extensions from two owners, installing or updating over an existing install, `quarto remove`, qualified id lookup and the "More than one extension" warning. `unowned-multiple.tar.gz` contains `acs` and `coolster` both unowned and under `quarto-test/`; they flatten onto the same folder and the test does not check which copy wins. Most install tests need network access to GitHub.
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ All changes included in 1.11:

- ([#14254](https://github.com/quarto-dev/quarto-cli/issues/14254)): Fix `quarto render` hanging when the input file is at the filesystem root. (author: @mcanouil)
- ([#14936](https://github.com/quarto-dev/quarto-cli/pull/14936)): Accept and ignore static engine declaration keys (`name`, `claims`, `file-extensions`, `claims-files` — including an optional `processor` on `claims-files` entries) in the `external-engine` schema, so extensions can declare them for Quarto 2's engine resolution without breaking Quarto 1 validation. No Quarto 1 behavior change. (author: @gordonwoodhull)
- ([#15111](https://github.com/quarto-dev/quarto-cli/issues/15111)): `quarto add`, `quarto update` and `quarto use template` now tell which name to use for an extension stored under another owner in its source. The extension is installed under the owner it is installed from (none for a local path or an archive), so a name qualified with the source owner did not find it.

## Engines

Expand Down
21 changes: 19 additions & 2 deletions src/extension/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,14 +330,15 @@ export async function copyExtensions(
ensureDirSync(finalExtensionTargetDir);

// Move extensions into the target directory (root or owner)
await readAndCopyExtensions(srcDir, finalExtensionTargetDir);
await readAndCopyExtensions(srcDir, finalExtensionTargetDir, source.owner);
}

// Reads the extensions from an extensions directory and copies
// them to a destination directory
// them to a destination directory (the owner directory, if any)
async function readAndCopyExtensions(
extensionsDir: string,
targetDir: string,
owner?: string,
) {
const extensions = await readExtensions(extensionsDir);
info(
Expand All @@ -347,6 +348,22 @@ async function readAndCopyExtensions(
);

for (const extension of extensions) {
// The namespace comes from where the extension is installed from,
// not from an owner folder in the source
const sourceOwner = extension.id.organization;
if (sourceOwner && sourceOwner !== owner) {
const sourceId = `${sourceOwner}/${extension.id.name}`;
if (owner) {
const installedId = `${owner}/${extension.id.name}`;
info(
` '${sourceId}' installed as '${installedId}', under the owner it is installed from. Refer to it as '${extension.id.name}' or '${installedId}'.`,
);
} else {
info(
` '${sourceId}' installed as '${extension.id.name}', as extensions from a local path or an archive have no owner. Refer to it as '${extension.id.name}'.`,
);
}
}
copyTo(
extension.path,
join(targetDir, extension.id.name),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
title: Coolster
author: Charles Teague
version: 0.0.1
contributes:
shortcodes:
- cool.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
function cool(args)
return pandoc.Str("You know what is cool? " .. pandoc.utils.stringify(args[1]) .. '.');
end
5 changes: 5 additions & 0 deletions tests/docs/extensions/owned-local/template.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: Owned local template
---

{{< cool Quarto >}}
31 changes: 30 additions & 1 deletion tests/smoke/extensions/install.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { noErrorsOrWarnings } from "../../verify.ts";
import { noErrorsOrWarnings, printsMessage } from "../../verify.ts";
import { join } from "../../../src/deno_ral/path.ts";
import { ExecuteOutput, testQuartoCmd, Verify } from "../../test.ts";
import { assert } from "testing/asserts";
Expand Down Expand Up @@ -48,6 +48,7 @@ testQuartoCmd(
noErrorsOrWarnings,
verifySubDirCount("_extensions", 1),
verifySubDirName("_extensions", "quarto-ext"),
printsMessage({ level: "INFO", regex: /installed as/, negate: true }),
],
{
cwd: () => {
Expand Down Expand Up @@ -185,3 +186,31 @@ for (const zipFile of zipFiles) {
},
);
}

// Verify installation from a local directory drops the source owner folder
// and says so (#15111)
testQuartoCmd(
"add",
[join(testDirAbs, "owned-local"), "--no-prompt"],
[
noErrorsOrWarnings,
verifySubDirCount("_extensions", 1),
verifySubDirName("_extensions", "coolster"),
printsMessage({
level: "INFO",
regex: /'quarto-test\/coolster' installed as 'coolster'/,
}),
],
{
cwd: () => {
return workingDir;
},
teardown: () => {
try {
Deno.removeSync("_extensions", { recursive: true });
} catch {
}
return Promise.resolve();
},
},
);
46 changes: 46 additions & 0 deletions tests/smoke/use/template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "../../verify.ts";
import { join } from "../../../src/deno_ral/path.ts";
import { ensureDirSync } from "../../../src/deno_ral/fs.ts";
import { docs } from "../../utils.ts";

const tempDir = Deno.makeTempDirSync();

Expand All @@ -23,6 +24,7 @@ for (const ext of ["jasa", "acm"]) {
noErrorsOrWarnings,
fileExists(`${templateFolder}.qmd`),
folderExists(extensionDir),
printsMessage({ level: "INFO", regex: /installed as/, negate: true }),
],
{
setup: () => {
Expand All @@ -42,6 +44,50 @@ for (const ext of ["jasa", "acm"]) {
);
}

// Extensions keep no owner folder from the template source; the namespace
// comes from where the template is installed from, and a note says so (#15111)
const ownerNoteCases = [
{
folder: "owned-local",
template: join(Deno.cwd(), docs(join("extensions", "owned-local"))),
installed: ["coolster"],
note: /'quarto-test\/coolster' installed as 'coolster'/,
},
{
folder: "manuscript",
template: "quarto-ext/manuscript-template-vscode",
installed: ["quarto-ext", "agu"],
note: /'quarto-journals\/agu' installed as 'quarto-ext\/agu'/,
},
];
for (const ownerNoteCase of ownerNoteCases) {
const caseWorkingDir = join(tempDir, ownerNoteCase.folder);
ensureDirSync(caseWorkingDir);
testQuartoCmd(
"use",
["template", ownerNoteCase.template, "--no-prompt"],
[
noErrorsOrWarnings,
folderExists(
join(caseWorkingDir, "_extensions", ...ownerNoteCase.installed),
),
printsMessage({ level: "INFO", regex: ownerNoteCase.note }),
],
{
cwd: () => {
return caseWorkingDir;
},
teardown: () => {
try {
Deno.removeSync(caseWorkingDir, { recursive: true });
} catch {
}
return Promise.resolve();
},
},
);
}

const nonEmptyTemplateFolder = "notempty";
const nonEmptyFileName = `${nonEmptyTemplateFolder}.qmd`;
const nonEmptyWorkingDir = join(tempDir, nonEmptyTemplateFolder);
Expand Down
Loading