@coda-console/protocol 0.0.0-stage → 1.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.
@@ -0,0 +1,242 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://coda.pub/schemas/plugin/v1/setting.json",
4
+ "title": "一项设置的声明",
5
+ "description": "插件用 JSON Schema 声明设置,Coda 读的是其中一部分:单个值;多选(items.anyOf 的常量);多个模型服务;一列行;表格(additionalProperties 是每一项)。表格的每一项是单个值、Agent 组合或几列;一列是单个值或 Agent 组合。格式只认 coda-agent(新会话用什么启动)、coda-agent-match(表格的行名:哪些会话)、duration(ISO 8601 时长)、coda-repo、coda-directory、coda-model-provider(别的插件提供的模型服务)、coda-model-url(模型服务的地址,Key 与接口写在同组的 modelKey、modelApi)。writeOnly 是密文:只写不读,放在数据库旁的文件里。",
6
+ "$ref": "#/$defs/setting",
7
+ "$defs": {
8
+ "base": {
9
+ "type": "object",
10
+ "properties": {
11
+ "type": {"description": "type 取 boolean、integer、number、string、object、array", "enum": ["boolean", "integer", "number", "string", "object", "array"]},
12
+ "title": {"type": "string"},
13
+ "description": {"type": "string"},
14
+ "markdownDescription": {"type": "string"},
15
+ "order": {"type": "number"},
16
+ "default": true,
17
+ "enum": {"description": "enum 是非空的数组", "type": "array", "minItems": 1},
18
+ "enumDescriptions": {"type": "array"},
19
+ "enumItemLabels": {"description": "enumItemLabels 是每个选项显示的文字,各不超过 100 字", "type": "array", "items": {"type": "string", "maxLength": 100}},
20
+ "minimum": {"type": "number"},
21
+ "maximum": {"type": "number"},
22
+ "format": {
23
+ "description": "format 只认 coda-agent、coda-agent-match、duration、coda-repo、coda-directory、coda-model-provider、coda-model-url",
24
+ "enum": ["coda-agent", "coda-agent-match", "duration", "coda-repo", "coda-directory", "coda-model-provider", "coda-model-url"]
25
+ }
26
+ },
27
+ "required": ["type"],
28
+ "allOf": [
29
+ {
30
+ "if": {"properties": {"format": {"const": "coda-agent"}}, "required": ["format"]},
31
+ "then": {"description": "coda-agent 是对象", "properties": {"type": {"const": "object"}}}
32
+ },
33
+ {
34
+ "if": {
35
+ "properties": {"format": {"enum": ["duration", "coda-repo", "coda-directory", "coda-model-provider", "coda-model-url"]}},
36
+ "required": ["format"]
37
+ },
38
+ "then": {"description": "duration、coda-repo、coda-directory、coda-model-provider、coda-model-url 是文本", "properties": {"type": {"const": "string"}}}
39
+ },
40
+ {"if": {"required": ["enum"]}, "then": {"description": "enum 只用于文本与数字", "properties": {"type": {"enum": ["string", "integer", "number"]}}}},
41
+ {"if": {"required": ["enumItemLabels"]}, "then": {"description": "enumItemLabels 跟着 enum 写", "required": ["enum"]}},
42
+ {
43
+ "if": {"anyOf": [{"required": ["modelKey"]}, {"required": ["modelApi"]}]},
44
+ "then": {
45
+ "description": "modelKey 与 modelApi 只写在 coda-model-url 的地址上",
46
+ "properties": {"format": {"const": "coda-model-url"}},
47
+ "required": ["format"]
48
+ }
49
+ }
50
+ ]
51
+ },
52
+ "secret": {"description": "writeOnly 是开或关", "type": "boolean"},
53
+ "modelKey": {
54
+ "description": "modelKey 是同组的一个密文的名字",
55
+ "type": "string",
56
+ "x-ref": {"pointer": "2", "match": {"properties": {"writeOnly": {"const": true}}, "required": ["writeOnly"]}}
57
+ },
58
+ "modelApi": {
59
+ "description": "modelApi 是同组的一个文本(不是密文)的名字",
60
+ "type": "string",
61
+ "x-ref": {"pointer": "2", "match": {"properties": {"type": {"const": "string"}, "writeOnly": {"const": false}}, "required": ["type"]}}
62
+ },
63
+ "setting": {
64
+ "description": "插件的一项设置",
65
+ "$ref": "#/$defs/base",
66
+ "properties": {
67
+ "writeOnly": {"$ref": "#/$defs/secret"},
68
+ "modelKey": {"$ref": "#/$defs/modelKey"},
69
+ "modelApi": {"$ref": "#/$defs/modelApi"},
70
+ "items": true,
71
+ "uniqueItems": {"description": "uniqueItems 只能是 true(每项至多一次)", "const": true},
72
+ "additionalProperties": true,
73
+ "propertyNames": true
74
+ },
75
+ "unevaluatedProperties": false,
76
+ "allOf": [
77
+ {
78
+ "if": {"properties": {"writeOnly": {"const": true}}, "required": ["writeOnly"]},
79
+ "then": {
80
+ "description": "密文设置(writeOnly)只能是没有 format、enum 与默认值的文本",
81
+ "properties": {"type": {"const": "string"}},
82
+ "not": {"anyOf": [{"required": ["format"]}, {"required": ["enum"]}, {"required": ["default"]}]}
83
+ }
84
+ },
85
+ {
86
+ "if": {"properties": {"type": {"const": "array"}}, "required": ["type"]},
87
+ "then": {"$ref": "#/$defs/array"},
88
+ "else": {
89
+ "description": "只有多选、多个模型服务与一列行(type: array)才写 items 与 uniqueItems",
90
+ "not": {"anyOf": [{"required": ["items"]}, {"required": ["uniqueItems"]}]}
91
+ }
92
+ },
93
+ {
94
+ "if": {
95
+ "properties": {"type": {"const": "object"}},
96
+ "required": ["type"],
97
+ "not": {"properties": {"format": {"const": "coda-agent"}}, "required": ["format"]}
98
+ },
99
+ "then": {
100
+ "description": "表格写 additionalProperties(每一项是什么);propertyNames 只能是 { format: coda-agent-match }",
101
+ "required": ["additionalProperties"],
102
+ "properties": {
103
+ "additionalProperties": {"$ref": "#/$defs/entry"},
104
+ "propertyNames": {
105
+ "description": "propertyNames 只支持 format: coda-agent-match",
106
+ "type": "object",
107
+ "properties": {"format": {"const": "coda-agent-match"}},
108
+ "required": ["format"],
109
+ "additionalProperties": false
110
+ }
111
+ }
112
+ }
113
+ }
114
+ ]
115
+ },
116
+ "array": {
117
+ "description": "多选、多个模型服务与一列行写 items,不写 format、minimum 与 maximum",
118
+ "required": ["items"],
119
+ "not": {"anyOf": [{"required": ["format"]}, {"required": ["minimum"]}, {"required": ["maximum"]}]},
120
+ "if": {"properties": {"items": {"properties": {"format": {"const": "coda-model-provider"}}, "required": ["format"]}}},
121
+ "then": {"properties": {"items": {"$ref": "#/$defs/modelProviders"}}},
122
+ "else": {
123
+ "if": {"properties": {"items": {"properties": {"type": {"const": "object"}}, "required": ["type"]}}},
124
+ "then": {"description": "一列行不写 uniqueItems", "not": {"required": ["uniqueItems"]}, "properties": {"items": {"$ref": "#/$defs/rows"}}},
125
+ "else": {"properties": {"items": {"$ref": "#/$defs/options"}}}
126
+ }
127
+ },
128
+ "modelProviders": {
129
+ "description": "多个模型服务写 items: { type: string, format: coda-model-provider, modelApis }",
130
+ "type": "object",
131
+ "properties": {
132
+ "type": {"const": "string"},
133
+ "format": {"const": "coda-model-provider"},
134
+ "modelApis": {
135
+ "description": "modelApis 是 1–10 个接口名,取 openai-completions、openai-responses、anthropic-messages",
136
+ "type": "array",
137
+ "minItems": 1,
138
+ "maxItems": 10,
139
+ "items": {"enum": ["openai-completions", "openai-responses", "anthropic-messages"]}
140
+ }
141
+ },
142
+ "required": ["type", "format"],
143
+ "additionalProperties": false
144
+ },
145
+ "rows": {
146
+ "description": "一列行写 items: { type: object, properties, required }",
147
+ "$ref": "#/$defs/entry",
148
+ "required": ["properties"],
149
+ "not": {"required": ["format"]},
150
+ "properties": {
151
+ "properties": {
152
+ "additionalProperties": {"description": "一列行的列不能是密文,要密文用表格", "not": {"properties": {"writeOnly": {"const": true}}, "required": ["writeOnly"]}}
153
+ }
154
+ }
155
+ },
156
+ "options": {
157
+ "description": "多选写 items: { anyOf: [{ const, title, description }] },1–50 项",
158
+ "type": "object",
159
+ "properties": {
160
+ "anyOf": {
161
+ "type": "array",
162
+ "minItems": 1,
163
+ "maxItems": 50,
164
+ "items": {
165
+ "description": "多选的一项是 { const, title, description }:const 只用字母、数字和「._-」,title 与 description 不超过 400 字",
166
+ "type": "object",
167
+ "properties": {
168
+ "const": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"},
169
+ "title": {"type": "string", "maxLength": 400},
170
+ "description": {"type": "string", "maxLength": 400}
171
+ },
172
+ "required": ["const"],
173
+ "additionalProperties": false
174
+ },
175
+ "x-unique-by": ["const"]
176
+ }
177
+ },
178
+ "required": ["anyOf"],
179
+ "additionalProperties": false
180
+ },
181
+ "entry": {
182
+ "description": "表格的一项:单个值、Agent 组合,或 properties 写明的 1–20 列",
183
+ "$ref": "#/$defs/base",
184
+ "properties": {"properties": true, "required": true},
185
+ "unevaluatedProperties": false,
186
+ "allOf": [
187
+ {"if": {"properties": {"type": {"const": "array"}}, "required": ["type"]}, "then": {"description": "多选与一列行只能是插件的一项设置,不能放进表格", "not": {}}},
188
+ {
189
+ "if": {"properties": {"type": {"const": "object"}}, "required": ["type"]},
190
+ "then": {
191
+ "description": "表格的每一项只能是单个值、Agent 组合,或 properties 写明的 1–20 列",
192
+ "anyOf": [{"properties": {"format": {"const": "coda-agent"}}, "required": ["format"]}, {"required": ["properties"]}]
193
+ }
194
+ },
195
+ {
196
+ "if": {"anyOf": [{"required": ["properties"]}, {"required": ["required"]}]},
197
+ "then": {"description": "只有一行几列时才写 properties 与 required", "properties": {"type": {"const": "object"}}, "not": {"required": ["format"]}}
198
+ },
199
+ {"if": {"required": ["properties"]}, "then": {"$ref": "#/$defs/row"}}
200
+ ]
201
+ },
202
+ "row": {
203
+ "properties": {
204
+ "properties": {
205
+ "description": "一行写 1–20 列;一行是一组,至多一个模型地址(coda-model-url):检查可用模型按组找它",
206
+ "type": "object",
207
+ "minProperties": 1,
208
+ "maxProperties": 20,
209
+ "propertyNames": {"description": "列名只用字母和数字,以小写字母开头", "pattern": "^[a-z][A-Za-z0-9]{0,39}$"},
210
+ "additionalProperties": {"$ref": "#/$defs/column"},
211
+ "x-max-contains": {"match": {"properties": {"format": {"const": "coda-model-url"}}, "required": ["format"]}, "max": 1}
212
+ },
213
+ "required": {
214
+ "description": "required 是本行的列名,不重复",
215
+ "type": "array",
216
+ "uniqueItems": true,
217
+ "items": {"description": "required 是本行的列名,不重复", "type": "string", "x-ref": {"pointer": "2/properties"}}
218
+ }
219
+ }
220
+ },
221
+ "column": {
222
+ "description": "一列:单个值或 Agent 组合",
223
+ "$ref": "#/$defs/base",
224
+ "properties": {"writeOnly": {"$ref": "#/$defs/secret"}, "modelKey": {"$ref": "#/$defs/modelKey"}, "modelApi": {"$ref": "#/$defs/modelApi"}},
225
+ "unevaluatedProperties": false,
226
+ "allOf": [
227
+ {
228
+ "if": {"properties": {"writeOnly": {"const": true}}, "required": ["writeOnly"]},
229
+ "then": {
230
+ "description": "密文设置(writeOnly)只能是没有 format、enum 与默认值的文本",
231
+ "properties": {"type": {"const": "string"}},
232
+ "not": {"anyOf": [{"required": ["format"]}, {"required": ["enum"]}, {"required": ["default"]}]}
233
+ }
234
+ },
235
+ {
236
+ "if": {"properties": {"type": {"enum": ["object", "array"]}}, "required": ["type"]},
237
+ "then": {"description": "一列只能是单个值或 Agent 组合", "properties": {"format": {"const": "coda-agent"}}, "required": ["format"]}
238
+ }
239
+ ]
240
+ }
241
+ }
242
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://coda.pub/schemas/plugin/v1/setup.json",
4
+ "title": "contributes.setup:准备运行环境",
5
+ "description": "用户安装或更新插件后运行的一行命令。",
6
+ "$ref": "common.json#/$defs/commandHolder"
7
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://coda.pub/schemas/plugin/v1/skills.json",
4
+ "title": "contributes.skills:插件带来的 Agent Skill",
5
+ "description": "插件目录里的目录,每个有自己的 SKILL.md;Agent 按目录名找到它,所以目录名不重复。",
6
+ "type": "array",
7
+ "maxItems": 100,
8
+ "items": {
9
+ "description": "插件目录里的相对路径,目录名只用字母、数字、「.」「_」「-」,里面有 SKILL.md",
10
+ "type": "string",
11
+ "pattern": "^(?!/)(?!(.*/)?\\.\\.(/|$))(?!\\.?/*$)(.*/)?[A-Za-z0-9][A-Za-z0-9._-]{0,63}/*$",
12
+ "x-file": {"type": "directory", "contains": "SKILL.md"}
13
+ },
14
+ "x-unique-by": [{"part": "([^/]+)/*$"}]
15
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://coda.pub/schemas/plugin/v1/views.json",
4
+ "title": "contributes.views:客户端为插件打开的页面",
5
+ "description": "插件目录里的 HTML,客户端在沙箱里开:在哪常驻(side 会话旁、page 一页),图标,接管打开的引用种类(handles,像 VS Code 的自定义编辑器),读数(readout)与只给哪种工作位置的会话(checkout)。",
6
+ "type": "array",
7
+ "maxItems": 10,
8
+ "items": {
9
+ "type": "object",
10
+ "properties": {
11
+ "id": {"$ref": "common.json#/$defs/id"},
12
+ "title": {"$ref": "common.json#/$defs/line40"},
13
+ "entry": {"description": "插件目录里的 .html 文件", "type": "string", "pattern": "^(?!/)(?!(.*/)?\\.\\.(/|$)).*\\.html$", "x-file": {"type": "file"}},
14
+ "in": {"description": "它常驻的位置:side、page,不重复", "type": "array", "uniqueItems": true, "items": {"enum": ["side", "page"]}},
15
+ "icon": {"$ref": "common.json#/$defs/symbol"},
16
+ "handles": {
17
+ "description": "它接管打开的引用种类(如 workflow-runs),至多 8 个,不重复",
18
+ "type": "array",
19
+ "maxItems": 8,
20
+ "uniqueItems": true,
21
+ "items": {"$ref": "common.json#/$defs/id"}
22
+ },
23
+ "readout": {"description": "读数:workflow-runs、machines、changes", "enum": ["workflow-runs", "machines", "changes"]},
24
+ "checkout": {"description": "只给哪种工作位置的会话:worktree", "enum": ["worktree"]}
25
+ },
26
+ "required": ["id", "title", "entry"],
27
+ "additionalProperties": false
28
+ },
29
+ "x-unique-by": ["id"]
30
+ }
package/validate.mjs ADDED
@@ -0,0 +1,182 @@
1
+ // A plugin is right when what it writes is of the types it names: each key of a manifest's `contributes` is a type with a
2
+ // JSON Schema of its own (schemas/plugin/, `https://coda.pub/schemas/plugin/v1/…`), and a key that is no type of Coda's is
3
+ // one a plugin's extension point declares, for that plugin to read. Checking one is checking it against those schemas,
4
+ // nothing else: a field joins a type by joining its schema, never by a rule here. Coda checks a plugin with this module
5
+ // when it reads one, and coda-plugins' contract check does where a plugin is written.
6
+ // What JSON Schema cannot say of one value alone it says with five keywords, each meaning one thing wherever it is written,
7
+ // as Kubernetes extends its OpenAPI with `x-kubernetes-*`:
8
+ // - `x-unique-by`: [name or { part }] — in an array, each named property of its objects is unique among the items having it,
9
+ // and so is `part`'s first group of each of its texts (a directory's last name);
10
+ // - `x-max-contains`: { match, max } — of an object's values, at most `max` satisfy `match` (`maxContains` of an object);
11
+ // - `x-ref`: { pointer, part?, match? } — the value (or `part`'s first group of it) names a key of the object, or an element
12
+ // of the array, at `pointer` in the manifest: absolute (`/contributes/configuration/properties`), or relative to the value
13
+ // (`2/properties`: two levels up, then down); what it names satisfies `match` when that is given;
14
+ // - `x-placeholders`: { names?, config? } — every `${…}` in the text is one of `names`, or `config:<setting>` naming one of
15
+ // the plugin's settings that satisfies `config`;
16
+ // - `x-file`: { type, under?, suffix?, contains?, maxBytes?, magic?, absent? } — the value is a path in the plugin's directory
17
+ // (with `under` before it and `suffix` after it) that never leaves it, of that type, holding `contains`, at most
18
+ // `maxBytes` long, starting with `magic`; with `absent`, a path there is none at. Checked when the directory is known.
19
+ // It imports nothing but Node and Ajv.
20
+ import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs';
21
+ import { isAbsolute, join, normalize, sep } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import Ajv2020 from 'ajv/dist/2020.js';
24
+
25
+ export const SCHEMA_DIR = fileURLToPath(new URL('./schemas/plugin/', import.meta.url));
26
+ const BASE = 'https://coda.pub/schemas/plugin/v1/';
27
+ // The schemas, by file name.
28
+ export const SCHEMAS = Object.fromEntries(readdirSync(SCHEMA_DIR).filter(file => file.endsWith('.json')).sort()
29
+ .map(file => [file, JSON.parse(readFileSync(join(SCHEMA_DIR, file), 'utf8'))]));
30
+ // The types a plugin may contribute to Coda itself, as the manifest's schema names them.
31
+ export const TYPES = Object.keys(SCHEMAS['plugin.json'].properties.contributes.properties);
32
+
33
+ // What a pointer names, from where a keyword is: the manifest's root, or levels up from the value itself.
34
+ function locate(pointer, { rootData, instancePath }) {
35
+ const relative = /^(\d+)(.*)$/.exec(pointer);
36
+ const up = relative ? Number(relative[1]) : 0, rest = relative ? relative[2] : pointer;
37
+ const here = instancePath.split('/').slice(1).map(unescape);
38
+ if (up > here.length) return undefined;
39
+ const steps = [...(relative ? here.slice(0, here.length - up) : []), ...rest.split('/').slice(1).map(unescape)];
40
+ return steps.reduce((at, step) => at !== null && typeof at === 'object' && Object.hasOwn(at, step) ? at[step] : undefined, rootData);
41
+ }
42
+ const unescape = step => step.replace(/~1/g, '/').replace(/~0/g, '~');
43
+ const fail = (validate, keyword, message, parentSchema) => { validate.errors = [{ keyword, message, params: {}, parentSchema }]; return false; };
44
+
45
+ function keywords(ajv) {
46
+ ajv.addKeyword({
47
+ keyword: 'x-max-contains', type: 'object', schemaType: 'object',
48
+ compile({ match, max }) {
49
+ const matcher = ajv.compile(match);
50
+ return value => Object.values(value).filter(each => matcher(each)).length <= max;
51
+ },
52
+ });
53
+ ajv.addKeyword({
54
+ keyword: 'x-unique-by', type: 'array', schemaType: 'array', errors: true,
55
+ validate: function unique(keys, items, parentSchema) {
56
+ for (const key of keys) {
57
+ const part = typeof key === 'object' ? new RegExp(key.part, 'u') : null, seen = new Set();
58
+ for (const item of items) {
59
+ const value = part ? (typeof item === 'string' ? part.exec(item)?.[1] : undefined)
60
+ : item && typeof item === 'object' && Object.hasOwn(item, key) ? item[key] : undefined;
61
+ if (value === undefined) continue;
62
+ const said = JSON.stringify(value);
63
+ if (seen.has(said)) return fail(unique, 'x-unique-by', part ? `不能重复:${value}` : `${key} 不能重复:${value}`, parentSchema);
64
+ seen.add(said);
65
+ }
66
+ }
67
+ return true;
68
+ },
69
+ });
70
+ // A reference: whether `name` is a key (or element) of what `pointer` names, and that satisfies `match`.
71
+ const resolves = (ref, matcher, name, cxt) => {
72
+ const at = locate(ref.pointer, cxt);
73
+ if (Array.isArray(at)) return at.includes(name);
74
+ if (!at || typeof at !== 'object' || !Object.hasOwn(at, name)) return false;
75
+ return !matcher || matcher(at[name]);
76
+ };
77
+ ajv.addKeyword({
78
+ keyword: 'x-ref', type: 'string', schemaType: 'object',
79
+ compile(ref) {
80
+ const matcher = ref.match ? ajv.compile(ref.match) : null, part = ref.part ? new RegExp(ref.part, 'u') : null;
81
+ return (value, cxt) => {
82
+ const name = part ? part.exec(value)?.[1] : value;
83
+ return name !== undefined && resolves(ref, matcher, name, cxt);
84
+ };
85
+ },
86
+ });
87
+ ajv.addKeyword({
88
+ keyword: 'x-placeholders', type: 'string', schemaType: 'object',
89
+ compile(allowed) {
90
+ const config = allowed.config ? { pointer: '/contributes/configuration/properties' } : null, matcher = allowed.config ? ajv.compile(allowed.config) : null;
91
+ return (value, cxt) => [...value.matchAll(/\$\{([^}]*)\}/g)].every(([, place]) => {
92
+ if ((allowed.names ?? []).includes(place)) return true;
93
+ const setting = /^config:(.+)$/.exec(place)?.[1];
94
+ return !!(config && setting && resolves(config, matcher, setting, cxt));
95
+ });
96
+ },
97
+ });
98
+ ajv.addKeyword({
99
+ keyword: 'x-file', type: 'string', schemaType: 'object', errors: true,
100
+ validate: function file(spec, value, parentSchema) {
101
+ const dir = this?.dir;
102
+ const path = normalize(spec.under ? join(spec.under, `${value}${spec.suffix ?? ''}`) : `${value}${spec.suffix ?? ''}`);
103
+ if (isAbsolute(path) || path === '.' || path === '..' || path.startsWith(`..${sep}`)) return fail(file, 'x-file', `${value} 须为不离开插件目录的相对路径`, parentSchema);
104
+ if (!dir) return true;
105
+ let found;
106
+ try { found = statSync(join(dir, path)); } catch { return spec.absent ? true : fail(file, 'x-file', `插件目录里没有 ${path}`, parentSchema); }
107
+ if (spec.absent) return fail(file, 'x-file', `插件目录里已有 ${path}`, parentSchema);
108
+ if (spec.type === 'directory' ? !found.isDirectory() : !found.isFile()) return fail(file, 'x-file', `${path} 须为插件目录里的${spec.type === 'directory' ? '目录' : '文件'}`, parentSchema);
109
+ if (spec.contains) try { if (!statSync(join(dir, path, spec.contains)).isFile()) throw new Error(); }
110
+ catch { return fail(file, 'x-file', `${path} 里没有 ${spec.contains}`, parentSchema); }
111
+ if (spec.maxBytes !== undefined && found.size > spec.maxBytes) return fail(file, 'x-file', `${path} 超过 ${spec.maxBytes / 1024} KiB`, parentSchema);
112
+ if (spec.magic) {
113
+ const head = Buffer.alloc(spec.magic.length), fd = openSync(join(dir, path), 'r');
114
+ try { readSync(fd, head, 0, head.length, 0); } finally { closeSync(fd); }
115
+ if (head.toString('latin1') !== spec.magic) return fail(file, 'x-file', `${path} 不是 ${spec.magic.replace(/\W/g, '')}`, parentSchema);
116
+ }
117
+ return true;
118
+ },
119
+ });
120
+ }
121
+
122
+ const ajv = new Ajv2020({ allErrors: false, verbose: true, passContext: true, strict: true, strictTypes: false, strictRequired: false, allowUnionTypes: true });
123
+ keywords(ajv);
124
+ for (const schema of Object.values(SCHEMAS)) ajv.addSchema(schema);
125
+ // The validators of the schemas here, by file.
126
+ const compiled = new Map();
127
+ const validator = file => {
128
+ if (!compiled.has(file)) compiled.set(file, ajv.getSchema(`${BASE}${file}`));
129
+ return compiled.get(file);
130
+ };
131
+
132
+ // Where in the manifest a value is, as a person writes it: `contributes.composerModes[0].in`.
133
+ const place = (path, base) => path.split('/').slice(1).map(unescape)
134
+ .reduce((at, step) => /^\d+$/.test(step) ? `${at}[${step}]` : !/^[A-Za-z_$][\w$]*$/.test(step) ? `${at}「${step}」` : at ? `${at}.${step}` : step, base);
135
+ const TYPE_NAMES = { array: '数组', object: '对象', string: '文本', number: '数字', integer: '整数', boolean: '开或关' };
136
+ // What the schema says of a failing value: the description nearest above the keyword that failed (a branch's `then`, a
137
+ // property's), found by where the error says it is, from the schema it failed in (the one a `$ref` names, any of these).
138
+ const nodes = function* (node) {
139
+ if (!node || typeof node !== 'object') return;
140
+ yield node;
141
+ for (const child of Object.values(node)) yield* nodes(child);
142
+ };
143
+ function described(error, { roots = true } = {}) {
144
+ if (!error.parentSchema) return undefined;
145
+ if (error.parentSchema.description) return roots || !error.parentSchema.$id ? error.parentSchema.description : undefined;
146
+ const steps = error.schemaPath.replace(/^#\//, '').split('/').map(unescape).slice(0, -1);
147
+ for (const root of Object.values(SCHEMAS)) for (const from of nodes(root)) {
148
+ const path = [from];
149
+ for (const step of steps) path.push(path.at(-1)?.[step]);
150
+ if (path.at(-1) !== error.parentSchema) continue;
151
+ const said = path.slice(0, -1).reverse().find(node => typeof node?.description === 'string');
152
+ return roots || !said?.$id ? said?.description : undefined;
153
+ }
154
+ return undefined;
155
+ }
156
+ // What is wrong, said once: the first error, by its place and what the schema says of the value there.
157
+ // What a whole type says of itself is too much for a key it lacks or one written twice: those say only what is wrong.
158
+ export function explain(errors, base = '') {
159
+ const [error] = errors;
160
+ const at = place(error.instancePath, base) || 'package.json', name = error.propertyName ? `「${error.propertyName}」` : '';
161
+ if (error.keyword === 'x-unique-by') return `${at}${name}:${error.message}`;
162
+ if (error.keyword === 'required') { const said = described(error, { roots: false }); return `${at} 缺 ${error.params.missingProperty}${said ? `:${said}` : ''}`; }
163
+ if (error.keyword === 'additionalProperties' || error.keyword === 'unevaluatedProperties')
164
+ return `${at} 里有不认识的键:${error.params.additionalProperty ?? error.params.unevaluatedProperty}`;
165
+ const said = described(error);
166
+ if (error.keyword === 'type') return `${at}${name} 须为${TYPE_NAMES[error.params.type] ?? error.params.type}${said ? `:${said}` : ''}`;
167
+ if (error.keyword === 'x-file') { const own = described(error, { roots: false }); return `${at}${name}:${error.message}${own ? `(${own})` : ''}`; }
168
+ return `${at}${name}:${said ?? error.message}`;
169
+ }
170
+
171
+ // A manifest checked against its schema, the files it names in `dir` when given. Returns null, or what is wrong.
172
+ export function manifestProblem(manifest, dir = null) {
173
+ const validate = validator('plugin.json');
174
+ return validate.call({ dir }, manifest) ? null : explain(validate.errors);
175
+ }
176
+ // A value checked against one of the schemas (`ref`: a file, with a fragment, as `composer-modes.json#/$defs/parameters`),
177
+ // as a plugin gives it while it runs: null, or what is wrong, at `where`.
178
+ export function valueProblem(ref, value, where) {
179
+ const validate = ajv.getSchema(`${BASE}${ref}`) ?? null;
180
+ if (!validate) throw new Error(`没有这个 schema:${ref}`);
181
+ return validate.call({ dir: null }, value) ? null : explain(validate.errors, where);
182
+ }