Skip to content

Commit 86c08cf

Browse files
fix: remove cargo-dist, use native fetch for npm installer (#646)
* chore: remove cargo-dist, use native fetch for npm installer * fix: harden npm scripts — spawnSync, signal handling, binary-exists check * fix: address PR review comments - Add null body guard for fetch response - Fix PowerShell Expand-Archive path quoting vulnerability - Sanitize error output to prevent ANSI escape injection - Add proxy support limitation note - Fix upgrade bug: use .version marker so npm update downloads new binary - Downgrade changeset from minor to patch (chore, not feature) - Update AGENTS.md: remove stale cargo-dist reference from labels * fix: use flat archives for consistent tar/zip extraction Both tar.gz and zip archives now contain files at root (no nested directory). Removes --strip-components 1 from install.js since it is no longer needed. This makes extraction consistent across platforms. --------- Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
1 parent c7c6646 commit 86c08cf

10 files changed

Lines changed: 499 additions & 342 deletions

File tree

.changeset/remove-cargo-dist.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
Remove cargo-dist; use native Node.js fetch for npm binary installer
6+
7+
Replaces the cargo-dist generated release pipeline and npm package with:
8+
- A custom GitHub Actions release workflow with matrix cross-compilation
9+
- A zero-dependency npm installer using native `fetch()` (Node 18+)
10+
- Removes axios, rimraf, detect-libc, console.table, and axios-proxy-builder dependencies from the published npm package

.github/workflows/release.yml

Lines changed: 131 additions & 296 deletions
Large diffs are not rendered by default.

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ Use these labels to categorize pull requests and issues:
178178
- `area: http` — Request execution, URL building, response handling
179179
- `area: docs` — README, contributing guides, documentation
180180
- `area: tui` — Setup wizard, picker, input fields
181-
- `area: distribution` — Nix flake, cargo-dist, npm packaging, install methods
181+
- `area: distribution` — Nix flake, npm packaging, GitHub Actions release workflow, install methods
182182
- `area: auth` — OAuth, credentials, multi-account, ADC
183183
- `area: skills` — AI skill generation and management
184184

dist-workspace.toml

Lines changed: 0 additions & 44 deletions
This file was deleted.

npm/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Downloaded binary (created during npm postinstall)
2+
bin/

npm/install.js

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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+
});

npm/package.json

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
{
2+
"name": "@googleworkspace/cli",
3+
"description": "Google Workspace CLI — dynamic command surface from Discovery Service",
4+
"version": "0.22.3",
5+
"license": "Apache-2.0",
6+
"author": "Justin Poehnelt",
7+
"repository": {
8+
"type": "git",
9+
"url": "https://github.com/googleworkspace/cli.git"
10+
},
11+
"homepage": "https://github.com/googleworkspace/cli",
12+
"bugs": {
13+
"url": "https://github.com/googleworkspace/cli/issues"
14+
},
15+
"bin": {
16+
"gws": "run.js"
17+
},
18+
"scripts": {
19+
"postinstall": "node install.js"
20+
},
21+
"engines": {
22+
"node": ">=18"
23+
},
24+
"preferUnplugged": true,
25+
"keywords": [
26+
"cli",
27+
"google-workspace",
28+
"google",
29+
"google-api",
30+
"google-drive",
31+
"google-gmail",
32+
"google-sheets",
33+
"google-calendar",
34+
"google-docs",
35+
"google-chat",
36+
"google-admin",
37+
"gsuite",
38+
"discovery-api",
39+
"ai-agent",
40+
"agent-skills",
41+
"automation",
42+
"oauth2",
43+
"rust"
44+
],
45+
"publishConfig": {
46+
"provenance": true,
47+
"registry": "https://wombat-dressing-room.appspot.com"
48+
},
49+
"supportedPlatforms": {
50+
"aarch64-apple-darwin": {
51+
"artifact": "google-workspace-cli-aarch64-apple-darwin.tar.gz",
52+
"binary": "gws"
53+
},
54+
"x86_64-apple-darwin": {
55+
"artifact": "google-workspace-cli-x86_64-apple-darwin.tar.gz",
56+
"binary": "gws"
57+
},
58+
"aarch64-unknown-linux-gnu": {
59+
"artifact": "google-workspace-cli-aarch64-unknown-linux-gnu.tar.gz",
60+
"binary": "gws"
61+
},
62+
"aarch64-unknown-linux-musl": {
63+
"artifact": "google-workspace-cli-aarch64-unknown-linux-musl.tar.gz",
64+
"binary": "gws"
65+
},
66+
"x86_64-unknown-linux-gnu": {
67+
"artifact": "google-workspace-cli-x86_64-unknown-linux-gnu.tar.gz",
68+
"binary": "gws"
69+
},
70+
"x86_64-unknown-linux-musl": {
71+
"artifact": "google-workspace-cli-x86_64-unknown-linux-musl.tar.gz",
72+
"binary": "gws"
73+
},
74+
"x86_64-pc-windows-msvc": {
75+
"artifact": "google-workspace-cli-x86_64-pc-windows-msvc.zip",
76+
"binary": "gws.exe"
77+
}
78+
}
79+
}

