Files
deepseek-harness/packages/sdk/helper/src/documents/package-json-file.ts
T

170 lines
6.0 KiB
TypeScript

/**
* 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<string, string>
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
workspaces?: string[]
resolutions?: Record<string, string>
[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<string, string>): Record<string, string> {
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<PackageManifest> {
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))
}
}