@mearl/daemon-core 3.0.0-beta.1 → 3.0.0-beta.10

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.md CHANGED
@@ -1,79 +1,81 @@
1
1
  # @mearl/daemon-core
2
2
 
3
- Mearl 各 Node 包共享的守护进程生命周期原语:进程存活探测、JSON 记录注册表、日志滚动,以及一个 detached 守护进程管理器。
3
+ [English](README.md) | [简体中文](README.zh-CN.md)
4
4
 
5
- 把 `cloud-server`、`cloud-connector`、`native-host` 里原本各写一份、容易彼此漂移的 daemon 逻辑(`isAlive`、原子读写注册表、日志滚动、按 key 派生路径)收敛到这一处,确保「写方」和「读方」对**记录位置与格式**始终一致。
5
+ Daemon lifecycle primitives shared by Mearl's Node packages: process liveness, JSON registration records, log rotation, and a detached daemon manager.
6
6
 
7
- 此包只在 Node 中运行,可以作为普通依赖使用,也可以由执行宿主打包内联。
7
+ `cloud-server`, `cloud-connector`, and `native-host` share daemon operations (`isAlive`, atomic registry reads and writes, log rotation, and keyed paths) here so all readers and writers agree on **record locations and formats**.
8
+
9
+ This Node-only package can be used as a normal dependency or inlined into an execution host bundle.
8
10
 
9
11
  ## API
10
12
 
11
- ### 进程存活
13
+ ### Process liveness
12
14
 
13
15
  ```typescript
14
16
  isAlive(pid: number | undefined | null): boolean
15
17
  isProcessGroupAlive(group: number): boolean
16
18
  ```
17
19
 
18
- 用 signal-0 探测进程是否存活(`EPERM` 视为存活——进程存在但属于他人)。Linux 同时读取 `/proc/<pid>/stat`,将已退出、尚未回收的 zombie 和 dead 状态视为已停止;主线程退出但仍有其他线程时,以及无法读取状态时,保守视为存活。
20
+ Uses signal-0 to probe liveness. `EPERM` counts as alive: the process exists but belongs to another user. On Linux, `/proc/<pid>/stat` identifies exited, unreaped zombie and dead processes as stopped. If other threads remain after the main thread exits, or status cannot be read, it conservatively treats the process as alive.
19
21
 
20
- POSIX 进程组检查包含根进程退出后仍存活的后代。Linux 只有在两次检查得到相同的已退出成员及创建身份时,才确认组内执行已停止;活跃后代、成员变化和权限或读取失败都会保留清理未确认状态。Windows 不使用 POSIX 进程组检查。
22
+ POSIX process-group checks include descendants surviving the root process. Linux confirms execution has stopped only when two checks find the same exited members and creation identities. Active descendants, membership changes, permission errors, or read failures leave cleanup unconfirmed. Windows does not use POSIX process-group checks.
21
23
 
22
- ### 路径派生(多实例)
24
+ ### Path derivation (multiple instances)
23
25
 
24
26
  ```typescript
25
27
  DAEMON_HOME // ~/.mearl
26
- hashKey(key: string): string // sha256(key) 前 16 位十六进制
28
+ hashKey(key: string): string // First 16 hexadecimal digits of sha256(key)
27
29
  keyedDir(subdir: string): string // ~/.mearl/<subdir>
28
30
  keyedConfigFile(subdir, key): string // ~/.mearl/<subdir>/<hash>.json
29
31
  keyedLogFile(subdir, key): string // ~/.mearl/<subdir>/<hash>.log
30
32
  ```
31
33
 
32
- 多实例子系统(如按 `serverUrl` 区分的连接器)用这些 helper 派生每实例的文件路径。**所有读写方都必须经由它们派生路径**,这样彼此才不会对记录位置产生分歧。
34
+ Multi-instance subsystems, such as connectors keyed by `serverUrl`, derive per-instance paths with these helpers. **All readers and writers must use them** to agree on record locations.
33
35
 
34
- ### 记录注册表
36
+ ### Record registry
35
37
 
36
38
  ```typescript
37
39
  interface DaemonRecord { pid: number; startedAt?: string; [k: string]: unknown }
38
40
 
39
41
  ensureDir(dir: string): void
40
- writeRecord(file: string, record: DaemonRecord): void // 跨进程加锁,临时文件 + rename 原子写
41
- claimRecord(file: string, record: DaemonRecord): boolean // 仅在文件不存在时原子占用
42
+ writeRecord(file: string, record: DaemonRecord): void // Cross-process lock; atomic temporary file + rename
43
+ claimRecord(file: string, record: DaemonRecord): boolean // Atomically claim only if absent
42
44
  readRecord<T>(file: string): T | null
43
45
  removeRecord(file: string): void
44
- removeRecordIfPid(file: string, pid: number): boolean // 仅删除指定进程仍拥有的记录
46
+ removeRecordIfPid(file: string, pid: number): boolean // Remove only if still owned by this PID
45
47
  writeRecordIfPid(file: string, pid: number, record: DaemonRecord): boolean
46
- removeInvalidRecord(file: string): boolean // 清理无法解析的残留记录
47
- listRecordFiles(dir: string): string[] // 目录下所有 *.json 绝对路径
48
- readLiveRecords<T>(dir: string): T[] // 读取存活记录,并顺带清理死记录
48
+ removeInvalidRecord(file: string): boolean // Clean up unparseable records
49
+ listRecordFiles(dir: string): string[] // Absolute paths of all *.json files
50
+ readLiveRecords<T>(dir: string): T[] // Read live records and clean up dead ones
49
51
  ```
50
52
 
51
- ### 守护进程管理器
53
+ ### Daemon manager
52
54
 
53
55
  ```typescript
54
56
  createDaemon(opts: CreateDaemonOptions): Daemon
55
57
  ```
56
58
 
57
- 围绕一对 `(configFile, logFile)` 构建单实例守护进程管理器。`start()` 先原子占用启动锁,
58
- 再以 `--foreground` detached 方式拉起消费方 CLI,并立即写入带子进程 pid 的 `starting`
59
- 记录。子进程就绪后覆盖该记录,父进程据此确认启动完成。并发 `start()` 会复用正在启动或
60
- 已经运行的子进程。多实例子系统按实例 key 派生各自的 `configFile` / `logFile`。
59
+ Builds a single-instance daemon manager around `(configFile, logFile)`. `start()` atomically claims a startup lock,
60
+ launches the consumer CLI detached with `--foreground`, and immediately writes a `starting` record
61
+ with the child PID. The ready child overwrites the record, allowing the parent to confirm startup. Concurrent `start()` calls reuse a starting
62
+ or running child. Multi-instance subsystems derive their own `configFile` / `logFile` from the instance key.
61
63
 
