From 1533a233079cb9afe82d4dfa844081a5034aceb8 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 27 Sep 2026 04:35:57 -0500 Subject: [PATCH] feat: scaffold extension host with core Prohelp manifest Dual-repo: shell-architecture stays canon; this repo is manifests, discovery, and registration. Prohelp is a core extension pointer only. --- .github/workflows/ci.yml | 28 ++++++++++ .gitignore | 19 +++++++ CONTRIBUTING.md | 25 +++++++++ LICENSE | 21 ++++++++ README.md | 56 ++++++++++++++++++- docs/extension-manifest.md | 26 +++++++++ manifests/core.index.json | 6 +++ manifests/core/prohelp.manifest.json | 12 +++++ package.json | 42 +++++++++++++++ pnpm-lock.yaml | 39 ++++++++++++++ src/discovery.ts | 81 ++++++++++++++++++++++++++++ src/extension-host.ts | 72 +++++++++++++++++++++++++ src/index.ts | 55 +++++++++++++++++++ src/types.ts | 77 ++++++++++++++++++++++++++ test/extension-host.test.js | 64 ++++++++++++++++++++++ tsconfig.json | 18 +++++++ 16 files changed, 639 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 docs/extension-manifest.md create mode 100644 manifests/core.index.json create mode 100644 manifests/core/prohelp.manifest.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 src/discovery.ts create mode 100644 src/extension-host.ts create mode 100644 src/index.ts create mode 100644 src/types.ts create mode 100644 test/extension-host.test.js create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c25fbc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main, "cursor/**"] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10.28.2 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Test + run: pnpm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8775d7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Allow-list: ignore everything, then un-ignore tracked paths. +* +!.gitignore +!LICENSE +!README.md +!CONTRIBUTING.md +!package.json +!pnpm-lock.yaml +!tsconfig.json +!manifests/ +!manifests/** +!src/ +!src/** +!test/ +!test/** +!docs/ +!docs/** +!.github/ +!.github/** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..efad368 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contributing to shell-framework + +This repo is the **extension host** for OpenShellOrg. Architecture and thesis live in [shell-architecture](https://github.com/openshellorg/shell-architecture). + +## Layout + +- `src/` — host implementation (manifest loading, registration, core discovery) +- `manifests/core/` — JSON manifests that **point at** upstream repos; never vendor extension source here +- `test/` — Node test runner checks against built `dist/` + +## Adding a core extension + +1. Add `manifests/core/.manifest.json` following the schema in [`docs/extension-manifest.md`](docs/extension-manifest.md). +2. Register the file path in `manifests/core.index.json`. +3. Extend tests if the extension has required fields beyond the base schema. + +## Development + +```bash +pnpm install +pnpm build +pnpm test +``` + +Pull requests should keep CI green (`pnpm test` runs typecheck, build, and tests). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b91892 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenShellOrg contributors + +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 3b9d71d..4026108 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,57 @@ # shell-framework -OpenShellOrg modular extension host (scaffold in progress). +**OpenShellOrg modular extension host** — manifests, discovery, and registration for shell extensions. This repository implements the host stub that loads extension manifests and registers core and user-supplied extensions. -Canon architecture: https://github.com/openshellorg/shell-architecture +## Dual-repo model + +OpenShellOrg splits **architecture** from **implementation**: + +| Repository | Role | +|------------|------| +| [**shell-architecture**](https://github.com/openshellorg/shell-architecture) | **Canon** — thesis, Tool Runs, command channels, Antora docs, diagrams | +| **shell-framework** (this repo) | **Extension host** — manifest schema, core extension registry, discovery, host APIs | + +Do not duplicate architecture docs here. When you need design rationale (structured pipelines, SOS relationship, host identity, env refresh plans), use [shell-architecture](https://github.com/openshellorg/shell-architecture). + +## What lives here + +- **`manifests/core/`** — pointers to first-party core extensions (repos/packages), not vendored source +- **`src/`** — TypeScript extension host: load manifests, register extensions, discover bundled core set +- **Tests + CI** — build and verify core registration (e.g. Prohelp) without cloning extension repos + +## Core extensions + +Core extensions are registered **by manifest only**. The host does not embed their code. + +| Extension | Manifest | Upstream | +|-----------|----------|----------| +| Prohelp | [`manifests/core/prohelp.manifest.json`](manifests/core/prohelp.manifest.json) | [openshellorg/prohelp](https://github.com/openshellorg/prohelp) · CLI: [prohelp-cli](https://github.com/openshellorg/prohelp-cli) | + +## Quick start + +```bash +pnpm install +pnpm build +pnpm test +``` + +Programmatic bootstrap: + +```ts +import { bootstrapExtensionHost } from "@openshellorg/shell-framework"; + +const host = await bootstrapExtensionHost(); +console.log(host.listCore()); // includes openshellorg/prohelp +``` + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for layout and manifest conventions. + +## Related projects + +- [shell-architecture](https://github.com/openshellorg/shell-architecture) — canon docs +- [prohelp](https://github.com/openshellorg/prohelp) — structured help / gutter tooling (first core extension) +- [docs](https://github.com/openshellorg/docs) — SOS certification and org docs site + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/docs/extension-manifest.md b/docs/extension-manifest.md new file mode 100644 index 0000000..9d4c9d8 --- /dev/null +++ b/docs/extension-manifest.md @@ -0,0 +1,26 @@ +# Extension manifest + +Core and third-party extensions are described by JSON manifests. The host loads manifests from disk and registers them; it does not download or embed extension repositories. + +## Required fields + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Stable identifier (e.g. `openshellorg/prohelp`) | +| `name` | string | Display name | +| `repository` | string | Canonical source repo URL | +| `core` | boolean | When `true`, discovered via `manifests/core/` | + +## Optional fields + +| Field | Type | Description | +|-------|------|-------------| +| `version` | string | Manifest or pinned extension version hint | +| `package` | string | npm package name when published | +| `cli` | object | CLI-related pointers | +| `cli.repository` | string | CLI repo URL (e.g. prohelp-cli) | +| `cli.binary` | string | Expected CLI command name | + +## Example (Prohelp) + +See [`manifests/core/prohelp.manifest.json`](../manifests/core/prohelp.manifest.json). diff --git a/manifests/core.index.json b/manifests/core.index.json new file mode 100644 index 0000000..86e69bc --- /dev/null +++ b/manifests/core.index.json @@ -0,0 +1,6 @@ +{ + "description": "Ordered list of core extension manifest paths relative to the repository root.", + "manifests": [ + "manifests/core/prohelp.manifest.json" + ] +} diff --git a/manifests/core/prohelp.manifest.json b/manifests/core/prohelp.manifest.json new file mode 100644 index 0000000..8fe947d --- /dev/null +++ b/manifests/core/prohelp.manifest.json @@ -0,0 +1,12 @@ +{ + "id": "openshellorg/prohelp", + "name": "Prohelp", + "version": "0.0.0", + "core": true, + "repository": "https://github.com/openshellorg/prohelp", + "package": "@openshellorg/prohelp", + "cli": { + "repository": "https://github.com/openshellorg/prohelp-cli", + "binary": "prohelp" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..950b304 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "@openshellorg/shell-framework", + "version": "0.0.0", + "description": "OpenShellOrg modular extension host — manifests, discovery, and registration", + "license": "MIT", + "type": "module", + "packageManager": "pnpm@10.28.2", + "engines": { + "node": ">=20" + }, + "files": [ + "dist", + "manifests", + "README.md", + "LICENSE" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./manifests/core/*": "./manifests/core/*" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "pnpm build && node --test test/**/*.test.js", + "prepublishOnly": "pnpm test" + }, + "repository": { + "type": "git", + "url": "https://github.com/openshellorg/shell-framework.git" + }, + "bugs": { + "url": "https://github.com/openshellorg/shell-framework/issues" + }, + "homepage": "https://github.com/openshellorg/shell-framework#readme", + "devDependencies": { + "@types/node": "^22.13.10", + "typescript": "^5.8.2" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..c74dc69 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,39 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^22.13.10 + version: 22.20.4 + typescript: + specifier: ^5.8.2 + version: 5.9.3 + +packages: + + '@types/node@22.20.4': + resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@types/node@22.20.4': + dependencies: + undici-types: 6.21.0 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/src/discovery.ts b/src/discovery.ts new file mode 100644 index 0000000..300ca99 --- /dev/null +++ b/src/discovery.ts @@ -0,0 +1,81 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { + type CoreIndex, + type ExtensionManifest, + ManifestValidationError, + validateExtensionManifest, +} from "./types.js"; + +export async function loadJsonFile(filePath: string): Promise { + const raw = await readFile(filePath, "utf8"); + try { + return JSON.parse(raw) as unknown; + } catch { + throw new ManifestValidationError(`Invalid JSON: ${filePath}`); + } +} + +export async function loadExtensionManifest( + manifestPath: string, +): Promise { + const parsed = await loadJsonFile(manifestPath); + return validateExtensionManifest(parsed, manifestPath); +} + +export async function loadCoreIndex(indexPath: string): Promise { + const parsed = await loadJsonFile(indexPath); + if (parsed === null || typeof parsed !== "object") { + throw new ManifestValidationError(`${indexPath}: expected an object`); + } + const record = parsed as Record; + if (!Array.isArray(record.manifests)) { + throw new ManifestValidationError(`${indexPath}: "manifests" must be an array`); + } + const manifests = record.manifests.map((entry, i) => { + if (typeof entry !== "string" || entry.length === 0) { + throw new ManifestValidationError( + `${indexPath}: manifests[${i}] must be a non-empty string path`, + ); + } + return entry; + }); + const description = + typeof record.description === "string" ? record.description : undefined; + return { description, manifests }; +} + +export interface DiscoverCoreOptions { + /** Repository root (defaults to cwd). */ + rootDir?: string; + /** Path to core index JSON relative to rootDir. */ + coreIndexPath?: string; +} + +/** + * Resolve and load all core extension manifests listed in manifests/core.index.json. + */ +export async function discoverCoreExtensions( + options: DiscoverCoreOptions = {}, +): Promise> { + const rootDir = options.rootDir ?? process.cwd(); + const coreIndexPath = + options.coreIndexPath ?? path.join("manifests", "core.index.json"); + const indexAbs = path.resolve(rootDir, coreIndexPath); + const index = await loadCoreIndex(indexAbs); + + const results: Array<{ manifest: ExtensionManifest; manifestPath: string }> = + []; + for (const relativePath of index.manifests) { + const manifestPath = path.resolve(rootDir, relativePath); + const manifest = await loadExtensionManifest(manifestPath); + if (manifest.core !== true) { + throw new ManifestValidationError( + `${manifestPath}: core index entries must set "core": true`, + ); + } + results.push({ manifest, manifestPath }); + } + return results; +} diff --git a/src/extension-host.ts b/src/extension-host.ts new file mode 100644 index 0000000..7513674 --- /dev/null +++ b/src/extension-host.ts @@ -0,0 +1,72 @@ +import type { + ExtensionManifest, + ExtensionSource, + RegisteredExtension, +} from "./types.js"; +import { validateExtensionManifest } from "./types.js"; + +export interface ExtensionHostOptions { + /** ISO timestamp for registrations (tests). */ + now?: () => string; +} + +/** + * In-process registry for extension manifests loaded by the host. + */ +export class ExtensionHost { + readonly #extensions = new Map(); + readonly #now: () => string; + + constructor(options: ExtensionHostOptions = {}) { + this.#now = options.now ?? (() => new Date().toISOString()); + } + + register( + manifestInput: ExtensionManifest, + source: ExtensionSource, + manifestPath?: string, + ): RegisteredExtension { + const manifest = validateExtensionManifest(manifestInput); + if (this.#extensions.has(manifest.id)) { + throw new Error(`Extension already registered: ${manifest.id}`); + } + const registered: RegisteredExtension = { + ...manifest, + source, + manifestPath, + registeredAt: this.#now(), + }; + this.#extensions.set(manifest.id, registered); + return registered; + } + + registerFromManifestFile( + manifest: ExtensionManifest, + manifestPath: string, + source: ExtensionSource = "manifest", + ): RegisteredExtension { + return this.register(manifest, source, manifestPath); + } + + get(id: string): RegisteredExtension | undefined { + return this.#extensions.get(id); + } + + has(id: string): boolean { + return this.#extensions.has(id); + } + + list(): RegisteredExtension[] { + return [...this.#extensions.values()].sort((a, b) => + a.id.localeCompare(b.id), + ); + } + + listCore(): RegisteredExtension[] { + return this.list().filter((entry) => entry.core === true); + } + + size(): number { + return this.#extensions.size; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..504b679 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,55 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { discoverCoreExtensions } from "./discovery.js"; +import { ExtensionHost, type ExtensionHostOptions } from "./extension-host.js"; +import type { RegisteredExtension } from "./types.js"; + +export type { + CoreIndex, + ExtensionCliManifest, + ExtensionManifest, + ExtensionSource, + RegisteredExtension, +} from "./types.js"; +export { ManifestValidationError, validateExtensionManifest } from "./types.js"; +export { discoverCoreExtensions, loadCoreIndex, loadExtensionManifest } from "./discovery.js"; +export { ExtensionHost } from "./extension-host.js"; + +export interface BootstrapOptions extends ExtensionHostOptions { + rootDir?: string; +} + +export interface BootstrappedExtensionHost { + host: ExtensionHost; + rootDir: string; + core: RegisteredExtension[]; +} + +/** Repository root (directory containing manifests/). */ +export function defaultPackageRoot(): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, ".."); +} + +/** + * Create a host, discover core manifests under rootDir, and register them. + */ +export async function bootstrapExtensionHost( + options: BootstrapOptions = {}, +): Promise { + const rootDir = options.rootDir ?? defaultPackageRoot(); + const host = new ExtensionHost({ now: options.now }); + const discovered = await discoverCoreExtensions({ rootDir }); + const core: RegisteredExtension[] = []; + for (const { manifest, manifestPath } of discovered) { + core.push(host.registerFromManifestFile(manifest, manifestPath, "core")); + } + return { host, rootDir, core }; +} + +export function createExtensionHost( + options: ExtensionHostOptions = {}, +): ExtensionHost { + return new ExtensionHost(options); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d0b85f6 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,77 @@ +/** + * Extension manifest as loaded from JSON on disk. + * Pointers only — no vendored extension source in shell-framework. + */ +export interface ExtensionCliManifest { + repository?: string; + binary?: string; +} + +export interface ExtensionManifest { + id: string; + name: string; + repository: string; + version?: string; + package?: string; + core?: boolean; + cli?: ExtensionCliManifest; +} + +export interface CoreIndex { + description?: string; + manifests: string[]; +} + +export type ExtensionSource = "core" | "manifest"; + +export interface RegisteredExtension extends ExtensionManifest { + source: ExtensionSource; + manifestPath?: string; + registeredAt: string; +} + +export class ManifestValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ManifestValidationError"; + } +} + +export function validateExtensionManifest( + value: unknown, + label = "manifest", +): ExtensionManifest { + if (value === null || typeof value !== "object") { + throw new ManifestValidationError(`${label}: expected an object`); + } + const record = value as Record; + const id = record.id; + const name = record.name; + const repository = record.repository; + if (typeof id !== "string" || id.length === 0) { + throw new ManifestValidationError(`${label}: "id" must be a non-empty string`); + } + if (typeof name !== "string" || name.length === 0) { + throw new ManifestValidationError(`${label}: "name" must be a non-empty string`); + } + if (typeof repository !== "string" || repository.length === 0) { + throw new ManifestValidationError( + `${label}: "repository" must be a non-empty string`, + ); + } + const manifest: ExtensionManifest = { id, name, repository }; + if (typeof record.version === "string") manifest.version = record.version; + if (typeof record.package === "string") manifest.package = record.package; + if (record.core === true) manifest.core = true; + if (record.cli !== undefined && record.cli !== null && typeof record.cli === "object") { + const cli = record.cli as Record; + manifest.cli = {}; + if (typeof cli.repository === "string") { + manifest.cli.repository = cli.repository; + } + if (typeof cli.binary === "string") { + manifest.cli.binary = cli.binary; + } + } + return manifest; +} diff --git a/test/extension-host.test.js b/test/extension-host.test.js new file mode 100644 index 0000000..838ab7e --- /dev/null +++ b/test/extension-host.test.js @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +import { + bootstrapExtensionHost, + createExtensionHost, + discoverCoreExtensions, +} from "../dist/index.js"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +test("discoverCoreExtensions finds Prohelp manifest", async () => { + const discovered = await discoverCoreExtensions({ rootDir: repoRoot }); + assert.equal(discovered.length, 1); + assert.equal(discovered[0].manifest.id, "openshellorg/prohelp"); + assert.equal( + discovered[0].manifest.repository, + "https://github.com/openshellorg/prohelp", + ); + assert.equal( + discovered[0].manifest.cli?.repository, + "https://github.com/openshellorg/prohelp-cli", + ); +}); + +test("bootstrapExtensionHost registers core extensions", async () => { + const { host, core } = await bootstrapExtensionHost({ rootDir: repoRoot }); + assert.equal(core.length, 1); + assert.equal(host.size(), 1); + const prohelp = host.get("openshellorg/prohelp"); + assert.ok(prohelp); + assert.equal(prohelp.source, "core"); + assert.equal(prohelp.core, true); + assert.match(prohelp.manifestPath ?? "", /prohelp\.manifest\.json$/); +}); + +test("ExtensionHost rejects duplicate registration", () => { + const host = createExtensionHost({ now: () => "2026-01-01T00:00:00.000Z" }); + host.register( + { + id: "example/demo", + name: "Demo", + repository: "https://github.com/example/demo", + }, + "manifest", + ); + assert.throws( + () => + host.register( + { + id: "example/demo", + name: "Demo again", + repository: "https://github.com/example/demo", + }, + "manifest", + ), + /already registered/, + ); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fa54d59 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +}