From 6b3ec32b3ab7a82eb937cc850d217413ec752483 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Mon, 27 Apr 2026 08:53:17 -0400 Subject: [PATCH] feat(config): add TypeScript build and config support --- .envrc | 1 + AGENTS.md | 2 +- README.md | 56 ++- flake.nix | 4 +- package-lock.json | 610 ++++++++++++++++++++++++++++- package.json | 18 +- src/config.ts | 117 ++++++ src/{driver.js => driver.ts} | 32 +- src/{index.js => index.ts} | 51 ++- src/providers/{kagi.js => kagi.ts} | 52 ++- test/smoke.js | 212 +++++++--- tsconfig.json | 15 + 12 files changed, 1043 insertions(+), 127 deletions(-) create mode 100644 .envrc create mode 100644 src/config.ts rename src/{driver.js => driver.ts} (56%) rename src/{index.js => index.ts} (91%) rename src/providers/{kagi.js => kagi.ts} (51%) create mode 100644 tsconfig.json diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/AGENTS.md b/AGENTS.md index 70630b7..d412f0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ If running outside Nix, document that Firefox and geckodriver must be on `PATH`. ## Code Style -- Use ES modules. +- Use TypeScript with ES modules; source lives in `src/**/*.ts` and builds to `dist/`. - Keep code direct and minimal; avoid abstractions until they are needed. - Add short Title Case comments above cohesive logic blocks. - Prefer exact, actionable error messages. diff --git a/README.md b/README.md index 7c7bb18..06ad08d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ If running directly with Node.js, install dependencies and make sure `firefox` a ```bash npm install -node src/index.js exec https://example.com --js='return document.title' +npm run build +node dist/src/index.js exec https://example.com --js='return document.title' ``` ## Glimpse CLI @@ -31,6 +32,7 @@ glimpse [options] Common options: +- `--config=` - read config from a custom path instead of `~/.config/glimpse/config.json` - `--no-headless` - show Firefox instead of running headless - `--url=` - connect to an existing WebDriver server - `--timeout=` - maximum wait time in milliseconds for command waits (default: `10000`) @@ -173,22 +175,45 @@ Output: Search using a supported provider and print a JSON array of results. Currently only Kagi is supported. -Kagi requires `--token=` or a `KAGI_TOKEN` environment variable. The token is validated by the Kagi provider and sent to Kagi as the `token` query parameter. +Kagi requires a token from `--token=`, `KAGI_TOKEN`, or the glimpse config file. The token is validated by the Kagi provider and sent to Kagi as the `token` query parameter. + +Default config path: + +```text +~/.config/glimpse/config.json +``` + +Example config: + +```json +{ + "search": { + "provider": "kagi" + }, + "providers": { + "kagi": { + "token": "your-kagi-token" + } + } +} +``` + +Then search without exposing the token in command arguments: ```bash -KAGI_TOKEN=... nix run .#glimpse -- search --provider=kagi "nix flakes selenium webdriver" +nix run .#glimpse -- search "nix flakes selenium webdriver" ``` Local usage: ```bash -KAGI_TOKEN=... ./result/bin/glimpse search "nix flakes selenium webdriver" +./result/bin/glimpse search "nix flakes selenium webdriver" ``` Options: -- `--provider=` - search provider: `kagi` (default: `kagi`) -- `--token=` - Kagi token (default: `KAGI_TOKEN`) +- `--provider=` - search provider: `kagi` (default: config or `kagi`) +- `--token=` - Kagi token (default: `KAGI_TOKEN` or config) - `--no-headless` - show Firefox instead of running headless - `--url=` - connect to an existing WebDriver server - `--timeout=` - wait time for results before returning `[]` (default: `10000`) @@ -217,7 +242,7 @@ Run the built tool: ```bash ./result/bin/glimpse exec https://example.com --js='return document.title' -KAGI_TOKEN=... ./result/bin/glimpse search "example query" +./result/bin/glimpse search "example query" ``` ## Development @@ -253,16 +278,19 @@ node test/smoke.js snapshot js Useful local commands: ```bash -node src/index.js snapshot 'data:text/html,Hello

Hello