62
64
  ```typescript
63
65
  interface CreateDaemonOptions {
64
- name: string; // 日志标签,如 'CloudServer' / 'CloudConnector'
65
- scriptPath: string; // 可重新拉起的 CLI 脚本绝对路径(消费方打包后的 cli.js)
66
- configFile: string; // 本实例的 JSON 记录文件(由前台子进程写入)
67
- logFile: string; // 本实例的日志文件
68
- foregroundArgs?: string[]; // 追加在 `--foreground` 之后的 argv
69
- readyTimeoutMs?: number; // 等待子进程写记录的超时(默认 15000ms)
70
- formatInfo?: (record: DaemonRecord) => string | null; // 起来后额外打印的信息
66
+ name: string; // Log label, e.g. 'CloudServer' / 'CloudConnector'
67
+ scriptPath: string; // Absolute path to the consumer's bundled cli.js
68
+ configFile: string; // Instance JSON record written by the foreground child
69
+ logFile: string; // Instance log file
70
+ foregroundArgs?: string[]; // argv appended after `--foreground`
71
+ readyTimeoutMs?: number; // Child record timeout, default 15000ms
72
+ formatInfo?: (record: DaemonRecord) => string | null; // Additional startup information
71
73
  startingRecord?: (pid: number, startedAt: string) => DaemonRecord;
72
74
  }
73
75
 
74
76
  interface Daemon {
75
77
  start(): Promise<void>;
76
- stop(expectedPid?: number): Promise<void>; // 指定 PID 时仅停止仍匹配的实例
78
+ stop(expectedPid?: number): Promise<void>; // Stop only the matching instance when PID is supplied
77
79
  restart(): Promise<void>;
78
80
  status(): void;
79
81
  logs(lines?: number): void;
@@ -81,20 +83,20 @@ interface Daemon {
81
83
  }
82
84
  ```
83
85
 
84
- > `scriptPath` 必须是消费方自己的 `cli.js`:用打包进 cli 入口的某个模块里的 `fileURLToPath(import.meta.url)` 计算。daemon-core 自身被独立解析时,它的 `import.meta.url` 指向 daemon-core,而不是 cli。
86
+ > `scriptPath` must identify the consumer's own `cli.js`. Derive it with `fileURLToPath(import.meta.url)` in a module bundled into the CLI entry. If daemon-core is resolved separately, its `import.meta.url` points to daemon-core, not the CLI.
85
87
 
86
- ### 日志
88
+ ### Logs
87
89
 
88
90
  ```typescript
89
- rotateLogIfNeeded(logFile: string, maxBytes?: number): void // 超限则滚动为 <log>.1,默认 5MB
91
+ rotateLogIfNeeded(logFile: string, maxBytes?: number): void // Rotate to <log>.1 above limit; default 5MB
90
92
  tailFile(logFile: string, lines: number): string
91
93
  ```
92
94
 
93
- ## 使用方
95
+ ## Consumers
94
96
 
95
- - **cloud-server** —— `createDaemon`(单实例,`~/.mearl/cloud-server.json`)
96
- - **cloud-connector** —— `createDaemon`(多实例,按 `serverUrl` 区分,`~/.mearl/connectors/<hash>.json`)+ `readLiveRecords` 列举
97
- - **native-host** —— 仅用原语(`isAlive` / `keyedConfigFile` / `readRecord` / `readLiveRecords` 等)读取连接器共享注册表,以发现/接管/列举连接器
97
+ - **cloud-server** — `createDaemon`, single instance at `~/.mearl/cloud-server.json`.
98
+ - **cloud-connector** — `createDaemon`, instances keyed by `serverUrl` at `~/.mearl/connectors/<hash>.json`, listed through `readLiveRecords`.
99
+ - **native-host** — Uses primitives such as `isAlive` / `keyedConfigFile` / `readRecord` / `readLiveRecords` to discover, take over, and list connectors through their shared registry.
98
100
 
99
101
 
100
- `@mearl/daemon-core/capabilities` 的 `NodeCapabilityRegistry` 可在第三个构造参数传入 `hooks`,只加载这些绑定引用的模块。选择本身仍完整校验,目标模块字节按摘要验证,提交仍核对当前选择;忽略的模块不执行。该局部实例适用于安装 CLI 等只需要少量本机服务的宿主,不替代完整 Native Host 的激活与就绪回执。省略 `hooks` 时保持完整加载。
102
+ `NodeCapabilityRegistry` from `@mearl/daemon-core/capabilities` accepts `hooks` in its third constructor argument to load only modules referenced by those bindings. The whole selection is still validated, target module bytes are digest-verified, and commits still check the current selection. Ignored modules do not execute. This partial instance suits hosts needing a few native services, such as the installer CLI; it does not replace full Native Host activation and readiness receipts. Omit `hooks` for full loading.
@@ -0,0 +1,102 @@
1
+ # @mearl/daemon-core
2
+
3
+ [English](README.md) | [简体中文](README.zh-CN.md)
4
+
5
+ Mearl 各 Node 包共享的守护进程生命周期原语:进程存活探测、JSON 记录注册表、日志滚动,以及一个 detached 守护进程管理器。
6
+
7
+ 把 `cloud-server`、`cloud-connector`、`native-host` 里原本各写一份、容易彼此漂移的 daemon 逻辑(`isAlive`、原子读写注册表、日志滚动、按 key 派生路径)收敛到这一处,确保「写方」和「读方」对**记录位置与格式**始终一致。
8
+
9
+ 此包只在 Node 中运行,可以作为普通依赖使用,也可以由执行宿主打包内联。
10
+
11
+ ## API
12
+
13
+ ### 进程存活
14
+
15
+ ```typescript
16
+ isAlive(pid: number | undefined | null): boolean
17
+ isProcessGroupAlive(group: number): boolean
18
+ ```
19
+
20
+ 用 signal-0 探测进程是否存活(`EPERM` 视为存活——进程存在但属于他人)。Linux 同时读取 `/proc/<pid>/stat`,将已退出、尚未回收的 zombie 和 dead 状态视为已停止;主线程退出但仍有其他线程时,以及无法读取状态时,保守视为存活。
21
+
22
+ POSIX 进程组检查包含根进程退出后仍存活的后代。Linux 只有在两次检查得到相同的已退出成员及创建身份时,才确认组内执行已停止;活跃后代、成员变化和权限或读取失败都会保留清理未确认状态。Windows 不使用 POSIX 进程组检查。
23
+
24
+ ### 路径派生(多实例)
25
+
26
+ ```typescript
27
+ DAEMON_HOME // ~/.mearl
28
+ hashKey(key: string): string // sha256(key) 前 16 位十六进制
29
+ keyedDir(subdir: string): string // ~/.mearl/<subdir>
30
+ keyedConfigFile(subdir, key): string // ~/.mearl/<subdir>/<hash>.json
31
+ keyedLogFile(subdir, key): string // ~/.mearl/<subdir>/<hash>.log
32
+ ```
33
+
34
+ 多实例子系统(如按 `serverUrl` 区分的连接器)用这些 helper 派生每实例的文件路径。**所有读写方都必须经由它们派生路径**,这样彼此才不会对记录位置产生分歧。
35
+
36
+ ### 记录注册表
37
+
38
+ ```typescript
39
+ interface DaemonRecord { pid: number; startedAt?: string; [k: string]: unknown }
40
+
41
+ ensureDir(dir: string): void
42
+ writeRecord(file: string, record: DaemonRecord): void // 跨进程加锁,临时文件 + rename 原子写
43
+ claimRecord(file: string, record: DaemonRecord): boolean // 仅在文件不存在时原子占用
44
+ readRecord<T>(file: string): T | null
45
+ removeRecord(file: string): void
46
+ removeRecordIfPid(file: string, pid: number): boolean // 仅删除指定进程仍拥有的记录
47
+ writeRecordIfPid(file: string, pid: number, record: DaemonRecord): boolean
48
+ removeInvalidRecord(file: string): boolean // 清理无法解析的残留记录
49
+ listRecordFiles(dir: string): string[] // 目录下所有 *.json 绝对路径
50
+ readLiveRecords<T>(dir: string): T[] // 读取存活记录,并顺带清理死记录
51
+ ```
52
+
53
+ ### 守护进程管理器
54
+
55
+ ```typescript
56
+ createDaemon(opts: CreateDaemonOptions): Daemon
57
+ ```
58
+
59
+ 围绕一对 `(configFile, logFile)` 构建单实例守护进程管理器。`start()` 先原子占用启动锁,
60
+ 再以 `--foreground` detached 方式拉起消费方 CLI,并立即写入带子进程 pid 的 `starting`
61
+ 记录。子进程就绪后覆盖该记录,父进程据此确认启动完成。并发 `start()` 会复用正在启动或
62
+ 已经运行的子进程。多实例子系统按实例 key 派生各自的 `configFile` / `logFile`。
63
+
64
+ ```typescript
65
+ interface CreateDaemonOptions {
66
+ name: string; // 日志标签,如 'CloudServer' / 'CloudConnector'
67
+ scriptPath: string; // 可重新拉起的 CLI 脚本绝对路径(消费方打包后的 cli.js)
68
+ configFile: string; // 本实例的 JSON 记录文件(由前台子进程写入)
69
+ logFile: string; // 本实例的日志文件
70
+ foregroundArgs?: string[]; // 追加在 `--foreground` 之后的 argv
71
+ readyTimeoutMs?: number; // 等待子进程写记录的超时(默认 15000ms)
72
+ formatInfo?: (record: DaemonRecord) => string | null; // 起来后额外打印的信息
73
+ startingRecord?: (pid: number, startedAt: string) => DaemonRecord;
74
+ }
75
+
76
+ interface Daemon {
77
+ start(): Promise<void>;
78
+ stop(expectedPid?: number): Promise<void>; // 指定 PID 时仅停止仍匹配的实例
79
+ restart(): Promise<void>;
80
+ status(): void;
81
+ logs(lines?: number): void;
82
+ running(): DaemonRecord | null;
83
+ }
84
+ ```
85
+
86
+ > `scriptPath` 必须是消费方自己的 `cli.js`:用打包进 cli 入口的某个模块里的 `fileURLToPath(import.meta.url)` 计算。daemon-core 自身被独立解析时,它的 `import.meta.url` 指向 daemon-core,而不是 cli。
87
+
88
+ ### 日志
89
+
90
+ ```typescript
91
+ rotateLogIfNeeded(logFile: string, maxBytes?: number): void // 超限则滚动为 <log>.1,默认 5MB
92
+ tailFile(logFile: string, lines: number): string
93
+ ```
94
+
95
+ ## 使用方
96
+
97
+ - **cloud-server** —— `createDaemon`(单实例,`~/.mearl/cloud-server.json`)
98
+ - **cloud-connector** —— `createDaemon`(多实例,按 `serverUrl` 区分,`~/.mearl/connectors/<hash>.json`)+ `readLiveRecords` 列举
99
+ - **native-host** —— 仅用原语(`isAlive` / `keyedConfigFile` / `readRecord` / `readLiveRecords` 等)读取连接器共享注册表,以发现/接管/列举连接器
100
+
101
+
102
+ `@mearl/daemon-core/capabilities` 的 `NodeCapabilityRegistry` 可在第三个构造参数传入 `hooks`,只加载这些绑定引用的模块。选择本身仍完整校验,目标模块字节按摘要验证,提交仍核对当前选择;忽略的模块不执行。该局部实例适用于安装 CLI 等只需要少量本机服务的宿主,不替代完整 Native Host 的激活与就绪回执。省略 `hooks` 时保持完整加载。
package/dist/command.d.ts CHANGED
@@ -41,6 +41,8 @@ export declare function resolveSpawnCommand(executable: string, args: string[],
41
41
  export declare function prependCommandPath(command: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
42
42
  export declare function prependNodePath(env?: NodeJS.ProcessEnv, nodeExecutable?: string): NodeJS.ProcessEnv;
43
43
  export declare function prependPathDirectory(directory: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
44
+ /** npm exec's package selector belongs to its invocation, not nested package runners. */
45
+ export declare function npmExecEnvironment(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
44
46
  export declare function findExecutable(name: string, options?: {
45
47
  env?: NodeJS.ProcessEnv;
46
48
  existsSync?: (filePath: string) => boolean;
package/dist/command.js CHANGED
@@ -6,7 +6,16 @@ import { isProcessGroupAlive, isProcessOwnerAlive, processIdentity } from './pro
6
6
  const ownedCommands = new AsyncLocalStorage();
7
7
  /** Accepted installation commands retain process ownership until their complete process group exits. */
8
8
  export function withOwnedCommands(lifecycle, operation) {
9
- return ownedCommands.run(lifecycle, operation);
9
+ const parent = ownedCommands.getStore();
10
+ return ownedCommands.run(parent ? {
11
+ started(owner) { parent.started(owner); lifecycle.started(owner); },
12
+ finished(owner) { try {
13
+ lifecycle.finished(owner);
14
+ }
15
+ finally {
16
+ parent.finished(owner);
17
+ } },
18
+ } : lifecycle, operation);
10
19
  }
11
20
  export function isCommandProcessAlive(owner) {
12
21
  if (!owner.group || process.platform === 'win32')
@@ -118,6 +127,10 @@ export function prependPathDirectory(directory, env = process.env) {
118
127
  return env;
119
128
  return { ...env, [pathKey]: [directory, ...entries].join(path.delimiter) };
120
129
  }
130
+ /** npm exec's package selector belongs to its invocation, not nested package runners. */
131
+ export function npmExecEnvironment(env = process.env) {
132
+ return Object.fromEntries(Object.entries(env).filter(([key]) => key.toLowerCase() !== 'npm_config_package'));
133
+ }
121
134
  export function findExecutable(name, options = {}) {
122
135
  const env = options.env ?? process.env;
123
136
  const platform = options.platform ?? process.platform;
@@ -14,6 +14,7 @@ export declare class NodeCapabilityRegistry<Host extends object = object> {
14
14
  get revision(): string | undefined;
15
15
  get installationRevision(): string | undefined;
16
16
  get<T extends NodeCapabilityHandler>(name: string): T | undefined;
17
+ entries(prefix?: string): Array<[string, NodeCapabilityHandler]>;
17
18
  require<T extends NodeCapabilityHandler>(name: string): T;
18
19
  refresh(): Promise<string | undefined>;
19
20
  private readSelection;
@@ -1,12 +1,13 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { createRequire } from 'node:module';
3
3
  import { Script } from 'node:vm';
4
+ import { isDeepStrictEqual } from 'node:util';
4
5
  import { constants } from 'node:fs';
5
6
  import { lstat, open } from 'node:fs/promises';
6
7
  import { join } from 'node:path';
7
8
  import { DAEMON_HOME } from './paths.js';
8
- import { verifyNodeCapabilityActivation, MAX_NODE_SELECTION_BYTES } from '@mearl/preset';
9
- const MAX_SELECTION_BYTES = MAX_NODE_SELECTION_BYTES + 128;
9
+ import { verifyCapabilityActivation, MAX_CAPABILITY_SELECTION_BYTES } from '@mearl/preset';
10
+ const MAX_SELECTION_BYTES = MAX_CAPABILITY_SELECTION_BYTES + 128;
10
11
  const handlerName = /^[a-z][a-zA-Z0-9_.-]{0,127}$/;
11
12
  async function readBoundedFile(file, limit) {
12
13
  const stat = await lstat(file);
@@ -50,6 +51,9 @@ export class NodeCapabilityRegistry {
50
51
  get revision() { return this.active?.snapshot.revision; }
51
52
  get installationRevision() { return this.active?.snapshot.installationRevision; }
52
53
  get(name) { return this.active && Object.hasOwn(this.active.handlers, name) ? this.active.handlers[name] : undefined; }
54
+ entries(prefix = "") {
55
+ return Object.entries(this.active?.handlers ?? {}).filter(([name]) => name.startsWith(prefix));
56
+ }
53
57
  require(name) {
54
58
  const handler = this.get(name);
55
59
  if (!handler)
@@ -84,7 +88,7 @@ export class NodeCapabilityRegistry {
84
88
  return undefined;
85
89
  throw error;
86
90
  }
87
- return verifyNodeCapabilityActivation(JSON.parse(bytes.toString('utf8')));
91
+ return verifyCapabilityActivation(JSON.parse(bytes.toString('utf8')));
88
92
  }
89
93
  async load(snapshot) {
90
94
  if (!snapshot) {
@@ -104,7 +108,7 @@ export class NodeCapabilityRegistry {
104
108
  const pointer = join(this.directory, 'active-node.json');
105
109
  const bytes = await readBoundedFile(pointer, MAX_SELECTION_BYTES);
106
110
  const requested = JSON.parse(bytes.toString('utf8'));
107
- const current = await verifyNodeCapabilityActivation(requested).catch(() => undefined);
111
+ const current = await verifyCapabilityActivation(requested).catch(() => undefined);
108
112
  if (current && (current.revision !== failed?.revision || current.installationRevision !== failed?.installationRevision)) {
109
113
  try {
110
114
  await this.load(current);
@@ -117,7 +121,7 @@ export class NodeCapabilityRegistry {
117
121
  const record = JSON.parse((await readBoundedFile(join(this.directory, 'rollback.json'), MAX_SELECTION_BYTES + 256)).toString('utf8'));
118
122
  if (record.candidate !== requested.installationRevision || !record.previous)
119
123
  return;
120
- const previous = await verifyNodeCapabilityActivation(record.previous);
124
+ const previous = await verifyCapabilityActivation(record.previous);
121
125
  const expected = createHash('sha256').update(bytes).digest('hex');
122
126
  await this.prepareSelection(previous, async () => {
123
127
  const current = await readBoundedFile(pointer, MAX_SELECTION_BYTES);
@@ -138,6 +142,12 @@ export class NodeCapabilityRegistry {
138
142
  }
139
143
  const modules = new Map();
140
144
  for (const [id, resource] of entries) {
145
+ const previous = this.active?.snapshot.modules[id];
146
+ const reusable = this.active?.modules.get(id);
147
+ if (reusable && previous?.sha256 === resource.sha256 && previous.byteLength === resource.byteLength && isDeepStrictEqual(previous.config ?? {}, resource.config ?? {})) {
148
+ modules.set(id, reusable);
149
+ continue;
150
+ }
141
151
  const bytes = resources.get(id);
142
152
  // CommonJS wrappers can be reclaimed after their active and in-flight handlers are released.
143
153
  const module = { exports: {} };
@@ -163,7 +173,7 @@ export class NodeCapabilityRegistry {
163
173
  const current = await guard();
164
174
  if (!current)
165
175
  throw new Error('Node capability selection changed while preparing modules');
166
- this.active = { snapshot: current, handlers: Object.freeze(handlers) };
176
+ this.active = { snapshot: current, handlers: Object.freeze(handlers), modules };
167
177
  return snapshot.revision;
168
178
  }
169
179
  }
@@ -0,0 +1,27 @@
1
+ import { type PageRuntimeBinding } from '@mearl/preset/bundles';
2
+ export interface PageRuntimeOwner {
3
+ browser: string;
4
+ target: string;
5
+ pid?: number;
6
+ processIdentity?: string;
7
+ }
8
+ export declare const MAX_NODE_PAGE_CACHE_BYTES: number;
9
+ export declare function capabilityDirectory(): string;
10
+ /** Browser references survive host restarts; unavailable remote browsers retain their exact code. */
11
+ export declare class NodePageRuntimeStore {
12
+ private readonly quota;
13
+ private readonly root;
14
+ private readonly index;
15
+ constructor(directory?: string, quota?: number);
16
+ resolve(binding: PageRuntimeBinding, provide?: (binding: PageRuntimeBinding) => string | undefined, owner?: PageRuntimeOwner): string;
17
+ release(owner: PageRuntimeOwner): void;
18
+ retain(owner: PageRuntimeOwner, binding?: PageRuntimeBinding): void;
19
+ /** A live target inventory is authoritative after a disconnected host lost close events. */
20
+ reconcile(browser: string, targets: ReadonlySet<string>): void;
21
+ private key;
22
+ private mutate;
23
+ private collect;
24
+ private read;
25
+ private write;
26
+ private verify;
27
+ }
@@ -0,0 +1,163 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { DAEMON_HOME } from './paths.js';
5
+ import { isProcessOwnerAlive } from './process.js';
6
+ import { withRecordLock } from './registry.js';
7
+ import { MAX_PAGE_RUNTIME_BYTES } from '@mearl/preset/bundles';
8
+ const sha256 = /^[a-f0-9]{64}$/;
9
+ export const MAX_NODE_PAGE_CACHE_BYTES = 64 * 1024 * 1024;
10
+ export function capabilityDirectory() { return process.env.MEARL_CAPABILITY_DIR || join(DAEMON_HOME, 'capabilities'); }
11
+ /** Browser references survive host restarts; unavailable remote browsers retain their exact code. */
12
+ export class NodePageRuntimeStore {
13
+ quota;
14
+ root;
15
+ index;
16
+ constructor(directory = capabilityDirectory(), quota = MAX_NODE_PAGE_CACHE_BYTES) {
17
+ this.quota = quota;
18
+ this.root = join(directory, 'page-runtimes');
19
+ this.index = join(this.root, 'references.json');
20
+ }
21
+ resolve(binding, provide, owner) {
22
+ if (!/^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(binding.id) || !sha256.test(binding.sha256))
23
+ throw new Error('Invalid page runtime binding');
24
+ return this.mutate(references => {
25
+ const file = join(this.root, `${binding.sha256}.cjs`);
26
+ let source;
27
+ if (existsSync(file))
28
+ source = this.verify(binding, this.read(file, MAX_PAGE_RUNTIME_BYTES));
29
+ else {
30
+ const provided = provide?.(binding);
31
+ if (typeof provided !== 'string' || !provided.trim())
32
+ throw new Error(`Required page runtime is not installed: ${binding.id}@${binding.sha256}`);
33
+ const bytes = Buffer.from(provided);
34
+ source = this.verify(binding, bytes);
35
+ this.collect(references, bytes.byteLength, 1);
36
+ this.write(file, bytes);
37
+ }
38
+ if (owner) {
39
+ if (!sha256.test(owner.browser) || !owner.target || owner.target.length > 256)
40
+ throw new Error('Invalid page runtime owner');
41
+ const key = this.key(owner);
42
+ const previous = references.owners[key];
43
+ if (!previous && Object.keys(references.owners).length >= 4096)
44
+ throw new Error('Page runtime reference limit exceeded');
45
+ references.owners[key] = { ...owner, digests: [...new Set([...(previous?.digests ?? []), binding.sha256])] };
46
+ }
47
+ return source;
48
+ });
49
+ }
50
+ release(owner) {
51
+ if (!existsSync(this.index))
52
+ return;
53
+ this.mutate(references => { delete references.owners[this.key(owner)]; this.collect(references); });
54
+ }
55
+ retain(owner, binding) {
56
+ if (!existsSync(this.index))
57
+ return;
58
+ this.mutate(references => {
59
+ const previous = references.owners[this.key(owner)];
60
+ if (previous && binding && previous.digests.includes(binding.sha256))
61
+ previous.digests = [binding.sha256];
62
+ else if (!binding)
63
+ delete references.owners[this.key(owner)];
64
+ this.collect(references);
65
+ });
66
+ }
67
+ /** A live target inventory is authoritative after a disconnected host lost close events. */
68
+ reconcile(browser, targets) {
69
+ if (!existsSync(this.index))
70
+ return;
71
+ this.mutate(references => {
72
+ for (const [key, owner] of Object.entries(references.owners))
73
+ if (owner.browser === browser && !targets.has(owner.target))
74
+ delete references.owners[key];
75
+ this.collect(references);
76
+ });
77
+ }
78
+ key(owner) { return createHash('sha256').update(JSON.stringify([owner.browser, owner.target])).digest('hex'); }
79
+ mutate(action) {
80
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
81
+ return withRecordLock(this.index, () => {
82
+ const references = existsSync(this.index) ? JSON.parse(this.read(this.index, 4 * 1024 * 1024).toString('utf8')) : { owners: {} };
83
+ if (!references || !references.owners || typeof references.owners !== 'object' || Array.isArray(references.owners) || Object.keys(references.owners).length > 4096)
84
+ throw new Error('Invalid page runtime reference index');
85
+ const liveness = new Map();
86
+ for (const [key, owner] of Object.entries(references.owners)) {
87
+ if (!owner || !sha256.test(owner.browser) || typeof owner.target !== 'string' || !Array.isArray(owner.digests) || owner.digests.length > 256 || owner.digests.some(digest => typeof digest !== 'string' || !sha256.test(digest)))
88
+ throw new Error('Invalid page runtime reference');
89
+ if (owner.pid !== undefined) {
90
+ const identity = JSON.stringify([owner.pid, owner.processIdentity]);
91
+ if (!liveness.has(identity))
92
+ liveness.set(identity, isProcessOwnerAlive({ pid: owner.pid, processIdentity: owner.processIdentity }));
93
+ if (!liveness.get(identity))
94
+ delete references.owners[key];
95
+ }
96
+ }
97
+ const result = action(references);
98
+ const bytes = Buffer.from(JSON.stringify(references));
99
+ if (bytes.byteLength > 4 * 1024 * 1024)
100
+ throw new Error('Page runtime reference index exceeds 4 MiB');
101
+ this.write(this.index, bytes);
102
+ return result;
103
+ });
104
+ }
105
+ collect(references, requiredBytes = 0, requiredEntries = 0) {
106
+ const protectedDigests = new Set(Object.values(references.owners).flatMap(owner => owner.digests));
107
+ const files = readdirSync(this.root).filter(name => /^[a-f0-9]{64}\.cjs$/.test(name)).map(name => ({ file: join(this.root, name), digest: name.slice(0, 64), info: statSync(join(this.root, name)) }));
108
+ let total = files.reduce((sum, file) => sum + file.info.size, 0);
109
+ let entries = files.length;
110
+ for (const file of files.sort((a, b) => a.info.mtimeMs - b.info.mtimeMs)) {
111
+ if (protectedDigests.has(file.digest))
112
+ continue;
113
+ // Explicit release/reconciliation reclaims unreferenced objects; insertions evict as needed.
114
+ if (requiredBytes && total + requiredBytes <= this.quota && entries + requiredEntries <= 256)
115
+ break;
116
+ unlinkSync(file.file);
117
+ total -= file.info.size;
118
+ entries--;
119
+ }
120
+ if (total + requiredBytes > this.quota || entries + requiredEntries > 256)
121
+ throw new Error('Page runtime cache is full of active browser references');
122
+ }
123
+ read(file, maximum) {
124
+ const handle = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
125
+ try {
126
+ const stat = fstatSync(handle);
127
+ if (!stat.isFile() || stat.size > maximum)
128
+ throw new Error('Stored page runtime exceeds its limit');
129
+ const bytes = Buffer.alloc(maximum + 1);
130
+ let length = 0;
131
+ while (length < bytes.length) {
132
+ const count = readSync(handle, bytes, length, bytes.length - length, null);
133
+ if (!count)
134
+ break;
135
+ length += count;
136
+ }
137
+ if (length > maximum)
138
+ throw new Error('Stored page runtime exceeds its limit');
139
+ return bytes.subarray(0, length);
140
+ }
141
+ finally {
142
+ closeSync(handle);
143
+ }
144
+ }
145
+ write(file, bytes) {
146
+ const temporary = `${file}.${randomUUID()}.tmp`;
147
+ try {
148
+ writeFileSync(temporary, bytes, { mode: 0o600, flag: 'wx' });
149
+ renameSync(temporary, file);
150
+ }
151
+ finally {
152
+ try {
153
+ unlinkSync(temporary);
154
+ }
155
+ catch { /* Atomic replacement consumed the temporary file. */ }
156
+ }
157
+ }
158
+ verify(binding, bytes) {
159
+ if (bytes.byteLength > MAX_PAGE_RUNTIME_BYTES || createHash('sha256').update(bytes).digest('hex') !== binding.sha256)
160
+ throw new Error('Page runtime integrity check failed');
161
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
162
+ }
163
+ }
@@ -0,0 +1,55 @@
1
+ import { type JsonObject, type CapabilityActivation, type ResolvedPreset } from '@mearl/preset';
2
+ import { type PreparedBundle, type PreparedPreset, type PresetSnapshot } from '@mearl/preset/bundles';
3
+ export interface PresetInstallReceipt {
4
+ schemaVersion: 1;
5
+ snapshot: PresetSnapshot;
6
+ bundles: Array<Omit<PreparedBundle, 'resources'>>;
7
+ browserRevision: string;
8
+ nodeRevision: string;
9
+ managedModules: string[];
10
+ nodeSyncRequired: boolean;
11
+ browserSyncRequired?: boolean;
12
+ overrides: JsonObject;
13
+ revision: string;
14
+ }
15
+ interface SelectionState {
16
+ rawHash: string | null;
17
+ activation?: CapabilityActivation;
18
+ receipt?: PresetInstallReceipt;
19
+ prepared?: PreparedPreset;
20
+ preset?: ResolvedPreset;
21
+ }
22
+ /** One pointer binds the desired preset, user overrides and exact Node selection. */
23
+ export declare class PresetInstallationStore {
24
+ readonly directory: string;
25
+ readonly mearlApiVersion: string;
26
+ private readonly pointer;
27
+ constructor(options?: {
28
+ directory?: string;
29
+ mearlApiVersion?: string;
30
+ });
31
+ private assertCurrent;
32
+ current(options?: {
33
+ verifyResources?: boolean;
34
+ }): Promise<SelectionState>;
35
+ apply(input: Pick<PreparedPreset, 'snapshot' | 'bundles'>, options: {
36
+ expectedRevision: string | null;
37
+ overrides?: JsonObject;
38
+ signal?: AbortSignal;
39
+ confirm?: () => Promise<void>;
40
+ }): Promise<PresetInstallReceipt>;
41
+ rollback(expectedRevision: string): Promise<void>;
42
+ collectGarbage(): Promise<void>;
43
+ private collectLocked;
44
+ }
45
+ export declare function presetSelectionRevision(state: SelectionState): string | null;
46
+ /** Reads accepted local metadata without loading capability factories, browsers or network sources. */
47
+ export declare function readInstallationManagement(options?: {
48
+ directory?: string;
49
+ }): Promise<{
50
+ setup: import("@mearl/preset").SetupManagement | undefined;
51
+ source: string | undefined;
52
+ revision: string | undefined;
53
+ }>;
54
+ export { setupManagementCommand, setupRuntimeCompatible } from '@mearl/preset';
55
+ export type { SetupManagement } from '@mearl/preset';
@@ -0,0 +1,260 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants, closeSync, fstatSync, lstatSync, linkSync, mkdirSync, openSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
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';
8
+ const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
9
+ const hashPattern = /^[a-f0-9]{64}$/;
10
+ const MAX_RECEIPT_BYTES = 16 * 1024 * 1024;
11
+ const MAX_OVERRIDES_BYTES = 262_144;
12
+ const POINTER_BYTES = MAX_CAPABILITY_SELECTION_BYTES + 128;
13
+ function readBounded(file, limit) {
14
+ const before = lstatSync(file);
15
+ if (!before.isFile() || before.isSymbolicLink() || before.size > limit)
16
+ throw new Error('Invalid preset resource file or size');
17
+ const descriptor = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
18
+ try {
19
+ const stat = fstatSync(descriptor);
20
+ if (!stat.isFile() || stat.size > limit)
21
+ throw new Error('Invalid preset resource file or size');
22
+ const bytes = Buffer.alloc(Math.min(stat.size, limit) + 1);
23
+ let length = 0;
24
+ while (length < bytes.byteLength) {
25
+ const count = readSync(descriptor, bytes, length, bytes.byteLength - length, null);
26
+ if (count === 0)
27
+ break;
28
+ length += count;
29
+ }
30
+ if (length > stat.size)
31
+ throw new Error('Preset resource changed while reading');
32
+ return bytes.subarray(0, length);
33
+ }
34
+ finally {
35
+ closeSync(descriptor);
36
+ }
37
+ }
38
+ function optionalFile(file, limit) {
39
+ try {
40
+ return readBounded(file, limit);
41
+ }
42
+ catch (error) {
43
+ if (error.code === 'ENOENT')
44
+ return undefined;
45
+ throw error;
46
+ }
47
+ }
48
+ function atomicWrite(file, bytes) {
49
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
50
+ try {
51
+ writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o600 });
52
+ renameSync(temporary, file);
53
+ }
54
+ finally {
55
+ rmSync(temporary, { force: true });
56
+ }
57
+ }
58
+ function revision(state) {
59
+ return state.activation?.installationRevision ?? (state.activation ? `node:${state.activation.revision}` : null);
60
+ }
61
+ function overrides(value) {
62
+ if (Object.hasOwn(value, 'management'))
63
+ throw new BundleError('BUNDLE_INVALID', 'Installation management cannot be overridden');
64
+ const accepted = mergePresetDefaults({}, value);
65
+ if (Buffer.byteLength(JSON.stringify(accepted)) > MAX_OVERRIDES_BYTES)
66
+ throw new BundleError('BUNDLE_QUOTA', 'User overrides exceed 256 KiB');
67
+ return accepted;
68
+ }
69
+ /** One pointer binds the desired preset, user overrides and exact Node selection. */
70
+ export class PresetInstallationStore {
71
+ directory;
72
+ mearlApiVersion;
73
+ pointer;
74
+ constructor(options = {}) {
75
+ this.directory = resolve(options.directory ?? (process.env.MEARL_CAPABILITY_DIR || join(homedir(), '.mearl/capabilities')));
76
+ this.mearlApiVersion = options.mearlApiVersion ?? '1.0.0';
77
+ this.pointer = join(this.directory, 'active-node.json');
78
+ }
79
+ assertCurrent(expected) {
80
+ const bytes = optionalFile(this.pointer, POINTER_BYTES);
81
+ if ((bytes ? digest(bytes) : null) !== expected)
82
+ throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed during preparation');
83
+ }
84
+ async current(options = {}) {
85
+ const raw = optionalFile(this.pointer, POINTER_BYTES);
86
+ if (!raw)
87
+ return { rawHash: null };
88
+ const rawHash = digest(raw);
89
+ const activation = await verifyCapabilityActivation(JSON.parse(raw.toString('utf8')));
90
+ if (!activation.installationRevision)
91
+ return { rawHash, activation };
92
+ const captured = withRecordLock(this.pointer, () => {
93
+ this.assertCurrent(rawHash);
94
+ const receiptBytes = readBounded(join(this.directory, 'installations', `${activation.installationRevision}.json`), MAX_RECEIPT_BYTES);
95
+ const receipt = JSON.parse(receiptBytes.toString('utf8'));
96
+ 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))) {
101
+ throw new BundleError('BUNDLE_INTEGRITY', 'Installed preset receipt does not match its selection');
102
+ }
103
+ const acceptedOverrides = overrides(data.overrides);
104
+ let total = 0;
105
+ const metadata = data.bundles.map(bundle => {
106
+ if (Object.keys(bundle).some(key => !['source', 'manifest', 'revision'].includes(key)))
107
+ throw new BundleError('BUNDLE_INVALID', 'Invalid installed package metadata');
108
+ const manifest = validateBundleManifest(bundle.manifest, this.mearlApiVersion);
109
+ total += Object.values(manifest.resources).reduce((sum, entry) => sum + entry.byteLength, 0);
110
+ return { ...bundle, manifest };
111
+ });
112
+ if (total > MAX_BROWSER_STORAGE_BYTES)
113
+ 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)) {
116
+ const bytes = readBounded(join(this.directory, 'objects', `${entry.sha256}.cjs`), entry.byteLength);
117
+ if (bytes.byteLength !== entry.byteLength || digest(bytes) !== entry.sha256)
118
+ throw new BundleError('BUNDLE_INTEGRITY', 'Installed Node object is missing or corrupt');
119
+ }
120
+ return { receipt: { ...receipt, overrides: acceptedOverrides }, bundles };
121
+ });
122
+ if (options.verifyResources === false) {
123
+ 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);
126
+ return { rawHash, activation, receipt: captured.receipt, preset: graph.preset };
127
+ }
128
+ const prepared = await verifyPreparedPreset({ snapshot: captured.receipt.snapshot, bundles: captured.bundles }, this.mearlApiVersion);
129
+ if (prepared.browser.snapshot.revision !== captured.receipt.browserRevision)
130
+ throw new BundleError('BUNDLE_INTEGRITY', 'Installed browser projection does not match');
131
+ 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)) ||
133
+ Object.entries(selected.snapshot.modules).some(([id, entry]) => JSON.stringify(entry) !== JSON.stringify(activation.modules[id])) ||
134
+ Object.entries(selected.snapshot.bindings).some(([hook, entry]) => JSON.stringify(entry) !== JSON.stringify(activation.bindings[hook])) ||
135
+ Object.values(activation.bindings).filter(entry => captured.receipt.managedModules.includes(entry.module)).length !== Object.keys(selected.snapshot.bindings).length) {
136
+ throw new BundleError('BUNDLE_INTEGRITY', 'Installed managed Node selection does not match its packages');
137
+ }
138
+ return { rawHash, activation, prepared, receipt: captured.receipt, preset: prepared.preset };
139
+ }
140
+ async apply(input, options) {
141
+ options.signal?.throwIfAborted();
142
+ const acceptedOverrides = options.overrides === undefined ? undefined : overrides(options.overrides);
143
+ const [current, prepared] = await Promise.all([this.current({ verifyResources: false }), verifyPreparedPreset(input, this.mearlApiVersion)]);
144
+ if (revision(current) !== options.expectedRevision)
145
+ throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed');
146
+ 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 ?? {})));
147
+ const previousManaged = new Set(current.receipt?.managedModules ?? []);
148
+ const unmanaged = Object.fromEntries(Object.entries(current.activation?.modules ?? {}).filter(([id]) => !previousManaged.has(id)));
149
+ for (const entry of Object.values(unmanaged)) {
150
+ const bytes = readBounded(join(this.directory, 'objects', `${entry.sha256}.cjs`), entry.byteLength);
151
+ if (bytes.byteLength !== entry.byteLength || digest(bytes) !== entry.sha256)
152
+ throw new BundleError('BUNDLE_INTEGRITY', 'An independent Node module is corrupt');
153
+ }
154
+ if (Object.keys(selected.snapshot.modules).some(id => Object.hasOwn(unmanaged, id)))
155
+ throw new BundleError('BUNDLE_CONFLICT', 'A preset module conflicts with an independently installed Node module');
156
+ const bindings = Object.fromEntries(Object.entries(current.activation?.bindings ?? {}).filter(([, entry]) => Object.hasOwn(unmanaged, entry.module)));
157
+ if (Object.keys(selected.snapshot.bindings).some(hook => Object.hasOwn(bindings, hook)))
158
+ 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 } });
160
+ 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'))),
166
+ browserSyncRequired: Boolean(current.receipt?.browserSyncRequired || presetRequiresBrowser(prepared.preset)),
167
+ };
168
+ const receipt = { ...data, revision: digest(JSON.stringify(data)) };
169
+ const receiptBytes = JSON.stringify(receipt);
170
+ if (Buffer.byteLength(receiptBytes) > MAX_RECEIPT_BYTES)
171
+ throw new BundleError('BUNDLE_QUOTA', 'Preset installation receipt exceeds 16 MiB');
172
+ const activation = { ...snapshot, installationRevision: receipt.revision };
173
+ await options.confirm?.();
174
+ withRecordLock(this.pointer, () => {
175
+ this.assertCurrent(current.rawHash);
176
+ options.signal?.throwIfAborted();
177
+ for (const child of ['blobs', 'objects', 'installations'])
178
+ mkdirSync(join(this.directory, child), { recursive: true, mode: 0o700 });
179
+ for (const bundle of prepared.bundles)
180
+ for (const [name, entry] of Object.entries(bundle.manifest.resources)) {
181
+ atomicWrite(join(this.directory, 'blobs', `${entry.sha256}.bin`), bundle.resources[name]);
182
+ }
183
+ for (const entry of Object.values(selected.snapshot.modules)) {
184
+ const destination = join(this.directory, 'objects', `${entry.sha256}.cjs`);
185
+ const temporary = `${destination}.${randomUUID()}.tmp`;
186
+ try {
187
+ linkSync(join(this.directory, 'blobs', `${entry.sha256}.bin`), temporary);
188
+ renameSync(temporary, destination);
189
+ }
190
+ finally {
191
+ rmSync(temporary, { force: true });
192
+ }
193
+ }
194
+ atomicWrite(join(this.directory, 'installations', `${receipt.revision}.json`), receiptBytes);
195
+ if (current.receipt?.revision !== receipt.revision)
196
+ atomicWrite(join(this.directory, 'rollback.json'), JSON.stringify({ candidate: receipt.revision, previous: current.activation ?? null }));
197
+ atomicWrite(this.pointer, JSON.stringify(activation));
198
+ });
199
+ return receipt;
200
+ }
201
+ async rollback(expectedRevision) {
202
+ const current = await this.current({ verifyResources: false });
203
+ if (current.activation?.installationRevision !== expectedRevision)
204
+ throw new BundleError('BUNDLE_CONFLICT', 'A newer preset installation cannot be rolled back');
205
+ const record = JSON.parse(readBounded(join(this.directory, 'rollback.json'), POINTER_BYTES + 256).toString('utf8'));
206
+ if (record.candidate !== expectedRevision)
207
+ throw new BundleError('BUNDLE_CONFLICT', 'The preset rollback record has changed');
208
+ const previous = record.previous === null ? undefined : await verifyCapabilityActivation(record.previous);
209
+ withRecordLock(this.pointer, () => {
210
+ this.assertCurrent(current.rawHash);
211
+ if (previous)
212
+ atomicWrite(this.pointer, JSON.stringify(previous));
213
+ else
214
+ rmSync(this.pointer, { force: true });
215
+ rmSync(join(this.directory, 'rollback.json'), { force: true });
216
+ this.collectLocked(previous);
217
+ });
218
+ }
219
+ async collectGarbage() {
220
+ const current = await this.current({ verifyResources: false });
221
+ const raw = optionalFile(join(this.directory, 'rollback.json'), POINTER_BYTES + 256);
222
+ const record = raw && JSON.parse(raw.toString('utf8'));
223
+ const previous = record?.candidate === current.activation?.installationRevision && record.previous !== null ? await verifyCapabilityActivation(record.previous) : undefined;
224
+ withRecordLock(this.pointer, () => {
225
+ this.assertCurrent(current.rawHash);
226
+ for (const child of ['blobs', 'objects', 'installations'])
227
+ mkdirSync(join(this.directory, child), { recursive: true, mode: 0o700 });
228
+ this.collectLocked(current.activation, previous);
229
+ });
230
+ }
231
+ collectLocked(active, previous) {
232
+ const receipts = new Set([active?.installationRevision, previous?.installationRevision].filter((value) => Boolean(value)));
233
+ const blobs = new Set();
234
+ const modules = new Set([active, previous].flatMap(selection => Object.values(selection?.modules ?? {}).map(entry => entry.sha256)));
235
+ for (const id of receipts) {
236
+ const record = JSON.parse(readBounded(join(this.directory, 'installations', `${id}.json`), MAX_RECEIPT_BYTES).toString('utf8'));
237
+ for (const bundle of record.bundles)
238
+ for (const entry of Object.values(bundle.manifest.resources))
239
+ blobs.add(entry.sha256);
240
+ }
241
+ for (const [directory, extension, retained] of [['blobs', '.bin', blobs], ['objects', '.cjs', modules], ['installations', '.json', receipts]]) {
242
+ for (const name of readdirSync(join(this.directory, directory))) {
243
+ const id = name.slice(0, -extension.length);
244
+ if (name.endsWith(extension) && hashPattern.test(id) && !retained.has(id))
245
+ rmSync(join(this.directory, directory, name), { force: true });
246
+ }
247
+ }
248
+ }
249
+ }
250
+ export function presetSelectionRevision(state) { return revision(state); }
251
+ /** Reads accepted local metadata without loading capability factories, browsers or network sources. */
252
+ export async function readInstallationManagement(options = {}) {
253
+ const current = await new PresetInstallationStore(options).current({ verifyResources: false });
254
+ return {
255
+ setup: parseSetupManagement(current.preset?.defaults.management),
256
+ source: current.receipt?.snapshot.source,
257
+ revision: current.receipt?.revision,
258
+ };
259
+ }
260
+ export { setupManagementCommand, setupRuntimeCompatible } from '@mearl/preset';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/daemon-core",
3
- "version": "3.0.0-beta.1",
3
+ "version": "3.0.0-beta.10",
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",
@@ -13,6 +13,14 @@
13
13
  "./capabilities": {
14
14
  "types": "./dist/node-capabilities.d.ts",
15
15
  "import": "./dist/node-capabilities.js"
16
+ },
17
+ "./installation": {
18
+ "types": "./dist/preset-installation.d.ts",
19
+ "import": "./dist/preset-installation.js"
20
+ },
21
+ "./page-runtimes": {
22
+ "types": "./dist/page-runtimes.d.ts",
23
+ "import": "./dist/page-runtimes.js"
16
24
  }
17
25
  },
18
26
  "files": [
@@ -25,7 +33,7 @@
25
33
  ],
26
34
  "license": "Apache-2.0",
27
35
  "engines": {
28
- "node": ">=22.12.0"
36
+ "node": ">=20.18.0"
29
37
  },
30
38
  "publishConfig": {
31
39
  "registry": "https://registry.npmjs.org"
@@ -35,11 +43,11 @@
35
43
  "typescript": "^5.8.3"
36
44
  },
37
45
  "dependencies": {
38
- "@mearl/preset": "3.0.0-beta.1"
46
+ "@mearl/preset": "3.0.0-beta.10"
39
47
  },
40
48
  "repository": {
41
49
  "type": "git",
42
- "url": "git+https://github.com/F-loat/mearl.git",
50
+ "url": "git+https://github.com/mearlhq/mearl.git",
43
51
  "directory": "packages/daemon-core"
44
52
  },
45
53
  "scripts": {