Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/remove-cargo-dist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@googleworkspace/cli": patch
---

Remove cargo-dist; use native Node.js fetch for npm binary installer

Replaces the cargo-dist generated release pipeline and npm package with:
- A custom GitHub Actions release workflow with matrix cross-compilation
- A zero-dependency npm installer using native `fetch()` (Node 18+)
- Removes axios, rimraf, detect-libc, console.table, and axios-proxy-builder dependencies from the published npm package
427 changes: 131 additions & 296 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Use these labels to categorize pull requests and issues:
- `area: http` — Request execution, URL building, response handling
- `area: docs` — README, contributing guides, documentation
- `area: tui` — Setup wizard, picker, input fields
- `area: distribution` — Nix flake, cargo-dist, npm packaging, install methods
- `area: distribution` — Nix flake, npm packaging, GitHub Actions release workflow, install methods
- `area: auth` — OAuth, credentials, multi-account, ADC
- `area: skills` — AI skill generation and management

Expand Down
44 changes: 0 additions & 44 deletions dist-workspace.toml

This file was deleted.

2 changes: 2 additions & 0 deletions npm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Downloaded binary (created during npm postinstall)
bin/
152 changes: 152 additions & 0 deletions npm/install.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env node

"use strict";

const fs = require("fs");
const path = require("path");
const os = require("os");
const { pipeline } = require("stream/promises");
const { createWriteStream, mkdirSync, rmSync } = require("fs");
const { spawnSync } = require("child_process");
const { getPlatform } = require("./platform");

const INSTALL_DIR = path.join(__dirname, "bin");

/**
* Get the GitHub release download URL base for the current package version.
*/
function getDownloadUrl(artifactName) {
const { version } = require("./package.json");
return `https://github.com/googleworkspace/cli/releases/download/v${version}/${artifactName}`;
}

/**
* Strip ANSI escape sequences from a string.
*/
function sanitize(str) {
// eslint-disable-next-line no-control-regex
return String(str).replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
}
Comment thread
jpoehnelt marked this conversation as resolved.

/**
* Download a file using native fetch (Node 18+).
*
* NOTE: Native fetch does not respect HTTP_PROXY / HTTPS_PROXY environment
* variables. If proxy support is needed, consider using the `undici` ProxyAgent
* or a Node.js build with proxy support.
Comment thread
jpoehnelt marked this conversation as resolved.
*/
async function download(url, dest) {
const res = await fetch(url, { redirect: "follow" });
Comment thread
jpoehnelt marked this conversation as resolved.
Comment thread
jpoehnelt marked this conversation as resolved.

if (!res.ok) {
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
}

if (!res.body) {
throw new Error(`Failed to download ${url}: Response body is empty`);
}

const fileStream = createWriteStream(dest);
// Convert web ReadableStream to Node stream and pipe
const { Readable } = require("stream");
const nodeStream = Readable.fromWeb(res.body);
await pipeline(nodeStream, fileStream);
Comment thread
jpoehnelt marked this conversation as resolved.
}

/**
* Run a command and throw on failure.
*/
function run(cmd, args) {
const result = spawnSync(cmd, args, { stdio: "pipe" });
if (result.error) {
throw new Error(`Failed to run ${cmd}: ${result.error.message}`);
}
if ((result.status ?? 1) !== 0) {
const stderr = result.stderr ? result.stderr.toString() : "";
throw new Error(
`Command failed: ${cmd} ${args.join(" ")}\n${stderr}`,
);
}
}

/**
* Extract the archive to the install directory.
*/
function extract(archivePath, destDir) {
const isZip = archivePath.endsWith(".zip");
const isTar = archivePath.includes(".tar.");

if (isTar) {
run("tar", ["xf", archivePath, "-C", destDir]);
} else if (isZip) {
if (process.platform === "win32") {
// Use single-quoted PowerShell strings with doubled single-quote escaping
// to safely handle paths containing spaces and special characters.
const psArchive = archivePath.replace(/'/g, "''");
const psDest = destDir.replace(/'/g, "''");
run("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
`Expand-Archive -LiteralPath '${psArchive}' -DestinationPath '${psDest}' -Force`,
]);
Comment thread
jpoehnelt marked this conversation as resolved.
} else {
run("unzip", ["-q", "-o", archivePath, "-d", destDir]);
}
Comment thread
jpoehnelt marked this conversation as resolved.
} else {
throw new Error(`Unsupported archive format: ${archivePath}`);
}
}

async function install() {
const platform = getPlatform();
const { version } = require("./package.json");
const url = getDownloadUrl(platform.artifact);

// Check if the correct version is already installed
const binPath = path.join(INSTALL_DIR, platform.binary);
const versionFile = path.join(INSTALL_DIR, ".version");
if (fs.existsSync(binPath) && fs.existsSync(versionFile)) {
const installed = fs.readFileSync(versionFile, "utf8").trim();
if (installed === version) {
console.error(`gws v${version} is already installed, skipping.`);
return;
}
console.error(`Upgrading gws from v${installed} to v${version}`);
}

// Clean and create install directory
if (fs.existsSync(INSTALL_DIR)) {
rmSync(INSTALL_DIR, { recursive: true, force: true });
}
mkdirSync(INSTALL_DIR, { recursive: true });

// Download to a temp file
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gws-"));
const archiveName = path.basename(platform.artifact);
const tmpFile = path.join(tmpDir, archiveName);

try {
console.error(`Downloading gws from ${url}`);
await download(url, tmpFile);

console.error(`Extracting to ${INSTALL_DIR}`);
extract(tmpFile, INSTALL_DIR);

// Make binary executable on Unix
if (process.platform !== "win32") {
fs.chmodSync(binPath, 0o755);
}

console.error(`gws v${version} has been installed!`);
fs.writeFileSync(versionFile, version);
} finally {
// Clean up temp files
rmSync(tmpDir, { recursive: true, force: true });
}
}

install().catch((err) => {
console.error(`Error installing gws: ${sanitize(err.message)}`);
process.exit(1);
});
79 changes: 79 additions & 0 deletions npm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"name": "@googleworkspace/cli",
"description": "Google Workspace CLI — dynamic command surface from Discovery Service",
"version": "0.22.3",
"license": "Apache-2.0",
"author": "Justin Poehnelt",
"repository": {
"type": "git",
"url": "https://github.com/googleworkspace/cli.git"
},
"homepage": "https://github.com/googleworkspace/cli",
"bugs": {
"url": "https://github.com/googleworkspace/cli/issues"
},
"bin": {
"gws": "run.js"
},
"scripts": {
"postinstall": "node install.js"
},
"engines": {
"node": ">=18"
},
"preferUnplugged": true,
"keywords": [
"cli",
"google-workspace",
"google",
"google-api",
"google-drive",
"google-gmail",
"google-sheets",
"google-calendar",
"google-docs",
"google-chat",
"google-admin",
"gsuite",
"discovery-api",
"ai-agent",
"agent-skills",
"automation",
"oauth2",
"rust"
],
"publishConfig": {
"provenance": true,
"registry": "https://wombat-dressing-room.appspot.com"
},
"supportedPlatforms": {
"aarch64-apple-darwin": {
"artifact": "google-workspace-cli-aarch64-apple-darwin.tar.gz",
"binary": "gws"
},
"x86_64-apple-darwin": {
"artifact": "google-workspace-cli-x86_64-apple-darwin.tar.gz",
"binary": "gws"
},
"aarch64-unknown-linux-gnu": {
"artifact": "google-workspace-cli-aarch64-unknown-linux-gnu.tar.gz",
"binary": "gws"
},
"aarch64-unknown-linux-musl": {
"artifact": "google-workspace-cli-aarch64-unknown-linux-musl.tar.gz",
"binary": "gws"
},
"x86_64-unknown-linux-gnu": {
"artifact": "google-workspace-cli-x86_64-unknown-linux-gnu.tar.gz",
"binary": "gws"
},
"x86_64-unknown-linux-musl": {
"artifact": "google-workspace-cli-x86_64-unknown-linux-musl.tar.gz",
"binary": "gws"
},
"x86_64-pc-windows-msvc": {
"artifact": "google-workspace-cli-x86_64-pc-windows-msvc.zip",
"binary": "gws.exe"
}
}
}
86 changes: 86 additions & 0 deletions npm/platform.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env node

"use strict";

const os = require("os");
const path = require("path");
const fs = require("fs");
const { spawnSync } = require("child_process");

const { supportedPlatforms } = require("./package.json");

/**
* Map Node.js os.type() and os.arch() to Rust-style target triples.
*/
function getPlatformKey() {
const rawOs = os.type();
const rawArch = os.arch();

let osType;
switch (rawOs) {
case "Windows_NT":
osType = "pc-windows-msvc";
break;
case "Darwin":
osType = "apple-darwin";
break;
case "Linux":
osType = "unknown-linux-gnu";
break;
default:
throw new Error(`Unsupported operating system: ${rawOs}`);
}

let arch;
switch (rawArch) {
case "x64":
arch = "x86_64";
break;
case "arm64":
arch = "aarch64";
break;
default:
throw new Error(`Unsupported architecture: ${rawArch}`);
}

// On Linux, try to detect musl libc
if (rawOs === "Linux") {
try {
const result = spawnSync("ldd", ["--version"], {
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
});
// musl ldd prints version info to stderr
const output = (result.stdout || "") + (result.stderr || "");
if (output.toLowerCase().includes("musl")) {
osType = "unknown-linux-musl";
}
} catch {
// If ldd fails, assume glibc
}
}

const key = `${arch}-${osType}`;

if (!supportedPlatforms[key]) {
// Try musl fallback on Linux if glibc binary is not available
if (rawOs === "Linux") {
const muslKey = `${arch}-unknown-linux-musl`;
if (supportedPlatforms[muslKey]) {
return muslKey;
}
}
throw new Error(
`Unsupported platform: ${key}\nSupported platforms: ${Object.keys(supportedPlatforms).join(", ")}`,
);
}

return key;
}

function getPlatform() {
const key = getPlatformKey();
return supportedPlatforms[key];
}

module.exports = { getPlatform, getPlatformKey };
Loading
Loading