Files
deepseek-harness/packages/sdk/helper/src/project/project-edit-session.ts
2026-08-05 16:16:57 +08:00

617 lines
26 KiB
TypeScript

/**
* Isolated domain-command and commit boundary for SDK project changes.
*
* @module @deepseek-ai/dsh-helper/project/project-edit-session
*/
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import type {
Feature,
FeatureInstallation,
FeatureProjectView,
FeatureRequirement,
} from '../features/feature.ts'
import type { FeatureRegistry } from '../features/registry.ts'
import type { ProjectResource } from '../features/resources.ts'
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 { ProjectFile } from '../documents/project-file.ts'
import { TsConfigFile } from '../documents/tsconfig-file.ts'
import { featureId, type FeatureId, type ResourceKey } from '../ids.ts'
import { LinkWorkspace } from '../package-managers/link-workspace.ts'
import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts'
import type { FeatureSelection, ProjectProfile } from './types.ts'
import { resolveNpmDependency } from './npm-dependency-policy.ts'
import type { ChangeSet, ProjectCommitResult } from './change-set.ts'
import type { SdkProject } from './sdk-project.ts'
interface MutableFeatureState {
selection?: FeatureSelection
state: FeatureInstallation['state']
}
function sameText(left: ProjectFile, right: ProjectFile | undefined): boolean {
return right !== undefined && left.serialize() === right.serialize()
}
function npmDependencyShape(manifest: Readonly<PackageManifest>): string {
return JSON.stringify({
/* v8 ignore next -- generated manifests always carry the managed dependency maps */
dependencies: manifest.dependencies ?? {},
/* v8 ignore next -- generated manifests always carry the managed dependency maps */
devDependencies: manifest.devDependencies ?? {},
})
}
function asError(error: unknown): Error {
/* v8 ignore else -- node:fs promise APIs reject Error objects */
if (error instanceof Error) return error
/* v8 ignore next -- node:fs promise APIs reject Error objects */
return new Error(String(error))
}
function canUpdateResource(previous: ProjectResource, next: ProjectResource): boolean {
if (previous.kind !== next.kind) return false
switch (previous.kind) {
case 'npm-dependency': return previous.name === (next as typeof previous).name
case 'package-script': return previous.name === (next as typeof previous).name
case 'cordis-config-entry': {
const candidate = next as typeof previous
return previous.entry.name === candidate.entry.name
}
case 'environment': return previous.name === (next as typeof previous).name
case 'owned-file': return previous.document.relativePath === (next as typeof previous).document.relativePath
}
}
/** Mutable working copy that applies feature and local-plugin domain commands. */
export class ProjectEditSession implements FeatureProjectView {
readonly profile: ProjectProfile
private readonly source: SdkProject
private readonly registry: FeatureRegistry
private readonly documents: Map<string, ProjectFile>
private readonly removed = new Map<string, ProjectFile>()
private readonly states = new Map<FeatureId, MutableFeatureState>()
private readonly added = new Set<FeatureId>()
private readonly enabled = new Set<FeatureId>()
private readonly disabled = new Set<FeatureId>()
private readonly configured = new Set<FeatureId>()
private readonly addedPlugins = new Set<string>()
private readonly enabledPlugins = new Set<string>()
private readonly disabledPlugins = new Set<string>()
private committed = false
/** Clone one project snapshot into an isolated working copy. */
constructor(source: SdkProject, registry: FeatureRegistry) {
this.source = source
this.registry = registry
this.profile = source.profile
this.documents = source.cloneDocuments()
for (const feature of registry.all()) {
/* v8 ignore next -- no current built-in feature is interface-specific */
if (!feature.isApplicable(this.profile)) continue
const installation = feature.inspect(this)
this.states.set(feature.id, {
state: installation.state,
...installation.selection ? { selection: installation.selection } : {},
})
}
}
/** Root manifest value for feature inspection. */
packageManifest(): Readonly<PackageManifest> {
return this.manifest().value()
}
/** Cordis config entries for feature and custom-plugin inspection. */
cordisConfigEntries(): readonly CordisConfigEntry[] {
return this.cordis().entries()
}
/** Whether one managed document exists in the working copy. */
/* jscpd:ignore-start -- FeatureProjectView deliberately has symmetric snapshot/edit implementations. */
hasDocument(path: string): boolean {
return this.documents.has(path)
}
/** Read one unique working-copy environment variable. */
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)
}
/* jscpd:ignore-end */
/** Inspect every applicable builtin against the current working copy. */
inspections(): readonly FeatureInstallation[] {
return this.registry.inspect(this)
}
/** Install a builtin and recursively satisfy its declared requirements. */
installFeature(feature: Feature, selection: FeatureSelection): void {
this.assertOpen()
this.installFeatureRecursive(feature, selection, new Set())
}
/** Replace one installed builtin's feature-option and captured-input selection. */
configureFeature(feature: Feature, selection: FeatureSelection): void {
this.assertOpen()
const current = this.state(feature)
if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`)
if (current.state === 'absent' || !current.selection) {
this.installFeature(feature, selection)
return
}
const normalized = feature.normalizeSelection(selection, this.profile)
this.ensureRequirements(feature, normalized, new Set([feature.id]))
this.replaceContribution(
feature.contribution(current.selection, this.profile),
feature.contribution(normalized, this.profile),
)
current.selection = normalized
current.state = current.state === 'disabled' ? 'disabled' : 'enabled'
if (current.state === 'disabled') this.setFeatureDisabled(feature, normalized, true)
this.assertFeatureConsistent(feature)
this.configured.add(feature.id)
}
/** Enable all entries owned by one installed feature. */
enableFeature(feature: Feature): void {
this.assertOpen()
const current = this.state(feature)
if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`)
if (current.state === 'absent' || !current.selection) {
throw new Error(`feature ${feature.id} is not installed`)
}
this.setFeatureDisabled(feature, current.selection, false)
current.state = 'enabled'
this.assertFeatureConsistent(feature)
this.disabled.delete(feature.id)
this.enabled.add(feature.id)
}
/** Disable an optional feature without removing its configuration. */
disableFeature(feature: Feature): void {
this.assertOpen()
if (feature.required) throw new Error(`required feature ${feature.id} cannot be disabled`)
const current = this.state(feature)
if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`)
if (current.state === 'absent' || !current.selection) {
throw new Error(`feature ${feature.id} is not installed`)
}
const dependent = this.registry.all().find((candidate) => {
const state = this.states.get(candidate.id)
return state?.state === 'enabled' && state.selection
&& candidate.requirements(state.selection).some(requirement => requirement.id === feature.id)
})
if (dependent) throw new Error(`feature ${feature.id} is required by ${dependent.id}`)
this.setFeatureDisabled(feature, current.selection, true)
current.state = 'disabled'
this.assertFeatureConsistent(feature)
this.enabled.delete(feature.id)
this.disabled.add(feature.id)
}
/** Add a generated local plugin and all four of its project registrations. */
addPlugin(blueprint: LocalPluginBlueprint): void {
this.assertOpen()
const manifest = this.manifest()
const cordis = this.cordis()
const tsconfig = this.documents.get('tsconfig.json')
if (!(tsconfig instanceof TsConfigFile)) {
throw new Error('adding a local plugin requires a valid tsconfig.json')
}
const packageName = blueprint.packageName(this.profile.name)
if (manifest.npmDependency(packageName)) throw new Error(`root NPM dependency already exists: ${packageName}`)
const entry = blueprint.cordisConfigEntry(this.profile.name)
if (cordis.entry(entry.id)) throw new Error(`Cordis config entry already exists: ${entry.id}`)
const documents = blueprint.documents(this.profile.name, this.profile.releaseVersion)
for (const document of documents) {
if (this.documents.has(document.relativePath)) {
throw new Error(`local plugin file already exists: ${document.relativePath}`)
}
}
for (const document of documents) this.documents.set(document.relativePath, document)
manifest.setNpmDependency('dependencies', packageName, this.profile.packageManager.localPluginSpec())
tsconfig.addReference(`./${blueprint.directory}`)
cordis.addEntry(entry)
this.addedPlugins.add(entry.id)
}
/**
* Mount a Cordis entry for an external dependency the package manager has already
* added (github or npm), without generating files or re-adding the dependency.
* @param id - stable Cordis config entry id.
* @param packageName - the installed dependency's package name.
*/
addExternalPlugin(id: string, packageName: string): void {
this.assertOpen()
if (!this.manifest().npmDependency(packageName)) {
throw new Error(`external plugin dependency is not installed: ${packageName}`)
}
const cordis = this.cordis()
if (cordis.entry(id)) throw new Error(`Cordis config entry already exists: ${id}`)
cordis.addEntry({ id, name: packageName })
this.addedPlugins.add(id)
}
/** Enable or disable one custom/manual Cordis config entry by stable id. */
setCustomPluginDisabled(id: string, disabled: boolean): void {
this.assertOpen()
const entry = this.cordis().entry(id)
if (!entry) throw new Error(`Cordis config entry does not exist: ${id}`)
if (this.registry.ownerOfPackage(entry.name, this.profile)) {
throw new Error(`Cordis config entry ${id} belongs to a builtin feature`)
}
this.cordis().setDisabled(id, disabled)
if (disabled) {
this.enabledPlugins.delete(id)
this.disabledPlugins.add(id)
} else {
this.disabledPlugins.delete(id)
this.enabledPlugins.add(id)
}
}
/** Summarize all pending domain and file changes. */
changes(): ChangeSet {
const changedFiles = new Set<string>()
for (const [path, document] of this.documents) {
if (this.source.origin === 'create' || !sameText(document, this.source.document(path))) changedFiles.add(path)
}
for (const path of this.removed.keys()) changedFiles.add(path)
return {
addedFeatures: [...this.added].sort(),
enabledFeatures: [...this.enabled].sort(),
disabledFeatures: [...this.disabled].sort(),
configuredFeatures: [...this.configured].sort(),
addedPlugins: [...this.addedPlugins].sort(),
enabledPlugins: [...this.enabledPlugins].sort(),
disabledPlugins: [...this.disabledPlugins].sort(),
changedFiles: [...changedFiles].sort(),
npmDependenciesChanged: npmDependencyShape(this.manifest().value())
!== npmDependencyShape(this.source.packageManifest()),
}
}
/** Validate, detect external edits, write affected files, and return a fresh snapshot. */
async commit(): Promise<ProjectCommitResult<SdkProject>> {
this.assertOpen()
if (this.profile.linkWorkspaceRoot) {
const workspace = await LinkWorkspace.open(this.profile.linkWorkspaceRoot)
workspace.apply(
this.source.root,
this.manifest(),
this.profile.packageManager,
[...this.documents.values()],
)
}
this.validateFinalState()
const changes = this.changes()
await this.assertUnchanged(changes.changedFiles)
await mkdir(this.source.root, { recursive: true })
for (const path of changes.changedFiles) {
const document = this.documents.get(path)
const absolute = resolve(this.source.root, path)
if (!document) {
await unlink(absolute)
continue
}
await mkdir(dirname(absolute), { recursive: true })
await writeFile(absolute, document.serialize(), {
encoding: 'utf8',
...document.createMode === undefined ? {} : { mode: document.createMode },
})
}
this.committed = true
return { project: await this.source.reopen(), changes }
}
private installFeatureRecursive(
feature: Feature,
selection: FeatureSelection,
stack: Set<FeatureId>,
): void {
if (stack.has(feature.id)) throw new Error(`cyclic feature requirement involving ${feature.id}`)
const current = this.state(feature)
if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`)
if (current.state !== 'absent' && current.selection) {
this.configureFeature(feature, selection)
if (current.state === 'disabled') this.enableFeature(feature)
return
}
const normalized = feature.normalizeSelection(selection, this.profile)
const nextStack = new Set(stack).add(feature.id)
this.ensureRequirements(feature, normalized, nextStack)
this.replaceContribution(undefined, feature.contribution(normalized, this.profile))
current.selection = normalized
current.state = 'enabled'
this.assertFeatureConsistent(feature)
this.added.add(feature.id)
}
private ensureRequirements(feature: Feature, selection: FeatureSelection, stack: Set<FeatureId>): void {
for (const requirement of feature.requirements(selection)) {
const required = this.registry.get(requirement.id)
const state = this.state(required)
if (state.state === 'inconsistent') throw new Error(`required feature ${required.id} is inconsistent`)
if (state.state === 'absent' || !state.selection) {
this.installFeatureRecursive(required, {
id: required.id,
options: requirement.options ?? required.defaultOptions(this.profile),
}, stack)
} else {
const next = this.selectionWithRequiredOptions(required, state.selection, requirement)
if (next !== state.selection) this.configureFeature(required, next)
if (state.state === 'disabled') this.enableFeature(required)
}
}
}
private selectionWithRequiredOptions(
feature: Feature,
selection: FeatureSelection,
requirement: FeatureRequirement,
): FeatureSelection {
if (!requirement.options || requirement.options.every(option => selection.options.includes(option))) {
return selection
}
if (feature.mode !== 'multiple') {
throw new Error(`${feature.id} does not satisfy the option requirement from another feature`)
}
return { ...selection, options: [...new Set([...selection.options, ...requirement.options])] }
}
private replaceContribution(
previous: ReturnType<Feature['contribution']> | undefined,
next: ReturnType<Feature['contribution']>,
): void {
const previousByKey = previous?.byKey() ?? new Map<ResourceKey, ProjectResource>()
const nextByKey = next.byKey()
for (const [key, resource] of previousByKey) {
const replacement = nextByKey.get(key)
if (!replacement || !canUpdateResource(resource, replacement)) this.removeResource(resource)
}
for (const [key, resource] of nextByKey) {
const previousResource = previousByKey.get(key)
this.applyResource(
resource,
previousResource && canUpdateResource(previousResource, resource) ? previousResource : undefined,
)
}
}
private applyResource(resource: ProjectResource, previous: ProjectResource | undefined): void {
switch (resource.kind) {
case 'npm-dependency': {
const dependency = resolveNpmDependency(resource.name, resource.section, this.profile.releaseVersion)
this.manifest().setNpmDependency(dependency.section, resource.name, dependency.spec)
return
}
case 'package-script': {
const manifest = this.manifest()
const current = manifest.script(resource.name)
if (!previous || previous.kind !== 'package-script') {
if (current !== undefined) throw new Error(`feature-owned package script already exists: ${resource.name}`)
manifest.setScript(resource.name, resource.command)
return
}
if (current === resource.command) return
if (current !== previous.command) {
throw new Error(`feature-owned package script was modified: ${resource.name}`)
}
manifest.setScript(resource.name, resource.command)
return
}
case 'cordis-config-entry': {
const current = this.cordis().entry(resource.entry.id)
if (!current) this.cordis().addEntry(resource.entry, resource.commentedExample)
else {
if (current.name !== resource.entry.name) {
throw new Error(`Cordis config entry ${resource.entry.id} is owned by ${current.name}, not ${resource.entry.name}`)
}
this.cordis().updateOwnedConfig(
resource.entry.id,
resource.ownedConfigKeys,
resource.entry.config ?? {},
)
this.cordis().setDisabled(resource.entry.id, false)
}
return
}
case 'environment': {
this.environment('.env.example').set(resource.name, resource.exampleValue)
/* v8 ignore else -- an omitted secret intentionally materializes only its example placeholder */
if (resource.value !== undefined) {
const environment = this.environment('.env')
environment.append(
resource.name,
resource.value,
resource.value === '' ? resource.comment : undefined,
)
}
return
}
case 'owned-file': {
const existing = this.documents.get(resource.document.relativePath)
if (!existing) {
this.documents.set(resource.document.relativePath, resource.document.clone())
this.removed.delete(resource.document.relativePath)
return
}
if (!previous || previous.kind !== 'owned-file') {
throw new Error(`feature-owned file already exists: ${resource.document.relativePath}`)
}
if (previous.document.serialize() === resource.document.serialize()) return
if (existing.serialize() !== previous.document.serialize()) {
throw new Error(`feature-owned file was modified: ${resource.document.relativePath}`)
}
this.documents.set(resource.document.relativePath, resource.document.clone())
this.removed.delete(resource.document.relativePath)
return
}
}
}
private removeResource(resource: ProjectResource): void {
switch (resource.kind) {
case 'npm-dependency':
this.manifest().removeNpmDependency(resource.section, resource.name)
return
case 'package-script': {
const manifest = this.manifest()
const current = manifest.script(resource.name)
if (current === undefined) throw new Error(`owned package script is missing: ${resource.name}`)
if (resource.removeOnlyWhenUnchanged && current !== resource.command) {
throw new Error(`feature-owned package script was modified: ${resource.name}`)
}
manifest.removeScript(resource.name)
return
}
case 'cordis-config-entry': {
const entry = this.cordis().entry(resource.entry.id)
if (!entry || entry.name !== resource.entry.name) {
throw new Error(`cannot confirm old Cordis resource ${resource.entry.id}`)
}
this.cordis().removeEntry(resource.entry.id)
return
}
case 'environment':
this.environment('.env.example').remove(resource.name)
return
case 'owned-file': {
const document = this.documents.get(resource.document.relativePath)
if (!document) throw new Error(`owned file is missing: ${resource.document.relativePath}`)
if (resource.removeOnlyWhenUnchanged && document.serialize() !== resource.document.serialize()) {
throw new Error(`owned file was modified: ${resource.document.relativePath}`)
}
this.documents.delete(resource.document.relativePath)
if (this.source.document(resource.document.relativePath)) {
this.removed.set(resource.document.relativePath, document)
}
}
}
}
private setFeatureDisabled(feature: Feature, selection: FeatureSelection, disabled: boolean): void {
for (const resource of feature.contribution(selection, this.profile).resources) {
if (resource.kind === 'cordis-config-entry') this.cordis().setDisabled(resource.entry.id, disabled)
}
}
private validateFinalState(): void {
for (const document of this.documents.values()) document.validate()
const profile = this.finalProfile()
const view = this.projectView(profile)
for (const feature of this.registry.all()) {
const state = this.states.get(feature.id)
/* v8 ignore next 5 -- no current built-in feature is interface-specific */
if (!feature.isApplicable(profile)) {
if (state?.state === 'enabled') {
throw new Error(`feature ${feature.id} is not available for ${profile.runInterface}`)
}
continue
}
const installation = feature.inspect(view)
/* v8 ignore next 3 -- public domain commands assert feature consistency before final validation */
if (installation.state === 'inconsistent') {
throw new Error(`feature ${feature.id} is inconsistent: ${installation.diagnostics.join('; ')}`)
}
/* v8 ignore next 3 -- required features are installed by creation and cannot be disabled by public commands */
if (feature.required && installation.state !== 'enabled') {
throw new Error(`required feature ${feature.id} must be installed and enabled`)
}
if (installation.state !== 'enabled' || !installation.selection) continue
for (const requirement of feature.requirements(installation.selection)) {
const required = this.registry.get(requirement.id).inspect(view)
/* v8 ignore next 3 -- ensureRequirements establishes enabled requirements before contributions change */
if (required.state !== 'enabled') {
throw new Error(`feature ${feature.id} requires enabled ${requirement.id}`)
}
for (const option of requirement.options ?? []) {
/* v8 ignore next 3 -- selectionWithRequiredOptions establishes required options before commit */
if (!required.options.includes(option)) {
throw new Error(`feature ${feature.id} requires ${requirement.id} option ${option}`)
}
}
}
}
}
private assertFeatureConsistent(feature: Feature): void {
const installation = feature.inspect(this)
/* v8 ignore next 3 -- resource application either succeeds completely or throws at the owning operation */
if (installation.state === 'inconsistent') {
throw new Error(`feature ${feature.id} is inconsistent: ${installation.diagnostics.join('; ')}`)
}
}
private finalProfile(): ProjectProfile {
const runInterface = this.states.get(featureId('app'))?.selection?.options[0]
if (runInterface !== 'acp' && runInterface !== 'embed') return this.profile
return { ...this.profile, runInterface }
}
private projectView(profile: ProjectProfile): FeatureProjectView {
return {
profile,
cordisConfigEntries: () => this.cordisConfigEntries(),
packageManifest: () => this.packageManifest(),
hasDocument: path => this.hasDocument(path),
readEnvironment: (path, name) => this.readEnvironment(path, name),
}
}
private async assertUnchanged(paths: readonly string[]): Promise<void> {
for (const path of paths) {
const source = this.source.document(path)
const absolute = resolve(this.source.root, path)
try {
const current = await readFile(absolute, 'utf8')
if (source?.originalText === undefined || current !== source.originalText) {
throw new Error(`project file changed outside this edit session: ${path}`)
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT' && source?.originalText === undefined) continue
if (error instanceof Error && error.message.startsWith('project file changed outside')) throw error
throw new Error(`cannot verify project file ${path}: ${asError(error).message}`)
}
}
}
private state(feature: Feature): MutableFeatureState {
const state = this.states.get(feature.id)
if (!state) throw new Error(`feature ${feature.id} is not applicable to this project`)
return state
}
private manifest(): PackageJsonFile {
const document = this.documents.get('package.json')
if (!(document instanceof PackageJsonFile)) throw new Error('project package.json is missing')
return document
}
private cordis(): CordisYamlFile {
const document = this.documents.get('cordis.yml')
if (!(document instanceof CordisYamlFile)) throw new Error('project cordis.yml is missing')
return document
}
private environment(path: '.env' | '.env.example'): EnvFile {
const existing = this.documents.get(path)
if (existing instanceof EnvFile) return existing
if (existing) throw new Error(`${path} is not an environment document`)
const document = EnvFile.create(path)
this.documents.set(path, document)
return document
}
private assertOpen(): void {
if (this.committed) throw new Error('project edit session has already committed')
}
}