{#each locales as locale}
@@ -208,8 +211,8 @@ export default defineAddon({
const { ast, generateCode } = parseSvelte(content);
const scriptAst = svelte.ensureScript(ast, { langTs: typescript });
- imports.addNamed(scriptAst, { imports: { m: 'm' }, from: '$lib/paraglide/messages.js' });
- imports.addNamed(scriptAst, {
+ js.imports.addNamed(scriptAst, { imports: { m: 'm' }, from: '$lib/paraglide/messages.js' });
+ js.imports.addNamed(scriptAst, {
imports: {
setLocale: 'setLocale'
},
@@ -247,10 +250,10 @@ export default defineAddon({
}
},
- nextSteps: ({ highlighter }) => {
- const steps = [`Edit your messages in ${highlighter.path('messages/en.json')}`];
+ nextSteps: () => {
+ const steps = [`Edit your messages in ${color.path('messages/en.json')}`];
if (options.demo) {
- steps.push(`Visit ${highlighter.route('/demo/paraglide')} route to view the demo`);
+ steps.push(`Visit ${color.route('/demo/paraglide')} route to view the demo`);
}
return steps;
diff --git a/packages/sv/lib/addons/playwright/index.ts b/packages/sv/lib/addons/playwright/index.ts
index 6bf862ed..186128b5 100644
--- a/packages/sv/lib/addons/playwright/index.ts
+++ b/packages/sv/lib/addons/playwright/index.ts
@@ -1,6 +1,4 @@
-import { dedent, defineAddon, log } from '../../core/index.ts';
-import { common, exports, imports, object } from '../../core/tooling/js/index.ts';
-import { parseJson, parseScript } from '../../core/tooling/parsers.ts';
+import { dedent, defineAddon, js, log, parseJson, parseScript } from '../../core.ts';
export default defineAddon({
id: 'playwright',
@@ -45,8 +43,8 @@ export default defineAddon({
sv.file(`playwright.config.${ext}`, (content) => {
const { ast, generateCode } = parseScript(content);
- const defineConfig = common.parseExpression('defineConfig({})');
- const { value: defaultExport } = exports.createDefault(ast, { fallback: defineConfig });
+ const defineConfig = js.common.parseExpression('defineConfig({})');
+ const { value: defaultExport } = js.exports.createDefault(ast, { fallback: defineConfig });
const config = {
webServer: {
@@ -61,11 +59,11 @@ export default defineAddon({
defaultExport.arguments[0]?.type === 'ObjectExpression'
) {
// uses the `defineConfig` helper
- imports.addNamed(ast, { imports: ['defineConfig'], from: '@playwright/test' });
- object.overrideProperties(defaultExport.arguments[0], config);
+ js.imports.addNamed(ast, { imports: ['defineConfig'], from: '@playwright/test' });
+ js.object.overrideProperties(defaultExport.arguments[0], config);
} else if (defaultExport.type === 'ObjectExpression') {
// if the config is just an object expression, just add the properties
- object.overrideProperties(defaultExport, config);
+ js.object.overrideProperties(defaultExport, config);
} else {
// unexpected config shape
log.warn('Unexpected playwright config for playwright add-on. Could not update.');
diff --git a/packages/sv/lib/addons/prettier/index.ts b/packages/sv/lib/addons/prettier/index.ts
index c8ec4bc5..c9a74cac 100644
--- a/packages/sv/lib/addons/prettier/index.ts
+++ b/packages/sv/lib/addons/prettier/index.ts
@@ -1,6 +1,4 @@
-import { dedent, defineAddon, log, colors } from '../../core/index.ts';
-import { addEslintConfigPrettier } from '../common.ts';
-import { parseJson } from '../../core/tooling/parsers.ts';
+import { addEslintConfigPrettier, dedent, defineAddon, log, parseJson, color } from '../../core.ts';
export default defineAddon({
id: 'prettier',
@@ -35,7 +33,7 @@ export default defineAddon({
({ data, generateCode } = parseJson(content));
} catch {
log.warn(
- `A ${colors.yellow('.prettierrc')} config already exists and cannot be parsed as JSON. Skipping initialization.`
+ `A ${color.warning('.prettierrc')} config already exists and cannot be parsed as JSON. Skipping initialization.`
);
return content;
}
@@ -91,9 +89,9 @@ export default defineAddon({
if (eslintVersion?.startsWith(SUPPORTED_ESLINT_VERSION) === false) {
log.warn(
- `An older major version of ${colors.yellow(
+ `An older major version of ${color.warning(
'eslint'
- )} was detected. Skipping ${colors.yellow('eslint-config-prettier')} installation.`
+ )} was detected. Skipping ${color.warning('eslint-config-prettier')} installation.`
);
}
diff --git a/packages/sv/lib/addons/storybook/index.ts b/packages/sv/lib/addons/storybook/index.ts
index 70d32096..b199f861 100644
--- a/packages/sv/lib/addons/storybook/index.ts
+++ b/packages/sv/lib/addons/storybook/index.ts
@@ -1,6 +1,6 @@
import process from 'node:process';
-import { defineAddon } from '../../core/index.ts';
-import { getNodeTypesVersion } from '../common.ts';
+
+import { defineAddon, getNodeTypesVersion } from '../../core.ts';
export default defineAddon({
id: 'storybook',
diff --git a/packages/sv/lib/addons/sveltekit-adapter/index.ts b/packages/sv/lib/addons/sveltekit-adapter/index.ts
index 7f82302b..b6ec5fd5 100644
--- a/packages/sv/lib/addons/sveltekit-adapter/index.ts
+++ b/packages/sv/lib/addons/sveltekit-adapter/index.ts
@@ -1,6 +1,4 @@
-import { defineAddon, defineAddonOptions } from '../../core/index.ts';
-import { exports, functions, imports, object } from '../../core/tooling/js/index.ts';
-import { parseJson, parseScript } from '../../core/tooling/parsers.ts';
+import { defineAddon, defineAddonOptions, js, parseJson, parseScript } from '../../core.ts';
const adapters = [
{ id: 'auto', package: '@sveltejs/adapter-auto', version: '^7.0.0' },
@@ -70,22 +68,22 @@ export default defineAddon({
adapterName = adapterImportDecl.specifiers?.find((s) => s.type === 'ImportDefaultSpecifier')
?.local?.name as string;
} else {
- imports.addDefault(ast, { from: adapter.package, as: adapterName });
+ js.imports.addDefault(ast, { from: adapter.package, as: adapterName });
}
- const { value: config } = exports.createDefault(ast, { fallback: object.create({}) });
+ const { value: config } = js.exports.createDefault(ast, { fallback: js.object.create({}) });
// override the adapter property
- object.overrideProperties(config, {
+ js.object.overrideProperties(config, {
kit: {
- adapter: functions.createCall({ name: adapterName, args: [], useIdentifiers: true })
+ adapter: js.functions.createCall({ name: adapterName, args: [], useIdentifiers: true })
}
});
// reset the comment for non-auto adapters
if (adapter.package !== '@sveltejs/adapter-auto') {
- const fallback = object.create({});
- const cfgKitValue = object.property(config, { name: 'kit', fallback });
+ const fallback = js.object.create({});
+ const cfgKitValue = js.object.property(config, { name: 'kit', fallback });
// removes any existing adapter auto comments
comments.remove(
diff --git a/packages/sv/lib/addons/tailwindcss/index.ts b/packages/sv/lib/addons/tailwindcss/index.ts
index c43fcb12..18937635 100644
--- a/packages/sv/lib/addons/tailwindcss/index.ts
+++ b/packages/sv/lib/addons/tailwindcss/index.ts
@@ -1,8 +1,14 @@
-import { defineAddon, defineAddonOptions } from '../../core/index.ts';
-import { imports, vite } from '../../core/tooling/js/index.ts';
-import * as svelte from '../../core/tooling/svelte/index.ts';
-import * as css from '../../core/tooling/css/index.ts';
-import { parseCss, parseJson, parseScript, parseSvelte } from '../../core/tooling/parsers.ts';
+import {
+ css,
+ defineAddon,
+ defineAddonOptions,
+ js,
+ parseCss,
+ parseJson,
+ parseScript,
+ parseSvelte,
+ svelte
+} from '../../core.ts';
const plugins = [
{
@@ -53,8 +59,8 @@ export default defineAddon({
const { ast, generateCode } = parseScript(content);
const vitePluginName = 'tailwindcss';
- imports.addDefault(ast, { as: vitePluginName, from: '@tailwindcss/vite' });
- vite.addPlugin(ast, { code: `${vitePluginName}()`, mode: 'prepend' });
+ js.imports.addDefault(ast, { as: vitePluginName, from: '@tailwindcss/vite' });
+ js.vite.addPlugin(ast, { code: `${vitePluginName}()`, mode: 'prepend' });
return generateCode();
});
@@ -90,7 +96,7 @@ export default defineAddon({
sv.file(appSvelte, (content) => {
const { ast, generateCode } = parseSvelte(content);
const scriptAst = svelte.ensureScript(ast, { langTs: typescript });
- imports.addEmpty(scriptAst, { from: stylesheetRelative });
+ js.imports.addEmpty(scriptAst, { from: stylesheetRelative });
return generateCode();
});
} else {
@@ -99,7 +105,7 @@ export default defineAddon({
sv.file(layoutSvelte, (content) => {
const { ast, generateCode } = parseSvelte(content);
const scriptAst = svelte.ensureScript(ast, { langTs: typescript });
- imports.addEmpty(scriptAst, { from: stylesheetRelative });
+ js.imports.addEmpty(scriptAst, { from: stylesheetRelative });
if (content.length === 0) {
const svelteVersion = dependencyVersion('svelte');
diff --git a/packages/sv/lib/addons/vitest-addon/index.ts b/packages/sv/lib/addons/vitest-addon/index.ts
index f2c048ad..49c33ec3 100644
--- a/packages/sv/lib/addons/vitest-addon/index.ts
+++ b/packages/sv/lib/addons/vitest-addon/index.ts
@@ -1,6 +1,12 @@
-import { dedent, defineAddon, defineAddonOptions } from '../../core/index.ts';
-import { array, imports, object, functions, vite } from '../../core/tooling/js/index.ts';
-import { parseJson, parseScript } from '../../core/tooling/parsers.ts';
+import {
+ dedent,
+ defineAddon,
+ defineAddonOptions,
+ js,
+ parseJson,
+ parseScript,
+ color
+} from '../../core.ts';
const options = defineAddonOptions()
.add('usages', {
@@ -100,13 +106,13 @@ export default defineAddon({
sv.file(files.viteConfig, (content) => {
const { ast, generateCode } = parseScript(content);
- const clientObjectExpression = object.create({
+ const clientObjectExpression = js.object.create({
extends: `./${files.viteConfig}`,
test: {
name: 'client',
browser: {
enabled: true,
- provider: functions.createCall({ name: 'playwright', args: [] }),
+ provider: js.functions.createCall({ name: 'playwright', args: [] }),
instances: [{ browser: 'chromium', headless: true }]
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
@@ -114,7 +120,7 @@ export default defineAddon({
}
});
- const serverObjectExpression = object.create({
+ const serverObjectExpression = js.object.create({
extends: `./${files.viteConfig}`,
test: {
name: 'server',
@@ -124,58 +130,58 @@ export default defineAddon({
}
});
- const viteConfig = vite.getConfig(ast);
+ const viteConfig = js.vite.getConfig(ast);
- const testObject = object.property(viteConfig, {
+ const testObject = js.object.property(viteConfig, {
name: 'test',
- fallback: object.create({
+ fallback: js.object.create({
expect: {
requireAssertions: true
}
})
});
- const workspaceArray = object.property(testObject, {
+ const workspaceArray = js.object.property(testObject, {
name: 'projects',
- fallback: array.create()
+ fallback: js.array.create()
});
- if (componentTesting) array.append(workspaceArray, clientObjectExpression);
- if (unitTesting) array.append(workspaceArray, serverObjectExpression);
+ if (componentTesting) js.array.append(workspaceArray, clientObjectExpression);
+ if (unitTesting) js.array.append(workspaceArray, serverObjectExpression);
// Manage imports
if (componentTesting)
- imports.addNamed(ast, { imports: ['playwright'], from: '@vitest/browser-playwright' });
+ js.imports.addNamed(ast, { imports: ['playwright'], from: '@vitest/browser-playwright' });
const importName = 'defineConfig';
- const { statement, alias } = imports.find(ast, { name: importName, from: 'vite' });
+ const { statement, alias } = js.imports.find(ast, { name: importName, from: 'vite' });
if (statement) {
// Switch the import from 'vite' to 'vitest/config' (keeping the alias)
- imports.addNamed(ast, { imports: { defineConfig: alias }, from: 'vitest/config' });
+ js.imports.addNamed(ast, { imports: { defineConfig: alias }, from: 'vitest/config' });
// Remove the old import
- imports.remove(ast, { name: importName, from: 'vite', statement });
+ js.imports.remove(ast, { name: importName, from: 'vite', statement });
}
return generateCode();
});
},
- nextSteps: ({ highlighter, typescript, options }) => {
+ nextSteps: ({ typescript, options }) => {
const toReturn: string[] = [];
if (vitestV3Installed) {
const componentTesting = options.usages.includes('component');
if (componentTesting) {
- toReturn.push(`Uninstall ${highlighter.command('@vitest/browser')} package`);
+ toReturn.push(`Uninstall ${color.command('@vitest/browser')} package`);
toReturn.push(
- `Update usage from ${highlighter.command("'@vitest/browser...'")} to ${highlighter.command("'vitest/browser'")}`
+ `Update usage from ${color.command("'@vitest/browser...'")} to ${color.command("'vitest/browser'")}`
);
}
toReturn.push(
- `${highlighter.optional('Optional')} Check ${highlighter.path('./vite.config.ts')} and remove duplicate project definitions`
+ `${color.optional('Optional')} Check ${color.path('./vite.config.ts')} and remove duplicate project definitions`
);
toReturn.push(
- `${highlighter.optional('Optional')} Remove ${highlighter.path('./vitest-setup-client' + (typescript ? '.ts' : '.js'))} file`
+ `${color.optional('Optional')} Remove ${color.path('./vitest-setup-client' + (typescript ? '.ts' : '.js'))} file`
);
}
diff --git a/packages/sv/lib/cli/add/fetch-packages.ts b/packages/sv/lib/cli/add/fetch-packages.ts
index 55b456ab..d58a98c7 100644
--- a/packages/sv/lib/cli/add/fetch-packages.ts
+++ b/packages/sv/lib/cli/add/fetch-packages.ts
@@ -1,9 +1,15 @@
import fs from 'node:fs';
+import { platform } from 'node:os';
import path from 'node:path';
-import { createGunzip } from 'node:zlib';
-import { fileURLToPath } from 'node:url';
import { pipeline } from 'node:stream/promises';
-import type { AddonWithoutExplicitArgs } from '../../core/index.ts';
+import { fileURLToPath } from 'node:url';
+import { createGunzip } from 'node:zlib';
+import { extract } from 'tar-fs';
+
+import { color, type ResolvedAddon } from '../../core.ts';
+import * as common from '../utils/common.ts';
+// eslint-disable-next-line no-restricted-imports
+import { downloadJson } from '../../core/downloadJson.ts';
// path to the `node_modules` directory of `sv`
const NODE_MODULES = fileURLToPath(new URL('../node_modules', import.meta.url));
@@ -28,20 +34,52 @@ function verifyPackage(pkg: Record
, specifier: string) {
}
}
+/**
+ * Recursively copies a directory from source to destination
+ * Skips node_modules directories
+ */
+function copyDirectorySync(src: string, dest: string) {
+ const stats = fs.statSync(src);
+ if (stats.isDirectory()) {
+ // Skip node_modules directories - they'll be installed separately
+ if (path.basename(src) === 'node_modules') {
+ return;
+ }
+
+ if (!fs.existsSync(dest)) {
+ fs.mkdirSync(dest, { recursive: true });
+ }
+ const entries = fs.readdirSync(src, { withFileTypes: true });
+ for (const entry of entries) {
+ const srcPath = path.join(src, entry.name);
+ const destPath = path.join(dest, entry.name);
+
+ if (entry.isDirectory()) {
+ copyDirectorySync(srcPath, destPath);
+ } else {
+ fs.copyFileSync(srcPath, destPath);
+ }
+ }
+ } else {
+ fs.copyFileSync(src, dest);
+ }
+}
+
type DownloadOptions = { path?: string; pkg: any };
/**
* Downloads and installs the package into the `node_modules` of `sv`.
* @returns the details of the downloaded addon
*/
-export async function downloadPackage(options: DownloadOptions): Promise {
+export async function downloadPackage(options: DownloadOptions): Promise {
const { pkg } = options;
if (options.path) {
// we'll create a symlink so that we can dynamically import the package via `import(pkg-name)`
+ // On Windows, symlinks require admin privileges, so we fall back to copying if symlink fails
const dest = path.join(NODE_MODULES, pkg.name.split('/').join(path.sep));
- // ensures that a new symlink is always created
+ // ensures that a new symlink/copy is always created
if (fs.existsSync(dest)) {
- fs.rmSync(dest);
+ fs.rmSync(dest, { recursive: true });
}
// `symlinkSync` doesn't recursively create directories to the `destination` path,
@@ -50,13 +88,26 @@ export async function downloadPackage(options: DownloadOptions): Promise {
- // // file paths from the tarball will always have a `package/` prefix,
- // // so we'll need to replace it with the name of the package
- // header.name = header.name.replace('package', pkg.name);
- // return header;
- // }
- // })
+ createGunzip(),
+ extract(NODE_MODULES, {
+ map: (header: any) => {
+ // file paths from the tarball will always have a `package/` prefix,
+ // so we'll need to replace it with the name of the package
+ header.name = header.name.replace('package', pkg.name);
+ return header;
+ }
+ })
);
const { default: details } = await import(pkg.name);
@@ -118,20 +169,26 @@ async function fetchPackageJSON(packageName: string) {
let pkgName = packageName;
let scope = '';
if (packageName.startsWith('@')) {
- const [org, name] = pkgName.split('/', 2);
- scope = `${org}/`;
- pkgName = name!;
+ if (packageName.includes('/')) {
+ const [org, name] = pkgName.split('/', 2);
+ scope = `${org}/`;
+ pkgName = name;
+ } else {
+ scope = `${packageName}/`;
+ pkgName = 'sv';
+ }
}
const [name, tag = 'latest'] = pkgName.split('@');
- const pkgUrl = `${REGISTRY}/${scope + name}/${tag}`;
- const resp = await fetch(pkgUrl);
- if (resp.status === 404) {
- throw new Error(`Package '${packageName}' doesn't exist in the registry: '${pkgUrl}'`);
- }
- if (resp.status < 200 && resp.status >= 300) {
- throw new Error(`Failed to fetch '${pkgUrl}' - GET ${resp.status}`);
- }
+ const fullName = `${scope + name}`;
+ const pkgUrl = `${REGISTRY}/${fullName}/${tag}`;
+
+ const blocklist = await downloadJson(
+ 'https://raw.githubusercontent.com/sveltejs/cli/refs/heads/main/packages/sv/blocklist.json'
+ );
+ const blockedNpmAddons = blocklist.npm_names.includes(fullName);
+ if (blockedNpmAddons)
+ common.errorAndExit(`${color.warning(fullName)} blocked from being installed.`);
- return await resp.json();
+ return await downloadJson(pkgUrl);
}
diff --git a/packages/sv/lib/cli/add/index.ts b/packages/sv/lib/cli/add/index.ts
index 84ce8146..812b113a 100644
--- a/packages/sv/lib/cli/add/index.ts
+++ b/packages/sv/lib/cli/add/index.ts
@@ -1,68 +1,64 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import process from 'node:process';
import * as p from '@clack/prompts';
-import {
- officialAddons as _officialAddons,
- communityAddonIds,
- getAddonDetails,
- getCommunityAddon
-} from '../../addons/index.ts';
-import type {
- AddonSetupResult,
- AddonWithoutExplicitArgs,
- OptionValues,
- Workspace
-} from '../../core/index.ts';
import { Command } from 'commander';
import * as pkg from 'empathic/package';
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
import pc from 'picocolors';
import * as v from 'valibot';
-import { applyAddons, setupAddons, type AddonMap } from '../../addons/install.ts';
+import {
+ officialAddons as _officialAddons,
+ getAddonDetails
+} from '../../addons/_config/official.ts';
+import { type AddonMap, applyAddons, setupAddons } from '../../addons/add.ts';
+import type { AddonSetupResult, OptionValues, ResolvedAddon, Workspace } from '../../core.ts';
+import { noDownloadCheckOption, noInstallOption } from '../create.ts';
import * as common from '../utils/common.ts';
-import { verifyCleanWorkingDirectory, verifyUnsupportedAddons } from './verifiers.ts';
import {
- addPnpmBuildDependencies,
AGENT_NAMES,
+ addPnpmBuildDependencies,
installDependencies,
installOption,
packageManagerPrompt
} from '../utils/package-manager.ts';
-import { Directive, downloadPackage, getPackageJSON } from './fetch-packages.ts';
-import { formatFiles, getHighlighter } from './utils.ts';
+import { downloadPackage, getPackageJSON } from './fetch-packages.ts';
+import { formatFiles, color } from './utils.ts';
+import { verifyCleanWorkingDirectory, verifyUnsupportedAddons } from './verifiers.ts';
import { createWorkspace } from './workspace.ts';
const officialAddons = Object.values(_officialAddons);
-const aliases = officialAddons.map((c) => c.alias).filter((v) => v !== undefined);
const addonOptions = getAddonOptionFlags();
-const communityDetails: AddonWithoutExplicitArgs[] = [];
-const AddonsSchema = v.array(v.string());
const OptionsSchema = v.strictObject({
cwd: v.string(),
install: v.union([v.boolean(), v.picklist(AGENT_NAMES)]),
gitCheck: v.boolean(),
- community: v.optional(v.union([AddonsSchema, v.boolean()])),
+ downloadCheck: v.boolean(),
addons: v.record(v.string(), v.optional(v.array(v.string())))
});
type Options = v.InferOutput;
-export type AddonArgs = { id: string; options: string[] | undefined };
+type AddonArgsIn = { id: string; options?: string[] };
+type AddonArgsOut = AddonArgsIn & {
+ options: string[];
+ kind: 'official' | 'file' | 'scoped';
+ resolvedId: string;
+};
// infers the workspace cwd if a `package.json` resides in a parent directory
const defaultPkgPath = pkg.up();
const defaultCwd = defaultPkgPath ? path.dirname(defaultPkgPath) : undefined;
export const add = new Command('add')
.description('applies specified add-ons into a project')
- .argument('[add-on...]', `add-ons to install`, (value: string, previous: AddonArgs[] = []) =>
+ .argument('[add-on...]', `add-ons to install`, (value: string, previous: AddonArgsOut[] = []) =>
addonArgsHandler(previous, value)
)
.option('-C, --cwd ', 'path to working directory', defaultCwd)
.option('--no-git-check', 'even if some files are dirty, no prompt will be shown')
- .option('--no-install', 'skip installing dependencies')
+ .addOption(noDownloadCheckOption)
+ .addOption(noInstallOption)
.addOption(installOption)
- //.option('--community [add-on...]', 'community addons to install')
.configureHelp({
...common.helpConfig,
formatHelp(cmd, helper) {
@@ -149,7 +145,7 @@ export const add = new Command('add')
return output.join('\n');
}
})
- .action(async (addonArgs: AddonArgs[], opts) => {
+ .action(async (addonArgs: AddonArgsIn[], opts) => {
// validate workspace
if (opts.cwd === undefined) {
common.errorAndExit(
@@ -162,25 +158,39 @@ export const add = new Command('add')
);
}
- const selectedAddonArgs = sanitizeAddons(addonArgs);
-
const options = v.parse(OptionsSchema, { ...opts, addons: {} });
- selectedAddonArgs.forEach((addon) => (options.addons[addon.id] = addon.options));
+ const selectedAddonArgs = sanitizeAddons(addonArgs);
const workspace = await createWorkspace({ cwd: options.cwd });
common.runCommand(async () => {
- const selectedAddonIds = selectedAddonArgs.map(({ id }) => id);
+ // Resolve all addons (official and community) into a unified structure
+ const { resolvedAddons, specifierToId } = await resolveAddons(
+ selectedAddonArgs,
+ options.cwd,
+ options.downloadCheck
+ );
- const { answersCommunity, answersOfficial, selectedAddons } = await promptAddonQuestions({
+ // Map options from original specifiers to resolved IDs
+ for (const addonArg of selectedAddonArgs) {
+ const resolvedId = specifierToId.get(addonArg.id) ?? addonArg.id;
+ options.addons[resolvedId] = addonArg.options;
+ }
+
+ // Map selectedAddonIds to use resolved IDs
+ const selectedAddonIds = selectedAddonArgs.map(({ id }) => {
+ return specifierToId.get(id) ?? id;
+ });
+
+ const { answers, selectedAddons } = await promptAddonQuestions({
options,
selectedAddonIds,
+ allAddons: resolvedAddons,
workspace
});
const { nextSteps } = await runAddonsApply({
- answersOfficial,
- answersCommunity,
+ answers,
options,
selectedAddons,
workspace,
@@ -193,215 +203,236 @@ export const add = new Command('add')
});
});
-export type SelectedAddon = { type: 'official' | 'community'; addon: AddonWithoutExplicitArgs };
+/**
+ * Resolves all addons (official and community) into a unified structure.
+ * Returns a map of resolved addons keyed by their resolved ID.
+ */
+export async function resolveAddons(
+ addonArgs: AddonArgsOut[],
+ cwd: string,
+ downloadCheck: boolean
+): Promise<{
+ resolvedAddons: Map;
+ specifierToId: Map;
+}> {
+ const resolvedAddons = new Map();
+ const specifierToId = new Map();
+
+ // Separate official and community addons for resolution
+ const officialAddonArgs = addonArgs.filter((addon) => addon.kind === 'official');
+ const communityAddonArgs = addonArgs.filter((addon) => addon.kind !== 'official');
+
+ // Resolve official addons
+ for (const addonArg of officialAddonArgs) {
+ const addon = getAddonDetails(addonArg.id);
+ // Official addons don't need originalSpecifier since they're referenced by ID
+ resolvedAddons.set(addon.id, addon);
+ specifierToId.set(addonArg.id, addon.id);
+ }
+
+ // Resolve community addons (file: and scoped packages)
+ if (communityAddonArgs.length > 0) {
+ const communitySpecifiers = communityAddonArgs.map((addon) => addon.id);
+ const communityAddons = await resolveNonOfficialAddons(cwd, communityAddonArgs, downloadCheck);
+
+ // Map community addons by position (they're resolved in the same order)
+ communitySpecifiers.forEach((specifier, index) => {
+ const resolvedAddon = communityAddons[index];
+ if (resolvedAddon) {
+ // Store the original specifier directly on the addon
+ resolvedAddon.originalSpecifier = specifier;
+ resolvedAddons.set(resolvedAddon.id, resolvedAddon);
+ specifierToId.set(specifier, resolvedAddon.id);
+ }
+ });
+ }
+
+ return { resolvedAddons, specifierToId };
+}
export async function promptAddonQuestions({
options,
selectedAddonIds,
+ allAddons,
workspace
}: {
options: Options;
selectedAddonIds: string[];
+ allAddons: Map;
workspace: Workspace;
}) {
- const selectedOfficialAddons: Array = [];
+ const selectedAddons: ResolvedAddon[] = [];
- // Find which official addons were specified in the args
- selectedAddonIds.map((id) => {
- if (officialAddons.find((a) => a.id === id)) {
- selectedOfficialAddons.push(getAddonDetails(id));
+ // Find addons by ID using unified lookup
+ for (const id of selectedAddonIds) {
+ const addon = allAddons.get(id);
+ if (addon) {
+ selectedAddons.push(addon);
}
- });
+ }
const emptyAnswersReducer = (acc: Record>, id: string) => {
acc[id] = {};
return acc;
};
- const answersOfficial: Record> = selectedOfficialAddons
+ const answers: Record> = selectedAddons
.map(({ id }) => id)
.reduce(emptyAnswersReducer, {});
// apply specified options from CLI, inquire about the rest
- for (const addonOption of addonOptions) {
- const addonId = addonOption.id;
+ for (const addonId of Object.keys(options.addons)) {
const specifiedOptions = options.addons[addonId];
if (!specifiedOptions) continue;
- const details = getAddonDetails(addonId);
- if (!selectedOfficialAddons.find((d) => d === details)) {
- selectedOfficialAddons.push(details);
+ // Get addon details using unified lookup
+ const details = allAddons.get(addonId);
+
+ if (!details) continue;
+
+ if (!selectedAddons.find((d) => d.id === details.id)) {
+ selectedAddons.push(details);
}
- answersOfficial[addonId] ??= {};
+ answers[addonId] ??= {};
const optionEntries = Object.entries(details.options);
const specifiedOptionsObject = Object.fromEntries(
specifiedOptions.map((option) => option.split(':', 2))
);
- for (const option of specifiedOptions) {
- const [optionId, optionValue] = option.split(':', 2);
-
- // validates that the option exists
- const optionEntry = optionEntries.find(([id, question]) => {
- // simple ID match
- if (id === optionId) return true;
-
- // group match - need to check conditions and value validity
- if (question.group === optionId) {
- // does the value exist for this option?
- if (question.type === 'select') {
- const isValidValue = question.options.some((opt) => opt.value === optionValue);
- if (!isValidValue) return false;
- } else if (question.type === 'multiselect') {
- // For multiselect, split by comma and validate each value
- const values = optionValue === 'none' ? [] : optionValue.split(',');
- const isValidValue = values.every((val) =>
- question.options.some((opt) => opt.value === val.trim())
- );
- if (!isValidValue) return false;
+ // Only process CLI options if any were actually specified
+ if (specifiedOptions.length > 0) {
+ for (const option of specifiedOptions) {
+ const [optionId, optionValue] = option.split(':', 2);
+
+ // validates that the option exists
+ const optionEntry = optionEntries.find(([id, question]) => {
+ // simple ID match
+ if (id === optionId) return true;
+
+ // group match - need to check conditions and value validity
+ if (question.group === optionId) {
+ // does the value exist for this option?
+ if (question.type === 'select') {
+ const isValidValue = question.options.some((opt) => opt.value === optionValue);
+ if (!isValidValue) return false;
+ } else if (question.type === 'multiselect') {
+ // For multiselect, split by comma and validate each value
+ const values = optionValue === 'none' ? [] : optionValue.split(',');
+ const isValidValue = values.every((val) =>
+ question.options.some((opt) => opt.value === val.trim())
+ );
+ if (!isValidValue) return false;
+ }
+
+ // if there's a condition, does it pass?
+ if (question.condition) {
+ return question.condition(specifiedOptionsObject);
+ }
+
+ // finally, unconditional
+ return true;
}
- // if there's a condition, does it pass?
- if (question.condition) {
- return question.condition(specifiedOptionsObject);
- }
+ // unrecognized optionId
+ return false;
+ });
- // finally, unconditional
- return true;
+ if (!optionEntry) {
+ const { choices } = getOptionChoices(details);
+ common.errorAndExit(
+ `Invalid '${addonId}' add-on option: '${option}'\nAvailable options: ${choices.join(', ')}`
+ );
+ throw new Error();
}
- // unrecognized optionId
- return false;
- });
-
- if (!optionEntry) {
- const { choices } = getOptionChoices(details);
- common.errorAndExit(
- `Invalid '${addonId}' add-on option: '${option}'\nAvailable options: ${choices.join(', ')}`
- );
- throw new Error();
- }
-
- const [questionId, question] = optionEntry;
+ const [questionId, question] = optionEntry;
- // Validate multiselect values for simple ID matches (already validated for group matches above)
- if (question.type === 'multiselect' && questionId === optionId) {
- const values = optionValue === 'none' || optionValue === '' ? [] : optionValue.split(',');
- const invalidValues = values.filter(
- (val) => !question.options.some((opt) => opt.value === val.trim())
- );
- if (invalidValues.length > 0) {
- const validValues = question.options.map((opt) => opt.value).join(', ');
- common.errorAndExit(
- `Invalid '${addonId}' add-on option: '${option}'\nInvalid values: ${invalidValues.join(', ')}\nAvailable values: ${validValues}`
+ // Validate multiselect values for simple ID matches (already validated for group matches above)
+ if (question.type === 'multiselect' && questionId === optionId) {
+ const values = optionValue === 'none' || optionValue === '' ? [] : optionValue.split(',');
+ const invalidValues = values.filter(
+ (val) => !question.options.some((opt) => opt.value === val.trim())
);
+ if (invalidValues.length > 0) {
+ const validValues = question.options.map((opt) => opt.value).join(', ');
+ common.errorAndExit(
+ `Invalid '${addonId}' add-on option: '${option}'\nInvalid values: ${invalidValues.join(', ')}\nAvailable values: ${validValues}`
+ );
+ }
}
- }
- // validate that there are no conflicts
- let existingOption = answersOfficial[addonId][questionId];
- if (existingOption !== undefined) {
- if (typeof existingOption === 'boolean') {
- // need to transform the boolean back to `yes` or `no`
- existingOption = existingOption ? 'yes' : 'no';
+ // validate that there are no conflicts
+ let existingOption = answers[addonId][questionId];
+ if (existingOption !== undefined) {
+ if (typeof existingOption === 'boolean') {
+ // need to transform the boolean back to `yes` or `no`
+ existingOption = existingOption ? 'yes' : 'no';
+ }
+ common.errorAndExit(
+ `Conflicting '${addonId}' option: '${option}' conflicts with '${questionId}:${existingOption}'`
+ );
}
- common.errorAndExit(
- `Conflicting '${addonId}' option: '${option}' conflicts with '${questionId}:${existingOption}'`
- );
- }
- if (question.type === 'boolean') {
- answersOfficial[addonId][questionId] = optionValue === 'yes';
- } else if (question.type === 'number') {
- answersOfficial[addonId][questionId] = Number(optionValue);
- } else if (question.type === 'multiselect') {
- // multiselect options can be specified with a `none` option, which equates to an empty array
- if (optionValue === 'none' || optionValue === '') {
- answersOfficial[addonId][questionId] = [];
+ if (question.type === 'boolean') {
+ answers[addonId][questionId] = optionValue === 'yes';
+ } else if (question.type === 'number') {
+ answers[addonId][questionId] = Number(optionValue);
+ } else if (question.type === 'multiselect') {
+ // multiselect options can be specified with a `none` option, which equates to an empty array
+ if (optionValue === 'none' || optionValue === '') {
+ answers[addonId][questionId] = [];
+ } else {
+ // split by comma and trim each value
+ answers[addonId][questionId] = optionValue.split(',').map((v) => v.trim());
+ }
} else {
- // split by comma and trim each value
- answersOfficial[addonId][questionId] = optionValue.split(',').map((v) => v.trim());
+ answers[addonId][questionId] = optionValue;
}
- } else {
- answersOfficial[addonId][questionId] = optionValue;
}
- }
- // apply defaults to unspecified options
- for (const [id, question] of Object.entries(details.options)) {
- // we'll only apply defaults to options that don't explicitly fail their conditions
- if (question.condition?.(answersOfficial[addonId]) !== false) {
- answersOfficial[addonId][id] ??= question.default;
- } else {
- // we'll also error out if a specified option is incompatible with other options.
- // (e.g. `libsql` isn't a valid client for a `mysql` database: `sv add drizzle=database:mysql2,client:libsql`)
- if (answersOfficial[addonId][id] !== undefined) {
- throw new Error(
- `Incompatible '${addonId}' option specified: '${answersOfficial[addonId][id]}'`
- );
+ // apply defaults to unspecified options (only if CLI options were specified)
+ for (const [id, question] of Object.entries(details.options)) {
+ // we'll only apply defaults to options that don't explicitly fail their conditions
+ if (question.condition?.(answers[addonId]) !== false) {
+ answers[addonId][id] ??= question.default;
+ } else {
+ // we'll also error out if a specified option is incompatible with other options.
+ // (e.g. `libsql` isn't a valid client for a `mysql` database: `sv add drizzle=database:mysql2,client:libsql`)
+ if (answers[addonId][id] !== undefined) {
+ throw new Error(
+ `Incompatible '${addonId}' option specified: '${answers[addonId][id]}'`
+ );
+ }
}
}
}
}
- // we'll let the user choose community addons when `--community` is specified without args
- if (options.community === true) {
- const communityAddons = await Promise.all(
- communityAddonIds.map(async (id) => await getCommunityAddon(id))
- );
-
- const promptOptions = communityAddons.map((addon) => ({
- value: addon.id,
- label: addon.id,
- hint: 'https://www.npmjs.com/package/' + addon.id
- }));
-
- const selected = await p.multiselect({
- message: 'Which community tools would you like to add to your project?',
- options: promptOptions,
- required: false
- });
-
- if (p.isCancel(selected)) {
- p.cancel('Operation cancelled.');
- process.exit(1);
- } else if (selected.length === 0) {
- p.cancel('No add-ons selected. Exiting.');
- process.exit(1);
- }
-
- options.community = selected;
+ // Process all selected addons (including those without CLI options) to ensure they're initialized
+ // Note: We don't apply defaults here - defaults will be used as initial values when asking questions
+ for (const addon of selectedAddons) {
+ const addonId = addon.id;
+ answers[addonId] ??= {};
}
- // we'll prepare empty answers for selected community addons
- const selectedCommunityAddons: Array = [];
- const answersCommunity: Record> = selectedCommunityAddons
- .map(({ id }) => id)
- .reduce(emptyAnswersReducer, {});
+ // run setup if we have access to workspace
+ // prepare addons (both official and non-official)
+ let addonSetupResults: Record = {};
- // Find community addons specified in the --community option as well as
- // the ones selected above
- if (Array.isArray(options.community) && options.community.length > 0) {
- selectedCommunityAddons.push(...(await resolveCommunityAddons(options.cwd, options.community)));
+ // If we have selected addons, run setup on them (regardless of official status)
+ if (selectedAddons.length > 0) {
+ addonSetupResults = setupAddons(selectedAddons, workspace);
}
- const selectedAddons: SelectedAddon[] = [
- ...selectedOfficialAddons.map((addon) => ({ type: 'official' as const, addon })),
- ...selectedCommunityAddons.map((addon) => ({ type: 'community' as const, addon }))
- ];
-
- // run setup if we have access to workspace
- // prepare official addons
- const setups = selectedAddons.length ? selectedAddons.map(({ addon }) => addon) : officialAddons;
- const addonSetupResults = setupAddons(setups, workspace);
-
- // prompt which addons to apply
- if (selectedAddons.length === 0) {
- const allSetupResults = setupAddons(officialAddons, workspace);
+ // prompt which addons to apply (only when no addons were specified)
+ // Only show selection prompt if no addons were specified at all
+ if (selectedAddonIds.length === 0) {
+ // For the prompt, we only show official addons
+ const results = setupAddons(officialAddons, workspace);
const addonOptions = officialAddons
// only display supported addons relative to the current environment
- .filter(({ id }) => allSetupResults[id].unsupported.length === 0)
+ .filter(({ id }) => results[id].unsupported.length === 0)
.map(({ id, homepage, shortDescription }) => ({
label: id,
value: id,
@@ -419,37 +450,82 @@ export async function promptAddonQuestions({
}
for (const id of selected) {
- const addon = getAddonDetails(id);
- selectedAddons.push({ type: 'official', addon });
+ const addon = allAddons.get(id);
+ if (addon) {
+ selectedAddons.push(addon);
+ answers[id] = {};
+ }
}
+
+ // Re-run setup for all selected addons (including any that were added via CLI options)
+ addonSetupResults = setupAddons(selectedAddons, workspace);
+ }
+
+ // Ensure all selected addons have setup results
+ // This should always be the case, but we add a safeguard
+ const missingSetupResults = selectedAddons.filter((addon) => !addonSetupResults[addon.id]);
+ if (missingSetupResults.length > 0) {
+ const additionalSetupResults = setupAddons(missingSetupResults, workspace);
+ Object.assign(addonSetupResults, additionalSetupResults);
}
// add inter-addon dependencies
- for (const { addon } of selectedAddons) {
- const setupResult = addonSetupResults[addon.id];
- const missingDependencies = setupResult.dependsOn.filter(
- (depId) => !selectedAddons.some((a) => a.addon.id === depId)
- );
+ // We need to iterate until no new dependencies are added (to handle transitive dependencies)
+ let hasNewDependencies = true;
+ while (hasNewDependencies) {
+ hasNewDependencies = false;
+ const addonsToProcess = [...selectedAddons]; // Work with a snapshot to avoid infinite loops
+
+ for (const addon of addonsToProcess) {
+ const setupResult = addonSetupResults[addon.id];
+ if (!setupResult) {
+ common.errorAndExit(`Setup result missing for addon: ${addon.id}`);
+ }
+ const missingDependencies = setupResult.dependsOn.filter(
+ (depId) => !selectedAddons.some((a) => a.id === depId)
+ );
- for (const depId of missingDependencies) {
- // TODO: this will have to be adjusted when we work on community add-ons
- const dependency = officialAddons.find((a) => a.id === depId);
- if (!dependency) throw new Error(`'${addon.id}' depends on an invalid add-on: '${depId}'`);
+ for (const depId of missingDependencies) {
+ hasNewDependencies = true;
+ // Dependencies are always official addons
+ const depAddon = allAddons.get(depId);
+ if (!depAddon) {
+ // If not in resolved addons, try to get it (dependencies are always official)
+ const officialDep = officialAddons.find((a) => a.id === depId);
+ if (!officialDep) {
+ throw new Error(`'${addon.id}' depends on an invalid add-on: '${depId}'`);
+ }
+ // Add official dependency to the map and use it
+ const officialAddonDetails = getAddonDetails(depId);
+ allAddons.set(depId, officialAddonDetails);
+ selectedAddons.push(officialAddonDetails);
+ answers[depId] = {};
+ continue;
+ }
- // prompt to install the dependent
- const install = await p.confirm({
- message: `The ${pc.bold(pc.cyan(addon.id))} add-on requires ${pc.bold(pc.cyan(depId))} to also be setup. ${pc.green('Include it?')}`
- });
- if (install !== true) {
- p.cancel('Operation cancelled.');
- process.exit(1);
+ // prompt to install the dependent
+ const install = await p.confirm({
+ message: `The ${pc.bold(pc.cyan(addon.id))} add-on requires ${pc.bold(pc.cyan(depId))} to also be setup. ${pc.green('Include it?')}`
+ });
+ if (install !== true) {
+ p.cancel('Operation cancelled.');
+ process.exit(1);
+ }
+ selectedAddons.push(depAddon);
+ answers[depId] = {};
}
- selectedAddons.push({ type: 'official', addon: dependency });
+ }
+
+ // Run setup for any newly added dependencies
+ const newlyAddedAddons = selectedAddons.filter((addon) => !addonSetupResults[addon.id]);
+ if (newlyAddedAddons.length > 0) {
+ const newSetupResults = setupAddons(newlyAddedAddons, workspace);
+ Object.assign(addonSetupResults, newSetupResults);
}
}
// run all setups after inter-addon deps have been added
- const addons = selectedAddons.map(({ addon }) => addon);
+ const addons = selectedAddons;
const verifications = [
...verifyCleanWorkingDirectory(options.cwd, options.gitCheck),
...verifyUnsupportedAddons(addons, addonSetupResults)
@@ -479,19 +555,12 @@ export async function promptAddonQuestions({
}
// ask remaining questions
- for (const { addon, type } of selectedAddons) {
+ for (const addon of selectedAddons) {
const addonId = addon.id;
const questionPrefix = selectedAddons.length > 1 ? `${addon.id}: ` : '';
- let values: OptionValues = {};
- if (type === 'official') {
- answersOfficial[addonId] ??= {};
- values = answersOfficial[addonId];
- }
- if (type === 'community') {
- answersCommunity[addonId] ??= {};
- values = answersCommunity[addonId];
- }
+ answers[addonId] ??= {};
+ const values = answers[addonId];
for (const [questionId, question] of Object.entries(addon.options)) {
const shouldAsk = question.condition?.(values);
@@ -520,7 +589,7 @@ export async function promptAddonQuestions({
if (question.type === 'string' || question.type === 'number') {
answer = await p.text({
message,
- initialValue: question.default.toString(),
+ initialValue: question.default?.toString() ?? (question.type === 'number' ? '0' : ''),
placeholder: question.placeholder,
validate: question.validate
});
@@ -537,30 +606,28 @@ export async function promptAddonQuestions({
}
}
- return { selectedAddons, answersOfficial, answersCommunity };
+ return { selectedAddons, answers };
}
export async function runAddonsApply({
- answersOfficial,
- answersCommunity,
+ answers,
options,
selectedAddons,
addonSetupResults,
workspace,
fromCommand
}: {
- answersOfficial: Record>;
- answersCommunity: Record>;
+ answers: Record>;
options: Options;
- selectedAddons: SelectedAddon[];
+ selectedAddons: ResolvedAddon[];
addonSetupResults?: Record;
workspace: Workspace;
fromCommand: 'create' | 'add';
}): Promise<{ nextSteps: string[]; argsFormattedAddons: string[]; filesToFormat: string[] }> {
if (!addonSetupResults) {
- const setups = selectedAddons.length
- ? selectedAddons.map(({ addon }) => addon)
- : officialAddons;
+ // When no addons are selected, use official addons for setup
+ const officialAddonsList = officialAddons;
+ const setups = selectedAddons.length ? selectedAddons : officialAddonsList;
addonSetupResults = setupAddons(setups, workspace);
}
// we'll return early when no addons are selected,
@@ -569,18 +636,12 @@ export async function runAddonsApply({
return { nextSteps: [], argsFormattedAddons: [], filesToFormat: [] };
// apply addons
- const officialDetails = Object.keys(answersOfficial).map((id) => getAddonDetails(id));
- const commDetails = Object.keys(answersCommunity).map(
- (id) => communityDetails.find((a) => a.id === id)!
- );
- const details = officialDetails.concat(commDetails);
-
- const addonMap: AddonMap = Object.assign({}, ...details.map((a) => ({ [a.id]: a })));
+ const addonMap: AddonMap = Object.assign({}, ...selectedAddons.map((a) => ({ [a.id]: a })));
const { filesToFormat, pnpmBuildDependencies, status } = await applyAddons({
workspace,
addonSetupResults,
addons: addonMap,
- options: answersOfficial
+ options: answers
});
const addonSuccess: string[] = [];
@@ -588,7 +649,7 @@ export async function runAddonsApply({
if (info === 'success') addonSuccess.push(addonId);
else {
p.log.warn(`Canceled ${addonId}: ${info.join(', ')}`);
- selectedAddons = selectedAddons.filter((a) => a.addon.id !== addonId);
+ selectedAddons = selectedAddons.filter((a) => a.id !== addonId);
}
}
@@ -596,9 +657,8 @@ export async function runAddonsApply({
p.cancel('All selected add-ons were canceled.');
process.exit(1);
} else {
- const highlighter = getHighlighter();
p.log.success(
- `Successfully setup add-ons: ${addonSuccess.map((c) => highlighter.addon(c)).join(', ')}`
+ `Successfully setup add-ons: ${addonSuccess.map((c) => color.addon(c)).join(', ')}`
);
}
@@ -615,18 +675,20 @@ export async function runAddonsApply({
]);
const argsFormattedAddons: string[] = [];
- for (const { addon, type } of selectedAddons) {
+ for (const addon of selectedAddons) {
const addonId = addon.id;
- const answers = type === 'official' ? answersOfficial[addonId] : answersCommunity[addonId];
- if (!answers) continue;
+ const addonAnswers = answers[addonId];
+ if (!addonAnswers) continue;
+
+ // Use original specifier if available, otherwise fall back to resolved ID
+ const addonSpecifier = addon.originalSpecifier ?? addonId;
- const addonDetails = type === 'official' ? getAddonDetails(addonId) : addon;
const optionParts: string[] = [];
- for (const [optionId, value] of Object.entries(answers)) {
+ for (const [optionId, value] of Object.entries(addonAnswers)) {
if (value === undefined) continue;
- const question = addonDetails.options[optionId];
+ const question = addon.options[optionId];
if (!question) continue;
let formattedValue: string;
@@ -652,13 +714,19 @@ export async function runAddonsApply({
}
if (optionParts.length > 0) {
- argsFormattedAddons.push(`${addonId}="${optionParts.join('+')}"`);
+ argsFormattedAddons.push(`${addonSpecifier}="${optionParts.join('+')}"`);
} else {
- argsFormattedAddons.push(addonId);
+ argsFormattedAddons.push(addonSpecifier);
}
}
- if (fromCommand === 'add') common.logArgs(packageManager, 'add', argsFormattedAddons);
+ if (!options.downloadCheck) argsFormattedAddons.push('--no-download-check');
+
+ if (fromCommand === 'add') {
+ if (!options.gitCheck) argsFormattedAddons.push('--no-git-check');
+
+ common.logArgs(packageManager, 'add', argsFormattedAddons);
+ }
if (packageManager) {
workspace.packageManager = packageManager;
@@ -666,14 +734,12 @@ export async function runAddonsApply({
await formatFiles({ packageManager, cwd: options.cwd, filesToFormat });
}
- const highlighter = getHighlighter();
-
// print next steps
const nextSteps = selectedAddons
- .map(({ addon }) => {
+ .map((addon) => {
if (!addon.nextSteps) return;
- const addonOptions = answersOfficial[addon.id];
- const addonNextSteps = addon.nextSteps({ ...workspace, options: addonOptions, highlighter });
+ const addonOptions = answers[addon.id];
+ const addonNextSteps = addon.nextSteps({ ...workspace, options: addonOptions });
if (addonNextSteps.length === 0) return;
let addonMessage = `${pc.green(addon.id)}:\n`;
@@ -685,26 +751,58 @@ export async function runAddonsApply({
return { nextSteps, argsFormattedAddons, filesToFormat };
}
-/**
- * Sanitizes the add-on arguments by checking for invalid add-ons and transforming aliases.
- * @param addonArgs The add-on arguments to sanitize.
- * @returns The sanitized add-on arguments.
- */
-export function sanitizeAddons(addonArgs: AddonArgs[]): AddonArgs[] {
- const officialAddonIds = officialAddons.map((addon) => addon.id);
- const invalidAddons = addonArgs
- .filter(({ id }) => !officialAddonIds.includes(id) && !aliases.includes(id))
- .map(({ id }) => id);
+export function sanitizeAddons(addonArgs: AddonArgsIn[]): AddonArgsOut[] {
+ const toRet = new Map();
+
+ const invalidAddons: string[] = [];
+ for (const addon of addonArgs) {
+ const official = officialAddons.find((a) => a.id === addon.id || a.alias === addon.id);
+ if (official) {
+ toRet.set(official.id, {
+ id: official.id,
+ options: addon.options ?? [],
+ kind: 'official',
+ resolvedId: official.id
+ });
+ } else if (addon.id.startsWith('file:')) {
+ const resolvedId = addon.id.replace('file:', '').trim();
+ if (!resolvedId) {
+ invalidAddons.push('file:');
+ continue;
+ }
+ toRet.set(addon.id, {
+ id: addon.id,
+ options: addon.options ?? [],
+ kind: 'file',
+ resolvedId
+ });
+ } else if (addon.id.startsWith('@')) {
+ // Scoped package (e.g., @org/name)
+ const resolvedId = addon.id.includes('/') ? addon.id : addon.id + '/sv';
+ toRet.set(addon.id, {
+ id: addon.id,
+ options: addon.options ?? [],
+ kind: 'scoped',
+ resolvedId
+ });
+ } else {
+ invalidAddons.push(addon.id);
+ }
+ }
if (invalidAddons.length > 0) {
- common.errorAndExit(`Invalid add-ons specified: ${invalidAddons.join(', ')}`);
+ common.errorAndExit(
+ `Invalid add-ons specified: ${invalidAddons.map((id) => color.command(id)).join(', ')}\n` +
+ `${color.optional('Check the documentation for valid add-on specifiers:')} ${color.website('https://svelte.dev/docs/cli/sv-add')}`
+ );
}
- return transformAliases(addonArgs);
+
+ return Array.from(toRet.values());
}
/**
* Handles passed add-on arguments, accumulating them into an array of {@link AddonArgs}.
*/
-export function addonArgsHandler(acc: AddonArgs[], current: string): AddonArgs[] {
+export function addonArgsHandler(acc: AddonArgsIn[], current: string): AddonArgsIn[] {
const [addonId, optionFlags] = current.split('=', 2);
// validates that there are no repeated add-ons (e.g. `sv add foo=demo:yes foo=demo:no`)
@@ -727,23 +825,6 @@ export function addonArgsHandler(acc: AddonArgs[], current: string): AddonArgs[]
return acc;
}
-/**
- * Dedupes and transforms aliases into their respective addon id
- */
-function transformAliases(addons: AddonArgs[]): AddonArgs[] {
- const set = new Map();
-
- for (const addon of addons) {
- if (aliases.includes(addon.id)) {
- const officialAddon = officialAddons.find((a) => a.alias === addon.id)!;
- set.set(officialAddon.id, { id: officialAddon.id, options: addon.options });
- } else {
- set.set(addon.id, addon);
- }
- }
- return Array.from(set.values());
-}
-
function getAddonOptionFlags() {
const options: Array<{ id: string; choices: string; preset: string }> = [];
for (const addon of officialAddons) {
@@ -761,7 +842,7 @@ function getAddonOptionFlags() {
return options;
}
-function getOptionChoices(details: AddonWithoutExplicitArgs) {
+function getOptionChoices(details: ResolvedAddon) {
const choices: string[] = [];
const defaults: string[] = [];
const groups: Record = {};
@@ -792,7 +873,7 @@ function getOptionChoices(details: AddonWithoutExplicitArgs) {
}
if (question.type === 'string' || question.type === 'number') {
values = [''];
- if (applyDefault) {
+ if (applyDefault && question.default !== undefined) {
options[id] = question.default;
defaults.push(question.default.toString());
}
@@ -807,33 +888,26 @@ function getOptionChoices(details: AddonWithoutExplicitArgs) {
return { choices, defaults, groups };
}
-async function resolveCommunityAddons(cwd: string, community: string[]) {
- const selectedAddons: Array = [];
- const addons = community.map((id) => {
- // ids with directives are passed unmodified so they can be processed during downloads
- const hasDirective = Object.values(Directive).some((directive) => id.startsWith(directive));
- if (hasDirective) return id;
-
- const validAddon = communityAddonIds.includes(id);
- if (!validAddon) {
- throw new Error(
- `Invalid community add-on specified: '${id}'\nAvailable options: ${communityAddonIds.join(', ')}`
- );
- }
- return id;
- });
+export async function resolveNonOfficialAddons(
+ cwd: string,
+ addons: AddonArgsOut[],
+ downloadCheck: boolean
+) {
+ const selectedAddons: ResolvedAddon[] = [];
const { start, stop } = p.spinner();
+
try {
- start('Resolving community add-on packages');
+ start(`Resolving ${addons.map((a) => color.addon(a.id)).join(', ')} packages`);
+
const pkgs = await Promise.all(
- addons.map(async (id) => {
- return await getPackageJSON({ cwd, packageName: id });
+ addons.map(async (a) => {
+ return await getPackageJSON({ cwd, packageName: a.id });
})
);
stop('Resolved community add-on packages');
p.log.warn(
- 'The Svelte maintainers have not reviewed community add-ons for malicious code. Use at your discretion.'
+ 'Svelte maintainers have not reviewed community add-ons for malicious code. Use at your discretion.'
);
const paddingName = common.getPadding(pkgs.map(({ pkg }) => pkg.name));
@@ -847,22 +921,25 @@ async function resolveCommunityAddons(cwd: string, community: string[]) {
});
p.log.message(packageInfos.join('\n'));
- const confirm = await p.confirm({ message: 'Would you like to continue?' });
- if (confirm !== true) {
- p.cancel('Operation cancelled.');
- process.exit(1);
+ if (downloadCheck) {
+ const confirm = await p.confirm({ message: 'Would you like to continue?' });
+ if (confirm !== true) {
+ p.cancel('Operation cancelled.');
+ process.exit(1);
+ }
}
start('Downloading community add-on packages');
const details = await Promise.all(pkgs.map(async (opts) => downloadPackage(opts)));
for (const addon of details) {
- communityDetails.push(addon);
selectedAddons.push(addon);
}
stop('Downloaded community add-on packages');
} catch (err) {
- stop('Failed to resolve community add-on packages', 1);
- throw err;
+ const msg = err instanceof Error ? err.message : 'Unknown error';
+ common.errorAndExit(
+ `Failed to resolve ${addons.map((a) => color.addon(a.id)).join(', ')}\n${color.optional(msg)}`
+ );
}
return selectedAddons;
}
diff --git a/packages/sv/lib/cli/add/utils.ts b/packages/sv/lib/cli/add/utils.ts
index d2e4e821..2dd20dd9 100644
--- a/packages/sv/lib/cli/add/utils.ts
+++ b/packages/sv/lib/cli/add/utils.ts
@@ -1,11 +1,11 @@
+import * as p from '@clack/prompts';
import fs from 'node:fs';
import path from 'node:path';
+import { type AgentName, resolveCommand } from 'package-manager-detector';
import pc from 'picocolors';
import { exec } from 'tinyexec';
-import { parseJson } from '../../core/tooling/parsers.ts';
-import { resolveCommand, type AgentName } from 'package-manager-detector';
-import type { Highlighter, Workspace } from '../../core/index.ts';
-import * as p from '@clack/prompts';
+
+import { type Workspace, parseJson } from '../../core.ts';
export type Package = {
name: string;
@@ -136,14 +136,13 @@ export const commonFilePaths = {
viteConfigTS: 'vite.config.ts'
} as const;
-export function getHighlighter(): Highlighter {
- return {
- addon: (str) => pc.green(str),
- command: (str) => pc.bold(pc.cyanBright(str)),
- env: (str) => pc.yellow(str),
- path: (str) => pc.green(str),
- route: (str) => pc.bold(str),
- website: (str) => pc.whiteBright(str),
- optional: (str) => pc.gray(str)
- };
-}
+export const color = {
+ addon: (str: string): string => pc.green(str),
+ command: (str: string): string => pc.bold(pc.cyanBright(str)),
+ env: (str: string): string => pc.yellow(str),
+ path: (str: string): string => pc.green(str),
+ route: (str: string): string => pc.bold(str),
+ website: (str: string): string => pc.cyan(str),
+ optional: (str: string): string => pc.gray(str),
+ warning: (str: string): string => pc.yellow(str)
+};
diff --git a/packages/sv/lib/cli/add/verifiers.ts b/packages/sv/lib/cli/add/verifiers.ts
index 127f6e9a..a3c51db2 100644
--- a/packages/sv/lib/cli/add/verifiers.ts
+++ b/packages/sv/lib/cli/add/verifiers.ts
@@ -1,6 +1,7 @@
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
-import type { AddonSetupResult, AddonWithoutExplicitArgs, Verification } from '../../core/index.ts';
+
+import type { AddonSetupResult, ResolvedAddon, Verification } from '../../core.ts';
import { UnsupportedError } from '../utils/errors.ts';
export function verifyCleanWorkingDirectory(cwd: string, gitCheck: boolean) {
@@ -38,7 +39,7 @@ export function verifyCleanWorkingDirectory(cwd: string, gitCheck: boolean) {
}
export function verifyUnsupportedAddons(
- addons: AddonWithoutExplicitArgs[],
+ addons: ResolvedAddon[],
addonSetupResult: Record
) {
const verifications: Verification[] = [];
diff --git a/packages/sv/lib/cli/add/workspace.ts b/packages/sv/lib/cli/add/workspace.ts
index 886ca746..cc6877d8 100644
--- a/packages/sv/lib/cli/add/workspace.ts
+++ b/packages/sv/lib/cli/add/workspace.ts
@@ -1,12 +1,11 @@
+import * as find from 'empathic/find';
import fs from 'node:fs';
import path from 'node:path';
-import * as find from 'empathic/find';
-import { common, object, type AstTypes } from '../../core/tooling/js/index.ts';
-import { parseScript } from '../../core/tooling/parsers.ts';
import { detect } from 'package-manager-detector';
-import type { PackageManager, Workspace } from '../../core/index.ts';
-import { commonFilePaths, getPackageJson, readFile } from './utils.ts';
+
+import { type AstTypes, type PackageManager, type Workspace, js, parseScript } from '../../core.ts';
import { getUserAgent } from '../utils/package-manager.ts';
+import { commonFilePaths, getPackageJson, readFile } from './utils.ts';
type CreateWorkspaceOptions = {
cwd: string;
@@ -166,10 +165,13 @@ function parseKitOptions(cwd: string) {
// We'll error out since we can't safely determine the config object
if (!objectExpression) throw new Error('Unexpected svelte config shape from `svelte.config.js`');
- const kit = object.property(objectExpression, { name: 'kit', fallback: object.create({}) });
- const files = object.property(kit, { name: 'files', fallback: object.create({}) });
- const routes = object.property(files, { name: 'routes', fallback: common.createLiteral('') });
- const lib = object.property(files, { name: 'lib', fallback: common.createLiteral('') });
+ const kit = js.object.property(objectExpression, { name: 'kit', fallback: js.object.create({}) });
+ const files = js.object.property(kit, { name: 'files', fallback: js.object.create({}) });
+ const routes = js.object.property(files, {
+ name: 'routes',
+ fallback: js.common.createLiteral('')
+ });
+ const lib = js.object.property(files, { name: 'lib', fallback: js.common.createLiteral('') });
const routesDirectory = (routes.value as string) || 'src/routes';
const libDirectory = (lib.value as string) || 'src/lib';
diff --git a/packages/sv/lib/cli/check.ts b/packages/sv/lib/cli/check.ts
index 4a388048..8fd2750b 100644
--- a/packages/sv/lib/cli/check.ts
+++ b/packages/sv/lib/cli/check.ts
@@ -1,11 +1,12 @@
-import process from 'node:process';
-import { execSync } from 'node:child_process';
-import pc from 'picocolors';
import { Command } from 'commander';
import * as resolve from 'empathic/resolve';
+import { execSync } from 'node:child_process';
+import process from 'node:process';
import { resolveCommand } from 'package-manager-detector/commands';
-import { getUserAgent } from './utils/package-manager.ts';
+import pc from 'picocolors';
+
import { forwardExitCode } from './utils/common.ts';
+import { getUserAgent } from './utils/package-manager.ts';
export const check = new Command('check')
.description('a CLI for checking your Svelte code')
diff --git a/packages/sv/lib/cli/create.ts b/packages/sv/lib/cli/create.ts
index e02d2eeb..8e7a366b 100644
--- a/packages/sv/lib/cli/create.ts
+++ b/packages/sv/lib/cli/create.ts
@@ -1,13 +1,18 @@
+import * as p from '@clack/prompts';
+import { Command, Option } from 'commander';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
-import * as p from '@clack/prompts';
-import type { OptionValues, Workspace } from '../core/index.ts';
+import { detect, resolveCommand } from 'package-manager-detector';
+import pc from 'picocolors';
+import * as v from 'valibot';
+
+import type { OptionValues, ResolvedAddon, Workspace } from '../core.ts';
import {
- create as createKit,
- templates,
type LanguageType,
- type TemplateType
+ type TemplateType,
+ create as createKit,
+ templates
} from '../create/index.ts';
import {
detectPlaygroundDependencies,
@@ -16,30 +21,25 @@ import {
setupPlaygroundProject,
validatePlaygroundUrl
} from '../create/playground.ts';
-import { Command, Option } from 'commander';
-import { detect, resolveCommand } from 'package-manager-detector';
-import pc from 'picocolors';
-import * as v from 'valibot';
-
+import { dist } from '../create/utils.ts';
+import {
+ addonArgsHandler,
+ promptAddonQuestions,
+ resolveAddons,
+ runAddonsApply,
+ sanitizeAddons
+} from './add/index.ts';
+import { commonFilePaths, formatFiles, getPackageJson } from './add/utils.ts';
+import { createWorkspace } from './add/workspace.ts';
import * as common from './utils/common.ts';
import {
- addPnpmBuildDependencies,
AGENT_NAMES,
+ addPnpmBuildDependencies,
getUserAgent,
installDependencies,
installOption,
packageManagerPrompt
} from './utils/package-manager.ts';
-import {
- addonArgsHandler,
- promptAddonQuestions,
- runAddonsApply,
- sanitizeAddons,
- type SelectedAddon
-} from './add/index.ts';
-import { commonFilePaths, formatFiles, getPackageJson } from './add/utils.ts';
-import { createWorkspace } from './add/workspace.ts';
-import { dist } from '../create/utils.ts';
const langs = ['ts', 'jsdoc'] as const;
const langMap: Record = {
@@ -54,6 +54,11 @@ const templateOption = new Option('--template ', 'template to scaffold').c
);
const noAddonsOption = new Option('--no-add-ons', 'do not prompt to add add-ons').conflicts('add');
const addOption = new Option('--add ', 'add-on to include').default([]);
+export const noDownloadCheckOption = new Option(
+ '--no-download-check',
+ 'skip all download confirmation prompts'
+);
+export const noInstallOption = new Option('--no-install', 'skip installing dependencies');
const ProjectPathSchema = v.optional(v.string());
const OptionsSchema = v.strictObject({
@@ -66,7 +71,8 @@ const OptionsSchema = v.strictObject({
install: v.union([v.boolean(), v.picklist(AGENT_NAMES)]),
template: v.optional(v.picklist(templateChoices)),
fromPlayground: v.optional(v.string()),
- dirCheck: v.boolean()
+ dirCheck: v.boolean(),
+ downloadCheck: v.boolean()
});
type Options = v.InferOutput;
type ProjectPath = v.InferOutput;
@@ -79,9 +85,10 @@ export const create = new Command('create')
.option('--no-types')
.addOption(noAddonsOption)
.addOption(addOption)
- .option('--no-install', 'skip installing dependencies')
+ .addOption(noInstallOption)
.option('--from-playground ', 'create a project from the svelte playground')
.option('--no-dir-check', 'even if the folder is not empty, no prompt will be shown')
+ .addOption(noDownloadCheckOption)
.addOption(installOption)
.configureHelp(common.helpConfig)
.action((projectPath, opts) => {
@@ -140,7 +147,7 @@ export const create = new Command('create')
async function createProject(cwd: ProjectPath, options: Options) {
if (options.fromPlayground) {
p.log.warn(
- 'The Svelte maintainers have not reviewed playgrounds for malicious code. Use at your discretion.'
+ 'Svelte maintainers have not reviewed playgrounds for malicious code. Use at your discretion.'
);
}
@@ -180,14 +187,22 @@ async function createProject(cwd: ProjectPath, options: Options) {
// always use the minimal template for playground projects
if (options.fromPlayground) return Promise.resolve('minimal');
+ const availableTemplates =
+ options.add.length > 0 ? templates.filter((t) => t.name !== 'addon') : templates;
+
return p.select({
message: 'Which template would you like?',
initialValue: 'minimal',
- options: templates.map((t) => ({ label: t.title, value: t.name, hint: t.description }))
+ options: availableTemplates.map((t) => ({
+ label: t.title,
+ value: t.name,
+ hint: t.description
+ }))
});
},
- language: () => {
+ language: (o) => {
if (options.types) return Promise.resolve(options.types);
+ if (o.results.template === 'addon') return Promise.resolve('none');
return p.select({
message: 'Add type checking with TypeScript?',
initialValue: 'typescript',
@@ -208,50 +223,59 @@ async function createProject(cwd: ProjectPath, options: Options) {
);
const projectPath = path.resolve(directory);
- const projectName = path.basename(projectPath);
+ const basename = path.basename(projectPath);
+ const parentDirName = path.basename(path.dirname(projectPath));
+ const projectName = parentDirName.startsWith('@') ? `${parentDirName}/${basename}` : basename;
- let selectedAddons: SelectedAddon[] = [];
- let answersOfficial: Record> = {};
- let answersCommunity: Record> = {};
+ let selectedAddons: ResolvedAddon[] = [];
+ let answers: Record> = {};
let sanitizedAddonsMap: Record = {};
const workspace = await createVirtualWorkspace({
cwd: projectPath,
template,
- type: language
+ type: language as LanguageType
});
- if (options.addOns || options.add.length > 0) {
+ if (template !== 'addon' && (options.addOns || options.add.length > 0)) {
const addons = options.add.reduce(addonArgsHandler, []);
- sanitizedAddonsMap = sanitizeAddons(addons).reduce>(
- (acc, curr) => {
- acc[curr.id] = curr.options;
- return acc;
- },
- {}
+ const sanitizedAddons = sanitizeAddons(addons);
+
+ // Resolve all addons (official and community) into a unified structure
+ const { resolvedAddons, specifierToId } = await resolveAddons(
+ sanitizedAddons,
+ projectPath,
+ options.downloadCheck
);
+ // Map options from original specifiers to resolved IDs
+ sanitizedAddonsMap = {};
+ for (const addonArg of sanitizedAddons) {
+ const resolvedId = specifierToId.get(addonArg.id) ?? addonArg.id;
+ sanitizedAddonsMap[resolvedId] = addonArg.options;
+ }
+
const result = await promptAddonQuestions({
options: {
cwd: projectPath,
install: false,
gitCheck: false,
- community: [],
+ downloadCheck: options.downloadCheck,
addons: sanitizedAddonsMap
},
selectedAddonIds: Object.keys(sanitizedAddonsMap),
+ allAddons: resolvedAddons,
workspace
});
selectedAddons = result.selectedAddons;
- answersOfficial = result.answersOfficial;
- answersCommunity = result.answersCommunity;
+ answers = result.answers;
}
createKit(projectPath, {
name: projectName,
template,
- types: language
+ types: language as LanguageType
});
if (options.fromPlayground) {
@@ -263,20 +287,19 @@ async function createProject(cwd: ProjectPath, options: Options) {
let addOnNextSteps: string[] = [];
let argsFormattedAddons: string[] = [];
let addOnFilesToFormat: string[] = [];
- if (options.addOns || options.add.length > 0) {
+ if (template !== 'addon' && (options.addOns || options.add.length > 0)) {
const {
nextSteps,
argsFormattedAddons: argsFormatted,
filesToFormat
} = await runAddonsApply({
- answersOfficial,
- answersCommunity,
+ answers,
options: {
cwd: projectPath,
// in the create command, we don't want to install dependencies, we want to do it after the project is created
install: false,
gitCheck: false,
- community: [],
+ downloadCheck: options.downloadCheck,
addons: sanitizedAddonsMap
},
selectedAddons,
diff --git a/packages/sv/lib/cli/migrate.ts b/packages/sv/lib/cli/migrate.ts
index b62c01af..20609b66 100644
--- a/packages/sv/lib/cli/migrate.ts
+++ b/packages/sv/lib/cli/migrate.ts
@@ -1,9 +1,10 @@
+import { Command } from 'commander';
import { execSync } from 'node:child_process';
import process from 'node:process';
-import { Command } from 'commander';
import { resolveCommand } from 'package-manager-detector';
-import { getUserAgent } from './utils/package-manager.ts';
+
import { forwardExitCode } from './utils/common.ts';
+import { getUserAgent } from './utils/package-manager.ts';
export const migrate = new Command('migrate')
.description('a CLI for migrating Svelte(Kit) codebases')
diff --git a/packages/sv/lib/cli/tests/addon.ts b/packages/sv/lib/cli/tests/addon.ts
new file mode 100644
index 00000000..fbc380cf
--- /dev/null
+++ b/packages/sv/lib/cli/tests/addon.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+
+import { sanitizeAddons } from '../add/index.ts';
+
+describe('sanitizeAddons', () => {
+ it('should find official addon', () => {
+ expect(sanitizeAddons([{ id: 'eslint' }])).toEqual([
+ { id: 'eslint', options: [], kind: 'official', resolvedId: 'eslint' }
+ ]);
+ });
+ it('should find official addon with alias', () => {
+ expect(sanitizeAddons([{ id: 'tailwind' }])).toEqual([
+ { id: 'tailwindcss', options: [], kind: 'official', resolvedId: 'tailwindcss' }
+ ]);
+ });
+ it('should have 2', () => {
+ expect(sanitizeAddons([{ id: 'eslint' }, { id: 'tailwindcss' }])).toEqual([
+ { id: 'eslint', options: [], kind: 'official', resolvedId: 'eslint' },
+ { id: 'tailwindcss', options: [], kind: 'official', resolvedId: 'tailwindcss' }
+ ]);
+ });
+ it('should dedupe even with alias', () => {
+ expect(sanitizeAddons([{ id: 'tailwind' }, { id: 'tailwindcss' }])).toEqual([
+ { id: 'tailwindcss', options: [], kind: 'official', resolvedId: 'tailwindcss' }
+ ]);
+ });
+ it('should find file addons', () => {
+ expect(sanitizeAddons([{ id: 'file:../' }])).toEqual([
+ { id: 'file:../', options: [], kind: 'file', resolvedId: '../' }
+ ]);
+ });
+});
diff --git a/packages/sv/lib/cli/tests/cli.ts b/packages/sv/lib/cli/tests/cli.ts
index f7088f7b..346d5cf0 100644
--- a/packages/sv/lib/cli/tests/cli.ts
+++ b/packages/sv/lib/cli/tests/cli.ts
@@ -1,14 +1,15 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { exec } from 'tinyexec';
import { beforeAll, describe, expect, it } from 'vitest';
-import { exec } from 'tinyexec';
-import path from 'node:path';
-import fs from 'node:fs';
-import { parseJson } from '../../core/tooling/index.ts';
+import { parseJson } from '../../core.ts';
const monoRepoPath = path.resolve(__dirname, '..', '..', '..', '..', '..');
+const svBinPath = path.resolve(monoRepoPath, 'packages', 'sv', 'dist', 'bin.mjs');
beforeAll(() => {
- const testOutputCliPath = path.resolve(monoRepoPath, '.test-output', 'cli');
+ const testOutputCliPath = path.resolve(monoRepoPath, 'packages', 'sv', '.test-output', 'cli');
if (fs.existsSync(testOutputCliPath)) {
fs.rmSync(testOutputCliPath, { force: true, recursive: true });
@@ -36,42 +37,49 @@ describe('cli', () => {
'mcp=ide:claude-code,cursor,gemini,opencode,vscode,other+setup:local'
// 'storybook' // No storybook addon during tests!
]
+ },
+ {
+ projectName: '@my-org/sv',
+ template: 'addon',
+ args: []
}
];
it.for(testCases)(
'should create a new project with name $projectName',
- { timeout: 10_000 },
+ { timeout: 51_000 },
async (testCase) => {
- const { projectName, args } = testCase;
- const svBinPath = path.resolve(monoRepoPath, 'packages', 'sv', 'dist', 'bin.mjs');
- const testOutputPath = path.resolve(monoRepoPath, '.test-output', 'cli', projectName);
-
- const result = await exec(
- 'node',
- [
- svBinPath,
- 'create',
- testOutputPath,
- '--template',
- 'minimal',
- '--types',
- 'ts',
- '--no-install',
- ...args
- ],
- { nodeOptions: { stdio: 'pipe' } }
+ const { projectName, args, template = 'minimal' } = testCase;
+
+ const testOutputPath = path.resolve(
+ monoRepoPath,
+ 'packages',
+ 'sv',
+ '.test-output',
+ 'cli',
+ projectName
);
- // cli finished well
- expect(result.exitCode).toBe(0);
+ const allArgs = [
+ svBinPath,
+ 'create',
+ testOutputPath,
+ '--template',
+ template,
+ ...(template === 'addon' ? [] : ['--types', 'ts']),
+ '--no-install',
+ ...args
+ ];
+ const result = await exec('node', allArgs, { nodeOptions: { stdio: 'pipe' } });
+ // cli finished well
+ expect(result.exitCode, `Error with cli: '${result.stderr}'`).toBe(0);
// test output path exists
expect(fs.existsSync(testOutputPath)).toBe(true);
// package.json has a name
const packageJsonPath = path.resolve(testOutputPath, 'package.json');
- const packageJson = parseJson(fs.readFileSync(packageJsonPath, 'utf-8'));
+ const { data: packageJson } = parseJson(fs.readFileSync(packageJsonPath, 'utf-8'));
expect(packageJson.name).toBe(projectName);
const snapPath = path.resolve(
@@ -91,7 +99,7 @@ describe('cli', () => {
let generated = fs.readFileSync(path.resolve(testOutputPath, relativeFile), 'utf-8');
if (relativeFile === 'package.json') {
- const generatedPackageJson = parseJson(generated);
+ const { data: generatedPackageJson } = parseJson(generated);
// remove @types/node from generated package.json as we test on different node versions
delete generatedPackageJson.devDependencies['@types/node'];
generated = JSON.stringify(generatedPackageJson, null, 3).replaceAll(' ', '\t');
@@ -105,6 +113,34 @@ describe('cli', () => {
`file "${relativeFile}" does not match snapshot`
);
}
+
+ if (template === 'addon') {
+ // replace sv version in package.json for tests
+ const packageJsonPath = path.resolve(testOutputPath, 'package.json');
+ const { data: packageJson } = parseJson(fs.readFileSync(packageJsonPath, 'utf-8'));
+ packageJson.dependencies['sv'] = 'file:../../../..';
+ fs.writeFileSync(
+ packageJsonPath,
+ JSON.stringify(packageJson, null, 3).replaceAll(' ', '\t')
+ );
+
+ const cmds = [
+ // list of cmds to test
+ ['i'],
+ ['run', 'demo-create'],
+ ['run', 'demo-add:ci'],
+ ['run', 'test']
+ ];
+ for (const cmd of cmds) {
+ const res = await exec('npm', cmd, {
+ nodeOptions: { stdio: 'pipe', cwd: testOutputPath }
+ });
+ expect(
+ res.exitCode,
+ `Error addon test: '${cmd}' -> ${JSON.stringify(res, null, 2)}`
+ ).toBe(0);
+ }
+ }
}
);
});
diff --git a/packages/sv/lib/cli/tests/common.ts b/packages/sv/lib/cli/tests/common.ts
index 16b394bd..7dd57c29 100644
--- a/packages/sv/lib/cli/tests/common.ts
+++ b/packages/sv/lib/cli/tests/common.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+
import { parseAddonOptions } from '../utils/common.ts';
describe('parseAddonOptions', () => {
diff --git a/packages/sv/lib/cli/tests/snapshots/@my-org/sv/.gitignore b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/.gitignore
new file mode 100644
index 00000000..b46f0dc1
--- /dev/null
+++ b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/.gitignore
@@ -0,0 +1,27 @@
+node_modules
+demo/
+.test-output/
+
+# Output
+.output
+.vercel
+.netlify
+.wrangler
+/.svelte-kit
+/build
+/dist
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
+
diff --git a/packages/sv/lib/cli/tests/snapshots/@my-org/sv/CONTRIBUTING.md b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/CONTRIBUTING.md
new file mode 100644
index 00000000..16dd865e
--- /dev/null
+++ b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/CONTRIBUTING.md
@@ -0,0 +1,34 @@
+# Contributing Guide
+
+Some convenient scripts are provided to help develop the add-on.
+
+```sh
+## create a new minimal project in the `demo` directory
+npm run demo-create
+
+## add your current add-on to the demo project
+npm run demo-add
+
+## run the tests
+npm run test
+```
+
+## Key things to note
+
+Your `add-on` should:
+
+- export a function that returns a `defineAddon` object.
+- have a `package.json` with an `exports` field that points to the main entry point of the add-on.
+
+## Sharing your add-on
+
+When you're ready to publish your add-on to npm, run:
+
+```shell
+npm login
+npm publish
+```
+
+## Things to be aware of
+
+Community add-ons are **not permitted** to have any external dependencies outside of `sv`. If the use of a dependency is absolutely necessary, then they can be bundled using a bundler of your choosing (e.g. Rollup, Rolldown, tsup, etc.).
diff --git a/packages/sv/lib/cli/tests/snapshots/@my-org/sv/README.md b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/README.md
new file mode 100644
index 00000000..8fbe1893
--- /dev/null
+++ b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/README.md
@@ -0,0 +1,29 @@
+# [sv](https://svelte.dev/docs/cli/overview) community add-on: [@my-org/sv](https://github.com/@my-org/sv)
+
+> [!IMPORTANT]
+> Svelte maintainers have not reviewed community add-ons for malicious code. Use at your discretion
+
+## Usage
+
+To install the add-on, run:
+
+```shell
+npx sv add @my-org
+```
+
+## What you get [TO BE FILLED...]
+
+- A super cool stuff
+- Another one!
+
+## Options [TO BE FILLED...]
+
+### `who`
+
+The name of the person to say hello to.
+
+Default: `you`
+
+```shell
+npx sv add @my-org="who:your-name"
+```
diff --git a/community-addon-template/jsconfig.json b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/jsconfig.json
similarity index 100%
rename from community-addon-template/jsconfig.json
rename to packages/sv/lib/cli/tests/snapshots/@my-org/sv/jsconfig.json
diff --git a/packages/sv/lib/cli/tests/snapshots/@my-org/sv/package.json b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/package.json
new file mode 100644
index 00000000..b99be658
--- /dev/null
+++ b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@my-org/sv",
+ "description": "sv add-on for @my-org/sv",
+ "version": "0.0.1",
+ "type": "module",
+ "license": "MIT",
+ "scripts": {
+ "demo-create": "sv create demo --types ts --template minimal --no-add-ons --no-install",
+ "demo-add": "sv add file:../ --cwd demo --no-git-check --no-install",
+ "demo-add:ci": "sv add file:../=who:you --cwd demo --no-git-check --no-download-check --no-install",
+ "test": "vitest run"
+ },
+ "files": [
+ "src",
+ "!src/**/*.test.*"
+ ],
+ "exports": {
+ ".": {
+ "default": "./src/index.js"
+ }
+ },
+ "dependencies": {
+ "sv": "latest"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.56.1",
+ "vitest": "4.0.7"
+ },
+ "keywords": [
+ "sv-add"
+ ],
+ "publishConfig": {
+ "directory": "dist",
+ "access": "public"
+ }
+}
diff --git a/packages/sv/lib/cli/tests/snapshots/@my-org/sv/src/index.js b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/src/index.js
new file mode 100644
index 00000000..d76e6229
--- /dev/null
+++ b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/src/index.js
@@ -0,0 +1,50 @@
+import { defineAddon, defineAddonOptions, js, parseSvelte, svelte } from 'sv/core';
+
+const options = defineAddonOptions()
+ .add('who', {
+ question: 'To whom should the addon say hello?',
+ type: 'string',
+ default: ''
+ })
+ .build();
+
+export default defineAddon({
+ id: '@my-org/sv',
+ options,
+ setup: ({ kit, unsupported }) => {
+ if (!kit) unsupported('Requires SvelteKit');
+ },
+ run: ({ kit, sv, options, typescript }) => {
+ if (!kit) throw new Error('SvelteKit is required');
+
+ sv.file(`src/lib/@my-org/sv/content.txt`, () => {
+ return `This is a text file made by the Community Addon Template demo for the add-on: '@my-org/sv'!`;
+ });
+
+ sv.file(`src/lib/@my-org/sv/HelloComponent.svelte`, (content) => {
+ const { ast, generateCode } = parseSvelte(content);
+ const scriptAst = svelte.ensureScript(ast, { langTs: typescript });
+
+ js.imports.addDefault(scriptAst, { as: 'content', from: './content.txt?raw' });
+
+ ast.fragment.nodes.push(...svelte.toFragment('{content}
'));
+ ast.fragment.nodes.push(...svelte.toFragment(`Hello ${options.who}!
`));
+
+ return generateCode();
+ });
+
+ sv.file('src/routes/+page.svelte', (content) => {
+ const { ast, generateCode } = parseSvelte(content);
+ const scriptAst = svelte.ensureScript(ast, { langTs: typescript });
+
+ js.imports.addDefault(scriptAst, {
+ as: 'HelloComponent',
+ from: `$lib/@my-org/sv/HelloComponent.svelte`
+ });
+
+ ast.fragment.nodes.push(...svelte.toFragment(''));
+
+ return generateCode();
+ });
+ }
+});
diff --git a/packages/sv/lib/cli/tests/snapshots/@my-org/sv/tests/addon.test.js b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/tests/addon.test.js
new file mode 100644
index 00000000..f1511ce0
--- /dev/null
+++ b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/tests/addon.test.js
@@ -0,0 +1,52 @@
+import { expect } from '@playwright/test';
+import fs from 'node:fs';
+import path from 'node:path';
+
+import addon from '../src/index.js';
+import { setupTest } from './setup/suite.js';
+
+const id = addon.id;
+
+// set to true to enable browser testing
+const browser = false;
+
+const { test, prepareServer, testCases } = setupTest(
+ { addon },
+ {
+ kinds: [{ type: 'default', options: { [id]: addon } }],
+ filter: (testCase) => testCase.variant.includes('kit'),
+ browser
+ }
+);
+
+test.concurrent.for(testCases)(
+ '@my-org/sv $kind.type $variant',
+ async (testCase, { page, ...ctx }) => {
+ const cwd = ctx.cwd(testCase);
+
+ const msg =
+ "This is a text file made by the Community Addon Template demo for the add-on: '@my-org/sv'!";
+
+ const contentPath = path.resolve(cwd, `src/lib/@my-org/sv/content.txt`);
+ const contentContent = fs.readFileSync(contentPath, 'utf8');
+
+ // Check if we have the imports
+ expect(contentContent).toContain(msg);
+
+ // For browser testing
+ if (browser) {
+ const { close } = await prepareServer({ cwd, page });
+ // kill server process when we're done
+ ctx.onTestFinished(async () => await close());
+
+ // expectations
+ const textContent = await page.locator('p').last().textContent();
+ if (testCase.variant.includes('kit')) {
+ expect(textContent).toContain(msg);
+ } else {
+ // it's not a kit plugin!
+ expect(textContent).not.toContain(msg);
+ }
+ }
+ }
+);
diff --git a/packages/sv/lib/cli/tests/snapshots/@my-org/sv/tests/setup/global.js b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/tests/setup/global.js
new file mode 100644
index 00000000..1f9a11ce
--- /dev/null
+++ b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/tests/setup/global.js
@@ -0,0 +1,14 @@
+import { fileURLToPath } from 'node:url';
+import { setupGlobal } from 'sv/testing';
+
+const TEST_DIR = fileURLToPath(new URL('../../.test-output/', import.meta.url));
+
+export default setupGlobal({
+ TEST_DIR,
+ pre: async () => {
+ // global setup (e.g. spin up docker containers)
+ },
+ post: async () => {
+ // tear down... (e.g. cleanup docker containers)
+ }
+});
diff --git a/packages/sv/lib/cli/tests/snapshots/@my-org/sv/tests/setup/suite.js b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/tests/setup/suite.js
new file mode 100644
index 00000000..128ff6f9
--- /dev/null
+++ b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/tests/setup/suite.js
@@ -0,0 +1,130 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { execSync } from 'node:child_process';
+import { inject, test as vitestTest, beforeAll, beforeEach } from 'vitest';
+import { chromium } from '@playwright/test';
+
+import { add } from 'sv';
+import { createProject, addPnpmBuildDependencies, prepareServer } from 'sv/testing';
+
+const cwd = inject('testDir');
+const templatesDir = inject('templatesDir');
+const variants = inject('variants');
+
+/**
+ * @template {import('sv').AddonMap} AddonMap
+ * @param {AddonMap} addons
+ * @param {import('sv/testing').SetupTestOptions} [options]
+ * @returns {{ test: ReturnType>, testCases: Array>, prepareServer: typeof prepareServer }}
+ */
+export function setupTest(addons, options) {
+ /** @type {ReturnType>} */
+ // @ts-ignore - vitest.extend expects fixtures object but we provide it in beforeEach
+ const test = vitestTest.extend({});
+
+ const withBrowser = options?.browser ?? true;
+
+ /** @type {ReturnType} */
+ let create;
+ /** @type {Awaited>} */
+ let browser;
+
+ if (withBrowser) {
+ beforeAll(async () => {
+ browser = await chromium.launch();
+ return async () => {
+ await browser.close();
+ };
+ });
+ }
+
+ /** @type {Array>} */
+ const testCases = [];
+ for (const kind of options?.kinds ?? []) {
+ for (const variant of variants) {
+ const addonTestCase = { variant, kind };
+ if (options?.filter === undefined || options.filter(addonTestCase)) {
+ testCases.push(addonTestCase);
+ }
+ }
+ }
+ /** @type {string} */
+ let testName;
+ beforeAll(async ({ name }) => {
+ testName = path.dirname(name).split('/').at(-1) ?? '';
+
+ // constructs a builder to create test projects
+ create = createProject({ cwd, templatesDir, testName });
+
+ // creates a pnpm workspace in each addon dir
+ fs.writeFileSync(
+ path.resolve(cwd, testName, 'pnpm-workspace.yaml'),
+ "packages:\n - '**/*'",
+ 'utf8'
+ );
+
+ // creates a barebones package.json in each addon dir
+ fs.writeFileSync(
+ path.resolve(cwd, testName, 'package.json'),
+ JSON.stringify({
+ name: `${testName}-workspace-root`,
+ private: true
+ })
+ );
+
+ for (const addonTestCase of testCases) {
+ const { variant, kind } = addonTestCase;
+ const cwd = create({ testId: `${kind.type}-${variant}`, variant });
+
+ // test metadata
+ const metaPath = path.resolve(cwd, 'meta.json');
+ fs.writeFileSync(metaPath, JSON.stringify({ variant, kind }, null, '\t'), 'utf8');
+
+ if (options?.preAdd) {
+ await options.preAdd({ addonTestCase, cwd });
+ }
+ const { pnpmBuildDependencies } = await add({
+ cwd,
+ addons,
+ options: kind.options,
+ packageManager: 'pnpm'
+ });
+ await addPnpmBuildDependencies(cwd, 'pnpm', ['esbuild', ...pnpmBuildDependencies]);
+ }
+
+ execSync('pnpm install', { cwd: path.resolve(cwd, testName), stdio: 'pipe' });
+ });
+
+ // runs before each test case
+ /**
+ * @param {import('sv/testing').Fixtures & import('vitest').TestContext} ctx
+ */
+ beforeEach(async (ctx) => {
+ /** @type {Awaited>} */
+ let browserCtx;
+ if (withBrowser) {
+ browserCtx = await browser.newContext();
+ /** @type {import('sv/testing').Fixtures} */ (/** @type {unknown} */ (ctx)).page =
+ await browserCtx.newPage();
+ }
+
+ /**
+ * @param {import('sv/testing').AddonTestCase} addonTestCase
+ * @returns {string}
+ */
+ /** @type {import('sv/testing').Fixtures} */ (/** @type {unknown} */ (ctx)).cwd = (
+ addonTestCase
+ ) => {
+ return path.join(cwd, testName, `${addonTestCase.kind.type}-${addonTestCase.variant}`);
+ };
+
+ return async () => {
+ if (withBrowser) {
+ await browserCtx.close();
+ }
+ // ...other tear downs
+ };
+ });
+
+ return { test, testCases, prepareServer };
+}
diff --git a/community-addon-template/vitest.config.js b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/vitest.config.js
similarity index 79%
rename from community-addon-template/vitest.config.js
rename to packages/sv/lib/cli/tests/snapshots/@my-org/sv/vitest.config.js
index 5fd114c7..febf9248 100644
--- a/community-addon-template/vitest.config.js
+++ b/packages/sv/lib/cli/tests/snapshots/@my-org/sv/vitest.config.js
@@ -4,12 +4,11 @@ const ONE_MINUTE = 1000 * 60;
export default defineConfig({
test: {
- name: 'community-addon-template',
include: ['tests/**/*.test.{js,ts}'],
exclude: ['tests/setup/*'],
testTimeout: ONE_MINUTE * 3,
hookTimeout: ONE_MINUTE * 3,
- globalSetup: ['tests/setup/global.ts'],
+ globalSetup: ['tests/setup/global.js'],
expect: {
requireAssertions: true
}
diff --git a/packages/sv/lib/cli/utils/common.ts b/packages/sv/lib/cli/utils/common.ts
index d377525f..89bb5ac3 100644
--- a/packages/sv/lib/cli/utils/common.ts
+++ b/packages/sv/lib/cli/utils/common.ts
@@ -1,11 +1,12 @@
-import pc from 'picocolors';
-import pkg from '../../../package.json' with { type: 'json' };
import * as p from '@clack/prompts';
import type { Argument, HelpConfiguration, Option } from 'commander';
-import { UnsupportedError } from './errors.ts';
import process from 'node:process';
-import { isVersionUnsupportedBelow } from '../../core/index.ts';
-import { resolveCommand, type AgentName } from 'package-manager-detector';
+import { type AgentName, resolveCommand } from 'package-manager-detector';
+import pc from 'picocolors';
+
+import pkg from '../../../package.json' with { type: 'json' };
+import { isVersionUnsupportedBelow } from '../../core.ts';
+import { UnsupportedError } from './errors.ts';
const NO_PREFIX = '--no-';
let options: readonly Option[] = [];
@@ -155,6 +156,7 @@ export function logArgs(
export function errorAndExit(message: string) {
p.log.error(message);
+ p.log.message();
p.cancel('Operation failed.');
process.exit(1);
}
diff --git a/packages/sv/lib/cli/utils/package-manager.ts b/packages/sv/lib/cli/utils/package-manager.ts
index e9d3652c..293f3db7 100644
--- a/packages/sv/lib/cli/utils/package-manager.ts
+++ b/packages/sv/lib/cli/utils/package-manager.ts
@@ -1,20 +1,20 @@
+import * as p from '@clack/prompts';
+import { Option } from 'commander';
+import * as find from 'empathic/find';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
-import * as find from 'empathic/find';
-import { exec } from 'tinyexec';
-import { Option } from 'commander';
-import * as p from '@clack/prompts';
import {
AGENTS,
+ type AgentName,
COMMANDS,
constructCommand,
- detect,
- type AgentName
+ detect
} from 'package-manager-detector';
-import { parseJson, parseYaml } from '../../core/tooling/parsers.ts';
-import { isVersionUnsupportedBelow } from '../../core/index.ts';
-import { getHighlighter } from '../../cli/add/utils.ts';
+import { exec } from 'tinyexec';
+
+import { color } from '../../cli/add/utils.ts';
+import { isVersionUnsupportedBelow, parseJson, parseYaml } from '../../core.ts';
export const AGENT_NAMES: AgentName[] = AGENTS.filter(
(agent): agent is AgentName => !agent.includes('@')
@@ -50,9 +50,8 @@ export async function packageManagerPrompt(cwd: string): Promise {
- const highlighter = getHighlighter();
const task = p.taskLog({
- title: `Installing dependencies with ${highlighter.command(agent)}...`,
+ title: `Installing dependencies with ${color.command(agent)}...`,
limit: Math.ceil(process.stdout.rows / 2),
spacing: 0,
retainLog: true
@@ -71,7 +70,7 @@ export async function installDependencies(agent: AgentName, cwd: string): Promis
task.message(line, { raw: true });
}
- task.success(`Successfully installed dependencies with ${highlighter.command(agent)}`);
+ task.success(`Successfully installed dependencies with ${color.command(agent)}`);
} catch {
task.error('Failed to install dependencies');
p.cancel('Operation failed.');
diff --git a/packages/sv/lib/core.ts b/packages/sv/lib/core.ts
new file mode 100644
index 00000000..ff878673
--- /dev/null
+++ b/packages/sv/lib/core.ts
@@ -0,0 +1,31 @@
+export { log } from '@clack/prompts';
+export { default as dedent } from 'dedent';
+
+export { defineAddon, defineAddonOptions } from './core/addon/config.ts';
+export { isVersionUnsupportedBelow, splitVersion } from './core/common.ts';
+
+export * as utils from './core/utils.ts';
+export type * from './core/addon/processors.ts';
+export type * from './core/addon/options.ts';
+export type * from './core/addon/config.ts';
+export type * from './core/addon/workspace.ts';
+
+export * as css from './core/tooling/css/index.ts';
+export * as js from './core/tooling/js/index.ts';
+export * as svelte from './core/tooling/svelte/index.ts';
+export * as html from './core/tooling/html/index.ts';
+export {
+ parseSvelte,
+ parseScript,
+ parseCss,
+ parseHtml,
+ parseJson,
+ parseYaml
+} from './core/tooling/parsers.ts';
+export { Walker } from './core/tooling/index.ts';
+export type { Comments, AstTypes } from './core/tooling/index.ts';
+export type { SvelteAst } from './core/tooling/svelte/index.ts';
+export { resolveCommand } from 'package-manager-detector/commands';
+export { getNodeTypesVersion, addToDemoPage, addEslintConfigPrettier } from './addons/common.ts';
+export { getSharedFiles } from './create/utils.ts';
+export { color } from './cli/add/utils.ts';
diff --git a/packages/sv/lib/core/addon/config.ts b/packages/sv/lib/core/addon/config.ts
index b0633157..df385c1c 100644
--- a/packages/sv/lib/core/addon/config.ts
+++ b/packages/sv/lib/core/addon/config.ts
@@ -1,5 +1,6 @@
import type { OptionDefinition, OptionValues, Question } from './options.ts';
import type { Workspace, WorkspaceOptions } from './workspace.ts';
+import type { officialAddons } from '../../addons/_config/official.ts';
export type ConditionDefinition = (Workspace: Workspace) => boolean;
@@ -18,10 +19,15 @@ export type Scripts = {
};
export type SvApi = {
+ /** Add a package to the pnpm build dependencies. */
pnpmBuildDependency: (pkg: string) => void;
+ /** Add a package to the dependencies. */
dependency: (pkg: string, version: string) => void;
+ /** Add a package to the dev dependencies. */
devDependency: (pkg: string, version: string) => void;
+ /** Execute a command in the workspace. */
execute: (args: string[], stdio: 'inherit' | 'pipe') => Promise;
+ /** Edit a file in the workspace. (will create it if it doesn't exist) */
file: (path: string, edit: (content: string) => string) => void;
};
@@ -31,35 +37,39 @@ export type Addon = {
shortDescription?: string;
homepage?: string;
options: Args;
+ /** Setup the addon. Will be called before the addon is run. */
setup?: (
workspace: Workspace & {
- dependsOn: (name: string) => void;
+ /** On what official addons does this addon depend on? */
+ dependsOn: (name: keyof typeof officialAddons) => void;
+
+ /** Why is this addon not supported?
+ *
+ * @example
+ * if (!kit) unsupported('Requires SvelteKit');
+ */
unsupported: (reason: string) => void;
- runsAfter: (addonName: string) => void;
+
+ /** On what official addons does this addon run after? */
+ runsAfter: (name: keyof typeof officialAddons) => void;
}
) => MaybePromise;
+ /** Run the addon. The actual execution of the addon... Add files, edit files, etc. */
run: (
workspace: Workspace & {
+ /** Add-on sptions */
options: WorkspaceOptions;
+ /** Api to interact with the workspace. */
sv: SvApi;
+ /** Cancel the addon at any time!
+ * @example
+ * return cancel('There is a problem with...');
+ */
cancel: (reason: string) => void;
}
) => MaybePromise;
- nextSteps?: (
- data: {
- highlighter: Highlighter;
- } & Workspace & { options: WorkspaceOptions }
- ) => string[];
-};
-
-export type Highlighter = {
- addon: (str: string) => string;
- path: (str: string) => string;
- command: (str: string) => string;
- website: (str: string) => string;
- route: (str: string) => string;
- env: (str: string) => string; // used for printing environment variable names
- optional: (str: string) => string;
+ /** Next steps to display after the addon is run. */
+ nextSteps?: (data: Workspace & { options: WorkspaceOptions }) => string[];
};
export function defineAddon(config: Addon): Addon {
@@ -68,7 +78,10 @@ export function defineAddon(config: Addon):
export type AddonSetupResult = { dependsOn: string[]; unsupported: string[]; runsAfter: string[] };
-export type AddonWithoutExplicitArgs = Addon>>;
+export type ResolvedAddon = Addon>> & {
+ /** Original specifier used to reference this addon (e.g., "file:../path" or "@scope/name") */
+ originalSpecifier?: string;
+};
export type Tests = {
expectProperty: (selector: string, property: string, expectedValue: string) => Promise;
@@ -104,6 +117,18 @@ export type OptionBuilder = {
};
// Initializing with an empty object is intended given that the starting state _is_ empty.
+/**
+ * Example:
+ * ```ts
+ * const options = defineAddonOptions()
+ * .add('demo', {
+ * question: `Do you want to include a demo? ${style.optional('(includes a login/register page)')}`
+ * type: 'boolean' | 'string' | 'number' | 'select' | 'multiselect' | 'boolean',
+ * default: true,
+ * })
+ * .build();
+ * ```
+ */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export function defineAddonOptions(): OptionBuilder<{}> {
return createOptionBuilder({});
diff --git a/packages/sv/lib/core/addon/workspace.ts b/packages/sv/lib/core/addon/workspace.ts
index 4c1fdc4d..06779a11 100644
--- a/packages/sv/lib/core/addon/workspace.ts
+++ b/packages/sv/lib/core/addon/workspace.ts
@@ -13,7 +13,9 @@ export type Workspace = {
* @returns the dependency version with any leading characters such as ^ or ~ removed
*/
dependencyVersion: (pkg: string) => string | undefined;
+ /** Whether the workspace is using typescript */
typescript: boolean;
+ /** The files that are available in the workspace */
files: {
viteConfig: 'vite.config.js' | 'vite.config.ts';
svelteConfig: 'svelte.config.js' | 'svelte.config.ts';
@@ -31,7 +33,9 @@ export type Workspace = {
/** Get the relative path between two files */
getRelative: ({ from, to }: { from?: string; to: string }) => string;
};
+ /** If we are in a kit project, this object will contain the lib and routes directories */
kit: { libDirectory: string; routesDirectory: string } | undefined;
+ /** The package manager used to install dependencies */
packageManager: PackageManager;
};
diff --git a/packages/sv/lib/core/downloadJson.ts b/packages/sv/lib/core/downloadJson.ts
new file mode 100644
index 00000000..23605738
--- /dev/null
+++ b/packages/sv/lib/core/downloadJson.ts
@@ -0,0 +1,41 @@
+const inMemoryCache = new Map();
+
+export const downloadJson = async (url: string) => {
+ if (inMemoryCache.has(url)) {
+ return inMemoryCache.get(url);
+ }
+
+ let lastError: Error | null = null;
+ const maxRetries = 3;
+
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ try {
+ const response = await fetch(url);
+ if (
+ !response.ok ||
+ response.status === 404 ||
+ (response.status < 200 && response.status >= 300)
+ ) {
+ throw new Error(`${response.status} - ${response.statusText} - Failed to fetch ${url}`);
+ }
+ const data = await response.json();
+
+ inMemoryCache.set(url, data);
+ return data;
+ } catch (error) {
+ lastError = error instanceof Error ? error : new Error(String(error));
+
+ if (attempt < maxRetries) {
+ // Exponential backoff: wait 100ms, 200ms, 400ms
+ const delay = 100 * Math.pow(2, attempt);
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ continue;
+ }
+
+ // All retries exhausted
+ throw lastError;
+ }
+ }
+
+ throw lastError || new Error(`Failed to fetch ${url} after ${maxRetries} retries`);
+};
diff --git a/packages/sv/lib/core/index.ts b/packages/sv/lib/core/index.ts
deleted file mode 100644
index bd89a09a..00000000
--- a/packages/sv/lib/core/index.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-export { defineAddon, defineAddonOptions } from './addon/config.ts';
-export { log } from '@clack/prompts';
-export { default as colors } from 'picocolors';
-export { default as dedent } from 'dedent';
-export * as utils from './utils.ts';
-export { isVersionUnsupportedBelow, splitVersion } from './common.ts';
-
-export type * from './addon/processors.ts';
-export type * from './addon/options.ts';
-export type * from './addon/config.ts';
-export type * from './addon/workspace.ts';
-
-export { Walker } from './tooling/index.ts';
-export * as js from './tooling/js/index.ts';
-export * as svelte from './tooling/svelte/index.ts';
-export { parseSvelte } from './tooling/parsers.ts';
diff --git a/packages/sv/lib/core/tests/downloadJson.ts b/packages/sv/lib/core/tests/downloadJson.ts
new file mode 100644
index 00000000..87494a7e
--- /dev/null
+++ b/packages/sv/lib/core/tests/downloadJson.ts
@@ -0,0 +1,79 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { downloadJson } from '../downloadJson.ts';
+
+describe('downloadJson', () => {
+ let originalFetch: typeof globalThis.fetch;
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ originalFetch = globalThis.fetch;
+ fetchMock = vi.fn();
+ globalThis.fetch = fetchMock as typeof globalThis.fetch;
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ vi.resetModules();
+ });
+
+ it('should cache responses and return cached data on subsequent calls', async () => {
+ const mockData = { cached: true };
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockData)
+ } as unknown as Response);
+
+ const url = 'https://example.com/api/cached';
+ const firstResult = await downloadJson(url);
+ const secondResult = await downloadJson(url);
+
+ expect(firstResult).toEqual(mockData);
+ expect(secondResult).toEqual(mockData);
+ // Fetch should only be called once due to caching
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('should retry on network errors with exponential backoff', async () => {
+ const mockData = { success: true };
+ const networkError = new Error('Network error');
+
+ // First two attempts fail, third succeeds
+ fetchMock
+ .mockRejectedValueOnce(networkError)
+ .mockRejectedValueOnce(networkError)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockData)
+ } as unknown as Response);
+
+ const result = await downloadJson('https://example.com/api/retry');
+
+ expect(result).toEqual(mockData);
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ });
+
+ it('should throw error after all retries are exhausted', async () => {
+ const networkError = new Error('Network error');
+
+ // All attempts fail
+ fetchMock.mockRejectedValue(networkError);
+
+ await expect(downloadJson('https://example.com/api/fail')).rejects.toThrow('Network error');
+ // Should attempt 4 times (initial + 3 retries)
+ expect(fetchMock).toHaveBeenCalledTimes(4);
+ });
+
+ it('should handle JSON parsing errors', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: () => {
+ return Promise.reject(new Error('Invalid JSON'));
+ }
+ } as unknown as Response);
+
+ await expect(downloadJson('https://example.com/api/invalid-json')).rejects.toThrow(
+ 'Invalid JSON'
+ );
+ expect(fetchMock).toHaveBeenCalledTimes(4);
+ });
+});
diff --git a/packages/sv/lib/core/tooling/html/index.ts b/packages/sv/lib/core/tooling/html/index.ts
index f9a0f9cd..a1531ab2 100644
--- a/packages/sv/lib/core/tooling/html/index.ts
+++ b/packages/sv/lib/core/tooling/html/index.ts
@@ -1,4 +1,4 @@
-import { parseHtml, type SvelteAst } from '../index.ts';
+import { type SvelteAst, parseHtml } from '../index.ts';
export type { SvelteAst };
diff --git a/packages/sv/lib/core/tooling/index.ts b/packages/sv/lib/core/tooling/index.ts
index 5ef8d559..d290a24d 100644
--- a/packages/sv/lib/core/tooling/index.ts
+++ b/packages/sv/lib/core/tooling/index.ts
@@ -1,13 +1,14 @@
-import * as Walker from 'zimmerframe';
-import type { TsEstree } from './js/ts-estree.ts';
-import * as fleece from 'silver-fleece';
+import { tsPlugin } from '@sveltejs/acorn-typescript';
+import * as acorn from 'acorn';
import { print as esrapPrint } from 'esrap';
import ts from 'esrap/languages/ts';
-import * as acorn from 'acorn';
-import { tsPlugin } from '@sveltejs/acorn-typescript';
-import { parse as svelteParse, type AST as SvelteAst, print as sveltePrint } from 'svelte/compiler';
-import * as yaml from 'yaml';
import type { BaseNode } from 'estree';
+import * as fleece from 'silver-fleece';
+import { type AST as SvelteAst, parse as svelteParse, print as sveltePrint } from 'svelte/compiler';
+import * as yaml from 'yaml';
+import * as Walker from 'zimmerframe';
+
+import type { TsEstree } from './js/ts-estree.ts';
export {
// ast walker
diff --git a/packages/sv/lib/core/tooling/js/array.ts b/packages/sv/lib/core/tooling/js/array.ts
index c3ac0443..840ce2ba 100644
--- a/packages/sv/lib/core/tooling/js/array.ts
+++ b/packages/sv/lib/core/tooling/js/array.ts
@@ -1,5 +1,5 @@
-import { areNodesEqual } from './common.ts';
import type { AstTypes } from '../index.ts';
+import { areNodesEqual } from './common.ts';
export function create(): AstTypes.ArrayExpression {
const arrayExpression: AstTypes.ArrayExpression = {
diff --git a/packages/sv/lib/core/tooling/js/common.ts b/packages/sv/lib/core/tooling/js/common.ts
index b1e2c7c8..a93ad874 100644
--- a/packages/sv/lib/core/tooling/js/common.ts
+++ b/packages/sv/lib/core/tooling/js/common.ts
@@ -1,3 +1,6 @@
+import decircular from 'decircular';
+import dedent from 'dedent';
+
import {
type AstTypes,
type Comments,
@@ -6,8 +9,6 @@ import {
serializeScript,
stripAst
} from '../index.ts';
-import decircular from 'decircular';
-import dedent from 'dedent';
export function addJsDocTypeComment(
node: AstTypes.Node,
diff --git a/packages/sv/lib/core/tooling/js/imports.ts b/packages/sv/lib/core/tooling/js/imports.ts
index 7dcde782..ae1de111 100644
--- a/packages/sv/lib/core/tooling/js/imports.ts
+++ b/packages/sv/lib/core/tooling/js/imports.ts
@@ -1,4 +1,4 @@
-import { Walker, type AstTypes } from '../index.ts';
+import { type AstTypes, Walker } from '../index.ts';
import { areNodesEqual } from './common.ts';
export function addEmpty(node: AstTypes.Program, options: { from: string }): void {
diff --git a/packages/sv/lib/core/tooling/js/kit.ts b/packages/sv/lib/core/tooling/js/kit.ts
index fed1cf76..527a65e1 100644
--- a/packages/sv/lib/core/tooling/js/kit.ts
+++ b/packages/sv/lib/core/tooling/js/kit.ts
@@ -1,9 +1,9 @@
-import { Walker, type AstTypes } from '../index.ts';
+import { type AstTypes, Walker } from '../index.ts';
import * as common from './common.ts';
+import * as exports from './exports.ts';
import * as functions from './function.ts';
import * as imports from './imports.ts';
import * as variables from './variables.ts';
-import * as exports from './exports.ts';
export function addGlobalAppInterface(
node: AstTypes.TSProgram,
diff --git a/packages/sv/lib/core/tooling/js/vite.ts b/packages/sv/lib/core/tooling/js/vite.ts
index 44f87c45..474ff177 100644
--- a/packages/sv/lib/core/tooling/js/vite.ts
+++ b/packages/sv/lib/core/tooling/js/vite.ts
@@ -1,4 +1,4 @@
-import { array, functions, imports, object, exports, type AstTypes, common } from './index.ts';
+import { type AstTypes, array, common, exports, functions, imports, object } from './index.ts';
function isConfigWrapper(
callExpression: AstTypes.CallExpression,
diff --git a/packages/sv/lib/core/tooling/svelte/index.ts b/packages/sv/lib/core/tooling/svelte/index.ts
index c73df090..f2a2014d 100644
--- a/packages/sv/lib/core/tooling/svelte/index.ts
+++ b/packages/sv/lib/core/tooling/svelte/index.ts
@@ -1,6 +1,6 @@
-import { parseScript, type AstTypes, type SvelteAst } from '../index.ts';
-import { parseSvelte } from '../parsers.ts';
+import { type AstTypes, type SvelteAst, parseScript } from '../index.ts';
import { appendFromString } from '../js/common.ts';
+import { parseSvelte } from '../parsers.ts';
export type { SvelteAst };
diff --git a/packages/sv/lib/create/index.ts b/packages/sv/lib/create/index.ts
index 59b1ba8f..b60f8655 100644
--- a/packages/sv/lib/create/index.ts
+++ b/packages/sv/lib/create/index.ts
@@ -1,11 +1,12 @@
import fs from 'node:fs';
import path from 'node:path';
-import { mkdirp, copy, dist, getSharedFiles } from './utils.ts';
+
+import { copy, dist, getSharedFiles, mkdirp, replace } from './utils.ts';
export type TemplateType = (typeof templateTypes)[number];
export type LanguageType = (typeof languageTypes)[number];
-const templateTypes = ['minimal', 'demo', 'library'] as const;
+const templateTypes = ['minimal', 'demo', 'library', 'addon'] as const;
const languageTypes = ['typescript', 'checkjs', 'none'] as const;
export type Options = {
@@ -35,6 +36,12 @@ export function create(cwd: string, options: Options): void {
write_template_files(options.template, options.types, options.name, cwd);
write_common_files(cwd, options, options.name);
+
+ // Files that are not relevant for addon projects
+ if (options.template === 'addon') {
+ fs.rmSync(path.join(cwd, 'svelte.config.js'));
+ fs.rmSync(path.join(cwd, 'vite.config.js'));
+ }
}
export type TemplateMetadata = { name: TemplateType; title: string; description: string };
@@ -49,10 +56,18 @@ export const templates: TemplateMetadata[] = templateTypes.map((dir) => {
};
});
+const kv = (name: string) => {
+ const protocolName = name.startsWith('@') ? name.split('/')[0] : name;
+ return {
+ '~SV-PROTOCOL-NAME-TODO~': protocolName,
+ '~SV-NAME-TODO~': name
+ };
+};
+
function write_template_files(template: string, types: LanguageType, name: string, cwd: string) {
const dir = dist(`templates/${template}`);
- copy(`${dir}/assets`, cwd, (name: string) => name.replace('DOT-', '.'));
- copy(`${dir}/package.json`, `${cwd}/package.json`);
+ copy(`${dir}/assets`, cwd, (name: string) => name.replace('DOT-', '.'), kv(name));
+ copy(`${dir}/package.json`, `${cwd}/package.json`, undefined, kv(name));
const manifest = `${dir}/files.types=${types}.json`;
const files = JSON.parse(fs.readFileSync(manifest, 'utf-8')) as File[];
@@ -60,8 +75,7 @@ function write_template_files(template: string, types: LanguageType, name: strin
files.forEach((file) => {
const dest = path.join(cwd, file.name);
mkdirp(path.dirname(dest));
-
- fs.writeFileSync(dest, file.contents.replace(/~TODO~/g, name));
+ fs.writeFileSync(dest, replace(file.contents, kv(name)));
});
}
@@ -69,7 +83,7 @@ function write_common_files(cwd: string, options: Options, name: string) {
const files = getSharedFiles();
const pkg_file = path.join(cwd, 'package.json');
- const pkg = /** @type {any} */ JSON.parse(fs.readFileSync(pkg_file, 'utf-8'));
+ const pkg = JSON.parse(fs.readFileSync(pkg_file, 'utf-8'));
sort_files(files).forEach((file) => {
const include = file.include.every((condition) => matches_condition(condition, options));
@@ -83,7 +97,7 @@ function write_common_files(cwd: string, options: Options, name: string) {
} else {
const dest = path.join(cwd, file.name);
mkdirp(path.dirname(dest));
- fs.writeFileSync(dest, file.contents);
+ fs.writeFileSync(dest, replace(file.contents, kv(name)));
}
});
@@ -165,5 +179,5 @@ function to_valid_package_name(name: string) {
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/^[._]/, '')
- .replace(/[^a-z0-9~.-]+/g, '-');
+ .replace(/[^a-z0-9@/~./-]+/g, '-');
}
diff --git a/packages/sv/lib/create/playground.ts b/packages/sv/lib/create/playground.ts
index 6ec21641..3c301af8 100644
--- a/packages/sv/lib/create/playground.ts
+++ b/packages/sv/lib/create/playground.ts
@@ -1,12 +1,19 @@
import fs from 'node:fs';
import path from 'node:path';
-import * as js from '../core/tooling/js/index.ts';
-import * as svelte from '../core/tooling/svelte/index.ts';
-import { parseJson, parseScript, parseSvelte } from '../core/tooling/parsers.ts';
-import { isVersionUnsupportedBelow } from '../core/index.ts';
-import { getSharedFiles } from './utils.ts';
import { walk } from 'zimmerframe';
+import {
+ isVersionUnsupportedBelow,
+ js,
+ parseJson,
+ parseScript,
+ parseSvelte,
+ svelte
+} from '../core.ts';
+import { getSharedFiles } from './utils.ts';
+// eslint-disable-next-line no-restricted-imports
+import { downloadJson } from '../core/downloadJson.ts';
+
export function validatePlaygroundUrl(link: string): boolean {
try {
const url = new URL(link);
@@ -54,8 +61,7 @@ export async function downloadPlaygroundData({
if (hash) {
data = JSON.parse(await decodeAndDecompressText(hash));
} else {
- const response = await fetch(`https://svelte.dev/playground/api/${playgroundId}.json`);
- data = await response.json();
+ data = await downloadJson(`https://svelte.dev/playground/api/${playgroundId}.json`);
}
// saved playgrounds and playground hashes have a different structure
diff --git a/packages/sv/lib/create/scripts/build-templates.js b/packages/sv/lib/create/scripts/build-templates.js
index d348d32d..51e54649 100644
--- a/packages/sv/lib/create/scripts/build-templates.js
+++ b/packages/sv/lib/create/scripts/build-templates.js
@@ -1,11 +1,11 @@
// @ts-check
+import parser from 'gitignore-parser';
import fs from 'node:fs';
import path from 'node:path';
-import parser from 'gitignore-parser';
+import { fileURLToPath } from 'node:url';
import prettier from 'prettier';
import { transform } from 'sucrase';
import glob from 'tiny-glob/sync.js';
-import { fileURLToPath } from 'node:url';
/** @import { File, LanguageType } from '../index.ts' */
@@ -96,8 +96,8 @@ async function generate_templates(dist, shared) {
// package.json in newly created projects (based on package.template.json)
if (name === 'package.template.json') {
let contents = fs.readFileSync(path.join(cwd, name), 'utf8');
- // TODO package-specific versions
- contents = contents.replace(/workspace:\*/g, 'next');
+ contents = contents.replace(/workspace:\*/g, 'latest');
+
fs.writeFileSync(path.join(dir, 'package.json'), contents);
continue;
}
@@ -224,7 +224,7 @@ async function generate_templates(dist, shared) {
/**
* @param {string} string
* @param {RegExp} regexp
- * @param {{ (m: any, attrs: string, typescript: string): Promise; (arg0: any): any; }} replacer
+ * @param {(m: any, attrs: string, typescript: string) => Promise} replacer
*/
async function replace_async(string, regexp, replacer) {
const replacements = await Promise.all(
diff --git a/packages/sv/lib/create/scripts/update-template-repo-contents.js b/packages/sv/lib/create/scripts/update-template-repo-contents.js
index 32e240c4..33c655e6 100644
--- a/packages/sv/lib/create/scripts/update-template-repo-contents.js
+++ b/packages/sv/lib/create/scripts/update-template-repo-contents.js
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { create } from '../../cli/dist/lib/index.js';
+
+import { create } from '../../../dist/lib/index.mjs';
const repo = /** @type {string} */ (process.argv[2]);
diff --git a/packages/sv/lib/create/shared/+addon-typescript/jsconfig.json b/packages/sv/lib/create/shared/+addon-typescript/jsconfig.json
new file mode 100644
index 00000000..316d2186
--- /dev/null
+++ b/packages/sv/lib/create/shared/+addon-typescript/jsconfig.json
@@ -0,0 +1,10 @@
+{
+ "exclude": ["**/temp/**"],
+ "compilerOptions": {
+ "strict": true,
+ "skipLibCheck": true,
+ "checkJs": true,
+ "module": "Node16",
+ "moduleResolution": "Node16"
+ }
+}
diff --git a/packages/sv/lib/create/shared/+addon/CONTRIBUTING.md b/packages/sv/lib/create/shared/+addon/CONTRIBUTING.md
new file mode 100644
index 00000000..16dd865e
--- /dev/null
+++ b/packages/sv/lib/create/shared/+addon/CONTRIBUTING.md
@@ -0,0 +1,34 @@
+# Contributing Guide
+
+Some convenient scripts are provided to help develop the add-on.
+
+```sh
+## create a new minimal project in the `demo` directory
+npm run demo-create
+
+## add your current add-on to the demo project
+npm run demo-add
+
+## run the tests
+npm run test
+```
+
+## Key things to note
+
+Your `add-on` should:
+
+- export a function that returns a `defineAddon` object.
+- have a `package.json` with an `exports` field that points to the main entry point of the add-on.
+
+## Sharing your add-on
+
+When you're ready to publish your add-on to npm, run:
+
+```shell
+npm login
+npm publish
+```
+
+## Things to be aware of
+
+Community add-ons are **not permitted** to have any external dependencies outside of `sv`. If the use of a dependency is absolutely necessary, then they can be bundled using a bundler of your choosing (e.g. Rollup, Rolldown, tsup, etc.).
diff --git a/packages/sv/lib/create/shared/+addon/README.md b/packages/sv/lib/create/shared/+addon/README.md
new file mode 100644
index 00000000..8463cc79
--- /dev/null
+++ b/packages/sv/lib/create/shared/+addon/README.md
@@ -0,0 +1,29 @@
+# [sv](https://svelte.dev/docs/cli/overview) community add-on: [~SV-NAME-TODO~](https://github.com/~SV-NAME-TODO~)
+
+> [!IMPORTANT]
+> Svelte maintainers have not reviewed community add-ons for malicious code. Use at your discretion
+
+## Usage
+
+To install the add-on, run:
+
+```shell
+npx sv add ~SV-PROTOCOL-NAME-TODO~
+```
+
+## What you get [TO BE FILLED...]
+
+- A super cool stuff
+- Another one!
+
+## Options [TO BE FILLED...]
+
+### `who`
+
+The name of the person to say hello to.
+
+Default: `you`
+
+```shell
+npx sv add ~SV-PROTOCOL-NAME-TODO~="who:your-name"
+```
diff --git a/packages/sv/lib/create/templates/addon/.gitignore b/packages/sv/lib/create/templates/addon/.gitignore
new file mode 100644
index 00000000..b46f0dc1
--- /dev/null
+++ b/packages/sv/lib/create/templates/addon/.gitignore
@@ -0,0 +1,27 @@
+node_modules
+demo/
+.test-output/
+
+# Output
+.output
+.vercel
+.netlify
+.wrangler
+/.svelte-kit
+/build
+/dist
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
+
diff --git a/packages/sv/lib/create/templates/addon/.ignore b/packages/sv/lib/create/templates/addon/.ignore
new file mode 100644
index 00000000..449d6440
--- /dev/null
+++ b/packages/sv/lib/create/templates/addon/.ignore
@@ -0,0 +1,3 @@
+package.json
+.meta.json
+.turbo
\ No newline at end of file
diff --git a/packages/sv/lib/create/templates/addon/.meta.json b/packages/sv/lib/create/templates/addon/.meta.json
new file mode 100644
index 00000000..ca10b19a
--- /dev/null
+++ b/packages/sv/lib/create/templates/addon/.meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "sv community add-on",
+ "description": "scaffolding an empty community add-on, ready to be tested and published"
+}
diff --git a/packages/sv/lib/create/templates/addon/package.template.json b/packages/sv/lib/create/templates/addon/package.template.json
new file mode 100644
index 00000000..1e6d4e57
--- /dev/null
+++ b/packages/sv/lib/create/templates/addon/package.template.json
@@ -0,0 +1,31 @@
+{
+ "name": "~SV-NAME-TODO~",
+ "description": "sv add-on for ~SV-NAME-TODO~",
+ "version": "0.0.1",
+ "type": "module",
+ "license": "MIT",
+ "scripts": {
+ "demo-create": "sv create demo --types ts --template minimal --no-add-ons --no-install",
+ "demo-add": "sv add file:../ --cwd demo --no-git-check --no-install",
+ "demo-add:ci": "sv add file:../=who:you --cwd demo --no-git-check --no-download-check --no-install",
+ "test": "vitest run"
+ },
+ "files": ["src", "!src/**/*.test.*"],
+ "exports": {
+ ".": {
+ "default": "./src/index.js"
+ }
+ },
+ "dependencies": {
+ "sv": "workspace:*"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.56.1",
+ "vitest": "4.0.7"
+ },
+ "keywords": ["sv-add"],
+ "publishConfig": {
+ "directory": "dist",
+ "access": "public"
+ }
+}
diff --git a/packages/sv/lib/create/templates/addon/src/index.js b/packages/sv/lib/create/templates/addon/src/index.js
new file mode 100644
index 00000000..ffdc9de3
--- /dev/null
+++ b/packages/sv/lib/create/templates/addon/src/index.js
@@ -0,0 +1,50 @@
+import { defineAddon, defineAddonOptions, js, parseSvelte, svelte } from 'sv/core';
+
+const options = defineAddonOptions()
+ .add('who', {
+ question: 'To whom should the addon say hello?',
+ type: 'string',
+ default: ''
+ })
+ .build();
+
+export default defineAddon({
+ id: '~SV-NAME-TODO~',
+ options,
+ setup: ({ kit, unsupported }) => {
+ if (!kit) unsupported('Requires SvelteKit');
+ },
+ run: ({ kit, sv, options, typescript }) => {
+ if (!kit) throw new Error('SvelteKit is required');
+
+ sv.file(`src/lib/~SV-NAME-TODO~/content.txt`, () => {
+ return `This is a text file made by the Community Addon Template demo for the add-on: '~SV-NAME-TODO~'!`;
+ });
+
+ sv.file(`src/lib/~SV-NAME-TODO~/HelloComponent.svelte`, (content) => {
+ const { ast, generateCode } = parseSvelte(content);
+ const scriptAst = svelte.ensureScript(ast, { langTs: typescript });
+
+ js.imports.addDefault(scriptAst, { as: 'content', from: './content.txt?raw' });
+
+ ast.fragment.nodes.push(...svelte.toFragment('{content}
'));
+ ast.fragment.nodes.push(...svelte.toFragment(`Hello ${options.who}!
`));
+
+ return generateCode();
+ });
+
+ sv.file('src/routes/+page.svelte', (content) => {
+ const { ast, generateCode } = parseSvelte(content);
+ const scriptAst = svelte.ensureScript(ast, { langTs: typescript });
+
+ js.imports.addDefault(scriptAst, {
+ as: 'HelloComponent',
+ from: `$lib/~SV-NAME-TODO~/HelloComponent.svelte`
+ });
+
+ ast.fragment.nodes.push(...svelte.toFragment(''));
+
+ return generateCode();
+ });
+ }
+});
diff --git a/packages/sv/lib/create/templates/addon/tests/addon.test.js b/packages/sv/lib/create/templates/addon/tests/addon.test.js
new file mode 100644
index 00000000..5262decd
--- /dev/null
+++ b/packages/sv/lib/create/templates/addon/tests/addon.test.js
@@ -0,0 +1,52 @@
+import { expect } from '@playwright/test';
+import fs from 'node:fs';
+import path from 'node:path';
+
+import addon from '../src/index.js';
+import { setupTest } from './setup/suite.js';
+
+const id = addon.id;
+
+// set to true to enable browser testing
+const browser = false;
+
+const { test, prepareServer, testCases } = setupTest(
+ { addon },
+ {
+ kinds: [{ type: 'default', options: { [id]: addon } }],
+ filter: (testCase) => testCase.variant.includes('kit'),
+ browser
+ }
+);
+
+test.concurrent.for(testCases)(
+ '~SV-NAME-TODO~ $kind.type $variant',
+ async (testCase, { page, ...ctx }) => {
+ const cwd = ctx.cwd(testCase);
+
+ const msg =
+ "This is a text file made by the Community Addon Template demo for the add-on: '~SV-NAME-TODO~'!";
+
+ const contentPath = path.resolve(cwd, `src/lib/~SV-NAME-TODO~/content.txt`);
+ const contentContent = fs.readFileSync(contentPath, 'utf8');
+
+ // Check if we have the imports
+ expect(contentContent).toContain(msg);
+
+ // For browser testing
+ if (browser) {
+ const { close } = await prepareServer({ cwd, page });
+ // kill server process when we're done
+ ctx.onTestFinished(async () => await close());
+
+ // expectations
+ const textContent = await page.locator('p').last().textContent();
+ if (testCase.variant.includes('kit')) {
+ expect(textContent).toContain(msg);
+ } else {
+ // it's not a kit plugin!
+ expect(textContent).not.toContain(msg);
+ }
+ }
+ }
+);
diff --git a/packages/sv/lib/create/templates/addon/tests/setup/global.js b/packages/sv/lib/create/templates/addon/tests/setup/global.js
new file mode 100644
index 00000000..1f9a11ce
--- /dev/null
+++ b/packages/sv/lib/create/templates/addon/tests/setup/global.js
@@ -0,0 +1,14 @@
+import { fileURLToPath } from 'node:url';
+import { setupGlobal } from 'sv/testing';
+
+const TEST_DIR = fileURLToPath(new URL('../../.test-output/', import.meta.url));
+
+export default setupGlobal({
+ TEST_DIR,
+ pre: async () => {
+ // global setup (e.g. spin up docker containers)
+ },
+ post: async () => {
+ // tear down... (e.g. cleanup docker containers)
+ }
+});
diff --git a/packages/sv/lib/create/templates/addon/tests/setup/suite.js b/packages/sv/lib/create/templates/addon/tests/setup/suite.js
new file mode 100644
index 00000000..128ff6f9
--- /dev/null
+++ b/packages/sv/lib/create/templates/addon/tests/setup/suite.js
@@ -0,0 +1,130 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { execSync } from 'node:child_process';
+import { inject, test as vitestTest, beforeAll, beforeEach } from 'vitest';
+import { chromium } from '@playwright/test';
+
+import { add } from 'sv';
+import { createProject, addPnpmBuildDependencies, prepareServer } from 'sv/testing';
+
+const cwd = inject('testDir');
+const templatesDir = inject('templatesDir');
+const variants = inject('variants');
+
+/**
+ * @template {import('sv').AddonMap} AddonMap
+ * @param {AddonMap} addons
+ * @param {import('sv/testing').SetupTestOptions} [options]
+ * @returns {{ test: ReturnType>, testCases: Array>, prepareServer: typeof prepareServer }}
+ */
+export function setupTest(addons, options) {
+ /** @type {ReturnType>} */
+ // @ts-ignore - vitest.extend expects fixtures object but we provide it in beforeEach
+ const test = vitestTest.extend({});
+
+ const withBrowser = options?.browser ?? true;
+
+ /** @type {ReturnType} */
+ let create;
+ /** @type {Awaited>} */
+ let browser;
+
+ if (withBrowser) {
+ beforeAll(async () => {
+ browser = await chromium.launch();
+ return async () => {
+ await browser.close();
+ };
+ });
+ }
+
+ /** @type {Array>} */
+ const testCases = [];
+ for (const kind of options?.kinds ?? []) {
+ for (const variant of variants) {
+ const addonTestCase = { variant, kind };
+ if (options?.filter === undefined || options.filter(addonTestCase)) {
+ testCases.push(addonTestCase);
+ }
+ }
+ }
+ /** @type {string} */
+ let testName;
+ beforeAll(async ({ name }) => {
+ testName = path.dirname(name).split('/').at(-1) ?? '';
+
+ // constructs a builder to create test projects
+ create = createProject({ cwd, templatesDir, testName });
+
+ // creates a pnpm workspace in each addon dir
+ fs.writeFileSync(
+ path.resolve(cwd, testName, 'pnpm-workspace.yaml'),
+ "packages:\n - '**/*'",
+ 'utf8'
+ );
+
+ // creates a barebones package.json in each addon dir
+ fs.writeFileSync(
+ path.resolve(cwd, testName, 'package.json'),
+ JSON.stringify({
+ name: `${testName}-workspace-root`,
+ private: true
+ })
+ );
+
+ for (const addonTestCase of testCases) {
+ const { variant, kind } = addonTestCase;
+ const cwd = create({ testId: `${kind.type}-${variant}`, variant });
+
+ // test metadata
+ const metaPath = path.resolve(cwd, 'meta.json');
+ fs.writeFileSync(metaPath, JSON.stringify({ variant, kind }, null, '\t'), 'utf8');
+
+ if (options?.preAdd) {
+ await options.preAdd({ addonTestCase, cwd });
+ }
+ const { pnpmBuildDependencies } = await add({
+ cwd,
+ addons,
+ options: kind.options,
+ packageManager: 'pnpm'
+ });
+ await addPnpmBuildDependencies(cwd, 'pnpm', ['esbuild', ...pnpmBuildDependencies]);
+ }
+
+ execSync('pnpm install', { cwd: path.resolve(cwd, testName), stdio: 'pipe' });
+ });
+
+ // runs before each test case
+ /**
+ * @param {import('sv/testing').Fixtures & import('vitest').TestContext} ctx
+ */
+ beforeEach(async (ctx) => {
+ /** @type {Awaited>} */
+ let browserCtx;
+ if (withBrowser) {
+ browserCtx = await browser.newContext();
+ /** @type {import('sv/testing').Fixtures} */ (/** @type {unknown} */ (ctx)).page =
+ await browserCtx.newPage();
+ }
+
+ /**
+ * @param {import('sv/testing').AddonTestCase} addonTestCase
+ * @returns {string}
+ */
+ /** @type {import('sv/testing').Fixtures} */ (/** @type {unknown} */ (ctx)).cwd = (
+ addonTestCase
+ ) => {
+ return path.join(cwd, testName, `${addonTestCase.kind.type}-${addonTestCase.variant}`);
+ };
+
+ return async () => {
+ if (withBrowser) {
+ await browserCtx.close();
+ }
+ // ...other tear downs
+ };
+ });
+
+ return { test, testCases, prepareServer };
+}
diff --git a/packages/sv/lib/create/templates/addon/vitest.config.js b/packages/sv/lib/create/templates/addon/vitest.config.js
new file mode 100644
index 00000000..febf9248
--- /dev/null
+++ b/packages/sv/lib/create/templates/addon/vitest.config.js
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vitest/config';
+
+const ONE_MINUTE = 1000 * 60;
+
+export default defineConfig({
+ test: {
+ include: ['tests/**/*.test.{js,ts}'],
+ exclude: ['tests/setup/*'],
+ testTimeout: ONE_MINUTE * 3,
+ hookTimeout: ONE_MINUTE * 3,
+ globalSetup: ['tests/setup/global.js'],
+ expect: {
+ requireAssertions: true
+ }
+ }
+});
diff --git a/packages/sv/lib/create/templates/demo/package.template.json b/packages/sv/lib/create/templates/demo/package.template.json
index e8ff0697..3f90b8f3 100644
--- a/packages/sv/lib/create/templates/demo/package.template.json
+++ b/packages/sv/lib/create/templates/demo/package.template.json
@@ -1,5 +1,5 @@
{
- "name": "~TODO~",
+ "name": "~SV-NAME-TODO~",
"private": true,
"version": "0.0.1",
"type": "module",
diff --git a/packages/sv/lib/create/templates/demo/src/routes/+page.svelte b/packages/sv/lib/create/templates/demo/src/routes/+page.svelte
index a328ff63..b30bcfa4 100644
--- a/packages/sv/lib/create/templates/demo/src/routes/+page.svelte
+++ b/packages/sv/lib/create/templates/demo/src/routes/+page.svelte
@@ -1,7 +1,8 @@
diff --git a/packages/sv/lib/create/templates/demo/src/routes/Header.svelte b/packages/sv/lib/create/templates/demo/src/routes/Header.svelte
index 0e719eb0..ebe8faa4 100644
--- a/packages/sv/lib/create/templates/demo/src/routes/Header.svelte
+++ b/packages/sv/lib/create/templates/demo/src/routes/Header.svelte
@@ -1,8 +1,8 @@
diff --git a/packages/sv/lib/create/templates/demo/src/routes/sverdle/+page.server.ts b/packages/sv/lib/create/templates/demo/src/routes/sverdle/+page.server.ts
index 4a24244f..95ff72a8 100644
--- a/packages/sv/lib/create/templates/demo/src/routes/sverdle/+page.server.ts
+++ b/packages/sv/lib/create/templates/demo/src/routes/sverdle/+page.server.ts
@@ -1,6 +1,7 @@
import { fail } from '@sveltejs/kit';
+
+import type { Actions, PageServerLoad } from './$types';
import { Game } from './game.ts';
-import type { PageServerLoad, Actions } from './$types';
/** @satisfies {import('./$types').PageServerLoad} */
export const load = (({ cookies }) => {
diff --git a/packages/sv/lib/create/templates/demo/src/routes/sverdle/+page.svelte b/packages/sv/lib/create/templates/demo/src/routes/sverdle/+page.svelte
index 98d43fca..987e3ea4 100644
--- a/packages/sv/lib/create/templates/demo/src/routes/sverdle/+page.svelte
+++ b/packages/sv/lib/create/templates/demo/src/routes/sverdle/+page.svelte
@@ -2,9 +2,10 @@
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { confetti } from '@neoconfetti/svelte';
- import type { ActionData, PageData } from './$types';
import { MediaQuery } from 'svelte/reactivity';
+ import type { ActionData, PageData } from './$types';
+
interface Props {
data: PageData;
form: ActionData;
diff --git a/packages/sv/lib/create/templates/demo/src/routes/sverdle/game.ts b/packages/sv/lib/create/templates/demo/src/routes/sverdle/game.ts
index 1dba0403..c3b97a01 100644
--- a/packages/sv/lib/create/templates/demo/src/routes/sverdle/game.ts
+++ b/packages/sv/lib/create/templates/demo/src/routes/sverdle/game.ts
@@ -1,4 +1,4 @@
-import { words, allowed } from './words.server.ts';
+import { allowed, words } from './words.server.ts';
export class Game {
index: number;
diff --git a/packages/sv/lib/create/templates/demo/vite.config.js b/packages/sv/lib/create/templates/demo/vite.config.js
index 3dc75a73..218b4759 100644
--- a/packages/sv/lib/create/templates/demo/vite.config.js
+++ b/packages/sv/lib/create/templates/demo/vite.config.js
@@ -1,5 +1,5 @@
-import path from 'node:path';
import { sveltekit } from '@sveltejs/kit/vite';
+import path from 'node:path';
import { defineConfig } from 'vite';
export default defineConfig({
diff --git a/packages/sv/lib/create/templates/library/package.template.json b/packages/sv/lib/create/templates/library/package.template.json
index 90561b20..2d70b6d1 100644
--- a/packages/sv/lib/create/templates/library/package.template.json
+++ b/packages/sv/lib/create/templates/library/package.template.json
@@ -1,5 +1,5 @@
{
- "name": "~TODO~",
+ "name": "~SV-NAME-TODO~",
"version": "0.0.1",
"scripts": {
"dev": "vite dev",
diff --git a/packages/sv/lib/create/templates/minimal/package.template.json b/packages/sv/lib/create/templates/minimal/package.template.json
index bd3b6b80..e7dea8af 100644
--- a/packages/sv/lib/create/templates/minimal/package.template.json
+++ b/packages/sv/lib/create/templates/minimal/package.template.json
@@ -1,5 +1,5 @@
{
- "name": "~TODO~",
+ "name": "~SV-NAME-TODO~",
"private": true,
"version": "0.0.1",
"type": "module",
diff --git a/packages/sv/lib/create/test/check.ts b/packages/sv/lib/create/test/check.ts
index 268bdedd..828ae62d 100644
--- a/packages/sv/lib/create/test/check.ts
+++ b/packages/sv/lib/create/test/check.ts
@@ -1,17 +1,18 @@
+import { type PromiseWithChild, exec } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
-import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
-import { exec, type PromiseWithChild } from 'node:child_process';
+import { promisify } from 'node:util';
import { beforeAll, describe, expect, test } from 'vitest';
-import { create, type LanguageType, type TemplateType } from '../index.ts';
-import { installAddon, officialAddons } from '../../../../sv/lib/index.ts';
+
+import { add, officialAddons } from '../../../../sv/lib/index.ts';
+import { type LanguageType, type TemplateType, create } from '../index.ts';
// Resolve the given path relative to the current file
const resolve_path = (path: string) => fileURLToPath(new URL(path, import.meta.url));
// use a directory outside of packages to ensure it isn't added to the pnpm workspace
-const test_workspace_dir = resolve_path('../../../.test-output/create/');
+const test_workspace_dir = resolve_path('../../../../../.test-output/create/');
// prepare test pnpm workspace
fs.rmSync(test_workspace_dir, { recursive: true, force: true });
@@ -35,7 +36,7 @@ const script_test_map = new Map PromiseWithChild t !== 'addon')) {
if (template[0] === '.') continue;
for (const types of ['checkjs', 'typescript', 'none'] as LanguageType[]) {
@@ -43,7 +44,7 @@ for (const template of templates) {
fs.rmSync(cwd, { recursive: true, force: true });
create(cwd, { name: `create-svelte-test-${template}-${types}`, template, types });
- await installAddon({ cwd, addons: { eslint: officialAddons.eslint }, options: { eslint: {} } });
+ await add({ cwd, addons: { eslint: officialAddons.eslint }, options: { eslint: {} } });
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
@@ -73,7 +74,7 @@ for (const template of templates) {
}
for (const [script, tests] of script_test_map) {
- describe.concurrent(script, { timeout: 60000 }, () => {
+ describe.concurrent(script, { timeout: 61_000 }, () => {
for (const [name, task] of tests) {
test(name, task);
}
diff --git a/packages/sv/lib/create/test/playground.ts b/packages/sv/lib/create/test/playground.ts
index 77f0bb71..35d8cd11 100644
--- a/packages/sv/lib/create/test/playground.ts
+++ b/packages/sv/lib/create/test/playground.ts
@@ -1,15 +1,16 @@
+import * as fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
import { expect, test } from 'vitest';
+
+import { create } from '../index.ts';
import {
+ detectPlaygroundDependencies,
downloadPlaygroundData,
parsePlaygroundUrl,
setupPlaygroundProject,
- validatePlaygroundUrl,
- detectPlaygroundDependencies
+ validatePlaygroundUrl
} from '../playground.ts';
-import { fileURLToPath } from 'node:url';
-import { create } from '../index.ts';
-import path from 'node:path';
-import * as fs from 'node:fs';
const resolvePath = (path: string) => fileURLToPath(new URL(path, import.meta.url));
const testWorkspaceDir = resolvePath('../../../.test-output/create/');
diff --git a/packages/sv/lib/create/utils.ts b/packages/sv/lib/create/utils.ts
index 08dd6891..f8aff0b9 100644
--- a/packages/sv/lib/create/utils.ts
+++ b/packages/sv/lib/create/utils.ts
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+
import type { Common } from './index.ts';
export function mkdirp(dir: string): void {
@@ -17,22 +18,29 @@ function identity(x: T): T {
return x;
}
+export function replace(contents: string, kv: Record): string {
+ for (const [key, value] of Object.entries(kv)) {
+ contents = contents.replaceAll(key, value);
+ }
+ return contents;
+}
+
export function copy(
from: string,
to: string,
- rename: (basename: string) => string = identity
+ rename: (basename: string) => string = identity,
+ kv: Record = {}
): void {
if (!fs.existsSync(from)) return;
-
const stats = fs.statSync(from);
if (stats.isDirectory()) {
fs.readdirSync(from).forEach((file) => {
- copy(path.join(from, file), path.join(to, rename(file)));
+ copy(path.join(from, file), path.join(to, rename(file)), rename, kv);
});
} else {
mkdirp(path.dirname(to));
- fs.copyFileSync(from, to);
+ fs.writeFileSync(to, replace(fs.readFileSync(from, 'utf-8'), kv));
}
}
diff --git a/packages/sv/lib/index.ts b/packages/sv/lib/index.ts
index 86bfd94b..243ade78 100644
--- a/packages/sv/lib/index.ts
+++ b/packages/sv/lib/index.ts
@@ -1,4 +1,4 @@
export { create, type TemplateType, type LanguageType } from './create/index.ts';
-export { installAddon } from './addons/install.ts';
-export type { AddonMap, InstallOptions, OptionMap } from './addons/install.ts';
+export { add } from './addons/add.ts';
+export type { AddonMap, InstallOptions, OptionMap } from './addons/add.ts';
export { officialAddons } from './addons/_config/official.ts';
diff --git a/packages/sv/lib/testing.ts b/packages/sv/lib/testing.ts
index 1fa1532b..6adffdc2 100644
--- a/packages/sv/lib/testing.ts
+++ b/packages/sv/lib/testing.ts
@@ -1,13 +1,19 @@
+import degit from 'degit';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
-import degit from 'degit';
-import { x, exec } from 'tinyexec';
-import { create } from './create/index.ts';
+import { execSync } from 'node:child_process';
import pstree, { type PS } from 'ps-tree';
+import { exec, x } from 'tinyexec';
+
+import { create } from './create/index.ts';
+import type { TestProject } from 'vitest/node';
+import type { AddonMap, OptionMap } from './addons/add.ts';
+import type { Page } from '@playwright/test';
export { addPnpmBuildDependencies } from './cli/utils/package-manager.ts';
export type ProjectVariant = 'kit-js' | 'kit-ts' | 'vite-js' | 'vite-ts';
+export const variants: ProjectVariant[] = ['kit-js', 'kit-ts', 'vite-js', 'vite-ts'];
const TEMPLATES_DIR = '.templates';
@@ -90,7 +96,7 @@ export async function startPreview({
const proc = exec(cmd, args, {
nodeOptions: { cwd, stdio: 'pipe' },
throwOnError: true,
- timeout: 60_000
+ timeout: 66_999
});
const close = async () => {
@@ -155,3 +161,93 @@ function kill(pid: number) {
// this can happen if a process has been automatically terminated.
}
}
+
+declare module 'vitest' {
+ export interface ProvidedContext {
+ testDir: string;
+ templatesDir: string;
+ variants: ProjectVariant[];
+ }
+}
+
+export function setupGlobal({
+ TEST_DIR,
+ pre,
+ post
+}: {
+ TEST_DIR: string;
+ pre?: () => Promise;
+ post?: () => Promise;
+}): ({ provide }: TestProject) => Promise<() => Promise> {
+ return async function ({ provide }: TestProject) {
+ await pre?.();
+
+ // downloads different project configurations (sveltekit, js/ts, vite-only, etc)
+ const { templatesDir } = await setup({ cwd: TEST_DIR, variants });
+
+ provide('testDir', TEST_DIR);
+ provide('templatesDir', templatesDir);
+ provide('variants', variants);
+
+ return async () => {
+ await post?.();
+ };
+ };
+}
+
+export type Fixtures = {
+ page: Page;
+ cwd(addonTestCase: AddonTestCase): string;
+};
+
+export type AddonTestCase = {
+ variant: ProjectVariant;
+ kind: { type: string; options: OptionMap };
+};
+
+export type SetupTestOptions = {
+ kinds: Array['kind']>;
+ filter?: (addonTestCase: AddonTestCase) => boolean;
+ browser?: boolean;
+ preAdd?: (o: { addonTestCase: AddonTestCase; cwd: string }) => Promise | void;
+};
+
+export type PrepareServerOptions = {
+ cwd: string;
+ page: Page;
+ buildCommand?: string;
+ previewCommand?: string;
+};
+
+export type PrepareServerReturn = {
+ url: string;
+ close: () => Promise;
+};
+
+// installs dependencies, builds the project, and spins up the preview server
+export async function prepareServer({
+ cwd,
+ page,
+ buildCommand = 'pnpm build',
+ previewCommand = 'pnpm preview'
+}: PrepareServerOptions): Promise {
+ // build project
+ if (buildCommand) execSync(buildCommand, { cwd, stdio: 'pipe' });
+
+ // start preview server
+ const { url, close } = await startPreview({ cwd, command: previewCommand });
+
+ // increases timeout as 30s is not always enough when running the full suite
+ page.setDefaultNavigationTimeout(62_000);
+
+ try {
+ // navigate to the page
+ await page.goto(url);
+ } catch (e) {
+ // cleanup in the instance of a timeout
+ await close();
+ throw e;
+ }
+
+ return { url, close };
+}
diff --git a/packages/sv/package.json b/packages/sv/package.json
index 8a67dc83..07e4b809 100644
--- a/packages/sv/package.json
+++ b/packages/sv/package.json
@@ -21,16 +21,16 @@
"bin": "./dist/bin.mjs",
"exports": {
".": {
- "types": "./dist/index.d.mts",
+ "types": "./dist/lib/index.d.mts",
"default": "./dist/lib/index.mjs"
},
"./testing": {
- "types": "./dist/testing.d.mts",
+ "types": "./dist/lib/testing.d.mts",
"default": "./dist/lib/testing.mjs"
},
"./core": {
- "types": "./dist/lib/core/index.d.ts",
- "default": "./dist/lib/core/index.mjs"
+ "types": "./dist/lib/core.d.mts",
+ "default": "./dist/lib/core.mjs"
}
},
"devDependencies": {
@@ -40,6 +40,7 @@
"@types/estree": "^1.0.8",
"@types/gitignore-parser": "^0.0.3",
"@types/ps-tree": "^1.1.6",
+ "@types/tar-fs": "^2.0.4",
"acorn": "^8.15.0",
"commander": "^14.0.2",
"decircular": "^1.0.0",
@@ -55,6 +56,7 @@
"silver-fleece": "^1.2.1",
"sucrase": "^3.35.1",
"svelte": "^5.45.10",
+ "tar-fs": "^3.1.1",
"tiny-glob": "^0.2.9",
"tinyexec": "^1.0.2",
"valibot": "^1.2.0",
diff --git a/packages/sv/tsconfig.json b/packages/sv/tsconfig.json
index e3f1b394..bbac4df0 100644
--- a/packages/sv/tsconfig.json
+++ b/packages/sv/tsconfig.json
@@ -5,7 +5,7 @@
"dist/**",
"lib/core/tests/**/input.ts",
"lib/core/tests/**/output.ts",
- "lib/create/templates/demo/**",
+ "lib/create/templates/**",
"lib/create/shared/vite.config.ts",
"lib/cli/tests/snapshots/**"
],
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 02cc36dd..acbfe5ee 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -54,8 +54,8 @@ importers:
specifier: ^5.45.10
version: 5.45.10
tsdown:
- specifier: ^0.17.3
- version: 0.17.3(@typescript/native-preview@7.0.0-dev.20251212.1)(synckit@0.11.11)(typescript@5.9.3)
+ specifier: ^0.18.1
+ version: 0.18.1(@typescript/native-preview@7.0.0-dev.20251212.1)(synckit@0.11.11)(typescript@5.9.3)
typescript:
specifier: ^5.9.3
version: 5.9.3
@@ -66,19 +66,6 @@ importers:
specifier: 4.0.15
version: 4.0.15(@types/node@20.19.26)(@vitest/ui@4.0.15)(jiti@2.6.1)(yaml@2.8.2)
- community-addon-template:
- dependencies:
- sv:
- specifier: workspace:*
- version: link:../packages/sv
- devDependencies:
- '@playwright/test':
- specifier: ^1.57.0
- version: 1.57.0
- vitest:
- specifier: 4.0.15
- version: 4.0.15(@types/node@25.0.1)(@vitest/ui@4.0.15)(jiti@2.6.1)(yaml@2.8.2)
-
packages/migrate:
dependencies:
'@clack/prompts':
@@ -145,6 +132,9 @@ importers:
'@types/ps-tree':
specifier: ^1.1.6
version: 1.1.6
+ '@types/tar-fs':
+ specifier: ^2.0.4
+ version: 2.0.4
acorn:
specifier: ^8.15.0
version: 8.15.0
@@ -190,6 +180,9 @@ importers:
svelte:
specifier: ^5.45.10
version: 5.45.10
+ tar-fs:
+ specifier: ^3.1.1
+ version: 3.1.1
tiny-glob:
specifier: ^0.2.9
version: 0.2.9
@@ -562,8 +555,8 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
- '@oxc-project/types@0.101.0':
- resolution: {integrity: sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ==}
+ '@oxc-project/types@0.103.0':
+ resolution: {integrity: sha512-bkiYX5kaXWwUessFRSoXFkGIQTmc6dLGdxuRTrC+h8PSnIdZyuXHHlLAeTmOue5Br/a0/a7dHH0Gca6eXn9MKg==}
'@pkgr/core@0.2.9':
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
@@ -580,85 +573,85 @@ packages:
'@quansync/fs@1.0.0':
resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
- '@rolldown/binding-android-arm64@1.0.0-beta.53':
- resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==}
+ '@rolldown/binding-android-arm64@1.0.0-beta.55':
+ resolution: {integrity: sha512-5cPpHdO+zp+klznZnIHRO1bMHDq5hS9cqXodEKAaa/dQTPDjnE91OwAsy3o1gT2x4QaY8NzdBXAvutYdaw0WeA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@rolldown/binding-darwin-arm64@1.0.0-beta.53':
- resolution: {integrity: sha512-yIsKqMz0CtRnVa6x3Pa+mzTihr4Ty+Z6HfPbZ7RVbk1Uxnco4+CUn7Qbm/5SBol1JD/7nvY8rphAgyAi7Lj6Vg==}
+ '@rolldown/binding-darwin-arm64@1.0.0-beta.55':
+ resolution: {integrity: sha512-l0887CGU2SXZr0UJmeEcXSvtDCOhDTTYXuoWbhrEJ58YQhQk24EVhDhHMTyjJb1PBRniUgNc1G0T51eF8z+TWw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@rolldown/binding-darwin-x64@1.0.0-beta.53':
- resolution: {integrity: sha512-GTXe+mxsCGUnJOFMhfGWmefP7Q9TpYUseHvhAhr21nCTgdS8jPsvirb0tJwM3lN0/u/cg7bpFNa16fQrjKrCjQ==}
+ '@rolldown/binding-darwin-x64@1.0.0-beta.55':
+ resolution: {integrity: sha512-d7qP2AVYzN0tYIP4vJ7nmr26xvmlwdkLD/jWIc9Z9dqh5y0UGPigO3m5eHoHq9BNazmwdD9WzDHbQZyXFZjgtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@rolldown/binding-freebsd-x64@1.0.0-beta.53':
- resolution: {integrity: sha512-9Tmp7bBvKqyDkMcL4e089pH3RsjD3SUungjmqWtyhNOxoQMh0fSmINTyYV8KXtE+JkxYMPWvnEt+/mfpVCkk8w==}
+ '@rolldown/binding-freebsd-x64@1.0.0-beta.55':
+ resolution: {integrity: sha512-j311E4NOB0VMmXHoDDZhrWidUf7L/Sa6bu/+i2cskvHKU40zcUNPSYeD2YiO2MX+hhDFa5bJwhliYfs+bTrSZw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53':
- resolution: {integrity: sha512-a1y5fiB0iovuzdbjUxa7+Zcvgv+mTmlGGC4XydVIsyl48eoxgaYkA3l9079hyTyhECsPq+mbr0gVQsFU11OJAQ==}
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.55':
+ resolution: {integrity: sha512-lAsaYWhfNTW2A/9O7zCpb5eIJBrFeNEatOS/DDOZ5V/95NHy50g4b/5ViCqchfyFqRb7MKUR18/+xWkIcDkeIw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53':
- resolution: {integrity: sha512-bpIGX+ov9PhJYV+wHNXl9rzq4F0QvILiURn0y0oepbQx+7stmQsKA0DhPGwmhfvF856wq+gbM8L92SAa/CBcLg==}
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.55':
+ resolution: {integrity: sha512-2x6ffiVLZrQv7Xii9+JdtyT1U3bQhKj59K3eRnYlrXsKyjkjfmiDUVx2n+zSyijisUqD62fcegmx2oLLfeTkCA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- '@rolldown/binding-linux-arm64-musl@1.0.0-beta.53':
- resolution: {integrity: sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==}
+ '@rolldown/binding-linux-arm64-musl@1.0.0-beta.55':
+ resolution: {integrity: sha512-QbNncvqAXziya5wleI+OJvmceEE15vE4yn4qfbI/hwT/+8ZcqxyfRZOOh62KjisXxp4D0h3JZspycXYejxAU3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- '@rolldown/binding-linux-x64-gnu@1.0.0-beta.53':
- resolution: {integrity: sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==}
+ '@rolldown/binding-linux-x64-gnu@1.0.0-beta.55':
+ resolution: {integrity: sha512-YZCTZZM+rujxwVc6A+QZaNMJXVtmabmFYLG2VGQTKaBfYGvBKUgtbMEttnp/oZ88BMi2DzadBVhOmfQV8SuHhw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- '@rolldown/binding-linux-x64-musl@1.0.0-beta.53':
- resolution: {integrity: sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==}
+ '@rolldown/binding-linux-x64-musl@1.0.0-beta.55':
+ resolution: {integrity: sha512-28q9OQ/DDpFh2keS4BVAlc3N65/wiqKbk5K1pgLdu/uWbKa8hgUJofhXxqO+a+Ya2HVTUuYHneWsI2u+eu3N5Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- '@rolldown/binding-openharmony-arm64@1.0.0-beta.53':
- resolution: {integrity: sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==}
+ '@rolldown/binding-openharmony-arm64@1.0.0-beta.55':
+ resolution: {integrity: sha512-LiCA4BjCnm49B+j1lFzUtlC+4ZphBv0d0g5VqrEJua/uyv9Ey1v9tiaMql1C8c0TVSNDUmrkfHQ71vuQC7YfpQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@rolldown/binding-wasm32-wasi@1.0.0-beta.53':
- resolution: {integrity: sha512-BUjAEgpABEJXilGq/BPh7jeU3WAJ5o15c1ZEgHaDWSz3LB881LQZnbNJHmUiM4d1JQWMYYyR1Y490IBHi2FPJg==}
+ '@rolldown/binding-wasm32-wasi@1.0.0-beta.55':
+ resolution: {integrity: sha512-nZ76tY7T0Oe8vamz5Cv5CBJvrqeQxwj1WaJ2GxX8Msqs0zsQMMcvoyxOf0glnJlxxgKjtoBxAOxaAU8ERbW6Tg==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
- '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53':
- resolution: {integrity: sha512-s27uU7tpCWSjHBnxyVXHt3rMrQdJq5MHNv3BzsewCIroIw3DJFjMH1dzCPPMUFxnh1r52Nf9IJ/eWp6LDoyGcw==}
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.55':
+ resolution: {integrity: sha512-TFVVfLfhL1G+pWspYAgPK/FSqjiBtRKYX9hixfs508QVEZPQlubYAepHPA7kEa6lZXYj5ntzF87KC6RNhxo+ew==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@rolldown/binding-win32-x64-msvc@1.0.0-beta.53':
- resolution: {integrity: sha512-cjWL/USPJ1g0en2htb4ssMjIycc36RvdQAx1WlXnS6DpULswiUTVXPDesTifSKYSyvx24E0YqQkEm0K/M2Z/AA==}
+ '@rolldown/binding-win32-x64-msvc@1.0.0-beta.55':
+ resolution: {integrity: sha512-j1WBlk0p+ISgLzMIgl0xHp1aBGXenoK2+qWYc/wil2Vse7kVOdFq9aeQ8ahK6/oxX2teQ5+eDvgjdywqTL+daA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
- '@rolldown/pluginutils@1.0.0-beta.53':
- resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
+ '@rolldown/pluginutils@1.0.0-beta.55':
+ resolution: {integrity: sha512-vajw/B3qoi7aYnnD4BQ4VoCcXQWnF0roSwE2iynbNxgW4l9mFwtLmLmUhpDdcTBfKyZm1p/T0D13qG94XBLohA==}
'@rollup/rollup-android-arm-eabi@4.53.3':
resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==}
@@ -829,9 +822,6 @@ packages:
'@types/node@20.19.26':
resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==}
- '@types/node@25.0.1':
- resolution: {integrity: sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==}
-
'@types/prompts@2.4.9':
resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==}
@@ -841,6 +831,12 @@ packages:
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
+ '@types/tar-fs@2.0.4':
+ resolution: {integrity: sha512-ipPec0CjTmVDWE+QKr9cTmIIoTl7dFG/yARCM5MqK8i6CNLIG1P8x4kwDsOQY1ChZOZjH0wO9nvfgBvWl4R3kA==}
+
+ '@types/tar-stream@3.1.4':
+ resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==}
+
'@typescript-eslint/eslint-plugin@8.49.0':
resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1031,15 +1027,61 @@ packages:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
+ b4a@1.7.3:
+ resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==}
+ peerDependencies:
+ react-native-b4a: '*'
+ peerDependenciesMeta:
+ react-native-b4a:
+ optional: true
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ bare-events@2.8.2:
+ resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
+ peerDependencies:
+ bare-abort-controller: '*'
+ peerDependenciesMeta:
+ bare-abort-controller:
+ optional: true
+
+ bare-fs@4.5.2:
+ resolution: {integrity: sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==}
+ engines: {bare: '>=1.16.0'}
+ peerDependencies:
+ bare-buffer: '*'
+ peerDependenciesMeta:
+ bare-buffer:
+ optional: true
+
+ bare-os@3.6.2:
+ resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==}
+ engines: {bare: '>=1.14.0'}
+
+ bare-path@3.0.0:
+ resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==}
+
+ bare-stream@2.7.0:
+ resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==}
+ peerDependencies:
+ bare-buffer: '*'
+ bare-events: '*'
+ peerDependenciesMeta:
+ bare-buffer:
+ optional: true
+ bare-events:
+ optional: true
+
+ bare-url@2.3.2:
+ resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==}
+
better-path-resolve@1.0.0:
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
engines: {node: '>=4'}
- birpc@3.0.0:
- resolution: {integrity: sha512-by+04pHuxpCEQcucAXqzopqfhyI8TLK5Qg5MST0cB6MP+JhHna9ollrtK9moVh27aq6Q6MEJgebD0cVm//yBkg==}
+ birpc@4.0.0:
+ resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@@ -1182,6 +1224,9 @@ packages:
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
engines: {node: '>=14'}
+ end-of-stream@1.4.5:
+ resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
enhanced-resolve@5.18.4:
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'}
@@ -1295,6 +1340,9 @@ packages:
event-stream@3.3.4:
resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==}
+ events-universal@1.0.1:
+ resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
+
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -1305,6 +1353,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+ fast-fifo@1.3.2:
+ resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
+
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -1447,8 +1498,8 @@ packages:
import-meta-resolve@4.2.0:
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
- import-without-cache@0.2.3:
- resolution: {integrity: sha512-roCvX171VqJ7+7pQt1kSRfwaJvFAC2zhThJWXal1rN8EqzPS3iapkAoNpHh4lM8Na1BDen+n9rVfo73RN+Y87g==}
+ import-without-cache@0.2.4:
+ resolution: {integrity: sha512-b/Ke0y4n26ffQhkLvgBxV/NVO/QEE6AZlrMj8DYuxBWNAAu4iMQWZTFWzKcCTEmv7VQ0ae0j8KwrlGzSy8sYQQ==}
engines: {node: '>=20.19.0'}
imurmurhash@0.1.4:
@@ -1611,6 +1662,9 @@ packages:
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -1771,6 +1825,9 @@ packages:
engines: {node: '>= 0.10'}
hasBin: true
+ pump@3.0.3:
+ resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1803,13 +1860,13 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
- rolldown-plugin-dts@0.18.3:
- resolution: {integrity: sha512-rd1LZ0Awwfyn89UndUF/HoFF4oH9a5j+2ZeuKSJYM80vmeN/p0gslYMnHTQHBEXPhUlvAlqGA3tVgXB/1qFNDg==}
+ rolldown-plugin-dts@0.19.1:
+ resolution: {integrity: sha512-6z501zDTGq6ZrIEdk57qNUwq7kBRGzv3I3SAN2HMJ2KFYjHLnAuPYOmvfiwdxbRZMJ0iMdkV9rYdC3GjurT2cg==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@ts-macro/tsc': ^0.3.6
'@typescript/native-preview': '>=7.0.0-dev.20250601.1'
- rolldown: ^1.0.0-beta.51
+ rolldown: ^1.0.0-beta.55
typescript: ^5.0.0
vue-tsc: ~3.1.0
peerDependenciesMeta:
@@ -1822,8 +1879,8 @@ packages:
vue-tsc:
optional: true
- rolldown@1.0.0-beta.53:
- resolution: {integrity: sha512-Qd9c2p0XKZdgT5AYd+KgAMggJ8ZmCs3JnS9PTMWkyUfteKlfmKtxJbWTHkVakxwXs1Ub7jrRYVeFeF7N0sQxyw==}
+ rolldown@1.0.0-beta.55:
+ resolution: {integrity: sha512-r8Ws43aYCnfO07ao0SvQRz4TBAtZJjGWNvScRBOHuiNHvjfECOJBIqJv0nUkL1GYcltjvvHswRilDF1ocsC0+g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -1902,6 +1959,9 @@ packages:
stream-combiner@0.0.4:
resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==}
+ streamx@2.23.0:
+ resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
+
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -1944,10 +2004,19 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
+ tar-fs@3.1.1:
+ resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==}
+
+ tar-stream@3.1.7:
+ resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
+
term-size@2.2.1:
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
engines: {node: '>=8'}
+ text-decoder@1.2.3:
+ resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
+
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@@ -2003,13 +2072,13 @@ packages:
ts-morph@24.0.0:
resolution: {integrity: sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==}
- tsdown@0.17.3:
- resolution: {integrity: sha512-bgLgTog+oyadDTr9SZ57jZtb+A4aglCjo3xgJrkCDxbzcQl2l2iDDr4b06XHSQHwyDNIhYFDgPRhuu1wL3pNsw==}
+ tsdown@0.18.1:
+ resolution: {integrity: sha512-na4MdVA8QS9Zw++0KovGpjvw1BY5WvoCWcE4Aw0dyfff9nWK8BPzniQEVs+apGUg3DHaYMDfs+XiFaDDgqDDzQ==}
engines: {node: '>=20.19.0'}
hasBin: true
peerDependencies:
'@arethetypeswrong/core': ^0.18.1
- '@vitejs/devtools': ^0.0.0-alpha.19
+ '@vitejs/devtools': '*'
publint: ^0.3.0
typescript: ^5.0.0
unplugin-lightningcss: ^0.4.0
@@ -2053,15 +2122,12 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
- undici-types@7.16.0:
- resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
-
universalify@0.1.2:
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
engines: {node: '>= 4.0.0'}
- unrun@0.2.19:
- resolution: {integrity: sha512-DbwbJ9BvPEb3BeZnIpP9S5tGLO/JIgPQ3JrpMRFIfZMZfMG19f26OlLbC2ml8RRdrI2ZA7z2t+at5tsIHbh6Qw==}
+ unrun@0.2.20:
+ resolution: {integrity: sha512-YhobStTk93HYRN/4iBs3q3/sd7knvju1XrzwwrVVfRujyTG1K88hGONIxCoJN0PWBuO+BX7fFiHH0sVDfE3MWw==}
engines: {node: '>=20.19.0'}
hasBin: true
peerDependencies:
@@ -2178,6 +2244,9 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
@@ -2593,7 +2662,7 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1
- '@oxc-project/types@0.101.0': {}
+ '@oxc-project/types@0.103.0': {}
'@pkgr/core@0.2.9': {}
@@ -2607,48 +2676,48 @@ snapshots:
dependencies:
quansync: 1.0.0
- '@rolldown/binding-android-arm64@1.0.0-beta.53':
+ '@rolldown/binding-android-arm64@1.0.0-beta.55':
optional: true
- '@rolldown/binding-darwin-arm64@1.0.0-beta.53':
+ '@rolldown/binding-darwin-arm64@1.0.0-beta.55':
optional: true
- '@rolldown/binding-darwin-x64@1.0.0-beta.53':
+ '@rolldown/binding-darwin-x64@1.0.0-beta.55':
optional: true
- '@rolldown/binding-freebsd-x64@1.0.0-beta.53':
+ '@rolldown/binding-freebsd-x64@1.0.0-beta.55':
optional: true
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53':
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.55':
optional: true
- '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53':
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.55':
optional: true
- '@rolldown/binding-linux-arm64-musl@1.0.0-beta.53':
+ '@rolldown/binding-linux-arm64-musl@1.0.0-beta.55':
optional: true
- '@rolldown/binding-linux-x64-gnu@1.0.0-beta.53':
+ '@rolldown/binding-linux-x64-gnu@1.0.0-beta.55':
optional: true
- '@rolldown/binding-linux-x64-musl@1.0.0-beta.53':
+ '@rolldown/binding-linux-x64-musl@1.0.0-beta.55':
optional: true
- '@rolldown/binding-openharmony-arm64@1.0.0-beta.53':
+ '@rolldown/binding-openharmony-arm64@1.0.0-beta.55':
optional: true
- '@rolldown/binding-wasm32-wasi@1.0.0-beta.53':
+ '@rolldown/binding-wasm32-wasi@1.0.0-beta.55':
dependencies:
'@napi-rs/wasm-runtime': 1.1.0
optional: true
- '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53':
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.55':
optional: true
- '@rolldown/binding-win32-x64-msvc@1.0.0-beta.53':
+ '@rolldown/binding-win32-x64-msvc@1.0.0-beta.55':
optional: true
- '@rolldown/pluginutils@1.0.0-beta.53': {}
+ '@rolldown/pluginutils@1.0.0-beta.55': {}
'@rollup/rollup-android-arm-eabi@4.53.3':
optional: true
@@ -2778,11 +2847,6 @@ snapshots:
dependencies:
undici-types: 6.21.0
- '@types/node@25.0.1':
- dependencies:
- undici-types: 7.16.0
- optional: true
-
'@types/prompts@2.4.9':
dependencies:
'@types/node': 20.19.26
@@ -2792,6 +2856,15 @@ snapshots:
'@types/semver@7.7.1': {}
+ '@types/tar-fs@2.0.4':
+ dependencies:
+ '@types/node': 20.19.26
+ '@types/tar-stream': 3.1.4
+
+ '@types/tar-stream@3.1.4':
+ dependencies:
+ '@types/node': 20.19.26
+
'@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -3008,13 +3081,52 @@ snapshots:
axobject-query@4.1.0: {}
+ b4a@1.7.3: {}
+
balanced-match@1.0.2: {}
+ bare-events@2.8.2: {}
+
+ bare-fs@4.5.2:
+ dependencies:
+ bare-events: 2.8.2
+ bare-path: 3.0.0
+ bare-stream: 2.7.0(bare-events@2.8.2)
+ bare-url: 2.3.2
+ fast-fifo: 1.3.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+ optional: true
+
+ bare-os@3.6.2:
+ optional: true
+
+ bare-path@3.0.0:
+ dependencies:
+ bare-os: 3.6.2
+ optional: true
+
+ bare-stream@2.7.0(bare-events@2.8.2):
+ dependencies:
+ streamx: 2.23.0
+ optionalDependencies:
+ bare-events: 2.8.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+ optional: true
+
+ bare-url@2.3.2:
+ dependencies:
+ bare-path: 3.0.0
+ optional: true
+
better-path-resolve@1.0.0:
dependencies:
is-windows: 1.0.2
- birpc@3.0.0: {}
+ birpc@4.0.0: {}
brace-expansion@1.1.12:
dependencies:
@@ -3104,6 +3216,10 @@ snapshots:
empathic@2.0.0: {}
+ end-of-stream@1.4.5:
+ dependencies:
+ once: 1.4.0
+
enhanced-resolve@5.18.4:
dependencies:
graceful-fs: 4.2.11
@@ -3283,12 +3399,20 @@ snapshots:
stream-combiner: 0.0.4
through: 2.3.8
+ events-universal@1.0.1:
+ dependencies:
+ bare-events: 2.8.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+
expect-type@1.3.0: {}
extendable-error@0.1.7: {}
fast-deep-equal@3.1.3: {}
+ fast-fifo@1.3.2: {}
+
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -3414,7 +3538,7 @@ snapshots:
import-meta-resolve@4.2.0: {}
- import-without-cache@0.2.3: {}
+ import-without-cache@0.2.4: {}
imurmurhash@0.1.4: {}
@@ -3540,6 +3664,10 @@ snapshots:
obug@2.1.1: {}
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -3665,6 +3793,11 @@ snapshots:
dependencies:
event-stream: 3.3.4
+ pump@3.0.3:
+ dependencies:
+ end-of-stream: 1.4.5
+ once: 1.4.0
+
punycode@2.3.1: {}
quansync@0.2.11: {}
@@ -3688,42 +3821,41 @@ snapshots:
reusify@1.1.0: {}
- rolldown-plugin-dts@0.18.3(@typescript/native-preview@7.0.0-dev.20251212.1)(rolldown@1.0.0-beta.53)(typescript@5.9.3):
+ rolldown-plugin-dts@0.19.1(@typescript/native-preview@7.0.0-dev.20251212.1)(rolldown@1.0.0-beta.55)(typescript@5.9.3):
dependencies:
'@babel/generator': 7.28.5
'@babel/parser': 7.28.5
'@babel/types': 7.28.5
ast-kit: 2.2.0
- birpc: 3.0.0
+ birpc: 4.0.0
dts-resolver: 2.1.3
get-tsconfig: 4.13.0
- magic-string: 0.30.21
obug: 2.1.1
- rolldown: 1.0.0-beta.53
+ rolldown: 1.0.0-beta.55
optionalDependencies:
'@typescript/native-preview': 7.0.0-dev.20251212.1
typescript: 5.9.3
transitivePeerDependencies:
- oxc-resolver
- rolldown@1.0.0-beta.53:
+ rolldown@1.0.0-beta.55:
dependencies:
- '@oxc-project/types': 0.101.0
- '@rolldown/pluginutils': 1.0.0-beta.53
+ '@oxc-project/types': 0.103.0
+ '@rolldown/pluginutils': 1.0.0-beta.55
optionalDependencies:
- '@rolldown/binding-android-arm64': 1.0.0-beta.53
- '@rolldown/binding-darwin-arm64': 1.0.0-beta.53
- '@rolldown/binding-darwin-x64': 1.0.0-beta.53
- '@rolldown/binding-freebsd-x64': 1.0.0-beta.53
- '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.53
- '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.53
- '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.53
- '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.53
- '@rolldown/binding-linux-x64-musl': 1.0.0-beta.53
- '@rolldown/binding-openharmony-arm64': 1.0.0-beta.53
- '@rolldown/binding-wasm32-wasi': 1.0.0-beta.53
- '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.53
- '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.53
+ '@rolldown/binding-android-arm64': 1.0.0-beta.55
+ '@rolldown/binding-darwin-arm64': 1.0.0-beta.55
+ '@rolldown/binding-darwin-x64': 1.0.0-beta.55
+ '@rolldown/binding-freebsd-x64': 1.0.0-beta.55
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.55
+ '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.55
+ '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.55
+ '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.55
+ '@rolldown/binding-linux-x64-musl': 1.0.0-beta.55
+ '@rolldown/binding-openharmony-arm64': 1.0.0-beta.55
+ '@rolldown/binding-wasm32-wasi': 1.0.0-beta.55
+ '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.55
+ '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.55
rollup@4.53.3:
dependencies:
@@ -3816,6 +3948,15 @@ snapshots:
dependencies:
duplexer: 0.1.2
+ streamx@2.23.0:
+ dependencies:
+ events-universal: 1.0.1
+ fast-fifo: 1.3.2
+ text-decoder: 1.2.3
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@@ -3873,8 +4014,35 @@ snapshots:
tapable@2.3.0: {}
+ tar-fs@3.1.1:
+ dependencies:
+ pump: 3.0.3
+ tar-stream: 3.1.7
+ optionalDependencies:
+ bare-fs: 4.5.2
+ bare-path: 3.0.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+
+ tar-stream@3.1.7:
+ dependencies:
+ b4a: 1.7.3
+ fast-fifo: 1.3.2
+ streamx: 2.23.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
term-size@2.2.1: {}
+ text-decoder@1.2.3:
+ dependencies:
+ b4a: 1.7.3
+ transitivePeerDependencies:
+ - react-native-b4a
+
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@@ -3922,23 +4090,24 @@ snapshots:
'@ts-morph/common': 0.25.0
code-block-writer: 13.0.3
- tsdown@0.17.3(@typescript/native-preview@7.0.0-dev.20251212.1)(synckit@0.11.11)(typescript@5.9.3):
+ tsdown@0.18.1(@typescript/native-preview@7.0.0-dev.20251212.1)(synckit@0.11.11)(typescript@5.9.3):
dependencies:
ansis: 4.2.0
cac: 6.7.14
defu: 6.1.4
empathic: 2.0.0
hookable: 5.5.3
- import-without-cache: 0.2.3
+ import-without-cache: 0.2.4
obug: 2.1.1
- rolldown: 1.0.0-beta.53
- rolldown-plugin-dts: 0.18.3(@typescript/native-preview@7.0.0-dev.20251212.1)(rolldown@1.0.0-beta.53)(typescript@5.9.3)
+ picomatch: 4.0.3
+ rolldown: 1.0.0-beta.55
+ rolldown-plugin-dts: 0.19.1(@typescript/native-preview@7.0.0-dev.20251212.1)(rolldown@1.0.0-beta.55)(typescript@5.9.3)
semver: 7.7.3
tinyexec: 1.0.2
tinyglobby: 0.2.15
tree-kill: 1.2.2
unconfig-core: 7.4.2
- unrun: 0.2.19(synckit@0.11.11)
+ unrun: 0.2.20(synckit@0.11.11)
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
@@ -3975,14 +4144,11 @@ snapshots:
undici-types@6.21.0: {}
- undici-types@7.16.0:
- optional: true
-
universalify@0.1.2: {}
- unrun@0.2.19(synckit@0.11.11):
+ unrun@0.2.20(synckit@0.11.11):
dependencies:
- rolldown: 1.0.0-beta.53
+ rolldown: 1.0.0-beta.55
optionalDependencies:
synckit: 0.11.11
@@ -4010,20 +4176,6 @@ snapshots:
jiti: 2.6.1
yaml: 2.8.2
- vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(yaml@2.8.2):
- dependencies:
- esbuild: 0.25.12
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
- postcss: 8.5.6
- rollup: 4.53.3
- tinyglobby: 0.2.15
- optionalDependencies:
- '@types/node': 25.0.1
- fsevents: 2.3.3
- jiti: 2.6.1
- yaml: 2.8.2
-
vitest@4.0.15(@types/node@20.19.26)(@vitest/ui@4.0.15)(jiti@2.6.1)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.15
@@ -4062,44 +4214,6 @@ snapshots:
- tsx
- yaml
- vitest@4.0.15(@types/node@25.0.1)(@vitest/ui@4.0.15)(jiti@2.6.1)(yaml@2.8.2):
- dependencies:
- '@vitest/expect': 4.0.15
- '@vitest/mocker': 4.0.15(vite@7.2.7(@types/node@20.19.26)(jiti@2.6.1)(yaml@2.8.2))
- '@vitest/pretty-format': 4.0.15
- '@vitest/runner': 4.0.15
- '@vitest/snapshot': 4.0.15
- '@vitest/spy': 4.0.15
- '@vitest/utils': 4.0.15
- es-module-lexer: 1.7.0
- expect-type: 1.3.0
- magic-string: 0.30.21
- obug: 2.1.1
- pathe: 2.0.3
- picomatch: 4.0.3
- std-env: 3.10.0
- tinybench: 2.9.0
- tinyexec: 1.0.2
- tinyglobby: 0.2.15
- tinyrainbow: 3.0.3
- vite: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(yaml@2.8.2)
- why-is-node-running: 2.3.0
- optionalDependencies:
- '@types/node': 25.0.1
- '@vitest/ui': 4.0.15(vitest@4.0.15)
- transitivePeerDependencies:
- - jiti
- - less
- - lightningcss
- - msw
- - sass
- - sass-embedded
- - stylus
- - sugarss
- - terser
- - tsx
- - yaml
-
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
@@ -4118,6 +4232,8 @@ snapshots:
word-wrap@1.2.5: {}
+ wrappy@1.0.2: {}
+
yaml@1.10.2: {}
yaml@2.8.2: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 6e0eb8cb..3536d76e 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,6 +1,5 @@
packages:
- 'packages/*'
- - 'community-addon-template'
- '!.test-tmp/**'
onlyBuiltDependencies:
diff --git a/tsdown.config.ts b/tsdown.config.ts
index 505720a6..c19c2c10 100644
--- a/tsdown.config.ts
+++ b/tsdown.config.ts
@@ -1,34 +1,16 @@
import { buildTemplates } from './packages/sv/lib/create/scripts/build-templates.js';
-import MagicString from 'magic-string';
-import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { defineConfig } from 'tsdown';
export default defineConfig({
- cwd: 'packages/sv',
- entry: ['lib/index.ts', 'lib/testing.ts', 'bin.ts', 'lib/core/index.ts'],
+ cwd: path.resolve('packages/sv'),
+ entry: ['lib/index.ts', 'lib/testing.ts', 'lib/core.ts', 'bin.ts'],
sourcemap: !process.env.CI,
dts: {
oxc: true
},
- plugins: [
- {
- name: 'evaluate-community-addon-ids',
- transform: {
- filter: { id: /_config[/\\]community\.ts$/ },
- handler(code, id) {
- const ms = new MagicString(code, { filename: id });
- const start = code.indexOf('export const communityAddonIds');
- const end = code.indexOf(';', start);
- const ids = fs.readdirSync('community-addons').map((p) => path.parse(p).name);
- const generated = `export const communityAddonIds = ${JSON.stringify(ids)}`;
- ms.overwrite(start, end, generated);
- return { code: ms.toString(), map: ms.generateMap() };
- }
- }
- }
- ],
+ plugins: [],
inputOptions: {
experimental: {
resolveNewUrlToAsset: false
diff --git a/vitest.config.ts b/vitest.config.ts
index 5be35e58..be8291ca 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,8 +7,7 @@ export default defineConfig({
'packages/sv/lib/cli/vitest.config.ts',
'packages/sv/lib/addons/vitest.config.ts',
'packages/sv/lib/create/vitest.config.ts',
- 'packages/sv/lib/core/vitest.config.ts',
- 'community-addon-template'
+ 'packages/sv/lib/core/vitest.config.ts'
]
}
});