@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/README.en.md +46 -43
- package/README.md +46 -43
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +50 -19
- package/dist/preset.d.ts +7 -2
- package/dist/preset.js +296 -49
- package/dist/skillSourceReceipts.d.ts +1 -1
- package/dist/skillSourceReceipts.js +54 -10
- package/dist/skillSources.d.ts +1 -1
- package/dist/skillSources.js +85 -33
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +60 -14
- package/package.json +6 -6
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)
|
|
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 ||
|
|
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:' &&
|
|
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
|
|
72
|
-
|
|
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 &&
|
|
76
|
-
|
|
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, {
|
|
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 {
|
|
98
|
-
|
|
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 {
|
|
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'
|
|
117
|
-
|
|
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 {
|
|
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', {
|
|
130
|
-
|
|
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 ||
|
|
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 = {
|
|
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 =
|
|
165
|
-
|
|
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) &&
|
|
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({
|
|
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({
|
|
277
|
+
result.pending.push({
|
|
278
|
+
kind,
|
|
279
|
+
id,
|
|
280
|
+
runtime: 'node',
|
|
281
|
+
reason: actual?.reason ?? 'INSTALL_REQUIRED',
|
|
282
|
+
});
|
|
199
283
|
}
|
|
200
|
-
if (receipt.nodeSyncRequired &&
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
result.pending.push({
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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 = [
|
|
228
|
-
|
|
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), {
|
|
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 {
|
|
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 {
|
|
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 = {
|
|
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, {
|
|
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 &&
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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'
|
|
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 [
|
|
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 ||
|
|
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) ||
|
|
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 {
|
|
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)
|
|
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
|
|
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 ?? '')
|
|
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
|
|
134
|
+
return (refMatch === 'exact' &&
|
|
135
|
+
normalize(requested) === normalize(tracked.source, tracked.sourceType) &&
|
|
136
|
+
!tracked.ref);
|
|
93
137
|
}
|
|
94
138
|
}
|