diff --git a/.changeset/remove-cargo-dist.md b/.changeset/remove-cargo-dist.md new file mode 100644 index 00000000..eda35a15 --- /dev/null +++ b/.changeset/remove-cargo-dist.md @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cb112d9..bb46a589 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,359 +1,194 @@ -# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# Release workflow for @googleworkspace/cli # -# Copyright 2022-2024, axodotdev -# SPDX-License-Identifier: MIT or Apache-2.0 -# -# CI that: -# -# * checks for a Git Tag that looks like a release -# * builds artifacts with dist (archives, installers, hashes) -# * uploads those artifacts to temporary workflow zip -# * on success, uploads the artifacts to a GitHub Release -# -# Note that the GitHub Release will be created with a generated -# title/body based on your changelogs. +# Triggered by pushing a semver tag (e.g. v0.22.3). +# Builds platform binaries, creates a GitHub Release, publishes to npm and crates.io. name: Release permissions: - "contents": "write" + contents: write + attestations: write + id-token: write -# This task will run whenever you push a git tag that looks like a version -# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. -# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where -# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION -# must be a Cargo-style SemVer Version (must have at least major.minor.patch). -# -# If PACKAGE_NAME is specified, then the announcement will be for that -# package (erroring out if it doesn't have the given version or isn't dist-able). -# -# If PACKAGE_NAME isn't specified, then the announcement will be for all -# (dist-able) packages in the workspace with that version (this mode is -# intended for workspaces with only one dist-able package, or with all dist-able -# packages versioned/released in lockstep). -# -# If you push multiple tags at once, separate instances of this workflow will -# spin up, creating an independent announcement for each one. However, GitHub -# will hard limit this to 3 tags per commit, as it will assume more tags is a -# mistake. -# -# If there's a prerelease-style suffix to the version, then the release(s) -# will be marked as a prerelease. on: pull_request: push: tags: - - '**[0-9]+.[0-9]+.[0-9]+*' + - 'v[0-9]+.[0-9]+.[0-9]+*' + +env: + CARGO_TERM_COLOR: always jobs: - # Run 'dist plan' (or host) to determine what tasks we need to do plan: - runs-on: "ubuntu-22.04" + runs-on: ubuntu-latest outputs: - val: ${{ steps.plan.outputs.manifest }} - tag: ${{ !github.event.pull_request && github.ref_name || '' }} - tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} - publishing: ${{ !github.event.pull_request }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + version: ${{ steps.meta.outputs.version }} + prerelease: ${{ steps.meta.outputs.prerelease }} + publishing: ${{ github.ref_type == 'tag' }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - submodules: recursive - - name: Install dist - # we specify bash to get pipefail; it guards against the `curl` command - # failing. otherwise `sh` won't catch that `curl` returned non-0 - shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" - - name: Cache dist - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: cargo-dist-cache - path: ~/.cargo/bin/dist - # sure would be cool if github gave us proper conditionals... - # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible - # functionality based on whether this is a pull_request, and whether it's from a fork. - # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* - # but also really annoying to build CI around when it needs secrets to work right.) - - id: plan + - id: meta run: | - dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json - echo "dist ran successfully" - cat plan-dist-manifest.json - echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: artifacts-plan-dist-manifest - path: plan-dist-manifest.json + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + if [[ "$VERSION" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi - # Build and packages all the platform-specific things - build-local-artifacts: - name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) - # Let the initial task tell us to not run (currently very blunt) - needs: - - plan - if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + build: + needs: plan strategy: fail-fast: false - # Target platforms/runners are computed by dist in create-release. - # Each member of the matrix has the following arguments: - # - # - runner: the github runner - # - dist-args: cli flags to pass to dist - # - install-dist: expression to run to install dist on the runner - # - # Typically there will be: - # - 1 "global" task that builds universal installers - # - N "local" tasks that build each platform's binaries and platform-specific installers - matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + matrix: + include: + - target: aarch64-apple-darwin + runner: macos-latest + archive: tar.gz + - target: x86_64-apple-darwin + runner: macos-latest + archive: tar.gz + - target: aarch64-unknown-linux-gnu + runner: ubuntu-latest + archive: tar.gz + cross: true + - target: aarch64-unknown-linux-musl + runner: ubuntu-latest + archive: tar.gz + cross: true + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + archive: tar.gz + - target: x86_64-unknown-linux-musl + runner: ubuntu-latest + archive: tar.gz + cross: true + - target: x86_64-pc-windows-msvc + runner: windows-latest + archive: zip runs-on: ${{ matrix.runner }} - container: ${{ matrix.container && matrix.container.image || null }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json - permissions: - "attestations": "write" - "contents": "read" - "id-token": "write" steps: - - name: enable windows longpaths - run: | - git config --global core.longpaths true - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: - persist-credentials: false - submodules: recursive - - name: Install Rust non-interactively if not already installed - if: ${{ matrix.container }} + targets: ${{ matrix.target }} + + - name: Install cross + if: matrix.cross + run: cargo install cross --git https://github.com/cross-rs/cross + + - name: Build run: | - if ! command -v cargo > /dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - echo "$HOME/.cargo/bin" >> $GITHUB_PATH + if [ "${{ matrix.cross }}" = "true" ]; then + cross build --release --target ${{ matrix.target }} --locked + else + cargo build --release --target ${{ matrix.target }} --locked fi - - name: Install dist - run: ${{ matrix.install_dist.run }} - # Get the dist-manifest - - name: Fetch local artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: artifacts-* - path: target/distrib/ - merge-multiple: true - - name: Install dependencies + shell: bash + + - name: Package (unix) + if: matrix.archive == 'tar.gz' run: | - ${{ matrix.packages_install }} - - name: Build artifacts + ARTIFACT="google-workspace-cli-${{ matrix.target }}" + mkdir -p staging + cp "target/${{ matrix.target }}/release/gws" staging/ + cp LICENSE README.md CHANGELOG.md staging/ + tar czf "${ARTIFACT}.tar.gz" -C staging . + shasum -a 256 "${ARTIFACT}.tar.gz" > "${ARTIFACT}.tar.gz.sha256" + shell: bash + + - name: Package (windows) + if: matrix.archive == 'zip' run: | - # Actually do builds and make zips and whatnot - dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json - echo "dist ran successfully" + $ARTIFACT = "google-workspace-cli-${{ matrix.target }}" + New-Item -ItemType Directory -Path $ARTIFACT + Copy-Item "target/${{ matrix.target }}/release/gws.exe" "$ARTIFACT/" + Copy-Item LICENSE, README.md, CHANGELOG.md "$ARTIFACT/" + Compress-Archive -Path "$ARTIFACT/*" -DestinationPath "$ARTIFACT.zip" + (Get-FileHash "$ARTIFACT.zip" -Algorithm SHA256).Hash.ToLower() + " $ARTIFACT.zip" | Out-File -Encoding ascii "$ARTIFACT.zip.sha256" + shell: pwsh + - name: Attest + if: github.ref_type == 'tag' uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 # v3 with: - subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*" - - id: cargo-dist - name: Post-build - # We force bash here just because github makes it really hard to get values up - # to "real" actions without writing to env-vars, and writing to env-vars has - # inconsistent syntax between shell and powershell. - shell: bash - run: | - # Parse out what we just built and upload it to scratch storage - echo "paths<> "$GITHUB_OUTPUT" - dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" + subject-path: google-workspace-cli-${{ matrix.target }}.${{ matrix.archive }} - cp dist-manifest.json "$BUILD_MANIFEST_NAME" - - name: "Upload artifacts" - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: artifacts-build-local-${{ join(matrix.targets, '_') }} + name: binary-${{ matrix.target }} path: | - ${{ steps.cargo-dist.outputs.paths }} - ${{ env.BUILD_MANIFEST_NAME }} + google-workspace-cli-${{ matrix.target }}.${{ matrix.archive }} + google-workspace-cli-${{ matrix.target }}.${{ matrix.archive }}.sha256 - # Build and package all the platform-agnostic(ish) things - build-global-artifacts: - needs: - - plan - - build-local-artifacts - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + release: + needs: [plan, build] + if: github.ref_type == 'tag' + runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - submodules: recursive - - name: Install cached dist - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - name: cargo-dist-cache - path: ~/.cargo/bin/ - - run: chmod +x ~/.cargo/bin/dist - # Get all the local artifacts for the global tasks to use (for e.g. checksums) - - name: Fetch local artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: artifacts-* - path: target/distrib/ - merge-multiple: true - - id: cargo-dist - shell: bash - run: | - dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json - echo "dist ran successfully" - - # Parse out what we just built and upload it to scratch storage - echo "paths<> "$GITHUB_OUTPUT" - jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - cp dist-manifest.json "$BUILD_MANIFEST_NAME" - - name: "Upload artifacts" - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: artifacts-build-global - path: | - ${{ steps.cargo-dist.outputs.paths }} - ${{ env.BUILD_MANIFEST_NAME }} - # Determines if we should publish/announce - host: - needs: - - plan - - build-local-artifacts - - build-global-artifacts - # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) - if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - runs-on: "ubuntu-22.04" - outputs: - val: ${{ steps.host.outputs.manifest }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - submodules: recursive - - name: Install cached dist - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - name: cargo-dist-cache - path: ~/.cargo/bin/ - - run: chmod +x ~/.cargo/bin/dist - # Fetch artifacts from scratch-storage - - name: Fetch artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - pattern: artifacts-* - path: target/distrib/ + pattern: binary-* + path: artifacts/ merge-multiple: true - - id: host - shell: bash - run: | - dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json - echo "artifacts uploaded and released successfully" - cat dist-manifest.json - echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" - - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - # Overwrite the previous copy - name: artifacts-dist-manifest - path: dist-manifest.json - # Create a GitHub Release while uploading all files to it - - name: "Download GitHub Artifacts" - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: artifacts-* - path: artifacts - merge-multiple: true - - name: Cleanup - run: | - # Remove the granular manifests - rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release env: - PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" - ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" - ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" - RELEASE_COMMIT: "${{ github.sha }}" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Write and read notes from a file to avoid quoting breaking things - echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt - - gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + PRERELEASE_FLAG="" + if [ "${{ needs.plan.outputs.prerelease }}" = "true" ]; then + PRERELEASE_FLAG="--prerelease" + fi + gh release create "${{ github.ref_name }}" \ + --target "${{ github.sha }}" \ + --title "${{ github.ref_name }}" \ + --generate-notes \ + $PRERELEASE_FLAG \ + artifacts/* publish-npm: - needs: - - plan - - host - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PLAN: ${{ needs.plan.outputs.val }} - if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + needs: [plan, release] + runs-on: ubuntu-latest + if: ${{ needs.plan.outputs.publishing == 'true' && needs.plan.outputs.prerelease == 'false' }} steps: - - name: Fetch npm packages - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: artifacts-* - path: npm/ - merge-multiple: true + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: '20.x' registry-url: 'https://wombat-dressing-room.appspot.com' - - run: | - for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith("-npm-package.tar.gz")] | any)'); do - pkg=$(echo "$release" | jq '.artifacts[] | select(endswith("-npm-package.tar.gz"))' --raw-output) - npm publish --access public "./npm/${pkg}" - done + + - name: Publish to npm + working-directory: npm + run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish-cargo: - needs: - - plan - - host - runs-on: "ubuntu-22.04" - if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + needs: [plan, release] + runs-on: ubuntu-latest + if: ${{ needs.plan.outputs.publishing == 'true' && needs.plan.outputs.prerelease == 'false' }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + # Publish library crate first (CLI depends on it) - name: Publish google-workspace run: cargo publish --package google-workspace env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + # Wait for crates.io to index the library crate - name: Wait for crates.io index run: sleep 30 + - name: Publish google-workspace-cli run: cargo publish --package google-workspace-cli env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - - announce: - needs: - - plan - - host - - publish-npm - - publish-cargo - # use "always() && ..." to allow us to wait for all publish jobs while - # still allowing individual publish jobs to skip themselves (for prereleases). - # "host" however must run to completion, no skipping allowed! - if: ${{ always() && needs.host.result == 'success' && (needs.publish-npm.result == 'skipped' || needs.publish-npm.result == 'success') && (needs.publish-cargo.result == 'skipped' || needs.publish-cargo.result == 'success') }} - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - submodules: recursive - diff --git a/AGENTS.md b/AGENTS.md index 07198523..72211226 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/dist-workspace.toml b/dist-workspace.toml deleted file mode 100644 index aee41cee..00000000 --- a/dist-workspace.toml +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright 2026 Google LLC -# -# 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. - -[workspace] -members = ["cargo:crates/google-workspace-cli"] - -# Config for 'cargo dist' -[dist] -# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.31.0" -# CI backends to support -ci = "github" -# The installers to generate for each app -installers = ["shell", "powershell", "npm"] -# Publish jobs to run -publish-jobs = ["npm"] -npm-scope = "@googleworkspace" -# Enable github attestations -github-attestations = true -npm-package = "cli" -# Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] -# Which actions to run on pull requests -pr-run-mode = "plan" -# Don't overwrite release.yml on `cargo dist init` (preserves custom npm registry config) -allow-dirty = ["ci"] -# The archive format to use for windows builds (defaults .zip) -# Using .zip routes through PowerShell's Expand-Archive, which correctly -# handles Windows paths. Using .tar.gz causes failures in Git Bash because -# MSYS tar interprets "C:" as a remote host (issue #152). -windows-archive = ".zip" -# The archive format to use for non-windows builds (defaults .tar.xz) -unix-archive = ".tar.gz" diff --git a/npm/.gitignore b/npm/.gitignore new file mode 100644 index 00000000..3c95b725 --- /dev/null +++ b/npm/.gitignore @@ -0,0 +1,2 @@ +# Downloaded binary (created during npm postinstall) +bin/ diff --git a/npm/install.js b/npm/install.js new file mode 100644 index 00000000..3b906605 --- /dev/null +++ b/npm/install.js @@ -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, ""); +} + +/** + * 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. + */ +async function download(url, dest) { + const res = await fetch(url, { redirect: "follow" }); + + 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); +} + +/** + * 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`, + ]); + } else { + run("unzip", ["-q", "-o", archivePath, "-d", destDir]); + } + } 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); +}); diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 00000000..561693b8 --- /dev/null +++ b/npm/package.json @@ -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" + } + } +} diff --git a/npm/platform.js b/npm/platform.js new file mode 100644 index 00000000..cf90d471 --- /dev/null +++ b/npm/platform.js @@ -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 }; diff --git a/npm/run.js b/npm/run.js new file mode 100644 index 00000000..67ff6fdd --- /dev/null +++ b/npm/run.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node + +"use strict"; + +const path = require("path"); +const fs = require("fs"); +const { spawnSync } = require("child_process"); +const { getPlatform } = require("./platform"); + +const platform = getPlatform(); +const binPath = path.join(__dirname, "bin", platform.binary); + +if (!fs.existsSync(binPath)) { + console.error( + `gws binary not found at ${binPath}\nRun "npm install -g @googleworkspace/cli" to install it.`, + ); + process.exit(1); +} + +const result = spawnSync(binPath, process.argv.slice(2), { + cwd: process.cwd(), + stdio: "inherit", +}); + +if (result.error) { + console.error(`Error running gws: ${result.error.message}`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/scripts/version-sync.sh b/scripts/version-sync.sh index 385e04d1..4dd83ec2 100755 --- a/scripts/version-sync.sh +++ b/scripts/version-sync.sh @@ -26,6 +26,13 @@ done sed -i.bak -E "s/(google-workspace = \{ version = \")[^\"]+/\1${VERSION}/" crates/google-workspace-cli/Cargo.toml rm -f crates/google-workspace-cli/Cargo.toml.bak +# Update npm installer package.json version +node -e " + const pkg = require('./npm/package.json'); + pkg.version = '${VERSION}'; + require('fs').writeFileSync('./npm/package.json', JSON.stringify(pkg, null, 2) + '\n'); +" + # Update Cargo.lock to match cargo generate-lockfile @@ -38,5 +45,5 @@ fi cargo run -- generate-skills --output-dir skills # Stage the changed files so changesets/action commits them -git add crates/*/Cargo.toml Cargo.lock flake.nix flake.lock skills/ +git add crates/*/Cargo.toml Cargo.lock flake.nix flake.lock skills/ npm/package.json