fix(sdk): address project tooling review findings
This commit is contained in:
@@ -81,7 +81,8 @@ export async function createProject(
|
|||||||
}
|
}
|
||||||
context.stdout.write(CREATE_TEMPLATES.nextSteps.render({
|
context.stdout.write(CREATE_TEMPLATES.nextSteps.render({
|
||||||
directory: resolved.directory,
|
directory: resolved.directory,
|
||||||
packageManager: resolved.request.packageManager.name,
|
setupRequired: !resolved.install,
|
||||||
|
...packageManagerTemplateModel(resolved.request.packageManager),
|
||||||
}))
|
}))
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
|
{{#if setupRequired}}
|
||||||
|
Next: cd {{directory}} && {{packageManager}} {{installArgs}} && {{packageManager}} {{buildArgs}} && {{packageManager}} start
|
||||||
|
{{else}}
|
||||||
Next: cd {{directory}} && {{packageManager}} start
|
Next: cd {{directory}} && {{packageManager}} start
|
||||||
|
{{/if}}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ interface CreatedTemplateModel {
|
|||||||
directory: string
|
directory: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NextStepsTemplateModel {
|
interface NextStepsTemplateModel extends PackageManagerTemplateModel {
|
||||||
directory: string
|
directory: string
|
||||||
packageManager: PackageManagerName
|
setupRequired: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetupFailureTemplateModel extends PackageManagerTemplateModel {
|
interface SetupFailureTemplateModel extends PackageManagerTemplateModel {
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ describe('create-sdk terminal contract', () => {
|
|||||||
}),
|
}),
|
||||||
next: CREATE_TEMPLATES.nextSteps.render({
|
next: CREATE_TEMPLATES.nextSteps.render({
|
||||||
directory: resolved.directory,
|
directory: resolved.directory,
|
||||||
packageManager: resolved.request.packageManager.name,
|
setupRequired: false,
|
||||||
|
...packageManagerTemplateModel(resolved.request.packageManager),
|
||||||
}),
|
}),
|
||||||
failure: CREATE_TEMPLATES.setupFailure.render({
|
failure: CREATE_TEMPLATES.setupFailure.render({
|
||||||
directory: resolved.directory,
|
directory: resolved.directory,
|
||||||
|
|||||||
@@ -449,6 +449,7 @@ describe('create command composition', () => {
|
|||||||
'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
|
'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
|
||||||
]))
|
]))
|
||||||
await expect(createProject(argv('next', false), noInstall)).resolves.toBeDefined()
|
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 () => {
|
it('uses the package manager setup path when no setup override is supplied', async () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export class EnvFile extends ProjectFile {
|
|||||||
private readonly lines: string[]
|
private readonly lines: string[]
|
||||||
|
|
||||||
private constructor(relativePath: '.env' | '.env.example', lines: string[], originalText?: string) {
|
private constructor(relativePath: '.env' | '.env.example', lines: string[], originalText?: string) {
|
||||||
super(relativePath, originalText)
|
super(relativePath, originalText, relativePath === '.env' ? 0o600 : undefined)
|
||||||
this.lines = [...lines]
|
this.lines = [...lines]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,16 @@ export class PackageJsonFile extends ProjectFile {
|
|||||||
this.manifest.scripts[name] = command
|
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. */
|
/** Set one NPM dependency in its runtime or development section. */
|
||||||
setNpmDependency(section: NpmDependencySection, name: string, spec: string): void {
|
setNpmDependency(section: NpmDependencySection, name: string, spec: string): void {
|
||||||
this.manifest[section] ??= {}
|
this.manifest[section] ??= {}
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ export abstract class ProjectFile {
|
|||||||
/** Text observed when the document entered the snapshot; absent for a new file. */
|
/** Text observed when the document entered the snapshot; absent for a new file. */
|
||||||
readonly originalText: string | undefined
|
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('..')) {
|
if (relativePath.startsWith('/') || relativePath.split('/').includes('..')) {
|
||||||
throw new Error(`project document path must stay inside the project: ${relativePath}`)
|
throw new Error(`project document path must stay inside the project: ${relativePath}`)
|
||||||
}
|
}
|
||||||
this.relativePath = relativePath
|
this.relativePath = relativePath
|
||||||
this.originalText = originalText
|
this.originalText = originalText
|
||||||
|
this.createMode = createMode
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clone the document for an isolated edit session. */
|
/** Clone the document for an isolated edit session. */
|
||||||
|
|||||||
@@ -6,15 +6,41 @@
|
|||||||
|
|
||||||
import { featureId } from '../../ids.ts'
|
import { featureId } from '../../ids.ts'
|
||||||
import type { ProjectProfile } from '../../project/types.ts'
|
import type { ProjectProfile } from '../../project/types.ts'
|
||||||
|
import {
|
||||||
|
createAppPackageScripts,
|
||||||
|
createAppProjectArtifacts,
|
||||||
|
createProjectTemplateContext,
|
||||||
|
} from '../../templates/project-template.ts'
|
||||||
import {
|
import {
|
||||||
FeatureOption,
|
FeatureOption,
|
||||||
ExclusiveOptionFeature,
|
ExclusiveOptionFeature,
|
||||||
} from '../feature.ts'
|
} from '../feature.ts'
|
||||||
import { ProjectContribution } from '../resources.ts'
|
import { ProjectContribution, type ProjectResource } from '../resources.ts'
|
||||||
import { npmCordisConfigEntry, optionalString, requiredString } from './helpers.ts'
|
import {
|
||||||
|
npmCordisConfigEntry,
|
||||||
|
optionalString,
|
||||||
|
ownedTextFile,
|
||||||
|
packageScript,
|
||||||
|
requiredString,
|
||||||
|
} from './helpers.ts'
|
||||||
|
|
||||||
const ID = featureId('app')
|
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 {
|
class AppOption extends FeatureOption {
|
||||||
override readonly id: 'acp' | 'stdio' | 'embed'
|
override readonly id: 'acp' | 'stdio' | 'embed'
|
||||||
override readonly label: string
|
override readonly label: string
|
||||||
@@ -45,6 +71,7 @@ class AppOption extends FeatureOption {
|
|||||||
switch (this.id) {
|
switch (this.id) {
|
||||||
case 'acp':
|
case 'acp':
|
||||||
return new ProjectContribution([
|
return new ProjectContribution([
|
||||||
|
...appProjectResources(profile, this.id),
|
||||||
...npmCordisConfigEntry(ID, {
|
...npmCordisConfigEntry(ID, {
|
||||||
id: 'user-interaction',
|
id: 'user-interaction',
|
||||||
name: '@deepseek-ai/dsh-user-interaction',
|
name: '@deepseek-ai/dsh-user-interaction',
|
||||||
@@ -57,6 +84,7 @@ class AppOption extends FeatureOption {
|
|||||||
])
|
])
|
||||||
case 'stdio':
|
case 'stdio':
|
||||||
return new ProjectContribution([
|
return new ProjectContribution([
|
||||||
|
...appProjectResources(profile, this.id),
|
||||||
...npmCordisConfigEntry(ID, {
|
...npmCordisConfigEntry(ID, {
|
||||||
id: 'user-interaction',
|
id: 'user-interaction',
|
||||||
name: '@deepseek-ai/dsh-user-interaction',
|
name: '@deepseek-ai/dsh-user-interaction',
|
||||||
@@ -74,7 +102,7 @@ class AppOption extends FeatureOption {
|
|||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
case 'embed':
|
case 'embed':
|
||||||
return new ProjectContribution([])
|
return new ProjectContribution(appProjectResources(profile, this.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
EnvironmentResource,
|
EnvironmentResource,
|
||||||
OwnedFileResource,
|
OwnedFileResource,
|
||||||
NpmDependencyResource,
|
NpmDependencyResource,
|
||||||
|
PackageScriptResource,
|
||||||
} from '../resources.ts'
|
} from '../resources.ts'
|
||||||
|
|
||||||
/** Create a runtime NPM dependency resource. */
|
/** 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. */
|
/** Create a Cordis config entry resource with explicitly owned config keys. */
|
||||||
export function cordisConfigEntry(
|
export function cordisConfigEntry(
|
||||||
_owner: string,
|
_owner: string,
|
||||||
|
|||||||
@@ -285,6 +285,11 @@ export abstract class Feature {
|
|||||||
diagnostics.push(`missing package.json ${resource.section} entry ${resource.name}`)
|
diagnostics.push(`missing package.json ${resource.section} entry ${resource.name}`)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
case 'package-script':
|
||||||
|
if (!manifest.scripts?.[resource.name]) {
|
||||||
|
diagnostics.push(`missing package.json script ${resource.name}`)
|
||||||
|
}
|
||||||
|
break
|
||||||
case 'owned-file':
|
case 'owned-file':
|
||||||
if (!project.hasDocument(resource.document.relativePath)) diagnostics.push(`missing owned file ${resource.document.relativePath}`)
|
if (!project.hasDocument(resource.document.relativePath)) diagnostics.push(`missing owned file ${resource.document.relativePath}`)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ export interface NpmDependencyResource {
|
|||||||
section: 'dependencies' | 'devDependencies'
|
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. */
|
/** Owned Cordis config entry plus the config keys safe to update in place. */
|
||||||
export interface CordisConfigEntryResource {
|
export interface CordisConfigEntryResource {
|
||||||
kind: 'cordis-config-entry'
|
kind: 'cordis-config-entry'
|
||||||
@@ -46,6 +55,7 @@ export interface OwnedFileResource {
|
|||||||
/** Any resource a feature can add to a project. */
|
/** Any resource a feature can add to a project. */
|
||||||
export type ProjectResource =
|
export type ProjectResource =
|
||||||
| NpmDependencyResource
|
| NpmDependencyResource
|
||||||
|
| PackageScriptResource
|
||||||
| CordisConfigEntryResource
|
| CordisConfigEntryResource
|
||||||
| EnvironmentResource
|
| EnvironmentResource
|
||||||
| OwnedFileResource
|
| OwnedFileResource
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function canUpdateResource(previous: ProjectResource, next: ProjectResource): bo
|
|||||||
if (previous.kind !== next.kind) return false
|
if (previous.kind !== next.kind) return false
|
||||||
switch (previous.kind) {
|
switch (previous.kind) {
|
||||||
case 'npm-dependency': return previous.name === (next as typeof previous).name
|
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': {
|
case 'cordis-config-entry': {
|
||||||
const candidate = next as typeof previous
|
const candidate = next as typeof previous
|
||||||
return previous.entry.name === candidate.entry.name
|
return previous.entry.name === candidate.entry.name
|
||||||
@@ -282,7 +283,10 @@ export class ProjectEditSession implements FeatureProjectView {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
await mkdir(dirname(absolute), { recursive: true })
|
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
|
this.committed = true
|
||||||
return { project: await this.source.reopen(), changes }
|
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)
|
this.manifest().setNpmDependency(dependency.section, resource.name, dependency.spec)
|
||||||
return
|
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': {
|
case 'cordis-config-entry': {
|
||||||
const current = this.cordis().entry(resource.entry.id)
|
const current = this.cordis().entry(resource.entry.id)
|
||||||
if (!current) this.cordis().addEntry(resource.entry)
|
if (!current) this.cordis().addEntry(resource.entry)
|
||||||
@@ -412,7 +431,9 @@ export class ProjectEditSession implements FeatureProjectView {
|
|||||||
if (existing.serialize() !== previous.document.serialize()) {
|
if (existing.serialize() !== previous.document.serialize()) {
|
||||||
throw new Error(`feature-owned file was modified: ${resource.document.relativePath}`)
|
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':
|
case 'npm-dependency':
|
||||||
this.manifest().removeNpmDependency(resource.section, resource.name)
|
this.manifest().removeNpmDependency(resource.section, resource.name)
|
||||||
return
|
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': {
|
case 'cordis-config-entry': {
|
||||||
const entry = this.cordis().entry(resource.entry.id)
|
const entry = this.cordis().entry(resource.entry.id)
|
||||||
if (!entry || entry.name !== resource.entry.name) {
|
if (!entry || entry.name !== resource.entry.name) {
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import {
|
|||||||
type PackageManagerName,
|
type PackageManagerName,
|
||||||
} from '../package-managers/package-manager.ts'
|
} from '../package-managers/package-manager.ts'
|
||||||
import {
|
import {
|
||||||
|
createBaselineProjectArtifacts,
|
||||||
createPackageJsonDoc,
|
createPackageJsonDoc,
|
||||||
createProjectArtifacts,
|
createProjectTemplateContext,
|
||||||
type ProjectTemplateContext,
|
|
||||||
} from '../templates/project-template.ts'
|
} from '../templates/project-template.ts'
|
||||||
import type { ProjectCreationRequest, ProjectProfile, RunInterface } from './types.ts'
|
import type { ProjectCreationRequest, ProjectProfile, RunInterface } from './types.ts'
|
||||||
import type { FeatureRegistry } from '../features/registry.ts'
|
import type { FeatureRegistry } from '../features/registry.ts'
|
||||||
@@ -36,6 +36,8 @@ const OPTIONAL_DOCUMENTS = [
|
|||||||
'pnpm-workspace.yaml',
|
'pnpm-workspace.yaml',
|
||||||
'hooks.json',
|
'hooks.json',
|
||||||
'codex-hooks.json',
|
'codex-hooks.json',
|
||||||
|
'README.md',
|
||||||
|
'index.ts',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function runInterface(entries: readonly CordisConfigEntry[]): RunInterface {
|
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}. */
|
/** A project snapshot whose documents can only be changed through {@link ProjectEditSession}. */
|
||||||
export class SdkProject {
|
export class SdkProject {
|
||||||
/** Absolute project directory. */
|
/** Absolute project directory. */
|
||||||
@@ -172,7 +158,7 @@ export class SdkProject {
|
|||||||
releaseVersion: request.releaseVersion,
|
releaseVersion: request.releaseVersion,
|
||||||
...request.linkWorkspaceRoot ? { linkWorkspaceRoot: resolve(request.linkWorkspaceRoot) } : {},
|
...request.linkWorkspaceRoot ? { linkWorkspaceRoot: resolve(request.linkWorkspaceRoot) } : {},
|
||||||
}
|
}
|
||||||
const templates = templateContext(profile)
|
const templates = createProjectTemplateContext(profile)
|
||||||
const manifest = createPackageJsonDoc(templates)
|
const manifest = createPackageJsonDoc(templates)
|
||||||
const documents = new Map<string, ProjectFile>()
|
const documents = new Map<string, ProjectFile>()
|
||||||
documents.set(manifest.relativePath, manifest)
|
documents.set(manifest.relativePath, manifest)
|
||||||
@@ -182,7 +168,7 @@ export class SdkProject {
|
|||||||
for (const document of request.packageManager.configureWorkspace(manifest)) {
|
for (const document of request.packageManager.configureWorkspace(manifest)) {
|
||||||
documents.set(document.relativePath, document)
|
documents.set(document.relativePath, document)
|
||||||
}
|
}
|
||||||
for (const document of createProjectArtifacts(templates)) {
|
for (const document of createBaselineProjectArtifacts(templates)) {
|
||||||
documents.set(document.relativePath, document)
|
documents.set(document.relativePath, document)
|
||||||
}
|
}
|
||||||
return new SdkProject(root, 'create', profile, documents)
|
return new SdkProject(root, 'create', profile, documents)
|
||||||
|
|||||||
@@ -5,10 +5,8 @@
|
|||||||
"description": {{description}},
|
"description": {{description}},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": {{devScript}},
|
|
||||||
"build": "dsh build",
|
"build": "dsh build",
|
||||||
"typecheck": "tsc -b",
|
"typecheck": "tsc -b",
|
||||||
"start": {{startScript}},
|
|
||||||
"config": "dsh config"
|
"config": "dsh config"
|
||||||
},
|
},
|
||||||
"dependencies": {{dependencies}},
|
"dependencies": {{dependencies}},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { PackageJsonFile } from '../documents/package-json-file.ts'
|
|||||||
import { TextProjectFile } from '../documents/project-file.ts'
|
import { TextProjectFile } from '../documents/project-file.ts'
|
||||||
import type { PackageManagerName } from '../package-managers/package-manager.ts'
|
import type { PackageManagerName } from '../package-managers/package-manager.ts'
|
||||||
import { baselineNpmDependencies } from '../project/npm-dependency-policy.ts'
|
import { baselineNpmDependencies } from '../project/npm-dependency-policy.ts'
|
||||||
|
import type { ProjectProfile, RunInterface } from '../project/types.ts'
|
||||||
import { loadHelperTemplate } from './template-assets.ts'
|
import { loadHelperTemplate } from './template-assets.ts'
|
||||||
import type { TextTemplate } from './text-template.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<{
|
const PACKAGE_JSON_TEMPLATE = loadHelperTemplate<{
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
devScript: string
|
|
||||||
startScript: string
|
|
||||||
dependencies: string
|
dependencies: string
|
||||||
devDependencies: string
|
devDependencies: string
|
||||||
}>('package.json.tpl')
|
}>('package.json.tpl')
|
||||||
@@ -49,25 +48,42 @@ const TSCONFIG_BASE_TEMPLATE = loadHelperTemplate<ProjectTemplateContext>('tscon
|
|||||||
const GITIGNORE_TEMPLATE = loadHelperTemplate<ProjectTemplateContext>('gitignore.tpl')
|
const GITIGNORE_TEMPLATE = loadHelperTemplate<ProjectTemplateContext>('gitignore.tpl')
|
||||||
const YARNRC_TEMPLATE = loadHelperTemplate<ProjectTemplateContext>('yarnrc.yml.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. */
|
/** Render the complete root package defaults before structured contributions merge. */
|
||||||
export function createPackageJsonDoc(context: ProjectTemplateContext): PackageJsonFile {
|
export function createPackageJsonDoc(context: ProjectTemplateContext): PackageJsonFile {
|
||||||
const stdioModelArg = context.isStdio ? ` -- --model=${JSON.stringify(context.model)}` : ''
|
|
||||||
const npmDependencies = baselineNpmDependencies(context.releaseVersion)
|
const npmDependencies = baselineNpmDependencies(context.releaseVersion)
|
||||||
return PackageJsonFile.create(PACKAGE_JSON_TEMPLATE.render({
|
return PackageJsonFile.create(PACKAGE_JSON_TEMPLATE.render({
|
||||||
name: JSON.stringify(context.name),
|
name: JSON.stringify(context.name),
|
||||||
description: JSON.stringify(context.description),
|
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),
|
dependencies: JSON.stringify(npmDependencies.dependencies),
|
||||||
devDependencies: JSON.stringify(npmDependencies.devDependencies),
|
devDependencies: JSON.stringify(npmDependencies.devDependencies),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build every one-shot project artifact from one template context. */
|
/** Build interface-independent one-shot project artifacts. */
|
||||||
export function createProjectArtifacts(context: ProjectTemplateContext): TemplateArtifact<ProjectTemplateContext>[] {
|
export function createBaselineProjectArtifacts(
|
||||||
|
context: ProjectTemplateContext,
|
||||||
|
): TemplateArtifact<ProjectTemplateContext>[] {
|
||||||
return [
|
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('tsdown.config.ts', TSDOWN_TEMPLATE, context),
|
||||||
new TemplateArtifact('tsconfig.base.json', TSCONFIG_BASE_TEMPLATE, context),
|
new TemplateArtifact('tsconfig.base.json', TSCONFIG_BASE_TEMPLATE, context),
|
||||||
new TemplateArtifact('.gitignore', GITIGNORE_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}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
scrubEnvironment,
|
scrubEnvironment,
|
||||||
type CommandRunner,
|
type CommandRunner,
|
||||||
} from '../src/package-managers/package-manager.ts'
|
} 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 { loadHelperTemplate } from '../src/templates/template-assets.ts'
|
||||||
import { TextTemplate } from '../src/templates/text-template.ts'
|
import { TextTemplate } from '../src/templates/text-template.ts'
|
||||||
import { resolveNpmDependency } from '../src/project/npm-dependency-policy.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(() => featureId('Bad Id')).toThrow('invalid feature id')
|
||||||
expect(() => resourceKey('')).toThrow('must not be empty')
|
expect(() => resourceKey('')).toThrow('must not be empty')
|
||||||
expect(() => loadHelperTemplate('../bad.tpl')).toThrow('must not contain a directory')
|
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',
|
name: 'demo', description: 'demo', releaseVersion: '0.0.1', model: 'model', modelLiteral: '"model"', packageManager: 'yarn',
|
||||||
isAcp: false, isStdio: false, isEmbed: true,
|
isAcp: false, isStdio: false, isEmbed: true,
|
||||||
installArgs: 'install', buildArgs: 'build',
|
installArgs: 'install', buildArgs: 'build',
|
||||||
|
|||||||
@@ -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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
@@ -25,6 +25,7 @@ import { FeatureRegistry } from '../src/features/registry.ts'
|
|||||||
import { ProjectContribution } from '../src/features/resources.ts'
|
import { ProjectContribution } from '../src/features/resources.ts'
|
||||||
import type { CordisConfigEntryResource, ProjectResource } 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 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 { TextProjectFile } from '../src/documents/project-file.ts'
|
||||||
import { featureId, resourceKey } from '../src/ids.ts'
|
import { featureId, resourceKey } from '../src/ids.ts'
|
||||||
import { NpmPackageManager } from '../src/package-managers/package-manager.ts'
|
import { NpmPackageManager } from '../src/package-managers/package-manager.ts'
|
||||||
@@ -246,6 +247,44 @@ describe('SdkProject and ProjectEditSession', () => {
|
|||||||
expect(committed.changes.npmDependenciesChanged).toBe(true)
|
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 () => {
|
it('supports disabled feature reconfiguration and rejects invalid state operations', async () => {
|
||||||
const project = await createCommitted([selection('todo', ['default'])])
|
const project = await createCommitted([selection('todo', ['default'])])
|
||||||
const registry = createBuiltinRegistry(project.profile)
|
const registry = createBuiltinRegistry(project.profile)
|
||||||
@@ -379,9 +418,29 @@ describe('SdkProject and ProjectEditSession', () => {
|
|||||||
const nextFile: ProjectResource = {
|
const nextFile: ProjectResource = {
|
||||||
...existingFile, key: resourceKey('file:owned.txt'), document: new TextProjectFile('owned.txt', 'replacement'),
|
...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'))
|
internals.documents.set('owned.txt', new TextProjectFile('owned.txt', 'user edit'))
|
||||||
expect(() => { internals.applyResource(nextFile, previousFile) }).toThrow('was modified')
|
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({
|
expect(() => { internals.removeResource({
|
||||||
...existingFile, key: resourceKey('file:missing.txt'), document: new TextProjectFile('missing.txt', 'missing'),
|
...existingFile, key: resourceKey('file:missing.txt'), document: new TextProjectFile('missing.txt', 'missing'),
|
||||||
}) }).toThrow('owned file is 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 () => {
|
it('preserves duplicate and existing .env values while appending differently named secrets', async () => {
|
||||||
const project = await createCommitted()
|
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'
|
const original = '# keep\nDEEPSEEK_API_KEY=first\nDEEPSEEK_API_KEY=second\n'
|
||||||
await writeFile(join(project.root, '.env'), original)
|
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 reopened = await SdkProject.open(project.root)
|
||||||
const registry = createBuiltinRegistry(reopened.profile)
|
const registry = createBuiltinRegistry(reopened.profile)
|
||||||
expect(registry.get(featureId('provider')).inspect(reopened)).toMatchObject({
|
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' }))
|
edit.installFeature(registry.get(featureId('web')), selection('web', ['exa'], { apiKey: 'exa-key' }))
|
||||||
const withExa = (await edit.commit()).project
|
const withExa = (await edit.commit()).project
|
||||||
expect(await readFile(join(withExa.root, '.env'), 'utf8')).toBe(`${original}EXA_API_KEY=exa-key\n`)
|
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 nextRegistry = createBuiltinRegistry(withExa.profile)
|
||||||
const remove = withExa.edit(nextRegistry)
|
const remove = withExa.edit(nextRegistry)
|
||||||
remove.configureFeature(nextRegistry.get(featureId('web')), selection('web', ['deepseek']))
|
remove.configureFeature(nextRegistry.get(featureId('web')), selection('web', ['deepseek']))
|
||||||
@@ -767,7 +833,9 @@ describe('extension points', () => {
|
|||||||
expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1)
|
expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1)
|
||||||
const embedOption = app.options.find(option => option.id === 'embed')
|
const embedOption = app.options.find(option => option.id === 'embed')
|
||||||
expect(embedOption?.markerConfigEntries(profile)).toEqual([])
|
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([
|
expect(embedOption?.matchesConfigEntries([
|
||||||
{ id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' },
|
{ id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' },
|
||||||
{ id: 'stdio', name: '@deepseek-ai/dsh-stdio' },
|
{ id: 'stdio', name: '@deepseek-ai/dsh-stdio' },
|
||||||
|
|||||||
Reference in New Issue
Block a user