npm/platform.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env node
2+
3+
"use strict";
4+
5+
const os = require("os");
6+
const path = require("path");
7+
const fs = require("fs");
8+
const { spawnSync } = require("child_process");
9+
10+
const { supportedPlatforms } = require("./package.json");
11+
12+
/**
13+
* Map Node.js os.type() and os.arch() to Rust-style target triples.
14+
*/
15+
function getPlatformKey() {
16+
const rawOs = os.type();
17+
const rawArch = os.arch();
18+
19+
let osType;
20+
switch (rawOs) {
21+
case "Windows_NT":
22+
osType = "pc-windows-msvc";
23+
break;
24+
case "Darwin":
25+
osType = "apple-darwin";
26+
break;
27+
case "Linux":
28+
osType = "unknown-linux-gnu";
29+
break;
30+
default:
31+
throw new Error(`Unsupported operating system: ${rawOs}`);
32+
}
33+
34+
let arch;
35+
switch (rawArch) {
36+
case "x64":
37+
arch = "x86_64";
38+
break;
39+
case "arm64":
40+
arch = "aarch64";
41+
break;
42+
default:
43+
throw new Error(`Unsupported architecture: ${rawArch}`);
44+
}
45+
46+
// On Linux, try to detect musl libc
47+
if (rawOs === "Linux") {
48+
try {
49+
const result = spawnSync("ldd", ["--version"], {
50+
encoding: "utf8",
51+
stdio: ["pipe", "pipe", "pipe"],
52+
});
53+
// musl ldd prints version info to stderr
54+
const output = (result.stdout || "") + (result.stderr || "");
55+
if (output.toLowerCase().includes("musl")) {
56+
osType = "unknown-linux-musl";
57+
}
58+
} catch {
59+
// If ldd fails, assume glibc
60+
}
61+
}
62+
63+
const key = `${arch}-${osType}`;
64+
65+
if (!supportedPlatforms[key]) {
66+
// Try musl fallback on Linux if glibc binary is not available
67+
if (rawOs === "Linux") {
68+
const muslKey = `${arch}-unknown-linux-musl`;
69+
if (supportedPlatforms[muslKey]) {
70+
return muslKey;
71+
}
72+
}
73+
throw new Error(
74+
`Unsupported platform: ${key}\nSupported platforms: ${Object.keys(supportedPlatforms).join(", ")}`,
75+
);
76+
}
77+
78+
return key;
79+
}
80+
81+
function getPlatform() {
82+
const key = getPlatformKey();
83+
return supportedPlatforms[key];
84+
}
85+
86+
module.exports = { getPlatform, getPlatformKey };

0 commit comments

Comments
 (0)