Skip to content

Commit 57de0f5

Browse files
committed
Split download.ts into three files
Install code, download code, and exec code.
1 parent dc6b4d3 commit 57de0f5

11 files changed

+307
-309
lines changed

src/download.test.ts

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,10 @@
1-
import * as assert from "assert"
1+
// import * as assert from "assert"
22
import * as vscode from "vscode"
3-
import * as download from "./download"
3+
// import * as download from "./download"
44

55
suite("Download", () => {
66
vscode.window.showInformationMessage("Start download tests.")
77

8-
teardown(() => {
9-
delete process.env.CODER_MOCK_STATE
10-
})
11-
12-
test("binaryExists", async () => {
13-
assert.strictEqual(await download.binaryExists("sh"), true)
14-
assert.strictEqual(await download.binaryExists("surely-no-binary-named-like-this-exists"), false)
15-
})
16-
17-
test("execCoder", async () => {
18-
assert.strictEqual(await download.execCoder("--help"), "help\n")
19-
20-
// This will attempt to authenticate first, which will fail.
21-
process.env.CODER_MOCK_STATE = "fail"
22-
await assert.rejects(download.execCoder("--help"), {
23-
name: "Error",
24-
message: /Command failed: .+ --help\nstderr message from fail state\n/,
25-
})
26-
})
27-
28-
test("install", async () => {
29-
await assert.rejects(download.install("false", []), {
30-
name: "Error",
31-
message: `Command "false" failed with code 1`,
32-
})
33-
// TODO: Test successful download.
34-
})
35-
368
// TODO: Implement.
37-
test("maybeInstall")
389
test("download")
39-
test("maybeDownload")
4010
})

src/download.ts

Lines changed: 24 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,17 @@
1-
import * as cp from "child_process"
21
import { promises as fs } from "fs"
32
import * as path from "path"
4-
import { promisify } from "util"
53
import * as vscode from "vscode"
6-
import * as nodeWhich from "which"
74
import { requestResponse } from "./request"
8-
import { context, debug, extractTar, extractZip, getAssetUrl, onLine, outputChannel, wrapExit } from "./utils"
5+
import { debug, extractTar, extractZip, getAssetUrl } from "./utils"
96

