@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/chunk-VV3ERZXS.js +1 -0
  4. package/dist/contracts-resolver-QM4FBQQV.js +1 -0
  5. package/dist/index.d.ts +1909 -0
  6. package/dist/index.js +8 -0
  7. package/package.json +47 -0
  8. package/src/assembly/app-manifest.ts +132 -0
  9. package/src/assembly/provider-catalog.ts +167 -0
  10. package/src/assembly/walk-manifests.ts +461 -0
  11. package/src/core/contracts-expected.ts +31 -0
  12. package/src/core/contracts-resolver.ts +274 -0
  13. package/src/core/credential-format.ts +98 -0
  14. package/src/core/credential-mask.ts +19 -0
  15. package/src/core/env-expansion.ts +51 -0
  16. package/src/core/errors.ts +46 -0
  17. package/src/core/file-cache.ts +65 -0
  18. package/src/core/fskit.ts +21 -0
  19. package/src/core/module-schema.ts +139 -0
  20. package/src/dto.ts +298 -0
  21. package/src/factory.ts +297 -0
  22. package/src/index.ts +137 -0
  23. package/src/integrations/credentials.ts +153 -0
  24. package/src/integrations/mock-bindings.ts +55 -0
  25. package/src/integrations/predicate-io.ts +199 -0
  26. package/src/integrations/predicate.ts +598 -0
  27. package/src/integrations/read.ts +53 -0
  28. package/src/integrations/write.ts +448 -0
  29. package/src/views/app.ts +28 -0
  30. package/src/views/context.ts +70 -0
  31. package/src/views/modules.ts +48 -0
  32. package/src/views/providers.ts +165 -0
  33. package/src/views/service-detail.ts +32 -0
  34. package/src/views/service-resolutions.ts +323 -0
  35. package/tests/contracts-resolver.test.ts +203 -0
  36. package/tests/demo-app.ts +146 -0
  37. package/tests/dev-manifest-catalog.test.ts +114 -0
  38. package/tests/error-name-safety.test.ts +26 -0
  39. package/tests/file-cache.test.ts +242 -0
  40. package/tests/import-layers.test.ts +111 -0
  41. package/tests/module-schema.test.ts +84 -0
  42. package/tests/naming-alignment.test.ts +50 -0
  43. package/tests/views.test.ts +427 -0
  44. package/tests/write-core.test.ts +188 -0
  45. package/tsconfig.json +11 -0
  46. package/tsup.config.ts +29 -0
