feat(cli): add local demo broker commands
This commit is contained in:
parent
306c421a4e
commit
4c61a73c02
49
README.md
49
README.md
@ -80,6 +80,55 @@ pnpm setup:nats
|
||||
pnpm run doctor:standalone
|
||||
```
|
||||
|
||||
## Install From The HiveCast/Gitea Registry
|
||||
|
||||
This path does not require cloning this repository, a HiveCast account, a
|
||||
HiveCast local install, or a preinstalled Matrix binary. It installs the SDK
|
||||
packages from the npm-compatible Gitea registry and runs a local demo broker.
|
||||
|
||||
```bash
|
||||
mkdir matrix-sdk-customer-demo
|
||||
cd matrix-sdk-customer-demo
|
||||
npm init -y
|
||||
|
||||
cat > .npmrc <<'EOF'
|
||||
@open-matrix:registry=https://registry.hivecast.ai/api/packages/open-matrix/npm/
|
||||
registry=https://registry.npmjs.org/
|
||||
EOF
|
||||
|
||||
npm install @open-matrix/sdk @open-matrix/cli
|
||||
npx matrix broker setup
|
||||
npx matrix broker start --port 4222
|
||||
|
||||
cat > GreeterActor.mjs <<'EOF'
|
||||
import { MatrixActor } from '@open-matrix/sdk';
|
||||
|
||||
export default class GreeterActor extends MatrixActor {
|
||||
static accepts = { echo: {} };
|
||||
onEcho(payload = {}) {
|
||||
return {
|
||||
ok: true,
|
||||
message: payload.message ?? null,
|
||||
actor: 'GreeterActor',
|
||||
};
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
npx matrix actor run ./GreeterActor.mjs \
|
||||
--mount demo.greeter \
|
||||
--nats-url nats://127.0.0.1:4222 \
|
||||
--root demo \
|
||||
--check-op echo \
|
||||
--check-payload '{"message":"hello-from-npm-install"}' \
|
||||
--json
|
||||
|
||||
npx matrix broker stop
|
||||
```
|
||||
|
||||
Expected result: the actor run command prints JSON with `ok: true` and the
|
||||
`echo` result from `GreeterActor`.
|
||||
|
||||
If you need the HiveCast browser proof and `pnpm doctor:hivecast` reports
|
||||
missing Chromium:
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@open-matrix/browser-host",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"description": "Browser host shell for Matrix custom elements (extracted from @open-matrix/core during core-decontamination).",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@open-matrix/browser-kit",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"description": "Shared browser infrastructure for Matrix webapps — shims, theme tokens, vite config factory, app shell",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -18,6 +18,9 @@ matrix init
|
||||
matrix actor greeter
|
||||
matrix package run . --nats-url nats://127.0.0.1:4222 --root demo --check-op echo
|
||||
matrix actor run ./dist/GreeterActor.js --mount demo.greeter --nats-url nats://127.0.0.1:4222 --root demo --check-op echo
|
||||
matrix broker setup
|
||||
matrix broker start --port 4222
|
||||
matrix broker stop
|
||||
matrix config set registry https://registry.npmjs.org/
|
||||
matrix registry doctor
|
||||
```
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@open-matrix/cli",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"description": "Matrix package-author CLI.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
290
packages/cli/src/commands/broker.ts
Normal file
290
packages/cli/src/commands/broker.ts
Normal file
@ -0,0 +1,290 @@
|
||||
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||
import { closeSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { get as httpsGet } from 'node:https';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createConnection } from 'node:net';
|
||||
|
||||
export interface IBrokerSetupOptions {
|
||||
readonly toolsDir?: string;
|
||||
readonly json?: boolean;
|
||||
}
|
||||
|
||||
export interface IBrokerStartOptions {
|
||||
readonly toolsDir?: string;
|
||||
readonly port?: string;
|
||||
readonly host?: string;
|
||||
readonly pidFile?: string;
|
||||
readonly logFile?: string;
|
||||
readonly json?: boolean;
|
||||
}
|
||||
|
||||
export interface IBrokerStopOptions {
|
||||
readonly pidFile?: string;
|
||||
readonly json?: boolean;
|
||||
}
|
||||
|
||||
const NATS_VERSION = '2.14.1';
|
||||
|
||||
interface INatsPlatform {
|
||||
readonly key: string;
|
||||
readonly archiveName: string;
|
||||
readonly binaryRelativePath: string;
|
||||
}
|
||||
|
||||
interface IBrokerSetupResult {
|
||||
readonly ok: true;
|
||||
readonly tool: 'nats-server';
|
||||
readonly version: string;
|
||||
readonly source: 'matrix-cli-managed';
|
||||
readonly path: string;
|
||||
readonly archiveUrl: string;
|
||||
}
|
||||
|
||||
interface IBrokerStartResult {
|
||||
readonly ok: true;
|
||||
readonly tool: 'nats-server';
|
||||
readonly pid: number;
|
||||
readonly url: string;
|
||||
readonly pidFile: string;
|
||||
readonly logFile: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
interface IBrokerStopResult {
|
||||
readonly ok: true;
|
||||
readonly pid: number;
|
||||
readonly pidFile: string;
|
||||
}
|
||||
|
||||
export async function brokerSetupCommand(options: IBrokerSetupOptions): Promise<void> {
|
||||
const result = await ensureNatsServer(options.toolsDir);
|
||||
printResult(result, options.json);
|
||||
}
|
||||
|
||||
export async function brokerStartCommand(options: IBrokerStartOptions): Promise<void> {
|
||||
const setup = await ensureNatsServer(options.toolsDir);
|
||||
const port = parsePort(options.port);
|
||||
const host = options.host?.trim() || '127.0.0.1';
|
||||
const pidFile = resolve(options.pidFile || join('.matrix', 'broker', 'nats-server.pid'));
|
||||
const logFile = resolve(options.logFile || join('.matrix', 'broker', 'nats-server.log'));
|
||||
mkdirSync(dirname(pidFile), { recursive: true });
|
||||
mkdirSync(dirname(logFile), { recursive: true });
|
||||
|
||||
if (existsSync(pidFile)) {
|
||||
const pid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
|
||||
if (Number.isFinite(pid) && isProcessAlive(pid)) {
|
||||
throw new Error(`MATRIX_BROKER_ALREADY_RUNNING: pid ${pid} from ${pidFile} is still alive`);
|
||||
}
|
||||
rmSync(pidFile, { force: true });
|
||||
}
|
||||
|
||||
const logFd = openSync(logFile, 'a');
|
||||
const child = spawn(setup.path, ['-p', String(port), '-a', host], {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
});
|
||||
closeSync(logFd);
|
||||
child.unref();
|
||||
writeFileSync(pidFile, `${child.pid}\n`);
|
||||
await waitForTcp(host, port, child);
|
||||
|
||||
const result: IBrokerStartResult = {
|
||||
ok: true,
|
||||
tool: 'nats-server',
|
||||
pid: child.pid ?? 0,
|
||||
url: `nats://${host}:${port}`,
|
||||
pidFile,
|
||||
logFile,
|
||||
path: setup.path,
|
||||
};
|
||||
printResult(result, options.json);
|
||||
}
|
||||
|
||||
export async function brokerStopCommand(options: IBrokerStopOptions): Promise<void> {
|
||||
const pidFile = resolve(options.pidFile || join('.matrix', 'broker', 'nats-server.pid'));
|
||||
if (!existsSync(pidFile)) {
|
||||
throw new Error(`MATRIX_BROKER_PID_FILE_NOT_FOUND: ${pidFile}`);
|
||||
}
|
||||
const pid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
|
||||
if (!Number.isFinite(pid)) {
|
||||
throw new Error(`MATRIX_BROKER_PID_FILE_INVALID: ${pidFile}`);
|
||||
}
|
||||
if (isProcessAlive(pid)) {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
await waitForExit(pid);
|
||||
}
|
||||
rmSync(pidFile, { force: true });
|
||||
const result: IBrokerStopResult = {
|
||||
ok: true,
|
||||
pid,
|
||||
pidFile,
|
||||
};
|
||||
printResult(result, options.json);
|
||||
}
|
||||
|
||||
async function ensureNatsServer(toolsDirOption?: string): Promise<IBrokerSetupResult> {
|
||||
const platform = resolveNatsPlatform();
|
||||
const toolsDir = resolve(toolsDirOption || join('.matrix', 'tools'));
|
||||
const installRoot = join(toolsDir, 'nats-server', `v${NATS_VERSION}`, platform.key);
|
||||
const binaryPath = join(installRoot, platform.binaryRelativePath);
|
||||
const archiveUrl = `https://github.com/nats-io/nats-server/releases/download/v${NATS_VERSION}/${platform.archiveName}`;
|
||||
if (!existsSync(binaryPath)) {
|
||||
mkdirSync(installRoot, { recursive: true });
|
||||
const archivePath = join(tmpdir(), `${platform.archiveName}.${process.pid}`);
|
||||
await downloadFile(archiveUrl, archivePath);
|
||||
extractArchive(archivePath, installRoot, platform.archiveName);
|
||||
rmSync(archivePath, { force: true });
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
tool: 'nats-server',
|
||||
version: NATS_VERSION,
|
||||
source: 'matrix-cli-managed',
|
||||
path: binaryPath,
|
||||
archiveUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNatsPlatform(): INatsPlatform {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
if (platform === 'linux' && arch === 'x64') {
|
||||
return {
|
||||
key: 'linux-amd64',
|
||||
archiveName: `nats-server-v${NATS_VERSION}-linux-amd64.tar.gz`,
|
||||
binaryRelativePath: `nats-server-v${NATS_VERSION}-linux-amd64/nats-server`,
|
||||
};
|
||||
}
|
||||
if (platform === 'linux' && arch === 'arm64') {
|
||||
return {
|
||||
key: 'linux-arm64',
|
||||
archiveName: `nats-server-v${NATS_VERSION}-linux-arm64.tar.gz`,
|
||||
binaryRelativePath: `nats-server-v${NATS_VERSION}-linux-arm64/nats-server`,
|
||||
};
|
||||
}
|
||||
if (platform === 'darwin' && arch === 'x64') {
|
||||
return {
|
||||
key: 'darwin-amd64',
|
||||
archiveName: `nats-server-v${NATS_VERSION}-darwin-amd64.tar.gz`,
|
||||
binaryRelativePath: `nats-server-v${NATS_VERSION}-darwin-amd64/nats-server`,
|
||||
};
|
||||
}
|
||||
if (platform === 'darwin' && arch === 'arm64') {
|
||||
return {
|
||||
key: 'darwin-arm64',
|
||||
archiveName: `nats-server-v${NATS_VERSION}-darwin-arm64.tar.gz`,
|
||||
binaryRelativePath: `nats-server-v${NATS_VERSION}-darwin-arm64/nats-server`,
|
||||
};
|
||||
}
|
||||
throw new Error(`MATRIX_BROKER_PLATFORM_UNSUPPORTED: ${platform}/${arch}`);
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, destination: string, redirectCount = 0): Promise<void> {
|
||||
await new Promise<void>((resolveDownload, rejectDownload) => {
|
||||
const request = httpsGet(url, (response) => {
|
||||
if (
|
||||
response.statusCode
|
||||
&& response.statusCode >= 300
|
||||
&& response.statusCode < 400
|
||||
&& response.headers.location
|
||||
) {
|
||||
response.resume();
|
||||
if (redirectCount >= 5) {
|
||||
rejectDownload(new Error(`MATRIX_BROKER_DOWNLOAD_REDIRECT_LIMIT: ${url}`));
|
||||
return;
|
||||
}
|
||||
const redirected = new URL(response.headers.location, url).toString();
|
||||
downloadFile(redirected, destination, redirectCount + 1).then(resolveDownload, rejectDownload);
|
||||
return;
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
rejectDownload(new Error(`MATRIX_BROKER_DOWNLOAD_FAILED: ${url} returned ${response.statusCode}`));
|
||||
response.resume();
|
||||
return;
|
||||
}
|
||||
const output = createWriteStream(destination, { mode: 0o600 });
|
||||
response.pipe(output);
|
||||
output.once('finish', () => output.close(() => resolveDownload()));
|
||||
output.once('error', rejectDownload);
|
||||
});
|
||||
request.once('error', rejectDownload);
|
||||
});
|
||||
}
|
||||
|
||||
function extractArchive(archivePath: string, installRoot: string, archiveName: string): void {
|
||||
if (!archiveName.endsWith('.tar.gz')) {
|
||||
throw new Error(`MATRIX_BROKER_ARCHIVE_UNSUPPORTED: ${archiveName}`);
|
||||
}
|
||||
const result = spawnSyncChecked('tar', ['-xzf', archivePath, '-C', installRoot]);
|
||||
if (result !== 0) {
|
||||
throw new Error(`MATRIX_BROKER_EXTRACT_FAILED: tar exited ${result}`);
|
||||
}
|
||||
}
|
||||
|
||||
function spawnSyncChecked(command: string, args: readonly string[]): number {
|
||||
const result = spawnSync(command, [...args], { stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
async function waitForTcp(host: string, port: number, child: ChildProcess): Promise<void> {
|
||||
const deadline = Date.now() + 10_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`MATRIX_BROKER_START_FAILED: nats-server exited ${child.exitCode}`);
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolveConnect, rejectConnect) => {
|
||||
const socket = createConnection({ host, port });
|
||||
socket.once('connect', () => {
|
||||
socket.end();
|
||||
resolveConnect();
|
||||
});
|
||||
socket.once('error', rejectConnect);
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error('MATRIX_BROKER_START_TIMEOUT');
|
||||
}
|
||||
|
||||
async function waitForExit(pid: number): Promise<void> {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessAlive(pid)) return;
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
|
||||
}
|
||||
if (isProcessAlive(pid)) {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePort(value: string | undefined): number {
|
||||
const port = Number.parseInt(value || '4222', 10);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`MATRIX_BROKER_PORT_INVALID: ${value}`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function printResult(value: unknown, json: boolean | undefined): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify(value));
|
||||
}
|
||||
@ -31,6 +31,11 @@ import {
|
||||
import { outdatedCommand } from './commands/outdated.js';
|
||||
import { updateForkCommand } from './commands/update-fork.js';
|
||||
import { searchCommand } from './commands/search.js';
|
||||
import {
|
||||
brokerSetupCommand,
|
||||
brokerStartCommand,
|
||||
brokerStopCommand,
|
||||
} from './commands/broker.js';
|
||||
|
||||
function fail(err: unknown): never {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
@ -42,7 +47,61 @@ export function createProgram(): Command {
|
||||
program
|
||||
.name('matrix')
|
||||
.description('Matrix SDK CLI for package authors')
|
||||
.version('0.1.0');
|
||||
.version('0.1.3');
|
||||
|
||||
const broker = program
|
||||
.command('broker')
|
||||
.description('Local demo broker commands');
|
||||
|
||||
broker
|
||||
.command('setup')
|
||||
.description('Install a project-local nats-server binary for SDK demos')
|
||||
.option('--tools-dir <path>', 'Tool install directory (default: ./.matrix/tools)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: { toolsDir?: string; json?: boolean }) => {
|
||||
try {
|
||||
await brokerSetupCommand(options);
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
|
||||
broker
|
||||
.command('start')
|
||||
.description('Start the project-local demo broker in the background')
|
||||
.option('--tools-dir <path>', 'Tool install directory (default: ./.matrix/tools)')
|
||||
.option('--port <port>', 'NATS broker port (default: 4222)')
|
||||
.option('--host <host>', 'NATS broker host (default: 127.0.0.1)')
|
||||
.option('--pid-file <path>', 'PID file path (default: ./.matrix/broker/nats-server.pid)')
|
||||
.option('--log-file <path>', 'Log file path (default: ./.matrix/broker/nats-server.log)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: {
|
||||
toolsDir?: string;
|
||||
port?: string;
|
||||
host?: string;
|
||||
pidFile?: string;
|
||||
logFile?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
await brokerStartCommand(options);
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
|
||||
broker
|
||||
.command('stop')
|
||||
.description('Stop the project-local demo broker')
|
||||
.option('--pid-file <path>', 'PID file path (default: ./.matrix/broker/nats-server.pid)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: { pidFile?: string; json?: boolean }) => {
|
||||
try {
|
||||
await brokerStopCommand(options);
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('init')
|
||||
|
||||
@ -86,6 +86,15 @@ describe('Matrix SDK CLI foundation', () => {
|
||||
assert.equal(packageRunCmd.description(), 'Run one package directly on the selected Space bus without managed host inventory');
|
||||
});
|
||||
|
||||
it('has local demo broker commands', () => {
|
||||
const program = createProgram();
|
||||
const brokerCmd = program.commands.find(cmd => cmd.name() === 'broker');
|
||||
assert.ok(brokerCmd, 'Should have broker command');
|
||||
assert.ok(brokerCmd.commands.find(cmd => cmd.name() === 'setup'), 'Should have broker setup command');
|
||||
assert.ok(brokerCmd.commands.find(cmd => cmd.name() === 'start'), 'Should have broker start command');
|
||||
assert.ok(brokerCmd.commands.find(cmd => cmd.name() === 'stop'), 'Should have broker stop command');
|
||||
});
|
||||
|
||||
it('does not expose provider machine-link credential commands', () => {
|
||||
const program = createProgram();
|
||||
const refreshCmd = program.commands.find(cmd => cmd.name() === 'refresh-credentials');
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@open-matrix/contracts",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"description": "Type contracts for Matrix substrate (placement, service registry, runtime presence). Almost zero runtime — one pure function (decideMatrixPlacementBind) plus pure types. Zero @open-matrix/core dependency. Extracted from @open-matrix/core during core-decontamination Phase 2 so schema evolution doesn't rebuild the actor kernel.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@open-matrix/core",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"description": "Matrix runtime SDK \u2014 actors, Web Components, transports, serialization, and LISP-on-Protocol",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@open-matrix/federation",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"description": "Matrix federation layer: mailbox-based cross-realm messaging with legacy (matrix3) and modern (mxenv1) wire format support and security guardrails.",
|
||||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@open-matrix/omega-core",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"description": "Narrow Omega core/interpreter facade for Matrix membrane consumers",
|
||||
"private": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@open-matrix/sdk",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"description": "Public Matrix actor SDK facade for package authors.",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user