feat(sdk): add developer project tooling
docs(rfc): propose SDK developer project tooling feat: rename / docs ci: fix windows gates docs: revert
This commit is contained in:
137
packages/sdk/helper/src/package-managers/link-workspace.ts
Normal file
137
packages/sdk/helper/src/package-managers/link-workspace.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Repository package discovery and NPM dependency-closure rewriting for live links.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/package-managers/link-workspace
|
||||
*/
|
||||
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { existsSync, realpathSync } from 'node:fs'
|
||||
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
||||
import type { PackageJsonFile, PackageManifest } from '../documents/package-json-file.ts'
|
||||
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
|
||||
import type { ProjectFile } from '../documents/project-file.ts'
|
||||
import type { PackageManager } from './package-manager.ts'
|
||||
|
||||
interface WorkspacePackage {
|
||||
directory: string
|
||||
manifest: PackageManifest
|
||||
}
|
||||
|
||||
function posixPath(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
function canonicalPath(path: string): string {
|
||||
let existing = resolve(path)
|
||||
const suffix: string[] = []
|
||||
while (!existsSync(existing)) {
|
||||
const parent = dirname(existing)
|
||||
/* v8 ignore next -- every absolute path reaches the existing filesystem root */
|
||||
if (parent === existing) throw new Error(`cannot resolve an existing ancestor for ${path}`)
|
||||
suffix.unshift(basename(existing))
|
||||
existing = parent
|
||||
}
|
||||
return resolve(realpathSync(existing), ...suffix)
|
||||
}
|
||||
|
||||
async function packageDirectories(root: string): Promise<string[]> {
|
||||
const result: string[] = []
|
||||
for (const vendor of await readdir(join(root, 'vendor'), { withFileTypes: true })) {
|
||||
if (vendor.isDirectory()) result.push(join(root, 'vendor', vendor.name))
|
||||
}
|
||||
for (const group of await readdir(join(root, 'packages'), { withFileTypes: true })) {
|
||||
if (!group.isDirectory()) continue
|
||||
for (const pkg of await readdir(join(root, 'packages', group.name), { withFileTypes: true })) {
|
||||
if (pkg.isDirectory()) result.push(join(root, 'packages', group.name, pkg.name))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Index of repository packages used by `--link-workspace`. */
|
||||
export class LinkWorkspace {
|
||||
readonly root: string
|
||||
private readonly packages: Map<string, WorkspacePackage>
|
||||
|
||||
private constructor(root: string, packages: Map<string, WorkspacePackage>) {
|
||||
this.root = root
|
||||
this.packages = packages
|
||||
}
|
||||
|
||||
/** Scan vendor and package workspaces from a repository root. */
|
||||
static async open(root: string): Promise<LinkWorkspace> {
|
||||
const absolute = resolve(root)
|
||||
const packages = new Map<string, WorkspacePackage>()
|
||||
for (const directory of await packageDirectories(absolute)) {
|
||||
let manifest: PackageManifest
|
||||
try {
|
||||
manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as PackageManifest
|
||||
} catch (error) {
|
||||
throw new Error(`cannot read linked package at ${directory}: ${String(error)}`)
|
||||
}
|
||||
if (!manifest.name || typeof manifest.name !== 'string') continue
|
||||
if (packages.has(manifest.name)) throw new Error(`duplicate linked package name: ${manifest.name}`)
|
||||
packages.set(manifest.name, { directory, manifest })
|
||||
}
|
||||
if (!packages.has('cordis') || !packages.has('@deepseek-ai/dsh-scripts')) {
|
||||
throw new Error(`not a DeepSeek Harness repository root: ${absolute}`)
|
||||
}
|
||||
return new LinkWorkspace(absolute, packages)
|
||||
}
|
||||
|
||||
/** Expand direct NPM dependencies through all repository-local NPM dependency edges. */
|
||||
closure(names: Iterable<string>): string[] {
|
||||
const pending = [...names]
|
||||
const result = new Set<string>()
|
||||
while (pending.length > 0) {
|
||||
const name = pending.pop()
|
||||
if (!name || result.has(name)) continue
|
||||
const pkg = this.packages.get(name)
|
||||
/* v8 ignore next -- closure() only returns names present in this package map */
|
||||
if (!pkg) continue
|
||||
result.add(name)
|
||||
const edges = {
|
||||
...pkg.manifest.dependencies,
|
||||
...pkg.manifest.peerDependencies as Record<string, string> | undefined,
|
||||
}
|
||||
for (const dependencyName of Object.keys(edges)) {
|
||||
if (this.packages.has(dependencyName) && !result.has(dependencyName)) pending.push(dependencyName)
|
||||
}
|
||||
}
|
||||
return [...result].sort()
|
||||
}
|
||||
|
||||
/** Rewrite the full local closure to manager-specific live-link specs. */
|
||||
apply(
|
||||
projectRoot: string,
|
||||
manifest: PackageJsonFile,
|
||||
manager: PackageManager,
|
||||
documents: readonly ProjectFile[],
|
||||
): void {
|
||||
const canonicalProjectRoot = canonicalPath(projectRoot)
|
||||
const names = this.closure(manifest.npmDependencyNames())
|
||||
for (const name of names) {
|
||||
const pkg = this.packages.get(name)
|
||||
/* v8 ignore next -- closure() only returns names present in this package map */
|
||||
if (!pkg) continue
|
||||
const relativePath = posixPath(relative(canonicalProjectRoot, realpathSync(pkg.directory)))
|
||||
const spec = manager.linkSpec(relativePath)
|
||||
const current = manifest.npmDependency(name)
|
||||
manifest.setNpmDependency(current?.section ?? 'dependencies', name, spec)
|
||||
if (manager.name === 'yarn') manifest.setResolution(name, spec)
|
||||
}
|
||||
if (manager.name === 'pnpm') {
|
||||
const workspace = documents.find((item): item is PnpmWorkspaceFile => item instanceof PnpmWorkspaceFile)
|
||||
if (!workspace) throw new Error('pnpm link mode requires pnpm-workspace.yaml')
|
||||
workspace.disableAutoInstallPeers()
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a package directory for diagnostics and tests. */
|
||||
packageDirectory(name: string): string | undefined {
|
||||
const directory = this.packages.get(name)?.directory
|
||||
return directory
|
||||
? resolve(dirname(directory), directory.split(sep).at(-1) as string)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
280
packages/sdk/helper/src/package-managers/package-manager.ts
Normal file
280
packages/sdk/helper/src/package-managers/package-manager.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Package-manager strategies for SDK project workspaces and child commands.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/package-managers/package-manager
|
||||
*/
|
||||
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import type { PackageJsonFile } from '../documents/package-json-file.ts'
|
||||
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
|
||||
import type { ProjectFile } from '../documents/project-file.ts'
|
||||
|
||||
/** Supported generated-project package managers. */
|
||||
export type PackageManagerName = 'npm' | 'pnpm' | 'yarn'
|
||||
|
||||
/** Result from one child package-manager process. */
|
||||
export interface CommandResult {
|
||||
exitCode: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
}
|
||||
|
||||
/** Injectable subprocess boundary used by package-manager strategies. */
|
||||
export interface CommandRunner {
|
||||
/** Run one executable without a shell and await process exit. */
|
||||
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult>
|
||||
}
|
||||
|
||||
/** Injectable package-manager version probe used by project creation. */
|
||||
export type PackageManagerVersionProbe = (name: PackageManagerName, cwd: string) => Promise<string>
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
/**
|
||||
* Read a manager version without forwarding ambient credentials.
|
||||
* @param name - package-manager executable.
|
||||
* @param cwd - working directory used for resolution.
|
||||
* @returns trimmed version output.
|
||||
*/
|
||||
export async function probePackageManagerVersion(name: PackageManagerName, cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(name, ['--version'], {
|
||||
cwd,
|
||||
env: scrubEnvironment(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const version = stdout.trim()
|
||||
if (!version) throw new Error('empty version output')
|
||||
return version
|
||||
} catch (error) {
|
||||
throw new Error(`cannot run ${name} --version: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove credential-shaped environment variables from spawned commands. */
|
||||
export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
return Object.fromEntries(Object.entries(environment).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/i.test(name)))
|
||||
}
|
||||
|
||||
/** Node child-process command runner with inherited stdio and quiescent completion. */
|
||||
export class NodeCommandRunner implements CommandRunner {
|
||||
/** Spawn one child and settle only after its exit. */
|
||||
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, [...args], {
|
||||
cwd,
|
||||
env: scrubEnvironment(),
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function major(version: string): number {
|
||||
const match = /^(\d+)/.exec(version)
|
||||
if (!match?.[1]) throw new Error(`invalid package manager version: ${JSON.stringify(version)}`)
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
/** Behavior owned by one generated-project package manager. */
|
||||
export abstract class PackageManager {
|
||||
/** Manager executable and project identity. */
|
||||
abstract readonly name: PackageManagerName
|
||||
|
||||
/** Detected concrete manager version. */
|
||||
readonly version: string
|
||||
|
||||
constructor(version: string) {
|
||||
this.version = version
|
||||
}
|
||||
|
||||
/** Validate the detected version against this SDK's supported floor. */
|
||||
abstract validateVersion(): void
|
||||
|
||||
/**
|
||||
* Configure root manifest fields and return manager-specific files.
|
||||
* @param manifest - generated root manifest to update.
|
||||
* @returns manager-specific companion documents.
|
||||
*/
|
||||
abstract configureWorkspace(manifest: PackageJsonFile): ProjectFile[]
|
||||
|
||||
/**
|
||||
* Build the NPM dependency spec for a local workspace plugin.
|
||||
* @returns manager-specific local NPM dependency spec.
|
||||
*/
|
||||
abstract localPluginSpec(): string
|
||||
|
||||
/**
|
||||
* Resolve a repository live-link NPM dependency.
|
||||
* @param relativePath - relative path from generated project to package.
|
||||
* @returns manager-specific NPM dependency spec.
|
||||
*/
|
||||
abstract linkSpec(relativePath: string): string
|
||||
|
||||
/**
|
||||
* Build install command arguments.
|
||||
* @returns arguments following the manager executable.
|
||||
*/
|
||||
installCommand(): readonly string[] {
|
||||
return ['install']
|
||||
}
|
||||
|
||||
/**
|
||||
* Build project-build command arguments.
|
||||
* @returns arguments following the manager executable.
|
||||
*/
|
||||
buildCommand(): readonly string[] {
|
||||
return ['run', 'build']
|
||||
}
|
||||
|
||||
/**
|
||||
* Run NPM dependency installation and fail on non-zero or signalled exit.
|
||||
* @param cwd - generated project directory.
|
||||
* @param runner - optional subprocess boundary.
|
||||
*/
|
||||
async install(cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
|
||||
await this.runChecked(runner, this.installCommand(), cwd, 'install')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the project build and fail on non-zero or signalled exit.
|
||||
* @param cwd - generated project directory.
|
||||
* @param runner - optional subprocess boundary.
|
||||
*/
|
||||
async build(cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
|
||||
await this.runChecked(runner, this.buildCommand(), cwd, 'build')
|
||||
}
|
||||
|
||||
private async runChecked(runner: CommandRunner, args: readonly string[], cwd: string, operation: string): Promise<void> {
|
||||
const result = await runner.run(this.name, args, cwd)
|
||||
if (result.signal !== null) {
|
||||
throw new Error(`${this.name} ${operation} was killed by ${result.signal}`)
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`${this.name} ${operation} exited with code ${String(result.exitCode)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** npm workspace behavior. */
|
||||
export class NpmPackageManager extends PackageManager {
|
||||
override readonly name = 'npm'
|
||||
|
||||
/** npm 10 is the supported floor at the repository's Node floor. */
|
||||
override validateVersion(): void {
|
||||
if (major(this.version) < 10) throw new Error(`npm >=10 is required, got ${this.version}`)
|
||||
}
|
||||
|
||||
/** Configure package.json workspaces; npm needs no companion file. */
|
||||
override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] {
|
||||
manifest.addWorkspace('plugins/*')
|
||||
manifest.setPackageManager(undefined)
|
||||
return []
|
||||
}
|
||||
|
||||
/** npm resolves workspace packages through its ordinary wildcard. */
|
||||
override localPluginSpec(): string {
|
||||
return '*'
|
||||
}
|
||||
|
||||
/** npm live links use file NPM dependencies. */
|
||||
override linkSpec(relativePath: string): string {
|
||||
return `file:${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
/** pnpm workspace behavior. */
|
||||
export class PnpmPackageManager extends PackageManager {
|
||||
override readonly name = 'pnpm'
|
||||
|
||||
/** pnpm 10 is the supported floor for strict NPM dependency-build policy. */
|
||||
override validateVersion(): void {
|
||||
if (major(this.version) < 10) throw new Error(`pnpm >=10 is required, got ${this.version}`)
|
||||
}
|
||||
|
||||
/** Configure packageManager and a structured pnpm workspace file. */
|
||||
override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] {
|
||||
manifest.setPackageManager(`pnpm@${this.version}`)
|
||||
const workspace = PnpmWorkspaceFile.create()
|
||||
workspace.addPackage('plugins/*')
|
||||
return [workspace]
|
||||
}
|
||||
|
||||
/** pnpm uses its explicit workspace protocol. */
|
||||
override localPluginSpec(): string {
|
||||
return 'workspace:*'
|
||||
}
|
||||
|
||||
/** pnpm live links use link NPM dependencies. */
|
||||
override linkSpec(relativePath: string): string {
|
||||
return `link:${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
/** Yarn Berry-compatible workspace behavior. */
|
||||
export class YarnPackageManager extends PackageManager {
|
||||
override readonly name = 'yarn'
|
||||
|
||||
/** Yarn classic is excluded because the generated project relies on modern workspaces. */
|
||||
override validateVersion(): void {
|
||||
if (major(this.version) < 2) throw new Error(`Yarn >=2 is required, got ${this.version}`)
|
||||
}
|
||||
|
||||
/** Configure packageManager and package.json workspaces. */
|
||||
override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] {
|
||||
manifest.addWorkspace('plugins/*')
|
||||
manifest.setPackageManager(`yarn@${this.version}`)
|
||||
return []
|
||||
}
|
||||
|
||||
/** Modern Yarn uses the workspace protocol. */
|
||||
override localPluginSpec(): string {
|
||||
return 'workspace:*'
|
||||
}
|
||||
|
||||
/** Yarn live links use portal NPM dependencies to preserve package identity. */
|
||||
override linkSpec(relativePath: string): string {
|
||||
return `portal:${relativePath}`
|
||||
}
|
||||
|
||||
/** Yarn runs scripts without the `run` token. */
|
||||
override buildCommand(): readonly string[] {
|
||||
return ['build']
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct and validate one package-manager strategy.
|
||||
* @param name - selected manager.
|
||||
* @param version - detected concrete version.
|
||||
* @returns validated strategy.
|
||||
*/
|
||||
export function createPackageManager(name: PackageManagerName, version: string): PackageManager {
|
||||
let manager: PackageManager
|
||||
switch (name) {
|
||||
case 'npm': manager = new NpmPackageManager(version); break
|
||||
case 'pnpm': manager = new PnpmPackageManager(version); break
|
||||
case 'yarn': manager = new YarnPackageManager(version); break
|
||||
}
|
||||
manager.validateVersion()
|
||||
return manager
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer a package manager from an explicit choice or npm user-agent value.
|
||||
* @param explicit - explicit CLI selection.
|
||||
* @param userAgent - npm-compatible user-agent string.
|
||||
* @returns selected or inferred manager name.
|
||||
*/
|
||||
export function inferPackageManagerName(
|
||||
explicit: PackageManagerName | undefined,
|
||||
userAgent: string | undefined = process.env.npm_config_user_agent,
|
||||
): PackageManagerName | undefined {
|
||||
if (explicit) return explicit
|
||||
const token = userAgent?.split(' ')[0]?.split('/')[0]
|
||||
if (token === 'npm' || token === 'pnpm' || token === 'yarn') return token
|
||||
return undefined
|
||||
}
|
||||
Reference in New Issue
Block a user