Compare commits
13
Commits
385b454387
..
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f16536fffd | ||
|
|
626ad4156d | ||
|
|
6e7080d687 | ||
|
|
56ad2392a5 | ||
|
|
d32d5fb701 | ||
|
|
ef1a234f63 | ||
|
|
5562c697ae | ||
|
|
7d7c684724 | ||
|
|
ccab3c097f | ||
|
|
e8a88180eb | ||
|
|
30a47b33bd | ||
|
|
0712a932cc | ||
|
|
4d552f6e28 |
No files matched your search
@@ -45,26 +45,27 @@ await ctx.websearch.transform((editor) => {
|
|||||||
{
|
{
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"package": "~/.config/opencode/plugins/fastcrw",
|
"package": "@haylan/opencode-fastcrw",
|
||||||
"options": { "baseURL": "http://crw.home:3000", "limit": 8 },
|
"options": { "baseURL": "http://crw.home:3000", "limit": 8 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"websearch": { "provider": "fastcrw" },
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- 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`.
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.release/
|
||||||
|
package.tgz
|
||||||
@@ -5,3 +5,13 @@ 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/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
## [Unreleased]
|
## [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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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/,
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user