diff --git a/scripts/build-exe-for-python-sdk-staging.spec.ts b/scripts/build-exe-for-python-sdk-staging.spec.ts new file mode 100644 index 0000000000..3bbceb4b71 --- /dev/null +++ b/scripts/build-exe-for-python-sdk-staging.spec.ts @@ -0,0 +1,66 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { materializeStagedLinks } from './build-exe-for-python-sdk-staging.ts' + +const temporaryDirectories: string[] = [] +const directoryLinkType = process.platform === 'win32' ? 'junction' : 'dir' + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) +}) + +async function fixture(prefix: string) { + const workspace = await mkdtemp(join(tmpdir(), prefix)) + temporaryDirectories.push(workspace) + return workspace +} + +/** List every symbolic link below a directory. */ +async function listSymlinks(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }) + const nested = await Promise.all(entries.map(async (entry) => { + const path = join(directory, entry.name) + if (entry.isSymbolicLink()) return [path] + if (entry.isDirectory()) return listSymlinks(path) + return [] + })) + return nested.flat() +} + +it('drops the deploy root link that points back into the payload', async () => { + const workspace = await fixture('dsh-python-staging-root-') + const project = join(workspace, 'python', 'sdk-runtime') + const staging = join(project, 'src', 'runtime', 'node') + await mkdir(join(staging, 'node_modules'), { recursive: true }) + await writeFile(join(project, 'package.json'), '{"name":"dsh-python-runtime-closure"}\n') + await symlink(project, join(staging, 'node_modules', 'dsh-python-runtime-closure'), directoryLinkType) + + await materializeStagedLinks(staging) + + expect(existsSync(join(staging, 'node_modules', 'dsh-python-runtime-closure'))).toBe(false) + await expect(readFile(join(project, 'package.json'), 'utf8')).resolves.toContain('dsh-python-runtime-closure') +}) + +it('copies workspace package links as files and removes command shims', async () => { + const workspace = await fixture('dsh-python-staging-packages-') + const staging = join(workspace, 'runtime', 'node') + const packageDirectory = join(workspace, 'packages', 'dsh-base') + await mkdir(join(packageDirectory, 'lib'), { recursive: true }) + await writeFile(join(packageDirectory, 'package.json'), '{"name":"@deepseek-ai/dsh-base"}\n') + await writeFile(join(packageDirectory, 'lib', 'index.js'), 'export {}\n') + await mkdir(join(staging, 'node_modules', '@deepseek-ai'), { recursive: true }) + await mkdir(join(staging, 'node_modules', '.bin'), { recursive: true }) + await writeFile(join(staging, 'package.json'), '{"name":"dsh-python-runtime-closure"}\n') + await symlink(packageDirectory, join(staging, 'node_modules', '@deepseek-ai', 'dsh-base'), directoryLinkType) + await symlink(packageDirectory, join(staging, 'node_modules', '.bin', 'dsh'), directoryLinkType) + + await materializeStagedLinks(staging) + + expect(existsSync(join(staging, 'node_modules', '.bin'))).toBe(false) + await expect(readFile(join(staging, 'node_modules', '@deepseek-ai', 'dsh-base', 'lib', 'index.js'), 'utf8')) + .resolves.toBe('export {}\n') + expect(await listSymlinks(staging)).toEqual([]) +}) diff --git a/scripts/build-exe-for-python-sdk-staging.ts b/scripts/build-exe-for-python-sdk-staging.ts new file mode 100644 index 0000000000..3ec0deb6ca --- /dev/null +++ b/scripts/build-exe-for-python-sdk-staging.ts @@ -0,0 +1,58 @@ +/** Turn the deploy-time package links of the Python runtime payload into real files. */ +import { cp, lstat, readdir, realpath, rm } from 'node:fs/promises' +import { join, sep } from 'node:path' + +/** Return the first symbolic link below a directory, if one exists. */ +async function findSymlink(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + const metadata = await lstat(path) + if (metadata.isSymbolicLink()) return path + if (metadata.isDirectory()) { + const nested = await findSymlink(path) + if (nested !== undefined) return nested + } + } + return undefined +} + +/** + * Replace every deploy-time package link below the staged payload with real files. + * + * Legacy deploy links workspace packages into the target's `node_modules`, and + * those links must survive packaging as files. One link is not a package: when + * the deploy target sits inside the deployed project, pnpm links that project + * into its own `node_modules`. Dereferencing it would copy the payload into + * itself, and the packaged payload never carries the project source, so the + * entry is dropped instead. + * @param staging - Deployed payload root whose `node_modules` becomes symlink-free. + */ +export async function materializeStagedLinks(staging: string): Promise { + const payload = await realpath(staging) + const nodeModules = join(staging, 'node_modules') + let remaining = await findSymlink(nodeModules) + while (remaining !== undefined) { + const segments = remaining.slice(nodeModules.length + 1).split(sep) + const binIndex = segments.lastIndexOf('.bin') + if (binIndex >= 0) { + await rm(join(nodeModules, ...segments.slice(0, binIndex + 1)), { recursive: true, force: true }) + remaining = await findSymlink(nodeModules) + continue + } + const destination = remaining + const source = await realpath(destination) + if (payload === source || payload.startsWith(source + sep)) { + await rm(destination, { recursive: true, force: true }) + remaining = await findSymlink(nodeModules) + continue + } + const nestedNodeModules = join(source, 'node_modules') + await rm(destination, { recursive: true, force: true }) + await cp(source, destination, { + recursive: true, + dereference: true, + filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep), + }) + remaining = await findSymlink(nodeModules) + } +} diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index d3f58803c6..2b9ddfe5de 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -8,12 +8,13 @@ import { spawn } from 'node:child_process' import { existsSync, statSync } from 'node:fs' -import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { chmod, copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, extname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts' import { copyOfficeSidecar, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts' +import { materializeStagedLinks } from './build-exe-for-python-sdk-staging.ts' import { preparePrimaryRuntime, smokePrimaryRuntime, type PrimaryRuntimeTarget } from './primary-runtime/prepare.ts' const root = resolve(import.meta.dirname, '..') @@ -372,41 +373,7 @@ class SingleExeBuild { console.log('build-exe-for-python-sdk: [dry-run] materialize staged package links') return } - const nodeModules = join(this.staging, 'node_modules') - let remaining = await this.findSymlink(nodeModules) - while (remaining !== undefined) { - const segments = remaining.slice(nodeModules.length + 1).split(sep) - const binIndex = segments.lastIndexOf('.bin') - if (binIndex >= 0) { - await rm(join(nodeModules, ...segments.slice(0, binIndex + 1)), { recursive: true, force: true }) - remaining = await this.findSymlink(nodeModules) - continue - } - const destination = remaining - const source = await realpath(destination) - const nestedNodeModules = join(source, 'node_modules') - await rm(destination, { recursive: true, force: true }) - await cp(source, destination, { - recursive: true, - dereference: true, - filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep), - }) - remaining = await this.findSymlink(nodeModules) - } - } - - /** Return the first symbolic link below a directory, if one exists. */ - private async findSymlink(directory: string): Promise { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - const metadata = await lstat(path) - if (metadata.isSymbolicLink()) return path - if (metadata.isDirectory()) { - const nested = await this.findSymlink(path) - if (nested !== undefined) return nested - } - } - return undefined + await materializeStagedLinks(this.staging) } /** Add the executable entry and pkg assets to the staged manifest. */