diff --git a/packages/sdk/create-sdk/src/command.ts b/packages/sdk/create-sdk/src/command.ts index 613eb62a4e..0c7076c90b 100644 --- a/packages/sdk/create-sdk/src/command.ts +++ b/packages/sdk/create-sdk/src/command.ts @@ -81,7 +81,8 @@ export async function createProject( } context.stdout.write(CREATE_TEMPLATES.nextSteps.render({ directory: resolved.directory, - packageManager: resolved.request.packageManager.name, + setupRequired: !resolved.install, + ...packageManagerTemplateModel(resolved.request.packageManager), })) return result } diff --git a/packages/sdk/create-sdk/src/templates/assets/next-steps.txt.tpl b/packages/sdk/create-sdk/src/templates/assets/next-steps.txt.tpl index 247d1259aa..c3f237deb6 100644 --- a/packages/sdk/create-sdk/src/templates/assets/next-steps.txt.tpl +++ b/packages/sdk/create-sdk/src/templates/assets/next-steps.txt.tpl @@ -1 +1,5 @@ +{{#if setupRequired}} +Next: cd {{directory}} && {{packageManager}} {{installArgs}} && {{packageManager}} {{buildArgs}} && {{packageManager}} start +{{else}} Next: cd {{directory}} && {{packageManager}} start +{{/if}} diff --git a/packages/sdk/create-sdk/src/templates/create-templates.ts b/packages/sdk/create-sdk/src/templates/create-templates.ts index 99a587ce89..efeaf759c6 100644 --- a/packages/sdk/create-sdk/src/templates/create-templates.ts +++ b/packages/sdk/create-sdk/src/templates/create-templates.ts @@ -15,9 +15,9 @@ interface CreatedTemplateModel { directory: string } -interface NextStepsTemplateModel { +interface NextStepsTemplateModel extends PackageManagerTemplateModel { directory: string - packageManager: PackageManagerName + setupRequired: boolean } interface SetupFailureTemplateModel extends PackageManagerTemplateModel { diff --git a/packages/sdk/create-sdk/tests/create.snapshot.ts b/packages/sdk/create-sdk/tests/create.snapshot.ts index 1f7a13dd4e..a5ea46db53 100644 --- a/packages/sdk/create-sdk/tests/create.snapshot.ts +++ b/packages/sdk/create-sdk/tests/create.snapshot.ts @@ -126,7 +126,8 @@ describe('create-sdk terminal contract', () => { }), next: CREATE_TEMPLATES.nextSteps.render({ directory: resolved.directory, - packageManager: resolved.request.packageManager.name, + setupRequired: false, + ...packageManagerTemplateModel(resolved.request.packageManager), }), failure: CREATE_TEMPLATES.setupFailure.render({ directory: resolved.directory, diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index c673395649..8233806ac5 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -449,6 +449,7 @@ describe('create command composition', () => { 'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none', ])) await expect(createProject(argv('next', false), noInstall)).resolves.toBeDefined() + expect(noInstall.readStdout()).toContain('npm install && npm run build && npm start') }) it('uses the package manager setup path when no setup override is supplied', async () => { diff --git a/packages/sdk/helper/src/documents/env-file.ts b/packages/sdk/helper/src/documents/env-file.ts index 5e076d0e34..d0e6ca8473 100644 --- a/packages/sdk/helper/src/documents/env-file.ts +++ b/packages/sdk/helper/src/documents/env-file.ts @@ -19,7 +19,7 @@ export class EnvFile extends ProjectFile { private readonly lines: string[] private constructor(relativePath: '.env' | '.env.example', lines: string[], originalText?: string) { - super(relativePath, originalText) + super(relativePath, originalText, relativePath === '.env' ? 0o600 : undefined) this.lines = [...lines] } diff --git a/packages/sdk/helper/src/documents/package-json-file.ts b/packages/sdk/helper/src/documents/package-json-file.ts index d979906b96..f565f383d2 100644 --- a/packages/sdk/helper/src/documents/package-json-file.ts +++ b/packages/sdk/helper/src/documents/package-json-file.ts @@ -77,6 +77,16 @@ export class PackageJsonFile extends ProjectFile { 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] ??= {} diff --git a/packages/sdk/helper/src/documents/project-file.ts b/packages/sdk/helper/src/documents/project-file.ts index c5ec38cc07..9be20c3395 100644 --- a/packages/sdk/helper/src/documents/project-file.ts +++ b/packages/sdk/helper/src/documents/project-file.ts @@ -17,12 +17,16 @@ export abstract class ProjectFile { /** Text observed when the document entered the snapshot; absent for a new file. */ readonly originalText: string | undefined - protected constructor(relativePath: string, originalText?: string) { + /** Permission bits used only when the file is first created. */ + readonly createMode: number | undefined + + protected constructor(relativePath: string, originalText?: string, createMode?: number) { if (relativePath.startsWith('/') || relativePath.split('/').includes('..')) { throw new Error(`project document path must stay inside the project: ${relativePath}`) } this.relativePath = relativePath this.originalText = originalText + this.createMode = createMode } /** Clone the document for an isolated edit session. */ diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index 8e20e82e44..a89f2c84ae 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -6,15 +6,41 @@ import { featureId } from '../../ids.ts' import type { ProjectProfile } from '../../project/types.ts' +import { + createAppPackageScripts, + createAppProjectArtifacts, + createProjectTemplateContext, +} from '../../templates/project-template.ts' import { FeatureOption, ExclusiveOptionFeature, } from '../feature.ts' -import { ProjectContribution } from '../resources.ts' -import { npmCordisConfigEntry, optionalString, requiredString } from './helpers.ts' +import { ProjectContribution, type ProjectResource } from '../resources.ts' +import { + npmCordisConfigEntry, + optionalString, + ownedTextFile, + packageScript, + requiredString, +} from './helpers.ts' const ID = featureId('app') +function appProjectResources( + profile: ProjectProfile, + runInterface: 'acp' | 'stdio' | 'embed', +): readonly ProjectResource[] { + const context = createProjectTemplateContext(profile, runInterface) + const scripts = createAppPackageScripts(context) + return [ + ...createAppProjectArtifacts(context).map(document => ( + ownedTextFile(ID, document.relativePath, document.serialize()) + )), + packageScript(ID, 'dev', scripts.dev), + packageScript(ID, 'start', scripts.start), + ] +} + class AppOption extends FeatureOption { override readonly id: 'acp' | 'stdio' | 'embed' override readonly label: string @@ -45,6 +71,7 @@ class AppOption extends FeatureOption { switch (this.id) { case 'acp': return new ProjectContribution([ + ...appProjectResources(profile, this.id), ...npmCordisConfigEntry(ID, { id: 'user-interaction', name: '@deepseek-ai/dsh-user-interaction', @@ -57,6 +84,7 @@ class AppOption extends FeatureOption { ]) case 'stdio': return new ProjectContribution([ + ...appProjectResources(profile, this.id), ...npmCordisConfigEntry(ID, { id: 'user-interaction', name: '@deepseek-ai/dsh-user-interaction', @@ -74,7 +102,7 @@ class AppOption extends FeatureOption { ]), ]) case 'embed': - return new ProjectContribution([]) + return new ProjectContribution(appProjectResources(profile, this.id)) } } } diff --git a/packages/sdk/helper/src/features/builtin/helpers.ts b/packages/sdk/helper/src/features/builtin/helpers.ts index 384f6ffea8..60f7530c61 100644 --- a/packages/sdk/helper/src/features/builtin/helpers.ts +++ b/packages/sdk/helper/src/features/builtin/helpers.ts @@ -12,6 +12,7 @@ import type { EnvironmentResource, OwnedFileResource, NpmDependencyResource, + PackageScriptResource, } from '../resources.ts' /** Create a runtime NPM dependency resource. */ @@ -24,6 +25,17 @@ function npmDependency(_owner: string, name: string): NpmDependencyResource { } } +/** Create a feature-owned package script that is replaceable only while unchanged. */ +export function packageScript(_owner: string, name: string, command: string): PackageScriptResource { + return { + kind: 'package-script', + key: resourceKey(`package-script:${name}`), + name, + command, + removeOnlyWhenUnchanged: true, + } +} + /** Create a Cordis config entry resource with explicitly owned config keys. */ export function cordisConfigEntry( _owner: string, diff --git a/packages/sdk/helper/src/features/feature.ts b/packages/sdk/helper/src/features/feature.ts index fcbe64e052..1335d8e29b 100644 --- a/packages/sdk/helper/src/features/feature.ts +++ b/packages/sdk/helper/src/features/feature.ts @@ -285,6 +285,11 @@ export abstract class Feature { diagnostics.push(`missing package.json ${resource.section} entry ${resource.name}`) } break + case 'package-script': + if (!manifest.scripts?.[resource.name]) { + diagnostics.push(`missing package.json script ${resource.name}`) + } + break case 'owned-file': if (!project.hasDocument(resource.document.relativePath)) diagnostics.push(`missing owned file ${resource.document.relativePath}`) break diff --git a/packages/sdk/helper/src/features/resources.ts b/packages/sdk/helper/src/features/resources.ts index dc6ecd2452..6528451e10 100644 --- a/packages/sdk/helper/src/features/resources.ts +++ b/packages/sdk/helper/src/features/resources.ts @@ -16,6 +16,15 @@ export interface NpmDependencyResource { section: 'dependencies' | 'devDependencies' } +/** Feature-owned package script. */ +export interface PackageScriptResource { + kind: 'package-script' + key: ResourceKey + name: string + command: string + removeOnlyWhenUnchanged: boolean +} + /** Owned Cordis config entry plus the config keys safe to update in place. */ export interface CordisConfigEntryResource { kind: 'cordis-config-entry' @@ -46,6 +55,7 @@ export interface OwnedFileResource { /** Any resource a feature can add to a project. */ export type ProjectResource = | NpmDependencyResource + | PackageScriptResource | CordisConfigEntryResource | EnvironmentResource | OwnedFileResource diff --git a/packages/sdk/helper/src/project/project-edit-session.ts b/packages/sdk/helper/src/project/project-edit-session.ts index 2fb3cc4c77..4e47a5a53f 100644 --- a/packages/sdk/helper/src/project/project-edit-session.ts +++ b/packages/sdk/helper/src/project/project-edit-session.ts @@ -56,6 +56,7 @@ function canUpdateResource(previous: ProjectResource, next: ProjectResource): bo 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 @@ -282,7 +283,10 @@ export class ProjectEditSession implements FeatureProjectView { continue } await mkdir(dirname(absolute), { recursive: true }) - await writeFile(absolute, document.serialize(), 'utf8') + await writeFile(absolute, document.serialize(), { + encoding: 'utf8', + ...document.createMode === undefined ? {} : { mode: document.createMode }, + }) } this.committed = true return { project: await this.source.reopen(), changes } @@ -369,6 +373,21 @@ export class ProjectEditSession implements FeatureProjectView { 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) @@ -412,7 +431,9 @@ export class ProjectEditSession implements FeatureProjectView { if (existing.serialize() !== previous.document.serialize()) { throw new Error(`feature-owned file was modified: ${resource.document.relativePath}`) } - throw new Error(`updating feature-owned files is not supported: ${resource.document.relativePath}`) + this.documents.set(resource.document.relativePath, resource.document.clone()) + this.removed.delete(resource.document.relativePath) + return } } } @@ -422,6 +443,16 @@ export class ProjectEditSession implements FeatureProjectView { 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) { diff --git a/packages/sdk/helper/src/project/sdk-project.ts b/packages/sdk/helper/src/project/sdk-project.ts index 21d5510117..cd55ffe2e5 100644 --- a/packages/sdk/helper/src/project/sdk-project.ts +++ b/packages/sdk/helper/src/project/sdk-project.ts @@ -18,9 +18,9 @@ import { type PackageManagerName, } from '../package-managers/package-manager.ts' import { + createBaselineProjectArtifacts, createPackageJsonDoc, - createProjectArtifacts, - type ProjectTemplateContext, + createProjectTemplateContext, } from '../templates/project-template.ts' import type { ProjectCreationRequest, ProjectProfile, RunInterface } from './types.ts' import type { FeatureRegistry } from '../features/registry.ts' @@ -36,6 +36,8 @@ const OPTIONAL_DOCUMENTS = [ 'pnpm-workspace.yaml', 'hooks.json', 'codex-hooks.json', + 'README.md', + 'index.ts', ] as const function runInterface(entries: readonly CordisConfigEntry[]): RunInterface { @@ -113,22 +115,6 @@ function parseOptionalDocument(path: string, text: string): ProjectFile { } } -function templateContext(profile: ProjectProfile): ProjectTemplateContext { - return { - name: profile.name, - description: profile.description, - releaseVersion: profile.releaseVersion, - model: profile.runtime.model, - modelLiteral: JSON.stringify(profile.runtime.model), - isAcp: profile.runInterface === 'acp', - isStdio: profile.runInterface === 'stdio', - isEmbed: profile.runInterface === 'embed', - packageManager: profile.packageManager.name, - installArgs: profile.packageManager.installCommand().join(' '), - buildArgs: profile.packageManager.buildCommand().join(' '), - } -} - /** A project snapshot whose documents can only be changed through {@link ProjectEditSession}. */ export class SdkProject { /** Absolute project directory. */ @@ -172,7 +158,7 @@ export class SdkProject { releaseVersion: request.releaseVersion, ...request.linkWorkspaceRoot ? { linkWorkspaceRoot: resolve(request.linkWorkspaceRoot) } : {}, } - const templates = templateContext(profile) + const templates = createProjectTemplateContext(profile) const manifest = createPackageJsonDoc(templates) const documents = new Map() documents.set(manifest.relativePath, manifest) @@ -182,7 +168,7 @@ export class SdkProject { for (const document of request.packageManager.configureWorkspace(manifest)) { documents.set(document.relativePath, document) } - for (const document of createProjectArtifacts(templates)) { + for (const document of createBaselineProjectArtifacts(templates)) { documents.set(document.relativePath, document) } return new SdkProject(root, 'create', profile, documents) diff --git a/packages/sdk/helper/src/templates/assets/package.json.tpl b/packages/sdk/helper/src/templates/assets/package.json.tpl index 759639053b..9884f0913f 100644 --- a/packages/sdk/helper/src/templates/assets/package.json.tpl +++ b/packages/sdk/helper/src/templates/assets/package.json.tpl @@ -5,10 +5,8 @@ "description": {{description}}, "type": "module", "scripts": { - "dev": {{devScript}}, "build": "dsh build", "typecheck": "tsc -b", - "start": {{startScript}}, "config": "dsh config" }, "dependencies": {{dependencies}}, diff --git a/packages/sdk/helper/src/templates/project-template.ts b/packages/sdk/helper/src/templates/project-template.ts index a9686649ad..90ab46d51e 100644 --- a/packages/sdk/helper/src/templates/project-template.ts +++ b/packages/sdk/helper/src/templates/project-template.ts @@ -8,6 +8,7 @@ import { PackageJsonFile } from '../documents/package-json-file.ts' import { TextProjectFile } from '../documents/project-file.ts' import type { PackageManagerName } from '../package-managers/package-manager.ts' import { baselineNpmDependencies } from '../project/npm-dependency-policy.ts' +import type { ProjectProfile, RunInterface } from '../project/types.ts' import { loadHelperTemplate } from './template-assets.ts' import type { TextTemplate } from './text-template.ts' @@ -38,8 +39,6 @@ const README_TEMPLATE = loadHelperTemplate('README.md.tp const PACKAGE_JSON_TEMPLATE = loadHelperTemplate<{ name: string description: string - devScript: string - startScript: string dependencies: string devDependencies: string }>('package.json.tpl') @@ -49,25 +48,42 @@ const TSCONFIG_BASE_TEMPLATE = loadHelperTemplate('tscon const GITIGNORE_TEMPLATE = loadHelperTemplate('gitignore.tpl') const YARNRC_TEMPLATE = loadHelperTemplate('yarnrc.yml.tpl') +/** Build the template model for one project and selected run interface. */ +export function createProjectTemplateContext( + profile: ProjectProfile, + runInterface: RunInterface = profile.runInterface, +): ProjectTemplateContext { + return { + name: profile.name, + description: profile.description, + releaseVersion: profile.releaseVersion, + model: profile.runtime.model, + modelLiteral: JSON.stringify(profile.runtime.model), + isAcp: runInterface === 'acp', + isStdio: runInterface === 'stdio', + isEmbed: runInterface === 'embed', + packageManager: profile.packageManager.name, + installArgs: profile.packageManager.installCommand().join(' '), + buildArgs: profile.packageManager.buildCommand().join(' '), + } +} + /** Render the complete root package defaults before structured contributions merge. */ export function createPackageJsonDoc(context: ProjectTemplateContext): PackageJsonFile { - const stdioModelArg = context.isStdio ? ` -- --model=${JSON.stringify(context.model)}` : '' const npmDependencies = baselineNpmDependencies(context.releaseVersion) return PackageJsonFile.create(PACKAGE_JSON_TEMPLATE.render({ name: JSON.stringify(context.name), description: JSON.stringify(context.description), - devScript: JSON.stringify(`dsh dev index.ts${stdioModelArg}`), - startScript: JSON.stringify(`dsh start index.js${stdioModelArg}`), dependencies: JSON.stringify(npmDependencies.dependencies), devDependencies: JSON.stringify(npmDependencies.devDependencies), })) } -/** Build every one-shot project artifact from one template context. */ -export function createProjectArtifacts(context: ProjectTemplateContext): TemplateArtifact[] { +/** Build interface-independent one-shot project artifacts. */ +export function createBaselineProjectArtifacts( + context: ProjectTemplateContext, +): TemplateArtifact[] { return [ - new TemplateArtifact('README.md', README_TEMPLATE, context), - new TemplateArtifact('index.ts', INDEX_TEMPLATE, context), new TemplateArtifact('tsdown.config.ts', TSDOWN_TEMPLATE, context), new TemplateArtifact('tsconfig.base.json', TSCONFIG_BASE_TEMPLATE, context), new TemplateArtifact('.gitignore', GITIGNORE_TEMPLATE, context), @@ -76,3 +92,22 @@ export function createProjectArtifacts(context: ProjectTemplateContext): Templat : [], ] } + +/** Build files owned by the selected app feature option. */ +export function createAppProjectArtifacts( + context: ProjectTemplateContext, +): TemplateArtifact[] { + return [ + new TemplateArtifact('README.md', README_TEMPLATE, context), + new TemplateArtifact('index.ts', INDEX_TEMPLATE, context), + ] +} + +/** Build package scripts owned by the selected app feature option. */ +export function createAppPackageScripts(context: ProjectTemplateContext): Readonly> { + const modelArg = context.isStdio ? ` -- --model=${JSON.stringify(context.model)}` : '' + return { + dev: `dsh dev index.ts${modelArg}`, + start: `dsh start index.js${modelArg}`, + } +} diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 5c7b483fa3..f71f016ab5 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -22,7 +22,7 @@ import { scrubEnvironment, type CommandRunner, } from '../src/package-managers/package-manager.ts' -import { createProjectArtifacts } from '../src/templates/project-template.ts' +import { createBaselineProjectArtifacts } from '../src/templates/project-template.ts' import { loadHelperTemplate } from '../src/templates/template-assets.ts' import { TextTemplate } from '../src/templates/text-template.ts' import { resolveNpmDependency } from '../src/project/npm-dependency-policy.ts' @@ -219,7 +219,7 @@ describe('structured project documents', () => { expect(() => featureId('Bad Id')).toThrow('invalid feature id') expect(() => resourceKey('')).toThrow('must not be empty') expect(() => loadHelperTemplate('../bad.tpl')).toThrow('must not contain a directory') - expect(createProjectArtifacts({ + expect(createBaselineProjectArtifacts({ name: 'demo', description: 'demo', releaseVersion: '0.0.1', model: 'model', modelLiteral: '"model"', packageManager: 'yarn', isAcp: false, isStdio: false, isEmbed: true, installArgs: 'install', buildArgs: 'build', diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index a565c6998b..37c0c2f903 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -25,6 +25,7 @@ import { FeatureRegistry } from '../src/features/registry.ts' import { ProjectContribution } from '../src/features/resources.ts' import type { CordisConfigEntryResource, ProjectResource } from '../src/features/resources.ts' import type { CordisConfigEntry } from '../src/documents/cordis-yaml-file.ts' +import { PackageJsonFile } from '../src/documents/package-json-file.ts' import { TextProjectFile } from '../src/documents/project-file.ts' import { featureId, resourceKey } from '../src/ids.ts' import { NpmPackageManager } from '../src/package-managers/package-manager.ts' @@ -246,6 +247,44 @@ describe('SdkProject and ProjectEditSession', () => { expect(committed.changes.npmDependenciesChanged).toBe(true) }) + it('switches app-owned files and scripts while protecting user edits', async () => { + const project = await createCommitted() + const registry = createBuiltinRegistry(project.profile) + const edit = project.edit(registry) + edit.configureFeature(registry.get(featureId('app')), selection('app', ['acp'])) + const acp = (await edit.commit()).project + expect(acp.profile.runInterface).toBe('acp') + expect(acp.packageManifest().scripts).toMatchObject({ + dev: 'dsh dev index.ts', + start: 'dsh start index.js', + }) + expect(await readFile(join(acp.root, 'README.md'), 'utf8')).toContain('Run as an ACP server') + expect(await readFile(join(acp.root, 'index.ts'), 'utf8')).not.toContain('agents.create') + + const acpRegistry = createBuiltinRegistry(acp.profile) + const embedEdit = acp.edit(acpRegistry) + embedEdit.configureFeature(acpRegistry.get(featureId('app')), selection('app', ['embed'])) + const embed = (await embedEdit.commit()).project + expect(embed.profile.runInterface).toBe('embed') + expect(await readFile(join(embed.root, 'README.md'), 'utf8')).toContain('Embed the harness') + expect(await readFile(join(embed.root, 'index.ts'), 'utf8')).toContain('agents.create') + + await writeFile(join(embed.root, 'README.md'), '# Custom README\n') + const modified = await SdkProject.open(embed.root) + const modifiedRegistry = createBuiltinRegistry(modified.profile) + expect(() => { modified.edit(modifiedRegistry).configureFeature( + modifiedRegistry.get(featureId('app')), + selection('app', ['stdio']), + ) }).toThrow('feature-owned file was modified: README.md') + + const manifest = PackageJsonFile.parse(await readFile(join(embed.root, 'package.json'), 'utf8')) + manifest.removeScript('dev') + await writeFile(join(embed.root, 'package.json'), manifest.serialize()) + const incomplete = await SdkProject.open(embed.root) + expect(createBuiltinRegistry(incomplete.profile).get(featureId('app')).inspect(incomplete).diagnostics) + .toContain('missing package.json script dev') + }) + it('supports disabled feature reconfiguration and rejects invalid state operations', async () => { const project = await createCommitted([selection('todo', ['default'])]) const registry = createBuiltinRegistry(project.profile) @@ -379,9 +418,29 @@ describe('SdkProject and ProjectEditSession', () => { const nextFile: ProjectResource = { ...existingFile, key: resourceKey('file:owned.txt'), document: new TextProjectFile('owned.txt', 'replacement'), } - expect(() => { internals.applyResource(nextFile, previousFile) }).toThrow('updating feature-owned files') + internals.applyResource(nextFile, previousFile) + expect(internals.documents.get('owned.txt')?.serialize()).toBe('replacement\n') internals.documents.set('owned.txt', new TextProjectFile('owned.txt', 'user edit')) expect(() => { internals.applyResource(nextFile, previousFile) }).toThrow('was modified') + const existingScript: ProjectResource = { + kind: 'package-script', key: resourceKey('package-script:build'), + name: 'build', command: 'other build', removeOnlyWhenUnchanged: true, + } + expect(() => { internals.applyResource(existingScript, undefined) }).toThrow('script already exists') + const transientScript: ProjectResource = { + kind: 'package-script', key: resourceKey('package-script:transient'), + name: 'transient', command: 'first', removeOnlyWhenUnchanged: true, + } + internals.applyResource(transientScript, undefined) + const nextScript: ProjectResource = { ...transientScript, command: 'second' } + internals.applyResource(nextScript, transientScript) + internals.applyResource(nextScript, transientScript) + ;(internals.manifest() as PackageJsonFile).setScript('transient', 'user edit') + expect(() => { internals.applyResource(transientScript, nextScript) }).toThrow('script was modified') + expect(() => { internals.removeResource(nextScript) }).toThrow('script was modified') + ;(internals.manifest() as PackageJsonFile).setScript('transient', 'second') + internals.removeResource(nextScript) + expect(() => { internals.removeResource(nextScript) }).toThrow('script is missing') expect(() => { internals.removeResource({ ...existingFile, key: resourceKey('file:missing.txt'), document: new TextProjectFile('missing.txt', 'missing'), }) }).toThrow('owned file is missing') @@ -546,8 +605,12 @@ describe('SdkProject and ProjectEditSession', () => { it('preserves duplicate and existing .env values while appending differently named secrets', async () => { const project = await createCommitted() + if (process.platform !== 'win32') { + expect((await stat(join(project.root, '.env'))).mode & 0o777).toBe(0o600) + } const original = '# keep\nDEEPSEEK_API_KEY=first\nDEEPSEEK_API_KEY=second\n' await writeFile(join(project.root, '.env'), original) + if (process.platform !== 'win32') await chmod(join(project.root, '.env'), 0o640) const reopened = await SdkProject.open(project.root) const registry = createBuiltinRegistry(reopened.profile) expect(registry.get(featureId('provider')).inspect(reopened)).toMatchObject({ @@ -561,6 +624,9 @@ describe('SdkProject and ProjectEditSession', () => { edit.installFeature(registry.get(featureId('web')), selection('web', ['exa'], { apiKey: 'exa-key' })) const withExa = (await edit.commit()).project expect(await readFile(join(withExa.root, '.env'), 'utf8')).toBe(`${original}EXA_API_KEY=exa-key\n`) + if (process.platform !== 'win32') { + expect((await stat(join(withExa.root, '.env'))).mode & 0o777).toBe(0o640) + } const nextRegistry = createBuiltinRegistry(withExa.profile) const remove = withExa.edit(nextRegistry) remove.configureFeature(nextRegistry.get(featureId('web')), selection('web', ['deepseek'])) @@ -767,7 +833,9 @@ describe('extension points', () => { expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1) const embedOption = app.options.find(option => option.id === 'embed') expect(embedOption?.markerConfigEntries(profile)).toEqual([]) - expect(embedOption?.contribution(profile, {}).resources).toEqual([]) + expect(embedOption?.contribution(profile, {}).resources.map(resource => resource.kind)).toEqual([ + 'owned-file', 'owned-file', 'package-script', 'package-script', + ]) expect(embedOption?.matchesConfigEntries([ { id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' }, { id: 'stdio', name: '@deepseek-ai/dsh-stdio' },