@sema-agent/settings-schema 11.1.0 → 11.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +14 -1
  3. package/bin/settings-schema.mjs +5 -9
  4. package/conformance/VERSION +1 -1
  5. package/conformance/cases/c73-projects-extended-registration-reads/expected.json +150 -0
  6. package/conformance/cases/c73-projects-extended-registration-reads/input.json +42 -0
  7. package/conformance/cases/c73-projects-extended-registration-reads/meta.json +6 -0
  8. package/conformance/cases/c74-projects-over-bound-refused/expected.json +89 -0
  9. package/conformance/cases/c74-projects-over-bound-refused/input.json +29 -0
  10. package/conformance/cases/c74-projects-over-bound-refused/meta.json +5 -0
  11. package/conformance/cases/c75-projects-patch-remote-uniqueness/expected.json +89 -0
  12. package/conformance/cases/c75-projects-patch-remote-uniqueness/input.json +35 -0
  13. package/conformance/cases/c75-projects-patch-remote-uniqueness/meta.json +5 -0
  14. package/dist/api/feedback.js +2 -3
  15. package/dist/api/scopes.js +2 -1
  16. package/dist/atomic-write.d.ts +7 -1
  17. package/dist/atomic-write.js +43 -5
  18. package/dist/cli.d.ts +34 -6
  19. package/dist/cli.js +80 -130
  20. package/dist/config-d-file.d.ts +11 -0
  21. package/dist/config-d-file.js +35 -1
  22. package/dist/config-dir-io.d.ts +2 -0
  23. package/dist/config-dir-io.js +14 -0
  24. package/dist/config-dir-migration.d.ts +67 -0
  25. package/dist/config-dir-migration.js +181 -0
  26. package/dist/cross-domain.d.ts +8 -3
  27. package/dist/cross-domain.js +23 -13
  28. package/dist/file-edit.js +7 -5
  29. package/dist/file-store.js +2 -1
  30. package/dist/fleet.js +3 -4
  31. package/dist/hooks.d.ts +14 -1
  32. package/dist/hooks.js +61 -16
  33. package/dist/host-path.d.ts +17 -0
  34. package/dist/host-path.js +22 -0
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.js +1 -0
  37. package/dist/integer.js +2 -1
  38. package/dist/local-load.d.ts +1 -1
  39. package/dist/local-load.js +9 -31
  40. package/dist/migration-receipt.d.ts +3 -2
  41. package/dist/node.d.ts +2 -0
  42. package/dist/node.js +3 -0
  43. package/dist/one-time-migrations.d.ts +5 -3
  44. package/dist/one-time-migrations.js +21 -7
  45. package/dist/previous-web-admin-env.d.ts +15 -0
  46. package/dist/previous-web-admin-env.js +20 -0
  47. package/dist/quoted-issue.d.ts +41 -0
  48. package/dist/quoted-issue.js +35 -0
  49. package/dist/remote-exec.d.ts +3 -0
  50. package/dist/remote-exec.js +12 -7
  51. package/dist/retired-envs.js +13 -2
  52. package/dist/retired-form.d.ts +6 -1
  53. package/dist/retired-form.js +10 -2
  54. package/dist/rfc3339.d.ts +10 -0
  55. package/dist/rfc3339.js +12 -0
  56. package/dist/runtime-lift-targets.d.ts +7 -0
  57. package/dist/runtime-lift-targets.js +7 -0
  58. package/dist/shell-quote.d.ts +1 -0
  59. package/dist/shell-quote.js +14 -0
  60. package/dist/types.d.ts +140 -20
  61. package/dist/types.js +224 -114
  62. package/dist/web-admin-credential.d.ts +2 -2
  63. package/dist/web-admin-credential.js +5 -10
  64. package/dist/web-admin-env.d.ts +2 -1
  65. package/dist/web-admin-env.js +2 -1
  66. package/dist/web-admin-session-url.d.ts +17 -0
  67. package/dist/web-admin-session-url.js +64 -0
  68. package/package.json +1 -1
