@sema-agent/settings-schema 10.1.1 → 11.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 +414 -0
- package/README.md +141 -65
- package/bin/settings-schema.mjs +13 -6
- package/conformance/VERSION +1 -1
- package/conformance/baseline.json +3 -1
- package/conformance/cases/c01-entry-salvage-then-typo/expected.json +3 -1
- package/conformance/cases/c02-hosts-clamp-stacked/expected.json +12 -5
- package/conformance/cases/c02-hosts-clamp-stacked/meta.json +1 -1
- package/conformance/cases/c03-literal-dotted-top-key/expected.json +22 -2
- package/conformance/cases/c04-all-bad-collection-not-salvaged/expected.json +3 -1
- package/conformance/cases/c06-legacy-governance-lift/expected.json +11 -101
- package/conformance/cases/c06-legacy-governance-lift/meta.json +1 -1
- package/conformance/cases/c07-hooks-is-not-a-config-domain/expected.json +3 -1
- package/conformance/cases/c08-limits-unknown-carried-64/expected.json +203 -2
- package/conformance/cases/c09-limits-unknown-carried-65/expected.json +204 -2
- package/conformance/cases/c10-record-key-secret-redacted-twice/expected.json +3 -1
- package/conformance/cases/c11-unicode-unknown-keys/expected.json +20 -2
- package/conformance/cases/c12-prototype-name-top-keys/expected.json +3 -1
- package/conformance/cases/c13-models-field-null/expected.json +3 -1
- package/conformance/cases/c14-models-field-empty-array/expected.json +6 -1
- package/conformance/cases/c15-models-field-number/expected.json +3 -1
- package/conformance/cases/c16-deep-nesting-205/expected.json +14 -2
- package/conformance/cases/c17-empty-domain-file/expected.json +3 -1
- package/conformance/cases/c18-wire-envelope-not-an-object/expected.json +3 -1
- package/conformance/cases/c19-wire-envelope-no-version/expected.json +3 -1
- package/conformance/cases/c20-governance-absent-lifts/expected.json +11 -86
- package/conformance/cases/c20-governance-absent-lifts/meta.json +1 -1
- package/conformance/cases/c21-governance-empty-object-wins/expected.json +6 -5
- package/conformance/cases/c21-governance-empty-object-wins/input.json +1 -4
- package/conformance/cases/c21-governance-empty-object-wins/meta.json +1 -1
- package/conformance/cases/c23-mixed-validity-models/expected.json +3 -1
- package/conformance/cases/c24-valid-entries-with-unknown-keys/expected.json +22 -2
- package/conformance/cases/c25-published-roundtrip-zero-warnings/expected.json +6 -1
- package/conformance/cases/c26-hosts-array-1024/expected.json +3 -1
- package/conformance/cases/c27-hosts-array-1025/expected.json +6 -7175
- package/conformance/cases/c27-hosts-array-1025/meta.json +1 -1
- package/conformance/cases/c28-large-document-over-1mb/expected.json +14 -2
- package/conformance/cases/c29-salvage-then-domain-refine-fails/expected.json +3 -1
- package/conformance/cases/c30-version-triple-a-first/expected.json +6 -1
- package/conformance/cases/c31-version-triple-b-changed/expected.json +6 -1
- package/conformance/cases/c32-version-triple-a-restored/expected.json +6 -1
- package/conformance/cases/c33-limits-nested-carried-key/expected.json +18 -2
- package/conformance/cases/c34-skills-content-vs-manifest/expected.json +19 -3
- package/conformance/cases/c35-portable-domain-file-is-null/expected.json +3 -1
- package/conformance/cases/c36-skills-entry-without-content/expected.json +6 -2
- package/conformance/cases/c37-skills-field-null/expected.json +6 -2
- package/conformance/cases/c38-remote-exec-is-a-non-domain-file/expected.json +3 -1
- package/conformance/cases/c39-hidden-file-in-config-d/expected.json +3 -1
- package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +11 -4
- package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +9 -3
- package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +39 -2
- package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +4 -1
- package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +3 -1
- package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +19 -10
- package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/meta.json +1 -1
- package/conformance/cases/{c46-s811-host-name-url-userinfo-characterization → c46-s811-host-name-url-userinfo-withheld}/expected.json +12 -5
- package/conformance/cases/c46-s811-host-name-url-userinfo-withheld/meta.json +9 -0
- package/conformance/cases/{c47-s811-roles-key-url-userinfo-characterization → c47-s811-roles-key-url-userinfo-withheld}/expected.json +23 -3
- package/conformance/cases/{c47-s811-roles-key-url-userinfo-characterization → c47-s811-roles-key-url-userinfo-withheld}/input.json +6 -0
- package/conformance/cases/c47-s811-roles-key-url-userinfo-withheld/meta.json +10 -0
- package/conformance/cases/c48-s935-model-headers-transported/expected.json +6 -1
- package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/expected.json +3 -1
- package/conformance/cases/c50-s784-lane-host-legal/expected.json +3 -1
- package/conformance/cases/c51-s784-lane-e2b-legal/expected.json +3 -1
- package/conformance/cases/c52-s784-lane-k8s-legal/expected.json +3 -1
- package/conformance/cases/c53-s784-lane-ssh-legal/expected.json +3 -1
- package/conformance/cases/c54-s784-lane-adb-legal/expected.json +3 -1
- package/conformance/cases/c55-s784-lane-local-docker-legal/expected.json +3 -1
- package/conformance/cases/c56-s784-lane-device-legal/expected.json +3 -1
- package/conformance/cases/c57-s784-g1-enabled-no-lane/expected.json +3 -1
- package/conformance/cases/c58-s784-g2-multitenant-host/expected.json +3 -1
- package/conformance/cases/c59-s784-g3-k8s-missing-image/expected.json +3 -1
- package/conformance/cases/c60-s784-g3-e2b-missing-key/expected.json +3 -1
- package/conformance/cases/c61-s784-g3-other-lane-key/expected.json +3 -1
- package/conformance/cases/c62-s784-g7a-session-tidb/expected.json +3 -1
- package/conformance/cases/c63-s784-host-two-replicas/expected.json +3 -1
- package/conformance/cases/c64-s784-multitenant-local-store/expected.json +3 -1
- package/conformance/cases/c65-s784-pg-without-db/expected.json +3 -1
- package/conformance/cases/c66-s784-memory-backend-mismatch/expected.json +3 -1
- package/conformance/cases/c67-s784-retired-field-loud/expected.json +3 -1
- package/conformance/cases/c67-s784-retired-field-loud/meta.json +1 -1
- package/conformance/cases/c68-s784-device-local-store/expected.json +3 -1
- package/conformance/cases/c69-deep-encoded-token-past-every-reading/expected.json +3 -1
- package/conformance/cases/c70-rewritten-token-in-front-of-encoded-pem-header/expected.json +3 -1
- package/conformance/cases/c71-one-scanner-write-reads-display-forms/expected.json +119 -0
- package/conformance/cases/c71-one-scanner-write-reads-display-forms/input.json +24 -0
- package/conformance/cases/c71-one-scanner-write-reads-display-forms/meta.json +10 -0
- package/conformance/cases/c72-dangling-reference-write-seat-refuses/expected.json +111 -0
- package/conformance/cases/c72-dangling-reference-write-seat-refuses/input.json +26 -0
- package/conformance/cases/c72-dangling-reference-write-seat-refuses/meta.json +6 -0
- package/conformance/cases/cc01-mcp-toolfaces-non-object/expected.json +3 -1
- package/conformance/cases/cc02-mcp-source-key-is-transported/expected.json +3 -1
- package/conformance/cases/cc03-model-compat-unknown-subkey/expected.json +6 -1
- package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +11 -1
- package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +11 -1
- package/conformance/cases/cli03-credential-pasted-into-unknown-key/expected.json +16 -2
- package/conformance/cases/cli04-env-value-with-newline/expected.json +3 -1
- package/conformance/cases/cli05-long-and-prefix-colliding-names/expected.json +6 -1
- package/conformance/cases/t01-absent-domain-key-not-null/expected.json +3 -1
- package/conformance/cases/t03-prototype-name-key-not-swallowed/expected.json +29 -2
- package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +3 -1
- package/conformance/cases/z01-every-domain-empty-object/expected.json +8 -4
- package/conformance/cases/z01-every-domain-empty-object/input.json +0 -1
- package/conformance/cases/z01-every-domain-empty-object/meta.json +1 -1
- package/conformance/cases/z02-every-domain-unknown-key/expected.json +163 -23
- package/conformance/cases/z02-every-domain-unknown-key/input.json +0 -3
- package/conformance/cases/z02-every-domain-unknown-key/meta.json +1 -1
- package/dist/api/auth-bridge.d.ts +51 -181
- package/dist/api/auth-bridge.js +58 -33
- package/dist/api/auth.d.ts +37 -146
- package/dist/api/auth.js +16 -26
- package/dist/api/feedback.d.ts +87 -0
- package/dist/api/feedback.js +66 -0
- package/dist/api/pull-token.d.ts +32 -0
- package/dist/api/pull-token.js +88 -0
- package/dist/api/scopes.d.ts +82 -174
- package/dist/api/scopes.js +23 -18
- package/dist/bundle.d.ts +15 -7
- package/dist/bundle.js +28 -27
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +132 -81
- package/dist/config-d-file.d.ts +14 -0
- package/dist/config-d-file.js +67 -0
- package/dist/config-fns.d.ts +87 -186
- package/dist/config-fns.js +207 -262
- package/dist/conformance/contract-adapter.d.ts +9 -2
- package/dist/conformance/contract-adapter.js +15 -7
- package/dist/conformance/corpus.d.ts +3 -0
- package/dist/conformance/corpus.js +5 -0
- package/dist/conformance/derive.d.ts +3 -0
- package/dist/conformance/index.d.ts +2 -2
- package/dist/conformance/index.js +2 -2
- package/dist/conformance/run.js +67 -22
- package/dist/conformance/types.d.ts +31 -15
- package/dist/conformance/types.js +5 -0
- package/dist/conformance/warnings.d.ts +1 -1
- package/dist/conformance/warnings.js +1 -4
- package/dist/cross-domain.d.ts +66 -22
- package/dist/cross-domain.js +164 -93
- package/dist/cross-session.d.ts +29 -11
- package/dist/default-model.d.ts +43 -0
- package/dist/default-model.js +34 -0
- package/dist/execution-lane-index.d.ts +4 -8
- package/dist/execution-lane-index.js +4 -8
- package/dist/execution-lanes.d.ts +31 -8
- package/dist/execution-lanes.js +23 -5
- package/dist/file-edit.js +5 -2
- package/dist/file-store.d.ts +7 -6
- package/dist/file-store.js +37 -50
- package/dist/fleet.d.ts +364 -762
- package/dist/fleet.js +277 -224
- package/dist/hooks.d.ts +1151 -4737
- package/dist/hooks.js +59 -15
- package/dist/index.d.ts +10 -4
- package/dist/index.js +13 -7
- package/dist/integer.d.ts +11 -0
- package/dist/integer.js +11 -0
- package/dist/issue-path.d.ts +10 -0
- package/dist/issue-path.js +10 -0
- package/dist/loopback.d.ts +26 -0
- package/dist/loopback.js +54 -0
- package/dist/migration-receipt.d.ts +42 -0
- package/dist/migration-receipt.js +12 -0
- package/dist/one-time-migrations.d.ts +56 -0
- package/dist/one-time-migrations.js +819 -0
- package/dist/prompts-digest.d.ts +1 -1
- package/dist/prompts-digest.js +3 -2
- package/dist/reader.d.ts +2 -2
- package/dist/remote-exec.d.ts +45 -187
- package/dist/remote-exec.js +13 -7
- package/dist/resolve-roster.d.ts +18 -3
- package/dist/resolve-roster.js +28 -25
- package/dist/retired-envs.d.ts +24 -0
- package/dist/retired-envs.js +114 -0
- package/dist/retired-form.d.ts +31 -0
- package/dist/retired-form.js +28 -0
- package/dist/safety-merge-spec.d.ts +131 -191
- package/dist/safety-merge-spec.js +18 -14
- package/dist/scheduler-lifetime.d.ts +5 -0
- package/dist/scheduler-lifetime.js +7 -0
- package/dist/scheduler-store-migration.d.ts +14 -0
- package/dist/scheduler-store-migration.js +14 -0
- package/dist/scheduler-store-node.d.ts +24 -9
- package/dist/scheduler-store-node.js +25 -15
- package/dist/scheduler-store.d.ts +47 -14
- package/dist/scheduler-store.js +32 -61
- package/dist/secret-refs.d.ts +3 -3
- package/dist/secret-refs.js +1 -1
- package/dist/secret-scan.d.ts +14 -24
- package/dist/secret-scan.js +45 -23
- package/dist/sha256-core.d.ts +16 -0
- package/dist/sha256-core.js +138 -0
- package/dist/sha256.d.ts +6 -1
- package/dist/sha256.js +8 -98
- package/dist/skills-manifest.js +1 -1
- package/dist/types.d.ts +1501 -13536
- package/dist/types.js +494 -438
- package/dist/url-shape.d.ts +34 -0
- package/dist/url-shape.js +61 -0
- package/dist/web-admin-credential.d.ts +96 -0
- package/dist/{center-credential.js → web-admin-credential.js} +46 -43
- package/dist/web-admin-env.d.ts +33 -0
- package/dist/web-admin-env.js +33 -0
- package/dist/worker-row-verdict.d.ts +16 -0
- package/dist/worker-row-verdict.js +31 -0
- package/dist/workers-rows.d.ts +34 -0
- package/dist/workers-rows.js +42 -0
- package/dist/zod-unwrap.d.ts +11 -0
- package/dist/zod-unwrap.js +25 -0
- package/package.json +17 -9
- package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/meta.json +0 -6
- package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/meta.json +0 -6
- package/conformance/migrate-workers/m01-g5-k8s-env-lifted/expected.json +0 -49
- package/conformance/migrate-workers/m01-g5-k8s-env-lifted/input.json +0 -26
- package/conformance/migrate-workers/m01-g5-k8s-env-lifted/meta.json +0 -4
- package/conformance/migrate-workers/m02-g5-idempotent/expected.json +0 -38
- package/conformance/migrate-workers/m02-g5-idempotent/input.json +0 -33
- package/conformance/migrate-workers/m02-g5-idempotent/meta.json +0 -5
- package/conformance/migrate-workers/m03-g5-refused-no-lane/expected.json +0 -71
- package/conformance/migrate-workers/m03-g5-refused-no-lane/input.json +0 -42
- package/conformance/migrate-workers/m03-g5-refused-no-lane/meta.json +0 -4
- package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/expected.json +0 -52
- package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/input.json +0 -30
- package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/meta.json +0 -4
- package/conformance/migrate-workers/m05-row-verdict-reserved-env/expected.json +0 -82
- package/conformance/migrate-workers/m05-row-verdict-reserved-env/input.json +0 -68
- package/conformance/migrate-workers/m05-row-verdict-reserved-env/meta.json +0 -4
- package/dist/center-credential.d.ts +0 -116
- package/dist/issue-text.d.ts +0 -10
- package/dist/issue-text.js +0 -8
- package/dist/migrate-workers.d.ts +0 -71
- package/dist/migrate-workers.js +0 -351
- package/dist/migrate.d.ts +0 -255
- package/dist/migrate.js +0 -542
- /package/conformance/cases/{c46-s811-host-name-url-userinfo-characterization → c46-s811-host-name-url-userinfo-withheld}/input.json +0 -0
package/dist/api/scopes.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `api/scopes` — the MULTI-TENANT SCOPE wire contract for
|
|
3
|
-
* /api/v1/auth/scope). Frozen here so
|
|
2
|
+
* `api/scopes` — the MULTI-TENANT SCOPE wire contract for web-admin (/api/v1/scopes* +
|
|
3
|
+
* /api/v1/auth/scope). Frozen here so web-admin (the implementation), the orchestrator, workers and
|
|
4
4
|
* the sema shell import ONE source of truth — same "契约先行" discipline as `api/auth`.
|
|
5
5
|
*
|
|
6
|
-
* THE SCOPE MODEL (normative —
|
|
6
|
+
* THE SCOPE MODEL (normative — web-admin implements EXACTLY this):
|
|
7
7
|
*
|
|
8
8
|
* - A **scope** is a tenant boundary: `{id, name, createdAt}`. `id` is a slug (SCOPE_ID_REGEX).
|
|
9
9
|
* The reserved id `"global"` (GLOBAL_SCOPE) is the DEFAULT scope: it always exists, is never
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
* and `viewer` in scope B.
|
|
14
14
|
* - The **instance-level** `users.role` is unchanged and orthogonal: an instance `admin` administers
|
|
15
15
|
* ALL scopes — inside any scope their effective role is always `"admin"` (see effectiveScopeRole).
|
|
16
|
-
* - The **access JWT**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* - The **access JWT** carries a `scope` claim (SCOPE_CLAIM) binding the token to its active scope —
|
|
17
|
+
* REQUIRED on read: a token without it is refused (readTokenScope ⇒ `scope_claim_missing`, answered 401);
|
|
18
|
+
* it is not read as `"global"`. The refresh token is NOT scope-bound; refresh re-mints per
|
|
19
|
+
* resolveTokenScope (always with the claim), and the client re-switches if it wants another scope.
|
|
20
20
|
*
|
|
21
21
|
* ENDPOINTS (paths in SCOPES_V1_PATHS / scopeMembersPath):
|
|
22
22
|
*
|
|
@@ -37,13 +37,14 @@
|
|
|
37
37
|
*
|
|
38
38
|
* WIRE CASING: /api/v1/auth/scope is part of the OAuth face — snake_case on the wire (access_token,
|
|
39
39
|
* expires_in), matching `api/auth`. The /api/v1/scopes management face uses this package's camelCase
|
|
40
|
-
* (createdAt, scopeId) — it is a
|
|
40
|
+
* (createdAt, scopeId) — it is a web-admin management API, not an OAuth endpoint.
|
|
41
41
|
*
|
|
42
42
|
* Pure zod contract + pure decision functions — zero IO, browser-safe. The decision functions
|
|
43
|
-
* (resolveTokenScope / canSwitchScope / effectiveScopeRole) are THE semantics:
|
|
43
|
+
* (resolveTokenScope / canSwitchScope / effectiveScopeRole) are THE semantics: web-admin
|
|
44
44
|
* implementation imports and calls these — it must not re-implement them.
|
|
45
45
|
*/
|
|
46
46
|
import { z } from "zod";
|
|
47
|
+
import { INTEGER } from "../integer.js";
|
|
47
48
|
import { USER_ROLES } from "../config-fns.js";
|
|
48
49
|
import { ACCESS_TOKEN_TTL_SECONDS } from "./auth.js";
|
|
49
50
|
// ── constants ────────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -53,14 +54,15 @@ export const GLOBAL_SCOPE = "global";
|
|
|
53
54
|
export const RESERVED_SCOPE_IDS = [GLOBAL_SCOPE];
|
|
54
55
|
/** Slug shape for scope ids: lowercase alphanumeric + inner dashes, 1–64 chars, no leading/trailing dash. */
|
|
55
56
|
export const SCOPE_ID_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
56
|
-
/** The scope endpoints, relative to
|
|
57
|
+
/** The scope endpoints, relative to web-admin origin. `members` needs the scope id — use scopeMembersPath. */
|
|
57
58
|
export const SCOPES_V1_PATHS = {
|
|
58
59
|
/** GET (list) + POST (create). */
|
|
59
60
|
scopes: "/api/v1/scopes",
|
|
60
61
|
/** POST — mint a new access token bound to another scope. */
|
|
61
62
|
authScope: "/api/v1/auth/scope",
|
|
62
63
|
};
|
|
63
|
-
/** The JWT claim carrying the token's active scope.
|
|
64
|
+
/** The JWT claim carrying the token's active scope. Every minted access token carries it; a token without it is refused on read
|
|
65
|
+
* ({@link readTokenScope}). */
|
|
64
66
|
export const SCOPE_CLAIM = "scope";
|
|
65
67
|
/** `/api/v1/scopes/{id}/members` for PUT (upsert) and DELETE (remove). */
|
|
66
68
|
export function scopeMembersPath(scopeId) {
|
|
@@ -124,7 +126,7 @@ export const ScopeSwitchResponse = z
|
|
|
124
126
|
access_token: z.string().min(1),
|
|
125
127
|
token_type: z.literal("Bearer"),
|
|
126
128
|
/** Access-token lifetime in seconds (ACCESS_TOKEN_TTL_SECONDS — same mint as api/auth). */
|
|
127
|
-
expires_in:
|
|
129
|
+
expires_in: INTEGER.positive(),
|
|
128
130
|
scope: ScopeId,
|
|
129
131
|
role: ScopeRole,
|
|
130
132
|
})
|
|
@@ -177,7 +179,7 @@ export function effectiveScopeRole(membershipRole, instanceRole) {
|
|
|
177
179
|
}
|
|
178
180
|
/**
|
|
179
181
|
* THE token-minting scope decision — which scope (and role claim) a fresh access token binds to, given
|
|
180
|
-
* the principal's scope memberships and instance role. Deterministic;
|
|
182
|
+
* the principal's scope memberships and instance role. Deterministic; web-admin (Node/Next) imports
|
|
181
183
|
* and calls this at every mint (device-flow grant AND refresh). NORMATIVE RULES (verbatim, 任务书拍定):
|
|
182
184
|
*
|
|
183
185
|
* 1. **唯一成员 scope** — exactly one membership → bind that scope, role = that membership's effective
|
|
@@ -218,11 +220,14 @@ export function canSwitchScope(memberships, instanceRole, targetScope) {
|
|
|
218
220
|
return true;
|
|
219
221
|
return memberships.some((m) => m.scopeId === targetScope);
|
|
220
222
|
}
|
|
223
|
+
/** Why {@link readTokenScope} refuses — a CLOSED set (one word today). */
|
|
224
|
+
export const TOKEN_SCOPE_REFUSALS = ["scope_claim_missing"];
|
|
221
225
|
/**
|
|
222
|
-
* Read the active scope off verified JWT claims
|
|
223
|
-
*
|
|
224
|
-
*
|
|
226
|
+
* Read the active scope off verified JWT claims — the ONE reader. A non-empty string `scope` claim ⇒ `{ scope }`; a claim that is
|
|
227
|
+
* missing, empty or not a string ⇒ `{ refused: "scope_claim_missing" }` (the reader answers 401). There is no default scope here:
|
|
228
|
+
* every access token web-admin mints carries the claim, an access token lives at most {@link ACCESS_TOKEN_TTL_SECONDS}, and a refresh
|
|
229
|
+
* re-mints one with the claim — a token without it is not a token this contract reads (it used to be read as `"global"`).
|
|
225
230
|
*/
|
|
226
|
-
export function
|
|
227
|
-
return typeof claims.scope === "string" && claims.scope.length > 0 ? claims.scope :
|
|
231
|
+
export function readTokenScope(claims) {
|
|
232
|
+
return typeof claims.scope === "string" && claims.scope.length > 0 ? { scope: claims.scope } : { refused: "scope_claim_missing" };
|
|
228
233
|
}
|
package/dist/bundle.d.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
+
import { type PortableDomain } from "./file-store.js";
|
|
1
2
|
import type { ConfigReader } from "./reader.js";
|
|
2
|
-
|
|
3
|
-
|
|
3
|
+
/** A bundle carries PORTABLE domains only — the non-portable ones (`NON_PORTABLE_DOMAIN_REASONS`: workers / hosts / systems / projects /
|
|
4
|
+
* entitlement) never travel, and the three bundle functions are that table's other face (types narrow to {@link PortableDomain}; a
|
|
5
|
+
* JS / `as` caller is refused at run time). */
|
|
6
|
+
export type ConfigBundle = Partial<Record<PortableDomain, unknown>>;
|
|
4
7
|
/** Read the (portable, by default) domains out of any ConfigReader → a bundle object. Absent domains are
|
|
5
8
|
* omitted (not defaulted) so a re-import doesn't fabricate domains the source never set. */
|
|
6
|
-
export declare function exportBundle(reader: ConfigReader, domains?: readonly
|
|
9
|
+
export declare function exportBundle(reader: ConfigReader, domains?: readonly PortableDomain[]): Promise<ConfigBundle>;
|
|
7
10
|
/** Write a bundle to `<root>/config.d/<domain>.json` — each file atomic + VALIDATED (writeDomainFile parses
|
|
8
11
|
* against the domain zod, so a malformed source domain fails loudly rather than writing garbage). Returns the
|
|
9
12
|
* domains written. (The TOC owns `<root>`; secrets stay in `<root>/.env`, never in these files.) */
|
|
10
|
-
export declare function writeBundle(root: string, bundle: ConfigBundle): Promise<
|
|
11
|
-
/** Read a `<root>/config.d/*.json` bundle → raw domain configs
|
|
12
|
-
*
|
|
13
|
-
|
|
13
|
+
export declare function writeBundle(root: string, bundle: ConfigBundle): Promise<PortableDomain[]>;
|
|
14
|
+
/** Read a `<root>/config.d/*.json` bundle → raw domain configs. The reverse of writeBundle: feed into web-admin's per-domain PUT to
|
|
15
|
+
* import, or into the resolver to run locally. Each file is read by the ONE `config.d/` file reading `FileConfigStore` uses
|
|
16
|
+
* (`./config-d-file`): a missing file → omitted (not fabricated); a present file that is empty / whitespace, bad JSON or not a JSON
|
|
17
|
+
* object is corrupt → throws (same sentence as `FileConfigStore`). A RETIRED domain's file in the directory (`config.d/runtime.json`,
|
|
18
|
+
* see `RETIRED_DOMAINS`) is read for its tombstone, whichever `domains` were asked for — the same judge and sentence as
|
|
19
|
+
* `FileConfigStore`: carrying facts ⇒ `RetiredDomainError` (a bundle assembled from the remaining files would silently drop those
|
|
20
|
+
* facts); absent / `{}` ⇒ left alone. */
|
|
21
|
+
export declare function readBundle(root: string, domains?: readonly PortableDomain[]): Promise<ConfigBundle>;
|
package/dist/bundle.js
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Round-trip between a config SOURCE (any {@link ConfigReader} —
|
|
2
|
+
* Round-trip between a config SOURCE (any {@link ConfigReader} — web-admin's store OR a FileConfigStore)
|
|
3
3
|
* and a local `config.d/<domain>.json` BUNDLE. This is how a FLEET config seeds a TOC desktop (export → copy
|
|
4
|
-
* the bundle + set .env → run locally) and how a local bundle seeds
|
|
4
|
+
* the bundle + set .env → run locally) and how a local bundle seeds web-admin (readBundle → PUT each domain
|
|
5
5
|
* via the existing domain API). The bundle = the RAW authorable domain configs; `skills` carry their full
|
|
6
6
|
* bodies (SkillSpec.content is stored in full — NOT the /effective manifest), so there is no separate
|
|
7
7
|
* body-bundling step and the round-trip is symmetric.
|
|
8
8
|
*
|
|
9
9
|
* Node-only (writeBundle/readBundle touch the filesystem) → imported via `@sema-agent/settings-schema/node`.
|
|
10
10
|
*/
|
|
11
|
-
import { readFile } from "node:fs/promises";
|
|
12
|
-
import path from "node:path";
|
|
13
11
|
import { writeDomainFile } from "./file-edit.js";
|
|
14
12
|
import { PORTABLE_DOMAINS } from "./file-store.js";
|
|
15
|
-
import {
|
|
13
|
+
import { configDPath, readConfigDFile, refuseRetiredDomainFiles } from "./config-d-file.js";
|
|
14
|
+
/** The runtime guard of the same rule (a name that is not even a domain is refused first — no path traversal). */
|
|
15
|
+
function assertPortable(d) {
|
|
16
|
+
if (!PORTABLE_DOMAINS.includes(d)) {
|
|
17
|
+
throw new Error(`"${d}" is not a portable domain — a bundle carries only ${PORTABLE_DOMAINS.join(" / ")} (the others never travel; see NON_PORTABLE_DOMAIN_REASONS)`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
16
20
|
/** Read the (portable, by default) domains out of any ConfigReader → a bundle object. Absent domains are
|
|
17
21
|
* omitted (not defaulted) so a re-import doesn't fabricate domains the source never set. */
|
|
18
22
|
export async function exportBundle(reader, domains = PORTABLE_DOMAINS) {
|
|
19
23
|
const out = {};
|
|
20
24
|
for (const d of domains) {
|
|
25
|
+
assertPortable(d);
|
|
21
26
|
const v = await reader.getDomain(d);
|
|
22
27
|
if (v !== undefined)
|
|
23
28
|
out[d] = v;
|
|
@@ -32,36 +37,32 @@ export async function writeBundle(root, bundle) {
|
|
|
32
37
|
for (const [d, v] of Object.entries(bundle)) {
|
|
33
38
|
if (v === undefined)
|
|
34
39
|
continue;
|
|
40
|
+
assertPortable(d);
|
|
35
41
|
await writeDomainFile(root, d, v);
|
|
36
42
|
written.push(d);
|
|
37
43
|
}
|
|
38
44
|
return written;
|
|
39
45
|
}
|
|
40
|
-
/** Read a `<root>/config.d/*.json` bundle → raw domain configs
|
|
41
|
-
*
|
|
46
|
+
/** Read a `<root>/config.d/*.json` bundle → raw domain configs. The reverse of writeBundle: feed into web-admin's per-domain PUT to
|
|
47
|
+
* import, or into the resolver to run locally. Each file is read by the ONE `config.d/` file reading `FileConfigStore` uses
|
|
48
|
+
* (`./config-d-file`): a missing file → omitted (not fabricated); a present file that is empty / whitespace, bad JSON or not a JSON
|
|
49
|
+
* object is corrupt → throws (same sentence as `FileConfigStore`). A RETIRED domain's file in the directory (`config.d/runtime.json`,
|
|
50
|
+
* see `RETIRED_DOMAINS`) is read for its tombstone, whichever `domains` were asked for — the same judge and sentence as
|
|
51
|
+
* `FileConfigStore`: carrying facts ⇒ `RetiredDomainError` (a bundle assembled from the remaining files would silently drop those
|
|
52
|
+
* facts); absent / `{}` ⇒ left alone. */
|
|
42
53
|
export async function readBundle(root, domains = PORTABLE_DOMAINS) {
|
|
54
|
+
for (const d of domains) {
|
|
55
|
+
if (!PORTABLE_DOMAINS.includes(d)) {
|
|
56
|
+
configDPath(root, d); // a name that is not a domain at all is refused by the one name guard first (no `../` traversal)
|
|
57
|
+
assertPortable(d);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await refuseRetiredDomainFiles(root);
|
|
43
61
|
const out = {};
|
|
44
62
|
await Promise.all(domains.map(async (d) => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
text = await readFile(path.join(root, "config.d", `${d}.json`), "utf8");
|
|
50
|
-
}
|
|
51
|
-
catch (e) {
|
|
52
|
-
if (e.code === "ENOENT")
|
|
53
|
-
return; // absent file → omit (not fabricated)
|
|
54
|
-
throw e;
|
|
55
|
-
}
|
|
56
|
-
const trimmed = text.trim();
|
|
57
|
-
if (!trimmed)
|
|
58
|
-
return;
|
|
59
|
-
try {
|
|
60
|
-
out[d] = JSON.parse(trimmed);
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
throw new Error(`invalid JSON in config.d/${d}.json`); // sanitized: never echo the raw file text
|
|
64
|
-
}
|
|
63
|
+
const v = await readConfigDFile(root, d);
|
|
64
|
+
if (v !== undefined)
|
|
65
|
+
out[d] = v;
|
|
65
66
|
}));
|
|
66
67
|
return out;
|
|
67
68
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
/** 入口注入的 I/O(核心不碰 node:fs)。`writeFileAtomic` 必须原子(写临时文件再改名)
|
|
1
|
+
/** 入口注入的 I/O(核心不碰 node:fs)。`writeFileAtomic` 必须原子(写临时文件再改名):半写的域文档比旧文档更糟。 */
|
|
2
2
|
export interface SettingsSchemaCliIo {
|
|
3
|
+
readDir(path: string): Promise<string[]>;
|
|
3
4
|
readFile(path: string): Promise<string>;
|
|
4
5
|
writeFileAtomic(path: string, text: string): Promise<void>;
|
|
6
|
+
removeFile(path: string): Promise<void>;
|
|
5
7
|
stdout(text: string): void;
|
|
6
8
|
stderr(text: string): void;
|
|
7
9
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,34 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `settings-schema` 命令行的核心(
|
|
3
|
-
*
|
|
2
|
+
* `settings-schema` 命令行的核心(11.0.0)—— 子命令只有一个:`migrate <config.d 目录> [--check]`,即一次性迁移
|
|
3
|
+
* `migrateStoredDocuments`(`./one-time-migrations`)的目录形(本地 config.d / 离线文件用;web-admin 在它起服时直接调同一只函数)。
|
|
4
|
+
* 目录被当作**一个 scope 的全集**(`coverage: "scope"`):目录里没有的域文件就是从未设过。
|
|
4
5
|
*
|
|
5
6
|
* 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(那是本包唯一用 fs 写文件的地方),所以测试可以直接驱动它。
|
|
6
7
|
* 不进主桶 —— 它是 bin 的实现,不是契约面。
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 只在「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的文档 `--check` 也报 refused / rc 1。
|
|
9
|
+
* 读哪些文件:目录里 `<名>.json`,名是配置域或retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁);别的文件
|
|
10
|
+
* (`remote-exec.json`、隐藏文件、不认识的名)不读不动。
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* 退出码:0 = 已写回 / 无事可做;1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 {@link verdictOf}),**一字不写**;
|
|
13
|
+
* 2 = 用法 / 读目录 / 读文件错,或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
|
|
14
|
+
* 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
|
|
15
|
+
*
|
|
16
|
+
* ## 报告形(让「没写」在机读上不可能被读成「写了」)
|
|
17
|
+
* 退出码 0 / 1 / 3 时 stdout 恰两行:第 1 行 = **唯一结论行** `outcome=<refused|applied|noop|pending> written=<true|false>`;第 2 行 = JSON 报告
|
|
18
|
+
* (`event: "stored_documents_migrated"`,逐条收据只点名坐标与处置、不带值),同带 `outcome` / `written`。`written` 由 `outcome` 派生
|
|
19
|
+
* (只有 `applied` 写盘)。**键名带时态**:`written=true` 才有 `applied`;没写(refused / noop / pending)时是 `wouldApply`。
|
|
20
|
+
* 结论行在**写盘成功之后**才出(写盘抛错 ⇒ 没有 `outcome=applied`)。写盘次序:先写改过 / 新建的文件,最后删被移除的retired 域文件 ——
|
|
21
|
+
* 中途断掉再跑一次,迁移照样恒等收尾(已搬过去的值在新位上,旧文件里同名键按「新位赢」丢弃)。
|
|
19
22
|
*/
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
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 { MIGRATE_COMMAND } from "./retired-form.js";
|
|
28
|
+
import { isDomainName, isRetiredDomainName } from "./types.js";
|
|
25
29
|
const USAGE = [
|
|
26
|
-
`usage: ${
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
+
`usage: ${MIGRATE_COMMAND} <config.d directory> [--check]`,
|
|
31
|
+
" Runs the one-time migrations (@sema-agent/settings-schema/one-time-migrations) over every <domain>.json in the directory, read as one",
|
|
32
|
+
" scope (a missing domain file = never set): writes the rewritten files in place and removes retired-domain files (runtime.json) only when",
|
|
33
|
+
" 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).",
|
|
30
35
|
" stdout (exit 0 / 1 / 3): line 1 = outcome=<refused|applied|noop|pending> written=<true|false>, line 2 = the JSON report",
|
|
31
|
-
" (
|
|
36
|
+
" (applied only when written=true; otherwise wouldApply).",
|
|
32
37
|
].join("\n");
|
|
33
38
|
/** 这个结论写没写盘 —— 只有 `applied` 写。`written` 从这里派生,不是报告里一个可以和 `outcome` 各说各话的独立字段。 */
|
|
34
39
|
function writtenOf(outcome) {
|
|
@@ -45,99 +50,145 @@ function writtenOf(outcome) {
|
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
}
|
|
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
|
+
}
|
|
48
75
|
/**
|
|
49
|
-
* 迁移结果 →
|
|
50
|
-
*
|
|
51
|
-
*
|
|
76
|
+
* 迁移结果 → 结论。顺序:迁移步拒 → 无改动 ⇒ noop → **整域终判**(要写回的每个域文档 —— 改过的、新建的 —— 过读侧那一只
|
|
77
|
+
* `parseDomain`:名字 / 路由唯一这类跨行规则只有整域判得出)→ 有改动。整域终判在 `--check` 之前:否则同一个目录 `--check` 报 pending、
|
|
78
|
+
* 真跑写出一份读侧必拒的文档。没改的文件不归这里判(迁移不碰它,它读不读得进是读者的事)。
|
|
52
79
|
*/
|
|
53
|
-
function verdictOf(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
refused: m.refused.map((r) => ({ name: r.name, field: r.field, reason: r.reason })),
|
|
58
|
-
why: `${m.refused.length} worker(s) cannot be migrated (see "refused") — fix them by hand and re-run`,
|
|
59
|
-
};
|
|
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` };
|
|
60
84
|
}
|
|
61
|
-
if (
|
|
85
|
+
if (receipts.length === 0)
|
|
62
86
|
return { kind: "noop" };
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
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" };
|
|
79
107
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
function reportOf(outcome, file, m, refused) {
|
|
108
|
+
/** 一次发出报告:结论行 + JSON 行(一次 `stdout` 调用)。 */
|
|
109
|
+
function reportOf(outcome, dir, applied, refused) {
|
|
83
110
|
const written = writtenOf(outcome);
|
|
84
|
-
const dropped = m.dropped.map((d) => ({ name: d.name, field: formatConfigPath(d.field) }));
|
|
85
111
|
const json = JSON.stringify({
|
|
86
|
-
event: "
|
|
112
|
+
event: "stored_documents_migrated",
|
|
87
113
|
outcome,
|
|
88
114
|
written,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
refused
|
|
115
|
+
dir,
|
|
116
|
+
[written ? "applied" : "wouldApply"]: applied.map(reportReceipt),
|
|
117
|
+
refused,
|
|
92
118
|
});
|
|
93
119
|
return `outcome=${outcome} written=${written}\n${json}\n`;
|
|
94
120
|
}
|
|
121
|
+
const join = (dir, name) => `${dir.replace(/[\\/]+$/, "")}/${name}`;
|
|
95
122
|
export async function runSettingsSchemaCli(argv, io) {
|
|
96
123
|
const [command, ...rest] = argv;
|
|
97
124
|
const check = rest.includes("--check");
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
if (command !== "migrate
|
|
125
|
+
const dirs = rest.filter((a) => a !== "--check");
|
|
126
|
+
const dir = dirs[0];
|
|
127
|
+
if (command !== "migrate" || dirs.length !== 1 || dir === undefined || dir.startsWith("-")) {
|
|
101
128
|
io.stderr(`${USAGE}\n`);
|
|
102
129
|
return 2;
|
|
103
130
|
}
|
|
104
|
-
let
|
|
131
|
+
let names;
|
|
105
132
|
try {
|
|
106
|
-
|
|
133
|
+
names = await io.readDir(dir);
|
|
107
134
|
}
|
|
108
135
|
catch (err) {
|
|
109
|
-
io.stderr(`settings-schema: cannot read ${
|
|
136
|
+
io.stderr(`settings-schema: cannot read directory ${dir}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
110
137
|
return 2;
|
|
111
138
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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`);
|
|
152
|
+
return 2;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
docs[base] = judgeConfigDText(base, text); // the one config.d file judge: empty / whitespace / bad JSON / not an object ⇒ same sentence as FileConfigStore
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
io.stderr(`settings-schema: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
159
|
+
return 2;
|
|
160
|
+
}
|
|
119
161
|
}
|
|
120
|
-
const m =
|
|
121
|
-
const
|
|
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);
|
|
122
165
|
switch (verdict.kind) {
|
|
123
166
|
case "refused":
|
|
124
|
-
io.stdout(reportOf("refused",
|
|
167
|
+
io.stdout(reportOf("refused", dir, applied, verdict.refused));
|
|
125
168
|
io.stderr(`settings-schema: ${verdict.why}; nothing was written\n`);
|
|
126
169
|
return 1;
|
|
127
170
|
case "noop":
|
|
128
|
-
io.stdout(reportOf("noop",
|
|
171
|
+
io.stdout(reportOf("noop", dir, [], []));
|
|
129
172
|
return 0;
|
|
130
173
|
case "changes":
|
|
131
|
-
|
|
132
|
-
io.stdout(reportOf("pending", file, m, []));
|
|
133
|
-
return 3;
|
|
134
|
-
}
|
|
135
|
-
await io.writeFileAtomic(file, `${JSON.stringify(m.doc, null, 2)}\n`);
|
|
136
|
-
io.stdout(reportOf("applied", file, m, [])); // 写盘成功之后才报 applied
|
|
137
|
-
return 0;
|
|
174
|
+
break;
|
|
138
175
|
default: {
|
|
139
176
|
const unhandled = verdict;
|
|
140
177
|
throw new Error(`settings-schema: unhandled verdict ${JSON.stringify(unhandled)}`);
|
|
141
178
|
}
|
|
142
179
|
}
|
|
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;
|
|
143
194
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type DomainName, type RetiredDomainName } from "./types.js";
|
|
2
|
+
/** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是配置域也不是 retired 域 ⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
|
|
3
|
+
export declare function configDPath(root: string, name: string): string;
|
|
4
|
+
/** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、
|
|
5
|
+
* 不是对象 ⇒ 抛。读盘的入口(`readConfigDFile`)与自己读文本的命令行(`migrate`)都过它,四种坏形一句话。 */
|
|
6
|
+
export declare function judgeConfigDText(name: DomainName | RetiredDomainName, text: string): Record<string, unknown>;
|
|
7
|
+
/** 读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ {@link judgeConfigDText}。 */
|
|
8
|
+
export declare function readConfigDFile(root: string, name: DomainName | RetiredDomainName): Promise<Record<string, unknown> | undefined>;
|
|
9
|
+
/**
|
|
10
|
+
* `config.d/` 里每个 **retired 域**的文件过一次墓碑(`refuseRetiredDomain`,与 `buildEffective` / `parseDomain` 同一只判官、同一句):带事实 ⇒
|
|
11
|
+
* `RetiredDomainError`;缺席 / `{}` ⇒ 交回读到的原文(调用方要它进版本 hash)。读 `config.d/` 的**每个入口**都先过它 —— `FileConfigStore`
|
|
12
|
+
* 的 `getDomain` / `getEffective`(于是走 `getDomain` 的 `exportBundle` 同答)与 `readBundle`;不论调用方要的是哪几个域。
|
|
13
|
+
*/
|
|
14
|
+
export declare function refuseRetiredDomainFiles(root: string): Promise<Partial<Record<RetiredDomainName, unknown>>>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包内私有(不进主桶、不进 exports 表):**一只** `config.d/<名>.json` 文件读法 —— `FileConfigStore`、`readBundle` 与命令行 `migrate` 读同一个目录,
|
|
3
|
+
* 同一种文件形同一个答案、同一句话。分两只:纯判 `judgeConfigDText`(文本 → 值 | 抛,零 I/O;命令行自己读文本,只调它)与读盘的
|
|
4
|
+
* `readConfigDFile`(调它);路径与名字的判只在 `configDPath`。
|
|
5
|
+
*
|
|
6
|
+
* 律:**只有不存在(ENOENT)= 缺席**(`undefined`);在场的文件必须是一个 JSON 对象。空 / 纯空白(半写、截断)、坏 JSON、解析出来不是对象
|
|
7
|
+
* (`null` / 数组 / 数 / 串 / 布尔)一律是**坏文件** ⇒ 抛(fail-closed:把一个被截断的文件读成缺席,就是静默丢掉那一个域 —— 例:截断的
|
|
8
|
+
* governance.json 丢掉它的护栏)。拒句只点名文件与形,从不回显文件原文。
|
|
9
|
+
*/
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { refuseRetiredDomain } from "./config-fns.js";
|
|
13
|
+
import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
|
|
14
|
+
/** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是配置域也不是 retired 域 ⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
|
|
15
|
+
export function configDPath(root, name) {
|
|
16
|
+
if (!isDomainName(name) && !isRetiredDomainName(name))
|
|
17
|
+
throw new Error(`"${name}" is not a config domain — refusing to build a config.d path from it (no traversal)`);
|
|
18
|
+
return path.join(root, "config.d", `${name}.json`);
|
|
19
|
+
}
|
|
20
|
+
/** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、
|
|
21
|
+
* 不是对象 ⇒ 抛。读盘的入口(`readConfigDFile`)与自己读文本的命令行(`migrate`)都过它,四种坏形一句话。 */
|
|
22
|
+
export function judgeConfigDText(name, text) {
|
|
23
|
+
const trimmed = text.trim();
|
|
24
|
+
if (!trimmed)
|
|
25
|
+
throw new Error(`config.d/${name}.json is empty/whitespace — delete it for the domain default, or write a valid object`);
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(trimmed);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(`invalid JSON in config.d/${name}.json`); // sanitized: never echo the raw file text into the error
|
|
32
|
+
}
|
|
33
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
34
|
+
const kind = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
|
|
35
|
+
throw new Error(`config.d/${name}.json must be a JSON object (got ${kind}) — delete the file for the domain default, or write a valid object`);
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
/** 读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ {@link judgeConfigDText}。 */
|
|
40
|
+
export async function readConfigDFile(root, name) {
|
|
41
|
+
let text;
|
|
42
|
+
try {
|
|
43
|
+
text = await readFile(configDPath(root, name), "utf8");
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (e.code === "ENOENT")
|
|
47
|
+
return undefined;
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
return judgeConfigDText(name, text);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `config.d/` 里每个 **retired 域**的文件过一次墓碑(`refuseRetiredDomain`,与 `buildEffective` / `parseDomain` 同一只判官、同一句):带事实 ⇒
|
|
54
|
+
* `RetiredDomainError`;缺席 / `{}` ⇒ 交回读到的原文(调用方要它进版本 hash)。读 `config.d/` 的**每个入口**都先过它 —— `FileConfigStore`
|
|
55
|
+
* 的 `getDomain` / `getEffective`(于是走 `getDomain` 的 `exportBundle` 同答)与 `readBundle`;不论调用方要的是哪几个域。
|
|
56
|
+
*/
|
|
57
|
+
export async function refuseRetiredDomainFiles(root) {
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const d of Object.keys(RETIRED_DOMAINS)) {
|
|
60
|
+
if (!isRetiredDomainName(d))
|
|
61
|
+
continue;
|
|
62
|
+
const raw = await readConfigDFile(root, d);
|
|
63
|
+
refuseRetiredDomain(d, raw);
|
|
64
|
+
out[d] = raw;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|