@tetherto/mdk-cli 0.0.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +447 -0
- package/dist/commands/add.js +61 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/agent.js +42 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/create.js +261 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/inspect.js +171 -0
- package/dist/commands/inspect.js.map +1 -0
- package/dist/commands/meta.js +12 -0
- package/dist/commands/meta.js.map +1 -0
- package/dist/commands/onboard.js +447 -0
- package/dist/commands/onboard.js.map +1 -0
- package/dist/commands/run.js +203 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/validate.js +125 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/add-package.js +148 -0
- package/dist/lib/add-package.js.map +1 -0
- package/dist/lib/app-package.js +182 -0
- package/dist/lib/app-package.js.map +1 -0
- package/dist/lib/app-scaffold.js +221 -0
- package/dist/lib/app-scaffold.js.map +1 -0
- package/dist/lib/app-ui-check.js +178 -0
- package/dist/lib/app-ui-check.js.map +1 -0
- package/dist/lib/app-ui-runner.js +122 -0
- package/dist/lib/app-ui-runner.js.map +1 -0
- package/dist/lib/catalog.js +69 -0
- package/dist/lib/catalog.js.map +1 -0
- package/dist/lib/checkout.js +14 -0
- package/dist/lib/checkout.js.map +1 -0
- package/dist/lib/dashboard-apps.js +71 -0
- package/dist/lib/dashboard-apps.js.map +1 -0
- package/dist/lib/dashboard.js +318 -0
- package/dist/lib/dashboard.js.map +1 -0
- package/dist/lib/detect.js +38 -0
- package/dist/lib/detect.js.map +1 -0
- package/dist/lib/env-file.js +57 -0
- package/dist/lib/env-file.js.map +1 -0
- package/dist/lib/fetch-template.js +33 -0
- package/dist/lib/fetch-template.js.map +1 -0
- package/dist/lib/help.js +22 -0
- package/dist/lib/help.js.map +1 -0
- package/dist/lib/names.js +36 -0
- package/dist/lib/names.js.map +1 -0
- package/dist/lib/npm.js +113 -0
- package/dist/lib/npm.js.map +1 -0
- package/dist/lib/package-source.js +76 -0
- package/dist/lib/package-source.js.map +1 -0
- package/dist/lib/pkg.js +5 -0
- package/dist/lib/pkg.js.map +1 -0
- package/dist/lib/plugin-setup.js +204 -0
- package/dist/lib/plugin-setup.js.map +1 -0
- package/dist/lib/ports.js +31 -0
- package/dist/lib/ports.js.map +1 -0
- package/dist/lib/project.js +297 -0
- package/dist/lib/project.js.map +1 -0
- package/dist/lib/run-lock.js +188 -0
- package/dist/lib/run-lock.js.map +1 -0
- package/dist/lib/runtime.js +683 -0
- package/dist/lib/runtime.js.map +1 -0
- package/dist/lib/scaffold.js +82 -0
- package/dist/lib/scaffold.js.map +1 -0
- package/dist/lib/shutdown.js +53 -0
- package/dist/lib/shutdown.js.map +1 -0
- package/dist/lib/skill.js +38 -0
- package/dist/lib/skill.js.map +1 -0
- package/dist/lib/spec.js +375 -0
- package/dist/lib/spec.js.map +1 -0
- package/dist/lib/status.js +245 -0
- package/dist/lib/status.js.map +1 -0
- package/dist/lib/theme.js +50 -0
- package/dist/lib/theme.js.map +1 -0
- package/dist/lib/worker-package.js +74 -0
- package/dist/lib/worker-package.js.map +1 -0
- package/dist/lib/worker-scaffold.js +122 -0
- package/dist/lib/worker-scaffold.js.map +1 -0
- package/dist/lib/worker-smoke.js +243 -0
- package/dist/lib/worker-smoke.js.map +1 -0
- package/dist/program.js +58 -0
- package/dist/program.js.map +1 -0
- package/package.json +59 -1
- package/templates/app/README.md +66 -0
- package/templates/app/mdk-app.yaml +10 -0
- package/templates/app/package.json +17 -0
- package/templates/app/plugin/controllers/summary.js +36 -0
- package/templates/app/plugin/lib/client.js +11 -0
- package/templates/app/plugin/mdk-plugin.json +16 -0
- package/templates/app-ui/tsconfig.json +29 -0
- package/templates/app-ui/ui-src/components/summary-panel.tsx +24 -0
- package/templates/app-ui/ui-src/data/summary.ts +18 -0
- package/templates/app-ui/ui-src/hooks/use-summary.ts +54 -0
- package/templates/app-ui/ui-src/index.tsx +15 -0
- package/templates/app-ui/ui-src/pages/overview-page.tsx +18 -0
- package/templates/app-ui/ui-src/styles.css +13 -0
- package/templates/app-ui/ui-src/styles.d.ts +3 -0
- package/templates/app-ui/ui-src/vite.config.ts +26 -0
- package/templates/worker/README.md +93 -0
- package/templates/worker/mdk-contract.json +26 -0
- package/templates/worker/mock/server.js +93 -0
- package/templates/worker/package.json +23 -0
- package/templates/worker/smoke.config.js +31 -0
- package/templates/worker/src/client.js +47 -0
- package/templates/worker/src/commands/reboot.js +5 -0
- package/templates/worker/src/commands/set-power-mode.js +10 -0
- package/templates/worker/src/telemetry/hashrate-rt.js +5 -0
- package/templates/worker/src/telemetry/power-mode.js +5 -0
- package/templates/worker/src/telemetry/power.js +5 -0
- package/templates/worker/src/telemetry/temperature.js +5 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { isLocalPackagePath, unresolvedEnvRef } from './spec.js';
|
|
7
|
+
import { DEFAULT_HOST, findFreePort, isPortFree } from './ports.js';
|
|
8
|
+
import { APP_MANIFEST } from './app-package.js';
|
|
9
|
+
import { WORKER_CONTRACT } from './worker-package.js';
|
|
10
|
+
import { theme, tick, arrow } from './theme.js';
|
|
11
|
+
import { appChildFile, appChildPid } from './run-lock.js';
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
/** Lazily loads the `@tetherto/mdk-core` runtime (a workspace dependency). */
|
|
14
|
+
function mdkCore() {
|
|
15
|
+
return require('@tetherto/mdk-core');
|
|
16
|
+
}
|
|
17
|
+
/** Lazily loads the `@tetherto/mdk-worker` runtime host (a workspace dependency). */
|
|
18
|
+
function mdkWorker() {
|
|
19
|
+
return require('@tetherto/mdk-worker');
|
|
20
|
+
}
|
|
21
|
+
export function localDiscovery() {
|
|
22
|
+
return require('@tetherto/mdk-core/lib/local-discovery');
|
|
23
|
+
}
|
|
24
|
+
/** Lazily loads the `@tetherto/mdk-mcp` runtime (a workspace dependency). */
|
|
25
|
+
function mdkMcp() {
|
|
26
|
+
return require('@tetherto/mdk-mcp');
|
|
27
|
+
}
|
|
28
|
+
/** Lazily loads the `@tetherto/mdk-app-runtime` package (a workspace dependency). */
|
|
29
|
+
function mdkAppRuntime() {
|
|
30
|
+
return require('@tetherto/mdk-app-runtime');
|
|
31
|
+
}
|
|
32
|
+
// --- project-local runtime state ------------------------------------------
|
|
33
|
+
// Everything `mdk run` writes lives under `<project>/.mdk` — gitignored and safe
|
|
34
|
+
// to delete. Each component gets its own data root so no two of them share a
|
|
35
|
+
// directory, while the two cross-process handoff artifacts sit at the top level
|
|
36
|
+
// where either side can find them: `kernel.key` (read by the gateway) and
|
|
37
|
+
// `keys/` (written by workers, watched by the kernel). This leaves room for
|
|
38
|
+
// `logs/` and `run/` when `mdk logs` and `mdk status` land.
|
|
39
|
+
/** Absolute runtime-state root for a project — `<project>/.mdk`. */
|
|
40
|
+
export function mdkDir(projectDir) {
|
|
41
|
+
return resolve(projectDir, '.mdk');
|
|
42
|
+
}
|
|
43
|
+
/** A path under `.mdk`, e.g. `componentDir(dir, 'workers', name)`. */
|
|
44
|
+
function componentDir(projectDir, ...parts) {
|
|
45
|
+
return join(mdkDir(projectDir), ...parts);
|
|
46
|
+
}
|
|
47
|
+
/** Shared Kernel HRPC key file — how a separate gateway process discovers the Kernel. */
|
|
48
|
+
export function kernelKeyFile(projectDir) {
|
|
49
|
+
return componentDir(projectDir, 'kernel.key');
|
|
50
|
+
}
|
|
51
|
+
/** Where the Gateway publishes its public key; app runtimes accept calls from it and the MCP server's. */
|
|
52
|
+
export function gatewayKeyFile(projectDir) {
|
|
53
|
+
return componentDir(projectDir, 'gateway.key');
|
|
54
|
+
}
|
|
55
|
+
/** Where the MCP server publishes the key it calls app runtimes with. */
|
|
56
|
+
export function mcpKeyFile(projectDir) {
|
|
57
|
+
return componentDir(projectDir, 'mcp.key');
|
|
58
|
+
}
|
|
59
|
+
/** Where an app runtime publishes its public key, for the Gateway to find it. */
|
|
60
|
+
export function appKeyFile(projectDir, name) {
|
|
61
|
+
return componentDir(projectDir, 'apps', name, 'runtime.key');
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Shared worker-key dir for `discovery: { mode: 'local' }`, passed *explicitly*
|
|
65
|
+
* to both sides. mdk-core otherwise defaults it to `<root>/.worker-keys`, which
|
|
66
|
+
* would nest it inside whichever component's data root was passed — the Kernel
|
|
67
|
+
* and its workers must agree on one path independent of their own roots.
|
|
68
|
+
*/
|
|
69
|
+
export function workerKeysDir(projectDir) {
|
|
70
|
+
return componentDir(projectDir, 'keys');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Shared trusted-keys dir. The Kernel and every Worker reject unknown RPC
|
|
74
|
+
* callers, so each component publishes the key it dials with here
|
|
75
|
+
* (`kernel.pub`, `gateway.pub`, `mcp.pub`, `app-<name>.pub`): the Kernel admits callers from it
|
|
76
|
+
* and separately-run workers allow what they find in it.
|
|
77
|
+
*/
|
|
78
|
+
export function trustedKeysDir(projectDir) {
|
|
79
|
+
return componentDir(projectDir, 'trusted-keys');
|
|
80
|
+
}
|
|
81
|
+
/** Default gap between the Gateway's port and the MCP server's, when `spec.mcp.port` is not set. */
|
|
82
|
+
export const DEFAULT_MCP_PORT_OFFSET = 100;
|
|
83
|
+
/**
|
|
84
|
+
* Splits a spec `package` into the npm package to resolve and the subdirectory
|
|
85
|
+
* inside it, if any: `@scope/pkg/plugin-a` is one installed package and one
|
|
86
|
+
* directory within it, not a package called `.../plugin-a`.
|
|
87
|
+
*
|
|
88
|
+
* Subpaths exist for packages that ship several plugins as subdirectories of a
|
|
89
|
+
* single package, with no `package.json` of their own.
|
|
90
|
+
*/
|
|
91
|
+
function splitPackageSubpath(pkgName) {
|
|
92
|
+
const parts = pkgName.split('/');
|
|
93
|
+
const nameLength = pkgName.startsWith('@') ? 2 : 1;
|
|
94
|
+
return { pkg: parts.slice(0, nameLength).join('/'), subpath: parts.slice(nameLength).join('/') };
|
|
95
|
+
}
|
|
96
|
+
/** The spec the project's package.json gives `pkg`, if it lists it. */
|
|
97
|
+
function declaredDependency(projectDir, pkg) {
|
|
98
|
+
try {
|
|
99
|
+
const manifest = JSON.parse(readFileSync(join(resolve(projectDir), 'package.json'), 'utf8'));
|
|
100
|
+
return manifest.dependencies?.[pkg] ?? manifest.devDependencies?.[pkg];
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Resolves an installed package's root directory from the target project's
|
|
108
|
+
* `node_modules` (via its `package.json`), or a subdirectory of one. Throws an
|
|
109
|
+
* actionable error naming the missing package — a package `mdk.yaml` declares
|
|
110
|
+
* must be installed into the project first.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveProjectPackageDir(projectDir, pkgName) {
|
|
113
|
+
const { pkg, subpath } = splitPackageSubpath(pkgName);
|
|
114
|
+
let root;
|
|
115
|
+
try {
|
|
116
|
+
root = dirname(require.resolve(`${pkg}/package.json`, { paths: [resolve(projectDir)] }));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// A dependency package.json already declares (a `file:` app, say) only needs
|
|
120
|
+
// `npm install`; naming it would fetch an unrelated registry package. The
|
|
121
|
+
// install command names the *package*: `npm install @scope/pkg/plugin-a`
|
|
122
|
+
// is not a thing npm can do.
|
|
123
|
+
const declared = declaredDependency(projectDir, pkg);
|
|
124
|
+
throw new Error(`Package "${pkgName}" is not installed in ${resolve(projectDir)}.\n` +
|
|
125
|
+
(declared
|
|
126
|
+
? `package.json declares it ("${declared}"), so run: npm install`
|
|
127
|
+
: `Install it first, e.g.: npm install ${pkg}`));
|
|
128
|
+
}
|
|
129
|
+
if (!subpath)
|
|
130
|
+
return root;
|
|
131
|
+
// A subpath that is not there is a typo in mdk.yaml, not a missing install, and
|
|
132
|
+
// the two want different fixes. Whether the directory holds a plugin manifest
|
|
133
|
+
// (or a worker contract) is left to whoever loads it — that check belongs with
|
|
134
|
+
// the loader that knows which of the two it wanted.
|
|
135
|
+
const dir = join(root, subpath);
|
|
136
|
+
if (!existsSync(dir)) {
|
|
137
|
+
throw new Error(`Package "${pkgName}" is installed as "${pkg}", but it has no "${subpath}" in it (looked in ${dir}).\n` +
|
|
138
|
+
`Check the subpath — e.g. package: "${pkg}/<dir>".`);
|
|
139
|
+
}
|
|
140
|
+
return dir;
|
|
141
|
+
}
|
|
142
|
+
/** Boots the Kernel (local discovery, project-local state), logging its key. */
|
|
143
|
+
export async function runKernel(projectDir) {
|
|
144
|
+
const core = mdkCore();
|
|
145
|
+
const root = componentDir(projectDir, 'kernel');
|
|
146
|
+
mkdirSync(root, { recursive: true });
|
|
147
|
+
process.stderr.write(`${theme.muted('Starting kernel')} ${arrow} ${theme.value(root)}\n`);
|
|
148
|
+
const kernel = await core.getKernel({
|
|
149
|
+
root,
|
|
150
|
+
keyFile: kernelKeyFile(projectDir),
|
|
151
|
+
discovery: { mode: 'local', dir: workerKeysDir(projectDir) },
|
|
152
|
+
trustedKeysDir: trustedKeysDir(projectDir),
|
|
153
|
+
});
|
|
154
|
+
const key = typeof kernel.getPublicKey === 'function' ? kernel.getPublicKey() : undefined;
|
|
155
|
+
const keyHex = key ? key.toString('hex') : '(unknown)';
|
|
156
|
+
process.stderr.write(`${tick} ${theme.label('kernel')} ready ${theme.muted(keyHex)}\n`);
|
|
157
|
+
return kernel;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Shared by assertGatewayPortFree/assertMcpPortFree below: unlike a mock port,
|
|
161
|
+
* these are published endpoints (the dashboard proxies to the gateway's, agents
|
|
162
|
+
* dial the MCP one directly), so neither is ever relocated behind the
|
|
163
|
+
* operator's back — fail with the fix instead of a bare EADDRINUSE from deep
|
|
164
|
+
* inside the server. One implementation keeps the two messages from drifting
|
|
165
|
+
* apart as the wording around them changes.
|
|
166
|
+
*/
|
|
167
|
+
async function assertPortFree(port, label, specKey) {
|
|
168
|
+
if (await isPortFree(port))
|
|
169
|
+
return;
|
|
170
|
+
throw new Error(`${label} port ${port} is already in use.\n` +
|
|
171
|
+
`Another stack is probably running — stop it, or change \`${specKey}\` in mdk.yaml.`);
|
|
172
|
+
}
|
|
173
|
+
/** Exported so `run all` can check the gateway's port before booting anything. */
|
|
174
|
+
export async function assertGatewayPortFree(port) {
|
|
175
|
+
return assertPortFree(port, 'Gateway', 'spec.gateway.port');
|
|
176
|
+
}
|
|
177
|
+
/** Neither runs app code, so an app's config goes to its runtime only. */
|
|
178
|
+
function forwardedApps(projectDir, apps) {
|
|
179
|
+
return apps.map((a) => ({
|
|
180
|
+
name: a.name,
|
|
181
|
+
appDir: resolveAppPackageDir(projectDir, a.package),
|
|
182
|
+
keyFile: appKeyFile(projectDir, a.name),
|
|
183
|
+
...(a.mount !== undefined ? { mount: a.mount } : {}),
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Boots the Gateway. When `kernel` is provided the connection is in-process
|
|
188
|
+
* (target `all`); otherwise the Kernel key is resolved from the shared key
|
|
189
|
+
* file written by a separate `mdk run kernel` process.
|
|
190
|
+
*/
|
|
191
|
+
export async function runGateway(projectDir, spec, kernel) {
|
|
192
|
+
const core = mdkCore();
|
|
193
|
+
// Resolve every path to an absolute one *before* the chdir below, so nothing
|
|
194
|
+
// depends on the (about-to-change) working directory.
|
|
195
|
+
const keyFile = kernelKeyFile(projectDir);
|
|
196
|
+
const root = componentDir(projectDir, 'gateway');
|
|
197
|
+
const ownKeyFile = gatewayKeyFile(projectDir);
|
|
198
|
+
const trustDir = trustedKeysDir(projectDir);
|
|
199
|
+
const apps = forwardedApps(projectDir, spec.spec.apps);
|
|
200
|
+
// Out-of-process gateway: the Kernel must already be running so its HRPC key
|
|
201
|
+
// is on disk. Fail early with an actionable message instead of silently
|
|
202
|
+
// booting a gateway that can never reach a Kernel.
|
|
203
|
+
if (!kernel && !existsSync(keyFile)) {
|
|
204
|
+
throw new Error('Kernel is not running (no key file found).\nStart it first: `mdk run kernel`.');
|
|
205
|
+
}
|
|
206
|
+
await assertGatewayPortFree(spec.spec.gateway.port);
|
|
207
|
+
mkdirSync(root, { recursive: true });
|
|
208
|
+
// The gateway's corestore (`store/http`) is created relative to the working
|
|
209
|
+
// directory (tether-wrk-base convention — the mvp-site example likewise runs
|
|
210
|
+
// each process with its own `cwd`). Run from the gateway state root so that
|
|
211
|
+
// store nests under `.mdk/gateway/` instead of polluting the project.
|
|
212
|
+
process.chdir(root);
|
|
213
|
+
process.stderr.write(`${theme.muted('Starting gateway')} ${arrow} ${theme.value(`:${spec.spec.gateway.port}`)}\n`);
|
|
214
|
+
const gateway = await core.startGateway({
|
|
215
|
+
root,
|
|
216
|
+
port: spec.spec.gateway.port,
|
|
217
|
+
...(kernel ? { kernel } : { keyFile }),
|
|
218
|
+
apps,
|
|
219
|
+
gatewayKeyFile: ownKeyFile,
|
|
220
|
+
// Admits the gateway's client key on the Kernel and on workers in other processes.
|
|
221
|
+
trustedKeysDir: trustDir,
|
|
222
|
+
...(Object.keys(spec.spec.gateway.config ?? {}).length ? { common: spec.spec.gateway.config } : {}),
|
|
223
|
+
// `auth: true` routes (the agent chat) admit callers on this machine unless
|
|
224
|
+
// mdk.yaml names another built-in verifier in gateway.config.authVerifier.
|
|
225
|
+
...(spec.spec.gateway.config?.authVerifier === undefined ? { authVerifier: 'loopback' } : {}),
|
|
226
|
+
});
|
|
227
|
+
process.stderr.write(`${tick} ${theme.label('gateway')} listening on ${theme.value(`http://localhost:${spec.spec.gateway.port}`)}\n`);
|
|
228
|
+
return gateway;
|
|
229
|
+
}
|
|
230
|
+
/** Same reasoning as `assertGatewayPortFree` — see `assertPortFree` above. */
|
|
231
|
+
export async function assertMcpPortFree(port) {
|
|
232
|
+
return assertPortFree(port, 'MCP', 'spec.mcp.port');
|
|
233
|
+
}
|
|
234
|
+
/** The Kernel's public key (hex): from an in-process handle, else from the key file `mdk run kernel` wrote. */
|
|
235
|
+
function resolveKernelKey(projectDir, kernel) {
|
|
236
|
+
if (kernel) {
|
|
237
|
+
const key = typeof kernel.getPublicKey === 'function' ? kernel.getPublicKey() : undefined;
|
|
238
|
+
return key ? (Buffer.isBuffer(key) ? key.toString('hex') : String(key)) : undefined;
|
|
239
|
+
}
|
|
240
|
+
const keyFile = kernelKeyFile(projectDir);
|
|
241
|
+
if (existsSync(keyFile))
|
|
242
|
+
return readFileSync(keyFile, 'utf8').trim();
|
|
243
|
+
throw new Error('Kernel is not running (no key file found).\nStart it first: `mdk run kernel`.');
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Boots the standalone MCP server, pointed at the apps `mdk.yaml` declares
|
|
247
|
+
* under `spec.apps` — their routes become MCP
|
|
248
|
+
* tools with no separate mcp-specific plugin authoring required, each call
|
|
249
|
+
* forwarded to the app's runtime, so an app's tools answer once `mdk run` has
|
|
250
|
+
* started it. Requires the
|
|
251
|
+
* Kernel to already be reachable: an in-process `kernel` handle (target
|
|
252
|
+
* `all`, if this is ever wired into it), or the shared key file written by a
|
|
253
|
+
* separately-running `mdk run kernel` — the same contract `runGateway` uses.
|
|
254
|
+
*/
|
|
255
|
+
export async function runMcp(projectDir, spec, kernel, flags = {}) {
|
|
256
|
+
// An unset variable leaves the literal `${NAME}` as the token: /mcp would look
|
|
257
|
+
// protected while accepting a bearer anyone can read in mdk.yaml.
|
|
258
|
+
const token = spec.spec.mcp.token;
|
|
259
|
+
const unresolved = token ? unresolvedEnvRef(token) : undefined;
|
|
260
|
+
if (unresolved) {
|
|
261
|
+
throw new Error(`\`spec.mcp.token\` uses \${${unresolved}}, but ${unresolved} is not set in the environment or the project .env.\n` +
|
|
262
|
+
`Set ${unresolved}, or remove \`spec.mcp.token\` to run /mcp without a bearer token.`);
|
|
263
|
+
}
|
|
264
|
+
const mcp = mdkMcp();
|
|
265
|
+
const root = componentDir(projectDir, 'mcp');
|
|
266
|
+
const apps = forwardedApps(projectDir, spec.spec.apps);
|
|
267
|
+
const kernelKey = resolveKernelKey(projectDir, kernel);
|
|
268
|
+
const port = spec.spec.mcp.port ?? spec.spec.gateway.port + DEFAULT_MCP_PORT_OFFSET;
|
|
269
|
+
await assertMcpPortFree(port);
|
|
270
|
+
mkdirSync(root, { recursive: true });
|
|
271
|
+
// A persistent caller identity every tool plugin's client dials with
|
|
272
|
+
// (config.kernelKeyPair), admitted on the in-process Kernel and published
|
|
273
|
+
// for a separately-running Kernel and its workers.
|
|
274
|
+
const discovery = localDiscovery();
|
|
275
|
+
const kernelKeyPair = discovery.callerKeyPair(join(root, 'identity'));
|
|
276
|
+
if (kernel && typeof kernel.allowKey === 'function')
|
|
277
|
+
kernel.allowKey(kernelKeyPair.publicKey);
|
|
278
|
+
discovery.publishTrustedKey(trustedKeysDir(projectDir), 'mcp', kernelKeyPair.publicKey);
|
|
279
|
+
process.stderr.write(`${theme.muted('Starting mcp')} ${arrow} ${theme.value(`:${port}`)}\n`);
|
|
280
|
+
// Like the Gateway, the MCP server runs no app code: every tool call is
|
|
281
|
+
// forwarded to the app's runtime (started by `mdk run`), bounded by
|
|
282
|
+
// `spec.gateway.config.appTimeoutMs`.
|
|
283
|
+
const server = await mcp.createMcpServer(root, port, { ...(spec.spec.gateway.config ?? {}), kernelKey, kernelKeyPair },
|
|
284
|
+
// `pluginDirs` (native, author-written `mcp-plugin.json` tools) is not yet
|
|
285
|
+
// reachable from `mdk.yaml` — there is no `spec.mcp.plugins` list. App
|
|
286
|
+
// route tools (via `apps` below) are deliberately the only
|
|
287
|
+
// CLI path today: every declared app's routes already become
|
|
288
|
+
// tools with no separate authoring, which covers the common case. Add a
|
|
289
|
+
// `spec.mcp.plugins` list here if native MCP-only plugins need a CLI path.
|
|
290
|
+
[], apps, {
|
|
291
|
+
// Write tools are opt-in: `--allow-write` or `spec.mcp.allowWrite`.
|
|
292
|
+
allowWrite: flags.allowWrite === true || spec.spec.mcp.allowWrite === true,
|
|
293
|
+
...(spec.spec.mcp.allowTools ? { allowTools: spec.spec.mcp.allowTools } : {}),
|
|
294
|
+
...(spec.spec.mcp.token ? { token: spec.spec.mcp.token } : {}),
|
|
295
|
+
...(spec.spec.mcp.identity ? { identity: spec.spec.mcp.identity } : {}),
|
|
296
|
+
// The CLI owns SIGINT/SIGTERM (installShutdown) and stops via close().
|
|
297
|
+
handleSignals: false,
|
|
298
|
+
// Every app runtime allow-lists this key (resolveAppOptions).
|
|
299
|
+
keyFile: mcpKeyFile(projectDir),
|
|
300
|
+
});
|
|
301
|
+
process.stderr.write(`${tick} ${theme.label('mcp')} listening on ${theme.value(`http://localhost:${port}/mcp`)}\n`);
|
|
302
|
+
return { stop: () => new Promise((resolve) => server.close(() => resolve())) };
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Waits until a freshly created mock is actually accepting connections.
|
|
306
|
+
*
|
|
307
|
+
* The mock framework binds differently per transport, so neither signal alone is
|
|
308
|
+
* enough: HTTP mocks hand back a Fastify instance and expose the bind as a
|
|
309
|
+
* `ready` promise, while TCP mocks hand back a `net.Server` that has already
|
|
310
|
+
* been told to listen and reports it by emitting `'listening'`. Waiting on the
|
|
311
|
+
* event alone would hang forever on a Fastify instance (it never emits it), and
|
|
312
|
+
* waiting on `ready` alone would return before a TCP mock is bound.
|
|
313
|
+
*/
|
|
314
|
+
async function awaitMockReady(mock) {
|
|
315
|
+
if (mock.ready)
|
|
316
|
+
await mock.ready;
|
|
317
|
+
const server = mock.server;
|
|
318
|
+
// `listening` is the tell for a real net.Server; guarding on it also avoids
|
|
319
|
+
// waiting for an event that has already fired.
|
|
320
|
+
if (server?.on && server.listening === false) {
|
|
321
|
+
await once(server, 'listening');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** Frees the listener now, whether the mock is Fastify-shaped or net.Server-shaped. */
|
|
325
|
+
function dropMockConnections(mock) {
|
|
326
|
+
mock.server?.closeAllConnections?.();
|
|
327
|
+
mock.server?.server?.closeAllConnections?.();
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Resolves each seed device's mock port to one that is actually bindable.
|
|
331
|
+
*
|
|
332
|
+
* A mock port is a private contract between the simulator this CLI starts and
|
|
333
|
+
* the plugin that dials it — nothing outside the process cares which number it
|
|
334
|
+
* is. So when the configured port is taken (a second stack on the machine, a
|
|
335
|
+
* worker left running, two devices sharing a port), relocating beats refusing to
|
|
336
|
+
* boot. The configured port is always tried first, so a free one stays stable
|
|
337
|
+
* and matches mdk.yaml. Real (non-mock) devices are never touched: their port
|
|
338
|
+
* belongs to the hardware.
|
|
339
|
+
*/
|
|
340
|
+
async function resolveMockPorts(workerName, seeds, reserved) {
|
|
341
|
+
// Seeded with the ports later workers have declared but not yet bound, so one
|
|
342
|
+
// squatter cannot cascade: a relocated worker steps over its neighbours'
|
|
343
|
+
// ports instead of pushing each of them along in turn.
|
|
344
|
+
const claimed = new Set(reserved ?? []);
|
|
345
|
+
const resolved = [];
|
|
346
|
+
for (const d of seeds) {
|
|
347
|
+
const opts = { ...(d.opts ?? {}) };
|
|
348
|
+
const wanted = opts.port;
|
|
349
|
+
if (typeof wanted === 'number') {
|
|
350
|
+
const host = typeof opts.host === 'string' ? opts.host : DEFAULT_HOST;
|
|
351
|
+
const available = !claimed.has(wanted) && (await isPortFree(wanted, host));
|
|
352
|
+
const port = available ? wanted : await findFreePort({ start: wanted + 1, host, taken: claimed });
|
|
353
|
+
if (port !== wanted) {
|
|
354
|
+
process.stderr.write(theme.muted(` mock port ${wanted} is in use — device "${d.id}" of "${workerName}" moved to ${port}\n`));
|
|
355
|
+
}
|
|
356
|
+
opts.port = port;
|
|
357
|
+
claimed.add(port);
|
|
358
|
+
}
|
|
359
|
+
resolved.push(opts);
|
|
360
|
+
}
|
|
361
|
+
return resolved;
|
|
362
|
+
}
|
|
363
|
+
/** Mock ports a worker's seed devices ask for — what a later boot must avoid. */
|
|
364
|
+
export function declaredMockPorts(worker) {
|
|
365
|
+
const config = worker.config;
|
|
366
|
+
if (!config.mock || !Array.isArray(config.devices))
|
|
367
|
+
return [];
|
|
368
|
+
return config.devices
|
|
369
|
+
.map((d) => d.opts?.port)
|
|
370
|
+
.filter((port) => typeof port === 'number');
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Resolves a spec `package` to its directory, the one way `run`, `status` and
|
|
374
|
+
* `add` all do it: a **local path** (`./…`, `../…` or absolute, resolved against
|
|
375
|
+
* the project dir) or an installed package, optionally with a subpath (see
|
|
376
|
+
* `resolveProjectPackageDir`). Throws naming the fix when it does not resolve.
|
|
377
|
+
*/
|
|
378
|
+
export function resolvePackageDir(projectDir, pkg) {
|
|
379
|
+
if (!isLocalPackagePath(pkg))
|
|
380
|
+
return resolveProjectPackageDir(projectDir, pkg);
|
|
381
|
+
const dir = resolve(projectDir, pkg);
|
|
382
|
+
if (!existsSync(dir)) {
|
|
383
|
+
throw new Error(`Package path "${pkg}" does not exist (looked in ${dir}).`);
|
|
384
|
+
}
|
|
385
|
+
return dir;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Resolves a worker package to its directory, and confirms it is a
|
|
389
|
+
* contract-first Worker Plugin (an `mdk-contract.json` at its root) — the only
|
|
390
|
+
* shape `mdk run worker` supports. Checked here, rather than left to surface
|
|
391
|
+
* as `WorkerRuntimeV2`'s own `ERR_CONTRACT_NOT_FOUND`/`ERR_WORKER_DIR_REQUIRED`,
|
|
392
|
+
* so a misshapen package gets a message that names the fix.
|
|
393
|
+
*/
|
|
394
|
+
function resolveWorkerContractDir(projectDir, pkg) {
|
|
395
|
+
const dir = resolvePackageDir(projectDir, pkg);
|
|
396
|
+
if (!existsSync(join(dir, WORKER_CONTRACT))) {
|
|
397
|
+
throw new Error(`Worker package "${pkg}" has no ${WORKER_CONTRACT} — mdk run only supports contract-first ` +
|
|
398
|
+
'Worker Plugins (a directory with mdk-contract.json at its root; `mdk create worker` scaffolds one).');
|
|
399
|
+
}
|
|
400
|
+
return dir;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Boots one worker on WorkerRuntimeV2, exactly the way the mvp-site example
|
|
404
|
+
* does: start a mock device server per seed device (so the plugin talks to a
|
|
405
|
+
* simulator instead of hardware), build the runtime's device set from
|
|
406
|
+
* `config.devices`, then register with the Kernel.
|
|
407
|
+
*
|
|
408
|
+
* Discovery follows the same contract as the rest of MDK: an in-process
|
|
409
|
+
* `kernel` handle registers the worker by key (target `all`); otherwise the
|
|
410
|
+
* worker publishes its RPC key to the shared keys dir for a separately-running
|
|
411
|
+
* local-mode Kernel to pick up.
|
|
412
|
+
*/
|
|
413
|
+
export async function runWorker(projectDir, worker, kernel, opts = {}) {
|
|
414
|
+
const dir = resolveWorkerContractDir(projectDir, worker.package);
|
|
415
|
+
const config = worker.config;
|
|
416
|
+
const seeds = Array.isArray(config.devices) ? config.devices : [];
|
|
417
|
+
if (seeds.length === 0) {
|
|
418
|
+
throw new Error(`Worker "${worker.name}" has no \`config.devices\` in mdk.yaml — nothing to run.`);
|
|
419
|
+
}
|
|
420
|
+
// Per-worker persistent state: plugin SQLite under storeDir, DHT/RPC seeds
|
|
421
|
+
// under storeDir/identity so the worker's public key survives restarts.
|
|
422
|
+
const storeDir = componentDir(projectDir, 'workers', worker.name);
|
|
423
|
+
mkdirSync(storeDir, { recursive: true });
|
|
424
|
+
const dbPath = join(storeDir, 'data.db');
|
|
425
|
+
const identityDir = join(storeDir, 'identity');
|
|
426
|
+
// Holds the worker's key seeds: owner-only, also for a dir an older CLI made.
|
|
427
|
+
mkdirSync(identityDir, { recursive: true, mode: 0o700 });
|
|
428
|
+
chmodSync(identityDir, 0o700);
|
|
429
|
+
process.stderr.write(`${theme.muted('Starting worker')} ${arrow} ${theme.value(worker.name)} ` +
|
|
430
|
+
`${theme.muted(`(${seeds.length} device${seeds.length === 1 ? '' : 's'})`)}\n`);
|
|
431
|
+
// Per-device options, with mock ports resolved to something actually bindable
|
|
432
|
+
// before anything starts. Both the mock and the plugin read from this same
|
|
433
|
+
// list, so they cannot disagree about which port the device is on.
|
|
434
|
+
const deviceOpts = config.mock
|
|
435
|
+
? await resolveMockPorts(worker.name, seeds, opts.reservedPorts)
|
|
436
|
+
: seeds.map((d) => ({ ...(d.opts ?? {}) }));
|
|
437
|
+
// Mock device servers — one per seed device, from `<pkg>/mock/server`.
|
|
438
|
+
const mocks = [];
|
|
439
|
+
if (config.mock) {
|
|
440
|
+
const mockPath = join(dir, 'mock', 'server.js');
|
|
441
|
+
if (!existsSync(mockPath)) {
|
|
442
|
+
throw new Error(`config.mock is set but ${worker.package} has no mock/server.js.`);
|
|
443
|
+
}
|
|
444
|
+
const mockMod = require(mockPath);
|
|
445
|
+
for (const [i, d] of seeds.entries()) {
|
|
446
|
+
const opts = deviceOpts[i];
|
|
447
|
+
try {
|
|
448
|
+
const m = mockMod.createServer(opts);
|
|
449
|
+
mocks.push(m);
|
|
450
|
+
// Wait until the mock is actually listening — the plugin's connect()
|
|
451
|
+
// probes the device on boot, so a not-yet-bound port would fail startup.
|
|
452
|
+
await awaitMockReady(m);
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
throw new Error(`Could not start the mock device for worker "${worker.name}", device "${d.id}" ` +
|
|
456
|
+
`on ${String(opts.host ?? DEFAULT_HOST)}:${String(opts.port)}: ` +
|
|
457
|
+
`${error instanceof Error ? error.message : String(error)}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Device set: `config.devices[].opts` is the plugin's own per-device config;
|
|
462
|
+
// dbPath is threaded through for the sample plugin's SQLite persistence.
|
|
463
|
+
const devices = seeds.map((d, i) => ({ deviceId: d.id, config: { ...deviceOpts[i], dbPath } }));
|
|
464
|
+
// Worker RPC is fail-closed: allow the Kernel's client key and the callers
|
|
465
|
+
// it admitted (in process), or what the Kernel and Gateway published to the
|
|
466
|
+
// trusted-keys dir (separate process), and keep following later admissions.
|
|
467
|
+
const discovery = localDiscovery();
|
|
468
|
+
const trustDir = trustedKeysDir(projectDir);
|
|
469
|
+
let allowedKeys;
|
|
470
|
+
if (kernel) {
|
|
471
|
+
const client = typeof kernel.getClientPublicKey === 'function' ? kernel.getClientPublicKey() : null;
|
|
472
|
+
if (!client)
|
|
473
|
+
throw new Error('Kernel has no client key (is HRPC enabled?) — cannot admit it on the worker.');
|
|
474
|
+
allowedKeys = [client, ...(kernel.listAllowedKeys?.('write') ?? [])];
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
const trusted = discovery.readTrustedKeys(trustDir);
|
|
478
|
+
if (!trusted.some((k) => k.name === 'kernel')) {
|
|
479
|
+
throw new Error('Kernel is not running (no kernel key in the trusted-keys dir).\nStart it first: `mdk run kernel`.');
|
|
480
|
+
}
|
|
481
|
+
allowedKeys = trusted.map((k) => k.key);
|
|
482
|
+
}
|
|
483
|
+
const { WorkerRuntimeV2 } = mdkWorker();
|
|
484
|
+
const runtime = new WorkerRuntimeV2(dir, {
|
|
485
|
+
workerId: worker.name,
|
|
486
|
+
devices,
|
|
487
|
+
env: worker.env ?? {},
|
|
488
|
+
storeDir: identityDir,
|
|
489
|
+
allowedKeys,
|
|
490
|
+
});
|
|
491
|
+
const trust = kernel ? discovery.trustKernel(runtime, kernel) : discovery.trustKeysFromDir(runtime, trustDir);
|
|
492
|
+
try {
|
|
493
|
+
await runtime.start();
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
trust.stop();
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
const rpcKeyHex = runtime.getPublicKey().toString('hex');
|
|
500
|
+
if (kernel) {
|
|
501
|
+
await kernel.registerWorker(runtime.getPublicKey());
|
|
502
|
+
if (Array.isArray(kernel._cleanup)) {
|
|
503
|
+
kernel._cleanup.push(() => {
|
|
504
|
+
trust.stop();
|
|
505
|
+
return runtime.stop();
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
discovery.publishWorkerKey(workerKeysDir(projectDir), worker.name, rpcKeyHex);
|
|
511
|
+
}
|
|
512
|
+
process.stderr.write(`${tick} ${theme.label('worker')} ${theme.value(worker.name)} ready ${theme.muted(rpcKeyHex.slice(0, 16))}\n`);
|
|
513
|
+
// Signals are owned by the `run` command (see lib/shutdown.ts), which stops
|
|
514
|
+
// every component in reverse boot order and exits even if one of them wedges.
|
|
515
|
+
const stop = async () => {
|
|
516
|
+
trust.stop();
|
|
517
|
+
await runtime.stop();
|
|
518
|
+
for (const m of mocks) {
|
|
519
|
+
try {
|
|
520
|
+
m.exit();
|
|
521
|
+
// `server.close()` only stops new connections; a keep-alive socket from
|
|
522
|
+
// the plugin's poller would hold the listener (and its port) open.
|
|
523
|
+
dropMockConnections(m);
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
/* best-effort */
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
return { runtime, mocks, stop };
|
|
531
|
+
}
|
|
532
|
+
// --- app runtimes ---------------------------------------------------------
|
|
533
|
+
/**
|
|
534
|
+
* Resolves an app package's directory — a local path (resolved against the
|
|
535
|
+
* project dir) or an installed package, optionally with a subpath — and
|
|
536
|
+
* confirms it holds an `mdk-app.yaml`.
|
|
537
|
+
*/
|
|
538
|
+
export function resolveAppPackageDir(projectDir, pkg) {
|
|
539
|
+
const dir = resolvePackageDir(projectDir, pkg);
|
|
540
|
+
if (!existsSync(join(dir, APP_MANIFEST))) {
|
|
541
|
+
throw new Error(`App package "${pkg}" has no ${APP_MANIFEST} (looked in ${dir}).`);
|
|
542
|
+
}
|
|
543
|
+
return dir;
|
|
544
|
+
}
|
|
545
|
+
/** The `createAppRuntime` options for one `spec.apps[]` entry. */
|
|
546
|
+
export function resolveAppOptions(projectDir, app, kernel) {
|
|
547
|
+
return {
|
|
548
|
+
name: app.name,
|
|
549
|
+
appDir: resolveAppPackageDir(projectDir, app.package),
|
|
550
|
+
config: app.config,
|
|
551
|
+
kernelKey: resolveKernelKey(projectDir, kernel),
|
|
552
|
+
storeDir: componentDir(projectDir, 'apps', app.name, 'store'),
|
|
553
|
+
// Read per connection by the runtime, so the Gateway and MCP server may start after the app.
|
|
554
|
+
allowedKeyFiles: [gatewayKeyFile(projectDir), mcpKeyFile(projectDir)],
|
|
555
|
+
keyFile: appKeyFile(projectDir, app.name),
|
|
556
|
+
// The Kernel is fail-closed and admits every `<name>.pub` in the trusted-keys dir.
|
|
557
|
+
kernelKeyFile: join(trustedKeysDir(projectDir), `app-${app.name}.pub`),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
/** Boots one app runtime in this process (`mdk run app <name>`); the Kernel must already be running. */
|
|
561
|
+
export async function runApp(projectDir, app) {
|
|
562
|
+
const options = resolveAppOptions(projectDir, app);
|
|
563
|
+
mkdirSync(options.storeDir, { recursive: true });
|
|
564
|
+
process.stderr.write(`${theme.muted('Starting app')} ${arrow} ${theme.value(app.name)}\n`);
|
|
565
|
+
const runtime = mdkAppRuntime().createAppRuntime(options);
|
|
566
|
+
await runtime.start();
|
|
567
|
+
process.stderr.write(`${tick} ${theme.label('app')} ${theme.value(app.name)} ready\n`);
|
|
568
|
+
return { stop: () => runtime.stop() };
|
|
569
|
+
}
|
|
570
|
+
const APP_STARTUP_TIMEOUT_MS = 30_000;
|
|
571
|
+
/** First restart delay; doubled after each crash, up to the max. */
|
|
572
|
+
const APP_RESTART_DELAY_MS = 1000;
|
|
573
|
+
const APP_RESTART_MAX_MS = 30_000;
|
|
574
|
+
/** A child that ran this long before exiting was healthy: its restart starts the backoff over. */
|
|
575
|
+
const APP_STABLE_MS = 60_000;
|
|
576
|
+
const APP_STOP_GRACE_MS = 3000;
|
|
577
|
+
/**
|
|
578
|
+
* Runs one app runtime as a child process (`mdk run`). Resolves once the child
|
|
579
|
+
* reports `ready`; rejects if it reports an `error`, exits first, or stays
|
|
580
|
+
* silent past the startup timeout. An exit `stop()` did not ask for is
|
|
581
|
+
* restarted, with a delay that doubles while the app keeps crashing. `entry`
|
|
582
|
+
* defaults to the runtime's `bin/run-app.js`.
|
|
583
|
+
*/
|
|
584
|
+
export async function superviseApp(projectDir, app, kernel, entry = require.resolve('@tetherto/mdk-app-runtime/bin/run-app.js')) {
|
|
585
|
+
const options = resolveAppOptions(projectDir, app, kernel);
|
|
586
|
+
mkdirSync(options.storeDir, { recursive: true });
|
|
587
|
+
// Resolved now: a restart runs after the CLI has chdir'd into the Gateway's state root.
|
|
588
|
+
const cwd = resolve(projectDir);
|
|
589
|
+
// The options carry the app's config, so they go in the environment, not argv.
|
|
590
|
+
const env = { ...process.env, MDK_APP_RUNTIME_OPTIONS: JSON.stringify(options) };
|
|
591
|
+
const log = (line) => process.stderr.write(`${line}\n`);
|
|
592
|
+
const childFile = appChildFile(projectDir, app.name);
|
|
593
|
+
let child;
|
|
594
|
+
let stopped = false;
|
|
595
|
+
let restartTimer;
|
|
596
|
+
let restartDelay = APP_RESTART_DELAY_MS;
|
|
597
|
+
let readyAt = 0;
|
|
598
|
+
const launch = () => new Promise((resolveReady, rejectReady) => {
|
|
599
|
+
// `cwd` is pinned because the CLI later chdirs into the Gateway's state
|
|
600
|
+
// root. `detached` keeps a terminal Ctrl+C from reaching the child: the
|
|
601
|
+
// CLI stops it, and the child exits by itself when its IPC channel closes.
|
|
602
|
+
const proc = spawn(process.execPath, [entry], {
|
|
603
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
|
604
|
+
env,
|
|
605
|
+
cwd,
|
|
606
|
+
// On Windows `detached` opens a console window per child instead.
|
|
607
|
+
detached: process.platform !== 'win32',
|
|
608
|
+
});
|
|
609
|
+
child = proc;
|
|
610
|
+
let ready = false;
|
|
611
|
+
const fail = (error) => {
|
|
612
|
+
clearTimeout(timer);
|
|
613
|
+
proc.kill('SIGKILL');
|
|
614
|
+
rejectReady(error);
|
|
615
|
+
};
|
|
616
|
+
const timer = setTimeout(() => fail(new Error(`App "${app.name}" did not report ready within ${APP_STARTUP_TIMEOUT_MS}ms.`)), APP_STARTUP_TIMEOUT_MS);
|
|
617
|
+
proc.on('message', (raw) => {
|
|
618
|
+
const msg = (raw ?? {});
|
|
619
|
+
if (ready)
|
|
620
|
+
return;
|
|
621
|
+
if (msg.type === 'ready') {
|
|
622
|
+
ready = true;
|
|
623
|
+
readyAt = Date.now();
|
|
624
|
+
clearTimeout(timer);
|
|
625
|
+
mkdirSync(dirname(childFile), { recursive: true });
|
|
626
|
+
writeFileSync(childFile, String(proc.pid));
|
|
627
|
+
resolveReady();
|
|
628
|
+
}
|
|
629
|
+
else if (msg.type === 'error') {
|
|
630
|
+
fail(new Error(msg.message ?? 'unknown error'));
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
proc.on('error', fail);
|
|
634
|
+
proc.on('exit', (code, signal) => {
|
|
635
|
+
clearTimeout(timer);
|
|
636
|
+
if (child === proc)
|
|
637
|
+
child = undefined;
|
|
638
|
+
if (appChildPid(projectDir, app.name) === proc.pid)
|
|
639
|
+
rmSync(childFile, { force: true });
|
|
640
|
+
const how = signal ? `signal ${signal}` : `code ${code}`;
|
|
641
|
+
if (!ready) {
|
|
642
|
+
rejectReady(new Error(`App "${app.name}" exited before it was ready (${how}).`));
|
|
643
|
+
}
|
|
644
|
+
else if (!stopped) {
|
|
645
|
+
if (Date.now() - readyAt >= APP_STABLE_MS)
|
|
646
|
+
restartDelay = APP_RESTART_DELAY_MS;
|
|
647
|
+
log(`${theme.warn('!')} app ${theme.value(app.name)} exited unexpectedly (${how}); ` +
|
|
648
|
+
`restarting in ${Math.round(restartDelay / 1000)}s`);
|
|
649
|
+
scheduleRestart();
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
const scheduleRestart = () => {
|
|
654
|
+
const delay = restartDelay;
|
|
655
|
+
restartDelay = Math.min(restartDelay * 2, APP_RESTART_MAX_MS);
|
|
656
|
+
restartTimer = setTimeout(() => {
|
|
657
|
+
launch().then(() => log(`${tick} ${theme.label('app')} ${theme.value(app.name)} restarted`), (error) => {
|
|
658
|
+
if (stopped)
|
|
659
|
+
return;
|
|
660
|
+
log(`${theme.warn('!')} app ${theme.value(app.name)} failed to restart: ${error.message}`);
|
|
661
|
+
scheduleRestart();
|
|
662
|
+
});
|
|
663
|
+
}, delay);
|
|
664
|
+
};
|
|
665
|
+
log(`${theme.muted('Starting app')} ${arrow} ${theme.value(app.name)}`);
|
|
666
|
+
await launch();
|
|
667
|
+
log(`${tick} ${theme.label('app')} ${theme.value(app.name)} ready`);
|
|
668
|
+
return {
|
|
669
|
+
stop: async () => {
|
|
670
|
+
stopped = true;
|
|
671
|
+
clearTimeout(restartTimer);
|
|
672
|
+
const proc = child;
|
|
673
|
+
if (!proc)
|
|
674
|
+
return;
|
|
675
|
+
const killTimer = setTimeout(() => proc.kill('SIGKILL'), APP_STOP_GRACE_MS);
|
|
676
|
+
const exited = once(proc, 'exit');
|
|
677
|
+
proc.kill('SIGTERM');
|
|
678
|
+
await exited;
|
|
679
|
+
clearTimeout(killTimer);
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
//# sourceMappingURL=runtime.js.map
|