@@ -0,0 +1,89 @@
1
+ {
2
+ "domains": {
3
+ "outcome": "rejected",
4
+ "stage": "validate"
5
+ },
6
+ "strictWrite": {
7
+ "outcome": "rejected",
8
+ "stage": "validate",
9
+ "why": "拒:example.com/acme/shared 已登记给前一个项目;坐标 projects.<后来者>.gitRemotes[1]。"
10
+ },
11
+ "wire": {
12
+ "outcome": "rejected",
13
+ "stage": "validate",
14
+ "why": "domains 腿是拒绝态,不派生;wire 腿把同一份文档交给同一个 buildEffective,拒绝层相同"
15
+ },
16
+ "localRoot": {
17
+ "outcome": "effective",
18
+ "effective": {
19
+ "collab": {
20
+ "templates": []
21
+ },
22
+ "entitlement": {
23
+ "budgetGroups": [],
24
+ "groupBindings": [],
25
+ "subjects": [],
26
+ "tiers": []
27
+ },
28
+ "execution": {
29
+ "allowedLanes": [
30
+ "host"
31
+ ],
32
+ "required": false
33
+ },
34
+ "governance": {},
35
+ "hosts": {
36
+ "hosts": []
37
+ },
38
+ "limits": {},
39
+ "mcp": {
40
+ "servers": []
41
+ },
42
+ "models": {
43
+ "atMention": {
44
+ "mode": "all"
45
+ },
46
+ "models": [],
47
+ "roles": {},
48
+ "tierGroups": []
49
+ },
50
+ "plugins": {
51
+ "plugins": []
52
+ },
53
+ "projects": {
54
+ "projects": {}
55
+ },
56
+ "prompts": {
57
+ "bindings": []
58
+ },
59
+ "rosters": {
60
+ "rosters": []
61
+ },
62
+ "scenarios": {
63
+ "scenarios": []
64
+ },
65
+ "skills": {
66
+ "skills": []
67
+ },
68
+ "systems": {
69
+ "systems": []
70
+ },
71
+ "workers": {
72
+ "workers": []
73
+ }
74
+ },
75
+ "warnings": [
76
+ {
77
+ "domain": "projects",
78
+ "file": "projects.json",
79
+ "kind": "unread-config-file",
80
+ "why": "non-portable-domain"
81
+ }
82
+ ],
83
+ "metadata": {
84
+ "version": 2617555914511015,
85
+ "updatedAt": "1970-01-01T00:00:00.000Z"
86
+ },
87
+ "why": "projects 不是便携域:config.d 腿不读它(T3),文件报 unread-config-file、域取默认"
88
+ }
89
+ }
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "contributor": "contract",
3
+ "claim": "PATCH 合并出来的形:后一个项目(被 PATCH 的那个,排在文档末尾)带上了前一个项目已登记的 remote。remote 全局唯一由同一只 superRefine 判,整域拒、坐标落在后来者的那一条 remote 上。打的是「PATCH 只判请求体、不对整份登记簿再判一次 remote 唯一」的写入面(两个项目同领一条 remote = 找回阶梯二义)。",
4
+ "firstRedOn": []
5
+ }
@@ -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(),
@@ -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), { message: `"${GLOBAL_SCOPE}" is reserved` }),
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();
@@ -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
- /** 原子写 `text`(UTF-8)到 `target`;模式按 `kind` 在 {@link 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;
19
+ /** 原子写 `text`(UTF-8)到 `target`(符号链接则写到它指向的真文件,见文件头);模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
14
20
  export declare function writeFileAtomic(target: string, text: string, kind: AtomicFileClass): void;
@@ -11,23 +11,61 @@
11
11
  * **已有的文件沿用它原来的模式**(运维 chmod 过的文件不被一次写回覆盖)。
12
12
  * · `scheduler-store` —— 调度店耐久记录(可能带 principal / session 标识):新建 = 0o600,且**每次写都回到 0o600**(已有的更宽的文件被收回)。
13
13
  * 新建的模式照常经进程 umask 遮一道(`writeFile` 的 `mode` 语义):比表更严的 umask 只会更严,不会被这里放宽;沿用的模式原样恢复(不再过 umask)。
14
+ *
15
+ * ## 目标是符号链接
16
+ * 域文件可以是符号链接 —— 运维用链接把 `config.d` 指到别处管理的部署,那是有意的布局,写器不替他改布局。规则一条:**先把目标解析到真文件(`realpath`),
17
+ * 临时文件建在真文件所在目录、rename 到真文件**:链接(及链上每一环)原样保留,真文件的内容更新,模式规则作用在真文件上。临时文件与目标同目录 ⇒ rename 不会跨文件系统;
18
+ * 真文件所在目录写不进、或 rename 因别的原因失败 ⇒ 响亮抛,**绝不**回退成「在链接位置写一个普通文件」(那是静默换形)。路径本身是悬空链接 ⇒ 响亮抛
19
+ * (不在链接位置建普通文件,也不替链接建它指向的文件);成环的链接由 `realpath` 抛 `ELOOP`。目标不存在 ⇒ 新建(没有链接可言;经符号链接的目录写新文件时,文件落在真目录里)。
20
+ * 「链接 ⇒ 真文件、悬空 ⇒ 响亮抛」这一只判法是 {@link resolveSymlinkTarget}:目录迁移的读口(`./config-dir-io`)读目录 / 读文件前也问它,
21
+ * 悬空链接在读阶段与写阶段是**同一句**({@link danglingSymlinkSentence})。
14
22
  */
