@karmaniverous/jeeves 0.3.1 → 0.4.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/content/agents-section.md +6 -7
- package/dist/cli/jeeves/index.js +556 -239
- package/dist/cli/plugin/index.js +1003 -0
- package/dist/cli/service/index.js +916 -0
- package/dist/index.d.ts +628 -146
- package/dist/index.js +2890 -1055
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,203 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, join, resolve } from 'node:path';
|
|
1
|
+
import { writeFileSync, renameSync, existsSync, readFileSync, mkdirSync, rmSync, readdirSync, copyFileSync, unlinkSync, cpSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, resolve } from 'node:path';
|
|
4
3
|
import { lock } from 'proper-lockfile';
|
|
5
|
-
import {
|
|
4
|
+
import { JSONPath } from 'jsonpath-plus';
|
|
5
|
+
import * as commander from 'commander';
|
|
6
|
+
import { gte, gt } from 'semver';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { execSync, spawn } from 'node:child_process';
|
|
9
|
+
import { z } from 'zod';
|
|
6
10
|
import { fileURLToPath } from 'node:url';
|
|
7
11
|
import { packageDirectorySync } from 'package-directory';
|
|
8
|
-
import { z } from 'zod';
|
|
9
|
-
import { execSync } from 'node:child_process';
|
|
10
|
-
import { homedir } from 'node:os';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Generic config query handler with JSONPath support.
|
|
14
|
-
*
|
|
15
|
-
* @remarks
|
|
16
|
-
* Provides a transport-agnostic config query function that can be
|
|
17
|
-
* used by any Jeeves component's HTTP API. Returns the full config
|
|
18
|
-
* document or filters it via JSONPath expressions.
|
|
19
|
-
*/
|
|
20
|
-
/**
|
|
21
|
-
* Create a config query handler.
|
|
22
|
-
*
|
|
23
|
-
* @remarks
|
|
24
|
-
* - No `path` parameter → returns the full config document.
|
|
25
|
-
* - Valid JSONPath → returns matching results with count.
|
|
26
|
-
* - Invalid JSONPath → returns 400 error.
|
|
27
|
-
*
|
|
28
|
-
* @param getConfig - Function that returns the current config object.
|
|
29
|
-
* @returns A config query handler function.
|
|
30
|
-
*/
|
|
31
|
-
function createConfigQueryHandler(getConfig) {
|
|
32
|
-
return (query) => {
|
|
33
|
-
const config = getConfig();
|
|
34
|
-
if (!query.path) {
|
|
35
|
-
return Promise.resolve({ status: 200, body: config });
|
|
36
|
-
}
|
|
37
|
-
try {
|
|
38
|
-
const result = JSONPath({
|
|
39
|
-
path: query.path,
|
|
40
|
-
json: config,
|
|
41
|
-
});
|
|
42
|
-
return Promise.resolve({
|
|
43
|
-
status: 200,
|
|
44
|
-
body: { result, count: result.length },
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
catch (error) {
|
|
48
|
-
const message = error instanceof Error ? error.message : 'Query failed';
|
|
49
|
-
return Promise.resolve({ status: 400, body: { error: message } });
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Directory and file path conventions for the Jeeves platform.
|
|
56
|
-
*/
|
|
57
|
-
/** Core config directory name within the config root. */
|
|
58
|
-
const CORE_CONFIG_DIR = 'jeeves-core';
|
|
59
|
-
/** Prefix for component config directories: `jeeves-{name}`. */
|
|
60
|
-
const COMPONENT_CONFIG_PREFIX = 'jeeves-';
|
|
61
|
-
/** Default workspace file names. */
|
|
62
|
-
const WORKSPACE_FILES = {
|
|
63
|
-
/** TOOLS.md — live platform state and component sections. */
|
|
64
|
-
tools: 'TOOLS.md',
|
|
65
|
-
/** SOUL.md — professional discipline and behavioral foundations. */
|
|
66
|
-
soul: 'SOUL.md',
|
|
67
|
-
/** AGENTS.md — operational protocols and memory architecture. */
|
|
68
|
-
agents: 'AGENTS.md',
|
|
69
|
-
};
|
|
70
|
-
/** Templates directory name within core config. */
|
|
71
|
-
const TEMPLATES_DIR = 'templates';
|
|
72
|
-
/** Registry cache file name. */
|
|
73
|
-
const REGISTRY_CACHE_FILE = 'registry-cache.json';
|
|
74
|
-
/** Core config file name. */
|
|
75
|
-
const CONFIG_FILE = 'config.json';
|
|
76
|
-
/** Component versions state file name. */
|
|
77
|
-
const COMPONENT_VERSIONS_FILE = 'component-versions.json';
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Core library version, inlined at build time.
|
|
81
|
-
*
|
|
82
|
-
* @remarks
|
|
83
|
-
* The `0.3.0` placeholder is replaced by
|
|
84
|
-
* `@rollup/plugin-replace` during the build with the actual version
|
|
85
|
-
* from `package.json`. This ensures the correct version survives
|
|
86
|
-
* when consumers bundle core into their own dist (where runtime
|
|
87
|
-
* `import.meta.url`-based resolution would find the wrong package.json).
|
|
88
|
-
*/
|
|
89
|
-
/** The core library version from package.json (inlined at build time). */
|
|
90
|
-
const CORE_VERSION = '0.3.0';
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Shared file I/O helpers for managed section operations.
|
|
94
|
-
*
|
|
95
|
-
* @remarks
|
|
96
|
-
* Extracts the atomic write pattern and file-level locking into
|
|
97
|
-
* reusable utilities, eliminating duplication between
|
|
98
|
-
* `updateManagedSection` and `removeManagedSection`.
|
|
99
|
-
*/
|
|
100
|
-
/** Stale lock threshold in ms (2 minutes). */
|
|
101
|
-
const STALE_LOCK_MS = 120_000;
|
|
102
|
-
/** Default core version when none provided. */
|
|
103
|
-
const DEFAULT_CORE_VERSION = CORE_VERSION;
|
|
104
|
-
/** Lock retry options. */
|
|
105
|
-
const LOCK_RETRIES = { retries: 5, minTimeout: 100, maxTimeout: 1000 };
|
|
106
|
-
/**
|
|
107
|
-
* Write content to a file atomically via a temp file + rename.
|
|
108
|
-
*
|
|
109
|
-
* @param filePath - Absolute path to the target file.
|
|
110
|
-
* @param content - Content to write.
|
|
111
|
-
*/
|
|
112
|
-
function atomicWrite(filePath, content) {
|
|
113
|
-
const dir = dirname(filePath);
|
|
114
|
-
const tempPath = join(dir, `.${String(Date.now())}.tmp`);
|
|
115
|
-
writeFileSync(tempPath, content, 'utf-8');
|
|
116
|
-
renameSync(tempPath, filePath);
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Execute a callback while holding a file lock.
|
|
120
|
-
*
|
|
121
|
-
* @remarks
|
|
122
|
-
* Acquires a lock on the file, executes the callback, and releases
|
|
123
|
-
* the lock in a finally block. The lock uses a 2-minute stale threshold
|
|
124
|
-
* and retries up to 5 times.
|
|
125
|
-
*
|
|
126
|
-
* @param filePath - Absolute path to the file to lock.
|
|
127
|
-
* @param fn - Async callback to execute while holding the lock.
|
|
128
|
-
*/
|
|
129
|
-
async function withFileLock(filePath, fn) {
|
|
130
|
-
let release;
|
|
131
|
-
try {
|
|
132
|
-
release = await lock(filePath, {
|
|
133
|
-
stale: STALE_LOCK_MS,
|
|
134
|
-
retries: LOCK_RETRIES,
|
|
135
|
-
});
|
|
136
|
-
await fn();
|
|
137
|
-
}
|
|
138
|
-
finally {
|
|
139
|
-
if (release) {
|
|
140
|
-
try {
|
|
141
|
-
await release();
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
// Lock already released or file deleted — safe to ignore
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Shared component version state file management.
|
|
152
|
-
*
|
|
153
|
-
* @remarks
|
|
154
|
-
* Each `ComponentWriter` cycle writes its component's entry to
|
|
155
|
-
* `{coreConfigDir}/component-versions.json`. The Platform Handlebars
|
|
156
|
-
* template reads this file to populate ALL rows in the service health
|
|
157
|
-
* table, not just the calling component's.
|
|
158
|
-
*/
|
|
159
|
-
/**
|
|
160
|
-
* Read the component versions state file.
|
|
161
|
-
*
|
|
162
|
-
* @param coreConfigDir - Path to the core config directory.
|
|
163
|
-
* @returns The parsed state, or an empty object if the file doesn't exist.
|
|
164
|
-
*/
|
|
165
|
-
function readComponentVersions(coreConfigDir) {
|
|
166
|
-
const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
|
|
167
|
-
if (!existsSync(filePath))
|
|
168
|
-
return {};
|
|
169
|
-
try {
|
|
170
|
-
const raw = readFileSync(filePath, 'utf-8');
|
|
171
|
-
return JSON.parse(raw);
|
|
172
|
-
}
|
|
173
|
-
catch {
|
|
174
|
-
return {};
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* Write a component's version entry to the shared state file.
|
|
179
|
-
*
|
|
180
|
-
* @remarks
|
|
181
|
-
* Reads the existing file, merges the new entry, and writes atomically.
|
|
182
|
-
*
|
|
183
|
-
* @param coreConfigDir - Path to the core config directory.
|
|
184
|
-
* @param options - Component version data to write.
|
|
185
|
-
*/
|
|
186
|
-
function writeComponentVersion(coreConfigDir, options) {
|
|
187
|
-
const existing = readComponentVersions(coreConfigDir);
|
|
188
|
-
existing[options.componentName] = {
|
|
189
|
-
pluginVersion: options.pluginVersion,
|
|
190
|
-
servicePackage: options.servicePackage,
|
|
191
|
-
pluginPackage: options.pluginPackage,
|
|
192
|
-
updatedAt: new Date().toISOString(),
|
|
193
|
-
};
|
|
194
|
-
const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
|
|
195
|
-
const dir = dirname(filePath);
|
|
196
|
-
if (!existsSync(dir)) {
|
|
197
|
-
mkdirSync(dir, { recursive: true });
|
|
198
|
-
}
|
|
199
|
-
atomicWrite(filePath, JSON.stringify(existing, null, 2) + '\n');
|
|
200
|
-
}
|
|
201
12
|
|
|
202
13
|
/**
|
|
203
14
|
* Comment markers for managed content blocks.
|
|
@@ -216,6 +27,8 @@ const TOOLS_MARKERS = {
|
|
|
216
27
|
end: 'END JEEVES PLATFORM TOOLS',
|
|
217
28
|
/** H1 title prepended in section mode. */
|
|
218
29
|
title: 'Jeeves Platform Tools',
|
|
30
|
+
/** Managed block at bottom of file. */
|
|
31
|
+
position: 'bottom',
|
|
219
32
|
};
|
|
220
33
|
/** Default markers for SOUL.md managed block. */
|
|
221
34
|
const SOUL_MARKERS = {
|
|
@@ -225,6 +38,8 @@ const SOUL_MARKERS = {
|
|
|
225
38
|
end: 'END JEEVES SOUL',
|
|
226
39
|
/** H1 title prepended in the managed block. */
|
|
227
40
|
title: 'Jeeves Platform Soul',
|
|
41
|
+
/** Managed block at bottom of file. */
|
|
42
|
+
position: 'bottom',
|
|
228
43
|
};
|
|
229
44
|
/** Default markers for AGENTS.md managed block. */
|
|
230
45
|
const AGENTS_MARKERS = {
|
|
@@ -234,6 +49,8 @@ const AGENTS_MARKERS = {
|
|
|
234
49
|
end: 'END JEEVES AGENTS',
|
|
235
50
|
/** H1 title prepended in the managed block. */
|
|
236
51
|
title: 'Jeeves Platform Agents',
|
|
52
|
+
/** Managed block at bottom of file. */
|
|
53
|
+
position: 'bottom',
|
|
237
54
|
};
|
|
238
55
|
/** All known marker sets — single source of truth for cross-contamination detection. */
|
|
239
56
|
const ALL_MARKERS = [
|
|
@@ -254,6 +71,33 @@ const STALENESS_THRESHOLD_MS = 5 * 60 * 1000;
|
|
|
254
71
|
/** Warning text prepended inside managed block when cleanup is needed. */
|
|
255
72
|
const CLEANUP_FLAG = '> ⚠️ CLEANUP NEEDED: Orphaned Jeeves content may exist below this managed section. Review everything after the END marker and remove any content that duplicates what appears above.';
|
|
256
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Directory and file path conventions for the Jeeves platform.
|
|
76
|
+
*/
|
|
77
|
+
/** Core config directory name within the config root. */
|
|
78
|
+
const CORE_CONFIG_DIR = 'jeeves-core';
|
|
79
|
+
/** Prefix for component config directories: `jeeves-{name}`. */
|
|
80
|
+
const COMPONENT_CONFIG_PREFIX = 'jeeves-';
|
|
81
|
+
/** Default workspace file names. */
|
|
82
|
+
const WORKSPACE_FILES = {
|
|
83
|
+
/** TOOLS.md — live platform state and component sections. */
|
|
84
|
+
tools: 'TOOLS.md',
|
|
85
|
+
/** SOUL.md — professional discipline and behavioral foundations. */
|
|
86
|
+
soul: 'SOUL.md',
|
|
87
|
+
/** AGENTS.md — operational protocols and memory architecture. */
|
|
88
|
+
agents: 'AGENTS.md',
|
|
89
|
+
/** HEARTBEAT.md — platform status and health alerts. */
|
|
90
|
+
heartbeat: 'HEARTBEAT.md',
|
|
91
|
+
};
|
|
92
|
+
/** Templates directory name within core config. */
|
|
93
|
+
const TEMPLATES_DIR = 'templates';
|
|
94
|
+
/** Registry cache file name. */
|
|
95
|
+
const REGISTRY_CACHE_FILE = 'registry-cache.json';
|
|
96
|
+
/** Core config file name. */
|
|
97
|
+
const CONFIG_FILE = 'config.json';
|
|
98
|
+
/** Component versions state file name. */
|
|
99
|
+
const COMPONENT_VERSIONS_FILE = 'component-versions.json';
|
|
100
|
+
|
|
257
101
|
/**
|
|
258
102
|
* Default port assignments for Jeeves platform services.
|
|
259
103
|
*
|
|
@@ -281,7 +125,7 @@ const DEFAULT_PORTS = {
|
|
|
281
125
|
};
|
|
282
126
|
|
|
283
127
|
/**
|
|
284
|
-
* Managed section IDs
|
|
128
|
+
* Managed section IDs, stable ordering, and platform component registry.
|
|
285
129
|
*
|
|
286
130
|
* @remarks
|
|
287
131
|
* Section ordering is fixed to prevent diff churn regardless of which
|
|
@@ -311,6 +155,35 @@ const SECTION_ORDER = [
|
|
|
311
155
|
SECTION_IDS.Runner,
|
|
312
156
|
SECTION_IDS.Meta,
|
|
313
157
|
];
|
|
158
|
+
/**
|
|
159
|
+
* The four essential platform components.
|
|
160
|
+
*
|
|
161
|
+
* @remarks
|
|
162
|
+
* These components constitute the Jeeves platform. `jeeves install` writes
|
|
163
|
+
* initial HEARTBEAT "Not installed" alerts for all of them. The HEARTBEAT
|
|
164
|
+
* writer generates "Not installed" alerts only for platform components not
|
|
165
|
+
* in `component-versions.json`. Optional future components (not in this list)
|
|
166
|
+
* appear in HEARTBEAT only after explicit install.
|
|
167
|
+
*/
|
|
168
|
+
const PLATFORM_COMPONENTS = [
|
|
169
|
+
'runner',
|
|
170
|
+
'watcher',
|
|
171
|
+
'server',
|
|
172
|
+
'meta',
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Core library version, inlined at build time.
|
|
177
|
+
*
|
|
178
|
+
* @remarks
|
|
179
|
+
* The `0.4.0` placeholder is replaced by
|
|
180
|
+
* `@rollup/plugin-replace` during the build with the actual version
|
|
181
|
+
* from `package.json`. This ensures the correct version survives
|
|
182
|
+
* when consumers bundle core into their own dist (where runtime
|
|
183
|
+
* `import.meta.url`-based resolution would find the wrong package.json).
|
|
184
|
+
*/
|
|
185
|
+
/** The core library version from package.json (inlined at build time). */
|
|
186
|
+
const CORE_VERSION = '0.4.0';
|
|
314
187
|
|
|
315
188
|
/**
|
|
316
189
|
* Workspace and config root initialization.
|
|
@@ -395,20 +268,1996 @@ function resetInit() {
|
|
|
395
268
|
}
|
|
396
269
|
|
|
397
270
|
/**
|
|
398
|
-
*
|
|
271
|
+
* Shared file I/O helpers for managed section operations.
|
|
399
272
|
*
|
|
400
273
|
* @remarks
|
|
401
|
-
*
|
|
402
|
-
*
|
|
274
|
+
* Extracts the atomic write pattern and file-level locking into
|
|
275
|
+
* reusable utilities, eliminating duplication between
|
|
276
|
+
* `updateManagedSection` and `removeManagedSection`.
|
|
403
277
|
*/
|
|
404
|
-
/**
|
|
405
|
-
const
|
|
278
|
+
/** Stale lock threshold in ms (2 minutes). */
|
|
279
|
+
const STALE_LOCK_MS = 120_000;
|
|
280
|
+
/** Default core version when none provided. */
|
|
281
|
+
const DEFAULT_CORE_VERSION = CORE_VERSION;
|
|
282
|
+
/** Lock retry options. */
|
|
283
|
+
const LOCK_RETRIES = { retries: 5, minTimeout: 100, maxTimeout: 1000 };
|
|
406
284
|
/**
|
|
407
|
-
*
|
|
285
|
+
* Write content to a file atomically via a temp file + rename.
|
|
408
286
|
*
|
|
409
|
-
* @param
|
|
410
|
-
* @param
|
|
411
|
-
|
|
287
|
+
* @param filePath - Absolute path to the target file.
|
|
288
|
+
* @param content - Content to write.
|
|
289
|
+
*/
|
|
290
|
+
function atomicWrite(filePath, content) {
|
|
291
|
+
const dir = dirname(filePath);
|
|
292
|
+
const tempPath = join(dir, `.${String(Date.now())}.tmp`);
|
|
293
|
+
writeFileSync(tempPath, content, 'utf-8');
|
|
294
|
+
renameSync(tempPath, filePath);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Execute a callback while holding a file lock.
|
|
298
|
+
*
|
|
299
|
+
* @remarks
|
|
300
|
+
* Acquires a lock on the file, executes the callback, and releases
|
|
301
|
+
* the lock in a finally block. The lock uses a 2-minute stale threshold
|
|
302
|
+
* and retries up to 5 times.
|
|
303
|
+
*
|
|
304
|
+
* @param filePath - Absolute path to the file to lock.
|
|
305
|
+
* @param fn - Async callback to execute while holding the lock.
|
|
306
|
+
*/
|
|
307
|
+
async function withFileLock(filePath, fn) {
|
|
308
|
+
let release;
|
|
309
|
+
try {
|
|
310
|
+
release = await lock(filePath, {
|
|
311
|
+
stale: STALE_LOCK_MS,
|
|
312
|
+
retries: LOCK_RETRIES,
|
|
313
|
+
});
|
|
314
|
+
await fn();
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
if (release) {
|
|
318
|
+
try {
|
|
319
|
+
await release();
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// Lock already released or file deleted — safe to ignore
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Factory for a framework-agnostic config apply HTTP handler.
|
|
330
|
+
*
|
|
331
|
+
* @remarks
|
|
332
|
+
* Derives the config file path from the descriptor, validates patches
|
|
333
|
+
* against the descriptor's Zod schema, deep-merges (or replaces),
|
|
334
|
+
* writes atomically, and calls the optional `onConfigApply` callback.
|
|
335
|
+
*/
|
|
336
|
+
/**
|
|
337
|
+
* Deep-merge two plain objects. Arrays and non-objects are replaced.
|
|
338
|
+
*
|
|
339
|
+
* @param target - Base object.
|
|
340
|
+
* @param source - Object to merge on top.
|
|
341
|
+
* @returns A new merged object.
|
|
342
|
+
*/
|
|
343
|
+
function deepMerge(target, source) {
|
|
344
|
+
const result = { ...target };
|
|
345
|
+
for (const key of Object.keys(source)) {
|
|
346
|
+
const tVal = target[key];
|
|
347
|
+
const sVal = source[key];
|
|
348
|
+
if (isPlainObject(tVal) && isPlainObject(sVal)) {
|
|
349
|
+
result[key] = deepMerge(tVal, sVal);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
result[key] = sVal;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Check if a value is a plain object (not null, not an array).
|
|
359
|
+
*
|
|
360
|
+
* @param val - Value to check.
|
|
361
|
+
* @returns True if the value is a plain object.
|
|
362
|
+
*/
|
|
363
|
+
function isPlainObject(val) {
|
|
364
|
+
return typeof val === 'object' && val !== null && !Array.isArray(val);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Read and parse a JSON config file.
|
|
368
|
+
*
|
|
369
|
+
* @param filePath - Absolute path to the file.
|
|
370
|
+
* @returns Parsed object or empty object if not found.
|
|
371
|
+
*/
|
|
372
|
+
function readConfigFile(filePath) {
|
|
373
|
+
if (!existsSync(filePath))
|
|
374
|
+
return {};
|
|
375
|
+
try {
|
|
376
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
377
|
+
return JSON.parse(raw);
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
381
|
+
console.warn(`jeeves-core: Could not read config file ${filePath}: ${msg}`);
|
|
382
|
+
return {};
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Create a framework-agnostic config apply handler.
|
|
387
|
+
*
|
|
388
|
+
* @remarks
|
|
389
|
+
* The handler:
|
|
390
|
+
* 1. Reads existing config from `{configRoot}/jeeves-{name}/{configFileName}`
|
|
391
|
+
* 2. Deep-merges the patch (or replaces if `replace: true`)
|
|
392
|
+
* 3. Validates the merged result against `descriptor.configSchema`
|
|
393
|
+
* 4. Writes atomically
|
|
394
|
+
* 5. Calls `descriptor.onConfigApply` with the merged config (if defined)
|
|
395
|
+
*
|
|
396
|
+
* @param descriptor - The component descriptor.
|
|
397
|
+
* @returns An async handler returning `{ status, body }`.
|
|
398
|
+
*/
|
|
399
|
+
function createConfigApplyHandler(descriptor) {
|
|
400
|
+
return async (request) => {
|
|
401
|
+
const { patch, replace } = request;
|
|
402
|
+
// Derive config path
|
|
403
|
+
const configDir = getComponentConfigDir(descriptor.name);
|
|
404
|
+
const configPath = join(configDir, descriptor.configFileName);
|
|
405
|
+
// Read existing config
|
|
406
|
+
const existing = readConfigFile(configPath);
|
|
407
|
+
// Merge or replace
|
|
408
|
+
const merged = replace ? { ...patch } : deepMerge(existing, patch);
|
|
409
|
+
// Validate against schema
|
|
410
|
+
const schema = descriptor.configSchema;
|
|
411
|
+
const parseResult = schema.safeParse(merged);
|
|
412
|
+
if (!parseResult.success) {
|
|
413
|
+
return {
|
|
414
|
+
status: 400,
|
|
415
|
+
body: {
|
|
416
|
+
error: 'Config validation failed',
|
|
417
|
+
issues: parseResult.error.issues,
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
// Extract validated data (Zod returns unknown from ZodTypeAny)
|
|
422
|
+
const validatedConfig = parseResult.data;
|
|
423
|
+
// Write atomically
|
|
424
|
+
try {
|
|
425
|
+
const json = JSON.stringify(validatedConfig, null, 2) + '\n';
|
|
426
|
+
atomicWrite(configPath, json);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
430
|
+
return {
|
|
431
|
+
status: 500,
|
|
432
|
+
body: { error: `Failed to write config: ${message}` },
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
// Call onConfigApply callback if defined
|
|
436
|
+
if (descriptor.onConfigApply) {
|
|
437
|
+
try {
|
|
438
|
+
await descriptor.onConfigApply(validatedConfig);
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
442
|
+
return {
|
|
443
|
+
status: 200,
|
|
444
|
+
body: {
|
|
445
|
+
applied: true,
|
|
446
|
+
warning: `Config written but callback failed: ${message}`,
|
|
447
|
+
config: validatedConfig,
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
status: 200,
|
|
454
|
+
body: {
|
|
455
|
+
applied: true,
|
|
456
|
+
config: validatedConfig,
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Generic config query handler with JSONPath support.
|
|
464
|
+
*
|
|
465
|
+
* @remarks
|
|
466
|
+
* Provides a transport-agnostic config query function that can be
|
|
467
|
+
* used by any Jeeves component's HTTP API. Returns the full config
|
|
468
|
+
* document or filters it via JSONPath expressions.
|
|
469
|
+
*/
|
|
470
|
+
/**
|
|
471
|
+
* Create a config query handler.
|
|
472
|
+
*
|
|
473
|
+
* @remarks
|
|
474
|
+
* - No `path` parameter → returns the full config document.
|
|
475
|
+
* - Valid JSONPath → returns matching results with count.
|
|
476
|
+
* - Invalid JSONPath → returns 400 error.
|
|
477
|
+
*
|
|
478
|
+
* @param getConfig - Function that returns the current config object.
|
|
479
|
+
* @returns A config query handler function.
|
|
480
|
+
*/
|
|
481
|
+
function createConfigQueryHandler(getConfig) {
|
|
482
|
+
return (query) => {
|
|
483
|
+
const config = getConfig();
|
|
484
|
+
if (!query.path) {
|
|
485
|
+
return Promise.resolve({ status: 200, body: config });
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
const result = JSONPath({
|
|
489
|
+
path: query.path,
|
|
490
|
+
json: config,
|
|
491
|
+
});
|
|
492
|
+
return Promise.resolve({
|
|
493
|
+
status: 200,
|
|
494
|
+
body: { result, count: result.length },
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
const message = error instanceof Error ? error.message : 'Query failed';
|
|
499
|
+
return Promise.resolve({ status: 400, body: { error: message } });
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Factory for a framework-agnostic `/status` HTTP handler.
|
|
506
|
+
*
|
|
507
|
+
* @remarks
|
|
508
|
+
* Returns a standard status response shape consumed by HEARTBEAT
|
|
509
|
+
* orchestration and the `{name}_status` plugin tool.
|
|
510
|
+
* Tracks process start time internally for uptime calculation.
|
|
511
|
+
*/
|
|
512
|
+
/**
|
|
513
|
+
* Create a framework-agnostic status handler.
|
|
514
|
+
*
|
|
515
|
+
* @param options - Handler configuration.
|
|
516
|
+
* @returns An async function returning `{ status, body }`.
|
|
517
|
+
*/
|
|
518
|
+
function createStatusHandler(options) {
|
|
519
|
+
const startTime = Date.now();
|
|
520
|
+
return async () => {
|
|
521
|
+
const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000);
|
|
522
|
+
let health = {};
|
|
523
|
+
let overallStatus = 'healthy';
|
|
524
|
+
if (options.getHealth) {
|
|
525
|
+
try {
|
|
526
|
+
health = await options.getHealth();
|
|
527
|
+
}
|
|
528
|
+
catch (err) {
|
|
529
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
530
|
+
health = { error: message };
|
|
531
|
+
overallStatus = 'degraded';
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
status: 200,
|
|
536
|
+
body: {
|
|
537
|
+
name: options.name,
|
|
538
|
+
version: options.version,
|
|
539
|
+
uptime: uptimeSeconds,
|
|
540
|
+
status: overallStatus,
|
|
541
|
+
health,
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function getDefaultExportFromCjs (x) {
|
|
548
|
+
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function getAugmentedNamespace(n) {
|
|
552
|
+
if (Object.prototype.hasOwnProperty.call(n, '__esModule')) return n;
|
|
553
|
+
var f = n.default;
|
|
554
|
+
if (typeof f == "function") {
|
|
555
|
+
var a = function a () {
|
|
556
|
+
var isInstance = false;
|
|
557
|
+
try {
|
|
558
|
+
isInstance = this instanceof a;
|
|
559
|
+
} catch {}
|
|
560
|
+
if (isInstance) {
|
|
561
|
+
return Reflect.construct(f, arguments, this.constructor);
|
|
562
|
+
}
|
|
563
|
+
return f.apply(this, arguments);
|
|
564
|
+
};
|
|
565
|
+
a.prototype = f.prototype;
|
|
566
|
+
} else a = {};
|
|
567
|
+
Object.defineProperty(a, '__esModule', {value: true});
|
|
568
|
+
Object.keys(n).forEach(function (k) {
|
|
569
|
+
var d = Object.getOwnPropertyDescriptor(n, k);
|
|
570
|
+
Object.defineProperty(a, k, d.get ? d : {
|
|
571
|
+
enumerable: true,
|
|
572
|
+
get: function () {
|
|
573
|
+
return n[k];
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
return a;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
var extraTypings = {exports: {}};
|
|
581
|
+
|
|
582
|
+
var require$$0 = /*@__PURE__*/getAugmentedNamespace(commander);
|
|
583
|
+
|
|
584
|
+
var hasRequiredExtraTypings;
|
|
585
|
+
|
|
586
|
+
function requireExtraTypings () {
|
|
587
|
+
if (hasRequiredExtraTypings) return extraTypings.exports;
|
|
588
|
+
hasRequiredExtraTypings = 1;
|
|
589
|
+
(function (module, exports$1) {
|
|
590
|
+
const commander = require$$0;
|
|
591
|
+
|
|
592
|
+
exports$1 = module.exports = {};
|
|
593
|
+
|
|
594
|
+
// Return a different global program than commander,
|
|
595
|
+
// and don't also return it as default export.
|
|
596
|
+
exports$1.program = new commander.Command();
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Expose classes. The FooT versions are just types, so return Commander original implementations!
|
|
600
|
+
*/
|
|
601
|
+
|
|
602
|
+
exports$1.Argument = commander.Argument;
|
|
603
|
+
exports$1.Command = commander.Command;
|
|
604
|
+
exports$1.CommanderError = commander.CommanderError;
|
|
605
|
+
exports$1.Help = commander.Help;
|
|
606
|
+
exports$1.InvalidArgumentError = commander.InvalidArgumentError;
|
|
607
|
+
exports$1.InvalidOptionArgumentError = commander.InvalidArgumentError; // Deprecated
|
|
608
|
+
exports$1.Option = commander.Option;
|
|
609
|
+
|
|
610
|
+
exports$1.createCommand = (name) => new commander.Command(name);
|
|
611
|
+
exports$1.createOption = (flags, description) =>
|
|
612
|
+
new commander.Option(flags, description);
|
|
613
|
+
exports$1.createArgument = (name, description) =>
|
|
614
|
+
new commander.Argument(name, description);
|
|
615
|
+
} (extraTypings, extraTypings.exports));
|
|
616
|
+
return extraTypings.exports;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
var extraTypingsExports = requireExtraTypings();
|
|
620
|
+
var extraTypingsCommander = /*@__PURE__*/getDefaultExportFromCjs(extraTypingsExports);
|
|
621
|
+
|
|
622
|
+
// wrapper to provide named exports for ESM.
|
|
623
|
+
const {
|
|
624
|
+
program,
|
|
625
|
+
createCommand,
|
|
626
|
+
createArgument,
|
|
627
|
+
createOption,
|
|
628
|
+
CommanderError,
|
|
629
|
+
InvalidArgumentError,
|
|
630
|
+
InvalidOptionArgumentError, // deprecated old name
|
|
631
|
+
Command,
|
|
632
|
+
Argument,
|
|
633
|
+
Option,
|
|
634
|
+
Help,
|
|
635
|
+
} = extraTypingsCommander;
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Shared component version state file management.
|
|
639
|
+
*
|
|
640
|
+
* @remarks
|
|
641
|
+
* Each `ComponentWriter` cycle writes its component's entry to
|
|
642
|
+
* `{coreConfigDir}/component-versions.json`. The Platform Handlebars
|
|
643
|
+
* template reads this file to populate ALL rows in the service health
|
|
644
|
+
* table, not just the calling component's.
|
|
645
|
+
*/
|
|
646
|
+
/**
|
|
647
|
+
* Read the component versions state file.
|
|
648
|
+
*
|
|
649
|
+
* @param coreConfigDir - Path to the core config directory.
|
|
650
|
+
* @returns The parsed state, or an empty object if the file doesn't exist.
|
|
651
|
+
*/
|
|
652
|
+
function readComponentVersions(coreConfigDir) {
|
|
653
|
+
const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
|
|
654
|
+
if (!existsSync(filePath))
|
|
655
|
+
return {};
|
|
656
|
+
try {
|
|
657
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
658
|
+
return JSON.parse(raw);
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
return {};
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Write a component's version entry to the shared state file.
|
|
666
|
+
*
|
|
667
|
+
* @remarks
|
|
668
|
+
* Reads the existing file, merges the new entry, and writes atomically.
|
|
669
|
+
*
|
|
670
|
+
* @param coreConfigDir - Path to the core config directory.
|
|
671
|
+
* @param options - Component version data to write.
|
|
672
|
+
*/
|
|
673
|
+
function writeComponentVersion(coreConfigDir, options) {
|
|
674
|
+
const existing = readComponentVersions(coreConfigDir);
|
|
675
|
+
existing[options.componentName] = {
|
|
676
|
+
pluginVersion: options.pluginVersion,
|
|
677
|
+
servicePackage: options.servicePackage,
|
|
678
|
+
pluginPackage: options.pluginPackage,
|
|
679
|
+
updatedAt: new Date().toISOString(),
|
|
680
|
+
};
|
|
681
|
+
const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
|
|
682
|
+
const dir = dirname(filePath);
|
|
683
|
+
if (!existsSync(dir)) {
|
|
684
|
+
mkdirSync(dir, { recursive: true });
|
|
685
|
+
}
|
|
686
|
+
atomicWrite(filePath, JSON.stringify(existing, null, 2) + '\n');
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Remove a component's version entry from the shared state file.
|
|
690
|
+
*
|
|
691
|
+
* @remarks
|
|
692
|
+
* Called during plugin uninstall to prevent the HEARTBEAT writer from
|
|
693
|
+
* probing a service that's intentionally gone. If the component isn't
|
|
694
|
+
* in the file, this is a no-op.
|
|
695
|
+
*
|
|
696
|
+
* @param coreConfigDir - Path to the core config directory.
|
|
697
|
+
* @param componentName - The component name to remove.
|
|
698
|
+
*/
|
|
699
|
+
function removeComponentVersion(coreConfigDir, componentName) {
|
|
700
|
+
const existing = readComponentVersions(coreConfigDir);
|
|
701
|
+
if (!(componentName in existing))
|
|
702
|
+
return;
|
|
703
|
+
const updated = Object.fromEntries(Object.entries(existing).filter(([key]) => key !== componentName));
|
|
704
|
+
const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
|
|
705
|
+
atomicWrite(filePath, JSON.stringify(updated, null, 2) + '\n');
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Heading-based HEARTBEAT section writer.
|
|
710
|
+
*
|
|
711
|
+
* @remarks
|
|
712
|
+
* Manages the `# Jeeves Platform Status` section in HEARTBEAT.md.
|
|
713
|
+
* Unlike TOOLS/SOUL/AGENTS (which use HTML comment markers), HEARTBEAT
|
|
714
|
+
* uses markdown headings as markers — this ensures the file passes
|
|
715
|
+
* OpenClaw's heartbeat emptiness check when only headings remain.
|
|
716
|
+
*
|
|
717
|
+
* The section is always at the bottom of the file (H1 to EOF).
|
|
718
|
+
* User heartbeat items above the section are preserved.
|
|
719
|
+
*/
|
|
720
|
+
/** The H1 heading that anchors the platform status section. */
|
|
721
|
+
const HEARTBEAT_HEADING = '# Jeeves Platform Status';
|
|
722
|
+
/**
|
|
723
|
+
* Parse the HEARTBEAT.md file content.
|
|
724
|
+
*
|
|
725
|
+
* @param fileContent - Full file content.
|
|
726
|
+
* @returns Parsed result with user zone and component entries.
|
|
727
|
+
*/
|
|
728
|
+
function parseHeartbeat(fileContent) {
|
|
729
|
+
const headingIndex = fileContent.indexOf(HEARTBEAT_HEADING);
|
|
730
|
+
if (headingIndex === -1) {
|
|
731
|
+
return {
|
|
732
|
+
userContent: fileContent.trim(),
|
|
733
|
+
found: false,
|
|
734
|
+
entries: [],
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
const userContent = fileContent.slice(0, headingIndex).trim();
|
|
738
|
+
const sectionContent = fileContent.slice(headingIndex + HEARTBEAT_HEADING.length);
|
|
739
|
+
const entries = [];
|
|
740
|
+
const h2Re = /^## (jeeves-\S+?)(?:: declined)?$/gm;
|
|
741
|
+
let match;
|
|
742
|
+
const h2Positions = [];
|
|
743
|
+
while ((match = h2Re.exec(sectionContent)) !== null) {
|
|
744
|
+
const fullHeading = match[0];
|
|
745
|
+
const name = match[1];
|
|
746
|
+
const declined = fullHeading.endsWith(': declined');
|
|
747
|
+
h2Positions.push({ name, declined, start: match.index });
|
|
748
|
+
}
|
|
749
|
+
for (let i = 0; i < h2Positions.length; i++) {
|
|
750
|
+
const pos = h2Positions[i];
|
|
751
|
+
const headingLine = pos.declined
|
|
752
|
+
? `## ${pos.name}: declined`
|
|
753
|
+
: `## ${pos.name}`;
|
|
754
|
+
const contentStart = pos.start + headingLine.length;
|
|
755
|
+
const contentEnd = i + 1 < h2Positions.length
|
|
756
|
+
? h2Positions[i + 1].start
|
|
757
|
+
: sectionContent.length;
|
|
758
|
+
const content = sectionContent.slice(contentStart, contentEnd).trim();
|
|
759
|
+
entries.push({
|
|
760
|
+
name: pos.name,
|
|
761
|
+
declined: pos.declined,
|
|
762
|
+
content,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
return { userContent, found: true, entries };
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Build the HEARTBEAT section content from entries.
|
|
769
|
+
*
|
|
770
|
+
* @param entries - Component entries to write.
|
|
771
|
+
* @returns The full section string (H1 + H2s).
|
|
772
|
+
*/
|
|
773
|
+
function buildHeartbeatSection(entries) {
|
|
774
|
+
const parts = [HEARTBEAT_HEADING];
|
|
775
|
+
for (const entry of entries) {
|
|
776
|
+
if (entry.declined) {
|
|
777
|
+
parts.push(`## ${entry.name}: declined`);
|
|
778
|
+
}
|
|
779
|
+
else if (entry.content) {
|
|
780
|
+
parts.push(`## ${entry.name}`);
|
|
781
|
+
parts.push(entry.content);
|
|
782
|
+
}
|
|
783
|
+
// Healthy components (no content, not declined) get no H2 section
|
|
784
|
+
}
|
|
785
|
+
return parts.join('\n');
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Write the HEARTBEAT section to a file.
|
|
789
|
+
*
|
|
790
|
+
* @remarks
|
|
791
|
+
* Replaces everything from `# Jeeves Platform Status` to EOF.
|
|
792
|
+
* Preserves user content above the heading. Uses file-level locking.
|
|
793
|
+
*
|
|
794
|
+
* @param filePath - Absolute path to HEARTBEAT.md.
|
|
795
|
+
* @param entries - Component entries to write.
|
|
796
|
+
*/
|
|
797
|
+
async function writeHeartbeatSection(filePath, entries) {
|
|
798
|
+
const dir = dirname(filePath);
|
|
799
|
+
if (!existsSync(dir)) {
|
|
800
|
+
mkdirSync(dir, { recursive: true });
|
|
801
|
+
}
|
|
802
|
+
if (!existsSync(filePath)) {
|
|
803
|
+
writeFileSync(filePath, '', 'utf-8');
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
await withFileLock(filePath, () => {
|
|
807
|
+
const fileContent = readFileSync(filePath, 'utf-8');
|
|
808
|
+
const parsed = parseHeartbeat(fileContent);
|
|
809
|
+
const section = buildHeartbeatSection(entries);
|
|
810
|
+
const parts = [];
|
|
811
|
+
if (parsed.userContent) {
|
|
812
|
+
parts.push(parsed.userContent);
|
|
813
|
+
parts.push('');
|
|
814
|
+
}
|
|
815
|
+
parts.push(section);
|
|
816
|
+
parts.push('');
|
|
817
|
+
atomicWrite(filePath, parts.join('\n'));
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
catch (err) {
|
|
821
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
822
|
+
console.warn(`jeeves-core: writeHeartbeatSection failed for ${filePath}: ${message}`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Stable section ordering for managed TOOLS.md blocks.
|
|
828
|
+
*
|
|
829
|
+
* @remarks
|
|
830
|
+
* Sorts sections by the canonical SECTION_ORDER: known sections
|
|
831
|
+
* appear in their defined order, unknown sections are appended after.
|
|
832
|
+
* Used by both parseManaged (for consistent output) and
|
|
833
|
+
* updateManagedSection (for reassembly).
|
|
834
|
+
*/
|
|
835
|
+
/**
|
|
836
|
+
* Sort sections in place by stable ordering.
|
|
837
|
+
*
|
|
838
|
+
* @param sections - Array of managed sections to sort.
|
|
839
|
+
* @returns The sorted array (same reference, mutated in place).
|
|
840
|
+
*/
|
|
841
|
+
function sortSectionsByOrder(sections) {
|
|
842
|
+
return sections.sort((a, b) => {
|
|
843
|
+
const aIdx = SECTION_ORDER.indexOf(a.id);
|
|
844
|
+
const bIdx = SECTION_ORDER.indexOf(b.id);
|
|
845
|
+
const aOrder = aIdx === -1 ? SECTION_ORDER.length : aIdx;
|
|
846
|
+
const bOrder = bIdx === -1 ? SECTION_ORDER.length : bIdx;
|
|
847
|
+
return aOrder - bOrder;
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Parse managed block from file content.
|
|
853
|
+
*
|
|
854
|
+
* @remarks
|
|
855
|
+
* Extracts managed content delimited by comment markers, parses H2
|
|
856
|
+
* sections within the block, and returns the structured result plus
|
|
857
|
+
* user content outside the markers.
|
|
858
|
+
*/
|
|
859
|
+
/**
|
|
860
|
+
* Build regex patterns for the given markers.
|
|
861
|
+
*
|
|
862
|
+
* @param markers - Begin/end marker strings.
|
|
863
|
+
* @returns Object with begin and end regex patterns.
|
|
864
|
+
*/
|
|
865
|
+
function buildMarkerPatterns(markers) {
|
|
866
|
+
const escapedBegin = markers.begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
867
|
+
const escapedEnd = markers.end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
868
|
+
return {
|
|
869
|
+
beginRe: new RegExp(`^<!--\\s*${escapedBegin}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$`, 'm'),
|
|
870
|
+
endRe: new RegExp(`^<!--\\s*${escapedEnd}\\s*-->\\s*$`, 'm'),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Parse H2 sections from managed block content.
|
|
875
|
+
*
|
|
876
|
+
* @param content - Raw managed block content.
|
|
877
|
+
* @returns Array of parsed sections in stable order.
|
|
878
|
+
*/
|
|
879
|
+
function parseSections(content) {
|
|
880
|
+
const lines = content.split('\n');
|
|
881
|
+
const sections = [];
|
|
882
|
+
let currentId;
|
|
883
|
+
let currentLines = [];
|
|
884
|
+
for (const line of lines) {
|
|
885
|
+
const h2Match = /^## (.+)$/.exec(line);
|
|
886
|
+
if (h2Match) {
|
|
887
|
+
if (currentId !== undefined) {
|
|
888
|
+
sections.push({
|
|
889
|
+
id: currentId,
|
|
890
|
+
content: currentLines.join('\n').trim(),
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
currentId = h2Match[1];
|
|
894
|
+
currentLines = [];
|
|
895
|
+
}
|
|
896
|
+
else if (currentId !== undefined) {
|
|
897
|
+
currentLines.push(line);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (currentId !== undefined) {
|
|
901
|
+
sections.push({
|
|
902
|
+
id: currentId,
|
|
903
|
+
content: currentLines.join('\n').trim(),
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
return sortSectionsByOrder(sections);
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Parse a managed block from file content.
|
|
910
|
+
*
|
|
911
|
+
* @param fileContent - Full file content.
|
|
912
|
+
* @param markers - Optional custom markers (defaults to TOOLS markers).
|
|
913
|
+
* @returns Parsed result with sections, version stamp, and user content.
|
|
914
|
+
*/
|
|
915
|
+
function parseManaged(fileContent, markers = TOOLS_MARKERS) {
|
|
916
|
+
const { beginRe, endRe } = buildMarkerPatterns(markers);
|
|
917
|
+
const beginMatch = beginRe.exec(fileContent);
|
|
918
|
+
if (!beginMatch) {
|
|
919
|
+
return {
|
|
920
|
+
found: false,
|
|
921
|
+
versionStamp: undefined,
|
|
922
|
+
managedContent: '',
|
|
923
|
+
sections: [],
|
|
924
|
+
beforeContent: '',
|
|
925
|
+
userContent: fileContent,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
const endMatch = endRe.exec(fileContent.slice(beginMatch.index + beginMatch[0].length));
|
|
929
|
+
if (!endMatch) {
|
|
930
|
+
// Corrupt: BEGIN without END — treat as fresh file
|
|
931
|
+
return {
|
|
932
|
+
found: false,
|
|
933
|
+
versionStamp: undefined,
|
|
934
|
+
managedContent: '',
|
|
935
|
+
sections: [],
|
|
936
|
+
beforeContent: '',
|
|
937
|
+
userContent: fileContent,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
const beforeContent = fileContent.slice(0, beginMatch.index).trim();
|
|
941
|
+
const managedStart = beginMatch.index + beginMatch[0].length;
|
|
942
|
+
const managedEnd = managedStart + endMatch.index;
|
|
943
|
+
const managedContent = fileContent.slice(managedStart, managedEnd).trim();
|
|
944
|
+
const afterEnd = managedStart + endMatch.index + endMatch[0].length;
|
|
945
|
+
const userContent = fileContent.slice(afterEnd).trim();
|
|
946
|
+
// Extract version stamp from BEGIN marker line
|
|
947
|
+
let versionStamp;
|
|
948
|
+
const stampMatch = VERSION_STAMP_PATTERN.exec(beginMatch[0]);
|
|
949
|
+
if (stampMatch?.[2] && stampMatch[3]) {
|
|
950
|
+
versionStamp = {
|
|
951
|
+
version: stampMatch[2],
|
|
952
|
+
timestamp: stampMatch[3],
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
const sections = parseSections(managedContent);
|
|
956
|
+
return {
|
|
957
|
+
found: true,
|
|
958
|
+
versionStamp,
|
|
959
|
+
managedContent,
|
|
960
|
+
sections,
|
|
961
|
+
beforeContent,
|
|
962
|
+
userContent,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Version-stamp parsing and convergence logic.
|
|
968
|
+
*
|
|
969
|
+
* @remarks
|
|
970
|
+
* When multiple component plugins bundle different core library versions,
|
|
971
|
+
* they independently maintain shared managed content. The version-stamp
|
|
972
|
+
* mechanism ensures convergence without coordination state.
|
|
973
|
+
*/
|
|
974
|
+
/**
|
|
975
|
+
* Format the BEGIN marker comment with a version stamp.
|
|
976
|
+
*
|
|
977
|
+
* @param markerText - The marker text (e.g., 'BEGIN JEEVES PLATFORM TOOLS').
|
|
978
|
+
* @param version - The core library version.
|
|
979
|
+
* @returns Formatted comment line.
|
|
980
|
+
*/
|
|
981
|
+
function formatBeginMarker(markerText, version) {
|
|
982
|
+
const timestamp = new Date().toISOString();
|
|
983
|
+
return `<!-- ${markerText} | core:${version} | ${timestamp} -->`;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Format the END marker comment.
|
|
987
|
+
*
|
|
988
|
+
* @param markerText - The marker text (e.g., 'END JEEVES PLATFORM TOOLS').
|
|
989
|
+
* @returns Formatted comment line.
|
|
990
|
+
*/
|
|
991
|
+
function formatEndMarker(markerText) {
|
|
992
|
+
return `<!-- ${markerText} -->`;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Determine whether this writer should proceed based on version-stamp
|
|
996
|
+
* convergence rules.
|
|
997
|
+
*
|
|
998
|
+
* @param myVersion - The current core library version.
|
|
999
|
+
* @param existing - The existing version stamp (if any).
|
|
1000
|
+
* @param stalenessThresholdMs - Staleness threshold in ms (default: 5 min).
|
|
1001
|
+
* @returns `true` if the writer should proceed with the write.
|
|
1002
|
+
*/
|
|
1003
|
+
function shouldWrite(myVersion, existing, stalenessThresholdMs = STALENESS_THRESHOLD_MS) {
|
|
1004
|
+
// No existing stamp — always write
|
|
1005
|
+
if (!existing)
|
|
1006
|
+
return true;
|
|
1007
|
+
// My version >= stamped version — always write (I'm current or newer)
|
|
1008
|
+
if (gte(myVersion, existing.version))
|
|
1009
|
+
return true;
|
|
1010
|
+
// My version < stamped version — check staleness
|
|
1011
|
+
const stampAge = Date.now() - new Date(existing.timestamp).getTime();
|
|
1012
|
+
return stampAge >= stalenessThresholdMs;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Remove a managed section or entire managed block from a file.
|
|
1017
|
+
*
|
|
1018
|
+
* @remarks
|
|
1019
|
+
* Supports two modes:
|
|
1020
|
+
* - No `sectionId`: Remove the entire managed block (markers + content),
|
|
1021
|
+
* leaving user content intact.
|
|
1022
|
+
* - With `sectionId`: Remove a specific H2 section from within the
|
|
1023
|
+
* managed block. If it was the last section, remove the entire block.
|
|
1024
|
+
*
|
|
1025
|
+
* Provides file-level locking and atomic writes (temp file + rename).
|
|
1026
|
+
* Missing markers or nonexistent sections are no-ops (no error thrown).
|
|
1027
|
+
*/
|
|
1028
|
+
/**
|
|
1029
|
+
* Remove a managed section or entire managed block from a file.
|
|
1030
|
+
*
|
|
1031
|
+
* @param filePath - Absolute path to the target file.
|
|
1032
|
+
* @param options - Optional section ID and custom markers.
|
|
1033
|
+
*/
|
|
1034
|
+
async function removeManagedSection(filePath, options = {}) {
|
|
1035
|
+
const { sectionId, markers = TOOLS_MARKERS } = options;
|
|
1036
|
+
if (!existsSync(filePath))
|
|
1037
|
+
return;
|
|
1038
|
+
await withFileLock(filePath, () => {
|
|
1039
|
+
const fileContent = readFileSync(filePath, 'utf-8');
|
|
1040
|
+
const parsed = parseManaged(fileContent, markers);
|
|
1041
|
+
if (!parsed.found)
|
|
1042
|
+
return;
|
|
1043
|
+
let newContent;
|
|
1044
|
+
if (!sectionId) {
|
|
1045
|
+
// Remove entire managed block
|
|
1046
|
+
newContent = buildWithoutBlock(parsed.beforeContent, parsed.userContent);
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
// Remove specific section
|
|
1050
|
+
const remaining = parsed.sections.filter((s) => s.id !== sectionId);
|
|
1051
|
+
if (remaining.length === parsed.sections.length) {
|
|
1052
|
+
// Section not found — no-op
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
if (remaining.length === 0) {
|
|
1056
|
+
// Last section removed — remove entire block
|
|
1057
|
+
newContent = buildWithoutBlock(parsed.beforeContent, parsed.userContent);
|
|
1058
|
+
}
|
|
1059
|
+
else {
|
|
1060
|
+
// Rebuild managed block without the removed section
|
|
1061
|
+
newContent = buildWithSections(parsed.beforeContent, parsed.userContent, remaining, markers, parsed.versionStamp?.version);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
atomicWrite(filePath, newContent);
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
/** Build file content without the managed block. */
|
|
1068
|
+
function buildWithoutBlock(beforeContent, userContent) {
|
|
1069
|
+
const parts = [];
|
|
1070
|
+
if (beforeContent)
|
|
1071
|
+
parts.push(beforeContent);
|
|
1072
|
+
if (userContent) {
|
|
1073
|
+
if (parts.length > 0)
|
|
1074
|
+
parts.push('');
|
|
1075
|
+
parts.push(userContent);
|
|
1076
|
+
}
|
|
1077
|
+
if (parts.length === 0)
|
|
1078
|
+
return '';
|
|
1079
|
+
return parts.join('\n') + '\n';
|
|
1080
|
+
}
|
|
1081
|
+
/** Rebuild file content with remaining sections. */
|
|
1082
|
+
function buildWithSections(beforeContent, userContent, sections, markers, coreVersion) {
|
|
1083
|
+
const sorted = sortSectionsByOrder([...sections]);
|
|
1084
|
+
const sectionText = sorted
|
|
1085
|
+
.map((s) => `## ${s.id}\n\n${s.content}`)
|
|
1086
|
+
.join('\n\n');
|
|
1087
|
+
const managedBody = markers.title
|
|
1088
|
+
? `# ${markers.title}\n\n${sectionText}`
|
|
1089
|
+
: sectionText;
|
|
1090
|
+
const beginLine = formatBeginMarker(markers.begin, coreVersion ?? DEFAULT_CORE_VERSION);
|
|
1091
|
+
const endLine = formatEndMarker(markers.end);
|
|
1092
|
+
const parts = [];
|
|
1093
|
+
if (beforeContent) {
|
|
1094
|
+
parts.push(beforeContent);
|
|
1095
|
+
parts.push('');
|
|
1096
|
+
}
|
|
1097
|
+
parts.push(beginLine);
|
|
1098
|
+
parts.push('');
|
|
1099
|
+
parts.push(managedBody);
|
|
1100
|
+
parts.push('');
|
|
1101
|
+
parts.push(endLine);
|
|
1102
|
+
if (userContent) {
|
|
1103
|
+
parts.push('');
|
|
1104
|
+
parts.push(userContent);
|
|
1105
|
+
}
|
|
1106
|
+
parts.push('');
|
|
1107
|
+
return parts.join('\n');
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* OpenClaw configuration helpers for plugin CLI installers.
|
|
1112
|
+
*
|
|
1113
|
+
* @remarks
|
|
1114
|
+
* Provides resolution of OpenClaw home directory and config file path,
|
|
1115
|
+
* plus idempotent config patching for plugin install/uninstall.
|
|
1116
|
+
*/
|
|
1117
|
+
/**
|
|
1118
|
+
* Resolve the OpenClaw home directory.
|
|
1119
|
+
*
|
|
1120
|
+
* @remarks
|
|
1121
|
+
* Resolution order:
|
|
1122
|
+
* 1. `OPENCLAW_CONFIG` env var → dirname of the config file path
|
|
1123
|
+
* 2. `OPENCLAW_HOME` env var → resolved path
|
|
1124
|
+
* 3. Default: `~/.openclaw`
|
|
1125
|
+
*
|
|
1126
|
+
* @returns Absolute path to the OpenClaw home directory.
|
|
1127
|
+
*/
|
|
1128
|
+
function resolveOpenClawHome() {
|
|
1129
|
+
if (process.env.OPENCLAW_CONFIG) {
|
|
1130
|
+
return dirname(resolve(process.env.OPENCLAW_CONFIG));
|
|
1131
|
+
}
|
|
1132
|
+
if (process.env.OPENCLAW_HOME) {
|
|
1133
|
+
return resolve(process.env.OPENCLAW_HOME);
|
|
1134
|
+
}
|
|
1135
|
+
return join(homedir(), '.openclaw');
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Resolve the OpenClaw config file path.
|
|
1139
|
+
*
|
|
1140
|
+
* @remarks
|
|
1141
|
+
* If `OPENCLAW_CONFIG` is set, uses that directly.
|
|
1142
|
+
* Otherwise defaults to `{home}/openclaw.json`.
|
|
1143
|
+
*
|
|
1144
|
+
* @param home - The OpenClaw home directory.
|
|
1145
|
+
* @returns Absolute path to the config file.
|
|
1146
|
+
*/
|
|
1147
|
+
function resolveConfigPath(home) {
|
|
1148
|
+
if (process.env.OPENCLAW_CONFIG) {
|
|
1149
|
+
return resolve(process.env.OPENCLAW_CONFIG);
|
|
1150
|
+
}
|
|
1151
|
+
return join(home, 'openclaw.json');
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Patch an allowlist array: add or remove the plugin ID.
|
|
1155
|
+
*
|
|
1156
|
+
* @returns A log message if a change was made, or undefined.
|
|
1157
|
+
*/
|
|
1158
|
+
function patchAllowList(parent, key, label, pluginId, mode) {
|
|
1159
|
+
if (mode === 'add') {
|
|
1160
|
+
if (!Array.isArray(parent[key])) {
|
|
1161
|
+
parent[key] = [pluginId];
|
|
1162
|
+
return `Created ${label} with "${pluginId}"`;
|
|
1163
|
+
}
|
|
1164
|
+
const list = parent[key];
|
|
1165
|
+
if (!list.includes(pluginId)) {
|
|
1166
|
+
list.push(pluginId);
|
|
1167
|
+
return `Added "${pluginId}" to ${label}`;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
else {
|
|
1171
|
+
if (!Array.isArray(parent[key]))
|
|
1172
|
+
return undefined;
|
|
1173
|
+
const list = parent[key];
|
|
1174
|
+
const filtered = list.filter((id) => id !== pluginId);
|
|
1175
|
+
if (filtered.length !== list.length) {
|
|
1176
|
+
parent[key] = filtered;
|
|
1177
|
+
return `Removed "${pluginId}" from ${label}`;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return undefined;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Patch an OpenClaw config for plugin install or uninstall.
|
|
1184
|
+
*
|
|
1185
|
+
* @remarks
|
|
1186
|
+
* Manages `plugins.entries.{pluginId}` and `tools.alsoAllow`.
|
|
1187
|
+
* Idempotent: adding twice produces no duplicates; removing when absent
|
|
1188
|
+
* produces no errors.
|
|
1189
|
+
*
|
|
1190
|
+
* @param config - The parsed OpenClaw config object (mutated in place).
|
|
1191
|
+
* @param pluginId - The plugin identifier.
|
|
1192
|
+
* @param mode - Whether to add or remove the plugin.
|
|
1193
|
+
* @returns Array of log messages describing changes made.
|
|
1194
|
+
*/
|
|
1195
|
+
function patchConfig(config, pluginId, mode) {
|
|
1196
|
+
const messages = [];
|
|
1197
|
+
// Ensure plugins section
|
|
1198
|
+
if (!config.plugins || typeof config.plugins !== 'object') {
|
|
1199
|
+
config.plugins = {};
|
|
1200
|
+
}
|
|
1201
|
+
const plugins = config.plugins;
|
|
1202
|
+
// plugins.entries
|
|
1203
|
+
if (!plugins.entries || typeof plugins.entries !== 'object') {
|
|
1204
|
+
plugins.entries = {};
|
|
1205
|
+
}
|
|
1206
|
+
const entries = plugins.entries;
|
|
1207
|
+
if (mode === 'add') {
|
|
1208
|
+
if (!entries[pluginId]) {
|
|
1209
|
+
entries[pluginId] = { enabled: true };
|
|
1210
|
+
messages.push(`Added "${pluginId}" to plugins.entries`);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
else if (pluginId in entries) {
|
|
1214
|
+
Reflect.deleteProperty(entries, pluginId);
|
|
1215
|
+
messages.push(`Removed "${pluginId}" from plugins.entries`);
|
|
1216
|
+
}
|
|
1217
|
+
// tools.alsoAllow
|
|
1218
|
+
if (!config.tools || typeof config.tools !== 'object') {
|
|
1219
|
+
config.tools = {};
|
|
1220
|
+
}
|
|
1221
|
+
const tools = config.tools;
|
|
1222
|
+
const toolAlsoAllow = patchAllowList(tools, 'alsoAllow', 'tools.alsoAllow', pluginId, mode);
|
|
1223
|
+
if (toolAlsoAllow)
|
|
1224
|
+
messages.push(toolAlsoAllow);
|
|
1225
|
+
return messages;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Factory for the standard `-openclaw` plugin installer CLI.
|
|
1230
|
+
*
|
|
1231
|
+
* @remarks
|
|
1232
|
+
* Produces a Commander program with `install` and `uninstall` commands
|
|
1233
|
+
* that handle the full plugin lifecycle: copy dist to extensions,
|
|
1234
|
+
* patch OpenClaw config, manage HEARTBEAT entries, and clean up
|
|
1235
|
+
* managed sections on uninstall.
|
|
1236
|
+
*/
|
|
1237
|
+
/**
|
|
1238
|
+
* Derive a component name from a plugin ID.
|
|
1239
|
+
*
|
|
1240
|
+
* @remarks
|
|
1241
|
+
* Strips `jeeves-` prefix and `-openclaw` suffix.
|
|
1242
|
+
*
|
|
1243
|
+
* @param pluginId - The plugin identifier.
|
|
1244
|
+
* @returns Component short name.
|
|
1245
|
+
*/
|
|
1246
|
+
function deriveComponentName(pluginId) {
|
|
1247
|
+
return pluginId.replace(/^jeeves-/, '').replace(/-openclaw$/, '');
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Copy all files from source directory to destination.
|
|
1251
|
+
*
|
|
1252
|
+
* @param srcDir - Source directory.
|
|
1253
|
+
* @param destDir - Destination directory.
|
|
1254
|
+
*/
|
|
1255
|
+
function copyDistFiles(srcDir, destDir) {
|
|
1256
|
+
mkdirSync(destDir, { recursive: true });
|
|
1257
|
+
const entries = readdirSync(srcDir, { withFileTypes: true });
|
|
1258
|
+
for (const entry of entries) {
|
|
1259
|
+
const srcPath = join(srcDir, entry.name);
|
|
1260
|
+
const destPath = join(destDir, entry.name);
|
|
1261
|
+
if (entry.isDirectory()) {
|
|
1262
|
+
copyDistFiles(srcPath, destPath);
|
|
1263
|
+
}
|
|
1264
|
+
else {
|
|
1265
|
+
copyFileSync(srcPath, destPath);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Read and parse a JSON file, returning an empty object if not found.
|
|
1271
|
+
*
|
|
1272
|
+
* @param filePath - Path to the JSON file.
|
|
1273
|
+
* @returns Parsed object.
|
|
1274
|
+
*/
|
|
1275
|
+
function readJsonFile(filePath) {
|
|
1276
|
+
try {
|
|
1277
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
1278
|
+
return JSON.parse(raw);
|
|
1279
|
+
}
|
|
1280
|
+
catch {
|
|
1281
|
+
return {};
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Create a standard plugin installer CLI program.
|
|
1286
|
+
*
|
|
1287
|
+
* @param options - Plugin CLI configuration.
|
|
1288
|
+
* @returns A Commander program ready for `.parse()`.
|
|
1289
|
+
*/
|
|
1290
|
+
function createPluginCli(options) {
|
|
1291
|
+
const { pluginId, distDir, pluginPackage, configRoot = 'j:/config', } = options;
|
|
1292
|
+
const componentName = options.componentName ?? deriveComponentName(pluginId);
|
|
1293
|
+
const program = new Command()
|
|
1294
|
+
.name(pluginPackage)
|
|
1295
|
+
.description(`Jeeves ${componentName} plugin installer`);
|
|
1296
|
+
program
|
|
1297
|
+
.command('install')
|
|
1298
|
+
.description(`Install the ${componentName} plugin`)
|
|
1299
|
+
.option('--memory', 'Claim a memory slot for this plugin')
|
|
1300
|
+
.option('-w, --workspace <path>', 'Workspace root path')
|
|
1301
|
+
.option('-c, --config-root <path>', 'Platform config root path', configRoot)
|
|
1302
|
+
.action((opts) => {
|
|
1303
|
+
const openClawHome = resolveOpenClawHome();
|
|
1304
|
+
const configPath = resolveConfigPath(openClawHome);
|
|
1305
|
+
// 1. Copy dist to extensions
|
|
1306
|
+
const extensionsDir = join(openClawHome, 'extensions', pluginId);
|
|
1307
|
+
console.log(`Copying dist to ${extensionsDir}...`);
|
|
1308
|
+
copyDistFiles(distDir, extensionsDir);
|
|
1309
|
+
console.log(' ✓ Dist files copied');
|
|
1310
|
+
// 2. Patch openclaw.json
|
|
1311
|
+
console.log('Patching OpenClaw config...');
|
|
1312
|
+
const config = readJsonFile(configPath);
|
|
1313
|
+
const messages = patchConfig(config, pluginId, 'add');
|
|
1314
|
+
// 3. Memory slot claim
|
|
1315
|
+
if (opts.memory) {
|
|
1316
|
+
if (!config.agents || typeof config.agents !== 'object') {
|
|
1317
|
+
config.agents = {};
|
|
1318
|
+
}
|
|
1319
|
+
const agents = config.agents;
|
|
1320
|
+
if (!agents.defaults || typeof agents.defaults !== 'object') {
|
|
1321
|
+
agents.defaults = {};
|
|
1322
|
+
}
|
|
1323
|
+
const defaults = agents.defaults;
|
|
1324
|
+
if (!defaults.memory || typeof defaults.memory !== 'object') {
|
|
1325
|
+
defaults.memory = {};
|
|
1326
|
+
}
|
|
1327
|
+
const memory = defaults.memory;
|
|
1328
|
+
if (!memory[componentName]) {
|
|
1329
|
+
memory[componentName] = {};
|
|
1330
|
+
messages.push(`Claimed memory slot for "${componentName}"`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
1334
|
+
for (const msg of messages) {
|
|
1335
|
+
console.log(` ✓ ${msg}`);
|
|
1336
|
+
}
|
|
1337
|
+
// 4. Write initial HEARTBEAT entry
|
|
1338
|
+
try {
|
|
1339
|
+
const cfgRoot = opts.configRoot;
|
|
1340
|
+
const agents = config.agents;
|
|
1341
|
+
const defaults = agents?.defaults;
|
|
1342
|
+
const ws = opts.workspace ?? defaults?.workspace;
|
|
1343
|
+
if (ws) {
|
|
1344
|
+
init({ workspacePath: ws, configRoot: cfgRoot });
|
|
1345
|
+
const heartbeatPath = join(ws, WORKSPACE_FILES.heartbeat);
|
|
1346
|
+
try {
|
|
1347
|
+
const existing = existsSync(heartbeatPath)
|
|
1348
|
+
? readFileSync(heartbeatPath, 'utf-8')
|
|
1349
|
+
: '';
|
|
1350
|
+
const parsed = parseHeartbeat(existing);
|
|
1351
|
+
const fullName = `jeeves-${componentName}`;
|
|
1352
|
+
// Only add if not already present
|
|
1353
|
+
const hasEntry = parsed.entries.some((e) => e.name === fullName);
|
|
1354
|
+
if (!hasEntry) {
|
|
1355
|
+
parsed.entries.push({
|
|
1356
|
+
name: fullName,
|
|
1357
|
+
declined: false,
|
|
1358
|
+
content: `- Plugin installed. Awaiting service configuration.`,
|
|
1359
|
+
});
|
|
1360
|
+
const section = buildHeartbeatSection(parsed.entries);
|
|
1361
|
+
atomicWrite(heartbeatPath, section);
|
|
1362
|
+
console.log(' ✓ HEARTBEAT entry written');
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
catch {
|
|
1366
|
+
console.log(' ⚠ Could not write HEARTBEAT entry');
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
catch {
|
|
1371
|
+
// HEARTBEAT is best-effort during install
|
|
1372
|
+
}
|
|
1373
|
+
console.log();
|
|
1374
|
+
console.log(`✅ ${pluginPackage} installed.`);
|
|
1375
|
+
});
|
|
1376
|
+
program
|
|
1377
|
+
.command('uninstall')
|
|
1378
|
+
.description(`Uninstall the ${componentName} plugin`)
|
|
1379
|
+
.option('-w, --workspace <path>', 'Workspace root path')
|
|
1380
|
+
.option('-c, --config-root <path>', 'Platform config root path', configRoot)
|
|
1381
|
+
.action(async (opts) => {
|
|
1382
|
+
const openClawHome = resolveOpenClawHome();
|
|
1383
|
+
const cfgPath = resolveConfigPath(openClawHome);
|
|
1384
|
+
// 1. Remove from extensions
|
|
1385
|
+
const extensionsDir = join(openClawHome, 'extensions', pluginId);
|
|
1386
|
+
if (existsSync(extensionsDir)) {
|
|
1387
|
+
rmSync(extensionsDir, { recursive: true, force: true });
|
|
1388
|
+
console.log(' ✓ Extension files removed');
|
|
1389
|
+
}
|
|
1390
|
+
// 2. Unpatch openclaw.json
|
|
1391
|
+
if (existsSync(cfgPath)) {
|
|
1392
|
+
const config = readJsonFile(cfgPath);
|
|
1393
|
+
const messages = patchConfig(config, pluginId, 'remove');
|
|
1394
|
+
writeFileSync(cfgPath, JSON.stringify(config, null, 2) + '\n');
|
|
1395
|
+
for (const msg of messages) {
|
|
1396
|
+
console.log(` ✓ ${msg}`);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
// 3. Remove TOOLS.md section
|
|
1400
|
+
try {
|
|
1401
|
+
const cfgRoot = opts.configRoot;
|
|
1402
|
+
const ws = opts.workspace;
|
|
1403
|
+
if (ws) {
|
|
1404
|
+
init({ workspacePath: ws, configRoot: cfgRoot });
|
|
1405
|
+
const sectionId = componentName.charAt(0).toUpperCase() + componentName.slice(1);
|
|
1406
|
+
const toolsPath = join(ws, WORKSPACE_FILES.tools);
|
|
1407
|
+
if (existsSync(toolsPath)) {
|
|
1408
|
+
await removeManagedSection(toolsPath, {
|
|
1409
|
+
sectionId,
|
|
1410
|
+
markers: TOOLS_MARKERS,
|
|
1411
|
+
});
|
|
1412
|
+
console.log(' ✓ TOOLS.md section removed');
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
catch {
|
|
1417
|
+
console.log(' ⚠ Could not remove TOOLS.md section');
|
|
1418
|
+
}
|
|
1419
|
+
// 4. Remove component-versions.json entry
|
|
1420
|
+
try {
|
|
1421
|
+
const cfgRoot = opts.configRoot;
|
|
1422
|
+
init({
|
|
1423
|
+
workspacePath: opts.workspace ?? '.',
|
|
1424
|
+
configRoot: cfgRoot,
|
|
1425
|
+
});
|
|
1426
|
+
removeComponentVersion(getCoreConfigDir(), componentName);
|
|
1427
|
+
console.log(' ✓ Component version entry removed');
|
|
1428
|
+
}
|
|
1429
|
+
catch {
|
|
1430
|
+
console.log(' ⚠ Could not remove component version entry');
|
|
1431
|
+
}
|
|
1432
|
+
console.log();
|
|
1433
|
+
console.log(`✅ ${pluginPackage} uninstalled.`);
|
|
1434
|
+
});
|
|
1435
|
+
return program;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Zod schema for the Jeeves component descriptor.
|
|
1440
|
+
*
|
|
1441
|
+
* @remarks
|
|
1442
|
+
* The descriptor replaces the v0.4.0 `JeevesComponent` interface with a
|
|
1443
|
+
* Zod-first approach. The TypeScript type is inferred via `z.infer<>`.
|
|
1444
|
+
* Validates at parse time: prime interval, callable functions.
|
|
1445
|
+
*/
|
|
1446
|
+
/**
|
|
1447
|
+
* Check whether a number is prime.
|
|
1448
|
+
*
|
|
1449
|
+
* @param n - Number to check.
|
|
1450
|
+
* @returns `true` if n is prime.
|
|
1451
|
+
*/
|
|
1452
|
+
function isPrime(n) {
|
|
1453
|
+
if (n < 2)
|
|
1454
|
+
return false;
|
|
1455
|
+
if (n === 2)
|
|
1456
|
+
return true;
|
|
1457
|
+
if (n % 2 === 0)
|
|
1458
|
+
return false;
|
|
1459
|
+
for (let i = 3; i * i <= n; i += 2) {
|
|
1460
|
+
if (n % i === 0)
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1463
|
+
return true;
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Zod schema for the Jeeves component descriptor.
|
|
1467
|
+
*
|
|
1468
|
+
* @remarks
|
|
1469
|
+
* Single source of truth for what a component must provide.
|
|
1470
|
+
* Factories consume this descriptor to produce CLI commands,
|
|
1471
|
+
* plugin tools, and HTTP handlers.
|
|
1472
|
+
*/
|
|
1473
|
+
const jeevesComponentDescriptorSchema = z.object({
|
|
1474
|
+
/** Component name (e.g., 'watcher', 'runner', 'server', 'meta'). */
|
|
1475
|
+
name: z.string().min(1, 'name must be a non-empty string'),
|
|
1476
|
+
/** Component version (from package.json). */
|
|
1477
|
+
version: z.string().min(1, 'version must be a non-empty string'),
|
|
1478
|
+
/** npm package name for the service. */
|
|
1479
|
+
servicePackage: z.string().min(1),
|
|
1480
|
+
/** npm package name for the plugin. */
|
|
1481
|
+
pluginPackage: z.string().min(1),
|
|
1482
|
+
/** System service name. Defaults to `jeeves-${name}` when not provided. */
|
|
1483
|
+
serviceName: z.string().min(1).optional(),
|
|
1484
|
+
/** Default port for the service's HTTP API. */
|
|
1485
|
+
defaultPort: z.number().int().positive(),
|
|
1486
|
+
/** Zod schema for validating config files. */
|
|
1487
|
+
configSchema: z.custom((val) => val !== null &&
|
|
1488
|
+
typeof val === 'object' &&
|
|
1489
|
+
typeof val.parse === 'function', { message: 'configSchema must be a Zod schema' }),
|
|
1490
|
+
/** Config file name (e.g., 'jeeves-watcher.config.json'). */
|
|
1491
|
+
configFileName: z.string().min(1),
|
|
1492
|
+
/** Returns a default config object for `init`. */
|
|
1493
|
+
initTemplate: z.function().returns(z.record(z.unknown())),
|
|
1494
|
+
/**
|
|
1495
|
+
* Service-side callback after config apply. Receives the merged,
|
|
1496
|
+
* validated config (not the raw patch). Optional — if omitted,
|
|
1497
|
+
* write-only (service picks up changes on restart).
|
|
1498
|
+
*/
|
|
1499
|
+
onConfigApply: z
|
|
1500
|
+
.function()
|
|
1501
|
+
.args(z.record(z.unknown()))
|
|
1502
|
+
.returns(z.promise(z.void()))
|
|
1503
|
+
.optional(),
|
|
1504
|
+
/**
|
|
1505
|
+
* Returns command + args for launching the service process.
|
|
1506
|
+
* Consumed by `start` CLI command and `service install`.
|
|
1507
|
+
*/
|
|
1508
|
+
startCommand: z.function().args(z.string()).returns(z.array(z.string())),
|
|
1509
|
+
/** TOOLS.md section name (e.g., 'Watcher'). */
|
|
1510
|
+
sectionId: z.string().min(1, 'sectionId must be a non-empty string'),
|
|
1511
|
+
/** Refresh interval in seconds (must be a prime number). */
|
|
1512
|
+
refreshIntervalSeconds: z.number().int().positive().refine(isPrime, {
|
|
1513
|
+
message: 'refreshIntervalSeconds must be a prime number',
|
|
1514
|
+
}),
|
|
1515
|
+
/** Produce the component's TOOLS.md section content. */
|
|
1516
|
+
generateToolsContent: z.function().returns(z.string()),
|
|
1517
|
+
/** Component dependencies for HEARTBEAT alert suppression. */
|
|
1518
|
+
dependencies: z
|
|
1519
|
+
.object({
|
|
1520
|
+
hard: z.array(z.string()),
|
|
1521
|
+
soft: z.array(z.string()),
|
|
1522
|
+
})
|
|
1523
|
+
.optional(),
|
|
1524
|
+
/** Extension point: add custom CLI commands to the service CLI. */
|
|
1525
|
+
customCliCommands: z
|
|
1526
|
+
.function()
|
|
1527
|
+
.args(z.custom())
|
|
1528
|
+
.returns(z.void())
|
|
1529
|
+
.optional(),
|
|
1530
|
+
/** Extension point: return additional plugin tool descriptors. */
|
|
1531
|
+
customPluginTools: z
|
|
1532
|
+
.function()
|
|
1533
|
+
.args(z.custom())
|
|
1534
|
+
.returns(z.array(z.unknown()))
|
|
1535
|
+
.optional(),
|
|
1536
|
+
});
|
|
1537
|
+
/**
|
|
1538
|
+
* Derive the effective service name from a descriptor.
|
|
1539
|
+
*
|
|
1540
|
+
* @param descriptor - The component descriptor.
|
|
1541
|
+
* @returns The service name (explicit or derived from `jeeves-{name}`).
|
|
1542
|
+
*/
|
|
1543
|
+
function getEffectiveServiceName(descriptor) {
|
|
1544
|
+
return descriptor.serviceName ?? `jeeves-${descriptor.name}`;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* HTTP helpers for the OpenClaw plugin SDK.
|
|
1549
|
+
*
|
|
1550
|
+
* @remarks
|
|
1551
|
+
* Thin wrappers around `fetch` that throw on non-OK responses
|
|
1552
|
+
* and handle JSON serialisation/deserialisation.
|
|
1553
|
+
*/
|
|
1554
|
+
/**
|
|
1555
|
+
* Fetch a URL with an automatic abort timeout.
|
|
1556
|
+
*
|
|
1557
|
+
* @param url - URL to fetch.
|
|
1558
|
+
* @param timeoutMs - Timeout in milliseconds before aborting.
|
|
1559
|
+
* @param init - Optional `fetch` init options.
|
|
1560
|
+
* @returns The fetch Response object.
|
|
1561
|
+
*/
|
|
1562
|
+
async function fetchWithTimeout(url, timeoutMs, init) {
|
|
1563
|
+
const controller = new AbortController();
|
|
1564
|
+
const timeout = setTimeout(() => {
|
|
1565
|
+
controller.abort();
|
|
1566
|
+
}, timeoutMs);
|
|
1567
|
+
try {
|
|
1568
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
1569
|
+
}
|
|
1570
|
+
finally {
|
|
1571
|
+
clearTimeout(timeout);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Fetch JSON from a URL, throwing on non-OK responses.
|
|
1576
|
+
*
|
|
1577
|
+
* @param url - URL to fetch.
|
|
1578
|
+
* @param init - Optional `fetch` init options.
|
|
1579
|
+
* @returns Parsed JSON response body.
|
|
1580
|
+
* @throws Error with `HTTP {status}: {body}` message on non-OK responses.
|
|
1581
|
+
*/
|
|
1582
|
+
async function fetchJson(url, init) {
|
|
1583
|
+
const res = await fetch(url, init);
|
|
1584
|
+
if (!res.ok) {
|
|
1585
|
+
throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
|
|
1586
|
+
}
|
|
1587
|
+
return res.json();
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* POST JSON to a URL and return parsed response.
|
|
1591
|
+
*
|
|
1592
|
+
* @param url - URL to POST to.
|
|
1593
|
+
* @param body - Request body (will be JSON-stringified).
|
|
1594
|
+
* @returns Parsed JSON response body.
|
|
1595
|
+
*/
|
|
1596
|
+
async function postJson(url, body) {
|
|
1597
|
+
return fetchJson(url, {
|
|
1598
|
+
method: 'POST',
|
|
1599
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1600
|
+
body: JSON.stringify(body),
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/**
|
|
1605
|
+
* Platform-aware service state detection.
|
|
1606
|
+
*
|
|
1607
|
+
* @remarks
|
|
1608
|
+
* Detects whether a system service is installed and running.
|
|
1609
|
+
* Delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
|
|
1610
|
+
*/
|
|
1611
|
+
/**
|
|
1612
|
+
* Detect the state of a system service by name.
|
|
1613
|
+
*
|
|
1614
|
+
* @param serviceName - The service name (e.g., 'jeeves-runner').
|
|
1615
|
+
* @returns The detected service state.
|
|
1616
|
+
*/
|
|
1617
|
+
function getServiceState(serviceName) {
|
|
1618
|
+
switch (process.platform) {
|
|
1619
|
+
case 'win32':
|
|
1620
|
+
return getServiceStateWindows(serviceName);
|
|
1621
|
+
case 'darwin':
|
|
1622
|
+
return getServiceStateMacOS(serviceName);
|
|
1623
|
+
default:
|
|
1624
|
+
return getServiceStateLinux(serviceName);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Windows: detect via NSSM.
|
|
1629
|
+
* - Exit code 3 = service does not exist
|
|
1630
|
+
* - "SERVICE_RUNNING" in output = running
|
|
1631
|
+
* - Other output = stopped/paused
|
|
1632
|
+
*/
|
|
1633
|
+
function getServiceStateWindows(serviceName) {
|
|
1634
|
+
try {
|
|
1635
|
+
const output = execSync(`nssm status ${serviceName}`, {
|
|
1636
|
+
encoding: 'utf-8',
|
|
1637
|
+
timeout: 5000,
|
|
1638
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1639
|
+
}).trim();
|
|
1640
|
+
if (output.includes('SERVICE_RUNNING'))
|
|
1641
|
+
return 'running';
|
|
1642
|
+
return 'stopped';
|
|
1643
|
+
}
|
|
1644
|
+
catch (err) {
|
|
1645
|
+
// NSSM exits with code 3 when the service doesn't exist
|
|
1646
|
+
if (isExecError(err) && err.status === 3)
|
|
1647
|
+
return 'not_installed';
|
|
1648
|
+
// Any other error (nssm not found, timeout, etc.) — treat as not installed
|
|
1649
|
+
return 'not_installed';
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Linux: detect via systemd user services.
|
|
1654
|
+
* - `systemctl --user is-enabled {name}.service` exits non-zero = not installed
|
|
1655
|
+
* - `systemctl --user is-active {name}.service` returns "active" = running
|
|
1656
|
+
*/
|
|
1657
|
+
function getServiceStateLinux(serviceName) {
|
|
1658
|
+
try {
|
|
1659
|
+
execSync(`systemctl --user is-enabled ${serviceName}.service`, {
|
|
1660
|
+
encoding: 'utf-8',
|
|
1661
|
+
timeout: 5000,
|
|
1662
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
catch {
|
|
1666
|
+
return 'not_installed';
|
|
1667
|
+
}
|
|
1668
|
+
try {
|
|
1669
|
+
const output = execSync(`systemctl --user is-active ${serviceName}.service`, {
|
|
1670
|
+
encoding: 'utf-8',
|
|
1671
|
+
timeout: 5000,
|
|
1672
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1673
|
+
}).trim();
|
|
1674
|
+
if (output === 'active')
|
|
1675
|
+
return 'running';
|
|
1676
|
+
return 'stopped';
|
|
1677
|
+
}
|
|
1678
|
+
catch {
|
|
1679
|
+
return 'stopped';
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* macOS: detect via launchctl.
|
|
1684
|
+
* - `launchctl list {name}` exits non-zero = not installed
|
|
1685
|
+
* - PID column is `-` or `0` = stopped
|
|
1686
|
+
*/
|
|
1687
|
+
function getServiceStateMacOS(serviceName) {
|
|
1688
|
+
try {
|
|
1689
|
+
const output = execSync(`launchctl list ${serviceName}`, {
|
|
1690
|
+
encoding: 'utf-8',
|
|
1691
|
+
timeout: 5000,
|
|
1692
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1693
|
+
}).trim();
|
|
1694
|
+
// launchctl list output formats:
|
|
1695
|
+
// 1. Table row: "PID\tStatus\tLabel" (e.g., "1234\t0\tcom.jeeves.runner")
|
|
1696
|
+
// 2. Plist-style: '"PID" = 1234;'
|
|
1697
|
+
// 3. Single service: first token is the PID or "-"
|
|
1698
|
+
// Try table format: first token is PID
|
|
1699
|
+
const tableMatch = /^(\d+|-)\s/m.exec(output);
|
|
1700
|
+
if (tableMatch) {
|
|
1701
|
+
const pid = tableMatch[1];
|
|
1702
|
+
return pid !== '-' && Number(pid) > 0 ? 'running' : 'stopped';
|
|
1703
|
+
}
|
|
1704
|
+
// Try plist-style: "PID" = <number>;
|
|
1705
|
+
const plistMatch = /"PID"\s*=\s*(\d+)/m.exec(output);
|
|
1706
|
+
if (plistMatch) {
|
|
1707
|
+
return Number(plistMatch[1]) > 0 ? 'running' : 'stopped';
|
|
1708
|
+
}
|
|
1709
|
+
// If we got output but can't parse it, assume stopped (service exists but state unclear)
|
|
1710
|
+
return 'stopped';
|
|
1711
|
+
}
|
|
1712
|
+
catch {
|
|
1713
|
+
return 'not_installed';
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
/** Type guard for execSync errors with a status code. */
|
|
1717
|
+
function isExecError(err) {
|
|
1718
|
+
return (typeof err === 'object' &&
|
|
1719
|
+
err !== null &&
|
|
1720
|
+
'status' in err &&
|
|
1721
|
+
typeof err.status === 'number');
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/**
|
|
1725
|
+
* Factory for platform-aware service lifecycle management.
|
|
1726
|
+
*
|
|
1727
|
+
* @remarks
|
|
1728
|
+
* Produces a `ServiceManager` that handles install, uninstall, start,
|
|
1729
|
+
* stop, restart, and status for system services. Delegates to NSSM
|
|
1730
|
+
* (Windows), systemd (Linux), or launchd (macOS) based on platform.
|
|
1731
|
+
*/
|
|
1732
|
+
/** Exec helper that returns stdout. */
|
|
1733
|
+
function run(cmd) {
|
|
1734
|
+
return execSync(cmd, {
|
|
1735
|
+
encoding: 'utf-8',
|
|
1736
|
+
timeout: 30_000,
|
|
1737
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1738
|
+
}).trim();
|
|
1739
|
+
}
|
|
1740
|
+
/** Exec helper that suppresses errors and returns success boolean. */
|
|
1741
|
+
function runQuiet(cmd) {
|
|
1742
|
+
try {
|
|
1743
|
+
execSync(cmd, {
|
|
1744
|
+
encoding: 'utf-8',
|
|
1745
|
+
timeout: 30_000,
|
|
1746
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1747
|
+
});
|
|
1748
|
+
return true;
|
|
1749
|
+
}
|
|
1750
|
+
catch {
|
|
1751
|
+
return false;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Resolve the effective service name from options and descriptor.
|
|
1756
|
+
*
|
|
1757
|
+
* @param descriptor - Component descriptor.
|
|
1758
|
+
* @param options - Optional overrides.
|
|
1759
|
+
* @returns The service name to use.
|
|
1760
|
+
*/
|
|
1761
|
+
function resolveServiceName(descriptor, options) {
|
|
1762
|
+
return options?.name ?? getEffectiveServiceName(descriptor);
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* Resolve the config path for install.
|
|
1766
|
+
*
|
|
1767
|
+
* @param descriptor - Component descriptor.
|
|
1768
|
+
* @param options - Optional overrides.
|
|
1769
|
+
* @returns Absolute config file path.
|
|
1770
|
+
*/
|
|
1771
|
+
function resolveConfigFilePath(descriptor, options) {
|
|
1772
|
+
if (options?.configPath)
|
|
1773
|
+
return options.configPath;
|
|
1774
|
+
const configDir = getComponentConfigDir(descriptor.name);
|
|
1775
|
+
return join(configDir, descriptor.configFileName);
|
|
1776
|
+
}
|
|
1777
|
+
/** Build a Windows NSSM service manager. */
|
|
1778
|
+
function createWindowsManager(descriptor) {
|
|
1779
|
+
return {
|
|
1780
|
+
install(options) {
|
|
1781
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1782
|
+
const cfgPath = resolveConfigFilePath(descriptor, options);
|
|
1783
|
+
const cmdArgs = descriptor.startCommand(cfgPath);
|
|
1784
|
+
const appPath = cmdArgs[0];
|
|
1785
|
+
const appArgs = cmdArgs.slice(1).join(' ');
|
|
1786
|
+
run(`nssm install ${svcName} ${appPath}`);
|
|
1787
|
+
if (appArgs) {
|
|
1788
|
+
run(`nssm set ${svcName} AppParameters ${appArgs}`);
|
|
1789
|
+
}
|
|
1790
|
+
run(`nssm set ${svcName} AppStdout ${join(homedir(), `${svcName}.log`)}`);
|
|
1791
|
+
run(`nssm set ${svcName} AppStderr ${join(homedir(), `${svcName}.log`)}`);
|
|
1792
|
+
run(`nssm set ${svcName} AppRotateFiles 1`);
|
|
1793
|
+
run(`nssm set ${svcName} AppRotateBytes 1048576`);
|
|
1794
|
+
},
|
|
1795
|
+
uninstall(options) {
|
|
1796
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1797
|
+
runQuiet(`nssm stop ${svcName}`);
|
|
1798
|
+
run(`nssm remove ${svcName} confirm`);
|
|
1799
|
+
},
|
|
1800
|
+
start(options) {
|
|
1801
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1802
|
+
run(`nssm start ${svcName}`);
|
|
1803
|
+
},
|
|
1804
|
+
stop(options) {
|
|
1805
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1806
|
+
run(`nssm stop ${svcName}`);
|
|
1807
|
+
},
|
|
1808
|
+
restart(options) {
|
|
1809
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1810
|
+
run(`nssm restart ${svcName}`);
|
|
1811
|
+
},
|
|
1812
|
+
status(options) {
|
|
1813
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1814
|
+
return getServiceState(svcName);
|
|
1815
|
+
},
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Generate a systemd user unit file.
|
|
1820
|
+
*
|
|
1821
|
+
* @param svcName - Service name.
|
|
1822
|
+
* @param cmdArgs - Command + args array.
|
|
1823
|
+
* @returns Unit file content.
|
|
1824
|
+
*/
|
|
1825
|
+
function buildSystemdUnit(svcName, cmdArgs) {
|
|
1826
|
+
const execStart = cmdArgs.join(' ');
|
|
1827
|
+
return [
|
|
1828
|
+
'[Unit]',
|
|
1829
|
+
`Description=${svcName}`,
|
|
1830
|
+
'After=network.target',
|
|
1831
|
+
'',
|
|
1832
|
+
'[Service]',
|
|
1833
|
+
'Type=simple',
|
|
1834
|
+
`ExecStart=${execStart}`,
|
|
1835
|
+
'Restart=on-failure',
|
|
1836
|
+
'RestartSec=5',
|
|
1837
|
+
'',
|
|
1838
|
+
'[Install]',
|
|
1839
|
+
'WantedBy=default.target',
|
|
1840
|
+
].join('\n');
|
|
1841
|
+
}
|
|
1842
|
+
/** Build a Linux systemd service manager. */
|
|
1843
|
+
function createLinuxManager(descriptor) {
|
|
1844
|
+
const unitDir = join(homedir(), '.config', 'systemd', 'user');
|
|
1845
|
+
function unitPath(svcName) {
|
|
1846
|
+
return join(unitDir, `${svcName}.service`);
|
|
1847
|
+
}
|
|
1848
|
+
return {
|
|
1849
|
+
install(options) {
|
|
1850
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1851
|
+
const cfgPath = resolveConfigFilePath(descriptor, options);
|
|
1852
|
+
const cmdArgs = descriptor.startCommand(cfgPath);
|
|
1853
|
+
mkdirSync(unitDir, { recursive: true });
|
|
1854
|
+
writeFileSync(unitPath(svcName), buildSystemdUnit(svcName, cmdArgs));
|
|
1855
|
+
run('systemctl --user daemon-reload');
|
|
1856
|
+
run(`systemctl --user enable ${svcName}.service`);
|
|
1857
|
+
},
|
|
1858
|
+
uninstall(options) {
|
|
1859
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1860
|
+
runQuiet(`systemctl --user stop ${svcName}.service`);
|
|
1861
|
+
runQuiet(`systemctl --user disable ${svcName}.service`);
|
|
1862
|
+
const path = unitPath(svcName);
|
|
1863
|
+
if (existsSync(path))
|
|
1864
|
+
unlinkSync(path);
|
|
1865
|
+
runQuiet('systemctl --user daemon-reload');
|
|
1866
|
+
},
|
|
1867
|
+
start(options) {
|
|
1868
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1869
|
+
run(`systemctl --user start ${svcName}.service`);
|
|
1870
|
+
},
|
|
1871
|
+
stop(options) {
|
|
1872
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1873
|
+
run(`systemctl --user stop ${svcName}.service`);
|
|
1874
|
+
},
|
|
1875
|
+
restart(options) {
|
|
1876
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1877
|
+
run(`systemctl --user restart ${svcName}.service`);
|
|
1878
|
+
},
|
|
1879
|
+
status(options) {
|
|
1880
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1881
|
+
return getServiceState(svcName);
|
|
1882
|
+
},
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Generate a macOS launchd plist.
|
|
1887
|
+
*
|
|
1888
|
+
* @param svcName - Service label.
|
|
1889
|
+
* @param cmdArgs - Command + args array.
|
|
1890
|
+
* @returns Plist XML content.
|
|
1891
|
+
*/
|
|
1892
|
+
function buildLaunchdPlist(svcName, cmdArgs) {
|
|
1893
|
+
const argsXml = cmdArgs.map((a) => ` <string>${a}</string>`).join('\n');
|
|
1894
|
+
return [
|
|
1895
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1896
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"',
|
|
1897
|
+
' "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
1898
|
+
'<plist version="1.0">',
|
|
1899
|
+
'<dict>',
|
|
1900
|
+
' <key>Label</key>',
|
|
1901
|
+
` <string>${svcName}</string>`,
|
|
1902
|
+
' <key>ProgramArguments</key>',
|
|
1903
|
+
' <array>',
|
|
1904
|
+
argsXml,
|
|
1905
|
+
' </array>',
|
|
1906
|
+
' <key>RunAtLoad</key>',
|
|
1907
|
+
' <true/>',
|
|
1908
|
+
' <key>KeepAlive</key>',
|
|
1909
|
+
' <true/>',
|
|
1910
|
+
' <key>StandardOutPath</key>',
|
|
1911
|
+
` <string>${join(homedir(), 'Library', 'Logs', `${svcName}.log`)}</string>`,
|
|
1912
|
+
' <key>StandardErrorPath</key>',
|
|
1913
|
+
` <string>${join(homedir(), 'Library', 'Logs', `${svcName}.log`)}</string>`,
|
|
1914
|
+
'</dict>',
|
|
1915
|
+
'</plist>',
|
|
1916
|
+
].join('\n');
|
|
1917
|
+
}
|
|
1918
|
+
/** Build a macOS launchd service manager. */
|
|
1919
|
+
function createMacOSManager(descriptor) {
|
|
1920
|
+
const agentsDir = join(homedir(), 'Library', 'LaunchAgents');
|
|
1921
|
+
function plistPath(svcName) {
|
|
1922
|
+
return join(agentsDir, `${svcName}.plist`);
|
|
1923
|
+
}
|
|
1924
|
+
return {
|
|
1925
|
+
install(options) {
|
|
1926
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1927
|
+
const cfgPath = resolveConfigFilePath(descriptor, options);
|
|
1928
|
+
const cmdArgs = descriptor.startCommand(cfgPath);
|
|
1929
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
1930
|
+
writeFileSync(plistPath(svcName), buildLaunchdPlist(svcName, cmdArgs));
|
|
1931
|
+
},
|
|
1932
|
+
uninstall(options) {
|
|
1933
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1934
|
+
runQuiet(`launchctl unload ${plistPath(svcName)}`);
|
|
1935
|
+
const path = plistPath(svcName);
|
|
1936
|
+
if (existsSync(path))
|
|
1937
|
+
unlinkSync(path);
|
|
1938
|
+
},
|
|
1939
|
+
start(options) {
|
|
1940
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1941
|
+
run(`launchctl load ${plistPath(svcName)}`);
|
|
1942
|
+
},
|
|
1943
|
+
stop(options) {
|
|
1944
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1945
|
+
run(`launchctl unload ${plistPath(svcName)}`);
|
|
1946
|
+
},
|
|
1947
|
+
restart(options) {
|
|
1948
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1949
|
+
runQuiet(`launchctl unload ${plistPath(svcName)}`);
|
|
1950
|
+
run(`launchctl load ${plistPath(svcName)}`);
|
|
1951
|
+
},
|
|
1952
|
+
status(options) {
|
|
1953
|
+
const svcName = resolveServiceName(descriptor, options);
|
|
1954
|
+
return getServiceState(svcName);
|
|
1955
|
+
},
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* Create a platform-aware service manager from a component descriptor.
|
|
1960
|
+
*
|
|
1961
|
+
* @remarks
|
|
1962
|
+
* Detects the current platform and returns a `ServiceManager` that
|
|
1963
|
+
* delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
|
|
1964
|
+
*
|
|
1965
|
+
* @param descriptor - The component descriptor.
|
|
1966
|
+
* @returns A `ServiceManager` for the current platform.
|
|
1967
|
+
*/
|
|
1968
|
+
function createServiceManager(descriptor) {
|
|
1969
|
+
switch (process.platform) {
|
|
1970
|
+
case 'win32':
|
|
1971
|
+
return createWindowsManager(descriptor);
|
|
1972
|
+
case 'darwin':
|
|
1973
|
+
return createMacOSManager(descriptor);
|
|
1974
|
+
default:
|
|
1975
|
+
return createLinuxManager(descriptor);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
/**
|
|
1980
|
+
* Factory for the standard Jeeves service CLI.
|
|
1981
|
+
*
|
|
1982
|
+
* @remarks
|
|
1983
|
+
* Produces a Commander program with all standard commands from
|
|
1984
|
+
* a component descriptor. Components add domain-specific commands
|
|
1985
|
+
* via `descriptor.customCliCommands`.
|
|
1986
|
+
*/
|
|
1987
|
+
/**
|
|
1988
|
+
* Create a standard service CLI program from a component descriptor.
|
|
1989
|
+
*
|
|
1990
|
+
* @remarks
|
|
1991
|
+
* Standard commands:
|
|
1992
|
+
* - `start -c <path>` - Launch the service process (foreground)
|
|
1993
|
+
* - `status [-p port]` - Probe service health
|
|
1994
|
+
* - `config [jsonpath] [-p port]` - Query running config
|
|
1995
|
+
* - `config validate -c <path>` - Validate a config file
|
|
1996
|
+
* - `config apply [-p port] [--file path] [--replace]` - Apply config patch
|
|
1997
|
+
* - `init [-o path]` - Generate default config
|
|
1998
|
+
* - `service install` - Install system service
|
|
1999
|
+
* - `service uninstall` - Uninstall system service
|
|
2000
|
+
* - `service start` - Start system service
|
|
2001
|
+
* - `service stop` - Stop system service
|
|
2002
|
+
* - `service restart` - Restart system service
|
|
2003
|
+
* - `service status` - Query system service state
|
|
2004
|
+
*
|
|
2005
|
+
* @param descriptor - The component descriptor.
|
|
2006
|
+
* @returns A Commander program ready for custom commands and `.parse()`.
|
|
2007
|
+
*/
|
|
2008
|
+
function createServiceCli(descriptor) {
|
|
2009
|
+
const defaultServiceName = getEffectiveServiceName(descriptor);
|
|
2010
|
+
const program = new Command()
|
|
2011
|
+
.name(`jeeves-${descriptor.name}`)
|
|
2012
|
+
.description(`Jeeves ${descriptor.name} service CLI`)
|
|
2013
|
+
.version(descriptor.version)
|
|
2014
|
+
.enablePositionalOptions()
|
|
2015
|
+
.passThroughOptions();
|
|
2016
|
+
// --- start ---
|
|
2017
|
+
program
|
|
2018
|
+
.command('start')
|
|
2019
|
+
.description('Launch the service process (foreground)')
|
|
2020
|
+
.requiredOption('-c, --config <path>', 'Config file path')
|
|
2021
|
+
.action((opts) => {
|
|
2022
|
+
const cmdArgs = descriptor.startCommand(opts.config);
|
|
2023
|
+
const proc = spawn(cmdArgs[0], cmdArgs.slice(1), {
|
|
2024
|
+
stdio: 'inherit',
|
|
2025
|
+
});
|
|
2026
|
+
proc.on('exit', (code) => {
|
|
2027
|
+
process.exit(code ?? 1);
|
|
2028
|
+
});
|
|
2029
|
+
});
|
|
2030
|
+
// --- status ---
|
|
2031
|
+
program
|
|
2032
|
+
.command('status')
|
|
2033
|
+
.description('Probe service health and version')
|
|
2034
|
+
.option('-p, --port <port>', 'Service port', String(descriptor.defaultPort))
|
|
2035
|
+
.action(async (opts) => {
|
|
2036
|
+
const url = `http://127.0.0.1:${opts.port}`;
|
|
2037
|
+
try {
|
|
2038
|
+
const result = await fetchJson(`${url}/status`);
|
|
2039
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2040
|
+
}
|
|
2041
|
+
catch (err) {
|
|
2042
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2043
|
+
console.error(`Service unreachable: ${msg}`);
|
|
2044
|
+
process.exitCode = 1;
|
|
2045
|
+
}
|
|
2046
|
+
});
|
|
2047
|
+
// --- config ---
|
|
2048
|
+
const configCmd = program
|
|
2049
|
+
.command('config')
|
|
2050
|
+
.description('Query or manage service configuration');
|
|
2051
|
+
configCmd
|
|
2052
|
+
.command('query')
|
|
2053
|
+
.description('Query running service config via JSONPath')
|
|
2054
|
+
.argument('[jsonpath]', 'JSONPath expression')
|
|
2055
|
+
.option('-p, --port <port>', 'Service port', String(descriptor.defaultPort))
|
|
2056
|
+
.action(async (jsonpath, opts) => {
|
|
2057
|
+
const url = `http://127.0.0.1:${opts.port}`;
|
|
2058
|
+
const qs = jsonpath ? `?path=${encodeURIComponent(jsonpath)}` : '';
|
|
2059
|
+
try {
|
|
2060
|
+
const result = await fetchJson(`${url}/config${qs}`);
|
|
2061
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2062
|
+
}
|
|
2063
|
+
catch (err) {
|
|
2064
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2065
|
+
console.error(`Config query failed: ${msg}`);
|
|
2066
|
+
process.exitCode = 1;
|
|
2067
|
+
}
|
|
2068
|
+
});
|
|
2069
|
+
configCmd
|
|
2070
|
+
.command('validate')
|
|
2071
|
+
.description('Validate a config file against the schema')
|
|
2072
|
+
.requiredOption('-c, --config <path>', 'Config file path')
|
|
2073
|
+
.action((opts) => {
|
|
2074
|
+
try {
|
|
2075
|
+
const raw = readFileSync(opts.config, 'utf-8');
|
|
2076
|
+
const parsed = JSON.parse(raw);
|
|
2077
|
+
descriptor.configSchema.parse(parsed);
|
|
2078
|
+
console.log('Config is valid.');
|
|
2079
|
+
}
|
|
2080
|
+
catch (err) {
|
|
2081
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2082
|
+
console.error(`Validation failed: ${msg}`);
|
|
2083
|
+
process.exitCode = 1;
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
configCmd
|
|
2087
|
+
.command('apply')
|
|
2088
|
+
.description('Apply a config patch to the running service')
|
|
2089
|
+
.option('-p, --port <port>', 'Service port', String(descriptor.defaultPort))
|
|
2090
|
+
.option('-f, --file <path>', 'Config patch file (JSON)')
|
|
2091
|
+
.option('--replace', 'Replace entire config instead of merging')
|
|
2092
|
+
.action(async (opts) => {
|
|
2093
|
+
const url = `http://127.0.0.1:${opts.port}`;
|
|
2094
|
+
let patch = {};
|
|
2095
|
+
if (opts.file) {
|
|
2096
|
+
const raw = readFileSync(opts.file, 'utf-8');
|
|
2097
|
+
patch = JSON.parse(raw);
|
|
2098
|
+
}
|
|
2099
|
+
else {
|
|
2100
|
+
// Read from stdin
|
|
2101
|
+
const chunks = [];
|
|
2102
|
+
for await (const chunk of process.stdin) {
|
|
2103
|
+
chunks.push(chunk);
|
|
2104
|
+
}
|
|
2105
|
+
const input = Buffer.concat(chunks).toString('utf-8').trim();
|
|
2106
|
+
if (input) {
|
|
2107
|
+
patch = JSON.parse(input);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
const qs = opts.replace ? '?replace=true' : '';
|
|
2111
|
+
try {
|
|
2112
|
+
const result = await postJson(`${url}/config/apply${qs}`, patch);
|
|
2113
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2114
|
+
}
|
|
2115
|
+
catch (err) {
|
|
2116
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2117
|
+
console.error(`Config apply failed: ${msg}`);
|
|
2118
|
+
process.exitCode = 1;
|
|
2119
|
+
}
|
|
2120
|
+
});
|
|
2121
|
+
// --- init ---
|
|
2122
|
+
program
|
|
2123
|
+
.command('init')
|
|
2124
|
+
.description('Generate default config file')
|
|
2125
|
+
.option('-o, --output <path>', 'Output directory')
|
|
2126
|
+
.action((opts) => {
|
|
2127
|
+
const outputDir = opts.output ?? getComponentConfigDir(descriptor.name);
|
|
2128
|
+
mkdirSync(outputDir, { recursive: true });
|
|
2129
|
+
const configPath = join(outputDir, descriptor.configFileName);
|
|
2130
|
+
if (existsSync(configPath)) {
|
|
2131
|
+
console.log(`Config already exists at ${configPath}`);
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
const template = descriptor.initTemplate();
|
|
2135
|
+
writeFileSync(configPath, JSON.stringify(template, null, 2) + '\n');
|
|
2136
|
+
console.log(`Config written to ${configPath}`);
|
|
2137
|
+
});
|
|
2138
|
+
// --- service ---
|
|
2139
|
+
const serviceCmd = program
|
|
2140
|
+
.command('service')
|
|
2141
|
+
.description('System service management');
|
|
2142
|
+
const svcManager = createServiceManager(descriptor);
|
|
2143
|
+
serviceCmd
|
|
2144
|
+
.command('install')
|
|
2145
|
+
.description('Install as a system service')
|
|
2146
|
+
.option('-c, --config <path>', 'Config file path')
|
|
2147
|
+
.option('-n, --name <name>', 'Service name', defaultServiceName)
|
|
2148
|
+
.action((opts) => {
|
|
2149
|
+
try {
|
|
2150
|
+
svcManager.install({ name: opts.name, configPath: opts.config });
|
|
2151
|
+
console.log(`Service "${opts.name}" installed.`);
|
|
2152
|
+
}
|
|
2153
|
+
catch (err) {
|
|
2154
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2155
|
+
console.error(`Install failed: ${msg}`);
|
|
2156
|
+
process.exitCode = 1;
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
2159
|
+
serviceCmd
|
|
2160
|
+
.command('uninstall')
|
|
2161
|
+
.description('Uninstall the system service')
|
|
2162
|
+
.option('-n, --name <name>', 'Service name', defaultServiceName)
|
|
2163
|
+
.action((opts) => {
|
|
2164
|
+
try {
|
|
2165
|
+
svcManager.uninstall({ name: opts.name });
|
|
2166
|
+
console.log(`Service "${opts.name}" uninstalled.`);
|
|
2167
|
+
}
|
|
2168
|
+
catch (err) {
|
|
2169
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2170
|
+
console.error(`Uninstall failed: ${msg}`);
|
|
2171
|
+
process.exitCode = 1;
|
|
2172
|
+
}
|
|
2173
|
+
});
|
|
2174
|
+
serviceCmd
|
|
2175
|
+
.command('start')
|
|
2176
|
+
.description('Start the system service')
|
|
2177
|
+
.option('-n, --name <name>', 'Service name', defaultServiceName)
|
|
2178
|
+
.action((opts) => {
|
|
2179
|
+
try {
|
|
2180
|
+
svcManager.start({ name: opts.name });
|
|
2181
|
+
console.log(`Service "${opts.name}" started.`);
|
|
2182
|
+
}
|
|
2183
|
+
catch (err) {
|
|
2184
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2185
|
+
console.error(`Start failed: ${msg}`);
|
|
2186
|
+
process.exitCode = 1;
|
|
2187
|
+
}
|
|
2188
|
+
});
|
|
2189
|
+
serviceCmd
|
|
2190
|
+
.command('stop')
|
|
2191
|
+
.description('Stop the system service')
|
|
2192
|
+
.option('-n, --name <name>', 'Service name', defaultServiceName)
|
|
2193
|
+
.action((opts) => {
|
|
2194
|
+
try {
|
|
2195
|
+
svcManager.stop({ name: opts.name });
|
|
2196
|
+
console.log(`Service "${opts.name}" stopped.`);
|
|
2197
|
+
}
|
|
2198
|
+
catch (err) {
|
|
2199
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2200
|
+
console.error(`Stop failed: ${msg}`);
|
|
2201
|
+
process.exitCode = 1;
|
|
2202
|
+
}
|
|
2203
|
+
});
|
|
2204
|
+
serviceCmd
|
|
2205
|
+
.command('restart')
|
|
2206
|
+
.description('Restart the system service')
|
|
2207
|
+
.option('-n, --name <name>', 'Service name', defaultServiceName)
|
|
2208
|
+
.action((opts) => {
|
|
2209
|
+
try {
|
|
2210
|
+
svcManager.restart({ name: opts.name });
|
|
2211
|
+
console.log(`Service "${opts.name}" restarted.`);
|
|
2212
|
+
}
|
|
2213
|
+
catch (err) {
|
|
2214
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2215
|
+
console.error(`Restart failed: ${msg}`);
|
|
2216
|
+
process.exitCode = 1;
|
|
2217
|
+
}
|
|
2218
|
+
});
|
|
2219
|
+
serviceCmd
|
|
2220
|
+
.command('status')
|
|
2221
|
+
.description('Query system service state')
|
|
2222
|
+
.option('-n, --name <name>', 'Service name', defaultServiceName)
|
|
2223
|
+
.action((opts) => {
|
|
2224
|
+
try {
|
|
2225
|
+
const state = svcManager.status({ name: opts.name });
|
|
2226
|
+
console.log(`Service "${opts.name}": ${state}`);
|
|
2227
|
+
}
|
|
2228
|
+
catch (err) {
|
|
2229
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2230
|
+
console.error(`Status failed: ${msg}`);
|
|
2231
|
+
process.exitCode = 1;
|
|
2232
|
+
}
|
|
2233
|
+
});
|
|
2234
|
+
// Apply custom CLI commands if provided
|
|
2235
|
+
if (descriptor.customCliCommands) {
|
|
2236
|
+
// Cast required: @commander-js/extra-typings Command has generic type
|
|
2237
|
+
// parameters that don't align with the descriptor's base Command type.
|
|
2238
|
+
// The descriptor can't know the parent Command's exact generic parameters
|
|
2239
|
+
// at definition time. The cast is safe — customCliCommands only adds
|
|
2240
|
+
// subcommands and doesn't depend on the parent's generic state.
|
|
2241
|
+
descriptor.customCliCommands(program);
|
|
2242
|
+
}
|
|
2243
|
+
return program;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/**
|
|
2247
|
+
* Similarity-based cleanup detection for orphaned managed content.
|
|
2248
|
+
*
|
|
2249
|
+
* @remarks
|
|
2250
|
+
* Uses Jaccard similarity on 3-word shingles (Decision 22) to detect
|
|
2251
|
+
* when orphaned managed content exists in the user content zone.
|
|
2252
|
+
*/
|
|
2253
|
+
/** Default similarity threshold for cleanup detection. */
|
|
2254
|
+
const DEFAULT_THRESHOLD = 0.15;
|
|
2255
|
+
/**
|
|
2256
|
+
* Generate a set of n-word shingles from text.
|
|
2257
|
+
*
|
|
2258
|
+
* @param text - Input text.
|
|
2259
|
+
* @param n - Shingle size (default 3).
|
|
2260
|
+
* @returns Set of n-word shingles.
|
|
412
2261
|
*/
|
|
413
2262
|
function shingles(text, n = 3) {
|
|
414
2263
|
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
|
|
@@ -449,146 +2298,6 @@ function needsCleanup(managedContent, userContent, threshold = DEFAULT_THRESHOLD
|
|
|
449
2298
|
return jaccard(shingles(managedContent), shingles(userContent)) > threshold;
|
|
450
2299
|
}
|
|
451
2300
|
|
|
452
|
-
/**
|
|
453
|
-
* Stable section ordering for managed TOOLS.md blocks.
|
|
454
|
-
*
|
|
455
|
-
* @remarks
|
|
456
|
-
* Sorts sections by the canonical SECTION_ORDER: known sections
|
|
457
|
-
* appear in their defined order, unknown sections are appended after.
|
|
458
|
-
* Used by both parseManaged (for consistent output) and
|
|
459
|
-
* updateManagedSection (for reassembly).
|
|
460
|
-
*/
|
|
461
|
-
/**
|
|
462
|
-
* Sort sections in place by stable ordering.
|
|
463
|
-
*
|
|
464
|
-
* @param sections - Array of managed sections to sort.
|
|
465
|
-
* @returns The sorted array (same reference, mutated in place).
|
|
466
|
-
*/
|
|
467
|
-
function sortSectionsByOrder(sections) {
|
|
468
|
-
return sections.sort((a, b) => {
|
|
469
|
-
const aIdx = SECTION_ORDER.indexOf(a.id);
|
|
470
|
-
const bIdx = SECTION_ORDER.indexOf(b.id);
|
|
471
|
-
const aOrder = aIdx === -1 ? SECTION_ORDER.length : aIdx;
|
|
472
|
-
const bOrder = bIdx === -1 ? SECTION_ORDER.length : bIdx;
|
|
473
|
-
return aOrder - bOrder;
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* Parse managed block from file content.
|
|
479
|
-
*
|
|
480
|
-
* @remarks
|
|
481
|
-
* Extracts managed content delimited by comment markers, parses H2
|
|
482
|
-
* sections within the block, and returns the structured result plus
|
|
483
|
-
* user content outside the markers.
|
|
484
|
-
*/
|
|
485
|
-
/**
|
|
486
|
-
* Build regex patterns for the given markers.
|
|
487
|
-
*
|
|
488
|
-
* @param markers - Begin/end marker strings.
|
|
489
|
-
* @returns Object with begin and end regex patterns.
|
|
490
|
-
*/
|
|
491
|
-
function buildMarkerPatterns(markers) {
|
|
492
|
-
const escapedBegin = markers.begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
493
|
-
const escapedEnd = markers.end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
494
|
-
return {
|
|
495
|
-
beginRe: new RegExp(`^<!--\\s*${escapedBegin}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$`, 'm'),
|
|
496
|
-
endRe: new RegExp(`^<!--\\s*${escapedEnd}\\s*-->\\s*$`, 'm'),
|
|
497
|
-
};
|
|
498
|
-
}
|
|
499
|
-
/**
|
|
500
|
-
* Parse H2 sections from managed block content.
|
|
501
|
-
*
|
|
502
|
-
* @param content - Raw managed block content.
|
|
503
|
-
* @returns Array of parsed sections in stable order.
|
|
504
|
-
*/
|
|
505
|
-
function parseSections(content) {
|
|
506
|
-
const lines = content.split('\n');
|
|
507
|
-
const sections = [];
|
|
508
|
-
let currentId;
|
|
509
|
-
let currentLines = [];
|
|
510
|
-
for (const line of lines) {
|
|
511
|
-
const h2Match = /^## (.+)$/.exec(line);
|
|
512
|
-
if (h2Match) {
|
|
513
|
-
if (currentId !== undefined) {
|
|
514
|
-
sections.push({
|
|
515
|
-
id: currentId,
|
|
516
|
-
content: currentLines.join('\n').trim(),
|
|
517
|
-
});
|
|
518
|
-
}
|
|
519
|
-
currentId = h2Match[1];
|
|
520
|
-
currentLines = [];
|
|
521
|
-
}
|
|
522
|
-
else if (currentId !== undefined) {
|
|
523
|
-
currentLines.push(line);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
if (currentId !== undefined) {
|
|
527
|
-
sections.push({
|
|
528
|
-
id: currentId,
|
|
529
|
-
content: currentLines.join('\n').trim(),
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
return sortSectionsByOrder(sections);
|
|
533
|
-
}
|
|
534
|
-
/**
|
|
535
|
-
* Parse a managed block from file content.
|
|
536
|
-
*
|
|
537
|
-
* @param fileContent - Full file content.
|
|
538
|
-
* @param markers - Optional custom markers (defaults to TOOLS markers).
|
|
539
|
-
* @returns Parsed result with sections, version stamp, and user content.
|
|
540
|
-
*/
|
|
541
|
-
function parseManaged(fileContent, markers = TOOLS_MARKERS) {
|
|
542
|
-
const { beginRe, endRe } = buildMarkerPatterns(markers);
|
|
543
|
-
const beginMatch = beginRe.exec(fileContent);
|
|
544
|
-
if (!beginMatch) {
|
|
545
|
-
return {
|
|
546
|
-
found: false,
|
|
547
|
-
versionStamp: undefined,
|
|
548
|
-
managedContent: '',
|
|
549
|
-
sections: [],
|
|
550
|
-
beforeContent: '',
|
|
551
|
-
userContent: fileContent,
|
|
552
|
-
};
|
|
553
|
-
}
|
|
554
|
-
const endMatch = endRe.exec(fileContent.slice(beginMatch.index + beginMatch[0].length));
|
|
555
|
-
if (!endMatch) {
|
|
556
|
-
// Corrupt: BEGIN without END — treat as fresh file
|
|
557
|
-
return {
|
|
558
|
-
found: false,
|
|
559
|
-
versionStamp: undefined,
|
|
560
|
-
managedContent: '',
|
|
561
|
-
sections: [],
|
|
562
|
-
beforeContent: '',
|
|
563
|
-
userContent: fileContent,
|
|
564
|
-
};
|
|
565
|
-
}
|
|
566
|
-
const beforeContent = fileContent.slice(0, beginMatch.index).trim();
|
|
567
|
-
const managedStart = beginMatch.index + beginMatch[0].length;
|
|
568
|
-
const managedEnd = managedStart + endMatch.index;
|
|
569
|
-
const managedContent = fileContent.slice(managedStart, managedEnd).trim();
|
|
570
|
-
const afterEnd = managedStart + endMatch.index + endMatch[0].length;
|
|
571
|
-
const userContent = fileContent.slice(afterEnd).trim();
|
|
572
|
-
// Extract version stamp from BEGIN marker line
|
|
573
|
-
let versionStamp;
|
|
574
|
-
const stampMatch = VERSION_STAMP_PATTERN.exec(beginMatch[0]);
|
|
575
|
-
if (stampMatch?.[2] && stampMatch[3]) {
|
|
576
|
-
versionStamp = {
|
|
577
|
-
version: stampMatch[2],
|
|
578
|
-
timestamp: stampMatch[3],
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
|
-
const sections = parseSections(managedContent);
|
|
582
|
-
return {
|
|
583
|
-
found: true,
|
|
584
|
-
versionStamp,
|
|
585
|
-
managedContent,
|
|
586
|
-
sections,
|
|
587
|
-
beforeContent,
|
|
588
|
-
userContent,
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
|
|
592
2301
|
/**
|
|
593
2302
|
* Strip foreign managed blocks from content.
|
|
594
2303
|
*
|
|
@@ -631,55 +2340,6 @@ function stripForeignMarkers(content, currentMarkers) {
|
|
|
631
2340
|
return result.replace(/\n{3,}/g, '\n\n').trim();
|
|
632
2341
|
}
|
|
633
2342
|
|
|
634
|
-
/**
|
|
635
|
-
* Version-stamp parsing and convergence logic.
|
|
636
|
-
*
|
|
637
|
-
* @remarks
|
|
638
|
-
* When multiple component plugins bundle different core library versions,
|
|
639
|
-
* they independently maintain shared managed content. The version-stamp
|
|
640
|
-
* mechanism ensures convergence without coordination state.
|
|
641
|
-
*/
|
|
642
|
-
/**
|
|
643
|
-
* Format the BEGIN marker comment with a version stamp.
|
|
644
|
-
*
|
|
645
|
-
* @param markerText - The marker text (e.g., 'BEGIN JEEVES PLATFORM TOOLS').
|
|
646
|
-
* @param version - The core library version.
|
|
647
|
-
* @returns Formatted comment line.
|
|
648
|
-
*/
|
|
649
|
-
function formatBeginMarker(markerText, version) {
|
|
650
|
-
const timestamp = new Date().toISOString();
|
|
651
|
-
return `<!-- ${markerText} | core:${version} | ${timestamp} -->`;
|
|
652
|
-
}
|
|
653
|
-
/**
|
|
654
|
-
* Format the END marker comment.
|
|
655
|
-
*
|
|
656
|
-
* @param markerText - The marker text (e.g., 'END JEEVES PLATFORM TOOLS').
|
|
657
|
-
* @returns Formatted comment line.
|
|
658
|
-
*/
|
|
659
|
-
function formatEndMarker(markerText) {
|
|
660
|
-
return `<!-- ${markerText} -->`;
|
|
661
|
-
}
|
|
662
|
-
/**
|
|
663
|
-
* Determine whether this writer should proceed based on version-stamp
|
|
664
|
-
* convergence rules.
|
|
665
|
-
*
|
|
666
|
-
* @param myVersion - The current core library version.
|
|
667
|
-
* @param existing - The existing version stamp (if any).
|
|
668
|
-
* @param stalenessThresholdMs - Staleness threshold in ms (default: 5 min).
|
|
669
|
-
* @returns `true` if the writer should proceed with the write.
|
|
670
|
-
*/
|
|
671
|
-
function shouldWrite(myVersion, existing, stalenessThresholdMs = STALENESS_THRESHOLD_MS) {
|
|
672
|
-
// No existing stamp — always write
|
|
673
|
-
if (!existing)
|
|
674
|
-
return true;
|
|
675
|
-
// My version >= stamped version — always write (I'm current or newer)
|
|
676
|
-
if (gte(myVersion, existing.version))
|
|
677
|
-
return true;
|
|
678
|
-
// My version < stamped version — check staleness
|
|
679
|
-
const stampAge = Date.now() - new Date(existing.timestamp).getTime();
|
|
680
|
-
return stampAge >= stalenessThresholdMs;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
2343
|
/**
|
|
684
2344
|
* Generic managed-section writer with block and section modes.
|
|
685
2345
|
*
|
|
@@ -747,32 +2407,51 @@ async function updateManagedSection(filePath, content, options = {}) {
|
|
|
747
2407
|
? `# ${markers.title}\n\n${sectionText}`
|
|
748
2408
|
: sectionText;
|
|
749
2409
|
}
|
|
2410
|
+
// Combine beforeContent + userContent for the user zone.
|
|
2411
|
+
// When migrating from top→bottom, beforeContent is empty and
|
|
2412
|
+
// userContent has the real content. When already at bottom,
|
|
2413
|
+
// beforeContent has the user content and userContent is empty.
|
|
2414
|
+
const rawUserContent = [parsed.beforeContent, parsed.userContent]
|
|
2415
|
+
.filter(Boolean)
|
|
2416
|
+
.join('\n\n')
|
|
2417
|
+
.trim();
|
|
750
2418
|
// Strip foreign managed blocks from user content (cross-contamination fix)
|
|
751
|
-
const userContent = stripForeignMarkers(
|
|
2419
|
+
const userContent = stripForeignMarkers(rawUserContent, markers);
|
|
752
2420
|
const cleanupNeeded = needsCleanup(newManagedBody, userContent);
|
|
753
2421
|
// Build the full managed block
|
|
754
2422
|
const beginLine = formatBeginMarker(markers.begin, coreVersion);
|
|
755
2423
|
const endLine = formatEndMarker(markers.end);
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
parts.push(parsed.beforeContent);
|
|
759
|
-
parts.push('');
|
|
760
|
-
}
|
|
761
|
-
parts.push(beginLine);
|
|
2424
|
+
const managedParts = [];
|
|
2425
|
+
managedParts.push(beginLine);
|
|
762
2426
|
if (cleanupNeeded) {
|
|
763
|
-
|
|
764
|
-
|
|
2427
|
+
managedParts.push('');
|
|
2428
|
+
managedParts.push(CLEANUP_FLAG);
|
|
2429
|
+
}
|
|
2430
|
+
managedParts.push('');
|
|
2431
|
+
managedParts.push(newManagedBody);
|
|
2432
|
+
managedParts.push('');
|
|
2433
|
+
managedParts.push(endLine);
|
|
2434
|
+
const managedBlock = managedParts.join('\n');
|
|
2435
|
+
const position = markers.position ?? 'top';
|
|
2436
|
+
const fileParts = [];
|
|
2437
|
+
if (position === 'bottom') {
|
|
2438
|
+
// User content first, managed block at end
|
|
2439
|
+
if (userContent) {
|
|
2440
|
+
fileParts.push(userContent);
|
|
2441
|
+
fileParts.push('');
|
|
2442
|
+
}
|
|
2443
|
+
fileParts.push(managedBlock);
|
|
765
2444
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
2445
|
+
else {
|
|
2446
|
+
// Managed block first (legacy default), user content below
|
|
2447
|
+
fileParts.push(managedBlock);
|
|
2448
|
+
if (userContent) {
|
|
2449
|
+
fileParts.push('');
|
|
2450
|
+
fileParts.push(userContent);
|
|
2451
|
+
}
|
|
773
2452
|
}
|
|
774
|
-
|
|
775
|
-
const newFileContent =
|
|
2453
|
+
fileParts.push('');
|
|
2454
|
+
const newFileContent = fileParts.join('\n');
|
|
776
2455
|
atomicWrite(filePath, newFileContent);
|
|
777
2456
|
});
|
|
778
2457
|
}
|
|
@@ -850,6 +2529,8 @@ At minimum, always brief sub-agents on:
|
|
|
850
2529
|
|
|
851
2530
|
**Anything important enough to have a permanent cron/heartbeat entry is important enough to be codified into the data flow.**
|
|
852
2531
|
|
|
2532
|
+
**HEARTBEAT efficiency:** When HEARTBEAT fires with multiple component alerts, resolve them all in a single conversation. Walk the user through each component in dependency order. Each heartbeat cycle is a full LLM session — resolve all alerts in one pass.
|
|
2533
|
+
|
|
853
2534
|
## Messaging Dispatch
|
|
854
2535
|
|
|
855
2536
|
**Same-channel replies:** Don't use the \`message\` tool. It fires immediately, jumping ahead of streaming narration. Just write text as your response. Use \`message\` only for **cross-channel** sends (sending to a different channel or DM than the one you're responding in).
|
|
@@ -858,7 +2539,7 @@ At minimum, always brief sub-agents on:
|
|
|
858
2539
|
|
|
859
2540
|
## Heartbeat Discipline
|
|
860
2541
|
|
|
861
|
-
**Default state of HEARTBEAT.md is
|
|
2542
|
+
**Default state of HEARTBEAT.md is effectively empty** — the \`# Jeeves Platform Status\` section contains only headings when all components are healthy or declined. No list content, no LLM cost. User heartbeat items (above the platform status section) follow the same transient/termination rules as before.
|
|
862
2543
|
|
|
863
2544
|
Heartbeat items are for **transient, session-requiring work-in-progress ONLY**. Each item must include its own termination condition.
|
|
864
2545
|
|
|
@@ -919,14 +2600,11 @@ When discovering a new data source, integrate it into the existing data flow pip
|
|
|
919
2600
|
|
|
920
2601
|
## Bootstrap Protocol
|
|
921
2602
|
|
|
922
|
-
|
|
2603
|
+
**Proactive platform status:** HEARTBEAT.md is loaded every session. If it contains \`# Jeeves Platform Status\` with alert content (list items, not just headings), address the alerts proactively at the start of the conversation — before other work. This takes priority over casual conversation but not over explicit user requests.
|
|
923
2604
|
|
|
924
|
-
|
|
925
|
-
2. **Inform** the user what's missing and what it provides
|
|
926
|
-
3. **Guide** them through installation using the component's CLI install command
|
|
927
|
-
4. **Verify** the installation succeeded (probe the port, check the plugin registration)
|
|
2605
|
+
**Follow the instructions:** Explain the component, ask for consent, execute the command, verify. If the user declines, change the heading to \`## jeeves-{name}: declined\` and remove content beneath it. Do not prompt for declined components. Do not improvise — the HEARTBEAT instructions are authoritative.
|
|
928
2606
|
|
|
929
|
-
|
|
2607
|
+
When resolving multiple component alerts, walk the user through each in dependency order (watcher before meta, runner and server independent) within a single conversation rather than one per heartbeat cycle.
|
|
930
2608
|
|
|
931
2609
|
## Em-Dash Discipline
|
|
932
2610
|
|
|
@@ -1165,330 +2843,106 @@ Read these templates when creating new specs, onboarding to new projects, or whe
|
|
|
1165
2843
|
* When bundled into a consumer plugin, returns undefined and template
|
|
1166
2844
|
* copying is skipped (templates are seeded by `jeeves install`, not plugins).
|
|
1167
2845
|
*
|
|
1168
|
-
* Content `.md` files (soul, agents, platform template) are inlined at
|
|
1169
|
-
* build time via the rollup md plugin and imported as string literals.
|
|
1170
|
-
* They do not use this function.
|
|
1171
|
-
*
|
|
1172
|
-
* @returns Absolute path to the content/ directory, or undefined.
|
|
1173
|
-
*/
|
|
1174
|
-
function getContentDir() {
|
|
1175
|
-
const pkgDir = packageDirectorySync({
|
|
1176
|
-
cwd: fileURLToPath(import.meta.url),
|
|
1177
|
-
});
|
|
1178
|
-
if (!pkgDir)
|
|
1179
|
-
return undefined;
|
|
1180
|
-
const dir = join(pkgDir, 'content');
|
|
1181
|
-
return existsSync(dir) ? dir : undefined;
|
|
1182
|
-
}
|
|
1183
|
-
/**
|
|
1184
|
-
* Copy templates from content/templates/ to the core config directory.
|
|
1185
|
-
*
|
|
1186
|
-
* @param coreConfigDir - Core config directory path.
|
|
1187
|
-
*/
|
|
1188
|
-
function copyTemplates(coreConfigDir) {
|
|
1189
|
-
const contentDir = getContentDir();
|
|
1190
|
-
if (!contentDir)
|
|
1191
|
-
return;
|
|
1192
|
-
const sourceDir = join(contentDir, 'templates');
|
|
1193
|
-
if (!existsSync(sourceDir))
|
|
1194
|
-
return;
|
|
1195
|
-
const destDir = join(coreConfigDir, TEMPLATES_DIR);
|
|
1196
|
-
if (!existsSync(destDir)) {
|
|
1197
|
-
mkdirSync(destDir, { recursive: true });
|
|
1198
|
-
}
|
|
1199
|
-
cpSync(sourceDir, destDir, { recursive: true });
|
|
1200
|
-
}
|
|
1201
|
-
/**
|
|
1202
|
-
* Render the Platform template using simple string replacement.
|
|
1203
|
-
*
|
|
1204
|
-
* @param templatePath - Path to the templates directory.
|
|
1205
|
-
* @returns Rendered platform content string.
|
|
1206
|
-
*/
|
|
1207
|
-
function renderPlatformTemplate(templatePath) {
|
|
1208
|
-
const templatesAvailable = existsSync(templatePath);
|
|
1209
|
-
let content = toolsPlatformTemplate;
|
|
1210
|
-
// Handle <!-- IF_TEMPLATES --> ... <!-- ELSE_TEMPLATES --> ... <!-- ENDIF_TEMPLATES --> block
|
|
1211
|
-
const ifRegex = /<!-- IF_TEMPLATES -->([\s\S]*?)<!-- ELSE_TEMPLATES -->([\s\S]*?)<!-- ENDIF_TEMPLATES -->/;
|
|
1212
|
-
const match = ifRegex.exec(content);
|
|
1213
|
-
if (match) {
|
|
1214
|
-
content = content.replace(match[0], templatesAvailable ? match[1] : match[2]);
|
|
1215
|
-
}
|
|
1216
|
-
// Replace __TEMPLATE_PATH__ with the actual path
|
|
1217
|
-
content = content.replace(/__TEMPLATE_PATH__/g, templatePath);
|
|
1218
|
-
return content;
|
|
1219
|
-
}
|
|
1220
|
-
/**
|
|
1221
|
-
* Refresh platform content: SOUL.md, AGENTS.md, and TOOLS.md Platform section.
|
|
1222
|
-
*
|
|
1223
|
-
* @param options - Configuration for the refresh cycle.
|
|
1224
|
-
*/
|
|
1225
|
-
async function refreshPlatformContent(options) {
|
|
1226
|
-
const { coreVersion, componentName, componentVersion, servicePackage, pluginPackage, stalenessThresholdMs, } = options;
|
|
1227
|
-
const workspacePath = getWorkspacePath();
|
|
1228
|
-
const coreConfigDir = getCoreConfigDir();
|
|
1229
|
-
// 1. Write calling component's version entry
|
|
1230
|
-
if (componentName) {
|
|
1231
|
-
writeComponentVersion(coreConfigDir, {
|
|
1232
|
-
componentName,
|
|
1233
|
-
pluginVersion: componentVersion,
|
|
1234
|
-
servicePackage,
|
|
1235
|
-
pluginPackage,
|
|
1236
|
-
});
|
|
1237
|
-
}
|
|
1238
|
-
// 2. Render Platform template
|
|
1239
|
-
const templatePath = join(coreConfigDir, TEMPLATES_DIR);
|
|
1240
|
-
const platformContent = renderPlatformTemplate(templatePath);
|
|
1241
|
-
// 3. Write TOOLS.md Platform section
|
|
1242
|
-
const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
|
|
1243
|
-
await updateManagedSection(toolsPath, platformContent, {
|
|
1244
|
-
mode: 'section',
|
|
1245
|
-
sectionId: 'Platform',
|
|
1246
|
-
markers: TOOLS_MARKERS,
|
|
1247
|
-
coreVersion,
|
|
1248
|
-
stalenessThresholdMs,
|
|
1249
|
-
});
|
|
1250
|
-
// 4. Write SOUL.md managed block
|
|
1251
|
-
const soulPath = join(workspacePath, WORKSPACE_FILES.soul);
|
|
1252
|
-
await updateManagedSection(soulPath, soulSectionContent, {
|
|
1253
|
-
mode: 'block',
|
|
1254
|
-
markers: SOUL_MARKERS,
|
|
1255
|
-
coreVersion,
|
|
1256
|
-
stalenessThresholdMs,
|
|
1257
|
-
});
|
|
1258
|
-
// 5. Write AGENTS.md managed block
|
|
1259
|
-
const agentsPath = join(workspacePath, WORKSPACE_FILES.agents);
|
|
1260
|
-
await updateManagedSection(agentsPath, agentsSectionContent, {
|
|
1261
|
-
mode: 'block',
|
|
1262
|
-
markers: AGENTS_MARKERS,
|
|
1263
|
-
coreVersion,
|
|
1264
|
-
stalenessThresholdMs,
|
|
1265
|
-
});
|
|
1266
|
-
// 6. Copy templates to config dir
|
|
1267
|
-
copyTemplates(coreConfigDir);
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
/**
|
|
1271
|
-
* Timer-based orchestrator for managed content writing.
|
|
1272
|
-
*
|
|
1273
|
-
* @remarks
|
|
1274
|
-
* `ComponentWriter` manages a component's TOOLS.md section writes
|
|
1275
|
-
* and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
|
|
1276
|
-
* on a configurable prime-interval timer cycle.
|
|
1277
|
-
*/
|
|
1278
|
-
/**
|
|
1279
|
-
* Orchestrates managed content writing for a single Jeeves component.
|
|
1280
|
-
*
|
|
1281
|
-
* @remarks
|
|
1282
|
-
* Created via `createComponentWriter()`. Manages a timer that fires
|
|
1283
|
-
* at the component's prime-interval, calling `generateToolsContent()`
|
|
1284
|
-
* and `refreshPlatformContent()` on each cycle.
|
|
1285
|
-
*/
|
|
1286
|
-
class ComponentWriter {
|
|
1287
|
-
timer;
|
|
1288
|
-
component;
|
|
1289
|
-
configDir;
|
|
1290
|
-
/** @internal */
|
|
1291
|
-
constructor(component) {
|
|
1292
|
-
this.component = component;
|
|
1293
|
-
this.configDir = getComponentConfigDir(component.name);
|
|
1294
|
-
}
|
|
1295
|
-
/** The component's config directory path. */
|
|
1296
|
-
get componentConfigDir() {
|
|
1297
|
-
return this.configDir;
|
|
1298
|
-
}
|
|
1299
|
-
/** Whether the writer timer is currently running. */
|
|
1300
|
-
get isRunning() {
|
|
1301
|
-
return this.timer !== undefined;
|
|
1302
|
-
}
|
|
1303
|
-
/**
|
|
1304
|
-
* Start the writer timer.
|
|
1305
|
-
*
|
|
1306
|
-
* @remarks
|
|
1307
|
-
* Performs an immediate first write, then sets up the interval.
|
|
1308
|
-
*/
|
|
1309
|
-
start() {
|
|
1310
|
-
if (this.timer)
|
|
1311
|
-
return;
|
|
1312
|
-
// Fire immediately, then on interval
|
|
1313
|
-
void this.cycle();
|
|
1314
|
-
this.timer = setInterval(() => void this.cycle(), this.component.refreshIntervalSeconds * 1000);
|
|
1315
|
-
}
|
|
1316
|
-
/** Stop the writer timer. */
|
|
1317
|
-
stop() {
|
|
1318
|
-
if (this.timer) {
|
|
1319
|
-
clearInterval(this.timer);
|
|
1320
|
-
this.timer = undefined;
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
/**
|
|
1324
|
-
* Execute a single write cycle.
|
|
1325
|
-
*
|
|
1326
|
-
* @remarks
|
|
1327
|
-
* Calls `generateToolsContent()` and writes the component's
|
|
1328
|
-
* TOOLS.md section via `updateManagedSection()`. Also calls
|
|
1329
|
-
* `refreshPlatformContent()` for shared content maintenance.
|
|
1330
|
-
*/
|
|
1331
|
-
async cycle() {
|
|
1332
|
-
try {
|
|
1333
|
-
const workspacePath = getWorkspacePath();
|
|
1334
|
-
const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
|
|
1335
|
-
// Write the component's TOOLS.md section
|
|
1336
|
-
const toolsContent = this.component.generateToolsContent();
|
|
1337
|
-
await updateManagedSection(toolsPath, toolsContent, {
|
|
1338
|
-
mode: 'section',
|
|
1339
|
-
sectionId: this.component.sectionId,
|
|
1340
|
-
markers: TOOLS_MARKERS,
|
|
1341
|
-
coreVersion: CORE_VERSION,
|
|
1342
|
-
});
|
|
1343
|
-
// Platform content maintenance: SOUL.md, AGENTS.md, Platform section
|
|
1344
|
-
await refreshPlatformContent({
|
|
1345
|
-
coreVersion: CORE_VERSION,
|
|
1346
|
-
componentName: this.component.name,
|
|
1347
|
-
componentVersion: this.component.version,
|
|
1348
|
-
servicePackage: this.component.servicePackage,
|
|
1349
|
-
pluginPackage: this.component.pluginPackage,
|
|
1350
|
-
});
|
|
1351
|
-
}
|
|
1352
|
-
catch (err) {
|
|
1353
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1354
|
-
console.warn(`jeeves-core: ComponentWriter cycle failed for ${this.component.name}: ${message}`);
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
/**
|
|
1360
|
-
* Creates a synchronous content accessor backed by an async data source.
|
|
1361
|
-
*
|
|
1362
|
-
* @remarks
|
|
1363
|
-
* Solves the sync/async gap in `JeevesComponent.generateToolsContent()`:
|
|
1364
|
-
* the interface is synchronous, but most components fetch live data from
|
|
1365
|
-
* their HTTP service. This utility returns a sync `() => string` that
|
|
1366
|
-
* serves the last successfully fetched value while kicking off a background
|
|
1367
|
-
* refresh on each call.
|
|
1368
|
-
*
|
|
1369
|
-
* First call returns `placeholder`. Subsequent calls return the last
|
|
1370
|
-
* successfully fetched content. If a refresh fails, the previous good
|
|
1371
|
-
* value is retained.
|
|
1372
|
-
*
|
|
1373
|
-
* @example
|
|
1374
|
-
* ```typescript
|
|
1375
|
-
* const getContent = createAsyncContentCache({
|
|
1376
|
-
* fetch: async () => {
|
|
1377
|
-
* const res = await fetch('http://127.0.0.1:1936/status');
|
|
1378
|
-
* return formatWatcherStatus(await res.json());
|
|
1379
|
-
* },
|
|
1380
|
-
* placeholder: '> Initializing watcher status...',
|
|
1381
|
-
* });
|
|
1382
|
-
*
|
|
1383
|
-
* const writer = createComponentWriter({
|
|
1384
|
-
* // ...
|
|
1385
|
-
* generateToolsContent: getContent,
|
|
1386
|
-
* });
|
|
1387
|
-
* ```
|
|
1388
|
-
*/
|
|
1389
|
-
/**
|
|
1390
|
-
* Creates a synchronous content accessor backed by an async data source.
|
|
1391
|
-
*
|
|
1392
|
-
* @param options - Cache configuration.
|
|
1393
|
-
* @returns A sync `() => string` suitable for `generateToolsContent`.
|
|
1394
|
-
*/
|
|
1395
|
-
function createAsyncContentCache(options) {
|
|
1396
|
-
const { fetch: fetchContent, placeholder = '> Initializing...', onError = (err) => {
|
|
1397
|
-
console.warn('[jeeves] async content cache refresh failed:', err);
|
|
1398
|
-
}, } = options;
|
|
1399
|
-
let cached = placeholder;
|
|
1400
|
-
let refreshing = false;
|
|
1401
|
-
return () => {
|
|
1402
|
-
if (!refreshing) {
|
|
1403
|
-
refreshing = true;
|
|
1404
|
-
fetchContent()
|
|
1405
|
-
.then((content) => {
|
|
1406
|
-
cached = content;
|
|
1407
|
-
})
|
|
1408
|
-
.catch(onError)
|
|
1409
|
-
.finally(() => {
|
|
1410
|
-
refreshing = false;
|
|
1411
|
-
});
|
|
1412
|
-
}
|
|
1413
|
-
return cached;
|
|
1414
|
-
};
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
/**
|
|
1418
|
-
* Factory function for creating a ComponentWriter.
|
|
2846
|
+
* Content `.md` files (soul, agents, platform template) are inlined at
|
|
2847
|
+
* build time via the rollup md plugin and imported as string literals.
|
|
2848
|
+
* They do not use this function.
|
|
1419
2849
|
*
|
|
1420
|
-
* @
|
|
1421
|
-
* Validates the component descriptor at runtime:
|
|
1422
|
-
* - `refreshIntervalSeconds` must be a prime number
|
|
1423
|
-
* - `serviceCommands` and `pluginCommands` must be provided
|
|
1424
|
-
* - `name`, `version`, `sectionId` must be non-empty strings
|
|
1425
|
-
* - `generateToolsContent` must be a function
|
|
2850
|
+
* @returns Absolute path to the content/ directory, or undefined.
|
|
1426
2851
|
*/
|
|
2852
|
+
function getContentDir() {
|
|
2853
|
+
const pkgDir = packageDirectorySync({
|
|
2854
|
+
cwd: fileURLToPath(import.meta.url),
|
|
2855
|
+
});
|
|
2856
|
+
if (!pkgDir)
|
|
2857
|
+
return undefined;
|
|
2858
|
+
const dir = join(pkgDir, 'content');
|
|
2859
|
+
return existsSync(dir) ? dir : undefined;
|
|
2860
|
+
}
|
|
1427
2861
|
/**
|
|
1428
|
-
*
|
|
2862
|
+
* Copy templates from content/templates/ to the core config directory.
|
|
1429
2863
|
*
|
|
1430
|
-
* @param
|
|
1431
|
-
* @returns `true` if n is prime.
|
|
2864
|
+
* @param coreConfigDir - Core config directory path.
|
|
1432
2865
|
*/
|
|
1433
|
-
function
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
if (
|
|
1439
|
-
return
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
2866
|
+
function copyTemplates(coreConfigDir) {
|
|
2867
|
+
const contentDir = getContentDir();
|
|
2868
|
+
if (!contentDir)
|
|
2869
|
+
return;
|
|
2870
|
+
const sourceDir = join(contentDir, 'templates');
|
|
2871
|
+
if (!existsSync(sourceDir))
|
|
2872
|
+
return;
|
|
2873
|
+
const destDir = join(coreConfigDir, TEMPLATES_DIR);
|
|
2874
|
+
if (!existsSync(destDir)) {
|
|
2875
|
+
mkdirSync(destDir, { recursive: true });
|
|
1443
2876
|
}
|
|
1444
|
-
|
|
2877
|
+
cpSync(sourceDir, destDir, { recursive: true });
|
|
1445
2878
|
}
|
|
1446
2879
|
/**
|
|
1447
|
-
*
|
|
2880
|
+
* Render the Platform template using simple string replacement.
|
|
1448
2881
|
*
|
|
1449
|
-
* @param
|
|
1450
|
-
* @
|
|
2882
|
+
* @param templatePath - Path to the templates directory.
|
|
2883
|
+
* @returns Rendered platform content string.
|
|
1451
2884
|
*/
|
|
1452
|
-
function
|
|
1453
|
-
const
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
if (!component['sectionId'] || typeof component['sectionId'] !== 'string') {
|
|
1461
|
-
throw new Error('JeevesComponent.sectionId must be a non-empty string');
|
|
1462
|
-
}
|
|
1463
|
-
if (typeof component['refreshIntervalSeconds'] !== 'number' ||
|
|
1464
|
-
!isPrime(component['refreshIntervalSeconds'])) {
|
|
1465
|
-
throw new Error(`JeevesComponent.refreshIntervalSeconds must be a prime number, got ${String(component['refreshIntervalSeconds'])}`);
|
|
1466
|
-
}
|
|
1467
|
-
if (typeof component['generateToolsContent'] !== 'function') {
|
|
1468
|
-
throw new Error('JeevesComponent.generateToolsContent must be a function');
|
|
1469
|
-
}
|
|
1470
|
-
const svc = component['serviceCommands'];
|
|
1471
|
-
if (!svc ||
|
|
1472
|
-
typeof svc['stop'] !== 'function' ||
|
|
1473
|
-
typeof svc['uninstall'] !== 'function' ||
|
|
1474
|
-
typeof svc['status'] !== 'function') {
|
|
1475
|
-
throw new Error('JeevesComponent.serviceCommands must provide stop, uninstall, and status functions');
|
|
1476
|
-
}
|
|
1477
|
-
const plg = component['pluginCommands'];
|
|
1478
|
-
if (!plg || typeof plg['uninstall'] !== 'function') {
|
|
1479
|
-
throw new Error('JeevesComponent.pluginCommands must provide an uninstall function');
|
|
2885
|
+
function renderPlatformTemplate(templatePath) {
|
|
2886
|
+
const templatesAvailable = existsSync(templatePath);
|
|
2887
|
+
let content = toolsPlatformTemplate;
|
|
2888
|
+
// Handle <!-- IF_TEMPLATES --> ... <!-- ELSE_TEMPLATES --> ... <!-- ENDIF_TEMPLATES --> block
|
|
2889
|
+
const ifRegex = /<!-- IF_TEMPLATES -->([\s\S]*?)<!-- ELSE_TEMPLATES -->([\s\S]*?)<!-- ENDIF_TEMPLATES -->/;
|
|
2890
|
+
const match = ifRegex.exec(content);
|
|
2891
|
+
if (match) {
|
|
2892
|
+
content = content.replace(match[0], templatesAvailable ? match[1] : match[2]);
|
|
1480
2893
|
}
|
|
2894
|
+
// Replace __TEMPLATE_PATH__ with the actual path
|
|
2895
|
+
content = content.replace(/__TEMPLATE_PATH__/g, templatePath);
|
|
2896
|
+
return content;
|
|
1481
2897
|
}
|
|
1482
2898
|
/**
|
|
1483
|
-
*
|
|
2899
|
+
* Refresh platform content: SOUL.md, AGENTS.md, and TOOLS.md Platform section.
|
|
1484
2900
|
*
|
|
1485
|
-
* @param
|
|
1486
|
-
* @returns A new `ComponentWriter` instance.
|
|
1487
|
-
* @throws Error if the component descriptor is invalid.
|
|
2901
|
+
* @param options - Configuration for the refresh cycle.
|
|
1488
2902
|
*/
|
|
1489
|
-
function
|
|
1490
|
-
|
|
1491
|
-
|
|
2903
|
+
async function refreshPlatformContent(options) {
|
|
2904
|
+
const { coreVersion, componentName, componentVersion, servicePackage, pluginPackage, stalenessThresholdMs, } = options;
|
|
2905
|
+
const workspacePath = getWorkspacePath();
|
|
2906
|
+
const coreConfigDir = getCoreConfigDir();
|
|
2907
|
+
// 1. Write calling component's version entry
|
|
2908
|
+
if (componentName) {
|
|
2909
|
+
writeComponentVersion(coreConfigDir, {
|
|
2910
|
+
componentName,
|
|
2911
|
+
pluginVersion: componentVersion,
|
|
2912
|
+
servicePackage,
|
|
2913
|
+
pluginPackage,
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2916
|
+
// 2. Render Platform template
|
|
2917
|
+
const templatePath = join(coreConfigDir, TEMPLATES_DIR);
|
|
2918
|
+
const platformContent = renderPlatformTemplate(templatePath);
|
|
2919
|
+
// 3. Write TOOLS.md Platform section
|
|
2920
|
+
const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
|
|
2921
|
+
await updateManagedSection(toolsPath, platformContent, {
|
|
2922
|
+
mode: 'section',
|
|
2923
|
+
sectionId: 'Platform',
|
|
2924
|
+
markers: TOOLS_MARKERS,
|
|
2925
|
+
coreVersion,
|
|
2926
|
+
stalenessThresholdMs,
|
|
2927
|
+
});
|
|
2928
|
+
// 4. Write SOUL.md managed block
|
|
2929
|
+
const soulPath = join(workspacePath, WORKSPACE_FILES.soul);
|
|
2930
|
+
await updateManagedSection(soulPath, soulSectionContent, {
|
|
2931
|
+
mode: 'block',
|
|
2932
|
+
markers: SOUL_MARKERS,
|
|
2933
|
+
coreVersion,
|
|
2934
|
+
stalenessThresholdMs,
|
|
2935
|
+
});
|
|
2936
|
+
// 5. Write AGENTS.md managed block
|
|
2937
|
+
const agentsPath = join(workspacePath, WORKSPACE_FILES.agents);
|
|
2938
|
+
await updateManagedSection(agentsPath, agentsSectionContent, {
|
|
2939
|
+
mode: 'block',
|
|
2940
|
+
markers: AGENTS_MARKERS,
|
|
2941
|
+
coreVersion,
|
|
2942
|
+
stalenessThresholdMs,
|
|
2943
|
+
});
|
|
2944
|
+
// 6. Copy templates to config dir
|
|
2945
|
+
copyTemplates(coreConfigDir);
|
|
1492
2946
|
}
|
|
1493
2947
|
|
|
1494
2948
|
/**
|
|
@@ -1506,12 +2960,22 @@ const serviceEntrySchema = z.object({
|
|
|
1506
2960
|
/** Service URL (must be a valid URL). */
|
|
1507
2961
|
url: z.string().url().describe('Service URL'),
|
|
1508
2962
|
});
|
|
2963
|
+
/** Default bind address for all Jeeves services. */
|
|
2964
|
+
const DEFAULT_BIND_ADDRESS = '0.0.0.0';
|
|
1509
2965
|
/** Zod schema for the core config file. */
|
|
1510
2966
|
const coreConfigSchema = z.object({
|
|
1511
2967
|
/** JSON Schema pointer for IDE autocomplete. */
|
|
1512
2968
|
$schema: z.string().optional().describe('JSON Schema pointer'),
|
|
1513
2969
|
/** Owner identity keys (canonical identityLinks references). */
|
|
1514
2970
|
owners: z.array(z.string()).default([]).describe('Owner identity keys'),
|
|
2971
|
+
/**
|
|
2972
|
+
* Bind address for all Jeeves services. Default: `0.0.0.0` (all interfaces).
|
|
2973
|
+
* Individual components can override in their own config.
|
|
2974
|
+
*/
|
|
2975
|
+
bindAddress: z
|
|
2976
|
+
.string()
|
|
2977
|
+
.default(DEFAULT_BIND_ADDRESS)
|
|
2978
|
+
.describe('Bind address for all Jeeves services'),
|
|
1515
2979
|
/** Service URL overrides keyed by service name. */
|
|
1516
2980
|
services: z
|
|
1517
2981
|
.record(z.string(), serviceEntrySchema)
|
|
@@ -1548,6 +3012,11 @@ function generateJsonSchema() {
|
|
|
1548
3012
|
items: { type: 'string' },
|
|
1549
3013
|
default: [],
|
|
1550
3014
|
},
|
|
3015
|
+
bindAddress: {
|
|
3016
|
+
type: 'string',
|
|
3017
|
+
default: '0.0.0.0',
|
|
3018
|
+
description: 'Bind address for all Jeeves services',
|
|
3019
|
+
},
|
|
1551
3020
|
services: {
|
|
1552
3021
|
type: 'object',
|
|
1553
3022
|
additionalProperties: {
|
|
@@ -1691,395 +3160,532 @@ function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
|
|
|
1691
3160
|
}
|
|
1692
3161
|
|
|
1693
3162
|
/**
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1696
|
-
* @remarks
|
|
1697
|
-
* Supports two modes:
|
|
1698
|
-
* - No `sectionId`: Remove the entire managed block (markers + content),
|
|
1699
|
-
* leaving user content intact.
|
|
1700
|
-
* - With `sectionId`: Remove a specific H2 section from within the
|
|
1701
|
-
* managed block. If it was the last section, remove the entire block.
|
|
1702
|
-
*
|
|
1703
|
-
* Provides file-level locking and atomic writes (temp file + rename).
|
|
1704
|
-
* Missing markers or nonexistent sections are no-ops (no error thrown).
|
|
1705
|
-
*/
|
|
1706
|
-
/**
|
|
1707
|
-
* Remove a managed section or entire managed block from a file.
|
|
1708
|
-
*
|
|
1709
|
-
* @param filePath - Absolute path to the target file.
|
|
1710
|
-
* @param options - Optional section ID and custom markers.
|
|
1711
|
-
*/
|
|
1712
|
-
async function removeManagedSection(filePath, options = {}) {
|
|
1713
|
-
const { sectionId, markers = TOOLS_MARKERS } = options;
|
|
1714
|
-
if (!existsSync(filePath))
|
|
1715
|
-
return;
|
|
1716
|
-
await withFileLock(filePath, () => {
|
|
1717
|
-
const fileContent = readFileSync(filePath, 'utf-8');
|
|
1718
|
-
const parsed = parseManaged(fileContent, markers);
|
|
1719
|
-
if (!parsed.found)
|
|
1720
|
-
return;
|
|
1721
|
-
let newContent;
|
|
1722
|
-
if (!sectionId) {
|
|
1723
|
-
// Remove entire managed block
|
|
1724
|
-
newContent = buildWithoutBlock(parsed.beforeContent, parsed.userContent);
|
|
1725
|
-
}
|
|
1726
|
-
else {
|
|
1727
|
-
// Remove specific section
|
|
1728
|
-
const remaining = parsed.sections.filter((s) => s.id !== sectionId);
|
|
1729
|
-
if (remaining.length === parsed.sections.length) {
|
|
1730
|
-
// Section not found — no-op
|
|
1731
|
-
return;
|
|
1732
|
-
}
|
|
1733
|
-
if (remaining.length === 0) {
|
|
1734
|
-
// Last section removed — remove entire block
|
|
1735
|
-
newContent = buildWithoutBlock(parsed.beforeContent, parsed.userContent);
|
|
1736
|
-
}
|
|
1737
|
-
else {
|
|
1738
|
-
// Rebuild managed block without the removed section
|
|
1739
|
-
newContent = buildWithSections(parsed.beforeContent, parsed.userContent, remaining, markers, parsed.versionStamp?.version);
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
|
-
atomicWrite(filePath, newContent);
|
|
1743
|
-
});
|
|
1744
|
-
}
|
|
1745
|
-
/** Build file content without the managed block. */
|
|
1746
|
-
function buildWithoutBlock(beforeContent, userContent) {
|
|
1747
|
-
const parts = [];
|
|
1748
|
-
if (beforeContent)
|
|
1749
|
-
parts.push(beforeContent);
|
|
1750
|
-
if (userContent) {
|
|
1751
|
-
if (parts.length > 0)
|
|
1752
|
-
parts.push('');
|
|
1753
|
-
parts.push(userContent);
|
|
1754
|
-
}
|
|
1755
|
-
if (parts.length === 0)
|
|
1756
|
-
return '';
|
|
1757
|
-
return parts.join('\n') + '\n';
|
|
1758
|
-
}
|
|
1759
|
-
/** Rebuild file content with remaining sections. */
|
|
1760
|
-
function buildWithSections(beforeContent, userContent, sections, markers, coreVersion) {
|
|
1761
|
-
const sorted = sortSectionsByOrder([...sections]);
|
|
1762
|
-
const sectionText = sorted
|
|
1763
|
-
.map((s) => `## ${s.id}\n\n${s.content}`)
|
|
1764
|
-
.join('\n\n');
|
|
1765
|
-
const managedBody = markers.title
|
|
1766
|
-
? `# ${markers.title}\n\n${sectionText}`
|
|
1767
|
-
: sectionText;
|
|
1768
|
-
const beginLine = formatBeginMarker(markers.begin, coreVersion ?? DEFAULT_CORE_VERSION);
|
|
1769
|
-
const endLine = formatEndMarker(markers.end);
|
|
1770
|
-
const parts = [];
|
|
1771
|
-
if (beforeContent) {
|
|
1772
|
-
parts.push(beforeContent);
|
|
1773
|
-
parts.push('');
|
|
1774
|
-
}
|
|
1775
|
-
parts.push(beginLine);
|
|
1776
|
-
parts.push('');
|
|
1777
|
-
parts.push(managedBody);
|
|
1778
|
-
parts.push('');
|
|
1779
|
-
parts.push(endLine);
|
|
1780
|
-
if (userContent) {
|
|
1781
|
-
parts.push('');
|
|
1782
|
-
parts.push(userContent);
|
|
1783
|
-
}
|
|
1784
|
-
parts.push('');
|
|
1785
|
-
return parts.join('\n');
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
/**
|
|
1789
|
-
* One-shot content seeding used by the CLI install command.
|
|
1790
|
-
*
|
|
1791
|
-
* @remarks
|
|
1792
|
-
* Seeds SOUL.md, AGENTS.md, and TOOLS.md Platform section using the same
|
|
1793
|
-
* `updateManagedSection()` code path as writer cycles. Also copies templates
|
|
1794
|
-
* and creates core config with defaults if missing.
|
|
1795
|
-
*/
|
|
1796
|
-
/**
|
|
1797
|
-
* Create the core config file with defaults if it doesn't already exist.
|
|
1798
|
-
*
|
|
1799
|
-
* @param coreConfigDir - Path to the core config directory.
|
|
1800
|
-
*/
|
|
1801
|
-
function ensureCoreConfig(coreConfigDir) {
|
|
1802
|
-
if (!existsSync(coreConfigDir)) {
|
|
1803
|
-
mkdirSync(coreConfigDir, { recursive: true });
|
|
1804
|
-
}
|
|
1805
|
-
const configPath = join(coreConfigDir, CONFIG_FILE);
|
|
1806
|
-
if (existsSync(configPath))
|
|
1807
|
-
return;
|
|
1808
|
-
const defaults = coreConfigSchema.parse({});
|
|
1809
|
-
const configWithSchema = {
|
|
1810
|
-
$schema: './config.schema.json',
|
|
1811
|
-
...defaults,
|
|
1812
|
-
};
|
|
1813
|
-
writeFileSync(configPath, JSON.stringify(configWithSchema, null, 2), 'utf-8');
|
|
1814
|
-
// Write JSON schema file alongside config
|
|
1815
|
-
const schemaPath = join(coreConfigDir, 'config.schema.json');
|
|
1816
|
-
const jsonSchema = generateJsonSchema();
|
|
1817
|
-
writeFileSync(schemaPath, JSON.stringify(jsonSchema, null, 2), 'utf-8');
|
|
1818
|
-
}
|
|
1819
|
-
/**
|
|
1820
|
-
* Seed all platform content into the workspace.
|
|
1821
|
-
*
|
|
1822
|
-
* @remarks
|
|
1823
|
-
* Uses the same `updateManagedSection()` code path as writer cycles.
|
|
1824
|
-
* Creates core config with defaults if missing. Copies templates.
|
|
1825
|
-
* Jaccard cleanup detection runs automatically via `updateManagedSection`.
|
|
1826
|
-
*
|
|
1827
|
-
* @param options - Seeding configuration.
|
|
1828
|
-
*/
|
|
1829
|
-
async function seedContent(options) {
|
|
1830
|
-
const coreConfigDir = getCoreConfigDir();
|
|
1831
|
-
// Ensure core config exists
|
|
1832
|
-
ensureCoreConfig(coreConfigDir);
|
|
1833
|
-
// Seed content via the same code path as writer cycles
|
|
1834
|
-
await refreshPlatformContent({
|
|
1835
|
-
coreVersion: options.coreVersion,
|
|
1836
|
-
});
|
|
1837
|
-
}
|
|
1838
|
-
|
|
1839
|
-
/**
|
|
1840
|
-
* HTTP helpers for the OpenClaw plugin SDK.
|
|
3163
|
+
* HEARTBEAT health orchestration.
|
|
1841
3164
|
*
|
|
1842
3165
|
* @remarks
|
|
1843
|
-
*
|
|
1844
|
-
*
|
|
3166
|
+
* Determines the state of each platform component and generates
|
|
3167
|
+
* HEARTBEAT entries with actionable alert text. Applies the dependency
|
|
3168
|
+
* graph for alert suppression and auto-decline.
|
|
1845
3169
|
*/
|
|
3170
|
+
/** Derive the full service name from a component name. */
|
|
3171
|
+
function toServiceName(name) {
|
|
3172
|
+
return `jeeves-${name}`;
|
|
3173
|
+
}
|
|
3174
|
+
/** Known dependency declarations for platform components. */
|
|
3175
|
+
const COMPONENT_DEPS = {
|
|
3176
|
+
meta: { hard: ['watcher'], soft: [] },
|
|
3177
|
+
server: { hard: [], soft: ['watcher', 'runner', 'meta'] },
|
|
3178
|
+
runner: { hard: [], soft: [] },
|
|
3179
|
+
watcher: { hard: [], soft: [] },
|
|
3180
|
+
};
|
|
3181
|
+
/** "Not installed" alert text for each platform component. Shared with seedContent. */
|
|
3182
|
+
const NOT_INSTALLED_ALERTS = {
|
|
3183
|
+
runner: 'Not installed. The runner is the job execution engine — it runs scheduled scripts for data fetching, transformation, and maintenance. Ask the user for consent to install. On approval, execute: `npx @karmaniverous/jeeves-runner-openclaw install`.',
|
|
3184
|
+
watcher: 'Not installed. The watcher indexes documents into a vector store for semantic search. Ask the user for consent to install. On approval, execute: `npx @karmaniverous/jeeves-watcher-openclaw install`.',
|
|
3185
|
+
server: 'Not installed. The server provides a web UI for document browsing, rendering, sharing, and search. Ask the user for consent to install. On approval, execute: `npx @karmaniverous/jeeves-server-openclaw install`.',
|
|
3186
|
+
meta: 'Not installed. Meta is the knowledge synthesis engine. Requires jeeves-watcher. Ask the user for consent to install. On approval, install watcher first if needed, then execute: `npx @karmaniverous/jeeves-meta-openclaw install`.',
|
|
3187
|
+
};
|
|
3188
|
+
/** Alert text generators by state. */
|
|
3189
|
+
const ALERT_TEXT = {
|
|
3190
|
+
runner: {
|
|
3191
|
+
not_installed: NOT_INSTALLED_ALERTS['runner'],
|
|
3192
|
+
config_missing: (configRoot) => `Plugin installed but no configuration file found at \`${configRoot}/jeeves-runner/config.json\`. The plugin installer should have created a default config. Ask the user for consent to re-run the installer: \`npx @karmaniverous/jeeves-runner-openclaw install\`.`,
|
|
3193
|
+
service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-runner service install`. Verify the service is installed.',
|
|
3194
|
+
service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-runner service start`. Verify via `GET http://127.0.0.1:1937/status`.',
|
|
3195
|
+
},
|
|
3196
|
+
watcher: {
|
|
3197
|
+
not_installed: NOT_INSTALLED_ALERTS['watcher'],
|
|
3198
|
+
deps_missing: 'Plugin installed but Qdrant is not responding on `http://127.0.0.1:6333`. Qdrant is the vector database required for semantic search. Ask the user for consent to set up Qdrant. Guide them through installation for their platform — Docker is simplest: `docker run -p 6333:6333 qdrant/qdrant`. Verify via `GET http://127.0.0.1:6333/collections`.',
|
|
3199
|
+
config_missing: (configRoot) => `Plugin installed, Qdrant available, but config file missing or invalid at \`${configRoot}/jeeves-watcher/config.json\`. The plugin installer should have created a default config. If missing, re-run: \`npx @karmaniverous/jeeves-watcher-openclaw install\`.`,
|
|
3200
|
+
service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-watcher service install`. Verify the service is installed.',
|
|
3201
|
+
service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-watcher service start`. Verify via `GET http://127.0.0.1:1936/status`.',
|
|
3202
|
+
},
|
|
3203
|
+
server: {
|
|
3204
|
+
not_installed: NOT_INSTALLED_ALERTS['server'],
|
|
3205
|
+
config_missing: (configRoot) => `Plugin installed but config file missing or invalid at \`${configRoot}/jeeves-server/config.json\`. The plugin installer should have created a default config. If missing, re-run: \`npx @karmaniverous/jeeves-server-openclaw install\`.`,
|
|
3206
|
+
service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-server service install`. Verify the service is installed.',
|
|
3207
|
+
service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-server service start`. Verify via `GET http://127.0.0.1:1934/status`.',
|
|
3208
|
+
},
|
|
3209
|
+
meta: {
|
|
3210
|
+
not_installed: NOT_INSTALLED_ALERTS['meta'],
|
|
3211
|
+
deps_missing: 'Plugin installed but required dependency jeeves-watcher is not available. The watcher must be installed and running before meta can function. Do not attempt to set up meta until jeeves-watcher is healthy.',
|
|
3212
|
+
config_missing: (configRoot) => `Plugin installed, watcher available, but config file missing or invalid at \`${configRoot}/jeeves-meta/config.json\`. The plugin installer should have created a default config. If missing, re-run: \`npx @karmaniverous/jeeves-meta-openclaw install\`.`,
|
|
3213
|
+
service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-meta service install`. Verify the service is installed.',
|
|
3214
|
+
service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-meta service start`. Verify via `GET http://127.0.0.1:1938/status`.',
|
|
3215
|
+
},
|
|
3216
|
+
};
|
|
3217
|
+
/** Default Qdrant URL for watcher dependency check. */
|
|
3218
|
+
const QDRANT_URL = 'http://127.0.0.1:6333';
|
|
3219
|
+
/** Health probe timeout in milliseconds. */
|
|
3220
|
+
const PROBE_TIMEOUT_MS$1 = 3000;
|
|
1846
3221
|
/**
|
|
1847
|
-
*
|
|
3222
|
+
* Check if Qdrant is reachable (watcher dependency).
|
|
1848
3223
|
*
|
|
1849
|
-
* @
|
|
1850
|
-
* @param timeoutMs - Timeout in milliseconds before aborting.
|
|
1851
|
-
* @param init - Optional `fetch` init options.
|
|
1852
|
-
* @returns The fetch Response object.
|
|
3224
|
+
* @returns True if Qdrant responds.
|
|
1853
3225
|
*/
|
|
1854
|
-
async function
|
|
1855
|
-
const controller = new AbortController();
|
|
1856
|
-
const timeout = setTimeout(() => {
|
|
1857
|
-
controller.abort();
|
|
1858
|
-
}, timeoutMs);
|
|
3226
|
+
async function isQdrantAvailable() {
|
|
1859
3227
|
try {
|
|
1860
|
-
|
|
3228
|
+
await fetchWithTimeout(`${QDRANT_URL}/collections`, PROBE_TIMEOUT_MS$1);
|
|
3229
|
+
return true;
|
|
1861
3230
|
}
|
|
1862
|
-
|
|
1863
|
-
|
|
3231
|
+
catch {
|
|
3232
|
+
return false;
|
|
1864
3233
|
}
|
|
1865
3234
|
}
|
|
1866
3235
|
/**
|
|
1867
|
-
*
|
|
3236
|
+
* Determine the state of a single component.
|
|
3237
|
+
*
|
|
3238
|
+
* @param name - Component name.
|
|
3239
|
+
* @param registry - Current component-versions.json contents.
|
|
3240
|
+
* @param configRoot - Config root path.
|
|
3241
|
+
* @param healthySet - Set of component names known to be healthy (for dep checks).
|
|
3242
|
+
* @returns The component's state.
|
|
3243
|
+
*/
|
|
3244
|
+
async function determineComponentState(name, registry, configRoot, healthySet) {
|
|
3245
|
+
// Not in registry = not installed
|
|
3246
|
+
if (!(name in registry))
|
|
3247
|
+
return 'not_installed';
|
|
3248
|
+
// Check hard dependencies
|
|
3249
|
+
const deps = COMPONENT_DEPS[name];
|
|
3250
|
+
for (const hardDep of deps.hard) {
|
|
3251
|
+
if (!healthySet.has(hardDep))
|
|
3252
|
+
return 'deps_missing';
|
|
3253
|
+
}
|
|
3254
|
+
// Watcher-specific: check Qdrant
|
|
3255
|
+
if (name === 'watcher' && !(await isQdrantAvailable())) {
|
|
3256
|
+
return 'deps_missing';
|
|
3257
|
+
}
|
|
3258
|
+
// Check config file
|
|
3259
|
+
const configPath = join(configRoot, `jeeves-${name}`, CONFIG_FILE);
|
|
3260
|
+
if (!existsSync(configPath))
|
|
3261
|
+
return 'config_missing';
|
|
3262
|
+
// Fast path: probe HTTP health endpoint
|
|
3263
|
+
try {
|
|
3264
|
+
const url = getServiceUrl(name);
|
|
3265
|
+
await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS$1);
|
|
3266
|
+
// Healthy — check for available updates
|
|
3267
|
+
const entry = registry[name];
|
|
3268
|
+
if (entry.pluginPackage && entry.pluginVersion) {
|
|
3269
|
+
const componentConfigDir = join(configRoot, `jeeves-${name}`);
|
|
3270
|
+
const latestVersion = checkRegistryVersion(entry.pluginPackage, componentConfigDir);
|
|
3271
|
+
if (latestVersion && gt(latestVersion, entry.pluginVersion)) {
|
|
3272
|
+
return 'update_available';
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
return 'healthy';
|
|
3276
|
+
}
|
|
3277
|
+
catch {
|
|
3278
|
+
// Service not responding — classify sub-state
|
|
3279
|
+
const serviceState = getServiceState(toServiceName(name));
|
|
3280
|
+
if (serviceState === 'not_installed')
|
|
3281
|
+
return 'service_not_installed';
|
|
3282
|
+
if (serviceState === 'stopped')
|
|
3283
|
+
return 'service_stopped';
|
|
3284
|
+
// serviceState === 'running' but HTTP failed — still treat as stopped
|
|
3285
|
+
return 'service_stopped';
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
/**
|
|
3289
|
+
* Generate the alert text for a component in a given state.
|
|
1868
3290
|
*
|
|
1869
|
-
* @param
|
|
1870
|
-
* @param
|
|
1871
|
-
* @
|
|
1872
|
-
* @
|
|
3291
|
+
* @param name - Component name.
|
|
3292
|
+
* @param state - The component's state.
|
|
3293
|
+
* @param configRoot - Config root path.
|
|
3294
|
+
* @returns Alert text (list items), or empty string if healthy.
|
|
1873
3295
|
*/
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
3296
|
+
function generateAlertText(name, state, configRoot, registry) {
|
|
3297
|
+
if (state === 'healthy')
|
|
3298
|
+
return '';
|
|
3299
|
+
// Update available — dynamic text with version info
|
|
3300
|
+
if (state === 'update_available') {
|
|
3301
|
+
const entry = registry[name];
|
|
3302
|
+
const currentVersion = entry.pluginVersion ?? 'unknown';
|
|
3303
|
+
const componentConfigDir = join(configRoot, `jeeves-${name}`);
|
|
3304
|
+
const latestVersion = entry.pluginPackage
|
|
3305
|
+
? (checkRegistryVersion(entry.pluginPackage, componentConfigDir) ??
|
|
3306
|
+
'unknown')
|
|
3307
|
+
: 'unknown';
|
|
3308
|
+
const installCmd = entry.pluginPackage
|
|
3309
|
+
? `\`npx ${entry.pluginPackage} install\``
|
|
3310
|
+
: `\`npx @karmaniverous/jeeves-${name}-openclaw install\``;
|
|
3311
|
+
return `- Update available: v${currentVersion} → v${latestVersion}. Ask the user for consent to update. On approval, execute: ${installCmd}.`;
|
|
1878
3312
|
}
|
|
1879
|
-
|
|
3313
|
+
const componentAlerts = ALERT_TEXT[name];
|
|
3314
|
+
const alertOrFn = componentAlerts[state];
|
|
3315
|
+
if (!alertOrFn)
|
|
3316
|
+
return '';
|
|
3317
|
+
const text = typeof alertOrFn === 'function' ? alertOrFn(configRoot) : alertOrFn;
|
|
3318
|
+
return `- ${text}`;
|
|
1880
3319
|
}
|
|
1881
3320
|
/**
|
|
1882
|
-
*
|
|
3321
|
+
* Orchestrate HEARTBEAT entries for all platform components.
|
|
1883
3322
|
*
|
|
1884
|
-
* @param
|
|
1885
|
-
* @
|
|
1886
|
-
* @returns Parsed JSON response body.
|
|
3323
|
+
* @param options - Orchestration configuration.
|
|
3324
|
+
* @returns Array of HeartbeatEntry for writeHeartbeatSection.
|
|
1887
3325
|
*/
|
|
1888
|
-
async function
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
3326
|
+
async function orchestrateHeartbeat(options) {
|
|
3327
|
+
const { coreConfigDir, configRoot, declinedNames } = options;
|
|
3328
|
+
const registry = readComponentVersions(coreConfigDir);
|
|
3329
|
+
// First pass: determine which components are healthy (for dep resolution)
|
|
3330
|
+
const healthySet = new Set();
|
|
3331
|
+
for (const name of PLATFORM_COMPONENTS) {
|
|
3332
|
+
if (declinedNames.has(toServiceName(name)))
|
|
3333
|
+
continue;
|
|
3334
|
+
if (!(name in registry))
|
|
3335
|
+
continue;
|
|
3336
|
+
try {
|
|
3337
|
+
const url = getServiceUrl(name);
|
|
3338
|
+
await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS$1);
|
|
3339
|
+
healthySet.add(name);
|
|
3340
|
+
}
|
|
3341
|
+
catch {
|
|
3342
|
+
// Not healthy — will be classified in second pass
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
// Second pass: generate entries
|
|
3346
|
+
const entries = [];
|
|
3347
|
+
for (const name of PLATFORM_COMPONENTS) {
|
|
3348
|
+
const fullName = toServiceName(name);
|
|
3349
|
+
// Declined
|
|
3350
|
+
if (declinedNames.has(fullName)) {
|
|
3351
|
+
// Auto-decline dependents of declined hard deps
|
|
3352
|
+
entries.push({ name: fullName, declined: true, content: '' });
|
|
3353
|
+
continue;
|
|
3354
|
+
}
|
|
3355
|
+
const state = await determineComponentState(name, registry, configRoot, healthySet);
|
|
3356
|
+
// Auto-decline if hard dep is declined
|
|
3357
|
+
const deps = COMPONENT_DEPS[name];
|
|
3358
|
+
const hardDepDeclined = deps.hard.some((d) => declinedNames.has(toServiceName(d)));
|
|
3359
|
+
if (hardDepDeclined) {
|
|
3360
|
+
entries.push({ name: fullName, declined: true, content: '' });
|
|
3361
|
+
continue;
|
|
3362
|
+
}
|
|
3363
|
+
const alertText = generateAlertText(name, state, configRoot, registry);
|
|
3364
|
+
entries.push({ name: fullName, declined: false, content: alertText });
|
|
3365
|
+
}
|
|
3366
|
+
// Add soft-dep informational alerts for any healthy component with soft deps
|
|
3367
|
+
for (const entry of entries) {
|
|
3368
|
+
if (entry.declined || entry.content)
|
|
3369
|
+
continue;
|
|
3370
|
+
// Entry is healthy (no alert, not declined) — check for soft deps
|
|
3371
|
+
const shortName = entry.name.replace(/^jeeves-/, '');
|
|
3372
|
+
const deps = COMPONENT_DEPS[shortName];
|
|
3373
|
+
if (!deps.soft.length)
|
|
3374
|
+
continue;
|
|
3375
|
+
const softAlerts = [];
|
|
3376
|
+
for (const dep of deps.soft) {
|
|
3377
|
+
const depFullName = toServiceName(dep);
|
|
3378
|
+
if (declinedNames.has(depFullName))
|
|
3379
|
+
continue;
|
|
3380
|
+
if (!healthySet.has(dep)) {
|
|
3381
|
+
softAlerts.push(`- ${entry.name} is running. Some features are unavailable because ${depFullName} is not installed/running.`);
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
if (softAlerts.length > 0) {
|
|
3385
|
+
entry.content = softAlerts.join('\n');
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
return entries;
|
|
1894
3389
|
}
|
|
1895
3390
|
|
|
1896
3391
|
/**
|
|
1897
|
-
*
|
|
3392
|
+
* Timer-based orchestrator for managed content writing.
|
|
1898
3393
|
*
|
|
1899
3394
|
* @remarks
|
|
1900
|
-
*
|
|
1901
|
-
*
|
|
3395
|
+
* `ComponentWriter` manages a component's TOOLS.md section writes
|
|
3396
|
+
* and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
|
|
3397
|
+
* on a configurable prime-interval timer cycle.
|
|
1902
3398
|
*/
|
|
1903
3399
|
/**
|
|
1904
|
-
*
|
|
3400
|
+
* Orchestrates managed content writing for a single Jeeves component.
|
|
1905
3401
|
*
|
|
1906
3402
|
* @remarks
|
|
1907
|
-
*
|
|
1908
|
-
*
|
|
1909
|
-
*
|
|
1910
|
-
* 3. Default: `~/.openclaw`
|
|
1911
|
-
*
|
|
1912
|
-
* @returns Absolute path to the OpenClaw home directory.
|
|
3403
|
+
* Created via `createComponentWriter()`. Manages a timer that fires
|
|
3404
|
+
* at the component's prime-interval, calling `generateToolsContent()`
|
|
3405
|
+
* and `refreshPlatformContent()` on each cycle.
|
|
1913
3406
|
*/
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
3407
|
+
class ComponentWriter {
|
|
3408
|
+
timer;
|
|
3409
|
+
component;
|
|
3410
|
+
configDir;
|
|
3411
|
+
/** @internal */
|
|
3412
|
+
constructor(component) {
|
|
3413
|
+
this.component = component;
|
|
3414
|
+
this.configDir = getComponentConfigDir(component.name);
|
|
1917
3415
|
}
|
|
1918
|
-
|
|
1919
|
-
|
|
3416
|
+
/** The component's config directory path. */
|
|
3417
|
+
get componentConfigDir() {
|
|
3418
|
+
return this.configDir;
|
|
3419
|
+
}
|
|
3420
|
+
/** Whether the writer timer is currently running. */
|
|
3421
|
+
get isRunning() {
|
|
3422
|
+
return this.timer !== undefined;
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* Start the writer timer.
|
|
3426
|
+
*
|
|
3427
|
+
* @remarks
|
|
3428
|
+
* Performs an immediate first write, then sets up the interval.
|
|
3429
|
+
*/
|
|
3430
|
+
start() {
|
|
3431
|
+
if (this.timer)
|
|
3432
|
+
return;
|
|
3433
|
+
// Fire immediately, then on interval
|
|
3434
|
+
void this.cycle();
|
|
3435
|
+
this.timer = setInterval(() => void this.cycle(), this.component.refreshIntervalSeconds * 1000);
|
|
3436
|
+
}
|
|
3437
|
+
/** Stop the writer timer. */
|
|
3438
|
+
stop() {
|
|
3439
|
+
if (this.timer) {
|
|
3440
|
+
clearInterval(this.timer);
|
|
3441
|
+
this.timer = undefined;
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
/**
|
|
3445
|
+
* Execute a single write cycle.
|
|
3446
|
+
*
|
|
3447
|
+
* @remarks
|
|
3448
|
+
* Calls `generateToolsContent()` and writes the component's
|
|
3449
|
+
* TOOLS.md section via `updateManagedSection()`. Also calls
|
|
3450
|
+
* `refreshPlatformContent()` for shared content maintenance.
|
|
3451
|
+
*/
|
|
3452
|
+
async cycle() {
|
|
3453
|
+
try {
|
|
3454
|
+
const workspacePath = getWorkspacePath();
|
|
3455
|
+
const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
|
|
3456
|
+
// Write the component's TOOLS.md section
|
|
3457
|
+
const toolsContent = this.component.generateToolsContent();
|
|
3458
|
+
await updateManagedSection(toolsPath, toolsContent, {
|
|
3459
|
+
mode: 'section',
|
|
3460
|
+
sectionId: this.component.sectionId,
|
|
3461
|
+
markers: TOOLS_MARKERS,
|
|
3462
|
+
coreVersion: CORE_VERSION,
|
|
3463
|
+
});
|
|
3464
|
+
// Platform content maintenance: SOUL.md, AGENTS.md, Platform section
|
|
3465
|
+
await refreshPlatformContent({
|
|
3466
|
+
coreVersion: CORE_VERSION,
|
|
3467
|
+
componentName: this.component.name,
|
|
3468
|
+
componentVersion: this.component.version,
|
|
3469
|
+
servicePackage: this.component.servicePackage,
|
|
3470
|
+
pluginPackage: this.component.pluginPackage,
|
|
3471
|
+
});
|
|
3472
|
+
// HEARTBEAT health orchestration
|
|
3473
|
+
const heartbeatPath = join(workspacePath, WORKSPACE_FILES.heartbeat);
|
|
3474
|
+
try {
|
|
3475
|
+
const existingContent = (() => {
|
|
3476
|
+
try {
|
|
3477
|
+
return readFileSync(heartbeatPath, 'utf-8');
|
|
3478
|
+
}
|
|
3479
|
+
catch (err) {
|
|
3480
|
+
// Only swallow "file not found" — let permission errors propagate
|
|
3481
|
+
if (err instanceof Error &&
|
|
3482
|
+
'code' in err &&
|
|
3483
|
+
err.code === 'ENOENT') {
|
|
3484
|
+
return '';
|
|
3485
|
+
}
|
|
3486
|
+
throw err;
|
|
3487
|
+
}
|
|
3488
|
+
})();
|
|
3489
|
+
const parsed = parseHeartbeat(existingContent);
|
|
3490
|
+
const declinedNames = new Set(parsed.entries.filter((e) => e.declined).map((e) => e.name));
|
|
3491
|
+
const entries = await orchestrateHeartbeat({
|
|
3492
|
+
coreConfigDir: getCoreConfigDir(),
|
|
3493
|
+
configRoot: getConfigRoot(),
|
|
3494
|
+
declinedNames,
|
|
3495
|
+
});
|
|
3496
|
+
await writeHeartbeatSection(heartbeatPath, entries);
|
|
3497
|
+
}
|
|
3498
|
+
catch (err) {
|
|
3499
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3500
|
+
console.warn(`jeeves-core: HEARTBEAT orchestration failed: ${msg}`);
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
catch (err) {
|
|
3504
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3505
|
+
console.warn(`jeeves-core: ComponentWriter cycle failed for ${this.component.name}: ${message}`);
|
|
3506
|
+
}
|
|
1920
3507
|
}
|
|
1921
|
-
return join(homedir(), '.openclaw');
|
|
1922
3508
|
}
|
|
3509
|
+
|
|
1923
3510
|
/**
|
|
1924
|
-
*
|
|
3511
|
+
* Creates a synchronous content accessor backed by an async data source.
|
|
1925
3512
|
*
|
|
1926
3513
|
* @remarks
|
|
1927
|
-
*
|
|
1928
|
-
*
|
|
3514
|
+
* Solves the sync/async gap in `JeevesComponentDescriptor.generateToolsContent()`:
|
|
3515
|
+
* the interface is synchronous, but most components fetch live data from
|
|
3516
|
+
* their HTTP service. This utility returns a sync `() => string` that
|
|
3517
|
+
* serves the last successfully fetched value while kicking off a background
|
|
3518
|
+
* refresh on each call.
|
|
1929
3519
|
*
|
|
1930
|
-
*
|
|
1931
|
-
*
|
|
3520
|
+
* First call returns `placeholder`. Subsequent calls return the last
|
|
3521
|
+
* successfully fetched content. If a refresh fails, the previous good
|
|
3522
|
+
* value is retained.
|
|
3523
|
+
*
|
|
3524
|
+
* @example
|
|
3525
|
+
* ```typescript
|
|
3526
|
+
* const getContent = createAsyncContentCache({
|
|
3527
|
+
* fetch: async () => {
|
|
3528
|
+
* const res = await fetch('http://127.0.0.1:1936/status');
|
|
3529
|
+
* return formatWatcherStatus(await res.json());
|
|
3530
|
+
* },
|
|
3531
|
+
* placeholder: '> Initializing watcher status...',
|
|
3532
|
+
* });
|
|
3533
|
+
*
|
|
3534
|
+
* const writer = createComponentWriter({
|
|
3535
|
+
* // ...
|
|
3536
|
+
* generateToolsContent: getContent,
|
|
3537
|
+
* });
|
|
3538
|
+
* ```
|
|
1932
3539
|
*/
|
|
1933
|
-
function resolveConfigPath(home) {
|
|
1934
|
-
if (process.env.OPENCLAW_CONFIG) {
|
|
1935
|
-
return resolve(process.env.OPENCLAW_CONFIG);
|
|
1936
|
-
}
|
|
1937
|
-
return join(home, 'openclaw.json');
|
|
1938
|
-
}
|
|
1939
3540
|
/**
|
|
1940
|
-
*
|
|
3541
|
+
* Creates a synchronous content accessor backed by an async data source.
|
|
1941
3542
|
*
|
|
1942
|
-
* @
|
|
3543
|
+
* @param options - Cache configuration.
|
|
3544
|
+
* @returns A sync `() => string` suitable for `generateToolsContent`.
|
|
1943
3545
|
*/
|
|
1944
|
-
function
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
if (!
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
if (filtered.length !== list.length) {
|
|
1962
|
-
parent[key] = filtered;
|
|
1963
|
-
return `Removed "${pluginId}" from ${label}`;
|
|
3546
|
+
function createAsyncContentCache(options) {
|
|
3547
|
+
const { fetch: fetchContent, placeholder = '> Initializing...', onError = (err) => {
|
|
3548
|
+
console.warn('[jeeves] async content cache refresh failed:', err);
|
|
3549
|
+
}, } = options;
|
|
3550
|
+
let cached = placeholder;
|
|
3551
|
+
let refreshing = false;
|
|
3552
|
+
return () => {
|
|
3553
|
+
if (!refreshing) {
|
|
3554
|
+
refreshing = true;
|
|
3555
|
+
fetchContent()
|
|
3556
|
+
.then((content) => {
|
|
3557
|
+
cached = content;
|
|
3558
|
+
})
|
|
3559
|
+
.catch(onError)
|
|
3560
|
+
.finally(() => {
|
|
3561
|
+
refreshing = false;
|
|
3562
|
+
});
|
|
1964
3563
|
}
|
|
1965
|
-
|
|
1966
|
-
|
|
3564
|
+
return cached;
|
|
3565
|
+
};
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
/**
|
|
3569
|
+
* Factory function for creating a ComponentWriter from a descriptor.
|
|
3570
|
+
*
|
|
3571
|
+
* @remarks
|
|
3572
|
+
* Validates the descriptor via Zod schema and creates a ComponentWriter.
|
|
3573
|
+
* Accepts `JeevesComponentDescriptor` (v0.5.0) only. The v0.4.0
|
|
3574
|
+
* `JeevesComponent` interface is no longer accepted.
|
|
3575
|
+
*/
|
|
3576
|
+
/**
|
|
3577
|
+
* Create a ComponentWriter for a validated component descriptor.
|
|
3578
|
+
*
|
|
3579
|
+
* @remarks
|
|
3580
|
+
* The descriptor is validated via the Zod schema at runtime.
|
|
3581
|
+
* This replaces the v0.4.0 `createComponentWriter(JeevesComponent)`.
|
|
3582
|
+
*
|
|
3583
|
+
* @param descriptor - The component descriptor to validate and wrap.
|
|
3584
|
+
* @returns A new `ComponentWriter` instance.
|
|
3585
|
+
* @throws ZodError if the descriptor is invalid.
|
|
3586
|
+
*/
|
|
3587
|
+
function createComponentWriter(descriptor) {
|
|
3588
|
+
// Validate via Zod — throws ZodError with detailed messages on failure
|
|
3589
|
+
jeevesComponentDescriptorSchema.parse(descriptor);
|
|
3590
|
+
return new ComponentWriter(descriptor);
|
|
1967
3591
|
}
|
|
3592
|
+
|
|
1968
3593
|
/**
|
|
1969
|
-
*
|
|
3594
|
+
* Resolve the bind address for a Jeeves service.
|
|
1970
3595
|
*
|
|
1971
3596
|
* @remarks
|
|
1972
|
-
*
|
|
1973
|
-
*
|
|
1974
|
-
*
|
|
3597
|
+
* Resolution order (four-tier):
|
|
3598
|
+
* 1. Component config `bindAddress` field (if componentName provided)
|
|
3599
|
+
* 2. Core config `bindAddress` field
|
|
3600
|
+
* 3. `JEEVES_BIND_ADDRESS` environment variable
|
|
3601
|
+
* 4. Default: `0.0.0.0`
|
|
3602
|
+
*/
|
|
3603
|
+
/**
|
|
3604
|
+
* Resolve the bind address for a Jeeves service.
|
|
1975
3605
|
*
|
|
1976
|
-
* @param
|
|
1977
|
-
* @
|
|
1978
|
-
* @param mode - Whether to add or remove the plugin.
|
|
1979
|
-
* @returns Array of log messages describing changes made.
|
|
3606
|
+
* @param componentName - Optional component name for component-specific override.
|
|
3607
|
+
* @returns The resolved bind address.
|
|
1980
3608
|
*/
|
|
1981
|
-
function
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
const plugins = config.plugins;
|
|
1988
|
-
// plugins.entries
|
|
1989
|
-
if (!plugins.entries || typeof plugins.entries !== 'object') {
|
|
1990
|
-
plugins.entries = {};
|
|
1991
|
-
}
|
|
1992
|
-
const entries = plugins.entries;
|
|
1993
|
-
if (mode === 'add') {
|
|
1994
|
-
if (!entries[pluginId]) {
|
|
1995
|
-
entries[pluginId] = { enabled: true };
|
|
1996
|
-
messages.push(`Added "${pluginId}" to plugins.entries`);
|
|
3609
|
+
function getBindAddress(componentName) {
|
|
3610
|
+
// Tier 1: Component config (if provided)
|
|
3611
|
+
if (componentName) {
|
|
3612
|
+
const componentConfig = loadConfig(getComponentConfigDir(componentName));
|
|
3613
|
+
if (componentConfig?.bindAddress) {
|
|
3614
|
+
return componentConfig.bindAddress;
|
|
1997
3615
|
}
|
|
1998
3616
|
}
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
3617
|
+
// Tier 2: Core config
|
|
3618
|
+
const coreConfig = loadConfig(getCoreConfigDir());
|
|
3619
|
+
if (coreConfig?.bindAddress) {
|
|
3620
|
+
return coreConfig.bindAddress;
|
|
2002
3621
|
}
|
|
2003
|
-
//
|
|
2004
|
-
|
|
2005
|
-
|
|
3622
|
+
// Tier 3: Environment variable
|
|
3623
|
+
const envValue = process.env['JEEVES_BIND_ADDRESS'];
|
|
3624
|
+
if (envValue) {
|
|
3625
|
+
return envValue;
|
|
2006
3626
|
}
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
if (toolAlsoAllow)
|
|
2010
|
-
messages.push(toolAlsoAllow);
|
|
2011
|
-
return messages;
|
|
3627
|
+
// Tier 4: Default
|
|
3628
|
+
return DEFAULT_BIND_ADDRESS;
|
|
2012
3629
|
}
|
|
2013
3630
|
|
|
2014
3631
|
/**
|
|
2015
|
-
*
|
|
3632
|
+
* One-shot content seeding used by the CLI install command.
|
|
2016
3633
|
*
|
|
2017
3634
|
* @remarks
|
|
2018
|
-
*
|
|
2019
|
-
*
|
|
2020
|
-
*
|
|
3635
|
+
* Seeds SOUL.md, AGENTS.md, and TOOLS.md Platform section using the same
|
|
3636
|
+
* `updateManagedSection()` code path as writer cycles. Also copies templates
|
|
3637
|
+
* and creates core config with defaults if missing.
|
|
2021
3638
|
*/
|
|
2022
3639
|
/**
|
|
2023
|
-
*
|
|
2024
|
-
*
|
|
2025
|
-
* @remarks
|
|
2026
|
-
* Tries three sources in order:
|
|
2027
|
-
* 1. `api.config.agents.defaults.workspace` — explicit config
|
|
2028
|
-
* 2. `api.resolvePath('.')` — gateway-provided path resolver
|
|
2029
|
-
* 3. `process.cwd()` — last resort
|
|
3640
|
+
* Create the core config file with defaults if it doesn't already exist.
|
|
2030
3641
|
*
|
|
2031
|
-
* @param
|
|
2032
|
-
* @returns Absolute path to the workspace root.
|
|
3642
|
+
* @param coreConfigDir - Path to the core config directory.
|
|
2033
3643
|
*/
|
|
2034
|
-
function
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
return configured;
|
|
2038
|
-
}
|
|
2039
|
-
if (typeof api.resolvePath === 'function') {
|
|
2040
|
-
return api.resolvePath('.');
|
|
3644
|
+
function ensureCoreConfig(coreConfigDir) {
|
|
3645
|
+
if (!existsSync(coreConfigDir)) {
|
|
3646
|
+
mkdirSync(coreConfigDir, { recursive: true });
|
|
2041
3647
|
}
|
|
2042
|
-
|
|
3648
|
+
const configPath = join(coreConfigDir, CONFIG_FILE);
|
|
3649
|
+
if (existsSync(configPath))
|
|
3650
|
+
return;
|
|
3651
|
+
const defaults = coreConfigSchema.parse({});
|
|
3652
|
+
const configWithSchema = {
|
|
3653
|
+
$schema: './config.schema.json',
|
|
3654
|
+
...defaults,
|
|
3655
|
+
};
|
|
3656
|
+
writeFileSync(configPath, JSON.stringify(configWithSchema, null, 2), 'utf-8');
|
|
3657
|
+
// Write JSON schema file alongside config
|
|
3658
|
+
const schemaPath = join(coreConfigDir, 'config.schema.json');
|
|
3659
|
+
const jsonSchema = generateJsonSchema();
|
|
3660
|
+
writeFileSync(schemaPath, JSON.stringify(jsonSchema, null, 2), 'utf-8');
|
|
2043
3661
|
}
|
|
2044
3662
|
/**
|
|
2045
|
-
*
|
|
2046
|
-
* plugin config → environment variable → fallback value.
|
|
3663
|
+
* Seed all platform content into the workspace.
|
|
2047
3664
|
*
|
|
2048
|
-
* @
|
|
2049
|
-
*
|
|
2050
|
-
*
|
|
2051
|
-
*
|
|
2052
|
-
*
|
|
2053
|
-
* @returns The resolved setting value.
|
|
2054
|
-
*/
|
|
2055
|
-
function resolvePluginSetting(api, pluginId, key, envVar, fallback) {
|
|
2056
|
-
const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
|
|
2057
|
-
if (typeof fromPlugin === 'string')
|
|
2058
|
-
return fromPlugin;
|
|
2059
|
-
const fromEnv = process.env[envVar];
|
|
2060
|
-
if (fromEnv)
|
|
2061
|
-
return fromEnv;
|
|
2062
|
-
return fallback;
|
|
2063
|
-
}
|
|
2064
|
-
/**
|
|
2065
|
-
* Resolve an optional plugin setting via the two-step fallback chain:
|
|
2066
|
-
* plugin config → environment variable. Returns `undefined` if neither
|
|
2067
|
-
* source provides a value.
|
|
3665
|
+
* @remarks
|
|
3666
|
+
* Uses the same `updateManagedSection()` code path as writer cycles.
|
|
3667
|
+
* Creates core config with defaults if missing. Copies templates.
|
|
3668
|
+
* Writes initial HEARTBEAT with "Not installed" alerts for all platform components.
|
|
3669
|
+
* Jaccard cleanup detection runs automatically via `updateManagedSection`.
|
|
2068
3670
|
*
|
|
2069
|
-
* @param
|
|
2070
|
-
* @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
|
|
2071
|
-
* @param key - Config key within the plugin's config object.
|
|
2072
|
-
* @param envVar - Environment variable name.
|
|
2073
|
-
* @returns The resolved setting value, or `undefined`.
|
|
3671
|
+
* @param options - Seeding configuration.
|
|
2074
3672
|
*/
|
|
2075
|
-
function
|
|
2076
|
-
const
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
3673
|
+
async function seedContent(options) {
|
|
3674
|
+
const coreConfigDir = getCoreConfigDir();
|
|
3675
|
+
// Ensure core config exists
|
|
3676
|
+
ensureCoreConfig(coreConfigDir);
|
|
3677
|
+
// Seed SOUL.md, AGENTS.md, TOOLS.md Platform section
|
|
3678
|
+
await refreshPlatformContent({
|
|
3679
|
+
coreVersion: options.coreVersion,
|
|
3680
|
+
});
|
|
3681
|
+
// Seed HEARTBEAT.md with "Not installed" alerts for all platform components
|
|
3682
|
+
const heartbeatPath = join(getWorkspacePath(), WORKSPACE_FILES.heartbeat);
|
|
3683
|
+
const entries = PLATFORM_COMPONENTS.map((name) => ({
|
|
3684
|
+
name: toServiceName(name),
|
|
3685
|
+
declined: false,
|
|
3686
|
+
content: `- ${NOT_INSTALLED_ALERTS[name]}`,
|
|
3687
|
+
}));
|
|
3688
|
+
await writeHeartbeatSection(heartbeatPath, entries);
|
|
2083
3689
|
}
|
|
2084
3690
|
|
|
2085
3691
|
/**
|
|
@@ -2151,4 +3757,233 @@ function connectionFail(error, baseUrl, pluginId) {
|
|
|
2151
3757
|
return fail(error);
|
|
2152
3758
|
}
|
|
2153
3759
|
|
|
2154
|
-
|
|
3760
|
+
/**
|
|
3761
|
+
* Factory for the standard plugin tool set.
|
|
3762
|
+
*
|
|
3763
|
+
* @remarks
|
|
3764
|
+
* Produces four standard tools from a component descriptor:
|
|
3765
|
+
* - `{name}_status` - Probe service health + version + uptime
|
|
3766
|
+
* - `{name}_config` - Query running config with optional JSONPath
|
|
3767
|
+
* - `{name}_config_apply` - Push config patch to running service
|
|
3768
|
+
* - `{name}_service` - Service lifecycle management
|
|
3769
|
+
*
|
|
3770
|
+
* Components add domain-specific tools separately.
|
|
3771
|
+
*/
|
|
3772
|
+
/** Timeout for HTTP probes in milliseconds. */
|
|
3773
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
3774
|
+
/**
|
|
3775
|
+
* Create the standard plugin tool set from a component descriptor.
|
|
3776
|
+
*
|
|
3777
|
+
* @param descriptor - The component descriptor.
|
|
3778
|
+
* @returns Array of tool descriptors to register.
|
|
3779
|
+
*/
|
|
3780
|
+
function createPluginToolset(descriptor) {
|
|
3781
|
+
const { name, defaultPort } = descriptor;
|
|
3782
|
+
const baseUrl = `http://127.0.0.1:${String(defaultPort)}`;
|
|
3783
|
+
const svcManager = createServiceManager(descriptor);
|
|
3784
|
+
const statusTool = {
|
|
3785
|
+
name: `${name}_status`,
|
|
3786
|
+
description: `Get ${name} service health, version, and uptime.`,
|
|
3787
|
+
parameters: {
|
|
3788
|
+
type: 'object',
|
|
3789
|
+
properties: {},
|
|
3790
|
+
},
|
|
3791
|
+
execute: async () => {
|
|
3792
|
+
try {
|
|
3793
|
+
const res = await fetchWithTimeout(`${baseUrl}/status`, PROBE_TIMEOUT_MS);
|
|
3794
|
+
if (!res.ok) {
|
|
3795
|
+
return fail(`HTTP ${String(res.status)}: ${await res.text()}`);
|
|
3796
|
+
}
|
|
3797
|
+
const data = await res.json();
|
|
3798
|
+
return ok(data);
|
|
3799
|
+
}
|
|
3800
|
+
catch (err) {
|
|
3801
|
+
return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
|
|
3802
|
+
}
|
|
3803
|
+
},
|
|
3804
|
+
};
|
|
3805
|
+
const configTool = {
|
|
3806
|
+
name: `${name}_config`,
|
|
3807
|
+
description: `Query ${name} running configuration. Optional JSONPath filter.`,
|
|
3808
|
+
parameters: {
|
|
3809
|
+
type: 'object',
|
|
3810
|
+
properties: {
|
|
3811
|
+
path: {
|
|
3812
|
+
type: 'string',
|
|
3813
|
+
description: 'JSONPath expression (optional)',
|
|
3814
|
+
},
|
|
3815
|
+
},
|
|
3816
|
+
},
|
|
3817
|
+
execute: async (_id, params) => {
|
|
3818
|
+
const path = params.path;
|
|
3819
|
+
const qs = path ? `?path=${encodeURIComponent(path)}` : '';
|
|
3820
|
+
try {
|
|
3821
|
+
const result = await fetchJson(`${baseUrl}/config${qs}`);
|
|
3822
|
+
return ok(result);
|
|
3823
|
+
}
|
|
3824
|
+
catch (err) {
|
|
3825
|
+
return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
|
|
3826
|
+
}
|
|
3827
|
+
},
|
|
3828
|
+
};
|
|
3829
|
+
const configApplyTool = {
|
|
3830
|
+
name: `${name}_config_apply`,
|
|
3831
|
+
description: `Apply a config patch to the running ${name} service.`,
|
|
3832
|
+
parameters: {
|
|
3833
|
+
type: 'object',
|
|
3834
|
+
properties: {
|
|
3835
|
+
config: {
|
|
3836
|
+
type: 'object',
|
|
3837
|
+
description: 'Config patch to apply',
|
|
3838
|
+
},
|
|
3839
|
+
},
|
|
3840
|
+
required: ['config'],
|
|
3841
|
+
},
|
|
3842
|
+
execute: async (_id, params) => {
|
|
3843
|
+
const config = params.config;
|
|
3844
|
+
if (!config) {
|
|
3845
|
+
return fail('Missing required parameter: config');
|
|
3846
|
+
}
|
|
3847
|
+
try {
|
|
3848
|
+
const result = await postJson(`${baseUrl}/config/apply`, config);
|
|
3849
|
+
return ok(result);
|
|
3850
|
+
}
|
|
3851
|
+
catch (err) {
|
|
3852
|
+
return connectionFail(err, baseUrl, `jeeves-${name}-openclaw`);
|
|
3853
|
+
}
|
|
3854
|
+
},
|
|
3855
|
+
};
|
|
3856
|
+
const serviceTool = {
|
|
3857
|
+
name: `${name}_service`,
|
|
3858
|
+
description: `Manage the ${name} system service. Actions: install, uninstall, start, stop, restart, status.`,
|
|
3859
|
+
parameters: {
|
|
3860
|
+
type: 'object',
|
|
3861
|
+
properties: {
|
|
3862
|
+
action: {
|
|
3863
|
+
type: 'string',
|
|
3864
|
+
enum: ['install', 'uninstall', 'start', 'stop', 'restart', 'status'],
|
|
3865
|
+
description: 'Service action to perform',
|
|
3866
|
+
},
|
|
3867
|
+
},
|
|
3868
|
+
required: ['action'],
|
|
3869
|
+
},
|
|
3870
|
+
execute: (_id, params) => {
|
|
3871
|
+
const action = params.action;
|
|
3872
|
+
const validActions = [
|
|
3873
|
+
'install',
|
|
3874
|
+
'uninstall',
|
|
3875
|
+
'start',
|
|
3876
|
+
'stop',
|
|
3877
|
+
'restart',
|
|
3878
|
+
'status',
|
|
3879
|
+
];
|
|
3880
|
+
if (!validActions.includes(action)) {
|
|
3881
|
+
return Promise.resolve(fail(`Invalid action: ${action}`));
|
|
3882
|
+
}
|
|
3883
|
+
try {
|
|
3884
|
+
if (action === 'status') {
|
|
3885
|
+
const state = svcManager.status();
|
|
3886
|
+
return Promise.resolve(ok({ service: name, state }));
|
|
3887
|
+
}
|
|
3888
|
+
// Call the appropriate method
|
|
3889
|
+
const methodMap = {
|
|
3890
|
+
install: () => {
|
|
3891
|
+
svcManager.install();
|
|
3892
|
+
},
|
|
3893
|
+
uninstall: () => {
|
|
3894
|
+
svcManager.uninstall();
|
|
3895
|
+
},
|
|
3896
|
+
start: () => {
|
|
3897
|
+
svcManager.start();
|
|
3898
|
+
},
|
|
3899
|
+
stop: () => {
|
|
3900
|
+
svcManager.stop();
|
|
3901
|
+
},
|
|
3902
|
+
restart: () => {
|
|
3903
|
+
svcManager.restart();
|
|
3904
|
+
},
|
|
3905
|
+
};
|
|
3906
|
+
methodMap[action]();
|
|
3907
|
+
return Promise.resolve(ok({ service: name, action, success: true }));
|
|
3908
|
+
}
|
|
3909
|
+
catch (err) {
|
|
3910
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3911
|
+
return Promise.resolve(fail(`Service ${action} failed: ${msg}`));
|
|
3912
|
+
}
|
|
3913
|
+
},
|
|
3914
|
+
};
|
|
3915
|
+
return [statusTool, configTool, configApplyTool, serviceTool];
|
|
3916
|
+
}
|
|
3917
|
+
|
|
3918
|
+
/**
|
|
3919
|
+
* Plugin resolution helpers for the OpenClaw plugin SDK.
|
|
3920
|
+
*
|
|
3921
|
+
* @remarks
|
|
3922
|
+
* Provides workspace path resolution and plugin setting resolution
|
|
3923
|
+
* with a standard three-step fallback chain:
|
|
3924
|
+
* plugin config → environment variable → default value.
|
|
3925
|
+
*/
|
|
3926
|
+
/**
|
|
3927
|
+
* Resolve the workspace root from the OpenClaw plugin API.
|
|
3928
|
+
*
|
|
3929
|
+
* @remarks
|
|
3930
|
+
* Tries three sources in order:
|
|
3931
|
+
* 1. `api.config.agents.defaults.workspace` — explicit config
|
|
3932
|
+
* 2. `api.resolvePath('.')` — gateway-provided path resolver
|
|
3933
|
+
* 3. `process.cwd()` — last resort
|
|
3934
|
+
*
|
|
3935
|
+
* @param api - The plugin API object provided by the gateway.
|
|
3936
|
+
* @returns Absolute path to the workspace root.
|
|
3937
|
+
*/
|
|
3938
|
+
function resolveWorkspacePath(api) {
|
|
3939
|
+
const configured = api.config?.agents?.defaults?.workspace;
|
|
3940
|
+
if (typeof configured === 'string' && configured.trim()) {
|
|
3941
|
+
return configured;
|
|
3942
|
+
}
|
|
3943
|
+
if (typeof api.resolvePath === 'function') {
|
|
3944
|
+
return api.resolvePath('.');
|
|
3945
|
+
}
|
|
3946
|
+
return process.cwd();
|
|
3947
|
+
}
|
|
3948
|
+
/**
|
|
3949
|
+
* Resolve a plugin setting via the standard three-step fallback chain:
|
|
3950
|
+
* plugin config → environment variable → fallback value.
|
|
3951
|
+
*
|
|
3952
|
+
* @param api - Plugin API object.
|
|
3953
|
+
* @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
|
|
3954
|
+
* @param key - Config key within the plugin's config object.
|
|
3955
|
+
* @param envVar - Environment variable name.
|
|
3956
|
+
* @param fallback - Default value if neither source provides one.
|
|
3957
|
+
* @returns The resolved setting value.
|
|
3958
|
+
*/
|
|
3959
|
+
function resolvePluginSetting(api, pluginId, key, envVar, fallback) {
|
|
3960
|
+
const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
|
|
3961
|
+
if (typeof fromPlugin === 'string')
|
|
3962
|
+
return fromPlugin;
|
|
3963
|
+
const fromEnv = process.env[envVar];
|
|
3964
|
+
if (fromEnv)
|
|
3965
|
+
return fromEnv;
|
|
3966
|
+
return fallback;
|
|
3967
|
+
}
|
|
3968
|
+
/**
|
|
3969
|
+
* Resolve an optional plugin setting via the two-step fallback chain:
|
|
3970
|
+
* plugin config → environment variable. Returns `undefined` if neither
|
|
3971
|
+
* source provides a value.
|
|
3972
|
+
*
|
|
3973
|
+
* @param api - Plugin API object.
|
|
3974
|
+
* @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
|
|
3975
|
+
* @param key - Config key within the plugin's config object.
|
|
3976
|
+
* @param envVar - Environment variable name.
|
|
3977
|
+
* @returns The resolved setting value, or `undefined`.
|
|
3978
|
+
*/
|
|
3979
|
+
function resolveOptionalPluginSetting(api, pluginId, key, envVar) {
|
|
3980
|
+
const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
|
|
3981
|
+
if (typeof fromPlugin === 'string')
|
|
3982
|
+
return fromPlugin;
|
|
3983
|
+
const fromEnv = process.env[envVar];
|
|
3984
|
+
if (fromEnv)
|
|
3985
|
+
return fromEnv;
|
|
3986
|
+
return undefined;
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
export { AGENTS_MARKERS, CLEANUP_FLAG, COMPONENT_CONFIG_PREFIX, COMPONENT_VERSIONS_FILE, CONFIG_FILE, CORE_CONFIG_DIR, CORE_VERSION, ComponentWriter, DEFAULT_BIND_ADDRESS, DEFAULT_CORE_VERSION, DEFAULT_PORTS, HEARTBEAT_HEADING, META_PORT, PLATFORM_COMPONENTS, REGISTRY_CACHE_FILE, RUNNER_PORT, SECTION_IDS, SECTION_ORDER, SERVER_PORT, SOUL_MARKERS, STALENESS_THRESHOLD_MS, STALE_LOCK_MS, TEMPLATES_DIR, TOOLS_MARKERS, VERSION_STAMP_PATTERN, WATCHER_PORT, WORKSPACE_FILES, atomicWrite, buildHeartbeatSection, checkRegistryVersion, connectionFail, coreConfigSchema, createAsyncContentCache, createComponentWriter, createConfigApplyHandler, createConfigQueryHandler, createPluginCli, createPluginToolset, createServiceCli, createServiceManager, createStatusHandler, fail, fetchJson, fetchWithTimeout, formatBeginMarker, formatEndMarker, generateJsonSchema, getBindAddress, getComponentConfigDir, getConfigRoot, getCoreConfigDir, getCoreConfigFile, getEffectiveServiceName, getServiceState, getServiceUrl, getWorkspacePath, init, isPrime, jaccard, jeevesComponentDescriptorSchema, needsCleanup, ok, orchestrateHeartbeat, parseHeartbeat, parseManaged, patchConfig, postJson, readComponentVersions, refreshPlatformContent, removeComponentVersion, removeManagedSection, resetInit, resolveConfigPath, resolveOpenClawHome, resolveOptionalPluginSetting, resolvePluginSetting, resolveWorkspacePath, seedContent, shingles, shouldWrite, updateManagedSection, withFileLock, writeComponentVersion, writeHeartbeatSection };
|