|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +"use strict"; |
| 4 | + |
| 5 | +const fs = require("fs"); |
| 6 | +const path = require("path"); |
| 7 | +const os = require("os"); |
| 8 | +const { pipeline } = require("stream/promises"); |
| 9 | +const { createWriteStream, mkdirSync, rmSync } = require("fs"); |
| 10 | +const { spawnSync } = require("child_process"); |
| 11 | +const { getPlatform } = require("./platform"); |
| 12 | + |
| 13 | +const INSTALL_DIR = path.join(__dirname, "bin"); |
| 14 | + |
| 15 | +/** |
| 16 | + * Get the GitHub release download URL base for the current package version. |
| 17 | + */ |
| 18 | +function getDownloadUrl(artifactName) { |
| 19 | + const { version } = require("./package.json"); |
| 20 | + return `https://github.com/googleworkspace/cli/releases/download/v${version}/${artifactName}`; |
| 21 | +} |
| 22 | + |
| 23 | +/** |
| 24 | + * Strip ANSI escape sequences from a string. |
| 25 | + */ |
| 26 | +function sanitize(str) { |
| 27 | + // eslint-disable-next-line no-control-regex |
| 28 | + return String(str).replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); |
| 29 | +} |
| 30 | + |
| 31 | +/** |
| 32 | + * Download a file using native fetch (Node 18+). |
| 33 | + * |
| 34 | + * NOTE: Native fetch does not respect HTTP_PROXY / HTTPS_PROXY environment |
| 35 | + * variables. If proxy support is needed, consider using the `undici` ProxyAgent |
| 36 | + * or a Node.js build with proxy support. |
| 37 | + */ |
| 38 | +async function download(url, dest) { |
| 39 | + const res = await fetch(url, { redirect: "follow" }); |
| 40 | + |
| 41 | + if (!res.ok) { |
| 42 | + throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`); |
| 43 | + } |
| 44 | + |
| 45 | + if (!res.body) { |
| 46 | + throw new Error(`Failed to download ${url}: Response body is empty`); |
| 47 | + } |
| 48 | + |
| 49 | + const fileStream = createWriteStream(dest); |
| 50 | + // Convert web ReadableStream to Node stream and pipe |
| 51 | + const { Readable } = require("stream"); |
| 52 | + const nodeStream = Readable.fromWeb(res.body); |
| 53 | + await pipeline(nodeStream, fileStream); |
| 54 | +} |
| 55 | + |
| 56 | +/** |
| 57 | + * Run a command and throw on failure. |
| 58 | + */ |
| 59 | +function run(cmd, args) { |
| 60 | + const result = spawnSync(cmd, args, { stdio: "pipe" }); |
| 61 | + if (result.error) { |
| 62 | + throw new Error(`Failed to run ${cmd}: ${result.error.message}`); |
| 63 | + } |
| 64 | + if ((result.status ?? 1) !== 0) { |
| 65 | + const stderr = result.stderr ? result.stderr.toString() : ""; |
| 66 | + throw new Error( |
| 67 | + `Command failed: ${cmd} ${args.join(" ")}\n${stderr}`, |
| 68 | + ); |
| 69 | + } |
| 70 | +} |
| 71 | + |
| 72 | +/** |
| 73 | + * Extract the archive to the install directory. |
| 74 | + */ |
| 75 | +function extract(archivePath, destDir) { |
| 76 | + const isZip = archivePath.endsWith(".zip"); |
| 77 | + const isTar = archivePath.includes(".tar."); |
| 78 | + |
| 79 | + if (isTar) { |
| 80 | + run("tar", ["xf", archivePath, "-C", destDir]); |
| 81 | + } else if (isZip) { |
| 82 | + if (process.platform === "win32") { |
| 83 | + // Use single-quoted PowerShell strings with doubled single-quote escaping |
| 84 | + // to safely handle paths containing spaces and special characters. |
| 85 | + const psArchive = archivePath.replace(/'/g, "''"); |
| 86 | + const psDest = destDir.replace(/'/g, "''"); |
| 87 | + run("powershell.exe", [ |
| 88 | + "-NoProfile", |
| 89 | + "-NonInteractive", |
| 90 | + "-Command", |
| 91 | + `Expand-Archive -LiteralPath '${psArchive}' -DestinationPath '${psDest}' -Force`, |
| 92 | + ]); |
| 93 | + } else { |
| 94 | + run("unzip", ["-q", "-o", archivePath, "-d", destDir]); |
| 95 | + } |
| 96 | + } else { |
| 97 | + throw new Error(`Unsupported archive format: ${archivePath}`); |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +async function install() { |
| 102 | + const platform = getPlatform(); |
| 103 | + const { version } = require("./package.json"); |
| 104 | + const url = getDownloadUrl(platform.artifact); |
| 105 | + |
| 106 | + // Check if the correct version is already installed |
| 107 | + const binPath = path.join(INSTALL_DIR, platform.binary); |
| 108 | + const versionFile = path.join(INSTALL_DIR, ".version"); |
| 109 | + if (fs.existsSync(binPath) && fs.existsSync(versionFile)) { |
| 110 | + const installed = fs.readFileSync(versionFile, "utf8").trim(); |
| 111 | + if (installed === version) { |
| 112 | + console.error(`gws v${version} is already installed, skipping.`); |
| 113 | + return; |
| 114 | + } |
| 115 | + console.error(`Upgrading gws from v${installed} to v${version}`); |
| 116 | + } |
| 117 | + |
| 118 | + // Clean and create install directory |
| 119 | + if (fs.existsSync(INSTALL_DIR)) { |
| 120 | + rmSync(INSTALL_DIR, { recursive: true, force: true }); |
| 121 | + } |
| 122 | + mkdirSync(INSTALL_DIR, { recursive: true }); |
| 123 | + |
| 124 | + // Download to a temp file |
| 125 | + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gws-")); |
| 126 | + const archiveName = path.basename(platform.artifact); |
| 127 | + const tmpFile = path.join(tmpDir, archiveName); |
| 128 | + |
| 129 | + try { |
| 130 | + console.error(`Downloading gws from ${url}`); |
| 131 | + await download(url, tmpFile); |
| 132 | + |
| 133 | + console.error(`Extracting to ${INSTALL_DIR}`); |
| 134 | + extract(tmpFile, INSTALL_DIR); |
| 135 | + |
| 136 | + // Make binary executable on Unix |
| 137 | + if (process.platform !== "win32") { |
| 138 | + fs.chmodSync(binPath, 0o755); |
| 139 | + } |
| 140 | + |
| 141 | + console.error(`gws v${version} has been installed!`); |
| 142 | + fs.writeFileSync(versionFile, version); |
| 143 | + } finally { |
| 144 | + // Clean up temp files |
| 145 | + rmSync(tmpDir, { recursive: true, force: true }); |
| 146 | + } |
| 147 | +} |
| 148 | + |
| 149 | +install().catch((err) => { |
| 150 | + console.error(`Error installing gws: ${sanitize(err.message)}`); |
| 151 | + process.exit(1); |
| 152 | +}); |
0 commit comments