Skip to content

Commit 63571e3

Browse files
authored
Explain which name to use when an extension's owner folder is dropped (#15117)
When an extension's source stores it under an owner folder, such as _extensions/quarto-journals/acm/, install drops that folder. The namespace comes from the install source (the GitHub owner, or none for a local path or archive), so a copy cannot overwrite the original. A document using the qualified name quarto-journals/acm-pdf then fails with "Unable to read the extension 'quarto-journals/acm'", and nothing at install time says why. quarto add, quarto update and quarto use template now print the id an extension was installed as and the names to use for it when the source owner differs from the install owner. - Print the install id and usable names from readAndCopyExtensions() - Test a local directory, a cross-owner GitHub template, and the case where the owners match (no note printed) - Document the install namespace rules and lookup in llm-docs Follow-up to #14010. See #14008 for the repo that stores its extension under an owner folder. Related to #15111.
1 parent f99ce7a commit 63571e3

9 files changed

Lines changed: 194 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
paths:
3+
- "src/extension/**"
4+
- "src/command/add/**"
5+
- "src/command/update/**"
6+
- "src/command/remove/**"
7+
- "src/command/use/**"
8+
- "tests/smoke/extensions/install.test.ts"
9+
- "tests/smoke/use/**"
10+
---
11+
12+
# Extension installation
13+
14+
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`.
15+
16+
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.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
main_commit: 4c7b5d25d
3+
analyzed_date: 2026-10-19
4+
key_files:
5+
- src/extension/install.ts
6+
- src/extension/extension.ts
7+
- src/extension/extension-host.ts
8+
- src/extension/remove.ts
9+
- src/command/add/cmd.ts
10+
- src/command/update/cmd.ts
11+
- src/command/remove/cmd.ts
12+
- src/command/use/commands/template.ts
13+
- tests/smoke/extensions/install.test.ts
14+
- tests/smoke/use/template.test.ts
15+
---
16+
17+
# Extension installation and namespaces
18+
19+
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.
20+
21+
## The namespace rule
22+
23+
An installed extension lives at `_extensions/<owner>/<name>/` or `_extensions/<name>/`.
24+
25+
- `<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`).
26+
- 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").
27+
- `<name>` is the extension's folder name in the source.
28+
- 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.
29+
30+
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/`.
31+
32+
### History
33+
34+
- 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`).
35+
- June 2022 (`959f25f85`, `dc94a9673`, `e72966d30`): ids became `org/name`, the owner comes from GitHub, local sources are flattened.
36+
- 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.
37+
- `92458565d` (v1.5.15) passed the template root to `copyExtensions`, so templates with an owner folder installed nothing (#14008). Fixed by #14010 (1.11).
38+
39+
## Source layout (what extension authors ship)
40+
41+
`_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.
42+
43+
## Install pipeline (`src/extension/install.ts`)
44+
45+
1. `installExtension` resolves the source (`extensionSource` in `extension-host.ts`): `{type: "local"}` for an existing path, `{type: "remote", owner, targetSubdir}` for a resolved URL.
46+
2. `stageExtension` builds a staged `_extensions` tree in a temp dir:
47+
- remote or local archive → `unzipAndStage` → `copyExtensions(source, <archive _extensions>, staged)`;
48+
- local directory → `readAndCopyExtensions(<dir>/_extensions, staged/_extensions)` with no owner.
49+
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.
50+
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.
51+
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.
52+
53+
`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.
54+
55+
## Reading and lookup (`src/extension/extension.ts`)
56+
57+
- `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`.
58+
- `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.
59+
- 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.
60+
61+
## Tests
62+
63+
`tests/smoke/extensions/install.test.ts` and `tests/smoke/use/template.test.ts` are the only tests asserting install layout:
64+
65+
- 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.
66+
- Local archives in `tests/docs/extensions/ext-repo/` (all owner-scoped) and the local directory `tests/docs/extensions/owned-local/` install flattened.
67+
68+
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.

‎news/changelog-1.11.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ All changes included in 1.11:
139139

