diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18311e0..1c2e7f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,39 @@ name: CI -# Runners: uvacompute (https://uvacompute.com) -# To enable, set the UVA_RUNNER repo variable to the correct runner label. -# runs-on: ${{ vars.UVA_RUNNER || 'ubuntu-latest' }} - on: pull_request: branches: [main] push: branches: [main] workflow_dispatch: + inputs: + bump: + description: Version bump type (only for workflow_dispatch) + type: choice + options: + - patch + - minor + - major + default: patch + publish_npm: + description: Publish to npm + type: boolean + default: true + publish_crates: + description: Publish to crates.io + type: boolean + default: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true permissions: contents: write - packages: write jobs: changes: name: Changes - runs-on: ubuntu-latest + runs-on: [self-hosted, netty] outputs: rust: ${{ steps.check.outputs.rust }} version: ${{ steps.version.outputs.version }} @@ -37,7 +52,11 @@ jobs: - 'tests/**' - 'Cargo.toml' - 'Cargo.lock' + - 'npm/**' + - 'flake.nix' + - 'flake.lock' - 'docker/**' + - '.github/workflows/**' - 'Makefile' - name: Set outputs @@ -53,34 +72,36 @@ jobs: id: version if: github.event_name != 'pull_request' && steps.check.outputs.rust == 'true' run: | - BASE=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') - IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE" + CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" - LATEST=$(git tag -l "v${MAJOR}.${MINOR}.*" | sort -V | tail -1) - - if [ -z "$LATEST" ]; then - NEW="$BASE" - else - LATEST_VER="${LATEST#v}" - IFS='.' read -r _ _ LATEST_PATCH <<< "$LATEST_VER" - NEW_PATCH=$((LATEST_PATCH + 1)) - NEW="${MAJOR}.${MINOR}.${NEW_PATCH}" - fi - - # Ensure the computed version does not already have a tag - while git rev-parse "v${NEW}" >/dev/null 2>&1; do - IFS='.' read -r MAJOR MINOR PATCH <<< "$NEW" - NEW="${MAJOR}.${MINOR}.$((PATCH + 1))" - done + BUMP="${{ inputs.bump || 'patch' }}" + case "$BUMP" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) + LATEST=$(git tag -l "v${MAJOR}.${MINOR}.*" | sort -V | tail -1) + if [ -z "$LATEST" ]; then + NEW_PATCH=$PATCH + else + LATEST_VER="${LATEST#v}" + IFS='.' read -r _ _ LATEST_PATCH <<< "$LATEST_VER" + NEW_PATCH=$((LATEST_PATCH + 1)) + fi + PATCH=$NEW_PATCH + ;; + esac + NEW="${MAJOR}.${MINOR}.${PATCH}" echo "version=${NEW}" >> "$GITHUB_OUTPUT" echo "tag=v${NEW}" >> "$GITHUB_OUTPUT" + echo "Computed version: ${NEW} (v${NEW})" validate: name: Validate needs: changes if: needs.changes.outputs.rust == 'true' - runs-on: ubuntu-latest + runs-on: [self-hosted, netty] steps: - uses: actions/checkout@v4 @@ -104,9 +125,6 @@ jobs: - name: Install site dependencies run: pnpm --dir site install --frozen-lockfile - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev - - name: Format check run: make fmt-check @@ -123,7 +141,7 @@ jobs: name: Integration (Xvfb) needs: changes if: needs.changes.outputs.rust == 'true' - runs-on: ubuntu-latest + runs-on: [self-hosted, netty] steps: - uses: actions/checkout@v4 @@ -131,79 +149,35 @@ jobs: - uses: Swatinem/rust-cache@v2 - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev xvfb - - 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' - runs-on: ubuntu-latest - strategy: - fail-fast: true - matrix: - target: [cargo, docker] + distribution: + name: Distribution Validate + needs: changes + if: needs.changes.outputs.rust == 'true' + runs-on: [self-hosted, netty] 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' - - 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' + - uses: actions/setup-node@v4 with: - name: deskctl-linux-x86_64 - path: target/release/deskctl - retention-days: 7 + node-version: 22 - # --- Docker steps --- - - uses: docker/setup-buildx-action@v3 - if: matrix.target == 'docker' + - name: Distribution validation + run: make dist-validate - - 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 + # --- Release pipeline: update-manifests -> build -> release -> publish --- + # These stay on ubuntu-latest for artifact upload/download and registry publishing. update-manifests: name: Update Manifests - needs: [changes, build] - if: github.event_name != 'pull_request' + needs: [changes, validate, integration, distribution] + if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -212,7 +186,11 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - name: Update version in Cargo.toml + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Update versions run: | CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') NEW="${{ needs.changes.outputs.version }}" @@ -220,26 +198,70 @@ jobs: sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml cargo generate-lockfile fi + node -e ' + const fs = require("node:fs"); + const p = "npm/deskctl/package.json"; + const pkg = JSON.parse(fs.readFileSync(p, "utf8")); + pkg.version = process.argv[1]; + fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + "\n"); + ' "$NEW" - name: Commit, tag, and push run: | git config user.name "github-actions[bot]" 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/package.json + if ! git diff --cached --quiet; then git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]" fi - - if ! git rev-parse "${{ needs.changes.outputs.tag }}" >/dev/null 2>&1; then - git tag "${{ needs.changes.outputs.tag }}" - fi + git tag "${{ needs.changes.outputs.tag }}" 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 }} + + - 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: Verify version + run: | + CARGO_VER=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + EXPECTED="${{ needs.changes.outputs.version }}" + if [ "$CARGO_VER" != "$EXPECTED" ]; then + echo "Version mismatch: Cargo.toml=$CARGO_VER expected=$EXPECTED" + exit 1 + fi + echo "Building version $CARGO_VER" + + - 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] - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -256,9 +278,87 @@ jobs: chmod +x artifacts/deskctl mv artifacts/deskctl artifacts/deskctl-linux-x86_64 cd artifacts && sha256sum deskctl-linux-x86_64 > checksums.txt && cd .. + 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 - gh release create "${{ needs.changes.outputs.tag }}" \ - --title "${{ needs.changes.outputs.tag }}" \ - --generate-notes \ - artifacts/deskctl-linux-x86_64 \ - artifacts/checksums.txt + publish-npm: + name: Publish npm + needs: [changes, update-manifests, release] + if: >- + github.event_name != 'pull_request' + && needs.changes.outputs.rust == 'true' + && (inputs.publish_npm == true || inputs.publish_npm == '') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.changes.outputs.tag }} + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Check if already published + id: published + run: | + VERSION="${{ needs.changes.outputs.version }}" + if npm view "deskctl@${VERSION}" version >/dev/null 2>&1; then + echo "npm=true" >> "$GITHUB_OUTPUT" + else + echo "npm=false" >> "$GITHUB_OUTPUT" + fi + + - name: Validate npm package + if: steps.published.outputs.npm != 'true' + run: node npm/deskctl/scripts/validate-package.js + + - name: Publish npm + if: steps.published.outputs.npm != 'true' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish ./npm/deskctl --access public + + publish-crates: + name: Publish crates.io + needs: [changes, update-manifests, release] + if: >- + github.event_name != 'pull_request' + && needs.changes.outputs.rust == 'true' + && (inputs.publish_crates == true || inputs.publish_crates == '') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.changes.outputs.tag }} + + - uses: dtolnay/rust-toolchain@stable + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev + + - name: Check if already published + id: published + run: | + VERSION="${{ needs.changes.outputs.version }}" + 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: Publish crates.io + if: steps.published.outputs.crates != 'true' + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish --locked diff --git a/.gitignore b/.gitignore index 7406874..40542a9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ secret/ .claude/ .codex/ openspec/ +npm/deskctl/vendor/ +npm/deskctl/*.tgz diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a1a2a2..97e8c7c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ pnpm --dir site install - `src/` holds production code and unit tests - `tests/` holds integration tests - `tests/support/` holds shared X11 and daemon helpers for integration coverage -- `docs/runtime-output.md` is the stable-vs-best-effort runtime output contract for agent-facing CLI work +- `docs/runtime-contract.md` is the stable-vs-best-effort runtime output contract for agent-facing CLI work Keep integration-only helpers out of `src/`. @@ -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` +- 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.lock b/Cargo.lock index 1355d04..eb0e2ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 [[package]] name = "ab_glyph" @@ -241,9 +241,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "deskctl" -version = "0.1.5" +version = "0.1.14" dependencies = [ "ab_glyph", "anyhow", @@ -911,9 +911,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ "once_cell", "wasm-bindgen", @@ -1039,9 +1039,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -1699,9 +1699,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd_helpers" @@ -1861,9 +1861,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -1907,9 +1907,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ "cfg-if", "once_cell", @@ -1920,9 +1920,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1930,9 +1930,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ "bumpalo", "proc-macro2", @@ -1943,9 +1943,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" dependencies = [ "unicode-ident", ] @@ -2297,9 +2297,9 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index 023e18a..be051c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,23 @@ [package] name = "deskctl" -version = "0.1.5" +version = "0.1.14" 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..7e1f852 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/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 --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..dccbe04 100644 --- a/README.md +++ b/README.md @@ -1,213 +1,46 @@ # deskctl +[![npm](https://img.shields.io/npm/v/deskctl?label=npm)](https://www.npmjs.com/package/deskctl) +[![skill](https://img.shields.io/badge/skills.sh-deskctl-111827)](skills/deskctl) + +Desktop control cli for AI agents on X11. + +https://github.com/user-attachments/assets/e820787e-4d1a-463f-bdcf-a829588778bf -Desktop control CLI for AI agents on Linux X11. ## Install ```bash -cargo install deskctl +npm install -g deskctl ``` -Build a Linux binary with Docker: - ```bash -docker compose -f docker/docker-compose.yml run --rm build +deskctl doctor +deskctl snapshot --annotate ``` -This writes `dist/deskctl-linux-x86_64`. - -Copy it to an SSH machine where `scp` is unavailable: +## Skill ```bash -ssh -p 443 deskctl@ssh.agentcomputer.ai 'cat > ~/deskctl && chmod +x ~/deskctl' < dist/deskctl-linux-x86_64 +npx skills add harivansh-afk/deskctl ``` -Run it on an X11 session: +## Docs + +- runtime contract: [docs/runtime-contract.md](docs/runtime-contract.md) +- releasing: [docs/releasing.md](docs/releasing.md) +- contributing: [CONTRIBUTING.md](CONTRIBUTING.md) + +## Install paths + +Nix: ```bash -DISPLAY=:1 XDG_SESSION_TYPE=x11 ~/deskctl --json snapshot --annotate +nix run github:harivansh-afk/deskctl -- --help +nix profile install github:harivansh-afk/deskctl ``` -Local source build requirements: +Rust: + ```bash cargo build ``` - -At the moment there are no extra native build dependencies beyond a Rust toolchain. - -## Quick Start - -```bash -# Diagnose the environment first -deskctl doctor - -# See the desktop -deskctl snapshot - -# Query focused runtime state -deskctl get active-window -deskctl get monitors - -# Click a window -deskctl click @w1 - -# Type text -deskctl type "hello world" - -# Wait for a window or focus transition -deskctl wait window --selector 'title=Firefox' --timeout 10 -deskctl wait focus --selector 'class=firefox' --timeout 5 - -# Focus by explicit selector -deskctl focus 'title=Firefox' -``` - -## Architecture - -Client-daemon architecture over Unix sockets (NDJSON wire protocol). -The daemon starts automatically on first command and keeps the X11 connection alive for fast repeated calls. - -Source layout: - -- `src/lib.rs` exposes the shared library target -- `src/main.rs` is the thin CLI wrapper -- `src/` contains production code and unit tests -- `tests/` contains Linux/X11 integration tests -- `tests/support/` contains shared integration helpers - -## Runtime Requirements - -- Linux with X11 session -- Rust 1.75+ (for build) - -The binary itself only links the standard glibc runtime on Linux (`libc`, `libm`, `libgcc_s`). - -For deskctl to be fully functional on a fresh VM you still need: - -- an X11 server and an active `DISPLAY` -- `XDG_SESSION_TYPE=x11` or an equivalent X11 session environment -- a window manager or desktop environment that exposes standard EWMH properties such as `_NET_CLIENT_LIST_STACKING` and `_NET_ACTIVE_WINDOW` -- an X server with the extensions needed for input simulation and screen metadata, which is standard on normal desktop X11 setups - -If setup fails, run: - -```bash -deskctl doctor -``` - -## Contract Notes - -- `@wN` refs are short-lived handles assigned by `snapshot` and `list-windows` -- `--json` output includes a stable `window_id` for programmatic targeting within the current daemon session -- `list-windows` is a cheap read-only operation and does not capture or write a screenshot -- the stable runtime JSON/error contract is documented in [docs/runtime-output.md](docs/runtime-output.md) - -## Read and Wait Surface - -The grouped runtime reads are: - -```bash -deskctl get active-window -deskctl get monitors -deskctl get version -deskctl get systeminfo -``` - -The grouped runtime waits are: - -```bash -deskctl wait window --selector 'title=Firefox' --timeout 10 -deskctl wait focus --selector 'id=win3' --timeout 5 -``` - -Successful `get active-window`, `wait window`, and `wait focus` responses return a `window` payload with: -- `ref_id` -- `window_id` -- `title` -- `app_name` -- geometry (`x`, `y`, `width`, `height`) -- state flags (`focused`, `minimized`) - -`get monitors` returns: -- `count` -- `monitors[]` with geometry and primary/automatic flags - -`get version` returns: -- `version` -- `backend` - -`get systeminfo` stays runtime-scoped and returns: -- `backend` -- `display` -- `session_type` -- `session` -- `socket_path` -- `screen` -- `monitor_count` -- `monitors` - -Wait timeout and selector failures are structured in `--json` mode so agents can recover without string parsing. - -## Output Policy - -Text mode is compact and follow-up-oriented, but JSON is the parsing contract. - -- use `--json` when an agent needs strict parsing -- rely on `window_id`, selector-related fields, grouped read payloads, and structured error `kind` values for stable automation -- treat monitor naming, incidental whitespace, and default screenshot file names as best-effort - -See [docs/runtime-output.md](docs/runtime-output.md) for the exact stable-vs-best-effort breakdown. - -## Selector Contract - -Explicit selector modes: - -```bash -ref=w1 -id=win1 -title=Firefox -class=firefox -focused -``` - -Legacy refs remain supported: - -```bash -@w1 -w1 -win1 -``` - -Bare selectors such as `firefox` are still supported as fuzzy substring matches, but they now fail on ambiguity and return candidate windows instead of silently picking the first match. - -## Support Boundary - -`deskctl` supports Linux X11 in this phase. Wayland and Hyprland are explicitly out of scope for the current runtime contract. - -## Workflow - -Local validation uses the root `Makefile`: - -```bash -make fmt-check -make lint -make test-unit -make test-integration -make site-format-check -make validate -``` - -`make validate` is the full repo-quality check and requires Linux with `xvfb-run` plus `pnpm --dir site install`. - -The repository standardizes on `pre-commit` for fast commit-time checks: - -```bash -pre-commit install -pre-commit run --all-files -``` - -See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contributor guide. - -## Acknowledgements - -- [@barrettruth](github.com/barrettruth) - i stole the website from [vimdoc](https://github.com/barrettruth/vimdoc-language-server) diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..70ac230 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,969 @@ + + + + + +deskctl - Desktop Control for AI Agents + + + + +
+

