Skip to content

Commit df64b0e

Browse files
committed
fix(cli): isolate embedded output from host process logging
1 parent 493e678 commit df64b0e

22 files changed

Lines changed: 239 additions & 154 deletions

packages/sim-cli/src/auth/device-flow.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createHash, randomBytes, randomInt } from 'node:crypto'
2+
import { writeStderr } from '#cli/output/io'
23
import { sleep } from '../helpers'
34
import { buildUrl, REDIRECT_STATUSES, redirectEndpoint, SimApiError } from '../http/client'
45
import { userAgent } from '../version'
@@ -207,7 +208,7 @@ export async function pollForKey(
207208
consecutiveTransportFailures >= TRANSPORT_FAILURES_BEFORE_WARNING
208209
) {
209210
warnedAboutTransport = true
210-
process.stderr.write(
211+
writeStderr(
211212
`Still waiting: ${endpoint} is not answering the login poll (${(cause as Error).message}). Check the endpoint; retrying until you approve or the login times out.\n`
212213
)
213214
}

packages/sim-cli/src/commands/auth.ts

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { spawn } from 'node:child_process'
22
import { createInterface } from 'node:readline/promises'
33
import { Command } from 'commander'
4+
import { printLine } from '#cli/output/io'
45
import { styles } from '#cli/output/presentation'
56
import {
67
buildApprovalUrl,
@@ -212,9 +213,9 @@ async function chooseWorkspace(client: Pick<SimClient, 'request'>): Promise<Sele
212213
)
213214
}
214215

215-
console.log('\nAvailable workspaces:')
216+
printLine('\nAvailable workspaces:')
216217
for (const [index, workspace] of workspaces.entries()) {
217-
console.log(` ${index + 1}) ${safeOneLine(workspace.name)} (${workspace.id})`)
218+
printLine(` ${index + 1}) ${safeOneLine(workspace.name)} (${workspace.id})`)
218219
}
219220

220221
const prompt = createInterface({ input: process.stdin, output: process.stderr })
@@ -257,12 +258,10 @@ function addProfileCommand(): Command {
257258
workspace: normalizeWorkspaceId(workspace.id, 'the workspace response'),
258259
})
259260

260-
console.log(
261-
styles().green(`✓ Added profile "${safeOneLine(profileName)}" in ${configPath()}`)
262-
)
263-
console.log(` Workspace: ${safeOneLine(workspace.name)} (${workspace.id})`)
264-
console.log(` Authentication: ${safeOneLine(authProfile)}`)
265-
console.log(styles().dim(` Try: sim --profile ${safeOneLine(profileName)} whoami`))
261+
printLine(styles().green(`✓ Added profile "${safeOneLine(profileName)}" in ${configPath()}`))
262+
printLine(` Workspace: ${safeOneLine(workspace.name)} (${workspace.id})`)
263+
printLine(` Authentication: ${safeOneLine(authProfile)}`)
264+
printLine(styles().dim(` Try: sim --profile ${safeOneLine(profileName)} whoami`))
266265
})
267266
}
268267

