@coda-console/plugin-check 0.4.9 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ // What Coda says to an agent and takes from it beside the connection itself (docs/agent-protocol.md): the capabilities it
2
+ // sends, session instructions, steering, the agent's own waits, and how long Coda waits on it. Part of the plugin contract
3
+ // coda-plugins' check is given as written (scripts/plugin-contract.mjs).
4
+
5
+ // `initialize` as Coda sends it.
6
+ export const clientCapabilities = () => ({
7
+ elicitation: { form: {}, url: {} },
8
+ // A config option may be a switch: shown as one, its value on or off (`options`).
9
+ session: { compaction: {}, configOptions: { boolean: {} } },
10
+ // Coda takes the agent's own waits.
11
+ _meta: { coda: { waits: {} } },
12
+ });
13
+ // The agent refuses until it is signed in (ACP's auth_required).
14
+ export const SIGNED_OUT = -32000;
15
+ // Session instructions (「会话指令」): ACP's `systemPrompt`, only to an agent that declares it, the same on every open of
16
+ // the session, appended in one text block.
17
+ export const takesInstructions = init => init?.agentCapabilities?.sessionCapabilities?.systemPrompt != null;
18
+ export const systemPrompt = text => ({ mode: 'append', content: [{ type: 'text', text }] });
19
+ // Steering (「插话」): declared beside the capabilities, as the extension has it, and sent only while a turn is under way.
20
+ // The agent answers `STEERED` when the message joined the running turn and `NOT_STEERED` when the turn had ended; anything
21
+ // else leaves the message of unknown fate.
22
+ export const STEERING = '_session/steering';
23
+ export const STEERED = 'injected', NOT_STEERED = 'promptRequired';
24
+ export const supportsSteering = init => init?._meta?.steering?.supported === true;
25
+ export const steeringParams = (sessionId, text) => ({ sessionId, prompt: [{ type: 'text', text }], _meta: { steering: { idleBehavior: NOT_STEERED } } });
26
+ // The model a session runs with: its model option's value, else what ACP's older `models` names.
27
+ export const currentModel = session => session?.configOptions?.find?.(x => x?.category === 'model')?.currentValue ?? session?.models?.currentModelId;
28
+
29
+ // What an agent may say of a wait of its own (「_coda/wait」); anything else is dropped unread.
30
+ const record = value => value && typeof value === 'object' && !Array.isArray(value);
31
+ export const WAIT_STATES = new Set(['waiting', 'handling', 'done', 'failed', 'cancelled']);
32
+ const fits = (value, limit) => value === undefined || (typeof value === 'string' && value.length <= limit);
33
+ export const validWait = p => record(p) && typeof p.sessionId === 'string' && p.sessionId.length > 0 && p.sessionId.length <= 200
34
+ && typeof p.waitId === 'string' && p.waitId.length > 0 && p.waitId.length <= 200 && WAIT_STATES.has(p.state)
35
+ && fits(p.kind, 50) && fits(p.title, 500) && fits(p.detail, 4000) && fits(p.childSessionId, 200) && fits(p.model, 200) && fits(p.outputFile, 4096)
36
+ && fits(p.toolCallId, 200)
37
+ && [p.stoppable, p.recurring].every(flag => flag === undefined || typeof flag === 'boolean')
38
+ && (p.at === undefined || (typeof p.at === 'string' && Number.isFinite(Date.parse(p.at))));
39
+
40
+ // Opening a session (initialize, then session/new or session/load) may take this long.
41
+ export const OPEN_LIMIT = 60_000;
42
+ // A turn the user stops ends within this, or Coda closes the agent's process group (「Agent 活动」).
43
+ export const STOP_LIMIT = 30_000;
44
+ // Coda stops an agent with SIGTERM to its process group, and SIGKILL this long after.
45
+ export const TERM_GRACE = 800;
@@ -0,0 +1,11 @@
1
+ // ISO 8601 durations (`P30D`, `PT6H`), as JSON Schema's `format: duration` is and bpmn-engine reads its timers
2
+ // (@0dep/piso): what plugin settings, panels and workflows all write a length of time in.
3
+ import { parseDuration } from '@0dep/piso';
4
+
5
+ // A duration, not negative.
6
+ export function isDuration(text) {
7
+ if (typeof text !== 'string' || !text || text.length > 200) return false;
8
+ try { return parseDuration(text).result.sign !== -1; } catch { return false; }
9
+ }
10
+ // How long a duration is, counted back from `at` (a month is the one before it).
11
+ export const durationMs = (text, at = Date.now()) => at - parseDuration(text).getStartAt(new Date(at)).getTime();
@@ -0,0 +1,11 @@
1
+ export class ContractError extends Error {
2
+ constructor(code, message) {
3
+ super(message);
4
+ this.name = 'ContractError';
5
+ this.code = code;
6
+ }
7
+ }
8
+
9
+ export function requireCondition(condition, code, message) {
10
+ if (!condition) throw new ContractError(code, message);
11
+ }
@@ -0,0 +1,39 @@
1
+ // Shapes every layer checks what it is given against, and the stable serialization requests are compared by.
2
+ import { isAbsolute, normalize } from 'node:path';
3
+ import { requireCondition as check } from './errors.mjs';
4
+
5
+ // An agent's ID, as sessions, shortcuts, settings and the plugins declaring agents write it.
6
+ export const AGENT_ID = /^[a-z][a-z0-9-]{0,39}$/;
7
+
8
+ export function object(value, name) {
9
+ check(value && typeof value === 'object' && !Array.isArray(value), 'invalid_input', `${name} 必须是对象`);
10
+ return value;
11
+ }
12
+
13
+ export function text(value, name, max = 200) {
14
+ check(typeof value === 'string' && value.trim().length > 0 && value.length <= max && !value.includes('\0'),
15
+ 'invalid_input', `${name} 为空或格式无效`);
16
+ return value;
17
+ }
18
+
19
+ export function keys(value, allowed) {
20
+ check(Object.keys(value).every(key => allowed.includes(key)), 'invalid_input', '包含不支持的字段');
21
+ }
22
+
23
+ // Stable serialization is only for semantic request equality, never shell quoting.
24
+ export function canonical(value) {
25
+ if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
26
+ if (value && typeof value === 'object') {
27
+ return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}';
28
+ }
29
+ const encoded = JSON.stringify(value);
30
+ check(encoded !== undefined, 'invalid_input', '数据必须可以编码为 JSON');
31
+ return encoded;
32
+ }
33
+
34
+ // A path the user gave, as an absolute one.
35
+ export function absolutePath(value) {
36
+ text(value, '路径', 4096);
37
+ check(isAbsolute(value), 'invalid_input', '需要绝对路径');
38
+ return normalize(value);
39
+ }
@@ -0,0 +1,16 @@
1
+ const record = value => value && typeof value === 'object' && !Array.isArray(value);
2
+
3
+ // Only the shared protocol establishes a category; provider codes and error text stay with the plugin.
4
+ export function agentFailure(error) {
5
+ const failure = error?.data?._meta?.coda?.failure;
6
+ if (!record(failure)) return null;
7
+ return failure.kind === 'quota_exhausted' ||
8
+ (failure.kind === 'service_unavailable' && ['accepted', 'not_accepted'].includes(failure.input)) ? failure : null;
9
+ }
10
+
11
+ export function agentErrorMessage(error) {
12
+ const message = typeof error?.message === 'string' && error.message ? error.message : 'Agent 返回错误';
13
+ const detail = typeof error?.data?.message === 'string' ? error.data.message.trim() : '';
14
+ const text = detail && !message.includes(detail) ? `${message}\n\n${detail}` : message;
15
+ return agentFailure(error)?.kind === 'quota_exhausted' && !text.startsWith('额度不足') ? `额度不足\n\n${text}` : text;
16
+ }
@@ -0,0 +1,46 @@
1
+ // A panel's command run once to its end (a reading, an action): `sh -c` in its own process group, its standard output kept
2
+ // apart from what it says on standard error, both bounded. Past its deadline the group is asked to stop, and made to two
3
+ // seconds later; once `signal` aborts (the module closing) it is made to at once.
4
+ import { spawn } from 'node:child_process';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ const ansi = /\x1b\[[0-9;]*[A-Za-z]/g;
8
+ // The last thing a failed command said, for the one line the user sees.
9
+ export const lastLine = text => text.replace(ansi, '').split('\n').map(line => line.trim()).filter(Boolean).at(-1)?.slice(0, 200) ?? '';
10
+
11
+ export function runCommand(command, { env, timeoutMs, signal: aborted, maxBytes = 64 * 1024 }) {
12
+ return new Promise(resolve => {
13
+ const child = spawn('/bin/sh', ['-c', command], { cwd: tmpdir(), env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
14
+ const out = [], err = [];
15
+ let sizes = [0, 0], overflow = false, timedOut = false, finished = false, hard = null;
16
+ const signal = name => { try { process.kill(-child.pid, name); } catch {} };
17
+ const stop = () => { signal('SIGTERM'); hard = setTimeout(() => signal('SIGKILL'), 2000); };
18
+ const due = setTimeout(() => { timedOut = true; stop(); }, timeoutMs);
19
+ const abort = () => { if (!finished) signal('SIGKILL'); };
20
+ aborted?.addEventListener('abort', abort, { once: true });
21
+ const keep = (chunks, i) => chunk => {
22
+ if (sizes[i] + chunk.length > maxBytes) { if (i === 0 && !overflow) { overflow = true; stop(); } return; }
23
+ sizes[i] += chunk.length; chunks.push(chunk);
24
+ };
25
+ child.stdout.on('data', keep(out, 0));
26
+ child.stderr.on('data', keep(err, 1));
27
+ const finish = (code, error) => {
28
+ if (finished) return;
29
+ finished = true; clearTimeout(due); clearTimeout(hard); aborted?.removeEventListener('abort', abort);
30
+ // Whatever it left behind in its group goes with it.
31
+ if (child.pid) signal('SIGKILL');
32
+ resolve({ code, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8'), timedOut, overflow, ...(error ? { error } : {}) });
33
+ };
34
+ child.on('error', error => finish(null, error.message));
35
+ child.on('close', code => finish(code));
36
+ });
37
+ }
38
+ // A command ended well: by itself, with 0. One stopped for its deadline or its output may still say 0 on the way out.
39
+ export const ended = result => result.code === 0 && !result.timedOut && !result.overflow;
40
+ // Why a command did not end well, in one line.
41
+ export function outcome(result, what) {
42
+ if (result.timedOut) return `${what}超时`;
43
+ if (result.overflow) return '输出过长';
44
+ if (result.error) return `${what}没有开始:${result.error}`;
45
+ return lastLine(result.stderr) || lastLine(result.stdout) || `${what}没有完成(退出码 ${result.code ?? '无'})`;
46
+ }
@@ -0,0 +1,225 @@
1
+ // The panels installed plugins bring to this machine's resources page (docs/plugins.md「面板」), read and acted on here, in
2
+ // the resources plugin's module: what each plugin declares comes from Coda's collection of the installed plugins
3
+ // (`coda.watch("plugins")`), and each command runs with that plugin's own environment (`coda.environment(null, { plugin })`),
4
+ // as Coda gives every process of the plugin's. A panel is read only when a page asks (opening it, refreshing), never in
5
+ // the background, and no reading is kept on disk. What every panel gets from here and not from its plugin: one reading
6
+ // at a time (a second ask joins it), the last good reading shown again within the panel's `maxAge` and listed with the
7
+ // panel, and on failure the last good one kept and marked as such. The machine keeps the panels it hides, in this
8
+ // plugin's state; their order is their plugins' (docs/resources-page.md「顺序」).
9
+ import { spawnSync } from 'node:child_process';
10
+ import { readFileSync, writeFileSync, renameSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { runCommand, ended, outcome } from './command.mjs';
14
+ import { PanelError, panelEntries, panelItems, actionArgs, shownItems, displayOptions, keepMissingMetrics } from './rules.mjs';
15
+ import { SignIn, authorizationLink, deviceCode } from './sign-in.mjs';
16
+
17
+ const fail = (code, message) => { throw new PanelError(code, message); };
18
+
19
+ export class Panels {
20
+ // `onChange(key)`: a panel moved (its reading, its login, whether it is hidden); none for the list as a whole.
21
+ // `run`: how a command runs (panels/command.mjs runCommand); `now()`: the time, as readings are dated by it.
22
+ constructor(coda, { run = runCommand, now = Date.now, readTimeoutMs = 30_000, actionTimeoutMs = 10 * 60_000 } = {}) {
23
+ this.coda = coda; this.run = run; this.now = now; this.readTimeoutMs = readTimeoutMs; this.actionTimeoutMs = actionTimeoutMs;
24
+ // `last`: each panel's last good items, for a failed reading to show again; `shown`: what its last reading answered.
25
+ this.plugins = new Map(); this.last = new Map(); this.shown = new Map(); this.reading = new Map(); this.acting = new Map(); this.failures = new Map();
26
+ this.logins = new Map(); this.envs = new Map(); this.listeners = new Set(); this.closing = new AbortController();
27
+ this.file = join(coda.plugin.state ?? tmpdir(), 'hidden.json');
28
+ this.hides = Promise.resolve();
29
+ }
30
+ listen(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
31
+ // Nothing is told before the plugins' environments are there (`ready`): what a login says of whether its program is
32
+ // installed is checked with its plugin's own.
33
+ changed(key = null) { if (this.ready) for (const fn of this.listeners) try { fn(key); } catch {} }
34
+ // Once: the installed plugins watched, what this machine hides, and the environment of each plugin whose panels sign in.
35
+ start() {
36
+ this.started ??= (async () => {
37
+ this.hidden = this.readHidden();
38
+ const watched = await this.coda.watch('plugins', ({ type, id, object }) => {
39
+ if (type === 'DELETED') this.plugins.delete(id); else this.plugins.set(id, object);
40
+ // Another version (another directory, another environment): what was checked with the last is checked again.
41
+ this.envs.delete(id);
42
+ for (const [key, login] of this.logins) if (key.startsWith(`${id}/`)) login.recheck();
43
+ // A login of a panel gone (its plugin removed, or a version without it) goes with it.
44
+ const keys = new Set(this.installed().map(e => e.key));
45
+ for (const [key, login] of this.logins) if (!keys.has(key)) { login.close(); this.logins.delete(key); }
46
+ if (this.ready) this.prepare().then(() => this.changed());
47
+ });
48
+ this.unwatch = () => watched.dispose();
49
+ await this.prepare();
50
+ this.ready = true;
51
+ })();
52
+ return this.started;
53
+ }
54
+ // What the machine hides, as it was kept: none when nothing is, or what is cannot be read.
55
+ readHidden() {
56
+ try { return new Set(JSON.parse(readFileSync(this.file, 'utf8'))); } catch { return new Set(); }
57
+ }
58
+ keepHidden(hidden) {
59
+ const next = `${this.file}.next`;
60
+ writeFileSync(next, JSON.stringify([...hidden].sort()));
61
+ renameSync(next, this.file);
62
+ }
63
+ // The installed plugins' panels, by plugin id, each plugin's in the order it declares them. A project disabling a plugin
64
+ // does not take its panels away: the page is the machine's, not a project's. One Coda cannot read is kept with why.
65
+ installed() {
66
+ return [...this.plugins.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).flatMap(plugin =>
67
+ panelEntries(plugin.contributes?.panels, plugin.name).map(entry => {
68
+ const panel = entry.panel ?? { id: entry.invalid.id, title: entry.invalid.title, actions: {} };
69
+ return { plugin, panel, key: `${plugin.id}/${panel.id}`, ...(entry.invalid ? { invalid: entry.invalid.error } : {}) };
70
+ }));
71
+ }
72
+ find(key) {
73
+ return this.installed().find(e => e.key === key) ?? fail('not_found', `没有面板 ${key}`);
74
+ }
75
+ // Before a list is shown: the environment of each plugin whose panels sign in, for whether what they sign in to is there.
76
+ async prepare() {
77
+ const plugins = new Set(this.installed().filter(e => e.panel.signIn && !this.envs.has(e.plugin.id)).map(e => e.plugin.id));
78
+ await Promise.all([...plugins].map(id => this.env(id).catch(() => {})));
79
+ }
80
+ // What a page shows of a panel: what it is, whether the machine hides it, what was last read and how that stands now.
81
+ view(entry, index) {
82
+ const { plugin, panel, key } = entry, shown = this.shown.get(key);
83
+ return { plugin: plugin.id, pluginName: plugin.name, panel: panel.id, title: panel.title, version: plugin.version ?? '', source: plugin.source ?? { kind: 'builtin' },
84
+ actions: Object.fromEntries(Object.entries(panel.actions ?? {}).map(([name, a]) => [name, a.confirm ? { confirm: a.confirm } : {}])),
85
+ configurable: !!panel.displaySetting, signsIn: !!panel.signIn, hidden: this.hidden.has(key), index,
86
+ signIn: panel.signIn ? this.login(entry).view() : null,
87
+ reading: shown ? this.present(entry, shown) : null,
88
+ busy: this.reading.has(key), failure: entry.invalid ? `声明无效:${entry.invalid}` : this.failures.get(key) ?? null,
89
+ acting: this.acting.get(key)?.button ?? null, actionError: this.acting.get(key)?.error ?? null };
90
+ }
91
+ list() { return this.installed().map((entry, index) => [entry.key, this.view(entry, index)]); }
92
+ // One panel's, as `list` has it; none for one no longer installed.
93
+ viewOf(key) {
94
+ const listed = this.installed(), index = listed.findIndex(e => e.key === key);
95
+ return index < 0 ? null : this.view(listed[index], index);
96
+ }
97
+ // The plugin's environment, as Coda gives every process of it, read again for each command: what it holds (its
98
+ // settings, the concurrency groups now) is of the moment.
99
+ async env(plugin, extra = {}) {
100
+ const env = { ...(await this.coda.environment(null, { plugin })), ...extra };
101
+ this.envs.set(plugin, env);
102
+ return env;
103
+ }
104
+ settingsOf(plugin) {
105
+ try { return JSON.parse(this.envs.get(plugin)?.CODA_PLUGIN_SETTINGS ?? '{}'); } catch { return {}; }
106
+ }
107
+ present({ plugin, panel }, reading) {
108
+ if (!panel.displaySetting) return reading;
109
+ const values = this.settingsOf(plugin.id)[panel.displaySetting] ?? {};
110
+ return { ...reading, items: shownItems(reading.items, values), display: { setting: panel.displaySetting, options: displayOptions(reading.items, values) } };
111
+ }
112
+ // `{ items, readAt }`, or with `error` when this reading failed: then the last good items with `stale`, or none.
113
+ read(key, { force = false } = {}) {
114
+ const entry = this.find(key);
115
+ if (entry.invalid) return Promise.resolve({ items: [], error: `声明无效:${entry.invalid}` });
116
+ const pending = this.reading.get(key);
117
+ if (pending) return pending;
118
+ const last = this.last.get(key);
119
+ if (!force && last && entry.panel.maxAgeMs && this.now() - last.at < entry.panel.maxAgeMs && !this.shown.get(key)?.error) return Promise.resolve(this.present(entry, { items: last.items, readAt: last.at }));
120
+ const reading = this.fetch(entry).finally(() => { this.reading.delete(key); this.changed(key); });
121
+ this.reading.set(key, reading);
122
+ this.changed(key);
123
+ return reading;
124
+ }
125
+ async fetch(entry) {
126
+ const { plugin, panel, key } = entry;
127
+ let failure;
128
+ try {
129
+ const result = await this.run(panel.command, { env: await this.env(plugin.id), timeoutMs: this.readTimeoutMs, signal: this.closing.signal });
130
+ if (ended(result)) {
131
+ const parsed = panelItems(result.stdout, panel, plugin.views ?? []), at = this.now();
132
+ const items = panel.displaySetting ? keepMissingMetrics(parsed, this.last.get(key)?.items ?? []) : parsed;
133
+ this.last.set(key, { items, at }); this.failures.delete(key);
134
+ this.shown.set(key, { items, readAt: at });
135
+ return this.present(entry, { items, readAt: at });
136
+ }
137
+ failure = outcome(result, '读取');
138
+ } catch (e) {
139
+ if (!(e instanceof PanelError)) { this.failures.set(key, e.message); throw e; }
140
+ failure = `输出无效:${e.message}`;
141
+ }
142
+ const last = this.last.get(key);
143
+ const reading = last ? { items: last.items, readAt: last.at, stale: true, error: failure } : { items: [], error: failure };
144
+ this.shown.set(key, reading);
145
+ return this.present(entry, reading);
146
+ }
147
+ // Runs one of a panel's actions with what its button sent, then reads the panel again. One action of a panel at a time;
148
+ // one under way goes on to its end even when whoever asked has gone.
149
+ async act(key, name, args, button = name) {
150
+ const entry = this.find(key), { plugin, panel } = entry;
151
+ if (this.acting.get(key)?.button) fail('panel_busy', `${panel.title} 正在进行另一项操作`);
152
+ // What went wrong is said under the panel, whatever it was.
153
+ try {
154
+ const action = panel.actions?.[name] ?? fail('not_found', `面板 ${panel.title} 没有动作 ${name}`);
155
+ const given = actionArgs(args);
156
+ this.acting.set(key, { button }); this.changed(key);
157
+ const result = await this.run(action.command, { env: await this.env(plugin.id, { CODA_PANEL_ARGS: JSON.stringify(given) }),
158
+ timeoutMs: this.actionTimeoutMs, signal: this.closing.signal });
159
+ if (!ended(result)) fail('action_failed', outcome(result, '操作'));
160
+ this.acting.delete(key);
161
+ } catch (e) {
162
+ this.acting.set(key, { error: e.message }); this.changed(key);
163
+ throw e;
164
+ }
165
+ // A reading that began before the action ended may not show what it did.
166
+ await this.reading.get(key);
167
+ return this.read(key, { force: true });
168
+ }
169
+ // One panel hidden or shown again on this machine, the others as they were: one change at a time.
170
+ hide(key, hidden) {
171
+ const change = this.hides.then(() => {
172
+ const next = new Set(this.hidden);
173
+ if (hidden) next.add(key); else next.delete(key);
174
+ this.keepHidden(next);
175
+ this.hidden = next;
176
+ this.changed(key);
177
+ });
178
+ this.hides = change.catch(() => {});
179
+ return change;
180
+ }
181
+ // A panel's login from Coda (docs/plugins.md「面板的登录」), one per panel at a time. Signed in, the panel is read anew;
182
+ // its CLI installed, the plugin's setup is run again (docs/plugins.md「准备运行环境」), which done well has Coda read its
183
+ // agents again: what they offer may be readable now.
184
+ login(entry) {
185
+ const { plugin, panel, key } = entry, known = this.logins.get(key);
186
+ const declared = panel.signIn ?? fail('not_found', `${panel.title} 不能从 Coda 登录`);
187
+ if (known?.declared === JSON.stringify(declared)) return known;
188
+ known?.close();
189
+ const env = () => this.envs.get(plugin.id) ?? process.env;
190
+ // Whether it is there: a quick check with the plugin's own environment, asked again after a moment at most. Without
191
+ // one, it always is; without the environment yet, it is not checked, and nothing is kept of it.
192
+ let checkedAt = 0, there = false;
193
+ const installed = (fresh = false) => {
194
+ if (!declared.installed) return true;
195
+ const given = this.envs.get(plugin.id);
196
+ if (!given) return false;
197
+ if (fresh || Date.now() - checkedAt > 2000) { there = spawnSync('/bin/sh', ['-c', declared.installed], { cwd: tmpdir(), env: given, stdio: 'ignore', timeout: 2000 }).status === 0; checkedAt = Date.now(); }
198
+ return there;
199
+ };
200
+ const login = new SignIn({ provider: key, name: panel.title, command: declared.command, installed, install: declared.install ?? null,
201
+ readCode: declared.input ? authorizationLink : deviceCode, input: !!declared.input, note: declared.note ?? null },
202
+ { env, onChange: () => this.changed(key),
203
+ onSignedIn: () => { this.read(key, { force: true }).catch(() => {}); },
204
+ onInstalled: () => { checkedAt = 0; this.coda.api(`plugins/${encodeURIComponent(plugin.id)}/setup`, {}).catch(() => {}); } });
205
+ login.declared = JSON.stringify(declared);
206
+ login.recheck = () => { checkedAt = 0; };
207
+ this.logins.set(key, login);
208
+ return login;
209
+ }
210
+ async signIn(key, what, body = {}) {
211
+ const entry = this.find(key);
212
+ // The command runs with the plugin's environment as it is now.
213
+ await this.env(entry.plugin.id);
214
+ const login = this.login(entry);
215
+ switch (what) {
216
+ case 'signIn': return login.start();
217
+ case 'install': return login.install();
218
+ case 'cancelSignIn': return login.cancel();
219
+ case 'submitSignIn': return login.submit(body);
220
+ default: return login.view();
221
+ }
222
+ }
223
+ // The module going (the plugin host stopping): what runs in a process group of its own is made to end, as nothing would later.
224
+ close() { this.closing.abort(); this.unwatch?.(); for (const login of this.logins.values()) login.close('SIGKILL'); }
225
+ }
@@ -0,0 +1,242 @@
1
+ // What a panel is (docs/plugins.md「面板」): how a plugin declares one (`contributes.panels`) and what its command may
2
+ // print, checked here, where the panels are read, the way a task's card is drawn from its fields. Nothing here knows what
3
+ // a panel is about: CPU, an account's quota and a disk are all meters and figures.
4
+
5
+ // What is not as it must be: the panel says so where it would show.
6
+ export class PanelError extends Error {
7
+ constructor(code, message) { super(message); this.code = code; }
8
+ }
9
+ function check(condition, code, message) {
10
+ if (!condition) throw new PanelError(code, message);
11
+ return condition;
12
+ }
13
+ // An ISO 8601 duration as panels write their `maxAge` (`PT5M`, `P1D`): weeks, days and time, not years or months.
14
+ const DURATION = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
15
+ export function isDuration(text) {
16
+ const m = typeof text === 'string' && text.length <= 200 && DURATION.exec(text);
17
+ return !!m && text !== 'P' && !text.endsWith('T');
18
+ }
19
+ export function durationMs(text) {
20
+ const [, w = 0, d = 0, h = 0, min = 0, s = 0] = DURATION.exec(text);
21
+ return ((((+w * 7 + +d) * 24 + +h) * 60 + +min) * 60 + +s) * 1000;
22
+ }
23
+
24
+ const NAME = /^[a-z][a-z0-9-]{0,63}$/;
25
+ export const ITEM_TYPES = ['card', 'meter', 'stat', 'markdown', 'button', 'error'];
26
+ // What a panel's command may print: past these, the reading fails rather than being cut short.
27
+ export const OUTPUT_LIMIT = 64 * 1024;
28
+ export const ARGS_LIMIT = 4 * 1024;
29
+ const ITEMS = 50, CARD_ITEMS = 20, TITLE = 40, LABEL = 80, IDENTITY = 200, MARKDOWN = 4000, ERROR = 500, CONFIRM = 200;
30
+ const fail = (code, message) => check(false, code, message);
31
+ const keys = (value, allowed, where, code) => {
32
+ const extra = Object.keys(value).filter(k => !allowed.includes(k));
33
+ check(!extra.length, code, `${where} 里有 Coda 不支持的键:${extra.join('、')}`);
34
+ };
35
+ const text = (value, max, where, code, { need = true, line = true } = {}) => {
36
+ if (value === undefined && !need) return undefined;
37
+ check(typeof value === 'string' && value.trim() && [...value].length <= max && !value.includes('\0') && (!line || !/[\r\n]/.test(value)), code,
38
+ `${where} 须为不超过 ${max} 字的${line ? '一行' : ''}文本`);
39
+ return value;
40
+ };
41
+ const object = (value, where, code) => check(value && typeof value === 'object' && !Array.isArray(value), code, `${where} 须为对象`);
42
+
43
+ // ---- Declarations (`contributes.panels`) ----
44
+ // One panel as the plugin declares it: what it is called, the command that reads it, how long a reading may be shown
45
+ // again without asking, the actions its buttons may take, each a command of its own with what to ask first, and how to
46
+ // sign in to what it shows from Coda.
47
+ function declaration(raw, where) {
48
+ const code = 'invalid_plugin';
49
+ object(raw, where, code);
50
+ keys(raw, ['id', 'title', 'command', 'maxAge', 'actions', 'signIn', 'displaySetting'], where, code);
51
+ check(typeof raw.id === 'string' && NAME.test(raw.id), code, `${where} 的 id 只能用小写字母、数字和「-」,以字母开头`);
52
+ const at = `面板 ${raw.id}`;
53
+ check(raw.maxAge === undefined || (typeof raw.maxAge === 'string' && isDuration(raw.maxAge)), code, `${at} 的 maxAge 须为 ISO 8601 时长,如 PT5M`);
54
+ const actions = {};
55
+ if (raw.actions !== undefined) {
56
+ object(raw.actions, `${at} 的 actions`, code);
57
+ for (const [name, action] of Object.entries(raw.actions)) {
58
+ check(NAME.test(name), code, `${at} 的动作名只能用小写字母、数字和「-」,以字母开头:${name}`);
59
+ object(action, `${at} 的动作 ${name}`, code);
60
+ keys(action, ['command', 'confirm'], `${at} 的动作 ${name}`, code);
61
+ actions[name] = { command: text(action.command, 4000, `${at} 的动作 ${name} 的 command`, code),
62
+ ...(action.confirm !== undefined ? { confirm: text(action.confirm, CONFIRM, `${at} 的动作 ${name} 的 confirm`, code, { line: false }) } : {}) };
63
+ }
64
+ }
65
+ return { id: raw.id, title: text(raw.title, TITLE, `${at} 的 title`, code), command: text(raw.command, 4000, `${at} 的 command`, code),
66
+ ...(raw.maxAge !== undefined ? { maxAge: raw.maxAge, maxAgeMs: durationMs(raw.maxAge) } : {}), actions,
67
+ ...(raw.displaySetting !== undefined ? { displaySetting: text(raw.displaySetting, LABEL, `${at} 的 displaySetting`, code) } : {}),
68
+ ...(raw.signIn !== undefined ? { signIn: signIn(raw.signIn, `${at} 的 signIn`) } : {}) };
69
+ }
70
+ // Signing in from Coda (adapters/sign-in.mjs): a login command that prints a link to
71
+ // confirm on any device (with a code, or asking for the authorization code the page shows, which goes to its standard
72
+ // input), and where Coda may install what it needs, a quick check that it is there and the command that installs it.
73
+ function signIn(raw, where) {
74
+ const code = 'invalid_plugin';
75
+ object(raw, where, code);
76
+ keys(raw, ['command', 'installed', 'install', 'input', 'note'], where, code);
77
+ check(raw.input === undefined || raw.input === 'authorizationCode', code, `${where} 的 input 只能是 authorizationCode`);
78
+ check(raw.install === undefined || raw.installed !== undefined, code, `${where}:写了 install 就要写 installed,Coda 才知道装没装好`);
79
+ return { command: text(raw.command, 4000, `${where} 的 command`, code),
80
+ ...(raw.installed !== undefined ? { installed: text(raw.installed, 4000, `${where} 的 installed`, code) } : {}),
81
+ ...(raw.install !== undefined ? { install: text(raw.install, 4000, `${where} 的 install`, code) } : {}),
82
+ ...(raw.input ? { input: raw.input } : {}),
83
+ ...(raw.note !== undefined ? { note: text(raw.note, CONFIRM, `${where} 的 note`, code, { line: false }) } : {}) };
84
+ }
85
+ // A plugin's panels as it declares them, each on its own: one Coda cannot read is kept with why, under the id and title it
86
+ // wrote (or its place and the plugin's name), for the page to say so where it would show.
87
+ export function panelEntries(raw, pluginName) {
88
+ if (raw === undefined) return [];
89
+ if (!Array.isArray(raw)) return [{ invalid: { id: 'panels', title: pluginName, error: 'contributes.panels 须为数组' } }];
90
+ const seen = new Set();
91
+ return raw.map((item, i) => {
92
+ // An ID is the first one's that writes it, read or not; one whose ID is not one, or another's, goes by its place.
93
+ const named = typeof item?.id === 'string' && NAME.test(item.id), again = named && seen.has(item.id);
94
+ const id = named && !again ? item.id : `#${i + 1}`;
95
+ if (named) seen.add(item.id);
96
+ try {
97
+ check(!again, 'invalid_plugin', `contributes.panels 里有重复的 id:${item.id}`);
98
+ return { panel: declaration(item, `contributes.panels[${i}]`) };
99
+ } catch (e) {
100
+ if (!(e instanceof PanelError)) throw e;
101
+ return { invalid: { id, title: typeof item?.title === 'string' && item.title.trim() ? item.title.slice(0, 40) : pluginName, error: e.message } };
102
+ }
103
+ });
104
+ }
105
+ export function panelDeclarations(raw) {
106
+ if (raw === undefined) return [];
107
+ check(Array.isArray(raw), 'invalid_plugin', 'contributes.panels 须为数组');
108
+ const panels = raw.map((p, i) => declaration(p, `contributes.panels[${i}]`));
109
+ check(new Set(panels.map(p => p.id)).size === panels.length, 'invalid_plugin', 'contributes.panels 里有重复的 id');
110
+ return panels;
111
+ }
112
+
113
+ // ---- What a panel's command prints ----
114
+ // `{ items: [...] }`, each item one of ITEM_TYPES; a card holds the others. A button names one of the panel's actions,
115
+ // or one of its plugin's views for the client to open (docs/plugins.md「视图」). `scope`: `{ actions, views }`, what the
116
+ // panel declares and the ids of its plugin's views. Returned as the clients get it: only the keys each type has.
117
+ function item(raw, where, scope, inCard) {
118
+ object(raw, where, 'invalid_panel');
119
+ const { display, ...content } = raw;
120
+ const out = plainItem(content, where, scope, inCard);
121
+ if (display !== undefined) {
122
+ object(display, `${where}.display`, 'invalid_panel');
123
+ keys(display, ['id', 'default'], `${where}.display`, 'invalid_panel');
124
+ check(['meter', 'stat'].includes(out.type), 'invalid_panel', '只有数值项可以声明 display');
125
+ check(typeof display.default === 'boolean', 'invalid_panel', 'display.default 须为开或关');
126
+ out.display = { id: text(display.id, 200, 'display.id', 'invalid_panel'), default: display.default };
127
+ }
128
+ return out;
129
+ }
130
+ function plainItem(raw, where, scope, inCard) {
131
+ const code = 'invalid_panel';
132
+ object(raw, where, code);
133
+ check(ITEM_TYPES.includes(raw.type), code, `${where} 的 type 只能是 ${ITEM_TYPES.join('、')}`);
134
+ check(!(inCard && raw.type === 'card'), code, `${where}:卡片里不能再放卡片`);
135
+ switch (raw.type) {
136
+ case 'card': {
137
+ keys(raw, ['type', 'title', 'subtitle', 'identity', 'items'], where, code);
138
+ check(Array.isArray(raw.items) && raw.items.length <= CARD_ITEMS, code, `${where} 的 items 须为不超过 ${CARD_ITEMS} 项的数组`);
139
+ // The outside identity it shows (an account), compared and nothing else: a group view shows cards naming the same
140
+ // one once for the machines that have them.
141
+ return { type: 'card', title: text(raw.title, LABEL, `${where} 的 title`, code),
142
+ ...(raw.subtitle !== undefined ? { subtitle: text(raw.subtitle, LABEL, `${where} 的 subtitle`, code) } : {}),
143
+ ...(raw.identity !== undefined ? { identity: text(raw.identity, IDENTITY, `${where} 的 identity`, code) } : {}),
144
+ items: raw.items.map((r, i) => item(r, `${where}.items[${i}]`, scope, true)) };
145
+ }
146
+ case 'meter': {
147
+ keys(raw, ['type', 'label', 'value', 'fraction', 'note', 'time', 'timeLabel'], where, code);
148
+ // Unknown is said by leaving the fraction out, never by a zero.
149
+ check(raw.fraction === undefined || raw.fraction === null || (typeof raw.fraction === 'number' && raw.fraction >= 0 && raw.fraction <= 1), code,
150
+ `${where} 的 fraction 须在 0–1 之间,不知道时不写`);
151
+ check(raw.time === undefined || (typeof raw.time === 'string' && Number.isFinite(Date.parse(raw.time))), code, `${where} 的 time 须为 ISO 8601 时间`);
152
+ return { type: 'meter', label: text(raw.label, LABEL, `${where} 的 label`, code),
153
+ ...(raw.value !== undefined ? { value: text(raw.value, LABEL, `${where} 的 value`, code) } : {}),
154
+ ...(typeof raw.fraction === 'number' ? { fraction: raw.fraction } : {}),
155
+ ...(raw.note !== undefined ? { note: text(raw.note, LABEL, `${where} 的 note`, code) } : {}),
156
+ ...(raw.time !== undefined ? { time: new Date(raw.time).toISOString() } : {}),
157
+ ...(raw.timeLabel !== undefined ? { timeLabel: text(raw.timeLabel, LABEL, `${where} 的 timeLabel`, code) } : {}) };
158
+ }
159
+ case 'stat':
160
+ keys(raw, ['type', 'label', 'value'], where, code);
161
+ return { type: 'stat', label: text(raw.label, LABEL, `${where} 的 label`, code), value: text(raw.value, LABEL, `${where} 的 value`, code) };
162
+ case 'markdown':
163
+ keys(raw, ['type', 'text'], where, code);
164
+ return { type: 'markdown', text: text(raw.text, MARKDOWN, `${where} 的 text`, code, { line: false }) };
165
+ case 'error':
166
+ keys(raw, ['type', 'text'], where, code);
167
+ return { type: 'error', text: text(raw.text, ERROR, `${where} 的 text`, code, { line: false }) };
168
+ case 'button': {
169
+ keys(raw, ['type', 'label', 'action', 'view', 'args', 'disabled', 'confirm'], where, code);
170
+ // What it does: run one of the panel's actions here, or open one of its plugin's views in the client.
171
+ const opens = raw.view !== undefined;
172
+ check(opens !== (raw.action !== undefined), code, `${where} 须有 action 或 view 之一`);
173
+ if (opens) check(typeof raw.view === 'string' && scope.views.includes(raw.view), code, `${where} 的 view 须是插件声明的视图:${raw.view}`);
174
+ else check(typeof raw.action === 'string' && Object.hasOwn(scope.actions, raw.action), code, `${where} 的 action 须是面板声明的动作:${raw.action}`);
175
+ check(raw.args === undefined || (raw.args && typeof raw.args === 'object' && !Array.isArray(raw.args) && JSON.stringify(raw.args).length <= ARGS_LIMIT), code,
176
+ `${where} 的 args 须为不超过 ${ARGS_LIMIT} 字节的对象`);
177
+ check(raw.disabled === undefined || typeof raw.disabled === 'boolean', code, `${where} 的 disabled 须为开或关`);
178
+ // Its own question, for what this one button does (which account, which credit), in place of its action's.
179
+ return { type: 'button', label: text(raw.label, LABEL, `${where} 的 label`, code), ...(opens ? { view: raw.view } : { action: raw.action }),
180
+ ...(raw.args ? { args: raw.args } : {}), ...(raw.disabled ? { disabled: true } : {}),
181
+ ...(raw.confirm !== undefined ? { confirm: text(raw.confirm, CONFIRM, `${where} 的 confirm`, code, { line: false }) } : {}) };
182
+ }
183
+ }
184
+ return fail(code, `${where} 的 type 不受支持`);
185
+ }
186
+ // A panel's reading from what its command printed. Anything it cannot be fails the reading, with where. `views`: the ids
187
+ // of its plugin's views, which a button may open.
188
+ export function panelItems(output, panel, views = []) {
189
+ const code = 'invalid_panel';
190
+ check(Buffer.byteLength(output) <= OUTPUT_LIMIT, code, `输出超过 ${OUTPUT_LIMIT / 1024} KiB`);
191
+ let value;
192
+ try { value = JSON.parse(output); } catch { return fail(code, '输出不是 JSON'); }
193
+ object(value, '输出', code);
194
+ keys(value, ['items'], '输出', code);
195
+ check(Array.isArray(value.items) && value.items.length <= ITEMS, code, `输出的 items 须为不超过 ${ITEMS} 项的数组`);
196
+ const items = value.items.map((r, i) => item(r, `items[${i}]`, { actions: panel.actions, views }, false));
197
+ const ids = items.flatMap(i => i.type === 'card' ? i.items : [i]).filter(i => i.display).map(i => i.display.id);
198
+ check(!ids.length || panel.displaySetting, code, '数值项的 display 需要面板声明 displaySetting');
199
+ check(new Set(ids).size === ids.length, code, 'display.id 不能重复');
200
+ return items;
201
+ }
202
+ // What a button sends: an object the action's command reads as JSON.
203
+ export function actionArgs(value) {
204
+ if (value === undefined || value === null) return {};
205
+ check(typeof value === 'object' && !Array.isArray(value) && JSON.stringify(value).length <= ARGS_LIMIT, 'invalid_input', `args 须为不超过 ${ARGS_LIMIT} 字节的对象`);
206
+ return value;
207
+ }
208
+
209
+ // A reading keeps all measurements in memory; display choices are ordinary plugin settings, applied at read time.
210
+ export function shownItems(items, choices = {}) {
211
+ return items.flatMap(({ display, ...item }) => {
212
+ if (display && !(choices[display.id] ?? display.default)) return [];
213
+ if (item.type === 'card') {
214
+ item.items = shownItems(item.items, choices);
215
+ if (!item.items.length) return [];
216
+ }
217
+ return [item];
218
+ });
219
+ }
220
+ export function displayOptions(items, values = {}) {
221
+ return items.flatMap(item => (item.type === 'card' ? item.items : [item]).filter(i => i.display).map(i => ({
222
+ id: i.display.id, label: i.label, group: item.type === 'card' ? item.title : '',
223
+ enabled: values[i.display.id] ?? i.display.default, default: i.display.default,
224
+ })));
225
+ }
226
+
227
+ // A disconnected device keeps its place for this backend's lifetime, with no old number presented as current.
228
+ export function keepMissingMetrics(items, previous) {
229
+ const ids = new Set(items.flatMap(i => i.type === 'card' ? i.items : [i]).flatMap(i => i.display ? [i.display.id] : []));
230
+ const result = [...items];
231
+ for (const old of previous) {
232
+ const missing = (old.type === 'card' ? old.items : [old]).filter(i => i.display && !ids.has(i.display.id))
233
+ .map(i => ({ type: 'stat', label: i.label, value: '暂不可读', display: i.display }));
234
+ if (!missing.length) continue;
235
+ if (old.type === 'card') {
236
+ const same = result.find(i => i.type === 'card' && i.title === old.title);
237
+ if (same) same.items = [...same.items, ...missing].slice(0, 20);
238
+ else if (result.length < 50) result.push({ ...old, items: missing });
239
+ } else if (result.length < 50) result.push(...missing);
240
+ }
241
+ return result;
242
+ }