@mearl/daemon-core 3.0.1 → 3.0.3

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.
@@ -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 ?? (state.activation ? `node:${state.activation.revision}` : null);
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 ?? (process.env.MEARL_CAPABILITY_DIR || join(homedir(), '.mearl/capabilities')));
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 || digest(JSON.stringify(data)) !== receiptRevision || data.schemaVersion !== 1 ||
98
- Object.keys(data).some(key => !['schemaVersion', 'snapshot', 'bundles', 'browserRevision', 'nodeRevision', 'managedModules', 'nodeSyncRequired', 'browserSyncRequired', 'overrides'].includes(key)) || typeof data.nodeSyncRequired !== 'boolean' || (data.browserSyncRequired !== undefined && typeof data.browserSyncRequired !== 'boolean') ||
99
- data.nodeRevision !== activation.revision || !Array.isArray(data.bundles) || data.bundles.length > 64 ||
100
- !Array.isArray(data.managedModules) || new Set(data.managedModules).size !== data.managedModules.length || data.managedModules.some(id => typeof id !== 'string' || !Object.hasOwn(activation.modules, id))) {
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 ? [] : metadata.map(bundle => ({ ...bundle, resources: Object.fromEntries(Object.entries(bundle.manifest.resources).map(([name, entry]) => [name, readBounded(join(this.directory, 'blobs', `${entry.sha256}.bin`), entry.byteLength)])) }));
115
- for (const entry of options.verifyResources === false ? [] : Object.values(activation.modules)) {
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({ source: graph.snapshot.source, manifests: graph.snapshot.manifests, packageTargets: graph.snapshot.packageTargets,
125
- bundleRevisions: Object.fromEntries(Object.entries(graph.snapshot.bundleRevisions).filter(([id]) => graph.preset.packages[id].targets?.includes('browser'))), revision: captured.receipt.browserRevision }, this.mearlApiVersion);
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 || captured.receipt.managedModules.some(id => !Object.hasOwn(selected.snapshot.modules, id)) ||
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([this.current({ verifyResources: false }), verifyPreparedPreset(input, this.mearlApiVersion)]);
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({ schemaVersion: 1, modules: { ...unmanaged, ...selected.snapshot.modules }, bindings: { ...bindings, ...selected.snapshot.bindings } });
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, snapshot: prepared.snapshot,
162
- bundles: prepared.bundles.map(({ source, revision, manifest }) => ({ source, revision, manifest })),
163
- browserRevision: prepared.browser.snapshot.revision, nodeRevision: snapshot.revision,
164
- managedModules: Object.keys(selected.snapshot.modules), overrides: acceptedOverrides ?? current.receipt?.overrides ?? {},
165
- nodeSyncRequired: Boolean(current.receipt?.nodeSyncRequired || current.receipt?.managedModules.length || Object.values(prepared.preset.packages).some(entry => entry.enabled !== false && entry.targets?.includes('node'))),
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 = { ...snapshot, installationRevision: receipt.revision };
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?.candidate === current.activation?.installationRevision && record.previous !== null ? await verifyCapabilityActivation(record.previous) : undefined;
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 [['blobs', '.bin', blobs], ['objects', '.cjs', modules], ['installations', '.json', receipts]]) {
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) { return revision(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.1",
3
+ "version": "3.0.3",
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.1"
47
+ "@mearl/preset": "3.0.3"
48
48
  },
49
49
  "repository": {
50
50
  "type": "git",