diff --git a/.gitignore b/.gitignore index dd842a0..09a5255 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ Desktop.ini .mcpb/ artifacts/*.mcpb integrations/claude-desktop/node_modules/ +assets/migrations/ +.artifacts/ +.codegraph/ diff --git a/.prettierignore b/.prettierignore index 8578380..bb02667 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,4 +3,4 @@ dist/ coverage/ pnpm-lock.yaml .superpowers/ - +.codegraph/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..578ab0e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Krishna916 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d723cf0..f9cf90d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Agent integrations: see [setup](docs/agent-integration.md) and [troubleshooting] The approved agent-integration contract is documented in the [decision record](docs/decisions/0002-agent-integration-contracts.md), [MCP tool reference](docs/mcp-tools.md), [CLI reference](docs/cli-reference.md), and [session semantics](docs/session-semantics.md). The production MCP task tools and source-checkout CLI are shipped. -Distribution planning is documented in the [Distribution decision](docs/decisions/0003-distribution-filesystem-and-lifecycle.md) and [Distribution contracts](docs/distribution/). The public npm package and operational distribution commands remain future work. +Distribution planning is documented in the [Distribution decision](docs/decisions/0003-distribution-filesystem-and-lifecycle.md) and [Distribution contracts](docs/distribution/). Build and verify the local publishable tarball with the [npm package guide](docs/distribution/npm-package.md); registry publication remains a separate maintainer action. Relay is a local task sidecar for human–AI workflows. The current MVP is usable through its local web UI and through five safe local stdio MCP task tools. @@ -70,8 +70,8 @@ It exposes five task tools—`task_capture`, `task_list`, `task_get`, `task_find Relay uses a local SQLite database containing task data. The default database file is: -- Windows: `%APPDATA%\relay\relay.db` -- macOS: `~/Library/Application Support/relay/relay.db` +- Windows: `%LOCALAPPDATA%\Relay\relay.db` +- macOS: `~/Library/Application Support/Relay/relay.db` - Linux: `${XDG_DATA_HOME:-~/.local/share}/relay/relay.db` Set `RELAY_DB_PATH` to use an explicit database file instead. Migrations run automatically when the HTTP/UI runtime starts. Connections enable foreign keys, WAL journal mode, and a 5-second SQLite busy timeout. Relay uses explicit SQL rather than an ORM. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..dfdb80a --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,6 @@ +# Third-party notices + +Relay includes open-source dependencies listed in `package.json` and their +transitive dependencies. Their licenses and notices are installed alongside +the package by npm/pnpm. This file identifies the application distribution's +third-party notice location without duplicating dependency license text. diff --git a/docs/distribution/npm-package.md b/docs/distribution/npm-package.md new file mode 100644 index 0000000..75f4308 --- /dev/null +++ b/docs/distribution/npm-package.md @@ -0,0 +1,61 @@ +# Relay npm package + +Issue #40 builds the local tarball for `@krishna916/relay`. Registry +publication is deliberately not part of this workflow. + +## Build and inspect + +Use Node.js 24 and pnpm 10.2.0: + +```text +pnpm pack:tarball +pnpm verify:package:contents +``` + +The tarball is written to `.artifacts/npm/`. The positive `files` allowlist +contains the compiled Node runtime, compiled React UI, SQL migrations, +canonical skills, integration templates, README, license, and notices. It +does not contain source, tests, databases, logs, secrets, repository metadata, +or MCPB staging output. + +## Install from a tarball + +Install into a disposable prefix and invoke the installed command from a +different directory: + +```text +npm install --global --prefix +/bin/relay task list --output json +/bin/relay mcp +/bin/relay ui +``` + +On Windows the executable is `\relay.cmd`. The package +resolves immutable assets relative to its installed package root, while data, +configuration, and cache paths are independent of the current directory. + +`RELAY_DB_PATH` must be an absolute path. It takes precedence over the +platform default, and whitespace or relative values fail with usage/validation +exit code 2. CLI, MCP, and UI use the same effective database path. + +## Supported runtime + +The initial release claim is Node.js 24 on Windows x64, macOS arm64, and +Linux x64 with glibc. Windows arm64, macOS x64, Linux arm64, and Alpine/musl +are not claimed. Native `better-sqlite3` installation and runtime evidence is +required for each platform claim. + +If `better-sqlite3` cannot load, first confirm Node 24, then install the +platform's native build tools, and reinstall the tarball into a clean prefix. +Do not copy a repository-local `node_modules` directory into the installed +package. A normal uninstall removes package files but retains Relay user data. + +## Human verification checklist + +- inspect the complete normalized tarball inventory +- run the installed command from an unrelated directory +- capture, list, get, session-capture, and one lifecycle mutation through the installed CLI +- perform an MCP initialize/health handshake and confirm stdout is protocol-only +- start the UI, confirm loopback binding, packaged index HTML, and health/version +- record the temporary database path and native addon load result +- run `pnpm verify`, `pnpm verify:package`, and the independent MCPB staging checks diff --git a/docs/superpowers/plans/2026-08-01-pr-47-review-fixes.md b/docs/superpowers/plans/2026-08-01-pr-47-review-fixes.md new file mode 100644 index 0000000..e08bc83 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-pr-47-review-fixes.md @@ -0,0 +1,604 @@ +# PR #47 Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve the three correctness findings on PR #47 without widening issue #40 scope: enforce a fail-closed npm tarball allowlist, derive installed smoke-test version expectations from the package being tested, and reject `RELAY_DB_PATH=:memory:` while preserving explicit internal/test in-memory injection. + +**Architecture:** Keep package policy centralized in `scripts/package/package-files.ts`, expose a pure inventory validator from `inspect-tarball.ts` so malicious/unexpected inventories can be tested without constructing archives, and pass the packed package version explicitly through installed smoke assertions. Keep runtime path precedence unchanged, but distinguish whether the selected database path came from explicit dependency injection or the public environment. + +**Tech Stack:** TypeScript, Node.js 24, npm tarballs, Vitest, existing package smoke tooling, pnpm. + +## Global Constraints + +- Stay on branch `agent/issue-40-publishable-npm-package`; update PR #47 rather than opening another PR. +- Do not change task, MCP, HTTP, migration, or lifecycle behavior beyond the three review findings. +- The npm package remains `@krishna916/relay` with one `relay` bin. +- Package validation must be positive/fail-closed: every tarball entry must match an explicitly approved exact path or narrowly approved generated-asset pattern. +- Do not approve whole directories such as `package/dist/`, `package/skills/`, or `package/integrations/`. +- Vite hashed assets may vary by content hash, but only files directly under `package/dist/web/assets/` with approved extensions may pass. +- Source maps are not required runtime assets and must not be added to the allowlist. If current `tsup` output publishes `.map` files, exclude them from the package rather than approving them. +- The expected application version in smoke tests must come from the root package metadata used to produce the tarball; no literal release version may appear in smoke assertions. +- `:memory:` is allowed only through `explicitDatabasePath` internal/test injection. `RELAY_DB_PATH` must be a non-empty absolute filesystem path. +- Keep MCP stdout protocol-only and preserve all existing installed CLI/MCP/UI smoke coverage. +- Each task follows red-green-refactor and ends with a focused commit. +- Final acceptance requires `pnpm verify`, `pnpm verify:package`, and all focused tests to pass. + +--- + +### Task 1: Replace the tarball denylist with a positive allowlist + +**Files:** + +- Modify: `scripts/package/package-files.ts` +- Modify: `scripts/package/inspect-tarball.ts` +- Modify: `tests/integration/package-tarball.test.ts` +- Modify if required to stop publishing source maps: `tsup.config.ts` + +**Interfaces:** + +- Consumes: `REQUIRED_PACKAGE_PATHS`, `REQUIRED_MIGRATION_PATHS`, and the normalized `package/...` entry names returned by `normalizedTarballInventory()`. +- Produces: + - `isApprovedPackagePath(path: string): boolean` + - `validatePackageInventory(entries: readonly string[]): void` + - failure output containing `Missing:` and `Unexpected:` sections + +- [ ] **Step 1: Add failing pure inventory-validation tests.** + +In `tests/integration/package-tarball.test.ts`, import `validatePackageInventory` and `REQUIRED_PACKAGE_PATHS`. Add tests before the existing actual-archive test: + +```ts +import { REQUIRED_PACKAGE_PATHS } from '../../scripts/package/package-files.js'; +import { + inspectTarball, + normalizedTarballInventory, + validatePackageInventory, +} from '../../scripts/package/inspect-tarball.js'; + +it('rejects an unexpected file inside an otherwise published directory', () => { + const inventory = [ + ...REQUIRED_PACKAGE_PATHS, + 'package/dist/web/assets/index-ABC123.js', + 'package/skills/relay-capture/private-notes.txt', + ]; + + expect(() => validatePackageInventory(inventory)).toThrowError( + /Unexpected:\npackage\/skills\/relay-capture\/private-notes\.txt/, + ); +}); + +it('accepts only narrowly generated Vite assets', () => { + const base = [...REQUIRED_PACKAGE_PATHS]; + + expect(() => + validatePackageInventory([ + ...base, + 'package/dist/web/assets/index-ABC123.js', + 'package/dist/web/assets/index-ABC123.css', + ]), + ).not.toThrow(); + + for (const unexpected of [ + 'package/dist/web/assets/secrets.json', + 'package/dist/web/assets/nested/index-ABC123.js', + 'package/dist/web/debug.txt', + 'package/dist/cli/extra.js', + ]) { + expect(() => + validatePackageInventory([...base, 'package/dist/web/assets/index-ABC123.js', unexpected]), + ).toThrowError(new RegExp(unexpected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } +}); + +it('rejects missing required files separately from unexpected files', () => { + const inventory = REQUIRED_PACKAGE_PATHS.filter( + (path) => path !== 'package/assets/migrations/0004_task_normalized_title.sql', + ); + + expect(() => validatePackageInventory(inventory)).toThrowError( + /Missing:\npackage\/assets\/migrations\/0004_task_normalized_title\.sql/, + ); +}); +``` + +Do not build a synthetic `.tgz` for these cases. The pure validator is the contract under test; the existing actual `npm pack` test remains the end-to-end proof. + +- [ ] **Step 2: Run the focused test and confirm it fails.** + +Run: + +```bash +pnpm test -- tests/integration/package-tarball.test.ts +``` + +Expected: FAIL because `validatePackageInventory` is not exported/implemented. + +- [ ] **Step 3: Define the exact package allowlist policy.** + +Replace `FORBIDDEN_PACKAGE_PATTERNS` in `scripts/package/package-files.ts` with the following exports: + +```ts +export const APPROVED_GENERATED_PACKAGE_PATTERNS: readonly RegExp[] = [ + /^package\/dist\/web\/assets\/[A-Za-z0-9_-]+\.(?:js|css|svg|png|jpg|jpeg|webp|ico|woff2?)$/, +]; + +const approvedExactPackagePaths = new Set(REQUIRED_PACKAGE_PATHS); + +export function isApprovedPackagePath(path: string): boolean { + return ( + approvedExactPackagePaths.has(path) || + APPROVED_GENERATED_PACKAGE_PATTERNS.some((pattern) => pattern.test(path)) + ); +} +``` + +Rules Luna must preserve exactly: + +- Every migration, skill, integration template, legal document, compiled entry point, `package.json`, and `dist/web/index.html` remains an exact required path through `REQUIRED_PACKAGE_PATHS`. +- Only content-hashed Vite assets are pattern-approved. +- The pattern permits no nested directories below `assets/`. +- The pattern permits no `.json`, `.map`, `.txt`, `.md`, `.db`, `.log`, or arbitrary extension. +- Do not retain the old forbidden-pattern check as the primary policy. A positive allowlist makes it redundant; unexpected paths fail regardless of name. + +- [ ] **Step 4: Add and use the pure inventory validator.** + +In `scripts/package/inspect-tarball.ts`, import `isApprovedPackagePath` and implement: + +```ts +export function validatePackageInventory(entries: readonly string[]): void { + const missing = REQUIRED_PACKAGE_PATHS.filter((path) => !entries.includes(path)); + const unexpected = entries.filter((path) => !isApprovedPackagePath(path)); + const generatedWebAssets = entries.filter((path) => + /^package\/dist\/web\/assets\/[A-Za-z0-9_-]+\.(?:js|css|svg|png|jpg|jpeg|webp|ico|woff2?)$/.test( + path, + ), + ); + + if (generatedWebAssets.length === 0) { + missing.push('package/dist/web/assets/'); + } + + if (missing.length > 0 || unexpected.length > 0) { + throw new Error( + `Relay npm tarball inventory mismatch.\nMissing:\n${missing.join('\n') || '(none)'}\nUnexpected:\n${unexpected.join('\n') || '(none)'}`, + ); + } +} + +export async function inspectTarball(tarballPath: string): Promise { + validatePackageInventory(normalizedTarballInventory(tarballPath)); +} +``` + +Use the same approved generated-asset regex in one place only. Prefer exporting a helper/pattern from `package-files.ts` rather than duplicating the literal regex as shown in the minimal sketch above. + +- [ ] **Step 5: Ensure compiled source maps are not published.** + +Run the actual tarball test: + +```bash +pnpm test -- tests/integration/package-tarball.test.ts +``` + +If it reports unexpected `package/dist/**/*.js.map` entries, modify `tsup.config.ts` from: + +```ts +sourcemap: true, +``` + +to: + +```ts +sourcemap: false, +``` + +Do not add `.map` to the allowlist. Rebuild and rerun the test. + +Expected: PASS, and an injected file such as `package/skills/relay-capture/private-notes.txt` is rejected. + +- [ ] **Step 6: Run package-content verification.** + +```bash +pnpm pack:tarball +pnpm verify:package:contents +``` + +Expected: PASS. Manually inspect the printed inventory and confirm every entry is covered by either `REQUIRED_PACKAGE_PATHS` or the generated Vite-asset pattern. + +- [ ] **Step 7: Commit the fail-closed package policy.** + +```bash +git add scripts/package/package-files.ts scripts/package/inspect-tarball.ts tests/integration/package-tarball.test.ts tsup.config.ts +git commit -m "fix: enforce package file allowlist" +``` + +If `tsup.config.ts` did not change, omit it from `git add`. + +--- + +### Task 2: Derive installed smoke-test version expectations from package metadata + +**Files:** + +- Modify: `scripts/package/smoke-installed-package.ts` +- Modify: `tests/integration/installed-package.test.ts` + +**Interfaces:** + +- Consumes: root `package.json` version from the same checkout passed to `verifyInstalledPackage(rootDir)`. +- Produces: + - `readExpectedPackageVersion(rootDir: string): string` + - `verifyMcp(..., expectedVersion: string): Promise` + - UI and MCP assertions against the derived value + +- [ ] **Step 1: Add a failing version-derivation test.** + +In `tests/integration/installed-package.test.ts`, add a temporary package-root fixture test that proves the helper does not use `0.1.0`: + +```ts +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + readExpectedPackageVersion, + verifyInstalledPackage, +} from '../../scripts/package/smoke-installed-package.js'; + +it('derives the expected smoke version from package metadata', () => { + const root = mkdtempSync(join(tmpdir(), 'relay-version-fixture-')); + try { + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: '@krishna916/relay', version: '9.8.7' }), + 'utf8', + ); + + expect(readExpectedPackageVersion(root)).toBe('9.8.7'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); +``` + +Merge imports with the file’s existing imports rather than creating duplicate import statements. + +- [ ] **Step 2: Run the focused test and confirm it fails.** + +```bash +pnpm test -- tests/integration/installed-package.test.ts +``` + +Expected: FAIL because `readExpectedPackageVersion` does not exist. + +- [ ] **Step 3: Implement strict package-version reading.** + +In `scripts/package/smoke-installed-package.ts`, extend the `node:fs` import with `readFileSync`, then add: + +```ts +export function readExpectedPackageVersion(rootDir: string): string { + const packagePath = join(rootDir, 'package.json'); + const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as { + readonly name?: string; + readonly version?: string; + }; + + if (parsed.name !== '@krishna916/relay') { + throw new Error(`Package smoke expected @krishna916/relay metadata at ${packagePath}.`); + } + if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(parsed.version ?? '')) { + throw new Error(`Package smoke found an invalid version in ${packagePath}.`); + } + + return parsed.version!; +} +``` + +Do not import production `readPackageVersion()` here. The smoke script must read the source package metadata that controls the tarball it is about to build; this avoids accidentally validating against repository/module resolution behavior. + +- [ ] **Step 4: Thread the derived version through MCP and UI checks.** + +At the start of `verifyInstalledPackage` add: + +```ts +const expectedVersion = readExpectedPackageVersion(rootDir); +``` + +Change `verifyMcp` to accept `expectedVersion`: + +```ts +async function verifyMcp( + commandPath: string, + installedMain: string, + cwd: string, + databasePath: string, + taskId: string, + expectedVersion: string, +): Promise { +``` + +Replace: + +```ts +parsed.version !== '0.1.0'; +``` + +with: + +```ts +parsed.version !== expectedVersion; +``` + +Pass `expectedVersion` from `verifyInstalledPackage`, and replace the UI assertion: + +```ts +health.version !== '0.1.0'; +``` + +with: + +```ts +health.version !== expectedVersion; +``` + +Improve errors so mismatches show both values: + +```ts +throw new Error( + `Installed MCP health reported version ${String(parsed.version)}; expected ${expectedVersion}.`, +); +``` + +Use equivalent wording for UI health. + +- [ ] **Step 5: Prove no literal release version remains in package smoke logic.** + +Run: + +```bash +rg "0\.1\.0" scripts/package tests/integration/installed-package.test.ts +``` + +Expected: no result from `scripts/package/smoke-installed-package.ts`. A literal may remain only in an intentionally version-specific fixture unrelated to smoke expectations; remove it if it controls an assertion. + +- [ ] **Step 6: Run focused and installed smoke tests.** + +```bash +pnpm test -- tests/integration/installed-package.test.ts +pnpm verify:package +``` + +Expected: PASS. MCP health and UI health both equal the package version read before packing. + +- [ ] **Step 7: Commit version-consistency verification.** + +```bash +git add scripts/package/smoke-installed-package.ts tests/integration/installed-package.test.ts +git commit -m "fix: derive installed smoke version" +``` + +--- + +### Task 3: Restrict the in-memory database exception to explicit injection + +**Files:** + +- Modify: `src/distribution/resolve-runtime-paths.ts` +- Modify: `tests/unit/distribution/runtime-paths.test.ts` +- Modify only if an existing test relies on the invalid environment behavior: the directly affected test file + +**Interfaces:** + +- Consumes precedence: + 1. `explicitDatabasePath` + 2. `env.RELAY_DB_PATH` + 3. platform default +- Produces: + - explicit `:memory:` accepted + - environment `:memory:` rejected as non-absolute + - explicit absolute path still overrides environment + - empty/whitespace environment remains a validation error + +- [ ] **Step 1: Add tests that distinguish explicit injection from environment override.** + +In `tests/unit/distribution/runtime-paths.test.ts`, add: + +```ts +it('allows an in-memory database only through explicit internal injection', () => { + expect( + resolveRuntimePaths({ + platform: 'linux', + homeDir: '/home/relay', + env: {}, + explicitDatabasePath: ':memory:', + }).databasePath, + ).toBe(':memory:'); +}); + +it('rejects an in-memory database supplied through RELAY_DB_PATH', () => { + expect(() => + resolveRuntimePaths({ + platform: 'linux', + homeDir: '/home/relay', + env: { RELAY_DB_PATH: ':memory:' }, + }), + ).toThrowError('Database path must be absolute: :memory:'); +}); + +it('does not let an invalid environment value override explicit in-memory injection', () => { + expect( + resolveRuntimePaths({ + platform: 'linux', + homeDir: '/home/relay', + env: { RELAY_DB_PATH: 'relative.db' }, + explicitDatabasePath: ':memory:', + }).databasePath, + ).toBe(':memory:'); +}); +``` + +Keep or add the existing absolute environment-path test: + +```ts +expect( + resolveRuntimePaths({ + platform: 'linux', + homeDir: '/home/relay', + env: { RELAY_DB_PATH: '/tmp/relay.db' }, + }).databasePath, +).toBe('/tmp/relay.db'); +``` + +- [ ] **Step 2: Run the focused test and confirm the environment case fails.** + +```bash +pnpm test -- tests/unit/distribution/runtime-paths.test.ts +``` + +Expected: FAIL because `RELAY_DB_PATH=:memory:` is currently accepted. + +- [ ] **Step 3: Track the source of the selected path explicitly.** + +Replace the candidate selection in `selectDatabasePath` with source-aware logic: + +```ts +const fromExplicitInput = input.explicitDatabasePath !== undefined; +const candidate = fromExplicitInput ? input.explicitDatabasePath : env.RELAY_DB_PATH; + +if (candidate !== undefined) { + const normalized = candidate.trim(); + if (!normalized) { + throw new RelayError('RELAY_DB_PATH/database path cannot be empty or whitespace only.'); + } + + const isExplicitInMemory = fromExplicitInput && normalized === ':memory:'; + if (!isExplicitInMemory && !isAbsoluteForPlatform(normalized, platform)) { + throw new RelayError(`Database path must be absolute: ${normalized}`); + } + + return normalized; +} +``` + +Do not infer source by comparing values after `??`; the explicit and environment values may be identical. Preserve explicit-input precedence even when the environment is invalid. + +- [ ] **Step 4: Run path, database, and installed-package regressions.** + +```bash +pnpm test -- tests/unit/distribution/runtime-paths.test.ts +pnpm test -- tests/unit/database/connection.test.ts +pnpm verify:package +``` + +Expected: PASS. The installed smoke continues using an absolute temporary `RELAY_DB_PATH`. + +- [ ] **Step 5: Commit the contract correction.** + +```bash +git add src/distribution/resolve-runtime-paths.ts tests/unit/distribution/runtime-paths.test.ts +git commit -m "fix: restrict in-memory database override" +``` + +--- + +### Task 4: Run final verification and close the review threads + +**Files:** + +- Modify only when a verification failure identifies a defect directly caused by Tasks 1–3. +- Do not add unrelated cleanup to this PR. + +**Interfaces:** + +- Consumes: the three focused commits. +- Produces: green repository verification, green installed package smoke, inspected fail-closed tarball, and replies on all three review threads with evidence. + +- [ ] **Step 1: Run formatting without silently mixing changes.** + +```bash +pnpm format + +git diff --check +git status --short +``` + +Inspect every formatting change. Retain only files touched by this plan. + +- [ ] **Step 2: Run the complete verification sequence.** + +```bash +pnpm verify +pnpm pack:tarball +pnpm verify:package:contents +pnpm verify:package +``` + +Expected: all commands exit `0`. + +- [ ] **Step 3: Inspect the tarball inventory manually.** + +From the output of `pnpm verify:package:contents`, confirm: + +- no source maps +- no tests, source files, repository metadata, databases, logs, `.env` files, caches, or MCPB staging output +- only the three compiled Node entry points +- only exact canonical skills and integrations +- all four migrations +- `dist/web/index.html` and only generated runtime assets under `dist/web/assets/` + +If any unexpected entry is present, fix the package source/allowlist rather than weakening the validator. + +- [ ] **Step 4: Commit any formatting-only follow-up if needed.** + +```bash +git add +git commit -m "style: format PR 47 review fixes" +``` + +Skip this commit when `git status --short` is clean. + +- [ ] **Step 5: Push the branch and reply to each review thread.** + +```bash +git push origin agent/issue-40-publishable-npm-package +``` + +Reply to the tarball thread with: + +```text +Fixed with a fail-closed positive allowlist. Every tarball entry must now be an exact approved path or a narrowly matched Vite runtime asset. Added regression coverage for an unexpected file under `skills/` and for disallowed generated-asset paths. `pnpm verify:package:contents` passes against the real tarball. +``` + +Reply to the version thread with: + +```text +Fixed by deriving the expected version from the root `package.json` used to produce the tarball and threading it through MCP and UI smoke assertions. Added a non-0.1.0 fixture test and reran `pnpm verify:package`. +``` + +Reply to the runtime-path thread with: + +```text +Fixed by tracking whether the selected value came from explicit injection or `RELAY_DB_PATH`. Explicit `:memory:` remains valid for tests/internal use; environment `:memory:` now fails the absolute-path contract. Added precedence and regression tests. +``` + +- [ ] **Step 6: Resolve threads only after CI is green.** + +Do not resolve a thread merely because code was pushed. Confirm the PR’s CI run for the latest head SHA succeeds, then resolve all three addressed threads. + +## Human Review Checkpoint + +Before merging PR #47, manually verify: + +1. Add a temporary extra file under `skills/relay-capture/`, run `npm pack`, and confirm inventory validation fails; remove the file afterward. +2. Temporarily change the package version locally to a different valid semver, run the installed smoke, and confirm MCP/UI assertions follow that version; revert the change afterward. +3. Run the CLI with `RELAY_DB_PATH=:memory:` and confirm startup fails with the absolute-path error. +4. Confirm tests that inject `explicitDatabasePath: ':memory:'` still pass. +5. Confirm the latest CI run and `pnpm verify:package` are green. + +## Deferred Decisions + +- Do not redesign package staging or introduce a general manifest generator in this fix. +- Do not add new package asset categories. +- Do not add a `RELAY_HOME` override. +- Do not change registry publication, setup, doctor, release workflows, or platform claims. +- Do not resolve unrelated review or CodeRabbit findings inside these commits. diff --git a/package.json b/package.json index d35e399..535be43 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,28 @@ { - "name": "relay", + "name": "@krishna916/relay", "version": "0.1.0", "description": "Local task sidecar for human–AI workflows", - "private": true, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/krishna916/relay.git" + }, + "bugs": { + "url": "https://github.com/krishna916/relay/issues" + }, + "homepage": "https://github.com/krishna916/relay#readme", + "files": [ + "dist/", + "assets/migrations/", + "skills/", + "integrations/", + "README.md", + "LICENSE", + "THIRD_PARTY_NOTICES.md" + ], "type": "module", "engines": { "node": ">=24 <25" @@ -18,8 +38,7 @@ ] }, "bin": { - "relay": "./dist/cli/main.js", - "relay-mcp": "./dist/mcp/main.js" + "relay": "./dist/cli/main.js" }, "scripts": { "test": "pnpm build:node && vitest run", @@ -31,21 +50,26 @@ "format:check": "prettier --check .", "build:node": "tsup", "build:web": "vite build", + "stage:package:assets": "node --import tsx/esm scripts/package/stage-package-assets.ts", "build:mcpb:stage": "node --import tsx/esm scripts/mcpb/assert-linux-build-target.ts && corepack pnpm build:node && node --import tsx/esm scripts/mcpb/stage-linux-mcpb.ts", "validate:mcpb": "mcpb validate .mcpb/relay", "verify:mcpb:stage": "node --import tsx/esm scripts/mcpb/verify-linux-mcpb.ts", "pack:mcpb": "node --import tsx/esm scripts/mcpb/pack-linux-mcpb.ts", "build:mcpb": "node --import tsx/esm scripts/mcpb/assert-linux-build-target.ts && corepack pnpm build:mcpb:stage && corepack pnpm validate:mcpb && corepack pnpm verify:mcpb:stage && corepack pnpm pack:mcpb", "build:clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", - "build": "pnpm build:clean && pnpm build:node && pnpm build:web", + "build": "pnpm build:clean && pnpm build:node && pnpm build:web && pnpm stage:package:assets", "dev:mcp": "node --import tsx/esm src/interfaces/mcp/main.ts", "dev:cli": "node --import tsx/esm src/interfaces/cli/main.ts", "dev:http": "node --import tsx/esm src/interfaces/http/main.ts", "dev:web": "vite", "dev:ui": "concurrently -k -p name -c \"blue,green\" \"pnpm dev:http\" \"pnpm dev:web\"", "validate:assets": "node --import tsx/esm scripts/validate-repository-assets.ts", + "verify:package:metadata": "node --import tsx/esm scripts/package/verify-package-metadata.ts", + "pack:tarball": "pnpm build && node -e \"require('node:fs').mkdirSync('.artifacts/npm', { recursive: true }); require('node:fs').mkdirSync('.artifacts/npm-cache', { recursive: true })\" && npm --cache .artifacts/npm-cache pack --json --pack-destination .artifacts/npm", + "verify:package:contents": "node --import tsx/esm scripts/package/inspect-tarball.ts", + "verify:package": "node --import tsx/esm scripts/package/smoke-installed-package.ts", "audit": "pnpm audit --audit-level high", - "verify": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm build && pnpm validate:assets && pnpm audit --audit-level high" + "verify": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm build && pnpm verify:package:metadata && pnpm validate:assets && pnpm audit --audit-level high" }, "devDependencies": { "@anthropic-ai/mcpb": "2.1.2", diff --git a/scripts/mcpb/model.ts b/scripts/mcpb/model.ts index 3adc3b4..769d21d 100644 --- a/scripts/mcpb/model.ts +++ b/scripts/mcpb/model.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; +import { REQUIRED_ONLY_BUILT_DEPENDENCIES } from '../package/verify-package-metadata.js'; const runtimeDependencies = ['@modelcontextprotocol/sdk', 'better-sqlite3', 'zod'] as const; const supportedArchitectures = ['x64', 'arm64'] as const; @@ -43,6 +44,7 @@ export interface RuntimePackage { readonly type: string; readonly engines: { readonly node: string }; readonly dependencies: Readonly>; + readonly pnpm?: { readonly onlyBuiltDependencies?: readonly string[] }; readonly [key: string]: unknown; } @@ -60,16 +62,16 @@ export function readRelayPackageMetadata(rootDir: string): RelayPackageMetadata version?: string; engines?: { node?: string }; }>(join(resolve(rootDir), 'package.json')); - if (packageJson.name !== 'relay') { + if (packageJson.name !== '@krishna916/relay' && packageJson.name !== 'relay') { throw new Error( - `MCPB packaging requires root package name relay; received ${String(packageJson.name)}.`, + `MCPB packaging requires root package name @krishna916/relay or relay; received ${String(packageJson.name)}.`, ); } if (!packageJson.version || !packageJson.engines?.node) { throw new Error('MCPB packaging requires a root version and Node engine.'); } return { - name: packageJson.name, + name: 'relay', version: packageJson.version, nodeEngine: packageJson.engines.node, }; @@ -148,6 +150,7 @@ export function createStagedRuntimePackage( version: relay.version, engines: { node: relay.nodeEngine }, dependencies: { ...source.dependencies }, + pnpm: { onlyBuiltDependencies: [...REQUIRED_ONLY_BUILT_DEPENDENCIES] }, }; } diff --git a/scripts/mcpb/stage-linux-mcpb.ts b/scripts/mcpb/stage-linux-mcpb.ts index d2a758d..a785870 100644 --- a/scripts/mcpb/stage-linux-mcpb.ts +++ b/scripts/mcpb/stage-linux-mcpb.ts @@ -15,6 +15,7 @@ import { type McpbManifest, type RuntimePackage, } from './model.js'; +import { verifyPackageMetadata } from '../package/verify-package-metadata.js'; export interface CommandOptions { readonly cwd: string; @@ -107,6 +108,7 @@ async function assertSafeStageInventory(stageDir: string): Promise { export async function stageLinuxMcpb(options: StageLinuxMcpbOptions = {}): Promise { const rootDir = resolve(options.rootDir ?? process.cwd()); assertLinuxBuildTarget(options.platform); + verifyPackageMetadata(rootDir); const relay = readRelayPackageMetadata(rootDir); const paths = resolveLinuxMcpbPaths(rootDir, options.arch, relay.version); const rootPackage = readLockedRuntimeDependencies(rootDir); diff --git a/scripts/package/inspect-tarball.ts b/scripts/package/inspect-tarball.ts new file mode 100644 index 0000000..e46f9c6 --- /dev/null +++ b/scripts/package/inspect-tarball.ts @@ -0,0 +1,77 @@ +import { gunzipSync } from 'node:zlib'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + APPROVED_GENERATED_PACKAGE_PATTERNS, + isApprovedPackagePath, + REQUIRED_PACKAGE_PATHS, +} from './package-files.js'; + +function readTarEntries(tarballPath: string): string[] { + const archive = gunzipSync(readFileSync(tarballPath)); + const entries: string[] = []; + for (let offset = 0; offset + 512 <= archive.length;) { + const header = archive.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) break; + const typeflag = String.fromCharCode(header[156] ?? 0); + if (typeflag === 'L' || typeflag === 'x' || typeflag === 'g') { + throw new Error(`Unsupported tar extended header type "${typeflag}" at offset ${offset}.`); + } + const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, ''); + const prefix = header.subarray(345, 500).toString('utf8').replace(/\0.*$/, ''); + const sizeText = header.subarray(124, 136).toString('ascii').replace(/\0.*$/, '').trim(); + const size = sizeText ? parseInt(sizeText, 8) : 0; + if (name) entries.push(prefix ? `${prefix}/${name}` : name); + offset += 512 + Math.ceil(size / 512) * 512; + } + return [...new Set(entries)].sort(); +} + +export function normalizedTarballInventory(tarballPath: string): readonly string[] { + return readTarEntries(tarballPath); +} + +export function validatePackageInventory(entries: readonly string[]): void { + const missing = REQUIRED_PACKAGE_PATHS.filter((path) => !entries.includes(path)); + const unexpected = entries.filter((path) => !isApprovedPackagePath(path)); + const generatedWebAssets = entries.filter((path) => + APPROVED_GENERATED_PACKAGE_PATTERNS.some((pattern) => pattern.test(path)), + ); + + if (generatedWebAssets.length === 0) { + missing.push('package/dist/web/assets/'); + } + + if (missing.length > 0 || unexpected.length > 0) { + throw new Error( + `Relay npm tarball inventory mismatch.\nMissing:\n${missing.join('\n') || '(none)'}\nUnexpected:\n${unexpected.join('\n') || '(none)'}`, + ); + } +} + +export async function inspectTarball(tarballPath: string): Promise { + validatePackageInventory(normalizedTarballInventory(tarballPath)); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const defaultArtifactDir = join(process.cwd(), '.artifacts', 'npm'); + const defaultTarball = readdirSync(defaultArtifactDir) + .filter((name) => name.endsWith('.tgz')) + .sort() + .at(-1); + const tarballPath = + process.argv[2] ?? + (defaultTarball === undefined ? undefined : join(defaultArtifactDir, defaultTarball)); + if (!tarballPath) { + process.stderr.write('Usage: inspect-tarball.ts \n'); + process.exitCode = 2; + } else { + inspectTarball(tarballPath) + .then(() => process.stdout.write(`${normalizedTarballInventory(tarballPath).join('\n')}\n`)) + .catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); + } +} diff --git a/scripts/package/package-files.ts b/scripts/package/package-files.ts new file mode 100644 index 0000000..50d8ae5 --- /dev/null +++ b/scripts/package/package-files.ts @@ -0,0 +1,50 @@ +export const REQUIRED_MIGRATION_PATHS: readonly string[] = [ + 'package/assets/migrations/0001_scaffold.sql', + 'package/assets/migrations/0002_tasks.sql', + 'package/assets/migrations/0003_task_session_id.sql', + 'package/assets/migrations/0004_task_normalized_title.sql', +]; + +export const REQUIRED_PACKAGE_PATHS: readonly string[] = [ + 'package/package.json', + 'package/dist/cli/main.js', + 'package/dist/mcp/main.js', + 'package/dist/http/main.js', + 'package/dist/web/index.html', + ...REQUIRED_MIGRATION_PATHS, + 'package/skills/relay-capture/SKILL.md', + 'package/skills/relay-session-review/SKILL.md', + 'package/skills/fixtures/capture-negative.md', + 'package/skills/fixtures/capture-positive.md', + 'package/skills/fixtures/session-review-negative.md', + 'package/skills/fixtures/session-review-positive.md', + 'package/integrations/codex/config.toml.example', + 'package/integrations/codex/README.md', + 'package/integrations/claude-code/.mcp.json.example', + 'package/integrations/claude-code/README.md', + 'package/integrations/claude-desktop/.mcpbignore', + 'package/integrations/claude-desktop/NOTICE.md', + 'package/integrations/claude-desktop/README.md', + 'package/integrations/claude-desktop/manifest.json', + 'package/integrations/claude-desktop/package.json', + 'package/integrations/claude-desktop/pnpm-lock.yaml', + 'package/integrations/generic-mcp/server-config.json.example', + 'package/integrations/generic-mcp/README.md', + 'package/integrations/generic-cli/README.md', + 'package/LICENSE', + 'package/README.md', + 'package/THIRD_PARTY_NOTICES.md', +]; + +export const APPROVED_GENERATED_PACKAGE_PATTERNS: readonly RegExp[] = [ + /^package\/dist\/web\/assets\/[A-Za-z0-9_-]+\.(?:js|css|svg|png|jpg|jpeg|webp|ico|woff2?)$/, +]; + +const approvedExactPackagePaths = new Set(REQUIRED_PACKAGE_PATHS); + +export function isApprovedPackagePath(path: string): boolean { + return ( + approvedExactPackagePaths.has(path) || + APPROVED_GENERATED_PACKAGE_PATTERNS.some((pattern) => pattern.test(path)) + ); +} diff --git a/scripts/package/smoke-installed-package.ts b/scripts/package/smoke-installed-package.ts new file mode 100644 index 0000000..062e097 --- /dev/null +++ b/scripts/package/smoke-installed-package.ts @@ -0,0 +1,348 @@ +import { execFileSync, spawn, spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { inspectTarball } from './inspect-tarball.js'; + +interface CliRun { + readonly status: number | null; + readonly stdout: string; + readonly stderr: string; +} + +function npmCommand(): string { + return process.platform === 'win32' ? 'npm.cmd' : 'npm'; +} +function pnpmCommand(): string { + return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +} + +function runPnpmBuild(rootDir: string): void { + if (process.platform === 'win32') { + execFileSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', 'pnpm build'], { + cwd: rootDir, + stdio: 'pipe', + }); + } else { + execFileSync(pnpmCommand(), ['build'], { cwd: rootDir, stdio: 'pipe' }); + } +} + +function runNpm( + args: readonly string[], + cwd: string, + encoding?: BufferEncoding, + npmCache?: string, +): string { + const env = npmCache === undefined ? process.env : { ...process.env, npm_config_cache: npmCache }; + if (process.platform === 'win32') { + const command = `npm ${args + .map((arg) => (/[\s"]/.test(arg) ? `"${arg.replaceAll('"', '\\"')}"` : arg)) + .join(' ')}`; + return execFileSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', command], { + cwd, + encoding: encoding ?? 'buffer', + stdio: encoding ? ['ignore', 'pipe', 'inherit'] : 'inherit', + env, + }) as string; + } + return execFileSync(npmCommand(), args, { cwd, encoding: encoding ?? 'buffer', env }) as string; +} + +export function readExpectedPackageVersion(rootDir: string): string { + const packagePath = join(rootDir, 'package.json'); + const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as { + readonly name?: string; + readonly version?: string; + }; + + if (parsed.name !== '@krishna916/relay') { + throw new Error(`Package smoke expected @krishna916/relay metadata at ${packagePath}.`); + } + if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(parsed.version ?? '')) { + throw new Error(`Package smoke found an invalid version in ${packagePath}.`); + } + + return parsed.version!; +} + +function pack(rootDir: string, artifactDir: string): string { + runPnpmBuild(rootDir); + const result = JSON.parse( + runNpm( + ['pack', '--json', '--pack-destination', artifactDir], + rootDir, + 'utf8', + join(artifactDir, 'npm-cache'), + ), + ) as Array<{ filename: string }>; + const filename = result[0]?.filename; + if (!filename) throw new Error('npm pack did not return a tarball filename.'); + return join(artifactDir, filename); +} + +function runCli( + commandPath: string, + cwd: string, + databasePath: string, + args: readonly string[], +): CliRun { + const result = + process.platform === 'win32' + ? spawnSync( + process.env.ComSpec ?? 'cmd.exe', + [ + '/d', + '/s', + '/c', + [commandPath, ...args].map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(' '), + ], + { cwd, env: { ...process.env, RELAY_DB_PATH: databasePath }, encoding: 'utf8' }, + ) + : spawnSync(commandPath, args, { + cwd, + env: { ...process.env, RELAY_DB_PATH: databasePath }, + encoding: 'utf8', + }); + return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; +} + +interface CliEnvelope { + readonly data?: { + readonly task?: { readonly id?: string }; + readonly change?: { readonly to?: string }; + }; +} + +function envelope(run: CliRun): CliEnvelope { + if (run.stdout.trim().split(/\r?\n/).filter(Boolean).length !== 1) + throw new Error(`Installed CLI was not JSON-clean: ${run.stdout}`); + return JSON.parse(run.stdout) as CliEnvelope; +} + +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : undefined; + server.close((error) => + error + ? reject(error) + : port + ? resolve(port) + : reject(new Error('Could not allocate a port.')), + ); + }); + }); +} + +async function waitForHttp(url: string): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + const response = await fetch(`${url}/api/health`); + if (response.ok) return; + } catch { + /* startup race */ + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Installed UI did not become ready at ${url}.`); +} + +async function verifyMcp( + commandPath: string, + installedMain: string, + cwd: string, + databasePath: string, + taskId: string, + expectedVersion: string, +): Promise { + const command = process.platform === 'win32' ? process.execPath : commandPath; + const args = process.platform === 'win32' ? [installedMain, 'mcp'] : ['mcp']; + const transport = new StdioClientTransport({ + command, + args, + cwd, + env: { ...process.env, RELAY_DB_PATH: databasePath }, + stderr: 'pipe', + }); + const client = new Client({ name: 'relay-installed-package-smoke', version: '1.0.0' }); + try { + await Promise.race([ + client.connect(transport), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Installed MCP handshake timed out.')), 15_000), + ), + ]); + const health = (await client.callTool({ name: 'relay_health', arguments: {} })) as { + content?: Array<{ text?: string }>; + }; + const parsed = JSON.parse(health.content?.[0]?.text ?? '{}') as { + version?: string; + status?: string; + }; + if (parsed.status !== 'ok' || parsed.version !== expectedVersion) + throw new Error( + `Installed MCP health reported version ${String(parsed.version)}; expected ${expectedVersion}.`, + ); + const task = (await client.callTool({ name: 'task_get', arguments: { taskId } })) as { + structuredContent?: { data?: { task?: { id?: string } } }; + }; + if (task.structuredContent?.data?.task?.id !== taskId) + throw new Error('Installed MCP did not observe the CLI-created task.'); + } finally { + await transport.close().catch(() => undefined); + } +} + +export async function verifyInstalledPackage(rootDir = process.cwd()): Promise { + const expectedVersion = readExpectedPackageVersion(rootDir); + const temporaryRoot = mkdtempSync(join(tmpdir(), 'relay-installed-package-')); + const prefix = join(temporaryRoot, 'prefix'); + const unrelatedCwd = join(temporaryRoot, 'unrelated-cwd'); + const databasePath = join(temporaryRoot, 'data', 'relay.db'); + const artifactDir = join(temporaryRoot, 'artifacts'); + mkdirSync(unrelatedCwd, { recursive: true }); + mkdirSync(artifactDir, { recursive: true }); + const tarballPath = pack(rootDir, artifactDir); + try { + runNpm( + ['install', '--global', '--prefix', prefix, tarballPath], + unrelatedCwd, + undefined, + join(artifactDir, 'npm-cache'), + ); + const commandPath = + process.platform === 'win32' ? join(prefix, 'relay.cmd') : join(prefix, 'bin', 'relay'); + const installedMain = join( + prefix, + 'node_modules', + '@krishna916', + 'relay', + 'dist', + 'cli', + 'main.js', + ); + if (!existsSync(commandPath)) + throw new Error(`Installed Relay executable is missing: ${commandPath}`); + if (commandPath.includes(rootDir)) + throw new Error('Installed smoke resolved the repository executable.'); + + const capture = runCli(commandPath, unrelatedCwd, databasePath, [ + 'task', + 'capture', + '--title', + 'Installed-Relay-task', + '--agent', + 'Smoke', + '--session', + 'installed-session', + '--workspace', + 'package', + '--source-context', + 'tarball', + '--output', + 'json', + ]); + if (capture.status !== 0 || capture.stderr !== '') + throw new Error(`Installed capture failed: ${capture.stderr}`); + const taskId = envelope(capture).data?.task?.id; + if (!taskId) throw new Error('Installed capture did not return a task id.'); + for (const args of [ + ['task', 'list', '--output', 'json'], + ['task', 'get', taskId, '--output', 'json'], + ['task', 'find-similar', '--title', 'Installed-Relay-task', '--output', 'json'], + ['session', 'captures', '--session', 'installed-session', '--output', 'json'], + ]) { + const result = runCli(commandPath, unrelatedCwd, databasePath, args); + if (result.status !== 0) + throw new Error(`Installed command failed (${args.join(' ')}): ${result.stderr}`); + envelope(result); + } + const triage = runCli(commandPath, unrelatedCwd, databasePath, [ + 'task', + 'triage', + taskId, + '--to', + 'ACTIVE', + '--output', + 'json', + ]); + if (triage.status !== 0 || envelope(triage).data?.change?.to !== 'ACTIVE') + throw new Error('Installed lifecycle mutation did not persist.'); + const invalid = runCli(commandPath, unrelatedCwd, databasePath, [ + 'task', + 'list', + '--output', + 'json', + ]); + if (invalid.status !== 0) throw new Error('Installed SQLite database could not be reopened.'); + await verifyMcp( + commandPath, + installedMain, + unrelatedCwd, + databasePath, + taskId, + expectedVersion, + ); + + const port = await findFreePort(); + const ui = + process.platform === 'win32' + ? spawn(process.execPath, [installedMain, 'ui'], { + cwd: unrelatedCwd, + env: { ...process.env, RELAY_DB_PATH: databasePath, RELAY_HTTP_PORT: String(port) }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + : spawn(commandPath, ['ui'], { + cwd: unrelatedCwd, + env: { ...process.env, RELAY_DB_PATH: databasePath, RELAY_HTTP_PORT: String(port) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + ui.stderr.on('data', (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + const url = `http://127.0.0.1:${port}`; + try { + await waitForHttp(url); + const health = (await (await fetch(`${url}/api/health`)).json()) as { version?: string }; + const tasks = (await (await fetch(`${url}/api/tasks`)).json()) as { + tasks?: Array<{ id?: string }>; + }; + const index = await (await fetch(`${url}/`)).text(); + if ( + health.version !== expectedVersion || + !tasks.tasks?.some((task) => task.id === taskId) || + !index.includes('') + ) + throw new Error( + `Installed UI health reported version ${String(health.version)}; expected ${expectedVersion}, or it did not serve the shared task database and packaged assets.`, + ); + } finally { + ui.kill(); + if (ui.exitCode === null) + await new Promise((resolve) => ui.once('exit', () => resolve())); + } + if (!stderr.includes(url)) + throw new Error(`Installed UI did not report its loopback URL: ${stderr}`); + await inspectTarball(tarballPath); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + verifyInstalledPackage() + .then(() => process.stdout.write('Installed Relay package smoke passed.\n')) + .catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/package/stage-package-assets.ts b/scripts/package/stage-package-assets.ts new file mode 100644 index 0000000..5a124e5 --- /dev/null +++ b/scripts/package/stage-package-assets.ts @@ -0,0 +1,44 @@ +import { cp, mkdir, readdir, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export interface StagePackageAssetsOptions { + readonly rootDir?: string; +} + +async function copyMigrations(rootDir: string): Promise { + const sourceDir = join(rootDir, 'src', 'database', 'migrations'); + const destinationDir = join(rootDir, 'assets', 'migrations'); + if (!existsSync(sourceDir)) throw new Error(`Package asset source is missing: ${sourceDir}`); + const entries = (await readdir(sourceDir, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith('.sql')) + .sort((left, right) => left.name.localeCompare(right.name)); + if (entries.length === 0) throw new Error('Package staging requires at least one SQL migration.'); + await rm(destinationDir, { recursive: true, force: true }); + await mkdir(destinationDir, { recursive: true }); + for (const entry of entries) + await cp(join(sourceDir, entry.name), join(destinationDir, entry.name)); +} + +export async function stagePackageAssets(options: StagePackageAssetsOptions = {}): Promise { + const rootDir = resolve(options.rootDir ?? process.cwd()); + await copyMigrations(rootDir); + for (const required of [ + 'dist/web/index.html', + 'skills/relay-capture/SKILL.md', + 'integrations/generic-mcp/README.md', + ]) { + if (!existsSync(join(rootDir, required))) + throw new Error(`Package asset is missing after build: ${join(rootDir, required)}`); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + stagePackageAssets() + .then(() => process.stdout.write('Relay package assets staged.\n')) + .catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/package/verify-package-metadata.ts b/scripts/package/verify-package-metadata.ts new file mode 100644 index 0000000..15bdcec --- /dev/null +++ b/scripts/package/verify-package-metadata.ts @@ -0,0 +1,104 @@ +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +export const REQUIRED_ONLY_BUILT_DEPENDENCIES = ['better-sqlite3', 'esbuild'] as const; +export const REQUIRED_PNPM_OVERRIDES = { tmp: '0.2.7' } as const; + +interface PackageJson { + readonly name?: string; + readonly private?: boolean; + readonly version?: string; + readonly engines?: { readonly node?: string }; + readonly bin?: Record; + readonly pnpm?: { + readonly overrides?: Record; + readonly onlyBuiltDependencies?: readonly string[]; + }; + readonly files?: readonly string[]; + readonly license?: string; +} + +export function countTopLevelKey(source: string, key: string): number { + let depth = 0; + let inString = false; + let escaped = false; + let count = 0; + const quotedKey = JSON.stringify(key); + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (inString) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') { + if (depth === 1 && source.slice(index, index + quotedKey.length) === quotedKey) { + const rest = source.slice(index + quotedKey.length).match(/^\s*:/); + if (rest) count += 1; + } + inString = true; + } else if (character === '{') depth += 1; + else if (character === '}') depth -= 1; + } + return count; +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +export function verifyPackageMetadata(rootDir: string): void { + const resolvedRoot = resolve(rootDir); + const packagePath = join(resolvedRoot, 'package.json'); + const packageText = readFileSync(packagePath, 'utf8'); + assert( + countTopLevelKey(packageText, 'pnpm') === 1, + 'package.json must contain one top-level pnpm object.', + ); + const pkg = JSON.parse(packageText) as PackageJson; + assert(pkg.name === '@krishna916/relay', 'package.json name must be @krishna916/relay.'); + assert(pkg.private !== true, 'Publishable Relay package must not be private.'); + assert( + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(pkg.version ?? ''), + 'package.json version must be SemVer.', + ); + assert(pkg.engines?.node === '>=24 <25', 'package.json engines.node must be >=24 <25.'); + assert( + JSON.stringify(pkg.bin) === JSON.stringify({ relay: './dist/cli/main.js' }), + 'package.json bin must expose only relay.', + ); + assert( + JSON.stringify(pkg.pnpm?.overrides) === JSON.stringify(REQUIRED_PNPM_OVERRIDES), + 'package.json must preserve the tmp 0.2.7 override.', + ); + assert( + JSON.stringify(pkg.pnpm?.onlyBuiltDependencies) === + JSON.stringify(REQUIRED_ONLY_BUILT_DEPENDENCIES), + 'package.json must approve better-sqlite3 and esbuild builds.', + ); + assert( + typeof pkg.license === 'string' && pkg.license.length > 0, + 'package.json must declare a license.', + ); + assert( + Array.isArray(pkg.files) && pkg.files.includes('dist/'), + 'package.json must declare a positive dist/ files allowlist.', + ); + + const lockfile = readFileSync(join(resolvedRoot, 'pnpm-lock.yaml'), 'utf8'); + assert( + !/tmp@0\.0\.33|\btmp:\s*0\.0\.33\b/.test(lockfile), + 'pnpm-lock.yaml must not regress to tmp@0.0.33.', + ); + assert(/tmp@0\.2\.7|\btmp:\s*0\.2\.7\b/.test(lockfile), 'pnpm-lock.yaml must resolve tmp@0.2.7.'); +} + +if (import.meta.url === new URL(`file://${process.argv[1] ?? ''}`).href) { + try { + verifyPackageMetadata(process.cwd()); + } catch (error: unknown) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/validate-mcpb-assets.ts b/scripts/validate-mcpb-assets.ts index 41e56cf..c4753f7 100644 --- a/scripts/validate-mcpb-assets.ts +++ b/scripts/validate-mcpb-assets.ts @@ -1,5 +1,9 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; +import { + REQUIRED_ONLY_BUILT_DEPENDENCIES, + REQUIRED_PNPM_OVERRIDES, +} from './package/verify-package-metadata.js'; export interface ValidateMcpbAssetsOptions { readonly rootDir?: string; @@ -97,6 +101,16 @@ export function validateMcpbAssets(options: ValidateMcpbAssetsOptions = {}): voi 'build:mcpb', ]) if (!root.scripts?.[script]) fail(`Root package is missing ${script}.`); + const rootPnpm = ( + root as { pnpm?: { overrides?: Record; onlyBuiltDependencies?: string[] } } + ).pnpm; + if (JSON.stringify(rootPnpm?.overrides) !== JSON.stringify(REQUIRED_PNPM_OVERRIDES)) + fail('Root pnpm metadata must preserve the tmp 0.2.7 override.'); + if ( + JSON.stringify(rootPnpm?.onlyBuiltDependencies) !== + JSON.stringify(REQUIRED_ONLY_BUILT_DEPENDENCIES) + ) + fail('Root pnpm metadata must approve better-sqlite3 and esbuild builds.'); const ignored = readFileSync(join(sourceDir, '.mcpbignore'), 'utf8'); for (const value of ['.env', '*.db', '*.log', '*.map', 'tests/', 'coverage/']) if (!ignored.includes(value)) fail(`.mcpbignore must exclude ${value}.`); diff --git a/scripts/validate-repository-assets.ts b/scripts/validate-repository-assets.ts index 5c9d215..ac4a514 100644 --- a/scripts/validate-repository-assets.ts +++ b/scripts/validate-repository-assets.ts @@ -5,6 +5,7 @@ import { parse as parseToml } from '@iarna/toml'; import { validateSkillAssets } from './validate-skill-assets.js'; import { validateAgentIntegrationAssets } from './validate-agent-integration-assets.js'; import { validateMcpbAssets } from './validate-mcpb-assets.js'; +import { verifyPackageMetadata } from './package/verify-package-metadata.js'; function fail(msg: string): never { throw new Error(`[ASSET VALIDATION FAILURE] ${msg}`); @@ -430,16 +431,13 @@ export function validateRepositoryAssets(options: ValidateRepositoryAssetsOption bin?: Record; }; validateDistributionContract(rootDir, pkg); - const binRelayMcp = pkg.bin?.['relay-mcp']; + if (pkg.name === '@krishna916/relay' && existsSync(join(rootDir, 'pnpm-lock.yaml'))) { + verifyPackageMetadata(rootDir); + } const binRelay = pkg.bin?.relay; if (binRelay !== './dist/cli/main.js') { fail(`package.json#bin.relay must point to ./dist/cli/main.js (got ${String(binRelay)})`); } - if (binRelayMcp !== './dist/mcp/main.js') { - fail( - `package.json#bin.relay-mcp must point to ./dist/mcp/main.js (got ${String(binRelayMcp)})`, - ); - } // 3. Built MCP file existence after build if (!existsSync(join(rootDir, 'dist', 'mcp', 'main.js'))) { diff --git a/src/database/database-config.ts b/src/database/database-config.ts index 400cddb..e493bf7 100644 --- a/src/database/database-config.ts +++ b/src/database/database-config.ts @@ -1,32 +1,11 @@ -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { RelayError } from '../shared/errors.js'; +import { resolveRuntimePaths } from '../distribution/resolve-runtime-paths.js'; export function getDefaultDatabasePath(): string { - const home = homedir(); - if (process.platform === 'win32') { - const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming'); - return join(appData, 'relay', 'relay.db'); - } - if (process.platform === 'darwin') { - return join(home, 'Library', 'Application Support', 'relay', 'relay.db'); - } - const xdgData = process.env.XDG_DATA_HOME || join(home, '.local', 'share'); - return join(xdgData, 'relay', 'relay.db'); + return resolveRuntimePaths().databasePath; } export function resolveDatabasePath(explicitPath?: string): string { - if (explicitPath !== undefined) { - if (!explicitPath.trim()) { - throw new RelayError('Database path cannot be empty or whitespace only.'); - } - return explicitPath.trim(); - } - - const envPath = process.env.RELAY_DB_PATH; - if (envPath && envPath.trim()) { - return envPath.trim(); - } - - return getDefaultDatabasePath(); + return resolveRuntimePaths( + explicitPath === undefined ? {} : { explicitDatabasePath: explicitPath }, + ).databasePath; } diff --git a/src/database/migrate.ts b/src/database/migrate.ts index e91d958..104014f 100644 --- a/src/database/migrate.ts +++ b/src/database/migrate.ts @@ -1,7 +1,9 @@ import type Database from 'better-sqlite3'; +import { existsSync } from 'node:fs'; import { loadMigrationFiles } from './migration.js'; import { RelayError } from '../shared/errors.js'; import { resolveFromPackageRoot } from '../shared/runtime-paths.js'; +import { resolvePackageAssets } from '../distribution/package-assets.js'; import { normalizeTaskTitleV1 } from './migrations/functions/normalize-task-title-v1.js'; export interface MigrationOptions { @@ -10,8 +12,12 @@ export interface MigrationOptions { export function runMigrations(db: Database.Database, options: MigrationOptions = {}): void { db.function('relay_normalize_task_title_v1', normalizeTaskTitleV1); + const packageAssets = resolvePackageAssets(); const migrationsDir = - options.migrationsDir || resolveFromPackageRoot('src', 'database', 'migrations'); + options.migrationsDir || + (existsSync(packageAssets.migrationsDir) + ? packageAssets.migrationsDir + : resolveFromPackageRoot('src', 'database', 'migrations')); db.exec(` CREATE TABLE IF NOT EXISTS _relay_migrations ( diff --git a/src/distribution/package-assets.ts b/src/distribution/package-assets.ts new file mode 100644 index 0000000..f494ac6 --- /dev/null +++ b/src/distribution/package-assets.ts @@ -0,0 +1,58 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { RelayError } from '../shared/errors.js'; + +const RELAY_PACKAGE_NAMES = new Set(['@krishna916/relay', 'relay']); + +export interface PackageAssets { + readonly packageRoot: string; + readonly migrationsDir: string; + readonly webRoot: string; + readonly skillsDir: string; + readonly integrationsDir: string; +} + +function findPackageRoot(startDirectory: string): string { + let current = resolve(startDirectory); + for (;;) { + const packagePath = join(current, 'package.json'); + if (existsSync(packagePath)) { + const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as { name?: string }; + if (parsed.name && RELAY_PACKAGE_NAMES.has(parsed.name)) return current; + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + throw new RelayError( + `Unable to locate the installed @krishna916/relay package (or MCPB relay package) from ${startDirectory}. Reinstall Relay and try again.`, + ); +} + +export function resolvePackageAssets(moduleUrl = import.meta.url): PackageAssets { + const modulePath = moduleUrl.startsWith('file:') ? fileURLToPath(moduleUrl) : moduleUrl; + const packageRoot = findPackageRoot(dirname(modulePath)); + return { + packageRoot, + migrationsDir: join(packageRoot, 'assets', 'migrations'), + webRoot: join(packageRoot, 'dist', 'web'), + skillsDir: join(packageRoot, 'skills'), + integrationsDir: join(packageRoot, 'integrations'), + }; +} + +export function assertPackageAssetDirectories(assets: PackageAssets): void { + for (const [label, path] of [ + ['migrations', assets.migrationsDir], + ['web', assets.webRoot], + ['skills', assets.skillsDir], + ['integrations', assets.integrationsDir], + ] as const) { + if (!existsSync(path)) { + throw new RelayError( + `Installed Relay package is missing its ${label} assets at ${path}. Reinstall Relay and try again.`, + ); + } + } +} diff --git a/src/distribution/package-version.ts b/src/distribution/package-version.ts new file mode 100644 index 0000000..5e49a06 --- /dev/null +++ b/src/distribution/package-version.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { RelayError } from '../shared/errors.js'; +import { resolvePackageAssets, type PackageAssets } from './package-assets.js'; + +const cachedVersions = new Map(); + +export function readPackageVersion(assets: PackageAssets = resolvePackageAssets()): string { + const cachedVersion = cachedVersions.get(assets.packageRoot); + if (cachedVersion !== undefined) return cachedVersion; + const packagePath = join(assets.packageRoot, 'package.json'); + const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')) as { version?: string }; + if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(packageJson.version ?? '')) { + throw new RelayError( + `Installed Relay package has an invalid version in ${packagePath}. Reinstall Relay and try again.`, + ); + } + const version = packageJson.version; + if (version === undefined) + throw new RelayError( + `Installed Relay package has no version in ${packagePath}. Reinstall Relay and try again.`, + ); + cachedVersions.set(assets.packageRoot, version); + return version; +} diff --git a/src/distribution/platform-paths.ts b/src/distribution/platform-paths.ts new file mode 100644 index 0000000..31b592f --- /dev/null +++ b/src/distribution/platform-paths.ts @@ -0,0 +1,53 @@ +import { join as posixJoin } from 'node:path/posix'; +import { join as win32Join } from 'node:path/win32'; + +export interface PlatformPathInput { + readonly platform: NodeJS.Platform; + readonly homeDir: string; + readonly env: Readonly>; +} + +export interface RuntimePaths { + readonly dataRoot: string; + readonly configRoot: string; + readonly cacheRoot: string; + readonly databasePath: string; +} + +export function getPlatformDefaultPaths(input: PlatformPathInput): RuntimePaths { + if (input.platform === 'win32') { + const localAppData = input.env.LOCALAPPDATA ?? win32Join(input.homeDir, 'AppData', 'Local'); + const appData = input.env.APPDATA ?? win32Join(input.homeDir, 'AppData', 'Roaming'); + const dataRoot = win32Join(localAppData, 'Relay'); + return { + dataRoot, + configRoot: win32Join(appData, 'Relay'), + cacheRoot: win32Join(dataRoot, 'Cache'), + databasePath: win32Join(dataRoot, 'relay.db'), + }; + } + + if (input.platform === 'darwin') { + const dataRoot = posixJoin(input.homeDir, 'Library', 'Application Support', 'Relay'); + return { + dataRoot, + configRoot: posixJoin(dataRoot, 'config'), + cacheRoot: posixJoin(input.homeDir, 'Library', 'Caches', 'Relay'), + databasePath: posixJoin(dataRoot, 'relay.db'), + }; + } + + const dataRoot = posixJoin( + input.env.XDG_DATA_HOME ?? posixJoin(input.homeDir, '.local', 'share'), + 'relay', + ); + return { + dataRoot, + configRoot: posixJoin( + input.env.XDG_CONFIG_HOME ?? posixJoin(input.homeDir, '.config'), + 'relay', + ), + cacheRoot: posixJoin(input.env.XDG_CACHE_HOME ?? posixJoin(input.homeDir, '.cache'), 'relay'), + databasePath: posixJoin(dataRoot, 'relay.db'), + }; +} diff --git a/src/distribution/resolve-runtime-paths.ts b/src/distribution/resolve-runtime-paths.ts new file mode 100644 index 0000000..dcf40ad --- /dev/null +++ b/src/distribution/resolve-runtime-paths.ts @@ -0,0 +1,48 @@ +import { homedir } from 'node:os'; +import { isAbsolute as posixIsAbsolute } from 'node:path/posix'; +import { isAbsolute as win32IsAbsolute } from 'node:path/win32'; +import { RelayError } from '../shared/errors.js'; +import { getPlatformDefaultPaths, type RuntimePaths } from './platform-paths.js'; + +export type { PlatformPathInput, RuntimePaths } from './platform-paths.js'; +export { getPlatformDefaultPaths } from './platform-paths.js'; + +interface RuntimePathInput { + readonly explicitDatabasePath?: string; + readonly platform?: NodeJS.Platform; + readonly homeDir?: string; + readonly env?: Readonly>; +} + +function isAbsoluteForPlatform(value: string, platform: NodeJS.Platform): boolean { + return platform === 'win32' ? win32IsAbsolute(value) : posixIsAbsolute(value); +} + +function selectDatabasePath( + input: RuntimePathInput, + paths: RuntimePaths, + platform: NodeJS.Platform, + env: Readonly>, +): string { + const fromExplicitInput = input.explicitDatabasePath !== undefined; + const candidate = fromExplicitInput ? input.explicitDatabasePath : env.RELAY_DB_PATH; + if (candidate !== undefined) { + const normalized = candidate.trim(); + if (!normalized) + throw new RelayError('RELAY_DB_PATH/database path cannot be empty or whitespace only.'); + const isExplicitInMemory = fromExplicitInput && normalized === ':memory:'; + if (!isExplicitInMemory && !isAbsoluteForPlatform(normalized, platform)) { + throw new RelayError(`Database path must be absolute: ${normalized}`); + } + return normalized; + } + return paths.databasePath; +} + +export function resolveRuntimePaths(input: RuntimePathInput = {}): RuntimePaths { + const platform = input.platform ?? process.platform; + const homeDir = input.homeDir ?? homedir(); + const env = input.env ?? process.env; + const defaults = getPlatformDefaultPaths({ platform, homeDir, env }); + return { ...defaults, databasePath: selectDatabasePath(input, defaults, platform, env) }; +} diff --git a/src/interfaces/cli/main.ts b/src/interfaces/cli/main.ts index be75709..6fe5e70 100644 --- a/src/interfaces/cli/main.ts +++ b/src/interfaces/cli/main.ts @@ -1,10 +1,18 @@ #!/usr/bin/env node -import { createTaskRuntime } from '../shared/create-task-runtime.js'; import { runCli } from './run-cli.js'; +import { runRelay } from './run-relay.js'; +import { createTaskRuntime } from '../shared/create-task-runtime.js'; +import { runMcpServer, runUiServer } from '../production-dependencies.js'; -void runCli(process.argv.slice(2), { - createRuntime: createTaskRuntime, - stdout: process.stdout, +void runRelay(process.argv.slice(2), { + runTaskCommand: (argv) => + runCli(argv, { + createRuntime: createTaskRuntime, + stdout: process.stdout, + stderr: process.stderr, + }), + runMcp: runMcpServer, + runUi: runUiServer, stderr: process.stderr, }).then((exitCode) => { process.exitCode = exitCode; diff --git a/src/interfaces/cli/run-relay.ts b/src/interfaces/cli/run-relay.ts new file mode 100644 index 0000000..2600770 --- /dev/null +++ b/src/interfaces/cli/run-relay.ts @@ -0,0 +1,18 @@ +export interface RelayCommandDependencies { + readonly runTaskCommand: (argv: readonly string[]) => Promise; + readonly runMcp: () => Promise; + readonly runUi: () => Promise; + readonly stderr: { write(text: string): unknown }; +} + +export async function runRelay( + argv: readonly string[], + dependencies: RelayCommandDependencies, +): Promise { + const command = argv[0]; + if (command === 'mcp') return (await dependencies.runMcp()) ?? 0; + if (command === 'ui') return (await dependencies.runUi()) ?? 0; + if (command === 'task' || command === 'session') return dependencies.runTaskCommand(argv); + dependencies.stderr.write('Unknown or missing command.\n'); + return 2; +} diff --git a/src/interfaces/http/create-http-server.ts b/src/interfaces/http/create-http-server.ts index 4753b2b..a06b701 100644 --- a/src/interfaces/http/create-http-server.ts +++ b/src/interfaces/http/create-http-server.ts @@ -3,13 +3,14 @@ import { existsSync, statSync } from 'node:fs'; import { extname, relative, resolve } from 'node:path'; import type { TaskApplication } from '../../application/tasks/task-application.js'; import { RelayError } from '../../shared/errors.js'; -import { resolveFromPackageRoot } from '../../shared/runtime-paths.js'; +import { resolvePackageAssets, type PackageAssets } from '../../distribution/package-assets.js'; import { routeHttpRequest } from './http-router.js'; export interface HttpServerOptions { readonly host?: string; readonly port?: number; readonly taskApplication: TaskApplication; + readonly assets?: PackageAssets; } export interface HttpServerInstance { @@ -20,8 +21,6 @@ export interface HttpServerInstance { readonly stop: () => Promise; } -const webBuildDirectory = resolveFromPackageRoot('dist', 'web'); - export function getContentType(filePath: string): string { switch (extname(filePath)) { case '.html': @@ -41,7 +40,7 @@ export function getContentType(filePath: string): string { } } -export function resolveStaticAsset(pathname: string): string | null { +export function resolveStaticAsset(pathname: string, webBuildDirectory: string): string | null { if (!existsSync(webBuildDirectory)) { return null; } @@ -92,6 +91,7 @@ export function resolveHttpPort(explicitPort?: number): number { export function createHttpServer(options: HttpServerOptions): Promise { const host = options.host || '127.0.0.1'; let port: number; + let webRoot: string; try { port = resolveHttpPort(options.port); @@ -100,6 +100,7 @@ export function createHttpServer(options: HttpServerOptions): Promise { void routeHttpRequest(req, res, { taskApplication: options.taskApplication, - getStaticAsset: resolveStaticAsset, + getStaticAsset: (pathname) => resolveStaticAsset(pathname, webRoot), getContentType, }); }; diff --git a/src/interfaces/http/http-router.ts b/src/interfaces/http/http-router.ts index e5d445d..d8a3a09 100644 --- a/src/interfaces/http/http-router.ts +++ b/src/interfaces/http/http-router.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { readFileSync } from 'node:fs'; +import { extname } from 'node:path'; import type { TaskApplication } from '../../application/tasks/task-application.js'; import { TaskApplicationError } from '../../application/tasks/task-application-errors.js'; import { getHealth } from '../../application/health/get-health.js'; @@ -37,7 +38,9 @@ export async function routeHttpRequest( if (url.pathname.startsWith('/api/')) throw new HttpError(404, 'NOT_FOUND', 'API route was not found.'); if (request.method === 'GET' || request.method === 'HEAD') { - const asset = options.getStaticAsset(url.pathname); + const asset = + options.getStaticAsset(url.pathname) ?? + (extname(url.pathname) === '' ? options.getStaticAsset('/') : null); if (asset) { response.writeHead(200, { 'Content-Type': options.getContentType(asset) }); response.end(request.method === 'HEAD' ? undefined : readFileSync(asset)); diff --git a/src/interfaces/http/main.ts b/src/interfaces/http/main.ts index 5b55f21..6785de5 100644 --- a/src/interfaces/http/main.ts +++ b/src/interfaces/http/main.ts @@ -1,12 +1,18 @@ import { createHttpServer } from './create-http-server.js'; import { createTaskRuntime } from '../shared/create-task-runtime.js'; +import type { PackageAssets } from '../../distribution/package-assets.js'; -async function main(): Promise { +export async function runUiServer( + options: { readonly assets?: PackageAssets } = {}, +): Promise { try { const runtime = createTaskRuntime(); let instance; try { - instance = await createHttpServer({ taskApplication: runtime.taskApplication }); + instance = await createHttpServer({ + taskApplication: runtime.taskApplication, + ...(options.assets === undefined ? {} : { assets: options.assets }), + }); } catch (error) { runtime.close(); throw error; @@ -35,4 +41,4 @@ async function main(): Promise { } } -void main(); +if (/[\\/]http[\\/]main\.(?:js|ts)$/.test(process.argv[1] ?? '')) void runUiServer(); diff --git a/src/interfaces/mcp/main.ts b/src/interfaces/mcp/main.ts index 8c9fa37..e92cb21 100644 --- a/src/interfaces/mcp/main.ts +++ b/src/interfaces/mcp/main.ts @@ -3,10 +3,10 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { createMcpServer } from './create-mcp-server.js'; import { mcpLogger } from './logger.js'; import { createTaskRuntime } from '../shared/create-task-runtime.js'; -import { runMcpServer } from './run-mcp-server.js'; +import { runMcpServer as runMcpServerWithDependencies } from './run-mcp-server.js'; -async function main(): Promise { - await runMcpServer({ +export async function runMcpServer(): Promise { + const started = await runMcpServerWithDependencies({ createRuntime: createTaskRuntime, createServer: createMcpServer, createTransport: () => new StdioServerTransport(), @@ -16,6 +16,8 @@ async function main(): Promise { process.exitCode = 1; }, }); + return started ? 0 : 1; } -void main(); +if (/(?:[\\/]mcp|[\\/]server)[\\/]main\.(?:js|ts)$/.test(process.argv[1] ?? '')) + void runMcpServer(); diff --git a/src/interfaces/production-dependencies.ts b/src/interfaces/production-dependencies.ts new file mode 100644 index 0000000..d15b324 --- /dev/null +++ b/src/interfaces/production-dependencies.ts @@ -0,0 +1,4 @@ +import { runMcpServer } from './mcp/main.js'; +import { runUiServer } from './http/main.js'; + +export { runMcpServer, runUiServer }; diff --git a/src/shared/package-metadata.ts b/src/shared/package-metadata.ts index 4473600..90ec076 100644 --- a/src/shared/package-metadata.ts +++ b/src/shared/package-metadata.ts @@ -1,5 +1,5 @@ -import { readFileSync } from 'node:fs'; -import { resolveFromPackageRoot } from './runtime-paths.js'; +import { resolvePackageAssets } from '../distribution/package-assets.js'; +import { readPackageVersion } from '../distribution/package-version.js'; export interface PackageMetadata { readonly name: string; @@ -11,13 +11,13 @@ let cachedMetadata: PackageMetadata | null = null; export function getPackageMetadata(): PackageMetadata { if (cachedMetadata) return cachedMetadata; - const pkgPath = resolveFromPackageRoot('package.json'); - const content = readFileSync(pkgPath, 'utf-8'); - const parsed = JSON.parse(content) as { name: string; version: string }; + const assets = resolvePackageAssets(); cachedMetadata = { - name: parsed.name, - version: parsed.version, + // Protocol and health contracts retain the application name relay; + // npm publication identity is validated separately as @krishna916/relay. + name: 'relay', + version: readPackageVersion(assets), }; return cachedMetadata; } diff --git a/tests/fixtures/package-root/package.json b/tests/fixtures/package-root/package.json new file mode 100644 index 0000000..7322d75 --- /dev/null +++ b/tests/fixtures/package-root/package.json @@ -0,0 +1,4 @@ +{ + "name": "@krishna916/relay", + "version": "9.8.7" +} diff --git a/tests/fixtures/package-smoke/README.md b/tests/fixtures/package-smoke/README.md new file mode 100644 index 0000000..79893cd --- /dev/null +++ b/tests/fixtures/package-smoke/README.md @@ -0,0 +1,5 @@ +# Installed package smoke fixture + +The package smoke harness creates its own prefix, unrelated working directory, +temporary database, and tarball. It never resolves the repository's local +executable or durable database. diff --git a/tests/integration/http-health.test.ts b/tests/integration/http-health.test.ts index fee2f70..6d9ee0f 100644 --- a/tests/integration/http-health.test.ts +++ b/tests/integration/http-health.test.ts @@ -58,14 +58,17 @@ describe('http-health integration', () => { expect(res.headers.get('allow')).toBe('GET'); }); - it('returns 404 Not Found for unknown routes', async () => { + it('serves the web shell for unknown client routes', async () => { serverInstance = await createServer(); const { url } = serverInstance; const res = await fetch(`${url}/unknown-route`); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: { code: string } }; - expect(body).toEqual({ error: { code: 'NOT_FOUND', message: 'Route was not found.' } }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + await expect(res.text()).resolves.toContain('
'); + + const apiResponse = await fetch(`${url}/api/unknown-route`); + expect(apiResponse.status).toBe(404); }); it('serves the built web shell from GET / when present', async () => { diff --git a/tests/integration/installed-package.test.ts b/tests/integration/installed-package.test.ts new file mode 100644 index 0000000..046f5c6 --- /dev/null +++ b/tests/integration/installed-package.test.ts @@ -0,0 +1,30 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + readExpectedPackageVersion, + verifyInstalledPackage, +} from '../../scripts/package/smoke-installed-package.js'; + +it('derives the expected smoke version from package metadata', () => { + const root = mkdtempSync(join(tmpdir(), 'relay-version-fixture-')); + try { + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: '@krishna916/relay', version: '9.8.7' }), + 'utf8', + ); + + expect(readExpectedPackageVersion(root)).toBe('9.8.7'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe.skipIf(process.env.RELAY_RUN_PACKAGE_SMOKE !== '1')('installed Relay npm package', () => { + it('executes from an isolated prefix and unrelated cwd', async () => { + await verifyInstalledPackage(); + }, 300_000); +}); diff --git a/tests/integration/mcpb-stage.test.ts b/tests/integration/mcpb-stage.test.ts index faacd71..cc757bb 100644 --- a/tests/integration/mcpb-stage.test.ts +++ b/tests/integration/mcpb-stage.test.ts @@ -1,5 +1,6 @@ import { createRequire } from 'node:module'; -import { lstat, readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { lstat } from 'node:fs/promises'; import { join, relative } from 'node:path'; import { describe, expect, it } from 'vitest'; import { @@ -11,8 +12,11 @@ const runLinuxMcpbStageTests = process.platform === 'linux' && process.env.RELAY_RUN_MCPB_STAGE_TESTS === '1'; describe.skipIf(!runLinuxMcpbStageTests)('Linux MCPB staged runtime', () => { - it('includes the native SQLite addon and emitted esbuild runtime chunks', async () => { + it('includes the staged MCP entry point and native SQLite addon', async () => { const stageDir = join(process.cwd(), '.mcpb', 'relay'); + const serverPath = join(stageDir, 'server', 'main.js'); + expect(existsSync(serverPath)).toBe(true); + const requireFromStage = createRequire(join(stageDir, 'package.json')); const resolvedDatabase = requireFromStage.resolve('better-sqlite3'); expect(relative(stageDir, resolvedDatabase)).not.toMatch(/^\.\.(?:[/\\]|$)/); @@ -21,9 +25,6 @@ describe.skipIf(!runLinuxMcpbStageTests)('Linux MCPB staged runtime', () => { }; const database = new Database(':memory:'); database.close(); - - const stagedFiles = await readdir(stageDir); - expect(stagedFiles.some((file) => /^chunk-.+\.js$/.test(file))).toBe(true); }); it('materializes SDK transitive dependencies without archive-fragile symlinks', async () => { diff --git a/tests/integration/package-tarball.test.ts b/tests/integration/package-tarball.test.ts new file mode 100644 index 0000000..0fe6f38 --- /dev/null +++ b/tests/integration/package-tarball.test.ts @@ -0,0 +1,116 @@ +import { execFileSync } from 'node:child_process'; +import { gzipSync } from 'node:zlib'; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { REQUIRED_PACKAGE_PATHS } from '../../scripts/package/package-files.js'; +import { + inspectTarball, + normalizedTarballInventory, + validatePackageInventory, +} from '../../scripts/package/inspect-tarball.js'; + +describe('Relay npm tarball', () => { + it.each(['L', 'x', 'g'] as const)('rejects unsupported tar header type %s', (typeflag) => { + const artifactRoot = mkdtempSync(join(tmpdir(), 'relay-tar-header-')); + try { + const header = Buffer.alloc(512); + header[156] = typeflag.charCodeAt(0); + const tarballPath = join(artifactRoot, 'unsupported.tgz'); + writeFileSync(tarballPath, gzipSync(Buffer.concat([header, Buffer.alloc(512)]))); + + expect(() => normalizedTarballInventory(tarballPath)).toThrowError( + `Unsupported tar extended header type "${typeflag}" at offset 0.`, + ); + } finally { + rmSync(artifactRoot, { recursive: true, force: true }); + } + }); + + it('rejects an unexpected file inside an otherwise published directory', () => { + const inventory = [ + ...REQUIRED_PACKAGE_PATHS, + 'package/dist/web/assets/index-ABC123.js', + 'package/skills/relay-capture/private-notes.txt', + ]; + + expect(() => validatePackageInventory(inventory)).toThrowError( + /Unexpected:\npackage\/skills\/relay-capture\/private-notes\.txt/, + ); + }); + + it('accepts only narrowly generated Vite assets', () => { + const base = [...REQUIRED_PACKAGE_PATHS]; + + expect(() => + validatePackageInventory([ + ...base, + 'package/dist/web/assets/index-ABC123.js', + 'package/dist/web/assets/index-ABC123.css', + ]), + ).not.toThrow(); + + for (const unexpected of [ + 'package/dist/web/assets/secrets.json', + 'package/dist/web/assets/nested/index-ABC123.js', + 'package/dist/web/debug.txt', + 'package/dist/cli/extra.js', + ]) { + expect(() => + validatePackageInventory([...base, 'package/dist/web/assets/index-ABC123.js', unexpected]), + ).toThrowError(new RegExp(unexpected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } + }); + + it('rejects missing required files separately from unexpected files', () => { + const inventory = REQUIRED_PACKAGE_PATHS.filter( + (path) => path !== 'package/assets/migrations/0004_task_normalized_title.sql', + ); + + expect(() => validatePackageInventory(inventory)).toThrowError( + /Missing:\npackage\/assets\/migrations\/0004_task_normalized_title\.sql/, + ); + }); + + it('inspects the actual npm pack archive', async () => { + const artifactRoot = mkdtempSync(join(tmpdir(), 'relay-npm-pack-')); + mkdirSync(artifactRoot, { recursive: true }); + try { + if (process.platform === 'win32') { + execFileSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', 'pnpm build'], { + cwd: process.cwd(), + stdio: 'pipe', + }); + } else { + execFileSync('pnpm', ['build'], { cwd: process.cwd(), stdio: 'pipe' }); + } + const packOutput = + process.platform === 'win32' + ? execFileSync( + process.env.ComSpec ?? 'cmd.exe', + ['/d', '/s', '/c', `npm pack --json --pack-destination ${artifactRoot}`], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, npm_config_cache: join(artifactRoot, 'npm-cache') }, + }, + ) + : execFileSync('npm', ['pack', '--json', '--pack-destination', artifactRoot], { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, npm_config_cache: join(artifactRoot, 'npm-cache') }, + }); + const result = JSON.parse(packOutput) as Array<{ filename: string }>; + const tarballPath = join(artifactRoot, result[0]?.filename ?? ''); + await inspectTarball(tarballPath); + const inventory = normalizedTarballInventory(tarballPath); + expect(inventory).toEqual([...inventory].sort()); + expect(readdirSync(artifactRoot).filter((entry) => entry.endsWith('.tgz'))).toEqual([ + result[0]?.filename, + ]); + } finally { + rmSync(artifactRoot, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/tests/integration/packaged-assets.test.ts b/tests/integration/packaged-assets.test.ts new file mode 100644 index 0000000..97c9f93 --- /dev/null +++ b/tests/integration/packaged-assets.test.ts @@ -0,0 +1,38 @@ +import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { stagePackageAssets } from '../../scripts/package/stage-package-assets.js'; + +describe('packaged immutable assets', () => { + it('stages canonical migrations without touching mutable runtime paths', async () => { + const rootDir = mkdtempSync(join(tmpdir(), 'relay-package-assets-')); + try { + cpSync( + join(process.cwd(), 'src', 'database', 'migrations'), + join(rootDir, 'src', 'database', 'migrations'), + { recursive: true }, + ); + mkdirSync(join(rootDir, 'dist', 'web'), { recursive: true }); + writeFileSync(join(rootDir, 'dist', 'web', 'index.html'), ''); + mkdirSync(join(rootDir, 'skills', 'relay-capture'), { recursive: true }); + writeFileSync(join(rootDir, 'skills', 'relay-capture', 'SKILL.md'), '# Relay Capture\n'); + mkdirSync(join(rootDir, 'integrations', 'generic-mcp'), { recursive: true }); + writeFileSync(join(rootDir, 'integrations', 'generic-mcp', 'README.md'), '# Generic MCP\n'); + + await stagePackageAssets({ rootDir }); + const staged = join(rootDir, 'assets', 'migrations', '0001_scaffold.sql'); + expect(existsSync(staged)).toBe(true); + expect(await readFile(staged, 'utf8')).toBe( + await readFile( + join(process.cwd(), 'src', 'database', 'migrations', '0001_scaffold.sql'), + 'utf8', + ), + ); + expect(existsSync(join(rootDir, 'assets', 'relay.db'))).toBe(false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/database/connection.test.ts b/tests/unit/database/connection.test.ts index f8ce233..e139ef5 100644 --- a/tests/unit/database/connection.test.ts +++ b/tests/unit/database/connection.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; const mockPragma = vi.fn(); const mockClose = vi.fn(); @@ -30,7 +32,16 @@ describe('createDatabaseConnection', () => { const { createDatabaseConnection } = await import('../../../src/database/connection.js'); - expect(() => createDatabaseConnection({ path: 'tmp/test.db' })).toThrow(/journal mode/i); + expect(() => + createDatabaseConnection({ path: join(tmpdir(), 'relay-connection-test.db') }), + ).toThrow(/journal mode/i); expect(mockClose).toHaveBeenCalledTimes(1); }); + + it('rejects relative injected database paths', async () => { + const { createDatabaseConnection } = await import('../../../src/database/connection.js'); + + expect(() => createDatabaseConnection({ path: 'tmp/test.db' })).toThrow(/absolute/i); + expect(mockDatabase).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/distribution/package-assets.test.ts b/tests/unit/distribution/package-assets.test.ts new file mode 100644 index 0000000..8fc2fbd --- /dev/null +++ b/tests/unit/distribution/package-assets.test.ts @@ -0,0 +1,73 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { readPackageVersion } from '../../../src/distribution/package-version.js'; +import { resolvePackageAssets } from '../../../src/distribution/package-assets.js'; + +describe('packaged immutable assets', () => { + const temporaryRoots: string[] = []; + afterEach(() => { + for (const root of temporaryRoots) rmSync(root, { recursive: true, force: true }); + temporaryRoots.length = 0; + }); + + it('walks from a module URL to the named package root independently of cwd', () => { + const fixtureRoot = join(process.cwd(), 'tests', 'fixtures', 'package-root'); + const originalCwd = process.cwd(); + const unrelated = mkdtempSync(join(tmpdir(), 'relay-unrelated-')); + temporaryRoots.push(unrelated); + process.chdir(unrelated); + try { + const assets = resolvePackageAssets( + pathToFileURL(join(fixtureRoot, 'dist', 'cli', 'main.js')).href, + ); + expect(assets.packageRoot).toBe(fixtureRoot); + expect(assets.migrationsDir).toBe(join(fixtureRoot, 'assets', 'migrations')); + expect(assets.webRoot).toBe(join(fixtureRoot, 'dist', 'web')); + expect(assets.skillsDir).toBe(join(fixtureRoot, 'skills')); + expect(assets.integrationsDir).toBe(join(fixtureRoot, 'integrations')); + expect(assets.packageRoot).not.toContain(unrelated); + expect(readPackageVersion(assets)).toBe('9.8.7'); + } finally { + process.chdir(originalCwd); + } + }); + + it('rejects an entry that is not inside a named Relay package', () => { + const root = mkdtempSync(join(tmpdir(), 'relay-missing-assets-')); + temporaryRoots.push(root); + mkdirSync(join(root, 'nested'), { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'not-relay', version: '1.0.0' }), + ); + expect(() => resolvePackageAssets(pathToFileURL(join(root, 'nested', 'main.js')).href)).toThrow( + /@krishna916\/relay.*reinstall/i, + ); + }); + + it('accepts the relay identity used by staged MCPB runtimes', () => { + const root = mkdtempSync(join(tmpdir(), 'relay-mcpb-assets-')); + temporaryRoots.push(root); + mkdirSync(join(root, 'server'), { recursive: true }); + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'relay', version: '1.0.0' })); + + expect( + resolvePackageAssets(pathToFileURL(join(root, 'server', 'main.js')).href).packageRoot, + ).toBe(root); + }); + + it('fails with an actionable message when package metadata is invalid', () => { + const root = mkdtempSync(join(tmpdir(), 'relay-invalid-version-')); + temporaryRoots.push(root); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: '@krishna916/relay', version: 'dev' }), + ); + expect(() => + readPackageVersion(resolvePackageAssets(pathToFileURL(join(root, 'main.js')).href)), + ).toThrow(/invalid version.*reinstall/i); + }); +}); diff --git a/tests/unit/distribution/runtime-paths.test.ts b/tests/unit/distribution/runtime-paths.test.ts new file mode 100644 index 0000000..49c86b0 --- /dev/null +++ b/tests/unit/distribution/runtime-paths.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { + getPlatformDefaultPaths, + resolveRuntimePaths, +} from '../../../src/distribution/resolve-runtime-paths.js'; + +describe('Relay runtime paths', () => { + it.each([ + ['win32', 'C:\\Users\\relay', 'C:\\Users\\relay\\AppData\\Local\\Relay\\relay.db'], + ['darwin', '/Users/relay', '/Users/relay/Library/Application Support/Relay/relay.db'], + ['linux', '/home/relay', '/home/relay/.local/share/relay/relay.db'], + ] as const)( + 'calculates %s defaults without process globals', + (platform, homeDir, databasePath) => { + const paths = getPlatformDefaultPaths({ platform, homeDir, env: {} }); + expect(paths.databasePath).toBe(databasePath); + expect(paths.dataRoot).toBe( + platform === 'win32' + ? 'C:\\Users\\relay\\AppData\\Local\\Relay' + : platform === 'darwin' + ? '/Users/relay/Library/Application Support/Relay' + : '/home/relay/.local/share/relay', + ); + }, + ); + + it('uses Windows local and roaming application roots', () => { + expect( + getPlatformDefaultPaths({ + platform: 'win32', + homeDir: 'C:\\Users\\relay', + env: { LOCALAPPDATA: 'C:\\Local', APPDATA: 'C:\\Roaming' }, + }), + ).toEqual({ + dataRoot: 'C:\\Local\\Relay', + configRoot: 'C:\\Roaming\\Relay', + cacheRoot: 'C:\\Local\\Relay\\Cache', + databasePath: 'C:\\Local\\Relay\\relay.db', + }); + }); + + it('uses Linux XDG overrides', () => { + expect( + getPlatformDefaultPaths({ + platform: 'linux', + homeDir: '/home/relay', + env: { + XDG_DATA_HOME: '/var/data', + XDG_CONFIG_HOME: '/var/config', + XDG_CACHE_HOME: '/var/cache', + }, + }), + ).toEqual({ + dataRoot: '/var/data/relay', + configRoot: '/var/config/relay', + cacheRoot: '/var/cache/relay', + databasePath: '/var/data/relay/relay.db', + }); + }); + + it('applies explicit, environment, then platform default database precedence', () => { + const input = { + platform: 'linux' as const, + homeDir: '/home/relay', + env: { RELAY_DB_PATH: '/env.db' }, + }; + expect( + resolveRuntimePaths({ ...input, explicitDatabasePath: '/explicit.db' }).databasePath, + ).toBe('/explicit.db'); + expect(resolveRuntimePaths(input).databasePath).toBe('/env.db'); + expect( + resolveRuntimePaths({ platform: 'linux', homeDir: '/home/relay', env: {} }).databasePath, + ).toBe('/home/relay/.local/share/relay/relay.db'); + }); + + it('allows an in-memory database only through explicit internal injection', () => { + expect( + resolveRuntimePaths({ + platform: 'linux', + homeDir: '/home/relay', + env: {}, + explicitDatabasePath: ':memory:', + }).databasePath, + ).toBe(':memory:'); + }); + + it('rejects an in-memory database supplied through RELAY_DB_PATH', () => { + expect(() => + resolveRuntimePaths({ + platform: 'linux', + homeDir: '/home/relay', + env: { RELAY_DB_PATH: ':memory:' }, + }), + ).toThrowError('Database path must be absolute: :memory:'); + }); + + it('does not let an invalid environment value override explicit in-memory injection', () => { + expect( + resolveRuntimePaths({ + platform: 'linux', + homeDir: '/home/relay', + env: { RELAY_DB_PATH: 'relative.db' }, + explicitDatabasePath: ':memory:', + }).databasePath, + ).toBe(':memory:'); + }); + + it.each(['', ' '])('rejects whitespace RELAY_DB_PATH values (%j)', (value) => { + expect(() => + resolveRuntimePaths({ + platform: 'linux', + homeDir: '/home/relay', + env: { RELAY_DB_PATH: value }, + }), + ).toThrow(/RELAY_DB_PATH.*empty|whitespace/i); + }); + + it.each(['relative.db', 'nested/relative.db'])('rejects relative database paths (%s)', (path) => { + expect(() => + resolveRuntimePaths({ + platform: 'linux', + homeDir: '/home/relay', + env: { RELAY_DB_PATH: path }, + }), + ).toThrow(/absolute/i); + expect(() => + resolveRuntimePaths({ + platform: 'linux', + homeDir: '/home/relay', + env: {}, + explicitDatabasePath: path, + }), + ).toThrow(/absolute/i); + }); +}); diff --git a/tests/unit/interfaces/cli/run-relay.test.ts b/tests/unit/interfaces/cli/run-relay.test.ts new file mode 100644 index 0000000..22cdf38 --- /dev/null +++ b/tests/unit/interfaces/cli/run-relay.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { + runRelay, + type RelayCommandDependencies, +} from '../../../../src/interfaces/cli/run-relay.js'; + +function dependencies(overrides: Partial = {}): RelayCommandDependencies { + return { + runTaskCommand: async () => 0, + runMcp: async () => 0, + runUi: async () => 0, + stderr: { write: () => undefined }, + ...overrides, + }; +} + +describe('runRelay', () => { + it('routes mcp without creating a task runtime', async () => { + const calls: string[] = []; + const code = await runRelay( + ['mcp'], + dependencies({ + runMcp: async () => { + calls.push('mcp'); + }, + }), + ); + expect(code).toBe(0); + expect(calls).toEqual(['mcp']); + }); + + it('propagates a non-zero MCP exit code', async () => { + await expect(runRelay(['mcp'], dependencies({ runMcp: async () => 1 }))).resolves.toBe(1); + }); + + it('routes ui without creating a task runtime', async () => { + const calls: string[] = []; + const code = await runRelay( + ['ui'], + dependencies({ + runUi: async () => { + calls.push('ui'); + }, + }), + ); + expect(code).toBe(0); + expect(calls).toEqual(['ui']); + }); + + it('passes every task or session argv unchanged to the existing runner', async () => { + const received: readonly string[][] = []; + const code = await runRelay( + ['task', 'capture', '--title', 'Keep', '--output', 'json'], + dependencies({ + runTaskCommand: async (argv) => { + (received as string[][]).push([...argv]); + return 3; + }, + }), + ); + expect(code).toBe(3); + expect(received).toEqual([['task', 'capture', '--title', 'Keep', '--output', 'json']]); + }); + + it('rejects unknown operational commands with usage exit code 2', async () => { + let message = ''; + const code = await runRelay( + ['doctor'], + dependencies({ + stderr: { + write: (text) => { + message += text; + }, + }, + }), + ); + expect(code).toBe(2); + expect(message).toMatch(/unknown.*command/i); + }); +}); diff --git a/tests/unit/interfaces/http/create-http-server.test.ts b/tests/unit/interfaces/http/create-http-server.test.ts index f827ec2..32b18da 100644 --- a/tests/unit/interfaces/http/create-http-server.test.ts +++ b/tests/unit/interfaces/http/create-http-server.test.ts @@ -1,4 +1,5 @@ import { execSync } from 'node:child_process'; +import { join } from 'node:path'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; import { resolveHttpPort, @@ -88,18 +89,20 @@ describe('getContentType', () => { }); describe('resolveStaticAsset', () => { + const webRoot = join(process.cwd(), 'dist', 'web'); + it('resolves the built index.html asset from the web output directory', () => { - const assetPath = resolveStaticAsset('/'); + const assetPath = resolveStaticAsset('/', webRoot); expect(assetPath).toBeTruthy(); expect(assetPath).toMatch(/dist[\\/]web[\\/]index\.html$/); }); it('rejects path traversal outside the built web directory', () => { - expect(resolveStaticAsset('/../package.json')).toBeNull(); + expect(resolveStaticAsset('/../package.json', webRoot)).toBeNull(); }); it('returns null for unknown static files', () => { - expect(resolveStaticAsset('/assets/does-not-exist.js')).toBeNull(); + expect(resolveStaticAsset('/assets/does-not-exist.js', webRoot)).toBeNull(); }); }); diff --git a/tests/unit/scripts/mcpb/stage-linux-mcpb.test.ts b/tests/unit/scripts/mcpb/stage-linux-mcpb.test.ts index 5889907..0d69f8e 100644 --- a/tests/unit/scripts/mcpb/stage-linux-mcpb.test.ts +++ b/tests/unit/scripts/mcpb/stage-linux-mcpb.test.ts @@ -19,9 +19,16 @@ async function fixtureRoot(): Promise { writeFile( join(rootDir, 'package.json'), JSON.stringify({ - name: 'relay', + name: '@krishna916/relay', version: '0.1.0', + license: 'MIT', + files: ['dist/'], + bin: { relay: './dist/cli/main.js' }, engines: { node: '>=24 <25' }, + pnpm: { + overrides: { tmp: '0.2.7' }, + onlyBuiltDependencies: ['better-sqlite3', 'esbuild'], + }, dependencies: { '@modelcontextprotocol/sdk': '1.29.0', 'better-sqlite3': '13.0.1', @@ -31,7 +38,7 @@ async function fixtureRoot(): Promise { ), writeFile( join(rootDir, 'pnpm-lock.yaml'), - "importers:\n\n .:\n dependencies:\n '@modelcontextprotocol/sdk':\n specifier: ^1.29.0\n version: 1.29.0\n better-sqlite3:\n specifier: ^13.0.1\n version: 13.0.1\n zod:\n specifier: ^4.4.3\n version: 4.4.3\npackages:\n", + "overrides:\n tmp: 0.2.7\n\nimporters:\n\n .:\n dependencies:\n '@modelcontextprotocol/sdk':\n specifier: ^1.29.0\n version: 1.29.0\n better-sqlite3:\n specifier: ^13.0.1\n version: 13.0.1\n zod:\n specifier: ^4.4.3\n version: 4.4.3\npackages:\n", ), writeFile(join(rootDir, 'dist/mcp/main.js'), 'process.exit(0);'), writeFile(join(rootDir, 'dist/chunk-runtime.js'), 'export const runtime = true;'), diff --git a/tests/unit/scripts/package/verify-package-metadata.test.ts b/tests/unit/scripts/package/verify-package-metadata.test.ts new file mode 100644 index 0000000..f747da5 --- /dev/null +++ b/tests/unit/scripts/package/verify-package-metadata.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + countTopLevelKey, + REQUIRED_ONLY_BUILT_DEPENDENCIES, + REQUIRED_PNPM_OVERRIDES, + verifyPackageMetadata, +} from '../../../../scripts/package/verify-package-metadata.js'; + +describe('countTopLevelKey', () => { + it('counts one top-level key', () => { + expect(countTopLevelKey('{"pnpm":{}}', 'pnpm')).toBe(1); + }); + + it('counts multiple top-level keys', () => { + expect(countTopLevelKey('{"pnpm":{},"other":1,"pnpm":{}}', 'pnpm')).toBe(2); + }); + + it('does not count nested keys', () => { + expect(countTopLevelKey('{"nested":{"pnpm":{}},"pnpm":{}}', 'pnpm')).toBe(1); + }); + + it('ignores escaped quotes in nearby string values', () => { + const source = '{"description":"escaped \\"pnpm\\": value","pnpm":{}}'; + expect(countTopLevelKey(source, 'pnpm')).toBe(1); + }); +}); + +describe('publishable package metadata', () => { + it('declares the public package and one stable executable', () => { + const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) as Record< + string, + unknown + >; + + expect(packageJson.name).toBe('@krishna916/relay'); + expect(packageJson.private).toBeUndefined(); + expect(packageJson.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(packageJson.engines).toEqual({ node: '>=24 <25' }); + expect(packageJson.bin).toEqual({ relay: './dist/cli/main.js' }); + expect(packageJson.publishConfig).toEqual({ access: 'public' }); + expect(packageJson.pnpm).toEqual({ + overrides: REQUIRED_PNPM_OVERRIDES, + onlyBuiltDependencies: [...REQUIRED_ONLY_BUILT_DEPENDENCIES], + }); + }); + + it('validates root metadata and the lockfile contract', () => { + expect(() => verifyPackageMetadata(resolve('.'))).not.toThrow(); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 61b6cc4..194e2ca 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -10,7 +10,8 @@ export default defineConfig({ target: 'node24', outDir: 'dist', clean: false, - sourcemap: true, + sourcemap: false, + splitting: false, bundle: true, shims: true, });