From dd1872e3de2f014e243571388861976eb276290f Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 8 Jun 2026 07:24:38 +0000 Subject: [PATCH] docs: make SDK onboarding self-checking --- .gitignore | 1 + README.md | 82 +++++++++-- examples/fresh-server-actor/README.md | 3 +- examples/fresh-web-page/README.md | 4 +- examples/standalone-greeter/README.md | 3 + examples/standalone-greeter/run-demo.mjs | 15 +- package.json | 7 +- scripts/doctor.mjs | 119 ++++++++++++++++ scripts/lib/nats-server-tool.mjs | 170 +++++++++++++++++++++++ scripts/setup-nats-server.mjs | 33 +++++ 10 files changed, 422 insertions(+), 15 deletions(-) create mode 100644 scripts/doctor.mjs create mode 100644 scripts/lib/nats-server-tool.mjs create mode 100644 scripts/setup-nats-server.mjs diff --git a/.gitignore b/.gitignore index a15d2fa..48114fd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ .tsbuildinfo **/.tsbuildinfo *.tgz +.tools/ diff --git a/README.md b/README.md index 030296a..53f57e8 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ Prerequisites: - Node.js 22+ - pnpm 10+ -- `nats-server` on `PATH` for the no-account standalone broker demo +- `nats-server` for the no-account standalone broker demo. The easiest path is + `pnpm setup:nats`, which installs a repo-local server under `.tools/`. - Google Chrome/Chromium on `PATH`, or run `pnpm exec playwright install chromium`, for the browser proof in `prove:fresh-samples` @@ -68,9 +69,25 @@ Prerequisites: git clone git@github.com:matrix-sdk/matrix-sdk.git cd matrix-sdk pnpm install --frozen-lockfile +pnpm run doctor pnpm prove ``` +If the standalone broker demo reports missing NATS: + +```bash +pnpm setup:nats +pnpm run doctor:standalone +``` + +If you need the HiveCast browser proof and `pnpm doctor:hivecast` reports +missing Chromium: + +```bash +pnpm setup:browser +pnpm run doctor:hivecast +``` + `pnpm prove` runs the standalone proof: ```text @@ -161,9 +178,15 @@ belong in overlay packages, not in the SDK CLI core. ## Demos -Standalone SDK demo, no provider account or key required: +### Path A: Local Broker, No HiveCast Account + +Use this path when you only want to prove the SDK can run an actor over a real +broker without a managed platform account. ```bash +pnpm install --frozen-lockfile +pnpm setup:nats +pnpm run doctor:standalone pnpm demo:standalone ``` @@ -172,20 +195,38 @@ This builds the SDK and `examples/standalone-greeter`, starts the actor with and shuts it down. It does not use a provider account, machine link, managed host service, or local HTTP control server. +### Path B: HiveCast Broker, HiveCast API Key + +Use this path when you want HiveCast to issue the broker credential. The SDK +still runs the actor/page directly; HiveCast supplies NATS TCP/WebSocket URLs and +short-lived actor credentials. + +Create a HiveCast API key in the HiveCast web UI: + +```text +Settings -> API Keys -> Create Key +Purpose: Machine install +Required scopes: host:init host:credentials:issue profile:install +``` + +Then run: + +```bash +export MATRIX_CLOUD=https:// +export MATRIX_SPACE= +export MATRIX_API_KEY= + +pnpm install --frozen-lockfile +pnpm setup:browser +pnpm run doctor:hivecast +pnpm prove:fresh-samples +``` + Provider API-key login/config work has been moved out of the active SDK demo set and preserved under `archive/sdk-provider-overlay-candidates/`. It belongs to a provider overlay that can write the same broker config shape consumed by the SDK CLI. -HiveCast-managed SDK demos, API key required: - -```bash -MATRIX_CLOUD=https:// \ -MATRIX_SPACE= \ -MATRIX_API_KEY= \ -pnpm prove:fresh-samples -``` - That proof builds and runs two fresh package-author demos: - `examples/fresh-server-actor` exchanges the API key for an actor-scoped @@ -199,6 +240,25 @@ That proof builds and runs two fresh package-author demos: The API key stays server-side for the web demo. The browser receives only the short-lived actor JWT/seed returned by the HiveCast credential exchange. +### Path C: Custom Broker + +Use this path when you already operate a broker and want to pass the connection +explicitly. + +```bash +matrix package run . \ + --nats-url nats://:4222 \ + --root \ + --jwt '' \ + --seed '' \ + --check-op ping +``` + +The SDK CLI also reads `MATRIX_NATS_URL`, `MATRIX_ROOT`/`MATRIX_SPACE`, +`MATRIX_NATS_JWT`, and `MATRIX_NATS_SEED`, plus config files under `--home`, +`./.matrix`, and `~/.matrix`. The SDK CLI does not create custom-broker +accounts. It consumes broker access you already have. + ## Repository Layout ```text diff --git a/examples/fresh-server-actor/README.md b/examples/fresh-server-actor/README.md index 9a4504b..da2f81e 100644 --- a/examples/fresh-server-actor/README.md +++ b/examples/fresh-server-actor/README.md @@ -16,7 +16,8 @@ export MATRIX_CLOUD=https:// export MATRIX_SPACE= export MATRIX_API_KEY= -pnpm install +pnpm install --frozen-lockfile +pnpm run doctor pnpm --filter matrix-sdk-example-fresh-server-actor build pnpm --filter matrix-sdk-example-fresh-server-actor check ``` diff --git a/examples/fresh-web-page/README.md b/examples/fresh-web-page/README.md index 15e3978..6bb2a15 100644 --- a/examples/fresh-web-page/README.md +++ b/examples/fresh-web-page/README.md @@ -21,7 +21,9 @@ export MATRIX_CLOUD=https:// export MATRIX_SPACE= export MATRIX_API_KEY= -pnpm install +pnpm install --frozen-lockfile +pnpm setup:browser +pnpm run doctor:hivecast pnpm --filter matrix-sdk-example-fresh-web-page dev ``` diff --git a/examples/standalone-greeter/README.md b/examples/standalone-greeter/README.md index 944adcb..83e6654 100644 --- a/examples/standalone-greeter/README.md +++ b/examples/standalone-greeter/README.md @@ -6,6 +6,9 @@ and without committed credentials. Run from the repository root: ```bash +pnpm install --frozen-lockfile +pnpm setup:nats +pnpm run doctor:standalone pnpm demo:standalone ``` diff --git a/examples/standalone-greeter/run-demo.mjs b/examples/standalone-greeter/run-demo.mjs index ca41c65..48c4cbf 100644 --- a/examples/standalone-greeter/run-demo.mjs +++ b/examples/standalone-greeter/run-demo.mjs @@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { createServer, createConnection } from 'node:net'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveNatsServer } from '../../scripts/lib/nats-server-tool.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..', '..'); @@ -82,7 +83,15 @@ try { run('pnpm', ['run', 'build']); run('pnpm', ['--filter', 'matrix-sdk-example-standalone-greeter', 'run', 'build']); - natsServer = spawn('nats-server', ['-p', String(port), '-a', '127.0.0.1'], { + const nats = resolveNatsServer(repoRoot); + if (!nats) { + throw new Error([ + 'nats-server is required for pnpm demo:standalone.', + 'Run pnpm setup:nats, install NATS Server on PATH, or set MATRIX_NATS_SERVER=/path/to/nats-server.', + ].join(' ')); + } + + natsServer = spawn(nats.path, ['-p', String(port), '-a', '127.0.0.1'], { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], env: process.env, @@ -117,6 +126,10 @@ try { mount: body.mount, root: body.root, natsUrl: body.natsUrl, + natsServer: { + source: nats.source, + path: nats.path, + }, authMode: body.authMode, check: body.check, }, null, 2)); diff --git a/package.json b/package.json index ffe10ec..a55c31b 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,13 @@ "test": "pnpm -r --sort --if-present run test", "type-check": "pnpm -r --sort --if-present run type-check", "pack:all": "pnpm --filter './packages/*' -r exec pnpm pack", + "doctor": "node scripts/doctor.mjs", + "doctor:standalone": "node scripts/doctor.mjs --standalone", + "doctor:hivecast": "node scripts/doctor.mjs --hivecast", + "setup:nats": "node scripts/setup-nats-server.mjs", + "setup:browser": "pnpm exec playwright install chromium", "demo:standalone": "node examples/standalone-greeter/run-demo.mjs", - "prove:cli": "pnpm --filter @open-matrix/cli build && pnpm --filter @open-matrix/cli test && pnpm run demo:standalone", + "prove:cli": "pnpm --filter @open-matrix/cli... build && pnpm --filter @open-matrix/cli test && pnpm run demo:standalone", "prove:external-consumer": "node scripts/prove-external-consumer.mjs", "prove:fresh-samples": "node scripts/prove-fresh-samples.mjs", "prove": "pnpm run clean && pnpm run build && pnpm run test && pnpm run type-check && pnpm run pack:all" diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs new file mode 100644 index 0000000..14e03cf --- /dev/null +++ b/scripts/doctor.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolveNatsServer, readNatsServerVersion } from './lib/nats-server-tool.mjs'; + +const json = process.argv.includes('--json'); +const mode = process.argv.includes('--standalone') + ? 'standalone' + : process.argv.includes('--hivecast') + ? 'hivecast' + : process.argv.includes('--all') + ? 'all' + : 'base'; + +function check(name, ok, details = {}) { + return { name, ok, ...details }; +} + +function run(command, args) { + const result = spawnSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + if (result.error) return { ok: false, error: result.error.message }; + if (result.status !== 0) return { ok: false, error: `${command} exited ${result.status}`, stderr: result.stderr.trim() }; + return { ok: true, stdout: result.stdout.trim(), stderr: result.stderr.trim() }; +} + +function major(version) { + return Number(String(version).split('.')[0] ?? 0); +} + +function findBrowser() { + if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE && existsSync(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE)) { + return { path: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, source: 'PLAYWRIGHT_CHROMIUM_EXECUTABLE' }; + } + for (const path of ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser']) { + if (existsSync(path)) return { path, source: 'system' }; + } + return null; +} + +const checks = []; + +const nodeVersion = process.versions.node; +checks.push(check('node', major(nodeVersion) >= 22, { + version: nodeVersion, + required: '22+', + fix: 'Install Node.js 22 or newer.', +})); + +const pnpm = run('pnpm', ['--version']); +checks.push(check('pnpm', pnpm.ok && major(pnpm.stdout) >= 10, { + version: pnpm.ok ? pnpm.stdout : null, + required: '10+', + fix: 'Install pnpm 10: corepack enable && corepack prepare pnpm@10.29.3 --activate', + error: pnpm.ok ? undefined : pnpm.error, +})); + +let nats = null; +try { + nats = resolveNatsServer(); + checks.push(check('nats-server', Boolean(nats), { + path: nats?.path ?? null, + source: nats?.source ?? null, + version: nats ? readNatsServerVersion(nats.path) : null, + requiredFor: 'pnpm demo:standalone', + fix: 'Run pnpm setup:nats, or install NATS Server and put nats-server on PATH.', + })); +} catch (error) { + checks.push(check('nats-server', false, { + error: error instanceof Error ? error.message : String(error), + requiredFor: 'pnpm demo:standalone', + fix: 'Run pnpm setup:nats, or set MATRIX_NATS_SERVER to the nats-server binary.', + })); +} + +const browser = findBrowser(); +checks.push(check('chromium', Boolean(browser), { + path: browser?.path ?? null, + source: browser?.source ?? null, + requiredFor: 'live browser proof in pnpm prove:fresh-samples', + fix: 'Run pnpm setup:browser, or set PLAYWRIGHT_CHROMIUM_EXECUTABLE to Chrome/Chromium.', +})); + +const hivecastEnv = { + MATRIX_CLOUD: Boolean(process.env.MATRIX_CLOUD), + MATRIX_SPACE: Boolean(process.env.MATRIX_SPACE), + MATRIX_API_KEY: Boolean(process.env.MATRIX_API_KEY), +}; +checks.push(check('hivecast-env', Object.values(hivecastEnv).every(Boolean), { + present: hivecastEnv, + requiredFor: 'live HiveCast server/browser samples in pnpm prove:fresh-samples', + fix: 'Create a HiveCast API key, then export MATRIX_CLOUD, MATRIX_SPACE, and MATRIX_API_KEY.', +})); + +const requiredByMode = { + base: ['node', 'pnpm'], + standalone: ['node', 'pnpm', 'nats-server'], + hivecast: ['node', 'pnpm', 'chromium', 'hivecast-env'], + all: ['node', 'pnpm', 'nats-server', 'chromium', 'hivecast-env'], +}; +const requiredFailures = checks.filter((item) => requiredByMode[mode].includes(item.name) && !item.ok); +const proof = { + ok: requiredFailures.length === 0, + mode, + checks, +}; + +if (json) { + console.log(JSON.stringify(proof, null, 2)); +} else { + console.log(`Matrix SDK doctor mode: ${mode}`); + for (const item of checks) { + const required = requiredByMode[mode].includes(item.name); + const mark = item.ok ? 'ok' : required ? 'missing' : 'optional-missing'; + console.log(`${mark} ${item.name}${item.version ? ` (${item.version})` : ''}${item.path ? ` at ${item.path}` : ''}`); + if (!item.ok) console.log(` fix: ${item.fix}`); + } +} + +process.exit(proof.ok ? 0 : 1); diff --git a/scripts/lib/nats-server-tool.mjs b/scripts/lib/nats-server-tool.mjs new file mode 100644 index 0000000..31ba262 --- /dev/null +++ b/scripts/lib/nats-server-tool.mjs @@ -0,0 +1,170 @@ +import { spawnSync } from 'node:child_process'; +import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { chmod, copyFile } from 'node:fs/promises'; +import { get } from 'node:https'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const NATS_SERVER_VERSION = '2.14.1'; + +const thisFile = fileURLToPath(import.meta.url); +export const repoRoot = resolve(dirname(thisFile), '..', '..'); + +function platformName() { + if (process.platform === 'linux') return 'linux'; + if (process.platform === 'darwin') return 'darwin'; + if (process.platform === 'win32') return 'windows'; + throw new Error(`Unsupported platform for managed nats-server install: ${process.platform}`); +} + +function archName() { + if (process.arch === 'x64') return 'amd64'; + if (process.arch === 'arm64') return 'arm64'; + throw new Error(`Unsupported architecture for managed nats-server install: ${process.arch}`); +} + +function executableName() { + return process.platform === 'win32' ? 'nats-server.exe' : 'nats-server'; +} + +export function managedNatsServerPath(root = repoRoot) { + return join(root, '.tools', 'nats-server', `v${NATS_SERVER_VERSION}`, `${platformName()}-${archName()}`, 'bin', executableName()); +} + +function pathEntries() { + return (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean); +} + +function executableExists(path) { + try { + return existsSync(path) && statSync(path).isFile(); + } catch { + return false; + } +} + +function findOnPath(name) { + for (const entry of pathEntries()) { + const candidate = join(entry, name); + if (executableExists(candidate)) return candidate; + } + return null; +} + +export function resolveNatsServer(root = repoRoot) { + const explicit = process.env.MATRIX_NATS_SERVER; + if (explicit) { + const resolved = resolve(explicit); + if (!executableExists(resolved)) { + throw new Error(`MATRIX_NATS_SERVER points to a missing file: ${resolved}`); + } + return { path: resolved, source: 'MATRIX_NATS_SERVER' }; + } + + const managed = managedNatsServerPath(root); + if (executableExists(managed)) return { path: managed, source: 'repo-managed' }; + + const onPath = findOnPath(executableName()) ?? findOnPath('nats-server'); + if (onPath) return { path: onPath, source: 'PATH' }; + + return null; +} + +export function natsServerInstallUrl(version = NATS_SERVER_VERSION) { + const format = process.platform === 'win32' ? 'zip' : 'tar.gz'; + const asset = `nats-server-v${version}-${platformName()}-${archName()}.${format}`; + return { + asset, + url: `https://github.com/nats-io/nats-server/releases/download/v${version}/${asset}`, + format, + }; +} + +function download(url, destination) { + return new Promise((resolveDownload, rejectDownload) => { + const request = get(url, (response) => { + if ([301, 302, 303, 307, 308].includes(response.statusCode ?? 0) && response.headers.location) { + response.resume(); + download(response.headers.location, destination).then(resolveDownload, rejectDownload); + return; + } + + if (response.statusCode !== 200) { + response.resume(); + rejectDownload(new Error(`Download failed with HTTP ${response.statusCode}: ${url}`)); + return; + } + + const file = createWriteStream(destination); + response.pipe(file); + file.on('finish', () => { + file.close(resolveDownload); + }); + file.on('error', rejectDownload); + }); + request.on('error', rejectDownload); + }); +} + +function walkFiles(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkFiles(path)); + else out.push(path); + } + return out; +} + +function extractArchive(archive, destination, format) { + mkdirSync(destination, { recursive: true }); + if (format === 'tar.gz') { + const result = spawnSync('tar', ['-xzf', archive, '-C', destination], { stdio: 'inherit' }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`tar extraction failed with exit ${result.status}`); + return; + } + + const script = `Expand-Archive -LiteralPath ${JSON.stringify(archive)} -DestinationPath ${JSON.stringify(destination)} -Force`; + const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], { stdio: 'inherit' }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`zip extraction failed with exit ${result.status}`); +} + +export async function installManagedNatsServer(root = repoRoot) { + const existing = resolveNatsServer(root); + if (existing?.source === 'repo-managed') { + return { ...existing, installed: false }; + } + + const target = managedNatsServerPath(root); + const installRoot = dirname(dirname(target)); + const tmpRoot = join(root, '.tools', 'tmp', `nats-server-v${NATS_SERVER_VERSION}-${Date.now()}`); + const { asset, url, format } = natsServerInstallUrl(); + const archive = join(tmpRoot, asset); + const extractDir = join(tmpRoot, 'extract'); + + rmSync(installRoot, { recursive: true, force: true }); + mkdirSync(tmpRoot, { recursive: true }); + + try { + await download(url, archive); + extractArchive(archive, extractDir, format); + const binary = walkFiles(extractDir).find((path) => path.endsWith(executableName()) || path.endsWith('/nats-server')); + if (!binary) throw new Error(`Downloaded ${asset} did not contain ${executableName()}`); + + mkdirSync(dirname(target), { recursive: true }); + await copyFile(binary, target); + if (process.platform !== 'win32') await chmod(target, 0o755); + return { path: target, source: 'repo-managed', installed: true, url }; + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + } +} + +export function readNatsServerVersion(binaryPath) { + const result = spawnSync(binaryPath, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${binaryPath} --version failed with exit ${result.status}`); + return `${result.stdout}${result.stderr}`.trim(); +} diff --git a/scripts/setup-nats-server.mjs b/scripts/setup-nats-server.mjs new file mode 100644 index 0000000..7a1452c --- /dev/null +++ b/scripts/setup-nats-server.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import { installManagedNatsServer, readNatsServerVersion } from './lib/nats-server-tool.mjs'; + +const json = process.argv.includes('--json'); + +try { + const result = await installManagedNatsServer(); + const version = readNatsServerVersion(result.path); + const proof = { + ok: true, + tool: 'nats-server', + installed: result.installed, + source: result.source, + path: result.path, + version, + url: result.url, + }; + if (json) { + console.log(JSON.stringify(proof, null, 2)); + } else { + console.log(`nats-server ready: ${version}`); + console.log(`path: ${result.path}`); + console.log(`source: ${result.source}${result.installed ? ' (installed now)' : ''}`); + } +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (json) { + console.log(JSON.stringify({ ok: false, tool: 'nats-server', error: message }, null, 2)); + } else { + console.error(`nats-server setup failed: ${message}`); + } + process.exit(1); +}