@nekro-nxt/contracts 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +6 -2
- package/dist/index.mjs +4475 -0
- package/dist/index.mjs.map +1 -0
- package/lib/types/community.d.ts +226 -0
- package/lib/types/community.d.ts.map +1 -0
- package/lib/types/config-schema.d.ts +133 -0
- package/lib/types/config-schema.d.ts.map +1 -0
- package/lib/types/domain.d.ts +291 -0
- package/lib/types/domain.d.ts.map +1 -0
- package/lib/types/extension-capabilities.d.ts +277 -0
- package/lib/types/extension-capabilities.d.ts.map +1 -0
- package/lib/types/extension-ui.d.ts +789 -0
- package/lib/types/extension-ui.d.ts.map +1 -0
- package/lib/types/host-api.d.ts +9120 -0
- package/lib/types/host-api.d.ts.map +1 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/management-api.d.ts +108 -0
- package/lib/types/management-api.d.ts.map +1 -0
- package/lib/types/runtime-compatibility.d.ts +72 -0
- package/lib/types/runtime-compatibility.d.ts.map +1 -0
- package/package.json +40 -3
- package/src/community.ts +120 -0
- package/src/config-schema.ts +351 -0
- package/src/domain.ts +365 -0
- package/src/extension-capabilities.ts +473 -0
- package/src/extension-ui.ts +433 -0
- package/src/host-api.ts +3383 -0
- package/src/index.ts +8 -0
- package/src/management-api.ts +168 -0
- package/src/runtime-compatibility.ts +28 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { JsonValueSchema, type JsonValue } from './domain.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The single configuration format: serialized Schemastery (`Schema.toJSON()`), restricted to the subset the
|
|
6
|
+
* product form can render and the Host can validate without executing Schema callbacks.
|
|
7
|
+
*
|
|
8
|
+
* Supported nodes: object (dict), string, number, natural, percent, boolean, const, union of consts (enum) and
|
|
9
|
+
* array of primitives. `meta.role: 'secret'` marks a write-only credential. NekroNXT reads three extra meta keys:
|
|
10
|
+
* `hint` (help text), `advanced` (collapsed by default) and `visibleWhen` (show only when a sibling equals a value).
|
|
11
|
+
*/
|
|
12
|
+
export const CONFIG_SCHEMA_MAX_BYTES = 64 * 1024
|
|
13
|
+
|
|
14
|
+
const DescriptionSchema = z.union([z.string(), z.record(z.string(), z.string())])
|
|
15
|
+
|
|
16
|
+
export const ConfigSchemaMetaSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
description: DescriptionSchema.optional(),
|
|
19
|
+
hint: z.string().max(500).optional(),
|
|
20
|
+
required: z.boolean().optional(),
|
|
21
|
+
default: JsonValueSchema.optional(),
|
|
22
|
+
role: z.string().max(32).optional(),
|
|
23
|
+
hidden: z.boolean().optional(),
|
|
24
|
+
min: z.number().finite().optional(),
|
|
25
|
+
max: z.number().finite().optional(),
|
|
26
|
+
step: z.number().finite().positive().optional(),
|
|
27
|
+
pattern: z.object({ source: z.string().max(500), flags: z.string().max(8).optional() }).optional(),
|
|
28
|
+
advanced: z.boolean().optional(),
|
|
29
|
+
group: z.string().max(40).optional(),
|
|
30
|
+
visibleWhen: z
|
|
31
|
+
.object({ key: z.string().min(1).max(64), equals: JsonValueSchema })
|
|
32
|
+
.strict()
|
|
33
|
+
.optional(),
|
|
34
|
+
})
|
|
35
|
+
.loose()
|
|
36
|
+
|
|
37
|
+
export type ConfigSchemaMeta = z.output<typeof ConfigSchemaMetaSchema>
|
|
38
|
+
|
|
39
|
+
export type ConfigSchemaNode =
|
|
40
|
+
| {
|
|
41
|
+
readonly type: 'object'
|
|
42
|
+
readonly dict: Readonly<Record<string, ConfigSchemaNode>>
|
|
43
|
+
readonly meta?: ConfigSchemaMeta | undefined
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
readonly type: 'string' | 'number' | 'natural' | 'percent' | 'boolean'
|
|
47
|
+
readonly meta?: ConfigSchemaMeta | undefined
|
|
48
|
+
}
|
|
49
|
+
| { readonly type: 'const'; readonly value: JsonValue; readonly meta?: ConfigSchemaMeta | undefined }
|
|
50
|
+
| { readonly type: 'union'; readonly list: readonly ConfigSchemaNode[]; readonly meta?: ConfigSchemaMeta | undefined }
|
|
51
|
+
| { readonly type: 'array'; readonly inner: ConfigSchemaNode; readonly meta?: ConfigSchemaMeta | undefined }
|
|
52
|
+
|
|
53
|
+
const SAFE_KEY = /^(?!__proto__$|prototype$|constructor$)[A-Za-z_$][\w$-]{0,63}$/u
|
|
54
|
+
|
|
55
|
+
export const ConfigSchemaNodeSchema: z.ZodType<ConfigSchemaNode> = z.lazy(() =>
|
|
56
|
+
z.union([
|
|
57
|
+
z
|
|
58
|
+
.object({
|
|
59
|
+
type: z.literal('object'),
|
|
60
|
+
dict: z.record(z.string().regex(SAFE_KEY), ConfigSchemaNodeSchema),
|
|
61
|
+
meta: ConfigSchemaMetaSchema.optional(),
|
|
62
|
+
})
|
|
63
|
+
.loose(),
|
|
64
|
+
z
|
|
65
|
+
.object({
|
|
66
|
+
type: z.enum(['string', 'number', 'natural', 'percent', 'boolean']),
|
|
67
|
+
meta: ConfigSchemaMetaSchema.optional(),
|
|
68
|
+
})
|
|
69
|
+
.loose(),
|
|
70
|
+
z.object({ type: z.literal('const'), value: JsonValueSchema, meta: ConfigSchemaMetaSchema.optional() }).loose(),
|
|
71
|
+
z
|
|
72
|
+
.object({
|
|
73
|
+
type: z.literal('union'),
|
|
74
|
+
list: z.array(ConfigSchemaNodeSchema).min(1).max(64),
|
|
75
|
+
meta: ConfigSchemaMetaSchema.optional(),
|
|
76
|
+
})
|
|
77
|
+
.loose(),
|
|
78
|
+
z
|
|
79
|
+
.object({ type: z.literal('array'), inner: ConfigSchemaNodeSchema, meta: ConfigSchemaMetaSchema.optional() })
|
|
80
|
+
.loose(),
|
|
81
|
+
]),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
/** A whole configuration document: always an object at the root. */
|
|
85
|
+
export const ConfigSchemaDocumentSchema = ConfigSchemaNodeSchema.refine(
|
|
86
|
+
(node) => node.type === 'object',
|
|
87
|
+
'配置 Schema 的根节点必须是 object。',
|
|
88
|
+
).refine((node) => new TextEncoder().encode(JSON.stringify(node)).length <= CONFIG_SCHEMA_MAX_BYTES, {
|
|
89
|
+
message: `配置 Schema 不能超过 ${CONFIG_SCHEMA_MAX_BYTES} 字节。`,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
export type ConfigSchemaDocument = Extract<ConfigSchemaNode, { readonly type: 'object' }>
|
|
93
|
+
|
|
94
|
+
export const EMPTY_CONFIG_SCHEMA: ConfigSchemaDocument = { type: 'object', dict: {} }
|
|
95
|
+
|
|
96
|
+
export type ConfigFieldKind = 'string' | 'number' | 'boolean' | 'enum' | 'secret' | 'array' | 'object'
|
|
97
|
+
|
|
98
|
+
export interface ConfigField {
|
|
99
|
+
readonly key: string
|
|
100
|
+
readonly kind: ConfigFieldKind
|
|
101
|
+
readonly title: string
|
|
102
|
+
readonly hint?: string
|
|
103
|
+
readonly required: boolean
|
|
104
|
+
readonly default?: JsonValue
|
|
105
|
+
readonly advanced: boolean
|
|
106
|
+
readonly group?: string
|
|
107
|
+
readonly options?: readonly { readonly value: JsonValue; readonly label: string }[]
|
|
108
|
+
readonly node: ConfigSchemaNode
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const configDescription = (meta: ConfigSchemaMeta | undefined): string | undefined => {
|
|
112
|
+
const description = meta?.description
|
|
113
|
+
if (typeof description === 'string') return description
|
|
114
|
+
if (description) return description['zh-CN'] ?? description['zh'] ?? Object.values(description)[0]
|
|
115
|
+
return undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const isEnumUnion = (node: ConfigSchemaNode): node is Extract<ConfigSchemaNode, { readonly type: 'union' }> =>
|
|
119
|
+
node.type === 'union' && node.list.every((option) => option.type === 'const')
|
|
120
|
+
|
|
121
|
+
export const configFieldKind = (node: ConfigSchemaNode): ConfigFieldKind => {
|
|
122
|
+
if (node.meta?.role === 'secret') return 'secret'
|
|
123
|
+
if (node.type === 'string') return 'string'
|
|
124
|
+
if (node.type === 'number' || node.type === 'natural' || node.type === 'percent') return 'number'
|
|
125
|
+
if (node.type === 'boolean') return 'boolean'
|
|
126
|
+
if (node.type === 'array') return 'array'
|
|
127
|
+
if (node.type === 'object') return 'object'
|
|
128
|
+
if (node.type === 'const' || isEnumUnion(node)) return 'enum'
|
|
129
|
+
throw new TypeError('配置 Schema 只支持常量组成的 union。')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Flattens the top level of a document into product form fields (order preserved). */
|
|
133
|
+
const jsonLabel = (value: JsonValue): string => (typeof value === 'string' ? value : JSON.stringify(value))
|
|
134
|
+
|
|
135
|
+
export const configFields = (document: ConfigSchemaDocument): readonly ConfigField[] =>
|
|
136
|
+
Object.entries(document.dict).map(([key, node]) => {
|
|
137
|
+
const kind = configFieldKind(node)
|
|
138
|
+
const title = configDescription(node.meta) ?? key
|
|
139
|
+
const options =
|
|
140
|
+
node.type === 'union' && isEnumUnion(node)
|
|
141
|
+
? node.list.map((option) => ({
|
|
142
|
+
value: option.type === 'const' ? option.value : null,
|
|
143
|
+
label: configDescription(option.meta) ?? (option.type === 'const' ? jsonLabel(option.value) : ''),
|
|
144
|
+
}))
|
|
145
|
+
: node.type === 'const'
|
|
146
|
+
? [{ value: node.value, label: configDescription(node.meta) ?? jsonLabel(node.value) }]
|
|
147
|
+
: undefined
|
|
148
|
+
return {
|
|
149
|
+
key,
|
|
150
|
+
kind,
|
|
151
|
+
title,
|
|
152
|
+
...(node.meta?.hint === undefined ? {} : { hint: node.meta.hint }),
|
|
153
|
+
required: node.meta?.required === true,
|
|
154
|
+
...(node.meta?.default === undefined ? {} : { default: node.meta.default }),
|
|
155
|
+
advanced: node.meta?.advanced === true,
|
|
156
|
+
...(node.meta?.group === undefined ? {} : { group: node.meta.group }),
|
|
157
|
+
...(options === undefined ? {} : { options }),
|
|
158
|
+
node,
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
export const configSecretKeys = (document: ConfigSchemaDocument): readonly string[] =>
|
|
163
|
+
configFields(document)
|
|
164
|
+
.filter((field) => field.kind === 'secret')
|
|
165
|
+
.map((field) => field.key)
|
|
166
|
+
|
|
167
|
+
export interface ConfigValidationIssue {
|
|
168
|
+
readonly path: string
|
|
169
|
+
readonly message: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, JsonValue>> =>
|
|
173
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
174
|
+
|
|
175
|
+
const sameJson = (left: JsonValue, right: JsonValue): boolean => JSON.stringify(left) === JSON.stringify(right)
|
|
176
|
+
|
|
177
|
+
const validateNode = (
|
|
178
|
+
node: ConfigSchemaNode,
|
|
179
|
+
value: JsonValue | undefined,
|
|
180
|
+
path: string,
|
|
181
|
+
label: string,
|
|
182
|
+
issues: ConfigValidationIssue[],
|
|
183
|
+
): JsonValue | undefined => {
|
|
184
|
+
const resolved = value ?? node.meta?.default
|
|
185
|
+
if (resolved === undefined || resolved === null) {
|
|
186
|
+
if (node.meta?.required === true) issues.push({ path, message: `请填写${label}。` })
|
|
187
|
+
if (node.type === 'object') return validateNode(node, {}, path, label, issues)
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
switch (node.type) {
|
|
191
|
+
case 'object': {
|
|
192
|
+
if (!isRecord(resolved)) {
|
|
193
|
+
issues.push({ path, message: `${label}必须是对象。` })
|
|
194
|
+
return undefined
|
|
195
|
+
}
|
|
196
|
+
const output: Record<string, JsonValue> = {}
|
|
197
|
+
for (const key of Object.keys(resolved)) {
|
|
198
|
+
if (!Object.hasOwn(node.dict, key))
|
|
199
|
+
issues.push({ path: path ? `${path}.${key}` : key, message: `未知配置项:${key}` })
|
|
200
|
+
}
|
|
201
|
+
for (const [key, child] of Object.entries(node.dict)) {
|
|
202
|
+
const childPath = path ? `${path}.${key}` : key
|
|
203
|
+
const next = validateNode(child, resolved[key], childPath, configDescription(child.meta) ?? key, issues)
|
|
204
|
+
if (next !== undefined) output[key] = next
|
|
205
|
+
}
|
|
206
|
+
return output
|
|
207
|
+
}
|
|
208
|
+
case 'string': {
|
|
209
|
+
if (typeof resolved !== 'string') {
|
|
210
|
+
issues.push({ path, message: `${label}必须是文本。` })
|
|
211
|
+
return undefined
|
|
212
|
+
}
|
|
213
|
+
if (node.meta?.required === true && resolved.trim().length === 0)
|
|
214
|
+
issues.push({ path, message: `请填写${label}。` })
|
|
215
|
+
const pattern = node.meta?.pattern
|
|
216
|
+
if (pattern && resolved.length > 0 && !new RegExp(pattern.source, pattern.flags ?? '').test(resolved)) {
|
|
217
|
+
issues.push({ path, message: `${label}的格式不正确。` })
|
|
218
|
+
}
|
|
219
|
+
return resolved
|
|
220
|
+
}
|
|
221
|
+
case 'number':
|
|
222
|
+
case 'natural':
|
|
223
|
+
case 'percent': {
|
|
224
|
+
if (typeof resolved !== 'number' || !Number.isFinite(resolved)) {
|
|
225
|
+
issues.push({ path, message: `${label}必须是数字。` })
|
|
226
|
+
return undefined
|
|
227
|
+
}
|
|
228
|
+
if (node.type === 'natural' && (!Number.isInteger(resolved) || resolved < 0)) {
|
|
229
|
+
issues.push({ path, message: `${label}必须是非负整数。` })
|
|
230
|
+
}
|
|
231
|
+
if (node.type === 'percent' && (resolved < 0 || resolved > 1))
|
|
232
|
+
issues.push({ path, message: `${label}必须在 0 到 1 之间。` })
|
|
233
|
+
if (node.meta?.min !== undefined && resolved < node.meta.min)
|
|
234
|
+
issues.push({ path, message: `${label}不能小于 ${node.meta.min}。` })
|
|
235
|
+
if (node.meta?.max !== undefined && resolved > node.meta.max)
|
|
236
|
+
issues.push({ path, message: `${label}不能大于 ${node.meta.max}。` })
|
|
237
|
+
return resolved
|
|
238
|
+
}
|
|
239
|
+
case 'boolean':
|
|
240
|
+
if (typeof resolved !== 'boolean') {
|
|
241
|
+
issues.push({ path, message: `${label}必须是开关值。` })
|
|
242
|
+
return undefined
|
|
243
|
+
}
|
|
244
|
+
return resolved
|
|
245
|
+
case 'const':
|
|
246
|
+
if (!sameJson(resolved, node.value)) issues.push({ path, message: `${label}的取值无效。` })
|
|
247
|
+
return resolved
|
|
248
|
+
case 'union':
|
|
249
|
+
if (!node.list.some((option) => option.type === 'const' && sameJson(option.value, resolved))) {
|
|
250
|
+
issues.push({ path, message: `${label}的取值无效。` })
|
|
251
|
+
}
|
|
252
|
+
return resolved
|
|
253
|
+
case 'array': {
|
|
254
|
+
if (!Array.isArray(resolved)) {
|
|
255
|
+
issues.push({ path, message: `${label}必须是列表。` })
|
|
256
|
+
return undefined
|
|
257
|
+
}
|
|
258
|
+
return resolved.flatMap((item, index) => {
|
|
259
|
+
const next = validateNode(node.inner, item, `${path}[${index}]`, `${label}第 ${index + 1} 项`, issues)
|
|
260
|
+
return next === undefined ? [] : [next]
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Validates a configuration value against a document and fills declared defaults. Secret fields are not part of the
|
|
268
|
+
* stored value; callers pass them through the credential store instead.
|
|
269
|
+
*/
|
|
270
|
+
export const validateConfigValue = (
|
|
271
|
+
document: ConfigSchemaDocument,
|
|
272
|
+
value: JsonValue | undefined,
|
|
273
|
+
options: { readonly skipKeys?: readonly string[] } = {},
|
|
274
|
+
): { readonly value: Readonly<Record<string, JsonValue>>; readonly issues: readonly ConfigValidationIssue[] } => {
|
|
275
|
+
const skip = new Set(options.skipKeys ?? [])
|
|
276
|
+
const issues: ConfigValidationIssue[] = []
|
|
277
|
+
const input = isRecord(value) ? value : value === undefined || value === null ? {} : undefined
|
|
278
|
+
if (input === undefined) return { value: {}, issues: [{ path: '', message: '配置必须是对象。' }] }
|
|
279
|
+
const filtered: ConfigSchemaDocument = {
|
|
280
|
+
...document,
|
|
281
|
+
dict: Object.fromEntries(Object.entries(document.dict).filter(([key]) => !skip.has(key))),
|
|
282
|
+
}
|
|
283
|
+
const output = validateNode(filtered, input, '', '配置', issues)
|
|
284
|
+
return { value: isRecord(output) ? output : {}, issues }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Throws the first validation issue as a user-readable error. */
|
|
288
|
+
export const parseConfigValue = (
|
|
289
|
+
document: ConfigSchemaDocument,
|
|
290
|
+
value: JsonValue | undefined,
|
|
291
|
+
options: { readonly skipKeys?: readonly string[] } = {},
|
|
292
|
+
): Readonly<Record<string, JsonValue>> => {
|
|
293
|
+
const result = validateConfigValue(document, value, options)
|
|
294
|
+
const first = result.issues[0]
|
|
295
|
+
if (first) throw new TypeError(first.message)
|
|
296
|
+
return result.value
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface FieldOptions<Value extends JsonValue> {
|
|
300
|
+
readonly hint?: string
|
|
301
|
+
readonly default?: Value
|
|
302
|
+
readonly required?: boolean
|
|
303
|
+
readonly advanced?: boolean
|
|
304
|
+
readonly group?: string
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const fieldMeta = <Value extends JsonValue>(title: string, options: FieldOptions<Value> = {}): ConfigSchemaMeta => ({
|
|
308
|
+
description: title,
|
|
309
|
+
...(options.hint === undefined ? {} : { hint: options.hint }),
|
|
310
|
+
...(options.default === undefined ? {} : { default: options.default }),
|
|
311
|
+
...(options.required === undefined ? {} : { required: options.required }),
|
|
312
|
+
...(options.advanced === undefined ? {} : { advanced: options.advanced }),
|
|
313
|
+
...(options.group === undefined ? {} : { group: options.group }),
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
/** Builds serialized Schemastery documents without depending on the Schemastery runtime. */
|
|
317
|
+
export const configSchema = {
|
|
318
|
+
object: (dict: Readonly<Record<string, ConfigSchemaNode>>): ConfigSchemaDocument => ({ type: 'object', dict }),
|
|
319
|
+
string: (title: string, options: FieldOptions<string> & { readonly multiline?: boolean } = {}): ConfigSchemaNode => ({
|
|
320
|
+
type: 'string',
|
|
321
|
+
meta: { ...fieldMeta(title, options), ...(options.multiline ? { role: 'textarea' } : {}) },
|
|
322
|
+
}),
|
|
323
|
+
secret: (title: string, options: Omit<FieldOptions<string>, 'default'> = {}): ConfigSchemaNode => ({
|
|
324
|
+
type: 'string',
|
|
325
|
+
meta: { ...fieldMeta(title, options), role: 'secret' },
|
|
326
|
+
}),
|
|
327
|
+
number: (
|
|
328
|
+
title: string,
|
|
329
|
+
options: FieldOptions<number> & { readonly min?: number; readonly max?: number; readonly integer?: boolean } = {},
|
|
330
|
+
): ConfigSchemaNode => ({
|
|
331
|
+
type: options.integer ? 'natural' : 'number',
|
|
332
|
+
meta: {
|
|
333
|
+
...fieldMeta(title, options),
|
|
334
|
+
...(options.min === undefined ? {} : { min: options.min }),
|
|
335
|
+
...(options.max === undefined ? {} : { max: options.max }),
|
|
336
|
+
},
|
|
337
|
+
}),
|
|
338
|
+
boolean: (title: string, options: FieldOptions<boolean> = {}): ConfigSchemaNode => ({
|
|
339
|
+
type: 'boolean',
|
|
340
|
+
meta: fieldMeta(title, options),
|
|
341
|
+
}),
|
|
342
|
+
enum: (
|
|
343
|
+
title: string,
|
|
344
|
+
choices: readonly { readonly value: string | number; readonly label: string }[],
|
|
345
|
+
options: FieldOptions<string | number> = {},
|
|
346
|
+
): ConfigSchemaNode => ({
|
|
347
|
+
type: 'union',
|
|
348
|
+
list: choices.map((choice) => ({ type: 'const', value: choice.value, meta: { description: choice.label } })),
|
|
349
|
+
meta: fieldMeta(title, options),
|
|
350
|
+
}),
|
|
351
|
+
} as const
|