fix(scaffold): resolve the framework peer from this repository, not a registry
Two sites reached a registry for the vendored framework, which the rescope turns from a silent second copy into a hard failure. Live-link mode relinked only the root manifest, so a generated workspace member — `plugins/*/package.json` — resolved its own dependencies from the registry and installed upstream cordis beside this repository's vendored copy. `LinkWorkspace.relinkNestedManifest()` relinks every nested generated manifest; `peerDependencies` keeps its range because package managers reject a link spec there. The sandbox publish-path rehearsal installs this repository's vendored cordis and cosmokit tarballs instead of naming a registry version.
This commit is contained in:
@@ -134,4 +134,37 @@ export class LinkWorkspace {
|
||||
? resolve(dirname(directory), directory.split(sep).at(-1) as string)
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite one nested generated manifest's local dependencies to live-link specs.
|
||||
*
|
||||
* A generated workspace member resolves its own dependencies, so every local
|
||||
* name it declares must point into this repository as well: none of them —
|
||||
* the harness packages or the rescoped framework — exists on a public
|
||||
* registry, so a semver spec there fails the install outright.
|
||||
* `peerDependencies` keeps its range because a peer states what the consumer
|
||||
* must supply, and package managers reject a link spec in that section.
|
||||
* @param projectRoot - Absolute root of the generated project.
|
||||
* @param manifestPath - The nested manifest's project-relative POSIX path.
|
||||
* @param text - The nested manifest's complete current text.
|
||||
* @param manager - Package manager whose link-spec form applies.
|
||||
* @returns The manifest text with every resolved local dependency relinked.
|
||||
*/
|
||||
relinkNestedManifest(projectRoot: string, manifestPath: string, text: string, manager: PackageManager): string {
|
||||
const manifest = JSON.parse(text) as Record<string, unknown>
|
||||
const manifestDirectory = resolve(canonicalPath(projectRoot), dirname(manifestPath))
|
||||
let changed = false
|
||||
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
||||
const dependencies = manifest[section]
|
||||
if (typeof dependencies !== 'object' || dependencies === null) continue
|
||||
for (const [name] of Object.entries(dependencies as Record<string, string>)) {
|
||||
const pkg = this.packages.get(name)
|
||||
if (!pkg) continue
|
||||
const relativePath = posixPath(relative(manifestDirectory, realpathSync(pkg.directory)))
|
||||
;(dependencies as Record<string, string>)[name] = manager.linkSpec(relativePath)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? `${JSON.stringify(manifest, null, 2)}\n` : text
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import type { ProjectResource } from '../features/resources.ts'
|
||||
import { CordisYamlFile, type CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
|
||||
import { EnvFile } from '../documents/env-file.ts'
|
||||
import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts'
|
||||
import { ProjectFile } from '../documents/project-file.ts'
|
||||
import { ProjectFile, TextProjectFile } from '../documents/project-file.ts'
|
||||
import { TsConfigFile } from '../documents/tsconfig-file.ts'
|
||||
import { featureId, type FeatureId, type ResourceKey } from '../ids.ts'
|
||||
import { LinkWorkspace } from '../package-managers/link-workspace.ts'
|
||||
@@ -288,6 +288,18 @@ export class ProjectEditSession implements FeatureProjectView {
|
||||
this.profile.packageManager,
|
||||
[...this.documents.values()],
|
||||
)
|
||||
// Generated workspace members resolve their own dependencies, so the root
|
||||
// manifest's links are not enough: relink every nested manifest as well.
|
||||
for (const [path, document] of this.documents) {
|
||||
if (path === 'package.json' || !path.endsWith('/package.json')) continue
|
||||
const relinked = workspace.relinkNestedManifest(
|
||||
this.source.root,
|
||||
path,
|
||||
document.serialize(),
|
||||
this.profile.packageManager,
|
||||
)
|
||||
this.documents.set(path, new TextProjectFile(path, relinked, document.originalText))
|
||||
}
|
||||
}
|
||||
this.validateFinalState()
|
||||
const changes = this.changes()
|
||||
|
||||
@@ -371,6 +371,27 @@ describe('package manager strategies', () => {
|
||||
expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis'))
|
||||
expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis')
|
||||
expect(workspace.packageDirectory('missing')).toBeUndefined()
|
||||
// A generated workspace member resolves its own dependencies: every local name it
|
||||
// declares relinks, while a peer keeps the range package managers require there.
|
||||
const nested = workspace.relinkNestedManifest(join(root, 'consumer'), 'plugins/probe/package.json', `${JSON.stringify({
|
||||
name: 'probe',
|
||||
dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1', 'left-pad': '^1' },
|
||||
peerDependencies: { '@deepseek-ai/dsh-scripts': '^0.0.1' },
|
||||
devDependencies: { '@deepseek-ai/dsh-scripts': '^0.0.1' },
|
||||
}, null, 2)}\n`, new PnpmPackageManager('10.0.0'))
|
||||
const nestedManifest = JSON.parse(nested) as {
|
||||
dependencies: Record<string, string>
|
||||
peerDependencies: Record<string, string>
|
||||
devDependencies: Record<string, string>
|
||||
}
|
||||
expect(nestedManifest.dependencies['@deepseek-ai/dsh-helper']).toMatch(/^link:\.\.\/\.\.\//)
|
||||
expect(nestedManifest.dependencies['left-pad']).toBe('^1')
|
||||
expect(nestedManifest.devDependencies['@deepseek-ai/dsh-scripts']).toMatch(/^link:\.\.\/\.\.\//)
|
||||
expect(nestedManifest.peerDependencies['@deepseek-ai/dsh-scripts']).toBe('^0.0.1')
|
||||
// Nothing local to relink, and a non-object section, leave the text byte-identical.
|
||||
const untouched = `${JSON.stringify({ name: 'probe', dependencies: { 'left-pad': '^1' }, devDependencies: null }, null, 2)}\n`
|
||||
expect(workspace.relinkNestedManifest(join(root, 'consumer'), 'plugins/probe/package.json', untouched, new PnpmPackageManager('10.0.0')))
|
||||
.toBe(untouched)
|
||||
const yarnManifest = PackageJsonFile.create('{"name":"consumer"}')
|
||||
yarnManifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1')
|
||||
workspace.apply(join(root, 'consumer-yarn'), yarnManifest, new YarnPackageManager('4.0.0'), [])
|
||||
|
||||
@@ -698,14 +698,24 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
it('does not mistake a linked NPM dependency closure for an installed feature', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-link-closure-inspection-'))
|
||||
temporary.push(root)
|
||||
const base = request([selection('hooks', ['claude'])])
|
||||
const base = request([selection('hooks', ['claude'])], [new LocalPluginBlueprint('probe', 'plugin')])
|
||||
const creation: ProjectCreationRequest = { ...base, linkWorkspaceRoot: repoRoot }
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
for (const blueprint of creation.localPlugins) edit.addPlugin(blueprint)
|
||||
const committed = (await edit.commit()).project
|
||||
expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/)
|
||||
// A generated workspace member resolves its own dependencies, so its manifest links too.
|
||||
const plugin = JSON.parse(await readFile(join(root, 'plugins/probe/package.json'), 'utf8')) as {
|
||||
devDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
}
|
||||
// Asserted by shape, not by the framework's name: what matters is that the
|
||||
// resolved section links into this repository while the peer keeps its range.
|
||||
expect(Object.values(plugin.devDependencies ?? {}).every(spec => spec.startsWith('file:'))).toBe(true)
|
||||
expect(Object.values(plugin.peerDependencies ?? {}).some(spec => spec.startsWith('^'))).toBe(true)
|
||||
expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user