Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/community-addon-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'sv': patch
---

feat(sv): auto-discover community add-ons from local and global node_modules in the interactive prompt
11 changes: 11 additions & 0 deletions documentation/docs/20-commands/20-sv-add.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion documentation/docs/30-add-ons/99-community.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 63 additions & 14 deletions packages/sv/src/cli/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -437,35 +441,77 @@ 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)) {
p.cancel('Operation cancelled.');
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);
}
Expand Down Expand Up @@ -952,7 +998,8 @@ function getOptionChoices(details: AddonDefinition) {

export async function resolveNonOfficialAddons(
refs: AddonReference[],
downloadCheck: boolean
downloadCheck: boolean,
options?: { skipWarning?: boolean }
): Promise<AddonDefinition[]> {
const selectedAddons: AddonDefinition[] = [];
const { start, stop } = p.spinner();
Expand All @@ -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})`));
Expand Down
2 changes: 2 additions & 0 deletions packages/sv/src/cli/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ async function createProject(cwd: ProjectPath, options: Options) {
install: false,
gitCheck: false,
downloadCheck: options.downloadCheck,

addons: addonsOptionsMap
},
loadedAddons,
Expand Down Expand Up @@ -354,6 +355,7 @@ async function createProject(cwd: ProjectPath, options: Options) {
install: false,
gitCheck: false,
downloadCheck: options.downloadCheck,

addons: addonsOptionsMap
},
loadedAddons,
Expand Down
80 changes: 80 additions & 0 deletions packages/sv/src/core/fetch-packages.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, CommunityAddonInfo>();
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;
}
}
Loading