Skip to content

feat: skip package manager prompt when nothing needs installing - #1195

Open
jycouet wants to merge 3 commits into
version-1from
feat/install-when-needed
Open

feat: skip package manager prompt when nothing needs installing#1195
jycouet wants to merge 3 commits into
version-1from
feat/install-when-needed

Conversation

@jycouet

@jycouet jycouet commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Closes #1088

sv add now only asks which package manager to use when an add-on actually adds or widens a dependency in package.json (e.g. sv add mcp=ide:claude-code+setup:remote needs nothing installed). Files are still formatted when the install is skipped for that reason.

Adds isRangeWithin to @sveltejs/sv-utils, so an existing stricter range (^9.2.0 vs a requested ^9.0.0) is kept and doesn't trigger an install.

@pkg-svelte-dev

pkg-svelte-dev Bot commented Jul 25, 2026

Copy link
Copy Markdown

Install the latest version of sv from 57085d3:

pnpm add https://pkg.svelte.dev/sv/c/57085d36e0863c0b5711cc05237e2355f1285307

Open in pkg.svelte.dev: https://pkg.svelte.dev/repos/cli/pr/1195

@changeset-bot

changeset-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 57085d3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@sveltejs/sv-utils Patch
sv Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@jycouet
jycouet force-pushed the feat/install-when-needed branch from e072bab to a883eb7 Compare July 25, 2026 17:07
@svelte-docs-bot

Copy link
Copy Markdown

@sacrosanctic

Copy link
Copy Markdown
Contributor

Darn, I was working on the issue too, but yours look better.