140140
- ([#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)
141141
- ([#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)
142+
- ([#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.
142143

143144
## Engines
144145

‎src/extension/install.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,14 +330,15 @@ export async function copyExtensions(
330330
ensureDirSync(finalExtensionTargetDir);
331331

332332
// Move extensions into the target directory (root or owner)
333-
await readAndCopyExtensions(srcDir, finalExtensionTargetDir);
333+
await readAndCopyExtensions(srcDir, finalExtensionTargetDir, source.owner);
334334
}
335335

336336
// Reads the extensions from an extensions directory and copies
337-
// them to a destination directory
337+
// them to a destination directory (the owner directory, if any)
338338
async function readAndCopyExtensions(
339339
extensionsDir: string,
340340
targetDir: string,
341+
owner?: string,
341342
) {
342343
const extensions = await readExtensions(extensionsDir);
343344
info(
@@ -347,6 +348,22 @@ async function readAndCopyExtensions(
347348
);
348349

349350
for (const extension of extensions) {
351+
// The namespace comes from where the extension is installed from,
352+
// not from an owner folder in the source
353+
const sourceOwner = extension.id.organization;
354+
if (sourceOwner && sourceOwner !== owner) {
355+
const sourceId = `${sourceOwner}/${extension.id.name}`;
356+
if (owner) {
357+
const installedId = `${owner}/${extension.id.name}`;
358+
info(
359+
` '${sourceId}' installed as '${installedId}', under the owner it is installed from. Refer to it as '${extension.id.name}' or '${installedId}'.`,
360+
);
361+
} else {
362+
info(
363+
` '${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}'.`,
364+
);
365+
}
366+
}
350367
copyTo(
351368
extension.path,
352369
join(targetDir, extension.id.name),
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
title: Coolster
2+
author: Charles Teague
3+
version: 0.0.1
4+
contributes:
5+
shortcodes:
6+
- cool.lua
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
function cool(args)
2+
return pandoc.Str("You know what is cool? " .. pandoc.utils.stringify(args[1]) .. '.');
3+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
title: Owned local template
3+
---
4+
5+
{{< cool Quarto >}}

‎tests/smoke/extensions/install.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { noErrorsOrWarnings } from "../../verify.ts";
1+
import { noErrorsOrWarnings, printsMessage } from "../../verify.ts";
22
import { join } from "../../../src/deno_ral/path.ts";
33
import { ExecuteOutput, testQuartoCmd, Verify } from "../../test.ts";
44
import { assert } from "testing/asserts";
@@ -48,6 +48,7 @@ testQuartoCmd(
4848
noErrorsOrWarnings,
4949
verifySubDirCount("_extensions", 1),
5050
verifySubDirName("_extensions", "quarto-ext"),
51+
printsMessage({ level: "INFO", regex: /installed as/, negate: true }),
5152
],
5253
{
5354
cwd: () => {
@@ -185,3 +186,31 @@ for (const zipFile of zipFiles) {
185186
},
186187
);
187188
}
189+
190+
// Verify installation from a local directory drops the source owner folder
191+
// and says so (#15111)
192+
testQuartoCmd(
193+
"add",
194+
[join(testDirAbs, "owned-local"), "--no-prompt"],
195+
[
196+
noErrorsOrWarnings,
197+
verifySubDirCount("_extensions", 1),
198+
verifySubDirName("_extensions", "coolster"),
199+
printsMessage({
200+
level: "INFO",
201+
regex: /'quarto-test\/coolster' installed as 'coolster'/,
202+
}),
203+
],
204+
{
205+
cwd: () => {
206+
return workingDir;
207+
},
208+
teardown: () => {
209+
try {
210+
Deno.removeSync("_extensions", { recursive: true });
211+
} catch {
212+
}
213+
return Promise.resolve();
214+
},
215+
},
216+
);

‎tests/smoke/use/template.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "../../verify.ts";
99
import { join } from "../../../src/deno_ral/path.ts";
1010
import { ensureDirSync } from "../../../src/deno_ral/fs.ts";
11+
import { docs } from "../../utils.ts";
1112

1213
const tempDir = Deno.makeTempDirSync();
1314

@@ -23,6 +24,7 @@ for (const ext of ["jasa", "acm"]) {
2324
noErrorsOrWarnings,
2425
fileExists(`${templateFolder}.qmd`),
2526
folderExists(extensionDir),
27+
printsMessage({ level: "INFO", regex: /installed as/, negate: true }),
2628
],
2729
{
2830
setup: () => {
@@ -42,6 +44,50 @@ for (const ext of ["jasa", "acm"]) {
4244
);
4345
}
4446

47+
// Extensions keep no owner folder from the template source; the namespace
48+
// comes from where the template is installed from, and a note says so (#15111)
49+
const ownerNoteCases = [
50+
{
51+
folder: "owned-local",
52+
template: join(Deno.cwd(), docs(join("extensions", "owned-local"))),
53+
installed: ["coolster"],
54+
note: /'quarto-test\/coolster' installed as 'coolster'/,
55+
},
56+
{
57+
folder: "manuscript",
58+
template: "quarto-ext/manuscript-template-vscode",
59+
installed: ["quarto-ext", "agu"],
60+
note: /'quarto-journals\/agu' installed as 'quarto-ext\/agu'/,
61+
},
62+
];
63+
for (const ownerNoteCase of ownerNoteCases) {
64+
const caseWorkingDir = join(tempDir, ownerNoteCase.folder);
65+
ensureDirSync(caseWorkingDir);
66+
testQuartoCmd(
67+
"use",
68+
["template", ownerNoteCase.template, "--no-prompt"],
69+
[
70+
noErrorsOrWarnings,
71+
folderExists(
72+
join(caseWorkingDir, "_extensions", ...ownerNoteCase.installed),
73+
),
74+
printsMessage({ level: "INFO", regex: ownerNoteCase.note }),
75+
],
76+
{
77+
cwd: () => {
78+
return caseWorkingDir;
79+
},
80+
teardown: () => {
81+
try {
82+
Deno.removeSync(caseWorkingDir, { recursive: true });
83+
} catch {
84+
}
85+
return Promise.resolve();
86+
},
87+
},
88+
);
89+
}
90+
4591
const nonEmptyTemplateFolder = "notempty";
4692
const nonEmptyFileName = `${nonEmptyTemplateFolder}.qmd`;
4793
const nonEmptyWorkingDir = join(tempDir, nonEmptyTemplateFolder);

0 commit comments

Comments
 (0)