From fdcf48f47fc382777af5c9d8fb15891039660c53 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:47:00 +0800 Subject: [PATCH 01/21] drop in replacement --- packages/sv/src/addons/tests/better-auth/test.ts | 6 +++--- packages/sv/src/addons/tests/drizzle/test.ts | 12 ++++++------ packages/sv/src/addons/tests/eslint/test.ts | 8 ++++---- packages/sv/src/addons/tests/prettier/test.ts | 8 ++++---- packages/sv/src/cli/check.ts | 6 +++--- packages/sv/src/cli/migrate.ts | 5 +++-- packages/sv/src/core/formatFiles.ts | 2 +- packages/sv/src/core/verifiers.ts | 9 ++++----- packages/sv/src/create/tests/check.ts | 12 ++++-------- packages/sv/src/testing.ts | 8 +++++--- 10 files changed, 37 insertions(+), 39 deletions(-) diff --git a/packages/sv/src/addons/tests/better-auth/test.ts b/packages/sv/src/addons/tests/better-auth/test.ts index 4954877ad..4be8b5c3e 100644 --- a/packages/sv/src/addons/tests/better-auth/test.ts +++ b/packages/sv/src/addons/tests/better-auth/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; -import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { execSync } from 'tinyexec'; import betterAuth from '../../better-auth.ts'; import drizzle from '../../drizzle.ts'; import { setupTest } from '../_setup/suite.ts'; @@ -37,7 +37,7 @@ test.concurrent.for(testCases)('better-auth $variant', async (testCase, { page, fs.writeFileSync(envPath, envContent, 'utf8'); // Generate auth schema using better-auth CLI - execSync('npm run auth:schema', { cwd, stdio: 'pipe' }); + execSync('npm', ['run', 'auth:schema'], { nodeOptions: { cwd } }); // Verify schema has auth tables const schemaPath = path.resolve(cwd, `src/lib/server/db/schema.${language}`); @@ -46,7 +46,7 @@ test.concurrent.for(testCases)('better-auth $variant', async (testCase, { page, expect(schemaContent).toContain('./auth.schema'); // Push schema to DB - execSync('npm run db:push -- --force', { cwd, stdio: 'pipe' }); + execSync('npm', ['run', 'db:push', '--', '--force'], { nodeOptions: { cwd } }); /** ----- BROWSER SECTION ----- */ const { url, close } = await prepareServer({ cwd, page }); diff --git a/packages/sv/src/addons/tests/drizzle/test.ts b/packages/sv/src/addons/tests/drizzle/test.ts index 4ba45200c..258a5bcb8 100644 --- a/packages/sv/src/addons/tests/drizzle/test.ts +++ b/packages/sv/src/addons/tests/drizzle/test.ts @@ -1,4 +1,4 @@ -import { execSync } from 'node:child_process'; +import { execSync } from 'tinyexec'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; @@ -42,21 +42,21 @@ beforeAll(() => { const cwd = path.dirname(fileURLToPath(import.meta.url)); try { - execSync('docker --version', { cwd, stdio: 'pipe' }); + execSync('docker', ['--version'], { nodeOptions: { cwd } }); dockerInstalled = true; } catch { dockerInstalled = false; } - if (dockerInstalled) execSync('docker compose up --detach', { cwd, stdio: 'pipe' }); + if (dockerInstalled) execSync('docker', ['compose', 'up', '--detach'], { nodeOptions: { cwd } }); // cleans up the containers on interrupts (ctrl+c) process.addListener('SIGINT', () => { - if (dockerInstalled) execSync('docker compose down --volumes', { cwd, stdio: 'pipe' }); + if (dockerInstalled) execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); }); return () => { - if (dockerInstalled) execSync('docker compose down --volumes', { cwd, stdio: 'pipe' }); + if (dockerInstalled) execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); }; }); @@ -92,7 +92,7 @@ test.concurrent.for(testCases)( const pageServerPath = path.resolve(routes, `+page.server.${ts ? 'ts' : 'js'}`); fs.writeFileSync(pageServerPath, pageServer, 'utf8'); - execSync('npm run db:push', { cwd, stdio: 'pipe' }); + execSync('npm', ['run', 'db:push'], { nodeOptions: { cwd } }); const { close } = await prepareServer({ cwd, page }); // kill server process when we're done diff --git a/packages/sv/src/addons/tests/eslint/test.ts b/packages/sv/src/addons/tests/eslint/test.ts index c458f5b3c..7dda9a5a0 100644 --- a/packages/sv/src/addons/tests/eslint/test.ts +++ b/packages/sv/src/addons/tests/eslint/test.ts @@ -1,6 +1,6 @@ -import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { execSync } from 'tinyexec'; import eslint from '../../eslint.ts'; import { setupTest } from '../_setup/suite.ts'; @@ -15,9 +15,9 @@ test.concurrent.for(testCases)('eslint $variant', (testCase, { expect, ...ctx }) const unlintedFile = 'let foo = "";\nif (Boolean(foo)) {\n//\n}'; fs.writeFileSync(path.resolve(cwd, 'src/lib/foo.js'), unlintedFile, 'utf8'); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).toThrow(); + expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).toThrow(); - expect(() => execSync('pnpm eslint --fix .', { cwd, stdio: 'pipe' })).not.toThrow(); + expect(() => execSync('pnpm', ['eslint', '--fix', '.'], { nodeOptions: { cwd } })).not.toThrow(); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).not.toThrow(); + expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).not.toThrow(); }); diff --git a/packages/sv/src/addons/tests/prettier/test.ts b/packages/sv/src/addons/tests/prettier/test.ts index 9af677bcc..096b0776e 100644 --- a/packages/sv/src/addons/tests/prettier/test.ts +++ b/packages/sv/src/addons/tests/prettier/test.ts @@ -1,7 +1,7 @@ import { log } from '@clack/prompts'; -import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { execSync } from 'tinyexec'; import { vi } from 'vitest'; import { ESLINT_VERSION } from '../../common.ts'; import prettier from '../../prettier.ts'; @@ -48,11 +48,11 @@ test.concurrent.for(testCases)('prettier $kind.type $variant', (testCase, { expe const unformattedFile = 'const foo = "bar"'; fs.writeFileSync(path.resolve(cwd, 'src/lib/foo.js'), unformattedFile, 'utf8'); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).toThrow(); + expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).toThrow(); - expect(() => execSync('pnpm format', { cwd, stdio: 'pipe' })).not.toThrow(); + expect(() => execSync('pnpm', ['format'], { nodeOptions: { cwd } })).not.toThrow(); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).not.toThrow(); + expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).not.toThrow(); } else if (testCase.kind.type === 'supported-eslint') { expect(fs.existsSync(path.resolve(cwd, 'eslint.config.js'))).toBe(true); diff --git a/packages/sv/src/cli/check.ts b/packages/sv/src/cli/check.ts index fd5f2dfa0..721470dd9 100644 --- a/packages/sv/src/cli/check.ts +++ b/packages/sv/src/cli/check.ts @@ -1,8 +1,8 @@ import { color, resolveCommandArray } from '@sveltejs/sv-utils'; import { Command } from 'commander'; import * as resolve from 'empathic/resolve'; -import { execSync } from 'node:child_process'; import process from 'node:process'; +import { execSync } from 'tinyexec'; import { forwardExitCode } from '../core/common.ts'; import { detectPackageManager } from '../core/package-manager.ts'; @@ -39,8 +39,8 @@ async function runCheck(cwd: string, args: string[]) { // avoids printing the stack trace for `sv` when `svelte-check` exits with an error code try { - const cmd = resolveCommandArray(pm, 'execute-local', ['svelte-check', ...args]).join(' '); - execSync(cmd, { stdio: 'inherit', cwd }); + const [cmd, ...cmdArgs] = resolveCommandArray(pm, 'execute-local', ['svelte-check', ...args]); + execSync(cmd, cmdArgs, { nodeOptions: { cwd, stdio: 'inherit' } }); } catch (error) { forwardExitCode(error); } finally { diff --git a/packages/sv/src/cli/migrate.ts b/packages/sv/src/cli/migrate.ts index 22886dc60..8545af19c 100644 --- a/packages/sv/src/cli/migrate.ts +++ b/packages/sv/src/cli/migrate.ts @@ -1,7 +1,7 @@ import { resolveCommandArray } from '@sveltejs/sv-utils'; import { Command } from 'commander'; -import { execSync } from 'node:child_process'; import process from 'node:process'; +import { execSync } from 'tinyexec'; import { forwardExitCode } from '../core/common.ts'; import { detectPackageManager } from '../core/package-manager.ts'; @@ -23,7 +23,8 @@ async function runMigrate(cwd: string, args: string[]) { // skips the download confirmation prompt for `npx` if (pm === 'npm') cmdArgs.unshift('--yes'); - execSync(resolveCommandArray(pm, 'execute', cmdArgs).join(' '), { stdio: 'inherit', cwd }); + const cmd = resolveCommand(pm, 'execute', cmdArgs)!; + execSync(cmd.command, cmd.args, { nodeOptions: { cwd, stdio: 'inherit' } }); } catch (error) { forwardExitCode(error); } diff --git a/packages/sv/src/core/formatFiles.ts b/packages/sv/src/core/formatFiles.ts index 932fe8024..d19ca06a4 100644 --- a/packages/sv/src/core/formatFiles.ts +++ b/packages/sv/src/core/formatFiles.ts @@ -36,7 +36,7 @@ async function run( cwd: string ): Promise<{ error?: string; notFound?: boolean }> { try { - await exec(command, args, { nodeOptions: { cwd, stdio: 'pipe' }, throwOnError: true }); + await exec(command, args, { nodeOptions: { cwd }, throwOnError: true }); return {}; } catch (e) { // @ts-expect-error tinyexec rethrows the spawn error as-is diff --git a/packages/sv/src/core/verifiers.ts b/packages/sv/src/core/verifiers.ts index 884ef20de..1fdc5a425 100644 --- a/packages/sv/src/core/verifiers.ts +++ b/packages/sv/src/core/verifiers.ts @@ -1,5 +1,4 @@ -import { exec } from 'node:child_process'; -import { promisify } from 'node:util'; +import { exec } from 'tinyexec'; import type { AddonDefinition, SetupResult, Verification } from './config.ts'; import { UnsupportedError } from './errors.ts'; @@ -17,9 +16,9 @@ export function verifyCleanWorkingDirectory(cwd: string, gitCheck: boolean) { // there are no pending changes. If the below command is run outside of a git repository, // git will exit with a failing exit code, which will trigger the catch statement. // also see https://remarkablemark.org/blog/2017/10/12/check-git-dirty/#git-status - const asyncExec = promisify(exec); - const { stdout } = await asyncExec('git status --short', { - cwd + const { stdout } = await exec('git', ['status', '--short'], { + nodeOptions: { cwd }, + throwOnError: true }); if (stdout) { diff --git a/packages/sv/src/create/tests/check.ts b/packages/sv/src/create/tests/check.ts index 9aa03af48..7c9eac824 100644 --- a/packages/sv/src/create/tests/check.ts +++ b/packages/sv/src/create/tests/check.ts @@ -1,9 +1,7 @@ -import { type PromiseWithChild, exec as nodeExec } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; -import { exec } from 'tinyexec'; +import { exec, type Result } from 'tinyexec'; import { beforeAll, describe, expect, test } from 'vitest'; import { add, officialAddons } from '../../../../sv/src/index.ts'; import { createProject } from '../../cli/create.ts'; @@ -21,11 +19,9 @@ fs.mkdirSync(test_workspace_dir, { recursive: true }); fs.writeFileSync(path.join(test_workspace_dir, 'pnpm-workspace.yaml'), 'packages:\n - ./*\n'); -const exec_async = promisify(nodeExec); - beforeAll(async () => { const install = await exec('pnpm', ['install', '--no-frozen-lockfile'], { - nodeOptions: { cwd: test_workspace_dir, stdio: 'pipe' } + nodeOptions: { cwd: test_workspace_dir } }); if (install.exitCode !== 0) { throw new Error( @@ -38,7 +34,7 @@ beforeAll(async () => { * Tests in different templates can be run concurrently for a nice speedup locally, but tests within a template must be run sequentially. * It'd be better to group tests by template, but vitest doesn't support that yet. */ -const script_test_map = new Map PromiseWithChild]>>(); +const script_test_map = new Map Result]>>(); const templates = fs.readdirSync(resolve_path('../templates/')) as TemplateType[]; @@ -85,7 +81,7 @@ for (const template of templates.filter((t) => t !== 'addon')) { for (const script of scripts_to_test) { const tests = script_test_map.get(script) ?? []; - tests.push([`${template}-${types}`, () => exec_async(`pnpm ${script}`, { cwd })]); + tests.push([`${template}-${types}`, () => exec('pnpm', [script], { nodeOptions: { cwd } })]); script_test_map.set(script, tests); } diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index 5301b2516..74712e4a0 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -1,10 +1,9 @@ import type { Page } from '@playwright/test'; -import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import pstree, { type PS } from 'ps-tree'; -import { exec, x } from 'tinyexec'; +import { exec, execSync, x } from 'tinyexec'; import type { TestProject } from 'vitest/node'; import { add, type AddonMap, type OptionMap } from './core/engine.ts'; import { addPnpmAllowBuilds } from './core/package-manager.ts'; @@ -228,7 +227,10 @@ export async function prepareServer({ expect }: PrepareServerOptions): Promise { // build project - if (buildCommand) execSync(buildCommand, { cwd, stdio: 'pipe' }); + if (buildCommand) { + const [cmd, ...args] = buildCommand.split(' '); + execSync(cmd, args, { nodeOptions: { cwd }, throwOnError: true }); + } // start preview server const { url, close } = await startPreview({ cwd, command: previewCommand }); From c791d830430449b1d7a7fb6ae2117587a5212d81 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:48:01 +0800 Subject: [PATCH 02/21] add run helper that defaults cwd to testOutputPath --- packages/sv/src/cli/tests/cli.ts | 37 ++++++++++++++------------------ 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 0bac5f647..6b776988e 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -96,9 +96,15 @@ describe('cli', () => { ...args ]; + /** + * Same as `exec`. Defaults `cwd` to `testOutputPath` + */ + const run = (...args: Parameters) => + exec(args[0], args[1], { nodeOptions: { cwd: testOutputPath }, ...args[2] }); + // useful for debugging // console.log(`command`, `node ${allArgs.join(' ')}`); - const result = await exec('node', allArgs, { nodeOptions: { stdio: 'pipe' } }); + const result = await exec('node', allArgs); // cli finished well expect( @@ -199,24 +205,18 @@ describe('cli', () => { if (projectName === 'create-with-all-addons' && process.platform !== 'win32') { // the generated project lives inside this repo, so it must not join its workspace - const installResult = await exec( - 'pnpm', - ['install', '--no-frozen-lockfile', '--ignore-workspace'], - { nodeOptions: { stdio: 'pipe', cwd: testOutputPath } } - ); + const installResult = await run('pnpm', [ + 'install', + '--no-frozen-lockfile', + '--ignore-workspace' + ]); expect( installResult.exitCode, `pnpm install failed:\n stdout: ${installResult.stdout}\n stderr: ${installResult.stderr}` ).toBe(0); - await exec('pnpm', ['build'], { - nodeOptions: { stdio: 'pipe', cwd: testOutputPath } - }); - await exec('pnpm', ['auth:schema'], { - nodeOptions: { stdio: 'pipe', cwd: testOutputPath } - }); - const check = await exec('pnpm', ['check'], { - nodeOptions: { stdio: 'pipe', cwd: testOutputPath } - }); + await run('pnpm', ['build']); + await run('pnpm', ['auth:schema']); + const check = await run('pnpm', ['check']); expect( check.exitCode, `svelte-check failed:\n stdout: ${check.stdout}\n stderr: ${check.stderr}` @@ -225,9 +225,6 @@ describe('cli', () => { // `kit@next` moves fast - only a real install/build/check catches options it removed if (projectName === 'create-experimental-next' && process.platform !== 'win32') { - const run = (cmd: string, cmdArgs: string[]) => - exec(cmd, cmdArgs, { nodeOptions: { stdio: 'pipe', cwd: testOutputPath } }); - const install = await run('pnpm', [ 'install', '--no-frozen-lockfile', @@ -287,10 +284,8 @@ describe('cli', () => { for (const cmd of cmds) { // use npm here so the install doesn't walk up into the monorepo's // pnpm workspace and try to resolve packages from there - const res = await exec('npm', cmd, { + const res = await run('npm', cmd, { nodeOptions: { - stdio: 'pipe', - cwd: testOutputPath, env: { ...process.env, // allow npm under a repo whose packageManager is pnpm From 2115be448097d82e9262f3904653738b204ec653 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:49:35 +0800 Subject: [PATCH 03/21] refactor out `constructCommand` --- packages/sv/src/core/package-manager.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/sv/src/core/package-manager.ts b/packages/sv/src/core/package-manager.ts index fece9f764..5c1fcc28d 100644 --- a/packages/sv/src/core/package-manager.ts +++ b/packages/sv/src/core/package-manager.ts @@ -1,13 +1,5 @@ import * as p from '@clack/prompts'; -import { - AGENTS, - type AgentName, - COMMANDS, - color, - constructCommand, - detect, - pnpm -} from '@sveltejs/sv-utils'; +import { AGENTS, type AgentName, color, detect, pnpm, resolveCommand } from '@sveltejs/sv-utils'; import { Option } from 'commander'; import * as find from 'empathic/find'; import fs from 'node:fs'; @@ -72,12 +64,9 @@ export async function installDependencies(agent: AgentName, cwd: string): Promis retainLog: true }); - const { command, args } = constructCommand(COMMANDS[agent].install, [])!; + const { command, args } = resolveCommand(agent, 'install', [])!; - const proc = exec(command, args, { - nodeOptions: { cwd, stdio: 'pipe' }, - throwOnError: false - }); + const proc = exec(command, args, { nodeOptions: { cwd }, throwOnError: false }); const output: string[] = []; try { From 1fc25b7cc33658633dc2e10c534c9af9a39e28d7 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 19:00:03 +0800 Subject: [PATCH 04/21] refactor to no throw --- packages/sv/src/core/verifiers.ts | 36 +++++++++++-------------------- 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/packages/sv/src/core/verifiers.ts b/packages/sv/src/core/verifiers.ts index 1fdc5a425..197503cda 100644 --- a/packages/sv/src/core/verifiers.ts +++ b/packages/sv/src/core/verifiers.ts @@ -9,26 +9,17 @@ export function verifyCleanWorkingDirectory(cwd: string, gitCheck: boolean) { verifications.push({ name: 'clean working directory', run: async () => { - try { - // If a user has pending git changes the output of the following command will list - // all files that have been added/modified/deleted and thus the output will not be empty. - // In case the output of the command below is an empty text, we can safely assume - // there are no pending changes. If the below command is run outside of a git repository, - // git will exit with a failing exit code, which will trigger the catch statement. - // also see https://remarkablemark.org/blog/2017/10/12/check-git-dirty/#git-status - const { stdout } = await exec('git', ['status', '--short'], { - nodeOptions: { cwd }, - throwOnError: true - }); - - if (stdout) { - return { success: false, message: 'Uncommited changes found' }; - } - - return { success: true, message: undefined }; - } catch { - return { success: true, message: 'Not a git repository' }; - } + // If a user has pending git changes the output of the following command will list + // all files that have been added/modified/deleted and thus the output will not be empty. + // In case the output of the command below is an empty text, we can safely assume + // there are no pending changes. If the below command is run outside of a git repository, + // git will exit with a failing exit code, which will trigger the catch statement. + // also see https://remarkablemark.org/blog/2017/10/12/check-git-dirty/#git-status + const result = await exec('git', ['status', '--short'], { nodeOptions: { cwd } }); + + if (result.exitCode !== 0) return { success: true, message: 'Not a git repository' }; + if (result.stdout) return { success: false, message: 'Uncommited changes found' }; + return { success: true, message: undefined }; } }); } @@ -49,10 +40,7 @@ export function verifyUnsupportedAddons( setupResults[a.id].unsupported.map((reason) => ({ id: a.id, reason })) ); - if (reasons.length === 0) { - return { success: true, message: undefined }; - } - + if (reasons.length === 0) return { success: true, message: undefined }; throw new UnsupportedError(reasons); } }); From 21bef9910ee42fd6909c5ff8eaa0ddc5d949d445 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 19:08:32 +0800 Subject: [PATCH 05/21] `tinyexec` doesn't throw by default --- packages/sv/src/addons/tests/eslint/test.ts | 15 ++++++++++++--- packages/sv/src/addons/tests/prettier/test.ts | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/sv/src/addons/tests/eslint/test.ts b/packages/sv/src/addons/tests/eslint/test.ts index 7dda9a5a0..43c92c831 100644 --- a/packages/sv/src/addons/tests/eslint/test.ts +++ b/packages/sv/src/addons/tests/eslint/test.ts @@ -15,9 +15,18 @@ test.concurrent.for(testCases)('eslint $variant', (testCase, { expect, ...ctx }) const unlintedFile = 'let foo = "";\nif (Boolean(foo)) {\n//\n}'; fs.writeFileSync(path.resolve(cwd, 'src/lib/foo.js'), unlintedFile, 'utf8'); - expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should fail on unlinted file' + ).not.toBe(0); - expect(() => execSync('pnpm', ['eslint', '--fix', '.'], { nodeOptions: { cwd } })).not.toThrow(); + expect( + execSync('pnpm', ['eslint', '--fix', '.'], { nodeOptions: { cwd } }).exitCode, + 'eslint --fix should succeed' + ).toBe(0); - expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).not.toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should pass after fix' + ).toBe(0); }); diff --git a/packages/sv/src/addons/tests/prettier/test.ts b/packages/sv/src/addons/tests/prettier/test.ts index 096b0776e..ae0379f97 100644 --- a/packages/sv/src/addons/tests/prettier/test.ts +++ b/packages/sv/src/addons/tests/prettier/test.ts @@ -48,11 +48,20 @@ test.concurrent.for(testCases)('prettier $kind.type $variant', (testCase, { expe const unformattedFile = 'const foo = "bar"'; fs.writeFileSync(path.resolve(cwd, 'src/lib/foo.js'), unformattedFile, 'utf8'); - expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should fail on unformatted file' + ).not.toBe(0); - expect(() => execSync('pnpm', ['format'], { nodeOptions: { cwd } })).not.toThrow(); + expect( + execSync('pnpm', ['format'], { nodeOptions: { cwd } }).exitCode, + 'format should succeed' + ).toBe(0); - expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).not.toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should pass after format' + ).toBe(0); } else if (testCase.kind.type === 'supported-eslint') { expect(fs.existsSync(path.resolve(cwd, 'eslint.config.js'))).toBe(true); From d7d20f9b35f55ae376619271898733ec3d29b23f Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:52:31 +0800 Subject: [PATCH 06/21] clean up --- packages/sv/src/cli/check.ts | 6 +++--- packages/sv/src/cli/migrate.ts | 18 +++++++++--------- packages/sv/src/core/engine.ts | 8 ++++---- packages/sv/src/testing.ts | 14 ++++---------- 4 files changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/sv/src/cli/check.ts b/packages/sv/src/cli/check.ts index 721470dd9..0df455a6e 100644 --- a/packages/sv/src/cli/check.ts +++ b/packages/sv/src/cli/check.ts @@ -1,4 +1,4 @@ -import { color, resolveCommandArray } from '@sveltejs/sv-utils'; +import { color, resolveCommand, resolveCommandArray } from '@sveltejs/sv-utils'; import { Command } from 'commander'; import * as resolve from 'empathic/resolve'; import process from 'node:process'; @@ -39,8 +39,8 @@ async function runCheck(cwd: string, args: string[]) { // avoids printing the stack trace for `sv` when `svelte-check` exits with an error code try { - const [cmd, ...cmdArgs] = resolveCommandArray(pm, 'execute-local', ['svelte-check', ...args]); - execSync(cmd, cmdArgs, { nodeOptions: { cwd, stdio: 'inherit' } }); + const cmd = resolveCommand(pm, 'execute-local', ['svelte-check', ...args])!; + execSync(cmd.command, cmd.args, { nodeOptions: { cwd, stdio: 'inherit' } }); } catch (error) { forwardExitCode(error); } finally { diff --git a/packages/sv/src/cli/migrate.ts b/packages/sv/src/cli/migrate.ts index 8545af19c..b62ef7896 100644 --- a/packages/sv/src/cli/migrate.ts +++ b/packages/sv/src/cli/migrate.ts @@ -1,4 +1,4 @@ -import { resolveCommandArray } from '@sveltejs/sv-utils'; +import { resolveCommand } from '@sveltejs/sv-utils'; import { Command } from 'commander'; import process from 'node:process'; import { execSync } from 'tinyexec'; @@ -9,21 +9,21 @@ export const migrate = new Command('migrate') .description('a CLI for migrating Svelte(Kit) codebases') .argument('[migration]', 'migration to run') .option('-C, --cwd ', 'path to working directory', process.cwd()) - .action(async (migration, options) => { - await runMigrate(options.cwd, [migration]); - }); + .action((migration, options) => runMigrate(options.cwd, [migration])); async function runMigrate(cwd: string, args: string[]) { const pm = await detectPackageManager(cwd); // avoids printing the stack trace for `sv` when `svelte-migrate` exits with an error code try { - const cmdArgs = ['svelte-migrate@latest', ...args]; + const newArgs = [ + // skips the download confirmation prompt for `npx` + ...(pm === 'npm' ? '--yes' : ''), + 'svelte-migrate@latest', + ...args + ]; - // skips the download confirmation prompt for `npx` - if (pm === 'npm') cmdArgs.unshift('--yes'); - - const cmd = resolveCommand(pm, 'execute', cmdArgs)!; + const cmd = resolveCommand(pm, 'execute', newArgs)!; execSync(cmd.command, cmd.args, { nodeOptions: { cwd, stdio: 'inherit' } }); } catch (error) { forwardExitCode(error); diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index 8540c56ba..6c910b073 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -250,10 +250,10 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } } }, execute: async (commandArgs, stdio) => { - const { command, args } = resolveCommand(workspace.packageManager, 'execute', commandArgs)!; + const cmd = resolveCommand(workspace.packageManager, 'execute', commandArgs)!; const addonPrefix = multiple ? `${addon.id}: ` : ''; - const executedCommand = [command, ...args].join(' '); + const executedCommand = [cmd.command, ...cmd.args].join(' '); if (!TESTING) { p.log.step( `${addonPrefix}Running external command ${color.optional(`(${executedCommand})`)}` @@ -261,10 +261,10 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } } // adding --yes as the first parameter helps avoiding the "Need to install the following packages:" message - if (workspace.packageManager === 'npm') args.unshift('--yes'); + if (workspace.packageManager === 'npm') cmd.args.unshift('--yes'); try { - await exec(command, args, { + await exec(cmd.command, cmd.args, { nodeOptions: { cwd: workspace.cwd, stdio: TESTING ? 'pipe' : stdio }, throwOnError: true }); diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index 74712e4a0..72fefe127 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import pstree, { type PS } from 'ps-tree'; -import { exec, execSync, x } from 'tinyexec'; +import { exec, execSync } from 'tinyexec'; import type { TestProject } from 'vitest/node'; import { add, type AddonMap, type OptionMap } from './core/engine.ts'; import { addPnpmAllowBuilds } from './core/package-manager.ts'; @@ -81,11 +81,7 @@ async function startPreview({ command = 'npm run preview' }: PreviewOptions): Promise<{ url: string; close: () => Promise }> { const [cmd, ...args] = command.split(' '); - const proc = exec(cmd, args, { - nodeOptions: { cwd, stdio: 'pipe' }, - throwOnError: true, - timeout: 66_999 - }); + const proc = exec(cmd, args, { nodeOptions: { cwd }, throwOnError: true, timeout: 66_999 }); const close = async () => { if (!proc.pid) return; @@ -128,7 +124,7 @@ async function getProcessTree(pid: number) { async function terminate(pid: number) { if (process.platform === 'win32') { // on windows, use taskkill to terminate the process tree - await x('taskkill', ['/PID', `${pid}`, '/T', '/F']); + await exec('taskkill', ['/PID', `${pid}`, '/T', '/F']); return; } const children = await getProcessTree(pid); @@ -367,9 +363,7 @@ export function createSetupTest( } const installDir = path.resolve(cwd, testName); - const install = await exec('pnpm', ['install'], { - nodeOptions: { cwd: installDir, stdio: 'pipe' } - }); + const install = await exec('pnpm', ['install'], { nodeOptions: { cwd: installDir } }); if (install.exitCode !== 0) { throw new Error( `pnpm install failed in ${installDir}\n stdout: ${install.stdout}\n stderr: ${install.stderr}` From 696efaa5b4d859739d4889ec9907b567cc541434 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:53:23 +0800 Subject: [PATCH 07/21] every `exec` on this page should `throw`? --- packages/sv/src/testing.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index 72fefe127..229162e9f 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -124,7 +124,7 @@ async function getProcessTree(pid: number) { async function terminate(pid: number) { if (process.platform === 'win32') { // on windows, use taskkill to terminate the process tree - await exec('taskkill', ['/PID', `${pid}`, '/T', '/F']); + await exec('taskkill', ['/PID', `${pid}`, '/T', '/F'], { throwOnError: true }); return; } const children = await getProcessTree(pid); @@ -363,7 +363,10 @@ export function createSetupTest( } const installDir = path.resolve(cwd, testName); - const install = await exec('pnpm', ['install'], { nodeOptions: { cwd: installDir } }); + const install = await exec('pnpm', ['install'], { + nodeOptions: { cwd: installDir }, + throwOnError: true + }); if (install.exitCode !== 0) { throw new Error( `pnpm install failed in ${installDir}\n stdout: ${install.stdout}\n stderr: ${install.stderr}` From f44f36e0dbb62f54d084ed78efd10c63fbdf3996 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:53:59 +0800 Subject: [PATCH 08/21] not sure if this is a one to one refactor --- packages/sv/src/addons/tests/vitest/test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/sv/src/addons/tests/vitest/test.ts b/packages/sv/src/addons/tests/vitest/test.ts index b00440972..f299ec4d4 100644 --- a/packages/sv/src/addons/tests/vitest/test.ts +++ b/packages/sv/src/addons/tests/vitest/test.ts @@ -1,6 +1,6 @@ -import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { execSync } from 'tinyexec'; import vitest from '../../vitest-addon.ts'; import { setupTest } from '../_setup/suite.ts'; @@ -13,16 +13,17 @@ test.concurrent.for(testCases)('vitest $variant', (testCase, { expect, ...ctx }) const cwd = ctx.cwd(testCase); expect( - spawnSync('pnpm exec playwright install chromium', { - cwd, - stdio: 'pipe', - shell: true, - timeout: 2 * 60_000 - }).status + execSync('pnpm', ['exec', 'playwright', 'install', 'chromium'], { + nodeOptions: { + cwd, + timeout: 2 * 60_000, + shell: true + } + }).exitCode ).toBe(0); expect( - spawnSync('pnpm test', { cwd, stdio: 'pipe', shell: true, timeout: 2 * 60_000 }).status + execSync('pnpm', ['test'], { nodeOptions: { cwd, shell: true, timeout: 2 * 60_000 } }).exitCode ).toBe(0); const viteFile = ['vite.config.ts', 'vite.config.js'] From 390a6ea1f9b143b48e5a232246ded50089928353 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 19:15:46 +0800 Subject: [PATCH 09/21] lint --- packages/sv/src/addons/tests/drizzle/test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/sv/src/addons/tests/drizzle/test.ts b/packages/sv/src/addons/tests/drizzle/test.ts index 258a5bcb8..38e57b867 100644 --- a/packages/sv/src/addons/tests/drizzle/test.ts +++ b/packages/sv/src/addons/tests/drizzle/test.ts @@ -1,8 +1,8 @@ -import { execSync } from 'tinyexec'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { execSync } from 'tinyexec'; import { beforeAll, expect } from 'vitest'; import drizzle from '../../drizzle.ts'; import { setupTest } from '../_setup/suite.ts'; @@ -52,11 +52,13 @@ beforeAll(() => { // cleans up the containers on interrupts (ctrl+c) process.addListener('SIGINT', () => { - if (dockerInstalled) execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); + if (dockerInstalled) + execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); }); return () => { - if (dockerInstalled) execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); + if (dockerInstalled) + execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); }; }); From 78030c6c2624617ac8905aaf88bb60d59df94459 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 21:06:27 +0800 Subject: [PATCH 10/21] fix --- packages/sv/src/cli/tests/cli.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 6b776988e..ac954f09e 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -97,10 +97,15 @@ describe('cli', () => { ]; /** - * Same as `exec`. Defaults `cwd` to `testOutputPath` + * Same as `exec`. but `cwd` defaults to `testOutputPath` */ - const run = (...args: Parameters) => - exec(args[0], args[1], { nodeOptions: { cwd: testOutputPath }, ...args[2] }); + const run = (...args: Parameters) => { + const opts = args[2] ?? {}; + return exec(args[0], args[1], { + nodeOptions: { cwd: testOutputPath, ...opts.nodeOptions }, + ...opts + }); + }; // useful for debugging // console.log(`command`, `node ${allArgs.join(' ')}`); From e48f361697526a66a855c5b1756c7fd911e9350a Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sun, 2 Aug 2026 01:30:49 +0800 Subject: [PATCH 11/21] fix --- packages/sv/src/cli/tests/cli.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index ac954f09e..7f4765326 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -101,9 +101,10 @@ describe('cli', () => { */ const run = (...args: Parameters) => { const opts = args[2] ?? {}; + const { nodeOptions, ...rest } = opts; return exec(args[0], args[1], { - nodeOptions: { cwd: testOutputPath, ...opts.nodeOptions }, - ...opts + nodeOptions: { cwd: testOutputPath, ...nodeOptions }, + ...rest }); }; From 20674ce260e3554b8d63e369ba74ee982f8662d1 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sun, 2 Aug 2026 02:01:22 +0800 Subject: [PATCH 12/21] nit --- packages/sv/src/cli/tests/cli.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 7f4765326..b99c7ebc0 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -99,13 +99,11 @@ describe('cli', () => { /** * Same as `exec`. but `cwd` defaults to `testOutputPath` */ - const run = (...args: Parameters) => { - const opts = args[2] ?? {}; - const { nodeOptions, ...rest } = opts; - return exec(args[0], args[1], { - nodeOptions: { cwd: testOutputPath, ...nodeOptions }, - ...rest - }); + const run = (...params: Parameters) => { + const [command, args, options = {}] = params ?? []; + options.nodeOptions ??= {}; + options.nodeOptions.cwd ??= testOutputPath; + return exec(command, args, options); }; // useful for debugging From 764cf6ab0bf0da293c1b063cabd470850dece029 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sun, 2 Aug 2026 02:16:38 +0800 Subject: [PATCH 13/21] rerun test From ea6eb5884f9b5ad9f04513d02d7ff871c978a8c0 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sun, 2 Aug 2026 20:48:14 +0800 Subject: [PATCH 14/21] Update packages/sv/src/core/verifiers.ts Co-authored-by: CokaKoala <31664583+AdrianGonz97@users.noreply.github.com> --- packages/sv/src/core/verifiers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sv/src/core/verifiers.ts b/packages/sv/src/core/verifiers.ts index 197503cda..914058369 100644 --- a/packages/sv/src/core/verifiers.ts +++ b/packages/sv/src/core/verifiers.ts @@ -13,7 +13,7 @@ export function verifyCleanWorkingDirectory(cwd: string, gitCheck: boolean) { // all files that have been added/modified/deleted and thus the output will not be empty. // In case the output of the command below is an empty text, we can safely assume // there are no pending changes. If the below command is run outside of a git repository, - // git will exit with a failing exit code, which will trigger the catch statement. + // git will exit with a failing exit code. // also see https://remarkablemark.org/blog/2017/10/12/check-git-dirty/#git-status const result = await exec('git', ['status', '--short'], { nodeOptions: { cwd } }); From 6901b16b9beb20c00a7a4632c36b055e9ccfe8cb Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 3 Aug 2026 16:03:28 +0800 Subject: [PATCH 15/21] adjust throws --- packages/sv/src/addons/tests/better-auth/test.ts | 7 +++++-- packages/sv/src/addons/tests/drizzle/test.ts | 4 ++-- packages/sv/src/testing.ts | 5 ++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/sv/src/addons/tests/better-auth/test.ts b/packages/sv/src/addons/tests/better-auth/test.ts index 4be8b5c3e..d29f06d0e 100644 --- a/packages/sv/src/addons/tests/better-auth/test.ts +++ b/packages/sv/src/addons/tests/better-auth/test.ts @@ -37,7 +37,7 @@ test.concurrent.for(testCases)('better-auth $variant', async (testCase, { page, fs.writeFileSync(envPath, envContent, 'utf8'); // Generate auth schema using better-auth CLI - execSync('npm', ['run', 'auth:schema'], { nodeOptions: { cwd } }); + execSync('npm', ['run', 'auth:schema'], { nodeOptions: { cwd }, throwOnError: true }); // Verify schema has auth tables const schemaPath = path.resolve(cwd, `src/lib/server/db/schema.${language}`); @@ -46,7 +46,10 @@ test.concurrent.for(testCases)('better-auth $variant', async (testCase, { page, expect(schemaContent).toContain('./auth.schema'); // Push schema to DB - execSync('npm', ['run', 'db:push', '--', '--force'], { nodeOptions: { cwd } }); + execSync('npm', ['run', 'db:push', '--', '--force'], { + nodeOptions: { cwd }, + throwOnError: true + }); /** ----- BROWSER SECTION ----- */ const { url, close } = await prepareServer({ cwd, page }); diff --git a/packages/sv/src/addons/tests/drizzle/test.ts b/packages/sv/src/addons/tests/drizzle/test.ts index 38e57b867..2c374adf1 100644 --- a/packages/sv/src/addons/tests/drizzle/test.ts +++ b/packages/sv/src/addons/tests/drizzle/test.ts @@ -42,7 +42,7 @@ beforeAll(() => { const cwd = path.dirname(fileURLToPath(import.meta.url)); try { - execSync('docker', ['--version'], { nodeOptions: { cwd } }); + execSync('docker', ['--version'], { nodeOptions: { cwd }, throwOnError: true }); dockerInstalled = true; } catch { dockerInstalled = false; @@ -94,7 +94,7 @@ test.concurrent.for(testCases)( const pageServerPath = path.resolve(routes, `+page.server.${ts ? 'ts' : 'js'}`); fs.writeFileSync(pageServerPath, pageServer, 'utf8'); - execSync('npm', ['run', 'db:push'], { nodeOptions: { cwd } }); + execSync('npm', ['run', 'db:push'], { nodeOptions: { cwd }, throwOnError: true }); const { close } = await prepareServer({ cwd, page }); // kill server process when we're done diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index 229162e9f..18a835461 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -124,7 +124,7 @@ async function getProcessTree(pid: number) { async function terminate(pid: number) { if (process.platform === 'win32') { // on windows, use taskkill to terminate the process tree - await exec('taskkill', ['/PID', `${pid}`, '/T', '/F'], { throwOnError: true }); + await exec('taskkill', ['/PID', `${pid}`, '/T', '/F']); return; } const children = await getProcessTree(pid); @@ -364,8 +364,7 @@ export function createSetupTest( const installDir = path.resolve(cwd, testName); const install = await exec('pnpm', ['install'], { - nodeOptions: { cwd: installDir }, - throwOnError: true + nodeOptions: { cwd: installDir } }); if (install.exitCode !== 0) { throw new Error( From 584df66438b4fe780366ab567909409e73d1417b Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 3 Aug 2026 16:03:32 +0800 Subject: [PATCH 16/21] lint --- packages/sv/src/core/verifiers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/sv/src/core/verifiers.ts b/packages/sv/src/core/verifiers.ts index 914058369..465a26566 100644 --- a/packages/sv/src/core/verifiers.ts +++ b/packages/sv/src/core/verifiers.ts @@ -40,7 +40,10 @@ export function verifyUnsupportedAddons( setupResults[a.id].unsupported.map((reason) => ({ id: a.id, reason })) ); - if (reasons.length === 0) return { success: true, message: undefined }; + if (reasons.length === 0) { + return { success: true, message: undefined }; + } + throw new UnsupportedError(reasons); } }); From 4b0773d5009fe8e2252986172b79c65f05ca0979 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 3 Aug 2026 16:38:37 +0800 Subject: [PATCH 17/21] Clarify where error properties come from --- packages/sv/src/core/formatFiles.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/sv/src/core/formatFiles.ts b/packages/sv/src/core/formatFiles.ts index d19ca06a4..9a9829004 100644 --- a/packages/sv/src/core/formatFiles.ts +++ b/packages/sv/src/core/formatFiles.ts @@ -1,6 +1,6 @@ import * as p from '@clack/prompts'; import { type AgentName, resolveCommand } from '@sveltejs/sv-utils'; -import { exec } from 'tinyexec'; +import { exec, NonZeroExitError } from 'tinyexec'; export async function formatFiles(options: { packageManager: AgentName; @@ -39,12 +39,17 @@ async function run( await exec(command, args, { nodeOptions: { cwd }, throwOnError: true }); return {}; } catch (e) { - // @ts-expect-error tinyexec rethrows the spawn error as-is - if (e?.code === 'ENOENT') return { notFound: true, error: `${command} not found` }; - // @ts-expect-error `output` is only present on tinyexec's `NonZeroExitError` - const output = e?.output as { stderr?: string; stdout?: string } | undefined; - // failures can land on either stream, so report both - const message = [output?.stderr, output?.stdout].filter(Boolean).join('\n').trim(); - return { error: message || (e instanceof Error ? e.message : 'unknown error') }; + // tinyexec rethrows the spawn error as-is + if ((e as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + return { notFound: true, error: `${command} not found` }; + } + if (e instanceof NonZeroExitError) { + // failures can land on either stream, so report both + const { stderr, stdout } = e.output ?? {}; + const message = [stderr, stdout].filter(Boolean).join('\n').trim(); + return { error: message || e.message }; + } + if (e instanceof Error) return { error: e.message }; + return { error: 'unknown error' }; } } From 9b31da4365fd6f44d5c327fb2dbb049ee959cec0 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 3 Aug 2026 21:41:26 +0800 Subject: [PATCH 18/21] throw --- packages/sv/src/addons/tests/drizzle/test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/sv/src/addons/tests/drizzle/test.ts b/packages/sv/src/addons/tests/drizzle/test.ts index 2c374adf1..372198c5e 100644 --- a/packages/sv/src/addons/tests/drizzle/test.ts +++ b/packages/sv/src/addons/tests/drizzle/test.ts @@ -48,7 +48,12 @@ beforeAll(() => { dockerInstalled = false; } - if (dockerInstalled) execSync('docker', ['compose', 'up', '--detach'], { nodeOptions: { cwd } }); + if (dockerInstalled) { + execSync('docker', ['compose', 'up', '--detach'], { + nodeOptions: { cwd }, + throwOnError: true + }); + } // cleans up the containers on interrupts (ctrl+c) process.addListener('SIGINT', () => { From 1ab1830738c64cd5455a7ec612d1b1f5c64e339b Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Mon, 3 Aug 2026 21:49:42 +0800 Subject: [PATCH 19/21] more throws --- packages/sv/src/cli/check.ts | 5 ++++- packages/sv/src/cli/migrate.ts | 5 ++++- packages/sv/src/cli/tests/cli.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/sv/src/cli/check.ts b/packages/sv/src/cli/check.ts index 0df455a6e..ae59243aa 100644 --- a/packages/sv/src/cli/check.ts +++ b/packages/sv/src/cli/check.ts @@ -40,7 +40,10 @@ async function runCheck(cwd: string, args: string[]) { // avoids printing the stack trace for `sv` when `svelte-check` exits with an error code try { const cmd = resolveCommand(pm, 'execute-local', ['svelte-check', ...args])!; - execSync(cmd.command, cmd.args, { nodeOptions: { cwd, stdio: 'inherit' } }); + execSync(cmd.command, cmd.args, { + nodeOptions: { cwd, stdio: 'inherit' }, + throwOnError: true + }); } catch (error) { forwardExitCode(error); } finally { diff --git a/packages/sv/src/cli/migrate.ts b/packages/sv/src/cli/migrate.ts index b62ef7896..c6b37ddae 100644 --- a/packages/sv/src/cli/migrate.ts +++ b/packages/sv/src/cli/migrate.ts @@ -24,7 +24,10 @@ async function runMigrate(cwd: string, args: string[]) { ]; const cmd = resolveCommand(pm, 'execute', newArgs)!; - execSync(cmd.command, cmd.args, { nodeOptions: { cwd, stdio: 'inherit' } }); + execSync(cmd.command, cmd.args, { + nodeOptions: { cwd, stdio: 'inherit' }, + throwOnError: true + }); } catch (error) { forwardExitCode(error); } diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index b99c7ebc0..fef3f6e7e 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -100,7 +100,7 @@ describe('cli', () => { * Same as `exec`. but `cwd` defaults to `testOutputPath` */ const run = (...params: Parameters) => { - const [command, args, options = {}] = params ?? []; + const [command, args, options = {}] = params; options.nodeOptions ??= {}; options.nodeOptions.cwd ??= testOutputPath; return exec(command, args, options); From 4b83db55bef15308573721dbdee50717fd0e3d30 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Wed, 5 Aug 2026 20:50:50 +0800 Subject: [PATCH 20/21] no type casting --- packages/sv/src/core/engine.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index 6c910b073..35d864990 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -268,11 +268,14 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } nodeOptions: { cwd: workspace.cwd, stdio: TESTING ? 'pipe' : stdio }, throwOnError: true }); - } catch (error) { - const typedError = error as NonZeroExitError; - throw new Error(`Failed to execute scripts '${executedCommand}': ${typedError.message}`, { - cause: error - }); + } catch (e) { + let msg; + if (e instanceof NonZeroExitError || e instanceof Error) { + msg = `Failed to execute scripts '${executedCommand}': ${e.message}`; + } else { + msg = 'unknown error'; + } + throw new Error(msg, { cause: e }); } }, dependency: (pkg, version) => { From a0c77558e938bac6bc25da4e454bdb5eb93d0e8e Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Thu, 6 Aug 2026 03:14:36 +0800 Subject: [PATCH 21/21] add `isNodeError` --- packages/sv/src/core/common.ts | 4 ++++ packages/sv/src/core/formatFiles.ts | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/sv/src/core/common.ts b/packages/sv/src/core/common.ts index e9403ea19..75a311290 100644 --- a/packages/sv/src/core/common.ts +++ b/packages/sv/src/core/common.ts @@ -339,3 +339,7 @@ export const filePaths = { viteConfig: 'vite.config.js', viteConfigTS: 'vite.config.ts' } as const; + +export function isNodeError(e: unknown): e is Error & { code: string } { + return e instanceof Error && 'code' in e && typeof e.code === 'string'; +} diff --git a/packages/sv/src/core/formatFiles.ts b/packages/sv/src/core/formatFiles.ts index 9a9829004..5d6abe57b 100644 --- a/packages/sv/src/core/formatFiles.ts +++ b/packages/sv/src/core/formatFiles.ts @@ -1,6 +1,7 @@ import * as p from '@clack/prompts'; import { type AgentName, resolveCommand } from '@sveltejs/sv-utils'; import { exec, NonZeroExitError } from 'tinyexec'; +import { isNodeError } from './common.ts'; export async function formatFiles(options: { packageManager: AgentName; @@ -39,8 +40,7 @@ async function run( await exec(command, args, { nodeOptions: { cwd }, throwOnError: true }); return {}; } catch (e) { - // tinyexec rethrows the spawn error as-is - if ((e as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + if (isNodeError(e) && e.code === 'ENOENT') { return { notFound: true, error: `${command} not found` }; } if (e instanceof NonZeroExitError) { @@ -49,7 +49,9 @@ async function run( const message = [stderr, stdout].filter(Boolean).join('\n').trim(); return { error: message || e.message }; } - if (e instanceof Error) return { error: e.message }; + if (e instanceof Error) { + return { error: e.message }; + } return { error: 'unknown error' }; } }