Compare commits

..
24 Commits
Author SHA1 Message Date
haylan f16536fffd Merge pull request 'ci: tag the release commit and link the package to the repository' (#18) from ci/tag-and-link-release into main
Reviewed-on: haylan/fastcrw-opencode-websarch#18
2026-10-03 18:46:00 +00:00
haylanandClaude Bot 626ad4156d ci: tag the release commit and link the package to the repository
check / check (pull_request) Successful in 26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 20:44:07 +02:00
haylan 6e7080d687 Merge pull request 'docs: add README and prepare changelog for 0.0.1' (#17) from docs/readme-changelog into main
Reviewed-on: haylan/fastcrw-opencode-websarch#17
2026-10-03 18:40:42 +00:00
haylan 56ad2392a5 Merge pull request 'docs: record runtime check results' (#16) from docs/runtime-checks into main
Reviewed-on: haylan/fastcrw-opencode-websarch#16
2026-10-03 18:40:34 +00:00
haylanandClaude Bot d32d5fb701 docs: add README, prepare changelog for 0.0.1, fix plugin skill loading rules
check / check (pull_request) Successful in 27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 20:38:09 +02:00
haylanandClaude Bot ef1a234f63 docs: record plugin loading rules and runtime check results
check / check (pull_request) Successful in 27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 20:33:58 +02:00
haylanandClaude Bot 5562c697ae docs: record smoke test result against the real server
check / check (pull_request) Successful in 28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 20:25:27 +02:00
haylanandClaude Bot 7d7c684724 docs: record runtime check results
check / check (pull_request) Successful in 27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 19:40:19 +02:00
haylan ccab3c097f Merge pull request 'ci: add release workflow, release script and smoke script' (#15) from feat/release into main
Reviewed-on: haylan/fastcrw-opencode-websarch#15
2026-10-03 17:30:13 +00:00
haylan e8a88180eb Merge branch 'main' into feat/release
check / check (pull_request) Successful in 33s
2026-10-03 17:30:08 +00:00
haylan 30a47b33bd Merge pull request 'ci: run check, tests and build on pull requests' (#14) from ci/check-workflow into main
Reviewed-on: haylan/fastcrw-opencode-websarch#14
2026-10-03 17:30:01 +00:00
haylanandClaude Bot 0712a932cc ci: add release workflow, release script and smoke script
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 19:29:40 +02:00
haylanandClaude Bot 4d552f6e28 ci: run check, tests and build on pull requests
check / check (pull_request) Successful in 35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 19:28:45 +02:00
haylan 385b454387 Merge pull request 'feat: wire the fastCRW provider into the plugin entry' (#13) from feat/index into main
Reviewed-on: haylan/fastcrw-opencode-websarch#13
2026-10-03 17:28:06 +00:00
haylanandClaude Bot bcb6dfbe8c feat: wire the fastCRW provider into the plugin entry
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 19:27:04 +02:00
haylan bdac7ddf17 Merge pull request 'feat: add fastCRW search request, mapping and error handling' (#12) from feat/search into main
Reviewed-on: haylan/fastcrw-opencode-websarch#12
2026-10-03 17:26:10 +00:00
haylanandClaude Bot cc3814deeb feat: add fastCRW search request, mapping and error handling
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 19:10:41 +02:00
haylan b80b7eb87d Merge pull request 'feat: resolve and validate plugin config' (#11) from feat/config into main
Reviewed-on: haylan/fastcrw-opencode-websarch#11
2026-10-03 17:09:22 +00:00
haylanandClaude Bot c92a83a256 feat: resolve and validate plugin config
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 19:07:38 +02:00
haylan fc710f1365 Merge pull request 'chore: scaffold package, TypeScript, Prettier and changelog' (#10) from feat/scaffold into main
Reviewed-on: haylan/fastcrw-opencode-websarch#10
2026-10-03 17:04:51 +00:00
haylanandClaude Bot 5e3671fa8b chore: add TypeScript, Prettier and changelog scaffold
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 19:04:21 +02:00
haylanandClaude Bot 6da76a8a18 chore: scaffold package.json and dependencies
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:59:50 +02:00
haylan f58f77703f Merge pull request 'docs: add build-ready spec and Release glossary term' (#9) from docs/spec into main
Reviewed-on: haylan/fastcrw-opencode-websarch#9
2026-10-03 16:53:23 +00:00
haylanandClaude Bot 75b1a5b307 docs: add build-ready spec and Release glossary term
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:52:00 +02:00
26 changed files with 5481 additions and 62 deletions

No files matched your search

+10 -10
View File
@@ -35,16 +35,16 @@ Result: `url`, `title`, `description`, `snippet` (always-populated alias of `des
Body `{ "success": false, "error": string, "error_code": string }`. Body `{ "success": false, "error": string, "error_code": string }`.
| HTTP | error_code | | HTTP | error_code |
|---|---| | ---- | ------------------------------------- |
| 400 | `invalid_request` | | 400 | `invalid_request` |
| 401 | auth middleware message | | 401 | auth middleware message |
| 422 | `target_unreachable` | | 422 | `target_unreachable` |
| 429 | `rate_limited` | | 429 | `rate_limited` |
| 500 | `internal_error` | | 500 | `internal_error` |
| 502 | `http_error` | | 502 | `http_error` |
| 503 | `search_disabled` / `search_degraded` | | 503 | `search_disabled` / `search_degraded` |
| 504 | `timeout` | | 504 | `timeout` |
Self-hosted search needs a SearXNG-style backend (`CRW_SEARCH__SEARCH_BACKEND_URL`); without one expect 503 `search_disabled`. Self-hosted search needs a SearXNG-style backend (`CRW_SEARCH__SEARCH_BACKEND_URL`); without one expect 503 `search_disabled`.
+16 -10
View File
@@ -43,23 +43,29 @@ await ctx.websearch.transform((editor) => {
```jsonc ```jsonc
{ {
"plugins": [{ "package": "~/.config/opencode/plugins/fastcrw", "options": { "baseURL": "http://crw.home:3000", "limit": 8 } }], "plugins": [
"websearch": { "provider": "fastcrw" } {
"package": "@haylan/opencode-fastcrw",
"options": { "baseURL": "http://crw.home:3000", "limit": 8 },
},
],
} }
``` ```
- Key is plural `plugins` (v1: `plugin`). Entries are a string or `{ package, options }`. - Key is plural `plugins` (v1: `plugin`). Entries are a string or `{ package, options }`.
- `package` may be an npm name (`name`, `name@1.2.0`, `@scope/name`), `./local`, an absolute path or `file://`. - `package` may be an npm name (`name`, `name@1.2.0`, `@scope/name`), `./relative` (resolved from the config file), an absolute path or `file://`. `~` is not expanded.
- Auto-discovered: `.ts`/`.js` files and packages in `.opencode/plugins/` and `~/.config/opencode/plugins/`. - **Local paths must be directories**, resolved by file path (`<dir>/server`, then `<dir>/index`); `package.json` `exports` are ignored. A file entry is ignored with a warning, and a directory without a root `index`/`server` file loads silently nothing. npm names resolve through `exports`.
- `websearch.provider` is a provider id or `"random"`; `"websearch": false` disables search. Built-in ids: exa, firecrawl, parallel, tavily, tinyfish. - Auto-discovered: `.ts`/`.js` files (not `.mjs`) and directories in `.opencode/plugins/` and `~/.config/opencode/plugins/`. Drop-in files get no options.
- CLI: `opencode plugin add|list|check|update|remove`. - `websearch.provider` is a provider id or `"random"`; `"websearch": false` disables search. Built-in ids: exa, firecrawl, parallel, tavily, tinyfish. A provider named here **wins** over a plugin's `editor.default.set`; with it unset, the plugin's default applies.
- CLI: `opencode plugin add|list|check|update|remove` (`add` writes the global config).
- Loading is lazy: after startup or reload the plugin list fills in after several seconds. Load errors show as `state.status: "failed"` in `GET /api/plugin` (private server: `opencode serve`, Basic auth user `opencode`).
## Packaging (npm) ## Packaging (npm)
`"type": "module"`, `exports["."]` pointing at the entry, dependency on `@opencode/plugin`. `"type": "module"`, `exports["."]` pointing at the entry, dependency on `@opencode/plugin`.
## Unverified: check at runtime, do not assume ## Still unverified
- Exact TypeScript types for the editor, `time` and `execute` args (read `@opencode/plugin` typings after install). - Installing by name from the Gitea registry with a scoped `.npmrc`.
- Inner shape of `time`, and whether extra result fields are allowed.
- Precedence between config `websearch.provider` and `editor.default.set(id)`. Verified results: `docs/runtime-checks.md`.
+18
View File
@@ -0,0 +1,18 @@
name: check
on:
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- run: npm run check
- run: npm test
- run: npm run build
+22
View File
@@ -0,0 +1,22 @@
name: release
on: workflow_dispatch
jobs:
release:
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- run: npm test
- run: npm run build
- run: node scripts/release.ts
env:
SERVER_URL: ${{ gitea.server_url }}
REPOSITORY: ${{ gitea.repository }}
SHA: ${{ gitea.sha }}
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.GITHUB_TOKEN }}
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.release/
package.tgz
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
package-lock.json
+4
View File
@@ -0,0 +1,4 @@
{
"semi": false,
"printWidth": 100
}
+17
View File
@@ -0,0 +1,17 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
## [0.0.1] - 2026-10-03
### Added
- opencode v2 plugin `fastcrw.websearch` that registers a `fastcrw` search provider backed by a self-hosted fastCRW server (`POST /v1/search`) and makes it the default websearch provider.
- Plugin options `baseURL`, `apiKey`, `limit`, `lang` and `tbs`, with `CRW_API_URL` and `CRW_API_KEY` as environment fallbacks.
- Validation at plugin load: a bad option makes the plugin fail with a message naming the option.
- Mapping of both fastCRW response shapes (`data[]` and `data.results`) and descriptive errors for API failures, non-JSON responses, unreachable servers and timeouts.
- Gitea Actions workflows for pull request checks and for publishing releases to the Gitea npm registry.
+4
View File
@@ -20,6 +20,10 @@ _Avoid_: host, server URL
The optional Bearer key sent to the fastCRW server. Set by the `apiKey` option or `CRW_API_KEY`. Absent means no auth header. The optional Bearer key sent to the fastCRW server. Set by the `apiKey` option or `CRW_API_KEY`. Absent means no auth header.
_Avoid_: secret, password _Avoid_: secret, password
**Release**:
A version recorded as a dated heading in `CHANGELOG.md` and published to the Gitea npm registry. The latest changelog heading is the current release.
_Avoid_: build, tag (a release is defined by the changelog, not a git tag)
**Plugin options**: **Plugin options**:
The values opencode passes to the plugin from `opencode.json`: `baseURL`, `apiKey`, `limit`, `lang`, `tbs`. The values opencode passes to the plugin from `opencode.json`: `baseURL`, `apiKey`, `limit`, `lang`, `tbs`.
_Avoid_: settings, config (config means `opencode.json` as a whole) _Avoid_: settings, config (config means `opencode.json` as a whole)
+93
View File
@@ -0,0 +1,93 @@
# @haylan/opencode-fastcrw
An [opencode](https://opencode.ai) v2 plugin that makes a self-hosted [fastCRW](https://docs.fastcrw.com) server opencode's websearch. It calls `POST {baseURL}/v1/search` directly; it does not use the `crw-mcp` MCP server.
The plugin registers a search provider with id `fastcrw` and makes it the default. If fastCRW fails, searches fail; they never fall back to the built-in providers (Exa, Firecrawl, Parallel, Tavily, TinyFish).
## Install
The package is published to the Gitea npm registry. Map the scope to it in your `.npmrc`:
```
@haylan:registry=https://git.arthurerlich.de/api/packages/haylan/npm/
```
Then add the plugin to your `opencode.json`:
```jsonc
{
"plugins": [
{
"package": "@haylan/opencode-fastcrw",
"options": { "baseURL": "http://crw.home:3000" },
},
],
}
```
Installing by name from the Gitea registry has not been verified end to end yet; see [docs/runtime-checks.md](docs/runtime-checks.md).
### Local build
To try the plugin without a registry:
```bash
npm ci
npm run build
```
opencode loads local plugins as directories, resolved by file path (`<dir>/server`, then `<dir>/index`), and ignores `package.json` `exports`, so this package cannot be pointed at directly. Use a small shim instead. Create `~/.config/opencode/plugins/fastcrw.js` (a `.js` or `.ts` file; `.mjs` is ignored) with:
```js
export { default } from "file:///ABSOLUTE/PATH/TO/fastcrw-opencode-websarch/dist/index.js"
```
A drop-in file cannot receive plugin options, so configure the plugin with the environment variables below (`CRW_API_URL`, `CRW_API_KEY`). To use options instead, put the same line in an `index.js` inside a directory and reference that directory as `"package": "./that-dir"` (a relative path is resolved from the config file; `~` is not expanded).
## Configuration
Plugin options come from `opencode.json`. Precedence is option, then environment variable, then default.
| Option | Environment variable | Default | Notes |
| --------- | -------------------- | ----------------------- | --------------------------------------------------------------------------------------- |
| `baseURL` | `CRW_API_URL` | `http://localhost:3000` | Base URL of the fastCRW server, `http` or `https`. Trailing slashes are removed. |
| `apiKey` | `CRW_API_KEY` | none | Optional. Sent as a Bearer token. Prefer the environment variable over `opencode.json`. |
| `limit` | none | `8` | Results per search, clamped to 1 to 20. An invalid value falls back to 8. |
| `lang` | none | unset | Search language, for example `en` or `de`. Omitted from the request when unset. |
| `tbs` | none | unset | Time range: `qdr:h`, `qdr:d`, `qdr:w`, `qdr:m` or `qdr:y`. |
A bad `baseURL`, `tbs`, `lang` or `apiKey` makes the plugin fail to load, with a message naming the option. opencode keeps running and shows the plugin as failed.
### Choosing the provider
Do not set `websearch.provider` to another provider. In opencode, a provider named in `opencode.json` wins over the one a plugin sets as default, so `"websearch": { "provider": "exa" }` would send your searches to Exa. Leaving it unset, or setting it to `"fastcrw"`, uses this plugin.
## Errors
Failures are thrown as `fastCRW <status> <error_code>: <message>`, with a hint for a missing or invalid key (401) and for a server without a search backend (503 `search_disabled`). A server that is unreachable, times out (30 s) or returns something that is not JSON gets its own message. opencode currently shows only a generic "Web search request failed: fastcrw", so check the server when searches fail.
A self-hosted fastCRW server needs a SearXNG-style search backend (`CRW_SEARCH__SEARCH_BACKEND_URL`); without one, searches return 503.
## Development
```bash
npm ci
npm run check # Prettier and type check
npm test # node --test, needs Node 22.18 or later
npm run build # compile to dist/
npm run smoke -- "query" # one real search against CRW_API_URL (manual)
```
`docs/spec.md` is the build spec, `docs/runtime-checks.md` records what was verified against a real opencode, and `CONTEXT.md` defines the vocabulary.
### Releasing
Releases are published by the manually started `release` workflow on Gitea Actions. It reads the latest version heading in `CHANGELOG.md` (the repository keeps the dev version `0.0.0-dev` in `package.json`) and then:
1. publishes that version to the Gitea npm registry, unless it already exists;
2. tags the workflow's commit `v<version>`, unless the tag already exists;
3. links the package to this repository, unless it is already linked.
Every step skips itself when done, so starting the workflow again completes a release that only partly succeeded.
To release: add a dated `## [x.y.z]` entry to `CHANGELOG.md`, merge it to `main`, then start the workflow. It needs a token with `write:package` and `write:repository` rights: the job token if it has them, otherwise a `PACKAGE_TOKEN` secret.
+41 -31
View File
@@ -18,14 +18,14 @@ Resolves issue #3. Researched 2026-10-03 against `main` of github.com/us/crw (`f
## Env var names ## Env var names
| Var | Used by | Meaning | | Var | Used by | Meaning |
|---|---|---| | -------------------- | ---------------- | --------------------------------------------------------------------------------------------------- |
| `CRW_API_URL` | crw-mcp, crw CLI | Remote server base URL. Default `https://api.fastcrw.com`; `http://localhost:3000` for self-hosted. | | `CRW_API_URL` | crw-mcp, crw CLI | Remote server base URL. Default `https://api.fastcrw.com`; `http://localhost:3000` for self-hosted. |
| `CRW_API_KEY` | crw-mcp, crw CLI | Bearer token for the remote server. | | `CRW_API_KEY` | crw-mcp, crw CLI | Bearer token for the remote server. |
| `CRW_LOCAL` | crw-mcp | Truthy forces embedded mode, ignoring `CRW_API_URL`. | | `CRW_LOCAL` | crw-mcp | Truthy forces embedded mode, ignoring `CRW_API_URL`. |
| `CRW_AUTH__API_KEYS` | server | Accepted keys (server side). | | `CRW_AUTH__API_KEYS` | server | Accepted keys (server side). |
| `CRW_SEARCH__*` | server | Search config (`enabled`, `search_backend_url`, `timeout_ms`, `default_limit`, `max_limit`). | | `CRW_SEARCH__*` | server | Search config (`enabled`, `search_backend_url`, `timeout_ms`, `default_limit`, `max_limit`). |
| `CRW_SEARXNG_URL` | CLI | Legacy local search backend URL. | | `CRW_SEARXNG_URL` | CLI | Legacy local search backend URL. |
Sources: docs.fastcrw.com (REST API section), crates/crw-core/src/config.rs, crw-mcp README. Sources: docs.fastcrw.com (REST API section), crates/crw-core/src/config.rs, crw-mcp README.
Plugin should use `CRW_API_URL` / `CRW_API_KEY` to match upstream naming. Plugin should use `CRW_API_URL` / `CRW_API_KEY` to match upstream naming.
@@ -34,16 +34,16 @@ Plugin should use `CRW_API_URL` / `CRW_API_KEY` to match upstream naming.
Source: `SearchRequest` in crates/crw-core/src/types.rs (camelCase serde); blog/crw-search-api-release.md. Source: `SearchRequest` in crates/crw-core/src/types.rs (camelCase serde); blog/crw-search-api-release.md.
| Field | Type | Notes | | Field | Type | Notes |
|---|---|---| | ------------------------------------------------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------- |
| `query` | string, required | Max 2000 chars (handler validation). | | `query` | string, required | Max 2000 chars (handler validation). |
| `limit` | int, optional | Default 5 (`default_limit`); clamped server-side to `1..=max_limit` (max 20). | | `limit` | int, optional | Default 5 (`default_limit`); clamped server-side to `1..=max_limit` (max 20). |
| `lang` | string | SearXNG language, e.g. `en`, `de`, `auto`; validated as a language tag. | | `lang` | string | SearXNG language, e.g. `en`, `de`, `auto`; validated as a language tag. |
| `tbs` | enum | `qdr:h`, `qdr:d`, `qdr:w`, `qdr:m`, `qdr:y`. | | `tbs` | enum | `qdr:h`, `qdr:d`, `qdr:w`, `qdr:m`, `qdr:y`. |
| `sources` | array | `web`, `news`, `images`. Presence switches response to grouped form. | | `sources` | array | `web`, `news`, `images`. Presence switches response to grouped form. |
| `categories` | array, max 5 | `github`, `research`, `pdf`, or passthrough string. | | `categories` | array, max 5 | `github`, `research`, `pdf`, or passthrough string. |
| `scrapeOptions` | object | Scrapes each web result (timeout bounds 1..60000 ms). Adds `markdown` etc. to results. | | `scrapeOptions` | object | Scrapes each web result (timeout bounds 1..60000 ms). Adds `markdown` etc. to results. |
| `answer`, `answerTopN`, `summarizeResults`, `maxCharsPerSource`, `llmApiKey`, `llmProvider`, `llmModel` | | LLM synthesis; need `scrapeOptions`. Not needed by this plugin. | | `answer`, `answerTopN`, `summarizeResults`, `maxCharsPerSource`, `llmApiKey`, `llmProvider`, `llmModel` | | LLM synthesis; need `scrapeOptions`. Not needed by this plugin. |
Minimal request is `{"query","limit"}`; `lang`/`tbs` are the only extras worth exposing. Minimal request is `{"query","limit"}`; `lang`/`tbs` are the only extras worth exposing.
@@ -56,9 +56,19 @@ Envelope `ApiResponse` (camelCase, absent fields omitted): `success`, `data`, `e
Hosted example (flat, per docs): Hosted example (flat, per docs):
```json ```json
{"success": true, "data": [ {
{"url": "https://...", "title": "...", "description": "...", "snippet": "...", "position": 1, "score": 9.5} "success": true,
]} "data": [
{
"url": "https://...",
"title": "...",
"description": "...",
"snippet": "...",
"position": 1,
"score": 9.5
}
]
}
``` ```
Self-hosted (CLI parser handles `{"success": true, "data": {"results": [ ... ]}}`). Self-hosted (CLI parser handles `{"success": true, "data": {"results": [ ... ]}}`).
@@ -71,16 +81,16 @@ Conflict note: the docs example and CLI show flat `data[]` for hosted; the struc
JSON body `{"success": false, "error": "<message>", "error_code": "<code>"}` (crates/crw-server/src/error.rs). JSON body `{"success": false, "error": "<message>", "error_code": "<code>"}` (crates/crw-server/src/error.rs).
| HTTP | error_code | Cause | | HTTP | error_code | Cause |
|---|---|---| | ---- | ------------------------------------- | -------------------------------- |
| 400 | `invalid_request` | bad query/limit/lang | | 400 | `invalid_request` | bad query/limit/lang |
| 401 | (auth middleware message) | missing/invalid key | | 401 | (auth middleware message) | missing/invalid key |
| 429 | `rate_limited` | rate limit | | 429 | `rate_limited` | rate limit |
| 502 | `http_error` | search backend HTTP/JSON failure | | 502 | `http_error` | search backend HTTP/JSON failure |
| 504 | `timeout` | search timeout (default 15 s) | | 504 | `timeout` | search timeout (default 15 s) |
| 503 | `search_disabled` / `search_degraded` | search off or backend unhealthy | | 503 | `search_disabled` / `search_degraded` | search off or backend unhealthy |
| 422 | `target_unreachable` | backend unreachable | | 422 | `target_unreachable` | backend unreachable |
| 500 | `internal_error` | other | | 500 | `internal_error` | other |
Backend hostnames/credentials are never leaked in errors. Backend hostnames/credentials are never leaked in errors.
+29 -11
View File
@@ -13,15 +13,16 @@ Caveat: pages were read through a fetch-and-summarise tool, so quotes are as ret
## Verdict on the user's sketch ## Verdict on the user's sketch
| Sketch element | Verdict | Evidence | | Sketch element | Verdict | Evidence |
|---|---|---| | ------------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Plugin.define` from `"@opencode/plugin"` | RIGHT | [S1] `import { Plugin } from "@opencode/plugin"` / `export default Plugin.define({ id: "example", async setup(ctx) {...} })`. V1 package was `@opencode-ai/plugin` [S5]. | | `Plugin.define` from `"@opencode/plugin"` | RIGHT | [S1] `import { Plugin } from "@opencode/plugin"` / `export default Plugin.define({ id: "example", async setup(ctx) {...} })`. V1 package was `@opencode-ai/plugin` [S5]. |
| `ctx.websearch.transform` | RIGHT | [S1] `await ctx.websearch.transform((editor) => { ... })` | | `ctx.websearch.transform` | RIGHT | [S1] `await ctx.websearch.transform((editor) => { ... })` |
| `editor.add` | RIGHT | [S1] `editor.add({ id: "internal", name: "Internal search", execute: async ({ query }, { signal }) => {...} })` | | `editor.add` | RIGHT | [S1] `editor.add({ id: "internal", name: "Internal search", execute: async ({ query }, { signal }) => {...} })` |
| `plugins[].package` / `options` in opencode.json | RIGHT | [S2][S3] key is `"plugins"` (V1 was `"plugin"`, tuples replaced by objects [S5]); object form `{ "package": "...", "options": {...} }` | | `plugins[].package` / `options` in opencode.json | RIGHT | [S2][S3] key is `"plugins"` (V1 was `"plugin"`, tuples replaced by objects [S5]); object form `{ "package": "...", "options": {...} }` |
| `websearch.provider` | RIGHT (config key) | [S3][S4] `{ "websearch": { "provider": "tavily" } }`; also `"random"`. Built-in ids: exa, firecrawl, parallel, tavily, tinyfish [S4]. | | `websearch.provider` | RIGHT (config key) | [S3][S4] `{ "websearch": { "provider": "tavily" } }`; also `"random"`. Built-in ids: exa, firecrawl, parallel, tavily, tinyfish [S4]. |
Things the sketch likely missed / must add: Things the sketch likely missed / must add:
- `id` is required on `Plugin.define` (stable, used for storage scoping/diagnostics) [S5]. - `id` is required on `Plugin.define` (stable, used for storage scoping/diagnostics) [S5].
- A plugin-added provider is made active with `editor.default.set("<id>")` [S1]; `websearch.provider` in config selects by provider id, so `"provider": "<your-id>"` is the expected pairing (inference from [S1]+[S4]; docs do not state it explicitly -> verify at runtime). - A plugin-added provider is made active with `editor.default.set("<id>")` [S1]; `websearch.provider` in config selects by provider id, so `"provider": "<your-id>"` is the expected pairing (inference from [S1]+[S4]; docs do not state it explicitly -> verify at runtime).
- Options are read as `ctx.options` (not passed as a setup arg) [S1]. - Options are read as `ctx.options` (not passed as a setup arg) [S1].
@@ -29,47 +30,64 @@ Things the sketch likely missed / must add:
## Sub-questions ## Sub-questions
### 1. Plugin entry / definition shape ### 1. Plugin entry / definition shape
Each module has one default export with a unique id and `setup`: Each module has one default export with a unique id and `setup`:
```ts ```ts
import { Plugin } from "@opencode/plugin" import { Plugin } from "@opencode/plugin"
export default Plugin.define({ export default Plugin.define({
id: "example", id: "example",
async setup(ctx) { /* may return a cleanup function */ }, async setup(ctx) {
/* may return a cleanup function */
},
}) })
``` ```
[S1][S5]. Context: `ctx.app.version`, `ctx.location` (`directory`, optional `workspaceID`, project), `ctx.options`, `ctx.storage` (durable JSON, plugin-scoped), plus domains `ctx.session/permission/shell/tool/websearch/...` [S1][S5]. Return cleanup from `setup` for timers/sockets [S5]. V1 plugins do not run in V2; dual-support is possible via spreading `Plugin.define(...)` and adding a V1 `server()` [S1][S5]. [S1][S5]. Context: `ctx.app.version`, `ctx.location` (`directory`, optional `workspaceID`, project), `ctx.options`, `ctx.storage` (durable JSON, plugin-scoped), plus domains `ctx.session/permission/shell/tool/websearch/...` [S1][S5]. Return cleanup from `setup` for timers/sockets [S5]. V1 plugins do not run in V2; dual-support is possible via spreading `Plugin.define(...)` and adding a V1 `server()` [S1][S5].
### 2. Registration/loading (local path vs npm) ### 2. Registration/loading (local path vs npm)
`"plugins"` array in `opencode.json(c)`; entries are strings or objects [S2]: `"plugins"` array in `opencode.json(c)`; entries are strings or objects [S2]:
npm `"opencode-acme-plugin"`, `"opencode-acme-plugin@1.2.0"`, `"@acme/opencode-plugin"`; local `"./plugins/local"`, `"../shared/plugin.ts"`, `"/abs/plugin.ts"`, `"file:///..."`. Also auto-discovery of `.ts/.js` files and packages in `.opencode/plugins/` or `~/.config/opencode/plugins/` [S2]. CLI: `opencode plugin add|list|check|update|remove` [S2]. Disable via ids: `["*", "-opencode.provider.*", ...]` [S2]. Published package needs `"type":"module"`, `exports["."]` -> entry, dependency `@opencode/plugin` [S1]. npm `"opencode-acme-plugin"`, `"opencode-acme-plugin@1.2.0"`, `"@acme/opencode-plugin"`; local `"./plugins/local"`, `"../shared/plugin.ts"`, `"/abs/plugin.ts"`, `"file:///..."`. Also auto-discovery of `.ts/.js` files and packages in `.opencode/plugins/` or `~/.config/opencode/plugins/` [S2]. CLI: `opencode plugin add|list|check|update|remove` [S2]. Disable via ids: `["*", "-opencode.provider.*", ...]` [S2]. Published package needs `"type":"module"`, `exports["."]` -> entry, dependency `@opencode/plugin` [S1].
### 3. Passing options ### 3. Passing options
```jsonc ```jsonc
{ "plugins": [ { "package": "./plugins/company", "options": { "strict": true } } ] } { "plugins": [{ "package": "./plugins/company", "options": { "strict": true } }] }
``` ```
read via `ctx.options.strict` in `setup` [S1][S3]. Options type is untyped key/value (validate yourself). read via `ctx.options.strict` in `setup` [S1][S3]. Options type is untyped key/value (validate yourself).
### 4. Can a plugin provide/replace the websearch provider? ### 4. Can a plugin provide/replace the websearch provider?
Yes. [S1]: Yes. [S1]:
```ts ```ts
await ctx.websearch.transform((editor) => { await ctx.websearch.transform((editor) => {
editor.add({ id: "internal", name: "Internal search", editor.add({
id: "internal",
name: "Internal search",
execute: async ({ query }, { signal }) => { execute: async ({ query }, { signal }) => {
const response = await fetch(`https://search.example.com?q=${query}`, { signal }) const response = await fetch(`https://search.example.com?q=${query}`, { signal })
return [{ url: "", title: "", content: "", time: {} }] return [{ url: "", title: "", content: "", time: {} }]
} }) },
})
editor.default.set("internal") editor.default.set("internal")
}) })
``` ```
Disable: `editor.default.set(false)`; re-evaluate: `await ctx.websearch.reload()` [S1]. The API reference lists add/default/remove operations [S6] (remove not shown in S1 example; exact editor type unverified). Disable: `editor.default.set(false)`; re-evaluate: `await ctx.websearch.reload()` [S1]. The API reference lists add/default/remove operations [S6] (remove not shown in S1 example; exact editor type unverified).
### 5. How `websearch.provider` is selected ### 5. How `websearch.provider` is selected
Config `{ "websearch": { "provider": "<id>" | "random" } }`; `"random"` picks an available provider; `"websearch": false` disables search entirely [S3][S4]. Provider on HTTP 429 enters cooldown and search retries another provider [S4]. Provider API keys via `/connect` or env (e.g. `TAVILY_API_KEY`) [S4]. For a plugin provider, `editor.default.set(id)` is the plugin-side selection [S1]; interplay/precedence with the config `provider` key is not documented -> verify. Config `{ "websearch": { "provider": "<id>" | "random" } }`; `"random"` picks an available provider; `"websearch": false` disables search entirely [S3][S4]. Provider on HTTP 429 enters cooldown and search retries another provider [S4]. Provider API keys via `/connect` or env (e.g. `TAVILY_API_KEY`) [S4]. For a plugin provider, `editor.default.set(id)` is the plugin-side selection [S1]; interplay/precedence with the config `provider` key is not documented -> verify.
### 6. Required search result shape ### 6. Required search result shape
From [S1] the `execute` return is an array of `{ url, title, content, time }`; the example uses `time: {}` (object, inner shape undocumented). [S6] lists the same four fields. Unverified: whether `time` fields are optional, exact `time` keys (e.g. published/updated), and whether extra fields are allowed. `execute` receives `({ query }, { signal })`; `signal` is an AbortSignal and must be passed to fetch [S1]. From [S1] the `execute` return is an array of `{ url, title, content, time }`; the example uses `time: {}` (object, inner shape undocumented). [S6] lists the same four fields. Unverified: whether `time` fields are optional, exact `time` keys (e.g. published/updated), and whether extra fields are allowed. `execute` receives `({ query }, { signal })`; `signal` is an AbortSignal and must be passed to fetch [S1].
## Open items (need source or runtime check) ## Open items (need source or runtime check)
- Exact TS types for editor, `time`, and execute args (read anomalyco/opencode source or `@opencode/plugin` typings after install). - Exact TS types for editor, `time`, and execute args (read anomalyco/opencode source or `@opencode/plugin` typings after install).
- Precedence of config `websearch.provider` vs plugin `editor.default.set`. - Precedence of config `websearch.provider` vs plugin `editor.default.set`.
- [S6] suggested `POST /api/websearch/query` with `{query, provider?}` -> `{results, provider}`; this came from the summariser and is lower confidence. - [S6] suggested `POST /api/websearch/query` with `{query, provider?}` -> `{results, provider}`; this came from the summariser and is lower confidence.
+72
View File
@@ -0,0 +1,72 @@
# Runtime checks
Results of the verification list in `docs/spec.md`. Run on 2026-10-03 against opencode v2.0.22 (Windows), in an isolated profile (`XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME`, `XDG_STATE_HOME` pointed at a temp folder, a private `opencode serve` on a local port, a mock fastCRW server on another), so the real opencode config was never touched.
## Summary
| # | Check | Result |
| --- | --------------------------------------------------- | ------------------------------------------------------------- |
| 1 | Built-in providers removable or disableable | Done: not removable by a plugin, but never used as a fallback |
| 2 | Throwing `setup` disables only this plugin | Done: yes, loudly |
| 3 | Config `websearch.provider` vs `editor.default.set` | Done: config wins |
| 4 | Install by name from the Gitea registry | Not run (needs a published release) |
| 5 | Release job token has package-write rights | Not run (needs a release run) |
| 6 | `@opencode/plugin` install weight | Done |
| 7 | `npm run smoke` against the real fastCRW server | Done |
| 8 | Typings, `time` shape, 429 cooldown | Done: a 429 from our plugin triggers no cooldown |
## How plugins load (read this first)
Read from the opencode binary and confirmed with hello-world plugins:
- A `plugins` entry in `opencode.json` that points at a **file** is ignored with the warning "configured plugin path must be a directory". It must be a directory, or an npm package name.
- A local directory is resolved **by file path**: `<dir>/server` then `<dir>/index`. The directory's `package.json` `exports` are not used. This repo has no root `index.js` (the entry is `dist/index.js`), so pointing a config entry at the repo or an unpacked package directory loads nothing and logs nothing. My first three attempts failed for this reason.
- An npm package name resolves through `exports` (`<name>/server`, then `<name>`), so `exports["."]` works for registry installs (still unverified end to end, check 4).
- Auto-discovery in `plugins/` folders (global config dir or project `.opencode/`) accepts only `.ts` and `.js` files and directories. `.mjs` files are ignored.
- `~` paths are not expanded; use `./relative`, an absolute path or `file://`. The `~/.config/opencode/plugins/fastcrw` example in the original sketch does not work.
- Loading is lazy: the plugin list is empty for several seconds after startup or `POST /api/location/reload`.
With a one-line directory shim (`index.js`: `export { default } from "file:///.../dist/index.js"`) the real plugin loaded, registered provider `fastcrw`, honoured `baseURL` and `limit` from the plugin options, and returned the mapped result for `POST /api/websearch`.
## Findings
### 1. Built-in providers
The plugin API has no way to remove a provider (`WebSearchEditor` only has `add` and `default`), so the built-ins (exa, firecrawl, parallel, tavily, tinyfish) stay registered. In practice fastCRW is still the only one used:
- With no `websearch` config, the plugin's `editor.default.set("fastcrw")` makes it the default.
- When fastCRW was unreachable or returned 429, opencode answered 503 "Web search request failed: fastcrw". It did **not** fall back to a built-in.
- If no default is set (for example the plugin failed to load), a query without a provider id is rejected with 400 "Web search provider is required".
### 2. Throwing `setup`
With `baseURL: "nope"`, only this plugin ended up in state `failed`, with our message and stack (`fastCRW: invalid baseURL "nope" (expected an http(s) URL)`), and a `WARN failed to load plugin` line was logged. The other plugins stayed `active` and the server kept running. The provider is then not registered. The plan holds: fail loudly, no silent search elsewhere.
### 3. Precedence
Config `websearch.provider` wins over the plugin's `editor.default.set`: with the config set to `exa`, a query without a provider id went to Exa (and worked without a key, so the query left the machine). With the config unset or set to `fastcrw`, the plugin's provider was used. Consequence: do not set `websearch.provider` to anything else; setting it to `fastcrw` is harmless and explicit.
### 6. `@opencode/plugin` install weight
`npm install` of `@opencode/plugin@2.0.22` adds about 100 MB of transitive dependencies (mainly `effect`, OpenTelemetry, `@redis`). Its peers `solid-js`, `@opentui/core`, `@opentui/solid` and `@opencode/theme` are optional and not installed. Decision for the first release: keep it in `dependencies` as the docs say; revisit if install size matters.
### 7. Smoke test
Run against `http://crw.home` and `http://192.168.2.15:8013` (the same server; earlier attempts failed on DNS, a refused connection and an upstream 502 until the service came up). No token was needed. The envelope is `{ success, data }` and `data` is an object, so this server returns the self-hosted shape `data.results`, not a flat array; the plugin's dual-shape parser handles it. Each result has `url`, `title`, `description`, `snippet`, `position`, `score` and `category`.
### 8. Typings, `time`, 429
From `@opencode/schema` 2.0.22: a result is `{ url: string, title?: string, content?: string, time: { published?: number } }`, so `time: {}` is valid; `execute` receives `({ query }, { signal })`; `editor.default.set(selection: string | false)`. A 429 from fastCRW surfaced as a plain failure: three queries in a row all reached the server, so no provider cooldown was triggered. opencode also shows only the generic "Web search request failed: fastcrw" through the API; our detailed message was not visible there or in the debug log.
## Follow-ups this implies
- **Local installs need a root `index.js`.** Add `index.js` (`export { default } from "./dist/index.js"`) to the package and `files`, so a config entry pointing at an unpacked package directory works as well as an npm-name install. The release script stages `package.json` and `dist/` only and would need to include it.
- Update the README and spec examples: no `~` paths, and a directory (not a file) for local `plugins` entries.
- Optional: decide whether the generic user-facing error is acceptable, since our detailed messages are not surfaced.
- Still to verify after the first release: check 4 (install by name with a scoped `.npmrc`) and check 5 (job token package-write rights).
## Useful for later runs
- `opencode serve --hostname 127.0.0.1 --port <p>` prints a generated server password; HTTP Basic auth with user `opencode` works. `GET /openapi.json` lists the API.
- Relevant operations: `GET /api/plugin` (shows `state`, including load errors), `GET /api/websearch/provider`, `POST /api/websearch` (body `query`, optional `providerID`), `POST /api/location/reload`.
- `opencode debug config` hung in an empty profile; `opencode plugin add` writes to the global config, so avoid it on a real machine.
+122
View File
@@ -0,0 +1,122 @@
# Spec: fastCRW websearch plugin for opencode v2
Compiled from the closed tickets on the map ([Wayfinder map: fastCRW websearch plugin for opencode v2](https://git.arthurerlich.de/haylan/fastcrw-opencode-websarch/issues/1)). Detail lives in each ticket and in `docs/research/`. Vocabulary is defined in `CONTEXT.md`. Reference skills: `.claude/skills/opencode-v2-plugin`, `.claude/skills/fastcrw-search-api`.
## Goal
An opencode v2 plugin that makes a self-hosted fastCRW server opencode's only websearch **search provider**, via `POST {baseURL}/v1/search`. It is a native plugin, not the `crw-mcp` MCP server.
## Identifiers
| What | Value |
| --------------------------- | ------------------------------------------------------ |
| npm package | `@haylan/opencode-fastcrw` |
| Plugin id (`Plugin.define`) | `fastcrw.websearch` |
| Provider id / display name | `fastcrw` / "fastCRW" |
| Registry | `https://git.arthurerlich.de/api/packages/haylan/npm/` |
## Plugin behaviour
`src/index.ts` only wires things together:
```ts
export default Plugin.define({
id: "fastcrw.websearch",
async setup(ctx) {
const config = resolveConfig(ctx.options, process.env) // throws on bad values
await ctx.websearch.transform((editor) => {
editor.add({
id: "fastcrw",
name: "fastCRW",
execute: ({ query }, { signal }) => search(config, query, signal, fetch),
})
editor.default.set("fastcrw")
})
},
})
```
fastCRW is the default and only provider; there is no fallback to built-ins.
### Config (`src/config.ts`)
Precedence: plugin option > env var > default.
| Option | Env var | Default | Rules |
| --------- | ------------- | ----------------------- | ---------------------------------------------------------------------- |
| `baseURL` | `CRW_API_URL` | `http://localhost:3000` | trim, strip trailing `/`, must be `http(s)` URL |
| `apiKey` | `CRW_API_KEY` | none | optional; Bearer header only when set; never logged or echoed |
| `limit` | none | `8` | clamp to 1..20; invalid falls back to 8, never throws |
| `lang` | none | unset | string; omitted from request when unset |
| `tbs` | none | unset | one of `qdr:h`, `qdr:d`, `qdr:w`, `qdr:m`, `qdr:y`; omitted when unset |
Validation runs in `setup`. A malformed `baseURL`, bad `tbs` or non-string `lang` makes `setup` throw, with a message naming the option and the bad value. Example `opencode.json`:
```jsonc
{
"plugins": [
{
"package": "@haylan/opencode-fastcrw",
"options": { "baseURL": "http://crw.home:3000", "limit": 8 },
},
],
"websearch": { "provider": "fastcrw" },
}
```
### Search (`src/search.ts`)
- Request: `POST {baseURL}/v1/search`, JSON `{ query, limit, lang?, tbs? }`, `Authorization: Bearer <apiKey>` only when a key exists. Query sent as-is (server rejects over 2000 chars with 400).
- Response: accept `data` as an array or `data.results` as an array; any other shape throws "unexpected response shape".
- Mapping: `{ url, title, content: snippet || description || "", time: {} }`. Entries without a string `url` are dropped; a missing `title` falls back to `url`. Empty list returns `[]`.
- Errors: throw `Error("fastCRW <status> <error_code>: <error>")`; HTTP 200 with `success:false` is handled the same (status 200). Fixed hints only for 401 ("check apiKey or CRW_API_KEY") and 503 `search_disabled` ("the server has no search backend configured"). Non-JSON body: `fastCRW <status>: non-JSON response` plus the first 200 chars. Network failure: `fastCRW: cannot reach <baseURL>` with the original error as `cause`. The token never appears in any message.
- Abort: an abort via opencode's `signal` is rethrown untouched.
- Timeout: fixed 30 s, combined with `signal` via `AbortSignal.any`; not configurable. No retries; a 429 keeps its status in the message.
## Repository
- Layout: `src/{config,search,index}.ts`, `test/*.test.ts`, `scripts/` (release and smoke), `docs/`, `CHANGELOG.md` (Keep a Changelog 1.1.0), `.gitea/workflows/`.
- `package.json`: `"type": "module"`, `exports["."]` → `dist/index.js`, `engines.node >=20`, `version` `0.0.0-dev`, `@opencode/plugin` in `dependencies`. Scripts: `build` (`tsc`), `format` (`prettier --write .`), `check` (`prettier --check . && tsc --noEmit`), `test` (`node --test`), `smoke`.
- TypeScript: `strict`, target ES2022, module `NodeNext`, plain `tsc` to `dist/` (ESM plus `.d.ts`). No bundler, no linter.
- Prettier: `semi: false`, `printWidth: 100`. No pre-commit hooks.
- Git: `main` is protected (PR required, 0 approvals, no force-push or deletion; already set up). Commits use Conventional Commits.
## Tests (`node --test`, Node 24, `fetch` mocked)
Cover: option/env/default precedence; each validation throw; `limit` clamp and fallback; request body and headers (Bearer only with a key; `lang`/`tbs` omitted when unset); both response shapes; every error path above; abort passthrough; the 30 s timeout.
## Workflows (Gitea Actions, runner `ubuntu-latest`)
1. `check.yml`, on `pull_request` to `main`: Node 24, `npm ci`, `npm run check`, `npm test`, `npm run build`. Once it exists, add its status as a required check on `main`.
2. `release.yml`, `workflow_dispatch` only, run on `main`:
- read the latest `## [x.y.z]` heading in `CHANGELOG.md` (ignore `[Unreleased]`);
- set `package.json` `version` to it in the CI workspace only (never committed back);
- `npm ci`, `npm run build`;
- a dependency-free Node script in `scripts/` builds the tarball, `GET`s the registry to check the version, exits 0 with "already published" if it exists, else `PUT`s it to the registry API;
- no `npm publish`. Auth via a Gitea Actions secret.
## Verification (manual, recorded in `docs/runtime-checks.md`)
Run once on a real opencode v2 install; a failed check reopens the design as a new ticket on the map.
1. Can built-in providers be removed or disabled? Fallback: `"websearch": false` plus an explicit `fastcrw`.
2. Does a throwing `setup` disable only this plugin, not opencode startup?
3. Precedence of config `websearch.provider` vs `editor.default.set`.
4. Does install-by-name from the Gitea registry work with a scoped `.npmrc` (`@haylan:registry=...`)? Fallback: tarball or local path.
5. Does the release workflow's job token have package-write rights? Fallback: a `write:package` secret.
6. Does `npm install` of `@opencode/plugin` drag in `solid-js` and the OpenTUI peers? If heavy, move it to `peerDependencies` plus `devDependencies`.
7. `npm run smoke` against the real fastCRW server (`CRW_API_URL`/`CRW_API_KEY`): record the real response shape (`data[]` vs `data.results`).
8. Confirm the exact `@opencode/plugin` TypeScript types, the shape of `time`, and that a 429 status in an error message triggers opencode's provider cooldown.
## Build order
1. Open a PR with the pending `CONTEXT.md` change plus scaffold: `package.json`, `tsconfig.json`, Prettier config, `CHANGELOG.md` with `[Unreleased]`.
2. `src/config.ts` and `src/search.ts` test-first, then `src/index.ts`.
3. `check.yml`; make it a required check on `main`.
4. `scripts/` release and smoke scripts, then `release.yml`.
5. Run the verification list; write `docs/runtime-checks.md`.
6. First release: add a changelog entry, run the release workflow.
## Out of scope
The `crw-mcp` MCP-server approach, pre-commit hooks, a bundler or linter, a configurable timeout, retries, and a fallback to built-in providers.
+4197
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@haylan/opencode-fastcrw",
"version": "0.0.0-dev",
"description": "opencode v2 plugin that makes a self-hosted fastCRW server the websearch provider",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc",
"format": "prettier --write .",
"check": "prettier --check . && tsc --noEmit",
"test": "node --test",
"smoke": "node scripts/smoke.ts"
},
"repository": {
"type": "git",
"url": "https://git.arthurerlich.de/haylan/fastcrw-opencode-websarch.git"
},
"license": "MIT",
"dependencies": {
"@opencode/plugin": "^2.0.22"
},
"devDependencies": {
"@types/node": "^26.6.4",
"prettier": "^3.9.9",
"typescript": "^7.0.2"
}
}
+148
View File
@@ -0,0 +1,148 @@
// Publishes the latest CHANGELOG.md version to a Gitea npm registry without `npm publish`, tags the
// commit `v<version>` and links the package to this repository. Every step is idempotent, so a
// rerun repairs a release that is only partly done.
// Env: SERVER_URL, REPOSITORY (owner/repo), SHA (from the workflow), TOKEN (needs write:package
// and write:repository).
import { execFileSync } from "node:child_process"
import { createHash } from "node:crypto"
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
export function latestVersion(changelog: string): string {
const match = changelog.match(/^## \[(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]/m)
if (!match) throw new Error("no release heading like `## [1.0.0]` found in CHANGELOG.md")
return match[1]
}
export const encodeName = (name: string) => name.replace("/", "%2F")
export const isPublished = (packument: { versions?: Record<string, unknown> }, version: string) =>
Boolean(packument.versions?.[version])
export function publishBody(pkg: { name: string; version: string }, tarball: Buffer): object {
const { name, version } = pkg
const file = `${name}-${version}.tgz`
return {
_id: name,
name,
"dist-tags": { latest: version },
versions: {
[version]: {
...pkg,
_id: `${name}@${version}`,
dist: {
shasum: createHash("sha1").update(tarball).digest("hex"),
integrity: `sha512-${createHash("sha512").update(tarball).digest("base64")}`,
},
},
},
_attachments: {
[file]: {
content_type: "application/octet-stream",
data: tarball.toString("base64"),
length: tarball.length,
},
},
}
}
type Api = { base: string; headers: Record<string, string>; fetchFn?: typeof fetch }
async function call(api: Api, method: string, path: string, body?: object) {
return (api.fetchFn ?? fetch)(`${api.base}${path}`, {
method,
headers: { ...api.headers, "Content-Type": "application/json" },
body: body && JSON.stringify(body),
})
}
const fail = async (what: string, res: Response) =>
new Error(`${what} failed: ${res.status} ${await res.text()}`)
export async function ensureTag(api: Api, repo: string, version: string, sha: string) {
const tag = `v${version}`
const existing = await call(api, "GET", `/repos/${repo}/tags/${tag}`)
if (existing.ok) return "exists"
if (existing.status !== 404) throw await fail("tag lookup", existing)
const res = await call(api, "POST", `/repos/${repo}/tags`, {
tag_name: tag,
target: sha,
message: `Release ${version}`,
})
if (!res.ok) throw await fail("tag creation", res)
return "created"
}
export async function ensureLinked(
api: Api,
owner: string,
name: string,
version: string,
repoName: string,
) {
const pkg = `/packages/${owner}/npm/${encodeURIComponent(name)}`
const existing = await call(api, "GET", `${pkg}/${version}`)
if (!existing.ok) throw await fail("package lookup", existing)
if (((await existing.json()) as { repository?: unknown }).repository) return "already linked"
const res = await call(api, "POST", `${pkg}/-/link/${repoName}`)
if (!res.ok) throw await fail("package link", res)
return "linked"
}
async function main() {
const { SERVER_URL, REPOSITORY, SHA, TOKEN: token } = process.env
if (!SERVER_URL || !REPOSITORY || !SHA || !token) {
throw new Error("SERVER_URL, REPOSITORY, SHA and TOKEN must be set")
}
const [owner, repoName] = REPOSITORY.split("/")
const server = SERVER_URL.replace(/\/+$/, "")
const registry = `${server}/api/packages/${owner}/npm`
const version = latestVersion(readFileSync("CHANGELOG.md", "utf8"))
// The version is rewritten in the staged copy only; the repo keeps its dev version.
const pkg = { ...JSON.parse(readFileSync("package.json", "utf8")), version }
const url = `${registry}/${encodeName(pkg.name)}`
const headers = { Authorization: `Bearer ${token}` }
const existing = await fetch(url, { headers })
if (existing.ok && isPublished(await existing.json(), version)) {
console.log(`${pkg.name}@${version} is already published, skipping publish`)
} else if (!existing.ok && existing.status !== 404) {
throw new Error(`registry lookup failed: ${existing.status} ${await existing.text()}`)
} else {
await publish(pkg, url, headers)
}
const api = { base: `${server}/api/v1`, headers }
console.log(`tag v${version}: ${await ensureTag(api, REPOSITORY, version, SHA)}`)
console.log(`package link: ${await ensureLinked(api, owner, pkg.name, version, repoName)}`)
}
async function publish(
pkg: { name: string; version: string },
url: string,
headers: Record<string, string>,
) {
const { version } = pkg
rmSync(".release", { recursive: true, force: true })
mkdirSync(".release/package", { recursive: true })
writeFileSync(".release/package/package.json", JSON.stringify(pkg, null, 2))
cpSync("dist", ".release/package/dist", { recursive: true })
execFileSync("tar", ["-czf", "package.tgz", "-C", ".release", "package"], { cwd: "." })
const tarball = readFileSync("package.tgz")
const res = await fetch(url, {
method: "PUT",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(publishBody(pkg, tarball)),
})
if (!res.ok) throw new Error(`publish failed: ${res.status} ${await res.text()}`)
console.log(`published ${pkg.name}@${version}`)
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((err) => {
console.error(err.message)
process.exit(1)
})
}
+22
View File
@@ -0,0 +1,22 @@
// Manual check against a real fastCRW server: prints the raw response shape and mapped results.
// Usage: CRW_API_URL=http://crw.home:3000 [CRW_API_KEY=...] npm run smoke -- "query"
import { resolveConfig } from "../src/config.ts"
import { search } from "../src/search.ts"
const config = resolveConfig({}, process.env)
const query = process.argv[2] ?? "opencode"
const headers: Record<string, string> = { "Content-Type": "application/json" }
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`
const raw = await fetch(`${config.baseURL}/v1/search`, {
method: "POST",
headers,
body: JSON.stringify({ query, limit: config.limit }),
})
const body: any = await raw.json().catch(() => undefined)
console.log(
`HTTP ${raw.status}; data shape:`,
Array.isArray(body?.data) ? "data[]" : body?.data?.results ? "data.results" : "other",
)
console.log(await search(config, query, new AbortController().signal))
+63
View File
@@ -0,0 +1,63 @@
export type Config = {
baseURL: string
apiKey: string | undefined
limit: number
lang: string | undefined
tbs: string | undefined
}
const DEFAULT_BASE_URL = "http://localhost:3000"
const DEFAULT_LIMIT = 8
const TBS_VALUES = ["qdr:h", "qdr:d", "qdr:w", "qdr:m", "qdr:y"]
// Blank strings count as unset so an empty env var does not shadow the default.
function str(value: unknown, name: string, secret = false): string | undefined {
if (value === undefined || value === null) return undefined
if (typeof value !== "string") {
throw new Error(
`fastCRW: option ${name} must be a string${secret ? "" : `, got ${JSON.stringify(value)}`}`,
)
}
return value.trim() || undefined
}
function baseURL(value: string): string {
let url: URL
try {
url = new URL(value)
} catch {
throw new Error(`fastCRW: invalid baseURL ${JSON.stringify(value)} (expected an http(s) URL)`)
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`fastCRW: invalid baseURL ${JSON.stringify(value)} (expected an http(s) URL)`)
}
return value.replace(/\/+$/, "")
}
function limit(value: unknown): number {
const n = typeof value === "string" && value.trim() ? Number(value) : value
if (typeof n !== "number" || !Number.isFinite(n)) return DEFAULT_LIMIT
return Math.min(20, Math.max(1, Math.trunc(n)))
}
export function resolveConfig(
options: Record<string, unknown> | undefined,
env: Record<string, string | undefined>,
): Config {
const opts = options ?? {}
const tbs = str(opts.tbs, "tbs")
if (tbs !== undefined && !TBS_VALUES.includes(tbs)) {
throw new Error(
`fastCRW: invalid tbs ${JSON.stringify(tbs)} (expected one of ${TBS_VALUES.join(", ")})`,
)
}
return {
baseURL: baseURL(
str(opts.baseURL, "baseURL") ?? str(env.CRW_API_URL, "CRW_API_URL") ?? DEFAULT_BASE_URL,
),
apiKey: str(opts.apiKey, "apiKey", true) ?? str(env.CRW_API_KEY, "CRW_API_KEY", true),
limit: limit(opts.limit),
lang: str(opts.lang, "lang"),
tbs,
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Plugin } from "@opencode/plugin"
import { resolveConfig } from "./config.ts"
import { search } from "./search.ts"
export default Plugin.define({
id: "fastcrw.websearch",
async setup(ctx) {
// Throws on bad options so a misconfigured plugin fails loudly instead of silently searching elsewhere.
const config = resolveConfig(ctx.options, process.env)
await ctx.websearch.transform((editor) => {
editor.add({
id: "fastcrw",
name: "fastCRW",
execute: ({ query }, { signal }) => search(config, query, signal),
})
editor.default.set("fastcrw")
})
},
})
+89
View File
@@ -0,0 +1,89 @@
import type { Config } from "./config.ts"
export type SearchResult = {
url: string
title: string
content: string
time: Record<string, never>
}
const TIMEOUT_MS = 30_000
const str = (value: unknown) => (typeof value === "string" ? value : "")
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null
function hint(status: number, code: string | undefined): string {
if (status === 401) return " (check apiKey or CRW_API_KEY)"
if (code === "search_disabled") return " (the server has no search backend configured)"
return ""
}
// Hosted fastCRW returns `data` as an array, self-hosted as `{ results: [...] }`.
function items(data: unknown): unknown[] {
const list = isObject(data) && !Array.isArray(data) ? data.results : data
if (!Array.isArray(list)) throw new Error("fastCRW: unexpected response shape")
return list
}
export async function search(
config: Config,
query: string,
signal: AbortSignal,
fetchFn: typeof fetch = fetch,
timeoutMs = TIMEOUT_MS,
): Promise<SearchResult[]> {
const timeout = AbortSignal.timeout(timeoutMs)
const headers: Record<string, string> = { "Content-Type": "application/json" }
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`
let res: Response
let text: string
try {
res = await fetchFn(`${config.baseURL}/v1/search`, {
method: "POST",
signal: AbortSignal.any([signal, timeout]),
headers,
body: JSON.stringify({
query,
limit: config.limit,
...(config.lang && { lang: config.lang }),
...(config.tbs && { tbs: config.tbs }),
}),
})
text = await res.text()
} catch (cause) {
if (signal.aborted) throw cause
if (timeout.aborted) throw new Error(`fastCRW: timed out after ${timeoutMs / 1000}s`, { cause })
throw new Error(`fastCRW: cannot reach ${config.baseURL}`, { cause })
}
let body: unknown
try {
body = JSON.parse(text)
} catch {
throw new Error(`fastCRW ${res.status}: non-JSON response: ${text.slice(0, 200)}`)
}
const parsed = isObject(body) ? body : {}
if (!res.ok || parsed.success === false) {
const code = str(parsed.error_code) || undefined
const message = str(parsed.error) || res.statusText || "request failed"
throw new Error(
`fastCRW ${res.status}${code ? ` ${code}` : ""}: ${message}${hint(res.status, code)}`,
)
}
return items(parsed.data).flatMap((item) => {
if (!isObject(item) || !str(item.url)) return []
const url = str(item.url)
return [
{
url,
title: str(item.title) || url,
content: str(item.snippet) || str(item.description),
time: {},
},
]
})
}
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { resolveConfig } from "../src/config.ts"
test("defaults", () => {
assert.deepEqual(resolveConfig({}, {}), {
baseURL: "http://localhost:3000",
apiKey: undefined,
limit: 8,
lang: undefined,
tbs: undefined,
})
})
test("undefined options use defaults", () => {
assert.equal(resolveConfig(undefined, {}).baseURL, "http://localhost:3000")
})
test("baseURL precedence: option > env > default", () => {
const env = { CRW_API_URL: "http://env:1" }
assert.equal(resolveConfig({ baseURL: "http://opt:2" }, env).baseURL, "http://opt:2")
assert.equal(resolveConfig({}, env).baseURL, "http://env:1")
})
test("baseURL is trimmed and loses trailing slashes", () => {
assert.equal(
resolveConfig({ baseURL: " http://crw.home:3000// " }, {}).baseURL,
"http://crw.home:3000",
)
})
test("blank baseURL option falls through to env", () => {
assert.equal(
resolveConfig({ baseURL: " " }, { CRW_API_URL: "http://env:1" }).baseURL,
"http://env:1",
)
})
test("invalid baseURL throws and names option and value", () => {
for (const bad of ["not a url", "ftp://host", 42]) {
assert.throws(() => resolveConfig({ baseURL: bad }, {}), /baseURL/)
}
assert.throws(() => resolveConfig({}, { CRW_API_URL: "nope" }), /baseURL.*nope/)
})
test("apiKey precedence: option > env; unset when absent or blank", () => {
assert.equal(resolveConfig({ apiKey: "a" }, { CRW_API_KEY: "b" }).apiKey, "a")
assert.equal(resolveConfig({}, { CRW_API_KEY: "b" }).apiKey, "b")
assert.equal(resolveConfig({}, { CRW_API_KEY: "" }).apiKey, undefined)
assert.equal(resolveConfig({ apiKey: " " }, {}).apiKey, undefined)
})
test("non-string apiKey throws without echoing the value", () => {
assert.throws(
() => resolveConfig({ apiKey: 12345 }, {}),
(err: Error) => /apiKey/.test(err.message) && !err.message.includes("12345"),
)
})
test("limit: clamps to 1..20, invalid falls back to 8, never throws", () => {
assert.equal(resolveConfig({ limit: 5 }, {}).limit, 5)
assert.equal(resolveConfig({ limit: 99 }, {}).limit, 20)
assert.equal(resolveConfig({ limit: 0 }, {}).limit, 1)
assert.equal(resolveConfig({ limit: -3 }, {}).limit, 1)
assert.equal(resolveConfig({ limit: "12" }, {}).limit, 12)
assert.equal(resolveConfig({ limit: 3.9 }, {}).limit, 3)
for (const bad of ["abc", NaN, null, {}, [], true]) {
assert.equal(resolveConfig({ limit: bad }, {}).limit, 8)
}
})
test("lang: string passes through, blank is unset, non-string throws", () => {
assert.equal(resolveConfig({ lang: "de" }, {}).lang, "de")
assert.equal(resolveConfig({ lang: "" }, {}).lang, undefined)
assert.throws(() => resolveConfig({ lang: 1 }, {}), /lang/)
})
test("tbs: allowed values pass, others throw", () => {
for (const ok of ["qdr:h", "qdr:d", "qdr:w", "qdr:m", "qdr:y"]) {
assert.equal(resolveConfig({ tbs: ok }, {}).tbs, ok)
}
assert.equal(resolveConfig({ tbs: "" }, {}).tbs, undefined)
assert.throws(() => resolveConfig({ tbs: "qdr:x" }, {}), /tbs.*qdr:x/)
assert.throws(() => resolveConfig({ tbs: 1 }, {}), /tbs/)
})
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import plugin from "../src/index.ts"
function fakeContext(options: Record<string, unknown>) {
const added: { id: string; name: string; execute: Function }[] = []
const defaults: unknown[] = []
const editor = {
add: (definition: (typeof added)[number]) => added.push(definition),
default: { get: () => undefined, set: (value: unknown) => defaults.push(value) },
}
const ctx = {
options,
websearch: { transform: async (fn: (e: typeof editor) => void) => fn(editor) },
}
return { ctx: ctx as never, added, defaults }
}
test("plugin id", () => {
assert.equal(plugin.id, "fastcrw.websearch")
})
test("setup registers the fastcrw provider and makes it the default", async () => {
const { ctx, added, defaults } = fakeContext({ baseURL: "http://crw.test:3000" })
await plugin.setup(ctx)
assert.equal(added.length, 1)
assert.equal(added[0].id, "fastcrw")
assert.equal(added[0].name, "fastCRW")
assert.deepEqual(defaults, ["fastcrw"])
})
test("setup throws on bad options and registers nothing", async () => {
const { ctx, added, defaults } = fakeContext({ baseURL: "nope" })
await assert.rejects(Promise.resolve(plugin.setup(ctx)), /baseURL/)
assert.equal(added.length + defaults.length, 0)
})
test("the registered provider searches the configured endpoint", async () => {
const { ctx, added } = fakeContext({ baseURL: "http://crw.test:3000", limit: 2 })
await plugin.setup(ctx)
const realFetch = globalThis.fetch
let seen = ""
globalThis.fetch = (async (url: string) => {
seen = url
return new Response(
JSON.stringify({ success: true, data: [{ url: "https://a", title: "A", snippet: "s" }] }),
)
}) as typeof fetch
try {
const results = await added[0].execute(
{ query: "cats" },
{ signal: new AbortController().signal },
)
assert.equal(seen, "http://crw.test:3000/v1/search")
assert.deepEqual(results, [{ url: "https://a", title: "A", content: "s", time: {} }])
} finally {
globalThis.fetch = realFetch
}
})
+131
View File
@@ -0,0 +1,131 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import {
encodeName,
ensureLinked,
ensureTag,
isPublished,
latestVersion,
publishBody,
} from "../scripts/release.ts"
const changelog = (body: string) => `# Changelog\n\n${body}\n`
test("latestVersion picks the first released heading and skips Unreleased", () => {
const text = changelog(
"## [Unreleased]\n\n- wip\n\n## [1.2.0] - 2026-10-10\n\n## [1.1.0] - 2026-10-01\n",
)
assert.equal(latestVersion(text), "1.2.0")
})
test("latestVersion accepts prerelease versions", () => {
assert.equal(latestVersion(changelog("## [2.0.0-beta.1] - 2026-10-10")), "2.0.0-beta.1")
})
test("latestVersion throws when there is no release", () => {
assert.throws(() => latestVersion(changelog("## [Unreleased]\n\n- wip")), /no release/)
})
test("encodeName escapes the scope slash", () => {
assert.equal(encodeName("@haylan/opencode-fastcrw"), "@haylan%2Fopencode-fastcrw")
})
test("isPublished checks the versions map of a packument", () => {
assert.equal(isPublished({ versions: { "1.0.0": {} } }, "1.0.0"), true)
assert.equal(isPublished({ versions: { "1.0.0": {} } }, "1.1.0"), false)
assert.equal(isPublished({}, "1.0.0"), false)
})
test("publishBody builds the npm publish payload with the tarball attached", () => {
const pkg = { name: "@haylan/opencode-fastcrw", version: "1.0.0", description: "d" }
const tarball = Buffer.from("tarball-bytes")
const body = publishBody(pkg, tarball) as any
const file = "@haylan/opencode-fastcrw-1.0.0.tgz"
assert.equal(body._id, "@haylan/opencode-fastcrw")
assert.deepEqual(body["dist-tags"], { latest: "1.0.0" })
assert.equal(body.versions["1.0.0"]._id, "@haylan/opencode-fastcrw@1.0.0")
assert.match(body.versions["1.0.0"].dist.shasum, /^[0-9a-f]{40}$/)
assert.match(body.versions["1.0.0"].dist.integrity, /^sha512-/)
assert.equal(body._attachments[file].data, tarball.toString("base64"))
assert.equal(body._attachments[file].length, tarball.length)
})
// Fake Gitea API: each route answers with a status and optional JSON body; calls are recorded.
function fakeApi(routes: Record<string, [number, unknown?]>) {
const calls: string[] = []
const fetchFn = (async (url: string, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${url.replace("https://git.test/api/v1", "")}`
calls.push(key + (init?.body ? ` ${init.body}` : ""))
const [status, body] = routes[key] ?? [500, { message: `unexpected ${key}` }]
return new Response(JSON.stringify(body ?? {}), { status })
}) as typeof fetch
return {
calls,
api: { base: "https://git.test/api/v1", headers: { Authorization: "Bearer t" }, fetchFn },
}
}
test("ensureTag creates the tag when it does not exist", async () => {
const { calls, api } = fakeApi({
"GET /repos/haylan/r/tags/v1.0.0": [404],
"POST /repos/haylan/r/tags": [201],
})
assert.equal(await ensureTag(api, "haylan/r", "1.0.0", "abc123"), "created")
assert.deepEqual(JSON.parse(calls[1].replace("POST /repos/haylan/r/tags ", "")), {
tag_name: "v1.0.0",
target: "abc123",
message: "Release 1.0.0",
})
})
test("ensureTag leaves an existing tag alone", async () => {
const { calls, api } = fakeApi({ "GET /repos/haylan/r/tags/v1.0.0": [200, { name: "v1.0.0" }] })
assert.equal(await ensureTag(api, "haylan/r", "1.0.0", "abc123"), "exists")
assert.equal(calls.length, 1)
})
test("ensureTag throws on an unexpected API answer", async () => {
const { api } = fakeApi({ "GET /repos/haylan/r/tags/v1.0.0": [403, { message: "no" }] })
await assert.rejects(ensureTag(api, "haylan/r", "1.0.0", "abc"), /403/)
})
const pkgPath = "/packages/haylan/npm/%40haylan%2Fopencode-fastcrw"
test("ensureLinked links an unlinked package to the repository", async () => {
const { calls, api } = fakeApi({
[`GET ${pkgPath}/1.0.0`]: [200, { repository: null }],
[`POST ${pkgPath}/-/link/r`]: [201],
})
assert.equal(
await ensureLinked(api, "haylan", "@haylan/opencode-fastcrw", "1.0.0", "r"),
"linked",
)
assert.equal(calls.length, 2)
})
test("ensureLinked skips a package that already has a repository", async () => {
const { calls, api } = fakeApi({
[`GET ${pkgPath}/1.0.0`]: [200, { repository: { full_name: "haylan/r" } }],
})
assert.equal(
await ensureLinked(api, "haylan", "@haylan/opencode-fastcrw", "1.0.0", "r"),
"already linked",
)
assert.equal(calls.length, 1)
})
test("ensureLinked throws when the package lookup or link fails", async () => {
const lookup = fakeApi({ [`GET ${pkgPath}/1.0.0`]: [404] })
await assert.rejects(
ensureLinked(lookup.api, "haylan", "@haylan/opencode-fastcrw", "1.0.0", "r"),
/404/,
)
const link = fakeApi({
[`GET ${pkgPath}/1.0.0`]: [200, {}],
[`POST ${pkgPath}/-/link/r`]: [403],
})
await assert.rejects(
ensureLinked(link.api, "haylan", "@haylan/opencode-fastcrw", "1.0.0", "r"),
/403/,
)
})
+160
View File
@@ -0,0 +1,160 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import type { Config } from "../src/config.ts"
import { search } from "../src/search.ts"
const config: Config = {
baseURL: "http://crw.test:3000",
apiKey: undefined,
limit: 8,
lang: undefined,
tbs: undefined,
}
const signal = new AbortController().signal
type Call = { url: string; init: RequestInit }
function respond(body: unknown, status = 200) {
const calls: Call[] = []
const fetchFn = (async (url: string, init: RequestInit) => {
calls.push({ url, init })
return new Response(typeof body === "string" ? body : JSON.stringify(body), { status })
}) as typeof fetch
return { calls, fetchFn }
}
const ok = (data: unknown) => ({ success: true, data })
test("sends POST /v1/search with JSON body and no auth header by default", async () => {
const { calls, fetchFn } = respond(ok([]))
await search(config, "cats", signal, fetchFn)
assert.equal(calls[0].url, "http://crw.test:3000/v1/search")
assert.equal(calls[0].init.method, "POST")
const headers = calls[0].init.headers as Record<string, string>
assert.equal(headers["Content-Type"], "application/json")
assert.equal("Authorization" in headers, false)
assert.deepEqual(JSON.parse(calls[0].init.body as string), { query: "cats", limit: 8 })
})
test("sends Bearer token, lang and tbs when configured", async () => {
const { calls, fetchFn } = respond(ok([]))
await search({ ...config, apiKey: "k", lang: "de", tbs: "qdr:w", limit: 3 }, "q", signal, fetchFn)
const headers = calls[0].init.headers as Record<string, string>
assert.equal(headers.Authorization, "Bearer k")
assert.deepEqual(JSON.parse(calls[0].init.body as string), {
query: "q",
limit: 3,
lang: "de",
tbs: "qdr:w",
})
})
test("maps a flat data array", async () => {
const { fetchFn } = respond(
ok([{ url: "https://a", title: "A", snippet: "s", description: "d" }]),
)
assert.deepEqual(await search(config, "q", signal, fetchFn), [
{ url: "https://a", title: "A", content: "s", time: {} },
])
})
test("maps data.results and falls back to description", async () => {
const { fetchFn } = respond(ok({ results: [{ url: "https://a", title: "A", description: "d" }] }))
const [r] = await search(config, "q", signal, fetchFn)
assert.equal(r.content, "d")
})
test("drops entries without a url, falls back to url for title, empty content", async () => {
const { fetchFn } = respond(ok([{ title: "no url" }, null, { url: "https://b" }]))
assert.deepEqual(await search(config, "q", signal, fetchFn), [
{ url: "https://b", title: "https://b", content: "", time: {} },
])
})
test("empty results return []", async () => {
assert.deepEqual(await search(config, "q", signal, respond(ok([])).fetchFn), [])
assert.deepEqual(await search(config, "q", signal, respond(ok({ results: [] })).fetchFn), [])
})
test("unexpected shape throws", async () => {
for (const data of [{ web: [] }, "x", null]) {
await assert.rejects(
search(config, "q", signal, respond(ok(data)).fetchFn),
/unexpected response shape/,
)
}
})
test("API error keeps status and code", async () => {
const body = { success: false, error: "slow down", error_code: "rate_limited" }
await assert.rejects(search(config, "q", signal, respond(body, 429).fetchFn), {
message: "fastCRW 429 rate_limited: slow down",
})
})
test("503 search_disabled and 401 carry a hint", async () => {
await assert.rejects(
search(
config,
"q",
signal,
respond({ success: false, error: "off", error_code: "search_disabled" }, 503).fetchFn,
),
/fastCRW 503 search_disabled: off .*no search backend configured/,
)
await assert.rejects(
search(config, "q", signal, respond({ success: false, error: "Invalid API key" }, 401).fetchFn),
/fastCRW 401: Invalid API key .*check apiKey or CRW_API_KEY/,
)
})
test("HTTP 200 with success:false is an error with status 200", async () => {
const body = { success: false, error: "search failed", error_code: "http_error" }
await assert.rejects(search(config, "q", signal, respond(body, 200).fetchFn), {
message: "fastCRW 200 http_error: search failed",
})
})
test("errors never include the token", async () => {
const body = { success: false, error: "bad", error_code: "invalid_request" }
await assert.rejects(
search({ ...config, apiKey: "SECRET" }, "q", signal, respond(body, 400).fetchFn),
(err: Error) => !err.message.includes("SECRET"),
)
})
test("non-JSON body reports status and the first 200 chars", async () => {
const html = "<html>" + "x".repeat(500)
await assert.rejects(
search(config, "q", signal, respond(html, 502).fetchFn),
(err: Error) =>
err.message.startsWith("fastCRW 502: non-JSON response") && err.message.length < 260,
)
})
test("network failure names the baseURL and keeps the cause", async () => {
const cause = new TypeError("fetch failed")
const fetchFn = (async () => {
throw cause
}) as typeof fetch
await assert.rejects(search(config, "q", signal, fetchFn), (err: Error) => {
assert.equal(err.message, "fastCRW: cannot reach http://crw.test:3000")
assert.equal(err.cause, cause)
return true
})
})
// A fetch that never answers and rejects with the signal's reason, like the real one.
const hang = ((_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal!.addEventListener("abort", () => reject(init.signal!.reason))
})) as typeof fetch
test("caller abort is rethrown untouched", async () => {
const controller = new AbortController()
const pending = search(config, "q", controller.signal, hang)
controller.abort()
await assert.rejects(pending, (err) => err === controller.signal.reason)
})
test("internal timeout aborts the request", async () => {
await assert.rejects(search(config, "q", signal, hang, 20), /timed out/)
})
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"],
"skipLibCheck": true,
"rewriteRelativeImportExtensions": true
},
"include": ["src"]
}