Comment on lines +693 to +697
let packageManager: AgentName | null | undefined = null;
if (options.install !== false && installNeeded) {
packageManager =
options.install === true ? await packageManagerPrompt(options.cwd) : options.install;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let packageManager: AgentName | null | undefined = null;
if (options.install !== false && installNeeded) {
packageManager =
options.install === true ? await packageManagerPrompt(options.cwd) : options.install;
}
let packageManager: AgentName | null | undefined = null;
if (options.install === true) {
// prompt user when `package.json` has changed
if (installNeeded) {
packageManager = await packageManagerPrompt(options.cwd);
}
} else if (options.install === false) {
// user choose not to install
} else {
packageManager = options.install;
}

Comment on lines +760 to +761
// still format when the install was skipped only because no new dependency was added
if (packageManager || (!installNeeded && options.install !== false)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

really hard to understand this condition

Comment on lines +39 to +50
/**
* Returns `true` when every version matching `range` also matches `target`,
* e.g. `^9.2.0` is within `^9.0.0` but `^8.0.0` is not.
* Unparseable inputs (`latest`, `workspace:*`, ...) return `false`.
*/
export function isRangeWithin(range: string, target: string): boolean {
try {
return isRangeSubset(range, target);
} catch {
return false;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the param names are a bit too ambiguous. i think sticking with isRangeSubset's naming makes the most sense.

Suggested change
/**
* Returns `true` when every version matching `range` also matches `target`,
* e.g. `^9.2.0` is within `^9.0.0` but `^8.0.0` is not.
* Unparseable inputs (`latest`, `workspace:*`, ...) return `false`.
*/
export function isRangeWithin(range: string, target: string): boolean {
try {
return isRangeSubset(range, target);
} catch {
return false;
}
}
/**
* Returns `true` when every version matching `subset` also matches `superset`,
* e.g. `^9.2.0` is within `^9.0.0` but `^8.0.0` is not.
* Unparseable inputs (`latest`, `workspace:*`, ...) return `false`.
*/
export function isRangeWithin(subset: string, superset: string): boolean {
try {
return isRangeSubset(subset, superset);
} catch {
return false;
}
}

Comment on lines 52 to 54
export function isVersionUnsupportedBelow(
versionStr: string,
belowStr: string

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drive-by change

Suggested change
export function isVersionUnsupportedBelow(
versionStr: string,
belowStr: string
export function isVersionUnsupportedBelow(
version: string,
below: string

Comment on lines +78 to +95
describe('isRangeWithin', () => {
const combinations = [
{ range: '^9.0.0', target: '^9.0.0', expected: true },
{ range: '^9.2.0', target: '^9.0.0', expected: true },
{ range: '9.2.0', target: '^9.0.0', expected: true },
{ range: '^9.0.0', target: '^9.2.0', expected: false },
{ range: '^8.0.0', target: '^9.0.0', expected: false },
{ range: '*', target: '^9.0.0', expected: false },
{ range: 'latest', target: '^9.0.0', expected: false },
{ range: 'workspace:^9.0.0', target: '^9.0.0', expected: false }
] as const;
it.each(combinations)(
'($range within $target) should be $expected',
({ range, target, expected }) => {
expect(isRangeWithin(range, target)).toEqual(expected);
}
);
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...continues the optional name change

Suggested change
describe('isRangeWithin', () => {
const combinations = [
{ range: '^9.0.0', target: '^9.0.0', expected: true },
{ range: '^9.2.0', target: '^9.0.0', expected: true },
{ range: '9.2.0', target: '^9.0.0', expected: true },
{ range: '^9.0.0', target: '^9.2.0', expected: false },
{ range: '^8.0.0', target: '^9.0.0', expected: false },
{ range: '*', target: '^9.0.0', expected: false },
{ range: 'latest', target: '^9.0.0', expected: false },
{ range: 'workspace:^9.0.0', target: '^9.0.0', expected: false }
] as const;
it.each(combinations)(
'($range within $target) should be $expected',
({ range, target, expected }) => {
expect(isRangeWithin(range, target)).toEqual(expected);
}
);
});
describe('isRangeWithin', () => {
const combinations = [
{ subset: '^9.0.0', superset: '^9.0.0', expected: true },
{ subset: '^9.2.0', superset: '^9.0.0', expected: true },
{ subset: '9.2.0', superset: '^9.0.0', expected: true },
{ subset: '^9.0.0', superset: '^9.2.0', expected: false },
{ subset: '^8.0.0', superset: '^9.0.0', expected: false },
{ subset: '*', superset: '^9.0.0', expected: false },
{ subset: 'latest', superset: '^9.0.0', expected: false },
{ subset: 'workspace:^9.0.0', superset: '^9.0.0', expected: false }
] as const;
it.each(combinations)(
'($subset within $superset) should be $expected',
({ subset, superset, expected }) => {
expect(isRangeWithin(subset, superset)).toEqual(expected);
}
);
});

Comment on lines +40 to 48
/** Returns `true` when `package.json` gained or widened a dependency, i.e. an install is required. */
function updatePackages(
dependencies: Array<{ pkg: string; version: string; dev: boolean }>,
sv: SvApi
) {
if (dependencies.length === 0) return;
): boolean {
if (dependencies.length === 0) return false;

let installNeeded = false;
const pkgPath = filePaths.packageJson;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is very much a nitpick, but returning an object should remove the need for the comment here

Suggested change
/** Returns `true` when `package.json` gained or widened a dependency, i.e. an install is required. */
function updatePackages(
dependencies: Array<{ pkg: string; version: string; dev: boolean }>,
sv: SvApi
) {
if (dependencies.length === 0) return;
): boolean {
if (dependencies.length === 0) return false;
let installNeeded = false;
const pkgPath = filePaths.packageJson;
/** Returns `true` when `package.json` gained or widened a dependency, i.e. an install is required. */
function updatePackages(
dependencies: Array<{ pkg: string; version: string; dev: boolean }>,
sv: SvApi
): { installNeeded: boolean } {
let installNeeded = false;
if (dependencies.length === 0) return { installNeeded };
const pkgPath = filePaths.packageJson;

})
);

return installNeeded;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a follow-up from the previous suggestion

Suggested change
return installNeeded;
return { installNeeded };

sv,
finalize: () => {
updatePackages(dependencies, sv);
const installNeeded = updatePackages(dependencies, sv);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another follow-up

Suggested change
const installNeeded = updatePackages(dependencies, sv);
const { installNeeded } = updatePackages(dependencies, sv);

Comment on lines 755 to 766
if (packageManager) {
workspace.packageManager = packageManager;
await installDependencies(packageManager, options.cwd);
}

// still format when the install was skipped only because no new dependency was added
if (packageManager || (!installNeeded && options.install !== false)) {
await formatFiles({
packageManager,
packageManager: packageManager ?? workspace.packageManager,
cwd: options.cwd,
filesToFormat,
strategy: 'files-only'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im not exactly sure when this changed, but iirc, we used to guard formatting files behind whether we ran the deps install ourselves because we couldn't guarantee that the formatter was installed locally. if it wasn't, then it would result in an error when attempting to format.

I think that seems to be the case now. for example:

  1. create a new project
  2. install prettier and eslint with their respective add-ons
  3. delete node_modules
  4. try running the eslint add-on

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants