feat(config): add TypeScript build and config support

This commit is contained in:
2026-04-27 08:53:17 -04:00
parent 2f83fa3117
commit 6b3ec32b3a
12 changed files with 1043 additions and 127 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

View File

@@ -52,7 +52,7 @@ If running outside Nix, document that Firefox and geckodriver must be on `PATH`.
## Code Style ## 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. - Keep code direct and minimal; avoid abstractions until they are needed.
- Add short Title Case comments above cohesive logic blocks. - Add short Title Case comments above cohesive logic blocks.
- Prefer exact, actionable error messages. - Prefer exact, actionable error messages.

View File

@@ -20,7 +20,8 @@ If running directly with Node.js, install dependencies and make sure `firefox` a
```bash ```bash
npm install 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 ## Glimpse CLI
@@ -31,6 +32,7 @@ glimpse <command> [options]
Common options: Common options:
- `--config=<file>` - read config from a custom path instead of `~/.config/glimpse/config.json`
- `--no-headless` - show Firefox instead of running headless - `--no-headless` - show Firefox instead of running headless
- `--url=<server>` - connect to an existing WebDriver server - `--url=<server>` - connect to an existing WebDriver server
- `--timeout=<ms>` - maximum wait time in milliseconds for command waits (default: `10000`) - `--timeout=<ms>` - 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. Search using a supported provider and print a JSON array of results. Currently only Kagi is supported.
Kagi requires `--token=<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=<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 ```bash
KAGI_TOKEN=... nix run .#glimpse -- search --provider=kagi "nix flakes selenium webdriver" nix run .#glimpse -- search "nix flakes selenium webdriver"
``` ```
Local usage: Local usage:
```bash ```bash
KAGI_TOKEN=... ./result/bin/glimpse search "nix flakes selenium webdriver" ./result/bin/glimpse search "nix flakes selenium webdriver"
``` ```
Options: Options:
- `--provider=<provider>` - search provider: `kagi` (default: `kagi`) - `--provider=<provider>` - search provider: `kagi` (default: config or `kagi`)
- `--token=<token>` - Kagi token (default: `KAGI_TOKEN`) - `--token=<token>` - Kagi token (default: `KAGI_TOKEN` or config)
- `--no-headless` - show Firefox instead of running headless - `--no-headless` - show Firefox instead of running headless
- `--url=<server>` - connect to an existing WebDriver server - `--url=<server>` - connect to an existing WebDriver server
- `--timeout=<ms>` - wait time for results before returning `[]` (default: `10000`) - `--timeout=<ms>` - wait time for results before returning `[]` (default: `10000`)
@@ -217,7 +242,7 @@ Run the built tool:
```bash ```bash
./result/bin/glimpse exec https://example.com --js='return document.title' ./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 ## Development
@@ -253,16 +278,19 @@ node test/smoke.js snapshot js
Useful local commands: Useful local commands:
```bash ```bash
node src/index.js snapshot 'data:text/html,<title>Hello</title><h1>Hello</h1>' npm run build
node src/index.js exec 'data:text/html,<title>Hello</title>' --js='return document.title' node dist/src/index.js snapshot 'data:text/html,<title>Hello</title><h1>Hello</h1>'
node src/index.js screenshot 'data:text/html,<title>Hello</title>' --output=/tmp/page.png node dist/src/index.js exec 'data:text/html,<title>Hello</title>' --js='return document.title'
node src/index.js reader 'https://example.com/article' node dist/src/index.js screenshot 'data:text/html,<title>Hello</title>' --output=/tmp/page.png
node dist/src/index.js reader 'https://example.com/article'
``` ```
## Project Structure ## Project Structure
- `src/index.js` - `glimpse` CLI with subcommands, including Firefox Reader View extraction and provider-backed search - `src/index.ts` - `glimpse` CLI with subcommands, including Firefox Reader View extraction and provider-backed search
- `src/driver.js` - Firefox WebDriver creation and geckodriver resolution - `src/config.ts` - home-dir config loading for CLI defaults and provider settings
- `src/providers/kagi.js` - reusable Kagi search provider implementation - `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 - `flake.nix` - Nix dev shell, package, wrappers, and apps
- `KAGI.md` - Kagi-specific notes - `KAGI.md` - Kagi-specific notes

View File

@@ -42,8 +42,8 @@
version = "1.0.0"; version = "1.0.0";
src = source; src = source;
npmDepsHash = "sha256-IWzSvrGgkoR6gg7P1m/mwakGOOKmm2OFtBirKgE09Ds="; npmDepsHash = "sha256-ycAjPZZqI3ZMIUubJbWy8G6X6LaXDcgdZGswikfkQj8=";
dontNpmBuild = true; npmBuildScript = "build";
nativeBuildInputs = [ pkgs.makeWrapper ]; nativeBuildInputs = [ pkgs.makeWrapper ];

610
package-lock.json generated
View File

@@ -14,7 +14,14 @@
"turndown": "^7.2.4" "turndown": "^7.2.4"
}, },
"bin": { "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": { "node_modules/@bazel/runfiles": {
@@ -23,6 +30,448 @@
"integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==", "integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==",
"license": "Apache-2.0" "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": { "node_modules/@mixmark-io/domino": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", "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": "^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": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT" "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": { "node_modules/immediate": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -449,6 +1006,16 @@
"util-deprecate": "~1.0.1" "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": { "node_modules/safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@@ -504,6 +1071,26 @@
"node": ">=14.14" "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": { "node_modules/turndown": {
"version": "7.2.4", "version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
@@ -517,6 +1104,27 @@
"npm": ">=9" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -3,13 +3,14 @@
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"type": "module", "type": "module",
"main": "src/index.js", "main": "dist/src/index.js",
"bin": { "bin": {
"glimpse": "./src/index.js" "glimpse": "./dist/src/index.js"
}, },
"scripts": { "scripts": {
"lint": "oxlint --ignore-pattern node_modules --ignore-pattern .direnv .", "build": "tsc && chmod +x dist/src/index.js",
"start": "node 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": "node test/smoke.js",
"test:smoke": "node test/smoke.js", "test:smoke": "node test/smoke.js",
"test:list": "node test/smoke.js --list", "test:list": "node test/smoke.js --list",
@@ -23,10 +24,17 @@
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "MIT",
"dependencies": { "dependencies": {
"oxlint": "^1.61.0", "oxlint": "^1.61.0",
"selenium-webdriver": "^4.43.0", "selenium-webdriver": "^4.43.0",
"turndown": "^7.2.4" "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"
} }
} }

117
src/config.ts Normal file
View File

@@ -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<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function validateObject(
value: unknown,
path: string,
name: string,
): asserts value is Record<string, unknown> | 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;
}

View File

@@ -1,13 +1,13 @@
import { execFileSync } from "node:child_process"; 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"; import firefox from "selenium-webdriver/firefox.js";
/** export interface DriverOptions {
* Resolve the geckodriver path from $PATH. headless?: boolean;
* existingUrl?: string;
* @returns {string} }
*/
function findGeckodriver() { function findGeckodriver(): string {
try { try {
return execFileSync("which", ["geckodriver"], { encoding: "utf-8" }).trim(); return execFileSync("which", ["geckodriver"], { encoding: "utf-8" }).trim();
} catch { } catch {
@@ -17,16 +17,10 @@ function findGeckodriver() {
} }
} }
/** export async function createDriver({
* Create a Firefox WebDriver instance. headless = false,
* existingUrl,
* @param {object} opts }: DriverOptions = {}): Promise<WebDriver> {
* @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<import("selenium-webdriver").WebDriver>}
*/
export async function createDriver({ headless = false, existingUrl } = {}) {
const options = new firefox.Options(); const options = new firefox.Options();
// Configure Headless // Configure Headless
@@ -34,7 +28,9 @@ export async function createDriver({ headless = false, existingUrl } = {}) {
options.addArguments("--headless"); options.addArguments("--headless");
} }
const builder = new Builder().forBrowser("firefox").setFirefoxOptions(options); const builder = new Builder()
.forBrowser("firefox")
.setFirefoxOptions(options);
// Connect to Existing Server // Connect to Existing Server
if (existingUrl) { if (existingUrl) {

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { loadConfig, type GlimpseConfig } from "./config.js";
import { createDriver } from "./driver.js"; import { createDriver } from "./driver.js";
import { searchKagi } from "./providers/kagi.js"; import { searchKagi } from "./providers/kagi.js";
import { readFileSync, writeFileSync } from "node:fs"; import { readFileSync, writeFileSync } from "node:fs";
@@ -8,7 +9,7 @@ import TurndownService from "turndown";
const DEFAULT_TIMEOUT_MS = 10000; const DEFAULT_TIMEOUT_MS = 10000;
const POLL_INTERVAL_MS = 200; const POLL_INTERVAL_MS = 200;
const startTime = Date.now(); const startTime = Date.now();
const runContext = {}; const runContext: { targetUrl?: string; currentUrl?: string } = {};
// Parse CLI Args // Parse CLI Args
const [command, ...args] = process.argv.slice(2); const [command, ...args] = process.argv.slice(2);
@@ -18,6 +19,8 @@ const inlineJs = getOption("--js");
const scriptPath = getOption("--script"); const scriptPath = getOption("--script");
const waitJs = getOption("--wait-js"); const waitJs = getOption("--wait-js");
const waitUntil = getOption("--wait-until") ?? "none"; const waitUntil = getOption("--wait-until") ?? "none";
const configPath = getOption("--config");
let appConfig: GlimpseConfig = {};
let timeoutMs = DEFAULT_TIMEOUT_MS; let timeoutMs = DEFAULT_TIMEOUT_MS;
function getOption(name) { function getOption(name) {
@@ -50,6 +53,9 @@ function printResult(result) {
} }
class CliError extends Error { class CliError extends Error {
code: string;
details: Record<string, unknown>;
constructor(code, message, details = {}) { constructor(code, message, details = {}) {
super(message); super(message);
this.code = code; this.code = code;
@@ -84,6 +90,7 @@ Common Options:
--wait-until=<state> Wait for readiness: none, interactive, complete (default: none) --wait-until=<state> Wait for readiness: none, interactive, complete (default: none)
--js=<code> Execute inline JS before command logic --js=<code> Execute inline JS before command logic
--script=<file> Execute JS from a file before command logic --script=<file> Execute JS from a file before command logic
--config=<file> Read config from a custom path
Exec Options: Exec Options:
--js=<code> Return the top-level JS result --js=<code> Return the top-level JS result
@@ -97,8 +104,8 @@ Reader Options:
--output=<file> Write output to a file --output=<file> Write output to a file
Search Options: Search Options:
--provider=<provider> Search provider: kagi (default: kagi) --provider=<provider> Search provider: kagi (default: config or kagi)
--token=<token> Kagi token (default: KAGI_TOKEN) --token=<token> Kagi token (default: KAGI_TOKEN or config)
Examples: Examples:
glimpse snapshot https://example.com glimpse snapshot https://example.com
@@ -114,7 +121,10 @@ function printHelp() {
} }
function usage() { function usage() {
cliError("USAGE_ERROR", "Usage: glimpse <command> <url> [options]. Run glimpse --help for details."); cliError(
"USAGE_ERROR",
"Usage: glimpse <command> <url> [options]. Run glimpse --help for details.",
);
} }
function parseTimeout() { function parseTimeout() {
@@ -179,7 +189,9 @@ async function waitForReadyState(driver) {
try { try {
await driver.wait(async () => { await driver.wait(async () => {
const readyState = await driver.executeScript("return document.readyState"); const readyState = await driver.executeScript(
"return document.readyState",
);
return waitUntil === "interactive" return waitUntil === "interactive"
? ["interactive", "complete"].includes(readyState) ? ["interactive", "complete"].includes(readyState)
: readyState === "complete"; : readyState === "complete";
@@ -214,7 +226,10 @@ async function waitForJs(driver) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); 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) { async function runPreludeScript(driver) {
@@ -417,7 +432,8 @@ function renderReaderOutput(article, format) {
} }
async function searchCommand() { async function searchCommand() {
const provider = getOption("--provider") ?? "kagi"; const provider =
getOption("--provider") ?? appConfig.search?.provider ?? "kagi";
const query = getPositionalArgs().join(" "); const query = getPositionalArgs().join(" ");
if (!query) usage(); if (!query) usage();
@@ -428,6 +444,7 @@ async function searchCommand() {
return searchKagi({ return searchKagi({
query, query,
token: getOption("--token"), token: getOption("--token"),
config: appConfig,
headless, headless,
existingUrl, existingUrl,
timeoutMs, timeoutMs,
@@ -458,8 +475,9 @@ async function readerCommand() {
// Wait For Reader Content // Wait For Reader Content
let article; let article;
try { try {
article = await driver.wait(async () => { article = await driver.wait(
return driver.executeScript(` async () => {
return driver.executeScript(`
const content = document.querySelector("#moz-reader-content, .moz-reader-content"); const content = document.querySelector("#moz-reader-content, .moz-reader-content");
const error = document.querySelector(".reader-error"); const error = document.querySelector(".reader-error");
const text = content?.innerText?.trim() || ""; const text = content?.innerText?.trim() || "";
@@ -481,7 +499,10 @@ async function readerCommand() {
return null; return null;
`); `);
}, timeoutMs, `No readable article content found for URL: ${targetUrl}`); },
timeoutMs,
`No readable article content found for URL: ${targetUrl}`,
);
} catch (err) { } catch (err) {
cliError("TIMEOUT", err.message); cliError("TIMEOUT", err.message);
} }
@@ -517,6 +538,9 @@ async function main() {
validateCommonOptions(); validateCommonOptions();
// Load Config
appConfig = loadConfig({ path: configPath });
switch (command) { switch (command) {
case "snapshot": case "snapshot":
return snapshotCommand(); return snapshotCommand();
@@ -537,7 +561,12 @@ main()
.then(printResult) .then(printResult)
.catch((err) => { .catch((err) => {
const code = err.code || "COMMAND_FAILED"; const code = err.code || "COMMAND_FAILED";
const output = { const output: {
ok: false;
error: { code: string; message: string };
elapsedMs: number;
url?: string;
} = {
ok: false, ok: false,
error: { error: {
code, code,

View File

@@ -1,7 +1,26 @@
import { createDriver } from "../driver.js"; 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 { export class SearchProviderError extends Error {
constructor(code, message) { code: string;
constructor(code: string, message: string) {
super(message); super(message);
this.code = code; this.code = code;
} }
@@ -17,28 +36,36 @@ export const kagiSearchScript = `return Array.from(document.querySelectorAll("di
}));`; }));`;
// Build Kagi Search Url // 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)}`; 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 // Search Kagi
export async function searchKagi({ export async function searchKagi({
query, query,
token = process.env.KAGI_TOKEN, token,
config = {},
headless = true, headless = true,
existingUrl, existingUrl,
timeoutMs = 5000, timeoutMs = 5000,
intervalMs = 200, intervalMs = 200,
} = {}) { }: SearchKagiOptions = {}): Promise<SearchResult[]> {
if (!query) { if (!query) {
throw new SearchProviderError("QUERY_REQUIRED", "query is required"); throw new SearchProviderError("QUERY_REQUIRED", "query is required");
} }
// Validate Kagi Token // Resolve Kagi Token
if (!token) { const resolvedToken = token || process.env.KAGI_TOKEN || configToken(config);
if (!resolvedToken) {
throw new SearchProviderError( throw new SearchProviderError(
"KAGI_TOKEN_REQUIRED", "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 { try {
// Navigate To Kagi // Navigate To Kagi
await driver.get(buildKagiSearchUrl(query, token)); await driver.get(buildKagiSearchUrl(query, resolvedToken));
// Poll For Results // Poll For Results
let result = []; let result: SearchResult[] = [];
const start = Date.now(); const start = Date.now();
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
result = await driver.executeScript(kagiSearchScript); const scriptResult = await driver.executeScript(kagiSearchScript);
if (Array.isArray(result) && result.length > 0) break; result = Array.isArray(scriptResult)
? (scriptResult as SearchResult[])
: [];
if (result.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, intervalMs)); await new Promise((resolve) => setTimeout(resolve, intervalMs));
} }

View File

@@ -1,12 +1,18 @@
#!/usr/bin/env node #!/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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import assert from "node:assert/strict"; 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 tempDir = mkdtempSync(join(tmpdir(), "glimpse-smoke-"));
const filters = process.argv.slice(2).filter((arg) => arg !== "--list"); const filters = process.argv.slice(2).filter((arg) => arg !== "--list");
const shouldList = process.argv.includes("--list"); const shouldList = process.argv.includes("--list");
@@ -21,7 +27,7 @@ function dataHtml(html) {
} }
function runCli(args, options = {}) { function runCli(args, options = {}) {
return spawnSync(process.execPath, [cliPath, ...args], { return spawnSync(process.execPath, ["--import", "tsx", cliPath, ...args], {
encoding: "utf-8", encoding: "utf-8",
env: options.env ?? process.env, env: options.env ?? process.env,
timeout: 30000, timeout: 30000,
@@ -36,14 +42,14 @@ function parseJson(text) {
} }
} }
function expectSuccess(args) { function expectSuccess(args, options = {}) {
const result = runCli(args); const result = runCli(args, options);
assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(result.status, 0, result.stderr || result.stdout);
return parseJson(result.stdout); return parseJson(result.stdout);
} }
function expectFailure(args) { function expectFailure(args, options = {}) {
const result = runCli(args); const result = runCli(args, options);
assert.notEqual(result.status, 0, result.stdout || result.stderr); assert.notEqual(result.status, 0, result.stdout || result.stderr);
return parseJson(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"], () => { test("snapshot returns page metadata and content", ["snapshot"], () => {
const output = expectSuccess([ const output = expectSuccess([
"snapshot", "snapshot",
dataHtml('<title>Hello</title><h1>Main</h1><a href="/x">X</a><button>Go</button>'), dataHtml(
'<title>Hello</title><h1>Main</h1><a href="/x">X</a><button>Go</button>',
),
]); ]);
assert.equal(output.ok, true); assert.equal(output.ok, true);
@@ -98,24 +106,30 @@ test("snapshot returns page metadata and content", ["snapshot"], () => {
test("snapshot extracts aria headings", ["snapshot"], () => { test("snapshot extracts aria headings", ["snapshot"], () => {
const output = expectSuccess([ const output = expectSuccess([
"snapshot", "snapshot",
dataHtml('<title>Hello</title><div role="heading" aria-level="2">ARIA</div>'), dataHtml(
'<title>Hello</title><div role="heading" aria-level="2">ARIA</div>',
),
]); ]);
assert.equal(output.ok, true); assert.equal(output.ok, true);
assert.deepEqual(output.result.headings, [{ level: 2, text: "ARIA" }]); assert.deepEqual(output.result.headings, [{ level: 2, text: "ARIA" }]);
}); });
test("snapshot runs top-level javascript before extraction", ["snapshot", "js"], () => { test(
const output = expectSuccess([ "snapshot runs top-level javascript before extraction",
"snapshot", ["snapshot", "js"],
dataHtml("<title>Hello</title><h1>Old</h1>"), () => {
"--js=document.querySelector('h1').textContent = 'New'", const output = expectSuccess([
]); "snapshot",
dataHtml("<title>Hello</title><h1>Old</h1>"),
"--js=document.querySelector('h1').textContent = 'New'",
]);
assert.equal(output.ok, true); assert.equal(output.ok, true);
assert.deepEqual(output.result.headings, [{ level: 1, text: "New" }]); assert.deepEqual(output.result.headings, [{ level: 1, text: "New" }]);
assert.equal(output.result.text, "New"); assert.equal(output.result.text, "New");
}); },
);
test("exec returns javascript result", ["exec", "js"], () => { test("exec returns javascript result", ["exec", "js"], () => {
const result = runCli([ const result = runCli([
@@ -128,19 +142,23 @@ test("exec returns javascript result", ["exec", "js"], () => {
assert.equal(result.stdout.trim(), "Hello"); assert.equal(result.stdout.trim(), "Hello");
}); });
test("screenshot returns standard success envelope and writes file", ["screenshot"], () => { test(
const outputPath = join(tempDir, "page.png"); "screenshot returns standard success envelope and writes file",
const output = expectSuccess([ ["screenshot"],
"screenshot", () => {
dataHtml("<title>Hello</title>"), const outputPath = join(tempDir, "page.png");
`--output=${outputPath}`, const output = expectSuccess([
]); "screenshot",
dataHtml("<title>Hello</title>"),
`--output=${outputPath}`,
]);
assert.equal(output.ok, true); assert.equal(output.ok, true);
assert.equal(output.result.path, outputPath); assert.equal(output.result.path, outputPath);
assert.equal(typeof output.elapsedMs, "number"); assert.equal(typeof output.elapsedMs, "number");
assert.equal(existsSync(outputPath), true); assert.equal(existsSync(outputPath), true);
}); },
);
test("search validates kagi token in provider", ["search", "errors"], () => { test("search validates kagi token in provider", ["search", "errors"], () => {
const env = { ...process.env }; 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.ok, false);
assert.equal(output.error.code, "KAGI_TOKEN_REQUIRED"); assert.equal(output.error.code, "KAGI_TOKEN_REQUIRED");
assert.match(output.error.message, /Kagi search requires/); assert.match(output.error.message, /Kagi search requires/);
assert.match(output.error.message, /config token/);
assert.equal(typeof output.elapsedMs, "number"); 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("<title>Hello</title>"),
`--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("<title>Hello</title>"),
`--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("<title>Hello</title><h1>Main</h1>")],
{ 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"], () => { test("unknown command returns structured error", ["errors", "cli"], () => {
const output = expectFailure(["nope", dataHtml("<title>Hello</title>")]); const output = expectFailure(["nope", dataHtml("<title>Hello</title>")]);
@@ -164,18 +236,22 @@ test("unknown command returns structured error", ["errors", "cli"], () => {
assert.equal(typeof output.elapsedMs, "number"); assert.equal(typeof output.elapsedMs, "number");
}); });
test("invalid timeout returns invalid option before browser startup", ["errors", "timeout"], () => { test(
const output = expectFailure([ "invalid timeout returns invalid option before browser startup",
"snapshot", ["errors", "timeout"],
dataHtml("<title>Hello</title>"), () => {
"--timeout=abc", const output = expectFailure([
]); "snapshot",
dataHtml("<title>Hello</title>"),
"--timeout=abc",
]);
assert.equal(output.ok, false); assert.equal(output.ok, false);
assert.equal(output.error.code, "INVALID_OPTION"); assert.equal(output.error.code, "INVALID_OPTION");
assert.match(output.error.message, /--timeout must be a positive integer/); assert.match(output.error.message, /--timeout must be a positive integer/);
assert.equal(typeof output.elapsedMs, "number"); assert.equal(typeof output.elapsedMs, "number");
}); },
);
test("invalid wait-until returns invalid option", ["errors", "wait"], () => { test("invalid wait-until returns invalid option", ["errors", "wait"], () => {
const output = expectFailure([ const output = expectFailure([
@@ -215,31 +291,39 @@ test("wait-js timeout returns wait timeout", ["wait", "errors"], () => {
assert.match(output.url, /^data:text\/html,/); assert.match(output.url, /^data:text\/html,/);
}); });
test("wait-js exception returns script failed", ["wait", "errors", "js"], () => { test(
const output = expectFailure([ "wait-js exception returns script failed",
"snapshot", ["wait", "errors", "js"],
dataHtml("<title>Hello</title>"), () => {
'--wait-js=throw new Error("boom")', const output = expectFailure([
]); "snapshot",
dataHtml("<title>Hello</title>"),
'--wait-js=throw new Error("boom")',
]);
assert.equal(output.ok, false); assert.equal(output.ok, false);
assert.equal(output.error.code, "SCRIPT_FAILED"); assert.equal(output.error.code, "SCRIPT_FAILED");
assert.match(output.error.message, /--wait-js failed/); assert.match(output.error.message, /--wait-js failed/);
assert.match(output.error.message, /boom/); assert.match(output.error.message, /boom/);
}); },
);
test("top-level javascript exception returns script failed", ["errors", "js"], () => { test(
const output = expectFailure([ "top-level javascript exception returns script failed",
"snapshot", ["errors", "js"],
dataHtml("<title>Hello</title>"), () => {
'--js=throw new Error("boom")', const output = expectFailure([
]); "snapshot",
dataHtml("<title>Hello</title>"),
'--js=throw new Error("boom")',
]);
assert.equal(output.ok, false); assert.equal(output.ok, false);
assert.equal(output.error.code, "SCRIPT_FAILED"); assert.equal(output.error.code, "SCRIPT_FAILED");
assert.match(output.error.message, /Prelude script failed/); assert.match(output.error.message, /Prelude script failed/);
assert.match(output.error.message, /boom/); assert.match(output.error.message, /boom/);
}); },
);
function listTests() { function listTests() {
const tags = [...new Set(tests.flatMap((entry) => entry.tags))].sort(); const tags = [...new Set(tests.flatMap((entry) => entry.tags))].sort();

15
tsconfig.json Normal file
View File

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