From a2061213589ea0f74166e9df8e1723ff48b2b73b Mon Sep 17 00:00:00 2001 From: cmorten Date: Tue, 1 Sep 2026 10:18:52 +0100 Subject: [PATCH 1/5] feat: linux setup --- .github/workflows/test.yml | 49 ++++++++++++++++++- src/commands/setup/index.ts | 6 +++ .../setup/linux/getLinuxDistribution.ts | 44 +++++++++++++++++ src/commands/setup/linux/installPackages.ts | 49 +++++++++++++++++++ src/commands/setup/linux/setup.ts | 11 +++++ .../setup/linux/validateLinuxDistribution.ts | 18 +++++++ src/errors.ts | 9 ++++ src/logging.ts | 2 +- 8 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 src/commands/setup/linux/getLinuxDistribution.ts create mode 100644 src/commands/setup/linux/installPackages.ts create mode 100644 src/commands/setup/linux/setup.ts create mode 100644 src/commands/setup/linux/validateLinuxDistribution.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 91a9832..15796cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,8 @@ jobs: windows-2022, windows-2025, windows-11-arm, + ubuntu-26.04, + ubuntu-26.04-arm, ] steps: - uses: actions/checkout@v6 @@ -38,6 +40,7 @@ jobs: name: artifacts-test-${{ matrix.os }} path: | **/recordings/**/* + test-setup-ignore-tcc-db: runs-on: ${{ matrix.os }} strategy: @@ -57,6 +60,32 @@ jobs: path: | **/recordings/**/* + test-setup-unsupported: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-24.04] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + - run: yarn install --frozen-lockfile + - name: Setup should fail + run: | + set +e + + yarn setup + exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + echo "Expected setup to fail, but it succeeded." + + exit 1 + fi + + echo "Setup correctly failed with exit code $exit_code." + test-install: runs-on: ${{ matrix.os }} strategy: @@ -71,7 +100,10 @@ jobs: windows-2022, windows-2025, windows-11-arm, - ubuntu-latest, + ubuntu-24.04, + ubuntu-24.04-arm, + ubuntu-26.04, + ubuntu-26.04-arm, ] steps: - uses: actions/checkout@v6 @@ -93,6 +125,21 @@ jobs: { "version": 1, "screenReaders": [ + { + "id": "orca", + "name": "Orca", + "platforms": ["linux"], + "defaultDownload": true, + "assets": [ + { + "version": "0.1.1-50.2", + "platformVersion": "6", + "repository": "guidepup/orca", + "asset": "guidepup-orca-0.1.1-50.2.tar.gz", + "sha256": "36e87abb0fc7fca178128a95c245a81644aae73109e8dd2beb7c84b05b878494" + } + ] + }, { "id": "nvda", "name": "NVDA", diff --git a/src/commands/setup/index.ts b/src/commands/setup/index.ts index 236b150..aaba3de 100644 --- a/src/commands/setup/index.ts +++ b/src/commands/setup/index.ts @@ -1,6 +1,7 @@ import { platform } from "node:os"; import { Command } from "commander"; import { setup as setupMacOS } from "./macOS/setup"; +import { setup as setupLinux } from "./linux/setup"; import { ERR_SETUP_UNSUPPORTED_OS } from "../../errors"; import { handleSetupError, handleSetupComplete } from "../../logging"; @@ -23,6 +24,11 @@ async function setup(options: SetupCommandOptions): Promise { break; } + case "linux": { + await setupLinux(); + + break; + } default: { throw new Error(ERR_SETUP_UNSUPPORTED_OS); } diff --git a/src/commands/setup/linux/getLinuxDistribution.ts b/src/commands/setup/linux/getLinuxDistribution.ts new file mode 100644 index 0000000..051c3a9 --- /dev/null +++ b/src/commands/setup/linux/getLinuxDistribution.ts @@ -0,0 +1,44 @@ +import { readFile } from "node:fs/promises"; +import { ERR_SETUP_LINUX_GET_DISTRIBUTION } from "../../../errors"; + +export interface LinuxDistribution { + id: string; + versionId: string; +} + +export async function getLinuxDistribution(): Promise { + try { + const osRelease = await readFile("/etc/os-release", "utf8"); + + const values = Object.fromEntries( + osRelease + .split("\n") + .filter((line) => line.includes("=")) + .map((line) => { + const separatorIndex = line.indexOf("="); + + return [ + line.slice(0, separatorIndex), + line.slice(separatorIndex + 1).replace(/^"|"$/g, ""), + ]; + }), + ); + + if (!values.ID) { + throw new Error("Missing required `ID` field in /etc/os-release."); + } + + if (!values.VERSION_ID) { + throw new Error( + "Missing required `VERSION_ID` fields in /etc/os-release.", + ); + } + + return { + id: values.ID, + versionId: values.VERSION_ID, + }; + } catch (cause) { + throw new Error(ERR_SETUP_LINUX_GET_DISTRIBUTION, { cause }); + } +} diff --git a/src/commands/setup/linux/installPackages.ts b/src/commands/setup/linux/installPackages.ts new file mode 100644 index 0000000..ca1b209 --- /dev/null +++ b/src/commands/setup/linux/installPackages.ts @@ -0,0 +1,49 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { + ERR_SETUP_LINUX_INSTALL_PACKAGES, + ERR_SETUP_LINUX_UPDATE_PACKAGES, +} from "../../../errors"; + +const execFileAsync = promisify(execFile); + +const LINUX_PACKAGES = [ + "orca", + "xvfb", + "dbus-x11", + "libglib2.0-bin", + "pulseaudio", +] as const; + +function getAptCommand(): { + command: string; + args: string[]; +} { + if (process.getuid?.() === 0) { + return { + command: "apt-get", + args: [], + }; + } + + return { + command: "sudo", + args: ["apt-get"], + }; +} + +export async function installPackages(): Promise { + const { command, args } = getAptCommand(); + + try { + await execFileAsync(command, [...args, "update"]); + } catch (cause) { + throw new Error(ERR_SETUP_LINUX_UPDATE_PACKAGES, { cause }); + } + + try { + await execFileAsync(command, [...args, "install", "-y", ...LINUX_PACKAGES]); + } catch (cause) { + throw new Error(ERR_SETUP_LINUX_INSTALL_PACKAGES, { cause }); + } +} diff --git a/src/commands/setup/linux/setup.ts b/src/commands/setup/linux/setup.ts new file mode 100644 index 0000000..ac4f030 --- /dev/null +++ b/src/commands/setup/linux/setup.ts @@ -0,0 +1,11 @@ +import { getLinuxDistribution } from "./getLinuxDistribution"; +import { installPackages } from "./installPackages"; +import { validateLinuxDistribution } from "./validateLinuxDistribution"; + +export async function setup(): Promise { + const distribution = await getLinuxDistribution(); + + validateLinuxDistribution(distribution); + + await installPackages(); +} diff --git a/src/commands/setup/linux/validateLinuxDistribution.ts b/src/commands/setup/linux/validateLinuxDistribution.ts new file mode 100644 index 0000000..8711994 --- /dev/null +++ b/src/commands/setup/linux/validateLinuxDistribution.ts @@ -0,0 +1,18 @@ +import { ERR_SETUP_LINUX_UNSUPPORTED_DISTRIBUTION } from "../../../errors"; +import type { LinuxDistribution } from "./getLinuxDistribution"; + +export function validateLinuxDistribution({ + id, + versionId, +}: LinuxDistribution): void { + const version = parseInt(versionId.split(".")[0]); + + const supported = + (id === "ubuntu" && version >= 26) || (id === "debian" && version >= 13); + + if (!supported) { + throw new Error( + `${ERR_SETUP_LINUX_UNSUPPORTED_DISTRIBUTION}\n\n\tID: ${id}\n\tVERSION: ${version}`, + ); + } +} diff --git a/src/errors.ts b/src/errors.ts index 37b6b60..ddce01c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -24,6 +24,15 @@ export const ERR_SETUP_MACOS_UNABLE_TO_STOP_VOICEOVER = export const ERR_SETUP_MACOS_UNABLE_TO_FIND_VOICEOVER_PREFERENCES = "Unable to find VoiceOver preferences"; +export const ERR_SETUP_LINUX_GET_DISTRIBUTION = + "Failed to determine the Linux distribution."; +export const ERR_SETUP_LINUX_UNSUPPORTED_DISTRIBUTION = + "Unsupported Linux OS - consider contributing to Guidepup?"; +export const ERR_SETUP_LINUX_UPDATE_PACKAGES = + "Failed to update Linux package information."; +export const ERR_SETUP_LINUX_INSTALL_PACKAGES = + "Failed to install Linux packages."; + export const ERR_INSTALL_GUIDEPUP_PACKAGE_NOT_FOUND = "No local installation of '@guidepup/guidepup' was found\n\nPlease install '@guidepup/guidepup' first before running the CLI install"; export const ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID = diff --git a/src/logging.ts b/src/logging.ts index 9067537..0389d68 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -17,7 +17,7 @@ export function handleNoInstallation(): never { ); logInfo(""); - process.exit(0); + process.exit(1); } export function handleInstallComplete(): never { From 4cf4267a1d81038f07e5997f6659b0a1aedddee7 Mon Sep 17 00:00:00 2001 From: cmorten Date: Tue, 1 Sep 2026 10:25:19 +0100 Subject: [PATCH 2/5] feat: support .tar.gz assets --- src/commands/install/download-assets.ts | 21 +++++++++++ src/commands/install/extract-tar-gz.ts | 46 +++++++++++++++++++++++++ src/commands/install/extract-zip.ts | 12 +++---- 3 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 src/commands/install/extract-tar-gz.ts diff --git a/src/commands/install/download-assets.ts b/src/commands/install/download-assets.ts index 8494be6..cb9b264 100644 --- a/src/commands/install/download-assets.ts +++ b/src/commands/install/download-assets.ts @@ -13,6 +13,7 @@ import { import { handleInfoWithPath } from "../../logging"; import { getPlatformVersion } from "./get-platform-version"; import { platformMajorVersion } from "../../platform-major-version"; +import { extractTarGz } from "./extract-tar-gz"; function selectAsset(screenReader: ScreenReader): Asset { const currentPlatform = platform(); @@ -101,6 +102,26 @@ async function downloadScreenReader( await extractZip(asset, downloadDestination, unzipDestination); + handleInfoWithPath( + `${screenReader.name} (${versionMessage}) successfully extracted to`, + unzipDestination, + ); + } else if (asset.asset.endsWith(".tar.gz")) { + const unzipDestination = join( + cachePath, + screenReader.id, + getPlatformVersion(asset), + asset.version, + "extracted", + ); + + handleInfoWithPath( + `Extracting ${screenReader.name} (${versionMessage}) from`, + downloadDestination, + ); + + await extractTarGz(asset, downloadDestination, unzipDestination); + handleInfoWithPath( `${screenReader.name} (${versionMessage}) successfully extracted to`, unzipDestination, diff --git a/src/commands/install/extract-tar-gz.ts b/src/commands/install/extract-tar-gz.ts new file mode 100644 index 0000000..6a9ebfe --- /dev/null +++ b/src/commands/install/extract-tar-gz.ts @@ -0,0 +1,46 @@ +import { execFile } from "node:child_process"; +import { mkdir, readdir } from "node:fs/promises"; +import { promisify } from "node:util"; +import { + ERR_INSTALL_EMPTY_EXTRACTED_ASSET, + ERR_INSTALL_FAILED_TO_EXTRACT_ASSET, +} from "../../errors"; +import { handleInfoWithPath } from "../../logging"; +import type { Asset } from "./types"; +import { deleteAsset } from "./delete-asset"; + +const execFileAsync = promisify(execFile); + +export async function extractTarGz( + asset: Asset, + source: string, + destination: string, +): Promise { + let files: string[]; + + try { + await mkdir(destination, { recursive: true }); + + await execFileAsync("tar", ["-xzf", source, "-C", destination]); + + files = await readdir(destination); + } catch (cause) { + handleInfoWithPath("Unable to extract from", source); + + await deleteAsset(source); + await deleteAsset(destination); + + throw new Error(`${ERR_INSTALL_FAILED_TO_EXTRACT_ASSET}: ${asset.asset}`, { + cause, + }); + } + + if (!files.length) { + handleInfoWithPath("Extracted asset is empty at", destination); + + await deleteAsset(source); + await deleteAsset(destination); + + throw new Error(`${ERR_INSTALL_EMPTY_EXTRACTED_ASSET}: ${asset.asset}`); + } +} diff --git a/src/commands/install/extract-zip.ts b/src/commands/install/extract-zip.ts index edb9b1c..2447964 100644 --- a/src/commands/install/extract-zip.ts +++ b/src/commands/install/extract-zip.ts @@ -9,12 +9,6 @@ import { handleInfoWithPath } from "../../logging"; import type { Asset } from "./types"; import { deleteAsset } from "./delete-asset"; -async function deleteAssets(...assets: string[]) { - for (const asset of assets) { - await deleteAsset(asset); - } -} - export async function extractZip( asset: Asset, source: string, @@ -31,7 +25,8 @@ export async function extractZip( } catch (cause) { handleInfoWithPath("Unable to extract from", source); - await deleteAssets(source, destination); + await deleteAsset(source); + await deleteAsset(destination); throw new Error(`${ERR_INSTALL_FAILED_TO_EXTRACT_ASSET}: ${asset.asset}`, { cause, @@ -41,7 +36,8 @@ export async function extractZip( if (!files.length) { handleInfoWithPath("Extracted asset is empty at", destination); - await deleteAssets(source, destination); + await deleteAsset(source); + await deleteAsset(destination); throw new Error(`${ERR_INSTALL_EMPTY_EXTRACTED_ASSET}: ${asset.asset}`); } From 0bf92222562bdebb7ccc5cedf45177471eae6a7b Mon Sep 17 00:00:00 2001 From: cmorten Date: Tue, 1 Sep 2026 10:33:14 +0100 Subject: [PATCH 3/5] test: expected failures --- .github/workflows/test.yml | 108 +++++++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 15796cd..90c0762 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -132,11 +132,11 @@ jobs: "defaultDownload": true, "assets": [ { - "version": "0.1.1-50.2", - "platformVersion": "6", + "version": "0.2.0-50.2", + "platformVersion": "7", "repository": "guidepup/orca", - "asset": "guidepup-orca-0.1.1-50.2.tar.gz", - "sha256": "36e87abb0fc7fca178128a95c245a81644aae73109e8dd2beb7c84b05b878494" + "asset": "guidepup-orca-0.2.0-50.2.tar.gz", + "sha256": "9ce2b7962e6e3e5245782e1c2d628feeb2637484d32139dffe841fd585e07b71" } ] }, @@ -188,3 +188,103 @@ jobs: EOF guidepup install + + test-install-unsupported: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-24.04, ubuntu-24.04-arm] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + - run: yarn install --frozen-lockfile + - run: yarn build + - shell: bash + run: | + tmpdir=$(mktemp -d) + cd "$tmpdir" + npm init -y + npm install @guidepup/guidepup + npm install -g "${GITHUB_WORKSPACE}" + export PATH="$(npm bin -g):$PATH" + + cat > node_modules/@guidepup/guidepup/manifest.json <<'EOF' + { + "version": 1, + "screenReaders": [ + { + "id": "orca", + "name": "Orca", + "platforms": ["linux"], + "defaultDownload": true, + "assets": [ + { + "version": "0.2.0-50.2", + "platformVersion": "7", + "repository": "guidepup/orca", + "asset": "guidepup-orca-0.2.0-50.2.tar.gz", + "sha256": "9ce2b7962e6e3e5245782e1c2d628feeb2637484d32139dffe841fd585e07b71" + } + ] + }, + { + "id": "nvda", + "name": "NVDA", + "platforms": ["win32"], + "defaultDownload": true, + "assets": [ + { + "version": "0.2.1-2026.1.1", + "repository": "guidepup/nvda", + "asset": "guidepup-nvda-0.2.1-2026.1.1.zip", + "sha256": "b6fdfde86190c7c14132d459f2f88995da6251ded431e23947d676ffb152f963" + } + ] + }, + { + "id": "voiceover", + "name": "VoiceOver", + "platforms": ["darwin"], + "defaultDownload": true, + "assets": [ + { + "version": "0.0.1", + "platformVersion": "23", + "repository": "guidepup/voiceover", + "asset": "guidepup-voiceover-preferences-macos-14.dmg", + "sha256": "c6595f5ca50440e8553cde278936a46eff58d4c904e19c87e7d0e25950617ee1" + }, + { + "version": "0.0.1", + "platformVersion": "24", + "repository": "guidepup/voiceover", + "asset": "guidepup-voiceover-preferences-macos-15.dmg", + "sha256": "8bdc3a11c45a19cc876a859bfbd64529469a8b7d4ad41fd7e00a0729ebdbcb25" + }, + { + "version": "0.0.1", + "platformVersion": "25", + "repository": "guidepup/voiceover", + "asset": "guidepup-voiceover-preferences-macos-26.dmg", + "sha256": "9af2a3af7c9bffae1b2af26f1970548ecd62f33965fd30f4e43f21b9f57ce5ca" + } + ] + } + ] + } + EOF + + set +e + + guidepup install + exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + echo "Expected install to fail, but it succeeded." + + exit 1 + fi + + echo "Install correctly failed with exit code $exit_code." From cd1439d1dbbd1bafa67eec7be0eb35407ae5873c Mon Sep 17 00:00:00 2001 From: cmorten Date: Tue, 1 Sep 2026 10:35:09 +0100 Subject: [PATCH 4/5] ci: fix yarn setup --> yarn ci --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90c0762..0c16631 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,7 +75,7 @@ jobs: run: | set +e - yarn setup + yarn ci exit_code=$? if [ "$exit_code" -eq 0 ]; then From 32082ea211863b190786a352deda5b9be6a0ff7d Mon Sep 17 00:00:00 2001 From: cmorten Date: Tue, 1 Sep 2026 10:36:22 +0100 Subject: [PATCH 5/5] ci: remove unsupported versions from supported test list --- .github/workflows/test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c16631..7d8a932 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -100,8 +100,6 @@ jobs: windows-2022, windows-2025, windows-11-arm, - ubuntu-24.04, - ubuntu-24.04-arm, ubuntu-26.04, ubuntu-26.04-arm, ]