From f3fd4e109f8c0d07f40724c7f8248280c1e66e86 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 7 Jun 2026 23:45:51 +0000 Subject: [PATCH] feat: add standalone and HiveCast SDK demos --- README.md | 33 +++++ STANDALONE-AUDIT.md | 18 +++ examples/hivecast-login/.env.example | 5 + examples/hivecast-login/.gitignore | 3 + examples/hivecast-login/README.md | 42 ++++++ examples/hivecast-login/run-demo.mjs | 120 ++++++++++++++++++ examples/standalone-greeter/.gitignore | 2 + examples/standalone-greeter/README.md | 20 +++ examples/standalone-greeter/matrix.json | 28 ++++ examples/standalone-greeter/package.json | 15 +++ examples/standalone-greeter/run-demo.mjs | 113 +++++++++++++++++ .../standalone-greeter/src/GreeterActor.ts | 24 ++++ .../src/create-standalone-GreeterActor.ts | 50 ++++++++ examples/standalone-greeter/src/index.ts | 2 + examples/standalone-greeter/tsconfig.json | 11 ++ package.json | 4 +- pnpm-lock.yaml | 10 ++ pnpm-workspace.yaml | 1 + 18 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 examples/hivecast-login/.env.example create mode 100644 examples/hivecast-login/.gitignore create mode 100644 examples/hivecast-login/README.md create mode 100644 examples/hivecast-login/run-demo.mjs create mode 100644 examples/standalone-greeter/.gitignore create mode 100644 examples/standalone-greeter/README.md create mode 100644 examples/standalone-greeter/matrix.json create mode 100644 examples/standalone-greeter/package.json create mode 100644 examples/standalone-greeter/run-demo.mjs create mode 100644 examples/standalone-greeter/src/GreeterActor.ts create mode 100644 examples/standalone-greeter/src/create-standalone-GreeterActor.ts create mode 100644 examples/standalone-greeter/src/index.ts create mode 100644 examples/standalone-greeter/tsconfig.json diff --git a/README.md b/README.md index 68e17af..b6273a9 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,37 @@ configure broker access, run actors, and invoke them without importing provider overlay packages into the SDK layer. Managed-host promotion is optional and is only exercised when `MATRIX_SDK_RUNTIME_HOST_BIN` is supplied. +## Demos + +Standalone SDK demo, no provider account or key required: + +```bash +pnpm demo:standalone +``` + +This builds the SDK and `examples/standalone-greeter`, starts the actor with +`matrix run`, calls it with `matrix invoke`, and shuts it down. + +HiveCast API-key login/config demo: + +```bash +export MATRIX_CLOUD=https://dev.hivecast.ai +export MATRIX_SPACE= +export MATRIX_API_KEY= +pnpm demo:hivecast-login +``` + +This validates the key with HiveCast and writes the SDK package-local +`.matrix/config.json` plus `.matrix/credentials/matrix-api-key.json` shape +without printing or committing the key. By default the demo uses a temporary +home and deletes it after success. + +Current boundary: this HiveCast demo proves SDK-side key validation and config +storage. It does not yet prove a package running directly on a HiveCast-issued +broker credential. That requires the provider-owned login/credential-exchange +slice so `matrix login` can replace manual `matrix configure` and issue/store +the broker credentials needed by the SDK transport. + ## Repository Layout ```text @@ -169,6 +200,8 @@ packages/ omega-core/ sdk/ scripts/ + demo:standalone + demo:hivecast-login prove-external-consumer.mjs prove-sdk-cli-uat.mjs ``` diff --git a/STANDALONE-AUDIT.md b/STANDALONE-AUDIT.md index db22a1f..662949a 100644 --- a/STANDALONE-AUDIT.md +++ b/STANDALONE-AUDIT.md @@ -23,6 +23,13 @@ The SDK package surface is standalone enough for the current authoring proof: The proof script also checks that generated author-package files do not contain legacy monorepo paths or upper-layer package dependencies. +Checked-in demos now cover the same boundary in user-facing form: + +- `pnpm demo:standalone` runs `examples/standalone-greeter` without provider + credentials. +- `pnpm demo:hivecast-login` validates a user-supplied HiveCast API key and + writes package-local SDK config/credentials in a temporary demo home. + ## Cleanup Completed - Root README now describes this as a provider-neutral SDK, not a HiveCast @@ -87,3 +94,14 @@ Expected: - `legacyMonorepoPathReferences` is `false`. - `managedHostProof` is skipped unless `MATRIX_SDK_RUNTIME_HOST_BIN` is supplied. - `prove` builds, tests, type-checks, and packs all workspace packages. + +Demo checks: + +```bash +pnpm demo:standalone +pnpm demo:hivecast-login -- --check +``` + +`demo:hivecast-login -- --check` proves the demo wiring without requiring a +secret. A real HiveCast proof requires `MATRIX_API_KEY`, `MATRIX_CLOUD`, and +`MATRIX_SPACE` from the user. diff --git a/examples/hivecast-login/.env.example b/examples/hivecast-login/.env.example new file mode 100644 index 0000000..65d2a30 --- /dev/null +++ b/examples/hivecast-login/.env.example @@ -0,0 +1,5 @@ +# Copy this file outside git-tracked secrets handling or export these variables +# in your shell. Do not commit real keys. +MATRIX_CLOUD=https://dev.hivecast.ai +MATRIX_SPACE=your-space-root +MATRIX_API_KEY=hc_your_key_here diff --git a/examples/hivecast-login/.gitignore b/examples/hivecast-login/.gitignore new file mode 100644 index 0000000..559a2c3 --- /dev/null +++ b/examples/hivecast-login/.gitignore @@ -0,0 +1,3 @@ +.env +.matrix/ +demo-home/ diff --git a/examples/hivecast-login/README.md b/examples/hivecast-login/README.md new file mode 100644 index 0000000..22a3199 --- /dev/null +++ b/examples/hivecast-login/README.md @@ -0,0 +1,42 @@ +# HiveCast API-Key Login Demo + +This demo proves the SDK-side HiveCast credential shape without committing a +secret. + +It validates a user-supplied HiveCast API key against the configured HiveCast +cloud, then writes the same package-local `.matrix` config/credential files that +`matrix configure` writes. This is the current API-key login shape. + +It does **not** prove hosted broker execution yet. The SDK still needs a +provider-owned credential exchange/login command before a package can use a +HiveCast-issued broker JWT directly from this repo. + +## Run + +```bash +export MATRIX_CLOUD=https://dev.hivecast.ai +export MATRIX_SPACE= +export MATRIX_API_KEY= + +pnpm demo:hivecast-login +``` + +Optional: + +```bash +MATRIX_DEMO_HOME=/tmp/my-sdk-hivecast-demo pnpm demo:hivecast-login +MATRIX_DEMO_KEEP=1 pnpm demo:hivecast-login +``` + +The key is never printed. By default the temporary demo home is deleted after a +successful run. Set `MATRIX_DEMO_KEEP=1` to inspect the generated `.matrix` +files locally. + +## Check Script Wiring Without A Key + +```bash +pnpm demo:hivecast-login -- --check +``` + +This only verifies that the demo is installed and intentionally refuses to run +without credentials. diff --git a/examples/hivecast-login/run-demo.mjs b/examples/hivecast-login/run-demo.mjs new file mode 100644 index 0000000..63333c8 --- /dev/null +++ b/examples/hivecast-login/run-demo.mjs @@ -0,0 +1,120 @@ +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const args = new Set(process.argv.slice(2)); +const checkOnly = args.has('--check'); +const repoRoot = resolve(new URL('../..', import.meta.url).pathname); +const matrixCli = resolve(repoRoot, 'packages', 'cli', 'dist', 'bin', 'matrix.js'); + +function envString(...names) { + for (const name of names) { + const value = process.env[name]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return undefined; +} + +function run(command, commandArgs, options = {}) { + const result = spawnSync(command, commandArgs, { + cwd: options.cwd ?? repoRoot, + stdio: options.stdio ?? 'pipe', + env: process.env, + encoding: 'utf8', + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} ${commandArgs.join(' ')} failed with exit ${result.status}\n${result.stderr}${result.stdout}`); + } + return result; +} + +function resolveConfig() { + const key = envString('MATRIX_API_KEY', 'HIVECAST_API_KEY', 'HIVECAST_TEST_KEY'); + const cloud = envString('MATRIX_CLOUD', 'HIVECAST_CLOUD', 'HIVECAST_TEST_CLOUD'); + const space = envString('MATRIX_SPACE', 'HIVECAST_SPACE', 'HIVECAST_TEST_SPACE'); + const provided = [key, cloud, space].filter(Boolean).length; + if (provided > 0 && provided < 3) { + throw new Error('Provide MATRIX_API_KEY, MATRIX_CLOUD, and MATRIX_SPACE together'); + } + return key && cloud && space + ? { key, cloud: cloud.replace(/\/+$/g, ''), space } + : null; +} + +async function validateHiveCastKey(config) { + const response = await fetch(`${config.cloud}/api/install/key-info`, { + method: 'POST', + headers: { + authorization: `Bearer ${config.key}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ space: config.space }), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`HiveCast key validation failed (${response.status}): ${text}`); + } + const body = text.trim() ? JSON.parse(text) : {}; + if (body?.ok !== true) { + throw new Error(`HiveCast key validation did not return ok:true: ${text}`); + } + return { + keyId: body.key?.id ?? body.id ?? null, + fingerprint: body.key?.fingerprint ?? body.fingerprint ?? null, + }; +} + +const config = resolveConfig(); +if (!config) { + const message = 'HiveCast demo requires MATRIX_API_KEY, MATRIX_CLOUD, and MATRIX_SPACE'; + if (checkOnly) { + console.log(JSON.stringify({ ok: true, demo: 'hivecast-login', credentialsRequired: true, message }, null, 2)); + process.exit(0); + } + throw new Error(message); +} +if (checkOnly) { + console.log(JSON.stringify({ ok: true, demo: 'hivecast-login', credentialsAvailable: true }, null, 2)); + process.exit(0); +} + +const demoHome = process.env.MATRIX_DEMO_HOME + ? resolve(process.env.MATRIX_DEMO_HOME) + : mkdtempSync(join(tmpdir(), 'matrix-sdk-hivecast-login.')); +const keep = process.env.MATRIX_DEMO_KEEP === '1' || Boolean(process.env.MATRIX_DEMO_HOME); + +try { + const validation = await validateHiveCastKey(config); + run(process.execPath, [ + matrixCli, 'configure', + '--dir', demoHome, + '--key', config.key, + '--cloud', config.cloud, + '--space', config.space, + ]); + + const credentialPath = join(demoHome, '.matrix', 'credentials', 'matrix-api-key.json'); + const configPath = join(demoHome, '.matrix', 'config.json'); + if (!existsSync(credentialPath)) throw new Error(`Credential file was not written: ${credentialPath}`); + if (!existsSync(configPath)) throw new Error(`Config file was not written: ${configPath}`); + const writtenCredential = JSON.parse(readFileSync(credentialPath, 'utf8')); + if (writtenCredential.key !== config.key) throw new Error('Written credential key does not match input key'); + if (writtenCredential.cloud !== config.cloud) throw new Error('Written credential cloud does not match input cloud'); + if (writtenCredential.space !== config.space) throw new Error('Written credential space does not match input space'); + + console.log(JSON.stringify({ + ok: true, + demo: 'hivecast-login', + cloud: config.cloud, + space: config.space, + keyValidated: true, + keyId: validation.keyId, + fingerprint: validation.fingerprint, + demoHome: keep ? demoHome : '(temporary, removed)', + credentialPath: keep ? credentialPath : '(temporary, removed)', + }, null, 2)); +} finally { + if (!keep) rmSync(demoHome, { recursive: true, force: true }); +} diff --git a/examples/standalone-greeter/.gitignore b/examples/standalone-greeter/.gitignore new file mode 100644 index 0000000..c1f67d9 --- /dev/null +++ b/examples/standalone-greeter/.gitignore @@ -0,0 +1,2 @@ +.matrix/ +dist/ diff --git a/examples/standalone-greeter/README.md b/examples/standalone-greeter/README.md new file mode 100644 index 0000000..773ecbd --- /dev/null +++ b/examples/standalone-greeter/README.md @@ -0,0 +1,20 @@ +# Standalone Greeter Demo + +This demo proves the SDK can run without HiveCast, without a managed platform, +and without committed credentials. + +Run from the repository root: + +```bash +pnpm demo:standalone +``` + +What it proves: + +- the SDK packages build from this repo, +- this package builds against `@open-matrix/core`, +- `matrix run` starts the actor from the package manifest, +- `matrix invoke demo.greeter echo ...` calls the actor over the SDK local + runtime path, +- all runtime state is written under the demo package's local `.matrix/` + directory. diff --git a/examples/standalone-greeter/matrix.json b/examples/standalone-greeter/matrix.json new file mode 100644 index 0000000..fd6bf2b --- /dev/null +++ b/examples/standalone-greeter/matrix.json @@ -0,0 +1,28 @@ +{ + "name": "matrix-sdk-example-standalone-greeter", + "version": "0.0.0", + "runtime": { + "language": "typescript", + "entry": "./dist/index.js", + "factory": { + "kind": "factory", + "export": "createStandaloneGreeterActor", + "instance": { + "id": "RUNTIME-HOST-DEMO-GREETER", + "class": "service", + "rootKind": "component", + "componentType": "GreeterActor", + "autoStart": true + } + } + }, + "components": [ + { + "type": "GreeterActor", + "export": "GreeterActor", + "mount": "demo.greeter", + "surface": "headless", + "accepts": ["echo", "getStatus"] + } + ] +} diff --git a/examples/standalone-greeter/package.json b/examples/standalone-greeter/package.json new file mode 100644 index 0000000..e6ff173 --- /dev/null +++ b/examples/standalone-greeter/package.json @@ -0,0 +1,15 @@ +{ + "name": "matrix-sdk-example-standalone-greeter", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "@open-matrix/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} diff --git a/examples/standalone-greeter/run-demo.mjs b/examples/standalone-greeter/run-demo.mjs new file mode 100644 index 0000000..a3bc63d --- /dev/null +++ b/examples/standalone-greeter/run-demo.mjs @@ -0,0 +1,113 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..', '..'); +const demoDir = __dirname; +const matrixCli = resolve(repoRoot, 'packages', 'cli', 'dist', 'bin', 'matrix.js'); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? repoRoot, + stdio: options.stdio ?? 'inherit', + env: process.env, + encoding: 'utf8', + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with exit ${result.status}`); + } + return result; +} + +async function freePort() { + return await new Promise((resolvePort, reject) => { + const server = createServer(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Could not allocate demo port'))); + return; + } + const { port } = address; + server.close(() => resolvePort(port)); + }); + }); +} + +async function waitForHealth(port, child) { + const deadline = Date.now() + 10_000; + let lastError; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`matrix run exited early with ${child.exitCode}`); + try { + const response = await fetch(`http://127.0.0.1:${port}/healthz`); + if (response.ok) return; + lastError = new Error(`healthz returned ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + } + throw lastError ?? new Error('Timed out waiting for matrix run healthz'); +} + +function stop(child) { + return new Promise((resolveStop) => { + if (child.exitCode !== null) { + resolveStop(); + return; + } + child.once('exit', () => resolveStop()); + child.kill('SIGTERM'); + setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }, 3000).unref(); + }); +} + +const port = await freePort(); +let runner; + +try { + run('pnpm', ['run', 'build']); + run('pnpm', ['--filter', 'matrix-sdk-example-standalone-greeter', 'run', 'build']); + + runner = spawn(process.execPath, [ + matrixCli, 'run', demoDir, + '--dir', demoDir, + '--port', String(port), + ], { + cwd: repoRoot, + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }); + runner.stderr.on('data', (chunk) => process.stderr.write(chunk)); + await waitForHealth(port, runner); + + const invoked = run(process.execPath, [ + matrixCli, 'invoke', + 'demo.greeter', + 'echo', + '{"message":"hello-from-standalone-demo"}', + '--dir', demoDir, + '--port', String(port), + ], { cwd: repoRoot, stdio: 'pipe' }); + const body = JSON.parse(invoked.stdout.trim()); + if (body?.ok !== true || body?.result?.message !== 'hello-from-standalone-demo') { + throw new Error(`Unexpected invoke result: ${invoked.stdout}`); + } + + console.log(JSON.stringify({ + ok: true, + demo: 'standalone-greeter', + mount: 'demo.greeter', + port, + invoke: body, + }, null, 2)); +} finally { + if (runner) await stop(runner); +} diff --git a/examples/standalone-greeter/src/GreeterActor.ts b/examples/standalone-greeter/src/GreeterActor.ts new file mode 100644 index 0000000..1f777a7 --- /dev/null +++ b/examples/standalone-greeter/src/GreeterActor.ts @@ -0,0 +1,24 @@ +import { MatrixActor } from '@open-matrix/core'; + +export class GreeterActor extends MatrixActor { + static accepts = { + echo: {}, + getStatus: {}, + }; + + onEcho(payload: { message?: string }) { + return { + ok: true, + message: payload.message ?? null, + actor: 'GreeterActor', + }; + } + + onGetStatus() { + return { + ok: true, + actor: 'GreeterActor', + mount: 'demo.greeter', + }; + } +} diff --git a/examples/standalone-greeter/src/create-standalone-GreeterActor.ts b/examples/standalone-greeter/src/create-standalone-GreeterActor.ts new file mode 100644 index 0000000..5aef880 --- /dev/null +++ b/examples/standalone-greeter/src/create-standalone-GreeterActor.ts @@ -0,0 +1,50 @@ +import { InMemoryBroker, InMemoryTransport, MatrixRuntime } from '@open-matrix/core'; + +import { GreeterActor } from './GreeterActor.js'; + +interface StandaloneBootContext { + readonly root?: string; + readonly mount?: string; +} + +interface StandaloneRunnerServices { + readonly runtime?: MatrixRuntime; +} + +function readTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function requiredStandaloneRoot(requestedRoot: string | undefined, runtime: MatrixRuntime): string { + const runtimeRoot = readTrimmedString(runtime.transport.root); + const root = requestedRoot ?? runtimeRoot; + if (!root) throw new Error('createStandaloneGreeterActor requires bootContext.root or a runtime transport root'); + return root; +} + +export async function createStandaloneGreeterActor( + _overrides: Record = {}, + services?: StandaloneRunnerServices, + _config?: unknown, + bootContext: StandaloneBootContext = {}, +): Promise<{ + runtime: MatrixRuntime; + actor: GreeterActor; + root: string; + mount: string; +}> { + const requestedRoot = readTrimmedString(bootContext.root); + const runtime = services?.runtime ?? new MatrixRuntime({ + transport: new InMemoryTransport(new InMemoryBroker(), { + name: 'demo.greeter-standalone', + ...(requestedRoot ? { root: requestedRoot } : {}), + }), + logging: false, + }); + const root = requiredStandaloneRoot(requestedRoot, runtime); + const mount = readTrimmedString(bootContext.mount) ?? 'demo.greeter'; + const actor = await runtime.createSupervised(GreeterActor, mount); + return { runtime, actor, root, mount }; +} diff --git a/examples/standalone-greeter/src/index.ts b/examples/standalone-greeter/src/index.ts new file mode 100644 index 0000000..d893d00 --- /dev/null +++ b/examples/standalone-greeter/src/index.ts @@ -0,0 +1,2 @@ +export { GreeterActor } from './GreeterActor.js'; +export { createStandaloneGreeterActor } from './create-standalone-GreeterActor.js'; diff --git a/examples/standalone-greeter/tsconfig.json b/examples/standalone-greeter/tsconfig.json new file mode 100644 index 0000000..68c8e6b --- /dev/null +++ b/examples/standalone-greeter/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/package.json b/package.json index fc5f2b4..88f7677 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "build": "pnpm -r --sort --if-present run build", "test": "pnpm -r --sort --if-present run test", "type-check": "pnpm -r --sort --if-present run type-check", - "pack:all": "pnpm -r exec pnpm pack", + "pack:all": "pnpm --filter './packages/*' -r exec pnpm pack", + "demo:standalone": "node examples/standalone-greeter/run-demo.mjs", + "demo:hivecast-login": "node examples/hivecast-login/run-demo.mjs", "prove:external-consumer": "node scripts/prove-external-consumer.mjs", "prove:cli-uat": "node scripts/prove-sdk-cli-uat.mjs", "prove": "pnpm run clean && pnpm run build && pnpm run test && pnpm run type-check && pnpm run pack:all" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c22e5f..70b8a0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,16 @@ importers: specifier: ^5.7.0 version: 5.9.3 + examples/standalone-greeter: + dependencies: + '@open-matrix/core': + specifier: workspace:* + version: link:../../packages/core + devDependencies: + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/browser-host: dependencies: '@open-matrix/contracts': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e9..89723f9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - "packages/*" + - "examples/*"