Files
deepseek-harness/packages/sdk/helper/src/project/sdk-project.ts
T
2026-07-20 19:26:04 +08:00

306 lines
11 KiB
TypeScript

/**
* Read-only aggregate for one generated or existing SDK project.
*
* @module @deepseek-ai/dsh-helper/project/sdk-project
*/
import { access, readFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import { CordisYamlFile, type CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
import { EnvFile } from '../documents/env-file.ts'
import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts'
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
import { ProjectFile, TextProjectFile } from '../documents/project-file.ts'
import { TsConfigFile } from '../documents/tsconfig-file.ts'
import {
createPackageManager,
type PackageManager,
type PackageManagerName,
} from '../package-managers/package-manager.ts'
import {
createBaselineProjectArtifacts,
createPackageJsonDoc,
createProjectTemplateContext,
} from '../templates/project-template.ts'
import type { ProjectCreationRequest, ProjectProfile, RunInterface } from './types.ts'
import type { FeatureRegistry } from '../features/registry.ts'
import { ProjectEditSession } from './project-edit-session.ts'
/** Whether a project snapshot describes uncommitted creation or files on disk. */
export type ProjectOrigin = 'create' | 'disk'
const OPTIONAL_DOCUMENTS = [
'.env',
'.env.example',
'tsconfig.json',
'pnpm-workspace.yaml',
'hooks.json',
'codex-hooks.json',
'README.md',
'index.ts',
] as const
function runInterface(entries: readonly CordisConfigEntry[]): RunInterface {
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')) return 'acp'
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-tui')) return 'tui'
return 'embed'
}
function runtimeModel(entries: readonly CordisConfigEntry[]): string {
const acp = entries.find(entry => entry.name === '@deepseek-ai/dsh-acp')
if (typeof acp?.config?.model === 'string' && acp.config.model.length > 0) return acp.config.model
const provider = entries.find(entry => entry.name === '@deepseek-ai/dsh-llm-deepseek'
|| entry.name === '@deepseek-ai/dsh-llm-pi-ai')
const models = provider?.config?.models
if (Array.isArray(models) && typeof models[0] === 'string') return models[0]
return 'deepseek-v4-flash'
}
function releaseVersion(manifest: Readonly<PackageManifest>): string {
const spec = manifest.dependencies?.['@deepseek-ai/dsh-scripts']
const match = spec && /(?:^|[^0-9])(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(spec)
return match?.[1] ?? '0.0.1'
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch (error) {
/* v8 ignore else -- the other arm requires a filesystem permission/IO fault from access */
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
/* v8 ignore next -- paired with the ignored defensive access-error arm above */
throw error
}
}
async function detectPackageManager(root: string, manifest: Readonly<PackageManifest>): Promise<PackageManager> {
let name: PackageManagerName = 'npm'
let version = '10.0.0'
const field = manifest.packageManager
if (field) {
const match = /^(npm|pnpm|yarn)@(.+)$/.exec(field)
if (!match?.[1] || !match[2]) throw new Error(`invalid packageManager field: ${field}`)
name = match[1] as PackageManagerName
version = match[2]
} else if (await pathExists(resolve(root, 'pnpm-lock.yaml'))) {
name = 'pnpm'
version = '10.0.0'
} else if (await pathExists(resolve(root, 'yarn.lock'))) {
name = 'yarn'
version = '2.0.0'
}
return createPackageManager(name, version)
}
function linkedRepositoryRoot(root: string, manifest: Readonly<PackageManifest>): string | undefined {
const spec = manifest.dependencies?.['@deepseek-ai/dsh-scripts']
const match = /^(?:file|link|portal):(.+)\/packages\/sdk\/scripts\/?$/.exec(spec ?? '')
return match?.[1] ? resolve(root, match[1]) : undefined
}
function parseOptionalDocument(path: string, text: string): ProjectFile {
try {
switch (path) {
case '.env': return EnvFile.parse('.env', text)
case '.env.example': return EnvFile.parse('.env.example', text)
case 'tsconfig.json': return TsConfigFile.parse(text)
case 'pnpm-workspace.yaml': return PnpmWorkspaceFile.parse(text)
default: return new TextProjectFile(path, text, text)
}
} catch {
// Optional malformed resources do not invalidate the project aggregate;
// an operation that needs their structure checks the concrete document type.
return new TextProjectFile(path, text, text)
}
}
/** A project snapshot whose documents can only be changed through {@link ProjectEditSession}. */
export class SdkProject {
/** Absolute project directory. */
readonly root: string
/** Whether this snapshot is an uncommitted blueprint or disk state. */
readonly origin: ProjectOrigin
/** Project identity, runtime, interface, and package-manager context. */
readonly profile: ProjectProfile
private readonly documents: ReadonlyMap<string, ProjectFile>
private constructor(
root: string,
origin: ProjectOrigin,
profile: ProjectProfile,
documents: ReadonlyMap<string, ProjectFile>,
) {
this.root = resolve(root)
this.origin = origin
this.profile = profile
this.documents = documents
}
/**
* Build an in-memory project blueprint without touching the target directory.
* @param root - target project directory.
* @param request - complete creation request.
* @returns uncommitted project snapshot.
*/
static create(root: string, request: ProjectCreationRequest): SdkProject {
const app = request.features.find(selection => selection.id === 'app')
const selectedInterface = app?.options[0]
if (selectedInterface !== 'acp' && selectedInterface !== 'tui' && selectedInterface !== 'embed') {
throw new Error('project creation requires one app feature option')
}
const profile: ProjectProfile = {
name: request.name,
description: request.description,
runtime: request.runtime,
runInterface: selectedInterface,
packageManager: request.packageManager,
releaseVersion: request.releaseVersion,
...request.linkWorkspaceRoot ? { linkWorkspaceRoot: resolve(request.linkWorkspaceRoot) } : {},
}
const templates = createProjectTemplateContext(profile)
const manifest = createPackageJsonDoc(templates)
const documents = new Map<string, ProjectFile>()
documents.set(manifest.relativePath, manifest)
documents.set('cordis.yml', CordisYamlFile.create())
documents.set('.env.example', EnvFile.create('.env.example'))
documents.set('tsconfig.json', TsConfigFile.create())
for (const document of request.packageManager.configureWorkspace(manifest)) {
documents.set(document.relativePath, document)
}
for (const document of createBaselineProjectArtifacts(templates)) {
documents.set(document.relativePath, document)
}
return new SdkProject(root, 'create', profile, documents)
}
/**
* Load an existing project from required and SDK-managed optional files.
* @param root - existing project directory.
* @returns disk-backed project snapshot.
*/
static async open(root: string): Promise<SdkProject> {
const absolute = resolve(root)
const [manifestText, cordisText] = await Promise.all([
readFile(resolve(absolute, 'package.json'), 'utf8'),
readFile(resolve(absolute, 'cordis.yml'), 'utf8'),
])
const manifest = PackageJsonFile.parse(manifestText)
const cordis = CordisYamlFile.parse(cordisText)
const value = manifest.value()
const manager = await detectPackageManager(absolute, value)
const entries = cordis.entries()
const linkWorkspaceRoot = linkedRepositoryRoot(absolute, value)
const profile: ProjectProfile = {
name: value.name ?? basename(absolute),
description: typeof value.description === 'string' ? value.description : '',
runtime: { model: runtimeModel(entries) },
runInterface: runInterface(entries),
packageManager: manager,
releaseVersion: releaseVersion(value),
...linkWorkspaceRoot ? { linkWorkspaceRoot } : {},
}
const documents = new Map<string, ProjectFile>([
['package.json', manifest],
['cordis.yml', cordis],
])
await Promise.all(OPTIONAL_DOCUMENTS.map(async (path) => {
try {
const text = await readFile(resolve(absolute, path), 'utf8')
documents.set(path, parseOptionalDocument(path, text))
} catch (error) {
/* v8 ignore next -- optional-file reads fail normally only with ENOENT; other IO faults surface */
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}))
return new SdkProject(absolute, 'disk', profile, documents)
}
/**
* Read the root package manifest defensively.
* @returns cloned manifest value.
*/
packageManifest(): Readonly<PackageManifest> {
return this.packageJson.value()
}
/**
* Read Cordis config entries defensively in file order.
* @returns cloned Cordis config entries.
*/
cordisConfigEntries(): readonly CordisConfigEntry[] {
return this.cordis.entries()
}
/**
* Check whether this snapshot contains one managed document.
* @param path - project-relative document path.
* @returns whether the document is loaded.
*/
hasDocument(path: string): boolean {
return this.documents.has(path)
}
/**
* Read one environment variable from a loaded dotenv document.
* @param path - environment file to read.
* @param name - variable name.
* @returns variable value when present.
*/
readEnvironment(path: '.env' | '.env.example', name: string): string | undefined {
const document = this.documents.get(path)
if (!document) return undefined
if (!(document instanceof EnvFile)) throw new Error(`${path} is not an environment document`)
return document.get(name)
}
/** Read the root package document. */
get packageJson(): PackageJsonFile {
const document = this.documents.get('package.json')
if (!(document instanceof PackageJsonFile)) throw new Error('project package.json is missing or invalid')
return document
}
/** Read the root Cordis document. */
get cordis(): CordisYamlFile {
const document = this.documents.get('cordis.yml')
if (!(document instanceof CordisYamlFile)) throw new Error('project cordis.yml is missing or invalid')
return document
}
/**
* Return one managed document without exposing the aggregate map.
* @param path - project-relative document path.
* @returns loaded document when present.
*/
document(path: string): ProjectFile | undefined {
return this.documents.get(path)
}
/**
* Create the only mutable boundary for this snapshot.
* @param registry - feature catalog governing edits.
* @returns isolated edit session.
*/
edit(registry: FeatureRegistry): ProjectEditSession {
return new ProjectEditSession(this, registry)
}
/**
* Clone every managed document for an isolated edit session.
* @returns project-relative document map.
*/
cloneDocuments(): Map<string, ProjectFile> {
return new Map([...this.documents].map(([path, document]) => [path, document.clone()]))
}
/**
* Reload this aggregate from committed disk state.
* @returns fresh disk-backed snapshot.
*/
reopen(): Promise<SdkProject> {
return SdkProject.open(this.root)
}
}