diff --git a/README.md b/README.md index a03eec0..cd30484 100644 --- a/README.md +++ b/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: diff --git a/packages/browser-host/package.json b/packages/browser-host/package.json index f56ba19..63f61ab 100644 --- a/packages/browser-host/package.json +++ b/packages/browser-host/package.json @@ -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", diff --git a/packages/browser-kit/package.json b/packages/browser-kit/package.json index e5bdfd7..02bb848 100644 --- a/packages/browser-kit/package.json +++ b/packages/browser-kit/package.json @@ -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", diff --git a/packages/cli/README.md b/packages/cli/README.md index f8ef63b..a3b8ffd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 ``` diff --git a/packages/cli/package.json b/packages/cli/package.json index 7d1cccb..8f82803 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@open-matrix/cli", - "version": "0.1.0", + "version": "0.1.3", "description": "Matrix package-author CLI.", "type": "module", "bin": { diff --git a/packages/cli/src/commands/broker.ts b/packages/cli/src/commands/broker.ts new file mode 100644 index 0000000..14b236f --- /dev/null +++ b/packages/cli/src/commands/broker.ts @@ -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 { + const result = await ensureNatsServer(options.toolsDir); + printResult(result, options.json); +} + +export async function brokerStartCommand(options: IBrokerStartOptions): Promise { + 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 { + 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 { + 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 { + await new Promise((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 { + 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((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 { + 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)); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index aa13b63..775b5f8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 ', '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 ', 'Tool install directory (default: ./.matrix/tools)') + .option('--port ', 'NATS broker port (default: 4222)') + .option('--host ', 'NATS broker host (default: 127.0.0.1)') + .option('--pid-file ', 'PID file path (default: ./.matrix/broker/nats-server.pid)') + .option('--log-file ', '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 ', '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') diff --git a/packages/cli/tests/unit/cli-help.spec.ts b/packages/cli/tests/unit/cli-help.spec.ts index 8431f16..857dfcc 100644 --- a/packages/cli/tests/unit/cli-help.spec.ts +++ b/packages/cli/tests/unit/cli-help.spec.ts @@ -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'); diff --git a/packages/contracts/package.json b/packages/contracts/package.json index a6f5b2f..934743f 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index 5b201ce..72f1943 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/federation/package.json b/packages/federation/package.json index 14351c1..f05f20c 100644 --- a/packages/federation/package.json +++ b/packages/federation/package.json @@ -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", diff --git a/packages/omega-core/package.json b/packages/omega-core/package.json index 9d8867c..67ec7a5 100644 --- a/packages/omega-core/package.json +++ b/packages/omega-core/package.json @@ -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, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a6ef74b..58705f4 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -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,