@zhin.js/runtime 1.0.0

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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/lib/compatibility.d.ts +10 -0
  4. package/lib/compatibility.js +45 -0
  5. package/lib/config-composer.d.ts +27 -0
  6. package/lib/config-composer.js +212 -0
  7. package/lib/config-document.d.ts +19 -0
  8. package/lib/config-document.js +6 -0
  9. package/lib/config-patch-planner.d.ts +25 -0
  10. package/lib/config-patch-planner.js +126 -0
  11. package/lib/environment-store.d.ts +56 -0
  12. package/lib/environment-store.js +300 -0
  13. package/lib/environment.d.ts +8 -0
  14. package/lib/environment.js +13 -0
  15. package/lib/feature-projector.d.ts +15 -0
  16. package/lib/feature-projector.js +57 -0
  17. package/lib/generation-assets.d.ts +9 -0
  18. package/lib/generation-assets.js +67 -0
  19. package/lib/hmr-coordinator.d.ts +23 -0
  20. package/lib/hmr-coordinator.js +110 -0
  21. package/lib/index.d.ts +21 -0
  22. package/lib/index.js +21 -0
  23. package/lib/invalidation-planner.d.ts +29 -0
  24. package/lib/invalidation-planner.js +106 -0
  25. package/lib/isolation.d.ts +28 -0
  26. package/lib/isolation.js +1 -0
  27. package/lib/manifest.d.ts +45 -0
  28. package/lib/manifest.js +195 -0
  29. package/lib/module-runtime.d.ts +17 -0
  30. package/lib/module-runtime.js +7 -0
  31. package/lib/native-development-runtime.d.ts +22 -0
  32. package/lib/native-development-runtime.js +190 -0
  33. package/lib/node-discovery-host.d.ts +10 -0
  34. package/lib/node-discovery-host.js +37 -0
  35. package/lib/package-resolver.d.ts +23 -0
  36. package/lib/package-resolver.js +121 -0
  37. package/lib/plugin-scope-assembler.d.ts +38 -0
  38. package/lib/plugin-scope-assembler.js +167 -0
  39. package/lib/process-restart.d.ts +15 -0
  40. package/lib/process-restart.js +29 -0
  41. package/lib/project-graph.d.ts +30 -0
  42. package/lib/project-graph.js +129 -0
  43. package/lib/restart-boundary.d.ts +6 -0
  44. package/lib/restart-boundary.js +68 -0
  45. package/lib/root-runtime.d.ts +37 -0
  46. package/lib/root-runtime.js +432 -0
  47. package/lib/runtime-generation.d.ts +18 -0
  48. package/lib/runtime-generation.js +1 -0
  49. package/lib/slot-generation-preparer.d.ts +9 -0
  50. package/lib/slot-generation-preparer.js +76 -0
  51. package/lib/source-ownership.d.ts +19 -0
  52. package/lib/source-ownership.js +105 -0
  53. package/lib/subtree-generation-preparer.d.ts +24 -0
  54. package/lib/subtree-generation-preparer.js +152 -0
  55. package/lib/topology-generation-preparer.d.ts +22 -0
  56. package/lib/topology-generation-preparer.js +249 -0
  57. package/lib/topology-transaction.d.ts +26 -0
  58. package/lib/topology-transaction.js +142 -0
  59. package/lib/typescript-specifier-remap.d.ts +23 -0
  60. package/lib/typescript-specifier-remap.js +92 -0
  61. package/package.json +57 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 凉菜
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # @zhin.js/runtime
2
+
3
+ Zhin.js Plugin 树的 Root 生命周期权威。它从静态 package manifest 构建 Plugin instance
4
+ tree,按 owner 组合 schema/config/env,通过 Feature provider 发现 capability,并用
5
+ immutable snapshot、CAS generation 与 lease 驱动局部 HMR。
6
+
7
+ ```ts
8
+ import { EsmModuleRuntime, RootRuntime } from '@zhin.js/runtime';
9
+
10
+ const runtime = new RootRuntime({
11
+ projectRoot: process.cwd(),
12
+ modules: new EsmModuleRuntime(),
13
+ environment: { mode: 'development' },
14
+ });
15
+
16
+ await runtime.start();
17
+ ```
18
+
19
+ ## 事务边界
20
+
21
+ - capability 文件变化只 prepare 对应 Slot。
22
+ - child/schema 变化替换最浅受影响 subtree。
23
+ - manifest topology transaction 支持 child/Feature 新增、删除和移动。
24
+ - Root/package ABI、lockfile 和未知 importer 变化升级为 process restart。
25
+ - 候选校验或 handoff 失败不提交 generation;旧 snapshot 保持可用。
26
+ - 最后一个旧 lease 释放后,旧资源才按 children-first 顺序回收。
27
+
28
+ Runtime 不静态导入 Command、Agent、Page 或 Adapter provider。具体能力由插件 manifest
29
+ 动态装配。Ajv 用于整树 schema 校验,semver 用于 engine/Feature API admission;它们只
30
+ 属于显式安装的 Root 控制面,不进入 `zhin.js` 默认 IM 安装。
31
+
32
+ `zhin.js/runtime` 是指向本包的 optional-peer facade。新项目应显式安装本包;这保证
33
+ `zhin.js` 默认 IM 闭包继续满足 10MB 门禁。
34
+
35
+ ```bash
36
+ pnpm --filter @zhin.js/runtime build
37
+ pnpm --filter @zhin.js/runtime test
38
+ pnpm --filter @zhin.js/runtime check:size
39
+ ```
@@ -0,0 +1,10 @@
1
+ import type { PackageReference } from './manifest.js';
2
+ import type { ResolvedPackage } from './package-resolver.js';
3
+ export declare const runtimeEngineVersion = "1.0.0";
4
+ export declare class PackageCompatibilityError extends Error {
5
+ readonly packageName: string;
6
+ readonly contract: 'engine' | 'feature-api';
7
+ constructor(packageName: string, contract: 'engine' | 'feature-api', message: string);
8
+ }
9
+ export declare function assertPackageEngine(pkg: ResolvedPackage, engineVersion?: string): void;
10
+ export declare function assertFeatureApi(owner: ResolvedPackage, reference: PackageReference, feature: ResolvedPackage): void;
@@ -0,0 +1,45 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+ const semver = require('semver');
4
+ export const runtimeEngineVersion = '1.0.0';
5
+ export class PackageCompatibilityError extends Error {
6
+ packageName;
7
+ contract;
8
+ constructor(packageName, contract, message) {
9
+ super(`Incompatible ${contract} for ${packageName}: ${message}`);
10
+ this.packageName = packageName;
11
+ this.contract = contract;
12
+ this.name = 'PackageCompatibilityError';
13
+ }
14
+ }
15
+ export function assertPackageEngine(pkg, engineVersion = runtimeEngineVersion) {
16
+ const range = pkg.packageJson.zhin.engine;
17
+ if (!range)
18
+ return;
19
+ assertRange(pkg.name, 'engine', range);
20
+ if (!semver.satisfies(engineVersion, range, { includePrerelease: true })) {
21
+ throw new PackageCompatibilityError(pkg.name, 'engine', `requires ${range}, Runtime provides ${engineVersion}`);
22
+ }
23
+ }
24
+ export function assertFeatureApi(owner, reference, feature) {
25
+ const actual = feature.packageJson.zhin.type === 'feature'
26
+ ? feature.packageJson.zhin.featureApi
27
+ : undefined;
28
+ if (actual && !semver.valid(actual)) {
29
+ throw new PackageCompatibilityError(feature.name, 'feature-api', `declares invalid featureApi version ${actual}`);
30
+ }
31
+ if (!reference.api)
32
+ return;
33
+ assertRange(owner.name, 'feature-api', reference.api);
34
+ if (!actual) {
35
+ throw new PackageCompatibilityError(feature.name, 'feature-api', `must declare a valid featureApi version for ${owner.name}'s ${reference.api} requirement`);
36
+ }
37
+ if (!semver.satisfies(actual, reference.api, { includePrerelease: true })) {
38
+ throw new PackageCompatibilityError(feature.name, 'feature-api', `${owner.name} requires ${reference.api}, provider declares ${actual}`);
39
+ }
40
+ }
41
+ function assertRange(packageName, contract, range) {
42
+ if (!semver.validRange(range)) {
43
+ throw new PackageCompatibilityError(packageName, contract, `invalid semver range ${range}`);
44
+ }
45
+ }
@@ -0,0 +1,27 @@
1
+ import type { PluginId } from '@zhin.js/plugin-runtime';
2
+ import type { ProjectGraph } from './project-graph.js';
3
+ export type JsonSchema = Readonly<Record<string, unknown>>;
4
+ export type RuntimeConfigDocument = Readonly<Record<string, unknown>>;
5
+ export interface ComposedConfig {
6
+ readonly effectiveSchema: JsonSchema;
7
+ readonly document: RuntimeConfigDocument;
8
+ readonly views: ReadonlyMap<PluginId, unknown>;
9
+ }
10
+ export declare class ConfigSchemaCollisionError extends Error {
11
+ readonly plugin: PluginId;
12
+ readonly instanceKey: string;
13
+ constructor(plugin: PluginId, instanceKey: string);
14
+ }
15
+ export declare class ConfigValidationError extends Error {
16
+ readonly issues: readonly string[];
17
+ /** Source config file name, when known (e.g. `zhin.config.yml`). */
18
+ readonly source?: string | undefined;
19
+ constructor(issues: readonly string[],
20
+ /** Source config file name, when known (e.g. `zhin.config.yml`). */
21
+ source?: string | undefined);
22
+ }
23
+ export declare class ConfigComposer {
24
+ compose(graph: ProjectGraph, input?: RuntimeConfigDocument,
25
+ /** Source config file name, annotated on ConfigValidationError. */
26
+ source?: string): Promise<ComposedConfig>;
27
+ }
@@ -0,0 +1,212 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import Ajv2020 from 'ajv/dist/2020.js';
4
+ export class ConfigSchemaCollisionError extends Error {
5
+ plugin;
6
+ instanceKey;
7
+ constructor(plugin, instanceKey) {
8
+ super(`Config property ${instanceKey} in ${plugin} collides with a child Plugin`);
9
+ this.plugin = plugin;
10
+ this.instanceKey = instanceKey;
11
+ this.name = 'ConfigSchemaCollisionError';
12
+ }
13
+ }
14
+ export class ConfigValidationError extends Error {
15
+ issues;
16
+ source;
17
+ constructor(issues,
18
+ /** Source config file name, when known (e.g. `zhin.config.yml`). */
19
+ source) {
20
+ super(`Invalid Plugin config${source ? ` in ${source}` : ''}:\n${issues.map((issue) => `- ${issue}`).join('\n')}`);
21
+ this.issues = issues;
22
+ this.source = source;
23
+ this.name = 'ConfigValidationError';
24
+ }
25
+ }
26
+ export class ConfigComposer {
27
+ async compose(graph, input = {},
28
+ /** Source config file name, annotated on ConfigValidationError. */
29
+ source) {
30
+ // Effective schemas include child namespaces for whole-tree validation;
31
+ // ownSchemas retain each package's private configuration contract.
32
+ const ownSchemas = new Map();
33
+ const rootOwn = await readOwnSchema(graph.root);
34
+ ownSchemas.set(graph.root.id, rootOwn);
35
+ const childSchemas = await Promise.all(graph.root.children.map(async (child) => [
36
+ child.instanceKey,
37
+ await composeNode(child, ownSchemas),
38
+ ]));
39
+ // Host-level keys (`http`, `database`, `ai`, `mcp`, `a2a`, `speech`,
40
+ // `htmlRenderer`, `assistant`, `collaboration`, `log_level`) are consumed by CLI Root installers /
41
+ // start-command, not Plugin ConfigViews.
42
+ const effectiveSchema = Object.freeze({
43
+ type: 'object',
44
+ additionalProperties: false,
45
+ properties: {
46
+ plugin: withDefault(rootOwn),
47
+ plugins: {
48
+ type: 'object',
49
+ additionalProperties: false,
50
+ default: {},
51
+ properties: Object.fromEntries(childSchemas.map(([key, schema]) => [key, withDefault(schema)])),
52
+ },
53
+ http: Object.freeze({
54
+ type: 'object',
55
+ additionalProperties: true,
56
+ }),
57
+ database: Object.freeze({
58
+ type: 'object',
59
+ additionalProperties: true,
60
+ }),
61
+ ai: Object.freeze({
62
+ type: 'object',
63
+ additionalProperties: true,
64
+ }),
65
+ mcp: Object.freeze({
66
+ type: 'object',
67
+ additionalProperties: true,
68
+ }),
69
+ a2a: Object.freeze({
70
+ type: 'object',
71
+ additionalProperties: true,
72
+ }),
73
+ speech: Object.freeze({
74
+ type: 'object',
75
+ additionalProperties: true,
76
+ }),
77
+ htmlRenderer: Object.freeze({
78
+ type: 'object',
79
+ additionalProperties: true,
80
+ }),
81
+ assistant: Object.freeze({
82
+ type: 'object',
83
+ additionalProperties: true,
84
+ }),
85
+ collaboration: Object.freeze({
86
+ type: 'object',
87
+ additionalProperties: true,
88
+ }),
89
+ log_level: Object.freeze({
90
+ type: ['string', 'number'],
91
+ }),
92
+ },
93
+ });
94
+ const document = structuredClone(input);
95
+ const validate = new Ajv2020({
96
+ allErrors: true,
97
+ useDefaults: true,
98
+ strict: true,
99
+ // 当前内置 schema 无需 union(log_level 的 ['string','number'] 是 type 数组,
100
+ // 不触发 allowUnionTypes);保留此项是面向未来插件 schema 可能出现的
101
+ // anyOf/oneOf 标量 union,避免届时 Ajv strict 模式直接报错。
102
+ allowUnionTypes: true,
103
+ }).compile(effectiveSchema);
104
+ if (!validate(document)) {
105
+ throw new ConfigValidationError(formatErrors(validate.errors ?? []), source);
106
+ }
107
+ const views = new Map();
108
+ // A Plugin never receives its effective node object because that object
109
+ // also contains descendants. Re-pick fields from the owner's own schema.
110
+ views.set(graph.root.id, pickOwnFields(document.plugin, requireOwnSchema(ownSchemas, graph.root.id)));
111
+ collectChildViews(graph.root, document.plugins, ownSchemas, views);
112
+ return Object.freeze({
113
+ effectiveSchema,
114
+ document: Object.freeze(document),
115
+ views,
116
+ });
117
+ }
118
+ }
119
+ async function composeNode(node, ownSchemas) {
120
+ const own = await readOwnSchema(node);
121
+ ownSchemas.set(node.id, own);
122
+ const properties = { ...schemaProperties(own) };
123
+ for (const child of node.children) {
124
+ if (Object.hasOwn(properties, child.instanceKey)) {
125
+ throw new ConfigSchemaCollisionError(node.id, child.instanceKey);
126
+ }
127
+ properties[child.instanceKey] = withDefault(await composeNode(child, ownSchemas));
128
+ }
129
+ return Object.freeze({ ...own, properties });
130
+ }
131
+ async function readOwnSchema(node) {
132
+ const file = join(node.package.root, 'schema.json');
133
+ let value;
134
+ try {
135
+ value = JSON.parse(await readFile(file, 'utf8'));
136
+ }
137
+ catch (error) {
138
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
139
+ value = { type: 'object', additionalProperties: false, properties: {} };
140
+ }
141
+ else {
142
+ throw error;
143
+ }
144
+ }
145
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
146
+ throw new TypeError(`${file} must contain a JSON Schema object`);
147
+ }
148
+ const { $schema: _schema, $id: _id, ...schema } = value;
149
+ if (schema.type !== undefined && schema.type !== 'object') {
150
+ throw new TypeError(`${file} root schema type must be object`);
151
+ }
152
+ return Object.freeze({
153
+ type: 'object',
154
+ additionalProperties: false,
155
+ ...schema,
156
+ properties: schemaProperties(schema),
157
+ });
158
+ }
159
+ function schemaProperties(schema) {
160
+ const properties = schema.properties;
161
+ if (properties === undefined)
162
+ return {};
163
+ if (!properties || typeof properties !== 'object' || Array.isArray(properties)) {
164
+ throw new TypeError('JSON Schema properties must be an object');
165
+ }
166
+ return { ...properties };
167
+ }
168
+ function withDefault(schema) {
169
+ return Object.freeze({ ...schema, default: schema.default ?? {} });
170
+ }
171
+ function collectChildViews(parent, value, ownSchemas, views) {
172
+ const record = value && typeof value === 'object'
173
+ ? value
174
+ : {};
175
+ for (const child of parent.children) {
176
+ const childConfig = record[child.instanceKey] ?? Object.freeze({});
177
+ views.set(child.id, pickOwnFields(childConfig, requireOwnSchema(ownSchemas, child.id)));
178
+ collectChildViews(child, childConfig, ownSchemas, views);
179
+ }
180
+ }
181
+ function pickOwnFields(value, schema) {
182
+ const record = value && typeof value === 'object'
183
+ ? value
184
+ : {};
185
+ const result = {};
186
+ for (const key of Object.keys(schemaProperties(schema))) {
187
+ if (Object.hasOwn(record, key))
188
+ result[key] = record[key];
189
+ }
190
+ return Object.freeze(result);
191
+ }
192
+ function requireOwnSchema(schemas, plugin) {
193
+ const schema = schemas.get(plugin);
194
+ if (!schema)
195
+ throw new Error(`Missing own config schema for ${plugin}`);
196
+ return schema;
197
+ }
198
+ function formatErrors(errors) {
199
+ return errors.map((error) => {
200
+ const base = `${error.instancePath || '/'} ${error.message ?? error.keyword}`;
201
+ // Ajv params pinpoint the offending key / allowed enum so users can find
202
+ // the typo'd field instead of a bare "must NOT have additional properties".
203
+ const params = error.params;
204
+ if (typeof params?.additionalProperty === 'string') {
205
+ return `${base} (additionalProperty: ${params.additionalProperty})`;
206
+ }
207
+ if (Array.isArray(params?.allowedValues)) {
208
+ return `${base} (allowedValues: ${JSON.stringify(params.allowedValues)})`;
209
+ }
210
+ return base;
211
+ });
212
+ }
@@ -0,0 +1,19 @@
1
+ import type { RuntimeConfigDocument } from './config-composer.js';
2
+ import type { ConfigPatch } from './config-patch-planner.js';
3
+ export interface ConfigDocumentSnapshot {
4
+ readonly document: RuntimeConfigDocument;
5
+ readonly revision: string;
6
+ }
7
+ /** A prepared write is inert until the generation handoff activates it. */
8
+ export interface PreparedConfigDocument {
9
+ readonly document: RuntimeConfigDocument;
10
+ commit(): Promise<ConfigDocumentSnapshot>;
11
+ rollback(): Promise<void>;
12
+ }
13
+ export interface ConfigDocumentPort {
14
+ read(): Promise<ConfigDocumentSnapshot>;
15
+ prepare(current: ConfigDocumentSnapshot, patches: readonly ConfigPatch[]): Promise<PreparedConfigDocument>;
16
+ }
17
+ export declare class ConfigDocumentDivergenceError extends Error {
18
+ constructor();
19
+ }
@@ -0,0 +1,6 @@
1
+ export class ConfigDocumentDivergenceError extends Error {
2
+ constructor() {
3
+ super('ConfigDocument adapter candidate differs from the validated Runtime candidate');
4
+ this.name = 'ConfigDocumentDivergenceError';
5
+ }
6
+ }
@@ -0,0 +1,25 @@
1
+ import { type PluginId } from '@zhin.js/plugin-runtime';
2
+ import { ConfigComposer, type ComposedConfig, type RuntimeConfigDocument } from './config-composer.js';
3
+ import type { ProjectGraph } from './project-graph.js';
4
+ export type ConfigPatch = {
5
+ readonly op: 'set';
6
+ readonly path: readonly string[];
7
+ readonly value: unknown;
8
+ } | {
9
+ readonly op: 'remove';
10
+ readonly path: readonly string[];
11
+ };
12
+ export interface ConfigPatchPlan extends ComposedConfig {
13
+ readonly candidate: RuntimeConfigDocument;
14
+ readonly documentChanged: boolean;
15
+ readonly roots: readonly PluginId[];
16
+ }
17
+ export declare class ConfigPatchPathError extends Error {
18
+ constructor(message: string);
19
+ }
20
+ /** Validates a candidate document before deriving its minimal replacement forest. */
21
+ export declare class ConfigPatchPlanner {
22
+ private readonly composer;
23
+ constructor(composer?: ConfigComposer);
24
+ plan(graph: ProjectGraph, current: RuntimeConfigDocument, patches: readonly ConfigPatch[]): Promise<ConfigPatchPlan>;
25
+ }
@@ -0,0 +1,126 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { rootPluginId } from '@zhin.js/plugin-runtime';
3
+ import { ConfigComposer, } from './config-composer.js';
4
+ export class ConfigPatchPathError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = 'ConfigPatchPathError';
8
+ }
9
+ }
10
+ /** Validates a candidate document before deriving its minimal replacement forest. */
11
+ export class ConfigPatchPlanner {
12
+ composer;
13
+ constructor(composer = new ConfigComposer()) {
14
+ this.composer = composer;
15
+ }
16
+ async plan(graph, current, patches) {
17
+ const previous = await this.composer.compose(graph, current);
18
+ let candidate = structuredClone(current);
19
+ for (const patch of patches)
20
+ candidate = applyPatch(candidate, patch);
21
+ const next = await this.composer.compose(graph, candidate);
22
+ const changed = indexGraph(graph)
23
+ .filter((node) => !isDeepStrictEqual(previous.views.get(node.id), next.views.get(node.id)))
24
+ .map((node) => node.id);
25
+ return Object.freeze({
26
+ ...next,
27
+ candidate: Object.freeze(candidate),
28
+ documentChanged: !isDeepStrictEqual(current, candidate),
29
+ roots: Object.freeze(collapseRoots(changed)),
30
+ });
31
+ }
32
+ }
33
+ function applyPatch(document, patch) {
34
+ assertPath(patch.path);
35
+ if (patch.path.length === 0) {
36
+ if (patch.op === 'remove') {
37
+ throw new ConfigPatchPathError('The config document root cannot be removed');
38
+ }
39
+ return cloneDocument(patch.value);
40
+ }
41
+ if (patch.op === 'set')
42
+ setValue(document, patch.path, structuredClone(patch.value));
43
+ else
44
+ removeValue(document, patch.path);
45
+ return document;
46
+ }
47
+ function setValue(document, path, value) {
48
+ let target = document;
49
+ for (const [index, segment] of path.slice(0, -1).entries()) {
50
+ const existing = target[segment];
51
+ if (existing === undefined) {
52
+ const created = {};
53
+ target[segment] = created;
54
+ target = created;
55
+ }
56
+ else {
57
+ target = requireRecord(existing, path.slice(0, index + 1));
58
+ }
59
+ }
60
+ target[lastSegment(path)] = value;
61
+ }
62
+ function removeValue(document, path) {
63
+ let target = document;
64
+ for (const [index, segment] of path.slice(0, -1).entries()) {
65
+ const existing = target[segment];
66
+ if (existing === undefined)
67
+ return;
68
+ target = requireRecord(existing, path.slice(0, index + 1));
69
+ }
70
+ delete target[lastSegment(path)];
71
+ }
72
+ function cloneDocument(value) {
73
+ return requireRecord(structuredClone(value), []);
74
+ }
75
+ function requireRecord(value, path) {
76
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
77
+ throw new ConfigPatchPathError(`Config path ${pointer(path)} is not an object`);
78
+ }
79
+ return value;
80
+ }
81
+ function assertPath(path) {
82
+ for (const segment of path) {
83
+ if (!segment || segment === '__proto__' || segment === 'prototype' || segment === 'constructor') {
84
+ throw new ConfigPatchPathError(`Unsafe config path segment: ${segment || '<empty>'}`);
85
+ }
86
+ }
87
+ }
88
+ function lastSegment(path) {
89
+ const segment = path[path.length - 1];
90
+ if (!segment)
91
+ throw new ConfigPatchPathError('Config patch path is empty');
92
+ return segment;
93
+ }
94
+ function pointer(path) {
95
+ if (path.length === 0)
96
+ return '/';
97
+ return `/${path.map((segment) => segment.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}`;
98
+ }
99
+ function indexGraph(graph) {
100
+ const result = [];
101
+ const visit = (node) => {
102
+ result.push(node);
103
+ for (const child of node.children)
104
+ visit(child);
105
+ };
106
+ visit(graph.root);
107
+ return result;
108
+ }
109
+ function collapseRoots(plugins) {
110
+ if (plugins.includes(rootPluginId()))
111
+ return [rootPluginId()];
112
+ const selected = new Set(plugins);
113
+ return plugins.filter((plugin) => {
114
+ let parent = parentPlugin(plugin);
115
+ while (parent) {
116
+ if (selected.has(parent))
117
+ return false;
118
+ parent = parentPlugin(parent);
119
+ }
120
+ return true;
121
+ });
122
+ }
123
+ function parentPlugin(plugin) {
124
+ const separator = plugin.lastIndexOf('/');
125
+ return separator < 0 ? undefined : plugin.slice(0, separator);
126
+ }
@@ -0,0 +1,56 @@
1
+ import { type PluginId } from '@zhin.js/plugin-runtime';
2
+ import type { RuntimeEnvironment } from './environment.js';
3
+ export type EnvironmentSource = Readonly<Record<string, string | undefined>>;
4
+ export interface EnvironmentLayers {
5
+ readonly base?: EnvironmentSource;
6
+ readonly environments?: Readonly<Record<string, EnvironmentSource>>;
7
+ readonly plugins?: Readonly<Record<string, EnvironmentSource>>;
8
+ }
9
+ export interface EnvSchema<T> {
10
+ readonly secretKeys?: readonly string[];
11
+ parse(source: EnvironmentSource): T;
12
+ }
13
+ export interface EnvStore {
14
+ readonly owner: PluginId;
15
+ readonly environment: RuntimeEnvironment;
16
+ has(key: string): boolean;
17
+ get(key: string): string | undefined;
18
+ require(key: string): string;
19
+ parse<T>(schema: EnvSchema<T>): Readonly<T>;
20
+ /** Strict expansion: missing keys throw `EnvironmentVariableMissingError`. */
21
+ expand<T>(value: T): T;
22
+ /**
23
+ * Config-document expansion for Root compose.
24
+ * Missing keys become `""` so adapters that require credentials soft-fail at
25
+ * create instead of treating literal `${VAR}` as a real token.
26
+ */
27
+ expandMissingAsEmpty<T>(value: T): T;
28
+ redact(value: unknown, secretKeys: readonly string[]): unknown;
29
+ }
30
+ export declare const envStoreToken: import("@zhin.js/plugin-runtime").Token<EnvStore>;
31
+ export declare class EnvironmentVariableMissingError extends Error {
32
+ readonly owner: PluginId;
33
+ readonly key: string;
34
+ constructor(owner: PluginId, key: string);
35
+ }
36
+ export declare class EnvSchemaParseError extends Error {
37
+ readonly owner: PluginId;
38
+ constructor(owner: PluginId, message: string);
39
+ }
40
+ export declare function defineEnvSchema<T>(schema: EnvSchema<T>): Readonly<EnvSchema<T>>;
41
+ export declare function defineEnvironmentLayers(layers?: EnvironmentLayers): Readonly<EnvironmentLayers>;
42
+ export declare function createEnvStore(owner: PluginId, environment: RuntimeEnvironment, layers?: EnvironmentLayers): EnvStore;
43
+ /** Normalizes layers once, then derives immutable stores for each Plugin owner. */
44
+ export declare class EnvStoreFactory {
45
+ #private;
46
+ constructor(environment: RuntimeEnvironment, layers?: EnvironmentLayers);
47
+ create(owner: PluginId): EnvStore;
48
+ }
49
+ /**
50
+ * Standalone deep expansion over an arbitrary config value (e.g. a raw `ai`
51
+ * config document before EnvStore scoping exists). Supports `${VAR}` and
52
+ * `${VAR:-default}` / `${VAR:=default}` (default applies when the variable is
53
+ * unset or empty). Missing plain references resolve via `onMissing`
54
+ * (default: `""`, matching `EnvStore.expandMissingAsEmpty`).
55
+ */
56
+ export declare function expandEnvironmentValue<T>(value: T, lookup: (key: string) => string | undefined, onMissing?: (key: string) => string): T;