@tbox.cn/app-toolkit 0.1.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.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/chunk-VV3ERZXS.js +1 -0
- package/dist/contracts-resolver-QM4FBQQV.js +1 -0
- package/dist/index.d.ts +1909 -0
- package/dist/index.js +8 -0
- package/package.json +47 -0
- package/src/assembly/app-manifest.ts +132 -0
- package/src/assembly/provider-catalog.ts +167 -0
- package/src/assembly/walk-manifests.ts +461 -0
- package/src/core/contracts-expected.ts +31 -0
- package/src/core/contracts-resolver.ts +274 -0
- package/src/core/credential-format.ts +98 -0
- package/src/core/credential-mask.ts +19 -0
- package/src/core/env-expansion.ts +51 -0
- package/src/core/errors.ts +46 -0
- package/src/core/file-cache.ts +65 -0
- package/src/core/fskit.ts +21 -0
- package/src/core/module-schema.ts +139 -0
- package/src/dto.ts +298 -0
- package/src/factory.ts +297 -0
- package/src/index.ts +137 -0
- package/src/integrations/credentials.ts +153 -0
- package/src/integrations/mock-bindings.ts +55 -0
- package/src/integrations/predicate-io.ts +199 -0
- package/src/integrations/predicate.ts +598 -0
- package/src/integrations/read.ts +53 -0
- package/src/integrations/write.ts +448 -0
- package/src/views/app.ts +28 -0
- package/src/views/context.ts +70 -0
- package/src/views/modules.ts +48 -0
- package/src/views/providers.ts +165 -0
- package/src/views/service-detail.ts +32 -0
- package/src/views/service-resolutions.ts +323 -0
- package/tests/contracts-resolver.test.ts +203 -0
- package/tests/demo-app.ts +146 -0
- package/tests/dev-manifest-catalog.test.ts +114 -0
- package/tests/error-name-safety.test.ts +26 -0
- package/tests/file-cache.test.ts +242 -0
- package/tests/import-layers.test.ts +111 -0
- package/tests/module-schema.test.ts +84 -0
- package/tests/naming-alignment.test.ts +50 -0
- package/tests/views.test.ts +427 -0
- package/tests/write-core.test.ts +188 -0
- package/tsconfig.json +11 -0
- package/tsup.config.ts +29 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import Ajv from 'ajv';
|
|
4
|
+
import { resolveContractsForApp } from '../core/contracts-resolver.js';
|
|
5
|
+
import type { FileCache } from '../core/file-cache.js';
|
|
6
|
+
import { writeAtomic } from '../core/fskit.js';
|
|
7
|
+
import { SAFE_NAME_PATTERN } from '../core/module-schema.js';
|
|
8
|
+
import { walkManifests } from '../assembly/walk-manifests.js';
|
|
9
|
+
import { APP_INTEGRATION_NODE_KEYS } from '../dto.js';
|
|
10
|
+
import {
|
|
11
|
+
planAppCredentialArtifacts,
|
|
12
|
+
planServiceCredentialArtifacts,
|
|
13
|
+
reconcileOrphanCredentials,
|
|
14
|
+
writeCredentialArtifacts,
|
|
15
|
+
type AppliedCredential,
|
|
16
|
+
type CredentialInput,
|
|
17
|
+
} from './credentials.js';
|
|
18
|
+
import type { IntegrationServiceIssue } from './predicate.js';
|
|
19
|
+
import { checkInstanceKeys } from './predicate.js';
|
|
20
|
+
import { AppToolkitError } from '../core/errors.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 写核心(C3-4 write;FX-1c 管线重排——F3 九步定序 + D-B2 语义 + F4 输入扩位):
|
|
24
|
+
* PUT = 节点全量替换(三同构——body ≡ 文件节点;三 PUT 端点无例外)。
|
|
25
|
+
*
|
|
26
|
+
* 九步定序(applyApp / applyService 同构):
|
|
27
|
+
* 1 输入约束(assertSafeNames 值位 + assertSafeInstanceKeys map 键位——F4)
|
|
28
|
+
* 2 合并 node → next;文件缺席 → 骨架物化(R13 语义翻转提示入 issues)
|
|
29
|
+
* 3 凭据校验链(type 匹配 400 + credentialSchema ajv 字段级——schema 缺席宽松;dryRun 也跑)
|
|
30
|
+
* 4 凭据 stem/ref **纯派生**(plan*——零 I/O)+ ref 回填节点(**先于 no-diff**:
|
|
31
|
+
* node 缺 ref + 带 credentials → 节点变化 → 全量写;node 已含同 ref → 节点不变 → 纯凭据写)
|
|
32
|
+
* 5 noDiff(**深度结构等价**,非 stringify);hasCredentialInput
|
|
33
|
+
* 6 noDiff && 无凭据输入 → 短路 written:false
|
|
34
|
+
* 7 严格整文档校验(contracts parseIntegrationsConfig——触发 = !noDiff ∥ 有凭据输入)
|
|
35
|
+
* 8 凭据工件落盘(**后置于校验通过**——防「凭据已写 + zod 拒绝」不一致态;
|
|
36
|
+
* dryRun → writeCredentialArtifacts 零写盘 + existsSync 预览)
|
|
37
|
+
* 9 finalize:written = integrations 实写 ∥ 凭据实写(**D-B2**);files 分列两类路径;
|
|
38
|
+
* restartScheduled = written && onApplied(凭据影响 boot 装配——重启正当);写后缓存失效
|
|
39
|
+
* 归一化:N1 模块节点 {} → 删 domains[键];N3 服务节点 {} 不归一化(显式通配激活 = 合法 TIER0 态);
|
|
40
|
+
* N2 已随 ?instance= 退役。dryRun(PUT only):复读完整管线不落盘不排程。
|
|
41
|
+
* DELETE:节点移除 + 孤儿凭据写后对账(覆盖 DELETE 与 PUT 替换两类来源,只报告不清理——D9)。
|
|
42
|
+
* 并发:进程内串行(per-factory promise chain 由工厂持有——本模块假设单飞调用)。
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
export interface SaveResult {
|
|
46
|
+
written: boolean;
|
|
47
|
+
restartScheduled: boolean;
|
|
48
|
+
files: string[];
|
|
49
|
+
credentials: Array<{ stem: string; ref: string; overwrote: boolean }>;
|
|
50
|
+
issues: IntegrationServiceIssue[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface WriteOptions {
|
|
54
|
+
dryRun?: boolean;
|
|
55
|
+
/** body 唯一非文件字段(真值直落 config/credentials/,派生 ref 回填本节点) */
|
|
56
|
+
credentials?: CredentialInput;
|
|
57
|
+
credentialsByInstance?: Record<string, CredentialInput>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface WriteCoreOptions {
|
|
61
|
+
onApplied?: () => void;
|
|
62
|
+
cache?: FileCache;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const INTEGRATIONS_FILE = join('config', 'integrations.json');
|
|
66
|
+
|
|
67
|
+
/** 深度结构等价(非 stringify——键序陷阱误判 diff 防线) */
|
|
68
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
69
|
+
if (a === b) return true;
|
|
70
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
71
|
+
if (a.length !== b.length) return false;
|
|
72
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
73
|
+
}
|
|
74
|
+
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
|
75
|
+
const ka = Object.keys(a as Record<string, unknown>);
|
|
76
|
+
const kb = Object.keys(b as Record<string, unknown>);
|
|
77
|
+
if (ka.length !== kb.length) return false;
|
|
78
|
+
return ka.every((k) => deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]));
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 输入约束①(安全钉子,F4 扩位):node 内 provider/instanceId 值位 + map 键位字符集校验
|
|
84
|
+
* (第一道;deriveStem 断言为第二道)。违例 → 400 + fields 指向。 */
|
|
85
|
+
function assertSafeNames(value: unknown, path: string): void {
|
|
86
|
+
if (typeof value === 'string') return;
|
|
87
|
+
if (value && typeof value === 'object') {
|
|
88
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
89
|
+
if ((k === 'provider' || k === 'instanceId') && typeof v === 'string' && !SAFE_NAME_PATTERN.test(v)) {
|
|
90
|
+
throw new AppToolkitError(
|
|
91
|
+
'VALIDATION_FAILED',
|
|
92
|
+
400,
|
|
93
|
+
`${k} 名含非法字符(需 /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)`,
|
|
94
|
+
[`${path}.${k}`],
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
assertSafeNames(v, `${path}.${k}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** F4 map 键位校验:进凭据 stem 派生的 map 键(instances 覆盖位 / ByInstance 位 / credentialsByInstance 选项)。
|
|
103
|
+
* 通配键:instances map 的 '*'/'**' 豁免(TIER0 通配激活合法态——合法性归 zod/谓词 #8);
|
|
104
|
+
* ByInstance 层与 credentialsByInstance 无通配语义(TIER0 精确键)→ '*' 亦拒。 */
|
|
105
|
+
function assertSafeMapKeys(map: unknown, where: string, wildcardOk: boolean): void {
|
|
106
|
+
if (!map || typeof map !== 'object' || Array.isArray(map)) return;
|
|
107
|
+
for (const k of Object.keys(map as Record<string, unknown>)) {
|
|
108
|
+
if (wildcardOk && (k === '*' || k === '**')) continue;
|
|
109
|
+
if (!SAFE_NAME_PATTERN.test(k)) {
|
|
110
|
+
throw new AppToolkitError(
|
|
111
|
+
'VALIDATION_FAILED',
|
|
112
|
+
400,
|
|
113
|
+
`${where} 键 ${k} 含非法字符(需 /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)——进凭据 stem 派生,防路径逃逸`,
|
|
114
|
+
[where],
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** F4 调用面:按节点形态分派 map 键位校验(applyApp root / applyService slot / credentialsByInstance 选项) */
|
|
121
|
+
function assertSafeInstanceKeys(node: Record<string, unknown>, writeOpts: WriteOptions, kind: 'root' | 'slot'): void {
|
|
122
|
+
if (kind === 'root') {
|
|
123
|
+
// root.instances[].id(注册表 id——谓词 #8 白名单与实例键检查的同一空间)
|
|
124
|
+
const instances = node.instances;
|
|
125
|
+
if (Array.isArray(instances)) {
|
|
126
|
+
instances.forEach((entry, i) => {
|
|
127
|
+
const id = entry && typeof entry === 'object' ? (entry as { id?: unknown }).id : undefined;
|
|
128
|
+
if (typeof id === 'string' && !SAFE_NAME_PATTERN.test(id)) {
|
|
129
|
+
throw new AppToolkitError(
|
|
130
|
+
'VALIDATION_FAILED',
|
|
131
|
+
400,
|
|
132
|
+
`instances[${i}].id 含非法字符(需 /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)`,
|
|
133
|
+
[`root.instances[${i}].id`],
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
assertSafeMapKeys(node.configByInstance, 'root.configByInstance', false);
|
|
139
|
+
assertSafeMapKeys(node.credentialRefByInstance, 'root.credentialRefByInstance', false);
|
|
140
|
+
} else {
|
|
141
|
+
assertSafeMapKeys(node.instances, 'services.instances', true);
|
|
142
|
+
}
|
|
143
|
+
assertSafeMapKeys(writeOpts.credentialsByInstance, 'credentialsByInstance', false);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 写时读当前配置(cache 优先;缺席 → null——调用方决定骨架物化) */
|
|
147
|
+
function loadConfig(appDir: string, cache?: FileCache): Record<string, unknown> | null {
|
|
148
|
+
const file = join(appDir, INTEGRATIONS_FILE);
|
|
149
|
+
const parse = (raw: string): Record<string, unknown> => JSON.parse(raw) as Record<string, unknown>;
|
|
150
|
+
if (cache) return cache.read(file, parse) ?? null;
|
|
151
|
+
if (!existsSync(file)) return null;
|
|
152
|
+
try {
|
|
153
|
+
return parse(readFileSync(file, 'utf8'));
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 轻量复查 #8(写路径——谓词 checkInstanceKeys 单源,FX-2b):malls = root instances
|
|
160
|
+
* 注册表 ids;参考表缺席 → 跳过(谓词 #8 同语义;fail-visible——422 退役) */
|
|
161
|
+
function registryCheck(config: Record<string, unknown>): IntegrationServiceIssue[] {
|
|
162
|
+
const registryIds = ((config.instances as Array<{ id: string }> | undefined) ?? []).map((i) => i.id);
|
|
163
|
+
if (registryIds.length === 0) return [];
|
|
164
|
+
return checkInstanceKeys(config as never, registryIds);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** (provider, service) → 声明 credentialType(catalog 槽供给声明序首个;内联声明由调用方先行覆盖) */
|
|
168
|
+
function declaredCredentialType(appDir: string, provider: string, service: string | undefined, cache?: FileCache): string | undefined {
|
|
169
|
+
const { catalog } = walkManifests(appDir, cache);
|
|
170
|
+
const vendorImpls = catalog.providers[provider];
|
|
171
|
+
if (!vendorImpls) return undefined;
|
|
172
|
+
const types = new Set<string>();
|
|
173
|
+
for (const entry of Object.values(vendorImpls)) {
|
|
174
|
+
if (service === undefined || entry.services.includes(service)) types.add(entry.credentialType);
|
|
175
|
+
}
|
|
176
|
+
return types.size === 1 ? [...types][0] : undefined; // 歧义/缺席 → undefined(跳过 type 校验)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 凭据校验链(H8):type 匹配 400 + credentialSchema ajv 字段级 400 + schema 缺席跳过 */
|
|
180
|
+
function validateCredential(
|
|
181
|
+
appDir: string,
|
|
182
|
+
provider: string,
|
|
183
|
+
service: string | undefined,
|
|
184
|
+
credentials: CredentialInput,
|
|
185
|
+
inlineCredentialType: string | undefined,
|
|
186
|
+
cache?: FileCache,
|
|
187
|
+
): void {
|
|
188
|
+
// ① type 匹配(内联声明优先;catalog 槽供给次之;均缺席 → 跳过——宽松与 P11 对齐)
|
|
189
|
+
const expectedType = inlineCredentialType ?? declaredCredentialType(appDir, provider, service, cache);
|
|
190
|
+
if (expectedType !== undefined && expectedType !== '-' && credentials.type !== expectedType) {
|
|
191
|
+
throw new AppToolkitError(
|
|
192
|
+
'VALIDATION_FAILED',
|
|
193
|
+
400,
|
|
194
|
+
`凭据 type=${credentials.type} 与绑定 credentialType ${expectedType} 不一致`,
|
|
195
|
+
['credentials.type'],
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
// ② credentialSchema ajv 字段级(schema 缺席 → 跳过;坏文件 → 跳过——#6 归谓词)
|
|
199
|
+
if (service === undefined) return;
|
|
200
|
+
const { serviceSchemas } = walkManifests(appDir, cache);
|
|
201
|
+
const entry = serviceSchemas[`${provider}\u0000${service}`];
|
|
202
|
+
if (!entry?.credentialSchema) return;
|
|
203
|
+
const schemaFile = resolve(entry.packageDir, entry.credentialSchema);
|
|
204
|
+
if (!existsSync(schemaFile)) return;
|
|
205
|
+
try {
|
|
206
|
+
const ajv = new Ajv({ allErrors: true });
|
|
207
|
+
const validate = ajv.compile(JSON.parse(readFileSync(schemaFile, 'utf8')) as object);
|
|
208
|
+
if (!validate({ type: credentials.type, ...credentials.values })) {
|
|
209
|
+
const fields = (validate.errors ?? []).map(
|
|
210
|
+
(e) => `credentials.values.${String(e.instancePath || e.params?.missingProperty || '').replace(/^\//, '')}`,
|
|
211
|
+
);
|
|
212
|
+
throw new AppToolkitError('VALIDATION_FAILED', 400, `凭据字段校验失败(ajv 字段级)`, fields);
|
|
213
|
+
}
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (err instanceof AppToolkitError) throw err;
|
|
216
|
+
// schema 坏文件 → 跳过 ajv(宽松)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface WriteCore {
|
|
221
|
+
/** 预热(工厂创建/写前调用——严格校验缓存就绪;memoized resolver 后续 1 stat 级) */
|
|
222
|
+
warmup(): Promise<void>;
|
|
223
|
+
/** PUT /app/integration(root 节点全集) */
|
|
224
|
+
applyApp(node: Record<string, unknown>, opts?: WriteOptions): SaveResult;
|
|
225
|
+
/** PUT /modules/:module/integration(domains 换算;N1 {} 删域键) */
|
|
226
|
+
applyModule(moduleId: string, node: Record<string, unknown>, opts?: WriteOptions): SaveResult;
|
|
227
|
+
/** PUT /services/:service/integration(整节点含 instances map;N3 {} 不归一化) */
|
|
228
|
+
applyService(service: string, node: Record<string, unknown>, opts?: WriteOptions): SaveResult;
|
|
229
|
+
/** DELETE /services/:service/integration(恒整节点;幂等;无预演) */
|
|
230
|
+
deleteService(service: string): SaveResult;
|
|
231
|
+
/** 模块服务域键(module → domains 键 = 服务集派生域键集 upsert 位) */
|
|
232
|
+
moduleDomainKey(moduleId: string): string | undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function createWriteCore(appDir: string, opts: WriteCoreOptions = {}): WriteCore {
|
|
236
|
+
const { onApplied, cache } = opts;
|
|
237
|
+
|
|
238
|
+
function finalize(
|
|
239
|
+
next: Record<string, unknown> | null,
|
|
240
|
+
appliedCredentials: AppliedCredential[],
|
|
241
|
+
issues: IntegrationServiceIssue[],
|
|
242
|
+
dryRun: boolean | undefined,
|
|
243
|
+
credentialsWritten: boolean,
|
|
244
|
+
): SaveResult {
|
|
245
|
+
// D-B2:written ⟺ 任一文件实写;files 分列(integrations.json + 凭据路径);
|
|
246
|
+
// restartScheduled 跟随 written(凭据文件影响 boot 装配——重启正当)
|
|
247
|
+
const integrationsWritten = !dryRun && next !== null;
|
|
248
|
+
if (integrationsWritten) {
|
|
249
|
+
writeAtomic(join(appDir, INTEGRATIONS_FILE), `${JSON.stringify(next, null, 2)}\n`);
|
|
250
|
+
cache?.invalidate([join(appDir, INTEGRATIONS_FILE)]);
|
|
251
|
+
}
|
|
252
|
+
const written = integrationsWritten || credentialsWritten;
|
|
253
|
+
const restartScheduled = written && onApplied !== undefined;
|
|
254
|
+
if (restartScheduled) onApplied?.();
|
|
255
|
+
const files: string[] = [];
|
|
256
|
+
if (integrationsWritten) files.push(INTEGRATIONS_FILE);
|
|
257
|
+
if (credentialsWritten) files.push(...appliedCredentials.map((a) => `config/credentials/${a.stem}.json`));
|
|
258
|
+
return {
|
|
259
|
+
written,
|
|
260
|
+
restartScheduled,
|
|
261
|
+
files,
|
|
262
|
+
credentials: appliedCredentials,
|
|
263
|
+
issues,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let strictContracts: Awaited<ReturnType<typeof resolveContractsForApp>> | undefined;
|
|
268
|
+
|
|
269
|
+
async function warmup(): Promise<void> {
|
|
270
|
+
strictContracts = await resolveContractsForApp(appDir);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function runStrictValidation(next: Record<string, unknown>, issues: IntegrationServiceIssue[]): void {
|
|
274
|
+
const strict = strictContracts;
|
|
275
|
+
if (!strict || !strict.strictValidation || !strict.module.parseIntegrationsConfig) {
|
|
276
|
+
throw new AppToolkitError(
|
|
277
|
+
'CONTRACTS_NOT_RESOLVED',
|
|
278
|
+
503,
|
|
279
|
+
'contracts 严格校验面不可用(需 ≥0.9)——写路径门控失败(工厂边缘断言应先行拦截)',
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
void issues;
|
|
283
|
+
strict.module.parseIntegrationsConfig(next);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
warmup,
|
|
288
|
+
moduleDomainKey(moduleId: string): string | undefined {
|
|
289
|
+
const { modules } = walkManifests(appDir, cache);
|
|
290
|
+
const mod = modules.find((m) => m.id === moduleId);
|
|
291
|
+
if (!mod) return undefined;
|
|
292
|
+
const services = mod.descriptor.contributes.services ?? [];
|
|
293
|
+
const domains = new Set(services.map((s) => (s.service.includes('.') ? s.service.slice(0, s.service.indexOf('.')) : s.service)));
|
|
294
|
+
// 单域模块 → 该域键;多域/零域 → 模块 id 兜底键(domains 换算层约定)
|
|
295
|
+
return domains.size === 1 ? [...domains][0] : moduleId;
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
applyApp(node: Record<string, unknown>, writeOpts: WriteOptions = {}): SaveResult {
|
|
299
|
+
assertSafeNames(node, 'root');
|
|
300
|
+
assertSafeInstanceKeys(node, writeOpts, 'root');
|
|
301
|
+
const issues: IntegrationServiceIssue[] = [];
|
|
302
|
+
const current = loadConfig(appDir, cache);
|
|
303
|
+
const base: Record<string, unknown> = current ?? { services: {} };
|
|
304
|
+
if (current === null) {
|
|
305
|
+
issues.push({
|
|
306
|
+
severity: 'warning',
|
|
307
|
+
code: 'SKELETON_MATERIALIZED',
|
|
308
|
+
message: 'config/integrations.json 缺席——已物化骨架(写时自动初始化;R13 语义翻转提示)',
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// root 全量替换(D14 无例外语义):缺席根键删除(services/domains/modules 域不在根键集——随 base 保留),
|
|
312
|
+
// node 全键置入(未知键交严格 zod 拒——fail-visible;B10:原实现 spread+merge 残留 stale 根键)
|
|
313
|
+
const next: Record<string, unknown> = { ...base };
|
|
314
|
+
for (const k of APP_INTEGRATION_NODE_KEYS) if (!(k in node)) delete next[k];
|
|
315
|
+
for (const [k, v] of Object.entries(node)) next[k] = v;
|
|
316
|
+
// 凭据纯派生 + 校验链(dryRun 也跑——彩排语义)
|
|
317
|
+
const provider = typeof next.provider === 'string' ? next.provider : 'app';
|
|
318
|
+
const plan = planAppCredentialArtifacts(provider, writeOpts.credentials, writeOpts.credentialsByInstance);
|
|
319
|
+
for (const p of plan) validateCredential(appDir, provider, undefined, p.input, undefined, cache);
|
|
320
|
+
// ref 回填(先于 no-diff:node 缺 ref + 带 credentials → 节点变化 → 全量写)
|
|
321
|
+
const rootArtifact = plan.find((p) => p.instanceId === undefined);
|
|
322
|
+
if (writeOpts.credentials && rootArtifact) next.credentialRef = rootArtifact.ref;
|
|
323
|
+
if (writeOpts.credentialsByInstance) {
|
|
324
|
+
const byInstance = { ...((next.credentialRefByInstance as Record<string, string>) ?? {}) };
|
|
325
|
+
for (const p of plan) {
|
|
326
|
+
if (p.instanceId !== undefined) byInstance[p.instanceId] = p.ref;
|
|
327
|
+
}
|
|
328
|
+
next.credentialRefByInstance = byInstance;
|
|
329
|
+
}
|
|
330
|
+
const hasCredentialInput = writeOpts.credentials !== undefined || writeOpts.credentialsByInstance !== undefined;
|
|
331
|
+
const noDiff = deepEqual(current, next);
|
|
332
|
+
if (noDiff && !hasCredentialInput) {
|
|
333
|
+
return { written: false, restartScheduled: false, files: [], credentials: [], issues };
|
|
334
|
+
}
|
|
335
|
+
runStrictValidation(next, issues);
|
|
336
|
+
// 凭据工件落盘(后置于校验通过;dryRun 零写盘 + existsSync 预览)
|
|
337
|
+
const dryRun = writeOpts.dryRun === true;
|
|
338
|
+
const applied = writeCredentialArtifacts(appDir, plan, dryRun);
|
|
339
|
+
issues.push(...registryCheck(next));
|
|
340
|
+
return finalize(noDiff ? null : next, applied, issues, writeOpts.dryRun, !dryRun && plan.length > 0);
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
applyModule(moduleId: string, node: Record<string, unknown>, writeOpts: WriteOptions = {}): SaveResult {
|
|
344
|
+
assertSafeNames(node, 'root');
|
|
345
|
+
const issues: IntegrationServiceIssue[] = [];
|
|
346
|
+
const current = loadConfig(appDir, cache);
|
|
347
|
+
if (current === null && Object.keys(node).length === 0) {
|
|
348
|
+
// 空文件清空域 = no-op(N1 幂等)
|
|
349
|
+
return { written: false, restartScheduled: false, files: [], credentials: [], issues };
|
|
350
|
+
}
|
|
351
|
+
if (current === null) {
|
|
352
|
+
issues.push({
|
|
353
|
+
severity: 'warning',
|
|
354
|
+
code: 'SKELETON_MATERIALIZED',
|
|
355
|
+
message: 'config/integrations.json 缺席——已物化骨架(写时自动初始化;R13 语义翻转提示)',
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
const base: Record<string, unknown> = current ?? { services: {} };
|
|
359
|
+
const domainKey = this.moduleDomainKey(moduleId);
|
|
360
|
+
if (domainKey === undefined) {
|
|
361
|
+
throw new AppToolkitError('MODULE_NOT_FOUND', 404, `模块 ${moduleId} 非已装模块`);
|
|
362
|
+
}
|
|
363
|
+
const domains = { ...((base.domains as Record<string, unknown>) ?? {}) };
|
|
364
|
+
const next: Record<string, unknown> = { ...base, domains };
|
|
365
|
+
// N1:模块节点 {} → 删 domains[键](清域);其余 = 域键全量替换
|
|
366
|
+
if (Object.keys(node).length === 0) delete domains[domainKey];
|
|
367
|
+
else domains[domainKey] = { ...node };
|
|
368
|
+
if (deepEqual(current, next)) {
|
|
369
|
+
return { written: false, restartScheduled: false, files: [], credentials: [], issues };
|
|
370
|
+
}
|
|
371
|
+
runStrictValidation(next, issues);
|
|
372
|
+
issues.push(...registryCheck(next));
|
|
373
|
+
return finalize(next, [], issues, writeOpts.dryRun, false);
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
applyService(service: string, node: Record<string, unknown>, writeOpts: WriteOptions = {}): SaveResult {
|
|
377
|
+
assertSafeNames(node, 'root');
|
|
378
|
+
assertSafeInstanceKeys(node, writeOpts, 'slot');
|
|
379
|
+
const issues: IntegrationServiceIssue[] = [];
|
|
380
|
+
const current = loadConfig(appDir, cache);
|
|
381
|
+
if (current === null) {
|
|
382
|
+
issues.push({
|
|
383
|
+
severity: 'warning',
|
|
384
|
+
code: 'SKELETON_MATERIALIZED',
|
|
385
|
+
message: 'config/integrations.json 缺席——已物化骨架(写时自动初始化;R13 语义翻转提示)',
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const base: Record<string, unknown> = current ?? { services: {} };
|
|
389
|
+
const services = { ...((base.services as Record<string, unknown>) ?? {}) };
|
|
390
|
+
const next: Record<string, unknown> = { ...base, services };
|
|
391
|
+
// N3:服务节点 {} 不归一化(显式通配激活 = 合法 TIER0 态,与键缺席两义)——原样落位
|
|
392
|
+
services[service] = { ...node };
|
|
393
|
+
// 凭据纯派生 + 校验链(dryRun 也跑;内联声明 credentialType 为校验链输入)
|
|
394
|
+
const providerRaw = node.provider;
|
|
395
|
+
const inlineProvider = providerRaw !== null && typeof providerRaw === 'object' ? (providerRaw as Record<string, unknown>) : undefined;
|
|
396
|
+
const providerName = typeof providerRaw === 'string' ? providerRaw : typeof inlineProvider?.provider === 'string' ? inlineProvider.provider : 'app';
|
|
397
|
+
const domain = service.includes('.') ? service.slice(0, service.indexOf('.')) : service;
|
|
398
|
+
const plan = planServiceCredentialArtifacts(providerName, service, domain, writeOpts.credentials, writeOpts.credentialsByInstance);
|
|
399
|
+
for (const p of plan) {
|
|
400
|
+
validateCredential(appDir, providerName, service, p.input, typeof inlineProvider?.credentialType === 'string' ? inlineProvider.credentialType : undefined, cache);
|
|
401
|
+
}
|
|
402
|
+
// ref 回填本节点(先于 no-diff;credentials 在场覆盖 body ref;仅传 ref 原样透传)
|
|
403
|
+
const slotNode = services[service] as Record<string, unknown>;
|
|
404
|
+
const rootArtifact = plan.find((p) => p.instanceId === undefined);
|
|
405
|
+
if (writeOpts.credentials && rootArtifact) slotNode.credentialRef = rootArtifact.ref;
|
|
406
|
+
if (writeOpts.credentialsByInstance) {
|
|
407
|
+
const instances = { ...((slotNode.instances as Record<string, Record<string, unknown>>) ?? {}) };
|
|
408
|
+
for (const p of plan) {
|
|
409
|
+
if (p.instanceId === undefined) continue;
|
|
410
|
+
const inst = { ...(instances[p.instanceId] ?? {}) };
|
|
411
|
+
inst.credentialRef = p.ref;
|
|
412
|
+
instances[p.instanceId] = inst;
|
|
413
|
+
}
|
|
414
|
+
slotNode.instances = instances;
|
|
415
|
+
}
|
|
416
|
+
const hasCredentialInput = writeOpts.credentials !== undefined || writeOpts.credentialsByInstance !== undefined;
|
|
417
|
+
const noDiff = deepEqual(current, next);
|
|
418
|
+
if (noDiff && !hasCredentialInput) {
|
|
419
|
+
return { written: false, restartScheduled: false, files: [], credentials: [], issues };
|
|
420
|
+
}
|
|
421
|
+
runStrictValidation(next, issues);
|
|
422
|
+
// 凭据工件落盘(后置于校验通过;dryRun 零写盘 + existsSync 预览)
|
|
423
|
+
const dryRun = writeOpts.dryRun === true;
|
|
424
|
+
const applied = writeCredentialArtifacts(appDir, plan, dryRun);
|
|
425
|
+
issues.push(...registryCheck(next));
|
|
426
|
+
return finalize(noDiff ? null : next, applied, issues, writeOpts.dryRun, !dryRun && plan.length > 0);
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
deleteService(service: string): SaveResult {
|
|
430
|
+
const issues: IntegrationServiceIssue[] = [];
|
|
431
|
+
const current = loadConfig(appDir, cache);
|
|
432
|
+
const services = { ...((current?.services as Record<string, unknown>) ?? {}) };
|
|
433
|
+
if (!(service in services)) {
|
|
434
|
+
// 幂等:清不存在的节点 → written:false(无 409;无预演)
|
|
435
|
+
return { written: false, restartScheduled: false, files: [], credentials: [], issues };
|
|
436
|
+
}
|
|
437
|
+
delete services[service];
|
|
438
|
+
const next: Record<string, unknown> = { ...(current ?? { services: {} }), services };
|
|
439
|
+
writeAtomic(join(appDir, INTEGRATIONS_FILE), `${JSON.stringify(next, null, 2)}\n`);
|
|
440
|
+
cache?.invalidate([join(appDir, INTEGRATIONS_FILE)]);
|
|
441
|
+
// 孤儿凭据写后对账(D9——覆盖 DELETE 与 PUT 替换两类来源)
|
|
442
|
+
issues.push(...reconcileOrphanCredentials(appDir));
|
|
443
|
+
const restartScheduled = onApplied !== undefined;
|
|
444
|
+
if (restartScheduled) onApplied?.();
|
|
445
|
+
return { written: true, restartScheduled, files: [INTEGRATIONS_FILE], credentials: [], issues };
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
package/src/views/app.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AppView } from '../dto.js';
|
|
2
|
+
import { APP_INTEGRATION_NODE_KEYS } from '../dto.js';
|
|
3
|
+
import { moduleOccupiedServices, type ViewSnapshot } from './context.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 应用静态投影(views/app;C4;FX-1c B10——root 节点投影修)。
|
|
7
|
+
* /app = 平台服务 + root 节点原值(纯静态,D12):
|
|
8
|
+
* services = 服务词汇 − 模块占用(裸 string[]——owner/标题 UI 按首点段派生,F2);
|
|
9
|
+
* 平台服务状态不在此(→ /service-resolutions);模块服务不在此(→ /modules/:m)。
|
|
10
|
+
* integration = **root 节点投影**(APP_INTEGRATION_NODE_KEYS——D21/L1:GET 预填 ≡ PUT body;
|
|
11
|
+
* 文件另含 services/domains/modules 域键,不进 root 节点投影——原实现整文件透传,
|
|
12
|
+
* PUT 回会级联写回域键);文件缺席 = null,文件在场 → 投影对象(可为空)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function loadAppView(snapshot: ViewSnapshot): AppView {
|
|
16
|
+
const occupied = moduleOccupiedServices(snapshot.walk);
|
|
17
|
+
const services = snapshot.walk.vocabulary.filter((s) => !occupied.has(s));
|
|
18
|
+
const config = snapshot.integrations;
|
|
19
|
+
let integration: AppView['integration'] = null;
|
|
20
|
+
if (config) {
|
|
21
|
+
const node: Record<string, unknown> = {};
|
|
22
|
+
for (const k of APP_INTEGRATION_NODE_KEYS) {
|
|
23
|
+
if (k in config) node[k] = config[k];
|
|
24
|
+
}
|
|
25
|
+
integration = node as AppView['integration'];
|
|
26
|
+
}
|
|
27
|
+
return { services, integration };
|
|
28
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readIntegrationsConfig } from '../integrations/read.js';
|
|
2
|
+
import { walkManifests, type WalkCatalog, type WalkManifestsResult } from '../assembly/walk-manifests.js';
|
|
3
|
+
import type { FileCache } from '../core/file-cache.js';
|
|
4
|
+
import type { ResolvedContracts } from '../core/contracts-resolver.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 视图装配共享上下文(五投影共用;目录枚举零缓存——walkManifests 每次调用执行)。
|
|
8
|
+
* 域前缀换算(service → 首点段)= 本地一行 helper(文件格式词汇,静态面零 contracts 依赖)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ViewContext {
|
|
12
|
+
appDir: string;
|
|
13
|
+
cache?: FileCache;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ViewSnapshot {
|
|
17
|
+
appDir: string;
|
|
18
|
+
walk: WalkManifestsResult;
|
|
19
|
+
catalog: WalkCatalog;
|
|
20
|
+
integrations: Record<string, unknown> | null;
|
|
21
|
+
contracts: ResolvedContracts;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 域前缀换算(本地 helper——静态面零 contracts 依赖前提) */
|
|
25
|
+
export function domainOfService(service: string): string {
|
|
26
|
+
const i = service.indexOf('.');
|
|
27
|
+
return i === -1 ? service : service.slice(0, i);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 读取视图输入快照(每方法调用独立——Freshness Contract 单位 = 方法调用) */
|
|
31
|
+
export async function loadViewSnapshot(ctx: ViewContext): Promise<ViewSnapshot> {
|
|
32
|
+
const walk = walkManifests(ctx.appDir, ctx.cache);
|
|
33
|
+
const integrations = await readIntegrationsConfig(ctx.appDir, ctx.cache);
|
|
34
|
+
const { resolveContractsForApp } = await import('../core/contracts-resolver.js');
|
|
35
|
+
const contracts = await resolveContractsForApp(ctx.appDir);
|
|
36
|
+
return { appDir: ctx.appDir, walk, catalog: walk.catalog, integrations, contracts };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 同步快照(调用方已持有 contracts 时——避免重复解析) */
|
|
40
|
+
export function loadViewSnapshotSync(ctx: ViewContext, contracts: ResolvedContracts): ViewSnapshot {
|
|
41
|
+
const walk = walkManifests(ctx.appDir, ctx.cache);
|
|
42
|
+
const integrations = readIntegrationsConfig(ctx.appDir, ctx.cache);
|
|
43
|
+
return { appDir: ctx.appDir, walk, catalog: walk.catalog, integrations, contracts };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 模块占用服务集(demand 侧——平台服务 = 词汇 − 本集) */
|
|
47
|
+
export function moduleOccupiedServices(walk: WalkManifestsResult): Set<string> {
|
|
48
|
+
const occupied = new Set<string>();
|
|
49
|
+
for (const mod of walk.modules) {
|
|
50
|
+
for (const need of mod.descriptor.contributes.services ?? []) occupied.add(need.service);
|
|
51
|
+
for (const slot of mod.descriptor.contributes.providers?.slots ?? []) occupied.add(slot.service);
|
|
52
|
+
}
|
|
53
|
+
return occupied;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 供给关系点查:(provider, service) ∈ 供给关系 */
|
|
57
|
+
export function isSupplied(catalog: WalkCatalog, provider: string, service: string): boolean {
|
|
58
|
+
const impls = catalog.providers[provider];
|
|
59
|
+
if (!impls) return false;
|
|
60
|
+
return Object.values(impls).some((entry) => entry.services.includes(service));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 供给厂商名单(suppliedBy——picker 数据源;catalog 静态切片) */
|
|
64
|
+
export function suppliedByVendors(catalog: WalkCatalog, service: string): string[] {
|
|
65
|
+
const vendors: string[] = [];
|
|
66
|
+
for (const [vendor, impls] of Object.entries(catalog.providers)) {
|
|
67
|
+
if (Object.values(impls).some((entry) => entry.services.includes(service))) vendors.push(vendor);
|
|
68
|
+
}
|
|
69
|
+
return vendors.sort();
|
|
70
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ModuleDeclaration, ModuleDetailView, ServiceDemand } from '../dto.js';
|
|
2
|
+
import { AppToolkitError } from '../core/errors.js';
|
|
3
|
+
import { domainOfService, type ViewSnapshot } from './context.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 需求轴/模块静态投影(views/modules;C4)。静态端点结构性零求值(铁律 #3——
|
|
7
|
+
* 无 resolution/issues/contracts 键,永不 503)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** GET /modules:纯模块清单(demand 不在——卡片展开 = GET /modules/:m) */
|
|
11
|
+
export function loadModulesView(snapshot: ViewSnapshot): { modules: ModuleDeclaration[] } {
|
|
12
|
+
const modules: ModuleDeclaration[] = snapshot.walk.modules
|
|
13
|
+
.map((mod) => ({
|
|
14
|
+
id: mod.id,
|
|
15
|
+
name: mod.descriptor.name,
|
|
16
|
+
// 模块级 title/description:manifest 无模块级 meta 字段(demand meta 属 per-service)——
|
|
17
|
+
// 一期回退 install id / 空描述(UI 侧按词汇表或 demand meta 增强)
|
|
18
|
+
title: mod.descriptor.name,
|
|
19
|
+
description: '',
|
|
20
|
+
}))
|
|
21
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
22
|
+
return { modules };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 域键换算(模块服务集派生域键集;单域 = 该域,多域/零域 = 模块 id——与 write.moduleDomainKey 同约定) */
|
|
26
|
+
export function moduleDomainKeyOf(services: ServiceDemand[], moduleId: string): string {
|
|
27
|
+
const domainKeys = new Set(services.map((s) => domainOfService(s.service)));
|
|
28
|
+
return domainKeys.size === 1 ? [...domainKeys][0] : moduleId;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** GET /modules/:module:模块静态一站式(integration 节点原值 + demand;PUT 读对偶) */
|
|
32
|
+
export function loadModuleDetailView(snapshot: ViewSnapshot, moduleId: string): ModuleDetailView {
|
|
33
|
+
const mod = snapshot.walk.modules.find((m) => m.id === moduleId);
|
|
34
|
+
if (!mod) {
|
|
35
|
+
throw new AppToolkitError('MODULE_NOT_FOUND', 404, `模块 ${moduleId} 非已装模块`);
|
|
36
|
+
}
|
|
37
|
+
const services: ServiceDemand[] = (mod.descriptor.contributes.services ?? []).map((need) => ({
|
|
38
|
+
service: need.service,
|
|
39
|
+
...(need.title ? { title: need.title } : {}),
|
|
40
|
+
...(need.description ? { description: need.description } : {}),
|
|
41
|
+
required: need.optional !== false,
|
|
42
|
+
}));
|
|
43
|
+
const domains = (snapshot.integrations as { domains?: Record<string, unknown> } | null)?.domains ?? {};
|
|
44
|
+
const domainKey = moduleDomainKeyOf(services, moduleId);
|
|
45
|
+
// integration = domains[换算键] 节点原值(D21 编辑预填源);未配置 = null
|
|
46
|
+
const integration = (domains[domainKey] as ModuleDetailView['integration']) ?? null;
|
|
47
|
+
return { integration, services };
|
|
48
|
+
}
|