15
23
  import { randomBytes } from "node:crypto";
16
- import { chmodSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
24
+ import { chmodSync, lstatSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
17
25
  import path from "node:path";
18
26
  /** 按文档类的模式表(闭集)。 */
19
27
  export const ATOMIC_FILE_CLASSES = {
20
28
  "config-document": { newMode: 0o644, existing: "keep" },
21
29
  "scheduler-store": { newMode: 0o600, existing: "reset" },
22
30
  };
23
- /** 原子写 `text`(UTF-8)到 `target`;模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
31
+ /** 悬空符号链接的**唯一一句**(写器与目录迁移的读口抛同一句;不带 errno 码 —— 带了 `ENOENT` 的错会被「ENOENT = 缺席」的读者读成文件不在,
32
+ * 悬空链接恰恰不是缺席)。 */
33
+ export function danglingSymlinkSentence(link, pointsAt) {
34
+ 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`;
35
+ }
36
+ /** 读写的真目标 —— **一只判法**(写器与目录迁移的读口共用):`target` 是符号链接 ⇒ 解析到它指向的真文件(悬空 ⇒ 抛 {@link danglingSymlinkSentence});
37
+ * 不是链接或不存在 ⇒ 原样(不存在的由调用方的读 / 写照常报)。 */
38
+ export function resolveSymlinkTarget(target) {
39
+ let link;
40
+ try {
41
+ link = lstatSync(target).isSymbolicLink();
42
+ }
43
+ catch (e) {
44
+ if (e.code === "ENOENT")
45
+ return target; // 不存在:新建
46
+ throw e;
47
+ }
48
+ if (!link)
49
+ return target;
50
+ try {
51
+ return realpathSync(target);
52
+ }
53
+ catch (e) {
54
+ if (e.code === "ENOENT") {
55
+ throw new Error(danglingSymlinkSentence(target, readlinkSync(target)), { cause: e });
56
+ }
57
+ throw e;
58
+ }
59
+ }
60
+ /** 原子写 `text`(UTF-8)到 `target`(符号链接则写到它指向的真文件,见文件头);模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
24
61
  export function writeFileAtomic(target, text, kind) {
62
+ const real = resolveSymlinkTarget(target);
25
63
  const spec = ATOMIC_FILE_CLASSES[kind];
26
64
  let kept; // the existing file's mode, when this class keeps it
27
65
  switch (spec.existing) {
28
66
  case "keep":
29
67
  try {
30
- kept = statSync(target).mode & 0o777;
68
+ kept = statSync(real).mode & 0o777;
31
69
  }
32
70
  catch (e) {
33
71
  if (e.code !== "ENOENT")
@@ -41,12 +79,12 @@ export function writeFileAtomic(target, text, kind) {
41
79
  throw new Error(`writeFileAtomic: unhandled existing-file rule ${String(unhandled)}`);
42
80
  }
43
81
  }
44
- const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
82
+ const tmp = path.join(path.dirname(real), `.${path.basename(real)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
45
83
  try {
46
84
  writeFileSync(tmp, text, { encoding: "utf8", mode: kept ?? spec.newMode });
47
85
  if (kept !== undefined)
48
86
  chmodSync(tmp, kept); // a kept mode is restored exactly (the creation mode above went through the umask)
49
- renameSync(tmp, target);
87
+ renameSync(tmp, real);
50
88
  }
51
89
  catch (e) {
52
90
  try {
package/dist/cli.d.ts CHANGED
@@ -1,10 +1,38 @@
1
- /** 入口注入的 I/O(核心不碰 node:fs)。`writeFileAtomic` 必须原子(写临时文件再改名):半写的域文档比旧文档更糟;入口注入的就是 `./atomic-write` 的那一只。 */
2
- export interface SettingsSchemaCliIo {
3
- readDir(path: string): Promise<string[]>;
4
- readFile(path: string): Promise<string>;
5
- writeFileAtomic(path: string, text: string): Promise<void>;
6
- removeFile(path: string): Promise<void>;
1
+ /**
2
+ * `settings-schema` 命令行的核心(11.0.0)—— 子命令只有一个:`migrate <config.d 目录> [--check]`,即一次性迁移
3
+ * `migrateStoredDocuments`(`./one-time-migrations`)的目录形(本地 config.d / 离线文件用;web-admin 在它起服时直接调同一只函数)。
4
+ * 目录形的语义(读目录 → 迁 → 整域终判 → 原子写 → 删 retired 域文件)只有一处实现:`migrateConfigDirectory`(`./config-dir-migration`,`./node` 导出,引擎起服调同一只);
5
+ * 本模块是它的**渲染器**:把它的结果闭集渲染成退出码、报告行与 stderr 句子。命令行的覆盖面是全部配置域、retired 域与 `remote-exec.json`。
6
+ * 目录被当作**一个 scope 的全集**(`coverage: "scope"`):目录里没有的域文件就是从未设过。
7
+ *
8
+ * 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(真绑定是 `./config-dir-io` 的 `nodeConfigDirectoryIo`,写盘用的是 `./atomic-write` 那一只原子写器 —— 写法与文件模式在那里一处定),所以测试可以直接驱动它。
9
+ * 不进主桶 —— 它是 bin 的实现,不是契约面。
10
+ *
11
+ * 读哪些文件:目录里 `<名>.json`,名是配置域或 retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁)—— 以及 `remote-exec.json`
12
+ * (一次性迁移 SS-M7 的座位走查也走它的两个端点);别的文件(隐藏文件、不认识的名、非 `.json`)不读不动。
13
+ *
14
+ * 零域文档分两形:**有条目却没有域文档**(任何非 `.` 开头的条目 —— 子目录、说明文件、不认识的名)⇒ 退出 2;**目录真空**(零条目,或只有以 `.` 开头的条目)⇒ 照常走判定,
15
+ * 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
16
+ *
17
+ * 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
18
+ * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与错误(errno 码在 Node 的错误句里),不打栈 ——
19
+ * rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
20
+ * (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
21
+ * 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
22
+ *
23
+ * ## 报告形(让「没写」在机读上不可能被读成「写了」)
24
+ * 退出码 0 / 1 / 3 时 stdout 恰两行:第 1 行 = **唯一结论行** `outcome=<refused|applied|noop|pending> written=<true|false>`;第 2 行 = JSON 报告
25
+ * (`event: "stored_documents_migrated"`,逐条收据只点名坐标与处置、不带值),同带 `outcome` / `written`。`written` 由 `outcome` 派生
26
+ * (只有 `applied` 写盘)。**键名带时态**:`written=true` 才有 `applied`;没写(refused / noop / pending)时是 `wouldApply`。
27
+ * 结论行在**写盘成功之后**才出(写盘抛错 ⇒ 没有 `outcome=applied`)。写盘次序:先写改过 / 新建的文件,最后删被移除的 retired 域文件 ——
28
+ * 中途断掉再跑一次,迁移照样恒等收尾(已搬过去的值在新位上,旧文件里同名键按「新位赢」丢弃)。
29
+ */
30
+ import { type ConfigDirectoryIo } from "./config-dir-migration.js";
31
+ /** 入口注入的 I/O(核心不碰 node:fs):目录迁移的四个口加 stdout / stderr。入口注入的写盘是 `./atomic-write` 的那一只(经 `./config-dir-io`)。 */
32
+ export interface SettingsSchemaCliIo extends ConfigDirectoryIo {
7
33
  stdout(text: string): void;
8
34
  stderr(text: string): void;
9
35
  }
36
+ /** 命令行的覆盖面:全部配置域、retired 域(便携与否不论 —— 离线的 `workers.json` 一样迁)与 `remote-exec.json`。 */
37
+ export declare const COVERED_DOCUMENTS: ("models" | "rosters" | "scenarios" | "skills" | "execution" | "plugins" | "systems" | "limits" | "runtime" | "workers" | "hosts" | "projects" | "mcp" | "collab" | "governance" | "entitlement" | "prompts" | "remote-exec")[];
10
38
  export declare function runSettingsSchemaCli(argv: readonly string[], io: SettingsSchemaCliIo): Promise<number>;
package/dist/cli.js CHANGED
@@ -1,19 +1,22 @@
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 域与 `remote-exec.json`。
4
6
  * 目录被当作**一个 scope 的全集**(`coverage: "scope"`):目录里没有的域文件就是从未设过。
5
7
  *
6
- * 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(写盘用的是 `./atomic-write` 那一只原子写器 —— 写法与文件模式在那里一处定),所以测试可以直接驱动它。
8
+ * 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(真绑定是 `./config-dir-io` 的 `nodeConfigDirectoryIo`,写盘用的是 `./atomic-write` 那一只原子写器 —— 写法与文件模式在那里一处定),所以测试可以直接驱动它。
7
9
  * 不进主桶 —— 它是 bin 的实现,不是契约面。
8
10
  *
9
- * 读哪些文件:目录里 `<名>.json`,名是配置域或retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁);别的文件
10
- * (`remote-exec.json`、隐藏文件、不认识的名)不读不动。
11
+ * 读哪些文件:目录里 `<名>.json`,名是配置域或 retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁)—— 以及 `remote-exec.json`
12
+ * (一次性迁移 SS-M7 的座位走查也走它的两个端点);别的文件(隐藏文件、不认识的名、非 `.json`)不读不动。
11
13
  *
12
14
  * 零域文档分两形:**有条目却没有域文档**(任何非 `.` 开头的条目 —— 子目录、说明文件、不认识的名)⇒ 退出 2;**目录真空**(零条目,或只有以 `.` 开头的条目)⇒ 照常走判定,
13
15
  * 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
14
16
  *
15
- * 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 {@link verdictOf}),**一字不写**;
16
- * 2 = 用法 / 读目录 / 读文件错,或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
17
+ * 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
18
+ * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与错误(errno 码在 Node 的错误句里),不打栈 ——
19
+ * rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
17
20
  * (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
18
21
  * 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
19
22
  *
@@ -21,22 +24,23 @@
21
24
  * 退出码 0 / 1 / 3 时 stdout 恰两行:第 1 行 = **唯一结论行** `outcome=<refused|applied|noop|pending> written=<true|false>`;第 2 行 = JSON 报告
22
25
  * (`event: "stored_documents_migrated"`,逐条收据只点名坐标与处置、不带值),同带 `outcome` / `written`。`written` 由 `outcome` 派生
23
26
  * (只有 `applied` 写盘)。**键名带时态**:`written=true` 才有 `applied`;没写(refused / noop / pending)时是 `wouldApply`。
24
- * 结论行在**写盘成功之后**才出(写盘抛错 ⇒ 没有 `outcome=applied`)。写盘次序:先写改过 / 新建的文件,最后删被移除的retired 域文件 ——
27
+ * 结论行在**写盘成功之后**才出(写盘抛错 ⇒ 没有 `outcome=applied`)。写盘次序:先写改过 / 新建的文件,最后删被移除的 retired 域文件 ——
25
28
  * 中途断掉再跑一次,迁移照样恒等收尾(已搬过去的值在新位上,旧文件里同名键按「新位赢」丢弃)。
26
29
  */
27
- import { z } from "zod";
28
- import { formatConfigPath, parseDomain } from "./config-fns.js";
29
- import { judgeConfigDText } from "./config-d-file.js";
30
- import { migrateStoredDocuments } from "./one-time-migrations.js";
31
- import { MIGRATE_COMMAND } from "./retired-form.js";
32
- import { isDomainName, isRetiredDomainName } from "./types.js";
30
+ import { reportReceipt, joinDirPath, migrateConfigDirectory } from "./config-dir-migration.js";
31
+ import { MIGRATE_COMMAND_TEMPLATE, migrateCommandFor } from "./retired-form.js";
32
+ import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
33
+ import { DOMAINS, RETIRED_DOMAINS } from "./types.js";
34
+ /** 命令行的覆盖面:全部配置域、retired 域(便携与否不论 —— 离线的 `workers.json` 一样迁)与 `remote-exec.json`。 */
35
+ export const COVERED_DOCUMENTS = [...DOMAINS, ...Object.keys(RETIRED_DOMAINS), REMOTE_EXEC_DOCUMENT];
33
36
  const USAGE = [
34
- `usage: ${MIGRATE_COMMAND} <config.d directory> [--check]`,
35
- " Runs the one-time migrations (@sema-agent/settings-schema/one-time-migrations) over every <domain>.json in the directory, read as one",
37
+ `usage: ${MIGRATE_COMMAND_TEMPLATE} [--check]`,
38
+ " 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",
36
39
  " scope (a missing domain file = never set): writes the rewritten files in place and removes retired-domain files (runtime.json) only when",
37
40
  " nothing is refused and every document it would write reads as a whole domain; --check runs the same verdict and only reports (exit 3",
38
41
  " if something would change). The argument is the config.d directory itself; a directory that has entries but no <domain>.json among them is",
39
- " refused (exit 2); an empty directory has nothing to migrate (exit 0).",
42
+ " 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",
43
+ " one line on stderr naming the path and the error.",
40
44
  " stdout (exit 0 / 1 / 3): line 1 = outcome=<refused|applied|noop|pending> written=<true|false>, line 2 = the JSON report",
41
45
  " (applied only when written=true; otherwise wouldApply).",
42
46
  ].join("\n");
@@ -55,61 +59,6 @@ function writtenOf(outcome) {
55
59
  }
56
60
  }
57
61
  }
58
- /** 报告里的一张收据(坐标出门形;不带值)。 */
59
- function reportReceipt(r) {
60
- const base = { migration: r.migration, document: r.document, path: formatConfigPath(r.path), appliedAs: r.appliedAs };
61
- switch (r.appliedAs) {
62
- case "moved":
63
- return { ...base, to: `${r.to.document}:${formatConfigPath(r.to.path)}` };
64
- case "refused":
65
- return { ...base, reason: r.reason };
66
- case "rewritten":
67
- case "dropped-shadowed":
68
- case "dropped-inert":
69
- return base;
70
- default: {
71
- const unhandled = r;
72
- throw new Error(`settings-schema: unhandled receipt ${JSON.stringify(unhandled)}`);
73
- }
74
- }
75
- }
76
- /** 整域终判的一条拒(issue 来自 `parseDomain`,已过本包唯一的脱敏器 —— 坐标段与句子都是出门形)。 */
77
- function wholeDomainRefusal(document, path, reason) {
78
- return { migration: null, document, path: formatConfigPath(path), appliedAs: "refused", reason };
79
- }
80
- /**
81
- * 迁移结果 → 结论。顺序:迁移步拒 → 无改动 ⇒ noop → **整域终判**(要写回的每个域文档 —— 改过的、新建的 —— 过读侧那一只
82
- * `parseDomain`:名字 / 路由唯一这类跨行规则只有整域判得出)→ 有改动。整域终判在 `--check` 之前:否则同一个目录 `--check` 报 pending、
83
- * 真跑写出一份读侧必拒的文档。没改的文件不归这里判(迁移不碰它,它读不读得进是读者的事)。
84
- */
85
- function verdictOf(input, migrated, receipts) {
86
- const refusedReceipts = receipts.filter((r) => r.appliedAs === "refused");
87
- if (refusedReceipts.length > 0) {
88
- return { kind: "refused", refused: refusedReceipts.map(reportReceipt), why: `${refusedReceipts.length} coordinate(s) cannot be migrated (see "refused") — fix them by hand and re-run` };
89
- }
90
- if (receipts.length === 0)
91
- return { kind: "noop" };
92
- const refused = [];
93
- for (const [name, doc] of Object.entries(migrated)) {
94
- if (!isDomainName(name) || (Object.prototype.hasOwnProperty.call(input, name) && input[name] === doc))
95
- continue;
96
- try {
97
- parseDomain(name, doc);
98
- }
99
- catch (err) {
100
- if (err instanceof z.ZodError)
101
- for (const i of err.issues)
102
- refused.push(wholeDomainRefusal(name, i.path, i.message));
103
- else
104
- refused.push(wholeDomainRefusal(name, [], err instanceof Error ? err.message : String(err)));
105
- }
106
- }
107
- if (refused.length > 0) {
108
- const documents = [...new Set(refused.map((r) => String(r.document)))];
109
- 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` };
110
- }
111
- return { kind: "changes" };
112
- }
113
62
  /** 一次发出报告:结论行 + JSON 行(一次 `stdout` 调用)。 */
114
63
  function reportOf(outcome, dir, applied, refused) {
115
64
  const written = writtenOf(outcome);
@@ -118,18 +67,46 @@ function reportOf(outcome, dir, applied, refused) {
118
67
  outcome,
119
68
  written,
120
69
  dir,
121
- [written ? "applied" : "wouldApply"]: applied.map(reportReceipt),
70
+ [written ? "applied" : "wouldApply"]: applied.map((r) => reportReceipt(r)),
122
71
  refused,
123
72
  });
124
73
  return `outcome=${outcome} written=${written}\n${json}\n`;
125
74
  }
126
- const join = (dir, name) => `${dir.replace(/[\\/]+$/, "")}/${name}`;
127
- /** 「有条目却没有域文档」的拒句:点名这个目录、说要的是 `config.d` 目录本身;`<dir>/config.d` 在场(探一次)就直接点它 —— 最常见的错是填了它的上一层。 */
75
+ /** 写回阶段(写盘 / 删文件)的一次 I/O 失败 —— 环境错一臂(rc 2)。只由 {@link withEnvironmentArm} 包的两个写口铸;别的抛错(包内守卫、误用)不是它,照旧抛。 */
76
+ class EnvironmentFailure extends Error {
77
+ }
78
+ const oneLine = (text) => text.replace(/\s*\n\s*/g, " ");
79
+ const messageOf = (err) => oneLine(err instanceof Error ? err.message : String(err));
80
+ /** 把注入的写口包一层:写盘 / 删文件抛 ⇒ {@link EnvironmentFailure},句子点名**要写的那份文件**(原子写器的错误句里是临时文件的路径)与原错误句。
81
+ * 读口不包:读目录 / 读文件错由 `migrateConfigDirectory` 自己收成 `unreadable`(同为 rc 2)。 */
82
+ function withEnvironmentArm(io) {
83
+ return {
84
+ readDir: (path) => io.readDir(path),
85
+ readFile: (path) => io.readFile(path),
86
+ writeFileAtomic: async (path, text) => {
87
+ try {
88
+ await io.writeFileAtomic(path, text);
89
+ }
90
+ catch (err) {
91
+ throw new EnvironmentFailure(`cannot write ${path}: ${messageOf(err)}`, { cause: err });
92
+ }
93
+ },
94
+ removeFile: async (path) => {
95
+ try {
96
+ await io.removeFile(path);
97
+ }
98
+ catch (err) {
99
+ throw new EnvironmentFailure(`cannot remove ${path}: ${messageOf(err)}`, { cause: err });
100
+ }
101
+ },
102
+ };
103
+ }
104
+ /** 「有条目却没有域文档」的拒句:点名这个目录、说要的是 `config.d` 目录本身;`<dir>/config.d` 在场(探一次)就直接点它 —— 最常见的错是填了它的上一层。指路的命令里路径过 shell 引用,可直接粘贴。 */
128
105
  async function noDomainDocumentsSentence(dir, io) {
129
- const nested = join(dir, "config.d");
106
+ const nested = joinDirPath(dir, "config.d");
130
107
  const nestedExists = await io.readDir(nested).then(() => true, () => false);
131
108
  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`;
132
- return nestedExists ? `${base}; ${nested} exists — run: ${MIGRATE_COMMAND} ${nested}` : base;
109
+ return nestedExists ? `${base}; ${nested} exists — run: ${migrateCommandFor(nested)}` : base;
133
110
  }
134
111
  export async function runSettingsSchemaCli(argv, io) {
135
112
  const [command, ...rest] = argv;
@@ -140,73 +117,46 @@ export async function runSettingsSchemaCli(argv, io) {
140
117
  io.stderr(`${USAGE}\n`);
141
118
  return 2;
142
119
  }
143
- let names;
120
+ let m;
144
121
  try {
145
- names = await io.readDir(dir);
122
+ m = await migrateConfigDirectory(dir, { documents: COVERED_DOCUMENTS, io: withEnvironmentArm(io), check });
146
123
  }
147
124
  catch (err) {
148
- io.stderr(`settings-schema: cannot read directory ${dir}: ${err instanceof Error ? err.message : String(err)}\n`);
125
+ if (!(err instanceof EnvironmentFailure))
126
+ throw err;
127
+ // 写回阶段断在这里:之前写完的文档已在新形上,再跑一次幂等收尾(迁移只认形)。stdout 不出结论行 —— 没有 `outcome=applied`。
128
+ io.stderr(`settings-schema: ${err.message} — files written before it keep their migrated form; fix the cause and re-run\n`);
149
129
  return 2;
150
130
  }
151
- const docs = {};
152
- for (const name of [...names].sort()) {
153
- if (name.startsWith(".") || !name.endsWith(".json"))
154
- continue;
155
- const base = name.slice(0, -".json".length);
156
- if (!isDomainName(base) && !isRetiredDomainName(base))
157
- continue;
158
- let text;
159
- try {
160
- text = await io.readFile(join(dir, name));
161
- }
162
- catch (err) {
163
- io.stderr(`settings-schema: cannot read ${name}: ${err instanceof Error ? err.message : String(err)}\n`);
164
- return 2;
165
- }
166
- try {
167
- docs[base] = judgeConfigDText(base, text); // the one config.d file judge: empty / whitespace / bad JSON / not an object ⇒ same sentence as FileConfigStore
168
- }
169
- catch (err) {
170
- io.stderr(`settings-schema: ${err instanceof Error ? err.message : String(err)}\n`);
131
+ switch (m.kind) {
132
+ case "unreadable":
133
+ io.stderr(`settings-schema: ${oneLine(m.reason)}\n`);
171
134
  return 2;
172
- }
173
- }
174
- if (Object.keys(docs).length === 0 && names.some((name) => !name.startsWith("."))) {
175
- // 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
176
- // directory the command never looked into. A directory with nothing visible in it (empty, or only dot entries) is a legitimate scope.
177
- io.stderr(`${await noDomainDocumentsSentence(dir, io)}\n`);
178
- return 2;
179
- }
180
- const m = migrateStoredDocuments(docs, { coverage: "scope" });
181
- const applied = m.receipts.filter((r) => r.appliedAs !== "refused");
182
- const verdict = verdictOf(docs, m.docs, m.receipts);
183
- switch (verdict.kind) {
135
+ case "no-documents":
136
+ if (m.entries.some((name) => !name.startsWith("."))) {
137
+ // 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
138
+ // directory the command never looked into. A directory with nothing visible in it (empty, or only dot entries) is a legitimate scope.
139
+ io.stderr(`${await noDomainDocumentsSentence(dir, io)}\n`);
140
+ return 2;
141
+ }
142
+ io.stdout(reportOf("noop", dir, [], []));
143
+ return 0;
184
144
  case "refused":
185
- io.stdout(reportOf("refused", dir, applied, verdict.refused));
186
- io.stderr(`settings-schema: ${verdict.why}; nothing was written\n`);
145
+ io.stdout(reportOf("refused", dir, m.applied, m.refused));
146
+ io.stderr(`settings-schema: ${m.why}; nothing was written\n`);
187
147
  return 1;
188
148
  case "noop":
189
149
  io.stdout(reportOf("noop", dir, [], []));
190
150
  return 0;
191
- case "changes":
192
- break;
151
+ case "pending":
152
+ io.stdout(reportOf("pending", dir, m.applied, []));
153
+ return 3;
154
+ case "applied":
155
+ io.stdout(reportOf("applied", dir, m.applied, [])); // 写盘成功之后才报 applied
156
+ return 0;
193
157
  default: {
194
- const unhandled = verdict;
195
- throw new Error(`settings-schema: unhandled verdict ${JSON.stringify(unhandled)}`);
158
+ const unhandled = m;
159
+ throw new Error(`settings-schema: unhandled migration result ${JSON.stringify(unhandled)}`);
196
160
  }
197
161
  }
198
- if (check) {
199
- io.stdout(reportOf("pending", dir, applied, []));
200
- return 3;
201
- }
202
- for (const [name, doc] of Object.entries(m.docs)) {
203
- if (Object.prototype.hasOwnProperty.call(docs, name) && docs[name] === doc)
204
- continue;
205
- await io.writeFileAtomic(join(dir, `${name}.json`), `${JSON.stringify(doc, null, 2)}\n`);
206
- }
207
- for (const name of Object.keys(docs))
208
- if (!Object.prototype.hasOwnProperty.call(m.docs, name))
209
- await io.removeFile(join(dir, `${name}.json`));
210
- io.stdout(reportOf("applied", dir, applied, [])); // 写盘成功之后才报 applied
211
- return 0;
212
162
  }
@@ -1,4 +1,15 @@
1
+ import { 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
+ /** `config.d/remote-exec.json` 在场时的**文本** → 它的 JSON 值(纯判,零 I/O)—— `loadRemoteExec` 与目录迁移读同一只、同一句(从 `loadRemoteExec` 里搬出)。
7
+ * 空 / 纯空白(半写、截断 —— 不是「用默认」:默认车道要的是文件**缺席**)、坏 JSON ⇒ 抛,句子点名 `filePath`、不回显原文。值的形由
8
+ * {@link parseRemoteExecDocument} 判(不是对象也归它拒)。 */
9
+ export declare function judgeRemoteExecText(filePath: string, text: string): unknown;
10
+ /** 判一份 remote-exec 文档的值(`RemoteExecSpec`),错误**脱敏**(zod 会回显出错的值 —— 粘错位置的密钥不许随抛出的错走);反射访问抛(恶意 getter /
11
+ * Proxy)⇒ 定句。`loadRemoteExec` / `writeRemoteExec` 与目录迁移的整文档终判调同一只。 */
12
+ export declare function parseRemoteExecDocument(raw: unknown): RemoteExecSpec;
2
13
  /** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是配置域也不是 retired 域 ⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
3
14
  export declare function configDPath(root: string, name: string): string;
4
15
  /** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、