@nonbot/cli 0.10.14 → 0.10.16
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/CHANGELOG.md +19 -0
- package/LICENSE +22 -11
- package/README.md +21 -6
- package/dist/commands/daemon.js +2 -0
- package/dist/commands/doctor.js +184 -127
- package/dist/commands/logout.js +78 -0
- package/dist/commands/mcp.js +409 -0
- package/dist/commands/update.js +157 -0
- package/dist/index.js +125 -43
- package/dist/lib/mcp-account.js +232 -0
- package/dist/lib/mcp-config.js +202 -0
- package/dist/lib/mcp-probe.js +226 -0
- package/dist/lib/mcp-status.js +127 -0
- package/dist/lib/mcp-trust.js +63 -0
- package/dist/lib/output.js +40 -6
- package/dist/lib/quickstart-menu.js +86 -0
- package/dist/lib/update-check.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
import { VERSION } from '../version.js';
|
|
3
|
+
import { isNonbotRemoteUrl } from './mcp-config.js';
|
|
4
|
+
export const MCP_PROTOCOL_VERSION = '2025-06-18';
|
|
5
|
+
const INIT_PARAMS = {
|
|
6
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
7
|
+
capabilities: {},
|
|
8
|
+
clientInfo: { name: 'nonbot-cli', version: VERSION },
|
|
9
|
+
};
|
|
10
|
+
const LOADER_ENV = /^(NODE_OPTIONS|NODE_PATH|BASH_ENV|ENV|PROMPT_COMMAND|DYLD_.*|LD_.*)$/i;
|
|
11
|
+
export function safeConfigEnv(env) {
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const [k, v] of Object.entries(env))
|
|
14
|
+
if (!LOADER_ENV.test(k))
|
|
15
|
+
out[k] = v;
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
function toolsFrom(result) {
|
|
19
|
+
const r = (result ?? {});
|
|
20
|
+
const tools = Array.isArray(r.tools) ? r.tools.filter((t) => t && typeof t === 'object') : [];
|
|
21
|
+
return { tools, next: typeof r.nextCursor === 'string' && r.nextCursor ? r.nextCursor : undefined };
|
|
22
|
+
}
|
|
23
|
+
function serverInfo(result) {
|
|
24
|
+
const info = (result ?? {}).serverInfo ?? {};
|
|
25
|
+
return {
|
|
26
|
+
name: typeof info.name === 'string' ? info.name.slice(0, 80) : undefined,
|
|
27
|
+
version: typeof info.version === 'string' ? info.version.slice(0, 40) : undefined,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function finish(started, now, parts) {
|
|
31
|
+
return { ok: false, ms: now() - started, tools: [], toolBytes: 0, ...parts };
|
|
32
|
+
}
|
|
33
|
+
export async function probeStdio(spec, deps = {}) {
|
|
34
|
+
const now = deps.now ?? (() => Date.now());
|
|
35
|
+
const started = now();
|
|
36
|
+
if (!spec.command)
|
|
37
|
+
return finish(started, now, { error: 'no command in the config' });
|
|
38
|
+
const spawnImpl = deps.spawnImpl ?? ((cmd, args, opts) => nodeSpawn(cmd, args, { ...opts, stdio: ['pipe', 'pipe', 'ignore'], shell: process.platform === 'win32' }));
|
|
39
|
+
const timeoutMs = deps.timeoutMs ?? 45_000;
|
|
40
|
+
let child;
|
|
41
|
+
try {
|
|
42
|
+
child = spawnImpl(spec.command, spec.args, { cwd: spec.cwd, env: { ...process.env, ...safeConfigEnv(spec.env) } });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return finish(started, now, { error: `could not start "${spec.command}"` });
|
|
46
|
+
}
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
let done = false;
|
|
49
|
+
let buf = '';
|
|
50
|
+
let nextId = 1;
|
|
51
|
+
const waiting = new Map();
|
|
52
|
+
const end = (r) => {
|
|
53
|
+
if (done)
|
|
54
|
+
return;
|
|
55
|
+
done = true;
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
try {
|
|
58
|
+
child.stdin?.end();
|
|
59
|
+
}
|
|
60
|
+
catch { }
|
|
61
|
+
try {
|
|
62
|
+
child.kill('SIGTERM');
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
resolve(r);
|
|
66
|
+
};
|
|
67
|
+
const timer = setTimeout(() => end(finish(started, now, { error: `no answer within ${Math.round(timeoutMs / 1000)}s` })), timeoutMs);
|
|
68
|
+
child.on('error', () => end(finish(started, now, { error: `could not start "${spec.command}"` })));
|
|
69
|
+
child.on('exit', (code) => end(finish(started, now, { error: `it exited (code ${code ?? '?'}) before answering` })));
|
|
70
|
+
child.stdout?.setEncoding('utf-8');
|
|
71
|
+
child.stdout?.on('data', (chunk) => {
|
|
72
|
+
buf += chunk;
|
|
73
|
+
if (buf.length > 8_000_000)
|
|
74
|
+
return end(finish(started, now, { error: 'answer too large' }));
|
|
75
|
+
let nl;
|
|
76
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
77
|
+
const line = buf.slice(0, nl).trim();
|
|
78
|
+
buf = buf.slice(nl + 1);
|
|
79
|
+
if (!line)
|
|
80
|
+
continue;
|
|
81
|
+
let msg;
|
|
82
|
+
try {
|
|
83
|
+
msg = JSON.parse(line);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (typeof msg.id === 'number' && waiting.has(msg.id)) {
|
|
89
|
+
const cb = waiting.get(msg.id);
|
|
90
|
+
waiting.delete(msg.id);
|
|
91
|
+
cb(msg);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
const send = (method, params) => new Promise((res) => {
|
|
96
|
+
const id = nextId++;
|
|
97
|
+
waiting.set(id, res);
|
|
98
|
+
child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) }) + '\n');
|
|
99
|
+
});
|
|
100
|
+
const notify = (method) => child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method }) + '\n');
|
|
101
|
+
(async () => {
|
|
102
|
+
const init = await send('initialize', INIT_PARAMS);
|
|
103
|
+
if (init.error)
|
|
104
|
+
return end(finish(started, now, { error: 'it refused the handshake' }));
|
|
105
|
+
const info = serverInfo(init.result);
|
|
106
|
+
notify('notifications/initialized');
|
|
107
|
+
const all = [];
|
|
108
|
+
let cursor;
|
|
109
|
+
for (let page = 0; page < 20; page++) {
|
|
110
|
+
const res = await send('tools/list', cursor ? { cursor } : undefined);
|
|
111
|
+
if (res.error)
|
|
112
|
+
return end(finish(started, now, { serverName: info.name, serverVersion: info.version, error: 'tools/list failed' }));
|
|
113
|
+
const { tools, next } = toolsFrom(res.result);
|
|
114
|
+
all.push(...tools);
|
|
115
|
+
if (!next)
|
|
116
|
+
break;
|
|
117
|
+
cursor = next;
|
|
118
|
+
}
|
|
119
|
+
end({
|
|
120
|
+
ok: true,
|
|
121
|
+
ms: now() - started,
|
|
122
|
+
serverName: info.name,
|
|
123
|
+
serverVersion: info.version,
|
|
124
|
+
tools: all.map((t) => ({ name: String(t.name ?? '') })).filter((t) => t.name),
|
|
125
|
+
toolBytes: Buffer.byteLength(JSON.stringify({ tools: all })),
|
|
126
|
+
});
|
|
127
|
+
})().catch(() => end(finish(started, now, { error: 'unexpected answer' })));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
export async function probeRemote(spec, deps = {}) {
|
|
131
|
+
const now = deps.now ?? (() => Date.now());
|
|
132
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
133
|
+
const started = now();
|
|
134
|
+
if (!spec.url)
|
|
135
|
+
return finish(started, now, { error: 'no url in the config' });
|
|
136
|
+
const headers = {
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
Accept: 'application/json, text/event-stream',
|
|
139
|
+
...spec.headers,
|
|
140
|
+
};
|
|
141
|
+
if (!Object.keys(headers).some((k) => k.toLowerCase() === 'authorization') && deps.fallbackBearer && isNonbotRemoteUrl(spec.url)) {
|
|
142
|
+
headers.Authorization = `Bearer ${deps.fallbackBearer}`;
|
|
143
|
+
}
|
|
144
|
+
let nextId = 1;
|
|
145
|
+
const call = async (method, params, isNotification = false) => {
|
|
146
|
+
const body = isNotification
|
|
147
|
+
? { jsonrpc: '2.0', method }
|
|
148
|
+
: { jsonrpc: '2.0', id: nextId++, method, ...(params ? { params } : {}) };
|
|
149
|
+
const res = await fetchImpl(spec.url, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers,
|
|
152
|
+
body: JSON.stringify(body),
|
|
153
|
+
redirect: 'manual',
|
|
154
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? 15_000),
|
|
155
|
+
});
|
|
156
|
+
const session = res.headers.get('mcp-session-id');
|
|
157
|
+
if (session)
|
|
158
|
+
headers['Mcp-Session-Id'] = session;
|
|
159
|
+
if (isNotification)
|
|
160
|
+
return null;
|
|
161
|
+
if (res.status === 401 || res.status === 403)
|
|
162
|
+
throw new Error(`auth ${res.status}`);
|
|
163
|
+
if (!res.ok)
|
|
164
|
+
throw new Error(`HTTP ${res.status}`);
|
|
165
|
+
const text = await res.text();
|
|
166
|
+
if ((res.headers.get('content-type') ?? '').includes('text/event-stream')) {
|
|
167
|
+
for (const line of text.split('\n')) {
|
|
168
|
+
if (!line.startsWith('data:'))
|
|
169
|
+
continue;
|
|
170
|
+
try {
|
|
171
|
+
const msg = JSON.parse(line.slice(5).trim());
|
|
172
|
+
if (msg.result !== undefined || msg.error)
|
|
173
|
+
return msg;
|
|
174
|
+
}
|
|
175
|
+
catch { }
|
|
176
|
+
}
|
|
177
|
+
return { error: { message: 'no result' } };
|
|
178
|
+
}
|
|
179
|
+
return JSON.parse(text);
|
|
180
|
+
};
|
|
181
|
+
try {
|
|
182
|
+
const init = await call('initialize', INIT_PARAMS);
|
|
183
|
+
if (!init || init.error)
|
|
184
|
+
return finish(started, now, { error: 'it refused the handshake' });
|
|
185
|
+
const info = serverInfo(init.result);
|
|
186
|
+
await call('notifications/initialized', undefined, true).catch(() => null);
|
|
187
|
+
const all = [];
|
|
188
|
+
let cursor;
|
|
189
|
+
for (let page = 0; page < 20; page++) {
|
|
190
|
+
const res = await call('tools/list', cursor ? { cursor } : undefined);
|
|
191
|
+
if (!res || res.error)
|
|
192
|
+
return finish(started, now, { serverName: info.name, serverVersion: info.version, error: 'tools/list failed' });
|
|
193
|
+
const { tools, next } = toolsFrom(res.result);
|
|
194
|
+
all.push(...tools);
|
|
195
|
+
if (!next)
|
|
196
|
+
break;
|
|
197
|
+
cursor = next;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
ms: now() - started,
|
|
202
|
+
serverName: info.name,
|
|
203
|
+
serverVersion: info.version,
|
|
204
|
+
tools: all.map((t) => ({ name: String(t.name ?? '') })).filter((t) => t.name),
|
|
205
|
+
toolBytes: Buffer.byteLength(JSON.stringify({ tools: all })),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
const m = e.message;
|
|
210
|
+
return finish(started, now, { error: /^auth/.test(m) ? 'the server refused the token' : /^HTTP/.test(m) ? `the server answered ${m}` : 'could not reach it' });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export function probeMcp(spec, deps = {}) {
|
|
214
|
+
return spec.url && !spec.command ? probeRemote(spec, deps) : probeStdio(spec, deps);
|
|
215
|
+
}
|
|
216
|
+
export function toolGroups(tools) {
|
|
217
|
+
const counts = new Map();
|
|
218
|
+
for (const t of tools) {
|
|
219
|
+
const g = t.name.includes('_') ? t.name.slice(0, t.name.indexOf('_')) : t.name;
|
|
220
|
+
counts.set(g, (counts.get(g) ?? 0) + 1);
|
|
221
|
+
}
|
|
222
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
223
|
+
}
|
|
224
|
+
export function estimateTokens(bytes) {
|
|
225
|
+
return Math.round(bytes / 4);
|
|
226
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, lstatSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fetchLatestVersion, compareVersions, parseSemver } from './update-check.js';
|
|
5
|
+
import { scanMcpConfigs, describeNonbotMcp } from './mcp-config.js';
|
|
6
|
+
export const MCP_LATEST_URL = 'https://registry.npmjs.org/@nonbot/mcp/latest';
|
|
7
|
+
export const MCP_REMOTE_URL = 'https://mcp.non.bot/';
|
|
8
|
+
function readVersion(pkgJsonPath) {
|
|
9
|
+
try {
|
|
10
|
+
if (!existsSync(pkgJsonPath))
|
|
11
|
+
return null;
|
|
12
|
+
const v = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')).version;
|
|
13
|
+
return parseSemver(v) ? String(v) : null;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function defaultGlobalRoot(execPath = process.execPath, platform = process.platform) {
|
|
20
|
+
return platform === 'win32'
|
|
21
|
+
? join(dirname(execPath), 'node_modules')
|
|
22
|
+
: join(dirname(dirname(execPath)), 'lib', 'node_modules');
|
|
23
|
+
}
|
|
24
|
+
export function globalMcpIsLinked(globalRoot = defaultGlobalRoot()) {
|
|
25
|
+
try {
|
|
26
|
+
return lstatSync(join(globalRoot, '@nonbot', 'mcp')).isSymbolicLink();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function npxCachedMcpVersion(env = process.env, home = homedir()) {
|
|
33
|
+
const root = join(env.npm_config_cache || join(home, '.npm'), '_npx');
|
|
34
|
+
let best = null;
|
|
35
|
+
let dirs = [];
|
|
36
|
+
try {
|
|
37
|
+
dirs = readdirSync(root);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
for (const hash of dirs) {
|
|
43
|
+
const v = readVersion(join(root, hash, 'node_modules', '@nonbot', 'mcp', 'package.json'));
|
|
44
|
+
if (v && (best === null || compareVersions(v, best) > 0))
|
|
45
|
+
best = v;
|
|
46
|
+
}
|
|
47
|
+
return best;
|
|
48
|
+
}
|
|
49
|
+
export async function remoteMcpVersion(fetchImpl = fetch) {
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetchImpl(MCP_REMOTE_URL, {
|
|
52
|
+
headers: { Accept: 'text/html' },
|
|
53
|
+
signal: AbortSignal.timeout(3_000),
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
return null;
|
|
57
|
+
const m = /version\s+(\d{1,6}\.\d{1,6}\.\d{1,6})\b/.exec(await res.text());
|
|
58
|
+
return m ? m[1] : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function installedMcpVersion(entry, deps = {}) {
|
|
65
|
+
switch (entry.kind) {
|
|
66
|
+
case 'checkout':
|
|
67
|
+
return entry.checkoutDir ? readVersion(join(entry.checkoutDir, 'package.json')) : null;
|
|
68
|
+
case 'npx':
|
|
69
|
+
return entry.pinned && parseSemver(entry.pinned) ? entry.pinned : npxCachedMcpVersion(deps.env, deps.home);
|
|
70
|
+
case 'global':
|
|
71
|
+
return readVersion(join(deps.globalRoot ?? defaultGlobalRoot(), '@nonbot', 'mcp', 'package.json'));
|
|
72
|
+
case 'remote':
|
|
73
|
+
return remoteMcpVersion(deps.fetchImpl);
|
|
74
|
+
default:
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function nonbotMcpStatuses(deps = {}) {
|
|
79
|
+
const entries = scanMcpConfigs(deps).nonbot;
|
|
80
|
+
if (entries.length === 0)
|
|
81
|
+
return [];
|
|
82
|
+
const [latest, installed] = await Promise.all([
|
|
83
|
+
fetchLatestVersion({ url: MCP_LATEST_URL, fetchImpl: deps.fetchImpl }),
|
|
84
|
+
Promise.all(entries.map((e) => installedMcpVersion(e, deps))),
|
|
85
|
+
]);
|
|
86
|
+
return entries.map((entry, i) => ({ entry, installed: installed[i] ?? null, latest }));
|
|
87
|
+
}
|
|
88
|
+
export function mcpRow(s, home = homedir()) {
|
|
89
|
+
const how = describeNonbotMcp(s.entry, home);
|
|
90
|
+
const v = s.installed ? `v${s.installed} · ` : '';
|
|
91
|
+
const behind = !!(s.installed && s.latest && compareVersions(s.latest, s.installed) > 0);
|
|
92
|
+
const e = s.entry;
|
|
93
|
+
if (e.kind === 'npx' && e.pinned) {
|
|
94
|
+
return {
|
|
95
|
+
sigil: '⚠',
|
|
96
|
+
detail: `${v}npx, pinned · gets no updates`,
|
|
97
|
+
hint: 'unpin it: claude mcp remove nonbot && claude mcp add nonbot -- npx -y @nonbot/mcp',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (!behind) {
|
|
101
|
+
if (e.kind === 'npx' && !s.installed) {
|
|
102
|
+
return { sigil: '✓', detail: `npx @nonbot/mcp · fetches ${s.latest ? `v${s.latest}` : 'the latest'} on first start` };
|
|
103
|
+
}
|
|
104
|
+
return { sigil: '✓', detail: `${v}${how}` };
|
|
105
|
+
}
|
|
106
|
+
const out = `v${s.installed} · ${shortKind(e.kind)} · v${s.latest} is out`;
|
|
107
|
+
switch (e.kind) {
|
|
108
|
+
case 'npx':
|
|
109
|
+
return { sigil: 'ℹ', detail: out, hint: 'restart Claude Code (or /mcp → Reconnect) and npx fetches it' };
|
|
110
|
+
case 'global':
|
|
111
|
+
return { sigil: '⚠', detail: out, hint: 'update it: nonbot update mcp' };
|
|
112
|
+
case 'checkout':
|
|
113
|
+
return {
|
|
114
|
+
sigil: 'ℹ',
|
|
115
|
+
detail: out,
|
|
116
|
+
hint: `update the checkout: cd ${e.checkoutDir ? tildePath(e.checkoutDir, home) : '<checkout>'} && git pull && npm run build`,
|
|
117
|
+
};
|
|
118
|
+
default:
|
|
119
|
+
return { sigil: 'ℹ', detail: out };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function shortKind(kind) {
|
|
123
|
+
return kind === 'npx' ? 'npx @nonbot/mcp' : kind === 'checkout' ? 'source checkout' : kind === 'global' ? 'global install' : kind;
|
|
124
|
+
}
|
|
125
|
+
function tildePath(p, home) {
|
|
126
|
+
return p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
127
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync, chmodSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
function configDir() {
|
|
6
|
+
const override = process.env.NONBOT_CONFIG_DIR;
|
|
7
|
+
return override && override.length > 0 ? override : join(homedir(), '.config', 'nonbot');
|
|
8
|
+
}
|
|
9
|
+
export function trustFilePath(dir = configDir()) {
|
|
10
|
+
return join(dir, 'mcp-trust.json');
|
|
11
|
+
}
|
|
12
|
+
function realDir(dir) {
|
|
13
|
+
try {
|
|
14
|
+
return realpathSync(dir);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function launchFingerprint(spec) {
|
|
21
|
+
const env = Object.keys(spec.env).sort().map((k) => [k, spec.env[k]]);
|
|
22
|
+
const payload = JSON.stringify({ command: spec.command ?? null, args: spec.args, url: spec.url ?? null, env, cwd: realDir(spec.cwd) });
|
|
23
|
+
return createHash('sha256').update(payload).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
function readRecords(file) {
|
|
26
|
+
try {
|
|
27
|
+
if (!existsSync(file))
|
|
28
|
+
return [];
|
|
29
|
+
const json = JSON.parse(readFileSync(file, 'utf-8'));
|
|
30
|
+
return Array.isArray(json.approved)
|
|
31
|
+
? json.approved.filter((r) => r && typeof r === 'object' && typeof r.fingerprint === 'string')
|
|
32
|
+
: [];
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function isRememberedTrust(entry, spec, file = trustFilePath()) {
|
|
39
|
+
if (!entry.dir)
|
|
40
|
+
return false;
|
|
41
|
+
const dir = realDir(entry.dir);
|
|
42
|
+
const fp = launchFingerprint(spec);
|
|
43
|
+
return readRecords(file).some((r) => r.dir === dir && r.name === entry.name && r.fingerprint === fp);
|
|
44
|
+
}
|
|
45
|
+
export function mayStartEntry(entry, spec, isRemembered = (e, s) => isRememberedTrust(e, s)) {
|
|
46
|
+
if (entry.approved !== false)
|
|
47
|
+
return true;
|
|
48
|
+
return isRemembered(entry, spec);
|
|
49
|
+
}
|
|
50
|
+
export function rememberTrust(entry, spec, file = trustFilePath(), now = new Date()) {
|
|
51
|
+
if (!entry.dir)
|
|
52
|
+
return;
|
|
53
|
+
const dir = realDir(entry.dir);
|
|
54
|
+
const records = readRecords(file).filter((r) => !(r.dir === dir && r.name === entry.name));
|
|
55
|
+
records.push({ dir, name: entry.name, fingerprint: launchFingerprint(spec), approvedAt: now.toISOString() });
|
|
56
|
+
const parent = join(file, '..');
|
|
57
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
58
|
+
const tmp = `${file}.tmp`;
|
|
59
|
+
writeFileSync(tmp, JSON.stringify({ approved: records.slice(-200) }, null, 2) + '\n', { mode: 0o600 });
|
|
60
|
+
chmodSync(tmp, 0o600);
|
|
61
|
+
renameSync(tmp, file);
|
|
62
|
+
chmodSync(file, 0o600);
|
|
63
|
+
}
|
package/dist/lib/output.js
CHANGED
|
@@ -144,11 +144,40 @@ const WORDMARK_LINES = [
|
|
|
144
144
|
export const WORDMARK_WIDTH = 63;
|
|
145
145
|
export const WORDMARK_HEIGHT = 6;
|
|
146
146
|
export const WORDMARK_DOT_COLS = [31, 32, 33];
|
|
147
|
+
export const WORDMARK_GRADIENT_STOPS = [
|
|
148
|
+
[0x5e, 0xd8, 0xf0],
|
|
149
|
+
[0x6c, 0x8c, 0xff],
|
|
150
|
+
[0x9b, 0x6c, 0xf0],
|
|
151
|
+
[0xe0, 0x7a, 0xd8],
|
|
152
|
+
];
|
|
153
|
+
const WORDMARK_SHEEN = [0.24, 0.15, 0.07, 0, 0, 0];
|
|
154
|
+
const WORDMARK_SHADOW = 0.55;
|
|
155
|
+
function lerp(a, b, t) {
|
|
156
|
+
return a + (b - a) * t;
|
|
157
|
+
}
|
|
158
|
+
export function wordmarkCellColor(col, row, shadow = false) {
|
|
159
|
+
const span = WORDMARK_WIDTH - 1 + (WORDMARK_HEIGHT - 1) * 1.5;
|
|
160
|
+
const t = Math.min(1, Math.max(0, (col + row * 1.5) / span));
|
|
161
|
+
const stops = WORDMARK_GRADIENT_STOPS;
|
|
162
|
+
const seg = Math.min(stops.length - 2, Math.floor(t * (stops.length - 1)));
|
|
163
|
+
const local = t * (stops.length - 1) - seg;
|
|
164
|
+
const a = stops[seg];
|
|
165
|
+
const b = stops[seg + 1];
|
|
166
|
+
let rgb = [lerp(a[0], b[0], local), lerp(a[1], b[1], local), lerp(a[2], b[2], local)];
|
|
167
|
+
if (shadow) {
|
|
168
|
+
rgb = [rgb[0] * WORDMARK_SHADOW, rgb[1] * WORDMARK_SHADOW, rgb[2] * WORDMARK_SHADOW];
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const sheen = WORDMARK_SHEEN[row] ?? 0;
|
|
172
|
+
rgb = [lerp(rgb[0], 255, sheen), lerp(rgb[1], 255, sheen), lerp(rgb[2], 255, sheen)];
|
|
173
|
+
}
|
|
174
|
+
return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2])];
|
|
175
|
+
}
|
|
147
176
|
function hexFg(hex) {
|
|
148
177
|
const n = parseInt(hex.slice(1), 16);
|
|
149
178
|
return `\x1b[38;2;${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}m`;
|
|
150
179
|
}
|
|
151
|
-
function paintWordmarkLine(line, stream = process.stdout) {
|
|
180
|
+
function paintWordmarkLine(line, row, stream = process.stdout) {
|
|
152
181
|
if (!isTTY(stream))
|
|
153
182
|
return line;
|
|
154
183
|
const chars = Array.from(line);
|
|
@@ -159,11 +188,16 @@ function paintWordmarkLine(line, stream = process.stdout) {
|
|
|
159
188
|
out += ' ';
|
|
160
189
|
continue;
|
|
161
190
|
}
|
|
162
|
-
const
|
|
163
|
-
out += BRAND.bold
|
|
191
|
+
const [r, g, b] = wordmarkCellColor(i, row, ch !== '█');
|
|
192
|
+
out += `${BRAND.bold}\x1b[38;2;${r};${g};${b}m${ch}${BRAND.reset}`;
|
|
164
193
|
}
|
|
165
194
|
return out;
|
|
166
195
|
}
|
|
196
|
+
export function wordmarkArt(opts = {}) {
|
|
197
|
+
const stream = opts.stream ?? process.stdout;
|
|
198
|
+
const indent = opts.indent ?? '';
|
|
199
|
+
return WORDMARK_LINES.map((line, row) => indent + paintWordmarkLine(line, row, stream)).join('\n');
|
|
200
|
+
}
|
|
167
201
|
export function terminalWidth(width) {
|
|
168
202
|
const cols = width ?? (process.stdout && process.stdout.columns) ?? 80;
|
|
169
203
|
const min = WORDMARK_WIDTH + 6;
|
|
@@ -180,7 +214,7 @@ function trackedCaps(s) {
|
|
|
180
214
|
export function wordmark(version, opts = {}) {
|
|
181
215
|
const stream = opts.stream ?? process.stdout;
|
|
182
216
|
const state = opts.state;
|
|
183
|
-
const headLines = WORDMARK_LINES.map((line) => paintWordmarkLine(line, stream));
|
|
217
|
+
const headLines = WORDMARK_LINES.map((line, row) => paintWordmarkLine(line, row, stream));
|
|
184
218
|
const parts = [c.cyan(trackedCaps('DAEMON'), stream)];
|
|
185
219
|
if (version) {
|
|
186
220
|
parts.push(c.muted('·', stream));
|
|
@@ -207,7 +241,7 @@ export function renderWordmarkWithBadge(opts = {}) {
|
|
|
207
241
|
red: (s) => c.red(s, stream),
|
|
208
242
|
muted: (s) => c.muted(s, stream),
|
|
209
243
|
};
|
|
210
|
-
const lines = WORDMARK_LINES.map((wm) => paintWordmarkLine(wm, stream));
|
|
244
|
+
const lines = WORDMARK_LINES.map((wm, row) => paintWordmarkLine(wm, row, stream));
|
|
211
245
|
lines.push('');
|
|
212
246
|
lines.push(` ${dotColored[color]('●')} ${dotColored[color](label)}`);
|
|
213
247
|
for (const s of sublines) {
|
|
@@ -360,7 +394,7 @@ export function compactMasthead(version, opts = {}) {
|
|
|
360
394
|
const badgeColor = opts.badgeColor ?? 'green';
|
|
361
395
|
const badgeLabel = opts.badgeLabel ?? 'CONNECTED';
|
|
362
396
|
const dotGlyph = asciiOnly() ? '*' : '●';
|
|
363
|
-
const brand =
|
|
397
|
+
const brand = c.bold(c.cream('non.bot', stream), stream);
|
|
364
398
|
const badge = colorize(`${dotGlyph} ${badgeLabel}`, badgeColor, stream);
|
|
365
399
|
const sep = c.muted('·', stream);
|
|
366
400
|
const tail = [c.muted(`daemon ${version}`, stream), sep, c.muted(state, stream)].join(' ');
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const MOUSE_ON = '\x1b[?1000h\x1b[?1006h';
|
|
2
|
+
const MOUSE_OFF = '\x1b[?1000l\x1b[?1006l';
|
|
3
|
+
const ASK_CURSOR = '\x1b[6n';
|
|
4
|
+
const CURSOR_REPORT = /\x1b\[(\d+);(\d+)R/;
|
|
5
|
+
const MOUSE_SGR = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
6
|
+
export function interpretInput(chunk, state, linesBelowHelp) {
|
|
7
|
+
let rest = chunk;
|
|
8
|
+
const cur = CURSOR_REPORT.exec(rest);
|
|
9
|
+
if (cur) {
|
|
10
|
+
state.helpRow = Number(cur[1]) - linesBelowHelp;
|
|
11
|
+
rest = rest.replace(CURSOR_REPORT, '');
|
|
12
|
+
}
|
|
13
|
+
let sawMouse = false;
|
|
14
|
+
for (const m of rest.matchAll(MOUSE_SGR)) {
|
|
15
|
+
sawMouse = true;
|
|
16
|
+
const button = Number(m[1]);
|
|
17
|
+
const y = Number(m[3]);
|
|
18
|
+
const press = m[4] === 'M';
|
|
19
|
+
if (press && button === 0 && state.helpRow !== null && y === state.helpRow)
|
|
20
|
+
return 'help';
|
|
21
|
+
}
|
|
22
|
+
rest = rest.replace(MOUSE_SGR, '');
|
|
23
|
+
if (rest === '')
|
|
24
|
+
return null;
|
|
25
|
+
if (sawMouse && rest.trim() === '')
|
|
26
|
+
return null;
|
|
27
|
+
if (/^[hH][\r\n]*$/.test(rest))
|
|
28
|
+
return 'help';
|
|
29
|
+
return 'exit';
|
|
30
|
+
}
|
|
31
|
+
export function linesBelow(printed, marker) {
|
|
32
|
+
const at = printed.indexOf(marker);
|
|
33
|
+
if (at < 0)
|
|
34
|
+
return 0;
|
|
35
|
+
const lineStart = printed.lastIndexOf('\n', at) + 1;
|
|
36
|
+
return (printed.slice(lineStart).match(/\n/g) ?? []).length;
|
|
37
|
+
}
|
|
38
|
+
export function menuIsInteractive(io, env = process.env) {
|
|
39
|
+
if (!io.stdin.isTTY || !io.stdout.isTTY)
|
|
40
|
+
return false;
|
|
41
|
+
if (env.CI || env.NONBOT_NO_MENU === '1')
|
|
42
|
+
return false;
|
|
43
|
+
return typeof io.stdin.setRawMode === 'function';
|
|
44
|
+
}
|
|
45
|
+
export function waitForMenuChoice(io, linesBelowHelp) {
|
|
46
|
+
const { stdin, stdout } = io;
|
|
47
|
+
const state = { helpRow: null };
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
let done = false;
|
|
50
|
+
const finish = (choice) => {
|
|
51
|
+
if (done)
|
|
52
|
+
return;
|
|
53
|
+
done = true;
|
|
54
|
+
stdin.off('data', onData);
|
|
55
|
+
process.off('SIGINT', onSig);
|
|
56
|
+
process.off('SIGTERM', onSig);
|
|
57
|
+
try {
|
|
58
|
+
stdout.write(MOUSE_OFF);
|
|
59
|
+
}
|
|
60
|
+
catch { }
|
|
61
|
+
try {
|
|
62
|
+
stdin.setRawMode(false);
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
stdin.pause();
|
|
66
|
+
resolve(choice);
|
|
67
|
+
};
|
|
68
|
+
const onData = (buf) => {
|
|
69
|
+
const choice = interpretInput(buf.toString('utf-8'), state, linesBelowHelp);
|
|
70
|
+
if (choice)
|
|
71
|
+
finish(choice);
|
|
72
|
+
};
|
|
73
|
+
const onSig = () => finish('exit');
|
|
74
|
+
try {
|
|
75
|
+
stdin.setRawMode(true);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return resolve('exit');
|
|
79
|
+
}
|
|
80
|
+
stdin.resume();
|
|
81
|
+
stdin.on('data', onData);
|
|
82
|
+
process.once('SIGINT', onSig);
|
|
83
|
+
process.once('SIGTERM', onSig);
|
|
84
|
+
stdout.write(MOUSE_ON + ASK_CURSOR);
|
|
85
|
+
});
|
|
86
|
+
}
|
package/dist/lib/update-check.js
CHANGED
|
@@ -35,7 +35,7 @@ export function isNewerVersion(latest, current) {
|
|
|
35
35
|
return compareVersions(latest, current) > 0;
|
|
36
36
|
}
|
|
37
37
|
export function updateAvailableLine(current, latest) {
|
|
38
|
-
return `Update available: @nonbot/cli ${current} → ${latest} (
|
|
38
|
+
return `Update available: @nonbot/cli ${current} → ${latest} (run: nonbot update)`;
|
|
39
39
|
}
|
|
40
40
|
export async function fetchLatestVersion(opts = {}) {
|
|
41
41
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.10.
|
|
1
|
+
export const VERSION = '0.10.16';
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nonbot/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The local host for non.bot ▶ Run — opens a terminal on your machine and starts the work in your linked repo.",
|
|
6
|
-
"license": "
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
7
|
"bin": {
|
|
8
8
|
"nonbot": "dist/index.js"
|
|
9
9
|
},
|