107
/**
11-
* Options for installing and authenticating the Coder CLI.
8+
* Inner function for `download` so it can wrap with a singleton promise.
129
*/
13-
export interface CoderOptions {
14-
accessUri?: string
15-
token?: string
16-
version?: string
17-
}
18-
19-
/**
20-
* Return "true" if the binary is found in $PATH.
21-
*/
22-
export const binaryExists = async (bin: string): Promise<boolean> => {
23-
return new Promise((res) => {
24-
nodeWhich(bin, (err) => res(!err))
25-
})
26-
}
27-
28-
/**
29-
* Run a command with the Coder CLI after making sure it is installed and
30-
* authenticated. On success stdout is returned. On failure the error will
31-
* include stderr in the message.
32-
*/
33-
export const execCoder = async (command: string, opts?: CoderOptions): Promise<string> => {
34-
debug(`Run command: ${command}`)
35-
const coderBinary = await preflight(opts?.version)
36-
const output = await promisify(cp.exec)(coderBinary + " " + command)
37-
return output.stdout
38-
}
39-
40-
/**
41-
* How to invoke the Coder CLI.
42-
*
43-
* The CODER_BINARY environment variable is meant for tests.
44-
*/
45-
const coderInvocation = (): { cmd: string; args: string[] } => {
46-
if (process.env.CODER_BINARY) {
47-
return JSON.parse(process.env.CODER_BINARY)
48-
}
49-
return { cmd: process.platform === "win32" ? "coder.exe" : "coder", args: [] }
50-
}
51-
52-
/**
53-
* Download the Coder CLI to the provided location and return that location.
54-
*/
55-
export const download = async (version: string, downloadPath: string): Promise<string> => {
56-
const assetUrl = getAssetUrl(version)
57-
const response = await requestResponse(assetUrl)
58-
59-
await (assetUrl.endsWith(".tar.gz")
60-
? extractTar(response, path.dirname(downloadPath))
61-
: extractZip(response, path.dirname(downloadPath)))
62-
63-
return downloadPath
64-
}
65-
66-
/**
67-
* Download the Coder CLI if necessary to a temporary location and return that
68-
* location. If it has already been downloaded it will be reused without regard
69-
* to its version (it can be updated to match later).
70-
*/
71-
export const maybeDownload = async (version = "latest"): Promise<string> => {
72-
const invocation = coderInvocation()
73-
if (await binaryExists(invocation.cmd)) {
74-
debug(` - Found "${invocation.cmd}" on PATH`)
75-
return [invocation.cmd, ...invocation.args].join(" ")
76-
}
77-
10+
const doDownload = async (version: string, downloadPath: string): Promise<string> => {
7811
// See if we already downloaded it.
79-
const downloadPath = path.join(await context().globalStoragePath, invocation.cmd)
8012
try {
8113
await fs.access(downloadPath)
82-
debug(` - Using previously downloaded "${invocation.cmd}"`)
14+
debug(` - Using previously downloaded: ${downloadPath}`)
8315
return downloadPath
8416
// eslint-disable-next-line @typescript-eslint/no-explicit-any
8517
} catch (error: any) {
@@ -88,125 +20,44 @@ export const maybeDownload = async (version = "latest"): Promise<string> => {
8820
}
8921
}
9022

91-
debug(` - Downloading "${invocation.cmd}" ${version}`)
23+
debug(` - Downloading ${version} to ${downloadPath}`)
9224
return vscode.window.withProgress(
9325
{
9426
location: vscode.ProgressLocation.Notification,
9527
title: `Installing Coder CLI ${version}`,
9628
},
97-
() => download(version, downloadPath),
98-
)
99-
}
100-
101-
/**
102-
* Download then copy the Coder CLI to the specified location.
103-
*/
104-
export const downloadAndInstall = async (version: string, destination: string): Promise<void> => {
105-
const source = await maybeDownload(version)
106-
await fs.mkdir(destination, { recursive: true })
107-
await fs.rename(source, path.join(destination, "coder"))
108-
}
109-
110-
/**
111-
* Install the Coder CLI using the provided command.
112-
*/
113-
export const install = async (cmd: string, args: string[]): Promise<void> => {
114-
outputChannel.show()
115-
outputChannel.appendLine(cmd + " " + args.join(" "))
116-
117-
const proc = cp.spawn(cmd, args)
118-
onLine(proc.stdout, outputChannel.appendLine.bind(outputChannel))
119-
onLine(proc.stderr, outputChannel.appendLine.bind(outputChannel))
120-
121-
try {
122-
await wrapExit(proc)
123-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
124-
} catch (error: any) {
125-
outputChannel.appendLine(error.message)
126-
throw error
127-
}
128-
}
129-
130-
/**
131-
* Ask the user whether to install the Coder CLI if not already installed.
132-
*
133-
* Return the invocation for the binary for use with `cp.exec()`.
134-
*
135-
* @TODO Currently unused. Should call after connecting to the workspace
136-
* although it might be fine to just keep using the downloaded version?
137-
*/
138-
export const maybeInstall = async (version: string): Promise<string> => {
139-
const invocation = coderInvocation()
140-
if (await binaryExists(invocation.cmd)) {
141-
return [invocation.cmd, ...invocation.args].join(" ")
142-
}
143-
144-
const actions: string[] = []
145-
146-
// TODO: This will require sudo or we will need to install to a writable
147-
// location and ask the user to add it to their PATH if they have not already.
148-
const destination = "/usr/local/bin"
149-
// actions.push(`Install to ${destination}`)
150-
151-
if (await binaryExists("brew")) {
152-
actions.push("Install with `brew`")
153-
}
154-
155-
if (actions.length === 0) {
156-
throw new Error(`"${invocation.cmd}" not found in $PATH.`)
157-
}
29+
async () => {
30+
const assetUrl = getAssetUrl(version)
31+
const response = await requestResponse(assetUrl)
15832

159-
const action = await vscode.window.showInformationMessage(`"${invocation.cmd}" was not found in $PATH.`, ...actions)
160-
if (!action) {
161-
throw new Error(`"${invocation.cmd}" not found in $PATH.`)
162-
}
33+
await (assetUrl.endsWith(".tar.gz")
34+
? extractTar(response, path.dirname(downloadPath))
35+
: extractZip(response, path.dirname(downloadPath)))
16336

164-
await vscode.window.withProgress(
165-
{
166-
location: vscode.ProgressLocation.Notification,
167-
title: `Installing Coder CLI`,
168-
},
169-
async () => {
170-
switch (action) {
171-
case `Install to ${destination}`:
172-
return downloadAndInstall(version, destination)
173-
case "Install with `brew`":
174-
return install("brew", ["install", "cdr/coder/coder-cli@${version}"])
175-
}
37+
return downloadPath
17638
},
17739
)
178-
179-
// See if we can now find it via the path.
180-
if (await binaryExists(invocation.cmd)) {
181-
return [invocation.cmd, ...invocation.args].join(" ")
182-
} else {
183-
throw new Error(`"${invocation.cmd}" still not found in $PATH.`)
184-
}
18540
}
18641

187-
/** Only one preflight request at a time. */
188-
let _preflight: Promise<string> | undefined
42+
/** Only one request at a time. */
43+
let promise: Promise<string> | undefined
18944

19045
/**
191-
* Check that Coder is installed. If not try installing.
192-
*
193-
* Return the appropriate invocation for the binary.
46+
* Download the Coder CLI if necessary to the provided location while showing a
47+
* progress bar then return that location. If it has already been downloaded it
48+
* will be reused without regard to its version (it can be updated to match
49+
* later). This function is safe to call multiple times concurrently.
19450
*/
195-
export const preflight = async (version = "latest"): Promise<string> => {
196-
if (!_preflight) {
197-
_preflight = (async (): Promise<string> => {
51+
export const download = async (version: string, downloadPath: string): Promise<string> => {
52+
if (!promise) {
53+
promise = (async (): Promise<string> => {
19854
try {
199-
return await maybeDownload(version)
200-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
201-
} catch (error: any) {
202-
throw new Error(`${error.message}. Please [install manually](https://coder.com/docs/cli/installation).`)
55+
return await doDownload(version, downloadPath)
20356
} finally {
204-
// Clear after completion so we can try again in the case of errors, if
205-
// the binary is removed, etc.
206-
_preflight = undefined
57+
promise = undefined
20758
}
20859
})()
20960
}
21061

211-
return _preflight
62+
return promise
21263
}

src/exec.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import * as assert from "assert"
2+
import * as cp from "child_process"
3+
import * as vscode from "vscode"
4+
import * as exec from "./exec"
5+
6+
suite("Exec", () => {
7+
vscode.window.showInformationMessage("Start exec tests.")
8+
9+
teardown(() => {
10+
delete process.env.CODER_MOCK_STATE
11+
})
12+
13+
test("execCoder", async () => {
14+
assert.strictEqual(await exec.execCoder("--help"), "help\n")
15+
16+
// This will attempt to authenticate first, which will fail.
17+
process.env.CODER_MOCK_STATE = "fail"
18+
await assert.rejects(exec.execCoder("--help"), {
19+
name: "Error",
20+
message: /Command failed: .+ --help\nstderr message from fail state\n/,
21+
})
22+
})
23+
24+
test("onLine", async () => {
25+
// Try from zero to multiple lines.
26+
const lines = ["a", "b", "d", "e"]
27+
for (let i = 0; i < lines.length; ++i) {
28+
// Try both ending and not ending with a newline.
29+
for (const ending of ["\n", ""]) {
30+
const expected = lines.slice(0, i)
31+
if (ending === "\n" || i === 0) {
32+
expected.push("")
33+
}
34+
35+
// Windows requires wrapping single quotes or the `\n` becomes just `n`.
36+
const arg = expected.join("\n")
37+
const proc = cp.spawn("printf", [process.platform === "win32" ? `'${arg}'` : arg])
38+
39+
await new Promise<void>((resolve) => {
40+
exec.onLine(proc.stdout, (d) => {
41+
assert.strictEqual(d, expected.shift())
42+
if (expected.length === 0) {
43+
resolve()
44+
}
45+
})
46+
})
47+
}
48+
}
49+
})
50+
51+
test("wrapExit", async () => {
52+
assert.deepStrictEqual(await exec.wrapExit(cp.spawn("printf", ["stdout"])), undefined)
53+
assert.deepStrictEqual(await exec.wrapExit(cp.spawn("bash", ["-c", ">&2 printf stderr"])), undefined)
54+
await assert.rejects(exec.wrapExit(cp.spawn("false")), {
55+
name: "Error",
56+
message: `Command "false" failed with code 1`,
57+
})
58+
await assert.rejects(exec.wrapExit(cp.spawn("bash", ["-c", ">&2 printf stderr && exit 42"])), {
59+
name: "Error",
60+
message: `Command "bash" failed with code 42: stderr`,
61+
})
62+
await assert.rejects(exec.wrapExit(cp.spawn("surely-no-executable-named-like-this-exists")), {
63+
name: "Error",
64+
message: `spawn surely-no-executable-named-like-this-exists ENOENT`,
65+
})
66+
})
67+
68+
test("binaryExists", async () => {
69+
assert.strictEqual(await exec.binaryExists("sh"), true)
70+
assert.strictEqual(await exec.binaryExists("surely-no-binary-named-like-this-exists"), false)
71+
})
72+
})

0 commit comments

Comments
 (0)