diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18311e0..e95b27a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,6 @@ on: permissions: contents: write - packages: write jobs: changes: @@ -37,7 +36,11 @@ jobs: - 'tests/**' - 'Cargo.toml' - 'Cargo.lock' + - 'npm/**' + - 'flake.nix' + - 'flake.lock' - 'docker/**' + - '.github/workflows/**' - 'Makefile' - name: Set outputs @@ -137,72 +140,36 @@ jobs: - name: Xvfb integration tests run: make test-integration - build: - name: Build (${{ matrix.target }}) - needs: [changes, validate, integration] - if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true' + distribution: + name: Distribution Validate + needs: changes + if: needs.changes.outputs.rust == 'true' runs-on: ubuntu-latest - strategy: - fail-fast: true - matrix: - target: [cargo, docker] steps: - uses: actions/checkout@v4 - # --- Cargo steps --- - uses: dtolnay/rust-toolchain@stable - if: matrix.target == 'cargo' - with: - components: clippy - uses: Swatinem/rust-cache@v2 - if: matrix.target == 'cargo' + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: cachix/install-nix-action@v30 + with: + extra_nix_config: | + experimental-features = nix-command flakes - name: Install system dependencies - if: matrix.target == 'cargo' run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev - - name: Clippy - if: matrix.target == 'cargo' - run: cargo clippy -- -D warnings - - - name: Build - if: matrix.target == 'cargo' - run: cargo build --release --locked - - - uses: actions/upload-artifact@v4 - if: matrix.target == 'cargo' - with: - name: deskctl-linux-x86_64 - path: target/release/deskctl - retention-days: 7 - - # --- Docker steps --- - - uses: docker/setup-buildx-action@v3 - if: matrix.target == 'docker' - - - uses: docker/login-action@v3 - if: matrix.target == 'docker' - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - uses: docker/build-push-action@v6 - if: matrix.target == 'docker' - with: - context: . - file: docker/Dockerfile - push: true - tags: | - ghcr.io/${{ github.repository }}:latest - ghcr.io/${{ github.repository }}:${{ needs.changes.outputs.tag }} - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Distribution validation + run: make dist-validate update-manifests: name: Update Manifests - needs: [changes, build] + needs: [changes, validate, integration, distribution] if: github.event_name != 'pull_request' runs-on: ubuntu-latest steps: @@ -212,12 +179,17 @@ jobs: - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Update version in Cargo.toml run: | CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') NEW="${{ needs.changes.outputs.version }}" if [ "$CURRENT" != "$NEW" ]; then sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml + node -e 'const fs=require("node:fs"); const path="npm/deskctl-cli/package.json"; const pkg=JSON.parse(fs.readFileSync(path,"utf8")); pkg.version=process.argv[1]; fs.writeFileSync(path, JSON.stringify(pkg, null, 2)+"\n");' "$NEW" cargo generate-lockfile fi @@ -227,7 +199,7 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" if ! git diff --quiet; then - git add Cargo.toml Cargo.lock + git add Cargo.toml Cargo.lock npm/deskctl-cli/package.json git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]" fi @@ -236,6 +208,38 @@ jobs: fi git push origin main --tags + build: + name: Build Release Asset + needs: [changes, update-manifests] + if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.changes.outputs.tag }} + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev + + - name: Clippy + run: cargo clippy -- -D warnings + + - name: Build + run: cargo build --release --locked + + - uses: actions/upload-artifact@v4 + with: + name: deskctl-linux-x86_64 + path: target/release/deskctl + retention-days: 7 + release: name: Release needs: [changes, build, update-manifests] @@ -256,9 +260,15 @@ jobs: chmod +x artifacts/deskctl mv artifacts/deskctl artifacts/deskctl-linux-x86_64 cd artifacts && sha256sum deskctl-linux-x86_64 > checksums.txt && cd .. - - gh release create "${{ needs.changes.outputs.tag }}" \ - --title "${{ needs.changes.outputs.tag }}" \ - --generate-notes \ - artifacts/deskctl-linux-x86_64 \ - artifacts/checksums.txt + if gh release view "${{ needs.changes.outputs.tag }}" >/dev/null 2>&1; then + gh release upload "${{ needs.changes.outputs.tag }}" \ + artifacts/deskctl-linux-x86_64 \ + artifacts/checksums.txt \ + --clobber + else + gh release create "${{ needs.changes.outputs.tag }}" \ + --title "${{ needs.changes.outputs.tag }}" \ + --generate-notes \ + artifacts/deskctl-linux-x86_64 \ + artifacts/checksums.txt + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..329f151 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,102 @@ +name: Publish Registries + +on: + workflow_dispatch: + inputs: + tag: + description: Release tag to publish (for example v0.1.5) + required: true + type: string + publish_npm: + description: Publish deskctl-cli to npm + required: true + type: boolean + default: false + publish_crates: + description: Publish deskctl to crates.io + required: true + type: boolean + default: false + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - uses: dtolnay/rust-toolchain@stable + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev + + - name: Verify release exists and contains canonical assets + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release view "${{ inputs.tag }}" --json assets --jq '.assets[].name' > /tmp/release-assets.txt + grep -Fx "deskctl-linux-x86_64" /tmp/release-assets.txt >/dev/null + grep -Fx "checksums.txt" /tmp/release-assets.txt >/dev/null + + - name: Verify versions align with tag + run: | + TAG="${{ inputs.tag }}" + VERSION="${TAG#v}" + CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + NPM_VERSION=$(node -p 'require("./npm/deskctl-cli/package.json").version') + + test "$VERSION" = "$CARGO_VERSION" + test "$VERSION" = "$NPM_VERSION" + + - name: Check current published state + id: published + run: | + VERSION="${{ inputs.tag }}" + VERSION="${VERSION#v}" + + if npm view "deskctl-cli@${VERSION}" version >/dev/null 2>&1; then + echo "npm=true" >> "$GITHUB_OUTPUT" + else + echo "npm=false" >> "$GITHUB_OUTPUT" + fi + + if curl -fsSL "https://crates.io/api/v1/crates/deskctl/${VERSION}" >/dev/null 2>&1; then + echo "crates=true" >> "$GITHUB_OUTPUT" + else + echo "crates=false" >> "$GITHUB_OUTPUT" + fi + + - name: Validate npm package + run: | + mkdir -p ./tmp/npm-pack + node npm/deskctl-cli/scripts/validate-package.js + npm pack ./npm/deskctl-cli --pack-destination ./tmp/npm-pack >/dev/null + + - name: Validate crate publish path + run: cargo publish --dry-run --locked + + - name: Publish npm + if: inputs.publish_npm && steps.published.outputs.npm != 'true' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish ./npm/deskctl-cli --access public + + - name: Publish crates.io + if: inputs.publish_crates && steps.published.outputs.crates != 'true' + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish --locked + + - name: Summary + run: | + echo "tag=${{ inputs.tag }}" + echo "npm_already_published=${{ steps.published.outputs.npm }}" + echo "crates_already_published=${{ steps.published.outputs.crates }}" diff --git a/.gitignore b/.gitignore index 7406874..db552f7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ secret/ .claude/ .codex/ openspec/ +npm/deskctl-cli/vendor/ +npm/deskctl-cli/*.tgz diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a1a2a2..bdbce4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,10 +35,15 @@ make lint make test-unit make test-integration make site-format-check +make cargo-publish-dry-run +make npm-package-check +make nix-flake-check +make dist-validate make validate ``` `make validate` runs the full Phase 2 validation stack. It requires Linux, `xvfb-run`, and site dependencies to be installed. +`make dist-validate` runs the distribution validation stack. It requires `npm`, `nix`, and Linux for the full npm runtime smoke path. ## Pre-commit Hooks @@ -60,6 +65,19 @@ The hook config intentionally stays small: - Site files reuse the existing `site/` Prettier setup - Slower checks stay in CI or `make validate` +## Distribution Work + +Distribution support currently ships through: + +- crate: `deskctl` +- npm package: `deskctl-cli` +- repo flake: `flake.nix` +- command name on every channel: `deskctl` + +For maintainer release and publish steps, see [docs/releasing.md](docs/releasing.md). + +Source-build and packaging work should keep Docker as a local Linux build convenience, not as the canonical registry release path. + ## Integration Tests Integration coverage is Linux/X11-only in this phase. The supported local entrypoint is: diff --git a/Cargo.toml b/Cargo.toml index 023e18a..f373679 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,19 @@ edition = "2021" description = "X11 desktop control CLI for agents" license = "MIT" repository = "https://github.com/harivansh-afk/deskctl" +homepage = "https://github.com/harivansh-afk/deskctl" +readme = "README.md" +keywords = ["x11", "desktop", "automation", "cli", "agent"] +categories = ["command-line-utilities"] +rust-version = "1.75" +include = [ + "/Cargo.toml", + "/Cargo.lock", + "/README.md", + "/LICENCE", + "/assets/**", + "/src/**", +] [dependencies] clap = { version = "4", features = ["derive", "env"] } diff --git a/Makefile b/Makefile index bb02037..97857e3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: fmt fmt-check lint test-unit test-integration site-format-check validate +.PHONY: fmt fmt-check lint test-unit test-integration site-format-check cargo-publish-dry-run npm-package-check nix-flake-check dist-validate validate fmt: cargo fmt --all @@ -30,4 +30,34 @@ site-format-check: fi pnpm --dir site format:check +cargo-publish-dry-run: + cargo publish --dry-run --allow-dirty --locked + +npm-package-check: + @if ! command -v npm >/dev/null 2>&1; then \ + echo "npm is required for npm packaging validation."; \ + exit 1; \ + fi + node npm/deskctl-cli/scripts/validate-package.js + rm -rf tmp/npm-pack tmp/npm-install + mkdir -p tmp/npm-pack tmp/npm-install/bin + npm pack ./npm/deskctl-cli --pack-destination ./tmp/npm-pack >/dev/null + @if [ "$$(uname -s)" != "Linux" ]; then \ + echo "Skipping npm package runtime smoke test on non-Linux host."; \ + else \ + cargo build && \ + PACK_TGZ=$$(ls ./tmp/npm-pack/*.tgz | head -n 1) && \ + DESKCTL_BINARY_PATH="$$(pwd)/target/debug/deskctl" npm install --prefix ./tmp/npm-install "$${PACK_TGZ}" && \ + ./tmp/npm-install/node_modules/.bin/deskctl --version; \ + fi + +nix-flake-check: + @if ! command -v nix >/dev/null 2>&1; then \ + echo "nix is required for flake validation."; \ + exit 1; \ + fi + nix flake check + +dist-validate: test-unit cargo-publish-dry-run npm-package-check nix-flake-check + validate: fmt-check lint test-unit test-integration site-format-check diff --git a/README.md b/README.md index 6920615..036396a 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,45 @@ Desktop control CLI for AI agents on Linux X11. ## Install +### Cargo + ```bash cargo install deskctl ``` -Build a Linux binary with Docker: +Source builds on Linux require: + +- Rust 1.75+ +- `pkg-config` +- X11 development libraries for input and windowing, typically `libx11-dev` and `libxtst-dev` on Debian/Ubuntu + +### npm + +```bash +npm install -g deskctl-cli +deskctl --help +``` + +One-shot execution is also supported: + +```bash +npx deskctl-cli --help +``` + +`deskctl-cli` currently supports `linux-x64` and installs the `deskctl` command by downloading the matching GitHub Release asset. + +### Nix + +```bash +nix run github:harivansh-afk/deskctl -- --help +nix profile install github:harivansh-afk/deskctl +``` + +The repo flake is the supported Nix install surface in this phase. + +### Docker Convenience + +Build a Linux binary locally with Docker: ```bash docker compose -f docker/docker-compose.yml run --rm build @@ -28,13 +62,12 @@ Run it on an X11 session: DISPLAY=:1 XDG_SESSION_TYPE=x11 ~/deskctl --json snapshot --annotate ``` -Local source build requirements: +### Local Source Build + ```bash cargo build ``` -At the moment there are no extra native build dependencies beyond a Rust toolchain. - ## Quick Start ```bash @@ -78,7 +111,7 @@ Source layout: ## Runtime Requirements - Linux with X11 session -- Rust 1.75+ (for build) +- Rust 1.75+ plus the source-build dependencies above when building from source The binary itself only links the standard glibc runtime on Linux (`libc`, `libm`, `libgcc_s`). @@ -158,6 +191,16 @@ Text mode is compact and follow-up-oriented, but JSON is the parsing contract. See [docs/runtime-output.md](docs/runtime-output.md) for the exact stable-vs-best-effort breakdown. +## Distribution + +- GitHub Releases are the canonical binary source +- crates.io package: `deskctl` +- npm package: `deskctl-cli` +- installed command on every channel: `deskctl` +- repo-owned Nix install path: `flake.nix` + +For maintainer publishing and release steps, see [docs/releasing.md](docs/releasing.md). + ## Selector Contract Explicit selector modes: diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..7271b83 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,110 @@ +# Releasing deskctl + +This document covers the operator flow for shipping `deskctl` across: + +- GitHub Releases +- crates.io +- npm +- the repo flake + +GitHub Releases are the canonical binary source. The npm package consumes those release assets instead of building a separate binary. + +## Package Names + +- crate: `deskctl` +- npm package: `deskctl-cli` +- installed command: `deskctl` + +## Prerequisites + +Before the first live publish on each registry: + +- npm ownership for `deskctl-cli` +- crates.io ownership for `deskctl` +- repository secrets: + - `NPM_TOKEN` + - `CARGO_REGISTRY_TOKEN` + +These are user-owned prerequisites. The repo can validate and automate the rest, but it cannot create registry ownership for you. + +## Normal Release Flow + +1. Merge release-ready changes to `main`. +2. Let CI run: + - validation + - integration + - distribution validation + - release asset build +3. Confirm the GitHub Release exists for the version tag and includes: + - `deskctl-linux-x86_64` + - `checksums.txt` +4. Trigger the `Publish Registries` workflow with: + - `tag` + - `publish_npm` + - `publish_crates` +5. Confirm the publish summary for each channel. + +## What CI Validates + +The repository validates: + +- `cargo publish --dry-run --locked` +- npm package metadata and packability +- npm install smoke path on Linux using the packaged `deskctl` command +- repo flake evaluation/build + +The repository release workflow: + +- builds the Linux release binary +- publishes the canonical GitHub Release asset +- uploads `checksums.txt` + +The registry publish workflow: + +- targets an existing release tag +- checks that Cargo, npm, and the requested tag all agree on version +- checks whether that version is already published on npm and crates.io +- only publishes the channels explicitly requested + +## Rerun Safety + +Registry publishing is intentionally separate from release asset creation. + +If a partial failure happens: + +- GitHub Release assets remain the source of truth +- rerun the `Publish Registries` workflow for the same tag +- already-published channels are reported and skipped +- remaining channels can still be published + +## Local Validation + +Run the distribution checks locally with: + +```bash +make cargo-publish-dry-run +make npm-package-check +make nix-flake-check +make dist-validate +``` + +Notes: + +- `make npm-package-check` does a runtime smoke test only on Linux +- `make nix-flake-check` requires a local Nix installation +- Docker remains a local Linux build convenience, not the canonical release path + +## Nix Boundary + +The repo-owned `flake.nix` is the supported Nix surface in this phase. + +In scope: + +- `nix run github:harivansh-afk/deskctl` +- `nix profile install github:harivansh-afk/deskctl` +- CI validation for the repo flake + +Out of scope for this phase: + +- `nixpkgs` upstreaming +- extra distro packaging outside the repo diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f194334 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1774386573, + "narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1eafbaa --- /dev/null +++ b/flake.nix @@ -0,0 +1,77 @@ +{ + description = "deskctl - Desktop control CLI for AI agents on Linux X11"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + lib = pkgs.lib; + cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); + + deskctl = + pkgs.rustPlatform.buildRustPackage { + pname = cargoToml.package.name; + version = cargoToml.package.version; + src = ./.; + cargoLock.lockFile = ./Cargo.lock; + nativeBuildInputs = [ pkgs.pkg-config ]; + buildInputs = lib.optionals pkgs.stdenv.isLinux [ + pkgs.libx11 + pkgs.libxtst + ]; + doCheck = false; + + meta = with lib; { + description = cargoToml.package.description; + homepage = cargoToml.package.homepage; + license = licenses.mit; + mainProgram = "deskctl"; + platforms = platforms.linux; + }; + }; + in + { + formatter = pkgs.nixfmt; + + packages = lib.optionalAttrs pkgs.stdenv.isLinux { + inherit deskctl; + default = deskctl; + }; + + apps = lib.optionalAttrs pkgs.stdenv.isLinux { + default = flake-utils.lib.mkApp { drv = deskctl; }; + deskctl = flake-utils.lib.mkApp { drv = deskctl; }; + }; + + checks = lib.optionalAttrs pkgs.stdenv.isLinux { + build = deskctl; + }; + + devShells.default = pkgs.mkShell { + packages = + [ + pkgs.cargo + pkgs.clippy + pkgs.nodejs + pkgs.nixfmt + pkgs.pkg-config + pkgs.pnpm + pkgs.rustc + pkgs.rustfmt + ] + ++ lib.optionals pkgs.stdenv.isLinux [ + pkgs.libx11 + pkgs.libxtst + pkgs.xorg.xorgserver + ]; + }; + } + ); +} diff --git a/npm/deskctl-cli/README.md b/npm/deskctl-cli/README.md new file mode 100644 index 0000000..fd6f610 --- /dev/null +++ b/npm/deskctl-cli/README.md @@ -0,0 +1,36 @@ +# deskctl-cli + +`deskctl-cli` installs the `deskctl` command for Linux X11 systems. + +## Install + +```bash +npm install -g deskctl-cli +``` + +After install, run: + +```bash +deskctl --help +``` + +One-shot usage is also supported: + +```bash +npx deskctl-cli --help +``` + +## Runtime Support + +- Linux +- X11 session +- currently packaged release asset: `linux-x64` + +`deskctl-cli` downloads the matching GitHub Release binary during install. +Unsupported targets fail during install with a clear runtime support error instead of installing a broken command. + +If you want the Rust source-install path instead, use: + +```bash +cargo install deskctl +``` diff --git a/npm/deskctl-cli/bin/deskctl.js b/npm/deskctl-cli/bin/deskctl.js new file mode 100644 index 0000000..9f9b480 --- /dev/null +++ b/npm/deskctl-cli/bin/deskctl.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const { spawn } = require("node:child_process"); + +const { readPackageJson, releaseTag, supportedTarget, vendorBinaryPath } = require("../scripts/support"); + +function main() { + const pkg = readPackageJson(); + const target = supportedTarget(); + const binaryPath = vendorBinaryPath(target); + + if (!fs.existsSync(binaryPath)) { + console.error( + [ + "deskctl binary is missing from the npm package install.", + `Expected: ${binaryPath}`, + `Package version: ${pkg.version}`, + `Release tag: ${releaseTag(pkg)}`, + "Try reinstalling deskctl-cli or check that your target is supported." + ].join("\n") + ); + process.exit(1); + } + + const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" }); + child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); + }); +} + +main(); diff --git a/npm/deskctl-cli/package.json b/npm/deskctl-cli/package.json new file mode 100644 index 0000000..c1cdbbc --- /dev/null +++ b/npm/deskctl-cli/package.json @@ -0,0 +1,36 @@ +{ + "name": "deskctl-cli", + "version": "0.1.5", + "description": "Installable deskctl CLI package for Linux X11 agents", + "license": "MIT", + "homepage": "https://github.com/harivansh-afk/deskctl", + "repository": { + "type": "git", + "url": "git+https://github.com/harivansh-afk/deskctl.git" + }, + "bugs": { + "url": "https://github.com/harivansh-afk/deskctl/issues" + }, + "engines": { + "node": ">=18" + }, + "bin": { + "deskctl": "bin/deskctl.js" + }, + "files": [ + "README.md", + "bin", + "scripts" + ], + "scripts": { + "postinstall": "node scripts/postinstall.js", + "validate": "node scripts/validate-package.js" + }, + "keywords": [ + "deskctl", + "x11", + "desktop", + "automation", + "cli" + ] +} diff --git a/npm/deskctl-cli/scripts/postinstall.js b/npm/deskctl-cli/scripts/postinstall.js new file mode 100644 index 0000000..de1b1d0 --- /dev/null +++ b/npm/deskctl-cli/scripts/postinstall.js @@ -0,0 +1,49 @@ +const fs = require("node:fs"); + +const { + checksumsUrl, + checksumForAsset, + download, + ensureVendorDir, + installLocalBinary, + readPackageJson, + releaseAssetUrl, + releaseTag, + sha256, + supportedTarget, + vendorBinaryPath +} = require("./support"); + +async function main() { + const pkg = readPackageJson(); + const target = supportedTarget(); + const targetPath = vendorBinaryPath(target); + + ensureVendorDir(); + + if (process.env.DESKCTL_BINARY_PATH) { + installLocalBinary(process.env.DESKCTL_BINARY_PATH, targetPath); + return; + } + + const tag = releaseTag(pkg); + const assetUrl = releaseAssetUrl(tag, target.assetName); + const checksumText = (await download(checksumsUrl(tag))).toString("utf8"); + const expectedSha = checksumForAsset(checksumText, target.assetName); + const asset = await download(assetUrl); + const actualSha = sha256(asset); + + if (actualSha !== expectedSha) { + throw new Error( + `Checksum mismatch for ${target.assetName}. Expected ${expectedSha}, got ${actualSha}.` + ); + } + + fs.writeFileSync(targetPath, asset); + fs.chmodSync(targetPath, 0o755); +} + +main().catch((error) => { + console.error(`deskctl-cli install failed: ${error.message}`); + process.exit(1); +}); diff --git a/npm/deskctl-cli/scripts/support.js b/npm/deskctl-cli/scripts/support.js new file mode 100644 index 0000000..8d41520 --- /dev/null +++ b/npm/deskctl-cli/scripts/support.js @@ -0,0 +1,120 @@ +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const https = require("node:https"); + +const PACKAGE_ROOT = path.resolve(__dirname, ".."); +const VENDOR_DIR = path.join(PACKAGE_ROOT, "vendor"); +const PACKAGE_JSON = path.join(PACKAGE_ROOT, "package.json"); + +function readPackageJson() { + return JSON.parse(fs.readFileSync(PACKAGE_JSON, "utf8")); +} + +function releaseTag(pkg) { + return process.env.DESKCTL_RELEASE_TAG || `v${pkg.version}`; +} + +function supportedTarget(platform = process.platform, arch = process.arch) { + if (platform === "linux" && arch === "x64") { + return { + platform, + arch, + assetName: "deskctl-linux-x86_64", + binaryName: "deskctl-linux-x86_64" + }; + } + + throw new Error( + `deskctl-cli currently supports linux-x64 only. Received ${platform}-${arch}.` + ); +} + +function vendorBinaryPath(target) { + return path.join(VENDOR_DIR, target.binaryName); +} + +function releaseBaseUrl(tag) { + return ( + process.env.DESKCTL_RELEASE_BASE_URL || + `https://github.com/harivansh-afk/deskctl/releases/download/${tag}` + ); +} + +function releaseAssetUrl(tag, assetName) { + return process.env.DESKCTL_DOWNLOAD_URL || `${releaseBaseUrl(tag)}/${assetName}`; +} + +function checksumsUrl(tag) { + return `${releaseBaseUrl(tag)}/checksums.txt`; +} + +function ensureVendorDir() { + fs.mkdirSync(VENDOR_DIR, { recursive: true }); +} + +function checksumForAsset(contents, assetName) { + const line = contents + .split("\n") + .map((value) => value.trim()) + .find((value) => value.endsWith(` ${assetName}`) || value.endsWith(` *${assetName}`)); + + if (!line) { + throw new Error(`Could not find checksum entry for ${assetName}.`); + } + + return line.split(/\s+/)[0]; +} + +function sha256(buffer) { + return crypto.createHash("sha256").update(buffer).digest("hex"); +} + +function download(url) { + return new Promise((resolve, reject) => { + https + .get(url, (response) => { + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + response.resume(); + resolve(download(response.headers.location)); + return; + } + + if (response.statusCode !== 200) { + reject(new Error(`Download failed for ${url}: HTTP ${response.statusCode}`)); + return; + } + + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => resolve(Buffer.concat(chunks))); + }) + .on("error", reject); + }); +} + +function installLocalBinary(sourcePath, targetPath) { + fs.copyFileSync(sourcePath, targetPath); + fs.chmodSync(targetPath, 0o755); +} + +module.exports = { + PACKAGE_ROOT, + VENDOR_DIR, + checksumsUrl, + checksumForAsset, + download, + ensureVendorDir, + installLocalBinary, + readPackageJson, + releaseAssetUrl, + releaseTag, + sha256, + supportedTarget, + vendorBinaryPath +}; diff --git a/npm/deskctl-cli/scripts/validate-package.js b/npm/deskctl-cli/scripts/validate-package.js new file mode 100644 index 0000000..46d3e87 --- /dev/null +++ b/npm/deskctl-cli/scripts/validate-package.js @@ -0,0 +1,40 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const { readPackageJson, supportedTarget, vendorBinaryPath } = require("./support"); + +function readCargoVersion() { + const cargoToml = fs.readFileSync( + path.resolve(__dirname, "..", "..", "..", "Cargo.toml"), + "utf8" + ); + const match = cargoToml.match(/^version = "([^"]+)"/m); + if (!match) { + throw new Error("Could not determine Cargo.toml version."); + } + return match[1]; +} + +function main() { + const pkg = readPackageJson(); + const cargoVersion = readCargoVersion(); + + if (pkg.version !== cargoVersion) { + throw new Error( + `Version mismatch: npm package is ${pkg.version}, Cargo.toml is ${cargoVersion}.` + ); + } + + if (pkg.bin?.deskctl !== "bin/deskctl.js") { + throw new Error("deskctl-cli must expose the deskctl bin entrypoint."); + } + + const target = supportedTarget("linux", "x64"); + const targetPath = vendorBinaryPath(target); + const vendorDir = path.dirname(targetPath); + if (!vendorDir.endsWith(path.join("deskctl-cli", "vendor"))) { + throw new Error("Vendor binary directory resolved unexpectedly."); + } +} + +main();