/** * Structured package.json document owned by an SDK project. * * @module @deepseek-ai/dsh-helper/documents/package-json-file */ import { ProjectFile, withTrailingNewline } from './project-file.ts' /** NPM dependency sections managed by the SDK. */ export type NpmDependencySection = 'dependencies' | 'devDependencies' /** JSON shape retained by {@link PackageJsonFile}. */ export interface PackageManifest { name?: string version?: string private?: boolean description?: string type?: string packageManager?: string scripts?: Record dependencies?: Record devDependencies?: Record workspaces?: string[] resolutions?: Record [key: string]: unknown } function parseManifest(text: string): PackageManifest { let value: unknown try { value = JSON.parse(text) } catch (error) { throw new Error(`invalid package.json: ${String(error)}`) } if (value === null || Array.isArray(value) || typeof value !== 'object') { throw new Error('invalid package.json: root must be an object') } return value as PackageManifest } function sortedRecord(value: Record): Record { return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))) } /** Editable, deterministic package.json representation. */ export class PackageJsonFile extends ProjectFile { private readonly manifest: PackageManifest private constructor(manifest: PackageManifest, originalText?: string) { super('package.json', originalText) this.manifest = structuredClone(manifest) } /** Create a new package manifest from a complete rendered template. */ static create(text: string): PackageJsonFile { return new PackageJsonFile(parseManifest(text)) } /** Parse an existing package.json document. */ static parse(text: string): PackageJsonFile { return new PackageJsonFile(parseManifest(text), text) } /** Clone this document and its nested manifest data. */ override clone(): PackageJsonFile { return new PackageJsonFile(this.manifest, this.originalText) } /** Return a defensive copy of the manifest. */ value(): Readonly { return structuredClone(this.manifest) } /** Set one package script. */ setScript(name: string, command: string): void { this.manifest.scripts ??= {} this.manifest.scripts[name] = command } /** Read one package script. */ script(name: string): string | undefined { return this.manifest.scripts?.[name] } /** Remove one package script. */ removeScript(name: string): void { delete this.manifest.scripts?.[name] } /** Set one NPM dependency in its runtime or development section. */ setNpmDependency(section: NpmDependencySection, name: string, spec: string): void { this.manifest[section] ??= {} this.manifest[section][name] = spec } /** Remove one NPM dependency from a section. */ removeNpmDependency(section: NpmDependencySection, name: string): void { delete this.manifest[section]?.[name] } /** Read an NPM dependency spec from either managed section. */ npmDependency(name: string): { section: NpmDependencySection; spec: string } | undefined { for (const section of ['dependencies', 'devDependencies'] as const) { const spec = this.manifest[section]?.[name] if (spec !== undefined) return { section, spec } } return undefined } /** Return all managed NPM dependency names. */ npmDependencyNames(): string[] { return [...new Set([ ...Object.keys(this.manifest.dependencies ?? {}), ...Object.keys(this.manifest.devDependencies ?? {}), ])].sort() } /** Add a package-manager workspace glob. */ addWorkspace(pattern: string): void { const workspaces = this.manifest.workspaces ??= [] if (!workspaces.includes(pattern)) workspaces.push(pattern) } /** Set or remove the packageManager field. */ setPackageManager(value: string | undefined): void { if (value === undefined) delete this.manifest.packageManager else this.manifest.packageManager = value } /** Pin a Yarn resolution used by live-link projects. */ setResolution(name: string, spec: string): void { this.manifest.resolutions ??= {} this.manifest.resolutions[name] = spec } /** Validate the fields the SDK relies on. */ override validate(): void { if (!this.manifest.name || typeof this.manifest.name !== 'string') { throw new Error('package.json name must be a non-empty string') } for (const section of ['scripts', 'dependencies', 'devDependencies'] as const) { const value: unknown = this.manifest[section] if (value === undefined) continue if (value === null || Array.isArray(value) || typeof value !== 'object') { throw new Error(`package.json ${section} must be an object`) } for (const [key, item] of Object.entries(value)) { if (typeof item !== 'string' || item.length === 0) { throw new Error(`package.json ${section}.${key} must be a non-empty string`) } } } if (this.manifest.workspaces !== undefined && (!Array.isArray(this.manifest.workspaces) || this.manifest.workspaces.some(item => typeof item !== 'string'))) { throw new Error('package.json workspaces must be an array of strings') } } /** Serialize with deterministic managed maps and two-space JSON formatting. */ override serialize(): string { const value: PackageManifest = structuredClone(this.manifest) if (this.manifest.scripts) value.scripts = sortedRecord(this.manifest.scripts) if (this.manifest.dependencies) value.dependencies = sortedRecord(this.manifest.dependencies) if (this.manifest.devDependencies) value.devDependencies = sortedRecord(this.manifest.devDependencies) if (this.manifest.workspaces) value.workspaces = [...this.manifest.workspaces].sort() if (this.manifest.resolutions) value.resolutions = sortedRecord(this.manifest.resolutions) return withTrailingNewline(JSON.stringify(value, null, 2)) } }