@sema-agent/settings-schema 10.1.1 → 11.1.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 +469 -1
- package/README.md +144 -66
- package/bin/settings-schema.mjs +10 -12
- 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/atomic-write.d.ts +14 -0
- package/dist/atomic-write.js +60 -0
- package/dist/bundle.d.ts +15 -7
- package/dist/bundle.js +28 -27
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +149 -80
- 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.d.ts +3 -3
- package/dist/file-edit.js +11 -18
- 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/local-load.d.ts +1 -1
- package/dist/local-load.js +4 -14
- 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 +62 -0
- package/dist/one-time-migrations.js +828 -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 +16 -10
- 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 +28 -12
- package/dist/scheduler-store-node.js +32 -23
- 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 +1509 -13536
- package/dist/types.js +526 -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} +49 -46
- 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
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** 按文档类的模式表(闭集)。 */
|
|
2
|
+
export declare const ATOMIC_FILE_CLASSES: {
|
|
3
|
+
readonly "config-document": {
|
|
4
|
+
readonly newMode: 420;
|
|
5
|
+
readonly existing: "keep";
|
|
6
|
+
};
|
|
7
|
+
readonly "scheduler-store": {
|
|
8
|
+
readonly newMode: 384;
|
|
9
|
+
readonly existing: "reset";
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
export type AtomicFileClass = keyof typeof ATOMIC_FILE_CLASSES;
|
|
13
|
+
/** 原子写 `text`(UTF-8)到 `target`;模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
|
|
14
|
+
export declare function writeFileAtomic(target: string, text: string, kind: AtomicFileClass): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **本包写盘的唯一一只原子写器**(Node 专用,不进主桶):`writeDomainFile` / `writeRemoteExec`(`config.d` 下的契约文件)、调度店
|
|
3
|
+
* `saveSchedulerStore`、命令行迁移经 `bin/settings-schema.mjs` 注入的写器 —— 全部调它。写法只有一种:在目标同目录写一个唯一命名的隐藏
|
|
4
|
+
* 临时文件,再 `rename` 盖过目标(POSIX 上原子,并发读者永远看不到半截文件);失败时临时文件被清掉,不留残片。
|
|
5
|
+
*
|
|
6
|
+
* 同步实现(几 KB 的配置文件,一次写不到一毫秒):调度店的写口本来就是同步 API(`saveSchedulerStore(path, records): void`,
|
|
7
|
+
* `mutateSchedulerStore` 的提交点),异步调用方(`writeDomainFile` 等)直接调它 —— 一只写器,没有第二份同样的写法。
|
|
8
|
+
*
|
|
9
|
+
* ## 文件模式:一张按文档类的表({@link ATOMIC_FILE_CLASSES}),不在任何调用点另写
|
|
10
|
+
* · `config-document` —— `config.d` 下的契约 JSON(域文档、`remote-exec.json`):只带 env-NAME、不带秘密值(README 契约),新建 = 0o644;
|
|
11
|
+
* **已有的文件沿用它原来的模式**(运维 chmod 过的文件不被一次写回覆盖)。
|
|
12
|
+
* · `scheduler-store` —— 调度店耐久记录(可能带 principal / session 标识):新建 = 0o600,且**每次写都回到 0o600**(已有的更宽的文件被收回)。
|
|
13
|
+
* 新建的模式照常经进程 umask 遮一道(`writeFile` 的 `mode` 语义):比表更严的 umask 只会更严,不会被这里放宽;沿用的模式原样恢复(不再过 umask)。
|
|
14
|
+
*/
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import { chmodSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
/** 按文档类的模式表(闭集)。 */
|
|
19
|
+
export const ATOMIC_FILE_CLASSES = {
|
|
20
|
+
"config-document": { newMode: 0o644, existing: "keep" },
|
|
21
|
+
"scheduler-store": { newMode: 0o600, existing: "reset" },
|
|
22
|
+
};
|
|
23
|
+
/** 原子写 `text`(UTF-8)到 `target`;模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
|
|
24
|
+
export function writeFileAtomic(target, text, kind) {
|
|
25
|
+
const spec = ATOMIC_FILE_CLASSES[kind];
|
|
26
|
+
let kept; // the existing file's mode, when this class keeps it
|
|
27
|
+
switch (spec.existing) {
|
|
28
|
+
case "keep":
|
|
29
|
+
try {
|
|
30
|
+
kept = statSync(target).mode & 0o777;
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
if (e.code !== "ENOENT")
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
36
|
+
break;
|
|
37
|
+
case "reset":
|
|
38
|
+
break;
|
|
39
|
+
default: {
|
|
40
|
+
const unhandled = spec.existing;
|
|
41
|
+
throw new Error(`writeFileAtomic: unhandled existing-file rule ${String(unhandled)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(tmp, text, { encoding: "utf8", mode: kept ?? spec.newMode });
|
|
47
|
+
if (kept !== undefined)
|
|
48
|
+
chmodSync(tmp, kept); // a kept mode is restored exactly (the creation mode above went through the umask)
|
|
49
|
+
renameSync(tmp, target);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
try {
|
|
53
|
+
unlinkSync(tmp);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* 临时文件没建成:没什么可清 */
|
|
57
|
+
}
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
}
|
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` 必须原子(写临时文件再改名):半写的域文档比旧文档更糟;入口注入的就是 `./atomic-write` 的那一只。 */
|
|
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,44 @@
|
|
|
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
|
-
* 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(
|
|
6
|
+
* 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(写盘用的是 `./atomic-write` 那一只原子写器 —— 写法与文件模式在那里一处定),所以测试可以直接驱动它。
|
|
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
|
+
* 零域文档分两形:**有条目却没有域文档**(任何非 `.` 开头的条目 —— 子目录、说明文件、不认识的名)⇒ 退出 2;**目录真空**(零条目,或只有以 `.` 开头的条目)⇒ 照常走判定,
|
|
13
|
+
* 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
|
|
14
|
+
*
|
|
15
|
+
* 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 {@link verdictOf}),**一字不写**;
|
|
16
|
+
* 2 = 用法 / 读目录 / 读文件错,或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
|
|
17
|
+
* (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
|
|
18
|
+
* 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
|
|
19
|
+
*
|
|
20
|
+
* ## 报告形(让「没写」在机读上不可能被读成「写了」)
|
|
21
|
+
* 退出码 0 / 1 / 3 时 stdout 恰两行:第 1 行 = **唯一结论行** `outcome=<refused|applied|noop|pending> written=<true|false>`;第 2 行 = JSON 报告
|
|
22
|
+
* (`event: "stored_documents_migrated"`,逐条收据只点名坐标与处置、不带值),同带 `outcome` / `written`。`written` 由 `outcome` 派生
|
|
23
|
+
* (只有 `applied` 写盘)。**键名带时态**:`written=true` 才有 `applied`;没写(refused / noop / pending)时是 `wouldApply`。
|
|
24
|
+
* 结论行在**写盘成功之后**才出(写盘抛错 ⇒ 没有 `outcome=applied`)。写盘次序:先写改过 / 新建的文件,最后删被移除的retired 域文件 ——
|
|
25
|
+
* 中途断掉再跑一次,迁移照样恒等收尾(已搬过去的值在新位上,旧文件里同名键按「新位赢」丢弃)。
|
|
19
26
|
*/
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
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";
|
|
25
33
|
const USAGE = [
|
|
26
|
-
`usage: ${
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
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",
|
|
36
|
+
" scope (a missing domain file = never set): writes the rewritten files in place and removes retired-domain files (runtime.json) only when",
|
|
37
|
+
" 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
|
+
" 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).",
|
|
30
40
|
" stdout (exit 0 / 1 / 3): line 1 = outcome=<refused|applied|noop|pending> written=<true|false>, line 2 = the JSON report",
|
|
31
|
-
" (
|
|
41
|
+
" (applied only when written=true; otherwise wouldApply).",
|
|
32
42
|
].join("\n");
|
|
33
43
|
/** 这个结论写没写盘 —— 只有 `applied` 写。`written` 从这里派生,不是报告里一个可以和 `outcome` 各说各话的独立字段。 */
|
|
34
44
|
function writtenOf(outcome) {
|
|
@@ -45,99 +55,158 @@ function writtenOf(outcome) {
|
|
|
45
55
|
}
|
|
46
56
|
}
|
|
47
57
|
}
|
|
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
|
+
}
|
|
48
80
|
/**
|
|
49
|
-
* 迁移结果 →
|
|
50
|
-
*
|
|
51
|
-
*
|
|
81
|
+
* 迁移结果 → 结论。顺序:迁移步拒 → 无改动 ⇒ noop → **整域终判**(要写回的每个域文档 —— 改过的、新建的 —— 过读侧那一只
|
|
82
|
+
* `parseDomain`:名字 / 路由唯一这类跨行规则只有整域判得出)→ 有改动。整域终判在 `--check` 之前:否则同一个目录 `--check` 报 pending、
|
|
83
|
+
* 真跑写出一份读侧必拒的文档。没改的文件不归这里判(迁移不碰它,它读不读得进是读者的事)。
|
|
52
84
|
*/
|
|
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
|
-
};
|
|
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` };
|
|
60
89
|
}
|
|
61
|
-
if (
|
|
90
|
+
if (receipts.length === 0)
|
|
62
91
|
return { kind: "noop" };
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
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" };
|
|
79
112
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
function reportOf(outcome, file, m, refused) {
|
|
113
|
+
/** 一次发出报告:结论行 + JSON 行(一次 `stdout` 调用)。 */
|
|
114
|
+
function reportOf(outcome, dir, applied, refused) {
|
|
83
115
|
const written = writtenOf(outcome);
|
|
84
|
-
const dropped = m.dropped.map((d) => ({ name: d.name, field: formatConfigPath(d.field) }));
|
|
85
116
|
const json = JSON.stringify({
|
|
86
|
-
event: "
|
|
117
|
+
event: "stored_documents_migrated",
|
|
87
118
|
outcome,
|
|
88
119
|
written,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
refused
|
|
120
|
+
dir,
|
|
121
|
+
[written ? "applied" : "wouldApply"]: applied.map(reportReceipt),
|
|
122
|
+
refused,
|
|
92
123
|
});
|
|
93
124
|
return `outcome=${outcome} written=${written}\n${json}\n`;
|
|
94
125
|
}
|
|
126
|
+
const join = (dir, name) => `${dir.replace(/[\\/]+$/, "")}/${name}`;
|
|
127
|
+
/** 「有条目却没有域文档」的拒句:点名这个目录、说要的是 `config.d` 目录本身;`<dir>/config.d` 在场(探一次)就直接点它 —— 最常见的错是填了它的上一层。 */
|
|
128
|
+
async function noDomainDocumentsSentence(dir, io) {
|
|
129
|
+
const nested = join(dir, "config.d");
|
|
130
|
+
const nestedExists = await io.readDir(nested).then(() => true, () => false);
|
|
131
|
+
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;
|
|
133
|
+
}
|
|
95
134
|
export async function runSettingsSchemaCli(argv, io) {
|
|
96
135
|
const [command, ...rest] = argv;
|
|
97
136
|
const check = rest.includes("--check");
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
if (command !== "migrate
|
|
137
|
+
const dirs = rest.filter((a) => a !== "--check");
|
|
138
|
+
const dir = dirs[0];
|
|
139
|
+
if (command !== "migrate" || dirs.length !== 1 || dir === undefined || dir.startsWith("-")) {
|
|
101
140
|
io.stderr(`${USAGE}\n`);
|
|
102
141
|
return 2;
|
|
103
142
|
}
|
|
104
|
-
let
|
|
143
|
+
let names;
|
|
105
144
|
try {
|
|
106
|
-
|
|
145
|
+
names = await io.readDir(dir);
|
|
107
146
|
}
|
|
108
147
|
catch (err) {
|
|
109
|
-
io.stderr(`settings-schema: cannot read ${
|
|
148
|
+
io.stderr(`settings-schema: cannot read directory ${dir}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
110
149
|
return 2;
|
|
111
150
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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`);
|
|
171
|
+
return 2;
|
|
172
|
+
}
|
|
115
173
|
}
|
|
116
|
-
|
|
117
|
-
|
|
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`);
|
|
118
178
|
return 2;
|
|
119
179
|
}
|
|
120
|
-
const m =
|
|
121
|
-
const
|
|
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);
|
|
122
183
|
switch (verdict.kind) {
|
|
123
184
|
case "refused":
|
|
124
|
-
io.stdout(reportOf("refused",
|
|
185
|
+
io.stdout(reportOf("refused", dir, applied, verdict.refused));
|
|
125
186
|
io.stderr(`settings-schema: ${verdict.why}; nothing was written\n`);
|
|
126
187
|
return 1;
|
|
127
188
|
case "noop":
|
|
128
|
-
io.stdout(reportOf("noop",
|
|
189
|
+
io.stdout(reportOf("noop", dir, [], []));
|
|
129
190
|
return 0;
|
|
130
191
|
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;
|
|
192
|
+
break;
|
|
138
193
|
default: {
|
|
139
194
|
const unhandled = verdict;
|
|
140
195
|
throw new Error(`settings-schema: unhandled verdict ${JSON.stringify(unhandled)}`);
|
|
141
196
|
}
|
|
142
197
|
}
|
|
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;
|
|
143
212
|
}
|
|
@@ -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>>>;
|