deskctl

+

desktop control CLI for AI agents

+
+ +
+
+
+
+
+
+
+
+
+ + +
+
+
+ Files ~/reports +
+
+
+
+ 📝 + task_brief.txt + 2.1 KB +
+
+ 📊 + nvda_q1_data.csv + 48 KB +
+
+ 📄 + prev_report.pdf + 1.2 MB +
+
+ 📁 + archive/ + -- +
+
+
+ task: Prepare NVDA Q1 earnings summary
+ source: finance.yahoo.com, local csv
+ output: Google Docs report with chart +
+
+
+ + +
+
+
+ Chrome - Yahoo Finance +
+
+
+ NVDA + $924.68 + +3.42% + 1Y +
+
+ + + + + + + + + + + + + $950 + $800 + $650 + +
+
+
+
+ + +
+
+
+ Chrome - Google Docs +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + NVDA 1Y + +
+
+
+
+ + +
@w1
+
@w2
+
@w3
+ +
+ +
+ +
+
+ + +
+
Files
+
Yahoo Finance
+
Google Docs
+
+
+
+ +
+
+
+
+
+ agent computer +
+
+
+
+ +
+

AI agent controlling a live desktop via deskctl

+ +
+ + + + diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..849d661 --- /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` +- installed command: `deskctl` + +## Prerequisites + +Before the first live publish on each registry: + +- npm ownership for `deskctl` +- 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 jobs (npm and crates.io run in parallel): + +- target an existing release tag +- check whether that version is already published on the respective registry +- skip already-published versions +- both default to enabled; can be toggled via workflow_dispatch inputs + +## 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/docs/runtime-contract.md b/docs/runtime-contract.md new file mode 100644 index 0000000..ee4727b --- /dev/null +++ b/docs/runtime-contract.md @@ -0,0 +1,70 @@ +# deskctl runtime contract + +All commands support `--json` and use the same top-level envelope: + +```json +{ + "success": true, + "data": {}, + "error": null +} +``` + +Use `--json` whenever you need to parse output programmatically. + +## Stable window fields + +Whenever a response includes a window payload, these fields are stable: + +- `ref_id` +- `window_id` +- `title` +- `app_name` +- `x` +- `y` +- `width` +- `height` +- `focused` +- `minimized` + +Use `window_id` for stable targeting inside a live daemon session. Use +`ref_id` or `@wN` for short-lived follow-up actions after `snapshot` or +`list-windows`. + +## Stable grouped reads + +- `deskctl get active-window` -> `data.window` +- `deskctl get monitors` -> `data.count`, `data.monitors` +- `deskctl get version` -> `data.version`, `data.backend` +- `deskctl get systeminfo` -> runtime-scoped diagnostic fields such as + `backend`, `display`, `session_type`, `session`, `socket_path`, `screen`, + `monitor_count`, and `monitors` + +## Stable waits + +- `deskctl wait window` -> `data.wait`, `data.selector`, `data.elapsed_ms`, + `data.window` +- `deskctl wait focus` -> `data.wait`, `data.selector`, `data.elapsed_ms`, + `data.window` + +## Stable structured error kinds + +When a command fails with structured JSON data, these `kind` values are stable: + +- `selector_not_found` +- `selector_ambiguous` +- `selector_invalid` +- `timeout` +- `not_found` + +Wait failures may also include `window_not_focused` in the last observation +payload. + +## Best-effort fields + +Treat these as useful but non-contractual: + +- exact monitor names +- incidental text formatting in non-JSON mode +- default screenshot file names when no explicit path was provided +- environment-dependent ordering details from the window manager diff --git a/docs/runtime-output.md b/docs/runtime-output.md deleted file mode 100644 index 7312357..0000000 --- a/docs/runtime-output.md +++ /dev/null @@ -1,178 +0,0 @@ -# Runtime Output Contract - -This document defines the current output contract for `deskctl`. - -It is intentionally scoped to the current Linux X11 runtime surface. -It does not promise stability for future Wayland or window-manager-specific features. - -## Goals - -- Keep `deskctl` fully non-interactive -- Make text output actionable for quick terminal and agent loops -- Make `--json` safe for agent consumption without depending on incidental formatting - -## JSON Envelope - -Every runtime command uses the same top-level JSON envelope: - -```json -{ - "success": true, - "data": {}, - "error": null -} -``` - -Stable top-level fields: - -- `success` -- `data` -- `error` - -`success` is always the authoritative success/failure bit. -When `success` is `false`, the CLI exits non-zero in both text mode and `--json` mode. - -## Stable Fields - -These fields are stable for agent consumption in the current Phase 1 runtime contract. - -### Window Identity - -Whenever a runtime response includes a window payload, these fields are stable: - -- `ref_id` -- `window_id` -- `title` -- `app_name` -- `x` -- `y` -- `width` -- `height` -- `focused` -- `minimized` - -`window_id` is the stable public identifier for a live daemon session. -`ref_id` is a short-lived convenience handle for the current window snapshot/ref map. - -### Grouped Reads - -`deskctl get active-window` - -- stable: `data.window` - -`deskctl get monitors` - -- stable: `data.count` -- stable: `data.monitors` -- stable per monitor: - - `name` - - `x` - - `y` - - `width` - - `height` - - `width_mm` - - `height_mm` - - `primary` - - `automatic` - -`deskctl get version` - -- stable: `data.version` -- stable: `data.backend` - -`deskctl get systeminfo` - -- stable: `data.backend` -- stable: `data.display` -- stable: `data.session_type` -- stable: `data.session` -- stable: `data.socket_path` -- stable: `data.screen` -- stable: `data.monitor_count` -- stable: `data.monitors` - -### Waits - -`deskctl wait window` -`deskctl wait focus` - -- stable: `data.wait` -- stable: `data.selector` -- stable: `data.elapsed_ms` -- stable: `data.window` - -### Selector-Driven Action Success - -For selector-driven action commands that resolve a window target, these identifiers are stable when present: - -- `data.ref_id` -- `data.window_id` -- `data.title` -- `data.selector` - -This applies to: - -- `click` -- `dblclick` -- `focus` -- `close` -- `move-window` -- `resize-window` - -The exact human-readable text rendering of those commands is not part of the JSON contract. - -### Artifact-Producing Commands - -`snapshot` -`screenshot` - -- stable: `data.screenshot` - -When the command also returns windows, `data.windows` uses the stable window payload documented above. - -## Stable Structured Error Kinds - -When a runtime command returns structured JSON failure data, these error kinds are stable: - -- `selector_not_found` -- `selector_ambiguous` -- `selector_invalid` -- `timeout` -- `not_found` -- `window_not_focused` as `data.last_observation.kind` or equivalent observation payload - -Stable structured failure fields include: - -- `data.kind` -- `data.selector` when selector-related -- `data.mode` when selector-related -- `data.candidates` for ambiguous selector failures -- `data.message` for invalid selector failures -- `data.wait` -- `data.timeout_ms` -- `data.poll_ms` -- `data.last_observation` - -## Best-Effort Fields - -These values are useful but environment-dependent and should be treated as best-effort: - -- exact monitor naming conventions -- EWMH/window-manager-dependent window ordering details -- cosmetic text formatting in non-JSON mode -- screenshot file names when the caller did not provide an explicit path -- command stderr wording outside the structured `kind` classifications above - -## Text Mode Expectations - -Text mode is intended to stay compact and follow-up-useful. - -The exact whitespace/alignment of text output is not stable. -The following expectations are stable at the behavioral level: - -- important runtime reads print actionable identifiers or geometry -- selector failures print enough detail to recover without `--json` -- artifact-producing commands print the artifact path -- window listings print both `@wN` refs and `window_id` values - -If an agent needs strict parsing, it should use `--json`. 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/README.md b/npm/deskctl/README.md new file mode 100644 index 0000000..81f07f4 --- /dev/null +++ b/npm/deskctl/README.md @@ -0,0 +1,48 @@ +# deskctl + +`deskctl` installs the command for Linux X11 systems. + +## Install + +```bash +npm install -g deskctl +``` + +After install, run: + +```bash +deskctl --help +``` + +To upgrade version: + +```bash +deskctl upgrade +``` + +For non-interactive use: + +```bash +deskctl upgrade --yes +``` + +One-shot usage is also supported: + +```bash +npx deskctl --help +``` + +## Runtime Support + +- Linux +- X11 session +- currently packaged release asset: `linux-x64` + +`deskctl` 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/bin/deskctl.js b/npm/deskctl/bin/deskctl.js new file mode 100644 index 0000000..b8514cf --- /dev/null +++ b/npm/deskctl/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 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/package.json b/npm/deskctl/package.json new file mode 100644 index 0000000..c676924 --- /dev/null +++ b/npm/deskctl/package.json @@ -0,0 +1,36 @@ +{ + "name": "deskctl", + "version": "0.1.14", + "description": "Installable deskctl 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/scripts/postinstall.js b/npm/deskctl/scripts/postinstall.js new file mode 100644 index 0000000..1f43ad0 --- /dev/null +++ b/npm/deskctl/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 install failed: ${error.message}`); + process.exit(1); +}); diff --git a/npm/deskctl/scripts/support.js b/npm/deskctl/scripts/support.js new file mode 100644 index 0000000..1fd0d47 --- /dev/null +++ b/npm/deskctl/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 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/scripts/validate-package.js b/npm/deskctl/scripts/validate-package.js new file mode 100644 index 0000000..450fd6c --- /dev/null +++ b/npm/deskctl/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 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", "vendor"))) { + throw new Error("Vendor binary directory resolved unexpectedly."); + } +} + +main(); diff --git a/site/src/layouts/DocLayout.astro b/site/src/layouts/DocLayout.astro index f2608de..afc8648 100644 --- a/site/src/layouts/DocLayout.astro +++ b/site/src/layouts/DocLayout.astro @@ -30,7 +30,7 @@ function formatTocText(text: string): string { { !isIndex && ( -