Skip to content
5 changes: 5 additions & 0 deletions .changeset/normalize-run-command-with-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": patch
---

Normalize runCommandWithOutput to return a CommandResult discriminated union instead of rejecting on spawn errors, fixing a crash in `info --json` when Docker is not installed.
11 changes: 3 additions & 8 deletions src/cli/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,8 @@ const getEventCount = async (): Promise<number | null> => {
}

const getRelayUptimeSeconds = async (): Promise<number | null> => {
let idResult: { code: number; stdout: string; stderr: string }
try {
idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream'], { timeoutMs: 1000 })
} catch {
return null
}
if (idResult.code !== 0) {
const idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream'], { timeoutMs: 1000 })
if (!idResult.ok || idResult.code !== 0) {
return null
}

Expand All @@ -74,7 +69,7 @@ const getRelayUptimeSeconds = async (): Promise<number | null> => {
const startedAtResult = await runCommandWithOutput('docker', ['inspect', '--format', '{{.State.StartedAt}}', containerId], {
timeoutMs: 1000,
})
if (startedAtResult.code !== 0) {
if (!startedAtResult.ok || startedAtResult.code !== 0) {
return null
}

Expand Down
4 changes: 4 additions & 0 deletions src/cli/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export const runUpdate = async (passthrough: string[]): Promise<number> => {
}

const stashResult = await runCommandWithOutput('git', ['stash', 'push', '-u', '-m', 'nostream-cli-update'])
if (!stashResult.ok) {
spinner.fail(stashResult.ok === false && stashResult.reason === 'not-found' ? 'Update failed: git is not installed' : 'Update failed while stashing local changes')
return 1
Comment on lines +21 to +23
}
if (stashResult.code !== 0) {
spinner.fail('Update failed while stashing local changes')
Comment on lines +22 to 26
return stashResult.code
Expand Down
50 changes: 39 additions & 11 deletions src/cli/utils/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export type RunOptions = {
timeoutMs?: number
}

export type CommandResult =
| { ok: true; code: number; stdout: string; stderr: string }
| { ok: false; reason: 'not-found' | 'permission-denied' | 'spawn-error' | 'timeout' | 'signal'; stdout: string; stderr: string }

export const runCommand = (command: string, args: string[], options: RunOptions = {}): Promise<number> => {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
Expand Down Expand Up @@ -38,10 +42,19 @@ export const runCommandWithOutput = (
command: string,
args: string[],
options: RunOptions = {},
): Promise<{ code: number; stdout: string; stderr: string }> => {
return new Promise((resolve, reject) => {
): Promise<CommandResult> => {
return new Promise((resolve) => {
let stdout = ''
let stderr = ''
let timedOut = false
let settled = false

const settle = (result: CommandResult) => {
if (!settled) {
settled = true
resolve(result)
}
}

const child = spawn(command, args, {
cwd: options.cwd,
Expand All @@ -53,6 +66,7 @@ export const runCommandWithOutput = (
const timer =
typeof options.timeoutMs === 'number'
? setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
}, options.timeoutMs)
: undefined
Expand All @@ -65,17 +79,31 @@ export const runCommandWithOutput = (
stderr += chunk.toString()
})

child.on('error', reject)
child.on('close', (code) => {
if (timer) {
clearTimeout(timer)
child.on('error', (err: NodeJS.ErrnoException) => {
if (timer) { clearTimeout(timer) }
if (err.code === 'ENOENT') {
settle({ ok: false, reason: 'not-found', stdout, stderr })
} else if (err.code === 'EACCES') {
settle({ ok: false, reason: 'permission-denied', stdout, stderr })
} else {
settle({ ok: false, reason: 'spawn-error', stdout, stderr })
}
})

child.on('close', (code, signal) => {
if (timer) { clearTimeout(timer) }

if (timedOut) {
settle({ ok: false, reason: 'timeout', stdout, stderr })
return
}

if (signal !== null && code === null) {
settle({ ok: false, reason: 'signal', stdout, stderr })
return
}

resolve({
code: code ?? 1,
stdout,
stderr,
})
settle({ ok: true, code: code ?? 1, stdout, stderr })
})
})
}
25 changes: 19 additions & 6 deletions test/unit/cli/info.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,27 @@ describe('runInfo', () => {
sinon.restore()
})

it('outputs valid JSON when docker is not installed (ENOENT)', async () => {
sinon.stub(fs, 'existsSync').returns(false)
sinon.stub(processUtils, 'runCommandWithOutput').resolves({ ok: false, reason: 'not-found', stdout: '', stderr: '' })

const code = await infoCommand.runInfo({ json: true })

expect(code).to.equal(0)
const parsed = JSON.parse(stdout)
expect(parsed).to.have.nested.property('runtime.uptimeSeconds', null)
expect(stderr).to.equal('')
})

it('prints detected I2P hostnames as JSON', async () => {
sinon.stub(fs, 'existsSync').callsFake((target) => String(target).endsWith('nostream.dat'))
sinon
.stub(processUtils, 'runCommandWithOutput')
.onFirstCall()
.resolves({ code: 1, stdout: '', stderr: '' })
.resolves({ ok: true, code: 1, stdout: '', stderr: '' })
.onSecondCall()
.resolves({
ok: true,
code: 0,
stdout: 'alphaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.b32.i2p\n',
stderr: 'betabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.b32.i2p\n',
Expand All @@ -58,7 +71,7 @@ describe('runInfo', () => {

it('prints a JSON error when I2P keys are missing', async () => {
sinon.stub(fs, 'existsSync').returns(false)
sinon.stub(processUtils, 'runCommandWithOutput').resolves({ code: 1, stdout: '', stderr: '' })
sinon.stub(processUtils, 'runCommandWithOutput').resolves({ ok: true, code: 1, stdout: '', stderr: '' })

const code = await infoCommand.runInfo({ i2pHostname: true, json: true })

Expand All @@ -77,9 +90,9 @@ describe('runInfo', () => {
sinon
.stub(processUtils, 'runCommandWithOutput')
.onFirstCall()
.resolves({ code: 1, stdout: '', stderr: '' })
.resolves({ ok: true, code: 1, stdout: '', stderr: '' })
.onSecondCall()
.resolves({ code: 0, stdout: '', stderr: '' })
.resolves({ ok: true, code: 0, stdout: '', stderr: '' })

const code = await infoCommand.runInfo({ i2pHostname: true, json: true })

Expand All @@ -101,9 +114,9 @@ describe('runInfo', () => {
sinon
.stub(processUtils, 'runCommandWithOutput')
.onFirstCall()
.resolves({ code: 1, stdout: '', stderr: '' })
.resolves({ ok: true, code: 1, stdout: '', stderr: '' })
.onSecondCall()
.resolves({ code: 0, stdout: '', stderr: '' })
.resolves({ ok: true, code: 0, stdout: '', stderr: '' })

const code = await infoCommand.runInfo({ i2pHostname: true })

Expand Down
44 changes: 44 additions & 0 deletions test/unit/cli/run-command.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const { expect } = require('chai')

const { runCommandWithOutput } = require('../../../dist/src/cli/utils/process.js')

describe('runCommandWithOutput', () => {
it('resolves ok:true with captured stdout, stderr and exit code 0', async () => {
const result = await runCommandWithOutput('sh', ['-c', 'echo out; echo err >&2'])

expect(result).to.deep.equal({ ok: true, code: 0, stdout: 'out\n', stderr: 'err\n' })
})

it('resolves ok:true with non-zero exit code', async () => {
const result = await runCommandWithOutput('sh', ['-c', 'exit 2'])

expect(result.ok).to.equal(true)
expect(result.code).to.equal(2)
})

it('resolves ok:false reason:not-found when command does not exist (ENOENT)', async () => {
const result = await runCommandWithOutput('__nostream_nonexistent_cmd__', [])

expect(result).to.deep.equal({ ok: false, reason: 'not-found', stdout: '', stderr: '' })
})

it('resolves ok:false reason:timeout when the process exceeds timeoutMs', async () => {
const result = await runCommandWithOutput('sleep', ['10'], { timeoutMs: 100 })

expect(result).to.deep.equal({ ok: false, reason: 'timeout', stdout: '', stderr: '' })
})

it('resolves ok:false reason:signal when the process is killed by a signal', async () => {
const result = await runCommandWithOutput('sh', ['-c', 'kill -9 $$'])

expect(result.ok).to.equal(false)
expect(result.reason).to.equal('signal')
})

it('does not double-settle when ENOENT fires both error and close', async () => {
const result = await runCommandWithOutput('__nostream_nonexistent_cmd__', [])

expect(result.ok).to.equal(false)
expect(result.reason).to.equal('not-found')
})
})
2 changes: 2 additions & 0 deletions test/unit/cli/update.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('runUpdate', () => {
sinon.stub(stopCommand, 'runStop').resolves(0)
const runStartStub = sinon.stub(startCommand, 'runStart').resolves(0)
sinon.stub(processUtils, 'runCommandWithOutput').resolves({
ok: true,
code: 0,
stdout: 'Saved working directory and index state WIP on main: abc123',
stderr: '',
Expand All @@ -38,6 +39,7 @@ describe('runUpdate', () => {
sinon.stub(stopCommand, 'runStop').resolves(0)
const runStartStub = sinon.stub(startCommand, 'runStart').resolves(0)
sinon.stub(processUtils, 'runCommandWithOutput').resolves({
ok: true,
code: 0,
stdout: 'Saved working directory and index state WIP on main: abc123',
stderr: '',
Expand Down
Loading