@@ -0,0 +1,274 @@
1
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import type { ResolvedModule } from './contracts-expected.js';
5
+ import { createFileCache, type FileCache } from './file-cache.js';
6
+
7
+ /**
8
+ * contracts 动态解析器(方案心脏,D3):`resolveContractsForApp(appDir)`——
9
+ * 成员扫描 + 包根定位。沙箱内 toolkit 与目标应用版本解耦:求值与校验双面由
10
+ * 应用自己的 @tbox.cn/app-contracts 版本驱动。
11
+ *
12
+ * 解析算法:
13
+ * 候选目录序(越靠前越优):
14
+ * 1. appDir/node_modules/@tbox.cn/app-contracts (npm 平铺 / 应用根直装)
15
+ * 2. appDir/apps/server/node_modules/… (pnpm 成员级——运行时消费方优先)
16
+ * 3. appDir/apps/<各成员>/node_modules/…(其余成员,字母序去重)
17
+ * 4. appDir/packages/<各包>/node_modules/…(codegen 模块包)
18
+ * 每候选:existsSync → realpathSync(解 pnpm symlink 至 store 真实路径)
19
+ * → 探测 <根>/dist/runtime.js(构建/发布/验证态)|<根>/src/runtime.ts(workspace link 态,TS-host 前提)
20
+ * → 读 **候选路径**(symlink 原路径)package.json version——版本轻探测:pnpm 升级换 store 目标
21
+ * 可觉察(realpath 缓存路径旧 version 恒旧,读候选路径修正——真 bug 候选防线)
22
+ * → await import(探测命中文件) → 逐命名导出存在性检查(contracts-expected EvaluationExports)
23
+ * 首个命中即返回(候选序即优先级);全候选缺席/导入拒绝 → 未解析(**绝不 throw**)。
24
+ *
25
+ * **红线**:禁 createRequire().resolve(contracts 发布面 exports 仅 types+import 双条件、
26
+ * 无 require/default——必抛 ERR_PACKAGE_PATH_NOT_EXPORTED);动态 import 路径运行时计算
27
+ * (pathToFileURL——打包器不得字面量折叠,README 打包纪律)。
28
+ *
29
+ * memoize:per appDir 记忆 { promise, version };每次 resolve 轻探测候选路径 version——
30
+ * 变更 → 重走候选链 + 动态 import(Node import 缓存按路径天然去重;升级契约自动生效,
31
+ * 不依赖事件广播——node_modules 被 FileWatcher IGNORED_DIRS 过滤,自愈设计不依赖广播)。
32
+ */
33
+
34
+ export interface ResolvedContracts {
35
+ /** 候选包 package.json version(未安装 → null) */
36
+ version: string | null;
37
+ /** 求值面可用(resolveEffectiveService 等命名导出在场) */
38
+ evaluation: boolean;
39
+ /** 严格 zod 面可用(parseIntegrationsConfig 在场——≥0.9 门控判据) */
40
+ strictValidation: boolean;
41
+ /** 动态 import 产物(命名导出子集——Partial<EvaluationExports>) */
42
+ module: ResolvedModule;
43
+ /** 解析诊断(N1 构建指引 message 三分支的输入:未安装 / 版本 <0.9 / 源码态未构建) */
44
+ notes: string[];
45
+ }
46
+
47
+ /** 求值面必需具名导出(在场 → evaluation: true) */
48
+ const EVALUATION_EXPORT_NAMES = [
49
+ 'resolveEffectiveService',
50
+ 'buildSupplyLookup',
51
+ 'moduleDomainOf',
52
+ 'normalizeInstances',
53
+ 'RESOURCE_TYPE_IDS',
54
+ ] as const;
55
+
56
+ /** 严格校验面具名导出(在场 → strictValidation: true;0.9+) */
57
+ const STRICT_EXPORT_NAME = 'parseIntegrationsConfig';
58
+
59
+ const UNRESOLVED: ResolvedContracts = {
60
+ version: null,
61
+ evaluation: false,
62
+ strictValidation: false,
63
+ module: {},
64
+ notes: [],
65
+ };
66
+
67
+ /** 候选目录序(server 优先——运行时消费方;其余字母序;packages/ 为 codegen 模块包) */
68
+ function candidateDirs(appDir: string): string[] {
69
+ const dirs: string[] = [appDir];
70
+ for (const sub of ['apps', 'packages']) {
71
+ const parent = join(appDir, sub);
72
+ if (!existsSync(parent)) continue;
73
+ const entries = readdirSync(parent, { withFileTypes: true })
74
+ .filter((d) => d.isDirectory())
75
+ .map((d) => d.name);
76
+ if (sub === 'apps') {
77
+ // server 运行时消费方优先,其余字母序
78
+ entries.sort((a, b) => {
79
+ if (a === 'server') return -1;
80
+ if (b === 'server') return 1;
81
+ return a.localeCompare(b);
82
+ });
83
+ } else {
84
+ entries.sort((a, b) => a.localeCompare(b));
85
+ }
86
+ for (const name of entries) dirs.push(join(parent, name));
87
+ }
88
+ return dirs;
89
+ }
90
+
91
+ /** 轻探测单候选快照(F9):路径 + version(file-cache)+ 产物态——memo 命中判定指纹的原子单元 */
92
+ interface CandidateProbe {
93
+ /** package.json 路径(轻探测读路径——symlink 原路径,pnpm 换 store 目标可觉察) */
94
+ pkgPath: string;
95
+ /** 候选包根(node_modules/@tbox.cn/app-contracts——symlink 原路径,产物态探测基准) */
96
+ candidateDir: string;
97
+ version: string | null;
98
+ /** dist(构建/发布态)| src(workspace link 态)| none(无产物——待构建)| gone(候选缺席) */
99
+ artifact: 'dist' | 'src' | 'none' | 'gone';
100
+ }
101
+
102
+ interface MemoEntry {
103
+ promise: Promise<ResolvedContracts>;
104
+ /** 解析时首个在册候选(候选序变化/修复/新候选出现可觉察) */
105
+ first: CandidateProbe | null;
106
+ /** 解析实际命中候选(unresolved → null;版本变更/产物物化/候选消失可觉察——F9 两处 stale 边缘同修) */
107
+ resolved: CandidateProbe | null;
108
+ }
109
+
110
+ const memo = new Map<string, MemoEntry>();
111
+ const versionProbeCache: FileCache = createFileCache();
112
+
113
+ /** 版本读取(候选路径——经 file-cache;文件缺席 → null) */
114
+ function readCandidateVersion(pkgPath: string): string | null {
115
+ return (
116
+ versionProbeCache.read(pkgPath, (raw) => {
117
+ const parsed = JSON.parse(raw) as { version?: string };
118
+ return typeof parsed.version === 'string' ? parsed.version : null;
119
+ }) ?? null
120
+ );
121
+ }
122
+
123
+ /** 候选产物态(realpath 解 symlink 后探测 dist/src;候选缺席 → 'gone') */
124
+ function artifactStateOf(candidateDir: string): CandidateProbe['artifact'] {
125
+ if (!existsSync(candidateDir)) return 'gone';
126
+ let root: string;
127
+ try {
128
+ root = realpathSync(candidateDir);
129
+ } catch {
130
+ root = candidateDir;
131
+ }
132
+ if (existsSync(join(root, 'dist', 'runtime.js'))) return 'dist';
133
+ if (existsSync(join(root, 'src', 'runtime.ts'))) return 'src';
134
+ return 'none';
135
+ }
136
+
137
+ /** 指定候选路径探测(命中候选复探——与候选序扫描解耦) */
138
+ function probeCandidatePath(candidateDir: string): CandidateProbe {
139
+ const pkgPath = join(candidateDir, 'package.json');
140
+ return { pkgPath, candidateDir, version: readCandidateVersion(pkgPath), artifact: artifactStateOf(candidateDir) };
141
+ }
142
+
143
+ /** 候选序首个在册候选(1 候选序扫描 + 1 版本读(cache)+ 1-2 产物 existsSync) */
144
+ function probeFirstCandidate(appDir: string): CandidateProbe | null {
145
+ for (const dir of candidateDirs(appDir)) {
146
+ const candidateDir = join(dir, 'node_modules', '@tbox.cn', 'app-contracts');
147
+ const pkgPath = join(candidateDir, 'package.json');
148
+ if (!existsSync(pkgPath)) continue;
149
+ return { pkgPath, candidateDir, version: readCandidateVersion(pkgPath), artifact: artifactStateOf(candidateDir) };
150
+ }
151
+ return null;
152
+ }
153
+
154
+ function candidateFingerprint(p: CandidateProbe | null): string {
155
+ return p === null ? 'none' : `${p.pkgPath}|${p.version ?? 'null'}|${p.artifact}`;
156
+ }
157
+
158
+ /** memo 命中判定(F9):首个在册候选指纹稳定(序/版本/产物态)+ 命中候选指纹稳定(版本/产物态)。
159
+ * 同指纹即等价——unresolved(resolved=null)仅需首个候选稳定。 */
160
+ function memoHit(appDir: string, entry: MemoEntry): boolean {
161
+ const first = probeFirstCandidate(appDir);
162
+ if (candidateFingerprint(first) !== candidateFingerprint(entry.first)) return false;
163
+ if (entry.resolved === null) return true;
164
+ const resolvedNow =
165
+ first !== null && first.candidateDir === entry.resolved.candidateDir ? first : probeCandidatePath(entry.resolved.candidateDir);
166
+ return candidateFingerprint(resolvedNow) === candidateFingerprint(entry.resolved);
167
+ }
168
+
169
+ /** 探测单候选:返回已解析产物或 null(该候选不可用——缺席/导入拒绝) */
170
+ async function probeCandidate(
171
+ candidate: string,
172
+ notes: string[],
173
+ ): Promise<ResolvedContracts | null> {
174
+ if (!existsSync(candidate)) return null;
175
+ // realpathSync 解 pnpm symlink 至 store 真实路径(探测/导入以包根为基准)
176
+ let packageRoot: string;
177
+ try {
178
+ packageRoot = realpathSync(candidate);
179
+ } catch {
180
+ return null;
181
+ }
182
+ // 探测链:dist/runtime.js(构建/发布/验证态)优先 → src/runtime.ts(workspace link 态,TS-host 前提)
183
+ const probes = [join(packageRoot, 'dist', 'runtime.js'), join(packageRoot, 'src', 'runtime.ts')];
184
+ const probeFile = probes.find((p) => existsSync(p));
185
+ // version 轻探测读**候选路径**(symlink 原路径)——非 realpath 缓存路径(pnpm 重装换 store 目标可觉察)
186
+ const version = versionProbeCache.read(join(candidate, 'package.json'), (raw) => {
187
+ const parsed = JSON.parse(raw) as { version?: string };
188
+ return typeof parsed.version === 'string' ? parsed.version : null;
189
+ }) ?? null;
190
+
191
+ if (probeFile === undefined) {
192
+ notes.push(
193
+ `contracts ${version ?? '(版本未知)'} 探测命中但无构建产物(dist/runtime.js 缺失)——workspace 源码态未构建:执行 pnpm --filter @tbox.cn/app-contracts build 后重试`,
194
+ );
195
+ return null;
196
+ }
197
+ try {
198
+ // 动态 import 路径运行时计算(pathToFileURL——Windows 安全 + 打包器禁折叠)。
199
+ // version query cache-bust:Node ESM import 缓存按完整 URL 去重——版本不变命中缓存
200
+ // (dedup 保留),版本变更 → 新 URL → 强制重 import(新版本语义生效,W4;覆盖
201
+ // symlink 换 store 目标与候选包原地 bump 两种升级形态)。
202
+ const versionQuery = version !== null ? `?v=${encodeURIComponent(version)}` : '';
203
+ const mod = (await import(pathToFileURL(probeFile).href + versionQuery)) as Record<string, unknown>;
204
+ const evaluation = EVALUATION_EXPORT_NAMES.every((name) => mod[name] !== undefined);
205
+ const strictValidation = mod[STRICT_EXPORT_NAME] !== undefined;
206
+ return {
207
+ version,
208
+ evaluation,
209
+ strictValidation,
210
+ module: mod as ResolvedModule,
211
+ notes: evaluation
212
+ ? []
213
+ : [`${probeFile} 命中但求值面具名导出缺席(${EVALUATION_EXPORT_NAMES.join('/')})——契约包形态异常`],
214
+ };
215
+ } catch (err) {
216
+ // import 拒绝一律视为「未解析」(绝不 throw)——N1 构建指引 message 分支输入
217
+ notes.push(
218
+ `contracts ${version ?? '(版本未知)'} 构建产物导入失败(${err instanceof Error ? err.message : String(err)})——workspace 源码态未构建或产物损坏:执行 pnpm --filter @tbox.cn/app-contracts build 后重试`,
219
+ );
220
+ return null;
221
+ }
222
+ }
223
+
224
+ /** 解析应用自己的 contracts(memoize + 轻探测指纹;绝不 throw——全缺席 → 未解析产物)。
225
+ * F9:命中判定 = 首个在册候选 + 命中候选双指纹(版本变更 / dist 物化 / 候选序变化 / 候选消失
226
+ * 全面可觉察——覆盖「build 后重试」长生命周期宿主与多候选走位两处 stale 边缘)。 */
227
+ export function resolveContractsForApp(appDir: string): Promise<ResolvedContracts> {
228
+ // 轻探测:双指纹与 memoized 比对(1 候选序扫描 + 版本读(cache)+ 产物 existsSync)
229
+ const hit = memo.get(appDir);
230
+ if (hit && memoHit(appDir, hit)) return hit.promise;
231
+
232
+ // in-flight 共享:resolved 未知——暂按首个在册候选估算(同指纹并发调用共享单次解析;
233
+ // 解决后按实际命中候选覆写;候选稳定期内的并发调用恒命中本 promise)
234
+ const provisional = probeFirstCandidate(appDir);
235
+ const entry: MemoEntry = {
236
+ promise: Promise.resolve({ ...UNRESOLVED }),
237
+ first: provisional,
238
+ resolved: provisional,
239
+ };
240
+ entry.promise = (async (): Promise<ResolvedContracts> => {
241
+ const notes: string[] = [];
242
+ for (const dir of candidateDirs(appDir)) {
243
+ const candidate = join(dir, 'node_modules', '@tbox.cn', 'app-contracts');
244
+ const resolved = await probeCandidate(candidate, notes);
245
+ if (resolved) {
246
+ if (resolved.version !== null && !resolved.strictValidation) {
247
+ // 注:push 进 resolved.notes(probeCandidate 返回体自带 notes 字段——调用侧 notes 仅供 null 路径聚合)
248
+ resolved.notes.push(
249
+ `contracts ${resolved.version} 无严格校验面(需 ≥0.9)——升级应用 @tbox.cn/app-contracts 依赖后可用`,
250
+ );
251
+ }
252
+ // 实际命中候选覆写(多候选走位可觉察——后续命中判定以命中候选为基准;F9)
253
+ entry.resolved = probeCandidatePath(candidate);
254
+ return resolved;
255
+ }
256
+ }
257
+ // 全候选缺席/不可用:unresolved 记录(首个候选指纹稳定即保持;其产物物化/版本变更/消失触发重扫)
258
+ entry.resolved = null;
259
+ // 全候选缺席:区分「未安装」与「探测命中但不可用」——notes 已带细分(导入拒绝/产物缺失)
260
+ if (notes.length === 0) {
261
+ notes.push('contracts 不可解析(未安装)——检查应用依赖安装后重试');
262
+ }
263
+ return { ...UNRESOLVED, notes };
264
+ })();
265
+
266
+ memo.set(appDir, entry);
267
+ return entry.promise;
268
+ }
269
+
270
+ /** 逃生舱(测试/宿主特殊场景——正常路径无需调用,Freshness Contract 自动;invalidate 逃生舱标注) */
271
+ export function invalidateContractsResolver(appDir?: string): void {
272
+ if (appDir === undefined) memo.clear();
273
+ else memo.delete(appDir);
274
+ }
@@ -0,0 +1,98 @@
1
+ import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ /**
5
+ * 凭据双工件·文件格式与 stem 派生(写端/读端共用格式单源)。
6
+ *
7
+ * 凭据文件 `config/credentials/<stem>.json` = `{ type, ...values }`(真值工件);
8
+ * integrations.json 恒持 `secret://<stem>` 引用(ref),任何 GET 路径无真值。
9
+ *
10
+ * stem 三形(服务端单源派生;与既有自由 stem 兼容):
11
+ * 服务级 `${provider}-${service}`
12
+ * 实例级 `${provider}-${instanceId}-${domain}`(domain = service 首点段)
13
+ * 应用级 `${provider}-global` / `${provider}-${instanceId}-global`
14
+ *
15
+ * 安全(纵深防御第二道):deriveStem 对任一输入含 `/`、`\`、`..` 直接 throw——
16
+ * 第一道为 descriptor/节点 zod 的字符集 pattern(module-schema SAFE_NAME_PATTERN)。
17
+ */
18
+ import { maskCredentialValues } from './credential-mask.js';
19
+
20
+ export { maskCredentialValues };
21
+
22
+ export interface CredentialFile {
23
+ type: string;
24
+ values: Record<string, string>;
25
+ }
26
+
27
+ /** 凭据 stem 派生输入(service 级 / instance 级 / app 级三形由调用方按层选择字段) */
28
+ export interface StemInput {
29
+ provider: string;
30
+ service?: string;
31
+ instanceId?: string;
32
+ domain?: string;
33
+ }
34
+
35
+ function assertSafeSegment(kind: string, value: string | undefined): void {
36
+ if (value === undefined) return;
37
+ if (value.includes('/') || value.includes('\\') || value.includes('..')) {
38
+ // 纵深防御断言:zod pattern 为第一道,此处兜底敌对输入(测试 O15 钉死)
39
+ throw new Error(`凭据 stem 派生输入含路径敌对字符(${kind})——拒绝派生`);
40
+ }
41
+ }
42
+
43
+ /** stem 单源派生(三形;输入含 / \ .. → throw——断言防线) */
44
+ export function deriveStem(input: StemInput): string {
45
+ assertSafeSegment('provider', input.provider);
46
+ assertSafeSegment('service', input.service);
47
+ assertSafeSegment('instanceId', input.instanceId);
48
+ assertSafeSegment('domain', input.domain);
49
+ if (input.instanceId !== undefined) {
50
+ // 实例级:${provider}-${instanceId}-${domain}(domain 缺省 = 'global'——应用级实例位)
51
+ const domain = input.domain ?? input.service ?? 'global';
52
+ return `${input.provider}-${input.instanceId}-${domain}`;
53
+ }
54
+ if (input.service !== undefined) {
55
+ // 服务级:${provider}-${service}
56
+ return `${input.provider}-${input.service}`;
57
+ }
58
+ // 应用级:${provider}-global
59
+ return `${input.provider}-global`;
60
+ }
61
+
62
+ /** stem → ref('secret://<stem>') */
63
+ export function stemToRef(stem: string): string {
64
+ return `secret://${stem}`;
65
+ }
66
+
67
+ /** stem → 凭据文件相对路径('config/credentials/<stem>.json') */
68
+ export function stemToFilePath(stem: string): string {
69
+ return `config/credentials/${stem}.json`;
70
+ }
71
+
72
+ /** 读凭据文件(真值工件;缺席 → null) */
73
+ export function readCredentialFile(appDir: string, stem: string): CredentialFile | null {
74
+ const file = join(appDir, stemToFilePath(stem));
75
+ if (!existsSync(file)) return null;
76
+ try {
77
+ const raw = JSON.parse(readFileSync(file, 'utf8')) as { type?: string } & Record<string, unknown>;
78
+ if (typeof raw.type !== 'string') return null;
79
+ const { type, ...rest } = raw;
80
+ const values: Record<string, string> = {};
81
+ for (const [k, v] of Object.entries(rest)) {
82
+ if (typeof v === 'string') values[k] = v;
83
+ }
84
+ return { type, values };
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /** 写凭据工件(真值;写前 ensureDir 物化父目录——credentials 目录首次写物化)。
91
+ * 返回 overwrote(同 stem 已在场 = 重写)。调用方负责先跑凭据校验链(type 匹配 + ajv 字段级)。 */
92
+ export function writeCredentialFile(appDir: string, stem: string, file: CredentialFile): boolean {
93
+ const path = join(appDir, stemToFilePath(stem));
94
+ const overwrote = existsSync(path);
95
+ mkdirSync(dirname(path), { recursive: true });
96
+ writeFileSync(path, `${JSON.stringify({ type: file.type, ...file.values }, null, 2)}\n`, 'utf8');
97
+ return overwrote;
98
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 凭据掩码算法(GET 路径永无真值——CredentialEcho.masked 单源)。
3
+ * 独立小模块:integrations/credentials.ts(读端掩码)与测试共用,避免 credential-format
4
+ * 的写端 fs 依赖被读端连带拉起(views 层零 fs 依赖约束的前置拆分)。
5
+ */
6
+
7
+ /** 掩码规则:每字段前 2 字符 + '****';≤4 字符退化全 '****';缺字段 → null。
8
+ * 序列化后零真值断言由测试 O4 钉死。 */
9
+ export function maskCredentialValues(values: Record<string, string>): Record<string, string | null> {
10
+ const out: Record<string, string | null> = {};
11
+ for (const [k, v] of Object.entries(values)) {
12
+ if (typeof v !== 'string') {
13
+ out[k] = null;
14
+ continue;
15
+ }
16
+ out[k] = v.length <= 4 ? '****' : `${v.slice(0, 2)}****`;
17
+ }
18
+ return out;
19
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 配置 JSON 环境变量占位展开(纯数据形态——自 app-sdk env-expansion.ts 迁入改造)。
3
+ *
4
+ * 语法(与 SDK 版语义逐字一致,共享向量 test-fixtures/env-expansion-cases.json 防漂移):
5
+ * ${NAME} 占位引用;NAME 限定 [A-Z][A-Z0-9_]*(UPPER_SNAKE,最小化与正文 `${...}` 误碰)
6
+ * $${NAME} 转义为字面量 ${NAME}(原样保留一次 $)
7
+ *
8
+ * 语义(评审 N1 钉死,壳层各自包装 throw/report):
9
+ * - 单遍非递归:env 值内若含 `${OTHER}` 不被二次展开(注入防线);String.replace 回调形态天然单遍。
10
+ * - 值经 JSON 字符串转义注入;不做数值/布尔强制转换。
11
+ * - **本版为纯数据返回形态** `{ text?: string; missing: string[] }`——缺变量不 throw,
12
+ * 由壳层(CLI doctor 壳 / app-sdk 桥 throw 壳 / 沙箱宿主)决定 fail-fast 还是降级:
13
+ * SDK 桥壳 missing 非空即 throw(错误文案逐字等价 SDK 旧版);CLI 壳同款。
14
+ * - 无 ${ 的文本快速路径:text 原文返回,零替换零开销。
15
+ */
16
+
17
+ /** 占位匹配:$ 或 $$ 后接 {[A-Z][A-Z0-9_]*}($$ 形态同匹配,展开期判首字符去留) */
18
+ const ENV_VAR_RE = /\$\$?\{([A-Z][A-Z0-9_]*)\}/g;
19
+
20
+ /** JSON 字符串字面量转义(值 → 可直接置于 "..." 内的安全文本)——
21
+ * 基于 JSON.stringify 切片:引号/反斜杠/控制字符(\b/\n/\r/\t/\f)转义语义与 JSON 精确一致 */
22
+ function jsonEscape(value: string): string {
23
+ return JSON.stringify(value).slice(1, -1);
24
+ }
25
+
26
+ export interface EnvExpansionResult {
27
+ /** 展开后文本;存在缺失变量时缺席(调用方按 missing 处理,正文不半展开) */
28
+ text?: string;
29
+ /** 缺失变量清单(不回显 env 值——机密不进日志) */
30
+ missing: string[];
31
+ }
32
+
33
+ /** 展开配置文本(纯数据);缺失变量 → `{ missing }`(text 缺席),由壳层决定 throw/降级 */
34
+ export function expandEnvVars(
35
+ text: string,
36
+ env: Readonly<Record<string, string | undefined>> = process.env,
37
+ ): EnvExpansionResult {
38
+ const missing: string[] = [];
39
+ // 单遍替换:回调返回值不回扫(非递归语义;$${} 转义在回调内消去一个 $)
40
+ const out = text.replace(ENV_VAR_RE, (raw, name: string) => {
41
+ if (raw.startsWith('$$')) return raw.slice(1);
42
+ const v = env[name];
43
+ if (v === undefined) {
44
+ missing.push(name);
45
+ return raw; // 占位保留(纯数据形态:missing 上报,不 throw)
46
+ }
47
+ return jsonEscape(v);
48
+ });
49
+ if (missing.length > 0) return { missing };
50
+ return { text: out, missing: [] };
51
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * AppToolkitError(领域 6 码 + 壳层 2 码分界——toolkit 只产领域码):
3
+ * VALIDATION_FAILED(400) / SERVICE_NOT_FOUND(404) / MODULE_NOT_FOUND(404) /
4
+ * PROVIDER_NOT_FOUND(404) / SUPPLY_NOT_FOUND(404) / CONTRACTS_NOT_RESOLVED(503)。
5
+ * AUTH_REQUIRED / ROUTE_NOT_FOUND 为 HTTP 层事实(壳层产生)——6 + 2 = 8(api.md §5)。
6
+ *
7
+ * **name 约定(仓规约束①——mangle 安全真源 process note 2026-09-11-publish-dist-minify)**:
8
+ * 构造器必须字符串字面量赋值 `this.name = 'AppToolkitError'`(禁 `this.name = X.name` binding
9
+ * 形态——src 态守卫不可见、mangle 后静默漂移);守卫 = tests/error-name-safety.test.ts(C4)。
10
+ * 消费面属性分发(.httpStatus/.code/.fields——比 err.name 分发更宽裕)。
11
+ */
12
+
13
+ export type AppToolkitErrorCode =
14
+ | 'VALIDATION_FAILED'
15
+ | 'SERVICE_NOT_FOUND'
16
+ | 'MODULE_NOT_FOUND'
17
+ | 'PROVIDER_NOT_FOUND'
18
+ | 'SUPPLY_NOT_FOUND'
19
+ | 'CONTRACTS_NOT_RESOLVED';
20
+
21
+ export class AppToolkitError extends Error {
22
+ readonly code: AppToolkitErrorCode;
23
+ readonly httpStatus: number;
24
+ readonly fields?: string[];
25
+
26
+ constructor(code: AppToolkitErrorCode, httpStatus: number, message: string, fields?: string[]) {
27
+ super(message);
28
+ // 字面量赋值(禁 binding 形态——minify mangle 安全,README 打包纪律三件套)
29
+ this.name = 'AppToolkitError';
30
+ this.code = code;
31
+ this.httpStatus = httpStatus;
32
+ this.fields = fields;
33
+ }
34
+ }
35
+
36
+ /** CONTRACTS_NOT_RESOLVED 三分支 message(N1 构建指引——api.md 错误信封示例) */
37
+ export function contractsNotResolvedMessage(contracts: { version: string | null; strictValidation: boolean; notes: readonly string[] }): string {
38
+ if (contracts.version === null) {
39
+ return 'contracts 不可解析(未安装)——检查应用依赖安装后重试';
40
+ }
41
+ if (!contracts.strictValidation) {
42
+ return `contracts ${contracts.version} 无严格校验面(需 ≥0.9)——升级应用 @tbox.cn/app-contracts 依赖后可用`;
43
+ }
44
+ // 版本达标但解析态异常(notes 携带细分——导入拒绝/产物缺失)
45
+ return contracts.notes[0] ?? 'contracts workspace 源码态未构建——执行 pnpm --filter @tbox.cn/app-contracts build 后重试';
46
+ }
@@ -0,0 +1,65 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+
3
+ /**
4
+ * 统一文件读原语:stat 指纹(mtimeMs+size)+ parse 产物缓存 + 写后失效协议(D26)。
5
+ *
6
+ * Freshness Contract 机制载体(README 单源引用):
7
+ * - 外部写自愈:每笔读 stat 指纹比对——不命中即重读重 parse(外部写下一笔即见);
8
+ * - read-your-writes:写核心成功后调用 invalidate(本实例即刻失效,不依赖 mtime 粒度——
9
+ * 同毫秒连写兜底,W1 钉死);
10
+ * - 缓存对象 = 文件**内容/parse 产物**;目录枚举不经此原语(walkManifests 每次执行——零缓存约束)。
11
+ *
12
+ * parse 失败不缓存(下一笔重试——坏文件短暂窗口自愈);read 前存在性由调用方判定或捕获。
13
+ */
14
+
15
+ interface CacheEntry {
16
+ fingerprint: string;
17
+ value: unknown;
18
+ }
19
+
20
+ export interface FileCache {
21
+ /** 读文件并 parse(指纹命中返回缓存产物;miss 重读)。文件缺席 → undefined(不缓存缺席态) */
22
+ read<T>(absolutePath: string, parse: (raw: string) => T): T | undefined;
23
+ /** 写后失效协议:paths 缺省全失效;指定路径仅失效对应条目 */
24
+ invalidate(paths?: readonly string[]): void;
25
+ /** 测试观测:parse 执行计数(W6 缓存命中不重 parse 断言用) */
26
+ readonly parseCount: number;
27
+ }
28
+
29
+ export function createFileCache(): FileCache {
30
+ const cache = new Map<string, CacheEntry>();
31
+ let parseCount = 0;
32
+
33
+ function fingerprintOf(path: string): string | null {
34
+ if (!existsSync(path)) return null;
35
+ const st = statSync(path);
36
+ return `${st.mtimeMs}:${st.size}`;
37
+ }
38
+
39
+ return {
40
+ read<T>(absolutePath: string, parse: (raw: string) => T): T | undefined {
41
+ const fp = fingerprintOf(absolutePath);
42
+ if (fp === null) {
43
+ // 文件缺席:清除旧条目(删除自愈),缺席态不缓存
44
+ cache.delete(absolutePath);
45
+ return undefined;
46
+ }
47
+ const hit = cache.get(absolutePath);
48
+ if (hit && hit.fingerprint === fp) return hit.value as T;
49
+ parseCount += 1;
50
+ const value = parse(readFileSync(absolutePath, 'utf8'));
51
+ cache.set(absolutePath, { fingerprint: fp, value });
52
+ return value;
53
+ },
54
+ invalidate(paths?: readonly string[]): void {
55
+ if (paths === undefined) {
56
+ cache.clear();
57
+ return;
58
+ }
59
+ for (const p of paths) cache.delete(p);
60
+ },
61
+ get parseCount(): number {
62
+ return parseCount;
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,21 @@
1
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ /**
5
+ * 原子写原语(自 CLI dev.ts 私有 writeAtomic 提升为导出——CLI/写核心/凭据工件三消费方单源)。
6
+ * temp+rename:rename 在同文件系统内原子;半写状态只会停留在 tmp(孤儿 tmp 无害)。
7
+ * 写前 ensureDir(递归 mkdir -p)——credentials 等目录首次写物化(H5)。
8
+ */
9
+
10
+ /** 原子写文本文件:写前 ensureDir 父目录 + temp(pid+ts 后缀防碰撞)+ rename */
11
+ export function writeAtomic(file: string, content: string): void {
12
+ ensureDir(dirname(file));
13
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
14
+ writeFileSync(tmp, content, 'utf8');
15
+ renameSync(tmp, file);
16
+ }
17
+
18
+ /** 递归物化目录(mkdir -p 语义;已存在静默) */
19
+ export function ensureDir(dir: string): void {
20
+ mkdirSync(dir, { recursive: true });
21
+ }