@mearl/daemon-core 3.0.1 → 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-installation.d.ts +4 -0
- package/dist/preset-installation.js +113 -25
- package/package.json +2 -2
|
@@ -38,6 +38,10 @@ export declare class PresetInstallationStore {
|
|
|
38
38
|
signal?: AbortSignal;
|
|
39
39
|
confirm?: () => Promise<void>;
|
|
40
40
|
}): Promise<PresetInstallReceipt>;
|
|
41
|
+
uninstall(expectedRevision: string, beforeRemove?: (selection: {
|
|
42
|
+
path: string;
|
|
43
|
+
sha256: string;
|
|
44
|
+
}) => void): Promise<CapabilityActivation | undefined>;
|
|
41
45
|
rollback(expectedRevision: string): Promise<void>;
|
|
42
46
|
collectGarbage(): Promise<void>;
|
|
43
47
|
private collectLocked;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { constants, closeSync, fstatSync, lstatSync, linkSync, mkdirSync, openSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { constants, closeSync, fstatSync, lstatSync, linkSync, mkdirSync, openSync, readSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import { withRecordLock } from './registry.js';
|
|
6
|
-
import { mergePresetDefaults, prepareNodeBundleSnapshot, prepareCapabilitySnapshot, verifyCapabilityActivation, resolveCapabilityConfig, MAX_CAPABILITY_SELECTION_BYTES, parseSetupManagement } from '@mearl/preset';
|
|
7
|
-
import { BundleError, MAX_BROWSER_STORAGE_BYTES, presetRequiresBrowser, validateBundleManifest, verifyPreset, verifyBrowserPreset, verifyPreparedPreset } from '@mearl/preset/bundles';
|
|
6
|
+
import { mergePresetDefaults, prepareNodeBundleSnapshot, prepareCapabilitySnapshot, verifyCapabilityActivation, resolveCapabilityConfig, MAX_CAPABILITY_SELECTION_BYTES, parseSetupManagement, } from '@mearl/preset';
|
|
7
|
+
import { BundleError, MAX_BROWSER_STORAGE_BYTES, presetRequiresBrowser, validateBundleManifest, verifyPreset, verifyBrowserPreset, verifyPreparedPreset, } from '@mearl/preset/bundles';
|
|
8
8
|
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
9
9
|
const hashPattern = /^[a-f0-9]{64}$/;
|
|
10
10
|
const MAX_RECEIPT_BYTES = 16 * 1024 * 1024;
|
|
@@ -56,7 +56,8 @@ function atomicWrite(file, bytes) {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
function revision(state) {
|
|
59
|
-
return state.activation?.installationRevision ??
|
|
59
|
+
return (state.activation?.installationRevision ??
|
|
60
|
+
(state.activation ? `node:${state.activation.revision}` : null));
|
|
60
61
|
}
|
|
61
62
|
function overrides(value) {
|
|
62
63
|
if (Object.hasOwn(value, 'management'))
|
|
@@ -72,7 +73,8 @@ export class PresetInstallationStore {
|
|
|
72
73
|
mearlApiVersion;
|
|
73
74
|
pointer;
|
|
74
75
|
constructor(options = {}) {
|
|
75
|
-
this.directory = resolve(options.directory ??
|
|
76
|
+
this.directory = resolve(options.directory ??
|
|
77
|
+
(process.env.MEARL_CAPABILITY_DIR || join(homedir(), '.mearl/capabilities')));
|
|
76
78
|
this.mearlApiVersion = options.mearlApiVersion ?? '1.0.0';
|
|
77
79
|
this.pointer = join(this.directory, 'active-node.json');
|
|
78
80
|
}
|
|
@@ -94,10 +96,28 @@ export class PresetInstallationStore {
|
|
|
94
96
|
const receiptBytes = readBounded(join(this.directory, 'installations', `${activation.installationRevision}.json`), MAX_RECEIPT_BYTES);
|
|
95
97
|
const receipt = JSON.parse(receiptBytes.toString('utf8'));
|
|
96
98
|
const { revision: receiptRevision, ...data } = receipt;
|
|
97
|
-
if (receiptRevision !== activation.installationRevision ||
|
|
98
|
-
|
|
99
|
-
data.
|
|
100
|
-
|
|
99
|
+
if (receiptRevision !== activation.installationRevision ||
|
|
100
|
+
digest(JSON.stringify(data)) !== receiptRevision ||
|
|
101
|
+
data.schemaVersion !== 1 ||
|
|
102
|
+
Object.keys(data).some(key => ![
|
|
103
|
+
'schemaVersion',
|
|
104
|
+
'snapshot',
|
|
105
|
+
'bundles',
|
|
106
|
+
'browserRevision',
|
|
107
|
+
'nodeRevision',
|
|
108
|
+
'managedModules',
|
|
109
|
+
'nodeSyncRequired',
|
|
110
|
+
'browserSyncRequired',
|
|
111
|
+
'overrides',
|
|
112
|
+
].includes(key)) ||
|
|
113
|
+
typeof data.nodeSyncRequired !== 'boolean' ||
|
|
114
|
+
(data.browserSyncRequired !== undefined && typeof data.browserSyncRequired !== 'boolean') ||
|
|
115
|
+
data.nodeRevision !== activation.revision ||
|
|
116
|
+
!Array.isArray(data.bundles) ||
|
|
117
|
+
data.bundles.length > 64 ||
|
|
118
|
+
!Array.isArray(data.managedModules) ||
|
|
119
|
+
new Set(data.managedModules).size !== data.managedModules.length ||
|
|
120
|
+
data.managedModules.some(id => typeof id !== 'string' || !Object.hasOwn(activation.modules, id))) {
|
|
101
121
|
throw new BundleError('BUNDLE_INTEGRITY', 'Installed preset receipt does not match its selection');
|
|
102
122
|
}
|
|
103
123
|
const acceptedOverrides = overrides(data.overrides);
|
|
@@ -111,8 +131,18 @@ export class PresetInstallationStore {
|
|
|
111
131
|
});
|
|
112
132
|
if (total > MAX_BROWSER_STORAGE_BYTES)
|
|
113
133
|
throw new BundleError('BUNDLE_QUOTA', 'Installed preset resources exceed 64 MiB');
|
|
114
|
-
const bundles = options.verifyResources === false
|
|
115
|
-
|
|
134
|
+
const bundles = options.verifyResources === false
|
|
135
|
+
? []
|
|
136
|
+
: metadata.map(bundle => ({
|
|
137
|
+
...bundle,
|
|
138
|
+
resources: Object.fromEntries(Object.entries(bundle.manifest.resources).map(([name, entry]) => [
|
|
139
|
+
name,
|
|
140
|
+
readBounded(join(this.directory, 'blobs', `${entry.sha256}.bin`), entry.byteLength),
|
|
141
|
+
])),
|
|
142
|
+
}));
|
|
143
|
+
for (const entry of options.verifyResources === false
|
|
144
|
+
? []
|
|
145
|
+
: Object.values(activation.modules)) {
|
|
116
146
|
const bytes = readBounded(join(this.directory, 'objects', `${entry.sha256}.cjs`), entry.byteLength);
|
|
117
147
|
if (bytes.byteLength !== entry.byteLength || digest(bytes) !== entry.sha256)
|
|
118
148
|
throw new BundleError('BUNDLE_INTEGRITY', 'Installed Node object is missing or corrupt');
|
|
@@ -121,15 +151,21 @@ export class PresetInstallationStore {
|
|
|
121
151
|
});
|
|
122
152
|
if (options.verifyResources === false) {
|
|
123
153
|
const graph = await verifyPreset(captured.receipt.snapshot, this.mearlApiVersion);
|
|
124
|
-
await verifyBrowserPreset({
|
|
125
|
-
|
|
154
|
+
await verifyBrowserPreset({
|
|
155
|
+
source: graph.snapshot.source,
|
|
156
|
+
manifests: graph.snapshot.manifests,
|
|
157
|
+
packageTargets: graph.snapshot.packageTargets,
|
|
158
|
+
bundleRevisions: Object.fromEntries(Object.entries(graph.snapshot.bundleRevisions).filter(([id]) => graph.preset.packages[id].targets?.includes('browser'))),
|
|
159
|
+
revision: captured.receipt.browserRevision,
|
|
160
|
+
}, this.mearlApiVersion);
|
|
126
161
|
return { rawHash, activation, receipt: captured.receipt, preset: graph.preset };
|
|
127
162
|
}
|
|
128
163
|
const prepared = await verifyPreparedPreset({ snapshot: captured.receipt.snapshot, bundles: captured.bundles }, this.mearlApiVersion);
|
|
129
164
|
if (prepared.browser.snapshot.revision !== captured.receipt.browserRevision)
|
|
130
165
|
throw new BundleError('BUNDLE_INTEGRITY', 'Installed browser projection does not match');
|
|
131
166
|
const selected = await prepareNodeBundleSnapshot(prepared.bundles.filter(bundle => prepared.preset.packages[bundle.manifest.id].targets?.includes('node')), 'native-host', this.mearlApiVersion, resolveCapabilityConfig(mergePresetDefaults(prepared.preset.defaults, captured.receipt.overrides)));
|
|
132
|
-
if (Object.keys(selected.snapshot.modules).length !== captured.receipt.managedModules.length ||
|
|
167
|
+
if (Object.keys(selected.snapshot.modules).length !== captured.receipt.managedModules.length ||
|
|
168
|
+
captured.receipt.managedModules.some(id => !Object.hasOwn(selected.snapshot.modules, id)) ||
|
|
133
169
|
Object.entries(selected.snapshot.modules).some(([id, entry]) => JSON.stringify(entry) !== JSON.stringify(activation.modules[id])) ||
|
|
134
170
|
Object.entries(selected.snapshot.bindings).some(([hook, entry]) => JSON.stringify(entry) !== JSON.stringify(activation.bindings[hook])) ||
|
|
135
171
|
Object.values(activation.bindings).filter(entry => captured.receipt.managedModules.includes(entry.module)).length !== Object.keys(selected.snapshot.bindings).length) {
|
|
@@ -140,7 +176,10 @@ export class PresetInstallationStore {
|
|
|
140
176
|
async apply(input, options) {
|
|
141
177
|
options.signal?.throwIfAborted();
|
|
142
178
|
const acceptedOverrides = options.overrides === undefined ? undefined : overrides(options.overrides);
|
|
143
|
-
const [current, prepared] = await Promise.all([
|
|
179
|
+
const [current, prepared] = await Promise.all([
|
|
180
|
+
this.current({ verifyResources: false }),
|
|
181
|
+
verifyPreparedPreset(input, this.mearlApiVersion),
|
|
182
|
+
]);
|
|
144
183
|
if (revision(current) !== options.expectedRevision)
|
|
145
184
|
throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed');
|
|
146
185
|
const selected = await prepareNodeBundleSnapshot(prepared.bundles.filter(bundle => prepared.preset.packages[bundle.manifest.id].targets?.includes('node')), 'native-host', this.mearlApiVersion, resolveCapabilityConfig(mergePresetDefaults(prepared.preset.defaults, acceptedOverrides ?? current.receipt?.overrides ?? {})));
|
|
@@ -156,20 +195,36 @@ export class PresetInstallationStore {
|
|
|
156
195
|
const bindings = Object.fromEntries(Object.entries(current.activation?.bindings ?? {}).filter(([, entry]) => Object.hasOwn(unmanaged, entry.module)));
|
|
157
196
|
if (Object.keys(selected.snapshot.bindings).some(hook => Object.hasOwn(bindings, hook)))
|
|
158
197
|
throw new BundleError('BUNDLE_CONFLICT', 'A preset hook conflicts with an independently installed Node module');
|
|
159
|
-
const snapshot = await prepareCapabilitySnapshot({
|
|
198
|
+
const snapshot = await prepareCapabilitySnapshot({
|
|
199
|
+
schemaVersion: 1,
|
|
200
|
+
modules: { ...unmanaged, ...selected.snapshot.modules },
|
|
201
|
+
bindings: { ...bindings, ...selected.snapshot.bindings },
|
|
202
|
+
});
|
|
160
203
|
const data = {
|
|
161
|
-
schemaVersion: 1,
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
204
|
+
schemaVersion: 1,
|
|
205
|
+
snapshot: prepared.snapshot,
|
|
206
|
+
bundles: prepared.bundles.map(({ source, revision, manifest }) => ({
|
|
207
|
+
source,
|
|
208
|
+
revision,
|
|
209
|
+
manifest,
|
|
210
|
+
})),
|
|
211
|
+
browserRevision: prepared.browser.snapshot.revision,
|
|
212
|
+
nodeRevision: snapshot.revision,
|
|
213
|
+
managedModules: Object.keys(selected.snapshot.modules),
|
|
214
|
+
overrides: acceptedOverrides ?? current.receipt?.overrides ?? {},
|
|
215
|
+
nodeSyncRequired: Boolean(current.receipt?.nodeSyncRequired ||
|
|
216
|
+
current.receipt?.managedModules.length ||
|
|
217
|
+
Object.values(prepared.preset.packages).some(entry => entry.enabled !== false && entry.targets?.includes('node'))),
|
|
166
218
|
browserSyncRequired: Boolean(current.receipt?.browserSyncRequired || presetRequiresBrowser(prepared.preset)),
|
|
167
219
|
};
|
|
168
220
|
const receipt = { ...data, revision: digest(JSON.stringify(data)) };
|
|
169
221
|
const receiptBytes = JSON.stringify(receipt);
|
|
170
222
|
if (Buffer.byteLength(receiptBytes) > MAX_RECEIPT_BYTES)
|
|
171
223
|
throw new BundleError('BUNDLE_QUOTA', 'Preset installation receipt exceeds 16 MiB');
|
|
172
|
-
const activation = {
|
|
224
|
+
const activation = {
|
|
225
|
+
...snapshot,
|
|
226
|
+
installationRevision: receipt.revision,
|
|
227
|
+
};
|
|
173
228
|
await options.confirm?.();
|
|
174
229
|
withRecordLock(this.pointer, () => {
|
|
175
230
|
this.assertCurrent(current.rawHash);
|
|
@@ -198,6 +253,29 @@ export class PresetInstallationStore {
|
|
|
198
253
|
});
|
|
199
254
|
return receipt;
|
|
200
255
|
}
|
|
256
|
+
async uninstall(expectedRevision, beforeRemove) {
|
|
257
|
+
const current = await this.current({ verifyResources: false });
|
|
258
|
+
if (current.receipt?.revision !== expectedRevision || !current.activation || !current.rawHash) {
|
|
259
|
+
throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed before removal');
|
|
260
|
+
}
|
|
261
|
+
const managed = new Set(current.receipt.managedModules);
|
|
262
|
+
const modules = Object.fromEntries(Object.entries(current.activation.modules).filter(([id]) => !managed.has(id)));
|
|
263
|
+
const bindings = Object.fromEntries(Object.entries(current.activation.bindings).filter(([, entry]) => Object.hasOwn(modules, entry.module)));
|
|
264
|
+
const remaining = Object.keys(modules).length
|
|
265
|
+
? await prepareCapabilitySnapshot({ schemaVersion: 1, modules, bindings })
|
|
266
|
+
: undefined;
|
|
267
|
+
const sha256 = current.rawHash;
|
|
268
|
+
withRecordLock(this.pointer, () => {
|
|
269
|
+
this.assertCurrent(sha256);
|
|
270
|
+
beforeRemove?.({ path: this.pointer, sha256 });
|
|
271
|
+
rmSync(join(this.directory, 'rollback.json'), { force: true });
|
|
272
|
+
if (remaining)
|
|
273
|
+
atomicWrite(this.pointer, JSON.stringify(remaining));
|
|
274
|
+
else
|
|
275
|
+
rmSync(this.pointer);
|
|
276
|
+
});
|
|
277
|
+
return remaining;
|
|
278
|
+
}
|
|
201
279
|
async rollback(expectedRevision) {
|
|
202
280
|
const current = await this.current({ verifyResources: false });
|
|
203
281
|
if (current.activation?.installationRevision !== expectedRevision)
|
|
@@ -220,7 +298,11 @@ export class PresetInstallationStore {
|
|
|
220
298
|
const current = await this.current({ verifyResources: false });
|
|
221
299
|
const raw = optionalFile(join(this.directory, 'rollback.json'), POINTER_BYTES + 256);
|
|
222
300
|
const record = raw && JSON.parse(raw.toString('utf8'));
|
|
223
|
-
const previous = record
|
|
301
|
+
const previous = record &&
|
|
302
|
+
record.candidate === current.activation?.installationRevision &&
|
|
303
|
+
record.previous !== null
|
|
304
|
+
? await verifyCapabilityActivation(record.previous)
|
|
305
|
+
: undefined;
|
|
224
306
|
withRecordLock(this.pointer, () => {
|
|
225
307
|
this.assertCurrent(current.rawHash);
|
|
226
308
|
for (const child of ['blobs', 'objects', 'installations'])
|
|
@@ -238,7 +320,11 @@ export class PresetInstallationStore {
|
|
|
238
320
|
for (const entry of Object.values(bundle.manifest.resources))
|
|
239
321
|
blobs.add(entry.sha256);
|
|
240
322
|
}
|
|
241
|
-
for (const [directory, extension, retained] of [
|
|
323
|
+
for (const [directory, extension, retained] of [
|
|
324
|
+
['blobs', '.bin', blobs],
|
|
325
|
+
['objects', '.cjs', modules],
|
|
326
|
+
['installations', '.json', receipts],
|
|
327
|
+
]) {
|
|
242
328
|
for (const name of readdirSync(join(this.directory, directory))) {
|
|
243
329
|
const id = name.slice(0, -extension.length);
|
|
244
330
|
if (name.endsWith(extension) && hashPattern.test(id) && !retained.has(id))
|
|
@@ -247,7 +333,9 @@ export class PresetInstallationStore {
|
|
|
247
333
|
}
|
|
248
334
|
}
|
|
249
335
|
}
|
|
250
|
-
export function presetSelectionRevision(state) {
|
|
336
|
+
export function presetSelectionRevision(state) {
|
|
337
|
+
return revision(state);
|
|
338
|
+
}
|
|
251
339
|
/** Reads accepted local metadata without loading capability factories, browsers or network sources. */
|
|
252
340
|
export async function readInstallationManagement(options = {}) {
|
|
253
341
|
const current = await new PresetInstallationStore(options).current({ verifyResources: false });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mearl/daemon-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"description": "Shared Node daemon lifecycle primitives for Mearl — process liveness, JSON record registry, log rotation, and a detached daemon manager",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"typescript": "^5.8.3"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@mearl/preset": "3.0.
|
|
47
|
+
"@mearl/preset": "3.0.2"
|
|
48
48
|
},
|
|
49
49
|
"repository": {
|
|
50
50
|
"type": "git",
|