@tiny-fish/cli 0.42.1 → 0.45.1-next.352
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 +11 -7
- package/dist/commands/auth.js +11 -1
- package/dist/commands/connect.js +112 -50
- package/dist/commands/doctor.js +4 -7
- package/dist/commands/run.js +5 -2
- package/dist/lib/auth.d.ts +2 -1
- package/dist/lib/auth.js +3 -1
- package/dist/lib/client.d.ts +2 -1
- package/dist/lib/client.js +50 -9
- package/dist/lib/connect-all-auth.js +3 -0
- package/dist/lib/connect-all-uninstall.js +6 -4
- package/dist/lib/connect-all.js +10 -4
- package/dist/lib/connect-clients.d.ts +13 -8
- package/dist/lib/connect-clients.js +51 -27
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/doctor-checks.js +4 -0
- package/dist/lib/doctor-report.d.ts +2 -1
- package/dist/lib/doctor-report.js +1 -2
- package/dist/lib/doctor-telemetry.js +0 -3
- package/dist/lib/harness-detect.d.ts +1 -0
- package/dist/lib/harness-detect.js +1 -0
- package/dist/lib/harness-spec.d.ts +4 -0
- package/dist/lib/harness-spec.js +3 -0
- package/dist/lib/hermes-config.d.ts +4 -0
- package/dist/lib/hermes-config.js +12 -3
- package/dist/lib/hermes-plugin.d.ts +4 -5
- package/dist/lib/hermes-plugin.js +92 -36
- package/dist/lib/mcp-json-config.d.ts +4 -2
- package/dist/lib/mcp-json-config.js +20 -10
- package/dist/lib/omp-config.js +5 -3
- package/dist/lib/output.js +7 -1
- package/dist/lib/registration-detect.js +12 -4
- package/dist/lib/setup-telemetry.d.ts +12 -2
- package/dist/lib/setup-telemetry.js +1 -1
- package/dist/lib/verify.d.ts +1 -1
- package/dist/lib/verify.js +29 -9
- package/package.json +2 -2
|
@@ -6,19 +6,15 @@ import spawn from 'cross-spawn';
|
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from './cli-install.js';
|
|
8
8
|
import { commandNotFound, ConnectStepError, spawnStepError, throwIfInterrupted, } from './connect-runtime.js';
|
|
9
|
-
import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
|
|
9
|
+
import { HARNESS_PROBE_TIMEOUT_MS, TINYFISH_ACCESS_MODE_HEADER, TINYFISH_KEYLESS_ACCESS_MODE, } from './constants.js';
|
|
10
10
|
import { HERMES_HEADER_TEMPLATE, HERMES_MCP_SERVER_KEY } from './hermes-config.js';
|
|
11
11
|
import { errLine, parseJson } from './output.js';
|
|
12
|
-
//
|
|
13
|
-
export const
|
|
14
|
-
//
|
|
15
|
-
export const HERMES_PLUGIN_VERSION = '0.1.0';
|
|
16
|
-
// Manifest `name:`, not the repo subdir; `plugins uninstall` keys on it.
|
|
17
|
-
const PLUGIN_SOURCE = 'tinyfish-io/tinyfish-web-agent-integrations/hermes';
|
|
18
|
-
const PLUGIN_REPO_URL = 'https://github.com/tinyfish-io/tinyfish-web-agent-integrations.git';
|
|
19
|
-
export const HERMES_PLUGIN_MANUAL_INSTALL = `hermes plugins install ${PLUGIN_SOURCE} --ref ${HERMES_PLUGIN_SHA} --enable`;
|
|
20
|
-
// Anchored to --ref: another rejected flag would fail the clone path too.
|
|
12
|
+
// Resolved at connect time, so plugin releases need no CLI release.
|
|
13
|
+
export const HERMES_PLUGIN_PACKAGE = '@tiny-fish/hermes';
|
|
14
|
+
// Anchored to --ref: an older Hermes rejects the flag and needs the plain URL.
|
|
21
15
|
const REF_UNSUPPORTED_PATTERN = /unrecognized arguments:[^\n]*--ref/;
|
|
16
|
+
// Tracks the repo head; npm @latest can trail a merge by one publish run.
|
|
17
|
+
export const HERMES_PLUGIN_MANUAL_INSTALL = 'hermes plugins install tinyfish-io/tinyfish-web-agent-integrations/hermes --enable';
|
|
22
18
|
function installSpawnEnv(home, apiKey) {
|
|
23
19
|
// Hermes' install-time requires_env check reads os.environ, not our .env seed.
|
|
24
20
|
return { ...process.env, HERMES_HOME: home, TINYFISH_API_KEY: apiKey };
|
|
@@ -42,42 +38,102 @@ function runGit(args) {
|
|
|
42
38
|
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
43
39
|
});
|
|
44
40
|
if (!result.error && result.status === 0)
|
|
45
|
-
return;
|
|
41
|
+
return result;
|
|
46
42
|
if (commandNotFound(result.error)) {
|
|
47
|
-
throw new ConnectStepError('git is required to stage the
|
|
43
|
+
throw new ConnectStepError('git is required to stage the plugin for Hermes; install git and run connect again', 'command_not_found');
|
|
48
44
|
}
|
|
49
45
|
// Built first: its interrupt check must run before any replay.
|
|
50
46
|
const error = spawnStepError('Could not stage the TinyFish Hermes plugin', result);
|
|
51
47
|
replay(capturedOutput(result));
|
|
52
48
|
throw error;
|
|
53
49
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
50
|
+
function runNpmDownload(tmp) {
|
|
51
|
+
// --prefix both downloads and extracts, so no tar dependency anywhere.
|
|
52
|
+
const result = spawn.sync('npm', [
|
|
53
|
+
'install',
|
|
54
|
+
`${HERMES_PLUGIN_PACKAGE}@latest`,
|
|
55
|
+
'--prefix',
|
|
56
|
+
tmp,
|
|
57
|
+
// Lifecycle scripts would run arbitrary code with the key in env.
|
|
58
|
+
'--ignore-scripts',
|
|
59
|
+
'--no-save',
|
|
60
|
+
'--no-audit',
|
|
61
|
+
'--no-fund',
|
|
62
|
+
'--loglevel=error',
|
|
63
|
+
], { encoding: 'utf8', maxBuffer: STEP_MAX_BUFFER, timeout: SKILL_INSTALL_TIMEOUT_MS });
|
|
64
|
+
if (!result.error && result.status === 0)
|
|
65
|
+
return;
|
|
66
|
+
if (commandNotFound(result.error)) {
|
|
67
|
+
throw new ConnectStepError(`npm is required to download the TinyFish Hermes plugin; to install it yourself run: ${HERMES_PLUGIN_MANUAL_INSTALL}`, 'command_not_found');
|
|
68
|
+
}
|
|
69
|
+
// Built first: its interrupt check must run before any replay.
|
|
70
|
+
const error = spawnStepError(`Could not download ${HERMES_PLUGIN_PACKAGE} from npm; to install it yourself run: ${HERMES_PLUGIN_MANUAL_INSTALL}`, result);
|
|
71
|
+
replay(capturedOutput(result));
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
function stagedPluginVersion(dir) {
|
|
57
75
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return runPluginInstall([`${pathToFileURL(tmp).href}#hermes`], env);
|
|
76
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
77
|
+
const version = parsed.version;
|
|
78
|
+
return typeof version === 'string' && version ? version : undefined;
|
|
62
79
|
}
|
|
63
|
-
|
|
64
|
-
|
|
80
|
+
catch {
|
|
81
|
+
return undefined;
|
|
65
82
|
}
|
|
66
83
|
}
|
|
84
|
+
// Hermes installs plugins only by git clone, so the payload gets a throwaway repo.
|
|
85
|
+
function stagePluginFromNpm(tmp) {
|
|
86
|
+
runNpmDownload(tmp);
|
|
87
|
+
const dir = path.join(tmp, 'node_modules', ...HERMES_PLUGIN_PACKAGE.split('/'));
|
|
88
|
+
// Empty hooksPath: a global hooks dir or init template must not run here.
|
|
89
|
+
const hooksDir = path.join(tmp, 'git-hooks');
|
|
90
|
+
fs.mkdirSync(hooksDir);
|
|
91
|
+
runGit(['-C', dir, 'init', '--quiet']);
|
|
92
|
+
runGit(['-C', dir, 'add', '-A']);
|
|
93
|
+
// Inline identity: fresh machines have no git config and commit fails without it.
|
|
94
|
+
runGit([
|
|
95
|
+
'-C',
|
|
96
|
+
dir,
|
|
97
|
+
'-c',
|
|
98
|
+
`core.hooksPath=${hooksDir}`,
|
|
99
|
+
'-c',
|
|
100
|
+
'user.name=tinyfish-cli',
|
|
101
|
+
'-c',
|
|
102
|
+
'user.email=support@tinyfish.io',
|
|
103
|
+
'commit',
|
|
104
|
+
'--quiet',
|
|
105
|
+
// A global commit.gpgSign would summon a signer this commit must not need.
|
|
106
|
+
'--no-gpg-sign',
|
|
107
|
+
'-m',
|
|
108
|
+
'stage @tiny-fish/hermes for install',
|
|
109
|
+
]);
|
|
110
|
+
const sha = String(runGit(['-C', dir, 'rev-parse', 'HEAD']).stdout ?? '').trim();
|
|
111
|
+
return { dir, sha, version: stagedPluginVersion(dir) };
|
|
112
|
+
}
|
|
67
113
|
export function installHermesPlugin(home, apiKey, { verbose }) {
|
|
68
114
|
errLine('Installing the TinyFish web plugin in Hermes...');
|
|
69
115
|
const env = installSpawnEnv(home, apiKey);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
116
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyfish-hermes-plugin-'));
|
|
117
|
+
try {
|
|
118
|
+
const staged = stagePluginFromNpm(tmp);
|
|
119
|
+
const url = pathToFileURL(staged.dir).href;
|
|
120
|
+
// --ref: a prior pinned install rejects an unpinned reinstall of the same name.
|
|
121
|
+
const primary = runPluginInstall([url, '--ref', staged.sha], env);
|
|
122
|
+
// A pre---ref Hermes never pins, so the plain URL is safe there.
|
|
123
|
+
const result = refUnsupported(primary) ? runPluginInstall([url], env) : primary;
|
|
124
|
+
if (result.error || result.status !== 0) {
|
|
125
|
+
// A declined security scan exits 1 here, and reconnecting repeats it.
|
|
126
|
+
const error = spawnStepError(`Could not install the TinyFish web plugin in Hermes; to install it yourself run: ${HERMES_PLUGIN_MANUAL_INSTALL}`, result);
|
|
127
|
+
replay(capturedOutput(result));
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
if (verbose)
|
|
131
|
+
replay(capturedOutput(result));
|
|
132
|
+
return staged.version;
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
78
136
|
}
|
|
79
|
-
if (verbose)
|
|
80
|
-
replay(capturedOutput(result));
|
|
81
137
|
}
|
|
82
138
|
function hermesConfig(args, home) {
|
|
83
139
|
return spawn.sync('hermes', ['config', ...args], {
|
|
@@ -97,15 +153,15 @@ function configWrite(home, args) {
|
|
|
97
153
|
throw error;
|
|
98
154
|
}
|
|
99
155
|
/** Absent `enabled` reads as enabled, so never write this field-by-field. */
|
|
100
|
-
export function writeHermesMcpEntry(home, mcpUrl) {
|
|
156
|
+
export function writeHermesMcpEntry(home, mcpUrl, mode = 'api-key') {
|
|
101
157
|
const entry = {
|
|
102
158
|
url: mcpUrl,
|
|
103
|
-
headers:
|
|
159
|
+
headers: mode === 'keyless'
|
|
160
|
+
? { [TINYFISH_ACCESS_MODE_HEADER]: TINYFISH_KEYLESS_ACCESS_MODE }
|
|
161
|
+
: { Authorization: HERMES_HEADER_TEMPLATE },
|
|
104
162
|
enabled: true,
|
|
105
163
|
};
|
|
106
|
-
|
|
107
|
-
// `config set` exits 0 on a bad key, so only an interrupt is worth raising here.
|
|
108
|
-
throwIfInterrupted(result);
|
|
164
|
+
configWrite(home, ['set', `mcp_servers.${HERMES_MCP_SERVER_KEY}`, JSON.stringify(entry)]);
|
|
109
165
|
}
|
|
110
166
|
/** Retracts our row; an absent `enabled` reads as enabled, so a partial one is live. */
|
|
111
167
|
export function removeHermesMcpEntry(home) {
|
|
@@ -17,11 +17,13 @@ export interface McpJsonWriteResult {
|
|
|
17
17
|
repaired?: boolean;
|
|
18
18
|
}
|
|
19
19
|
export declare function buildTinyfishServerEntry(mcpUrl: string, apiKey?: string): Record<string, unknown>;
|
|
20
|
+
export declare function buildTinyfishKeylessServerEntry(mcpUrl: string): Record<string, unknown>;
|
|
20
21
|
/** Dry-run description of the pending write; touches nothing. */
|
|
21
|
-
export declare function planWrite(target: McpJsonTarget, mcpUrl: string, apiKey?: string): string;
|
|
22
|
+
export declare function planWrite(target: McpJsonTarget, mcpUrl: string, apiKey?: string, serverEntry?: Record<string, unknown>): string;
|
|
22
23
|
export interface McpJsonServerEntry {
|
|
23
24
|
present: boolean;
|
|
24
25
|
hasApiKeyHeader: boolean;
|
|
26
|
+
keyless?: true;
|
|
25
27
|
/** Only ever false: harnesses with a disable toggle write it, the rest omit it. */
|
|
26
28
|
enabled?: false;
|
|
27
29
|
/** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
|
|
@@ -34,6 +36,6 @@ export interface McpJsonServerEntry {
|
|
|
34
36
|
/** Reports the header's shape, never its value. */
|
|
35
37
|
export declare function readTinyfishEntry(target: McpJsonTarget): McpJsonServerEntry;
|
|
36
38
|
/** Merges only the target's key; skips unreadable/corrupt files rather than clobber. */
|
|
37
|
-
export declare function writeMcpConfig(target: McpJsonTarget, mcpUrl: string, apiKey?: string): McpJsonWriteResult;
|
|
39
|
+
export declare function writeMcpConfig(target: McpJsonTarget, mcpUrl: string, apiKey?: string, serverEntry?: Record<string, unknown>): McpJsonWriteResult;
|
|
38
40
|
/** Removes only the target's key. */
|
|
39
41
|
export declare function removeServer(target: McpJsonTarget): McpJsonWriteResult;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import { matchesCliKey } from './auth.js';
|
|
3
|
+
import { TINYFISH_ACCESS_MODE_HEADER, TINYFISH_KEYLESS_ACCESS_MODE } from './constants.js';
|
|
3
4
|
function isPlainRecord(value) {
|
|
4
5
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
5
6
|
}
|
|
@@ -7,6 +8,9 @@ function isPlainRecord(value) {
|
|
|
7
8
|
export function buildTinyfishServerEntry(mcpUrl, apiKey) {
|
|
8
9
|
return apiKey ? { url: mcpUrl, headers: { 'X-API-Key': apiKey } } : { url: mcpUrl };
|
|
9
10
|
}
|
|
11
|
+
export function buildTinyfishKeylessServerEntry(mcpUrl) {
|
|
12
|
+
return { url: mcpUrl, headers: { [TINYFISH_ACCESS_MODE_HEADER]: TINYFISH_KEYLESS_ACCESS_MODE } };
|
|
13
|
+
}
|
|
10
14
|
function parseMcpJson(raw) {
|
|
11
15
|
try {
|
|
12
16
|
const parsed = JSON.parse(raw);
|
|
@@ -38,18 +42,21 @@ function readExisting(target) {
|
|
|
38
42
|
return { raw, parsed: parsed.value };
|
|
39
43
|
}
|
|
40
44
|
/** Dry-run description of the pending write; touches nothing. */
|
|
41
|
-
export function planWrite(target, mcpUrl, apiKey) {
|
|
45
|
+
export function planWrite(target, mcpUrl, apiKey, serverEntry) {
|
|
42
46
|
const existing = readExisting(target);
|
|
43
47
|
const filePath = target.file();
|
|
44
48
|
if ('error' in existing) {
|
|
45
49
|
return `${filePath}: existing file is corrupt (${existing.error}) — would skip and leave it untouched`;
|
|
46
50
|
}
|
|
47
|
-
const authNote =
|
|
48
|
-
?
|
|
49
|
-
:
|
|
51
|
+
const authNote = serverEntry
|
|
52
|
+
? ` with an ${TINYFISH_ACCESS_MODE_HEADER}: ${TINYFISH_KEYLESS_ACCESS_MODE} header`
|
|
53
|
+
: apiKey
|
|
54
|
+
? ' with an API-key header — the key is stored in plaintext in that file (value not shown here)'
|
|
55
|
+
: '';
|
|
56
|
+
const nextEntry = serverEntry ?? buildTinyfishServerEntry(mcpUrl, apiKey);
|
|
50
57
|
const servers = existing.parsed.mcpServers;
|
|
51
58
|
const current = isPlainRecord(servers) ? servers[target.serverKey] : undefined;
|
|
52
|
-
if (JSON.stringify(current) === JSON.stringify(
|
|
59
|
+
if (JSON.stringify(current) === JSON.stringify(nextEntry)) {
|
|
53
60
|
return `${filePath}: already has the ${target.serverKey} MCP entry — no change`;
|
|
54
61
|
}
|
|
55
62
|
return existing.raw === undefined
|
|
@@ -61,17 +68,21 @@ const ENV_TEMPLATE_VALUE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}$/;
|
|
|
61
68
|
/** Reports the header's shape, never its value. */
|
|
62
69
|
export function readTinyfishEntry(target) {
|
|
63
70
|
const existing = readExisting(target);
|
|
64
|
-
if ('error' in existing)
|
|
71
|
+
if ('error' in existing) {
|
|
65
72
|
return { present: false, hasApiKeyHeader: false, error: existing.error };
|
|
73
|
+
}
|
|
66
74
|
const servers = existing.parsed.mcpServers;
|
|
67
75
|
const entry = isPlainRecord(servers) ? servers[target.serverKey] : undefined;
|
|
68
76
|
if (!isPlainRecord(entry))
|
|
69
77
|
return { present: false, hasApiKeyHeader: false };
|
|
70
78
|
const key = readKeyHeader(entry.headers, target.keyHeader);
|
|
71
79
|
const templateVar = key ? ENV_TEMPLATE_VALUE.exec(key.value)?.[1] : undefined;
|
|
80
|
+
const accessMode = readKeyHeader(entry.headers, { name: TINYFISH_ACCESS_MODE_HEADER })?.value;
|
|
81
|
+
const keyless = accessMode?.trim().toLowerCase() === TINYFISH_KEYLESS_ACCESS_MODE;
|
|
72
82
|
return {
|
|
73
83
|
present: true,
|
|
74
84
|
hasApiKeyHeader: key !== undefined,
|
|
85
|
+
...(keyless ? { keyless: true } : {}),
|
|
75
86
|
...(entry.enabled === false ? { enabled: false } : {}),
|
|
76
87
|
...(matchesCliKey(key?.value) ? { keyMatchesCliKey: true } : {}),
|
|
77
88
|
...(templateVar ? { keyTemplateVar: templateVar } : {}),
|
|
@@ -114,19 +125,18 @@ function commitServers(target, existing, servers) {
|
|
|
114
125
|
return { status: 'written', backupPath };
|
|
115
126
|
}
|
|
116
127
|
/** Merges only the target's key; skips unreadable/corrupt files rather than clobber. */
|
|
117
|
-
export function writeMcpConfig(target, mcpUrl, apiKey) {
|
|
128
|
+
export function writeMcpConfig(target, mcpUrl, apiKey, serverEntry = buildTinyfishServerEntry(mcpUrl, apiKey)) {
|
|
118
129
|
const existing = readExisting(target);
|
|
119
130
|
if ('error' in existing)
|
|
120
131
|
return { status: 'corrupt_skip', error: existing.error };
|
|
121
132
|
const servers = isPlainRecord(existing.parsed.mcpServers)
|
|
122
133
|
? { ...existing.parsed.mcpServers }
|
|
123
134
|
: {};
|
|
124
|
-
|
|
125
|
-
if (JSON.stringify(servers[target.serverKey]) === JSON.stringify(nextEntry)) {
|
|
135
|
+
if (JSON.stringify(servers[target.serverKey]) === JSON.stringify(serverEntry)) {
|
|
126
136
|
return { status: 'unchanged' };
|
|
127
137
|
}
|
|
128
138
|
const repaired = target.serverKey in servers;
|
|
129
|
-
servers[target.serverKey] =
|
|
139
|
+
servers[target.serverKey] = serverEntry;
|
|
130
140
|
return { ...commitServers(target, existing, servers), repaired };
|
|
131
141
|
}
|
|
132
142
|
/** Removes only the target's key. */
|
package/dist/lib/omp-config.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as path from 'path';
|
|
|
2
2
|
import spawn from 'cross-spawn';
|
|
3
3
|
import { ConnectStepError } from './connect-runtime.js';
|
|
4
4
|
import { HARNESS_PROBE_TIMEOUT_MS } from './constants.js';
|
|
5
|
-
import { planWrite, readTinyfishEntry, removeServer, writeMcpConfig, } from './mcp-json-config.js';
|
|
5
|
+
import { buildTinyfishKeylessServerEntry, planWrite, readTinyfishEntry, removeServer, writeMcpConfig, } from './mcp-json-config.js';
|
|
6
6
|
const UNRESOLVED_REASON = '`omp config path` did not report a config directory';
|
|
7
7
|
// Cached: every later read must agree with the write.
|
|
8
8
|
let cachedAgentDir;
|
|
@@ -53,14 +53,16 @@ export function readOmpTinyfishEntry() {
|
|
|
53
53
|
}
|
|
54
54
|
/** Merges only the `tinyfish` key; throws when the dir is unresolved. */
|
|
55
55
|
export function writeOmpMcpConfig(mcpUrl, apiKey) {
|
|
56
|
-
|
|
56
|
+
const entry = apiKey ? undefined : buildTinyfishKeylessServerEntry(mcpUrl);
|
|
57
|
+
return writeMcpConfig(requireOmpTarget(), mcpUrl, apiKey, entry);
|
|
57
58
|
}
|
|
58
59
|
/** Dry-run description of the pending write; touches nothing. */
|
|
59
60
|
export function planOmpWrite(mcpUrl, apiKey) {
|
|
60
61
|
const target = ompMcpTarget();
|
|
61
62
|
if (!target)
|
|
62
63
|
return `${UNRESOLVED_REASON} — connect omp would fail the same way`;
|
|
63
|
-
|
|
64
|
+
const entry = apiKey ? undefined : buildTinyfishKeylessServerEntry(mcpUrl);
|
|
65
|
+
return planWrite(target, mcpUrl, apiKey, entry);
|
|
64
66
|
}
|
|
65
67
|
/** Removes only the `tinyfish` key. */
|
|
66
68
|
export function removeOmpMcpServer() {
|
package/dist/lib/output.js
CHANGED
|
@@ -41,6 +41,7 @@ export function errLine(line) {
|
|
|
41
41
|
}
|
|
42
42
|
const WARN_ON = '\x1b[33m';
|
|
43
43
|
const WARN_OFF = '\x1b[39m';
|
|
44
|
+
const VAULT_RECONNECT_REQUIRED = 'VAULT_RECONNECT_REQUIRED';
|
|
44
45
|
/** Advisory stderr line, yellow on a terminal and plain everywhere else so piped output stays clean. */
|
|
45
46
|
export function warnLine(line) {
|
|
46
47
|
const text = sanitizeLine(line);
|
|
@@ -57,9 +58,14 @@ export function handleApiError(e) {
|
|
|
57
58
|
const payload = { error: e.message, status: e.status };
|
|
58
59
|
if (e.code)
|
|
59
60
|
payload.code = e.code;
|
|
60
|
-
if (e.
|
|
61
|
+
if (e.code === VAULT_RECONNECT_REQUIRED) {
|
|
62
|
+
payload.hint =
|
|
63
|
+
'Reconnect the provider with `tinyfish vault connection add --provider 1password` or `tinyfish vault connection add --provider bitwarden --client-id <client-id>`. Vault secrets are read from TINYFISH_VAULT_TOKEN, TINYFISH_VAULT_CLIENT_SECRET, and TINYFISH_VAULT_MASTER_PASSWORD.';
|
|
64
|
+
}
|
|
65
|
+
else if (e.status === 401) {
|
|
61
66
|
payload.hint =
|
|
62
67
|
'Clear the TINYFISH_API_KEY environment variable, run `tinyfish auth login`, then try again.';
|
|
68
|
+
}
|
|
63
69
|
err(payload);
|
|
64
70
|
}
|
|
65
71
|
else if (e instanceof Error) {
|
|
@@ -326,9 +326,14 @@ function probeOmp() {
|
|
|
326
326
|
: entry.keyMatchesCliKey
|
|
327
327
|
? { keyMatchesCliKey: true }
|
|
328
328
|
: {};
|
|
329
|
+
let authMode = AuthMode.Unknown;
|
|
330
|
+
if (entry.hasApiKeyHeader)
|
|
331
|
+
authMode = AuthMode.ApiKey;
|
|
332
|
+
else if (entry.keyless)
|
|
333
|
+
authMode = AuthMode.Keyless;
|
|
329
334
|
return {
|
|
330
335
|
registered: Registered.Yes,
|
|
331
|
-
authMode
|
|
336
|
+
authMode,
|
|
332
337
|
...(entry.url ? { registeredUrl: entry.url } : {}),
|
|
333
338
|
...keyVerdict,
|
|
334
339
|
};
|
|
@@ -391,16 +396,19 @@ function probeHermes() {
|
|
|
391
396
|
if (entry.state === 'absent')
|
|
392
397
|
return NOT_REGISTERED;
|
|
393
398
|
// `mcp add` saves a disabled entry on a failed connect, which connect refuses too.
|
|
394
|
-
const
|
|
399
|
+
const details = entry.state === 'enabled' ? {} : { connected: false };
|
|
400
|
+
const registeredUrl = entry.url ? { registeredUrl: entry.url } : {};
|
|
395
401
|
// The .env key is Hermes-wide; the header template ties it here.
|
|
396
402
|
const storedKey = entry.usesKeyHeader ? readHermesKey(home) : undefined;
|
|
397
403
|
if (!storedKey) {
|
|
398
|
-
|
|
404
|
+
const authMode = entry.keyless ? AuthMode.Keyless : AuthMode.OAuth;
|
|
405
|
+
return { registered: Registered.Yes, authMode, ...details, ...registeredUrl };
|
|
399
406
|
}
|
|
400
407
|
return {
|
|
401
408
|
registered: Registered.Yes,
|
|
402
409
|
authMode: AuthMode.ApiKey,
|
|
403
|
-
...
|
|
410
|
+
...details,
|
|
411
|
+
...registeredUrl,
|
|
404
412
|
...(matchesCliKey(storedKey) ? { keyMatchesCliKey: true } : {}),
|
|
405
413
|
};
|
|
406
414
|
}
|
|
@@ -163,11 +163,16 @@ declare const doctorCompletedPayloadSchema: z.ZodObject<{
|
|
|
163
163
|
detected: z.ZodBoolean;
|
|
164
164
|
registered: z.ZodEnum<typeof Registered>;
|
|
165
165
|
auth_mode: z.ZodEnum<typeof AuthMode>;
|
|
166
|
+
recorded_auth_mode: z.ZodOptional<z.ZodEnum<{
|
|
167
|
+
keyless: "keyless";
|
|
168
|
+
"api-key": "api-key";
|
|
169
|
+
oauth: "oauth";
|
|
170
|
+
deferred: "deferred";
|
|
171
|
+
}>>;
|
|
166
172
|
proves_harness_reach: z.ZodBoolean;
|
|
167
173
|
}, z.core.$strip>>;
|
|
168
174
|
duration_ms: z.ZodInt;
|
|
169
175
|
hermes_plugin_version: z.ZodOptional<z.ZodString>;
|
|
170
|
-
hermes_plugin_expected_version: z.ZodOptional<z.ZodString>;
|
|
171
176
|
}, z.core.$strip>;
|
|
172
177
|
declare const doctorCouldNotRunPayloadSchema: z.ZodObject<{
|
|
173
178
|
outcome: z.ZodLiteral<"could_not_run">;
|
|
@@ -266,11 +271,16 @@ declare const doctorPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
266
271
|
detected: z.ZodBoolean;
|
|
267
272
|
registered: z.ZodEnum<typeof Registered>;
|
|
268
273
|
auth_mode: z.ZodEnum<typeof AuthMode>;
|
|
274
|
+
recorded_auth_mode: z.ZodOptional<z.ZodEnum<{
|
|
275
|
+
keyless: "keyless";
|
|
276
|
+
"api-key": "api-key";
|
|
277
|
+
oauth: "oauth";
|
|
278
|
+
deferred: "deferred";
|
|
279
|
+
}>>;
|
|
269
280
|
proves_harness_reach: z.ZodBoolean;
|
|
270
281
|
}, z.core.$strip>>;
|
|
271
282
|
duration_ms: z.ZodInt;
|
|
272
283
|
hermes_plugin_version: z.ZodOptional<z.ZodString>;
|
|
273
|
-
hermes_plugin_expected_version: z.ZodOptional<z.ZodString>;
|
|
274
284
|
}, z.core.$strip>, z.ZodObject<{
|
|
275
285
|
outcome: z.ZodLiteral<"could_not_run">;
|
|
276
286
|
error_class: z.ZodEnum<{
|
|
@@ -265,13 +265,13 @@ const doctorCompletedPayloadSchema = z.object({
|
|
|
265
265
|
detected: z.boolean(),
|
|
266
266
|
registered: z.enum(Registered),
|
|
267
267
|
auth_mode: z.enum(AuthMode),
|
|
268
|
+
recorded_auth_mode: z.enum(['api-key', 'keyless', 'oauth', 'deferred']).optional(),
|
|
268
269
|
proves_harness_reach: z.boolean(),
|
|
269
270
|
}))
|
|
270
271
|
.max(16),
|
|
271
272
|
duration_ms: z.int().nonnegative().max(3_600_000),
|
|
272
273
|
/** Bounded here, not on the report: a reject drops only telemetry. */
|
|
273
274
|
hermes_plugin_version: versionSchema.optional(),
|
|
274
|
-
hermes_plugin_expected_version: versionSchema.optional(),
|
|
275
275
|
});
|
|
276
276
|
// Detection threw, so there is no report to aggregate: enumerated outcome and timing only.
|
|
277
277
|
const doctorCouldNotRunPayloadSchema = z.object({
|
package/dist/lib/verify.d.ts
CHANGED
|
@@ -9,6 +9,6 @@ export interface VerifyResult {
|
|
|
9
9
|
status?: number;
|
|
10
10
|
}
|
|
11
11
|
/** Reachability check. Verify failure is a warning, never install failure. */
|
|
12
|
-
export declare function verifyMcpHealth(mcpUrl: string): Promise<VerifyResult>;
|
|
12
|
+
export declare function verifyMcpHealth(mcpUrl: string, keyless?: boolean): Promise<VerifyResult>;
|
|
13
13
|
/** Authenticated check — where the CLI holds the key (Cursor, OpenClaw, Hermes). */
|
|
14
14
|
export declare function verifyMcpAuth(apiKey: string, apiBaseUrl?: string): Promise<VerifyResult>;
|
package/dist/lib/verify.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { listRuns } from './client.js';
|
|
2
|
+
import { TINYFISH_ACCESS_MODE_HEADER, TINYFISH_KEYLESS_ACCESS_MODE } from './constants.js';
|
|
2
3
|
import { ApiError } from './output.js';
|
|
4
|
+
import { z } from 'zod';
|
|
3
5
|
const VERIFY_TIMEOUT_MS = 5_000;
|
|
6
|
+
const keylessInitializeSchema = z.object({
|
|
7
|
+
jsonrpc: z.literal('2.0'),
|
|
8
|
+
id: z.literal(1),
|
|
9
|
+
result: z.object({
|
|
10
|
+
capabilities: z.object({}),
|
|
11
|
+
serverInfo: z.object({ name: z.literal('tinyfish') }),
|
|
12
|
+
}),
|
|
13
|
+
});
|
|
4
14
|
// `fetch` collapses every network fault to "fetch failed" and hides the cause on `e.cause`.
|
|
5
15
|
function networkCode(e) {
|
|
6
16
|
const cause = e?.cause;
|
|
@@ -32,24 +42,34 @@ function authCode(e) {
|
|
|
32
42
|
return 'rate limited';
|
|
33
43
|
return e.status >= 500 ? 'TinyFish returned a server error' : `the API returned HTTP ${e.status}`;
|
|
34
44
|
}
|
|
45
|
+
function failedHealth(reason) {
|
|
46
|
+
return { depth: 'health', ok: false, reason, code: reason };
|
|
47
|
+
}
|
|
35
48
|
/** Reachability check. Verify failure is a warning, never install failure. */
|
|
36
|
-
export async function verifyMcpHealth(mcpUrl) {
|
|
49
|
+
export async function verifyMcpHealth(mcpUrl, keyless = false) {
|
|
37
50
|
try {
|
|
38
51
|
const response = await fetch(mcpUrl, {
|
|
39
|
-
method: 'GET',
|
|
52
|
+
method: keyless ? 'POST' : 'GET',
|
|
53
|
+
...(keyless
|
|
54
|
+
? {
|
|
55
|
+
headers: {
|
|
56
|
+
[TINYFISH_ACCESS_MODE_HEADER]: TINYFISH_KEYLESS_ACCESS_MODE,
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
Accept: 'application/json, text/event-stream',
|
|
59
|
+
},
|
|
60
|
+
body: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}',
|
|
61
|
+
}
|
|
62
|
+
: {}),
|
|
40
63
|
// Redirect (captive portal, typo'd --url) is not healthy.
|
|
41
64
|
redirect: 'error',
|
|
42
65
|
signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
|
|
43
66
|
});
|
|
44
67
|
// MCP rejects bare GET with 4xx — still proves endpoint routed.
|
|
45
|
-
if (response.status >= 500) {
|
|
46
|
-
return {
|
|
47
|
-
depth: 'health',
|
|
48
|
-
ok: false,
|
|
49
|
-
reason: `endpoint returned HTTP ${response.status}`,
|
|
50
|
-
code: `the endpoint returned HTTP ${response.status}`,
|
|
51
|
-
};
|
|
68
|
+
if (response.status >= 500 || (keyless && response.status !== 200)) {
|
|
69
|
+
return failedHealth(`endpoint returned HTTP ${response.status}`);
|
|
52
70
|
}
|
|
71
|
+
if (keyless && !keylessInitializeSchema.safeParse(await response.json()).success)
|
|
72
|
+
return failedHealth('endpoint returned an invalid MCP initialize response');
|
|
53
73
|
return { depth: 'health', ok: true };
|
|
54
74
|
}
|
|
55
75
|
catch (e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiny-fish/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.1-next.352",
|
|
4
4
|
"description": "TinyFish CLI — run web automations from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"format:check": "oxfmt --check ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@tiny-fish/sdk": "^0.
|
|
38
|
+
"@tiny-fish/sdk": "^0.7.0",
|
|
39
39
|
"commander": "^12.0.0",
|
|
40
40
|
"cross-spawn": "^7.0.6",
|
|
41
41
|
"tldts": "^6.1.86",
|