mirror of https://github.com/pulumi/pulumi.git
564 lines
18 KiB
TypeScript
564 lines
18 KiB
TypeScript
// Copyright 2016-2020, Pulumi Corporation.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
import execa from "execa";
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import * as semver from "semver";
|
|
import * as tmp from "tmp";
|
|
import * as util from "util";
|
|
|
|
import { createCommandError } from "./errors";
|
|
|
|
/** @internal */
|
|
export class CommandResult {
|
|
stdout: string;
|
|
stderr: string;
|
|
code: number;
|
|
err?: Error;
|
|
constructor(stdout: string, stderr: string, code: number, err?: Error) {
|
|
this.stdout = stdout;
|
|
this.stderr = stderr;
|
|
this.code = code;
|
|
this.err = err;
|
|
}
|
|
toString(): string {
|
|
let errStr = "";
|
|
if (this.err) {
|
|
errStr = this.err.toString();
|
|
}
|
|
return `code: ${this.code}\n stdout: ${this.stdout}\n stderr: ${this.stderr}\n err?: ${errStr}\n`;
|
|
}
|
|
}
|
|
|
|
export interface PulumiOptions {
|
|
version?: semver.SemVer;
|
|
root?: string;
|
|
}
|
|
|
|
export class Pulumi {
|
|
private constructor(readonly command: string, readonly version: semver.SemVer) {}
|
|
|
|
static async get(opts?: PulumiOptions): Promise<Pulumi> {
|
|
const command = opts?.root ? path.resolve(path.join(opts.root, "bin/pulumi")) : "pulumi";
|
|
|
|
const { stdout } = await exec(command, ["version"]);
|
|
|
|
const version = semver.parse(stdout) || semver.parse("3.0.0")!;
|
|
if (opts?.version && version.compare(opts.version.toString()) < 0) {
|
|
throw Error(`${command} version ${version} does not satisfy version ${opts.version}`);
|
|
}
|
|
return new Pulumi(command, version);
|
|
}
|
|
|
|
static async install(opts?: PulumiOptions): Promise<Pulumi> {
|
|
try {
|
|
return await Pulumi.get(opts);
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
if (process.platform === "win32") {
|
|
await Pulumi.installWindows(opts);
|
|
} else {
|
|
await Pulumi.installPosix(opts);
|
|
}
|
|
|
|
return await Pulumi.get(opts);
|
|
}
|
|
|
|
private static async installWindows(opts?: PulumiOptions): Promise<void> {
|
|
const script = await writeTempFile(installWindowsScript);
|
|
|
|
try {
|
|
const command = process.env.SystemRoot
|
|
? path.join(process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
: "powershell.exe";
|
|
|
|
const args = [
|
|
"-NoProfile",
|
|
"-InputFormat",
|
|
"None",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-File",
|
|
script.path,
|
|
];
|
|
|
|
if (opts?.root) {
|
|
args.push("-InstallRoot", opts.root);
|
|
}
|
|
if (opts?.version) {
|
|
args.push("-Version", `${opts.version}`);
|
|
}
|
|
|
|
await exec(command, args);
|
|
} finally {
|
|
script.cleanup();
|
|
}
|
|
}
|
|
|
|
private static async installPosix(opts?: PulumiOptions): Promise<void> {
|
|
const script = await writeTempFile(installPosixScript);
|
|
|
|
try {
|
|
const args = [script.path, "--no-path"];
|
|
if (opts?.root) {
|
|
args.push("--install-root", opts.root);
|
|
}
|
|
if (opts?.version) {
|
|
args.push("--version", `${opts.version}`);
|
|
}
|
|
|
|
await exec("/bin/sh", args);
|
|
} finally {
|
|
script.cleanup();
|
|
}
|
|
}
|
|
|
|
public run(
|
|
args: string[],
|
|
cwd: string,
|
|
additionalEnv: { [key: string]: string },
|
|
onOutput?: (data: string) => void,
|
|
): Promise<CommandResult> {
|
|
// all commands should be run in non-interactive mode.
|
|
// this causes commands to fail rather than prompting for input (and thus hanging indefinitely)
|
|
|
|
if (!args.includes("--non-interactive")) {
|
|
args.push("--non-interactive");
|
|
}
|
|
|
|
return exec(this.command || "pulumi", args, cwd, additionalEnv, onOutput);
|
|
}
|
|
}
|
|
|
|
async function exec(
|
|
command: string,
|
|
args: string[],
|
|
cwd?: string,
|
|
additionalEnv?: { [key: string]: string },
|
|
onOutput?: (data: string) => void,
|
|
): Promise<CommandResult> {
|
|
const unknownErrCode = -2;
|
|
|
|
const env = additionalEnv ? { ...additionalEnv } : undefined;
|
|
|
|
try {
|
|
const proc = execa(command, args, { env, cwd });
|
|
|
|
if (onOutput && proc.stdout) {
|
|
proc.stdout!.on("data", (data: any) => {
|
|
if (data?.toString) {
|
|
data = data.toString();
|
|
}
|
|
onOutput(data);
|
|
});
|
|
}
|
|
|
|
const { stdout, stderr, exitCode } = await proc;
|
|
const commandResult = new CommandResult(stdout, stderr, exitCode);
|
|
if (exitCode !== 0) {
|
|
throw createCommandError(commandResult);
|
|
}
|
|
|
|
return commandResult;
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
throw createCommandError(new CommandResult("", error.message, unknownErrCode, error));
|
|
}
|
|
}
|
|
|
|
function writeTempFile(contents: string): Promise<{ path: string; cleanup: () => void }> {
|
|
return new Promise<{ path: string; cleanup: () => void }>((resolve, reject) => {
|
|
tmp.file((tmpErr, tmpPath, fd, cleanup) => {
|
|
if (tmpErr) {
|
|
reject(tmpErr);
|
|
} else {
|
|
fs.writeFile(fd, contents, (writeErr) => {
|
|
if (writeErr) {
|
|
cleanup();
|
|
reject(writeErr);
|
|
} else {
|
|
resolve({ path: tmpPath, cleanup });
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
const installWindowsScript = `param(
|
|
[string]$Version
|
|
[string]$InstallRoot=$null
|
|
[bool]$AddToPath=$false
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference="Stop"
|
|
$ProgressPreference="SilentlyContinue"
|
|
|
|
# Some versions of PowerShell do not support Tls1.2 out of the box, but pulumi.com requires it
|
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
|
|
if ($Version -eq $null -or $Version -eq "") {
|
|
# Query pulumi.com/latest-version for the most recent release. Because this approach
|
|
# is now used by third parties as well (e.g., GitHub Actions virtual environments),
|
|
# changes to this API should be made with care to avoid breaking any services that
|
|
# rely on it (and ideally be accompanied by PRs to update them accordingly). Known
|
|
# consumers of this API include:
|
|
#
|
|
# * https://github.com/actions/virtual-environments
|
|
#
|
|
$latestVersion = (Invoke-WebRequest -UseBasicParsing https://www.pulumi.com/latest-version).Content.Trim()
|
|
$Version = $latestVersion
|
|
}
|
|
|
|
$downloadUrl = "https://get.pulumi.com/releases/sdk/pulumi-v\${Version}-windows-x64.zip"
|
|
|
|
Write-Host "Downloading $downloadUrl"
|
|
|
|
# Download to a temp file, Expand-Archive requires that the extention of the file be "zip", so we do a bit of work here
|
|
# to generate the filename.
|
|
$tempZip = New-Item -Type File (Join-Path $env:TEMP ([System.IO.Path]::ChangeExtension(([System.IO.Path]::GetRandomFileName()), "zip")))
|
|
Invoke-WebRequest $downloadUrl -OutFile $tempZip
|
|
|
|
# Extract the zip we've downloaded. It contains a single root folder named "Pulumi" with a sub-directory named "bin"
|
|
$tempDir = New-Item -Type Directory (Join-Path $env:TEMP ([System.IO.Path]::GetRandomFileName()))
|
|
|
|
# PowerShell 5.0 added a nice Expand-Archive command, which we'll use when its present, otherwise we fallback to using .NET
|
|
if ($PSVersionTable.PSVersion.Major -ge 5) {
|
|
Expand-Archive $tempZip $tempDir
|
|
} else {
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
[System.IO.Compression.ZipFile]::ExtractToDirectory($tempZip, $tempDir)
|
|
}
|
|
|
|
$pulumiInstallRoot = $InstallRoot
|
|
if (-not $pulumiInstallRoot) {
|
|
# Install into %USERPROFILE%\\.pulumi\\bin by default
|
|
$pulumiInstallRoot = (Join-Path $env:UserProfile ".pulumi")
|
|
}
|
|
$binRoot = (Join-Path $pulumiInstallRoot "bin")
|
|
|
|
Write-Host "Copying Pulumi to $binRoot"
|
|
|
|
# If we have a previous install, remove files with a pulumi prefix
|
|
if (Test-Path -Path (Join-Path $binRoot "pulumi")) {
|
|
Get-ChildItem -Path $binRoot -File | Where-Object { $_.Name -like "pulumi*" } | ForEach-Object {
|
|
Remove-Item $_.FullName -Force
|
|
}
|
|
}
|
|
|
|
# Create the %USERPROFILE%\\.pulumi\\bin directory if it doesn't exist
|
|
if (-not (Test-Path -Path $binRoot -PathType Container)) {
|
|
New-Item -Path $binRoot -ItemType Directory
|
|
}
|
|
|
|
# Our tarballs used to have a top level bin folder, so support that older
|
|
# format if we detect it. Newer tarballs just have all the binaries in
|
|
# the top level Pulumi folder.
|
|
if (Test-Path (Join-Path $tempDir (Join-Path "pulumi" "bin"))) {
|
|
Get-ChildItem -Path (Join-Path $tempDir (Join-Path "pulumi" "bin")) -File | ForEach-Object {
|
|
$destinationPath = Join-Path -Path $binRoot -ChildPath $_.Name
|
|
Move-Item -Path $_.FullName -Destination $destinationPath -Force
|
|
}
|
|
} else {
|
|
Move-Item (Join-Path $tempDir (Join-Path "pulumi" "bin")) $binRoot
|
|
}
|
|
|
|
|
|
# Attempt to add ourselves to the $PATH, but if we can't, don't fail the overall script.
|
|
if ($AddToPath) {
|
|
try {
|
|
$envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", [Microsoft.Win32.RegistryKeyPermissionCheck]::ReadWriteSubTree);
|
|
$val = $envKey.GetValue("PATH", "", [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames);
|
|
if ($val -notlike "*\${binRoot};*") {
|
|
$envKey.SetValue("PATH", "$binRoot;$val", [Microsoft.Win32.RegistryValueKind]::ExpandString);
|
|
Write-Host "Added $binRoot to the \`$PATH. Changes may not be visible until after a restart."
|
|
}
|
|
$envKey.Close();
|
|
} catch {
|
|
}
|
|
|
|
if ($env:PATH -notlike "*$binRoot*") {
|
|
$env:PATH = "$binRoot;$env:PATH"
|
|
}
|
|
}
|
|
|
|
# And cleanup our temp files
|
|
Remove-Item -Force $tempZip
|
|
Remove-Item -Recurse -Force $tempDir
|
|
|
|
Write-Host "Pulumi is now installed!"
|
|
Write-Host ""
|
|
Write-Host "Ensure that $binRoot is on your \`$PATH to use it."
|
|
Write-Host ""
|
|
Write-Host "Get started with Pulumi: https://www.pulumi.com/docs/quickstart"
|
|
`;
|
|
|
|
const installPosixScript = `
|
|
#!/bin/sh
|
|
set -e
|
|
|
|
RESET="\\\\033[0m"
|
|
RED="\\\\033[31;1m"
|
|
GREEN="\\\\033[32;1m"
|
|
YELLOW="\\\\033[33;1m"
|
|
BLUE="\\\\033[34;1m"
|
|
WHITE="\\\\033[37;1m"
|
|
|
|
print_unsupported_platform()
|
|
{
|
|
>&2 say_red "error: We're sorry, but it looks like Pulumi is not supported on your platform"
|
|
>&2 say_red " We support 64-bit versions of Linux and macOS and are interested in supporting"
|
|
>&2 say_red " more platforms. Please open an issue at https://github.com/pulumi/pulumi and"
|
|
>&2 say_red " let us know what platform you're using!"
|
|
}
|
|
|
|
say_green()
|
|
{
|
|
[ -z "\${SILENT}" ] && printf "%b%s%b\\\\n" "\${GREEN}" "$1" "\${RESET}"
|
|
return 0
|
|
}
|
|
|
|
say_red()
|
|
{
|
|
printf "%b%s%b\\\\n" "\${RED}" "$1" "\${RESET}"
|
|
}
|
|
|
|
say_yellow()
|
|
{
|
|
[ -z "\${SILENT}" ] && printf "%b%s%b\\\\n" "\${YELLOW}" "$1" "\${RESET}"
|
|
return 0
|
|
}
|
|
|
|
say_blue()
|
|
{
|
|
[ -z "\${SILENT}" ] && printf "%b%s%b\\\\n" "\${BLUE}" "$1" "\${RESET}"
|
|
return 0
|
|
}
|
|
|
|
say_white()
|
|
{
|
|
[ -z "\${SILENT}" ] && printf "%b%s%b\\\\n" "\${WHITE}" "$1" "\${RESET}"
|
|
return 0
|
|
}
|
|
|
|
at_exit()
|
|
{
|
|
# shellcheck disable=SC2181
|
|
# https://github.com/koalaman/shellcheck/wiki/SC2181
|
|
# Disable because we don't actually know the command we're running
|
|
if [ "$?" -ne 0 ]; then
|
|
>&2 say_red
|
|
>&2 say_red "We're sorry, but it looks like something might have gone wrong during installation."
|
|
>&2 say_red "If you need help, please join us on https://slack.pulumi.com/"
|
|
fi
|
|
}
|
|
|
|
trap at_exit EXIT
|
|
|
|
VERSION=""
|
|
INSTALL_ROOT=""
|
|
NO_PATH=""
|
|
SILENT=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--version)
|
|
if [ "$2" != "latest" ]; then
|
|
VERSION=$2
|
|
fi
|
|
;;
|
|
--silent)
|
|
SILENT="--silent"
|
|
;;
|
|
--install-root)
|
|
INSTALL_ROOT=$2
|
|
;;
|
|
--no-path)
|
|
NO_PATH="true"
|
|
;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
if [ -z "\${VERSION}" ]; then
|
|
|
|
# Query pulumi.com/latest-version for the most recent release. Because this approach
|
|
# is now used by third parties as well (e.g., GitHub Actions virtual environments),
|
|
# changes to this API should be made with care to avoid breaking any services that
|
|
# rely on it (and ideally be accompanied by PRs to update them accordingly). Known
|
|
# consumers of this API include:
|
|
#
|
|
# * https://github.com/actions/virtual-environments
|
|
#
|
|
|
|
if ! VERSION=$(curl --retry 3 --fail --silent -L "https://www.pulumi.com/latest-version"); then
|
|
>&2 say_red "error: could not determine latest version of Pulumi, try passing --version X.Y.Z to"
|
|
>&2 say_red " install an explicit version"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
OS=""
|
|
case $(uname) in
|
|
"Linux") OS="linux";;
|
|
"Darwin") OS="darwin";;
|
|
*)
|
|
print_unsupported_platform
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
ARCH=""
|
|
case $(uname -m) in
|
|
"x86_64") ARCH="x64";;
|
|
"arm64") ARCH="arm64";;
|
|
"aarch64") ARCH="arm64";;
|
|
*)
|
|
print_unsupported_platform
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
TARBALL_URL="https://github.com/pulumi/pulumi/releases/download/v\${VERSION}/"
|
|
TARBALL_URL_FALLBACK="https://get.pulumi.com/releases/sdk/"
|
|
TARBALL_PATH=pulumi-v\${VERSION}-\${OS}-\${ARCH}.tar.gz
|
|
|
|
PULUMI_INSTALL_ROOT=\${INSTALL_ROOT}
|
|
if [ "$PULUMI_INSTALL_ROOT" = "" ]; then
|
|
# Default to ~/.pulumi
|
|
PULUMI_INSTALL_ROOT="\${HOME}/.pulumi"
|
|
fi
|
|
|
|
PULUMI_CLI="\${PULUMI_INSTALL_ROOT}/bin/pulumi"
|
|
|
|
if [ ! -e "\${PULUMI_CLI}" ]; then
|
|
say_blue "=== Installing Pulumi v\${VERSION} ==="
|
|
else
|
|
say_blue "=== Upgrading Pulumi $(\${PULUMI_CLI} version) to v\${VERSION} ==="
|
|
fi
|
|
|
|
TARBALL_DEST=$(mktemp -t pulumi.tar.gz.XXXXXXXXXX)
|
|
|
|
download_tarball() {
|
|
# Try to download from github first, then fallback to get.pulumi.com
|
|
say_white "+ Downloading \${TARBALL_URL}\${TARBALL_PATH}..."
|
|
# This should opportunistically use the GITHUB_TOKEN to avoid rate limiting
|
|
# ...I think. It's hard to test accurately. But it at least doesn't seem to hurt.
|
|
if ! curl --fail \${SILENT} -L \\
|
|
--header "Authorization: Bearer $GITHUB_TOKEN" \\
|
|
-o "\${TARBALL_DEST}" "\${TARBALL_URL}\${TARBALL_PATH}"; then
|
|
say_white "+ Error encountered, falling back to \${TARBALL_URL_FALLBACK}\${TARBALL_PATH}..."
|
|
if ! curl --retry 2 --fail \${SILENT} -L -o "\${TARBALL_DEST}" "\${TARBALL_URL_FALLBACK}\${TARBALL_PATH}"; then
|
|
return 1
|
|
fi
|
|
fi
|
|
}
|
|
|
|
if download_tarball; then
|
|
say_white "+ Extracting to \${PULUMI_INSTALL_ROOT}/bin"
|
|
|
|
# If \`~/.pulumi/bin\` exists, remove previous files with a pulumi prefix
|
|
if [ -e "\${PULUMI_INSTALL_ROOT}/bin/pulumi" ]; then
|
|
rm "\${PULUMI_INSTALL_ROOT}/bin"/pulumi*
|
|
fi
|
|
|
|
mkdir -p "\${PULUMI_INSTALL_ROOT}"
|
|
|
|
# Yarn's shell installer does a similar dance of extracting to a temp
|
|
# folder and copying to not depend on additional tar flags
|
|
EXTRACT_DIR=$(mktemp -dt pulumi.XXXXXXXXXX)
|
|
tar zxf "\${TARBALL_DEST}" -C "\${EXTRACT_DIR}"
|
|
|
|
# Our tarballs used to have a top level bin folder, so support that older
|
|
# format if we detect it. Newer tarballs just have all the binaries in
|
|
# the top level Pulumi folder.
|
|
if [ -d "\${EXTRACT_DIR}/pulumi/bin" ]; then
|
|
mv "\${EXTRACT_DIR}/pulumi/bin" "\${PULUMI_INSTALL_ROOT}/"
|
|
else
|
|
cp -r "\${EXTRACT_DIR}/pulumi/." "\${PULUMI_INSTALL_ROOT}/bin/"
|
|
fi
|
|
|
|
rm -f "\${TARBALL_DEST}"
|
|
rm -rf "\${EXTRACT_DIR}"
|
|
else
|
|
>&2 say_red "error: failed to download \${TARBALL_URL}"
|
|
>&2 say_red " check your internet and try again; if the problem persists, file an"
|
|
>&2 say_red " issue at https://github.com/pulumi/pulumi/issues/new/choose"
|
|
exit 1
|
|
fi
|
|
|
|
# Now that we have installed Pulumi, if it is not already on the path, let's add a line to the
|
|
# user's profile to add the folder to the PATH for future sessions.
|
|
if [ "\${NO_PATH}" != "true" ]; then
|
|
if ! command -v pulumi >/dev/null; then
|
|
# If we can, we'll add a line to the user's .profile adding \${PULUMI_INSTALL_ROOT}/bin to the PATH
|
|
SHELL_NAME=$(basename "\${SHELL}")
|
|
PROFILE_FILE=""
|
|
|
|
case "\${SHELL_NAME}" in
|
|
"bash")
|
|
# Terminal.app on macOS prefers .bash_profile to .bashrc, so we prefer that
|
|
# file when trying to put our export into a profile. On *NIX, .bashrc is
|
|
# preferred as it is sourced for new interactive shells.
|
|
if [ "$(uname)" != "Darwin" ]; then
|
|
if [ -e "\${HOME}/.bashrc" ]; then
|
|
PROFILE_FILE="\${HOME}/.bashrc"
|
|
elif [ -e "\${HOME}/.bash_profile" ]; then
|
|
PROFILE_FILE="\${HOME}/.bash_profile"
|
|
fi
|
|
else
|
|
if [ -e "\${HOME}/.bash_profile" ]; then
|
|
PROFILE_FILE="\${HOME}/.bash_profile"
|
|
elif [ -e "\${HOME}/.bashrc" ]; then
|
|
PROFILE_FILE="\${HOME}/.bashrc"
|
|
fi
|
|
fi
|
|
;;
|
|
"zsh")
|
|
if [ -e "\${ZDOTDIR:-$HOME}/.zshrc" ]; then
|
|
PROFILE_FILE="\${ZDOTDIR:-$HOME}/.zshrc"
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
if [ -n "\${PROFILE_FILE}" ]; then
|
|
LINE_TO_ADD="export PATH=\\$PATH:\${PULUMI_INSTALL_ROOT}/bin"
|
|
if ! grep -q "# add Pulumi to the PATH" "\${PROFILE_FILE}"; then
|
|
say_white "+ Adding \${PULUMI_INSTALL_ROOT}/bin to \\$PATH in \${PROFILE_FILE}"
|
|
printf "\\\\n# add Pulumi to the PATH\\\\n%s\\\\n" "\${LINE_TO_ADD}" >> "\${PROFILE_FILE}"
|
|
fi
|
|
|
|
EXTRA_INSTALL_STEP="+ Please restart your shell or add \${PULUMI_INSTALL_ROOT}/bin to your \\$PATH"
|
|
else
|
|
EXTRA_INSTALL_STEP="+ Please add \${PULUMI_INSTALL_ROOT}/bin to your \\$PATH"
|
|
fi
|
|
elif [ "$(command -v pulumi)" != "\${PULUMI_INSTALL_ROOT}/bin/pulumi" ]; then
|
|
say_yellow
|
|
say_yellow "warning: Pulumi has been installed to \${PULUMI_INSTALL_ROOT}/bin, but it looks like there's a different copy"
|
|
say_yellow " on your \\$PATH at $(dirname "$(command -v pulumi)"). You'll need to explicitly invoke the"
|
|
say_yellow " version you just installed or modify your \\$PATH to prefer this location."
|
|
fi
|
|
fi
|
|
|
|
say_blue
|
|
say_blue "=== Pulumi is now installed! 🍹 ==="
|
|
if [ "$EXTRA_INSTALL_STEP" != "" ]; then
|
|
say_white "\${EXTRA_INSTALL_STEP}"
|
|
fi
|
|
say_green "+ Get started with Pulumi: https://www.pulumi.com/docs/quickstart"
|
|
`;
|