@coda-console/plugin-check 0.4.8 → 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.
@@ -0,0 +1,131 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { tmpdir } from 'node:os';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { PanelError } from './rules.mjs';
5
+
6
+ const check = (condition, code, message) => { if (!condition) throw new PanelError(code, message); };
7
+
8
+ const ansi = /\x1b\[[0-9;]*[A-Za-z]/g;
9
+ // A device-code login prints a verification URL that carries the code (`grok login --device-auth`, `kimi login`), or the
10
+ // code alone on a line after it. Only whole lines are read: output arrives in pieces, and a URL cut short would carry a
11
+ // code cut short. Codex uses a 4-5 code; Grok and Kimi use 4-4.
12
+ const codeFormat = /^[A-Z0-9]{4}-[A-Z0-9]{4,5}$/;
13
+ export function deviceCode(text) {
14
+ const plain = text.slice(0, text.lastIndexOf('\n') + 1).replace(ansi, '');
15
+ const url = plain.match(/https:\/\/\S+/)?.[0] ?? null;
16
+ let code = null;
17
+ try { code = url && new URL(url).searchParams.get('user_code'); } catch { return null; }
18
+ code ||= plain.match(/^\s*([A-Z0-9]{4}-[A-Z0-9]{4,5})\s*$/m)?.[1] ?? null;
19
+ return url && code && codeFormat.test(code) ? { url, code } : null;
20
+ }
21
+ // A login that asks for the authorization code its page shows prints the page's link and waits: the first whole line's
22
+ // link is it. Checking that the code belongs to this login is the login's own job.
23
+ export function authorizationLink(text) {
24
+ const url = text.slice(0, text.lastIndexOf('\n') + 1).replace(ansi, '').match(/https:\/\/\S+/)?.[0];
25
+ try { return url ? { url: new URL(url).href, input: 'authorizationCode' } : null; } catch { return null; }
26
+ }
27
+ const lastLine = output => output.replace(ansi, '').split('\n').map(line => line.trim()).filter(Boolean).at(-1)?.slice(0, 200);
28
+ // A command runs through a shell in its own process group (an installer pipes a download into a shell): ending it ends the
29
+ // whole group, asked (TERM) or, as the module goes and nothing is left to ask again, made to (KILL).
30
+ function end(child, signal = 'SIGTERM') {
31
+ try { process.kill(-child.pid, signal); } catch { child.kill(signal); }
32
+ }
33
+
34
+ // Signing in from Coda through a plugin's panel (docs/plugins.md「面板的登录」, panels.mjs): `login` is the
35
+ // panel's own — its login command, whether what it signs in to is there (`installed()`), the command that installs it, and
36
+ // how the link and code are read from what the login prints (`readCode`); `input`, whether the login asks for the
37
+ // authorization code its page shows, which goes to its standard input. Coda passes the link and code on; the command writes
38
+ // and renews the credentials, which Coda never reads.
39
+ // One login or install of each at a time: any page starting it again sees the one under way, and every page is told as it
40
+ // moves (`onChange`). Neither outlives the module that started it (`close` ends it), so neither is kept for after a restart.
41
+ export class SignIn {
42
+ constructor(login, { env = () => process.env, onChange = () => {}, onSignedIn = () => {}, onInstalled = () => {}, timeoutMs = 15 * 60 * 1000, startTimeoutMs = 20000, installTimeoutMs = 10 * 60 * 1000 }) {
43
+ this.login = login; this.env = env; this.onChange = onChange; this.onSignedIn = onSignedIn; this.onInstalled = onInstalled;
44
+ this.timeoutMs = timeoutMs; this.startTimeoutMs = startTimeoutMs; this.installTimeoutMs = installTimeoutMs;
45
+ this._state = { state: 'idle' }; this.child = null;
46
+ }
47
+ get state() { return this._state; }
48
+ set state(value) { this._state = value; this.onChange(); }
49
+ get available() { return this.login.installed(); }
50
+ view() { return { provider: this.login.provider, name: this.login.name, installed: this.available,
51
+ ...(this.login.note ? { note: this.login.note } : {}), ...this.state }; }
52
+ get busy() { return ['starting', 'waiting', 'installing'].includes(this.state.state); }
53
+ spawn(command, stdin) {
54
+ return spawn('/bin/sh', ['-c', command], { cwd: tmpdir(), env: this.env(), detached: true, stdio: [stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
55
+ }
56
+ // Starts the installer and answers at once: clients follow a download as they follow a waiting login.
57
+ install() {
58
+ if (this.busy) return this.view();
59
+ if (this.available) { this.state = { state: 'idle' }; return this.view(); }
60
+ if (!this.login.install) { this.state = { state: 'failed', error: `这台机器没有安装 ${this.login.name},Coda 不能代为安装` }; return this.view(); }
61
+ const child = this.spawn(this.login.install, false);
62
+ this.child = child;
63
+ let output = '';
64
+ this.state = { state: 'installing' };
65
+ const due = setTimeout(() => { if (this.child === child) end(child); }, this.installTimeoutMs);
66
+ const read = chunk => { output = (output + chunk).slice(-8000); };
67
+ child.stdout.on('data', read); child.stderr.on('data', read);
68
+ child.on('error', error => { output += `\n${error.message}`; });
69
+ child.on('close', code => {
70
+ clearTimeout(due);
71
+ if (this.child === child) {
72
+ this.child = null;
73
+ // Asked anew: what was there a moment ago is not what the installer left.
74
+ this.state = code === 0 && this.login.installed(true) ? { state: 'idle' } : { state: 'failed', error: lastLine(output) || '安装没有完成' };
75
+ if (this.state.state === 'idle') this.onInstalled();
76
+ }
77
+ });
78
+ return this.view();
79
+ }
80
+ // Resolves once the link and code are known, or the login has failed.
81
+ start() {
82
+ if (this.busy) return this.ready ?? Promise.resolve(this.view());
83
+ if (!this.available) { this.state = { state: 'failed', error: `这台机器没有安装 ${this.login.name}` }; return Promise.resolve(this.view()); }
84
+ const child = this.spawn(this.login.command, this.login.input);
85
+ child.stdin?.on('error', () => {});
86
+ const attemptId = randomUUID();
87
+ this.child = child;
88
+ let output = '', expired = false, settle;
89
+ const ready = new Promise(resolve => { settle = resolve; });
90
+ const done = () => { settle(this.view()); if (this.ready === ready) this.ready = null; };
91
+ this.ready = ready;
92
+ this.state = { state: 'starting' };
93
+ const startTimer = setTimeout(() => { if (this.child === child && this.state.state === 'starting') end(child); }, this.startTimeoutMs);
94
+ const due = setTimeout(() => { if (this.child === child) { expired = true; end(child); } }, this.timeoutMs);
95
+ const read = chunk => {
96
+ output = (output + chunk).slice(-8000);
97
+ const found = this.state.state === 'starting' && this.child === child && this.login.readCode(output);
98
+ if (found) { this.state = { state: 'waiting', ...found, ...(this.login.input ? { attemptId } : {}) }; done(); }
99
+ };
100
+ child.stdout.on('data', read); child.stderr.on('data', read);
101
+ child.on('error', error => { output += `\n${error.message}`; });
102
+ child.on('close', code => {
103
+ clearTimeout(startTimer); clearTimeout(due);
104
+ if (this.child !== child) return done(); // Cancelled: the state is already idle.
105
+ this.child = null;
106
+ if (code === 0) { this.state = { state: 'idle' }; this.onSignedIn(); }
107
+ else if (expired) this.state = { state: 'expired' };
108
+ else if (this.state.state !== 'idle') this.state = { state: 'failed', error: lastLine(output) || '登录没有完成' };
109
+ done();
110
+ });
111
+ return ready;
112
+ }
113
+ submit({ attemptId, code } = {}) {
114
+ check(this.login.input && this.child && this.state.state === 'waiting' && attemptId === this.state.attemptId,
115
+ 'invalid_input', '此登录已结束,请重新登录');
116
+ check(typeof code === 'string' && code.length <= 4096, 'invalid_input', '请粘贴完整的授权码');
117
+ if (!this.state.submitted) {
118
+ this.child.stdin.write(`${code.trim()}\n`);
119
+ this.state = { ...this.state, submitted: true };
120
+ }
121
+ return this.view();
122
+ }
123
+ cancel() {
124
+ const child = this.child; this.child = null;
125
+ if (child) end(child);
126
+ this.state = { state: 'idle' };
127
+ return this.view();
128
+ }
129
+ // `signal`: KILL as the module goes (the plugin host stopping), when nothing would end one that does not stop when asked.
130
+ close(signal = 'SIGTERM') { if (this.child) end(this.child, signal); this.child = null; }
131
+ }
package/check/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // The contract check of one plugin directory: the manifest, then each agent's handshake (and with `live`, a turn) and each
2
- // panel's reading. Coda's「读取来源」stays the authority; this runs the same rules where a plugin is written.
2
+ // panel's reading. The rules are Coda's own code (check/coda/), run where a plugin is written.
3
3
  import { resolve } from 'node:path';
4
4
  import { checkAgent } from './agent.mjs';
5
5
  import { checkManifest } from './manifest.mjs';
@@ -17,8 +17,7 @@ export async function checkPlugin(dir, { agent: only = null, manifestOnly = fals
17
17
  if (agents.length || plugin.panels.length) report.scope('handshake').skip('options.manifest', '只查清单(--manifest):不启动 Agent、不读面板');
18
18
  return { report, plugin };
19
19
  }
20
- const { settings, dependencies } = plugin;
21
- for (const agent of agents) await checkAgent({ agent, pluginDir, settings, dependencies, live, prompt, limits, say: level => report.scope(level, `Agent ${agent.id}`) });
22
- if (!only) for (const panel of plugin.panels) await checkPanel({ panel, pluginDir, settings, dependencies, say: report.scope('handshake', `面板 ${panel.id}`) });
20
+ for (const agent of agents) await checkAgent({ agent, checked: plugin, live, prompt, limits, say: level => report.scope(level, `Agent ${agent.id}`) });
21
+ if (!only) for (const panel of plugin.panels) await checkPanel({ panel, checked: plugin, say: report.scope('handshake', `面板 ${panel.id}`) });
23
22
  return { report, plugin };
24
23
  }
@@ -1,13 +1,14 @@
1
1
  // What the checker starts and makes goes however it ends: the process groups of agents and panels it started (detached,
2
- // so a Ctrl-C at the terminal does not reach them) and its temporary directories. bin/check.mjs turns SIGINT and SIGTERM
3
- // into an exit, which runs this.
2
+ // so a Ctrl-C at the terminal does not reach them), what stops those it had Coda's code start, and its temporary
3
+ // directories. bin/check.mjs turns SIGINT and SIGTERM into an exit, which runs this.
4
4
  import { mkdtempSync, rmSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
 
8
- const groups = new Set(), dirs = new Set();
8
+ const groups = new Set(), closers = new Set(), dirs = new Set();
9
9
  process.on('exit', () => {
10
10
  for (const pid of groups) try { process.kill(-pid, 'SIGKILL'); } catch {}
11
+ for (const close of closers) try { close(); } catch {}
11
12
  for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
12
13
  });
13
14
 
@@ -16,6 +17,11 @@ export function group(pid) {
16
17
  if (pid) groups.add(pid);
17
18
  return () => groups.delete(pid);
18
19
  }
20
+ // What ends what Coda's code started (it must do so at once); the returned function says it is done with.
21
+ export function closing(close) {
22
+ closers.add(close);
23
+ return () => closers.delete(close);
24
+ }
19
25
  // A new temporary directory; `remove()` removes it now.
20
26
  export function temporary(prefix) {
21
27
  const dir = mkdtempSync(join(tmpdir(), prefix));