From 13b730d151406769e35ca029ab076ed662911829 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 19 Oct 2026 17:24:53 +0200 Subject: [PATCH 1/2] Tell which name to use when an extension's source owner folder is dropped The namespace of an installed extension comes from where it is installed from: the GitHub owner, or none for a local path or an archive. An owner folder found in the source (_extensions//) is not kept, by design since 2022, so a fork or copy installed from another owner cannot overwrite the original extension. When the source stores an extension under another owner, a name qualified with that owner (e.g. quarto-journals/acm-pdf) then does not find the installed extension, with nothing telling the user why. quarto add, quarto update and quarto use template now print which id the extension was installed as and which names to use. Tests cover a local directory and a cross-owner GitHub template (quarto-ext/manuscript-template-vscode ships quarto-journals/agu), and check no note is printed when the source owner matches. Refs #15111 --- news/changelog-1.11.md | 1 + src/extension/install.ts | 21 ++++++++- .../quarto-test/coolster/_extension.yml | 6 +++ .../_extensions/quarto-test/coolster/cool.lua | 3 ++ .../docs/extensions/owned-local/template.qmd | 5 ++ tests/smoke/extensions/install.test.ts | 31 ++++++++++++- tests/smoke/use/template.test.ts | 46 +++++++++++++++++++ 7 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 tests/docs/extensions/owned-local/_extensions/quarto-test/coolster/_extension.yml create mode 100644 tests/docs/extensions/owned-local/_extensions/quarto-test/coolster/cool.lua create mode 100644 tests/docs/extensions/owned-local/template.qmd diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index d33f62147af..667039d1622 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -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 diff --git a/src/extension/install.ts b/src/extension/install.ts index 24171dc41df..a3bd2d12136 100644 --- a/src/extension/install.ts +++ b/src/extension/install.ts @@ -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( @@ -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), diff --git a/tests/docs/extensions/owned-local/_extensions/quarto-test/coolster/_extension.yml b/tests/docs/extensions/owned-local/_extensions/quarto-test/coolster/_extension.yml new file mode 100644 index 00000000000..b719d1d2427 --- /dev/null +++ b/tests/docs/extensions/owned-local/_extensions/quarto-test/coolster/_extension.yml @@ -0,0 +1,6 @@ +title: Coolster +author: Charles Teague +version: 0.0.1 +contributes: + shortcodes: + - cool.lua diff --git a/tests/docs/extensions/owned-local/_extensions/quarto-test/coolster/cool.lua b/tests/docs/extensions/owned-local/_extensions/quarto-test/coolster/cool.lua new file mode 100644 index 00000000000..87b1cc7d49e --- /dev/null +++ b/tests/docs/extensions/owned-local/_extensions/quarto-test/coolster/cool.lua @@ -0,0 +1,3 @@ +function cool(args) + return pandoc.Str("You know what is cool? " .. pandoc.utils.stringify(args[1]) .. '.'); +end \ No newline at end of file diff --git a/tests/docs/extensions/owned-local/template.qmd b/tests/docs/extensions/owned-local/template.qmd new file mode 100644 index 00000000000..15e2d233030 --- /dev/null +++ b/tests/docs/extensions/owned-local/template.qmd @@ -0,0 +1,5 @@ +--- +title: Owned local template +--- + +{{< cool Quarto >}} diff --git a/tests/smoke/extensions/install.test.ts b/tests/smoke/extensions/install.test.ts index d770a0bfdad..b94b91c7b9a 100644 --- a/tests/smoke/extensions/install.test.ts +++ b/tests/smoke/extensions/install.test.ts @@ -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"; @@ -48,6 +48,7 @@ testQuartoCmd( noErrorsOrWarnings, verifySubDirCount("_extensions", 1), verifySubDirName("_extensions", "quarto-ext"), + printsMessage({ level: "INFO", regex: /installed as/, negate: true }), ], { cwd: () => { @@ -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(); + }, + }, +); diff --git a/tests/smoke/use/template.test.ts b/tests/smoke/use/template.test.ts index 80f11195abf..a9a3ff79394 100644 --- a/tests/smoke/use/template.test.ts +++ b/tests/smoke/use/template.test.ts @@ -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(); @@ -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: () => { @@ -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); From f8a58daf9147ab7f3086eca0f2a83cd9be03e2c2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 19 Oct 2026 17:24:59 +0200 Subject: [PATCH 2/2] Add llm-docs for extension installation and namespaces Documents the namespace rule (owner from the install origin, not the source tree) with its 2022 history, the source layout, the install pipeline, id lookup, and what the install tests cover and miss. A path-scoped rule points to it from the extension install sources. --- .claude/rules/extensions/install.md | 16 +++++ llm-docs/extension-install-architecture.md | 68 ++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 .claude/rules/extensions/install.md create mode 100644 llm-docs/extension-install-architecture.md diff --git a/.claude/rules/extensions/install.md b/.claude/rules/extensions/install.md new file mode 100644 index 00000000000..940d747d0f5 --- /dev/null +++ b/.claude/rules/extensions/install.md @@ -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. diff --git a/llm-docs/extension-install-architecture.md b/llm-docs/extension-install-architecture.md new file mode 100644 index 00000000000..af9da1b58f9 --- /dev/null +++ b/llm-docs/extension-install-architecture.md @@ -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///` or `_extensions//`. + +- `` 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"). +- `` is the extension's folder name in the source. +- An owner folder found in the source (`_extensions///`) 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//` (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//_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 --embed `, which places it at `_extensions//_extensions///` 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, , staged)`; + - local directory → `readAndCopyExtensions(/_extensions, staged/_extensions)` with no owner. +3. `copyExtensions` targets `_extensions//` (or the root) and calls `readAndCopyExtensions`, which copies each extension to `/` 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 `, the folder of that extension, which must contribute a format. + +`quarto use template` (`src/command/use/commands/template.ts`) stages the template, reads `