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 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 diff --git a/packages/sv/src/cli/add.ts b/packages/sv/src/cli/add.ts index 5f967f901..161c1d994 100644 --- a/packages/sv/src/cli/add.ts +++ b/packages/sv/src/cli/add.ts @@ -19,7 +19,11 @@ import { getErrorHint } from '../core/config.ts'; import { applyAddons, orderAddons, setupAddons } from '../core/engine.ts'; -import { downloadPackage, getPackageJSON } from '../core/fetch-packages.ts'; +import { + discoverCommunityAddons, + downloadPackage, + getPackageJSON +} from '../core/fetch-packages.ts'; import { formatFiles } from '../core/formatFiles.ts'; import { AGENT_NAMES, @@ -437,21 +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 = 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} - ${homepage}` + hint: `${shortDescription}${homepage ? ` - ${color.website(homepage)}` : ''}` })); + // 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: 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)) { @@ -459,13 +479,39 @@ export async function promptAddonQuestions({ process.exit(1); } - for (const id of selected) { - // Create LoadedAddon for newly selected official addon - 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] = {}; } + // handle community selections + const selectedCommunity = selected + .filter((id) => !officialIds.has(id)) + .map((name) => { + const info = communityAddons.find((c) => c.name === name); + return { name, source: info?.source ?? 'global' }; + }); + + 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 + }); + + 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 +998,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 +1024,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..627f48c39 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,82 @@ export async function getPackageJSON(ref: AddonReference): Promise<{ warning }; } + +export type DiscoveredAddonSource = 'local' | 'global'; + +export type CommunityAddonInfo = { + name: string; + description: string; + homepage: string; + source: DiscoveredAddonSource; +}; + +/** + * Discovers community addons from local and global node_modules. + * Scans for packages with `sv-add` keyword and `sv` in peerDependencies. + */ +export function discoverCommunityAddons(cwd: string): CommunityAddonInfo[] { + const local = scanNodeModules(path.join(cwd, 'node_modules'), 'local'); + + 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()); +} + +function scanNodeModules( + nodeModulesPath: string, + source: DiscoveredAddonSource +): CommunityAddonInfo[] { + if (!fs.existsSync(nodeModulesPath)) return []; + + const results: CommunityAddonInfo[] = []; + for (const entry of fs.readdirSync(nodeModulesPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + + if (entry.name.startsWith('@')) { + const scopePath = path.join(nodeModulesPath, entry.name); + 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 { + const info = readCommunityAddon(path.join(nodeModulesPath, entry.name), source); + if (info) results.push(info); + } + } + return results; +} + +function readCommunityAddon( + pkgDir: string, + source: DiscoveredAddonSource +): CommunityAddonInfo | undefined { + try { + const raw = fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'); + const pkg = JSON.parse(raw); + + if (!pkg.keywords?.includes('sv-add')) return; + if (!pkg.peerDependencies?.sv) return; + + return { + name: pkg.name, + description: pkg.description ?? '', + homepage: pkg.homepage ?? pkg.repository?.homepage ?? pkg.repository?.url ?? '', + source + }; + } catch { + return; + } +}