@sema-agent/settings-schema 11.2.0 → 12.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/README.md +2 -1
- package/conformance/VERSION +1 -1
- package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +6 -3
- package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +4 -2
- package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +4 -2
- package/conformance/cases/c46-s811-host-name-url-userinfo-withheld/expected.json +2 -1
- package/conformance/cases/c73-projects-extended-registration-reads/expected.json +150 -0
- package/conformance/cases/c73-projects-extended-registration-reads/input.json +42 -0
- package/conformance/cases/c73-projects-extended-registration-reads/meta.json +6 -0
- package/conformance/cases/c74-projects-over-bound-refused/expected.json +89 -0
- package/conformance/cases/c74-projects-over-bound-refused/input.json +29 -0
- package/conformance/cases/c74-projects-over-bound-refused/meta.json +5 -0
- package/conformance/cases/c75-projects-patch-remote-uniqueness/expected.json +89 -0
- package/conformance/cases/c75-projects-patch-remote-uniqueness/input.json +35 -0
- package/conformance/cases/c75-projects-patch-remote-uniqueness/meta.json +5 -0
- package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +4 -2
- package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +4 -2
- package/dist/api/feedback.js +2 -3
- package/dist/api/scopes.js +2 -1
- package/dist/atomic-write.d.ts +6 -0
- package/dist/atomic-write.js +23 -6
- package/dist/cli.d.ts +8 -4
- package/dist/cli.js +55 -10
- package/dist/config-d-file.d.ts +12 -2
- package/dist/config-d-file.js +36 -9
- package/dist/config-dir-io.js +4 -3
- package/dist/config-dir-migration.d.ts +5 -3
- package/dist/config-dir-migration.js +19 -9
- package/dist/config-fns.d.ts +14 -2
- package/dist/config-fns.js +13 -1
- package/dist/cross-domain.d.ts +8 -3
- package/dist/cross-domain.js +23 -13
- package/dist/file-edit.js +7 -5
- package/dist/file-store.js +2 -1
- package/dist/fleet.js +3 -4
- package/dist/hooks.d.ts +14 -1
- package/dist/hooks.js +61 -16
- package/dist/host-path.d.ts +17 -0
- package/dist/host-path.js +22 -0
- package/dist/integer.js +2 -1
- package/dist/local-load.d.ts +1 -1
- package/dist/local-load.js +9 -31
- package/dist/migration-receipt.d.ts +4 -3
- package/dist/migration-receipt.js +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/one-time-migrations.d.ts +6 -4
- package/dist/one-time-migrations.js +73 -6
- package/dist/quoted-issue.d.ts +41 -0
- package/dist/quoted-issue.js +35 -0
- package/dist/remote-exec.d.ts +3 -0
- package/dist/remote-exec.js +12 -7
- package/dist/resolve-roster.d.ts +1 -1
- package/dist/resolve-roster.js +3 -3
- package/dist/retired-role-words.d.ts +5 -0
- package/dist/retired-role-words.js +25 -0
- package/dist/rfc3339.d.ts +10 -0
- package/dist/rfc3339.js +12 -0
- package/dist/safety-merge-spec.d.ts +2 -2
- package/dist/types.d.ts +140 -32
- package/dist/types.js +215 -114
- package/dist/web-admin-credential.d.ts +2 -2
- package/dist/web-admin-credential.js +5 -10
- package/dist/write-failure.d.ts +16 -0
- package/dist/write-failure.js +43 -0
- package/package.json +1 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"version": 17,
|
|
4
|
+
"updatedAt": "2026-10-12T00:00:00.000Z"
|
|
5
|
+
},
|
|
6
|
+
"domains": {
|
|
7
|
+
"projects": {
|
|
8
|
+
"projects": {
|
|
9
|
+
"01890a5d-ac96-774b-bcce-b302099a8057": {
|
|
10
|
+
"gitRemotes": [
|
|
11
|
+
"example.com/acme/shared"
|
|
12
|
+
],
|
|
13
|
+
"defaultScopes": [
|
|
14
|
+
"proj"
|
|
15
|
+
],
|
|
16
|
+
"registeredBy": "alice",
|
|
17
|
+
"registeredAt": "2026-10-01T00:00:00.000Z",
|
|
18
|
+
"displayName": "holder"
|
|
19
|
+
},
|
|
20
|
+
"9b2ee1f0-3f9a-4c1d-8b7e-2a54ff00c9d3": {
|
|
21
|
+
"gitRemotes": [
|
|
22
|
+
"example.com/acme/own",
|
|
23
|
+
"example.com/acme/shared"
|
|
24
|
+
],
|
|
25
|
+
"defaultScopes": [
|
|
26
|
+
"proj"
|
|
27
|
+
],
|
|
28
|
+
"registeredBy": "alice",
|
|
29
|
+
"registeredAt": "2026-10-01T00:00:00.000Z",
|
|
30
|
+
"displayName": "patched"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -76,7 +76,8 @@
|
|
|
76
76
|
"domain": "models",
|
|
77
77
|
"identity": {
|
|
78
78
|
"idWithheld": true,
|
|
79
|
-
"nameWithheld": true
|
|
79
|
+
"nameWithheld": true,
|
|
80
|
+
"token": "withheld:b2fca8da7930"
|
|
80
81
|
},
|
|
81
82
|
"kind": "entry-dropped",
|
|
82
83
|
"path": [
|
|
@@ -89,7 +90,8 @@
|
|
|
89
90
|
"domain": "models",
|
|
90
91
|
"identity": {
|
|
91
92
|
"idWithheld": true,
|
|
92
|
-
"nameWithheld": true
|
|
93
|
+
"nameWithheld": true,
|
|
94
|
+
"token": "withheld:946256d8ea6b"
|
|
93
95
|
},
|
|
94
96
|
"kind": "entry-dropped",
|
|
95
97
|
"path": [
|
|
@@ -89,7 +89,8 @@
|
|
|
89
89
|
"domain": "models",
|
|
90
90
|
"identity": {
|
|
91
91
|
"idWithheld": true,
|
|
92
|
-
"nameWithheld": true
|
|
92
|
+
"nameWithheld": true,
|
|
93
|
+
"token": "withheld:f3b4942da5cd"
|
|
93
94
|
},
|
|
94
95
|
"kind": "entry-dropped",
|
|
95
96
|
"path": [
|
|
@@ -102,7 +103,8 @@
|
|
|
102
103
|
"domain": "models",
|
|
103
104
|
"identity": {
|
|
104
105
|
"idWithheld": true,
|
|
105
|
-
"nameWithheld": true
|
|
106
|
+
"nameWithheld": true,
|
|
107
|
+
"token": "withheld:2469fd783cfd"
|
|
106
108
|
},
|
|
107
109
|
"kind": "entry-dropped",
|
|
108
110
|
"path": [
|
package/dist/api/feedback.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import { INTEGER } from "../integer.js";
|
|
16
|
+
import { buildQuotedIssue, fixed } from "../quoted-issue.js";
|
|
16
17
|
/** 报告的端点,相对 web-admin 根。 */
|
|
17
18
|
export const FEEDBACK_V1_PATH = "/api/v1/feedback";
|
|
18
19
|
/** `description`(去首尾空白后)的上限 —— 报告常带粘贴的报错与栈,所以宽,但有界。 */
|
|
@@ -36,9 +37,7 @@ export const FeedbackSubmitRequest = z.object({
|
|
|
36
37
|
/** 客户端自报的时刻(参考,不信任 —— 回体的 `createdAt` 由服务端盖)。 */
|
|
37
38
|
datetime: z.string(),
|
|
38
39
|
latestAssistantMessageId: z.string().nullable().optional(),
|
|
39
|
-
transcript: z.array(z.unknown()).refine((t) => JSON.stringify(t).length <= FEEDBACK_TRANSCRIPT_MAX_JSON_CHARS, {
|
|
40
|
-
message: `transcript must serialize to at most ${FEEDBACK_TRANSCRIPT_MAX_JSON_CHARS} characters`,
|
|
41
|
-
}),
|
|
40
|
+
transcript: z.array(z.unknown()).refine((t) => JSON.stringify(t).length <= FEEDBACK_TRANSCRIPT_MAX_JSON_CHARS, buildQuotedIssue `transcript must serialize to at most ${fixed(String(FEEDBACK_TRANSCRIPT_MAX_JSON_CHARS))} characters`),
|
|
42
41
|
errors: z.array(FeedbackError).optional(),
|
|
43
42
|
lastApiRequest: z.unknown().optional(),
|
|
44
43
|
subagentTranscripts: z.record(z.string(), z.unknown()).optional(),
|
package/dist/api/scopes.js
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
*/
|
|
46
46
|
import { z } from "zod";
|
|
47
47
|
import { INTEGER } from "../integer.js";
|
|
48
|
+
import { buildQuotedIssue, fixed } from "../quoted-issue.js";
|
|
48
49
|
import { USER_ROLES } from "../config-fns.js";
|
|
49
50
|
import { ACCESS_TOKEN_TTL_SECONDS } from "./auth.js";
|
|
50
51
|
// ── constants ────────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -137,7 +138,7 @@ export const SCOPE_SWITCH_TOKEN_TTL_SECONDS = ACCESS_TOKEN_TTL_SECONDS;
|
|
|
137
138
|
/** Reserved ids ("global") are rejected AT THE SCHEMA — the default scope pre-exists and is not creatable. */
|
|
138
139
|
export const ScopeCreateRequest = z
|
|
139
140
|
.object({
|
|
140
|
-
id: ScopeId.refine((id) => !isReservedScopeId(id),
|
|
141
|
+
id: ScopeId.refine((id) => !isReservedScopeId(id), buildQuotedIssue `"${fixed(GLOBAL_SCOPE)}" is reserved`),
|
|
141
142
|
name: z.string().min(1).max(200),
|
|
142
143
|
})
|
|
143
144
|
.passthrough();
|
package/dist/atomic-write.d.ts
CHANGED
|
@@ -10,5 +10,11 @@ export declare const ATOMIC_FILE_CLASSES: {
|
|
|
10
10
|
};
|
|
11
11
|
};
|
|
12
12
|
export type AtomicFileClass = keyof typeof ATOMIC_FILE_CLASSES;
|
|
13
|
+
/** 悬空符号链接的**唯一一句**(写器与目录迁移的读口抛同一句;不带 errno 码 —— 带了 `ENOENT` 的错会被「ENOENT = 缺席」的读者读成文件不在,
|
|
14
|
+
* 悬空链接恰恰不是缺席)。 */
|
|
15
|
+
export declare function danglingSymlinkSentence(link: string, pointsAt: string): string;
|
|
16
|
+
/** 读写的真目标 —— **一只判法**(写器与目录迁移的读口共用):`target` 是符号链接 ⇒ 解析到它指向的真文件(悬空 ⇒ 抛 {@link danglingSymlinkSentence});
|
|
17
|
+
* 不是链接或不存在 ⇒ 原样(不存在的由调用方的读 / 写照常报)。 */
|
|
18
|
+
export declare function resolveSymlinkTarget(target: string): string;
|
|
13
19
|
/** 原子写 `text`(UTF-8)到 `target`(符号链接则写到它指向的真文件,见文件头);模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
|
|
14
20
|
export declare function writeFileAtomic(target: string, text: string, kind: AtomicFileClass): void;
|
package/dist/atomic-write.js
CHANGED
|
@@ -17,17 +17,31 @@
|
|
|
17
17
|
* 临时文件建在真文件所在目录、rename 到真文件**:链接(及链上每一环)原样保留,真文件的内容更新,模式规则作用在真文件上。临时文件与目标同目录 ⇒ rename 不会跨文件系统;
|
|
18
18
|
* 真文件所在目录写不进、或 rename 因别的原因失败 ⇒ 响亮抛,**绝不**回退成「在链接位置写一个普通文件」(那是静默换形)。路径本身是悬空链接 ⇒ 响亮抛
|
|
19
19
|
* (不在链接位置建普通文件,也不替链接建它指向的文件);成环的链接由 `realpath` 抛 `ELOOP`。目标不存在 ⇒ 新建(没有链接可言;经符号链接的目录写新文件时,文件落在真目录里)。
|
|
20
|
+
* 「链接 ⇒ 真文件、悬空 ⇒ 响亮抛」这一只判法是 {@link resolveSymlinkTarget}:目录迁移的读口(`./config-dir-io`)读目录 / 读文件前也问它,
|
|
21
|
+
* 悬空链接在读阶段与写阶段是**同一句**({@link danglingSymlinkSentence})。
|
|
22
|
+
*
|
|
23
|
+
* ## 写失败(12.0.0)
|
|
24
|
+
* 两步各一句({@link AtomicWriteFailure},句子在 `./write-failure`):临时文件建不出 ⇒ 「cannot write into directory <dir> (<errno>): …」(点名目录 —— 目录不可写时
|
|
25
|
+
* 文件本身没错);`rename` 失败 ⇒ 「cannot replace <file> (<errno>): …」(点名真文件)。认得的 errno 给对应出路,别的给 Node 自己的句子;`code` / `cause` 留着。
|
|
26
|
+
* 可写目录里的只读文件照常写得进(`rename` 换的是目录项)。
|
|
20
27
|
*/
|
|
21
28
|
import { randomBytes } from "node:crypto";
|
|
22
29
|
import { chmodSync, lstatSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
23
30
|
import path from "node:path";
|
|
31
|
+
import { AtomicWriteFailure } from "./write-failure.js";
|
|
24
32
|
/** 按文档类的模式表(闭集)。 */
|
|
25
33
|
export const ATOMIC_FILE_CLASSES = {
|
|
26
34
|
"config-document": { newMode: 0o644, existing: "keep" },
|
|
27
35
|
"scheduler-store": { newMode: 0o600, existing: "reset" },
|
|
28
36
|
};
|
|
29
|
-
/**
|
|
30
|
-
|
|
37
|
+
/** 悬空符号链接的**唯一一句**(写器与目录迁移的读口抛同一句;不带 errno 码 —— 带了 `ENOENT` 的错会被「ENOENT = 缺席」的读者读成文件不在,
|
|
38
|
+
* 悬空链接恰恰不是缺席)。 */
|
|
39
|
+
export function danglingSymlinkSentence(link, pointsAt) {
|
|
40
|
+
return `${link} is a dangling symbolic link (it points at ${pointsAt}, which does not exist) — nothing is read or written through it; repair or remove the link`;
|
|
41
|
+
}
|
|
42
|
+
/** 读写的真目标 —— **一只判法**(写器与目录迁移的读口共用):`target` 是符号链接 ⇒ 解析到它指向的真文件(悬空 ⇒ 抛 {@link danglingSymlinkSentence});
|
|
43
|
+
* 不是链接或不存在 ⇒ 原样(不存在的由调用方的读 / 写照常报)。 */
|
|
44
|
+
export function resolveSymlinkTarget(target) {
|
|
31
45
|
let link;
|
|
32
46
|
try {
|
|
33
47
|
link = lstatSync(target).isSymbolicLink();
|
|
@@ -44,14 +58,14 @@ function resolveWriteTarget(target) {
|
|
|
44
58
|
}
|
|
45
59
|
catch (e) {
|
|
46
60
|
if (e.code === "ENOENT") {
|
|
47
|
-
throw new Error(
|
|
61
|
+
throw new Error(danglingSymlinkSentence(target, readlinkSync(target)), { cause: e });
|
|
48
62
|
}
|
|
49
63
|
throw e;
|
|
50
64
|
}
|
|
51
65
|
}
|
|
52
66
|
/** 原子写 `text`(UTF-8)到 `target`(符号链接则写到它指向的真文件,见文件头);模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
|
|
53
67
|
export function writeFileAtomic(target, text, kind) {
|
|
54
|
-
const real =
|
|
68
|
+
const real = resolveSymlinkTarget(target);
|
|
55
69
|
const spec = ATOMIC_FILE_CLASSES[kind];
|
|
56
70
|
let kept; // the existing file's mode, when this class keeps it
|
|
57
71
|
switch (spec.existing) {
|
|
@@ -71,11 +85,14 @@ export function writeFileAtomic(target, text, kind) {
|
|
|
71
85
|
throw new Error(`writeFileAtomic: unhandled existing-file rule ${String(unhandled)}`);
|
|
72
86
|
}
|
|
73
87
|
}
|
|
74
|
-
const
|
|
88
|
+
const dir = path.dirname(real);
|
|
89
|
+
const tmp = path.join(dir, `.${path.basename(real)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
90
|
+
let step = "create";
|
|
75
91
|
try {
|
|
76
92
|
writeFileSync(tmp, text, { encoding: "utf8", mode: kept ?? spec.newMode });
|
|
77
93
|
if (kept !== undefined)
|
|
78
94
|
chmodSync(tmp, kept); // a kept mode is restored exactly (the creation mode above went through the umask)
|
|
95
|
+
step = "replace";
|
|
79
96
|
renameSync(tmp, real);
|
|
80
97
|
}
|
|
81
98
|
catch (e) {
|
|
@@ -85,6 +102,6 @@ export function writeFileAtomic(target, text, kind) {
|
|
|
85
102
|
catch {
|
|
86
103
|
/* 临时文件没建成:没什么可清 */
|
|
87
104
|
}
|
|
88
|
-
throw e;
|
|
105
|
+
throw new AtomicWriteFailure(step, dir, real, e); // 12.0.0: the sentence names the step that failed and its way out (`./write-failure`)
|
|
89
106
|
}
|
|
90
107
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -2,20 +2,22 @@
|
|
|
2
2
|
* `settings-schema` 命令行的核心(11.0.0)—— 子命令只有一个:`migrate <config.d 目录> [--check]`,即一次性迁移
|
|
3
3
|
* `migrateStoredDocuments`(`./one-time-migrations`)的目录形(本地 config.d / 离线文件用;web-admin 在它起服时直接调同一只函数)。
|
|
4
4
|
* 目录形的语义(读目录 → 迁 → 整域终判 → 原子写 → 删 retired 域文件)只有一处实现:`migrateConfigDirectory`(`./config-dir-migration`,`./node` 导出,引擎起服调同一只);
|
|
5
|
-
* 本模块是它的**渲染器**:把它的结果闭集渲染成退出码、报告行与 stderr
|
|
5
|
+
* 本模块是它的**渲染器**:把它的结果闭集渲染成退出码、报告行与 stderr 句子。命令行的覆盖面是全部配置域、retired 域与 `remote-exec.json`。
|
|
6
6
|
* 目录被当作**一个 scope 的全集**(`coverage: "scope"`):目录里没有的域文件就是从未设过。
|
|
7
7
|
*
|
|
8
8
|
* 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(真绑定是 `./config-dir-io` 的 `nodeConfigDirectoryIo`,写盘用的是 `./atomic-write` 那一只原子写器 —— 写法与文件模式在那里一处定),所以测试可以直接驱动它。
|
|
9
9
|
* 不进主桶 —— 它是 bin 的实现,不是契约面。
|
|
10
10
|
*
|
|
11
|
-
* 读哪些文件:目录里 `<名>.json`,名是配置域或 retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁)
|
|
12
|
-
* (
|
|
11
|
+
* 读哪些文件:目录里 `<名>.json`,名是配置域或 retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁)—— 以及 `remote-exec.json`
|
|
12
|
+
* (一次性迁移 SS-M7 的座位走查也走它的两个端点);别的文件(隐藏文件、不认识的名、非 `.json`)不读不动。
|
|
13
13
|
*
|
|
14
14
|
* 零域文档分两形:**有条目却没有域文档**(任何非 `.` 开头的条目 —— 子目录、说明文件、不认识的名)⇒ 退出 2;**目录真空**(零条目,或只有以 `.` 开头的条目)⇒ 照常走判定,
|
|
15
15
|
* 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
|
|
16
16
|
*
|
|
17
17
|
* 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
|
|
18
|
-
* 2 =
|
|
18
|
+
* 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与 errno,不打栈;写盘失败按落点分句 ——
|
|
19
|
+
* 目录建不出文件 ⇒ 点名目录、目标换不掉 ⇒ 点名文件,句尾是对应出路(原子写器的句子,`./write-failure`)——
|
|
20
|
+
* rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或覆盖面内某份文档(域文档或 `remote-exec.json`)是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` / `loadRemoteExec` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
|
|
19
21
|
* (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
|
|
20
22
|
* 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
|
|
21
23
|
*
|
|
@@ -32,4 +34,6 @@ export interface SettingsSchemaCliIo extends ConfigDirectoryIo {
|
|
|
32
34
|
stdout(text: string): void;
|
|
33
35
|
stderr(text: string): void;
|
|
34
36
|
}
|
|
37
|
+
/** 命令行的覆盖面:全部配置域、retired 域(便携与否不论 —— 离线的 `workers.json` 一样迁)与 `remote-exec.json`。 */
|
|
38
|
+
export declare const COVERED_DOCUMENTS: ("models" | "rosters" | "scenarios" | "skills" | "execution" | "plugins" | "systems" | "limits" | "runtime" | "workers" | "hosts" | "projects" | "mcp" | "collab" | "governance" | "entitlement" | "prompts" | "remote-exec")[];
|
|
35
39
|
export declare function runSettingsSchemaCli(argv: readonly string[], io: SettingsSchemaCliIo): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -2,20 +2,22 @@
|
|
|
2
2
|
* `settings-schema` 命令行的核心(11.0.0)—— 子命令只有一个:`migrate <config.d 目录> [--check]`,即一次性迁移
|
|
3
3
|
* `migrateStoredDocuments`(`./one-time-migrations`)的目录形(本地 config.d / 离线文件用;web-admin 在它起服时直接调同一只函数)。
|
|
4
4
|
* 目录形的语义(读目录 → 迁 → 整域终判 → 原子写 → 删 retired 域文件)只有一处实现:`migrateConfigDirectory`(`./config-dir-migration`,`./node` 导出,引擎起服调同一只);
|
|
5
|
-
* 本模块是它的**渲染器**:把它的结果闭集渲染成退出码、报告行与 stderr
|
|
5
|
+
* 本模块是它的**渲染器**:把它的结果闭集渲染成退出码、报告行与 stderr 句子。命令行的覆盖面是全部配置域、retired 域与 `remote-exec.json`。
|
|
6
6
|
* 目录被当作**一个 scope 的全集**(`coverage: "scope"`):目录里没有的域文件就是从未设过。
|
|
7
7
|
*
|
|
8
8
|
* 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(真绑定是 `./config-dir-io` 的 `nodeConfigDirectoryIo`,写盘用的是 `./atomic-write` 那一只原子写器 —— 写法与文件模式在那里一处定),所以测试可以直接驱动它。
|
|
9
9
|
* 不进主桶 —— 它是 bin 的实现,不是契约面。
|
|
10
10
|
*
|
|
11
|
-
* 读哪些文件:目录里 `<名>.json`,名是配置域或 retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁)
|
|
12
|
-
* (
|
|
11
|
+
* 读哪些文件:目录里 `<名>.json`,名是配置域或 retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁)—— 以及 `remote-exec.json`
|
|
12
|
+
* (一次性迁移 SS-M7 的座位走查也走它的两个端点);别的文件(隐藏文件、不认识的名、非 `.json`)不读不动。
|
|
13
13
|
*
|
|
14
14
|
* 零域文档分两形:**有条目却没有域文档**(任何非 `.` 开头的条目 —— 子目录、说明文件、不认识的名)⇒ 退出 2;**目录真空**(零条目,或只有以 `.` 开头的条目)⇒ 照常走判定,
|
|
15
15
|
* 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
|
|
16
16
|
*
|
|
17
17
|
* 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
|
|
18
|
-
* 2 =
|
|
18
|
+
* 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与 errno,不打栈;写盘失败按落点分句 ——
|
|
19
|
+
* 目录建不出文件 ⇒ 点名目录、目标换不掉 ⇒ 点名文件,句尾是对应出路(原子写器的句子,`./write-failure`)——
|
|
20
|
+
* rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或覆盖面内某份文档(域文档或 `remote-exec.json`)是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` / `loadRemoteExec` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
|
|
19
21
|
* (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
|
|
20
22
|
* 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
|
|
21
23
|
*
|
|
@@ -28,16 +30,19 @@
|
|
|
28
30
|
*/
|
|
29
31
|
import { reportReceipt, joinDirPath, migrateConfigDirectory } from "./config-dir-migration.js";
|
|
30
32
|
import { MIGRATE_COMMAND_TEMPLATE, migrateCommandFor } from "./retired-form.js";
|
|
33
|
+
import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
|
|
34
|
+
import { AtomicWriteFailure } from "./write-failure.js";
|
|
31
35
|
import { DOMAINS, RETIRED_DOMAINS } from "./types.js";
|
|
32
|
-
/**
|
|
33
|
-
const COVERED_DOCUMENTS = [...DOMAINS, ...Object.keys(RETIRED_DOMAINS)];
|
|
36
|
+
/** 命令行的覆盖面:全部配置域、retired 域(便携与否不论 —— 离线的 `workers.json` 一样迁)与 `remote-exec.json`。 */
|
|
37
|
+
export const COVERED_DOCUMENTS = [...DOMAINS, ...Object.keys(RETIRED_DOMAINS), REMOTE_EXEC_DOCUMENT];
|
|
34
38
|
const USAGE = [
|
|
35
39
|
`usage: ${MIGRATE_COMMAND_TEMPLATE} [--check]`,
|
|
36
|
-
" Runs the one-time migrations (@sema-agent/settings-schema/one-time-migrations) over every <domain>.json in the directory, read as one",
|
|
40
|
+
" Runs the one-time migrations (@sema-agent/settings-schema/one-time-migrations) over every <domain>.json (and remote-exec.json) in the directory, read as one",
|
|
37
41
|
" scope (a missing domain file = never set): writes the rewritten files in place and removes retired-domain files (runtime.json) only when",
|
|
38
42
|
" nothing is refused and every document it would write reads as a whole domain; --check runs the same verdict and only reports (exit 3",
|
|
39
43
|
" 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).",
|
|
44
|
+
" refused (exit 2); an empty directory has nothing to migrate (exit 0). An I/O failure (reading, writing or removing a file) exits 2 with",
|
|
45
|
+
" one line on stderr naming the path and the error.",
|
|
41
46
|
" stdout (exit 0 / 1 / 3): line 1 = outcome=<refused|applied|noop|pending> written=<true|false>, line 2 = the JSON report",
|
|
42
47
|
" (applied only when written=true; otherwise wouldApply).",
|
|
43
48
|
].join("\n");
|
|
@@ -69,6 +74,36 @@ function reportOf(outcome, dir, applied, refused) {
|
|
|
69
74
|
});
|
|
70
75
|
return `outcome=${outcome} written=${written}\n${json}\n`;
|
|
71
76
|
}
|
|
77
|
+
/** 写回阶段(写盘 / 删文件)的一次 I/O 失败 —— 环境错一臂(rc 2)。只由 {@link withEnvironmentArm} 包的两个写口铸;别的抛错(包内守卫、误用)不是它,照旧抛。 */
|
|
78
|
+
class EnvironmentFailure extends Error {
|
|
79
|
+
}
|
|
80
|
+
const oneLine = (text) => text.replace(/\s*\n\s*/g, " ");
|
|
81
|
+
const messageOf = (err) => oneLine(err instanceof Error ? err.message : String(err));
|
|
82
|
+
/** 把注入的写口包一层:写盘 / 删文件抛 ⇒ {@link EnvironmentFailure}。本包的原子写器抛的是按失败那一步分好的句子({@link AtomicWriteFailure}:目录建不出文件 ⇒
|
|
83
|
+
* 点名目录,目标换不掉 ⇒ 点名文件,句尾是对应出路)—— 原样用;别的写口(注入的替身)抛的错 ⇒ 点名**要写的那份文件**与原错误句。
|
|
84
|
+
* 读口不包:读目录 / 读文件错由 `migrateConfigDirectory` 自己收成 `unreadable`(同为 rc 2)。 */
|
|
85
|
+
function withEnvironmentArm(io) {
|
|
86
|
+
return {
|
|
87
|
+
readDir: (path) => io.readDir(path),
|
|
88
|
+
readFile: (path) => io.readFile(path),
|
|
89
|
+
writeFileAtomic: async (path, text) => {
|
|
90
|
+
try {
|
|
91
|
+
await io.writeFileAtomic(path, text);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
throw new EnvironmentFailure(err instanceof AtomicWriteFailure ? err.message : `cannot write ${path}: ${messageOf(err)}`, { cause: err });
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
removeFile: async (path) => {
|
|
98
|
+
try {
|
|
99
|
+
await io.removeFile(path);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
throw new EnvironmentFailure(`cannot remove ${path}: ${messageOf(err)}`, { cause: err });
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
72
107
|
/** 「有条目却没有域文档」的拒句:点名这个目录、说要的是 `config.d` 目录本身;`<dir>/config.d` 在场(探一次)就直接点它 —— 最常见的错是填了它的上一层。指路的命令里路径过 shell 引用,可直接粘贴。 */
|
|
73
108
|
async function noDomainDocumentsSentence(dir, io) {
|
|
74
109
|
const nested = joinDirPath(dir, "config.d");
|
|
@@ -85,10 +120,20 @@ export async function runSettingsSchemaCli(argv, io) {
|
|
|
85
120
|
io.stderr(`${USAGE}\n`);
|
|
86
121
|
return 2;
|
|
87
122
|
}
|
|
88
|
-
|
|
123
|
+
let m;
|
|
124
|
+
try {
|
|
125
|
+
m = await migrateConfigDirectory(dir, { documents: COVERED_DOCUMENTS, io: withEnvironmentArm(io), check });
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
if (!(err instanceof EnvironmentFailure))
|
|
129
|
+
throw err;
|
|
130
|
+
// 写回阶段断在这里:之前写完的文档已在新形上,再跑一次幂等收尾(迁移只认形)。stdout 不出结论行 —— 没有 `outcome=applied`。
|
|
131
|
+
io.stderr(`settings-schema: ${err.message} — files written before it keep their migrated form; re-run once that is fixed\n`);
|
|
132
|
+
return 2;
|
|
133
|
+
}
|
|
89
134
|
switch (m.kind) {
|
|
90
135
|
case "unreadable":
|
|
91
|
-
io.stderr(`settings-schema: ${m.reason}\n`);
|
|
136
|
+
io.stderr(`settings-schema: ${oneLine(m.reason)}\n`);
|
|
92
137
|
return 2;
|
|
93
138
|
case "no-documents":
|
|
94
139
|
if (m.entries.some((name) => !name.startsWith("."))) {
|
package/dist/config-d-file.d.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
|
|
1
2
|
import { type DomainName, type RetiredDomainName } from "./types.js";
|
|
3
|
+
/** 一份 `config.d` 文档写盘的**文本形** —— 两格缩进的 JSON 加一个换行。写域文件(`writeDomainFile`)、写 `remote-exec.json`(`writeRemoteExec`)与目录迁移写回
|
|
4
|
+
* 都用这一只(此前三处各写一遍同一行),于是迁移写回的文件与编辑器写出的同一份文件逐字节同形。 */
|
|
5
|
+
export declare function configDDocumentText(value: unknown): string;
|
|
6
|
+
/** 判一份 remote-exec 文档的值(`RemoteExecSpec`),错误**脱敏**(zod 会回显出错的值 —— 粘错位置的密钥不许随抛出的错走);反射访问抛(恶意 getter /
|
|
7
|
+
* Proxy)⇒ 定句。`loadRemoteExec` / `writeRemoteExec` 与目录迁移的整文档终判调同一只。 */
|
|
8
|
+
export declare function parseRemoteExecDocument(raw: unknown): RemoteExecSpec;
|
|
2
9
|
/** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是配置域也不是 retired 域 ⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
|
|
3
10
|
export declare function configDPath(root: string, name: string): string;
|
|
11
|
+
/** `config.d` 里能放的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`,不是配置域)。 */
|
|
12
|
+
export type ConfigDDocumentName = DomainName | RetiredDomainName | typeof REMOTE_EXEC_DOCUMENT;
|
|
4
13
|
/** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、
|
|
5
|
-
* 不是对象 ⇒
|
|
6
|
-
|
|
14
|
+
* 不是对象 ⇒ 抛,每种一句(点名文件与形,句尾 {@link documentWayOut})。读盘的入口(`readConfigDFile`、`loadRemoteExec`)与自己读文本的命令行(`migrate`)
|
|
15
|
+
* 都过它 —— 一种坏形一句话,不论是哪份文档。 */
|
|
16
|
+
export declare function judgeConfigDText(name: ConfigDDocumentName, text: string): Record<string, unknown>;
|
|
7
17
|
/** 读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ {@link judgeConfigDText}。 */
|
|
8
18
|
export declare function readConfigDFile(root: string, name: DomainName | RetiredDomainName): Promise<Record<string, unknown> | undefined>;
|
|
9
19
|
/**
|
package/dist/config-d-file.js
CHANGED
|
@@ -1,38 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 包内私有(不进主桶、不进 exports 表):**一只** `config.d/<名>.json` 文件读法 —— `FileConfigStore`、`readBundle` 与命令行 `migrate` 读同一个目录,
|
|
3
|
-
*
|
|
4
|
-
* `readConfigDFile`(调它);路径与名字的判只在 `configDPath`。
|
|
2
|
+
* 包内私有(不进主桶、不进 exports 表):**一只** `config.d/<名>.json` 文件读法 —— `FileConfigStore`、`readBundle`、`loadRemoteExec` 与命令行 `migrate` 读同一个目录,
|
|
3
|
+
* 同一种文件形同一个答案、同一句话(12.0.0 起 `remote-exec.json` 也是这一只;此前它有自己的一只文本判,只拒空与坏 JSON,非对象在命令行上被当成「无事可做」)。
|
|
4
|
+
* 分两只:纯判 `judgeConfigDText`(文本 → 值 | 抛,零 I/O;命令行与 `loadRemoteExec` 自己读文本,只调它)与读盘的 `readConfigDFile`(调它);路径与名字的判只在 `configDPath`。
|
|
5
5
|
*
|
|
6
6
|
* 律:**只有不存在(ENOENT)= 缺席**(`undefined`);在场的文件必须是一个 JSON 对象。空 / 纯空白(半写、截断)、坏 JSON、解析出来不是对象
|
|
7
7
|
* (`null` / 数组 / 数 / 串 / 布尔)一律是**坏文件** ⇒ 抛(fail-closed:把一个被截断的文件读成缺席,就是静默丢掉那一个域 —— 例:截断的
|
|
8
|
-
* governance.json
|
|
8
|
+
* governance.json 丢掉它的护栏;截断的 remote-exec.json 把隔离车道降成宿主车道)。三种坏形每种一句,点名文件与形、句尾出路按文档类
|
|
9
|
+
* ({@link documentWayOut}),从不回显文件原文。
|
|
9
10
|
*/
|
|
10
11
|
import { readFile } from "node:fs/promises";
|
|
11
12
|
import path from "node:path";
|
|
12
|
-
import { refuseRetiredDomain } from "./config-fns.js";
|
|
13
|
+
import { refuseRetiredDomain, sanitizeZodError } from "./config-fns.js";
|
|
14
|
+
import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
|
|
13
15
|
import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
|
|
16
|
+
/** 一份 `config.d` 文档写盘的**文本形** —— 两格缩进的 JSON 加一个换行。写域文件(`writeDomainFile`)、写 `remote-exec.json`(`writeRemoteExec`)与目录迁移写回
|
|
17
|
+
* 都用这一只(此前三处各写一遍同一行),于是迁移写回的文件与编辑器写出的同一份文件逐字节同形。 */
|
|
18
|
+
export function configDDocumentText(value) {
|
|
19
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
20
|
+
}
|
|
21
|
+
/** 判一份 remote-exec 文档的值(`RemoteExecSpec`),错误**脱敏**(zod 会回显出错的值 —— 粘错位置的密钥不许随抛出的错走);反射访问抛(恶意 getter /
|
|
22
|
+
* Proxy)⇒ 定句。`loadRemoteExec` / `writeRemoteExec` 与目录迁移的整文档终判调同一只。 */
|
|
23
|
+
export function parseRemoteExecDocument(raw) {
|
|
24
|
+
let r;
|
|
25
|
+
try {
|
|
26
|
+
r = RemoteExecSpec.safeParse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error("invalid remoteExec (a value could not be read during validation)");
|
|
30
|
+
}
|
|
31
|
+
if (r.success)
|
|
32
|
+
return r.data;
|
|
33
|
+
throw sanitizeZodError(r.error);
|
|
34
|
+
}
|
|
14
35
|
/** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是配置域也不是 retired 域 ⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
|
|
15
36
|
export function configDPath(root, name) {
|
|
16
37
|
if (!isDomainName(name) && !isRetiredDomainName(name))
|
|
17
38
|
throw new Error(`"${name}" is not a config domain — refusing to build a config.d path from it (no traversal)`);
|
|
18
39
|
return path.join(root, "config.d", `${name}.json`);
|
|
19
40
|
}
|
|
41
|
+
/** 坏文件句的出路(按文档类):删掉它 = 回到缺席时的缺省(域 ⇒ 域缺省;`remote-exec` ⇒ 宿主车道缺省),或写成那一类的合法文档。 */
|
|
42
|
+
function documentWayOut(name) {
|
|
43
|
+
return name === REMOTE_EXEC_DOCUMENT ? "delete it for the host default, or write a valid remoteExec spec" : "delete it for the domain default, or write a valid object";
|
|
44
|
+
}
|
|
20
45
|
/** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、
|
|
21
|
-
* 不是对象 ⇒
|
|
46
|
+
* 不是对象 ⇒ 抛,每种一句(点名文件与形,句尾 {@link documentWayOut})。读盘的入口(`readConfigDFile`、`loadRemoteExec`)与自己读文本的命令行(`migrate`)
|
|
47
|
+
* 都过它 —— 一种坏形一句话,不论是哪份文档。 */
|
|
22
48
|
export function judgeConfigDText(name, text) {
|
|
49
|
+
const file = `config.d/${name}.json`;
|
|
23
50
|
const trimmed = text.trim();
|
|
24
51
|
if (!trimmed)
|
|
25
|
-
throw new Error(
|
|
52
|
+
throw new Error(`${file} is empty/whitespace — ${documentWayOut(name)}`);
|
|
26
53
|
let parsed;
|
|
27
54
|
try {
|
|
28
55
|
parsed = JSON.parse(trimmed);
|
|
29
56
|
}
|
|
30
57
|
catch {
|
|
31
|
-
throw new Error(`invalid JSON in
|
|
58
|
+
throw new Error(`invalid JSON in ${file} — ${documentWayOut(name)}`); // sanitized: never echo the raw file text into the error
|
|
32
59
|
}
|
|
33
60
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
34
61
|
const kind = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
|
|
35
|
-
throw new Error(
|
|
62
|
+
throw new Error(`${file} must be a JSON object (got ${kind}) — ${documentWayOut(name)}`);
|
|
36
63
|
}
|
|
37
64
|
return parsed;
|
|
38
65
|
}
|
package/dist/config-dir-io.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `migrateConfigDirectory`(`./config-dir-migration`)的**真 I/O 绑定**(Node 专用):读目录 / 读文件 / 删文件走 `node:fs/promises`,写盘走本包唯一的原子写器
|
|
3
3
|
* `./atomic-write`(同目录临时文件 → rename;`config-document` 类的模式规则:新建 0o644 经 umask、已有的沿用原模式;目标是符号链接则写到它指向的真文件)。
|
|
4
|
+
* 读目录 / 读文件先过写器同一只链接判法 `resolveSymlinkTarget`:链接读它指向的真文件 / 真目录,**悬空链接**抛与写器同一句(改前读阶段先撞 ENOENT,句子不提链接)。
|
|
4
5
|
* 命令行入口与引擎起服的目录迁移注入同一份,写法与文件模式因此只在原子写器那一处定。
|
|
5
6
|
*/
|
|
6
7
|
import { readdir, readFile, unlink } from "node:fs/promises";
|
|
7
|
-
import { writeFileAtomic } from "./atomic-write.js";
|
|
8
|
+
import { resolveSymlinkTarget, writeFileAtomic } from "./atomic-write.js";
|
|
8
9
|
export const nodeConfigDirectoryIo = {
|
|
9
|
-
readDir: (path) => readdir(path),
|
|
10
|
-
readFile: (path) => readFile(path, "utf8"),
|
|
10
|
+
readDir: async (path) => readdir(resolveSymlinkTarget(path)),
|
|
11
|
+
readFile: async (path) => readFile(resolveSymlinkTarget(path), "utf8"),
|
|
11
12
|
writeFileAtomic: async (path, text) => writeFileAtomic(path, text, "config-document"), // the files this migration writes are config.d domain documents
|
|
12
13
|
removeFile: (path) => unlink(path),
|
|
13
14
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { type ConfigDDocumentName } from "./config-d-file.js";
|
|
1
2
|
import { type MigrationReceipt } from "./one-time-migrations.js";
|
|
2
|
-
import { type DomainName, type RetiredDomainName } from "./types.js";
|
|
3
3
|
/** 注入的 I/O(本模块不碰 `node:fs`)。`writeFileAtomic` 必须原子(写临时文件再改名):半写的域文档比旧文档更糟。 */
|
|
4
4
|
export interface ConfigDirectoryIo {
|
|
5
5
|
readDir(path: string): Promise<string[]>;
|
|
@@ -43,12 +43,14 @@ export type ConfigDirectoryMigration = {
|
|
|
43
43
|
readonly removed: readonly string[];
|
|
44
44
|
};
|
|
45
45
|
export interface MigrateConfigDirectoryOptions {
|
|
46
|
-
/**
|
|
47
|
-
readonly documents: readonly
|
|
46
|
+
/** 覆盖面:调用方读的文档名集(配置域、retired 域,或 `remote-exec`)。拼错的名字 ⇒ 抛 `TypeError`,不让它悄悄变成「什么都没覆盖」。 */
|
|
47
|
+
readonly documents: readonly ConfigDirectoryDocument[];
|
|
48
48
|
readonly io: ConfigDirectoryIo;
|
|
49
49
|
/** 只判、不写:有改动且整域判得过 ⇒ `pending`。默认 `false`。 */
|
|
50
50
|
readonly check?: boolean;
|
|
51
51
|
}
|
|
52
|
+
/** 目录迁移能读写的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`)—— 就是 `config.d` 里能放的文档名。 */
|
|
53
|
+
export type ConfigDirectoryDocument = ConfigDDocumentName;
|
|
52
54
|
/** 目录加文件名(目录尾部的分隔符折掉)。包内私有(不进 `./node`)。 */
|
|
53
55
|
export declare const joinDirPath: (dir: string, name: string) => string;
|
|
54
56
|
type RefusedReceipt = Extract<MigrationReceipt, {
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* **一个 `config.d` 目录的一次性迁移** —— 「读目录 → 迁 → 整域终判 → 原子写 → 删 retired 域文件」这一条语义的唯一实现。命令行(`migrate <config.d 目录> [--check]`,
|
|
3
3
|
* 见 `./cli`)是它的渲染器(退出码 + 报告行);引擎起服时对本地 `config.d` 的迁移调同一只函数,不按同语义自写一份。
|
|
4
4
|
*
|
|
5
|
-
* **覆盖面 =
|
|
5
|
+
* **覆盖面 = 调用方读的文档名集**({@link MigrateConfigDirectoryOptions.documents}:配置域、retired 域,还可以是 `remote-exec` ——
|
|
6
|
+
* `config.d/remote-exec.json`,一次性迁移 SS-M7 的座位走查也读它;它与域文档同一条路:读 → 迁 → 改过的整文档终判 → 同一只写器写回):覆盖面内没有的域文件就是从未设过(一次性迁移的 `coverage: "scope"`);
|
|
6
7
|
* 覆盖面外的文件不读、不判、不写、不删 —— 本调用方没有资格改写它从不消费的数据,它们是坏文件也挡不住覆盖面内的迁移。命令行的覆盖面是全部配置域加 retired 域;
|
|
7
8
|
* 引擎的覆盖面是它本地读腿会读的那几份。**迁移不许越过覆盖面去写**:retired 的 `runtime` 域迁出时要写 `governance` 与 `limits`(`RUNTIME_LIFT_TARGETS`),
|
|
8
9
|
* 覆盖面没把这两个域一并包进来时,目录算不上「一个 scope 的全集」—— 迁移按 `coverage: "partial"` 走(`runtime` 在那一支上以收据 `refused` 拒迁、一字不写),
|
|
@@ -22,10 +23,19 @@
|
|
|
22
23
|
*/
|
|
23
24
|
import { z } from "zod";
|
|
24
25
|
import { formatConfigPath, parseDomain } from "./config-fns.js";
|
|
25
|
-
import { judgeConfigDText } from "./config-d-file.js";
|
|
26
|
+
import { configDDocumentText, judgeConfigDText, parseRemoteExecDocument } from "./config-d-file.js";
|
|
26
27
|
import { migrateStoredDocuments } from "./one-time-migrations.js";
|
|
28
|
+
import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
|
|
27
29
|
import { RUNTIME_LIFT_TARGETS } from "./runtime-lift-targets.js";
|
|
28
30
|
import { isDomainName, isRetiredDomainName } from "./types.js";
|
|
31
|
+
const isConfigDirectoryDocument = (name) => isDomainName(name) || isRetiredDomainName(name) || name === REMOTE_EXEC_DOCUMENT;
|
|
32
|
+
/** 改过的文档的整文档终判:域过读侧那一只 `parseDomain`,`remote-exec` 过 `loadRemoteExec` 那一只 `parseRemoteExecDocument`;retired 域不终判(它只会被删)。 */
|
|
33
|
+
function judgeWholeDocument(name, doc) {
|
|
34
|
+
if (isDomainName(name))
|
|
35
|
+
parseDomain(name, doc);
|
|
36
|
+
else if (name === REMOTE_EXEC_DOCUMENT)
|
|
37
|
+
parseRemoteExecDocument(doc);
|
|
38
|
+
}
|
|
29
39
|
/** 目录加文件名(目录尾部的分隔符折掉)。包内私有(不进 `./node`)。 */
|
|
30
40
|
export const joinDirPath = (dir, name) => `${dir.replace(/[\\/]+$/, "")}/${name}`;
|
|
31
41
|
export function reportReceipt(r) {
|
|
@@ -63,10 +73,10 @@ function verdictOf(input, migrated, receipts) {
|
|
|
63
73
|
return { kind: "noop" };
|
|
64
74
|
const refused = [];
|
|
65
75
|
for (const [name, doc] of Object.entries(migrated)) {
|
|
66
|
-
if (
|
|
76
|
+
if (Object.prototype.hasOwnProperty.call(input, name) && input[name] === doc)
|
|
67
77
|
continue;
|
|
68
78
|
try {
|
|
69
|
-
|
|
79
|
+
judgeWholeDocument(name, doc);
|
|
70
80
|
}
|
|
71
81
|
catch (err) {
|
|
72
82
|
if (err instanceof z.ZodError)
|
|
@@ -89,8 +99,8 @@ const messageOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
|
89
99
|
export async function migrateConfigDirectory(dir, opts) {
|
|
90
100
|
const covered = new Set();
|
|
91
101
|
for (const name of opts.documents) {
|
|
92
|
-
if (!
|
|
93
|
-
throw new TypeError(`migrateConfigDirectory: "${String(name)}" is not a config domain
|
|
102
|
+
if (!isConfigDirectoryDocument(name))
|
|
103
|
+
throw new TypeError(`migrateConfigDirectory: "${String(name)}" is not a config domain, a retired domain or remote-exec — the coverage names the documents the caller reads`);
|
|
94
104
|
covered.add(name);
|
|
95
105
|
}
|
|
96
106
|
const { io } = opts;
|
|
@@ -106,7 +116,7 @@ export async function migrateConfigDirectory(dir, opts) {
|
|
|
106
116
|
if (name.startsWith(".") || !name.endsWith(".json"))
|
|
107
117
|
continue;
|
|
108
118
|
const base = name.slice(0, -".json".length);
|
|
109
|
-
if (!covered.has(base))
|
|
119
|
+
if (!covered.has(base) || !isConfigDirectoryDocument(base))
|
|
110
120
|
continue;
|
|
111
121
|
let text;
|
|
112
122
|
try {
|
|
@@ -116,7 +126,7 @@ export async function migrateConfigDirectory(dir, opts) {
|
|
|
116
126
|
return { kind: "unreadable", reason: `cannot read ${name}: ${messageOf(err)}` };
|
|
117
127
|
}
|
|
118
128
|
try {
|
|
119
|
-
docs[base] = judgeConfigDText(base, text); // the one config.d
|
|
129
|
+
docs[base] = judgeConfigDText(base, text); // the one config.d text judge (FileConfigStore / loadRemoteExec read with it): empty / bad JSON / not an object ⇒ unreadable
|
|
120
130
|
}
|
|
121
131
|
catch (err) {
|
|
122
132
|
return { kind: "unreadable", reason: messageOf(err) };
|
|
@@ -154,7 +164,7 @@ export async function migrateConfigDirectory(dir, opts) {
|
|
|
154
164
|
for (const [name, doc] of Object.entries(m.docs)) {
|
|
155
165
|
if (Object.prototype.hasOwnProperty.call(docs, name) && docs[name] === doc)
|
|
156
166
|
continue;
|
|
157
|
-
await io.writeFileAtomic(joinDirPath(dir, `${name}.json`),
|
|
167
|
+
await io.writeFileAtomic(joinDirPath(dir, `${name}.json`), configDDocumentText(doc)); // the one config.d document text (writeDomainFile / writeRemoteExec write the same)
|
|
158
168
|
written.push(`${name}.json`);
|
|
159
169
|
}
|
|
160
170
|
for (const name of Object.keys(docs)) {
|