diff --git a/lib/docker.ts b/lib/docker.ts index 52ee8709..233eff48 100644 --- a/lib/docker.ts +++ b/lib/docker.ts @@ -257,9 +257,8 @@ export const configureNextcloud = async function(apps = ['viewer'], vendoredBran console.log('│ └─ OK !') // Build app list - const json = await runOcc(['app:list', '--output', 'json'], { container }) - // fix dockerode bug returning invalid leading characters - const applist = JSON.parse(json.substring(json.indexOf('{'))) + const json = await runOcc(['app:list', '--output', 'json'], { container, verbose: true }) + const applist = JSON.parse(json) // Enable apps and give status for (const app of apps) { @@ -270,6 +269,7 @@ export const configureNextcloud = async function(apps = ['viewer'], vendoredBran await runOcc(['app:enable', '--force', app], { container, verbose: true }) } else if (app in VENDOR_APPS) { // apps that are vendored but still missing (i.e. not build in or mounted already) + // NOTE: This currently fails in workflows since mounts are RO at this point await runExec(['git', 'clone', '--depth=1', `--branch=${vendoredBranch}`, VENDOR_APPS[app], `apps/${app}`], { container, verbose: true }) await runOcc(['app:enable', '--force', app], { container, verbose: true }) } else { @@ -386,13 +386,18 @@ interface RunExecOptions { verbose: boolean; } +type RunExecResult = { + stdout: string + stderr: string +} + /** - * Execute a command in the container + * Execute a command in the container and return stdout/stderr separately. */ -export const runExec = async function( +export const runExecRaw = async function( command: string | string[], { container, user='www-data', verbose=false, env=[] }: Partial = {}, -) { +): Promise { container = container || getContainer() const exec = await container.exec({ Cmd: typeof command === 'string' ? [command] : command, @@ -402,32 +407,103 @@ export const runExec = async function( Env: env, }) - return new Promise((resolve, reject) => { - const dataStream = new PassThrough() + return new Promise((resolve, reject) => { + const stdoutStream = new PassThrough() + const stderrStream = new PassThrough() - exec.start({}, (err, stream) => { - if (stream) { - // Pass stdout and stderr to dataStream - exec.modem.demuxStream(stream, dataStream, dataStream) - stream.on('end', () => dataStream.end()) - } else { - reject(err) + const stdout: string[] = [] + const stderr: string[] = [] + + let settled = false + let finishedStreams = 0 + + const cleanup = () => { + stdoutStream.removeAllListeners() + stderrStream.removeAllListeners() + } + + const settleResolve = (result: RunExecResult) => { + if (settled) { + return + } + settled = true + cleanup() + resolve(result) + } + + const settleReject = (err: unknown) => { + if (settled) { + return + } + settled = true + cleanup() + reject(err) + } + + const maybeResolve = () => { + finishedStreams++ + if (finishedStreams === 2) { + settleResolve({ + stdout: stdout.join(''), + stderr: stderr.join(''), + }) + } + } + + stdoutStream.on('data', (chunk) => { + const text = chunk.toString('utf8') + stdout.push(text) + if (verbose && text.trim()) { + console.log(`├─ stdout: ${text.trim().replace(/\n/gi, '\n├─ stdout: ')}`) + } + }) + + stderrStream.on('data', (chunk) => { + const text = chunk.toString('utf8') + stderr.push(text) + if (verbose && text.trim()) { + console.log(`├─ stderr: ${text.trim().replace(/\n/gi, '\n├─ stderr: ')}`) } }) - const data: string[] = [] - dataStream.on('data', (chunk) => { - data.push(chunk.toString('utf8')) - const printable = data.at(-1)?.trim() - if (verbose && printable) { - console.log(`├─ ${printable.replace(/\n/gi, '\n├─ ')}`) + stdoutStream.on('error', settleReject) + stderrStream.on('error', settleReject) + + stdoutStream.on('end', maybeResolve) + stderrStream.on('end', maybeResolve) + + exec.start({}, (err, stream) => { + if (err) { + settleReject(err) + return + } + if (!stream) { + settleReject(new Error('No exec stream returned')) + return } + + stream.on('error', settleReject) + stream.on('end', () => { + stdoutStream.end() + stderrStream.end() + }) + + exec.modem.demuxStream(stream, stdoutStream, stderrStream) }) - dataStream.on('error', (err) => reject(err)) - dataStream.on('end', () => resolve(data.join(''))) }) } +/** + * Execute a command in the container and return stdout only. + */ +export const runExec = async function( + command: string | string[], + options: Partial = {}, +): Promise { + const { stdout } = await runExecRaw(command, options) + return stdout +} + /** * Execute an occ command in the container */