diff --git a/packages/scaffold/helper/src/package-managers/link-workspace.ts b/packages/scaffold/helper/src/package-managers/link-workspace.ts index 1a6b518fa9..3fbc063451 100644 --- a/packages/scaffold/helper/src/package-managers/link-workspace.ts +++ b/packages/scaffold/helper/src/package-managers/link-workspace.ts @@ -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 + 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)) { + const pkg = this.packages.get(name) + if (!pkg) continue + const relativePath = posixPath(relative(manifestDirectory, realpathSync(pkg.directory))) + ;(dependencies as Record)[name] = manager.linkSpec(relativePath) + changed = true + } + } + return changed ? `${JSON.stringify(manifest, null, 2)}\n` : text + } } diff --git a/packages/scaffold/helper/src/project/project-edit-session.ts b/packages/scaffold/helper/src/project/project-edit-session.ts index 4c3d39c07c..3d86050017 100644 --- a/packages/scaffold/helper/src/project/project-edit-session.ts +++ b/packages/scaffold/helper/src/project/project-edit-session.ts @@ -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() diff --git a/packages/scaffold/helper/tests/documents.spec.ts b/packages/scaffold/helper/tests/documents.spec.ts index e3ffe18b77..317a9ed3ab 100644 --- a/packages/scaffold/helper/tests/documents.spec.ts +++ b/packages/scaffold/helper/tests/documents.spec.ts @@ -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 + peerDependencies: Record + devDependencies: Record + } + 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'), []) diff --git a/packages/scaffold/helper/tests/project.spec.ts b/packages/scaffold/helper/tests/project.spec.ts index 41acf530ba..01918d7570 100644 --- a/packages/scaffold/helper/tests/project.spec.ts +++ b/packages/scaffold/helper/tests/project.spec.ts @@ -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 + peerDependencies?: Record + } + // 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') })