Просмотр исходного кода

fix(sdk): address project tooling review findings

imccyu 2 месяцев назад
Родитель
Сommit
e150bc446d

+ 2 - 1
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
 }

+ 4 - 0
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}}

+ 2 - 2
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 {

+ 2 - 1
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,

+ 1 - 0
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 () => {

+ 1 - 1
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]
   }
 

+ 10 - 0
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] ??= {}

+ 5 - 1
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. */

+ 31 - 3
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))
     }
   }
 }

+ 12 - 0
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,

+ 5 - 0
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

+ 10 - 0
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

+ 33 - 2
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) {

+ 6 - 20
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<string, ProjectFile>()
     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)

+ 0 - 2
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}},

+ 44 - 9
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<ProjectTemplateContext>('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<ProjectTemplateContext>('tscon
 const GITIGNORE_TEMPLATE = loadHelperTemplate<ProjectTemplateContext>('gitignore.tpl')
 const YARNRC_TEMPLATE = loadHelperTemplate<ProjectTemplateContext>('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<ProjectTemplateContext>[] {
+/** Build interface-independent one-shot project artifacts. */
+export function createBaselineProjectArtifacts(
+  context: ProjectTemplateContext,
+): TemplateArtifact<ProjectTemplateContext>[] {
   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<ProjectTemplateContext>[] {
+  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<Record<'dev' | 'start', string>> {
+  const modelArg = context.isStdio ? ` -- --model=${JSON.stringify(context.model)}` : ''
+  return {
+    dev: `dsh dev index.ts${modelArg}`,
+    start: `dsh start index.js${modelArg}`,
+  }
+}

+ 2 - 2
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',

+ 71 - 3
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' },