From 983016d83b51ac65a39efabf04e41b100276aa8f Mon Sep 17 00:00:00 2001 From: jycouet Date: Mon, 6 Apr 2026 20:35:03 +0200 Subject: [PATCH 1/4] v0 --- packages/sv/src/cli/add.ts | 80 +++++++++++++++--- packages/sv/src/cli/create.ts | 2 + packages/sv/src/core/fetch-packages.ts | 107 +++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 9 deletions(-) diff --git a/packages/sv/src/cli/add.ts b/packages/sv/src/cli/add.ts index 5f967f901..e6e5a1714 100644 --- a/packages/sv/src/cli/add.ts +++ b/packages/sv/src/cli/add.ts @@ -19,7 +19,12 @@ import { getErrorHint } from '../core/config.ts'; import { applyAddons, orderAddons, setupAddons } from '../core/engine.ts'; -import { downloadPackage, getPackageJSON } from '../core/fetch-packages.ts'; +import { + type CommunityAddonInfo, + discoverCommunityAddons, + downloadPackage, + getPackageJSON +} from '../core/fetch-packages.ts'; import { formatFiles } from '../core/formatFiles.ts'; import { AGENT_NAMES, @@ -440,15 +445,36 @@ export async function promptAddonQuestions({ // For the prompt, we only show official addons const officialLoaded = officialAddons.map((a) => createLoadedAddon(a)); const results = setupAddons(officialLoaded, workspace); - const addonOptions = officialAddons + const addonOptions: Array<{ label: string; value: string; hint: string }> = officialAddons // only display supported addons relative to the current environment .filter(({ id, hidden }) => results[id].unsupported.length === 0 && !hidden) .map(({ id, homepage, shortDescription }) => ({ label: id, value: id, - hint: `${shortDescription} - ${homepage}` + hint: `${shortDescription} - ${color.website(homepage)}` })); + // discover community addons from local and global node_modules + let communityAddons = discoverCommunityAddons(options.cwd); + // filter out any that share an id with an official addon + const officialIds = new Set(officialAddons.map((a) => a.id)); + communityAddons = communityAddons.filter((c) => !officialIds.has(c.name)); + + for (const community of communityAddons) { + const sourceHint = community.local ? '[local]' : '[global]'; + const displayName = community.name.endsWith('/sv') + ? community.name.slice(0, -3) + : community.name; + const homepage = community.homepage + ? ` - ${color.website(community.homepage)}` + : ''; + addonOptions.push({ + label: `${color.dim('[community]')} ${displayName}`, + value: community.name, + hint: `${sourceHint} ${community.shortDescription}${homepage}` + }); + } + const selected = await p.multiselect({ message: `What would you like to add to your project? ${color.dim('(use arrow keys / space bar)')}`, options: addonOptions, @@ -459,13 +485,46 @@ export async function promptAddonQuestions({ process.exit(1); } - for (const id of selected) { - // Create LoadedAddon for newly selected official addon + // separate official and community selections + const selectedOfficialIds = selected.filter((id) => officialIds.has(id)); + const selectedCommunityNames = selected.filter((id) => !officialIds.has(id)); + + for (const id of selectedOfficialIds) { const addon = getAddonDetails(id); addons.push(createLoadedAddon(addon)); answers[id] = {}; } + // resolve selected community addons + if (selectedCommunityNames.length > 0) { + const communityRefs = selectedCommunityNames.map((name) => { + const info = communityAddons.find((c) => c.name === name); + const isLocal = info?.local ?? false; + return { name, isLocal }; + }); + + const addonInputs: AddonInput[] = communityRefs.map(({ name }) => ({ + specifier: name, + options: [] + })); + + const refs = classifyAddons(addonInputs, options.cwd); + + // skip the security warning for locally installed addons + const allLocal = communityRefs.every((r) => r.isLocal); + const resolved = await resolveNonOfficialAddons(refs, !allLocal, { + skipWarning: allLocal + }); + + for (let i = 0; i < refs.length; i++) { + const addon = resolved[i]; + if (addon) { + addons.push({ reference: refs[i], addon }); + answers[addon.id] = {}; + } + } + } + // Re-run setup for all selected addons (including any that were added via CLI options) setupResults = setupAddons(addons, workspace); } @@ -952,7 +1011,8 @@ function getOptionChoices(details: AddonDefinition) { export async function resolveNonOfficialAddons( refs: AddonReference[], - downloadCheck: boolean + downloadCheck: boolean, + options?: { skipWarning?: boolean } ): Promise { const selectedAddons: AddonDefinition[] = []; const { start, stop } = p.spinner(); @@ -977,9 +1037,11 @@ export async function resolveNonOfficialAddons( } } - p.log.warn( - 'Svelte maintainers have not reviewed community add-ons for malicious code. Use at your discretion.' - ); + if (!options?.skipWarning) { + p.log.warn( + 'Svelte maintainers have not reviewed community add-ons for malicious code. Use at your discretion.' + ); + } const paddingName = common.getPadding(pkgs.map(({ pkg }) => pkg.name)); const paddingVersion = common.getPadding(pkgs.map(({ pkg }) => `(v${pkg.version})`)); diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts index 9d32ffa47..26d4922c3 100644 --- a/packages/sv/src/cli/create.ts +++ b/packages/sv/src/cli/create.ts @@ -314,6 +314,7 @@ async function createProject(cwd: ProjectPath, options: Options) { install: false, gitCheck: false, downloadCheck: options.downloadCheck, + addons: addonsOptionsMap }, loadedAddons, @@ -354,6 +355,7 @@ async function createProject(cwd: ProjectPath, options: Options) { install: false, gitCheck: false, downloadCheck: options.downloadCheck, + addons: addonsOptionsMap }, loadedAddons, diff --git a/packages/sv/src/core/fetch-packages.ts b/packages/sv/src/core/fetch-packages.ts index 403f7221c..2a148f0af 100644 --- a/packages/sv/src/core/fetch-packages.ts +++ b/packages/sv/src/core/fetch-packages.ts @@ -1,4 +1,5 @@ import { color, downloadJson, splitVersion } from '@sveltejs/sv-utils'; +import { execSync } from 'node:child_process'; import fs from 'node:fs'; import { platform } from 'node:os'; import path from 'node:path'; @@ -212,3 +213,109 @@ export async function getPackageJSON(ref: AddonReference): Promise<{ warning }; } + +export type CommunityAddonInfo = { + name: string; + version: string; + shortDescription: string; + homepage: string; + /** Whether the addon was found in local node_modules (vs global) */ + local: boolean; +}; + +const SV_ADD_KEYWORD = 'sv-add'; + +/** + * Scans a node_modules directory (depth 0) for packages with the `sv-add` keyword. + */ +function scanNodeModules(nodeModulesPath: string, local: boolean): CommunityAddonInfo[] { + const addons: CommunityAddonInfo[] = []; + + if (!fs.existsSync(nodeModulesPath)) return addons; + + const entries = fs.readdirSync(nodeModulesPath, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.name === '.package-lock.json' || entry.name === '.cache') continue; + + // handle scoped packages (@scope/pkg) + if (entry.name.startsWith('@') && entry.isDirectory()) { + const scopePath = path.join(nodeModulesPath, entry.name); + const scopedEntries = fs.readdirSync(scopePath, { withFileTypes: true }); + for (const scopedEntry of scopedEntries) { + const info = readAddonInfo( + path.join(scopePath, scopedEntry.name), + `${entry.name}/${scopedEntry.name}`, + local + ); + if (info) addons.push(info); + } + } else if (entry.isDirectory()) { + const info = readAddonInfo(path.join(nodeModulesPath, entry.name), entry.name, local); + if (info) addons.push(info); + } + } + + return addons; +} + +function readAddonInfo( + pkgDir: string, + name: string, + local: boolean +): CommunityAddonInfo | undefined { + try { + const pkgJsonPath = path.join(pkgDir, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) return; + + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + const keywords: string[] = pkgJson.keywords ?? []; + + if (!keywords.includes(SV_ADD_KEYWORD)) return; + + // must have sv as a peerDependency + const peerDeps = pkgJson.peerDependencies ?? {}; + if (!peerDeps['sv']) return; + + return { + name: pkgJson.name ?? name, + version: pkgJson.version ?? '0.0.0', + shortDescription: pkgJson.description ?? '', + homepage: pkgJson.homepage ?? pkgJson.repository?.homepage ?? pkgJson.repository?.url ?? '', + local + }; + } catch { + return; + } +} + +/** + * Discovers community addons from local and global node_modules. + * Only returns packages with `sv-add` keyword and `sv` in peerDependencies. + */ +export function discoverCommunityAddons(cwd: string): CommunityAddonInfo[] { + const addons: CommunityAddonInfo[] = []; + + // scan local node_modules + const localNodeModules = path.join(cwd, 'node_modules'); + addons.push(...scanNodeModules(localNodeModules, true)); + + // scan global node_modules + try { + const globalRoot = execSync('npm root -g', { encoding: 'utf8' }).trim(); + addons.push(...scanNodeModules(globalRoot, false)); + } catch { + // silently ignore if npm root -g fails + } + + // deduplicate (prefer local over global) + const seen = new Map(); + for (const addon of addons) { + const existing = seen.get(addon.name); + if (!existing || addon.local) { + seen.set(addon.name, addon); + } + } + + return Array.from(seen.values()); +} From c5498a194a5bcd136e7665a8a9164ded13582142 Mon Sep 17 00:00:00 2001 From: jycouet Date: Mon, 6 Apr 2026 22:18:06 +0200 Subject: [PATCH 2/4] refacto --- packages/sv/src/cli/add.ts | 79 +++++++-------- packages/sv/src/core/fetch-packages.ts | 127 ++++++++++--------------- 2 files changed, 83 insertions(+), 123 deletions(-) diff --git a/packages/sv/src/cli/add.ts b/packages/sv/src/cli/add.ts index e6e5a1714..161c1d994 100644 --- a/packages/sv/src/cli/add.ts +++ b/packages/sv/src/cli/add.ts @@ -20,7 +20,6 @@ import { } from '../core/config.ts'; import { applyAddons, orderAddons, setupAddons } from '../core/engine.ts'; import { - type CommunityAddonInfo, discoverCommunityAddons, downloadPackage, getPackageJSON @@ -442,42 +441,37 @@ export async function promptAddonQuestions({ // prompt which addons to apply (only when no addons were specified) // Only show selection prompt if no addons were specified at all if (addons.length === 0) { - // For the prompt, we only show official addons + // build official addon options const officialLoaded = officialAddons.map((a) => createLoadedAddon(a)); const results = setupAddons(officialLoaded, workspace); - const addonOptions: Array<{ label: string; value: string; hint: string }> = officialAddons - // only display supported addons relative to the current environment + const officialIds = new Set(officialAddons.map((a) => a.id)); + + const officialOptions = officialAddons .filter(({ id, hidden }) => results[id].unsupported.length === 0 && !hidden) .map(({ id, homepage, shortDescription }) => ({ label: id, value: id, - hint: `${shortDescription} - ${color.website(homepage)}` + hint: `${shortDescription}${homepage ? ` - ${color.website(homepage)}` : ''}` })); - // discover community addons from local and global node_modules - let communityAddons = discoverCommunityAddons(options.cwd); - // filter out any that share an id with an official addon - const officialIds = new Set(officialAddons.map((a) => a.id)); - communityAddons = communityAddons.filter((c) => !officialIds.has(c.name)); - - for (const community of communityAddons) { - const sourceHint = community.local ? '[local]' : '[global]'; - const displayName = community.name.endsWith('/sv') - ? community.name.slice(0, -3) - : community.name; - const homepage = community.homepage - ? ` - ${color.website(community.homepage)}` - : ''; - addonOptions.push({ + // build community addon options from local and global node_modules + const communityAddons = discoverCommunityAddons(options.cwd).filter( + (c) => !officialIds.has(c.name) + ); + + const communityOptions = communityAddons.map((c) => { + const displayName = c.name.endsWith('/sv') ? c.name.slice(0, -3) : c.name; + const homepage = c.homepage ? ` - ${color.website(c.homepage)}` : ''; + return { label: `${color.dim('[community]')} ${displayName}`, - value: community.name, - hint: `${sourceHint} ${community.shortDescription}${homepage}` - }); - } + value: c.name, + hint: `[${c.source}] ${c.description}${homepage}` + }; + }); const selected = await p.multiselect({ message: `What would you like to add to your project? ${color.dim('(use arrow keys / space bar)')}`, - options: addonOptions, + options: [...officialOptions, ...communityOptions], required: false }); if (p.isCancel(selected)) { @@ -485,33 +479,26 @@ export async function promptAddonQuestions({ process.exit(1); } - // separate official and community selections - const selectedOfficialIds = selected.filter((id) => officialIds.has(id)); - const selectedCommunityNames = selected.filter((id) => !officialIds.has(id)); - - for (const id of selectedOfficialIds) { - const addon = getAddonDetails(id); - addons.push(createLoadedAddon(addon)); + // handle official selections + for (const id of selected.filter((id) => officialIds.has(id))) { + addons.push(createLoadedAddon(getAddonDetails(id))); answers[id] = {}; } - // resolve selected community addons - if (selectedCommunityNames.length > 0) { - const communityRefs = selectedCommunityNames.map((name) => { + // handle community selections + const selectedCommunity = selected + .filter((id) => !officialIds.has(id)) + .map((name) => { const info = communityAddons.find((c) => c.name === name); - const isLocal = info?.local ?? false; - return { name, isLocal }; + return { name, source: info?.source ?? 'global' }; }); - const addonInputs: AddonInput[] = communityRefs.map(({ name }) => ({ - specifier: name, - options: [] - })); - - const refs = classifyAddons(addonInputs, options.cwd); - - // skip the security warning for locally installed addons - const allLocal = communityRefs.every((r) => r.isLocal); + if (selectedCommunity.length > 0) { + const refs = classifyAddons( + selectedCommunity.map(({ name }) => ({ specifier: name, options: [] })), + options.cwd + ); + const allLocal = selectedCommunity.every((c) => c.source === 'local'); const resolved = await resolveNonOfficialAddons(refs, !allLocal, { skipWarning: allLocal }); diff --git a/packages/sv/src/core/fetch-packages.ts b/packages/sv/src/core/fetch-packages.ts index 2a148f0af..627f48c39 100644 --- a/packages/sv/src/core/fetch-packages.ts +++ b/packages/sv/src/core/fetch-packages.ts @@ -214,108 +214,81 @@ export async function getPackageJSON(ref: AddonReference): Promise<{ }; } +export type DiscoveredAddonSource = 'local' | 'global'; + export type CommunityAddonInfo = { name: string; - version: string; - shortDescription: string; + description: string; homepage: string; - /** Whether the addon was found in local node_modules (vs global) */ - local: boolean; + source: DiscoveredAddonSource; }; -const SV_ADD_KEYWORD = 'sv-add'; - /** - * Scans a node_modules directory (depth 0) for packages with the `sv-add` keyword. + * Discovers community addons from local and global node_modules. + * Scans for packages with `sv-add` keyword and `sv` in peerDependencies. */ -function scanNodeModules(nodeModulesPath: string, local: boolean): CommunityAddonInfo[] { - const addons: CommunityAddonInfo[] = []; +export function discoverCommunityAddons(cwd: string): CommunityAddonInfo[] { + const local = scanNodeModules(path.join(cwd, 'node_modules'), 'local'); - if (!fs.existsSync(nodeModulesPath)) return addons; + let global: CommunityAddonInfo[] = []; + try { + const globalRoot = execSync('npm root -g', { encoding: 'utf8' }).trim(); + global = scanNodeModules(globalRoot, 'global'); + } catch { + // silently ignore if npm root -g fails + } + + // deduplicate: local wins over global + const byName = new Map(); + for (const addon of [...global, ...local]) { + byName.set(addon.name, addon); + } + return Array.from(byName.values()); +} - const entries = fs.readdirSync(nodeModulesPath, { withFileTypes: true }); +function scanNodeModules( + nodeModulesPath: string, + source: DiscoveredAddonSource +): CommunityAddonInfo[] { + if (!fs.existsSync(nodeModulesPath)) return []; - for (const entry of entries) { - if (entry.name === '.package-lock.json' || entry.name === '.cache') continue; + const results: CommunityAddonInfo[] = []; + for (const entry of fs.readdirSync(nodeModulesPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; - // handle scoped packages (@scope/pkg) - if (entry.name.startsWith('@') && entry.isDirectory()) { + if (entry.name.startsWith('@')) { const scopePath = path.join(nodeModulesPath, entry.name); - const scopedEntries = fs.readdirSync(scopePath, { withFileTypes: true }); - for (const scopedEntry of scopedEntries) { - const info = readAddonInfo( - path.join(scopePath, scopedEntry.name), - `${entry.name}/${scopedEntry.name}`, - local - ); - if (info) addons.push(info); + for (const scoped of fs.readdirSync(scopePath, { withFileTypes: true })) { + if (!scoped.isDirectory()) continue; + const info = readCommunityAddon(path.join(scopePath, scoped.name), source); + if (info) results.push(info); } - } else if (entry.isDirectory()) { - const info = readAddonInfo(path.join(nodeModulesPath, entry.name), entry.name, local); - if (info) addons.push(info); + } else { + const info = readCommunityAddon(path.join(nodeModulesPath, entry.name), source); + if (info) results.push(info); } } - - return addons; + return results; } -function readAddonInfo( +function readCommunityAddon( pkgDir: string, - name: string, - local: boolean + source: DiscoveredAddonSource ): CommunityAddonInfo | undefined { try { - const pkgJsonPath = path.join(pkgDir, 'package.json'); - if (!fs.existsSync(pkgJsonPath)) return; - - const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); - const keywords: string[] = pkgJson.keywords ?? []; + const raw = fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'); + const pkg = JSON.parse(raw); - if (!keywords.includes(SV_ADD_KEYWORD)) return; - - // must have sv as a peerDependency - const peerDeps = pkgJson.peerDependencies ?? {}; - if (!peerDeps['sv']) return; + if (!pkg.keywords?.includes('sv-add')) return; + if (!pkg.peerDependencies?.sv) return; return { - name: pkgJson.name ?? name, - version: pkgJson.version ?? '0.0.0', - shortDescription: pkgJson.description ?? '', - homepage: pkgJson.homepage ?? pkgJson.repository?.homepage ?? pkgJson.repository?.url ?? '', - local + name: pkg.name, + description: pkg.description ?? '', + homepage: pkg.homepage ?? pkg.repository?.homepage ?? pkg.repository?.url ?? '', + source }; } catch { return; } } - -/** - * Discovers community addons from local and global node_modules. - * Only returns packages with `sv-add` keyword and `sv` in peerDependencies. - */ -export function discoverCommunityAddons(cwd: string): CommunityAddonInfo[] { - const addons: CommunityAddonInfo[] = []; - - // scan local node_modules - const localNodeModules = path.join(cwd, 'node_modules'); - addons.push(...scanNodeModules(localNodeModules, true)); - - // scan global node_modules - try { - const globalRoot = execSync('npm root -g', { encoding: 'utf8' }).trim(); - addons.push(...scanNodeModules(globalRoot, false)); - } catch { - // silently ignore if npm root -g fails - } - - // deduplicate (prefer local over global) - const seen = new Map(); - for (const addon of addons) { - const existing = seen.get(addon.name); - if (!existing || addon.local) { - seen.set(addon.name, addon); - } - } - - return Array.from(seen.values()); -} From 77e7901016a340434690bb16280b50b8a046ad4d Mon Sep 17 00:00:00 2001 From: jycouet Date: Mon, 6 Apr 2026 22:20:41 +0200 Subject: [PATCH 3/4] docs --- documentation/docs/20-commands/20-sv-add.md | 11 +++++++++++ documentation/docs/30-add-ons/99-community.md | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/documentation/docs/20-commands/20-sv-add.md b/documentation/docs/20-commands/20-sv-add.md index ccc60f755..3b6a1109a 100644 --- a/documentation/docs/20-commands/20-sv-add.md +++ b/documentation/docs/20-commands/20-sv-add.md @@ -73,6 +73,17 @@ Prevents installing dependencies Community add-ons are npm packages published by the community. Look out for add-ons from your favourite libraries and tools. _(soon)_ Many developers are building `sv` add-ons to make their integrations a one-liner. You can find them on [npmx](https://www.npmx.dev/search?q=keyword:sv-add) by searching for the keyword: `sv-add`. +### Automatic discovery + +Community add-ons installed in your project's `node_modules` or globally via `npm i -g` are automatically shown in the interactive selection prompt alongside official add-ons. Packages must have the `sv-add` keyword and `sv` as a peer dependency to be detected. + +- **[local]** add-ons are found in the project's `node_modules` +- **[global]** add-ons are found in the global `node_modules` + +Local add-ons skip the security warning since they are already part of your project. + +### Manual usage + ```sh # Install a community add-on by org name (it will look at @org/sv) npx sv add @supacool diff --git a/documentation/docs/30-add-ons/99-community.md b/documentation/docs/30-add-ons/99-community.md index a7e8d1a1b..bf3bbc427 100644 --- a/documentation/docs/30-add-ons/99-community.md +++ b/documentation/docs/30-add-ons/99-community.md @@ -167,11 +167,14 @@ Your add-on must have `sv` as a peer dependency and **no** `dependencies` in `pa // minimum version required to run by this add-on "sv": "^0.13.0" }, - // Add the "sv-add" keyword so users can discover your add-on + // Required: makes your add-on discoverable in the interactive prompt "keywords": ["sv-add", "svelte", "sveltekit"] } ``` +> [!NOTE] +> The `sv-add` keyword combined with `sv` in `peerDependencies` is what makes your add-on automatically appear in the interactive selection prompt when installed locally or globally. Without these, users must specify your package name explicitly. + ### Naming convention #### packages names From e0559f50b2722d642a43bb21133b953f15027fac Mon Sep 17 00:00:00 2001 From: jycouet Date: Mon, 6 Apr 2026 22:32:17 +0200 Subject: [PATCH 4/4] changeset --- .changeset/community-addon-discovery.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/community-addon-discovery.md diff --git a/.changeset/community-addon-discovery.md b/.changeset/community-addon-discovery.md new file mode 100644 index 000000000..c3a50fbb1 --- /dev/null +++ b/.changeset/community-addon-discovery.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +feat(sv): auto-discover community add-ons from local and global node_modules in the interactive prompt