From 8a9b788f769aaa60f6df91376f8c78d54c3fe84e Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 8 Jun 2026 04:18:18 +0000 Subject: [PATCH] feat(cli): replace prototype with SDK runner --- README.md | 64 +- .../CONTRACT.md | 0 .../cli-http-prototype-2026-06-08/README.md | 23 + .../package.json | 40 + .../scripts}/prove-sdk-cli-uat.mjs | 0 .../src/__tests__/cli.test.ts | 0 .../src/bin/matrix.ts | 0 .../cli-http-prototype-2026-06-08}/src/cli.ts | 0 .../tsconfig.json | 20 + .../fp30-auth-session.spec.ts | 2598 +++++++++++++++++ .../hivecast-link-store.spec.ts | 178 ++ .../auth.hivecast-device.ts | 1859 ++++++++++++ .../config-command.boundary-explain.spec.ts | 51 + .../config.boundary-explain.ts | 320 ++ .../README.mx-cli-original.md | 43 + ...ctor-run.hivecast-api-key-exchange.spec.ts | 292 ++ .../actor-run.hivecast-api-key-exchange.ts | 693 +++++ archive/mx-cli-runtime-host-commands/run.ts | 1052 +++++++ .../runner-control-plane.ts | 1418 +++++++++ .../runner-placement-policy.ts | 305 ++ .../runner-runtime-control.ts | 286 ++ .../runner-runtime.ts | 296 ++ .../mx-cli-runtime-host-commands/service.ts | 685 +++++ .../standalone-webapp-server.ts | 522 ++++ .../workspace-config.ts | 185 ++ .../actor-command.host-workspace.spec.ts | 171 ++ .../run-command.spec.ts | 408 +++ .../runner-control-plane.spec.ts | 920 ++++++ .../runner-runtime.spec.ts | 46 + .../runner-transport.spec.ts | 97 + .../service-command.spec.ts | 1032 +++++++ .../standalone-webapp-server.spec.ts | 244 ++ .../hivecast-link-store.ts | 992 +++++++ .../package-environment.ts | 139 + .../runner-transport.ts | 150 + .../webapp-manifest.ts | 112 + .../hivecast-login/.env.example | 0 .../hivecast-login/.gitignore | 0 .../hivecast-login/README.md | 0 .../hivecast-login/run-demo.mjs | 0 examples/standalone-greeter/README.md | 11 +- examples/standalone-greeter/run-demo.mjs | 64 +- package.json | 3 +- packages/cli/README.md | 50 +- packages/cli/package.json | 36 +- packages/cli/src/commands/actor-element.ts | 125 + packages/cli/src/commands/actor-run.ts | 605 ++++ packages/cli/src/commands/actor.ts | 236 ++ packages/cli/src/commands/auth.ts | 95 + packages/cli/src/commands/config.ts | 93 + packages/cli/src/commands/cqrs.ts | 114 + packages/cli/src/commands/element.ts | 108 + packages/cli/src/commands/fork.ts | 254 ++ packages/cli/src/commands/info.ts | 38 + packages/cli/src/commands/init.ts | 133 + packages/cli/src/commands/install.ts | 676 +++++ packages/cli/src/commands/list.ts | 19 + packages/cli/src/commands/outdated.ts | 91 + packages/cli/src/commands/pack.ts | 65 + packages/cli/src/commands/publish.ts | 173 ++ packages/cli/src/commands/registry.ts | 312 ++ packages/cli/src/commands/search.ts | 89 + packages/cli/src/commands/uninstall.ts | 26 + packages/cli/src/commands/update-fork.ts | 95 + packages/cli/src/commands/validate.ts | 94 + packages/cli/src/commands/webapp.ts | 553 ++++ packages/cli/src/index.ts | 805 +++++ packages/cli/src/utils/archive.ts | 73 + packages/cli/src/utils/auth-store.ts | 207 ++ packages/cli/src/utils/config-store.ts | 174 ++ packages/cli/src/utils/discovery-extractor.ts | 538 ++++ packages/cli/src/utils/discovery-index.ts | 378 +++ packages/cli/src/utils/manifestValidator.ts | 255 ++ packages/cli/src/utils/npm-registry-client.ts | 345 +++ packages/cli/src/utils/package-store.ts | 212 ++ packages/cli/src/utils/registry-store.ts | 131 + packages/cli/src/utils/runner-security.ts | 16 + packages/cli/src/utils/scaffold.ts | 164 ++ packages/cli/src/utils/service-manifest.ts | 448 +++ packages/cli/src/utils/versioning.ts | 28 + packages/cli/tests/helpers/cli-harness.ts | 75 + .../cli/tests/helpers/package-fixtures.ts | 73 + .../integration/fp30-auth-publish.spec.ts | 589 ++++ .../integration/fp30-fork-lifecycle.spec.ts | 73 + .../fp30-install-lifecycle.spec.ts | 146 + .../fp30-install-source-resolution.spec.ts | 611 ++++ ...stall-uninstall-list-info-validate.spec.ts | 167 ++ .../fp30-outdated-update-fork.spec.ts | 142 + .../integration/fp30-pack-command.spec.ts | 53 + .../fp30-publish-preflight.spec.ts | 342 +++ .../integration/fp30-registry-command.spec.ts | 192 ++ .../fp30-scaffold-actor-element.spec.ts | 54 + .../integration/fp30-scaffold-cqrs.spec.ts | 54 + .../integration/fp30-scaffold-element.spec.ts | 54 + .../cli/tests/integration/fp30-search.spec.ts | 341 +++ .../integration/fp30-update-fork.spec.ts | 120 + packages/cli/tests/support/installCoreStub.ts | 77 + packages/cli/tests/unit/actor-run.spec.ts | 265 ++ packages/cli/tests/unit/cli-help.spec.ts | 98 + .../cli/tests/unit/config-command.spec.ts | 47 + packages/cli/tests/unit/init-command.spec.ts | 45 + .../cli/tests/unit/lifecycle-scaffold.spec.ts | 284 ++ packages/cli/tsconfig.json | 17 +- packages/cli/tsconfig.mx-cli-original.json | 54 + pnpm-lock.yaml | 37 +- 105 files changed, 27064 insertions(+), 119 deletions(-) rename {packages/cli => archive/cli-http-prototype-2026-06-08}/CONTRACT.md (100%) create mode 100644 archive/cli-http-prototype-2026-06-08/README.md create mode 100644 archive/cli-http-prototype-2026-06-08/package.json rename {scripts => archive/cli-http-prototype-2026-06-08/scripts}/prove-sdk-cli-uat.mjs (100%) rename {packages/cli => archive/cli-http-prototype-2026-06-08}/src/__tests__/cli.test.ts (100%) rename {packages/cli => archive/cli-http-prototype-2026-06-08}/src/bin/matrix.ts (100%) rename {packages/cli => archive/cli-http-prototype-2026-06-08}/src/cli.ts (100%) create mode 100644 archive/cli-http-prototype-2026-06-08/tsconfig.json create mode 100644 archive/mx-cli-product-command-tests/fp30-auth-session.spec.ts create mode 100644 archive/mx-cli-product-command-tests/hivecast-link-store.spec.ts create mode 100644 archive/mx-cli-product-commands/auth.hivecast-device.ts create mode 100644 archive/mx-cli-product-config-commands/config-command.boundary-explain.spec.ts create mode 100644 archive/mx-cli-product-config-commands/config.boundary-explain.ts create mode 100644 archive/mx-cli-product-docs/README.mx-cli-original.md create mode 100644 archive/mx-cli-provider-overlay-candidates/actor-run.hivecast-api-key-exchange.spec.ts create mode 100644 archive/mx-cli-provider-overlay-candidates/actor-run.hivecast-api-key-exchange.ts create mode 100644 archive/mx-cli-runtime-host-commands/run.ts create mode 100644 archive/mx-cli-runtime-host-commands/runner-control-plane.ts create mode 100644 archive/mx-cli-runtime-host-commands/runner-placement-policy.ts create mode 100644 archive/mx-cli-runtime-host-commands/runner-runtime-control.ts create mode 100644 archive/mx-cli-runtime-host-commands/runner-runtime.ts create mode 100644 archive/mx-cli-runtime-host-commands/service.ts create mode 100644 archive/mx-cli-runtime-host-commands/standalone-webapp-server.ts create mode 100644 archive/mx-cli-runtime-host-commands/workspace-config.ts create mode 100644 archive/mx-cli-runtime-host-tests/actor-command.host-workspace.spec.ts create mode 100644 archive/mx-cli-runtime-host-tests/run-command.spec.ts create mode 100644 archive/mx-cli-runtime-host-tests/runner-control-plane.spec.ts create mode 100644 archive/mx-cli-runtime-host-tests/runner-runtime.spec.ts create mode 100644 archive/mx-cli-runtime-host-tests/runner-transport.spec.ts create mode 100644 archive/mx-cli-runtime-host-tests/service-command.spec.ts create mode 100644 archive/mx-cli-runtime-host-tests/standalone-webapp-server.spec.ts create mode 100644 archive/mx-cli-unused-after-sdk-split/hivecast-link-store.ts create mode 100644 archive/mx-cli-unused-after-sdk-split/package-environment.ts create mode 100644 archive/mx-cli-unused-after-sdk-split/runner-transport.ts create mode 100644 archive/mx-cli-unused-after-sdk-split/webapp-manifest.ts rename {examples => archive/sdk-provider-overlay-candidates}/hivecast-login/.env.example (100%) rename {examples => archive/sdk-provider-overlay-candidates}/hivecast-login/.gitignore (100%) rename {examples => archive/sdk-provider-overlay-candidates}/hivecast-login/README.md (100%) rename {examples => archive/sdk-provider-overlay-candidates}/hivecast-login/run-demo.mjs (100%) create mode 100644 packages/cli/src/commands/actor-element.ts create mode 100644 packages/cli/src/commands/actor-run.ts create mode 100644 packages/cli/src/commands/actor.ts create mode 100644 packages/cli/src/commands/auth.ts create mode 100644 packages/cli/src/commands/config.ts create mode 100644 packages/cli/src/commands/cqrs.ts create mode 100644 packages/cli/src/commands/element.ts create mode 100644 packages/cli/src/commands/fork.ts create mode 100644 packages/cli/src/commands/info.ts create mode 100644 packages/cli/src/commands/init.ts create mode 100644 packages/cli/src/commands/install.ts create mode 100644 packages/cli/src/commands/list.ts create mode 100644 packages/cli/src/commands/outdated.ts create mode 100644 packages/cli/src/commands/pack.ts create mode 100644 packages/cli/src/commands/publish.ts create mode 100644 packages/cli/src/commands/registry.ts create mode 100644 packages/cli/src/commands/search.ts create mode 100644 packages/cli/src/commands/uninstall.ts create mode 100644 packages/cli/src/commands/update-fork.ts create mode 100644 packages/cli/src/commands/validate.ts create mode 100644 packages/cli/src/commands/webapp.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/utils/archive.ts create mode 100644 packages/cli/src/utils/auth-store.ts create mode 100644 packages/cli/src/utils/config-store.ts create mode 100644 packages/cli/src/utils/discovery-extractor.ts create mode 100644 packages/cli/src/utils/discovery-index.ts create mode 100644 packages/cli/src/utils/manifestValidator.ts create mode 100644 packages/cli/src/utils/npm-registry-client.ts create mode 100644 packages/cli/src/utils/package-store.ts create mode 100644 packages/cli/src/utils/registry-store.ts create mode 100644 packages/cli/src/utils/runner-security.ts create mode 100644 packages/cli/src/utils/scaffold.ts create mode 100644 packages/cli/src/utils/service-manifest.ts create mode 100644 packages/cli/src/utils/versioning.ts create mode 100644 packages/cli/tests/helpers/cli-harness.ts create mode 100644 packages/cli/tests/helpers/package-fixtures.ts create mode 100644 packages/cli/tests/integration/fp30-auth-publish.spec.ts create mode 100644 packages/cli/tests/integration/fp30-fork-lifecycle.spec.ts create mode 100644 packages/cli/tests/integration/fp30-install-lifecycle.spec.ts create mode 100644 packages/cli/tests/integration/fp30-install-source-resolution.spec.ts create mode 100644 packages/cli/tests/integration/fp30-install-uninstall-list-info-validate.spec.ts create mode 100644 packages/cli/tests/integration/fp30-outdated-update-fork.spec.ts create mode 100644 packages/cli/tests/integration/fp30-pack-command.spec.ts create mode 100644 packages/cli/tests/integration/fp30-publish-preflight.spec.ts create mode 100644 packages/cli/tests/integration/fp30-registry-command.spec.ts create mode 100644 packages/cli/tests/integration/fp30-scaffold-actor-element.spec.ts create mode 100644 packages/cli/tests/integration/fp30-scaffold-cqrs.spec.ts create mode 100644 packages/cli/tests/integration/fp30-scaffold-element.spec.ts create mode 100644 packages/cli/tests/integration/fp30-search.spec.ts create mode 100644 packages/cli/tests/integration/fp30-update-fork.spec.ts create mode 100644 packages/cli/tests/support/installCoreStub.ts create mode 100644 packages/cli/tests/unit/actor-run.spec.ts create mode 100644 packages/cli/tests/unit/cli-help.spec.ts create mode 100644 packages/cli/tests/unit/config-command.spec.ts create mode 100644 packages/cli/tests/unit/init-command.spec.ts create mode 100644 packages/cli/tests/unit/lifecycle-scaffold.spec.ts create mode 100644 packages/cli/tsconfig.mx-cli-original.json diff --git a/README.md b/README.md index b6273a9..cb609fb 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ packed tarballs. - Package-author CLIs and project scaffolds Matrix SDK is provider-neutral. It does not require a managed platform account, -device-link ceremony, host-link implementation, managed host service, local -platform install, Postgres database, or provider-owned broker authority. A +machine-link ceremony, managed host service, local platform install, Postgres +database, or provider-owned broker authority. A provider overlay can issue broker credentials and host runtimes on top of the SDK, but the SDK itself must remain usable against any compatible broker. @@ -134,27 +134,22 @@ The SDK CLI package owns the package-author `matrix` binary. ```bash matrix init [dir] -matrix add actor matrix actor -matrix configure --key --cloud --space -matrix run --mount -matrix invoke [json] +matrix actor run ./dist/MyActor.js --mount demo.actor --nats-url nats://127.0.0.1:4222 --root demo --check-op ping +matrix package run . --nats-url nats://127.0.0.1:4222 --root demo --check-op ping +matrix config list ``` -The current SDK CLI acceptance proof is: +The current SDK CLI proof is: ```bash -pnpm prove:cli-uat +pnpm prove:cli ``` -The command name uses `uat`, but its scope is only the SDK package-author CLI -flow. It is not managed-platform product UAT, browser signup/login UAT, -API-key UI UAT, or live deployment proof. - The CLI is intended to let a package author initialize a folder, add actors, -configure broker access, run actors, and invoke them without importing provider -overlay packages into the SDK layer. Managed-host promotion is optional and is -only exercised when `MATRIX_SDK_RUNTIME_HOST_BIN` is supplied. +configure broker access, and run/check actors without importing provider +overlay packages into the SDK layer. Managed-host promotion and provider login +belong in overlay packages, not in the SDK CLI core. ## Demos @@ -165,27 +160,14 @@ pnpm demo:standalone ``` This builds the SDK and `examples/standalone-greeter`, starts the actor with -`matrix run`, calls it with `matrix invoke`, and shuts it down. +`matrix package run`, calls it with `--check-op` over a real local NATS broker, +and shuts it down. It does not use a provider account, machine link, managed +host service, or local HTTP control server. -HiveCast API-key login/config demo: - -```bash -export MATRIX_CLOUD=https://dev.hivecast.ai -export MATRIX_SPACE= -export MATRIX_API_KEY= -pnpm demo:hivecast-login -``` - -This validates the key with HiveCast and writes the SDK package-local -`.matrix/config.json` plus `.matrix/credentials/matrix-api-key.json` shape -without printing or committing the key. By default the demo uses a temporary -home and deletes it after success. - -Current boundary: this HiveCast demo proves SDK-side key validation and config -storage. It does not yet prove a package running directly on a HiveCast-issued -broker credential. That requires the provider-owned login/credential-exchange -slice so `matrix login` can replace manual `matrix configure` and issue/store -the broker credentials needed by the SDK transport. +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. ## Repository Layout @@ -201,9 +183,7 @@ packages/ sdk/ scripts/ demo:standalone - demo:hivecast-login prove-external-consumer.mjs - prove-sdk-cli-uat.mjs ``` ## Current Status @@ -216,13 +196,15 @@ Done and proven: - A fresh clone passes `pnpm install --frozen-lockfile && pnpm prove`. - A temporary external consumer can install packed SDK tarballs, compile, start a `MatrixActor`, and call it through Matrix runtime/transport. +- `matrix package run` can mount and check a package against an explicit NATS + broker without provider-specific API key exchange. Still planned: -- Provider-neutral credential resolution for `matrix configure` and SDK users. -- Provider login commands that write the same config shape as - `matrix configure`. -- Direct broker mode with explicit broker URL/JWT/seed credentials. +- A polished provider-neutral `matrix configure` UX for broker URL/root/JWT/seed + files. +- Provider login commands in overlay packages that write the same broker config + shape consumed by SDK direct-run commands. - Managed-provider mode where an overlay issues scoped broker credentials. Publishing is intentionally not part of this repository's normal developer diff --git a/packages/cli/CONTRACT.md b/archive/cli-http-prototype-2026-06-08/CONTRACT.md similarity index 100% rename from packages/cli/CONTRACT.md rename to archive/cli-http-prototype-2026-06-08/CONTRACT.md diff --git a/archive/cli-http-prototype-2026-06-08/README.md b/archive/cli-http-prototype-2026-06-08/README.md new file mode 100644 index 0000000..26301bc --- /dev/null +++ b/archive/cli-http-prototype-2026-06-08/README.md @@ -0,0 +1,23 @@ +# @open-matrix/cli + +Package-author CLI for the Matrix SDK layer. + +This package owns the SDK/package-author `matrix` binary. It does not depend on +provider overlay packages, managed host packages, platform system actors, +device-link implementations, or broker-authority/signing packages. + +Initial command surface: + +```bash +matrix init [dir] +matrix add actor +matrix actor +matrix configure --key --cloud --space +matrix run --mount +matrix invoke [json] +``` + +`matrix run` starts a standalone actor runner in the current folder. +`matrix invoke` sends a Matrix request envelope to that runner through its local +control port. Managed host promotion belongs to provider overlays that consume +the SDK; it is not an SDK-layer requirement. diff --git a/archive/cli-http-prototype-2026-06-08/package.json b/archive/cli-http-prototype-2026-06-08/package.json new file mode 100644 index 0000000..a50444c --- /dev/null +++ b/archive/cli-http-prototype-2026-06-08/package.json @@ -0,0 +1,40 @@ +{ + "name": "@open-matrix/cli", + "version": "0.1.0", + "description": "Package-author Matrix CLI for SDK projects.", + "type": "module", + "private": true, + "bin": { + "matrix": "./dist/bin/matrix.js" + }, + "exports": { + ".": { + "types": "./dist/cli.d.ts", + "import": "./dist/cli.js", + "default": "./dist/cli.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "package.json", + "README.md", + "CONTRACT.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "node --test dist/__tests__/*.test.js", + "type-check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@open-matrix/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.0.10", + "typescript": "^5.7.0" + }, + "license": "MIT", + "engines": { + "node": ">=20" + } +} diff --git a/scripts/prove-sdk-cli-uat.mjs b/archive/cli-http-prototype-2026-06-08/scripts/prove-sdk-cli-uat.mjs similarity index 100% rename from scripts/prove-sdk-cli-uat.mjs rename to archive/cli-http-prototype-2026-06-08/scripts/prove-sdk-cli-uat.mjs diff --git a/packages/cli/src/__tests__/cli.test.ts b/archive/cli-http-prototype-2026-06-08/src/__tests__/cli.test.ts similarity index 100% rename from packages/cli/src/__tests__/cli.test.ts rename to archive/cli-http-prototype-2026-06-08/src/__tests__/cli.test.ts diff --git a/packages/cli/src/bin/matrix.ts b/archive/cli-http-prototype-2026-06-08/src/bin/matrix.ts similarity index 100% rename from packages/cli/src/bin/matrix.ts rename to archive/cli-http-prototype-2026-06-08/src/bin/matrix.ts diff --git a/packages/cli/src/cli.ts b/archive/cli-http-prototype-2026-06-08/src/cli.ts similarity index 100% rename from packages/cli/src/cli.ts rename to archive/cli-http-prototype-2026-06-08/src/cli.ts diff --git a/archive/cli-http-prototype-2026-06-08/tsconfig.json b/archive/cli-http-prototype-2026-06-08/tsconfig.json new file mode 100644 index 0000000..a42aa21 --- /dev/null +++ b/archive/cli-http-prototype-2026-06-08/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "strict": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "noEmit": false, + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/archive/mx-cli-product-command-tests/fp30-auth-session.spec.ts b/archive/mx-cli-product-command-tests/fp30-auth-session.spec.ts new file mode 100644 index 0000000..4d95a25 --- /dev/null +++ b/archive/mx-cli-product-command-tests/fp30-auth-session.spec.ts @@ -0,0 +1,2598 @@ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { connect as natsConnect, type NatsConnection } from 'nats'; +import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; +import { hiveCastLoginCommand, hiveCastLogoutCommand } from '../../src/commands/auth.js'; +import { readHiveCastLinkStatus, removeHiveCastHostLink, saveHiveCastHostLink } from '../../src/utils/hivecast-link-store.js'; + +describe('FP-30 mx auth session commands', () => { + const tempDirs: string[] = []; + const restoreEnvFns: Array<() => void> = []; + const TEST_LOCAL_OWNER_ROOT = 'local-test-owner-root'; + + afterEach(() => { + while (restoreEnvFns.length > 0) { + const restore = restoreEnvFns.pop(); + if (restore) { + restore(); + } + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + function useMatrixHome(matrixHome: string): void { + const previousMatrixHome = process.env.MATRIX_HOME; + const previousHostHome = process.env.MATRIX_HOST_HOME; + const previousCliConfig = process.env.MATRIX_CLI_CONFIG; + const previousPackageRegistry = process.env.MATRIX_PACKAGE_REGISTRY; + const previousRegistryUrl = process.env.MATRIX_REGISTRY_URL; + const previousHivecastRegistry = process.env.HIVECAST_PACKAGE_REGISTRY; + const previousNpmUserConfig = process.env.NPM_CONFIG_USERCONFIG; + const previousNodeAuthToken = process.env.NODE_AUTH_TOKEN; + const previousNpmToken = process.env.NPM_TOKEN; + process.env.MATRIX_HOME = matrixHome; + delete process.env.MATRIX_HOST_HOME; + delete process.env.MATRIX_CLI_CONFIG; + delete process.env.MATRIX_PACKAGE_REGISTRY; + delete process.env.MATRIX_REGISTRY_URL; + delete process.env.HIVECAST_PACKAGE_REGISTRY; + delete process.env.NPM_CONFIG_USERCONFIG; + delete process.env.NODE_AUTH_TOKEN; + delete process.env.NPM_TOKEN; + restoreEnvFns.push(() => { + restoreEnv('MATRIX_HOME', previousMatrixHome); + restoreEnv('MATRIX_HOST_HOME', previousHostHome); + restoreEnv('MATRIX_CLI_CONFIG', previousCliConfig); + restoreEnv('MATRIX_PACKAGE_REGISTRY', previousPackageRegistry); + restoreEnv('MATRIX_REGISTRY_URL', previousRegistryUrl); + restoreEnv('HIVECAST_PACKAGE_REGISTRY', previousHivecastRegistry); + restoreEnv('NPM_CONFIG_USERCONFIG', previousNpmUserConfig); + restoreEnv('NODE_AUTH_TOKEN', previousNodeAuthToken); + restoreEnv('NPM_TOKEN', previousNpmToken); + }); + } + + function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; + } + + async function readRequestBody(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); + } + + function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; + } + + function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; + } + + function installToken(installId: string): string { + return installId + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + + function installRootToken(installId: string): string { + return installId + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + + function localRootForInstallId(installId: string): string { + return `local-${installRootToken(installId)}`; + } + + function installScopeForInstallId(installId: string): string { + return installToken(installId); + } + + function writeInstallIdentity(matrixHome: string, installId: string): void { + const credentialsDir = path.join(matrixHome, 'credentials'); + fs.mkdirSync(credentialsDir, { recursive: true }); + fs.writeFileSync(path.join(credentialsDir, 'hivecast-install.json'), `${JSON.stringify({ + version: 1, + installId, + createdAt: '2026-05-27T00:00:00.000Z', + updatedAt: '2026-05-27T00:00:00.000Z', + }, null, 2)}\n`, 'utf8'); + } + + function readInstallIdentityId(matrixHome: string): string { + const identity = readJsonRecord(path.join(matrixHome, 'credentials', 'hivecast-install.json')); + const installId = optionalString(identity.installId); + assert.ok(installId); + return installId; + } + + async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate TCP port'))); + return; + } + const port = address.port; + server.close((err) => (err ? reject(err) : resolve(port))); + }); + }); + } + + async function waitForTcpPort(port: number): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const open = await new Promise((resolve) => { + const socket = net.connect({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => { + socket.destroy(); + resolve(false); + }); + socket.setTimeout(500, () => { + socket.destroy(); + resolve(false); + }); + }); + if (open) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`Timed out waiting for nats-server on port ${port}`); + } + + function resolveNatsServerBinary(): string { + const binaryName = process.platform === 'win32' ? 'nats-server.exe' : 'nats-server'; + const candidates = [ + process.env.NATS_SERVER_BINARY, + path.resolve(process.cwd(), 'projects', 'nats-server', binaryName), + path.resolve(process.cwd(), '..', '..', '..', 'nats-server', binaryName), + path.resolve(process.cwd(), '..', 'hivecast', 'dist', 'bin', binaryName), + ...((process.env.PATH ?? '').split(path.delimiter).map((entry) => path.join(entry, binaryName))), + ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); + const found = candidates.find((candidate) => fs.existsSync(candidate)); + if (!found) { + throw new Error('nats-server binary is required for HiveCast running-Host reload proof'); + } + return found; + } + + async function stopProcess(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null) { + return; + } + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (child.exitCode === null) { + child.kill('SIGKILL'); + } + }, 2_000); + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + child.kill('SIGTERM'); + }); + } + + async function withNatsServer(run: (natsUrl: string) => Promise): Promise { + const port = await getAvailablePort(); + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-nats-')); + tempDirs.push(dataDir); + const configPath = path.join(dataDir, 'nats-server.conf'); + fs.writeFileSync(configPath, [ + 'host: "127.0.0.1"', + `port: ${port}`, + '', + ].join('\n'), 'utf8'); + const child = spawn(resolveNatsServerBinary(), ['-c', configPath], { + stdio: 'pipe', + env: process.env, + }); + let stderr = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + try { + await Promise.race([ + waitForTcpPort(port), + new Promise((_resolve, reject) => { + child.once('exit', (code) => { + reject(new Error(`nats-server exited early (${code}): ${stderr}`)); + }); + }), + ]); + return await run(`nats://127.0.0.1:${port}`); + } finally { + await stopProcess(child); + } + } + + function writeHostStatusFromConfig(matrixHome: string, natsUrl: string, previousPid = process.pid): void { + const hostConfig = asRecord(JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8'))); + assert.ok(hostConfig); + const transport = asRecord(hostConfig.transport); + assert.ok(transport); + const nats = asRecord(transport.nats); + const root = optionalString(transport.root); + assert.ok(root); + const authorityRoot = optionalString(transport.authorityRoot) ?? root; + const addressRoot = optionalString(transport.addressRoot) ?? authorityRoot; + const wsUrl = optionalString(nats?.wsUrl); + fs.writeFileSync(path.join(matrixHome, 'host.status.json'), `${JSON.stringify({ + kind: 'MatrixHostStatus', + version: 1, + status: 'running', + root, + authorityRoot, + addressRoot, + pid: previousPid, + home: matrixHome, + supervisorMount: 'host.supervisor.PID-TEST', + startedAt: '2026-04-28T00:00:00.000Z', + updatedAt: new Date().toISOString(), + http: { + host: '127.0.0.1', + port: 3100, + origin: 'http://127.0.0.1:3100', + }, + transport: { + root, + authorityRoot, + addressRoot, + ...(optionalString(transport.spaceId) ? { spaceId: optionalString(transport.spaceId) } : {}), + ...(optionalString(transport.routeKey) ? { routeKey: optionalString(transport.routeKey) } : {}), + ...(optionalString(transport.publicNamespace) + ? { publicNamespace: optionalString(transport.publicNamespace) } + : {}), + nats: { + mode: 'external', + url: natsUrl, + ...(wsUrl ? { wsUrl } : {}), + }, + }, + }, null, 2)}\n`, 'utf8'); + } + + function testWsUrlForNatsUrl(natsUrl: string): string { + const parsed = new URL(natsUrl); + const port = Number.parseInt(parsed.port, 10); + assert.equal(Number.isInteger(port), true); + return `ws://127.0.0.1:${port + 1}`; + } + + function writeLocalOwnerHostStatus( + matrixHome: string, + natsUrl: string, + root = TEST_LOCAL_OWNER_ROOT, + ): void { + fs.mkdirSync(matrixHome, { recursive: true }); + const wsUrl = testWsUrlForNatsUrl(natsUrl); + fs.writeFileSync(path.join(matrixHome, 'host.status.json'), `${JSON.stringify({ + kind: 'MatrixHostStatus', + version: 1, + status: 'running', + root, + authorityRoot: root, + addressRoot: root, + pid: process.pid, + home: matrixHome, + supervisorMount: 'host.supervisor.PID-TEST', + startedAt: '2026-04-28T00:00:00.000Z', + updatedAt: new Date().toISOString(), + http: { + host: '127.0.0.1', + port: 3100, + origin: 'http://127.0.0.1:3100', + }, + transport: { + root, + authorityRoot: root, + nats: { + mode: 'external', + url: natsUrl, + wsUrl, + }, + }, + }, null, 2)}\n`, 'utf8'); + } + + function readJsonRecord(filePath: string): Record { + const parsed = asRecord(JSON.parse(fs.readFileSync(filePath, 'utf8'))); + assert.ok(parsed, `Expected JSON object at ${filePath}`); + return parsed; + } + + function assertHostConfigRoot( + matrixHome: string, + expectedRoot: string, + expected: { + readonly routeKey?: string; + readonly publicNamespace?: string; + readonly spaceId?: string; + } = {}, + ): void { + const hostConfig = readJsonRecord(path.join(matrixHome, 'host.json')); + const transport = asRecord(hostConfig.transport); + assert.ok(transport); + const root = optionalString(transport.root); + const authorityRoot = optionalString(transport.authorityRoot) ?? root; + const addressRoot = optionalString(transport.addressRoot) ?? authorityRoot; + assert.equal(root, expectedRoot); + assert.equal(authorityRoot, expectedRoot); + assert.equal(addressRoot, expectedRoot); + assert.equal(transport.routeKey, expected.routeKey); + assert.equal(transport.publicNamespace, expected.publicNamespace); + assert.equal(transport.spaceId, expected.spaceId); + } + + function assertHostStatusRoot( + matrixHome: string, + expectedRoot: string, + expected: { + readonly routeKey?: string; + readonly publicNamespace?: string; + readonly spaceId?: string; + } = {}, + ): void { + const status = readJsonRecord(path.join(matrixHome, 'host.status.json')); + const transport = asRecord(status.transport); + assert.ok(transport); + assert.equal(status.root, expectedRoot); + assert.equal(status.authorityRoot, expectedRoot); + assert.equal(status.addressRoot, expectedRoot); + assert.equal(transport.root, expectedRoot); + assert.equal(transport.authorityRoot, expectedRoot); + assert.equal(transport.addressRoot, expectedRoot); + assert.equal(transport.routeKey, expected.routeKey); + assert.equal(transport.publicNamespace, expected.publicNamespace); + assert.equal(transport.spaceId, expected.spaceId); + } + + async function withReloadSupervisor( + natsUrl: string, + matrixHome: string, + run: (reloadCount: () => number) => Promise, + onReload?: (reloadCount: number) => void, + ): Promise { + const nc: NatsConnection = await natsConnect({ servers: [natsUrl], name: 'mx-cli-reload-proof' }); + const sub = nc.subscribe('>'); + let reloads = 0; + const loop = (async () => { + for await (const msg of sub) { + const decoded = asRecord(JSON.parse(new TextDecoder().decode(msg.data) || '{}')); + if (decoded?.op !== 'config.reload') { + continue; + } + reloads += 1; + onReload?.(reloads); + writeHostStatusFromConfig(matrixHome, natsUrl); + const replyTo = optionalString(decoded.replyTo) ?? msg.reply; + if (replyTo) { + nc.publish(replyTo, new TextEncoder().encode(JSON.stringify({ + ok: true, + correlationId: optionalString(decoded.correlationId), + result: { ok: true, status: 'reloading' }, + }))); + } + } + })(); + try { + return await run(() => reloads); + } finally { + sub.unsubscribe(); + await nc.drain(); + await loop.catch(() => undefined); + } + } + + async function withTokenExchangeServer( + handler: (body: Record) => Record, + run: (cloudUrl: string) => Promise, + ): Promise { + const server = http.createServer(async (req, res) => { + if (req.method !== 'POST' || req.url !== '/api/token-exchange') { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'not found' })); + return; + } + const body = JSON.parse(await readRequestBody(req)) as Record; + const payload = handler(body); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(payload)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert(address && typeof address === 'object'); + try { + return await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + } + } + + async function withPairFlowServer( + config: { + readonly pairRequestId: string; + readonly approvalCode: string; + readonly exchange: Record; + readonly expectedRouteKey?: string; + readonly autoApprove?: boolean; + }, + run: ( + cloudUrl: string, + controls: { + readonly getLastLocalReturnUrl: () => string | null; + readonly getStartBody: () => Record | null; + }, + ) => Promise, + ): Promise { + let origin = ''; + let lastLocalReturnUrl: string | null = null; + let startBody: Record | null = null; + const server = http.createServer(async (req, res) => { + if (req.method === 'POST' && req.url === '/_auth/pair/start') { + const body = JSON.parse(await readRequestBody(req) || '{}') as Record; + startBody = body; + if (config.expectedRouteKey !== undefined) { + assert.equal(body.routeKey, config.expectedRouteKey); + } + if (typeof body.localReturnUrl === 'string') { + lastLocalReturnUrl = body.localReturnUrl; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + ok: true, + pairRequestId: config.pairRequestId, + approvalUrl: `${origin}/_auth/pair/${config.pairRequestId}`, + expiresIn: 600, + })); + if (config.autoApprove && lastLocalReturnUrl) { + const nonce = typeof body.nonce === 'string' ? body.nonce : ''; + setImmediate(() => { + void fetch(`${lastLocalReturnUrl}?pairRequestId=${config.pairRequestId}&approvalCode=${config.approvalCode}&nonce=${encodeURIComponent(nonce)}`); + }); + } + return; + } + if (req.method === 'POST' && req.url === '/_auth/pair/exchange') { + const body = JSON.parse(await readRequestBody(req)) as Record; + assert.equal(body.pairRequestId, config.pairRequestId); + assert.equal(body.approvalCode, config.approvalCode); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + ok: true, + ...config.exchange, + })); + return; + } + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'not found' })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert(address && typeof address === 'object'); + origin = `http://127.0.0.1:${address.port}`; + try { + return await run(origin, { + getLastLocalReturnUrl: () => lastLocalReturnUrl, + getStartBody: () => startBody, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + } + } + + async function withDeviceFlowServer( + run: (cloudUrl: string) => Promise, + config: { readonly authorityCoordinator?: boolean } = {}, + ): Promise { + const server = http.createServer(async (req, res) => { + if (req.method === 'POST' && req.url === '/_auth/device/start') { + const body = JSON.parse(await readRequestBody(req) || '{}') as Record; + assert.equal(body.routeKey, 'hivecast-lab'); + assert.equal(body.hostName, 'test-host'); + assert.equal(body.authorityCoordinator, false); + assert.equal(Object.prototype.hasOwnProperty.call(body, 'name'), false); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + ok: true, + deviceCode: 'DEVICE-123', + userCode: 'K7P9-TQ2M', + verificationUri: 'https://hivecast.example.test/activate', + verificationUriComplete: 'https://hivecast.example.test/activate?user_code=K7P9-TQ2M', + expiresIn: 600, + interval: 1, + })); + return; + } + if (req.method === 'POST' && req.url === '/_auth/device/poll') { + const body = JSON.parse(await readRequestBody(req)) as Record; + assert.equal(body.deviceCode, 'DEVICE-123'); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, status: 'approved', interval: 1 })); + return; + } + if (req.method === 'POST' && req.url === '/_auth/device/exchange') { + const body = JSON.parse(await readRequestBody(req)) as Record; + assert.equal(body.deviceCode, 'DEVICE-123'); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + ok: true, + root: 'SPACE-HIVECAST-LAB', + routeKey: 'hivecast-lab', + publicNamespace: 'space.hivecast-lab', + spaceId: 'space_01DEVICE', + principalId: 'p_device', + hostName: 'test-host', + deviceSlug: 'test-host', + hostLink: { + id: 'host-link-device-01', + hostId: 'DEVICE-123', + hostName: 'test-host', + deviceSlug: 'test-host', + ...(typeof config.authorityCoordinator === 'boolean' ? { authorityCoordinator: config.authorityCoordinator } : {}), + heartbeatToken: 'hlink_test_device_token', + status: 'active', + }, + ...(typeof config.authorityCoordinator === 'boolean' ? { authorityCoordinator: config.authorityCoordinator } : {}), + nats: { + url: 'nats://hivecast.example.test:4222', + wsUrl: 'wss://hivecast.example.test/nats-ws', + }, + credentials: { + jwt: 'device.jwt.token', + seed: 'SUDEVICESEED', + }, + })); + return; + } + if (req.method === 'POST' && req.url === '/_auth/host-link/revoke') { + assert.equal(req.headers.authorization, 'Bearer hlink_test_device_token'); + const body = JSON.parse(await readRequestBody(req)) as Record; + assert.equal(body.hostLinkId, 'host-link-device-01'); + assert.equal(body.hostId, 'DEVICE-123'); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + ok: true, + hostLink: { + id: 'host-link-device-01', + hostId: 'DEVICE-123', + status: 'revoked', + }, + })); + return; + } + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'not found' })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert(address && typeof address === 'object'); + try { + return await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + } + } + + async function withDeviceNamespaceRequiredServer( + run: (cloudUrl: string) => Promise, + ): Promise { + const server = http.createServer(async (req, res) => { + if (req.method === 'POST' && req.url === '/_auth/device/start') { + const body = JSON.parse(await readRequestBody(req) || '{}') as Record; + assert.equal(body.routeKey, 'owned-key'); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + ok: true, + deviceCode: 'DEVICE-OWNED', + userCode: 'K7P9-OWND', + verificationUri: 'https://hivecast.example.test/activate', + verificationUriComplete: 'https://hivecast.example.test/activate?user_code=K7P9-OWND', + expiresIn: 600, + interval: 1, + })); + return; + } + if (req.method === 'POST' && req.url === '/_auth/device/poll') { + const body = JSON.parse(await readRequestBody(req)) as Record; + assert.equal(body.deviceCode, 'DEVICE-OWNED'); + res.writeHead(409, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + ok: false, + error: 'namespace_required', + status: 'namespace_required', + setupUrl: '/apps/web/#setup?redirect=%2Factivate%3Fuser_code%3DK7P9-OWND', + })); + return; + } + if (req.method === 'POST' && req.url === '/_auth/device/exchange') { + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'exchange should not be reached' })); + return; + } + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'not found' })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert(address && typeof address === 'object'); + try { + return await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + } + } + + it('supports login, whoami, and logout lifecycle', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-auth-')); + tempDirs.push(root); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-123', + '--registry', + 'https://pkg.matrix.dev', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: root }); + assertExitCode(loginResult, 0); + + const whoamiAfterLogin = await runMxCli([ + 'node', + 'mx', + 'whoami', + '--registry', + 'https://pkg.matrix.dev', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: root }); + assertExitCode(whoamiAfterLogin, 0); + const whoamiPayload = parseLastJson(whoamiAfterLogin.logs) as { authenticated: boolean; username?: string }; + assert.equal(whoamiPayload.authenticated, true); + assert.equal(whoamiPayload.username, 'alice'); + + const logoutResult = await runMxCli([ + 'node', + 'mx', + 'logout', + '--registry', + 'https://pkg.matrix.dev', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: root }); + assertExitCode(logoutResult, 0); + + const whoamiAfterLogout = await runMxCli([ + 'node', + 'mx', + 'whoami', + '--registry', + 'https://pkg.matrix.dev', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: root }); + assertExitCode(whoamiAfterLogout, 0); + const afterLogoutPayload = parseLastJson(whoamiAfterLogout.logs) as { authenticated: boolean }; + assert.equal(afterLogoutPayload.authenticated, false); + }); + + it('reads npm auth token from configured .npmrc for whoami', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-npmrc-auth-')); + tempDirs.push(root); + useMatrixHome(path.join(root, '.matrix')); + const registry = 'https://registry.example.test/api/packages/open-matrix/npm/'; + const npmrcPath = path.join(root, '.npmrc'); + fs.writeFileSync( + npmrcPath, + '//registry.example.test/api/packages/open-matrix/npm/:_authToken=test-token-from-npmrc\n', + 'utf8', + ); + process.env.NPM_CONFIG_USERCONFIG = npmrcPath; + + const whoamiResult = await runMxCli([ + 'node', + 'mx', + 'whoami', + '--registry', + registry, + '--json', + ], { cwd: root }); + assertExitCode(whoamiResult, 0); + const payload = parseLastJson(whoamiResult.logs) as { + authenticated: boolean; + username?: string; + registry: string; + }; + assert.equal(payload.authenticated, true); + assert.equal(payload.username, 'npm-token'); + assert.equal(payload.registry, registry); + }); + + it('does not read home .npmrc when NPM_CONFIG_USERCONFIG points at an explicit file', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-explicit-npmrc-auth-')); + tempDirs.push(root); + const homeDir = path.join(root, 'home'); + fs.mkdirSync(homeDir, { recursive: true }); + useMatrixHome(path.join(root, '.matrix')); + const previousHome = process.env.HOME; + const previousUserConfig = process.env.NPM_CONFIG_USERCONFIG; + restoreEnvFns.push(() => { + restoreEnv('HOME', previousHome); + restoreEnv('NPM_CONFIG_USERCONFIG', previousUserConfig); + }); + process.env.HOME = homeDir; + const registry = 'https://registry.example.test/api/packages/open-matrix/npm/'; + fs.writeFileSync( + path.join(homeDir, '.npmrc'), + '//registry.example.test/api/packages/open-matrix/npm/:_authToken=poisoned-home-token\n', + 'utf8', + ); + const explicitNpmrcPath = path.join(root, 'empty.npmrc'); + fs.writeFileSync(explicitNpmrcPath, '', 'utf8'); + process.env.NPM_CONFIG_USERCONFIG = explicitNpmrcPath; + + const whoamiResult = await runMxCli([ + 'node', + 'mx', + 'whoami', + '--registry', + registry, + '--json', + ], { cwd: root }); + assertExitCode(whoamiResult, 0); + const payload = parseLastJson(whoamiResult.logs) as { + authenticated: boolean; + username?: string; + registry: string; + }; + assert.equal(payload.authenticated, false); + assert.equal(payload.username, undefined); + assert.equal(payload.registry, registry); + }); + + it('uses Matrix CLI config for the default registry', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-config-')); + tempDirs.push(root); + useMatrixHome(path.join(root, '.matrix')); + const configuredRegistry = 'http://127.0.0.1:4567/api/packages/open-matrix/npm/'; + + const defaultConfig = await runMxCli([ + 'node', + 'mx', + 'config', + 'get', + 'registry', + '--json', + ], { cwd: root }); + assertExitCode(defaultConfig, 0); + const defaultPayload = parseLastJson(defaultConfig.logs) as { value: string; source: string }; + assert.equal(defaultPayload.value, 'https://registry.hivecast.ai/api/packages/open-matrix/npm/'); + assert.equal(defaultPayload.source, 'default'); + + const setConfig = await runMxCli([ + 'node', + 'mx', + 'config', + 'set', + 'registry', + configuredRegistry, + '--json', + ], { cwd: root }); + assertExitCode(setConfig, 0); + + const listConfig = await runMxCli([ + 'node', + 'mx', + 'config', + 'list', + '--json', + ], { cwd: root }); + assertExitCode(listConfig, 0); + const listPayload = parseLastJson(listConfig.logs) as { + values: { registry?: string }; + effective: { registry: string; registrySource: string }; + }; + assert.equal(listPayload.values.registry, configuredRegistry); + assert.equal(listPayload.effective.registry, configuredRegistry); + assert.equal(listPayload.effective.registrySource, 'config'); + + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-123', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: root }); + assertExitCode(loginResult, 0); + const loginPayload = parseLastJson(loginResult.logs) as { registry: string }; + assert.equal(loginPayload.registry, configuredRegistry); + + const whoamiResult = await runMxCli([ + 'node', + 'mx', + 'whoami', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: root }); + assertExitCode(whoamiResult, 0); + const whoamiPayload = parseLastJson(whoamiResult.logs) as { authenticated: boolean; registry: string }; + assert.equal(whoamiPayload.authenticated, true); + assert.equal(whoamiPayload.registry, configuredRegistry); + + const resetConfig = await runMxCli([ + 'node', + 'mx', + 'config', + 'reset', + '--json', + ], { cwd: root }); + assertExitCode(resetConfig, 0); + + const afterReset = await runMxCli([ + 'node', + 'mx', + 'config', + 'get', + 'registry', + '--json', + ], { cwd: root }); + assertExitCode(afterReset, 0); + const resetPayload = parseLastJson(afterReset.logs) as { value: string; source: string }; + assert.equal(resetPayload.value, 'https://registry.hivecast.ai/api/packages/open-matrix/npm/'); + assert.equal(resetPayload.source, 'default'); + }); + + it('prints a HiveCast pair approval URL before approval is available', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-url-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + + await withPairFlowServer( + { + pairRequestId: 'PAIR-URL-123', + approvalCode: 'PAIR-URL-APPROVAL', + expectedRouteKey: 'hivecast-lab', + exchange: { + root: 'SPACE-HIVECAST-LAB', + routeKey: 'hivecast-lab', + publicNamespace: 'space.hivecast-lab', + nats: { + url: 'wss://hivecast.example.test/nats-ws', + }, + credentials: { + jwt: 'unused.jwt.token', + seed: 'SUUNUSED', + }, + }, + }, + async (cloudUrl, controls) => { + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--hivecast', + '--cloud', + cloudUrl, + '--route-key', + 'hivecast-lab', + '--no-wait', + '--home', + matrixHome, + '--json', + ], { cwd: root }); + assertExitCode(loginResult, 0); + const payload = parseLastJson(loginResult.logs) as { + mode: string; + linked: boolean; + setupUrl: string; + }; + assert.equal(payload.mode, 'approval-required'); + assert.equal(payload.linked, false); + const setupUrl = new URL(payload.setupUrl); + assert.equal(setupUrl.origin, cloudUrl); + assert.equal(setupUrl.pathname, '/_auth/pair/PAIR-URL-123'); + const startBody = controls.getStartBody(); + assert.ok(startBody); + assert.equal(startBody.routeKey, 'hivecast-lab'); + assert.equal(startBody.authorityCoordinator, true); + assert.equal(controls.getLastLocalReturnUrl(), null); + }, + ); + }); + + it('completes HiveCast setup exchange and stores local Host link state', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-link-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + fs.mkdirSync(matrixHome, { recursive: true }); + fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({ + kind: 'MatrixHostConfig', + version: 1, + home: matrixHome, + transport: { + root: TEST_LOCAL_OWNER_ROOT, + nats: { + mode: 'embedded', + port: 45222, + wsPort: 45223, + dataDir: 'nats/test-local', + pidFile: 'nats/test-local/nats-server.pid', + binaryPath: 'bin/nats-server', + }, + }, + }, null, 2)}\n`); + await withTokenExchangeServer( + (body) => { + assert.equal(body.code, 'SETUP-123'); + return { + ok: true, + root: 'SPACE-HIVECAST-LAB', + routeKey: 'hivecast-lab', + publicNamespace: 'space.hivecast-lab', + spaceId: 'space_01TEST', + principalId: 'p_test', + displayName: 'HiveCast Lab', + nats: { + url: 'wss://hivecast.example.test/nats-ws', + wsUrl: 'wss://hivecast.example.test/nats-ws', + }, + credentials: { + jwt: 'setup.jwt.token', + seed: 'SUFAKESEED', + }, + }; + }, + async (cloudUrl) => { + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--hivecast', + '--cloud', + cloudUrl, + '--setup-code', + 'SETUP-123', + '--home', + matrixHome, + '--json', + ], { cwd: root }); + assertExitCode(loginResult, 0); + const loginPayload = parseLastJson(loginResult.logs) as { + linked: boolean; + authorityRoot: string; + routeKey: string; + matrixHome: string; + }; + assert.equal(loginPayload.linked, true); + assert.equal(loginPayload.authorityRoot, 'SPACE-HIVECAST-LAB'); + assert.equal(loginPayload.routeKey, 'hivecast-lab'); + assert.equal(loginPayload.matrixHome, matrixHome); + + const linkPath = path.join(matrixHome, 'credentials', 'hivecast-link.json'); + const natsPath = path.join(matrixHome, 'credentials', 'hivecast-nats.json'); + const hostConfigPath = path.join(matrixHome, 'host.json'); + assert.equal(fs.existsSync(linkPath), true); + assert.equal(fs.existsSync(natsPath), true); + assert.equal(fs.existsSync(hostConfigPath), true); + const natsCredentials = JSON.parse(fs.readFileSync(natsPath, 'utf8')) as { + jwt?: string; + seed?: string; + accountSeed?: string; + }; + assert.equal(natsCredentials.jwt, 'setup.jwt.token'); + assert.equal(natsCredentials.seed, 'SUFAKESEED'); + assert.equal(Object.prototype.hasOwnProperty.call(natsCredentials, 'accountSeed'), false); + + const hostConfig = JSON.parse(fs.readFileSync(hostConfigPath, 'utf8')) as { + transport: { + root: string; + authorityRoot: string; + nats: { + mode: string; + url: string; + credentialsRef?: string; + }; + }; + hivecastLocalOwnerTransport?: { + root?: string; + nats?: { + mode?: string; + port?: number; + wsPort?: number; + }; + }; + }; + assert.equal(hostConfig.transport.root, TEST_LOCAL_OWNER_ROOT); + assert.equal(hostConfig.transport.authorityRoot, TEST_LOCAL_OWNER_ROOT); + assert.equal(hostConfig.transport.nats.mode, 'embedded'); + assert.equal(hostConfig.transport.nats.credentialsRef, undefined); + assert.equal(hostConfig.hivecastLocalOwnerTransport?.root, TEST_LOCAL_OWNER_ROOT); + assert.equal(hostConfig.hivecastLocalOwnerTransport?.nats?.mode, 'embedded'); + assert.equal(hostConfig.hivecastLocalOwnerTransport?.nats?.port, 45222); + assert.equal(hostConfig.hivecastLocalOwnerTransport?.nats?.wsPort, 45223); + + const whoamiResult = await runMxCli([ + 'node', + 'mx', + 'whoami', + '--hivecast', + '--home', + matrixHome, + '--json', + ], { cwd: root }); + assertExitCode(whoamiResult, 0); + const whoamiPayload = parseLastJson(whoamiResult.logs) as { + linked: boolean; + authorityRoot: string; + routeKey: string; + publicNamespace: string; + principalId: string; + }; + assert.equal(whoamiPayload.linked, true); + assert.equal(whoamiPayload.authorityRoot, 'SPACE-HIVECAST-LAB'); + assert.equal(whoamiPayload.routeKey, 'hivecast-lab'); + assert.equal(whoamiPayload.publicNamespace, 'space.hivecast-lab'); + assert.equal(whoamiPayload.principalId, 'p_test'); + + const statusResult = await runMxCli([ + 'node', + 'mx', + 'link-status', + '--home', + matrixHome, + '--json', + ], { cwd: root }); + assertExitCode(statusResult, 0); + const statusPayload = parseLastJson(statusResult.logs) as { + linked: boolean; + authorityRoot: string; + spaceId: string; + natsCredentialsPresent: boolean; + hostConfigPresent: boolean; + }; + assert.equal(statusPayload.linked, true); + assert.equal(statusPayload.authorityRoot, 'SPACE-HIVECAST-LAB'); + assert.equal(statusPayload.spaceId, 'space_01TEST'); + assert.equal(statusPayload.natsCredentialsPresent, true); + assert.equal(statusPayload.hostConfigPresent, true); + + const logoutResult = await runMxCli([ + 'node', + 'mx', + 'logout', + '--hivecast', + '--home', + matrixHome, + '--json', + ], { cwd: root }); + assertExitCode(logoutResult, 0); + const logoutPayload = parseLastJson(logoutResult.logs) as { removed: boolean; hostConfigReset: boolean }; + assert.equal(logoutPayload.removed, true); + assert.equal(logoutPayload.hostConfigReset, true); + assert.equal(fs.existsSync(linkPath), false); + assert.equal(fs.existsSync(natsPath), false); + assert.equal(fs.existsSync(path.join(matrixHome, 'credentials.json')), false); + const localOnlyHostConfig = JSON.parse(fs.readFileSync(hostConfigPath, 'utf8')) as { + transport: { + root: string; + authorityRoot?: string; + routeKey?: string; + publicNamespace?: string; + spaceId?: string; + nats: { + mode: string; + port?: number; + wsPort?: number; + dataDir?: string; + credentialsRef?: string; + }; + }; + }; + assert.equal(localOnlyHostConfig.transport.root, TEST_LOCAL_OWNER_ROOT); + assert.equal(localOnlyHostConfig.transport.authorityRoot, undefined); + assert.equal(localOnlyHostConfig.transport.routeKey, undefined); + assert.equal(localOnlyHostConfig.transport.publicNamespace, undefined); + assert.equal(localOnlyHostConfig.transport.spaceId, undefined); + assert.equal(localOnlyHostConfig.transport.nats.mode, 'embedded'); + assert.equal(localOnlyHostConfig.transport.nats.port, 45222); + assert.equal(localOnlyHostConfig.transport.nats.wsPort, 45223); + assert.equal(localOnlyHostConfig.transport.nats.dataDir, 'nats/test-local'); + assert.equal(localOnlyHostConfig.transport.nats.credentialsRef, undefined); + + const afterLogoutStatusResult = await runMxCli([ + 'node', + 'mx', + 'link-status', + '--home', + matrixHome, + '--json', + ], { cwd: root }); + assertExitCode(afterLogoutStatusResult, 0); + const afterLogoutStatus = parseLastJson(afterLogoutStatusResult.logs) as { + linked: boolean; + natsCredentialsPresent: boolean; + hostConfigLinked: boolean; + }; + assert.equal(afterLogoutStatus.linked, false); + assert.equal(afterLogoutStatus.natsCredentialsPresent, false); + assert.equal(afterLogoutStatus.hostConfigLinked, false); + }, + ); + }); + + it('preserves the running local owner NATS ports when linking from a started Host', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-running-ports-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + fs.mkdirSync(matrixHome, { recursive: true }); + const installId = 'install_running_ports'; + writeInstallIdentity(matrixHome, installId); + fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({ + kind: 'MatrixHostConfig', + version: 1, + home: matrixHome, + transport: { + root: TEST_LOCAL_OWNER_ROOT, + nats: { + mode: 'embedded', + port: 4222, + wsPort: 4223, + dataDir: 'nats/host-default', + pidFile: 'nats/host-default/nats-server.pid', + binaryPath: 'bin/nats-server', + }, + }, + }, null, 2)}\n`); + fs.writeFileSync(path.join(matrixHome, 'host.status.json'), `${JSON.stringify({ + kind: 'MatrixHostStatus', + version: 1, + status: 'running', + pid: process.pid, + home: matrixHome, + supervisorMount: `host.supervisor.PID-${process.pid}`, + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + http: { + host: '127.0.0.1', + port: 45100, + origin: 'http://127.0.0.1:45100', + }, + transport: { + root: TEST_LOCAL_OWNER_ROOT, + authorityRoot: TEST_LOCAL_OWNER_ROOT, + nats: { + mode: 'external', + url: 'nats://127.0.0.1:45222', + wsUrl: 'ws://127.0.0.1:45223', + }, + }, + }, null, 2)}\n`); + const localScope = installScopeForInstallId(installId); + const localEdgeRuntimeId = `RUNTIME-HOST-${localScope}-EDGE`; + const staleLinkedRuntimeId = 'RUNTIME-HOST-STALE-LINKED-EDGE'; + const staleLinkedChatRuntimeId = 'RUNTIME-HOST-STALE-LINKED-CHAT'; + for (const runtimeId of [localEdgeRuntimeId, staleLinkedRuntimeId, staleLinkedChatRuntimeId]) { + const recordDir = path.join(matrixHome, 'runtimes', runtimeId); + fs.mkdirSync(recordDir, { recursive: true }); + fs.writeFileSync(path.join(recordDir, 'runtime.json'), `${JSON.stringify({ + runtimeId, + packageDir: path.join(matrixHome, 'packages', 'global', 'node_modules', '@open-matrix', 'matrix-edge'), + status: 'stopped', + startup: 'auto', + restart: 'always', + startedAt: new Date().toISOString(), + stoppedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + localMounts: ['edge'], + metadata: {}, + }, null, 2)}\n`); + } + + saveHiveCastHostLink({ + cwd: root, + matrixHome, + cloudUrl: 'https://hivecast.example.test', + hostId: 'host_running_ports', + authorityRoot: 'SPACE-HIVECAST-LAB', + routeKey: 'hivecast-lab', + publicNamespace: 'space.hivecast-lab', + spaceId: 'space_01RUNNINGPORTS', + nats: { url: 'wss://hivecast.example.test/nats-ws' }, + natsCredentials: { accountSeed: 'SARUNNINGPORTS' }, + }); + const linkedConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as { + transport?: { + root?: string; + authorityRoot?: string; + routeKey?: string; + publicNamespace?: string; + spaceId?: string; + nats?: { + credentialsRef?: string; + }; + }; + hivecastLocalOwnerTransport?: { + nats?: { + mode?: string; + url?: string; + wsUrl?: string; + port?: number; + wsPort?: number; + }; + }; + }; + assert.equal(linkedConfig.transport?.root, TEST_LOCAL_OWNER_ROOT); + assert.equal(linkedConfig.transport?.authorityRoot, TEST_LOCAL_OWNER_ROOT); + assert.equal(linkedConfig.transport?.routeKey, undefined); + assert.equal(linkedConfig.transport?.publicNamespace, undefined); + assert.equal(linkedConfig.transport?.spaceId, undefined); + assert.equal(linkedConfig.transport?.nats?.credentialsRef, undefined); + assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.mode, 'external'); + assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.url, 'nats://127.0.0.1:45222'); + assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.wsUrl, 'ws://127.0.0.1:45223'); + assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.port, 45222); + assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.wsPort, 45223); + const localEdgeAfterLink = JSON.parse(fs.readFileSync( + path.join(matrixHome, 'runtimes', localEdgeRuntimeId, 'runtime.json'), + 'utf8', + )) as { startup?: string; restart?: string }; + assert.equal(localEdgeAfterLink.startup, 'auto'); + assert.equal(localEdgeAfterLink.restart, 'always'); + + const removed = removeHiveCastHostLink(root, matrixHome); + assert.equal(removed.hostConfigReset, true); + const localConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as { + transport: { + authorityRoot?: string; + nats: { + mode?: string; + url?: string; + wsUrl?: string; + port?: number; + wsPort?: number; + }; + }; + }; + assert.equal(localConfig.transport.authorityRoot, undefined); + assert.equal(localConfig.transport.nats.mode, 'external'); + assert.equal(localConfig.transport.nats.url, 'nats://127.0.0.1:45222'); + assert.equal(localConfig.transport.nats.wsUrl, 'ws://127.0.0.1:45223'); + assert.equal(localConfig.transport.nats.port, 45222); + assert.equal(localConfig.transport.nats.wsPort, 45223); + const localEdgeAfterLogout = JSON.parse(fs.readFileSync( + path.join(matrixHome, 'runtimes', localEdgeRuntimeId, 'runtime.json'), + 'utf8', + )) as { startup?: string; restart?: string }; + const staleLinkedAfterLogout = JSON.parse(fs.readFileSync( + path.join(matrixHome, 'runtimes', staleLinkedRuntimeId, 'runtime.json'), + 'utf8', + )) as { startup?: string; restart?: string }; + const staleLinkedChatAfterLogout = JSON.parse(fs.readFileSync( + path.join(matrixHome, 'runtimes', staleLinkedChatRuntimeId, 'runtime.json'), + 'utf8', + )) as { startup?: string; restart?: string }; + assert.equal(localEdgeAfterLogout.startup, 'auto'); + assert.equal(localEdgeAfterLogout.restart, 'always'); + assert.equal(staleLinkedAfterLogout.startup, 'manual'); + assert.equal(staleLinkedAfterLogout.restart, 'never'); + assert.equal(staleLinkedChatAfterLogout.startup, 'manual'); + assert.equal(staleLinkedChatAfterLogout.restart, 'never'); + }); + + it('derives a fresh unlinked Device root from the stable install identity', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-fresh-local-root-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + const installId = 'install_fresh_device_root'; + writeInstallIdentity(matrixHome, installId); + + saveHiveCastHostLink({ + cwd: root, + matrixHome, + cloudUrl: 'https://hivecast.example.test', + hostId: 'host_fresh_local_root', + authorityRoot: 'SPACE-HIVECAST-LAB', + routeKey: 'hivecast-lab', + publicNamespace: 'space.hivecast-lab', + spaceId: 'space_01FRESHLOCALROOT', + nats: { url: 'wss://hivecast.example.test/nats-ws' }, + natsCredentials: { accountSeed: 'SAFRESHLOCALROOT' }, + }); + + const hostConfig = readJsonRecord(path.join(matrixHome, 'host.json')); + const transport = asRecord(hostConfig.transport); + assert.ok(transport); + assert.equal(transport.root, localRootForInstallId(installId)); + assert.equal(transport.authorityRoot, localRootForInstallId(installId)); + assert.equal(transport.addressRoot, localRootForInstallId(installId)); + assert.match(String(transport.root), /^local-install-fresh-device-root$/); + }); + + it('preserves local owner NATS ports from runtime records when device login links a stopped Host', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-stopped-ports-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + fs.mkdirSync(matrixHome, { recursive: true }); + const installId = 'install_stopped_ports'; + writeInstallIdentity(matrixHome, installId); + fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({ + kind: 'MatrixHostConfig', + version: 1, + home: matrixHome, + transport: { + root: TEST_LOCAL_OWNER_ROOT, + nats: { + mode: 'embedded', + port: 4222, + wsPort: 4223, + dataDir: 'nats/host-default', + pidFile: 'nats/host-default/nats-server.pid', + binaryPath: 'bin/nats-server', + }, + }, + }, null, 2)}\n`); + const localScope = installScopeForInstallId(installId); + const localGatewayRuntimeId = `RUNTIME-HOST-${localScope}-GATEWAY`; + const recordDir = path.join(matrixHome, 'runtimes', localGatewayRuntimeId); + fs.mkdirSync(recordDir, { recursive: true }); + fs.writeFileSync(path.join(recordDir, 'runtime.json'), `${JSON.stringify({ + runtimeId: localGatewayRuntimeId, + packageDir: path.join(matrixHome, 'packages', 'system', '@open-matrix', 'system-gateway-http'), + status: 'stopped', + startup: 'auto', + restart: 'always', + startedAt: new Date().toISOString(), + stoppedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + localMounts: ['system.gateway.http'], + metadata: { + transport: { + root: TEST_LOCAL_OWNER_ROOT, + authorityRoot: TEST_LOCAL_OWNER_ROOT, + nats: { + mode: 'external', + url: 'nats://127.0.0.1:46222', + wsUrl: 'ws://127.0.0.1:46223', + }, + }, + }, + }, null, 2)}\n`); + + saveHiveCastHostLink({ + cwd: root, + matrixHome, + cloudUrl: 'https://hivecast.example.test', + hostId: 'host_stopped_ports', + authorityRoot: 'SPACE-HIVECAST-LAB', + routeKey: 'hivecast-lab', + publicNamespace: 'space.hivecast-lab', + spaceId: 'space_01STOPPEDPORTS', + nats: { url: 'wss://hivecast.example.test/nats-ws' }, + natsCredentials: { accountSeed: 'SASTOPPEDPORTS' }, + }); + const linkedConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as { + hivecastLocalOwnerTransport?: { + nats?: { + port?: number; + wsPort?: number; + }; + }; + }; + assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.port, 46222); + assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.wsPort, 46223); + + const removed = removeHiveCastHostLink(root, matrixHome); + assert.equal(removed.hostConfigReset, true); + const localConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as { + transport: { + root?: string; + authorityRoot?: string; + routeKey?: string; + publicNamespace?: string; + spaceId?: string; + nats: { + mode?: string; + port?: number; + wsPort?: number; + }; + }; + }; + assert.equal(localConfig.transport.root, TEST_LOCAL_OWNER_ROOT); + assert.equal(localConfig.transport.authorityRoot, undefined); + assert.equal(localConfig.transport.routeKey, undefined); + assert.equal(localConfig.transport.publicNamespace, undefined); + assert.equal(localConfig.transport.spaceId, undefined); + assert.equal(localConfig.transport.nats.mode, 'external'); + assert.equal(localConfig.transport.nats.url, 'nats://127.0.0.1:46222'); + assert.equal(localConfig.transport.nats.wsUrl, 'ws://127.0.0.1:46223'); + assert.equal(localConfig.transport.nats.port, 46222); + assert.equal(localConfig.transport.nats.wsPort, 46223); + }); + + it('preserves sibling local owner NATS ports from host config when linking a stopped Host', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-stopped-config-ports-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + fs.mkdirSync(matrixHome, { recursive: true }); + fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({ + kind: 'MatrixHostConfig', + version: 1, + home: matrixHome, + transport: { + root: TEST_LOCAL_OWNER_ROOT, + authorityRoot: TEST_LOCAL_OWNER_ROOT, + nats: { + mode: 'external', + url: 'nats://127.0.0.1:47222', + wsUrl: 'ws://127.0.0.1:47223', + port: 47222, + wsPort: 47223, + dataDir: 'nats/host-default', + pidFile: 'nats/host-default/nats-server.pid', + binaryPath: 'bin/nats-server', + }, + }, + }, null, 2)}\n`); + + saveHiveCastHostLink({ + cwd: root, + matrixHome, + cloudUrl: 'https://hivecast.example.test', + hostId: 'host_stopped_config_ports', + authorityRoot: 'SPACE-HIVECAST-LAB', + routeKey: 'hivecast-lab', + publicNamespace: 'space.hivecast-lab', + spaceId: 'space_01STOPPEDCONFIGPORTS', + nats: { url: 'wss://hivecast.example.test/nats-ws' }, + natsCredentials: { accountSeed: 'SASTOPPEDCONFIGPORTS' }, + }); + const linkedConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as { + hivecastLocalOwnerTransport?: { + nats?: { + port?: number; + wsPort?: number; + }; + }; + }; + assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.port, 47222); + assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.wsPort, 47223); + + const removed = removeHiveCastHostLink(root, matrixHome); + assert.equal(removed.hostConfigReset, true); + const localConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as { + transport: { + root?: string; + authorityRoot?: string; + routeKey?: string; + publicNamespace?: string; + spaceId?: string; + nats: { + mode?: string; + port?: number; + wsPort?: number; + credentialsRef?: string; + }; + }; + }; + assert.equal(localConfig.transport.root, TEST_LOCAL_OWNER_ROOT); + assert.equal(localConfig.transport.authorityRoot, undefined); + assert.equal(localConfig.transport.routeKey, undefined); + assert.equal(localConfig.transport.publicNamespace, undefined); + assert.equal(localConfig.transport.spaceId, undefined); + assert.equal(localConfig.transport.nats.mode, 'external'); + assert.equal(localConfig.transport.nats.url, 'nats://127.0.0.1:47222'); + assert.equal(localConfig.transport.nats.wsUrl, 'ws://127.0.0.1:47223'); + assert.equal(localConfig.transport.nats.port, 47222); + assert.equal(localConfig.transport.nats.wsPort, 47223); + assert.equal(localConfig.transport.nats.credentialsRef, undefined); + }); + + it('moves authority-coordinator Host config onto the linked user authority root', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-linked-coordinator-root-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + fs.mkdirSync(matrixHome, { recursive: true }); + const installId = 'install_42fab8eeedac46d6e348'; + writeInstallIdentity(matrixHome, installId); + fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({ + kind: 'MatrixHostConfig', + version: 1, + home: matrixHome, + transport: { + root: TEST_LOCAL_OWNER_ROOT, + authorityRoot: TEST_LOCAL_OWNER_ROOT, + nats: { + mode: 'external', + url: 'nats://127.0.0.1:48222', + wsUrl: 'ws://127.0.0.1:48223', + port: 48222, + wsPort: 48223, + }, + }, + }, null, 2)}\n`); + + const installScope = installScopeForInstallId(installId); + const installSystemRuntimeId = `RUNTIME-HOST-${installScope}-SYSTEM`; + const installHostOpsRuntimeId = `RUNTIME-HOST-${installScope}-HOST-OPS`; + const localScope = 'LOCAL-STALE-HASH-SCOPE'; + const localSystemRuntimeId = `RUNTIME-HOST-${localScope}-SYSTEM`; + const localHostOpsRuntimeId = `RUNTIME-HOST-${localScope}-HOST-OPS`; + for (const runtimeId of [ + installSystemRuntimeId, + installHostOpsRuntimeId, + localSystemRuntimeId, + localHostOpsRuntimeId, + ]) { + const recordDir = path.join(matrixHome, 'runtimes', runtimeId); + fs.mkdirSync(recordDir, { recursive: true }); + fs.writeFileSync(path.join(recordDir, 'runtime.json'), `${JSON.stringify({ + runtimeId, + packageDir: path.join(matrixHome, 'packages', 'global', 'node_modules', '@open-matrix', 'system'), + status: 'stopped', + startup: 'auto', + restart: 'always', + updatedAt: new Date().toISOString(), + localMounts: ['system', 'system.registry', 'system.devices', 'system.runtimes'], + metadata: {}, + }, null, 2)}\n`); + } + + saveHiveCastHostLink({ + cwd: root, + matrixHome, + cloudUrl: 'https://hivecast.example.test', + hostId: 'install_42fab8eeedac46d6e348', + authorityRoot: 'test-user-1', + routeKey: 'test-user-1', + publicNamespace: 'test-user-1', + spaceId: 'space_test_user_1', + authorityCoordinator: true, + nats: { + url: 'nats://dev-platform:4222', + wsUrl: 'wss://dev-platform/nats-ws', + }, + natsCredentials: { accountSeed: 'SATESTUSER1' }, + }); + + const linkedConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as { + transport?: { + root?: string; + authorityRoot?: string; + addressRoot?: string; + routeKey?: string; + publicNamespace?: string; + spaceId?: string; + nats?: { + mode?: string; + url?: string; + wsUrl?: string; + port?: number; + wsPort?: number; + credentialsRef?: string; + }; + }; + hivecastLocalOwnerTransport?: { + root?: string; + nats?: { + url?: string; + wsUrl?: string; + }; + }; + }; + assert.equal(linkedConfig.transport?.root, 'test-user-1'); + assert.equal(linkedConfig.transport?.authorityRoot, 'test-user-1'); + assert.equal(linkedConfig.transport?.addressRoot, 'test-user-1'); + assert.equal(linkedConfig.transport?.routeKey, 'test-user-1'); + assert.equal(linkedConfig.transport?.publicNamespace, 'test-user-1'); + assert.equal(linkedConfig.transport?.spaceId, 'space_test_user_1'); + assert.equal(linkedConfig.transport?.nats?.mode, 'external'); + assert.equal(linkedConfig.transport?.nats?.url, 'nats://dev-platform:4222'); + assert.equal(linkedConfig.transport?.nats?.wsUrl, 'wss://dev-platform/nats-ws'); + assert.equal(linkedConfig.transport?.nats?.port, undefined); + assert.equal(linkedConfig.transport?.nats?.wsPort, undefined); + assert.equal( + linkedConfig.transport?.nats?.credentialsRef, + `file:${path.join(matrixHome, 'credentials', 'hivecast-nats.json')}`, + ); + assert.equal(linkedConfig.hivecastLocalOwnerTransport?.root, TEST_LOCAL_OWNER_ROOT); + assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.url, 'nats://127.0.0.1:48222'); + assert.equal(linkedConfig.hivecastLocalOwnerTransport?.nats?.wsUrl, 'ws://127.0.0.1:48223'); + + const installSystemAfterLink = JSON.parse(fs.readFileSync( + path.join(matrixHome, 'runtimes', installSystemRuntimeId, 'runtime.json'), + 'utf8', + )) as { startup?: string; restart?: string; metadata?: { hivecastDefaultRuntimePolicy?: string } }; + assert.equal(installSystemAfterLink.startup, 'auto'); + assert.equal(installSystemAfterLink.restart, 'always'); + assert.equal(installSystemAfterLink.metadata?.hivecastDefaultRuntimePolicy, 'active'); + + const localSystemAfterLink = JSON.parse(fs.readFileSync( + path.join(matrixHome, 'runtimes', localSystemRuntimeId, 'runtime.json'), + 'utf8', + )) as { startup?: string; restart?: string; metadata?: { hivecastDefaultRuntimePolicy?: string } }; + assert.equal(localSystemAfterLink.startup, 'manual'); + assert.equal(localSystemAfterLink.restart, 'never'); + assert.equal(localSystemAfterLink.metadata?.hivecastDefaultRuntimePolicy, 'inactive'); + + const installHostOpsAfterLink = JSON.parse(fs.readFileSync( + path.join(matrixHome, 'runtimes', installHostOpsRuntimeId, 'runtime.json'), + 'utf8', + )) as { startup?: string; restart?: string; metadata?: { hivecastDefaultRuntimePolicy?: string } }; + assert.equal(installHostOpsAfterLink.startup, 'auto'); + assert.equal(installHostOpsAfterLink.restart, 'always'); + assert.equal(installHostOpsAfterLink.metadata?.hivecastDefaultRuntimePolicy, 'active'); + + const localHostOpsAfterLink = JSON.parse(fs.readFileSync( + path.join(matrixHome, 'runtimes', localHostOpsRuntimeId, 'runtime.json'), + 'utf8', + )) as { startup?: string; restart?: string; metadata?: { hivecastDefaultRuntimePolicy?: string } }; + assert.equal(localHostOpsAfterLink.startup, 'manual'); + assert.equal(localHostOpsAfterLink.restart, 'never'); + assert.equal(localHostOpsAfterLink.metadata?.hivecastDefaultRuntimePolicy, 'inactive'); + }); + + it('refuses authority-coordinator links without an installed local Host transport', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-linked-coordinator-no-local-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + fs.mkdirSync(matrixHome, { recursive: true }); + + assert.throws( + () => saveHiveCastHostLink({ + cwd: root, + matrixHome, + cloudUrl: 'https://hivecast.example.test', + hostId: 'install_ws_only', + authorityRoot: 'test-user-1', + authorityCoordinator: true, + nats: { url: 'wss://hivecast.example.test/nats-ws' }, + natsCredentials: { jwt: 'device.jwt.token', seed: 'SUDEVICESEED' }, + }), + /HIVECAST_LINK_LOCAL_TRANSPORT_MISSING/, + ); + }); + + it('does not classify local owner authorityRoot as a HiveCast link', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-local-authority-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + fs.mkdirSync(matrixHome, { recursive: true }); + fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({ + kind: 'MatrixHostConfig', + version: 1, + home: matrixHome, + transport: { + root: TEST_LOCAL_OWNER_ROOT, + authorityRoot: TEST_LOCAL_OWNER_ROOT, + nats: { + mode: 'embedded', + port: 46222, + wsPort: 46223, + }, + }, + }, null, 2)}\n`); + + const status = readHiveCastLinkStatus(root, matrixHome); + assert.equal(status.linked, false); + assert.equal(status.hostConfigLinked, false); + }); + + it('does not classify sibling local owner NATS as a HiveCast link', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-local-sibling-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + fs.mkdirSync(matrixHome, { recursive: true }); + fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({ + kind: 'MatrixHostConfig', + version: 1, + home: matrixHome, + transport: { + root: TEST_LOCAL_OWNER_ROOT, + authorityRoot: TEST_LOCAL_OWNER_ROOT, + nats: { + mode: 'external', + url: 'nats://127.0.0.1:47222', + wsUrl: 'ws://127.0.0.1:47223', + port: 47222, + wsPort: 47223, + }, + }, + }, null, 2)}\n`); + + const status = readHiveCastLinkStatus(root, matrixHome); + assert.equal(status.linked, false); + assert.equal(status.hostConfigLinked, false); + }); + + it('completes HiveCast device login and stores local Host link state', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-device-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + await withDeviceFlowServer(async (cloudUrl) => { + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--hivecast', + '--device', + '--cloud', + cloudUrl, + '--route-key', + 'hivecast-lab', + '--name', + 'test-host', + '--home', + matrixHome, + '--json', + ], { cwd: root }); + assertExitCode(loginResult, 0); + const loginPayload = parseLastJson(loginResult.logs) as { + linked: boolean; + hostLinkId: string; + hostId: string; + authorityRoot: string; + routeKey: string; + publicNamespace: string; + spaceId: string; + hostName?: string; + deviceSlug?: string; + }; + assert.equal(loginPayload.linked, true); + assert.equal(loginPayload.hostLinkId, 'host-link-device-01'); + assert.equal(loginPayload.hostId, 'DEVICE-123'); + assert.equal(loginPayload.authorityRoot, 'SPACE-HIVECAST-LAB'); + assert.equal(loginPayload.routeKey, 'hivecast-lab'); + assert.equal(loginPayload.publicNamespace, 'space.hivecast-lab'); + assert.equal(loginPayload.spaceId, 'space_01DEVICE'); + assert.equal(loginPayload.hostName, 'test-host'); + assert.equal(loginPayload.deviceSlug, 'test-host'); + assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-link.json')), true); + const natsCredentials = JSON.parse(fs.readFileSync(path.join(matrixHome, 'credentials', 'hivecast-nats.json'), 'utf8')) as { + jwt?: string; + seed?: string; + accountSeed?: string; + }; + assert.equal(natsCredentials.jwt, 'device.jwt.token'); + assert.equal(natsCredentials.seed, 'SUDEVICESEED'); + assert.equal(Object.prototype.hasOwnProperty.call(natsCredentials, 'accountSeed'), false); + assert.equal(fs.existsSync(path.join(matrixHome, 'host.json')), true); + const hostConfig = JSON.parse(fs.readFileSync(path.join(matrixHome, 'host.json'), 'utf8')) as { + transport: { + root: string; + authorityRoot: string; + routeKey?: string; + publicNamespace?: string; + spaceId?: string; + nats?: { + credentialsRef?: string; + }; + }; + }; + const expectedLocalRoot = localRootForInstallId(readInstallIdentityId(matrixHome)); + assert.equal(hostConfig.transport.root, expectedLocalRoot); + assert.equal(hostConfig.transport.authorityRoot, expectedLocalRoot); + assert.equal(hostConfig.transport.routeKey, undefined); + assert.equal(hostConfig.transport.publicNamespace, undefined); + assert.equal(hostConfig.transport.spaceId, undefined); + assert.equal(hostConfig.transport.nats?.credentialsRef, undefined); + + const whoamiResult = await runMxCli([ + 'node', + 'mx', + 'whoami', + '--hivecast', + '--home', + matrixHome, + '--json', + ], { cwd: root }); + assertExitCode(whoamiResult, 0); + const whoamiPayload = parseLastJson(whoamiResult.logs) as { + linked: boolean; + hostLinkId: string; + authorityRoot: string; + routeKey: string; + publicNamespace: string; + principalId: string; + hostName?: string; + deviceSlug?: string; + }; + assert.equal(whoamiPayload.linked, true); + assert.equal(whoamiPayload.hostLinkId, 'host-link-device-01'); + assert.equal(whoamiPayload.authorityRoot, 'SPACE-HIVECAST-LAB'); + assert.equal(whoamiPayload.routeKey, 'hivecast-lab'); + assert.equal(whoamiPayload.publicNamespace, 'space.hivecast-lab'); + assert.equal(whoamiPayload.principalId, 'p_device'); + assert.equal(whoamiPayload.hostName, 'test-host'); + assert.equal(whoamiPayload.deviceSlug, 'test-host'); + + const statusResult = await runMxCli([ + 'node', + 'mx', + 'link-status', + '--home', + matrixHome, + '--json', + ], { cwd: root }); + assertExitCode(statusResult, 0); + const statusPayload = parseLastJson(statusResult.logs) as { + linked: boolean; + hostLinkId: string; + authorityRoot: string; + routeKey: string; + publicNamespace: string; + spaceId: string; + natsCredentialsPresent: boolean; + hostConfigLinked: boolean; + }; + assert.equal(statusPayload.linked, true); + assert.equal(statusPayload.hostLinkId, 'host-link-device-01'); + assert.equal(statusPayload.authorityRoot, 'SPACE-HIVECAST-LAB'); + assert.equal(statusPayload.routeKey, 'hivecast-lab'); + assert.equal(statusPayload.publicNamespace, 'space.hivecast-lab'); + assert.equal(statusPayload.spaceId, 'space_01DEVICE'); + assert.equal(statusPayload.natsCredentialsPresent, true); + assert.equal(statusPayload.hostConfigLinked, true); + + const logoutResult = await runMxCli([ + 'node', + 'mx', + 'logout', + '--hivecast', + '--revoke-cloud-link', + '--home', + matrixHome, + '--json', + ], { cwd: root }); + assertExitCode(logoutResult, 0); + const logoutPayload = parseLastJson(logoutResult.logs) as { + removed: boolean; + cloudRevoke?: { + attempted: boolean; + revoked: boolean; + }; + hostConfigReset: boolean; + }; + assert.equal(logoutPayload.removed, true); + assert.equal(logoutPayload.cloudRevoke?.attempted, true); + assert.equal(logoutPayload.cloudRevoke?.revoked, true); + assert.equal(logoutPayload.hostConfigReset, true); + assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-link.json')), false); + }); + }); + + it('prints HiveCast device-code challenge without polling when no-wait is requested', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-device-no-wait-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + await withDeviceFlowServer(async (cloudUrl) => { + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--hivecast', + '--device', + '--cloud', + cloudUrl, + '--route-key', + 'hivecast-lab', + '--name', + 'test-host', + '--home', + matrixHome, + '--json', + '--no-wait', + ], { cwd: root }); + assertExitCode(loginResult, 0); + const loginPayload = parseLastJson(loginResult.logs) as { + mode: string; + linked: boolean; + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete: string; + setupUrl: string; + }; + assert.equal(loginPayload.mode, 'approval-required'); + assert.equal(loginPayload.linked, false); + assert.equal(loginPayload.deviceCode, 'DEVICE-123'); + assert.equal(loginPayload.userCode, 'K7P9-TQ2M'); + assert.equal(loginPayload.verificationUri, 'https://hivecast.example.test/activate'); + assert.equal(loginPayload.verificationUriComplete, 'https://hivecast.example.test/activate?user_code=K7P9-TQ2M'); + assert.equal(loginPayload.setupUrl, loginPayload.verificationUriComplete); + assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-link.json')), false); + assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-nats.json')), false); + }); + }); + + it('treats --headless as the product alias for HiveCast device-code login', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-headless-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + await withDeviceFlowServer(async (cloudUrl) => { + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--hivecast', + '--headless', + '--cloud', + cloudUrl, + '--route-key', + 'hivecast-lab', + '--name', + 'test-host', + '--home', + matrixHome, + '--json', + ], { cwd: root }); + assertExitCode(loginResult, 0); + const loginPayload = parseLastJson(loginResult.logs) as { + linked: boolean; + hostLinkId: string; + authorityRoot: string; + }; + assert.equal(loginPayload.linked, true); + assert.equal(loginPayload.hostLinkId, 'host-link-device-01'); + assert.equal(loginPayload.authorityRoot, 'SPACE-HIVECAST-LAB'); + assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-link.json')), true); + }); + }); + + it('keeps headless HiveCast device login on the linked authority root for an installed Host', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-headless-root-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + await withNatsServer(async (natsUrl) => { + writeLocalOwnerHostStatus(matrixHome, natsUrl, 'hivecast-headless-local-owner-root'); + await withReloadSupervisor(natsUrl, matrixHome, async (reloadCount) => { + await withDeviceFlowServer(async (cloudUrl) => { + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--hivecast', + '--headless', + '--cloud', + cloudUrl, + '--route-key', + 'hivecast-lab', + '--name', + 'test-host', + '--home', + matrixHome, + '--json', + ], { cwd: root }); + assertExitCode(loginResult, 0); + const loginPayload = parseLastJson(loginResult.logs) as { + linked: boolean; + hostLinkId: string; + authorityRoot: string; + authorityCoordinator: boolean; + }; + assert.equal(loginPayload.linked, true); + assert.equal(loginPayload.hostLinkId, 'host-link-device-01'); + assert.equal(loginPayload.authorityRoot, 'SPACE-HIVECAST-LAB'); + assert.equal(loginPayload.authorityCoordinator, false); + assert.equal(reloadCount(), 1); + assertHostConfigRoot(matrixHome, 'SPACE-HIVECAST-LAB', { + routeKey: 'hivecast-lab', + publicNamespace: 'space.hivecast-lab', + spaceId: 'space_01DEVICE', + }); + assertHostStatusRoot(matrixHome, 'SPACE-HIVECAST-LAB', { + routeKey: 'hivecast-lab', + publicNamespace: 'space.hivecast-lab', + spaceId: 'space_01DEVICE', + }); + }, { authorityCoordinator: true }); + }); + }); + }); + + it('keeps polling HiveCast device login while approval waits for namespace setup', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-device-owned-key-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + await withDeviceNamespaceRequiredServer(async (cloudUrl) => { + const result = await runMxCli([ + 'node', + 'mx', + 'login', + '--hivecast', + '--device', + '--cloud', + cloudUrl, + '--route-key', + 'owned-key', + '--home', + matrixHome, + '--json', + '--poll-timeout-ms', + '50', + ], { cwd: root }); + assertExitCode(result, 1); + assert.match(result.errors.join('\n'), /HIVECAST_DEVICE_TIMEOUT/); + assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-link.json')), false); + assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-nats.json')), false); + }); + }); + + it('keeps HiveCast login idempotent and refuses accidental relink to a different route key', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-idempotent-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + let exchangeCalls = 0; + await withTokenExchangeServer( + (body) => { + exchangeCalls += 1; + const code = String(body.code ?? ''); + if (code === 'FIRST') { + return { + ok: true, + root: 'SPACE-HIVECAST-LAB', + routeKey: 'hivecast-lab', + publicNamespace: 'space.hivecast-lab', + spaceId: 'space_01IDEMPOTENT', + nats: { url: 'wss://hivecast.example.test/nats-ws' }, + credentials: { jwt: 'first.jwt.token', seed: 'SUFIRSTIDEMPOTENT' }, + }; + } + if (code === 'SECOND') { + return { + ok: true, + root: 'SPACE-OTHER-LAB', + routeKey: 'other-lab', + publicNamespace: 'space.other-lab', + spaceId: 'space_01RELINK', + nats: { url: 'wss://hivecast.example.test/nats-ws' }, + credentials: { jwt: 'second.jwt.token', seed: 'SUSECONDRELINK' }, + }; + } + return { ok: false, error: `unexpected code ${code}` }; + }, + async (cloudUrl) => { + const first = await hiveCastLoginCommand({ + cwd: root, + home: matrixHome, + cloudUrl, + setupCode: 'FIRST', + }); + assert.equal(first.linked, true); + assert.equal(first.routeKey, 'hivecast-lab'); + assert.equal(exchangeCalls, 1); + + const alreadyLinked = await hiveCastLoginCommand({ + cwd: root, + home: matrixHome, + cloudUrl, + routeKey: 'hivecast-lab', + }); + assert.equal(alreadyLinked.linked, true); + assert.equal(alreadyLinked.alreadyLinked, true); + assert.equal(alreadyLinked.routeKey, 'hivecast-lab'); + assert.equal(exchangeCalls, 1); + + await assert.rejects( + () => hiveCastLoginCommand({ + cwd: root, + home: matrixHome, + cloudUrl, + routeKey: 'other-lab', + }), + /HIVECAST_LINK_ROUTE_MISMATCH/, + ); + await assert.rejects( + () => hiveCastLoginCommand({ + cwd: root, + home: matrixHome, + cloudUrl: 'https://other-hivecast.example.test', + routeKey: 'hivecast-lab', + }), + /HIVECAST_LINK_CLOUD_MISMATCH/, + ); + assert.equal(exchangeCalls, 1); + + const relinked = await hiveCastLoginCommand({ + cwd: root, + home: matrixHome, + cloudUrl, + setupCode: 'SECOND', + forceRelink: true, + }); + assert.equal(relinked.linked, true); + assert.equal(relinked.alreadyLinked, undefined); + assert.equal(relinked.routeKey, 'other-lab'); + assert.equal(relinked.authorityRoot, 'SPACE-OTHER-LAB'); + assert.equal(exchangeCalls, 2); + }, + ); + }); + + it('keeps running Host roots converged across HiveCast login, already-linked, relink, and logout', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-running-host-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + const localOwnerRoot = 'hivecast-proof-local-owner-root'; + const hostCredentialsPath = path.join(matrixHome, 'credentials.json'); + const credentialPresenceAtReload: boolean[] = []; + await withNatsServer(async (natsUrl) => { + writeLocalOwnerHostStatus(matrixHome, natsUrl, localOwnerRoot); + await withReloadSupervisor(natsUrl, matrixHome, async (reloadCount) => { + await withTokenExchangeServer( + (body) => { + const code = String(body.code ?? ''); + if (code === 'RUNNING-HOST') { + return { + ok: true, + root: 'SPACE-HIVECAST-RUNNING', + routeKey: 'hivecast-running', + publicNamespace: 'space.hivecast-running', + spaceId: 'space_01RUNNING', + authorityCoordinator: true, + nats: { + url: 'nats://hivecast.example.test:4222', + }, + credentials: { + jwt: 'running.jwt.token', + seed: 'SURUNNINGHOST', + }, + }; + } + if (code === 'RELINK-HOST') { + return { + ok: true, + root: 'SPACE-HIVECAST-RELINK', + routeKey: 'hivecast-relink', + publicNamespace: 'space.hivecast-relink', + spaceId: 'space_01RELINKHOST', + authorityCoordinator: true, + nats: { + url: 'nats://hivecast.example.test:4222', + }, + credentials: { + jwt: 'relink.jwt.token', + seed: 'SURELINKHOST', + }, + }; + } + return { + ok: false, + error: `unexpected code ${code}`, + }; + }, + async (cloudUrl) => { + const result = await hiveCastLoginCommand({ + cwd: root, + home: matrixHome, + cloudUrl, + setupCode: 'RUNNING-HOST', + }); + assert.equal(result.linked, true); + assert.equal(result.authorityCoordinator, true); + const loginReload = result.hostConfigReload; + assert.ok(loginReload); + assert.equal(loginReload.attempted, true); + assert.equal(loginReload.applied, true); + assert.equal(reloadCount(), 1); + assertHostConfigRoot(matrixHome, 'SPACE-HIVECAST-RUNNING', { + routeKey: 'hivecast-running', + publicNamespace: 'space.hivecast-running', + spaceId: 'space_01RUNNING', + }); + assertHostStatusRoot(matrixHome, 'SPACE-HIVECAST-RUNNING', { + routeKey: 'hivecast-running', + publicNamespace: 'space.hivecast-running', + spaceId: 'space_01RUNNING', + }); + + const alreadyLinked = await hiveCastLoginCommand({ + cwd: root, + home: matrixHome, + cloudUrl, + routeKey: 'hivecast-running', + }); + assert.equal(alreadyLinked.linked, true); + assert.equal(alreadyLinked.alreadyLinked, true); + assert.equal(alreadyLinked.authorityRoot, 'SPACE-HIVECAST-RUNNING'); + assert.equal(alreadyLinked.authorityCoordinator, true); + assert.equal(reloadCount(), 2); + assertHostConfigRoot(matrixHome, 'SPACE-HIVECAST-RUNNING', { + routeKey: 'hivecast-running', + publicNamespace: 'space.hivecast-running', + spaceId: 'space_01RUNNING', + }); + assertHostStatusRoot(matrixHome, 'SPACE-HIVECAST-RUNNING', { + routeKey: 'hivecast-running', + publicNamespace: 'space.hivecast-running', + spaceId: 'space_01RUNNING', + }); + + const relinked = await hiveCastLoginCommand({ + cwd: root, + home: matrixHome, + cloudUrl, + setupCode: 'RELINK-HOST', + forceRelink: true, + }); + assert.equal(relinked.linked, true); + assert.equal(relinked.alreadyLinked, undefined); + assert.equal(relinked.authorityRoot, 'SPACE-HIVECAST-RELINK'); + assert.equal(relinked.authorityCoordinator, true); + assert.equal(reloadCount(), 3); + assertHostConfigRoot(matrixHome, 'SPACE-HIVECAST-RELINK', { + routeKey: 'hivecast-relink', + publicNamespace: 'space.hivecast-relink', + spaceId: 'space_01RELINKHOST', + }); + assertHostStatusRoot(matrixHome, 'SPACE-HIVECAST-RELINK', { + routeKey: 'hivecast-relink', + publicNamespace: 'space.hivecast-relink', + spaceId: 'space_01RELINKHOST', + }); + + const logout = await hiveCastLogoutCommand({ + cwd: root, + home: matrixHome, + }); + assert.equal(logout.removed, true); + const logoutReload = logout.hostConfigReload; + assert.ok(logoutReload); + assert.equal(logoutReload.attempted, true); + assert.equal(logoutReload.applied, true); + assert.equal(reloadCount(), 4); + + assertHostConfigRoot(matrixHome, localOwnerRoot); + assertHostStatusRoot(matrixHome, localOwnerRoot); + assert.deepEqual(credentialPresenceAtReload, [true, true, true, true]); + assert.equal(fs.existsSync(hostCredentialsPath), false); + }, + ); + }, () => { + credentialPresenceAtReload.push(fs.existsSync(hostCredentialsPath)); + }); + }); + }); + + it('rewrites invalid shared bootstrap root to the install-local root before saving linked Host config', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-root-repair-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + const installId = 'install_root_repair_01'; + const expectedLocalRoot = localRootForInstallId(installId); + const invalidSharedRoot = 'hivecast-local-development-platform-topic'; + writeInstallIdentity(matrixHome, installId); + fs.writeFileSync(path.join(matrixHome, 'host.json'), `${JSON.stringify({ + kind: 'MatrixHostConfig', + version: 1, + home: matrixHome, + transport: { + root: invalidSharedRoot, + authorityRoot: invalidSharedRoot, + addressRoot: invalidSharedRoot, + nats: { + mode: 'external', + url: 'nats://127.0.0.1:48222', + wsUrl: 'ws://127.0.0.1:48223', + port: 48222, + wsPort: 48223, + }, + }, + }, null, 2)}\n`, 'utf8'); + + saveHiveCastHostLink({ + cwd: root, + matrixHome, + cloudUrl: 'https://hivecast.example.test', + hostId: 'install_root_repair_01', + authorityRoot: 'richard-santomauro-nimbletec-com', + authorityCoordinator: true, + nats: { + url: 'nats://hivecast.example.test:4222', + }, + natsCredentials: { + jwt: 'root.repair.jwt', + seed: 'SUROOTREPAIR', + }, + hostLinkToken: 'hostlink-root-repair-token', + hostLinkId: 'hostlink_root_repair', + principalId: 'principal_root_repair', + hostName: 'root-repair-device', + }); + + const linkedConfig = readJsonRecord(path.join(matrixHome, 'host.json')); + const localOwnerTransport = asRecord(linkedConfig.hivecastLocalOwnerTransport); + assert.ok(localOwnerTransport); + assert.equal(localOwnerTransport.root, expectedLocalRoot); + assertHostConfigRoot(matrixHome, 'richard-santomauro-nimbletec-com'); + + const removed = removeHiveCastHostLink(root, matrixHome); + assert.equal(removed.hostConfigReset, true); + assertHostConfigRoot(matrixHome, expectedLocalRoot); + }); + + it('refuses linked success when a running Host does not apply linked config', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-reload-failure-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + await withNatsServer(async (natsUrl) => { + writeLocalOwnerHostStatus(matrixHome, natsUrl, 'hivecast-reload-failure-local-root'); + await withTokenExchangeServer( + () => ({ + ok: true, + root: 'SPACE-HIVECAST-RELOAD-FAILED', + routeKey: 'hivecast-reload-failed', + publicNamespace: 'space.hivecast-reload-failed', + spaceId: 'space_01RELOADFAILED', + authorityCoordinator: true, + nats: { + url: 'nats://hivecast.example.test:4222', + }, + credentials: { + jwt: 'reload-failed.jwt.token', + seed: 'SURELOADFAILED', + }, + }), + async (cloudUrl) => { + await assert.rejects( + () => hiveCastLoginCommand({ + cwd: root, + home: matrixHome, + cloudUrl, + setupCode: 'RELOAD-FAILED', + }), + /HIVECAST_LINK_HOST_CONFIG_RELOAD_FAILED: running Host did not apply linked config[\s\S]*expectedRoot=SPACE-HIVECAST-RELOAD-FAILED/, + ); + }, + ); + }); + }); + + it('waits for loopback HiveCast pair approval before exchanging the approval code', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-wait-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + const openedApprovalUrls: string[] = []; + await withNatsServer(async (natsUrl) => { + writeLocalOwnerHostStatus(matrixHome, natsUrl, 'hivecast-loopback-local-owner-root'); + await withReloadSupervisor(natsUrl, matrixHome, async (reloadCount) => { + await withPairFlowServer( + { + pairRequestId: 'PAIR-WAIT', + approvalCode: 'APPROVAL-WAIT', + exchange: { + root: 'SPACE-HIVECAST-WAIT', + routeKey: 'hivecast-wait', + publicNamespace: 'space.hivecast-wait', + spaceId: 'space_01WAIT', + authorityCoordinator: true, + nats: { + url: 'nats://hivecast.example.test:4222', + }, + credentials: { + jwt: 'wait.jwt.token', + seed: 'SUFAKEWAIT', + }, + }, + }, + async (cloudUrl, controls) => { + const result = await hiveCastLoginCommand({ + cwd: root, + home: matrixHome, + cloudUrl, + waitForCallback: true, + noOpen: true, + callbackTimeoutMs: 5_000, + openApprovalUrl: (approvalUrl) => { + openedApprovalUrls.push(approvalUrl); + }, + onSetupUrl: (setupUrl) => { + const parsed = new URL(setupUrl); + assert.equal(parsed.origin, cloudUrl); + assert.equal(parsed.pathname, '/_auth/pair/PAIR-WAIT'); + const localReturnUrl = controls.getLastLocalReturnUrl(); + assert.ok(localReturnUrl); + const startBody = controls.getStartBody(); + assert.equal(typeof startBody?.nonce, 'string'); + setImmediate(() => { + void fetch(`${localReturnUrl}?pairRequestId=PAIR-WAIT&approvalCode=APPROVAL-WAIT&nonce=${encodeURIComponent(String(startBody?.nonce))}`); + }); + }, + }); + assert.equal(result.linked, true); + assert.equal(result.authorityRoot, 'SPACE-HIVECAST-WAIT'); + assert.equal(result.authorityCoordinator, true); + assert.equal(result.routeKey, 'hivecast-wait'); + assert.equal(reloadCount(), 1); + assertHostConfigRoot(matrixHome, 'SPACE-HIVECAST-WAIT', { + routeKey: 'hivecast-wait', + publicNamespace: 'space.hivecast-wait', + spaceId: 'space_01WAIT', + }); + assertHostStatusRoot(matrixHome, 'SPACE-HIVECAST-WAIT', { + routeKey: 'hivecast-wait', + publicNamespace: 'space.hivecast-wait', + spaceId: 'space_01WAIT', + }); + assert.equal(fs.existsSync(path.join(matrixHome, 'credentials', 'hivecast-link.json')), true); + assert.deepEqual(openedApprovalUrls, []); + }, + ); + }); + }); + }); + + it('opens the HiveCast pair approval URL by default during loopback login', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-open-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + const openedApprovalUrls: string[] = []; + await withPairFlowServer( + { + pairRequestId: 'PAIR-OPEN', + approvalCode: 'APPROVAL-OPEN', + exchange: { + root: 'SPACE-HIVECAST-OPEN', + routeKey: 'hivecast-open', + publicNamespace: 'space.hivecast-open', + nats: { + url: 'wss://hivecast.example.test/nats-ws', + }, + credentials: { + jwt: 'open.jwt.token', + seed: 'SUFAKEOPEN', + }, + }, + }, + async (cloudUrl, controls) => { + let announcedSetupUrl = ''; + const result = await hiveCastLoginCommand({ + cwd: root, + home: matrixHome, + cloudUrl, + waitForCallback: true, + callbackTimeoutMs: 5_000, + openApprovalUrl: (approvalUrl) => { + openedApprovalUrls.push(approvalUrl); + }, + onSetupUrl: (setupUrl) => { + announcedSetupUrl = setupUrl; + const localReturnUrl = controls.getLastLocalReturnUrl(); + assert.ok(localReturnUrl); + const startBody = controls.getStartBody(); + assert.equal(typeof startBody?.nonce, 'string'); + setImmediate(() => { + void fetch(`${localReturnUrl}?pairRequestId=PAIR-OPEN&approvalCode=APPROVAL-OPEN&nonce=${encodeURIComponent(String(startBody?.nonce))}`); + }); + }, + }); + assert.equal(result.linked, true); + assert.equal(result.authorityRoot, 'SPACE-HIVECAST-OPEN'); + assert.equal(result.routeKey, 'hivecast-open'); + assert.deepEqual(openedApprovalUrls, [announcedSetupUrl]); + }, + ); + }); + + it('prints the HiveCast pair approval URL without opening a browser when --print-url is used', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-hivecast-print-url-')); + tempDirs.push(root); + const matrixHome = path.join(root, '.matrix'); + await withPairFlowServer( + { + pairRequestId: 'PAIR-PRINT', + approvalCode: 'APPROVAL-PRINT', + autoApprove: true, + exchange: { + root: 'SPACE-HIVECAST-PRINT', + routeKey: 'hivecast-print', + publicNamespace: 'space.hivecast-print', + nats: { + url: 'wss://hivecast.example.test/nats-ws', + }, + credentials: { + jwt: 'print.jwt.token', + seed: 'SUFAKEPRINT', + }, + }, + }, + async (cloudUrl) => { + const result = await runMxCli([ + 'node', + 'mx', + 'login', + '--hivecast', + '--cloud', + cloudUrl, + '--home', + matrixHome, + '--print-url', + ], { cwd: root }); + assertExitCode(result, 0); + assert.equal(result.logs.includes('Open this URL to approve connecting this device to HiveCast:'), true); + assert.equal(result.logs.includes(`${cloudUrl}/_auth/pair/PAIR-PRINT`), true); + assert.equal(result.logs.some((line) => line.includes('HiveCast device connected: SPACE-HIVECAST-PRINT')), true); + }, + ); + }); +}); diff --git a/archive/mx-cli-product-command-tests/hivecast-link-store.spec.ts b/archive/mx-cli-product-command-tests/hivecast-link-store.spec.ts new file mode 100644 index 0000000..d8c7e78 --- /dev/null +++ b/archive/mx-cli-product-command-tests/hivecast-link-store.spec.ts @@ -0,0 +1,178 @@ +import { strict as assert } from 'node:assert'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +import { saveHiveCastHostLink } from '../../src/utils/hivecast-link-store.js'; + +describe('HiveCast link store', () => { + it('keeps a normal linked Device on local NATS when the saved local-owner snapshot is missing', () => { + const root = mkdtempSync(join(tmpdir(), 'mx-hivecast-link-store-')); + try { + const matrixHome = join(root, '.matrix'); + const hostConfigPath = join(matrixHome, 'host.json'); + mkdirSync(matrixHome, { recursive: true }); + writeFileSync(hostConfigPath, `${JSON.stringify({ + kind: 'MatrixHostConfig', + version: 1, + home: matrixHome, + transport: { + root: 'richard-santomauro-nimbletec-com', + authorityRoot: 'richard-santomauro-nimbletec-com', + addressRoot: 'richard-santomauro-nimbletec-com', + nats: { + mode: 'external', + url: 'nats://local.hivecast.ai:4222', + wsUrl: 'wss://local.hivecast.ai/nats-ws', + credentialsRef: 'file:/var/lib/hivecast/credentials/hivecast-nats.json', + }, + }, + }, null, 2)}\n`); + + saveHiveCastHostLink({ + cwd: root, + matrixHome, + cloudUrl: 'https://local.hivecast.ai', + authorityRoot: 'richard-santomauro-nimbletec-com', + deviceRegistryRoot: 'richard-santomauro-nimbletec-com', + hostLinkId: 'hostlink_test', + hostId: 'install_test', + principalId: 'principal_test', + hostName: 'test-device', + deviceSlug: 'test-device', + routeKey: 'richard-santomauro-nimbletec-com', + publicNamespace: 'richard-santomauro-nimbletec-com', + spaceId: 'space_test', + authorityCoordinator: false, + nats: { + url: 'nats://local.hivecast.ai:4222', + wsUrl: 'wss://local.hivecast.ai/nats-ws', + }, + natsCredentials: { + jwt: 'jwt', + seed: 'seed', + }, + }); + + const hostConfig = JSON.parse(readFileSync(hostConfigPath, 'utf8')) as { + readonly transport?: { + readonly root?: string; + readonly authorityRoot?: string; + readonly nats?: { + readonly url?: string; + readonly wsUrl?: string; + readonly credentialsRef?: string; + readonly binaryPath?: string; + }; + }; + }; + + assert.equal(hostConfig.transport?.root, 'richard-santomauro-nimbletec-com'); + assert.equal(hostConfig.transport?.authorityRoot, 'richard-santomauro-nimbletec-com'); + assert.equal(hostConfig.transport?.nats?.url, 'nats://127.0.0.1:4222'); + assert.equal(hostConfig.transport?.nats?.wsUrl, 'ws://127.0.0.1:4223'); + assert.equal(hostConfig.transport?.nats?.credentialsRef, undefined); + assert.equal(hostConfig.transport?.nats?.binaryPath, undefined); + assert.equal(Object.prototype.hasOwnProperty.call(hostConfig, 'natsTopology'), false); + assert.equal(existsSync(join(matrixHome, 'credentials', 'hivecast-nats.creds')), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('configures linked Device leaf-node transport when the platform exchange advertises it', () => { + const root = mkdtempSync(join(tmpdir(), 'mx-hivecast-link-store-leaf-')); + try { + const matrixHome = join(root, '.matrix'); + const hostConfigPath = join(matrixHome, 'host.json'); + mkdirSync(matrixHome, { recursive: true }); + writeFileSync(hostConfigPath, `${JSON.stringify({ + kind: 'MatrixHostConfig', + version: 1, + home: matrixHome, + transport: { + root: 'local-install-test', + authorityRoot: 'local-install-test', + addressRoot: 'local-install-test', + nats: { + mode: 'external', + url: 'nats://127.0.0.1:4222', + wsUrl: 'ws://127.0.0.1:4223', + }, + }, + }, null, 2)}\n`); + + saveHiveCastHostLink({ + cwd: root, + matrixHome, + cloudUrl: 'https://local.hivecast.ai', + authorityRoot: 'richard-santomauro-nimbletec-com', + deviceRegistryRoot: 'richard-santomauro-nimbletec-com', + hostLinkId: 'hostlink_test', + hostId: 'install_test', + principalId: 'principal_test', + hostName: 'test-device', + deviceSlug: 'test-device', + routeKey: 'richard-santomauro-nimbletec-com', + publicNamespace: 'richard-santomauro-nimbletec-com', + spaceId: 'space_test', + authorityCoordinator: false, + nats: { + url: 'nats://local.hivecast.ai:4222', + wsUrl: 'wss://local.hivecast.ai/nats-ws', + leafnodeUrl: 'nats://local.hivecast.ai:7422', + }, + natsCredentials: { + jwt: 'jwt', + seed: 'seed', + }, + }); + + const hostConfig = JSON.parse(readFileSync(hostConfigPath, 'utf8')) as { + readonly transport?: { + readonly root?: string; + readonly authorityRoot?: string; + readonly nats?: { + readonly url?: string; + readonly wsUrl?: string; + readonly credentialsRef?: string; + readonly binaryPath?: string; + }; + }; + readonly natsTopology?: { + readonly mode?: string; + readonly clientUrl?: string; + readonly wsUrl?: string; + readonly leafnodes?: readonly { + readonly url?: string; + readonly credentialsRef?: string; + }[]; + }; + }; + const link = JSON.parse(readFileSync(join(matrixHome, 'credentials', 'hivecast-link.json'), 'utf8')) as { + readonly credentials?: { + readonly natsLeafnodeCredentialRef?: string; + }; + }; + const leafCredentials = readFileSync(join(matrixHome, 'credentials', 'hivecast-nats.creds'), 'utf8'); + + assert.equal(hostConfig.transport?.root, 'richard-santomauro-nimbletec-com'); + assert.equal(hostConfig.transport?.authorityRoot, 'richard-santomauro-nimbletec-com'); + assert.equal(hostConfig.transport?.nats?.url, 'nats://127.0.0.1:4222'); + assert.equal(hostConfig.transport?.nats?.wsUrl, 'ws://127.0.0.1:4223'); + assert.equal(hostConfig.transport?.nats?.credentialsRef, undefined); + assert.equal(hostConfig.transport?.nats?.binaryPath, undefined); + assert.equal(hostConfig.natsTopology?.mode, 'leafnode'); + assert.equal(hostConfig.natsTopology?.clientUrl, 'nats://127.0.0.1:4222'); + assert.equal(hostConfig.natsTopology?.wsUrl, 'ws://127.0.0.1:4223'); + assert.equal(hostConfig.natsTopology?.leafnodes?.[0]?.url, 'nats://local.hivecast.ai:7422'); + assert.equal(hostConfig.natsTopology?.leafnodes?.[0]?.credentialsRef, 'file:credentials/hivecast-nats.creds'); + assert.equal(link.credentials?.natsLeafnodeCredentialRef, 'file:credentials/hivecast-nats.creds'); + assert.match(leafCredentials, /-----BEGIN NATS USER JWT-----\njwt\n------END NATS USER JWT------/); + assert.match(leafCredentials, /-----BEGIN USER NKEY SEED-----\nseed\n------END USER NKEY SEED------/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/archive/mx-cli-product-commands/auth.hivecast-device.ts b/archive/mx-cli-product-commands/auth.hivecast-device.ts new file mode 100644 index 0000000..b8b815a --- /dev/null +++ b/archive/mx-cli-product-commands/auth.hivecast-device.ts @@ -0,0 +1,1859 @@ +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { createServer } from 'node:http'; +import { createConnection } from 'node:net'; +import * as os from 'node:os'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + invokeHostControlFromStatus, + type IHostSupervisorHostStatus, +} from '@open-matrix/host-control'; + +import { + readRegistryCredential, + removeRegistryCredential, + saveRegistryCredential, + type IRegistryCredential, +} from '../utils/auth-store.js'; +import { + readHiveCastHostLink, + readHiveCastLocalOwnerTransport, + readHiveCastLinkStatus, + ensureHiveCastInstallIdentity, + removeHiveCastCredentialFiles, + removeHiveCastHostLink, + saveHiveCastHostLink, + type IHiveCastLinkStatus, + type IHiveCastHostLinkRecord, + type IHiveCastNatsConfig, + type IHiveCastNatsCredentials, +} from '../utils/hivecast-link-store.js'; + +export interface IAuthContextOptions { + readonly cwd?: string; + readonly registry?: string; + readonly credentialsPath?: string; + readonly home?: string; +} + +export interface ILoginCommandOptions extends IAuthContextOptions { + readonly username: string; + readonly token: string; + readonly email?: string; + readonly expiresAt?: string; +} + +export interface ILoginCommandResult { + readonly registry: string; + readonly username: string; + readonly credentialsPath: string; +} + +export interface ILogoutCommandResult { + readonly registry: string; + readonly removed: boolean; + readonly credentialsPath: string; +} + +export interface IWhoamiCommandResult { + readonly registry: string; + readonly authenticated: boolean; + readonly username?: string; + readonly email?: string; + readonly expiresAt?: string; +} + +export interface IHiveCastLoginCommandOptions extends IAuthContextOptions { + readonly cloudUrl?: string; + readonly setupCode?: string; + readonly routeKey?: string; + readonly device?: boolean; + readonly hostName?: string; + readonly callbackUrl?: string; + readonly pairNonce?: string; + readonly localPort?: number; + readonly waitForCallback?: boolean; + readonly noOpen?: boolean; + readonly forceRelink?: boolean; + readonly callbackTimeoutMs?: number; + readonly pollTimeoutMs?: number; + readonly openApprovalUrl?: (url: string) => void; + readonly onSetupUrl?: (setupUrl: string) => void; + readonly onDeviceStart?: (challenge: IHiveCastDeviceChallenge) => void; + readonly onProgress?: (event: IHiveCastLoginProgressEvent) => void; +} + +export interface IHiveCastLoginProgressEvent { + readonly phase: 'link' | 'namespace' | 'ready'; + readonly message: string; +} + +export interface IHiveCastLoginCommandResult { + readonly mode: 'approval-required' | 'linked'; + readonly linked: boolean; + readonly alreadyLinked?: boolean; + readonly cloudUrl: string; + readonly setupUrl?: string; + readonly deviceCode?: string; + readonly userCode?: string; + readonly verificationUri?: string; + readonly verificationUriComplete?: string; + readonly expiresIn?: number; + readonly matrixHome?: string; + readonly linkPath?: string; + readonly hostConfigPath?: string; + readonly natsCredentialsPath?: string; + readonly hostLinkId?: string; + readonly hostId?: string; + readonly authorityRoot?: string; + readonly routeKey?: string; + readonly publicNamespace?: string; + readonly spaceId?: string; + readonly hostName?: string; + readonly deviceSlug?: string; + readonly authorityCoordinator?: boolean; + readonly linkedAt?: string; + readonly hostConfigReload?: IHiveCastHostConfigReloadResult; +} + +export interface IHiveCastLogoutCommandResult { + readonly removed: boolean; + readonly cloudRevoke?: IHiveCastCloudRevokeResult; + readonly hostConfigReset: boolean; + readonly matrixHome: string; + readonly linkPath: string; + readonly natsCredentialsPath: string; + readonly hostConfigReload?: IHiveCastHostConfigReloadResult; +} + +export interface IHiveCastRefreshCredentialsCommandResult { + readonly refreshed: boolean; + readonly matrixHome: string; + readonly linkPath: string; + readonly natsCredentialsPath: string; + readonly currentCredentialsExpiresAt?: string; + readonly currentCredentialsSecondsRemaining?: number; + readonly refreshThresholdSeconds?: number; + readonly hostLinkId?: string; + readonly hostId?: string; + readonly authorityRoot?: string; + readonly routeKey?: string; + readonly publicNamespace?: string; + readonly spaceId?: string; + readonly hostName?: string; + readonly deviceSlug?: string; + readonly authorityCoordinator?: boolean; + readonly hostConfigReload?: IHiveCastHostConfigReloadResult; +} + +export interface IHiveCastWhoamiCommandResult { + readonly linked: boolean; + readonly matrixHome: string; + readonly cloudUrl?: string; + readonly hostLinkId?: string; + readonly authorityRoot?: string; + readonly routeKey?: string; + readonly publicNamespace?: string; + readonly spaceId?: string; + readonly principalId?: string; + readonly hostName?: string; + readonly deviceSlug?: string; + readonly linkedAt?: string; +} + +export interface IHiveCastHostConfigReloadResult { + readonly attempted: boolean; + readonly applied: boolean; + readonly statusPath: string; + readonly error?: string; +} + +interface ITokenExchangeResponse { + readonly ok?: unknown; + readonly root?: unknown; + readonly authorityRoot?: unknown; + readonly deviceRegistryRoot?: unknown; + readonly routeKey?: unknown; + readonly publicNamespace?: unknown; + readonly spaceId?: unknown; + readonly principalId?: unknown; + readonly hostName?: unknown; + readonly deviceSlug?: unknown; + readonly authorityCoordinator?: unknown; + readonly hostLinkToken?: unknown; + readonly hostLinkId?: unknown; + readonly hostId?: unknown; + readonly hostLink?: unknown; + readonly device?: unknown; + readonly nats?: unknown; + readonly credentials?: unknown; + readonly error?: unknown; +} + +export interface IHiveCastDeviceChallenge { + readonly deviceCode: string; + readonly userCode: string; + readonly verificationUri: string; + readonly verificationUriComplete?: string; + readonly expiresIn: number; + readonly interval: number; +} + +interface IDeviceStartResponse { + readonly ok?: unknown; + readonly deviceCode?: unknown; + readonly userCode?: unknown; + readonly verificationUri?: unknown; + readonly verificationUriComplete?: unknown; + readonly expiresIn?: unknown; + readonly interval?: unknown; + readonly error?: unknown; +} + +interface IDevicePollResponse { + readonly ok?: unknown; + readonly status?: unknown; + readonly interval?: unknown; + readonly error?: unknown; +} + +interface IPairStartResponse { + readonly ok?: unknown; + readonly pairRequestId?: unknown; + readonly approvalUrl?: unknown; + readonly expiresIn?: unknown; + readonly error?: unknown; +} + +interface INormalizedSetupExchange { + readonly authorityRoot: string; + readonly deviceRegistryRoot?: string; + readonly hostLinkId?: string; + readonly hostId?: string; + readonly routeKey?: string; + readonly publicNamespace?: string; + readonly spaceId?: string; + readonly principalId?: string; + readonly hostName?: string; + readonly deviceSlug?: string; + readonly authorityCoordinator?: boolean; + readonly hostLinkToken?: string; + readonly nats: IHiveCastNatsConfig; + readonly credentials: IHiveCastNatsCredentials; +} + +interface IHiveCastCloudRevokeResult { + readonly attempted: boolean; + readonly revoked: boolean; + readonly skippedReason?: string; + readonly error?: string; +} + +export interface IHiveCastLogoutCommandOptions extends IAuthContextOptions { + readonly localOnly?: boolean; + readonly revokeCloudLink?: boolean; + readonly all?: boolean; +} + +export interface IHiveCastRefreshCredentialsCommandOptions extends IAuthContextOptions { + readonly ifExpiringWithinSeconds?: number; + readonly authorityCoordinator?: boolean; +} + +interface IHostConfigReloadExpectation { + readonly root: string; + readonly authorityRoot?: string; + readonly routeKey?: string | null; + readonly publicNamespace?: string | null; + readonly spaceId?: string | null; + readonly forceReload?: boolean; + readonly label?: 'linked' | 'local-owner'; +} + +function hostConfigExpectationForLink( + matrixHome: string, + record: IHiveCastHostLinkRecord, + forceReload: boolean, +): IHostConfigReloadExpectation { + return { + root: record.authorityRoot, + authorityRoot: record.authorityRoot, + routeKey: record.routeKey ?? null, + publicNamespace: record.publicNamespace ?? null, + spaceId: record.spaceId ?? null, + forceReload, + label: 'linked', + }; +} + +function reportLoginProgress( + options: IHiveCastLoginCommandOptions, + phase: IHiveCastLoginProgressEvent['phase'], + message: string, +): void { + options.onProgress?.({ phase, message }); +} + +function assertRunningHostConfigReloadApplied( + result: IHiveCastHostConfigReloadResult, + expectation: IHostConfigReloadExpectation, +): void { + if (!result.attempted || result.applied) { + return; + } + const expected = [ + `expectedRoot=${expectation.root}`, + ...(expectation.authorityRoot ? [`expectedAuthorityRoot=${expectation.authorityRoot}`] : []), + `statusPath=${result.statusPath}`, + ].join(' '); + throw new Error( + `HIVECAST_LINK_HOST_CONFIG_RELOAD_FAILED: running Host did not apply ${expectation.label ?? 'linked'} config` + + `${result.error ? `: ${result.error}` : ''}; ${expected}`, + ); +} + +function localOwnerTransportForExpectation( + matrixHome: string, +): { readonly root: string; readonly authorityRoot?: string } | null { + const localOwnerTransport = readHiveCastLocalOwnerTransport(process.cwd(), matrixHome); + if (localOwnerTransport) { + return localOwnerTransport; + } + const hostConfig = readJsonRecordFile(path.join(matrixHome, 'host.json')); + const transport = asRecord(hostConfig?.transport); + const root = readOptionalString(transport?.root); + if (!root) { + return null; + } + const authorityRoot = readOptionalString(transport?.authorityRoot); + return { + root, + ...(authorityRoot ? { authorityRoot } : {}), + }; +} + +const DEFAULT_HIVECAST_CLOUD_URL = 'https://hivecast.ai'; +const DEFAULT_NATS_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024; +const HOST_CONFIG_STATUS_APPLY_TIMEOUT_MS = 60_000; +const HOST_CONFIG_RUNTIME_APPLY_MIN_TIMEOUT_MS = 90_000; +const HOST_CONFIG_RUNTIME_APPLY_PER_RUNTIME_MS = 20_000; +const LOCAL_OWNER_NATS_START_TIMEOUT_MS = 10_000; + +function readOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function decodeJwtPayload(token: string): Record | null { + const parts = token.split('.'); + if (parts.length !== 3 || !parts[1]) { + return null; + } + try { + return asRecord(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))); + } catch { + return null; + } +} + +function readJwtExpirationSeconds(token: string): number | null { + const payload = decodeJwtPayload(token); + const exp = payload?.exp; + return typeof exp === 'number' && Number.isFinite(exp) ? exp : null; +} + +function readCredentialsExpirationSeconds(filePath: string): number | null { + if (!fs.existsSync(filePath)) { + return null; + } + let parsed: Record | null; + try { + parsed = asRecord(JSON.parse(fs.readFileSync(filePath, 'utf8'))); + } catch { + return null; + } + const jwt = readOptionalString(parsed?.jwt); + return jwt ? readJwtExpirationSeconds(jwt) : null; +} + +export function normalizeNonNegativeInteger(value: string | undefined, label: string): number | undefined { + if (value === undefined) { + return undefined; + } + if (!/^\d+$/.test(value.trim())) { + throw new Error(`${label} must be a non-negative integer number of seconds`); + } + return Number.parseInt(value, 10); +} + +async function readJsonResponse( + response: Response, + label: string, +): Promise { + const text = await response.text(); + if (!text.trim()) { + throw new Error(`${label}: empty response body (HTTP ${response.status})`); + } + try { + return JSON.parse(text) as T; + } catch (error: unknown) { + const preview = text.slice(0, 240).replace(/\s+/g, ' ').trim(); + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`${label}: invalid JSON response (HTTP ${response.status}): ${reason}${preview ? `: ${preview}` : ''}`); + } +} + +function normalizeCloudUrl(raw: string | undefined): string { + const value = raw?.trim() || process.env.HIVECAST_PLATFORM_URL || DEFAULT_HIVECAST_CLOUD_URL; + const parsed = new URL(value); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error(`HIVECAST_LOGIN_INVALID_CLOUD_URL: unsupported protocol ${parsed.protocol}`); + } + return parsed.origin; +} + +function openBrowser(url: string): void { + const command = process.platform === 'darwin' + ? 'open' + : process.platform === 'win32' + ? 'cmd' + : 'xdg-open'; + const args = process.platform === 'win32' + ? ['/c', 'start', '', url] + : [url]; + const child = spawn(command, args, { + stdio: 'ignore', + detached: true, + windowsHide: true, + }); + child.on('error', () => { + // Best effort: the setup URL is printed by the caller. + }); + child.unref(); +} + +function readJsonRecordFile(filePath: string): Record | null { + if (!fs.existsSync(filePath)) { + return null; + } + try { + return asRecord(JSON.parse(fs.readFileSync(filePath, 'utf8'))); + } catch { + return null; + } +} + +function hostStatusPath(matrixHome: string): string { + return path.join(matrixHome, 'host.status.json'); +} + +function hostRuntimeRecordsDir(matrixHome: string): string { + return path.join(matrixHome, 'runtimes'); +} + +function readHostStatusForSupervisor(statusPath: string): IHostSupervisorHostStatus | null { + const parsed = readJsonRecordFile(statusPath); + if (!parsed || readOptionalString(parsed.status) !== 'running') { + return null; + } + const transport = asRecord(parsed.transport); + const nats = asRecord(transport?.nats); + const root = readOptionalString(transport?.root); + const natsUrl = readOptionalString(nats?.url); + if (!root || !natsUrl) { + return null; + } + const credentialsRef = readOptionalString(nats?.credentialsRef); + const supervisorMount = readOptionalString(parsed.supervisorMount); + return { + ...(supervisorMount ? { supervisorMount } : {}), + transport: { + root, + nats: { + url: natsUrl, + ...(credentialsRef ? { credentialsRef } : {}), + }, + }, + }; +} + +function hostStatusMatchesExpectation( + statusPath: string, + expectation: IHostConfigReloadExpectation, +): boolean { + const parsed = readJsonRecordFile(statusPath); + const transport = asRecord(parsed?.transport); + if (!transport) { + return false; + } + const root = readOptionalString(transport.root); + const authorityRoot = readOptionalString(transport.authorityRoot) ?? root; + const routeKey = readOptionalString(transport.routeKey); + const publicNamespace = readOptionalString(transport.publicNamespace); + const spaceId = readOptionalString(transport.spaceId); + if (root !== expectation.root) { + return false; + } + if (expectation.authorityRoot !== undefined && authorityRoot !== expectation.authorityRoot) { + return false; + } + if (expectation.routeKey !== undefined && routeKey !== (expectation.routeKey ?? undefined)) { + return false; + } + if ( + expectation.publicNamespace !== undefined + && publicNamespace !== (expectation.publicNamespace ?? undefined) + ) { + return false; + } + if (expectation.spaceId !== undefined && spaceId !== (expectation.spaceId ?? undefined)) { + return false; + } + return true; +} + +async function waitForHostStatusExpectation( + statusPath: string, + expectation: IHostConfigReloadExpectation, + timeoutMs = HOST_CONFIG_STATUS_APPLY_TIMEOUT_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (hostStatusMatchesExpectation(statusPath, expectation)) { + return true; + } + await delay(100); + } + return false; +} + +function readHostRuntimeRecords(matrixHome: string): Record[] { + const recordsDir = hostRuntimeRecordsDir(matrixHome); + if (!fs.existsSync(recordsDir)) { + return []; + } + const records: Record[] = []; + for (const entry of fs.readdirSync(recordsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const record = readJsonRecordFile(path.join(recordsDir, entry.name, 'runtime.json')); + if (record) { + records.push(record); + } + } + return records; +} + +function isProcessAliveForRuntime(pid: unknown): boolean { + if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + return asRecord(error)?.code === 'EPERM'; + } +} + +function positivePort(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0 || value > 65535) { + return undefined; + } + return value; +} + +function positiveInteger(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + return undefined; + } + return value; +} + +function loopbackUrlPort(raw: string | undefined): number | undefined { + if (!raw) return undefined; + try { + const parsed = new URL(raw); + if (!['127.0.0.1', 'localhost', '[::1]', '::1'].includes(parsed.hostname)) { + return undefined; + } + const port = Number.parseInt(parsed.port, 10); + return Number.isFinite(port) && port > 0 && port <= 65535 ? port : undefined; + } catch { + return undefined; + } +} + +async function isTcpPortOpen(host: string, port: number): Promise { + return await new Promise((resolve) => { + const socket = createConnection({ host, port }); + const done = (open: boolean): void => { + socket.removeAllListeners(); + socket.destroy(); + resolve(open); + }; + socket.setTimeout(500); + socket.once('connect', () => done(true)); + socket.once('timeout', () => done(false)); + socket.once('error', () => done(false)); + }); +} + +async function waitForTcpPort(host: string, port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (await isTcpPortOpen(host, port)) { + return true; + } + await delay(100); + } + return false; +} + +function resolveHostRelativePath(matrixHome: string, raw: string | undefined, defaultRelative: string): string { + const value = raw ?? defaultRelative; + return path.isAbsolute(value) ? value : path.resolve(matrixHome, value); +} + +function writeLocalOwnerNatsConfig(matrixHome: string, nats: Record): { + readonly binaryPath: string; + readonly confFile: string; + readonly pidFile: string; + readonly dataDir: string; + readonly port: number; + readonly wsPort: number; +} { + const port = positivePort(nats.port) ?? loopbackUrlPort(readOptionalString(nats.url)); + const wsPort = positivePort(nats.wsPort) ?? loopbackUrlPort(readOptionalString(nats.wsUrl)); + const maxPayloadBytes = positiveInteger(nats.maxPayloadBytes) ?? DEFAULT_NATS_MAX_PAYLOAD_BYTES; + if (!port || !wsPort) { + throw new Error('Local owner NATS transport requires loopback client and websocket ports'); + } + const dataDir = resolveHostRelativePath(matrixHome, readOptionalString(nats.dataDir), path.join('nats', 'host-default')); + const pidFile = resolveHostRelativePath( + matrixHome, + readOptionalString(nats.pidFile), + path.join('nats', 'host-default', 'nats-server.pid'), + ); + const binaryPath = resolveHostRelativePath(matrixHome, readOptionalString(nats.binaryPath), path.join('bin', 'nats-server')); + const confFile = path.join(dataDir, 'nats-server.conf'); + fs.mkdirSync(dataDir, { recursive: true }); + const escapedStoreDir = dataDir.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + fs.writeFileSync(confFile, [ + 'host: "127.0.0.1"', + `port: ${port}`, + `max_payload: ${maxPayloadBytes}`, + 'jetstream {', + ` store_dir: "${escapedStoreDir}"`, + '}', + 'websocket {', + ' host: "127.0.0.1"', + ` port: ${wsPort}`, + ' no_tls: true', + '}', + '', + ].join('\n'), 'utf8'); + return { binaryPath, confFile, pidFile, dataDir, port, wsPort }; +} + +async function ensureLocalOwnerNatsDependency(matrixHome: string): Promise { + const config = readJsonRecordFile(path.join(matrixHome, 'host.json')); + const transport = asRecord(config?.transport); + const nats = asRecord(transport?.nats); + if (!transport || !nats) return; + const root = readOptionalString(transport.root); + const mode = readOptionalString(nats.mode); + const credentialsRef = readOptionalString(nats.credentialsRef); + if (!root || mode !== 'external' || credentialsRef) { + return; + } + const port = positivePort(nats.port) ?? loopbackUrlPort(readOptionalString(nats.url)); + const wsPort = positivePort(nats.wsPort) ?? loopbackUrlPort(readOptionalString(nats.wsUrl)); + if (!port || !wsPort) return; + if (await isTcpPortOpen('127.0.0.1', port)) { + return; + } + const paths = writeLocalOwnerNatsConfig(matrixHome, nats); + if (!fs.existsSync(paths.binaryPath)) { + throw new Error(`Local owner NATS binary is missing: ${paths.binaryPath}`); + } + if (fs.existsSync(paths.pidFile)) { + const pid = Number.parseInt(fs.readFileSync(paths.pidFile, 'utf8').trim(), 10); + if (Number.isInteger(pid) && pid > 0 && isProcessAliveForRuntime(pid)) { + throw new Error(`Local owner NATS pid ${pid} is alive but port ${port} is not reachable`); + } + fs.rmSync(paths.pidFile, { force: true }); + } + fs.mkdirSync(path.dirname(paths.pidFile), { recursive: true }); + fs.mkdirSync(path.join(matrixHome, 'logs'), { recursive: true }); + const stdout = fs.openSync(path.join(matrixHome, 'logs', 'nats.stdout.log'), 'a'); + const stderr = fs.openSync(path.join(matrixHome, 'logs', 'nats.stderr.log'), 'a'); + const child = spawn(paths.binaryPath, ['-c', paths.confFile], { + detached: true, + stdio: ['ignore', stdout, stderr], + windowsHide: true, + }); + if (!child.pid) { + throw new Error('Local owner NATS did not provide a process id'); + } + fs.writeFileSync(paths.pidFile, `${child.pid}\n`, 'utf8'); + child.unref(); + const started = await waitForTcpPort('127.0.0.1', port, LOCAL_OWNER_NATS_START_TIMEOUT_MS); + if (!started) { + throw new Error(`Local owner NATS pid ${child.pid} did not open port ${port} within ${LOCAL_OWNER_NATS_START_TIMEOUT_MS}ms`); + } +} + +function runtimeRecordMatchesExpectation( + record: Record, + expectation: IHostConfigReloadExpectation, +): boolean { + const metadata = asRecord(record.metadata); + const transport = asRecord(metadata?.transport); + const root = readOptionalString(transport?.root) ?? readOptionalString(record.runtimeWireRoot); + const authorityRoot = readOptionalString(transport?.authorityRoot) ?? root; + const routeKey = readOptionalString(transport?.routeKey); + const publicNamespace = readOptionalString(transport?.publicNamespace); + const spaceId = readOptionalString(transport?.spaceId); + if (root !== expectation.root) { + return false; + } + if (expectation.authorityRoot !== undefined && authorityRoot !== expectation.authorityRoot) { + return false; + } + if (expectation.routeKey !== undefined && routeKey !== (expectation.routeKey ?? undefined)) { + return false; + } + if ( + expectation.publicNamespace !== undefined + && publicNamespace !== (expectation.publicNamespace ?? undefined) + ) { + return false; + } + if (expectation.spaceId !== undefined && spaceId !== (expectation.spaceId ?? undefined)) { + return false; + } + return true; +} + +function activeDefaultRuntimeRecordsReady( + matrixHome: string, + expectation: IHostConfigReloadExpectation, +): boolean { + const activeDefaultRecords = readActiveDefaultRuntimeRecords(matrixHome); + if (activeDefaultRecords.length === 0) { + return true; + } + return activeDefaultRecords.every((record) => ( + record.status === 'running' + && isProcessAliveForRuntime(record.pid) + && runtimeRecordMatchesExpectation(record, expectation) + )); +} + +function readActiveDefaultRuntimeRecords(matrixHome: string): Record[] { + return readHostRuntimeRecords(matrixHome).filter((record) => { + const metadata = asRecord(record.metadata); + return metadata?.hivecastDefaultRuntimePolicy === 'active' && record.startup === 'auto'; + }); +} + +function hostConfigRuntimeApplyTimeoutMs(matrixHome: string): number { + const activeRuntimeCount = readActiveDefaultRuntimeRecords(matrixHome).length; + if (activeRuntimeCount === 0) { + return HOST_CONFIG_RUNTIME_APPLY_MIN_TIMEOUT_MS; + } + // Host Service starts persisted runtimes in dependency order and waits for + // each control actor. The CLI reload waiter must scale with that contract. + return Math.max( + HOST_CONFIG_RUNTIME_APPLY_MIN_TIMEOUT_MS, + activeRuntimeCount * HOST_CONFIG_RUNTIME_APPLY_PER_RUNTIME_MS, + ); +} + +async function waitForActiveDefaultRuntimeRecords( + matrixHome: string, + expectation: IHostConfigReloadExpectation, + timeoutMs = hostConfigRuntimeApplyTimeoutMs(matrixHome), +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (activeDefaultRuntimeRecordsReady(matrixHome, expectation)) { + return true; + } + await delay(100); + } + return false; +} + +async function activeDefaultWebappOriginsReady(matrixHome: string): Promise { + const webappOrigins = readActiveDefaultRuntimeRecords(matrixHome) + .map((record) => readOptionalString(asRecord(asRecord(record.metadata)?.webapp)?.origin)) + .filter((origin): origin is string => origin !== undefined); + if (webappOrigins.length === 0) { + return true; + } + const probes = await Promise.all(webappOrigins.map((origin) => webappOriginReady(origin))); + return probes.every(Boolean); +} + +async function activeDefaultHostWebappRoutesReady(matrixHome: string, statusPath: string): Promise { + const hostOrigin = readHostHttpOrigin(statusPath); + if (!hostOrigin) { + return false; + } + const routePrefixes = readActiveDefaultRuntimeRecords(matrixHome) + .map((record) => readOptionalString(asRecord(asRecord(record.metadata)?.webapp)?.routePrefix)) + .filter((routePrefix): routePrefix is string => routePrefix !== undefined); + if (routePrefixes.length === 0) { + return true; + } + const probes = await Promise.all(routePrefixes.map((routePrefix) => { + const routeUrl = new URL(routePrefix, hostOrigin); + return webappOriginReady(routeUrl.href); + })); + return probes.every(Boolean); +} + +async function waitForActiveDefaultWebappOrigins( + matrixHome: string, + statusPath: string, + timeoutMs = hostConfigRuntimeApplyTimeoutMs(matrixHome), +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if ( + await activeDefaultWebappOriginsReady(matrixHome) + && await activeDefaultHostWebappRoutesReady(matrixHome, statusPath) + ) { + return true; + } + await delay(100); + } + return false; +} + +async function describeActiveDefaultWebappReadiness(matrixHome: string, statusPath: string): Promise { + const hostOrigin = readHostHttpOrigin(statusPath); + const records = readActiveDefaultRuntimeRecords(matrixHome) + .map((record) => { + const webapp = asRecord(asRecord(record.metadata)?.webapp); + return { + runtimeId: readOptionalString(record.runtimeId) ?? 'unknown-runtime', + routePrefix: readOptionalString(webapp?.routePrefix), + origin: readOptionalString(webapp?.origin), + }; + }) + .filter((record) => record.routePrefix || record.origin); + const lines: string[] = []; + for (const record of records) { + const direct = record.origin + ? await describeWebappUrl(record.origin) + : 'direct=missing'; + const routed = hostOrigin && record.routePrefix + ? await describeWebappUrl(new URL(record.routePrefix, hostOrigin).href) + : 'routed=missing'; + lines.push(`${record.runtimeId} route=${record.routePrefix ?? 'none'} ${direct} ${routed}`); + } + return lines.slice(0, 12).join('; '); +} + +async function describeWebappUrl(url: string): Promise { + try { + const response = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(1_500) }); + const body = await response.text(); + const assetUrls = webappAssetUrls(new URL(url), body); + const firstAsset = assetUrls[0]; + if (!firstAsset) { + return `${url} html=${response.status} asset=none`; + } + try { + const assetResponse = await fetch(firstAsset, { method: 'GET', signal: AbortSignal.timeout(1_500) }); + await assetResponse.arrayBuffer(); + return `${url} html=${response.status} asset=${assetResponse.status} assetUrl=${firstAsset.href}`; + } catch (error) { + return `${url} html=${response.status} asset=fetch-failed:${error instanceof Error ? error.message : String(error)}`; + } + } catch (error) { + return `${url} html=fetch-failed:${error instanceof Error ? error.message : String(error)}`; + } +} + +function readHostHttpOrigin(statusPath: string): string | undefined { + const parsed = readJsonRecordFile(statusPath); + const http = asRecord(parsed?.http); + return readOptionalString(http?.origin); +} + +async function webappOriginReady(origin: string): Promise { + let url: URL; + try { + url = new URL(origin); + } catch { + return false; + } + if (!['http:', 'https:'].includes(url.protocol)) { + return false; + } + try { + const response = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(1_500) }); + const body = await response.text(); + if (response.status < 200 || response.status >= 500) { + return false; + } + const assetUrls = webappAssetUrls(url, body); + if (assetUrls.length === 0) { + return true; + } + const assetResponses = await Promise.all(assetUrls.map(async (assetUrl) => { + try { + const assetResponse = await fetch(assetUrl, { method: 'GET', signal: AbortSignal.timeout(1_500) }); + await assetResponse.arrayBuffer(); + return assetResponse.status >= 200 && assetResponse.status < 500; + } catch { + return false; + } + })); + return assetResponses.every(Boolean); + } catch { + return false; + } +} + +function webappAssetUrls(origin: URL, body: string): URL[] { + const urls: URL[] = []; + const seen = new Set(); + const assetPattern = /\b(?:src|href)=["']([^"']*assets\/[^"']+)["']/g; + let match = assetPattern.exec(body); + while (match) { + const raw = match[1]; + if (raw) { + try { + const url = new URL(raw, origin); + if (url.origin === origin.origin && !seen.has(url.href)) { + seen.add(url.href); + urls.push(url); + } + } catch { + // Ignore malformed asset references in readiness probing. + } + } + match = assetPattern.exec(body); + } + return urls.slice(0, 8); +} + +async function reloadRunningHostConfig( + matrixHome: string, + expectation: IHostConfigReloadExpectation, +): Promise { + const statusPath = hostStatusPath(matrixHome); + if ( + !expectation.forceReload + && hostStatusMatchesExpectation(statusPath, expectation) + && activeDefaultRuntimeRecordsReady(matrixHome, expectation) + ) { + return { attempted: false, applied: true, statusPath }; + } + const status = readHostStatusForSupervisor(statusPath); + if (!status) { + return { attempted: false, applied: false, statusPath }; + } + try { + await invokeHostControlFromStatus(status, 'config.reload', {}, 5_000); + const statusApplied = await waitForHostStatusExpectation(statusPath, expectation); + const applied = statusApplied; + const expectationLabel = expectation.label ?? 'linked'; + return { + attempted: true, + applied, + statusPath, + ...(applied ? {} : { error: statusApplied + ? `Running Host applied ${expectationLabel} config but did not report success` + : `Timed out waiting for running Host to apply ${expectationLabel} config` }), + }; + } catch (error) { + return { + attempted: true, + applied: false, + statusPath, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function waitForLoopbackSetupCode( + cloudUrl: string, + options: IHiveCastLoginCommandOptions, +): Promise { + const timeoutMs = options.callbackTimeoutMs ?? 10 * 60 * 1000; + const pairNonce = randomBytes(18).toString('base64url'); + return await new Promise((resolve, reject) => { + let settled = false; + const server = createServer((req, res) => { + void (async () => { + const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (requestUrl.pathname !== '/auth/callback') { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + return; + } + const pairRequestId = readOptionalString(requestUrl.searchParams.get('pairRequestId')); + const approvalCode = readOptionalString(requestUrl.searchParams.get('approvalCode')); + const nonce = readOptionalString(requestUrl.searchParams.get('nonce')); + if (!pairRequestId || !approvalCode) { + res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }); + res.end('

HiveCast login failed

Missing approval code.

'); + return; + } + if (nonce !== pairNonce) { + res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }); + res.end('

HiveCast login failed

Invalid approval nonce.

'); + return; + } + const exchange = await exchangePairApprovalWithRequiredCodes(cloudUrl, pairRequestId, approvalCode); + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end('

HiveCast connected

You can return to the terminal.

'); + settled = true; + clearTimeout(timer); + resolve(exchange); + server.close(); + })().catch((error: unknown) => { + if (!settled) { + settled = true; + clearTimeout(timer); + if (!res.headersSent) { + res.writeHead(500, { 'content-type': 'text/html; charset=utf-8' }); + res.end('

HiveCast login failed

Pairing exchange failed.

'); + } + server.close(); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + }); + const timer = setTimeout(() => { + if (settled) return; + settled = true; + server.close(); + reject(new Error('HIVECAST_LOGIN_TIMEOUT: timed out waiting for browser approval')); + }, timeoutMs); + server.once('error', (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(err); + }); + server.listen(options.localPort ?? 0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address !== 'object') { + clearTimeout(timer); + settled = true; + server.close(); + reject(new Error('HIVECAST_LOGIN_CALLBACK_FAILED: failed to bind loopback callback')); + return; + } + void startPairFlow(cloudUrl, { + ...options, + callbackUrl: `http://127.0.0.1:${address.port}/auth/callback`, + pairNonce, + }).then((pair) => { + options.onSetupUrl?.(pair.approvalUrl); + if (!options.noOpen) { + (options.openApprovalUrl ?? openBrowser)(pair.approvalUrl); + } + }).catch((error: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timer); + server.close(); + reject(error instanceof Error ? error : new Error(String(error))); + }); + }); + }); +} + +function normalizeSetupExchange(value: ITokenExchangeResponse): INormalizedSetupExchange { + const authorityRoot = readOptionalString(value.authorityRoot) ?? readOptionalString(value.root); + if (!authorityRoot) { + throw new Error('HIVECAST_LOGIN_INVALID_EXCHANGE: response is missing authority root'); + } + const nats = asRecord(value.nats); + const natsUrl = readOptionalString(nats?.url); + if (!natsUrl) { + throw new Error('HIVECAST_LOGIN_INVALID_EXCHANGE: response is missing nats.url'); + } + const credentials = asRecord(value.credentials); + const jwt = readOptionalString(credentials?.jwt); + const seed = readOptionalString(credentials?.seed); + if (!jwt || !seed) { + throw new Error('HIVECAST_LOGIN_INVALID_EXCHANGE: credentials must contain device-scoped jwt+seed'); + } + const wsUrl = readOptionalString(nats?.wsUrl); + const leafnodeUrl = readOptionalString(nats?.leafnodeUrl); + const routeKey = readOptionalString(value.routeKey); + const deviceRegistryRoot = readOptionalString(value.deviceRegistryRoot); + const publicNamespace = readOptionalString(value.publicNamespace); + const spaceId = readOptionalString(value.spaceId); + const principalId = readOptionalString(value.principalId); + const hostLink = asRecord(value.hostLink); + const device = asRecord(value.device); + const hostLinkId = readOptionalString(value.hostLinkId) + ?? readOptionalString(hostLink?.id); + const hostId = readOptionalString(value.hostId) + ?? readOptionalString(hostLink?.hostId) + ?? readOptionalString(device?.deviceCode); + const hostName = readOptionalString(value.hostName) + ?? readOptionalString(hostLink?.hostName); + const deviceSlug = readOptionalString(value.deviceSlug) + ?? readOptionalString(hostLink?.deviceSlug); + const authorityCoordinator = typeof value.authorityCoordinator === 'boolean' + ? value.authorityCoordinator + : typeof hostLink?.authorityCoordinator === 'boolean' + ? hostLink.authorityCoordinator + : undefined; + const hostLinkToken = readOptionalString(value.hostLinkToken) + ?? readOptionalString(hostLink?.heartbeatToken) + ?? readOptionalString(hostLink?.hostLinkToken); + return { + authorityRoot, + ...(deviceRegistryRoot ? { deviceRegistryRoot } : {}), + ...(hostLinkId ? { hostLinkId } : {}), + ...(hostId ? { hostId } : {}), + ...(routeKey ? { routeKey } : {}), + ...(publicNamespace ? { publicNamespace } : {}), + ...(spaceId ? { spaceId } : {}), + ...(principalId ? { principalId } : {}), + ...(hostName ? { hostName } : {}), + ...(deviceSlug ? { deviceSlug } : {}), + ...(typeof authorityCoordinator === 'boolean' ? { authorityCoordinator } : {}), + ...(hostLinkToken ? { hostLinkToken } : {}), + nats: { + url: natsUrl, + ...(wsUrl ? { wsUrl } : {}), + ...(leafnodeUrl ? { leafnodeUrl } : {}), + }, + credentials: { jwt, seed }, + }; +} + +async function exchangeSetupCode(cloudUrl: string, code: string): Promise { + const response = await fetch(`${cloudUrl}/api/token-exchange`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code }), + }); + const parsed = await readJsonResponse(response, 'HiveCast setup exchange'); + if (!response.ok || parsed.ok !== true) { + const message = readOptionalString(parsed.error) ?? `HiveCast setup exchange failed: HTTP ${response.status}`; + throw new Error(`HIVECAST_LOGIN_EXCHANGE_FAILED: ${message}`); + } + return normalizeSetupExchange(parsed); +} + +async function exchangePairApproval( + cloudUrl: string, + pairRequestId: string, + approvalCode: string, +): Promise { + const response = await fetch(`${cloudUrl}/_auth/pair/exchange`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ pairRequestId, approvalCode }), + }); + const parsed = await readJsonResponse(response, 'HiveCast pair exchange'); + if (!response.ok || parsed.ok !== true) { + const message = readOptionalString(parsed.error) ?? `HiveCast pair exchange failed: HTTP ${response.status}`; + throw new Error(`HIVECAST_PAIR_EXCHANGE_FAILED: ${message}`); + } + return normalizeSetupExchange(parsed); +} + +async function exchangePairApprovalWithRequiredCodes( + cloudUrl: string, + pairRequestId: string | undefined, + approvalCode: string | undefined, +): Promise { + if (!pairRequestId || !approvalCode) { + throw new Error('HIVECAST_PAIR_APPROVAL_REQUIRED: missing pair approval code'); + } + return await exchangePairApproval(cloudUrl, pairRequestId, approvalCode); +} + +async function startPairFlow( + cloudUrl: string, + options: IHiveCastLoginCommandOptions, +): Promise<{ readonly pairRequestId: string; readonly approvalUrl: string; readonly expiresIn: number }> { + const localReturnUrl = readOptionalString(options.callbackUrl); + const nonce = readOptionalString(options.pairNonce); + const routeKey = readOptionalString(options.routeKey); + const requestedHostName = readOptionalString(options.hostName) ?? os.hostname(); + const { identity } = ensureHiveCastInstallIdentity({ + cwd: options.cwd ?? process.cwd(), + matrixHome: options.home, + hostName: requestedHostName, + }); + const response = await fetch(`${cloudUrl}/_auth/pair/start`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + hostId: identity.installId, + hostName: identity.hostName ?? requestedHostName, + authorityCoordinator: false, + ...(localReturnUrl ? { localReturnUrl } : {}), + ...(nonce ? { nonce } : {}), + ...(routeKey ? { routeKey } : {}), + }), + }); + const parsed = await readJsonResponse(response, 'HiveCast pair start'); + if (!response.ok || parsed.ok !== true) { + const message = readOptionalString(parsed.error) ?? `HiveCast pairing start failed: HTTP ${response.status}`; + throw new Error(`HIVECAST_PAIR_START_FAILED: ${message}`); + } + const pairRequestId = readOptionalString(parsed.pairRequestId); + const approvalUrl = readOptionalString(parsed.approvalUrl); + if (!pairRequestId || !approvalUrl) { + throw new Error('HIVECAST_PAIR_INVALID_START: response is missing pairing challenge fields'); + } + const expiresIn = typeof parsed.expiresIn === 'number' && Number.isFinite(parsed.expiresIn) + ? parsed.expiresIn + : 180 * 24 * 60 * 60; + return { pairRequestId, approvalUrl, expiresIn }; +} + +function normalizeDeviceChallenge(value: IDeviceStartResponse): IHiveCastDeviceChallenge { + const deviceCode = readOptionalString(value.deviceCode); + const userCode = readOptionalString(value.userCode); + const verificationUri = readOptionalString(value.verificationUri); + const verificationUriComplete = readOptionalString(value.verificationUriComplete); + const expiresIn = typeof value.expiresIn === 'number' && Number.isFinite(value.expiresIn) + ? value.expiresIn + : 180 * 24 * 60 * 60; + const interval = typeof value.interval === 'number' && Number.isFinite(value.interval) && value.interval > 0 + ? value.interval + : 2; + if (!deviceCode || !userCode || !verificationUri) { + throw new Error('HIVECAST_DEVICE_INVALID_START: response is missing device challenge fields'); + } + return { + deviceCode, + userCode, + verificationUri, + ...(verificationUriComplete ? { verificationUriComplete } : {}), + expiresIn, + interval, + }; +} + +async function startDeviceFlow( + cloudUrl: string, + options: IHiveCastLoginCommandOptions, +): Promise { + const requestedHostName = readOptionalString(options.hostName) ?? os.hostname(); + const { identity } = ensureHiveCastInstallIdentity({ + cwd: options.cwd ?? process.cwd(), + matrixHome: options.home, + hostName: requestedHostName, + }); + let lastError: unknown; + for (let attempt = 1; attempt <= 4; attempt += 1) { + try { + const response = await fetch(`${cloudUrl}/_auth/device/start`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + hostId: identity.installId, + hostName: identity.hostName ?? requestedHostName, + authorityCoordinator: false, + ...(readOptionalString(options.routeKey) ? { routeKey: readOptionalString(options.routeKey) } : {}), + }), + }); + const parsed = await readJsonResponse(response, 'HiveCast device start'); + if (!response.ok || parsed.ok !== true) { + const message = readOptionalString(parsed.error) ?? `HiveCast device start failed: HTTP ${response.status}`; + throw new Error(`HIVECAST_DEVICE_START_FAILED: ${message}`); + } + return normalizeDeviceChallenge(parsed); + } catch (error: unknown) { + lastError = error; + const message = error instanceof Error ? error.message : String(error); + if (!isRetryableDeviceStartError(message) || attempt === 4) { + break; + } + await delay(attempt * 500); + } + } + const message = lastError instanceof Error ? lastError.message : String(lastError ?? 'unknown error'); + throw message.startsWith('HIVECAST_DEVICE_START_FAILED:') + ? new Error(message) + : new Error(`HIVECAST_DEVICE_START_FAILED: ${message}`); +} + +function isRetryableDeviceStartError(message: string): boolean { + return /\bHTTP (502|503|504)\b/i.test(message) + || /empty response body/i.test(message) + || /fetch failed|network|timeout/i.test(message); +} + +async function pollDeviceApproval( + cloudUrl: string, + challenge: IHiveCastDeviceChallenge, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let intervalMs = Math.max(1, challenge.interval) * 1000; + while (Date.now() < deadline) { + const response = await fetch(`${cloudUrl}/_auth/device/poll`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ deviceCode: challenge.deviceCode }), + }); + let parsed: IDevicePollResponse; + try { + parsed = await readJsonResponse(response, 'HiveCast device poll'); + } catch (error: unknown) { + const remainingMs = deadline - Date.now(); + if (response.ok && remainingMs > 1_000) { + await delay(Math.min(1_000, remainingMs)); + continue; + } + throw error; + } + const status = readOptionalString(parsed.status); + if ((!response.ok || parsed.ok !== true) && status !== 'namespace_required') { + const message = readOptionalString(parsed.error) ?? `HiveCast device poll failed: HTTP ${response.status}`; + throw new Error(`HIVECAST_DEVICE_POLL_FAILED: ${message}`); + } + if (status === 'approved') { + return; + } + if (status !== 'authorization_pending' && status !== 'namespace_required') { + throw new Error(`HIVECAST_DEVICE_POLL_FAILED: unexpected status ${status ?? 'unknown'}`); + } + if (typeof parsed.interval === 'number' && Number.isFinite(parsed.interval) && parsed.interval > 0) { + intervalMs = parsed.interval * 1000; + } + await delay(Math.min(intervalMs, Math.max(0, deadline - Date.now()))); + } + throw new Error('HIVECAST_DEVICE_TIMEOUT: timed out waiting for device approval'); +} + +async function exchangeDeviceCode( + cloudUrl: string, + deviceCode: string, +): Promise { + const response = await fetch(`${cloudUrl}/_auth/device/exchange`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ deviceCode }), + }); + const parsed = await readJsonResponse(response, 'HiveCast device exchange'); + if (!response.ok || parsed.ok !== true) { + const message = readOptionalString(parsed.error) ?? `HiveCast device exchange failed: HTTP ${response.status}`; + throw new Error(`HIVECAST_DEVICE_EXCHANGE_FAILED: ${message}`); + } + return normalizeSetupExchange(parsed); +} + +function deviceChallengeTimeoutMs(challenge: IHiveCastDeviceChallenge): number { + if (Number.isFinite(challenge.expiresIn) && challenge.expiresIn > 0) { + return (challenge.expiresIn * 1000) + 5_000; + } + return 180 * 24 * 60 * 60 * 1000; +} + +async function delay(ms: number): Promise { + if (ms <= 0) { + return; + } + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export async function loginCommand(options: ILoginCommandOptions): Promise { + if (!options.username.trim()) { + throw new Error('MX_AUTH_REQUIRED: username is required for login'); + } + if (!options.token.trim()) { + throw new Error('MX_AUTH_REQUIRED: token is required for login'); + } + + const cwd = options.cwd ?? process.cwd(); + const credential: IRegistryCredential = { + username: options.username.trim(), + token: options.token.trim(), + email: options.email?.trim() || undefined, + expiresAt: options.expiresAt?.trim() || undefined, + }; + + const { registry, credentialsPath } = saveRegistryCredential(cwd, credential, { + registry: options.registry, + credentialsPath: options.credentialsPath, + }); + + return { + registry, + username: credential.username, + credentialsPath, + }; +} + +export async function logoutCommand(options: IAuthContextOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + return removeRegistryCredential(cwd, { + registry: options.registry, + credentialsPath: options.credentialsPath, + }); +} + +export async function whoamiCommand(options: IAuthContextOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const { registry, credential } = readRegistryCredential(cwd, { + registry: options.registry, + credentialsPath: options.credentialsPath, + }); + + if (!credential) { + return { registry, authenticated: false }; + } + + return { + registry, + authenticated: true, + username: credential.username, + email: credential.email, + expiresAt: credential.expiresAt, + }; +} + +export async function hiveCastLoginCommand( + options: IHiveCastLoginCommandOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cloudUrl = normalizeCloudUrl(options.cloudUrl); + const existing = readHiveCastHostLink(cwd, options.home); + if (existing.record && options.forceRelink !== true) { + let activeRecord = existing.record; + let activePaths = existing.paths; + reportLoginProgress(options, 'link', `existing Device link found for ${activeRecord.authorityRoot}`); + const requestedRouteKey = readOptionalString(options.routeKey); + if (normalizeCloudUrl(activeRecord.cloudUrl) !== cloudUrl) { + throw new Error( + `HIVECAST_LINK_CLOUD_MISMATCH: Host is already linked to ${activeRecord.cloudUrl}; use --relink to replace it`, + ); + } + if (requestedRouteKey && activeRecord.routeKey !== requestedRouteKey) { + throw new Error( + `HIVECAST_LINK_ROUTE_MISMATCH: Host is already linked to route key ${activeRecord.routeKey ?? '(none)'}; use --relink to replace it`, + ); + } + if (options.device === true) { + reportLoginProgress(options, 'link', 'refreshing linked Device credentials'); + await hiveCastRefreshCredentialsCommand({ + cwd, + home: options.home, + authorityCoordinator: false, + }); + const refreshed = readHiveCastHostLink(cwd, options.home); + if (refreshed.record) { + activeRecord = refreshed.record; + activePaths = refreshed.paths; + } + } + const expectation = hostConfigExpectationForLink(activePaths.matrixHome, activeRecord, false); + reportLoginProgress(options, 'namespace', `applying authority root ${expectation.root}`); + const hostConfigReload = await reloadRunningHostConfig(activePaths.matrixHome, expectation); + assertRunningHostConfigReloadApplied(hostConfigReload, expectation); + reportLoginProgress( + options, + 'namespace', + hostConfigReload.applied ? `Host config converged at ${expectation.root}` : 'no running Host config to reload', + ); + return { + mode: 'linked', + linked: true, + alreadyLinked: true, + cloudUrl: activeRecord.cloudUrl, + matrixHome: activePaths.matrixHome, + linkPath: activePaths.linkPath, + hostConfigPath: activePaths.hostConfigPath, + natsCredentialsPath: activePaths.natsCredentialsPath, + ...(activeRecord.hostLinkId ? { hostLinkId: activeRecord.hostLinkId } : {}), + ...(activeRecord.hostId ? { hostId: activeRecord.hostId } : {}), + authorityRoot: activeRecord.authorityRoot, + ...(activeRecord.routeKey ? { routeKey: activeRecord.routeKey } : {}), + ...(activeRecord.publicNamespace ? { publicNamespace: activeRecord.publicNamespace } : {}), + ...(activeRecord.spaceId ? { spaceId: activeRecord.spaceId } : {}), + ...(typeof activeRecord.authorityCoordinator === 'boolean' + ? { authorityCoordinator: activeRecord.authorityCoordinator } + : {}), + linkedAt: activeRecord.linkedAt, + hostConfigReload, + }; + } + let exchange: INormalizedSetupExchange | null = null; + if (options.device) { + reportLoginProgress(options, 'link', `starting device approval with ${cloudUrl}`); + const challenge = await startDeviceFlow(cloudUrl, options); + options.onDeviceStart?.(challenge); + if (options.waitForCallback === false) { + return { + mode: 'approval-required', + linked: false, + cloudUrl, + setupUrl: challenge.verificationUriComplete ?? challenge.verificationUri, + deviceCode: challenge.deviceCode, + userCode: challenge.userCode, + verificationUri: challenge.verificationUri, + ...(challenge.verificationUriComplete ? { verificationUriComplete: challenge.verificationUriComplete } : {}), + expiresIn: challenge.expiresIn, + }; + } + reportLoginProgress(options, 'link', `waiting for approval code ${challenge.userCode}`); + await pollDeviceApproval( + cloudUrl, + challenge, + options.pollTimeoutMs ?? options.callbackTimeoutMs ?? deviceChallengeTimeoutMs(challenge), + ); + reportLoginProgress(options, 'link', 'approved'); + reportLoginProgress(options, 'link', 'exchanging device credentials'); + exchange = await exchangeDeviceCode(cloudUrl, challenge.deviceCode); + } + const setupCode = readOptionalString(options.setupCode); + if (!exchange && setupCode) { + exchange = await exchangeSetupCode(cloudUrl, setupCode); + } + if (!exchange && options.waitForCallback) { + exchange = await waitForLoopbackSetupCode(cloudUrl, options); + } + if (!exchange) { + const pair = await startPairFlow(cloudUrl, options); + return { + mode: 'approval-required', + linked: false, + cloudUrl, + setupUrl: pair.approvalUrl, + }; + } + const routeKey = readOptionalString(options.routeKey) ?? exchange.routeKey; + const publicNamespace = exchange.publicNamespace ?? (routeKey ? `space.${routeKey}` : undefined); + const requestedHostName = readOptionalString(options.hostName); + const { identity: installIdentity } = ensureHiveCastInstallIdentity({ + cwd, + matrixHome: options.home, + ...(requestedHostName ? { hostName: requestedHostName } : {}), + }); + const hostName = installIdentity.hostName ?? exchange.hostName ?? requestedHostName; + const { record, paths } = saveHiveCastHostLink({ + cwd, + matrixHome: options.home, + cloudUrl, + ...(exchange.deviceRegistryRoot ? { deviceRegistryRoot: exchange.deviceRegistryRoot } : {}), + authorityRoot: exchange.authorityRoot, + nats: exchange.nats, + natsCredentials: exchange.credentials, + ...(exchange.hostLinkToken ? { hostLinkToken: exchange.hostLinkToken } : {}), + ...(exchange.hostLinkId ? { hostLinkId: exchange.hostLinkId } : {}), + ...(exchange.hostId ? { hostId: exchange.hostId } : {}), + ...(exchange.principalId ? { principalId: exchange.principalId } : {}), + ...(hostName ? { hostName } : {}), + ...(exchange.deviceSlug ? { deviceSlug: exchange.deviceSlug } : {}), + ...(typeof exchange.authorityCoordinator === 'boolean' ? { authorityCoordinator: exchange.authorityCoordinator } : {}), + ...(exchange.spaceId ? { spaceId: exchange.spaceId } : {}), + ...(routeKey ? { routeKey } : {}), + ...(publicNamespace ? { publicNamespace } : {}), + }); + reportLoginProgress(options, 'link', `writing credentials: ${paths.linkPath}`); + const expectation = hostConfigExpectationForLink(paths.matrixHome, record, true); + reportLoginProgress(options, 'namespace', `applying authority root ${expectation.root}`); + const hostConfigReload = await reloadRunningHostConfig(paths.matrixHome, expectation); + assertRunningHostConfigReloadApplied(hostConfigReload, expectation); + reportLoginProgress( + options, + 'namespace', + hostConfigReload.applied ? `Host config converged at ${expectation.root}` : 'no running Host config to reload', + ); + reportLoginProgress(options, 'ready', `Device link persisted for ${record.authorityRoot}`); + + return { + mode: 'linked', + linked: true, + cloudUrl, + matrixHome: paths.matrixHome, + linkPath: paths.linkPath, + hostConfigPath: paths.hostConfigPath, + natsCredentialsPath: paths.natsCredentialsPath, + ...(record.hostLinkId ? { hostLinkId: record.hostLinkId } : {}), + ...(record.hostId ? { hostId: record.hostId } : {}), + authorityRoot: record.authorityRoot, + ...(record.routeKey ? { routeKey: record.routeKey } : {}), + ...(record.publicNamespace ? { publicNamespace: record.publicNamespace } : {}), + ...(record.spaceId ? { spaceId: record.spaceId } : {}), + ...(typeof record.authorityCoordinator === 'boolean' ? { authorityCoordinator: record.authorityCoordinator } : {}), + ...(record.hostName ? { hostName: record.hostName } : {}), + ...(record.deviceSlug ? { deviceSlug: record.deviceSlug } : {}), + linkedAt: record.linkedAt, + hostConfigReload, + }; +} + +async function revokeHiveCastCloudHostLink( + existing: ReturnType, +): Promise { + const { record, paths } = existing; + if (!record) { + return { attempted: false, revoked: false, skippedReason: 'not-linked' }; + } + if (!record.hostLinkId || !record.hostId) { + return { attempted: false, revoked: false, skippedReason: 'missing-host-link-id-or-host-id' }; + } + const hostLinkToken = fs.existsSync(paths.hostLinkTokenPath) + ? fs.readFileSync(paths.hostLinkTokenPath, 'utf8').trim() + : ''; + if (!hostLinkToken) { + return { attempted: false, revoked: false, skippedReason: 'missing-host-link-token' }; + } + try { + const response = await fetch(`${record.cloudUrl}/_auth/host-link/revoke`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${hostLinkToken}`, + }, + body: JSON.stringify({ + hostLinkId: record.hostLinkId, + hostId: record.hostId, + }), + }); + const parsed = await readJsonResponse<{ readonly ok?: unknown; readonly error?: unknown }>( + response, + 'HiveCast Host-link revoke', + ); + if (!response.ok || parsed.ok !== true) { + return { + attempted: true, + revoked: false, + error: readOptionalString(parsed.error) ?? `HiveCast Host-link revoke failed: HTTP ${response.status}`, + }; + } + return { attempted: true, revoked: true }; + } catch (error) { + return { + attempted: true, + revoked: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function hiveCastRefreshCredentialsCommand( + options: IHiveCastRefreshCredentialsCommandOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const { record, paths } = readHiveCastHostLink(cwd, options.home); + if (!record) { + throw new Error('HIVECAST_REFRESH_NOT_LINKED: no local HiveCast Host link found'); + } + if (!record.hostLinkId || !record.hostId) { + throw new Error('HIVECAST_REFRESH_MISSING_LINK_ID: local HiveCast Host link is missing hostLinkId or hostId'); + } + const refreshThresholdSeconds = options.ifExpiringWithinSeconds; + const currentExpirationSeconds = readCredentialsExpirationSeconds(paths.natsCredentialsPath) + ?? readCredentialsExpirationSeconds(paths.hostCredentialsPath); + if (refreshThresholdSeconds !== undefined && currentExpirationSeconds !== null) { + const currentCredentialsSecondsRemaining = currentExpirationSeconds - Math.floor(Date.now() / 1000); + if (currentCredentialsSecondsRemaining > refreshThresholdSeconds) { + return { + refreshed: false, + matrixHome: paths.matrixHome, + linkPath: paths.linkPath, + natsCredentialsPath: paths.natsCredentialsPath, + currentCredentialsExpiresAt: new Date(currentExpirationSeconds * 1000).toISOString(), + currentCredentialsSecondsRemaining, + refreshThresholdSeconds, + ...(record.hostLinkId ? { hostLinkId: record.hostLinkId } : {}), + ...(record.hostId ? { hostId: record.hostId } : {}), + authorityRoot: record.authorityRoot, + ...(record.routeKey ? { routeKey: record.routeKey } : {}), + ...(record.publicNamespace ? { publicNamespace: record.publicNamespace } : {}), + ...(record.spaceId ? { spaceId: record.spaceId } : {}), + ...(record.hostName ? { hostName: record.hostName } : {}), + ...(record.deviceSlug ? { deviceSlug: record.deviceSlug } : {}), + ...(typeof record.authorityCoordinator === 'boolean' ? { authorityCoordinator: record.authorityCoordinator } : {}), + }; + } + } + const hostLinkToken = fs.existsSync(paths.hostLinkTokenPath) + ? fs.readFileSync(paths.hostLinkTokenPath, 'utf8').trim() + : ''; + if (!hostLinkToken) { + throw new Error('HIVECAST_REFRESH_MISSING_TOKEN: local HiveCast Host link is missing its refresh token'); + } + const response = await fetch(`${record.cloudUrl}/_auth/host-link/credentials/refresh`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${hostLinkToken}`, + }, + body: JSON.stringify({ + hostLinkId: record.hostLinkId, + hostId: record.hostId, + ...(options.authorityCoordinator === false ? { authorityCoordinator: false } : {}), + }), + }); + const parsed = await readJsonResponse(response, 'HiveCast credential refresh'); + if (!response.ok || parsed.ok !== true) { + const message = readOptionalString(parsed.error) ?? `HiveCast credential refresh failed: HTTP ${response.status}`; + throw new Error(`HIVECAST_REFRESH_FAILED: ${message}`); + } + const exchange = normalizeSetupExchange(parsed); + const routeKey = exchange.routeKey ?? record.routeKey; + const publicNamespace = exchange.publicNamespace ?? record.publicNamespace; + const hostName = exchange.hostName ?? record.hostName; + const { record: saved, paths: savedPaths } = saveHiveCastHostLink({ + cwd, + matrixHome: options.home, + cloudUrl: record.cloudUrl, + ...(exchange.deviceRegistryRoot ?? record.deviceRegistryRoot ? { deviceRegistryRoot: exchange.deviceRegistryRoot ?? record.deviceRegistryRoot } : {}), + authorityRoot: exchange.authorityRoot, + nats: exchange.nats, + natsCredentials: exchange.credentials, + hostLinkToken: exchange.hostLinkToken ?? hostLinkToken, + ...(exchange.hostLinkId ?? record.hostLinkId ? { hostLinkId: exchange.hostLinkId ?? record.hostLinkId } : {}), + ...(exchange.hostId ?? record.hostId ? { hostId: exchange.hostId ?? record.hostId } : {}), + ...(exchange.principalId ?? record.principalId ? { principalId: exchange.principalId ?? record.principalId } : {}), + ...(hostName ? { hostName } : {}), + ...(exchange.deviceSlug ?? record.deviceSlug ? { deviceSlug: exchange.deviceSlug ?? record.deviceSlug } : {}), + ...(typeof (exchange.authorityCoordinator ?? record.authorityCoordinator) === 'boolean' + ? { authorityCoordinator: exchange.authorityCoordinator ?? record.authorityCoordinator } + : {}), + ...(exchange.spaceId ?? record.spaceId ? { spaceId: exchange.spaceId ?? record.spaceId } : {}), + ...(routeKey ? { routeKey } : {}), + ...(publicNamespace ? { publicNamespace } : {}), + linkedAt: record.linkedAt, + }); + const hostConfigReload = await reloadRunningHostConfig( + savedPaths.matrixHome, + hostConfigExpectationForLink(savedPaths.matrixHome, saved, true), + ); + assertRunningHostConfigReloadApplied( + hostConfigReload, + hostConfigExpectationForLink(savedPaths.matrixHome, saved, true), + ); + const refreshedExpirationSeconds = readCredentialsExpirationSeconds(savedPaths.natsCredentialsPath); + return { + refreshed: true, + matrixHome: savedPaths.matrixHome, + linkPath: savedPaths.linkPath, + natsCredentialsPath: savedPaths.natsCredentialsPath, + ...(refreshedExpirationSeconds !== null + ? { + currentCredentialsExpiresAt: new Date(refreshedExpirationSeconds * 1000).toISOString(), + currentCredentialsSecondsRemaining: refreshedExpirationSeconds - Math.floor(Date.now() / 1000), + } + : {}), + ...(refreshThresholdSeconds !== undefined ? { refreshThresholdSeconds } : {}), + ...(saved.hostLinkId ? { hostLinkId: saved.hostLinkId } : {}), + ...(saved.hostId ? { hostId: saved.hostId } : {}), + authorityRoot: saved.authorityRoot, + ...(saved.routeKey ? { routeKey: saved.routeKey } : {}), + ...(saved.publicNamespace ? { publicNamespace: saved.publicNamespace } : {}), + ...(saved.spaceId ? { spaceId: saved.spaceId } : {}), + ...(saved.hostName ? { hostName: saved.hostName } : {}), + ...(saved.deviceSlug ? { deviceSlug: saved.deviceSlug } : {}), + ...(typeof saved.authorityCoordinator === 'boolean' ? { authorityCoordinator: saved.authorityCoordinator } : {}), + hostConfigReload, + }; +} + +export async function hiveCastLogoutCommand( + options: IHiveCastLogoutCommandOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const existing = readHiveCastHostLink(cwd, options.home); + const shouldRevokeCloudLink = options.all === true || options.revokeCloudLink === true; + const cloudRevoke = shouldRevokeCloudLink + ? await revokeHiveCastCloudHostLink(existing) + : undefined; + const result = removeHiveCastHostLink(cwd, options.home, { preserveCredentials: true }); + await ensureLocalOwnerNatsDependency(result.paths.matrixHome); + const localOwnerTransport = localOwnerTransportForExpectation(result.paths.matrixHome); + if (!localOwnerTransport) { + throw new Error(`HIVECAST_LINK_LOCAL_TRANSPORT_MISSING: installed local Host transport is missing for ${result.paths.matrixHome}`); + } + const hostConfigReload = await reloadRunningHostConfig(result.paths.matrixHome, { + root: localOwnerTransport.root, + authorityRoot: localOwnerTransport.authorityRoot ?? localOwnerTransport.root, + routeKey: null, + publicNamespace: null, + spaceId: null, + forceReload: true, + label: 'local-owner', + }); + removeHiveCastCredentialFiles(result.paths); + return { + removed: result.removed, + ...(cloudRevoke ? { cloudRevoke } : {}), + hostConfigReset: result.hostConfigReset, + matrixHome: result.paths.matrixHome, + linkPath: result.paths.linkPath, + natsCredentialsPath: result.paths.natsCredentialsPath, + hostConfigReload, + }; +} + +export async function hiveCastWhoamiCommand( + options: IAuthContextOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const { record, paths } = readHiveCastHostLink(cwd, options.home); + if (!record) { + return { + linked: false, + matrixHome: paths.matrixHome, + }; + } + return { + linked: true, + matrixHome: paths.matrixHome, + cloudUrl: record.cloudUrl, + ...(record.hostLinkId ? { hostLinkId: record.hostLinkId } : {}), + authorityRoot: record.authorityRoot, + ...(record.routeKey ? { routeKey: record.routeKey } : {}), + ...(record.publicNamespace ? { publicNamespace: record.publicNamespace } : {}), + ...(record.spaceId ? { spaceId: record.spaceId } : {}), + ...(record.principalId ? { principalId: record.principalId } : {}), + ...(record.hostName ? { hostName: record.hostName } : {}), + ...(record.deviceSlug ? { deviceSlug: record.deviceSlug } : {}), + linkedAt: record.linkedAt, + }; +} + +export async function hiveCastLinkStatusCommand( + options: IAuthContextOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + return readHiveCastLinkStatus(cwd, options.home); +} + +export function formatHiveCastWhoami(result: IHiveCastWhoamiCommandResult): string { + if (!result.linked) { + return `HiveCast Host is not linked (${result.matrixHome})`; + } + return [ + `Cloud: ${result.cloudUrl ?? 'unknown'}`, + result.hostName ? `Device name: ${result.hostName}` : null, + result.deviceSlug ? `Device inventory projection: system.devices.${result.deviceSlug}` : null, + result.routeKey ? `Route key: ${result.routeKey}` : null, + result.publicNamespace ? `Public namespace: ${result.publicNamespace}` : null, + result.spaceId ? `Space: ${result.spaceId}` : null, + `Authority root: ${result.authorityRoot ?? 'unknown'}`, + `Matrix home: ${result.matrixHome}`, + ].filter((line): line is string => Boolean(line)).join('\n'); +} diff --git a/archive/mx-cli-product-config-commands/config-command.boundary-explain.spec.ts b/archive/mx-cli-product-config-commands/config-command.boundary-explain.spec.ts new file mode 100644 index 0000000..9715a8f --- /dev/null +++ b/archive/mx-cli-product-config-commands/config-command.boundary-explain.spec.ts @@ -0,0 +1,51 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import path from 'node:path'; +import { configExplainCommand } from '../../src/commands/config.js'; + +describe('mx config command', () => { + it('classifies package declarations as shared direct and managed inputs', async () => { + const result = await configExplainCommand('packages/chat/matrix.json', { + cwd: '/repo', + }); + + assert.equal(result.kind, 'package-declaration'); + assert.equal(result.directRunReads, true); + assert.equal(result.managedHostReads, true); + assert.equal(result.generated, false); + assert.equal(result.relativePath, 'packages/chat/matrix.json'); + }); + + it('classifies Runtime Host generated state as managed-only generated state', async () => { + const result = await configExplainCommand('/repo/.matrix/home/runtimes/RUNTIME-1/status.json', { + cwd: '/repo', + }); + + assert.equal(result.kind, 'runtime-host-generated-state'); + assert.equal(result.directRunReads, false); + assert.equal(result.managedHostReads, true); + assert.equal(result.generated, true); + }); + + it('classifies stored API keys as credential state read through resolvers', async () => { + const result = await configExplainCommand(path.join('/repo', '.matrix/home/credentials/matrix-api-key.json'), { + cwd: '/repo', + }); + + assert.equal(result.kind, 'credential-link-state'); + assert.equal(result.directRunReads, true); + assert.equal(result.managedHostReads, true); + assert.match(result.recommendedAction, /never commit/); + }); + + it('classifies historical deployment paths as deletion candidates', async () => { + const result = await configExplainCommand('packages/dev-platform-deployment/package.json', { + cwd: '/repo', + }); + + assert.equal(result.kind, 'deletion-candidate'); + assert.equal(result.directRunReads, false); + assert.equal(result.managedHostReads, false); + assert.match(result.recommendedAction, /grep\/test proof/); + }); +}); diff --git a/archive/mx-cli-product-config-commands/config.boundary-explain.ts b/archive/mx-cli-product-config-commands/config.boundary-explain.ts new file mode 100644 index 0000000..3594bf5 --- /dev/null +++ b/archive/mx-cli-product-config-commands/config.boundary-explain.ts @@ -0,0 +1,320 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + readMatrixCliConfig, + resetMatrixCliConfig, + resolvePackageRegistry, + setMatrixCliConfigValue, + type IMatrixCliConfig, +} from '../utils/config-store.js'; + +export interface IConfigCommandOptions { + readonly cwd?: string; + readonly configPath?: string; +} + +export interface IConfigGetResult { + readonly key: string; + readonly value: string | undefined; + readonly source?: 'explicit' | 'env' | 'config' | 'default'; + readonly configPath: string; +} + +export interface IConfigListResult { + readonly configPath: string; + readonly values: IMatrixCliConfig; + readonly effective: { + readonly registry: string; + readonly registrySource: 'explicit' | 'env' | 'config' | 'default'; + }; +} + +export interface IConfigSetResult { + readonly key: string; + readonly value: string; + readonly configPath: string; +} + +export interface IConfigResetResult { + readonly configPath: string; + readonly values: IMatrixCliConfig; +} + +export type ConfigClassificationKind = + | 'direct-actor-input' + | 'package-declaration' + | 'runtime-host-desired-state' + | 'credential-link-state' + | 'runtime-host-generated-state' + | 'runtime-host-preset' + | 'platform-deployment' + | 'deletion-candidate' + | 'unknown'; + +export interface IConfigExplainResult { + readonly path: string; + readonly relativePath: string; + readonly exists: boolean; + readonly kind: ConfigClassificationKind; + readonly owner: string; + readonly authoredBy: string; + readonly directRunReads: boolean; + readonly managedHostReads: boolean; + readonly generated: boolean; + readonly recommendedAction: string; + readonly notes: readonly string[]; +} + +function assertSupportedKey(key: string): 'registry' { + const normalized = key.trim(); + if (normalized !== 'registry') { + throw new Error(`MX_CONFIG_INVALID: unsupported config key "${key}"`); + } + return normalized; +} + +export async function configGetCommand( + key: string, + options: IConfigCommandOptions = {}, +): Promise { + const normalizedKey = assertSupportedKey(key); + const stored = readMatrixCliConfig(options); + const effective = resolvePackageRegistry(undefined, options); + return { + key: normalizedKey, + value: stored.values.registry ?? effective.registry, + source: stored.values.registry ? 'config' : effective.source, + configPath: stored.configPath, + }; +} + +export async function configListCommand(options: IConfigCommandOptions = {}): Promise { + const stored = readMatrixCliConfig(options); + const effective = resolvePackageRegistry(undefined, options); + return { + configPath: stored.configPath, + values: stored.values, + effective: { + registry: effective.registry, + registrySource: effective.source, + }, + }; +} + +export async function configSetCommand( + key: string, + value: string, + options: IConfigCommandOptions = {}, +): Promise { + const normalizedKey = assertSupportedKey(key); + const result = setMatrixCliConfigValue(normalizedKey, value, options); + return { + key: normalizedKey, + value: result.values.registry ?? '', + configPath: result.configPath, + }; +} + +export async function configResetCommand(options: IConfigCommandOptions = {}): Promise { + return resetMatrixCliConfig(options); +} + +export async function configExplainCommand( + targetPath: string, + options: IConfigCommandOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const absolutePath = path.resolve(cwd, targetPath); + const relativePath = normalizePath(path.relative(cwd, absolutePath) || path.basename(absolutePath)); + const exists = fs.existsSync(absolutePath); + return { + path: absolutePath, + relativePath, + exists, + ...classifyConfigPath(relativePath), + }; +} + +function classifyConfigPath(relativePath: string): Omit { + const normalized = normalizePath(relativePath); + const basename = path.posix.basename(normalized); + const segments = normalized.split('/').filter(Boolean); + + if (basename === 'ActorRunnerSmokeActor.mjs' || basename.endsWith('Actor.ts') || basename.endsWith('Actor.mjs')) { + return classification({ + kind: 'direct-actor-input', + owner: 'Standalone Actor Runner', + authoredBy: 'CLI caller or package author', + directRunReads: true, + managedHostReads: false, + generated: false, + recommendedAction: 'keep as direct actor input; do not register Host-managed state', + notes: ['Direct actor execution receives this path from the CLI caller.'], + }); + } + + if (basename === 'matrix.json') { + return classification({ + kind: 'package-declaration', + owner: 'Package', + authoredBy: 'package author or scaffold', + directRunReads: true, + managedHostReads: true, + generated: false, + recommendedAction: 'keep; package declarations are shared by direct and managed execution', + notes: [`${basename} is package-authored declaration state.`], + }); + } + + if (basename === 'package.json.matrix') { + return classification({ + kind: 'package-declaration', + owner: 'Package', + authoredBy: 'package author or scaffold', + directRunReads: true, + managedHostReads: true, + generated: false, + recommendedAction: 'migrate to matrix.json when a package-local matrix.json exists', + notes: ['This is a duplicate package manifest candidate, not Runtime Host state.'], + }); + } + + if (basename === 'host.json' || basename === 'workspace.json' || basename === 'daemon.json') { + return classification({ + kind: 'runtime-host-desired-state', + owner: 'Runtime Host', + authoredBy: 'developer, operator, or Host setup tool', + directRunReads: false, + managedHostReads: true, + generated: false, + recommendedAction: 'normalize behind the Runtime Host Plan before changing shape', + notes: ['Direct actor/package execution must not read this file.'], + }); + } + + if (basename.endsWith('.environment.json') && segments.includes('.matrix')) { + return classification({ + kind: 'runtime-host-desired-state', + owner: 'Runtime Host', + authoredBy: 'developer, operator, or package environment author', + directRunReads: false, + managedHostReads: true, + generated: false, + recommendedAction: 'normalize into Runtime Host desired-state model', + notes: ['Package environment manifests belong to managed execution, not direct cloud-bus run.'], + }); + } + + if (segments.includes('credentials') || basename === 'matrix-api-key.json' || basename === 'hivecast-link.json') { + return classification({ + kind: 'credential-link-state', + owner: 'Credentials layer', + authoredBy: 'CLI, cloud exchange, or operator', + directRunReads: true, + managedHostReads: true, + generated: false, + recommendedAction: 'keep secret; never commit; use only through configured credential resolvers', + notes: ['Direct-run may read stored API keys; Runtime Host may read Host/link credentials.'], + }); + } + + if ( + basename === 'host.status.json' + || basename === 'metadata.webapp' + || segments.includes('runtimes') + || segments.includes('runtime-env') + || segments.includes('package-records') + ) { + return classification({ + kind: 'runtime-host-generated-state', + owner: 'Runtime Host reconciler', + authoredBy: 'generated by Host runtime', + directRunReads: false, + managedHostReads: true, + generated: true, + recommendedAction: 'keep out of authored config; safe to regenerate from managed Host state', + notes: ['Direct actor/package execution must not create this state.'], + }); + } + + if ( + normalized.includes('/deployments/nodes/') + || normalized.startsWith('deployments/nodes/') + || normalized.includes('/dist/profiles/') + || normalized.startsWith('dist/profiles/') + || basename.endsWith('.profile.json') + || basename.endsWith('.node.json') + ) { + return classification({ + kind: 'runtime-host-preset', + owner: 'Distribution profile resolver', + authoredBy: 'release engineer', + directRunReads: false, + managedHostReads: true, + generated: false, + recommendedAction: 'keep until profile/preset cleanup unifies resolver semantics', + notes: ['Profile and node presets are managed Host inputs, not direct-run inputs.'], + }); + } + + if ( + normalized.includes('/deployments/environments/') + || normalized.startsWith('deployments/environments/') + || normalized.includes('/deployments/topologies/') + || normalized.startsWith('deployments/topologies/') + || normalized.endsWith('/deployments/schema.json') + || normalized === 'deployments/schema.json' + || normalized.includes('/packages/') && normalized.includes('-deployment/') + || normalized.startsWith('packages/') && normalized.includes('-deployment/') + ) { + return classification({ + kind: 'deletion-candidate', + owner: 'Historical deployment layer', + authoredBy: 'historical deployment tooling', + directRunReads: false, + managedHostReads: false, + generated: false, + recommendedAction: 'delete only after grep/test proof shows no live reader', + notes: ['This path matches Phase 6 deletion candidates.'], + }); + } + + if ( + basename === 'docker-compose.yml' + || basename === 'Dockerfile' + || normalized.includes('/systemd/') + || normalized.includes('/native-dist/') + ) { + return classification({ + kind: 'platform-deployment', + owner: 'Platform operator', + authoredBy: 'operator or release engineer', + directRunReads: false, + managedHostReads: false, + generated: false, + recommendedAction: 'treat as deployment packaging, not package/runtime config', + notes: ['Platform deployment files sit outside direct and managed package execution.'], + }); + } + + return classification({ + kind: 'unknown', + owner: 'unknown', + authoredBy: 'unknown', + directRunReads: false, + managedHostReads: false, + generated: false, + recommendedAction: 'inspect before changing; add a classifier case if this becomes a recurring config path', + notes: ['No current config simplification rule matched this path.'], + }); +} + +function classification( + result: Omit, +): Omit { + return result; +} + +function normalizePath(value: string): string { + return value.replace(/\\/g, '/'); +} diff --git a/archive/mx-cli-product-docs/README.mx-cli-original.md b/archive/mx-cli-product-docs/README.mx-cli-original.md new file mode 100644 index 0000000..3fa212e --- /dev/null +++ b/archive/mx-cli-product-docs/README.mx-cli-original.md @@ -0,0 +1,43 @@ +# @matrix/mx-cli + +Matrix CLI for package management. + +## Installation + +```bash +cd packages/mx-cli +npm install +npm run build +``` + +## Usage + +```bash +mx --help +mx --version +mx init # Initialize a new Matrix package (B013) +mx actor # Scaffold a new actor package (B014) +mx install # Install a package (future) +mx fork # Fork and install a package (future) +mx publish # Publish a package (future) +mx list # List installed packages (future) +``` + +## Development + +- **Build**: `npm run build` +- **Test**: `npm test` (requires Node spawn capability) + +## Architecture + +See: +- `ARCHITECTURE/protocol/MATRIX-PROTOCOL-10.md` (MXTYPE-1 - Homoiconic Type System) +- `ralph/beads/cycle-0002-B012-mx-cli-package-foundation.md` +- `ralph/beads/cycle-0002-B013-mx-init-command.md` +- `ralph/beads/cycle-0002-B014-mx-actor-scaffold.md` + +## Status + +- **B012**: Foundation complete (package structure, CLI routing, help/version) +- **B013**: `mx init` implemented (manifest + directory scaffolding + validation) +- **B014**: `mx actor` implemented (actor package scaffold with examples/skills/tests) diff --git a/archive/mx-cli-provider-overlay-candidates/actor-run.hivecast-api-key-exchange.spec.ts b/archive/mx-cli-provider-overlay-candidates/actor-run.hivecast-api-key-exchange.spec.ts new file mode 100644 index 0000000..11a75c6 --- /dev/null +++ b/archive/mx-cli-provider-overlay-candidates/actor-run.hivecast-api-key-exchange.spec.ts @@ -0,0 +1,292 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + exchangeActorCredential, + loadActorConstructor, + resolveActorRunProvider, + resolvePackageRunSpec, +} from '../../src/commands/actor-run.js'; + +describe('matrix actor run', () => { + it('loads a named actor export from an entry module', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-run-entry-')); + try { + const entry = path.join(dir, 'actors.mjs'); + fs.writeFileSync(entry, [ + 'export class NamedActor {}', + 'export class OtherActor {}', + '', + ].join('\n'), 'utf8'); + + const actor = await loadActorConstructor(entry, 'NamedActor'); + + assert.equal(actor.name, 'NamedActor'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('loads a default actor export from an entry module', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-run-default-entry-')); + try { + const entry = path.join(dir, 'actor.mjs'); + fs.writeFileSync(entry, [ + 'export default class DefaultActor {}', + '', + ].join('\n'), 'utf8'); + + const actor = await loadActorConstructor(entry, 'default'); + + assert.equal(actor.name, 'DefaultActor'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('reports available actor exports when the requested export is missing', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-run-missing-entry-')); + try { + const entry = path.join(dir, 'actor.mjs'); + fs.writeFileSync(entry, [ + 'export class PresentActor {}', + '', + ].join('\n'), 'utf8'); + + await assert.rejects( + () => loadActorConstructor(entry, 'MissingActor'), + /No actor class found as export "MissingActor".*PresentActor/u, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resolves API-key, cloud, and Space from an explicit home without Host state', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-run-provider-')); + try { + fs.mkdirSync(path.join(home, 'credentials'), { recursive: true }); + fs.writeFileSync( + path.join(home, 'credentials', 'matrix-api-key.json'), + `${JSON.stringify({ + apiKey: 'hc_home_key', + cloud: 'https://dev.hivecast.ai/', + authorityRoot: 'richard-santomauro-nimbletec-com', + })}\n`, + 'utf8', + ); + + const resolved = resolveActorRunProvider({ home }); + assert.equal(resolved.key, 'hc_home_key'); + assert.equal(resolved.cloud, 'https://dev.hivecast.ai'); + assert.equal(resolved.space, 'richard-santomauro-nimbletec-com'); + assert.ok(resolved.checked.includes('/credentials/matrix-api-key.json')); + assert.equal(fs.existsSync(path.join(home, 'host.json')), false); + assert.equal(fs.existsSync(path.join(home, 'runtimes')), false); + assert.equal(fs.existsSync(path.join(home, 'package-records')), false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('prefers explicit provider options over environment and home configuration', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-run-provider-precedence-')); + const previous = { + MATRIX_API_KEY: process.env.MATRIX_API_KEY, + HIVECAST_API_KEY: process.env.HIVECAST_API_KEY, + MATRIX_CLOUD: process.env.MATRIX_CLOUD, + HIVECAST_CLOUD: process.env.HIVECAST_CLOUD, + MATRIX_SPACE: process.env.MATRIX_SPACE, + HIVECAST_SPACE: process.env.HIVECAST_SPACE, + }; + try { + fs.mkdirSync(path.join(home, 'credentials'), { recursive: true }); + fs.writeFileSync( + path.join(home, 'credentials', 'matrix-api-key.json'), + `${JSON.stringify({ + apiKey: 'hc_home_key', + cloud: 'https://home.hivecast.ai', + space: 'home-space', + })}\n`, + 'utf8', + ); + process.env.MATRIX_API_KEY = 'hc_env_key'; + process.env.MATRIX_CLOUD = 'https://env.hivecast.ai'; + process.env.MATRIX_SPACE = 'env-space'; + + const resolved = resolveActorRunProvider({ + home, + key: 'hc_explicit_key', + cloud: 'https://explicit.hivecast.ai/', + space: 'explicit-space', + }); + + assert.equal(resolved.key, 'hc_explicit_key'); + assert.equal(resolved.cloud, 'https://explicit.hivecast.ai'); + assert.equal(resolved.space, 'explicit-space'); + } finally { + restoreEnv(previous); + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('reports missing key, cloud, and Space before contacting the cloud', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-run-provider-missing-')); + const previous = { + MATRIX_API_KEY: process.env.MATRIX_API_KEY, + HIVECAST_API_KEY: process.env.HIVECAST_API_KEY, + MATRIX_CLOUD: process.env.MATRIX_CLOUD, + HIVECAST_CLOUD: process.env.HIVECAST_CLOUD, + MATRIX_SPACE: process.env.MATRIX_SPACE, + HIVECAST_SPACE: process.env.HIVECAST_SPACE, + }; + try { + delete process.env.MATRIX_API_KEY; + delete process.env.HIVECAST_API_KEY; + delete process.env.MATRIX_CLOUD; + delete process.env.HIVECAST_CLOUD; + delete process.env.MATRIX_SPACE; + delete process.env.HIVECAST_SPACE; + + assert.throws( + () => resolveActorRunProvider({ home }), + /MATRIX_API_KEY_REQUIRED/u, + ); + assert.throws( + () => resolveActorRunProvider({ home, key: 'hc_key' }), + /MATRIX_CLOUD_REQUIRED/u, + ); + assert.throws( + () => resolveActorRunProvider({ home, key: 'hc_key', cloud: 'https://dev.hivecast.ai' }), + /MATRIX_SPACE_REQUIRED/u, + ); + } finally { + restoreEnv(previous); + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('exchanges through /api/install/actor-credential without putting the key in the URL', async () => { + const requests: Array<{ readonly url: string; readonly init: Record }> = []; + const provider = { + key: 'hc_actor_key', + cloud: 'https://dev.hivecast.ai', + space: 'richard-santomauro-nimbletec-com', + checked: [], + }; + const fetchImpl = async (url: URL, init: RequestInit): Promise => { + requests.push({ url: url.toString(), init: init as Record }); + return new Response(JSON.stringify({ + ok: true, + space: { authorityRoot: 'richard-santomauro-nimbletec-com' }, + transport: { natsUrl: 'nats://dev.hivecast.ai:4422' }, + mount: 'apps.ping', + effectiveMount: 'richard-santomauro-nimbletec-com.apps.ping', + credentials: { jwt: 'jwt', seed: 'SU' }, + source: 'standalone-actor', + credentialPipeline: 'api-key-exchange', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + const exchanged = await exchangeActorCredential({ + provider, + mount: 'apps.ping', + actorId: 'PingActor', + packageId: '@acme/ping', + runtimeId: 'standalone-actor:ping', + accepts: { ping: {} }, + emits: {}, + subscribes: {}, + ttlSeconds: 90, + fetchImpl, + }); + + assert.equal(exchanged.ok, true); + assert.equal(requests.length, 1); + assert.equal(requests[0]?.url, 'https://dev.hivecast.ai/api/install/actor-credential'); + assert.equal(JSON.stringify(requests[0]).includes('hc_actor_key'), true); + assert.equal(requests[0]?.url.includes('hc_actor_key'), false); + assert.equal((requests[0]?.init.headers as Record).authorization, 'Bearer hc_actor_key'); + const body = JSON.parse(String(requests[0]?.init.body)) as Record; + assert.equal(body.space, 'richard-santomauro-nimbletec-com'); + assert.equal(body.mount, 'apps.ping'); + assert.equal(body.actorId, 'PingActor'); + assert.deepEqual(body.accepts, { ping: {} }); + assert.equal(body.ttlSeconds, 90); + }); + + it('resolves direct package run metadata from matrix.json runtime.factory without Host state', () => { + const packageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-package-run-spec-')); + try { + fs.mkdirSync(path.join(packageDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(packageDir, 'package.json'), + `${JSON.stringify({ name: '@example/direct-run', version: '0.1.0', type: 'module' }, null, 2)}\n`, + 'utf8', + ); + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + `${JSON.stringify({ + name: '@example/direct-run', + version: '0.1.0', + runtime: { + language: 'javascript', + entry: 'src/index.mjs', + factory: { + kind: 'factory', + export: 'createDirectRunActor', + instance: { + id: 'RUNTIME-HOST-DIRECT-RUN', + class: 'service', + rootKind: 'component', + componentType: 'DirectRunActor', + mount: 'actor-runner-smoke', + autoStart: true, + }, + }, + }, + components: [{ + type: 'DirectRunActor', + mount: 'actor-runner-smoke', + accepts: ['ping', 'getStatus'], + }], + }, null, 2)}\n`, + 'utf8', + ); + fs.writeFileSync(path.join(packageDir, 'src', 'index.mjs'), 'export function createDirectRunActor() {}\n', 'utf8'); + + const spec = resolvePackageRunSpec(packageDir, { mount: 'actor-runner-smoke' }); + + assert.equal(spec.packageName, '@example/direct-run'); + assert.equal(spec.manifestPath, path.join(packageDir, 'matrix.json')); + assert.equal(spec.exportName, 'createDirectRunActor'); + assert.equal(spec.mount, 'actor-runner-smoke'); + assert.equal(spec.actorId, 'DirectRunActor'); + assert.equal(spec.packageId, '@example/direct-run'); + assert.equal(spec.accepts.ping && typeof spec.accepts.ping, 'object'); + assert.equal(spec.accepts.getStatus && typeof spec.accepts.getStatus, 'object'); + assert.equal(fs.existsSync(path.join(packageDir, 'host.json')), false); + assert.equal(fs.existsSync(path.join(packageDir, 'runtimes')), false); + assert.equal(fs.existsSync(path.join(packageDir, 'package-records')), false); + } finally { + fs.rmSync(packageDir, { recursive: true, force: true }); + } + }); + +}); + +function restoreEnv(previous: Record): void { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} diff --git a/archive/mx-cli-provider-overlay-candidates/actor-run.hivecast-api-key-exchange.ts b/archive/mx-cli-provider-overlay-candidates/actor-run.hivecast-api-key-exchange.ts new file mode 100644 index 0000000..aaf9885 --- /dev/null +++ b/archive/mx-cli-provider-overlay-candidates/actor-run.hivecast-api-key-exchange.ts @@ -0,0 +1,693 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { MatrixRuntime } from '@open-matrix/core/runtime/MatrixRuntime.js'; +import type { MatrixActor } from '@open-matrix/core/core/MatrixActor.js'; +import { RequestReply } from '@open-matrix/core/engine/remoting/RequestReply.js'; +import { NatsTransport } from '@open-matrix/core/transport/NatsTransport.js'; +import { connect as natsConnect, jwtAuthenticator, type NatsConnection } from 'nats'; + +import { loadMatrixServiceManifest, type ILoadedMatrixServiceManifest } from '../utils/service-manifest.js'; +import { createRunnerSecurityRealm } from '../utils/runner-security.js'; + +export interface IActorRunCommandOptions { + readonly key?: string; + readonly cloud?: string; + readonly space?: string; + readonly home?: string; + readonly export?: string; + readonly mount?: string; + readonly json?: boolean; + readonly props?: string; + readonly ttlSeconds?: number; + readonly checkOp?: string; + readonly checkPayload?: string; +} + +export interface IActorRunProviderResolution { + readonly key: string; + readonly cloud: string; + readonly space: string; + readonly checked: readonly string[]; +} + +interface IActorCredentialExchange { + readonly ok?: unknown; + readonly error?: unknown; + readonly space?: { + readonly authorityRoot?: unknown; + readonly spaceId?: unknown; + readonly routeKey?: unknown; + readonly publicNamespace?: unknown; + }; + readonly transport?: { + readonly natsUrl?: unknown; + readonly root?: unknown; + }; + readonly mount?: unknown; + readonly effectiveMount?: unknown; + readonly credentials?: { + readonly jwt?: unknown; + readonly seed?: unknown; + readonly userPublicKey?: unknown; + readonly expiresAt?: unknown; + }; + readonly source?: unknown; + readonly credentialPipeline?: unknown; +} + +type ActorConstructor = { + new (): MatrixActor; + readonly accepts?: Record; + readonly emits?: Record; + readonly subscribes?: Record; + readonly name: string; +}; + +type PackageBootstrapFunction = (...args: readonly unknown[]) => Promise | unknown; + +export interface IPackageRunCommandOptions extends IActorRunCommandOptions { + readonly entry?: string; + readonly overrides?: string; +} + +export interface IPackageRunSpec { + readonly packageDir: string; + readonly packageName: string; + readonly manifestPath: string; + readonly entryPath: string; + readonly exportName: string; + readonly mount: string; + readonly actorId: string; + readonly packageId: string; + readonly runtimeId: string; + readonly accepts: Record; + readonly emits: Record; + readonly subscribes: Record; + readonly overrides: Record; + readonly serviceInstance: ILoadedMatrixServiceManifest['serviceInstance']; +} + +export async function actorRunCommand( + entry: string, + options: IActorRunCommandOptions, +): Promise { + const entryPath = path.resolve(entry); + if (!fs.existsSync(entryPath)) { + throw new Error(`Actor entry not found: ${entryPath}`); + } + const exportName = options.export?.trim() || 'default'; + const ActorClass = await loadActorConstructor(entryPath, exportName); + const mount = options.mount?.trim() || inferMountFromEntry(entryPath); + if (!mount) { + throw new Error('matrix actor run requires --mount when it cannot infer a mount from the entry file'); + } + const provider = resolveActorRunProvider(options); + const exchange = await exchangeActorCredential({ + provider, + mount, + actorId: ActorClass.name || mount, + packageId: readPackageNameForEntry(entryPath) ?? 'standalone-actor', + runtimeId: `standalone-actor:${process.pid}:${mount}`, + accepts: ActorClass.accepts ?? {}, + emits: ActorClass.emits ?? {}, + subscribes: ActorClass.subscribes ?? {}, + ttlSeconds: options.ttlSeconds, + }); + const authorityRoot = readRequiredString(exchange.space?.authorityRoot, 'actor credential response space.authorityRoot'); + const natsUrl = readRequiredString(exchange.transport?.natsUrl, 'actor credential response transport.natsUrl'); + const jwt = readRequiredString(exchange.credentials?.jwt, 'actor credential response credentials.jwt'); + const seed = readRequiredString(exchange.credentials?.seed, 'actor credential response credentials.seed'); + const connection = await connectActorNats({ + natsUrl, + authorityRoot, + jwt, + seed, + }); + const transport = new NatsTransport(connection, { root: authorityRoot }); + const runtime = new MatrixRuntime({ + transport, + logging: false, + allowDynamicCompilation: false, + securityRealm: createRunnerSecurityRealm(), + }); + + const props = parseProps(options.props); + await runtime.createSupervised(ActorClass, mount, props); + await transport.flush(); + const check = await runOptionalActorCheck(runtime, mount, options); + + const result = { + ok: true, + mode: 'standalone-actor-runner', + entry: entryPath, + export: exportName, + actor: ActorClass.name, + mount, + authorityRoot, + effectiveMount: resolveEffectiveMount(exchange.effectiveMount, authorityRoot, mount), + cloud: provider.cloud, + natsUrl, + source: readRequiredString(exchange.source, 'actor credential response source'), + credentialPipeline: readRequiredString(exchange.credentialPipeline, 'actor credential response credentialPipeline'), + hostStateWritten: false, + ...(check ? { check } : {}), + }; + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log('Actor mounted:'); + console.log(` logical: ${result.mount}`); + console.log(` authority: ${result.authorityRoot}`); + console.log(` effective: ${result.effectiveMount}`); + console.log(` cloud: ${result.cloud}`); + console.log('Press Ctrl+C to stop.'); + } + + if (check) { + await runtime.shutdown(); + await transport.disconnect(); + if (!connection.isClosed()) { + await connection.close(); + } + return; + } + + await waitForActorShutdown(async () => { + await runtime.shutdown(); + await transport.disconnect(); + if (!connection.isClosed()) { + await connection.close(); + } + }); +} + +export async function packageRunCommand( + packageDir: string, + options: IPackageRunCommandOptions, +): Promise { + const spec = resolvePackageRunSpec(packageDir, options); + const provider = resolveActorRunProvider(options); + const exchange = await exchangeActorCredential({ + provider, + mount: spec.mount, + actorId: spec.actorId, + packageId: spec.packageId, + runtimeId: spec.runtimeId, + accepts: spec.accepts, + emits: spec.emits, + subscribes: spec.subscribes, + ttlSeconds: options.ttlSeconds, + }); + const authorityRoot = readRequiredString(exchange.space?.authorityRoot, 'actor credential response space.authorityRoot'); + const natsUrl = readRequiredString(exchange.transport?.natsUrl, 'actor credential response transport.natsUrl'); + const jwt = readRequiredString(exchange.credentials?.jwt, 'actor credential response credentials.jwt'); + const seed = readRequiredString(exchange.credentials?.seed, 'actor credential response credentials.seed'); + const connection = await connectActorNats({ + natsUrl, + authorityRoot, + jwt, + seed, + }); + const transport = new NatsTransport(connection, { root: authorityRoot }); + const runtime = new MatrixRuntime({ + transport, + logging: false, + allowDynamicCompilation: false, + securityRealm: createRunnerSecurityRealm(), + }); + + const bootstrap = await loadPackageBootstrap(spec.entryPath, spec.exportName); + const bootstrapResult = await bootstrap( + spec.overrides, + { runtime }, + undefined, + { + packageName: spec.packageName, + packageDir: spec.packageDir, + manifestPath: spec.manifestPath, + entryPath: spec.entryPath, + exportName: spec.exportName, + root: authorityRoot, + mount: spec.mount, + instance: spec.serviceInstance, + serviceInstance: spec.serviceInstance, + }, + ); + await transport.flush(); + const check = await runOptionalActorCheck(runtime, spec.mount, options); + + const result = { + ok: true, + mode: 'standalone-package-runner', + packageDir: spec.packageDir, + packageName: spec.packageName, + manifest: spec.manifestPath, + entry: spec.entryPath, + export: spec.exportName, + mount: spec.mount, + authorityRoot, + effectiveMount: resolveEffectiveMount(exchange.effectiveMount, authorityRoot, spec.mount), + cloud: provider.cloud, + natsUrl, + source: readRequiredString(exchange.source, 'actor credential response source'), + credentialPipeline: readRequiredString(exchange.credentialPipeline, 'actor credential response credentialPipeline'), + hostStateWritten: false, + ...(check ? { check } : {}), + }; + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log('Package actor mounted:'); + console.log(` package: ${result.packageName}`); + console.log(` logical: ${result.mount}`); + console.log(` authority: ${result.authorityRoot}`); + console.log(` effective: ${result.effectiveMount}`); + console.log(` cloud: ${result.cloud}`); + console.log('Press Ctrl+C to stop.'); + } + + if (check) { + await shutdownPackageBootstrapResult(bootstrapResult, runtime); + await runtime.shutdown(); + await transport.disconnect(); + if (!connection.isClosed()) { + await connection.close(); + } + return; + } + + await waitForActorShutdown(async () => { + await shutdownPackageBootstrapResult(bootstrapResult, runtime); + await runtime.shutdown(); + await transport.disconnect(); + if (!connection.isClosed()) { + await connection.close(); + } + }); +} + +export function resolveActorRunProvider(options: IActorRunCommandOptions): IActorRunProviderResolution { + const home = path.resolve(options.home?.trim() || path.join(os.homedir(), '.matrix')); + const homeCredential = readApiKeyCredentialFile(path.join(home, 'credentials', 'matrix-api-key.json')); + const homeConfig = readApiKeyConfigFile(path.join(home, 'config.json')); + const defaultHome = path.resolve(os.homedir(), '.matrix'); + const defaultCredential = home === defaultHome ? {} : readApiKeyCredentialFile(path.join(defaultHome, 'credentials', 'matrix-api-key.json')); + const defaultConfig = home === defaultHome ? {} : readApiKeyConfigFile(path.join(defaultHome, 'config.json')); + const hivecastNamedCredential = readApiKeyCredentialFile(path.join(defaultHome, 'credentials', 'hivecast-api-key.json')); + const key = readOptionalString(options.key) + ?? readOptionalString(process.env.MATRIX_API_KEY) + ?? readOptionalString(process.env.HIVECAST_API_KEY) + ?? homeCredential.key + ?? homeConfig.key + ?? defaultCredential.key + ?? defaultConfig.key + ?? hivecastNamedCredential.key; + const cloud = readOptionalString(options.cloud) + ?? readOptionalString(process.env.MATRIX_CLOUD) + ?? readOptionalString(process.env.HIVECAST_CLOUD) + ?? homeCredential.cloud + ?? homeConfig.cloud + ?? defaultCredential.cloud + ?? defaultConfig.cloud + ?? hivecastNamedCredential.cloud; + const space = readOptionalString(options.space) + ?? readOptionalString(process.env.MATRIX_SPACE) + ?? readOptionalString(process.env.HIVECAST_SPACE) + ?? homeCredential.space + ?? homeConfig.space + ?? defaultCredential.space + ?? defaultConfig.space + ?? hivecastNamedCredential.space; + + const checked = [ + '--key', + 'MATRIX_API_KEY', + 'HIVECAST_API_KEY', + '/credentials/matrix-api-key.json', + '/config.json', + '~/.matrix/credentials/matrix-api-key.json', + '~/.matrix/config.json', + '~/.matrix/credentials/hivecast-api-key.json', + ]; + + if (!key) { + throw new Error(`MATRIX_API_KEY_REQUIRED: matrix actor run requires --key or a configured API key. Checked: ${checked.join(', ')}`); + } + if (!cloud) { + throw new Error('MATRIX_CLOUD_REQUIRED: matrix actor run requires --cloud or configured MATRIX_CLOUD/HIVECAST_CLOUD'); + } + if (!space) { + throw new Error('MATRIX_SPACE_REQUIRED: matrix actor run requires --space or configured MATRIX_SPACE/HIVECAST_SPACE'); + } + return { + key, + cloud: normalizeCloudUrl(cloud), + space, + checked, + }; +} + +export async function exchangeActorCredential(input: { + readonly provider: IActorRunProviderResolution; + readonly mount: string; + readonly actorId: string; + readonly packageId: string; + readonly runtimeId: string; + readonly accepts: Record; + readonly emits: Record; + readonly subscribes: Record; + readonly ttlSeconds?: number; + readonly fetchImpl?: typeof fetch; +}): Promise { + const fetchImpl = input.fetchImpl ?? fetch; + const response = await fetchImpl(new URL('/api/install/actor-credential', input.provider.cloud), { + method: 'POST', + headers: { + authorization: `Bearer ${input.provider.key}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + space: input.provider.space, + mount: input.mount, + actorId: input.actorId, + packageId: input.packageId, + runtimeId: input.runtimeId, + accepts: input.accepts, + emits: input.emits, + subscribes: input.subscribes, + ...(input.ttlSeconds ? { ttlSeconds: input.ttlSeconds } : {}), + }), + }); + const body = await response.json().catch(() => null) as IActorCredentialExchange | null; + if (!response.ok || body?.ok !== true) { + const error = readOptionalString(body?.error) ?? `HTTP_${response.status}`; + throw new Error(`ACTOR_CREDENTIAL_EXCHANGE_FAILED: ${error}`); + } + return body; +} + +export function resolvePackageRunSpec(packageDir: string, options: IPackageRunCommandOptions): IPackageRunSpec { + const loaded = loadMatrixServiceManifest(packageDir); + const entryPath = options.entry + ? resolvePackageEntryPath(loaded.packageDir, options.entry) + : loaded.entryPath; + const exportName = options.export?.trim() || loaded.exportName; + const mount = options.mount?.trim() || loaded.mount; + if (!mount) { + throw new Error('matrix package run requires --mount when the package descriptor does not declare one'); + } + const binding = resolvePackageBindingForMount(loaded, mount); + const componentType = binding?.type ?? loaded.serviceInstance.componentType ?? loaded.serviceInstance.class; + const actorId = componentType || mount; + const runtimeId = `standalone-package:${process.pid}:${sanitizeRuntimeIdPart(loaded.packageName)}:${sanitizeRuntimeIdPart(mount)}`; + return { + packageDir: loaded.packageDir, + packageName: loaded.packageName, + manifestPath: loaded.manifestPath, + entryPath, + exportName, + mount, + actorId, + packageId: loaded.packageName, + runtimeId, + accepts: acceptsArrayToRecord(binding?.accepts ?? []), + emits: {}, + subscribes: {}, + overrides: parseOverrides(options.overrides), + serviceInstance: loaded.serviceInstance, + }; +} + +async function connectActorNats(input: { + readonly natsUrl: string; + readonly authorityRoot: string; + readonly jwt: string; + readonly seed: string; +}): Promise { + const encoder = new TextEncoder(); + return await natsConnect({ + servers: [input.natsUrl], + name: `matrix-actor-run-${input.authorityRoot}-${process.pid}`, + timeout: 5_000, + authenticator: jwtAuthenticator( + () => input.jwt, + () => encoder.encode(input.seed), + ), + }); +} + +export async function loadActorConstructor(entryPath: string, exportName: string): Promise { + const moduleUrl = `${pathToFileURL(entryPath).href}?matrix_actor_run=${Date.now()}-${Math.random().toString(16).slice(2)}`; + const mod = await import(moduleUrl) as Record; + const requested = actorConstructorFromExport(mod[exportName]); + if (requested) { + return requested; + } + const available = Object.entries(mod) + .filter(([, value]) => actorConstructorFromExport(value)) + .map(([name]) => name); + throw new Error(`No actor class found as export "${exportName}". Available exports: ${available.join(', ') || '(none)'}`); +} + +async function loadPackageBootstrap(entryPath: string, exportName: string): Promise { + const moduleUrl = `${pathToFileURL(entryPath).href}?matrix_package_run=${Date.now()}-${Math.random().toString(16).slice(2)}`; + const mod = await import(moduleUrl) as Record; + const bootstrap = mod[exportName]; + if (typeof bootstrap !== 'function') { + throw new Error(`Package run export "${exportName}" was not found in ${entryPath}`); + } + return bootstrap as PackageBootstrapFunction; +} + +function actorConstructorFromExport(value: unknown): ActorConstructor | null { + if (typeof value !== 'function') { + return null; + } + const candidate = value as ActorConstructor; + return candidate.prototype ? candidate : null; +} + +function inferMountFromEntry(entryPath: string): string { + return path.basename(entryPath, path.extname(entryPath)) + .replace(/Actor$/i, '') + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +function readPackageNameForEntry(entryPath: string): string | undefined { + let dir = path.dirname(entryPath); + while (dir !== path.dirname(dir)) { + const packageJsonPath = path.join(dir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const parsed = readJsonFile(packageJsonPath); + return readOptionalString(parsed?.name); + } + dir = path.dirname(dir); + } + return undefined; +} + +function parseProps(value: string | undefined): Record | undefined { + const text = value?.trim(); + if (!text) { + return undefined; + } + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('matrix actor run --props must be a JSON object'); + } + return parsed as Record; +} + +function parseOverrides(value: string | undefined): Record { + const text = value?.trim(); + if (!text) { + return {}; + } + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('matrix package run --overrides must be a JSON object'); + } + return parsed as Record; +} + +async function runOptionalActorCheck( + runtime: MatrixRuntime, + mount: string, + options: IActorRunCommandOptions, +): Promise<{ readonly op: string; readonly result: unknown } | null> { + const op = options.checkOp?.trim(); + if (!op) { + return null; + } + const payload = parseCheckPayload(options.checkPayload); + const result = await RequestReply.execute, unknown>( + runtime.getRootContext(), + mount, + op, + payload, + { timeoutMs: 5_000 }, + ); + return { op, result }; +} + +function parseCheckPayload(value: string | undefined): Record { + const text = value?.trim(); + if (!text) { + return {}; + } + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('matrix actor/package run --check-payload must be a JSON object'); + } + return parsed as Record; +} + +function resolvePackageEntryPath(packageDir: string, entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) { + throw new Error('matrix package run --entry must be a non-empty path'); + } + const entryPath = path.resolve(packageDir, trimmed); + if (!fs.existsSync(entryPath)) { + throw new Error(`matrix package run entry does not exist: ${trimmed}`); + } + return entryPath; +} + +function resolvePackageBindingForMount( + loaded: ILoadedMatrixServiceManifest, + mount: string, +): ILoadedMatrixServiceManifest['packageComponents'][number] | ILoadedMatrixServiceManifest['packageRoot'] | undefined { + if (loaded.packageRoot?.mount === mount) { + return loaded.packageRoot; + } + return loaded.packageComponents.find((entry) => entry.mount === mount); +} + +function acceptsArrayToRecord(accepts: readonly string[]): Record { + const result: Record = {}; + for (const op of accepts) { + result[op] = {}; + } + return result; +} + +function sanitizeRuntimeIdPart(value: string): string { + return value + .replace(/^@/u, '') + .replace(/[^a-zA-Z0-9._-]+/gu, '-') + .replace(/-+/gu, '-') + .replace(/^-|-$/gu, '') + || 'package'; +} + +async function shutdownPackageBootstrapResult(value: unknown, ownedRuntime: MatrixRuntime): Promise { + if (!value || typeof value !== 'object') { + return; + } + const runtime = (value as { readonly runtime?: unknown }).runtime; + if (!runtime || typeof runtime !== 'object') { + return; + } + if (runtime === ownedRuntime) { + return; + } + const shutdown = (runtime as { readonly shutdown?: unknown }).shutdown; + if (typeof shutdown === 'function') { + await shutdown.call(runtime); + } +} + +function waitForActorShutdown(onShutdown: () => Promise): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: unknown): void => { + if (settled) return; + settled = true; + process.off('SIGINT', shutdown); + process.off('SIGTERM', shutdown); + if (error) { + reject(error); + return; + } + resolve(); + }; + const shutdown = (): void => { + void onShutdown().then(() => finish()).catch((error) => finish(error)); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + }); +} + +function readApiKeyCredentialFile(filePath: string): { readonly key?: string; readonly cloud?: string; readonly space?: string } { + const value = readJsonFile(filePath); + return { + ...(readOptionalString(value?.apiKey) ?? readOptionalString(value?.key) ? { key: (readOptionalString(value?.apiKey) ?? readOptionalString(value?.key))! } : {}), + ...(readOptionalString(value?.cloud) ? { cloud: readOptionalString(value?.cloud)! } : {}), + ...(readOptionalString(value?.space) ?? readOptionalString(value?.authorityRoot) ? { space: (readOptionalString(value?.space) ?? readOptionalString(value?.authorityRoot))! } : {}), + }; +} + +function readApiKeyConfigFile(filePath: string): { readonly key?: string; readonly cloud?: string; readonly space?: string } { + const value = readJsonFile(filePath); + const apiKey = value?.apiKey && typeof value.apiKey === 'object' && !Array.isArray(value.apiKey) + ? value.apiKey as Record + : {}; + return { + ...(readOptionalString(apiKey.apiKey) ?? readOptionalString(apiKey.key) ? { key: (readOptionalString(apiKey.apiKey) ?? readOptionalString(apiKey.key))! } : {}), + ...(readOptionalString(value?.cloud) ?? readOptionalString(value?.hivecastCloud) ? { cloud: (readOptionalString(value?.cloud) ?? readOptionalString(value?.hivecastCloud))! } : {}), + ...(readOptionalString(value?.space) ?? readOptionalString(value?.authorityRoot) ? { space: (readOptionalString(value?.space) ?? readOptionalString(value?.authorityRoot))! } : {}), + }; +} + +function readJsonFile(filePath: string): Record | null { + if (!fs.existsSync(filePath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid JSON in ${filePath}: ${message}`); + } +} + +function readRequiredString(value: unknown, label: string): string { + const result = readOptionalString(value); + if (!result) { + throw new Error(`Missing ${label}`); + } + return result; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function resolveEffectiveMount(value: unknown, authorityRoot: string, mount: string): string { + const fromExchange = readOptionalString(value); + if (fromExchange) { + return fromExchange; + } + return `${authorityRoot}.${mount}`; +} + +function normalizeCloudUrl(value: string): string { + const parsed = new URL(value); + return parsed.toString().replace(/\/$/, ''); +} diff --git a/archive/mx-cli-runtime-host-commands/run.ts b/archive/mx-cli-runtime-host-commands/run.ts new file mode 100644 index 0000000..ac5a7bd --- /dev/null +++ b/archive/mx-cli-runtime-host-commands/run.ts @@ -0,0 +1,1052 @@ +/** + * mx run — package-first standalone runner. + * + * Preferred mode: + * mx run . --env dev + * + * Actor-file mode: + * mx run ./MyActor.ts --mount my-svc --root MY-ROOT + */ + +import { pathToFileURL } from 'node:url'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import type { NatsConnection, Subscription } from 'nats'; +import type { MatrixActor } from '@open-matrix/core/core/MatrixActor.js'; +import { + buildMatrixHttpAssetMount, + MatrixHttpAssetEndpointActor, +} from '@open-matrix/system-gateway-http'; +import { HostAuthStateStore, HostCredentialStore } from '@open-matrix/system-auth'; +import { + serviceCommand, + startServiceCommand, + type IServiceCommandOptions, + type IStartedServiceCommand, +} from './service.js'; +import { loadPackageEnvironment, loadPackageEnvironmentFromPath } from '../utils/package-environment.js'; +import { + createRunnerRuntimeHandle, + formatRunnerRuntimeClosed, + type IRunnerRuntimeHandle, +} from '../utils/runner-runtime.js'; +import { resolveNatsBinary } from '../utils/runner-transport.js'; +import { resolveRunnerTransportPlan, type IResolvedRunnerTransportPlan } from '../utils/runner-transport.js'; +import { startStandaloneWebappServer, type IStandaloneWebappServerHandle } from '../utils/standalone-webapp-server.js'; +import { loadMatrixWebappManifest } from '../utils/webapp-manifest.js'; +import { + deregisterRunnerRuntime, + heartbeatRunnerRuntime, + registerRunnerRuntime, + type IRunnerRuntimeRegistration, + type IRunnerWebappRouteMetadata, +} from '../utils/runner-control-plane.js'; +import { + packageDeclaresStandaloneFactory, + type ILoadedMatrixServiceManifest, +} from '../utils/service-manifest.js'; +import { + defaultRunnerControlMount, + defaultRunnerRuntimeMount, + mountRunnerRuntimeControl, + type RunnerRuntimeLifecycleState, +} from '../utils/runner-runtime-control.js'; +import type { ILoadedMatrixWebappManifest } from '../utils/webapp-manifest.js'; + +export interface IRunCommandOptions { + readonly check?: boolean; + readonly json?: boolean; + readonly env?: string; + readonly envFile?: string; + readonly serve?: boolean; + readonly entry?: string; + readonly mount?: string; + readonly root?: string; + readonly export?: string; + readonly config?: string; + readonly secrets?: string; + readonly overrides?: string; + readonly broker?: boolean; + readonly brokerPort?: number; + readonly federation?: boolean; + readonly backboneUrl?: string; + readonly props?: string; +} + +type ActorConstructor = { + new (): MatrixActor; + readonly accepts?: Record; + readonly name: string; +}; + +type BrokerLike = { + publish(topic: string, payload: unknown): void; +}; + +type BrokerConstructor = new () => BrokerLike; +type TransportConstructor = new (broker: BrokerLike, options: { readonly name: string }) => unknown; +type RuntimeConstructor = new () => unknown; +type MatrixContextFactory = { + create(mount: string, transport: unknown, runtime: unknown): Parameters[0]; +}; +type FederationPeerConstructor = new (options: { + readonly localRoot: string; + readonly localBus: unknown; + readonly instanceResolver: unknown; +}) => { + setBackbone(transport: unknown): Promise; + start(): Promise; +}; +type ExactInstanceResolverConstructor = new () => unknown; +type CreateNatsTransport = (client: NatsConnection) => unknown; + +interface IActorFileRunCommandOptions { + readonly mount: string; + readonly root?: string; + readonly export?: string; + readonly broker?: boolean; + readonly brokerPort?: number; + readonly federation?: boolean; + readonly backboneUrl?: string; + readonly props?: string; +} + +interface IRunningNatsServer { + readonly port: number; + stop(): Promise; +} + +// P1.65 — set process.title for ps/tasklist visibility. Sourcing order: +// MATRIX_RUNTIME_KEY env → packageRef shortname → packageDir basename +// → MATRIX_RUNTIME_ID stripped → default 'runtime'. +function setRuntimeProcessTitle(input: { + readonly runtimeKey?: string; + readonly runtimeId?: string; + readonly packageDir?: string; +}): void { + const fromKey = readTrimmed(input.runtimeKey); + const fromPackageRef = readPackageRefShortName(input.packageDir); + const fromDir = readPackageDirBasename(input.packageDir); + const fromRuntimeId = readRuntimeIdShortName(input.runtimeId); + const name = fromKey ?? fromPackageRef ?? fromDir ?? fromRuntimeId ?? 'runtime'; + process.title = `hivecast-runtime:${normalizeRuntimeTitleName(name)}`; +} + +function readTrimmed(value: string | undefined): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function readPackageRefShortName(packageDir: string | undefined): string | undefined { + if (!packageDir) return undefined; + try { + const manifestPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(manifestPath)) return undefined; + const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { name?: unknown }; + const name = typeof parsed.name === 'string' ? parsed.name : undefined; + if (!name) return undefined; + return name.split('/').pop(); + } catch { + return undefined; + } +} + +function readPackageDirBasename(packageDir: string | undefined): string | undefined { + if (!packageDir) return undefined; + const base = path.basename(packageDir); + return base.length > 0 ? base : undefined; +} + +function readRuntimeIdShortName(runtimeId: string | undefined): string | undefined { + const value = readTrimmed(runtimeId); + if (!value) return undefined; + return value + .replace(/^RUNTIME-/i, '') + .replace(/^HOST-/i, '') + .replace(/^LOCAL-/i, ''); +} + +function normalizeRuntimeTitleName(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/^@[^/]+\//, '') + .replace(/@[^@/]+$/, '') + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'runtime'; +} + +async function waitForPort( + port: number, + timeoutMs: number, + child: ChildProcessWithoutNullStreams, + getLogs: () => string, + getSpawnError?: () => Error | null, +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const spawnError = getSpawnError?.(); + if (spawnError) { + throw new Error(`Failed to spawn nats-server: ${spawnError.message}`); + } + if (child.exitCode != null) { + throw new Error(`nats-server exited early (${child.exitCode}): ${getLogs()}`); + } + + const listening = await new Promise((resolve) => { + const socket = net.connect({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => { + socket.destroy(); + resolve(false); + }); + socket.setTimeout(500, () => { + socket.destroy(); + resolve(false); + }); + }); + + if (listening) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error(`Timed out waiting for nats-server on port ${port}: ${getLogs()}`); +} + +async function stopChildProcess(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode != null) { + return; + } + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (child.exitCode == null) { + child.kill('SIGKILL'); + } + }, 2000); + + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + + child.kill('SIGTERM'); + }); +} + +async function startNatsServer(port: number, baseDir: string): Promise { + const binaryPath = resolveNatsBinary(baseDir); + const child = spawn(binaryPath, ['-a', '127.0.0.1', '-p', String(port), '-js'], { + stdio: 'pipe', + env: process.env, + windowsHide: true, + }); + + let spawnError: Error | null = null; + let stderr = ''; + child.once('error', (error: Error) => { + spawnError = error; + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + + await waitForPort(port, 10_000, child, () => { + if (spawnError) { + return `${spawnError.message}${stderr ? ` | ${stderr.trim()}` : ''}`; + } + return stderr.trim(); + }, () => spawnError); + + return { + port, + async stop(): Promise { + await stopChildProcess(child); + }, + }; +} + +function localTopicToNatsSubject(topic: string, root: string, mount: string): string | null { + if (topic === `${mount}/$events`) { + return `${root}.${mount}.$events`; + } + if (topic.startsWith('$replies.')) { + return `${root}.$reply.${topic.slice('$replies.'.length)}`; + } + if (topic.startsWith(`${root}.$reply.`)) { + return topic; + } + return null; +} + +function decodeNatsPayload( + data: Uint8Array, + jsonCodec: { decode(payload: Uint8Array): unknown }, + stringCodec: { decode(payload: Uint8Array): string }, +): unknown { + try { + return jsonCodec.decode(data); + } catch { + const text = stringCodec.decode(data); + try { + return JSON.parse(text); + } catch { + return text; + } + } +} + +function pumpSubscription( + subscription: Subscription, + label: string, + onMessage: (subject: string, payload: unknown) => void, + jsonCodec: { decode(payload: Uint8Array): unknown }, + stringCodec: { decode(payload: Uint8Array): string }, +): void { + void (async () => { + for await (const message of subscription) { + const payload = decodeNatsPayload(message.data, jsonCodec, stringCodec); + onMessage(message.subject, payload); + } + })().catch((error) => { + console.error(`[mx run] ${label} subscription failed: ${error}`); + }); +} + +export async function runCommand( + targetPath: string, + options: IRunCommandOptions, +): Promise { + const resolvedPath = path.resolve(targetPath); + if (!fs.existsSync(resolvedPath)) { + throw new Error(`Run target not found: ${resolvedPath}`); + } + + const targetStats = fs.statSync(resolvedPath); + if (targetStats.isDirectory()) { + await runPackageCommand(resolvedPath, options); + return; + } + + await runActorFileCommand(resolvedPath, assertActorFileRunOptions(options)); +} + +async function runPackageCommand( + packageDir: string, + options: IRunCommandOptions, +): Promise { + // P1.65 — name the runtime process for ps/tasklist. Sourcing order: + // MATRIX_RUNTIME_KEY env → packageRef shortname → packageDir basename + // → MATRIX_RUNTIME_ID → 'runtime'. + setRuntimeProcessTitle({ + runtimeKey: process.env.MATRIX_RUNTIME_KEY, + runtimeId: process.env.MATRIX_RUNTIME_ID, + packageDir, + }); + const hasServiceManifest = packageDeclaresStandaloneFactory(packageDir); + const webapp = loadMatrixWebappManifest(packageDir); + + if (!options.serve && !hasServiceManifest && !webapp) { + throw new Error( + `Package run target ${packageDir} does not declare a matrix.json runtime.factory. ` + + 'Use a package directory with runtime.factory metadata, or pass an actor file path with --mount.', + ); + } + + if (options.serve || (!hasServiceManifest && webapp)) { + await runStandaloneWebappPackage(packageDir, options, { + hasServiceManifest, + webapp, + serve: options.serve === true, + }); + return; + } + + const result = await serviceCommand(packageDir, { + check: options.check, + json: options.json, + env: options.env, + envFile: options.envFile, + entry: options.entry, + export: options.export, + root: options.root, + mount: options.mount, + config: options.config, + secrets: options.secrets, + overrides: options.overrides, + } satisfies IServiceCommandOptions); + + if (!options.check) { + return; + } + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(`Validated run-in-place bootstrap for ${result.packageName}`); + console.log(`manifest: ${result.manifestPath}`); + console.log(`entry: ${result.entryPath}`); + console.log(`export: ${result.exportName}`); +} + +async function runStandaloneWebappPackage( + packageDir: string, + options: IRunCommandOptions, + state: { + readonly hasServiceManifest: boolean; + readonly webapp: ReturnType; + readonly serve: boolean; + }, +): Promise { + if (!state.webapp) { + throw new Error(`Package run target ${packageDir} does not declare matrix.json webapp metadata`); + } + const envName = options.env?.trim(); + const envFile = options.envFile?.trim(); + if (!envName) { + throw new Error('Package webapp runtime mode requires --env '); + } + + const loadedEnvironment = envFile + ? loadPackageEnvironmentFromPath(packageDir, envFile, envName) + : loadPackageEnvironment(packageDir, envName); + const root = options.root?.trim() || loadedEnvironment.environment.runtime.root; + const runnerTransport = resolveRunnerTransportPlan(packageDir, loadedEnvironment, root); + + let serviceHandle: IStartedServiceCommand | undefined; + let runnerRuntimeHandle: IRunnerRuntimeHandle | undefined; + let webappServer: IStandaloneWebappServerHandle | undefined; + let runtimeRegistration: IRunnerRuntimeRegistration | undefined; + let webappAssetMount: string | undefined; + let heartbeatTimer: NodeJS.Timeout | undefined; + let runtimeReclaimAttempts = 0; + let lifecycleState: RunnerRuntimeLifecycleState = 'starting'; + let requestRuntimeShutdown: (reason: string) => void = () => undefined; + const shutdownRequested = new Promise((resolve) => { + requestRuntimeShutdown = resolve; + }); + + try { + if (state.hasServiceManifest) { + serviceHandle = await startServiceCommand(packageDir, { + env: envName, + envFile, + entry: options.entry, + export: options.export, + root: options.root, + mount: options.mount, + config: options.config, + secrets: options.secrets, + overrides: options.overrides, + } satisfies IServiceCommandOptions); + } else { + runnerRuntimeHandle = await createRunnerRuntimeHandle(root, runnerTransport); + registerHostHomeService(runnerRuntimeHandle.runtime, loadedEnvironment.environment.host?.matrixDir); + const runtimeId = resolveWebappRuntimeId(state.webapp, loadedEnvironment); + const runtimeMount = loadedEnvironment.environment.runtime.runtimeMount + ?? process.env.MATRIX_RUNTIME_MOUNT + ?? defaultRunnerRuntimeMount(runtimeId); + const controlMount = loadedEnvironment.environment.runtime.controlMount + ?? process.env.MATRIX_RUNTIME_CONTROL_MOUNT + ?? defaultRunnerControlMount(runtimeId); + await mountRunnerRuntimeControl({ + runtime: runnerRuntimeHandle.runtime, + runtimeId, + runtimeMount, + controlMount, + startedAt: new Date().toISOString(), + getState: () => lifecycleState, + requestShutdown: (reason) => requestRuntimeShutdown(reason), + loadedPackage: { + packageRef: state.webapp.packageName, + }, + }); + if (state.serve) { + webappServer = await startStandaloneWebappServer({ + loadedEnvironment, + webapp: state.webapp, + runnerTransport, + }); + } + webappAssetMount = await mountMatrixHttpAssetEndpoint(runnerRuntimeHandle.runtime, state.webapp, runtimeId); + runtimeRegistration = await registerRunnerRuntime( + runnerRuntimeHandle.runtime, + buildWebappRuntimeManifest(state.webapp), + loadedEnvironment, + runnerTransport, + undefined, + runtimeMount, + controlMount, + buildRunnerWebappRouteMetadata(state.webapp, webappAssetMount, webappServer), + ); + if (!options.check) { + const heartbeatRuntime = runnerRuntimeHandle.runtime; + heartbeatTimer = setInterval(() => { + void (async () => { + const heartbeat = await heartbeatRunnerRuntime(heartbeatRuntime, runtimeRegistration); + if (!heartbeat || heartbeat.known) { + runtimeReclaimAttempts = 0; + return; + } + runtimeReclaimAttempts += 1; + if (runtimeReclaimAttempts === 1 || runtimeReclaimAttempts % 10 === 0) { + console.error( + `[mx run] registry forgot runtime claim for ${heartbeat.runtimeId} (renewed=0); reclaiming`, + ); + } + const reclaimed = await runtimeRegistration?.reclaim(); + if (reclaimed) { + console.error( + `[mx run] runtime reclaim recovered for ${heartbeat.runtimeId} after ${runtimeReclaimAttempts} attempt(s): ${reclaimed.inventoryClaimCount} inventory claims re-issued`, + ); + runtimeReclaimAttempts = 0; + } + })().catch((error: unknown) => { + if (runtimeReclaimAttempts === 1 || runtimeReclaimAttempts % 10 === 0) { + console.error( + `[mx run] runtime reclaim failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }); + }, 10_000); + } + } + + if (state.serve && serviceHandle) { + webappServer = await startStandaloneWebappServer({ + loadedEnvironment, + webapp: state.webapp, + runnerTransport: serviceHandle?.result.runnerTransport ?? runnerTransport, + }); + } + lifecycleState = 'live'; + + const buildServeResult = () => ({ + packageName: state.webapp?.packageName, + packageDir, + environmentName: loadedEnvironment.envName, + environmentPath: loadedEnvironment.environmentPath, + root, + mode: state.serve ? 'standalone-webapp' : 'webapp-assets', + ...(webappAssetMount ? { assetMount: webappAssetMount } : {}), + ...(webappServer ? { + baseUrl: webappServer.baseUrl, + httpPort: webappServer.port, + bootstrapPath: webappServer.bootstrapPath, + natsWsPath: webappServer.natsWsPath, + } : {}), + runnerTransport: serviceHandle?.result.runnerTransport ?? runnerTransport, + ...(serviceHandle ? { service: serviceHandle.result } : {}), + }); + + const shutdownAll = async (): Promise => { + lifecycleState = 'stopping'; + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = undefined; + } + if (webappServer) { + const current = webappServer; + webappServer = undefined; + await current.shutdown(); + } + if (serviceHandle) { + const current = serviceHandle; + serviceHandle = undefined; + await current.shutdown(); + } + if (runnerRuntimeHandle) { + const current = runnerRuntimeHandle; + runnerRuntimeHandle = undefined; + await deregisterRunnerRuntime(current.runtime, runtimeRegistration); + await current.shutdown(); + } + lifecycleState = 'stopped'; + }; + + if (options.check) { + const result = buildServeResult(); + await shutdownAll(); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else if (state.serve) { + console.log(`Validated standalone webapp serve for ${state.webapp.packageName}`); + console.log(`base: ${result.baseUrl}`); + } else { + console.log(`Validated webapp asset runtime for ${state.webapp.packageName}`); + console.log(`asset mount: ${result.assetMount ?? '(service runtime)'}`); + } + return; + } + + if (options.json) { + console.log(JSON.stringify(buildServeResult(), null, 2)); + } else if (state.serve && webappServer) { + console.log(`[mx run] serving ${state.webapp.packageName} at ${webappServer.baseUrl}`); + console.log(`[mx run] bootstrap: ${webappServer.baseUrl}${webappServer.bootstrapPath}`); + console.log(`[mx run] nats-ws: ${webappServer.baseUrl}${webappServer.natsWsPath}`); + } else { + console.log(`[mx run] running ${state.webapp.packageName} web assets at ${webappAssetMount ?? '(service runtime)'}`); + } + + const stopTriggers: Promise[] = [ + waitForShutdownSignal(shutdownAll), + shutdownRequested.then(async () => { + await shutdownAll(); + }), + ]; + if (serviceHandle) { + stopTriggers.push(serviceHandle.shutdownRequested.then(async () => { + await shutdownAll(); + })); + if (serviceHandle.transportClosed) { + stopTriggers.push(serviceHandle.transportClosed.then((closed) => { + throw new Error(formatRunnerRuntimeClosed(closed)); + })); + } + } + if (runnerRuntimeHandle?.closed) { + stopTriggers.push(runnerRuntimeHandle.closed.then((closed) => { + throw new Error(formatRunnerRuntimeClosed(closed)); + })); + } + + await Promise.race(stopTriggers); + return; + } catch (error) { + lifecycleState = 'failed'; + if (webappServer) { + await webappServer.shutdown().catch(() => undefined); + } + if (serviceHandle) { + await serviceHandle.shutdown().catch(() => undefined); + } + if (runnerRuntimeHandle) { + await deregisterRunnerRuntime(runnerRuntimeHandle.runtime, runtimeRegistration).catch(() => undefined); + await runnerRuntimeHandle.shutdown().catch(() => undefined); + } + throw error; + } +} + +function registerHostHomeService( + runtime: IRunnerRuntimeHandle['runtime'], + matrixDir: string | undefined, +): void { + const hostHome = matrixDir?.trim(); + if (hostHome) { + runtime.registerService('matrix-host-home', hostHome); + runtime.registerService('credential-store', new HostCredentialStore(new HostAuthStateStore(hostHome))); + } +} + +async function mountMatrixHttpAssetEndpoint( + runtime: IRunnerRuntimeHandle['runtime'], + webapp: ILoadedMatrixWebappManifest, + runtimeId?: string, +): Promise { + const assetMount = buildMatrixHttpAssetMount(webapp.appName, runtimeId); + if (!runtime.hasComponent(assetMount)) { + await runtime.createSupervised(MatrixHttpAssetEndpointActor as unknown as new () => MatrixActor, assetMount, { + assetEndpoint: { + packageName: webapp.packageName, + appName: webapp.appName, + distDir: webapp.distDir, + entryFile: webapp.entryFile, + }, + }); + } + return assetMount; +} + +function buildRunnerWebappRouteMetadata( + webapp: ILoadedMatrixWebappManifest, + assetMount: string, + webappServer?: IStandaloneWebappServerHandle, +): IRunnerWebappRouteMetadata { + return { + appName: webapp.appName, + routePrefix: `/apps/${webapp.appName}/`, + ...(webapp.displayName ? { displayName: webapp.displayName } : {}), + ...(webapp.icon ? { icon: webapp.icon } : {}), + ...(typeof webapp.navOrder === 'number' ? { navOrder: webapp.navOrder } : {}), + ...(webapp.description ? { description: webapp.description } : {}), + ...(webapp.shells && webapp.shells.length > 0 ? { shells: webapp.shells } : {}), + assetMount, + ...(webappServer ? { + origin: webappServer.baseUrl, + port: webappServer.port, + } : {}), + }; +} + +function buildWebappRuntimeManifest( + webapp: NonNullable>, +): ILoadedMatrixServiceManifest { + return { + packageDir: webapp.packageDir, + packageName: webapp.packageName, + manifestPath: webapp.manifestPath, + entryPath: path.join(webapp.distDir, webapp.entryFile), + exportName: 'webapp', + kind: 'factory', + overrides: {}, + packageComponents: [], + serviceInstance: { + id: webapp.packageName, + packageName: webapp.packageName, + class: 'webapp', + rootKind: 'package-root', + autoStart: true, + registrationSource: 'matrix-service', + }, + }; +} + +function resolveWebappRuntimeId( + webapp: NonNullable>, + loadedEnvironment: ReturnType, +): string { + const runtimeId = loadedEnvironment.environment.runtime.runtimeId?.trim(); + if (runtimeId) { + return runtimeId; + } + return webapp.packageName; +} + +function actorConstructorFromExport(value: unknown): ActorConstructor | null { + if (typeof value !== 'function') { + return null; + } + return value as ActorConstructor; +} + +function requireFunctionExport( + moduleExports: Record, + exportName: string, +): T { + const value = moduleExports[exportName]; + if (typeof value !== 'function') { + throw new Error(`Expected function export ${exportName}`); + } + return value as T; +} + +function requireObjectExport( + moduleExports: Record, + exportName: string, +): T { + const value = moduleExports[exportName]; + if (!value || typeof value !== 'object') { + throw new Error(`Expected object export ${exportName}`); + } + return value as T; +} + +function assertActorFileRunOptions(options: IRunCommandOptions): IActorFileRunCommandOptions { + const mount = options.mount?.trim(); + if (!mount) { + throw new Error( + 'Actor-file mode requires --mount. ' + + 'For package execution use: mx run . --env ', + ); + } + return { + mount, + root: options.root, + export: options.export, + broker: options.broker, + brokerPort: options.brokerPort, + federation: options.federation, + backboneUrl: options.backboneUrl, + props: options.props, + }; +} + +async function waitForShutdownSignal(onShutdown: () => Promise): Promise { + await new Promise((resolve, reject) => { + let settled = false; + const complete = (error?: unknown) => { + if (settled) { + return; + } + settled = true; + process.off('SIGINT', handleSignal); + process.off('SIGTERM', handleSignal); + if (error) { + reject(error); + return; + } + resolve(); + }; + + const handleSignal = () => { + void onShutdown() + .then(() => complete()) + .catch((error) => complete(error)); + }; + + process.on('SIGINT', handleSignal); + process.on('SIGTERM', handleSignal); + }); +} + +async function runActorFileCommand( + actorPath: string, + options: IActorFileRunCommandOptions, +): Promise { + const root = options.root ?? 'standalone'; + const mount = options.mount; + const exportName = options.export ?? 'default'; + const props = options.props ? JSON.parse(options.props) : {}; + + // ── Step 1: Import actor class ────────────────────────────────────── + const moduleUrl = pathToFileURL(actorPath).href; + const mod = await import(moduleUrl) as Record; + + // Try named export, then default, then first export that looks like a class + let ActorClass = actorConstructorFromExport(mod[exportName]); + if (!ActorClass && exportName === 'default') { + // Try first exported class + for (const [key, val] of Object.entries(mod)) { + const candidate = actorConstructorFromExport(val); + if (candidate && key !== 'default') { + ActorClass = candidate; + console.log(`[mx run] Using export: ${key}`); + break; + } + } + } + if (!ActorClass) { + const available = Object.keys(mod).filter(k => actorConstructorFromExport(mod[k])); + throw new Error( + `No actor class found as export '${exportName}'. Available exports: ${available.join(', ') || '(none)'}` + ); + } + + // ── Step 2: Set up transport ──────────────────────────────────────── + // + // Three modes: + // 1. InMemory only (default) — local actors talk, no external access + // 2. +broker — starts embedded NATS NATS on a port, CLI can connect + // 3. +federation — connects to NATS hub backbone, world can reach us + + // Dynamic imports — import individual modules to avoid barrel hangs + const cwd = process.cwd(); + + async function importModule(relativePath: string): Promise> { + const full = path.resolve(cwd, relativePath); + return await import(pathToFileURL(full).href) as Record; + } + + const [brokerMod, transportMod, contextMod, runtimeMod] = await Promise.all([ + importModule('src/transport/InMemoryBroker.ts'), + importModule('src/transport/InMemoryTransport.ts'), + importModule('src/engine/core/MatrixContext.ts'), + importModule('src/core/MockRuntime.ts'), + ]); + + const InMemoryBroker = requireFunctionExport(brokerMod, 'InMemoryBroker'); + const InMemoryTransport = requireFunctionExport(transportMod, 'InMemoryTransport'); + const MatrixContext = requireObjectExport(contextMod, 'MatrixContext'); + const MockRuntime = requireFunctionExport(runtimeMod, 'MockRuntime'); + + const broker = new InMemoryBroker(); + const transport = new InMemoryTransport(broker, { name: 'mx-run' }); + const runtime = new MockRuntime(); + + // ── Step 3: Optional embedded NATS NATS server ────────────────────────────── + let natsServer: IRunningNatsServer | null = null; + let natsClient: NatsConnection | null = null; + + if (options.broker || options.federation) { + const port = options.brokerPort ?? 4222; + try { + const { connect, JSONCodec, StringCodec } = await import('nats'); + const jsonCodec = JSONCodec(); + const stringCodec = StringCodec(); + + natsServer = await startNatsServer(port, cwd); + + console.log(`[mx run] NATS server listening on nats://127.0.0.1:${port}`); + + // Bridge InMemoryBroker ↔ local NATS so actors are reachable via NATS + natsClient = await connect({ + servers: `nats://127.0.0.1:${port}`, + name: `mx-run-${process.pid}`, + timeout: 5000, + }); + + const actorInbox = `${root}.${mount}.$inbox`; + pumpSubscription( + natsClient.subscribe(actorInbox), + actorInbox, + (_subject, payload) => { + broker.publish(`${mount}/$inbox`, payload); + }, + jsonCodec, + stringCodec, + ); + + const rootInbox = `${root}.$inbox`; + pumpSubscription( + natsClient.subscribe(rootInbox), + rootInbox, + (_subject, payload) => { + broker.publish('$inbox', payload); + }, + jsonCodec, + stringCodec, + ); + + // Hook actor replies — the actor publishes to the replyTo topic + const origPublish = broker.publish.bind(broker); + broker.publish = (topic: string, payload: unknown) => { + origPublish(topic, payload); + const subject = localTopicToNatsSubject(topic, root, mount); + if (subject) { + natsClient?.publish(subject, jsonCodec.encode(payload)); + } + }; + } catch (err) { + throw new Error(`Failed to start NATS server for mx run: ${String(err)}`); + } + } + + // ── Step 4: Optional Federation ───────────────────────────────────── + if (options.federation) { + const backboneUrl = options.backboneUrl ?? 'nats://hub.example.com:4222'; + try { + const fedPath = path.resolve(cwd, 'packages', 'federation', 'dist', 'src', 'index.js'); + const fedMod = await import(pathToFileURL(fedPath).href) as Record; + + const FederationPeer = requireFunctionExport(fedMod, 'FederationPeer'); + const ExactInstanceResolver = requireFunctionExport(fedMod, 'ExactInstanceResolver'); + const createNatsTransport = requireFunctionExport(fedMod, 'createNatsTransport'); + const { connect } = await import('nats'); + + // Connect to backbone + const backboneClient = await connect({ + servers: backboneUrl, + name: `mx-run-fed-${root}-${process.pid}`, + timeout: 10000, + }); + + console.log(`[mx run] Federation backbone connected: ${backboneUrl}`); + + const backboneTransport = createNatsTransport(backboneClient); + + if (!natsServer) { + throw new Error('Federation mode requires the embedded NATS server to be running'); + } + const localNats = await connect({ + servers: `nats://127.0.0.1:${options.brokerPort ?? 4222}`, + name: `mx-run-fed-local-${process.pid}`, + timeout: 3000, + }); + const localBus = createNatsTransport(localNats); + + const peer = new FederationPeer({ + localRoot: root, + localBus, + instanceResolver: new ExactInstanceResolver(), + }); + + await peer.setBackbone(backboneTransport); + await peer.start(); + + console.log(`[mx run] FederationPeer active for root: ${root}`); + console.log(`[mx run] Actor reachable at: ${root}/${mount}`); + } catch (err) { + throw new Error(`Federation setup failed for mx run: ${String(err)}`); + } + } + + // ── Step 5: Boot the actor ────────────────────────────────────────── + const context = MatrixContext.create(mount, transport, runtime); + const actor = new ActorClass(); + await actor.initialize(context, ActorClass.name || 'Actor'); + + // Assign actor properties requested by the CLI. + for (const [key, value] of Object.entries(props)) { + Object.defineProperty(actor, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); + } + + const accepts = ActorClass.accepts + ? Object.keys(ActorClass.accepts).join(', ') + : 'N/A'; + + console.log(''); + console.log('╔════════════════════════════════════════════════╗'); + console.log('║ mx run — Actor Live ║'); + console.log('╠════════════════════════════════════════════════╣'); + console.log(`║ Actor: ${ActorClass.name}`.padEnd(49) + '║'); + console.log(`║ Mount: ${mount}`.padEnd(49) + '║'); + console.log(`║ Root: ${root}`.padEnd(49) + '║'); + console.log(`║ Accepts: ${accepts}`.padEnd(49) + '║'); + if (options.broker || options.federation) { + console.log(`║ Broker: nats://127.0.0.1:${options.brokerPort ?? 4222}`.padEnd(49) + '║'); + } + if (options.federation) { + console.log(`║ Federation: CONNECTED`.padEnd(49) + '║'); + console.log(`║ URI: ${root}/${mount}`.padEnd(49) + '║'); + } + console.log('╚════════════════════════════════════════════════╝'); + console.log(''); + + if (options.broker) { + console.log(`Talk to this actor:`); + console.log(` matrix send ${mount} $introspect -r ${root}`); + console.log(''); + } + + if (options.federation) { + console.log(`From FlowPad or a federated client:`); + console.log(` flow().fromValue({}).viaRemote("${root}/${mount}", "introspect")`); + console.log(''); + } + + console.log('Press Ctrl+C to stop.'); + + // Keep process alive + await new Promise(() => { + const shutdown = async () => { + console.log('\n[mx run] Shutting down...'); + try { + if (natsClient && !natsClient.isClosed()) { + await natsClient.drain(); + } + } catch { + // Best-effort shutdown only. + } + try { + await natsServer?.stop(); + } catch { + // Best-effort shutdown only. + } + process.exit(0); + }; + + process.on('SIGINT', () => { + void shutdown(); + }); + }); +} diff --git a/archive/mx-cli-runtime-host-commands/runner-control-plane.ts b/archive/mx-cli-runtime-host-commands/runner-control-plane.ts new file mode 100644 index 0000000..06913ce --- /dev/null +++ b/archive/mx-cli-runtime-host-commands/runner-control-plane.ts @@ -0,0 +1,1418 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { RequestReply } from '@open-matrix/core/engine/remoting/RequestReply.js'; +import type { MatrixActor } from '@open-matrix/core/core/MatrixActor.js'; +import { MatrixRuntime } from '@open-matrix/core/runtime/MatrixRuntime.js'; +import { + countRuntimeDirectory, + publishRuntimePresence, +} from '@open-matrix/core'; +import type { + MatrixMountCardinality, + MatrixPlacementClaimMode, + RuntimeInventoryPlacementMetadata, +} from '@open-matrix/contracts/placement'; +import type { RuntimePresenceRecordV1 } from '@open-matrix/contracts/runtime-presence'; +import type { ILoadedMatrixPackageEnvironment } from './package-environment.js'; +import type { IResolvedRunnerTransportPlan } from './runner-transport.js'; +import type { + ILoadedMatrixBindingDeclaration, + ILoadedMatrixServiceManifest, +} from './service-manifest.js'; +import { readRunnerPlacementStandbyResults } from './runner-placement-policy.js'; +import { BindingsRegistryActor } from '@open-matrix/system-bindings'; +import { ServiceRegistryActor } from '@open-matrix/system-platform'; +import { RuntimeManagerActor } from '@open-matrix/system-runtimes'; + +const RUNTIME_REGISTRY_MOUNT = 'system.runtimes'; +const LOGICAL_REGISTRY_MOUNT = 'system.registry'; +const BINDINGS_REGISTRY_MOUNT = 'system.bindings'; +const RUNTIME_PRESENCE_TTL_MS = 30_000; +const LOCAL_CONTROL_PLANE_REQUEST_TIMEOUT_MS = 5_000; +const LINKED_CONTROL_PLANE_REQUEST_TIMEOUT_MS = 15_000; + +interface IRuntimeInventoryEntry { + readonly mount: string; + readonly type?: string; + readonly runtimeId: string; + readonly claimKind: MatrixPlacementClaimMode; + readonly placement: RuntimeInventoryPlacementMetadata; + readonly description?: string; + readonly hasChildren?: boolean; +} + +interface IPlacementDeclarationIndex { + readonly byMount: ReadonlyMap; +} + +export interface IRunnerControlPlaneState { + readonly runtimeRegistryMount: string; + readonly logicalRegistryMount: string; + readonly bindingsRegistryMount: string; + readonly createdRuntimeRegistry: boolean; + readonly createdLogicalRegistry: boolean; + readonly createdBindingsRegistry: boolean; +} + +export interface IRunnerRuntimeRegistration { + readonly runtimeId: string; + readonly runtimeRoot: string; + readonly presence: RuntimePresenceRecordV1; + readonly registryMount: string; + readonly logicalRegistryMount: string; + readonly controlTargetRoot?: string; + readonly protocolRequest?: boolean; + readonly runtimeMount?: string; + readonly controlMount?: string; + readonly created: boolean; + readonly actorCount: number; + readonly inventoryClaimCount: number; + readonly runtimeCount: number; + readonly registryOptional?: boolean; + /** + * Re-issue the inventory `registry.claim` calls and re-publish presence. + * Used by the heartbeat tick when `registry.claim.renew` reports the + * registry no longer has our claim (e.g. after the registry actor's + * in-memory `_claims` map was wiped by a SYSTEM runtime restart). + * Idempotent: `onRegistryClaim` preserves `registeredAt`/`claimedAt` + * when an existing entry is found. + */ + readonly reclaim: () => Promise<{ inventoryClaimCount: number }>; +} + +export interface IRunnerRuntimeDeregistration { + readonly runtimeId: string; + readonly registryMount: string; + readonly removed: boolean; + readonly runtimeCount: number; +} + +export interface IRunnerRuntimeHeartbeat { + readonly runtimeId: string; + readonly known: boolean; +} + +export interface IRunnerWebappRouteMetadata { + readonly appName: string; + readonly routePrefix?: string; + readonly displayName?: string; + readonly icon?: string; + readonly navOrder?: number; + readonly description?: string; + readonly shells?: readonly string[]; + readonly assetMount?: string; + readonly origin?: string; + readonly port?: number; +} + +export interface IRunnerBindingRegistration { + readonly runtimeId: string; + readonly registryMount: string; + readonly logicalRegistryMount: string; + readonly controlTargetRoot?: string; + readonly protocolRequest?: boolean; + readonly registeredCount: number; + readonly registryClaimCount: number; + readonly registryOptional?: boolean; + readonly bindings: ReadonlyArray<{ + readonly binding: string; + readonly localMount: string; + readonly ops: readonly string[]; + readonly packageName: string; + }>; + /** + * Re-issue every server-side record this runner published at registration + * time: the canonical-binding `registry.claim` calls AND the + * `bindings.register` calls. Both are in-memory on their respective + * registry actors, so a single SYSTEM restart wipes them together. The + * heartbeat tick triggers reclaim when `registry.claim.renew` reports the + * registry no longer has our claims; bindings.register loss is treated as + * a co-occurring symptom of the same SYSTEM restart and reclaimed in + * lockstep. Both server-side ops are idempotent (onRegistryClaim + * preserves identity; bindings.register is keyed by provider+match), so + * calling reclaim() against a healthy registry is harmless. + */ + readonly reclaim: () => Promise<{ + readonly registryClaimCount: number; + readonly bindingsRegisteredCount: number; + }>; +} + +export interface IRunnerBindingDeregistration { + readonly runtimeId: string; + readonly registryMount: string; + readonly logicalRegistryMount: string; + readonly removedCount: number; + readonly registryRemovedCount: number; +} + +export interface IRunnerBindingRenewal { + readonly runtimeId: string; + readonly refreshed: number; +} + +interface ICanonicalBindingDefinition { + readonly binding: string; + readonly localMount: string; + readonly ops: readonly string[]; + readonly packageName: string; +} + +interface IRunnerHostPlacement { + readonly deviceId: string; + readonly hostId: string; + readonly hostName?: string; + readonly deviceSlug?: string; +} + +interface IControlPlaneRequestOptions { + readonly timeoutMs: number; + readonly targetRoot?: string; + readonly protocolRequest?: boolean; +} + +export async function ensureRunnerControlPlane(runtime: MatrixRuntime): Promise { + const createdRuntimeRegistry = await ensureRegistryActor(runtime, RUNTIME_REGISTRY_MOUNT, RuntimeManagerActor); + const createdLogicalRegistry = await ensureRegistryActor(runtime, LOGICAL_REGISTRY_MOUNT, ServiceRegistryActor); + const createdBindingsRegistry = await ensureRegistryActor(runtime, BINDINGS_REGISTRY_MOUNT, BindingsRegistryActor); + return { + runtimeRegistryMount: RUNTIME_REGISTRY_MOUNT, + logicalRegistryMount: LOGICAL_REGISTRY_MOUNT, + bindingsRegistryMount: BINDINGS_REGISTRY_MOUNT, + createdRuntimeRegistry, + createdLogicalRegistry, + createdBindingsRegistry, + }; +} + +export interface IEnsureRunnerControlPlaneOptions { + readonly createLocalRegistries?: boolean; +} + +export async function ensureRunnerControlPlaneForMode( + runtime: MatrixRuntime, + options: IEnsureRunnerControlPlaneOptions = {}, +): Promise { + const createLocalRegistries = options.createLocalRegistries ?? true; + const createdRuntimeRegistry = createLocalRegistries + ? await ensureRegistryActor(runtime, RUNTIME_REGISTRY_MOUNT, RuntimeManagerActor) + : false; + const createdLogicalRegistry = createLocalRegistries + ? await ensureRegistryActor(runtime, LOGICAL_REGISTRY_MOUNT, ServiceRegistryActor) + : false; + const createdBindingsRegistry = createLocalRegistries + ? await ensureRegistryActor(runtime, BINDINGS_REGISTRY_MOUNT, BindingsRegistryActor) + : false; + return { + runtimeRegistryMount: RUNTIME_REGISTRY_MOUNT, + logicalRegistryMount: LOGICAL_REGISTRY_MOUNT, + bindingsRegistryMount: BINDINGS_REGISTRY_MOUNT, + createdRuntimeRegistry, + createdLogicalRegistry, + createdBindingsRegistry, + }; +} + +export async function registerRunnerRuntime( + runtime: MatrixRuntime, + manifest: ILoadedMatrixServiceManifest, + environment: ILoadedMatrixPackageEnvironment | undefined, + runnerTransport: IResolvedRunnerTransportPlan | undefined, + serviceMount?: string, + runtimeMount?: string, + controlMount?: string, + webappRoute?: IRunnerWebappRouteMetadata, +): Promise { + const controlPlane = await ensureRunnerControlPlaneForMode(runtime, { + createLocalRegistries: !isHostManagedEnvironment(environment), + }); + const runtimeId = resolveRunnerRuntimeId(manifest, environment); + const runtimeRoot = readTransportRoot(runtime) + ?? environment?.environment.runtime.root + ?? manifest.root + ?? runtimeId; + const hostPlacement = resolveRunnerHostPlacement(runtime, environment); + const registryOptional = isHostedAuthorityLeafEnvironment(runtime, environment); + const actorCount = runtime.getAllComponents().length; + const placementDeclarations = readPlacementDeclarations(manifest); + const requestOptions = controlPlaneRequestOptions( + runtime, + environment, + controlPlaneRequestTimeoutMs(environment), + ); + const inventory = buildRuntimeInventory(runtime, runtimeId, placementDeclarations); + const presence = buildRuntimePresenceRecord({ + runtimeId, + runtimeRoot, + manifest, + environment, + actorCount, + inventory, + serviceMount, + runtimeMount, + controlMount, + runnerTransport, + webappRoute, + hostPlacement, + }); + const inventoryClaimCount = await registerRuntimeInventoryClaims( + runtime, + controlPlane.logicalRegistryMount, + manifest, + environment, + runtimeId, + runtimeRoot, + inventory, + serviceMount, + webappRoute, + hostPlacement, + registryOptional, + ); + + publishRuntimePresence(runtime.getRootContext(), presence, 'announce'); + const runtimeCount = await tryReadRegisteredRuntimeCount( + runtime, + controlPlane.logicalRegistryMount, + ); + + // Closure for re-issuing the same inventory claims when the registry has + // forgotten us (renew returns renewed: 0). Captures everything needed — + // the runtime, the original manifest/environment, runtimeId, etc. — + // so the heartbeat tick can call reclaim() without re-resolving any + // identity. `onRegistryClaim` is idempotent for an existing entry, so + // calling reclaim() when the registry IS healthy is harmless. + const reclaim = async (): Promise<{ inventoryClaimCount: number }> => { + const freshActorCount = runtime.getAllComponents().length; + const freshInventory = buildRuntimeInventory(runtime, runtimeId, placementDeclarations); + const freshPresence = buildRuntimePresenceRecord({ + runtimeId, + runtimeRoot, + manifest, + environment, + actorCount: freshActorCount, + inventory: freshInventory, + serviceMount, + runtimeMount, + controlMount, + runnerTransport, + webappRoute, + hostPlacement, + }); + const reclaimedCount = await registerRuntimeInventoryClaims( + runtime, + controlPlane.logicalRegistryMount, + manifest, + environment, + runtimeId, + runtimeRoot, + freshInventory, + serviceMount, + webappRoute, + hostPlacement, + registryOptional, + ); + // Re-announce presence so RuntimeManagerActor's _runtimes Map repopulates + // with us even if it was cleared by a SYSTEM restart. + publishRuntimePresence(runtime.getRootContext(), freshPresence, 'announce'); + return { inventoryClaimCount: reclaimedCount }; + }; + + return { + runtimeId, + runtimeRoot, + presence, + registryMount: controlPlane.runtimeRegistryMount, + logicalRegistryMount: controlPlane.logicalRegistryMount, + ...(requestOptions.targetRoot ? { controlTargetRoot: requestOptions.targetRoot } : {}), + ...(requestOptions.protocolRequest === true ? { protocolRequest: true } : {}), + ...(runtimeMount ? { runtimeMount } : {}), + ...(controlMount ? { controlMount } : {}), + created: true, + actorCount, + inventoryClaimCount, + runtimeCount, + ...(registryOptional ? { registryOptional: true } : {}), + reclaim, + }; +} + +export async function deregisterRunnerRuntime( + runtime: MatrixRuntime, + registration: IRunnerRuntimeRegistration | undefined, +): Promise { + if (!registration) { + return undefined; + } + if (registration.registryOptional === true) { + const departedAt = Date.now(); + publishRuntimePresence( + runtime.getRootContext(), + { + ...registration.presence, + lastHeartbeat: departedAt, + health: { + status: 'stopped', + updatedAt: departedAt, + }, + }, + 'departed', + ); + return { + runtimeId: registration.runtimeId, + registryMount: registration.registryMount, + removed: false, + runtimeCount: 0, + }; + } + + const registryReleased = await RequestReply.execute, { removed?: number }>( + runtime.getRootContext(), + registration.logicalRegistryMount, + 'registry.release', + { providerRuntimeId: registration.runtimeId }, + registrationControlPlaneRequestOptions(registration, LOCAL_CONTROL_PLANE_REQUEST_TIMEOUT_MS), + ); + const departedAt = Date.now(); + publishRuntimePresence( + runtime.getRootContext(), + { + ...registration.presence, + lastHeartbeat: departedAt, + health: { + status: 'stopped', + updatedAt: departedAt, + }, + }, + 'departed', + ); + + const runtimeCount = await tryReadRegisteredRuntimeCount( + runtime, + registration.logicalRegistryMount, + ); + + return { + runtimeId: registration.runtimeId, + registryMount: registration.registryMount, + removed: typeof registryReleased.removed === 'number' && registryReleased.removed > 0, + runtimeCount, + }; +} + +async function tryReadRegisteredRuntimeCount( + runtime: MatrixRuntime, + _registryMount: string, +): Promise { + // Slice 1b (control-actor-nav): runtime directory authority is + // `system.runtimes`, not the service registry. Read through the adapter. + try { + return await countRuntimeDirectory(runtime.getRootContext(), { includeStale: true }); + } catch { + return 0; + } +} + +export async function heartbeatRunnerRuntime( + runtime: MatrixRuntime, + registration: IRunnerRuntimeRegistration | undefined, +): Promise { + if (!registration) { + return undefined; + } + if (registration.registryOptional === true) { + const now = Date.now(); + publishRuntimePresence( + runtime.getRootContext(), + { + ...registration.presence, + inventory: rebuildRuntimeInventoryForHeartbeat(runtime, registration), + lastHeartbeat: now, + health: { + status: registration.presence.health?.status ?? 'ok', + ...(registration.presence.health?.reason ? { reason: registration.presence.health.reason } : {}), + updatedAt: now, + }, + }, + 'heartbeat', + ); + return { + runtimeId: registration.runtimeId, + known: true, + }; + } + const renewal = await RequestReply.execute, { renewed?: number }>( + runtime.getRootContext(), + registration.logicalRegistryMount, + 'registry.claim.renew', + { + providerRuntimeId: registration.runtimeId, + ttlMs: 30_000, + }, + registrationControlPlaneRequestOptions(registration, 5_000), + ); + const now = Date.now(); + publishRuntimePresence( + runtime.getRootContext(), + { + ...registration.presence, + inventory: rebuildRuntimeInventoryForHeartbeat(runtime, registration), + lastHeartbeat: now, + health: { + status: registration.presence.health?.status ?? 'ok', + ...(registration.presence.health?.reason ? { reason: registration.presence.health.reason } : {}), + updatedAt: now, + }, + }, + 'heartbeat', + ); + return { + runtimeId: registration.runtimeId, + known: typeof renewal.renewed === 'number' && renewal.renewed > 0, + }; +} + +export async function registerRunnerBindings( + runtime: MatrixRuntime, + manifest: ILoadedMatrixServiceManifest, + environment: ILoadedMatrixPackageEnvironment | undefined, + runtimeId: string, + serviceMount?: string, +): Promise { + const controlPlane = await ensureRunnerControlPlaneForMode(runtime, { + createLocalRegistries: !isHostManagedEnvironment(environment), + }); + const nonCallableBindingMounts = await resolveNonCallableBindingMounts(runtime, manifest, serviceMount); + const bindings = deriveCanonicalBindings(manifest, environment, serviceMount) + .filter((binding) => !nonCallableBindingMounts.has(binding.localMount)); + const placementDeclarations = readPlacementDeclarations(manifest); + const runtimeWireRoot = readTransportRoot(runtime) + ?? environment?.environment.runtime.root + ?? manifest.root + ?? runtimeId; + const authorityRoot = environment?.environment.runtime.root + ?? manifest.root + ?? runtimeWireRoot; + const hostPlacement = resolveRunnerHostPlacement(runtime, environment); + const registryOptional = isHostedAuthorityLeafEnvironment(runtime, environment); + const timeoutMs = controlPlaneRequestTimeoutMs(environment); + const requestOptions = controlPlaneRequestOptions(runtime, environment, timeoutMs); + const publishBindingForwards = shouldPublishBindingForwards(environment); + + const issueBindingClaims = async (): Promise => { + let issued = 0; + for (const binding of bindings) { + const placement = placementForMount( + binding.localMount, + binding.binding, + resolveCardinalityForMount(placementDeclarations, binding.localMount), + true, + ); + await RequestReply.execute( + runtime.getRootContext(), + controlPlane.logicalRegistryMount, + 'registry.claim', + { + mount: binding.binding, + kind: 'actor', + componentId: `${binding.packageName}:${binding.binding}`, + componentType: 'actor', + authorityRoot, + scope: 'identity-relative', + providerRuntimeId: runtimeId, + runtimeWireRoot, + localMount: binding.localMount, + packageName: binding.packageName, + mode: placement.claimMode, + cardinality: placement.cardinality, + claimMode: placement.claimMode, + logicalMount: placement.logicalMount, + physicalMount: placement.physicalMount, + callable: placement.callable, + ...registryClaimPlacement(hostPlacement), + metadata: { + ops: [...binding.ops], + source: 'runner-control-plane', + placement, + ...hostPlacementMetadata(hostPlacement), + }, + }, + requestOptions, + ); + issued += 1; + } + return issued; + }; + + const registerBindingForwards = async (): Promise => { + if (!publishBindingForwards) { + return 0; + } + let registered = 0; + for (const binding of bindings) { + await RequestReply.execute( + runtime.getRootContext(), + controlPlane.bindingsRegistryMount, + 'bindings.register', + { + provider: runtimeId, + runtimeId, + packageName: binding.packageName, + localMount: binding.localMount, + match: binding.binding, + ops: [...binding.ops], + forward: binding.localMount, + }, + requestOptions, + ); + registered += 1; + } + return registered; + }; + + const registryClaimCount = registryOptional ? 0 : await issueBindingClaims(); + const bindingsRegisteredCount = registryOptional ? 0 : await registerBindingForwards(); + + // Reclaim re-issues every server-side record this runner published at + // registration time. Both registries (system.registry and system.bindings) + // store their state in-memory. A single SYSTEM restart wipes them + // together, so the heartbeat tick reclaims both in lockstep when + // registry.claim.renew reports loss. + const reclaim = async (): Promise<{ + registryClaimCount: number; + bindingsRegisteredCount: number; + }> => { + const registryClaimCount = registryOptional ? 0 : await issueBindingClaims(); + const bindingsRegisteredCount = registryOptional ? 0 : await registerBindingForwards(); + return { registryClaimCount, bindingsRegisteredCount }; + }; + + return { + runtimeId, + registryMount: controlPlane.bindingsRegistryMount, + logicalRegistryMount: controlPlane.logicalRegistryMount, + ...(requestOptions.targetRoot ? { controlTargetRoot: requestOptions.targetRoot } : {}), + ...(requestOptions.protocolRequest === true ? { protocolRequest: true } : {}), + registeredCount: bindingsRegisteredCount, + registryClaimCount, + ...(registryOptional ? { registryOptional: true } : {}), + bindings, + reclaim, + }; +} + +export async function deregisterRunnerBindings( + runtime: MatrixRuntime, + registration: IRunnerBindingRegistration | undefined, +): Promise { + if (!registration) { + return undefined; + } + if (registration.registeredCount === 0) { + return { + runtimeId: registration.runtimeId, + registryMount: registration.registryMount, + logicalRegistryMount: registration.logicalRegistryMount, + removedCount: 0, + registryRemovedCount: 0, + }; + } + + const removed = await RequestReply.execute( + runtime.getRootContext(), + registration.registryMount, + 'bindings.remove', + { provider: registration.runtimeId }, + registrationControlPlaneRequestOptions(registration, 5_000), + ) as { removed?: number }; + + return { + runtimeId: registration.runtimeId, + registryMount: registration.registryMount, + logicalRegistryMount: registration.logicalRegistryMount, + removedCount: typeof removed.removed === 'number' ? removed.removed : 0, + registryRemovedCount: 0, + }; +} + +export async function renewRunnerBindings( + runtime: MatrixRuntime, + registration: IRunnerBindingRegistration | undefined, +): Promise { + if (!registration) { + return undefined; + } + if (registration.registryOptional === true) { + return { + runtimeId: registration.runtimeId, + refreshed: registration.bindings.length, + }; + } + const renewal = await RequestReply.execute( + runtime.getRootContext(), + registration.logicalRegistryMount, + 'registry.claim.renew', + { + packageName: registration.bindings[0]?.packageName, + providerRuntimeId: registration.runtimeId, + }, + registrationControlPlaneRequestOptions(registration, 5_000), + ) as { renewed?: number }; + return { + runtimeId: registration.runtimeId, + refreshed: typeof renewal.renewed === 'number' ? renewal.renewed : 0, + }; +} + +export function resolveRunnerRuntimeId( + manifest: ILoadedMatrixServiceManifest, + environment: ILoadedMatrixPackageEnvironment | undefined, +): string { + const runtimeId = environment?.environment.runtime.runtimeId?.trim(); + if (runtimeId) { + return runtimeId; + } + const manifestId = manifest.serviceInstance.id.trim(); + if (manifestId.length > 0) { + return manifestId; + } + return manifest.packageName; +} + +function isHostManagedEnvironment(environment: ILoadedMatrixPackageEnvironment | undefined): boolean { + const matrixDir = environment?.environment.host?.matrixDir; + return typeof matrixDir === 'string' && matrixDir.trim().length > 0; +} + +function isHostedAuthorityLeafEnvironment( + runtime: MatrixRuntime, + environment: ILoadedMatrixPackageEnvironment | undefined, +): boolean { + if (!isHostManagedEnvironment(environment)) { + return false; + } + const topology = readRecord(runtime.getRootContext().getService('matrix-node-topology')) + ?? readRecord(environment?.environment.topology); + const roles = readRecord(topology?.roles); + const authorityCoordinator = readRecord(roles?.authorityCoordinator); + if (authorityCoordinator?.active === false) { + return true; + } + const hostHome = trimToUndefined(runtime.getRootContext().getService('matrix-host-home')) + ?? trimToUndefined(environment?.environment.host?.matrixDir); + if (!hostHome) { + return false; + } + const linkRecord = readJsonObjectIfPresent(path.join(hostHome, 'credentials', 'hivecast-link.json')); + return linkRecord?.authorityCoordinator === false + && Boolean(trimToUndefined(linkRecord.deviceRegistryRoot)); +} + +function readTransportRoot(runtime: MatrixRuntime): string | undefined { + const root = (runtime.transport as { root?: unknown }).root; + return typeof root === 'string' && root.trim().length > 0 ? root.trim() : undefined; +} + +async function resolveNonCallableBindingMounts( + runtime: MatrixRuntime, + manifest: ILoadedMatrixServiceManifest, + serviceMount: string | undefined, +): Promise> { + const mounts = new Set(); + for (const standby of readRunnerPlacementStandbyResults(runtime)) { + if (standby.placement.callable === false) { + mounts.add(standby.mount); + } + } + + if (manifest.packageName === '@open-matrix/system') { + const systemMount = serviceMount?.trim() || manifest.mount?.trim() || manifest.packageRoot?.mount?.trim() || 'system'; + const response = await RequestReply.execute, { + readonly children?: readonly unknown[]; + }>( + runtime.getRootContext(), + systemMount, + 'system.children', + {}, + { timeoutMs: 2_000 }, + ); + for (const child of response.children ?? []) { + const record = isRecord(child) ? child : null; + const mount = trimToUndefined(record?.mount); + const status = trimToUndefined(record?.status); + if (mount && status !== 'mounted') { + mounts.add(mount); + } + } + } + + return mounts; +} + +function buildRuntimeInventory( + runtime: MatrixRuntime, + runtimeId: string, + placementDeclarations: IPlacementDeclarationIndex, +): IRuntimeInventoryEntry[] { + const inventory: IRuntimeInventoryEntry[] = []; + for (const component of runtime.getAllComponents()) { + const mount = component.mount.trim(); + if (mount.length === 0) { + continue; + } + const placement = placementForMount( + mount, + mount, + resolveCardinalityForMount(placementDeclarations, mount), + true, + ); + const description = typeof (component.constructor as { description?: unknown }).description === 'string' + ? (component.constructor as { description?: string }).description + : undefined; + inventory.push({ + mount, + ...(component.constructor.name ? { type: component.constructor.name } : {}), + runtimeId, + claimKind: placement.claimMode, + placement, + ...(description ? { description } : {}), + ...(hasIntrospectChildren(component) ? { hasChildren: true } : {}), + }); + } + appendStandbyPlacementResults(inventory, runtime, runtimeId); + return inventory; +} + +function rebuildRuntimeInventoryForHeartbeat( + runtime: MatrixRuntime, + registration: IRunnerRuntimeRegistration, +): IRuntimeInventoryEntry[] { + const previousPlacementByMount = new Map(); + for (const entry of registration.presence.inventory ?? []) { + if (entry.placement) { + previousPlacementByMount.set(entry.mount, entry.placement); + } + } + const inventory: IRuntimeInventoryEntry[] = []; + for (const component of runtime.getAllComponents()) { + const mount = component.mount.trim(); + if (mount.length === 0) { + continue; + } + const previousPlacement = previousPlacementByMount.get(mount) + ?? placementForMount(mount, mount, 'named-instance', true); + const description = typeof (component.constructor as { description?: unknown }).description === 'string' + ? (component.constructor as { description?: string }).description + : undefined; + inventory.push({ + mount, + ...(component.constructor.name ? { type: component.constructor.name } : {}), + runtimeId: registration.runtimeId, + claimKind: previousPlacement.claimMode, + placement: previousPlacement, + ...(description ? { description } : {}), + ...(hasIntrospectChildren(component) ? { hasChildren: true } : {}), + }); + } + appendStandbyPlacementResults(inventory, runtime, registration.runtimeId); + return inventory; +} + +function appendStandbyPlacementResults( + inventory: IRuntimeInventoryEntry[], + runtime: MatrixRuntime, + runtimeId: string, +): void { + const seen = new Set(inventory.map((entry) => entry.mount)); + for (const standby of readRunnerPlacementStandbyResults(runtime)) { + if (standby.runtimeId !== runtimeId || seen.has(standby.mount)) { + continue; + } + inventory.push({ + mount: standby.mount, + runtimeId, + claimKind: standby.placement.claimMode, + placement: standby.placement, + }); + seen.add(standby.mount); + } +} + +function buildRuntimePresenceRecord(params: { + runtimeId: string; + runtimeRoot: string; + manifest: ILoadedMatrixServiceManifest; + environment: ILoadedMatrixPackageEnvironment | undefined; + actorCount: number; + inventory: readonly IRuntimeInventoryEntry[]; + serviceMount: string | undefined; + runtimeMount: string | undefined; + controlMount: string | undefined; + runnerTransport: IResolvedRunnerTransportPlan | undefined; + webappRoute: IRunnerWebappRouteMetadata | undefined; + hostPlacement: IRunnerHostPlacement | undefined; +}): RuntimePresenceRecordV1 { + const now = Date.now(); + const namespaceRoot = params.runtimeRoot; + return { + version: '1', + namespaceRoot, + runtimeId: params.runtimeId, + runtimeType: 'folder', + runtimeRoot: params.runtimeRoot, + authorityRoot: namespaceRoot, + app: params.manifest.packageName, + claims: buildRuntimeClaims(params.serviceMount, params.environment), + inventoryMode: 'embedded', + inventory: [...params.inventory], + ...(params.controlMount ? { controlMount: params.controlMount } : {}), + heartbeatTtlMs: RUNTIME_PRESENCE_TTL_MS, + registeredAt: now, + lastHeartbeat: now, + health: { + status: 'ok', + updatedAt: now, + }, + metadata: { + source: 'mx-runner-runtime-presence', + actorCount: params.actorCount, + packageDir: params.manifest.packageDir, + packageName: params.manifest.packageName, + // Runtime displayName is set by host-service at spawn (--name or + // generated). The host passes it via env so the runtime can publish + // it in its registry presence record. Consumers read this field + // directly; no derivation from package or runtimeId. + ...(process.env.MATRIX_RUNTIME_DISPLAY_NAME ? { displayName: process.env.MATRIX_RUNTIME_DISPLAY_NAME } : {}), + ...(params.environment ? { + environmentName: params.environment.envName, + environmentPath: params.environment.environmentPath, + } : {}), + ...(params.runtimeMount ? { runtimeMount: params.runtimeMount } : {}), + ...(params.manifest.mount ? { serviceMount: params.manifest.mount } : {}), + ...(params.environment?.environment.package?.publicRoot ? { + publicRoot: params.environment.environment.package.publicRoot, + } : {}), + ...(params.runnerTransport ? { runnerTransport: params.runnerTransport } : {}), + ...(params.webappRoute ? { webapp: params.webappRoute } : {}), + ...hostPlacementMetadata(params.hostPlacement), + }, + }; +} + +function hasIntrospectChildren(component: MatrixActor): boolean { + const constructor = component.constructor as { readonly hasDynamicChildren?: unknown }; + if (constructor.hasDynamicChildren === true) { + return true; + } + try { + const introspection = component.on$introspect({ depth: 'basic' }); + return Array.isArray(introspection.children) && introspection.children.length > 0; + } catch { + return false; + } +} + +async function registerRuntimeInventoryClaims( + runtime: MatrixRuntime, + logicalRegistryMount: string, + manifest: ILoadedMatrixServiceManifest, + environment: ILoadedMatrixPackageEnvironment | undefined, + runtimeId: string, + runtimeWireRoot: string, + inventory: readonly IRuntimeInventoryEntry[], + _serviceMount: string | undefined, + webappRoute: IRunnerWebappRouteMetadata | undefined, + hostPlacement: IRunnerHostPlacement | undefined, + registryOptional: boolean, +): Promise { + if (registryOptional) { + return 0; + } + const authorityRoot = environment?.environment.runtime.root + ?? manifest.root + ?? runtimeWireRoot; + const timeoutMs = controlPlaneRequestTimeoutMs(environment); + const requestOptions = controlPlaneRequestOptions(runtime, environment, timeoutMs); + let registered = 0; + for (const entry of inventory) { + const logicalMount = entry.mount.trim(); + if (entry.placement.callable === false) { + continue; + } + if (!shouldRegisterRuntimeInventoryClaim(runtimeId, logicalMount)) { + continue; + } + await RequestReply.execute( + runtime.getRootContext(), + logicalRegistryMount, + 'registry.claim', + { + mount: logicalMount, + kind: 'actor', + componentId: `${manifest.packageName}:inventory:${logicalMount}`, + componentType: entry.type ?? 'actor', + authorityRoot, + scope: 'identity-relative', + providerRuntimeId: runtimeId, + runtimeWireRoot, + localMount: logicalMount, + packageName: manifest.packageName, + mode: entry.placement.claimMode, + cardinality: entry.placement.cardinality, + claimMode: entry.placement.claimMode, + logicalMount: entry.placement.logicalMount, + physicalMount: entry.placement.physicalMount, + callable: entry.placement.callable, + ...(entry.placement.standbyReason ? { standbyReason: entry.placement.standbyReason } : {}), + ...(typeof entry.placement.placementEpoch === 'number' ? { placementEpoch: entry.placement.placementEpoch } : {}), + ...registryClaimPlacement(hostPlacement), + ttlMs: 30_000, + metadata: { + source: 'runner-runtime-inventory', + placement: entry.placement, + ...(entry.description ? { description: entry.description } : {}), + ...(entry.hasChildren === true ? { hasChildren: true } : {}), + ...hostPlacementMetadata(hostPlacement), + }, + }, + requestOptions, + ); + registered++; + } + if (webappRoute) { + registered += await registerWebappSurfaceClaim( + runtime, + logicalRegistryMount, + manifest, + authorityRoot, + runtimeId, + runtimeWireRoot, + webappRoute, + hostPlacement, + requestOptions, + ); + } + return registered; +} + +async function registerWebappSurfaceClaim( + runtime: MatrixRuntime, + logicalRegistryMount: string, + manifest: ILoadedMatrixServiceManifest, + authorityRoot: string, + runtimeId: string, + runtimeWireRoot: string, + webappRoute: IRunnerWebappRouteMetadata, + hostPlacement: IRunnerHostPlacement | undefined, + requestOptions: IControlPlaneRequestOptions, +): Promise { + const appName = webappRoute.appName.trim(); + if (!appName) { + return 0; + } + const routePrefix = normalizeWebappRoutePrefix(webappRoute.routePrefix, appName); + const placement = placementForMount( + webappRoute.assetMount ?? appName, + appName, + 'named-instance', + true, + ); + const displayName = readOptionalString(webappRoute.displayName); + const icon = readOptionalString(webappRoute.icon); + const description = readOptionalString(webappRoute.description); + const shells = [...new Set((webappRoute.shells ?? []).map((entry) => entry.trim()).filter((entry) => entry.length > 0))]; + await RequestReply.execute( + runtime.getRootContext(), + logicalRegistryMount, + 'registry.claim', + { + mount: appName, + kind: 'app', + componentId: `${manifest.packageName}:webapp:${appName}`, + componentType: 'webapp', + authorityRoot, + scope: 'identity-relative', + providerRuntimeId: runtimeId, + runtimeWireRoot, + localMount: webappRoute.assetMount ?? appName, + packageName: manifest.packageName, + runtimeId, + packageId: manifest.packageName, + packageRef: manifest.packageName, + mode: placement.claimMode, + cardinality: placement.cardinality, + claimMode: placement.claimMode, + logicalMount: placement.logicalMount, + physicalMount: placement.physicalMount, + callable: placement.callable, + ...registryClaimPlacement(hostPlacement), + ttlMs: 30_000, + app: { + route: routePrefix, + title: displayName ?? titleFromAppName(appName), + ...(icon ? { icon } : {}), + surface: 'page', + openable: true, + appName, + }, + metadata: { + source: 'runner-webapp-surface', + placement, + routeKind: webappRoute.assetMount ? 'matrix-asset-endpoint' : 'standalone-webapp', + ...(description ? { description } : {}), + ...(shells.length > 0 ? { shells } : {}), + ...(typeof webappRoute.navOrder === 'number' && Number.isFinite(webappRoute.navOrder) ? { navOrder: webappRoute.navOrder } : {}), + ...(webappRoute.assetMount ? { assetMount: webappRoute.assetMount } : {}), + ...(webappRoute.origin ? { origin: webappRoute.origin } : {}), + ...(typeof webappRoute.port === 'number' ? { port: webappRoute.port } : {}), + ...hostPlacementMetadata(hostPlacement), + }, + }, + requestOptions, + ); + return 1; +} + +function controlPlaneRequestOptions( + runtime: MatrixRuntime, + environment: ILoadedMatrixPackageEnvironment | undefined, + timeoutMs: number, +): IControlPlaneRequestOptions { + const targetRoot = environment?.environment.runtime.root + ?? readTransportRoot(runtime); + const protocolRequest = isHostManagedEnvironment(environment) + && typeof environment?.environment.nats?.credentialsRef === 'string' + && environment.environment.nats.credentialsRef.trim().length > 0; + return { + timeoutMs, + ...(targetRoot ? { targetRoot } : {}), + ...(protocolRequest ? { protocolRequest: true } : {}), + }; +} + +function registrationControlPlaneRequestOptions( + registration: Pick, + timeoutMs: number, +): IControlPlaneRequestOptions { + return { + timeoutMs, + ...(registration.controlTargetRoot ? { targetRoot: registration.controlTargetRoot } : {}), + ...(registration.protocolRequest === true ? { protocolRequest: true } : {}), + }; +} + +function shouldPublishBindingForwards(environment: ILoadedMatrixPackageEnvironment | undefined): boolean { + return !isHostManagedEnvironment(environment); +} + +function controlPlaneRequestTimeoutMs(environment: ILoadedMatrixPackageEnvironment | undefined): number { + return environment?.environment.nats?.credentialsRef + ? LINKED_CONTROL_PLANE_REQUEST_TIMEOUT_MS + : LOCAL_CONTROL_PLANE_REQUEST_TIMEOUT_MS; +} + +function normalizeWebappRoutePrefix(value: string | undefined, appName: string): string { + const routePrefix = value?.trim() || `/apps/${appName}/`; + return routePrefix.endsWith('/') ? routePrefix : `${routePrefix}/`; +} + +function readOptionalString(value: string | undefined): string | undefined { + const trimmed = value?.trim() ?? ''; + return trimmed.length > 0 ? trimmed : undefined; +} + +function titleFromAppName(appName: string): string { + return appName + .split(/[-_.]/) + .filter(Boolean) + .map((segment) => `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`) + .join(' '); +} + +export function shouldRegisterRuntimeInventoryClaim(runtimeId: string, logicalMount: string): boolean { + const trimmedRuntimeId = runtimeId.trim(); + const trimmedMount = logicalMount.trim(); + if (!trimmedRuntimeId || !trimmedMount) { + return false; + } + const runtimeMount = `system.runtimes.${trimmedRuntimeId}`; + if (trimmedMount === runtimeMount || trimmedMount.startsWith(`${runtimeMount}.`)) { + return false; + } + return true; +} + +function buildRuntimeClaims( + serviceMount: string | undefined, + environment: ILoadedMatrixPackageEnvironment | undefined, +): Array<{ prefix: string; kind: 'authoritative' }> { + const claims = new Set(); + const publicRoot = environment?.environment.package?.publicRoot?.trim(); + if (publicRoot) { + claims.add(publicRoot); + } + const resolvedServiceMount = serviceMount?.trim(); + if (resolvedServiceMount) { + claims.add(resolvedServiceMount); + } + return [...claims].map((prefix) => ({ prefix, kind: 'authoritative' as const })); +} + +function deriveCanonicalBindings( + manifest: ILoadedMatrixServiceManifest, + environment: ILoadedMatrixPackageEnvironment | undefined, + serviceMount: string | undefined, +): ICanonicalBindingDefinition[] { + const bindings = new Map(); + const packageName = manifest.packageName; + const publicRoot = trimToUndefined(environment?.environment.package?.publicRoot) + ?? trimToUndefined(manifest.packageNamespace); + const resolvedServiceMount = trimToUndefined(serviceMount) + ?? trimToUndefined(manifest.mount); + const rootKind = trimToUndefined(manifest.serviceInstance.rootKind) ?? 'package-root'; + + if (rootKind === 'package-root' && manifest.packageRoot) { + const canonicalRoot = resolveCanonicalRootBinding(manifest.packageRoot, publicRoot); + const localRootMount = resolvedServiceMount + ?? resolveLocalRootMount(manifest.packageRoot, publicRoot); + if (canonicalRoot && localRootMount) { + bindings.set(canonicalRoot, { + binding: canonicalRoot, + localMount: localRootMount, + ops: manifest.packageRoot.accepts, + packageName, + }); + } + } + + for (const declaration of manifest.packageComponents) { + const binding = resolveCanonicalComponentBinding(declaration, publicRoot); + const localMount = resolveLocalComponentMount(declaration, resolvedServiceMount, rootKind); + if (!binding || !localMount) { + continue; + } + bindings.set(binding, { + binding, + localMount, + ops: declaration.accepts, + packageName, + }); + } + + return [...bindings.values()]; +} + +function resolveCanonicalRootBinding( + declaration: ILoadedMatrixBindingDeclaration, + publicRoot: string | undefined, +): string | undefined { + if (isExplicitAbsoluteMount(declaration.mount)) { + return declaration.mount; + } + return publicRoot ?? declaration.mount; +} + +function resolveCanonicalComponentBinding( + declaration: ILoadedMatrixBindingDeclaration, + publicRoot: string | undefined, +): string | undefined { + if (isExplicitAbsoluteMount(declaration.mount)) { + return declaration.mount; + } + if (!publicRoot) { + return undefined; + } + return `${publicRoot}.${declaration.mount}`; +} + +function resolveLocalRootMount( + declaration: ILoadedMatrixBindingDeclaration, + publicRoot: string | undefined, +): string | undefined { + return declaration.mount + || publicRoot; +} + +function resolveLocalComponentMount( + declaration: ILoadedMatrixBindingDeclaration, + serviceMount: string | undefined, + rootKind: string, +): string | undefined { + if (isExplicitAbsoluteMount(declaration.mount)) { + return declaration.mount; + } + if (rootKind === 'package-root' && serviceMount) { + return `${serviceMount}.${declaration.mount}`; + } + return declaration.mount; +} + +function isExplicitAbsoluteMount(mount: string): boolean { + return mount.includes('.'); +} + +function trimToUndefined(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function readPlacementDeclarations(manifest: ILoadedMatrixServiceManifest): IPlacementDeclarationIndex { + const byMount = new Map(); + const manifestRecord = readJsonObjectIfPresent(path.join(manifest.packageDir, 'matrix.json')); + if (!manifestRecord) { + return { byMount }; + } + addPlacementDeclaration(byMount, manifestRecord.root); + const components = manifestRecord.components; + if (Array.isArray(components)) { + for (const component of components) { + addPlacementDeclaration(byMount, component); + } + } + return { byMount }; +} + +function addPlacementDeclaration( + byMount: Map, + value: unknown, +): void { + if (!isRecord(value)) { + return; + } + const mount = trimToUndefined(value.mount); + if (!mount) { + return; + } + const placement = isRecord(value.placement) ? value.placement : undefined; + const cardinality = readMountCardinality(placement?.cardinality); + if (cardinality) { + byMount.set(mount, cardinality); + } +} + +function resolveCardinalityForMount( + declarations: IPlacementDeclarationIndex, + mount: string, +): MatrixMountCardinality { + return declarations.byMount.get(mount) ?? 'named-instance'; +} + +function placementForMount( + physicalMount: string, + logicalMount: string, + cardinality: MatrixMountCardinality, + callable: boolean, +): RuntimeInventoryPlacementMetadata { + return { + cardinality, + claimMode: claimModeForCardinality(cardinality), + logicalMount, + physicalMount, + callable, + }; +} + +function claimModeForCardinality(cardinality: MatrixMountCardinality): MatrixPlacementClaimMode { + if (cardinality === 'authority-singleton') { + return 'authoritative'; + } + if (cardinality === 'queue-service') { + return 'pool-member'; + } + if (cardinality === 'replicated-read') { + return 'mirror'; + } + return 'local-only'; +} + +function readMountCardinality(value: unknown): MatrixMountCardinality | undefined { + return value === 'authority-singleton' + || value === 'device-singleton' + || value === 'device-local-control' + || value === 'queue-service' + || value === 'named-instance' + || value === 'replicated-read' + ? value + : undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function readRecord(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + +function resolveRunnerHostPlacement( + runtime: MatrixRuntime, + environment: ILoadedMatrixPackageEnvironment | undefined, +): IRunnerHostPlacement | undefined { + const hostHome = trimToUndefined(runtime.getRootContext().getService('matrix-host-home')) + ?? trimToUndefined(environment?.environment.host?.matrixDir); + if (!hostHome) { + return undefined; + } + + const credentialsDir = path.join(hostHome, 'credentials'); + const linkRecord = readJsonObjectIfPresent(path.join(credentialsDir, 'hivecast-link.json')); + const installRecord = readJsonObjectIfPresent(path.join(credentialsDir, 'hivecast-install.json')); + const hostId = trimToUndefined(linkRecord?.hostId) ?? trimToUndefined(installRecord?.installId); + if (!hostId) { + return undefined; + } + const hostName = trimToUndefined(linkRecord?.hostName) ?? trimToUndefined(installRecord?.hostName); + const deviceSlug = trimToUndefined(linkRecord?.deviceSlug); + return { + deviceId: hostId, + hostId, + ...(hostName ? { hostName } : {}), + ...(deviceSlug ? { deviceSlug } : {}), + }; +} + +function registryClaimPlacement(hostPlacement: IRunnerHostPlacement | undefined): Record { + return hostPlacement + ? { + deviceId: hostPlacement.deviceId, + hostId: hostPlacement.hostId, + } + : {}; +} + +function hostPlacementMetadata(hostPlacement: IRunnerHostPlacement | undefined): Record { + return hostPlacement + ? { + deviceId: hostPlacement.deviceId, + hostId: hostPlacement.hostId, + ...(hostPlacement.hostName ? { hostName: hostPlacement.hostName } : {}), + ...(hostPlacement.deviceSlug ? { deviceSlug: hostPlacement.deviceSlug } : {}), + } + : {}; +} + +function readJsonObjectIfPresent(filePath: string): Record | undefined { + if (!fs.existsSync(filePath)) { + return undefined; + } + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Expected JSON object at ${filePath}`); + } + return parsed as Record; +} + +async function ensureRegistryActor( + runtime: MatrixRuntime, + mount: string, + ActorClass: new () => TActor, +): Promise { + if (runtime.hasComponent(mount)) { + return false; + } + await runtime.createSupervised(ActorClass as never, mount); + return true; +} diff --git a/archive/mx-cli-runtime-host-commands/runner-placement-policy.ts b/archive/mx-cli-runtime-host-commands/runner-placement-policy.ts new file mode 100644 index 0000000..ad0bcc6 --- /dev/null +++ b/archive/mx-cli-runtime-host-commands/runner-placement-policy.ts @@ -0,0 +1,305 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { + decideMatrixPlacementBind, + type AuthorityCoordinatorLease, + type MatrixMountCardinality, + type MatrixPlacementBindDecision, + type MatrixPlacementBindRequest, + type MatrixPlacementPolicy, + type RuntimeInventoryPlacementMetadata, +} from '@open-matrix/contracts/placement'; +import type { MatrixActor } from '@open-matrix/core/core/MatrixActor.js'; +import { MatrixRuntime } from '@open-matrix/core/runtime/MatrixRuntime.js'; +import type { ILoadedMatrixPackageEnvironment } from './package-environment.js'; +import type { ILoadedMatrixServiceManifest } from './service-manifest.js'; + +export const MATRIX_PLACEMENT_POLICY_SERVICE = 'matrix-placement-policy'; +export const MATRIX_PLACEMENT_STANDBY_RESULTS_SERVICE = 'matrix-placement-standby-results'; + +export interface RunnerPlacementStandbyResult { + readonly mount: string; + readonly runtimeId: string; + readonly packageName: string; + readonly placement: RuntimeInventoryPlacementMetadata; +} + +interface PlacementDeclaration { + readonly cardinality: MatrixMountCardinality; + readonly physicalMount?: string; +} + +interface NodeTopologySnapshot { + readonly kind?: string; + readonly nodeId?: string; + readonly authorityRoot: string; + readonly authorityCoordinatorActive: boolean; + readonly authorityLease?: AuthorityCoordinatorLease | null; +} + +type SupervisedCreate = MatrixRuntime['createSupervised']; + +export function installRunnerPlacementPolicy(options: { + readonly runtime: MatrixRuntime; + readonly manifest: ILoadedMatrixServiceManifest; + readonly environment: ILoadedMatrixPackageEnvironment | undefined; + readonly runtimeId: string; + readonly authorityRoot: string | undefined; +}): void { + const declarations = readPlacementDeclarations(options.manifest); + const topology = resolveTopologySnapshot(options.runtime, options.environment, options.authorityRoot); + const standbyResults: RunnerPlacementStandbyResult[] = []; + const policy: MatrixPlacementPolicy = { + canBind(input: MatrixPlacementBindRequest): MatrixPlacementBindDecision { + return decideMatrixPlacementBind(input); + }, + }; + + options.runtime.registerService(MATRIX_PLACEMENT_POLICY_SERVICE, policy); + options.runtime.registerService(MATRIX_PLACEMENT_STANDBY_RESULTS_SERVICE, standbyResults); + + const originalCreateSupervised = options.runtime.createSupervised.bind(options.runtime) as SupervisedCreate; + options.runtime.createSupervised = (async ( + ComponentClass: new () => T, + mount: string, + props?: Record, + ): Promise => { + const trimmedMount = mount.trim(); + const declaration = declarations.get(trimmedMount); + if (!declaration) { + return await originalCreateSupervised(ComponentClass, mount, props); + } + const physicalMount = declaration.physicalMount ?? trimmedMount; + const request: MatrixPlacementBindRequest = { + authorityRoot: topology.authorityRoot, + runtimeId: options.runtimeId, + packageName: options.manifest.packageName, + mount: trimmedMount, + cardinality: declaration.cardinality, + ...(topology.kind ? { topologyKind: topology.kind } : {}), + authorityCoordinatorActive: topology.authorityCoordinatorActive, + ...(topology.nodeId ? { ownerNodeId: topology.nodeId } : {}), + ...(topology.authorityLease !== undefined ? { authorityLease: topology.authorityLease } : {}), + }; + const decision = policy.canBind(request); + if (decision.allowed) { + return await originalCreateSupervised(ComponentClass, mount, props); + } + standbyResults.push({ + mount: trimmedMount, + runtimeId: options.runtimeId, + packageName: options.manifest.packageName, + placement: { + cardinality: declaration.cardinality, + claimMode: claimModeForCardinality(declaration.cardinality), + logicalMount: trimmedMount, + physicalMount, + callable: decision.callable, + ...(decision.reason ? { standbyReason: decision.reason } : {}), + }, + }); + throw new Error(`Placement denied for ${trimmedMount}: ${decision.reason ?? 'UNKNOWN_PLACEMENT'}`); + }) as SupervisedCreate; +} + +export function readRunnerPlacementStandbyResults(runtime: MatrixRuntime): readonly RunnerPlacementStandbyResult[] { + return runtime.getRootContext().getService( + MATRIX_PLACEMENT_STANDBY_RESULTS_SERVICE, + ) ?? []; +} + +function resolveTopologySnapshot( + runtime: MatrixRuntime, + environment: ILoadedMatrixPackageEnvironment | undefined, + authorityRoot: string | undefined, +): NodeTopologySnapshot { + const currentRoot = authorityRoot?.trim() + || environment?.environment.runtime.root?.trim() + || runtime.transport.root.trim(); + const topology = readTopologyRecord(runtime.getRootContext().getService('matrix-node-topology')) + ?? readTopologyRecord(environment?.environment.topology); + const topologyAuthorityRoot = readAuthorityRoot(topology) ?? currentRoot; + const active = readAuthorityCoordinatorActive(topology); + const authorityLease = readAuthorityLease(runtime, environment, topology); + return { + ...(readString(topology?.kind) ? { kind: readString(topology?.kind) } : {}), + ...(readString(topology?.nodeId) ? { nodeId: readString(topology?.nodeId) } : {}), + authorityRoot: topologyAuthorityRoot, + authorityCoordinatorActive: active ?? true, + ...(authorityLease !== undefined ? { authorityLease } : {}), + }; +} + +function readAuthorityLease( + runtime: MatrixRuntime, + environment: ILoadedMatrixPackageEnvironment | undefined, + topology: Record | undefined, +): AuthorityCoordinatorLease | null | undefined { + const serviceLease = readAuthorityLeaseRecord(runtime.getRootContext().getService('matrix-authority-coordinator-lease')); + if (serviceLease !== undefined) { + return serviceLease; + } + const topologyLease = readAuthorityLeaseRecord(topology?.authorityLease); + if (topologyLease !== undefined) { + return topologyLease; + } + return readAuthorityLeaseRecord(environment?.environment.authorityLease); +} + +function readPlacementDeclarations(manifest: ILoadedMatrixServiceManifest): ReadonlyMap { + const declarations = new Map(); + const manifestRecord = readJsonObjectIfPresent(path.join(manifest.packageDir, 'matrix.json')); + if (!manifestRecord) { + return declarations; + } + addPlacementDeclaration(declarations, manifestRecord.root); + const components = manifestRecord.components; + if (Array.isArray(components)) { + for (const component of components) { + addPlacementDeclaration(declarations, component); + } + } + return declarations; +} + +function addPlacementDeclaration(declarations: Map, value: unknown): void { + if (!isRecord(value)) { + return; + } + const mount = readString(value.mount); + const placement = isRecord(value.placement) ? value.placement : undefined; + const cardinality = readMountCardinality(placement?.cardinality); + if (!mount || !cardinality) { + return; + } + const physicalMount = readString(placement?.physicalMount); + declarations.set(mount, { + cardinality, + ...(physicalMount ? { physicalMount } : {}), + }); +} + +function readTopologyRecord(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + +function readAuthorityRoot(topology: Record | undefined): string | undefined { + const authority = isRecord(topology?.authority) ? topology.authority : undefined; + return readString(authority?.authorityRoot); +} + +function readAuthorityCoordinatorActive(topology: Record | undefined): boolean | undefined { + const roles = isRecord(topology?.roles) ? topology.roles : undefined; + const authorityCoordinator = isRecord(roles?.authorityCoordinator) ? roles.authorityCoordinator : undefined; + return typeof authorityCoordinator?.active === 'boolean' ? authorityCoordinator.active : undefined; +} + +function readAuthorityLeaseRecord(value: unknown): AuthorityCoordinatorLease | null | undefined { + if (value === null) { + return null; + } + if (!isRecord(value)) { + return undefined; + } + if ( + value.version !== 1 + || typeof value.authorityRoot !== 'string' + || typeof value.leaseId !== 'string' + || typeof value.ownerNodeId !== 'string' + || typeof value.placementEpoch !== 'number' + || typeof value.acquiredAt !== 'string' + || typeof value.heartbeatAt !== 'string' + || typeof value.expiresAt !== 'string' + ) { + return undefined; + } + if ( + value.status !== 'active' + && value.status !== 'released' + && value.status !== 'expired' + && value.status !== 'fenced' + ) { + return undefined; + } + if ( + value.scope !== 'local-home' + && value.scope !== 'hub-authority' + && value.scope !== 'remote-projection' + ) { + return undefined; + } + if ( + value.leaseAuthority !== 'local' + && value.leaseAuthority !== 'hub' + ) { + return undefined; + } + if ( + value.source !== 'local-file' + && value.source !== 'hub-projection' + ) { + return undefined; + } + return { + version: 1, + authorityRoot: value.authorityRoot, + leaseId: value.leaseId, + ownerNodeId: value.ownerNodeId, + ...(readString(value.ownerHostId) ? { ownerHostId: readString(value.ownerHostId) } : {}), + ...(readString(value.ownerInstallId) ? { ownerInstallId: readString(value.ownerInstallId) } : {}), + ...(readString(value.ownerHostLinkId) ? { ownerHostLinkId: readString(value.ownerHostLinkId) } : {}), + placementEpoch: value.placementEpoch, + acquiredAt: value.acquiredAt, + heartbeatAt: value.heartbeatAt, + expiresAt: value.expiresAt, + status: value.status, + scope: value.scope, + leaseAuthority: value.leaseAuthority, + source: value.source, + ...(readString(value.reason) ? { reason: readString(value.reason) } : {}), + }; +} + +function claimModeForCardinality(cardinality: MatrixMountCardinality): RuntimeInventoryPlacementMetadata['claimMode'] { + if (cardinality === 'authority-singleton') { + return 'authoritative'; + } + if (cardinality === 'queue-service') { + return 'pool-member'; + } + if (cardinality === 'replicated-read') { + return 'mirror'; + } + return 'local-only'; +} + +function readMountCardinality(value: unknown): MatrixMountCardinality | undefined { + return value === 'authority-singleton' + || value === 'device-singleton' + || value === 'device-local-control' + || value === 'queue-service' + || value === 'named-instance' + || value === 'replicated-read' + ? value + : undefined; +} + +function readJsonObjectIfPresent(filePath: string): Record | undefined { + if (!fs.existsSync(filePath)) { + return undefined; + } + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')) as unknown; + if (!isRecord(parsed)) { + throw new Error(`Expected JSON object at ${filePath}`); + } + return parsed; +} + +function readString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/archive/mx-cli-runtime-host-commands/runner-runtime-control.ts b/archive/mx-cli-runtime-host-commands/runner-runtime-control.ts new file mode 100644 index 0000000..f81e8e6 --- /dev/null +++ b/archive/mx-cli-runtime-host-commands/runner-runtime-control.ts @@ -0,0 +1,286 @@ +import { MatrixActor } from '@open-matrix/core/core/MatrixActor.js'; +import { MatrixRuntime } from '@open-matrix/core/runtime/MatrixRuntime.js'; +import { + LoadedPackageActor, + RuntimeControlActor, + RUNTIME_CONTROL_SERVICE, + loadedPackageMount, + loadedPackageServiceName, + packageKeyFromPackageRef, + runtimeControlMount, + type ILoadedPackageService, + type IRuntimeControlService, + type LoadedPackageManifest, + type LoadedPackageRecord, + type RuntimeControlState, +} from '@open-matrix/system-runtimes'; + +type MatrixIntrospect = ReturnType; +type MatrixIntrospectChild = NonNullable[number]; + +export type RunnerRuntimeLifecycleState = + | 'starting' + | 'ready' + | 'live' + | 'stopping' + | 'stopped' + | 'failed'; + +export interface IRunnerRuntimeControlService { + readonly runtimeId: string; + readonly startedAt: string; + getState(): RunnerRuntimeLifecycleState; + getRuntime(): MatrixRuntime; + requestShutdown(reason: string): void; +} + +const RUNNER_RUNTIME_CONTROL_SERVICE = 'runner-runtime-control'; + +export interface IMountedLoadedPackage { + readonly packageRef: string; + readonly packageKey: string; + readonly mount: string; +} + +export interface IMountRunnerRuntimeControlOptions { + readonly runtime: MatrixRuntime; + readonly runtimeId: string; + readonly runtimeMount: string; + readonly controlMount: string; + readonly startedAt: string; + getState(): RunnerRuntimeLifecycleState; + requestShutdown(reason: string): void; + /** + * Optional loaded-package descriptor. When provided, a per-loaded-package + * actor is mounted at `system.runtimes..packages.` + * alongside the runtime control actor. Slice 1a wires this for runtimes + * that pass a single manifest at startup; multi-package runtimes are + * future work. + */ + readonly loadedPackage?: { + readonly packageRef: string; + readonly version?: string; + readonly manifest?: LoadedPackageManifest; + }; +} + +export interface IMountRunnerRuntimeControlResult { + readonly controlMount: string; + readonly runtimeMount: string; + readonly loadedPackages: readonly IMountedLoadedPackage[]; +} + +export class RunnerRuntimeControlActor extends MatrixActor { + static readonly hasDynamicChildren = true; + static description = 'Runtime control actor for Host-managed runner processes.'; + + static override accepts: Record = { + 'runtime.status': { description: 'Report runtime lifecycle and inventory status', options: 'object?' }, + 'runtime.ready': { description: 'Report whether the runtime is ready for traffic', options: 'object?' }, + 'runtime.health': { description: 'Report runtime health', options: 'object?' }, + 'runtime.shutdown': { description: 'Request graceful runtime shutdown', reason: 'string?' }, + }; + + protected override onIntrospect(payload?: { depth?: 'basic' | 'full' }): MatrixIntrospect { + const base = super.onIntrospect(payload); + const service = this._service(); + const currentMount = this.mount; + const children = service.getRuntime().getAllComponents() + .map((component): MatrixIntrospectChild | null => { + const mount = component.mount.trim(); + if (!mount || mount === currentMount) { + return null; + } + return { + id: mount, + slot: 'runtime', + mount, + componentClass: component.constructor.name || 'MatrixActor', + accepts: [], + emits: [], + tracks: [], + }; + }) + .filter((child): child is MatrixIntrospectChild => child !== null) + .sort((left, right) => left.mount.localeCompare(right.mount)); + return { + ...base, + children: [...(base.children ?? []), ...children], + }; + } + + onRuntimeStatus(): Record { + const service = this._service(); + const runtime = service.getRuntime(); + return { + ok: true, + runtimeId: service.runtimeId, + pid: process.pid, + ...(process.env.MATRIX_HOST_HOME ? { hostHome: process.env.MATRIX_HOST_HOME } : {}), + state: service.getState(), + startedAt: service.startedAt, + uptimeMs: Date.now() - Date.parse(service.startedAt), + actorCount: runtime.getAllComponents().length, + mounts: runtime.getAllComponents().map((component) => component.mount), + }; + } + + onRuntimeReady(): Record { + const service = this._service(); + const state = service.getState(); + return { + ok: true, + runtimeId: service.runtimeId, + ready: state === 'ready' || state === 'live', + state, + pid: process.pid, + ...(process.env.MATRIX_HOST_HOME ? { hostHome: process.env.MATRIX_HOST_HOME } : {}), + }; + } + + onRuntimeHealth(): Record { + const service = this._service(); + const state = service.getState(); + return { + ok: true, + healthy: state === 'ready' || state === 'live', + runtimeId: service.runtimeId, + state, + pid: process.pid, + ...(process.env.MATRIX_HOST_HOME ? { hostHome: process.env.MATRIX_HOST_HOME } : {}), + }; + } + + onRuntimeShutdown(payload?: { reason?: string }): Record { + const service = this._service(); + const reason = typeof payload?.reason === 'string' && payload.reason.trim().length > 0 + ? payload.reason.trim() + : 'runtime.shutdown'; + setTimeout(() => service.requestShutdown(reason), 0); + return { + ok: true, + accepted: true, + runtimeId: service.runtimeId, + state: 'stopping', + reason, + }; + } + + private _service(): IRunnerRuntimeControlService { + const service = this.context?.getService(RUNNER_RUNTIME_CONTROL_SERVICE); + if (!service) { + throw new Error('Runner runtime control service is not registered'); + } + return service; + } +} + +export async function mountRunnerRuntimeControl( + options: IMountRunnerRuntimeControlOptions, +): Promise { + const service: IRunnerRuntimeControlService = { + runtimeId: options.runtimeId, + startedAt: options.startedAt, + getState: options.getState, + getRuntime: () => options.runtime, + requestShutdown: options.requestShutdown, + }; + options.runtime.registerService(RUNNER_RUNTIME_CONTROL_SERVICE, service); + + const canonicalRuntimeMount = runtimeControlMount(options.runtimeId); + + // Build the loaded-package record set (Slice 1a: zero or one package). + const loadedPackages: LoadedPackageRecord[] = []; + if (options.loadedPackage && options.loadedPackage.packageRef.trim().length > 0) { + const packageRef = options.loadedPackage.packageRef.trim(); + const packageKey = packageKeyFromPackageRef(packageRef); + const record: LoadedPackageRecord = { + packageRef, + packageKey, + mount: loadedPackageMount(options.runtimeId, packageRef), + state: 'loaded', + loadedAt: Date.parse(options.startedAt) || Date.now(), + ...(options.loadedPackage.version ? { version: options.loadedPackage.version } : {}), + ...(options.loadedPackage.manifest ? { manifest: options.loadedPackage.manifest } : {}), + }; + loadedPackages.push(record); + } + + // Register the runtime-control service consumed by RuntimeControlActor. + const runtimeControlService: IRuntimeControlService = { + getRuntimeState: (): RuntimeControlState => ({ + runtimeId: options.runtimeId, + ...(process.env.MATRIX_RUNTIME_DISPLAY_NAME ? { displayName: process.env.MATRIX_RUNTIME_DISPLAY_NAME } : {}), + ...(loadedPackages[0]?.packageRef ? { packageRef: loadedPackages[0].packageRef } : {}), + controlMount: canonicalRuntimeMount, + state: lifecycleStateToControlState(options.getState()), + health: { + status: 'ok', + updatedAt: Date.now(), + }, + registeredAt: Date.parse(options.startedAt) || Date.now(), + lastHeartbeat: Date.now(), + }), + getLoadedPackages: () => loadedPackages, + requestShutdown: options.requestShutdown, + requestReload: async () => ({ accepted: false, reason: 'runtime.reload is not implemented in Slice 1a' }), + }; + options.runtime.registerService(RUNTIME_CONTROL_SERVICE, runtimeControlService); + + // Mount the canonical Runtime control actor at system.runtimes.. + // Slice 1b (control-actor-nav): the legacy `.control` sibling mount has been + // removed. The canonical runtime control mount is `system.runtimes.`. + if (!options.runtime.hasComponent(canonicalRuntimeMount)) { + await options.runtime.createSupervised(RuntimeControlActor, canonicalRuntimeMount); + } + + // Mount per-loaded-package actors and register their per-package services. + const mountedLoadedPackages: IMountedLoadedPackage[] = []; + for (const record of loadedPackages) { + const loadedPackageService: ILoadedPackageService = { + getRuntimeId: () => options.runtimeId, + getLoadedPackage: () => record, + requestReload: async () => ({ accepted: false, reason: 'package.reload is not implemented in Slice 1a' }), + }; + options.runtime.registerService(loadedPackageServiceName(record.packageKey), loadedPackageService); + if (!options.runtime.hasComponent(record.mount)) { + await options.runtime.createSupervised(LoadedPackageActor, record.mount); + } + mountedLoadedPackages.push({ + packageRef: record.packageRef, + packageKey: record.packageKey, + mount: record.mount, + }); + } + + return { + controlMount: canonicalRuntimeMount, + runtimeMount: canonicalRuntimeMount, + loadedPackages: mountedLoadedPackages, + }; +} + +function lifecycleStateToControlState(state: RunnerRuntimeLifecycleState): RuntimeControlState['state'] { + switch (state) { + case 'starting': + return 'starting'; + case 'ready': + case 'live': + return 'running'; + case 'stopping': + return 'stopping'; + case 'stopped': + case 'failed': + return 'stopped'; + } +} + +export function defaultRunnerRuntimeMount(runtimeId: string): string { + return `system.runtimes.${runtimeId}`; +} + +export function defaultRunnerControlMount(runtimeId: string): string { + // Slice 1b (control-actor-nav): the canonical runtime control mount is + // `system.runtimes.`. The legacy `.control` sibling is gone. + return defaultRunnerRuntimeMount(runtimeId); +} diff --git a/archive/mx-cli-runtime-host-commands/runner-runtime.ts b/archive/mx-cli-runtime-host-commands/runner-runtime.ts new file mode 100644 index 0000000..fd0969a --- /dev/null +++ b/archive/mx-cli-runtime-host-commands/runner-runtime.ts @@ -0,0 +1,296 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as path from 'node:path'; + +import { connect as natsConnect, type NatsConnection } from 'nats'; + +import { + DEFAULT_SECURITY_POLICY, + DEFAULT_SUPERVISION_POLICY, + SecurityRealm, +} from '@open-matrix/core'; +import { createPolicyEngine } from '@open-matrix/core/core/security/PolicyPresets'; +import { MatrixRuntime } from '@open-matrix/core/runtime/MatrixRuntime.js'; +import { InMemoryBroker } from '@open-matrix/core/transport/InMemoryBroker.js'; +import { InMemoryTransport } from '@open-matrix/core/transport/InMemoryTransport.js'; +import { NatsTransport } from '@open-matrix/core/transport/NatsTransport.js'; +import { authenticatorFromCredentialsRef } from '@open-matrix/nats-auth'; +import { SqliteRecordStoreProvider } from '@open-matrix/system-sqlite'; +import type { IResolvedRunnerTransportPlan } from './runner-transport.js'; + +export interface IRunnerRuntimeHandle { + readonly runtime: MatrixRuntime; + readonly transportMode: 'in-memory' | 'external' | 'embedded'; + readonly root?: string; + readonly runnerTransport?: IResolvedRunnerTransportPlan; + readonly closed?: Promise; + shutdown(): Promise; +} + +export interface IRunnerRuntimeClosed { + readonly error?: string; +} + +export function formatRunnerRuntimeClosed(closed: IRunnerRuntimeClosed): string { + const detail = closed.error?.trim(); + return detail ? `Runner transport closed: ${detail}` : 'Runner transport closed'; +} + +export function createRunnerSecurityRealm(): SecurityRealm { + return new SecurityRealm( + DEFAULT_SUPERVISION_POLICY, + DEFAULT_SECURITY_POLICY, + undefined, + undefined, + createPolicyEngine({ policyEngine: 'declarative', preset: 'standard' }), + ); +} + +export async function createRunnerRuntimeHandle( + root: string | undefined, + runnerTransport?: IResolvedRunnerTransportPlan, +): Promise { + if (!runnerTransport) { + const transport = new InMemoryTransport(new InMemoryBroker(), { + name: 'mx-service-runner', + ...(root ? { root } : {}), + }); + const runtime = new MatrixRuntime({ + transport, + logging: false, + allowDynamicCompilation: false, + securityRealm: createRunnerSecurityRealm(), + recordStoreProvider: new SqliteRecordStoreProvider(), + }); + return { + runtime, + transportMode: 'in-memory', + ...(root ? { root } : {}), + async shutdown(): Promise { + await runtime.shutdown(); + await transport.disconnect(); + }, + }; + } + + if (runnerTransport.mode === 'external') { + const authenticator = await authenticatorFromCredentialsRef(runnerTransport.credentialsRef); + const connection = await natsConnect({ + servers: [runnerTransport.url], + name: `mx-runner-${process.pid}`, + timeout: 5_000, + ...(authenticator ? { authenticator } : {}), + }); + const closed = watchNatsConnectionClosed(connection); + const transport = new NatsTransport(connection, { root: runnerTransport.root }); + const runtime = new MatrixRuntime({ + transport, + logging: false, + allowDynamicCompilation: false, + securityRealm: createRunnerSecurityRealm(), + recordStoreProvider: new SqliteRecordStoreProvider(), + }); + return { + runtime, + transportMode: 'external', + root: runnerTransport.root, + runnerTransport, + closed, + async shutdown(): Promise { + await runtime.shutdown(); + await transport.disconnect(); + if (!connection.isClosed()) { + await connection.close(); + } + await closed; + }, + }; + } + + const child = await startEmbeddedNatsServer(runnerTransport); + const connection = await natsConnect({ + servers: [runnerTransport.url], + name: `mx-runner-embedded-${process.pid}`, + timeout: 5_000, + }); + const closed = watchNatsConnectionClosed(connection); + const transport = new NatsTransport(connection, { root: runnerTransport.root }); + writePidFile(runnerTransport.pidFile, child.pid); + const runtime = new MatrixRuntime({ + transport, + logging: false, + allowDynamicCompilation: false, + securityRealm: createRunnerSecurityRealm(), + }); + + return { + runtime, + transportMode: 'embedded', + root: runnerTransport.root, + runnerTransport, + closed, + async shutdown(): Promise { + await runtime.shutdown(); + await transport.disconnect(); + if (!connection.isClosed()) { + await connection.close(); + } + await closed; + await stopChildProcess(child, runnerTransport.pidFile); + }, + }; +} + +function watchNatsConnectionClosed(connection: NatsConnection): Promise { + return connection.closed().then( + (reason): IRunnerRuntimeClosed => { + if (reason instanceof Error) { + return { error: reason.message }; + } + return {}; + }, + (err: unknown): IRunnerRuntimeClosed => ({ + error: err instanceof Error ? err.message : String(err), + }), + ); +} + +async function startEmbeddedNatsServer( + runnerTransport: IResolvedRunnerTransportPlan, +): Promise { + if (!runnerTransport.binaryPath) { + throw new Error('Embedded runner transport requires binaryPath'); + } + if (!runnerTransport.port) { + throw new Error('Embedded runner transport requires port'); + } + if (!runnerTransport.wsPort) { + throw new Error('Embedded runner transport requires wsPort'); + } + if (!runnerTransport.dataDir) { + throw new Error('Embedded runner transport requires dataDir'); + } + + fs.mkdirSync(runnerTransport.dataDir, { recursive: true }); + const configPath = path.join(runnerTransport.dataDir, 'nats-server.conf'); + const escapedStoreDir = runnerTransport.dataDir.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + fs.writeFileSync(configPath, [ + 'host: "127.0.0.1"', + `port: ${runnerTransport.port}`, + 'jetstream {', + ` store_dir: "${escapedStoreDir}"`, + '}', + 'websocket {', + ' host: "127.0.0.1"', + ` port: ${runnerTransport.wsPort}`, + ' no_tls: true', + '}', + '', + ].join('\n')); + + const child = spawn(runnerTransport.binaryPath, ['-c', configPath], { + stdio: 'pipe', + env: process.env, + windowsHide: true, + }); + + let stderr = ''; + let spawnError: Error | null = null; + child.once('error', (error: Error) => { + spawnError = error; + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + + await waitForPorts( + [runnerTransport.port, runnerTransport.wsPort], + 10_000, + child, + () => { + if (spawnError) { + return `${spawnError.message}${stderr ? ` | ${stderr.trim()}` : ''}`; + } + return stderr.trim(); + }, + () => spawnError, + ); + return child; +} + +async function waitForPorts( + ports: number[], + timeoutMs: number, + child: ChildProcessWithoutNullStreams, + getLogs: () => string, + getSpawnError?: () => Error | null, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const spawnError = getSpawnError?.(); + if (spawnError) { + throw new Error(`Failed to spawn nats-server: ${spawnError.message}`); + } + if (child.exitCode != null) { + throw new Error(`nats-server exited early (${child.exitCode}): ${getLogs()}`); + } + const allListening = await Promise.all(ports.map((port) => isPortListening(port))); + if (allListening.every(Boolean)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for nats-server on ports ${ports.join(', ')}: ${getLogs()}`); +} + +async function isPortListening(port: number): Promise { + return await new Promise((resolve) => { + const socket = net.connect({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => { + socket.destroy(); + resolve(false); + }); + socket.setTimeout(500, () => { + socket.destroy(); + resolve(false); + }); + }); +} + +async function stopChildProcess( + child: ChildProcessWithoutNullStreams, + pidFile?: string, +): Promise { + if (child.exitCode == null) { + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (child.exitCode == null) { + child.kill('SIGKILL'); + } + }, 2_000); + + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + + child.kill('SIGTERM'); + }); + } + if (pidFile) { + fs.rmSync(pidFile, { force: true }); + } +} + +function writePidFile(pidFile: string | undefined, pid: number | undefined): void { + if (!pidFile || !pid) { + return; + } + fs.mkdirSync(path.dirname(pidFile), { recursive: true }); + fs.writeFileSync(pidFile, `${pid}\n`, 'utf8'); +} diff --git a/archive/mx-cli-runtime-host-commands/service.ts b/archive/mx-cli-runtime-host-commands/service.ts new file mode 100644 index 0000000..da763eb --- /dev/null +++ b/archive/mx-cli-runtime-host-commands/service.ts @@ -0,0 +1,685 @@ +import * as fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +import type { MatrixActor } from '@open-matrix/core/core/MatrixActor.js'; +import { buildMountedInstanceBootstrapProps } from '@open-matrix/core/runtime/InstanceBootstrapContext.js'; +import { + buildMatrixHttpAssetMount, + MatrixHttpAssetEndpointActor, +} from '@open-matrix/system-gateway-http'; +import { HostAuthStateStore, HostCredentialStore } from '@open-matrix/system-auth'; +import { + loadBootstrapJsonObject, + resolveBootstrapRefPath, + loadBootstrapJsonValue, +} from '@open-matrix/core/runtime/InstanceBootstrapRefs.js'; +import { MatrixRuntime } from '@open-matrix/core/runtime/MatrixRuntime.js'; +import { loadMatrixServiceManifest } from '../utils/service-manifest.js'; +import { + loadPackageEnvironment, + loadPackageEnvironmentFromPath, + type IMatrixPackageEnvironment, +} from '../utils/package-environment.js'; +import { + deregisterRunnerBindings, + deregisterRunnerRuntime, + heartbeatRunnerRuntime, + type IRunnerWebappRouteMetadata, + registerRunnerBindings, + type IRunnerBindingDeregistration, + type IRunnerBindingRegistration, + registerRunnerRuntime, + renewRunnerBindings, + resolveRunnerRuntimeId, + type IRunnerRuntimeDeregistration, + type IRunnerRuntimeRegistration, +} from '../utils/runner-control-plane.js'; +import { + defaultRunnerRuntimeMount, + mountRunnerRuntimeControl, + type RunnerRuntimeLifecycleState, +} from '../utils/runner-runtime-control.js'; +import { resolveRunnerTransportPlan, type IResolvedRunnerTransportPlan } from '../utils/runner-transport.js'; +import { + createRunnerRuntimeHandle, + formatRunnerRuntimeClosed, + type IRunnerRuntimeClosed, + type IRunnerRuntimeHandle, +} from '../utils/runner-runtime.js'; +import { + loadMatrixWebappManifest, + type ILoadedMatrixWebappManifest, +} from '../utils/webapp-manifest.js'; +import { installRunnerPlacementPolicy } from '../utils/runner-placement-policy.js'; + +export interface IServiceCommandOptions { + readonly check?: boolean; + readonly json?: boolean; + readonly env?: string; + readonly envFile?: string; + readonly overrides?: string; + readonly entry?: string; + readonly export?: string; + readonly root?: string; + readonly mount?: string; + readonly config?: string; + readonly secrets?: string; +} + +export interface IServiceCommandResult { + readonly packageName: string; + readonly packageDir: string; + readonly manifestPath: string; + readonly entryPath: string; + readonly exportName: string; + readonly checked: boolean; + readonly environmentName?: string; + readonly environmentPath?: string; + readonly runnerTransport?: IResolvedRunnerTransportPlan; + readonly overrides: Record; + readonly root?: string; + readonly mount?: string; + readonly configRef?: string; + readonly secretsRef?: string; + readonly instance: Record; + readonly serviceInstance: Record; + readonly deploymentInstance: Record; + readonly runtimeRegistration?: IRunnerRuntimeRegistration; + readonly bindingRegistration?: IRunnerBindingRegistration; + readonly runtimeDeregistration?: IRunnerRuntimeDeregistration; + readonly bindingDeregistration?: IRunnerBindingDeregistration; + readonly result?: unknown; +} + +export interface IStartedServiceCommand { + readonly result: IServiceCommandResult; + readonly shutdownRequested: Promise; + readonly transportClosed?: Promise; + shutdown(): Promise; +} + +type BootstrapResult = { + runtime?: { + shutdown?: () => Promise | void; + }; +} & Record; + +interface IStandaloneRunnerServices { + readonly runtime: MatrixRuntime; + readonly environment?: IMatrixPackageEnvironment; + readonly runnerTransport?: IResolvedRunnerTransportPlan; +} + +export async function serviceCommand( + packageDir: string, + options: IServiceCommandOptions = {}, +): Promise { + const started = await startServiceCommand(packageDir, options); + if (options.check) { + return await started.shutdown(); + } + + if (options.json) { + console.log(JSON.stringify(started.result, null, 2)); + } else { + console.log(`[mx service] started ${started.result.packageName} via ${started.result.exportName}`); + console.log(`[mx service] manifest: ${started.result.manifestPath}`); + console.log(`[mx service] entry: ${started.result.entryPath}`); + } + + let finalResult = started.result; + const shutdownRequested = started.transportClosed + ? Promise.race([ + started.shutdownRequested, + started.transportClosed.then((closed) => { + throw new Error(formatRunnerRuntimeClosed(closed)); + }), + ]) + : started.shutdownRequested; + + await waitForShutdownSignal(async () => { + finalResult = await started.shutdown(); + }, shutdownRequested); + + return finalResult; +} + +export async function startServiceCommand( + packageDir: string, + options: IServiceCommandOptions = {}, +): Promise { + const loaded = loadMatrixServiceManifest(packageDir); + const webapp = loadMatrixWebappManifest(loaded.packageDir); + const envFile = options.envFile?.trim(); + const selectedEnvironment = envFile + ? loadPackageEnvironmentFromPath(packageDir, envFile, options.env) + : options.env?.trim() + ? loadPackageEnvironment(packageDir, options.env) + : undefined; + const entryPath = options.entry + ? loadBootstrapEntryPath(loaded.packageDir, options.entry, 'service entry override') + : loaded.entryPath; + const exportName = options.export?.trim() || loaded.exportName; + const root = options.root?.trim() + || selectedEnvironment?.environment.runtime.root + || loaded.root; + const runnerTransport = selectedEnvironment + ? resolveRunnerTransportPlan(loaded.packageDir, selectedEnvironment, root) + : undefined; + const mount = options.mount?.trim() || loaded.mount; + const configRef = options.config?.trim() || loaded.configRef; + const config = configRef ? loadBootstrapJsonObject(loaded.packageDir, configRef, 'service config') : {}; + const manifestOverrides = deepMerge(config, loaded.overrides); + const cliOverrides = parseOverrides(options.overrides); + const effectiveOverrides = deepMerge(manifestOverrides, cliOverrides); + const secretsRef = options.secrets?.trim() || undefined; + const secrets = secretsRef ? loadBootstrapJsonValue(loaded.packageDir, secretsRef, 'service secrets') : undefined; + const mountedInstanceBootstrap = buildMountedInstanceBootstrapProps({ + ...loaded.serviceInstance, + ...(mount ? { mount } : {}), + ...(configRef ? { configRef } : {}), + ...(Array.isArray(secrets) + ? { secretRefs: structuredClone(secrets) } + : loaded.serviceInstance.secretRefs + ? { secretRefs: structuredClone(loaded.serviceInstance.secretRefs) } + : {}), + }, effectiveOverrides); + const { instance, serviceInstance, deploymentInstance } = mountedInstanceBootstrap; + const bootstrapContext = { + packageName: loaded.packageName, + packageDir: loaded.packageDir, + manifestPath: loaded.manifestPath, + entryPath, + exportName, + ...(root ? { root } : {}), + ...(mount ? { mount } : {}), + ...(configRef ? { configRef } : {}), + ...(secretsRef ? { secretsRef } : {}), + ...(secrets !== undefined ? { secrets } : {}), + ...(loaded.transport ? { transport: loaded.transport } : {}), + ...(loaded.auth ? { auth: loaded.auth } : {}), + ...(loaded.http ? { http: loaded.http } : {}), + ...(selectedEnvironment ? { + environmentName: selectedEnvironment.envName, + environmentPath: selectedEnvironment.environmentPath, + environment: selectedEnvironment.environment, + } : {}), + ...(runnerTransport ? { runnerTransport } : {}), + ...mountedInstanceBootstrap, + }; + const runnerRuntimeHandle = await createRunnerRuntimeHandle(root, runnerTransport); + const runnerRuntime = runnerRuntimeHandle.runtime; + registerHostHomeService(runnerRuntime, selectedEnvironment?.environment.host?.matrixDir); + const runtimeId = resolveRunnerRuntimeId(loaded, selectedEnvironment); + const runtimeMount = selectedEnvironment?.environment.runtime.runtimeMount + ?? process.env.MATRIX_RUNTIME_MOUNT + ?? defaultRunnerRuntimeMount(runtimeId); + // Slice 1b (control-actor-nav): control authority is the canonical + // per-runtime mount, not the legacy `.control` sibling. + const controlMount = selectedEnvironment?.environment.runtime.controlMount + ?? process.env.MATRIX_RUNTIME_CONTROL_MOUNT + ?? runtimeMount; + let lifecycleState: RunnerRuntimeLifecycleState = 'starting'; + let requestRuntimeShutdown: (reason: string) => void = () => undefined; + const shutdownRequested = new Promise((resolve) => { + requestRuntimeShutdown = resolve; + }); + const runnerServices: IStandaloneRunnerServices = { + runtime: runnerRuntime, + ...(selectedEnvironment ? { environment: selectedEnvironment.environment } : {}), + ...(runnerTransport ? { runnerTransport } : {}), + }; + installRunnerPlacementPolicy({ + runtime: runnerRuntime, + manifest: loaded, + environment: selectedEnvironment, + runtimeId, + authorityRoot: root, + }); + + const moduleUrl = `${pathToFileURL(entryPath).href}?mx_service=${Date.now()}-${Math.random().toString(16).slice(2)}`; + const mod = await import(moduleUrl) as Record; + const bootstrap = mod[exportName]; + if (typeof bootstrap !== 'function') { + throw new Error(`Package runtime factory export "${exportName}" was not found in ${entryPath}`); + } + + const bootstrapResult = await (bootstrap as (...args: unknown[]) => Promise | unknown)( + effectiveOverrides, + runnerServices, + undefined, + bootstrapContext, + ); + const activeRuntime = resolveBootstrapRuntime(bootstrapResult) ?? runnerRuntime; + const webappAssetMount = webapp + ? await mountMatrixHttpAssetEndpoint(activeRuntime, webapp, runtimeId) + : undefined; + const mounted = await mountRunnerRuntimeControl({ + runtime: activeRuntime, + runtimeId, + runtimeMount, + controlMount, + startedAt: new Date().toISOString(), + getState: () => lifecycleState, + requestShutdown: (reason) => requestRuntimeShutdown(reason), + loadedPackage: { + packageRef: loaded.packageName, + manifest: { + packageRef: loaded.packageName, + ...(loaded.mount ? { mount: loaded.mount } : {}), + packageDir: loaded.packageDir, + }, + }, + }); + const mountedControlMount = mounted.controlMount; + lifecycleState = 'ready'; + const runtimeRegistration = await registerRunnerRuntime( + activeRuntime, + loaded, + selectedEnvironment, + runnerTransport, + mountedInstanceBootstrap.serviceInstance.mount, + runtimeMount, + mountedControlMount, + webapp && webappAssetMount ? buildRunnerWebappRouteMetadata(webapp, webappAssetMount) : undefined, + ); + const bindingRegistration = await registerRunnerBindings( + activeRuntime, + loaded, + selectedEnvironment, + runtimeRegistration.runtimeId, + mountedInstanceBootstrap.serviceInstance.mount, + ); + lifecycleState = 'live'; + const serializedInstance = sanitizeForJson(instance) as Record; + let runtimeDeregistration: IRunnerRuntimeDeregistration | undefined; + let bindingDeregistration: IRunnerBindingDeregistration | undefined; + // Heartbeat tick + self-healing reclaim. + // + // registry.claim.renew (inside heartbeatRunnerRuntime / renewRunnerBindings) + // returns the count of claims the server-side registry actually refreshed. + // 0 means "the registry has no record of your claim" — typically because + // the registry actor's in-memory _claims map was wiped when the SYSTEM + // runtime restarted. Without reclaim the original claim's TTL (45s) + // expires, the runtime vanishes from registry.query, the gateway can't + // route to it, and apps appear "not registered" until manual intervention. + // + // We re-issue exactly what we issued at registration time. Server-side ops + // are idempotent (onRegistryClaim preserves registeredAt/claimedAt for + // existing entries; bindings.register is keyed by provider+match), so + // reclaiming against a healthy registry is harmless. + // + // Log discipline: log STATE TRANSITIONS, not every tick. First failure + // logs; subsequent consecutive failures at the same attempt count stay + // silent; every 10th attempt logs that we're still trying; recovery from + // a degraded state logs the duration. Healthy ticks log nothing. + let runtimeReclaimAttempts = 0; + let bindingReclaimAttempts = 0; + + const logIfTransitionOrMilestone = ( + attempts: number, + onFirst: () => void, + onMilestone: () => void, + ): void => { + if (attempts === 1) { + onFirst(); + } else if (attempts % 10 === 0) { + onMilestone(); + } + }; + + const runtimePresenceTimer = options.check === true + ? undefined + : setInterval(() => { + void (async () => { + try { + const [heartbeat, renewal] = await Promise.all([ + heartbeatRunnerRuntime(activeRuntime, runtimeRegistration), + renewRunnerBindings(activeRuntime, bindingRegistration), + ]); + + if (heartbeat && !heartbeat.known) { + runtimeReclaimAttempts += 1; + logIfTransitionOrMilestone( + runtimeReclaimAttempts, + () => console.error( + `[mx service] registry forgot runtime claim for ${heartbeat.runtimeId} (renewed=0); reclaiming`, + ), + () => console.error( + `[mx service] still reclaiming runtime ${heartbeat.runtimeId} after ${runtimeReclaimAttempts} attempts`, + ), + ); + try { + const reclaimed = await runtimeRegistration.reclaim(); + if (runtimeReclaimAttempts > 0) { + console.error( + `[mx service] runtime reclaim recovered for ${heartbeat.runtimeId} after ${runtimeReclaimAttempts} attempt(s): ${reclaimed.inventoryClaimCount} inventory claims re-issued`, + ); + } + runtimeReclaimAttempts = 0; + } catch (reclaimErr) { + logIfTransitionOrMilestone( + runtimeReclaimAttempts, + () => console.error( + `[mx service] runtime reclaim failed for ${heartbeat.runtimeId}: ${reclaimErr instanceof Error ? reclaimErr.message : String(reclaimErr)} (will retry next tick)`, + ), + () => console.error( + `[mx service] runtime reclaim still failing for ${heartbeat.runtimeId} after ${runtimeReclaimAttempts} attempts: ${reclaimErr instanceof Error ? reclaimErr.message : String(reclaimErr)}`, + ), + ); + } + } else if (heartbeat && runtimeReclaimAttempts > 0) { + // Registry started answering renew=N>0 without us reclaiming + // first — extremely rare (the registry would need to repopulate + // claims from somewhere) but log the recovery so operators see + // it. Reset the counter. + console.error( + `[mx service] runtime registry recovered without explicit reclaim for ${heartbeat.runtimeId} after ${runtimeReclaimAttempts} attempt(s)`, + ); + runtimeReclaimAttempts = 0; + } + + if (renewal && renewal.refreshed === 0 && (bindingRegistration?.bindings.length ?? 0) > 0) { + bindingReclaimAttempts += 1; + logIfTransitionOrMilestone( + bindingReclaimAttempts, + () => console.error( + `[mx service] registry forgot binding claims for ${renewal.runtimeId} (refreshed=0); reclaiming`, + ), + () => console.error( + `[mx service] still reclaiming bindings for ${renewal.runtimeId} after ${bindingReclaimAttempts} attempts`, + ), + ); + try { + const reclaimed = await bindingRegistration?.reclaim(); + if (reclaimed && bindingReclaimAttempts > 0) { + console.error( + `[mx service] binding reclaim recovered for ${renewal.runtimeId} after ${bindingReclaimAttempts} attempt(s): ${reclaimed.registryClaimCount} registry claims + ${reclaimed.bindingsRegisteredCount} binding forwards re-issued`, + ); + } + bindingReclaimAttempts = 0; + } catch (reclaimErr) { + logIfTransitionOrMilestone( + bindingReclaimAttempts, + () => console.error( + `[mx service] binding reclaim failed for ${renewal.runtimeId}: ${reclaimErr instanceof Error ? reclaimErr.message : String(reclaimErr)} (will retry next tick)`, + ), + () => console.error( + `[mx service] binding reclaim still failing for ${renewal.runtimeId} after ${bindingReclaimAttempts} attempts: ${reclaimErr instanceof Error ? reclaimErr.message : String(reclaimErr)}`, + ), + ); + } + } else if (renewal && bindingReclaimAttempts > 0) { + console.error( + `[mx service] binding registry recovered without explicit reclaim for ${renewal.runtimeId} after ${bindingReclaimAttempts} attempt(s)`, + ); + bindingReclaimAttempts = 0; + } + } catch (err) { + console.error( + `[mx service] runtime presence tick failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + })(); + }, 10_000); + const buildResult = (): IServiceCommandResult => ({ + packageName: loaded.packageName, + packageDir: loaded.packageDir, + manifestPath: loaded.manifestPath, + entryPath, + exportName, + checked: options.check === true, + ...(selectedEnvironment ? { + environmentName: selectedEnvironment.envName, + environmentPath: selectedEnvironment.environmentPath, + } : {}), + ...(runnerTransport ? { runnerTransport } : {}), + overrides: effectiveOverrides, + ...(root ? { root } : {}), + ...(mount ? { mount } : {}), + ...(configRef ? { configRef } : {}), + ...(secretsRef ? { secretsRef } : {}), + instance: serializedInstance, + serviceInstance: structuredClone(serializedInstance), + deploymentInstance: structuredClone(serializedInstance), + runtimeRegistration, + bindingRegistration, + ...(runtimeDeregistration ? { runtimeDeregistration } : {}), + ...(bindingDeregistration ? { bindingDeregistration } : {}), + result: sanitizeForJson(bootstrapResult), + }); + let shutdownPromise: Promise | null = null; + + return { + result: buildResult(), + ...(runnerRuntimeHandle.closed ? { transportClosed: runnerRuntimeHandle.closed } : {}), + async shutdown(): Promise { + if (!shutdownPromise) { + shutdownPromise = (async () => { + lifecycleState = 'stopping'; + if (runtimePresenceTimer) { + clearInterval(runtimePresenceTimer); + } + ({ runtimeDeregistration, bindingDeregistration } = await shutdownBootstrapRuntime( + bootstrapResult, + activeRuntime, + runtimeRegistration, + bindingRegistration, + runnerRuntimeHandle, + )); + lifecycleState = 'stopped'; + return buildResult(); + })(); + } + return await shutdownPromise; + }, + shutdownRequested, + }; +} + +function registerHostHomeService( + runtime: MatrixRuntime, + matrixDir: string | undefined, +): void { + const hostHome = matrixDir?.trim(); + if (hostHome) { + runtime.registerService('matrix-host-home', hostHome); + runtime.registerService('credential-store', new HostCredentialStore(new HostAuthStateStore(hostHome))); + } +} + +async function mountMatrixHttpAssetEndpoint( + runtime: MatrixRuntime, + webapp: ILoadedMatrixWebappManifest, + runtimeId?: string, +): Promise { + const assetMount = buildMatrixHttpAssetMount(webapp.appName, runtimeId); + if (!runtime.hasComponent(assetMount)) { + await runtime.createSupervised(MatrixHttpAssetEndpointActor as unknown as new () => MatrixActor, assetMount, { + assetEndpoint: { + packageName: webapp.packageName, + appName: webapp.appName, + distDir: webapp.distDir, + entryFile: webapp.entryFile, + }, + }); + } + return assetMount; +} + +function buildRunnerWebappRouteMetadata( + webapp: ILoadedMatrixWebappManifest, + assetMount: string, +): IRunnerWebappRouteMetadata { + return { + appName: webapp.appName, + routePrefix: `/apps/${webapp.appName}/`, + ...(webapp.displayName ? { displayName: webapp.displayName } : {}), + ...(webapp.icon ? { icon: webapp.icon } : {}), + ...(typeof webapp.navOrder === 'number' ? { navOrder: webapp.navOrder } : {}), + ...(webapp.description ? { description: webapp.description } : {}), + ...(webapp.shells && webapp.shells.length > 0 ? { shells: webapp.shells } : {}), + assetMount, + }; +} + +function parseOverrides(raw: string | undefined): Record { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) { + throw new Error('overrides must be a JSON object'); + } + return parsed; + } catch (err) { + throw new Error(`Failed to parse service overrides: ${err instanceof Error ? err.message : String(err)}`); + } +} + +function deepMerge( + base: Record, + patch: Record, +): Record { + const output = structuredClone(base); + for (const [key, value] of Object.entries(patch)) { + const current = output[key]; + if (isRecord(current) && isRecord(value)) { + output[key] = deepMerge(current, value); + continue; + } + output[key] = value; + } + return output; +} + +function loadBootstrapEntryPath(packageDir: string, ref: string, label: string): string { + const entryPath = ref.trim(); + if (entryPath.length === 0) { + throw new Error(`${label} must be a non-empty path`); + } + const filePath = resolveBootstrapRefPath(packageDir, entryPath); + if (!fs.existsSync(filePath)) { + throw new Error(`${label} not found at ${filePath}`); + } + return filePath; +} + +async function shutdownBootstrapRuntime( + result: unknown, + activeRuntime?: MatrixRuntime, + registration?: IRunnerRuntimeRegistration, + bindingRegistration?: IRunnerBindingRegistration, + runnerRuntimeHandle?: IRunnerRuntimeHandle, +): Promise<{ + readonly runtimeDeregistration?: IRunnerRuntimeDeregistration; + readonly bindingDeregistration?: IRunnerBindingDeregistration; +}> { + let shutdownHandled = false; + let runtimeDeregistration: IRunnerRuntimeDeregistration | undefined; + let bindingDeregistration: IRunnerBindingDeregistration | undefined; + if (isRecord(result)) { + const runtime: unknown = result.runtime; + if (isRecord(runtime)) { + const shutdown = runtime.shutdown; + if (typeof shutdown === 'function') { + if (activeRuntime) { + bindingDeregistration = await deregisterRunnerBindings(activeRuntime, bindingRegistration); + runtimeDeregistration = await deregisterRunnerRuntime(activeRuntime, registration); + } + await shutdown.call(runtime); + shutdownHandled = Object.is(runtime, runnerRuntimeHandle?.runtime); + } + } + } + if (runnerRuntimeHandle && !shutdownHandled) { + if (!runtimeDeregistration && activeRuntime) { + bindingDeregistration = await deregisterRunnerBindings(activeRuntime, bindingRegistration); + runtimeDeregistration = await deregisterRunnerRuntime(activeRuntime, registration); + } + await runnerRuntimeHandle.shutdown(); + } + return { + ...(runtimeDeregistration ? { runtimeDeregistration } : {}), + ...(bindingDeregistration ? { bindingDeregistration } : {}), + }; +} + +function resolveBootstrapRuntime(result: unknown): MatrixRuntime | undefined { + if (!isRecord(result)) { + return undefined; + } + const runtime = result.runtime; + return runtime instanceof MatrixRuntime ? runtime : undefined; +} + +function sanitizeForJson(value: unknown, depth = 8, seen = new WeakSet()): unknown { + if (value == null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (Array.isArray(value)) { + if (depth <= 0) return `[array:${value.length}]`; + return value + .map((entry) => sanitizeForJson(entry, depth - 1, seen)) + .filter((entry) => entry !== undefined); + } + if (!isRecord(value)) { + return String(value); + } + if (seen.has(value)) { + return '[circular]'; + } + seen.add(value); + if (depth <= 0) { + return '[object]'; + } + const output: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const sanitized = sanitizeForJson(entry, depth - 1, seen); + if (sanitized !== undefined) { + output[key] = sanitized; + } + } + return output; +} + +async function waitForShutdownSignal( + onShutdown: () => Promise, + shutdownRequested?: Promise, +): Promise { + await new Promise((resolve, reject) => { + let settled = false; + const complete = (err?: unknown) => { + if (settled) return; + settled = true; + process.off('SIGINT', handleSignal); + process.off('SIGTERM', handleSignal); + if (err) { + reject(err); + return; + } + resolve(); + }; + + const handleSignal = () => { + void onShutdown() + .then(() => complete()) + .catch((err) => complete(err)); + }; + + process.on('SIGINT', handleSignal); + process.on('SIGTERM', handleSignal); + shutdownRequested + ?.then(() => handleSignal()) + .catch((err) => complete(err)); + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/archive/mx-cli-runtime-host-commands/standalone-webapp-server.ts b/archive/mx-cli-runtime-host-commands/standalone-webapp-server.ts new file mode 100644 index 0000000..3c18726 --- /dev/null +++ b/archive/mx-cli-runtime-host-commands/standalone-webapp-server.ts @@ -0,0 +1,522 @@ +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as net from 'node:net'; +import * as path from 'node:path'; +import * as tls from 'node:tls'; +import type { AddressInfo } from 'node:net'; +import type { Duplex } from 'node:stream'; + +import type { ILoadedMatrixPackageEnvironment } from './package-environment.js'; +import type { IResolvedRunnerTransportPlan } from './runner-transport.js'; +import type { ILoadedMatrixWebappManifest } from './webapp-manifest.js'; +import { jwtCredentialsFromCredentialsRef } from '@open-matrix/nats-auth'; + +export interface IStandaloneWebappServerOptions { + readonly loadedEnvironment: ILoadedMatrixPackageEnvironment; + readonly webapp: ILoadedMatrixWebappManifest; + readonly runnerTransport: IResolvedRunnerTransportPlan; +} + +export interface IStandaloneWebappServerHandle { + readonly port: number; + readonly baseUrl: string; + readonly webapp: ILoadedMatrixWebappManifest; + readonly bootstrapPath: '/api/bootstrap'; + readonly natsWsPath: '/nats-ws'; + shutdown(): Promise; +} + +interface IMimeTypeMap { + readonly [key: string]: string; +} + +const MIME_TYPES: IMimeTypeMap = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', +}; + +export async function startStandaloneWebappServer( + options: IStandaloneWebappServerOptions, +): Promise { + const httpConfig = options.loadedEnvironment.environment.http; + if (httpConfig?.enabled !== true || typeof httpConfig.port !== 'number' || httpConfig.port <= 0) { + throw new Error( + `Package environment "${options.loadedEnvironment.envName}" must enable http.port for --serve mode`, + ); + } + if (!fs.existsSync(options.webapp.distDir)) { + throw new Error( + `Built webapp dist directory not found for ${options.webapp.packageName}: ${options.webapp.distDir}`, + ); + } + const entryPath = path.join(options.webapp.distDir, options.webapp.entryFile); + if (!fs.existsSync(entryPath)) { + throw new Error( + `Built webapp entry file not found for ${options.webapp.packageName}: ${entryPath}`, + ); + } + if (!options.runnerTransport.wsUrl) { + throw new Error( + `Runner transport for ${options.webapp.packageName} does not expose wsUrl required for /nats-ws proxy`, + ); + } + + const runtimeRoot = options.runnerTransport.root; + const brandingDir = resolveBrandingDir(options.loadedEnvironment.packageDir); + const server = http.createServer((req, res) => { + void handleRequest(req, res, { + entryPath, + brandingDir, + runtimeRoot, + webapp: options.webapp, + loadedEnvironment: options.loadedEnvironment, + runnerTransport: options.runnerTransport, + }).catch((error) => { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + })); + }); + }); + + server.on('upgrade', (req, socket, head) => { + if ((req.url ?? '').split('?')[0] !== '/nats-ws') { + socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + return; + } + proxyWebSocketUpgrade(req, socket, head, options.runnerTransport.wsUrl!); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(httpConfig.port, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address !== 'object') { + throw new Error(`Standalone webapp server for ${options.webapp.packageName} did not expose a TCP address`); + } + const port = (address as AddressInfo).port; + + return { + port, + baseUrl: `http://127.0.0.1:${port}`, + webapp: options.webapp, + bootstrapPath: '/api/bootstrap', + natsWsPath: '/nats-ws', + async shutdown(): Promise { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +async function handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + options: { + readonly entryPath: string; + readonly brandingDir: string | null; + readonly runtimeRoot: string; + readonly webapp: ILoadedMatrixWebappManifest; + readonly loadedEnvironment: ILoadedMatrixPackageEnvironment; + readonly runnerTransport: IResolvedRunnerTransportPlan; + }, +): Promise { + const method = req.method ?? 'GET'; + const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1'); + const pathname = requestUrl.pathname; + const isNatsAuthRequest = pathname === '/api/nats-jwt' && method === 'POST'; + if (method !== 'GET' && method !== 'HEAD' && !isNatsAuthRequest) { + res.statusCode = 405; + res.setHeader('Allow', pathname === '/api/nats-jwt' ? 'GET, HEAD, POST' : 'GET, HEAD'); + res.end(); + return; + } + + if (pathname === '/api/bootstrap') { + const payload = buildBootstrapPayload(req, options.runtimeRoot, options.runnerTransport, options.loadedEnvironment); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + writeBody(res, method, JSON.stringify(payload)); + return; + } + + if (pathname === '/api/auth/me') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + writeBody(res, method, JSON.stringify({ + authenticated: true, + principal: { + id: 'local-runner', + displayName: 'Local Runner', + email: null, + }, + session: { + sub: 'local-runner', + email: null, + exp: null, + localClient: true, + }, + })); + return; + } + + if (pathname === '/api/config') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + writeBody(res, method, JSON.stringify({ + root: options.runtimeRoot, + environment: options.loadedEnvironment.environment.name, + transport: { + mode: options.runnerTransport.mode, + url: options.runnerTransport.url, + wsUrl: options.runnerTransport.wsUrl ?? null, + }, + })); + return; + } + + if (pathname === '/api/nats-jwt') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + writeBody(res, method, JSON.stringify(buildNatsAuthPayload( + options.runnerTransport, + options.loadedEnvironment.packageDir, + ))); + return; + } + + if (pathname === '/healthz') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + writeBody(res, method, JSON.stringify({ + ok: true, + ready: true, + phase: 'ready', + root: options.runtimeRoot, + })); + return; + } + + if (pathname === '/nats-ws') { + res.statusCode = 426; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + writeBody(res, method, 'Upgrade Required'); + return; + } + + if (pathname === '/favicon.ico' || pathname === '/manifest.json' || pathname.startsWith('/branding/')) { + const assetPath = resolveBrandingAssetPath(pathname, options.brandingDir); + if (!assetPath) { + res.statusCode = 404; + res.end(); + return; + } + await serveFile(res, method, assetPath); + return; + } + + const resolvedPath = resolveStaticAssetPath(pathname, options.webapp, options.entryPath); + if (!resolvedPath) { + res.statusCode = 404; + res.end(); + return; + } + await serveFile(res, method, resolvedPath); +} + +function buildBootstrapPayload( + req: http.IncomingMessage, + runtimeRoot: string, + runnerTransport: IResolvedRunnerTransportPlan, + loadedEnvironment: ILoadedMatrixPackageEnvironment, +): Record { + const hostHeader = Array.isArray(req.headers.host) + ? req.headers.host[0] + : req.headers.host; + const host = hostHeader || `127.0.0.1:${loadedEnvironment.environment.http?.port ?? 0}`; + const protocol = 'ws:'; + const wsUrl = `${protocol}//${host}/nats-ws`; + const hasCredentialsRef = typeof runnerTransport.credentialsRef === 'string' + && runnerTransport.credentialsRef.trim().length > 0; + const authorityContext = { + assetHostRoot: runtimeRoot, + platformServiceRoot: runtimeRoot, + sessionAuthorityRoot: runtimeRoot, + selectedAuthorityRoot: runtimeRoot, + runtimeInstanceRoot: null, + isAuthenticated: true, + isPlatformAdmin: false, + selectableAuthorityRoots: [runtimeRoot], + }; + return { + authorityContext, + natsWsUrl: wsUrl, + root: runtimeRoot, + authorityRoot: runtimeRoot, + assetHostRoot: runtimeRoot, + platformServiceRoot: runtimeRoot, + sessionAuthorityRoot: runtimeRoot, + selectedAuthorityRoot: runtimeRoot, + runtimeInstanceRoot: null, + selectableAuthorityRoots: [runtimeRoot], + isPlatformAdmin: false, + busRoot: runtimeRoot, + platformRoot: runtimeRoot, + role: 'client', + addressRoot: runtimeRoot, + provisioned: true, + authorityReady: true, + authenticated: true, + principal: { + principalId: 'local-runner', + displayName: 'Local Runner', + }, + natsOwnership: { + mode: runnerTransport.mode, + source: 'package-environment:nats', + }, + federation: { + connected: false, + hubUrl: null, + source: 'none', + }, + transportPlan: { + protocolVersion: 1, + kind: 'nats', + wsMode: 'same-origin-proxy', + wsPath: '/nats-ws', + authMode: hasCredentialsRef ? 'jwt' : 'anonymous', + authEndpoint: hasCredentialsRef ? '/api/nats-jwt' : null, + busAuthMode: 'none', + sessionMode: 'local-client', + }, + sources: { + root: 'package-environment:runtime.root', + authorityRoot: 'package-environment:runtime.root', + assetHostRoot: 'package-environment:runtime.root', + platformServiceRoot: 'package-environment:runtime.root', + sessionAuthorityRoot: 'package-environment:runtime.root', + selectedAuthorityRoot: 'package-environment:runtime.root', + runtimeInstanceRoot: 'none', + platformRoot: 'package-environment:runtime.root', + addressRoot: 'package-environment:runtime.root', + browserWsPath: 'transportPlan.wsPath', + transportAuthMode: hasCredentialsRef ? 'credentials-ref' : 'standalone-runner', + busToken: 'none', + sessionIdentity: 'local-client', + federation: 'none', + }, + }; +} + +function buildNatsAuthPayload( + runnerTransport: IResolvedRunnerTransportPlan, + baseDir: string, +): Record { + const credentials = jwtCredentialsFromCredentialsRef( + runnerTransport.credentialsRef, + baseDir, + `matrix-browser-${process.pid}`, + ); + if (!credentials) { + return { mode: 'anonymous' }; + } + return { + mode: 'jwt', + jwt: credentials.jwt, + seed: credentials.seed, + }; +} + +function resolveStaticAssetPath( + pathname: string, + webapp: ILoadedMatrixWebappManifest, + entryPath: string, +): string | null { + const decodedPath = safeDecodePath(pathname); + if (!decodedPath) { + return null; + } + const appBasePath = `/apps/${webapp.appName}`; + const cleanPath = decodedPath === appBasePath || decodedPath.startsWith(`${appBasePath}/`) + ? decodedPath.slice(appBasePath.length) || '/' + : decodedPath; + if (cleanPath === '/') { + return entryPath; + } + + const relativePath = cleanPath.replace(/^\/+/, ''); + const distRoot = path.resolve(webapp.distDir); + const resolved = path.resolve(webapp.distDir, relativePath); + if (resolved.startsWith(distRoot) && fs.existsSync(resolved) && fs.statSync(resolved).isFile()) { + return resolved; + } + + if (!path.extname(relativePath)) { + return entryPath; + } + + return null; +} + +function resolveBrandingAssetPath(pathname: string, brandingDir: string | null): string | null { + if (!brandingDir) { + return null; + } + + let assetPath: string; + if (pathname === '/favicon.ico') { + assetPath = path.join(brandingDir, 'favicon.ico'); + } else if (pathname === '/manifest.json') { + assetPath = path.join(brandingDir, 'manifest.json'); + } else { + const relativePath = safeDecodePath(pathname.slice('/branding/'.length)); + if (!relativePath) { + return null; + } + assetPath = path.join(brandingDir, relativePath); + } + + const resolved = path.resolve(assetPath); + if (!resolved.startsWith(path.resolve(brandingDir)) || !fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { + return null; + } + return resolved; +} + +async function serveFile( + res: http.ServerResponse, + method: string, + filePath: string, +): Promise { + const stat = await fs.promises.stat(filePath); + const ext = path.extname(filePath).toLowerCase(); + res.statusCode = 200; + res.setHeader('Content-Type', MIME_TYPES[ext] ?? 'application/octet-stream'); + res.setHeader('Content-Length', String(stat.size)); + res.setHeader('Cache-Control', 'no-cache'); + if (method === 'HEAD') { + res.end(); + return; + } + const stream = fs.createReadStream(filePath); + await new Promise((resolve, reject) => { + stream.once('error', reject); + res.once('error', reject); + res.once('finish', resolve); + stream.pipe(res); + }); +} + +function writeBody( + res: http.ServerResponse, + method: string, + body: string, +): void { + res.setHeader('Content-Length', Buffer.byteLength(body)); + if (method === 'HEAD') { + res.end(); + return; + } + res.end(body); +} + +function proxyWebSocketUpgrade( + req: http.IncomingMessage, + socket: Duplex, + head: Buffer, + targetOrigin: string, +): void { + let target: URL; + try { + target = new URL(targetOrigin); + } catch { + socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + return; + } + if (!['ws:', 'wss:', 'http:', 'https:'].includes(target.protocol)) { + socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + return; + } + + const port = target.port + ? Number(target.port) + : (target.protocol === 'wss:' || target.protocol === 'https:' ? 443 : 80); + const upstream: Duplex = (target.protocol === 'wss:' || target.protocol === 'https:' + ? tls.connect(port, target.hostname, { servername: target.hostname }) + : net.connect(port, target.hostname)); + + upstream.once('connect', () => { + const forwardedPath = buildProxyPath(target, req.url); + let rawRequest = `GET ${forwardedPath} HTTP/${req.httpVersion}\r\n`; + rawRequest += `Host: ${target.host}\r\n`; + for (const [key, value] of Object.entries(req.headers)) { + const lowerKey = key.toLowerCase(); + if (lowerKey === 'host' || lowerKey === 'origin' || value === undefined) { + continue; + } + const values = Array.isArray(value) ? value : [value]; + for (const headerValue of values) { + rawRequest += `${key}: ${headerValue}\r\n`; + } + } + rawRequest += '\r\n'; + upstream.write(rawRequest); + if (head.length > 0) { + upstream.write(head); + } + socket.pipe(upstream); + upstream.pipe(socket); + }); + + upstream.once('error', () => { + socket.destroy(); + }); + socket.once('error', () => { + upstream.destroy(); + }); +} + +function buildProxyPath(target: URL, rawUrl: string | undefined): string { + const incoming = new URL(rawUrl ?? '/', 'http://matrix.local'); + const basePath = target.pathname === '/' ? '' : target.pathname.replace(/\/$/, ''); + if (basePath.length > 0) { + return `${basePath}${incoming.search}`; + } + return `${incoming.pathname}${incoming.search}`; +} + +function resolveBrandingDir(packageDir: string): string | null { + const brandingDir = path.join(packageDir, 'branding'); + return fs.existsSync(path.join(brandingDir, 'manifest.json')) ? brandingDir : null; +} + +function safeDecodePath(pathname: string): string | null { + try { + const decoded = decodeURIComponent(pathname); + if (decoded.includes('\0')) { + return null; + } + return decoded; + } catch { + return null; + } +} diff --git a/archive/mx-cli-runtime-host-commands/workspace-config.ts b/archive/mx-cli-runtime-host-commands/workspace-config.ts new file mode 100644 index 0000000..bb55192 --- /dev/null +++ b/archive/mx-cli-runtime-host-commands/workspace-config.ts @@ -0,0 +1,185 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export type MatrixWorkspacePackageStore = 'global' | 'system'; + +export type MatrixWorkspaceServiceDeclaration = + | { readonly path: string } + | { readonly package: string; readonly store?: MatrixWorkspacePackageStore }; + +export interface MatrixWorkspaceConfig { + readonly home?: string; + readonly default?: readonly string[]; + readonly services?: Record; +} + +export interface MatrixWorkspaceInitResult { + readonly workspaceRoot: string; + readonly workspaceConfigPath: string; + readonly home: string; + readonly created: boolean; +} + +export function ensureMatrixWorkspace(workspaceRoot: string): MatrixWorkspaceInitResult { + const root = path.resolve(workspaceRoot); + const matrixDir = path.join(root, '.matrix'); + const workspaceConfigPath = path.join(matrixDir, 'workspace.json'); + fs.mkdirSync(matrixDir, { recursive: true }); + + let created = false; + if (!fs.existsSync(workspaceConfigPath)) { + writeWorkspaceConfig(root, { + home: '.matrix/home', + default: [], + services: {}, + }); + created = true; + } + + const config = readWorkspaceConfig(root); + const home = path.resolve(root, config.home ?? '.matrix/home'); + fs.mkdirSync(home, { recursive: true }); + return { + workspaceRoot: root, + workspaceConfigPath, + home, + created, + }; +} + +export function readWorkspaceConfig(workspaceRoot: string): MatrixWorkspaceConfig { + const workspaceConfigPath = path.join(path.resolve(workspaceRoot), '.matrix', 'workspace.json'); + const parsed = JSON.parse(fs.readFileSync(workspaceConfigPath, 'utf8')) as unknown; + if (!isRecord(parsed)) { + throw new Error(`${workspaceConfigPath} must contain a JSON object`); + } + return { + ...(typeof parsed.home === 'string' && parsed.home.trim() ? { home: parsed.home.trim() } : {}), + ...(Array.isArray(parsed.default) ? { default: parsed.default.filter(isNonEmptyString) } : {}), + ...(isRecord(parsed.services) ? { services: normalizeServices(parsed.services, workspaceConfigPath) } : {}), + }; +} + +export function writeWorkspaceConfig(workspaceRoot: string, config: MatrixWorkspaceConfig): string { + const root = path.resolve(workspaceRoot); + const matrixDir = path.join(root, '.matrix'); + const workspaceConfigPath = path.join(matrixDir, 'workspace.json'); + fs.mkdirSync(matrixDir, { recursive: true }); + fs.writeFileSync(workspaceConfigPath, `${JSON.stringify({ + home: config.home ?? '.matrix/home', + default: config.default ?? [], + services: config.services ?? {}, + }, null, 2)}\n`, 'utf8'); + return workspaceConfigPath; +} + +export function addWorkspaceService( + workspaceRoot: string, + serviceId: string, + declaration: MatrixWorkspaceServiceDeclaration, + options: { readonly addToDefault?: boolean } = {}, +): string { + ensureMatrixWorkspace(workspaceRoot); + const config = readWorkspaceConfig(workspaceRoot); + const services = { ...(config.services ?? {}) }; + services[serviceId] = declaration; + const currentDefault = config.default ?? []; + const nextDefault = options.addToDefault === false || currentDefault.includes(serviceId) + ? currentDefault + : [...currentDefault, serviceId]; + return writeWorkspaceConfig(workspaceRoot, { + ...config, + services, + default: nextDefault, + }); +} + +export function ensureBaseHostServices(workspaceRoot: string): string { + ensureMatrixWorkspace(workspaceRoot); + const config = readWorkspaceConfig(workspaceRoot); + const services = { ...(config.services ?? {}) }; + + const systemSource = findSourceCheckoutPackage(workspaceRoot, 'system'); + const hostControlSource = findSourceCheckoutPackage(workspaceRoot, 'host-control'); + + if (!services.system) { + services.system = systemSource + ? { path: relativeWorkspacePath(workspaceRoot, systemSource) } + : { package: '@open-matrix/system', store: 'system' }; + } + if (!services['host-control']) { + services['host-control'] = hostControlSource + ? { path: relativeWorkspacePath(workspaceRoot, hostControlSource) } + : { package: '@open-matrix/host-control', store: 'system' }; + } + + const currentDefault = config.default ?? []; + const prefix = ['system', 'host-control']; + return writeWorkspaceConfig(workspaceRoot, { + ...config, + services, + default: [ + ...prefix, + ...currentDefault.filter((entry) => !prefix.includes(entry)), + ], + }); +} + +export function findSourceCheckoutPackage(workspaceRoot: string, packageDirName: string): string | null { + const root = path.resolve(workspaceRoot); + const candidates = [ + path.join(root, 'projects', 'matrix-3', 'packages', packageDirName), + path.join(root, 'packages', packageDirName), + ]; + for (const candidate of candidates) { + if ( + fs.existsSync(path.join(candidate, 'package.json')) + && fs.existsSync(path.join(candidate, 'matrix.json')) + ) { + return candidate; + } + } + return null; +} + +export function relativeWorkspacePath(workspaceRoot: string, target: string): string { + const relative = path.relative(path.resolve(workspaceRoot), path.resolve(target)).replaceAll(path.sep, '/'); + if (!relative) { + return '.'; + } + return relative.startsWith('.') ? relative : `./${relative}`; +} + +function normalizeServices( + services: Record, + workspaceConfigPath: string, +): Record { + const normalized: Record = {}; + for (const [id, value] of Object.entries(services)) { + if (!isRecord(value)) { + throw new Error(`${workspaceConfigPath}: services.${id} must be an object`); + } + if (typeof value.path === 'string' && value.path.trim()) { + normalized[id] = { path: value.path.trim() }; + continue; + } + if (typeof value.package === 'string' && value.package.trim()) { + const store = value.store === 'system' ? 'system' : value.store === 'global' ? 'global' : undefined; + normalized[id] = { + package: value.package.trim(), + ...(store ? { store } : {}), + }; + continue; + } + throw new Error(`${workspaceConfigPath}: services.${id} must declare path or package`); + } + return normalized; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/archive/mx-cli-runtime-host-tests/actor-command.host-workspace.spec.ts b/archive/mx-cli-runtime-host-tests/actor-command.host-workspace.spec.ts new file mode 100644 index 0000000..9f65494 --- /dev/null +++ b/archive/mx-cli-runtime-host-tests/actor-command.host-workspace.spec.ts @@ -0,0 +1,171 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { actorCommand } from '../../src/commands/actor.js'; +import { installOpenMatrixCoreStub } from '../support/installCoreStub.ts'; +import { loadMatrixServiceManifest } from '../../src/utils/service-manifest.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '../../../../'); + +describe('mx actor command', () => { + it('creates a compilable actor scaffold package', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-')); + const outputDir = path.join(tmpRoot, 'my-weather-service'); + + try { + const result = await actorCommand('my-weather-service', { + cwd: tmpRoot, + outputDir, + dev: true, + }); + + assert.equal(result.rootDir, outputDir); + assert.equal(result.workspacePath, path.join(tmpRoot, '.matrix', 'workspace.json')); + assert.equal(fs.existsSync(path.join(outputDir, 'matrix.json')), true); + assert.equal(fs.existsSync(path.join(outputDir, 'src', `${result.className}.ts`)), true); + assert.equal(fs.existsSync(path.join(outputDir, 'src', 'index.ts')), true); + assert.equal(fs.existsSync(path.join(outputDir, 'examples', 'get-status.mxq')), true); + assert.equal(fs.existsSync(path.join(outputDir, 'skills', 'my-weather-service.skill.md')), true); + assert.equal(fs.existsSync(path.join(outputDir, 'tests', 'my-weather-service.spec.ts')), true); + assert.equal(fs.existsSync(path.join(outputDir, 'package.json')), true, 'package.json should be generated'); + assert.equal(fs.existsSync(path.join(outputDir, 'tsconfig.json')), true, 'tsconfig.json should be generated'); + assert.equal(fs.existsSync(path.join(outputDir, 'matrix.service.json')), false, 'standalone factory is declared in matrix.json'); + assert.equal( + fs.existsSync(path.join(outputDir, 'src', `create-standalone-${result.className}.ts`)), + true, + 'standalone service factory should be generated', + ); + + const pkg = JSON.parse(fs.readFileSync(path.join(outputDir, 'package.json'), 'utf8')); + assert.equal(pkg.type, 'module', 'package.json should have type: module'); + assert.ok(Array.isArray(pkg.files), 'package.json should have files array'); + assert.equal(pkg.files.includes('matrix.service.json'), false, 'package files should not ship a second service manifest'); + assert.equal(pkg.scripts?.build, 'tsc -p tsconfig.json'); + assert.equal(pkg.scripts?.['build:watch'], 'tsc -w -p tsconfig.json --preserveWatchOutput'); + assert.equal(pkg.scripts?.dev, 'tsc -w -p tsconfig.json --preserveWatchOutput'); + assert.equal(pkg.scripts?.test, 'node --import tsx --test tests/**/*.spec.ts'); + assert.equal(pkg.dependencies?.['@open-matrix/core'], '*', 'actor service package should declare runtime SDK dependency'); + assert.equal(pkg.devDependencies?.['@open-matrix/core'], undefined, 'actor service package should not hide runtime SDK in devDependencies'); + + const tsconfig = JSON.parse(fs.readFileSync(path.join(outputDir, 'tsconfig.json'), 'utf8')); + assert.ok(tsconfig.compilerOptions, 'tsconfig.json should have compilerOptions'); + assert.equal(tsconfig.compilerOptions.target, 'ES2022'); + assert.equal(tsconfig.compilerOptions.incremental, true); + assert.equal(tsconfig.compilerOptions.tsBuildInfoFile, '.tsbuildinfo'); + assert.equal(tsconfig.compilerOptions.sourceMap, true); + + const manifest = JSON.parse(fs.readFileSync(path.join(outputDir, 'matrix.json'), 'utf8')) as { + name: string; + runtime?: { + factory?: { + kind?: string; + export?: string; + instance?: { + rootKind?: string; + componentType?: string; + autoStart?: boolean; + }; + }; + }; + components: Array<{ mount?: string; type?: string }>; + }; + assert.equal(manifest.name, '@matrix/my-weather-service'); + assert.equal(manifest.components[0]?.mount, 'my-weather-service'); + assert.equal(manifest.components[0]?.type, result.className); + assert.equal(manifest.runtime?.factory?.kind, 'factory'); + assert.equal(manifest.runtime?.factory?.export, `createStandalone${result.className}`); + assert.equal(manifest.runtime?.factory?.instance?.rootKind, 'component'); + assert.equal(manifest.runtime?.factory?.instance?.componentType, result.className); + assert.equal(manifest.runtime?.factory?.instance?.autoStart, true); + + // Install a minimal runtime stub so the scaffolded actor can resolve + // the published package import shape without depending on a built workspace copy. + installOpenMatrixCoreStub(outputDir); + + const compile = spawnSync( + process.execPath, + [ + path.resolve(repoRoot, 'node_modules/tsx/dist/cli.mjs'), + path.join(outputDir, 'src', `${result.className}.ts`), + ], + { cwd: outputDir, encoding: 'utf8' } + ); + assert.equal( + compile.status, + 0, + `expected generated actor to compile, stderr: ${compile.stderr || '(none)'}` + ); + + const build = spawnSync( + process.execPath, + [ + path.resolve(repoRoot, 'node_modules/typescript/bin/tsc'), + '-p', + path.join(outputDir, 'tsconfig.json'), + ], + { cwd: outputDir, encoding: 'utf8' }, + ); + assert.equal(build.status, 0, `expected generated package to build, stderr: ${build.stderr || '(none)'}`); + + const loadedService = loadMatrixServiceManifest(outputDir); + assert.equal(loadedService.manifestPath, path.join(outputDir, 'matrix.json')); + assert.equal(loadedService.exportName, `createStandalone${result.className}`); + assert.equal(loadedService.entryPath, path.join(outputDir, 'dist', 'index.js')); + assert.equal(loadedService.serviceInstance.rootKind, 'component'); + assert.equal(loadedService.serviceInstance.mount, 'my-weather-service'); + + const workspace = JSON.parse(fs.readFileSync(result.workspacePath!, 'utf8')); + assert.equal(workspace.home, '.matrix/home'); + assert.deepEqual(workspace.default, ['system', 'host-control', 'my-weather-service']); + assert.deepEqual(workspace.services?.system, { + package: '@open-matrix/system', + store: 'system', + }); + assert.deepEqual(workspace.services?.['host-control'], { + package: '@open-matrix/host-control', + store: 'system', + }); + assert.deepEqual(workspace.services?.['my-weather-service'], { + path: './my-weather-service', + }); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('adds the source checkout system service before the actor in dev workspaces', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-system-dev-')); + const systemDir = path.join(tmpRoot, 'projects', 'matrix-3', 'packages', 'system'); + const hostControlDir = path.join(tmpRoot, 'projects', 'matrix-3', 'packages', 'host-control'); + + try { + fs.mkdirSync(systemDir, { recursive: true }); + fs.writeFileSync(path.join(systemDir, 'package.json'), JSON.stringify({ name: '@open-matrix/system' }), 'utf8'); + fs.writeFileSync(path.join(systemDir, 'matrix.json'), JSON.stringify({ name: '@open-matrix/system' }), 'utf8'); + fs.mkdirSync(hostControlDir, { recursive: true }); + fs.writeFileSync(path.join(hostControlDir, 'package.json'), JSON.stringify({ name: '@open-matrix/host-control' }), 'utf8'); + fs.writeFileSync(path.join(hostControlDir, 'matrix.json'), JSON.stringify({ name: '@open-matrix/host-control' }), 'utf8'); + + const result = await actorCommand('dev-loop-demo', { + cwd: tmpRoot, + outputDir: path.join(tmpRoot, 'dev-loop-demo'), + dev: true, + }); + + const workspace = JSON.parse(fs.readFileSync(result.workspacePath!, 'utf8')); + assert.equal(workspace.home, '.matrix/home'); + assert.deepEqual(workspace.default, ['system', 'host-control', 'dev-loop-demo']); + assert.deepEqual(workspace.services?.system, { path: './projects/matrix-3/packages/system' }); + assert.deepEqual(workspace.services?.['host-control'], { path: './projects/matrix-3/packages/host-control' }); + assert.deepEqual(workspace.services?.['dev-loop-demo'], { path: './dev-loop-demo' }); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/archive/mx-cli-runtime-host-tests/run-command.spec.ts b/archive/mx-cli-runtime-host-tests/run-command.spec.ts new file mode 100644 index 0000000..1e7b828 --- /dev/null +++ b/archive/mx-cli-runtime-host-tests/run-command.spec.ts @@ -0,0 +1,408 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawn } from 'node:child_process'; + +import { resolveNatsBinary } from '../../src/utils/runner-transport.js'; +import { assertExitCode, parseLastJson, runMxCli } from '../helpers/cli-harness.js'; + +interface IWebSocketLike { + addEventListener( + type: 'open' | 'error' | 'close', + listener: (event: unknown) => void, + options?: { once?: boolean }, + ): void; + close(): void; +} + +async function getAvailablePorts(count: number): Promise { + const servers = await Promise.all( + Array.from({ length: count }, () => new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve(server)); + })), + ); + const ports = servers.map((server) => { + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return address.port; + }); + await Promise.all(servers.map((server) => new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }))); + return ports; +} + +async function createRunInPlaceFixture(rootDir: string): Promise { + const packageDir = path.join(rootDir, 'sample-run-package'); + const runtimeDir = path.join(packageDir, 'dist', 'runtime'); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.mkdirSync(path.join(packageDir, '.matrix'), { recursive: true }); + const [natsPort, wsPort] = await getAvailablePorts(2); + + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify({ + name: '@acme/sample-run-package', + version: '1.0.0', + type: 'module', + main: './dist/runtime/index.js', + }, null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + JSON.stringify({ + name: '@acme/sample-run-package', + version: '1.0.0', + runtime: { + language: 'typescript', + entry: './dist/runtime/index.js', + factory: { + kind: 'factory', + export: 'createSampleRunService', + bootstrap: { + overrides: { + mode: 'run-in-place', + }, + }, + instance: { + packageName: '@acme/sample-run-package', + packageRevision: '1.0.0', + class: 'service', + rootKind: 'package-root', + mount: 'sample-run.local', + autoStart: true, + }, + }, + }, + components: [ + { + type: 'SampleRunService', + export: 'SampleRunService', + mount: 'sample-run', + autoStart: true, + }, + ], + permissions: { + fsPolicy: 'none', + }, + }, null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(packageDir, '.matrix', 'dev.environment.json'), + JSON.stringify({ + name: 'dev', + runtime: { + root: 'COM.TEST.RUNNER', + runtimeId: 'sample-run-dev', + }, + nats: { + mode: 'embedded', + port: natsPort, + wsPort, + binaryPath: resolveNatsBinary(process.cwd()), + }, + }, null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(runtimeDir, 'index.js'), + `export async function createSampleRunService(overrides = {}, _services, _configResolution, bootstrapContext) { + return { + ok: true, + overrides, + bootContext: bootstrapContext, + runtime: { + async shutdown() { + return undefined; + } + } + }; + } + `, + 'utf8', + ); + + return packageDir; +} + +async function createServeFixture(rootDir: string): Promise<{ + packageDir: string; + httpPort: number; +}> { + const packageDir = path.join(rootDir, 'sample-serve-package'); + const distDir = path.join(packageDir, 'dist'); + fs.mkdirSync(path.join(packageDir, '.matrix'), { recursive: true }); + fs.mkdirSync(distDir, { recursive: true }); + fs.mkdirSync(path.join(packageDir, 'branding'), { recursive: true }); + const [httpPort, natsPort, wsPort] = await getAvailablePorts(3); + + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify({ + name: '@acme/sample-serve-package', + version: '1.0.0', + type: 'module', + }, null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + JSON.stringify({ + name: '@acme/sample-serve-package', + version: '1.0.0', + webapp: { + distDir: 'dist', + entry: 'index.html', + appName: 'sample-serve', + }, + permissions: { + fsPolicy: 'none', + }, + }, null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(packageDir, '.matrix', 'dev.environment.json'), + JSON.stringify({ + name: 'dev', + runtime: { + root: 'COM.TEST.SERVE', + runtimeId: 'sample-serve-dev', + }, + nats: { + mode: 'embedded', + port: natsPort, + wsPort, + binaryPath: resolveNatsBinary(process.cwd()), + }, + http: { + enabled: true, + port: httpPort, + }, + }, null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(distDir, 'index.html'), + 'Sample Serve
Sample Serve Fixture
\n', + 'utf8', + ); + + fs.writeFileSync( + path.join(distDir, 'app.js'), + 'console.log("sample-serve");\n', + 'utf8', + ); + + fs.writeFileSync( + path.join(packageDir, 'branding', 'manifest.json'), + JSON.stringify({ + name: 'Sample Serve', + short_name: 'Sample', + start_url: '/', + display: 'standalone', + icons: [{ src: '/branding/favicon.svg', sizes: '16x16', type: 'image/svg+xml' }], + }, null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(packageDir, 'branding', 'favicon.svg'), + '\n', + 'utf8', + ); + + return { packageDir, httpPort }; +} + +async function waitForHttpReady( + url: string, + child: ReturnType, + timeoutMs = 15_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`mx run exited early with code ${child.exitCode}`); + } + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + // Retry until the deadline expires. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${url}`); +} + +describe('mx run package front door', () => { + it('runs a package from the current folder via matrix.json runtime.factory and package-local environment', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-run-package-')); + const packageDir = await createRunInPlaceFixture(tempRoot); + + assert.equal(fs.existsSync(path.join(packageDir, '.matrix', 'host.status.json')), false); + + const result = await runMxCli( + ['node', 'mx', 'run', '.', '--env', 'dev', '--check', '--json'], + { cwd: packageDir }, + ); + + assertExitCode(result, 0); + const payload = parseLastJson(result.logs) as Record; + assert.equal(payload.packageName, '@acme/sample-run-package'); + assert.equal(payload.packageDir, packageDir); + assert.equal(payload.environmentName, 'dev'); + assert.equal(payload.root, 'COM.TEST.RUNNER'); + assert.equal(payload.mount, 'sample-run.local'); + assert.equal(payload.entryPath, path.join(packageDir, 'dist', 'runtime', 'index.js')); + assert.equal(payload.runnerTransport?.mode, 'embedded'); + assert.equal(payload.runnerTransport?.root, 'COM.TEST.RUNNER'); + assert.match(String(payload.runnerTransport?.url), /^nats:\/\/127\.0\.0\.1:\d+$/); + assert.match(String(payload.runnerTransport?.wsUrl), /^ws:\/\/127\.0\.0\.1:\d+$/); + }); + + it('fails clearly when a directory target does not declare a runtime factory', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-run-missing-service-')); + const packageDir = path.join(tempRoot, 'missing-service'); + fs.mkdirSync(packageDir, { recursive: true }); + + const result = await runMxCli( + ['node', 'mx', 'run', '.'], + { cwd: packageDir }, + ); + + assertExitCode(result, 1); + assert.match( + result.errors.join('\n'), + /does not declare a matrix\.json runtime\.factory/, + ); + }); + + it('runs a webapp package as a Matrix asset runtime without a package HTTP server', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-run-webapp-assets-')); + const { packageDir } = await createServeFixture(tempRoot); + + const result = await runMxCli( + ['node', 'mx', 'run', '.', '--env', 'dev', '--check', '--json'], + { cwd: packageDir }, + ); + + assertExitCode(result, 0); + const payload = parseLastJson(result.logs) as Record; + assert.equal(payload.packageName, '@acme/sample-serve-package'); + assert.equal(payload.mode, 'webapp-assets'); + assert.equal(payload.assetMount, 'system.runtimes.sample-serve-dev.http'); + assert.equal(payload.baseUrl, undefined); + assert.equal(payload.runnerTransport?.mode, 'embedded'); + }); + + it('publishes a served webapp HTTP origin in run metadata', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-run-webapp-origin-')); + const { packageDir, httpPort } = await createServeFixture(tempRoot); + + const result = await runMxCli( + ['node', 'mx', 'run', '.', '--env', 'dev', '--serve', '--check', '--json'], + { cwd: packageDir }, + ); + + assertExitCode(result, 0); + const payload = parseLastJson(result.logs) as Record; + assert.equal(payload.packageName, '@acme/sample-serve-package'); + assert.equal(payload.mode, 'standalone-webapp'); + assert.equal(payload.assetMount, 'system.runtimes.sample-serve-dev.http'); + assert.equal(payload.baseUrl, `http://127.0.0.1:${httpPort}`); + assert.equal(payload.httpPort, httpPort); + assert.equal(payload.runnerTransport?.mode, 'embedded'); + }); + + it('serves built webapp assets with same-origin bootstrap and nats-ws routes', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-run-serve-')); + const { packageDir, httpPort } = await createServeFixture(tempRoot); + const mxCliDir = path.resolve(import.meta.dirname, '../..'); + const cliEntry = path.resolve(import.meta.dirname, '../../src/index.ts'); + const tsxLoader = path.resolve(import.meta.dirname, '../../../../node_modules/tsx/dist/loader.mjs'); + const child = spawn( + process.execPath, + ['--import', tsxLoader, cliEntry, 'run', packageDir, '--env', 'dev', '--serve'], + { + cwd: mxCliDir, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + const stdout: string[] = []; + const stderr: string[] = []; + child.stdout.on('data', (chunk: Buffer) => { + stdout.push(chunk.toString('utf8')); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr.push(chunk.toString('utf8')); + }); + + try { + const baseUrl = `http://127.0.0.1:${httpPort}`; + await waitForHttpReady(`${baseUrl}/healthz`, child); + + const html = await fetch(`${baseUrl}/`); + assert.equal(html.status, 200); + assert.match(await html.text(), /Sample Serve Fixture/); + + const bootstrap = await fetch(`${baseUrl}/api/bootstrap`); + assert.equal(bootstrap.status, 200); + const payload = await bootstrap.json() as { + root?: string; + transportPlan?: { wsPath?: string; authMode?: string; sessionMode?: string }; + }; + assert.equal(payload.root, 'COM.TEST.SERVE'); + assert.equal(payload.transportPlan?.wsPath, '/nats-ws'); + assert.equal(payload.transportPlan?.authMode, 'anonymous'); + assert.equal(payload.transportPlan?.sessionMode, 'local-client'); + + const manifest = await fetch(`${baseUrl}/manifest.json`); + assert.equal(manifest.status, 200); + const branding = await fetch(`${baseUrl}/branding/favicon.svg`); + assert.equal(branding.status, 200); + + const WebSocketCtor = (globalThis as typeof globalThis & { + WebSocket?: new (url: string) => IWebSocketLike; + }).WebSocket; + assert.ok(WebSocketCtor, 'Node runtime must expose WebSocket for same-origin proxy proof'); + const ws = new WebSocketCtor(`ws://127.0.0.1:${httpPort}/nats-ws`); + await new Promise((resolve, reject) => { + ws.addEventListener('open', () => { + ws.close(); + }, { once: true }); + ws.addEventListener('error', (event) => { + reject(new Error(`WebSocket upgrade failed: ${String(event)}`)); + }, { once: true }); + ws.addEventListener('close', () => { + resolve(); + }, { once: true }); + }); + } finally { + child.kill('SIGTERM'); + await new Promise((resolve) => { + child.once('exit', () => resolve()); + }); + } + + assert.match(stdout.join(''), /serving @acme\/sample-serve-package/); + assert.doesNotMatch(stderr.join(''), /Run target not found|does not declare|Error:/); + }); +}); diff --git a/archive/mx-cli-runtime-host-tests/runner-control-plane.spec.ts b/archive/mx-cli-runtime-host-tests/runner-control-plane.spec.ts new file mode 100644 index 0000000..ac21461 --- /dev/null +++ b/archive/mx-cli-runtime-host-tests/runner-control-plane.spec.ts @@ -0,0 +1,920 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + InMemoryBroker, + InMemoryTransport, + MatrixActor, + MatrixRuntime, + RequestReply, + subscribeRuntimePresence, +} from '@open-matrix/core'; +import { decideMatrixPlacementBind } from '@open-matrix/contracts/placement'; + +import { + heartbeatRunnerRuntime, + registerRunnerBindings, + registerRunnerRuntime, + shouldRegisterRuntimeInventoryClaim, +} from '../../src/utils/runner-control-plane.js'; +import { ServiceRegistryActor } from '@open-matrix/system-platform'; +import { BindingsRegistryActor } from '@open-matrix/system-bindings'; +import { installRunnerPlacementPolicy } from '../../src/utils/runner-placement-policy.js'; +import type { ILoadedMatrixServiceManifest } from '../../src/utils/service-manifest.js'; + +const runtimePresenceManifest: ILoadedMatrixServiceManifest = { + packageDir: '/tmp/runtime-presence-package', + packageName: '@open-matrix/runtime-presence-test', + packageNamespace: 'runtime-presence-test', + manifestPath: '/tmp/runtime-presence-package/matrix.json', + entryPath: '/tmp/runtime-presence-package/dist/index.js', + exportName: 'RuntimePresenceTestActor', + kind: 'factory', + overrides: {}, + root: 'SPACE-RUNNER', + mount: 'runtime-presence-test', + packageRoot: { + mount: 'runtime-presence-test', + accepts: ['runtime-presence.ping'], + }, + packageComponents: [], + serviceInstance: { + id: 'runtime-presence-runner', + packageName: '@open-matrix/runtime-presence-test', + class: 'RuntimePresenceTestActor', + rootKind: 'package-root', + autoStart: true, + registrationSource: 'matrix-service', + }, +}; + +class PlacementGatewayActor extends MatrixActor { + static override description = 'Placement gateway test actor'; +} + +class PlacementCredentialBridgeActor extends MatrixActor { + static override description = 'Placement credential bridge test actor'; +} + +class FakeAuthorityActor extends MatrixActor { + static override accepts = { + 'fake.ping': {}, + }; + + onFakePing(): { ok: true; actor: 'authority' } { + return { ok: true, actor: 'authority' }; + } +} + +class FakeDeviceActor extends MatrixActor { + static override accepts = { + 'fake.ping': {}, + }; + + onFakePing(): { ok: true; actor: 'device' } { + return { ok: true, actor: 'device' }; + } +} + +describe('runner control plane', () => { + it('keeps ephemeral runtime mounts out of the logical registry', () => { + assert.equal( + shouldRegisterRuntimeInventoryClaim('RUNTIME-HOST-CHAT', 'system.runtimes.RUNTIME-HOST-CHAT'), + false, + ); + assert.equal( + shouldRegisterRuntimeInventoryClaim('RUNTIME-HOST-CHAT', 'system.runtimes.RUNTIME-HOST-CHAT.control'), + false, + ); + assert.equal( + shouldRegisterRuntimeInventoryClaim('RUNTIME-BROWSER-1234', 'system.runtimes.RUNTIME-BROWSER-1234.director'), + false, + ); + }); + + it('continues to register canonical actor mounts', () => { + assert.equal(shouldRegisterRuntimeInventoryClaim('RUNTIME-HOST-CHAT', 'chat'), true); + assert.equal(shouldRegisterRuntimeInventoryClaim('RUNTIME-HOST-CHAT', 'chat.conversation'), true); + assert.equal(shouldRegisterRuntimeInventoryClaim('RUNTIME-HOST-SYSTEM', 'system.runtimes'), true); + }); + + it('publishes runtime presence when registering a runner runtime', async () => { + const runtime = new MatrixRuntime({ + transport: new InMemoryTransport(new InMemoryBroker(), { + name: 'runner-presence-test', + root: 'SPACE-RUNNER', + }), + logging: false, + }); + const observed: string[] = []; + const unsubscribe = subscribeRuntimePresence( + runtime.getRootContext(), + 'SPACE-RUNNER', + (record, event) => { + observed.push(`${event}:${record.runtimeId}:${record.app ?? ''}:${record.health?.status ?? ''}`); + }, + ); + + try { + await registerRunnerRuntime( + runtime, + runtimePresenceManifest, + undefined, + undefined, + 'runtime-presence-test', + ); + + // Slice 1b: runtime directory authority is system.runtimes + // (RuntimeManagerActor). registerRunnerRuntime publishes presence on the + // bus; subscribers like RuntimeManagerActor observe and serve it via + // runtimes.list. There is no longer a `kind: 'runtime'` registry claim. + assert.deepEqual(observed, ['announce:runtime-presence-runner:@open-matrix/runtime-presence-test:ok']); + } finally { + unsubscribe(); + await runtime.shutdown(); + } + }); + + it('marks linked-device control-plane calls for broker protocol request replies', async () => { + const runtime = new MatrixRuntime({ + transport: new InMemoryTransport(new InMemoryBroker(), { + name: 'runner-linked-authority-test', + root: 'test-user-1', + }), + logging: false, + }); + + try { + const registration = await registerRunnerRuntime( + runtime, + runtimePresenceManifest, + { + packageDir: '/tmp/runtime-presence-package', + envName: 'hivecast', + environmentPath: '/tmp/runtime-presence-package/.matrix/hivecast.environment.json', + environment: { + name: 'hivecast', + runtime: { + root: 'test-user-1', + runtimeId: 'runtime-presence-runner', + }, + nats: { + mode: 'external', + url: 'nats://dev-platform:4222', + credentialsRef: 'file:/var/lib/hivecast/credentials/hivecast-nats.json', + }, + host: { + matrixDir: '/var/lib/hivecast', + }, + }, + }, + { + envName: 'hivecast', + root: 'test-user-1', + mode: 'external', + url: 'nats://dev-platform:4222', + credentialsRef: 'file:/var/lib/hivecast/credentials/hivecast-nats.json', + }, + 'runtime-presence-test', + ); + + assert.equal(registration.controlTargetRoot, 'test-user-1'); + assert.equal(registration.protocolRequest, true); + } finally { + await runtime.shutdown(); + } + }); + + it('reclaim re-issues registry.claim after the registry actor was wiped (simulates SYSTEM restart)', async () => { + // This test pins the production failure mode that motivated the reclaim() + // closure on IRunnerRuntimeRegistration / IRunnerBindingRegistration: + // + // 1. Runner registers, registry stores claim in its in-memory _claims Map + // 2. SYSTEM runtime restarts (deploy, supervisor bounce). Its _claims + // starts empty + // 3. Runner's 10s heartbeat calls registry.claim.renew → registry has + // no record → returns { renewed: 0 } + // 4. Without reclaim, the runner's original 45s-TTL claim expires and + // the runtime vanishes from registry.query + // + // The fix: heartbeatRunnerRuntime / renewRunnerBindings return + // { known: false } / { refreshed: 0 } when the registry forgot us, and + // the heartbeat tick calls registration.reclaim() to re-issue claims. + const home = mkdtempSync(join(tmpdir(), 'runner-reclaim-test-')); + writeFileSync(join(home, 'matrix.json'), JSON.stringify({ + name: '@open-matrix/reclaim-test', + root: { + type: 'ReclaimTestRootActor', + mount: 'reclaim-test', + }, + components: [], + })); + const reclaimManifest: ILoadedMatrixServiceManifest = { + ...runtimePresenceManifest, + packageDir: home, + packageName: '@open-matrix/reclaim-test', + packageNamespace: 'reclaim-test', + manifestPath: join(home, 'matrix.json'), + entryPath: join(home, 'dist/index.js'), + packageRoot: { + mount: 'reclaim-test', + accepts: ['reclaim.ping'], + }, + serviceInstance: { + ...runtimePresenceManifest.serviceInstance, + id: 'reclaim-test-runner', + packageName: '@open-matrix/reclaim-test', + }, + }; + const runtime = new MatrixRuntime({ + transport: new InMemoryTransport(new InMemoryBroker(), { + name: 'runner-reclaim-test', + root: 'SPACE-RECLAIM', + }), + logging: false, + }); + + const queryRegistry = async () => await RequestReply.execute( + runtime.getRootContext(), + 'system.registry', + 'registry.query', + { kind: 'actor', includeStale: true }, + { timeoutMs: 5_000 }, + ) as { entries?: Array<{ mount?: string; providerRuntimeId?: string }> }; + + const queryBindings = async () => await RequestReply.execute( + runtime.getRootContext(), + 'system.bindings', + 'bindings.list', + {}, + { timeoutMs: 5_000 }, + ) as { ok?: boolean; count?: number; bindings?: Array<{ binding?: string }> }; + + try { + const runtimeRegistration = await registerRunnerRuntime( + runtime, + reclaimManifest, + undefined, + undefined, + 'reclaim-test', + ); + const bindingRegistration = await registerRunnerBindings( + runtime, + reclaimManifest, + undefined, + runtimeRegistration.runtimeId, + 'reclaim-test', + ); + + // Sanity: both registries know about us before wipe. + const beforeWipeRegistry = await queryRegistry(); + const beforeRegistryMounts = new Set((beforeWipeRegistry.entries ?? []).map((e) => e.mount)); + assert.ok(beforeRegistryMounts.size > 0, 'system.registry should have at least one claim before wipe'); + const beforeWipeBindings = await queryBindings(); + const beforeBindingMounts = new Set((beforeWipeBindings.bindings ?? []).map((b) => b.binding)); + assert.ok(beforeBindingMounts.size > 0, 'system.bindings should have at least one binding before wipe'); + + // Simulate SYSTEM restart: tear down BOTH registries and replace them + // with fresh ones. Their _claims / _bindings maps start empty — + // exactly the state the production registries are in right after a + // SYSTEM bounce. + await runtime.remove('system.registry'); + await runtime.remove('system.bindings'); + await runtime.createSupervised(ServiceRegistryActor, 'system.registry'); + await runtime.createSupervised(BindingsRegistryActor, 'system.bindings'); + + const afterWipeRegistry = await queryRegistry(); + assert.deepEqual( + afterWipeRegistry.entries ?? [], + [], + 'system.registry should be empty after wipe (production failure mode)', + ); + const afterWipeBindings = await queryBindings(); + assert.deepEqual( + afterWipeBindings.bindings ?? [], + [], + 'system.bindings should be empty after wipe (production failure mode)', + ); + + // Reclaim should restore every server-side record we published at + // registration time. After both calls the registries see us again. + const runtimeReclaim = await runtimeRegistration.reclaim(); + assert.ok( + runtimeReclaim.inventoryClaimCount >= 0, + 'runtime reclaim returns the count of inventory claims re-issued', + ); + const bindingReclaim = await bindingRegistration.reclaim(); + assert.ok( + bindingReclaim.registryClaimCount >= 1, + 'binding reclaim re-issues at least the canonical package-root registry claim', + ); + assert.ok( + bindingReclaim.bindingsRegisteredCount >= 1, + 'binding reclaim re-issues at least the canonical package-root bindings.register', + ); + assert.equal( + bindingReclaim.registryClaimCount, + bindingReclaim.bindingsRegisteredCount, + 'every canonical binding produces exactly one registry.claim AND one bindings.register; reclaim must restore them in lockstep', + ); + + const afterReclaimRegistry = await queryRegistry(); + const afterReclaimRegistryMounts = new Set((afterReclaimRegistry.entries ?? []).map((e) => e.mount)); + assert.ok( + afterReclaimRegistryMounts.size >= beforeRegistryMounts.size, + `reclaim should restore all registry claims (had ${beforeRegistryMounts.size}, now ${afterReclaimRegistryMounts.size})`, + ); + assert.ok( + afterReclaimRegistryMounts.has('reclaim-test'), + 'canonical package-root registry claim should be reclaimed', + ); + + const afterReclaimBindings = await queryBindings(); + const afterReclaimBindingMounts = new Set((afterReclaimBindings.bindings ?? []).map((b) => b.binding)); + assert.deepEqual( + afterReclaimBindingMounts, + beforeBindingMounts, + 'reclaim should restore every bindings.register record (otherwise canonical lookups break silently)', + ); + } finally { + await runtime.shutdown(); + rmSync(home, { recursive: true, force: true }); + } + }); + + it('publishes placement metadata for runtime inventory and registry claims', async () => { + const home = mkdtempSync(join(tmpdir(), 'runner-placement-manifest-')); + const manifestPath = join(home, 'matrix.json'); + writeFileSync(manifestPath, JSON.stringify({ + name: '@open-matrix/placement-test', + root: { + type: 'PlacementRootActor', + mount: 'system', + }, + components: [ + { + type: 'PlacementRegistryActor', + mount: 'system.registry', + placement: { cardinality: 'authority-singleton' }, + }, + { + type: 'PlacementGatewayActor', + mount: 'system.gateway', + placement: { cardinality: 'device-local-control' }, + }, + { + type: 'PlacementCredentialBridgeActor', + mount: 'system.security.credential-bridge', + placement: { cardinality: 'device-singleton' }, + }, + ], + })); + + const manifest: ILoadedMatrixServiceManifest = { + ...runtimePresenceManifest, + packageDir: home, + packageName: '@open-matrix/placement-test', + packageNamespace: 'system', + manifestPath, + entryPath: join(home, 'dist/index.js'), + serviceInstance: { + ...runtimePresenceManifest.serviceInstance, + id: 'placement-test-runner', + packageName: '@open-matrix/placement-test', + }, + }; + const runtime = new MatrixRuntime({ + transport: new InMemoryTransport(new InMemoryBroker(), { + name: 'runner-placement-test', + root: 'SPACE-RUNNER', + }), + logging: false, + }); + + try { + await runtime.createSupervised(PlacementGatewayActor, 'system.gateway'); + await runtime.createSupervised(PlacementCredentialBridgeActor, 'system.security.credential-bridge'); + + const registration = await registerRunnerRuntime( + runtime, + manifest, + undefined, + undefined, + 'system', + ); + + const inventoryByMount = new Map( + (registration.presence.inventory ?? []).map((entry) => [entry.mount, entry]), + ); + assert.equal(inventoryByMount.get('system.registry')?.placement?.cardinality, 'authority-singleton'); + assert.equal(inventoryByMount.get('system.registry')?.placement?.claimMode, 'authoritative'); + assert.equal(inventoryByMount.get('system.registry')?.placement?.logicalMount, 'system.registry'); + assert.equal(inventoryByMount.get('system.registry')?.placement?.physicalMount, 'system.registry'); + assert.equal(inventoryByMount.get('system.registry')?.placement?.callable, true); + assert.equal(inventoryByMount.get('system.gateway')?.placement?.cardinality, 'device-local-control'); + assert.equal(inventoryByMount.get('system.gateway')?.placement?.claimMode, 'local-only'); + assert.equal(inventoryByMount.get('system.security.credential-bridge')?.placement?.cardinality, 'device-singleton'); + assert.equal(inventoryByMount.get('system.security.credential-bridge')?.placement?.claimMode, 'local-only'); + + const actorEntries = await RequestReply.execute( + runtime.getRootContext(), + 'system.registry', + 'registry.query', + { namespaceRoot: 'SPACE-RUNNER', kind: 'actor' }, + { timeoutMs: 5_000 }, + ) as { + entries?: Array<{ + mount?: string; + claim?: { mode?: string }; + placement?: { + cardinality?: string; + claimMode?: string; + logicalMount?: string; + physicalMount?: string; + callable?: boolean; + }; + metadata?: { placement?: { callable?: boolean } }; + }>; + }; + + const byMount = new Map((actorEntries.entries ?? []).map((entry) => [entry.mount, entry])); + assert.equal(byMount.get('system.registry')?.claim?.mode, 'authoritative'); + assert.equal(byMount.get('system.registry')?.placement?.cardinality, 'authority-singleton'); + assert.equal(byMount.get('system.registry')?.placement?.claimMode, 'authoritative'); + assert.equal(byMount.get('system.registry')?.placement?.callable, true); + assert.equal(byMount.get('system.registry')?.metadata?.placement?.callable, true); + assert.equal(byMount.get('system.gateway')?.claim?.mode, 'local-only'); + assert.equal(byMount.get('system.gateway')?.placement?.cardinality, 'device-local-control'); + assert.equal(byMount.get('system.gateway')?.placement?.callable, true); + assert.equal(byMount.get('system.security.credential-bridge')?.claim?.mode, 'local-only'); + assert.equal(byMount.get('system.security.credential-bridge')?.placement?.cardinality, 'device-singleton'); + } finally { + await runtime.shutdown(); + rmSync(home, { recursive: true, force: true }); + } + }); + + it('blocks manifest authority-singleton actors before inbox bind on standby topology', async () => { + const home = mkdtempSync(join(tmpdir(), 'runner-placement-denial-')); + writeFileSync(join(home, 'matrix.json'), JSON.stringify({ + name: '@open-matrix/placement-denial-test', + components: [ + { + type: 'FakeAuthorityActor', + mount: 'system.fakeAuthority', + placement: { + cardinality: 'authority-singleton', + }, + }, + { + type: 'FakeDeviceActor', + mount: 'system.fakeDevice', + placement: { + cardinality: 'device-local-control', + }, + }, + ], + })); + + const manifest: ILoadedMatrixServiceManifest = { + ...runtimePresenceManifest, + packageDir: home, + packageName: '@open-matrix/placement-denial-test', + packageNamespace: 'system', + manifestPath: join(home, 'matrix.json'), + entryPath: join(home, 'dist/index.js'), + packageComponents: [ + { + mount: 'system.fakeAuthority', + accepts: ['fake.ping'], + }, + { + mount: 'system.fakeDevice', + accepts: ['fake.ping'], + }, + ], + serviceInstance: { + ...runtimePresenceManifest.serviceInstance, + id: 'placement-denial-runner', + packageName: '@open-matrix/placement-denial-test', + }, + }; + const runtime = new MatrixRuntime({ + transport: new InMemoryTransport(new InMemoryBroker(), { + name: 'runner-placement-denial-test', + root: 'SPACE-HIVECAST-LAB', + }), + logging: false, + }); + + try { + installRunnerPlacementPolicy({ + runtime, + manifest, + environment: { + packageDir: home, + envName: 'standby', + environmentPath: join(home, '.matrix', 'standby.environment.json'), + environment: { + name: 'standby', + runtime: { + root: 'SPACE-HIVECAST-LAB', + runtimeId: 'placement-denial-runner', + }, + topology: { + kind: 'linked-device', + roles: { + authorityCoordinator: { + active: false, + reason: 'linked-device-standby', + }, + }, + authority: { + authorityRoot: 'SPACE-HIVECAST-LAB', + }, + }, + }, + }, + runtimeId: 'placement-denial-runner', + authorityRoot: 'SPACE-HIVECAST-LAB', + }); + + await assert.rejects( + () => runtime.createSupervised(FakeAuthorityActor, 'system.fakeAuthority'), + /SINGLETON_MOUNT_NOT_OWNED/, + ); + await runtime.createSupervised(FakeDeviceActor, 'system.fakeDevice'); + + const registration = await registerRunnerRuntime( + runtime, + manifest, + undefined, + undefined, + 'system', + ); + const bindings = await registerRunnerBindings( + runtime, + manifest, + undefined, + registration.runtimeId, + 'system', + ); + + assert.equal(runtime.hasComponent('system.fakeAuthority'), false); + assert.equal(runtime.hasComponent('system.fakeDevice'), true); + assert.equal(bindings.bindings.some((binding) => binding.localMount === 'system.fakeAuthority'), false); + assert.equal(bindings.bindings.some((binding) => binding.localMount === 'system.fakeDevice'), true); + await assert.rejects( + () => RequestReply.execute( + runtime.getRootContext(), + 'system.fakeAuthority', + 'fake.ping', + {}, + { timeoutMs: 150 }, + ), + ); + const deviceReply = await RequestReply.execute( + runtime.getRootContext(), + 'system.fakeDevice', + 'fake.ping', + {}, + { timeoutMs: 5_000 }, + ) as { ok?: boolean; actor?: string }; + assert.deepEqual(deviceReply, { ok: true, actor: 'device' }); + + const inventoryByMount = new Map( + (registration.presence.inventory ?? []).map((entry) => [entry.mount, entry]), + ); + assert.equal(inventoryByMount.get('system.fakeAuthority')?.placement?.cardinality, 'authority-singleton'); + assert.equal(inventoryByMount.get('system.fakeAuthority')?.placement?.callable, false); + assert.equal( + inventoryByMount.get('system.fakeAuthority')?.placement?.standbyReason, + 'SINGLETON_MOUNT_NOT_OWNED', + ); + assert.equal(inventoryByMount.get('system.fakeDevice')?.placement?.cardinality, 'device-local-control'); + assert.equal(inventoryByMount.get('system.fakeDevice')?.placement?.claimMode, 'local-only'); + assert.equal(inventoryByMount.get('system.fakeDevice')?.placement?.callable, true); + + // Slice 1b: query without namespaceRoot filter because + // registerRuntimeInventoryClaims writes claims with manifest.root as the + // namespaceRoot, while presence uses the transport root. The substrate + // truth here is "system.fakeDevice is claimed by this runtime" — the + // test does not need to assert which namespaceRoot the claim is stored + // under (that's a separate pre-existing inconsistency the workstream + // does not address). + const actorEntries = await RequestReply.execute( + runtime.getRootContext(), + 'system.registry', + 'registry.query', + { kind: 'actor', includeStale: true }, + { timeoutMs: 5_000 }, + ) as { + entries?: Array<{ + mount?: string; + placement?: { + cardinality?: string; + claimMode?: string; + callable?: boolean; + }; + }>; + }; + const byMount = new Map((actorEntries.entries ?? []).map((entry) => [entry.mount, entry])); + assert.equal(byMount.has('system.fakeAuthority'), false); + assert.equal(byMount.get('system.fakeDevice')?.placement?.cardinality, 'device-local-control'); + assert.equal(byMount.get('system.fakeDevice')?.placement?.claimMode, 'local-only'); + assert.equal(byMount.get('system.fakeDevice')?.placement?.callable, true); + } finally { + await runtime.shutdown(); + rmSync(home, { recursive: true, force: true }); + } + }); + + it('uses authority leases to fence active authority-singleton placement', () => { + const activeLease = { + version: 1 as const, + authorityRoot: 'SPACE-HIVECAST-LAB', + leaseId: 'lease-active', + ownerNodeId: 'owner-a', + placementEpoch: 2, + acquiredAt: '2026-05-09T00:00:00.000Z', + heartbeatAt: '2026-05-09T00:00:00.000Z', + expiresAt: '2026-05-09T00:05:00.000Z', + status: 'active' as const, + scope: 'local-home' as const, + leaseAuthority: 'local' as const, + source: 'local-file' as const, + }; + + assert.equal(decideMatrixPlacementBind({ + authorityRoot: 'SPACE-HIVECAST-LAB', + mount: 'system.registry', + cardinality: 'authority-singleton', + authorityCoordinatorActive: true, + ownerNodeId: 'owner-a', + authorityLease: activeLease, + }).allowed, true); + + const nonOwned = decideMatrixPlacementBind({ + authorityRoot: 'SPACE-HIVECAST-LAB', + mount: 'system.registry', + cardinality: 'authority-singleton', + authorityCoordinatorActive: true, + ownerNodeId: 'owner-b', + authorityLease: activeLease, + }); + assert.equal(nonOwned.allowed, false); + assert.equal(nonOwned.reason, 'AUTHORITY_COORDINATOR_LEASE_NOT_OWNED'); + + const fenced = decideMatrixPlacementBind({ + authorityRoot: 'SPACE-HIVECAST-LAB', + mount: 'system.registry', + cardinality: 'authority-singleton', + authorityCoordinatorActive: true, + ownerNodeId: 'owner-a', + authorityLease: { + ...activeLease, + status: 'fenced', + reason: 'LEASE_FORCED', + }, + }); + assert.equal(fenced.allowed, false); + assert.equal(fenced.reason, 'AUTHORITY_COORDINATOR_LEASE_NOT_OWNED'); + + assert.equal(decideMatrixPlacementBind({ + authorityRoot: 'SPACE-HIVECAST-LAB', + mount: 'system.registry', + cardinality: 'authority-singleton', + authorityCoordinatorActive: true, + ownerNodeId: 'owner-a', + }).allowed, true); + }); + + it('claims webapp surfaces in system.registry for shell launchers', async () => { + const runtime = new MatrixRuntime({ + transport: new InMemoryTransport(new InMemoryBroker(), { + name: 'runner-webapp-surface-test', + root: 'SPACE-RUNNER', + }), + logging: false, + }); + + try { + const registration = await registerRunnerRuntime( + runtime, + runtimePresenceManifest, + undefined, + undefined, + 'runtime-presence-test', + undefined, + undefined, + { + appName: 'director', + routePrefix: '/apps/director/', + displayName: 'Director', + icon: 'director', + navOrder: 20, + description: 'Matrix actor hierarchy explorer', + shells: ['platform', 'edge'], + assetMount: 'system.runtimes.runtime-presence-runner.http', + }, + ); + const appEntries = await RequestReply.execute( + runtime.getRootContext(), + 'system.registry', + 'registry.query', + { namespaceRoot: 'SPACE-RUNNER', kind: 'app', openable: true }, + { timeoutMs: 5_000 }, + ) as { + entries?: Array<{ + mount?: string; + app?: { appName?: string; route?: string; title?: string; icon?: string; openable?: boolean }; + metadata?: { source?: string; assetMount?: string; routeKind?: string; description?: string; navOrder?: number; shells?: string[] }; + placement?: { runtimeId?: string; packageRef?: string }; + }>; + }; + + assert.ok(registration.inventoryClaimCount >= 1); + assert.deepEqual(appEntries.entries?.map((entry) => entry.mount), ['director']); + assert.equal(appEntries.entries?.[0]?.app?.appName, 'director'); + assert.equal(appEntries.entries?.[0]?.app?.title, 'Director'); + assert.equal(appEntries.entries?.[0]?.app?.icon, 'director'); + assert.equal(appEntries.entries?.[0]?.app?.route, '/apps/director/'); + assert.equal(appEntries.entries?.[0]?.app?.openable, true); + assert.equal(appEntries.entries?.[0]?.metadata?.source, 'runner-webapp-surface'); + assert.equal(appEntries.entries?.[0]?.metadata?.description, 'Matrix actor hierarchy explorer'); + assert.equal(appEntries.entries?.[0]?.metadata?.navOrder, 20); + assert.deepEqual(appEntries.entries?.[0]?.metadata?.shells, ['platform', 'edge']); + assert.equal(appEntries.entries?.[0]?.metadata?.routeKind, 'matrix-asset-endpoint'); + assert.equal(appEntries.entries?.[0]?.metadata?.assetMount, 'system.runtimes.runtime-presence-runner.http'); + assert.equal(appEntries.entries?.[0]?.placement?.runtimeId, 'runtime-presence-runner'); + assert.equal(appEntries.entries?.[0]?.placement?.packageRef, '@open-matrix/runtime-presence-test'); + + await runtime.remove('system.registry'); + await runtime.createSupervised(ServiceRegistryActor, 'system.registry'); + + const afterWipe = await RequestReply.execute( + runtime.getRootContext(), + 'system.registry', + 'registry.query', + { namespaceRoot: 'SPACE-RUNNER', kind: 'app', openable: true }, + { timeoutMs: 5_000 }, + ) as { entries?: Array<{ mount?: string }> }; + assert.deepEqual(afterWipe.entries ?? [], []); + + const heartbeat = await heartbeatRunnerRuntime(runtime, registration); + assert.equal(heartbeat?.known, false); + const reclaimed = await registration.reclaim(); + assert.ok( + reclaimed.inventoryClaimCount >= 1, + 'webapp runtime reclaim re-issues the app-surface registry claim after a SYSTEM restart', + ); + + const reclaimedAppEntries = await RequestReply.execute( + runtime.getRootContext(), + 'system.registry', + 'registry.query', + { namespaceRoot: 'SPACE-RUNNER', kind: 'app', openable: true }, + { timeoutMs: 5_000 }, + ) as { entries?: Array<{ mount?: string; metadata?: { source?: string } }> }; + assert.deepEqual(reclaimedAppEntries.entries?.map((entry) => entry.mount), ['director']); + assert.equal(reclaimedAppEntries.entries?.[0]?.metadata?.source, 'runner-webapp-surface'); + } finally { + await runtime.shutdown(); + } + }); + + it('projects Host identity into Service Registry placement', async () => { + const home = mkdtempSync(join(tmpdir(), 'runner-host-placement-')); + mkdirSync(join(home, 'credentials'), { recursive: true }); + writeFileSync(join(home, 'credentials', 'hivecast-install.json'), JSON.stringify({ + version: 1, + installId: 'install_test000000000003', + hostName: 'Test Device', + })); + writeFileSync(join(home, 'credentials', 'hivecast-link.json'), JSON.stringify({ + version: 1, + hostId: 'install_test000000000003', + hostName: 'Test Device', + deviceSlug: 'test-device', + })); + const runtime = new MatrixRuntime({ + transport: new InMemoryTransport(new InMemoryBroker(), { + name: 'runner-host-placement-test', + root: 'SPACE-RUNNER', + }), + logging: false, + }); + runtime.registerService('matrix-host-home', home); + const observed: Array<{ + runtimeId: string; + metadata?: Record; + }> = []; + const unsubscribe = subscribeRuntimePresence( + runtime.getRootContext(), + 'SPACE-RUNNER', + (record) => { + observed.push({ + runtimeId: record.runtimeId, + ...(record.metadata ? { metadata: record.metadata } : {}), + }); + }, + ); + + try { + await registerRunnerRuntime( + runtime, + runtimePresenceManifest, + undefined, + undefined, + 'runtime-presence-test', + undefined, + undefined, + { + appName: 'director', + routePrefix: '/apps/director/', + assetMount: 'system.runtimes.runtime-presence-runner.http', + }, + ); + + const appEntries = await RequestReply.execute( + runtime.getRootContext(), + 'system.registry', + 'registry.query', + { namespaceRoot: 'SPACE-RUNNER', kind: 'app', openable: true }, + { timeoutMs: 5_000 }, + ) as { + entries?: Array<{ + placement?: { deviceId?: string; hostId?: string }; + metadata?: { deviceId?: string; hostId?: string; hostName?: string; deviceSlug?: string }; + }>; + }; + + // Slice 1b: real registry app claim still carries Host identity. + assert.equal(appEntries.entries?.[0]?.placement?.deviceId, 'install_test000000000003'); + assert.equal(appEntries.entries?.[0]?.placement?.hostId, 'install_test000000000003'); + assert.equal(appEntries.entries?.[0]?.metadata?.hostName, 'Test Device'); + assert.equal(appEntries.entries?.[0]?.metadata?.deviceSlug, 'test-device'); + // Runtime identity in this test is now asserted via presence (the + // runtime directory authority), not via a registry kind=runtime claim. + assert.equal(observed[0]?.metadata?.deviceId, 'install_test000000000003'); + assert.equal(observed[0]?.metadata?.hostName, 'Test Device'); + } finally { + unsubscribe(); + await runtime.shutdown(); + rmSync(home, { recursive: true, force: true }); + } + }); + + it('does not require Host placement before a fresh local install has identity files', async () => { + const home = mkdtempSync(join(tmpdir(), 'runner-host-placement-empty-')); + mkdirSync(join(home, 'credentials'), { recursive: true }); + const runtime = new MatrixRuntime({ + transport: new InMemoryTransport(new InMemoryBroker(), { + name: 'runner-host-placement-empty-test', + root: 'SPACE-RUNNER', + }), + logging: false, + }); + runtime.registerService('matrix-host-home', home); + + try { + await registerRunnerRuntime( + runtime, + runtimePresenceManifest, + undefined, + undefined, + 'runtime-presence-test', + undefined, + undefined, + { + appName: 'director', + routePrefix: '/apps/director/', + assetMount: 'system.runtimes.runtime-presence-runner.http', + }, + ); + + const appEntries = await RequestReply.execute( + runtime.getRootContext(), + 'system.registry', + 'registry.query', + { namespaceRoot: 'SPACE-RUNNER', kind: 'app', openable: true }, + { timeoutMs: 5_000 }, + ) as { + entries?: Array<{ + placement?: { deviceId?: string; hostId?: string }; + metadata?: { deviceId?: string; hostId?: string }; + }>; + }; + + assert.equal(appEntries.entries?.[0]?.placement?.deviceId, undefined); + assert.equal(appEntries.entries?.[0]?.placement?.hostId, undefined); + assert.equal(appEntries.entries?.[0]?.metadata?.deviceId, undefined); + assert.equal(appEntries.entries?.[0]?.metadata?.hostId, undefined); + } finally { + await runtime.shutdown(); + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/archive/mx-cli-runtime-host-tests/runner-runtime.spec.ts b/archive/mx-cli-runtime-host-tests/runner-runtime.spec.ts new file mode 100644 index 0000000..eda663e --- /dev/null +++ b/archive/mx-cli-runtime-host-tests/runner-runtime.spec.ts @@ -0,0 +1,46 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { MatrixActor } from '@open-matrix/core/core/MatrixActor.js'; +import { RequestReply } from '@open-matrix/core/engine/remoting/RequestReply.js'; +import { createRunnerRuntimeHandle } from '../../src/utils/runner-runtime.js'; + +class FactoryProbeActor extends MatrixActor {} + +describe('runner runtime security policy', () => { + it('starts production runner runtimes with standard policy and dynamic compilation disabled', async () => { + const handle = await createRunnerRuntimeHandle('COM.TEST.RUNNER-POLICY'); + try { + assert.equal(handle.runtime.isDynamicCompilationAllowed(), false); + + const rootContext = handle.runtime.getRootContext(); + const policyEngine = rootContext.securityRealm?.policyEngine; + assert.ok(policyEngine, 'runner runtime should install a policy engine'); + assert.equal( + policyEngine.evaluate({ + op: 'factory.create-from-code', + payload: {}, + sender: 'test.probe', + meta: { topic: 'factory-probe.$inbox', ts: Date.now() }, + }).action, + 'deny', + ); + + const actor = new FactoryProbeActor(); + await actor.initialize(rootContext.derive('factory-probe'), 'factory-probe'); + + await assert.rejects( + () => RequestReply.execute( + rootContext, + 'factory-probe', + 'factory.create-from-code', + { name: 'probe', code: 'class Probe extends MatrixActor {}' }, + { timeoutMs: 1_000 }, + ), + /Dynamic component compilation denied by standard policy/, + ); + } finally { + await handle.shutdown(); + } + }); +}); diff --git a/archive/mx-cli-runtime-host-tests/runner-transport.spec.ts b/archive/mx-cli-runtime-host-tests/runner-transport.spec.ts new file mode 100644 index 0000000..d9969c2 --- /dev/null +++ b/archive/mx-cli-runtime-host-tests/runner-transport.spec.ts @@ -0,0 +1,97 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + resolveNatsBinary, + resolveRunnerTransportPlan, +} from '../../src/utils/runner-transport.js'; +import type { ILoadedMatrixPackageEnvironment } from '../../src/utils/package-environment.js'; + +function createLoadedEnvironment( + packageDir: string, + envName: string, + environment: ILoadedMatrixPackageEnvironment['environment'], +): ILoadedMatrixPackageEnvironment { + return { + packageDir, + envName, + environmentPath: path.join(packageDir, '.matrix', `${envName}.environment.json`), + environment, + }; +} + +describe('runner transport resolution', () => { + it('prefers environment-owned embedded NATS paths over shared defaults', () => { + const packageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-runner-embedded-')); + const loaded = createLoadedEnvironment(packageDir, 'dev', { + name: 'dev', + runtime: { root: 'COM.TEST.EMBEDDED' }, + nats: { + mode: 'embedded', + port: 5111, + wsPort: 5112, + binaryPath: './tools/nats-server-custom', + dataDir: './runtime-state/nats-data', + pidFile: './runtime-state/nats.pid', + }, + }); + + const plan = resolveRunnerTransportPlan(packageDir, loaded); + + assert.equal(plan.mode, 'embedded'); + assert.equal(plan.root, 'COM.TEST.EMBEDDED'); + assert.equal(plan.url, 'nats://127.0.0.1:5111'); + assert.equal(plan.wsUrl, 'ws://127.0.0.1:5112'); + assert.equal(plan.binaryPath, path.join(packageDir, 'tools', 'nats-server-custom')); + assert.equal(plan.dataDir, path.join(packageDir, 'runtime-state', 'nats-data')); + assert.equal(plan.pidFile, path.join(packageDir, 'runtime-state', 'nats.pid')); + }); + + it('uses MATRIX_NATS_SERVER_BINARY before shared repo fallbacks', () => { + const packageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-runner-envbinary-')); + const fakeBinary = path.join(packageDir, 'custom-bin', 'nats-server'); + fs.mkdirSync(path.dirname(fakeBinary), { recursive: true }); + fs.writeFileSync(fakeBinary, '', 'utf8'); + + const original = process.env.MATRIX_NATS_SERVER_BINARY; + process.env.MATRIX_NATS_SERVER_BINARY = fakeBinary; + try { + const resolved = resolveNatsBinary(packageDir); + assert.equal(resolved, fakeBinary); + } finally { + if (original === undefined) { + delete process.env.MATRIX_NATS_SERVER_BINARY; + } else { + process.env.MATRIX_NATS_SERVER_BINARY = original; + } + } + }); + + it('resolves external NATS environments without inventing embedded state', () => { + const packageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-runner-external-')); + const loaded = createLoadedEnvironment(packageDir, 'integration', { + name: 'integration', + runtime: { root: 'COM.TEST.EXTERNAL' }, + nats: { + mode: 'external', + url: 'nats://example.net:4229', + wsUrl: 'wss://example.net/nats-ws', + credentialsRef: 'file:~/.matrix/credentials.json', + }, + }); + + const plan = resolveRunnerTransportPlan(packageDir, loaded, 'COM.TEST.EXTERNAL.OVERRIDE'); + + assert.equal(plan.mode, 'external'); + assert.equal(plan.root, 'COM.TEST.EXTERNAL.OVERRIDE'); + assert.equal(plan.url, 'nats://example.net:4229'); + assert.equal(plan.wsUrl, 'wss://example.net/nats-ws'); + assert.equal(plan.credentialsRef, 'file:~/.matrix/credentials.json'); + assert.equal('binaryPath' in plan, false); + assert.equal('dataDir' in plan, false); + assert.equal('pidFile' in plan, false); + }); +}); diff --git a/archive/mx-cli-runtime-host-tests/service-command.spec.ts b/archive/mx-cli-runtime-host-tests/service-command.spec.ts new file mode 100644 index 0000000..20e0ad6 --- /dev/null +++ b/archive/mx-cli-runtime-host-tests/service-command.spec.ts @@ -0,0 +1,1032 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { resolveNatsBinary } from '../../src/utils/runner-transport.js'; +import { assertExitCode, parseLastJson, runMxCli } from '../helpers/cli-harness.js'; + +async function getAvailablePorts(count: number): Promise { + const servers = await Promise.all( + Array.from({ length: count }, () => new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve(server)); + })), + ); + const ports = servers.map((server) => { + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return address.port; + }); + await Promise.all(servers.map((server) => new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }))); + return ports; +} + +async function createServiceFixture(rootDir: string, options?: { + readonly conflictingTopLevelFields?: boolean; + readonly topLevelSecretsFile?: boolean; + readonly withEnvironment?: boolean; +}): Promise { + const packageDir = path.join(rootDir, 'sample-service'); + const runtimeDir = path.join(packageDir, 'dist', 'runtime'); + fs.mkdirSync(runtimeDir, { recursive: true }); + const conflictingTopLevelFields = options?.conflictingTopLevelFields === true; + const topLevelSecretsFile = options?.topLevelSecretsFile === true; + const withEnvironment = options?.withEnvironment === true; + const runtimeFactory = { + kind: 'factory', + export: 'createSampleService', + ...(conflictingTopLevelFields ? { + mount: 'old.mount', + config: { + file: './config/old-config.json', + }, + dependsOn: ['old.identity'], + policyRef: 'policies/old', + } : {}), + ...(topLevelSecretsFile ? { + secrets: { + file: './config/manifest-secrets.json', + }, + } : {}), + bootstrap: { + root: 'local.root', + transport: { + mode: 'local-memory', + }, + http: { + enabled: false, + }, + overrides: { + config: { + source: 'manifest', + nested: { enabled: false }, + }, + }, + }, + instance: { + packageName: '@acme/sample-service', + packageRevision: '1.0.0', + class: 'service', + rootKind: 'package-root', + mount: 'sample-service.local', + configRef: './config/service-config.json', + secretRefs: [ + { + name: 'apiKey', + provider: 'system.factotum', + ref: 'secret://sample/api-key', + required: true, + }, + ], + dependsOn: ['system.identity'], + policyRef: 'policies/standalone', + autoStart: true, + }, + }; + + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify({ + name: '@acme/sample-service', + version: '1.0.0', + type: 'module', + main: './dist/runtime/index.js', + }, null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + JSON.stringify({ + name: '@acme/sample-service', + version: '1.0.0', + namespace: 'sample', + root: { + type: 'SampleRoot', + export: 'SampleRoot', + mount: 'sample', + }, + runtime: { + language: 'typescript', + entry: './dist/runtime/index.js', + factory: runtimeFactory, + }, + components: [ + { + type: 'SampleService', + export: 'SampleService', + mount: 'worker', + accepts: ['sample.run', 'sample.run'], + autoStart: true, + }, + ], + permissions: { + fsPolicy: 'none', + }, + }, null, 2), + 'utf8', + ); + + fs.mkdirSync(path.join(packageDir, 'config'), { recursive: true }); + fs.writeFileSync( + path.join(packageDir, 'config', 'old-config.json'), + JSON.stringify({ + config: { + old: true, + }, + }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(packageDir, 'config', 'service-config.json'), + JSON.stringify({ + config: { + fromFile: true, + nested: { file: true }, + }, + }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(packageDir, 'config', 'cli-config.json'), + JSON.stringify({ + config: { + fromCliFile: true, + nested: { file: 'cli' }, + }, + }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(packageDir, 'config', 'cli-secrets.json'), + JSON.stringify([ + { + name: 'overrideKey', + provider: 'system.factotum', + ref: 'secret://sample/override-key', + }, + ], null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(packageDir, 'config', 'manifest-secrets.json'), + JSON.stringify([ + { + name: 'manifestKey', + provider: 'system.factotum', + ref: 'secret://sample/manifest-key', + }, + ], null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(runtimeDir, 'index.js'), + `export async function createSampleService(overrides = {}, services = {}, _configResolution, bootstrapContext) { + return { + ok: true, + effectiveConfig: overrides, + bootContext: bootstrapContext, + runnerRuntimeProvided: Boolean(services.runtime), + runnerRuntimeRoot: services.runtime?.transport?.root ?? null, + runtime: { + async shutdown() { + return undefined; + } + } + }; + } + `, + 'utf8', + ); + + if (withEnvironment) { + const [natsPort, wsPort] = await getAvailablePorts(2); + fs.mkdirSync(path.join(packageDir, '.matrix'), { recursive: true }); + fs.writeFileSync( + path.join(packageDir, '.matrix', 'dev.environment.json'), + JSON.stringify({ + name: 'dev', + runtime: { + root: 'COM.TEST.PACKAGE-ENV', + runtimeId: 'sample-service-dev', + }, + package: { + publicRoot: 'sample', + }, + nats: { + mode: 'embedded', + port: natsPort, + wsPort, + binaryPath: resolveNatsBinary(process.cwd()), + }, + }, null, 2), + 'utf8', + ); + } + + return packageDir; +} + +function createPackageRootFixture(rootDir: string): string { + const packageDir = path.join(rootDir, 'package-root-service'); + const runtimeDir = path.join(packageDir, 'dist', 'runtime'); + fs.mkdirSync(runtimeDir, { recursive: true }); + + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify({ + name: '@acme/package-root-service', + version: '2.0.0', + type: 'module', + main: './dist/runtime/index.js', + }, null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + JSON.stringify({ + name: '@acme/package-root-service', + version: '2.0.0', + runtime: { + language: 'typescript', + entry: './dist/runtime/index.js', + factory: { + kind: 'factory', + export: 'createPackageRootService', + bootstrap: { + overrides: { + mode: 'package-root', + }, + }, + instance: { + rootKind: 'package-root', + }, + }, + }, + components: [ + { + type: 'NestedService', + export: 'NestedService', + mount: 'nested-component-mount', + autoStart: true, + }, + ], + permissions: { + fsPolicy: 'none', + }, + }, null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(runtimeDir, 'index.js'), + `export async function createPackageRootService(overrides = {}, _services, _configResolution, bootstrapContext) { + return { + ok: true, + effectiveConfig: overrides, + bootContext: bootstrapContext, + runtime: { + async shutdown() { + return undefined; + } + } + }; + } + `, + 'utf8', + ); + + return packageDir; +} + +function createComponentRootFixture( + rootDir: string, + options: { + name: string; + components: Array<{ type: string; mount?: string }>; + instance?: Record; + }, +): string { + const packageDir = path.join(rootDir, options.name); + const runtimeDir = path.join(packageDir, 'dist', 'runtime'); + fs.mkdirSync(runtimeDir, { recursive: true }); + + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify({ + name: `@acme/${options.name}`, + version: '1.0.0', + type: 'module', + main: './dist/runtime/index.js', + }, null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + JSON.stringify({ + name: `@acme/${options.name}`, + version: '1.0.0', + runtime: { + language: 'typescript', + entry: './dist/runtime/index.js', + factory: { + kind: 'factory', + export: 'createComponentRootService', + instance: { + rootKind: 'component', + ...(options.instance ?? {}), + }, + }, + }, + components: options.components.map((component) => ({ + type: component.type, + export: component.type, + ...(component.mount ? { mount: component.mount } : {}), + autoStart: false, + })), + permissions: { + fsPolicy: 'none', + }, + }, null, 2), + 'utf8', + ); + + fs.writeFileSync( + path.join(runtimeDir, 'index.js'), + `export async function createComponentRootService(overrides = {}, _services, _configResolution, bootstrapContext) { + return { + ok: true, + effectiveConfig: overrides, + bootContext: bootstrapContext, + runtime: { + async shutdown() { + return undefined; + } + } + }; + } + `, + 'utf8', + ); + + return packageDir; +} + +describe('mx service command', () => { + it('marks runtime control ready before registry registration', () => { + const source = fs.readFileSync(path.join(process.cwd(), 'src', 'commands', 'service.ts'), 'utf8'); + const controlIndex = source.indexOf('const mountedControlMount = mounted.controlMount;'); + const readyIndex = source.indexOf("lifecycleState = 'ready';"); + const registerIndex = source.indexOf('const runtimeRegistration = await registerRunnerRuntime('); + const liveIndex = source.indexOf("lifecycleState = 'live';"); + + assert.notEqual(controlIndex, -1); + assert.notEqual(readyIndex, -1); + assert.notEqual(registerIndex, -1); + assert.notEqual(liveIndex, -1); + assert.ok(controlIndex < readyIndex, 'control actor mount result must precede ready state'); + assert.ok(readyIndex < registerIndex, 'runtime.ready must not wait on self-registration'); + assert.ok(registerIndex < liveIndex, 'live state remains after registry registration'); + }); + + it('loads matrix.json runtime.factory and invokes the declared bootstrap export in check mode', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-service-')); + try { + const packageDir = await createServiceFixture(tmpRoot); + const result = await runMxCli([ + 'node', + 'mx', + 'service', + packageDir, + '--check', + '--json', + '--root', + 'cli.root', + '--mount', + 'cli.mount', + '--config', + './config/cli-config.json', + '--secrets', + './config/cli-secrets.json', + '--overrides', + JSON.stringify({ + config: { + nested: { enabled: true }, + cli: 'override', + }, + }), + ]); + + assertExitCode(result, 0); + const payload = parseLastJson(result.logs) as { + packageName: string; + exportName: string; + checked: boolean; + root?: string; + mount?: string; + configRef?: string; + secretsRef?: string; + instance?: { + id?: string; + class?: string; + mount?: string; + rootKind?: string; + configRef?: string; + policyRef?: string; + dependsOn?: string[]; + secretRefs?: Array<{ + name?: string; + provider?: string; + ref?: string; + required?: boolean; + }>; + registrationSource?: string; + }; + serviceInstance?: { + id?: string; + class?: string; + mount?: string; + rootKind?: string; + configRef?: string; + policyRef?: string; + dependsOn?: string[]; + secretRefs?: Array<{ + name?: string; + provider?: string; + ref?: string; + required?: boolean; + }>; + registrationSource?: string; + }; + runtimeRegistration?: { + runtimeId?: string; + runtimeRoot?: string; + registryMount?: string; + created?: boolean; + actorCount?: number; + runtimeCount?: number; + }; + bindingRegistration?: { + runtimeId?: string; + registryMount?: string; + registeredCount?: number; + bindings?: Array<{ + binding?: string; + localMount?: string; + packageName?: string; + ops?: string[]; + }>; + }; + runtimeDeregistration?: { + runtimeId?: string; + registryMount?: string; + removed?: boolean; + runtimeCount?: number; + }; + bindingDeregistration?: { + runtimeId?: string; + registryMount?: string; + removedCount?: number; + }; + result?: { + effectiveConfig?: { + config?: { + source?: string; + cli?: string; + nested?: { enabled?: boolean }; + fromCliFile?: boolean; + }; + }; + runnerRuntimeProvided?: boolean; + runnerRuntimeRoot?: string | null; + bootContext?: { + root?: string; + mount?: string; + configRef?: string; + secretsRef?: string; + transport?: { mode?: string }; + http?: { enabled?: boolean }; + instance?: { + mount?: string; + configRef?: string; + dependsOn?: string[]; + policyRef?: string; + secretRefs?: Array<{ + name?: string; + provider?: string; + ref?: string; + }>; + registrationSource?: string; + }; + serviceInstance?: { + mount?: string; + configRef?: string; + dependsOn?: string[]; + policyRef?: string; + secretRefs?: Array<{ + name?: string; + provider?: string; + ref?: string; + }>; + registrationSource?: string; + }; + }; + }; + }; + + assert.equal(payload.packageName, '@acme/sample-service'); + assert.equal(payload.exportName, 'createSampleService'); + assert.equal(payload.checked, true); + assert.equal(payload.root, 'cli.root'); + assert.equal(payload.mount, 'cli.mount'); + assert.equal(payload.configRef, './config/cli-config.json'); + assert.equal(payload.secretsRef, './config/cli-secrets.json'); + assert.equal(payload.result?.effectiveConfig?.config?.source, 'manifest'); + assert.equal(payload.result?.effectiveConfig?.config?.fromCliFile, true); + assert.equal(payload.result?.effectiveConfig?.config?.cli, 'override'); + assert.equal(payload.result?.effectiveConfig?.config?.nested?.enabled, true); + assert.equal(payload.result?.runnerRuntimeProvided, true); + assert.equal(payload.result?.runnerRuntimeRoot, 'cli.root'); + assert.equal(payload.runtimeRegistration?.runtimeId, '@acme/sample-service:sample-service.local'); + assert.equal(payload.runtimeRegistration?.runtimeRoot, 'cli.root'); + assert.equal(payload.runtimeRegistration?.registryMount, 'system.runtimes'); + assert.equal(payload.runtimeRegistration?.created, true); + // Runtime control now has one RuntimeControlActor at + // system.runtimes.. LoadedPackageActor adds one mount while + // the separate .control mount is absent, so actorCount stays at 5. + assert.equal(payload.runtimeRegistration?.actorCount, 5); + assert.equal(payload.runtimeRegistration?.runtimeCount, 1); + assert.equal(payload.bindingRegistration?.runtimeId, '@acme/sample-service:sample-service.local'); + assert.equal(payload.bindingRegistration?.registryMount, 'system.bindings'); + assert.equal(payload.bindingRegistration?.registeredCount, 2); + assert.deepEqual(payload.bindingRegistration?.bindings, [ + { + binding: 'sample', + localMount: 'cli.mount', + packageName: '@acme/sample-service', + ops: [], + }, + { + binding: 'sample.worker', + localMount: 'cli.mount.worker', + packageName: '@acme/sample-service', + ops: ['sample.run'], + }, + ]); + assert.equal(payload.runtimeDeregistration?.runtimeId, '@acme/sample-service:sample-service.local'); + assert.equal(payload.runtimeDeregistration?.registryMount, 'system.runtimes'); + assert.equal(payload.runtimeDeregistration?.removed, true); + assert.equal(payload.runtimeDeregistration?.runtimeCount, 0); + assert.equal(payload.bindingDeregistration?.runtimeId, '@acme/sample-service:sample-service.local'); + assert.equal(payload.bindingDeregistration?.registryMount, 'system.bindings'); + assert.equal(payload.bindingDeregistration?.removedCount, 2); + assert.equal(payload.instance?.autoStart, true); + assert.equal(payload.instance?.mount, 'cli.mount'); + assert.equal(payload.instance?.configRef, './config/cli-config.json'); + assert.equal(payload.instance?.rootKind, 'package-root'); + assert.equal(payload.instance?.policyRef, 'policies/standalone'); + assert.deepEqual(payload.instance?.dependsOn, ['system.identity']); + assert.equal(payload.instance?.registrationSource, 'matrix-service'); + assert.equal(payload.instance?.secretRefs?.[0]?.name, 'overrideKey'); + assert.equal(payload.instance?.secretRefs?.[0]?.provider, 'system.factotum'); + assert.equal(payload.instance?.secretRefs?.[0]?.ref, 'secret://sample/override-key'); + assert.equal(payload.serviceInstance?.mount, 'cli.mount'); + assert.equal(payload.serviceInstance?.configRef, './config/cli-config.json'); + assert.equal(payload.serviceInstance?.rootKind, 'package-root'); + assert.equal(payload.serviceInstance?.autoStart, true); + assert.equal(payload.serviceInstance?.policyRef, 'policies/standalone'); + assert.deepEqual(payload.serviceInstance?.dependsOn, ['system.identity']); + assert.equal(payload.serviceInstance?.registrationSource, 'matrix-service'); + assert.equal(payload.serviceInstance?.secretRefs?.[0]?.name, 'overrideKey'); + assert.equal(payload.serviceInstance?.secretRefs?.[0]?.provider, 'system.factotum'); + assert.equal(payload.serviceInstance?.secretRefs?.[0]?.ref, 'secret://sample/override-key'); + assert.equal(payload.result?.bootContext?.root, 'cli.root'); + assert.equal(payload.result?.bootContext?.mount, 'cli.mount'); + assert.equal(payload.result?.bootContext?.configRef, './config/cli-config.json'); + assert.equal(payload.result?.bootContext?.secretsRef, './config/cli-secrets.json'); + assert.equal(payload.result?.bootContext?.transport?.mode, 'local-memory'); + assert.equal(payload.result?.bootContext?.http?.enabled, false); + assert.equal(payload.result?.bootContext?.instance?.mount, 'cli.mount'); + assert.equal(payload.result?.bootContext?.instance?.configRef, './config/cli-config.json'); + assert.equal(payload.result?.bootContext?.instance?.autoStart, true); + assert.deepEqual(payload.result?.bootContext?.instance?.dependsOn, ['system.identity']); + assert.equal(payload.result?.bootContext?.instance?.policyRef, 'policies/standalone'); + assert.equal(payload.result?.bootContext?.instance?.registrationSource, 'matrix-service'); + assert.equal(payload.result?.bootContext?.instance?.secretRefs?.[0]?.name, 'overrideKey'); + assert.equal(payload.result?.bootContext?.serviceInstance?.mount, 'cli.mount'); + assert.equal(payload.result?.bootContext?.serviceInstance?.configRef, './config/cli-config.json'); + assert.equal(payload.result?.bootContext?.serviceInstance?.autoStart, true); + assert.deepEqual(payload.result?.bootContext?.serviceInstance?.dependsOn, ['system.identity']); + assert.equal(payload.result?.bootContext?.serviceInstance?.policyRef, 'policies/standalone'); + assert.equal(payload.result?.bootContext?.serviceInstance?.registrationSource, 'matrix-service'); + assert.equal(payload.result?.bootContext?.serviceInstance?.secretRefs?.[0]?.name, 'overrideKey'); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('loads package-local environment manifests and threads them into standalone bootstrap', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-service-env-')); + const packageDir = await createServiceFixture(tempRoot, { withEnvironment: true }); + + const result = await runMxCli(['node', 'mx', 'service', packageDir, '--check', '--json', '--env', 'dev']); + assertExitCode(result, 0); + const payload = parseLastJson(result.logs) as { + environmentName?: string; + root?: string; + environmentPath?: string; + runnerTransport?: { + mode?: string; + root?: string; + url?: string; + wsUrl?: string; + dataDir?: string; + pidFile?: string; + }; + runtimeRegistration?: { + runtimeId?: string; + runtimeRoot?: string; + runtimeCount?: number; + }; + bindingRegistration?: { + runtimeId?: string; + registryMount?: string; + registeredCount?: number; + bindings?: Array<{ + binding?: string; + localMount?: string; + packageName?: string; + ops?: string[]; + }>; + }; + runtimeDeregistration?: { + runtimeId?: string; + removed?: boolean; + runtimeCount?: number; + }; + bindingDeregistration?: { + runtimeId?: string; + removedCount?: number; + }; + result?: { + bootContext?: { + environment?: { + name?: string; + runtime?: { root?: string }; + }; + runnerTransport?: { + mode?: string; + url?: string; + wsUrl?: string; + }; + }; + }; + }; + + assert.equal(payload.environmentName, 'dev'); + assert.equal(payload.root, 'COM.TEST.PACKAGE-ENV'); + assert.equal( + payload.environmentPath, + path.join(packageDir, '.matrix', 'dev.environment.json'), + ); + assert.equal(payload.runnerTransport?.mode, 'embedded'); + assert.equal(payload.runnerTransport?.root, 'COM.TEST.PACKAGE-ENV'); + assert.match(String(payload.runnerTransport?.url), /^nats:\/\/127\.0\.0\.1:\d+$/); + assert.match(String(payload.runnerTransport?.wsUrl), /^ws:\/\/127\.0\.0\.1:\d+$/); + assert.equal(payload.runnerTransport?.dataDir, path.join(packageDir, '.matrix', 'state', 'dev', 'nats')); + assert.equal(payload.runnerTransport?.pidFile, path.join(packageDir, '.matrix', 'state', 'dev', 'nats-server.pid')); + assert.equal(payload.result?.bootContext?.environment?.name, 'dev'); + assert.equal(payload.result?.bootContext?.environment?.runtime?.root, 'COM.TEST.PACKAGE-ENV'); + assert.equal(payload.result?.bootContext?.runnerTransport?.mode, 'embedded'); + assert.match(String(payload.result?.bootContext?.runnerTransport?.url), /^nats:\/\/127\.0\.0\.1:\d+$/); + assert.match(String(payload.result?.bootContext?.runnerTransport?.wsUrl), /^ws:\/\/127\.0\.0\.1:\d+$/); + assert.equal(payload.runtimeRegistration?.runtimeId, 'sample-service-dev'); + assert.equal(payload.runtimeRegistration?.runtimeRoot, 'COM.TEST.PACKAGE-ENV'); + assert.equal(payload.runtimeRegistration?.runtimeCount, 1); + assert.equal(payload.bindingRegistration?.runtimeId, 'sample-service-dev'); + assert.equal(payload.bindingRegistration?.registryMount, 'system.bindings'); + assert.equal(payload.bindingRegistration?.registeredCount, 2); + assert.deepEqual(payload.bindingRegistration?.bindings, [ + { + binding: 'sample', + localMount: 'sample-service.local', + packageName: '@acme/sample-service', + ops: [], + }, + { + binding: 'sample.worker', + localMount: 'sample-service.local.worker', + packageName: '@acme/sample-service', + ops: ['sample.run'], + }, + ]); + assert.equal(payload.runtimeDeregistration?.runtimeId, 'sample-service-dev'); + assert.equal(payload.runtimeDeregistration?.removed, true); + assert.equal(payload.runtimeDeregistration?.runtimeCount, 0); + assert.equal(payload.bindingDeregistration?.runtimeId, 'sample-service-dev'); + assert.equal(payload.bindingDeregistration?.removedCount, 2); + }); + + it('fails clearly when standalone top-level fields are present', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-service-instance-conflict-')); + try { + const packageDir = await createServiceFixture(tmpRoot, { conflictingTopLevelFields: true }); + const result = await runMxCli([ + 'node', + 'mx', + 'service', + packageDir, + '--check', + '--json', + ]); + + assertExitCode(result, 1); + assert.match(result.errors.join('\n'), /top-level "mount" is no longer supported; use "instance\.mount"/i); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('fails clearly when matrix.json runtime.factory uses top-level secrets.file', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-service-secrets-field-')); + try { + const packageDir = await createServiceFixture(tmpRoot, { topLevelSecretsFile: true }); + const result = await runMxCli([ + 'node', + 'mx', + 'service', + packageDir, + '--check', + '--json', + ]); + + assertExitCode(result, 1); + assert.match(result.errors.join('\n'), /top-level "secrets\.file" is no longer supported; use "instance\.secretRefs or CLI --secrets"/i); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('fails clearly when matrix.json runtime.factory uses top-level standalone bootstrap fields', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-service-bootstrap-field-')); + try { + const packageDir = await createServiceFixture(tmpRoot); + const manifestPath = path.join(packageDir, 'matrix.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { + runtime?: { factory?: Record }; + }; + fs.writeFileSync( + manifestPath, + JSON.stringify({ + ...manifest, + runtime: { + ...(manifest.runtime ?? {}), + factory: { + ...(manifest.runtime?.factory ?? {}), + root: 'old.root', + }, + }, + }, null, 2), + 'utf8', + ); + + const result = await runMxCli([ + 'node', + 'mx', + 'service', + packageDir, + '--check', + '--json', + ]); + + assertExitCode(result, 1); + assert.match(result.errors.join('\n'), /top-level "root" is no longer supported; use "bootstrap\.root"/i); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('fails clearly when matrix.json runtime.factory is missing', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-service-missing-')); + try { + const packageDir = path.join(tmpRoot, 'broken-service'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + JSON.stringify({ + name: '@acme/broken-service', + version: '1.0.0', + runtime: { language: 'typescript', entry: './dist/runtime/index.js' }, + components: [], + permissions: { fsPolicy: 'none' }, + }, null, 2), + 'utf8', + ); + + const result = await runMxCli([ + 'node', + 'mx', + 'service', + packageDir, + '--check', + ]); + + assertExitCode(result, 1); + assert.match(result.errors.join('\n'), /matrix\.json runtime\.factory must be a JSON object/i); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('fails clearly when matrix.json runtime.factory instance identity disagrees with matrix.json', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-service-package-identity-mismatch-')); + try { + const packageDir = await createServiceFixture(tmpRoot); + const manifestPath = path.join(packageDir, 'matrix.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { + runtime?: { factory?: { instance?: Record } }; + }; + fs.writeFileSync( + manifestPath, + JSON.stringify({ + ...manifest, + runtime: { + ...(manifest.runtime ?? {}), + factory: { + ...(manifest.runtime?.factory ?? {}), + instance: { + ...(manifest.runtime?.factory?.instance ?? {}), + packageName: '@acme/not-the-same-service', + }, + }, + }, + }, null, 2), + 'utf8', + ); + + const result = await runMxCli([ + 'node', + 'mx', + 'service', + packageDir, + '--check', + '--json', + ]); + + assertExitCode(result, 1); + assert.match( + result.errors.join('\n'), + /instance\.packageName "@acme\/not-the-same-service" does not match matrix\.json name "@acme\/sample-service"/, + ); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('does not invent a component mount for package-root standalone services', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-service-package-root-')); + try { + const packageDir = createPackageRootFixture(tmpRoot); + const result = await runMxCli([ + 'node', + 'mx', + 'service', + packageDir, + '--check', + '--json', + ]); + + assertExitCode(result, 0); + const payload = parseLastJson(result.logs) as { + mount?: string; + instance?: { + id?: string; + mount?: string; + rootKind?: string; + }; + serviceInstance?: { + id?: string; + mount?: string; + rootKind?: string; + }; + result?: { + bootContext?: { + mount?: string; + instance?: { + mount?: string; + rootKind?: string; + }; + serviceInstance?: { + mount?: string; + rootKind?: string; + }; + }; + }; + }; + + assert.equal(payload.mount, undefined); + assert.equal(payload.instance?.id, '@acme/package-root-service'); + assert.equal(payload.instance?.mount, undefined); + assert.equal(payload.instance?.rootKind, 'package-root'); + assert.equal(payload.serviceInstance?.id, '@acme/package-root-service'); + assert.equal(payload.serviceInstance?.mount, undefined); + assert.equal(payload.serviceInstance?.rootKind, 'package-root'); + assert.equal(payload.result?.bootContext?.mount, undefined); + assert.equal(payload.result?.bootContext?.instance?.mount, undefined); + assert.equal(payload.result?.bootContext?.instance?.rootKind, 'package-root'); + assert.equal(payload.result?.bootContext?.serviceInstance?.mount, undefined); + assert.equal(payload.result?.bootContext?.serviceInstance?.rootKind, 'package-root'); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('resolves component-root standalone services by declared componentType', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-service-component-root-')); + try { + const packageDir = createComponentRootFixture(tmpRoot, { + name: 'component-root-service', + components: [ + { type: 'PrimaryService', mount: 'primary.mount' }, + { type: 'SecondaryService', mount: 'secondary.mount' }, + ], + instance: { + componentType: 'SecondaryService', + }, + }); + const result = await runMxCli([ + 'node', + 'mx', + 'service', + packageDir, + '--check', + '--json', + ]); + + assertExitCode(result, 0); + const payload = parseLastJson(result.logs) as { + mount?: string; + instance?: { + componentType?: string; + mount?: string; + rootKind?: string; + }; + result?: { + bootContext?: { + mount?: string; + instance?: { + componentType?: string; + mount?: string; + rootKind?: string; + }; + }; + }; + }; + + assert.equal(payload.mount, 'secondary.mount'); + assert.equal(payload.instance?.componentType, 'SecondaryService'); + assert.equal(payload.instance?.mount, 'secondary.mount'); + assert.equal(payload.instance?.rootKind, 'component'); + assert.equal(payload.result?.bootContext?.mount, 'secondary.mount'); + assert.equal(payload.result?.bootContext?.instance?.componentType, 'SecondaryService'); + assert.equal(payload.result?.bootContext?.instance?.mount, 'secondary.mount'); + assert.equal(payload.result?.bootContext?.instance?.rootKind, 'component'); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('fails clearly when component-root standalone bootstrap is ambiguous', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-service-component-ambiguous-')); + try { + const packageDir = createComponentRootFixture(tmpRoot, { + name: 'ambiguous-component-root-service', + components: [ + { type: 'PrimaryService', mount: 'primary.mount' }, + { type: 'SecondaryService', mount: 'secondary.mount' }, + ], + }); + const result = await runMxCli([ + 'node', + 'mx', + 'service', + packageDir, + '--check', + ]); + + assertExitCode(result, 1); + assert.match(result.errors.join('\n'), /component-root bootstrap is ambiguous/i); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/archive/mx-cli-runtime-host-tests/standalone-webapp-server.spec.ts b/archive/mx-cli-runtime-host-tests/standalone-webapp-server.spec.ts new file mode 100644 index 0000000..a1be1d2 --- /dev/null +++ b/archive/mx-cli-runtime-host-tests/standalone-webapp-server.spec.ts @@ -0,0 +1,244 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { ILoadedMatrixPackageEnvironment } from '../../src/utils/package-environment.js'; +import type { IResolvedRunnerTransportPlan } from '../../src/utils/runner-transport.js'; +import type { ILoadedMatrixWebappManifest } from '../../src/utils/webapp-manifest.js'; +import { startStandaloneWebappServer } from '../../src/utils/standalone-webapp-server.js'; + +async function getAvailablePort(): Promise { + const server = await new Promise((resolve, reject) => { + const candidate = net.createServer(); + candidate.once('error', reject); + candidate.listen(0, '127.0.0.1', () => resolve(candidate)); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const port = address.port; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +describe('standalone webapp server', () => { + it('advertises JWT browser auth when the runner transport uses credentialsRef', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'standalone-webapp-server-')); + const packageDir = path.join(tempRoot, 'package'); + const distDir = path.join(packageDir, 'dist'); + fs.mkdirSync(distDir, { recursive: true }); + const httpPort = await getAvailablePort(); + const credentialsPath = path.join(tempRoot, 'credentials.json'); + fs.writeFileSync(credentialsPath, JSON.stringify({ + jwt: 'sample.jwt.value', + seed: 'SUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }), 'utf8'); + fs.writeFileSync( + path.join(distDir, 'index.html'), + 'fixture', + 'utf8', + ); + + const loadedEnvironment: ILoadedMatrixPackageEnvironment = { + packageDir, + envName: 'host', + environmentPath: path.join(packageDir, '.matrix', 'host.environment.json'), + environment: { + name: 'host', + runtime: { root: 'COM.TEST.EXTERNAL' }, + nats: { + mode: 'external', + url: 'nats://example.test:4222', + wsUrl: 'wss://example.test/nats-ws', + credentialsRef: `file:${credentialsPath}`, + }, + http: { + enabled: true, + port: httpPort, + }, + }, + }; + const webapp: ILoadedMatrixWebappManifest = { + packageDir, + packageName: '@acme/webapp', + manifestPath: path.join(packageDir, 'matrix.json'), + distDir, + entryFile: 'index.html', + appName: 'webapp', + }; + const runnerTransport: IResolvedRunnerTransportPlan = { + envName: 'host', + root: 'COM.TEST.EXTERNAL', + mode: 'external', + url: 'nats://example.test:4222', + wsUrl: 'wss://example.test/nats-ws', + credentialsRef: `file:${credentialsPath}`, + }; + + const server = await startStandaloneWebappServer({ + loadedEnvironment, + webapp, + runnerTransport, + }); + try { + const bootstrap = await fetch(`${server.baseUrl}/api/bootstrap`); + assert.equal(bootstrap.status, 200); + const bootstrapPayload = await bootstrap.json() as { + authorityContext?: { + assetHostRoot?: string; + platformServiceRoot?: string; + sessionAuthorityRoot?: string | null; + selectedAuthorityRoot?: string | null; + runtimeInstanceRoot?: string | null; + isAuthenticated?: boolean; + isPlatformAdmin?: boolean; + selectableAuthorityRoots?: string[]; + }; + authorityRoot?: string; + busRoot?: string; + runtimeInstanceRoot?: string | null; + transportPlan?: { + authMode?: string; + authEndpoint?: string | null; + wsPath?: string; + }; + sources?: { + transportAuthMode?: string; + }; + }; + assert.deepEqual(bootstrapPayload.authorityContext, { + assetHostRoot: 'COM.TEST.EXTERNAL', + platformServiceRoot: 'COM.TEST.EXTERNAL', + sessionAuthorityRoot: 'COM.TEST.EXTERNAL', + selectedAuthorityRoot: 'COM.TEST.EXTERNAL', + runtimeInstanceRoot: null, + isAuthenticated: true, + isPlatformAdmin: false, + selectableAuthorityRoots: ['COM.TEST.EXTERNAL'], + }); + assert.equal(bootstrapPayload.authorityRoot, 'COM.TEST.EXTERNAL'); + assert.equal(bootstrapPayload.busRoot, 'COM.TEST.EXTERNAL'); + assert.equal(bootstrapPayload.runtimeInstanceRoot, null); + assert.equal(bootstrapPayload.transportPlan?.wsPath, '/nats-ws'); + assert.equal(bootstrapPayload.transportPlan?.authMode, 'jwt'); + assert.equal(bootstrapPayload.transportPlan?.authEndpoint, '/api/nats-jwt'); + assert.equal(bootstrapPayload.sources?.transportAuthMode, 'credentials-ref'); + + const auth = await fetch(`${server.baseUrl}/api/nats-jwt`, { method: 'POST' }); + assert.equal(auth.status, 200); + const authPayload = await auth.json() as { + mode?: string; + jwt?: string; + seed?: string; + }; + assert.equal(authPayload.mode, 'jwt'); + assert.equal(authPayload.jwt, 'sample.jwt.value'); + assert.equal(authPayload.seed, 'SUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + } finally { + await server.shutdown(); + } + }); + + it('forwards explicit upstream WebSocket paths without duplicating /nats-ws', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'standalone-webapp-server-proxy-')); + const packageDir = path.join(tempRoot, 'package'); + const distDir = path.join(packageDir, 'dist'); + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync( + path.join(distDir, 'index.html'), + 'fixture', + 'utf8', + ); + const httpPort = await getAvailablePort(); + const upstreamPort = await getAvailablePort(); + let capturedRequest = ''; + const upstream = net.createServer(); + const captured = new Promise((resolve) => { + upstream.on('connection', (socket) => { + socket.once('data', (chunk: Buffer) => { + capturedRequest = chunk.toString('utf8'); + socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); + resolve(); + }); + }); + }); + await new Promise((resolve, reject) => { + upstream.once('error', reject); + upstream.listen(upstreamPort, '127.0.0.1', () => { + upstream.off('error', reject); + resolve(); + }); + }); + + const loadedEnvironment: ILoadedMatrixPackageEnvironment = { + packageDir, + envName: 'host', + environmentPath: path.join(packageDir, '.matrix', 'host.environment.json'), + environment: { + name: 'host', + runtime: { root: 'COM.TEST.EXTERNAL' }, + nats: { + mode: 'external', + url: `nats://127.0.0.1:${upstreamPort}`, + wsUrl: `ws://127.0.0.1:${upstreamPort}/nats-ws`, + }, + http: { + enabled: true, + port: httpPort, + }, + }, + }; + const webapp: ILoadedMatrixWebappManifest = { + packageDir, + packageName: '@acme/webapp', + manifestPath: path.join(packageDir, 'matrix.json'), + distDir, + entryFile: 'index.html', + appName: 'webapp', + }; + const runnerTransport: IResolvedRunnerTransportPlan = { + envName: 'host', + root: 'COM.TEST.EXTERNAL', + mode: 'external', + url: `nats://127.0.0.1:${upstreamPort}`, + wsUrl: `ws://127.0.0.1:${upstreamPort}/nats-ws`, + }; + + const server = await startStandaloneWebappServer({ + loadedEnvironment, + webapp, + runnerTransport, + }); + const client = net.connect(httpPort, '127.0.0.1'); + try { + await new Promise((resolve) => { + client.once('connect', resolve); + }); + client.write([ + 'GET /nats-ws?probe=1 HTTP/1.1', + `Host: 127.0.0.1:${httpPort}`, + 'Connection: Upgrade', + 'Upgrade: websocket', + 'Origin: http://127.0.0.1:9999', + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version: 13', + '', + '', + ].join('\r\n')); + await captured; + assert.match(capturedRequest, /^GET \/nats-ws\?probe=1 HTTP\/1\.1\r\n/); + assert.doesNotMatch(capturedRequest, /\/nats-ws\/nats-ws/); + assert.doesNotMatch(capturedRequest, /\r\nOrigin:/i); + } finally { + client.destroy(); + await server.shutdown(); + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + } + }); +}); diff --git a/archive/mx-cli-unused-after-sdk-split/hivecast-link-store.ts b/archive/mx-cli-unused-after-sdk-split/hivecast-link-store.ts new file mode 100644 index 0000000..0aae141 --- /dev/null +++ b/archive/mx-cli-unused-after-sdk-split/hivecast-link-store.ts @@ -0,0 +1,992 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { randomBytes } from 'node:crypto'; + +type JsonRecord = Record; + +export interface IHiveCastNatsConfig { + readonly url: string; + readonly wsUrl?: string; + readonly leafnodeUrl?: string; +} + +export interface IHiveCastNatsCredentials { + readonly accountSeed?: string; + readonly jwt?: string; + readonly seed?: string; +} + +export interface IHiveCastHostLinkRecord { + readonly version: 1; + readonly cloudUrl: string; + readonly deviceRegistryRoot?: string; + readonly hostLinkId?: string; + readonly hostId?: string; + readonly principalId?: string; + readonly hostName?: string; + readonly deviceSlug?: string; + readonly authorityCoordinator?: boolean; + readonly spaceId?: string; + readonly routeKey?: string; + readonly publicNamespace?: string; + readonly authorityRoot: string; + readonly linkedAt: string; + readonly credentials: { + readonly hostLinkTokenRef?: string; + readonly registryTokenRef?: string; + readonly natsCredentialRef: string; + readonly natsLeafnodeCredentialRef?: string; + }; +} + +interface ILocalOwnerTransportSnapshot { + readonly root: string; + readonly authorityRoot?: string; + readonly nats: JsonRecord; +} + +export interface IHiveCastLocalOwnerTransport { + readonly root: string; + readonly authorityRoot?: string; +} + +interface ILinkedAuthorityTransportSnapshot { + readonly authorityRoot: string; + readonly nats: JsonRecord; + readonly leafnode?: { + readonly url: string; + readonly credentialsRef: string; + }; + readonly authorityCoordinator?: boolean; + readonly routeKey?: string; + readonly publicNamespace?: string; + readonly spaceId?: string; +} + +export interface IHiveCastLinkPaths { + readonly matrixHome: string; + readonly credentialsDir: string; + readonly installIdentityPath: string; + readonly linkPath: string; + readonly natsCredentialsPath: string; + readonly natsLeafnodeCredentialsPath: string; + readonly hostLinkTokenPath: string; + readonly hostCredentialsPath: string; + readonly hostConfigPath: string; +} + +export interface IHiveCastInstallIdentity { + readonly version: 1; + readonly installId: string; + readonly hostName?: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface ISaveHiveCastHostLinkOptions { + readonly cwd: string; + readonly matrixHome?: string; + readonly cloudUrl: string; + readonly deviceRegistryRoot?: string; + readonly authorityRoot: string; + readonly nats: IHiveCastNatsConfig; + readonly natsCredentials: IHiveCastNatsCredentials; + readonly hostLinkToken?: string; + readonly hostLinkId?: string; + readonly hostId?: string; + readonly principalId?: string; + readonly hostName?: string; + readonly deviceSlug?: string; + readonly authorityCoordinator?: boolean; + readonly spaceId?: string; + readonly routeKey?: string; + readonly publicNamespace?: string; + readonly linkedAt?: string; +} + +export interface IHiveCastLinkStatus { + readonly linked: boolean; + readonly matrixHome: string; + readonly linkPath: string; + readonly natsCredentialsPath: string; + readonly hostConfigPath: string; + readonly natsCredentialsPresent: boolean; + readonly hostConfigPresent: boolean; + readonly hostConfigLinked: boolean; + readonly hostLinkId?: string; + readonly authorityRoot?: string; + readonly routeKey?: string; + readonly publicNamespace?: string; + readonly spaceId?: string; + readonly authorityCoordinator?: boolean; + readonly cloudUrl?: string; + readonly linkedAt?: string; +} + +export interface IRemoveHiveCastHostLinkResult { + readonly removed: boolean; + readonly hostConfigReset: boolean; + readonly paths: IHiveCastLinkPaths; +} + +export interface IRemoveHiveCastHostLinkOptions { + readonly preserveCredentials?: boolean; +} + +const DEFAULT_MATRIX_HOME = path.join(os.homedir(), '.matrix'); +const HIVECAST_NATS_JSON_CREDENTIAL_REF = 'file:credentials/hivecast-nats.json'; +const HIVECAST_NATS_LEAFNODE_CREDENTIAL_REF = 'file:credentials/hivecast-nats.creds'; +const HIVECAST_DEFAULT_RUNTIME_KEYS = [ + 'SYSTEM', + 'GATEWAY', + 'INFERENCE', + 'AGENTS', + 'WEB', + 'EDGE', + 'DIRECTOR', + 'CHAT', + 'INFERENCE-SETTINGS', + 'FLOWPAD', + 'SMITHERS', + 'HOST-CONTROL', + 'HOST-OPS', +] as const; + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function expandHome(value: string): string { + if (value === '~') { + return os.homedir(); + } + return value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value; +} + +export function resolveHiveCastMatrixHome(cwd: string, matrixHome?: string): string { + const raw = matrixHome + ?? process.env.MATRIX_HOST_HOME + ?? process.env.MATRIX_HOME + ?? DEFAULT_MATRIX_HOME; + const expanded = expandHome(raw); + return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(cwd, expanded); +} + +export function hiveCastLinkPaths(cwd: string, matrixHome?: string): IHiveCastLinkPaths { + const resolvedHome = resolveHiveCastMatrixHome(cwd, matrixHome); + const credentialsDir = path.join(resolvedHome, 'credentials'); + return { + matrixHome: resolvedHome, + credentialsDir, + installIdentityPath: path.join(credentialsDir, 'hivecast-install.json'), + linkPath: path.join(credentialsDir, 'hivecast-link.json'), + natsCredentialsPath: path.join(credentialsDir, 'hivecast-nats.json'), + natsLeafnodeCredentialsPath: path.join(credentialsDir, 'hivecast-nats.creds'), + hostLinkTokenPath: path.join(credentialsDir, 'hivecast-host-link-token'), + hostCredentialsPath: path.join(resolvedHome, 'credentials.json'), + hostConfigPath: path.join(resolvedHome, 'host.json'), + }; +} + +export function ensureHiveCastInstallIdentity(options: { + readonly cwd: string; + readonly matrixHome?: string; + readonly hostName?: string; +}): { readonly identity: IHiveCastInstallIdentity; readonly paths: IHiveCastLinkPaths } { + const paths = hiveCastLinkPaths(options.cwd, options.matrixHome); + const existing = readJsonFile(paths.installIdentityPath); + const existingInstallId = optionalString(existing?.installId); + const now = new Date().toISOString(); + const hostName = optionalString(options.hostName); + if (existingInstallId) { + const existingHostName = optionalString(existing?.hostName); + const nextHostName = hostName ?? existingHostName; + const identity: IHiveCastInstallIdentity = { + version: 1, + installId: existingInstallId, + ...(nextHostName ? { hostName: nextHostName } : {}), + createdAt: optionalString(existing?.createdAt) ?? now, + updatedAt: hostName && hostName !== existingHostName ? now : (optionalString(existing?.updatedAt) ?? now), + }; + if (hostName && hostName !== existingHostName) { + writePrivateJson(paths.installIdentityPath, identity); + } + return { identity, paths }; + } + const identity: IHiveCastInstallIdentity = { + version: 1, + installId: `install_${randomBytes(10).toString('hex')}`, + ...(hostName ? { hostName } : {}), + createdAt: now, + updatedAt: now, + }; + writePrivateJson(paths.installIdentityPath, identity); + return { identity, paths }; +} + +function localOwnerRootToken(installId: string): string { + const token = installId + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!token) { + throw new Error('HIVECAST_INSTALL_IDENTITY_INVALID: installId cannot produce a local owner root'); + } + return token; +} + +function localOwnerAuthorityRoot(matrixHome: string): string { + const { identity } = ensureHiveCastInstallIdentity({ cwd: process.cwd(), matrixHome }); + return `local-${localOwnerRootToken(identity.installId)}`; +} + +function runtimeScopeToken(installId: string): string { + const token = installId + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!token) { + throw new Error('HIVECAST_INSTALL_IDENTITY_INVALID: installId cannot produce a runtime scope'); + } + return token; +} + +function installRuntimeScope(matrixHome: string): string { + const { identity } = ensureHiveCastInstallIdentity({ cwd: process.cwd(), matrixHome }); + return runtimeScopeToken(identity.installId); +} + +function writePrivateJson(filePath: string, payload: object): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + try { + fs.chmodSync(filePath, 0o600); + } catch { + } +} + +function writePrivateText(filePath: string, payload: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, payload, 'utf8'); + try { + fs.chmodSync(filePath, 0o600); + } catch { + } +} + +function natsUserCredentialsFile(credentials: IHiveCastNatsCredentials): string | undefined { + const jwt = optionalString(credentials.jwt); + const seed = optionalString(credentials.seed); + if (!jwt || !seed) { + return undefined; + } + return [ + '-----BEGIN NATS USER JWT-----', + jwt, + '------END NATS USER JWT------', + '', + '-----BEGIN USER NKEY SEED-----', + seed, + '------END USER NKEY SEED------', + '', + ].join('\n'); +} + +function ownerForPath(filePath: string): { readonly uid: number; readonly gid: number } { + const stat = fs.statSync(filePath); + return { uid: stat.uid, gid: stat.gid }; +} + +function alignOwnership(filePath: string, owner: { readonly uid: number; readonly gid: number }): void { + if (typeof process.getuid !== 'function' || process.getuid() !== 0) { + return; + } + fs.chownSync(filePath, owner.uid, owner.gid); +} + +function readJsonFile(filePath: string): JsonRecord | null { + if (!fs.existsSync(filePath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + return asRecord(parsed); + } catch { + return null; + } +} + +function buildHostConfig( + existing: JsonRecord | null, + matrixHome: string, + localOwnerTransportOverride?: ILocalOwnerTransportSnapshot, + linkedAuthorityTransport?: ILinkedAuthorityTransportSnapshot, +): JsonRecord { + const currentHttp = asRecord(existing?.http); + const currentRuntimeStorage = asRecord(existing?.runtimeStorage); + const currentPackageStorage = asRecord(existing?.packageStorage); + const currentAuth = asRecord(existing?.auth); + const localOwnerTransport = readLocalOwnerTransportSnapshotForHome(matrixHome, existing) + ?? localOwnerTransportOverride + ?? captureLocalOwnerTransport(matrixHome, existing); + const generatedLocalOwnerRoot = localOwnerAuthorityRoot(matrixHome); + const generatedLocalOwnerTransport = { + root: generatedLocalOwnerRoot, + authorityRoot: generatedLocalOwnerRoot, + addressRoot: generatedLocalOwnerRoot, + nats: defaultLocalOwnerNatsTransport(), + }; + const localOwnerHostTransport = localOwnerTransport + ? localOwnerHostConfigTransport(localOwnerTransport) + : generatedLocalOwnerTransport; + const hostTransport = linkedAuthorityTransport + ? linkedAuthorityTransport.authorityCoordinator === false + ? linkedLeafHostConfigTransport(linkedAuthorityTransport, localOwnerHostTransport) + : linkedAuthorityHostConfigTransport(linkedAuthorityTransport) + : localOwnerHostTransport; + + const nextConfig: JsonRecord = { + ...(existing ?? {}), + kind: optionalString(existing?.kind) ?? 'MatrixHostConfig', + version: typeof existing?.version === 'number' ? existing.version : 1, + home: matrixHome, + ...(localOwnerTransport ? { hivecastLocalOwnerTransport: localOwnerTransport } : {}), + http: currentHttp ?? { + host: '127.0.0.1', + port: 3100, + }, + transport: hostTransport, + auth: currentAuth ?? { + mode: 'local-client', + }, + runtimeStorage: currentRuntimeStorage ?? { + recordsDir: 'runtimes', + logsDir: 'logs/runtimes', + }, + packageStorage: currentPackageStorage ?? { + globalDir: 'packages/global', + systemDir: 'packages/system', + }, + }; + if (linkedAuthorityTransport?.authorityCoordinator === false && linkedAuthorityTransport.leafnode) { + nextConfig.natsTopology = linkedLeafNatsTopology(hostTransport.nats as JsonRecord, linkedAuthorityTransport.leafnode); + } else { + delete nextConfig.natsTopology; + } + return nextConfig; +} + +function linkedLeafNatsTopology( + localNats: JsonRecord, + leafnode: { readonly url: string; readonly credentialsRef: string }, +): JsonRecord { + const clientUrl = optionalString(localNats.url); + const wsUrl = optionalString(localNats.wsUrl); + return { + mode: 'leafnode', + ...(clientUrl ? { clientUrl } : {}), + ...(wsUrl ? { wsUrl } : {}), + leafnodes: [{ + url: leafnode.url, + credentialsRef: leafnode.credentialsRef, + }], + }; +} + +function assertLinkedHostConfigConverged( + record: IHiveCastHostLinkRecord, + hostConfig: JsonRecord, +): void { + const transport = asRecord(hostConfig.transport); + const root = optionalString(transport?.root); + const authorityRoot = optionalString(transport?.authorityRoot); + const addressRoot = optionalString(transport?.addressRoot); + for (const [field, actual] of [ + ['transport.root', root], + ['transport.authorityRoot', authorityRoot], + ['transport.addressRoot', addressRoot], + ] as const) { + if (actual !== record.authorityRoot) { + throw new Error( + `HIVECAST_LINK_ROOT_CONVERGENCE_FAILED: expected host config ${field} ${record.authorityRoot}, got ${actual ?? '(missing)'}`, + ); + } + } +} + +function linkedAuthorityHostConfigTransport(snapshot: ILinkedAuthorityTransportSnapshot): JsonRecord { + return { + root: snapshot.authorityRoot, + authorityRoot: snapshot.authorityRoot, + addressRoot: snapshot.authorityRoot, + ...(snapshot.spaceId ? { spaceId: snapshot.spaceId } : {}), + ...(snapshot.routeKey ? { routeKey: snapshot.routeKey } : {}), + ...(snapshot.publicNamespace ? { publicNamespace: snapshot.publicNamespace } : {}), + nats: { ...snapshot.nats }, + }; +} + +function linkedLeafHostConfigTransport( + snapshot: ILinkedAuthorityTransportSnapshot, + localOwnerTransport: JsonRecord, +): JsonRecord { + return { + root: snapshot.authorityRoot, + authorityRoot: snapshot.authorityRoot, + addressRoot: snapshot.authorityRoot, + ...(snapshot.spaceId ? { spaceId: snapshot.spaceId } : {}), + ...(snapshot.routeKey ? { routeKey: snapshot.routeKey } : {}), + ...(snapshot.publicNamespace ? { publicNamespace: snapshot.publicNamespace } : {}), + nats: localNatsTransportForLinkedLeaf(snapshot, localOwnerTransport), + }; +} + +function localNatsTransportForLinkedLeaf( + snapshot: ILinkedAuthorityTransportSnapshot, + localOwnerTransport: JsonRecord, +): JsonRecord { + const localNats = asRecord(localOwnerTransport.nats); + return stripNatsCredentialFields(localNats ?? snapshot.nats); +} + +function defaultLocalOwnerNatsTransport(): JsonRecord { + return { + mode: 'external', + url: 'nats://127.0.0.1:4222', + wsUrl: 'ws://127.0.0.1:4223', + port: 4222, + wsPort: 4223, + dataDir: 'nats/host-default', + pidFile: 'nats/host-default/nats-server.pid', + }; +} + +function stripNatsCredentialFields(nats: JsonRecord): JsonRecord { + const { + credentialsRef: _credentialsRef, + credentials: _credentials, + natsCredentialRef: _natsCredentialRef, + ...rest + } = nats; + return rest; +} + +function linkedAuthorityNatsTransport( + nats: IHiveCastNatsConfig, + natsCredentialsPath: string, +): JsonRecord { + const url = optionalString(nats.url); + if (!url) { + throw new Error('HIVECAST_LINK_AUTHORITY_NATS_MISSING: linked Device setup requires nats.url from the setup exchange'); + } + if (/^wss?:\/\//i.test(url)) { + throw new Error( + `HIVECAST_LINK_AUTHORITY_NATS_WS_ONLY: linked Device setup requires a server NATS URL for runtimes, got ${url}`, + ); + } + const wsUrl = optionalString(nats.wsUrl); + return { + mode: 'external', + url, + ...(wsUrl ? { wsUrl } : {}), + credentialsRef: `file:${natsCredentialsPath}`, + }; +} + +function localOwnerHostConfigTransport(snapshot: ILocalOwnerTransportSnapshot): JsonRecord { + return { + root: snapshot.root, + authorityRoot: snapshot.authorityRoot ?? snapshot.root, + addressRoot: snapshot.root, + nats: { ...snapshot.nats }, + }; +} + +function captureLocalOwnerTransport(matrixHome: string, existing: JsonRecord | null): ILocalOwnerTransportSnapshot | undefined { + return readLocalOwnerTransportSnapshotFromTransportForHome(matrixHome, asRecord(existing?.transport)); +} + +function captureLocalOwnerTransportFromCurrentNats(matrixHome: string, existing: JsonRecord | null): ILocalOwnerTransportSnapshot | undefined { + const transport = asRecord(existing?.transport); + const nats = asRecord(transport?.nats); + if (!nats) return undefined; + return readLocalOwnerTransportSnapshotFromTransportForHome(matrixHome, { + root: localOwnerAuthorityRoot(matrixHome), + nats: { ...nats }, + }); +} + +function readLocalOwnerTransportSnapshot(config: JsonRecord | null): ILocalOwnerTransportSnapshot | undefined { + const snapshot = asRecord(config?.hivecastLocalOwnerTransport); + if (!snapshot) return undefined; + const root = optionalString(snapshot.root); + const nats = asRecord(snapshot.nats); + if (!root || !nats) return undefined; + return readLocalOwnerTransportSnapshotFromTransport({ + root, + ...(optionalString(snapshot.authorityRoot) ? { authorityRoot: optionalString(snapshot.authorityRoot) } : {}), + nats: { ...nats }, + }); +} + +function readLocalOwnerTransportSnapshotForHome( + matrixHome: string, + config: JsonRecord | null, +): ILocalOwnerTransportSnapshot | undefined { + const snapshot = readLocalOwnerTransportSnapshot(config); + if (snapshot) return snapshot; + const saved = asRecord(config?.hivecastLocalOwnerTransport); + if (!saved) return undefined; + return readLocalOwnerTransportSnapshotFromTransportForHome(matrixHome, saved); +} + +function readLocalOwnerTransportSnapshotFromStatus(matrixHome: string): ILocalOwnerTransportSnapshot | undefined { + const status = readJsonFile(path.join(matrixHome, 'host.status.json')); + if (optionalString(status?.status) !== 'running') return undefined; + return readLocalOwnerTransportSnapshotFromTransportForHome(matrixHome, asRecord(status?.transport)); +} + +function readLocalOwnerTransportSnapshotFromRuntimeRecords(matrixHome: string): ILocalOwnerTransportSnapshot | undefined { + const runtimesDir = path.join(matrixHome, 'runtimes'); + if (!fs.existsSync(runtimesDir)) return undefined; + const localScope = localDefaultRuntimeScope(matrixHome); + const preferredRuntimeIds = HIVECAST_DEFAULT_RUNTIME_KEYS.map((key) => `RUNTIME-HOST-${localScope}-${key}`); + for (const runtimeId of preferredRuntimeIds) { + const snapshot = readLocalOwnerTransportSnapshotFromRuntimeRecord(matrixHome, path.join(runtimesDir, runtimeId, 'runtime.json')); + if (snapshot) return snapshot; + } + for (const entry of fs.readdirSync(runtimesDir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith(`RUNTIME-HOST-${localScope}-`)) continue; + const snapshot = readLocalOwnerTransportSnapshotFromRuntimeRecord(matrixHome, path.join(runtimesDir, entry.name, 'runtime.json')); + if (snapshot) return snapshot; + } + return undefined; +} + +function readLocalOwnerTransportSnapshotFromRuntimeRecord( + matrixHome: string, + recordPath: string, +): ILocalOwnerTransportSnapshot | undefined { + const record = readJsonFile(recordPath); + const metadata = asRecord(record?.metadata); + return readLocalOwnerTransportSnapshotFromTransportForHome(matrixHome, asRecord(metadata?.transport)); +} + +function readLocalOwnerTransportSnapshotFromTransportForHome( + _matrixHome: string, + transport: JsonRecord | null, +): ILocalOwnerTransportSnapshot | undefined { + return readLocalOwnerTransportSnapshotFromTransport(transport); +} + +function readLocalOwnerTransportSnapshotFromTransport(transport: JsonRecord | null): ILocalOwnerTransportSnapshot | undefined { + if (!transport) return undefined; + if ( + optionalString(transport.routeKey) + || optionalString(transport.publicNamespace) + || optionalString(transport.spaceId) + ) { + return undefined; + } + const root = optionalString(transport.root); + if (!root) return undefined; + const nats = asRecord(transport.nats); + if (!nats || optionalString(nats.credentialsRef)) return undefined; + const mode = optionalString(nats.mode); + if (mode !== 'embedded' && mode !== 'external') return undefined; + const natsPort = positivePort(nats.port) ?? loopbackUrlPort(optionalString(nats.url)); + const natsWsPort = positivePort(nats.wsPort) ?? loopbackUrlPort(optionalString(nats.wsUrl)); + const maxPayloadBytes = positiveInteger(nats.maxPayloadBytes); + if (!natsPort || !natsWsPort) return undefined; + const natsUrl = mode === 'external' + ? optionalString(nats.url) ?? `nats://127.0.0.1:${natsPort}` + : undefined; + const natsWsUrl = mode === 'external' + ? optionalString(nats.wsUrl) ?? `ws://127.0.0.1:${natsWsPort}` + : undefined; + const authorityRoot = optionalString(transport.authorityRoot); + return { + root, + ...(authorityRoot && authorityRoot !== root ? { authorityRoot } : {}), + nats: { + mode, + ...(natsUrl ? { url: natsUrl } : {}), + ...(natsWsUrl ? { wsUrl: natsWsUrl } : {}), + port: natsPort, + wsPort: natsWsPort, + ...(maxPayloadBytes ? { maxPayloadBytes } : {}), + dataDir: optionalString(nats.dataDir) ?? 'nats/host-default', + pidFile: optionalString(nats.pidFile) ?? 'nats/host-default/nats-server.pid', + ...(optionalString(nats.binaryPath) ? { binaryPath: optionalString(nats.binaryPath) } : {}), + }, + }; +} + +export function readHiveCastLocalOwnerTransport( + cwd: string, + matrixHome?: string, +): IHiveCastLocalOwnerTransport | null { + const paths = hiveCastLinkPaths(cwd, matrixHome); + const config = readJsonFile(paths.hostConfigPath); + const snapshot = readLocalOwnerTransportSnapshot(config) + ?? readLocalOwnerTransportSnapshotForHome(paths.matrixHome, config) + ?? captureLocalOwnerTransport(paths.matrixHome, config) + ?? readLocalOwnerTransportSnapshotFromStatus(paths.matrixHome) + ?? readLocalOwnerTransportSnapshotFromRuntimeRecords(paths.matrixHome); + if (!snapshot) return null; + return { + root: snapshot.root, + ...(snapshot.authorityRoot ? { authorityRoot: snapshot.authorityRoot } : {}), + }; +} + +function positivePort(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + return undefined; + } + return value; +} + +function positiveInteger(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + return undefined; + } + return value; +} + +function loopbackUrlPort(raw: string | undefined): number | undefined { + if (!raw) return undefined; + try { + const parsed = new URL(raw); + if (!['127.0.0.1', 'localhost', '[::1]', '::1'].includes(parsed.hostname)) { + return undefined; + } + const port = Number.parseInt(parsed.port, 10); + return Number.isFinite(port) && port > 0 ? port : undefined; + } catch { + return undefined; + } +} + +function localDefaultRuntimeScope(matrixHome: string): string { + return installRuntimeScope(matrixHome); +} + +function activeDefaultRuntimeScope(matrixHome: string): string { + return localDefaultRuntimeScope(matrixHome); +} + +function defaultRuntimeIdsForScope(scope: string): Set { + return new Set(HIVECAST_DEFAULT_RUNTIME_KEYS.map((key) => `RUNTIME-HOST-${scope}-${key}`)); +} + +function isHiveCastDefaultRuntimeId(runtimeId: string): boolean { + return runtimeId.startsWith('RUNTIME-HOST-') + && HIVECAST_DEFAULT_RUNTIME_KEYS.some((key) => runtimeId.endsWith(`-${key}`)); +} + +function updateHiveCastDefaultRuntimePolicies(matrixHome: string, activeScope: string): void { + const runtimesDir = path.join(matrixHome, 'runtimes'); + if (!fs.existsSync(runtimesDir)) return; + const activeIds = defaultRuntimeIdsForScope(activeScope); + for (const entry of fs.readdirSync(runtimesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const runtimeId = entry.name; + if (!isHiveCastDefaultRuntimeId(runtimeId)) continue; + const recordPath = path.join(runtimesDir, runtimeId, 'runtime.json'); + const record = readJsonFile(recordPath); + if (!record) continue; + const recordOwner = ownerForPath(recordPath); + const active = activeIds.has(runtimeId); + const nextRecord = { + ...record, + startup: active ? 'auto' : 'manual', + restart: active ? 'always' : 'never', + updatedAt: new Date().toISOString(), + metadata: { + ...(asRecord(record.metadata) ?? {}), + hivecastDefaultRuntimePolicy: active ? 'active' : 'inactive', + hivecastDefaultRuntimePolicyUpdatedAt: new Date().toISOString(), + }, + }; + writePrivateJson(recordPath, nextRecord); + alignOwnership(recordPath, recordOwner); + } +} + +export function saveHiveCastHostLink(options: ISaveHiveCastHostLinkOptions): { + readonly record: IHiveCastHostLinkRecord; + readonly paths: IHiveCastLinkPaths; +} { + const paths = hiveCastLinkPaths(options.cwd, options.matrixHome); + fs.mkdirSync(paths.matrixHome, { recursive: true }); + const homeOwner = ownerForPath(paths.matrixHome); + fs.mkdirSync(paths.credentialsDir, { recursive: true }); + alignOwnership(paths.credentialsDir, homeOwner); + + writePrivateJson(paths.natsCredentialsPath, options.natsCredentials); + alignOwnership(paths.natsCredentialsPath, homeOwner); + writePrivateJson(paths.hostCredentialsPath, options.natsCredentials); + alignOwnership(paths.hostCredentialsPath, homeOwner); + const leafnodeUrl = optionalString(options.nats.leafnodeUrl); + const leafnodeCredentialBody = natsUserCredentialsFile(options.natsCredentials); + if (options.authorityCoordinator !== true && leafnodeUrl) { + if (!leafnodeCredentialBody) { + throw new Error('HIVECAST_LINK_LEAFNODE_CREDENTIALS_MISSING: linked Device leaf-node transport requires jwt+seed credentials'); + } + writePrivateText(paths.natsLeafnodeCredentialsPath, leafnodeCredentialBody); + alignOwnership(paths.natsLeafnodeCredentialsPath, homeOwner); + } else { + fs.rmSync(paths.natsLeafnodeCredentialsPath, { force: true }); + } + if (options.hostLinkToken) { + writePrivateText(paths.hostLinkTokenPath, `${options.hostLinkToken}\n`); + alignOwnership(paths.hostLinkTokenPath, homeOwner); + } else { + fs.rmSync(paths.hostLinkTokenPath, { force: true }); + } + + const record: IHiveCastHostLinkRecord = { + version: 1, + cloudUrl: options.cloudUrl, + ...(options.deviceRegistryRoot ? { deviceRegistryRoot: options.deviceRegistryRoot } : {}), + ...(options.hostLinkId ? { hostLinkId: options.hostLinkId } : {}), + ...(options.hostId ? { hostId: options.hostId } : {}), + ...(options.principalId ? { principalId: options.principalId } : {}), + ...(options.hostName ? { hostName: options.hostName } : {}), + ...(options.deviceSlug ? { deviceSlug: options.deviceSlug } : {}), + authorityCoordinator: options.authorityCoordinator === true, + ...(options.spaceId ? { spaceId: options.spaceId } : {}), + ...(options.routeKey ? { routeKey: options.routeKey } : {}), + ...(options.publicNamespace ? { publicNamespace: options.publicNamespace } : {}), + authorityRoot: options.authorityRoot, + linkedAt: options.linkedAt ?? new Date().toISOString(), + credentials: { + ...(options.hostLinkToken ? { hostLinkTokenRef: 'file:credentials/hivecast-host-link-token' } : {}), + natsCredentialRef: HIVECAST_NATS_JSON_CREDENTIAL_REF, + ...(options.authorityCoordinator !== true && leafnodeUrl + ? { natsLeafnodeCredentialRef: HIVECAST_NATS_LEAFNODE_CREDENTIAL_REF } + : {}), + }, + }; + writePrivateJson(paths.linkPath, record); + alignOwnership(paths.linkPath, homeOwner); + + const existingHostConfig = readJsonFile(paths.hostConfigPath); + const localOwnerTransport = readLocalOwnerTransportSnapshotForHome(paths.matrixHome, existingHostConfig) + ?? captureLocalOwnerTransportFromCurrentNats(paths.matrixHome, existingHostConfig) + ?? readLocalOwnerTransportSnapshotFromStatus(paths.matrixHome) + ?? readLocalOwnerTransportSnapshotFromRuntimeRecords(paths.matrixHome) + ?? captureLocalOwnerTransport(paths.matrixHome, existingHostConfig); + const linkedAuthorityTransport: ILinkedAuthorityTransportSnapshot = { + authorityRoot: options.authorityRoot, + ...(options.routeKey ? { routeKey: options.routeKey } : {}), + ...(options.publicNamespace ? { publicNamespace: options.publicNamespace } : {}), + ...(options.spaceId ? { spaceId: options.spaceId } : {}), + authorityCoordinator: options.authorityCoordinator === true, + ...(options.authorityCoordinator !== true && leafnodeUrl + ? { leafnode: { url: leafnodeUrl, credentialsRef: HIVECAST_NATS_LEAFNODE_CREDENTIAL_REF } } + : {}), + nats: linkedAuthorityNatsTransport(options.nats, paths.natsCredentialsPath), + }; + const hostConfig = buildHostConfig( + existingHostConfig, + paths.matrixHome, + localOwnerTransport, + linkedAuthorityTransport, + ); + assertLinkedHostConfigConverged(record, hostConfig); + writePrivateJson(paths.hostConfigPath, hostConfig); + alignOwnership(paths.hostConfigPath, homeOwner); + updateHiveCastDefaultRuntimePolicies(paths.matrixHome, activeDefaultRuntimeScope(paths.matrixHome)); + + return { record, paths }; +} + +export function readHiveCastHostLink(cwd: string, matrixHome?: string): { + readonly record: IHiveCastHostLinkRecord | null; + readonly paths: IHiveCastLinkPaths; +} { + const paths = hiveCastLinkPaths(cwd, matrixHome); + const parsed = readJsonFile(paths.linkPath); + if (!parsed || parsed.version !== 1) { + return { record: null, paths }; + } + const authorityRoot = optionalString(parsed.authorityRoot); + const cloudUrl = optionalString(parsed.cloudUrl); + const credentials = asRecord(parsed.credentials); + const natsCredentialRef = optionalString(credentials?.natsCredentialRef); + const linkedAt = optionalString(parsed.linkedAt); + if (!authorityRoot || !cloudUrl || !natsCredentialRef || !linkedAt) { + return { record: null, paths }; + } + const hostId = optionalString(parsed.hostId); + const deviceRegistryRoot = optionalString(parsed.deviceRegistryRoot); + const hostLinkId = optionalString(parsed.hostLinkId); + const principalId = optionalString(parsed.principalId); + const hostName = optionalString(parsed.hostName); + const deviceSlug = optionalString(parsed.deviceSlug); + const authorityCoordinator = typeof parsed.authorityCoordinator === 'boolean' + ? parsed.authorityCoordinator + : undefined; + const spaceId = optionalString(parsed.spaceId); + const routeKey = optionalString(parsed.routeKey); + const publicNamespace = optionalString(parsed.publicNamespace); + const hostLinkTokenRef = optionalString(credentials?.hostLinkTokenRef); + const registryTokenRef = optionalString(credentials?.registryTokenRef); + const natsLeafnodeCredentialRef = optionalString(credentials?.natsLeafnodeCredentialRef); + return { + paths, + record: { + version: 1, + cloudUrl, + ...(deviceRegistryRoot ? { deviceRegistryRoot } : {}), + ...(hostLinkId ? { hostLinkId } : {}), + ...(hostId ? { hostId } : {}), + ...(principalId ? { principalId } : {}), + ...(hostName ? { hostName } : {}), + ...(deviceSlug ? { deviceSlug } : {}), + ...(typeof authorityCoordinator === 'boolean' ? { authorityCoordinator } : {}), + ...(spaceId ? { spaceId } : {}), + ...(routeKey ? { routeKey } : {}), + ...(publicNamespace ? { publicNamespace } : {}), + authorityRoot, + linkedAt, + credentials: { + natsCredentialRef, + ...(hostLinkTokenRef ? { hostLinkTokenRef } : {}), + ...(registryTokenRef ? { registryTokenRef } : {}), + ...(natsLeafnodeCredentialRef ? { natsLeafnodeCredentialRef } : {}), + }, + }, + }; +} + +export function removeHiveCastCredentialFiles( + paths: Pick, +): boolean { + const existed = fs.existsSync(paths.natsCredentialsPath) + || fs.existsSync(paths.natsLeafnodeCredentialsPath) + || fs.existsSync(paths.hostCredentialsPath) + || fs.existsSync(paths.hostLinkTokenPath); + fs.rmSync(paths.natsCredentialsPath, { force: true }); + fs.rmSync(paths.natsLeafnodeCredentialsPath, { force: true }); + fs.rmSync(paths.hostCredentialsPath, { force: true }); + fs.rmSync(paths.hostLinkTokenPath, { force: true }); + return existed; +} + +export function removeHiveCastHostLink( + cwd: string, + matrixHome?: string, + options: IRemoveHiveCastHostLinkOptions = {}, +): IRemoveHiveCastHostLinkResult { + const paths = hiveCastLinkPaths(cwd, matrixHome); + const existed = fs.existsSync(paths.linkPath) + || fs.existsSync(paths.natsCredentialsPath) + || fs.existsSync(paths.natsLeafnodeCredentialsPath) + || fs.existsSync(paths.hostLinkTokenPath) + || fs.existsSync(paths.hostCredentialsPath) + || isHostConfigLinked(paths.hostConfigPath); + fs.rmSync(paths.linkPath, { force: true }); + updateHiveCastDefaultRuntimePolicies(paths.matrixHome, localDefaultRuntimeScope(paths.matrixHome)); + const hostConfigReset = resetHostConfigToLocalOwner(paths.hostConfigPath); + if (options.preserveCredentials !== true) { + removeHiveCastCredentialFiles(paths); + } + return { removed: existed, hostConfigReset, paths }; +} + +export function readHiveCastLinkStatus(cwd: string, matrixHome?: string): IHiveCastLinkStatus { + const { record, paths } = readHiveCastHostLink(cwd, matrixHome); + const hostConfigLinked = Boolean(record) && fs.existsSync(paths.hostConfigPath); + return { + linked: Boolean(record), + matrixHome: paths.matrixHome, + linkPath: paths.linkPath, + natsCredentialsPath: paths.natsCredentialsPath, + hostConfigPath: paths.hostConfigPath, + natsCredentialsPresent: fs.existsSync(paths.natsCredentialsPath), + hostConfigPresent: fs.existsSync(paths.hostConfigPath), + hostConfigLinked, + ...(record?.hostLinkId ? { hostLinkId: record.hostLinkId } : {}), + ...(record?.authorityRoot ? { authorityRoot: record.authorityRoot } : {}), + ...(record?.routeKey ? { routeKey: record.routeKey } : {}), + ...(record?.publicNamespace ? { publicNamespace: record.publicNamespace } : {}), + ...(record?.spaceId ? { spaceId: record.spaceId } : {}), + ...(typeof record?.authorityCoordinator === 'boolean' ? { authorityCoordinator: record.authorityCoordinator } : {}), + ...(record?.cloudUrl ? { cloudUrl: record.cloudUrl } : {}), + ...(record?.linkedAt ? { linkedAt: record.linkedAt } : {}), + }; +} + +function isHostConfigLinked(hostConfigPath: string): boolean { + const config = readJsonFile(hostConfigPath); + const transport = asRecord(config?.transport); + if (!transport) return false; + const nats = asRecord(transport.nats); + const root = optionalString(transport.root); + const authorityRoot = optionalString(transport.authorityRoot); + const matrixHome = optionalString(config?.home) ?? path.dirname(hostConfigPath); + const localOwnerTransport = readLocalOwnerTransportSnapshotForHome(matrixHome, config); + const localOwnerRoot = localOwnerTransport?.root; + const hasCloudAuthority = Boolean( + authorityRoot + && authorityRoot !== root, + ) && (localOwnerRoot ? authorityRoot !== localOwnerRoot : true); + const externalNatsIsHiveCast = nats?.mode === 'external' + && ( + Boolean(optionalString(nats.credentialsRef)) + || (!loopbackUrlPort(optionalString(nats.url)) && !loopbackUrlPort(optionalString(nats.wsUrl))) + ); + return Boolean( + optionalString(transport.routeKey) + || optionalString(transport.publicNamespace) + || optionalString(transport.spaceId) + || hasCloudAuthority + || externalNatsIsHiveCast, + ); +} + +function resetHostConfigToLocalOwner(hostConfigPath: string): boolean { + const config = readJsonFile(hostConfigPath); + if (!config) return false; + const transport = asRecord(config.transport); + if (!transport) return false; + + const currentNats = asRecord(transport.nats); + const matrixHome = optionalString(config.home) ?? path.dirname(hostConfigPath); + const localOwnerTransport = readLocalOwnerTransportSnapshotForHome(matrixHome, config) + ?? readLocalOwnerTransportSnapshotFromRuntimeRecords(matrixHome) + ?? readLocalOwnerTransportSnapshotFromStatus(matrixHome); + const nextTransport: JsonRecord = { ...transport }; + const localRoot = localOwnerAuthorityRoot(matrixHome); + nextTransport.root = localOwnerTransport?.root ?? localRoot; + if (localOwnerTransport?.authorityRoot) { + nextTransport.authorityRoot = localOwnerTransport.authorityRoot; + } else { + delete nextTransport.authorityRoot; + } + nextTransport.addressRoot = localOwnerTransport?.root ?? localRoot; + delete nextTransport.routeKey; + delete nextTransport.publicNamespace; + delete nextTransport.spaceId; + nextTransport.nats = localOwnerTransport?.nats + ?? (currentNats && currentNats.mode !== 'external' ? currentNats : defaultLocalOwnerNatsTransport()); + + const nextConfig: JsonRecord = { + ...config, + transport: nextTransport, + }; + delete nextConfig.natsTopology; + writePrivateJson(hostConfigPath, nextConfig); + return true; +} diff --git a/archive/mx-cli-unused-after-sdk-split/package-environment.ts b/archive/mx-cli-unused-after-sdk-split/package-environment.ts new file mode 100644 index 0000000..5c24770 --- /dev/null +++ b/archive/mx-cli-unused-after-sdk-split/package-environment.ts @@ -0,0 +1,139 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +type JsonRecord = Record; + +export interface IMatrixPackageEnvironment { + readonly name: string; + readonly nats?: { + readonly mode?: string; + readonly port?: number; + readonly wsPort?: number; + readonly url?: string; + readonly wsUrl?: string; + readonly credentialsRef?: string; + readonly dataDir?: string; + readonly pidFile?: string; + readonly binaryPath?: string; + }; + readonly runtime: { + readonly root: string; + readonly runtimeId?: string; + readonly runtimeMount?: string; + readonly controlMount?: string; + }; + readonly package?: { + readonly publicRoot?: string; + }; + readonly http?: { + readonly enabled?: boolean; + readonly port?: number; + }; + readonly host?: { + readonly matrixDir?: string; + }; + readonly topology?: Record; + readonly authorityLease?: Record | null; +} + +export interface ILoadedMatrixPackageEnvironment { + readonly packageDir: string; + readonly envName: string; + readonly environmentPath: string; + readonly environment: IMatrixPackageEnvironment; +} + +export function loadPackageEnvironment(packageDir: string, envName: string): ILoadedMatrixPackageEnvironment { + const resolvedPackageDir = path.resolve(packageDir); + const cleanEnvName = envName.trim(); + if (cleanEnvName.length === 0) { + throw new Error('Package environment name must be a non-empty string'); + } + + const environmentPath = path.join(resolvedPackageDir, '.matrix', `${cleanEnvName}.environment.json`); + if (!fs.existsSync(environmentPath)) { + throw new Error(`Package environment "${cleanEnvName}" not found at ${environmentPath}`); + } + + return loadPackageEnvironmentFromPath(resolvedPackageDir, environmentPath, cleanEnvName); +} + +export function loadPackageEnvironmentFromPath( + packageDir: string, + environmentPath: string, + envName?: string, +): ILoadedMatrixPackageEnvironment { + const resolvedPackageDir = path.resolve(packageDir); + const resolvedEnvironmentPath = path.resolve(environmentPath); + if (!fs.existsSync(resolvedEnvironmentPath)) { + throw new Error(`Package environment file not found at ${resolvedEnvironmentPath}`); + } + + const label = path.basename(resolvedEnvironmentPath); + const parsed = readJsonObject(resolvedEnvironmentPath, label); + const name = readRequiredString(parsed.name, `Package environment "${envName ?? label}" requires name`); + const cleanEnvName = readOptionalString(envName) ?? name; + const runtime = asRecord(parsed.runtime); + const root = readRequiredString(runtime?.root, `Package environment "${cleanEnvName}" requires runtime.root`); + + const environment: IMatrixPackageEnvironment = { + name, + runtime: { + root, + ...(readOptionalString(runtime?.runtimeId) ? { runtimeId: readOptionalString(runtime?.runtimeId) } : {}), + ...(readOptionalString(runtime?.runtimeMount) ? { runtimeMount: readOptionalString(runtime?.runtimeMount) } : {}), + ...(readOptionalString(runtime?.controlMount) ? { controlMount: readOptionalString(runtime?.controlMount) } : {}), + }, + ...(asRecord(parsed.nats) ? { nats: asRecord(parsed.nats) as IMatrixPackageEnvironment['nats'] } : {}), + ...(asRecord(parsed.package) ? { package: asRecord(parsed.package) as IMatrixPackageEnvironment['package'] } : {}), + ...(asRecord(parsed.http) ? { http: asRecord(parsed.http) as IMatrixPackageEnvironment['http'] } : {}), + ...(asRecord(parsed.host) ? { host: asRecord(parsed.host) as IMatrixPackageEnvironment['host'] } : {}), + ...(asRecord(parsed.topology) ? { topology: asRecord(parsed.topology) } : {}), + ...(parsed.authorityLease === null || asRecord(parsed.authorityLease) + ? { authorityLease: parsed.authorityLease === null ? null : asRecord(parsed.authorityLease) } + : {}), + }; + + return { + packageDir: resolvedPackageDir, + envName: cleanEnvName, + environmentPath: resolvedEnvironmentPath, + environment, + }; +} + +function readJsonObject(filePath: string, label: string): JsonRecord { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); + if (!isRecord(parsed)) { + throw new Error(`${label} must contain a JSON object`); + } + return parsed; + } catch (err) { + throw new Error(`Failed to parse ${label}: ${err instanceof Error ? err.message : String(err)}`); + } +} + +function readRequiredString(value: unknown, message: string): string { + const resolved = readOptionalString(value); + if (!resolved) { + throw new Error(message); + } + return resolved; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asRecord(value: unknown): JsonRecord | undefined { + return isRecord(value) ? value : undefined; +} diff --git a/archive/mx-cli-unused-after-sdk-split/runner-transport.ts b/archive/mx-cli-unused-after-sdk-split/runner-transport.ts new file mode 100644 index 0000000..5fbe905 --- /dev/null +++ b/archive/mx-cli-unused-after-sdk-split/runner-transport.ts @@ -0,0 +1,150 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { ILoadedMatrixPackageEnvironment } from './package-environment.js'; + +export interface IResolvedRunnerTransportPlan { + readonly envName: string; + readonly root: string; + readonly mode: 'embedded' | 'external'; + readonly url: string; + readonly wsUrl?: string; + readonly credentialsRef?: string; + readonly port?: number; + readonly wsPort?: number; + readonly binaryPath?: string; + readonly dataDir?: string; + readonly pidFile?: string; +} + +export function resolveNatsBinary(baseDir: string, explicitBinaryPath?: string): string { + const binaryOverride = readOptionalString(explicitBinaryPath); + if (binaryOverride) { + return path.resolve(baseDir, binaryOverride); + } + + const envBinary = readOptionalString(process.env.MATRIX_NATS_SERVER_BINARY) + ?? readOptionalString(process.env.NATS_SERVER_BINARY); + if (envBinary) { + return path.resolve(envBinary); + } + + const binaryName = process.platform === 'win32' ? 'nats-server.exe' : 'nats-server'; + const searchRoots = new Set([ + baseDir, + path.resolve(baseDir, '..'), + path.resolve(baseDir, '..', '..'), + path.resolve(baseDir, '..', '..', '..'), + path.resolve(baseDir, '..', '..', '..', '..'), + ]); + + for (const root of searchRoots) { + const candidates = [ + path.resolve(root, 'projects', 'matrix-3', 'packages', 'daemon', 'dist', 'bin', binaryName), + path.resolve(root, 'projects', 'nats-server', binaryName), + path.resolve(root, 'packages', 'daemon', 'dist', 'bin', binaryName), + path.resolve(root, 'nats-server', binaryName), + ]; + + for (const candidate of candidates) { + if (pathExists(candidate)) { + return candidate; + } + } + } + + const pathEntries = (process.env.PATH ?? '') + .split(path.delimiter) + .filter((entry) => entry.length > 0); + for (const entry of pathEntries) { + const candidate = path.join(entry, binaryName); + if (pathExists(candidate)) { + return candidate; + } + } + + return binaryName; +} + +export function resolveRunnerTransportPlan( + packageDir: string, + selectedEnvironment: ILoadedMatrixPackageEnvironment, + rootOverride?: string, +): IResolvedRunnerTransportPlan { + const nats = selectedEnvironment.environment.nats ?? {}; + const root = readOptionalString(rootOverride) ?? selectedEnvironment.environment.runtime.root; + const mode = inferMode(nats.mode, nats.url); + + if (mode === 'external') { + const url = readOptionalString(nats.url); + if (!url) { + throw new Error( + `Package environment "${selectedEnvironment.envName}" requires nats.url when nats.mode is external`, + ); + } + const wsUrl = readOptionalString(nats.wsUrl); + const credentialsRef = readOptionalString(nats.credentialsRef); + return { + envName: selectedEnvironment.envName, + root, + mode: 'external', + url, + ...(wsUrl ? { wsUrl } : {}), + ...(credentialsRef ? { credentialsRef } : {}), + }; + } + + const port = readOptionalNumber(nats.port) ?? 4222; + const wsPort = readOptionalNumber(nats.wsPort); + const url = readOptionalString(nats.url) ?? `nats://127.0.0.1:${port}`; + const wsUrl = readOptionalString(nats.wsUrl) ?? (wsPort ? `ws://127.0.0.1:${wsPort}` : undefined); + const dataDir = path.resolve( + packageDir, + readOptionalString(nats.dataDir) ?? path.join('.matrix', 'state', selectedEnvironment.envName, 'nats'), + ); + const pidFile = path.resolve( + packageDir, + readOptionalString(nats.pidFile) ?? path.join('.matrix', 'state', selectedEnvironment.envName, 'nats-server.pid'), + ); + const binaryPath = resolveNatsBinary(packageDir, readOptionalString(nats.binaryPath)); + + return { + envName: selectedEnvironment.envName, + root, + mode: 'embedded', + url, + port, + ...(wsUrl ? { wsUrl } : {}), + ...(wsPort !== undefined ? { wsPort } : {}), + binaryPath, + dataDir, + pidFile, + }; +} + +function inferMode(mode: string | undefined, url: string | undefined): 'embedded' | 'external' { + const normalizedMode = readOptionalString(mode)?.toLowerCase(); + if (normalizedMode === 'external' || readOptionalString(url)) { + return 'external'; + } + return 'embedded'; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function readOptionalNumber(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return value; +} + +function pathExists(filePath: string): boolean { + return fs.existsSync(filePath); +} diff --git a/archive/mx-cli-unused-after-sdk-split/webapp-manifest.ts b/archive/mx-cli-unused-after-sdk-split/webapp-manifest.ts new file mode 100644 index 0000000..bfab311 --- /dev/null +++ b/archive/mx-cli-unused-after-sdk-split/webapp-manifest.ts @@ -0,0 +1,112 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +type JsonRecord = Record; + +export interface ILoadedMatrixWebappManifest { + readonly packageDir: string; + readonly packageName: string; + readonly manifestPath: string; + readonly distDir: string; + readonly entryFile: string; + readonly appName: string; + readonly displayName?: string; + readonly icon?: string; + readonly navOrder?: number; + readonly description?: string; + readonly shells?: readonly string[]; +} + +export function loadMatrixWebappManifest(packageDir: string): ILoadedMatrixWebappManifest | null { + const resolvedPackageDir = path.resolve(packageDir); + const manifestPath = path.join(resolvedPackageDir, 'matrix.json'); + if (!fs.existsSync(manifestPath)) { + return null; + } + + const manifest = readJsonObject(manifestPath, 'matrix.json'); + const webapp = asRecord(manifest.webapp); + if (!webapp) { + return null; + } + + const packageName = readRequiredString( + manifest.name, + `matrix.json in ${resolvedPackageDir} requires a non-empty "name"`, + ); + const distDir = path.resolve( + resolvedPackageDir, + readOptionalString(webapp.distDir) ?? 'dist', + ); + const entryFile = readOptionalString(webapp.entry) ?? 'index.html'; + const appName = readOptionalString(webapp.appName) ?? packageName.replace(/^@[^/]+\//, ''); + const displayName = readOptionalString(webapp.displayName); + const icon = readOptionalString(webapp.icon); + const navOrder = readOptionalNumber(webapp.navOrder); + const description = readOptionalString(webapp.description); + const shells = readStringArray(webapp.shells); + + return { + packageDir: resolvedPackageDir, + packageName, + manifestPath, + distDir, + entryFile, + appName, + ...(displayName ? { displayName } : {}), + ...(icon ? { icon } : {}), + ...(typeof navOrder === 'number' ? { navOrder } : {}), + ...(description ? { description } : {}), + ...(shells.length > 0 ? { shells } : {}), + }; +} + +function readJsonObject(filePath: string, label: string): JsonRecord { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); + if (!isRecord(parsed)) { + throw new Error(`${label} must contain a JSON object`); + } + return parsed; + } catch (error) { + throw new Error(`Failed to parse ${label}: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function readRequiredString(value: unknown, message: string): string { + const resolved = readOptionalString(value); + if (!resolved) { + throw new Error(message); + } + return resolved; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function readOptionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function readStringArray(value: unknown): readonly string[] { + if (!Array.isArray(value)) { + return []; + } + const entries = value + .map((entry) => readOptionalString(entry)) + .filter((entry): entry is string => typeof entry === 'string'); + return [...new Set(entries)]; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asRecord(value: unknown): JsonRecord | undefined { + return isRecord(value) ? value : undefined; +} diff --git a/examples/hivecast-login/.env.example b/archive/sdk-provider-overlay-candidates/hivecast-login/.env.example similarity index 100% rename from examples/hivecast-login/.env.example rename to archive/sdk-provider-overlay-candidates/hivecast-login/.env.example diff --git a/examples/hivecast-login/.gitignore b/archive/sdk-provider-overlay-candidates/hivecast-login/.gitignore similarity index 100% rename from examples/hivecast-login/.gitignore rename to archive/sdk-provider-overlay-candidates/hivecast-login/.gitignore diff --git a/examples/hivecast-login/README.md b/archive/sdk-provider-overlay-candidates/hivecast-login/README.md similarity index 100% rename from examples/hivecast-login/README.md rename to archive/sdk-provider-overlay-candidates/hivecast-login/README.md diff --git a/examples/hivecast-login/run-demo.mjs b/archive/sdk-provider-overlay-candidates/hivecast-login/run-demo.mjs similarity index 100% rename from examples/hivecast-login/run-demo.mjs rename to archive/sdk-provider-overlay-candidates/hivecast-login/run-demo.mjs diff --git a/examples/standalone-greeter/README.md b/examples/standalone-greeter/README.md index 773ecbd..944adcb 100644 --- a/examples/standalone-greeter/README.md +++ b/examples/standalone-greeter/README.md @@ -13,8 +13,9 @@ What it proves: - the SDK packages build from this repo, - this package builds against `@open-matrix/core`, -- `matrix run` starts the actor from the package manifest, -- `matrix invoke demo.greeter echo ...` calls the actor over the SDK local - runtime path, -- all runtime state is written under the demo package's local `.matrix/` - directory. +- a local unauthenticated `nats-server` is started for the demo only, +- `matrix package run` starts the actor from the package manifest against that + broker, +- `--check-op echo` calls the actor over the Matrix runtime/NATS transport path, +- no managed host service, machine link, provider account, provider API key, or + local HTTP control server is involved. diff --git a/examples/standalone-greeter/run-demo.mjs b/examples/standalone-greeter/run-demo.mjs index a3bc63d..ca41c65 100644 --- a/examples/standalone-greeter/run-demo.mjs +++ b/examples/standalone-greeter/run-demo.mjs @@ -1,12 +1,12 @@ import { spawn, spawnSync } from 'node:child_process'; -import { createServer } from 'node:http'; +import { createServer, createConnection } from 'node:net'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..', '..'); const demoDir = __dirname; -const matrixCli = resolve(repoRoot, 'packages', 'cli', 'dist', 'bin', 'matrix.js'); +const matrixCli = resolve(repoRoot, 'packages', 'cli', 'dist', 'index.js'); function run(command, args, options = {}) { const result = spawnSync(command, args, { @@ -38,21 +38,27 @@ async function freePort() { }); } -async function waitForHealth(port, child) { +async function waitForTcp(port, child) { const deadline = Date.now() + 10_000; let lastError; while (Date.now() < deadline) { - if (child.exitCode !== null) throw new Error(`matrix run exited early with ${child.exitCode}`); + if (child.exitCode !== null) throw new Error(`nats-server exited early with ${child.exitCode}`); try { - const response = await fetch(`http://127.0.0.1:${port}/healthz`); - if (response.ok) return; - lastError = new Error(`healthz returned ${response.status}`); + await new Promise((resolveConnect, rejectConnect) => { + const socket = createConnection({ host: '127.0.0.1', 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 ?? new Error('Timed out waiting for matrix run healthz'); + throw lastError ?? new Error('Timed out waiting for nats-server'); } function stop(child) { @@ -70,44 +76,50 @@ function stop(child) { } const port = await freePort(); -let runner; +let natsServer; try { run('pnpm', ['run', 'build']); run('pnpm', ['--filter', 'matrix-sdk-example-standalone-greeter', 'run', 'build']); - runner = spawn(process.execPath, [ - matrixCli, 'run', demoDir, - '--dir', demoDir, - '--port', String(port), - ], { + natsServer = spawn('nats-server', ['-p', String(port), '-a', '127.0.0.1'], { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], env: process.env, }); - runner.stderr.on('data', (chunk) => process.stderr.write(chunk)); - await waitForHealth(port, runner); + natsServer.stderr.on('data', (chunk) => process.stderr.write(chunk)); + await waitForTcp(port, natsServer); const invoked = run(process.execPath, [ - matrixCli, 'invoke', - 'demo.greeter', + matrixCli, + 'package', + 'run', + demoDir, + '--nats-url', + `nats://127.0.0.1:${port}`, + '--root', + 'demo', + '--check-op', 'echo', + '--check-payload', '{"message":"hello-from-standalone-demo"}', - '--dir', demoDir, - '--port', String(port), + '--json', ], { cwd: repoRoot, stdio: 'pipe' }); const body = JSON.parse(invoked.stdout.trim()); - if (body?.ok !== true || body?.result?.message !== 'hello-from-standalone-demo') { - throw new Error(`Unexpected invoke result: ${invoked.stdout}`); + if (body?.ok !== true || body?.check?.result?.message !== 'hello-from-standalone-demo') { + throw new Error(`Unexpected package run result: ${invoked.stdout}`); } console.log(JSON.stringify({ ok: true, demo: 'standalone-greeter', - mount: 'demo.greeter', - port, - invoke: body, + mode: body.mode, + mount: body.mount, + root: body.root, + natsUrl: body.natsUrl, + authMode: body.authMode, + check: body.check, }, null, 2)); } finally { - if (runner) await stop(runner); + if (natsServer) await stop(natsServer); } diff --git a/package.json b/package.json index 88f7677..c5e5bbc 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,8 @@ "type-check": "pnpm -r --sort --if-present run type-check", "pack:all": "pnpm --filter './packages/*' -r exec pnpm pack", "demo:standalone": "node examples/standalone-greeter/run-demo.mjs", - "demo:hivecast-login": "node examples/hivecast-login/run-demo.mjs", + "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:cli-uat": "node scripts/prove-sdk-cli-uat.mjs", "prove": "pnpm run clean && pnpm run build && pnpm run test && pnpm run type-check && pnpm run pack:all" }, "dependencies": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 26301bc..f8ef63b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,23 +1,43 @@ # @open-matrix/cli -Package-author CLI for the Matrix SDK layer. +Package-author CLI for Matrix SDK. -This package owns the SDK/package-author `matrix` binary. It does not depend on -provider overlay packages, managed host packages, platform system actors, -device-link implementations, or broker-authority/signing packages. +## Scope -Initial command surface: +This package owns the `matrix` binary for SDK users who build, package, and run +Matrix actors and browser surfaces. + +It does not own managed provider login, machine linking, local platform install, +or managed host supervision. Provider overlays can add those flows by writing +the broker config shape consumed by the SDK runner. + +## Common Commands ```bash -matrix init [dir] -matrix add actor -matrix actor -matrix configure --key --cloud --space -matrix run --mount -matrix invoke [json] +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 config set registry https://registry.npmjs.org/ +matrix registry doctor ``` -`matrix run` starts a standalone actor runner in the current folder. -`matrix invoke` sends a Matrix request envelope to that runner through its local -control port. Managed host promotion belongs to provider overlays that consume -the SDK; it is not an SDK-layer requirement. +Direct runner credentials are resolved from explicit flags, environment +variables, package-local `.matrix`, and `~/.matrix`: + +```text +--nats-url / MATRIX_NATS_URL / NATS_URL +--root / --space / MATRIX_ROOT / MATRIX_SPACE +--jwt / MATRIX_NATS_JWT / NATS_JWT +--seed / MATRIX_NATS_SEED / NATS_SEED +./.matrix/credentials/matrix-broker.json +./.matrix/credentials/matrix-nats.json +./.matrix/config.json +~/.matrix/credentials/matrix-broker.json +~/.matrix/credentials/matrix-nats.json +~/.matrix/config.json +``` + +JWT and seed must be supplied together. If neither is supplied, the runner +connects to the broker without auth, which is intended for local/demo brokers +only. diff --git a/packages/cli/package.json b/packages/cli/package.json index a50444c..7d1cccb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,40 +1,36 @@ { "name": "@open-matrix/cli", "version": "0.1.0", - "description": "Package-author Matrix CLI for SDK projects.", + "description": "Matrix package-author CLI.", "type": "module", - "private": true, "bin": { - "matrix": "./dist/bin/matrix.js" - }, - "exports": { - ".": { - "types": "./dist/cli.d.ts", - "import": "./dist/cli.js", - "default": "./dist/cli.js" - }, - "./package.json": "./package.json" + "matrix": "./dist/index.js" }, "files": [ - "dist", - "package.json", - "README.md", - "CONTRACT.md" + "dist/", + "README.md" ], "scripts": { - "build": "tsc -p tsconfig.json", - "test": "node --test dist/__tests__/*.test.js", - "type-check": "tsc -p tsconfig.json --noEmit" + "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --external:node:* --external:commander --external:nats --log-level=warning", + "type-check": "tsc -p tsconfig.json --noEmit", + "test": "node -e \"const{globSync}=require('glob');const{execFileSync}=require('child_process');const files=globSync('tests/unit/*.spec.ts');if(!files.length){console.error('No test files found');process.exit(1)}execFileSync(process.execPath,['--import','tsx','--test',...files],{stdio:'inherit'})\"" }, "dependencies": { - "@open-matrix/core": "workspace:*" + "@open-matrix/contracts": "workspace:*", + "@open-matrix/core": "workspace:*", + "@open-matrix/federation": "workspace:*", + "commander": "^11.0.0", + "nats": "^2.29.3" }, "devDependencies": { "@types/node": "^25.0.10", + "esbuild": "^0.27.3", + "glob": "^13.0.6", + "tsx": "^4.15.6", "typescript": "^5.7.0" }, "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22" } } diff --git a/packages/cli/src/commands/actor-element.ts b/packages/cli/src/commands/actor-element.ts new file mode 100644 index 0000000..bc909e1 --- /dev/null +++ b/packages/cli/src/commands/actor-element.ts @@ -0,0 +1,125 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { toKebabCase, toPascalCase, validateMatrixManifest, writeJsonFile, generatePackageJson, generateTsconfig } from '../utils/scaffold.js'; + +export interface IActorElementCommandOptions { + readonly cwd?: string; + readonly outputDir?: string; +} + +export interface IActorElementCommandResult { + readonly rootDir: string; + readonly actorClassName: string; + readonly elementClassName: string; +} + +function actorTemplate(className: string, matrixActorImport: string): string { + return [ + `import { MatrixActor } from ${JSON.stringify(matrixActorImport)};`, + '', + `export class ${className} extends MatrixActor {`, + ' static override accepts = {', + " getValue: {},", + ' };', + '', + ' protected onGetValue(): { value: number } {', + ' return { value: 42 };', + ' }', + '}', + '', + ].join('\n'); +} + +function elementTemplate(className: string, matrixActorImport: string): string { + return [ + `import { MatrixActor } from ${JSON.stringify(matrixActorImport)};`, + '', + `export class ${className} extends MatrixActor {`, + ' static override accepts = {', + " render: {},", + ' };', + '', + ' protected onRender(): { ok: boolean } {', + ' return { ok: true };', + ' }', + '}', + '', + ].join('\n'); +} + +export async function actorElementCommand( + name: string, + options: IActorElementCommandOptions = {} +): Promise { + const normalizedName = toKebabCase(name); + if (!normalizedName) { + throw new Error('actor-element name must not be empty'); + } + + const actorClassName = `${toPascalCase(normalizedName)}Actor`; + const elementClassName = `${toPascalCase(normalizedName)}Element`; + const cwd = path.resolve(options.cwd ?? process.cwd()); + const rootDir = path.resolve(options.outputDir ?? path.join(cwd, normalizedName)); + const matrixActorImport = '@open-matrix/core'; + + fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'examples'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'skills'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'tests'), { recursive: true }); + + const manifest = { + name: normalizedName, + version: '0.1.0', + description: `${actorClassName} + ${elementClassName} generated by mx actor-element`, + runtime: { language: 'typescript', entry: 'dist/index.js' }, + components: [ + { + type: actorClassName, + export: actorClassName, + mount: `${normalizedName}.actor`, + autoStart: true, + }, + { + type: elementClassName, + export: elementClassName, + mount: `${normalizedName}.element`, + autoStart: true, + }, + ], + permissions: { fsPolicy: 'none' }, + }; + + const validation = validateMatrixManifest(manifest); + if (!validation.valid) { + throw new Error(`Generated matrix.json failed validation: ${validation.errors.join('; ')}`); + } + + writeJsonFile(path.join(rootDir, 'matrix.json'), manifest); + writeJsonFile(path.join(rootDir, 'package.json'), generatePackageJson(normalizedName)); + writeJsonFile(path.join(rootDir, 'tsconfig.json'), generateTsconfig()); + fs.writeFileSync(path.join(rootDir, 'src', `${actorClassName}.ts`), actorTemplate(actorClassName, matrixActorImport), 'utf8'); + fs.writeFileSync(path.join(rootDir, 'src', `${elementClassName}.ts`), elementTemplate(elementClassName, matrixActorImport), 'utf8'); + fs.writeFileSync( + path.join(rootDir, 'src', 'index.ts'), + `export { ${actorClassName} } from './${actorClassName}.js';\nexport { ${elementClassName} } from './${elementClassName}.js';\n`, + 'utf8' + ); + writeJsonFile(path.join(rootDir, 'examples', 'query-value.mxq'), { + label: 'Query actor value', + operation: 'getValue', + payload: {}, + }); + writeJsonFile(path.join(rootDir, 'examples', 'render-element.mxq'), { + label: 'Render element', + operation: 'render', + payload: {}, + }); + fs.writeFileSync( + path.join(rootDir, 'skills', `${normalizedName}.skill.md`), + ['---', `name: ${normalizedName}`, 'intents:', ' - action: query', ' operation: getValue', '---', '', '# actor-element skill', ''].join('\n'), + 'utf8' + ); + + console.log(`Created actor-element scaffold at ${rootDir}`); + return { rootDir, actorClassName, elementClassName }; +} diff --git a/packages/cli/src/commands/actor-run.ts b/packages/cli/src/commands/actor-run.ts new file mode 100644 index 0000000..858997f --- /dev/null +++ b/packages/cli/src/commands/actor-run.ts @@ -0,0 +1,605 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type { MatrixActor } from '@open-matrix/core/core/MatrixActor.js'; +import { RequestReply } from '@open-matrix/core/engine/remoting/RequestReply.js'; +import { MatrixRuntime } from '@open-matrix/core/runtime/MatrixRuntime.js'; +import { NatsTransport } from '@open-matrix/core/transport/NatsTransport.js'; +import { connect as natsConnect, jwtAuthenticator, type NatsConnection } from 'nats'; + +import { loadMatrixServiceManifest, type ILoadedMatrixServiceManifest } from '../utils/service-manifest.js'; +import { createRunnerSecurityRealm } from '../utils/runner-security.js'; + +export interface IActorRunCommandOptions { + readonly natsUrl?: string; + readonly root?: string; + readonly space?: string; + readonly jwt?: string; + readonly seed?: string; + readonly home?: string; + readonly export?: string; + readonly mount?: string; + readonly json?: boolean; + readonly props?: string; + readonly checkOp?: string; + readonly checkPayload?: string; +} + +export interface IBrokerRunConfig { + readonly natsUrl: string; + readonly root: string; + readonly jwt?: string; + readonly seed?: string; + readonly checked: readonly string[]; +} + +type ActorConstructor = { + new (): MatrixActor; + readonly accepts?: Record; + readonly emits?: Record; + readonly subscribes?: Record; + readonly name: string; +}; + +type PackageBootstrapFunction = (...args: readonly unknown[]) => Promise | unknown; + +export interface IPackageRunCommandOptions extends IActorRunCommandOptions { + readonly entry?: string; + readonly overrides?: string; +} + +export interface IPackageRunSpec { + readonly packageDir: string; + readonly packageName: string; + readonly manifestPath: string; + readonly entryPath: string; + readonly exportName: string; + readonly mount: string; + readonly actorId: string; + readonly packageId: string; + readonly runtimeId: string; + readonly accepts: Record; + readonly emits: Record; + readonly subscribes: Record; + readonly overrides: Record; + readonly serviceInstance: ILoadedMatrixServiceManifest['serviceInstance']; +} + +export async function actorRunCommand( + entry: string, + options: IActorRunCommandOptions, +): Promise { + const entryPath = path.resolve(entry); + if (!fs.existsSync(entryPath)) { + throw new Error(`Actor entry not found: ${entryPath}`); + } + const exportName = options.export?.trim() || 'default'; + const ActorClass = await loadActorConstructor(entryPath, exportName); + const mount = options.mount?.trim() || inferMountFromEntry(entryPath); + if (!mount) { + throw new Error('matrix actor run requires --mount when it cannot infer a mount from the entry file'); + } + const broker = resolveActorRunBroker(options); + const connection = await connectActorNats(broker); + const transport = new NatsTransport(connection, { root: broker.root }); + const runtime = new MatrixRuntime({ + transport, + logging: false, + allowDynamicCompilation: false, + securityRealm: createRunnerSecurityRealm(), + }); + + const props = parseProps(options.props); + await runtime.createSupervised(ActorClass, mount, props); + await transport.flush(); + const check = await runOptionalActorCheck(runtime, mount, options); + + const result = { + ok: true, + mode: 'standalone-actor-runner', + entry: entryPath, + export: exportName, + actor: ActorClass.name, + mount, + root: broker.root, + effectiveMount: `${broker.root}.${mount}`, + natsUrl: broker.natsUrl, + authMode: broker.jwt ? 'jwt' : 'none', + hostStateWritten: false, + ...(check ? { check } : {}), + }; + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log('Actor mounted:'); + console.log(` logical: ${result.mount}`); + console.log(` root: ${result.root}`); + console.log(` effective: ${result.effectiveMount}`); + console.log(` broker: ${result.natsUrl}`); + console.log('Press Ctrl+C to stop.'); + } + + if (check) { + await runtime.shutdown(); + await transport.disconnect(); + if (!connection.isClosed()) { + await connection.close(); + } + return; + } + + await waitForActorShutdown(async () => { + await runtime.shutdown(); + await transport.disconnect(); + if (!connection.isClosed()) { + await connection.close(); + } + }); +} + +export async function packageRunCommand( + packageDir: string, + options: IPackageRunCommandOptions, +): Promise { + const spec = resolvePackageRunSpec(packageDir, options); + const broker = resolveActorRunBroker(options); + const connection = await connectActorNats(broker); + const transport = new NatsTransport(connection, { root: broker.root }); + const runtime = new MatrixRuntime({ + transport, + logging: false, + allowDynamicCompilation: false, + securityRealm: createRunnerSecurityRealm(), + }); + + const bootstrap = await loadPackageBootstrap(spec.entryPath, spec.exportName); + const bootstrapResult = await bootstrap( + spec.overrides, + { runtime }, + undefined, + { + packageName: spec.packageName, + packageDir: spec.packageDir, + manifestPath: spec.manifestPath, + entryPath: spec.entryPath, + exportName: spec.exportName, + root: broker.root, + mount: spec.mount, + instance: spec.serviceInstance, + serviceInstance: spec.serviceInstance, + }, + ); + await transport.flush(); + const check = await runOptionalActorCheck(runtime, spec.mount, options); + + const result = { + ok: true, + mode: 'standalone-package-runner', + packageDir: spec.packageDir, + packageName: spec.packageName, + manifest: spec.manifestPath, + entry: spec.entryPath, + export: spec.exportName, + mount: spec.mount, + root: broker.root, + effectiveMount: `${broker.root}.${spec.mount}`, + natsUrl: broker.natsUrl, + authMode: broker.jwt ? 'jwt' : 'none', + hostStateWritten: false, + ...(check ? { check } : {}), + }; + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log('Package actor mounted:'); + console.log(` package: ${result.packageName}`); + console.log(` logical: ${result.mount}`); + console.log(` root: ${result.root}`); + console.log(` effective: ${result.effectiveMount}`); + console.log(` broker: ${result.natsUrl}`); + console.log('Press Ctrl+C to stop.'); + } + + if (check) { + await shutdownPackageBootstrapResult(bootstrapResult, runtime); + await runtime.shutdown(); + await transport.disconnect(); + if (!connection.isClosed()) { + await connection.close(); + } + return; + } + + await waitForActorShutdown(async () => { + await shutdownPackageBootstrapResult(bootstrapResult, runtime); + await runtime.shutdown(); + await transport.disconnect(); + if (!connection.isClosed()) { + await connection.close(); + } + }); +} + +export function resolveActorRunBroker(options: IActorRunCommandOptions): IBrokerRunConfig { + const candidateHomes = resolveCandidateHomes(options.home); + const files = candidateHomes.flatMap((home) => [ + { + label: `${formatHomeLabel(home)}/credentials/matrix-broker.json`, + value: readBrokerCredentialFile(path.join(home, 'credentials', 'matrix-broker.json')), + }, + { + label: `${formatHomeLabel(home)}/credentials/matrix-nats.json`, + value: readBrokerCredentialFile(path.join(home, 'credentials', 'matrix-nats.json')), + }, + { + label: `${formatHomeLabel(home)}/config.json`, + value: readBrokerConfigFile(path.join(home, 'config.json')), + }, + ]); + + const natsUrl = readOptionalString(options.natsUrl) + ?? readOptionalString(process.env.MATRIX_NATS_URL) + ?? readOptionalString(process.env.NATS_URL) + ?? firstConfigured(files, 'natsUrl'); + const root = readOptionalString(options.root) + ?? readOptionalString(options.space) + ?? readOptionalString(process.env.MATRIX_ROOT) + ?? readOptionalString(process.env.MATRIX_SPACE) + ?? firstConfigured(files, 'root') + ?? firstConfigured(files, 'space'); + const jwt = readOptionalString(options.jwt) + ?? readOptionalString(process.env.MATRIX_NATS_JWT) + ?? readOptionalString(process.env.NATS_JWT) + ?? firstConfigured(files, 'jwt'); + const seed = readOptionalString(options.seed) + ?? readOptionalString(process.env.MATRIX_NATS_SEED) + ?? readOptionalString(process.env.NATS_SEED) + ?? firstConfigured(files, 'seed'); + + const checked = [ + '--nats-url', + '--root', + '--space', + '--jwt', + '--seed', + 'MATRIX_NATS_URL', + 'MATRIX_ROOT', + 'MATRIX_SPACE', + 'MATRIX_NATS_JWT', + 'MATRIX_NATS_SEED', + 'NATS_URL', + 'NATS_JWT', + 'NATS_SEED', + ...files.map((entry) => entry.label), + ]; + + if (!natsUrl) { + throw new Error(`MATRIX_NATS_URL_REQUIRED: matrix actor/package run requires --nats-url or configured broker URL. Checked: ${checked.join(', ')}`); + } + if (!root) { + throw new Error('MATRIX_ROOT_REQUIRED: matrix actor/package run requires --root, --space, MATRIX_ROOT, MATRIX_SPACE, or configured root'); + } + if ((jwt && !seed) || (!jwt && seed)) { + throw new Error('MATRIX_NATS_JWT_SEED_PAIR_REQUIRED: provide both JWT and seed, or neither for an unauthenticated local broker'); + } + + return { + natsUrl, + root, + ...(jwt ? { jwt } : {}), + ...(seed ? { seed } : {}), + checked, + }; +} + +export function resolvePackageRunSpec(packageDir: string, options: IPackageRunCommandOptions): IPackageRunSpec { + const loaded = loadMatrixServiceManifest(packageDir); + const entryPath = options.entry + ? resolvePackageEntryPath(loaded.packageDir, options.entry) + : loaded.entryPath; + const exportName = options.export?.trim() || loaded.exportName; + const mount = options.mount?.trim() || loaded.mount; + if (!mount) { + throw new Error('matrix package run requires --mount when the package descriptor does not declare one'); + } + const binding = resolvePackageBindingForMount(loaded, mount); + const componentType = binding?.type ?? loaded.serviceInstance.componentType ?? loaded.serviceInstance.class; + const actorId = componentType || mount; + const runtimeId = `standalone-package:${process.pid}:${sanitizeRuntimeIdPart(loaded.packageName)}:${sanitizeRuntimeIdPart(mount)}`; + return { + packageDir: loaded.packageDir, + packageName: loaded.packageName, + manifestPath: loaded.manifestPath, + entryPath, + exportName, + mount, + actorId, + packageId: loaded.packageName, + runtimeId, + accepts: acceptsArrayToRecord(binding?.accepts ?? []), + emits: {}, + subscribes: {}, + overrides: parseOverrides(options.overrides), + serviceInstance: loaded.serviceInstance, + }; +} + +async function connectActorNats(input: IBrokerRunConfig): Promise { + const encoder = new TextEncoder(); + return await natsConnect({ + servers: [input.natsUrl], + name: `matrix-actor-run-${input.root}-${process.pid}`, + timeout: 5_000, + ...(input.jwt && input.seed + ? { + authenticator: jwtAuthenticator( + () => input.jwt!, + () => encoder.encode(input.seed!), + ), + } + : {}), + }); +} + +export async function loadActorConstructor(entryPath: string, exportName: string): Promise { + const moduleUrl = `${pathToFileURL(entryPath).href}?matrix_actor_run=${Date.now()}-${Math.random().toString(16).slice(2)}`; + const mod = await import(moduleUrl) as Record; + const requested = actorConstructorFromExport(mod[exportName]); + if (requested) { + return requested; + } + const available = Object.entries(mod) + .filter(([, value]) => actorConstructorFromExport(value)) + .map(([name]) => name); + throw new Error(`No actor class found as export "${exportName}". Available exports: ${available.join(', ') || '(none)'}`); +} + +async function loadPackageBootstrap(entryPath: string, exportName: string): Promise { + const moduleUrl = `${pathToFileURL(entryPath).href}?matrix_package_run=${Date.now()}-${Math.random().toString(16).slice(2)}`; + const mod = await import(moduleUrl) as Record; + const bootstrap = mod[exportName]; + if (typeof bootstrap !== 'function') { + throw new Error(`Package run export "${exportName}" was not found in ${entryPath}`); + } + return bootstrap as PackageBootstrapFunction; +} + +function actorConstructorFromExport(value: unknown): ActorConstructor | null { + if (typeof value !== 'function') { + return null; + } + const candidate = value as ActorConstructor; + return candidate.prototype ? candidate : null; +} + +function inferMountFromEntry(entryPath: string): string { + return path.basename(entryPath, path.extname(entryPath)) + .replace(/Actor$/i, '') + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +function parseProps(value: string | undefined): Record | undefined { + const text = value?.trim(); + if (!text) { + return undefined; + } + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('matrix actor run --props must be a JSON object'); + } + return parsed as Record; +} + +function parseOverrides(value: string | undefined): Record { + const text = value?.trim(); + if (!text) { + return {}; + } + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('matrix package run --overrides must be a JSON object'); + } + return parsed as Record; +} + +async function runOptionalActorCheck( + runtime: MatrixRuntime, + mount: string, + options: IActorRunCommandOptions, +): Promise<{ readonly op: string; readonly result: unknown } | null> { + const op = options.checkOp?.trim(); + if (!op) { + return null; + } + const payload = parseCheckPayload(options.checkPayload); + const result = await RequestReply.execute, unknown>( + runtime.getRootContext(), + mount, + op, + payload, + { timeoutMs: 5_000 }, + ); + return { op, result }; +} + +function parseCheckPayload(value: string | undefined): Record { + const text = value?.trim(); + if (!text) { + return {}; + } + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('matrix actor/package run --check-payload must be a JSON object'); + } + return parsed as Record; +} + +function resolvePackageEntryPath(packageDir: string, entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) { + throw new Error('matrix package run --entry must be a non-empty path'); + } + const entryPath = path.resolve(packageDir, trimmed); + if (!fs.existsSync(entryPath)) { + throw new Error(`matrix package run entry does not exist: ${trimmed}`); + } + return entryPath; +} + +function resolvePackageBindingForMount( + loaded: ILoadedMatrixServiceManifest, + mount: string, +): ILoadedMatrixServiceManifest['packageComponents'][number] | ILoadedMatrixServiceManifest['packageRoot'] | undefined { + if (loaded.packageRoot?.mount === mount) { + return loaded.packageRoot; + } + return loaded.packageComponents.find((entry) => entry.mount === mount); +} + +function acceptsArrayToRecord(accepts: readonly string[]): Record { + const result: Record = {}; + for (const op of accepts) { + result[op] = {}; + } + return result; +} + +function sanitizeRuntimeIdPart(value: string): string { + return value + .replace(/^@/u, '') + .replace(/[^a-zA-Z0-9._-]+/gu, '-') + .replace(/-+/gu, '-') + .replace(/^-|-$/gu, '') + || 'package'; +} + +async function shutdownPackageBootstrapResult(value: unknown, ownedRuntime: MatrixRuntime): Promise { + if (!value || typeof value !== 'object') { + return; + } + const runtime = (value as { readonly runtime?: unknown }).runtime; + if (!runtime || typeof runtime !== 'object') { + return; + } + if (runtime === ownedRuntime) { + return; + } + const shutdown = (runtime as { readonly shutdown?: unknown }).shutdown; + if (typeof shutdown === 'function') { + await shutdown.call(runtime); + } +} + +function waitForActorShutdown(onShutdown: () => Promise): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: unknown): void => { + if (settled) return; + settled = true; + process.off('SIGINT', shutdown); + process.off('SIGTERM', shutdown); + if (error) { + reject(error); + return; + } + resolve(); + }; + const shutdown = (): void => { + void onShutdown().then(() => finish()).catch((error) => finish(error)); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + }); +} + +function resolveCandidateHomes(homeOption: string | undefined): readonly string[] { + const homes = [ + homeOption ? path.resolve(homeOption) : undefined, + path.resolve(process.cwd(), '.matrix'), + path.resolve(os.homedir(), '.matrix'), + ].filter((value): value is string => Boolean(value)); + return [...new Set(homes)]; +} + +function formatHomeLabel(home: string): string { + const defaultHome = path.resolve(os.homedir(), '.matrix'); + const cwdHome = path.resolve(process.cwd(), '.matrix'); + if (home === defaultHome) return '~/.matrix'; + if (home === cwdHome) return './.matrix'; + return home; +} + +function readBrokerCredentialFile(filePath: string): Record { + const value = readJsonFile(filePath); + return normalizeBrokerConfig(value); +} + +function readBrokerConfigFile(filePath: string): Record { + const value = readJsonFile(filePath); + const nested = [ + value, + value?.broker, + value?.nats, + value?.transport, + value?.credentials, + ].filter((entry): entry is Record => Boolean(entry && typeof entry === 'object' && !Array.isArray(entry))); + return nested.reduce>((result, entry) => ({ + ...result, + ...normalizeBrokerConfig(entry), + }), {}); +} + +function normalizeBrokerConfig(value: Record | null): Record { + if (!value) { + return {}; + } + return { + ...(readOptionalString(value.natsUrl) ?? readOptionalString(value.url) ?? readOptionalString(value.brokerUrl) ? { natsUrl: (readOptionalString(value.natsUrl) ?? readOptionalString(value.url) ?? readOptionalString(value.brokerUrl))! } : {}), + ...(readOptionalString(value.root) ?? readOptionalString(value.space) ?? readOptionalString(value.authorityRoot) ? { root: (readOptionalString(value.root) ?? readOptionalString(value.space) ?? readOptionalString(value.authorityRoot))! } : {}), + ...(readOptionalString(value.space) ? { space: readOptionalString(value.space)! } : {}), + ...(readOptionalString(value.jwt) ? { jwt: readOptionalString(value.jwt)! } : {}), + ...(readOptionalString(value.seed) ? { seed: readOptionalString(value.seed)! } : {}), + }; +} + +function firstConfigured( + files: readonly { readonly value: Record }[], + key: 'natsUrl' | 'root' | 'space' | 'jwt' | 'seed', +): string | undefined { + for (const file of files) { + const value = file.value[key]; + if (value) { + return value; + } + } + return undefined; +} + +function readJsonFile(filePath: string): Record | null { + if (!fs.existsSync(filePath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid JSON in ${filePath}: ${message}`); + } +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/packages/cli/src/commands/actor.ts b/packages/cli/src/commands/actor.ts new file mode 100644 index 0000000..24a77f0 --- /dev/null +++ b/packages/cli/src/commands/actor.ts @@ -0,0 +1,236 @@ +/** + * mx actor command implementation. + * + * Scaffolds a complete actor package with TypeScript, tests, examples, and skills. + * Implementation: B014 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { toKebabCase, toPascalCase, validateMatrixManifest, writeJsonFile, generatePackageJson, generateTsconfig } from '../utils/scaffold.js'; + +export interface IActorCommandOptions { + readonly cwd?: string; + readonly outputDir?: string; +} + +export interface IActorCommandResult { + readonly rootDir: string; + readonly className: string; + readonly mount: string; + readonly workspacePath?: string; +} + +function actorTemplate(className: string, matrixActorImport: string): string { + return [ + `import { MatrixActor } from ${JSON.stringify(matrixActorImport)};`, + '', + `export class ${className} extends MatrixActor {`, + ' static override accepts = {', + " getStatus: {},", + ' };', + '', + ' static override emits = {', + " statusChanged: { status: 'string' },", + ' };', + '', + " private _status = 'ready';", + '', + ' protected onGetStatus(): { status: string } {', + ' return { status: this._status };', + ' }', + '}', + '', + ].join('\n'); +} + +function actorIndexTemplate(className: string): string { + return [ + `export { ${className} } from './${className}.js';`, + `export { createStandalone${className} } from './create-standalone-${className}.js';`, + '', + ].join('\n'); +} + +function actorFactoryTemplate(className: string, mount: string, matrixActorImport: string): string { + const factoryName = `createStandalone${className}`; + const actorFile = `./${className}.js`; + return [ + `import { InMemoryBroker, InMemoryTransport, MatrixRuntime } from ${JSON.stringify(matrixActorImport)};`, + '', + `import { ${className} } from ${JSON.stringify(actorFile)};`, + '', + 'interface IStandaloneBootContext {', + ' readonly root?: string;', + ' readonly mount?: string;', + '}', + '', + 'interface IStandaloneRunnerServices {', + ' readonly runtime?: MatrixRuntime;', + '}', + '', + 'function readTrimmedString(value: unknown): string | undefined {', + " if (typeof value !== 'string') {", + ' return undefined;', + ' }', + ' const trimmed = value.trim();', + ' return trimmed.length > 0 ? trimmed : undefined;', + '}', + '', + 'function requiredStandaloneRoot(requestedRoot: string | undefined, runtime: MatrixRuntime): string {', + ' const runtimeRoot = readTrimmedString(runtime.transport.root);', + ' const root = requestedRoot ?? runtimeRoot;', + ' if (!root) {', + ` throw new Error('${factoryName} requires bootContext.root or a runtime transport root');`, + ' }', + ' return root;', + '}', + '', + `export async function ${factoryName}(`, + ' _overrides: Record = {},', + ' services?: IStandaloneRunnerServices,', + ' _config?: unknown,', + ' bootContext: IStandaloneBootContext = {},', + '): Promise<{', + ' runtime: MatrixRuntime;', + ` actor: ${className};`, + ' root: string;', + ' mount: string;', + '}> {', + ' const requestedRoot = readTrimmedString(bootContext.root);', + ' const runtime = services?.runtime ?? new MatrixRuntime({', + ' transport: new InMemoryTransport(new InMemoryBroker(), {', + ` name: ${JSON.stringify(`${mount}-standalone`)},`, + ' ...(requestedRoot ? { root: requestedRoot } : {}),', + ' }),', + ' logging: false,', + ' });', + ' const root = requiredStandaloneRoot(requestedRoot, runtime);', + ` const mount = readTrimmedString(bootContext.mount) ?? ${JSON.stringify(mount)};`, + ` const actor = await runtime.createSupervised(${className}, mount);`, + ' return { runtime, actor, root, mount };', + '}', + '', + ].join('\n'); +} + +function actorFactoryManifest(className: string, mount: string): Record { + return { + kind: 'factory', + export: `createStandalone${className}`, + instance: { + id: `RUNTIME-HOST-${mount.toUpperCase().replace(/[^A-Z0-9]+/g, '-')}`, + class: 'service', + rootKind: 'component', + componentType: className, + autoStart: true, + }, + }; +} + +function actorTestTemplate(className: string): string { + return [ + "import { describe, it } from 'node:test';", + "import { strict as assert } from 'node:assert';", + `import { ${className} } from '../src/${className}.ts';`, + '', + `describe('${className}', () => {`, + " it('declares getStatus command', () => {", + ` assert.ok(Object.prototype.hasOwnProperty.call(${className}.accepts, 'getStatus'));`, + ' });', + '});', + '', + ].join('\n'); +} + +function actorSkillTemplate(name: string, className: string): string { + return [ + '---', + `name: ${name}`, + 'intents:', + ' - action: query', + ' dataType: status', + ' category: system', + '---', + '', + `# ${className}`, + '', + 'Returns actor status for orchestration and health checks.', + '', + ].join('\n'); +} + +export async function actorCommand(name: string, options: IActorCommandOptions = {}): Promise { + const normalizedName = toKebabCase(name); + if (!normalizedName) { + throw new Error('Actor name must not be empty'); + } + + const className = `${toPascalCase(normalizedName)}Actor`; + const mount = normalizedName; + const cwd = path.resolve(options.cwd ?? process.cwd()); + const rootDir = path.resolve(options.outputDir ?? path.join(cwd, normalizedName)); + const matrixActorImport = '@open-matrix/core'; + + fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'examples'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'skills'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'tests'), { recursive: true }); + + const manifest = { + name: `@matrix/${normalizedName}`, + version: '0.1.0', + description: `${className} generated by mx actor`, + runtime: { + language: 'typescript', + entry: 'dist/index.js', + factory: actorFactoryManifest(className, mount), + }, + components: [ + { + type: className, + export: className, + mount, + surface: 'headless', + accepts: ['getStatus'], + emits: ['statusChanged'], + }, + ], + permissions: { fsPolicy: 'none' }, + }; + + const validation = validateMatrixManifest(manifest); + if (!validation.valid) { + throw new Error(`Generated matrix.json failed validation: ${validation.errors.join('; ')}`); + } + + writeJsonFile(path.join(rootDir, 'matrix.json'), manifest); + writeJsonFile(path.join(rootDir, 'package.json'), generatePackageJson(normalizedName, { service: true })); + writeJsonFile(path.join(rootDir, 'tsconfig.json'), generateTsconfig({ service: true })); + fs.writeFileSync( + path.join(rootDir, 'src', `${className}.ts`), + actorTemplate(className, matrixActorImport), + 'utf8' + ); + fs.writeFileSync( + path.join(rootDir, 'src', `create-standalone-${className}.ts`), + actorFactoryTemplate(className, mount, matrixActorImport), + 'utf8' + ); + fs.writeFileSync(path.join(rootDir, 'src', 'index.ts'), actorIndexTemplate(className), 'utf8'); + writeJsonFile(path.join(rootDir, 'examples', 'get-status.mxq'), { + label: 'Get Status', + operation: 'getStatus', + payload: {}, + description: `Runs ${className}.getStatus`, + }); + fs.writeFileSync( + path.join(rootDir, 'skills', `${normalizedName}.skill.md`), + actorSkillTemplate(normalizedName, className), + 'utf8' + ); + fs.writeFileSync(path.join(rootDir, 'tests', `${normalizedName}.spec.ts`), actorTestTemplate(className), 'utf8'); + + console.log(`Created actor scaffold at ${rootDir}`); + return { rootDir, className, mount }; +} diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts new file mode 100644 index 0000000..1d022e6 --- /dev/null +++ b/packages/cli/src/commands/auth.ts @@ -0,0 +1,95 @@ +import { + readRegistryCredential, + removeRegistryCredential, + saveRegistryCredential, + type IRegistryCredential, +} from '../utils/auth-store.js'; + +export interface IAuthContextOptions { + readonly cwd?: string; + readonly registry?: string; + readonly credentialsPath?: string; +} + +export interface ILoginCommandOptions extends IAuthContextOptions { + readonly username: string; + readonly token: string; + readonly email?: string; + readonly expiresAt?: string; +} + +export interface ILoginCommandResult { + readonly registry: string; + readonly username: string; + readonly credentialsPath: string; +} + +export interface ILogoutCommandResult { + readonly registry: string; + readonly removed: boolean; + readonly credentialsPath: string; +} + +export interface IWhoamiCommandResult { + readonly registry: string; + readonly authenticated: boolean; + readonly username?: string; + readonly email?: string; + readonly expiresAt?: string; +} + +export async function loginCommand(options: ILoginCommandOptions): Promise { + if (!options.username.trim()) { + throw new Error('MX_AUTH_REQUIRED: username is required for login'); + } + if (!options.token.trim()) { + throw new Error('MX_AUTH_REQUIRED: token is required for login'); + } + + const cwd = options.cwd ?? process.cwd(); + const credential: IRegistryCredential = { + username: options.username.trim(), + token: options.token.trim(), + email: options.email?.trim() || undefined, + expiresAt: options.expiresAt?.trim() || undefined, + }; + + const { registry, credentialsPath } = saveRegistryCredential(cwd, credential, { + registry: options.registry, + credentialsPath: options.credentialsPath, + }); + + return { + registry, + username: credential.username, + credentialsPath, + }; +} + +export async function logoutCommand(options: IAuthContextOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + return removeRegistryCredential(cwd, { + registry: options.registry, + credentialsPath: options.credentialsPath, + }); +} + +export async function whoamiCommand(options: IAuthContextOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const { registry, credential } = readRegistryCredential(cwd, { + registry: options.registry, + credentialsPath: options.credentialsPath, + }); + + if (!credential) { + return { registry, authenticated: false }; + } + + return { + registry, + authenticated: true, + username: credential.username, + email: credential.email, + expiresAt: credential.expiresAt, + }; +} diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts new file mode 100644 index 0000000..3238bd7 --- /dev/null +++ b/packages/cli/src/commands/config.ts @@ -0,0 +1,93 @@ +import { + readMatrixCliConfig, + resetMatrixCliConfig, + resolvePackageRegistry, + setMatrixCliConfigValue, + type IMatrixCliConfig, +} from '../utils/config-store.js'; + +export interface IConfigCommandOptions { + readonly cwd?: string; + readonly configPath?: string; +} + +export interface IConfigGetResult { + readonly key: string; + readonly value: string | undefined; + readonly source?: 'explicit' | 'env' | 'config' | 'default'; + readonly configPath: string; +} + +export interface IConfigListResult { + readonly configPath: string; + readonly values: IMatrixCliConfig; + readonly effective: { + readonly registry: string; + readonly registrySource: 'explicit' | 'env' | 'config' | 'default'; + }; +} + +export interface IConfigSetResult { + readonly key: string; + readonly value: string; + readonly configPath: string; +} + +export interface IConfigResetResult { + readonly configPath: string; + readonly values: IMatrixCliConfig; +} + +function assertSupportedKey(key: string): 'registry' { + const normalized = key.trim(); + if (normalized !== 'registry') { + throw new Error(`MX_CONFIG_INVALID: unsupported config key "${key}"`); + } + return normalized; +} + +export async function configGetCommand( + key: string, + options: IConfigCommandOptions = {}, +): Promise { + const normalizedKey = assertSupportedKey(key); + const stored = readMatrixCliConfig(options); + const effective = resolvePackageRegistry(undefined, options); + return { + key: normalizedKey, + value: stored.values.registry ?? effective.registry, + source: stored.values.registry ? 'config' : effective.source, + configPath: stored.configPath, + }; +} + +export async function configListCommand(options: IConfigCommandOptions = {}): Promise { + const stored = readMatrixCliConfig(options); + const effective = resolvePackageRegistry(undefined, options); + return { + configPath: stored.configPath, + values: stored.values, + effective: { + registry: effective.registry, + registrySource: effective.source, + }, + }; +} + +export async function configSetCommand( + key: string, + value: string, + options: IConfigCommandOptions = {}, +): Promise { + const normalizedKey = assertSupportedKey(key); + const result = setMatrixCliConfigValue(normalizedKey, value, options); + return { + key: normalizedKey, + value: result.values.registry ?? '', + configPath: result.configPath, + }; +} + +export async function configResetCommand(options: IConfigCommandOptions = {}): Promise { + return resetMatrixCliConfig(options); +} diff --git a/packages/cli/src/commands/cqrs.ts b/packages/cli/src/commands/cqrs.ts new file mode 100644 index 0000000..3268ba7 --- /dev/null +++ b/packages/cli/src/commands/cqrs.ts @@ -0,0 +1,114 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { toKebabCase, toPascalCase, validateMatrixManifest, writeJsonFile, generatePackageJson, generateTsconfig } from '../utils/scaffold.js'; + +export interface ICqrsCommandOptions { + readonly cwd?: string; + readonly outputDir?: string; +} + +export interface ICqrsCommandResult { + readonly rootDir: string; + readonly commandClassName: string; + readonly queryClassName: string; +} + +function actorTemplate(className: string, operation: string, matrixActorImport: string): string { + return [ + `import { MatrixActor } from ${JSON.stringify(matrixActorImport)};`, + '', + `export class ${className} extends MatrixActor {`, + ' static override accepts = {', + ` ${operation}: {},`, + ' };', + '', + ` protected on${operation.charAt(0).toUpperCase()}${operation.slice(1)}(): { ok: boolean } {`, + ' return { ok: true };', + ' }', + '}', + '', + ].join('\n'); +} + +export async function cqrsCommand(name: string, options: ICqrsCommandOptions = {}): Promise { + const normalizedName = toKebabCase(name); + if (!normalizedName) { + throw new Error('cqrs name must not be empty'); + } + + const baseName = toPascalCase(normalizedName); + const commandClassName = `${baseName}CommandActor`; + const queryClassName = `${baseName}QueryActor`; + const cwd = path.resolve(options.cwd ?? process.cwd()); + const rootDir = path.resolve(options.outputDir ?? path.join(cwd, normalizedName)); + const matrixActorImport = '@open-matrix/core'; + + fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'examples'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'skills'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'tests'), { recursive: true }); + + const manifest = { + name: normalizedName, + version: '0.1.0', + description: `${baseName} CQRS package generated by mx cqrs`, + runtime: { language: 'typescript', entry: 'dist/index.js' }, + components: [ + { + type: commandClassName, + export: commandClassName, + mount: `${normalizedName}.command`, + autoStart: true, + }, + { + type: queryClassName, + export: queryClassName, + mount: `${normalizedName}.query`, + autoStart: true, + }, + ], + permissions: { fsPolicy: 'none' }, + }; + + const validation = validateMatrixManifest(manifest); + if (!validation.valid) { + throw new Error(`Generated matrix.json failed validation: ${validation.errors.join('; ')}`); + } + + writeJsonFile(path.join(rootDir, 'matrix.json'), manifest); + writeJsonFile(path.join(rootDir, 'package.json'), generatePackageJson(normalizedName)); + writeJsonFile(path.join(rootDir, 'tsconfig.json'), generateTsconfig()); + fs.writeFileSync( + path.join(rootDir, 'src', `${commandClassName}.ts`), + actorTemplate(commandClassName, 'execute', matrixActorImport), + 'utf8' + ); + fs.writeFileSync( + path.join(rootDir, 'src', `${queryClassName}.ts`), + actorTemplate(queryClassName, 'query', matrixActorImport), + 'utf8' + ); + fs.writeFileSync( + path.join(rootDir, 'src', 'index.ts'), + `export { ${commandClassName} } from './${commandClassName}.js';\nexport { ${queryClassName} } from './${queryClassName}.js';\n`, + 'utf8' + ); + writeJsonFile(path.join(rootDir, 'examples', 'execute.mxq'), { + label: 'Execute command', + operation: 'execute', + payload: {}, + }); + writeJsonFile(path.join(rootDir, 'examples', 'query.mxq'), { + label: 'Query projection', + operation: 'query', + payload: {}, + }); + fs.writeFileSync( + path.join(rootDir, 'skills', `${normalizedName}.skill.md`), + ['---', `name: ${normalizedName}`, 'intents:', ' - action: command', ' operation: execute', '---', '', '# cqrs skill', ''].join('\n'), + 'utf8' + ); + + console.log(`Created cqrs scaffold at ${rootDir}`); + return { rootDir, commandClassName, queryClassName }; +} diff --git a/packages/cli/src/commands/element.ts b/packages/cli/src/commands/element.ts new file mode 100644 index 0000000..4c700c0 --- /dev/null +++ b/packages/cli/src/commands/element.ts @@ -0,0 +1,108 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { toKebabCase, toPascalCase, validateMatrixManifest, writeJsonFile, generatePackageJson, generateTsconfig } from '../utils/scaffold.js'; + +export interface IElementCommandOptions { + readonly cwd?: string; + readonly outputDir?: string; +} + +export interface IElementCommandResult { + readonly rootDir: string; + readonly className: string; + readonly mount: string; +} + +function elementTemplate(className: string, matrixActorImport: string): string { + return [ + `import { MatrixActor } from ${JSON.stringify(matrixActorImport)};`, + '', + `export class ${className} extends MatrixActor {`, + ' static override accepts = {', + " render: {},", + ' };', + '', + ' protected onRender(): { ok: boolean; kind: string } {', + " return { ok: true, kind: 'element' };", + ' }', + '}', + '', + ].join('\n'); +} + +export async function elementCommand(name: string, options: IElementCommandOptions = {}): Promise { + const normalizedName = toKebabCase(name); + if (!normalizedName) { + throw new Error('Element name must not be empty'); + } + + const className = `${toPascalCase(normalizedName)}Element`; + const mount = normalizedName; + const cwd = path.resolve(options.cwd ?? process.cwd()); + const rootDir = path.resolve(options.outputDir ?? path.join(cwd, normalizedName)); + const matrixActorImport = '@open-matrix/core'; + + fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'examples'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'skills'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'tests'), { recursive: true }); + + const manifest = { + name: normalizedName, + version: '0.1.0', + description: `${className} generated by mx element`, + runtime: { language: 'typescript', entry: 'dist/index.js' }, + components: [ + { + type: className, + export: className, + mount, + autoStart: true, + props: { + surface: 'element', + }, + }, + ], + permissions: { fsPolicy: 'none' }, + }; + + const validation = validateMatrixManifest(manifest); + if (!validation.valid) { + throw new Error(`Generated matrix.json failed validation: ${validation.errors.join('; ')}`); + } + + writeJsonFile(path.join(rootDir, 'matrix.json'), manifest); + writeJsonFile(path.join(rootDir, 'package.json'), generatePackageJson(normalizedName)); + writeJsonFile(path.join(rootDir, 'tsconfig.json'), generateTsconfig()); + fs.writeFileSync(path.join(rootDir, 'src', `${className}.ts`), elementTemplate(className, matrixActorImport), 'utf8'); + fs.writeFileSync(path.join(rootDir, 'src', 'index.ts'), `export { ${className} } from './${className}.js';\n`, 'utf8'); + writeJsonFile(path.join(rootDir, 'examples', 'render.mxq'), { + label: 'Render Element', + operation: 'render', + payload: {}, + }); + fs.writeFileSync( + path.join(rootDir, 'skills', `${normalizedName}.skill.md`), + ['---', `name: ${normalizedName}`, 'intents:', ' - action: render', ' operation: render', '---', '', `# ${className}`, ''].join('\n'), + 'utf8' + ); + fs.writeFileSync( + path.join(rootDir, 'tests', `${normalizedName}.spec.ts`), + [ + "import { describe, it } from 'node:test';", + "import { strict as assert } from 'node:assert';", + `import { ${className} } from '../src/${className}.ts';`, + '', + `describe('${className}', () => {`, + " it('declares render command', () => {", + ` assert.ok(Object.prototype.hasOwnProperty.call(${className}.accepts, 'render'));`, + ' });', + '});', + '', + ].join('\n'), + 'utf8' + ); + + console.log(`Created element scaffold at ${rootDir}`); + return { rootDir, className, mount }; +} diff --git a/packages/cli/src/commands/fork.ts b/packages/cli/src/commands/fork.ts new file mode 100644 index 0000000..8f4a2c8 --- /dev/null +++ b/packages/cli/src/commands/fork.ts @@ -0,0 +1,254 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + packageNameToPath, + readVersionFromPackageDir, + resolvePackagesNodeModulesRoot, +} from '../utils/package-store.js'; +import { computeDirectorySha256 } from '../utils/archive.js'; + +type JsonRecord = Record; + +interface IForkedFromRecord { + readonly origin: string; + readonly originVersion: string; + readonly forkedAt: string; + readonly forkedBy: string; + readonly originSha256: string; +} + +interface INameRename { + readonly oldName: string; + readonly newName: string; +} + +export interface IForkCommandOptions { + readonly cwd?: string; + readonly packagesDir?: string; + readonly name?: string; + readonly replaceOrigin?: boolean; + readonly forkedBy?: string; +} + +export interface IForkCommandResult { + readonly sourcePackageName: string; + readonly forkedPackageName: string; + readonly sourceDir: string; + readonly targetDir: string; +} + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +function toPrefix(targetPackageName: string): { readonly mountPrefix: string; readonly typePrefix: string } { + const scoped = targetPackageName.startsWith('@') + ? targetPackageName.slice(1).split('/')[0] + : targetPackageName.split('/')[0]; + const mountPrefix = (scoped || 'fork').replace(/[^a-zA-Z0-9]+/g, '.').replace(/\.+/g, '.').replace(/^\./, '').toLowerCase(); + const typePrefix = mountPrefix + .split('.') + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(''); + return { + mountPrefix: mountPrefix || 'fork', + typePrefix: typePrefix || 'Fork', + }; +} + +function renameType(original: unknown, typePrefix: string): string | undefined { + if (typeof original !== 'string') return undefined; + const trimmed = original.trim(); + if (!trimmed) return undefined; + return trimmed.startsWith(typePrefix) ? trimmed : `${typePrefix}${trimmed}`; +} + +function renameMount(original: unknown, mountPrefix: string): string | undefined { + if (typeof original !== 'string') return undefined; + const trimmed = original.trim(); + if (!trimmed) return undefined; + return trimmed.startsWith(`${mountPrefix}.`) ? trimmed : `${mountPrefix}.${trimmed}`; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function deriveForkName(sourcePackageName: string): string { + if (sourcePackageName.startsWith('@')) { + const [, rawBase = 'package'] = sourcePackageName.split('/'); + return `@fork/${rawBase}`; + } + return `${sourcePackageName}-fork`; +} + +function readJson(filePath: string): JsonRecord { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + const record = asRecord(parsed); + if (!record) { + throw new Error(`Expected JSON object in ${filePath}`); + } + return record; +} + +function writeJson(filePath: string, payload: JsonRecord): void { + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8'); +} + +function rewriteMatrixManifest( + targetDir: string, + targetPackageName: string, + replaceOrigin: boolean, +): ReadonlyArray { + const matrixJsonPath = path.join(targetDir, 'matrix.json'); + if (!fs.existsSync(matrixJsonPath)) { + return []; + } + + const manifest = readJson(matrixJsonPath); + const renames: INameRename[] = []; + manifest.name = targetPackageName; + if (!replaceOrigin && Array.isArray(manifest.components)) { + const { mountPrefix, typePrefix } = toPrefix(targetPackageName); + manifest.components = manifest.components.map((entry) => { + const component = asRecord(entry) ?? {}; + const renamedType = renameType(component.type, typePrefix); + const renamedExport = renameType(component.export, typePrefix); + const renamedMount = renameMount(component.mount, mountPrefix); + if (typeof component.type === 'string' && renamedType && renamedType !== component.type) { + renames.push({ oldName: component.type, newName: renamedType }); + } + if (typeof component.export === 'string' && renamedExport && renamedExport !== component.export) { + renames.push({ oldName: component.export, newName: renamedExport }); + } + return { + ...component, + type: renamedType ?? component.type, + export: renamedExport ?? component.export, + mount: renamedMount ?? component.mount, + }; + }); + } + + writeJson(matrixJsonPath, manifest); + return renames; +} + +function rewritePackageJsonName(targetDir: string, targetPackageName: string): void { + const packageJsonPath = path.join(targetDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return; + } + const packageJson = readJson(packageJsonPath); + packageJson.name = targetPackageName; + writeJson(packageJsonPath, packageJson); +} + +function copyPackageTree(sourceDir: string, targetDir: string): void { + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.cpSync(sourceDir, targetDir, { + recursive: true, + filter: (entry) => { + const base = path.basename(entry); + return base !== '.git' && base !== 'node_modules' && base !== '.forked-from'; + }, + }); +} + +function rewriteSourceExports(targetDir: string, renames: ReadonlyArray): void { + if (renames.length === 0) { + return; + } + + const uniqueRenames = Array.from( + new Map(renames.map((entry) => [`${entry.oldName}=>${entry.newName}`, entry] as const)).values() + ); + + const filesToRewrite: string[] = []; + const queue = [targetDir]; + while (queue.length > 0) { + const current = queue.pop()!; + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.git') { + continue; + } + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) { + queue.push(absolute); + } else if (entry.isFile() && /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(entry.name)) { + filesToRewrite.push(absolute); + } + } + } + + for (const filePath of filesToRewrite) { + let content = fs.readFileSync(filePath, 'utf8'); + let changed = false; + for (const rename of uniqueRenames) { + const pattern = new RegExp(`\\b${escapeRegExp(rename.oldName)}\\b`, 'g'); + if (pattern.test(content)) { + content = content.replace(pattern, rename.newName); + changed = true; + } + } + if (changed) { + fs.writeFileSync(filePath, content, 'utf8'); + } + } +} + +function pruneIfEmpty(dirPath: string): void { + if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { + return; + } + if (fs.readdirSync(dirPath).length === 0) { + fs.rmdirSync(dirPath); + } +} + +export async function forkCommand(sourcePackageName: string, options: IForkCommandOptions = {}): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const nodeModulesRoot = resolvePackagesNodeModulesRoot(cwd, options.packagesDir); + const sourceDir = path.join(nodeModulesRoot, packageNameToPath(sourcePackageName)); + if (!fs.existsSync(sourceDir)) { + throw new Error(`MX_FORK_SOURCE_MISSING: ${sourcePackageName}`); + } + + const targetPackageName = options.name?.trim() || deriveForkName(sourcePackageName); + const targetDir = path.join(nodeModulesRoot, packageNameToPath(targetPackageName)); + if (fs.existsSync(targetDir)) { + throw new Error(`Target package already exists: ${targetPackageName}`); + } + + copyPackageTree(sourceDir, targetDir); + rewritePackageJsonName(targetDir, targetPackageName); + const renames = rewriteMatrixManifest(targetDir, targetPackageName, Boolean(options.replaceOrigin)); + if (!options.replaceOrigin) { + rewriteSourceExports(targetDir, renames); + } + + const record: IForkedFromRecord = { + origin: sourcePackageName, + originVersion: readVersionFromPackageDir(sourceDir), + forkedAt: new Date().toISOString(), + forkedBy: options.forkedBy ?? process.env.USER ?? 'unknown', + originSha256: computeDirectorySha256(sourceDir), + }; + fs.writeFileSync(path.join(targetDir, '.forked-from'), JSON.stringify(record, null, 2), 'utf8'); + + if (options.replaceOrigin && path.resolve(sourceDir) !== path.resolve(targetDir)) { + fs.rmSync(sourceDir, { recursive: true, force: true }); + pruneIfEmpty(path.dirname(sourceDir)); + } + + return { + sourcePackageName, + forkedPackageName: targetPackageName, + sourceDir, + targetDir, + }; +} diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts new file mode 100644 index 0000000..19134a2 --- /dev/null +++ b/packages/cli/src/commands/info.ts @@ -0,0 +1,38 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + packageNameToPath, + readManifestFromPackageDir, + resolvePackagesNodeModulesRoot, +} from '../utils/package-store.js'; + +export interface IInfoCommandOptions { + readonly cwd?: string; + readonly packagesDir?: string; +} + +export interface IInfoCommandResult { + readonly packageName: string; + readonly installPath: string; + readonly hasMatrixJson: boolean; + readonly manifest: Record; +} + +export async function infoCommand(packageName: string, options: IInfoCommandOptions = {}): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const nodeModulesRoot = resolvePackagesNodeModulesRoot(cwd, options.packagesDir); + const installPath = path.join(nodeModulesRoot, packageNameToPath(packageName)); + if (!fs.existsSync(installPath)) { + throw new Error(`Package is not installed: ${packageName}`); + } + + const { packageName: resolvedName, manifest } = readManifestFromPackageDir(installPath); + const hasMatrixJson = fs.existsSync(path.join(installPath, 'matrix.json')); + + return { + packageName: resolvedName, + installPath, + hasMatrixJson, + manifest, + }; +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 0000000..f321ce6 --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,133 @@ +/** + * mx init command implementation. + * + * Creates matrix.json and scaffolds directory structure. + * Implementation: B013 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import { validateMatrixManifest, writeJsonFile } from '../utils/scaffold.js'; + +export interface IInitCommandOptions { + readonly cwd?: string; + readonly name?: string; + readonly version?: string; + readonly description?: string; + readonly yes?: boolean; +} + +export interface IInitCommandResult { + readonly rootDir: string; + readonly manifestPath: string; +} + +function defaultPackageName(cwd: string): string { + return path.basename(cwd) || 'matrix-package'; +} + +async function promptIfNeeded( + question: string, + fallback: string, + enabled: boolean, + rl: readline.Interface +): Promise { + if (!enabled) { + return fallback; + } + + const suffix = fallback.length > 0 ? ` (${fallback})` : ''; + const answer = (await rl.question(`${question}${suffix}: `)).trim(); + return answer.length > 0 ? answer : fallback; +} + +export async function initCommand(options: IInitCommandOptions = {}): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const shouldPrompt = options.yes !== true; + + const rl = readline.createInterface({ input, output }); + try { + const name = await promptIfNeeded( + 'Package name', + options.name ?? defaultPackageName(cwd), + shouldPrompt, + rl + ); + const version = await promptIfNeeded( + 'Version', + options.version ?? '0.1.0', + shouldPrompt, + rl + ); + const description = await promptIfNeeded( + 'Description', + options.description ?? '', + shouldPrompt, + rl + ); + + const manifest = { + name, + version, + description, + runtime: { language: 'typescript', entry: 'dist/index.js' }, + components: [], + permissions: { fsPolicy: 'none' }, + }; + + const validation = validateMatrixManifest(manifest); + if (!validation.valid) { + throw new Error(`Generated matrix.json failed validation: ${validation.errors.join('; ')}`); + } + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.mkdirSync(path.join(cwd, 'examples'), { recursive: true }); + fs.mkdirSync(path.join(cwd, 'skills'), { recursive: true }); + fs.mkdirSync(path.join(cwd, 'tests'), { recursive: true }); + + const manifestPath = path.join(cwd, 'matrix.json'); + writeJsonFile(manifestPath, manifest); + + const srcIndex = path.join(cwd, 'src', 'index.ts'); + if (!fs.existsSync(srcIndex)) { + fs.writeFileSync( + srcIndex, + [ + '// Entry point for your Matrix package.', + '// Add exports for your actors/services here.', + 'export {};', + '', + ].join('\n'), + 'utf8' + ); + } + + const testScaffold = path.join(cwd, 'tests', 'package.spec.ts'); + if (!fs.existsSync(testScaffold)) { + fs.writeFileSync( + testScaffold, + [ + "import { describe, it } from 'node:test';", + "import { strict as assert } from 'node:assert';", + '', + "describe('package scaffold', () => {", + " it('has a valid matrix manifest placeholder', () => {", + " assert.equal(true, true);", + ' });', + '});', + '', + ].join('\n'), + 'utf8' + ); + } + + console.log('Created matrix.json'); + console.log('Created src/, examples/, skills/, and tests/'); + + return { rootDir: cwd, manifestPath }; + } finally { + rl.close(); + } +} diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts new file mode 100644 index 0000000..3c1d827 --- /dev/null +++ b/packages/cli/src/commands/install.ts @@ -0,0 +1,676 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + packageNameToPath, + resolveRegistryRoot, + readManifestFromPackageDir, + readVersionFromPackageDir, + resolvePackagesNodeModulesRoot, + validateResolvedManifest, +} from '../utils/package-store.js'; +import { extractTarball } from '../utils/archive.js'; +import { resolveRegistryTarball } from '../utils/registry-store.js'; +import { + MATRIX_NPM_REGISTRY_SCOPES, + npmPackageSpecifier, + prepareNpmRegistryTarball, +} from '../utils/npm-registry-client.js'; +import { resolvePackageRegistry } from '../utils/config-store.js'; +import { readRegistryCredential } from '../utils/auth-store.js'; + +export interface IInstallCommandOptions { + readonly cwd?: string; + readonly packagesDir?: string; + readonly registryDir?: string; + readonly registry?: string; + readonly credentialsPath?: string; + readonly force?: boolean; +} + +export interface IInstallCommandResult { + readonly packageName: string; + readonly source: string; + readonly version: string; + readonly targetDir: string; + readonly lifecyclePhases: readonly string[]; +} + +type JsonRecord = Record; +type LifecyclePhase = 'validate' | 'migrate' | 'seed' | 'verify'; + +interface DependencyInstallOptions { + readonly install: boolean; + readonly registry?: string; + readonly credentialsPath?: string; + readonly cwd: string; +} + +interface LifecycleHook { + readonly script: string; + readonly description?: string; +} + +interface InstallLifecycle { + readonly validate?: LifecycleHook; + readonly migrate?: LifecycleHook; + readonly seed?: LifecycleHook; + readonly verify?: LifecycleHook; +} + +interface RuntimeNpmDependencies { + readonly dependencies?: JsonRecord; + readonly optionalDependencies?: JsonRecord; +} + +function parsePackageSpecifier(specifier: string): { packageName: string; version?: string } { + if (specifier.startsWith('@')) { + const slashIndex = specifier.indexOf('/'); + if (slashIndex === -1) { + return { packageName: specifier }; + } + const atIndex = specifier.lastIndexOf('@'); + if (atIndex > slashIndex) { + return { + packageName: specifier.slice(0, atIndex), + version: specifier.slice(atIndex + 1), + }; + } + return { packageName: specifier }; + } + + const atIndex = specifier.lastIndexOf('@'); + if (atIndex > 0) { + return { + packageName: specifier.slice(0, atIndex), + version: specifier.slice(atIndex + 1), + }; + } + + return { packageName: specifier }; +} + +function locateExtractedPackageDir(extractedRoot: string): string { + const packageDir = path.join(extractedRoot, 'package'); + if (fs.existsSync(path.join(packageDir, 'matrix.json')) || fs.existsSync(path.join(packageDir, 'package.json'))) { + return packageDir; + } + if (fs.existsSync(path.join(extractedRoot, 'matrix.json')) || fs.existsSync(path.join(extractedRoot, 'package.json'))) { + return extractedRoot; + } + + const entries = fs.readdirSync(extractedRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const candidate = path.join(extractedRoot, entry.name); + if (fs.existsSync(path.join(candidate, 'matrix.json')) || fs.existsSync(path.join(candidate, 'package.json'))) { + return candidate; + } + } + + throw new Error(`Unable to locate package manifest inside extracted tarball: ${extractedRoot}`); +} + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +function readLifecycleHook(value: unknown, phase: LifecyclePhase): LifecycleHook | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + + const script = typeof record.script === 'string' ? record.script.trim() : ''; + if (!script) { + throw new Error(`install.${phase}.script must be a non-empty string`); + } + + const description = typeof record.description === 'string' ? record.description.trim() : ''; + return description ? { script, description } : { script }; +} + +function readInstallLifecycle(manifest: JsonRecord): InstallLifecycle { + const install = asRecord(manifest.install); + if (!install) { + return {}; + } + + return { + validate: readLifecycleHook(install.validate, 'validate'), + migrate: readLifecycleHook(install.migrate, 'migrate'), + seed: readLifecycleHook(install.seed, 'seed'), + verify: readLifecycleHook(install.verify, 'verify'), + }; +} + +function readPackageJson(packageDir: string): JsonRecord | null { + const packageJsonPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return null; + } + + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as unknown; + return asRecord(parsed); +} + +function hasDependencyEntries(value: unknown): boolean { + const record = asRecord(value); + return Boolean(record && Object.keys(record).length > 0); +} + +function readRuntimeNpmDependencies(packageDir: string): RuntimeNpmDependencies | null { + const packageJson = readPackageJson(packageDir); + if (!packageJson) { + return null; + } + const dependencies = hasDependencyEntries(packageJson.dependencies) + ? asRecord(packageJson.dependencies) ?? undefined + : undefined; + const optionalDependencies = hasDependencyEntries(packageJson.optionalDependencies) + ? asRecord(packageJson.optionalDependencies) ?? undefined + : undefined; + + if (!dependencies && !optionalDependencies) { + return null; + } + return { + ...(dependencies ? { dependencies } : {}), + ...(optionalDependencies ? { optionalDependencies } : {}), + }; +} + +function registryAuthConfigKey(registryUrl: string): string { + const parsed = new URL(registryUrl); + const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`; + return `//${parsed.host}${pathname}:_authToken`; +} + +export function dependencyInstallNpmrcLines(params: { + readonly registry?: string; + readonly token?: string; +}): readonly string[] { + const lines = [ + 'registry=https://registry.npmjs.org/', + ]; + if (params.registry) { + for (const scope of MATRIX_NPM_REGISTRY_SCOPES) { + lines.push(`${scope}:registry=${params.registry}`); + } + if (params.token) { + lines.push('always-auth=true'); + lines.push(`${registryAuthConfigKey(params.registry)}=${params.token}`); + } + } + return lines; +} + +function writeDependencyInstallNpmrc(params: { + readonly tempDir: string; + readonly registry?: string; + readonly token?: string; +}): string { + const npmrcPath = path.join(params.tempDir, '.npmrc'); + const lines = dependencyInstallNpmrcLines(params); + fs.writeFileSync(npmrcPath, `${lines.join('\n')}\n`, 'utf8'); + return npmrcPath; +} + +function writeEmptyNpmGlobalConfig(tempDir: string): string { + const npmrcPath = path.join(tempDir, 'empty-global-npmrc'); + fs.writeFileSync(npmrcPath, '', 'utf8'); + return npmrcPath; +} + +function runtimeDependencyPackageName(packageName: string): string { + const normalized = packageName.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+/, ''); + return `mx-runtime-deps-${normalized || 'package'}`; +} + +function installRuntimeNpmDependencies(params: { + readonly packageName: string; + readonly packageDir: string; + readonly dependencyOptions: DependencyInstallOptions; + readonly lifecyclePhases: string[]; +}): void { + const runtimeDependencies = readRuntimeNpmDependencies(params.packageDir); + if (!params.dependencyOptions.install || !runtimeDependencies) { + return; + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-install-deps-')); + const packageJsonPath = path.join(params.packageDir, 'package.json'); + const originalPackageJson = fs.readFileSync(packageJsonPath, 'utf8'); + try { + fs.writeFileSync( + packageJsonPath, + `${JSON.stringify({ + private: true, + name: runtimeDependencyPackageName(params.packageName), + version: '0.0.0', + ...runtimeDependencies, + }, null, 2)}\n`, + 'utf8', + ); + + const credential = params.dependencyOptions.registry + ? readRegistryCredential(params.dependencyOptions.cwd, { + registry: params.dependencyOptions.registry, + credentialsPath: params.dependencyOptions.credentialsPath, + }).credential + : null; + const userConfig = writeDependencyInstallNpmrc({ + tempDir, + registry: params.dependencyOptions.registry, + token: credential?.token, + }); + const globalConfig = writeEmptyNpmGlobalConfig(tempDir); + + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const result = spawnSync(npmCommand, [ + 'install', + '--omit=dev', + '--omit=peer', + '--no-audit', + '--fund=false', + '--package-lock=false', + '--userconfig', + userConfig, + '--globalconfig', + globalConfig, + ], { + cwd: params.packageDir, + encoding: 'utf8', + env: { + ...process.env, + npm_config_cache: path.join(tempDir, '.npm-cache'), + NPM_CONFIG_USERCONFIG: userConfig, + npm_config_userconfig: userConfig, + NPM_CONFIG_GLOBALCONFIG: globalConfig, + npm_config_globalconfig: globalConfig, + }, + windowsHide: true, + }); + + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + const stdout = result.stdout?.trim(); + const output = [stderr, stdout].filter(Boolean).join('\n'); + throw new Error( + `${params.packageName} npm dependency install failed` + + (output ? `\n${output}` : '') + ); + } + + params.lifecyclePhases.push('dependencies'); + } finally { + fs.writeFileSync(packageJsonPath, originalPackageJson, 'utf8'); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function runInstallLifecycle(params: { + lifecycle: InstallLifecycle; + packageName: string; + version: string; + packageDir: string; + packagesRoot: string; + installSource: string; + lifecyclePhases: string[]; +}): void { + const phases: LifecyclePhase[] = ['validate', 'migrate', 'seed', 'verify']; + + for (const phase of phases) { + const hook = params.lifecycle[phase]; + if (!hook) continue; + + const scriptPath = path.join(params.packageDir, hook.script); + if (!fs.existsSync(scriptPath)) { + throw new Error(`${params.packageName} install.${phase}.script does not exist (${hook.script})`); + } + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: params.packageDir, + encoding: 'utf8', + env: { + ...process.env, + MATRIX_PACKAGES_DIR: params.packagesRoot, + MATRIX_PACKAGE_DIR: params.packageDir, + MATRIX_PACKAGE_NAME: params.packageName, + MATRIX_PACKAGE_VERSION: params.version, + MATRIX_INSTALL_SOURCE: params.installSource, + MATRIX_INSTALL_PHASE: phase, + }, + windowsHide: true, + }); + + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + const stdout = result.stdout?.trim(); + const output = [stderr, stdout].filter(Boolean).join('\n'); + throw new Error( + `${params.packageName} install.${phase} failed` + + (output ? `\n${output}` : '') + ); + } + + params.lifecyclePhases.push(phase); + } +} + +function installFromDirectory( + sourceDir: string, + packagesRoot: string, + nodeModulesRoot: string, + force: boolean, + installSource: string, + dependencyOptions: DependencyInstallOptions, +): { packageName: string; version: string; targetDir: string; lifecyclePhases: string[] } { + const { packageName, manifest } = readManifestFromPackageDir(sourceDir); + validateResolvedManifest(packageName, manifest); + const version = readVersionFromPackageDir(sourceDir); + const lifecycle = readInstallLifecycle(manifest); + + const targetDir = path.join(nodeModulesRoot, packageNameToPath(packageName)); + const stageDir = `${targetDir}.installing-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + const backupDir = `${targetDir}.backup-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + const hadExistingTarget = fs.existsSync(targetDir); + if (hadExistingTarget && !force) { + throw new Error(`Package already installed: ${packageName} (${targetDir})`); + } + + fs.mkdirSync(path.dirname(stageDir), { recursive: true }); + fs.cpSync(sourceDir, stageDir, { recursive: true }); + + const lifecyclePhases: string[] = []; + try { + if (hadExistingTarget) { + fs.renameSync(targetDir, backupDir); + } + fs.renameSync(stageDir, targetDir); + installRuntimeNpmDependencies({ + packageName, + packageDir: targetDir, + dependencyOptions, + lifecyclePhases, + }); + runInstallLifecycle({ + lifecycle, + packageName, + version, + packageDir: targetDir, + packagesRoot, + installSource, + lifecyclePhases, + }); + if (hadExistingTarget && fs.existsSync(backupDir)) { + fs.rmSync(backupDir, { recursive: true, force: true }); + } + } catch (error) { + if (fs.existsSync(stageDir)) { + fs.rmSync(stageDir, { recursive: true, force: true }); + } + if (fs.existsSync(targetDir)) { + fs.rmSync(targetDir, { recursive: true, force: true }); + } + if (hadExistingTarget && fs.existsSync(backupDir)) { + fs.renameSync(backupDir, targetDir); + } + throw error; + } + + return { + packageName, + version, + targetDir, + lifecyclePhases, + }; +} + +function runtimeEntryFromManifest(manifest: JsonRecord): string | null { + const runtime = asRecord(manifest.runtime); + const entry = typeof runtime?.entry === 'string' ? runtime.entry.trim() : ''; + return entry.length > 0 ? entry : null; +} + +function hasWebappSection(manifest: JsonRecord): boolean { + return asRecord(manifest.webapp) !== null; +} + +function readWebappConfig(manifest: JsonRecord): { distDir: string; entryFile: string } | null { + const webapp = asRecord(manifest.webapp); + if (!webapp) return null; + + const distDir = typeof webapp.distDir === 'string' && webapp.distDir.trim().length > 0 + ? webapp.distDir.trim() + : 'dist'; + const entryFile = typeof webapp.entry === 'string' && webapp.entry.trim().length > 0 + ? webapp.entry.trim() + : 'index.html'; + return { distDir, entryFile }; +} + +function walkFiles(rootDir: string): string[] { + if (!fs.existsSync(rootDir)) { + return []; + } + + const results: string[] = []; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop()!; + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile()) { + results.push(fullPath); + } + } + } + return results; +} + +async function maybeBuildLocalTypescriptPackage(sourceDir: string): Promise { + const { manifest } = readManifestFromPackageDir(sourceDir); + const runtimeEntry = runtimeEntryFromManifest(manifest); + const webapp = readWebappConfig(manifest); + if (!runtimeEntry && !webapp) { + return sourceDir; + } + + const sourceEntry = runtimeEntry ? path.join(sourceDir, runtimeEntry) : null; + const webappEntryPath = webapp ? path.join(sourceDir, webapp.distDir, webapp.entryFile) : null; + if ((sourceEntry && fs.existsSync(sourceEntry)) && (!webappEntryPath || fs.existsSync(webappEntryPath))) { + return sourceDir; + } + + const srcDir = path.join(sourceDir, 'src'); + if (!fs.existsSync(srcDir)) { + return sourceDir; + } + + const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-install-build-')); + fs.cpSync(sourceDir, stagingDir, { recursive: true }); + + const stagedSrcDir = path.join(stagingDir, 'src'); + const stagedRuntimeEntry = runtimeEntry ? path.join(stagingDir, runtimeEntry) : null; + const stagedDistDir = stagedRuntimeEntry + ? path.dirname(stagedRuntimeEntry) + : path.join(stagingDir, webapp?.distDir ?? 'dist'); + fs.mkdirSync(stagedDistDir, { recursive: true }); + + const ts = await import('typescript'); + for (const filePath of walkFiles(stagedSrcDir)) { + const relativePath = path.relative(stagedSrcDir, filePath); + const extension = path.extname(filePath).toLowerCase(); + const outputPath = path.join( + stagedDistDir, + relativePath.replace(/\.(mts|cts|tsx|ts)$/i, '.js'), + ); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + + if (extension === '.ts' || extension === '.tsx' || extension === '.mts' || extension === '.cts') { + const source = fs.readFileSync(filePath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + esModuleInterop: true, + sourceMap: false, + inlineSourceMap: false, + declaration: false, + }, + fileName: filePath, + }); + fs.writeFileSync(outputPath, transpiled.outputText, 'utf8'); + continue; + } + + fs.copyFileSync(filePath, path.join(stagedDistDir, relativePath)); + } + + if (webapp) { + const sourceHtmlPath = path.join(stagingDir, webapp.entryFile); + const stagedHtmlPath = path.join(stagingDir, webapp.distDir, webapp.entryFile); + if (fs.existsSync(sourceHtmlPath) && !fs.existsSync(stagedHtmlPath)) { + let html = fs.readFileSync(sourceHtmlPath, 'utf8'); + html = html.replace( + /(]+src=["'])\.\/src\/([^"']+)\.(?:ts|tsx|mts|cts)(["'][^>]*>)/g, + '$1./$2.js$3', + ); + fs.mkdirSync(path.dirname(stagedHtmlPath), { recursive: true }); + fs.writeFileSync(stagedHtmlPath, html, 'utf8'); + } + } + + return stagingDir; +} + +export async function installCommand(packagePath: string, options: IInstallCommandOptions = {}): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const packagesRoot = path.resolve(cwd, options.packagesDir ?? path.join(os.homedir(), '.matrix', 'packages')); + const nodeModulesRoot = resolvePackagesNodeModulesRoot(cwd, options.packagesDir); + fs.mkdirSync(nodeModulesRoot, { recursive: true }); + + const normalizedPackagePath = packagePath.trim(); + if (!normalizedPackagePath) { + throw new Error('Install source is required'); + } + const resolvedPath = path.resolve(cwd, normalizedPackagePath); + const force = Boolean(options.force); + + if (fs.existsSync(resolvedPath)) { + if (fs.statSync(resolvedPath).isDirectory()) { + const preparedSourceDir = await maybeBuildLocalTypescriptPackage(resolvedPath); + try { + const installed = installFromDirectory(preparedSourceDir, packagesRoot, nodeModulesRoot, force, resolvedPath, { + install: false, + cwd, + }); + return { + packageName: installed.packageName, + source: resolvedPath, + version: installed.version, + targetDir: installed.targetDir, + lifecyclePhases: installed.lifecyclePhases, + }; + } finally { + if (preparedSourceDir !== resolvedPath) { + fs.rmSync(preparedSourceDir, { recursive: true, force: true }); + } + } + } + + if (resolvedPath.endsWith('.tar.gz') || resolvedPath.endsWith('.tgz')) { + const extractRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-install-tarball-')); + try { + extractTarball(resolvedPath, extractRoot); + const sourceDir = locateExtractedPackageDir(extractRoot); + const installed = installFromDirectory(sourceDir, packagesRoot, nodeModulesRoot, force, resolvedPath, { + install: true, + registry: resolvePackageRegistry(options.registry, { cwd }).registry, + credentialsPath: options.credentialsPath, + cwd, + }); + return { + packageName: installed.packageName, + source: resolvedPath, + version: installed.version, + targetDir: installed.targetDir, + lifecyclePhases: installed.lifecyclePhases, + }; + } finally { + fs.rmSync(extractRoot, { recursive: true, force: true }); + } + } + + throw new Error(`Unsupported install source: ${resolvedPath}`); + } + + const { packageName, version } = parsePackageSpecifier(normalizedPackagePath); + + if (options.registry && options.registryDir) { + throw new Error('MX_REGISTRY_INVALID: use either --registry or --registry-dir, not both'); + } + + if (!options.registryDir || options.registry) { + const registry = resolvePackageRegistry(options.registry, { cwd }).registry; + const specifier = npmPackageSpecifier(packageName, version); + const fetched = await prepareNpmRegistryTarball( + specifier, + registry, + cwd, + options.credentialsPath, + ); + const extractRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-install-npm-extract-')); + try { + extractTarball(fetched.tarballPath, extractRoot); + const sourceDir = locateExtractedPackageDir(extractRoot); + const installed = installFromDirectory(sourceDir, packagesRoot, nodeModulesRoot, force, specifier, { + install: true, + registry, + credentialsPath: options.credentialsPath, + cwd, + }); + return { + packageName: installed.packageName, + source: `${installed.packageName}@${installed.version}`, + version: installed.version, + targetDir: installed.targetDir, + lifecyclePhases: installed.lifecyclePhases, + }; + } finally { + fs.rmSync(extractRoot, { recursive: true, force: true }); + fs.rmSync(fetched.cleanupDir, { recursive: true, force: true }); + } + } + + const registryRoot = resolveRegistryRoot(cwd, options.registryDir); + const resolved = resolveRegistryTarball(registryRoot, packageName, version); + const extractRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-install-registry-')); + try { + extractTarball(resolved.tarballPath, extractRoot); + const sourceDir = locateExtractedPackageDir(extractRoot); + const installed = installFromDirectory(sourceDir, packagesRoot, nodeModulesRoot, force, `${packageName}@${resolved.version}`, { + install: true, + registry: resolvePackageRegistry(undefined, { cwd }).registry, + cwd, + }); + return { + packageName: installed.packageName, + source: `${packageName}@${resolved.version}`, + version: installed.version, + targetDir: installed.targetDir, + lifecyclePhases: installed.lifecyclePhases, + }; + } finally { + fs.rmSync(extractRoot, { recursive: true, force: true }); + } +} diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts new file mode 100644 index 0000000..8670193 --- /dev/null +++ b/packages/cli/src/commands/list.ts @@ -0,0 +1,19 @@ +import * as path from 'node:path'; +import { listInstalledPackageNames, resolvePackagesNodeModulesRoot } from '../utils/package-store.js'; + +export interface IListCommandOptions { + readonly cwd?: string; + readonly packagesDir?: string; +} + +export interface IListCommandResult { + readonly packages: string[]; +} + +export async function listCommand(options: IListCommandOptions = {}): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const nodeModulesRoot = resolvePackagesNodeModulesRoot(cwd, options.packagesDir); + return { + packages: listInstalledPackageNames(nodeModulesRoot), + }; +} diff --git a/packages/cli/src/commands/outdated.ts b/packages/cli/src/commands/outdated.ts new file mode 100644 index 0000000..ff23348 --- /dev/null +++ b/packages/cli/src/commands/outdated.ts @@ -0,0 +1,91 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + listInstalledPackageNames, + packageNameToPath, + resolvePackagesNodeModulesRoot, + resolveRegistryRoot, +} from '../utils/package-store.js'; +import { getLatestRegistryVersion } from '../utils/registry-store.js'; +import { compareSemver } from '../utils/versioning.js'; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +function readForkRecord(forkRecordPath: string): { + readonly origin: string; + readonly originVersion: string; +} | null { + if (!fs.existsSync(forkRecordPath)) { + return null; + } + const parsed = JSON.parse(fs.readFileSync(forkRecordPath, 'utf8')) as unknown; + const record = asRecord(parsed); + const origin = typeof record?.origin === 'string' ? record.origin.trim() : ''; + const originVersion = typeof record?.originVersion === 'string' ? record.originVersion.trim() : ''; + if (!origin || !originVersion) { + return null; + } + return { origin, originVersion }; +} + +export interface IOutdatedCommandOptions { + readonly cwd?: string; + readonly packagesDir?: string; + readonly registryDir?: string; +} + +export interface IOutdatedEntry { + readonly packageName: string; + readonly origin: string; + readonly localOriginVersion: string; + readonly latestOriginVersion: string | null; + readonly status: 'up-to-date' | 'outdated' | 'missing-upstream'; +} + +export interface IOutdatedCommandResult { + readonly entries: ReadonlyArray; +} + +export async function outdatedCommand(options: IOutdatedCommandOptions = {}): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const nodeModulesRoot = resolvePackagesNodeModulesRoot(cwd, options.packagesDir); + const registryRoot = resolveRegistryRoot(cwd, options.registryDir); + const entries: IOutdatedEntry[] = []; + + for (const packageName of listInstalledPackageNames(nodeModulesRoot)) { + const packageDir = path.join(nodeModulesRoot, packageNameToPath(packageName)); + const forkRecord = readForkRecord(path.join(packageDir, '.forked-from')); + if (!forkRecord) { + continue; + } + + const latestOriginVersion = getLatestRegistryVersion(registryRoot, forkRecord.origin); + if (!latestOriginVersion) { + entries.push({ + packageName, + origin: forkRecord.origin, + localOriginVersion: forkRecord.originVersion, + latestOriginVersion: null, + status: 'missing-upstream', + }); + continue; + } + + entries.push({ + packageName, + origin: forkRecord.origin, + localOriginVersion: forkRecord.originVersion, + latestOriginVersion, + status: compareSemver(latestOriginVersion, forkRecord.originVersion) > 0 ? 'outdated' : 'up-to-date', + }); + } + + return { entries }; +} diff --git a/packages/cli/src/commands/pack.ts b/packages/cli/src/commands/pack.ts new file mode 100644 index 0000000..fbbecea --- /dev/null +++ b/packages/cli/src/commands/pack.ts @@ -0,0 +1,65 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createTarball } from '../utils/archive.js'; +import { + readManifestFromPackageDir, + readVersionFromPackageDir, + validateResolvedManifest, +} from '../utils/package-store.js'; + +export interface IPackCommandOptions { + readonly cwd?: string; + readonly outDir?: string; + readonly buildFirst?: boolean; +} + +export interface IPackCommandResult { + readonly packageName: string; + readonly version: string; + readonly tarballPath: string; + readonly sourceDir: string; +} + +function packageFileName(packageName: string, version: string): string { + const normalized = packageName.replace(/^@/, '').split('/').join('-'); + return `${normalized}-${version}.tar.gz`; +} + +function runBuild(sourceDir: string): void { + const result = spawnSync('npm', ['run', 'build'], { + cwd: sourceDir, + encoding: 'utf8', + windowsHide: true, + }); + if (result.status !== 0) { + throw new Error(`Build failed: ${result.stderr || result.stdout || `exit ${result.status ?? 'unknown'}`}`); + } +} + +export async function packCommand(options: IPackCommandOptions = {}): Promise { + const sourceDir = path.resolve(options.cwd ?? process.cwd()); + if (!fs.existsSync(sourceDir)) { + throw new Error(`Package directory does not exist: ${sourceDir}`); + } + + const { packageName, manifest } = readManifestFromPackageDir(sourceDir); + validateResolvedManifest(packageName, manifest); + const version = readVersionFromPackageDir(sourceDir); + + if (options.buildFirst) { + runBuild(sourceDir); + } + + const outDir = path.resolve(sourceDir, options.outDir ?? '.mx-pack'); + fs.mkdirSync(outDir, { recursive: true }); + const tarballPath = path.join(outDir, packageFileName(packageName, version)); + createTarball(sourceDir, tarballPath); + + return { + packageName, + version, + tarballPath, + sourceDir, + }; +} diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts new file mode 100644 index 0000000..19133cf --- /dev/null +++ b/packages/cli/src/commands/publish.ts @@ -0,0 +1,173 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { packCommand } from './pack.js'; +import { readRegistryCredential } from '../utils/auth-store.js'; +import { + readManifestFromPackageDir, + readVersionFromPackageDir, + resolveRegistryRoot, + validateResolvedManifest, +} from '../utils/package-store.js'; +import { publishRegistryVersion } from '../utils/registry-store.js'; +import { + packNpmPublishTarball, + publishNpmTarball, +} from '../utils/npm-registry-client.js'; +import { resolvePackageRegistry } from '../utils/config-store.js'; +import { loadMatrixServiceManifest } from '../utils/service-manifest.js'; +import { extractDiscoveryMetadata, type IPackageDiscoveryMetadata } from '../utils/discovery-extractor.js'; +import { publishPackageDiscoveryMetadata } from '../utils/discovery-index.js'; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +export interface IPublishCommandOptions { + readonly cwd?: string; + readonly registryDir?: string; + readonly registry?: string; + readonly credentialsPath?: string; + readonly dryRun?: boolean; +} + +export interface IPublishCommandResult { + readonly packageName: string; + readonly version: string; + readonly tarballPath: string; + readonly registryTarballPath?: string; + readonly registryUrl?: string; + readonly registryRoot: string; + readonly discoveryMetadata?: IPackageDiscoveryMetadata; + readonly dryRun: boolean; +} + +function ensurePublishPreflight(packageDir: string): { packageName: string; manifest: JsonRecord } { + const { packageName, manifest } = readManifestFromPackageDir(packageDir); + validateResolvedManifest(packageName, manifest); + + const runtime = asRecord(manifest.runtime); + const runtimeEntry = typeof runtime?.entry === 'string' ? runtime.entry.trim() : ''; + if (!runtimeEntry) { + throw new Error('MX_MANIFEST_INVALID: runtime.entry is required'); + } + if (!fs.existsSync(path.join(packageDir, runtimeEntry))) { + throw new Error(`MX_MANIFEST_INVALID: runtime.entry does not exist (${runtimeEntry})`); + } + + const hasComponents = Array.isArray(manifest.components) && manifest.components.length > 0; + const root = asRecord(manifest.root); + const hasRoot = typeof root?.type === 'string' + && root.type.trim().length > 0 + && typeof root?.export === 'string' + && root.export.trim().length > 0; + const hasServiceFactory = asRecord(runtime?.factory) !== null; + if (hasServiceFactory) { + loadMatrixServiceManifest(packageDir); + } + const webapp = asRecord(manifest.webapp); + const webappDistDir = typeof webapp?.distDir === 'string' ? webapp.distDir.trim() : ''; + const webappEntry = typeof webapp?.entry === 'string' ? webapp.entry.trim() : ''; + const hasWebapp = Boolean(webapp && webappDistDir && webappEntry); + if (hasWebapp && !fs.existsSync(path.join(packageDir, webappDistDir, webappEntry))) { + throw new Error(`MX_MANIFEST_INVALID: webapp entry does not exist (${path.join(webappDistDir, webappEntry)})`); + } + + if (!hasComponents && !hasWebapp && !hasRoot && !hasServiceFactory) { + throw new Error('MX_MANIFEST_INVALID: declare a root actor, component, webapp, or matrix.json runtime.factory'); + } + + return { packageName, manifest }; +} + +export async function publishCommand(options: IPublishCommandOptions = {}): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const registryRoot = resolveRegistryRoot(cwd, options.registryDir); + if (options.registry && options.registryDir) { + throw new Error('MX_REGISTRY_INVALID: use either --registry or --registry-dir, not both'); + } + const registryUrl = options.registryDir && !options.registry + ? undefined + : resolvePackageRegistry(options.registry, { cwd }).registry; + + const auth = readRegistryCredential(cwd, { + ...(registryUrl ? { registry: registryUrl } : {}), + credentialsPath: options.credentialsPath, + }); + if (!auth.credential) { + throw new Error('MX_AUTH_REQUIRED: run `mx login` before publish'); + } + + const { packageName } = ensurePublishPreflight(cwd); + const version = readVersionFromPackageDir(cwd); + const discoveryMetadata = extractDiscoveryMetadata(cwd); + const packed = registryUrl + ? await packNpmPublishTarball(cwd, path.join(cwd, '.mx-pack', 'npm')) + : await packCommand({ + cwd, + outDir: '.mx-pack', + }); + + if (options.dryRun) { + return { + packageName, + version, + tarballPath: packed.tarballPath, + ...(registryUrl ? { registryUrl } : {}), + registryRoot, + discoveryMetadata, + dryRun: true, + }; + } + + if (registryUrl) { + const result = await publishNpmTarball( + packed.tarballPath, + registryUrl, + cwd, + options.credentialsPath, + ); + if (result.exitCode !== 0) { + const output = [result.stderr, result.stdout] + .filter((value) => typeof value === 'string' && value.trim().length > 0) + .join('\n') + .trim(); + throw new Error( + `MX_REGISTRY_PUBLISH_FAILED: failed to publish ${packageName}@${version} to ${registryUrl}` + + (output ? `\n${output}` : ''), + ); + } + return { + packageName, + version, + tarballPath: packed.tarballPath, + registryUrl, + registryRoot, + discoveryMetadata, + dryRun: false, + }; + } + + const published = publishRegistryVersion( + registryRoot, + packageName, + version, + packed.tarballPath, + auth.credential.username, + ); + publishPackageDiscoveryMetadata(registryRoot, discoveryMetadata, auth.credential.username); + + return { + packageName, + version, + tarballPath: packed.tarballPath, + registryTarballPath: published.registryTarballPath, + registryRoot, + discoveryMetadata, + dryRun: false, + }; +} diff --git a/packages/cli/src/commands/registry.ts b/packages/cli/src/commands/registry.ts new file mode 100644 index 0000000..2790bf4 --- /dev/null +++ b/packages/cli/src/commands/registry.ts @@ -0,0 +1,312 @@ +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { loginCommand } from './auth.js'; +import { configSetCommand } from './config.js'; +import { readRegistryCredential } from '../utils/auth-store.js'; +import { resolvePackageRegistry } from '../utils/config-store.js'; +import { MATRIX_NPM_REGISTRY_SCOPES } from '../utils/npm-registry-client.js'; + +export interface IRegistryCommandOptions { + readonly cwd?: string; + readonly registry?: string; + readonly username?: string; + readonly token?: string; + readonly email?: string; + readonly expiresAt?: string; + readonly credentialsPath?: string; + readonly configPath?: string; + readonly timeoutMs?: number; +} + +export interface IRegistryUseResult { + readonly ok: true; + readonly profile: string; + readonly registry: string; + readonly configPath: string; +} + +export interface IRegistryLoginResult { + readonly ok: true; + readonly registry: string; + readonly username: string; + readonly credentialsPath: string; +} + +export interface IRegistryDoctorResult { + readonly ok: boolean; + readonly mode: 'registry-doctor'; + readonly registry: { + readonly url: string; + readonly source: string; + readonly reachable: boolean; + readonly status?: number; + readonly error?: string; + }; + readonly credentials: { + readonly found: boolean; + readonly path: string; + readonly username?: string; + readonly authChecked: boolean; + readonly authOk?: boolean; + readonly whoami?: string; + readonly error?: string; + }; + readonly npm: { + readonly userConfig: string; + readonly scopeResolution: Record; + readonly allScopesMatch: boolean; + readonly poisonedUserConfigIgnored: boolean; + }; +} + +const LOCAL_GITEA_REGISTRY = 'http://127.0.0.1:3000/api/packages/open-matrix/npm/'; + +export async function registryUseCommand( + profile: string, + options: IRegistryCommandOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const registry = registryForProfile(profile, options.registry, cwd, options.configPath); + const result = await configSetCommand('registry', registry, { + cwd, + configPath: options.configPath, + }); + return { + ok: true, + profile, + registry: result.value, + configPath: result.configPath, + }; +} + +export async function registryLoginCommand(options: IRegistryCommandOptions = {}): Promise { + if (!options.username || !options.token) { + throw new Error('MX_REGISTRY_LOGIN_REQUIRED: --username and --token are required'); + } + const result = await loginCommand({ + username: options.username, + token: options.token, + email: options.email, + expiresAt: options.expiresAt, + registry: options.registry, + credentialsPath: options.credentialsPath, + cwd: options.cwd, + }); + return { + ok: true, + registry: result.registry, + username: result.username, + credentialsPath: result.credentialsPath, + }; +} + +export async function registryDoctorCommand(options: IRegistryCommandOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const resolved = resolvePackageRegistry(options.registry, { + cwd, + configPath: options.configPath, + }); + const credential = readRegistryCredential(cwd, { + registry: resolved.registry, + credentialsPath: options.credentialsPath, + }); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-registry-doctor-')); + try { + const userConfig = writeDoctorNpmrc(tempDir, resolved.registry, credential.credential?.token ?? null); + const npmRegistry = await runNpm(['config', 'get', 'registry', '--userconfig', userConfig], { + cwd, + registryUrl: resolved.registry, + userConfig, + timeoutMs: options.timeoutMs, + }); + const scopeResolution: Record = {}; + for (const scope of MATRIX_NPM_REGISTRY_SCOPES) { + const result = await runNpm(['config', 'get', `${scope}:registry`, '--userconfig', userConfig], { + cwd, + registryUrl: resolved.registry, + userConfig, + timeoutMs: options.timeoutMs, + }); + scopeResolution[scope] = result.stdout.trim(); + } + const registryValue = npmRegistry.stdout.trim(); + const allScopesMatch = registryValue === resolved.registry + && MATRIX_NPM_REGISTRY_SCOPES.every((scope) => scopeResolution[scope] === resolved.registry); + const reachability = await checkRegistryReachability(resolved.registry, options.timeoutMs ?? 3_000); + const auth = credential.credential + ? await checkNpmWhoami(cwd, resolved.registry, userConfig, options.timeoutMs) + : { checked: false as const }; + const ok = allScopesMatch + && (auth.checked ? auth.ok === true : true); + return { + ok, + mode: 'registry-doctor', + registry: { + url: resolved.registry, + source: resolved.source, + reachable: reachability.reachable, + ...(reachability.status !== undefined ? { status: reachability.status } : {}), + ...(reachability.error ? { error: reachability.error } : {}), + }, + credentials: { + found: Boolean(credential.credential), + path: credential.credentialsPath, + ...(credential.credential?.username ? { username: credential.credential.username } : {}), + authChecked: auth.checked, + ...(auth.checked ? { authOk: auth.ok } : {}), + ...(auth.checked && auth.whoami ? { whoami: auth.whoami } : {}), + ...(auth.checked && auth.error ? { error: auth.error } : {}), + }, + npm: { + userConfig, + scopeResolution, + allScopesMatch, + poisonedUserConfigIgnored: allScopesMatch, + }, + }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function registryForProfile( + profile: string, + explicitRegistry: string | undefined, + cwd: string, + configPath: string | undefined, +): string { + if (profile === 'local-gitea') { + return resolvePackageRegistry(explicitRegistry ?? LOCAL_GITEA_REGISTRY, { cwd, configPath }).registry; + } + if (explicitRegistry) { + return resolvePackageRegistry(explicitRegistry, { cwd, configPath }).registry; + } + if (profile.startsWith('http://') || profile.startsWith('https://')) { + return resolvePackageRegistry(profile, { cwd, configPath }).registry; + } + throw new Error(`MX_REGISTRY_PROFILE_UNKNOWN: ${profile}`); +} + +function writeDoctorNpmrc(tempDir: string, registry: string, token: string | null): string { + const filePath = path.join(tempDir, '.npmrc'); + const lines = [ + `registry=${registry}`, + ...MATRIX_NPM_REGISTRY_SCOPES.map((scope) => `${scope}:registry=${registry}`), + ]; + if (token) { + lines.push('always-auth=true'); + lines.push(`${registryAuthConfigKey(registry)}=${token}`); + } + fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); + return filePath; +} + +function registryAuthConfigKey(registry: string): string { + const parsed = new URL(registry); + const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`; + return `//${parsed.host}${pathname}:_authToken`; +} + +async function checkRegistryReachability( + registry: string, + timeoutMs: number, +): Promise<{ readonly reachable: boolean; readonly status?: number; readonly error?: string }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(registry, { + method: 'GET', + signal: controller.signal, + headers: { accept: 'application/json,text/plain,*/*' }, + }); + return { reachable: true, status: response.status }; + } catch (error) { + return { + reachable: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeout); + } +} + +async function checkNpmWhoami( + cwd: string, + registry: string, + userConfig: string, + timeoutMs: number | undefined, +): Promise<{ + readonly checked: true; + readonly ok: boolean; + readonly whoami?: string; + readonly error?: string; +}> { + const result = await runNpm(['whoami', '--registry', registry, '--userconfig', userConfig], { + cwd, + registryUrl: registry, + userConfig, + timeoutMs, + }); + const output = [result.stderr, result.stdout] + .map((value) => value.trim()) + .filter(Boolean) + .join('\n'); + return { + checked: true, + ok: result.exitCode === 0, + ...(result.exitCode === 0 && result.stdout.trim() ? { whoami: result.stdout.trim() } : {}), + ...(result.exitCode === 0 ? {} : { error: output || `npm whoami exited ${result.exitCode}` }), + }; +} + +async function runNpm( + args: readonly string[], + options: { + readonly cwd: string; + readonly registryUrl: string; + readonly userConfig: string; + readonly timeoutMs?: number; + }, +): Promise<{ readonly exitCode: number | null; readonly stdout: string; readonly stderr: string }> { + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + return await new Promise((resolve, reject) => { + const child = spawn(npmCommand, args, { + cwd: options.cwd, + env: { + ...process.env, + npm_config_userconfig: options.userConfig, + NPM_CONFIG_USERCONFIG: options.userConfig, + npm_config_registry: options.registryUrl, + NPM_CONFIG_REGISTRY: options.registryUrl, + npm_config_cache: path.join(path.dirname(options.userConfig), '.npm-cache'), + }, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + const stdout: string[] = []; + const stderr: string[] = []; + const timeout = setTimeout(() => { + child.kill('SIGTERM'); + }, options.timeoutMs ?? 10_000); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => stdout.push(chunk)); + child.stderr.on('data', (chunk: string) => stderr.push(chunk)); + child.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on('close', (exitCode) => { + clearTimeout(timeout); + resolve({ + exitCode, + stdout: stdout.join(''), + stderr: stderr.join(''), + }); + }); + }); +} diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts new file mode 100644 index 0000000..74625be --- /dev/null +++ b/packages/cli/src/commands/search.ts @@ -0,0 +1,89 @@ +import { resolvePackageRegistry } from '../utils/config-store.js'; +import { resolveRegistryRoot } from '../utils/package-store.js'; +import { searchPackageDiscoveryIndex } from '../utils/discovery-index.js'; +import { + searchNpmRegistry, + type INpmRegistrySearchResult, +} from '../utils/npm-registry-client.js'; + +export interface ISearchCommandOptions { + readonly cwd?: string; + readonly registryDir?: string; + readonly registry?: string; + readonly credentialsPath?: string; + readonly limit?: number; +} + +export interface IPackageSearchResult extends INpmRegistrySearchResult { + readonly displayName?: string; + readonly inferredKind?: string; + readonly declaredKind?: string; + readonly matchedFields?: readonly string[]; + readonly score?: number; +} + +export interface ISearchCommandResult { + readonly query: string; + readonly registry: string; + readonly registrySource: 'explicit' | 'env' | 'config' | 'default' | 'local'; + readonly results: readonly IPackageSearchResult[]; +} + +export async function searchCommand( + query: string, + options: ISearchCommandOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const limit = normalizeLimit(options.limit); + if (options.registry && options.registryDir) { + throw new Error('MX_REGISTRY_INVALID: use either --registry or --registry-dir, not both'); + } + if (options.registryDir) { + const registryRoot = resolveRegistryRoot(cwd, options.registryDir); + const results = searchPackageDiscoveryIndex( + registryRoot, + { text: query.trim() }, + limit, + ).map((entry): IPackageSearchResult => ({ + packageName: entry.packageName, + version: entry.version, + ...(entry.description ? { description: entry.description } : {}), + ...(entry.displayName ? { displayName: entry.displayName } : {}), + ...(entry.inferredKind ? { inferredKind: entry.inferredKind } : {}), + ...(entry.declaredKind ? { declaredKind: entry.declaredKind } : {}), + keywords: entry.keywords, + matchedFields: entry.matchedFields, + score: entry.score, + })); + return { + query: query.trim(), + registry: registryRoot, + registrySource: 'local', + results, + }; + } + const registry = resolvePackageRegistry(options.registry, { cwd }); + const results = await searchNpmRegistry( + query, + registry.registry, + cwd, + options.credentialsPath, + limit, + ); + return { + query: query.trim(), + registry: registry.registry, + registrySource: registry.source, + results, + }; +} + +function normalizeLimit(value: number | undefined): number { + if (value === undefined) { + return 20; + } + if (!Number.isInteger(value) || value <= 0 || value > 100) { + throw new Error('MX_SEARCH_INVALID: --limit must be an integer from 1 to 100'); + } + return value; +} diff --git a/packages/cli/src/commands/uninstall.ts b/packages/cli/src/commands/uninstall.ts new file mode 100644 index 0000000..8135cb1 --- /dev/null +++ b/packages/cli/src/commands/uninstall.ts @@ -0,0 +1,26 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { packageNameToPath, resolvePackagesNodeModulesRoot } from '../utils/package-store.js'; + +export interface IUninstallCommandOptions { + readonly cwd?: string; + readonly packagesDir?: string; +} + +export interface IUninstallCommandResult { + readonly packageName: string; + readonly removedPath: string; +} + +export async function uninstallCommand(packageName: string, options: IUninstallCommandOptions = {}): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const nodeModulesRoot = resolvePackagesNodeModulesRoot(cwd, options.packagesDir); + const removedPath = path.join(nodeModulesRoot, packageNameToPath(packageName)); + + if (!fs.existsSync(removedPath)) { + throw new Error(`Package is not installed: ${packageName}`); + } + + fs.rmSync(removedPath, { recursive: true, force: true }); + return { packageName, removedPath }; +} diff --git a/packages/cli/src/commands/update-fork.ts b/packages/cli/src/commands/update-fork.ts new file mode 100644 index 0000000..0bbdbc6 --- /dev/null +++ b/packages/cli/src/commands/update-fork.ts @@ -0,0 +1,95 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + packageNameToPath, + resolvePackagesNodeModulesRoot, + resolveRegistryRoot, +} from '../utils/package-store.js'; +import { resolveRegistryTarball } from '../utils/registry-store.js'; +import { compareSemver } from '../utils/versioning.js'; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +interface IForkRecord { + origin: string; + originVersion: string; + [key: string]: unknown; +} + +function readForkRecord(filePath: string): IForkRecord { + if (!fs.existsSync(filePath)) { + throw new Error(`Fork metadata missing: ${filePath}`); + } + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + const record = asRecord(parsed); + const origin = typeof record?.origin === 'string' ? record.origin.trim() : ''; + const originVersion = typeof record?.originVersion === 'string' ? record.originVersion.trim() : ''; + if (!origin || !originVersion) { + throw new Error(`Invalid fork metadata in ${filePath}`); + } + return { + ...(record ?? {}), + origin, + originVersion, + }; +} + +function writeForkRecord(filePath: string, payload: IForkRecord): void { + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8'); +} + +export interface IUpdateForkCommandOptions { + readonly cwd?: string; + readonly packagesDir?: string; + readonly registryDir?: string; + readonly version?: string; +} + +export interface IUpdateForkCommandResult { + readonly packageName: string; + readonly origin: string; + readonly previousOriginVersion: string; + readonly nextOriginVersion: string; + readonly updated: boolean; +} + +export async function updateForkCommand( + packageName: string, + options: IUpdateForkCommandOptions = {}, +): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const nodeModulesRoot = resolvePackagesNodeModulesRoot(cwd, options.packagesDir); + const registryRoot = resolveRegistryRoot(cwd, options.registryDir); + const packageDir = path.join(nodeModulesRoot, packageNameToPath(packageName)); + if (!fs.existsSync(packageDir)) { + throw new Error(`Package is not installed: ${packageName}`); + } + + const forkRecordPath = path.join(packageDir, '.forked-from'); + const forkRecord = readForkRecord(forkRecordPath); + const upstream = resolveRegistryTarball(registryRoot, forkRecord.origin, options.version); + const previousOriginVersion = forkRecord.originVersion; + const nextOriginVersion = upstream.version; + const updated = compareSemver(nextOriginVersion, previousOriginVersion) > 0; + + if (updated) { + forkRecord.originVersion = nextOriginVersion; + forkRecord.updatedAt = new Date().toISOString(); + writeForkRecord(forkRecordPath, forkRecord); + } + + return { + packageName, + origin: forkRecord.origin, + previousOriginVersion, + nextOriginVersion, + updated, + }; +} diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts new file mode 100644 index 0000000..7bc849a --- /dev/null +++ b/packages/cli/src/commands/validate.ts @@ -0,0 +1,94 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { validateManifestForMxCli } from '../utils/manifestValidator.js'; +import { readManifestFromPackageDir } from '../utils/package-store.js'; + +type JsonRecord = Record; + +export interface IValidateCommandOptions { + readonly cwd?: string; +} + +export interface IValidateCommandResult { + readonly packageName: string; + readonly valid: boolean; + readonly diagnostics: ReadonlyArray<{ code: string; message: string; field?: string; componentIndex?: number }>; +} + +function readJsonObject(filePath: string): JsonRecord { + const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${filePath} must contain a JSON object`); + } + return parsed as JsonRecord; +} + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +function resolveManifest(validationTarget: string): { packageName: string; manifest: JsonRecord } { + const targetStat = fs.statSync(validationTarget); + if (targetStat.isDirectory()) { + return readManifestFromPackageDir(validationTarget); + } + + const normalized = path.basename(validationTarget).toLowerCase(); + if (normalized === 'matrix.json') { + const manifest = readJsonObject(validationTarget); + const packageName = typeof manifest.name === 'string' ? manifest.name.trim() : ''; + if (!packageName) { + throw new Error(`matrix.json must contain a non-empty "name" field (${validationTarget})`); + } + return { packageName, manifest }; + } + + if (normalized === 'package.json') { + const pkg = readJsonObject(validationTarget); + const packageName = typeof pkg.name === 'string' ? pkg.name.trim() : ''; + if (!packageName) { + throw new Error(`package.json must contain a non-empty "name" field (${validationTarget})`); + } + const matrix = asRecord(pkg.matrix) ?? {}; + return { + packageName, + manifest: { + name: packageName, + version: pkg.version, + runtime: matrix.runtime, + components: matrix.components, + permissions: matrix.permissions, + }, + }; + } + + throw new Error('Validation target must be a directory, matrix.json, or package.json'); +} + +export async function validateCommand( + target: string | undefined, + options: IValidateCommandOptions = {} +): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const resolvedTarget = path.resolve(cwd, target ?? '.'); + if (!fs.existsSync(resolvedTarget)) { + throw new Error(`Validation target does not exist: ${resolvedTarget}`); + } + + const { packageName, manifest } = resolveManifest(resolvedTarget); + const result = validateManifestForMxCli(manifest); + return { + packageName, + valid: result.valid, + diagnostics: result.diagnostics.map((entry) => ({ + code: entry.code, + message: entry.message, + field: entry.field, + componentIndex: entry.componentIndex, + })), + }; +} diff --git a/packages/cli/src/commands/webapp.ts b/packages/cli/src/commands/webapp.ts new file mode 100644 index 0000000..25ec032 --- /dev/null +++ b/packages/cli/src/commands/webapp.ts @@ -0,0 +1,553 @@ +/** + * mx webapp command implementation. + * + * Scaffolds a complete browser web app package with: + * - Domain actor (MatrixActor) for business logic + * - Shell (MatrixActorHtmlElement) for browser UI + * - Federation bootstrap (init.ts) + * - Vite build config + * - HTML template + CSS styles + * - Tests and examples + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + toKebabCase, toPascalCase, validateMatrixManifest, writeJsonFile, + generatePackageJson, generateTsconfig, +} from '../utils/scaffold.js'; + +export interface IWebappCommandOptions { + readonly cwd?: string; + readonly outputDir?: string; +} + +export interface IWebappCommandResult { + readonly rootDir: string; + readonly appClassName: string; + readonly shellClassName: string; + readonly tagName: string; + readonly appName: string; +} + +// ── Template generators ───────────────────────────────────────────────── + +function appActorTemplate(className: string): string { + return `import { MatrixActor } from '@open-matrix/core'; + +export class ${className} extends MatrixActor { + static override accepts = { + increment: {}, + decrement: {}, + getCount: {}, + reset: {}, + }; + + static override emits = { + countChanged: { count: 'number' }, + }; + + private _count = 0; + + protected onIncrement(): void { + this._count++; + this.emit('countChanged', { count: this._count }); + } + + protected onDecrement(): void { + this._count--; + this.emit('countChanged', { count: this._count }); + } + + protected onGetCount(): { count: number } { + return { count: this._count }; + } + + protected onReset(): void { + this._count = 0; + this.emit('countChanged', { count: this._count }); + } +} +`; +} + +function shellTemplate(shellClassName: string, appClassName: string, tagName: string): string { + return `import { MatrixActorHtmlElement } from '@open-matrix/core'; +import { ${appClassName} } from '../${appClassName}.js'; +import { appTemplate } from '../template/app.template.js'; + +export class ${shellClassName} extends MatrixActorHtmlElement { + static override accepts = { + ...${appClassName}.accepts, + }; + + static override emits = { + ...${appClassName}.emits, + }; + + static override template = appTemplate; + + static getActorClass() { + return ${appClassName}; + } + + async onBootstrap(): Promise { + const root = this.shadowRoot!; + + // Wire up button handlers + root.getElementById('incrementBtn')?.addEventListener('click', () => { + this.sendToSelf('increment', {}); + }); + root.getElementById('decrementBtn')?.addEventListener('click', () => { + this.sendToSelf('decrement', {}); + }); + root.getElementById('resetBtn')?.addEventListener('click', () => { + this.sendToSelf('reset', {}); + }); + root.getElementById('introspectBtn')?.addEventListener('click', () => { + this.sendToSelf('$introspect', {}); + }); + + // Request initial count + this.sendToSelf('getCount', {}); + } + + onCountChanged(payload: { count: number }): void { + const el = this.shadowRoot?.getElementById('countDisplay'); + if (el) el.textContent = String(payload.count); + } +} + +if (!customElements.get('${tagName}')) { + customElements.define('${tagName}', ${shellClassName}); +} +`; +} + +function templateFileContent(appName: string): string { + return `import { appStyles } from '../styles/app.styles.ts'; + +export const appTemplate = \` +
+
+

${appName}

+
+ Connecting... +
+
+ +
+
+
+ Count: + 0 +
+
+ + + +
+
+ +
+

This app demonstrates the Matrix Shell pattern:

+
    +
  • Shell sends commands via sendToSelf()
  • +
  • Actor processes commands and emits events
  • +
  • Shell reacts to events via on<Event>() handlers
  • +
+ +
+
+
+ +\`; +`; +} + +function stylesFileContent(): string { + return `export const appStyles = \` + :host { + display: block; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: #e0e0e0; + background: #1a1a2e; + min-height: 100vh; + } + + .app-container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; + } + + .app-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid #333; + } + + .app-header h1 { + margin: 0; + font-size: 1.5rem; + color: #00d4ff; + } + + .transport-indicator { + font-size: 0.8rem; + padding: 4px 12px; + border-radius: 12px; + background: #2a2a4a; + } + + .counter-card, .info-card { + background: #16213e; + border: 1px solid #2a2a4a; + border-radius: 8px; + padding: 2rem; + margin-bottom: 1.5rem; + } + + .counter-display { + text-align: center; + margin-bottom: 1.5rem; + } + + .counter-label { + font-size: 1rem; + color: #888; + margin-right: 0.5rem; + } + + .counter-value { + font-size: 3rem; + font-weight: bold; + color: #00d4ff; + font-variant-numeric: tabular-nums; + } + + .counter-controls { + display: flex; + justify-content: center; + gap: 1rem; + } + + .btn { + padding: 8px 20px; + border-radius: 6px; + border: 1px solid #444; + cursor: pointer; + font-size: 1rem; + transition: all 0.2s; + } + + .btn-primary { + background: #00d4ff; + color: #000; + border-color: #00d4ff; + } + .btn-primary:hover { background: #00b8e6; } + + .btn-secondary { + background: #ff6b6b; + color: #fff; + border-color: #ff6b6b; + } + .btn-secondary:hover { background: #e55a5a; } + + .btn-outline { + background: transparent; + color: #aaa; + border-color: #555; + } + .btn-outline:hover { border-color: #00d4ff; color: #00d4ff; } + + .info-card p { margin-top: 0; color: #999; } + .info-card ul { color: #bbb; line-height: 1.8; } + .info-card code { + background: #2a2a4a; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.85em; + color: #00d4ff; + } +\`; +`; +} + +function indexHtmlTemplate(tagName: string, appName: string): string { + return ` + + + + + ${appName} + + + +
Loading ${appName}...
+ <${tagName} id="app" style="display:none"> + + + +`; +} + +function initTemplate(tagName: string): string { + return `/** + * App initialization — sets up Matrix runtime and federation. + */ +import { MatrixRuntime, BrowserDomStrategy, TransportProxy } from '@open-matrix/core'; +import { + getBrowserRouter, + createNatsTransport as createFederationNatsTransport, +} from '@open-matrix/federation'; + +// Register custom elements +import './index.ts'; + +const loadingEl = document.getElementById('loading')!; +const appEl = document.getElementById('app') as any; + +async function init() { + try { + const urlParams = new URLSearchParams(window.location.search); + const root = urlParams.get('root') || 'APP-ROOT'; + const backboneUrl = urlParams.get('backboneUrl') || 'nats://hub.example.com:4222'; + const allowLocalFallback = ['1', 'true'].includes( + (urlParams.get('allowLocalFallback') || 'true').toLowerCase() + ); + + console.log('[App] Initializing...'); + loadingEl.textContent = 'Initializing Matrix runtime...'; + + // Get browser router from federation package + const { router, adapter, root: tabRoot } = getBrowserRouter({ + baseRoot: root, + }); + + console.log('[App] Tab root:', tabRoot.value); + + // Connect to NATS hub backbone for cross-root communication + loadingEl.textContent = 'Connecting to federation backbone...'; + try { + const natsModule = await import('nats'); + const nats = natsModule.default || natsModule; + const connect = typeof nats === 'function' ? nats + : typeof nats.connect === 'function' ? nats.connect + : natsModule.connect; + + const clientId = \`app-\${Date.now()}-\${Math.random().toString(36).slice(2)}\`; + const client = connect(backboneUrl, { + clientId, + clean: true, + reconnectPeriod: 1000, + connectTimeout: 10000, + }); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Connection timeout')), 10000); + client.on('connect', () => { clearTimeout(timeout); resolve(); }); + client.on('error', (err: Error) => { clearTimeout(timeout); reject(err); }); + }); + + const backbone = createFederationNatsTransport(client as any, { jsonMode: false, qos: 0 }); + router.setBackbone(backbone); + console.log('[App] Backbone connected'); + } catch (err: any) { + if (!allowLocalFallback) throw err; + console.warn('[App] Backbone connection failed, local-only mode:', err?.message); + } + + // Create runtime + const transportProxy = new TransportProxy(adapter as any); + const runtime = new MatrixRuntime({ + transport: transportProxy, + domStrategy: new BrowserDomStrategy(), + }); + + const context = runtime.getRootContext(); + const pageContext = context.derive('app-page'); + + // Register services so shell components can find them + const SESSION_INFO_KEY = Symbol.for('matrix.session.info'); + const FEDERATION_PEER_KEY = Symbol.for('matrix.federation.peer'); + context.setService(SESSION_INFO_KEY, { + mode: 'router', + root: tabRoot.value, + hasBackbone: router.hasBackbone, + }); + context.setService(FEDERATION_PEER_KEY, router); + + // Mount the app + await appEl.mount(pageContext); + + // Update transport indicator + const textEl = appEl.shadowRoot?.getElementById('transportText'); + if (textEl) { + textEl.textContent = router.hasBackbone + ? \`Federation (\${tabRoot.value})\` + : \`Local-only (\${tabRoot.value})\`; + } + + // Show app, hide loading + loadingEl.style.display = 'none'; + appEl.style.display = 'block'; + + console.log('[App] Ready!'); + console.log('[App] Root:', tabRoot.value); + console.log('[App] Backbone:', router.hasBackbone ? 'connected' : 'local-only'); + } catch (error: any) { + console.error('[App] Initialization failed:', error); + loadingEl.outerHTML = \` +
+ Failed to initialize +

\${error.message}

+
+ \`; + } +} + +init(); +`; +} + +function indexTsTemplate(shellClassName: string): string { + return `export { ${shellClassName} } from './shell/${shellClassName}.js'; +`; +} + +function viteConfigTemplate(appName: string): string { + return `import { defineConfig } from 'vite'; +import * as path from 'node:path'; + +export default defineConfig({ + root: '.', + base: '/apps/${appName}/', + build: { + outDir: 'dist', + target: 'es2022', + sourcemap: true, + }, + resolve: { + alias: { + // In development, resolve to monorepo source. + // For standalone use, install these as npm packages instead. + '@open-matrix/core': path.resolve(__dirname, '../../src/index.ts'), + }, + }, + optimizeDeps: { + include: ['nats'], + }, +}); +`; +} + +function testTemplate(appClassName: string, shellClassName: string): string { + return `import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { ${appClassName} } from '../src/${appClassName}.ts'; + +describe('${appClassName}', () => { + it('declares counter commands', () => { + assert.ok('increment' in ${appClassName}.accepts); + assert.ok('decrement' in ${appClassName}.accepts); + assert.ok('getCount' in ${appClassName}.accepts); + assert.ok('reset' in ${appClassName}.accepts); + }); + + it('declares countChanged event', () => { + assert.ok('countChanged' in ${appClassName}.emits); + }); +}); +`; +} + +// ── Main command ───────────────────────────────────────────────────────── + +export async function webappCommand( + name: string, + options: IWebappCommandOptions = {}, +): Promise { + const normalizedName = toKebabCase(name); + if (!normalizedName) { + throw new Error('Webapp name must not be empty'); + } + + const baseName = toPascalCase(normalizedName); + const appClassName = `${baseName}App`; + const shellClassName = `${baseName}AppShell`; + const tagName = `${normalizedName}-app`; + const appName = baseName; + const cwd = path.resolve(options.cwd ?? process.cwd()); + const rootDir = path.resolve(options.outputDir ?? path.join(cwd, normalizedName)); + + // Create directory structure + fs.mkdirSync(path.join(rootDir, 'src', 'shell'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'src', 'template'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'src', 'styles'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'examples'), { recursive: true }); + fs.mkdirSync(path.join(rootDir, 'tests'), { recursive: true }); + + // matrix.json + const manifest = { + name: `@open-matrix/${normalizedName}`, + version: '0.1.0', + description: `${appName} — a Matrix web application`, + runtime: { language: 'typescript', entry: 'dist/index.js' }, + webapp: { distDir: 'dist', entry: 'index.html', appName: normalizedName }, + components: [], + permissions: { fsPolicy: 'none' }, + }; + + const validation = validateMatrixManifest(manifest); + if (!validation.valid) { + throw new Error(`Generated matrix.json failed validation: ${validation.errors.join('; ')}`); + } + + writeJsonFile(path.join(rootDir, 'matrix.json'), manifest); + writeJsonFile(path.join(rootDir, 'package.json'), generatePackageJson(normalizedName, { webapp: true })); + writeJsonFile(path.join(rootDir, 'tsconfig.json'), generateTsconfig({ webapp: true })); + + // Vite config + fs.writeFileSync(path.join(rootDir, 'vite.config.ts'), viteConfigTemplate(normalizedName), 'utf8'); + + // index.html (entry point) + fs.writeFileSync(path.join(rootDir, 'index.html'), indexHtmlTemplate(tagName, appName), 'utf8'); + + // Source files + fs.writeFileSync(path.join(rootDir, 'src', 'index.ts'), indexTsTemplate(shellClassName), 'utf8'); + fs.writeFileSync(path.join(rootDir, 'src', 'init.ts'), initTemplate(tagName), 'utf8'); + fs.writeFileSync(path.join(rootDir, 'src', `${appClassName}.ts`), appActorTemplate(appClassName), 'utf8'); + fs.writeFileSync(path.join(rootDir, 'src', 'shell', `${shellClassName}.ts`), shellTemplate(shellClassName, appClassName, tagName), 'utf8'); + fs.writeFileSync(path.join(rootDir, 'src', 'template', 'app.template.ts'), templateFileContent(appName), 'utf8'); + fs.writeFileSync(path.join(rootDir, 'src', 'styles', 'app.styles.ts'), stylesFileContent(), 'utf8'); + + // Example + writeJsonFile(path.join(rootDir, 'examples', 'introspect.mxq'), { + label: 'Introspect App', + operation: '$introspect', + payload: {}, + description: `Introspect the ${appName} actor`, + }); + + // Test + fs.writeFileSync(path.join(rootDir, 'tests', `${normalizedName}.spec.ts`), testTemplate(appClassName, shellClassName), 'utf8'); + + console.log(`Created webapp scaffold at ${rootDir}`); + console.log(` Tag: <${tagName}>`); + console.log(` Actor: ${appClassName}`); + console.log(` Shell: ${shellClassName}`); + return { rootDir, appClassName, shellClassName, tagName, appName: normalizedName }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..aa13b63 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,805 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import { pathToFileURL } from 'node:url'; +import { initCommand } from './commands/init.js'; +import { actorCommand } from './commands/actor.js'; +import { actorRunCommand, packageRunCommand } from './commands/actor-run.js'; +import { elementCommand } from './commands/element.js'; +import { actorElementCommand } from './commands/actor-element.js'; +import { cqrsCommand } from './commands/cqrs.js'; +import { webappCommand } from './commands/webapp.js'; +import { installCommand } from './commands/install.js'; +import { uninstallCommand } from './commands/uninstall.js'; +import { listCommand } from './commands/list.js'; +import { infoCommand } from './commands/info.js'; +import { validateCommand } from './commands/validate.js'; +import { packCommand } from './commands/pack.js'; +import { forkCommand } from './commands/fork.js'; +import { publishCommand } from './commands/publish.js'; +import { loginCommand, logoutCommand, whoamiCommand } from './commands/auth.js'; +import { + configGetCommand, + configListCommand, + configResetCommand, + configSetCommand, +} from './commands/config.js'; +import { + registryDoctorCommand, + registryLoginCommand, + registryUseCommand, +} from './commands/registry.js'; +import { outdatedCommand } from './commands/outdated.js'; +import { updateForkCommand } from './commands/update-fork.js'; +import { searchCommand } from './commands/search.js'; + +function fail(err: unknown): never { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +} + +export function createProgram(): Command { + const program = new Command(); + program + .name('matrix') + .description('Matrix SDK CLI for package authors') + .version('0.1.0'); + + program + .command('init') + .description('Initialize a new Matrix package') + .option('--name ', 'Package name override') + .option('--version ', 'Package version override') + .option('--description ', 'Package description override') + .option('-y, --yes', 'Skip prompts and use defaults/options') + .action(async (options: { + name?: string; + version?: string; + description?: string; + yes?: boolean; + }) => { + await initCommand({ + name: options.name, + version: options.version, + description: options.description, + yes: options.yes, + }); + }); + + const actor = program + .command('actor [name]') + .description('Scaffold a new actor package') + .option('--out ', 'Output directory (default: ./)') + .action(async (name: string | undefined, options: { out?: string }) => { + if (!name) { + fail(new Error('actor requires a name. Use `matrix actor run ` for direct actor execution.')); + } + await actorCommand(name, { outputDir: options.out }); + }); + + actor + .command('run ') + .description('Run one actor directly on the selected Space bus without managed host inventory') + .option('--export ', 'Actor export name (default: default)') + .requiredOption('--mount ', 'Logical actor mount under the selected Space authority root') + .option('--nats-url ', 'NATS broker URL, e.g. nats://127.0.0.1:4222') + .option('--root ', 'Matrix/NATS subject root') + .option('--space ', 'Alias for --root') + .option('--jwt ', 'NATS user JWT') + .option('--seed ', 'NATS user seed') + .option('--home ', 'Credential/config home (also checks ./.matrix and ~/.matrix)') + .option('--props ', 'JSON object assigned as actor bootstrap props') + .option('--check-op ', 'Invoke one actor operation after mount, print the result, and exit') + .option('--check-payload ', 'JSON object passed to --check-op') + .option('--json', 'Output mount details as JSON') + .action(async (entry: string, options: { + export?: string; + mount: string; + natsUrl?: string; + root?: string; + space?: string; + jwt?: string; + seed?: string; + home?: string; + props?: string; + checkOp?: string; + checkPayload?: string; + json?: boolean; + }) => { + try { + await actorRunCommand(entry, options); + } catch (err) { + fail(err); + } + }); + + const packageCommand = program + .command('package') + .description('Direct package execution commands'); + + packageCommand + .command('run ') + .description('Run one package directly on the selected Space bus without managed host inventory') + .option('--entry ', 'Package entry override') + .option('--export ', 'Package export override') + .option('--mount ', 'Logical actor mount under the selected Space authority root') + .option('--nats-url ', 'NATS broker URL, e.g. nats://127.0.0.1:4222') + .option('--root ', 'Matrix/NATS subject root') + .option('--space ', 'Alias for --root') + .option('--jwt ', 'NATS user JWT') + .option('--seed ', 'NATS user seed') + .option('--home ', 'Credential/config home (also checks ./.matrix and ~/.matrix)') + .option('--overrides ', 'JSON object passed as package bootstrap overrides') + .option('--check-op ', 'Invoke one actor operation after package mount, print the result, and exit') + .option('--check-payload ', 'JSON object passed to --check-op') + .option('--json', 'Output mount details as JSON') + .action(async (packageDir: string, options: { + entry?: string; + export?: string; + mount?: string; + natsUrl?: string; + root?: string; + space?: string; + jwt?: string; + seed?: string; + home?: string; + overrides?: string; + checkOp?: string; + checkPayload?: string; + json?: boolean; + }) => { + try { + await packageRunCommand(packageDir, options); + } catch (err) { + fail(err); + } + }); + + program + .command('element ') + .description('Scaffold a new element package') + .option('--out ', 'Output directory (default: ./)') + .action(async (name: string, options: { out?: string }) => { + await elementCommand(name, { outputDir: options.out }); + }); + + program + .command('actor-element ') + .description('Scaffold a package containing paired actor + element components') + .option('--out ', 'Output directory (default: ./)') + .action(async (name: string, options: { out?: string }) => { + await actorElementCommand(name, { outputDir: options.out }); + }); + + program + .command('cqrs ') + .description('Scaffold a package with CQRS command/query actors') + .option('--out ', 'Output directory (default: ./)') + .action(async (name: string, options: { out?: string }) => { + await cqrsCommand(name, { outputDir: options.out }); + }); + + program + .command('webapp ') + .description('Scaffold a browser web app with Shell pattern, federation, and Vite') + .option('--out ', 'Output directory (default: ./)') + .action(async (name: string, options: { out?: string }) => { + await webappCommand(name, { outputDir: options.out }); + }); + + program + .command('pack') + .description('Create distributable tarball for current package') + .option('--out-dir ', 'Output directory for tarball') + .option('--build-first', 'Run npm build before packing') + .option('--json', 'Output as JSON') + .action(async (options: { outDir?: string; buildFirst?: boolean; json?: boolean }) => { + try { + const result = await packCommand({ + outDir: options.outDir, + buildFirst: options.buildFirst, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`Packed ${result.packageName}@${result.version} -> ${result.tarballPath}`); + } catch (err) { + fail(err); + } + }); + + program + .command('install ') + .description('Install a Matrix package from directory, tarball, or registry name') + .option('--packages-dir ', 'Override packages root (default: ~/.matrix/packages)') + .option('--registry-dir ', 'Override local registry root') + .option('--registry ', 'NPM-compatible registry URL') + .option('--credentials-file ', 'Override registry credentials file path') + .option('-f, --force', 'Overwrite existing installed package') + .option('--json', 'Output as JSON') + .action(async (source: string, options: { + packagesDir?: string; + registryDir?: string; + registry?: string; + credentialsFile?: string; + force?: boolean; + json?: boolean; + }) => { + try { + const result = await installCommand(source, { + packagesDir: options.packagesDir, + registryDir: options.registryDir, + registry: options.registry, + credentialsPath: options.credentialsFile, + force: options.force, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`Installed ${result.packageName}@${result.version} -> ${result.targetDir}`); + } catch (err) { + fail(err); + } + }); + + program + .command('uninstall ') + .description('Uninstall a Matrix package from package root') + .option('--packages-dir ', 'Override packages root (default: ~/.matrix/packages)') + .option('--json', 'Output as JSON') + .action(async (packageName: string, options: { packagesDir?: string; json?: boolean }) => { + try { + const result = await uninstallCommand(packageName, { + packagesDir: options.packagesDir, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`Uninstalled ${result.packageName}`); + } catch (err) { + fail(err); + } + }); + + program + .command('fork ') + .description('Fork an installed package for modification') + .option('--name ', 'Forked package name') + .option('--replace-origin', 'Do not rename component type/mount/export fields') + .option('--packages-dir ', 'Override packages root (default: ~/.matrix/packages)') + .option('--forked-by ', 'Fork author metadata override') + .option('--json', 'Output as JSON') + .action(async (packageName: string, options: { + name?: string; + replaceOrigin?: boolean; + packagesDir?: string; + forkedBy?: string; + json?: boolean; + }) => { + try { + const result = await forkCommand(packageName, { + name: options.name, + replaceOrigin: options.replaceOrigin, + packagesDir: options.packagesDir, + forkedBy: options.forkedBy, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`Forked ${result.sourcePackageName} -> ${result.forkedPackageName}`); + } catch (err) { + fail(err); + } + }); + + program + .command('publish') + .description('Publish current package to registry') + .option('--registry-dir ', 'Override local registry root') + .option('--registry ', 'NPM-compatible registry URL') + .option('--credentials-file ', 'Override credentials file path') + .option('--dry-run', 'Run publish preflight without writing registry artifacts') + .option('--json', 'Output as JSON') + .action(async (options: { + registryDir?: string; + registry?: string; + credentialsFile?: string; + dryRun?: boolean; + json?: boolean; + }) => { + try { + const result = await publishCommand({ + registryDir: options.registryDir, + registry: options.registry, + credentialsPath: options.credentialsFile, + dryRun: options.dryRun, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`Published ${result.packageName}@${result.version}`); + } catch (err) { + fail(err); + } + }); + + program + .command('search ') + .description('Search the configured npm-compatible Matrix package registry') + .option('--registry-dir ', 'Search local Matrix discovery index under this registry root') + .option('--registry ', 'NPM-compatible registry URL') + .option('--credentials-file ', 'Override registry credentials file path') + .option('--limit ', 'Maximum number of results (default: 20)', parseInt) + .option('--json', 'Output as JSON') + .action(async (query: string, options: { + registryDir?: string; + registry?: string; + credentialsFile?: string; + limit?: number; + json?: boolean; + }) => { + try { + const result = await searchCommand(query, { + registryDir: options.registryDir, + registry: options.registry, + credentialsPath: options.credentialsFile, + limit: options.limit, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + if (result.results.length === 0) { + console.log('(no packages found)'); + return; + } + for (const entry of result.results) { + const description = entry.description ? ` - ${entry.description}` : ''; + console.log(`${entry.packageName}@${entry.version}${description}`); + } + } catch (err) { + fail(err); + } + }); + + const config = program + .command('config') + .description('Read and write Matrix CLI configuration'); + + config + .command('get ') + .description('Read a Matrix CLI config value') + .option('--config-file ', 'Override CLI config file path') + .option('--json', 'Output as JSON') + .action(async (key: string, options: { configFile?: string; json?: boolean }) => { + try { + const result = await configGetCommand(key, { + configPath: options.configFile, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(result.value ?? ''); + } catch (err) { + fail(err); + } + }); + + config + .command('set ') + .description('Set a Matrix CLI config value') + .option('--config-file ', 'Override CLI config file path') + .option('--json', 'Output as JSON') + .action(async (key: string, value: string, options: { configFile?: string; json?: boolean }) => { + try { + const result = await configSetCommand(key, value, { + configPath: options.configFile, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`${result.key}=${result.value}`); + } catch (err) { + fail(err); + } + }); + + config + .command('list') + .description('List Matrix CLI config values') + .option('--config-file ', 'Override CLI config file path') + .option('--json', 'Output as JSON') + .action(async (options: { configFile?: string; json?: boolean }) => { + try { + const result = await configListCommand({ + configPath: options.configFile, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`registry=${result.effective.registry}`); + } catch (err) { + fail(err); + } + }); + + config + .command('reset') + .description('Reset Matrix CLI config values') + .option('--config-file ', 'Override CLI config file path') + .option('--json', 'Output as JSON') + .action(async (options: { configFile?: string; json?: boolean }) => { + try { + const result = await configResetCommand({ + configPath: options.configFile, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`Reset ${result.configPath}`); + } catch (err) { + fail(err); + } + }); + + const registry = program + .command('registry') + .description('Inspect and configure the Matrix package registry'); + + registry + .command('doctor') + .description('Diagnose npm registry and Matrix scope resolution') + .option('--registry ', 'Registry URL to diagnose') + .option('--credentials-file ', 'Override registry credentials file path') + .option('--config-file ', 'Override CLI config file path') + .option('--timeout-ms ', 'Network/command timeout in milliseconds', (value) => Number.parseInt(value, 10)) + .option('--json', 'Output as JSON') + .action(async (options: { + registry?: string; + credentialsFile?: string; + configFile?: string; + timeoutMs?: number; + json?: boolean; + }) => { + try { + const result = await registryDoctorCommand({ + registry: options.registry, + credentialsPath: options.credentialsFile, + configPath: options.configFile, + timeoutMs: options.timeoutMs, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`registry=${result.registry.url}`); + console.log(`registryReachable=${result.registry.reachable}`); + console.log(`credentials=${result.credentials.found ? result.credentials.username ?? 'found' : 'missing'}`); + console.log(`scopeResolution=${result.npm.allScopesMatch ? 'ok' : 'mismatch'}`); + } catch (err) { + fail(err); + } + }); + + registry + .command('login') + .description('Store credentials for a Matrix npm registry') + .requiredOption('--username ', 'Registry username') + .requiredOption('--token ', 'Registry token') + .option('--email ', 'Optional account email') + .option('--expires-at ', 'Optional token expiry timestamp') + .option('--registry ', 'Registry URL') + .option('--credentials-file ', 'Override registry credentials file path') + .option('--json', 'Output as JSON') + .action(async (options: { + username: string; + token: string; + email?: string; + expiresAt?: string; + registry?: string; + credentialsFile?: string; + json?: boolean; + }) => { + try { + const result = await registryLoginCommand({ + username: options.username, + token: options.token, + email: options.email, + expiresAt: options.expiresAt, + registry: options.registry, + credentialsPath: options.credentialsFile, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`Logged in as ${result.username} (${result.registry})`); + } catch (err) { + fail(err); + } + }); + + registry + .command('use ') + .description('Set the Matrix package registry profile') + .option('--registry ', 'Registry URL override') + .option('--config-file ', 'Override CLI config file path') + .option('--json', 'Output as JSON') + .action(async (profile: string, options: { + registry?: string; + configFile?: string; + json?: boolean; + }) => { + try { + const result = await registryUseCommand(profile, { + registry: options.registry, + configPath: options.configFile, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`registry=${result.registry}`); + } catch (err) { + fail(err); + } + }); + + program + .command('login') + .description('Authenticate with a Matrix package registry') + .option('--username ', 'Registry username') + .option('--token ', 'Registry token') + .option('--email ', 'Optional account email') + .option('--expires-at ', 'Optional token expiry timestamp') + .option('--registry ', 'Registry identifier') + .option('--credentials-file ', 'Override credentials file path') + .option('--json', 'Output as JSON') + .action(async (options: { + username: string; + token: string; + email?: string; + expiresAt?: string; + registry?: string; + credentialsFile?: string; + json?: boolean; + }) => { + try { + if (!options.username || !options.token) { + throw new Error('MX_AUTH_REQUIRED: username and token are required for registry login'); + } + const result = await loginCommand({ + username: options.username, + token: options.token, + email: options.email, + expiresAt: options.expiresAt, + registry: options.registry, + credentialsPath: options.credentialsFile, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`Logged in as ${result.username} (${result.registry})`); + } catch (err) { + fail(err); + } + }); + + program + .command('logout') + .description('Clear stored registry credentials') + .option('--registry ', 'Registry identifier') + .option('--credentials-file ', 'Override credentials file path') + .option('--json', 'Output as JSON') + .action(async (options: { + registry?: string; + credentialsFile?: string; + json?: boolean; + }) => { + try { + const result = await logoutCommand({ + registry: options.registry, + credentialsPath: options.credentialsFile, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(result.removed ? `Logged out of ${result.registry}` : `No session for ${result.registry}`); + } catch (err) { + fail(err); + } + }); + + program + .command('whoami') + .description('Show current registry identity') + .option('--registry ', 'Registry identifier') + .option('--credentials-file ', 'Override credentials file path') + .option('--json', 'Output as JSON') + .action(async (options: { + registry?: string; + credentialsFile?: string; + json?: boolean; + }) => { + try { + const result = await whoamiCommand({ + registry: options.registry, + credentialsPath: options.credentialsFile, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + if (!result.authenticated) { + console.log(`Not logged in (${result.registry})`); + return; + } + console.log(`${result.username} (${result.registry})`); + } catch (err) { + fail(err); + } + }); + + program + .command('outdated') + .description('Check if forked packages have upstream updates') + .option('--packages-dir ', 'Override packages root (default: ~/.matrix/packages)') + .option('--registry-dir ', 'Override local registry root') + .option('--json', 'Output as JSON') + .action(async (options: { packagesDir?: string; registryDir?: string; json?: boolean }) => { + try { + const result = await outdatedCommand({ + packagesDir: options.packagesDir, + registryDir: options.registryDir, + }); + if (options.json) { + console.log(JSON.stringify(result.entries, null, 2)); + return; + } + if (result.entries.length === 0) { + console.log('(no forked packages)'); + return; + } + for (const entry of result.entries) { + console.log(`${entry.packageName} ${entry.localOriginVersion} -> ${entry.latestOriginVersion ?? 'n/a'} (${entry.status})`); + } + } catch (err) { + fail(err); + } + }); + + program + .command('update-fork ') + .description('Update fork metadata to latest upstream version') + .option('--packages-dir ', 'Override packages root (default: ~/.matrix/packages)') + .option('--registry-dir ', 'Override local registry root') + .option('--version ', 'Target upstream version') + .option('--json', 'Output as JSON') + .action(async (packageName: string, options: { + packagesDir?: string; + registryDir?: string; + version?: string; + json?: boolean; + }) => { + try { + const result = await updateForkCommand(packageName, { + packagesDir: options.packagesDir, + registryDir: options.registryDir, + version: options.version, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + if (result.updated) { + console.log(`Updated ${result.packageName}: ${result.previousOriginVersion} -> ${result.nextOriginVersion}`); + } else { + console.log(`${result.packageName} already up to date (${result.nextOriginVersion})`); + } + } catch (err) { + fail(err); + } + }); + + program + .command('list') + .description('List installed Matrix packages') + .option('--packages-dir ', 'Override packages root (default: ~/.matrix/packages)') + .option('--json', 'Output as JSON') + .action(async (options: { packagesDir?: string; json?: boolean }) => { + try { + const result = await listCommand({ + packagesDir: options.packagesDir, + }); + if (options.json) { + console.log(JSON.stringify(result.packages, null, 2)); + return; + } + if (result.packages.length === 0) { + console.log('(no installed packages)'); + return; + } + for (const packageName of result.packages) { + console.log(packageName); + } + } catch (err) { + fail(err); + } + }); + + program + .command('info ') + .description('Show installed package metadata') + .option('--packages-dir ', 'Override packages root (default: ~/.matrix/packages)') + .option('--json', 'Output as JSON') + .action(async (packageName: string, options: { packagesDir?: string; json?: boolean }) => { + try { + const result = await infoCommand(packageName, { + packagesDir: options.packagesDir, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`${result.packageName}`); + console.log(`path: ${result.installPath}`); + console.log(`manifest: ${result.hasMatrixJson ? 'matrix.json' : 'package.json.matrix'}`); + } catch (err) { + fail(err); + } + }); + + program + .command('validate [target]') + .description('Validate matrix manifest at directory, matrix.json, or package.json') + .option('--json', 'Output as JSON') + .action(async (target: string | undefined, options: { json?: boolean }) => { + try { + const result = await validateCommand(target); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`package: ${result.packageName}`); + console.log(`valid: ${result.valid ? 'yes' : 'no'}`); + if (!result.valid) { + for (const diagnostic of result.diagnostics) { + console.log(`- ${diagnostic.code}: ${diagnostic.message}`); + } + } + } + if (!result.valid) { + process.exit(1); + } + } catch (err) { + fail(err); + } + }); + + return program; +} + +async function main(): Promise { + const program = createProgram(); + await program.parseAsync(process.argv); +} + +const invokedPath = process.argv[1]; +const isDirectExecution = Boolean(invokedPath) + && pathToFileURL(invokedPath).href === import.meta.url; + +if (isDirectExecution) { + main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); + }); +} diff --git a/packages/cli/src/utils/archive.ts b/packages/cli/src/utils/archive.ts new file mode 100644 index 0000000..389b27e --- /dev/null +++ b/packages/cli/src/utils/archive.ts @@ -0,0 +1,73 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; + +const DEFAULT_EXCLUDES = [ + 'node_modules', + '.git', + 'tests', + '.forked-from', + '.env', + '.env.*', + '*.spec.ts', + '*.test.ts', +]; + +function runTar(args: string[]): void { + const result = spawnSync('tar', args, { + encoding: 'utf8', + windowsHide: true, + }); + if (result.status !== 0) { + throw new Error(`tar failed: ${result.stderr || result.stdout || `exit ${result.status ?? 'unknown'}`}`); + } +} + +export function createTarball(sourceDir: string, tarballPath: string, excludes: string[] = DEFAULT_EXCLUDES): void { + fs.mkdirSync(path.dirname(tarballPath), { recursive: true }); + const excludeArgs = excludes.flatMap((pattern) => ['--exclude', pattern]); + runTar([ + '-czf', + tarballPath, + ...excludeArgs, + '-C', + sourceDir, + '.', + ]); +} + +export function extractTarball(tarballPath: string, outputDir: string): void { + fs.mkdirSync(outputDir, { recursive: true }); + runTar([ + '-xzf', + tarballPath, + '-C', + outputDir, + ]); +} + +export function computeDirectorySha256(rootDir: string, excludes: Set = new Set(['node_modules', '.git'])): string { + const hash = crypto.createHash('sha256'); + + function walk(dir: string): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .filter((entry) => !excludes.has(entry.name)) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + const absolute = path.join(dir, entry.name); + const relative = path.relative(rootDir, absolute).split(path.sep).join('/'); + if (entry.isDirectory()) { + hash.update(`dir:${relative}\n`); + walk(absolute); + } else if (entry.isFile()) { + hash.update(`file:${relative}\n`); + hash.update(fs.readFileSync(absolute)); + } + } + } + + walk(rootDir); + return hash.digest('hex'); +} diff --git a/packages/cli/src/utils/auth-store.ts b/packages/cli/src/utils/auth-store.ts new file mode 100644 index 0000000..3d7ee03 --- /dev/null +++ b/packages/cli/src/utils/auth-store.ts @@ -0,0 +1,207 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { resolvePackageRegistry } from './config-store.js'; + +type JsonRecord = Record; + +export interface IRegistryCredential { + readonly token: string; + readonly username: string; + readonly email?: string; + readonly expiresAt?: string; +} + +interface ICredentialFile { + readonly registries: Record; +} + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +function matrixHome(cwd: string): string { + return path.resolve( + cwd, + process.env.MATRIX_HOME + ?? path.join(cwd, '.matrix'), + ); +} + +function resolveCredentialPath(cwd: string, credentialsPath?: string): string { + return path.resolve( + cwd, + credentialsPath ?? path.join(matrixHome(cwd), 'credentials', 'registry.json'), + ); +} + +function readCredentialFile(filePath: string): ICredentialFile { + if (!fs.existsSync(filePath)) { + return { registries: {} }; + } + if (fs.statSync(filePath).isDirectory()) { + throw new Error(`MX_AUTH_CREDENTIALS_PATH_IS_DIRECTORY: ${filePath}`); + } + + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + const record = asRecord(parsed); + const registries = asRecord(record?.registries) ?? {}; + const normalized: Record = {}; + + for (const [registry, value] of Object.entries(registries)) { + const entry = asRecord(value); + if (!entry) continue; + if (typeof entry.token !== 'string' || typeof entry.username !== 'string') continue; + normalized[registry] = { + token: entry.token, + username: entry.username, + email: typeof entry.email === 'string' ? entry.email : undefined, + expiresAt: typeof entry.expiresAt === 'string' ? entry.expiresAt : undefined, + }; + } + + return { registries: normalized }; +} + +function writeCredentialFile(filePath: string, payload: ICredentialFile): void { + if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) { + throw new Error(`MX_AUTH_CREDENTIALS_PATH_IS_DIRECTORY: ${filePath}`); + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8'); +} + +function readNpmTokenFromEnvironment(): string | null { + const token = process.env.NODE_AUTH_TOKEN ?? process.env.NPM_TOKEN; + if (typeof token !== 'string') { + return null; + } + const trimmed = token.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function configuredNpmUserConfigPath(): string | undefined { + const value = process.env.NPM_CONFIG_USERCONFIG ?? process.env.npm_config_userconfig; + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function npmrcSearchPaths(cwd: string): readonly string[] { + const configuredUserConfig = configuredNpmUserConfigPath(); + if (configuredUserConfig) { + return [configuredUserConfig]; + } + return [ + path.join(cwd, '.npmrc'), + path.join(os.homedir(), '.npmrc'), + ]; +} + +function readNpmTokenFromNpmrc(filePath: string, registry: string): string | null { + if (!fs.existsSync(filePath)) { + return null; + } + const lines = fs.readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#') && !line.startsWith(';')); + const registryKeys = npmRegistryAuthKeys(registry); + for (const line of lines) { + for (const key of registryKeys) { + const prefix = `${key}:_authToken=`; + if (line.startsWith(prefix)) { + const token = line.slice(prefix.length).trim(); + return token.length > 0 ? token : null; + } + } + } + return null; +} + +function npmRegistryAuthKeys(registry: string): readonly string[] { + const parsed = new URL(registry); + const host = parsed.host; + const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`; + const segments = pathname.split('/').filter((segment) => segment.length > 0); + const keys = new Set(); + for (let length = segments.length; length >= 0; length -= 1) { + const partial = length === 0 ? '/' : `/${segments.slice(0, length).join('/')}/`; + keys.add(`//${host}${partial}`); + } + return [...keys]; +} + +function readNpmCredential(cwd: string, registry: string): IRegistryCredential | null { + const envToken = readNpmTokenFromEnvironment(); + if (envToken) { + return { username: 'npm-token', token: envToken }; + } + for (const filePath of npmrcSearchPaths(cwd)) { + const token = readNpmTokenFromNpmrc(filePath, registry); + if (token) { + return { username: 'npm-token', token }; + } + } + return null; +} + +export function saveRegistryCredential( + cwd: string, + credential: IRegistryCredential, + options: { + readonly registry?: string; + readonly credentialsPath?: string; + } = {}, +): { readonly registry: string; readonly credentialsPath: string } { + const registry = resolvePackageRegistry(options.registry, { cwd }).registry; + const filePath = resolveCredentialPath(cwd, options.credentialsPath); + const payload = readCredentialFile(filePath); + payload.registries[registry] = credential; + writeCredentialFile(filePath, payload); + return { registry, credentialsPath: filePath }; +} + +export function removeRegistryCredential( + cwd: string, + options: { + readonly registry?: string; + readonly credentialsPath?: string; + } = {}, +): { readonly registry: string; readonly removed: boolean; readonly credentialsPath: string } { + const registry = resolvePackageRegistry(options.registry, { cwd }).registry; + const filePath = resolveCredentialPath(cwd, options.credentialsPath); + const payload = readCredentialFile(filePath); + const removed = Boolean(payload.registries[registry]); + if (removed) { + delete payload.registries[registry]; + writeCredentialFile(filePath, payload); + } + return { registry, removed, credentialsPath: filePath }; +} + +export function readRegistryCredential( + cwd: string, + options: { + readonly registry?: string; + readonly credentialsPath?: string; + } = {}, +): { + readonly registry: string; + readonly credentialsPath: string; + readonly credential: IRegistryCredential | null; +} { + const registry = resolvePackageRegistry(options.registry, { cwd }).registry; + const filePath = resolveCredentialPath(cwd, options.credentialsPath); + const payload = readCredentialFile(filePath); + return { + registry, + credentialsPath: filePath, + credential: payload.registries[registry] ?? readNpmCredential(cwd, registry), + }; +} diff --git a/packages/cli/src/utils/config-store.ts b/packages/cli/src/utils/config-store.ts new file mode 100644 index 0000000..7a928fd --- /dev/null +++ b/packages/cli/src/utils/config-store.ts @@ -0,0 +1,174 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +type JsonRecord = Record; + +export const DEFAULT_PACKAGE_REGISTRY = 'https://registry.npmjs.org/'; + +export interface IMatrixCliConfig { + readonly registry?: string; +} + +export interface IConfigStoreOptions { + readonly cwd?: string; + readonly configPath?: string; +} + +export interface IResolvedRegistryConfig { + readonly registry: string; + readonly source: 'explicit' | 'env' | 'config' | 'default'; + readonly configPath: string; +} + +export interface IConfigCommandResult { + readonly configPath: string; + readonly values: IMatrixCliConfig; +} + +const CONFIG_KEYS = new Set(['registry']); + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeRegistryUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error('MX_CONFIG_INVALID: registry must be a non-empty HTTP(S) URL'); + } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error(`MX_CONFIG_INVALID: registry must be an HTTP(S) URL (${value})`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`MX_CONFIG_INVALID: registry must be an HTTP(S) URL (${value})`); + } + if (!parsed.pathname.endsWith('/')) { + parsed.pathname = `${parsed.pathname}/`; + } + return parsed.toString(); +} + +function matrixHome(cwd: string): string { + return path.resolve( + cwd, + process.env.MATRIX_HOME + ?? path.join(cwd, '.matrix'), + ); +} + +export function resolveMatrixCliConfigPath(options: IConfigStoreOptions = {}): string { + const cwd = path.resolve(options.cwd ?? process.cwd()); + return path.resolve( + cwd, + options.configPath + ?? process.env.MATRIX_CLI_CONFIG + ?? path.join(matrixHome(cwd), 'config.json'), + ); +} + +export function readMatrixCliConfig(options: IConfigStoreOptions = {}): IConfigCommandResult { + const configPath = resolveMatrixCliConfigPath(options); + if (!fs.existsSync(configPath)) { + return { configPath, values: {} }; + } + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as unknown; + const record = asRecord(parsed); + if (!record) { + throw new Error(`MX_CONFIG_INVALID: ${configPath} must contain a JSON object`); + } + const registry = readOptionalString(record.registry); + return { + configPath, + values: { + ...(registry ? { registry: normalizeRegistryUrl(registry) } : {}), + }, + }; +} + +export function writeMatrixCliConfig( + values: IMatrixCliConfig, + options: IConfigStoreOptions = {}, +): IConfigCommandResult { + const configPath = resolveMatrixCliConfigPath(options); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, `${JSON.stringify(values, null, 2)}\n`, 'utf8'); + return { configPath, values }; +} + +export function setMatrixCliConfigValue( + key: string, + value: string, + options: IConfigStoreOptions = {}, +): IConfigCommandResult { + const normalizedKey = key.trim(); + if (!CONFIG_KEYS.has(normalizedKey)) { + throw new Error(`MX_CONFIG_INVALID: unsupported config key "${key}"`); + } + const current = readMatrixCliConfig(options); + const next: IMatrixCliConfig = { + ...current.values, + registry: normalizeRegistryUrl(value), + }; + return writeMatrixCliConfig(next, options); +} + +export function resetMatrixCliConfig(options: IConfigStoreOptions = {}): IConfigCommandResult { + const configPath = resolveMatrixCliConfigPath(options); + if (fs.existsSync(configPath)) { + fs.rmSync(configPath, { force: true }); + } + return { configPath, values: {} }; +} + +export function resolvePackageRegistry( + explicitRegistry: string | undefined, + options: IConfigStoreOptions = {}, +): IResolvedRegistryConfig { + const configPath = resolveMatrixCliConfigPath(options); + if (explicitRegistry && explicitRegistry.trim().length > 0) { + return { + registry: normalizeRegistryUrl(explicitRegistry), + source: 'explicit', + configPath, + }; + } + + const envRegistry = process.env.MATRIX_PACKAGE_REGISTRY + ?? process.env.MATRIX_REGISTRY_URL; + if (envRegistry && envRegistry.trim().length > 0) { + return { + registry: normalizeRegistryUrl(envRegistry), + source: 'env', + configPath, + }; + } + + const stored = readMatrixCliConfig(options).values.registry; + if (stored) { + return { + registry: stored, + source: 'config', + configPath, + }; + } + + return { + registry: DEFAULT_PACKAGE_REGISTRY, + source: 'default', + configPath, + }; +} diff --git a/packages/cli/src/utils/discovery-extractor.ts b/packages/cli/src/utils/discovery-extractor.ts new file mode 100644 index 0000000..efe4eee --- /dev/null +++ b/packages/cli/src/utils/discovery-extractor.ts @@ -0,0 +1,538 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + readManifestFromPackageDir, + readPackageJson, + readVersionFromPackageDir, +} from './package-store.js'; + +type JsonRecord = Record; + +export interface IWebappDiscoveryMetadata { + readonly appName: string; + readonly routePrefix?: string; + readonly displayName?: string; + readonly icon?: string; + readonly navOrder?: number; + readonly description?: string; + readonly shells: readonly string[]; +} + +export interface IComponentDiscoveryMetadata { + readonly type: string; + readonly exportName?: string; + readonly mount?: string; + readonly surface?: string; + readonly description?: string; + readonly kind?: string; + readonly accepts: readonly string[]; + readonly emits: readonly string[]; + readonly skills: readonly string[]; + readonly tags: readonly string[]; + readonly capabilities: readonly string[]; +} + +export interface IPackageDiscoveryMetadata { + readonly packageName: string; + readonly version: string; + readonly displayName?: string; + readonly description?: string; + readonly declaredKind?: string; + readonly inferredKind: string; + readonly tags: readonly string[]; + readonly capabilities: readonly string[]; + readonly permissions: JsonRecord | null; + readonly webapp?: IWebappDiscoveryMetadata; + readonly components: readonly IComponentDiscoveryMetadata[]; +} + +interface IClassStaticMetadata { + readonly className: string; + readonly description?: string; + readonly kind?: string; + readonly accepts: readonly string[]; + readonly emits: readonly string[]; + readonly skills: readonly string[]; + readonly tags: readonly string[]; + readonly capabilities: readonly string[]; +} + +function asRecord(value: unknown): JsonRecord | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : null; +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function readOptionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function readStringArray(value: unknown): readonly string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function readObjectKeys(value: unknown): readonly string[] { + const record = asRecord(value); + return record ? Object.keys(record).sort((left, right) => left.localeCompare(right)) : []; +} + +function inferPackageKind(manifest: JsonRecord): string { + const webapp = asRecord(manifest.webapp); + const components = Array.isArray(manifest.components) ? manifest.components : []; + if (webapp && components.length > 0) { + return 'app'; + } + if (webapp) { + return 'webapp'; + } + if (components.length > 0) { + return 'service'; + } + return 'library'; +} + +function readPackageDescription(packageDir: string, manifest: JsonRecord): string | undefined { + const manifestDescription = readOptionalString(manifest.description); + if (manifestDescription) { + return manifestDescription; + } + try { + return readOptionalString(readPackageJson(packageDir).description); + } catch { + return undefined; + } +} + +function readWebappMetadata(manifest: JsonRecord): IWebappDiscoveryMetadata | undefined { + const webapp = asRecord(manifest.webapp); + const appName = readOptionalString(webapp?.appName); + if (!webapp || !appName) { + return undefined; + } + const shells = readStringArray(webapp.shells); + return { + appName, + ...(readOptionalString(webapp.routePrefix) ? { routePrefix: readOptionalString(webapp.routePrefix) } : {}), + ...(readOptionalString(webapp.displayName) ? { displayName: readOptionalString(webapp.displayName) } : {}), + ...(readOptionalString(webapp.icon) ? { icon: readOptionalString(webapp.icon) } : {}), + ...(readOptionalNumber(webapp.navOrder) !== undefined ? { navOrder: readOptionalNumber(webapp.navOrder) } : {}), + ...(readOptionalString(webapp.description) ? { description: readOptionalString(webapp.description) } : {}), + shells: shells.length > 0 ? shells : ['platform', 'edge'], + }; +} + +function readComponentManifestEntry(value: unknown): IComponentDiscoveryMetadata | null { + const record = asRecord(value); + const type = readOptionalString(record?.type); + if (!record || !type) { + return null; + } + return { + type, + ...(readOptionalString(record.export) ? { exportName: readOptionalString(record.export) } : {}), + ...(readOptionalString(record.mount) ? { mount: readOptionalString(record.mount) } : {}), + ...(readOptionalString(record.surface) ? { surface: readOptionalString(record.surface) } : {}), + ...(readOptionalString(record.description) ? { description: readOptionalString(record.description) } : {}), + ...(readOptionalString(record.kind) ? { kind: readOptionalString(record.kind) } : {}), + accepts: readStringArray(record.accepts).length > 0 ? readStringArray(record.accepts) : readObjectKeys(record.accepts), + emits: readStringArray(record.emits).length > 0 ? readStringArray(record.emits) : readObjectKeys(record.emits), + skills: readStringArray(record.skills), + tags: readStringArray(record.tags), + capabilities: readStringArray(record.capabilities), + }; +} + +function mergeUnique(left: readonly string[], right: readonly string[]): readonly string[] { + return [...new Set([...left, ...right])].sort((a, b) => a.localeCompare(b)); +} + +function enrichComponent( + component: IComponentDiscoveryMetadata, + classStatics: ReadonlyMap, +): IComponentDiscoveryMetadata { + const statics = classStatics.get(component.type) + ?? (component.exportName ? classStatics.get(component.exportName) : undefined); + if (!statics) { + return component; + } + return { + ...component, + description: component.description ?? statics.description, + kind: component.kind ?? statics.kind, + accepts: mergeUnique(component.accepts, statics.accepts), + emits: mergeUnique(component.emits, statics.emits), + skills: mergeUnique(component.skills, statics.skills), + tags: mergeUnique(component.tags, statics.tags), + capabilities: mergeUnique(component.capabilities, statics.capabilities), + }; +} + +function readSourceFiles(packageDir: string): readonly string[] { + const roots = ['src']; + const runtimeEntry = readOptionalString(asRecord(readManifestFromPackageDir(packageDir).manifest.runtime)?.entry); + if (runtimeEntry) { + roots.push(path.dirname(runtimeEntry)); + } + const sourceFiles: string[] = []; + const visited = new Set(); + + function walk(dir: string): void { + const resolved = path.resolve(dir); + if (visited.has(resolved) || !fs.existsSync(resolved)) { + return; + } + visited.add(resolved); + for (const entry of fs.readdirSync(resolved, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) { + continue; + } + const fullPath = path.join(resolved, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + continue; + } + if (entry.isFile() && /\.(?:ts|tsx|js|mjs|cjs)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { + sourceFiles.push(fullPath); + } + } + } + + for (const root of roots) { + walk(path.join(packageDir, root)); + } + return [...new Set(sourceFiles)].sort((left, right) => left.localeCompare(right)); +} + +function findMatchingBrace(source: string, openIndex: number): number { + let depth = 0; + let quote: '"' | '\'' | '`' | null = null; + let escaped = false; + let lineComment = false; + let blockComment = false; + + for (let index = openIndex; index < source.length; index += 1) { + const char = source[index] ?? ''; + const next = source[index + 1] ?? ''; + + if (lineComment) { + if (char === '\n') lineComment = false; + continue; + } + if (blockComment) { + if (char === '*' && next === '/') { + blockComment = false; + index += 1; + } + continue; + } + if (quote) { + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + if (char === '/' && next === '/') { + lineComment = true; + index += 1; + continue; + } + if (char === '/' && next === '*') { + blockComment = true; + index += 1; + continue; + } + if (char === '"' || char === '\'' || char === '`') { + quote = char; + continue; + } + if (char === '{') { + depth += 1; + continue; + } + if (char === '}') { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + return -1; +} + +function readStaticString(classBody: string, fieldName: string): string | undefined { + const pattern = new RegExp(`static\\s+${fieldName}\\s*(?::[^=;]+)?=\\s*(['"\`])([\\s\\S]*?)\\1`); + const match = pattern.exec(classBody); + return readOptionalString(match?.[2]); +} + +function readStaticArray(classBody: string, fieldName: string): readonly string[] { + const fieldIndex = classBody.search(new RegExp(`static\\s+${fieldName}\\s*(?::[^=;]+)?=`)); + if (fieldIndex < 0) { + return []; + } + const openIndex = classBody.indexOf('[', fieldIndex); + if (openIndex < 0) { + return []; + } + const closeIndex = findMatchingBracket(classBody, openIndex, '[', ']'); + if (closeIndex < 0) { + return []; + } + const body = classBody.slice(openIndex + 1, closeIndex); + const values: string[] = []; + const stringPattern = /(['"`])([\s\S]*?)\1/g; + let match: RegExpExecArray | null; + while ((match = stringPattern.exec(body)) !== null) { + const value = readOptionalString(match[2]); + if (value) values.push(value); + } + return [...new Set(values)].sort((left, right) => left.localeCompare(right)); +} + +function findMatchingBracket( + source: string, + openIndex: number, + openToken: '[' | '{', + closeToken: ']' | '}', +): number { + let depth = 0; + let quote: '"' | '\'' | '`' | null = null; + let escaped = false; + for (let index = openIndex; index < source.length; index += 1) { + const char = source[index] ?? ''; + if (quote) { + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + if (char === '"' || char === '\'' || char === '`') { + quote = char; + continue; + } + if (char === openToken) { + depth += 1; + continue; + } + if (char === closeToken) { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + return -1; +} + +function readStaticObjectKeys(classBody: string, fieldName: string): readonly string[] { + const fieldIndex = classBody.search(new RegExp(`static\\s+${fieldName}\\s*(?::[^=;]+)?=`)); + if (fieldIndex < 0) { + return []; + } + const openIndex = classBody.indexOf('{', fieldIndex); + if (openIndex < 0) { + return []; + } + const closeIndex = findMatchingBracket(classBody, openIndex, '{', '}'); + if (closeIndex < 0) { + return []; + } + const objectText = classBody.slice(openIndex + 1, closeIndex); + return readTopLevelObjectKeys(objectText); +} + +function readTopLevelObjectKeys(objectText: string): readonly string[] { + const keys: string[] = []; + let index = 0; + let depth = 0; + let quote: '"' | '\'' | '`' | null = null; + let escaped = false; + let expectingKey = true; + + while (index < objectText.length) { + const char = objectText[index] ?? ''; + if (quote) { + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === quote) { + quote = null; + } + index += 1; + continue; + } + + if (char === '"' || char === '\'' || char === '`') { + if (depth === 0 && expectingKey) { + const closeIndex = findStringEnd(objectText, index, char); + if (closeIndex > index) { + const key = readOptionalString(objectText.slice(index + 1, closeIndex)); + const afterKey = skipWhitespace(objectText, closeIndex + 1); + if (key && objectText[afterKey] === ':') { + keys.push(key); + expectingKey = false; + index = afterKey + 1; + continue; + } + } + } + quote = char; + index += 1; + continue; + } + + if (depth === 0 && expectingKey && /[A-Za-z_$]/.test(char)) { + let endIndex = index + 1; + while (endIndex < objectText.length && /[\w$-]/.test(objectText[endIndex] ?? '')) { + endIndex += 1; + } + const key = objectText.slice(index, endIndex); + const afterKey = skipWhitespace(objectText, endIndex); + if (objectText[afterKey] === ':') { + keys.push(key); + expectingKey = false; + index = afterKey + 1; + continue; + } + } + + if (char === '{' || char === '[' || char === '(') { + depth += 1; + } else if (char === '}' || char === ']' || char === ')') { + depth = Math.max(0, depth - 1); + } else if (char === ',' && depth === 0) { + expectingKey = true; + } + index += 1; + } + return [...new Set(keys)].sort((left, right) => left.localeCompare(right)); +} + +function skipWhitespace(source: string, startIndex: number): number { + let index = startIndex; + while (index < source.length && /\s/.test(source[index] ?? '')) { + index += 1; + } + return index; +} + +function findStringEnd(source: string, startIndex: number, quote: '"' | '\'' | '`'): number { + let escaped = false; + for (let index = startIndex + 1; index < source.length; index += 1) { + const char = source[index] ?? ''; + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === quote) { + return index; + } + } + return -1; +} + +function extractClassStaticsFromSource(source: string): readonly IClassStaticMetadata[] { + const classes: IClassStaticMetadata[] = []; + const classPattern = /(?:export\s+)?class\s+([A-Za-z_$][\w$]*)\b/g; + let match: RegExpExecArray | null; + while ((match = classPattern.exec(source)) !== null) { + const className = match[1] ?? ''; + const openIndex = source.indexOf('{', match.index + match[0].length); + if (!className || openIndex < 0) { + continue; + } + const closeIndex = findMatchingBrace(source, openIndex); + if (closeIndex < 0) { + continue; + } + const classBody = source.slice(openIndex + 1, closeIndex); + classes.push({ + className, + ...(readStaticString(classBody, 'description') ? { description: readStaticString(classBody, 'description') } : {}), + ...(readStaticString(classBody, 'kind') ? { kind: readStaticString(classBody, 'kind') } : {}), + accepts: readStaticObjectKeys(classBody, 'accepts'), + emits: readStaticObjectKeys(classBody, 'emits'), + skills: readStaticObjectKeys(classBody, 'skills'), + tags: readStaticArray(classBody, 'tags'), + capabilities: readStaticArray(classBody, 'capabilities'), + }); + classPattern.lastIndex = closeIndex + 1; + } + return classes; +} + +function extractClassStatics(packageDir: string): ReadonlyMap { + const statics = new Map(); + for (const sourceFile of readSourceFiles(packageDir)) { + const source = fs.readFileSync(sourceFile, 'utf8'); + for (const entry of extractClassStaticsFromSource(source)) { + statics.set(entry.className, entry); + } + } + return statics; +} + +export function extractDiscoveryMetadata(packageDir: string): IPackageDiscoveryMetadata { + const resolved = readManifestFromPackageDir(packageDir); + const version = readVersionFromPackageDir(packageDir); + const manifest = resolved.manifest; + const classStatics = extractClassStatics(packageDir); + const componentEntries = Array.isArray(manifest.components) + ? manifest.components + .map(readComponentManifestEntry) + .filter((entry): entry is IComponentDiscoveryMetadata => Boolean(entry)) + : []; + + const webapp = readWebappMetadata(manifest); + const declaredKind = readOptionalString(manifest.declaredKind) + ?? readOptionalString(manifest.kind) + ?? readOptionalString(manifest.class); + const displayName = readOptionalString(manifest.displayName) + ?? webapp?.displayName + ?? resolved.packageName.split('/').at(-1); + + return { + packageName: resolved.packageName, + version, + ...(displayName ? { displayName } : {}), + ...(readPackageDescription(packageDir, manifest) ? { description: readPackageDescription(packageDir, manifest) } : {}), + ...(declaredKind ? { declaredKind } : {}), + inferredKind: inferPackageKind(manifest), + tags: readStringArray(manifest.tags), + capabilities: readStringArray(manifest.capabilities), + permissions: asRecord(manifest.permissions), + ...(webapp ? { webapp } : {}), + components: componentEntries.map((component) => enrichComponent(component, classStatics)), + }; +} diff --git a/packages/cli/src/utils/discovery-index.ts b/packages/cli/src/utils/discovery-index.ts new file mode 100644 index 0000000..b11bd6a --- /dev/null +++ b/packages/cli/src/utils/discovery-index.ts @@ -0,0 +1,378 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { + IComponentDiscoveryMetadata, + IPackageDiscoveryMetadata, +} from './discovery-extractor.js'; + +type JsonRecord = Record; + +export interface IPackageDiscoveryIndexVersion { + readonly indexedAt: string; + readonly indexedBy: string; + readonly metadata: IPackageDiscoveryMetadata; +} + +export interface IPackageDiscoveryIndexPackage { + readonly versions: Record; +} + +export interface IPackageDiscoveryIndex { + readonly packages: Record; +} + +export interface IPackageDiscoverySearchQuery { + readonly text?: string; + readonly tag?: string; + readonly inferredKind?: string; + readonly declaredKind?: string; + readonly capability?: string; + readonly accepts?: string; + readonly emits?: string; + readonly skill?: string; + readonly shells?: string; +} + +export interface IPackageDiscoverySearchResult { + readonly packageName: string; + readonly version: string; + readonly description?: string; + readonly displayName?: string; + readonly inferredKind: string; + readonly declaredKind?: string; + readonly keywords: readonly string[]; + readonly matchedFields: readonly string[]; + readonly score: number; + readonly webapp?: { + readonly appName: string; + readonly routePrefix?: string; + readonly shells: readonly string[]; + readonly icon?: string; + }; + readonly components: readonly IComponentDiscoveryMetadata[]; +} + +function asRecord(value: unknown): JsonRecord | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : null; +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function readStringArray(value: unknown): readonly string[] { + return Array.isArray(value) + ? value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + : []; +} + +function readComponent(value: unknown): IComponentDiscoveryMetadata | null { + const record = asRecord(value); + const type = readOptionalString(record?.type); + if (!record || !type) { + return null; + } + return { + type, + ...(readOptionalString(record.exportName) ? { exportName: readOptionalString(record.exportName) } : {}), + ...(readOptionalString(record.mount) ? { mount: readOptionalString(record.mount) } : {}), + ...(readOptionalString(record.surface) ? { surface: readOptionalString(record.surface) } : {}), + ...(readOptionalString(record.description) ? { description: readOptionalString(record.description) } : {}), + ...(readOptionalString(record.kind) ? { kind: readOptionalString(record.kind) } : {}), + accepts: readStringArray(record.accepts), + emits: readStringArray(record.emits), + skills: readStringArray(record.skills), + tags: readStringArray(record.tags), + capabilities: readStringArray(record.capabilities), + }; +} + +function readPackageMetadata(value: unknown): IPackageDiscoveryMetadata | null { + const record = asRecord(value); + const packageName = readOptionalString(record?.packageName); + const version = readOptionalString(record?.version); + const inferredKind = readOptionalString(record?.inferredKind); + if (!record || !packageName || !version || !inferredKind) { + return null; + } + const webappRecord = asRecord(record.webapp); + const appName = readOptionalString(webappRecord?.appName); + return { + packageName, + version, + ...(readOptionalString(record.displayName) ? { displayName: readOptionalString(record.displayName) } : {}), + ...(readOptionalString(record.description) ? { description: readOptionalString(record.description) } : {}), + ...(readOptionalString(record.declaredKind) ? { declaredKind: readOptionalString(record.declaredKind) } : {}), + inferredKind, + tags: readStringArray(record.tags), + capabilities: readStringArray(record.capabilities), + permissions: asRecord(record.permissions), + ...(webappRecord && appName ? { + webapp: { + appName, + ...(readOptionalString(webappRecord.routePrefix) ? { routePrefix: readOptionalString(webappRecord.routePrefix) } : {}), + ...(readOptionalString(webappRecord.displayName) ? { displayName: readOptionalString(webappRecord.displayName) } : {}), + ...(readOptionalString(webappRecord.icon) ? { icon: readOptionalString(webappRecord.icon) } : {}), + ...(typeof webappRecord.navOrder === 'number' && Number.isFinite(webappRecord.navOrder) + ? { navOrder: webappRecord.navOrder } + : {}), + ...(readOptionalString(webappRecord.description) ? { description: readOptionalString(webappRecord.description) } : {}), + shells: readStringArray(webappRecord.shells), + }, + } : {}), + components: Array.isArray(record.components) + ? record.components + .map(readComponent) + .filter((entry): entry is IComponentDiscoveryMetadata => Boolean(entry)) + : [], + }; +} + +function resolveDiscoveryIndexPath(registryRoot: string): string { + return path.join(registryRoot, 'discovery-index.json'); +} + +export function readPackageDiscoveryIndex(registryRoot: string): IPackageDiscoveryIndex { + const indexPath = resolveDiscoveryIndexPath(registryRoot); + if (!fs.existsSync(indexPath)) { + return { packages: {} }; + } + const root = asRecord(JSON.parse(fs.readFileSync(indexPath, 'utf8')) as unknown); + const packagesRecord = asRecord(root?.packages) ?? {}; + const packages: Record = {}; + + for (const [packageName, packageValue] of Object.entries(packagesRecord)) { + const packageRecord = asRecord(packageValue); + const versionsRecord = asRecord(packageRecord?.versions) ?? {}; + const versions: Record = {}; + for (const [version, versionValue] of Object.entries(versionsRecord)) { + const versionRecord = asRecord(versionValue); + const indexedAt = readOptionalString(versionRecord?.indexedAt); + const indexedBy = readOptionalString(versionRecord?.indexedBy); + const metadata = readPackageMetadata(versionRecord?.metadata); + if (!indexedAt || !indexedBy || !metadata) { + continue; + } + versions[version] = { indexedAt, indexedBy, metadata }; + } + packages[packageName] = { versions }; + } + return { packages }; +} + +export function publishPackageDiscoveryMetadata( + registryRoot: string, + metadata: IPackageDiscoveryMetadata, + indexedBy: string, +): void { + const index = readPackageDiscoveryIndex(registryRoot); + const packageEntry = index.packages[metadata.packageName] ?? { versions: {} }; + packageEntry.versions[metadata.version] = { + indexedAt: new Date().toISOString(), + indexedBy, + metadata, + }; + index.packages[metadata.packageName] = packageEntry; + + const indexPath = resolveDiscoveryIndexPath(registryRoot); + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf8'); +} + +function latestVersionEntry(entry: IPackageDiscoveryIndexPackage): IPackageDiscoveryIndexVersion | null { + const versions = Object.entries(entry.versions); + if (versions.length === 0) { + return null; + } + versions.sort(([left], [right]) => left.localeCompare(right, undefined, { numeric: true })); + return versions.at(-1)?.[1] ?? null; +} + +function toLowerTerms(values: readonly string[]): readonly string[] { + return values + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0); +} + +function globMatches(pattern: string | undefined, values: readonly string[]): boolean { + if (!pattern) { + return true; + } + const lowerPattern = pattern.toLowerCase(); + const escaped = lowerPattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + const regexp = new RegExp(`^${escaped}$`); + return values.some((value) => regexp.test(value.toLowerCase())); +} + +function collectSearchFields(metadata: IPackageDiscoveryMetadata): readonly [string, string][] { + const fields: Array<[string, string]> = []; + function push(field: string, value: string | undefined): void { + if (value && value.trim().length > 0) { + fields.push([field, value]); + } + } + + push('packageName', metadata.packageName); + push('displayName', metadata.displayName); + push('description', metadata.description); + push('inferredKind', metadata.inferredKind); + push('declaredKind', metadata.declaredKind); + for (const tag of metadata.tags) push('tag', tag); + for (const capability of metadata.capabilities) push('capability', capability); + if (metadata.webapp) { + push('webapp.appName', metadata.webapp.appName); + push('webapp.displayName', metadata.webapp.displayName); + push('webapp.description', metadata.webapp.description); + for (const shell of metadata.webapp.shells) push('webapp.shells', shell); + } + for (const component of metadata.components) { + push('component.type', component.type); + push('component.exportName', component.exportName); + push('component.mount', component.mount); + push('component.surface', component.surface); + push('component.description', component.description); + push('component.kind', component.kind); + for (const value of component.accepts) push('component.accepts', value); + for (const value of component.emits) push('component.emits', value); + for (const value of component.skills) push('component.skills', value); + for (const value of component.tags) push('component.tags', value); + for (const value of component.capabilities) push('component.capabilities', value); + } + return fields; +} + +function scoreText(metadata: IPackageDiscoveryMetadata, text: string | undefined): { + readonly matches: boolean; + readonly score: number; + readonly matchedFields: readonly string[]; +} { + const tokens = toLowerTerms((text ?? '').split(/\s+/)); + if (tokens.length === 0) { + return { matches: true, score: 0, matchedFields: [] }; + } + const fields = collectSearchFields(metadata); + let score = 0; + const matchedFields = new Set(); + for (const token of tokens) { + let tokenMatched = false; + for (const [field, value] of fields) { + if (value.toLowerCase().includes(token)) { + tokenMatched = true; + matchedFields.add(field); + score += field === 'packageName' || field === 'displayName' ? 5 : 1; + } + } + if (!tokenMatched) { + return { matches: false, score: 0, matchedFields: [] }; + } + } + return { + matches: true, + score, + matchedFields: [...matchedFields].sort((left, right) => left.localeCompare(right)), + }; +} + +function metadataMatchesFilters(metadata: IPackageDiscoveryMetadata, query: IPackageDiscoverySearchQuery): boolean { + if (query.tag && !metadata.tags.some((tag) => tag.toLowerCase() === query.tag?.toLowerCase())) { + return false; + } + if (query.inferredKind && metadata.inferredKind.toLowerCase() !== query.inferredKind.toLowerCase()) { + return false; + } + if (query.declaredKind && metadata.declaredKind?.toLowerCase() !== query.declaredKind.toLowerCase()) { + return false; + } + if (query.capability && !metadata.capabilities.some((capability) => capability.toLowerCase() === query.capability?.toLowerCase())) { + return false; + } + if (query.shells && !metadata.webapp?.shells.some((shell) => shell.toLowerCase() === query.shells?.toLowerCase())) { + return false; + } + if (!globMatches(query.accepts, metadata.components.flatMap((component) => component.accepts))) { + return false; + } + if (!globMatches(query.emits, metadata.components.flatMap((component) => component.emits))) { + return false; + } + if (!globMatches(query.skill, metadata.components.flatMap((component) => component.skills))) { + return false; + } + return true; +} + +function keywordsFor(metadata: IPackageDiscoveryMetadata): readonly string[] { + return [ + metadata.inferredKind, + ...(metadata.declaredKind ? [metadata.declaredKind] : []), + ...metadata.tags, + ...metadata.capabilities, + ...(metadata.webapp ? metadata.webapp.shells : []), + ...metadata.components.flatMap((component) => [ + component.surface, + component.kind, + ...component.skills, + ...component.tags, + ...component.capabilities, + ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0)), + ].filter((value, index, all) => all.indexOf(value) === index); +} + +export function searchPackageDiscoveryIndex( + registryRoot: string, + query: IPackageDiscoverySearchQuery, + limit: number, +): readonly IPackageDiscoverySearchResult[] { + const index = readPackageDiscoveryIndex(registryRoot); + const results: IPackageDiscoverySearchResult[] = []; + for (const [packageName, entry] of Object.entries(index.packages)) { + const latest = latestVersionEntry(entry); + if (!latest) { + continue; + } + const metadata = latest.metadata; + if (metadata.packageName !== packageName) { + continue; + } + if (!metadataMatchesFilters(metadata, query)) { + continue; + } + const textScore = scoreText(metadata, query.text); + if (!textScore.matches) { + continue; + } + results.push({ + packageName: metadata.packageName, + version: metadata.version, + ...(metadata.description ? { description: metadata.description } : {}), + ...(metadata.displayName ? { displayName: metadata.displayName } : {}), + inferredKind: metadata.inferredKind, + ...(metadata.declaredKind ? { declaredKind: metadata.declaredKind } : {}), + keywords: keywordsFor(metadata), + matchedFields: textScore.matchedFields, + score: textScore.score, + ...(metadata.webapp ? { + webapp: { + appName: metadata.webapp.appName, + ...(metadata.webapp.routePrefix ? { routePrefix: metadata.webapp.routePrefix } : {}), + shells: metadata.webapp.shells, + ...(metadata.webapp.icon ? { icon: metadata.webapp.icon } : {}), + }, + } : {}), + components: metadata.components, + }); + } + return results + .sort((left, right) => { + if (left.score !== right.score) return right.score - left.score; + return left.packageName.localeCompare(right.packageName); + }) + .slice(0, limit); +} diff --git a/packages/cli/src/utils/manifestValidator.ts b/packages/cli/src/utils/manifestValidator.ts new file mode 100644 index 0000000..0504b7f --- /dev/null +++ b/packages/cli/src/utils/manifestValidator.ts @@ -0,0 +1,255 @@ +export interface IManifestDiagnostic { + readonly severity: 'error' | 'warn'; + readonly code: string; + readonly message: string; + readonly field?: string; + readonly componentIndex?: number; +} + +export interface IManifestValidationResult { + readonly valid: boolean; + readonly errors: string[]; + readonly diagnostics: ReadonlyArray; +} + +type JsonRecord = Record; + +const SUPPORTED_FS_POLICIES = new Set([ + 'none', + 'matrix-only', + 'project-readonly', + 'project-readwrite', + 'global-readonly', +]); +const SUPPORTED_HOST_API_POLICIES = new Set([ + 'none', + 'same-origin', +]); + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +function optionalTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function validateInstallHook( + diagnostics: IManifestDiagnostic[], + install: JsonRecord, + phase: 'validate' | 'migrate' | 'seed' | 'verify', +): void { + const hook = asRecord(install[phase]); + if (!hook) { + if (install[phase] !== undefined) { + pushError(diagnostics, { + code: 'MXPKG_INSTALL_HOOK_INVALID', + field: `install.${phase}`, + message: `install.${phase} must be an object when provided`, + }); + } + return; + } + + if (!optionalTrimmedString(hook.script)) { + pushError(diagnostics, { + code: 'MXPKG_INSTALL_HOOK_SCRIPT_INVALID', + field: `install.${phase}.script`, + message: `install.${phase}.script must be a non-empty string`, + }); + } +} + +function pushError( + diagnostics: IManifestDiagnostic[], + entry: Omit +): void { + diagnostics.push({ ...entry, severity: 'error' }); +} + +export function validateManifestForMxCli(manifest: unknown): IManifestValidationResult { + const diagnostics: IManifestDiagnostic[] = []; + const manifestRecord = asRecord(manifest); + if (!manifestRecord) { + pushError(diagnostics, { + code: 'MXPKG_MANIFEST_NOT_OBJECT', + message: 'Manifest must be a JSON object', + }); + return { + valid: false, + diagnostics, + errors: diagnostics.map((entry) => entry.message), + }; + } + + if (!optionalTrimmedString(manifestRecord.name)) { + pushError(diagnostics, { + code: 'MXPKG_NAME_INVALID', + field: 'name', + message: 'name must be a non-empty string', + }); + } + + if (!optionalTrimmedString(manifestRecord.version)) { + pushError(diagnostics, { + code: 'MXPKG_VERSION_INVALID', + field: 'version', + message: 'version must be a non-empty string', + }); + } + + const runtime = asRecord(manifestRecord.runtime); + if (!runtime || !optionalTrimmedString(runtime.language) || !optionalTrimmedString(runtime.entry)) { + pushError(diagnostics, { + code: 'MXPKG_RUNTIME_INVALID', + field: 'runtime', + message: 'runtime.language and runtime.entry are required', + }); + } + + const permissions = asRecord(manifestRecord.permissions); + const fsPolicy = permissions ? optionalTrimmedString(permissions.fsPolicy) : undefined; + if (!fsPolicy || !SUPPORTED_FS_POLICIES.has(fsPolicy)) { + pushError(diagnostics, { + code: 'MXPKG_PERMISSIONS_FSPOLICY_INVALID', + field: 'permissions.fsPolicy', + message: 'permissions.fsPolicy must be one of none|matrix-only|project-readonly|project-readwrite|global-readonly', + }); + } + const hostApi = permissions ? optionalTrimmedString(permissions.hostApi) : undefined; + if (permissions?.hostApi !== undefined && (!hostApi || !SUPPORTED_HOST_API_POLICIES.has(hostApi))) { + pushError(diagnostics, { + code: 'MXPKG_PERMISSIONS_HOSTAPI_INVALID', + field: 'permissions.hostApi', + message: 'permissions.hostApi must be one of none|same-origin', + }); + } + for (const field of ['network', 'subprocess', 'env']) { + if (permissions && permissions[field] !== undefined && typeof permissions[field] !== 'boolean') { + pushError(diagnostics, { + code: 'MXPKG_PERMISSIONS_BOOLEAN_INVALID', + field: `permissions.${field}`, + message: `permissions.${field} must be boolean when provided`, + }); + } + } + + const install = asRecord(manifestRecord.install); + if (manifestRecord.install !== undefined && !install) { + pushError(diagnostics, { + code: 'MXPKG_INSTALL_INVALID', + field: 'install', + message: 'install must be an object when provided', + }); + } else if (install) { + validateInstallHook(diagnostics, install, 'validate'); + validateInstallHook(diagnostics, install, 'migrate'); + validateInstallHook(diagnostics, install, 'seed'); + validateInstallHook(diagnostics, install, 'verify'); + } + + const componentsValue = manifestRecord.components; + if (!Array.isArray(componentsValue)) { + pushError(diagnostics, { + code: 'MXPKG_COMPONENTS_NOT_ARRAY', + field: 'components', + message: 'components must be an array', + }); + } else { + componentsValue.forEach((entry, componentIndex) => { + const component = asRecord(entry); + if (!component) { + pushError(diagnostics, { + code: 'MXPKG_COMPONENT_NOT_OBJECT', + field: 'components', + componentIndex, + message: `component[${componentIndex}] must be an object`, + }); + return; + } + + const type = optionalTrimmedString(component.type); + if (!type) { + pushError(diagnostics, { + code: 'MXPKG_COMPONENT_TYPE_INVALID', + field: 'type', + componentIndex, + message: `component[${componentIndex}].type must be a non-empty string`, + }); + return; + } + + const exportName = optionalTrimmedString(component.export); + if (component.export !== undefined && !exportName) { + pushError(diagnostics, { + code: 'MXPKG_COMPONENT_EXPORT_INVALID', + field: 'export', + componentIndex, + message: `component[${componentIndex}].export must be a non-empty string when provided`, + }); + return; + } + + const mount = optionalTrimmedString(component.mount); + if (component.mount !== undefined && !mount) { + pushError(diagnostics, { + code: 'MXPKG_COMPONENT_MOUNT_INVALID', + field: 'mount', + componentIndex, + message: `component[${componentIndex}].mount must be a non-empty string when provided`, + }); + return; + } + + if (component.autoStart !== undefined && typeof component.autoStart !== 'boolean') { + pushError(diagnostics, { + code: 'MXPKG_COMPONENT_AUTOSTART_INVALID', + field: 'autoStart', + componentIndex, + message: `component[${componentIndex}].autoStart must be boolean when provided`, + }); + return; + } + + if (component.props !== undefined && !asRecord(component.props)) { + pushError(diagnostics, { + code: 'MXPKG_COMPONENT_PROPS_INVALID', + field: 'props', + componentIndex, + message: `component[${componentIndex}].props must be an object when provided`, + }); + return; + } + + const componentFsPolicy = optionalTrimmedString(component.fsPolicy); + if ( + component.fsPolicy !== undefined + && (!componentFsPolicy || !SUPPORTED_FS_POLICIES.has(componentFsPolicy)) + ) { + pushError(diagnostics, { + code: 'MXPKG_COMPONENT_FSPOLICY_INVALID', + field: 'fsPolicy', + componentIndex, + message: `component[${componentIndex}].fsPolicy must be one of matrix-only|project-readonly|project-readwrite|global-readonly|none`, + }); + } + }); + } + + const errors = diagnostics + .filter((entry) => entry.severity === 'error') + .map((entry) => entry.message); + + return { + valid: errors.length === 0, + diagnostics, + errors, + }; +} diff --git a/packages/cli/src/utils/npm-registry-client.ts b/packages/cli/src/utils/npm-registry-client.ts new file mode 100644 index 0000000..9fbac8c --- /dev/null +++ b/packages/cli/src/utils/npm-registry-client.ts @@ -0,0 +1,345 @@ +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { readRegistryCredential } from './auth-store.js'; + +export const MATRIX_NPM_REGISTRY_SCOPES = ['@open-matrix', '@matrix', '@omega'] as const; + +export interface INpmCommandResult { + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; +} + +export interface INpmRegistrySearchResult { + readonly packageName: string; + readonly version: string; + readonly description?: string; + readonly keywords: readonly string[]; +} + +export function isHttpRegistryUrl(value: string | undefined): value is string { + if (!value) { + return false; + } + try { + const parsed = new URL(value); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +export function npmPackageSpecifier(packageName: string, version: string | undefined): string { + return version ? `${packageName}@${version}` : packageName; +} + +export async function prepareNpmRegistryTarball( + specifier: string, + registryUrl: string, + cwd: string, + credentialsPath: string | undefined, +): Promise<{ readonly tarballPath: string; readonly cleanupDir: string }> { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-install-npm-registry-')); + const credential = readRegistryCredential(cwd, { + registry: registryUrl, + credentialsPath, + }).credential; + const userConfig = writeTemporaryNpmrc(tempDir, registryUrl, credential?.token ?? null); + const result = await runNpmCommand([ + 'pack', + specifier, + '--pack-destination', + tempDir, + '--registry', + registryUrl, + '--userconfig', + userConfig, + ], { + cwd: tempDir, + registryUrl, + userConfig, + cacheDir: path.join(tempDir, '.npm-cache'), + }); + + if (result.exitCode !== 0) { + fs.rmSync(tempDir, { recursive: true, force: true }); + const output = [result.stderr, result.stdout] + .filter((value) => typeof value === 'string' && value.trim().length > 0) + .join('\n') + .trim(); + throw new Error( + `MX_REGISTRY_FETCH_FAILED: failed to fetch ${specifier} from ${registryUrl}` + + (output ? `\n${output}` : ''), + ); + } + + const tarballs = fs.readdirSync(tempDir) + .filter((entry) => entry.endsWith('.tgz') || entry.endsWith('.tar.gz')); + if (tarballs.length === 0) { + fs.rmSync(tempDir, { recursive: true, force: true }); + throw new Error(`MX_REGISTRY_FETCH_FAILED: npm pack produced no tarball for ${specifier}`); + } + + return { + tarballPath: path.join(tempDir, tarballs[0]!), + cleanupDir: tempDir, + }; +} + +export async function packNpmPublishTarball( + packageDir: string, + outDir: string, +): Promise<{ readonly tarballPath: string; readonly stdout: string; readonly stderr: string }> { + fs.mkdirSync(outDir, { recursive: true }); + const result = await runNpmCommand([ + 'pack', + packageDir, + '--pack-destination', + outDir, + ], { + cwd: packageDir, + cacheDir: path.join(outDir, '.npm-cache'), + }); + if (result.exitCode !== 0) { + const output = [result.stderr, result.stdout] + .filter((value) => typeof value === 'string' && value.trim().length > 0) + .join('\n') + .trim(); + throw new Error(`MX_REGISTRY_PACK_FAILED: npm pack failed${output ? `\n${output}` : ''}`); + } + + const stdoutTarball = result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .reverse() + .find((line) => line.endsWith('.tgz') || line.endsWith('.tar.gz')); + if (stdoutTarball) { + const tarballPath = path.join(outDir, path.basename(stdoutTarball)); + if (fs.existsSync(tarballPath)) { + return { + tarballPath, + stdout: result.stdout, + stderr: result.stderr, + }; + } + } + + const tarball = fs.readdirSync(outDir) + .filter((entry) => entry.endsWith('.tgz') || entry.endsWith('.tar.gz')) + .map((entry) => ({ + entry, + mtimeMs: fs.statSync(path.join(outDir, entry)).mtimeMs, + })) + .sort((left, right) => left.mtimeMs - right.mtimeMs) + .at(-1); + if (!tarball) { + throw new Error(`MX_REGISTRY_PACK_FAILED: npm pack produced no tarball under ${outDir}`); + } + return { + tarballPath: path.join(outDir, tarball.entry), + stdout: result.stdout, + stderr: result.stderr, + }; +} + +export async function publishNpmTarball( + tarballPath: string, + registryUrl: string, + cwd: string, + credentialsPath: string | undefined, +): Promise { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-publish-npm-registry-')); + try { + const credential = readRegistryCredential(cwd, { + registry: registryUrl, + credentialsPath, + }).credential; + const userConfig = writeTemporaryNpmrc(tempDir, registryUrl, credential?.token ?? null); + const result = await runNpmCommand([ + 'publish', + tarballPath, + '--registry', + registryUrl, + '--userconfig', + userConfig, + ], { + cwd, + registryUrl, + userConfig, + cacheDir: path.join(tempDir, '.npm-cache'), + }); + return result; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +export async function searchNpmRegistry( + query: string, + registryUrl: string, + cwd: string, + credentialsPath: string | undefined, + limit: number, +): Promise { + const trimmedQuery = query.trim(); + if (!trimmedQuery) { + throw new Error('MX_SEARCH_INVALID: query is required'); + } + const searchUrl = new URL('-/v1/search', registryUrl.endsWith('/') ? registryUrl : `${registryUrl}/`); + searchUrl.searchParams.set('text', trimmedQuery); + searchUrl.searchParams.set('size', String(limit)); + + const credential = readRegistryCredential(cwd, { + registry: registryUrl, + credentialsPath, + }).credential; + const response = await fetch(searchUrl, { + headers: { + accept: 'application/json', + ...(credential?.token ? { authorization: `Bearer ${credential.token}` } : {}), + }, + }); + if (!response.ok) { + const body = await response.text(); + throw new Error( + `MX_REGISTRY_SEARCH_FAILED: registry search failed with HTTP ${response.status}` + + (body.trim() ? `\n${body.trim()}` : ''), + ); + } + const parsed = asRecord(await response.json() as unknown); + const objects = Array.isArray(parsed?.objects) ? parsed.objects : []; + const results: INpmRegistrySearchResult[] = []; + for (const object of objects) { + const packageRecord = asRecord(asRecord(object)?.package); + const packageName = readNpmSearchPackageName(packageRecord); + const version = readString(packageRecord?.version); + if (!packageName || !version) { + continue; + } + results.push({ + packageName, + version, + ...(readString(packageRecord?.description) ? { description: readString(packageRecord?.description)! } : {}), + keywords: readStringArray(packageRecord?.keywords), + }); + } + return results; +} + +function readNpmSearchPackageName(packageRecord: Record | null): string | undefined { + const name = readString(packageRecord?.name); + if (!name) { + return undefined; + } + if (name.startsWith('@')) { + return name; + } + const scope = readString(packageRecord?.scope); + if (!scope) { + return name; + } + return `${scope.startsWith('@') ? scope : `@${scope}`}/${name}`; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function readString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function readStringArray(value: unknown): readonly string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function registryAuthConfigKey(registryUrl: string): string { + const parsed = new URL(registryUrl); + const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`; + return `//${parsed.host}${pathname}:_authToken`; +} + +function writeTemporaryNpmrc( + tempDir: string, + registryUrl: string, + token: string | null, +): string { + const npmrcPath = path.join(tempDir, '.npmrc'); + const lines = [ + `registry=${registryUrl}`, + ]; + for (const scope of MATRIX_NPM_REGISTRY_SCOPES) { + lines.push(`${scope}:registry=${registryUrl}`); + } + if (token) { + lines.push('always-auth=true'); + lines.push(`${registryAuthConfigKey(registryUrl)}=${token}`); + } + fs.writeFileSync(npmrcPath, `${lines.join('\n')}\n`, 'utf8'); + return npmrcPath; +} + +async function runNpmCommand( + args: readonly string[], + options: { + readonly cwd: string; + readonly registryUrl?: string; + readonly userConfig?: string; + readonly cacheDir: string; + }, +): Promise { + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + fs.mkdirSync(options.cacheDir, { recursive: true }); + const globalConfig = path.join(options.cacheDir, 'empty-global-npmrc'); + if (!fs.existsSync(globalConfig)) { + fs.writeFileSync(globalConfig, '', 'utf8'); + } + return await new Promise((resolve, reject) => { + const child = spawn(npmCommand, [...args], { + cwd: options.cwd, + env: { + ...process.env, + ...(options.userConfig ? { + NPM_CONFIG_USERCONFIG: options.userConfig, + npm_config_userconfig: options.userConfig, + } : {}), + NPM_CONFIG_GLOBALCONFIG: globalConfig, + npm_config_globalconfig: globalConfig, + ...(options.registryUrl ? { npm_config_registry: options.registryUrl } : {}), + npm_config_cache: options.cacheDir, + }, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + const stdout: string[] = []; + const stderr: string[] = []; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => stdout.push(chunk)); + child.stderr.on('data', (chunk: string) => stderr.push(chunk)); + child.on('error', reject); + child.on('close', (exitCode) => { + resolve({ + exitCode, + stdout: stdout.join(''), + stderr: stderr.join(''), + }); + }); + }); +} diff --git a/packages/cli/src/utils/package-store.ts b/packages/cli/src/utils/package-store.ts new file mode 100644 index 0000000..1ceaa79 --- /dev/null +++ b/packages/cli/src/utils/package-store.ts @@ -0,0 +1,212 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { validateManifestForMxCli } from './manifestValidator.js'; + +type JsonRecord = Record; + +export interface IResolvedManifest { + readonly packageName: string; + readonly manifest: JsonRecord; +} + +const DEFAULT_GLOBAL_PACKAGES_ROOT = path.join(os.homedir(), '.matrix', 'packages'); +const DEFAULT_GLOBAL_REGISTRY_ROOT = path.join(os.homedir(), '.matrix', 'registry'); + +export function resolvePackagesRoot(cwd: string, packagesDir?: string): string { + return path.resolve(cwd, packagesDir ?? DEFAULT_GLOBAL_PACKAGES_ROOT); +} + +export function resolvePackagesNodeModulesRoot(cwd: string, packagesDir?: string): string { + return path.join(resolvePackagesRoot(cwd, packagesDir), 'node_modules'); +} + +export function resolveRegistryRoot(cwd: string, registryDir?: string): string { + return path.resolve(cwd, registryDir ?? DEFAULT_GLOBAL_REGISTRY_ROOT); +} + +export function packageNameToPath(packageName: string): string { + if (packageName.includes('/')) { + return packageName; + } + return packageName.trim(); +} + +export function listInstalledPackageNames(nodeModulesRoot: string): string[] { + if (!fs.existsSync(nodeModulesRoot)) { + return []; + } + const entries = fs.readdirSync(nodeModulesRoot, { withFileTypes: true }); + const names: string[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.')) continue; + + if (entry.name.startsWith('@')) { + const scopeRoot = path.join(nodeModulesRoot, entry.name); + for (const scoped of fs.readdirSync(scopeRoot, { withFileTypes: true })) { + if (!scoped.isDirectory()) continue; + names.push(`${entry.name}/${scoped.name}`); + } + continue; + } + + names.push(entry.name); + } + + return names.sort((a, b) => a.localeCompare(b)); +} + +function readJsonFile(filePath: string): JsonRecord { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${filePath} must contain a JSON object`); + } + return parsed as JsonRecord; +} + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +function readOptionalString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function readPackageJsonIfExists(packageDir: string): JsonRecord | null { + const packageJsonPath = path.join(packageDir, 'package.json'); + return fs.existsSync(packageJsonPath) ? readJsonFile(packageJsonPath) : null; +} + +function packageNameComparable(value: string): string { + const trimmed = value.trim(); + if (trimmed.startsWith('@matrix/')) { + return trimmed.slice('@matrix/'.length); + } + if (trimmed.startsWith('@open-matrix/')) { + return trimmed.slice('@open-matrix/'.length); + } + if (trimmed.startsWith('@') && trimmed.includes('/')) { + return trimmed.slice(trimmed.indexOf('/') + 1); + } + return trimmed; +} + +function assertMatrixAndPackageJsonAgree( + packageDir: string, + matrixManifest: JsonRecord, + packageJson: JsonRecord | null, +): void { + if (!packageJson) { + return; + } + + const matrixName = readOptionalString(matrixManifest.name); + const packageName = readOptionalString(packageJson.name); + if ( + matrixName + && packageName + && matrixName !== packageName + && packageNameComparable(matrixName) !== packageNameComparable(packageName) + ) { + throw new Error( + `MX_PACKAGE_IDENTITY_MISMATCH: matrix.json name (${matrixName}) ` + + `does not match package.json name (${packageName}) in ${packageDir}`, + ); + } + + const matrixVersion = readOptionalString(matrixManifest.version); + const packageVersion = readOptionalString(packageJson.version); + if (matrixVersion && packageVersion && matrixVersion !== packageVersion) { + throw new Error( + `MX_PACKAGE_VERSION_MISMATCH: matrix.json version (${matrixVersion}) ` + + `does not match package.json version (${packageVersion}) in ${packageDir}`, + ); + } +} + +export function readManifestFromPackageDir(packageDir: string): IResolvedManifest { + const matrixJsonPath = path.join(packageDir, 'matrix.json'); + if (fs.existsSync(matrixJsonPath)) { + const matrixManifest = readJsonFile(matrixJsonPath); + const packageJson = readPackageJsonIfExists(packageDir); + assertMatrixAndPackageJsonAgree(packageDir, matrixManifest, packageJson); + const packageName = readOptionalString(matrixManifest.name); + if (!packageName) { + throw new Error(`matrix.json must contain a non-empty "name" field (${matrixJsonPath})`); + } + return { + packageName, + manifest: matrixManifest, + }; + } + + const packageJsonPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`Package manifest not found in ${packageDir} (expected matrix.json or package.json)`); + } + + const packageJson = readJsonFile(packageJsonPath); + const packageName = typeof packageJson.name === 'string' ? packageJson.name.trim() : ''; + if (!packageName) { + throw new Error(`package.json must contain a non-empty "name" field (${packageJsonPath})`); + } + + const matrix = asRecord(packageJson.matrix) ?? {}; + const manifest: JsonRecord = { + name: packageName, + version: packageJson.version, + runtime: matrix.runtime, + components: matrix.components, + permissions: matrix.permissions, + install: matrix.install, + }; + return { packageName, manifest }; +} + +export function validateResolvedManifest(packageName: string, manifest: JsonRecord): void { + const validation = validateManifestForMxCli({ + ...manifest, + name: manifest.name ?? packageName, + }); + if (!validation.valid) { + throw new Error( + `Manifest validation failed for ${packageName}: ${validation.errors.join('; ')}` + ); + } +} + +export function readPackageJson(packageDir: string): JsonRecord { + const packageJsonPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`package.json not found in ${packageDir}`); + } + return readJsonFile(packageJsonPath); +} + +export function readVersionFromPackageDir(packageDir: string): string { + const matrixJsonPath = path.join(packageDir, 'matrix.json'); + const packageJson = readPackageJsonIfExists(packageDir); + if (fs.existsSync(matrixJsonPath)) { + const matrixManifest = readJsonFile(matrixJsonPath); + assertMatrixAndPackageJsonAgree(packageDir, matrixManifest, packageJson); + const matrixVersion = readOptionalString(matrixManifest.version); + const packageVersion = readOptionalString(packageJson?.version); + if (packageVersion.length > 0) { + return packageVersion; + } + if (matrixVersion.length > 0) { + return matrixVersion; + } + } + + const version = readOptionalString((packageJson ?? readPackageJson(packageDir)).version); + if (!version) { + throw new Error(`Unable to resolve package version in ${packageDir}`); + } + return version; +} diff --git a/packages/cli/src/utils/registry-store.ts b/packages/cli/src/utils/registry-store.ts new file mode 100644 index 0000000..b9eb04f --- /dev/null +++ b/packages/cli/src/utils/registry-store.ts @@ -0,0 +1,131 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { packageNameToPath } from './package-store.js'; +import { maxSemver } from './versioning.js'; + +type JsonRecord = Record; + +export interface IRegistryVersionEntry { + readonly tarballPath: string; + readonly publishedAt: string; + readonly publishedBy: string; +} + +export interface IRegistryPackageEntry { + readonly versions: Record; +} + +export interface IRegistryIndex { + readonly packages: Record; +} + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as JsonRecord; +} + +function readRegistryIndex(indexPath: string): IRegistryIndex { + if (!fs.existsSync(indexPath)) { + return { packages: {} }; + } + + const parsed = JSON.parse(fs.readFileSync(indexPath, 'utf8')) as unknown; + const root = asRecord(parsed); + const packagesRecord = asRecord(root?.packages) ?? {}; + const packages: Record = {}; + + for (const [packageName, packageValue] of Object.entries(packagesRecord)) { + const packageEntry = asRecord(packageValue); + const versionsRecord = asRecord(packageEntry?.versions) ?? {}; + const versions: Record = {}; + + for (const [version, versionValue] of Object.entries(versionsRecord)) { + const versionEntry = asRecord(versionValue); + if (!versionEntry) continue; + if (typeof versionEntry.tarballPath !== 'string') continue; + if (typeof versionEntry.publishedAt !== 'string') continue; + if (typeof versionEntry.publishedBy !== 'string') continue; + versions[version] = { + tarballPath: versionEntry.tarballPath, + publishedAt: versionEntry.publishedAt, + publishedBy: versionEntry.publishedBy, + }; + } + + packages[packageName] = { versions }; + } + + return { packages }; +} + +function writeRegistryIndex(indexPath: string, index: IRegistryIndex): void { + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf8'); +} + +function resolveIndexPath(registryRoot: string): string { + return path.join(registryRoot, 'index.json'); +} + +export function publishRegistryVersion( + registryRoot: string, + packageName: string, + version: string, + sourceTarballPath: string, + publishedBy: string, +): { readonly registryTarballPath: string } { + const indexPath = resolveIndexPath(registryRoot); + const index = readRegistryIndex(indexPath); + const packageEntry = index.packages[packageName] ?? { versions: {} }; + if (packageEntry.versions[version]) { + throw new Error(`MX_VERSION_EXISTS: ${packageName}@${version} already published`); + } + + const packageVersionDir = path.join(registryRoot, 'packages', packageNameToPath(packageName), version); + fs.mkdirSync(packageVersionDir, { recursive: true }); + const registryTarballPath = path.join(packageVersionDir, 'package.tar.gz'); + fs.copyFileSync(sourceTarballPath, registryTarballPath); + + packageEntry.versions[version] = { + tarballPath: registryTarballPath, + publishedAt: new Date().toISOString(), + publishedBy, + }; + index.packages[packageName] = packageEntry; + writeRegistryIndex(indexPath, index); + + return { registryTarballPath }; +} + +export function resolveRegistryTarball( + registryRoot: string, + packageName: string, + requestedVersion?: string, +): { readonly version: string; readonly tarballPath: string } { + const index = readRegistryIndex(resolveIndexPath(registryRoot)); + const packageEntry = index.packages[packageName]; + if (!packageEntry) { + throw new Error(`MX_PACKAGE_NOT_FOUND: ${packageName}`); + } + + const version = requestedVersion ?? maxSemver(Object.keys(packageEntry.versions)); + if (!version || !packageEntry.versions[version]) { + throw new Error(`MX_PACKAGE_NOT_FOUND: ${packageName}@${requestedVersion ?? 'latest'}`); + } + + return { + version, + tarballPath: packageEntry.versions[version]!.tarballPath, + }; +} + +export function getLatestRegistryVersion(registryRoot: string, packageName: string): string | null { + const index = readRegistryIndex(resolveIndexPath(registryRoot)); + const packageEntry = index.packages[packageName]; + if (!packageEntry) { + return null; + } + return maxSemver(Object.keys(packageEntry.versions)); +} diff --git a/packages/cli/src/utils/runner-security.ts b/packages/cli/src/utils/runner-security.ts new file mode 100644 index 0000000..145d2e3 --- /dev/null +++ b/packages/cli/src/utils/runner-security.ts @@ -0,0 +1,16 @@ +import { + DEFAULT_SECURITY_POLICY, + DEFAULT_SUPERVISION_POLICY, + SecurityRealm, +} from '@open-matrix/core'; +import { createPolicyEngine } from '@open-matrix/core/core/security/PolicyPresets'; + +export function createRunnerSecurityRealm(): SecurityRealm { + return new SecurityRealm( + DEFAULT_SUPERVISION_POLICY, + DEFAULT_SECURITY_POLICY, + undefined, + undefined, + createPolicyEngine({ policyEngine: 'declarative', preset: 'standard' }), + ); +} diff --git a/packages/cli/src/utils/scaffold.ts b/packages/cli/src/utils/scaffold.ts new file mode 100644 index 0000000..ffdb917 --- /dev/null +++ b/packages/cli/src/utils/scaffold.ts @@ -0,0 +1,164 @@ +/** + * Scaffolding utilities for mx CLI. + * + * Provides functions for creating package structures, templates, etc. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { validateManifestForMxCli } from './manifestValidator.js'; + +export interface ScaffoldOptions { + name: string; + description?: string; + version?: string; +} + +/** + * Create a basic package structure. + */ +export function createPackageStructure(options: ScaffoldOptions): void { + const root = path.resolve(options.name); + const folders = ['src', 'examples', 'skills', 'tests']; + fs.mkdirSync(root, { recursive: true }); + for (const folder of folders) { + fs.mkdirSync(path.join(root, folder), { recursive: true }); + } +} + +/** + * Generate matrix.json content. + */ +export function generateMatrixJson(options: ScaffoldOptions): string { + return JSON.stringify({ + name: options.name, + version: options.version ?? '0.1.0', + description: options.description ?? '', + runtime: { + language: 'typescript', + entry: 'src/index.ts', + }, + components: [], + permissions: { + fsPolicy: 'none', + }, + }, null, 2); +} + +export function toKebabCase(value: string): string { + return value + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .toLowerCase(); +} + +export function toPascalCase(value: string): string { + return value + .trim() + .replace(/[_-]+/g, ' ') + .split(/\s+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} + +export function writeJsonFile(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8'); +} + +export function validateMatrixManifest(manifest: unknown): { valid: boolean; errors: string[] } { + const result = validateManifestForMxCli(manifest); + return { valid: result.valid, errors: result.errors }; +} + +/** + * Generate package.json for a scaffolded package. + */ +export function generatePackageJson(name: string, options?: { + webapp?: boolean; + service?: boolean; + scripts?: Record; +}): Record { + const scripts: Record = options?.scripts ?? (options?.webapp + ? { dev: 'vite', build: 'vite build', test: 'node --import tsx --test tests/**/*.spec.ts' } + : options?.service + ? { + build: 'tsc -p tsconfig.json', + 'build:watch': 'tsc -w -p tsconfig.json --preserveWatchOutput', + dev: 'tsc -w -p tsconfig.json --preserveWatchOutput', + test: 'node --import tsx --test tests/**/*.spec.ts', + } + : { build: 'tsc', test: 'node --import tsx --test tests/**/*.spec.ts' }); + + const dependencies: Record = {}; + const devDependencies: Record = { + 'typescript': '^5.0.0', + }; + + if (options?.service) { + dependencies['@open-matrix/core'] = '*'; + } else { + devDependencies['@open-matrix/core'] = '*'; + } + + if (options?.webapp) { + devDependencies['@open-matrix/federation'] = '*'; + devDependencies['vite'] = '^5.0.0'; + devDependencies['nats'] = '^5.0.0'; + } + + return { + name: `@matrix/${name}`, + version: '0.1.0', + type: 'module', + files: [ + 'dist', + 'matrix.json', + ], + scripts, + ...(Object.keys(dependencies).length > 0 ? { dependencies } : {}), + devDependencies, + }; +} + +/** + * Generate tsconfig.json for a scaffolded package. + */ +export function generateTsconfig(options?: { webapp?: boolean; service?: boolean }): Record { + const compilerOptions: Record = { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + outDir: 'dist', + rootDir: 'src', + declaration: true, + strict: true, + esModuleInterop: true, + skipLibCheck: true, + }; + + if (options?.service) { + compilerOptions.incremental = true; + compilerOptions.tsBuildInfoFile = '.tsbuildinfo'; + compilerOptions.sourceMap = true; + } + + if (options?.webapp) { + compilerOptions.module = 'ESNext'; + compilerOptions.moduleResolution = 'Bundler'; + compilerOptions.noEmit = true; + compilerOptions.lib = ['ES2022', 'DOM', 'DOM.Iterable']; + delete compilerOptions.outDir; + delete compilerOptions.rootDir; + delete compilerOptions.declaration; + } + + return { + compilerOptions, + include: ['src'], + }; +} diff --git a/packages/cli/src/utils/service-manifest.ts b/packages/cli/src/utils/service-manifest.ts new file mode 100644 index 0000000..403c3dc --- /dev/null +++ b/packages/cli/src/utils/service-manifest.ts @@ -0,0 +1,448 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + normalizeMountedInstanceSpec, + type IMountedInstanceMetadataInput, + type IMountedInstanceSecretRefInput, +} from '@open-matrix/core/runtime/MountedInstanceMetadata.js'; + +type JsonRecord = Record; + +export interface IMatrixServiceSecretRef extends IMountedInstanceSecretRefInput {} + +export interface IMatrixServiceInstanceManifest extends IMountedInstanceMetadataInput { + readonly secretRefs?: ReadonlyArray; +} + +export interface IMatrixServiceBootstrapManifest { + readonly overrides?: Record; + readonly root?: string; + readonly transport?: Record; + readonly auth?: Record; + readonly http?: Record; +} + +export interface IMatrixRuntimeManifest { + readonly language?: string; + readonly entry?: string; + readonly factory?: IMatrixServiceManifest; +} + +export interface IMatrixServiceManifest { + readonly kind?: 'factory'; + readonly entry?: string; + readonly export?: string; + /** Standalone-only bootstrap envelope. */ + readonly bootstrap?: IMatrixServiceBootstrapManifest; + readonly overrides?: Record; + readonly root?: string; + readonly mount?: string; + readonly config?: { + readonly file?: string; + }; + readonly secrets?: { + readonly file?: string; + }; + readonly transport?: Record; + readonly auth?: Record; + readonly http?: Record; + readonly dependsOn?: ReadonlyArray; + readonly policyRef?: string; + readonly instance?: IMatrixServiceInstanceManifest; +} + +export interface IServiceInstanceBootstrap { + readonly id: string; + readonly packageName: string; + readonly packageRevision?: string; + readonly class: string; + readonly mount?: string; + readonly rootKind: string; + readonly componentType?: string; + readonly configRef?: string; + readonly secretRefs?: ReadonlyArray<{ + readonly name: string; + readonly provider: string; + readonly ref: string; + readonly required?: boolean; + }>; + readonly dependsOn?: ReadonlyArray; + readonly policyRef?: string; + readonly autoStart: boolean; + readonly registrationSource: 'matrix-service'; +} + +export interface ILoadedMatrixBindingDeclaration { + readonly type?: string; + readonly mount: string; + readonly accepts: readonly string[]; +} + +export interface ILoadedMatrixServiceManifest { + readonly packageDir: string; + readonly packageName: string; + readonly displayName?: string; + readonly packageNamespace?: string; + readonly manifestPath: string; + readonly entryPath: string; + readonly exportName: string; + readonly kind: 'factory'; + readonly overrides: Record; + readonly root?: string; + readonly mount?: string; + readonly configRef?: string; + readonly transport?: Record; + readonly auth?: Record; + readonly http?: Record; + readonly packageRoot?: ILoadedMatrixBindingDeclaration; + readonly packageComponents: ReadonlyArray; + readonly serviceInstance: IServiceInstanceBootstrap; +} + +export function packageDeclaresStandaloneFactory(packageDir: string): boolean { + const resolvedPackageDir = path.resolve(packageDir); + const matrixManifestPath = path.join(resolvedPackageDir, 'matrix.json'); + if (!fs.existsSync(matrixManifestPath)) { + return false; + } + const matrixManifest = readOptionalJsonObject(matrixManifestPath, 'matrix.json'); + const runtime = asRecord(matrixManifest?.runtime); + return isRecord(runtime?.factory); +} + +export function loadMatrixServiceManifest(packageDir: string): ILoadedMatrixServiceManifest { + const resolvedPackageDir = path.resolve(packageDir); + const matrixManifestPath = path.join(resolvedPackageDir, 'matrix.json'); + + const matrixManifest = readJsonObject(matrixManifestPath, 'matrix.json'); + const packageName = optionalTrimmedString(matrixManifest.name) ?? path.basename(resolvedPackageDir); + const displayName = optionalTrimmedString(matrixManifest.displayName); + const packageVersion = optionalTrimmedString(matrixManifest.version) ?? undefined; + const runtime = asRecord(matrixManifest.runtime) as IMatrixRuntimeManifest | null; + const factorySource = readFactorySource(runtime?.factory, matrixManifestPath); + const serviceManifest = factorySource.manifest; + const packageNamespace = optionalTrimmedString(matrixManifest.namespace); + const runtimeEntry = optionalTrimmedString(runtime?.entry); + const components = Array.isArray(matrixManifest.components) + ? matrixManifest.components.filter((entry): entry is Record => isRecord(entry)) + : []; + const packageRoot = readBindingDeclaration(asRecord(matrixManifest.root)); + const packageComponents = components + .map((entry) => readBindingDeclaration(entry)) + .filter((entry): entry is ILoadedMatrixBindingDeclaration => entry !== null); + const kind = optionalTrimmedString(serviceManifest.kind) ?? 'factory'; + if (kind !== 'factory') { + throw new Error(`${factorySource.label} kind must be "factory", got "${kind}"`); + } + + const entry = optionalTrimmedString(serviceManifest.entry) ?? runtimeEntry; + if (!entry) { + throw new Error(`${factorySource.label} requires entry or matrix.json runtime.entry`); + } + + const exportName = optionalTrimmedString(serviceManifest.export); + if (!exportName) { + throw new Error(`${factorySource.label} export must be a non-empty string`); + } + + const bootstrap = asRecord(serviceManifest.bootstrap); + assertUnsupportedStandaloneRecordRelocation({ + sourceLabel: factorySource.label, + oldPath: 'overrides', + oldValue: serviceManifest.overrides, + canonicalPath: 'bootstrap.overrides', + }); + const overrides = asRecord(bootstrap?.overrides) ?? {}; + assertUnsupportedStandaloneStringRelocation({ + sourceLabel: factorySource.label, + oldPath: 'root', + oldValue: serviceManifest.root, + canonicalPath: 'bootstrap.root', + }); + const root = optionalTrimmedString(bootstrap?.root) ?? undefined; + assertUnsupportedStandaloneRecordRelocation({ + sourceLabel: factorySource.label, + oldPath: 'transport', + oldValue: serviceManifest.transport, + canonicalPath: 'bootstrap.transport', + }); + const transport = asRecord(bootstrap?.transport); + assertUnsupportedStandaloneRecordRelocation({ + sourceLabel: factorySource.label, + oldPath: 'auth', + oldValue: serviceManifest.auth, + canonicalPath: 'bootstrap.auth', + }); + const auth = asRecord(bootstrap?.auth); + assertUnsupportedStandaloneRecordRelocation({ + sourceLabel: factorySource.label, + oldPath: 'http', + oldValue: serviceManifest.http, + canonicalPath: 'bootstrap.http', + }); + const http = asRecord(bootstrap?.http); + const instance = asRecord(serviceManifest.instance); + const instancePackageName = optionalTrimmedString(instance?.packageName) ?? packageName; + const declaredPackageRevision = optionalTrimmedString(instance?.packageRevision); + const instancePackageRevision = declaredPackageRevision ?? packageVersion; + if (optionalTrimmedString(instance?.packageName) && instancePackageName !== packageName) { + throw new Error(`${factorySource.label} instance.packageName "${instancePackageName}" does not match matrix.json name "${packageName}"`); + } + if (declaredPackageRevision && packageVersion && declaredPackageRevision !== packageVersion) { + throw new Error(`${factorySource.label} instance.packageRevision "${declaredPackageRevision}" does not match matrix.json version "${packageVersion}"`); + } + const rootKind = optionalTrimmedString(instance?.rootKind) ?? 'package-root'; + const componentType = optionalTrimmedString(instance?.componentType); + const instanceMount = optionalTrimmedString(instance?.mount); + assertUnsupportedStandaloneStringRelocation({ + sourceLabel: factorySource.label, + oldPath: 'mount', + oldValue: serviceManifest.mount, + canonicalPath: 'instance.mount', + }); + const mount = instanceMount + ?? (rootKind === 'component' + ? resolveComponentRootMount(factorySource.label, components, componentType) + : undefined); + const instanceConfigRef = optionalTrimmedString(instance?.configRef); + assertUnsupportedStandaloneStringRelocation({ + sourceLabel: factorySource.label, + oldPath: 'config.file', + oldValue: asRecord(serviceManifest.config)?.file, + canonicalPath: 'instance.configRef', + }); + const configRef = instanceConfigRef; + assertUnsupportedStandaloneStringRelocation({ + sourceLabel: factorySource.label, + oldPath: 'secrets.file', + oldValue: asRecord(serviceManifest.secrets)?.file, + canonicalPath: 'instance.secretRefs or CLI --secrets', + }); + const serviceClass = optionalTrimmedString(instance?.class) ?? 'service'; + const instanceDependsOn = normalizeStringArray(instance?.dependsOn); + const instancePolicyRef = optionalTrimmedString(instance?.policyRef); + assertUnsupportedStandaloneArrayRelocation({ + sourceLabel: factorySource.label, + oldPath: 'dependsOn', + oldValue: serviceManifest.dependsOn, + canonicalPath: 'instance.dependsOn', + }); + assertUnsupportedStandaloneStringRelocation({ + sourceLabel: factorySource.label, + oldPath: 'policyRef', + oldValue: serviceManifest.policyRef, + canonicalPath: 'instance.policyRef', + }); + const serviceInstance = normalizeMountedInstanceSpec({ + id: optionalTrimmedString(instance?.id) ?? buildServiceInstanceId(instancePackageName, mount), + packageName: instancePackageName, + packageRevision: instancePackageRevision, + class: serviceClass, + mount, + rootKind, + componentType, + configRef, + secretRefs: Array.isArray(instance?.secretRefs) ? instance.secretRefs : undefined, + dependsOn: instanceDependsOn.length > 0 ? instanceDependsOn : undefined, + policyRef: instancePolicyRef, + autoStart: instance?.autoStart === true, + }, { + registrationSource: 'matrix-service', + }) as IServiceInstanceBootstrap; + const entryPath = path.resolve(resolvedPackageDir, entry); + if (!fs.existsSync(entryPath)) { + throw new Error(`${factorySource.label} entry does not exist: ${entry}`); + } + + return { + packageDir: resolvedPackageDir, + packageName, + ...(displayName ? { displayName } : {}), + ...(packageNamespace ? { packageNamespace } : {}), + manifestPath: factorySource.path, + entryPath, + exportName, + kind: 'factory', + overrides: structuredClone(overrides), + ...(root ? { root } : {}), + ...(mount ? { mount } : {}), + ...(configRef ? { configRef } : {}), + ...(transport ? { transport: structuredClone(transport) } : {}), + ...(auth ? { auth: structuredClone(auth) } : {}), + ...(http ? { http: structuredClone(http) } : {}), + ...(packageRoot ? { packageRoot } : {}), + packageComponents, + serviceInstance, + }; +} + +function readFactorySource(factory: unknown, matrixManifestPath: string): { + readonly label: string; + readonly path: string; + readonly manifest: IMatrixServiceManifest; +} { + if (isRecord(factory)) { + return { + label: 'matrix.json runtime.factory', + path: matrixManifestPath, + manifest: factory as IMatrixServiceManifest, + }; + } + throw new Error('matrix.json runtime.factory must be a JSON object'); +} + +function readJsonObject(filePath: string, label: string): JsonRecord { + const parsed = readJsonValue(filePath, label); + if (!isRecord(parsed)) { + throw new Error(`${label} must contain a JSON object`); + } + return parsed; +} + +function readOptionalJsonObject(filePath: string, label: string): JsonRecord | null { + const parsed = readJsonValue(filePath, label); + if (!isRecord(parsed)) { + return null; + } + return parsed; +} + +function readJsonValue(filePath: string, label: string): unknown { + if (!fs.existsSync(filePath)) { + throw new Error(`${label} not found at ${filePath}`); + } + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); + return parsed; + } catch (err) { + throw new Error(`Failed to parse ${label}: ${err instanceof Error ? err.message : String(err)}`); + } +} + +function optionalTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function asRecord(value: unknown): Record | null { + if (!isRecord(value)) return null; + return value; +} + +function resolveComponentRootMount( + sourceLabel: string, + components: ReadonlyArray>, + componentType: string | undefined, +): string | undefined { + if (components.length === 0) { + throw new Error(`${sourceLabel} declares instance.rootKind="component", but matrix.json does not declare any components`); + } + + if (componentType) { + const matching = components.find((entry) => optionalTrimmedString(entry.type) === componentType); + if (!matching) { + throw new Error(`${sourceLabel} instance.componentType "${componentType}" was not found in matrix.json components`); + } + const mount = optionalTrimmedString(matching.mount); + if (!mount) { + throw new Error(`${sourceLabel} instance.componentType "${componentType}" does not declare a component mount in matrix.json`); + } + return mount; + } + + if (components.length === 1) { + const mount = optionalTrimmedString(components[0]?.mount); + if (!mount) { + throw new Error(`${sourceLabel} component-root bootstrap requires an explicit mount because the only matrix.json component does not declare one`); + } + return mount; + } + + throw new Error(`${sourceLabel} component-root bootstrap is ambiguous; declare instance.componentType or mount`); +} + +function readBindingDeclaration( + value: Record | null | undefined, +): ILoadedMatrixBindingDeclaration | null { + if (!value) { + return null; + } + const mount = optionalTrimmedString(value.mount); + if (!mount) { + return null; + } + return { + ...(optionalTrimmedString(value.type) ? { type: optionalTrimmedString(value.type) } : {}), + mount, + accepts: normalizeAccepts(value.accepts), + }; +} + +function normalizeAccepts(value: unknown): readonly string[] { + if (!Array.isArray(value)) { + return []; + } + const accepts = value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return [...new Set(accepts)]; +} + +function buildServiceInstanceId(packageName: string, mount: string | undefined): string { + return mount ? `${packageName}:${mount}` : packageName; +} + +function assertUnsupportedStandaloneStringRelocation(options: { + readonly sourceLabel: string; + readonly oldPath: string; + readonly oldValue: unknown; + readonly canonicalPath: string; +}): void { + if (optionalTrimmedString(options.oldValue) === undefined) return; + throw new Error( + `${options.sourceLabel} top-level "${options.oldPath}" is no longer supported; use "${options.canonicalPath}"`, + ); +} + +function assertUnsupportedStandaloneArrayRelocation(options: { + readonly sourceLabel: string; + readonly oldPath: string; + readonly oldValue: unknown; + readonly canonicalPath: string; +}): void { + if (normalizeStringArray(options.oldValue).length === 0) return; + throw new Error( + `${options.sourceLabel} top-level "${options.oldPath}" is no longer supported; use "${options.canonicalPath}"`, + ); +} + +function assertUnsupportedStandaloneRecordRelocation(options: { + readonly sourceLabel: string; + readonly oldPath: string; + readonly oldValue: unknown; + readonly canonicalPath: string; +}): void { + if (!isRecord(options.oldValue)) return; + throw new Error( + `${options.sourceLabel} top-level "${options.oldPath}" is no longer supported; use "${options.canonicalPath}"`, + ); +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const normalized: string[] = []; + for (const entry of value) { + const trimmed = optionalTrimmedString(entry); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + normalized.push(trimmed); + } + return normalized; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/cli/src/utils/versioning.ts b/packages/cli/src/utils/versioning.ts new file mode 100644 index 0000000..1a4434a --- /dev/null +++ b/packages/cli/src/utils/versioning.ts @@ -0,0 +1,28 @@ +export function compareSemver(left: string, right: string): number { + const leftParts = left.split('.').map((segment) => Number.parseInt(segment, 10)); + const rightParts = right.split('.').map((segment) => Number.parseInt(segment, 10)); + const length = Math.max(leftParts.length, rightParts.length); + + for (let i = 0; i < length; i++) { + const a = Number.isFinite(leftParts[i]) ? leftParts[i]! : 0; + const b = Number.isFinite(rightParts[i]) ? rightParts[i]! : 0; + if (a > b) return 1; + if (a < b) return -1; + } + + return 0; +} + +export function maxSemver(versions: string[]): string | null { + if (versions.length === 0) { + return null; + } + + let max = versions[0]!; + for (const version of versions.slice(1)) { + if (compareSemver(version, max) > 0) { + max = version; + } + } + return max; +} diff --git a/packages/cli/tests/helpers/cli-harness.ts b/packages/cli/tests/helpers/cli-harness.ts new file mode 100644 index 0000000..459dbf8 --- /dev/null +++ b/packages/cli/tests/helpers/cli-harness.ts @@ -0,0 +1,75 @@ +import { strict as assert } from 'node:assert'; + +export interface IRunMxCliResult { + readonly logs: string[]; + readonly errors: string[]; + readonly exitCode: number; +} + +async function loadMxCli(): Promise<{ createProgram: () => import('commander').Command }> { + const mxCliUrl = new URL('../../src/index.ts', import.meta.url).href; + return await import(`${mxCliUrl}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`); +} + +export async function runMxCli(argv: string[], options: { cwd?: string } = {}): Promise { + const logs: string[] = []; + const errors: string[] = []; + let exitCode = 0; + + const originalLog = console.log; + const originalError = console.error; + const originalExit = process.exit; + const originalCwd = process.cwd(); + const { createProgram } = await loadMxCli(); + + console.log = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + console.error = (...args: unknown[]) => { + errors.push(args.map((arg) => String(arg)).join(' ')); + }; + (process as unknown as { exit: (code?: number) => never }).exit = ((code?: number) => { + exitCode = code ?? 0; + throw new Error(`process.exit(${exitCode})`); + }) as never; + + if (options.cwd) { + process.chdir(options.cwd); + } + + try { + const program = createProgram(); + await program.parseAsync(argv); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!message.startsWith('process.exit(')) { + throw err; + } + } finally { + if (options.cwd) { + process.chdir(originalCwd); + } + console.log = originalLog; + console.error = originalError; + (process as unknown as { exit: typeof process.exit }).exit = originalExit; + } + + return { logs, errors, exitCode }; +} + +export function parseLastJson(lines: readonly string[]): unknown { + for (let i = lines.length - 1; i >= 0; i--) { + const value = lines[i]?.trim() ?? ''; + if (!value) continue; + try { + return JSON.parse(value); + } catch { + // continue + } + } + throw new Error(`No JSON payload found in output:\n${lines.join('\n')}`); +} + +export function assertExitCode(result: IRunMxCliResult, expected: number): void { + assert.equal(result.exitCode, expected, `Expected exit code ${expected}, got ${result.exitCode}`); +} diff --git a/packages/cli/tests/helpers/package-fixtures.ts b/packages/cli/tests/helpers/package-fixtures.ts new file mode 100644 index 0000000..b774456 --- /dev/null +++ b/packages/cli/tests/helpers/package-fixtures.ts @@ -0,0 +1,73 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export interface ICreatePackageOptions { + readonly packageName: string; + readonly version?: string; + readonly mount?: string; + readonly componentType?: string; + readonly runtimeEntry?: string; +} + +export function createMatrixPackage(rootDir: string, options: ICreatePackageOptions): { + readonly packageDir: string; + readonly packageName: string; + readonly version: string; +} { + const version = options.version ?? '1.0.0'; + const componentType = options.componentType ?? 'SampleActor'; + const mount = options.mount ?? 'sample.actor'; + const runtimeEntry = options.runtimeEntry ?? 'src/index.ts'; + const packageDir = path.join(rootDir, options.packageName.replace(/^@/, '').replace('/', '-')); + fs.mkdirSync(path.join(packageDir, 'src'), { recursive: true }); + + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + JSON.stringify( + { + name: options.packageName, + version, + runtime: { language: 'typescript', entry: runtimeEntry }, + components: [ + { + type: componentType, + export: componentType, + mount, + autoStart: true, + }, + ], + permissions: { fsPolicy: 'none' }, + }, + null, + 2 + ), + 'utf8' + ); + + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify( + { + name: options.packageName, + version, + type: 'module', + main: 'src/index.ts', + }, + null, + 2 + ), + 'utf8' + ); + + fs.writeFileSync( + path.join(packageDir, 'src', 'index.ts'), + `export class ${componentType} {}\n`, + 'utf8' + ); + + return { + packageDir, + packageName: options.packageName, + version, + }; +} diff --git a/packages/cli/tests/integration/fp30-auth-publish.spec.ts b/packages/cli/tests/integration/fp30-auth-publish.spec.ts new file mode 100644 index 0000000..54cad95 --- /dev/null +++ b/packages/cli/tests/integration/fp30-auth-publish.spec.ts @@ -0,0 +1,589 @@ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; +import { createMatrixPackage } from '../helpers/package-fixtures.js'; + +interface INpmPublishRegistryFixture { + readonly registryUrl: string; + readonly close: () => Promise; + readonly publishRequests: () => number; + readonly tarballRequests: () => number; + readonly authorizationHeaders: () => readonly string[]; +} + +interface IPublishedPackage { + readonly name: string; + readonly version: string; + readonly fileName: string; + readonly tarball: Buffer; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function addressPort(server: http.Server): number { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Fixture registry did not bind a TCP port'); + } + return address.port; +} + +async function readRequestBody(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +async function startNpmPublishRegistryFixture(): Promise { + let publishRequests = 0; + let tarballRequests = 0; + let published: IPublishedPackage | null = null; + const authorizationHeaders: string[] = []; + + const server = http.createServer((req, res) => { + void (async () => { + const auth = req.headers.authorization; + if (typeof auth === 'string') { + authorizationHeaders.push(auth); + } + const registryUrl = `http://127.0.0.1:${addressPort(server)}/`; + const url = new URL(req.url ?? '/', registryUrl); + if (req.method === 'PUT') { + publishRequests += 1; + const body = asRecord(JSON.parse(await readRequestBody(req)) as unknown); + const packageName = readString(body?.name); + const distTags = asRecord(body?.['dist-tags']); + const version = readString(distTags?.latest); + const attachments = asRecord(body?._attachments); + const attachmentEntry = attachments ? Object.entries(attachments)[0] : undefined; + const attachment = asRecord(attachmentEntry?.[1]); + const data = readString(attachment?.data); + if (!packageName || !version || !attachmentEntry || !data) { + res.writeHead(400, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid publish payload' })); + return; + } + published = { + name: packageName, + version, + fileName: attachmentEntry[0], + tarball: Buffer.from(data, 'base64'), + }; + res.writeHead(201, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (req.method === 'GET' && url.pathname.endsWith('.tgz')) { + tarballRequests += 1; + if (!published) { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'no tarball published' })); + return; + } + res.writeHead(200, { 'content-type': 'application/octet-stream' }); + res.end(published.tarball); + return; + } + + const decodedPath = decodeURIComponent(url.pathname); + if (req.method === 'GET' && published && decodedPath === `/${published.name}`) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + name: published.name, + 'dist-tags': { latest: published.version }, + versions: { + [published.version]: { + name: published.name, + version: published.version, + dist: { + tarball: new URL(`tarballs/${published.fileName}`, registryUrl).toString(), + }, + }, + }, + })); + return; + } + + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found', path: url.pathname })); + })().catch((error: unknown) => { + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + + return { + registryUrl: `http://127.0.0.1:${addressPort(server)}/`, + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + publishRequests: () => publishRequests, + tarballRequests: () => tarballRequests, + authorizationHeaders: () => authorizationHeaders, + }; +} + +describe('FP-30 mx auth + publish integration', { concurrency: false }, () => { + const tempDirs: string[] = []; + const restoreEnvFns: Array<() => void> = []; + + afterEach(() => { + while (restoreEnvFns.length > 0) { + const restore = restoreEnvFns.pop(); + if (restore) { + restore(); + } + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + function useMatrixHome(matrixHome: string): void { + const previousMatrixHome = process.env.MATRIX_HOME; + const previousCliConfig = process.env.MATRIX_CLI_CONFIG; + const previousPackageRegistry = process.env.MATRIX_PACKAGE_REGISTRY; + const previousRegistryUrl = process.env.MATRIX_REGISTRY_URL; + const previousNpmUserConfig = process.env.NPM_CONFIG_USERCONFIG; + const previousNodeAuthToken = process.env.NODE_AUTH_TOKEN; + const previousNpmToken = process.env.NPM_TOKEN; + process.env.MATRIX_HOME = matrixHome; + delete process.env.MATRIX_CLI_CONFIG; + delete process.env.MATRIX_PACKAGE_REGISTRY; + delete process.env.MATRIX_REGISTRY_URL; + delete process.env.NPM_CONFIG_USERCONFIG; + delete process.env.NODE_AUTH_TOKEN; + delete process.env.NPM_TOKEN; + restoreEnvFns.push(() => { + restoreEnv('MATRIX_HOME', previousMatrixHome); + restoreEnv('MATRIX_CLI_CONFIG', previousCliConfig); + restoreEnv('MATRIX_PACKAGE_REGISTRY', previousPackageRegistry); + restoreEnv('MATRIX_REGISTRY_URL', previousRegistryUrl); + restoreEnv('NPM_CONFIG_USERCONFIG', previousNpmUserConfig); + restoreEnv('NODE_AUTH_TOKEN', previousNodeAuthToken); + restoreEnv('NPM_TOKEN', previousNpmToken); + }); + } + + function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; + } + + it('requires login before publish and enables registry install after publish', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-auth-publish-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + const packagesDir = path.join(root, '.matrix', 'packages'); + const registryDir = path.join(root, '.matrix', 'registry'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + const homeDir = path.join(root, 'home'); + const previousHome = process.env.HOME; + fs.mkdirSync(homeDir, { recursive: true }); + process.env.HOME = homeDir; + + try { + const fixture = createMatrixPackage(path.join(root, 'source'), { + packageName: '@acme/fp30-auth-publish', + version: '1.0.0', + componentType: 'AuthPublishActor', + mount: 'auth.publish', + }); + + const publishWithoutLogin = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(publishWithoutLogin, 1); + assert.equal(publishWithoutLogin.errors.some((line) => line.includes('MX_AUTH_REQUIRED')), true); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-auth-publish', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(loginResult, 0); + + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(publishResult, 0); + + const installFromRegistry = await runMxCli([ + 'node', + 'mx', + 'install', + `${fixture.packageName}@${fixture.version}`, + '--packages-dir', + packagesDir, + '--registry-dir', + registryDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installFromRegistry, 0); + const payload = parseLastJson(installFromRegistry.logs) as { targetDir: string }; + assert.equal(fs.existsSync(payload.targetDir), true); + } finally { + restoreEnv('HOME', previousHome); + } + }); + + it('publishes to an npm registry API and installs from the published tarball', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-npm-publish-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + const packagesDir = path.join(root, '.matrix', 'packages'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + const registry = await startNpmPublishRegistryFixture(); + + try { + const fixture = createMatrixPackage(path.join(root, 'source'), { + packageName: '@acme/fp30-npm-publish', + version: '1.0.0', + componentType: 'NpmPublishActor', + mount: 'npm.publish', + }); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-npm-publish', + '--registry', + registry.registryUrl, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(loginResult, 0); + + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry', + registry.registryUrl, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(publishResult, 0); + const publishPayload = parseLastJson(publishResult.logs) as { + packageName: string; + version: string; + registryUrl?: string; + }; + assert.equal(publishPayload.packageName, fixture.packageName); + assert.equal(publishPayload.version, fixture.version); + assert.equal(publishPayload.registryUrl, registry.registryUrl); + assert.equal(registry.publishRequests(), 1); + assert.equal( + registry.authorizationHeaders().some((header) => header.includes('token-npm-publish')), + true + ); + + const installResult = await runMxCli([ + 'node', + 'mx', + 'install', + `${fixture.packageName}@${fixture.version}`, + '--packages-dir', + packagesDir, + '--registry', + registry.registryUrl, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installResult, 0); + const installPayload = parseLastJson(installResult.logs) as { targetDir: string }; + assert.equal(fs.existsSync(path.join(installPayload.targetDir, 'matrix.json')), true); + assert.equal(registry.tarballRequests(), 1); + } finally { + await registry.close(); + } + }); + + it('keeps Matrix scoped packages on the requested npm registry API', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-scoped-npm-publish-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + const homeDir = path.join(root, 'home'); + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.mkdirSync(homeDir, { recursive: true }); + const packagesDir = path.join(root, '.matrix', 'packages'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + const registry = await startNpmPublishRegistryFixture(); + const previousHome = process.env.HOME; + const previousGlobalConfig = process.env.NPM_CONFIG_GLOBALCONFIG; + const wrongRegistry = 'http://127.0.0.1:9/wrong/npm/'; + const poisonedGlobalConfig = path.join(root, 'poisoned-global-npmrc'); + + fs.writeFileSync( + path.join(homeDir, '.npmrc'), + [ + `@matrix:registry=${wrongRegistry}`, + `@open-matrix:registry=${wrongRegistry}`, + `@omega:registry=${wrongRegistry}`, + '', + ].join('\n'), + 'utf8', + ); + fs.writeFileSync( + poisonedGlobalConfig, + [ + `@matrix:registry=${wrongRegistry}`, + `@open-matrix:registry=${wrongRegistry}`, + `@omega:registry=${wrongRegistry}`, + '', + ].join('\n'), + 'utf8', + ); + process.env.HOME = homeDir; + process.env.NPM_CONFIG_GLOBALCONFIG = poisonedGlobalConfig; + + try { + const fixture = createMatrixPackage(path.join(root, 'source'), { + packageName: '@matrix/fp30-scoped-npm-publish', + version: '1.0.0', + componentType: 'ScopedNpmPublishActor', + mount: 'scoped.npm.publish', + }); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-scoped-npm-publish', + '--registry', + registry.registryUrl, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(loginResult, 0); + + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry', + registry.registryUrl, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(publishResult, 0); + assert.equal(registry.publishRequests(), 1); + assert.equal( + registry.authorizationHeaders().some((header) => header.includes('token-scoped-npm-publish')), + true, + ); + + const installResult = await runMxCli([ + 'node', + 'mx', + 'install', + `${fixture.packageName}@${fixture.version}`, + '--packages-dir', + packagesDir, + '--registry', + registry.registryUrl, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installResult, 0); + const installPayload = parseLastJson(installResult.logs) as { targetDir: string }; + assert.equal(fs.existsSync(path.join(installPayload.targetDir, 'matrix.json')), true); + assert.equal(registry.tarballRequests(), 1); + } finally { + restoreEnv('HOME', previousHome); + restoreEnv('NPM_CONFIG_GLOBALCONFIG', previousGlobalConfig); + await registry.close(); + } + }); + + it('publishes to the configured npm registry API without a per-command registry flag', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-configured-npm-publish-')); + tempDirs.push(root); + useMatrixHome(path.join(root, '.matrix')); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + const registry = await startNpmPublishRegistryFixture(); + + try { + const fixture = createMatrixPackage(path.join(root, 'source'), { + packageName: '@acme/fp30-configured-npm-publish', + version: '1.0.0', + componentType: 'ConfiguredNpmPublishActor', + mount: 'configured.npm.publish', + }); + + const setConfig = await runMxCli([ + 'node', + 'mx', + 'config', + 'set', + 'registry', + registry.registryUrl, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(setConfig, 0); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-configured-npm-publish', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(loginResult, 0); + + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(publishResult, 0); + const publishPayload = parseLastJson(publishResult.logs) as { + packageName: string; + version: string; + registryUrl?: string; + }; + assert.equal(publishPayload.packageName, fixture.packageName); + assert.equal(publishPayload.version, fixture.version); + assert.equal(publishPayload.registryUrl, registry.registryUrl); + assert.equal(registry.publishRequests(), 1); + assert.equal( + registry.authorizationHeaders().some((header) => header.includes('token-configured-npm-publish')), + true, + ); + } finally { + await registry.close(); + } + }); + + it('publishes to an npm registry API using an npmrc token', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-npmrc-publish-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + const packagesDir = path.join(root, '.matrix', 'packages'); + const fixture = createMatrixPackage(path.join(root, 'source'), { + packageName: '@acme/fp30-npmrc-publish', + version: '1.0.0', + componentType: 'NpmrcPublishActor', + mount: 'npmrc.publish', + }); + const registry = await startNpmPublishRegistryFixture(); + const npmrcPath = path.join(root, '.npmrc'); + const registryKey = registry.registryUrl.replace(/^https?:/, ''); + const previousNpmUserConfig = process.env.NPM_CONFIG_USERCONFIG; + restoreEnvFns.push(() => restoreEnv('NPM_CONFIG_USERCONFIG', previousNpmUserConfig)); + fs.writeFileSync( + npmrcPath, + `${registryKey}:_authToken=npmrc-token-publish\n`, + 'utf8', + ); + process.env.NPM_CONFIG_USERCONFIG = npmrcPath; + try { + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry', + registry.registryUrl, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(publishResult, 0); + + const installResult = await runMxCli([ + 'node', + 'mx', + 'install', + `${fixture.packageName}@${fixture.version}`, + '--packages-dir', + packagesDir, + '--registry', + registry.registryUrl, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installResult, 0); + + assert.equal(registry.publishRequests(), 1); + assert.equal(registry.tarballRequests(), 1); + assert.equal(registry.authorizationHeaders().includes('Bearer npmrc-token-publish'), true); + } finally { + await registry.close(); + } + }); +}); diff --git a/packages/cli/tests/integration/fp30-fork-lifecycle.spec.ts b/packages/cli/tests/integration/fp30-fork-lifecycle.spec.ts new file mode 100644 index 0000000..a47ef8e --- /dev/null +++ b/packages/cli/tests/integration/fp30-fork-lifecycle.spec.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; +import { createMatrixPackage } from '../helpers/package-fixtures.js'; + +describe('FP-30 mx fork lifecycle', () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it('creates a fork with provenance metadata and renamed manifest surface', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-fork-')); + tempDirs.push(root); + const cwd = path.join(root, 'workspace'); + const packagesDir = path.join(root, '.matrix', 'packages'); + fs.mkdirSync(cwd, { recursive: true }); + + const source = createMatrixPackage(root, { + packageName: '@matrix/filesystem', + version: '1.0.0', + componentType: 'FolderProjection', + mount: 'fs', + }); + + const installResult = await runMxCli([ + 'node', + 'mx', + 'install', + source.packageDir, + '--packages-dir', + packagesDir, + '--json', + ], { cwd }); + assertExitCode(installResult, 0); + + const forkResult = await runMxCli([ + 'node', + 'mx', + 'fork', + '@matrix/filesystem', + '--name', + '@alice/filesystem', + '--packages-dir', + packagesDir, + '--forked-by', + 'alice', + '--json', + ], { cwd }); + assertExitCode(forkResult, 0); + const forkPayload = parseLastJson(forkResult.logs) as { targetDir: string }; + assert.equal(fs.existsSync(forkPayload.targetDir), true); + assert.equal(fs.existsSync(path.join(forkPayload.targetDir, '.forked-from')), true); + + const manifest = JSON.parse(fs.readFileSync(path.join(forkPayload.targetDir, 'matrix.json'), 'utf8')) as { + name: string; + components: Array<{ type?: string; export?: string; mount?: string }>; + }; + assert.equal(manifest.name, '@alice/filesystem'); + assert.equal(manifest.components[0]?.type, 'AliceFolderProjection'); + assert.equal(manifest.components[0]?.export, 'AliceFolderProjection'); + assert.equal(manifest.components[0]?.mount, 'alice.fs'); + }); +}); diff --git a/packages/cli/tests/integration/fp30-install-lifecycle.spec.ts b/packages/cli/tests/integration/fp30-install-lifecycle.spec.ts new file mode 100644 index 0000000..8aeb0c5 --- /dev/null +++ b/packages/cli/tests/integration/fp30-install-lifecycle.spec.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; + +function createLifecyclePackage(rootDir: string, packageName: string, scriptBody: string): string { + const packageDir = path.join(rootDir, packageName.replace(/^@/, '').replace('/', '-')); + fs.mkdirSync(path.join(packageDir, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + JSON.stringify( + { + name: packageName, + version: '1.0.0', + runtime: { language: 'javascript', entry: 'dist/index.js' }, + components: [], + install: { + seed: { + script: 'dist/seed.js', + description: 'seed test hook', + }, + }, + permissions: { fsPolicy: 'none' }, + }, + null, + 2, + ), + 'utf8', + ); + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify( + { + name: packageName, + version: '1.0.0', + type: 'module', + main: 'dist/index.js', + }, + null, + 2, + ), + 'utf8', + ); + fs.writeFileSync(path.join(packageDir, 'dist', 'index.js'), 'export const ready = true;\n', 'utf8'); + fs.writeFileSync(path.join(packageDir, 'dist', 'seed.js'), scriptBody, 'utf8'); + return packageDir; +} + +describe('FP-30 mx install lifecycle', () => { + const tempDirs: string[] = []; + + afterEach(() => { + delete process.env.MX_TEST_MARKER; + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it('runs install lifecycle hooks and exposes install env', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-install-lifecycle-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + const packagesDir = path.join(root, '.matrix', 'packages'); + const markerPath = path.join(root, 'seed-marker.json'); + process.env.MX_TEST_MARKER = markerPath; + + const packageDir = createLifecyclePackage( + root, + '@acme/install-lifecycle', + `import { writeFileSync } from 'node:fs'; +const marker = process.env.MX_TEST_MARKER; +if (!marker) throw new Error('missing marker'); +writeFileSync(marker, JSON.stringify({ + phase: process.env.MATRIX_INSTALL_PHASE, + packagesDir: process.env.MATRIX_PACKAGES_DIR, + packageDir: process.env.MATRIX_PACKAGE_DIR, + packageName: process.env.MATRIX_PACKAGE_NAME +}), 'utf8'); +`, + ); + + const result = await runMxCli([ + 'node', + 'mx', + 'install', + packageDir, + '--packages-dir', + packagesDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(result, 0); + + const payload = parseLastJson(result.logs) as { + targetDir: string; + lifecyclePhases: string[]; + }; + assert.deepEqual(payload.lifecyclePhases, ['seed']); + assert.equal(fs.existsSync(payload.targetDir), true); + assert.equal(fs.existsSync(markerPath), true); + + const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')) as Record; + assert.equal(marker.phase, 'seed'); + assert.equal(path.resolve(marker.packagesDir), path.resolve(packagesDir)); + assert.equal(path.resolve(marker.packageDir), path.resolve(payload.targetDir)); + assert.equal(marker.packageName, '@acme/install-lifecycle'); + }); + + it('rolls back package install when a lifecycle hook fails', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-install-lifecycle-fail-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + const packagesDir = path.join(root, '.matrix', 'packages'); + + const packageDir = createLifecyclePackage( + root, + '@acme/install-lifecycle-fail', + `throw new Error('seed failed');\n`, + ); + + const result = await runMxCli([ + 'node', + 'mx', + 'install', + packageDir, + '--packages-dir', + packagesDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(result, 1); + assert.equal( + result.errors.some((line) => line.includes('install.seed failed')), + true, + `Expected lifecycle failure in stderr, got:\n${result.errors.join('\n')}`, + ); + + const installedPath = path.join(packagesDir, 'node_modules', '@acme', 'install-lifecycle-fail'); + assert.equal(fs.existsSync(installedPath), false); + }); +}); diff --git a/packages/cli/tests/integration/fp30-install-source-resolution.spec.ts b/packages/cli/tests/integration/fp30-install-source-resolution.spec.ts new file mode 100644 index 0000000..8b7e254 --- /dev/null +++ b/packages/cli/tests/integration/fp30-install-source-resolution.spec.ts @@ -0,0 +1,611 @@ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; +import { createMatrixPackage } from '../helpers/package-fixtures.js'; + +async function startNpmRegistryFixture(params: { + readonly packageName: string; + readonly version: string; + readonly tarballPath: string; + readonly expectedToken?: string; +}): Promise<{ + readonly registryUrl: string; + readonly metadataRequests: () => number; + readonly tarballRequests: () => number; + readonly authorizationHeaders: () => readonly string[]; + close(): Promise; +}> { + const tarball = fs.readFileSync(params.tarballPath); + const shasum = createHash('sha1').update(tarball).digest('hex'); + const integrity = `sha512-${createHash('sha512').update(tarball).digest('base64')}`; + let metadataRequestCount = 0; + let tarballRequestCount = 0; + const authorizationHeaders: string[] = []; + + const server = http.createServer((request, response) => { + const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); + const decodedPath = decodeURIComponent(requestUrl.pathname); + const authorization = request.headers.authorization; + if (typeof authorization === 'string') { + authorizationHeaders.push(authorization); + } else if (Array.isArray(authorization)) { + authorizationHeaders.push(...authorization); + } + + if (decodedPath === `/${params.packageName}`) { + metadataRequestCount += 1; + const registryUrl = `http://127.0.0.1:${addressPort(server)}/`; + const metadata = { + name: params.packageName, + 'dist-tags': { latest: params.version }, + versions: { + [params.version]: { + name: params.packageName, + version: params.version, + dist: { + tarball: new URL('tarballs/package.tgz', registryUrl).toString(), + shasum, + integrity, + }, + }, + }, + }; + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(metadata)); + return; + } + + if (decodedPath === '/tarballs/package.tgz') { + tarballRequestCount += 1; + response.writeHead(200, { + 'content-type': 'application/octet-stream', + 'content-length': String(tarball.byteLength), + }); + response.end(tarball); + return; + } + + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: `Not found: ${decodedPath}` })); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + + return { + registryUrl: `http://127.0.0.1:${addressPort(server)}/`, + metadataRequests: () => metadataRequestCount, + tarballRequests: () => tarballRequestCount, + authorizationHeaders: () => [...authorizationHeaders], + async close(): Promise { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +function addressPort(server: http.Server): number { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Registry fixture is not listening on a TCP port'); + } + return address.port; +} + +function npmPackPackage(packageDir: string, outDir: string): string { + fs.mkdirSync(outDir, { recursive: true }); + const result = spawnSync('npm', ['pack', packageDir, '--pack-destination', outDir], { + encoding: 'utf8', + }); + assert.equal( + result.status, + 0, + `npm pack failed\nstdout:\n${result.stdout ?? ''}\nstderr:\n${result.stderr ?? ''}`, + ); + const fileName = result.stdout.trim().split(/\r?\n/).filter(Boolean).pop(); + assert.equal(Boolean(fileName), true); + return path.join(outDir, fileName!); +} + +describe('FP-30 mx install source resolution', () => { + const tempDirs: string[] = []; + const closeFns: Array<() => Promise> = []; + const restoreEnvFns: Array<() => void> = []; + + afterEach(async () => { + while (restoreEnvFns.length > 0) { + const restore = restoreEnvFns.pop(); + if (restore) { + restore(); + } + } + while (closeFns.length > 0) { + const close = closeFns.pop(); + if (close) { + await close(); + } + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + function useMatrixHome(matrixHome: string): void { + const previousMatrixHome = process.env.MATRIX_HOME; + const previousCliConfig = process.env.MATRIX_CLI_CONFIG; + const previousPackageRegistry = process.env.MATRIX_PACKAGE_REGISTRY; + const previousRegistryUrl = process.env.MATRIX_REGISTRY_URL; + process.env.MATRIX_HOME = matrixHome; + delete process.env.MATRIX_CLI_CONFIG; + delete process.env.MATRIX_PACKAGE_REGISTRY; + delete process.env.MATRIX_REGISTRY_URL; + restoreEnvFns.push(() => { + restoreEnv('MATRIX_HOME', previousMatrixHome); + restoreEnv('MATRIX_CLI_CONFIG', previousCliConfig); + restoreEnv('MATRIX_PACKAGE_REGISTRY', previousPackageRegistry); + restoreEnv('MATRIX_REGISTRY_URL', previousRegistryUrl); + }); + } + + function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; + } + + it('installs package from local directory, tarball, and registry name', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-install-sources-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + + const packagesDir = path.join(root, '.matrix', 'packages'); + const registryDir = path.join(root, '.matrix', 'registry'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + + const fixture = createMatrixPackage(path.join(root, 'source'), { + packageName: '@acme/install-source', + version: '1.0.0', + componentType: 'InstallSourceActor', + mount: 'install.source', + }); + + const installFromDirectory = await runMxCli([ + 'node', + 'mx', + 'install', + fixture.packageDir, + '--packages-dir', + packagesDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installFromDirectory, 0); + const installedFromDir = parseLastJson(installFromDirectory.logs) as { targetDir: string }; + assert.equal(fs.existsSync(installedFromDir.targetDir), true); + + const uninstallAfterDirectory = await runMxCli([ + 'node', + 'mx', + 'uninstall', + fixture.packageName, + '--packages-dir', + packagesDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(uninstallAfterDirectory, 0); + + const packResult = await runMxCli([ + 'node', + 'mx', + 'pack', + '--out-dir', + path.join(root, 'packed'), + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(packResult, 0); + const packPayload = parseLastJson(packResult.logs) as { tarballPath: string }; + assert.equal(fs.existsSync(packPayload.tarballPath), true); + + const installFromTarball = await runMxCli([ + 'node', + 'mx', + 'install', + packPayload.tarballPath, + '--packages-dir', + packagesDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installFromTarball, 0); + const installedFromTarball = parseLastJson(installFromTarball.logs) as { targetDir: string }; + assert.equal(fs.existsSync(installedFromTarball.targetDir), true); + + const uninstallAfterTarball = await runMxCli([ + 'node', + 'mx', + 'uninstall', + fixture.packageName, + '--packages-dir', + packagesDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(uninstallAfterTarball, 0); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-install', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(loginResult, 0); + + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(publishResult, 0); + + const installFromRegistry = await runMxCli([ + 'node', + 'mx', + 'install', + `${fixture.packageName}@${fixture.version}`, + '--packages-dir', + packagesDir, + '--registry-dir', + registryDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installFromRegistry, 0); + const installedFromRegistry = parseLastJson(installFromRegistry.logs) as { source: string; targetDir: string }; + assert.equal(installedFromRegistry.source, `${fixture.packageName}@${fixture.version}`); + assert.equal(fs.existsSync(installedFromRegistry.targetDir), true); + }); + + it('installs package from an npm-compatible registry URL with stored registry credentials', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-install-npm-registry-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + + const packagesDir = path.join(root, '.matrix', 'packages'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + const fixture = createMatrixPackage(path.join(root, 'source'), { + packageName: '@acme/remote-install', + version: '1.2.3', + componentType: 'RemoteInstallActor', + mount: 'remote.install', + }); + fs.mkdirSync(path.join(fixture.packageDir, 'vendor', 'runtime-helper'), { recursive: true }); + fs.writeFileSync( + path.join(fixture.packageDir, 'vendor', 'runtime-helper', 'package.json'), + JSON.stringify({ + name: '@acme/runtime-helper', + version: '1.0.0', + type: 'module', + main: 'index.js', + }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(fixture.packageDir, 'vendor', 'runtime-helper', 'index.js'), + 'export const helper = true;\n', + 'utf8', + ); + const packageJsonPath = path.join(fixture.packageDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as Record; + packageJson.dependencies = { + '@acme/runtime-helper': 'file:./vendor/runtime-helper', + }; + packageJson.devDependencies = { + '@open-matrix/core': '*', + }; + packageJson.peerDependencies = { + '@open-matrix/core': '*', + }; + packageJson.peerDependenciesMeta = { + '@open-matrix/core': { optional: true }, + }; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8'); + + const packResult = await runMxCli([ + 'node', + 'mx', + 'pack', + '--out-dir', + path.join(root, 'packed'), + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(packResult, 0); + const packPayload = parseLastJson(packResult.logs) as { tarballPath: string }; + + const registry = await startNpmRegistryFixture({ + packageName: fixture.packageName, + version: fixture.version, + tarballPath: packPayload.tarballPath, + expectedToken: 'token-remote-install', + }); + closeFns.push(registry.close); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-remote-install', + '--registry', + registry.registryUrl, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(loginResult, 0); + + const installFromRemoteRegistry = await runMxCli([ + 'node', + 'mx', + 'install', + `${fixture.packageName}@${fixture.version}`, + '--packages-dir', + packagesDir, + '--registry', + registry.registryUrl, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installFromRemoteRegistry, 0); + + const installed = parseLastJson(installFromRemoteRegistry.logs) as { + source: string; + targetDir: string; + version: string; + }; + assert.equal(installed.source, `${fixture.packageName}@${fixture.version}`); + assert.equal(installed.version, fixture.version); + assert.equal(fs.existsSync(installed.targetDir), true); + assert.equal( + fs.existsSync(path.join(installed.targetDir, 'node_modules', '@acme', 'runtime-helper', 'package.json')), + true, + ); + assert.equal( + fs.existsSync(path.join(installed.targetDir, 'node_modules', '@open-matrix', 'core', 'package.json')), + false, + ); + assert.equal(registry.metadataRequests() > 0, true); + assert.equal(registry.tarballRequests() > 0, true); + assert.equal( + registry.authorizationHeaders().some((header) => header.includes('token-remote-install')), + true, + ); + }); + + it('installs package names from the configured npm registry without a per-command registry flag', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-install-config-registry-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + useMatrixHome(path.join(root, '.matrix')); + + const packagesDir = path.join(root, '.matrix', 'packages'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + const fixture = createMatrixPackage(path.join(root, 'source'), { + packageName: '@acme/configured-remote-install', + version: '1.2.3', + componentType: 'ConfiguredRemoteInstallActor', + mount: 'configured.remote.install', + }); + + const packResult = await runMxCli([ + 'node', + 'mx', + 'pack', + '--out-dir', + path.join(root, 'packed'), + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(packResult, 0); + const packPayload = parseLastJson(packResult.logs) as { tarballPath: string }; + + const registry = await startNpmRegistryFixture({ + packageName: fixture.packageName, + version: fixture.version, + tarballPath: packPayload.tarballPath, + expectedToken: 'token-configured-install', + }); + closeFns.push(registry.close); + + const setConfig = await runMxCli([ + 'node', + 'mx', + 'config', + 'set', + 'registry', + registry.registryUrl, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(setConfig, 0); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-configured-install', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(loginResult, 0); + + const installFromConfiguredRegistry = await runMxCli([ + 'node', + 'mx', + 'install', + `${fixture.packageName}@${fixture.version}`, + '--packages-dir', + packagesDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installFromConfiguredRegistry, 0); + + const installed = parseLastJson(installFromConfiguredRegistry.logs) as { + source: string; + targetDir: string; + version: string; + }; + assert.equal(installed.source, `${fixture.packageName}@${fixture.version}`); + assert.equal(installed.version, fixture.version); + assert.equal(fs.existsSync(installed.targetDir), true); + assert.equal(registry.metadataRequests() > 0, true); + assert.equal(registry.tarballRequests() > 0, true); + assert.equal( + registry.authorizationHeaders().some((header) => header.includes('token-configured-install')), + true, + ); + }); + + it('rejects npm registry packages whose matrix.json and package.json versions disagree', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-install-version-mismatch-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + + const packagesDir = path.join(root, '.matrix', 'packages'); + const fixture = createMatrixPackage(path.join(root, 'source'), { + packageName: '@acme/version-mismatch', + version: '1.0.0', + componentType: 'VersionMismatchActor', + mount: 'version.mismatch', + }); + fs.writeFileSync( + path.join(fixture.packageDir, 'package.json'), + JSON.stringify({ + name: fixture.packageName, + version: '9.9.9', + type: 'module', + main: 'src/index.ts', + }, null, 2), + 'utf8', + ); + const tarballPath = npmPackPackage(fixture.packageDir, path.join(root, 'packed')); + const registry = await startNpmRegistryFixture({ + packageName: fixture.packageName, + version: '9.9.9', + tarballPath, + }); + closeFns.push(registry.close); + + const installResult = await runMxCli([ + 'node', + 'mx', + 'install', + `${fixture.packageName}@9.9.9`, + '--packages-dir', + packagesDir, + '--registry', + registry.registryUrl, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installResult, 1); + assert.equal( + installResult.errors.some((line) => line.includes('MX_PACKAGE_VERSION_MISMATCH')), + true, + ); + }); + + it('normalizes whitespace-padded registry specifiers before install', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-install-specifier-trim-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + + const packagesDir = path.join(root, '.matrix', 'packages'); + const registryDir = path.join(root, '.matrix', 'registry'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + + const fixture = createMatrixPackage(path.join(root, 'source'), { + packageName: '@acme/install-source', + version: '1.0.0', + componentType: 'InstallSourceActor', + mount: 'install.source', + }); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-install', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(loginResult, 0); + + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(publishResult, 0); + + const installFromRegistry = await runMxCli([ + 'node', + 'mx', + 'install', + ` ${fixture.packageName}@${fixture.version} `, + '--packages-dir', + packagesDir, + '--registry-dir', + registryDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installFromRegistry, 0); + + const installedFromRegistry = parseLastJson(installFromRegistry.logs) as { source: string; targetDir: string }; + assert.equal(installedFromRegistry.source, `${fixture.packageName}@${fixture.version}`); + assert.equal(fs.existsSync(installedFromRegistry.targetDir), true); + }); +}); diff --git a/packages/cli/tests/integration/fp30-install-uninstall-list-info-validate.spec.ts b/packages/cli/tests/integration/fp30-install-uninstall-list-info-validate.spec.ts new file mode 100644 index 0000000..cb3763f --- /dev/null +++ b/packages/cli/tests/integration/fp30-install-uninstall-list-info-validate.spec.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +describe('FP-30 mx lifecycle commands', () => { + const tempDirs: string[] = []; + + function createLocalPackage(rootDir: string): { packageDir: string; packageName: string } { + const packageName = '@acme/weather-actor'; + const packageDir = path.join(rootDir, 'weather-actor'); + fs.mkdirSync(path.join(packageDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + JSON.stringify( + { + name: packageName, + version: '1.0.0', + runtime: { language: 'typescript', entry: 'src/index.ts' }, + components: [ + { + type: 'WeatherActor', + export: 'WeatherActor', + mount: 'weather.actor', + autoStart: true, + }, + ], + permissions: { fsPolicy: 'none' }, + }, + null, + 2 + ), + 'utf8' + ); + fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify({ name: packageName, version: '1.0.0' }, null, 2)); + fs.writeFileSync(path.join(packageDir, 'src', 'index.ts'), 'export class WeatherActor {}\n', 'utf8'); + return { packageDir, packageName }; + } + + async function loadMxCli(): Promise<{ createProgram: () => import('commander').Command }> { + const mxCliUrl = new URL('../../src/index.ts', import.meta.url).href; + return await import(`${mxCliUrl}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`); + } + + async function runMxCli(argv: string[]): Promise { + const logs: string[] = []; + const originalLog = console.log; + const originalExit = process.exit; + const { createProgram } = await loadMxCli(); + console.log = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + (process as unknown as { exit: (code?: number) => never }).exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as never; + + try { + const program = createProgram(); + await program.parseAsync(argv); + return logs; + } finally { + console.log = originalLog; + (process as unknown as { exit: typeof process.exit }).exit = originalExit; + } + } + + function parseLastJson(logs: string[]): unknown { + for (let i = logs.length - 1; i >= 0; i--) { + const value = logs[i].trim(); + if (!value) continue; + try { + return JSON.parse(value); + } catch { + // Continue scanning log lines until JSON is found. + } + } + throw new Error(`No JSON payload found in logs:\n${logs.join('\n')}`); + } + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it('installs, lists, inspects, validates, and uninstalls local packages', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-lifecycle-')); + tempDirs.push(root); + const cwd = path.join(root, 'workspace'); + const packagesDir = path.join(cwd, '.matrix', 'packages'); + fs.mkdirSync(cwd, { recursive: true }); + + const { packageDir, packageName } = createLocalPackage(root); + + await runMxCli([ + 'node', + 'mx', + 'install', + packageDir, + '--packages-dir', + packagesDir, + '--json', + ]); + + const installedPath = path.join(packagesDir, 'node_modules', '@acme', 'weather-actor'); + assert.equal(fs.existsSync(installedPath), true); + + const listedAfterInstall = parseLastJson(await runMxCli([ + 'node', + 'mx', + 'list', + '--packages-dir', + packagesDir, + '--json', + ])) as string[]; + assert.equal(Array.isArray(listedAfterInstall), true); + assert.equal(listedAfterInstall.includes(packageName), true); + + const infoPayload = parseLastJson(await runMxCli([ + 'node', + 'mx', + 'info', + packageName, + '--packages-dir', + packagesDir, + '--json', + ])) as { packageName: string; installPath: string }; + assert.equal(infoPayload.packageName, packageName); + assert.equal(path.resolve(infoPayload.installPath), path.resolve(installedPath)); + + const validatePayload = parseLastJson(await runMxCli([ + 'node', + 'mx', + 'validate', + path.join(packageDir, 'matrix.json'), + '--json', + ])) as { packageName: string; valid: boolean }; + assert.equal(validatePayload.packageName, packageName); + assert.equal(validatePayload.valid, true); + + await runMxCli([ + 'node', + 'mx', + 'uninstall', + packageName, + '--packages-dir', + packagesDir, + '--json', + ]); + assert.equal(fs.existsSync(installedPath), false); + + const listedAfterUninstall = parseLastJson(await runMxCli([ + 'node', + 'mx', + 'list', + '--packages-dir', + packagesDir, + '--json', + ])) as string[]; + assert.equal(Array.isArray(listedAfterUninstall), true); + assert.equal(listedAfterUninstall.includes(packageName), false); + }); +}); diff --git a/packages/cli/tests/integration/fp30-outdated-update-fork.spec.ts b/packages/cli/tests/integration/fp30-outdated-update-fork.spec.ts new file mode 100644 index 0000000..e1a9661 --- /dev/null +++ b/packages/cli/tests/integration/fp30-outdated-update-fork.spec.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; +import { createMatrixPackage } from '../helpers/package-fixtures.js'; + +describe('FP-30 mx outdated/update-fork', () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it('reports outdated forks and updates fork upstream version metadata', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-outdated-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + + const packagesDir = path.join(root, '.matrix', 'packages'); + const registryDir = path.join(root, '.matrix', 'registry'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + + const upstreamV1 = createMatrixPackage(path.join(root, 'upstream-v1'), { + packageName: '@matrix/filesystem', + version: '1.0.0', + componentType: 'FolderProjection', + mount: 'fs', + }); + const upstreamV12 = createMatrixPackage(path.join(root, 'upstream-v12'), { + packageName: '@matrix/filesystem', + version: '1.2.0', + componentType: 'FolderProjection', + mount: 'fs', + }); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-xyz', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(loginResult, 0); + + for (const pkg of [upstreamV1, upstreamV12]) { + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: pkg.packageDir }); + assertExitCode(publishResult, 0); + } + + const installOrigin = await runMxCli([ + 'node', + 'mx', + 'install', + '@matrix/filesystem@1.0.0', + '--packages-dir', + packagesDir, + '--registry-dir', + registryDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installOrigin, 0); + + const forkResult = await runMxCli([ + 'node', + 'mx', + 'fork', + '@matrix/filesystem', + '--name', + '@alice/filesystem', + '--packages-dir', + packagesDir, + '--forked-by', + 'alice', + '--json', + ], { cwd: workspaceDir }); + assertExitCode(forkResult, 0); + + const outdatedResult = await runMxCli([ + 'node', + 'mx', + 'outdated', + '--packages-dir', + packagesDir, + '--registry-dir', + registryDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(outdatedResult, 0); + const outdatedPayload = parseLastJson(outdatedResult.logs) as Array<{ + packageName: string; + status: string; + latestOriginVersion: string | null; + }>; + const forkEntry = outdatedPayload.find((entry) => entry.packageName === '@alice/filesystem'); + assert.ok(forkEntry); + assert.equal(forkEntry?.status, 'outdated'); + assert.equal(forkEntry?.latestOriginVersion, '1.2.0'); + + const updateResult = await runMxCli([ + 'node', + 'mx', + 'update-fork', + '@alice/filesystem', + '--packages-dir', + packagesDir, + '--registry-dir', + registryDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(updateResult, 0); + const updatePayload = parseLastJson(updateResult.logs) as { + updated: boolean; + previousOriginVersion: string; + nextOriginVersion: string; + }; + assert.equal(updatePayload.updated, true); + assert.equal(updatePayload.previousOriginVersion, '1.0.0'); + assert.equal(updatePayload.nextOriginVersion, '1.2.0'); + }); +}); diff --git a/packages/cli/tests/integration/fp30-pack-command.spec.ts b/packages/cli/tests/integration/fp30-pack-command.spec.ts new file mode 100644 index 0000000..062d15b --- /dev/null +++ b/packages/cli/tests/integration/fp30-pack-command.spec.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; +import { createMatrixPackage } from '../helpers/package-fixtures.js'; + +describe('FP-30 mx pack command', () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it('creates distributable tarball with package identity metadata', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-pack-')); + tempDirs.push(root); + + const fixture = createMatrixPackage(root, { + packageName: '@acme/fp30-pack', + version: '2.3.4', + componentType: 'PackActor', + mount: 'fp30.pack', + }); + const outDir = path.join(root, 'dist-pack'); + + const result = await runMxCli([ + 'node', + 'mx', + 'pack', + '--out-dir', + outDir, + '--json', + ], { cwd: fixture.packageDir }); + + assertExitCode(result, 0); + const payload = parseLastJson(result.logs) as { + packageName: string; + version: string; + tarballPath: string; + }; + assert.equal(payload.packageName, fixture.packageName); + assert.equal(payload.version, fixture.version); + assert.equal(fs.existsSync(payload.tarballPath), true); + assert.equal(payload.tarballPath.endsWith('.tar.gz'), true); + }); +}); diff --git a/packages/cli/tests/integration/fp30-publish-preflight.spec.ts b/packages/cli/tests/integration/fp30-publish-preflight.spec.ts new file mode 100644 index 0000000..6063eb1 --- /dev/null +++ b/packages/cli/tests/integration/fp30-publish-preflight.spec.ts @@ -0,0 +1,342 @@ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; +import { createMatrixPackage } from '../helpers/package-fixtures.js'; + +describe('FP-30 mx publish preflight', () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it('requires login and enforces immutable version publish', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-publish-')); + tempDirs.push(root); + + const fixture = createMatrixPackage(root, { + packageName: '@acme/fp30-publish', + version: '1.0.0', + componentType: 'PublishActor', + mount: 'fp30.publish', + }); + const registryDir = path.join(root, 'registry'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + + const unauthenticatedPublish = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(unauthenticatedPublish, 1); + assert.equal( + unauthenticatedPublish.errors.some((line) => line.includes('MX_AUTH_REQUIRED')), + true + ); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-abc', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(loginResult, 0); + + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(publishResult, 0); + const publishPayload = parseLastJson(publishResult.logs) as { + packageName: string; + version: string; + registryTarballPath?: string; + }; + assert.equal(publishPayload.packageName, fixture.packageName); + assert.equal(publishPayload.version, fixture.version); + assert.equal(Boolean(publishPayload.registryTarballPath && fs.existsSync(publishPayload.registryTarballPath)), true); + + const republishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(republishResult, 1); + assert.equal(republishResult.errors.some((line) => line.includes('MX_VERSION_EXISTS')), true); + }); + + it('allows webapp-only packages without actor components', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-publish-webapp-')); + tempDirs.push(root); + + const packageDir = path.join(root, 'webapp-only'); + const registryDir = path.join(root, 'registry'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + fs.mkdirSync(path.join(packageDir, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(packageDir, 'dist', 'index.html'), 'Webapp only\n', 'utf8'); + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + JSON.stringify( + { + name: '@acme/fp30-webapp-only', + version: '1.0.0', + runtime: { language: 'typescript', entry: 'dist/index.html' }, + webapp: { distDir: 'dist', entry: 'index.html', appName: 'webapp-only' }, + components: [], + permissions: { fsPolicy: 'none' }, + }, + null, + 2, + ), + 'utf8', + ); + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify( + { + name: '@acme/fp30-webapp-only', + version: '1.0.0', + type: 'module', + files: ['dist', 'matrix.json'], + }, + null, + 2, + ), + 'utf8', + ); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-webapp-only', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: packageDir }); + assertExitCode(loginResult, 0); + + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: packageDir }); + assertExitCode(publishResult, 0); + const publishPayload = parseLastJson(publishResult.logs) as { + packageName: string; + version: string; + registryTarballPath?: string; + }; + assert.equal(publishPayload.packageName, '@acme/fp30-webapp-only'); + assert.equal(publishPayload.version, '1.0.0'); + assert.equal(Boolean(publishPayload.registryTarballPath && fs.existsSync(publishPayload.registryTarballPath)), true); + }); + + it('allows runtime-only packages with a root actor and no components', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-publish-runtime-only-')); + tempDirs.push(root); + + const packageDir = path.join(root, 'runtime-only'); + const registryDir = path.join(root, 'registry'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + fs.mkdirSync(path.join(packageDir, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(packageDir, 'dist', 'index.js'), 'export class RuntimeOnlyActor {}\n', 'utf8'); + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + JSON.stringify( + { + name: '@acme/fp30-runtime-only', + version: '1.0.0', + root: { + type: 'RuntimeOnlyActor', + export: 'RuntimeOnlyActor', + description: 'Runtime-only root actor', + }, + runtime: { language: 'typescript', entry: 'dist/index.js' }, + components: [], + permissions: { fsPolicy: 'none' }, + }, + null, + 2, + ), + 'utf8', + ); + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify( + { + name: '@acme/fp30-runtime-only', + version: '1.0.0', + type: 'module', + files: ['dist', 'matrix.json'], + }, + null, + 2, + ), + 'utf8', + ); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-runtime-only', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: packageDir }); + assertExitCode(loginResult, 0); + + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: packageDir }); + assertExitCode(publishResult, 0); + const publishPayload = parseLastJson(publishResult.logs) as { + packageName: string; + version: string; + registryTarballPath?: string; + }; + assert.equal(publishPayload.packageName, '@acme/fp30-runtime-only'); + assert.equal(publishPayload.version, '1.0.0'); + assert.equal(Boolean(publishPayload.registryTarballPath && fs.existsSync(publishPayload.registryTarballPath)), true); + }); + + it('allows service factory packages declared by matrix.json runtime.factory', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-publish-service-')); + tempDirs.push(root); + + const packageDir = path.join(root, 'service-factory'); + const registryDir = path.join(root, 'registry'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + fs.mkdirSync(path.join(packageDir, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(packageDir, 'dist', 'index.js'), + 'export async function createServiceFactory() { return { runtime: { shutdown() {} } }; }\n', + 'utf8', + ); + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + JSON.stringify( + { + name: '@acme/fp30-service-factory', + version: '1.0.0', + runtime: { + language: 'typescript', + entry: 'dist/index.js', + factory: { + kind: 'factory', + export: 'createServiceFactory', + instance: { + id: 'RUNTIME-SERVICE-FACTORY', + class: 'service', + rootKind: 'package-root', + autoStart: true, + }, + }, + }, + components: [], + permissions: { fsPolicy: 'none' }, + }, + null, + 2, + ), + 'utf8', + ); + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify( + { + name: '@acme/fp30-service-factory', + version: '1.0.0', + type: 'module', + files: ['dist', 'matrix.json'], + }, + null, + 2, + ), + 'utf8', + ); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-service-factory', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: packageDir }); + assertExitCode(loginResult, 0); + + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: packageDir }); + assertExitCode(publishResult, 0); + const publishPayload = parseLastJson(publishResult.logs) as { + packageName: string; + version: string; + registryTarballPath?: string; + }; + assert.equal(publishPayload.packageName, '@acme/fp30-service-factory'); + assert.equal(publishPayload.version, '1.0.0'); + assert.equal(Boolean(publishPayload.registryTarballPath && fs.existsSync(publishPayload.registryTarballPath)), true); + }); +}); diff --git a/packages/cli/tests/integration/fp30-registry-command.spec.ts b/packages/cli/tests/integration/fp30-registry-command.spec.ts new file mode 100644 index 0000000..3a5c343 --- /dev/null +++ b/packages/cli/tests/integration/fp30-registry-command.spec.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; + +function addressPort(server: http.Server): number { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Fixture registry did not bind a TCP port'); + } + return address.port; +} + +function asRecord(value: unknown): Record { + assert.ok(value !== null && typeof value === 'object' && !Array.isArray(value)); + return value as Record; +} + +async function startRegistryFixture(): Promise<{ readonly registryUrl: string; readonly close: () => Promise }> { + const server = http.createServer((req, res) => { + const registryUrl = `http://127.0.0.1:${addressPort(server)}/api/packages/open-matrix/npm/`; + const url = new URL(req.url ?? '/', registryUrl); + if (req.method === 'GET' && url.pathname.endsWith('/-/whoami')) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ username: 'alice' })); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + + return { + registryUrl: `http://127.0.0.1:${addressPort(server)}/api/packages/open-matrix/npm/`, + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +describe('FP-30 mx registry commands', { concurrency: false }, () => { + const tempDirs: string[] = []; + const restoreEnvFns: Array<() => void> = []; + const fixtureClosers: Array<() => Promise> = []; + + afterEach(async () => { + while (fixtureClosers.length > 0) { + const close = fixtureClosers.pop(); + if (close) { + await close(); + } + } + while (restoreEnvFns.length > 0) { + const restore = restoreEnvFns.pop(); + if (restore) { + restore(); + } + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + function useCleanEnv(root: string): void { + const previousHome = process.env.HOME; + const previousMatrixHome = process.env.MATRIX_HOME; + const previousCliConfig = process.env.MATRIX_CLI_CONFIG; + const previousPackageRegistry = process.env.MATRIX_PACKAGE_REGISTRY; + const previousRegistryUrl = process.env.MATRIX_REGISTRY_URL; + const previousNpmUserConfig = process.env.NPM_CONFIG_USERCONFIG; + const previousNodeAuthToken = process.env.NODE_AUTH_TOKEN; + const previousNpmToken = process.env.NPM_TOKEN; + const home = path.join(root, 'home'); + fs.mkdirSync(home, { recursive: true }); + process.env.HOME = home; + process.env.MATRIX_HOME = path.join(root, '.matrix'); + delete process.env.MATRIX_CLI_CONFIG; + delete process.env.MATRIX_PACKAGE_REGISTRY; + delete process.env.MATRIX_REGISTRY_URL; + delete process.env.NPM_CONFIG_USERCONFIG; + delete process.env.NODE_AUTH_TOKEN; + delete process.env.NPM_TOKEN; + restoreEnvFns.push(() => { + restoreEnv('HOME', previousHome); + restoreEnv('MATRIX_HOME', previousMatrixHome); + restoreEnv('MATRIX_CLI_CONFIG', previousCliConfig); + restoreEnv('MATRIX_PACKAGE_REGISTRY', previousPackageRegistry); + restoreEnv('MATRIX_REGISTRY_URL', previousRegistryUrl); + restoreEnv('NPM_CONFIG_USERCONFIG', previousNpmUserConfig); + restoreEnv('NODE_AUTH_TOKEN', previousNodeAuthToken); + restoreEnv('NPM_TOKEN', previousNpmToken); + }); + } + + function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; + } + + it('uses a local Gitea registry profile and diagnoses scope config without reading poisoned user npmrc', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-registry-command-')); + tempDirs.push(root); + useCleanEnv(root); + fs.writeFileSync(path.join(process.env.HOME!, '.npmrc'), [ + '@matrix:registry=https://wrong.example.invalid/', + '@open-matrix:registry=https://wrong.example.invalid/', + '@omega:registry=https://wrong.example.invalid/', + '', + ].join('\n'), 'utf8'); + const fixture = await startRegistryFixture(); + fixtureClosers.push(fixture.close); + const credentialsFile = path.join(root, 'credentials.json'); + + const useResult = await runMxCli([ + 'node', + 'mx', + 'registry', + 'use', + 'local-gitea', + '--registry', + fixture.registryUrl, + '--json', + ], { cwd: root }); + assertExitCode(useResult, 0); + assert.equal((parseLastJson(useResult.logs) as Record).registry, fixture.registryUrl); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'registry', + 'login', + '--registry', + fixture.registryUrl, + '--username', + 'alice', + '--token', + 'token-registry-command', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: root }); + assertExitCode(loginResult, 0); + + const doctorResult = await runMxCli([ + 'node', + 'mx', + 'registry', + 'doctor', + '--registry', + fixture.registryUrl, + '--credentials-file', + credentialsFile, + '--timeout-ms', + '5000', + '--json', + ], { cwd: root }); + assertExitCode(doctorResult, 0); + const doctor = asRecord(parseLastJson(doctorResult.logs)); + const registry = asRecord(doctor.registry); + const credentials = asRecord(doctor.credentials); + const npm = asRecord(doctor.npm); + assert.equal(doctor.ok, true); + assert.equal(registry.url, fixture.registryUrl); + assert.equal(credentials.found, true); + assert.equal(credentials.authOk, true); + assert.equal(npm.allScopesMatch, true); + assert.equal(npm.poisonedUserConfigIgnored, true); + assert.deepEqual(npm.scopeResolution, { + '@open-matrix': fixture.registryUrl, + '@matrix': fixture.registryUrl, + '@omega': fixture.registryUrl, + }); + }); +}); diff --git a/packages/cli/tests/integration/fp30-scaffold-actor-element.spec.ts b/packages/cli/tests/integration/fp30-scaffold-actor-element.spec.ts new file mode 100644 index 0000000..04c0add --- /dev/null +++ b/packages/cli/tests/integration/fp30-scaffold-actor-element.spec.ts @@ -0,0 +1,54 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { actorElementCommand } from '../../src/commands/actor-element.js'; +import { installOpenMatrixCoreStub } from '../support/installCoreStub.ts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '../../../../'); + +describe('FP-30 mx actor-element scaffold', () => { + it('creates paired actor and element scaffold package', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-element-')); + const outputDir = path.join(tmpRoot, 'weather-panel'); + + try { + const result = await actorElementCommand('weather-panel', { + cwd: repoRoot, + outputDir, + }); + + assert.equal(fs.existsSync(path.join(outputDir, 'matrix.json')), true); + assert.equal(fs.existsSync(path.join(outputDir, 'src', `${result.actorClassName}.ts`)), true); + assert.equal(fs.existsSync(path.join(outputDir, 'src', `${result.elementClassName}.ts`)), true); + assert.equal(fs.existsSync(path.join(outputDir, 'examples', 'query-value.mxq')), true); + assert.equal(fs.existsSync(path.join(outputDir, 'examples', 'render-element.mxq')), true); + + const manifest = JSON.parse(fs.readFileSync(path.join(outputDir, 'matrix.json'), 'utf8')) as { + components: Array<{ mount?: string; type?: string }>; + }; + assert.equal(manifest.components.length, 2); + assert.equal(manifest.components.some((entry) => entry.mount === 'weather-panel.actor'), true); + assert.equal(manifest.components.some((entry) => entry.mount === 'weather-panel.element'), true); + + installOpenMatrixCoreStub(outputDir); + + const compile = spawnSync( + process.execPath, + [ + path.resolve(repoRoot, 'node_modules/tsx/dist/cli.mjs'), + path.join(outputDir, 'src', 'index.ts'), + ], + { cwd: outputDir, encoding: 'utf8' } + ); + assert.equal(compile.status, 0, compile.stderr || '(no stderr)'); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/tests/integration/fp30-scaffold-cqrs.spec.ts b/packages/cli/tests/integration/fp30-scaffold-cqrs.spec.ts new file mode 100644 index 0000000..820922e --- /dev/null +++ b/packages/cli/tests/integration/fp30-scaffold-cqrs.spec.ts @@ -0,0 +1,54 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { cqrsCommand } from '../../src/commands/cqrs.js'; +import { installOpenMatrixCoreStub } from '../support/installCoreStub.ts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '../../../../'); + +describe('FP-30 mx cqrs scaffold', () => { + it('creates command/query archetype package', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-cqrs-')); + const outputDir = path.join(tmpRoot, 'orders-cqrs'); + + try { + const result = await cqrsCommand('orders-cqrs', { + cwd: repoRoot, + outputDir, + }); + + assert.equal(fs.existsSync(path.join(outputDir, 'matrix.json')), true); + assert.equal(fs.existsSync(path.join(outputDir, 'src', `${result.commandClassName}.ts`)), true); + assert.equal(fs.existsSync(path.join(outputDir, 'src', `${result.queryClassName}.ts`)), true); + assert.equal(fs.existsSync(path.join(outputDir, 'examples', 'execute.mxq')), true); + assert.equal(fs.existsSync(path.join(outputDir, 'examples', 'query.mxq')), true); + + const manifest = JSON.parse(fs.readFileSync(path.join(outputDir, 'matrix.json'), 'utf8')) as { + components: Array<{ mount?: string; type?: string }>; + }; + assert.equal(manifest.components.length, 2); + assert.equal(manifest.components.some((entry) => entry.mount === 'orders-cqrs.command'), true); + assert.equal(manifest.components.some((entry) => entry.mount === 'orders-cqrs.query'), true); + + installOpenMatrixCoreStub(outputDir); + + const compile = spawnSync( + process.execPath, + [ + path.resolve(repoRoot, 'node_modules/tsx/dist/cli.mjs'), + path.join(outputDir, 'src', 'index.ts'), + ], + { cwd: outputDir, encoding: 'utf8' } + ); + assert.equal(compile.status, 0, compile.stderr || '(no stderr)'); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/tests/integration/fp30-scaffold-element.spec.ts b/packages/cli/tests/integration/fp30-scaffold-element.spec.ts new file mode 100644 index 0000000..105e67f --- /dev/null +++ b/packages/cli/tests/integration/fp30-scaffold-element.spec.ts @@ -0,0 +1,54 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { elementCommand } from '../../src/commands/element.js'; +import { installOpenMatrixCoreStub } from '../support/installCoreStub.ts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '../../../../'); + +describe('FP-30 mx element scaffold', () => { + it('creates a compilable element package archetype', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-element-')); + const outputDir = path.join(tmpRoot, 'status-element'); + + try { + const result = await elementCommand('status-element', { + cwd: repoRoot, + outputDir, + }); + + assert.equal(fs.existsSync(path.join(outputDir, 'matrix.json')), true); + assert.equal(fs.existsSync(path.join(outputDir, 'src', `${result.className}.ts`)), true); + assert.equal(fs.existsSync(path.join(outputDir, 'examples', 'render.mxq')), true); + assert.equal(fs.existsSync(path.join(outputDir, 'skills', 'status-element.skill.md')), true); + + const manifest = JSON.parse(fs.readFileSync(path.join(outputDir, 'matrix.json'), 'utf8')) as { + name: string; + components: Array<{ mount?: string; type?: string }>; + }; + assert.equal(manifest.name, 'status-element'); + assert.equal(manifest.components[0]?.mount, 'status-element'); + assert.equal(manifest.components[0]?.type, result.className); + + installOpenMatrixCoreStub(outputDir); + + const compile = spawnSync( + process.execPath, + [ + path.resolve(repoRoot, 'node_modules/tsx/dist/cli.mjs'), + path.join(outputDir, 'src', `${result.className}.ts`), + ], + { cwd: outputDir, encoding: 'utf8' } + ); + assert.equal(compile.status, 0, compile.stderr || '(no stderr)'); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/tests/integration/fp30-search.spec.ts b/packages/cli/tests/integration/fp30-search.spec.ts new file mode 100644 index 0000000..f564785 --- /dev/null +++ b/packages/cli/tests/integration/fp30-search.spec.ts @@ -0,0 +1,341 @@ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; +import { createMatrixPackage } from '../helpers/package-fixtures.js'; + +interface ISearchFixture { + readonly registryUrl: string; + readonly searchRequests: () => number; + readonly authorizationHeaders: () => readonly string[]; + readonly searchQueries: () => readonly string[]; + close(): Promise; +} + +function addressPort(server: http.Server): number { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Search registry fixture is not listening on a TCP port'); + } + return address.port; +} + +async function startSearchRegistryFixture(): Promise { + let searchRequestCount = 0; + const authorizationHeaders: string[] = []; + const searchQueries: string[] = []; + + const server = http.createServer((request, response) => { + const registryUrl = `http://127.0.0.1:${addressPort(server)}/`; + const requestUrl = new URL(request.url ?? '/', registryUrl); + const auth = request.headers.authorization; + if (typeof auth === 'string') { + authorizationHeaders.push(auth); + } + + if (requestUrl.pathname === '/-/v1/search') { + searchRequestCount += 1; + searchQueries.push(requestUrl.searchParams.get('text') ?? ''); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ + objects: [ + { + package: { + name: '@open-matrix/chat', + version: '0.2.3', + description: 'Chat package', + keywords: ['matrix', 'chat'], + }, + }, + { + package: { + scope: '@open-matrix', + name: 'flowpad', + version: '0.3.0', + description: 'FlowPad package', + keywords: ['matrix', 'flowpad'], + }, + }, + { + package: { + name: '@open-matrix/director', + version: '0.1.0', + description: 'Director package', + keywords: ['matrix', 'director'], + }, + }, + ], + })); + return; + } + + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: `Not found: ${requestUrl.pathname}` })); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + + return { + registryUrl: `http://127.0.0.1:${addressPort(server)}/`, + searchRequests: () => searchRequestCount, + authorizationHeaders: () => [...authorizationHeaders], + searchQueries: () => [...searchQueries], + async close(): Promise { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +describe('FP-30 mx search command', () => { + const tempDirs: string[] = []; + const closeFns: Array<() => Promise> = []; + const restoreEnvFns: Array<() => void> = []; + + afterEach(async () => { + while (restoreEnvFns.length > 0) { + const restore = restoreEnvFns.pop(); + if (restore) { + restore(); + } + } + while (closeFns.length > 0) { + const close = closeFns.pop(); + if (close) { + await close(); + } + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + function useMatrixHome(matrixHome: string): void { + const previousMatrixHome = process.env.MATRIX_HOME; + const previousCliConfig = process.env.MATRIX_CLI_CONFIG; + const previousPackageRegistry = process.env.MATRIX_PACKAGE_REGISTRY; + const previousRegistryUrl = process.env.MATRIX_REGISTRY_URL; + process.env.MATRIX_HOME = matrixHome; + delete process.env.MATRIX_CLI_CONFIG; + delete process.env.MATRIX_PACKAGE_REGISTRY; + delete process.env.MATRIX_REGISTRY_URL; + restoreEnvFns.push(() => { + restoreEnv('MATRIX_HOME', previousMatrixHome); + restoreEnv('MATRIX_CLI_CONFIG', previousCliConfig); + restoreEnv('MATRIX_PACKAGE_REGISTRY', previousPackageRegistry); + restoreEnv('MATRIX_REGISTRY_URL', previousRegistryUrl); + }); + } + + function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; + } + + it('searches the configured npm registry with stored registry credentials', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-search-')); + tempDirs.push(root); + useMatrixHome(path.join(root, '.matrix')); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + const registry = await startSearchRegistryFixture(); + closeFns.push(registry.close); + + const setConfig = await runMxCli([ + 'node', + 'mx', + 'config', + 'set', + 'registry', + registry.registryUrl, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(setConfig, 0); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-search', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(loginResult, 0); + + const searchResult = await runMxCli([ + 'node', + 'mx', + 'search', + 'chat', + '--credentials-file', + credentialsFile, + '--limit', + '5', + '--json', + ], { cwd: workspaceDir }); + assertExitCode(searchResult, 0); + const payload = parseLastJson(searchResult.logs) as { + query: string; + registry: string; + registrySource: string; + results: Array<{ packageName: string; version: string; description?: string; keywords: string[] }>; + }; + assert.equal(payload.query, 'chat'); + assert.equal(payload.registry, registry.registryUrl); + assert.equal(payload.registrySource, 'config'); + assert.equal(payload.results.length, 3); + assert.equal(payload.results[0]?.packageName, '@open-matrix/chat'); + assert.equal(payload.results[0]?.version, '0.2.3'); + assert.deepEqual(payload.results[0]?.keywords, ['matrix', 'chat']); + assert.equal(payload.results[1]?.packageName, '@open-matrix/flowpad'); + assert.equal(payload.results[1]?.version, '0.3.0'); + assert.equal(registry.searchRequests(), 1); + assert.deepEqual(registry.searchQueries(), ['chat']); + assert.equal( + registry.authorizationHeaders().some((header) => header.includes('token-search')), + true, + ); + }); + + it('searches the local package discovery index created by publish --registry-dir', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-discovery-search-')); + tempDirs.push(root); + useMatrixHome(path.join(root, '.matrix')); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + const registryDir = path.join(root, '.matrix', 'registry'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + const fixture = createMatrixPackage(path.join(root, 'source'), { + packageName: '@acme/semantic-chat', + version: '1.2.3', + componentType: 'SemanticChatActor', + mount: 'semantic.chat', + }); + + const manifestPath = path.join(fixture.packageDir, 'matrix.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; + manifest.description = 'Semantic package for chat workflows'; + manifest.class = 'hybrid'; + manifest.tags = ['communication']; + manifest.capabilities = ['conversation']; + manifest.webapp = { + distDir: 'dist', + entry: 'index.html', + appName: 'chat', + displayName: 'Semantic Chat', + icon: '💬', + navOrder: 30, + description: 'Conversation workspace', + shells: ['platform', 'edge'], + }; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); + fs.mkdirSync(path.join(fixture.packageDir, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(fixture.packageDir, 'dist', 'index.html'), 'Semantic Chat', 'utf8'); + fs.writeFileSync( + path.join(fixture.packageDir, 'src', 'index.ts'), + `export class SemanticChatActor { + static description = 'Coordinates indexed chat conversations'; + static kind = 'service'; + static accepts = { + 'chat.status': { description: 'Read chat status' }, + 'chat.send': { description: 'Send a chat message' } + }; + static emits = { + 'chat.message.created': { description: 'Message created' } + }; + static skills = { + 'conversation-management': { description: 'Manage conversation state' } + }; + static tags = ['chat', 'semantic']; + static capabilities = ['messaging']; +} +`, + 'utf8', + ); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-discovery-search', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(loginResult, 0); + + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: fixture.packageDir }); + assertExitCode(publishResult, 0); + assert.equal(fs.existsSync(path.join(registryDir, 'discovery-index.json')), true); + + const searchResult = await runMxCli([ + 'node', + 'mx', + 'search', + 'conversation', + '--registry-dir', + registryDir, + '--limit', + '5', + '--json', + ], { cwd: workspaceDir }); + assertExitCode(searchResult, 0); + const payload = parseLastJson(searchResult.logs) as { + query: string; + registry: string; + registrySource: string; + results: Array<{ + packageName: string; + version: string; + displayName?: string; + inferredKind?: string; + keywords: readonly string[]; + matchedFields?: readonly string[]; + }>; + }; + assert.equal(payload.query, 'conversation'); + assert.equal(payload.registry, registryDir); + assert.equal(payload.registrySource, 'local'); + assert.equal(payload.results.length, 1); + assert.equal(payload.results[0]?.packageName, '@acme/semantic-chat'); + assert.equal(payload.results[0]?.version, '1.2.3'); + assert.equal(payload.results[0]?.displayName, 'Semantic Chat'); + assert.equal(payload.results[0]?.inferredKind, 'app'); + assert.equal(payload.results[0]?.keywords.includes('conversation-management'), true); + assert.equal(payload.results[0]?.keywords.includes('edge'), true); + assert.equal(payload.results[0]?.matchedFields?.includes('component.skills'), true); + }); +}); diff --git a/packages/cli/tests/integration/fp30-update-fork.spec.ts b/packages/cli/tests/integration/fp30-update-fork.spec.ts new file mode 100644 index 0000000..6012518 --- /dev/null +++ b/packages/cli/tests/integration/fp30-update-fork.spec.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; +import { createMatrixPackage } from '../helpers/package-fixtures.js'; + +describe('FP-30 mx update-fork command', () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it('advances fork originVersion metadata when upstream has a newer release', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-update-fork-')); + tempDirs.push(root); + const workspaceDir = path.join(root, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + const packagesDir = path.join(root, '.matrix', 'packages'); + const registryDir = path.join(root, '.matrix', 'registry'); + const credentialsFile = path.join(root, '.matrix', 'credentials.json'); + + const upstreamV1 = createMatrixPackage(path.join(root, 'upstream-v1'), { + packageName: '@matrix/filesystem', + version: '1.0.0', + componentType: 'FolderProjection', + mount: 'fs', + }); + const upstreamV12 = createMatrixPackage(path.join(root, 'upstream-v12'), { + packageName: '@matrix/filesystem', + version: '1.2.0', + componentType: 'FolderProjection', + mount: 'fs', + }); + + const loginResult = await runMxCli([ + 'node', + 'mx', + 'login', + '--username', + 'alice', + '--token', + 'token-update-fork', + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(loginResult, 0); + + for (const pkg of [upstreamV1, upstreamV12]) { + const publishResult = await runMxCli([ + 'node', + 'mx', + 'publish', + '--registry-dir', + registryDir, + '--credentials-file', + credentialsFile, + '--json', + ], { cwd: pkg.packageDir }); + assertExitCode(publishResult, 0); + } + + const installOrigin = await runMxCli([ + 'node', + 'mx', + 'install', + '@matrix/filesystem@1.0.0', + '--packages-dir', + packagesDir, + '--registry-dir', + registryDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(installOrigin, 0); + + const forkResult = await runMxCli([ + 'node', + 'mx', + 'fork', + '@matrix/filesystem', + '--name', + '@alice/filesystem', + '--packages-dir', + packagesDir, + '--forked-by', + 'alice', + '--json', + ], { cwd: workspaceDir }); + assertExitCode(forkResult, 0); + + const updateResult = await runMxCli([ + 'node', + 'mx', + 'update-fork', + '@alice/filesystem', + '--packages-dir', + packagesDir, + '--registry-dir', + registryDir, + '--json', + ], { cwd: workspaceDir }); + assertExitCode(updateResult, 0); + const payload = parseLastJson(updateResult.logs) as { + updated: boolean; + previousOriginVersion: string; + nextOriginVersion: string; + }; + assert.equal(payload.updated, true); + assert.equal(payload.previousOriginVersion, '1.0.0'); + assert.equal(payload.nextOriginVersion, '1.2.0'); + }); +}); diff --git a/packages/cli/tests/support/installCoreStub.ts b/packages/cli/tests/support/installCoreStub.ts new file mode 100644 index 0000000..e73083e --- /dev/null +++ b/packages/cli/tests/support/installCoreStub.ts @@ -0,0 +1,77 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export function installOpenMatrixCoreStub(projectDir: string): void { + const pkgDir = path.join(projectDir, 'node_modules', '@open-matrix', 'core'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ + name: '@open-matrix/core', + type: 'module', + types: './index.d.ts', + exports: { + '.': './index.js', + }, + }, null, 2) + '\n', + 'utf8', + ); + fs.writeFileSync( + path.join(pkgDir, 'index.js'), + [ + 'export class MatrixActor {', + ' static accepts = {};', + ' static emits = {};', + '}', + '', + 'export class MatrixActorHtmlElement extends MatrixActor {}', + '', + 'export class InMemoryBroker {}', + '', + 'export class InMemoryTransport {', + ' constructor(_broker, options = {}) {', + ' this.root = options.root || options.name || null;', + ' }', + '}', + '', + 'export class MatrixRuntime {', + ' constructor(options = {}) {', + ' this.transport = options.transport || { root: null };', + ' }', + ' async createSupervised(ActorClass, mount) {', + ' const actor = new ActorClass();', + ' actor.mount = mount;', + ' return actor;', + ' }', + '}', + '', + ].join('\n'), + 'utf8', + ); + fs.writeFileSync( + path.join(pkgDir, 'index.d.ts'), + [ + 'export class MatrixActor {', + ' static accepts: Record;', + ' static emits: Record;', + '}', + '', + 'export class MatrixActorHtmlElement extends MatrixActor {}', + '', + 'export class InMemoryBroker {}', + '', + 'export class InMemoryTransport {', + ' readonly root?: string | null;', + ' constructor(broker: InMemoryBroker, options?: { name?: string; root?: string });', + '}', + '', + 'export class MatrixRuntime {', + ' readonly transport: { readonly root?: string | null };', + ' constructor(options?: { transport?: InMemoryTransport; logging?: boolean });', + ' createSupervised(ActorClass: new () => TActor, mount: string): Promise;', + '}', + '', + ].join('\n'), + 'utf8', + ); +} diff --git a/packages/cli/tests/unit/actor-run.spec.ts b/packages/cli/tests/unit/actor-run.spec.ts new file mode 100644 index 0000000..4a00ab3 --- /dev/null +++ b/packages/cli/tests/unit/actor-run.spec.ts @@ -0,0 +1,265 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + loadActorConstructor, + resolveActorRunBroker, + resolvePackageRunSpec, +} from '../../src/commands/actor-run.js'; + +describe('matrix actor run', () => { + it('loads a named actor export from an entry module', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-run-entry-')); + try { + const entry = path.join(dir, 'actors.mjs'); + fs.writeFileSync(entry, [ + 'export class NamedActor {}', + 'export class OtherActor {}', + '', + ].join('\n'), 'utf8'); + + const actor = await loadActorConstructor(entry, 'NamedActor'); + + assert.equal(actor.name, 'NamedActor'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('loads a default actor export from an entry module', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-run-default-entry-')); + try { + const entry = path.join(dir, 'actor.mjs'); + fs.writeFileSync(entry, [ + 'export default class DefaultActor {}', + '', + ].join('\n'), 'utf8'); + + const actor = await loadActorConstructor(entry, 'default'); + + assert.equal(actor.name, 'DefaultActor'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('reports available actor exports when the requested export is missing', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-run-missing-entry-')); + try { + const entry = path.join(dir, 'actor.mjs'); + fs.writeFileSync(entry, [ + 'export class PresentActor {}', + '', + ].join('\n'), 'utf8'); + + await assert.rejects( + () => loadActorConstructor(entry, 'MissingActor'), + /No actor class found as export "MissingActor".*PresentActor/u, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resolves broker URL, root, JWT, and seed from an explicit home without Host state', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-run-broker-')); + try { + fs.mkdirSync(path.join(home, 'credentials'), { recursive: true }); + fs.writeFileSync( + path.join(home, 'credentials', 'matrix-broker.json'), + `${JSON.stringify({ + natsUrl: 'nats://broker.example:4222', + root: 'acme-dev', + jwt: 'jwt-home', + seed: 'seed-home', + })}\n`, + 'utf8', + ); + + const resolved = resolveActorRunBroker({ home }); + assert.equal(resolved.natsUrl, 'nats://broker.example:4222'); + assert.equal(resolved.root, 'acme-dev'); + assert.equal(resolved.jwt, 'jwt-home'); + assert.equal(resolved.seed, 'seed-home'); + assert.ok(resolved.checked.includes(`${home}/credentials/matrix-broker.json`)); + assert.equal(fs.existsSync(path.join(home, 'host.json')), false); + assert.equal(fs.existsSync(path.join(home, 'runtimes')), false); + assert.equal(fs.existsSync(path.join(home, 'package-records')), false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('prefers explicit broker options over environment and home configuration', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-run-broker-precedence-')); + const previous = { + MATRIX_NATS_URL: process.env.MATRIX_NATS_URL, + NATS_URL: process.env.NATS_URL, + MATRIX_ROOT: process.env.MATRIX_ROOT, + MATRIX_SPACE: process.env.MATRIX_SPACE, + MATRIX_NATS_JWT: process.env.MATRIX_NATS_JWT, + MATRIX_NATS_SEED: process.env.MATRIX_NATS_SEED, + }; + try { + fs.mkdirSync(path.join(home, 'credentials'), { recursive: true }); + fs.writeFileSync( + path.join(home, 'credentials', 'matrix-broker.json'), + `${JSON.stringify({ + natsUrl: 'nats://home.example:4222', + root: 'home-root', + jwt: 'home-jwt', + seed: 'home-seed', + })}\n`, + 'utf8', + ); + process.env.MATRIX_NATS_URL = 'nats://env.example:4222'; + process.env.MATRIX_ROOT = 'env-root'; + process.env.MATRIX_NATS_JWT = 'env-jwt'; + process.env.MATRIX_NATS_SEED = 'env-seed'; + + const resolved = resolveActorRunBroker({ + home, + natsUrl: 'nats://explicit.example:4222', + root: 'explicit-root', + jwt: 'explicit-jwt', + seed: 'explicit-seed', + }); + + assert.equal(resolved.natsUrl, 'nats://explicit.example:4222'); + assert.equal(resolved.root, 'explicit-root'); + assert.equal(resolved.jwt, 'explicit-jwt'); + assert.equal(resolved.seed, 'explicit-seed'); + } finally { + restoreEnv(previous); + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('uses Space as a root alias for direct broker execution', () => { + const resolved = resolveActorRunBroker({ + natsUrl: 'nats://127.0.0.1:4222', + space: 'space-root', + }); + + assert.equal(resolved.natsUrl, 'nats://127.0.0.1:4222'); + assert.equal(resolved.root, 'space-root'); + assert.equal(resolved.jwt, undefined); + assert.equal(resolved.seed, undefined); + }); + + it('reports missing broker URL/root and half credentials before connecting', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-actor-run-broker-missing-')); + const previous = { + MATRIX_NATS_URL: process.env.MATRIX_NATS_URL, + NATS_URL: process.env.NATS_URL, + MATRIX_ROOT: process.env.MATRIX_ROOT, + MATRIX_SPACE: process.env.MATRIX_SPACE, + MATRIX_NATS_JWT: process.env.MATRIX_NATS_JWT, + MATRIX_NATS_SEED: process.env.MATRIX_NATS_SEED, + NATS_JWT: process.env.NATS_JWT, + NATS_SEED: process.env.NATS_SEED, + }; + try { + delete process.env.MATRIX_NATS_URL; + delete process.env.NATS_URL; + delete process.env.MATRIX_ROOT; + delete process.env.MATRIX_SPACE; + delete process.env.MATRIX_NATS_JWT; + delete process.env.MATRIX_NATS_SEED; + delete process.env.NATS_JWT; + delete process.env.NATS_SEED; + + assert.throws( + () => resolveActorRunBroker({ home }), + /MATRIX_NATS_URL_REQUIRED/u, + ); + assert.throws( + () => resolveActorRunBroker({ home, natsUrl: 'nats://127.0.0.1:4222' }), + /MATRIX_ROOT_REQUIRED/u, + ); + assert.throws( + () => resolveActorRunBroker({ home, natsUrl: 'nats://127.0.0.1:4222', root: 'demo', jwt: 'jwt-only' }), + /MATRIX_NATS_JWT_SEED_PAIR_REQUIRED/u, + ); + assert.throws( + () => resolveActorRunBroker({ home, natsUrl: 'nats://127.0.0.1:4222', root: 'demo', seed: 'seed-only' }), + /MATRIX_NATS_JWT_SEED_PAIR_REQUIRED/u, + ); + } finally { + restoreEnv(previous); + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('resolves direct package run metadata from matrix.json runtime.factory without Host state', () => { + const packageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-package-run-spec-')); + try { + fs.mkdirSync(path.join(packageDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(packageDir, 'package.json'), + `${JSON.stringify({ name: '@example/direct-run', version: '0.1.0', type: 'module' }, null, 2)}\n`, + 'utf8', + ); + fs.writeFileSync( + path.join(packageDir, 'matrix.json'), + `${JSON.stringify({ + name: '@example/direct-run', + version: '0.1.0', + runtime: { + language: 'javascript', + entry: 'src/index.mjs', + factory: { + kind: 'factory', + export: 'createDirectRunActor', + instance: { + id: 'RUNTIME-HOST-DIRECT-RUN', + class: 'service', + rootKind: 'component', + componentType: 'DirectRunActor', + mount: 'actor-runner-smoke', + autoStart: true, + }, + }, + }, + components: [{ + type: 'DirectRunActor', + mount: 'actor-runner-smoke', + accepts: ['ping', 'getStatus'], + }], + }, null, 2)}\n`, + 'utf8', + ); + fs.writeFileSync(path.join(packageDir, 'src', 'index.mjs'), 'export function createDirectRunActor() {}\n', 'utf8'); + + const spec = resolvePackageRunSpec(packageDir, { mount: 'actor-runner-smoke' }); + + assert.equal(spec.packageName, '@example/direct-run'); + assert.equal(spec.manifestPath, path.join(packageDir, 'matrix.json')); + assert.equal(spec.exportName, 'createDirectRunActor'); + assert.equal(spec.mount, 'actor-runner-smoke'); + assert.equal(spec.actorId, 'DirectRunActor'); + assert.equal(spec.packageId, '@example/direct-run'); + assert.equal(spec.accepts.ping && typeof spec.accepts.ping, 'object'); + assert.equal(spec.accepts.getStatus && typeof spec.accepts.getStatus, 'object'); + assert.equal(fs.existsSync(path.join(packageDir, 'host.json')), false); + assert.equal(fs.existsSync(path.join(packageDir, 'runtimes')), false); + assert.equal(fs.existsSync(path.join(packageDir, 'package-records')), false); + } finally { + fs.rmSync(packageDir, { recursive: true, force: true }); + } + }); + +}); + +function restoreEnv(previous: Record): void { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} diff --git a/packages/cli/tests/unit/cli-help.spec.ts b/packages/cli/tests/unit/cli-help.spec.ts new file mode 100644 index 0000000..8431f16 --- /dev/null +++ b/packages/cli/tests/unit/cli-help.spec.ts @@ -0,0 +1,98 @@ +/** + * Unit tests for Matrix SDK CLI basic functionality. + */ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { createProgram } from '../../src/index.js'; + +describe('Matrix SDK CLI foundation', () => { + it('creates program with correct name and version', () => { + const program = createProgram(); + assert.equal(program.name(), 'matrix'); + // Version is set via .version() method + const opts = program.opts(); + assert.ok(program.commands.length > 0, 'Should have commands registered'); + }); + + it('has init command', () => { + const program = createProgram(); + const initCmd = program.commands.find(cmd => cmd.name() === 'init'); + assert.ok(initCmd, 'Should have init command'); + assert.equal(initCmd.description(), 'Initialize a new Matrix package'); + }); + + it('has actor command', () => { + const program = createProgram(); + const actorCmd = program.commands.find(cmd => cmd.name() === 'actor'); + assert.ok(actorCmd, 'Should have actor command'); + assert.equal(actorCmd.description(), 'Scaffold a new actor package'); + assert.equal(actorCmd.options.some(option => option.long === '--dev'), false); + }); + + it('has install command', () => { + const program = createProgram(); + const installCmd = program.commands.find(cmd => cmd.name() === 'install'); + assert.ok(installCmd, 'Should have install command'); + }); + + it('has fork command', () => { + const program = createProgram(); + const forkCmd = program.commands.find(cmd => cmd.name() === 'fork'); + assert.ok(forkCmd, 'Should have fork command'); + }); + + it('has publish command', () => { + const program = createProgram(); + const publishCmd = program.commands.find(cmd => cmd.name() === 'publish'); + assert.ok(publishCmd, 'Should have publish command'); + }); + + it('has search command', () => { + const program = createProgram(); + const searchCmd = program.commands.find(cmd => cmd.name() === 'search'); + assert.ok(searchCmd, 'Should have search command'); + }); + + it('has config command', () => { + const program = createProgram(); + const configCmd = program.commands.find(cmd => cmd.name() === 'config'); + assert.ok(configCmd, 'Should have config command'); + const explainCmd = configCmd.commands.find(cmd => cmd.name() === 'explain'); + assert.equal(explainCmd, undefined); + }); + + it('has list command', () => { + const program = createProgram(); + const listCmd = program.commands.find(cmd => cmd.name() === 'list'); + assert.ok(listCmd, 'Should have list command'); + }); + + it('does not expose managed host commands', () => { + const program = createProgram(); + const serviceCmd = program.commands.find(cmd => cmd.name() === 'service'); + const runCmd = program.commands.find(cmd => cmd.name() === 'run'); + const upCmd = program.commands.find(cmd => cmd.name() === 'up'); + assert.equal(serviceCmd, undefined); + assert.equal(runCmd, undefined); + assert.equal(upCmd, undefined); + }); + + it('has package run command', () => { + const program = createProgram(); + const packageCmd = program.commands.find(cmd => cmd.name() === 'package'); + assert.ok(packageCmd, 'Should have package command'); + const packageRunCmd = packageCmd.commands.find(cmd => cmd.name() === 'run'); + assert.ok(packageRunCmd, 'Should have package run command'); + assert.equal(packageRunCmd.description(), 'Run one package directly on the selected Space bus without managed host inventory'); + }); + + it('does not expose provider machine-link credential commands', () => { + const program = createProgram(); + const refreshCmd = program.commands.find(cmd => cmd.name() === 'refresh-credentials'); + const revokeCmd = program.commands.find(cmd => cmd.name() === 'revoke-device'); + const linkStatusCmd = program.commands.find(cmd => cmd.name() === 'link-status'); + assert.equal(refreshCmd, undefined); + assert.equal(revokeCmd, undefined); + assert.equal(linkStatusCmd, undefined); + }); +}); diff --git a/packages/cli/tests/unit/config-command.spec.ts b/packages/cli/tests/unit/config-command.spec.ts new file mode 100644 index 0000000..606d517 --- /dev/null +++ b/packages/cli/tests/unit/config-command.spec.ts @@ -0,0 +1,47 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + configGetCommand, + configListCommand, + configResetCommand, + configSetCommand, +} from '../../src/commands/config.js'; + +describe('matrix config command', () => { + it('sets, reads, lists, and resets the package registry in a local Matrix config', async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'matrix-sdk-config-')); + try { + const configPath = path.join(cwd, '.matrix', 'config.json'); + + const set = await configSetCommand('registry', 'https://registry.example.test/npm', { cwd }); + assert.equal(set.configPath, configPath); + assert.equal(set.value, 'https://registry.example.test/npm/'); + + const get = await configGetCommand('registry', { cwd }); + assert.equal(get.value, 'https://registry.example.test/npm/'); + assert.equal(get.source, 'config'); + + const list = await configListCommand({ cwd }); + assert.equal(list.values.registry, 'https://registry.example.test/npm/'); + assert.equal(list.effective.registry, 'https://registry.example.test/npm/'); + assert.equal(list.effective.registrySource, 'config'); + + const reset = await configResetCommand({ cwd }); + assert.equal(reset.configPath, configPath); + assert.equal(fs.existsSync(configPath), false); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); + + it('rejects unknown config keys', async () => { + await assert.rejects( + () => configSetCommand('hostHome', '/var/lib/provider-local'), + /MX_CONFIG_INVALID/u, + ); + }); +}); diff --git a/packages/cli/tests/unit/init-command.spec.ts b/packages/cli/tests/unit/init-command.spec.ts new file mode 100644 index 0000000..a2535c2 --- /dev/null +++ b/packages/cli/tests/unit/init-command.spec.ts @@ -0,0 +1,45 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { initCommand } from '../../src/commands/init.js'; + +describe('mx init command', () => { + it('creates matrix.json and scaffold directories', async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-init-')); + try { + const result = await initCommand({ + cwd, + name: 'demo-package', + version: '1.2.3', + description: 'demo', + yes: true, + }); + + assert.equal(result.rootDir, cwd); + assert.equal(fs.existsSync(path.join(cwd, 'matrix.json')), true); + assert.equal(fs.existsSync(path.join(cwd, 'src')), true); + assert.equal(fs.existsSync(path.join(cwd, 'examples')), true); + assert.equal(fs.existsSync(path.join(cwd, 'skills')), true); + assert.equal(fs.existsSync(path.join(cwd, 'tests')), true); + + const manifest = JSON.parse(fs.readFileSync(path.join(cwd, 'matrix.json'), 'utf8')) as { + name: string; + version: string; + description: string; + runtime: { language: string; entry: string }; + components: unknown[]; + permissions: { fsPolicy: string }; + }; + assert.equal(manifest.name, 'demo-package'); + assert.equal(manifest.version, '1.2.3'); + assert.equal(manifest.description, 'demo'); + assert.equal(manifest.runtime.entry, 'dist/index.js'); + assert.equal(Array.isArray(manifest.components), true); + assert.equal(manifest.permissions.fsPolicy, 'none'); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/tests/unit/lifecycle-scaffold.spec.ts b/packages/cli/tests/unit/lifecycle-scaffold.spec.ts new file mode 100644 index 0000000..92f7d5b --- /dev/null +++ b/packages/cli/tests/unit/lifecycle-scaffold.spec.ts @@ -0,0 +1,284 @@ +/** + * Developer Lifecycle — Scaffold & Validate Tests + * + * Tests the scaffold → validate → install pipeline WITHOUT requiring + * the daemon runtime. These tests verify file generation, content + * correctness, and manifest validation. + * + * For full daemon integration (discover → serve), see: + * tests/integration/daemon/e2e-developer-lifecycle.spec.ts + */ +import { afterEach, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { actorCommand } from '../../src/commands/actor.js'; +import { webappCommand } from '../../src/commands/webapp.js'; +import { validateCommand } from '../../src/commands/validate.js'; +import { dependencyInstallNpmrcLines, installCommand } from '../../src/commands/install.js'; + +describe('developer lifecycle: scaffold + validate + install', () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + // --------------------------------------------------------------------------- + // Actor scaffold + // --------------------------------------------------------------------------- + describe('actor scaffold', () => { + it('routes all Matrix runtime dependency scopes to the configured registry', () => { + const lines = dependencyInstallNpmrcLines({ + registry: 'http://127.0.0.1:3000/api/packages/open-matrix/npm/', + token: 'token-runtime-install', + }); + assert.ok(lines.includes('@open-matrix:registry=http://127.0.0.1:3000/api/packages/open-matrix/npm/')); + assert.ok(lines.includes('@matrix:registry=http://127.0.0.1:3000/api/packages/open-matrix/npm/')); + assert.ok(lines.includes('@omega:registry=http://127.0.0.1:3000/api/packages/open-matrix/npm/')); + assert.ok(lines.includes('always-auth=true')); + assert.ok(lines.includes('//127.0.0.1:3000/api/packages/open-matrix/npm/:_authToken=token-runtime-install')); + }); + + it('generates all expected files with correct content', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-actor-')); + tempDirs.push(tempRoot); + + const actorDir = path.join(tempRoot, 'my-counter'); + const result = await actorCommand('my-counter', { outputDir: actorDir }); + + // Return value shape + assert.equal(result.rootDir, actorDir); + assert.ok(result.className, 'className should be set'); + assert.equal(result.mount, 'my-counter'); + + // All expected files + const expectedFiles = [ + 'matrix.json', + 'package.json', + 'tsconfig.json', + path.join('src', `${result.className}.ts`), + path.join('src', `create-standalone-${result.className}.ts`), + path.join('src', 'index.ts'), + path.join('examples', 'get-status.mxq'), + path.join('skills', 'my-counter.skill.md'), + path.join('tests', 'my-counter.spec.ts'), + ]; + for (const f of expectedFiles) { + assert.ok(fs.existsSync(path.join(actorDir, f)), `Missing: ${f}`); + } + + // matrix.json + const manifest = JSON.parse(fs.readFileSync(path.join(actorDir, 'matrix.json'), 'utf8')); + assert.equal(manifest.name, '@matrix/my-counter'); + assert.ok(Array.isArray(manifest.components)); + assert.equal(manifest.components[0]?.mount, 'my-counter'); + assert.equal(manifest.components[0]?.type, result.className); + assert.equal(manifest.runtime?.factory?.export, `createStandalone${result.className}`); + assert.equal(manifest.runtime?.factory?.instance?.componentType, result.className); + assert.equal(manifest.runtime?.factory?.instance?.autoStart, true); + + // package.json + const pkg = JSON.parse(fs.readFileSync(path.join(actorDir, 'package.json'), 'utf8')); + assert.equal(pkg.type, 'module'); + assert.equal(pkg.name, '@matrix/my-counter'); + assert.equal(pkg.scripts?.build, 'tsc -p tsconfig.json'); + assert.equal(pkg.scripts?.['build:watch'], 'tsc -w -p tsconfig.json --preserveWatchOutput'); + assert.equal(pkg.scripts?.dev, 'tsc -w -p tsconfig.json --preserveWatchOutput'); + assert.equal(pkg.scripts?.test, 'node --import tsx --test tests/**/*.spec.ts'); + assert.ok(pkg.dependencies?.['@open-matrix/core']); + assert.equal(pkg.devDependencies?.['@open-matrix/core'], undefined); + + // tsconfig.json + const tsconfig = JSON.parse(fs.readFileSync(path.join(actorDir, 'tsconfig.json'), 'utf8')); + assert.equal(tsconfig.compilerOptions.target, 'ES2022'); + assert.equal(tsconfig.compilerOptions.module, 'NodeNext'); + assert.equal(tsconfig.compilerOptions.declaration, true); + assert.equal(tsconfig.compilerOptions.incremental, true); + assert.equal(tsconfig.compilerOptions.tsBuildInfoFile, '.tsbuildinfo'); + assert.equal(tsconfig.compilerOptions.sourceMap, true); + assert.deepEqual(tsconfig.include, ['src']); + + // Actor source imports @open-matrix/core (not @open-matrix/core) + const actorSrc = fs.readFileSync(path.join(actorDir, 'src', `${result.className}.ts`), 'utf8'); + assert.ok(actorSrc.includes('@open-matrix/core'), 'should import from @open-matrix/core'); + assert.ok(!actorSrc.includes("'@open-matrix/core'"), 'should NOT import from @open-matrix/core'); + }); + + it('validates successfully after scaffolding', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-actor-val-')); + tempDirs.push(tempRoot); + + const actorDir = path.join(tempRoot, 'test-actor'); + await actorCommand('test-actor', { outputDir: actorDir }); + + const validation = await validateCommand(actorDir); + assert.equal(validation.valid, true, `Diagnostics: ${JSON.stringify(validation.diagnostics)}`); + assert.equal(validation.packageName, '@matrix/test-actor'); + }); + + it('installs into a packages directory', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-actor-inst-')); + tempDirs.push(tempRoot); + + const actorDir = path.join(tempRoot, 'my-service'); + await actorCommand('my-service', { outputDir: actorDir }); + + const packagesRoot = path.join(tempRoot, '.matrix', 'packages'); + fs.mkdirSync(path.join(packagesRoot, 'node_modules'), { recursive: true }); + + await installCommand(actorDir, { + cwd: tempRoot, + packagesDir: packagesRoot, + force: true, + }); + + // Verify installed + const installedManifest = path.join(packagesRoot, 'node_modules', '@matrix', 'my-service', 'matrix.json'); + assert.ok(fs.existsSync(installedManifest), 'Installed package should have matrix.json'); + + const manifest = JSON.parse(fs.readFileSync(installedManifest, 'utf8')); + assert.equal(manifest.name, '@matrix/my-service'); + }); + }); + + // --------------------------------------------------------------------------- + // Webapp scaffold + // --------------------------------------------------------------------------- + describe('webapp scaffold', () => { + it('generates all expected files with correct content', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-webapp-')); + tempDirs.push(tempRoot); + + const appDir = path.join(tempRoot, 'my-dashboard'); + const result = await webappCommand('my-dashboard', { outputDir: appDir }); + + // Return value shape + assert.equal(result.rootDir, appDir); + assert.ok(result.appClassName); + assert.ok(result.shellClassName); + assert.ok(result.tagName); + assert.equal(result.appName, 'my-dashboard'); + + // All expected webapp files + const expectedFiles = [ + 'matrix.json', + 'package.json', + 'tsconfig.json', + 'vite.config.ts', + 'index.html', + path.join('src', 'index.ts'), + path.join('src', 'init.ts'), + path.join('src', `${result.appClassName}.ts`), + path.join('src', 'shell', `${result.shellClassName}.ts`), + path.join('src', 'template', 'app.template.ts'), + path.join('src', 'styles', 'app.styles.ts'), + path.join('tests', 'my-dashboard.spec.ts'), + path.join('examples', 'introspect.mxq'), + ]; + for (const f of expectedFiles) { + assert.ok(fs.existsSync(path.join(appDir, f)), `Missing webapp file: ${f}`); + } + + // matrix.json — webapp section (scoped name: @open-matrix/) + const manifest = JSON.parse(fs.readFileSync(path.join(appDir, 'matrix.json'), 'utf8')); + assert.equal(manifest.name, '@open-matrix/my-dashboard'); + assert.ok(manifest.webapp, 'should have webapp section'); + assert.ok(manifest.webapp.distDir); + assert.equal(manifest.webapp.appName, 'my-dashboard'); + + // package.json — webapp deps + const pkg = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')); + assert.equal(pkg.type, 'module'); + assert.ok(pkg.scripts?.dev, 'should have vite dev script'); + assert.ok(pkg.scripts?.build, 'should have vite build script'); + assert.ok(pkg.devDependencies?.vite); + assert.ok(pkg.devDependencies?.['@open-matrix/core']); + assert.ok(pkg.devDependencies?.['@open-matrix/federation']); + + // tsconfig.json — DOM libs, noEmit + const tsconfig = JSON.parse(fs.readFileSync(path.join(appDir, 'tsconfig.json'), 'utf8')); + assert.ok(tsconfig.compilerOptions.lib?.includes('DOM')); + assert.equal(tsconfig.compilerOptions.noEmit, true); + assert.equal(tsconfig.compilerOptions.module, 'ESNext'); + assert.equal(tsconfig.compilerOptions.moduleResolution, 'Bundler'); + + // vite.config.ts — base path + const viteConfig = fs.readFileSync(path.join(appDir, 'vite.config.ts'), 'utf8'); + assert.ok(viteConfig.includes('/apps/my-dashboard/'), 'vite base should point to /apps/my-dashboard/'); + + // index.html — custom element tag + const indexHtml = fs.readFileSync(path.join(appDir, 'index.html'), 'utf8'); + assert.ok(indexHtml.includes(result.tagName), `HTML should contain custom element <${result.tagName}>`); + + // Shell imports federation + const shellSrc = fs.readFileSync( + path.join(appDir, 'src', 'shell', `${result.shellClassName}.ts`), + 'utf8', + ); + assert.ok(shellSrc.includes('@open-matrix/core'), 'shell should import from @open-matrix/core'); + }); + + it('validates successfully after scaffolding', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-webapp-val-')); + tempDirs.push(tempRoot); + + const appDir = path.join(tempRoot, 'test-app'); + await webappCommand('test-app', { outputDir: appDir }); + + const validation = await validateCommand(appDir); + assert.equal(validation.valid, true, `Diagnostics: ${JSON.stringify(validation.diagnostics)}`); + }); + + it('installs into a packages directory', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-webapp-inst-')); + tempDirs.push(tempRoot); + + const appDir = path.join(tempRoot, 'my-app'); + await webappCommand('my-app', { outputDir: appDir }); + + const packagesRoot = path.join(tempRoot, '.matrix', 'packages'); + fs.mkdirSync(path.join(packagesRoot, 'node_modules'), { recursive: true }); + + await installCommand(appDir, { + cwd: tempRoot, + packagesDir: packagesRoot, + force: true, + }); + + // Scoped package: @open-matrix/my-app → node_modules/@open-matrix/my-app/ + const installedManifest = path.join(packagesRoot, 'node_modules', '@open-matrix', 'my-app', 'matrix.json'); + assert.ok(fs.existsSync(installedManifest), 'Installed webapp should have matrix.json'); + + const manifest = JSON.parse(fs.readFileSync(installedManifest, 'utf8')); + assert.equal(manifest.name, '@open-matrix/my-app'); + assert.ok(manifest.webapp, 'Installed manifest should preserve webapp section'); + }); + }); + + // --------------------------------------------------------------------------- + // Validation + // --------------------------------------------------------------------------- + describe('manifest validation', () => { + it('rejects manifest with missing required fields', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-bad-')); + tempDirs.push(tempRoot); + + const badDir = path.join(tempRoot, 'bad-package'); + fs.mkdirSync(badDir, { recursive: true }); + fs.writeFileSync( + path.join(badDir, 'matrix.json'), + JSON.stringify({ name: 'bad-package' }, null, 2), + ); + + const validation = await validateCommand(badDir); + assert.ok(validation.diagnostics.length > 0, 'Should have diagnostics for bad manifest'); + }); + }); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index a42aa21..e3265b7 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,20 +1,19 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "strict": true, "module": "NodeNext", "moduleResolution": "NodeNext", - "types": ["node"], - "noEmit": false, - "tsBuildInfoFile": "dist/.tsbuildinfo" + "outDir": "./dist", + "rootDir": ".", + "baseUrl": "../..", + "resolveJsonModule": true, + "tsBuildInfoFile": ".tsbuildinfo" }, "include": [ - "src/**/*.ts" + "src/**/*" ], "exclude": [ - "dist", - "node_modules" + "node_modules", + "dist" ] } diff --git a/packages/cli/tsconfig.mx-cli-original.json b/packages/cli/tsconfig.mx-cli-original.json new file mode 100644 index 0000000..cc12b36 --- /dev/null +++ b/packages/cli/tsconfig.mx-cli-original.json @@ -0,0 +1,54 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": [ + "ES2022" + ], + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "../..", + "baseUrl": "../..", + "paths": { + "@open-matrix/host-control": [ + "packages/host-control/src/index.ts" + ], + "@open-matrix/nats-auth": [ + "packages/nats-auth/src/index.ts" + ], + "@open-matrix/system-bindings": [ + "packages/system-bindings/src/index.ts" + ], + "@open-matrix/system-gateway-http": [ + "packages/system-gateway-http/src/index.ts" + ], + "@open-matrix/system-auth": [ + "packages/system-auth/src/index.ts" + ], + "@open-matrix/system-platform": [ + "packages/system-platform/src/index.ts" + ], + "@open-matrix/system-runtimes": [ + "packages/system-runtimes/src/index.ts" + ] + }, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "incremental": true, + "tsBuildInfoFile": ".tsbuildinfo" + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "tests" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70b8a0d..1a7f3ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,13 +83,34 @@ importers: packages/cli: dependencies: + '@open-matrix/contracts': + specifier: workspace:* + version: link:../contracts '@open-matrix/core': specifier: workspace:* version: link:../core + '@open-matrix/federation': + specifier: workspace:* + version: link:../federation + commander: + specifier: ^11.0.0 + version: 11.1.0 + nats: + specifier: ^2.29.3 + version: 2.29.3 devDependencies: '@types/node': specifier: ^25.0.10 version: 25.9.2 + esbuild: + specifier: ^0.27.3 + version: 0.27.7 + glob: + specifier: ^13.0.6 + version: 13.0.6 + tsx: + specifier: ^4.15.6 + version: 4.22.4 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -807,6 +828,10 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -855,6 +880,11 @@ packages: resolution: {integrity: sha512-aM77V2SEc+B6lbxCMZK3qfRy4jg8pmHj+wZzQKDiDIQYhLPj6U2NSHHBex0syj72Ayzl4uR5Lp3aKXTaVLbRpw==} deprecated: 'Package deprecated. Use @nats-io/nats-core or nats.js instead: https://github.com/nats-io/nats.js' + nats@2.29.3: + resolution: {integrity: sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==} + engines: {node: '>= 14.0.0'} + deprecated: Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js + nkeys.js@1.1.0: resolution: {integrity: sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==} engines: {node: '>=10.0.0'} @@ -1289,6 +1319,8 @@ snapshots: dependencies: balanced-match: 4.0.4 + commander@11.1.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1398,10 +1430,13 @@ snapshots: optionalDependencies: nkeys.js: 1.1.0 + nats@2.29.3: + dependencies: + nkeys.js: 1.1.0 + nkeys.js@1.1.0: dependencies: tweetnacl: 1.0.3 - optional: true path-scurry@2.0.2: dependencies: