Skip to content

Add Code Link npm strategy modes#643

Open
huntercaron wants to merge 6 commits intomainfrom
cursor/code-link-npm-strategy-modes
Open

Add Code Link npm strategy modes#643
huntercaron wants to merge 6 commits intomainfrom
cursor/code-link-npm-strategy-modes

Conversation

@huntercaron
Copy link
Copy Markdown
Collaborator

@huntercaron huntercaron commented May 6, 2026

Description

Adds Code Link npm strategy handling so unsupported npm can run in acquire-types mode or package-manager mode. Package-manager mode reads codeLinkNpmStrategy/lockfiles, skips ATA writes into node_modules, and asks the plugin for Framer dependency versions to keep package.json dependencies current.

Changelog

  • Add --unsupported-npm=acquire-types and --unsupported-npm=package-manager.
  • Add codeLink.npmStrategy config and lockfile auto-detection for package-manager mode.

Testing

  • QA acquire-types mode
    • Run the Code Link plugin locally from plugins/code-link.
    • Use the alpha CLI with --unsupported-npm=acquire-types and verify unsupported package types are acquired.
  • QA package-manager mode
    • Run the Code Link plugin locally from plugins/code-link.
    • Use the alpha CLI with --unsupported-npm=package-manager, verify package.json updates, then run your package manager.

Co-authored-by: Cursor <cursoragent@cursor.com>
@huntercaron huntercaron requested a review from iamakulov May 6, 2026 09:06
huntercaron and others added 4 commits May 6, 2026 13:08
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@huntercaron huntercaron marked this pull request as ready for review May 7, 2026 11:13
Copilot AI review requested due to automatic review settings May 7, 2026 11:13
@github-actions github-actions Bot added the Auto submit to Marketplace on merge Submits the plugin to the marketplace after merging label May 7, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds support for “unsupported npm” handling in Code Link by introducing explicit npm strategy modes, enabling either ATA-based type acquisition or a package-manager–driven workflow that updates package.json and defers installs to the user’s package manager.

Changes:

  • Introduces NpmStrategy (none / acquire-types / package-manager) with CLI flag parsing and project-level resolution (package.json + lockfile detection).
  • Adds a new CLI↔Plugin message pair to request dependency versions from the plugin, used to keep package.json dependencies current in package-manager mode.
  • Migrates legacy Code Link package.json metadata fields into codeLink.* and updates framer-plugin dependency/version pins.

Reviewed changes

