@bevel-software/platform-core-backend 0.8.0 → 0.9.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/dist/core/core-ports.d.ts +7 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +24 -11
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -2
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +39 -18
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts +3 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +4 -2
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/render-roles-yaml.d.ts +22 -0
- package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
- package/dist/modules/access/render-roles-yaml.js +56 -0
- package/dist/modules/access/render-roles-yaml.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +15 -1
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +30 -15
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/settings/setup.routes.d.ts +10 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +111 -6
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/workspace/startup/kb-git.d.ts +23 -0
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-git.js +86 -0
- package/dist/modules/workspace/startup/kb-git.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.js +21 -0
- package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -0
- package/dist/modules/workspace/workspace.service.d.ts +0 -35
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +2 -101
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/kb-template/AGENTS.md +11 -5
- package/kb-template/access.md +40 -30
- package/kb-template/gitignore.template +16 -0
- package/package.json +3 -3
- package/src/core/core-ports.ts +7 -0
- package/src/core/create-core-server.ts +30 -11
- package/src/core/create-core-services.ts +43 -22
- package/src/modules/access/__tests__/roles-admin.service.test.ts +24 -2
- package/src/modules/access/access.routes.ts +3 -0
- package/src/modules/access/render-roles-yaml.ts +65 -0
- package/src/modules/access/roles-admin.service.ts +32 -14
- package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
- package/src/modules/settings/setup.routes.ts +112 -5
- package/src/modules/workspace/__tests__/workspace.service.test.ts +5 -94
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
- package/src/modules/workspace/startup/kb-git.ts +94 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
- package/src/modules/workspace/startup/on-server-start.ts +97 -0
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
- package/src/modules/workspace/{plugins-migration.ts → startup/steps/groups-to-plugins.step.ts} +342 -260
- package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
- package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
- package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
- package/src/modules/workspace/workspace.service.ts +2 -106
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -512
- package/src/modules/workspace/__tests__/plugins-migration.test.ts +0 -427
- package/src/modules/workspace/kb-seed.interface.ts +0 -36
- package/src/modules/workspace/kb-seed.service.ts +0 -584
package/src/modules/workspace/{plugins-migration.ts → startup/steps/groups-to-plugins.step.ts}
RENAMED
|
@@ -11,19 +11,24 @@ import {
|
|
|
11
11
|
PLUGIN_SKILLS_DIR,
|
|
12
12
|
renderPluginManifest,
|
|
13
13
|
} from '@bevel-software/platform-shared';
|
|
14
|
-
import type { ToolManualDescriptor } from '
|
|
15
|
-
import { normalizeToolManual } from '
|
|
16
|
-
import { parseOwnAccessEntries } from '
|
|
17
|
-
import { containsVariableReference } from '
|
|
18
|
-
import { IGNORE_FILENAME } from '
|
|
14
|
+
import type { ToolManualDescriptor } from '../../../tool-manuals/tool-manuals.contract.js';
|
|
15
|
+
import { normalizeToolManual } from '../../../tool-manuals/tool-manuals.service.js';
|
|
16
|
+
import { parseOwnAccessEntries } from '../../../access/access-control.service.js';
|
|
17
|
+
import { containsVariableReference } from '../../../../shared/variable-refs.js';
|
|
18
|
+
import { IGNORE_FILENAME } from '../../bevel-ignore.js';
|
|
19
|
+
import type { KbBranch, OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* One-way migration of a knowledge base from `Groups/` to the Agent Plugins
|
|
22
|
-
* layout under `Plugins/` (https://agent-plugins.org, v1.0.0)
|
|
23
|
+
* layout under `Plugins/` (https://agent-plugins.org, v1.0.0), as an
|
|
24
|
+
* {@link OnServerStart} step. (It began life as an in-place module run from
|
|
25
|
+
* the lazy top-up; that path is gone, and this is the only form.) Steps
|
|
26
|
+
* never write: every change is DECLARED on the branch handle
|
|
27
|
+
* (`move`/`write`/`remove`) and the runner applies it, while every read goes
|
|
28
|
+
* against the real, pre-step tree via `repoDir()`.
|
|
23
29
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* and a half-finished run is completed by the next one.
|
|
30
|
+
* Idempotent: a KB already on the new layout is untouched, and a half-finished
|
|
31
|
+
* run is completed by the next one.
|
|
27
32
|
*
|
|
28
33
|
* What moves — and what CONVERTS:
|
|
29
34
|
*
|
|
@@ -43,21 +48,26 @@ import { IGNORE_FILENAME } from './bevel-ignore.js';
|
|
|
43
48
|
* `extensions["software.bevel.hexis"].mcpServers[<id>]` block, the reverse-DNS
|
|
44
49
|
* namespace the spec reserves for client-specific data. `http`/`inline`
|
|
45
50
|
* manuals still MOVE as `.tool` files: nothing but this platform can run them.
|
|
51
|
+
*
|
|
52
|
+
* Scope: EVERY branch, drafts included and writable (`ctx.allBranches()`) —
|
|
53
|
+
* maintenance applied uniformly at the quiet moment is what keeps a draft's
|
|
54
|
+
* change-request diff down to the user's own changes; a stale draft against a
|
|
55
|
+
* migrated target would diff by the whole rename.
|
|
56
|
+
*
|
|
57
|
+
* Outcomes: a per-manual NOT-converted refusal makes the step `partial` with
|
|
58
|
+
* the reasons (the declared ops for everything else still apply); a branch
|
|
59
|
+
* carrying BOTH roots contributes nothing but a note; everything else is `ok`.
|
|
46
60
|
*/
|
|
61
|
+
export class GroupsToPluginsStep implements OnServerStart {
|
|
62
|
+
readonly name = 'groups-to-plugins';
|
|
47
63
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
/** Whether the `Groups/` → `Plugins/` root rename itself happened this run. */
|
|
56
|
-
renamed: boolean;
|
|
57
|
-
/** Whether the KB's own `.bevelignore` had its `Groups/` rule rewritten (a repo-root file, staged separately). */
|
|
58
|
-
ignoreRewritten: boolean;
|
|
59
|
-
/** Human-readable summary lines (plugin names, tool moves) for the seed log. */
|
|
60
|
-
notes: string[];
|
|
64
|
+
async run(ctx: ServerStartContext): Promise<StepResult> {
|
|
65
|
+
const refusals: string[] = [];
|
|
66
|
+
for (const branch of await ctx.allBranches()) {
|
|
67
|
+
await migrateBranch(branch, refusals);
|
|
68
|
+
}
|
|
69
|
+
return refusals.length > 0 ? { outcome: 'partial', reason: refusals.join('; ') } : { outcome: 'ok' };
|
|
70
|
+
}
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
// lstat, both helpers: SYMLINKS ARE NOT SUPPORTED IN PLUGINS, anywhere, so a
|
|
@@ -81,18 +91,6 @@ async function isDir(p: string): Promise<boolean> {
|
|
|
81
91
|
}
|
|
82
92
|
}
|
|
83
93
|
|
|
84
|
-
/**
|
|
85
|
-
* Move `from` to `to`, creating the parent. Never overwrites: a destination
|
|
86
|
-
* that already exists means a previous run got there first (or a human did),
|
|
87
|
-
* and clobbering it would destroy the newer copy.
|
|
88
|
-
*/
|
|
89
|
-
async function moveIfAbsent(from: string, to: string): Promise<boolean> {
|
|
90
|
-
if (await exists(to)) return false;
|
|
91
|
-
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
92
|
-
await fs.rename(from, to);
|
|
93
|
-
return true;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
94
|
/**
|
|
97
95
|
* A header value referencing a vault variable rather than carrying a literal.
|
|
98
96
|
* The substitutor's own grammar decides (shared/variable-refs.ts): both
|
|
@@ -129,126 +127,6 @@ async function readJson(p: string): Promise<Record<string, unknown> | null> {
|
|
|
129
127
|
}
|
|
130
128
|
}
|
|
131
129
|
|
|
132
|
-
/**
|
|
133
|
-
* Fold converted mcp manuals into the plugin's mcp.json and plugin.json.
|
|
134
|
-
*
|
|
135
|
-
* MERGE, never clobber: an entry already present under a manual's key — hand
|
|
136
|
-
* written or from a previous run — wins, because overwriting it would discard
|
|
137
|
-
* the newer intent. The extension block merges the same way. A plugin.json
|
|
138
|
-
* that does not parse costs the extension write (logged), not the migration.
|
|
139
|
-
*/
|
|
140
|
-
interface ConvertedManual {
|
|
141
|
-
manual: ToolManualDescriptor;
|
|
142
|
-
/** The source `.tool`, deleted only once the fold has landed. */
|
|
143
|
-
abs: string;
|
|
144
|
-
note: string;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async function foldIntoPluginFiles(
|
|
148
|
-
pluginDir: string,
|
|
149
|
-
folderName: string,
|
|
150
|
-
manuals: ConvertedManual[],
|
|
151
|
-
notes: string[],
|
|
152
|
-
): Promise<boolean> {
|
|
153
|
-
if (manuals.length === 0) return false;
|
|
154
|
-
|
|
155
|
-
const mcpPath = path.join(pluginDir, PLUGIN_MCP_FILE);
|
|
156
|
-
const mcp = (await readJson(mcpPath)) ?? { $schema: PLUGIN_MCP_SCHEMA, mcpServers: {} };
|
|
157
|
-
// An array (or any non-object) here would take property assignments and then
|
|
158
|
-
// drop them at stringify — normalize to an object before merging into it.
|
|
159
|
-
if (typeof mcp.mcpServers !== 'object' || mcp.mcpServers === null || Array.isArray(mcp.mcpServers)) {
|
|
160
|
-
mcp.mcpServers = {};
|
|
161
|
-
}
|
|
162
|
-
const servers = mcp.mcpServers as Record<string, unknown>;
|
|
163
|
-
|
|
164
|
-
const manifestPath = path.join(pluginDir, PLUGIN_MANIFEST_FILE);
|
|
165
|
-
const manifest = await readJson(manifestPath);
|
|
166
|
-
if (manifest === null) {
|
|
167
|
-
console.warn(
|
|
168
|
-
`[plugins-migration] ${folderName}/${PLUGIN_MANIFEST_FILE} is missing or unparsable — ` +
|
|
169
|
-
'mcp manuals convert only when they carry NOTHING for the extensions block; any ' +
|
|
170
|
-
'non-portable half (auth headers, variables, a description, or the local-only flag) ' +
|
|
171
|
-
'keeps the manual a `.tool` until the manifest is fixed.',
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
let wroteMcp = false;
|
|
176
|
-
let wroteManifest = false;
|
|
177
|
-
const folded: ConvertedManual[] = [];
|
|
178
|
-
for (const item of manuals.sort((a, b) => a.manual.name.localeCompare(b.manual.name))) {
|
|
179
|
-
const m = item.manual;
|
|
180
|
-
const { literal, credential } = splitHeaders(m.headers);
|
|
181
|
-
// A manual whose non-portable half (auth headers, variables, local flag)
|
|
182
|
-
// has nowhere to go — the manifest is missing or unparsable — is NOT
|
|
183
|
-
// converted at all: writing only its portable half and deleting the
|
|
184
|
-
// source would silently discard the credential wiring. It stays a
|
|
185
|
-
// `.tool` until the manifest is fixed.
|
|
186
|
-
const extEntryPreview = {
|
|
187
|
-
...(Object.keys(credential).length > 0 ? { headers: credential } : {}),
|
|
188
|
-
...(m.variables && m.variables.length > 0 ? { variables: m.variables } : {}),
|
|
189
|
-
...(typeof m.description === 'string' ? { description: m.description } : {}),
|
|
190
|
-
...(m.remote === false ? { local: true } : {}),
|
|
191
|
-
};
|
|
192
|
-
if (manifest === null && Object.keys(extEntryPreview).length > 0) {
|
|
193
|
-
notes.push(
|
|
194
|
-
`${folderName}: ${item.note} NOT converted — ${PLUGIN_MANIFEST_FILE} is missing/unparsable ` +
|
|
195
|
-
'and the manual carries declarations (auth headers, variables, a description, or the ' +
|
|
196
|
-
'local-only flag) that would be lost; fix the manifest first.',
|
|
197
|
-
);
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
folded.push(item);
|
|
201
|
-
// Own-property check: `in` sees `constructor` and friends on the prototype,
|
|
202
|
-
// which would silently skip a legitimately named server.
|
|
203
|
-
if (!Object.prototype.hasOwnProperty.call(servers, m.name)) {
|
|
204
|
-
servers[m.name] = {
|
|
205
|
-
type: 'streamable-http',
|
|
206
|
-
url: m.url,
|
|
207
|
-
...(Object.keys(literal).length > 0 ? { headers: literal } : {}),
|
|
208
|
-
};
|
|
209
|
-
wroteMcp = true;
|
|
210
|
-
notes.push(`${folderName}: ${m.name} → ${PLUGIN_MCP_FILE}`);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// The non-portable half: auth headers, variable declarations, local-only.
|
|
214
|
-
const extEntry = extEntryPreview;
|
|
215
|
-
if (manifest !== null && Object.keys(extEntry).length > 0) {
|
|
216
|
-
// Normalize each level: a parseable manifest can still carry a string or
|
|
217
|
-
// array where an object belongs, and mutating that would throw mid-run.
|
|
218
|
-
if (typeof manifest.extensions !== 'object' || manifest.extensions === null || Array.isArray(manifest.extensions)) {
|
|
219
|
-
manifest.extensions = {};
|
|
220
|
-
}
|
|
221
|
-
const ext = manifest.extensions as Record<string, unknown>;
|
|
222
|
-
if (typeof ext[HEXIS_EXTENSION_NS] !== 'object' || ext[HEXIS_EXTENSION_NS] === null || Array.isArray(ext[HEXIS_EXTENSION_NS])) {
|
|
223
|
-
ext[HEXIS_EXTENSION_NS] = {};
|
|
224
|
-
}
|
|
225
|
-
const ns = ext[HEXIS_EXTENSION_NS] as Record<string, unknown>;
|
|
226
|
-
if (typeof ns.mcpServers !== 'object' || ns.mcpServers === null || Array.isArray(ns.mcpServers)) {
|
|
227
|
-
ns.mcpServers = {};
|
|
228
|
-
}
|
|
229
|
-
const extServers = ns.mcpServers as Record<string, unknown>;
|
|
230
|
-
if (!Object.prototype.hasOwnProperty.call(extServers, m.name)) {
|
|
231
|
-
extServers[m.name] = extEntry;
|
|
232
|
-
wroteManifest = true;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (wroteMcp) await fs.writeFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`, 'utf8');
|
|
238
|
-
if (wroteManifest) {
|
|
239
|
-
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
240
|
-
notes.push(`${folderName}: wrote mcp-server declarations into ${PLUGIN_MANIFEST_FILE}`);
|
|
241
|
-
}
|
|
242
|
-
// Sources go LAST, once everything they carried is on disk elsewhere. A
|
|
243
|
-
// failure anywhere above leaves every `.tool` in place for the next run —
|
|
244
|
-
// which re-converts idempotently, since the fold never clobbers a key.
|
|
245
|
-
for (const item of folded) {
|
|
246
|
-
await fs.rm(item.abs, { force: true });
|
|
247
|
-
notes.push(`${folderName}: ${item.note} converted to an ${PLUGIN_MCP_FILE} entry`);
|
|
248
|
-
}
|
|
249
|
-
return wroteMcp || wroteManifest || folded.length > 0;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
130
|
/**
|
|
253
131
|
* Parse a `.tool`; a convertible MCP manual (has a url) parses to a
|
|
254
132
|
* descriptor. A non-candidate (other types, a file that will not parse)
|
|
@@ -302,46 +180,195 @@ async function asMcpManual(abs: string, repoRel: string): Promise<ToolManualDesc
|
|
|
302
180
|
}
|
|
303
181
|
}
|
|
304
182
|
|
|
305
|
-
|
|
183
|
+
interface ConvertedManual {
|
|
184
|
+
manual: ToolManualDescriptor;
|
|
185
|
+
/** Repo-relative path of the source `.tool`, removed only once the fold has been declared. */
|
|
186
|
+
rel: string;
|
|
187
|
+
note: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Migrate one branch. Declares ops only; a branch whose migration declares
|
|
192
|
+
* nothing stays clean, so a notes-only pass (advisory refusals) commits
|
|
193
|
+
* nothing — the same contract the in-place migration's `migrated` flag
|
|
194
|
+
* carried.
|
|
195
|
+
*/
|
|
196
|
+
async function migrateBranch(branch: KbBranch, refusals: string[]): Promise<void> {
|
|
197
|
+
const repoDir = await branch.repoDir();
|
|
198
|
+
const legacyDir = path.join(repoDir, LEGACY_GROUPS_DIR);
|
|
199
|
+
const pluginsDir = path.join(repoDir, PLUGINS_DIR);
|
|
200
|
+
|
|
201
|
+
const hasLegacy = await isDir(legacyDir);
|
|
202
|
+
const hasPlugins = await isDir(pluginsDir);
|
|
203
|
+
|
|
204
|
+
// `hasPlugins` is false for a FILE or SYMLINK squatting the `Plugins` name.
|
|
205
|
+
// Checked BEFORE the nothing-to-do early return below: a branch with a
|
|
206
|
+
// squatter and NO Groups/ would otherwise be silently skipped (and on a
|
|
207
|
+
// draft nothing later ever reports it), while a branch mid-migration would
|
|
208
|
+
// die in the applier with a bare ENOTDIR. Throw the actionable version
|
|
209
|
+
// either way: under the phase's fail-closed contract this stops the boot,
|
|
210
|
+
// which a squatted reserved root deserves (same stance as
|
|
211
|
+
// template-files.step.ts's reserved-dir check).
|
|
212
|
+
if (!hasPlugins && (await exists(pluginsDir))) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Branch "${branch.name}": "${PLUGINS_DIR}" exists but is not a directory` +
|
|
215
|
+
(hasLegacy ? `, so ${LEGACY_GROUPS_DIR}/ cannot be renamed to ${PLUGINS_DIR}/` : '') +
|
|
216
|
+
`. Remove or rename the "${PLUGINS_DIR}" entry — the platform requires this name to be a folder.`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (hasLegacy && hasPlugins) {
|
|
221
|
+
// Both present: somebody is mid-migration by hand, or two branches merged
|
|
222
|
+
// badly. Merging them here would guess at which copy of a same-named
|
|
223
|
+
// plugin wins, so we refuse and say so — loudly, because the KB is in a
|
|
224
|
+
// state a human needs to look at. The branch contributes no ops, only a
|
|
225
|
+
// note (which surfaces in a commit only if a later step dirties it).
|
|
226
|
+
console.warn(
|
|
227
|
+
`[groups-to-plugins] ${branch.name}: both ${LEGACY_GROUPS_DIR}/ and ${PLUGINS_DIR}/ exist — leaving both alone. ` +
|
|
228
|
+
`Merge ${LEGACY_GROUPS_DIR}/ into ${PLUGINS_DIR}/ by hand; nothing is being migrated automatically.`,
|
|
229
|
+
);
|
|
230
|
+
branch.note(
|
|
231
|
+
`${LEGACY_GROUPS_DIR}/ and ${PLUGINS_DIR}/ both exist — merge by hand; nothing was migrated automatically`,
|
|
232
|
+
);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (!hasLegacy && !hasPlugins) return;
|
|
236
|
+
|
|
237
|
+
// Every read below goes against the PRE-STEP tree: when the root rename is
|
|
238
|
+
// declared this run it is NOT yet on disk, so the plugin folders are still
|
|
239
|
+
// read under their legacy root while every declared op speaks post-rename
|
|
240
|
+
// `Plugins/…` paths — the root move is declared FIRST, so by the time the
|
|
241
|
+
// per-folder ops apply, the tree is already under `Plugins/`.
|
|
242
|
+
const rootOnDisk = hasLegacy ? legacyDir : pluginsDir;
|
|
243
|
+
// Detail notes are held back until we know ops were declared: the subject
|
|
244
|
+
// line must describe a commit that will actually exist.
|
|
245
|
+
const details: string[] = [];
|
|
246
|
+
let changed = false;
|
|
247
|
+
|
|
248
|
+
if (hasLegacy) {
|
|
249
|
+
// The Groups→Plugins root rename is ONE declared op, directory and all.
|
|
250
|
+
branch.move(LEGACY_GROUPS_DIR, PLUGINS_DIR);
|
|
251
|
+
details.push(`${LEGACY_GROUPS_DIR}/ → ${PLUGINS_DIR}/`);
|
|
252
|
+
changed = true;
|
|
253
|
+
// Rides WITH the rename, not on every run: the rename is what turned the
|
|
254
|
+
// ignore rule stale, so the run that renames is the run that heals it.
|
|
255
|
+
changed = (await rewriteIgnoreRootRule(repoDir, branch, details)) || changed;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Runs whether or not the rename just happened, so a KB already on
|
|
259
|
+
// `Plugins/` still gets missing manifests and any half-done reorganisation
|
|
260
|
+
// finished. That is what makes this idempotent rather than once-only.
|
|
261
|
+
for (const entry of await fs.readdir(rootOnDisk, { withFileTypes: true })) {
|
|
262
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
263
|
+
const folderChanged = await migratePluginFolder(
|
|
264
|
+
branch,
|
|
265
|
+
path.join(rootOnDisk, entry.name),
|
|
266
|
+
entry.name,
|
|
267
|
+
details,
|
|
268
|
+
refusals,
|
|
269
|
+
);
|
|
270
|
+
changed = changed || folderChanged;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!changed) return;
|
|
274
|
+
// First note becomes the commit subject — the same messages the lazy
|
|
275
|
+
// top-up committed under; the detail notes become its body.
|
|
276
|
+
branch.note(
|
|
277
|
+
hasLegacy
|
|
278
|
+
? `Move ${LEGACY_GROUPS_DIR}/ to ${PLUGINS_DIR}/ (Agent Plugins layout)`
|
|
279
|
+
: `Reorganise ${PLUGINS_DIR}/ to the Agent Plugins layout`,
|
|
280
|
+
);
|
|
281
|
+
for (const line of details) branch.note(line);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Rewrite the KB's `.bevelignore` rule for the renamed root: the exact line
|
|
286
|
+
* `Groups/` becomes `Plugins/`. Without this a migrated KB is left with a
|
|
287
|
+
* stale rule for a folder that no longer exists and NO rule for the new one,
|
|
288
|
+
* so plugin internals start showing up in the file tree and agent view.
|
|
289
|
+
*
|
|
290
|
+
* The file is the operator's — this touches ONE line, the one the platform's
|
|
291
|
+
* own rename invalidated, and only when `Plugins/` is not already listed
|
|
292
|
+
* (in which case the stale line is harmlessly dead and left alone).
|
|
293
|
+
*/
|
|
294
|
+
async function rewriteIgnoreRootRule(repoDir: string, branch: KbBranch, details: string[]): Promise<boolean> {
|
|
295
|
+
let current: string;
|
|
296
|
+
try {
|
|
297
|
+
current = await fs.readFile(path.join(repoDir, IGNORE_FILENAME), 'utf-8');
|
|
298
|
+
} catch {
|
|
299
|
+
return false; // no ignore file — nothing went stale
|
|
300
|
+
}
|
|
301
|
+
const legacyRule = `${LEGACY_GROUPS_DIR}/`;
|
|
302
|
+
const newRule = `${PLUGINS_DIR}/`;
|
|
303
|
+
const lines = current.split('\n');
|
|
304
|
+
if (lines.some((l) => l.trim() === newRule)) return false;
|
|
305
|
+
const idx = lines.findIndex((l) => l.trim() === legacyRule);
|
|
306
|
+
if (idx === -1) return false;
|
|
307
|
+
// EVERY exact-match line follows the rename: the first becomes the new
|
|
308
|
+
// rule, any further duplicates are dropped — rewriting only the first would
|
|
309
|
+
// leave stale `Groups/` lines behind, and the already-has-Plugins guard
|
|
310
|
+
// above means a second pass would never touch them.
|
|
311
|
+
lines[idx] = newRule;
|
|
312
|
+
const rewritten = lines.filter((l, i) => i <= idx || l.trim() !== legacyRule);
|
|
313
|
+
branch.write(IGNORE_FILENAME, rewritten.join('\n'));
|
|
314
|
+
details.push(`${IGNORE_FILENAME}: ${legacyRule} → ${newRule}`);
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Reorganise ONE plugin folder. `folderDir` is where the folder sits ON DISK
|
|
320
|
+
* (under the legacy root when the rename is buffered this run); declared op
|
|
321
|
+
* paths always speak `Plugins/<folder>/…`. Returns whether ops were declared.
|
|
322
|
+
*/
|
|
306
323
|
async function migratePluginFolder(
|
|
307
|
-
|
|
324
|
+
branch: KbBranch,
|
|
325
|
+
folderDir: string,
|
|
308
326
|
folderName: string,
|
|
309
|
-
|
|
310
|
-
|
|
327
|
+
details: string[],
|
|
328
|
+
refusals: string[],
|
|
329
|
+
): Promise<boolean> {
|
|
330
|
+
const relPlugin = `${PLUGINS_DIR}/${folderName}`;
|
|
311
331
|
let changed = false;
|
|
312
332
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
);
|
|
319
|
-
|
|
333
|
+
// When the manifest is written this run its content is remembered: the
|
|
334
|
+
// buffered write is not on disk yet, and the fold below must see it.
|
|
335
|
+
let renderedManifest: string | null = null;
|
|
336
|
+
if (!(await exists(path.join(folderDir, PLUGIN_MANIFEST_FILE)))) {
|
|
337
|
+
renderedManifest = renderPluginManifest(folderName);
|
|
338
|
+
branch.write(`${relPlugin}/${PLUGIN_MANIFEST_FILE}`, renderedManifest);
|
|
339
|
+
details.push(`${folderName}: wrote ${PLUGIN_MANIFEST_FILE}`);
|
|
320
340
|
changed = true;
|
|
321
341
|
}
|
|
322
342
|
|
|
323
|
-
const entries = await fs.readdir(
|
|
343
|
+
const entries = await fs.readdir(folderDir, { withFileTypes: true });
|
|
324
344
|
const converted: ConvertedManual[] = [];
|
|
325
345
|
|
|
326
|
-
const convertOrMove = async (abs: string,
|
|
327
|
-
const manual = await asMcpManual(abs,
|
|
346
|
+
const convertOrMove = async (abs: string, rel: string, note: string): Promise<void> => {
|
|
347
|
+
const manual = await asMcpManual(abs, rel);
|
|
328
348
|
if (manual !== null && typeof manual !== 'string') {
|
|
329
|
-
// QUEUED for conversion — the `.tool` is
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
converted.push({ manual,
|
|
349
|
+
// QUEUED for conversion — the `.tool`'s removal is declared only AFTER
|
|
350
|
+
// its entry's writes (see foldIntoPluginFiles). Buffering makes the
|
|
351
|
+
// apply all-or-nothing anyway, but the declaration order keeps the old
|
|
352
|
+
// recovery reasoning legible: the file IS the recovery path until the
|
|
353
|
+
// fold has landed.
|
|
354
|
+
converted.push({ manual, rel, note });
|
|
335
355
|
return;
|
|
336
356
|
}
|
|
337
357
|
if (typeof manual === 'string') {
|
|
338
358
|
// The operator's answer to "why is this integration not in mcp.json":
|
|
339
|
-
// a refused conversion must read as the deliberate retention it is
|
|
340
|
-
|
|
359
|
+
// a refused conversion must read as the deliberate retention it is —
|
|
360
|
+
// surfaced through the step's `partial` reason rather than a note, so
|
|
361
|
+
// it names what did NOT change instead of decorating a commit.
|
|
362
|
+
refusals.push(`${branch.name}: ${folderName}: ${note} NOT converted — ${manual}; kept as a .tool`);
|
|
341
363
|
}
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
364
|
+
const destDisk = path.join(folderDir, ...HEXIS_TOOLS_DIR.split('/'), path.basename(abs));
|
|
365
|
+
// Never overwrite: a destination that already exists means a previous run
|
|
366
|
+
// got there first (or a human did), and clobbering it would destroy the
|
|
367
|
+
// newer copy. The check is against the pre-step tree — the only tree that
|
|
368
|
+
// exists while this step runs.
|
|
369
|
+
if (abs !== destDisk && !(await exists(destDisk))) {
|
|
370
|
+
branch.move(rel, `${relPlugin}/${HEXIS_TOOLS_DIR}/${path.basename(abs)}`);
|
|
371
|
+
details.push(`${folderName}: ${note} → ${HEXIS_TOOLS_DIR}/${path.basename(abs)}`);
|
|
345
372
|
changed = true;
|
|
346
373
|
}
|
|
347
374
|
};
|
|
@@ -352,20 +379,20 @@ async function migratePluginFolder(
|
|
|
352
379
|
if (entry.name === PLUGIN_MCP_FILE || entry.name === 'access.md') continue;
|
|
353
380
|
if (entry.name === HEXIS_TOOLS_DIR.split('/')[0]) continue;
|
|
354
381
|
|
|
355
|
-
const abs = path.join(
|
|
382
|
+
const abs = path.join(folderDir, entry.name);
|
|
356
383
|
|
|
357
384
|
// A skill is a folder carrying SKILL.md — the same rule the catalog uses.
|
|
358
385
|
if (entry.isDirectory() && (await exists(path.join(abs, 'SKILL.md')))) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
386
|
+
if (!(await exists(path.join(folderDir, PLUGIN_SKILLS_DIR, entry.name)))) {
|
|
387
|
+
branch.move(`${relPlugin}/${entry.name}`, `${relPlugin}/${PLUGIN_SKILLS_DIR}/${entry.name}`);
|
|
388
|
+
details.push(`${folderName}: ${entry.name}/ → ${PLUGIN_SKILLS_DIR}/${entry.name}/`);
|
|
362
389
|
changed = true;
|
|
363
390
|
}
|
|
364
391
|
continue;
|
|
365
392
|
}
|
|
366
393
|
|
|
367
394
|
if (entry.isFile() && entry.name.toLowerCase().endsWith('.tool')) {
|
|
368
|
-
await convertOrMove(abs, entry.name
|
|
395
|
+
await convertOrMove(abs, `${relPlugin}/${entry.name}`, entry.name);
|
|
369
396
|
}
|
|
370
397
|
}
|
|
371
398
|
|
|
@@ -375,105 +402,160 @@ async function migratePluginFolder(
|
|
|
375
402
|
// `isDir` is lstat-based, so a SYMLINK planted at this path is not swept:
|
|
376
403
|
// this sweep DELETES what it converts, and following a link would delete
|
|
377
404
|
// `.tool` files from wherever the link really points.
|
|
378
|
-
|
|
405
|
+
//
|
|
406
|
+
// Under buffered ops this sweep reads the PRE-STEP tree, so a `.tool` the
|
|
407
|
+
// first sweep just declared moved here is not visible — and does not need
|
|
408
|
+
// to be: same-run movables were already converted or refused by the same
|
|
409
|
+
// `asMcpManual` logic above. The sweep exists for files parked by EARLIER
|
|
410
|
+
// runs, which are on disk. (The old in-place code happened to re-see
|
|
411
|
+
// same-run moves and no-op on them; the buffered read makes that a
|
|
412
|
+
// non-event by construction.)
|
|
413
|
+
const extToolsDir = path.join(folderDir, ...HEXIS_TOOLS_DIR.split('/'));
|
|
379
414
|
if (await isDir(extToolsDir)) {
|
|
380
415
|
for (const entry of await fs.readdir(extToolsDir, { withFileTypes: true })) {
|
|
381
416
|
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.tool')) continue;
|
|
382
417
|
const abs = path.join(extToolsDir, entry.name);
|
|
383
|
-
const
|
|
418
|
+
const rel = `${relPlugin}/${HEXIS_TOOLS_DIR}/${entry.name}`;
|
|
419
|
+
const manual = await asMcpManual(abs, rel);
|
|
384
420
|
// A refusal reason here is a SETTLED resident of the tools dir — it
|
|
385
421
|
// was named the run it moved in, and this sweep repeats every boot,
|
|
386
|
-
// so re-
|
|
422
|
+
// so re-raising it would be log spam. Only a convertible manual queues.
|
|
387
423
|
if (manual !== null && typeof manual !== 'string') {
|
|
388
|
-
converted.push({ manual,
|
|
424
|
+
converted.push({ manual, rel, note: `${HEXIS_TOOLS_DIR}/${entry.name}` });
|
|
389
425
|
}
|
|
390
426
|
}
|
|
391
427
|
}
|
|
392
428
|
|
|
393
|
-
changed =
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Rewrite the KB's `.bevelignore` rule for the renamed root: the exact line
|
|
399
|
-
* `Groups/` becomes `Plugins/`. Without this a migrated KB is left with a
|
|
400
|
-
* stale rule for a folder that no longer exists and NO rule for the new one,
|
|
401
|
-
* so plugin internals start showing up in the file tree and agent view.
|
|
402
|
-
*
|
|
403
|
-
* The file is the operator's — this touches ONE line, the one the platform's
|
|
404
|
-
* own rename invalidated, and only when `Plugins/` is not already listed
|
|
405
|
-
* (in which case the stale line is harmlessly dead and left alone).
|
|
406
|
-
*/
|
|
407
|
-
async function rewriteIgnoreRootRule(repoDir: string, notes: string[]): Promise<boolean> {
|
|
408
|
-
const ignorePath = path.join(repoDir, IGNORE_FILENAME);
|
|
409
|
-
let current: string;
|
|
410
|
-
try {
|
|
411
|
-
current = await fs.readFile(ignorePath, 'utf-8');
|
|
412
|
-
} catch {
|
|
413
|
-
return false; // no ignore file — nothing went stale
|
|
414
|
-
}
|
|
415
|
-
const legacyRule = `${LEGACY_GROUPS_DIR}/`;
|
|
416
|
-
const newRule = `${PLUGINS_DIR}/`;
|
|
417
|
-
const lines = current.split('\n');
|
|
418
|
-
if (lines.some((l) => l.trim() === newRule)) return false;
|
|
419
|
-
const idx = lines.findIndex((l) => l.trim() === legacyRule);
|
|
420
|
-
if (idx === -1) return false;
|
|
421
|
-
lines[idx] = newRule;
|
|
422
|
-
await fs.writeFile(ignorePath, lines.join('\n'), 'utf-8');
|
|
423
|
-
notes.push(`${IGNORE_FILENAME}: ${legacyRule} → ${newRule}`);
|
|
424
|
-
return true;
|
|
429
|
+
changed =
|
|
430
|
+
(await foldIntoPluginFiles(branch, folderDir, relPlugin, folderName, renderedManifest, converted, details, refusals)) ||
|
|
431
|
+
changed;
|
|
432
|
+
return changed;
|
|
425
433
|
}
|
|
426
434
|
|
|
427
435
|
/**
|
|
428
|
-
*
|
|
436
|
+
* Fold converted mcp manuals into the plugin's mcp.json and plugin.json.
|
|
429
437
|
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
438
|
+
* MERGE, never clobber: an entry already present under a manual's key — hand
|
|
439
|
+
* written or from a previous run — wins, because overwriting it would discard
|
|
440
|
+
* the newer intent. The extension block merges the same way. A plugin.json
|
|
441
|
+
* that does not parse costs the extension write (logged), not the migration.
|
|
433
442
|
*/
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
443
|
+
async function foldIntoPluginFiles(
|
|
444
|
+
branch: KbBranch,
|
|
445
|
+
folderDir: string,
|
|
446
|
+
relPlugin: string,
|
|
447
|
+
folderName: string,
|
|
448
|
+
renderedManifest: string | null,
|
|
449
|
+
manuals: ConvertedManual[],
|
|
450
|
+
details: string[],
|
|
451
|
+
refusals: string[],
|
|
452
|
+
): Promise<boolean> {
|
|
453
|
+
if (manuals.length === 0) return false;
|
|
438
454
|
|
|
439
|
-
const
|
|
440
|
-
|
|
455
|
+
const mcp = (await readJson(path.join(folderDir, PLUGIN_MCP_FILE))) ?? {
|
|
456
|
+
$schema: PLUGIN_MCP_SCHEMA,
|
|
457
|
+
mcpServers: {},
|
|
458
|
+
};
|
|
459
|
+
// An array (or any non-object) here would take property assignments and then
|
|
460
|
+
// drop them at stringify — normalize to an object before merging into it.
|
|
461
|
+
if (typeof mcp.mcpServers !== 'object' || mcp.mcpServers === null || Array.isArray(mcp.mcpServers)) {
|
|
462
|
+
mcp.mcpServers = {};
|
|
463
|
+
}
|
|
464
|
+
const servers = mcp.mcpServers as Record<string, unknown>;
|
|
441
465
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
466
|
+
// The manifest may have been DECLARED this very run — a buffered write not
|
|
467
|
+
// yet on disk. Reading the disk then would see "missing" and refuse
|
|
468
|
+
// conversions the write-then-read in-place code performed, so the step's
|
|
469
|
+
// own rendered content stands in for the tree it is about to produce.
|
|
470
|
+
const manifest =
|
|
471
|
+
renderedManifest !== null
|
|
472
|
+
? (JSON.parse(renderedManifest) as Record<string, unknown>)
|
|
473
|
+
: await readJson(path.join(folderDir, PLUGIN_MANIFEST_FILE));
|
|
474
|
+
if (manifest === null) {
|
|
447
475
|
console.warn(
|
|
448
|
-
`[plugins
|
|
449
|
-
|
|
476
|
+
`[groups-to-plugins] ${branch.name}: ${folderName}/${PLUGIN_MANIFEST_FILE} is missing or unparsable — ` +
|
|
477
|
+
'mcp manuals convert only when they carry NOTHING for the extensions block; any ' +
|
|
478
|
+
'non-portable half (auth headers, variables, a description, or the local-only flag) ' +
|
|
479
|
+
'keeps the manual a `.tool` until the manifest is fixed.',
|
|
450
480
|
);
|
|
451
|
-
return { migrated: false, renamed: false, ignoreRewritten: false, notes };
|
|
452
481
|
}
|
|
453
482
|
|
|
454
|
-
let
|
|
455
|
-
let
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
483
|
+
let wroteMcp = false;
|
|
484
|
+
let wroteManifest = false;
|
|
485
|
+
const folded: ConvertedManual[] = [];
|
|
486
|
+
for (const item of manuals.sort((a, b) => a.manual.name.localeCompare(b.manual.name))) {
|
|
487
|
+
const m = item.manual;
|
|
488
|
+
const { literal, credential } = splitHeaders(m.headers);
|
|
489
|
+
// A manual whose non-portable half (auth headers, variables, local flag)
|
|
490
|
+
// has nowhere to go — the manifest is missing or unparsable — is NOT
|
|
491
|
+
// converted at all: writing only its portable half and deleting the
|
|
492
|
+
// source would silently discard the credential wiring. It stays a
|
|
493
|
+
// `.tool` until the manifest is fixed.
|
|
494
|
+
const extEntry = {
|
|
495
|
+
...(Object.keys(credential).length > 0 ? { headers: credential } : {}),
|
|
496
|
+
...(m.variables && m.variables.length > 0 ? { variables: m.variables } : {}),
|
|
497
|
+
...(typeof m.description === 'string' ? { description: m.description } : {}),
|
|
498
|
+
...(m.remote === false ? { local: true } : {}),
|
|
499
|
+
};
|
|
500
|
+
if (manifest === null && Object.keys(extEntry).length > 0) {
|
|
501
|
+
refusals.push(
|
|
502
|
+
`${branch.name}: ${folderName}: ${item.note} NOT converted — ${PLUGIN_MANIFEST_FILE} is missing/unparsable ` +
|
|
503
|
+
'and the manual carries declarations (auth headers, variables, a description, or the ' +
|
|
504
|
+
'local-only flag) that would be lost; fix the manifest first',
|
|
505
|
+
);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
folded.push(item);
|
|
509
|
+
// Own-property check: `in` sees `constructor` and friends on the prototype,
|
|
510
|
+
// which would silently skip a legitimately named server.
|
|
511
|
+
if (!Object.prototype.hasOwnProperty.call(servers, m.name)) {
|
|
512
|
+
servers[m.name] = {
|
|
513
|
+
type: 'streamable-http',
|
|
514
|
+
url: m.url,
|
|
515
|
+
...(Object.keys(literal).length > 0 ? { headers: literal } : {}),
|
|
516
|
+
};
|
|
517
|
+
wroteMcp = true;
|
|
518
|
+
details.push(`${folderName}: ${m.name} → ${PLUGIN_MCP_FILE}`);
|
|
519
|
+
}
|
|
466
520
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
521
|
+
// The non-portable half: auth headers, variable declarations, local-only.
|
|
522
|
+
if (manifest !== null && Object.keys(extEntry).length > 0) {
|
|
523
|
+
// Normalize each level: a parseable manifest can still carry a string or
|
|
524
|
+
// array where an object belongs, and mutating that would throw mid-run.
|
|
525
|
+
if (typeof manifest.extensions !== 'object' || manifest.extensions === null || Array.isArray(manifest.extensions)) {
|
|
526
|
+
manifest.extensions = {};
|
|
527
|
+
}
|
|
528
|
+
const ext = manifest.extensions as Record<string, unknown>;
|
|
529
|
+
if (typeof ext[HEXIS_EXTENSION_NS] !== 'object' || ext[HEXIS_EXTENSION_NS] === null || Array.isArray(ext[HEXIS_EXTENSION_NS])) {
|
|
530
|
+
ext[HEXIS_EXTENSION_NS] = {};
|
|
531
|
+
}
|
|
532
|
+
const ns = ext[HEXIS_EXTENSION_NS] as Record<string, unknown>;
|
|
533
|
+
if (typeof ns.mcpServers !== 'object' || ns.mcpServers === null || Array.isArray(ns.mcpServers)) {
|
|
534
|
+
ns.mcpServers = {};
|
|
535
|
+
}
|
|
536
|
+
const extServers = ns.mcpServers as Record<string, unknown>;
|
|
537
|
+
if (!Object.prototype.hasOwnProperty.call(extServers, m.name)) {
|
|
538
|
+
extServers[m.name] = extEntry;
|
|
539
|
+
wroteManifest = true;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
476
542
|
}
|
|
477
543
|
|
|
478
|
-
|
|
544
|
+
if (wroteMcp) {
|
|
545
|
+
branch.write(`${relPlugin}/${PLUGIN_MCP_FILE}`, `${JSON.stringify(mcp, null, 2)}\n`);
|
|
546
|
+
}
|
|
547
|
+
if (wroteManifest) {
|
|
548
|
+
// May supersede the bare manifest declared above — ops apply in order, so
|
|
549
|
+
// the extended content is what lands.
|
|
550
|
+
branch.write(`${relPlugin}/${PLUGIN_MANIFEST_FILE}`, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
551
|
+
details.push(`${folderName}: wrote mcp-server declarations into ${PLUGIN_MANIFEST_FILE}`);
|
|
552
|
+
}
|
|
553
|
+
// Sources go LAST in declaration order, once everything they carried has
|
|
554
|
+
// been declared elsewhere — the same shape the in-place code used so a
|
|
555
|
+
// failure never stranded the non-portable half with no source to retry from.
|
|
556
|
+
for (const item of folded) {
|
|
557
|
+
branch.remove(item.rel);
|
|
558
|
+
details.push(`${folderName}: ${item.note} converted to an ${PLUGIN_MCP_FILE} entry`);
|
|
559
|
+
}
|
|
560
|
+
return wroteMcp || wroteManifest || folded.length > 0;
|
|
479
561
|
}
|