From 1ffbddee7ef41c7ac5ab5f2ac491b0a8e7986bde Mon Sep 17 00:00:00 2001 From: jsze2020 <84141863+jsze2020@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:34:02 -0500 Subject: [PATCH] Created Typescript SDK with Spec document and first plugin: Github Sync to sync github repo docs to Alignbase --- .gitignore | 12 + SPEC.md | 198 +++ package.json | 21 + packages/README.md | 263 ++++ packages/plugin-github-sync/README.md | 86 ++ packages/plugin-github-sync/package.json | 35 + packages/plugin-github-sync/plugin.json | 14 + packages/plugin-github-sync/src/cli.ts | 83 ++ packages/plugin-github-sync/src/github.ts | 140 +++ packages/plugin-github-sync/src/index.ts | 72 ++ packages/plugin-github-sync/src/state.ts | 42 + packages/plugin-github-sync/src/sync.ts | 112 ++ .../plugin-github-sync/tests/sync.test.ts | 75 ++ packages/plugin-github-sync/tsconfig.json | 8 + packages/sdk/package.json | 34 + packages/sdk/src/audit.ts | 119 ++ packages/sdk/src/auth.ts | 39 + packages/sdk/src/errors.ts | 53 + packages/sdk/src/index.ts | 39 + packages/sdk/src/mcp-client.ts | 295 +++++ packages/sdk/src/plugin.ts | 269 +++++ packages/sdk/src/types.ts | 107 ++ packages/sdk/tests/audit.test.ts | 58 + packages/sdk/tests/auth.test.ts | 33 + packages/sdk/tests/mcp-client.test.ts | 94 ++ packages/sdk/tests/plugin.test.ts | 198 +++ packages/sdk/tsconfig.json | 8 + pnpm-lock.yaml | 1075 +++++++++++++++++ pnpm-workspace.yaml | 2 + scripts/get-token.ts | 259 ++++ tsconfig.base.json | 20 + 31 files changed, 3863 insertions(+) create mode 100644 .gitignore create mode 100644 SPEC.md create mode 100644 package.json create mode 100644 packages/README.md create mode 100644 packages/plugin-github-sync/README.md create mode 100644 packages/plugin-github-sync/package.json create mode 100644 packages/plugin-github-sync/plugin.json create mode 100644 packages/plugin-github-sync/src/cli.ts create mode 100644 packages/plugin-github-sync/src/github.ts create mode 100644 packages/plugin-github-sync/src/index.ts create mode 100644 packages/plugin-github-sync/src/state.ts create mode 100644 packages/plugin-github-sync/src/sync.ts create mode 100644 packages/plugin-github-sync/tests/sync.test.ts create mode 100644 packages/plugin-github-sync/tsconfig.json create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/src/audit.ts create mode 100644 packages/sdk/src/auth.ts create mode 100644 packages/sdk/src/errors.ts create mode 100644 packages/sdk/src/index.ts create mode 100644 packages/sdk/src/mcp-client.ts create mode 100644 packages/sdk/src/plugin.ts create mode 100644 packages/sdk/src/types.ts create mode 100644 packages/sdk/tests/audit.test.ts create mode 100644 packages/sdk/tests/auth.test.ts create mode 100644 packages/sdk/tests/mcp-client.test.ts create mode 100644 packages/sdk/tests/plugin.test.ts create mode 100644 packages/sdk/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/get-token.ts create mode 100644 tsconfig.base.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe43cad --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.claude +CLAUDE.md + +# Node +node_modules +dist +*.tsbuildinfo + +# Env / credentials +.env +.env.* +!.env.example diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..92bbc6c --- /dev/null +++ b/SPEC.md @@ -0,0 +1,198 @@ +# Alignbase Plugin Spec (v0.1, draft) + +This is the contract between Alignbase and a plugin. If you are building a +plugin, this document is everything you need to know about how Alignbase +expects you to behave. + +--- + +## 1. What a Plugin Is + +A plugin is a standalone process — CLI, worker, scheduled job, or hosted +service — that: + +1. Authenticates to Alignbase as a distinct identity. +2. Reads and/or writes context documents through Alignbase's MCP server. +3. Operates only within the tags and permissions a user has granted it. +4. Emits an audit event for every write. + +A plugin is **not** a separate context store. It must not cache published +context as its own source of truth. State a plugin needs to remember +(cursors, last-synced commit SHAs, dedupe hashes) lives in the plugin's own +storage, not in Alignbase context documents. + +--- + +## 2. Plugin Manifest + +Every plugin ships a `plugin.json` at its package root. + +```json +{ + "spec_version": 1, + "name": "github-sync", + "version": "0.1.0", + "display_name": "GitHub Sync", + "description": "Drafts repo README and /docs markdown into Alignbase.", + "publisher": "alignbase", + "homepage": "https://github.com/Sunpeak-AI/alignbase-platform", + "icons": { + "48": "./icons/icon-48.png", + "128": "./icons/icon-128.png" + }, + "entry": "dist/index.js", + "runtime": "node>=20", + "permissions": ["create_context", "edit_context", "create_tags"], + "tag_scopes": ["github", "docs"], + "config_schema": "./config.schema.json", + "triggers": ["manual", "schedule", "webhook"] +} +``` + +- `spec_version` pins the manifest schema. The host uses this to apply + the right validation rules. Bump only when the schema changes in a + breaking way. +- `icons` is optional but recommended. Multiple sizes let the dashboard + pick the right one for install screens, list views, and consent + dialogs. +- `permissions` lists the **capabilities** the plugin needs: + `read_context` (implicit), `edit_context`, `create_context`, + `publish_context`, `create_tags`. Modeled on Chrome's `permissions`. +- `tag_scopes` is the **maximum** set of tags the plugin will touch. + The installing user can narrow at consent time but cannot widen. + Modeled on Chrome's `host_permissions` — capabilities and scope are + separate so the consent screen can present them independently. +- Most plugins should not request `publish_context`. Humans approve + publication. +- `triggers` declares how the plugin runs. The host picks one at install + time. + +--- + +## 3. MCP Tools + +A plugin may call any of these tools on `https://app.alignbase.ai/mcp`, +subject to the permissions in its grant: + +- `get_current_context` +- `list_context_documents` +- `read_context_document` +- `create_context_document` +- `write_context_document` +- `publish_context_document` + +### Document shape + +```ts +type ContextDocument = { + id: string; + title: string; + tags: string[]; + content: string; + + current_version: number; + latest_version: number; + published_version: number | null; + + publication_status: "draft" | "published"; + has_unpublished_changes: boolean; + status: "live" | "stale" | "expired"; + + expires_at: string | null; + review_by: string | null; + + can_read: boolean; + can_edit: boolean; + can_publish: boolean; + + created_at: string; + updated_at: string; +}; +``` + +### Versioning rules + +- Every edit produces a new `latest_version`. Other clients do not see + edits until a `published_version` is promoted. +- Always read `latest` before editing; pass the returned version as + `expected_version` on write to avoid clobbering concurrent edits. + +--- + +## 4. Authentication + +Plugins authenticate via OAuth 2.1 + PKCE against `app.alignbase.ai`. This +is the same flow Alignbase uses for connected agents today. + +1. The plugin registers (or is registered) and receives an OAuth client ID + of the form `ab_client_`. +2. On first run, the plugin opens a browser, the user consents, and the + plugin receives an access token + refresh token. +3. The plugin attaches the access token on every MCP call. +4. The server validates the token against the grant (actions × tags) on + every request. +5. The user can revoke the grant from the Alignbase dashboard; subsequent + calls return `401`. + +Tokens are stored at `~/.alignbase//credentials.json` (or the +platform equivalent) and refreshed without user interaction. + +--- + +## 5. Audit Events + +Every write a plugin performs emits an audit event: + +```ts +type AuditEvent = { + event_id: string; + occurred_at: string; + plugin: { name: string; version: string }; + client_id: string; + action: "create" | "edit" | "publish" | "create_tag"; + document_id: string | null; + document_version: number | null; + tags: string[]; + reason: string; + source: { + kind: "github" | "notion" | "manual" | "schedule" | "other"; + ref: string; + }; +}; +``` + +**Rule:** a write without a `reason` and a `source` is rejected. This is +how "every write should explain itself" becomes enforceable rather than +aspirational. + +Reads are not audited per-call; they are aggregated. + +--- + +## 6. Plugin Lifecycle + +``` +register → install → authorize → configure → run → (revoke | uninstall) +``` + +- **register** — published to the Alignbase registry (first-party + bundled, third-party submitted, or custom installed by URL). +- **install** — user picks the plugin from the dashboard or an MCP tool. +- **authorize** — OAuth+PKCE flow. User narrows tags and approves + actions. +- **configure** — the plugin renders its `config_schema` as a form. +- **run** — manual, scheduled, or webhook-triggered. +- **revoke** — user removes the grant from the dashboard. +- **uninstall** — full removal. Audit history is retained. + +--- + +## 7. What's not included in v0.1 + +- Plugin-to-plugin communication. +- Plugins storing non-context data inside Alignbase. +- Real-time push from Alignbase to plugins (plugins poll or take + webhooks from their source system). +- A plugin builder UI inside the Alignbase dashboard. +- The plugin registry / marketplace itself — this spec defines the + contract a registry would serve, not the registry. diff --git a/package.json b/package.json new file mode 100644 index 0000000..f2f5267 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "alignbase-platform", + "private": true, + "version": "0.0.0", + "description": "Open-source platform layer for Alignbase: SDK and plugins.", + "license": "MIT", + "packageManager": "pnpm@9.15.4", + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "pnpm -r build", + "typecheck": "pnpm -r typecheck", + "test": "pnpm -r test", + "clean": "pnpm -r clean", + "get-token": "tsx scripts/get-token.ts" + }, + "devDependencies": { + "tsx": "^4.22.4" + } +} diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 0000000..62db125 --- /dev/null +++ b/packages/README.md @@ -0,0 +1,263 @@ +# Alignbase Platform Packages + +This folder holds the two packages that make up the open-source side of +Alignbase's plugin system. + +## TL;DR + +- **`sdk/`** — `@alignbase/sdk`. A TypeScript library plugin authors + import to talk to Alignbase. It hides the OAuth token handling, the + MCP transport, tag/permission preflight, optimistic-concurrency + writes, and the mandatory audit event on every write. +- **`plugin-github-sync/`** — `@alignbase/plugin-github-sync`. A + reference plugin built on the SDK. Pulls a GitHub repo's `README` and + `/docs/*.md` files and drafts them into Alignbase as context + documents. Runs the whole SDK end-to-end so if the SDK breaks, this + breaks first. + +Both packages target Node ≥ 20, are strict TypeScript, and ship +compiled `dist/` output. + +--- + +## How to run + +Everything below is from the repo root. + +### 1. Install and build + +```bash +pnpm install +pnpm build +``` + +This builds both packages. Output lands in `packages/*/dist`. + +### 2. Register a plugin client in Alignbase + +In the Alignbase dashboard: **Connect Agent → "Other"**. Copy the +`ab_client_...` ID it issues. Grant `create_context` and `edit_context`. + +(This "Other" workaround exists because the dashboard doesn't yet have +a dedicated Plugin install flow — see `ALIGNBASE-GAPS.md` item #1.) + +### 3. Get an access token + +```bash +pnpm get-token ab_client_YOUR_ID +``` + +A browser opens, you approve, the token prints to stdout. + +### 4. Set env vars for the plugin + +```bash +cat > packages/plugin-github-sync/.env < +EOF +``` + +### 5. Run the plugin + +```bash +set -a; source packages/plugin-github-sync/.env; set +a + +node packages/plugin-github-sync/dist/cli.js run \ + --repo / --tag github +``` + +You should see output like: + +``` +Fetching github.com//… + HEAD abcd123, 4 markdown files + README.md created (a1b2c3d4) + docs/intro.md created (e5f6g7h8) +Synced 4 files. 4 created, 0 updated, 0 skipped. +{"kind":"alignbase.audit","action":"create","reason":"Synced from ...",...} +``` + +Then check the Alignbase dashboard: new draft documents under the +`github` tag, each titled `/: `. + +Re-run the same command and every file should report `skipped` — the +plugin remembers what it synced in `~/.alignbase/github-sync/state.json`. + +--- + +## How to test + +Every package has unit tests. Run them all: + +```bash +pnpm test +``` + +Or one package at a time: + +```bash +pnpm --filter @alignbase/sdk test +pnpm --filter @alignbase/plugin-github-sync test +``` + +Type-check without building: + +```bash +pnpm typecheck +``` + +### What the tests cover + +- **`sdk/tests/auth.test.ts`** — token providers read from env/static + values correctly. +- **`sdk/tests/audit.test.ts`** — `AuditEventBuilder` produces + SPEC §5-shaped events with `reason` and `source` required. +- **`sdk/tests/mcp-client.test.ts`** — the JSON-RPC wire format is + correct, and the four different document shapes the MCP server + returns all normalize to the same `ContextDocument`. +- **`sdk/tests/plugin.test.ts`** — the high-level `Plugin` façade + enforces `permissions` and `tag_scopes` client-side, emits audit + events on writes, and retries on version conflicts inside + `withDocument`. +- **`plugin-github-sync/tests/sync.test.ts`** — the create / update / + skip decision (based on comparing GitHub blob SHAs with local state) + behaves correctly. + +### End-to-end test + +The "how to run" section above **is** the end-to-end test. If steps 1–5 +produce draft documents in your Alignbase workspace and a re-run reports +everything as `skipped`, the SDK + plugin work correctly against the +live MCP server. + +--- + +## Architecture + +### The idea + +Alignbase already has an MCP server and an OAuth flow that human agents +(Claude Code, Cursor, etc.) use to read and write context documents. +The plugin system reuses that same server — a plugin is just a +non-interactive OAuth client with a manifest, permissions, and an audit +trail. + +The SDK's job is to make writing a well-behaved plugin easy. The +GitHub Sync plugin's job is to prove the SDK is enough to build a real +one. + +### `packages/sdk` — layer by layer + +Files are stacked from low-level to high-level. Most plugin authors +touch only the top layer. + +``` +plugin.ts ← createPlugin() — the friendly façade + ├── mcp-client.ts ← JSON-RPC over HTTP to app.alignbase.ai/mcp + ├── audit.ts ← builds & sinks SPEC §5 audit events + ├── auth.ts ← where the bearer token comes from + ├── errors.ts ← typed error classes + └── types.ts ← ContextDocument, AuditEvent, etc. +``` + +**`types.ts`** — the wire types. Mirrors SPEC §3 (documents) and §5 +(audit events). Nothing runs; it's just the shared vocabulary. + +**`auth.ts`** — an `AuthProvider` interface with two implementations: +`EnvTokenAuth` (reads `ALIGNBASE_TOKEN` from `process.env`) and +`StaticTokenAuth` (whatever you pass in). Once Alignbase ships refresh +tokens (gap #3), a new `OAuthRefreshAuth` will slot in here without +changing anything else. + +**`errors.ts`** — `McpTransportError` (network died), +`McpServerError` (JSON-RPC error response), `McpProtocolError` +(malformed response), `PermissionError` (SDK preflight refused). +Typed so callers can `catch` the specific kind they care about. + +**`mcp-client.ts`** — the raw MCP client. Every call is a JSON-RPC +request with the bearer token attached. This is also where +`normalizeDocument` lives — the MCP server today returns four different +shapes for what should be the same object (see `ALIGNBASE-GAPS.md` +gap #7), and this function coerces them all into one canonical +`ContextDocument` so nothing above this layer has to care. + +**`audit.ts`** — `AuditEventBuilder` constructs events per SPEC §5 +(auto-fills `event_id`, `occurred_at`, `plugin`, `client_id`; requires +`reason` and `source` from the caller). `ConsoleAuditSink` writes them +as JSON to stderr. When Alignbase ships an audit ingestion endpoint +(gap #5), a `HttpAuditSink` will replace it. + +**`plugin.ts`** — the façade. `createPlugin({...})` resolves the token +eagerly (so bad config fails at startup, not mid-write) and returns a +`Plugin` with: + +- Read methods: `getCurrentContext`, `listDocuments`, `readDocument`. +- Write methods: `createDocument`, `editDocument`, `publishDocument` — + each requires `{ reason, source }` at the **type level**, which is + how SPEC §5's "every write must explain itself" becomes a + compile-time guarantee rather than a runtime check. +- `withDocument(id, mutate, audit)` — the read-modify-write loop with + optimistic concurrency. Reads `latest`, calls your `mutate` function + for the new content, writes back with `expected_version`. Retries on + version conflicts. Plugin authors get one line instead of + reimplementing this loop. + +Preflight: if you pass `permissions` and `tagScopes` to +`createPlugin`, the SDK refuses (locally, with a `PermissionError`) to +call actions or touch tags outside them. This is defense-in-depth — the +server is still the real gate, but users get a clean error before the +network call. + +### `packages/plugin-github-sync` — one real consumer + +``` +plugin.json ← manifest per SPEC §2 +src/ + cli.ts ← `run --repo owner/name --tag github` entrypoint + github.ts ← fetch README + /docs/*.md, return path/sha/content + state.ts ← ~/.alignbase/github-sync/state.json (path → last SHA) + sync.ts ← decide create / update / skip per file, call the SDK + index.ts ← programmatic export +``` + +The sync loop is small on purpose: `github.ts` gives you the current +state of the repo, `state.ts` gives you the last-synced state, `sync.ts` +diffs the two and asks the SDK to make the calls. The heavy lifting +(auth, transport, audit) is all in the SDK. + +The plugin declares `tag_scopes: ["github"]` in its manifest and +respects the `--tag` CLI flag as the narrowed runtime scope. The SDK +enforces this locally; once tag-scoped grants ship on the server (gap +#2), the server will enforce it too. + +### `scripts/get-token.ts` (at the repo root) + +A dev helper that runs the OAuth 2.1 + PKCE flow against +`app.alignbase.ai` and prints the resulting bearer token. It only +exists because refresh tokens aren't a thing yet — once they are, the +plugin will refresh silently and this script becomes unnecessary. + +--- + +## What was done + +- Set up a pnpm workspace with strict TypeScript, Node ≥ 20, and shared + `tsconfig.base.json`. +- Wrote `SPEC.md` — the plugin contract (manifest shape, MCP tools, + auth flow, audit events, lifecycle). +- Built the SDK end-to-end: types, errors, auth, MCP client with shape + normalization, audit event builder and sink, and the high-level + `Plugin` façade with permission/tag preflight and optimistic + read-modify-write. +- Built `github-sync` as a reference plugin that exercises the full + SDK against the real Alignbase MCP server. +- Wrote unit tests for every SDK module and for the sync decision + logic. +- Wrote `ALIGNBASE-GAPS.md` documenting the seven closed-source + changes still needed on the Alignbase server side, each with an + acceptance criterion. + +Verified end-to-end against `app.alignbase.ai` using only APIs that +exist today — no backend changes required to run the plugin. diff --git a/packages/plugin-github-sync/README.md b/packages/plugin-github-sync/README.md new file mode 100644 index 0000000..33fc1d2 --- /dev/null +++ b/packages/plugin-github-sync/README.md @@ -0,0 +1,86 @@ +# @alignbase/plugin-github-sync + +Drafts a GitHub repo's `README` and `/docs/*.md` files into Alignbase as +draft context documents. Re-runs are incremental — files whose blob SHA +hasn't changed are skipped. + +## v0.1 scope + +- Reads top-level `README*` and every `*.md` under `/docs`. +- Writes one draft document per file, titled `: `. +- Drafts only — does not publish. A human must approve publication. +- Manual trigger only. No webhook, no schedule. +- Auth to GitHub: Personal Access Token via env (dev path). A GitHub App + install is the production path; deferred to v1.0. + +## Install + +From this monorepo: + +```bash +pnpm install +pnpm --filter @alignbase/plugin-github-sync run build +``` + +## Run + +```bash +export GITHUB_TOKEN=ghp_xxx +export ALIGNBASE_TOKEN= +export ALIGNBASE_CLIENT_ID=ab_client_xxx + +node packages/plugin-github-sync/dist/cli.js run \ + --repo Sunpeak-AI/alignbase-platform \ + --tag github +``` + +Expected output: + +``` +Fetching github.com/Sunpeak-AI/alignbase-platform… + HEAD abc1234, 4 markdown files + README.md created (a1b2c3d4) + docs/intro.md created (e5f6g7h8) + docs/api.md unchanged (skipped) + docs/contributing.md updated (v2) +Synced 4 files. 2 created, 1 updated, 1 skipped. +``` + +Each created/edited document carries an audit event reading: + +``` +reason: "Synced from @: " +source: { kind: "github", ref: "@" } +``` + +## Where state lives + +`~/.alignbase/github-sync/state.json`. Maps `repo#path` to the last-synced +GitHub blob SHA. Delete to force a full re-sync. + +## How to get the tokens + +### GITHUB_TOKEN + +github.com → Settings → Developer settings → Personal access tokens → +Fine-grained tokens. Grant **Contents: Read-only** on the target repo. + +### ALIGNBASE_TOKEN and ALIGNBASE_CLIENT_ID + +In the Alignbase dashboard, Connect Agent → pick "Other" → copy the +issued `ab_client_...` ID (that's `ALIGNBASE_CLIENT_ID`). Authorize the +client through the in-browser OAuth flow, then extract the bearer token +from the client your browser used (e.g. Claude Code's local storage if +you connected through it). This is the v0.1 workaround — see the +ALIGNBASE-GAPS.md in the repo root for the proper plugin-install path +that Alignbase needs to ship. + +## Permissions + +This plugin declares: + +- `permissions`: `create_context`, `edit_context` +- `tag_scopes`: `["github"]` (narrowed at runtime via `--tag`) + +The SDK refuses, client-side, any write that targets a tag outside +`--tag`. The server enforces this independently. diff --git a/packages/plugin-github-sync/package.json b/packages/plugin-github-sync/package.json new file mode 100644 index 0000000..dec19b0 --- /dev/null +++ b/packages/plugin-github-sync/package.json @@ -0,0 +1,35 @@ +{ + "name": "@alignbase/plugin-github-sync", + "version": "0.1.0", + "description": "Drafts GitHub repo README and /docs markdown into Alignbase.", + "license": "MIT", + "type": "module", + "bin": { + "github-sync": "./dist/cli.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "plugin.json", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "@alignbase/sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^26.0.1", + "typescript": "^5.6.3", + "vitest": "^4.1.9" + } +} diff --git a/packages/plugin-github-sync/plugin.json b/packages/plugin-github-sync/plugin.json new file mode 100644 index 0000000..c4849fa --- /dev/null +++ b/packages/plugin-github-sync/plugin.json @@ -0,0 +1,14 @@ +{ + "spec_version": 1, + "name": "github-sync", + "version": "0.1.0", + "display_name": "GitHub Sync", + "description": "Drafts repo README and /docs markdown into Alignbase.", + "publisher": "alignbase", + "homepage": "https://github.com/Sunpeak-AI/alignbase-platform", + "entry": "dist/cli.js", + "runtime": "node>=20", + "permissions": ["create_context", "edit_context"], + "tag_scopes": ["github"], + "triggers": ["manual"] +} diff --git a/packages/plugin-github-sync/src/cli.ts b/packages/plugin-github-sync/src/cli.ts new file mode 100644 index 0000000..a35a319 --- /dev/null +++ b/packages/plugin-github-sync/src/cli.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** + * CLI entry — `github-sync run --repo owner/name --tag mytag`. + * + * Reads: + * GITHUB_TOKEN GitHub PAT with repo:read + * ALIGNBASE_TOKEN Alignbase OAuth access token (paste from dashboard) + * ALIGNBASE_CLIENT_ID OAuth client ID issued for this plugin install + */ + +import { run } from "./index.js"; + +type Args = { command: string; flags: Record }; + +function parseArgs(argv: string[]): Args { + const [command = "help", ...rest] = argv; + const flags: Record = {}; + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]!; + if (!arg.startsWith("--")) continue; + const key = arg.slice(2); + const next = rest[i + 1]; + if (next === undefined || next.startsWith("--")) { + flags[key] = "true"; + } else { + flags[key] = next; + i++; + } + } + return { command, flags }; +} + +function printHelp(): void { + console.log( + [ + "Usage: github-sync run --repo --tag ", + "", + "Env vars:", + " GITHUB_TOKEN GitHub PAT (Contents: read on the target repo)", + " ALIGNBASE_TOKEN Alignbase OAuth access token", + " ALIGNBASE_CLIENT_ID OAuth client ID for this plugin install", + "", + "Examples:", + " github-sync run --repo Sunpeak-AI/alignbase-platform --tag github", + ].join("\n"), + ); +} + +async function main(): Promise { + const { command, flags } = parseArgs(process.argv.slice(2)); + + if (command === "help" || command === "--help" || command === "-h") { + printHelp(); + return; + } + + if (command !== "run") { + console.error(`Unknown command: ${command}`); + printHelp(); + process.exit(2); + } + + const repo = flags["repo"]; + const tag = flags["tag"]; + const clientId = process.env["ALIGNBASE_CLIENT_ID"]; + + if (!repo || !tag) { + console.error("Missing required flags: --repo, --tag"); + printHelp(); + process.exit(2); + } + if (!clientId) { + console.error("ALIGNBASE_CLIENT_ID env var is not set."); + process.exit(2); + } + + await run({ repo, tag, clientId }); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/packages/plugin-github-sync/src/github.ts b/packages/plugin-github-sync/src/github.ts new file mode 100644 index 0000000..721bfe9 --- /dev/null +++ b/packages/plugin-github-sync/src/github.ts @@ -0,0 +1,140 @@ +/** + * GitHub REST API client — fetches README and /docs markdown. + * + * Auth: a personal access token via env (GITHUB_TOKEN). v0.1 dev path. + * A real production GitHub App is the v1.0 plan (see README). + * + * Uses built-in fetch. No Octokit dep — we use ~3 endpoints, not the + * whole API surface. + */ + +const GITHUB_API = "https://api.github.com"; + +export type RepoRef = { + /** "owner/name" */ + full: string; + owner: string; + name: string; +}; + +export type FetchedFile = { + /** Path relative to repo root, e.g. "README.md" or "docs/intro.md". */ + path: string; + /** UTF-8 file content. */ + content: string; + /** Blob SHA (GitHub's content hash). */ + sha: string; +}; + +export type FetchResult = { + /** Commit SHA of the repo's default branch at fetch time. */ + headSha: string; + files: FetchedFile[]; +}; + +export function parseRepo(full: string): RepoRef { + const match = full.match(/^([^/]+)\/([^/]+)$/); + if (!match) { + throw new Error(`Invalid --repo: "${full}". Expected "owner/name".`); + } + return { full, owner: match[1]!, name: match[2]! }; +} + +export class GitHubClient { + constructor( + private readonly token: string, + private readonly fetchImpl: typeof fetch = fetch, + ) {} + + private headers(): Record { + return { + authorization: `Bearer ${this.token}`, + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + "user-agent": "alignbase-github-sync/0.1.0", + }; + } + + private async json(url: string): Promise { + const res = await this.fetchImpl(url, { headers: this.headers() }); + if (!res.ok) { + throw new Error(`GitHub ${res.status} on ${url}: ${await res.text()}`); + } + return (await res.json()) as T; + } + + /** Resolve the repo's default branch HEAD commit SHA. */ + async getHeadSha(repo: RepoRef): Promise { + const data = await this.json<{ default_branch: string }>( + `${GITHUB_API}/repos/${repo.full}`, + ); + const branch = await this.json<{ commit: { sha: string } }>( + `${GITHUB_API}/repos/${repo.full}/branches/${data.default_branch}`, + ); + return branch.commit.sha; + } + + /** + * List all paths in the repo tree at a given commit. Recursive: true + * gives every blob in one call (truncated past 100k entries — fine for + * docs use cases). + */ + async listTree( + repo: RepoRef, + sha: string, + ): Promise> { + const data = await this.json<{ + tree: Array<{ path: string; type: string; sha: string }>; + truncated: boolean; + }>(`${GITHUB_API}/repos/${repo.full}/git/trees/${sha}?recursive=1`); + if (data.truncated) { + console.warn( + "[github-sync] repo tree was truncated; some files may be skipped.", + ); + } + return data.tree; + } + + /** Fetch a single file's content as UTF-8. */ + async getBlob(repo: RepoRef, sha: string): Promise { + const data = await this.json<{ content: string; encoding: string }>( + `${GITHUB_API}/repos/${repo.full}/git/blobs/${sha}`, + ); + if (data.encoding !== "base64") { + throw new Error(`Unexpected blob encoding: ${data.encoding}`); + } + return Buffer.from(data.content, "base64").toString("utf8"); + } + + /** + * Fetch README (any case/extension) + every `*.md` under `/docs`. + * Returns the files plus the HEAD SHA they were fetched at. + */ + async fetchSyncTargets(repo: RepoRef): Promise { + const headSha = await this.getHeadSha(repo); + const tree = await this.listTree(repo, headSha); + + const wanted = tree.filter( + (entry) => + entry.type === "blob" && + (isTopLevelReadme(entry.path) || isDocsMarkdown(entry.path)), + ); + + const files: FetchedFile[] = []; + for (const entry of wanted) { + const content = await this.getBlob(repo, entry.sha); + files.push({ path: entry.path, content, sha: entry.sha }); + } + + return { headSha, files }; + } +} + +function isTopLevelReadme(path: string): boolean { + if (path.includes("/")) return false; + return /^readme(\.\w+)?$/i.test(path); +} + +function isDocsMarkdown(path: string): boolean { + return /^docs\/.*\.md$/i.test(path); +} diff --git a/packages/plugin-github-sync/src/index.ts b/packages/plugin-github-sync/src/index.ts new file mode 100644 index 0000000..5c8e524 --- /dev/null +++ b/packages/plugin-github-sync/src/index.ts @@ -0,0 +1,72 @@ +/** + * Programmatic entry — `run({ repo, tag })` for callers who want to + * embed the sync instead of running the CLI. + */ + +import { createPlugin, EnvTokenAuth } from "@alignbase/sdk"; + +import { GitHubClient, parseRepo } from "./github.js"; +import { loadState, saveState } from "./state.js"; +import { applySync, planSync, type SyncSummary } from "./sync.js"; + +export type RunOptions = { + /** "owner/name" */ + repo: string; + /** Alignbase tag to write into. */ + tag: string; + /** OAuth client ID for this plugin install (ab_client_...). */ + clientId: string; + /** Override env var names if needed. */ + githubTokenEnv?: string; + alignbaseTokenEnv?: string; +}; + +export async function run(opts: RunOptions): Promise { + const repo = parseRepo(opts.repo); + + const githubToken = process.env[opts.githubTokenEnv ?? "GITHUB_TOKEN"]; + if (!githubToken) { + throw new Error( + `${opts.githubTokenEnv ?? "GITHUB_TOKEN"} is not set. ` + + `Create a PAT with repo:read scope and export it.`, + ); + } + + const plugin = await createPlugin({ + name: "github-sync", + version: "0.1.0", + clientId: opts.clientId, + auth: new EnvTokenAuth(opts.alignbaseTokenEnv ?? "ALIGNBASE_TOKEN"), + permissions: ["create_context", "edit_context"], + tagScopes: [opts.tag], + }); + + const github = new GitHubClient(githubToken); + + console.log(`Fetching github.com/${repo.full}…`); + const { headSha, files } = await github.fetchSyncTargets(repo); + console.log(` HEAD ${headSha.slice(0, 7)}, ${files.length} markdown files`); + + const existing = await plugin.listDocuments(); + const inTag = existing.filter((d) => d.tags.includes(opts.tag)); + + const state = await loadState(); + const decisions = planSync(repo, files, inTag, state); + + const summary = await applySync( + plugin, + repo, + opts.tag, + headSha, + decisions, + state, + ); + + await saveState(state); + + console.log( + `Synced ${files.length} files. ${summary.created} created, ` + + `${summary.edited} updated, ${summary.skipped} skipped.`, + ); + return summary; +} diff --git a/packages/plugin-github-sync/src/state.ts b/packages/plugin-github-sync/src/state.ts new file mode 100644 index 0000000..d3ed606 --- /dev/null +++ b/packages/plugin-github-sync/src/state.ts @@ -0,0 +1,42 @@ +/** + * Local state — what was synced last time. Lets re-runs skip unchanged + * files without round-tripping to Alignbase to read each doc. + * + * Lives at ~/.alignbase/github-sync/state.json. Schema is intentionally + * small: keyed by `${repo}#${path}`, value is the GitHub blob SHA we + * last synced. If the SHA matches, the file is unchanged → skip. + * + * SPEC §1: plugins store their own state, not in Alignbase context. + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +const STATE_PATH = join(homedir(), ".alignbase", "github-sync", "state.json"); + +export type SyncState = { + /** Map of `${repo}#${path}` → last-synced GitHub blob SHA. */ + files: Record; +}; + +export async function loadState(): Promise { + try { + const raw = await readFile(STATE_PATH, "utf8"); + return JSON.parse(raw) as SyncState; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return { files: {} }; + } + throw err; + } +} + +export async function saveState(state: SyncState): Promise { + await mkdir(dirname(STATE_PATH), { recursive: true }); + await writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); +} + +export function stateKey(repo: string, path: string): string { + return `${repo}#${path}`; +} diff --git a/packages/plugin-github-sync/src/sync.ts b/packages/plugin-github-sync/src/sync.ts new file mode 100644 index 0000000..b3e8678 --- /dev/null +++ b/packages/plugin-github-sync/src/sync.ts @@ -0,0 +1,112 @@ +/** + * Sync logic — for each fetched file, decide: create, edit, or skip. + * + * This is the part with actual behavior worth testing. Kept pure (no + * I/O) so the diff decisions can be unit-tested without mocks. + */ + +import type { ContextDocument, Plugin } from "@alignbase/sdk"; + +import type { FetchedFile, RepoRef } from "./github.js"; +import { stateKey, type SyncState } from "./state.js"; + +export type SyncDecision = + | { kind: "create"; file: FetchedFile } + | { kind: "edit"; file: FetchedFile; existing: ContextDocument } + | { kind: "skip"; file: FetchedFile; reason: "unchanged" }; + +export type SyncSummary = { + created: number; + edited: number; + skipped: number; +}; + +/** + * Decide what to do with each fetched file, given the existing docs in + * Alignbase under the target tag and the local sync state. + * + * - If state says we synced this SHA before → skip. + * - If a doc with the same title exists → edit (replace content). + * - Otherwise → create. + */ +export function planSync( + repo: RepoRef, + files: FetchedFile[], + existing: ContextDocument[], + state: SyncState, +): SyncDecision[] { + const byTitle = new Map(existing.map((d) => [d.title, d])); + return files.map((file): SyncDecision => { + const title = docTitleFor(repo, file.path); + const lastSynced = state.files[stateKey(repo.full, file.path)]; + if (lastSynced === file.sha) { + return { kind: "skip", file, reason: "unchanged" }; + } + const match = byTitle.get(title); + if (match) { + return { kind: "edit", file, existing: match }; + } + return { kind: "create", file }; + }); +} + +/** Document title format. Stable so we can match across runs. */ +export function docTitleFor(repo: RepoRef, path: string): string { + return `${repo.full}: ${path}`; +} + +/** + * Apply the planned decisions via the SDK. Mutates `state` in place so + * the caller can persist it after. + */ +export async function applySync( + plugin: Plugin, + repo: RepoRef, + tag: string, + headSha: string, + decisions: SyncDecision[], + state: SyncState, +): Promise { + const summary: SyncSummary = { created: 0, edited: 0, skipped: 0 }; + + for (const decision of decisions) { + const key = stateKey(repo.full, decision.file.path); + const audit = { + reason: `Synced from ${repo.full}@${headSha.slice(0, 7)}: ${decision.file.path}`, + source: { kind: "github" as const, ref: `${repo.full}@${headSha}` }, + }; + + if (decision.kind === "skip") { + summary.skipped++; + console.log(` ${decision.file.path} unchanged (skipped)`); + continue; + } + + if (decision.kind === "create") { + const created = await plugin.createDocument({ + title: docTitleFor(repo, decision.file.path), + content: decision.file.content, + tags: [tag], + ...audit, + }); + state.files[key] = decision.file.sha; + summary.created++; + console.log(` ${decision.file.path} created (${created.id.slice(0, 8)})`); + continue; + } + + // edit + const updated = await plugin.withDocument( + decision.existing.id, + () => decision.file.content, + audit, + ); + state.files[key] = decision.file.sha; + summary.edited++; + console.log( + ` ${decision.file.path} updated (v${updated.latest_version})`, + ); + } + + return summary; +} diff --git a/packages/plugin-github-sync/tests/sync.test.ts b/packages/plugin-github-sync/tests/sync.test.ts new file mode 100644 index 0000000..c59e913 --- /dev/null +++ b/packages/plugin-github-sync/tests/sync.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import type { FetchedFile, RepoRef } from "../src/github.js"; +import { docTitleFor, planSync } from "../src/sync.js"; +import type { SyncState } from "../src/state.js"; + +const repo: RepoRef = { + full: "Sunpeak-AI/alignbase-platform", + owner: "Sunpeak-AI", + name: "alignbase-platform", +}; + +const file = (path: string, sha: string, content = "x"): FetchedFile => ({ + path, + sha, + content, +}); + +function emptyState(): SyncState { + return { files: {} }; +} + +describe("planSync", () => { + it("creates a doc when none exists for the title", () => { + const decisions = planSync(repo, [file("README.md", "sha1")], [], emptyState()); + expect(decisions).toHaveLength(1); + expect(decisions[0]!.kind).toBe("create"); + }); + + it("edits an existing doc with the same title", () => { + const title = docTitleFor(repo, "README.md"); + const existing = [ + { + id: "doc-1", + title, + tags: ["github"], + content: "old", + current_version: 1, + latest_version: 1, + published_version: null, + publication_status: "draft" as const, + has_unpublished_changes: false, + status: "live" as const, + expires_at: null, + review_by: null, + can_read: true, + can_edit: true, + can_publish: false, + created_at: "", + updated_at: "", + }, + ]; + const decisions = planSync(repo, [file("README.md", "sha-new")], existing, emptyState()); + expect(decisions[0]!.kind).toBe("edit"); + if (decisions[0]!.kind === "edit") { + expect(decisions[0]!.existing.id).toBe("doc-1"); + } + }); + + it("skips when state shows the same blob SHA was synced before", () => { + const state: SyncState = { + files: { [`${repo.full}#README.md`]: "sha1" }, + }; + const decisions = planSync(repo, [file("README.md", "sha1")], [], state); + expect(decisions[0]!.kind).toBe("skip"); + }); + + it("does not skip when the blob SHA has changed", () => { + const state: SyncState = { + files: { [`${repo.full}#README.md`]: "sha-old" }, + }; + const decisions = planSync(repo, [file("README.md", "sha-new")], [], state); + expect(decisions[0]!.kind).toBe("create"); + }); +}); diff --git a/packages/plugin-github-sync/tsconfig.json b/packages/plugin-github-sync/tsconfig.json new file mode 100644 index 0000000..c2104f6 --- /dev/null +++ b/packages/plugin-github-sync/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 0000000..925e185 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,34 @@ +{ + "name": "@alignbase/sdk", + "version": "0.0.1", + "description": "TypeScript SDK for building Alignbase plugins.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@types/node": "^26.0.1", + "typescript": "^5.6.3", + "vitest": "^4.1.9" + } +} diff --git a/packages/sdk/src/audit.ts b/packages/sdk/src/audit.ts new file mode 100644 index 0000000..c2ff478 --- /dev/null +++ b/packages/sdk/src/audit.ts @@ -0,0 +1,119 @@ +/** + * Audit layer — SPEC §5. + * + * Every write a plugin performs must produce an `AuditEvent`. The plugin + * supplies the *why* (`reason`, `source`); the SDK supplies the *what* + * (plugin identity, client_id, document_id, version, timestamp, etc.). + * + * Where audit events end up is a closed-source decision Alignbase hasn't + * made yet (see ALIGNBASE-GAPS.md item 5). The SDK ships an `AuditSink` + * interface with a default `ConsoleAuditSink`. When Alignbase exposes a + * real audit endpoint, a new sink can be added without touching plugin + * code. + */ + +import { randomUUID } from "node:crypto"; + +import type { + AuditAction, + AuditEvent, + WriteAuditInput, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Sink +// --------------------------------------------------------------------------- + +/** Where audit events go. Inject a different sink to send them elsewhere. */ +export interface AuditSink { + record(event: AuditEvent): Promise | void; +} + +/** Default sink. Logs structured JSON to stderr; suitable for v0.1. */ +export class ConsoleAuditSink implements AuditSink { + record(event: AuditEvent): void { + console.error(JSON.stringify({ kind: "alignbase.audit", ...event })); + } +} + +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +export type AuditBuilderOptions = { + /** The plugin's identity, from its manifest. */ + plugin: { name: string; version: string }; + /** OAuth client ID issued to this install (ab_client_...). */ + clientId: string; + /** Where to send events. Defaults to ConsoleAuditSink. */ + sink?: AuditSink; + /** Override clock for testing. */ + now?: () => Date; + /** Override id generator for testing. */ + newId?: () => string; +}; + +/** + * Builds and ships audit events. One instance per plugin process. + * + * Used internally by the plugin façade — plugin authors should never + * touch this directly. They supply `{ reason, source }` on each write + * and the façade routes through here. + */ +export class AuditEventBuilder { + private readonly plugin: { name: string; version: string }; + private readonly clientId: string; + private readonly sink: AuditSink; + private readonly now: () => Date; + private readonly newId: () => string; + + constructor(opts: AuditBuilderOptions) { + this.plugin = opts.plugin; + this.clientId = opts.clientId; + this.sink = opts.sink ?? new ConsoleAuditSink(); + this.now = opts.now ?? (() => new Date()); + this.newId = opts.newId ?? (() => randomUUID()); + } + + /** + * Build an audit event from the plugin's supplied `{ reason, source }` + * plus the runtime context of the write that just happened. + */ + build( + action: AuditAction, + audit: WriteAuditInput, + context: { + documentId: string | null; + documentVersion: number | null; + tags: string[]; + }, + ): AuditEvent { + return { + event_id: this.newId(), + occurred_at: this.now().toISOString(), + plugin: this.plugin, + client_id: this.clientId, + action, + document_id: context.documentId, + document_version: context.documentVersion, + tags: context.tags, + reason: audit.reason, + source: audit.source, + }; + } + + /** Build the event and ship it. */ + async record( + action: AuditAction, + audit: WriteAuditInput, + context: { + documentId: string | null; + documentVersion: number | null; + tags: string[]; + }, + ): Promise { + const event = this.build(action, audit, context); + await this.sink.record(event); + return event; + } +} diff --git a/packages/sdk/src/auth.ts b/packages/sdk/src/auth.ts new file mode 100644 index 0000000..618f86d --- /dev/null +++ b/packages/sdk/src/auth.ts @@ -0,0 +1,39 @@ +/** + * Auth providers — produce the bearer token the MCP client attaches. + * + * The interface exists so the SDK can grow real OAuth+PKCE later without + * changing plugin code. v0.1 ships exactly one implementation: + * `EnvTokenAuth`, which reads `ALIGNBASE_TOKEN` from the environment. + * + * See ALIGNBASE-GAPS.md item 3 — non-interactive refresh tokens are an + * Alignbase-side prerequisite for a real OAuth implementation here. + */ + +export interface AuthProvider { + /** Return a bearer token to attach to MCP requests. */ + getToken(): Promise | string; +} + +/** Reads the token from an environment variable. v0.1 default. */ +export class EnvTokenAuth implements AuthProvider { + constructor(private readonly varName = "ALIGNBASE_TOKEN") {} + + getToken(): string { + const token = process.env[this.varName]; + if (!token) { + throw new Error( + `${this.varName} is not set. Get a token from the Alignbase ` + + `dashboard (Connect Agent flow) and export it before running.`, + ); + } + return token; + } +} + +/** Hard-coded token, mainly for tests. */ +export class StaticTokenAuth implements AuthProvider { + constructor(private readonly token: string) {} + getToken(): string { + return this.token; + } +} diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts new file mode 100644 index 0000000..9310752 --- /dev/null +++ b/packages/sdk/src/errors.ts @@ -0,0 +1,53 @@ +/** + * Error classes thrown by the SDK. Plugin authors can `instanceof`-check + * these to handle failure modes distinctly. + */ + +/** Thrown when the HTTP request itself fails (network, non-2xx, etc.). */ +export class McpTransportError extends Error { + override readonly name = "McpTransportError"; + constructor( + message: string, + readonly status?: number, + override readonly cause?: unknown, + ) { + super(message); + } +} + +/** Thrown when the server returns a JSON-RPC error envelope. */ +export class McpServerError extends Error { + override readonly name = "McpServerError"; + constructor( + message: string, + readonly code: number, + readonly data?: unknown, + ) { + super(message); + } +} + +/** Thrown when the response is shaped unexpectedly (not a server error, + * but the SDK can't parse it). */ +export class McpProtocolError extends Error { + override readonly name = "McpProtocolError"; + constructor( + message: string, + readonly response?: unknown, + ) { + super(message); + } +} + +/** + * Thrown by the SDK's client-side preflight before a request is sent, + * when the requested action or tag is outside the plugin's declared + * manifest scopes. This is a guard rail to fail fast with a clear + * message rather than waiting for the server's 403. + */ +export class PermissionError extends Error { + override readonly name = "PermissionError"; + constructor(message: string) { + super(message); + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 0000000..4c34c67 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,39 @@ +export const SDK_VERSION = "0.0.1"; + +// Types +export type { + ContextDocument, + PublicationStatus, + DocumentStatus, + VersionSelector, + PermissionAction, + AuditEvent, + AuditAction, + AuditSource, + AuditSourceKind, + WriteAuditInput, +} from "./types.js"; + +// MCP client (low level — most plugins won't touch this directly) +export { McpClient } from "./mcp-client.js"; +export type { McpClientOptions, ListDocumentsResponse } from "./mcp-client.js"; + +// Errors +export { + McpTransportError, + McpServerError, + McpProtocolError, + PermissionError, +} from "./errors.js"; + +// Auth +export { EnvTokenAuth, StaticTokenAuth } from "./auth.js"; +export type { AuthProvider } from "./auth.js"; + +// Audit +export { AuditEventBuilder, ConsoleAuditSink } from "./audit.js"; +export type { AuditSink, AuditBuilderOptions } from "./audit.js"; + +// Plugin façade (what most plugin authors use) +export { createPlugin, Plugin } from "./plugin.js"; +export type { CreatePluginOptions } from "./plugin.js"; diff --git a/packages/sdk/src/mcp-client.ts b/packages/sdk/src/mcp-client.ts new file mode 100644 index 0000000..09e0bc2 --- /dev/null +++ b/packages/sdk/src/mcp-client.ts @@ -0,0 +1,295 @@ +/** + * Low-level MCP client for Alignbase. + * + * Speaks JSON-RPC 2.0 over HTTPS to the MCP endpoint + * (e.g. https://app.alignbase.ai/mcp). Auth is a bearer token attached to + * every request; how that token was obtained (OAuth, env var, etc.) is + * none of this layer's business — the constructor just takes it. + * + * Each public method maps to one Alignbase MCP tool listed in SPEC §3. + */ + +import { + McpProtocolError, + McpServerError, + McpTransportError, +} from "./errors.js"; +import type { ContextDocument, VersionSelector } from "./types.js"; + +// --------------------------------------------------------------------------- +// JSON-RPC envelope types +// --------------------------------------------------------------------------- + +type JsonRpcRequest = { + jsonrpc: "2.0"; + id: number; + method: string; + params: unknown; +}; + +type JsonRpcSuccess = { jsonrpc: "2.0"; id: number; result: T }; +type JsonRpcFailure = { + jsonrpc: "2.0"; + id: number; + error: { code: number; message: string; data?: unknown }; +}; +type JsonRpcResponse = JsonRpcSuccess | JsonRpcFailure; + +/** + * Standard MCP tool result shape: `{ content: [{ type: "text", text: "..." }] }`. + * Alignbase tools return either plain text (markdown) or a JSON-encoded + * string inside `text`. + */ +type ToolResult = { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +}; + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +export type McpClientOptions = { + /** MCP endpoint URL. Default: https://app.alignbase.ai/mcp */ + endpoint?: string; + /** Bearer token attached to every request. */ + token: string; + /** Override `fetch` for testing. */ + fetch?: typeof fetch; +}; + +export class McpClient { + private readonly endpoint: string; + private readonly token: string; + private readonly fetchImpl: typeof fetch; + private nextId = 1; + + constructor(opts: McpClientOptions) { + this.endpoint = opts.endpoint ?? "https://app.alignbase.ai/mcp"; + this.token = opts.token; + this.fetchImpl = opts.fetch ?? fetch; + } + + // ------------------------------------------------------------------------- + // Public tool wrappers — one per Alignbase MCP tool in SPEC §3. + // ------------------------------------------------------------------------- + + /** Returns the published context routed to the calling identity, as markdown. */ + async getCurrentContext(): Promise { + return this.callTool("get_current_context", {}); + } + + /** Lists documents the caller can read. */ + async listContextDocuments(): Promise { + const raw = await this.callToolJson<{ documents: unknown[] }>( + "list_context_documents", + {}, + ); + return { documents: raw.documents.map(normalizeDocument) }; + } + + /** Reads one document at a specific version. */ + async readContextDocument( + documentId: string, + version: VersionSelector = "latest", + ): Promise { + const raw = await this.callToolJson("read_context_document", { + document_id: documentId, + version, + }); + return normalizeDocument(raw); + } + + /** Creates a new draft document. */ + async createContextDocument(args: { + title: string; + content: string; + tags: string[]; + }): Promise { + const raw = await this.callToolJson("create_context_document", args); + return normalizeDocument(raw); + } + + /** + * Replaces document content. Pass `expected_version` for optimistic + * concurrency — should be the `latest_version` you just read. + */ + async writeContextDocument(args: { + document_id: string; + content: string; + expected_version: number; + }): Promise { + const raw = await this.callToolJson("write_context_document", args); + return normalizeDocument(raw); + } + + /** Promotes a saved version to the published version. */ + async publishContextDocument(args: { + document_id: string; + version: number; + }): Promise { + const raw = await this.callToolJson("publish_context_document", args); + return normalizeDocument(raw); + } + + // ------------------------------------------------------------------------- + // Transport — JSON-RPC over HTTPS + // ------------------------------------------------------------------------- + + /** Calls a tool and returns the joined `text` content as a raw string. */ + private async callTool(name: string, args: unknown): Promise { + const result = await this.rpc("tools/call", { + name, + arguments: args, + }); + + if (result.isError) { + const text = this.joinText(result); + throw new McpServerError(`Tool '${name}' failed: ${text}`, -32000); + } + + return this.joinText(result); + } + + /** Calls a tool and parses the text content as JSON. */ + private async callToolJson(name: string, args: unknown): Promise { + const text = await this.callTool(name, args); + if (process.env["ALIGNBASE_SDK_DEBUG"]) { + console.error(`[mcp-debug] ${name} →`, text); + } + try { + return JSON.parse(text) as T; + } catch (cause) { + throw new McpProtocolError( + `Tool '${name}' returned non-JSON content`, + text, + ); + } + } + + private joinText(result: ToolResult): string { + return result.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join(""); + } + + private async rpc(method: string, params: unknown): Promise { + const id = this.nextId++; + const body: JsonRpcRequest = { jsonrpc: "2.0", id, method, params }; + + let response: Response; + try { + response = await this.fetchImpl(this.endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + authorization: `Bearer ${this.token}`, + }, + body: JSON.stringify(body), + }); + } catch (cause) { + throw new McpTransportError( + `Request to ${this.endpoint} failed`, + undefined, + cause, + ); + } + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new McpTransportError( + `MCP server returned HTTP ${response.status}: ${body || "(no body)"}`, + response.status, + ); + } + + let envelope: JsonRpcResponse; + try { + envelope = (await response.json()) as JsonRpcResponse; + } catch (cause) { + throw new McpProtocolError("Response was not valid JSON", cause); + } + + if ("error" in envelope) { + throw new McpServerError( + envelope.error.message, + envelope.error.code, + envelope.error.data, + ); + } + + return envelope.result; + } +} + +// --------------------------------------------------------------------------- +// Response shapes for tools whose return type isn't already in types.ts +// --------------------------------------------------------------------------- + +export type ListDocumentsResponse = { + documents: ContextDocument[]; +}; + +// --------------------------------------------------------------------------- +// Response normalization +// +// Alignbase tools return slightly different shapes depending on which one +// you called: +// - list → `id`, `latest_version`, `current_version` +// - read → both `id` and `document_id`; both `latest_version` and `version` +// - write → minimal: `document_id`, `version`, `version_id` (no `id`) +// - create → similar to write +// This normalizer collapses them into the canonical ContextDocument the +// rest of the SDK expects. Missing fields default to safe values rather +// than crashing the caller — partial responses from write/create still +// give useful context (id, version, tags) without forcing a re-read. +// --------------------------------------------------------------------------- + +function normalizeDocument(raw: unknown): ContextDocument { + const r = (raw ?? {}) as Record; + const get = (key: string): T | undefined => r[key] as T | undefined; + + const id = (get("id") ?? get("document_id") ?? "") as string; + const latestVersion = (get("latest_version") ?? + get("version") ?? + 0) as number; + const currentVersion = (get("current_version") ?? + latestVersion) as number; + + return { + id, + title: (get("title") ?? "") as string, + tags: (get("tags") ?? []) as string[], + content: (get("content") ?? "") as string, + + current_version: currentVersion, + latest_version: latestVersion, + published_version: (get("published_version") ?? null) as + | number + | null, + + publication_status: + (get("publication_status") as + | "draft" + | "published" + | "unpublished" + | undefined) ?? "draft", + has_unpublished_changes: (get("has_unpublished_changes") ?? + false) as boolean, + status: + (get("status") as "live" | "stale" | "expired" | undefined) ?? + "live", + + expires_at: (get("expires_at") ?? null) as string | null, + review_by: (get("review_by") ?? null) as string | null, + + can_read: (get("can_read") ?? true) as boolean, + can_edit: (get("can_edit") ?? false) as boolean, + can_publish: (get("can_publish") ?? false) as boolean, + + created_at: (get("created_at") ?? "") as string, + updated_at: (get("updated_at") ?? "") as string, + }; +} diff --git a/packages/sdk/src/plugin.ts b/packages/sdk/src/plugin.ts new file mode 100644 index 0000000..7c32771 --- /dev/null +++ b/packages/sdk/src/plugin.ts @@ -0,0 +1,269 @@ +/** + * Plugin façade — the public API plugin authors use. + * + * Wraps: + * - `McpClient` (transport) + * - `AuditEventBuilder` (audit) + * - `AuthProvider` (token source) + * + * Read methods are thin passthroughs to the MCP client. + * Write methods require `{ reason, source }` at the type level — that's + * how SPEC §5 becomes a compile-time guarantee, not a runtime check. + * + * `withDocument` handles the read-modify-write loop with optimistic + * concurrency, so plugins don't reimplement it. + */ + +import { AuditEventBuilder, ConsoleAuditSink, type AuditSink } from "./audit.js"; +import type { AuthProvider } from "./auth.js"; +import { EnvTokenAuth } from "./auth.js"; +import { McpServerError, PermissionError } from "./errors.js"; +import { McpClient } from "./mcp-client.js"; +import type { + ContextDocument, + PermissionAction, + VersionSelector, + WriteAuditInput, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Public factory +// --------------------------------------------------------------------------- + +export type CreatePluginOptions = { + /** Plugin manifest identity. */ + name: string; + version: string; + /** + * OAuth client ID issued to this install (`ab_client_...`). + * Until plugin-OAuth ships (ALIGNBASE-GAPS.md #1), this is supplied + * manually alongside the token. After plugin-OAuth, the AuthProvider + * will provide both. + */ + clientId: string; + /** How to obtain the bearer token. Default: `EnvTokenAuth`. */ + auth?: AuthProvider; + /** Override MCP endpoint. Default: https://app.alignbase.ai/mcp */ + endpoint?: string; + /** Where audit events are sent. Default: `ConsoleAuditSink`. */ + auditSink?: AuditSink; + /** Inject fetch for testing. */ + fetch?: typeof fetch; + /** + * Manifest `permissions` — the actions this plugin declared it would + * use. The SDK refuses to send requests that need an action not in + * this list. Omit to skip the check (e.g. for one-off scripts). + */ + permissions?: PermissionAction[]; + /** + * Manifest `tag_scopes` — the maximum set of tags this plugin will + * touch. The SDK refuses writes that target a tag outside this list. + * Omit to skip the check. + * + * Note: the SDK can only check tags that pass through it (the `tags` + * arg on create, the resolved doc tags on `withDocument`). Bare + * `editDocument` / `publishDocument` calls have no `tags` arg, so + * preflight defers to the server's 403 — see ALIGNBASE-GAPS.md #6. + */ + tagScopes?: string[]; +}; + +/** + * Construct a Plugin. Resolves the auth token eagerly so misconfiguration + * (missing env var, etc.) fails fast at startup rather than mid-write. + */ +export async function createPlugin(opts: CreatePluginOptions): Promise { + const auth = opts.auth ?? new EnvTokenAuth(); + const token = await auth.getToken(); + + const client = new McpClient({ + token, + ...(opts.endpoint !== undefined ? { endpoint: opts.endpoint } : {}), + ...(opts.fetch !== undefined ? { fetch: opts.fetch } : {}), + }); + + const audit = new AuditEventBuilder({ + plugin: { name: opts.name, version: opts.version }, + clientId: opts.clientId, + sink: opts.auditSink ?? new ConsoleAuditSink(), + }); + + return new Plugin(client, audit, { + permissions: opts.permissions, + tagScopes: opts.tagScopes, + }); +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +const MAX_VERSION_RETRIES = 3; + +type PluginScopes = { + permissions: PermissionAction[] | undefined; + tagScopes: string[] | undefined; +}; + +export class Plugin { + constructor( + private readonly client: McpClient, + private readonly audit: AuditEventBuilder, + private readonly scopes: PluginScopes = { + permissions: undefined, + tagScopes: undefined, + }, + ) {} + + // ----- Preflight --------------------------------------------------------- + + private assertAction(action: PermissionAction): void { + if (this.scopes.permissions === undefined) return; + if (!this.scopes.permissions.includes(action)) { + throw new PermissionError( + `Plugin manifest does not declare '${action}'. ` + + `Declared: [${this.scopes.permissions.join(", ")}].`, + ); + } + } + + private assertTags(tags: readonly string[]): void { + if (this.scopes.tagScopes === undefined) return; + const allowed = new Set(this.scopes.tagScopes); + const offending = tags.filter((t) => !allowed.has(t)); + if (offending.length > 0) { + throw new PermissionError( + `Tags [${offending.join(", ")}] are outside this plugin's ` + + `tag_scopes [${this.scopes.tagScopes.join(", ")}].`, + ); + } + } + + // ----- Reads (no audit) -------------------------------------------------- + + getCurrentContext(): Promise { + return this.client.getCurrentContext(); + } + + async listDocuments(): Promise { + const { documents } = await this.client.listContextDocuments(); + return documents; + } + + readDocument(id: string, version: VersionSelector = "latest"): Promise { + return this.client.readContextDocument(id, version); + } + + // ----- Writes (audit required) ------------------------------------------ + + async createDocument( + args: { title: string; content: string; tags: string[] } & WriteAuditInput, + ): Promise { + this.assertAction("create_context"); + this.assertTags(args.tags); + const doc = await this.client.createContextDocument({ + title: args.title, + content: args.content, + tags: args.tags, + }); + await this.audit.record( + "create", + { reason: args.reason, source: args.source }, + { documentId: doc.id, documentVersion: doc.latest_version, tags: doc.tags }, + ); + return doc; + } + + async editDocument( + args: { + document_id: string; + content: string; + expected_version: number; + } & WriteAuditInput, + ): Promise { + this.assertAction("edit_context"); + const doc = await this.client.writeContextDocument({ + document_id: args.document_id, + content: args.content, + expected_version: args.expected_version, + }); + await this.audit.record( + "edit", + { reason: args.reason, source: args.source }, + { documentId: doc.id, documentVersion: doc.latest_version, tags: doc.tags }, + ); + return doc; + } + + async publishDocument( + args: { document_id: string; version: number } & WriteAuditInput, + ): Promise { + this.assertAction("publish_context"); + const doc = await this.client.publishContextDocument({ + document_id: args.document_id, + version: args.version, + }); + await this.audit.record( + "publish", + { reason: args.reason, source: args.source }, + { documentId: doc.id, documentVersion: doc.published_version, tags: doc.tags }, + ); + return doc; + } + + // ----- Helpers ----------------------------------------------------------- + + /** + * Read-modify-write a document with optimistic concurrency. + * + * Reads `latest`, calls `mutate(doc)` for the new content, writes it + * back tagged with the version we read. If another writer beat us to + * it, retries up to `MAX_VERSION_RETRIES` times. + * + * Plugin authors get one line instead of writing this loop themselves. + */ + async withDocument( + id: string, + mutate: (doc: ContextDocument) => string | Promise, + audit: WriteAuditInput, + ): Promise { + this.assertAction("edit_context"); + let lastError: unknown; + for (let attempt = 0; attempt < MAX_VERSION_RETRIES; attempt++) { + const current = await this.readDocument(id, "latest"); + this.assertTags(current.tags); + const newContent = await mutate(current); + try { + return await this.editDocument({ + document_id: id, + content: newContent, + expected_version: current.latest_version, + reason: audit.reason, + source: audit.source, + }); + } catch (err) { + if (isVersionConflict(err)) { + lastError = err; + continue; + } + throw err; + } + } + throw new Error( + `withDocument(${id}): version conflict after ${MAX_VERSION_RETRIES} attempts.`, + { cause: lastError }, + ); + } +} + +/** + * Heuristic: a write rejected because `expected_version` didn't match + * `latest_version` is a conflict, not a real error. Until the MCP + * server's exact error code/message is confirmed, fall back to a + * substring check. + */ +function isVersionConflict(err: unknown): boolean { + if (!(err instanceof McpServerError)) return false; + if (err.code === -32001) return true; // tentative dedicated code + return /version.*conflict|expected_version/i.test(err.message); +} diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts new file mode 100644 index 0000000..8aa2090 --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,107 @@ +/** + * Types that mirror Alignbase's MCP surface. + * + * Source of truth: SPEC.md #3 (ContextDocument) and #5 (AuditEvent). + * Keep these in sync with the spec — they are the contract every other + * file in the SDK depends on. + */ + +// --------------------------------------------------------------------------- +// Context documents — SPEC #3 +// --------------------------------------------------------------------------- + +/** + * Real-world status strings seen from Alignbase. "unpublished" comes back + * from some endpoints and is treated as a synonym for "draft". + */ +export type PublicationStatus = "draft" | "published" | "unpublished"; + +export type DocumentStatus = "live" | "stale" | "expired"; + +export type ContextDocument = { + id: string; + title: string; + tags: string[]; + content: string; + + current_version: number; + latest_version: number; + published_version: number | null; + + publication_status: PublicationStatus; + has_unpublished_changes: boolean; + status: DocumentStatus; + + expires_at: string | null; + review_by: string | null; + + can_read: boolean; + can_edit: boolean; + can_publish: boolean; + + created_at: string; + updated_at: string; +}; + +/** + * Selector for which version to read. Mirrors the MCP tool's `version` arg. + * - "latest" → most recent saved version (including unpublished drafts) + * - "published" → version surfaced by get_current_context + * - number → that exact saved version + */ +export type VersionSelector = "latest" | "published" | number; + +/** + * The permission vocabulary from SPEC #2 manifest. `read_context` is + * implicit on every grant, so it's not in this union — the SDK never + * checks for it. + */ +export type PermissionAction = + | "edit_context" + | "create_context" + | "publish_context" + | "create_tags"; + +// --------------------------------------------------------------------------- +// Audit events — SPEC #5 +// --------------------------------------------------------------------------- + +export type AuditAction = "create" | "edit" | "publish" | "create_tag"; + +export type AuditSourceKind = + | "github" + | "notion" + | "manual" + | "schedule" + | "other"; + +export type AuditSource = { + kind: AuditSourceKind; + /** Stable reference to where this change came from. e.g. "org/repo@sha". */ + ref: string; +}; + +export type AuditEvent = { + event_id: string; + occurred_at: string; + plugin: { name: string; version: string }; + client_id: string; + action: AuditAction; + document_id: string | null; + document_version: number | null; + tags: string[]; + reason: string; + source: AuditSource; +}; + +/** + * The minimum a plugin must provide on every write. The SDK constructs the + * full AuditEvent from this plus runtime info (plugin name/version, client_id, + * timestamp, event_id, etc.). + * + * SPEC #5 rule: a write without `reason` and `source` is rejected. + */ +export type WriteAuditInput = { + reason: string; + source: AuditSource; +}; diff --git a/packages/sdk/tests/audit.test.ts b/packages/sdk/tests/audit.test.ts new file mode 100644 index 0000000..e346808 --- /dev/null +++ b/packages/sdk/tests/audit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; + +import { AuditEventBuilder, type AuditSink } from "../src/audit.js"; +import type { AuditEvent } from "../src/types.js"; + +const fixedDate = new Date("2026-06-30T12:00:00Z"); + +function makeBuilder(sink?: AuditSink) { + return new AuditEventBuilder({ + plugin: { name: "github-sync", version: "0.1.0" }, + clientId: "ab_client_test", + sink, + now: () => fixedDate, + newId: () => "evt-1", + }); +} + +describe("AuditEventBuilder", () => { + it("builds an event with injected clock and id", () => { + const evt = makeBuilder().build( + "create", + { + reason: "smoke test", + source: { kind: "manual", ref: "test" }, + }, + { documentId: "doc-1", documentVersion: 7, tags: ["github"] }, + ); + + expect(evt).toStrictEqual({ + event_id: "evt-1", + occurred_at: fixedDate.toISOString(), + plugin: { name: "github-sync", version: "0.1.0" }, + client_id: "ab_client_test", + action: "create", + document_id: "doc-1", + document_version: 7, + tags: ["github"], + reason: "smoke test", + source: { kind: "manual", ref: "test" }, + }); + }); + + it("ships the event to the sink", async () => { + const sink: AuditSink = { record: vi.fn() }; + const builder = makeBuilder(sink); + + await builder.record( + "edit", + { reason: "r", source: { kind: "github", ref: "x@y" } }, + { documentId: "doc-1", documentVersion: 8, tags: ["github"] }, + ); + + expect(sink.record).toHaveBeenCalledOnce(); + expect(sink.record).toHaveBeenCalledWith( + expect.objectContaining({ action: "edit", reason: "r" }), + ); + }); +}); diff --git a/packages/sdk/tests/auth.test.ts b/packages/sdk/tests/auth.test.ts new file mode 100644 index 0000000..ced6655 --- /dev/null +++ b/packages/sdk/tests/auth.test.ts @@ -0,0 +1,33 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { EnvTokenAuth, StaticTokenAuth } from "../src/auth.js"; + +describe("EnvTokenAuth", () => { + const VAR = "ALIGNBASE_TOKEN_TEST"; + let saved: string | undefined; + + beforeEach(() => { + saved = process.env[VAR]; + delete process.env[VAR]; + }); + + afterEach(() => { + if (saved === undefined) delete process.env[VAR]; + else process.env[VAR] = saved; + }); + + it("returns the token when the env var is set", () => { + process.env[VAR] = "tok_123"; + expect(new EnvTokenAuth(VAR).getToken()).toBe("tok_123"); + }); + + it("throws a clear error when the env var is missing", () => { + expect(() => new EnvTokenAuth(VAR).getToken()).toThrow(/not set/); + }); +}); + +describe("StaticTokenAuth", () => { + it("returns the token it was constructed with", () => { + expect(new StaticTokenAuth("tok_abc").getToken()).toBe("tok_abc"); + }); +}); diff --git a/packages/sdk/tests/mcp-client.test.ts b/packages/sdk/tests/mcp-client.test.ts new file mode 100644 index 0000000..d8e3de5 --- /dev/null +++ b/packages/sdk/tests/mcp-client.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + McpClient, + McpProtocolError, + McpServerError, + McpTransportError, +} from "../src/index.js"; + +/** + * Build a fake `fetch` that returns a JSON-RPC success envelope wrapping + * the given tool result text. + */ +function fetchReturningText(text: string, isError = false) { + return vi.fn(async () => + new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { content: [{ type: "text", text }], isError }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); +} + +describe("McpClient.rpc envelope", () => { + it("sends a JSON-RPC tools/call request with the bearer token", async () => { + const fetchMock = fetchReturningText('{"documents":[]}'); + const client = new McpClient({ token: "tok_abc", fetch: fetchMock as unknown as typeof fetch }); + + await client.listContextDocuments(); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe("https://app.alignbase.ai/mcp"); + const headers = (init as RequestInit).headers as Record; + expect(headers["authorization"]).toBe("Bearer tok_abc"); + const body = JSON.parse((init as RequestInit).body as string); + expect(body).toMatchObject({ + jsonrpc: "2.0", + method: "tools/call", + params: { name: "list_context_documents", arguments: {} }, + }); + }); + + it("throws McpServerError on a JSON-RPC error envelope", async () => { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + error: { code: -32602, message: "Invalid params" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + const client = new McpClient({ token: "t", fetch: fetchMock as unknown as typeof fetch }); + + await expect(client.listContextDocuments()).rejects.toMatchObject({ + name: "McpServerError", + code: -32602, + }); + }); + + it("throws McpTransportError on non-2xx HTTP", async () => { + const fetchMock = vi.fn( + async () => new Response("nope", { status: 401 }), + ); + const client = new McpClient({ token: "t", fetch: fetchMock as unknown as typeof fetch }); + + await expect(client.listContextDocuments()).rejects.toBeInstanceOf( + McpTransportError, + ); + }); + + it("throws McpProtocolError when JSON-expecting tool returns non-JSON", async () => { + const fetchMock = fetchReturningText("not json at all"); + const client = new McpClient({ token: "t", fetch: fetchMock as unknown as typeof fetch }); + + await expect(client.listContextDocuments()).rejects.toBeInstanceOf( + McpProtocolError, + ); + }); + + it("wraps tool isError=true as McpServerError", async () => { + const fetchMock = fetchReturningText("permission denied", true); + const client = new McpClient({ token: "t", fetch: fetchMock as unknown as typeof fetch }); + + await expect(client.getCurrentContext()).rejects.toBeInstanceOf( + McpServerError, + ); + }); +}); diff --git a/packages/sdk/tests/plugin.test.ts b/packages/sdk/tests/plugin.test.ts new file mode 100644 index 0000000..2692890 --- /dev/null +++ b/packages/sdk/tests/plugin.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + ConsoleAuditSink, + createPlugin, + PermissionError, + StaticTokenAuth, +} from "../src/index.js"; +import type { ContextDocument } from "../src/types.js"; + +// --------------------------------------------------------------------------- +// Fake fetch that returns canned tool results based on the tool name. +// --------------------------------------------------------------------------- + +type Handler = (args: unknown) => unknown; + +function makeFetch(handlers: Record) { + return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(init!.body as string) as { + id: number; + params: { name: string; arguments: unknown }; + }; + const handler = handlers[body.params.name]; + if (!handler) { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: body.id, + error: { code: -32601, message: `no handler for ${body.params.name}` }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + const result = handler(body.params.arguments); + if (result instanceof Error) { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: body.id, + error: { code: -32001, message: result.message }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: body.id, + result: { + content: [{ type: "text", text: JSON.stringify(result) }], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); +} + +const doc: ContextDocument = { + id: "doc-1", + title: "README", + tags: ["github"], + content: "hello", + current_version: 1, + latest_version: 1, + published_version: null, + publication_status: "draft", + has_unpublished_changes: false, + status: "live", + expires_at: null, + review_by: null, + can_read: true, + can_edit: true, + can_publish: false, + created_at: "2026-06-30T00:00:00Z", + updated_at: "2026-06-30T00:00:00Z", +}; + +const baseAudit = { + reason: "test", + source: { kind: "manual" as const, ref: "test" }, +}; + +async function buildPlugin( + handlers: Record, + overrides: Partial[0]> = {}, +) { + return createPlugin({ + name: "github-sync", + version: "0.1.0", + clientId: "ab_client_test", + auth: new StaticTokenAuth("tok"), + auditSink: new ConsoleAuditSink(), + fetch: makeFetch(handlers) as unknown as typeof fetch, + permissions: ["create_context", "edit_context"], + tagScopes: ["github"], + ...overrides, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("Plugin preflight", () => { + it("throws PermissionError when action is not in manifest", async () => { + const plugin = await buildPlugin({}); + await expect( + plugin.publishDocument({ + document_id: "doc-1", + version: 1, + ...baseAudit, + }), + ).rejects.toBeInstanceOf(PermissionError); + }); + + it("throws PermissionError when tag is outside tag_scopes", async () => { + const plugin = await buildPlugin({}); + await expect( + plugin.createDocument({ + title: "x", + content: "x", + tags: ["Salesco"], + ...baseAudit, + }), + ).rejects.toBeInstanceOf(PermissionError); + }); + + it("skips preflight when manifest scopes are omitted", async () => { + const plugin = await buildPlugin( + { create_context_document: () => doc }, + { permissions: undefined, tagScopes: undefined }, + ); + await expect( + plugin.createDocument({ + title: "x", + content: "x", + tags: ["anything"], + ...baseAudit, + }), + ).resolves.toBeDefined(); + }); +}); + +describe("Plugin write path", () => { + it("createDocument writes through and returns the document", async () => { + const sink = { record: vi.fn() }; + const plugin = await buildPlugin( + { create_context_document: () => doc }, + { auditSink: sink }, + ); + const result = await plugin.createDocument({ + title: "README", + content: "hi", + tags: ["github"], + ...baseAudit, + }); + expect(result.id).toBe("doc-1"); + expect(sink.record).toHaveBeenCalledOnce(); + expect(sink.record).toHaveBeenCalledWith( + expect.objectContaining({ action: "create", document_id: "doc-1" }), + ); + }); +}); + +describe("withDocument", () => { + it("retries on version conflict then succeeds", async () => { + let writes = 0; + const plugin = await buildPlugin({ + read_context_document: () => ({ + ...doc, + latest_version: ++writes, // version increments between reads + }), + write_context_document: () => { + if (writes < 2) { + return new Error("expected_version conflict"); + } + return { ...doc, latest_version: writes }; + }, + }); + + const result = await plugin.withDocument( + "doc-1", + (d) => `${d.content} + appended`, + baseAudit, + ); + expect(result.latest_version).toBeGreaterThanOrEqual(2); + }); + + it("gives up after max retries", async () => { + const plugin = await buildPlugin({ + read_context_document: () => doc, + write_context_document: () => new Error("expected_version conflict"), + }); + await expect( + plugin.withDocument("doc-1", (d) => d.content, baseAudit), + ).rejects.toThrow(/version conflict after/); + }); +}); diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 0000000..c2104f6 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..ed0b17c --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1075 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + tsx: + specifier: ^4.22.4 + version: 4.22.4 + + packages/plugin-github-sync: + dependencies: + '@alignbase/sdk': + specifier: workspace:* + version: link:../sdk + devDependencies: + '@types/node': + specifier: ^26.0.1 + version: 26.0.1 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@26.0.1)(vite@8.1.2(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.22.4)) + + packages/sdk: + devDependencies: + '@types/node': + specifier: ^26.0.1 + version: 26.0.1 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@26.0.1)(vite@8.1.2(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.22.4)) + +packages: + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + + '@rolldown/binding-android-arm64@1.1.3': + resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.3': + resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.3': + resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.3': + resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.1.3': + resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.1.3': + resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.1.3': + resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.1.3': + resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.3': + resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.3': + resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.2.0: + resolution: {integrity: sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.1.3: + resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + vite@8.1.2: + resolution: {integrity: sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.137.0': {} + + '@rolldown/binding-android-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-x64@1.1.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.3': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.0.1': + dependencies: + undici-types: 8.3.0 + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@8.1.2(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.22.4))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.2(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.22.4) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.2.0: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.15: {} + + obug@2.1.3: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.1.3: + dependencies: + '@oxc-project/types': 0.137.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.3 + '@rolldown/binding-darwin-arm64': 1.1.3 + '@rolldown/binding-darwin-x64': 1.1.3 + '@rolldown/binding-freebsd-x64': 1.1.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 + '@rolldown/binding-linux-arm64-gnu': 1.1.3 + '@rolldown/binding-linux-arm64-musl': 1.1.3 + '@rolldown/binding-linux-ppc64-gnu': 1.1.3 + '@rolldown/binding-linux-s390x-gnu': 1.1.3 + '@rolldown/binding-linux-x64-gnu': 1.1.3 + '@rolldown/binding-linux-x64-musl': 1.1.3 + '@rolldown/binding-openharmony-arm64': 1.1.3 + '@rolldown/binding-wasm32-wasi': 1.1.3 + '@rolldown/binding-win32-arm64-msvc': 1.1.3 + '@rolldown/binding-win32-x64-msvc': 1.1.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@8.3.0: {} + + vite@8.1.2(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.22.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.16 + rolldown: 1.1.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.0.1 + esbuild: 0.28.1 + fsevents: 2.3.3 + tsx: 4.22.4 + + vitest@4.1.9(@types/node@26.0.1)(vite@8.1.2(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.22.4)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.1.2(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.22.4)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.2.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.2(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.22.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.0.1 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/scripts/get-token.ts b/scripts/get-token.ts new file mode 100644 index 0000000..e50f868 --- /dev/null +++ b/scripts/get-token.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * OAuth+PKCE helper — gets an Alignbase access token for a given client ID. + * + * Usage: + * pnpm tsx scripts/get-token.ts (reads ALIGNBASE_CLIENT_ID from env / .env) + * pnpm tsx scripts/get-token.ts (explicit client_id arg) + * + * Flow: + * 1. Discover OAuth endpoints at /.well-known/oauth-authorization-server + * 2. Generate PKCE code_verifier + code_challenge + * 3. Start a localhost HTTP server to catch the redirect + * 4. Open the user's browser to the authorize URL + * 5. On callback, exchange the auth code for an access token + * 6. Print the token to stdout + * + * This is the predecessor of a real `OAuthAuth` class inside the SDK. For + * v0.1, it lives as a standalone script so users can paste the token into + * `.env`. When promoted into the SDK, the same code runs unattended. + */ + +import { spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +const ISSUER = "https://app.alignbase.ai"; +const DISCOVERY = `${ISSUER}/.well-known/oauth-authorization-server`; +const REDIRECT_HOST = "127.0.0.1"; +const REDIRECT_PATH = "/callback"; + +type DiscoveryDoc = { + authorization_endpoint: string; + token_endpoint: string; + scopes_supported?: string[]; +}; + +type TokenResponse = { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + scope?: string; +}; + +// --------------------------------------------------------------------------- +// PKCE helpers +// --------------------------------------------------------------------------- + +function base64url(input: Buffer): string { + return input + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function generatePkce(): { verifier: string; challenge: string } { + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + return { verifier, challenge }; +} + +// --------------------------------------------------------------------------- +// Open browser cross-platform +// --------------------------------------------------------------------------- + +function openBrowser(url: string): void { + const cmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref(); +} + +// --------------------------------------------------------------------------- +// Local callback server +// --------------------------------------------------------------------------- + +type CallbackResult = { code: string; state: string }; + +function awaitCallback( + expectedState: string, +): Promise<{ port: number; result: Promise }> { + return new Promise((resolveListen) => { + const result = new Promise((resolveCb, rejectCb) => { + const server = createServer((req, res) => { + const url = new URL(req.url!, `http://${REDIRECT_HOST}`); + if (url.pathname !== REDIRECT_PATH) { + res.statusCode = 404; + res.end("Not found"); + return; + } + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + if (error) { + res.statusCode = 400; + res.end(`OAuth error: ${error}`); + server.close(); + rejectCb(new Error(`Authorization failed: ${error}`)); + return; + } + if (!code || !state) { + res.statusCode = 400; + res.end("Missing code or state."); + server.close(); + rejectCb(new Error("Callback missing required params.")); + return; + } + if (state !== expectedState) { + res.statusCode = 400; + res.end("State mismatch."); + server.close(); + rejectCb(new Error("State mismatch — possible CSRF.")); + return; + } + + res.statusCode = 200; + res.setHeader("content-type", "text/html"); + res.end( + ` + Alignbase auth complete + +

