Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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