Merge branch 'codex/invariant-package-registration-gate' into codex/package-invariant-checks
# Conflicts: # .agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml # .agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md # .agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md # .agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml # docs/rfc/INDEX.md # packages/AGENTS.md
This commit is contained in:
@@ -19,6 +19,9 @@ export interface CreateArgs {
|
||||
packageManager?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
config?: string
|
||||
configJson?: string
|
||||
json?: boolean
|
||||
help: boolean
|
||||
}
|
||||
|
||||
@@ -32,6 +35,9 @@ interface CommanderCreateOptions {
|
||||
pm?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
config?: string
|
||||
configJson?: string
|
||||
json?: boolean
|
||||
help?: boolean
|
||||
}
|
||||
|
||||
@@ -60,6 +66,9 @@ function createProgram(): Command {
|
||||
.addOption(new Option('--install').default(undefined))
|
||||
.addOption(new Option('--no-install').default(undefined))
|
||||
.option('--link-workspace')
|
||||
.option('--config <path>')
|
||||
.option('--config-json <json>')
|
||||
.addOption(new Option('--json').default(undefined))
|
||||
}
|
||||
|
||||
/** Parse create-sdk positionals/options through Commander into a domain-neutral value. */
|
||||
@@ -79,6 +88,9 @@ export function parseCreateArgs(argv: readonly string[]): CreateArgs {
|
||||
...options.pm === undefined ? {} : { packageManager: options.pm },
|
||||
...options.install === undefined ? {} : { install: options.install },
|
||||
...options.linkWorkspace ? { linkWorkspace: true } : {},
|
||||
...options.config === undefined ? {} : { config: options.config },
|
||||
...options.configJson === undefined ? {} : { configJson: options.configJson },
|
||||
...options.json === undefined ? {} : { json: options.json },
|
||||
help: options.help ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import {
|
||||
ClackPromptPort,
|
||||
HeadlessPromptError,
|
||||
HeadlessPromptPort,
|
||||
NodeCommandRunner,
|
||||
PromptCancelledError,
|
||||
type PackageManagerVersionProbe,
|
||||
type PromptPort,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import { parseCreateArgs } from './args.ts'
|
||||
import { parseCreateArgs, type CreateArgs } from './args.ts'
|
||||
import { CreateWizard, type ResolvedCreateRequest } from './create-wizard.ts'
|
||||
import { resolveHeadless } from './headless.ts'
|
||||
import { scaffoldProject, type ScaffoldResult } from './project-scaffolder.ts'
|
||||
import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts'
|
||||
|
||||
@@ -42,24 +46,29 @@ export async function createProject(
|
||||
context: CreateCommandContext,
|
||||
): Promise<ScaffoldResult | undefined> {
|
||||
const args = parseCreateArgs(argv)
|
||||
// Under --json, stdout carries only NDJSON events: human-readable progress
|
||||
// and package-manager child output move to stderr.
|
||||
const progress = args.json === true ? context.stderr : context.stdout
|
||||
if (args.help) {
|
||||
context.stdout.write(CREATE_TEMPLATES.usage.render({}))
|
||||
return undefined
|
||||
}
|
||||
if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
|
||||
throw new Error('create-sdk requires an interactive TTY')
|
||||
const headless = await resolveHeadless(args)
|
||||
if (!headless && !context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
|
||||
throw new Error('create-sdk requires an interactive TTY, --config <file>, or --config-json <json>')
|
||||
}
|
||||
const wizard = new CreateWizard({
|
||||
args,
|
||||
args: headless ? headless.args : args,
|
||||
/* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */
|
||||
port: context.port ?? new ClackPromptPort(context.stdin, context.stdout),
|
||||
port: context.port ?? (headless ? new HeadlessPromptPort() : new ClackPromptPort(context.stdin, context.stdout)),
|
||||
cwd: context.cwd,
|
||||
releaseVersion: context.releaseVersion ?? await readCreateSdkVersion(),
|
||||
...context.versionProbe ? { versionProbe: context.versionProbe } : {},
|
||||
...headless?.features ? { features: headless.features } : {},
|
||||
})
|
||||
const resolved = await wizard.run()
|
||||
const result = await scaffoldProject(resolved.directory, resolved.request)
|
||||
context.stdout.write(CREATE_TEMPLATES.created.render({
|
||||
progress.write(CREATE_TEMPLATES.created.render({
|
||||
name: resolved.request.name,
|
||||
directory: resolved.directory,
|
||||
}))
|
||||
@@ -67,8 +76,9 @@ export async function createProject(
|
||||
try {
|
||||
if (context.setup) await context.setup(resolved)
|
||||
else {
|
||||
await resolved.request.packageManager.install(resolved.directory)
|
||||
await resolved.request.packageManager.build(resolved.directory)
|
||||
const runner = args.json === true ? new NodeCommandRunner(context.stderr) : new NodeCommandRunner()
|
||||
await resolved.request.packageManager.install(resolved.directory, runner)
|
||||
await resolved.request.packageManager.build(resolved.directory, runner)
|
||||
}
|
||||
} catch (error) {
|
||||
context.stderr.write(CREATE_TEMPLATES.setupFailure.render({
|
||||
@@ -79,7 +89,7 @@ export async function createProject(
|
||||
throw error
|
||||
}
|
||||
}
|
||||
context.stdout.write(CREATE_TEMPLATES.nextSteps.render({
|
||||
progress.write(CREATE_TEMPLATES.nextSteps.render({
|
||||
directory: resolved.directory,
|
||||
setupRequired: !resolved.install,
|
||||
...packageManagerTemplateModel(resolved.request.packageManager),
|
||||
@@ -87,6 +97,17 @@ export async function createProject(
|
||||
return result
|
||||
}
|
||||
|
||||
/** Whether NDJSON lifecycle events were requested, tolerating unparseable argv. */
|
||||
function wantsJsonEvents(argv: readonly string[]): boolean {
|
||||
let parsed: CreateArgs
|
||||
try {
|
||||
parsed = parseCreateArgs(argv)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return parsed.json === true
|
||||
}
|
||||
|
||||
/** Run the create command with process defaults and convert cancellation to a clean exit. */
|
||||
export async function runCreateCommand(
|
||||
argv: readonly string[] = process.argv.slice(2),
|
||||
@@ -97,15 +118,27 @@ export async function runCreateCommand(
|
||||
stderr: process.stderr,
|
||||
},
|
||||
): Promise<number> {
|
||||
const json = wantsJsonEvents(argv)
|
||||
const emit = (event: Record<string, unknown>): void => {
|
||||
context.stdout.write(`${JSON.stringify(event)}\n`)
|
||||
}
|
||||
try {
|
||||
await createProject(argv, context)
|
||||
if (json) emit({ type: 'done' })
|
||||
return 0
|
||||
} catch (error) {
|
||||
if (error instanceof PromptCancelledError) {
|
||||
context.stderr.write('create-sdk: cancelled\n')
|
||||
if (json) emit({ type: 'error', reason: 'cancelled' })
|
||||
else context.stderr.write('create-sdk: cancelled\n')
|
||||
return 1
|
||||
}
|
||||
context.stderr.write(`create-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
if (json && error instanceof HeadlessPromptError) {
|
||||
emit({ type: 'action-required', prompt: error.prompt })
|
||||
return 1
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (json) emit({ type: 'error', message })
|
||||
else context.stderr.write(`create-sdk: ${message}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export class CreateWizard {
|
||||
private readonly versionProbe: PackageManagerVersionProbe
|
||||
private readonly userAgent: string
|
||||
private readonly linkWorkspaceRoot: string | undefined
|
||||
private readonly featurePlan: readonly FeatureSelection[] | undefined
|
||||
|
||||
/** Bind parsed args and infrastructure to one wizard run. */
|
||||
constructor(options: {
|
||||
@@ -57,6 +58,7 @@ export class CreateWizard {
|
||||
releaseVersion: string
|
||||
versionProbe?: PackageManagerVersionProbe
|
||||
userAgent?: string
|
||||
features?: readonly FeatureSelection[]
|
||||
}) {
|
||||
this.args = options.args
|
||||
this.port = options.port
|
||||
@@ -68,6 +70,7 @@ export class CreateWizard {
|
||||
this.linkWorkspaceRoot = options.args.linkWorkspace
|
||||
? fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
: undefined
|
||||
this.featurePlan = options.features
|
||||
}
|
||||
|
||||
/** Collect all answers before constructing any project files. */
|
||||
@@ -129,39 +132,43 @@ export class CreateWizard {
|
||||
const configurable = registry.all().filter(feature => feature.id === 'bash'
|
||||
|| feature.id === 'persistence'
|
||||
|| (!feature.required && feature.isApplicable(profile)))
|
||||
const selected = [...requireAnswer(await this.port.nestedMultiselect({
|
||||
message: 'Select features',
|
||||
options: configurable.map((feature) => {
|
||||
const nested = feature.mode !== 'single'
|
||||
const defaults = new Set(feature.defaultOptions(profile))
|
||||
return {
|
||||
value: feature.id,
|
||||
label: feature.summary,
|
||||
required: feature.required,
|
||||
default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo'
|
||||
|| feature.id === 'skill',
|
||||
...nested ? {
|
||||
choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const,
|
||||
choices: feature.options.map(option => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
default: defaults.has(option.id),
|
||||
})),
|
||||
} : {},
|
||||
const selected = this.featurePlan
|
||||
? this.featurePlan.map(feature => ({ value: feature.id, choices: feature.options }))
|
||||
: [...requireAnswer(await this.port.nestedMultiselect({
|
||||
message: 'Select features',
|
||||
options: configurable.map((feature) => {
|
||||
const nested = feature.mode !== 'single'
|
||||
const defaults = new Set(feature.defaultOptions(profile))
|
||||
return {
|
||||
value: feature.id,
|
||||
label: feature.summary,
|
||||
required: feature.required,
|
||||
default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo'
|
||||
|| feature.id === 'skill',
|
||||
...nested ? {
|
||||
choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const,
|
||||
choices: feature.options.map(option => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
default: defaults.has(option.id),
|
||||
})),
|
||||
} : {},
|
||||
}
|
||||
}),
|
||||
}))]
|
||||
if (!this.featurePlan) {
|
||||
for (const { value: id } of [...selected]) {
|
||||
const feature = registry.get(id)
|
||||
for (const suggestedId of feature.suggests) {
|
||||
if (selected.some(item => item.value === suggestedId)) continue
|
||||
const suggested = registry.get(suggestedId)
|
||||
const add = requireAnswer(await new ConfirmQuestion({
|
||||
id: `${feature.id}.${suggested.id}`,
|
||||
message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`,
|
||||
initialValue: true,
|
||||
}).resolve(this.port))
|
||||
if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
|
||||
}
|
||||
}),
|
||||
}))]
|
||||
for (const { value: id } of [...selected]) {
|
||||
const feature = registry.get(id)
|
||||
for (const suggestedId of feature.suggests) {
|
||||
if (selected.some(item => item.value === suggestedId)) continue
|
||||
const suggested = registry.get(suggestedId)
|
||||
const add = requireAnswer(await new ConfirmQuestion({
|
||||
id: `${feature.id}.${suggested.id}`,
|
||||
message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`,
|
||||
initialValue: true,
|
||||
}).resolve(this.port))
|
||||
if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
|
||||
}
|
||||
}
|
||||
const fixed = new Set(selections.map(selection => selection.id))
|
||||
@@ -174,12 +181,16 @@ export class CreateWizard {
|
||||
for (const choice of selected) {
|
||||
choices.set(choice.value, choice.choices.length > 0 ? choice.choices : undefined)
|
||||
}
|
||||
const plannedById = new Map((this.featurePlan ?? []).map(feature => [feature.id, feature]))
|
||||
for (const [id, options] of choices) {
|
||||
const planned = plannedById.get(id)
|
||||
selections.push(await configurator.configure(
|
||||
registry.get(id),
|
||||
profile,
|
||||
undefined,
|
||||
options,
|
||||
planned?.secrets ?? {},
|
||||
planned?.values ?? {},
|
||||
))
|
||||
}
|
||||
return selections
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Headless create input: a structured project spec supplied by an agent or CI
|
||||
* instead of interactive prompts.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk/headless
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { FeatureSelection, PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper'
|
||||
import type { CreateArgs } from './args.ts'
|
||||
|
||||
/**
|
||||
* Structured, non-interactive create input. Scalar fields mirror {@link CreateArgs}
|
||||
* project answers; `features` is the headless feature plan handed to `CreateWizard`
|
||||
* (the interactive tree/suggests prompts are skipped). Absent required answers make
|
||||
* the run fail loud through `HeadlessPromptPort` rather than blocking.
|
||||
*/
|
||||
interface HeadlessCreateSpec {
|
||||
directory?: string
|
||||
description?: string
|
||||
provider?: 'deepseek' | 'custom'
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
interface?: RunInterface
|
||||
pm?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
features?: readonly FeatureSelection[]
|
||||
}
|
||||
|
||||
/** Resolved headless input: the args the wizard reads plus the feature plan. */
|
||||
export interface ResolvedHeadless {
|
||||
args: CreateArgs
|
||||
features: readonly FeatureSelection[] | undefined
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, source: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${source}: expected a JSON object`)
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Parse and shallow-validate a headless spec from JSON text. */
|
||||
function parseHeadlessSpec(text: string, source: string): HeadlessCreateSpec {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- JSON.parse only throws Error instances; the String() branch is defensive */
|
||||
throw new Error(`${source}: invalid JSON (${error instanceof Error ? error.message : String(error)})`)
|
||||
}
|
||||
const record = asRecord(parsed, source)
|
||||
if (record.features !== undefined && !Array.isArray(record.features)) {
|
||||
throw new Error(`${source}: "features" must be an array`)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a headless spec from `--config-json` (inline) or `--config` (a JSON file),
|
||||
* returning `undefined` when neither is supplied.
|
||||
* @param args - parsed create args.
|
||||
* @param readFileText - file reader seam for tests.
|
||||
* @returns the resolved args + feature plan, or `undefined` for interactive runs.
|
||||
*/
|
||||
export async function resolveHeadless(
|
||||
args: CreateArgs,
|
||||
readFileText: (path: string) => Promise<string> = path => readFile(path, 'utf8'),
|
||||
): Promise<ResolvedHeadless | undefined> {
|
||||
let text: string
|
||||
let source: string
|
||||
if (args.configJson !== undefined) {
|
||||
text = args.configJson
|
||||
source = '--config-json'
|
||||
} else if (args.config !== undefined) {
|
||||
source = args.config
|
||||
text = await readFileText(args.config)
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
const spec = parseHeadlessSpec(text, source)
|
||||
const resolvedArgs: CreateArgs = {
|
||||
...spec.directory === undefined ? {} : { directory: spec.directory },
|
||||
...spec.description === undefined ? {} : { description: spec.description },
|
||||
...spec.provider === undefined ? {} : { provider: spec.provider },
|
||||
...spec.baseURL === undefined ? {} : { baseURL: spec.baseURL },
|
||||
...spec.apiKey === undefined ? {} : { apiKey: spec.apiKey },
|
||||
...spec.model === undefined ? {} : { model: spec.model },
|
||||
...spec.interface === undefined ? {} : { runInterface: spec.interface },
|
||||
...spec.pm === undefined ? {} : { packageManager: spec.pm },
|
||||
...spec.install === undefined ? {} : { install: spec.install },
|
||||
...spec.linkWorkspace ? { linkWorkspace: true } : {},
|
||||
help: false,
|
||||
}
|
||||
return { args: resolvedArgs, features: spec.features }
|
||||
}
|
||||
@@ -9,3 +9,6 @@ Options:
|
||||
--interface <acp|stdio|embed>
|
||||
--pm <npm|pnpm|yarn>
|
||||
--install / --no-install
|
||||
--config <path>
|
||||
--config-json <json>
|
||||
--json
|
||||
|
||||
Reference in New Issue
Block a user