@coda-console/plugin-check 0.0.0-stage → 0.4.4
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 +314 -2
- package/bin/check.mjs +38 -0
- package/check/agent.mjs +416 -0
- package/check/coda.mjs +156 -0
- package/check/index.mjs +24 -0
- package/check/leftovers.mjs +24 -0
- package/check/manifest.mjs +456 -0
- package/check/panel.mjs +114 -0
- package/check/process.mjs +87 -0
- package/check/report.mjs +29 -0
- package/check/schema.mjs +18 -0
- package/check/waits.mjs +58 -0
- package/package.json +28 -4
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
// The manifest level: what Coda reads of a plugin's directory before anything of it runs. The declaration rules are
|
|
2
|
+
// copied from Coda's readPlugin (backend/src/domain/plugins.mjs) and the modules it calls (agent-plugins.mjs, panels.mjs,
|
|
3
|
+
// plugin-extensions.mjs), what a plugin from a repository may not be from plugin-sources.mjs (treeProblem, manifestProblem,
|
|
4
|
+
// catalogProblem); check/coda.mjs names the
|
|
5
|
+
// commit. Coda stops at the first thing it cannot read; here each part is checked on its own, so one run lists them all.
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
8
|
+
import { basename, isAbsolute, join, normalize, relative, sep } from 'node:path';
|
|
9
|
+
import { inflateSync } from 'node:zlib';
|
|
10
|
+
import { AGENT_ID, AGENT_LIMITS, BUILTIN_AGENTS, BUILTIN_COMMANDS, BUILTIN_GROUPS, BUILTIN_HERE, BUILTIN_REPOSITORY, CAPABILITY,
|
|
11
|
+
CODA_COMMANDS, CODA_EVENT_DOMAINS, COMMAND, COMMAND_LIMIT, BUILTIN_ONLY, CONTRIBUTES, EVENT, EVENT_LIMIT, DEPTH, ICON_LIMIT, INSTRUCTIONS_LIMIT, NPM, NPM_LOCK, PANEL_ID, PANEL_LIMITS, PLUGIN_ID, POINT,
|
|
12
|
+
SEGMENT, SERVICE, SETTING_KEY, SETUP_KEYS, SIZE_LIMIT } from './coda.mjs';
|
|
13
|
+
|
|
14
|
+
class Invalid extends Error {}
|
|
15
|
+
const check = (condition, message) => { if (!condition) throw new Invalid(message); };
|
|
16
|
+
const object = (value, where) => check(value && typeof value === 'object' && !Array.isArray(value), `${where} 须为对象`);
|
|
17
|
+
const keys = (value, allowed, where) => {
|
|
18
|
+
const extra = Object.keys(value).filter(k => !allowed.includes(k));
|
|
19
|
+
check(!extra.length, `${where} 里有 Coda 不支持的键:${extra.join('、')}`);
|
|
20
|
+
};
|
|
21
|
+
const line = (value, max, where) => {
|
|
22
|
+
check(typeof value === 'string' && value.trim() && [...value].length <= max && !/[\0\r\n]/.test(value), `${where} 须为不超过 ${max} 字的一行文本`);
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
// panels.mjs `text`: a confirmation or a note may run over lines.
|
|
26
|
+
const text = (value, max, where, { line: one = true } = {}) => {
|
|
27
|
+
check(typeof value === 'string' && value.trim() && [...value].length <= max && !value.includes('\0') && (!one || !/[\r\n]/.test(value)),
|
|
28
|
+
`${where} 须为不超过 ${max} 字的${one ? '一行' : ''}文本`);
|
|
29
|
+
return value;
|
|
30
|
+
};
|
|
31
|
+
// agent-plugins.mjs `inside`: relative, never leaving the directory.
|
|
32
|
+
const inside = (value, where, { segments = Infinity } = {}) => {
|
|
33
|
+
line(value, AGENT_LIMITS.path, where);
|
|
34
|
+
const path = normalize(value);
|
|
35
|
+
check(!isAbsolute(value) && path !== '..' && !path.startsWith(`..${sep}`) && path.split(sep).length <= segments,
|
|
36
|
+
`${where} 须为不离开所在目录的相对路径${Number.isFinite(segments) ? `,至多 ${segments} 段` : ''}`);
|
|
37
|
+
return path;
|
|
38
|
+
};
|
|
39
|
+
// JSON Schema's `format: duration` (Coda reads it with @0dep/piso): an ISO 8601 duration, not negative. Close enough here.
|
|
40
|
+
const DURATION = /^P(?!$)(\d+(\.\d+)?Y)?(\d+(\.\d+)?M)?(\d+(\.\d+)?W)?(\d+(\.\d+)?D)?(T(?=\d)(\d+(\.\d+)?H)?(\d+(\.\d+)?M)?(\d+(\.\d+)?S)?)?$/;
|
|
41
|
+
const isDuration = value => typeof value === 'string' && value.length <= 200 && DURATION.test(value);
|
|
42
|
+
|
|
43
|
+
// ---- Settings (plugins.mjs `schema`, `checkValue`) ----
|
|
44
|
+
// A choice of several's option (plugin-rules.mjs OPTION).
|
|
45
|
+
const OPTION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
46
|
+
function schema(value, where, entry = false) {
|
|
47
|
+
check(value && typeof value === 'object' && !Array.isArray(value), `${where} 须为 JSON Schema 对象`);
|
|
48
|
+
const known = ['type', 'default', 'description', 'markdownDescription', 'title', 'enum', 'enumDescriptions', 'minimum', 'maximum', 'format', 'order',
|
|
49
|
+
...(entry ? [] : ['additionalProperties', 'propertyNames', 'items', 'uniqueItems'])];
|
|
50
|
+
check(value.type !== 'array' || !entry, `${where}:多选只能是插件的一项设置,不能放进表格`);
|
|
51
|
+
keys(value, known, where);
|
|
52
|
+
const agent = value.format === 'coda-agent';
|
|
53
|
+
check(['boolean', 'integer', 'number', 'string', 'object', 'array'].includes(value.type), `${where} 的 type 不受支持:${value.type}`);
|
|
54
|
+
check((value.items === undefined && value.uniqueItems === undefined) || value.type === 'array', `${where}:只有多选(type: array)才写 items 与 uniqueItems`);
|
|
55
|
+
check(value.format === undefined || agent || value.format === 'coda-agent-match' || value.format === 'duration', `${where} 的 format 不受支持:${value.format}`);
|
|
56
|
+
check(!agent || value.type === 'object', `${where}:coda-agent 是对象`);
|
|
57
|
+
check(value.format !== 'duration' || value.type === 'string', `${where}:duration 是文本`);
|
|
58
|
+
check(value.enum === undefined || (Array.isArray(value.enum) && value.enum.length && ['string', 'integer', 'number'].includes(value.type)), `${where} 的 enum 无效`);
|
|
59
|
+
const out = { type: value.type, format: value.format, enum: value.enum, minimum: value.minimum, maximum: value.maximum };
|
|
60
|
+
if (value.type === 'array') {
|
|
61
|
+
check(value.format === undefined && value.minimum === undefined && value.maximum === undefined, `${where}:多选不写 format、minimum 与 maximum`);
|
|
62
|
+
check(value.uniqueItems === undefined || value.uniqueItems === true, `${where}:多选的 uniqueItems 只能是 true(每项至多选一次)`);
|
|
63
|
+
const items = value.items, options = items?.anyOf;
|
|
64
|
+
check(items && typeof items === 'object' && !Array.isArray(items) && Object.keys(items).every(k => k === 'anyOf') && Array.isArray(options)
|
|
65
|
+
&& options.length >= 1 && options.length <= 50, `${where}:多选写 items: { anyOf: [{ const, title, description }] },1–50 项`);
|
|
66
|
+
out.options = options.map((option, i) => {
|
|
67
|
+
check(option && typeof option === 'object' && !Array.isArray(option) && Object.keys(option).every(k => ['const', 'title', 'description'].includes(k))
|
|
68
|
+
&& typeof option.const === 'string' && OPTION.test(option.const), `${where}.items.anyOf[${i}] 须为 { const, title, description },const 只用字母、数字和「._-」`);
|
|
69
|
+
check(['title', 'description'].every(k => option[k] === undefined || (typeof option[k] === 'string' && option[k].length <= 400)),
|
|
70
|
+
`${where}.items.anyOf[${i}] 的 title 与 description 须为不超过 400 字的文字`);
|
|
71
|
+
return option.const;
|
|
72
|
+
});
|
|
73
|
+
check(new Set(out.options).size === out.options.length, `${where}:多选的 const 不能重复`);
|
|
74
|
+
}
|
|
75
|
+
if (value.type === 'object' && !agent) {
|
|
76
|
+
check(!entry, `${where}:表格的每一项只能是单个值或 Agent 组合`);
|
|
77
|
+
check(value.additionalProperties !== undefined, `${where} 需要 additionalProperties`);
|
|
78
|
+
out.entries = schema(value.additionalProperties, `${where}.additionalProperties`, true);
|
|
79
|
+
if (value.propertyNames !== undefined) {
|
|
80
|
+
check(value.propertyNames && Object.keys(value.propertyNames).every(k => k === 'format') && value.propertyNames.format === 'coda-agent-match',
|
|
81
|
+
`${where}.propertyNames 只支持 format: coda-agent-match`);
|
|
82
|
+
out.keys = 'coda-agent-match';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (value.default !== undefined) { checkValue(out, value.default, `${where} 的默认值`); out.default = value.default; }
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
const agentMatch = key => key === '*' || (typeof key === 'string' && (parts => parts.length >= 1 && parts.length <= 3 && AGENT_ID.test(parts[0])
|
|
89
|
+
&& parts.every(p => p.length > 0 && p.length <= 200 && !/[\0\n]/.test(p)))(key.split('|')));
|
|
90
|
+
function checkValue(spec, value, where) {
|
|
91
|
+
// An agent combination: Coda also checks model, effort, mode and options (domain/commands.mjs settings); here only the agent.
|
|
92
|
+
if (spec.format === 'coda-agent') {
|
|
93
|
+
check(value && typeof value === 'object' && !Array.isArray(value) && typeof value.agent === 'string' && AGENT_ID.test(value.agent), `${where} 须为 Agent 组合,并指明 Agent`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (spec.type === 'array') {
|
|
97
|
+
check(Array.isArray(value) && value.every(v => typeof v === 'string'), `${where}须为选中的几项`);
|
|
98
|
+
const unknown = value.filter(v => !spec.options.includes(v));
|
|
99
|
+
check(!unknown.length, `${where}只能从这些里选:${spec.options.join('、')}(没有 ${unknown.join('、')})`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (spec.type === 'boolean') check(typeof value === 'boolean', `${where}须为开或关`);
|
|
103
|
+
else if (spec.type === 'integer' || spec.type === 'number') {
|
|
104
|
+
check(typeof value === 'number' && Number.isFinite(value) && (spec.type === 'number' || Number.isInteger(value)), `${where}须为${spec.type === 'integer' ? '整数' : '数字'}`);
|
|
105
|
+
check((spec.minimum === undefined || value >= spec.minimum) && (spec.maximum === undefined || value <= spec.maximum), `${where}须在 ${spec.minimum ?? '-∞'}–${spec.maximum ?? '∞'} 之间`);
|
|
106
|
+
} else if (spec.type === 'string') {
|
|
107
|
+
check(typeof value === 'string' && value.length <= 20000 && !value.includes('\0'), `${where}须为文本`);
|
|
108
|
+
check(spec.format !== 'duration' || isDuration(value), `${where}须为 ISO 8601 时长,如 P30D、PT6H`);
|
|
109
|
+
} else if (spec.type === 'object') {
|
|
110
|
+
object(value, where);
|
|
111
|
+
for (const [k, v] of Object.entries(value)) {
|
|
112
|
+
check(spec.keys !== 'coda-agent-match' || agentMatch(k), `${where}的「${k}」不是有效的作者`);
|
|
113
|
+
checkValue(spec.entries, v, `${where}「${k}」`);
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
check(!spec.enum || spec.enum.includes(value), `${where}只能是:${spec.enum?.join('、')}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- Panels (panels.mjs `declaration`, `signIn`) ----
|
|
121
|
+
function panel(raw, where) {
|
|
122
|
+
object(raw, where);
|
|
123
|
+
keys(raw, ['id', 'title', 'command', 'maxAge', 'actions', 'signIn'], where);
|
|
124
|
+
check(typeof raw.id === 'string' && PANEL_ID.test(raw.id), `${where} 的 id 只能用小写字母、数字和「-」,以字母开头`);
|
|
125
|
+
const at = `面板 ${raw.id}`;
|
|
126
|
+
check(raw.maxAge === undefined || isDuration(raw.maxAge), `${at} 的 maxAge 须为 ISO 8601 时长,如 PT5M`);
|
|
127
|
+
const actions = {};
|
|
128
|
+
if (raw.actions !== undefined) {
|
|
129
|
+
object(raw.actions, `${at} 的 actions`);
|
|
130
|
+
for (const [name, action] of Object.entries(raw.actions)) {
|
|
131
|
+
check(PANEL_ID.test(name), `${at} 的动作名只能用小写字母、数字和「-」,以字母开头:${name}`);
|
|
132
|
+
object(action, `${at} 的动作 ${name}`);
|
|
133
|
+
keys(action, ['command', 'confirm'], `${at} 的动作 ${name}`);
|
|
134
|
+
actions[name] = { command: text(action.command, PANEL_LIMITS.command, `${at} 的动作 ${name} 的 command`),
|
|
135
|
+
...(action.confirm !== undefined ? { confirm: text(action.confirm, PANEL_LIMITS.confirm, `${at} 的动作 ${name} 的 confirm`, { line: false }) } : {}) };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
let signIn;
|
|
139
|
+
if (raw.signIn !== undefined) {
|
|
140
|
+
const s = raw.signIn, w = `${at} 的 signIn`;
|
|
141
|
+
object(s, w);
|
|
142
|
+
keys(s, ['command', 'installed', 'install', 'input', 'note'], w);
|
|
143
|
+
check(s.input === undefined || s.input === 'authorizationCode', `${w} 的 input 只能是 authorizationCode`);
|
|
144
|
+
check(s.install === undefined || s.installed !== undefined, `${w}:写了 install 就要写 installed,Coda 才知道装没装好`);
|
|
145
|
+
signIn = { command: text(s.command, PANEL_LIMITS.command, `${w} 的 command`) };
|
|
146
|
+
if (s.installed !== undefined) signIn.installed = text(s.installed, PANEL_LIMITS.command, `${w} 的 installed`);
|
|
147
|
+
if (s.install !== undefined) signIn.install = text(s.install, PANEL_LIMITS.command, `${w} 的 install`);
|
|
148
|
+
if (s.note !== undefined) text(s.note, PANEL_LIMITS.note, `${w} 的 note`, { line: false });
|
|
149
|
+
}
|
|
150
|
+
return { id: raw.id, title: text(raw.title, PANEL_LIMITS.title, `${at} 的 title`), command: text(raw.command, PANEL_LIMITS.command, `${at} 的 command`),
|
|
151
|
+
actions, ...(signIn ? { signIn } : {}) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- Agents (agent-plugins.mjs `declaration`, `icon`) ----
|
|
155
|
+
function icon(dir, value, where) {
|
|
156
|
+
const path = inside(value, where);
|
|
157
|
+
let data;
|
|
158
|
+
try { data = readFileSync(join(dir, path)); } catch (e) { check(false, `${where} 读不到:${e.message}`); }
|
|
159
|
+
check(data.length <= ICON_LIMIT, `${where} 超过 ${ICON_LIMIT / 1024} KiB(现为 ${(data.length / 1024).toFixed(1)} KiB)`);
|
|
160
|
+
check(data.subarray(0, 5).toString('latin1') === '%PDF-', `${where} 须为 PDF`);
|
|
161
|
+
return { path, size: data.length, data };
|
|
162
|
+
}
|
|
163
|
+
function agent(raw, where, dir) {
|
|
164
|
+
object(raw, where);
|
|
165
|
+
keys(raw, ['id', 'displayName', 'icon', 'command', 'modes', 'skillsDir', 'configFiles', 'setupHint'], where);
|
|
166
|
+
check(typeof raw.id === 'string' && AGENT_ID.test(raw.id), `${where} 的 id 只能用小写字母、数字和「-」,以字母开头,至多 40 个字符`);
|
|
167
|
+
const at = `Agent ${raw.id}`, L = AGENT_LIMITS;
|
|
168
|
+
object(raw.icon, `${at} 的 icon`);
|
|
169
|
+
keys(raw.icon, ['light', 'dark', 'template'], `${at} 的 icon`);
|
|
170
|
+
check(raw.icon.template === undefined || typeof raw.icon.template === 'boolean', `${at} 的 icon.template 须为开或关`);
|
|
171
|
+
const modes = raw.modes ?? {};
|
|
172
|
+
object(modes, `${at} 的 modes`);
|
|
173
|
+
keys(modes, ['title', 'unset', 'note', 'auto', 'full'], `${at} 的 modes`);
|
|
174
|
+
check(raw.configFiles === undefined || (Array.isArray(raw.configFiles) && raw.configFiles.length <= L.configFiles), `${at} 的 configFiles 须为至多 ${L.configFiles} 项的数组`);
|
|
175
|
+
line(raw.displayName, L.displayName, `${at} 的 displayName`);
|
|
176
|
+
const icons = [icon(dir, raw.icon.light, `${at} 的 icon.light`), ...(raw.icon.dark !== undefined ? [icon(dir, raw.icon.dark, `${at} 的 icon.dark`)] : [])];
|
|
177
|
+
line(raw.command, L.command, `${at} 的 command`);
|
|
178
|
+
for (const [key, max] of [['title', L.modeTitle], ['unset', L.modeUnset], ['note', L.modeNote], ['auto', L.modeId], ['full', L.modeId]])
|
|
179
|
+
if (modes[key] !== undefined) line(modes[key], max, `${at} 的 modes.${key}`);
|
|
180
|
+
if (raw.skillsDir !== undefined) inside(raw.skillsDir, `${at} 的 skillsDir`, { segments: 3 });
|
|
181
|
+
(raw.configFiles ?? []).forEach((file, i) => line(file, L.path, `${at} 的 configFiles[${i}]`));
|
|
182
|
+
if (raw.setupHint !== undefined) line(raw.setupHint, L.setupHint, `${at} 的 setupHint`);
|
|
183
|
+
return { id: raw.id, displayName: raw.displayName, command: raw.command, icons, modes };
|
|
184
|
+
}
|
|
185
|
+
// iOS draws an icon's PDF at 96 points and only ever scales it down: a smaller page comes out as a dot (Coda's own icons are
|
|
186
|
+
// 256 points, backend/src/resources/plugins/grok/icons/README.md).
|
|
187
|
+
// The page box may sit in a compressed object stream (PDF 1.5 and later, as CairoSVG writes them).
|
|
188
|
+
function pageSize(data) {
|
|
189
|
+
const MEDIA_BOX = /\/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\]/;
|
|
190
|
+
const raw = data.toString('latin1'), texts = [raw];
|
|
191
|
+
for (const match of raw.matchAll(/stream\r?\n/g)) {
|
|
192
|
+
const start = match.index + match[0].length, end = raw.indexOf('endstream', start);
|
|
193
|
+
if (end < 0) continue;
|
|
194
|
+
try { texts.push(inflateSync(data.subarray(start, end)).toString('latin1')); } catch {}
|
|
195
|
+
}
|
|
196
|
+
for (const text of texts) {
|
|
197
|
+
const box = MEDIA_BOX.exec(text);
|
|
198
|
+
if (box) return { width: Number(box[3]) - Number(box[1]), height: Number(box[4]) - Number(box[2]) };
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- Files, as a repository source sees them (plugin-sources.mjs `treeProblem`, `sourcePath`) ----
|
|
204
|
+
// The files Coda would take from a commit: those Git tracks here, and those it would once they are committed. Outside Git,
|
|
205
|
+
// every file but `.git`.
|
|
206
|
+
function files(dir) {
|
|
207
|
+
const git = args => execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
208
|
+
let top = null;
|
|
209
|
+
try { top = git(['rev-parse', '--show-toplevel']).trim(); } catch {}
|
|
210
|
+
if (top) {
|
|
211
|
+
const tracked = git(['ls-files', '-s', '-z', '--', '.']).split('\0').filter(Boolean).map(entry => {
|
|
212
|
+
const tab = entry.indexOf('\t'), [mode] = entry.slice(0, tab).split(' ');
|
|
213
|
+
return { mode, name: entry.slice(tab + 1) };
|
|
214
|
+
});
|
|
215
|
+
const untracked = git(['ls-files', '-o', '--exclude-standard', '-z', '--', '.']).split('\0').filter(Boolean).map(name => ({ name, untracked: true }));
|
|
216
|
+
return { top, entries: [...tracked, ...untracked].map(e => withStat(dir, e)) };
|
|
217
|
+
}
|
|
218
|
+
const entries = [];
|
|
219
|
+
const walk = sub => {
|
|
220
|
+
for (const name of readdirSync(join(dir, sub))) {
|
|
221
|
+
if (!sub && name === '.git') continue;
|
|
222
|
+
const rel = sub ? `${sub}/${name}` : name, stat = lstatSync(join(dir, rel));
|
|
223
|
+
if (stat.isDirectory()) { if (existsSync(join(dir, rel, '.git'))) entries.push({ name: rel, mode: '160000', size: 0 }); else walk(rel); }
|
|
224
|
+
else entries.push(withStat(dir, { name: rel }));
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
walk('');
|
|
228
|
+
return { top: null, entries };
|
|
229
|
+
}
|
|
230
|
+
// The addresses the repository at `top` is fetched from.
|
|
231
|
+
function remotes(top) {
|
|
232
|
+
try { return execFileSync('git', ['-C', top, 'remote', '-v'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').map(l => l.split(/\s+/)[1]).filter(Boolean); }
|
|
233
|
+
catch { return []; }
|
|
234
|
+
}
|
|
235
|
+
// Why npm could not install from the lock file next to the manifest (`npm ci` refuses a lock that does not match; Coda's
|
|
236
|
+
// manifestProblem refuses it first), or null.
|
|
237
|
+
function lockProblem(dir, manifest) {
|
|
238
|
+
let lock;
|
|
239
|
+
try { lock = JSON.parse(readFileSync(join(dir, NPM_LOCK), 'utf8')); } catch (e) { return `${NPM_LOCK} 读不了:${e.message}`; }
|
|
240
|
+
const root = lock?.packages?.[''];
|
|
241
|
+
if (!root) return `${NPM_LOCK} 不是 npm 7 以后的格式(没有 packages[""])`;
|
|
242
|
+
const differ = NPM.filter(key => JSON.stringify(Object.entries(manifest[key] ?? {}).sort()) !== JSON.stringify(Object.entries(root[key] ?? {}).sort()));
|
|
243
|
+
return differ.length ? `${NPM_LOCK} 与 package.json 的 ${differ.join('、')} 对不上:npm ci 会拒绝,在插件目录里 npm install 重新生成` : null;
|
|
244
|
+
}
|
|
245
|
+
function withStat(dir, entry) {
|
|
246
|
+
let stat = null;
|
|
247
|
+
try { stat = lstatSync(join(dir, entry.name)); } catch {}
|
|
248
|
+
const mode = entry.mode ?? (stat?.isSymbolicLink() ? '120000' : '100644');
|
|
249
|
+
return { ...entry, mode, size: stat && !stat.isSymbolicLink() && stat.isFile() ? stat.size : 0, missing: !stat && mode !== '160000' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Checks the manifest in `dir` and reports under `scope` (report.mjs). Returns what the later levels need: the agents and
|
|
253
|
+
// panels as declared, and the plugin's settings defaults; null when there is no manifest to read.
|
|
254
|
+
export function checkManifest(dir, say) {
|
|
255
|
+
let manifest;
|
|
256
|
+
try { manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); }
|
|
257
|
+
catch (e) { say.fail('manifest.read', `没有可读的 package.json:${e.message}`); return null; }
|
|
258
|
+
const part = (rule, fn, passed) => {
|
|
259
|
+
try {
|
|
260
|
+
const value = fn(), message = typeof passed === 'function' ? passed(value) : passed;
|
|
261
|
+
if (message) say.pass(rule, message);
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
catch (e) { if (!(e instanceof Invalid)) throw e; say.fail(rule, e.message); return undefined; }
|
|
265
|
+
};
|
|
266
|
+
const { top, entries } = files(dir);
|
|
267
|
+
// Git names the top by its real path; so is the directory measured.
|
|
268
|
+
const path = top ? relative(top, realpathSync(dir)).split(sep).filter(Boolean) : null;
|
|
269
|
+
// A built-in plugin kept here, in its own place: Coda's copy of it comes from there, and so may a newer version.
|
|
270
|
+
const declaredId = manifest.id !== undefined ? manifest.id : manifest.name;
|
|
271
|
+
const own = BUILTIN_HERE.includes(declaredId) && path?.join('/') === `plugins/${declaredId}` && remotes(top).some(url => BUILTIN_REPOSITORY.test(url));
|
|
272
|
+
const id = part('manifest.name', () => {
|
|
273
|
+
check(typeof declaredId === 'string' && PLUGIN_ID.test(declaredId), `package.json 的 ${manifest.id !== undefined ? 'id' : 'name'} 只能用小写字母、数字和「-」,以字母开头,至多 40 个字符`);
|
|
274
|
+
return declaredId;
|
|
275
|
+
}, name => `package.json 能读,插件 ID ${name}${own ? `(Coda 内置插件 ${name} 自己的来源:从这里装的新版本盖过 Coda 的内置副本)` : ''}`);
|
|
276
|
+
const contributes = part('manifest.contributes', () => {
|
|
277
|
+
check(manifest.contributes && typeof manifest.contributes === 'object' && !Array.isArray(manifest.contributes),
|
|
278
|
+
'contributes 须为对象(Coda 只把带 contributes 的目录当插件)');
|
|
279
|
+
const unknown = Object.keys(manifest.contributes).filter(k => !CONTRIBUTES.includes(k));
|
|
280
|
+
check(!unknown.length, `contributes 里有 Coda 不支持的贡献:${unknown.join('、')}`);
|
|
281
|
+
// What is checked here is installed from a repository or npm, never as a copy Coda ships.
|
|
282
|
+
for (const [key, refusal] of Object.entries(BUILTIN_ONLY)) check(manifest.contributes[key] === undefined, `contributes.${key}:${refusal}`);
|
|
283
|
+
return manifest.contributes;
|
|
284
|
+
}) ?? {};
|
|
285
|
+
if (typeof manifest.version !== 'string') say.warn('manifest.version', 'package.json 没有 version:Coda 只认提交,version 只作显示,但安装确认里会空着');
|
|
286
|
+
const name = id ?? String(declaredId);
|
|
287
|
+
|
|
288
|
+
const settings = part('manifest.settings', () => {
|
|
289
|
+
const properties = contributes.configuration?.properties ?? {};
|
|
290
|
+
object(properties, 'contributes.configuration.properties');
|
|
291
|
+
return Object.fromEntries(Object.entries(properties).map(([key, value]) => {
|
|
292
|
+
check(SETTING_KEY.test(key) && key.split('.')[0] === name, `设置名须以「${name}.」开头,只用字母和数字:${key}`);
|
|
293
|
+
return [key, schema(value, key)];
|
|
294
|
+
}));
|
|
295
|
+
}) ?? {};
|
|
296
|
+
if (Object.keys(settings).length) say.pass('manifest.settings', `设置 ${Object.keys(settings).length} 项`);
|
|
297
|
+
|
|
298
|
+
part('manifest.capabilities', () => {
|
|
299
|
+
const list = sub => existsSync(join(dir, sub)) ? [...new Set(readdirSync(join(dir, sub)).map(f => /^(.+)\.bpmn$/.exec(f)?.[1]).filter(Boolean))].sort() : [];
|
|
300
|
+
const workflows = list('.coda/workflows'), listed = contributes.capabilities ?? [];
|
|
301
|
+
check(Array.isArray(listed) && listed.every(n => typeof n === 'string' && CAPABILITY.test(n)) && new Set(listed).size === listed.length,
|
|
302
|
+
'contributes.capabilities 须为能力名的数组(小写字母、数字和「-」,以字母开头),不重复');
|
|
303
|
+
const capabilities = [...listed].sort(), found = list('.coda/capabilities');
|
|
304
|
+
check(capabilities.length === found.length && capabilities.every((n, i) => n === found[i]),
|
|
305
|
+
`contributes.capabilities 与 .coda/capabilities/ 里的文件不一致:列出 ${capabilities.join('、') || '无'},文件 ${found.join('、') || '无'}`);
|
|
306
|
+
const both = capabilities.filter(n => workflows.includes(n));
|
|
307
|
+
check(!both.length, `${both.join('、')} 既是流程又是能力`);
|
|
308
|
+
if (workflows.length || capabilities.length) say.pass('manifest.capabilities', `流程 ${workflows.join('、') || '无'},能力 ${capabilities.join('、') || '无'}`);
|
|
309
|
+
});
|
|
310
|
+
part('manifest.instructions', () => {
|
|
311
|
+
const value = contributes.instructions;
|
|
312
|
+
check(value === undefined || (typeof value === 'string' && value.trim() && [...value].length <= INSTRUCTIONS_LIMIT), `contributes.instructions 须为不超过 ${INSTRUCTIONS_LIMIT} 字的说明`);
|
|
313
|
+
});
|
|
314
|
+
// A group and a command are one plugin's, as an agent is (plugin-sources.mjs catalogProblem): those of the built-in plugins
|
|
315
|
+
// are taken.
|
|
316
|
+
const taken = (what, value, builtin) => builtin[value] && builtin[value] !== name ? `${what} ${value} 已由内置插件 ${builtin[value]} 声明,换一个名字` : null;
|
|
317
|
+
const groups = part('manifest.groups', () => {
|
|
318
|
+
const groups = contributes.groups ?? [];
|
|
319
|
+
check(Array.isArray(groups) && groups.every(g => g && typeof g === 'object' && Object.keys(g).every(k => ['name', 'capacity'].includes(k))
|
|
320
|
+
&& typeof g.name === 'string' && /^\S{1,100}$/.test(g.name) && settings[g.capacity]?.type === 'integer')
|
|
321
|
+
&& new Set(groups.map(g => g.name)).size === groups.length,
|
|
322
|
+
'contributes.groups 须为 { name, capacity } 的数组:name 为 1–100 个非空白字符、不重复,capacity 是本插件声明的整数设置');
|
|
323
|
+
return groups;
|
|
324
|
+
}, groups => groups.length ? `并发组 ${groups.map(g => g.name).join('、')}` : null) ?? [];
|
|
325
|
+
part('manifest.events', () => {
|
|
326
|
+
const events = contributes.events ?? [];
|
|
327
|
+
check(Array.isArray(events) && events.length <= EVENT_LIMIT && events.every(e => e && typeof e === 'object' && Object.keys(e).every(k => ['name', 'starts', 'ends'].includes(k))
|
|
328
|
+
&& typeof e.name === 'string' && EVENT.test(e.name) && !CODA_EVENT_DOMAINS.includes(e.name.split('.')[0])
|
|
329
|
+
&& ['starts', 'ends'].every(k => e[k] === undefined || typeof e[k] === 'boolean'))
|
|
330
|
+
&& new Set(events.map(e => e.name)).size === events.length,
|
|
331
|
+
`contributes.events 须为 { name, starts?, ends? } 的数组,至多 ${EVENT_LIMIT} 个:name 是「<域>.<名>」(小写字母、数字和「_」,以字母开头,不超过 100 个字符,不重复,域不能是 Coda 自己的 ${CODA_EVENT_DOMAINS.join('、')}),starts、ends 是真假值`);
|
|
332
|
+
return events;
|
|
333
|
+
}, events => events.length ? `发布的事件 ${events.map(e => e.name).join('、')}` : null);
|
|
334
|
+
const commands = part('manifest.commands', () => {
|
|
335
|
+
const commands = contributes.commands ?? [];
|
|
336
|
+
check(Array.isArray(commands) && commands.every(c => c && typeof c === 'object' && Object.keys(c).every(k => ['name', 'command'].includes(k))
|
|
337
|
+
&& typeof c.name === 'string' && COMMAND.test(c.name) && !CODA_COMMANDS.includes(c.name)
|
|
338
|
+
&& typeof c.command === 'string' && c.command.trim() && c.command.length <= COMMAND_LIMIT && !/[\0\r\n]/.test(c.command))
|
|
339
|
+
&& new Set(commands.map(c => c.name)).size === commands.length,
|
|
340
|
+
`contributes.commands 须为 { name, command } 的数组:name 是命令名(小写字母、数字和「-」,以字母开头,不重复,不能是 Coda 自己的 ${CODA_COMMANDS.join('、')}),command 是一行命令`);
|
|
341
|
+
return commands;
|
|
342
|
+
}, commands => commands.length ? `会话 PATH 上的命令 ${commands.map(c => c.name).join('、')}` : null) ?? [];
|
|
343
|
+
part('manifest.services', () => {
|
|
344
|
+
const services = contributes.services ?? [];
|
|
345
|
+
check(Array.isArray(services) && services.every(id => typeof id === 'string' && SERVICE.test(id)) && new Set(services).size === services.length,
|
|
346
|
+
'contributes.services 须为服务 ID 的数组,不重复');
|
|
347
|
+
return services;
|
|
348
|
+
}, services => services.length ? `开启随包服务 ${services.join('、')}:没有已安装插件开启的服务不运行` : null);
|
|
349
|
+
part('manifest.setup', () => {
|
|
350
|
+
if (contributes.setup === undefined) return null;
|
|
351
|
+
object(contributes.setup, 'contributes.setup');
|
|
352
|
+
keys(contributes.setup, SETUP_KEYS, 'contributes.setup');
|
|
353
|
+
return text(contributes.setup.command, PANEL_LIMITS.command, 'contributes.setup 的 command');
|
|
354
|
+
}, command => command ? `设置动作(contributes.setup):一行命令;检查不运行它(可能装软件)` : null);
|
|
355
|
+
for (const g of groups) { const problem = taken('组', g.name, BUILTIN_GROUPS); if (problem) say.fail('manifest.claims', problem); }
|
|
356
|
+
for (const c of commands) { const problem = taken('命令', c.name, BUILTIN_COMMANDS); if (problem) say.fail('manifest.claims', problem); }
|
|
357
|
+
|
|
358
|
+
const panels = part('manifest.panels', () => {
|
|
359
|
+
if (contributes.panels === undefined) return [];
|
|
360
|
+
check(Array.isArray(contributes.panels), 'contributes.panels 须为数组');
|
|
361
|
+
const list = contributes.panels.map((p, i) => panel(p, `contributes.panels[${i}]`));
|
|
362
|
+
check(new Set(list.map(p => p.id)).size === list.length, 'contributes.panels 里有重复的 id');
|
|
363
|
+
return list;
|
|
364
|
+
}) ?? [];
|
|
365
|
+
for (const p of panels) say.pass('manifest.panels', `面板 ${p.id}「${p.title}」:动作 ${Object.keys(p.actions).join('、') || '无'}${p.signIn ? ',可从 Coda 登录' : ''}`);
|
|
366
|
+
|
|
367
|
+
let agents = [];
|
|
368
|
+
if (contributes.agents !== undefined) {
|
|
369
|
+
if (!Array.isArray(contributes.agents)) say.fail('manifest.agents', 'contributes.agents 须为数组');
|
|
370
|
+
else contributes.agents.forEach((raw, i) => {
|
|
371
|
+
const declared = part('manifest.agents', () => agent(raw, `contributes.agents[${i}]`, dir));
|
|
372
|
+
if (!declared) return;
|
|
373
|
+
if (agents.some(a => a.id === declared.id)) { say.fail('manifest.agents', 'contributes.agents 里有重复的 id'); return; }
|
|
374
|
+
// A version or replacement of a built-in plugin may declare its agents; installation checks whether the name is free.
|
|
375
|
+
// The old Claude ID stays nobody's.
|
|
376
|
+
const owner = BUILTIN_AGENTS[declared.id];
|
|
377
|
+
if (owner && (declared.id === 'claude' || owner !== name)) say.fail('manifest.agent-id', declared.id === 'claude'
|
|
378
|
+
? 'Agent ID claude 是 Coda 早先的 Claude 会话存的 ID:Coda 的读取来源不拦它,但装上会接管那些旧会话,换一个 ID'
|
|
379
|
+
: `Agent ${declared.id} 已由内置插件 ${owner} 声明,换一个 ID`);
|
|
380
|
+
agents.push(declared);
|
|
381
|
+
const sizes = declared.icons.map(i => `${i.path} ${(i.size / 1024).toFixed(1)} KiB`).join(',');
|
|
382
|
+
say.pass('manifest.agents', `Agent ${declared.id}「${declared.displayName}」:字段齐全,command 一行,图标 PDF(${sizes})`);
|
|
383
|
+
for (const i of declared.icons) {
|
|
384
|
+
const page = pageSize(i.data);
|
|
385
|
+
if (page && Math.min(page.width, page.height) < 64) say.warn('manifest.icon-page', `${i.path} 的页面只有 ${page.width}×${page.height} pt:iOS 按 96 pt 光栅化图标、只缩不放,页面太小会画成一个点;照 Coda 自带图标按 256 px(192 pt)出图`);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Where the plugins it depends on are, as its processes are told (CODA_PLUGIN_DEPENDENCIES): here a directory beside it
|
|
391
|
+
// that is that plugin, as one of the same commit Coda would install with it.
|
|
392
|
+
const dependencies = {};
|
|
393
|
+
part('manifest.extensions', () => {
|
|
394
|
+
const deps = manifest.extensionDependencies;
|
|
395
|
+
check(deps === undefined || (Array.isArray(deps) && deps.every(d => typeof d === 'string' && PLUGIN_ID.test(d)) && new Set(deps).size === deps.length),
|
|
396
|
+
'extensionDependencies 须为插件 ID 的数组,不重复');
|
|
397
|
+
check(!deps?.includes(name), 'extensionDependencies 不能写自己');
|
|
398
|
+
if (contributes.extensionPoints !== undefined) {
|
|
399
|
+
object(contributes.extensionPoints, 'contributes.extensionPoints');
|
|
400
|
+
for (const [point, value] of Object.entries(contributes.extensionPoints)) {
|
|
401
|
+
const where = `contributes.extensionPoints.${point}`;
|
|
402
|
+
check(POINT.test(point), `${where}:扩展点名只能用小写字母、数字和「-」,以字母开头`);
|
|
403
|
+
object(value, where); keys(value, ['description'], where); line(value.description, 200, `${where} 的 description`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (contributes.extensions !== undefined) {
|
|
407
|
+
object(contributes.extensions, 'contributes.extensions');
|
|
408
|
+
for (const [key, value] of Object.entries(contributes.extensions)) {
|
|
409
|
+
const where = `contributes.extensions.${key}`, [plugin, point, ...rest] = key.split('/');
|
|
410
|
+
check(PLUGIN_ID.test(plugin ?? '') && POINT.test(point ?? '') && !rest.length, `${where}:须写成「插件 ID/扩展点名」`);
|
|
411
|
+
check((deps ?? []).includes(plugin), `${where}:只能接所依赖的插件的扩展点,先把 ${plugin} 写进 extensionDependencies`);
|
|
412
|
+
object(value, where); keys(value, ['command'], where); line(value.command, 4000, `${where} 的 command`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (!deps?.length) return;
|
|
416
|
+
for (const dep of deps) {
|
|
417
|
+
const beside = join(dir, '..', dep);
|
|
418
|
+
try {
|
|
419
|
+
const sibling = JSON.parse(readFileSync(join(beside, 'package.json'), 'utf8'));
|
|
420
|
+
if ((sibling.id !== undefined ? sibling.id : sibling.name) === dep) dependencies[dep] = realpathSync(beside);
|
|
421
|
+
} catch {}
|
|
422
|
+
}
|
|
423
|
+
const missing = deps.filter(d => !dependencies[d]);
|
|
424
|
+
say.info('manifest.extensions', `依赖 ${deps.join('、')}:Coda 装它时先装它们(这台机器的插件,或同一提交里的兄弟目录),它的进程从 CODA_PLUGIN_DEPENDENCIES 拿到它们的目录${missing.length
|
|
425
|
+
? `;${missing.join('、')} 不在旁边,检查时不带` : ''}`);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// npm dependencies are installed from the lock file Coda takes with the rest of the commit.
|
|
429
|
+
const npm = NPM.filter(key => manifest[key] && typeof manifest[key] === 'object' && Object.keys(manifest[key]).length);
|
|
430
|
+
if (npm.length) {
|
|
431
|
+
const count = npm.reduce((n, key) => n + Object.keys(manifest[key]).length, 0), lock = entries.find(e => e.name === NPM_LOCK && !e.missing);
|
|
432
|
+
const problem = !lock ? `带 npm 依赖(${npm.join('、')})却没有 ${NPM_LOCK}${existsSync(join(dir, NPM_LOCK)) ? '(有这个文件,但被 .gitignore 忽略了)' : ''}:Coda 在插件的版本目录里 npm ci,没有锁文件就装不了`
|
|
433
|
+
: lockProblem(dir, manifest);
|
|
434
|
+
if (problem) say.fail('source.npm', problem);
|
|
435
|
+
else say.pass('source.npm', `npm 依赖 ${count} 个,带 ${NPM_LOCK}:Coda 在插件的版本目录里 npm ci 装它们`);
|
|
436
|
+
}
|
|
437
|
+
// What a repository source may not hold (plugin-sources.mjs).
|
|
438
|
+
const link = entries.find(e => e.mode === '120000'), module = entries.find(e => e.mode === '160000');
|
|
439
|
+
const size = entries.reduce((sum, e) => sum + e.size, 0);
|
|
440
|
+
if (link) say.fail('source.symlink', `目录里有符号链接:${link.name}`);
|
|
441
|
+
if (module) say.fail('source.submodule', `目录里有子模块:${module.name}`);
|
|
442
|
+
if (size > SIZE_LIMIT) say.fail('source.size', `整个目录超过 ${SIZE_LIMIT / 1024 / 1024} MiB(现为 ${(size / 1024 / 1024).toFixed(1)} MiB)`);
|
|
443
|
+
if (path) {
|
|
444
|
+
if (path.length > DEPTH) say.fail('source.depth', `插件目录 ${path.join('/')} 在仓库根下超过 ${DEPTH} 层:Coda 扫不到`);
|
|
445
|
+
const bad = path.find(p => !SEGMENT.test(p));
|
|
446
|
+
if (bad) say.fail('source.segment', `目录名 ${bad} 只能用字母、数字和「._-」`);
|
|
447
|
+
} else if (!SEGMENT.test(basename(dir))) say.fail('source.segment', `目录名 ${basename(dir)} 只能用字母、数字和「._-」`);
|
|
448
|
+
if (!link && !module && size <= SIZE_LIMIT) say.pass('source.files', `从仓库安装的限制:无符号链接与子模块,整个目录 ${(size / 1024).toFixed(1)} KiB`);
|
|
449
|
+
const untracked = entries.filter(e => e.untracked), missing = entries.filter(e => e.missing);
|
|
450
|
+
if (untracked.length) say.warn('source.untracked', `${untracked.length} 个文件还没提交(如 ${untracked[0].name}):Coda 只读提交里的文件`);
|
|
451
|
+
if (missing.length) say.warn('source.missing', `${missing.length} 个已跟踪的文件在工作区里不见了(如 ${missing[0].name})`);
|
|
452
|
+
if (!top) say.info('source.git', '目录不在 Git 仓库里:按目录里的文件检查,提交后以 Coda 读到的为准');
|
|
453
|
+
|
|
454
|
+
const defaults = Object.fromEntries(Object.entries(settings).map(([key, spec]) => [key, spec.default ?? (spec.entries ? {} : null)]));
|
|
455
|
+
return { id: name, manifest, agents, panels, settings: defaults, dependencies, path: path?.join('/') ?? null };
|
|
456
|
+
}
|
package/check/panel.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// A panel at the handshake level: its command run once as Coda reads a panel (backend/src/application/panels.mjs `env`,
|
|
2
|
+
// adapters/plugin-command.mjs), what it prints held to Coda's panel output (domain/panels.mjs `panelItems`, copied here).
|
|
3
|
+
// Its actions are not run: a button may change things (switch an account, spend a reset), and only a person presses it.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
import { ARGS_LIMIT, ITEM_LIMITS, ITEM_TYPES, OUTPUT_LIMIT, PANEL_READ_LIMIT, THREAD_ENV, cleared, groupsState, pluginEnv } from './coda.mjs';
|
|
8
|
+
import { group, temporary } from './leftovers.mjs';
|
|
9
|
+
|
|
10
|
+
class Invalid extends Error {}
|
|
11
|
+
const check = (condition, message) => { if (!condition) throw new Invalid(message); };
|
|
12
|
+
const object = (value, where) => check(value && typeof value === 'object' && !Array.isArray(value), `${where} 须为对象`);
|
|
13
|
+
const keys = (value, allowed, where) => {
|
|
14
|
+
const extra = Object.keys(value).filter(k => !allowed.includes(k));
|
|
15
|
+
check(!extra.length, `${where} 里有 Coda 不支持的键:${extra.join('、')}`);
|
|
16
|
+
};
|
|
17
|
+
const text = (value, max, where, { need = true, line = true } = {}) => {
|
|
18
|
+
if (value === undefined && !need) return;
|
|
19
|
+
check(typeof value === 'string' && value.trim() && [...value].length <= max && !value.includes('\0') && (!line || !/[\r\n]/.test(value)),
|
|
20
|
+
`${where} 须为不超过 ${max} 字的${line ? '一行' : ''}文本`);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function item(raw, where, actions, inCard) {
|
|
24
|
+
const L = ITEM_LIMITS;
|
|
25
|
+
object(raw, where);
|
|
26
|
+
check(ITEM_TYPES.includes(raw.type), `${where} 的 type 只能是 ${ITEM_TYPES.join('、')}`);
|
|
27
|
+
check(!(inCard && raw.type === 'card'), `${where}:卡片里不能再放卡片`);
|
|
28
|
+
switch (raw.type) {
|
|
29
|
+
case 'card':
|
|
30
|
+
keys(raw, ['type', 'title', 'subtitle', 'identity', 'items'], where);
|
|
31
|
+
check(Array.isArray(raw.items) && raw.items.length <= L.cardItems, `${where} 的 items 须为不超过 ${L.cardItems} 项的数组`);
|
|
32
|
+
text(raw.title, L.label, `${where} 的 title`); text(raw.subtitle, L.label, `${where} 的 subtitle`, { need: false });
|
|
33
|
+
text(raw.identity, L.identity, `${where} 的 identity`, { need: false });
|
|
34
|
+
return [raw.type, ...raw.items.flatMap((r, i) => item(r, `${where}.items[${i}]`, actions, true))];
|
|
35
|
+
case 'meter':
|
|
36
|
+
keys(raw, ['type', 'label', 'value', 'fraction', 'note', 'time', 'timeLabel'], where);
|
|
37
|
+
check(raw.fraction === undefined || raw.fraction === null || (typeof raw.fraction === 'number' && raw.fraction >= 0 && raw.fraction <= 1),
|
|
38
|
+
`${where} 的 fraction 须在 0–1 之间(还剩多少),不知道时不写`);
|
|
39
|
+
check(raw.time === undefined || (typeof raw.time === 'string' && Number.isFinite(Date.parse(raw.time))), `${where} 的 time 须为 ISO 8601 时间`);
|
|
40
|
+
text(raw.label, L.label, `${where} 的 label`);
|
|
41
|
+
for (const key of ['value', 'note', 'timeLabel']) text(raw[key], L.label, `${where} 的 ${key}`, { need: false });
|
|
42
|
+
return [raw.type];
|
|
43
|
+
case 'stat':
|
|
44
|
+
keys(raw, ['type', 'label', 'value'], where);
|
|
45
|
+
text(raw.label, L.label, `${where} 的 label`); text(raw.value, L.label, `${where} 的 value`);
|
|
46
|
+
return [raw.type];
|
|
47
|
+
case 'markdown':
|
|
48
|
+
keys(raw, ['type', 'text'], where);
|
|
49
|
+
text(raw.text, L.markdown, `${where} 的 text`, { line: false });
|
|
50
|
+
return [raw.type];
|
|
51
|
+
case 'error':
|
|
52
|
+
keys(raw, ['type', 'text'], where);
|
|
53
|
+
text(raw.text, L.error, `${where} 的 text`, { line: false });
|
|
54
|
+
return [raw.type];
|
|
55
|
+
case 'button':
|
|
56
|
+
keys(raw, ['type', 'label', 'action', 'args', 'disabled', 'confirm'], where);
|
|
57
|
+
check(typeof raw.action === 'string' && Object.hasOwn(actions, raw.action), `${where} 的 action 须是面板声明的动作:${raw.action}`);
|
|
58
|
+
check(raw.args === undefined || (raw.args && typeof raw.args === 'object' && !Array.isArray(raw.args) && JSON.stringify(raw.args).length <= ARGS_LIMIT),
|
|
59
|
+
`${where} 的 args 须为不超过 ${ARGS_LIMIT} 字节的对象`);
|
|
60
|
+
check(raw.disabled === undefined || typeof raw.disabled === 'boolean', `${where} 的 disabled 须为开或关`);
|
|
61
|
+
text(raw.label, L.label, `${where} 的 label`); text(raw.confirm, L.confirm, `${where} 的 confirm`, { need: false, line: false });
|
|
62
|
+
return [raw.type];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// The item types a reading holds, cards' items included; throws Invalid with where it went wrong.
|
|
66
|
+
export function panelItems(output, panel) {
|
|
67
|
+
check(Buffer.byteLength(output) <= OUTPUT_LIMIT, `输出超过 ${OUTPUT_LIMIT / 1024} KiB`);
|
|
68
|
+
let value;
|
|
69
|
+
try { value = JSON.parse(output); } catch { check(false, '输出不是 JSON'); }
|
|
70
|
+
object(value, '输出');
|
|
71
|
+
keys(value, ['items'], '输出');
|
|
72
|
+
check(Array.isArray(value.items) && value.items.length <= ITEM_LIMITS.items, `输出的 items 须为不超过 ${ITEM_LIMITS.items} 项的数组`);
|
|
73
|
+
return value.items.flatMap((r, i) => item(r, `items[${i}]`, panel.actions, false));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `sh -c` in its own process group from a temporary directory, stopped past the deadline or past 64 KiB of output. The
|
|
77
|
+
// group goes with the checker if that ends first (a Ctrl-C does not reach it).
|
|
78
|
+
function run(command, env, limit) {
|
|
79
|
+
return new Promise(resolve => {
|
|
80
|
+
const child = spawn('/bin/sh', ['-c', command], { cwd: tmpdir(), env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
81
|
+
const ended = group(child.pid), out = [], err = [];
|
|
82
|
+
let size = 0, overflow = false, timedOut = false;
|
|
83
|
+
const kill = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} ended(); };
|
|
84
|
+
const timer = setTimeout(() => { timedOut = true; kill(); }, limit);
|
|
85
|
+
child.stdout.on('data', chunk => { size += chunk.length; if (size > OUTPUT_LIMIT) { overflow = true; kill(); } else out.push(chunk); });
|
|
86
|
+
child.stderr.on('data', chunk => err.push(chunk));
|
|
87
|
+
child.on('error', error => { clearTimeout(timer); resolve({ code: null, error: error.message, stdout: '', stderr: '' }); });
|
|
88
|
+
child.on('close', code => { clearTimeout(timer); kill();
|
|
89
|
+
resolve({ code, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8'), overflow, timedOut }); });
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const lastLine = text => text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').split('\n').map(l => l.trim()).filter(Boolean).at(-1)?.slice(0, 200) ?? '';
|
|
93
|
+
|
|
94
|
+
export async function checkPanel({ panel, pluginDir, settings, dependencies = {}, say, limit = PANEL_READ_LIMIT }) {
|
|
95
|
+
// The plugin's state directory, which Coda keeps for it; here a temporary one, gone however the checker ends.
|
|
96
|
+
const state = temporary('coda-plugin-check-state-');
|
|
97
|
+
try {
|
|
98
|
+
const env = { ...cleared(process.env), ...pluginEnv({ pluginDir, stateDir: state.dir, settings, dependencies }), CODA_GROUPS_STATE: groupsState(),
|
|
99
|
+
PATH: [dirname(process.execPath), process.env.PATH].filter(Boolean).join(':') };
|
|
100
|
+
delete env[THREAD_ENV];
|
|
101
|
+
const started = Date.now(), result = await run(panel.command, env, limit);
|
|
102
|
+
if (result.timedOut) return say.fail('panel.read', `读取 ${limit / 1000} 秒没结束:Coda 停下这次读取`);
|
|
103
|
+
if (result.overflow) return say.fail('panel.read', `标准输出超过 ${OUTPUT_LIMIT / 1024} KiB:Coda 停下这次读取`);
|
|
104
|
+
if (result.code !== 0) return say.fail('panel.read', `读取命令${result.error ? `起不来:${result.error}` : `退出码 ${result.code}`}${lastLine(result.stderr || result.stdout) ? `:${lastLine(result.stderr || result.stdout)}` : ''}`);
|
|
105
|
+
let types;
|
|
106
|
+
try { types = panelItems(result.stdout, panel); } catch (e) { if (!(e instanceof Invalid)) throw e; return say.fail('panel.output', `输出不合面板格式:${e.message}`); }
|
|
107
|
+
const counts = Object.entries(Object.groupBy(types, t => t)).map(([t, list]) => `${t} ×${list.length}`).join(',');
|
|
108
|
+
if (!types.length) say.info('panel.read', `读出空的 items(${((Date.now() - started) / 1000).toFixed(1)} 秒):资源页不显示它${panel.signIn ? ',它出现在「添加资源」里' : ''}`);
|
|
109
|
+
else say.pass('panel.read', `读取 ${((Date.now() - started) / 1000).toFixed(1)} 秒,输出合面板格式:${counts}`);
|
|
110
|
+
if (Object.keys(panel.actions).length) say.skip('panel.actions', `动作 ${Object.keys(panel.actions).join('、')} 不运行:按钮可能改动东西,只由人点`);
|
|
111
|
+
} finally {
|
|
112
|
+
state.remove();
|
|
113
|
+
}
|
|
114
|
+
}
|