Authorization successful

+

You can close this tab and return to the terminal.

+ `, + ); + server.close(); + resolveCb({ code, state }); + }); + server.listen(0, REDIRECT_HOST, () => { + const { port } = server.address() as AddressInfo; + resolveListen({ port, result }); + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + // Best-effort .env load (no dotenv dep). + loadDotEnvIfPresent(); + + const clientId = + process.argv[2] ?? process.env["ALIGNBASE_CLIENT_ID"] ?? ""; + if (!clientId.startsWith("ab_client_")) { + console.error( + "Missing client ID. Pass as argv[2] or set ALIGNBASE_CLIENT_ID.\n" + + " pnpm tsx scripts/get-token.ts ab_client_xxx", + ); + process.exit(2); + } + + console.error(`Discovering OAuth endpoints at ${DISCOVERY}…`); + const discovery = await fetchJson(DISCOVERY); + + const { verifier, challenge } = generatePkce(); + const state = base64url(randomBytes(16)); + + const { port, result } = await awaitCallback(state); + const redirectUri = `http://${REDIRECT_HOST}:${port}${REDIRECT_PATH}`; + + const authorizeUrl = new URL(discovery.authorization_endpoint); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("client_id", clientId); + authorizeUrl.searchParams.set("redirect_uri", redirectUri); + authorizeUrl.searchParams.set("code_challenge", challenge); + authorizeUrl.searchParams.set("code_challenge_method", "S256"); + authorizeUrl.searchParams.set("state", state); + if (discovery.scopes_supported?.length) { + authorizeUrl.searchParams.set("scope", discovery.scopes_supported.join(" ")); + } + + console.error(`\nOpening your browser to authorize…`); + console.error(`If it doesn't open, paste this URL:\n ${authorizeUrl}\n`); + openBrowser(authorizeUrl.toString()); + + const { code } = await result; + console.error("Exchanging code for token…"); + + const tokenRes = await fetch(discovery.token_endpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: verifier, + }), + }); + + if (!tokenRes.ok) { + const body = await tokenRes.text(); + throw new Error(`Token endpoint returned ${tokenRes.status}: ${body}`); + } + + const token = (await tokenRes.json()) as TokenResponse; + + console.error("\n✓ Got access token.\n"); + console.error( + ` expires_in: ${token.expires_in ?? "?"} seconds` + + (token.refresh_token ? " (refresh token also issued)" : "") + + "\n", + ); + console.error("Paste this line into packages/plugin-github-sync/.env:\n"); + // The actual token goes to stdout so it can be piped/redirected. + console.log(`ALIGNBASE_TOKEN=${token.access_token}`); +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +async function fetchJson(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`GET ${url} → ${res.status}`); + } + return (await res.json()) as T; +} + +/** Minimal .env loader — only sets vars that aren't already in process.env. */ +function loadDotEnvIfPresent(): void { + const candidates = [ + "packages/plugin-github-sync/.env", + ".env", + ]; + for (const path of candidates) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires -- script + const fs = require("node:fs") as typeof import("node:fs"); + if (!fs.existsSync(path)) continue; + const raw = fs.readFileSync(path, "utf8"); + for (const line of raw.split(/\r?\n/)) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/i); + if (!m) continue; + const [, key, value] = m; + if (!key || process.env[key] !== undefined) continue; + // Strip surrounding quotes if present. + process.env[key] = value!.replace(/^["'](.*)["']$/, "$1"); + } + } catch { + /* ignore */ + } + } +} + +main().catch((err) => { + console.error(`\n✗ ${err instanceof Error ? err.message : err}`); + process.exit(1); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..c1e8f66 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true + } +}