npm
cargo
This commit is contained in:
Harivansh Rathi 2026-03-25 22:48:04 -04:00
parent 425a71095a
commit 511f21c7ba
16 changed files with 849 additions and 66 deletions

View file

@ -13,7 +13,6 @@ on:
permissions: permissions:
contents: write contents: write
packages: write
jobs: jobs:
changes: changes:
@ -37,7 +36,11 @@ jobs:
- 'tests/**' - 'tests/**'
- 'Cargo.toml' - 'Cargo.toml'
- 'Cargo.lock' - 'Cargo.lock'
- 'npm/**'
- 'flake.nix'
- 'flake.lock'
- 'docker/**' - 'docker/**'
- '.github/workflows/**'
- 'Makefile' - 'Makefile'
- name: Set outputs - name: Set outputs
@ -137,72 +140,36 @@ jobs:
- name: Xvfb integration tests - name: Xvfb integration tests
run: make test-integration run: make test-integration
build: distribution:
name: Build (${{ matrix.target }}) name: Distribution Validate
needs: [changes, validate, integration] needs: changes
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true' if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
target: [cargo, docker]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# --- Cargo steps ---
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
if: matrix.target == 'cargo'
with:
components: clippy
- uses: Swatinem/rust-cache@v2 - 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 - name: Install system dependencies
if: matrix.target == 'cargo'
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
- name: Clippy - name: Distribution validation
if: matrix.target == 'cargo' run: make dist-validate
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
update-manifests: update-manifests:
name: Update Manifests name: Update Manifests
needs: [changes, build] needs: [changes, validate, integration, distribution]
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -212,12 +179,17 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Update version in Cargo.toml - name: Update version in Cargo.toml
run: | run: |
CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
NEW="${{ needs.changes.outputs.version }}" NEW="${{ needs.changes.outputs.version }}"
if [ "$CURRENT" != "$NEW" ]; then if [ "$CURRENT" != "$NEW" ]; then
sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml 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 cargo generate-lockfile
fi fi
@ -227,7 +199,7 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
if ! git diff --quiet; then 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]" git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]"
fi fi
@ -236,6 +208,38 @@ jobs:
fi fi
git push origin main --tags 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: release:
name: Release name: Release
needs: [changes, build, update-manifests] needs: [changes, build, update-manifests]
@ -256,9 +260,15 @@ jobs:
chmod +x artifacts/deskctl chmod +x artifacts/deskctl
mv artifacts/deskctl artifacts/deskctl-linux-x86_64 mv artifacts/deskctl artifacts/deskctl-linux-x86_64
cd artifacts && sha256sum deskctl-linux-x86_64 > checksums.txt && cd .. 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 create "${{ needs.changes.outputs.tag }}" \ gh release upload "${{ needs.changes.outputs.tag }}" \
--title "${{ needs.changes.outputs.tag }}" \ artifacts/deskctl-linux-x86_64 \
--generate-notes \ artifacts/checksums.txt \
artifacts/deskctl-linux-x86_64 \ --clobber
artifacts/checksums.txt else
gh release create "${{ needs.changes.outputs.tag }}" \
--title "${{ needs.changes.outputs.tag }}" \
--generate-notes \
artifacts/deskctl-linux-x86_64 \
artifacts/checksums.txt
fi

102
.github/workflows/publish.yml vendored Normal file
View file

@ -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 }}"

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ secret/
.claude/ .claude/
.codex/ .codex/
openspec/ openspec/
npm/deskctl-cli/vendor/
npm/deskctl-cli/*.tgz

View file

@ -35,10 +35,15 @@ make lint
make test-unit make test-unit
make test-integration make test-integration
make site-format-check make site-format-check
make cargo-publish-dry-run
make npm-package-check
make nix-flake-check
make dist-validate
make validate make validate
``` ```
`make validate` runs the full Phase 2 validation stack. It requires Linux, `xvfb-run`, and site dependencies to be installed. `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 ## Pre-commit Hooks
@ -60,6 +65,19 @@ The hook config intentionally stays small:
- Site files reuse the existing `site/` Prettier setup - Site files reuse the existing `site/` Prettier setup
- Slower checks stay in CI or `make validate` - 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 Tests
Integration coverage is Linux/X11-only in this phase. The supported local entrypoint is: Integration coverage is Linux/X11-only in this phase. The supported local entrypoint is:

View file

@ -5,6 +5,19 @@ edition = "2021"
description = "X11 desktop control CLI for agents" description = "X11 desktop control CLI for agents"
license = "MIT" license = "MIT"
repository = "https://github.com/harivansh-afk/deskctl" 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] [dependencies]
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4", features = ["derive", "env"] }

View file

@ -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: fmt:
cargo fmt --all cargo fmt --all
@ -30,4 +30,34 @@ site-format-check:
fi fi
pnpm --dir site format:check 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 validate: fmt-check lint test-unit test-integration site-format-check

View file

@ -4,11 +4,45 @@ Desktop control CLI for AI agents on Linux X11.
## Install ## Install
### Cargo
```bash ```bash
cargo install deskctl 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 ```bash
docker compose -f docker/docker-compose.yml run --rm build 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 DISPLAY=:1 XDG_SESSION_TYPE=x11 ~/deskctl --json snapshot --annotate
``` ```
Local source build requirements: ### Local Source Build
```bash ```bash
cargo build cargo build
``` ```
At the moment there are no extra native build dependencies beyond a Rust toolchain.
## Quick Start ## Quick Start
```bash ```bash
@ -78,7 +111,7 @@ Source layout:
## Runtime Requirements ## Runtime Requirements
- Linux with X11 session - 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`). 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. 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 ## Selector Contract
Explicit selector modes: Explicit selector modes:

110
docs/releasing.md Normal file
View file

@ -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

61
flake.lock generated Normal file
View file

@ -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
}

77
flake.nix Normal file
View file

@ -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
];
};
}
);
}

36
npm/deskctl-cli/README.md Normal file
View file

@ -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
```

View file

@ -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();

View file

@ -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"
]
}

View file

@ -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);
});

View file

@ -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
};

View file

@ -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();