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
*.tgz
.tools/

View File

@ -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://<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
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://<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:
- `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://<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
```text

View File

@ -16,7 +16,8 @@ export MATRIX_CLOUD=https://<your-hivecast-cloud>
export MATRIX_SPACE=<your-space-root>
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 check
```

View File

@ -21,7 +21,9 @@ export MATRIX_CLOUD=https://<your-hivecast-cloud>
export MATRIX_SPACE=<your-space-root>
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
```

View File

@ -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
```

View File

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

View File

@ -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"

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