fix(create-sdk): keep --json stdout pure NDJSON

The SKILL.md contract says every stdout line is one JSON event, but
createProject wrote the Created/Next-steps templates to stdout and the
default install/build path inherited the launcher's stdio, so package-
manager child output interleaved with the event stream. Under --json,
route human progress to stderr and run install/build through a
NodeCommandRunner that redirects child stdout+stderr to stderr.
This commit is contained in:
imccyu
2026-07-18 21:50:46 +08:00
parent 78f21bf703
commit 013db6e8f0
4 changed files with 65 additions and 7 deletions

View File

@@ -58,17 +58,38 @@ export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env):
/** Node child-process command runner with inherited stdio and quiescent completion. */
export class NodeCommandRunner implements CommandRunner {
/** Spawn one child and settle only after its exit. */
private readonly output: NodeJS.WritableStream | undefined
/**
* @param output - redirect target for child stdout+stderr; the child inherits
* this process's stdio when absent. Callers whose own stdout carries a machine
* protocol (create-sdk --json NDJSON) redirect child output to keep the
* protocol stream pure.
*/
constructor(output?: NodeJS.WritableStream) {
this.output = output
}
/** Spawn one child and settle only after exit, with redirected stdio drained. */
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const output = this.output
if (output === undefined) {
const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), stdio: 'inherit', shell: false })
child.once('error', reject)
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
return
}
const child = spawn(command, [...args], {
cwd,
env: scrubEnvironment(),
stdio: 'inherit',
stdio: ['inherit', 'pipe', 'pipe'],
shell: false,
})
child.stdout.pipe(output, { end: false })
child.stderr.pipe(output, { end: false })
child.once('error', reject)
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) })
})
}
}

View File

@@ -1,6 +1,7 @@
import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Writable } from 'node:stream'
import { afterEach, describe, expect, it } from 'vitest'
import { CordisYamlFile, JsExpression } from '../src/documents/cordis-yaml-file.ts'
import { EnvFile } from '../src/documents/env-file.ts'
@@ -325,6 +326,19 @@ describe('package manager strategies', () => {
const runner = new NodeCommandRunner()
await expect(runner.run(process.execPath, ['-e', ''], root)).resolves.toEqual({ exitCode: 0, signal: null })
await expect(runner.run('missing-dsh-command', [], root)).rejects.toThrow()
let redirected = ''
const output = new Writable({
write(chunk, _encoding, callback) { redirected += String(chunk); callback() },
})
const redirecting = new NodeCommandRunner(output)
await expect(redirecting.run(
process.execPath,
['-e', 'process.stdout.write("child-out"); process.stderr.write("child-err")'],
root,
)).resolves.toEqual({ exitCode: 0, signal: null })
expect(redirected).toContain('child-out')
expect(redirected).toContain('child-err')
await expect(redirecting.run('missing-dsh-command', [], root)).rejects.toThrow()
})
it('discovers and rewrites a repository-local NPM dependency closure', async () => {