@sema-agent/settings-schema 11.0.0 → 11.2.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/CHANGELOG.md +148 -1
- package/README.md +17 -3
- package/bin/settings-schema.mjs +7 -20
- package/conformance/VERSION +1 -1
- package/conformance/cases/c06-legacy-governance-lift/expected.json +1 -1
- package/conformance/cases/c20-governance-absent-lifts/expected.json +1 -1
- package/conformance/cases/c67-s784-retired-field-loud/meta.json +1 -1
- package/dist/atomic-write.d.ts +14 -0
- package/dist/atomic-write.js +90 -0
- package/dist/cli.d.ts +31 -6
- package/dist/cli.js +50 -124
- package/dist/config-dir-io.d.ts +2 -0
- package/dist/config-dir-io.js +13 -0
- package/dist/config-dir-migration.d.ts +64 -0
- package/dist/config-dir-migration.js +167 -0
- package/dist/file-edit.d.ts +3 -3
- package/dist/file-edit.js +6 -16
- package/dist/file-store.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/local-load.d.ts +1 -1
- package/dist/local-load.js +4 -14
- package/dist/node.d.ts +2 -0
- package/dist/node.js +3 -0
- package/dist/one-time-migrations.d.ts +6 -0
- package/dist/one-time-migrations.js +12 -2
- package/dist/previous-web-admin-env.d.ts +15 -0
- package/dist/previous-web-admin-env.js +20 -0
- package/dist/remote-exec.js +3 -3
- package/dist/retired-envs.js +13 -2
- package/dist/retired-form.d.ts +7 -2
- package/dist/retired-form.js +11 -3
- package/dist/runtime-lift-targets.d.ts +7 -0
- package/dist/runtime-lift-targets.js +7 -0
- package/dist/scheduler-store-node.d.ts +4 -3
- package/dist/scheduler-store-node.js +7 -8
- package/dist/shell-quote.d.ts +1 -0
- package/dist/shell-quote.js +14 -0
- package/dist/types.d.ts +9 -1
- package/dist/types.js +71 -18
- package/dist/web-admin-credential.d.ts +1 -1
- package/dist/web-admin-credential.js +3 -3
- package/dist/web-admin-env.d.ts +2 -1
- package/dist/web-admin-env.js +2 -1
- package/dist/web-admin-session-url.d.ts +17 -0
- package/dist/web-admin-session-url.js +64 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,37 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `settings-schema` 命令行的核心(11.0.0)—— 子命令只有一个:`migrate <config.d 目录> [--check]`,即一次性迁移
|
|
3
3
|
* `migrateStoredDocuments`(`./one-time-migrations`)的目录形(本地 config.d / 离线文件用;web-admin 在它起服时直接调同一只函数)。
|
|
4
|
+
* 目录形的语义(读目录 → 迁 → 整域终判 → 原子写 → 删 retired 域文件)只有一处实现:`migrateConfigDirectory`(`./config-dir-migration`,`./node` 导出,引擎起服调同一只);
|
|
5
|
+
* 本模块是它的**渲染器**:把它的结果闭集渲染成退出码、报告行与 stderr 句子。命令行的覆盖面是全部配置域加 retired 域。
|
|
4
6
|
* 目录被当作**一个 scope 的全集**(`coverage: "scope"`):目录里没有的域文件就是从未设过。
|
|
5
7
|
*
|
|
6
|
-
* 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(
|
|
8
|
+
* 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(真绑定是 `./config-dir-io` 的 `nodeConfigDirectoryIo`,写盘用的是 `./atomic-write` 那一只原子写器 —— 写法与文件模式在那里一处定),所以测试可以直接驱动它。
|
|
7
9
|
* 不进主桶 —— 它是 bin 的实现,不是契约面。
|
|
8
10
|
*
|
|
9
|
-
* 读哪些文件:目录里 `<名>.json`,名是配置域或retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁);别的文件
|
|
11
|
+
* 读哪些文件:目录里 `<名>.json`,名是配置域或 retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁);别的文件
|
|
10
12
|
* (`remote-exec.json`、隐藏文件、不认识的名)不读不动。
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* 零域文档分两形:**有条目却没有域文档**(任何非 `.` 开头的条目 —— 子目录、说明文件、不认识的名)⇒ 退出 2;**目录真空**(零条目,或只有以 `.` 开头的条目)⇒ 照常走判定,
|
|
15
|
+
* 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
|
|
16
|
+
*
|
|
17
|
+
* 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
|
|
18
|
+
* 2 = 用法 / 读目录 / 读文件错,或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
|
|
19
|
+
* (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
|
|
14
20
|
* 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
|
|
15
21
|
*
|
|
16
22
|
* ## 报告形(让「没写」在机读上不可能被读成「写了」)
|
|
17
23
|
* 退出码 0 / 1 / 3 时 stdout 恰两行:第 1 行 = **唯一结论行** `outcome=<refused|applied|noop|pending> written=<true|false>`;第 2 行 = JSON 报告
|
|
18
24
|
* (`event: "stored_documents_migrated"`,逐条收据只点名坐标与处置、不带值),同带 `outcome` / `written`。`written` 由 `outcome` 派生
|
|
19
25
|
* (只有 `applied` 写盘)。**键名带时态**:`written=true` 才有 `applied`;没写(refused / noop / pending)时是 `wouldApply`。
|
|
20
|
-
* 结论行在**写盘成功之后**才出(写盘抛错 ⇒ 没有 `outcome=applied`)。写盘次序:先写改过 / 新建的文件,最后删被移除的retired 域文件 ——
|
|
26
|
+
* 结论行在**写盘成功之后**才出(写盘抛错 ⇒ 没有 `outcome=applied`)。写盘次序:先写改过 / 新建的文件,最后删被移除的 retired 域文件 ——
|
|
21
27
|
* 中途断掉再跑一次,迁移照样恒等收尾(已搬过去的值在新位上,旧文件里同名键按「新位赢」丢弃)。
|
|
22
28
|
*/
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
import { isDomainName, isRetiredDomainName } from "./types.js";
|
|
29
|
+
import { reportReceipt, joinDirPath, migrateConfigDirectory } from "./config-dir-migration.js";
|
|
30
|
+
import { MIGRATE_COMMAND_TEMPLATE, migrateCommandFor } from "./retired-form.js";
|
|
31
|
+
import { DOMAINS, RETIRED_DOMAINS } from "./types.js";
|
|
32
|
+
/** 命令行的覆盖面:全部配置域加 retired 域(便携与否不论 —— 离线的 `workers.json` 一样迁)。 */
|
|
33
|
+
const COVERED_DOCUMENTS = [...DOMAINS, ...Object.keys(RETIRED_DOMAINS)];
|
|
29
34
|
const USAGE = [
|
|
30
|
-
`usage: ${
|
|
35
|
+
`usage: ${MIGRATE_COMMAND_TEMPLATE} [--check]`,
|
|
31
36
|
" Runs the one-time migrations (@sema-agent/settings-schema/one-time-migrations) over every <domain>.json in the directory, read as one",
|
|
32
37
|
" scope (a missing domain file = never set): writes the rewritten files in place and removes retired-domain files (runtime.json) only when",
|
|
33
38
|
" nothing is refused and every document it would write reads as a whole domain; --check runs the same verdict and only reports (exit 3",
|
|
34
|
-
" if something would change).",
|
|
39
|
+
" if something would change). The argument is the config.d directory itself; a directory that has entries but no <domain>.json among them is",
|
|
40
|
+
" refused (exit 2); an empty directory has nothing to migrate (exit 0).",
|
|
35
41
|
" stdout (exit 0 / 1 / 3): line 1 = outcome=<refused|applied|noop|pending> written=<true|false>, line 2 = the JSON report",
|
|
36
42
|
" (applied only when written=true; otherwise wouldApply).",
|
|
37
43
|
].join("\n");
|
|
@@ -50,61 +56,6 @@ function writtenOf(outcome) {
|
|
|
50
56
|
}
|
|
51
57
|
}
|
|
52
58
|
}
|
|
53
|
-
/** 报告里的一张收据(坐标出门形;不带值)。 */
|
|
54
|
-
function reportReceipt(r) {
|
|
55
|
-
const base = { migration: r.migration, document: r.document, path: formatConfigPath(r.path), appliedAs: r.appliedAs };
|
|
56
|
-
switch (r.appliedAs) {
|
|
57
|
-
case "moved":
|
|
58
|
-
return { ...base, to: `${r.to.document}:${formatConfigPath(r.to.path)}` };
|
|
59
|
-
case "refused":
|
|
60
|
-
return { ...base, reason: r.reason };
|
|
61
|
-
case "rewritten":
|
|
62
|
-
case "dropped-shadowed":
|
|
63
|
-
case "dropped-inert":
|
|
64
|
-
return base;
|
|
65
|
-
default: {
|
|
66
|
-
const unhandled = r;
|
|
67
|
-
throw new Error(`settings-schema: unhandled receipt ${JSON.stringify(unhandled)}`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
/** 整域终判的一条拒(issue 来自 `parseDomain`,已过本包唯一的脱敏器 —— 坐标段与句子都是出门形)。 */
|
|
72
|
-
function wholeDomainRefusal(document, path, reason) {
|
|
73
|
-
return { migration: null, document, path: formatConfigPath(path), appliedAs: "refused", reason };
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* 迁移结果 → 结论。顺序:迁移步拒 → 无改动 ⇒ noop → **整域终判**(要写回的每个域文档 —— 改过的、新建的 —— 过读侧那一只
|
|
77
|
-
* `parseDomain`:名字 / 路由唯一这类跨行规则只有整域判得出)→ 有改动。整域终判在 `--check` 之前:否则同一个目录 `--check` 报 pending、
|
|
78
|
-
* 真跑写出一份读侧必拒的文档。没改的文件不归这里判(迁移不碰它,它读不读得进是读者的事)。
|
|
79
|
-
*/
|
|
80
|
-
function verdictOf(input, migrated, receipts) {
|
|
81
|
-
const refusedReceipts = receipts.filter((r) => r.appliedAs === "refused");
|
|
82
|
-
if (refusedReceipts.length > 0) {
|
|
83
|
-
return { kind: "refused", refused: refusedReceipts.map(reportReceipt), why: `${refusedReceipts.length} coordinate(s) cannot be migrated (see "refused") — fix them by hand and re-run` };
|
|
84
|
-
}
|
|
85
|
-
if (receipts.length === 0)
|
|
86
|
-
return { kind: "noop" };
|
|
87
|
-
const refused = [];
|
|
88
|
-
for (const [name, doc] of Object.entries(migrated)) {
|
|
89
|
-
if (!isDomainName(name) || (Object.prototype.hasOwnProperty.call(input, name) && input[name] === doc))
|
|
90
|
-
continue;
|
|
91
|
-
try {
|
|
92
|
-
parseDomain(name, doc);
|
|
93
|
-
}
|
|
94
|
-
catch (err) {
|
|
95
|
-
if (err instanceof z.ZodError)
|
|
96
|
-
for (const i of err.issues)
|
|
97
|
-
refused.push(wholeDomainRefusal(name, i.path, i.message));
|
|
98
|
-
else
|
|
99
|
-
refused.push(wholeDomainRefusal(name, [], err instanceof Error ? err.message : String(err)));
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
if (refused.length > 0) {
|
|
103
|
-
const documents = [...new Set(refused.map((r) => String(r.document)))];
|
|
104
|
-
return { kind: "refused", refused, why: `the migrated ${documents.join(" / ")} document(s) do not read as a whole domain (see "refused") — fix them by hand and re-run` };
|
|
105
|
-
}
|
|
106
|
-
return { kind: "changes" };
|
|
107
|
-
}
|
|
108
59
|
/** 一次发出报告:结论行 + JSON 行(一次 `stdout` 调用)。 */
|
|
109
60
|
function reportOf(outcome, dir, applied, refused) {
|
|
110
61
|
const written = writtenOf(outcome);
|
|
@@ -113,12 +64,18 @@ function reportOf(outcome, dir, applied, refused) {
|
|
|
113
64
|
outcome,
|
|
114
65
|
written,
|
|
115
66
|
dir,
|
|
116
|
-
[written ? "applied" : "wouldApply"]: applied.map(reportReceipt),
|
|
67
|
+
[written ? "applied" : "wouldApply"]: applied.map((r) => reportReceipt(r)),
|
|
117
68
|
refused,
|
|
118
69
|
});
|
|
119
70
|
return `outcome=${outcome} written=${written}\n${json}\n`;
|
|
120
71
|
}
|
|
121
|
-
|
|
72
|
+
/** 「有条目却没有域文档」的拒句:点名这个目录、说要的是 `config.d` 目录本身;`<dir>/config.d` 在场(探一次)就直接点它 —— 最常见的错是填了它的上一层。指路的命令里路径过 shell 引用,可直接粘贴。 */
|
|
73
|
+
async function noDomainDocumentsSentence(dir, io) {
|
|
74
|
+
const nested = joinDirPath(dir, "config.d");
|
|
75
|
+
const nestedExists = await io.readDir(nested).then(() => true, () => false);
|
|
76
|
+
const base = `settings-schema: no domain documents in ${dir} (a domain document is <domain>.json, e.g. models.json) — the argument is the config.d directory itself`;
|
|
77
|
+
return nestedExists ? `${base}; ${nested} exists — run: ${migrateCommandFor(nested)}` : base;
|
|
78
|
+
}
|
|
122
79
|
export async function runSettingsSchemaCli(argv, io) {
|
|
123
80
|
const [command, ...rest] = argv;
|
|
124
81
|
const check = rest.includes("--check");
|
|
@@ -128,67 +85,36 @@ export async function runSettingsSchemaCli(argv, io) {
|
|
|
128
85
|
io.stderr(`${USAGE}\n`);
|
|
129
86
|
return 2;
|
|
130
87
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
catch (err) {
|
|
136
|
-
io.stderr(`settings-schema: cannot read directory ${dir}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
137
|
-
return 2;
|
|
138
|
-
}
|
|
139
|
-
const docs = {};
|
|
140
|
-
for (const name of [...names].sort()) {
|
|
141
|
-
if (name.startsWith(".") || !name.endsWith(".json"))
|
|
142
|
-
continue;
|
|
143
|
-
const base = name.slice(0, -".json".length);
|
|
144
|
-
if (!isDomainName(base) && !isRetiredDomainName(base))
|
|
145
|
-
continue;
|
|
146
|
-
let text;
|
|
147
|
-
try {
|
|
148
|
-
text = await io.readFile(join(dir, name));
|
|
149
|
-
}
|
|
150
|
-
catch (err) {
|
|
151
|
-
io.stderr(`settings-schema: cannot read ${name}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
88
|
+
const m = await migrateConfigDirectory(dir, { documents: COVERED_DOCUMENTS, io, check });
|
|
89
|
+
switch (m.kind) {
|
|
90
|
+
case "unreadable":
|
|
91
|
+
io.stderr(`settings-schema: ${m.reason}\n`);
|
|
152
92
|
return 2;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const m = migrateStoredDocuments(docs, { coverage: "scope" });
|
|
163
|
-
const applied = m.receipts.filter((r) => r.appliedAs !== "refused");
|
|
164
|
-
const verdict = verdictOf(docs, m.docs, m.receipts);
|
|
165
|
-
switch (verdict.kind) {
|
|
93
|
+
case "no-documents":
|
|
94
|
+
if (m.entries.some((name) => !name.startsWith("."))) {
|
|
95
|
+
// The directory has entries and none of them is a domain document: a no-op report (rc 0) would read as "this scope is migrated" for a
|
|
96
|
+
// directory the command never looked into. A directory with nothing visible in it (empty, or only dot entries) is a legitimate scope.
|
|
97
|
+
io.stderr(`${await noDomainDocumentsSentence(dir, io)}\n`);
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
io.stdout(reportOf("noop", dir, [], []));
|
|
101
|
+
return 0;
|
|
166
102
|
case "refused":
|
|
167
|
-
io.stdout(reportOf("refused", dir, applied,
|
|
168
|
-
io.stderr(`settings-schema: ${
|
|
103
|
+
io.stdout(reportOf("refused", dir, m.applied, m.refused));
|
|
104
|
+
io.stderr(`settings-schema: ${m.why}; nothing was written\n`);
|
|
169
105
|
return 1;
|
|
170
106
|
case "noop":
|
|
171
107
|
io.stdout(reportOf("noop", dir, [], []));
|
|
172
108
|
return 0;
|
|
173
|
-
case "
|
|
174
|
-
|
|
109
|
+
case "pending":
|
|
110
|
+
io.stdout(reportOf("pending", dir, m.applied, []));
|
|
111
|
+
return 3;
|
|
112
|
+
case "applied":
|
|
113
|
+
io.stdout(reportOf("applied", dir, m.applied, [])); // 写盘成功之后才报 applied
|
|
114
|
+
return 0;
|
|
175
115
|
default: {
|
|
176
|
-
const unhandled =
|
|
177
|
-
throw new Error(`settings-schema: unhandled
|
|
116
|
+
const unhandled = m;
|
|
117
|
+
throw new Error(`settings-schema: unhandled migration result ${JSON.stringify(unhandled)}`);
|
|
178
118
|
}
|
|
179
119
|
}
|
|
180
|
-
if (check) {
|
|
181
|
-
io.stdout(reportOf("pending", dir, applied, []));
|
|
182
|
-
return 3;
|
|
183
|
-
}
|
|
184
|
-
for (const [name, doc] of Object.entries(m.docs)) {
|
|
185
|
-
if (Object.prototype.hasOwnProperty.call(docs, name) && docs[name] === doc)
|
|
186
|
-
continue;
|
|
187
|
-
await io.writeFileAtomic(join(dir, `${name}.json`), `${JSON.stringify(doc, null, 2)}\n`);
|
|
188
|
-
}
|
|
189
|
-
for (const name of Object.keys(docs))
|
|
190
|
-
if (!Object.prototype.hasOwnProperty.call(m.docs, name))
|
|
191
|
-
await io.removeFile(join(dir, `${name}.json`));
|
|
192
|
-
io.stdout(reportOf("applied", dir, applied, [])); // 写盘成功之后才报 applied
|
|
193
|
-
return 0;
|
|
194
120
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `migrateConfigDirectory`(`./config-dir-migration`)的**真 I/O 绑定**(Node 专用):读目录 / 读文件 / 删文件走 `node:fs/promises`,写盘走本包唯一的原子写器
|
|
3
|
+
* `./atomic-write`(同目录临时文件 → rename;`config-document` 类的模式规则:新建 0o644 经 umask、已有的沿用原模式;目标是符号链接则写到它指向的真文件)。
|
|
4
|
+
* 命令行入口与引擎起服的目录迁移注入同一份,写法与文件模式因此只在原子写器那一处定。
|
|
5
|
+
*/
|
|
6
|
+
import { readdir, readFile, unlink } from "node:fs/promises";
|
|
7
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
8
|
+
export const nodeConfigDirectoryIo = {
|
|
9
|
+
readDir: (path) => readdir(path),
|
|
10
|
+
readFile: (path) => readFile(path, "utf8"),
|
|
11
|
+
writeFileAtomic: async (path, text) => writeFileAtomic(path, text, "config-document"), // the files this migration writes are config.d domain documents
|
|
12
|
+
removeFile: (path) => unlink(path),
|
|
13
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type MigrationReceipt } from "./one-time-migrations.js";
|
|
2
|
+
import { type DomainName, type RetiredDomainName } from "./types.js";
|
|
3
|
+
/** 注入的 I/O(本模块不碰 `node:fs`)。`writeFileAtomic` 必须原子(写临时文件再改名):半写的域文档比旧文档更糟。 */
|
|
4
|
+
export interface ConfigDirectoryIo {
|
|
5
|
+
readDir(path: string): Promise<string[]>;
|
|
6
|
+
readFile(path: string): Promise<string>;
|
|
7
|
+
writeFileAtomic(path: string, text: string): Promise<void>;
|
|
8
|
+
removeFile(path: string): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
/** 一条拒绝 —— 迁移步的拒(收据 `refused`)与整域终判的拒**同一套形**:`migration`(整域终判拒为 `null`)/ `document` / `path`(相对该域文档,出门形)/ `appliedAs: "refused"` / `reason`。
|
|
11
|
+
* 句子来自本包唯一的脱敏器,坐标段与句子都是出门形、不带值。 */
|
|
12
|
+
export type ConfigDirectoryRefusal = {
|
|
13
|
+
readonly migration: string | null;
|
|
14
|
+
readonly document: string;
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly appliedAs: "refused";
|
|
17
|
+
readonly reason: string;
|
|
18
|
+
};
|
|
19
|
+
export type ConfigDirectoryMigration = {
|
|
20
|
+
readonly kind: "unreadable";
|
|
21
|
+
readonly reason: string;
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: "no-documents";
|
|
24
|
+
readonly entries: readonly string[];
|
|
25
|
+
} | {
|
|
26
|
+
readonly kind: "refused";
|
|
27
|
+
readonly read: readonly string[];
|
|
28
|
+
readonly applied: readonly MigrationReceipt[];
|
|
29
|
+
readonly refused: readonly ConfigDirectoryRefusal[];
|
|
30
|
+
readonly why: string;
|
|
31
|
+
} | {
|
|
32
|
+
readonly kind: "noop";
|
|
33
|
+
readonly read: readonly string[];
|
|
34
|
+
} | {
|
|
35
|
+
readonly kind: "pending";
|
|
36
|
+
readonly read: readonly string[];
|
|
37
|
+
readonly applied: readonly MigrationReceipt[];
|
|
38
|
+
} | {
|
|
39
|
+
readonly kind: "applied";
|
|
40
|
+
readonly read: readonly string[];
|
|
41
|
+
readonly applied: readonly MigrationReceipt[];
|
|
42
|
+
readonly written: readonly string[];
|
|
43
|
+
readonly removed: readonly string[];
|
|
44
|
+
};
|
|
45
|
+
export interface MigrateConfigDirectoryOptions {
|
|
46
|
+
/** 覆盖面:调用方读的域名集(配置域或 retired 域)。拼错的名字 ⇒ 抛 `TypeError`,不让它悄悄变成「什么都没覆盖」。 */
|
|
47
|
+
readonly documents: readonly (DomainName | RetiredDomainName)[];
|
|
48
|
+
readonly io: ConfigDirectoryIo;
|
|
49
|
+
/** 只判、不写:有改动且整域判得过 ⇒ `pending`。默认 `false`。 */
|
|
50
|
+
readonly check?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** 目录加文件名(目录尾部的分隔符折掉)。包内私有(不进 `./node`)。 */
|
|
53
|
+
export declare const joinDirPath: (dir: string, name: string) => string;
|
|
54
|
+
type RefusedReceipt = Extract<MigrationReceipt, {
|
|
55
|
+
readonly appliedAs: "refused";
|
|
56
|
+
}>;
|
|
57
|
+
/** 一张收据的出门形(坐标只点名、不带值):`refused` 带 `reason`(= {@link ConfigDirectoryRefusal} 的形),`moved` 带 `to`。包内私有 —— 命令行报告用它渲染 `applied`。 */
|
|
58
|
+
export declare function reportReceipt(r: RefusedReceipt): ConfigDirectoryRefusal;
|
|
59
|
+
export declare function reportReceipt(r: MigrationReceipt): Record<string, unknown>;
|
|
60
|
+
/**
|
|
61
|
+
* 对 `dir`(一个 `config.d` 目录)的覆盖面内文档跑一次一次性迁移;语义与结果见文件头。`dir` 与目录内文件名的拼接用 `/`。
|
|
62
|
+
*/
|
|
63
|
+
export declare function migrateConfigDirectory(dir: string, opts: MigrateConfigDirectoryOptions): Promise<ConfigDirectoryMigration>;
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **一个 `config.d` 目录的一次性迁移** —— 「读目录 → 迁 → 整域终判 → 原子写 → 删 retired 域文件」这一条语义的唯一实现。命令行(`migrate <config.d 目录> [--check]`,
|
|
3
|
+
* 见 `./cli`)是它的渲染器(退出码 + 报告行);引擎起服时对本地 `config.d` 的迁移调同一只函数,不按同语义自写一份。
|
|
4
|
+
*
|
|
5
|
+
* **覆盖面 = 调用方读的域名集**({@link MigrateConfigDirectoryOptions.documents}):覆盖面内没有的域文件就是从未设过(一次性迁移的 `coverage: "scope"`);
|
|
6
|
+
* 覆盖面外的文件不读、不判、不写、不删 —— 本调用方没有资格改写它从不消费的数据,它们是坏文件也挡不住覆盖面内的迁移。命令行的覆盖面是全部配置域加 retired 域;
|
|
7
|
+
* 引擎的覆盖面是它本地读腿会读的那几份。**迁移不许越过覆盖面去写**:retired 的 `runtime` 域迁出时要写 `governance` 与 `limits`(`RUNTIME_LIFT_TARGETS`),
|
|
8
|
+
* 覆盖面没把这两个域一并包进来时,目录算不上「一个 scope 的全集」—— 迁移按 `coverage: "partial"` 走(`runtime` 在那一支上以收据 `refused` 拒迁、一字不写),
|
|
9
|
+
* 不会把覆盖面外已有的 `governance.json` / `limits.json` 当作「从未设过」覆盖掉;日后若别的迁移步新建覆盖面外的文档,函数响亮抛(守卫),不静默写。
|
|
10
|
+
*
|
|
11
|
+
* 本模块不碰 `node:fs`:读写由注入的 {@link ConfigDirectoryIo} 做(真绑定是 `./config-dir-io` 的 `nodeConfigDirectoryIo`,写盘用 `./atomic-write` 那一只原子写器与它的模式表),
|
|
12
|
+
* 所以测试可以直接喂内存实现。
|
|
13
|
+
*
|
|
14
|
+
* ## 结果是一个闭集({@link ConfigDirectoryMigration}),调用方穷举
|
|
15
|
+
* · `unreadable` —— 读不了目录 / 某份文件,或某份文档是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象,与 `FileConfigStore` 同一只判、同一句)。一字不写。
|
|
16
|
+
* · `no-documents`—— 覆盖面内一份文档都没找到;带回目录清单 `entries`(调用方自己判这是「填错了目录」还是「真空的新目录」)。一字不写。
|
|
17
|
+
* · `refused` —— 迁移步拒(收据 `refused`:要猜才改得动的形)**或**要写回的文档整域读不进(整域终判:名字 / 路由唯一这类跨行规则只有整域判得出)。一字不写。
|
|
18
|
+
* · `noop` —— 读到了文档、没有要改的。
|
|
19
|
+
* · `pending` —— `check: true` 且有改动、整域判得过:一字不写。
|
|
20
|
+
* · `applied` —— 已写回:先写改过 / 新建的文档,最后删被移除的 retired 域文件 —— 中途断掉再跑一次,迁移照样恒等收尾(已搬过去的值在新位上,旧文件里同名键按「新位赢」丢弃)。
|
|
21
|
+
* 写盘阶段的 I/O 错误**原样抛出**(不吞、不改写):已写完的文档留在新位上,再跑一次幂等收尾。
|
|
22
|
+
*/
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { formatConfigPath, parseDomain } from "./config-fns.js";
|
|
25
|
+
import { judgeConfigDText } from "./config-d-file.js";
|
|
26
|
+
import { migrateStoredDocuments } from "./one-time-migrations.js";
|
|
27
|
+
import { RUNTIME_LIFT_TARGETS } from "./runtime-lift-targets.js";
|
|
28
|
+
import { isDomainName, isRetiredDomainName } from "./types.js";
|
|
29
|
+
/** 目录加文件名(目录尾部的分隔符折掉)。包内私有(不进 `./node`)。 */
|
|
30
|
+
export const joinDirPath = (dir, name) => `${dir.replace(/[\\/]+$/, "")}/${name}`;
|
|
31
|
+
export function reportReceipt(r) {
|
|
32
|
+
const base = { migration: r.migration, document: r.document, path: formatConfigPath(r.path), appliedAs: r.appliedAs };
|
|
33
|
+
switch (r.appliedAs) {
|
|
34
|
+
case "moved":
|
|
35
|
+
return { ...base, to: `${r.to.document}:${formatConfigPath(r.to.path)}` };
|
|
36
|
+
case "refused":
|
|
37
|
+
return { ...base, reason: r.reason };
|
|
38
|
+
case "rewritten":
|
|
39
|
+
case "dropped-shadowed":
|
|
40
|
+
case "dropped-inert":
|
|
41
|
+
return base;
|
|
42
|
+
default: {
|
|
43
|
+
const unhandled = r;
|
|
44
|
+
throw new Error(`settings-schema: unhandled receipt ${JSON.stringify(unhandled)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** 整域终判的一条拒(issue 来自 `parseDomain`,已过本包唯一的脱敏器 —— 坐标段与句子都是出门形)。 */
|
|
49
|
+
function wholeDomainRefusal(document, path, reason) {
|
|
50
|
+
return { migration: null, document, path: formatConfigPath(path), appliedAs: "refused", reason };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 迁移结果 → 结论。顺序:迁移步拒 → 无改动 ⇒ noop → **整域终判**(要写回的每个域文档 —— 改过的、新建的 —— 过读侧那一只
|
|
54
|
+
* `parseDomain`:名字 / 路由唯一这类跨行规则只有整域判得出)→ 有改动。整域终判在 `check` 之前:否则同一个目录 `check` 报 pending、
|
|
55
|
+
* 真跑写出一份读侧必拒的文档。没改的文件不归这里判(迁移不碰它,它读不读得进是读者的事)。
|
|
56
|
+
*/
|
|
57
|
+
function verdictOf(input, migrated, receipts) {
|
|
58
|
+
const refusedReceipts = receipts.filter((r) => r.appliedAs === "refused");
|
|
59
|
+
if (refusedReceipts.length > 0) {
|
|
60
|
+
return { kind: "refused", refused: refusedReceipts.map((r) => reportReceipt(r)), why: `${refusedReceipts.length} coordinate(s) cannot be migrated (see "refused") — fix them by hand and re-run` };
|
|
61
|
+
}
|
|
62
|
+
if (receipts.length === 0)
|
|
63
|
+
return { kind: "noop" };
|
|
64
|
+
const refused = [];
|
|
65
|
+
for (const [name, doc] of Object.entries(migrated)) {
|
|
66
|
+
if (!isDomainName(name) || (Object.prototype.hasOwnProperty.call(input, name) && input[name] === doc))
|
|
67
|
+
continue;
|
|
68
|
+
try {
|
|
69
|
+
parseDomain(name, doc);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (err instanceof z.ZodError)
|
|
73
|
+
for (const i of err.issues)
|
|
74
|
+
refused.push(wholeDomainRefusal(name, i.path, i.message));
|
|
75
|
+
else
|
|
76
|
+
refused.push(wholeDomainRefusal(name, [], err instanceof Error ? err.message : String(err)));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (refused.length > 0) {
|
|
80
|
+
const documents = [...new Set(refused.map((r) => r.document))];
|
|
81
|
+
return { kind: "refused", refused, why: `the migrated ${documents.join(" / ")} document(s) do not read as a whole domain (see "refused") — fix them by hand and re-run` };
|
|
82
|
+
}
|
|
83
|
+
return { kind: "changes" };
|
|
84
|
+
}
|
|
85
|
+
const messageOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
86
|
+
/**
|
|
87
|
+
* 对 `dir`(一个 `config.d` 目录)的覆盖面内文档跑一次一次性迁移;语义与结果见文件头。`dir` 与目录内文件名的拼接用 `/`。
|
|
88
|
+
*/
|
|
89
|
+
export async function migrateConfigDirectory(dir, opts) {
|
|
90
|
+
const covered = new Set();
|
|
91
|
+
for (const name of opts.documents) {
|
|
92
|
+
if (!isDomainName(name) && !isRetiredDomainName(name))
|
|
93
|
+
throw new TypeError(`migrateConfigDirectory: "${String(name)}" is not a config domain or a retired domain — the coverage names the documents the caller reads`);
|
|
94
|
+
covered.add(name);
|
|
95
|
+
}
|
|
96
|
+
const { io } = opts;
|
|
97
|
+
let names;
|
|
98
|
+
try {
|
|
99
|
+
names = await io.readDir(dir);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
return { kind: "unreadable", reason: `cannot read directory ${dir}: ${messageOf(err)}` };
|
|
103
|
+
}
|
|
104
|
+
const docs = {};
|
|
105
|
+
for (const name of [...names].sort()) {
|
|
106
|
+
if (name.startsWith(".") || !name.endsWith(".json"))
|
|
107
|
+
continue;
|
|
108
|
+
const base = name.slice(0, -".json".length);
|
|
109
|
+
if (!covered.has(base))
|
|
110
|
+
continue;
|
|
111
|
+
let text;
|
|
112
|
+
try {
|
|
113
|
+
text = await io.readFile(joinDirPath(dir, name));
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
return { kind: "unreadable", reason: `cannot read ${name}: ${messageOf(err)}` };
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
docs[base] = judgeConfigDText(base, text); // the one config.d file judge: empty / whitespace / bad JSON / not an object ⇒ same sentence as FileConfigStore
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
return { kind: "unreadable", reason: messageOf(err) };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const read = Object.keys(docs);
|
|
126
|
+
if (read.length === 0)
|
|
127
|
+
return { kind: "no-documents", entries: names };
|
|
128
|
+
// 目录是不是「一个 scope 的全集」只看一件事:SS-M1 要写的目标域是不是都在覆盖面里(见文件头);不是 ⇒ partial,runtime 的迁移被它自己拒掉。
|
|
129
|
+
const coverage = RUNTIME_LIFT_TARGETS.every((target) => covered.has(target)) ? "scope" : "partial";
|
|
130
|
+
const m = migrateStoredDocuments(docs, { coverage });
|
|
131
|
+
const outside = Object.keys(m.docs).filter((name) => !Object.prototype.hasOwnProperty.call(docs, name) && !covered.has(name));
|
|
132
|
+
if (outside.length > 0) {
|
|
133
|
+
// 守卫:迁移新建了覆盖面外的文档。那份文件调用方没读过,「从未设过」只对覆盖面内成立 —— 写下去会覆盖它。今天不会发生(见文件头);发生了就是迁移步的写入域变了,这里先红。
|
|
134
|
+
throw new Error(`migrateConfigDirectory: the migration produced document(s) outside the coverage (${outside.join(", ")}) — refusing to write files the caller did not read`);
|
|
135
|
+
}
|
|
136
|
+
const applied = m.receipts.filter((r) => r.appliedAs !== "refused");
|
|
137
|
+
const verdict = verdictOf(docs, m.docs, m.receipts);
|
|
138
|
+
switch (verdict.kind) {
|
|
139
|
+
case "refused":
|
|
140
|
+
return { kind: "refused", read, applied, refused: verdict.refused, why: verdict.why };
|
|
141
|
+
case "noop":
|
|
142
|
+
return { kind: "noop", read };
|
|
143
|
+
case "changes":
|
|
144
|
+
break;
|
|
145
|
+
default: {
|
|
146
|
+
const unhandled = verdict;
|
|
147
|
+
throw new Error(`settings-schema: unhandled verdict ${JSON.stringify(unhandled)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (opts.check === true)
|
|
151
|
+
return { kind: "pending", read, applied };
|
|
152
|
+
const written = [];
|
|
153
|
+
const removed = [];
|
|
154
|
+
for (const [name, doc] of Object.entries(m.docs)) {
|
|
155
|
+
if (Object.prototype.hasOwnProperty.call(docs, name) && docs[name] === doc)
|
|
156
|
+
continue;
|
|
157
|
+
await io.writeFileAtomic(joinDirPath(dir, `${name}.json`), `${JSON.stringify(doc, null, 2)}\n`);
|
|
158
|
+
written.push(`${name}.json`);
|
|
159
|
+
}
|
|
160
|
+
for (const name of Object.keys(docs)) {
|
|
161
|
+
if (Object.prototype.hasOwnProperty.call(m.docs, name))
|
|
162
|
+
continue;
|
|
163
|
+
await io.removeFile(joinDirPath(dir, `${name}.json`));
|
|
164
|
+
removed.push(`${name}.json`);
|
|
165
|
+
}
|
|
166
|
+
return { kind: "applied", read, applied, written, removed };
|
|
167
|
+
}
|
package/dist/file-edit.d.ts
CHANGED
|
@@ -17,9 +17,9 @@ export declare function validateRemoteExec(raw: unknown): ValidateResult<RemoteE
|
|
|
17
17
|
/** Absolute path to a domain's JSON file under a config root (mirrors FileConfigStore.domainPath). Guards the
|
|
18
18
|
* name (no `../../secret` traversal even if a caller bypasses the DomainName type). */
|
|
19
19
|
export declare function domainFilePath(root: string, domain: DomainName): string;
|
|
20
|
-
/** WRITE a domain file ATOMICALLY (write
|
|
21
|
-
* config.d/<domain>.json
|
|
22
|
-
* defaulted value so the file is canonical. */
|
|
20
|
+
/** WRITE a domain file ATOMICALLY (the package's one atomic writer, `./atomic-write`: tmp in the same dir → rename) so a crash never
|
|
21
|
+
* leaves a half-written config.d/<domain>.json; a new file takes the contract-document mode (0o644), an existing one keeps its own.
|
|
22
|
+
* VALIDATES first (throws on invalid — never persists garbage); persists the parsed + defaulted value so the file is canonical. */
|
|
23
23
|
export declare function writeDomainFile<K extends DomainName>(root: string, domain: K, value: unknown): Promise<DomainConfig[K]>;
|
|
24
24
|
export interface RequiredEnvName {
|
|
25
25
|
envName: string;
|
package/dist/file-edit.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Validation uses the SAME zod schemas as the server's parseDomain → identical rules on both sides.
|
|
7
7
|
*/
|
|
8
|
-
import { mkdir
|
|
8
|
+
import { mkdir } from "node:fs/promises";
|
|
9
9
|
import path from "node:path";
|
|
10
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
10
11
|
import { collectEnvRefs } from "./secret-refs.js";
|
|
11
12
|
import { RemoteExecSpec } from "./remote-exec.js";
|
|
12
13
|
import { RetiredDomainError, parseDomain, sanitizeZodError } from "./config-fns.js";
|
|
@@ -25,8 +26,6 @@ function assertDomainName(domain) {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
const DOMAINS_LIST = Object.keys(DOMAIN_SCHEMAS).join(", ");
|
|
28
|
-
// Monotonic suffix so concurrent same-domain atomic writes in one process+ms can't collide on the tmp name.
|
|
29
|
-
let tmpSeq = 0;
|
|
30
29
|
/** VALIDATE a domain payload against its zod schema WITHOUT writing — the editor's "is this form valid?" check.
|
|
31
30
|
* Returns the parsed+defaulted value or the zod issues. Identical rules to the server's parseDomain. Throws
|
|
32
31
|
* (clear message) if `domain` is not a real domain name (a misuse, not invalid user data). */
|
|
@@ -61,24 +60,15 @@ export function domainFilePath(root, domain) {
|
|
|
61
60
|
assertDomainName(domain);
|
|
62
61
|
return path.join(root, "config.d", `${domain}.json`);
|
|
63
62
|
}
|
|
64
|
-
/** WRITE a domain file ATOMICALLY (write
|
|
65
|
-
* config.d/<domain>.json
|
|
66
|
-
* defaulted value so the file is canonical. */
|
|
63
|
+
/** WRITE a domain file ATOMICALLY (the package's one atomic writer, `./atomic-write`: tmp in the same dir → rename) so a crash never
|
|
64
|
+
* leaves a half-written config.d/<domain>.json; a new file takes the contract-document mode (0o644), an existing one keeps its own.
|
|
65
|
+
* VALIDATES first (throws on invalid — never persists garbage); persists the parsed + defaulted value so the file is canonical. */
|
|
67
66
|
export async function writeDomainFile(root, domain, value) {
|
|
68
67
|
assertDomainName(domain); // a stray non-domain key (e.g. "remote-exec" from a config.d scan) fails LOUD + clear
|
|
69
68
|
const parsed = parseDomain(domain, value); // routes through the SANITIZED parser (explicit-null reject R4 + no secret echo R14)
|
|
70
69
|
const dir = path.join(root, "config.d");
|
|
71
70
|
await mkdir(dir, { recursive: true });
|
|
72
|
-
|
|
73
|
-
const tmp = path.join(dir, `.${domain}.json.${process.pid}.${Date.now()}.${tmpSeq++}.tmp`);
|
|
74
|
-
try {
|
|
75
|
-
await writeFile(tmp, JSON.stringify(parsed, null, 2) + "\n", "utf8");
|
|
76
|
-
await rename(tmp, dest);
|
|
77
|
-
}
|
|
78
|
-
catch (e) {
|
|
79
|
-
await unlink(tmp).catch(() => { }); // never leave a half-written tmp behind on a write/rename failure
|
|
80
|
-
throw e;
|
|
81
|
-
}
|
|
71
|
+
writeFileAtomic(path.join(dir, `${domain}.json`), JSON.stringify(parsed, null, 2) + "\n", "config-document");
|
|
82
72
|
return parsed;
|
|
83
73
|
}
|
|
84
74
|
/** LIST every env-NAME the current local config requires (across models/workers/mcp + the
|
package/dist/file-store.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* etc.) — no stubbing needed. 逐域为什么被排除见 {@link NON_PORTABLE_DOMAIN_REASONS}(禁裸排除)。
|
|
19
19
|
* - A RETIRED domain's file (`config.d/runtime.json`, see `RETIRED_DOMAINS`) is read only for its tombstone: carrying facts ⇒
|
|
20
20
|
* every read entry (`getDomain`, `getEffective` — and so `exportBundle`) throws `RetiredDomainError` (`refuseRetiredDomainFiles`, shared with `readBundle`) (the sentence names the one-time migration — `npx @sema-agent/settings-schema migrate
|
|
21
|
-
* <
|
|
21
|
+
* <config.d directory>` rewrites the directory); `{}` carries nothing and is left alone. It is never an "unread file".
|
|
22
22
|
* - The JSON files only ever carry env-NAMEs (the ENV_NAME boundary holds locally too); `.env` holds values.
|
|
23
23
|
* - `getVersion()` = a stable content hash of all portable files → a u53 number, so a consumer's
|
|
24
24
|
* "skip re-apply if version unchanged" works locally. `getEffective().updatedAt` = the max file mtime,
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export * from "./api/pull-token.js"; // scope-bound token FORM + minting rule (o
|
|
|
29
29
|
export * from "./api/feedback.js"; // /bug report wire contract: FeedbackSubmitRequest (POST body) + FeedbackWire / FeedbackListResponse + the two caps, pure/browser-safe
|
|
30
30
|
export * from "./web-admin-env.js"; // WEB_ADMIN_ENV — every env name an engine reads to reach web-admin (one table; a leaf module), pure/browser-safe
|
|
31
31
|
export * from "./web-admin-credential.js"; // S-698 web-admin credential FILE contract: WebAdminCredentialEnvelope (user-session envelope, strict) + parseWebAdminCredentialFile (bare / envelope / unreadable — the one discrimination law) + judgeWebAdminCredentialDeclaration(env, credential) (pick-exactly-one over the credential's WEB_ADMIN_ENV pair; WEB_ADMIN_CREDENTIALS = access | assertion), pure/browser-safe
|
|
32
|
+
export * from "./web-admin-session-url.js"; // webAdminSessionUrl(webBaseUrl, { worker, sessionId }) — THE web-admin session link template (<webBaseUrl>/chat?agent=<worker>&session=<sessionId>) + parseWebAdminSessionUrl(link) its paired reader, pure/browser-safe
|
|
32
33
|
export * from "./retired-envs.js"; // the retired-env TOMBSTONE table (renamed / replaced / removed, closed set) + retiredEnvRefusal(env) (the one refusal sentence) + buildRetiredEnvWire() — engine boot, catalog wire, shell and web-admin read this one table, pure/browser-safe
|
|
33
34
|
// NOTE: the Node-fs-backed helpers (FileConfigStore + the file editor) are NOT in this barrel — they pull
|
|
34
35
|
// `node:fs`/`node:path` and would break a browser/client bundle that only wants the contract (e.g.
|
package/dist/local-load.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export declare function remoteExecPath(root: string): string;
|
|
|
7
7
|
/** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if the file is absent
|
|
8
8
|
* (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file. */
|
|
9
9
|
export declare function loadRemoteExec(root: string): Promise<RemoteExecSpec | undefined>;
|
|
10
|
-
/** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (
|
|
10
|
+
/** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (the package's one atomic writer, `./atomic-write`; never a half file). The
|
|
11
11
|
* client's `config` editor writes the exec backend through this; the engine reads it via loadRemoteExec. */
|
|
12
12
|
export declare function writeRemoteExec(root: string, spec: unknown): Promise<RemoteExecSpec>;
|
|
13
13
|
/** Parse `<root>/.env` (KEY=VALUE, `#` comments, optional surrounding quotes) MERGED OVER process.env (the
|
package/dist/local-load.js
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* `<root>/config.d/remote-exec.json` (service vote), loaded/validated by loadRemoteExec — distinct from
|
|
8
8
|
* the per-domain `config.d/<domain>.json` the resolver reads. getEffective never reads it (not a DomainName).
|
|
9
9
|
*/
|
|
10
|
-
import { mkdir, readFile
|
|
10
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
13
14
|
import { FileConfigStore } from "./file-store.js";
|
|
14
15
|
import { listRequiredEnvNames } from "./file-edit.js";
|
|
15
16
|
import { sanitizeZodError } from "./config-fns.js";
|
|
@@ -28,9 +29,6 @@ function parseRemoteExec(raw) {
|
|
|
28
29
|
return r.data;
|
|
29
30
|
throw sanitizeZodError(r.error);
|
|
30
31
|
}
|
|
31
|
-
// Monotonic suffix so two atomic writes in the SAME process+millisecond can't collide on the tmp name (which
|
|
32
|
-
// would make one rename hit ENOENT / lose a write). pid+Date.now() alone isn't unique under concurrency.
|
|
33
|
-
let tmpSeq = 0;
|
|
34
32
|
/** The remoteExec file path under a config root (under config.d/, beside the per-domain files). */
|
|
35
33
|
export function remoteExecPath(root) {
|
|
36
34
|
return path.join(root, "config.d", "remote-exec.json");
|
|
@@ -62,21 +60,13 @@ export async function loadRemoteExec(root) {
|
|
|
62
60
|
}
|
|
63
61
|
return parseRemoteExec(json);
|
|
64
62
|
}
|
|
65
|
-
/** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (
|
|
63
|
+
/** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (the package's one atomic writer, `./atomic-write`; never a half file). The
|
|
66
64
|
* client's `config` editor writes the exec backend through this; the engine reads it via loadRemoteExec. */
|
|
67
65
|
export async function writeRemoteExec(root, spec) {
|
|
68
66
|
const parsed = parseRemoteExec(spec);
|
|
69
67
|
const dir = path.join(root, "config.d");
|
|
70
68
|
await mkdir(dir, { recursive: true });
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
await writeFile(tmp, JSON.stringify(parsed, null, 2) + "\n", "utf8");
|
|
74
|
-
await rename(tmp, remoteExecPath(root));
|
|
75
|
-
}
|
|
76
|
-
catch (e) {
|
|
77
|
-
await unlink(tmp).catch(() => { }); // never leave a half-written tmp behind
|
|
78
|
-
throw e;
|
|
79
|
-
}
|
|
69
|
+
writeFileAtomic(remoteExecPath(root), JSON.stringify(parsed, null, 2) + "\n", "config-document");
|
|
80
70
|
return parsed;
|
|
81
71
|
}
|
|
82
72
|
/** Resolve a `file:<path>` .env VALUE → the file's trimmed content (the file-NAME secret ref, config-research
|