@mearl/setup 2.22.0 → 3.0.0-beta.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.
Files changed (42) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +59 -59
  3. package/dist/browserExtension.d.ts +2 -2
  4. package/dist/browserExtension.js +25 -19
  5. package/dist/cli.d.ts +8 -3
  6. package/dist/cli.js +74 -17
  7. package/dist/index.d.ts +3 -1
  8. package/dist/index.js +2 -1
  9. package/dist/lifecycle.d.ts +11 -4
  10. package/dist/lifecycle.js +48 -15
  11. package/dist/managedPlugins.d.ts +21 -0
  12. package/dist/managedPlugins.js +76 -0
  13. package/dist/npmRegistry.d.ts +1 -1
  14. package/dist/npmRegistry.js +4 -16
  15. package/dist/plugin.d.ts +13 -3
  16. package/dist/plugin.js +106 -44
  17. package/dist/preset.d.ts +94 -0
  18. package/dist/preset.js +275 -0
  19. package/dist/presetBrowserTransfer.d.ts +11 -0
  20. package/dist/presetBrowserTransfer.js +72 -0
  21. package/dist/presetInstallationLease.d.ts +6 -0
  22. package/dist/presetInstallationLease.js +66 -0
  23. package/dist/presetStore.d.ts +48 -0
  24. package/dist/presetStore.js +252 -0
  25. package/dist/presetUpgrade.d.ts +13 -0
  26. package/dist/presetUpgrade.js +42 -0
  27. package/dist/provider.d.ts +2 -0
  28. package/dist/provider.js +24 -2
  29. package/dist/runtimeCleanup.js +1 -8
  30. package/dist/setupVersion.d.ts +2 -0
  31. package/dist/setupVersion.js +4 -0
  32. package/dist/skillSources.d.ts +43 -0
  33. package/dist/skillSources.js +247 -0
  34. package/dist/skills.d.ts +45 -0
  35. package/dist/skills.js +213 -0
  36. package/dist/version.d.ts +2 -0
  37. package/dist/version.js +6 -0
  38. package/package.json +22 -10
  39. package/dist/aliSkills.d.ts +0 -32
  40. package/dist/aliSkills.js +0 -212
  41. package/dist/internalPlugins.d.ts +0 -26
  42. 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, findOfficialPlugin, findPluginEntry, findPluginRegistration, listPluginNames, loadPlugin, OFFICIAL_PLUGIN_MANAGER, registerPlugin, resolvePluginEntry, unregisterPlugin, } from '@mearl/client/plugin';
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 { resolveAliSkillsInvocation, stripAnsi } from './aliSkills.js';
7
- import { findExecutable, prependNodePath, runCommand, } from '@mearl/daemon-core';
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 definition = findOfficialPlugin(name);
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 code = await installPlugins([...definition.installArgs], runner, {
56
- registrationManagedBy: OFFICIAL_PLUGIN_MANAGER,
57
- });
58
- if (code !== 0)
59
- throw new Error(`官方插件 ${name} 安装失败 (exit ${code})`);
60
- const registration = findPluginRegistration(name);
61
- if (!registration)
62
- throw new Error(`官方插件 ${name} 安装完成后仍未注册`);
63
- return 'installed';
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 globalAliSkill(entry) {
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 projectAliSkill(entry, managedBy) {
100
- if (managedBy !== 'ali-skills')
113
+ function projectSkill(entry, managedBy) {
114
+ if (managedBy !== 'skills' && managedBy !== 'ali-skills')
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 = projectAliSkill(entry, findPluginRegistration(name)?.managedBy) ?? globalAliSkill(entry);
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 = projectAliSkill(candidate, 'ali-skills') ?? globalAliSkill(candidate);
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 runner(invocation.command, [
166
- ...invocation.argsPrefix,
183
+ removed = await cli.run([
167
184
  'remove',
168
185
  skillName,
169
186
  ...(managedSkill.cwd ? [] : ['--global']),
170
187
  '--yes',
171
- ], { env: invocation.env, tee: true, ...(managedSkill.cwd ? { cwd: managedSkill.cwd } : {}) });
188
+ ], { tee: true, ...(managedSkill.cwd ? { cwd: managedSkill.cwd } : {}) });
172
189
  }
173
190
  catch (error) {
174
- throw new Error(`ali-skills remove ${skillName} 执行失败:${error instanceof Error ? error.message : String(error)};插件注册已保留。`, { cause: 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
- /* ali-skills 已移除,或目录中出现了新内容。 */
199
+ /* skills 已移除,或目录中出现了新内容。 */
183
200
  }
184
201
  }
185
202
  }
186
203
  if (removed.code !== 0) {
187
- throw new Error(`ali-skills remove ${skillName} 失败(exit ${removed.code});插件注册已保留。`);
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
- /** Ali Skills 的 Installed 区块列出成功路径;计数校验避免输出变化导致漏注册。 */
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 skillPath = resolve(value.startsWith('~/') ? join(homedir(), value.slice(2)) : value);
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('Ali Skills 安装结果格式无法识别,请使用 setup plugin [name] <path> 注册。');
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 不支持 -- 参数分隔符,以确保全局 symlink 安装选项生效。');
262
+ throw new Error('plugin install 不支持 -- 参数分隔符,以确保全局安装选项生效。');
238
263
  }
239
- const npx = findExecutable('npx', { extraDirectories: [dirname(process.execPath)] });
240
- if (!npx)
241
- throw new Error('未找到 npx,请检查 Node.js 安装。');
242
- // 固定共享安装模式,确保成功结果指向 ~/.agents/skills 中的入口。
243
- const installed = await runner(npx, ['-y', 'ali-skills', 'add', ...args, '--global', '--install-mode', 'symlink'], {
244
- env: prependNodePath(),
245
- tee: true,
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
- const paths = parseInstalledPluginPaths(output);
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('Ali Skills 安装成功但无法识别安装结果,请使用 setup plugin [name] <path> 注册。');
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
  }
@@ -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 };
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
+ }