From f573c38aa33ba7d4b3d36892b2a2ea773c09d715 Mon Sep 17 00:00:00 2001 From: cmorten Date: Sun, 12 Jul 2026 18:42:57 +0100 Subject: [PATCH 01/29] feat: new CLI structure - migrate to setup subcommand --- .github/workflows/test.yml | 19 - README.md | 45 +-- bin/{setup => guidepup} | 0 package.json | 14 +- spec.md | 336 ++++++++++++++++++ src/commands/install/index.ts | 10 + src/commands/setup/index.ts | 46 +++ .../setup}/macOS/checkVersion.ts | 6 +- .../macOS/disableDictationInputAutoEnable.ts | 10 +- .../disableSplashScreenSystemDefaults.ts | 10 +- .../enableAppleScriptControlSystemDefaults.ts | 8 +- .../setup}/macOS/enableDoNotDisturb.ts | 12 +- .../setup}/macOS/getPlatformVersionMajor.ts | 0 .../enabledDbFile.ts | 0 .../enabledDefaults.ts | 0 .../isAppleScriptControlEnabled/index.ts | 0 .../setup}/macOS/isSipEnabled.ts | 6 +- .../setup}/macOS/retryOnError.ts | 0 .../setup}/macOS/runAppleScript.ts | 0 src/{ => commands/setup}/macOS/setup.ts | 44 ++- src/{ => commands/setup}/macOS/updateTccDb.ts | 8 +- .../setup}/macOS/writeDatabaseFile.ts | 8 +- src/errors.ts | 18 +- src/index.ts | 45 ++- src/logging.ts | 17 +- src/windows/constants.ts | 12 - src/windows/createNvdaRegistryKey.ts | 11 - src/windows/getNvdaRegistryData.ts | 15 - src/windows/installNvda.ts | 88 ----- src/windows/isNvdaInstalled.ts | 31 -- src/windows/removeForegroundLock.ts | 30 -- src/windows/restartExplorer.ts | 26 -- src/windows/runVbsScript.ts | 49 --- src/windows/setup.ts | 36 -- src/windows/updateNvdaRegistryData.ts | 18 - yarn.lock | 5 + 36 files changed, 502 insertions(+), 481 deletions(-) rename bin/{setup => guidepup} (100%) create mode 100644 spec.md create mode 100644 src/commands/install/index.ts create mode 100644 src/commands/setup/index.ts rename src/{ => commands/setup}/macOS/checkVersion.ts (92%) rename src/{ => commands/setup}/macOS/disableDictationInputAutoEnable.ts (51%) rename src/{ => commands/setup}/macOS/disableSplashScreenSystemDefaults.ts (51%) rename src/{ => commands/setup}/macOS/enableAppleScriptControlSystemDefaults.ts (59%) rename src/{ => commands/setup}/macOS/enableDoNotDisturb.ts (92%) rename src/{ => commands/setup}/macOS/getPlatformVersionMajor.ts (100%) rename src/{ => commands/setup}/macOS/isAppleScriptControlEnabled/enabledDbFile.ts (100%) rename src/{ => commands/setup}/macOS/isAppleScriptControlEnabled/enabledDefaults.ts (100%) rename src/{ => commands/setup}/macOS/isAppleScriptControlEnabled/index.ts (100%) rename src/{ => commands/setup}/macOS/isSipEnabled.ts (61%) rename src/{ => commands/setup}/macOS/retryOnError.ts (100%) rename src/{ => commands/setup}/macOS/runAppleScript.ts (100%) rename src/{ => commands/setup}/macOS/setup.ts (72%) rename src/{ => commands/setup}/macOS/updateTccDb.ts (97%) rename src/{ => commands/setup}/macOS/writeDatabaseFile.ts (51%) delete mode 100644 src/windows/constants.ts delete mode 100644 src/windows/createNvdaRegistryKey.ts delete mode 100644 src/windows/getNvdaRegistryData.ts delete mode 100644 src/windows/installNvda.ts delete mode 100644 src/windows/isNvdaInstalled.ts delete mode 100644 src/windows/removeForegroundLock.ts delete mode 100644 src/windows/restartExplorer.ts delete mode 100644 src/windows/runVbsScript.ts delete mode 100644 src/windows/setup.ts delete mode 100644 src/windows/updateNvdaRegistryData.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 02d5e05..87d9be7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,22 +53,3 @@ jobs: name: artifacts-test-ignore-tcc-db-${{ matrix.os }} path: | **/recordings/**/* - test-nvda-install-dir: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [windows-2022, windows-2025, windows-11-arm] - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - run: yarn install --frozen-lockfile - - run: yarn ci:nvda-install-dir - - run: Get-ChildItem $env:USERPROFILE\nvda - - uses: actions/upload-artifact@v4 - if: always() - with: - name: artifacts-test-nvda-install-dir-${{ matrix.os }} - path: | - **/recordings/**/* diff --git a/README.md b/README.md index b804805..100b0b5 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,15 @@ ## [Documentation](https://www.guidepup.dev/docs/guides/automated-environment-setup) -[![MacOS Sonoma Support](https://img.shields.io/badge/macos-Somona-blue.svg?logo=apple)](https://apps.apple.com/us/app/macos-sonoma/id6450717509) -[![MacOS Sequoia Support](https://img.shields.io/badge/macos-Sequoia-blue.svg?logo=apple)](https://apps.apple.com/us/app/macos-sequoia/id6596773750) -[![MacOS Tahoe Support](https://img.shields.io/badge/macos-Tahoe-blue.svg?logo=apple)](https://www.apple.com/uk/os/macos/) +[![macOS Sonoma Support](https://img.shields.io/badge/macos-Somona-blue.svg?logo=apple)](https://apps.apple.com/us/app/macos-sonoma/id6450717509) +[![macOS Sequoia Support](https://img.shields.io/badge/macos-Sequoia-blue.svg?logo=apple)](https://apps.apple.com/us/app/macos-sequoia/id6596773750) +[![macOS Tahoe Support](https://img.shields.io/badge/macos-Tahoe-blue.svg?logo=apple)](https://www.apple.com/uk/os/macos/) [![Windows Server 2022 Support](https://img.shields.io/badge/windows_server-2022-blue.svg?logo=windows)](https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2022) [![Windows Server 2025 Support](https://img.shields.io/badge/windows_server-2025-blue.svg?logo=windows)](https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2025) This package sets up your environment for screen reader automation. -It enables automation for VoiceOver on MacOS and NVDA on Windows. +It enables automation for VoiceOver on macOS and NVDA on Windows. ## Getting Started @@ -37,7 +37,7 @@ If you are using GitHub Actions, check out the dedicated [`guidepup/setup-action uses: guidepup/setup-action ``` -### MacOS +### macOS If you are running this command locally you may need to take some manual steps to complete setup by following the [manual VoiceOver setup documentation](https://www.guidepup.dev/docs/guides/manual-voiceover-setup). @@ -49,12 +49,12 @@ If you are running this command in CI/CD, it is recommended to add the `--ci` fl npx @guidepup/setup --ci ``` -#### Ignore TCC.db Updates +#### Ignore TCC Database Updates -If updating the TCC.db is not possible (due to SIP) or required you can skip the database update step by using the `--ignore-tcc-db` flag: +If updating the `TCC.db` is not possible (due to enabled SIP) or not required, you can skip the database update step by using the `--macos-ignore-tcc-db` flag: ```console -npx @guidepup/setup --ignore-tcc-db +npx @guidepup/setup --macos-ignore-tcc-db ``` > [!NOTE] @@ -62,22 +62,10 @@ npx @guidepup/setup --ignore-tcc-db #### Recording -If you are encountering errors in CI for MacOS you can pass a `--record` flag to the command which will output a screen-recording of the setup to a `./recordings/` directory: +If you are encountering errors in CI for macOS you can pass a `--macos-record` flag to the command which will output a screen-recording of the setup to a `./recordings/` directory: ```console -npx @guidepup/setup --ci --record -``` - -### Windows - -#### NVDA Installation - -When running on windows a portable NVDA instance compatible with Guidepup will be installed to a temporary directory by default. The location of this installation directory is stored in the Windows registry under the key `HKCU\Software\Guidepup\Nvda`. - -If you want to specify the directory that NVDA is installed to you can pass a `--nvda-install-dir` flag to the command: - -```console -npx @guidepup/setup --nvda-install-dir +npx @guidepup/setup --ci --macos-record ``` ##### Using HTTP / HTTPS Proxy for Installation @@ -89,19 +77,6 @@ If you are using a proxy connection, you must define the proxy URL in an env var - `HTTP_PROXY` - `http_proxy` -#### Foreground Timeout Lock - -Modern versions of Windows have a setting which prevents new application instances launching in front of other applications in quick succession, requiring over 3 minutes between activations before it will actually show the window - in the interim it launches the window minimized. - -Many test automation frameworks will completely close down a browser after a test has finished and then launch a new instance for the next test - on Windows this suffers from the timeout lock on foreground windows. This impacts on screen reader automation which need the window to activate and focus to be able to drive the screen reader on the application. - -To mitigate this the setup script updates two keys in the Windows registry under `HKCU\Control Panel\Desktop`: - -- `ForegroundLockTimeout` - Specifies the time in milliseconds, following user input, during which the system will not allow applications to force themselves into the foreground. Defaults to `200000`. -- `ForegroundFlashCount` - Determines the number of times the taskbar button will flash to notify the user that a background window has been activated by the system. If the time elapsed since the last user input exceeds the value of ForegroundLockTimeout, the window will automatically be brought to the foreground. Defaults to `3`. - -Both of these are set to `0` by the setup script. - ## Powerful Tooling Check out some of the other Guidepup modules: diff --git a/bin/setup b/bin/guidepup similarity index 100% rename from bin/setup rename to bin/guidepup diff --git a/package.json b/package.json index 41fa80f..9c616f6 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "@guidepup/setup", - "version": "0.23.0", - "description": "Setup your environment for screen-reader automation.", + "version": "0.24.0", + "description": "Configure the local environment and manage screen readers for Guidepup.", "main": "lib/index.js", "typings": "lib/index.d.ts", "bin": { - "setup": "bin/setup" + "guidepup": "bin/guidepup" }, "author": "Craig Morten ", "license": "MIT", @@ -26,16 +26,15 @@ ], "scripts": { "build": "yarn clean && yarn compile", - "ci": "yarn clean && yarn lint && yarn build && yarn resolutionFix && yarn start --ci --record", - "ci:nvda-install-dir": "yarn clean && yarn lint && yarn build && yarn resolutionFix && yarn start --ci --record --nvda-install-dir %userprofile%\\nvda", - "ci:ignore-tcc-db": "yarn clean && yarn lint && yarn build && yarn resolutionFix && yarn start --ci --record --ignore-tcc-db", + "ci": "yarn clean && yarn lint && yarn build && yarn resolutionFix && yarn start:setup --ci --macos-record", + "ci:ignore-tcc-db": "yarn clean && yarn lint && yarn build && yarn resolutionFix && yarn start:setup --ci --macos-record --macos-ignore-tcc-db", "clean": "rimraf lib", "compile": "tsc", "dev": "ts-node ./src/index.ts", "resolutionFix": "ts-node ./.github/workflows/resolutionFix.ts", "lint": "eslint . --ext .ts", "lint:fix": "yarn lint --fix", - "start": "node ./bin/setup", + "start:setup": "node ./bin/guidepup setup", "prepublish": "yarn build" }, "devDependencies": { @@ -53,6 +52,7 @@ }, "dependencies": { "chalk": "^4.0.0", + "commander": "^15.0.0", "decompress": "^4.2.1", "https-proxy-agent": "^7.0.5", "regedit": "5.0.1", diff --git a/spec.md b/spec.md new file mode 100644 index 0000000..ce8a4ea --- /dev/null +++ b/spec.md @@ -0,0 +1,336 @@ +CLI Specification + +Summary + +This document defines the public behaviour of the @guidepup/setup CLI. + +The CLI has two primary responsibilities: + +- Configure the local environment required by Guidepup. +- Install screen reader assets required by an installed version of @guidepup/guidepup. + +The CLI intentionally mirrors the Playwright CLI where appropriate, providing a familiar developer experience while remaining independent from the implementation details of individual screen readers. + +⸻ + +Goals + +- Simple, predictable command structure. +- Separate environment configuration from screen reader installation. +- Reproducible installations. +- Deterministic asset selection. +- Shared per-user cache. +- Offline operation when all required assets already exist. +- Future extensibility without breaking existing users. + +⸻ + +Non-goals + +The following are explicitly out of scope for this specification: + +- Updating installed screen readers. +- Listing installed screen readers. +- Uninstalling screen readers. +- Pruning unused assets. +- Defining the manifest schema (covered separately). + +⸻ + +CLI + +Commands + +Setup + +Configures the local environment required by Guidepup. + +guidepup setup + +This command performs environment configuration only. + +It MUST NOT: + +- download screen readers +- install screen readers +- invoke the install command implicitly + +⸻ + +Install + +Installs screen reader assets. + +guidepup install +guidepup install + +Examples: + +guidepup install +guidepup install nvda +guidepup install voiceover + +When no screen reader is specified, the CLI installs all screen readers that: + +- are compatible with the current operating system +- have defaultDownload: true in the manifest + +When a screen reader is specified, only that screen reader is processed. + +If the requested screen reader is incompatible with the current operating system, the CLI MUST: + +- print a clear error explaining the incompatibility +- exit with status code 1 + +⸻ + +Help + +guidepup --help +guidepup install --help + +⸻ + +Version + +guidepup --version + +⸻ + +Manifest + +Source of truth + +@guidepup/guidepup owns compatibility between Guidepup and screen reader assets. + +@guidepup/setup MUST NOT contain hardcoded knowledge of compatible screen reader versions. + +Instead it reads a manifest supplied by the installed @guidepup/guidepup package. + +The manifest is the public contract between the runtime package and the CLI. + +⸻ + +Location + +The manifest MUST exist at: + +@guidepup/guidepup/ +manifest.json + +⸻ + +Discovery + +The CLI discovers the installed Guidepup package using normal Node.js module resolution. + +The nearest installed @guidepup/guidepup package is used. + +No CLI flags are required to locate the manifest. + +⸻ + +Missing package + +If no local installation of @guidepup/guidepup can be found, the CLI MUST fail with a clear error. + +Example: + +No local installation of @guidepup/guidepup was found. +Install @guidepup/guidepup before running: +guidepup install + +Exact wording is implementation defined. + +Exit status: + +1 + +⸻ + +Manifest validation + +The CLI MUST validate that: + +- the manifest exists +- the manifest is valid JSON +- required fields exist +- the schema version is supported + +If validation fails, the CLI MUST fail with a clear error explaining that the installation appears invalid or corrupted. + +The CLI MUST NOT attempt to recover from an invalid manifest. + +Exit status: + +1 + +⸻ + +Manifest schema version + +The manifest contains a schema version. + +The CLI supports one or more schema versions. + +If the CLI encounters an unsupported schema version it MUST fail with an error instructing the user to upgrade @guidepup/setup. + +Compatibility is determined by the manifest schema version—not by the versions of either npm package. + +⸻ + +Asset Resolution + +The manifest defines: + +- supported screen readers +- compatible operating systems +- downloadable asset information +- checksum +- default download behaviour + +The CLI consumes this information without embedding compatibility logic. + +⸻ + +Cache + +Default location + +Downloaded assets are stored in a per-user cache. + +Platform defaults: + +macOS + +~/Library/Caches/guidepup + +Linux + +~/.cache/guidepup + +Windows + +%LOCALAPPDATA%\guidepup + +⸻ + +Override + +The cache location may be overridden using: + +GUIDEPUP_SCREEN_READERS_PATH + +This path may point anywhere accessible to the current user. + +The CLI does not distinguish between user and machine-wide installations. + +⸻ + +Installation Behaviour + +For every required asset: + +1. Resolve the asset from the manifest. +2. Check whether the required version already exists. +3. Validate the local asset against the checksum in the manifest. +4. If valid: + - perform no download + - report success (wording implementation defined) +5. If missing or invalid: + - obtain the asset + - verify download integrity + - install the asset + - validate the installed result + +The CLI should be self-healing. + +Corrupt or incomplete installations are automatically replaced. + +⸻ + +Integrity + +The manifest is the trusted description of every asset. + +It contains sufficient information to validate both downloaded and installed assets. + +The download mechanism is intentionally abstract. + +Initially the CLI may construct GitHub Release download URLs. + +Future implementations may instead use: + +- GitHub APIs +- mirrors +- CDNs +- dedicated artifact services +- CLI tooling + +without requiring manifest changes. + +⸻ + +Offline Behaviour + +If every required asset already exists locally and passes checksum validation: + +- no network requests are required +- the install command succeeds +- the command behaves as a no-op + +Internet access is only required when an asset must be downloaded or replaced. + +⸻ + +Multiple Assets + +Each asset installation is independent. + +If one asset fails to install, remaining assets should still be attempted. + +At completion: + +If every asset succeeds: + +Exit status: + +0 + +If one or more assets fail: + +- report failures +- exit with status code + +1 + +⸻ + +Asset Lifetime + +Installing assets never removes existing assets. + +The install command only: + +- installs missing assets +- repairs corrupted assets + +Automatic pruning is out of scope. + +Future CLI commands may introduce explicit cache management. + +⸻ + +Design Principles + +The CLI follows these principles: + +- Guidepup owns compatibility. +- The manifest is the contract. +- The CLI executes the manifest. +- Assets are shared across projects. +- Existing installations are reused whenever possible. +- Installs are reproducible. +- Offline operation is supported. +- Commands perform one responsibility only. +- Destructive operations are always explicit. diff --git a/src/commands/install/index.ts b/src/commands/install/index.ts new file mode 100644 index 0000000..1599ea1 --- /dev/null +++ b/src/commands/install/index.ts @@ -0,0 +1,10 @@ +import { Command } from "commander"; + +export function installCommand() { + return new Command("install") + .description("Install screen readers.") + .argument("[screenreader]", "screen readers to install") + .action((screenreader?: string) => { + console.log("TODO", { screenreader }); + }); +} diff --git a/src/commands/setup/index.ts b/src/commands/setup/index.ts new file mode 100644 index 0000000..b45ba19 --- /dev/null +++ b/src/commands/setup/index.ts @@ -0,0 +1,46 @@ +import { platform } from "node:os"; +import { Command } from "commander"; +import { setup as setupMacOS } from "./macOS/setup"; +import { ERR_UNSUPPORTED_OS } from "../../errors"; +import { handleError, handleSetupComplete } from "../../logging"; + +interface SetupCommandOptions { + ci: boolean; + macosIgnoreTccDb: boolean; + macosRecord: boolean; +} + +async function setup(options: SetupCommandOptions): Promise { + const currentPlatform = platform(); + + try { + switch (currentPlatform) { + case "win32": { + break; + } + case "darwin": { + await setupMacOS(options); + + break; + } + default: { + throw new Error(ERR_UNSUPPORTED_OS); + } + } + } catch (e) { + handleError(e); + } + + handleSetupComplete(); +} + +export function setupCommand() { + return new Command("setup") + .description( + "Configure the local environment for screen reader automation.", + ) + .option("--ci", "Enable CI-specific behavior during setup.") + .option("--macos-ignore-tcc-db", "Skip macOS TCC database updates.") + .option("--macos-record", "Screen record the macOS setup.") + .action(setup); +} diff --git a/src/macOS/checkVersion.ts b/src/commands/setup/macOS/checkVersion.ts similarity index 92% rename from src/macOS/checkVersion.ts rename to src/commands/setup/macOS/checkVersion.ts index 0e071c3..6880437 100644 --- a/src/macOS/checkVersion.ts +++ b/src/commands/setup/macOS/checkVersion.ts @@ -1,12 +1,12 @@ // Derived from https://github.com/sindresorhus/macos-version // MIT License Copyright (c) Sindre Sorhus -import { readFileSync } from "fs"; +import { readFileSync } from "node:fs"; import { satisfies } from "semver"; import { ERR_MACOS_UNABLE_TO_VERIFY_VERSION, ERR_MACOS_UNSUPPORTED_VERSION, -} from "../errors"; +} from "../../../errors"; function clean(version: string): string { const { length } = version.split("."); @@ -23,7 +23,7 @@ function clean(version: string): string { export function checkVersion(): void { const plist = readFileSync( "/System/Library/CoreServices/SystemVersion.plist", - "utf8" + "utf8", ); const matches = /ProductVersion<\/key>\s*([\d.]+)<\/string>/.exec(plist); diff --git a/src/macOS/disableDictationInputAutoEnable.ts b/src/commands/setup/macOS/disableDictationInputAutoEnable.ts similarity index 51% rename from src/macOS/disableDictationInputAutoEnable.ts rename to src/commands/setup/macOS/disableDictationInputAutoEnable.ts index e584799..244ab6d 100644 --- a/src/macOS/disableDictationInputAutoEnable.ts +++ b/src/commands/setup/macOS/disableDictationInputAutoEnable.ts @@ -1,15 +1,13 @@ import { execSync } from "child_process"; -import { ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS } from "../errors"; +import { ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS } from "../../../errors"; export function disableDictationInputAutoEnable(): void { try { execSync( "defaults write com.apple.HIToolbox AppleDictationAutoEnable -bool false", - { encoding: "utf8" } - ); - } catch (e) { - throw new Error( - `${ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS}\n\n${e.message}` + { encoding: "utf8" }, ); + } catch (cause) { + throw new Error(ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS, { cause }); } } diff --git a/src/macOS/disableSplashScreenSystemDefaults.ts b/src/commands/setup/macOS/disableSplashScreenSystemDefaults.ts similarity index 51% rename from src/macOS/disableSplashScreenSystemDefaults.ts rename to src/commands/setup/macOS/disableSplashScreenSystemDefaults.ts index 80ee8ff..670ad08 100644 --- a/src/macOS/disableSplashScreenSystemDefaults.ts +++ b/src/commands/setup/macOS/disableSplashScreenSystemDefaults.ts @@ -1,15 +1,13 @@ import { execSync } from "child_process"; -import { ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS } from "../errors"; +import { ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS } from "../../../errors"; export function disableSplashScreenSystemDefaults(): void { try { execSync( "defaults write com.apple.VoiceOverTraining doNotShowSplashScreen -bool true", - { encoding: "utf8" } - ); - } catch (e) { - throw new Error( - `${ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS}\n\n${e.message}` + { encoding: "utf8" }, ); + } catch (cause) { + throw new Error(ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS, { cause }); } } diff --git a/src/macOS/enableAppleScriptControlSystemDefaults.ts b/src/commands/setup/macOS/enableAppleScriptControlSystemDefaults.ts similarity index 59% rename from src/macOS/enableAppleScriptControlSystemDefaults.ts rename to src/commands/setup/macOS/enableAppleScriptControlSystemDefaults.ts index 3b16cb3..e401db9 100644 --- a/src/macOS/enableAppleScriptControlSystemDefaults.ts +++ b/src/commands/setup/macOS/enableAppleScriptControlSystemDefaults.ts @@ -1,5 +1,5 @@ import { execSync } from "child_process"; -import { ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS } from "../errors"; +import { ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS } from "../../../errors"; export function enableAppleScriptControlSystemDefaults(): void { try { @@ -9,9 +9,7 @@ export function enableAppleScriptControlSystemDefaults(): void { ); return; - } catch (e) { - throw new Error( - `${ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS}\n\n${e.message}`, - ); + } catch (cause) { + throw new Error(ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS, { cause }); } } diff --git a/src/macOS/enableDoNotDisturb.ts b/src/commands/setup/macOS/enableDoNotDisturb.ts similarity index 92% rename from src/macOS/enableDoNotDisturb.ts rename to src/commands/setup/macOS/enableDoNotDisturb.ts index b9e791f..1db3d41 100644 --- a/src/macOS/enableDoNotDisturb.ts +++ b/src/commands/setup/macOS/enableDoNotDisturb.ts @@ -1,6 +1,6 @@ import { exec } from "child_process"; import { promisify } from "util"; -import { ERR_MACOS_FAILED_TO_ENABLE_DO_NOT_DISTURB } from "../errors"; +import { ERR_MACOS_FAILED_TO_ENABLE_DO_NOT_DISTURB } from "../../../errors"; import { runAppleScript } from "./runAppleScript"; import { retryOnError } from "./retryOnError"; import { getPlatformVersionMajor } from "./getPlatformVersionMajor"; @@ -103,21 +103,19 @@ export async function enableDoNotDisturb() { if (platformMajor <= 20) { await promisify(exec)(enableFocusModeShellscript); } else if (platformMajor === 21) { - // From MacOS 12 Monterey (Darwin 21) there is no known way to enable DND via system defaults + // From macOS 12 Monterey (Darwin 21) there is no known way to enable DND via system defaults await retryOnError(() => runAppleScript(enableFocusModeAppleScript)); } else { const { stdout: locale } = await promisify(exec)(getLocale); - // From MacOS 13 Ventura (Darwin 22) there is no known way to enable DND via system settings + // From macOS 13 Ventura (Darwin 22) there is no known way to enable DND via system settings await retryOnError(() => runAppleScript( enableFocusModeVenturaAppleScript(locale, platformMajor), ), ); } - } catch (e) { - throw new Error( - `${ERR_MACOS_FAILED_TO_ENABLE_DO_NOT_DISTURB}\n\n${e.message}`, - ); + } catch (cause) { + throw new Error(ERR_MACOS_FAILED_TO_ENABLE_DO_NOT_DISTURB, { cause }); } } diff --git a/src/macOS/getPlatformVersionMajor.ts b/src/commands/setup/macOS/getPlatformVersionMajor.ts similarity index 100% rename from src/macOS/getPlatformVersionMajor.ts rename to src/commands/setup/macOS/getPlatformVersionMajor.ts diff --git a/src/macOS/isAppleScriptControlEnabled/enabledDbFile.ts b/src/commands/setup/macOS/isAppleScriptControlEnabled/enabledDbFile.ts similarity index 100% rename from src/macOS/isAppleScriptControlEnabled/enabledDbFile.ts rename to src/commands/setup/macOS/isAppleScriptControlEnabled/enabledDbFile.ts diff --git a/src/macOS/isAppleScriptControlEnabled/enabledDefaults.ts b/src/commands/setup/macOS/isAppleScriptControlEnabled/enabledDefaults.ts similarity index 100% rename from src/macOS/isAppleScriptControlEnabled/enabledDefaults.ts rename to src/commands/setup/macOS/isAppleScriptControlEnabled/enabledDefaults.ts diff --git a/src/macOS/isAppleScriptControlEnabled/index.ts b/src/commands/setup/macOS/isAppleScriptControlEnabled/index.ts similarity index 100% rename from src/macOS/isAppleScriptControlEnabled/index.ts rename to src/commands/setup/macOS/isAppleScriptControlEnabled/index.ts diff --git a/src/macOS/isSipEnabled.ts b/src/commands/setup/macOS/isSipEnabled.ts similarity index 61% rename from src/macOS/isSipEnabled.ts rename to src/commands/setup/macOS/isSipEnabled.ts index 469fbc6..e7a5c05 100644 --- a/src/macOS/isSipEnabled.ts +++ b/src/commands/setup/macOS/isSipEnabled.ts @@ -1,13 +1,13 @@ import { execSync } from "child_process"; -import { ERR_MACOS_UNABLE_TO_VERIFY_SIP } from "../errors"; +import { ERR_MACOS_UNABLE_TO_VERIFY_SIP } from "../../../errors"; export function isSipEnabled(): boolean { let commandResult: string; try { commandResult = execSync("csrutil status", { encoding: "utf8" }); - } catch (e) { - throw new Error(`${ERR_MACOS_UNABLE_TO_VERIFY_SIP}\n\n${e.message}`); + } catch (cause) { + throw new Error(ERR_MACOS_UNABLE_TO_VERIFY_SIP, { cause }); } return commandResult.includes("enabled"); diff --git a/src/macOS/retryOnError.ts b/src/commands/setup/macOS/retryOnError.ts similarity index 100% rename from src/macOS/retryOnError.ts rename to src/commands/setup/macOS/retryOnError.ts diff --git a/src/macOS/runAppleScript.ts b/src/commands/setup/macOS/runAppleScript.ts similarity index 100% rename from src/macOS/runAppleScript.ts rename to src/commands/setup/macOS/runAppleScript.ts diff --git a/src/macOS/setup.ts b/src/commands/setup/macOS/setup.ts similarity index 72% rename from src/macOS/setup.ts rename to src/commands/setup/macOS/setup.ts index 3ec9d3f..c365f13 100644 --- a/src/macOS/setup.ts +++ b/src/commands/setup/macOS/setup.ts @@ -1,5 +1,4 @@ -import { platform, release } from "os"; -import chalk from "chalk"; +import { platform, release } from "node:os"; import { checkVersion } from "./checkVersion"; import { enableAppleScriptControlSystemDefaults } from "./enableAppleScriptControlSystemDefaults"; import { disableSplashScreenSystemDefaults } from "./disableSplashScreenSystemDefaults"; @@ -8,21 +7,27 @@ import { isSipEnabled } from "./isSipEnabled"; import { writeDatabaseFile } from "./writeDatabaseFile"; import { SYSTEM_PATH, USER_PATH, updateTccDb } from "./updateTccDb"; import { isAppleScriptControlEnabled } from "./isAppleScriptControlEnabled"; -import { handleWarning, logInfo } from "../logging"; -import { ERR_MACOS_REQUIRES_MANUAL_USER_INTERACTION } from "../errors"; +import { handleSetupManualRequired, handleWarning } from "../../../logging"; +import { ERR_MACOS_REQUIRES_MANUAL_USER_INTERACTION } from "../../../errors"; import { enableDoNotDisturb } from "./enableDoNotDisturb"; import { enabledDbFile } from "./isAppleScriptControlEnabled/enabledDbFile"; -const isCi = process.argv.includes("--ci"); -const ignoreTccDb = process.argv.includes("--ignore-tcc-db"); -const isRecorded = process.argv.includes("--record"); +interface MacOSSetupOptions { + ci?: boolean; + macosIgnoreTccDb?: boolean; + macosRecord?: boolean; +} -export async function setup(): Promise { - if (!ignoreTccDb) { +export async function setup({ + ci = false, + macosIgnoreTccDb = false, + macosRecord = false, +}: MacOSSetupOptions = {}): Promise { + if (!macosIgnoreTccDb) { try { updateTccDb(USER_PATH); } catch (e) { - if (isCi) { + if (ci) { throw e; } } @@ -34,7 +39,7 @@ export async function setup(): Promise { } } else { handleWarning( - "Ignoring TCC.db updates", + "Ignoring TCC database updates", "If the necessary permissions have not been granted by other means, using this flag may result in your environment not being set up for reliable screen reader automation.", ); } @@ -44,7 +49,7 @@ export async function setup(): Promise { let stopRecording: () => void = () => null; - if (isRecorded) { + if (macosRecord) { try { const { macOSRecord } = await import("@guidepup/record"); @@ -54,7 +59,7 @@ export async function setup(): Promise { } catch { handleWarning( "@guidepup/record not available", - "Recording will be skipped. This is expected on platforms without ffmpeg support (e.g., Windows ARM64).", + "Recording will be skipped. This is expected on platforms without ffmpeg support.", ); } } @@ -65,7 +70,7 @@ export async function setup(): Promise { disableSplashScreenSystemDefaults(); disableDictationInputAutoEnable(); - if (isCi) { + if (ci) { await enableDoNotDisturb(); } @@ -79,18 +84,11 @@ export async function setup(): Promise { return; } - if (isCi) { + if (ci) { throw new Error(ERR_MACOS_REQUIRES_MANUAL_USER_INTERACTION); } - logInfo( - "Please complete remaining setup by following this guide:\n\n--> " + - chalk.underline( - chalk.bold( - "https://www.guidepup.dev/docs/guides/manual-voiceover-setup", - ), - ), - ); + handleSetupManualRequired(); } finally { stopRecording(); } diff --git a/src/macOS/updateTccDb.ts b/src/commands/setup/macOS/updateTccDb.ts similarity index 97% rename from src/macOS/updateTccDb.ts rename to src/commands/setup/macOS/updateTccDb.ts index 85ba72c..eacfb10 100644 --- a/src/macOS/updateTccDb.ts +++ b/src/commands/setup/macOS/updateTccDb.ts @@ -1,6 +1,6 @@ import { release } from "os"; import { execSync } from "child_process"; -import { ERR_MACOS_UNABLE_TO_WRITE_USER_TCC_DB } from "../errors"; +import { ERR_MACOS_UNABLE_TO_WRITE_USER_TCC_DB } from "../../../errors"; const epoch = +Date.now(); @@ -197,10 +197,8 @@ export function updateTccDb(path: string): void { execSync(`sqlite3 "${path}" "${query}" >/dev/null 2>&1`, { encoding: "utf8", }); - } catch (e) { - throw new Error( - `${ERR_MACOS_UNABLE_TO_WRITE_USER_TCC_DB}\n\n${e.message}`, - ); + } catch (cause) { + throw new Error(ERR_MACOS_UNABLE_TO_WRITE_USER_TCC_DB, { cause }); } } } diff --git a/src/macOS/writeDatabaseFile.ts b/src/commands/setup/macOS/writeDatabaseFile.ts similarity index 51% rename from src/macOS/writeDatabaseFile.ts rename to src/commands/setup/macOS/writeDatabaseFile.ts index e73b0c1..42c0c1f 100644 --- a/src/macOS/writeDatabaseFile.ts +++ b/src/commands/setup/macOS/writeDatabaseFile.ts @@ -1,13 +1,13 @@ import { writeFileSync } from "fs"; -import { ERR_MACOS_UNABLE_TO_WRITE_DATABASE_FILE } from "../errors"; +import { ERR_MACOS_UNABLE_TO_WRITE_DATABASE_FILE } from "../../../errors"; export function writeDatabaseFile(): void { try { writeFileSync( "/private/var/db/Accessibility/.VoiceOverAppleScriptEnabled", - "a" + "a", ); - } catch (e) { - throw new Error(`${ERR_MACOS_UNABLE_TO_WRITE_DATABASE_FILE}\n\n${e.message}`); + } catch (cause) { + throw new Error(ERR_MACOS_UNABLE_TO_WRITE_DATABASE_FILE, { cause }); } } diff --git a/src/errors.ts b/src/errors.ts index feece95..98c8a64 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -10,22 +10,10 @@ export const ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS = export const ERR_MACOS_UNABLE_TO_VERIFY_SIP = "Unable to verify macOS SIP status"; export const ERR_MACOS_UNABLE_TO_WRITE_DATABASE_FILE = - "Unable to write to the VoiceOver database file - SIP might not be disabled"; + "Unable to write to the VoiceOver database file\n\nEnsure that SIP is disabled or pass '--macos-ignore-tcc-db' to ignore TCC database updates"; export const ERR_MACOS_UNABLE_TO_WRITE_USER_TCC_DB = - "Unable to write to the user TCC.db - SIP might not be disabled"; -export const ERR_MACOS_UI_PROMPT_FAILURE = - "Unable to retrieve user input from prompt"; -export const ERR_MACOS_UI_CONTROL_NOT_CONSENTED = - "Consent not given for UI control"; + "Unable to write to the user TCC database\n\nEnsure that SIP is disabled or pass '--macos-ignore-tcc-db' to ignore TCC database updates"; export const ERR_MACOS_REQUIRES_MANUAL_USER_INTERACTION = - "Unable to setup environment without manual user interaction"; + "Unable to setup environment without manual user interaction\n\nEnsure that SIP is disabled or preconfigure your environment with 'Allow VoiceOver to be controlled with AppleScript' enabled"; export const ERR_MACOS_FAILED_TO_ENABLE_DO_NOT_DISTURB = 'Failed to enable "Do not disturb" mode'; - -export const ERR_WINDOWS_UNABLE_TO_ACCESS_REGISTRY = - "Unable to access Windows registry"; -export const ERR_WINDOWS_UNABLE_TO_UPDATE_REGISTRY = - "Unable to update Windows registry"; -export const ERR_WINDOWS_FAILED_TO_INSTALL_NVDA = "Unable to install NVDA"; -export const ERR_WINDOWS_FAILED_TO_RESTART_EXPLORER = - "Unable to restart explorer.exe"; diff --git a/src/index.ts b/src/index.ts index 39276aa..c8f2dc1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,25 @@ -import { setup as setupMacOS } from "./macOS/setup"; -import { setup as setupWindows } from "./windows/setup"; -import { handleError, handleComplete } from "./logging"; -import { ERR_UNSUPPORTED_OS } from "./errors"; +import { Command } from "commander"; +import { setupCommand } from "./commands/setup"; +import { installCommand } from "./commands/install"; -async function run(): Promise { - try { - switch (process.platform) { - case "darwin": { - await setupMacOS(); - break; - } - case "win32": { - await setupWindows(); - break; - } - default: { - throw new Error(ERR_UNSUPPORTED_OS); - } - } - } catch (e) { - handleError(e); - } +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { version } = require("../package.json"); - handleComplete(); +function createProgram(): Command { + const program = new Command(); + + program + .name("guidepup") + .description( + "Configure the local environment and manage screen readers for Guidepup.", + ) + .version(version, "-v, --version", "Display the CLI version."); + + program.addCommand(setupCommand()); + + program.addCommand(installCommand()); + + return program; } -run(); +createProgram().parseAsync(process.argv); diff --git a/src/logging.ts b/src/logging.ts index fcf1c0f..ea34b8c 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -4,7 +4,7 @@ export const logInfo = console.info.bind(console); export const logWarn = console.warn.bind(console); export const logError = console.error.bind(console); -export function handleComplete(): never { +export function handleSetupComplete(): never { logInfo(""); logInfo(chalk.green("Environment setup complete 🎉")); logInfo(""); @@ -12,6 +12,17 @@ export function handleComplete(): never { process.exit(0); } +export function handleSetupManualRequired(): void { + logInfo( + "Please complete remaining setup by following this guide:\n\n--> " + + chalk.underline( + chalk.bold( + "https://www.guidepup.dev/docs/guides/manual-voiceover-setup", + ), + ), + ); +} + export function handleWarning(title: string, subtitle: string): void { logWarn(""); logWarn(chalk.bold(chalk.yellow(`[!] Warning: ${chalk.bold(title)}`))); @@ -35,8 +46,8 @@ export function handleError(err: Error): never { logError( chalk.dim( "Please raise new issues at: " + - chalk.underline("https://github.com/guidepup/setup/issues") - ) + chalk.underline("https://github.com/guidepup/setup/issues"), + ), ); logError(""); diff --git a/src/windows/constants.ts b/src/windows/constants.ts deleted file mode 100644 index bb03920..0000000 --- a/src/windows/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -/* eslint-disable @typescript-eslint/no-require-imports */ - -const { guidepupNvdaVersion } = require("../../package.json"); - -export const GUIDEPUP_NVDA_VERSION = guidepupNvdaVersion; -export const SUB_KEY_GUIDEPUP_NVDA = "HKCU\\Software\\Guidepup\\Nvda"; -export const VERSIONED_KEY = `guidepup_nvda_${GUIDEPUP_NVDA_VERSION}`; - -export const SUB_KEY_CONTROL_PANEL_DESKTOP = "HKCU\\Control Panel\\Desktop"; -export const FOREGROUND_LOCK_TIMEOUT_KEY = "ForegroundLockTimeout"; -export const FOREGROUND_FLASH_COUNT_KEY = "ForegroundFlashCount"; diff --git a/src/windows/createNvdaRegistryKey.ts b/src/windows/createNvdaRegistryKey.ts deleted file mode 100644 index 9232615..0000000 --- a/src/windows/createNvdaRegistryKey.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { promisified as regedit } from "regedit"; -import { SUB_KEY_GUIDEPUP_NVDA } from "./constants"; -import { ERR_WINDOWS_UNABLE_TO_UPDATE_REGISTRY } from "../errors"; - -export async function createNvdaRegistryKey() { - try { - await regedit.createKey([SUB_KEY_GUIDEPUP_NVDA]); - } catch (e) { - throw new Error(`${ERR_WINDOWS_UNABLE_TO_UPDATE_REGISTRY}\n\n${e.message}`); - } -} diff --git a/src/windows/getNvdaRegistryData.ts b/src/windows/getNvdaRegistryData.ts deleted file mode 100644 index 533f4be..0000000 --- a/src/windows/getNvdaRegistryData.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { promisified as regedit } from "regedit"; -import { SUB_KEY_GUIDEPUP_NVDA } from "./constants"; -import { ERR_WINDOWS_UNABLE_TO_ACCESS_REGISTRY } from "../errors"; - -export async function getNvdaRegistryData() { - try { - const { - [SUB_KEY_GUIDEPUP_NVDA]: { exists, values }, - } = await regedit.list([SUB_KEY_GUIDEPUP_NVDA]); - - return { exists, values }; - } catch (e) { - throw new Error(`${ERR_WINDOWS_UNABLE_TO_ACCESS_REGISTRY}\n\n${e.message}`); - } -} diff --git a/src/windows/installNvda.ts b/src/windows/installNvda.ts deleted file mode 100644 index 1407aa3..0000000 --- a/src/windows/installNvda.ts +++ /dev/null @@ -1,88 +0,0 @@ -import decompress from "decompress"; -import { get } from "https"; -import { createWriteStream, mkdirSync, mkdtempSync, rmSync } from "fs"; -import { join } from "path"; -import { tmpdir } from "os"; -import { ERR_WINDOWS_FAILED_TO_INSTALL_NVDA } from "../errors"; -import { GUIDEPUP_NVDA_VERSION } from "./constants"; -import { HttpsProxyAgent } from 'https-proxy-agent'; - -const appName = "guidepup_nvda"; -const sourceUrl = `https://codeload.github.com/guidepup/nvda/zip/refs/tags/${GUIDEPUP_NVDA_VERSION}`; - -export async function installNvda({ - userProvidedInstallDirectory, -}: { - userProvidedInstallDirectory: string; -}): Promise { - if (userProvidedInstallDirectory) { - mkdirSync(userProvidedInstallDirectory, { recursive: true }); - } - - const destinationBaseDirectory = - userProvidedInstallDirectory ?? mkdtempSync(join(tmpdir(), `${appName}_`)); - const destinationZip = join(destinationBaseDirectory, `${appName}.zip`); - const fileZip = createWriteStream(destinationZip); - - function removeAll() { - try { - rmSync(destinationBaseDirectory, { recursive: true }); - } catch { - // swallow - } - } - - function removeZip() { - try { - rmSync(destinationZip, { recursive: true }); - } catch { - // swallow - } - } - - let agent: HttpsProxyAgent | undefined - - const proxyUrl = ( - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy - ) - - if (proxyUrl) { - agent = new HttpsProxyAgent(proxyUrl) - } - - try { - await new Promise((resolve, reject) => { - function onSuccess() { - fileZip.close((error) => { - if (error) { - return reject(error); - } - - resolve(); - }); - } - - const request = get(sourceUrl, { agent }, (response) => response.pipe(fileZip)); - request.on("error", reject); - fileZip.on("finish", onSuccess); - fileZip.on("error", reject); - }); - - await decompress(destinationZip, destinationBaseDirectory); - - removeZip(); - } catch (e) { - removeAll(); - - throw new Error(`${ERR_WINDOWS_FAILED_TO_INSTALL_NVDA}\n\n${e.message}`); - } - - return join( - destinationBaseDirectory, - `nvda-${GUIDEPUP_NVDA_VERSION}`, - "nvda" - ); -} diff --git a/src/windows/isNvdaInstalled.ts b/src/windows/isNvdaInstalled.ts deleted file mode 100644 index c2b67a6..0000000 --- a/src/windows/isNvdaInstalled.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { existsSync } from "fs"; -import { VERSIONED_KEY } from "./constants"; -import { RegistryItemValue } from "regedit"; - -export function isNvdaInstalled({ - exists, - userProvidedInstallDirectory, - values, -}: { - exists: boolean; - userProvidedInstallDirectory: string | null; - values: { - [name: string]: RegistryItemValue; - }; -}) { - if (!exists) { - return false; - } - - const path = values[VERSIONED_KEY]?.value as string; - - if (!path) { - return false; - } - - if (userProvidedInstallDirectory !== path) { - return false; - } - - return existsSync(path); -} diff --git a/src/windows/removeForegroundLock.ts b/src/windows/removeForegroundLock.ts deleted file mode 100644 index 1113fc2..0000000 --- a/src/windows/removeForegroundLock.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { promisified as regedit } from "regedit"; -import { - SUB_KEY_CONTROL_PANEL_DESKTOP, - FOREGROUND_LOCK_TIMEOUT_KEY, - FOREGROUND_FLASH_COUNT_KEY, -} from "./constants"; -import { ERR_WINDOWS_UNABLE_TO_UPDATE_REGISTRY } from "../errors"; - -export async function removeForegroundLock() { - try { - await regedit.putValue({ - [SUB_KEY_CONTROL_PANEL_DESKTOP]: { - [FOREGROUND_LOCK_TIMEOUT_KEY]: { - value: 0, - type: "REG_DWORD", - }, - }, - }); - await regedit.putValue({ - [SUB_KEY_CONTROL_PANEL_DESKTOP]: { - [FOREGROUND_FLASH_COUNT_KEY]: { - value: 0, - type: "REG_DWORD", - }, - }, - }); - } catch (e) { - throw new Error(`${ERR_WINDOWS_UNABLE_TO_UPDATE_REGISTRY}\n\n${e.message}`); - } -} diff --git a/src/windows/restartExplorer.ts b/src/windows/restartExplorer.ts deleted file mode 100644 index 409647c..0000000 --- a/src/windows/restartExplorer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ERR_WINDOWS_FAILED_TO_RESTART_EXPLORER } from "../errors"; -import { runVbsScript } from "./runVbsScript"; - -export async function restartExplorer() { - const script = ` -SET objWMIService = GetObject("winmgmts:{impersonationLevel=impersonate}!" & chr(92) & chr(92) & "." & chr(92) & "root" & chr(92) & "cimv2") -SET colProcesses = objWMIService.ExecQuery("Select * from Win32_Process where Name = 'explorer.exe'") - -FOR EACH objProcess in colProcesses - objProcess.Terminate(1) -NEXT - -SET objWMIService = Nothing -SET colProcesses = Nothing - -SET WshShell = CreateObject("WScript.Shell") -WshShell.Run "explorer.exe" -SET WshShell = Nothing -`; - - try { - await runVbsScript(script); - } catch (e) { - throw new Error(`${ERR_WINDOWS_FAILED_TO_RESTART_EXPLORER}\n\n${e.message}`); - } -} diff --git a/src/windows/runVbsScript.ts b/src/windows/runVbsScript.ts deleted file mode 100644 index 8565f91..0000000 --- a/src/windows/runVbsScript.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { join, sep } from "path"; -import { mkdtemp, realpath, rm, writeFile } from "fs/promises"; -import { execFile } from "child_process"; -import { tmpdir } from "os"; - -const DEFAULT_MAX_BUFFER = 1000 * 1000 * 100; -const CSCRIPT = "cscript"; - -const withTempFile = async (fn) => - await withTempDir((dir: string) => fn(join(dir, "script.vbs"))); - -const withTempDir = async (fn) => { - const dir = await mkdtemp((await realpath(tmpdir())) + sep); - - try { - return await fn(dir); - } finally { - await rm(dir, { recursive: true }); - } -}; - -export async function runVbsScript(script: string): Promise { - return await withTempFile(async (filePath: string) => { - await writeFile(filePath, script); - - return new Promise((resolve, reject) => { - const child = execFile( - CSCRIPT, - [filePath], - { - maxBuffer: DEFAULT_MAX_BUFFER, - }, - (e, stdout) => { - if (e) { - return reject(e); - } - - if (!stdout) { - return resolve(); - } else { - return resolve(stdout.trim()); - } - } - ); - - child.stdin.end(); - }); - }); -} diff --git a/src/windows/setup.ts b/src/windows/setup.ts deleted file mode 100644 index f4cb484..0000000 --- a/src/windows/setup.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { getNvdaRegistryData } from "./getNvdaRegistryData"; -import { isNvdaInstalled } from "./isNvdaInstalled"; -import { createNvdaRegistryKey } from "./createNvdaRegistryKey"; -import { installNvda } from "./installNvda"; -import { updateNvdaRegistryData } from "./updateNvdaRegistryData"; -import { removeForegroundLock } from "./removeForegroundLock"; -import { restartExplorer } from "./restartExplorer"; -import { resolve } from "path"; - -export async function setup(): Promise { - const userProvidedInstallDirectoryFlagIndex = - process.argv.indexOf("--nvda-install-dir"); - const userProvidedInstallDirectoryRaw = - userProvidedInstallDirectoryFlagIndex > -1 - ? process.argv.at(userProvidedInstallDirectoryFlagIndex + 1) ?? null - : null; - const userProvidedInstallDirectory = userProvidedInstallDirectoryRaw - ? resolve(userProvidedInstallDirectoryRaw) - : null; - - const { exists, values } = await getNvdaRegistryData(); - - if (isNvdaInstalled({ exists, userProvidedInstallDirectory, values })) { - return; - } - - if (!exists) { - await createNvdaRegistryKey(); - } - - const nvdaDirectory = await installNvda({ userProvidedInstallDirectory }); - - await updateNvdaRegistryData({ nvdaDirectory }); - await removeForegroundLock(); - await restartExplorer(); -} diff --git a/src/windows/updateNvdaRegistryData.ts b/src/windows/updateNvdaRegistryData.ts deleted file mode 100644 index b257dc2..0000000 --- a/src/windows/updateNvdaRegistryData.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { promisified as regedit } from "regedit"; -import { SUB_KEY_GUIDEPUP_NVDA, VERSIONED_KEY } from "./constants"; -import { ERR_WINDOWS_UNABLE_TO_UPDATE_REGISTRY } from "../errors"; - -export async function updateNvdaRegistryData({ nvdaDirectory }) { - try { - await regedit.putValue({ - [SUB_KEY_GUIDEPUP_NVDA]: { - [VERSIONED_KEY]: { - value: nvdaDirectory, - type: "REG_SZ", - }, - }, - }); - } catch (e) { - throw new Error(`${ERR_WINDOWS_UNABLE_TO_UPDATE_REGISTRY}\n\n${e.message}`); - } -} diff --git a/yarn.lock b/yarn.lock index 5758c53..7863f63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -455,6 +455,11 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +commander@^15.0.0: + version "15.0.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-15.0.0.tgz#96f3961f12adac1799ef3fbd8bc61d40572d1b11" + integrity sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg== + commander@^2.8.1: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" From a3bb4a8881a43855afd52a6c9e419cf10cb8da81 Mon Sep 17 00:00:00 2001 From: Craig Morten Date: Sun, 12 Jul 2026 18:45:38 +0100 Subject: [PATCH 02/29] Apply suggestion from @cmorten --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9c616f6..da5f0e5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@guidepup/setup", "version": "0.24.0", - "description": "Configure the local environment and manage screen readers for Guidepup.", + "description": "Configure your environment and manage screen readers for Guidepup.", "main": "lib/index.js", "typings": "lib/index.d.ts", "bin": { From 8acc82ee4daa0a68c128d0774450f3e20d54de60 Mon Sep 17 00:00:00 2001 From: cmorten Date: Sun, 12 Jul 2026 21:06:35 +0100 Subject: [PATCH 03/29] feat: initial manifest check --- src/commands/install/index.ts | 36 +++- src/commands/install/manifest.ts | 157 ++++++++++++++++++ src/commands/setup/index.ts | 10 +- src/commands/setup/macOS/checkVersion.ts | 8 +- .../macOS/disableDictationInputAutoEnable.ts | 4 +- .../disableSplashScreenSystemDefaults.ts | 4 +- .../enableAppleScriptControlSystemDefaults.ts | 4 +- .../setup/macOS/enableDoNotDisturb.ts | 4 +- src/commands/setup/macOS/isSipEnabled.ts | 4 +- src/commands/setup/macOS/setup.ts | 4 +- src/commands/setup/macOS/updateTccDb.ts | 4 +- src/commands/setup/macOS/writeDatabaseFile.ts | 4 +- src/errors.ts | 27 ++- src/logging.ts | 33 +++- 14 files changed, 265 insertions(+), 38 deletions(-) create mode 100644 src/commands/install/manifest.ts diff --git a/src/commands/install/index.ts b/src/commands/install/index.ts index 1599ea1..a935ffa 100644 --- a/src/commands/install/index.ts +++ b/src/commands/install/index.ts @@ -1,10 +1,40 @@ import { Command } from "commander"; +import { + handleInstallComplete, + handleInstallError, + logInfo, +} from "../../logging"; +import { + type InstallTarget, + resolveGuidepupManifest, + selectInstallTargets, +} from "./manifest"; + +async function install(screenreader?: string): Promise { + let targets: InstallTarget[]; + + try { + const manifest = resolveGuidepupManifest(); + + targets = selectInstallTargets(manifest, screenreader); + } catch (error) { + handleInstallError(error); + } + + if (targets.length === 0) { + logInfo("No installable screen readers were found for this environment"); + } else { + logInfo( + `Resolved ${targets.length} install target(s): ${targets.map((target) => target.name).join(", ")}`, + ); + } + + handleInstallComplete(); +} export function installCommand() { return new Command("install") .description("Install screen readers.") .argument("[screenreader]", "screen readers to install") - .action((screenreader?: string) => { - console.log("TODO", { screenreader }); - }); + .action(install); } diff --git a/src/commands/install/manifest.ts b/src/commands/install/manifest.ts new file mode 100644 index 0000000..78a9c6d --- /dev/null +++ b/src/commands/install/manifest.ts @@ -0,0 +1,157 @@ +import { existsSync, readFileSync } from "node:fs"; +import * as path from "node:path"; +import { platform } from "node:os"; +import { + ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID, + ERR_INSTALL_GUIDEPUP_MANIFEST_UNSUPPORTED_SCHEMA, + ERR_INSTALL_GUIDEPUP_PACKAGE_NOT_FOUND, + ERR_INSTALL_GUIDEPUP_SCREEN_READER_UNAVAILABLE, +} from "../../errors"; + +const SUPPORTED_SCHEMA_VERSIONS = [1]; + +interface ManifestScreenReader { + id?: string; + name?: string; + platforms?: string[]; + defaultDownload?: boolean; + assets?: unknown[]; + [key: string]: unknown; +} + +export interface InstallTarget { + name: string; + data: ManifestScreenReader; +} + +function isCompatibleWithCurrentPlatform( + screenReader: ManifestScreenReader, +): boolean { + const compatiblePlatforms = screenReader.platforms; + + if (!compatiblePlatforms || compatiblePlatforms.length === 0) { + return true; + } + + const currentPlatform = platform(); + + return compatiblePlatforms.some((candidate) => { + if (typeof candidate !== "string") { + return false; + } + + return candidate.toLowerCase() === currentPlatform; + }); +} + +function getScreenReaders( + manifest: Record, +): Record { + const screenReaders = manifest.screenReaders; + + if (!Array.isArray(screenReaders) || screenReaders.length === 0) { + throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID); + } + + return screenReaders.reduce>( + (accumulator, screenReader) => { + if (!screenReader || typeof screenReader !== "object") { + return accumulator; + } + + const screenReaderRecord = screenReader as Record; + const id = screenReaderRecord.id; + + if (typeof id === "string" && id.length > 0) { + accumulator[id] = screenReaderRecord as ManifestScreenReader; + } + + return accumulator; + }, + {}, + ); +} + +export function resolveGuidepupManifest(): Record { + let packageJsonPath: string; + + try { + packageJsonPath = require.resolve("@guidepup/guidepup/package.json", { + paths: [process.cwd()], + }); + } catch { + throw new Error(ERR_INSTALL_GUIDEPUP_PACKAGE_NOT_FOUND); + } + + const manifestPath = path.join( + path.dirname(packageJsonPath), + "manifest.json", + ); + + if (!existsSync(manifestPath)) { + throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID); + } + + let manifest: unknown; + + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch { + throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID); + } + + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID); + } + + const manifestRecord = manifest as Record; + const schemaVersion = manifestRecord.version; + + if ( + typeof schemaVersion !== "number" || + !SUPPORTED_SCHEMA_VERSIONS.includes(schemaVersion) + ) { + throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_UNSUPPORTED_SCHEMA); + } + + return manifestRecord; +} + +export function selectInstallTargets( + manifest: Record, + requestedScreenReader?: string, +): InstallTarget[] { + const screenReaders = getScreenReaders(manifest); + + const selectedNames = requestedScreenReader + ? [requestedScreenReader] + : Object.keys(screenReaders).filter((name) => { + const screenReader = screenReaders[name]; + + return ( + screenReader?.defaultDownload === true && + isCompatibleWithCurrentPlatform(screenReader) + ); + }); + + const targets = selectedNames + .map((name) => ({ + name, + data: screenReaders[name], + })) + .filter((target) => { + if (!target.data) { + return false; + } + + return isCompatibleWithCurrentPlatform(target.data); + }); + + if (requestedScreenReader && targets.length === 0) { + throw new Error( + `${ERR_INSTALL_GUIDEPUP_SCREEN_READER_UNAVAILABLE}: ${requestedScreenReader}`, + ); + } + + return targets; +} diff --git a/src/commands/setup/index.ts b/src/commands/setup/index.ts index b45ba19..1aaafec 100644 --- a/src/commands/setup/index.ts +++ b/src/commands/setup/index.ts @@ -1,8 +1,8 @@ import { platform } from "node:os"; import { Command } from "commander"; import { setup as setupMacOS } from "./macOS/setup"; -import { ERR_UNSUPPORTED_OS } from "../../errors"; -import { handleError, handleSetupComplete } from "../../logging"; +import { ERR_SETUP_UNSUPPORTED_OS } from "../../errors"; +import { handleSetupError, handleSetupComplete } from "../../logging"; interface SetupCommandOptions { ci: boolean; @@ -24,11 +24,11 @@ async function setup(options: SetupCommandOptions): Promise { break; } default: { - throw new Error(ERR_UNSUPPORTED_OS); + throw new Error(ERR_SETUP_UNSUPPORTED_OS); } } - } catch (e) { - handleError(e); + } catch (error) { + handleSetupError(error); } handleSetupComplete(); diff --git a/src/commands/setup/macOS/checkVersion.ts b/src/commands/setup/macOS/checkVersion.ts index 6880437..75e730b 100644 --- a/src/commands/setup/macOS/checkVersion.ts +++ b/src/commands/setup/macOS/checkVersion.ts @@ -4,8 +4,8 @@ import { readFileSync } from "node:fs"; import { satisfies } from "semver"; import { - ERR_MACOS_UNABLE_TO_VERIFY_VERSION, - ERR_MACOS_UNSUPPORTED_VERSION, + ERR_SETUP_MACOS_UNABLE_TO_VERIFY_VERSION, + ERR_SETUP_MACOS_UNSUPPORTED_VERSION, } from "../../../errors"; function clean(version: string): string { @@ -29,12 +29,12 @@ export function checkVersion(): void { /ProductVersion<\/key>\s*([\d.]+)<\/string>/.exec(plist); if (!matches) { - throw new Error(ERR_MACOS_UNABLE_TO_VERIFY_VERSION); + throw new Error(ERR_SETUP_MACOS_UNABLE_TO_VERIFY_VERSION); } const version = clean(matches[1].replace("10.16", "11")); if (!satisfies(version, ">=11.0.0")) { - throw new Error(ERR_MACOS_UNSUPPORTED_VERSION); + throw new Error(ERR_SETUP_MACOS_UNSUPPORTED_VERSION); } } diff --git a/src/commands/setup/macOS/disableDictationInputAutoEnable.ts b/src/commands/setup/macOS/disableDictationInputAutoEnable.ts index 244ab6d..8558788 100644 --- a/src/commands/setup/macOS/disableDictationInputAutoEnable.ts +++ b/src/commands/setup/macOS/disableDictationInputAutoEnable.ts @@ -1,5 +1,5 @@ import { execSync } from "child_process"; -import { ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS } from "../../../errors"; +import { ERR_SETUP_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS } from "../../../errors"; export function disableDictationInputAutoEnable(): void { try { @@ -8,6 +8,6 @@ export function disableDictationInputAutoEnable(): void { { encoding: "utf8" }, ); } catch (cause) { - throw new Error(ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS, { cause }); + throw new Error(ERR_SETUP_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS, { cause }); } } diff --git a/src/commands/setup/macOS/disableSplashScreenSystemDefaults.ts b/src/commands/setup/macOS/disableSplashScreenSystemDefaults.ts index 670ad08..f94edd5 100644 --- a/src/commands/setup/macOS/disableSplashScreenSystemDefaults.ts +++ b/src/commands/setup/macOS/disableSplashScreenSystemDefaults.ts @@ -1,5 +1,5 @@ import { execSync } from "child_process"; -import { ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS } from "../../../errors"; +import { ERR_SETUP_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS } from "../../../errors"; export function disableSplashScreenSystemDefaults(): void { try { @@ -8,6 +8,6 @@ export function disableSplashScreenSystemDefaults(): void { { encoding: "utf8" }, ); } catch (cause) { - throw new Error(ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS, { cause }); + throw new Error(ERR_SETUP_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS, { cause }); } } diff --git a/src/commands/setup/macOS/enableAppleScriptControlSystemDefaults.ts b/src/commands/setup/macOS/enableAppleScriptControlSystemDefaults.ts index e401db9..9468d0f 100644 --- a/src/commands/setup/macOS/enableAppleScriptControlSystemDefaults.ts +++ b/src/commands/setup/macOS/enableAppleScriptControlSystemDefaults.ts @@ -1,5 +1,5 @@ import { execSync } from "child_process"; -import { ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS } from "../../../errors"; +import { ERR_SETUP_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS } from "../../../errors"; export function enableAppleScriptControlSystemDefaults(): void { try { @@ -10,6 +10,6 @@ export function enableAppleScriptControlSystemDefaults(): void { return; } catch (cause) { - throw new Error(ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS, { cause }); + throw new Error(ERR_SETUP_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS, { cause }); } } diff --git a/src/commands/setup/macOS/enableDoNotDisturb.ts b/src/commands/setup/macOS/enableDoNotDisturb.ts index 1db3d41..4f059d7 100644 --- a/src/commands/setup/macOS/enableDoNotDisturb.ts +++ b/src/commands/setup/macOS/enableDoNotDisturb.ts @@ -1,6 +1,6 @@ import { exec } from "child_process"; import { promisify } from "util"; -import { ERR_MACOS_FAILED_TO_ENABLE_DO_NOT_DISTURB } from "../../../errors"; +import { ERR_SETUP_MACOS_FAILED_TO_ENABLE_DO_NOT_DISTURB } from "../../../errors"; import { runAppleScript } from "./runAppleScript"; import { retryOnError } from "./retryOnError"; import { getPlatformVersionMajor } from "./getPlatformVersionMajor"; @@ -116,6 +116,6 @@ export async function enableDoNotDisturb() { ); } } catch (cause) { - throw new Error(ERR_MACOS_FAILED_TO_ENABLE_DO_NOT_DISTURB, { cause }); + throw new Error(ERR_SETUP_MACOS_FAILED_TO_ENABLE_DO_NOT_DISTURB, { cause }); } } diff --git a/src/commands/setup/macOS/isSipEnabled.ts b/src/commands/setup/macOS/isSipEnabled.ts index e7a5c05..885509f 100644 --- a/src/commands/setup/macOS/isSipEnabled.ts +++ b/src/commands/setup/macOS/isSipEnabled.ts @@ -1,5 +1,5 @@ import { execSync } from "child_process"; -import { ERR_MACOS_UNABLE_TO_VERIFY_SIP } from "../../../errors"; +import { ERR_SETUP_MACOS_UNABLE_TO_VERIFY_SIP } from "../../../errors"; export function isSipEnabled(): boolean { let commandResult: string; @@ -7,7 +7,7 @@ export function isSipEnabled(): boolean { try { commandResult = execSync("csrutil status", { encoding: "utf8" }); } catch (cause) { - throw new Error(ERR_MACOS_UNABLE_TO_VERIFY_SIP, { cause }); + throw new Error(ERR_SETUP_MACOS_UNABLE_TO_VERIFY_SIP, { cause }); } return commandResult.includes("enabled"); diff --git a/src/commands/setup/macOS/setup.ts b/src/commands/setup/macOS/setup.ts index c365f13..9880503 100644 --- a/src/commands/setup/macOS/setup.ts +++ b/src/commands/setup/macOS/setup.ts @@ -8,7 +8,7 @@ import { writeDatabaseFile } from "./writeDatabaseFile"; import { SYSTEM_PATH, USER_PATH, updateTccDb } from "./updateTccDb"; import { isAppleScriptControlEnabled } from "./isAppleScriptControlEnabled"; import { handleSetupManualRequired, handleWarning } from "../../../logging"; -import { ERR_MACOS_REQUIRES_MANUAL_USER_INTERACTION } from "../../../errors"; +import { ERR_SETUP_MACOS_REQUIRES_MANUAL_USER_INTERACTION } from "../../../errors"; import { enableDoNotDisturb } from "./enableDoNotDisturb"; import { enabledDbFile } from "./isAppleScriptControlEnabled/enabledDbFile"; @@ -85,7 +85,7 @@ export async function setup({ } if (ci) { - throw new Error(ERR_MACOS_REQUIRES_MANUAL_USER_INTERACTION); + throw new Error(ERR_SETUP_MACOS_REQUIRES_MANUAL_USER_INTERACTION); } handleSetupManualRequired(); diff --git a/src/commands/setup/macOS/updateTccDb.ts b/src/commands/setup/macOS/updateTccDb.ts index eacfb10..f63d546 100644 --- a/src/commands/setup/macOS/updateTccDb.ts +++ b/src/commands/setup/macOS/updateTccDb.ts @@ -1,6 +1,6 @@ import { release } from "os"; import { execSync } from "child_process"; -import { ERR_MACOS_UNABLE_TO_WRITE_USER_TCC_DB } from "../../../errors"; +import { ERR_SETUP_MACOS_UNABLE_TO_WRITE_USER_TCC_DB } from "../../../errors"; const epoch = +Date.now(); @@ -198,7 +198,7 @@ export function updateTccDb(path: string): void { encoding: "utf8", }); } catch (cause) { - throw new Error(ERR_MACOS_UNABLE_TO_WRITE_USER_TCC_DB, { cause }); + throw new Error(ERR_SETUP_MACOS_UNABLE_TO_WRITE_USER_TCC_DB, { cause }); } } } diff --git a/src/commands/setup/macOS/writeDatabaseFile.ts b/src/commands/setup/macOS/writeDatabaseFile.ts index 42c0c1f..73e413a 100644 --- a/src/commands/setup/macOS/writeDatabaseFile.ts +++ b/src/commands/setup/macOS/writeDatabaseFile.ts @@ -1,5 +1,5 @@ import { writeFileSync } from "fs"; -import { ERR_MACOS_UNABLE_TO_WRITE_DATABASE_FILE } from "../../../errors"; +import { ERR_SETUP_MACOS_UNABLE_TO_WRITE_DATABASE_FILE } from "../../../errors"; export function writeDatabaseFile(): void { try { @@ -8,6 +8,6 @@ export function writeDatabaseFile(): void { "a", ); } catch (cause) { - throw new Error(ERR_MACOS_UNABLE_TO_WRITE_DATABASE_FILE, { cause }); + throw new Error(ERR_SETUP_MACOS_UNABLE_TO_WRITE_DATABASE_FILE, { cause }); } } diff --git a/src/errors.ts b/src/errors.ts index 98c8a64..fb649a2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,19 +1,28 @@ -export const ERR_UNSUPPORTED_OS = +export const ERR_SETUP_UNSUPPORTED_OS = "Unsupported OS - consider contributing to Guidepup?"; -export const ERR_MACOS_UNABLE_TO_VERIFY_VERSION = +export const ERR_SETUP_MACOS_UNABLE_TO_VERIFY_VERSION = "Unable to verify macOS version"; -export const ERR_MACOS_UNSUPPORTED_VERSION = +export const ERR_SETUP_MACOS_UNSUPPORTED_VERSION = "Require macOS version 11 or later"; -export const ERR_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS = +export const ERR_SETUP_MACOS_UNABLE_UPDATE_SYSTEM_DEFAULTS = "Unable to update system defaults"; -export const ERR_MACOS_UNABLE_TO_VERIFY_SIP = +export const ERR_SETUP_MACOS_UNABLE_TO_VERIFY_SIP = "Unable to verify macOS SIP status"; -export const ERR_MACOS_UNABLE_TO_WRITE_DATABASE_FILE = +export const ERR_SETUP_MACOS_UNABLE_TO_WRITE_DATABASE_FILE = "Unable to write to the VoiceOver database file\n\nEnsure that SIP is disabled or pass '--macos-ignore-tcc-db' to ignore TCC database updates"; -export const ERR_MACOS_UNABLE_TO_WRITE_USER_TCC_DB = +export const ERR_SETUP_MACOS_UNABLE_TO_WRITE_USER_TCC_DB = "Unable to write to the user TCC database\n\nEnsure that SIP is disabled or pass '--macos-ignore-tcc-db' to ignore TCC database updates"; -export const ERR_MACOS_REQUIRES_MANUAL_USER_INTERACTION = +export const ERR_SETUP_MACOS_REQUIRES_MANUAL_USER_INTERACTION = "Unable to setup environment without manual user interaction\n\nEnsure that SIP is disabled or preconfigure your environment with 'Allow VoiceOver to be controlled with AppleScript' enabled"; -export const ERR_MACOS_FAILED_TO_ENABLE_DO_NOT_DISTURB = +export const ERR_SETUP_MACOS_FAILED_TO_ENABLE_DO_NOT_DISTURB = 'Failed to enable "Do not disturb" mode'; + +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 = + "The installed '@guidepup/guidepup' manifest appears to be invalid or corrupted\n\nPlease re-install '@guidepup/guidepup'"; +export const ERR_INSTALL_GUIDEPUP_MANIFEST_UNSUPPORTED_SCHEMA = + "Unsupported manifest schema version\n\nPlease upgrade '@guidepup/setup'"; +export const ERR_INSTALL_GUIDEPUP_SCREEN_READER_UNAVAILABLE = + "The requested screen reader is not available for this platform"; diff --git a/src/logging.ts b/src/logging.ts index ea34b8c..5fe5d00 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -4,6 +4,14 @@ export const logInfo = console.info.bind(console); export const logWarn = console.warn.bind(console); export const logError = console.error.bind(console); +export function handleInstallComplete(): never { + logInfo(""); + logInfo(chalk.green("Installation complete 🎉")); + logInfo(""); + + process.exit(0); +} + export function handleSetupComplete(): never { logInfo(""); logInfo(chalk.green("Environment setup complete 🎉")); @@ -31,7 +39,30 @@ export function handleWarning(title: string, subtitle: string): void { logError(""); } -export function handleError(err: Error): never { +export function handleInstallError(err: Error): never { + let message = err.message; + + if (err.name) { + message = `${err.name}: ${message}`; + } + + logError(""); + logError(chalk.bold(chalk.red(`[!] ${chalk.bold(message.toString())}`))); + logError(""); + logError("Unable to complete screen reader installation"); + logError(""); + logError( + chalk.dim( + "Please raise new issues at: " + + chalk.underline("https://github.com/guidepup/setup/issues"), + ), + ); + logError(""); + + process.exit(1); +} + +export function handleSetupError(err: Error): never { let message = err.message; if (err.name) { From 5d4af9bdc8e8a344775a29161d5bbcda97ddb3b0 Mon Sep 17 00:00:00 2001 From: cmorten Date: Sun, 12 Jul 2026 21:12:52 +0100 Subject: [PATCH 04/29] ci: first draft test workflow for install --- .github/workflows/test.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 87d9be7..41aae04 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,3 +53,21 @@ jobs: name: artifacts-test-ignore-tcc-db-${{ matrix.os }} path: | **/recordings/**/* + + install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + - run: yarn install --frozen-lockfile + - run: yarn build + - run: | + tmpdir=$(mktemp -d) + cd "$tmpdir" + npm init -y + npm install @guidepup/guidepup + npm install -g "${GITHUB_WORKSPACE}" + export PATH="$(npm bin -g):$PATH" + guidepup install From 23410929130a21042d5333aeb34a1e054f635e16 Mon Sep 17 00:00:00 2001 From: cmorten Date: Sun, 12 Jul 2026 21:18:18 +0100 Subject: [PATCH 05/29] ci: dummy manifest --- .github/workflows/test.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 41aae04..cf7e85c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,4 +70,29 @@ jobs: 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": "voiceover", + "name": "VoiceOver", + "platforms": ["darwin"], + "defaultDownload": true, + "assets": [ + { + "version": "1.0.0", + "platformVersion": "26", + "repository": "guidepup/voiceover", + "release": "v1.0.0", + "asset": "voiceover-macos-26.dmg", + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + ] + } + ] + } + EOF + guidepup install From 662ef106549eb73be0daf0a58c981f598a6504c5 Mon Sep 17 00:00:00 2001 From: cmorten Date: Sun, 12 Jul 2026 21:22:57 +0100 Subject: [PATCH 06/29] temp: logs --- src/commands/install/index.ts | 4 ++++ src/commands/install/manifest.ts | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/commands/install/index.ts b/src/commands/install/index.ts index a935ffa..3d46dd1 100644 --- a/src/commands/install/index.ts +++ b/src/commands/install/index.ts @@ -13,9 +13,13 @@ import { async function install(screenreader?: string): Promise { let targets: InstallTarget[]; + console.log({ screenreader }); + try { const manifest = resolveGuidepupManifest(); + console.log({ manifest }); + targets = selectInstallTargets(manifest, screenreader); } catch (error) { handleInstallError(error); diff --git a/src/commands/install/manifest.ts b/src/commands/install/manifest.ts index 78a9c6d..4e24433 100644 --- a/src/commands/install/manifest.ts +++ b/src/commands/install/manifest.ts @@ -35,6 +35,8 @@ function isCompatibleWithCurrentPlatform( const currentPlatform = platform(); + console.log({ currentPlatform }); + return compatiblePlatforms.some((candidate) => { if (typeof candidate !== "string") { return false; @@ -123,6 +125,8 @@ export function selectInstallTargets( ): InstallTarget[] { const screenReaders = getScreenReaders(manifest); + console.log({ screenReaders }); + const selectedNames = requestedScreenReader ? [requestedScreenReader] : Object.keys(screenReaders).filter((name) => { @@ -134,6 +138,8 @@ export function selectInstallTargets( ); }); + console.log({ selectedNames }); + const targets = selectedNames .map((name) => ({ name, @@ -147,6 +153,8 @@ export function selectInstallTargets( return isCompatibleWithCurrentPlatform(target.data); }); + console.log({ targets }); + if (requestedScreenReader && targets.length === 0) { throw new Error( `${ERR_INSTALL_GUIDEPUP_SCREEN_READER_UNAVAILABLE}: ${requestedScreenReader}`, From 54a9d8ce68d0780433ae1bdb1486063a2a0c3ab0 Mon Sep 17 00:00:00 2001 From: cmorten Date: Sun, 12 Jul 2026 21:24:28 +0100 Subject: [PATCH 07/29] feat: macos --- .github/workflows/test.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf7e85c..5e9d572 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: windows-11-arm, ] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version-file: .nvmrc @@ -41,7 +41,7 @@ jobs: matrix: os: [macos-latest] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version-file: .nvmrc @@ -55,9 +55,12 @@ jobs: **/recordings/**/* install: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [macos-14, macos-15, macos-15-intel, macos-26, macos-26-intel] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version-file: .nvmrc From 8401dbe7c50dee0836f5d640562a80ad101ece4b Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 16:55:30 +0100 Subject: [PATCH 08/29] feat: manifest resolution, cache, and download --- .github/workflows/test.yml | 21 ++- src/commands/install/download-assets.ts | 98 ++++++++++ .../install/download-release-asset.ts | 27 +++ src/commands/install/index.ts | 25 ++- src/commands/install/manifest.ts | 165 ----------------- src/commands/install/resolve-cache-path.ts | 50 ++++++ src/commands/install/resolve-manifest.ts | 50 ++++++ src/commands/install/select-targets.ts | 54 ++++++ src/commands/install/sha256.ts | 13 ++ src/commands/install/types.ts | 20 +++ src/commands/install/validate-manifest.ts | 169 ++++++++++++++++++ src/commands/install/verify-asset-checksum.ts | 54 ++++++ .../install/verify-cached-asset-checksum.ts | 34 ++++ src/errors.ts | 12 +- src/logging.ts | 4 + 15 files changed, 613 insertions(+), 183 deletions(-) create mode 100644 src/commands/install/download-assets.ts create mode 100644 src/commands/install/download-release-asset.ts delete mode 100644 src/commands/install/manifest.ts create mode 100644 src/commands/install/resolve-cache-path.ts create mode 100644 src/commands/install/resolve-manifest.ts create mode 100644 src/commands/install/select-targets.ts create mode 100644 src/commands/install/sha256.ts create mode 100644 src/commands/install/types.ts create mode 100644 src/commands/install/validate-manifest.ts create mode 100644 src/commands/install/verify-asset-checksum.ts create mode 100644 src/commands/install/verify-cached-asset-checksum.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5e9d572..3843c84 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -85,12 +85,25 @@ jobs: "defaultDownload": true, "assets": [ { - "version": "1.0.0", + "version": "0.0.1", + "platformVersion": "24", + "repository": "guidepup/voiceover", + "asset": "guidepup-voiceover-preferences-macos-24.dmg", + "sha256": "c6595f5ca50440e8553cde278936a46eff58d4c904e19c87e7d0e25950617ee1" + }, + { + "version": "0.0.1", + "platformVersion": "25", + "repository": "guidepup/voiceover", + "asset": "guidepup-voiceover-preferences-macos-25.dmg", + "sha256": "8bdc3a11c45a19cc876a859bfbd64529469a8b7d4ad41fd7e00a0729ebdbcb25" + }, + { + "version": "0.0.1", "platformVersion": "26", "repository": "guidepup/voiceover", - "release": "v1.0.0", - "asset": "voiceover-macos-26.dmg", - "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + "asset": "guidepup-voiceover-preferences-macos-26.dmg", + "sha256": "9af2a3af7c9bffae1b2af26f1970548ecd62f33965fd30f4e43f21b9f57ce5ca" } ] } diff --git a/src/commands/install/download-assets.ts b/src/commands/install/download-assets.ts new file mode 100644 index 0000000..ee0c6dc --- /dev/null +++ b/src/commands/install/download-assets.ts @@ -0,0 +1,98 @@ +import { platform, release } from "node:os"; +import { dirname, join } from "node:path"; +import { mkdirSync } from "node:fs"; +import type { Asset, ScreenReader } from "./types"; +import { + ERR_INSTALL_SCREEN_READER_ASSET_UNAVAILABLE, + ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH, +} from "../../errors"; +import { verifyCachedAssetChecksum } from "./verify-cached-asset-checksum"; +import { downloadReleaseAsset } from "./download-release-asset"; +import { verifyAssetChecksum } from "./verify-asset-checksum"; +import { handleDownload } from "../../logging"; + +function platformMajorVersion(): string { + return release().split(".", 1)[0]; +} + +function selectAsset(screenReader: ScreenReader): Asset { + const currentPlatform = platform(); + const currentPlatformVersion = platformMajorVersion(); + + const asset = screenReader.assets.find( + (asset) => + !asset.platformVersion || + asset.platformVersion === currentPlatformVersion, + ); + + if (!asset) { + throw new Error( + `${ERR_INSTALL_SCREEN_READER_ASSET_UNAVAILABLE}: ${screenReader.name} on ${currentPlatform} ${currentPlatformVersion}`, + ); + } + + return asset; +} + +async function downloadScreenReader( + cachePath: string, + screenReader: ScreenReader, +): Promise { + const asset = selectAsset(screenReader); + + const destination = join( + cachePath, + screenReader.id, + asset.platformVersion ?? "", + asset.version, + asset.asset, + ); + + const currentPlatform = platform(); + const versionMessage = `${asset.version}${asset.platformVersion ? ` - ${currentPlatform} ${asset.platformVersion}` : ""}`; + + if (await verifyCachedAssetChecksum(destination, asset.sha256)) { + handleDownload( + `${screenReader.name} (${versionMessage}) already in cache at`, + destination, + ); + + return; + } + + try { + mkdirSync(dirname(destination), { recursive: true }); + } catch (cause) { + throw new Error( + ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH, + cause, + ); + } + + const source = `https://github.com/${asset.repository}/releases/download/v${asset.version}/${asset.asset}`; + + handleDownload( + `Downloading ${screenReader.name} (${versionMessage}) from`, + source, + ); + + await downloadReleaseAsset(asset, source, destination); + + handleDownload( + `${screenReader.name} (${versionMessage}) downloaded to`, + destination, + ); + + await verifyAssetChecksum(destination, asset.sha256); +} + +export async function downloadAssets( + cachePath: string, + screenReaders: ScreenReader[], +): Promise { + await Promise.all( + screenReaders.map((screenReader) => + downloadScreenReader(cachePath, screenReader), + ), + ); +} diff --git a/src/commands/install/download-release-asset.ts b/src/commands/install/download-release-asset.ts new file mode 100644 index 0000000..d4193a2 --- /dev/null +++ b/src/commands/install/download-release-asset.ts @@ -0,0 +1,27 @@ +import { createWriteStream } from "node:fs"; +import { pipeline } from "node:stream/promises"; +import type { Asset } from "./types"; +import { ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET } from "../../errors"; + +export async function downloadReleaseAsset( + asset: Asset, + source: string, + destination: string, +): Promise { + const response = await fetch(source); + + if (!response.ok || !response.body) { + throw new Error( + `${ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET}: ${asset.asset} (${response.status} ${response.statusText})`, + ); + } + + try { + await pipeline(response.body, createWriteStream(destination)); + } catch (cause) { + throw new Error( + `${ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET}: ${asset.asset} (${response.status} ${response.statusText})`, + { cause }, + ); + } +} diff --git a/src/commands/install/index.ts b/src/commands/install/index.ts index 3d46dd1..98acf7e 100644 --- a/src/commands/install/index.ts +++ b/src/commands/install/index.ts @@ -4,23 +4,22 @@ import { handleInstallError, logInfo, } from "../../logging"; -import { - type InstallTarget, - resolveGuidepupManifest, - selectInstallTargets, -} from "./manifest"; - -async function install(screenreader?: string): Promise { - let targets: InstallTarget[]; +import { selectTargets } from "./select-targets"; +import { resolveManifest } from "./resolve-manifest"; +import type { ScreenReader } from "./types"; +import { resolveCachePath } from "./resolve-cache-path"; +import { downloadAssets } from "./download-assets"; - console.log({ screenreader }); +async function install(requestedScreenReaderIds?: string[]): Promise { + let targets: ScreenReader[]; try { - const manifest = resolveGuidepupManifest(); + const manifest = resolveManifest(); + const cachePath = resolveCachePath(); - console.log({ manifest }); + targets = selectTargets(manifest, requestedScreenReaderIds); - targets = selectInstallTargets(manifest, screenreader); + await downloadAssets(cachePath, targets); } catch (error) { handleInstallError(error); } @@ -39,6 +38,6 @@ async function install(screenreader?: string): Promise { export function installCommand() { return new Command("install") .description("Install screen readers.") - .argument("[screenreader]", "screen readers to install") + .argument("[screenReaders...]", "Screen readers to install") .action(install); } diff --git a/src/commands/install/manifest.ts b/src/commands/install/manifest.ts deleted file mode 100644 index 4e24433..0000000 --- a/src/commands/install/manifest.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import * as path from "node:path"; -import { platform } from "node:os"; -import { - ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID, - ERR_INSTALL_GUIDEPUP_MANIFEST_UNSUPPORTED_SCHEMA, - ERR_INSTALL_GUIDEPUP_PACKAGE_NOT_FOUND, - ERR_INSTALL_GUIDEPUP_SCREEN_READER_UNAVAILABLE, -} from "../../errors"; - -const SUPPORTED_SCHEMA_VERSIONS = [1]; - -interface ManifestScreenReader { - id?: string; - name?: string; - platforms?: string[]; - defaultDownload?: boolean; - assets?: unknown[]; - [key: string]: unknown; -} - -export interface InstallTarget { - name: string; - data: ManifestScreenReader; -} - -function isCompatibleWithCurrentPlatform( - screenReader: ManifestScreenReader, -): boolean { - const compatiblePlatforms = screenReader.platforms; - - if (!compatiblePlatforms || compatiblePlatforms.length === 0) { - return true; - } - - const currentPlatform = platform(); - - console.log({ currentPlatform }); - - return compatiblePlatforms.some((candidate) => { - if (typeof candidate !== "string") { - return false; - } - - return candidate.toLowerCase() === currentPlatform; - }); -} - -function getScreenReaders( - manifest: Record, -): Record { - const screenReaders = manifest.screenReaders; - - if (!Array.isArray(screenReaders) || screenReaders.length === 0) { - throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID); - } - - return screenReaders.reduce>( - (accumulator, screenReader) => { - if (!screenReader || typeof screenReader !== "object") { - return accumulator; - } - - const screenReaderRecord = screenReader as Record; - const id = screenReaderRecord.id; - - if (typeof id === "string" && id.length > 0) { - accumulator[id] = screenReaderRecord as ManifestScreenReader; - } - - return accumulator; - }, - {}, - ); -} - -export function resolveGuidepupManifest(): Record { - let packageJsonPath: string; - - try { - packageJsonPath = require.resolve("@guidepup/guidepup/package.json", { - paths: [process.cwd()], - }); - } catch { - throw new Error(ERR_INSTALL_GUIDEPUP_PACKAGE_NOT_FOUND); - } - - const manifestPath = path.join( - path.dirname(packageJsonPath), - "manifest.json", - ); - - if (!existsSync(manifestPath)) { - throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID); - } - - let manifest: unknown; - - try { - manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - } catch { - throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID); - } - - if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { - throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID); - } - - const manifestRecord = manifest as Record; - const schemaVersion = manifestRecord.version; - - if ( - typeof schemaVersion !== "number" || - !SUPPORTED_SCHEMA_VERSIONS.includes(schemaVersion) - ) { - throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_UNSUPPORTED_SCHEMA); - } - - return manifestRecord; -} - -export function selectInstallTargets( - manifest: Record, - requestedScreenReader?: string, -): InstallTarget[] { - const screenReaders = getScreenReaders(manifest); - - console.log({ screenReaders }); - - const selectedNames = requestedScreenReader - ? [requestedScreenReader] - : Object.keys(screenReaders).filter((name) => { - const screenReader = screenReaders[name]; - - return ( - screenReader?.defaultDownload === true && - isCompatibleWithCurrentPlatform(screenReader) - ); - }); - - console.log({ selectedNames }); - - const targets = selectedNames - .map((name) => ({ - name, - data: screenReaders[name], - })) - .filter((target) => { - if (!target.data) { - return false; - } - - return isCompatibleWithCurrentPlatform(target.data); - }); - - console.log({ targets }); - - if (requestedScreenReader && targets.length === 0) { - throw new Error( - `${ERR_INSTALL_GUIDEPUP_SCREEN_READER_UNAVAILABLE}: ${requestedScreenReader}`, - ); - } - - return targets; -} diff --git a/src/commands/install/resolve-cache-path.ts b/src/commands/install/resolve-cache-path.ts new file mode 100644 index 0000000..6408bbe --- /dev/null +++ b/src/commands/install/resolve-cache-path.ts @@ -0,0 +1,50 @@ +import { mkdirSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join, resolve } from "node:path"; +import { ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH } from "../../errors"; + +const defaultCacheLocationMap = { + darwin: resolve(homedir(), "Library", "Caches", "guidepup"), + win32: resolve( + process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), + "guidepup", + ), +}; + +const defaultCacheLocation = resolve(homedir(), ".cache", "guidepup"); + +export function resolveCachePath() { + const cacheLocationOverride = + process.env.GUIDEPUP_SCREEN_READERS_PATH?.trim(); + + if (cacheLocationOverride) { + const resolvedCacheLocation = resolve(cacheLocationOverride); + + try { + mkdirSync(resolvedCacheLocation, { recursive: true }); + } catch (cause) { + throw new Error( + ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH, + cause, + ); + } + + return resolvedCacheLocation; + } + + const currentPlatform = platform(); + + const resolvedCacheLocation = + defaultCacheLocationMap[currentPlatform] ?? defaultCacheLocation; + + try { + mkdirSync(resolvedCacheLocation, { recursive: true }); + } catch (cause) { + throw new Error( + ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH, + cause, + ); + } + + return resolvedCacheLocation; +} diff --git a/src/commands/install/resolve-manifest.ts b/src/commands/install/resolve-manifest.ts new file mode 100644 index 0000000..b79434a --- /dev/null +++ b/src/commands/install/resolve-manifest.ts @@ -0,0 +1,50 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID, + ERR_INSTALL_GUIDEPUP_MANIFEST_UNSUPPORTED_SCHEMA, + ERR_INSTALL_GUIDEPUP_PACKAGE_NOT_FOUND, +} from "../../errors"; +import { Manifest } from "./types"; +import { validateManifest } from "./validate-manifest"; + +const SUPPORTED_SCHEMA_VERSIONS = [1]; + +export function resolveManifest(): Manifest { + let packageJsonPath: string; + + try { + packageJsonPath = require.resolve("@guidepup/guidepup/package.json", { + paths: [process.cwd()], + }); + } catch (cause) { + throw new Error(ERR_INSTALL_GUIDEPUP_PACKAGE_NOT_FOUND, { cause }); + } + + const manifestPath = join(dirname(packageJsonPath), "manifest.json"); + + if (!existsSync(manifestPath)) { + throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID); + } + + let manifest: unknown; + + try { + const rawManifest = readFileSync(manifestPath, "utf8"); + + manifest = JSON.parse(rawManifest); + } catch (cause) { + throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID, { cause }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!SUPPORTED_SCHEMA_VERSIONS.includes((manifest as any)?.version)) { + throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_UNSUPPORTED_SCHEMA); + } + + if (!validateManifest(manifest)) { + throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID); + } + + return manifest; +} diff --git a/src/commands/install/select-targets.ts b/src/commands/install/select-targets.ts new file mode 100644 index 0000000..c209438 --- /dev/null +++ b/src/commands/install/select-targets.ts @@ -0,0 +1,54 @@ +import { platform } from "node:os"; +import { ERR_INSTALL_SCREEN_READER_UNAVAILABLE } from "../../errors"; +import type { ScreenReader, Manifest } from "./types"; + +function isScreenReaderCompatibleWithCurrentPlatform( + screenReader: ScreenReader, +): boolean { + const currentPlatform = platform(); + + return screenReader.platforms.includes(currentPlatform); +} + +function getRequestedTargets( + manifest: Manifest, + requestedScreenReadersIds: string[], +): ScreenReader[] { + const targets = []; + + for (const requestedScreenReaderId of requestedScreenReadersIds) { + const targetScreenReader = manifest.screenReaders.find( + (screenReader) => + (screenReader.id === requestedScreenReaderId || + screenReader.name === requestedScreenReaderId) && + isScreenReaderCompatibleWithCurrentPlatform(screenReader), + ); + + if (!targetScreenReader) { + throw new Error( + `${ERR_INSTALL_SCREEN_READER_UNAVAILABLE}: ${requestedScreenReaderId}`, + ); + } + + targets.push(targetScreenReader); + } + + return targets; +} + +function getDefaultTargets(manifest: Manifest): ScreenReader[] { + return manifest.screenReaders.filter( + (screenReader) => + screenReader.defaultDownload && + isScreenReaderCompatibleWithCurrentPlatform(screenReader), + ); +} + +export function selectTargets( + manifest: Manifest, + requestedScreenReadersIds?: string[], +): ScreenReader[] { + return requestedScreenReadersIds.length + ? getRequestedTargets(manifest, requestedScreenReadersIds) + : getDefaultTargets(manifest); +} diff --git a/src/commands/install/sha256.ts b/src/commands/install/sha256.ts new file mode 100644 index 0000000..3f5b6b6 --- /dev/null +++ b/src/commands/install/sha256.ts @@ -0,0 +1,13 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; + +export async function sha256(assetPath: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(assetPath); + + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} diff --git a/src/commands/install/types.ts b/src/commands/install/types.ts new file mode 100644 index 0000000..bf7d04e --- /dev/null +++ b/src/commands/install/types.ts @@ -0,0 +1,20 @@ +export interface Asset { + asset: string; + platformVersion?: string; + repository: string; + sha256: string; + version: string; +} + +export interface ScreenReader { + assets: [Asset, ...Asset[]]; + defaultDownload: boolean; + id: string; + name: string; + platforms: [NodeJS.Platform, ...NodeJS.Platform[]]; +} + +export interface Manifest { + screenReaders: [ScreenReader, ...ScreenReader[]]; + version: number; +} diff --git a/src/commands/install/validate-manifest.ts b/src/commands/install/validate-manifest.ts new file mode 100644 index 0000000..2e4acc4 --- /dev/null +++ b/src/commands/install/validate-manifest.ts @@ -0,0 +1,169 @@ +import type { Asset, Manifest, ScreenReader } from "./types"; + +function validateAsset(asset: unknown): asset is Asset { + if (!asset) { + return false; + } + + if (typeof asset !== "object") { + return false; + } + + if (Array.isArray(asset)) { + return false; + } + + if ( + !("asset" in asset) || + typeof asset.asset !== "string" || + !asset.asset.length + ) { + return false; + } + + if ( + "platformVersion" in asset && + (typeof asset.platformVersion !== "string" || !asset.platformVersion.length) + ) { + return false; + } + + if ( + !("repository" in asset) || + typeof asset.repository !== "string" || + !asset.repository.length + ) { + return false; + } + + if ( + !("sha256" in asset) || + typeof asset.sha256 !== "string" || + !asset.sha256.length + ) { + return false; + } + + if ( + !("version" in asset) || + typeof asset.version !== "string" || + !asset.version.length + ) { + return false; + } +} + +function validateAssets(assets: unknown): assets is Asset[] { + if (!Array.isArray(assets)) { + return false; + } + + return assets.every((asset) => validateAsset(asset)); +} + +function validatePlatform(platform: unknown): platform is NodeJS.Platform { + return typeof platform === "string" && !!platform.length; +} + +function validatePlatforms(platforms: unknown): platforms is NodeJS.Platform[] { + if (!Array.isArray(platforms)) { + return false; + } + + return platforms.every((platform) => validatePlatform(platform)); +} + +function validateScreenReader( + screenReader: unknown, +): screenReader is ScreenReader { + if (!screenReader) { + return false; + } + + if (typeof screenReader !== "object") { + return false; + } + + if (Array.isArray(screenReader)) { + return false; + } + + if (!("assets" in screenReader)) { + return false; + } + + if ( + !("defaultDownload" in screenReader) || + typeof screenReader.defaultDownload !== "boolean" + ) { + return false; + } + + if ( + !("id" in screenReader) || + typeof screenReader.id !== "string" || + !screenReader.id.length + ) { + return false; + } + + if ( + !("name" in screenReader) || + typeof screenReader.name !== "string" || + !screenReader.name.length + ) { + return false; + } + + if (!("platforms" in screenReader)) { + return false; + } + + return ( + validateAssets(screenReader.assets) && + validatePlatforms(screenReader.platforms) + ); +} + +function validateScreenReaders( + screenReaders: unknown, +): screenReaders is ScreenReader[] { + if (!Array.isArray(screenReaders)) { + return false; + } + + return screenReaders.every((screenReader) => + validateScreenReader(screenReader), + ); +} + +function validateVersion(version: unknown): version is number { + return typeof version === "number"; +} + +export function validateManifest(manifest: unknown): manifest is Manifest { + if (!manifest) { + return false; + } + + if (typeof manifest !== "object") { + return false; + } + + if (Array.isArray(manifest)) { + return false; + } + + if (!("screenReaders" in manifest)) { + return false; + } + + if (!("version" in manifest)) { + return false; + } + + return ( + validateScreenReaders(manifest.screenReaders) && + validateVersion(manifest.version) + ); +} diff --git a/src/commands/install/verify-asset-checksum.ts b/src/commands/install/verify-asset-checksum.ts new file mode 100644 index 0000000..cfb4405 --- /dev/null +++ b/src/commands/install/verify-asset-checksum.ts @@ -0,0 +1,54 @@ +import type { Stats } from "node:fs"; +import { rm, stat } from "node:fs/promises"; +import { sha256 } from "./sha256"; +import { + ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET, + ERR_INSTALL_FAILED_TO_VERIFY_CHECKSUM, + ERR_INSTALL_INVALID_CHECKSUM, +} from "../../errors"; + +async function deleteAsset(assetPath: string): Promise { + try { + await rm(assetPath, { force: true }); + } catch { + // Ignore cleanup failures, preferring to expose the error that triggered + // cleanup. + } +} + +export async function verifyAssetChecksum( + assetPath: string, + expectedSha256: string, +): Promise { + let file: Stats; + + try { + file = await stat(assetPath); + } catch (cause) { + throw new Error(ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET, { cause }); + } + + if (!file.isFile()) { + throw new Error(ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET); + } + + let actualSha256: string; + + try { + actualSha256 = await sha256(assetPath); + } catch (cause) { + await deleteAsset(assetPath); + + throw new Error(ERR_INSTALL_FAILED_TO_VERIFY_CHECKSUM, { cause }); + } + + if (actualSha256 === expectedSha256) { + return; + } + + await deleteAsset(assetPath); + + throw new Error( + `${ERR_INSTALL_INVALID_CHECKSUM}: "${assetPath}"\n\n\t- Expected ${expectedSha256}\n\t- Received ${actualSha256}`, + ); +} diff --git a/src/commands/install/verify-cached-asset-checksum.ts b/src/commands/install/verify-cached-asset-checksum.ts new file mode 100644 index 0000000..8030cee --- /dev/null +++ b/src/commands/install/verify-cached-asset-checksum.ts @@ -0,0 +1,34 @@ +import type { Stats } from "node:fs"; +import { rm, stat } from "node:fs/promises"; +import { sha256 } from "./sha256"; + +export async function verifyCachedAssetChecksum( + assetPath: string, + expectedSha256: string, +): Promise { + let file: Stats; + + try { + file = await stat(assetPath); + } catch { + return false; + } + + if (!file.isFile()) { + return false; + } + + const actualSha256 = await sha256(assetPath); + + if (actualSha256 === expectedSha256) { + return true; + } + + try { + await rm(assetPath, { force: true }); + } catch { + // Swallow + } + + return false; +} diff --git a/src/errors.ts b/src/errors.ts index fb649a2..a85fa56 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -24,5 +24,15 @@ export const ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID = "The installed '@guidepup/guidepup' manifest appears to be invalid or corrupted\n\nPlease re-install '@guidepup/guidepup'"; export const ERR_INSTALL_GUIDEPUP_MANIFEST_UNSUPPORTED_SCHEMA = "Unsupported manifest schema version\n\nPlease upgrade '@guidepup/setup'"; -export const ERR_INSTALL_GUIDEPUP_SCREEN_READER_UNAVAILABLE = +export const ERR_INSTALL_SCREEN_READER_UNAVAILABLE = "The requested screen reader is not available for this platform"; +export const ERR_INSTALL_SCREEN_READER_ASSET_UNAVAILABLE = + "The requested screen reader asset is not available for this platform"; +export const ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH = + "Unable to resolve or create the Guidepup cache path"; +export const ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET = + "Failed to download Guidepup cache asset"; +export const ERR_INSTALL_FAILED_TO_VERIFY_CHECKSUM = + "Failed to verify Guidepup cache asset checksum"; +export const ERR_INSTALL_INVALID_CHECKSUM = + "Guidepup cache asset checksum verification failed"; diff --git a/src/logging.ts b/src/logging.ts index 5fe5d00..cee8d2c 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -31,6 +31,10 @@ export function handleSetupManualRequired(): void { ); } +export function handleDownload(message, urlOrPath) { + logInfo(message + chalk.dim(urlOrPath)); +} + export function handleWarning(title: string, subtitle: string): void { logWarn(""); logWarn(chalk.bold(chalk.yellow(`[!] Warning: ${chalk.bold(title)}`))); From 2c3a4d7601a9298453b8c2f6f531a261ca9f516a Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 17:22:17 +0100 Subject: [PATCH 09/29] fix: missing return in validation --- ...oad-release-asset.ts => download-asset.ts} | 2 +- src/commands/install/download-assets.ts | 14 ++++++------- src/commands/install/index.ts | 16 ++++++--------- src/commands/install/validate-manifest.ts | 2 ++ src/commands/install/verify-asset-checksum.ts | 7 +++++++ src/logging.ts | 20 +++++++++++++++++-- 6 files changed, 41 insertions(+), 20 deletions(-) rename src/commands/install/{download-release-asset.ts => download-asset.ts} (94%) diff --git a/src/commands/install/download-release-asset.ts b/src/commands/install/download-asset.ts similarity index 94% rename from src/commands/install/download-release-asset.ts rename to src/commands/install/download-asset.ts index d4193a2..3f5b9a8 100644 --- a/src/commands/install/download-release-asset.ts +++ b/src/commands/install/download-asset.ts @@ -3,7 +3,7 @@ import { pipeline } from "node:stream/promises"; import type { Asset } from "./types"; import { ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET } from "../../errors"; -export async function downloadReleaseAsset( +export async function downloadAsset( asset: Asset, source: string, destination: string, diff --git a/src/commands/install/download-assets.ts b/src/commands/install/download-assets.ts index ee0c6dc..f5c7b21 100644 --- a/src/commands/install/download-assets.ts +++ b/src/commands/install/download-assets.ts @@ -7,9 +7,9 @@ import { ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH, } from "../../errors"; import { verifyCachedAssetChecksum } from "./verify-cached-asset-checksum"; -import { downloadReleaseAsset } from "./download-release-asset"; +import { downloadAsset } from "./download-asset"; import { verifyAssetChecksum } from "./verify-asset-checksum"; -import { handleDownload } from "../../logging"; +import { handleInfoWithPath } from "../../logging"; function platformMajorVersion(): string { return release().split(".", 1)[0]; @@ -52,7 +52,7 @@ async function downloadScreenReader( const versionMessage = `${asset.version}${asset.platformVersion ? ` - ${currentPlatform} ${asset.platformVersion}` : ""}`; if (await verifyCachedAssetChecksum(destination, asset.sha256)) { - handleDownload( + handleInfoWithPath( `${screenReader.name} (${versionMessage}) already in cache at`, destination, ); @@ -69,16 +69,16 @@ async function downloadScreenReader( ); } - const source = `https://github.com/${asset.repository}/releases/download/v${asset.version}/${asset.asset}`; + const source = `https://github.com/${asset.repository}/releases/download/${asset.version}/${asset.asset}`; - handleDownload( + handleInfoWithPath( `Downloading ${screenReader.name} (${versionMessage}) from`, source, ); - await downloadReleaseAsset(asset, source, destination); + await downloadAsset(asset, source, destination); - handleDownload( + handleInfoWithPath( `${screenReader.name} (${versionMessage}) downloaded to`, destination, ); diff --git a/src/commands/install/index.ts b/src/commands/install/index.ts index 98acf7e..ac16099 100644 --- a/src/commands/install/index.ts +++ b/src/commands/install/index.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { handleInstallComplete, handleInstallError, - logInfo, + handleNoInstallation, } from "../../logging"; import { selectTargets } from "./select-targets"; import { resolveManifest } from "./resolve-manifest"; @@ -15,23 +15,19 @@ async function install(requestedScreenReaderIds?: string[]): Promise { try { const manifest = resolveManifest(); - const cachePath = resolveCachePath(); targets = selectTargets(manifest, requestedScreenReaderIds); + if (targets.length === 0) { + handleNoInstallation(); + } + + const cachePath = resolveCachePath(); await downloadAssets(cachePath, targets); } catch (error) { handleInstallError(error); } - if (targets.length === 0) { - logInfo("No installable screen readers were found for this environment"); - } else { - logInfo( - `Resolved ${targets.length} install target(s): ${targets.map((target) => target.name).join(", ")}`, - ); - } - handleInstallComplete(); } diff --git a/src/commands/install/validate-manifest.ts b/src/commands/install/validate-manifest.ts index 2e4acc4..2c9aa81 100644 --- a/src/commands/install/validate-manifest.ts +++ b/src/commands/install/validate-manifest.ts @@ -51,6 +51,8 @@ function validateAsset(asset: unknown): asset is Asset { ) { return false; } + + return true; } function validateAssets(assets: unknown): assets is Asset[] { diff --git a/src/commands/install/verify-asset-checksum.ts b/src/commands/install/verify-asset-checksum.ts index cfb4405..c2976bb 100644 --- a/src/commands/install/verify-asset-checksum.ts +++ b/src/commands/install/verify-asset-checksum.ts @@ -6,8 +6,11 @@ import { ERR_INSTALL_FAILED_TO_VERIFY_CHECKSUM, ERR_INSTALL_INVALID_CHECKSUM, } from "../../errors"; +import { handleInfoWithPath } from "../../logging"; async function deleteAsset(assetPath: string): Promise { + handleInfoWithPath("Removing", assetPath); + try { await rm(assetPath, { force: true }); } catch { @@ -37,6 +40,8 @@ export async function verifyAssetChecksum( try { actualSha256 = await sha256(assetPath); } catch (cause) { + handleInfoWithPath("Unable to verify checksum for", assetPath); + await deleteAsset(assetPath); throw new Error(ERR_INSTALL_FAILED_TO_VERIFY_CHECKSUM, { cause }); @@ -46,6 +51,8 @@ export async function verifyAssetChecksum( return; } + handleInfoWithPath("Checksum verification failed for", assetPath); + await deleteAsset(assetPath); throw new Error( diff --git a/src/logging.ts b/src/logging.ts index cee8d2c..0a5903e 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -4,6 +4,22 @@ export const logInfo = console.info.bind(console); export const logWarn = console.warn.bind(console); export const logError = console.error.bind(console); +export function handleNoInstallation(): never { + handleWarning( + "No installable screen readers were found for this environment", + "Consider contributing to Guidepup?", + ); + logInfo( + chalk.dim( + "Please raise new issues at: " + + chalk.underline("https://github.com/guidepup/setup/issues"), + ), + ); + logInfo(""); + + process.exit(0); +} + export function handleInstallComplete(): never { logInfo(""); logInfo(chalk.green("Installation complete 🎉")); @@ -31,8 +47,8 @@ export function handleSetupManualRequired(): void { ); } -export function handleDownload(message, urlOrPath) { - logInfo(message + chalk.dim(urlOrPath)); +export function handleInfoWithPath(message, urlOrPath) { + logInfo(`${message} ${chalk.dim(urlOrPath)}`); } export function handleWarning(title: string, subtitle: string): void { From 563e01735b872773393cf63425fd26e56c8d3dc2 Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 17:25:51 +0100 Subject: [PATCH 10/29] fix: ci manifest --- .github/workflows/test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3843c84..3872cb1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -86,21 +86,21 @@ jobs: "assets": [ { "version": "0.0.1", - "platformVersion": "24", + "platformVersion": "23", "repository": "guidepup/voiceover", - "asset": "guidepup-voiceover-preferences-macos-24.dmg", + "asset": "guidepup-voiceover-preferences-macos-14.dmg", "sha256": "c6595f5ca50440e8553cde278936a46eff58d4c904e19c87e7d0e25950617ee1" }, { "version": "0.0.1", - "platformVersion": "25", + "platformVersion": "24", "repository": "guidepup/voiceover", - "asset": "guidepup-voiceover-preferences-macos-25.dmg", + "asset": "guidepup-voiceover-preferences-macos-15.dmg", "sha256": "8bdc3a11c45a19cc876a859bfbd64529469a8b7d4ad41fd7e00a0729ebdbcb25" }, { "version": "0.0.1", - "platformVersion": "26", + "platformVersion": "25", "repository": "guidepup/voiceover", "asset": "guidepup-voiceover-preferences-macos-26.dmg", "sha256": "9af2a3af7c9bffae1b2af26f1970548ecd62f33965fd30f4e43f21b9f57ce5ca" From 92440364bb0a9c7f332726949c60348212927fd4 Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 17:43:10 +0100 Subject: [PATCH 11/29] ci: test install for windows and linux --- .github/workflows/test.yml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3872cb1..3c61e2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,7 +58,18 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [macos-14, macos-15, macos-15-intel, macos-26, macos-26-intel] + os: + [ + macos-14, + macos-15, + macos-15-intel, + macos-26, + macos-26-intel, + windows-2022, + windows-2025, + windows-11-arm, + linux-latest, + ] steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -78,6 +89,20 @@ jobs: { "version": 1, "screenReaders": [ + { + "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", From 8801ce613acc32d4e7194dd5013240b0fae211d1 Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 17:44:32 +0100 Subject: [PATCH 12/29] ci: test jobs rename --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c61e2f..771113a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: branches: [main] jobs: - test: + test-setup: runs-on: ${{ matrix.os }} strategy: matrix: @@ -35,7 +35,7 @@ jobs: name: artifacts-test-${{ matrix.os }} path: | **/recordings/**/* - test-ignore-tcc-db: + test-setup-ignore-tcc-db: runs-on: ${{ matrix.os }} strategy: matrix: @@ -54,7 +54,7 @@ jobs: path: | **/recordings/**/* - install: + test-install: runs-on: ${{ matrix.os }} strategy: matrix: From b7858262eeeec24e7b0b4e6838e0b7cec5ca47e2 Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 17:46:36 +0100 Subject: [PATCH 13/29] ci: use bash --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 771113a..18fa935 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,7 +77,8 @@ jobs: node-version-file: .nvmrc - run: yarn install --frozen-lockfile - run: yarn build - - run: | + - shell: bash + run: | tmpdir=$(mktemp -d) cd "$tmpdir" npm init -y From 0753d2ec727eecfe015df8d2ca390c37094d1a77 Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 17:51:12 +0100 Subject: [PATCH 14/29] ci: fix ubuntu not linux --- .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 18fa935..f033e94 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,7 +68,7 @@ jobs: windows-2022, windows-2025, windows-11-arm, - linux-latest, + ubuntu-latest, ] steps: - uses: actions/checkout@v6 From e714f59e8555513c1ee22c12a51f8fbd7487fdfb Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 21:10:47 +0100 Subject: [PATCH 15/29] feat: support proxy env vars --- package.json | 8 +-- src/commands/install/download-asset.ts | 7 ++- yarn.lock | 82 +++----------------------- 3 files changed, 16 insertions(+), 81 deletions(-) diff --git a/package.json b/package.json index da5f0e5..30ffdc2 100644 --- a/package.json +++ b/package.json @@ -54,9 +54,7 @@ "chalk": "^4.0.0", "commander": "^15.0.0", "decompress": "^4.2.1", - "https-proxy-agent": "^7.0.5", - "regedit": "5.0.1", - "semver": "^7.5.4" - }, - "guidepupNvdaVersion": "0.2.0-2026.1.1" + "semver": "^7.5.4", + "undici": "^8.7.0" + } } diff --git a/src/commands/install/download-asset.ts b/src/commands/install/download-asset.ts index 3f5b9a8..2c0a331 100644 --- a/src/commands/install/download-asset.ts +++ b/src/commands/install/download-asset.ts @@ -1,14 +1,19 @@ import { createWriteStream } from "node:fs"; import { pipeline } from "node:stream/promises"; +import { EnvHttpProxyAgent, fetch } from "undici"; import type { Asset } from "./types"; import { ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET } from "../../errors"; +const dispatcher = new EnvHttpProxyAgent(); + export async function downloadAsset( asset: Asset, source: string, destination: string, ): Promise { - const response = await fetch(source); + const response = await fetch(source, { + dispatcher, + }); if (!response.ok || !response.body) { throw new Error( diff --git a/yarn.lock b/yarn.lock index 7863f63..92cc629 100644 --- a/yarn.lock +++ b/yarn.lock @@ -302,11 +302,6 @@ agent-base@6: dependencies: debug "4" -agent-base@^7.1.2: - version "7.1.4" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" - integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== - ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -506,13 +501,6 @@ debug@4, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: dependencies: ms "^2.1.3" -debug@^4.1.0: - version "4.3.5" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" - integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== - dependencies: - ms "2.1.2" - decompress-tar@^4.0.0, decompress-tar@^4.1.0, decompress-tar@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/decompress-tar/-/decompress-tar-4.1.1.tgz#718cbd3fcb16209716e70a26b84e7ba4592e5af1" @@ -917,24 +905,11 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" -https-proxy-agent@^7.0.5: - version "7.0.6" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" - integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== - dependencies: - agent-base "^7.1.2" - debug "4" - ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== -if-async@^3.7.4: - version "3.7.4" - resolved "https://registry.yarnpkg.com/if-async/-/if-async-3.7.4.tgz#55868deb0093d3c67bf7166e745353fb9bcb21a2" - integrity sha512-BFEH2mZyeF6KZKaKLVPZ0wMjIiWOdjvZ7zbx8ENec0qfZhJwKFbX/4jKM5LTKyJEc/GOqUKiiJ2IFKT9yWrZqA== - ignore@^5.2.0: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" @@ -966,7 +941,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: +inherits@2, inherits@^2.0.3, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -1008,11 +983,6 @@ is-stream@^1.1.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== - isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -1135,11 +1105,6 @@ minipass@^7.1.2: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" @@ -1285,16 +1250,6 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -"readable-stream@>=1.0.33-1 <1.1.0-0": - version "1.0.34" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" - integrity sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "0.0.1" - string_decoder "~0.10.x" - readable-stream@^2.3.0, readable-stream@^2.3.5: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" @@ -1317,16 +1272,6 @@ readable-stream@^3.0.2: string_decoder "^1.1.1" util-deprecate "^1.0.1" -regedit@5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/regedit/-/regedit-5.0.1.tgz#e55cf93fc70133d6d4ba849a2d4ead22a5075220" - integrity sha512-DiFTHk51G5d8bTAYGRDbHNJJPzg8EOF+3pNPdYjDc6kXOgObfayrVnjwfTseyzeIh5sVa+nhHkkVpmopUnOAQQ== - dependencies: - debug "^4.1.0" - if-async "^3.7.4" - stream-slicer "0.0.6" - through2 "^0.6.3" - resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -1398,11 +1343,6 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== -stream-slicer@0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/stream-slicer/-/stream-slicer-0.0.6.tgz#f86b2ac5c2440b7a0a87b71f33665c0788046138" - integrity sha512-QsY0LbweYE5L+e+iBQgtkM5WUIf7+kCMA/m2VULv8rEEDDnlDPsPvOHH4nli6uaZOKQEt64u65h0l/eeZo7lCw== - "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -1437,11 +1377,6 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -string_decoder@~0.10.x: - version "0.10.31" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" - integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== - string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" @@ -1507,14 +1442,6 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== -through2@^0.6.3: - version "0.6.5" - resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.5.tgz#41ab9c67b29d57209071410e1d7a7a968cd3ad48" - integrity sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg== - dependencies: - readable-stream ">=1.0.33-1 <1.1.0-0" - xtend ">=4.0.0 <4.1.0-0" - through@^2.3.8: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" @@ -1591,6 +1518,11 @@ undici-types@~7.16.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== +undici@^8.7.0: + version "8.7.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-8.7.0.tgz#04c5aae1db34d9867488588b44b8c749dee9baee" + integrity sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ== + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" @@ -1643,7 +1575,7 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0: +xtend@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== From e98e598606f398a98486cbbab2d8b2a037e19fcb Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 21:29:47 +0100 Subject: [PATCH 16/29] feat: auto-extract nvda zip assets --- src/commands/install/delete-asset.ts | 13 ++++++ src/commands/install/download-assets.ts | 45 ++++++++++++++----- src/commands/install/extract-zip.ts | 38 ++++++++++++++++ src/commands/install/verify-asset-checksum.ts | 14 +----- src/errors.ts | 4 ++ 5 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 src/commands/install/delete-asset.ts create mode 100644 src/commands/install/extract-zip.ts diff --git a/src/commands/install/delete-asset.ts b/src/commands/install/delete-asset.ts new file mode 100644 index 0000000..d8eacc4 --- /dev/null +++ b/src/commands/install/delete-asset.ts @@ -0,0 +1,13 @@ +import { rm } from "node:fs/promises"; +import { handleInfoWithPath } from "../../logging"; + +export async function deleteAsset(assetPath: string): Promise { + handleInfoWithPath("Removing", assetPath); + + try { + await rm(assetPath, { force: true, recursive: true }); + } catch { + // Ignore cleanup failures, preferring to expose the error that triggered + // cleanup. + } +} diff --git a/src/commands/install/download-assets.ts b/src/commands/install/download-assets.ts index f5c7b21..65adad0 100644 --- a/src/commands/install/download-assets.ts +++ b/src/commands/install/download-assets.ts @@ -2,13 +2,14 @@ import { platform, release } from "node:os"; import { dirname, join } from "node:path"; import { mkdirSync } from "node:fs"; import type { Asset, ScreenReader } from "./types"; +import { verifyCachedAssetChecksum } from "./verify-cached-asset-checksum"; +import { downloadAsset } from "./download-asset"; +import { verifyAssetChecksum } from "./verify-asset-checksum"; +import { extractZip } from "./extract-zip"; import { ERR_INSTALL_SCREEN_READER_ASSET_UNAVAILABLE, ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH, } from "../../errors"; -import { verifyCachedAssetChecksum } from "./verify-cached-asset-checksum"; -import { downloadAsset } from "./download-asset"; -import { verifyAssetChecksum } from "./verify-asset-checksum"; import { handleInfoWithPath } from "../../logging"; function platformMajorVersion(): string { @@ -40,7 +41,7 @@ async function downloadScreenReader( ): Promise { const asset = selectAsset(screenReader); - const destination = join( + const downloadDestination = join( cachePath, screenReader.id, asset.platformVersion ?? "", @@ -51,17 +52,17 @@ async function downloadScreenReader( const currentPlatform = platform(); const versionMessage = `${asset.version}${asset.platformVersion ? ` - ${currentPlatform} ${asset.platformVersion}` : ""}`; - if (await verifyCachedAssetChecksum(destination, asset.sha256)) { + if (await verifyCachedAssetChecksum(downloadDestination, asset.sha256)) { handleInfoWithPath( `${screenReader.name} (${versionMessage}) already in cache at`, - destination, + downloadDestination, ); return; } try { - mkdirSync(dirname(destination), { recursive: true }); + mkdirSync(dirname(downloadDestination), { recursive: true }); } catch (cause) { throw new Error( ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH, @@ -76,14 +77,36 @@ async function downloadScreenReader( source, ); - await downloadAsset(asset, source, destination); + await downloadAsset(asset, source, downloadDestination); handleInfoWithPath( - `${screenReader.name} (${versionMessage}) downloaded to`, - destination, + `${screenReader.name} (${versionMessage}) successfully downloaded to`, + downloadDestination, ); - await verifyAssetChecksum(destination, asset.sha256); + await verifyAssetChecksum(downloadDestination, asset.sha256); + + if (asset.asset.endsWith(".zip")) { + const unzipDestination = join( + cachePath, + screenReader.id, + asset.platformVersion ?? "", + asset.version, + "extracted", + ); + + handleInfoWithPath( + `Extracting ${screenReader.name} (${versionMessage}) from`, + downloadDestination, + ); + + await extractZip(asset, downloadDestination, unzipDestination); + + handleInfoWithPath( + `${screenReader.name} (${versionMessage}) successfully extracted to`, + unzipDestination, + ); + } } export async function downloadAssets( diff --git a/src/commands/install/extract-zip.ts b/src/commands/install/extract-zip.ts new file mode 100644 index 0000000..e72f705 --- /dev/null +++ b/src/commands/install/extract-zip.ts @@ -0,0 +1,38 @@ +import decompress from "decompress"; +import { + ERR_INSTALL_EMPTY_EXTRACTED_ASSET, + ERR_INSTALL_FAILED_TO_EXTRACT_ASSET, +} from "../../errors"; +import type { Asset } from "./types"; +import { deleteAsset } from "./delete-asset"; +import { handleInfoWithPath } from "../../logging"; + +export async function extractZip( + asset: Asset, + source: string, + destination: string, +): Promise { + let files: never[]; + + try { + files = await decompress(source, 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/verify-asset-checksum.ts b/src/commands/install/verify-asset-checksum.ts index c2976bb..627ac3a 100644 --- a/src/commands/install/verify-asset-checksum.ts +++ b/src/commands/install/verify-asset-checksum.ts @@ -1,6 +1,7 @@ import type { Stats } from "node:fs"; -import { rm, stat } from "node:fs/promises"; +import { stat } from "node:fs/promises"; import { sha256 } from "./sha256"; +import { deleteAsset } from "./delete-asset"; import { ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET, ERR_INSTALL_FAILED_TO_VERIFY_CHECKSUM, @@ -8,17 +9,6 @@ import { } from "../../errors"; import { handleInfoWithPath } from "../../logging"; -async function deleteAsset(assetPath: string): Promise { - handleInfoWithPath("Removing", assetPath); - - try { - await rm(assetPath, { force: true }); - } catch { - // Ignore cleanup failures, preferring to expose the error that triggered - // cleanup. - } -} - export async function verifyAssetChecksum( assetPath: string, expectedSha256: string, diff --git a/src/errors.ts b/src/errors.ts index a85fa56..2686863 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -36,3 +36,7 @@ export const ERR_INSTALL_FAILED_TO_VERIFY_CHECKSUM = "Failed to verify Guidepup cache asset checksum"; export const ERR_INSTALL_INVALID_CHECKSUM = "Guidepup cache asset checksum verification failed"; +export const ERR_INSTALL_FAILED_TO_EXTRACT_ASSET = + "Failed to extract Guidepup cache asset"; +export const ERR_INSTALL_EMPTY_EXTRACTED_ASSET = + "Extracted Guidepup cache asset is empty"; From 5d8888a6aa52c4cac478c2bc7f3ef57e8a891224 Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 21:32:34 +0100 Subject: [PATCH 17/29] ci: fix whitespace --- .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 f033e94..fc4b282 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,7 +99,7 @@ jobs: { "version": "0.2.1-2026.1.1", "repository": "guidepup/nvda", - "asset": "guidepup-nvda-0.2.1-2026.1.1.zip ", + "asset": "guidepup-nvda-0.2.1-2026.1.1.zip", "sha256": "b6fdfde86190c7c14132d459f2f88995da6251ded431e23947d676ffb152f963" } ] From 73d735752939f3eaedd91d80f155bc4886acc1eb Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 21:35:07 +0100 Subject: [PATCH 18/29] ci: temp log --- src/commands/install/extract-zip.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/install/extract-zip.ts b/src/commands/install/extract-zip.ts index e72f705..a9eeb13 100644 --- a/src/commands/install/extract-zip.ts +++ b/src/commands/install/extract-zip.ts @@ -17,6 +17,7 @@ export async function extractZip( try { files = await decompress(source, destination); } catch (cause) { + console.log(cause); // TODO: remove handleInfoWithPath("Unable to extract from", source); await deleteAsset(source); From 00a805ea74c78e0fe228adaf47e5cd69744721d2 Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 21:42:19 +0100 Subject: [PATCH 19/29] feat: migrate away from decompress --- package.json | 2 +- src/commands/install/extract-zip.ts | 24 +- yarn.lock | 330 +++++----------------------- 3 files changed, 69 insertions(+), 287 deletions(-) diff --git a/package.json b/package.json index 30ffdc2..f893230 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dependencies": { "chalk": "^4.0.0", "commander": "^15.0.0", - "decompress": "^4.2.1", + "extract-zip": "^2.0.1", "semver": "^7.5.4", "undici": "^8.7.0" } diff --git a/src/commands/install/extract-zip.ts b/src/commands/install/extract-zip.ts index a9eeb13..aa2b212 100644 --- a/src/commands/install/extract-zip.ts +++ b/src/commands/install/extract-zip.ts @@ -1,27 +1,34 @@ -import decompress from "decompress"; +import { readdir } from "node:fs/promises"; +import extract from "extract-zip"; 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"; -import { handleInfoWithPath } from "../../logging"; + +async function deleteAssets(...assets) { + for (const asset of assets) { + await deleteAsset(asset); + } +} export async function extractZip( asset: Asset, source: string, destination: string, ): Promise { - let files: never[]; + let files: string[]; try { - files = await decompress(source, destination); + await extract(source, { dir: destination }); + + files = await readdir(destination); } catch (cause) { - console.log(cause); // TODO: remove handleInfoWithPath("Unable to extract from", source); - await deleteAsset(source); - await deleteAsset(destination); + await deleteAssets(source, destination); throw new Error(`${ERR_INSTALL_FAILED_TO_EXTRACT_ASSET}: ${asset.asset}`, { cause, @@ -31,8 +38,7 @@ export async function extractZip( if (!files.length) { handleInfoWithPath("Extracted asset is empty at", destination); - await deleteAsset(source); - await deleteAsset(destination); + await deleteAssets(source, destination); throw new Error(`${ERR_INSTALL_EMPTY_EXTRACTED_ASSET}: ${asset.asset}`); } diff --git a/yarn.lock b/yarn.lock index 92cc629..3f15144 100644 --- a/yarn.lock +++ b/yarn.lock @@ -160,6 +160,13 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== +"@types/node@*": + version "26.1.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.1.1.tgz#bad758d601e97d6cf457d204ee76a35fce7bd119" + integrity sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw== + dependencies: + undici-types "~8.3.0" + "@types/node@^10.0.3": version "10.17.60" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b" @@ -172,6 +179,13 @@ dependencies: undici-types "~7.16.0" +"@types/yauzl@^2.9.1": + version "2.10.3" + resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999" + integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q== + dependencies: + "@types/node" "*" + "@typescript-eslint/eslint-plugin@^8.5.0": version "8.46.4" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz#005dc4eebcb27462f20de3afe888065f65cec100" @@ -349,19 +363,6 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - -bl@^1.0.0: - version "1.2.3" - resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7" - integrity sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww== - dependencies: - readable-stream "^2.3.5" - safe-buffer "^5.1.1" - brace-expansion@^1.1.7: version "1.1.12" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" @@ -384,42 +385,16 @@ braces@^3.0.3: dependencies: fill-range "^7.1.1" -buffer-alloc-unsafe@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" - integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== - -buffer-alloc@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" - integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== - dependencies: - buffer-alloc-unsafe "^1.1.0" - buffer-fill "^1.0.0" - buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== -buffer-fill@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" - integrity sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ== - buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer@^5.2.1: - version "5.7.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -455,11 +430,6 @@ commander@^15.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-15.0.0.tgz#96f3961f12adac1799ef3fbd8bc61d40572d1b11" integrity sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg== -commander@^2.8.1: - version "2.20.3" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" - integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== - concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -475,11 +445,6 @@ concat-stream@^2.0.0: readable-stream "^3.0.2" typedarray "^0.0.6" -core-util-is@~1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" - integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== - create-require@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" @@ -494,66 +459,13 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.6: shebang-command "^2.0.0" which "^2.0.1" -debug@4, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: +debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" -decompress-tar@^4.0.0, decompress-tar@^4.1.0, decompress-tar@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/decompress-tar/-/decompress-tar-4.1.1.tgz#718cbd3fcb16209716e70a26b84e7ba4592e5af1" - integrity sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ== - dependencies: - file-type "^5.2.0" - is-stream "^1.1.0" - tar-stream "^1.5.2" - -decompress-tarbz2@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz#3082a5b880ea4043816349f378b56c516be1a39b" - integrity sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A== - dependencies: - decompress-tar "^4.1.0" - file-type "^6.1.0" - is-stream "^1.1.0" - seek-bzip "^1.0.5" - unbzip2-stream "^1.0.9" - -decompress-targz@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/decompress-targz/-/decompress-targz-4.1.1.tgz#c09bc35c4d11f3de09f2d2da53e9de23e7ce1eee" - integrity sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w== - dependencies: - decompress-tar "^4.1.1" - file-type "^5.2.0" - is-stream "^1.1.0" - -decompress-unzip@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/decompress-unzip/-/decompress-unzip-4.0.1.tgz#deaaccdfd14aeaf85578f733ae8210f9b4848f69" - integrity sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw== - dependencies: - file-type "^3.8.0" - get-stream "^2.2.0" - pify "^2.3.0" - yauzl "^2.4.2" - -decompress@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/decompress/-/decompress-4.2.1.tgz#007f55cc6a62c055afa37c07eb6a4ee1b773f118" - integrity sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ== - dependencies: - decompress-tar "^4.0.0" - decompress-tarbz2 "^4.0.0" - decompress-targz "^4.0.0" - decompress-unzip "^4.0.1" - graceful-fs "^4.1.10" - make-dir "^1.0.0" - pify "^2.3.0" - strip-dirs "^2.0.0" - deep-is@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" @@ -586,10 +498,10 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== -end-of-stream@^1.0.0: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== +end-of-stream@^1.1.0: + version "1.4.5" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c" + integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== dependencies: once "^1.4.0" @@ -703,6 +615,17 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +extract-zip@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" + integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== + dependencies: + debug "^4.1.1" + get-stream "^5.1.0" + yauzl "^2.10.0" + optionalDependencies: + "@types/yauzl" "^2.9.1" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -760,21 +683,6 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-type@^3.8.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" - integrity sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA== - -file-type@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-5.2.0.tgz#2ddbea7c73ffe36368dfae49dc338c058c2b8ad6" - integrity sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ== - -file-type@^6.1.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-6.2.0.tgz#e50cd75d356ffed4e306dc4f5bcf52a79903a919" - integrity sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg== - fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -812,23 +720,17 @@ foreground-child@^3.3.1: cross-spawn "^7.0.6" signal-exit "^4.0.1" -fs-constants@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" - integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== - fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -get-stream@^2.2.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-2.3.1.tgz#5f38f93f346009666ee0150a054167f91bdd95de" - integrity sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA== +get-stream@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== dependencies: - object-assign "^4.0.1" - pinkie-promise "^2.0.0" + pump "^3.0.0" glob-parent@^5.1.2: version "5.1.2" @@ -875,11 +777,6 @@ globals@^13.19.0: dependencies: type-fest "^0.20.2" -graceful-fs@^4.1.10: - version "4.2.10" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" - integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== - graphemer@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" @@ -905,11 +802,6 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" -ieee754@^1.1.13: - version "1.2.1" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - ignore@^5.2.0: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" @@ -941,7 +833,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@~2.0.3: +inherits@2, inherits@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -963,11 +855,6 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: dependencies: is-extglob "^2.1.1" -is-natural-number@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/is-natural-number/-/is-natural-number-4.0.1.tgz#ab9d76e1db4ced51e35de0c72ebecf09f734cde8" - integrity sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ== - is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" @@ -978,16 +865,6 @@ is-path-inside@^3.0.3: resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== -is-stream@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== - -isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== - isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -1054,13 +931,6 @@ lru-cache@^11.0.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.2.tgz#40fd37edffcfae4b2940379c0722dc6eeaa75f24" integrity sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg== -make-dir@^1.0.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" - integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ== - dependencies: - pify "^3.0.0" - make-error@^1.1.1: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" @@ -1115,12 +985,7 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -object-assign@^4.0.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== - -once@^1.3.0, once@^1.4.0: +once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== @@ -1203,43 +1068,24 @@ picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== -pify@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" - integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== - -pify@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" - integrity sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg== - -pinkie-promise@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" - integrity sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw== - dependencies: - pinkie "^2.0.0" - -pinkie@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" - integrity sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg== - prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -process-nextick-args@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" - integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== - progress@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +pump@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.4.tgz#1f313430527fa8b905622ebd22fe1444e757ab3c" + integrity sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + punycode@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -1250,19 +1096,6 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -readable-stream@^2.3.0, readable-stream@^2.3.5: - version "2.3.7" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" - integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - readable-stream@^3.0.2: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" @@ -1304,23 +1137,11 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -safe-buffer@^5.1.1, safe-buffer@~5.2.0: +safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -safe-buffer@~5.1.0, safe-buffer@~5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -seek-bzip@^1.0.5: - version "1.0.6" - resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-1.0.6.tgz#35c4171f55a680916b52a07859ecf3b5857f21c4" - integrity sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ== - dependencies: - commander "^2.8.1" - semver@^7.5.4, semver@^7.6.0: version "7.7.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" @@ -1377,13 +1198,6 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -1405,13 +1219,6 @@ strip-ansi@^7.0.1: dependencies: ansi-regex "^6.0.1" -strip-dirs@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/strip-dirs/-/strip-dirs-2.1.0.tgz#4987736264fc344cf20f6c34aca9d13d1d4ed6c5" - integrity sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g== - dependencies: - is-natural-number "^4.0.1" - strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" @@ -1424,34 +1231,11 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -tar-stream@^1.5.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" - integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A== - dependencies: - bl "^1.0.0" - buffer-alloc "^1.2.0" - end-of-stream "^1.0.0" - fs-constants "^1.0.0" - readable-stream "^2.3.0" - to-buffer "^1.1.1" - xtend "^4.0.0" - text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== -through@^2.3.8: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= - -to-buffer@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" - integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg== - to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -1505,19 +1289,16 @@ typescript@^5.6.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== -unbzip2-stream@^1.0.9: - version "1.4.3" - resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" - integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== - dependencies: - buffer "^5.2.1" - through "^2.3.8" - undici-types@~7.16.0: version "7.16.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== +undici-types@~8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.3.0.tgz#44e9fc9f3244648cdea35e4f9bb2d681e9410809" + integrity sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ== + undici@^8.7.0: version "8.7.0" resolved "https://registry.yarnpkg.com/undici/-/undici-8.7.0.tgz#04c5aae1db34d9867488588b44b8c749dee9baee" @@ -1530,7 +1311,7 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -util-deprecate@^1.0.1, util-deprecate@~1.0.1: +util-deprecate@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== @@ -1575,12 +1356,7 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -xtend@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - -yauzl@^2.4.2: +yauzl@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g== From eb4b9ba53a2fada6c314247533ba25369f1410be Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 22:18:54 +0100 Subject: [PATCH 20/29] feat: register installs to cache and prune dead cache assets --- src/commands/install/download-assets.ts | 4 +- src/commands/install/index.ts | 10 ++- src/commands/install/prune-cache.ts | 89 +++++++++++++++++++ src/commands/install/register-installation.ts | 24 +++++ src/commands/install/resolve-manifest.ts | 13 ++- src/errors.ts | 2 + 6 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 src/commands/install/prune-cache.ts create mode 100644 src/commands/install/register-installation.ts diff --git a/src/commands/install/download-assets.ts b/src/commands/install/download-assets.ts index 65adad0..783e6d1 100644 --- a/src/commands/install/download-assets.ts +++ b/src/commands/install/download-assets.ts @@ -44,7 +44,7 @@ async function downloadScreenReader( const downloadDestination = join( cachePath, screenReader.id, - asset.platformVersion ?? "", + asset.platformVersion ?? "*", asset.version, asset.asset, ); @@ -90,7 +90,7 @@ async function downloadScreenReader( const unzipDestination = join( cachePath, screenReader.id, - asset.platformVersion ?? "", + asset.platformVersion ?? "*", asset.version, "extracted", ); diff --git a/src/commands/install/index.ts b/src/commands/install/index.ts index ac16099..a4d2f08 100644 --- a/src/commands/install/index.ts +++ b/src/commands/install/index.ts @@ -9,12 +9,17 @@ import { resolveManifest } from "./resolve-manifest"; import type { ScreenReader } from "./types"; import { resolveCachePath } from "./resolve-cache-path"; import { downloadAssets } from "./download-assets"; +import { registerInstallation } from "./register-installation"; +import { pruneCache } from "./prune-cache"; async function install(requestedScreenReaderIds?: string[]): Promise { let targets: ScreenReader[]; try { - const manifest = resolveManifest(); + const { manifestPath, manifest } = resolveManifest(); + + const cachePath = resolveCachePath(); + await registerInstallation(cachePath, manifestPath); targets = selectTargets(manifest, requestedScreenReaderIds); @@ -22,8 +27,9 @@ async function install(requestedScreenReaderIds?: string[]): Promise { handleNoInstallation(); } - const cachePath = resolveCachePath(); await downloadAssets(cachePath, targets); + + await pruneCache(cachePath); } catch (error) { handleInstallError(error); } diff --git a/src/commands/install/prune-cache.ts b/src/commands/install/prune-cache.ts new file mode 100644 index 0000000..5b767a6 --- /dev/null +++ b/src/commands/install/prune-cache.ts @@ -0,0 +1,89 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { deleteAsset } from "./delete-asset"; +import { resolveManifest } from "./resolve-manifest"; + +export async function pruneCache(cachePath: string): Promise { + const required = new Set(); + const linksPath = join(cachePath, ".links"); + + let links: string[]; + + try { + links = await readdir(linksPath); + } catch { + return; + } + + for (const link of links) { + const linkPath = join(linksPath, link); + + let manifestPath: string; + + try { + manifestPath = await readFile(linkPath, "utf8"); + } catch { + await deleteAsset(linkPath); + + continue; + } + + try { + const { manifest } = resolveManifest(manifestPath); + + for (const screenReader of manifest.screenReaders) { + for (const asset of screenReader.assets) { + required.add( + join(screenReader.id, asset.platformVersion ?? "*", asset.version), + ); + } + } + } catch { + await deleteAsset(linkPath); + } + } + + let screenReaders: string[]; + + try { + screenReaders = await readdir(cachePath); + } catch { + return; + } + + for (const screenReaderId of screenReaders) { + if (screenReaderId === ".links") { + continue; + } + + const screenReaderPath = join(cachePath, screenReaderId); + + let platformVersions: string[]; + + try { + platformVersions = await readdir(screenReaderPath); + } catch { + continue; + } + + for (const platformVersion of platformVersions) { + const platformPath = join(screenReaderPath, platformVersion); + + let versions: string[]; + + try { + versions = await readdir(platformPath); + } catch { + continue; + } + + for (const version of versions) { + const relativePath = join(screenReaderId, platformVersion, version); + + if (!required.has(relativePath)) { + await deleteAsset(join(cachePath, relativePath)); + } + } + } + } +} diff --git a/src/commands/install/register-installation.ts b/src/commands/install/register-installation.ts new file mode 100644 index 0000000..197936c --- /dev/null +++ b/src/commands/install/register-installation.ts @@ -0,0 +1,24 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { ERR_INSTALL_FAILED_TO_REGISTER_INSTALLATION } from "../../errors"; + +export async function registerInstallation( + cachePath: string, + manifestPath: string, +): Promise { + const linksPath = join(cachePath, ".links"); + + try { + await mkdir(linksPath, { recursive: true }); + + const id = createHash("sha256").update(manifestPath).digest("hex"); + const linkPath = join(linksPath, id); + + await writeFile(linkPath, manifestPath, "utf8"); + } catch (cause) { + throw new Error(ERR_INSTALL_FAILED_TO_REGISTER_INSTALLATION, { + cause, + }); + } +} diff --git a/src/commands/install/resolve-manifest.ts b/src/commands/install/resolve-manifest.ts index b79434a..2a999d6 100644 --- a/src/commands/install/resolve-manifest.ts +++ b/src/commands/install/resolve-manifest.ts @@ -10,7 +10,7 @@ import { validateManifest } from "./validate-manifest"; const SUPPORTED_SCHEMA_VERSIONS = [1]; -export function resolveManifest(): Manifest { +function getLocalManifestPath() { let packageJsonPath: string; try { @@ -21,7 +21,14 @@ export function resolveManifest(): Manifest { throw new Error(ERR_INSTALL_GUIDEPUP_PACKAGE_NOT_FOUND, { cause }); } - const manifestPath = join(dirname(packageJsonPath), "manifest.json"); + return join(dirname(packageJsonPath), "manifest.json"); +} + +export function resolveManifest(targetManifestPath?: string): { + manifestPath: string; + manifest: Manifest; +} { + const manifestPath = targetManifestPath ?? getLocalManifestPath(); if (!existsSync(manifestPath)) { throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID); @@ -46,5 +53,5 @@ export function resolveManifest(): Manifest { throw new Error(ERR_INSTALL_GUIDEPUP_MANIFEST_INVALID); } - return manifest; + return { manifestPath, manifest }; } diff --git a/src/errors.ts b/src/errors.ts index 2686863..e8cf5c5 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -30,6 +30,8 @@ export const ERR_INSTALL_SCREEN_READER_ASSET_UNAVAILABLE = "The requested screen reader asset is not available for this platform"; export const ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH = "Unable to resolve or create the Guidepup cache path"; +export const ERR_INSTALL_FAILED_TO_REGISTER_INSTALLATION = + "Failed to register the Guidepup cache installation"; export const ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET = "Failed to download Guidepup cache asset"; export const ERR_INSTALL_FAILED_TO_VERIFY_CHECKSUM = From fb925c3e5170b37b8ca6741a073a04c011464210 Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 22:27:43 +0100 Subject: [PATCH 21/29] feat: log error causes --- src/commands/install/download-assets.ts | 4 ++-- src/commands/install/resolve-cache-path.ts | 25 +++++----------------- src/errors.ts | 4 +++- src/logging.ts | 13 +++++------ 4 files changed, 17 insertions(+), 29 deletions(-) diff --git a/src/commands/install/download-assets.ts b/src/commands/install/download-assets.ts index 783e6d1..0693531 100644 --- a/src/commands/install/download-assets.ts +++ b/src/commands/install/download-assets.ts @@ -8,7 +8,7 @@ import { verifyAssetChecksum } from "./verify-asset-checksum"; import { extractZip } from "./extract-zip"; import { ERR_INSTALL_SCREEN_READER_ASSET_UNAVAILABLE, - ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH, + ERR_INSTALL_UNABLE_TO_CREATE_GUIDEPUP_CACHE_ASSET_PATH, } from "../../errors"; import { handleInfoWithPath } from "../../logging"; @@ -65,7 +65,7 @@ async function downloadScreenReader( mkdirSync(dirname(downloadDestination), { recursive: true }); } catch (cause) { throw new Error( - ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH, + ERR_INSTALL_UNABLE_TO_CREATE_GUIDEPUP_CACHE_ASSET_PATH, cause, ); } diff --git a/src/commands/install/resolve-cache-path.ts b/src/commands/install/resolve-cache-path.ts index 6408bbe..0802563 100644 --- a/src/commands/install/resolve-cache-path.ts +++ b/src/commands/install/resolve-cache-path.ts @@ -14,28 +14,13 @@ const defaultCacheLocationMap = { const defaultCacheLocation = resolve(homedir(), ".cache", "guidepup"); export function resolveCachePath() { - const cacheLocationOverride = - process.env.GUIDEPUP_SCREEN_READERS_PATH?.trim(); - - if (cacheLocationOverride) { - const resolvedCacheLocation = resolve(cacheLocationOverride); - - try { - mkdirSync(resolvedCacheLocation, { recursive: true }); - } catch (cause) { - throw new Error( - ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH, - cause, - ); - } - - return resolvedCacheLocation; - } - const currentPlatform = platform(); - const resolvedCacheLocation = - defaultCacheLocationMap[currentPlatform] ?? defaultCacheLocation; + const cacheLocationOverride = process.env.GUIDEPUP_SCREEN_READERS_PATH; + + const resolvedCacheLocation = cacheLocationOverride + ? resolve(cacheLocationOverride) + : (defaultCacheLocationMap[currentPlatform] ?? defaultCacheLocation); try { mkdirSync(resolvedCacheLocation, { recursive: true }); diff --git a/src/errors.ts b/src/errors.ts index e8cf5c5..32828d1 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -30,8 +30,10 @@ export const ERR_INSTALL_SCREEN_READER_ASSET_UNAVAILABLE = "The requested screen reader asset is not available for this platform"; export const ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH = "Unable to resolve or create the Guidepup cache path"; +export const ERR_INSTALL_UNABLE_TO_CREATE_GUIDEPUP_CACHE_ASSET_PATH = + "Unable to create the Guidepup cache asset path"; export const ERR_INSTALL_FAILED_TO_REGISTER_INSTALLATION = - "Failed to register the Guidepup cache installation"; + "Failed to register the Guidepup cache asset installation"; export const ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET = "Failed to download Guidepup cache asset"; export const ERR_INSTALL_FAILED_TO_VERIFY_CHECKSUM = diff --git a/src/logging.ts b/src/logging.ts index 0a5903e..138c5bb 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -60,14 +60,10 @@ export function handleWarning(title: string, subtitle: string): void { } export function handleInstallError(err: Error): never { - let message = err.message; - - if (err.name) { - message = `${err.name}: ${message}`; - } + const message = `${err.name}: ${err.message}`; logError(""); - logError(chalk.bold(chalk.red(`[!] ${chalk.bold(message.toString())}`))); + logError(chalk.bold(chalk.red(`[!] ${chalk.bold(message)}`))); logError(""); logError("Unable to complete screen reader installation"); logError(""); @@ -79,6 +75,11 @@ export function handleInstallError(err: Error): never { ); logError(""); + if (err.cause && Error.isError(err.cause)) { + logError(chalk.dim(`Cause: ${err.cause.name}: ${err.cause.message}`)); + logError(""); + } + process.exit(1); } From 270dad99f3162782173f63014280fbf877176b72 Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 22:32:06 +0100 Subject: [PATCH 22/29] fix: avoid * in file paths --- src/commands/install/download-assets.ts | 14 ++++++++------ src/commands/install/get-platform-version.ts | 5 +++++ src/commands/install/prune-cache.ts | 3 ++- src/commands/install/resolve-cache-path.ts | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 src/commands/install/get-platform-version.ts diff --git a/src/commands/install/download-assets.ts b/src/commands/install/download-assets.ts index 0693531..a4cc6f1 100644 --- a/src/commands/install/download-assets.ts +++ b/src/commands/install/download-assets.ts @@ -11,6 +11,7 @@ import { ERR_INSTALL_UNABLE_TO_CREATE_GUIDEPUP_CACHE_ASSET_PATH, } from "../../errors"; import { handleInfoWithPath } from "../../logging"; +import { getPlatformVersion } from "./get-platform-version"; function platformMajorVersion(): string { return release().split(".", 1)[0]; @@ -44,7 +45,7 @@ async function downloadScreenReader( const downloadDestination = join( cachePath, screenReader.id, - asset.platformVersion ?? "*", + getPlatformVersion(asset), asset.version, asset.asset, ); @@ -61,13 +62,14 @@ async function downloadScreenReader( return; } + const downloadDestinationDirectory = dirname(downloadDestination); + try { - mkdirSync(dirname(downloadDestination), { recursive: true }); + mkdirSync(downloadDestinationDirectory, { recursive: true }); } catch (cause) { - throw new Error( - ERR_INSTALL_UNABLE_TO_CREATE_GUIDEPUP_CACHE_ASSET_PATH, + throw new Error(ERR_INSTALL_UNABLE_TO_CREATE_GUIDEPUP_CACHE_ASSET_PATH, { cause, - ); + }); } const source = `https://github.com/${asset.repository}/releases/download/${asset.version}/${asset.asset}`; @@ -90,7 +92,7 @@ async function downloadScreenReader( const unzipDestination = join( cachePath, screenReader.id, - asset.platformVersion ?? "*", + getPlatformVersion(asset), asset.version, "extracted", ); diff --git a/src/commands/install/get-platform-version.ts b/src/commands/install/get-platform-version.ts new file mode 100644 index 0000000..7f360cf --- /dev/null +++ b/src/commands/install/get-platform-version.ts @@ -0,0 +1,5 @@ +import { Asset } from "./types"; + +export function getPlatformVersion(asset: Asset) { + return asset.platformVersion ?? "all"; +} diff --git a/src/commands/install/prune-cache.ts b/src/commands/install/prune-cache.ts index 5b767a6..3a50e67 100644 --- a/src/commands/install/prune-cache.ts +++ b/src/commands/install/prune-cache.ts @@ -2,6 +2,7 @@ import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { deleteAsset } from "./delete-asset"; import { resolveManifest } from "./resolve-manifest"; +import { getPlatformVersion } from "./get-platform-version"; export async function pruneCache(cachePath: string): Promise { const required = new Set(); @@ -34,7 +35,7 @@ export async function pruneCache(cachePath: string): Promise { for (const screenReader of manifest.screenReaders) { for (const asset of screenReader.assets) { required.add( - join(screenReader.id, asset.platformVersion ?? "*", asset.version), + join(screenReader.id, getPlatformVersion(asset), asset.version), ); } } diff --git a/src/commands/install/resolve-cache-path.ts b/src/commands/install/resolve-cache-path.ts index 0802563..81048e0 100644 --- a/src/commands/install/resolve-cache-path.ts +++ b/src/commands/install/resolve-cache-path.ts @@ -27,7 +27,7 @@ export function resolveCachePath() { } catch (cause) { throw new Error( ERR_INSTALL_UNABLE_TO_RESOLVE_OR_CREATE_GUIDEPUP_CACHE_PATH, - cause, + { cause }, ); } From ddc0f57cda87149ec091b9f9053dd523de28acb0 Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 22:48:41 +0100 Subject: [PATCH 23/29] docs: improve documentation --- README.md | 55 ++++++++++++++++++++++++++----------- src/commands/setup/index.ts | 4 +-- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 100b0b5..7570349 100644 --- a/README.md +++ b/README.md @@ -28,39 +28,38 @@ And get cracking with your screen reader automation code! ## Usage -### GitHub Actions +### Setup -If you are using GitHub Actions, check out the dedicated [`guidepup/setup-action`](https://github.com/marketplace/actions/guidepup-setup): +This subcommand allows you to setup an OS environment ready for screen reader automation. -```yaml -- name: Setup Environment - uses: guidepup/setup-action +```console +npx @guidepup/setup setup ``` -### macOS - -If you are running this command locally you may need to take some manual steps to complete setup by following the [manual VoiceOver setup documentation](https://www.guidepup.dev/docs/guides/manual-voiceover-setup). - #### CI -If you are running this command in CI/CD, it is recommended to add the `--ci` flag to prevent interactive prompts: +If you are running this command in CI/CD, it is recommended to add the `--ci` flag to prevent prompts for manual interaction: ```console -npx @guidepup/setup --ci +npx @guidepup/setup setup --ci ``` -#### Ignore TCC Database Updates +#### macOS + +If you are running this command locally you may need to take some manual steps to complete setup by following the [manual VoiceOver setup documentation](https://www.guidepup.dev/docs/guides/manual-voiceover-setup). + +##### Ignore TCC Database Updates -If updating the `TCC.db` is not possible (due to enabled SIP) or not required, you can skip the database update step by using the `--macos-ignore-tcc-db` flag: +If updating the `TCC.db` is not possible (due to enabled SIP) or not required for your macOS setup, you can skip the database update step by using the `--macos-ignore-tcc-db` flag: ```console -npx @guidepup/setup --macos-ignore-tcc-db +npx @guidepup/setup setup --macos-ignore-tcc-db ``` > [!NOTE] > If the necessary permissions have not been granted by other means, using this flag may result in your environment not being set up for reliable screen reader automation. -#### Recording +##### Recording If you are encountering errors in CI for macOS you can pass a `--macos-record` flag to the command which will output a screen-recording of the setup to a `./recordings/` directory: @@ -68,7 +67,29 @@ If you are encountering errors in CI for macOS you can pass a `--macos-record` f npx @guidepup/setup --ci --macos-record ``` -##### Using HTTP / HTTPS Proxy for Installation +### Install + +This subcommand allows you to install screen readers and their assets to use in screen reader automation with [Guidepup](https://www.guidepup.dev/). + +```console +npx @guidepup/setup install +``` + +By default VoiceOver is installed on macOS, and NVDA is installed on Windows. + +To install a specific screen reader you can pass it to the command as an argument: + +```console +npx @guidepup/setup install voiceover +npx @guidepup/setup install nvda +``` + +Screen reader options include: + +- `voiceover` on macOS (default) +- `nvda` on Windows (default) + +#### Using HTTP / HTTPS Proxy for Installation If you are using a proxy connection, you must define the proxy URL in an env variable. You can use any of the following variables: @@ -76,6 +97,8 @@ If you are using a proxy connection, you must define the proxy URL in an env var - `https_proxy` - `HTTP_PROXY` - `http_proxy` +- `NO_PROXY` +- `no_proxy` ## Powerful Tooling diff --git a/src/commands/setup/index.ts b/src/commands/setup/index.ts index 1aaafec..236b150 100644 --- a/src/commands/setup/index.ts +++ b/src/commands/setup/index.ts @@ -40,7 +40,7 @@ export function setupCommand() { "Configure the local environment for screen reader automation.", ) .option("--ci", "Enable CI-specific behavior during setup.") - .option("--macos-ignore-tcc-db", "Skip macOS TCC database updates.") - .option("--macos-record", "Screen record the macOS setup.") + .option("--macos-ignore-tcc-db", "Skip the TCC database updates on macOS.") + .option("--macos-record", "Screen record the setup on macOS.") .action(setup); } From 7250d33501302054d90276e3b29d6ed159a2d98a Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 22:51:28 +0100 Subject: [PATCH 24/29] docs: global install --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 7570349..14ccf37 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,21 @@ If you are using a proxy connection, you must define the proxy URL in an env var - `NO_PROXY` - `no_proxy` +### Global Install + +You can also install the CLI globally: + +```console +npm install -g @guidepup/setup +``` + +After which you can access the CLI through the `guidepup` command: + +```console +guidepup setup +guidepup install +``` + ## Powerful Tooling Check out some of the other Guidepup modules: From ecf3933a694922045df9c178ef36c09e6beddf18 Mon Sep 17 00:00:00 2001 From: cmorten Date: Fri, 17 Jul 2026 22:52:52 +0100 Subject: [PATCH 25/29] chore: remove spec.md for agents --- spec.md | 336 -------------------------------------------------------- 1 file changed, 336 deletions(-) delete mode 100644 spec.md diff --git a/spec.md b/spec.md deleted file mode 100644 index ce8a4ea..0000000 --- a/spec.md +++ /dev/null @@ -1,336 +0,0 @@ -CLI Specification - -Summary - -This document defines the public behaviour of the @guidepup/setup CLI. - -The CLI has two primary responsibilities: - -- Configure the local environment required by Guidepup. -- Install screen reader assets required by an installed version of @guidepup/guidepup. - -The CLI intentionally mirrors the Playwright CLI where appropriate, providing a familiar developer experience while remaining independent from the implementation details of individual screen readers. - -⸻ - -Goals - -- Simple, predictable command structure. -- Separate environment configuration from screen reader installation. -- Reproducible installations. -- Deterministic asset selection. -- Shared per-user cache. -- Offline operation when all required assets already exist. -- Future extensibility without breaking existing users. - -⸻ - -Non-goals - -The following are explicitly out of scope for this specification: - -- Updating installed screen readers. -- Listing installed screen readers. -- Uninstalling screen readers. -- Pruning unused assets. -- Defining the manifest schema (covered separately). - -⸻ - -CLI - -Commands - -Setup - -Configures the local environment required by Guidepup. - -guidepup setup - -This command performs environment configuration only. - -It MUST NOT: - -- download screen readers -- install screen readers -- invoke the install command implicitly - -⸻ - -Install - -Installs screen reader assets. - -guidepup install -guidepup install - -Examples: - -guidepup install -guidepup install nvda -guidepup install voiceover - -When no screen reader is specified, the CLI installs all screen readers that: - -- are compatible with the current operating system -- have defaultDownload: true in the manifest - -When a screen reader is specified, only that screen reader is processed. - -If the requested screen reader is incompatible with the current operating system, the CLI MUST: - -- print a clear error explaining the incompatibility -- exit with status code 1 - -⸻ - -Help - -guidepup --help -guidepup install --help - -⸻ - -Version - -guidepup --version - -⸻ - -Manifest - -Source of truth - -@guidepup/guidepup owns compatibility between Guidepup and screen reader assets. - -@guidepup/setup MUST NOT contain hardcoded knowledge of compatible screen reader versions. - -Instead it reads a manifest supplied by the installed @guidepup/guidepup package. - -The manifest is the public contract between the runtime package and the CLI. - -⸻ - -Location - -The manifest MUST exist at: - -@guidepup/guidepup/ -manifest.json - -⸻ - -Discovery - -The CLI discovers the installed Guidepup package using normal Node.js module resolution. - -The nearest installed @guidepup/guidepup package is used. - -No CLI flags are required to locate the manifest. - -⸻ - -Missing package - -If no local installation of @guidepup/guidepup can be found, the CLI MUST fail with a clear error. - -Example: - -No local installation of @guidepup/guidepup was found. -Install @guidepup/guidepup before running: -guidepup install - -Exact wording is implementation defined. - -Exit status: - -1 - -⸻ - -Manifest validation - -The CLI MUST validate that: - -- the manifest exists -- the manifest is valid JSON -- required fields exist -- the schema version is supported - -If validation fails, the CLI MUST fail with a clear error explaining that the installation appears invalid or corrupted. - -The CLI MUST NOT attempt to recover from an invalid manifest. - -Exit status: - -1 - -⸻ - -Manifest schema version - -The manifest contains a schema version. - -The CLI supports one or more schema versions. - -If the CLI encounters an unsupported schema version it MUST fail with an error instructing the user to upgrade @guidepup/setup. - -Compatibility is determined by the manifest schema version—not by the versions of either npm package. - -⸻ - -Asset Resolution - -The manifest defines: - -- supported screen readers -- compatible operating systems -- downloadable asset information -- checksum -- default download behaviour - -The CLI consumes this information without embedding compatibility logic. - -⸻ - -Cache - -Default location - -Downloaded assets are stored in a per-user cache. - -Platform defaults: - -macOS - -~/Library/Caches/guidepup - -Linux - -~/.cache/guidepup - -Windows - -%LOCALAPPDATA%\guidepup - -⸻ - -Override - -The cache location may be overridden using: - -GUIDEPUP_SCREEN_READERS_PATH - -This path may point anywhere accessible to the current user. - -The CLI does not distinguish between user and machine-wide installations. - -⸻ - -Installation Behaviour - -For every required asset: - -1. Resolve the asset from the manifest. -2. Check whether the required version already exists. -3. Validate the local asset against the checksum in the manifest. -4. If valid: - - perform no download - - report success (wording implementation defined) -5. If missing or invalid: - - obtain the asset - - verify download integrity - - install the asset - - validate the installed result - -The CLI should be self-healing. - -Corrupt or incomplete installations are automatically replaced. - -⸻ - -Integrity - -The manifest is the trusted description of every asset. - -It contains sufficient information to validate both downloaded and installed assets. - -The download mechanism is intentionally abstract. - -Initially the CLI may construct GitHub Release download URLs. - -Future implementations may instead use: - -- GitHub APIs -- mirrors -- CDNs -- dedicated artifact services -- CLI tooling - -without requiring manifest changes. - -⸻ - -Offline Behaviour - -If every required asset already exists locally and passes checksum validation: - -- no network requests are required -- the install command succeeds -- the command behaves as a no-op - -Internet access is only required when an asset must be downloaded or replaced. - -⸻ - -Multiple Assets - -Each asset installation is independent. - -If one asset fails to install, remaining assets should still be attempted. - -At completion: - -If every asset succeeds: - -Exit status: - -0 - -If one or more assets fail: - -- report failures -- exit with status code - -1 - -⸻ - -Asset Lifetime - -Installing assets never removes existing assets. - -The install command only: - -- installs missing assets -- repairs corrupted assets - -Automatic pruning is out of scope. - -Future CLI commands may introduce explicit cache management. - -⸻ - -Design Principles - -The CLI follows these principles: - -- Guidepup owns compatibility. -- The manifest is the contract. -- The CLI executes the manifest. -- Assets are shared across projects. -- Existing installations are reused whenever possible. -- Installs are reproducible. -- Offline operation is supported. -- Commands perform one responsibility only. -- Destructive operations are always explicit. From 65d749a100261917f88bb4bbd4111722cdb68b64 Mon Sep 17 00:00:00 2001 From: cmorten Date: Sat, 18 Jul 2026 19:15:08 +0100 Subject: [PATCH 26/29] docs: improve docs coverage --- README.md | 114 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 70 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 14ccf37..773d4be 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Guidepup Setup +# `@guidepup/setup` Guidepup Setup available on NPM Guidepup Setup test workflows @@ -12,75 +12,86 @@ [![Windows Server 2022 Support](https://img.shields.io/badge/windows_server-2022-blue.svg?logo=windows)](https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2022) [![Windows Server 2025 Support](https://img.shields.io/badge/windows_server-2025-blue.svg?logo=windows)](https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2025) -This package sets up your environment for screen reader automation. +The `@guidepup/setup` CLI enables automation for VoiceOver on macOS and NVDA on Windows. -It enables automation for VoiceOver on macOS and NVDA on Windows. +## Quick start -## Getting Started +```sh +# Install Guidepup +npm install @guidepup/guidepup -Run this command: +# Run setup once per machine +npx @guidepup/setup setup -```console -npx @guidepup/setup +# Run install per project and after upgrades +npx @guidepup/setup install ``` -And get cracking with your screen reader automation code! - -## Usage +## Environment setup -### Setup +The `setup` CLI command configures your operating system for screen reader automation and only needs to be run once per machine: -This subcommand allows you to setup an OS environment ready for screen reader automation. - -```console +```sh npx @guidepup/setup setup ``` -#### CI +### CI -If you are running this command in CI/CD, it is recommended to add the `--ci` flag to prevent prompts for manual interaction: +If you are running the `setup` CLI command in CI/CD, it is recommended to add the `--ci` flag to prevent prompts for manual interaction: -```console +```sh npx @guidepup/setup setup --ci ``` -#### macOS +### macOS If you are running this command locally you may need to take some manual steps to complete setup by following the [manual VoiceOver setup documentation](https://www.guidepup.dev/docs/guides/manual-voiceover-setup). -##### Ignore TCC Database Updates +#### Ignoring TCC database updates -If updating the `TCC.db` is not possible (due to enabled SIP) or not required for your macOS setup, you can skip the database update step by using the `--macos-ignore-tcc-db` flag: +If updating the `TCC.db` is not possible (due to enabled System Integrity Protection) or not required for your macOS setup, you can skip database updates by using the `--macos-ignore-tcc-db` flag: -```console +```sh npx @guidepup/setup setup --macos-ignore-tcc-db ``` > [!NOTE] > If the necessary permissions have not been granted by other means, using this flag may result in your environment not being set up for reliable screen reader automation. -##### Recording +> [!WARNING] +> For system TCC.db updates you must first disable [System Integrity Protection (SIP)](https://support.apple.com/en-gb/102149). This comes with **serious security implications**, so please first refer to the [Apple documentation](https://developer.apple.com/documentation/security/disabling-and-enabling-system-integrity-protection) for more details before taking any action. Consider whether you are able to use alternative manual setup steps for configuration before exploring this option further. +> +> SIP only needs to be disabled while the required changes are being made. Re-enable SIP once the `setup` CLI command has completed. + +#### Recording If you are encountering errors in CI for macOS you can pass a `--macos-record` flag to the command which will output a screen-recording of the setup to a `./recordings/` directory: -```console -npx @guidepup/setup --ci --macos-record +```sh +npx @guidepup/setup setup --ci --macos-record ``` -### Install +## Installing screen reader assets -This subcommand allows you to install screen readers and their assets to use in screen reader automation with [Guidepup](https://www.guidepup.dev/). +Each version of Guidepup requires specific screen reader assets to execute correctly. -```console +Install the default supported screen reader assets with one command from your project directory: + +```sh npx @guidepup/setup install ``` -By default VoiceOver is installed on macOS, and NVDA is installed on Windows. +The `install` CLI command uses your project's installed `@guidepup/guidepup` version to determine which screen reader assets to install. + +New Guidepup releases may update the required screen reader assets. If you update Guidepup, rerun the `install` CLI command to install the latest supported screen reader assets. -To install a specific screen reader you can pass it to the command as an argument: +You can install a specific screen reader by providing additional arguments to the `install` CLI command: -```console +```sh +# Install VoiceOver npx @guidepup/setup install voiceover + +# Install NVDA npx @guidepup/setup install nvda ``` @@ -89,33 +100,48 @@ Screen reader options include: - `voiceover` on macOS (default) - `nvda` on Windows (default) -#### Using HTTP / HTTPS Proxy for Installation +### Managing screen reader assets + +By default Guidepup downloads screen reader assets into the operating system's standard cache directory: -If you are using a proxy connection, you must define the proxy URL in an env variable. You can use any of the following variables: +- `~/.cache/guidepup/` on Linux +- `~/Library/Caches/guidepup/` on macOS +- `%USERPROFILE%\AppData\Local\guidepup\` on Windows -- `HTTPS_PROXY` -- `https_proxy` -- `HTTP_PROXY` -- `http_proxy` -- `NO_PROXY` -- `no_proxy` +You can override the default cache directory using an environment variable: + +```sh +GUIDEPUP_SCREEN_READERS_PATH=$HOME/guidepup npx @guidepup/setup install +``` + +### Unused screen reader cleanup + +Guidepup tracks which projects use each installed screen reader asset. When an asset is no longer referenced by any project, it is automatically removed the next time the `install` CLI command runs. + +### Installing behind a proxy + +Guidepup screen readers are installed directly from GitHub release URLs. If you are using a proxy you can use an environment variable to define the proxy URL: + +```sh +HTTPS_PROXY=https://192.0.2.1 npx @guidepup/setup install +``` -### Global Install +## Global install -You can also install the CLI globally: +You can install the CLI globally: -```console +```sh npm install -g @guidepup/setup ``` -After which you can access the CLI through the `guidepup` command: +After which you can access the CLI directly through the `guidepup` command: -```console +```sh guidepup setup guidepup install ``` -## Powerful Tooling +## Powerful tooling Check out some of the other Guidepup modules: From e8030715afdd117aa0ae396665078a02142efdc0 Mon Sep 17 00:00:00 2001 From: cmorten Date: Sat, 18 Jul 2026 19:17:52 +0100 Subject: [PATCH 27/29] chore: package description --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f893230..81892e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@guidepup/setup", "version": "0.24.0", - "description": "Configure your environment and manage screen readers for Guidepup.", + "description": "CLI for configuring environments and install screen reader assets for Guidepup.", "main": "lib/index.js", "typings": "lib/index.d.ts", "bin": { From fed6d5bb336ab3da881eb4fdbca1b8b0db02c8ad Mon Sep 17 00:00:00 2001 From: cmorten Date: Sat, 18 Jul 2026 22:11:46 +0100 Subject: [PATCH 28/29] docs: tweak quick start --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 773d4be..51558f5 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,13 @@ The `@guidepup/setup` CLI enables automation for