feat(cli): replace prototype with SDK runner
This commit is contained in:
parent
116b30454a
commit
8a9b788f76
64
README.md
64
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 <name>
|
||||
matrix actor <name>
|
||||
matrix configure --key <key> --cloud <url> --space <space>
|
||||
matrix run <entry> --mount <mount>
|
||||
matrix invoke <mount> <op> [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=<your-space-root>
|
||||
export MATRIX_API_KEY=<your-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
|
||||
|
||||
23
archive/cli-http-prototype-2026-06-08/README.md
Normal file
23
archive/cli-http-prototype-2026-06-08/README.md
Normal file
@ -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 <name>
|
||||
matrix actor <name>
|
||||
matrix configure --key <key> --cloud <url> --space <space>
|
||||
matrix run <entry> --mount <mount>
|
||||
matrix invoke <mount> <op> [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.
|
||||
40
archive/cli-http-prototype-2026-06-08/package.json
Normal file
40
archive/cli-http-prototype-2026-06-08/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
20
archive/cli-http-prototype-2026-06-08/tsconfig.json
Normal file
20
archive/cli-http-prototype-2026-06-08/tsconfig.json
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||
2598
archive/mx-cli-product-command-tests/fp30-auth-session.spec.ts
Normal file
2598
archive/mx-cli-product-command-tests/fp30-auth-session.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
178
archive/mx-cli-product-command-tests/hivecast-link-store.spec.ts
Normal file
178
archive/mx-cli-product-command-tests/hivecast-link-store.spec.ts
Normal file
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
1859
archive/mx-cli-product-commands/auth.hivecast-device.ts
Normal file
1859
archive/mx-cli-product-commands/auth.hivecast-device.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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/);
|
||||
});
|
||||
});
|
||||
@ -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<IConfigGetResult> {
|
||||
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<IConfigListResult> {
|
||||
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<IConfigSetResult> {
|
||||
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<IConfigResetResult> {
|
||||
return resetMatrixCliConfig(options);
|
||||
}
|
||||
|
||||
export async function configExplainCommand(
|
||||
targetPath: string,
|
||||
options: IConfigCommandOptions = {},
|
||||
): Promise<IConfigExplainResult> {
|
||||
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<IConfigExplainResult, 'path' | 'relativePath' | 'exists'> {
|
||||
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<IConfigExplainResult, 'path' | 'relativePath' | 'exists'>,
|
||||
): Omit<IConfigExplainResult, 'path' | 'relativePath' | 'exists'> {
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return value.replace(/\\/g, '/');
|
||||
}
|
||||
43
archive/mx-cli-product-docs/README.mx-cli-original.md
Normal file
43
archive/mx-cli-product-docs/README.mx-cli-original.md
Normal file
@ -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 <name> # Scaffold a new actor package (B014)
|
||||
mx install <package> # Install a package (future)
|
||||
mx fork <package> # 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)
|
||||
@ -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('<home>/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<string, unknown> }> = [];
|
||||
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<Response> => {
|
||||
requests.push({ url: url.toString(), init: init as Record<string, unknown> });
|
||||
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<string, string>).authorization, 'Bearer hc_actor_key');
|
||||
const body = JSON.parse(String(requests[0]?.init.body)) as Record<string, unknown>;
|
||||
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<string, string | undefined>): void {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<string, unknown>;
|
||||
readonly emits?: Record<string, unknown>;
|
||||
readonly subscribes?: Record<string, unknown>;
|
||||
readonly name: string;
|
||||
};
|
||||
|
||||
type PackageBootstrapFunction = (...args: readonly unknown[]) => Promise<unknown> | 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<string, unknown>;
|
||||
readonly emits: Record<string, unknown>;
|
||||
readonly subscribes: Record<string, unknown>;
|
||||
readonly overrides: Record<string, unknown>;
|
||||
readonly serviceInstance: ILoadedMatrixServiceManifest['serviceInstance'];
|
||||
}
|
||||
|
||||
export async function actorRunCommand(
|
||||
entry: string,
|
||||
options: IActorRunCommandOptions,
|
||||
): Promise<void> {
|
||||
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 <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<void> {
|
||||
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',
|
||||
'<home>/credentials/matrix-api-key.json',
|
||||
'<home>/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<string, unknown>;
|
||||
readonly emits: Record<string, unknown>;
|
||||
readonly subscribes: Record<string, unknown>;
|
||||
readonly ttlSeconds?: number;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
}): Promise<IActorCredentialExchange> {
|
||||
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 <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<NatsConnection> {
|
||||
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<ActorConstructor> {
|
||||
const moduleUrl = `${pathToFileURL(entryPath).href}?matrix_actor_run=${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const mod = await import(moduleUrl) as Record<string, unknown>;
|
||||
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<PackageBootstrapFunction> {
|
||||
const moduleUrl = `${pathToFileURL(entryPath).href}?matrix_package_run=${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const mod = await import(moduleUrl) as Record<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
function parseOverrides(value: string | undefined): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<Record<string, unknown>, unknown>(
|
||||
runtime.getRootContext(),
|
||||
mount,
|
||||
op,
|
||||
payload,
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
return { op, result };
|
||||
}
|
||||
|
||||
function parseCheckPayload(value: string | undefined): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
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<void> {
|
||||
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<void>): Promise<void> {
|
||||
return new Promise<void>((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<string, unknown>
|
||||
: {};
|
||||
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<string, unknown> | 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<string, unknown> : 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(/\/$/, '');
|
||||
}
|
||||
1052
archive/mx-cli-runtime-host-commands/run.ts
Normal file
1052
archive/mx-cli-runtime-host-commands/run.ts
Normal file
File diff suppressed because it is too large
Load Diff
1418
archive/mx-cli-runtime-host-commands/runner-control-plane.ts
Normal file
1418
archive/mx-cli-runtime-host-commands/runner-control-plane.ts
Normal file
File diff suppressed because it is too large
Load Diff
305
archive/mx-cli-runtime-host-commands/runner-placement-policy.ts
Normal file
305
archive/mx-cli-runtime-host-commands/runner-placement-policy.ts
Normal file
@ -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 <T extends MatrixActor>(
|
||||
ComponentClass: new () => T,
|
||||
mount: string,
|
||||
props?: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
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<readonly RunnerPlacementStandbyResult[]>(
|
||||
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<unknown>('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<string, unknown> | undefined,
|
||||
): AuthorityCoordinatorLease | null | undefined {
|
||||
const serviceLease = readAuthorityLeaseRecord(runtime.getRootContext().getService<unknown>('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<string, PlacementDeclaration> {
|
||||
const declarations = new Map<string, PlacementDeclaration>();
|
||||
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<string, PlacementDeclaration>, 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<string, unknown> | undefined {
|
||||
return isRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readAuthorityRoot(topology: Record<string, unknown> | undefined): string | undefined {
|
||||
const authority = isRecord(topology?.authority) ? topology.authority : undefined;
|
||||
return readString(authority?.authorityRoot);
|
||||
}
|
||||
|
||||
function readAuthorityCoordinatorActive(topology: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
286
archive/mx-cli-runtime-host-commands/runner-runtime-control.ts
Normal file
286
archive/mx-cli-runtime-host-commands/runner-runtime-control.ts
Normal file
@ -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<MatrixActor['on$introspect']>;
|
||||
type MatrixIntrospectChild = NonNullable<MatrixIntrospect['children']>[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.<runtimeId>.packages.<packageKey>`
|
||||
* 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<string, unknown> = {
|
||||
'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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<IRunnerRuntimeControlService>(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<IMountRunnerRuntimeControlResult> {
|
||||
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.<runtimeId>.
|
||||
// Slice 1b (control-actor-nav): the legacy `.control` sibling mount has been
|
||||
// removed. The canonical runtime control mount is `system.runtimes.<runtimeId>`.
|
||||
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.<runtimeId>`. The legacy `.control` sibling is gone.
|
||||
return defaultRunnerRuntimeMount(runtimeId);
|
||||
}
|
||||
296
archive/mx-cli-runtime-host-commands/runner-runtime.ts
Normal file
296
archive/mx-cli-runtime-host-commands/runner-runtime.ts
Normal file
@ -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<IRunnerRuntimeClosed>;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
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<IRunnerRuntimeHandle> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await runtime.shutdown();
|
||||
await transport.disconnect();
|
||||
if (!connection.isClosed()) {
|
||||
await connection.close();
|
||||
}
|
||||
await closed;
|
||||
await stopChildProcess(child, runnerTransport.pidFile);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function watchNatsConnectionClosed(connection: NatsConnection): Promise<IRunnerRuntimeClosed> {
|
||||
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<ChildProcessWithoutNullStreams> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
if (child.exitCode == null) {
|
||||
await new Promise<void>((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');
|
||||
}
|
||||
685
archive/mx-cli-runtime-host-commands/service.ts
Normal file
685
archive/mx-cli-runtime-host-commands/service.ts
Normal file
@ -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<string, unknown>;
|
||||
readonly root?: string;
|
||||
readonly mount?: string;
|
||||
readonly configRef?: string;
|
||||
readonly secretsRef?: string;
|
||||
readonly instance: Record<string, unknown>;
|
||||
readonly serviceInstance: Record<string, unknown>;
|
||||
readonly deploymentInstance: Record<string, unknown>;
|
||||
readonly runtimeRegistration?: IRunnerRuntimeRegistration;
|
||||
readonly bindingRegistration?: IRunnerBindingRegistration;
|
||||
readonly runtimeDeregistration?: IRunnerRuntimeDeregistration;
|
||||
readonly bindingDeregistration?: IRunnerBindingDeregistration;
|
||||
readonly result?: unknown;
|
||||
}
|
||||
|
||||
export interface IStartedServiceCommand {
|
||||
readonly result: IServiceCommandResult;
|
||||
readonly shutdownRequested: Promise<string>;
|
||||
readonly transportClosed?: Promise<IRunnerRuntimeClosed>;
|
||||
shutdown(): Promise<IServiceCommandResult>;
|
||||
}
|
||||
|
||||
type BootstrapResult = {
|
||||
runtime?: {
|
||||
shutdown?: () => Promise<void> | void;
|
||||
};
|
||||
} & Record<string, unknown>;
|
||||
|
||||
interface IStandaloneRunnerServices {
|
||||
readonly runtime: MatrixRuntime;
|
||||
readonly environment?: IMatrixPackageEnvironment;
|
||||
readonly runnerTransport?: IResolvedRunnerTransportPlan;
|
||||
}
|
||||
|
||||
export async function serviceCommand(
|
||||
packageDir: string,
|
||||
options: IServiceCommandOptions = {},
|
||||
): Promise<IServiceCommandResult> {
|
||||
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<IStartedServiceCommand> {
|
||||
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<string>((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<string, unknown>;
|
||||
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> | 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<string, unknown>;
|
||||
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<IServiceCommandResult> | null = null;
|
||||
|
||||
return {
|
||||
result: buildResult(),
|
||||
...(runnerRuntimeHandle.closed ? { transportClosed: runnerRuntimeHandle.closed } : {}),
|
||||
async shutdown(): Promise<IServiceCommandResult> {
|
||||
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<string> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>,
|
||||
patch: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
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<object>()): 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<string, unknown> = {};
|
||||
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<void>,
|
||||
shutdownRequested?: Promise<string>,
|
||||
): Promise<void> {
|
||||
await new Promise<void>((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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
522
archive/mx-cli-runtime-host-commands/standalone-webapp-server.ts
Normal file
522
archive/mx-cli-runtime-host-commands/standalone-webapp-server.ts
Normal file
@ -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<void>;
|
||||
}
|
||||
|
||||
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<IStandaloneWebappServerHandle> {
|
||||
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<void>((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<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse<http.IncomingMessage>,
|
||||
options: {
|
||||
readonly entryPath: string;
|
||||
readonly brandingDir: string | null;
|
||||
readonly runtimeRoot: string;
|
||||
readonly webapp: ILoadedMatrixWebappManifest;
|
||||
readonly loadedEnvironment: ILoadedMatrixPackageEnvironment;
|
||||
readonly runnerTransport: IResolvedRunnerTransportPlan;
|
||||
},
|
||||
): Promise<void> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<http.IncomingMessage>,
|
||||
method: string,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
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<void>((resolve, reject) => {
|
||||
stream.once('error', reject);
|
||||
res.once('error', reject);
|
||||
res.once('finish', resolve);
|
||||
stream.pipe(res);
|
||||
});
|
||||
}
|
||||
|
||||
function writeBody(
|
||||
res: http.ServerResponse<http.IncomingMessage>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
185
archive/mx-cli-runtime-host-commands/workspace-config.ts
Normal file
185
archive/mx-cli-runtime-host-commands/workspace-config.ts
Normal file
@ -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<string, MatrixWorkspaceServiceDeclaration>;
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
workspaceConfigPath: string,
|
||||
): Record<string, MatrixWorkspaceServiceDeclaration> {
|
||||
const normalized: Record<string, MatrixWorkspaceServiceDeclaration> = {};
|
||||
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<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
408
archive/mx-cli-runtime-host-tests/run-command.spec.ts
Normal file
408
archive/mx-cli-runtime-host-tests/run-command.spec.ts
Normal file
@ -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<number[]> {
|
||||
const servers = await Promise.all(
|
||||
Array.from({ length: count }, () => new Promise<net.Server>((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<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
})));
|
||||
return ports;
|
||||
}
|
||||
|
||||
async function createRunInPlaceFixture(rootDir: string): Promise<string> {
|
||||
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'),
|
||||
'<!DOCTYPE html><html><head><title>Sample Serve</title><link rel="manifest" href="/manifest.json"></head><body><div id="app">Sample Serve Fixture</div></body></html>\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'),
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#111827"/><path d="M4 11h8v2H4zM5 3h6v2H5zm1 4h4v2H6z" fill="#f8fafc"/></svg>\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return { packageDir, httpPort };
|
||||
}
|
||||
|
||||
async function waitForHttpReady(
|
||||
url: string,
|
||||
child: ReturnType<typeof spawn>,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<void> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<void>((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<void>((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:/);
|
||||
});
|
||||
});
|
||||
920
archive/mx-cli-runtime-host-tests/runner-control-plane.spec.ts
Normal file
920
archive/mx-cli-runtime-host-tests/runner-control-plane.spec.ts
Normal file
@ -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<string, unknown>;
|
||||
}> = [];
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
46
archive/mx-cli-runtime-host-tests/runner-runtime.spec.ts
Normal file
46
archive/mx-cli-runtime-host-tests/runner-runtime.spec.ts
Normal file
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
97
archive/mx-cli-runtime-host-tests/runner-transport.spec.ts
Normal file
97
archive/mx-cli-runtime-host-tests/runner-transport.spec.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
1032
archive/mx-cli-runtime-host-tests/service-command.spec.ts
Normal file
1032
archive/mx-cli-runtime-host-tests/service-command.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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<number> {
|
||||
const server = await new Promise<net.Server>((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<void>((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'),
|
||||
'<!doctype html><html><body>fixture</body></html>',
|
||||
'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'),
|
||||
'<!doctype html><html><body>fixture</body></html>',
|
||||
'utf8',
|
||||
);
|
||||
const httpPort = await getAvailablePort();
|
||||
const upstreamPort = await getAvailablePort();
|
||||
let capturedRequest = '';
|
||||
const upstream = net.createServer();
|
||||
const captured = new Promise<void>((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<void>((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<void>((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<void>((resolve) => {
|
||||
upstream.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
992
archive/mx-cli-unused-after-sdk-split/hivecast-link-store.ts
Normal file
992
archive/mx-cli-unused-after-sdk-split/hivecast-link-store.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
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<string> {
|
||||
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<IHiveCastLinkPaths, 'natsCredentialsPath' | 'natsLeafnodeCredentialsPath' | 'hostCredentialsPath' | 'hostLinkTokenPath'>,
|
||||
): 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;
|
||||
}
|
||||
139
archive/mx-cli-unused-after-sdk-split/package-environment.ts
Normal file
139
archive/mx-cli-unused-after-sdk-split/package-environment.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
readonly authorityLease?: Record<string, unknown> | 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;
|
||||
}
|
||||
150
archive/mx-cli-unused-after-sdk-split/runner-transport.ts
Normal file
150
archive/mx-cli-unused-after-sdk-split/runner-transport.ts
Normal file
@ -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<string>([
|
||||
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);
|
||||
}
|
||||
112
archive/mx-cli-unused-after-sdk-split/webapp-manifest.ts
Normal file
112
archive/mx-cli-unused-after-sdk-split/webapp-manifest.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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;
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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 <name>
|
||||
matrix actor <name>
|
||||
matrix configure --key <key> --cloud <url> --space <space>
|
||||
matrix run <entry> --mount <mount>
|
||||
matrix invoke <mount> <op> [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.
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
125
packages/cli/src/commands/actor-element.ts
Normal file
125
packages/cli/src/commands/actor-element.ts
Normal file
@ -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<IActorElementCommandResult> {
|
||||
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 };
|
||||
}
|
||||
605
packages/cli/src/commands/actor-run.ts
Normal file
605
packages/cli/src/commands/actor-run.ts
Normal file
@ -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<string, unknown>;
|
||||
readonly emits?: Record<string, unknown>;
|
||||
readonly subscribes?: Record<string, unknown>;
|
||||
readonly name: string;
|
||||
};
|
||||
|
||||
type PackageBootstrapFunction = (...args: readonly unknown[]) => Promise<unknown> | 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<string, unknown>;
|
||||
readonly emits: Record<string, unknown>;
|
||||
readonly subscribes: Record<string, unknown>;
|
||||
readonly overrides: Record<string, unknown>;
|
||||
readonly serviceInstance: ILoadedMatrixServiceManifest['serviceInstance'];
|
||||
}
|
||||
|
||||
export async function actorRunCommand(
|
||||
entry: string,
|
||||
options: IActorRunCommandOptions,
|
||||
): Promise<void> {
|
||||
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 <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<void> {
|
||||
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 <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<NatsConnection> {
|
||||
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<ActorConstructor> {
|
||||
const moduleUrl = `${pathToFileURL(entryPath).href}?matrix_actor_run=${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const mod = await import(moduleUrl) as Record<string, unknown>;
|
||||
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<PackageBootstrapFunction> {
|
||||
const moduleUrl = `${pathToFileURL(entryPath).href}?matrix_package_run=${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const mod = await import(moduleUrl) as Record<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
function parseOverrides(value: string | undefined): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<Record<string, unknown>, unknown>(
|
||||
runtime.getRootContext(),
|
||||
mount,
|
||||
op,
|
||||
payload,
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
return { op, result };
|
||||
}
|
||||
|
||||
function parseCheckPayload(value: string | undefined): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
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<void> {
|
||||
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<void>): Promise<void> {
|
||||
return new Promise<void>((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<string, string> {
|
||||
const value = readJsonFile(filePath);
|
||||
return normalizeBrokerConfig(value);
|
||||
}
|
||||
|
||||
function readBrokerConfigFile(filePath: string): Record<string, string> {
|
||||
const value = readJsonFile(filePath);
|
||||
const nested = [
|
||||
value,
|
||||
value?.broker,
|
||||
value?.nats,
|
||||
value?.transport,
|
||||
value?.credentials,
|
||||
].filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === 'object' && !Array.isArray(entry)));
|
||||
return nested.reduce<Record<string, string>>((result, entry) => ({
|
||||
...result,
|
||||
...normalizeBrokerConfig(entry),
|
||||
}), {});
|
||||
}
|
||||
|
||||
function normalizeBrokerConfig(value: Record<string, unknown> | null): Record<string, string> {
|
||||
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<string, string> }[],
|
||||
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<string, unknown> | 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<string, unknown> : 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;
|
||||
}
|
||||
236
packages/cli/src/commands/actor.ts
Normal file
236
packages/cli/src/commands/actor.ts
Normal file
@ -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<string, unknown> = {},',
|
||||
' 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<string, unknown> {
|
||||
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<IActorCommandResult> {
|
||||
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 };
|
||||
}
|
||||
95
packages/cli/src/commands/auth.ts
Normal file
95
packages/cli/src/commands/auth.ts
Normal file
@ -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<ILoginCommandResult> {
|
||||
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<ILogoutCommandResult> {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
return removeRegistryCredential(cwd, {
|
||||
registry: options.registry,
|
||||
credentialsPath: options.credentialsPath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function whoamiCommand(options: IAuthContextOptions = {}): Promise<IWhoamiCommandResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
93
packages/cli/src/commands/config.ts
Normal file
93
packages/cli/src/commands/config.ts
Normal file
@ -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<IConfigGetResult> {
|
||||
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<IConfigListResult> {
|
||||
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<IConfigSetResult> {
|
||||
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<IConfigResetResult> {
|
||||
return resetMatrixCliConfig(options);
|
||||
}
|
||||
114
packages/cli/src/commands/cqrs.ts
Normal file
114
packages/cli/src/commands/cqrs.ts
Normal file
@ -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<ICqrsCommandResult> {
|
||||
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 };
|
||||
}
|
||||
108
packages/cli/src/commands/element.ts
Normal file
108
packages/cli/src/commands/element.ts
Normal file
@ -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<IElementCommandResult> {
|
||||
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 };
|
||||
}
|
||||
254
packages/cli/src/commands/fork.ts
Normal file
254
packages/cli/src/commands/fork.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
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<INameRename> {
|
||||
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<INameRename>): 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<IForkCommandResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
38
packages/cli/src/commands/info.ts
Normal file
38
packages/cli/src/commands/info.ts
Normal file
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export async function infoCommand(packageName: string, options: IInfoCommandOptions = {}): Promise<IInfoCommandResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
133
packages/cli/src/commands/init.ts
Normal file
133
packages/cli/src/commands/init.ts
Normal file
@ -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<string> {
|
||||
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<IInitCommandResult> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
676
packages/cli/src/commands/install.ts
Normal file
676
packages/cli/src/commands/install.ts
Normal file
@ -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<string, unknown>;
|
||||
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<string> {
|
||||
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(
|
||||
/(<script[^>]+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<IInstallCommandResult> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
19
packages/cli/src/commands/list.ts
Normal file
19
packages/cli/src/commands/list.ts
Normal file
@ -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<IListCommandResult> {
|
||||
const cwd = path.resolve(options.cwd ?? process.cwd());
|
||||
const nodeModulesRoot = resolvePackagesNodeModulesRoot(cwd, options.packagesDir);
|
||||
return {
|
||||
packages: listInstalledPackageNames(nodeModulesRoot),
|
||||
};
|
||||
}
|
||||
91
packages/cli/src/commands/outdated.ts
Normal file
91
packages/cli/src/commands/outdated.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
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<IOutdatedEntry>;
|
||||
}
|
||||
|
||||
export async function outdatedCommand(options: IOutdatedCommandOptions = {}): Promise<IOutdatedCommandResult> {
|
||||
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 };
|
||||
}
|
||||
65
packages/cli/src/commands/pack.ts
Normal file
65
packages/cli/src/commands/pack.ts
Normal file
@ -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<IPackCommandResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
173
packages/cli/src/commands/publish.ts
Normal file
173
packages/cli/src/commands/publish.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
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<IPublishCommandResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
312
packages/cli/src/commands/registry.ts
Normal file
312
packages/cli/src/commands/registry.ts
Normal file
@ -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<string, string>;
|
||||
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<IRegistryUseResult> {
|
||||
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<IRegistryLoginResult> {
|
||||
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<IRegistryDoctorResult> {
|
||||
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<string, string> = {};
|
||||
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(''),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
89
packages/cli/src/commands/search.ts
Normal file
89
packages/cli/src/commands/search.ts
Normal file
@ -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<ISearchCommandResult> {
|
||||
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;
|
||||
}
|
||||
26
packages/cli/src/commands/uninstall.ts
Normal file
26
packages/cli/src/commands/uninstall.ts
Normal file
@ -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<IUninstallCommandResult> {
|
||||
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 };
|
||||
}
|
||||
95
packages/cli/src/commands/update-fork.ts
Normal file
95
packages/cli/src/commands/update-fork.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
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<IUpdateForkCommandResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
94
packages/cli/src/commands/validate.ts
Normal file
94
packages/cli/src/commands/validate.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
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<IValidateCommandResult> {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
553
packages/cli/src/commands/webapp.ts
Normal file
553
packages/cli/src/commands/webapp.ts
Normal file
@ -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<void> {
|
||||
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 = \`
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<h1>${appName}</h1>
|
||||
<div id="transportIndicator" class="transport-indicator">
|
||||
<span id="transportText">Connecting...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="counter-card">
|
||||
<div class="counter-display">
|
||||
<span class="counter-label">Count:</span>
|
||||
<span id="countDisplay" class="counter-value">0</span>
|
||||
</div>
|
||||
<div class="counter-controls">
|
||||
<button id="decrementBtn" class="btn btn-secondary" type="button">-</button>
|
||||
<button id="incrementBtn" class="btn btn-primary" type="button">+</button>
|
||||
<button id="resetBtn" class="btn btn-outline" type="button">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<p>This app demonstrates the Matrix Shell pattern:</p>
|
||||
<ul>
|
||||
<li><strong>Shell</strong> sends commands via <code>sendToSelf()</code></li>
|
||||
<li><strong>Actor</strong> processes commands and emits events</li>
|
||||
<li><strong>Shell</strong> reacts to events via <code>on<Event>()</code> handlers</li>
|
||||
</ul>
|
||||
<button id="introspectBtn" class="btn btn-outline" type="button">Introspect Actor</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<style>\${appStyles}</style>
|
||||
\`;
|
||||
`;
|
||||
}
|
||||
|
||||
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 `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${appName}</title>
|
||||
<style>
|
||||
body { margin: 0; background: #1a1a2e; }
|
||||
#loading {
|
||||
color: #888;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">Loading ${appName}...</div>
|
||||
<${tagName} id="app" style="display:none"></${tagName}>
|
||||
<script type="module" src="./src/init.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
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<void>((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 = \`
|
||||
<div style="color: #ff6b6b; font-family: sans-serif; text-align: center; padding: 4rem;">
|
||||
<strong>Failed to initialize</strong>
|
||||
<p>\${error.message}</p>
|
||||
</div>
|
||||
\`;
|
||||
}
|
||||
}
|
||||
|
||||
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<IWebappCommandResult> {
|
||||
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 };
|
||||
}
|
||||
805
packages/cli/src/index.ts
Normal file
805
packages/cli/src/index.ts
Normal file
@ -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 <name>', 'Package name override')
|
||||
.option('--version <version>', 'Package version override')
|
||||
.option('--description <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 <path>', 'Output directory (default: ./<name>)')
|
||||
.action(async (name: string | undefined, options: { out?: string }) => {
|
||||
if (!name) {
|
||||
fail(new Error('actor requires a name. Use `matrix actor run <entry>` for direct actor execution.'));
|
||||
}
|
||||
await actorCommand(name, { outputDir: options.out });
|
||||
});
|
||||
|
||||
actor
|
||||
.command('run <entry>')
|
||||
.description('Run one actor directly on the selected Space bus without managed host inventory')
|
||||
.option('--export <name>', 'Actor export name (default: default)')
|
||||
.requiredOption('--mount <mount>', 'Logical actor mount under the selected Space authority root')
|
||||
.option('--nats-url <url>', 'NATS broker URL, e.g. nats://127.0.0.1:4222')
|
||||
.option('--root <root>', 'Matrix/NATS subject root')
|
||||
.option('--space <root>', 'Alias for --root')
|
||||
.option('--jwt <jwt>', 'NATS user JWT')
|
||||
.option('--seed <seed>', 'NATS user seed')
|
||||
.option('--home <path>', 'Credential/config home (also checks ./.matrix and ~/.matrix)')
|
||||
.option('--props <json>', 'JSON object assigned as actor bootstrap props')
|
||||
.option('--check-op <op>', 'Invoke one actor operation after mount, print the result, and exit')
|
||||
.option('--check-payload <json>', '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 <packageDir>')
|
||||
.description('Run one package directly on the selected Space bus without managed host inventory')
|
||||
.option('--entry <path>', 'Package entry override')
|
||||
.option('--export <name>', 'Package export override')
|
||||
.option('--mount <mount>', 'Logical actor mount under the selected Space authority root')
|
||||
.option('--nats-url <url>', 'NATS broker URL, e.g. nats://127.0.0.1:4222')
|
||||
.option('--root <root>', 'Matrix/NATS subject root')
|
||||
.option('--space <root>', 'Alias for --root')
|
||||
.option('--jwt <jwt>', 'NATS user JWT')
|
||||
.option('--seed <seed>', 'NATS user seed')
|
||||
.option('--home <path>', 'Credential/config home (also checks ./.matrix and ~/.matrix)')
|
||||
.option('--overrides <json>', 'JSON object passed as package bootstrap overrides')
|
||||
.option('--check-op <op>', 'Invoke one actor operation after package mount, print the result, and exit')
|
||||
.option('--check-payload <json>', '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 <name>')
|
||||
.description('Scaffold a new element package')
|
||||
.option('--out <path>', 'Output directory (default: ./<name>)')
|
||||
.action(async (name: string, options: { out?: string }) => {
|
||||
await elementCommand(name, { outputDir: options.out });
|
||||
});
|
||||
|
||||
program
|
||||
.command('actor-element <name>')
|
||||
.description('Scaffold a package containing paired actor + element components')
|
||||
.option('--out <path>', 'Output directory (default: ./<name>)')
|
||||
.action(async (name: string, options: { out?: string }) => {
|
||||
await actorElementCommand(name, { outputDir: options.out });
|
||||
});
|
||||
|
||||
program
|
||||
.command('cqrs <name>')
|
||||
.description('Scaffold a package with CQRS command/query actors')
|
||||
.option('--out <path>', 'Output directory (default: ./<name>)')
|
||||
.action(async (name: string, options: { out?: string }) => {
|
||||
await cqrsCommand(name, { outputDir: options.out });
|
||||
});
|
||||
|
||||
program
|
||||
.command('webapp <name>')
|
||||
.description('Scaffold a browser web app with Shell pattern, federation, and Vite')
|
||||
.option('--out <path>', 'Output directory (default: ./<name>)')
|
||||
.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 <path>', '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 <source>')
|
||||
.description('Install a Matrix package from directory, tarball, or registry name')
|
||||
.option('--packages-dir <path>', 'Override packages root (default: ~/.matrix/packages)')
|
||||
.option('--registry-dir <path>', 'Override local registry root')
|
||||
.option('--registry <url>', 'NPM-compatible registry URL')
|
||||
.option('--credentials-file <path>', '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 <packageName>')
|
||||
.description('Uninstall a Matrix package from package root')
|
||||
.option('--packages-dir <path>', '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 <packageName>')
|
||||
.description('Fork an installed package for modification')
|
||||
.option('--name <newName>', 'Forked package name')
|
||||
.option('--replace-origin', 'Do not rename component type/mount/export fields')
|
||||
.option('--packages-dir <path>', 'Override packages root (default: ~/.matrix/packages)')
|
||||
.option('--forked-by <name>', '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 <path>', 'Override local registry root')
|
||||
.option('--registry <url>', 'NPM-compatible registry URL')
|
||||
.option('--credentials-file <path>', '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 <query>')
|
||||
.description('Search the configured npm-compatible Matrix package registry')
|
||||
.option('--registry-dir <path>', 'Search local Matrix discovery index under this registry root')
|
||||
.option('--registry <url>', 'NPM-compatible registry URL')
|
||||
.option('--credentials-file <path>', 'Override registry credentials file path')
|
||||
.option('--limit <count>', '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 <key>')
|
||||
.description('Read a Matrix CLI config value')
|
||||
.option('--config-file <path>', '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 <key> <value>')
|
||||
.description('Set a Matrix CLI config value')
|
||||
.option('--config-file <path>', '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 <path>', '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 <path>', '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 <url>', 'Registry URL to diagnose')
|
||||
.option('--credentials-file <path>', 'Override registry credentials file path')
|
||||
.option('--config-file <path>', 'Override CLI config file path')
|
||||
.option('--timeout-ms <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 <name>', 'Registry username')
|
||||
.requiredOption('--token <token>', 'Registry token')
|
||||
.option('--email <email>', 'Optional account email')
|
||||
.option('--expires-at <iso>', 'Optional token expiry timestamp')
|
||||
.option('--registry <url>', 'Registry URL')
|
||||
.option('--credentials-file <path>', '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 <profile>')
|
||||
.description('Set the Matrix package registry profile')
|
||||
.option('--registry <url>', 'Registry URL override')
|
||||
.option('--config-file <path>', '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 <name>', 'Registry username')
|
||||
.option('--token <token>', 'Registry token')
|
||||
.option('--email <email>', 'Optional account email')
|
||||
.option('--expires-at <iso>', 'Optional token expiry timestamp')
|
||||
.option('--registry <url>', 'Registry identifier')
|
||||
.option('--credentials-file <path>', '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 <url>', 'Registry identifier')
|
||||
.option('--credentials-file <path>', '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 <url>', 'Registry identifier')
|
||||
.option('--credentials-file <path>', '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 <path>', 'Override packages root (default: ~/.matrix/packages)')
|
||||
.option('--registry-dir <path>', '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 <packageName>')
|
||||
.description('Update fork metadata to latest upstream version')
|
||||
.option('--packages-dir <path>', 'Override packages root (default: ~/.matrix/packages)')
|
||||
.option('--registry-dir <path>', 'Override local registry root')
|
||||
.option('--version <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 <path>', '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 <packageName>')
|
||||
.description('Show installed package metadata')
|
||||
.option('--packages-dir <path>', '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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
73
packages/cli/src/utils/archive.ts
Normal file
73
packages/cli/src/utils/archive.ts
Normal file
@ -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<string> = 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');
|
||||
}
|
||||
207
packages/cli/src/utils/auth-store.ts
Normal file
207
packages/cli/src/utils/auth-store.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
export interface IRegistryCredential {
|
||||
readonly token: string;
|
||||
readonly username: string;
|
||||
readonly email?: string;
|
||||
readonly expiresAt?: string;
|
||||
}
|
||||
|
||||
interface ICredentialFile {
|
||||
readonly registries: Record<string, IRegistryCredential>;
|
||||
}
|
||||
|
||||
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<string, IRegistryCredential> = {};
|
||||
|
||||
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<string>();
|
||||
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),
|
||||
};
|
||||
}
|
||||
174
packages/cli/src/utils/config-store.ts
Normal file
174
packages/cli/src/utils/config-store.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
538
packages/cli/src/utils/discovery-extractor.ts
Normal file
538
packages/cli/src/utils/discovery-extractor.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
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<string, IClassStaticMetadata>,
|
||||
): 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<string>();
|
||||
|
||||
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<string, IClassStaticMetadata> {
|
||||
const statics = new Map<string, IClassStaticMetadata>();
|
||||
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)),
|
||||
};
|
||||
}
|
||||
378
packages/cli/src/utils/discovery-index.ts
Normal file
378
packages/cli/src/utils/discovery-index.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
export interface IPackageDiscoveryIndexVersion {
|
||||
readonly indexedAt: string;
|
||||
readonly indexedBy: string;
|
||||
readonly metadata: IPackageDiscoveryMetadata;
|
||||
}
|
||||
|
||||
export interface IPackageDiscoveryIndexPackage {
|
||||
readonly versions: Record<string, IPackageDiscoveryIndexVersion>;
|
||||
}
|
||||
|
||||
export interface IPackageDiscoveryIndex {
|
||||
readonly packages: Record<string, IPackageDiscoveryIndexPackage>;
|
||||
}
|
||||
|
||||
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<string, IPackageDiscoveryIndexPackage> = {};
|
||||
|
||||
for (const [packageName, packageValue] of Object.entries(packagesRecord)) {
|
||||
const packageRecord = asRecord(packageValue);
|
||||
const versionsRecord = asRecord(packageRecord?.versions) ?? {};
|
||||
const versions: Record<string, IPackageDiscoveryIndexVersion> = {};
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
255
packages/cli/src/utils/manifestValidator.ts
Normal file
255
packages/cli/src/utils/manifestValidator.ts
Normal file
@ -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<IManifestDiagnostic>;
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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<IManifestDiagnostic, 'severity'>
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
345
packages/cli/src/utils/npm-registry-client.ts
Normal file
345
packages/cli/src/utils/npm-registry-client.ts
Normal file
@ -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<INpmCommandResult> {
|
||||
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<readonly INpmRegistrySearchResult[]> {
|
||||
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<string, unknown> | 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<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: 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<INpmCommandResult> {
|
||||
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(''),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
212
packages/cli/src/utils/package-store.ts
Normal file
212
packages/cli/src/utils/package-store.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
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;
|
||||
}
|
||||
131
packages/cli/src/utils/registry-store.ts
Normal file
131
packages/cli/src/utils/registry-store.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
export interface IRegistryVersionEntry {
|
||||
readonly tarballPath: string;
|
||||
readonly publishedAt: string;
|
||||
readonly publishedBy: string;
|
||||
}
|
||||
|
||||
export interface IRegistryPackageEntry {
|
||||
readonly versions: Record<string, IRegistryVersionEntry>;
|
||||
}
|
||||
|
||||
export interface IRegistryIndex {
|
||||
readonly packages: Record<string, IRegistryPackageEntry>;
|
||||
}
|
||||
|
||||
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<string, IRegistryPackageEntry> = {};
|
||||
|
||||
for (const [packageName, packageValue] of Object.entries(packagesRecord)) {
|
||||
const packageEntry = asRecord(packageValue);
|
||||
const versionsRecord = asRecord(packageEntry?.versions) ?? {};
|
||||
const versions: Record<string, IRegistryVersionEntry> = {};
|
||||
|
||||
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));
|
||||
}
|
||||
16
packages/cli/src/utils/runner-security.ts
Normal file
16
packages/cli/src/utils/runner-security.ts
Normal file
@ -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' }),
|
||||
);
|
||||
}
|
||||
164
packages/cli/src/utils/scaffold.ts
Normal file
164
packages/cli/src/utils/scaffold.ts
Normal file
@ -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<string, string>;
|
||||
}): Record<string, unknown> {
|
||||
const scripts: Record<string, string> = 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<string, string> = {};
|
||||
const devDependencies: Record<string, string> = {
|
||||
'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<string, unknown> {
|
||||
const compilerOptions: Record<string, unknown> = {
|
||||
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'],
|
||||
};
|
||||
}
|
||||
448
packages/cli/src/utils/service-manifest.ts
Normal file
448
packages/cli/src/utils/service-manifest.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
export interface IMatrixServiceSecretRef extends IMountedInstanceSecretRefInput {}
|
||||
|
||||
export interface IMatrixServiceInstanceManifest extends IMountedInstanceMetadataInput {
|
||||
readonly secretRefs?: ReadonlyArray<IMatrixServiceSecretRef>;
|
||||
}
|
||||
|
||||
export interface IMatrixServiceBootstrapManifest {
|
||||
readonly overrides?: Record<string, unknown>;
|
||||
readonly root?: string;
|
||||
readonly transport?: Record<string, unknown>;
|
||||
readonly auth?: Record<string, unknown>;
|
||||
readonly http?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
readonly root?: string;
|
||||
readonly mount?: string;
|
||||
readonly config?: {
|
||||
readonly file?: string;
|
||||
};
|
||||
readonly secrets?: {
|
||||
readonly file?: string;
|
||||
};
|
||||
readonly transport?: Record<string, unknown>;
|
||||
readonly auth?: Record<string, unknown>;
|
||||
readonly http?: Record<string, unknown>;
|
||||
readonly dependsOn?: ReadonlyArray<string>;
|
||||
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<string>;
|
||||
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<string, unknown>;
|
||||
readonly root?: string;
|
||||
readonly mount?: string;
|
||||
readonly configRef?: string;
|
||||
readonly transport?: Record<string, unknown>;
|
||||
readonly auth?: Record<string, unknown>;
|
||||
readonly http?: Record<string, unknown>;
|
||||
readonly packageRoot?: ILoadedMatrixBindingDeclaration;
|
||||
readonly packageComponents: ReadonlyArray<ILoadedMatrixBindingDeclaration>;
|
||||
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<string, unknown> => 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<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveComponentRootMount(
|
||||
sourceLabel: string,
|
||||
components: ReadonlyArray<Record<string, unknown>>,
|
||||
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<string, unknown> | 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<string>();
|
||||
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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
28
packages/cli/src/utils/versioning.ts
Normal file
28
packages/cli/src/utils/versioning.ts
Normal file
@ -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;
|
||||
}
|
||||
75
packages/cli/tests/helpers/cli-harness.ts
Normal file
75
packages/cli/tests/helpers/cli-harness.ts
Normal file
@ -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<IRunMxCliResult> {
|
||||
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}`);
|
||||
}
|
||||
73
packages/cli/tests/helpers/package-fixtures.ts
Normal file
73
packages/cli/tests/helpers/package-fixtures.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
589
packages/cli/tests/integration/fp30-auth-publish.spec.ts
Normal file
589
packages/cli/tests/integration/fp30-auth-publish.spec.ts
Normal file
@ -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<void>;
|
||||
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<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: 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<string> {
|
||||
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<INpmPublishRegistryFixture> {
|
||||
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<void>((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<void>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
73
packages/cli/tests/integration/fp30-fork-lifecycle.spec.ts
Normal file
73
packages/cli/tests/integration/fp30-fork-lifecycle.spec.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
146
packages/cli/tests/integration/fp30-install-lifecycle.spec.ts
Normal file
146
packages/cli/tests/integration/fp30-install-lifecycle.spec.ts
Normal file
@ -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<string, string>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -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<void>;
|
||||
}> {
|
||||
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<void>((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<void> {
|
||||
await new Promise<void>((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<void>> = [];
|
||||
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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -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<string[]> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
142
packages/cli/tests/integration/fp30-outdated-update-fork.spec.ts
Normal file
142
packages/cli/tests/integration/fp30-outdated-update-fork.spec.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
53
packages/cli/tests/integration/fp30-pack-command.spec.ts
Normal file
53
packages/cli/tests/integration/fp30-pack-command.spec.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
342
packages/cli/tests/integration/fp30-publish-preflight.spec.ts
Normal file
342
packages/cli/tests/integration/fp30-publish-preflight.spec.ts
Normal file
@ -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'), '<!doctype html><title>Webapp only</title>\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);
|
||||
});
|
||||
});
|
||||
192
packages/cli/tests/integration/fp30-registry-command.spec.ts
Normal file
192
packages/cli/tests/integration/fp30-registry-command.spec.ts
Normal file
@ -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<string, unknown> {
|
||||
assert.ok(value !== null && typeof value === 'object' && !Array.isArray(value));
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function startRegistryFixture(): Promise<{ readonly registryUrl: string; readonly close: () => Promise<void> }> {
|
||||
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<void>((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<void>((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<void>> = [];
|
||||
|
||||
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<string, unknown>).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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
54
packages/cli/tests/integration/fp30-scaffold-cqrs.spec.ts
Normal file
54
packages/cli/tests/integration/fp30-scaffold-cqrs.spec.ts
Normal file
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
54
packages/cli/tests/integration/fp30-scaffold-element.spec.ts
Normal file
54
packages/cli/tests/integration/fp30-scaffold-element.spec.ts
Normal file
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
341
packages/cli/tests/integration/fp30-search.spec.ts
Normal file
341
packages/cli/tests/integration/fp30-search.spec.ts
Normal file
@ -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<void>;
|
||||
}
|
||||
|
||||
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<ISearchFixture> {
|
||||
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<void>((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<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('FP-30 mx search command', () => {
|
||||
const tempDirs: string[] = [];
|
||||
const closeFns: Array<() => Promise<void>> = [];
|
||||
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<string, unknown>;
|
||||
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'), '<!doctype html><title>Semantic Chat</title>', '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);
|
||||
});
|
||||
});
|
||||
120
packages/cli/tests/integration/fp30-update-fork.spec.ts
Normal file
120
packages/cli/tests/integration/fp30-update-fork.spec.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
77
packages/cli/tests/support/installCoreStub.ts
Normal file
77
packages/cli/tests/support/installCoreStub.ts
Normal file
@ -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<string, unknown>;',
|
||||
' static emits: Record<string, unknown>;',
|
||||
'}',
|
||||
'',
|
||||
'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<TActor>(ActorClass: new () => TActor, mount: string): Promise<TActor>;',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
265
packages/cli/tests/unit/actor-run.spec.ts
Normal file
265
packages/cli/tests/unit/actor-run.spec.ts
Normal file
@ -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<string, string | undefined>): void {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
98
packages/cli/tests/unit/cli-help.spec.ts
Normal file
98
packages/cli/tests/unit/cli-help.spec.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
47
packages/cli/tests/unit/config-command.spec.ts
Normal file
47
packages/cli/tests/unit/config-command.spec.ts
Normal file
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user