Copilot reviewed 17 out of 19 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
yarn.lock Updates lockfile entries for framer-plugin@3.11.0-alpha.14.
plugins/code-link/src/messages.ts Handles new request-dependency-versions message and replies with dependency-versions.
plugins/code-link/src/api.ts Adds fetchDependencyVersions() using the Framer plugin API.
plugins/code-link/package.json Bumps framer-plugin dependency to 3.11.0-alpha.14.
packages/code-link-shared/src/types.ts Adds DependencyVersions and new CLI↔Plugin message types.
packages/code-link-shared/src/index.ts Re-exports DependencyVersions.
packages/code-link-cli/src/utils/project.ts Adds readAndMigratePackageJson() and migrates Code Link metadata under package.json.codeLink.
packages/code-link-cli/src/utils/project.test.ts Updates existing expectations to codeLink.* and adds migration tests.
packages/code-link-cli/src/types.ts Replaces allowUnsupportedNpm with npmStrategy and adds NpmStrategy type.
packages/code-link-cli/src/index.ts Adds --unsupported-npm [mode] parsing and wires npmStrategy into config.
packages/code-link-cli/src/helpers/npm-strategy.ts New helper to resolve strategy from CLI flag / package.json / lockfiles.
packages/code-link-cli/src/helpers/installer.ts Implements package-manager mode (dependency collection + package.json refresh) and strategy-aware ATA behavior.
packages/code-link-cli/src/controller.ts Resolves npm strategy at runtime, adds dependency version request/response flow.
packages/code-link-cli/src/controller.rename.test.ts Updates config shape for new npmStrategy.
packages/code-link-cli/src/controller.once.test.ts Updates config shape for new npmStrategy.
packages/code-link-cli/src/controller.integration.test.ts Updates config shape and adjusts sync-status message field typing in test parsing.
packages/code-link-cli/src/controller.apply.test.ts Updates config shape for new npmStrategy.
packages/code-link-cli/package.json Bumps CLI version and adds stableVersion metadata.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +23 to +33
function parseUnsupportedNpmMode(mode: string | undefined): NpmStrategy {
if (mode === undefined) {
return "acquire-types"
}

if (mode === "acquire-types" || mode === "package-manager") {
return mode
}

throw new InvalidArgumentError("unsupported npm mode must be 'acquire-types' or 'package-manager'")
}
Comment on lines +10 to +30
export async function resolveNpmStrategy(config: Config, projectDir: string): Promise<NpmStrategy> {
if (config.npmStrategy) {
debug(`Using npm strategy from CLI flag: ${config.npmStrategy}`)
return config.npmStrategy
}

const packageJsonStrategy = await readPackageJsonStrategy(projectDir)
if (packageJsonStrategy) {
debug(`Using npm strategy from package.json ${CONFIG_FIELD}: ${packageJsonStrategy}`)
return packageJsonStrategy
}

const detectedLockfile = await detectLockfile(projectDir)
if (detectedLockfile) {
debug(`Using npm strategy package-manager from ${detectedLockfile}`)
return "package-manager"
}

debug("Using default npm strategy: none")
return "none"
}
Comment on lines 216 to +251
@@ -211,9 +227,10 @@
const coreImports = await this.buildPinnedImports(CORE_LIBRARIES)

// After pins are resolved, also include package.json deps
const packageJsonDeps = this.allowUnsupportedNpm
? Object.keys(this.pinnedTypeVersions).filter(name => !SUPPORTED_PACKAGES.has(name))
: []
const packageJsonDeps =
this.npmStrategy === "acquire-types"
? Object.keys(this.pinnedTypeVersions).filter(name => !SUPPORTED_PACKAGES.has(name))
: []

const imports = [...coreImports, ...(await this.buildPinnedImports(packageJsonDeps))].join("\n")
await this.ata(imports)
@@ -226,13 +243,19 @@
private async processImports(fileName: string, content: string): Promise<void> {
const allImports = extractImports(content).filter(i => i.type === "npm")

if (allImports.length === 0) return

if (this.npmStrategy === "package-manager") {
await this.enqueuePackageJsonRefresh(allImports.map(imp => getBasePackageName(imp.name)))
return
}
Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown
Contributor

@iamakulov iamakulov left a comment

Choose a reason for hiding this comment

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

Sorry, this is a big review, but a big chunk of it is about code complexity or DX. Feel free to skip those if it blocks shipping!

Some extra notes from QA/Slack:

  • [dx] the version ranges that we set in package.json should be pinned, not ^. we pin your dependency versions in framer, and the package.json should reflect that
  • [bug] with this project, dependency syncing simply doesn’t happen. (reproduced with npx framer-code-link@alpha --unsupported-npm=package-manager A8ekr3Wn.) files sync, but deps aren’t added into package.json. haven’t debugged why.

private async addPackageNamesFromDirectory(directory: string, packageNames: Set<string>): Promise<void> {
let entries: Dirent[]
try {
entries = await fs.readdir(directory, { withFileTypes: true })
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.

[potential bug] Does this need recursive: true? (Code files can be in subdirectories.)


try {
const content = await fs.readFile(entryPath, "utf-8")
for (const imported of extractImports(content).filter(i => i.type === "npm")) {
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.

[simplicity] Up to you whether to drive-by this but I noticed we only ever do extractImports().filter(i => i.type === "npm"). We can drop URL imports and simplify.

Copy link
Copy Markdown
Contributor

@iamakulov iamakulov May 7, 2026

Choose a reason for hiding this comment

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

[bug (pre-existing)] We can also switch from fragile regexes (eg they don’t work with imports like

import Foo, {
  useFoo,
} from "foo"

) to https://www.npmjs.com/package/parse-imports.

Comment on lines 186 to 189
/**
* Fire-and-forget processing of a component file to fetch missing types.
* JSON files are ignored.
*/
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
/**
* Fire-and-forget processing of a component file to fetch missing types or add `package.json` dependencies.
* JSON files are ignored.
*/

Comment on lines 243 to +251
private async processImports(fileName: string, content: string): Promise<void> {
const allImports = extractImports(content).filter(i => i.type === "npm")

if (allImports.length === 0) return

if (this.npmStrategy === "package-manager") {
await this.enqueuePackageJsonRefresh(allImports.map(imp => getBasePackageName(imp.name)))
return
}
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.

[simplicity] I think this would read much simpler if we rearrange this into:

    private async processImports(fileName: string, content: string): Promise<void> {
        const allImports = extractImports(content).filter(i => i.type === "npm")

        if (allImports.length === 0) return

        if (this.npmStrategy === "package-manager") {
            await this.enqueuePackageJsonRefresh(allImports.map(imp => getBasePackageName(imp.name)))
        } else if (this.npmStrategy === "acquire-types") {
            await this.acquireTypes(allImports)
        } else {
            const supportedImports = allImports.filter(i => this.isSupportedPackage(i.name))
            const unsupportedImports = allImports.filter(i => this.isSupportedPackage(i.name))
            if (unsupportedImports.length > 0) {
                debug(`Skipping unsupported packages: ${unsupported.join(", ")} (use --unsupported-npm to enable)`)
            }
            await acquireTypes(supportedImports)
        }

WDYT?

Comment on lines +336 to +338
private async enqueuePackageJsonRefresh(packageNames: string[]): Promise<void> {
const missingPackageNames = packageNames
.map(name => getBasePackageName(name))
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.

[simplicity] This is weird; two parts of this code tell different stories:

  • The param name (packageNames) tells that the function accepts sanitized package names (foo, @bar/baz)
  • But .map(name => getBasePackageName(name)) tells that the function accepts raw import paths that need to be sanitized into package names (@bar/baz/dist/index.js)

Looking at the usage, it seems the first story is true. Could we/should we simplify so that the story is consistent?

Suggested change
private async enqueuePackageJsonRefresh(packageNames: string[]): Promise<void> {
const missingPackageNames = packageNames
.map(name => getBasePackageName(name))
private async enqueuePackageJsonRefresh(packageNames: string[]): Promise<void> {
const missingPackageNames = packageNames

Comment on lines +23 to +33
function parseUnsupportedNpmMode(mode: string | undefined): NpmStrategy {
if (mode === undefined) {
return "acquire-types"
}

if (mode === "acquire-types" || mode === "package-manager") {
return mode
}

throw new InvalidArgumentError("unsupported npm mode must be 'acquire-types' or 'package-manager'")
}
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.

[bug] Don’t we also support "none"? (See npm-strategy.ts)

.option("--unsupported-npm", "Allow type acquisition for unsupported npm packages")
.option(
"--unsupported-npm [mode]",
"Handle unsupported npm packages (acquire-types or package-manager)",
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.

[dx]

Suggested change
"Handle unsupported npm packages (acquire-types or package-manager)",
"Handle unsupported npm packages (none or acquire-types or package-manager)",

.option("--dangerously-auto-delete", "Automatically delete remote files without confirmation")
.option("--unsupported-npm", "Allow type acquisition for unsupported npm packages")
.option(
"--unsupported-npm [mode]",
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.

[bug] Devin says (and I confirmed this is true):

--unsupported-npm without argument silently has no effect due to Commander optional-arg semantics

When --unsupported-npm is used without a value argument (the backward-compatible form), Commander does not call the parseUnsupportedNpmMode parse function. Instead, Commander sets the option value to true (boolean) because presetArg is undefined and the option uses [mode] (optional argument). This is confirmed by Commander's source code at node_modules/commander/lib/command.js:692-716 where val is null for optional options without an argument, and since presetArg is undefined, the parse function is skipped and val is set to true.

The boolean true flows into config.npmStrategy, passes resolveNpmStrategy's truthy check (npm-strategy.ts:11), and reaches the Installer where it doesn't match "acquire-types" or "package-manager", silently defaulting to "none" behavior. Users upgrading from the old boolean --unsupported-npm flag would find type acquisition for unsupported packages no longer works.

Comment on lines +286 to +287
const filteredContent =
this.npmStrategy === "acquire-types" ? content : await this.buildFilteredImports(imports)
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.

[bug (pre-existing)] This is a pre-existing bug, so just thought to note: I think we should (not necessarily in this PR) pin versions with this.npmStrategy === "acquire-types" too. Otherwise, a project might use foo@4.0.0, but we might accidentally acquire types for foo@5.0.0. We didn’t have an ability to pin/resolve versions in the past, but we do have it now.

(As a side note, do we know if anyone uses this.npmStrategy === "acquire-types"? Do we need to keep it around now that this.npmStrategy === "package-manager" is here? Maybe the latter one is what everyone would prefer?)

return [...packageNames]
}

private async addPackageNamesFromDirectory(directory: string, packageNames: Set<string>): Promise<void> {
Copy link
Copy Markdown
Contributor

@iamakulov iamakulov May 7, 2026

Choose a reason for hiding this comment

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

[simplicity] Had to pause for a moment to figure out where exactly this function adds package names. Could we / should we make it pure, as in, make it return a set rather than amend an existing set?

Then, above, we can simply do:

diff --git a/packages/code-link-cli/src/helpers/installer.ts b/packages/code-link-cli/src/helpers/installer.ts
index 4dd88e0591..b874a5db05 100644
--- a/packages/code-link-cli/src/helpers/installer.ts
+++ b/packages/code-link-cli/src/helpers/installer.ts
@@ -295,9 +295,10 @@
     }

     private async collectPackageManagerPackageNames(): Promise<string[]> {
-        const packageNames = new Set([...CORE_LIBRARIES, ...PACKAGE_MANAGER_DEV_DEPENDENCIES])
-        await this.addPackageNamesFromDirectory(path.join(this.projectDir, "files"), packageNames)
-        return [...packageNames]
+        const defaultPackageNames = new Set([...CORE_LIBRARIES, ...PACKAGE_MANAGER_DEV_DEPENDENCIES])
+        const packageNamesFromDir = await this.getPackageNamesFromDirectory(path.join(this.projectDir, "files"))
+
+        return [...defaultPackageNames.union(packageNamesFromDir)]
     }

     private async addPackageNamesFromDirectory(directory: string, packageNames: Set<string>): Promise<void> {

(set.union() is supported as of Node.js 22.)

let changed = false
for (const packageName of uniquePackageNames) {
const version = versions[packageName] ?? this.pinnedTypeVersions[packageName]
const version = (versions[packageName] ?? this.pinnedTypeVersions[packageName])?.replace(/^\^/, "")
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.

[simplicity] why do we have to strip the ^ here? this feels like the wrong place – we have code that adds a ^ somewhere, and we’re undoing that code’s work with more code here. we can just delete the original code, I think?

(is it unstable_getDependencyVersion that returns a version with a ^? then I’d def kill the ^ there. i’d expect the getDependencyVersion method to return a version, not a version range.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The ^ is persisted in the actual dependencies.json records for all Framer projects sadly, its something that is technically incorrect but was rushed through years ago.

Would you expect it here or in the Plugin API functions? fixing the source should happen holistically with the other npm fixes IMO, has to be monkey patched somewhere in this process for now.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Maybe doing it in the Plugin API/FramerStudio at least makes more sense so it can later have the knowledge if the project is fixed/migrated once that happens

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

Labels

Auto submit to Marketplace on merge Submits the plugin to the marketplace after merging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants