@mearl/setup 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/preset.js CHANGED
@@ -4,15 +4,24 @@ import { createRequire } from 'node:module';
4
4
  import { resolve } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { invoke, invokeLocalHost, listLiveBrowsers, SOCKET_PATH } from '@mearl/client/local';
7
+ import { uninstallOfficialPluginCatalog } from '@mearl/client/plugin';
7
8
  import { mergePresetDefaults } from '@mearl/preset';
8
- import { BundleError, createBundleFetchLoader, preparePreset } from '@mearl/preset/bundles';
9
+ import { BundleError, createBundleFetchLoader, preparePreset, } from '@mearl/preset/bundles';
9
10
  import { PresetInstallationStore, presetSelectionRevision } from './presetStore.js';
11
+ import { claimPresetInstallation } from './presetInstallationLease.js';
10
12
  const installationSite = createRequire(import.meta.url)('@mearl/preset/installation-site.json');
11
- export const MEARL_INSTALLATION_CONFIG_URL = new URL('installation.json', installationSite.url).href;
13
+ export const MEARL_INSTALLATION_CONFIG_URL = new URL('installation.json', installationSite.url)
14
+ .href;
12
15
  /** Runtime overrides apply to this local process, never to browser-supplied install requests. */
13
16
  export function resolveInstallationConfigUrl(value = process.env.MEARL_INSTALLATION_CONFIG_URL, allowLoopback = false) {
14
17
  const url = new URL(value || MEARL_INSTALLATION_CONFIG_URL);
15
- if (url.username || url.password || url.hash || (url.protocol !== 'https:' && !(allowLoopback && url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))))
18
+ if (url.username ||
19
+ url.password ||
20
+ url.hash ||
21
+ (url.protocol !== 'https:' &&
22
+ !(allowLoopback &&
23
+ url.protocol === 'http:' &&
24
+ ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))))
16
25
  throw new Error('Invalid installation configuration URL');
17
26
  return url;
18
27
  }
@@ -52,7 +61,8 @@ export function createNodePresetLoader() {
52
61
  await handle.close();
53
62
  }
54
63
  }
55
- if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
64
+ if (url.protocol !== 'https:' &&
65
+ !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
56
66
  throw new BundleError('BUNDLE_INVALID', 'Preset sources require HTTPS, local files or loopback HTTP');
57
67
  }
58
68
  return network(source, options);
@@ -68,12 +78,20 @@ function sourceUrl(source) {
68
78
  }
69
79
  function localHosts(allowStart = false) {
70
80
  const configured = process.env.MEARL_SOCKET_PATH?.trim();
71
- const hosts = configured ? [{ id: 'local', socketPath: configured }] : listLiveBrowsers().filter(browser => !browser.provider).map(browser => ({ id: browser.browserId, socketPath: browser.socketPath }));
72
- return hosts.length ? Array.from(new Map(hosts.map(host => [host.socketPath, host])).values()) : [{ id: 'local', ...(allowStart ? {} : { socketPath: SOCKET_PATH }) }];
81
+ const hosts = configured
82
+ ? [{ id: 'local', socketPath: configured }]
83
+ : listLiveBrowsers()
84
+ .filter(browser => !browser.provider)
85
+ .map(browser => ({ id: browser.browserId, socketPath: browser.socketPath }));
86
+ return hosts.length
87
+ ? Array.from(new Map(hosts.map(host => [host.socketPath, host])).values())
88
+ : [{ id: 'local', ...(allowStart ? {} : { socketPath: SOCKET_PATH }) }];
73
89
  }
74
90
  export function connectedPresetExtensions(browsers, socketPath) {
75
- return browsers.filter(browser => !browser.provider && browser.transport === 'extension' &&
76
- (browser.status === undefined || browser.status === 'connected') && (!socketPath || browser.socketPath === socketPath));
91
+ return browsers.filter(browser => !browser.provider &&
92
+ browser.transport === 'extension' &&
93
+ (browser.status === undefined || browser.status === 'connected') &&
94
+ (!socketPath || browser.socketPath === socketPath));
77
95
  }
78
96
  /** Installation and live acknowledgements are distinct; browser/external installs remain pending. */
79
97
  export class PresetInstaller {
@@ -86,16 +104,30 @@ export class PresetInstaller {
86
104
  invokeHost(action, data, target, timeoutSec, allowStart = false) {
87
105
  if (!this.options.invoke && !target.socketPath && !allowStart)
88
106
  return Promise.reject(new Error('HOST_UNAVAILABLE'));
89
- return (this.options.invoke ?? (target.socketPath ? invoke : invokeLocalHost))(action, data, { socketPath: target.socketPath, timeoutSec });
107
+ return (this.options.invoke ?? (target.socketPath ? invoke : invokeLocalHost))(action, data, {
108
+ socketPath: target.socketPath,
109
+ timeoutSec,
110
+ });
90
111
  }
91
- async hostStatus(reload, nodeRevision, installationRevision) {
92
- const targets = this.options.hosts?.() ?? localHosts(reload);
112
+ async hostStatus(reload, nodeRevision, installationRevision, targets = this.options.hosts?.() ?? localHosts(reload)) {
93
113
  return Promise.all(targets.map(async (target) => {
94
114
  try {
95
115
  const result = await this.invokeHost(reload ? 'node_capabilities_reload' : 'node_capabilities_status', reload ? { structured: true } : {}, target, 15, reload);
96
116
  if (reload && result?.accepted === false)
97
- return { id: target.id, state: 'rejected', error: typeof result.error === 'string' ? result.error : 'Node capability activation was rejected' };
98
- return { id: target.id, state: result?.revision === nodeRevision && result?.installationRevision === installationRevision ? 'active' : 'pending' };
117
+ return {
118
+ id: target.id,
119
+ state: 'rejected',
120
+ error: typeof result.error === 'string'
121
+ ? result.error
122
+ : 'Node capability activation was rejected',
123
+ };
124
+ return {
125
+ id: target.id,
126
+ state: result?.revision === nodeRevision &&
127
+ result?.installationRevision === installationRevision
128
+ ? 'active'
129
+ : 'pending',
130
+ };
99
131
  }
100
132
  catch {
101
133
  return { id: target.id, state: 'pending', error: 'HOST_UNAVAILABLE' };
@@ -105,19 +137,42 @@ export class PresetInstaller {
105
137
  async browserStatus(reload, browserRevision, installationRevision) {
106
138
  const targets = this.options.connectedExtensionsOnly
107
139
  ? connectedPresetExtensions(listLiveBrowsers(), process.env.MEARL_SOCKET_PATH?.trim()).map(browser => ({ id: browser.browserId, socketPath: browser.socketPath }))
108
- : this.options.hosts?.() ?? localHosts(reload);
140
+ : (this.options.hosts?.() ?? localHosts(reload));
109
141
  return Promise.all(targets.map(async (target) => {
110
142
  try {
111
143
  const result = await this.invokeHost(reload ? 'preset_browser_sync' : 'preset_browser_status', reload ? { installationRevision } : {}, target, reload ? 130 : 15, reload);
112
144
  if (result?.accepted === false)
113
- return { id: target.id, state: 'rejected', settingsReady: false, error: typeof result.error === 'string' ? result.error : 'Browser preset activation rejected' };
145
+ return {
146
+ id: target.id,
147
+ state: 'rejected',
148
+ settingsReady: false,
149
+ error: typeof result.error === 'string'
150
+ ? result.error
151
+ : 'Browser preset activation rejected',
152
+ };
114
153
  const error = Array.isArray(result?.permissionPending) && result.permissionPending.length
115
154
  ? 'PRESET_DOMAIN_PERMISSION_REQUIRED: Open extension settings and authorize preset domains'
116
- : typeof result?.error === 'string' ? result.error : undefined;
117
- return { id: target.id, state: result?.ready === true && result?.browserRevision === browserRevision && result?.installationRevision === installationRevision ? 'active' : 'pending', settingsReady: result?.settingsReady === true, ...(error ? { error } : {}) };
155
+ : typeof result?.error === 'string'
156
+ ? result.error
157
+ : undefined;
158
+ return {
159
+ id: target.id,
160
+ state: result?.ready === true &&
161
+ result?.browserRevision === browserRevision &&
162
+ result?.installationRevision === installationRevision
163
+ ? 'active'
164
+ : 'pending',
165
+ settingsReady: result?.settingsReady === true,
166
+ ...(error ? { error } : {}),
167
+ };
118
168
  }
119
169
  catch {
120
- return { id: target.id, state: 'pending', settingsReady: false, error: 'BROWSER_UNAVAILABLE' };
170
+ return {
171
+ id: target.id,
172
+ state: 'pending',
173
+ settingsReady: false,
174
+ error: 'BROWSER_UNAVAILABLE',
175
+ };
121
176
  }
122
177
  }));
123
178
  }
@@ -126,14 +181,23 @@ export class PresetInstaller {
126
181
  if (!target)
127
182
  return {};
128
183
  try {
129
- const result = await this.invokeHost(install ? 'preset_components_install' : 'preset_components_status', { installationRevision: receipt.revision, ...(install && replaceSkills?.length ? { replaceSkills } : {}) }, target, install ? 900 : 15, install);
130
- if (result?.installationRevision !== receipt.revision || result?.nodeRevision !== receipt.nodeRevision || !Array.isArray(result?.components))
184
+ const result = await this.invokeHost(install ? 'preset_components_install' : 'preset_components_status', {
185
+ installationRevision: receipt.revision,
186
+ ...(install && replaceSkills?.length ? { replaceSkills } : {}),
187
+ }, target, install ? 900 : 15, install);
188
+ if (result?.installationRevision !== receipt.revision ||
189
+ result?.nodeRevision !== receipt.nodeRevision ||
190
+ !Array.isArray(result?.components))
131
191
  throw new Error('Component installation host requires an update');
132
192
  if (result.catalogReady !== undefined && typeof result.catalogReady !== 'boolean')
133
193
  throw new Error('Invalid plugin catalog readiness');
134
194
  const seen = new Set();
135
195
  for (const entry of result.components) {
136
- if (!entry || !['skills', 'providers'].includes(entry.kind) || typeof entry.id !== 'string' || !['ready', 'pending', 'conflict'].includes(entry.state) || (entry.reason !== undefined && typeof entry.reason !== 'string'))
196
+ if (!entry ||
197
+ !['skills', 'providers'].includes(entry.kind) ||
198
+ typeof entry.id !== 'string' ||
199
+ !['ready', 'pending', 'conflict'].includes(entry.state) ||
200
+ (entry.reason !== undefined && typeof entry.reason !== 'string'))
137
201
  throw new Error('Invalid component installation status');
138
202
  const key = `${entry.kind}:${entry.id}`;
139
203
  if (seen.has(key))
@@ -148,7 +212,14 @@ export class PresetInstaller {
148
212
  }
149
213
  async status() {
150
214
  const current = await this.store.current();
151
- const result = { revision: presetSelectionRevision(current), ready: false, hosts: [], browsers: [], pending: [], warnings: [] };
215
+ const result = {
216
+ revision: presetSelectionRevision(current),
217
+ ready: false,
218
+ hosts: [],
219
+ browsers: [],
220
+ pending: [],
221
+ warnings: [],
222
+ };
152
223
  if (!current.receipt || !current.prepared)
153
224
  return result;
154
225
  const { preset } = current.prepared;
@@ -161,8 +232,9 @@ export class PresetInstaller {
161
232
  result.browserRevision = receipt.browserRevision;
162
233
  const browserSyncRequired = receipt.browserSyncRequired === true && !this.options.connectedExtensionsOnly;
163
234
  result.browserSyncRequired = browserSyncRequired;
164
- result.localComponentCount = Object.values(preset.packages).filter(entry => entry.enabled !== false && entry.targets?.includes('node')).length
165
- + [...Object.values(preset.skills), ...Object.values(preset.providers)].filter(entry => entry.enabled !== false).length;
235
+ result.localComponentCount =
236
+ Object.values(preset.packages).filter(entry => entry.enabled !== false && entry.targets?.includes('node')).length +
237
+ [...Object.values(preset.skills), ...Object.values(preset.providers)].filter(entry => entry.enabled !== false).length;
166
238
  result.hosts = await this.hostStatus(false, receipt.nodeRevision, receipt.revision);
167
239
  const nodeReady = result.hosts.length > 0 && result.hosts.every(host => host.state === 'active');
168
240
  if (receipt.browserSyncRequired)
@@ -176,7 +248,9 @@ export class PresetInstaller {
176
248
  if (browserSyncRequired && !browserReady)
177
249
  result.pending.push({ kind: 'packages', id, runtime, reason: 'BROWSER_SYNC_REQUIRED' });
178
250
  }
179
- else if (!receipt.managedModules.includes(id) && Object.keys(current.prepared.bundles.find(bundle => bundle.manifest.id === id)?.manifest.contributions?.nodeRuntimes ?? {}).length)
251
+ else if (!receipt.managedModules.includes(id) &&
252
+ Object.keys(current.prepared.bundles.find(bundle => bundle.manifest.id === id)?.manifest
253
+ .contributions?.nodeRuntimes ?? {}).length)
180
254
  result.pending.push({ kind: 'packages', id, runtime, reason: 'NODE_HOST_NOT_SUPPORTED' });
181
255
  else if (!nodeReady)
182
256
  result.pending.push({ kind: 'packages', id, runtime, reason: 'HOST_SYNC_REQUIRED' });
@@ -186,7 +260,12 @@ export class PresetInstaller {
186
260
  const hasCatalog = Boolean(current.activation?.bindings['plugins.catalog']);
187
261
  const components = hasExternal || hasCatalog ? await this.componentStatus(false, receipt) : {};
188
262
  if (hasCatalog && components.status?.catalogReady !== true)
189
- result.pending.push({ kind: 'preset', id: preset.id, runtime: 'node', reason: 'PLUGIN_CATALOG_SYNC_REQUIRED' });
263
+ result.pending.push({
264
+ kind: 'preset',
265
+ id: preset.id,
266
+ runtime: 'node',
267
+ reason: 'PLUGIN_CATALOG_SYNC_REQUIRED',
268
+ });
190
269
  if (components.error)
191
270
  result.warnings.push(`Component readiness check failed: ${components.error}`);
192
271
  for (const kind of ['skills', 'providers'])
@@ -195,14 +274,41 @@ export class PresetInstaller {
195
274
  continue;
196
275
  const actual = components.status?.components.find(component => component.kind === kind && component.id === id);
197
276
  if (actual?.state !== 'ready')
198
- result.pending.push({ kind, id, runtime: 'node', reason: actual?.reason ?? 'INSTALL_REQUIRED' });
277
+ result.pending.push({
278
+ kind,
279
+ id,
280
+ runtime: 'node',
281
+ reason: actual?.reason ?? 'INSTALL_REQUIRED',
282
+ });
199
283
  }
200
- if (receipt.nodeSyncRequired && !nodeReady && !result.pending.some(entry => entry.reason === 'HOST_SYNC_REQUIRED'))
201
- result.pending.push({ kind: 'preset', id: preset.id, runtime: 'node', reason: 'HOST_SYNC_REQUIRED' });
202
- if (browserSyncRequired && !browserReady && !result.pending.some(entry => entry.reason === 'BROWSER_SYNC_REQUIRED'))
203
- result.pending.push({ kind: 'preset', id: preset.id, runtime: 'browser', reason: 'BROWSER_SYNC_REQUIRED' });
204
- if (browserSyncRequired && browserReady && Object.keys(result.settings ?? {}).length && !result.browsers.every(host => host.settingsReady))
205
- result.pending.push({ kind: 'preset', id: preset.id, runtime: 'browser', reason: 'SETTINGS_APPLY_REQUIRED' });
284
+ if (receipt.nodeSyncRequired &&
285
+ !nodeReady &&
286
+ !result.pending.some(entry => entry.reason === 'HOST_SYNC_REQUIRED'))
287
+ result.pending.push({
288
+ kind: 'preset',
289
+ id: preset.id,
290
+ runtime: 'node',
291
+ reason: 'HOST_SYNC_REQUIRED',
292
+ });
293
+ if (browserSyncRequired &&
294
+ !browserReady &&
295
+ !result.pending.some(entry => entry.reason === 'BROWSER_SYNC_REQUIRED'))
296
+ result.pending.push({
297
+ kind: 'preset',
298
+ id: preset.id,
299
+ runtime: 'browser',
300
+ reason: 'BROWSER_SYNC_REQUIRED',
301
+ });
302
+ if (browserSyncRequired &&
303
+ browserReady &&
304
+ Object.keys(result.settings ?? {}).length &&
305
+ !result.browsers.every(host => host.settingsReady))
306
+ result.pending.push({
307
+ kind: 'preset',
308
+ id: preset.id,
309
+ runtime: 'browser',
310
+ reason: 'SETTINGS_APPLY_REQUIRED',
311
+ });
206
312
  result.ready = result.pending.length === 0;
207
313
  return result;
208
314
  }
@@ -217,15 +323,22 @@ export class PresetInstaller {
217
323
  await this.hostStatus(true, previous.activation?.revision ?? '', previous.activation?.installationRevision ?? '');
218
324
  throw new Error(`Node activation rejected; previous selection restored: ${rejection.error}`);
219
325
  }
220
- const browsers = receipt.browserSyncRequired ? await this.browserStatus(true, receipt.browserRevision, receipt.revision) : [];
326
+ const browsers = receipt.browserSyncRequired
327
+ ? await this.browserStatus(true, receipt.browserRevision, receipt.revision)
328
+ : [];
221
329
  const rejected = browsers.find(host => host.state === 'rejected');
222
330
  if (rejected && !this.options.connectedExtensionsOnly)
223
331
  throw new Error(`Browser activation rejected; installed selection retained for retry: ${rejected.error}`);
224
332
  const installed = await this.store.current({ verifyResources: false });
225
333
  if (installed.receipt?.revision !== receipt.revision || !installed.preset)
226
334
  throw new BundleError('BUNDLE_CONFLICT', 'The selected preset changed before component installation');
227
- const hasExternal = [...Object.values(installed.preset.skills), ...Object.values(installed.preset.providers)].some(entry => entry.enabled !== false);
228
- const components = hasExternal || installed.activation?.bindings['plugins.catalog'] ? await this.componentStatus(true, receipt, replaceSkills) : {};
335
+ const hasExternal = [
336
+ ...Object.values(installed.preset.skills),
337
+ ...Object.values(installed.preset.providers),
338
+ ].some(entry => entry.enabled !== false);
339
+ const components = hasExternal || installed.activation?.bindings['plugins.catalog']
340
+ ? await this.componentStatus(true, receipt, replaceSkills)
341
+ : {};
229
342
  const status = await this.status();
230
343
  if (rejected)
231
344
  status.warnings.push(`Browser preset activation rejected by ${rejected.id}: ${rejected.error}`);
@@ -242,7 +355,11 @@ export class PresetInstaller {
242
355
  return status;
243
356
  }
244
357
  async stageSource(source, expectedRevision, options) {
245
- const prepared = await preparePreset(sourceUrl(source), { mearlApiVersion: this.store.mearlApiVersion, load: this.options.load ?? createNodePresetLoader(), signal: options.signal });
358
+ const prepared = await preparePreset(sourceUrl(source), {
359
+ mearlApiVersion: this.store.mearlApiVersion,
360
+ load: this.options.load ?? createNodePresetLoader(),
361
+ signal: options.signal,
362
+ });
246
363
  if (options.expectedId !== undefined && prepared.preset.id !== options.expectedId)
247
364
  throw new BundleError('BUNDLE_INVALID', 'The preset identity differs from the accepted installation');
248
365
  await options.validatePrepared?.(prepared);
@@ -251,17 +368,22 @@ export class PresetInstaller {
251
368
  if (presetSelectionRevision(current) !== expectedRevision)
252
369
  throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed during update');
253
370
  if (current.receipt && current.prepared?.snapshot.revision === prepared.snapshot.revision) {
254
- return { revision: current.receipt.revision, activate: async () => {
371
+ return {
372
+ revision: current.receipt.revision,
373
+ activate: async () => {
255
374
  const status = await this.status();
256
375
  if (status.revision !== expectedRevision)
257
376
  throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed during update');
258
377
  return status.ready ? status : this.resume(current.receipt.revision);
259
- } };
378
+ },
379
+ };
260
380
  }
261
381
  }
262
382
  const receipt = await this.store.apply(prepared, { ...options, expectedRevision });
263
383
  let activated = false;
264
- return { revision: receipt.revision, activate: async () => {
384
+ return {
385
+ revision: receipt.revision,
386
+ activate: async () => {
265
387
  if (activated)
266
388
  throw new Error('Staged installation was already activated; use resume with its revision to retry');
267
389
  activated = true;
@@ -269,11 +391,17 @@ export class PresetInstaller {
269
391
  if (current.receipt?.revision !== receipt.revision)
270
392
  throw new BundleError('BUNDLE_CONFLICT', 'The selected preset changed before runtime setup completed');
271
393
  return this.synchronize(current.receipt, expectedRevision);
272
- } };
394
+ },
395
+ };
273
396
  }
274
397
  /** Save exact resources and desired selection; activate once after runtime setup, then retry via resume. */
275
398
  async stage(source, options = {}) {
276
- const accepted = { ...options, ...(options.overrides === undefined ? {} : { overrides: mergePresetDefaults({}, options.overrides) }) };
399
+ const accepted = {
400
+ ...options,
401
+ ...(options.overrides === undefined
402
+ ? {}
403
+ : { overrides: mergePresetDefaults({}, options.overrides) }),
404
+ };
277
405
  const current = await this.store.current({ verifyResources: false });
278
406
  const revision = presetSelectionRevision(current);
279
407
  if (accepted.expectedRevision !== undefined && accepted.expectedRevision !== revision)
@@ -281,42 +409,161 @@ export class PresetInstaller {
281
409
  return this.stageSource(source, revision, accepted);
282
410
  }
283
411
  async apply(source, options = {}) {
412
+ if (source === undefined)
413
+ return this.resumeSelected(options);
414
+ if (options.replaceSkills !== undefined)
415
+ throw new Error('Skill replacements require preset apply without a source');
284
416
  return (await this.stage(source, options)).activate();
285
417
  }
286
418
  async update(options = {}) {
287
419
  const current = await this.store.current({ verifyResources: false });
288
420
  if (!current.receipt)
289
421
  throw new Error('No preset is installed');
290
- return this.apply(current.receipt.snapshot.source, { ...options, expectedRevision: presetSelectionRevision(current) });
422
+ return this.apply(current.receipt.snapshot.source, {
423
+ ...options,
424
+ expectedRevision: presetSelectionRevision(current),
425
+ });
291
426
  }
292
427
  async resume(expectedRevision, options = {}) {
293
428
  const current = await this.store.current();
294
429
  if (!current.receipt || current.receipt.revision !== expectedRevision)
295
430
  throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed before resuming');
296
431
  const names = options.replaceSkills;
297
- if (names !== undefined && (!Array.isArray(names) || !names.length || names.length > 256 || new Set(names).size !== names.length || names.some(name => typeof name !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/.test(name) || !current.preset || !Object.hasOwn(current.preset.skills, name) || current.preset.skills[name].enabled === false)))
432
+ if (names !== undefined &&
433
+ (!Array.isArray(names) ||
434
+ !names.length ||
435
+ names.length > 256 ||
436
+ new Set(names).size !== names.length ||
437
+ names.some(name => typeof name !== 'string' ||
438
+ !/^[a-z][a-z0-9-]{0,79}$/.test(name) ||
439
+ !current.preset ||
440
+ !Object.hasOwn(current.preset.skills, name) ||
441
+ current.preset.skills[name].enabled === false)))
298
442
  throw new Error('Invalid Skill replacement selection');
299
443
  return this.synchronize(current.receipt, expectedRevision, options.replaceSkills);
300
444
  }
301
- async repair(options = {}) {
445
+ async resumeSelected(options) {
302
446
  const current = await this.store.current({ verifyResources: false });
303
447
  if (!current.receipt)
304
448
  throw new Error('No preset is installed');
305
449
  return this.resume(current.receipt.revision, options);
306
450
  }
451
+ /** @deprecated Use apply() without a source. */
452
+ async repair(options = {}) {
453
+ return this.resumeSelected(options);
454
+ }
455
+ async uninstall() {
456
+ const current = await this.store.current({ verifyResources: false });
457
+ const { receipt, preset } = current;
458
+ const configured = process.env.MEARL_SOCKET_PATH?.trim();
459
+ const live = this.options.hosts
460
+ ? []
461
+ : listLiveBrowsers().filter(browser => !browser.provider && (!configured || browser.socketPath === configured));
462
+ const explicitHosts = this.options
463
+ .hosts?.()
464
+ .filter(target => !configured || target.socketPath === configured);
465
+ const browserTargets = explicitHosts ??
466
+ connectedPresetExtensions(live).map(browser => ({
467
+ id: browser.browserId,
468
+ socketPath: browser.socketPath,
469
+ }));
470
+ if (configured && !browserTargets.length)
471
+ throw new Error('MEARL_SOCKET_PATH does not identify a connected extension.');
472
+ const selections = (await Promise.all(browserTargets.map(async (target) => {
473
+ const status = await this.invokeHost('preset_browser_status', {}, target, 15);
474
+ if (status?.browserRevision === null && !status.error)
475
+ return;
476
+ if (typeof status?.browserRevision !== 'string')
477
+ throw new Error(`Cannot read the preset in ${target.id}. Reconnect the extension and retry.`);
478
+ if (receipt &&
479
+ status.installationRevision !== receipt.revision &&
480
+ (status.presetId !== preset?.id || status.presetSource !== receipt.snapshot.source))
481
+ return;
482
+ if (!receipt && !configured)
483
+ throw new Error('Set MEARL_SOCKET_PATH to remove a browser preset without a local selection.');
484
+ if (status.uninstallSupported !== true || status.installationToken === undefined) {
485
+ throw new Error(`Update the Native Host and extension for ${target.id} before removing its preset.`);
486
+ }
487
+ return { target, status };
488
+ }))).filter(selection => selection !== undefined);
489
+ const result = {
490
+ revision: null,
491
+ ready: true,
492
+ hosts: [],
493
+ browsers: [],
494
+ pending: [],
495
+ warnings: [],
496
+ };
497
+ let remaining = current.activation;
498
+ if (receipt) {
499
+ const lease = claimPresetInstallation();
500
+ try {
501
+ remaining = await this.store.uninstall(receipt.revision, uninstallOfficialPluginCatalog);
502
+ }
503
+ finally {
504
+ lease.release();
505
+ }
506
+ try {
507
+ await this.store.collectGarbage();
508
+ }
509
+ catch (error) {
510
+ result.warnings.push(`Preset resource cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
511
+ }
512
+ }
513
+ const hosts = explicitHosts ?? [
514
+ ...new Map(live.map(browser => [
515
+ browser.socketPath,
516
+ { id: browser.browserId, socketPath: browser.socketPath },
517
+ ])).values(),
518
+ ];
519
+ result.hosts = await this.hostStatus(true, remaining?.revision ?? null, null, hosts);
520
+ result.browsers = await Promise.all(selections.map(async ({ target, status }) => {
521
+ try {
522
+ const removed = await this.invokeHost('preset_browser_uninstall', {
523
+ browserRevision: status.browserRevision,
524
+ installationToken: status.installationToken,
525
+ }, target, 130);
526
+ if (removed?.accepted !== true || removed.browserRevision !== null)
527
+ throw new Error(removed?.error ?? 'Browser preset removal was not acknowledged');
528
+ return { id: target.id, state: 'active', settingsReady: false };
529
+ }
530
+ catch (error) {
531
+ return {
532
+ id: target.id,
533
+ state: 'pending',
534
+ settingsReady: false,
535
+ error: error instanceof Error ? error.message : String(error),
536
+ };
537
+ }
538
+ }));
539
+ result.ready = [...result.hosts, ...result.browsers].every(host => host.state === 'active');
540
+ return result;
541
+ }
307
542
  async configure(value) {
308
543
  const current = await this.store.current();
309
544
  if (!current.prepared)
310
545
  throw new Error('No preset is installed');
311
- const receipt = await this.store.apply(current.prepared, { expectedRevision: presetSelectionRevision(current), overrides: value });
546
+ const receipt = await this.store.apply(current.prepared, {
547
+ expectedRevision: presetSelectionRevision(current),
548
+ overrides: value,
549
+ });
312
550
  return this.synchronize(receipt, presetSelectionRevision(current));
313
551
  }
314
552
  }
315
553
  export async function runPresetCommand(command, source, options = {}) {
316
- if (command !== 'repair' && options.replaceSkills !== undefined)
317
- throw new Error('Skill replacements require preset repair');
318
554
  const installer = new PresetInstaller({ connectedExtensionsOnly: true });
319
555
  if (command === 'repair')
320
556
  return installer.repair(options);
321
- return command === 'status' ? installer.status() : command === 'update' ? installer.update() : installer.apply(source);
557
+ if ((command !== 'apply' || source !== undefined) && options.replaceSkills !== undefined)
558
+ throw new Error('Skill replacements require preset apply without a source');
559
+ switch (command) {
560
+ case 'apply':
561
+ return installer.apply(source, options);
562
+ case 'update':
563
+ return installer.update();
564
+ case 'status':
565
+ return installer.status();
566
+ case 'uninstall':
567
+ return installer.uninstall();
568
+ }
322
569
  }
@@ -22,4 +22,4 @@ export declare function gitSource(source: string, ref?: string): {
22
22
  subdirectory?: string;
23
23
  };
24
24
  /** Compare the manager's existing source receipt; no network access or second lockfile. */
25
- export declare function matchesTrackedSkillSource(requested: string, tracked: TrackedSkillSource, normalize?: (source: string, sourceType?: string) => string): boolean;
25
+ export declare function matchesTrackedSkillSource(requested: string, tracked: TrackedSkillSource, normalize?: (source: string, sourceType?: string) => string, refMatch?: 'exact' | 'missing'): boolean;
@@ -4,7 +4,11 @@ import { isAbsolute, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  function lockFile(scope, cwd, options) {
6
6
  const env = options.env ?? process.env;
7
- return scope === 'project' ? join(cwd, 'skills-lock.json') : env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, 'skills', '.skill-lock.json') : join(options.homeDirectory ?? homedir(), '.agents', '.skill-lock.json');
7
+ return scope === 'project'
8
+ ? join(cwd, 'skills-lock.json')
9
+ : env.XDG_STATE_HOME
10
+ ? join(env.XDG_STATE_HOME, 'skills', '.skill-lock.json')
11
+ : join(options.homeDirectory ?? homedir(), '.agents', '.skill-lock.json');
8
12
  }
9
13
  export function readTrackedSkillSources(scope, cwd, options = {}) {
10
14
  const file = lockFile(scope, cwd, options);
@@ -33,12 +37,28 @@ export function readTrackedSkillSources(scope, cwd, options = {}) {
33
37
  if (source.length > 4096 || /[\0\r\n]/.test(source))
34
38
  throw new Error('Invalid tracked Skill source');
35
39
  const expectedHash = scope === 'global' ? entry.skillFolderHash : entry.computedHash;
36
- return [{ name, source, scope, ...(typeof entry.sourceType === 'string' ? { sourceType: entry.sourceType } : {}), ...(typeof entry.ref === 'string' ? { ref: entry.ref } : {}), ...(typeof entry.skillPath === 'string' ? { skillPath: entry.skillPath } : {}), ...(typeof expectedHash === 'string' && expectedHash ? { expectedHash } : {}), hashKind: scope === 'global' && typeof expectedHash === 'string' && /^[a-f0-9]{40}$/.test(expectedHash) ? 'git-tree' : 'folder' }];
40
+ return [
41
+ {
42
+ name,
43
+ source,
44
+ scope,
45
+ ...(typeof entry.sourceType === 'string' ? { sourceType: entry.sourceType } : {}),
46
+ ...(typeof entry.ref === 'string' ? { ref: entry.ref } : {}),
47
+ ...(typeof entry.skillPath === 'string' ? { skillPath: entry.skillPath } : {}),
48
+ ...(typeof expectedHash === 'string' && expectedHash ? { expectedHash } : {}),
49
+ hashKind: scope === 'global' &&
50
+ typeof expectedHash === 'string' &&
51
+ /^[a-f0-9]{40}$/.test(expectedHash)
52
+ ? 'git-tree'
53
+ : 'folder',
54
+ },
55
+ ];
37
56
  });
38
57
  }
39
58
  /** Custom source kinds require their installed backend, even if they use owner/repo syntax. */
40
59
  export function isPublicSkillSource(source) {
41
- return source.sourceType === undefined || ['github', 'gitlab', 'git', 'local', 'well-known'].includes(source.sourceType);
60
+ return (source.sourceType === undefined ||
61
+ ['github', 'gitlab', 'git', 'local', 'well-known'].includes(source.sourceType));
42
62
  }
43
63
  export function gitSource(source, ref) {
44
64
  if (/^[\w.-]+\/[\w.-]+$/.test(source))
@@ -46,17 +66,31 @@ export function gitSource(source, ref) {
46
66
  if (/^[\w.-]+@[\w.-]+:[^\s]+$/.test(source))
47
67
  return { repository: source, ...(ref ? { ref } : {}) };
48
68
  const url = new URL(source);
49
- if (!['https:', 'http:', 'ssh:', 'git:', 'file:'].includes(url.protocol) || url.password || (url.username && url.protocol !== 'ssh:') || url.search || url.hash)
69
+ if (!['https:', 'http:', 'ssh:', 'git:', 'file:'].includes(url.protocol) ||
70
+ url.password ||
71
+ (url.username && url.protocol !== 'ssh:') ||
72
+ url.search ||
73
+ url.hash)
50
74
  throw new Error('Skill source is not a supported Git repository');
51
75
  if (url.hostname === 'github.com' && !url.port) {
52
76
  const parts = url.pathname.split('/').filter(Boolean);
53
77
  if (parts.length >= 2)
54
- return { repository: `https://github.com/${parts[0]}/${parts[1].replace(/\.git$/, '')}.git`, ...(ref || (['tree', 'blob'].includes(parts[2]) && parts[3]) ? { ref: ref || decodeURIComponent(parts[3]) } : {}), ...(['tree', 'blob'].includes(parts[2]) && parts.length > 4 ? { subdirectory: decodeURIComponent(parts.slice(4).join('/')).replace(/\/SKILL\.md$/, '') } : {}) };
78
+ return {
79
+ repository: `https://github.com/${parts[0]}/${parts[1].replace(/\.git$/, '')}.git`,
80
+ ...(ref || (['tree', 'blob'].includes(parts[2]) && parts[3])
81
+ ? { ref: ref || decodeURIComponent(parts[3]) }
82
+ : {}),
83
+ ...(['tree', 'blob'].includes(parts[2]) && parts.length > 4
84
+ ? {
85
+ subdirectory: decodeURIComponent(parts.slice(4).join('/')).replace(/\/SKILL\.md$/, ''),
86
+ }
87
+ : {}),
88
+ };
55
89
  }
56
90
  return { repository: url.href, ...(ref ? { ref } : {}) };
57
91
  }
58
92
  /** Compare the manager's existing source receipt; no network access or second lockfile. */
59
- export function matchesTrackedSkillSource(requested, tracked, normalize = source => source) {
93
+ export function matchesTrackedSkillSource(requested, tracked, normalize = source => source, refMatch = 'exact') {
60
94
  const location = (source, ref, sourceType) => {
61
95
  const value = normalize(source, sourceType);
62
96
  if (typeof value !== 'string' || !value || value.length > 8192 || /[\0\r\n]/.test(value))
@@ -64,7 +98,9 @@ export function matchesTrackedSkillSource(requested, tracked, normalize = source
64
98
  const hash = value.indexOf('#');
65
99
  const fragment = hash < 0 ? undefined : value.slice(hash + 1);
66
100
  const plain = hash < 0 ? value : value.slice(0, hash);
67
- const parsed = isAbsolute(plain) ? { repository: plain, ref: ref ?? fragment } : gitSource(plain, ref ?? fragment);
101
+ const parsed = isAbsolute(plain)
102
+ ? { repository: plain, ref: ref ?? fragment }
103
+ : gitSource(plain, ref ?? fragment);
68
104
  let repository = parsed.repository;
69
105
  if (repository.startsWith('file:'))
70
106
  repository = fileURLToPath(repository);
@@ -81,14 +117,22 @@ export function matchesTrackedSkillSource(requested, tracked, normalize = source
81
117
  try {
82
118
  const expected = location(requested);
83
119
  const actual = location(tracked.source, tracked.ref, tracked.sourceType);
84
- if (expected.repository !== actual.repository || expected.ref !== actual.ref)
120
+ if (expected.repository !== actual.repository)
121
+ return false;
122
+ if (refMatch === 'missing'
123
+ ? !expected.ref || actual.ref !== undefined
124
+ : expected.ref !== actual.ref)
85
125
  return false;
86
126
  if (!expected.subdirectory)
87
127
  return true;
88
- const path = (tracked.skillPath ?? actual.subdirectory ?? '').replace(/(?:^|\/)SKILL\.md$/, '').replace(/\/+$/, '');
128
+ const path = (tracked.skillPath ?? actual.subdirectory ?? '')
129
+ .replace(/(?:^|\/)SKILL\.md$/, '')
130
+ .replace(/\/+$/, '');
89
131
  return path === expected.subdirectory || path.startsWith(expected.subdirectory + '/');
90
132
  }
91
133
  catch {
92
- return normalize(requested) === normalize(tracked.source, tracked.sourceType) && !tracked.ref;
134
+ return (refMatch === 'exact' &&
135
+ normalize(requested) === normalize(tracked.source, tracked.sourceType) &&
136
+ !tracked.ref);
93
137
  }
94
138
  }