@coda-console/plugin-check 0.4.9 → 0.5.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/README.md +86 -17
- package/bin/check.mjs +4 -3
- package/check/agent.mjs +29 -19
- package/check/coda/builtin.json +1273 -0
- package/check/coda/components/plugins/agent-plugins.mjs +88 -0
- package/check/coda/components/plugins/plugin-extensions.mjs +171 -0
- package/check/coda/components/plugins/plugin-rules.mjs +819 -0
- package/check/coda/components/plugins/plugin-source-rules.mjs +362 -0
- package/check/coda/components/plugins/skill-document.mjs +35 -0
- package/check/coda/components/prompt-recovery/prompt-recovery.mjs +56 -0
- package/check/coda/components/session-config/commands.mjs +111 -0
- package/check/coda/components/workflows/executor-labels.mjs +12 -0
- package/check/coda/contract.json +29 -0
- package/check/coda/domain/agent-protocol.mjs +45 -0
- package/check/coda/domain/duration.mjs +11 -0
- package/check/coda/domain/errors.mjs +11 -0
- package/check/coda/domain/values.mjs +39 -0
- package/check/coda/kernel/agent-failures.mjs +16 -0
- package/check/coda/resources/plugins/resources/panels/command.mjs +46 -0
- package/check/coda/resources/plugins/resources/panels/panels.mjs +225 -0
- package/check/coda/resources/plugins/resources/panels/rules.mjs +242 -0
- package/check/coda/resources/plugins/resources/panels/sign-in.mjs +131 -0
- package/check/index.mjs +3 -4
- package/check/leftovers.mjs +9 -3
- package/check/manifest.mjs +198 -434
- package/check/panel.mjs +49 -100
- package/check/waits.mjs +5 -4
- package/package.json +4 -2
- package/check/coda.mjs +0 -165
package/check/manifest.mjs
CHANGED
|
@@ -1,209 +1,127 @@
|
|
|
1
|
-
// The manifest level: what Coda reads of a plugin's directory before anything of it runs
|
|
2
|
-
// copied from Coda
|
|
3
|
-
// plugin-
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// The manifest level: what Coda reads of a plugin's directory before anything of it runs, by Coda's own code (check/coda/,
|
|
2
|
+
// copied as it is from the Coda commit check/coda/contract.json names), in the order its「从仓库添加…」reads a repository
|
|
3
|
+
// (components/plugins/plugin-sources.mjs readSource, inspect): whether it finds the directory, what the directory holds
|
|
4
|
+
// (treeProblem), the plugin (readPlugin), its npm packages (manifestProblem), and the plugin beside those of the machine it
|
|
5
|
+
// goes to (candidate). Coda stops at the first thing it refuses, and the verdict here is that one failure; the rest is
|
|
6
|
+
// advice of the checker's that Coda does not refuse a plugin for. The machine is a Coda fresh from installing it: the
|
|
7
|
+
// plugins it ships (check/coda/builtin.json, as its builtinPlugins reads them), those it installs from the start installed.
|
|
6
8
|
import { execFileSync } from 'node:child_process';
|
|
7
9
|
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
8
|
-
import { basename,
|
|
10
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
9
11
|
import { inflateSync } from 'node:zlib';
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
import shipped from './coda/builtin.json' with { type: 'json' };
|
|
13
|
+
import { choosesModelProviders, pluginEnv, readPlugin, resolveSettings } from './coda/components/plugins/plugin-rules.mjs';
|
|
14
|
+
import { candidate, candidates, catalogAfter, manifestId, manifestProblem, npmPackages, repoAddress, sameSource, sourcePath, treeProblem } from './coda/components/plugins/plugin-source-rules.mjs';
|
|
15
|
+
import { panelEntries } from './coda/resources/plugins/resources/panels/rules.mjs';
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
// The plugins Coda ships, by ID. Their files are Coda's, not here: none has a directory on this machine, and what Coda gives
|
|
18
|
+
// a plugin's processes of them names none.
|
|
19
|
+
const builtin = new Map(shipped.map(plugin => [plugin.id, { ...plugin, dir: undefined }]));
|
|
20
|
+
// Those it installs from the start, as rows without a source (components/plugins/plugins.mjs), and its catalog, which is
|
|
21
|
+
// they and nothing installed from elsewhere yet.
|
|
22
|
+
const installed = new Map(shipped.filter(plugin => plugin.defaultInstalled).map(plugin => [plugin.id, { id: plugin.id, source: null }]));
|
|
23
|
+
const catalog = new Map(builtin);
|
|
15
24
|
// The categories Coda's own plugins use, which its plugin pages list first, in this order.
|
|
16
25
|
export const CATEGORIES = ['Agent', '模型与账号', '工作流程', '机器与资源', 'Skills 与工具'];
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const keys = (value, allowed, where) => {
|
|
20
|
-
const extra = Object.keys(value).filter(k => !allowed.includes(k));
|
|
21
|
-
check(!extra.length, `${where} 里有 Coda 不支持的键:${extra.join('、')}`);
|
|
22
|
-
};
|
|
23
|
-
const line = (value, max, where) => {
|
|
24
|
-
check(typeof value === 'string' && value.trim() && [...value].length <= max && !/[\0\r\n]/.test(value), `${where} 须为不超过 ${max} 字的一行文本`);
|
|
25
|
-
return value;
|
|
26
|
-
};
|
|
27
|
-
// panels.mjs `text`: a confirmation or a note may run over lines.
|
|
28
|
-
const text = (value, max, where, { line: one = true } = {}) => {
|
|
29
|
-
check(typeof value === 'string' && value.trim() && [...value].length <= max && !value.includes('\0') && (!one || !/[\r\n]/.test(value)),
|
|
30
|
-
`${where} 须为不超过 ${max} 字的${one ? '一行' : ''}文本`);
|
|
31
|
-
return value;
|
|
32
|
-
};
|
|
33
|
-
// agent-plugins.mjs `inside`: relative, never leaving the directory.
|
|
34
|
-
const inside = (value, where, { segments = Infinity } = {}) => {
|
|
35
|
-
line(value, AGENT_LIMITS.path, where);
|
|
36
|
-
const path = normalize(value);
|
|
37
|
-
check(!isAbsolute(value) && path !== '..' && !path.startsWith(`..${sep}`) && path.split(sep).length <= segments,
|
|
38
|
-
`${where} 须为不离开所在目录的相对路径${Number.isFinite(segments) ? `,至多 ${segments} 段` : ''}`);
|
|
39
|
-
return path;
|
|
40
|
-
};
|
|
41
|
-
// JSON Schema's `format: duration` (Coda reads it with @0dep/piso): an ISO 8601 duration, not negative. Close enough here.
|
|
42
|
-
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)?)?$/;
|
|
43
|
-
const isDuration = value => typeof value === 'string' && value.length <= 200 && DURATION.test(value);
|
|
26
|
+
// Where a refusal comes in the order Coda reads: one after the first is not reached.
|
|
27
|
+
const ORDER = ['source.depth', 'source.segment', 'source.files', 'manifest.read', 'source.npm', 'source.catalog'];
|
|
44
28
|
|
|
45
|
-
// ----
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
check(value.format === undefined && value.minimum === undefined && value.maximum === undefined, `${where}:多个模型服务不写 format、minimum 与 maximum`);
|
|
66
|
-
check(value.uniqueItems === undefined || value.uniqueItems === true, `${where}:多个模型服务的 uniqueItems 只能是 true`);
|
|
67
|
-
check(Object.keys(items).every(k => ['type', 'format', 'modelApis'].includes(k)) && items.type === 'string',
|
|
68
|
-
`${where}:多个模型服务写 items: { type: string, format: coda-model-provider, modelApis }`);
|
|
69
|
-
check(items.modelApis === undefined || (Array.isArray(items.modelApis) && items.modelApis.length >= 1 && items.modelApis.length <= 10
|
|
70
|
-
&& items.modelApis.every(api => typeof api === 'string' && COMMAND.test(api))), `${where}.items.modelApis 须为 1–10 个接口名,如 anthropic-messages`);
|
|
71
|
-
out.format = 'coda-model-provider';
|
|
72
|
-
} else if (value.type === 'array') {
|
|
73
|
-
check(value.format === undefined && value.minimum === undefined && value.maximum === undefined, `${where}:多选不写 format、minimum 与 maximum`);
|
|
74
|
-
check(value.uniqueItems === undefined || value.uniqueItems === true, `${where}:多选的 uniqueItems 只能是 true(每项至多选一次)`);
|
|
75
|
-
const items = value.items, options = items?.anyOf;
|
|
76
|
-
check(items && typeof items === 'object' && !Array.isArray(items) && Object.keys(items).every(k => k === 'anyOf') && Array.isArray(options)
|
|
77
|
-
&& options.length >= 1 && options.length <= 50, `${where}:多选写 items: { anyOf: [{ const, title, description }] },1–50 项`);
|
|
78
|
-
out.options = options.map((option, i) => {
|
|
79
|
-
check(option && typeof option === 'object' && !Array.isArray(option) && Object.keys(option).every(k => ['const', 'title', 'description'].includes(k))
|
|
80
|
-
&& typeof option.const === 'string' && OPTION.test(option.const), `${where}.items.anyOf[${i}] 须为 { const, title, description },const 只用字母、数字和「._-」`);
|
|
81
|
-
check(['title', 'description'].every(k => option[k] === undefined || (typeof option[k] === 'string' && option[k].length <= 400)),
|
|
82
|
-
`${where}.items.anyOf[${i}] 的 title 与 description 须为不超过 400 字的文字`);
|
|
83
|
-
return option.const;
|
|
84
|
-
});
|
|
85
|
-
check(new Set(out.options).size === out.options.length, `${where}:多选的 const 不能重复`);
|
|
86
|
-
}
|
|
87
|
-
if (value.type === 'object' && !agent) {
|
|
88
|
-
check(!entry, `${where}:表格的每一项只能是单个值或 Agent 组合`);
|
|
89
|
-
check(value.additionalProperties !== undefined, `${where} 需要 additionalProperties`);
|
|
90
|
-
out.entries = schema(value.additionalProperties, `${where}.additionalProperties`, true);
|
|
91
|
-
if (value.propertyNames !== undefined) {
|
|
92
|
-
check(value.propertyNames && Object.keys(value.propertyNames).every(k => k === 'format') && value.propertyNames.format === 'coda-agent-match',
|
|
93
|
-
`${where}.propertyNames 只支持 format: coda-agent-match`);
|
|
94
|
-
out.keys = 'coda-agent-match';
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
if (value.default !== undefined) { checkValue(out, value.default, `${where} 的默认值`); out.default = value.default; }
|
|
98
|
-
return out;
|
|
29
|
+
// ---- The repository, as Coda's reading of it sees it ----
|
|
30
|
+
// The repository a directory is read from, as Coda would be given it — its Git origin, else its top directory (Coda reads a
|
|
31
|
+
// repository of this machine by its absolute path) —, the directory in it, and the files a commit of it would hold: those
|
|
32
|
+
// Git tracks, and those it would once they are committed (`untracked`). Outside Git, the directory is a repository of its own.
|
|
33
|
+
function repository(dir) {
|
|
34
|
+
const real = realpathSync(dir);
|
|
35
|
+
const git = (at, args) => execFileSync('git', ['-C', at, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
36
|
+
let top = null;
|
|
37
|
+
try { top = git(real, ['rev-parse', '--show-toplevel']).trim(); } catch {}
|
|
38
|
+
if (!top) return { top: null, at: real, repo: real, path: '', commit: null, files: walk(real) };
|
|
39
|
+
const tracked = git(top, ['ls-files', '-s', '-z']).split('\0').filter(Boolean).map(entry => {
|
|
40
|
+
const tab = entry.indexOf('\t'), [mode] = entry.slice(0, tab).split(' ');
|
|
41
|
+
return { mode, name: entry.slice(tab + 1) };
|
|
42
|
+
});
|
|
43
|
+
const untracked = git(top, ['ls-files', '-o', '--exclude-standard', '-z']).split('\0').filter(Boolean).map(name => ({ name, untracked: true }));
|
|
44
|
+
let repo = top, commit = null;
|
|
45
|
+
try { repo = repoAddress(git(top, ['remote', 'get-url', 'origin']).trim()); } catch {}
|
|
46
|
+
try { commit = git(top, ['rev-parse', 'HEAD']).trim(); } catch {}
|
|
47
|
+
// Git names the top by its real path; so is the directory measured.
|
|
48
|
+
return { top, at: top, repo, commit, path: relative(top, real).split(sep).filter(Boolean).join('/'), files: [...tracked, ...untracked].map(e => withStat(top, e)) };
|
|
99
49
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
if (spec.type === 'array') {
|
|
113
|
-
check(Array.isArray(value) && value.every(v => typeof v === 'string'), `${where}须为选中的几项`);
|
|
114
|
-
const unknown = value.filter(v => !spec.options.includes(v));
|
|
115
|
-
check(!unknown.length, `${where}只能从这些里选:${spec.options.join('、')}(没有 ${unknown.join('、')})`);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
if (spec.type === 'boolean') check(typeof value === 'boolean', `${where}须为开或关`);
|
|
119
|
-
else if (spec.type === 'integer' || spec.type === 'number') {
|
|
120
|
-
check(typeof value === 'number' && Number.isFinite(value) && (spec.type === 'number' || Number.isInteger(value)), `${where}须为${spec.type === 'integer' ? '整数' : '数字'}`);
|
|
121
|
-
check((spec.minimum === undefined || value >= spec.minimum) && (spec.maximum === undefined || value <= spec.maximum), `${where}须在 ${spec.minimum ?? '-∞'}–${spec.maximum ?? '∞'} 之间`);
|
|
122
|
-
} else if (spec.type === 'string') {
|
|
123
|
-
check(typeof value === 'string' && value.length <= 20000 && !value.includes('\0'), `${where}须为文本`);
|
|
124
|
-
check(spec.format !== 'duration' || isDuration(value), `${where}须为 ISO 8601 时长,如 P30D、PT6H`);
|
|
125
|
-
// A repository's address Coda reads as it installs from one; here only the directory and the model service.
|
|
126
|
-
check(spec.format !== 'coda-directory' || value === '' || value.startsWith('/'), `${where}须为绝对目录`);
|
|
127
|
-
check(spec.format !== 'coda-model-provider' || value === '' || MODEL_PROVIDER.test(value), `${where}须为「插件/服务」,如 cliproxy/cliproxy`);
|
|
128
|
-
} else if (spec.type === 'object') {
|
|
129
|
-
object(value, where);
|
|
130
|
-
for (const [k, v] of Object.entries(value)) {
|
|
131
|
-
check(spec.keys !== 'coda-agent-match' || agentMatch(k), `${where}的「${k}」不是有效的作者`);
|
|
132
|
-
checkValue(spec.entries, v, `${where}「${k}」`);
|
|
50
|
+
// Every file but `.git`, and the npm packages installed beside them, which do not count (SIZE_LIMIT); a directory with a `.git`
|
|
51
|
+
// of its own is another repository.
|
|
52
|
+
function walk(dir) {
|
|
53
|
+
const entries = [];
|
|
54
|
+
const visit = sub => {
|
|
55
|
+
for (const name of readdirSync(join(dir, sub))) {
|
|
56
|
+
if ((!sub && name === '.git') || name === 'node_modules') continue;
|
|
57
|
+
const rel = sub ? `${sub}/${name}` : name, stat = lstatSync(join(dir, rel));
|
|
58
|
+
if (stat.isDirectory()) { if (existsSync(join(dir, rel, '.git'))) entries.push({ name: rel, mode: '160000', size: 0 }); else visit(rel); }
|
|
59
|
+
else entries.push(withStat(dir, { name: rel }));
|
|
133
60
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
61
|
+
};
|
|
62
|
+
visit('');
|
|
63
|
+
return entries;
|
|
137
64
|
}
|
|
65
|
+
function withStat(dir, entry) {
|
|
66
|
+
let stat = null;
|
|
67
|
+
try { stat = lstatSync(join(dir, entry.name)); } catch {}
|
|
68
|
+
const mode = entry.mode ?? (stat?.isSymbolicLink() ? '120000' : '100644');
|
|
69
|
+
return { ...entry, mode, size: stat && !stat.isSymbolicLink() && stat.isFile() ? stat.size : 0, missing: !stat && mode !== '160000' };
|
|
70
|
+
}
|
|
71
|
+
// The files of the directory at `path`, named within it, as `git ls-tree -r -l <commit>:<path>` lists them for Coda.
|
|
72
|
+
const within = (files, path) => path ? files.filter(f => f.name.startsWith(`${path}/`)).map(f => ({ ...f, name: f.name.slice(path.length + 1) })) : files;
|
|
73
|
+
// Coda's reading of a commit takes a directory as a plugin by its manifest saying what it contributes.
|
|
74
|
+
const contributing = manifest => !!manifest && typeof manifest === 'object' && !Array.isArray(manifest) && 'contributes' in manifest;
|
|
138
75
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
check(PANEL_ID.test(name), `${at} 的动作名只能用小写字母、数字和「-」,以字母开头:${name}`);
|
|
151
|
-
object(action, `${at} 的动作 ${name}`);
|
|
152
|
-
keys(action, ['command', 'confirm'], `${at} 的动作 ${name}`);
|
|
153
|
-
actions[name] = { command: text(action.command, PANEL_LIMITS.command, `${at} 的动作 ${name} 的 command`),
|
|
154
|
-
...(action.confirm !== undefined ? { confirm: text(action.confirm, PANEL_LIMITS.confirm, `${at} 的动作 ${name} 的 confirm`, { line: false }) } : {}) };
|
|
155
|
-
}
|
|
76
|
+
// What a directory is as a plugin, as Coda's inspect says it (`found`: `{ plugin }` and why it cannot be installed, or only
|
|
77
|
+
// why), with the rule the first refusal is said under. `plugin` is read even where Coda stopped before it, for the levels
|
|
78
|
+
// after this one; `lock` is the package-lock.json a commit would hold (null without one, undefined when it does not parse).
|
|
79
|
+
// A file system's error reading it (a file where Coda reads a directory, say) is no problem of the directory's own to Coda:
|
|
80
|
+
// it fails Coda's reading of the whole repository (`broken`).
|
|
81
|
+
function inspect(dir, files, source) {
|
|
82
|
+
let plugin = null, manifest = null, unread = null, broken = false;
|
|
83
|
+
try { plugin = readPlugin(dir, source); manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); }
|
|
84
|
+
catch (e) {
|
|
85
|
+
if (e.code !== 'invalid_plugin' && !e.syscall) throw e;
|
|
86
|
+
unread = e.code === 'invalid_plugin' ? e.message : `读不了:${e.message}`; broken = !!e.syscall && e.code !== 'ENOENT';
|
|
156
87
|
}
|
|
157
|
-
let
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (s.installed !== undefined) signIn.installed = text(s.installed, PANEL_LIMITS.command, `${w} 的 installed`);
|
|
166
|
-
if (s.install !== undefined) signIn.install = text(s.install, PANEL_LIMITS.command, `${w} 的 install`);
|
|
167
|
-
if (s.note !== undefined) text(s.note, PANEL_LIMITS.note, `${w} 的 note`, { line: false });
|
|
168
|
-
}
|
|
169
|
-
return { id: raw.id, title: text(raw.title, PANEL_LIMITS.title, `${at} 的 title`), command: text(raw.command, PANEL_LIMITS.command, `${at} 的 command`),
|
|
170
|
-
actions, ...(signIn ? { signIn } : {}) };
|
|
88
|
+
let lock = null;
|
|
89
|
+
if (files.some(f => f.name === 'package-lock.json' && !f.missing)) try { lock = JSON.parse(readFileSync(join(dir, 'package-lock.json'), 'utf8')); } catch { lock = undefined; }
|
|
90
|
+
const tree = treeProblem(files);
|
|
91
|
+
if (tree) return { found: { problem: tree }, rule: 'source.files', plugin, lock };
|
|
92
|
+
if (unread) return { found: { problem: unread }, rule: 'manifest.read', plugin, lock, broken };
|
|
93
|
+
const npm = manifestProblem(manifest, lock);
|
|
94
|
+
plugin = { ...plugin, npm: npmPackages(manifest, lock) };
|
|
95
|
+
return { found: { plugin, problem: npm }, rule: npm ? 'source.npm' : null, plugin, lock };
|
|
171
96
|
}
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
|
|
179
|
-
check(data.subarray(0, 5).toString('latin1') === '%PDF-', `${where} 须为 PDF`);
|
|
180
|
-
return { path, size: data.length, data };
|
|
97
|
+
// Why Coda's reading of the repository does not get to the directory at `path`, or null: it looks for plugins only so deep
|
|
98
|
+
// (candidates), and a source names only some directories (sourcePath; readSource says so in these words).
|
|
99
|
+
function located(path) {
|
|
100
|
+
if (!candidates([path ? `${path}/package.json` : 'package.json']).length)
|
|
101
|
+
return { problem: `插件目录 ${path} 在仓库根下超过 2 层(或在 node_modules 里):Coda 读仓库时扫不到它`, rule: 'source.depth' };
|
|
102
|
+
try { sourcePath(path); } catch { return { problem: `目录名 ${path} 只能用字母、数字和「._-」`, rule: 'source.segment' }; }
|
|
103
|
+
return null;
|
|
181
104
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (modes[key] !== undefined) line(modes[key], max, `${at} 的 modes.${key}`);
|
|
199
|
-
if (raw.skillsDir !== undefined) inside(raw.skillsDir, `${at} 的 skillsDir`, { segments: 3 });
|
|
200
|
-
(raw.configFiles ?? []).forEach((file, i) => line(file, L.path, `${at} 的 configFiles[${i}]`));
|
|
201
|
-
if (raw.setupHint !== undefined) line(raw.setupHint, L.setupHint, `${at} 的 setupHint`);
|
|
202
|
-
return { id: raw.id, displayName: raw.displayName, command: raw.command, icons, modes };
|
|
105
|
+
// The other plugins of the same commit, by ID → `{ path, plugin }`: those that read, as Coda's readSource finds them; and
|
|
106
|
+
// those whose reading fails Coda's reading of the whole repository (`broken`: path → why).
|
|
107
|
+
function siblingsOf(repo, own) {
|
|
108
|
+
const found = new Map(), broken = new Map();
|
|
109
|
+
for (const path of candidates(repo.files.filter(f => !f.missing).map(f => f.name))) {
|
|
110
|
+
if (path === own) continue;
|
|
111
|
+
const dir = join(repo.at, path);
|
|
112
|
+
let manifest = null;
|
|
113
|
+
try { manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); } catch {}
|
|
114
|
+
if (!contributing(manifest)) continue;
|
|
115
|
+
try { sourcePath(path); } catch { continue; }
|
|
116
|
+
const read = inspect(dir, within(repo.files, path), { kind: 'git', repo: repo.repo, path, ref: '', commit: repo.commit });
|
|
117
|
+
if (read.broken) broken.set(path, read.found.problem);
|
|
118
|
+
else if (read.plugin && !read.found.problem) found.set(read.plugin.id, { path, plugin: read.plugin });
|
|
119
|
+
}
|
|
120
|
+
return { found, broken };
|
|
203
121
|
}
|
|
122
|
+
|
|
204
123
|
// 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
|
|
205
|
-
// 256 points,
|
|
206
|
-
// The page box may sit in a compressed object stream (PDF 1.5 and later, as CairoSVG writes them).
|
|
124
|
+
// 256 points). The page box may sit in a compressed object stream (PDF 1.5 and later, as CairoSVG writes them).
|
|
207
125
|
function pageSize(data) {
|
|
208
126
|
const MEDIA_BOX = /\/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\]/;
|
|
209
127
|
const raw = data.toString('latin1'), texts = [raw];
|
|
@@ -219,261 +137,107 @@ function pageSize(data) {
|
|
|
219
137
|
return null;
|
|
220
138
|
}
|
|
221
139
|
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
const git = args => execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
227
|
-
let top = null;
|
|
228
|
-
try { top = git(['rev-parse', '--show-toplevel']).trim(); } catch {}
|
|
229
|
-
if (top) {
|
|
230
|
-
const tracked = git(['ls-files', '-s', '-z', '--', '.']).split('\0').filter(Boolean).map(entry => {
|
|
231
|
-
const tab = entry.indexOf('\t'), [mode] = entry.slice(0, tab).split(' ');
|
|
232
|
-
return { mode, name: entry.slice(tab + 1) };
|
|
233
|
-
});
|
|
234
|
-
const untracked = git(['ls-files', '-o', '--exclude-standard', '-z', '--', '.']).split('\0').filter(Boolean).map(name => ({ name, untracked: true }));
|
|
235
|
-
return { top, entries: [...tracked, ...untracked].map(e => withStat(dir, e)) };
|
|
236
|
-
}
|
|
237
|
-
const entries = [];
|
|
238
|
-
const walk = sub => {
|
|
239
|
-
for (const name of readdirSync(join(dir, sub))) {
|
|
240
|
-
if (!sub && name === '.git') continue;
|
|
241
|
-
const rel = sub ? `${sub}/${name}` : name, stat = lstatSync(join(dir, rel));
|
|
242
|
-
if (stat.isDirectory()) { if (existsSync(join(dir, rel, '.git'))) entries.push({ name: rel, mode: '160000', size: 0 }); else walk(rel); }
|
|
243
|
-
else entries.push(withStat(dir, { name: rel }));
|
|
244
|
-
}
|
|
245
|
-
};
|
|
246
|
-
walk('');
|
|
247
|
-
return { top: null, entries };
|
|
248
|
-
}
|
|
249
|
-
// Why npm could not install from the lock file next to the manifest (`npm ci` refuses a lock that does not match; Coda's
|
|
250
|
-
// manifestProblem refuses it first), or null.
|
|
251
|
-
function lockProblem(dir, manifest) {
|
|
252
|
-
let lock;
|
|
253
|
-
try { lock = JSON.parse(readFileSync(join(dir, NPM_LOCK), 'utf8')); } catch (e) { return `${NPM_LOCK} 读不了:${e.message}`; }
|
|
254
|
-
const root = lock?.packages?.[''];
|
|
255
|
-
if (!root) return `${NPM_LOCK} 不是 npm 7 以后的格式(没有 packages[""])`;
|
|
256
|
-
const differ = NPM.filter(key => JSON.stringify(Object.entries(manifest[key] ?? {}).sort()) !== JSON.stringify(Object.entries(root[key] ?? {}).sort()));
|
|
257
|
-
return differ.length ? `${NPM_LOCK} 与 package.json 的 ${differ.join('、')} 对不上:npm ci 会拒绝,在插件目录里 npm install 重新生成` : null;
|
|
258
|
-
}
|
|
259
|
-
function withStat(dir, entry) {
|
|
260
|
-
let stat = null;
|
|
261
|
-
try { stat = lstatSync(join(dir, entry.name)); } catch {}
|
|
262
|
-
const mode = entry.mode ?? (stat?.isSymbolicLink() ? '120000' : '100644');
|
|
263
|
-
return { ...entry, mode, size: stat && !stat.isSymbolicLink() && stat.isFile() ? stat.size : 0, missing: !stat && mode !== '160000' };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Checks the manifest in `dir` and reports under `scope` (report.mjs). Returns what the later levels need: the agents and
|
|
267
|
-
// panels as declared, and the plugin's settings defaults; null when there is no manifest to read.
|
|
140
|
+
// Checks the manifest in `dir` and reports under `say` (report.mjs). Returns what the later levels need — the plugin as Coda
|
|
141
|
+
// reads it, its agents and the panels the resources plugin can read, its settings for every project, the directories of the
|
|
142
|
+
// plugins it depends on that are here, and `env(stateDir)`, what Coda gives each of its processes —; null when Coda reads no
|
|
143
|
+
// plugin there.
|
|
268
144
|
export function checkManifest(dir, say) {
|
|
269
145
|
let manifest;
|
|
270
146
|
try { manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); }
|
|
271
147
|
catch (e) { say.fail('manifest.read', `没有可读的 package.json:${e.message}`); return null; }
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
};
|
|
280
|
-
const { top, entries } = files(dir);
|
|
281
|
-
// Git names the top by its real path; so is the directory measured.
|
|
282
|
-
const path = top ? relative(top, realpathSync(dir)).split(sep).filter(Boolean) : null;
|
|
283
|
-
// A built-in plugin as its package is named: Coda ships a version of that package, and a newer one may stand in for it.
|
|
284
|
-
const declaredId = manifest.id !== undefined ? manifest.id : manifest.name;
|
|
285
|
-
const own = BUILTIN_HERE.includes(declaredId) && manifest.name === `${BUILTIN_SCOPE}${declaredId}`;
|
|
286
|
-
const id = part('manifest.name', () => {
|
|
287
|
-
check(typeof declaredId === 'string' && PLUGIN_ID.test(declaredId), `package.json 的 ${manifest.id !== undefined ? 'id' : 'name'} 只能用小写字母、数字和「-」,以字母开头,至多 40 个字符`);
|
|
288
|
-
return declaredId;
|
|
289
|
-
}, name => `package.json 能读,插件 ID ${name}${own ? `(Coda 内置插件 ${name}:Coda 带的是 npm 上 ${manifest.name} 的一个版本,这个包的新版本可以盖过它)` : ''}`);
|
|
290
|
-
const contributes = part('manifest.contributes', () => {
|
|
291
|
-
check(manifest.contributes && typeof manifest.contributes === 'object' && !Array.isArray(manifest.contributes),
|
|
292
|
-
'contributes 须为对象(Coda 只把带 contributes 的目录当插件)');
|
|
293
|
-
// A kind this copy does not know may be one Coda added since: said, for Coda's reading of the source to decide.
|
|
294
|
-
const unknown = Object.keys(manifest.contributes).filter(k => !CONTRIBUTES.includes(k) && !POINT_KEYS.includes(k));
|
|
295
|
-
if (unknown.length) say.warn('manifest.contributes-unknown', `contributes 里有检查不认识的贡献:${unknown.join('、')}:可能是写错,或是 Coda 新加的;以 Coda「从仓库添加…」的读取为准`);
|
|
296
|
-
// What is checked here is installed from a repository or npm, never as a copy Coda ships.
|
|
297
|
-
for (const [key, refusal] of Object.entries(BUILTIN_ONLY)) check(manifest.contributes[key] === undefined, `contributes.${key}:${refusal}`);
|
|
298
|
-
return manifest.contributes;
|
|
299
|
-
}) ?? {};
|
|
300
|
-
if (typeof manifest.version !== 'string') say.warn('manifest.version', 'package.json 没有 version:Coda 只认提交,version 只作显示,但安装确认里会空着');
|
|
301
|
-
// Coda's plugin pages group plugins by the first of their `categories` (Coda's docs/plugins.md「商店」), as VS Code writes them.
|
|
302
|
-
part('manifest.categories', () => {
|
|
303
|
-
check(manifest.categories === undefined || (Array.isArray(manifest.categories) && manifest.categories.every(c => typeof c === 'string')), 'categories 须为文本的数组');
|
|
304
|
-
const category = manifest.categories?.[0]?.trim();
|
|
305
|
-
if (!category) say.warn('manifest.categories', `package.json 没有 categories:Coda 的插件页把它归「其他」;分类用 ${CATEGORIES.join('、')} 之一`);
|
|
306
|
-
else if (!CATEGORIES.includes(category)) say.warn('manifest.categories', `分类「${category}」不是 Coda 自己的插件用的(${CATEGORIES.join('、')}):插件页把它排在这些之后`);
|
|
307
|
-
return category;
|
|
308
|
-
}, category => category && `分类 ${category}`);
|
|
309
|
-
const name = id ?? String(declaredId);
|
|
148
|
+
if (!contributing(manifest)) { say.fail('manifest.contributes', 'package.json 没有 contributes:Coda 读仓库时不把这个目录当插件'); return null; }
|
|
149
|
+
const repo = repository(dir), { path } = repo, files = within(repo.files, path);
|
|
150
|
+
// A plugin Coda ships, checked where it is kept: a new version of it, from the same source — its directory in the repository
|
|
151
|
+
// it comes from (as Coda's「从仓库添加…」reads it there), or the package Coda ships it as, read as from where that package is
|
|
152
|
+
// published. Any other of the same ID is a plugin from elsewhere, refused while Coda has the built-in one installed.
|
|
153
|
+
const own = [...builtin.values()].find(b => b.id === manifestId(manifest) && b.source?.repo
|
|
154
|
+
&& ((b.source.package && b.source.package === manifest.name) || sameSource(b.source, { repo: repo.repo, path })));
|
|
155
|
+
const source = { kind: 'git', repo: own?.source.repo ?? repo.repo, path: own?.source.path ?? path, ref: '', commit: repo.commit };
|
|
310
156
|
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (Object.keys(settings).length) say.pass('manifest.settings', `设置 ${Object.keys(settings).length} 项`);
|
|
320
|
-
|
|
321
|
-
part('manifest.capabilities', () => {
|
|
322
|
-
const list = sub => existsSync(join(dir, sub)) ? [...new Set(readdirSync(join(dir, sub)).map(f => /^(.+)\.bpmn$/.exec(f)?.[1]).filter(Boolean))].sort() : [];
|
|
323
|
-
const workflows = list('.coda/workflows'), listed = contributes.capabilities ?? [];
|
|
324
|
-
check(Array.isArray(listed) && listed.every(n => typeof n === 'string' && CAPABILITY.test(n)) && new Set(listed).size === listed.length,
|
|
325
|
-
'contributes.capabilities 须为能力名的数组(小写字母、数字和「-」,以字母开头),不重复');
|
|
326
|
-
const capabilities = [...listed].sort(), found = list('.coda/capabilities');
|
|
327
|
-
check(capabilities.length === found.length && capabilities.every((n, i) => n === found[i]),
|
|
328
|
-
`contributes.capabilities 与 .coda/capabilities/ 里的文件不一致:列出 ${capabilities.join('、') || '无'},文件 ${found.join('、') || '无'}`);
|
|
329
|
-
const both = capabilities.filter(n => workflows.includes(n));
|
|
330
|
-
check(!both.length, `${both.join('、')} 既是流程又是能力`);
|
|
331
|
-
if (workflows.length || capabilities.length) say.pass('manifest.capabilities', `流程 ${workflows.join('、') || '无'},能力 ${capabilities.join('、') || '无'}`);
|
|
332
|
-
});
|
|
333
|
-
part('manifest.instructions', () => {
|
|
334
|
-
const value = contributes.instructions;
|
|
335
|
-
check(value === undefined || (typeof value === 'string' && value.trim() && [...value].length <= INSTRUCTIONS_LIMIT), `contributes.instructions 须为不超过 ${INSTRUCTIONS_LIMIT} 字的说明`);
|
|
336
|
-
});
|
|
337
|
-
// A group and a command are one plugin's, as an agent is (plugin-sources.mjs catalogProblem): those of the built-in plugins
|
|
338
|
-
// are taken.
|
|
339
|
-
const taken = (what, value, builtin) => builtin[value] && builtin[value] !== name ? `${what} ${value} 已由内置插件 ${builtin[value]} 声明,换一个名字` : null;
|
|
340
|
-
const groups = part('manifest.groups', () => {
|
|
341
|
-
const groups = contributes.groups ?? [];
|
|
342
|
-
check(Array.isArray(groups) && groups.every(g => g && typeof g === 'object' && Object.keys(g).every(k => ['name', 'capacity'].includes(k))
|
|
343
|
-
&& typeof g.name === 'string' && /^\S{1,100}$/.test(g.name) && settings[g.capacity]?.type === 'integer')
|
|
344
|
-
&& new Set(groups.map(g => g.name)).size === groups.length,
|
|
345
|
-
'contributes.groups 须为 { name, capacity } 的数组:name 为 1–100 个非空白字符、不重复,capacity 是本插件声明的整数设置');
|
|
346
|
-
return groups;
|
|
347
|
-
}, groups => groups.length ? `并发组 ${groups.map(g => g.name).join('、')}` : null) ?? [];
|
|
348
|
-
part('manifest.events', () => {
|
|
349
|
-
const events = contributes.events ?? [];
|
|
350
|
-
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))
|
|
351
|
-
&& typeof e.name === 'string' && EVENT.test(e.name) && !CODA_EVENT_DOMAINS.includes(e.name.split('.')[0])
|
|
352
|
-
&& ['starts', 'ends'].every(k => e[k] === undefined || typeof e[k] === 'boolean'))
|
|
353
|
-
&& new Set(events.map(e => e.name)).size === events.length,
|
|
354
|
-
`contributes.events 须为 { name, starts?, ends? } 的数组,至多 ${EVENT_LIMIT} 个:name 是「<域>.<名>」(小写字母、数字和「_」,以字母开头,不超过 100 个字符,不重复,域不能是 Coda 自己的 ${CODA_EVENT_DOMAINS.join('、')}),starts、ends 是真假值`);
|
|
355
|
-
return events;
|
|
356
|
-
}, events => events.length ? `发布的事件 ${events.map(e => e.name).join('、')}` : null);
|
|
357
|
-
const commands = part('manifest.commands', () => {
|
|
358
|
-
const commands = contributes.commands ?? [];
|
|
359
|
-
check(Array.isArray(commands) && commands.every(c => c && typeof c === 'object' && Object.keys(c).every(k => ['name', 'command'].includes(k))
|
|
360
|
-
&& typeof c.name === 'string' && COMMAND.test(c.name) && !CODA_COMMANDS.includes(c.name)
|
|
361
|
-
&& typeof c.command === 'string' && c.command.trim() && c.command.length <= COMMAND_LIMIT && !/[\0\r\n]/.test(c.command))
|
|
362
|
-
&& new Set(commands.map(c => c.name)).size === commands.length,
|
|
363
|
-
`contributes.commands 须为 { name, command } 的数组:name 是命令名(小写字母、数字和「-」,以字母开头,不重复,不能是 Coda 自己的 ${CODA_COMMANDS.join('、')}),command 是一行命令`);
|
|
364
|
-
return commands;
|
|
365
|
-
}, commands => commands.length ? `会话 PATH 上的命令 ${commands.map(c => c.name).join('、')}` : null) ?? [];
|
|
366
|
-
part('manifest.services', () => {
|
|
367
|
-
const services = contributes.services ?? [];
|
|
368
|
-
check(Array.isArray(services) && services.every(id => typeof id === 'string' && SERVICE.test(id)) && new Set(services).size === services.length,
|
|
369
|
-
'contributes.services 须为服务 ID 的数组,不重复');
|
|
370
|
-
return services;
|
|
371
|
-
}, services => services.length ? `开启随包服务 ${services.join('、')}:没有已安装插件开启的服务不运行` : null);
|
|
372
|
-
part('manifest.setup', () => {
|
|
373
|
-
if (contributes.setup === undefined) return null;
|
|
374
|
-
object(contributes.setup, 'contributes.setup');
|
|
375
|
-
keys(contributes.setup, SETUP_KEYS, 'contributes.setup');
|
|
376
|
-
return text(contributes.setup.command, PANEL_LIMITS.command, 'contributes.setup 的 command');
|
|
377
|
-
}, command => command ? `设置动作(contributes.setup):一行命令;检查不运行它(可能装软件)` : null);
|
|
378
|
-
for (const g of groups) { const problem = taken('组', g.name, BUILTIN_GROUPS); if (problem) say.fail('manifest.claims', problem); }
|
|
379
|
-
for (const c of commands) { const problem = taken('命令', c.name, BUILTIN_COMMANDS); if (problem) say.fail('manifest.claims', problem); }
|
|
157
|
+
const where = located(path), read = inspect(dir, files, source), { found: siblings, broken } = siblingsOf(repo, path);
|
|
158
|
+
for (const [at, why] of broken) say.fail('source.read', `同一仓库的 ${at} ${why}:Coda 读这个仓库时整个出错,这个插件也装不了`);
|
|
159
|
+
const judged = candidate({ path, manifest, ...(where ? { problem: where.problem } : read.found), fallback: basename(path || repo.repo).replace(/\.git$/, '') },
|
|
160
|
+
{ catalog, installed, builtin }, siblings);
|
|
161
|
+
const refused = judged.problem ? where?.rule ?? read.rule ?? 'source.catalog' : null;
|
|
162
|
+
const reached = rule => !refused || ORDER.indexOf(rule) < ORDER.indexOf(refused);
|
|
163
|
+
if (refused) say.fail(refused, refused === 'manifest.read' ? `Coda 读不成插件:${judged.problem}`
|
|
164
|
+
: refused === 'source.npm' && read.lock === null && existsSync(join(dir, 'package-lock.json')) ? `${judged.problem}(有这个文件,但被 .gitignore 忽略了,不在提交里)` : judged.problem);
|
|
380
165
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
166
|
+
// What a commit of the directory holds, said after what Coda read of it.
|
|
167
|
+
const commit = () => {
|
|
168
|
+
const size = files.reduce((sum, e) => sum + e.size, 0), untracked = files.filter(e => e.untracked), missing = files.filter(e => e.missing);
|
|
169
|
+
if (reached('source.files') && refused !== 'source.files') say.pass('source.files', `从仓库安装的限制:无符号链接与子模块,整个目录 ${(size / 1024).toFixed(1)} KiB`);
|
|
170
|
+
if (untracked.length) say.warn('source.untracked', `${untracked.length} 个文件还没提交(如 ${untracked[0].name}):Coda 只读提交里的文件`);
|
|
171
|
+
if (missing.length) say.warn('source.missing', `${missing.length} 个已跟踪的文件在工作区里不见了(如 ${missing[0].name})`);
|
|
172
|
+
if (!repo.top) say.info('source.git', '目录不在 Git 仓库里:按目录里的文件检查,提交后以 Coda 读到的为准');
|
|
173
|
+
};
|
|
174
|
+
const { plugin } = read;
|
|
175
|
+
if (!plugin) { commit(); return null; }
|
|
389
176
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
// A version or replacement of a built-in plugin may declare its agents; installation checks whether the name is free.
|
|
398
|
-
// The old Claude ID stays nobody's.
|
|
399
|
-
const owner = BUILTIN_AGENTS[declared.id];
|
|
400
|
-
if (owner && (declared.id === 'claude' || owner !== name)) say.fail('manifest.agent-id', declared.id === 'claude'
|
|
401
|
-
? 'Agent ID claude 是 Coda 早先的 Claude 会话存的 ID:Coda 的读取来源不拦它,但装上会接管那些旧会话,换一个 ID'
|
|
402
|
-
: `Agent ${declared.id} 已由内置插件 ${owner} 声明,换一个 ID`);
|
|
403
|
-
agents.push(declared);
|
|
404
|
-
const sizes = declared.icons.map(i => `${i.path} ${(i.size / 1024).toFixed(1)} KiB`).join(',');
|
|
405
|
-
say.pass('manifest.agents', `Agent ${declared.id}「${declared.displayName}」:字段齐全,command 一行,图标 PDF(${sizes})`);
|
|
406
|
-
for (const i of declared.icons) {
|
|
407
|
-
const page = pageSize(i.data);
|
|
408
|
-
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)出图`);
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
}
|
|
177
|
+
say.pass('manifest.name', `package.json 能读,插件 ID ${plugin.id}${own ? `(Coda 内置插件 ${own.id}:Coda 带的是${own.source.package
|
|
178
|
+
? ` npm 上 ${own.source.package} 的一个版本` : ` ${own.source.repo} 的一个提交`},这里是它同一来源的新版本,可以盖过它)` : ''}`);
|
|
179
|
+
if (typeof manifest.version !== 'string') say.warn('manifest.version', 'package.json 没有 version:Coda 只认提交,version 只作显示,但安装确认里会空着');
|
|
180
|
+
// Coda's plugin pages group plugins by the first of their `categories` (Coda's docs/plugins.md「商店」).
|
|
181
|
+
if (!plugin.category) say.warn('manifest.categories', `package.json 没有 categories:Coda 的插件页把它归「其他」;分类用 ${CATEGORIES.join('、')} 之一`);
|
|
182
|
+
else if (!CATEGORIES.includes(plugin.category)) say.warn('manifest.categories', `分类「${plugin.category}」不是 Coda 自己的插件用的(${CATEGORIES.join('、')}):插件页把它排在这些之后`);
|
|
183
|
+
else say.pass('manifest.categories', `分类 ${plugin.category}`);
|
|
412
184
|
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
for (const [key, value] of Object.entries(contributes.extensions)) {
|
|
432
|
-
const where = `contributes.extensions.${key}`, [plugin, point, ...rest] = key.split('/');
|
|
433
|
-
check(PLUGIN_ID.test(plugin ?? '') && POINT.test(point ?? '') && !rest.length, `${where}:须写成「插件 ID/扩展点名」`);
|
|
434
|
-
check((deps ?? []).includes(plugin), `${where}:只能接所依赖的插件的扩展点,先把 ${plugin} 写进 extensionDependencies`);
|
|
435
|
-
object(value, where); keys(value, ['command'], where); line(value.command, 4000, `${where} 的 command`);
|
|
436
|
-
}
|
|
185
|
+
// What it brings, as Coda read it.
|
|
186
|
+
const settingCount = Object.keys(plugin.properties).length, names = list => list.map(x => x.name).join('、');
|
|
187
|
+
if (settingCount) say.pass('manifest.settings', `设置 ${settingCount} 项`);
|
|
188
|
+
if (plugin.workflows.length || plugin.capabilities.length) say.pass('manifest.capabilities', `流程 ${plugin.workflows.join('、') || '无'},能力 ${plugin.capabilities.join('、') || '无'}`);
|
|
189
|
+
if (plugin.groups.length) say.pass('manifest.groups', `并发组 ${names(plugin.groups)}`);
|
|
190
|
+
if (plugin.events?.length) say.pass('manifest.events', `发布的事件 ${names(plugin.events)}`);
|
|
191
|
+
if (plugin.commands.length) say.pass('manifest.commands', `会话 PATH 上的命令 ${names(plugin.commands)}`);
|
|
192
|
+
if (plugin.services.length) say.pass('manifest.services', `开启随包服务 ${plugin.services.join('、')}:没有已安装插件开启的服务不运行`);
|
|
193
|
+
if (plugin.setup) say.pass('manifest.setup', '设置动作(contributes.setup):一行命令;检查不运行它(可能装软件)');
|
|
194
|
+
plugin.agents.forEach((agent, i) => {
|
|
195
|
+
const declared = manifest.contributes.agents[i].icon;
|
|
196
|
+
const icons = [['light', declared.light], ...(agent.icon.dark ? [['dark', declared.dark]] : [])].map(([key, at]) => ({ at, data: Buffer.from(agent.icon[key], 'base64') }));
|
|
197
|
+
say.pass('manifest.agents', `Agent ${agent.id}「${agent.displayName}」:command 一行,图标 PDF(${icons.map(i => `${i.at} ${(i.data.length / 1024).toFixed(1)} KiB`).join(',')})`);
|
|
198
|
+
// Sessions from before #1069 store the agent ID `claude`: a plugin that declares it takes them over.
|
|
199
|
+
if (agent.id === 'claude') say.warn('manifest.agent-id', 'Agent ID claude 是 Coda 早先的 Claude 会话存的 ID:Coda 的读取来源不拦它,但装上会接管那些旧会话,换一个 ID');
|
|
200
|
+
for (const icon of icons) {
|
|
201
|
+
const page = pageSize(icon.data);
|
|
202
|
+
if (page && Math.min(page.width, page.height) < 64) say.warn('manifest.icon-page', `${icon.at} 的页面只有 ${page.width}×${page.height} pt:iOS 按 96 pt 光栅化图标、只缩不放,页面太小会画成一个点;照 Coda 自带图标按 256 px(192 pt)出图`);
|
|
437
203
|
}
|
|
438
|
-
if (!deps?.length) return;
|
|
439
|
-
for (const dep of deps) {
|
|
440
|
-
const beside = join(dir, '..', dep);
|
|
441
|
-
try {
|
|
442
|
-
const sibling = JSON.parse(readFileSync(join(beside, 'package.json'), 'utf8'));
|
|
443
|
-
if ((sibling.id !== undefined ? sibling.id : sibling.name) === dep) dependencies[dep] = realpathSync(beside);
|
|
444
|
-
} catch {}
|
|
445
|
-
}
|
|
446
|
-
const missing = deps.filter(d => !dependencies[d]);
|
|
447
|
-
say.info('manifest.extensions', `依赖 ${deps.join('、')}:Coda 装它时先装它们(这台机器的插件,或同一提交里的兄弟目录),它的进程从 CODA_PLUGIN_DEPENDENCIES 拿到它们的目录${missing.length
|
|
448
|
-
? `;${missing.join('、')} 不在旁边,检查时不带` : ''}`);
|
|
449
204
|
});
|
|
205
|
+
// Its panels, as the resources plugin reads what is written for its point: one it cannot read shows there as such.
|
|
206
|
+
const panels = [];
|
|
207
|
+
for (const entry of panelEntries(plugin.offers?.panels, plugin.displayName)) {
|
|
208
|
+
if (entry.invalid) { say.fail('manifest.panels', `面板 ${entry.invalid.id}:${entry.invalid.error}(资源页上它只显示「声明无效」)`); continue; }
|
|
209
|
+
const { panel } = entry;
|
|
210
|
+
panels.push(panel);
|
|
211
|
+
say.pass('manifest.panels', `面板 ${panel.id}「${panel.title}」:动作 ${Object.keys(panel.actions).join('、') || '无'}${panel.signIn ? ',可从 Coda 登录' : ''}`);
|
|
212
|
+
}
|
|
450
213
|
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
214
|
+
// What its processes are given (pluginEnv), on this machine: the plugins it depends on as Coda picks them (catalogAfter:
|
|
215
|
+
// the one the machine has, else the one of the same commit installed with it). One Coda ships has its directory in Coda,
|
|
216
|
+
// not here, unless this repository keeps its source (sameSource as Coda's), which stands in for it; one Coda has nowhere
|
|
217
|
+
// (it refuses the plugin for it) is not named.
|
|
218
|
+
const kept = id => { const at = siblings.get(id), shipped = builtin.get(id);
|
|
219
|
+
return at && shipped && sameSource(shipped.source, { repo: repo.repo, path: at.path }) ? at.plugin.dir : undefined; };
|
|
220
|
+
const local = catalogAfter(plugin, { catalog, siblings });
|
|
221
|
+
const given = new Map([...[...local].map(([id, each]) => [id, each.dir ? each : { ...each, dir: kept(id) }]),
|
|
222
|
+
[plugin.id, { ...plugin, dependencies: plugin.dependencies.filter(d => local.has(d)) }]]);
|
|
223
|
+
const dependencies = Object.fromEntries(plugin.dependencies.flatMap(d => given.get(d)?.dir ? [[d, given.get(d).dir]] : []));
|
|
224
|
+
if (plugin.dependencies.length) {
|
|
225
|
+
const absent = plugin.dependencies.filter(d => !dependencies[d]);
|
|
226
|
+
say.info('manifest.extensions', `依赖 ${plugin.dependencies.join('、')}:Coda 装它时先装它们(这台机器的插件,或同一提交里的兄弟目录),它的进程从 CODA_PLUGIN_DEPENDENCIES 拿到它们的目录${absent.length
|
|
227
|
+
? `;${absent.join('、')} 不在旁边,检查时不带` : ''}`);
|
|
228
|
+
}
|
|
229
|
+
commit();
|
|
230
|
+
if (reached('source.npm') && refused !== 'source.npm' && plugin.npm?.length)
|
|
231
|
+
say.pass('source.npm', `npm 依赖 ${plugin.npm.length} 个,带 package-lock.json:Coda 在插件的版本目录里 npm ci 装它们`);
|
|
232
|
+
if (!refused) {
|
|
233
|
+
const along = judged.together.map(each => each.from === 'source' ? `${each.id}(同一提交的 ${each.path})` : `${each.id}(Coda 自带)`);
|
|
234
|
+
say.pass('source.catalog', `与刚装好的 Coda 自带、默认装的插件不冲突${along.length ? `;一起装 ${along.join('、')}` : ''}`);
|
|
459
235
|
}
|
|
460
|
-
// What a repository source may not hold (plugin-sources.mjs).
|
|
461
|
-
const link = entries.find(e => e.mode === '120000'), module = entries.find(e => e.mode === '160000');
|
|
462
|
-
const size = entries.reduce((sum, e) => sum + e.size, 0);
|
|
463
|
-
if (link) say.fail('source.symlink', `目录里有符号链接:${link.name}`);
|
|
464
|
-
if (module) say.fail('source.submodule', `目录里有子模块:${module.name}`);
|
|
465
|
-
if (size > SIZE_LIMIT) say.fail('source.size', `整个目录超过 ${SIZE_LIMIT / 1024 / 1024} MiB(现为 ${(size / 1024 / 1024).toFixed(1)} MiB)`);
|
|
466
|
-
if (path) {
|
|
467
|
-
if (path.length > DEPTH) say.fail('source.depth', `插件目录 ${path.join('/')} 在仓库根下超过 ${DEPTH} 层:Coda 扫不到`);
|
|
468
|
-
const bad = path.find(p => !SEGMENT.test(p));
|
|
469
|
-
if (bad) say.fail('source.segment', `目录名 ${bad} 只能用字母、数字和「._-」`);
|
|
470
|
-
} else if (!SEGMENT.test(basename(dir))) say.fail('source.segment', `目录名 ${basename(dir)} 只能用字母、数字和「._-」`);
|
|
471
|
-
if (!link && !module && size <= SIZE_LIMIT) say.pass('source.files', `从仓库安装的限制:无符号链接与子模块,整个目录 ${(size / 1024).toFixed(1)} KiB`);
|
|
472
|
-
const untracked = entries.filter(e => e.untracked), missing = entries.filter(e => e.missing);
|
|
473
|
-
if (untracked.length) say.warn('source.untracked', `${untracked.length} 个文件还没提交(如 ${untracked[0].name}):Coda 只读提交里的文件`);
|
|
474
|
-
if (missing.length) say.warn('source.missing', `${missing.length} 个已跟踪的文件在工作区里不见了(如 ${missing[0].name})`);
|
|
475
|
-
if (!top) say.info('source.git', '目录不在 Git 仓库里:按目录里的文件检查,提交后以 Coda 读到的为准');
|
|
476
236
|
|
|
477
|
-
|
|
478
|
-
|
|
237
|
+
// Its settings for every project, as nobody has set any: the declarations' defaults.
|
|
238
|
+
const settings = resolveSettings(plugin).values;
|
|
239
|
+
// The model services of a machine with none, for one that chooses them; the PATH has no directories of Coda's own here.
|
|
240
|
+
const env = stateDir => pluginEnv({ catalog: given, installed, id: plugin.id, stateDir: id => id === plugin.id ? stateDir : null, settings,
|
|
241
|
+
providers: choosesModelProviders(plugin) ? {} : null, userPath: process.env.PATH ?? '' });
|
|
242
|
+
return { id: plugin.id, manifest, plugin, agents: plugin.agents, panels, settings, dependencies, path: repo.top ? path : null, env };
|
|
479
243
|
}
|