dsh-agent-plugins-market 0.6.2 → 0.7.0
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/README.md +117 -111
- package/README.zh.md +112 -98
- package/client/client.js +547 -540
- package/client/client.js.map +1 -1
- package/lib/application/catalog-context.js +181 -0
- package/lib/application/catalog.js +217 -1110
- package/lib/application/details.js +6 -6
- package/lib/application/install-store.js +95 -0
- package/lib/application/lsp-service.js +97 -0
- package/lib/application/mcp-service.js +246 -0
- package/lib/application/panel-resources.js +29 -12
- package/lib/application/ports.js +21 -0
- package/lib/application/project-agent-roles.js +1 -1
- package/lib/application/snapshot-cache.js +160 -0
- package/lib/application/source-store.js +521 -0
- package/lib/catalog/archive.js +34 -29
- package/lib/catalog/component-files.js +14 -6
- package/lib/catalog/fs-probes.js +43 -0
- package/lib/catalog/manifests.js +53 -92
- package/lib/catalog/native-project.js +2 -2
- package/lib/catalog/paths.js +66 -55
- package/lib/catalog/project-config.js +2 -1
- package/lib/catalog/project-hooks.js +9 -1
- package/lib/catalog/scan-resolvers.js +29 -45
- package/lib/catalog/skills-parse.js +14 -10
- package/lib/catalog/source-catalog.js +3 -2
- package/lib/catalog/suite-components.js +5 -5
- package/lib/catalog/suite-scanner.js +3 -1
- package/lib/catalog/surfaces.js +5 -26
- package/lib/catalog/validate.js +7 -11
- package/lib/contracts/market.js +1 -0
- package/lib/index.js +91 -208
- package/lib/model/types.js +26 -3
- package/lib/routes.js +45 -15
- package/lib/runtime/agent-role-names.js +70 -0
- package/lib/runtime/agent-role-router.js +116 -102
- package/lib/runtime/commands-mounts.js +9 -6
- package/lib/runtime/deadline.js +28 -0
- package/lib/runtime/feedback-tool.js +94 -43
- package/lib/runtime/hooks-mounts.js +9 -11
- package/lib/runtime/host-locale.js +10 -21
- package/lib/runtime/lsp-direct-config.js +19 -19
- package/lib/runtime/lsp-mounts.js +159 -68
- package/lib/runtime/lsp-server-state.js +4 -4
- package/lib/runtime/lsp-status.js +1 -1
- package/lib/runtime/mcp-auth-record.js +42 -0
- package/lib/runtime/mcp-backend.js +6 -5
- package/lib/runtime/mcp-client/config.js +0 -1
- package/lib/runtime/mcp-client/connection.js +32 -37
- package/lib/runtime/mcp-client/host-contract.js +10 -0
- package/lib/runtime/mcp-client/oauth.js +5 -7
- package/lib/runtime/mcp-client/plugin-identity.js +28 -0
- package/lib/runtime/mcp-client/projection.js +202 -0
- package/lib/runtime/mcp-client/tools.js +4 -189
- package/lib/runtime/mcp-config.js +29 -10
- package/lib/runtime/mcp-direct-config.js +33 -16
- package/lib/runtime/mcp-mounts.js +35 -75
- package/lib/runtime/mcp-overrides.js +5 -5
- package/lib/runtime/mcp-redaction.js +3 -2
- package/lib/runtime/mcp-status.js +7 -8
- package/lib/runtime/mount-lifecycle.js +73 -0
- package/lib/runtime/profile-seam.js +321 -0
- package/lib/runtime/project-runtime.js +11 -8
- package/lib/runtime/reconcile-scheduler.js +99 -0
- package/lib/runtime/reconciler.js +4 -0
- package/lib/runtime/server-config.js +6 -4
- package/lib/runtime/settings-namespace.js +136 -0
- package/lib/runtime/skills-provider.js +5 -3
- package/lib/runtime/source-auto-update.js +70 -0
- package/lib/{model/state.js → runtime/state-store.js} +9 -11
- package/lib/runtime/storage-migration.js +35 -18
- package/lib/runtime/subagent-catalog.js +5 -4
- package/lib/runtime/user-commands.js +2 -2
- package/lib/runtime/user-panels.js +31 -21
- package/lib/runtime/user-store.js +12 -13
- package/lib/types/application/catalog-context.d.ts +118 -0
- package/lib/types/application/catalog.d.ts +99 -304
- package/lib/types/application/install-store.d.ts +21 -0
- package/lib/types/application/lsp-service.d.ts +36 -0
- package/lib/types/application/mcp-service.d.ts +71 -0
- package/lib/types/application/panel-resources.d.ts +8 -0
- package/lib/types/application/ports.d.ts +72 -0
- package/lib/types/application/queries.d.ts +13 -26
- package/lib/types/application/snapshot-cache.d.ts +51 -0
- package/lib/types/application/source-store.d.ts +100 -0
- package/lib/types/catalog/archive.d.ts +3 -11
- package/lib/types/catalog/component-files.d.ts +4 -0
- package/lib/types/catalog/fs-probes.d.ts +10 -0
- package/lib/types/catalog/manifests.d.ts +17 -16
- package/lib/types/catalog/native-project.d.ts +2 -2
- package/lib/types/catalog/paths.d.ts +47 -15
- package/lib/types/catalog/scan-pipeline.d.ts +3 -3
- package/lib/types/catalog/scan-resolvers.d.ts +3 -9
- package/lib/types/catalog/skills-parse.d.ts +0 -7
- package/lib/types/catalog/source-catalog.d.ts +3 -3
- package/lib/types/catalog/suite-scanner.d.ts +8 -6
- package/lib/types/catalog/validate.d.ts +0 -2
- package/lib/types/contracts/lsp-status.d.ts +35 -0
- package/lib/types/contracts/market.d.ts +3 -2
- package/lib/types/contracts/mcp-status.d.ts +4 -0
- package/lib/types/model/types.d.ts +29 -5
- package/lib/types/runtime/agent-role-names.d.ts +6 -0
- package/lib/types/runtime/agent-role-router.d.ts +54 -51
- package/lib/types/runtime/deadline.d.ts +18 -0
- package/lib/types/runtime/feedback-tool.d.ts +33 -11
- package/lib/types/runtime/hooks-mounts.d.ts +1 -1
- package/lib/types/runtime/host-locale.d.ts +1 -1
- package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
- package/lib/types/runtime/lsp-mounts.d.ts +62 -19
- package/lib/types/runtime/lsp-status.d.ts +2 -1
- package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
- package/lib/types/runtime/mcp-backend.d.ts +7 -4
- package/lib/types/runtime/mcp-client/config.d.ts +0 -2
- package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
- package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
- package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
- package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
- package/lib/types/runtime/mcp-config.d.ts +25 -4
- package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
- package/lib/types/runtime/mcp-mounts.d.ts +10 -15
- package/lib/types/runtime/mcp-overrides.d.ts +1 -1
- package/lib/types/runtime/mcp-status.d.ts +1 -1
- package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
- package/lib/types/runtime/profile-seam.d.ts +64 -0
- package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
- package/lib/types/runtime/reconciler.d.ts +2 -0
- package/lib/types/runtime/settings-namespace.d.ts +76 -0
- package/lib/types/runtime/source-auto-update.d.ts +33 -0
- package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
- package/lib/types/runtime/storage-migration.d.ts +5 -3
- package/lib/types/runtime/subagent-catalog.d.ts +2 -0
- package/lib/types/runtime/user-panels.d.ts +8 -11
- package/lib/types/runtime/user-store.d.ts +2 -4
- package/package.json +48 -21
- package/schemas/README.md +1 -1
package/lib/index.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import { Catalog } from './application/catalog.js';
|
|
2
|
+
import { settlesWithin } from './runtime/deadline.js';
|
|
2
3
|
import { RuntimeReconciler } from './runtime/reconciler.js';
|
|
4
|
+
import { ReconcileScheduler } from './runtime/reconcile-scheduler.js';
|
|
5
|
+
import { MarketSettingsNamespace } from './runtime/settings-namespace.js';
|
|
6
|
+
import { deleteMcpAuthGrant } from './runtime/mcp-auth-record.js';
|
|
3
7
|
import { inspectToolRegistry } from './runtime/tool-registry-observer.js';
|
|
4
8
|
import { migratePluginStorage } from './runtime/storage-migration.js';
|
|
5
9
|
import { mountAgentRoleTool } from './runtime/agent-role-router.js';
|
|
6
10
|
import { projectAgentRoles } from './application/project-agent-roles.js';
|
|
7
11
|
import { mountProjectCommands, mountProjectMcp, mountProjectHooks, mountSuiteInstructions } from './runtime/project-runtime.js';
|
|
8
12
|
import { createPanelResources } from './application/panel-resources.js';
|
|
9
|
-
import { resolveDataRoot, resolveUserRoot } from './catalog/paths.js';
|
|
13
|
+
import { resolveAgentsRoot, resolveDataRoot, resolveUserRoot } from './catalog/paths.js';
|
|
10
14
|
import { mountSuiteRoutes } from './routes.js';
|
|
11
|
-
import { MCP_SETTINGS_NAMESPACE, MarketSettingsSchema, readMcpBackend } from './runtime/mcp-backend.js';
|
|
12
|
-
import { narrowDownloadRegion } from './runtime/regions.js';
|
|
13
15
|
import { SuiteSkillProvider } from './runtime/skills-provider.js';
|
|
14
16
|
import { loadLspServers } from './runtime/lsp-direct-config.js';
|
|
15
17
|
import { loadDisabledLspServers } from './runtime/lsp-server-state.js';
|
|
16
18
|
import { bindHostLocale, loadHostLocale } from './runtime/host-locale.js';
|
|
17
|
-
import { mountFeedbackTool } from './runtime/feedback-tool.js';
|
|
18
19
|
import { createUserPanelStores } from './runtime/user-panels.js';
|
|
20
|
+
import { SourceAutoUpdater } from './runtime/source-auto-update.js';
|
|
19
21
|
import { UserPanelSkillProvider } from './runtime/user-panels.js';
|
|
20
22
|
import { UserCommandMountRegistry } from './runtime/user-commands.js';
|
|
21
23
|
export const name = 'dsh-agent-plugins-market';
|
|
@@ -23,6 +25,7 @@ export const inject = ['skills', 'commands'];
|
|
|
23
25
|
export async function apply(ctx, config = {}) {
|
|
24
26
|
const userRoot = resolveUserRoot(config.userRoot);
|
|
25
27
|
const dataRoot = resolveDataRoot(config.dataRoot, userRoot);
|
|
28
|
+
const agentsRoot = resolveAgentsRoot();
|
|
26
29
|
const migration = await migratePluginStorage(config);
|
|
27
30
|
if (migration.conflicts.length > 0)
|
|
28
31
|
throw new Error(`Plugin storage migration conflicts (original files retained): ${migration.conflicts.join(', ')}`);
|
|
@@ -30,240 +33,126 @@ export async function apply(ctx, config = {}) {
|
|
|
30
33
|
let userPanelControl;
|
|
31
34
|
// Host runtime copy resolves from the harness `locale.preference` setting;
|
|
32
35
|
// the async settings read lands before the first session starts in practice.
|
|
33
|
-
const
|
|
36
|
+
const hostLocale = { t: bindHostLocale(undefined) };
|
|
34
37
|
void loadHostLocale().then(locale => {
|
|
35
38
|
hostLocale.t = locale.t;
|
|
36
39
|
providerControl?.invalidate();
|
|
37
40
|
userPanelControl?.invalidate();
|
|
38
41
|
});
|
|
39
|
-
const hostLocale = { t };
|
|
40
|
-
/** Settings watchers to release at teardown (backend watch + feedback switch). */
|
|
41
|
-
const settingsWatchers = [];
|
|
42
42
|
const runtime = new RuntimeReconciler(ctx, dataRoot, key => hostLocale.t(key));
|
|
43
43
|
// User panel stores (skills / commands / agent personas) and their runtime
|
|
44
44
|
// contributions: one extra skill provider plus one command mount registry.
|
|
45
|
-
const panels = createUserPanelStores(
|
|
45
|
+
const panels = createUserPanelStores(agentsRoot);
|
|
46
46
|
const userCommands = new UserCommandMountRegistry(ctx, panels.commands, key => hostLocale.t(key));
|
|
47
47
|
let disposed = false;
|
|
48
48
|
let projectCommands;
|
|
49
49
|
let projectMcp;
|
|
50
50
|
let projectHooks;
|
|
51
51
|
let suitePrompts;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const suites = await catalog.enabledUserSuites();
|
|
57
|
-
if (disposed)
|
|
58
|
-
return;
|
|
59
|
-
const diagnostics = await runtime.reconcile(suites);
|
|
60
|
-
catalog.mcpDiagnostics = diagnostics.mcp;
|
|
61
|
-
for (const diagnostic of diagnostics.mcp) {
|
|
62
|
-
ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" mcp server "${diagnostic.serverKey}": ${diagnostic.reason}`);
|
|
63
|
-
}
|
|
64
|
-
for (const diagnostic of diagnostics.lsp) {
|
|
65
|
-
ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" lsp server "${diagnostic.serverKey}": ${diagnostic.reason}`);
|
|
66
|
-
}
|
|
67
|
-
for (const diagnostic of diagnostics.commands) {
|
|
68
|
-
if (diagnostic.reason !== '')
|
|
69
|
-
ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" command "${diagnostic.command}": ${diagnostic.reason}`);
|
|
70
|
-
}
|
|
71
|
-
for (const diagnostic of diagnostics.hooks) {
|
|
72
|
-
ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" hooks: ${diagnostic.reason}`);
|
|
73
|
-
}
|
|
74
|
-
for (const error of diagnostics.errors) {
|
|
75
|
-
ctx.logger?.warn(`[dsh-agent-plugins-market] ${error.surface} reconcile failed: ${error.reason}`);
|
|
76
|
-
}
|
|
52
|
+
const scheduler = new ReconcileScheduler(ctx, runtime, {
|
|
53
|
+
enabledSuites: () => catalog.enabledUserSuites(),
|
|
54
|
+
publishMcpDiagnostics: diagnostics => {
|
|
55
|
+
catalog.mcpDiagnostics = diagnostics;
|
|
77
56
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
57
|
+
});
|
|
58
|
+
/** Reconcile the user command mounts and report each failure once. */
|
|
59
|
+
const reconcileUserCommands = async () => {
|
|
60
|
+
const diagnostics = await userCommands.reconcile().catch(() => []);
|
|
61
|
+
for (const reason of diagnostics) {
|
|
62
|
+
ctx.logger?.warn(`[dsh-agent-plugins-market] user commands: ${reason}`);
|
|
81
63
|
}
|
|
82
64
|
};
|
|
83
|
-
// Settings, credential and catalog events can arrive during a slow MCP
|
|
84
|
-
// connection. Keep one current pass and one fresh follow-up, not a queue
|
|
85
|
-
// of stale snapshots that all reconnect the same servers.
|
|
86
|
-
const reconcileMounts = () => {
|
|
87
|
-
if (disposed)
|
|
88
|
-
return Promise.resolve();
|
|
89
|
-
reconcileRequested = true;
|
|
90
|
-
reconciliation ??= (async () => {
|
|
91
|
-
try {
|
|
92
|
-
do {
|
|
93
|
-
reconcileRequested = false;
|
|
94
|
-
await reconcileOnce();
|
|
95
|
-
} while (reconcileRequested && !disposed);
|
|
96
|
-
}
|
|
97
|
-
finally {
|
|
98
|
-
reconciliation = undefined;
|
|
99
|
-
}
|
|
100
|
-
})();
|
|
101
|
-
return reconciliation;
|
|
102
|
-
};
|
|
103
65
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* and the remount lost. Pending refs are therefore kept until the next pass
|
|
109
|
-
* observes them, and bursts are collapsed into one reconcile.
|
|
66
|
+
* How long the change pipeline holds the queue on one stage. A mount that
|
|
67
|
+
* stops responding — a stdio server that never finishes its handshake, a
|
|
68
|
+
* browser authorization nobody completed — delays its own surface only; the
|
|
69
|
+
* stage keeps running while the pipeline moves on to the next change.
|
|
110
70
|
*/
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (refs.length > 0) {
|
|
121
|
-
ctx.logger?.info?.(`[dsh-agent-plugins-market] reconciling after credential update: ${refs.join(', ')}`);
|
|
71
|
+
const CHANGE_STAGE_DEADLINE_MS = 20_000;
|
|
72
|
+
/** Run one pipeline stage, logging its failure or its overrun instead of holding the pipeline. */
|
|
73
|
+
const runStage = async (stage, work) => {
|
|
74
|
+
const running = (async () => await work())();
|
|
75
|
+
void running.catch(error => {
|
|
76
|
+
ctx.logger?.warn(`[dsh-agent-plugins-market] ${stage} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
77
|
+
});
|
|
78
|
+
if (!(await settlesWithin(running, CHANGE_STAGE_DEADLINE_MS))) {
|
|
79
|
+
ctx.logger?.warn(`[dsh-agent-plugins-market] ${stage} is still running after ${CHANGE_STAGE_DEADLINE_MS}ms; the change pipeline continues without waiting for it`);
|
|
122
80
|
}
|
|
123
81
|
};
|
|
82
|
+
/**
|
|
83
|
+
* Catalog change pipeline: invalidate the derived skill and command
|
|
84
|
+
* surfaces, refresh the project mounts, then reconcile every runtime mount.
|
|
85
|
+
* `CatalogContext` schedules it in the background, so a stage that overruns
|
|
86
|
+
* costs freshness, never the mutation that asked for it.
|
|
87
|
+
*/
|
|
124
88
|
const onChanged = async () => {
|
|
125
89
|
if (disposed)
|
|
126
90
|
return;
|
|
127
91
|
providerControl?.invalidate();
|
|
128
92
|
userPanelControl?.invalidate();
|
|
129
|
-
await projectCommands?.refresh();
|
|
130
|
-
await suitePrompts?.refresh();
|
|
131
|
-
await Promise.all([projectMcp?.refresh(), projectHooks?.refresh()]);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
93
|
+
await runStage('project commands', () => projectCommands?.refresh());
|
|
94
|
+
await runStage('suite instructions', () => suitePrompts?.refresh());
|
|
95
|
+
await runStage('project MCP and hooks', () => Promise.all([projectMcp?.refresh(), projectHooks?.refresh()]));
|
|
96
|
+
await runStage('user commands', () => reconcileUserCommands());
|
|
97
|
+
await runStage('runtime mounts', () => scheduler.request());
|
|
98
|
+
};
|
|
99
|
+
// Background source updates: off until the settings switch says otherwise.
|
|
100
|
+
const autoUpdate = new SourceAutoUpdater(ctx, () => catalog.refreshSource());
|
|
101
|
+
const settings = new MarketSettingsNamespace(ctx, dataRoot, hostLocale, {
|
|
102
|
+
setScanProjectLayouts: enabled => catalog.setScanProjectLayouts(enabled),
|
|
103
|
+
refreshMcpMounts: () => {
|
|
104
|
+
void Promise.all([scheduler.request(), projectMcp?.refresh()]).catch(() => { });
|
|
105
|
+
},
|
|
106
|
+
setAutoUpdateSources: enabled => autoUpdate.setEnabled(enabled)
|
|
107
|
+
});
|
|
108
|
+
// The credentials store powers the MCP re-authorize action (dropping a grant
|
|
109
|
+
// record forces the next mount through a fresh browser authorization). Every
|
|
110
|
+
// port below is read at call time: this plugin's apply may run before the
|
|
111
|
+
// credentials, tools and settings services provision, and a snapshot taken
|
|
112
|
+
// here would be permanently undefined even after the service is live.
|
|
113
|
+
let toolsRegistry;
|
|
114
|
+
const ports = {
|
|
115
|
+
mcpToolSnapshot: () => (toolsRegistry === undefined ? [] : inspectToolRegistry(toolsRegistry)),
|
|
116
|
+
credentialsStore: {
|
|
117
|
+
deleteGrantRecord: async (serverName) => {
|
|
118
|
+
const store = ctx.get?.('credentials');
|
|
119
|
+
await deleteMcpAuthGrant(store, serverName);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
// Re-authorize must rebuild the live bridge, not just drop the grant: the
|
|
123
|
+
// registry flags the mount and the following reconcile tears it down and
|
|
124
|
+
// remounts, so the server's 401 restarts the browser authorization.
|
|
125
|
+
mcpRemount: (suiteId, serverKey) => runtime.forceMcpRemount(suiteId, serverKey),
|
|
126
|
+
mcpRemountAll: () => runtime.forceMcpRemountAll(),
|
|
127
|
+
mcpServerOwner: serverName => runtime.mcpServerOwner(serverName),
|
|
128
|
+
lspStatusSource: runtime.lsp,
|
|
129
|
+
mcpBackend: () => settings.backend(),
|
|
130
|
+
setMcpBackend: backend => settings.setBackend(backend),
|
|
131
|
+
downloadRegion: () => settings.downloadRegion()
|
|
137
132
|
};
|
|
138
|
-
|
|
139
|
-
// record forces the next mount through a fresh browser authorization).
|
|
140
|
-
// Resolved lazily at call time: this plugin's apply may run before the
|
|
141
|
-
// credentials plugin provisions, and a snapshot taken here would be
|
|
142
|
-
// permanently undefined even after the service is live.
|
|
143
|
-
const catalog = new Catalog({ userRoot, dataRoot, onChanged, ...(config.git === undefined ? {} : { git: config.git }) });
|
|
133
|
+
const catalog = new Catalog({ userRoot, dataRoot, agentsRoot, onChanged, ports, ...(config.git === undefined ? {} : { git: config.git }) });
|
|
144
134
|
await catalog.load();
|
|
145
135
|
await catalog.mergeSources(config.sources ?? []);
|
|
146
136
|
const resources = createPanelResources(catalog, panels);
|
|
147
|
-
// Mirror oauth.ts's `credentialIdFor`: the record is stored under the folded
|
|
148
|
-
// serverName, so the delete must fold the same way or it misses the record.
|
|
149
|
-
const credentialIdFor = (serverName) => serverName
|
|
150
|
-
.toLowerCase()
|
|
151
|
-
.replace(/[^a-z0-9-]+/g, '-')
|
|
152
|
-
.replace(/^-+|-+$/g, '') || 'server';
|
|
153
|
-
catalog.setCredentialsStore({
|
|
154
|
-
deleteGrantRecord: async (serverName) => {
|
|
155
|
-
const store = ctx.get?.('credentials');
|
|
156
|
-
if (store?.deleteRecord === undefined)
|
|
157
|
-
throw new Error('credentials service is not mounted');
|
|
158
|
-
await store.deleteRecord(`mcp-auth/${credentialIdFor(serverName)}`);
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
137
|
runtime.setMcpOverridesProvider(async () => catalog.allMcpOverrides(await catalog.enabledUserSuites()));
|
|
162
|
-
|
|
163
|
-
// registry flags the mount and the following reconcile tears it down and
|
|
164
|
-
// remounts, so the server's 401 restarts the browser authorization.
|
|
165
|
-
catalog.setMcpRemountHook((suiteId, serverKey) => runtime.forceMcpRemount(suiteId, serverKey), serverName => runtime.mcpServerOwner(serverName));
|
|
166
|
-
catalog.setLspStatusSource(runtime.lsp);
|
|
167
|
-
runtime.lsp.setDirectProvider(async () => (await loadLspServers(dataRoot)).servers);
|
|
138
|
+
runtime.lsp.setDirectProvider(async () => (await loadLspServers(agentsRoot)).servers);
|
|
168
139
|
runtime.lsp.setDisabledProvider(() => loadDisabledLspServers(dataRoot));
|
|
169
|
-
// The MCP backend choice is a host settings namespace (the registration is
|
|
170
|
-
// also what makes the plugin-config tab serve our card). The node half owns
|
|
171
|
-
// it: the provider derives the mount backend from the scope, the writer
|
|
172
|
-
// flips the switch, and the watcher remounts servers when it flips. The
|
|
173
|
-
// same single registration also gates the experience-feedback tool —
|
|
174
|
-
// `settings.register` throws on a duplicate namespace, so there is exactly
|
|
175
|
-
// one inject block and one `register` call.
|
|
176
|
-
//
|
|
177
|
-
// The whole callback is failure-contained on purpose: a throw here is
|
|
178
|
-
// INVISIBLE in the UI (the inject resolves asynchronously and cordis only
|
|
179
|
-
// logs), and it silently removes the namespace from settings.describe —
|
|
180
|
-
// which is exactly what hides the plugin-config card. Every failure mode
|
|
181
|
-
// lands in the logger with a loud prefix instead.
|
|
182
|
-
let feedbackDisposer;
|
|
183
|
-
ctx.inject(['settings'], settingsCtx => {
|
|
184
|
-
try {
|
|
185
|
-
ctx.logger?.info?.('[dsh-agent-plugins-market] settings inject resolved — registering namespace');
|
|
186
|
-
const settings = settingsCtx.settings;
|
|
187
|
-
const scope = settings.register(MCP_SETTINGS_NAMESPACE, MarketSettingsSchema, {
|
|
188
|
-
base: { mcpEnhanced: true, downloadRegion: 'auto', feedbackEnabled: true, scanProjectLayouts: true }
|
|
189
|
-
});
|
|
190
|
-
ctx.logger?.info?.('[dsh-agent-plugins-market] settings namespace registered — plugin-config card will serve');
|
|
191
|
-
catalog.setMcpBackendProvider(async () => (scope.get().mcpEnhanced === false ? 'host' : 'builtin'));
|
|
192
|
-
catalog.setMcpBackendWriter(async (backend) => {
|
|
193
|
-
await scope.update({ mcpEnhanced: backend !== 'host' });
|
|
194
|
-
});
|
|
195
|
-
catalog.setDownloadRegionProvider(async () => narrowDownloadRegion(scope.get().downloadRegion));
|
|
196
|
-
const syncProjectLayouts = () => {
|
|
197
|
-
void catalog.setScanProjectLayouts(scope.get().scanProjectLayouts !== false).catch(error => {
|
|
198
|
-
ctx.logger?.error?.(`[dsh-agent-plugins-market] project layout reconciliation failed: ${String(error)}`);
|
|
199
|
-
});
|
|
200
|
-
};
|
|
201
|
-
syncProjectLayouts();
|
|
202
|
-
settingsWatchers.push(scope.watch(syncProjectLayouts));
|
|
203
|
-
// One-time migration from the earlier data-root settings.json choice.
|
|
204
|
-
void readMcpBackend(dataRoot).then(backend => {
|
|
205
|
-
if (backend === 'host')
|
|
206
|
-
void scope.update({ mcpEnhanced: false }).catch(() => { });
|
|
207
|
-
});
|
|
208
|
-
let previousBackend = scope.get().mcpEnhanced !== false;
|
|
209
|
-
settingsWatchers.push(scope.watch(() => {
|
|
210
|
-
const backend = scope.get().mcpEnhanced !== false;
|
|
211
|
-
if (backend === previousBackend)
|
|
212
|
-
return;
|
|
213
|
-
previousBackend = backend;
|
|
214
|
-
void Promise.all([reconcileMounts(), projectMcp?.refresh()]).catch(() => { });
|
|
215
|
-
}));
|
|
216
|
-
// The experience-feedback model tool: gated by the namespace's
|
|
217
|
-
// `feedbackEnabled` field (default on); the switch unregisters it. The
|
|
218
|
-
// tool mount is doubly contained — its failure must never take the
|
|
219
|
-
// settings namespace (and with it the config card) down with it.
|
|
220
|
-
const syncFeedbackTool = () => {
|
|
221
|
-
try {
|
|
222
|
-
const state = scope.get();
|
|
223
|
-
const wanted = state.feedbackEnabled !== false;
|
|
224
|
-
if (wanted && feedbackDisposer === undefined) {
|
|
225
|
-
feedbackDisposer = mountFeedbackTool(ctx, dataRoot, (key, params) => hostLocale.t(key, params)) ?? undefined;
|
|
226
|
-
}
|
|
227
|
-
else if (!wanted && feedbackDisposer !== undefined) {
|
|
228
|
-
feedbackDisposer();
|
|
229
|
-
feedbackDisposer = undefined;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
catch (error) {
|
|
233
|
-
ctx.logger?.error?.(`[dsh-agent-plugins-market] feedback tool mount failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
234
|
-
}
|
|
235
|
-
};
|
|
236
|
-
syncFeedbackTool();
|
|
237
|
-
settingsWatchers.push(scope.watch(() => syncFeedbackTool()));
|
|
238
|
-
}
|
|
239
|
-
catch (error) {
|
|
240
|
-
ctx.logger?.error?.(`[dsh-agent-plugins-market] settings namespace registration failed — the plugin-config card will be hidden this boot: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
241
|
-
}
|
|
242
|
-
});
|
|
243
140
|
runtime.setMcpBackendProvider(() => catalog.mcpBackend());
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
clearTimeout(credentialFlush);
|
|
249
|
-
credentialFlush = setTimeout(flushCredentialUpdates, 150);
|
|
250
|
-
credentialFlush.unref?.();
|
|
251
|
-
}) ?? (() => { });
|
|
141
|
+
// The namespace must be registered after the catalog exists: the host
|
|
142
|
+
// resolves the inject callback synchronously when the settings service is
|
|
143
|
+
// already mounted, and the registration reads back the project-layout switch.
|
|
144
|
+
settings.mount();
|
|
252
145
|
ctx.inject(['tools'], toolsCtx => {
|
|
253
|
-
|
|
254
|
-
catalog.setMcpToolSnapshotProvider(() => inspectToolRegistry(registry));
|
|
146
|
+
toolsRegistry = toolsCtx.tools;
|
|
255
147
|
// Foreign-namespace guard: a native host MCP client (or another plugin)
|
|
256
148
|
// owning `mcp__<serverName>__` names makes the mount registry skip its
|
|
257
149
|
// own server with a clear diagnostic instead of failing mid-registration.
|
|
258
|
-
runtime.setMcpToolNamesProvider(() => inspectToolRegistry(
|
|
150
|
+
runtime.setMcpToolNamesProvider(() => inspectToolRegistry(toolsRegistry).map(tool => tool.name));
|
|
259
151
|
});
|
|
260
152
|
void Promise.resolve()
|
|
261
153
|
.then(async () => {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
ctx.logger?.warn(`[dsh-agent-plugins-market] user commands: ${reason}`);
|
|
265
|
-
}
|
|
266
|
-
await reconcileMounts();
|
|
154
|
+
await reconcileUserCommands();
|
|
155
|
+
await scheduler.request();
|
|
267
156
|
})
|
|
268
157
|
.catch(() => { });
|
|
269
158
|
ctx.skills.registerProvider(control => {
|
|
@@ -274,7 +163,7 @@ export async function apply(ctx, config = {}) {
|
|
|
274
163
|
// edit invalidates only its own catalog contribution.
|
|
275
164
|
ctx.skills.registerProvider(control => {
|
|
276
165
|
userPanelControl = control;
|
|
277
|
-
return new UserPanelSkillProvider(panels.skills
|
|
166
|
+
return new UserPanelSkillProvider(panels.skills);
|
|
278
167
|
});
|
|
279
168
|
ctx.inject(['tools', 'llm', 'subagents', 'agents'], hostCtx => {
|
|
280
169
|
hostCtx.effect(() => mountAgentRoleTool(hostCtx, async (parent) => [
|
|
@@ -310,15 +199,9 @@ export async function apply(ctx, config = {}) {
|
|
|
310
199
|
});
|
|
311
200
|
ctx.effect(() => () => {
|
|
312
201
|
disposed = true;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
credentialFlush = undefined;
|
|
317
|
-
pendingCredentialRefs.clear();
|
|
318
|
-
for (const dispose of settingsWatchers.splice(0))
|
|
319
|
-
dispose();
|
|
320
|
-
feedbackDisposer?.();
|
|
321
|
-
feedbackDisposer = undefined;
|
|
202
|
+
scheduler.dispose();
|
|
203
|
+
autoUpdate.dispose();
|
|
204
|
+
settings.dispose();
|
|
322
205
|
userCommands.disposeAll();
|
|
323
206
|
void runtime.dispose();
|
|
324
207
|
}, 'dsh-agent-plugins-market: lifecycle');
|
package/lib/model/types.js
CHANGED
|
@@ -7,11 +7,34 @@
|
|
|
7
7
|
* Codex `.codex-plugin/plugin.json`). Discovery maps every layout onto this
|
|
8
8
|
* internal shape; runtime injection consumes only the internal shape.
|
|
9
9
|
*/
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Archive URL suffixes with the payload kind each names, longest suffix first
|
|
12
|
+
* so `.tar.gz` wins over `.tar`. Source-kind inference, source-id derivation,
|
|
13
|
+
* and archive extraction all read this one table.
|
|
14
|
+
*/
|
|
15
|
+
export const ARCHIVE_SUFFIXES = [
|
|
16
|
+
['.tar.gz', 'targz'],
|
|
17
|
+
['.tgz', 'targz'],
|
|
18
|
+
['.zip', 'zip'],
|
|
19
|
+
['.tar', 'tar']
|
|
20
|
+
];
|
|
21
|
+
/** The archive suffix `url` ends with, or undefined when it names no known payload. */
|
|
22
|
+
function matchArchiveSuffix(url) {
|
|
23
|
+
const clean = url.trim().toLowerCase();
|
|
24
|
+
return ARCHIVE_SUFFIXES.find(([suffix]) => clean.endsWith(suffix));
|
|
25
|
+
}
|
|
26
|
+
/** Classify an archive URL by extension; undefined when unsupported. */
|
|
27
|
+
export function archiveFormatOf(url) {
|
|
28
|
+
return matchArchiveSuffix(url)?.[1];
|
|
29
|
+
}
|
|
12
30
|
/** Whether a URL points at a downloadable archive. */
|
|
13
31
|
export function isArchiveUrl(url) {
|
|
14
|
-
return
|
|
32
|
+
return matchArchiveSuffix(url) !== undefined;
|
|
33
|
+
}
|
|
34
|
+
/** Drop a trailing archive suffix (`plugin-0.1.zip` → `plugin-0.1`); other values pass through. */
|
|
35
|
+
export function stripArchiveSuffix(value) {
|
|
36
|
+
const suffix = matchArchiveSuffix(value)?.[0];
|
|
37
|
+
return suffix === undefined ? value : value.slice(0, -suffix.length);
|
|
15
38
|
}
|
|
16
39
|
/**
|
|
17
40
|
* Effective acquisition kind of a source: the explicit `kind` wins, the
|
package/lib/routes.js
CHANGED
|
@@ -130,7 +130,7 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
|
|
|
130
130
|
}
|
|
131
131
|
});
|
|
132
132
|
post(MARKET_ROUTES.addSource, async (body) => {
|
|
133
|
-
const url =
|
|
133
|
+
const url = textField(body['url'] ?? '', 'source url').trim();
|
|
134
134
|
if (url === '')
|
|
135
135
|
throw new Error('missing source url');
|
|
136
136
|
const local = body['local'] === true;
|
|
@@ -145,11 +145,11 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
|
|
|
145
145
|
if (!url.startsWith('~/') && url !== '~' && !isAbsolute(expanded))
|
|
146
146
|
throw new Error('local source url must be an absolute path or start with ~/');
|
|
147
147
|
}
|
|
148
|
-
const branch = body['branch'];
|
|
148
|
+
const branch = body['branch'] === undefined ? undefined : textField(body['branch'], 'branch');
|
|
149
149
|
const sha256 = parseSha256(body['sha256']);
|
|
150
150
|
const source = await manager.addSource({
|
|
151
151
|
url: local || kind === 'local' ? expandHome(url) : url,
|
|
152
|
-
...(
|
|
152
|
+
...(branch !== undefined && branch.trim() !== '' ? { branch: branch.trim() } : {}),
|
|
153
153
|
...(local ? { local: true } : {}),
|
|
154
154
|
...(kind === undefined ? {} : { kind }),
|
|
155
155
|
...(sha256 === undefined ? {} : { sha256 })
|
|
@@ -162,13 +162,13 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
|
|
|
162
162
|
throw new Error('missing source id');
|
|
163
163
|
const patch = {};
|
|
164
164
|
if (body['url'] !== undefined) {
|
|
165
|
-
const url =
|
|
165
|
+
const url = textField(body['url'], 'source url').trim();
|
|
166
166
|
if (url === '')
|
|
167
167
|
throw new Error('missing source url');
|
|
168
168
|
patch.url = url;
|
|
169
169
|
}
|
|
170
170
|
if (body['branch'] !== undefined)
|
|
171
|
-
patch.branch =
|
|
171
|
+
patch.branch = textField(body['branch'], 'branch').trim();
|
|
172
172
|
if (body['local'] !== undefined)
|
|
173
173
|
patch.local = body['local'] === true;
|
|
174
174
|
const kind = parseSourceKind(body['kind']);
|
|
@@ -197,8 +197,8 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
|
|
|
197
197
|
return {};
|
|
198
198
|
});
|
|
199
199
|
post(MARKET_ROUTES.refreshSource, async (body) => {
|
|
200
|
-
const id = body['id'];
|
|
201
|
-
await manager.refreshSource(
|
|
200
|
+
const id = body['id'] === undefined ? undefined : textField(body['id'], 'source id');
|
|
201
|
+
await manager.refreshSource(id === '' ? undefined : id);
|
|
202
202
|
return {};
|
|
203
203
|
});
|
|
204
204
|
post(MARKET_ROUTES.install, async (body) => {
|
|
@@ -299,12 +299,24 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
|
|
|
299
299
|
return {};
|
|
300
300
|
});
|
|
301
301
|
post(`${MARKET_ROUTES.lspServers}/enabled`, async (body) => {
|
|
302
|
-
const id =
|
|
302
|
+
const id = textField(body['id'] ?? '', 'LSP server id');
|
|
303
303
|
if (id === '')
|
|
304
304
|
throw new Error('missing LSP server id');
|
|
305
|
-
|
|
305
|
+
const enabled = body['enabled'];
|
|
306
|
+
if (typeof enabled !== 'boolean')
|
|
307
|
+
throw new Error('missing boolean enabled');
|
|
308
|
+
await manager.setLspServerEnabled(id, enabled);
|
|
306
309
|
return {};
|
|
307
310
|
});
|
|
311
|
+
// Upgrade repair: drop the hand-written profile layer an older release told
|
|
312
|
+
// the user to add for LSP. It edits a file the user owns, so the profile
|
|
313
|
+
// name is matched against what is on disk and nothing else is touched.
|
|
314
|
+
post(MARKET_ROUTES.migrateLspSeam, async (body) => {
|
|
315
|
+
const profile = textField(body['profile'] ?? '', 'profile name').trim();
|
|
316
|
+
if (profile === '')
|
|
317
|
+
throw new Error('missing profile name');
|
|
318
|
+
return { migration: await manager.migrateLegacyLspSeam(profile) };
|
|
319
|
+
});
|
|
308
320
|
// User panel CRUD (skills / commands / agent personas). The host web
|
|
309
321
|
// server matches exact pathnames, so the entry name rides the `name` query
|
|
310
322
|
// parameter (GET read, POST replace, POST .../delete remove). The routes
|
|
@@ -327,8 +339,8 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
|
|
|
327
339
|
sendJson(response, 200, { entry });
|
|
328
340
|
});
|
|
329
341
|
post(`${userPanelRoute(kind)}/create`, async (body) => {
|
|
330
|
-
const name =
|
|
331
|
-
const text =
|
|
342
|
+
const name = textField(body['name'] ?? '', 'entry name').trim();
|
|
343
|
+
const text = textField(body['text'] ?? '', 'entry text');
|
|
332
344
|
if (name === '')
|
|
333
345
|
throw new Error('missing entry name');
|
|
334
346
|
const entry = await storeOf(kind).create(name, text);
|
|
@@ -337,7 +349,7 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
|
|
|
337
349
|
});
|
|
338
350
|
post(`${userPanelRoute(kind)}/update`, async (body, request) => {
|
|
339
351
|
const name = queryOf(request).get('name') ?? '';
|
|
340
|
-
const text =
|
|
352
|
+
const text = textField(body['text'] ?? '', 'entry text');
|
|
341
353
|
if (name === '')
|
|
342
354
|
throw new Error('missing entry name');
|
|
343
355
|
await storeOf(kind).update(name, text);
|
|
@@ -345,7 +357,7 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
|
|
|
345
357
|
return {};
|
|
346
358
|
});
|
|
347
359
|
post(`${userPanelRoute(kind)}/delete`, async (body) => {
|
|
348
|
-
const name =
|
|
360
|
+
const name = textField(body['name'] ?? '', 'entry name');
|
|
349
361
|
if (name === '')
|
|
350
362
|
throw new Error('missing entry name');
|
|
351
363
|
await storeOf(kind).remove(name);
|
|
@@ -359,6 +371,23 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
|
|
|
359
371
|
dispose();
|
|
360
372
|
};
|
|
361
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Read a request field this route documents as a string. Bodies are arbitrary
|
|
376
|
+
* JSON, so the field may arrive as any value; anything but a string is
|
|
377
|
+
* rejected rather than coerced, because `String({})` is `"[object Object]"` —
|
|
378
|
+
* a non-empty value that every check downstream would accept and store as a
|
|
379
|
+
* source url, a branch name, or an entry name. Empty stays empty so each
|
|
380
|
+
* caller keeps owning whether that means "missing".
|
|
381
|
+
*/
|
|
382
|
+
function textField(value, label) {
|
|
383
|
+
if (typeof value !== 'string')
|
|
384
|
+
throw new Error(`${label} must be a string`);
|
|
385
|
+
return value;
|
|
386
|
+
}
|
|
387
|
+
/** Render an untrusted field for a diagnostic message. Never a value reader — see `textField`. */
|
|
388
|
+
function describe(value) {
|
|
389
|
+
return String(value);
|
|
390
|
+
}
|
|
362
391
|
function parseTarget(body) {
|
|
363
392
|
const sourceId = body['sourceId'];
|
|
364
393
|
const suiteId = body['suiteId'];
|
|
@@ -374,13 +403,13 @@ function parseSourceKind(raw) {
|
|
|
374
403
|
return undefined;
|
|
375
404
|
if (raw === 'git' || raw === 'local' || raw === 'archive')
|
|
376
405
|
return raw;
|
|
377
|
-
throw new Error(`invalid source kind "${
|
|
406
|
+
throw new Error(`invalid source kind "${describe(raw)}"`);
|
|
378
407
|
}
|
|
379
408
|
/** Parse an optional SHA-256 hex digest; rejects malformed values. */
|
|
380
409
|
function parseSha256(raw) {
|
|
381
410
|
if (raw === undefined || raw === null || raw === '')
|
|
382
411
|
return undefined;
|
|
383
|
-
const value =
|
|
412
|
+
const value = textField(raw, 'sha256').trim().toLowerCase();
|
|
384
413
|
if (!/^[0-9a-f]{64}$/.test(value))
|
|
385
414
|
throw new Error('sha256 must be a 64-character hex digest');
|
|
386
415
|
return value;
|
|
@@ -396,6 +425,7 @@ function sameOrigin(request) {
|
|
|
396
425
|
return false;
|
|
397
426
|
}
|
|
398
427
|
}
|
|
428
|
+
/** Parses the request body; `undefined` for an oversized, unparsable, or failed read. */
|
|
399
429
|
function readJsonBody(request) {
|
|
400
430
|
return new Promise(resolve => {
|
|
401
431
|
let size = 0;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* The `[source, suite, kind, name]` identity the host encodes into a discovery
|
|
4
|
+
* name, or `undefined` for a name that is not one — a user-authored role name
|
|
5
|
+
* is a plain string, and anything else carries no identity.
|
|
6
|
+
*/
|
|
7
|
+
function roleIdentity(name) {
|
|
8
|
+
let parsed;
|
|
9
|
+
try {
|
|
10
|
+
parsed = JSON.parse(name);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
if (!Array.isArray(parsed))
|
|
16
|
+
return undefined;
|
|
17
|
+
const parts = parsed;
|
|
18
|
+
if (parts.length !== 4)
|
|
19
|
+
return undefined;
|
|
20
|
+
const [source, suite, kind, id] = parts;
|
|
21
|
+
if (typeof source !== 'string' || typeof suite !== 'string' || typeof kind !== 'string' || typeof id !== 'string')
|
|
22
|
+
return undefined;
|
|
23
|
+
return [source, suite, kind, id];
|
|
24
|
+
}
|
|
25
|
+
/** Internal IDs stay opaque to callers; only colliding resource names need qualification. */
|
|
26
|
+
export function namedAgentRoles(entries) {
|
|
27
|
+
const roles = entries.map(entry => {
|
|
28
|
+
const identity = roleIdentity(entry.name);
|
|
29
|
+
const base = identity === undefined ? entry.name : identity[3].replace(/\.agent$/, '');
|
|
30
|
+
return { entry, base, suite: identity?.[1] ?? 'user', source: identity?.[0] ?? 'user', level: 0 };
|
|
31
|
+
});
|
|
32
|
+
const baseCounts = new Map();
|
|
33
|
+
for (const role of roles)
|
|
34
|
+
baseCounts.set(role.base, (baseCounts.get(role.base) ?? 0) + 1);
|
|
35
|
+
for (const role of roles)
|
|
36
|
+
if ((baseCounts.get(role.base) ?? 0) > 1)
|
|
37
|
+
role.level = 1;
|
|
38
|
+
const candidate = (role) => {
|
|
39
|
+
if (role.level === 0)
|
|
40
|
+
return role.base;
|
|
41
|
+
const qualified = `${role.suite}/${role.base}`;
|
|
42
|
+
const fullyQualified = `${role.source}/${role.suite}/${role.base}`;
|
|
43
|
+
if (role.level === 1)
|
|
44
|
+
return qualified;
|
|
45
|
+
if (role.level === 2)
|
|
46
|
+
return fullyQualified;
|
|
47
|
+
return `${fullyQualified}~${createHash('sha256').update(role.entry.name).digest('hex')}`;
|
|
48
|
+
};
|
|
49
|
+
// Recheck all names: a qualified collision may overlap another role's literal name.
|
|
50
|
+
for (;;) {
|
|
51
|
+
const groups = new Map();
|
|
52
|
+
for (const role of roles) {
|
|
53
|
+
const key = candidate(role);
|
|
54
|
+
groups.set(key, [...(groups.get(key) ?? []), role]);
|
|
55
|
+
}
|
|
56
|
+
let changed = false;
|
|
57
|
+
for (const group of groups.values()) {
|
|
58
|
+
if (group.length < 2)
|
|
59
|
+
continue;
|
|
60
|
+
for (const role of group) {
|
|
61
|
+
if (role.level < 3) {
|
|
62
|
+
role.level++;
|
|
63
|
+
changed = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!changed)
|
|
68
|
+
return roles.map(role => ({ ...role.entry, callName: candidate(role) }));
|
|
69
|
+
}
|
|
70
|
+
}
|