docs: make SDK onboarding self-checking

This commit is contained in:
Ubuntu 2026-06-08 07:24:38 +00:00
parent 4ab8dc7986
commit dd1872e3de
10 changed files with 422 additions and 15 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ dist/
.tsbuildinfo .tsbuildinfo
**/.tsbuildinfo **/.tsbuildinfo
*.tgz *.tgz
.tools/

View File

@ -60,7 +60,8 @@ Prerequisites:
- Node.js 22+ - Node.js 22+
- pnpm 10+ - 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`, - Google Chrome/Chromium on `PATH`, or run `pnpm exec playwright install chromium`,
for the browser proof in `prove:fresh-samples` for the browser proof in `prove:fresh-samples`
@ -68,9 +69,25 @@ Prerequisites:
git clone git@github.com:matrix-sdk/matrix-sdk.git git clone git@github.com:matrix-sdk/matrix-sdk.git
cd matrix-sdk cd matrix-sdk
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm run doctor
pnpm prove 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: `pnpm prove` runs the standalone proof:
```text ```text
@ -161,9 +178,15 @@ belong in overlay packages, not in the SDK CLI core.
## Demos ## 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 ```bash
pnpm install --frozen-lockfile
pnpm setup:nats
pnpm run doctor:standalone
pnpm demo: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 and shuts it down. It does not use a provider account, machine link, managed
host service, or local HTTP control server. 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://<your-hivecast-cloud>
export MATRIX_SPACE=<your-space-root>
export MATRIX_API_KEY=<your-hivecast-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 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 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 provider overlay that can write the same broker config shape consumed by the SDK
CLI. CLI.
HiveCast-managed SDK demos, API key required:
```bash
MATRIX_CLOUD=https://<your-hivecast-cloud> \
MATRIX_SPACE=<your-space-root> \
MATRIX_API_KEY=<your-hivecast-api-key> \
pnpm prove:fresh-samples
```
That proof builds and runs two fresh package-author demos: That proof builds and runs two fresh package-author demos:
- `examples/fresh-server-actor` exchanges the API key for an actor-scoped - `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 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. 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://<host>:4222 \
--root <subject-root> \
--jwt '<optional-user-jwt>' \
--seed '<optional-user-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 ## Repository Layout
```text ```text

View File

@ -16,7 +16,8 @@ export MATRIX_CLOUD=https://<your-hivecast-cloud>
export MATRIX_SPACE=<your-space-root> export MATRIX_SPACE=<your-space-root>
export MATRIX_API_KEY=<your-hivecast-api-key> export MATRIX_API_KEY=<your-hivecast-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 build
pnpm --filter matrix-sdk-example-fresh-server-actor check pnpm --filter matrix-sdk-example-fresh-server-actor check
``` ```

View File

@ -21,7 +21,9 @@ export MATRIX_CLOUD=https://<your-hivecast-cloud>
export MATRIX_SPACE=<your-space-root> export MATRIX_SPACE=<your-space-root>
export MATRIX_API_KEY=<your-hivecast-api-key> export MATRIX_API_KEY=<your-hivecast-api-key>
pnpm install pnpm install --frozen-lockfile
pnpm setup:browser
pnpm run doctor:hivecast
pnpm --filter matrix-sdk-example-fresh-web-page dev pnpm --filter matrix-sdk-example-fresh-web-page dev
``` ```

View File

@ -6,6 +6,9 @@ and without committed credentials.
Run from the repository root: Run from the repository root:
```bash ```bash
pnpm install --frozen-lockfile
pnpm setup:nats
pnpm run doctor:standalone
pnpm demo:standalone pnpm demo:standalone
``` ```

View File

@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process';
import { createServer, createConnection } from 'node:net'; import { createServer, createConnection } from 'node:net';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { resolveNatsServer } from '../../scripts/lib/nats-server-tool.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '..', '..'); const repoRoot = resolve(__dirname, '..', '..');
@ -82,7 +83,15 @@ try {
run('pnpm', ['run', 'build']); run('pnpm', ['run', 'build']);
run('pnpm', ['--filter', 'matrix-sdk-example-standalone-greeter', '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, cwd: repoRoot,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: process.env, env: process.env,
@ -117,6 +126,10 @@ try {
mount: body.mount, mount: body.mount,
root: body.root, root: body.root,
natsUrl: body.natsUrl, natsUrl: body.natsUrl,
natsServer: {
source: nats.source,
path: nats.path,
},
authMode: body.authMode, authMode: body.authMode,
check: body.check, check: body.check,
}, null, 2)); }, null, 2));

View File

@ -10,8 +10,13 @@
"test": "pnpm -r --sort --if-present run test", "test": "pnpm -r --sort --if-present run test",
"type-check": "pnpm -r --sort --if-present run type-check", "type-check": "pnpm -r --sort --if-present run type-check",
"pack:all": "pnpm --filter './packages/*' -r exec pnpm pack", "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", "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:external-consumer": "node scripts/prove-external-consumer.mjs",
"prove:fresh-samples": "node scripts/prove-fresh-samples.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" "prove": "pnpm run clean && pnpm run build && pnpm run test && pnpm run type-check && pnpm run pack:all"

119
scripts/doctor.mjs Normal file
View File

@ -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);

View File

@ -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();
}

View File

@ -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);
}