cabletidy 0.2.0 → 0.3.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,311 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { createHash } from "node:crypto";
5
-
6
- import {
7
- backupFile,
8
- normalizeConfig,
9
- resolveUpstreamSecret,
10
- } from "./config.mjs";
11
- import { configurationId, providerIdForConfiguration, configurationBaseUrl } from "../web/config-identity.js";
12
- import { resolveModelProfile } from "./model-resolver.mjs";
13
- import { loadCodexCatalog, planCodexCatalog, validateCatalog } from "./codex-catalog.mjs";
14
- import { patchRootConfig, patchProviderConfig, readCodexConfig } from "./codex-config-file.mjs";
15
-
16
- export const CODEX_NATIVE_PROVIDER_INTEGRATION = "codex-native-provider";
17
- export const CODEX_TARGET_FORMAT = "codex.config.toml.v1";
18
- export const CODEX_NATIVE_REQUIRED_ENDPOINTS = Object.freeze([
19
- Object.freeze({
20
- method: "GET",
21
- path: "/v1/models",
22
- purpose: "model_discovery",
23
- }),
24
- Object.freeze({
25
- method: "POST",
26
- path: "/v1/responses",
27
- purpose: "responses",
28
- supportsStreaming: true,
29
- }),
30
- ]);
31
-
32
- const ROOT_BEGIN = "# >>> CABLETIDY MANAGED PROVIDER";
33
- const ROOT_END = "# <<< CABLETIDY MANAGED PROVIDER";
34
- const ACTIVE_BEGIN = "# >>> CABLETIDY MANAGED ACTIVE PROVIDER";
35
- const ACTIVE_END = "# <<< CABLETIDY MANAGED ACTIVE PROVIDER";
36
-
37
- function quote(value) {
38
- return JSON.stringify(String(value ?? ""));
39
- }
40
-
41
- function providerForBinding(config, binding, virtualProvider) {
42
- const route = config.routes?.[virtualProvider?.route];
43
- if (route?.backends?.length !== 1) throw new Error("每份配置必须且只能连接一个 upstream");
44
- return config.upstreams?.[route.backends[0]?.upstream];
45
- }
46
-
47
- function profilePolicy(profile) {
48
- return {
49
- contextWindow: Number(profile?.contextWindow || 0) || null,
50
- compact: profile?.compact || null,
51
- };
52
- }
53
-
54
- export function buildCodexArtifacts(config, options = {}, secrets = {}) {
55
- // Collisions can block a chain of renames, so reject them before looking up a new ID.
56
- const names = new Set();
57
- for (const [id, binding] of Object.entries(config.bindings || {})) {
58
- const nameId = configurationId(binding?.name, id);
59
- if (names.has(nameId)) throw new Error(`配置名称规范化后重复: ${nameId}`);
60
- names.add(nameId);
61
- }
62
- let bindingId = options.bindingId;
63
- const requestedBinding = config.bindings?.[bindingId];
64
- config = normalizeConfig(config);
65
- if (requestedBinding) bindingId = configurationId(requestedBinding.name, bindingId);
66
- if (!bindingId || !config.bindings?.[bindingId]) {
67
- bindingId = Object.entries(config.bindings || {}).find(
68
- ([id, item]) =>
69
- item.target === "codex" &&
70
- (!options.bindingId || id === options.bindingId || item.id === options.bindingId),
71
- )?.[0];
72
- }
73
- const binding = bindingId ? config.bindings?.[bindingId] : null;
74
- if (!binding) throw new Error("找不到 Codex binding");
75
- const virtualProvider = config.virtualProviders?.[binding.virtualProvider];
76
- if (!virtualProvider) throw new Error("Binding 引用的 Virtual Provider 不存在");
77
- if (virtualProvider.id !== providerIdForConfiguration(bindingId) ||
78
- Object.values(config.bindings).filter((item) => item?.virtualProvider === virtualProvider.id).length !== 1) {
79
- throw new Error("配置与 Virtual Provider 必须一对一,Virtual Provider ID 必须为 cabletidy_<配置ID>");
80
- }
81
- if (binding.target !== "codex") {
82
- throw new Error(`binding ${bindingId} 不是 Codex target`);
83
- }
84
- const defaultModel = binding.defaultModel || virtualProvider.defaultModel;
85
- const modelResolution = defaultModel ? resolveModelProfile(config, virtualProvider, defaultModel) : null;
86
-
87
- const upstream = providerForBinding(config, binding, virtualProvider);
88
- if (!upstream) throw new Error("找不到 binding 对应的 upstream");
89
- const providerId = virtualProvider.id;
90
- const clientModel =
91
- modelResolution?.profile?.clientModelId ||
92
- modelResolution?.profile?.aliases?.[0] ||
93
- modelResolution?.clientModelId || null;
94
- const policy = profilePolicy(modelResolution?.profile);
95
- const activeContents = [
96
- `${ACTIVE_BEGIN} ${providerId} -->`,
97
- `model_provider = ${quote(providerId)}`,
98
- ...(clientModel ? [`model = ${quote(clientModel)}`] : []),
99
- `${ACTIVE_END} ${providerId} <--`,
100
- "",
101
- ].join("\n");
102
- const providerContents = [
103
- `${ROOT_BEGIN} ${providerId} -->`,
104
- `[model_providers.${providerId}]`,
105
- `name = ${quote(`CableTidy / ${upstream.name || upstream.id}`)}`,
106
- `base_url = ${quote(`${configurationBaseUrl(config, bindingId)}/v1`)}`,
107
- `wire_api = "responses"`,
108
- `requires_openai_auth = false`,
109
- `${ROOT_END} ${providerId} <--`,
110
- "",
111
- ].join("\n");
112
- const rootContents = `${activeContents}\n${providerContents}`;
113
-
114
- return {
115
- integration: CODEX_NATIVE_PROVIDER_INTEGRATION,
116
- targetFormat: CODEX_TARGET_FORMAT,
117
- format: CODEX_TARGET_FORMAT,
118
- target: "codex",
119
- mode: "managed_proxy",
120
- bindingId,
121
- virtualProviderId: virtualProvider.id,
122
- providerId,
123
- activeContents,
124
- providerContents,
125
- clientModelId: clientModel,
126
- modelPolicy: policy,
127
- files: [{ path: "config.toml", contents: rootContents }],
128
- environment: { vars: {}, shell: "" },
129
- upstream: {
130
- id: upstream.id,
131
- baseUrl: upstream.baseUrl,
132
- secretConfigured: Boolean(resolveUpstreamSecret(upstream, secrets)),
133
- },
134
- };
135
- }
136
-
137
- export async function prepareCodexArtifacts(config, options = {}, secrets = {}) {
138
- const artifacts = buildCodexArtifacts(config, options, secrets);
139
- config = normalizeConfig(config);
140
- const provider = config.virtualProviders[config.bindings[artifacts.bindingId].virtualProvider];
141
- artifacts.catalogPlan = provider.allowedModels?.length
142
- ? planCodexCatalog(config, provider, await (options.loadCatalog || loadCodexCatalog)())
143
- : { catalog: null, models: [], overrides: [], warnings: [] };
144
- return stageCodexArtifacts(artifacts, options);
145
- }
146
-
147
- const CATALOG_STATE_FILE = ".cabletidy-model-catalog.json";
148
-
149
- async function readOptional(file) {
150
- try { return await fs.readFile(file, "utf8"); }
151
- catch (error) { if (error.code === "ENOENT") return ""; throw error; }
152
- }
153
-
154
- function codexHomeFor(options) {
155
- return path.resolve(options.codexHome || process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
156
- }
157
-
158
- async function stageCodexArtifacts(artifacts, options = {}) {
159
- const codexHome = codexHomeFor(options);
160
- const existing = await readOptional(path.join(codexHome, "config.toml"));
161
- const current = readCodexConfig(existing);
162
- const warnings = [...(artifacts.catalogPlan?.warnings || [])];
163
- const stateText = await readOptional(path.join(codexHome, CATALOG_STATE_FILE));
164
- const saved = stateText ? JSON.parse(stateText) : null;
165
- const ownsCatalog = saved?.version === 1 && current.model_catalog_json === saved.managedPath;
166
- const previousCatalog = ownsCatalog ? saved.previousCatalog : current.model_catalog_json;
167
- const previousContext = ownsCatalog ? current.model_context_window ?? saved.previousContext : current.model_context_window;
168
- const rootValues = {
169
- model_provider: artifacts.providerId,
170
- ...(artifacts.clientModelId ? { model: artifacts.clientModelId } : {}),
171
- };
172
- const files = [];
173
- let catalogState;
174
- if (artifacts.catalogPlan?.catalog) {
175
- warnings.push("模型目录覆盖作用于当前 Codex 配置,不按 provider 隔离;切换到其他 provider 前需恢复官方定义或原目录。重新启动 Codex 后读取更新。");
176
- let catalog = structuredClone(artifacts.catalogPlan.catalog);
177
- if (previousCatalog) {
178
- if (typeof previousCatalog !== "string") throw new Error("现有 model_catalog_json 必须为路径字符串");
179
- const file = path.isAbsolute(previousCatalog) ? previousCatalog : path.resolve(codexHome, previousCatalog);
180
- const previous = validateCatalog(JSON.parse(await fs.readFile(file, "utf8")));
181
- const replacements = new Map(catalog.models.map((model) => [model.slug, model]));
182
- // Preserve foreign entries. Active model entries must keep their
183
- // official instructions rather than inheriting arbitrary user prompts.
184
- catalog = { ...previous, models: previous.models.map((model) =>
185
- artifacts.catalogPlan.models.includes(model.slug) ? replacements.get(model.slug) : model,
186
- ) };
187
- const present = new Set(catalog.models.map((model) => model.slug));
188
- for (const model of replacements.values()) if (!present.has(model.slug)) catalog.models.push(model);
189
- warnings.push("已保留原模型目录的其他条目;当前模型的覆盖在 Codex 配置中是全局的,不按 provider 隔离。");
190
- }
191
- const contents = `${JSON.stringify(catalog, null, 2)}\n`;
192
- const digest = createHash("sha256").update(contents).digest("hex").slice(0,24);
193
- const relative = `model-catalogs/cabletidy-${digest}.json`;
194
- rootValues.model_catalog_json = path.join(codexHome, relative);
195
- rootValues.model_context_window = undefined;
196
- catalogState = {
197
- version: 1,
198
- sourceVersion: artifacts.catalogPlan.sourceVersion,
199
- managedPath: rootValues.model_catalog_json,
200
- previousCatalog,
201
- previousContext,
202
- };
203
- files.push({ path: relative, contents, kind: "json" });
204
- if (current.model_context_window !== undefined) warnings.push("将暂时移除根级 model_context_window,使逐模型窗口生效;返回官方默认模式时恢复。");
205
- } else if (ownsCatalog) {
206
- rootValues.model_catalog_json = previousCatalog;
207
- // Do not discard a root override the user added after our last apply.
208
- if (current.model_context_window === undefined) rootValues.model_context_window = previousContext;
209
- catalogState = null;
210
- }
211
- for (const key of ["model_instructions_file", "model_auto_compact_token_limit", "model_supports_reasoning_summaries"]) {
212
- if (current[key] !== undefined) warnings.push(`保留现有 ${key},该用户配置仍会影响 Codex 行为。`);
213
- }
214
- if (!artifacts.catalogPlan?.catalog && previousCatalog) warnings.push("保留现有 model_catalog_json;Codex 将继续使用用户的模型目录。");
215
- if (!artifacts.catalogPlan?.catalog && previousContext !== undefined) warnings.push("保留现有 model_context_window,它会覆盖模型目录中的上下文窗口。");
216
- let root = patchRootConfig(existing, rootValues);
217
- root = patchProviderConfig(root, artifacts.providerId, artifacts.providerContents);
218
- return {
219
- ...artifacts,
220
- codexHome,
221
- rootBefore: existing,
222
- catalogState,
223
- warnings: [...new Set(warnings)],
224
- catalogSummary: artifacts.catalogPlan ? {
225
- sourceVersion: artifacts.catalogPlan.sourceVersion,
226
- mode: artifacts.catalogPlan.catalog ? "managed" : "official",
227
- overrides: artifacts.catalogPlan.overrides,
228
- } : undefined,
229
- files: [{ path: "config.toml", contents: `${root.trimEnd()}\n` }, ...files],
230
- };
231
- }
232
-
233
- function safeRelativePath(value) {
234
- const normalized = String(value || "").replaceAll("\\", "/");
235
- if (
236
- !normalized ||
237
- normalized === "." ||
238
- normalized.startsWith("/") ||
239
- /^[a-zA-Z]:\//.test(normalized) ||
240
- normalized.includes("\0") ||
241
- normalized.split("/").includes("..")
242
- ) {
243
- throw new Error(`Codex artifact 文件路径不安全: ${value}`);
244
- }
245
- return normalized;
246
- }
247
-
248
- async function writeArtifact(file, contents, paths, label) {
249
- await fs.mkdir(path.dirname(file), { recursive: true });
250
- await backupFile(file, paths.backups, label);
251
- const temporary = `${file}.${process.pid}.tmp`;
252
- await fs.writeFile(temporary, contents, { mode: 0o600 });
253
- await fs.rename(temporary, file);
254
- }
255
-
256
- let applyOperation = Promise.resolve();
257
-
258
- export function applyCodexArtifacts(artifacts, options = {}) {
259
- const operation = applyOperation.then(() => applyCodexArtifactsNow(artifacts, options));
260
- applyOperation = operation.catch(() => {});
261
- return operation;
262
- }
263
-
264
- async function applyCodexArtifactsNow(artifacts, options = {}) {
265
- if (!artifacts?.activeContents || !artifacts.providerContents || !artifacts.providerId) {
266
- throw new Error("Unsupported Codex artifacts: expected a generated provider configuration");
267
- }
268
- const staged = await stageCodexArtifacts(artifacts, options);
269
- const paths = options.paths || { backups: path.join(os.homedir(), ".cabletidy", "backups") };
270
- await fs.mkdir(paths.backups, { recursive: true, mode: 0o700 });
271
- await fs.mkdir(staged.codexHome, { recursive: true, mode: 0o700 });
272
- const rootFile = path.join(staged.codexHome, "config.toml");
273
- const stateFile = path.join(staged.codexHome, CATALOG_STATE_FILE);
274
- const previousState = await readOptional(stateFile);
275
- const applied = [];
276
- // Content-addressed catalogs are written first, so a failed config write
277
- // never changes the catalog used by the previous configuration.
278
- for (const file of staged.files.slice(1)) {
279
- const target = path.join(staged.codexHome, safeRelativePath(file.path));
280
- await writeArtifact(target, file.contents, paths, "codex-model-catalog.json");
281
- applied.push(target);
282
- }
283
- if (await readOptional(rootFile) !== staged.rootBefore) {
284
- throw new Error("Codex 配置在应用期间已改变,请重新预览并重试");
285
- }
286
- try {
287
- if (staged.catalogState !== undefined) {
288
- await writeArtifact(stateFile, `${JSON.stringify(staged.catalogState)}\n`, paths, "codex-catalog-state.json");
289
- }
290
- await writeArtifact(rootFile, staged.files[0].contents, paths, "codex-config.toml");
291
- } catch (error) {
292
- if (staged.catalogState !== undefined) {
293
- if (previousState) await fs.writeFile(stateFile, previousState, { mode: 0o600 });
294
- else await fs.unlink(stateFile).catch(() => {});
295
- }
296
- throw error;
297
- }
298
- applied.unshift(rootFile);
299
- return { applied, environment: staged.environment, mode: staged.mode, warnings: staged.warnings, catalogSummary: staged.catalogSummary };
300
- }
301
-
302
- export function publicArtifacts(artifacts) {
303
- const { catalogPlan, catalogState, rootBefore, codexHome, ...publicValues } = artifacts;
304
- return {
305
- ...publicValues,
306
- environment: {
307
- vars: artifacts.environment?.vars || {},
308
- shell: artifacts.environment?.shell || "",
309
- },
310
- };
311
- }
package/src/config.mjs DELETED
@@ -1,341 +0,0 @@
1
- import crypto from "node:crypto";
2
- import fs from "node:fs/promises";
3
- import { setTimeout as delay } from "node:timers/promises";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import { normalizeConfigurationIdentities } from "../web/config-identity.js";
7
-
8
- const DEFAULT_HOME = process.env.CABLETIDY_HOME || path.join(os.homedir(), ".cabletidy");
9
- const SECRET_FIELD_NAMES = new Set([
10
- "secret",
11
- "apiKey",
12
- "api_key",
13
- "token",
14
- "accessToken",
15
- "access_token",
16
- "refreshToken",
17
- "refresh_token",
18
- "clientSecret",
19
- "client_secret",
20
- "password",
21
- "privateKey",
22
- "private_key",
23
- ]);
24
-
25
- export function getPaths(home = DEFAULT_HOME) {
26
- return {
27
- home,
28
- config: path.join(home, "config.json"),
29
- secrets: path.join(home, "secrets.json"),
30
- runtime: path.join(home, "runtime.json"),
31
- lock: path.join(home, "daemon.lock"),
32
- backups: path.join(home, "backups"),
33
- };
34
- }
35
-
36
- export function defaultConfig() {
37
- return {
38
- version: 1,
39
- revision: 0,
40
- daemon: {},
41
- web: {
42
- listenHost: "127.0.0.1",
43
- port: 43100,
44
- },
45
- upstreams: {},
46
- models: {},
47
- routes: {},
48
- virtualProviders: {},
49
- bindings: {},
50
- };
51
- }
52
-
53
- function isRecord(value) {
54
- return value && typeof value === "object" && !Array.isArray(value);
55
- }
56
-
57
- function mergeRecord(base, value) {
58
- if (!isRecord(value)) return base;
59
- for (const [key, item] of Object.entries(value)) {
60
- if (isRecord(item) && isRecord(base[key])) {
61
- mergeRecord(base[key], item);
62
- } else {
63
- base[key] = item;
64
- }
65
- }
66
- return base;
67
- }
68
-
69
- export function normalizeConfig(input = {}) {
70
- const config = mergeRecord(defaultConfig(), structuredClone(input));
71
- config.version = Number(config.version) || 1;
72
- config.revision = Number(config.revision) || 0;
73
- for (const key of ["upstreams", "models", "routes", "virtualProviders", "bindings"]) {
74
- if (!isRecord(config[key])) config[key] = {};
75
- }
76
- // The Web console is a loopback-only local control surface; old session
77
- // lifetime settings are no longer part of the effective configuration.
78
- delete config.web.sessionTtlSeconds;
79
- if (isRecord(config.daemon)) delete config.daemon.proxyPortRange;
80
- discardObsoleteCodexFields(config);
81
- for (const provider of Object.values(config.virtualProviders)) {
82
- if (isRecord(provider)) {
83
- delete provider.localAuth;
84
- delete provider.listenHost;
85
- delete provider.listenPort;
86
- }
87
- }
88
- for (const binding of Object.values(config.bindings)) {
89
- if (isRecord(binding?.codex)) delete binding.codex.localEnvKey;
90
- if (isRecord(binding?.claude)) delete binding.claude.authEnv;
91
- }
92
- normalizeConfigurationIdentities(config);
93
- return config;
94
- }
95
-
96
- function discardObsoleteCodexFields(config) {
97
- for (const upstream of Object.values(config.upstreams)) {
98
- if (!isRecord(upstream)) continue;
99
- // These policy placeholders never affected runtime behavior.
100
- for (const field of ["requestMaxRetries", "streamMaxRetries", "streamIdleTimeoutMs", "requiresOpenaiAuth", "supportsWebsockets"]) {
101
- delete upstream[field];
102
- }
103
- delete upstream.envKey;
104
- delete upstream.codexNative;
105
- delete upstream.providerFormat;
106
- delete upstream.codexToml;
107
- }
108
-
109
- for (const model of Object.values(config.models)) {
110
- if (!isRecord(model)) continue;
111
- // Prototype metadata is discarded, never promoted into current fields.
112
- delete model.legacyCodex;
113
- if (isRecord(model.targetOverrides)) {
114
- delete model.targetOverrides.codex;
115
- if (Object.keys(model.targetOverrides).length === 0) delete model.targetOverrides;
116
- }
117
- // Reasoning effort is selected by the CLI request, not fixed on a model.
118
- delete model.reasoning;
119
- }
120
-
121
- for (const binding of Object.values(config.bindings)) {
122
- if (!isRecord(binding)) continue;
123
- delete binding.providerFormat;
124
- if (isRecord(binding.codex)) {
125
- for (const key of ["profileFiles", "sourceUpstream", "sourceProviderId"]) {
126
- delete binding.codex[key];
127
- }
128
- delete binding.codex.providerId;
129
- }
130
- delete binding.legacyCodex;
131
- }
132
- }
133
-
134
- export function publicConfig(config) {
135
- const copy = normalizeConfig(config);
136
- redactSecretFields(copy);
137
- return copy;
138
- }
139
-
140
- export function stripSecretFields(value) {
141
- const copy = structuredClone(value);
142
- redactSecretFields(copy);
143
- return copy;
144
- }
145
-
146
- export async function ensureStore(paths = getPaths()) {
147
- await fs.mkdir(paths.home, { recursive: true, mode: 0o700 });
148
- await fs.mkdir(paths.backups, { recursive: true, mode: 0o700 });
149
- }
150
-
151
- export async function loadConfig(paths = getPaths()) {
152
- try {
153
- const raw = await fs.readFile(paths.config, "utf8");
154
- return normalizeConfig(JSON.parse(raw));
155
- } catch (error) {
156
- if (error.code === "ENOENT") return null;
157
- throw error;
158
- }
159
- }
160
-
161
- export async function loadSecrets(paths = getPaths()) {
162
- try {
163
- const raw = await fs.readFile(paths.secrets, "utf8");
164
- const parsed = JSON.parse(raw);
165
- return isRecord(parsed) ? parsed : {};
166
- } catch (error) {
167
- if (error.code === "ENOENT") return {};
168
- throw error;
169
- }
170
- }
171
-
172
- export async function writeJsonAtomic(file, value, mode = 0o600) {
173
- const directory = path.dirname(file);
174
- await fs.mkdir(directory, { recursive: true, mode: 0o700 });
175
- const temporary = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
176
- try {
177
- await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode });
178
- await fs.chmod(temporary, mode);
179
- const deadline = Date.now() + 1000;
180
- while (true) {
181
- try {
182
- await fs.rename(temporary, file);
183
- return;
184
- } catch (error) {
185
- // Windows readers and antivirus can briefly deny an atomic replacement.
186
- if (!["EPERM", "EACCES", "EBUSY"].includes(error.code) || Date.now() >= deadline) throw error;
187
- await delay(50);
188
- }
189
- }
190
- } finally {
191
- await fs.rm(temporary, { force: true }).catch(() => {});
192
- }
193
- }
194
-
195
- export async function saveConfig(config, paths = getPaths()) {
196
- await writeJsonAtomic(paths.config, normalizeConfig(config), 0o600);
197
- }
198
-
199
- export async function saveSecrets(secrets, paths = getPaths()) {
200
- await writeJsonAtomic(paths.secrets, secrets, 0o600);
201
- }
202
-
203
- export async function backupFile(file, backupsDirectory, label) {
204
- let contents;
205
- try {
206
- contents = await fs.readFile(file);
207
- } catch (error) {
208
- if (error.code === "ENOENT") return null;
209
- throw error;
210
- }
211
- const stamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
212
- const backup = path.join(backupsDirectory, `${stamp}-${label}`);
213
- await fs.writeFile(backup, contents, { mode: 0o600 });
214
- return backup;
215
- }
216
-
217
- export function secretRefForUpstream(id) {
218
- return `secret://upstreams/${id}`;
219
- }
220
-
221
- export function resolveSecret(reference, secrets = {}) {
222
- if (typeof reference !== "string" || !reference) return "";
223
- return secrets[reference] || "";
224
- }
225
-
226
- export function resolveUpstreamSecret(upstream, secrets = {}) {
227
- return resolveSecret(upstream?.secretRef, secrets);
228
- }
229
-
230
- export function applySecretPayload(config, secrets, payload = {}) {
231
- const nextSecrets = { ...secrets };
232
- const upstreamSecrets = isRecord(payload.upstreamSecrets) ? payload.upstreamSecrets : {};
233
- for (const [id, value] of Object.entries(upstreamSecrets)) {
234
- if (typeof value !== "string" || !value.trim()) continue;
235
- const upstream = config.upstreams[id];
236
- if (!upstream) continue;
237
- upstream.secretRef ||= secretRefForUpstream(id);
238
- nextSecrets[upstream.secretRef] = value.trim();
239
- }
240
- return nextSecrets;
241
- }
242
-
243
- export async function writeRuntimeInfo(info, paths = getPaths()) {
244
- await writeJsonAtomic(paths.runtime, info, 0o600);
245
- }
246
-
247
- export async function readRuntimeInfo(paths = getPaths()) {
248
- try {
249
- return JSON.parse(await fs.readFile(paths.runtime, "utf8"));
250
- } catch (error) {
251
- if (error.code === "ENOENT") return null;
252
- throw error;
253
- }
254
- }
255
-
256
- /**
257
- * Return a redacted, path-oriented view of the effective configuration change.
258
- * Secrets are not part of config, and revision is metadata rather than behavior.
259
- */
260
- export function computeConfigDiff(beforeInput = {}, afterInput = {}) {
261
- const before = normalizeConfig(beforeInput);
262
- const after = normalizeConfig(afterInput);
263
- const added = [];
264
- const removed = [];
265
- const changed = [];
266
-
267
- diffValues(before, after, "", { added, removed, changed });
268
-
269
- const roots = new Set(
270
- [...added, ...removed, ...changed]
271
- .map((item) => item.path.split(".")[0])
272
- .filter(Boolean),
273
- );
274
- return {
275
- added,
276
- removed,
277
- changed,
278
- total: added.length + removed.length + changed.length,
279
- affected: [...roots],
280
- };
281
- }
282
-
283
- function diffValues(before, after, currentPath, output) {
284
- if (currentPath === "revision") return;
285
-
286
- if (before === undefined) {
287
- output.added.push({ path: currentPath, value: after });
288
- return;
289
- }
290
- if (after === undefined) {
291
- output.removed.push({ path: currentPath, value: before });
292
- return;
293
- }
294
-
295
- if (isRecord(before) && isRecord(after)) {
296
- const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
297
- for (const key of [...keys].sort()) {
298
- diffValues(
299
- before[key],
300
- after[key],
301
- currentPath ? `${currentPath}.${key}` : key,
302
- output,
303
- );
304
- }
305
- return;
306
- }
307
-
308
- if (!sameValue(before, after)) {
309
- output.changed.push({ path: currentPath, before, after });
310
- }
311
- }
312
-
313
- function redactSecretFields(value) {
314
- if (Array.isArray(value)) {
315
- for (const item of value) redactSecretFields(item);
316
- return;
317
- }
318
- if (!isRecord(value)) return;
319
- for (const [key, item] of Object.entries(value)) {
320
- if (SECRET_FIELD_NAMES.has(key)) {
321
- delete value[key];
322
- continue;
323
- }
324
- redactSecretFields(item);
325
- }
326
- }
327
-
328
- function sameValue(left, right) {
329
- if (Object.is(left, right)) return true;
330
- if (Array.isArray(left) && Array.isArray(right)) {
331
- if (left.length !== right.length) return false;
332
- return left.every((value, index) => sameValue(value, right[index]));
333
- }
334
- if (isRecord(left) && isRecord(right)) {
335
- const leftKeys = Object.keys(left);
336
- const rightKeys = Object.keys(right);
337
- if (leftKeys.length !== rightKeys.length) return false;
338
- return leftKeys.every((key) => Object.hasOwn(right, key) && sameValue(left[key], right[key]));
339
- }
340
- return false;
341
- }