• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

levibostian / new-deployment-tool / 15758275762

19 Jun 2025 12:49PM UTC coverage: 71.903% (-1.3%) from 73.169%
15758275762

push

github

levibostian
build: follow best-practice of tool and setup github actions workflow to run the tool for deployments and PRs

67 of 86 branches covered (77.91%)

Branch coverage included in aggregate %.

583 of 818 relevant lines covered (71.27%)

9.19 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.64
/lib/exec.ts
1
import * as log from "./log.ts"
5✔
2
import * as shellQuote from "shell-quote"
5✔
3
import { AnyStepInput } from "./types/environment.ts"
4

5
export interface RunResult {
6
  exitCode: number
7
  stdout: string
8
  output: Record<string, unknown> | undefined
9
}
10

11
export interface Exec {
12
  run: (
13
    { command, input, displayLogs }: {
14
      command: string
15
      input: AnyStepInput | undefined
16
      displayLogs?: boolean
17
      envVars?: { [key: string]: string }
18
      throwOnNonZeroExitCode?: boolean
19
    },
20
  ) => Promise<RunResult>
21
}
22

23
/*
24
Executes a command and returns the exit code and the stdout of the command.
25

26
The entire command is passed in as 1 string. This is for convenience but also because when used as a github action, the commands will be passed to the tool as a single string.
27
Then, the command is split into the command and the arguments. This is required by Deno.Command.
28
You cannot simply split the string by spaces to create the args list. Example, if you're given: `python3 -c "import os; print(os.getenv('INPUT'))"` we expect only 2 args: "-c" and "import ...".
29
We use a popular package to parse the string into the correct args list. See automated tests to verify that this works as expected.
30

31
To make this function testable, we not only have the stdout and stderr be piped to the console, but we return it from this function so tests can verify the output of the command.
32
*/
33
const run = async (
5✔
34
  { command, input, displayLogs, envVars, throwOnNonZeroExitCode }: {
5✔
35
    command: string
36
    input: AnyStepInput | undefined
37
    displayLogs?: boolean
38
    envVars?: { [key: string]: string }
39
    throwOnNonZeroExitCode?: boolean
40
  },
5✔
41
): Promise<RunResult> => {
42
  // If command actually contains 2 commands (using &&), throw an error. The API of this function simply doesn't support that.
43
  if (command.includes("&&")) {
×
44
    throw new Error(
×
45
      `The command "${command}" contains multiple commands (uses &&). This is not supported. Please run each command separately.`,
×
46
    )
47
  }
×
48

49
  if (displayLogs) {
×
50
    log.message(` $> ${command}`)
×
51
  } else {
×
52
    log.debug(` $> ${command}`)
13✔
53
  }
13✔
54

55
  const execCommand = command.split(" ")[0]
13✔
56
  const execArgs = shellQuote.parse(
13✔
57
    command.replace(new RegExp(`^${execCommand}\\s*`), ""),
13✔
58
  )
59
  const environmentVariablesToPassToCommand: { [key: string]: string } = envVars || {}
13✔
60

61
  // For some features to work, we need to communicate with the command. We need to send data to it and read data that it produces.
62
  // We use JSON as the data format to communicate with the command since pretty much every language has built-in support for it.
63
  // Since we are creating subprocesses to run the command, we are limited in how we can communicate with the command.
64
  // One common way would be to ask the subprocess to stdout a JSON string that we simply read, but this tool tries to promote stdout
65
  // as a way to communicate with the user, not the tool. So instead, we write the JSON to a file and pass the file path to the command.
66
  let tempFilePathToCommunicateWithCommand: string | undefined
13✔
67
  let inputDataFileContents: string | undefined
13✔
68
  if (input) {
13✔
69
    tempFilePathToCommunicateWithCommand = await Deno.makeTempFile({
13✔
70
      prefix: "new-deployment-tool-",
13✔
71
      suffix: ".json",
13✔
72
    })
13✔
73
    inputDataFileContents = JSON.stringify(input)
13✔
74
    await Deno.writeTextFile(
13✔
75
      tempFilePathToCommunicateWithCommand,
13✔
76
      inputDataFileContents,
13✔
77
    )
78

79
    environmentVariablesToPassToCommand["DATA_FILE_PATH"] = tempFilePathToCommunicateWithCommand
13✔
80
  }
13✔
81

82
  // We want to capture the stdout of the command but we also want to stream it to the console. By using streams, this allows us to
83
  // output the stdout/stderr to the console in real-time instead of waiting for the command to finish before we see the output.
84
  const process = new Deno.Command(execCommand, {
13✔
85
    args: execArgs,
13✔
86
    stdout: "piped",
13✔
87
    stderr: "piped",
13✔
88
    env: environmentVariablesToPassToCommand,
13✔
89
  })
13✔
90

91
  const child = process.spawn()
13✔
92

93
  let capturedStdout = ""
13✔
94
  let capturedStderr = ""
13✔
95

96
  child.stdout.pipeTo(
13✔
97
    new WritableStream({
13✔
98
      write(chunk) {
13✔
99
        const decodedChunk = new TextDecoder().decode(chunk)
20✔
100

101
        if (displayLogs) {
×
102
          log.message(decodedChunk)
×
103
        } else {
×
104
          log.debug(decodedChunk)
20✔
105
        }
20✔
106

107
        capturedStdout += decodedChunk.trimEnd()
20✔
108
      },
20✔
109
    }),
13✔
110
  )
111
  child.stderr.pipeTo(
13✔
112
    new WritableStream({
13✔
113
      write(chunk) {
×
114
        const decodedChunk = new TextDecoder().decode(chunk)
×
115

116
        if (displayLogs) {
×
117
          log.message(decodedChunk)
×
118
        } else {
×
119
          log.debug(decodedChunk)
×
120
        }
×
121

122
        capturedStderr += decodedChunk.trimEnd()
×
123
      },
×
124
    }),
13✔
125
  )
126

127
  const code = (await child.status).code
13✔
128
  if (capturedStdout) log.debug(capturedStdout)
13✔
129
  if (capturedStderr) log.debug(capturedStderr)
×
130

131
  let commandOutput: Record<string, unknown> | undefined = undefined
13✔
132

133
  if (tempFilePathToCommunicateWithCommand) {
13✔
134
    const outputDataFileContents = await Deno.readTextFile(
13✔
135
      tempFilePathToCommunicateWithCommand,
13✔
136
    )
137
    const commandOutputUntyped = JSON.parse(outputDataFileContents)
13✔
138
    // As long as the command wrote something to the file, we will use it.
139
    if (
13✔
140
      outputDataFileContents !== inputDataFileContents &&
13✔
141
      typeof commandOutputUntyped === "object" &&
13✔
142
      commandOutputUntyped !== null &&
13✔
143
      !Array.isArray(commandOutputUntyped)
13✔
144
    ) { // there is a chance that the command did not write to the file or they have a bug.
13✔
145
      commandOutput = commandOutputUntyped
14✔
146
    }
14✔
147
  }
13✔
148

149
  log.debug(
13✔
150
    `exit code, ${code}, command output: ${JSON.stringify(commandOutput)}`,
13✔
151
  )
152

153
  let shouldThrowError = true
13✔
154
  if (throwOnNonZeroExitCode !== undefined && throwOnNonZeroExitCode == false) {
×
155
    shouldThrowError = false
×
156
  }
×
157

158
  if (code !== 0 && shouldThrowError) {
×
159
    throw new Error(`Command: ${command}, failed with exit code: ${code}, output: ${capturedStdout}, stderr: ${capturedStderr}`)
×
160
  }
×
161

162
  return {
13✔
163
    exitCode: code,
13✔
164
    stdout: capturedStdout,
13✔
165
    output: commandOutput,
13✔
166
  }
13✔
167
}
5✔
168

169
export const exec: Exec = {
5✔
170
  run,
5✔
171
}
5✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc