From f573c38aa33ba7d4b3d36892b2a2ea773c09d715 Mon Sep 17 00:00:00 2001 From: cmorten Date: Sun, 12 Jul 2026 18:42:57 +0100 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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