' -node src/index.js exec 'data:text/html,Hello' --js='return document.title' -node src/index.js screenshot 'data:text/html,Hello' --output=/tmp/page.png -node src/index.js reader 'https://example.com/article' +npm run build +node dist/src/index.js snapshot 'data:text/html,Hello

Hello

' +node dist/src/index.js exec 'data:text/html,Hello' --js='return document.title' +node dist/src/index.js screenshot 'data:text/html,Hello' --output=/tmp/page.png +node dist/src/index.js reader 'https://example.com/article' ``` ## Project Structure -- `src/index.js` - `glimpse` CLI with subcommands, including Firefox Reader View extraction and provider-backed search -- `src/driver.js` - Firefox WebDriver creation and geckodriver resolution -- `src/providers/kagi.js` - reusable Kagi search provider implementation +- `src/index.ts` - `glimpse` CLI with subcommands, including Firefox Reader View extraction and provider-backed search +- `src/config.ts` - home-dir config loading for CLI defaults and provider settings +- `src/driver.ts` - Firefox WebDriver creation and geckodriver resolution +- `src/providers/kagi.ts` - reusable Kagi search provider implementation +- `tsconfig.json` - TypeScript compiler settings; build output goes to `dist/` - `flake.nix` - Nix dev shell, package, wrappers, and apps - `KAGI.md` - Kagi-specific notes diff --git a/flake.nix b/flake.nix index 2f36fd0..43a6fdc 100644 --- a/flake.nix +++ b/flake.nix @@ -42,8 +42,8 @@ version = "1.0.0"; src = source; - npmDepsHash = "sha256-IWzSvrGgkoR6gg7P1m/mwakGOOKmm2OFtBirKgE09Ds="; - dontNpmBuild = true; + npmDepsHash = "sha256-ycAjPZZqI3ZMIUubJbWy8G6X6LaXDcgdZGswikfkQj8="; + npmBuildScript = "build"; nativeBuildInputs = [ pkgs.makeWrapper ]; diff --git a/package-lock.json b/package-lock.json index 1e75ee0..7e4cc9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,14 @@ "turndown": "^7.2.4" }, "bin": { - "glimpse": "src/index.js" + "glimpse": "dist/src/index.js" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@types/selenium-webdriver": "^4.35.5", + "@types/turndown": "^5.0.6", + "tsx": "^4.21.0", + "typescript": "^6.0.3" } }, "node_modules/@bazel/runfiles": { @@ -23,6 +30,448 @@ "integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==", "license": "Apache-2.0" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@mixmark-io/domino": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", @@ -333,12 +782,120 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/selenium-webdriver": { + "version": "4.35.5", + "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-4.35.5.tgz", + "integrity": "sha512-wCQCjWmahRkUAO7S703UAvBFkxz4o/rjX4T2AOSWKXSi0sTQPsrXxR0GjtFUT0ompedLkYH4R5HO5Urz0hyeog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ws": "*" + } + }, + "node_modules/@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -449,6 +1006,16 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -504,6 +1071,26 @@ "node": ">=14.14" } }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/turndown": { "version": "7.2.4", "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", @@ -517,6 +1104,27 @@ "npm": ">=9" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index be7077e..acd1ca1 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,14 @@ "version": "1.0.0", "description": "", "type": "module", - "main": "src/index.js", + "main": "dist/src/index.js", "bin": { - "glimpse": "./src/index.js" + "glimpse": "./dist/src/index.js" }, "scripts": { - "lint": "oxlint --ignore-pattern node_modules --ignore-pattern .direnv .", - "start": "node src/index.js", + "build": "tsc && chmod +x dist/src/index.js", + "lint": "oxlint --ignore-pattern node_modules --ignore-pattern .direnv --ignore-pattern dist . && tsc --noEmit", + "start": "tsx src/index.ts", "test": "node test/smoke.js", "test:smoke": "node test/smoke.js", "test:list": "node test/smoke.js --list", @@ -23,10 +24,17 @@ }, "keywords": [], "author": "", - "license": "ISC", + "license": "MIT", "dependencies": { "oxlint": "^1.61.0", "selenium-webdriver": "^4.43.0", "turndown": "^7.2.4" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@types/selenium-webdriver": "^4.35.5", + "@types/turndown": "^5.0.6", + "tsx": "^4.21.0", + "typescript": "^6.0.3" } } diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..4dd8850 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,117 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface GlimpseConfig { + search?: { + provider?: "kagi"; + }; + providers?: { + kagi?: { + token?: string; + }; + }; +} + +export class ConfigError extends Error { + code: string; + + constructor(code: string, message: string) { + super(message); + this.code = code; + } +} + +export function defaultConfigPath( + env: NodeJS.ProcessEnv = process.env, +): string { + const configHome = env.XDG_CONFIG_HOME || join(homedir(), ".config"); + return join(configHome, "glimpse", "config.json"); +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function validateObject( + value: unknown, + path: string, + name: string, +): asserts value is Record | undefined { + if (value !== undefined && !isObject(value)) { + throw new ConfigError( + "INVALID_CONFIG", + `${name} must be an object: ${path}`, + ); + } +} + +function validateString( + value: unknown, + path: string, + name: string, +): asserts value is string | undefined { + if (value !== undefined && typeof value !== "string") { + throw new ConfigError( + "INVALID_CONFIG", + `${name} must be a string: ${path}`, + ); + } +} + +function validateConfig( + config: unknown, + path: string, +): asserts config is GlimpseConfig { + if (!isObject(config)) { + throw new ConfigError( + "INVALID_CONFIG", + `Config file must contain a JSON object: ${path}`, + ); + } + + // Validate Search Config + validateObject(config.search, path, "search"); + validateString(config.search?.provider, path, "search.provider"); + + if (config.search?.provider && !["kagi"].includes(config.search.provider)) { + throw new ConfigError( + "INVALID_CONFIG", + `Unsupported search.provider value: ${config.search.provider}. Expected kagi: ${path}`, + ); + } + + // Validate Provider Config + validateObject(config.providers, path, "providers"); + validateObject(config.providers?.kagi, path, "providers.kagi"); + validateString(config.providers?.kagi?.token, path, "providers.kagi.token"); +} + +export function loadConfig({ + path = defaultConfigPath(), +}: { path?: string } = {}): GlimpseConfig { + if (!existsSync(path)) { + return {}; + } + + let parsed: unknown; + + // Parse Config File + try { + parsed = JSON.parse(readFileSync(path, "utf-8")); + } catch (err) { + throw new ConfigError( + "CONFIG_READ_FAILED", + `Failed to read config file ${path}: ${errorMessage(err)}`, + ); + } + + // Validate Config Shape + validateConfig(parsed, path); + + return parsed; +} diff --git a/src/driver.js b/src/driver.ts similarity index 56% rename from src/driver.js rename to src/driver.ts index 5532528..300e3e5 100644 --- a/src/driver.js +++ b/src/driver.ts @@ -1,13 +1,13 @@ import { execFileSync } from "node:child_process"; -import { Builder } from "selenium-webdriver"; +import { Builder, type WebDriver } from "selenium-webdriver"; import firefox from "selenium-webdriver/firefox.js"; -/** - * Resolve the geckodriver path from $PATH. - * - * @returns {string} - */ -function findGeckodriver() { +export interface DriverOptions { + headless?: boolean; + existingUrl?: string; +} + +function findGeckodriver(): string { try { return execFileSync("which", ["geckodriver"], { encoding: "utf-8" }).trim(); } catch { @@ -17,16 +17,10 @@ function findGeckodriver() { } } -/** - * Create a Firefox WebDriver instance. - * - * @param {object} opts - * @param {boolean} [opts.headless=false] - Run Firefox in headless mode. - * @param {string} [opts.existingUrl] - Connect to an already-running - * WebDriver server (e.g. "http://localhost:4444"). - * @returns {Promise} - */ -export async function createDriver({ headless = false, existingUrl } = {}) { +export async function createDriver({ + headless = false, + existingUrl, +}: DriverOptions = {}): Promise { const options = new firefox.Options(); // Configure Headless @@ -34,7 +28,9 @@ export async function createDriver({ headless = false, existingUrl } = {}) { options.addArguments("--headless"); } - const builder = new Builder().forBrowser("firefox").setFirefoxOptions(options); + const builder = new Builder() + .forBrowser("firefox") + .setFirefoxOptions(options); // Connect to Existing Server if (existingUrl) { diff --git a/src/index.js b/src/index.ts similarity index 91% rename from src/index.js rename to src/index.ts index c21a59d..11092f8 100755 --- a/src/index.js +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { loadConfig, type GlimpseConfig } from "./config.js"; import { createDriver } from "./driver.js"; import { searchKagi } from "./providers/kagi.js"; import { readFileSync, writeFileSync } from "node:fs"; @@ -8,7 +9,7 @@ import TurndownService from "turndown"; const DEFAULT_TIMEOUT_MS = 10000; const POLL_INTERVAL_MS = 200; const startTime = Date.now(); -const runContext = {}; +const runContext: { targetUrl?: string; currentUrl?: string } = {}; // Parse CLI Args const [command, ...args] = process.argv.slice(2); @@ -18,6 +19,8 @@ const inlineJs = getOption("--js"); const scriptPath = getOption("--script"); const waitJs = getOption("--wait-js"); const waitUntil = getOption("--wait-until") ?? "none"; +const configPath = getOption("--config"); +let appConfig: GlimpseConfig = {}; let timeoutMs = DEFAULT_TIMEOUT_MS; function getOption(name) { @@ -50,6 +53,9 @@ function printResult(result) { } class CliError extends Error { + code: string; + details: Record; + constructor(code, message, details = {}) { super(message); this.code = code; @@ -84,6 +90,7 @@ Common Options: --wait-until= Wait for readiness: none, interactive, complete (default: none) --js= Execute inline JS before command logic --script= Execute JS from a file before command logic + --config= Read config from a custom path Exec Options: --js= Return the top-level JS result @@ -97,8 +104,8 @@ Reader Options: --output= Write output to a file Search Options: - --provider= Search provider: kagi (default: kagi) - --token= Kagi token (default: KAGI_TOKEN) + --provider= Search provider: kagi (default: config or kagi) + --token= Kagi token (default: KAGI_TOKEN or config) Examples: glimpse snapshot https://example.com @@ -114,7 +121,10 @@ function printHelp() { } function usage() { - cliError("USAGE_ERROR", "Usage: glimpse [options]. Run glimpse --help for details."); + cliError( + "USAGE_ERROR", + "Usage: glimpse [options]. Run glimpse --help for details.", + ); } function parseTimeout() { @@ -179,7 +189,9 @@ async function waitForReadyState(driver) { try { await driver.wait(async () => { - const readyState = await driver.executeScript("return document.readyState"); + const readyState = await driver.executeScript( + "return document.readyState", + ); return waitUntil === "interactive" ? ["interactive", "complete"].includes(readyState) : readyState === "complete"; @@ -214,7 +226,10 @@ async function waitForJs(driver) { await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } - cliError("WAIT_TIMEOUT", `Timed out after ${timeoutMs}ms waiting for --wait-js`); + cliError( + "WAIT_TIMEOUT", + `Timed out after ${timeoutMs}ms waiting for --wait-js`, + ); } async function runPreludeScript(driver) { @@ -417,7 +432,8 @@ function renderReaderOutput(article, format) { } async function searchCommand() { - const provider = getOption("--provider") ?? "kagi"; + const provider = + getOption("--provider") ?? appConfig.search?.provider ?? "kagi"; const query = getPositionalArgs().join(" "); if (!query) usage(); @@ -428,6 +444,7 @@ async function searchCommand() { return searchKagi({ query, token: getOption("--token"), + config: appConfig, headless, existingUrl, timeoutMs, @@ -458,8 +475,9 @@ async function readerCommand() { // Wait For Reader Content let article; try { - article = await driver.wait(async () => { - return driver.executeScript(` + article = await driver.wait( + async () => { + return driver.executeScript(` const content = document.querySelector("#moz-reader-content, .moz-reader-content"); const error = document.querySelector(".reader-error"); const text = content?.innerText?.trim() || ""; @@ -481,7 +499,10 @@ async function readerCommand() { return null; `); - }, timeoutMs, `No readable article content found for URL: ${targetUrl}`); + }, + timeoutMs, + `No readable article content found for URL: ${targetUrl}`, + ); } catch (err) { cliError("TIMEOUT", err.message); } @@ -517,6 +538,9 @@ async function main() { validateCommonOptions(); + // Load Config + appConfig = loadConfig({ path: configPath }); + switch (command) { case "snapshot": return snapshotCommand(); @@ -537,7 +561,12 @@ main() .then(printResult) .catch((err) => { const code = err.code || "COMMAND_FAILED"; - const output = { + const output: { + ok: false; + error: { code: string; message: string }; + elapsedMs: number; + url?: string; + } = { ok: false, error: { code, diff --git a/src/providers/kagi.js b/src/providers/kagi.ts similarity index 51% rename from src/providers/kagi.js rename to src/providers/kagi.ts index 26c50b8..33bad64 100644 --- a/src/providers/kagi.js +++ b/src/providers/kagi.ts @@ -1,7 +1,26 @@ import { createDriver } from "../driver.js"; +import type { GlimpseConfig } from "../config.js"; + +export interface SearchResult { + title: string; + url: string; + description: string; +} + +export interface SearchKagiOptions { + query?: string; + token?: string; + config?: GlimpseConfig; + headless?: boolean; + existingUrl?: string; + timeoutMs?: number; + intervalMs?: number; +} export class SearchProviderError extends Error { - constructor(code, message) { + code: string; + + constructor(code: string, message: string) { super(message); this.code = code; } @@ -17,28 +36,36 @@ export const kagiSearchScript = `return Array.from(document.querySelectorAll("di }));`; // Build Kagi Search Url -export function buildKagiSearchUrl(query, token) { +export function buildKagiSearchUrl(query: string, token: string): string { return `https://kagi.com/search?token=${encodeURIComponent(token)}&q=${encodeURIComponent(query)}`; } +// Resolve Config Token +function configToken(config: GlimpseConfig): string | undefined { + return config.providers?.kagi?.token; +} + // Search Kagi export async function searchKagi({ query, - token = process.env.KAGI_TOKEN, + token, + config = {}, headless = true, existingUrl, timeoutMs = 5000, intervalMs = 200, -} = {}) { +}: SearchKagiOptions = {}): Promise { if (!query) { throw new SearchProviderError("QUERY_REQUIRED", "query is required"); } - // Validate Kagi Token - if (!token) { + // Resolve Kagi Token + const resolvedToken = token || process.env.KAGI_TOKEN || configToken(config); + + if (!resolvedToken) { throw new SearchProviderError( "KAGI_TOKEN_REQUIRED", - "Kagi search requires --token or the KAGI_TOKEN environment variable.", + "Kagi search requires --token, the KAGI_TOKEN environment variable, or a config token.", ); } @@ -46,15 +73,18 @@ export async function searchKagi({ try { // Navigate To Kagi - await driver.get(buildKagiSearchUrl(query, token)); + await driver.get(buildKagiSearchUrl(query, resolvedToken)); // Poll For Results - let result = []; + let result: SearchResult[] = []; const start = Date.now(); while (Date.now() - start < timeoutMs) { - result = await driver.executeScript(kagiSearchScript); - if (Array.isArray(result) && result.length > 0) break; + const scriptResult = await driver.executeScript(kagiSearchScript); + result = Array.isArray(scriptResult) + ? (scriptResult as SearchResult[]) + : []; + if (result.length > 0) break; await new Promise((resolve) => setTimeout(resolve, intervalMs)); } diff --git a/test/smoke.js b/test/smoke.js index c462480..5ca55d0 100755 --- a/test/smoke.js +++ b/test/smoke.js @@ -1,12 +1,18 @@ #!/usr/bin/env node -import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { + mkdtempSync, + rmSync, + existsSync, + mkdirSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; import assert from "node:assert/strict"; -const cliPath = new URL("../src/index.js", import.meta.url).pathname; +const cliPath = new URL("../src/index.ts", import.meta.url).pathname; const tempDir = mkdtempSync(join(tmpdir(), "glimpse-smoke-")); const filters = process.argv.slice(2).filter((arg) => arg !== "--list"); const shouldList = process.argv.includes("--list"); @@ -21,7 +27,7 @@ function dataHtml(html) { } function runCli(args, options = {}) { - return spawnSync(process.execPath, [cliPath, ...args], { + return spawnSync(process.execPath, ["--import", "tsx", cliPath, ...args], { encoding: "utf-8", env: options.env ?? process.env, timeout: 30000, @@ -36,14 +42,14 @@ function parseJson(text) { } } -function expectSuccess(args) { - const result = runCli(args); +function expectSuccess(args, options = {}) { + const result = runCli(args, options); assert.equal(result.status, 0, result.stderr || result.stdout); return parseJson(result.stdout); } -function expectFailure(args) { - const result = runCli(args); +function expectFailure(args, options = {}) { + const result = runCli(args, options); assert.notEqual(result.status, 0, result.stdout || result.stderr); return parseJson(result.stderr); } @@ -83,7 +89,9 @@ test("help flag prints help", ["help", "cli"], () => { test("snapshot returns page metadata and content", ["snapshot"], () => { const output = expectSuccess([ "snapshot", - dataHtml('Hello

Main

X'), + dataHtml( + 'Hello

Main

X', + ), ]); assert.equal(output.ok, true); @@ -98,24 +106,30 @@ test("snapshot returns page metadata and content", ["snapshot"], () => { test("snapshot extracts aria headings", ["snapshot"], () => { const output = expectSuccess([ "snapshot", - dataHtml('Hello
ARIA
'), + dataHtml( + 'Hello
ARIA
', + ), ]); assert.equal(output.ok, true); assert.deepEqual(output.result.headings, [{ level: 2, text: "ARIA" }]); }); -test("snapshot runs top-level javascript before extraction", ["snapshot", "js"], () => { - const output = expectSuccess([ - "snapshot", - dataHtml("Hello

Old

"), - "--js=document.querySelector('h1').textContent = 'New'", - ]); +test( + "snapshot runs top-level javascript before extraction", + ["snapshot", "js"], + () => { + const output = expectSuccess([ + "snapshot", + dataHtml("Hello

Old

"), + "--js=document.querySelector('h1').textContent = 'New'", + ]); - assert.equal(output.ok, true); - assert.deepEqual(output.result.headings, [{ level: 1, text: "New" }]); - assert.equal(output.result.text, "New"); -}); + assert.equal(output.ok, true); + assert.deepEqual(output.result.headings, [{ level: 1, text: "New" }]); + assert.equal(output.result.text, "New"); + }, +); test("exec returns javascript result", ["exec", "js"], () => { const result = runCli([ @@ -128,19 +142,23 @@ test("exec returns javascript result", ["exec", "js"], () => { assert.equal(result.stdout.trim(), "Hello"); }); -test("screenshot returns standard success envelope and writes file", ["screenshot"], () => { - const outputPath = join(tempDir, "page.png"); - const output = expectSuccess([ - "screenshot", - dataHtml("Hello"), - `--output=${outputPath}`, - ]); +test( + "screenshot returns standard success envelope and writes file", + ["screenshot"], + () => { + const outputPath = join(tempDir, "page.png"); + const output = expectSuccess([ + "screenshot", + dataHtml("Hello"), + `--output=${outputPath}`, + ]); - assert.equal(output.ok, true); - assert.equal(output.result.path, outputPath); - assert.equal(typeof output.elapsedMs, "number"); - assert.equal(existsSync(outputPath), true); -}); + assert.equal(output.ok, true); + assert.equal(output.result.path, outputPath); + assert.equal(typeof output.elapsedMs, "number"); + assert.equal(existsSync(outputPath), true); + }, +); test("search validates kagi token in provider", ["search", "errors"], () => { const env = { ...process.env }; @@ -152,9 +170,63 @@ test("search validates kagi token in provider", ["search", "errors"], () => { assert.equal(output.ok, false); assert.equal(output.error.code, "KAGI_TOKEN_REQUIRED"); assert.match(output.error.message, /Kagi search requires/); + assert.match(output.error.message, /config token/); assert.equal(typeof output.elapsedMs, "number"); }); +test( + "invalid config returns structured error before browser startup", + ["config", "errors"], + () => { + const configPath = join(tempDir, "bad-config.json"); + writeFileSync(configPath, "not json"); + + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + `--config=${configPath}`, + ]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "CONFIG_READ_FAILED"); + assert.match(output.error.message, /Failed to read config file/); + }, +); + +test( + "invalid config schema returns structured error", + ["config", "errors"], + () => { + const configPath = join(tempDir, "bad-schema.json"); + writeFileSync(configPath, JSON.stringify({ search: { provider: 42 } })); + + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + `--config=${configPath}`, + ]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "INVALID_CONFIG"); + assert.match(output.error.message, /search\.provider must be a string/); + }, +); + +test("empty home config is accepted", ["config"], () => { + const configHome = join(tempDir, "config-home"); + const configDir = join(configHome, "glimpse"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "config.json"), "{}"); + + const output = expectSuccess( + ["snapshot", dataHtml("Hello

