@mearl/setup 2.22.0 → 3.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +59 -59
- package/dist/browserExtension.d.ts +2 -2
- package/dist/browserExtension.js +25 -19
- package/dist/cli.d.ts +8 -3
- package/dist/cli.js +74 -17
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/lifecycle.d.ts +11 -4
- package/dist/lifecycle.js +48 -15
- package/dist/managedPlugins.d.ts +21 -0
- package/dist/managedPlugins.js +76 -0
- package/dist/npmRegistry.d.ts +1 -1
- package/dist/npmRegistry.js +4 -16
- package/dist/plugin.d.ts +13 -3
- package/dist/plugin.js +106 -44
- package/dist/preset.d.ts +94 -0
- package/dist/preset.js +275 -0
- package/dist/presetBrowserTransfer.d.ts +11 -0
- package/dist/presetBrowserTransfer.js +72 -0
- package/dist/presetInstallationLease.d.ts +6 -0
- package/dist/presetInstallationLease.js +66 -0
- package/dist/presetStore.d.ts +48 -0
- package/dist/presetStore.js +252 -0
- package/dist/presetUpgrade.d.ts +13 -0
- package/dist/presetUpgrade.js +42 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +24 -2
- package/dist/runtimeCleanup.js +1 -8
- package/dist/setupVersion.d.ts +2 -0
- package/dist/setupVersion.js +4 -0
- package/dist/skillSources.d.ts +46 -0
- package/dist/skillSources.js +276 -0
- package/dist/skills.d.ts +45 -0
- package/dist/skills.js +213 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +6 -0
- package/package.json +27 -10
- package/dist/aliSkills.d.ts +0 -32
- package/dist/aliSkills.js +0 -212
- package/dist/internalPlugins.d.ts +0 -26
- package/dist/internalPlugins.js +0 -197
package/dist/plugin.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readlinkSync, realpathSync, rmdirSync, statSync, } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
-
import { assertPluginName, findPluginDirectoryEntry,
|
|
4
|
+
import { assertPluginName, findPluginDirectoryEntry, readOfficialPluginCatalog, findPluginEntry, findPluginRegistration, listPluginNames, loadPlugin, OFFICIAL_PLUGIN_MANAGER, registerPlugin, resolvePluginEntry, unregisterPlugin, } from '@mearl/client/plugin';
|
|
5
|
+
import { claimPresetInstallation } from './presetInstallationLease.js';
|
|
5
6
|
import { ensurePluginRuntime } from './pluginRuntime.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
7
|
+
import { captureSkillsCli, parseInstalledSkills, stripAnsi } from './skills.js';
|
|
8
|
+
import { runCommand, withOwnedCommands, } from '@mearl/daemon-core';
|
|
8
9
|
const registrationContext = {
|
|
9
10
|
options: {},
|
|
10
11
|
invoke: async () => {
|
|
@@ -47,22 +48,35 @@ export async function setupPlugin(target, requestedName) {
|
|
|
47
48
|
};
|
|
48
49
|
}
|
|
49
50
|
export async function ensureOfficialPlugin(name, runner = runCommand) {
|
|
50
|
-
const
|
|
51
|
+
const accepted = readOfficialPluginCatalog();
|
|
52
|
+
const definition = accepted?.plugins[name];
|
|
51
53
|
if (!definition)
|
|
52
54
|
throw new Error(`未知官方插件: ${name}`);
|
|
53
55
|
if (findPluginRegistration(name))
|
|
54
56
|
return 'existing';
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
const lease = claimPresetInstallation();
|
|
58
|
+
try {
|
|
59
|
+
return await withOwnedCommands(lease.lifecycle, async () => {
|
|
60
|
+
const code = await installPlugins([...definition.installArgs], runner, {
|
|
61
|
+
registrationManagedBy: OFFICIAL_PLUGIN_MANAGER,
|
|
62
|
+
selection: accepted.selection,
|
|
63
|
+
...(accepted.selection ? { expectedSkill: definition.skill, expectedPluginName: name } : {}),
|
|
64
|
+
confirm: () => { if (JSON.stringify(readOfficialPluginCatalog()) !== JSON.stringify(accepted))
|
|
65
|
+
throw new Error('PLUGIN_CATALOG_SELECTION_CHANGED'); },
|
|
66
|
+
});
|
|
67
|
+
if (code !== 0)
|
|
68
|
+
throw new Error(`官方插件 ${name} 安装失败 (exit ${code})`);
|
|
69
|
+
const registration = findPluginRegistration(name);
|
|
70
|
+
if (!registration)
|
|
71
|
+
throw new Error(`官方插件 ${name} 安装完成后仍未注册`);
|
|
72
|
+
return 'installed';
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
lease.release();
|
|
77
|
+
}
|
|
64
78
|
}
|
|
65
|
-
function
|
|
79
|
+
function globalSkill(entry) {
|
|
66
80
|
const globalSkillsDirectory = join(homedir(), '.agents', 'skills');
|
|
67
81
|
const relativeEntry = relative(globalSkillsDirectory, resolve(entry));
|
|
68
82
|
if (!isAbsolute(relativeEntry) &&
|
|
@@ -96,8 +110,8 @@ function globalAliSkill(entry) {
|
|
|
96
110
|
}
|
|
97
111
|
return undefined;
|
|
98
112
|
}
|
|
99
|
-
function
|
|
100
|
-
if (managedBy
|
|
113
|
+
function projectSkill(entry, managedBy) {
|
|
114
|
+
if (!managedBy)
|
|
101
115
|
return undefined;
|
|
102
116
|
const absolute = resolve(entry);
|
|
103
117
|
const marker = `${sep}.agents${sep}skills${sep}`;
|
|
@@ -117,13 +131,13 @@ function projectAliSkill(entry, managedBy) {
|
|
|
117
131
|
return undefined;
|
|
118
132
|
return { directory: join(cwd, '.agents', 'skills', name), name, cwd };
|
|
119
133
|
}
|
|
120
|
-
export async function uninstallPlugin(name, runner = runCommand) {
|
|
134
|
+
export async function uninstallPlugin(name, runner = runCommand, options = {}) {
|
|
121
135
|
assertPluginName(name);
|
|
122
136
|
const entry = findPluginEntry(name);
|
|
123
137
|
if (!entry)
|
|
124
138
|
return { removed: false, unregisteredNames: [] };
|
|
125
139
|
const entryMissing = !existsSync(entry);
|
|
126
|
-
const managedSkill =
|
|
140
|
+
const managedSkill = projectSkill(entry, findPluginRegistration(name)?.managedBy) ?? globalSkill(entry);
|
|
127
141
|
if (!managedSkill) {
|
|
128
142
|
unregisterPlugin(name);
|
|
129
143
|
return {
|
|
@@ -132,22 +146,26 @@ export async function uninstallPlugin(name, runner = runCommand) {
|
|
|
132
146
|
unregisteredNames: [name],
|
|
133
147
|
};
|
|
134
148
|
}
|
|
135
|
-
const invocation = resolveAliSkillsInvocation();
|
|
136
|
-
if (!invocation)
|
|
137
|
-
throw new Error('未找到 ali-skills 或 npx,插件注册已保留。');
|
|
138
149
|
const { directory: skillDirectory, name: skillName } = managedSkill;
|
|
139
150
|
const unregisteredNames = listPluginNames().filter(pluginName => {
|
|
140
151
|
try {
|
|
141
152
|
const candidate = findPluginEntry(pluginName);
|
|
142
153
|
if (!candidate)
|
|
143
154
|
return false;
|
|
144
|
-
const candidateSkill =
|
|
155
|
+
const candidateSkill = projectSkill(candidate, 'skills') ?? globalSkill(candidate);
|
|
145
156
|
return candidateSkill?.directory === skillDirectory;
|
|
146
157
|
}
|
|
147
158
|
catch {
|
|
148
159
|
return false;
|
|
149
160
|
}
|
|
150
161
|
});
|
|
162
|
+
if (options.preserveRegistrationsOutsideManager && unregisteredNames.some(pluginName => findPluginRegistration(pluginName)?.managedBy !== options.preserveRegistrationsOutsideManager)) {
|
|
163
|
+
unregisterPlugin(name);
|
|
164
|
+
return { removed: true, unregisteredNames: [name] };
|
|
165
|
+
}
|
|
166
|
+
const cli = await captureSkillsCli({ env: options.env, runCommand: runner });
|
|
167
|
+
if (!cli)
|
|
168
|
+
throw new Error('未找到 skills 或 npx,插件注册已保留。');
|
|
151
169
|
let placeholderCreated = false;
|
|
152
170
|
if (!existsSync(skillDirectory)) {
|
|
153
171
|
mkdirSync(dirname(skillDirectory), { recursive: true, mode: 0o700 });
|
|
@@ -162,16 +180,15 @@ export async function uninstallPlugin(name, runner = runCommand) {
|
|
|
162
180
|
}
|
|
163
181
|
let removed;
|
|
164
182
|
try {
|
|
165
|
-
removed = await
|
|
166
|
-
...invocation.argsPrefix,
|
|
183
|
+
removed = await cli.run([
|
|
167
184
|
'remove',
|
|
168
185
|
skillName,
|
|
169
186
|
...(managedSkill.cwd ? [] : ['--global']),
|
|
170
187
|
'--yes',
|
|
171
|
-
], {
|
|
188
|
+
], { tee: true, ...(managedSkill.cwd ? { cwd: managedSkill.cwd } : {}) });
|
|
172
189
|
}
|
|
173
190
|
catch (error) {
|
|
174
|
-
throw new Error(`
|
|
191
|
+
throw new Error(`skills remove ${skillName} 执行失败:${error instanceof Error ? error.message : String(error)};插件注册已保留。`, { cause: error });
|
|
175
192
|
}
|
|
176
193
|
finally {
|
|
177
194
|
if (placeholderCreated) {
|
|
@@ -179,12 +196,15 @@ export async function uninstallPlugin(name, runner = runCommand) {
|
|
|
179
196
|
rmdirSync(skillDirectory);
|
|
180
197
|
}
|
|
181
198
|
catch {
|
|
182
|
-
/*
|
|
199
|
+
/* skills 已移除,或目录中出现了新内容。 */
|
|
183
200
|
}
|
|
184
201
|
}
|
|
185
202
|
}
|
|
186
203
|
if (removed.code !== 0) {
|
|
187
|
-
throw new Error(`
|
|
204
|
+
throw new Error(`skills remove ${skillName} 失败(exit ${removed.code});插件注册已保留。`);
|
|
205
|
+
}
|
|
206
|
+
if (/Failed to remove\s+[1-9]\d*\s+skills?/i.test(stripAnsi(`${removed.stdout}\n${removed.stderr}`)) || existsSync(skillDirectory)) {
|
|
207
|
+
throw new Error(`skills remove ${skillName} 未完成;插件注册已保留。`);
|
|
188
208
|
}
|
|
189
209
|
for (const pluginName of unregisteredNames)
|
|
190
210
|
unregisterPlugin(pluginName);
|
|
@@ -195,8 +215,8 @@ export async function uninstallPlugin(name, runner = runCommand) {
|
|
|
195
215
|
unregisteredNames,
|
|
196
216
|
};
|
|
197
217
|
}
|
|
198
|
-
/**
|
|
199
|
-
export function parseInstalledPluginPaths(output) {
|
|
218
|
+
/** Skills 的 Installed 区块列出成功路径;计数校验避免输出变化导致漏注册。 */
|
|
219
|
+
export function parseInstalledPluginPaths(output, listed = []) {
|
|
200
220
|
const paths = [];
|
|
201
221
|
let expected = 0;
|
|
202
222
|
let inResult = false;
|
|
@@ -217,14 +237,19 @@ export function parseInstalledPluginPaths(output) {
|
|
|
217
237
|
if (!item)
|
|
218
238
|
continue;
|
|
219
239
|
const value = item[1].trim().replaceAll('\\', '/');
|
|
220
|
-
const
|
|
240
|
+
const copied = value.match(/^(.+?) \(copied\)$/);
|
|
241
|
+
const matches = copied ? listed.filter(skill => skill.scope === 'global' && skill.name === copied[1]) : [];
|
|
242
|
+
if (copied && matches.length !== 1)
|
|
243
|
+
throw new Error('无法从 Skills JSON 清单确认复制的 Skill 安装路径。');
|
|
244
|
+
const installedPath = copied ? matches[0].path : value;
|
|
245
|
+
const skillPath = resolve(installedPath.startsWith('~/') ? join(homedir(), installedPath.slice(2)) : installedPath);
|
|
221
246
|
if (dirname(skillPath) !== join(homedir(), '.agents', 'skills')) {
|
|
222
247
|
throw new Error(`无法识别全局 skill 安装路径: ${item[1]}。请使用 setup plugin [name] <path> 注册。`);
|
|
223
248
|
}
|
|
224
249
|
paths.push(skillPath);
|
|
225
250
|
}
|
|
226
251
|
if (paths.length !== expected)
|
|
227
|
-
throw new Error('
|
|
252
|
+
throw new Error('Skills 安装结果格式无法识别,请使用 setup plugin [name] <path> 注册。');
|
|
228
253
|
return [...new Set(paths)];
|
|
229
254
|
}
|
|
230
255
|
function permitsEmptyInstallResult(args, output) {
|
|
@@ -234,24 +259,51 @@ function permitsEmptyInstallResult(args, output) {
|
|
|
234
259
|
}
|
|
235
260
|
export async function installPlugins(args, runner = runCommand, options = {}) {
|
|
236
261
|
if (args.includes('--')) {
|
|
237
|
-
throw new Error('plugin install 不支持 --
|
|
262
|
+
throw new Error('plugin install 不支持 -- 参数分隔符,以确保全局安装选项生效。');
|
|
238
263
|
}
|
|
239
|
-
const
|
|
240
|
-
if (!
|
|
241
|
-
throw new Error('未找到 npx,请检查 Node.js 安装。');
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
264
|
+
const cli = await captureSkillsCli({ env: options.env, runCommand: runner });
|
|
265
|
+
if (!cli)
|
|
266
|
+
throw new Error('未找到 skills 或 npx,请检查 Node.js 安装。');
|
|
267
|
+
if (options.selection && (cli.selection?.path !== options.selection.path || cli.selection.sha256 !== options.selection.sha256))
|
|
268
|
+
throw new Error('PLUGIN_CATALOG_SELECTION_CHANGED');
|
|
269
|
+
const declaration = options.expectedSkill ? cli.managedSkills[options.expectedSkill] : undefined;
|
|
270
|
+
if (options.expectedSkill && (!declaration?.source || declaration.source !== args[0]))
|
|
271
|
+
throw new Error('PLUGIN_CATALOG_SKILL_UNDECLARED');
|
|
272
|
+
const verifySkill = (registered = false) => {
|
|
273
|
+
if (!options.expectedSkill || !declaration)
|
|
274
|
+
return;
|
|
275
|
+
const status = cli.inspectGlobal(declaration.source, options.expectedSkill, declaration.version);
|
|
276
|
+
if (status.state !== 'ready' && (registered || status.state !== 'registration'))
|
|
277
|
+
throw new Error(`${status.reason}: ${options.expectedSkill}`);
|
|
278
|
+
};
|
|
279
|
+
if (options.expectedSkill && declaration) {
|
|
280
|
+
const status = cli.inspectGlobal(declaration.source, options.expectedSkill, declaration.version);
|
|
281
|
+
if (status.state === 'conflict')
|
|
282
|
+
throw new Error(`${status.reason}: ${options.expectedSkill}`);
|
|
283
|
+
}
|
|
284
|
+
await options.confirm?.();
|
|
285
|
+
const installed = await cli.run(['add', ...args, '--global'], { tee: true });
|
|
247
286
|
if (installed.code !== 0)
|
|
248
287
|
return installed.code;
|
|
288
|
+
await options.confirm?.();
|
|
249
289
|
const output = `${installed.stdout ?? ''}\n${installed.stderr ?? ''}`;
|
|
250
|
-
|
|
290
|
+
let listed = [];
|
|
291
|
+
if (/✓[^\n]*\(copied\)/.test(stripAnsi(output))) {
|
|
292
|
+
const result = await cli.run(['list', '--global', '--json'], { capture: true });
|
|
293
|
+
if (result.code !== 0)
|
|
294
|
+
throw new Error('无法读取 Skills JSON 清单;插件尚未注册。');
|
|
295
|
+
listed = parseInstalledSkills(result.stdout);
|
|
296
|
+
}
|
|
297
|
+
const paths = parseInstalledPluginPaths(output, listed);
|
|
298
|
+
if (options.expectedSkill) {
|
|
299
|
+
if (paths.length !== 1 || paths[0] !== join(homedir(), '.agents', 'skills', options.expectedSkill))
|
|
300
|
+
throw new Error('PLUGIN_CATALOG_SKILL_RESULT_MISMATCH');
|
|
301
|
+
verifySkill();
|
|
302
|
+
}
|
|
251
303
|
if (paths.length === 0) {
|
|
252
304
|
if (permitsEmptyInstallResult(args, output))
|
|
253
305
|
return 0;
|
|
254
|
-
throw new Error('
|
|
306
|
+
throw new Error('Skills 安装成功但无法识别安装结果,请使用 setup plugin [name] <path> 注册。');
|
|
255
307
|
}
|
|
256
308
|
const plugins = new Map();
|
|
257
309
|
for (const skillPath of paths) {
|
|
@@ -263,6 +315,8 @@ export async function installPlugins(args, runner = runCommand, options = {}) {
|
|
|
263
315
|
const plugin = await loadPlugin(entry, registrationContext);
|
|
264
316
|
const name = plugin.name || basename(skillPath);
|
|
265
317
|
assertPluginName(name);
|
|
318
|
+
if (options.expectedPluginName && name !== options.expectedPluginName)
|
|
319
|
+
throw new Error('PLUGIN_CATALOG_ALIAS_MISMATCH');
|
|
266
320
|
if (plugins.has(name))
|
|
267
321
|
throw new Error(`多个 skill 使用插件名称 ${name},请调整入口的 name metadata。`);
|
|
268
322
|
plugins.set(name, {
|
|
@@ -280,14 +334,22 @@ export async function installPlugins(args, runner = runCommand, options = {}) {
|
|
|
280
334
|
}
|
|
281
335
|
await ensurePluginRuntime(runner);
|
|
282
336
|
for (const [name, { entry, description, toolbox, appProfiles, profileSources, hasPublicCommands },] of plugins) {
|
|
337
|
+
await options.confirm?.();
|
|
338
|
+
verifySkill();
|
|
339
|
+
const existing = findPluginRegistration(name);
|
|
340
|
+
if (options.registrationManagedBy && existing && existing.managedBy !== options.registrationManagedBy) {
|
|
341
|
+
console.error(`Plugin ${name}: existing user registration kept.`);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
283
344
|
registerPlugin(name, entry, description, {
|
|
284
345
|
hasPublicCommands,
|
|
285
|
-
managedBy: options.registrationManagedBy,
|
|
346
|
+
managedBy: options.registrationManagedBy ?? (cli.id === 'skills' ? undefined : cli.id),
|
|
286
347
|
...(toolbox ? { toolbox } : {}),
|
|
287
348
|
appProfiles,
|
|
288
349
|
profileSources,
|
|
289
350
|
});
|
|
290
351
|
console.error(`Plugin ${name} registered: ${entry}${hasPublicCommands ? `\nRun: mearl ${name} --help` : toolbox ? '\nOpen in Mearl toolbox.' : ''}`);
|
|
291
352
|
}
|
|
353
|
+
verifySkill(true);
|
|
292
354
|
return 0;
|
|
293
355
|
}
|
package/dist/preset.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { invoke } from '@mearl/client/local';
|
|
2
|
+
import { type JsonObject } from '@mearl/preset';
|
|
3
|
+
import { type BundleLoader } from '@mearl/preset/bundles';
|
|
4
|
+
import { PresetInstallationStore } from './presetStore.js';
|
|
5
|
+
export { claimPresetInstallation } from './presetInstallationLease.js';
|
|
6
|
+
export { PresetInstallationStore, presetSelectionRevision } from './presetStore.js';
|
|
7
|
+
export type { PresetInstallReceipt } from './presetStore.js';
|
|
8
|
+
export { PresetBrowserTransfer } from './presetBrowserTransfer.js';
|
|
9
|
+
export interface PresetHostTarget {
|
|
10
|
+
id: string;
|
|
11
|
+
socketPath?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface PresetHostStatus {
|
|
14
|
+
id: string;
|
|
15
|
+
state: 'active' | 'pending' | 'rejected';
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface PresetBrowserHostStatus extends PresetHostStatus {
|
|
19
|
+
settingsReady: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface PresetComponentStatus {
|
|
22
|
+
kind: 'skills' | 'providers';
|
|
23
|
+
id: string;
|
|
24
|
+
state: 'ready' | 'pending' | 'conflict';
|
|
25
|
+
reason?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface PresetComponentHostStatus {
|
|
28
|
+
installationRevision: string;
|
|
29
|
+
nodeRevision: string;
|
|
30
|
+
components: PresetComponentStatus[];
|
|
31
|
+
catalogReady?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface PresetInstallStatus {
|
|
34
|
+
revision: string | null;
|
|
35
|
+
id?: string;
|
|
36
|
+
version?: string;
|
|
37
|
+
ready: boolean;
|
|
38
|
+
settings?: JsonObject;
|
|
39
|
+
overrides?: JsonObject;
|
|
40
|
+
nodeRevision?: string;
|
|
41
|
+
browserRevision?: string;
|
|
42
|
+
browserSyncRequired?: boolean;
|
|
43
|
+
localComponentCount?: number;
|
|
44
|
+
hosts: PresetHostStatus[];
|
|
45
|
+
browsers: PresetBrowserHostStatus[];
|
|
46
|
+
pending: Array<{
|
|
47
|
+
kind: 'preset' | 'packages' | 'skills' | 'providers';
|
|
48
|
+
id: string;
|
|
49
|
+
runtime: 'browser' | 'node';
|
|
50
|
+
reason: string;
|
|
51
|
+
}>;
|
|
52
|
+
warnings: string[];
|
|
53
|
+
}
|
|
54
|
+
export interface PresetInstallerOptions {
|
|
55
|
+
directory?: string;
|
|
56
|
+
mearlApiVersion?: string;
|
|
57
|
+
load?: BundleLoader;
|
|
58
|
+
hosts?: () => PresetHostTarget[];
|
|
59
|
+
invoke?: typeof invoke;
|
|
60
|
+
}
|
|
61
|
+
export interface PresetApplyOptions {
|
|
62
|
+
overrides?: JsonObject;
|
|
63
|
+
nodeConfig?: Record<string, JsonObject>;
|
|
64
|
+
signal?: AbortSignal;
|
|
65
|
+
expectedRevision?: string | null;
|
|
66
|
+
expectedId?: string;
|
|
67
|
+
confirm?: () => Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
export interface StagedPresetInstallation {
|
|
70
|
+
revision: string;
|
|
71
|
+
activate: () => Promise<PresetInstallStatus>;
|
|
72
|
+
}
|
|
73
|
+
/** The caller chooses the source; this loader adds no identity or credential discovery. */
|
|
74
|
+
export declare function createNodePresetLoader(): BundleLoader;
|
|
75
|
+
/** Installation and live acknowledgements are distinct; browser/external installs remain pending. */
|
|
76
|
+
export declare class PresetInstaller {
|
|
77
|
+
readonly store: PresetInstallationStore;
|
|
78
|
+
private readonly options;
|
|
79
|
+
constructor(options?: PresetInstallerOptions);
|
|
80
|
+
private invokeHost;
|
|
81
|
+
private hostStatus;
|
|
82
|
+
private browserStatus;
|
|
83
|
+
private componentStatus;
|
|
84
|
+
status(): Promise<PresetInstallStatus>;
|
|
85
|
+
private synchronize;
|
|
86
|
+
private stageSource;
|
|
87
|
+
/** Save exact resources and desired selection; activate once after runtime setup, then retry via resume. */
|
|
88
|
+
stage(source: string, options?: PresetApplyOptions): Promise<StagedPresetInstallation>;
|
|
89
|
+
apply(source: string, options?: PresetApplyOptions): Promise<PresetInstallStatus>;
|
|
90
|
+
update(): Promise<PresetInstallStatus>;
|
|
91
|
+
resume(expectedRevision: string): Promise<PresetInstallStatus>;
|
|
92
|
+
configure(value: JsonObject): Promise<PresetInstallStatus>;
|
|
93
|
+
}
|
|
94
|
+
export declare function runPresetCommand(command: 'apply' | 'update' | 'status', source?: string): Promise<PresetInstallStatus>;
|
package/dist/preset.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { lstat, open } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import { invoke, invokeLocalHost, listLiveBrowsers, SOCKET_PATH } from '@mearl/client/local';
|
|
6
|
+
import { mergePresetDefaults } from '@mearl/preset';
|
|
7
|
+
import { BundleError, createBundleFetchLoader, preparePreset } from '@mearl/preset/bundles';
|
|
8
|
+
import { PresetInstallationStore, presetSelectionRevision } from './presetStore.js';
|
|
9
|
+
export { claimPresetInstallation } from './presetInstallationLease.js';
|
|
10
|
+
export { PresetInstallationStore, presetSelectionRevision } from './presetStore.js';
|
|
11
|
+
export { PresetBrowserTransfer } from './presetBrowserTransfer.js';
|
|
12
|
+
/** The caller chooses the source; this loader adds no identity or credential discovery. */
|
|
13
|
+
export function createNodePresetLoader() {
|
|
14
|
+
const network = createBundleFetchLoader();
|
|
15
|
+
return async (source, options) => {
|
|
16
|
+
const url = new URL(source);
|
|
17
|
+
if (url.protocol === 'file:') {
|
|
18
|
+
options.signal?.throwIfAborted();
|
|
19
|
+
const file = fileURLToPath(url);
|
|
20
|
+
const before = await lstat(file);
|
|
21
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > options.maxBytes)
|
|
22
|
+
throw new BundleError('BUNDLE_QUOTA', 'Invalid local preset resource file or size');
|
|
23
|
+
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
24
|
+
try {
|
|
25
|
+
const stat = await handle.stat();
|
|
26
|
+
if (!stat.isFile() || stat.size > options.maxBytes)
|
|
27
|
+
throw new BundleError('BUNDLE_QUOTA', 'Local preset resource exceeds its size limit');
|
|
28
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
29
|
+
let length = 0;
|
|
30
|
+
while (length < bytes.byteLength) {
|
|
31
|
+
options.signal?.throwIfAborted();
|
|
32
|
+
const result = await handle.read(bytes, length, bytes.byteLength - length, null);
|
|
33
|
+
if (!result.bytesRead)
|
|
34
|
+
break;
|
|
35
|
+
length += result.bytesRead;
|
|
36
|
+
}
|
|
37
|
+
if (length > stat.size)
|
|
38
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Local preset resource changed while reading');
|
|
39
|
+
return bytes.subarray(0, length);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
await handle.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
|
|
46
|
+
throw new BundleError('BUNDLE_INVALID', 'Preset sources require HTTPS, local files or loopback HTTP');
|
|
47
|
+
}
|
|
48
|
+
return network(source, options);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function sourceUrl(source) {
|
|
52
|
+
try {
|
|
53
|
+
return new URL(source).href;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return pathToFileURL(resolve(source)).href;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function localHosts(allowStart = false) {
|
|
60
|
+
const configured = process.env.MEARL_SOCKET_PATH?.trim();
|
|
61
|
+
const hosts = configured ? [{ id: 'local', socketPath: configured }] : listLiveBrowsers().filter(browser => !browser.provider).map(browser => ({ id: browser.browserId, socketPath: browser.socketPath }));
|
|
62
|
+
return hosts.length ? Array.from(new Map(hosts.map(host => [host.socketPath, host])).values()) : [{ id: 'local', ...(allowStart ? {} : { socketPath: SOCKET_PATH }) }];
|
|
63
|
+
}
|
|
64
|
+
/** Installation and live acknowledgements are distinct; browser/external installs remain pending. */
|
|
65
|
+
export class PresetInstaller {
|
|
66
|
+
store;
|
|
67
|
+
options;
|
|
68
|
+
constructor(options = {}) {
|
|
69
|
+
this.options = options;
|
|
70
|
+
this.store = new PresetInstallationStore(options);
|
|
71
|
+
}
|
|
72
|
+
invokeHost(action, data, target, timeoutSec, allowStart = false) {
|
|
73
|
+
if (!this.options.invoke && !target.socketPath && !allowStart)
|
|
74
|
+
return Promise.reject(new Error('HOST_UNAVAILABLE'));
|
|
75
|
+
return (this.options.invoke ?? (target.socketPath ? invoke : invokeLocalHost))(action, data, { socketPath: target.socketPath, timeoutSec });
|
|
76
|
+
}
|
|
77
|
+
async hostStatus(reload, nodeRevision, installationRevision) {
|
|
78
|
+
const targets = this.options.hosts?.() ?? localHosts(reload);
|
|
79
|
+
return Promise.all(targets.map(async (target) => {
|
|
80
|
+
try {
|
|
81
|
+
const result = await this.invokeHost(reload ? 'node_capabilities_reload' : 'node_capabilities_status', reload ? { structured: true } : {}, target, 15, reload);
|
|
82
|
+
if (reload && result?.accepted === false)
|
|
83
|
+
return { id: target.id, state: 'rejected', error: typeof result.error === 'string' ? result.error : 'Node capability activation was rejected' };
|
|
84
|
+
return { id: target.id, state: result?.revision === nodeRevision && result?.installationRevision === installationRevision ? 'active' : 'pending' };
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { id: target.id, state: 'pending', error: 'HOST_UNAVAILABLE' };
|
|
88
|
+
}
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
async browserStatus(reload, browserRevision, installationRevision) {
|
|
92
|
+
const targets = this.options.hosts?.() ?? localHosts(reload);
|
|
93
|
+
return Promise.all(targets.map(async (target) => {
|
|
94
|
+
try {
|
|
95
|
+
const result = await this.invokeHost(reload ? 'preset_browser_sync' : 'preset_browser_status', reload ? { installationRevision } : {}, target, reload ? 130 : 15, reload);
|
|
96
|
+
if (result?.accepted === false)
|
|
97
|
+
return { id: target.id, state: 'rejected', settingsReady: false, error: typeof result.error === 'string' ? result.error : 'Browser preset activation rejected' };
|
|
98
|
+
return { id: target.id, state: result?.ready === true && result?.browserRevision === browserRevision && result?.installationRevision === installationRevision ? 'active' : 'pending', settingsReady: result?.settingsReady === true, ...(Array.isArray(result?.permissionPending) && result.permissionPending.length ? { error: 'PRESET_DOMAIN_PERMISSION_REQUIRED: Open extension settings and authorize preset domains' } : {}) };
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { id: target.id, state: 'pending', settingsReady: false, error: 'BROWSER_UNAVAILABLE' };
|
|
102
|
+
}
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
async componentStatus(install, receipt) {
|
|
106
|
+
const target = (this.options.hosts?.() ?? localHosts(install))[0];
|
|
107
|
+
if (!target)
|
|
108
|
+
return {};
|
|
109
|
+
try {
|
|
110
|
+
const result = await this.invokeHost(install ? 'preset_components_install' : 'preset_components_status', { installationRevision: receipt.revision }, target, install ? 900 : 15, install);
|
|
111
|
+
if (result?.installationRevision !== receipt.revision || result?.nodeRevision !== receipt.nodeRevision || !Array.isArray(result?.components))
|
|
112
|
+
throw new Error('Component installation host requires an update');
|
|
113
|
+
if (result.catalogReady !== undefined && typeof result.catalogReady !== 'boolean')
|
|
114
|
+
throw new Error('Invalid plugin catalog readiness');
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
for (const entry of result.components) {
|
|
117
|
+
if (!entry || !['skills', 'providers'].includes(entry.kind) || typeof entry.id !== 'string' || !['ready', 'pending', 'conflict'].includes(entry.state) || (entry.reason !== undefined && typeof entry.reason !== 'string'))
|
|
118
|
+
throw new Error('Invalid component installation status');
|
|
119
|
+
const key = `${entry.kind}:${entry.id}`;
|
|
120
|
+
if (seen.has(key))
|
|
121
|
+
throw new Error('Duplicate component installation status');
|
|
122
|
+
seen.add(key);
|
|
123
|
+
}
|
|
124
|
+
return { status: result };
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async status() {
|
|
131
|
+
const current = await this.store.current();
|
|
132
|
+
const result = { revision: presetSelectionRevision(current), ready: false, hosts: [], browsers: [], pending: [], warnings: [] };
|
|
133
|
+
if (!current.receipt || !current.prepared)
|
|
134
|
+
return result;
|
|
135
|
+
const { preset } = current.prepared;
|
|
136
|
+
const receipt = current.receipt;
|
|
137
|
+
result.id = preset.id;
|
|
138
|
+
result.version = preset.version;
|
|
139
|
+
result.overrides = receipt.overrides;
|
|
140
|
+
result.settings = mergePresetDefaults(preset.defaults, receipt.overrides);
|
|
141
|
+
result.nodeRevision = receipt.nodeRevision;
|
|
142
|
+
result.browserRevision = receipt.browserRevision;
|
|
143
|
+
result.browserSyncRequired = receipt.browserSyncRequired === true;
|
|
144
|
+
result.localComponentCount = Object.values(preset.packages).filter(entry => entry.enabled !== false && entry.runtimes?.includes('node')).length
|
|
145
|
+
+ [...Object.values(preset.skills), ...Object.values(preset.providers)].filter(entry => entry.enabled !== false).length;
|
|
146
|
+
result.hosts = await this.hostStatus(false, receipt.nodeRevision, receipt.revision);
|
|
147
|
+
const nodeReady = result.hosts.length > 0 && result.hosts.every(host => host.state === 'active');
|
|
148
|
+
if (receipt.browserSyncRequired)
|
|
149
|
+
result.browsers = await this.browserStatus(false, receipt.browserRevision, receipt.revision);
|
|
150
|
+
const browserReady = result.browsers.length > 0 && result.browsers.every(host => host.state === 'active');
|
|
151
|
+
for (const [id, entry] of Object.entries(preset.packages)) {
|
|
152
|
+
if (entry.enabled === false)
|
|
153
|
+
continue;
|
|
154
|
+
for (const runtime of entry.runtimes ?? []) {
|
|
155
|
+
if (runtime === 'browser') {
|
|
156
|
+
if (!browserReady)
|
|
157
|
+
result.pending.push({ kind: 'packages', id, runtime, reason: 'BROWSER_SYNC_REQUIRED' });
|
|
158
|
+
}
|
|
159
|
+
else if (!receipt.managedModules.includes(id))
|
|
160
|
+
result.pending.push({ kind: 'packages', id, runtime, reason: 'NODE_HOST_NOT_SUPPORTED' });
|
|
161
|
+
else if (!nodeReady)
|
|
162
|
+
result.pending.push({ kind: 'packages', id, runtime, reason: 'HOST_SYNC_REQUIRED' });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const hasExternal = ['skills', 'providers'].some(kind => Object.values(preset[kind]).some(entry => entry.enabled !== false));
|
|
166
|
+
const hasCatalog = Boolean(current.activation?.bindings['plugins.catalog']);
|
|
167
|
+
const components = hasExternal || hasCatalog ? await this.componentStatus(false, receipt) : {};
|
|
168
|
+
if (hasCatalog && components.status?.catalogReady !== true)
|
|
169
|
+
result.pending.push({ kind: 'preset', id: preset.id, runtime: 'node', reason: 'PLUGIN_CATALOG_SYNC_REQUIRED' });
|
|
170
|
+
if (components.error)
|
|
171
|
+
result.warnings.push(`Component readiness check failed: ${components.error}`);
|
|
172
|
+
for (const kind of ['skills', 'providers'])
|
|
173
|
+
for (const [id, entry] of Object.entries(preset[kind])) {
|
|
174
|
+
if (entry.enabled === false)
|
|
175
|
+
continue;
|
|
176
|
+
const actual = components.status?.components.find(component => component.kind === kind && component.id === id);
|
|
177
|
+
if (actual?.state !== 'ready')
|
|
178
|
+
result.pending.push({ kind, id, runtime: 'node', reason: actual?.reason ?? 'INSTALL_REQUIRED' });
|
|
179
|
+
}
|
|
180
|
+
if (receipt.nodeSyncRequired && !nodeReady && !result.pending.some(entry => entry.reason === 'HOST_SYNC_REQUIRED'))
|
|
181
|
+
result.pending.push({ kind: 'preset', id: preset.id, runtime: 'node', reason: 'HOST_SYNC_REQUIRED' });
|
|
182
|
+
if (receipt.browserSyncRequired && !browserReady && !result.pending.some(entry => entry.reason === 'BROWSER_SYNC_REQUIRED'))
|
|
183
|
+
result.pending.push({ kind: 'preset', id: preset.id, runtime: 'browser', reason: 'BROWSER_SYNC_REQUIRED' });
|
|
184
|
+
if (receipt.browserSyncRequired && browserReady && Object.keys(result.settings ?? {}).length && !result.browsers.every(host => host.settingsReady))
|
|
185
|
+
result.pending.push({ kind: 'preset', id: preset.id, runtime: 'browser', reason: 'SETTINGS_APPLY_REQUIRED' });
|
|
186
|
+
result.ready = result.pending.length === 0;
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
async synchronize(receipt, previousRevision) {
|
|
190
|
+
const hosts = await this.hostStatus(true, receipt.nodeRevision, receipt.revision);
|
|
191
|
+
const rejection = hosts.find(host => host.state === 'rejected');
|
|
192
|
+
if (rejection) {
|
|
193
|
+
if (previousRevision === receipt.revision)
|
|
194
|
+
throw new Error(`Node activation rejected; installed selection retained: ${rejection.error}`);
|
|
195
|
+
await this.store.rollback(receipt.revision);
|
|
196
|
+
const previous = await this.store.current({ verifyResources: false });
|
|
197
|
+
await this.hostStatus(true, previous.activation?.revision ?? '', previous.activation?.installationRevision ?? '');
|
|
198
|
+
throw new Error(`Node activation rejected; previous selection restored: ${rejection.error}`);
|
|
199
|
+
}
|
|
200
|
+
if (receipt.browserSyncRequired) {
|
|
201
|
+
const browsers = await this.browserStatus(true, receipt.browserRevision, receipt.revision);
|
|
202
|
+
const rejected = browsers.find(host => host.state === 'rejected');
|
|
203
|
+
if (rejected)
|
|
204
|
+
throw new Error(`Browser activation rejected; installed selection retained for retry: ${rejected.error}`);
|
|
205
|
+
}
|
|
206
|
+
const installed = await this.store.current({ verifyResources: false });
|
|
207
|
+
if (installed.receipt?.revision !== receipt.revision || !installed.preset)
|
|
208
|
+
throw new BundleError('BUNDLE_CONFLICT', 'The selected preset changed before component installation');
|
|
209
|
+
const hasExternal = [...Object.values(installed.preset.skills), ...Object.values(installed.preset.providers)].some(entry => entry.enabled !== false);
|
|
210
|
+
const components = hasExternal || installed.activation?.bindings['plugins.catalog'] ? await this.componentStatus(true, receipt) : {};
|
|
211
|
+
const status = await this.status();
|
|
212
|
+
if (components.error)
|
|
213
|
+
status.warnings.push(`Component installation failed; completed assets retained for retry: ${components.error}`);
|
|
214
|
+
if (status.revision !== receipt.revision)
|
|
215
|
+
throw new BundleError('BUNDLE_CONFLICT', 'The selected preset changed while hosts were synchronizing');
|
|
216
|
+
try {
|
|
217
|
+
await this.store.collectGarbage();
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
status.warnings.push(`Preset resource cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
221
|
+
}
|
|
222
|
+
return status;
|
|
223
|
+
}
|
|
224
|
+
async stageSource(source, expectedRevision, options) {
|
|
225
|
+
const prepared = await preparePreset(sourceUrl(source), { mearlApiVersion: this.store.mearlApiVersion, load: this.options.load ?? createNodePresetLoader(), signal: options.signal });
|
|
226
|
+
if (options.expectedId !== undefined && prepared.preset.id !== options.expectedId)
|
|
227
|
+
throw new BundleError('BUNDLE_INVALID', 'The preset identity differs from the accepted installation');
|
|
228
|
+
const receipt = await this.store.apply(prepared, { ...options, expectedRevision });
|
|
229
|
+
let activated = false;
|
|
230
|
+
return { revision: receipt.revision, activate: async () => {
|
|
231
|
+
if (activated)
|
|
232
|
+
throw new Error('Staged installation was already activated; use resume with its revision to retry');
|
|
233
|
+
activated = true;
|
|
234
|
+
const current = await this.store.current();
|
|
235
|
+
if (current.receipt?.revision !== receipt.revision)
|
|
236
|
+
throw new BundleError('BUNDLE_CONFLICT', 'The selected preset changed before runtime setup completed');
|
|
237
|
+
return this.synchronize(current.receipt, expectedRevision);
|
|
238
|
+
} };
|
|
239
|
+
}
|
|
240
|
+
/** Save exact resources and desired selection; activate once after runtime setup, then retry via resume. */
|
|
241
|
+
async stage(source, options = {}) {
|
|
242
|
+
const accepted = { ...options, ...(options.overrides === undefined ? {} : { overrides: mergePresetDefaults({}, options.overrides) }), ...(options.nodeConfig === undefined ? {} : { nodeConfig: mergePresetDefaults({}, options.nodeConfig) }) };
|
|
243
|
+
const current = await this.store.current({ verifyResources: false });
|
|
244
|
+
const revision = presetSelectionRevision(current);
|
|
245
|
+
if (accepted.expectedRevision !== undefined && accepted.expectedRevision !== revision)
|
|
246
|
+
throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed before preparation');
|
|
247
|
+
return this.stageSource(source, revision, accepted);
|
|
248
|
+
}
|
|
249
|
+
async apply(source, options = {}) {
|
|
250
|
+
return (await this.stage(source, options)).activate();
|
|
251
|
+
}
|
|
252
|
+
async update() {
|
|
253
|
+
const current = await this.store.current({ verifyResources: false });
|
|
254
|
+
if (!current.receipt)
|
|
255
|
+
throw new Error('No preset is installed');
|
|
256
|
+
return this.apply(current.receipt.snapshot.source, { expectedRevision: presetSelectionRevision(current) });
|
|
257
|
+
}
|
|
258
|
+
async resume(expectedRevision) {
|
|
259
|
+
const current = await this.store.current();
|
|
260
|
+
if (!current.receipt || current.receipt.revision !== expectedRevision)
|
|
261
|
+
throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed before resuming');
|
|
262
|
+
return this.synchronize(current.receipt, expectedRevision);
|
|
263
|
+
}
|
|
264
|
+
async configure(value) {
|
|
265
|
+
const current = await this.store.current();
|
|
266
|
+
if (!current.prepared)
|
|
267
|
+
throw new Error('No preset is installed');
|
|
268
|
+
const receipt = await this.store.apply(current.prepared, { expectedRevision: presetSelectionRevision(current), overrides: value });
|
|
269
|
+
return this.synchronize(receipt, presetSelectionRevision(current));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
export async function runPresetCommand(command, source) {
|
|
273
|
+
const installer = new PresetInstaller();
|
|
274
|
+
return command === 'status' ? installer.status() : command === 'update' ? installer.update() : installer.apply(source);
|
|
275
|
+
}
|