@@ -294,7 +293,7 @@ export function loginCommand(): Command {
294293
if (readCredentialsProfile(profile.name).api_key && !options.yes) {
295294
const confirmed = await confirmProfileOverwrite(profile.name)
296295
if (!confirmed) {
297-
console.log(styles().dim('Login cancelled; the existing profile was not changed.'))
296+
printLine(styles().dim('Login cancelled; the existing profile was not changed.'))
298297
return
299298
}
300299
}
@@ -307,17 +306,17 @@ export function loginCommand(): Command {
307306
profile.workspaceId ?? undefined
308307
)
309308

310-
console.log(
309+
printLine(
311310
`Signing in to ${styles().bold(profile.endpoint)} as profile ${styles().bold(safeOneLine(profile.name))}`
312311
)
313-
console.log(`\nPairing code: ${styles().bold(auth.pairing)}`)
314-
console.log(
312+
printLine(`\nPairing code: ${styles().bold(auth.pairing)}`)
313+
printLine(
315314
styles().dim('Confirm this code matches what the browser shows before approving.\n')
316315
)
317-
console.log(url)
316+
printLine(url)
318317

319318
if (options.browser) openBrowser(url)
320-
console.log(styles().dim('\nWaiting for approval…'))
319+
printLine(styles().dim('\nWaiting for approval…'))
321320

322321
const key = await pollForKey(profile.endpoint, auth)
323322

@@ -360,19 +359,17 @@ export function loginCommand(): Command {
360359
writeConfigProfile(profile.name, settings)
361360
writeCredentialsProfile(profile.name, key.apiKey)
362361

363-
console.log(styles().green(`\n✓ Logged in. Key stored in ${credentialsPath()}`))
362+
printLine(styles().green(`\n✓ Logged in. Key stored in ${credentialsPath()}`))
364363
if (key.workspaceBound && key.workspaceId) {
365-
console.log(
366-
styles().dim(` Workspace-scoped key — it can only reach ${key.workspaceId}.`)
367-
)
364+
printLine(styles().dim(` Workspace-scoped key — it can only reach ${key.workspaceId}.`))
368365
} else if (key.workspaceId) {
369-
console.log(
366+
printLine(
370367
styles().dim(
371368
` Personal key, defaulting to ${key.workspaceId}. Override per command with --workspace.`
372369
)
373370
)
374371
} else {
375-
console.log(
372+
printLine(
376373
styles().dim(
377374
' Personal key with no default workspace. Set one with: sim configure --set-workspace <id>'
378375
)
@@ -398,10 +395,10 @@ export function logoutCommand(): Command {
398395
}
399396
const removed = deleteProfile(profileName)
400397
if (!removed.config && !removed.credentials) {
401-
console.log(styles().dim(`Nothing stored for profile "${safeOneLine(profileName)}".`))
398+
printLine(styles().dim(`Nothing stored for profile "${safeOneLine(profileName)}".`))
402399
return
403400
}
404-
console.log(styles().green(`✓ Removed profile "${safeOneLine(profileName)}".`))
401+
printLine(styles().green(`✓ Removed profile "${safeOneLine(profileName)}".`))
405402
return
406403
}
407404

@@ -415,17 +412,17 @@ export function logoutCommand(): Command {
415412
}
416413

417414
if (!readCredentialsProfile(profile.name).api_key) {
418-
console.log(styles().dim(`No stored key for profile "${safeOneLine(profile.name)}".`))
415+
printLine(styles().dim(`No stored key for profile "${safeOneLine(profile.name)}".`))
419416
return
420417
}
421418

422419
writeCredentialsProfile(profile.name, null)
423-
console.log(
420+
printLine(
424421
styles().green(`✓ Removed the stored key for profile "${safeOneLine(profile.name)}".`)
425422
)
426423
// The key still exists server-side; leaving that unsaid invites the
427424
// assumption that logging out revoked it.
428-
console.log(
425+
printLine(
429426
styles().dim(' The key itself is still active — revoke it in Settings → Sim API keys.')
430427
)
431428
})
@@ -759,7 +756,7 @@ export function profilesCommand(): Command {
759756
if (rows.length === 0) {
760757
// The prose belongs to the human formats; a script asking for json must
761758
// get an empty list, not a sentence it cannot parse.
762-
if (output === 'table') console.log(styles().dim('No profiles yet. Run: sim login'))
759+
if (output === 'table') printLine(styles().dim('No profiles yet. Run: sim login'))
763760
else printList(output, rows, PROFILE_COLUMNS)
764761
return
765762
}

packages/sim-cli/src/commands/configure.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Command } from 'commander'
2+
import { printLine } from '#cli/output/io'
23
import { styles } from '#cli/output/presentation'
34
import {
45
configPath,
@@ -154,11 +155,11 @@ export function configureCommand(): Command {
154155
if (Object.keys(updates).length === 0) {
155156
const current = readConfigProfile(profile.name)
156157
if (Object.keys(current).length === 0) {
157-
console.log(styles().dim(`No settings stored for profile "${profile.name}".`))
158+
printLine(styles().dim(`No settings stored for profile "${profile.name}".`))
158159
return
159160
}
160161
for (const [key, value] of Object.entries(current)) {
161-
console.log(`${styles().dim(`${key}:`)} ${value}`)
162+
printLine(`${styles().dim(`${key}:`)} ${value}`)
162163
}
163164
return
164165
}
@@ -168,12 +169,12 @@ export function configureCommand(): Command {
168169
// an update would claim a change that did not happen.
169170
const removalOnly = Object.values(updates).every((value) => value === null)
170171
if (removalOnly && Object.keys(readConfigProfile(profile.name)).length === 0) {
171-
console.log(styles().dim(`No settings stored for profile "${profile.name}".`))
172+
printLine(styles().dim(`No settings stored for profile "${profile.name}".`))
172173
return
173174
}
174175

175176
writeConfigProfile(profile.name, updates)
176-
console.log(styles().green(`✓ Updated profile "${profile.name}" in ${configPath()}`))
177+
printLine(styles().green(`✓ Updated profile "${profile.name}" in ${configPath()}`))
177178
}
178179
)
179180
}

packages/sim-cli/src/commands/protocol/chat.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { Command } from 'commander'
2+
import { embedStore } from '#cli/embed-context'
3+
import { writeStderr, writeStdout } from '#cli/output/io'
24
import { styles } from '#cli/output/presentation'
35
import { clientFrom } from '../../context'
46
import { type ChatResponse, V2_OPERATIONS } from '../../generated/v2-api'
@@ -204,18 +206,19 @@ Examples:
204206
/** Closes off streamed text so nothing is glued onto the line after it. */
205207
const endStreamedLine = (): void => {
206208
if (streamed.length > 0 && !streamed.endsWith('\n')) {
207-
process.stdout.write('\n')
209+
writeStdout('\n')
208210
streamed += '\n'
209211
}
210212
}
211213

212-
const restorePipeHandling = streaming ? ignoreBrokenPipe(process.stdout) : undefined
214+
const restorePipeHandling =
215+
streaming && !embedStore.getStore() ? ignoreBrokenPipe(process.stdout) : undefined
213216

214217
try {
215218
const result = await readChatStream(response, (content) => {
216219
if (!streaming) return
217220
streamed += content
218-
process.stdout.write(content)
221+
writeStdout(content)
219222
})
220223

221224
if (!streaming) {
@@ -230,11 +233,11 @@ Examples:
230233
// rather than duplicating the reply.
231234
const content = sanitize(result.content ?? '')
232235
if (content.startsWith(streamed) && content.length > streamed.length) {
233-
process.stdout.write(content.slice(streamed.length))
236+
writeStdout(content.slice(streamed.length))
234237
streamed = content
235238
}
236239
endStreamedLine()
237-
process.stderr.write(`${styles().dim(`conversation: ${result.conversationId}`)}\n`)
240+
writeStderr(`${styles().dim(`conversation: ${result.conversationId}`)}\n`)
238241
} catch (error) {
239242
endStreamedLine()
240243
throw error

packages/sim-cli/src/commands/protocol/files-get.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'node:path'
55
import { Readable, type Writable } from 'node:stream'
66
import { pipeline } from 'node:stream/promises'
77
import type { Command } from 'commander'
8+
import { writeStdout } from '#cli/output/io'
89
import { clientFrom } from '../../context'
910
import { embedStore } from '../../embed-context'
1011
import { V2_OPERATIONS } from '../../generated/v2-api'
@@ -227,14 +228,18 @@ export async function saveToFile(
227228
/** Streams a fetch body to stdout without closing the process-wide stream. */
228229
export async function streamToStdout(
229230
body: ReadableStream<Uint8Array>,
230-
output: NodeJS.WriteStream = process.stdout
231+
output?: NodeJS.WriteStream
231232
): Promise<void> {
232233
const reader = body.getReader()
233234
try {
234235
while (true) {
235236
const { done, value } = await reader.read()
236237
if (done) return
237-
if (!output.write(value)) await once(output, 'drain')
238+
if (output) {
239+
if (!output.write(value)) await once(output, 'drain')
240+
} else if (!writeStdout(value)) {
241+
await once(process.stdout, 'drain')
242+
}
238243
}
239244
} finally {
240245
await reader.cancel().catch(() => {})

packages/sim-cli/src/commands/protocol/logs-follow.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type Command, Option } from 'commander'
22
import { dump } from 'js-yaml'
3+
import { printLine, writeStderr } from '#cli/output/io'
34
import { hasProgressTerminal, styles } from '#cli/output/presentation'
45
import type { OutputFormat } from '../../config/index'
56
import { clientFrom } from '../../context'
@@ -213,7 +214,7 @@ function createTableWriter(): RowWriter {
213214
)
214215
)
215216
const header = widths
216-
console.log(
217+
printLine(
217218
styles().dim(
218219
COLUMNS.map((column, index) => pad(column.header.toUpperCase(), header[index]))
219220
.join(' ')
@@ -224,7 +225,7 @@ function createTableWriter(): RowWriter {
224225

225226
const locked = widths
226227
for (const line of lines) {
227-
console.log(
228+
printLine(
228229
line
229230
.map((cell, index) => pad(cell, locked[index]))
230231
.join(' ')
@@ -246,13 +247,13 @@ function createTableWriter(): RowWriter {
246247
function createWriter(format: OutputFormat): RowWriter {
247248
if (format === 'json') {
248249
return (rows) => {
249-
for (const row of rows) console.log(JSON.stringify(row))
250+
for (const row of rows) printLine(JSON.stringify(row))
250251
}
251252
}
252253
if (format === 'yaml') {
253254
return (rows) => {
254255
for (const row of rows) {
255-
console.log(`---\n${dump(row, { lineWidth: 0, noRefs: true }).trimEnd()}`)
256+
printLine(`---\n${dump(row, { lineWidth: 0, noRefs: true }).trimEnd()}`)
256257
}
257258
}
258259
}
@@ -292,19 +293,19 @@ export function followStatus(): FollowStatus {
292293
note: (message) => {
293294
if (!hasProgressTerminal()) return
294295
reported = true
295-
process.stderr.write(`\r${styles().dim(message)}${ERASE_LINE}`)
296+
writeStderr(`\r${styles().dim(message)}${ERASE_LINE}`)
296297
},
297298
warn: (message) => {
298299
if (reported) {
299300
reported = false
300-
process.stderr.write(`\r${ERASE_LINE}`)
301+
writeStderr(`\r${ERASE_LINE}`)
301302
}
302-
process.stderr.write(`warning: ${message}\n`)
303+
writeStderr(`warning: ${message}\n`)
303304
},
304305
clear: () => {
305306
if (!reported) return
306307
reported = false
307-
process.stderr.write(`\r${ERASE_LINE}`)
308+
writeStderr(`\r${ERASE_LINE}`)
308309
},
309310
}
310311
}

packages/sim-cli/src/commands/protocol/tables-import.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { setTimeout as sleep } from 'node:timers/promises'
22
import { type Command, Option } from 'commander'
3+
import { writeStderr } from '#cli/output/io'
34
import { hasProgressTerminal, styles } from '#cli/output/presentation'
45
import { clientFrom } from '../../context'
56
import type {
@@ -109,11 +110,11 @@ async function watchImport(
109110
const line = progressLine(current)
110111
if (hasProgressTerminal() && line !== reported) {
111112
reported = line
112-
process.stderr.write(`\r${styles().dim(line)}\u001b[K`)
113+
writeStderr(`\r${styles().dim(line)}\u001b[K`)
113114
}
114115
}
115116

116-
if (hasProgressTerminal() && reported !== null) process.stderr.write('\r\u001b[K')
117+
if (hasProgressTerminal() && reported !== null) writeStderr('\r\u001b[K')
117118
return current
118119
}
119120

packages/sim-cli/src/commands/protocol/workflow-run-follow.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Command } from 'commander'
2+
import { writeStderr } from '#cli/output/io'
23
import { styles } from '#cli/output/presentation'
34
import { clientFrom } from '../../context'
45
import { CLI_CONTRACT } from '../../contract/commands'
@@ -312,7 +313,7 @@ async function followRun(workflowId: string, command: Command): Promise<void> {
312313
const final = await renderRunStream(response.body, {
313314
includeThinking,
314315
includeToolCalls,
315-
stderr: process.stderr,
316+
stderr: { write: writeStderr },
316317
})
317318

318319
renderResult('executeWorkflow', profile.output, final, CLI_CONTRACT.executeWorkflow ?? {})

0 commit comments

Comments
 (0)