Main

")], + { env: { ...process.env, XDG_CONFIG_HOME: configHome } }, + ); + + assert.equal(output.ok, true); + assert.equal(output.title, "Hello"); +}); + test("unknown command returns structured error", ["errors", "cli"], () => { const output = expectFailure(["nope", dataHtml("Hello")]); @@ -164,18 +236,22 @@ test("unknown command returns structured error", ["errors", "cli"], () => { assert.equal(typeof output.elapsedMs, "number"); }); -test("invalid timeout returns invalid option before browser startup", ["errors", "timeout"], () => { - const output = expectFailure([ - "snapshot", - dataHtml("Hello"), - "--timeout=abc", - ]); +test( + "invalid timeout returns invalid option before browser startup", + ["errors", "timeout"], + () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + "--timeout=abc", + ]); - assert.equal(output.ok, false); - assert.equal(output.error.code, "INVALID_OPTION"); - assert.match(output.error.message, /--timeout must be a positive integer/); - assert.equal(typeof output.elapsedMs, "number"); -}); + assert.equal(output.ok, false); + assert.equal(output.error.code, "INVALID_OPTION"); + assert.match(output.error.message, /--timeout must be a positive integer/); + assert.equal(typeof output.elapsedMs, "number"); + }, +); test("invalid wait-until returns invalid option", ["errors", "wait"], () => { const output = expectFailure([ @@ -215,31 +291,39 @@ test("wait-js timeout returns wait timeout", ["wait", "errors"], () => { assert.match(output.url, /^data:text\/html,/); }); -test("wait-js exception returns script failed", ["wait", "errors", "js"], () => { - const output = expectFailure([ - "snapshot", - dataHtml("Hello"), - '--wait-js=throw new Error("boom")', - ]); +test( + "wait-js exception returns script failed", + ["wait", "errors", "js"], + () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + '--wait-js=throw new Error("boom")', + ]); - assert.equal(output.ok, false); - assert.equal(output.error.code, "SCRIPT_FAILED"); - assert.match(output.error.message, /--wait-js failed/); - assert.match(output.error.message, /boom/); -}); + assert.equal(output.ok, false); + assert.equal(output.error.code, "SCRIPT_FAILED"); + assert.match(output.error.message, /--wait-js failed/); + assert.match(output.error.message, /boom/); + }, +); -test("top-level javascript exception returns script failed", ["errors", "js"], () => { - const output = expectFailure([ - "snapshot", - dataHtml("Hello"), - '--js=throw new Error("boom")', - ]); +test( + "top-level javascript exception returns script failed", + ["errors", "js"], + () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + '--js=throw new Error("boom")', + ]); - assert.equal(output.ok, false); - assert.equal(output.error.code, "SCRIPT_FAILED"); - assert.match(output.error.message, /Prelude script failed/); - assert.match(output.error.message, /boom/); -}); + assert.equal(output.ok, false); + assert.equal(output.error.code, "SCRIPT_FAILED"); + assert.match(output.error.message, /Prelude script failed/); + assert.match(output.error.message, /boom/); + }, +); function listTests() { const tags = [...new Set(tests.flatMap((entry) => entry.tags))].sort(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5ee02d9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "rootDir": ".", + "outDir": "dist", + "strict": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}