170 lines
6.0 KiB
TypeScript
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))
|
|
}
|
|
}
|