@evantahler/mcpx 0.16.3 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/mcpx.md +4 -3
- package/.cursor/rules/mcpx.mdc +4 -3
- package/README.md +5 -8
- package/package.json +1 -1
- package/src/cli.ts +15 -1
- package/src/commands/check-update.ts +70 -0
- package/src/commands/upgrade.ts +226 -0
- package/src/config/loader.ts +1 -4
- package/src/constants.ts +9 -0
- package/src/output/format-output.ts +2 -2
- package/src/output/formatter.ts +150 -69
- package/src/update/background.ts +76 -0
- package/src/update/cache.ts +37 -0
- package/src/update/checker.ts +122 -0
package/.claude/skills/mcpx.md
CHANGED
|
@@ -30,7 +30,7 @@ mcpx exec <server> <tool> '<json args>' # explicit server (required if too
|
|
|
30
30
|
mcpx exec <server> <tool> -f params.json
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Output is JSON by default. Use `--json` to force JSON output in any context — prefer this when you need to parse results programmatically. Use `--format
|
|
33
|
+
Output is JSON by default. Use `--json` to force JSON output in any context — prefer this when you need to parse results programmatically. Use `--format markdown` for rich terminal rendering with colors, headings, and bullet lists.
|
|
34
34
|
|
|
35
35
|
## Rules
|
|
36
36
|
|
|
@@ -39,7 +39,6 @@ Output is JSON by default. Use `--json` to force JSON output in any context —
|
|
|
39
39
|
- Use `mcpx search -k` for exact name matching
|
|
40
40
|
- Pipe results through `jq` when you need to extract specific fields
|
|
41
41
|
- Use `--json` when parsing output programmatically (automatic when piped, but explicit is safer)
|
|
42
|
-
- Use `--format text` to extract plain text from tool results (strips MCP protocol wrapper)
|
|
43
42
|
- Use `--format markdown` for rich terminal-rendered output with colors and formatting
|
|
44
43
|
- Use `-v` for verbose debugging (HTTP details + JSON-RPC protocol messages) if an exec fails unexpectedly
|
|
45
44
|
- Use `-l debug` to see all server log messages, or `-l error` for errors only
|
|
@@ -153,13 +152,15 @@ mcpx deauth <server> # remove stored auth
|
|
|
153
152
|
| `mcpx task get <server> <taskId>` | Get task status |
|
|
154
153
|
| `mcpx task result <server> <taskId>` | Retrieve completed task result |
|
|
155
154
|
| `mcpx task cancel <server> <taskId>` | Cancel a running task |
|
|
155
|
+
| `mcpx check-update` | Check for a newer version of mcpx |
|
|
156
|
+
| `mcpx upgrade` | Upgrade mcpx to the latest version|
|
|
156
157
|
|
|
157
158
|
## Global flags
|
|
158
159
|
|
|
159
160
|
| Flag | Purpose |
|
|
160
161
|
| --------------------------- | -------------------------------------------------------- |
|
|
161
162
|
| `-j, --json` | Force JSON output (default when piped) |
|
|
162
|
-
| `-F, --format <format>` | Output format: `json
|
|
163
|
+
| `-F, --format <format>` | Output format: `json` or `markdown` |
|
|
163
164
|
| `-v, --verbose` | Show HTTP details and JSON-RPC protocol messages |
|
|
164
165
|
| `-d, --with-descriptions` | Include tool descriptions in list output |
|
|
165
166
|
| `-c, --config <path>` | Specify config file location |
|
package/.cursor/rules/mcpx.mdc
CHANGED
|
@@ -30,7 +30,7 @@ mcpx exec <server> <tool> '<json args>' # explicit server (required if too
|
|
|
30
30
|
mcpx exec <server> <tool> -f params.json
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Output is JSON by default. Use `--json` to force JSON output in any context — prefer this when you need to parse results programmatically. Use `--format
|
|
33
|
+
Output is JSON by default. Use `--json` to force JSON output in any context — prefer this when you need to parse results programmatically. Use `--format markdown` for rich terminal rendering with colors, headings, and bullet lists.
|
|
34
34
|
|
|
35
35
|
## Rules
|
|
36
36
|
|
|
@@ -39,7 +39,6 @@ Output is JSON by default. Use `--json` to force JSON output in any context —
|
|
|
39
39
|
- Use `mcpx search -k` for exact name matching
|
|
40
40
|
- Pipe results through `jq` when you need to extract specific fields
|
|
41
41
|
- Use `--json` when parsing output programmatically (automatic when piped, but explicit is safer)
|
|
42
|
-
- Use `--format text` to extract plain text from tool results (strips MCP protocol wrapper)
|
|
43
42
|
- Use `--format markdown` for rich terminal-rendered output with colors and formatting
|
|
44
43
|
- Use `-v` for verbose debugging (HTTP details + JSON-RPC protocol messages) if an exec fails unexpectedly
|
|
45
44
|
- Use `-l debug` to see all server log messages, or `-l error` for errors only
|
|
@@ -153,13 +152,15 @@ mcpx deauth <server> # remove stored auth
|
|
|
153
152
|
| `mcpx task get <server> <taskId>` | Get task status |
|
|
154
153
|
| `mcpx task result <server> <taskId>` | Retrieve completed task result |
|
|
155
154
|
| `mcpx task cancel <server> <taskId>` | Cancel a running task |
|
|
155
|
+
| `mcpx check-update` | Check for a newer version of mcpx |
|
|
156
|
+
| `mcpx upgrade` | Upgrade mcpx to the latest version|
|
|
156
157
|
|
|
157
158
|
## Global flags
|
|
158
159
|
|
|
159
160
|
| Flag | Purpose |
|
|
160
161
|
| --------------------------- | -------------------------------------------------------- |
|
|
161
162
|
| `-j, --json` | Force JSON output (default when piped) |
|
|
162
|
-
| `-F, --format <format>` | Output format: `json
|
|
163
|
+
| `-F, --format <format>` | Output format: `json` or `markdown` |
|
|
163
164
|
| `-v, --verbose` | Show HTTP details and JSON-RPC protocol messages |
|
|
164
165
|
| `-d, --with-descriptions` | Include tool descriptions in list output |
|
|
165
166
|
| `-c, --config <path>` | Specify config file location |
|
package/README.md
CHANGED
|
@@ -104,6 +104,8 @@ mcpx search -n 5 "manage pull requests"
|
|
|
104
104
|
| `mcpx task get <server> <taskId>` | Get task status |
|
|
105
105
|
| `mcpx task result <server> <taskId>` | Retrieve completed task result |
|
|
106
106
|
| `mcpx task cancel <server> <taskId>` | Cancel a running task |
|
|
107
|
+
| `mcpx check-update` | Check for a newer version of mcpx |
|
|
108
|
+
| `mcpx upgrade` | Upgrade mcpx to the latest version |
|
|
107
109
|
|
|
108
110
|
## Options
|
|
109
111
|
|
|
@@ -116,7 +118,7 @@ mcpx search -n 5 "manage pull requests"
|
|
|
116
118
|
| `-v, --verbose` | Show HTTP details and JSON-RPC protocol messages |
|
|
117
119
|
| `-S, --show-secrets` | Show full auth tokens in verbose output (unmasked) |
|
|
118
120
|
| `-j, --json` | Force JSON output (default when piped) |
|
|
119
|
-
| `-F, --format <format>` | Output format: `json
|
|
121
|
+
| `-F, --format <format>` | Output format: `json` or `markdown` |
|
|
120
122
|
| `-N, --no-interactive` | Decline server elicitation requests (for scripted usage) |
|
|
121
123
|
| `-l, --log-level <level>` | Minimum server log level to display (default: `warning`) |
|
|
122
124
|
|
|
@@ -519,18 +521,13 @@ Tool results (`exec`, `task result`) support three output formats via the global
|
|
|
519
521
|
# Default JSON output — full MCP response with content array
|
|
520
522
|
mcpx exec github search_repositories '{"query":"mcp"}'
|
|
521
523
|
|
|
522
|
-
# Text — just the content, no protocol wrapper
|
|
523
|
-
mcpx exec github search_repositories '{"query":"mcp"}' --format text
|
|
524
|
-
|
|
525
524
|
# Markdown — rich terminal rendering with colors and formatting
|
|
526
525
|
mcpx exec github search_repositories '{"query":"mcp"}' -F markdown
|
|
527
526
|
```
|
|
528
527
|
|
|
529
|
-
The `
|
|
530
|
-
|
|
531
|
-
The `markdown` format extracts text the same way, then renders it through Bun's built-in markdown parser with ANSI styling — headings, bold/italic, code blocks with borders, colored links, and bullet lists.
|
|
528
|
+
The `markdown` format extracts text from MCP content blocks and renders it through Bun's built-in markdown parser with ANSI styling — headings, bold/italic, code blocks with borders, colored links, and bullet lists. JSON content is converted to a structured document with headings and bullet lists.
|
|
532
529
|
|
|
533
|
-
For other commands (`list`, `info`, `search`), `--format json` forces JSON output and `--format
|
|
530
|
+
For other commands (`list`, `info`, `search`), `--format json` forces JSON output and `--format markdown` uses the existing human-friendly formatting.
|
|
534
531
|
|
|
535
532
|
### Chaining tool results
|
|
536
533
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -15,6 +15,9 @@ import { registerResourceCommand } from "./commands/resource.ts";
|
|
|
15
15
|
import { registerPromptCommand } from "./commands/prompt.ts";
|
|
16
16
|
import { registerServersCommand } from "./commands/servers.ts";
|
|
17
17
|
import { registerTaskCommand } from "./commands/task.ts";
|
|
18
|
+
import { registerCheckUpdateCommand } from "./commands/check-update.ts";
|
|
19
|
+
import { registerUpgradeCommand } from "./commands/upgrade.ts";
|
|
20
|
+
import { maybeCheckForUpdate } from "./update/background.ts";
|
|
18
21
|
|
|
19
22
|
import pkg from "../package.json";
|
|
20
23
|
|
|
@@ -25,7 +28,7 @@ program
|
|
|
25
28
|
.option("-c, --config <path>", "config directory path")
|
|
26
29
|
.option("-d, --with-descriptions", "include tool descriptions in output")
|
|
27
30
|
.option("-j, --json", "force JSON output")
|
|
28
|
-
.option("-F, --format <format>", "output format (json,
|
|
31
|
+
.option("-F, --format <format>", "output format (json, markdown)")
|
|
29
32
|
.option("-v, --verbose", "show HTTP details and JSON-RPC protocol messages")
|
|
30
33
|
.option("-S, --show-secrets", "show full auth tokens in verbose output")
|
|
31
34
|
.option("-N, --no-interactive", "decline server elicitation requests")
|
|
@@ -50,6 +53,8 @@ registerResourceCommand(program);
|
|
|
50
53
|
registerPromptCommand(program);
|
|
51
54
|
registerServersCommand(program);
|
|
52
55
|
registerTaskCommand(program);
|
|
56
|
+
registerCheckUpdateCommand(program);
|
|
57
|
+
registerUpgradeCommand(program);
|
|
53
58
|
|
|
54
59
|
// Detect unknown subcommands before commander misreports them as "too many arguments"
|
|
55
60
|
const knownCommands = new Set(program.commands.map((c) => c.name()));
|
|
@@ -77,4 +82,13 @@ if (firstCommand && !knownCommands.has(firstCommand)) {
|
|
|
77
82
|
process.exit(1);
|
|
78
83
|
}
|
|
79
84
|
|
|
85
|
+
// Fire-and-forget background update check
|
|
86
|
+
const updateNotice = maybeCheckForUpdate();
|
|
87
|
+
|
|
80
88
|
program.parse();
|
|
89
|
+
|
|
90
|
+
// Print update notice after command output completes
|
|
91
|
+
process.on("beforeExit", async () => {
|
|
92
|
+
const notice = await updateNotice;
|
|
93
|
+
if (notice) process.stderr.write(notice);
|
|
94
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { green, yellow, cyan, dim } from "ansis";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { createSpinner } from "nanospinner";
|
|
4
|
+
import pkg from "../../package.json";
|
|
5
|
+
import { checkForUpdate } from "../update/checker.ts";
|
|
6
|
+
import { saveUpdateCache } from "../update/cache.ts";
|
|
7
|
+
import type { UpdateCache } from "../update/checker.ts";
|
|
8
|
+
|
|
9
|
+
export function registerCheckUpdateCommand(program: Command) {
|
|
10
|
+
program
|
|
11
|
+
.command("check-update")
|
|
12
|
+
.description("Check for a newer version of mcpx")
|
|
13
|
+
.action(async () => {
|
|
14
|
+
const opts = program.opts();
|
|
15
|
+
const json = !!(opts.json as boolean | undefined);
|
|
16
|
+
const isTTY = process.stderr.isTTY ?? false;
|
|
17
|
+
|
|
18
|
+
const spinner =
|
|
19
|
+
!json && isTTY
|
|
20
|
+
? createSpinner("Checking for updates...", { stream: process.stderr }).start()
|
|
21
|
+
: null;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const info = await checkForUpdate(pkg.version);
|
|
25
|
+
|
|
26
|
+
// Save to cache
|
|
27
|
+
const cache: UpdateCache = {
|
|
28
|
+
lastCheckAt: new Date().toISOString(),
|
|
29
|
+
latestVersion: info.latestVersion,
|
|
30
|
+
hasUpdate: info.hasUpdate,
|
|
31
|
+
changelog: info.changelog,
|
|
32
|
+
};
|
|
33
|
+
await saveUpdateCache(cache);
|
|
34
|
+
|
|
35
|
+
spinner?.stop();
|
|
36
|
+
|
|
37
|
+
if (json) {
|
|
38
|
+
console.log(JSON.stringify(info, null, 2));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!info.hasUpdate) {
|
|
43
|
+
if (info.aheadOfLatest) {
|
|
44
|
+
console.log(
|
|
45
|
+
yellow(
|
|
46
|
+
`mcpx v${info.currentVersion} is ahead of latest published release (v${info.latestVersion})`,
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
} else {
|
|
50
|
+
console.log(green(`mcpx is up to date (v${info.currentVersion})`));
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(yellow(`Update available: ${info.currentVersion} → ${info.latestVersion}`));
|
|
56
|
+
|
|
57
|
+
if (info.changelog) {
|
|
58
|
+
console.log("");
|
|
59
|
+
console.log(dim(info.changelog));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log("");
|
|
63
|
+
console.log(cyan(`Run \`mcpx upgrade\` to update`));
|
|
64
|
+
} catch (err) {
|
|
65
|
+
spinner?.error({ text: "Failed to check for updates" });
|
|
66
|
+
console.error(String(err));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import { green, yellow, red, cyan, dim } from "ansis";
|
|
3
|
+
import type { Command } from "commander";
|
|
4
|
+
import { createSpinner } from "nanospinner";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import pkg from "../../package.json";
|
|
8
|
+
import {
|
|
9
|
+
checkForUpdate,
|
|
10
|
+
detectInstallMethod,
|
|
11
|
+
needsCheck,
|
|
12
|
+
type InstallMethod,
|
|
13
|
+
} from "../update/checker.ts";
|
|
14
|
+
import { loadUpdateCache, saveUpdateCache, clearUpdateCache } from "../update/cache.ts";
|
|
15
|
+
import type { UpdateCache } from "../update/checker.ts";
|
|
16
|
+
import pkgMeta from "../../package.json";
|
|
17
|
+
|
|
18
|
+
const GITHUB_REPO = pkgMeta.repository.url
|
|
19
|
+
.replace(/^https:\/\/github\.com\//, "")
|
|
20
|
+
.replace(/\.git$/, "");
|
|
21
|
+
|
|
22
|
+
function platformArtifactName(): string {
|
|
23
|
+
let os: string;
|
|
24
|
+
let ext = "";
|
|
25
|
+
switch (process.platform) {
|
|
26
|
+
case "darwin":
|
|
27
|
+
os = "darwin";
|
|
28
|
+
break;
|
|
29
|
+
case "win32":
|
|
30
|
+
os = "windows";
|
|
31
|
+
ext = ".exe";
|
|
32
|
+
break;
|
|
33
|
+
default:
|
|
34
|
+
os = "linux";
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
38
|
+
return `mcpx-${os}-${arch}${ext}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function upgradeWithPackageManager(command: string, args: string[]): Promise<boolean> {
|
|
42
|
+
const result = await $`${command} ${args}`.nothrow();
|
|
43
|
+
return result.exitCode === 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function upgradeFromBinary(latestVersion: string): Promise<boolean> {
|
|
47
|
+
const artifact = platformArtifactName();
|
|
48
|
+
const tag = `v${latestVersion}`;
|
|
49
|
+
const url = `https://github.com/${GITHUB_REPO}/releases/download/${tag}/${artifact}`;
|
|
50
|
+
|
|
51
|
+
const tmpPath = join(tmpdir(), `mcpx-upgrade-${Date.now()}`);
|
|
52
|
+
const targetPath = process.execPath;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(url);
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
console.error(red(`Failed to download binary: HTTP ${res.status}`));
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const bytes = await res.arrayBuffer();
|
|
62
|
+
await Bun.write(tmpPath, bytes);
|
|
63
|
+
|
|
64
|
+
await $`chmod +x ${tmpPath}`.quiet();
|
|
65
|
+
|
|
66
|
+
// Try to move into place
|
|
67
|
+
const mv = await $`mv ${tmpPath} ${targetPath}`.quiet().nothrow();
|
|
68
|
+
|
|
69
|
+
if (mv.exitCode !== 0) {
|
|
70
|
+
// Try with sudo
|
|
71
|
+
console.log(dim("Requires elevated permissions..."));
|
|
72
|
+
const sudo = await $`sudo mv ${tmpPath} ${targetPath}`.nothrow();
|
|
73
|
+
if (sudo.exitCode !== 0) {
|
|
74
|
+
console.error(red("Failed to install binary. Try running with sudo."));
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return true;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error(red(`Failed to upgrade binary: ${err}`));
|
|
82
|
+
// Clean up temp file
|
|
83
|
+
await $`rm -f ${tmpPath}`.quiet().nothrow();
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function registerUpgradeCommand(program: Command) {
|
|
89
|
+
program
|
|
90
|
+
.command("upgrade")
|
|
91
|
+
.description("Upgrade mcpx to the latest version")
|
|
92
|
+
.action(async () => {
|
|
93
|
+
const opts = program.opts();
|
|
94
|
+
const json = !!(opts.json as boolean | undefined);
|
|
95
|
+
const isTTY = process.stderr.isTTY ?? false;
|
|
96
|
+
|
|
97
|
+
const spinner =
|
|
98
|
+
!json && isTTY
|
|
99
|
+
? createSpinner("Checking for updates...", { stream: process.stderr }).start()
|
|
100
|
+
: null;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
// Check for update (use cache if fresh)
|
|
104
|
+
const cache = await loadUpdateCache();
|
|
105
|
+
let latestVersion: string;
|
|
106
|
+
let hasUpdate: boolean;
|
|
107
|
+
|
|
108
|
+
if (!needsCheck(cache) && cache) {
|
|
109
|
+
latestVersion = cache.latestVersion;
|
|
110
|
+
hasUpdate = cache.hasUpdate;
|
|
111
|
+
} else {
|
|
112
|
+
const info = await checkForUpdate(pkg.version);
|
|
113
|
+
latestVersion = info.latestVersion;
|
|
114
|
+
hasUpdate = info.hasUpdate;
|
|
115
|
+
|
|
116
|
+
const newCache: UpdateCache = {
|
|
117
|
+
lastCheckAt: new Date().toISOString(),
|
|
118
|
+
latestVersion,
|
|
119
|
+
hasUpdate,
|
|
120
|
+
changelog: info.changelog,
|
|
121
|
+
};
|
|
122
|
+
await saveUpdateCache(newCache);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!hasUpdate) {
|
|
126
|
+
spinner?.stop();
|
|
127
|
+
if (json) {
|
|
128
|
+
console.log(
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
upgraded: false,
|
|
131
|
+
currentVersion: pkg.version,
|
|
132
|
+
message: "Already up to date",
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
} else {
|
|
136
|
+
console.log(green(`mcpx is already up to date (v${pkg.version})`));
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const method: InstallMethod = detectInstallMethod();
|
|
142
|
+
spinner?.update({
|
|
143
|
+
text: `Upgrading from v${pkg.version} to v${latestVersion} (${method})...`,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
let success = false;
|
|
147
|
+
|
|
148
|
+
switch (method) {
|
|
149
|
+
case "bun":
|
|
150
|
+
spinner?.stop();
|
|
151
|
+
success = await upgradeWithPackageManager("bun", [
|
|
152
|
+
"install",
|
|
153
|
+
"-g",
|
|
154
|
+
`@evantahler/mcpx@${latestVersion}`,
|
|
155
|
+
]);
|
|
156
|
+
break;
|
|
157
|
+
|
|
158
|
+
case "npm":
|
|
159
|
+
spinner?.stop();
|
|
160
|
+
success = await upgradeWithPackageManager("npm", [
|
|
161
|
+
"install",
|
|
162
|
+
"-g",
|
|
163
|
+
`@evantahler/mcpx@${latestVersion}`,
|
|
164
|
+
]);
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case "binary":
|
|
168
|
+
spinner?.stop();
|
|
169
|
+
success = await upgradeFromBinary(latestVersion);
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
case "local-dev":
|
|
173
|
+
spinner?.stop();
|
|
174
|
+
if (json) {
|
|
175
|
+
console.log(
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
upgraded: false,
|
|
178
|
+
currentVersion: pkg.version,
|
|
179
|
+
latestVersion,
|
|
180
|
+
installMethod: "local-dev",
|
|
181
|
+
message: "Running from source. Use `git pull && bun install` to update.",
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
console.log(yellow("Running from source. Use `git pull && bun install` to update."));
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (success) {
|
|
191
|
+
await clearUpdateCache();
|
|
192
|
+
if (json) {
|
|
193
|
+
console.log(
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
upgraded: true,
|
|
196
|
+
previousVersion: pkg.version,
|
|
197
|
+
newVersion: latestVersion,
|
|
198
|
+
installMethod: method,
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
console.log(green(`Successfully upgraded mcpx: v${pkg.version} → v${latestVersion}`));
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
if (json) {
|
|
206
|
+
console.log(
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
upgraded: false,
|
|
209
|
+
currentVersion: pkg.version,
|
|
210
|
+
latestVersion,
|
|
211
|
+
installMethod: method,
|
|
212
|
+
message: "Upgrade failed",
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
} else {
|
|
216
|
+
console.error(red("Upgrade failed. See errors above."));
|
|
217
|
+
}
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
spinner?.error({ text: "Upgrade failed" });
|
|
222
|
+
console.error(String(err));
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { join, resolve } from "path";
|
|
2
|
-
import { homedir } from "os";
|
|
3
2
|
import { interpolateEnv } from "./env.ts";
|
|
4
|
-
import { ENV } from "../constants.ts";
|
|
3
|
+
import { DEFAULT_CONFIG_DIR, ENV } from "../constants.ts";
|
|
5
4
|
import {
|
|
6
5
|
type Config,
|
|
7
6
|
type ServersFile,
|
|
@@ -12,8 +11,6 @@ import {
|
|
|
12
11
|
validateSearchIndex,
|
|
13
12
|
} from "./schemas.ts";
|
|
14
13
|
|
|
15
|
-
const DEFAULT_CONFIG_DIR = join(homedir(), ".mcpx");
|
|
16
|
-
|
|
17
14
|
const EMPTY_SERVERS: ServersFile = { mcpServers: {} };
|
|
18
15
|
const EMPTY_AUTH: AuthFile = {};
|
|
19
16
|
const EMPTY_SEARCH_INDEX: SearchIndex = {
|
package/src/constants.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
|
|
4
|
+
/** Default config directory (~/.mcpx) */
|
|
5
|
+
export const DEFAULT_CONFIG_DIR = join(homedir(), ".mcpx");
|
|
6
|
+
|
|
1
7
|
/** Environment variable names used by mcpx */
|
|
2
8
|
export const ENV = {
|
|
3
9
|
DEBUG: "MCP_DEBUG",
|
|
@@ -6,6 +12,7 @@ export const ENV = {
|
|
|
6
12
|
MAX_RETRIES: "MCP_MAX_RETRIES",
|
|
7
13
|
STRICT_ENV: "MCP_STRICT_ENV",
|
|
8
14
|
CONFIG_PATH: "MCP_CONFIG_PATH",
|
|
15
|
+
NO_UPDATE_CHECK: "MCPX_NO_UPDATE_CHECK",
|
|
9
16
|
} as const;
|
|
10
17
|
|
|
11
18
|
/** Default values for configurable options */
|
|
@@ -16,4 +23,6 @@ export const DEFAULTS = {
|
|
|
16
23
|
TASK_TTL_MS: 60_000,
|
|
17
24
|
SEARCH_TOP_K: 10,
|
|
18
25
|
LOG_LEVEL: "warning",
|
|
26
|
+
UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000,
|
|
27
|
+
UPDATE_CHECK_TIMEOUT_MS: 5_000,
|
|
19
28
|
} as const;
|
|
@@ -5,7 +5,7 @@ import { isInteractive } from "./formatter.ts";
|
|
|
5
5
|
* Format output with automatic JSON/interactive branching.
|
|
6
6
|
* When --format is explicitly set, it takes precedence:
|
|
7
7
|
* json → JSON.stringify of jsonData
|
|
8
|
-
*
|
|
8
|
+
* markdown → interactiveFn() (already well-formatted for non-exec commands)
|
|
9
9
|
* Otherwise falls back to the existing auto-detection:
|
|
10
10
|
* non-interactive → JSON, interactive → formatted text.
|
|
11
11
|
*/
|
|
@@ -18,7 +18,7 @@ export function formatOutput(
|
|
|
18
18
|
if (options.format === "json") {
|
|
19
19
|
return JSON.stringify(jsonData, null, 2);
|
|
20
20
|
}
|
|
21
|
-
//
|
|
21
|
+
// markdown uses the interactive formatter for non-exec commands
|
|
22
22
|
return interactiveFn();
|
|
23
23
|
}
|
|
24
24
|
if (!isInteractive(options)) {
|
package/src/output/formatter.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { SearchResult } from "../search/index.ts";
|
|
|
6
6
|
import { formatOutput } from "./format-output.ts";
|
|
7
7
|
import { formatTable } from "./format-table.ts";
|
|
8
8
|
|
|
9
|
-
export const VALID_FORMATS = ["json", "
|
|
9
|
+
export const VALID_FORMATS = ["json", "markdown"] as const;
|
|
10
10
|
|
|
11
11
|
export type OutputFormat = (typeof VALID_FORMATS)[number];
|
|
12
12
|
|
|
@@ -355,8 +355,6 @@ export function formatCallResult(result: unknown, options: FormatOptions): strin
|
|
|
355
355
|
const format = options.format ?? "json";
|
|
356
356
|
|
|
357
357
|
switch (format) {
|
|
358
|
-
case "text":
|
|
359
|
-
return formatCallResultAsText(result);
|
|
360
358
|
case "markdown":
|
|
361
359
|
return formatCallResultAsMarkdown(result);
|
|
362
360
|
case "json":
|
|
@@ -365,58 +363,6 @@ export function formatCallResult(result: unknown, options: FormatOptions): strin
|
|
|
365
363
|
}
|
|
366
364
|
}
|
|
367
365
|
|
|
368
|
-
/** Extract human-readable text from an MCP tool call result */
|
|
369
|
-
function formatCallResultAsText(result: unknown): string {
|
|
370
|
-
const r = result as {
|
|
371
|
-
content?: Array<{
|
|
372
|
-
type: string;
|
|
373
|
-
text?: string;
|
|
374
|
-
data?: string;
|
|
375
|
-
mimeType?: string;
|
|
376
|
-
uri?: string;
|
|
377
|
-
}>;
|
|
378
|
-
isError?: boolean;
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
if (!r.content || !Array.isArray(r.content) || r.content.length === 0) {
|
|
382
|
-
return JSON.stringify(result, null, 2);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const parts: string[] = [];
|
|
386
|
-
|
|
387
|
-
for (const block of r.content) {
|
|
388
|
-
switch (block.type) {
|
|
389
|
-
case "text":
|
|
390
|
-
if (block.text !== undefined) {
|
|
391
|
-
try {
|
|
392
|
-
const parsed = JSON.parse(block.text);
|
|
393
|
-
parts.push(JSON.stringify(parsed, null, 2));
|
|
394
|
-
} catch {
|
|
395
|
-
parts.push(block.text);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
break;
|
|
399
|
-
case "image":
|
|
400
|
-
parts.push(
|
|
401
|
-
`[image: ${block.mimeType ?? "unknown type"}, ${block.data ? Math.ceil((block.data.length * 3) / 4) : 0} bytes]`,
|
|
402
|
-
);
|
|
403
|
-
break;
|
|
404
|
-
case "resource":
|
|
405
|
-
parts.push(`[resource: ${block.uri ?? "unknown"}]`);
|
|
406
|
-
break;
|
|
407
|
-
default:
|
|
408
|
-
parts.push(`[${block.type}]`);
|
|
409
|
-
break;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
let output = parts.join("\n");
|
|
414
|
-
if (r.isError) {
|
|
415
|
-
output = `error: ${output}`;
|
|
416
|
-
}
|
|
417
|
-
return output;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
366
|
/** Render an MCP tool call result as styled markdown for terminal output */
|
|
421
367
|
function formatCallResultAsMarkdown(result: unknown): string {
|
|
422
368
|
const r = result as {
|
|
@@ -483,30 +429,162 @@ function isPrimitive(value: unknown): value is string | number | boolean | null
|
|
|
483
429
|
return value === null || typeof value !== "object";
|
|
484
430
|
}
|
|
485
431
|
|
|
432
|
+
/**
|
|
433
|
+
* URL placeholders: Bun.markdown.ansi() wraps and auto-links URLs, so we
|
|
434
|
+
* replace them with short tokens before rendering, then swap them back after.
|
|
435
|
+
*/
|
|
436
|
+
let urlCounter = 0;
|
|
437
|
+
let urlMap = new Map<string, string>();
|
|
438
|
+
|
|
439
|
+
function resetUrlPlaceholders(): void {
|
|
440
|
+
urlCounter = 0;
|
|
441
|
+
urlMap = new Map();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function restoreUrlPlaceholders(ansiOutput: string): string {
|
|
445
|
+
for (const [token, url] of urlMap) {
|
|
446
|
+
ansiOutput = ansiOutput.replace(token, `\x1b[34m\x1b[4m${url}\x1b[24m\x1b[39m`);
|
|
447
|
+
}
|
|
448
|
+
return ansiOutput;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Format a primitive value, replacing URLs with placeholders to avoid mangling */
|
|
452
|
+
function formatPrimitive(value: string | number | boolean | null): string {
|
|
453
|
+
const str = String(value ?? "null");
|
|
454
|
+
if (typeof value === "string" && /^https?:\/\/\S+$/.test(value)) {
|
|
455
|
+
const token = `URLPLACEHOLDER${urlCounter++}`;
|
|
456
|
+
urlMap.set(token, str);
|
|
457
|
+
return token;
|
|
458
|
+
}
|
|
459
|
+
return str;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Normalize a key for label matching: lowercase, strip underscores/hyphens */
|
|
463
|
+
function normalizeKey(key: string): string {
|
|
464
|
+
return key.replace(/[_-]/g, "").toLowerCase();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Priority-ordered label keys (checked after normalization) */
|
|
468
|
+
const LABEL_KEYS = [
|
|
469
|
+
"name",
|
|
470
|
+
"displayname",
|
|
471
|
+
"fullname",
|
|
472
|
+
"username",
|
|
473
|
+
"screenname",
|
|
474
|
+
"title",
|
|
475
|
+
"subject",
|
|
476
|
+
"headline",
|
|
477
|
+
"heading",
|
|
478
|
+
"label",
|
|
479
|
+
"description",
|
|
480
|
+
"summary",
|
|
481
|
+
"email",
|
|
482
|
+
"url",
|
|
483
|
+
"slug",
|
|
484
|
+
"key",
|
|
485
|
+
"identifier",
|
|
486
|
+
];
|
|
487
|
+
|
|
488
|
+
/** Find the best label field in an object, returning { originalKey, value } or null */
|
|
489
|
+
function findLabel(obj: Record<string, unknown>): { originalKey: string; value: string } | null {
|
|
490
|
+
const entries = Object.entries(obj);
|
|
491
|
+
for (const candidate of LABEL_KEYS) {
|
|
492
|
+
for (const [key, val] of entries) {
|
|
493
|
+
if (normalizeKey(key) === candidate && typeof val === "string" && val.length > 0) {
|
|
494
|
+
return { originalKey: key, value: val };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Render object entries as an indented bullet list */
|
|
502
|
+
function objectToBullets(entries: [string, unknown][], indent: number, skipKey?: string): string {
|
|
503
|
+
const prefix = " ".repeat(indent);
|
|
504
|
+
const lines: string[] = [];
|
|
505
|
+
|
|
506
|
+
for (const [key, val] of entries) {
|
|
507
|
+
if (key === skipKey) continue;
|
|
508
|
+
const heading = humanizeKey(key);
|
|
509
|
+
|
|
510
|
+
if (isPrimitive(val)) {
|
|
511
|
+
lines.push(`${prefix}- **${heading}:** ${formatPrimitive(val)}`);
|
|
512
|
+
} else if (Array.isArray(val) && val.every(isPrimitive)) {
|
|
513
|
+
lines.push(`${prefix}- **${heading}:**`);
|
|
514
|
+
for (const v of val) {
|
|
515
|
+
lines.push(`${prefix} - ${formatPrimitive(v)}`);
|
|
516
|
+
}
|
|
517
|
+
} else if (Array.isArray(val)) {
|
|
518
|
+
lines.push(`${prefix}- **${heading}:**`);
|
|
519
|
+
for (const item of val) {
|
|
520
|
+
if (isPrimitive(item)) {
|
|
521
|
+
lines.push(`${prefix} - ${formatPrimitive(item)}`);
|
|
522
|
+
} else {
|
|
523
|
+
const itemObj = item as Record<string, unknown>;
|
|
524
|
+
const label = findLabel(itemObj);
|
|
525
|
+
if (label) {
|
|
526
|
+
lines.push(`${prefix} - ${label.value}`);
|
|
527
|
+
lines.push(objectToBullets(Object.entries(itemObj), indent + 4, label.originalKey));
|
|
528
|
+
} else {
|
|
529
|
+
lines.push(`${prefix} -`);
|
|
530
|
+
lines.push(objectToBullets(Object.entries(itemObj), indent + 4));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
lines.push(`${prefix}- **${heading}:**`);
|
|
536
|
+
lines.push(objectToBullets(Object.entries(val as Record<string, unknown>), indent + 2));
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return lines.join("\n");
|
|
541
|
+
}
|
|
542
|
+
|
|
486
543
|
/**
|
|
487
544
|
* Convert a JSON value into a readable markdown document.
|
|
488
|
-
*
|
|
489
|
-
* Arrays of
|
|
490
|
-
* Headings are capped at depth 6 (######); deeper nesting uses **bold** labels instead.
|
|
545
|
+
* Depth 1–2 use headings; depth 3+ switch to compact bullet lists.
|
|
546
|
+
* Arrays of objects use a label field (name, title, etc.) in the heading when available.
|
|
491
547
|
*/
|
|
492
|
-
export function jsonToMarkdown(value: unknown, depth: number = 1): string {
|
|
548
|
+
export function jsonToMarkdown(value: unknown, depth: number = 1, skipKey?: string): string {
|
|
493
549
|
if (isPrimitive(value)) {
|
|
494
|
-
return
|
|
550
|
+
return formatPrimitive(value);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// At depth >= 3, switch to bullet-list rendering
|
|
554
|
+
if (depth >= 3) {
|
|
555
|
+
if (Array.isArray(value)) {
|
|
556
|
+
if (value.every(isPrimitive)) {
|
|
557
|
+
return value.map((v) => `- ${formatPrimitive(v)}`).join("\n");
|
|
558
|
+
}
|
|
559
|
+
return value
|
|
560
|
+
.map((item) => {
|
|
561
|
+
if (isPrimitive(item)) return `- ${formatPrimitive(item)}`;
|
|
562
|
+
const obj = item as Record<string, unknown>;
|
|
563
|
+
const label = findLabel(obj);
|
|
564
|
+
const header = label ? `- ${label.value}` : `-`;
|
|
565
|
+
return `${header}\n${objectToBullets(Object.entries(obj), 2, label?.originalKey)}`;
|
|
566
|
+
})
|
|
567
|
+
.join("\n");
|
|
568
|
+
}
|
|
569
|
+
return objectToBullets(Object.entries(value as Record<string, unknown>), 0, skipKey);
|
|
495
570
|
}
|
|
496
571
|
|
|
497
572
|
if (Array.isArray(value)) {
|
|
498
573
|
// Array of all primitives → bullet list
|
|
499
574
|
if (value.every(isPrimitive)) {
|
|
500
|
-
return value.map((v) => `- ${
|
|
575
|
+
return value.map((v) => `- ${formatPrimitive(v)}`).join("\n");
|
|
501
576
|
}
|
|
502
|
-
// Array of objects → numbered sub-sections
|
|
577
|
+
// Array of objects → numbered sub-sections with label
|
|
503
578
|
return value
|
|
504
579
|
.map((item, i) => {
|
|
505
580
|
if (isPrimitive(item)) {
|
|
506
|
-
return `- ${
|
|
581
|
+
return `- ${formatPrimitive(item)}`;
|
|
507
582
|
}
|
|
508
|
-
const
|
|
509
|
-
|
|
583
|
+
const obj = item as Record<string, unknown>;
|
|
584
|
+
const labelInfo = findLabel(obj);
|
|
585
|
+
const numberLabel = labelInfo ? `${i + 1}. ${labelInfo.value}` : `${i + 1}`;
|
|
586
|
+
const heading = depth <= 6 ? `${"#".repeat(depth)} ${numberLabel}` : `**${numberLabel}**`;
|
|
587
|
+
return `${heading}\n\n${jsonToMarkdown(item, depth + 1, labelInfo?.originalKey)}`;
|
|
510
588
|
})
|
|
511
589
|
.join("\n\n");
|
|
512
590
|
}
|
|
@@ -520,13 +598,13 @@ export function jsonToMarkdown(value: unknown, depth: number = 1): string {
|
|
|
520
598
|
|
|
521
599
|
if (isPrimitive(val)) {
|
|
522
600
|
if (depth <= 6) {
|
|
523
|
-
lines.push(`${"#".repeat(depth)} ${heading}\n\n${
|
|
601
|
+
lines.push(`${"#".repeat(depth)} ${heading}\n\n${formatPrimitive(val)}`);
|
|
524
602
|
} else {
|
|
525
|
-
lines.push(`**${heading}:** ${
|
|
603
|
+
lines.push(`**${heading}:** ${formatPrimitive(val)}`);
|
|
526
604
|
}
|
|
527
605
|
} else if (Array.isArray(val) && val.every(isPrimitive)) {
|
|
528
606
|
// Array of primitives: heading then bullet list
|
|
529
|
-
const list = val.map((v) => `- ${
|
|
607
|
+
const list = val.map((v) => `- ${formatPrimitive(v)}`).join("\n");
|
|
530
608
|
if (depth <= 6) {
|
|
531
609
|
lines.push(`${"#".repeat(depth)} ${heading}\n\n${list}`);
|
|
532
610
|
} else {
|
|
@@ -544,7 +622,10 @@ export function jsonToMarkdown(value: unknown, depth: number = 1): string {
|
|
|
544
622
|
|
|
545
623
|
/** Render a markdown string to ANSI-styled terminal output using Bun's built-in renderer */
|
|
546
624
|
export function renderMarkdownToAnsi(input: string): string {
|
|
547
|
-
|
|
625
|
+
const result = Bun.markdown.ansi(input);
|
|
626
|
+
const restored = restoreUrlPlaceholders(result);
|
|
627
|
+
resetUrlPlaceholders();
|
|
628
|
+
return restored;
|
|
548
629
|
}
|
|
549
630
|
|
|
550
631
|
/** Recursively parse JSON strings inside MCP content blocks */
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { yellow, cyan, dim } from "ansis";
|
|
2
|
+
import pkg from "../../package.json";
|
|
3
|
+
import { ENV, DEFAULTS } from "../constants.ts";
|
|
4
|
+
import { checkForUpdate, needsCheck, type UpdateCache } from "./checker.ts";
|
|
5
|
+
import { loadUpdateCache, saveUpdateCache } from "./cache.ts";
|
|
6
|
+
|
|
7
|
+
/** Format an update notice for stderr output. */
|
|
8
|
+
function formatNotice(currentVersion: string, latestVersion: string, changelog?: string): string {
|
|
9
|
+
const lines: string[] = ["", yellow(`Update available: ${currentVersion} → ${latestVersion}`)];
|
|
10
|
+
|
|
11
|
+
if (changelog) {
|
|
12
|
+
lines.push("");
|
|
13
|
+
lines.push(dim(changelog));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
lines.push("");
|
|
17
|
+
lines.push(cyan(`Run \`mcpx upgrade\` to update`));
|
|
18
|
+
lines.push("");
|
|
19
|
+
|
|
20
|
+
return lines.join("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Non-blocking background update check. Returns a formatted notice string
|
|
25
|
+
* if an update is available, or null otherwise. Never throws.
|
|
26
|
+
*/
|
|
27
|
+
export async function maybeCheckForUpdate(): Promise<string | null> {
|
|
28
|
+
try {
|
|
29
|
+
// Opt-out via env var
|
|
30
|
+
if (process.env[ENV.NO_UPDATE_CHECK] === "1") return null;
|
|
31
|
+
|
|
32
|
+
// Skip if this is the check-update or upgrade command
|
|
33
|
+
const args = process.argv.slice(2);
|
|
34
|
+
const command = args.find((a) => !a.startsWith("-"));
|
|
35
|
+
if (command === "check-update" || command === "upgrade") return null;
|
|
36
|
+
|
|
37
|
+
// Only show in TTY
|
|
38
|
+
if (!(process.stderr.isTTY ?? false)) return null;
|
|
39
|
+
|
|
40
|
+
const cache = await loadUpdateCache();
|
|
41
|
+
|
|
42
|
+
if (!needsCheck(cache)) {
|
|
43
|
+
// Cache is fresh — use cached result
|
|
44
|
+
if (cache?.hasUpdate) {
|
|
45
|
+
return formatNotice(pkg.version, cache.latestVersion, cache.changelog);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Cache is stale or missing — check with timeout
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const timeout = setTimeout(() => controller.abort(), DEFAULTS.UPDATE_CHECK_TIMEOUT_MS);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const info = await checkForUpdate(pkg.version, controller.signal);
|
|
56
|
+
|
|
57
|
+
const newCache: UpdateCache = {
|
|
58
|
+
lastCheckAt: new Date().toISOString(),
|
|
59
|
+
latestVersion: info.latestVersion,
|
|
60
|
+
hasUpdate: info.hasUpdate,
|
|
61
|
+
changelog: info.changelog,
|
|
62
|
+
};
|
|
63
|
+
await saveUpdateCache(newCache);
|
|
64
|
+
|
|
65
|
+
if (info.hasUpdate) {
|
|
66
|
+
return formatNotice(pkg.version, info.latestVersion, info.changelog);
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timeout);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { DEFAULT_CONFIG_DIR } from "../constants.ts";
|
|
3
|
+
import type { UpdateCache } from "./checker.ts";
|
|
4
|
+
|
|
5
|
+
const UPDATE_CACHE_PATH = join(DEFAULT_CONFIG_DIR, "update.json");
|
|
6
|
+
|
|
7
|
+
/** Load the cached update check result, if it exists. */
|
|
8
|
+
export async function loadUpdateCache(): Promise<UpdateCache | undefined> {
|
|
9
|
+
try {
|
|
10
|
+
const file = Bun.file(UPDATE_CACHE_PATH);
|
|
11
|
+
if (!(await file.exists())) return undefined;
|
|
12
|
+
return JSON.parse(await file.text()) as UpdateCache;
|
|
13
|
+
} catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Save update check result to the cache file. */
|
|
19
|
+
export async function saveUpdateCache(cache: UpdateCache): Promise<void> {
|
|
20
|
+
try {
|
|
21
|
+
await Bun.write(UPDATE_CACHE_PATH, JSON.stringify(cache, null, 2) + "\n");
|
|
22
|
+
} catch {
|
|
23
|
+
// Ignore write failures (e.g. permissions)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Remove the cached update check result. */
|
|
28
|
+
export async function clearUpdateCache(): Promise<void> {
|
|
29
|
+
try {
|
|
30
|
+
const file = Bun.file(UPDATE_CACHE_PATH);
|
|
31
|
+
if (await file.exists()) {
|
|
32
|
+
await Bun.write(UPDATE_CACHE_PATH, "");
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Ignore
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import pkg from "../../package.json";
|
|
2
|
+
import { DEFAULTS } from "../constants.ts";
|
|
3
|
+
|
|
4
|
+
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${pkg.name}/latest`;
|
|
5
|
+
const GITHUB_REPO = pkg.repository.url
|
|
6
|
+
.replace(/^https:\/\/github\.com\//, "")
|
|
7
|
+
.replace(/\.git$/, "");
|
|
8
|
+
|
|
9
|
+
export interface UpdateInfo {
|
|
10
|
+
currentVersion: string;
|
|
11
|
+
latestVersion: string;
|
|
12
|
+
hasUpdate: boolean;
|
|
13
|
+
aheadOfLatest: boolean;
|
|
14
|
+
changelog?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UpdateCache {
|
|
18
|
+
lastCheckAt: string;
|
|
19
|
+
latestVersion: string;
|
|
20
|
+
hasUpdate: boolean;
|
|
21
|
+
changelog?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type InstallMethod = "npm" | "bun" | "binary" | "local-dev";
|
|
25
|
+
|
|
26
|
+
/** Compare two semver strings. Returns true if latest > current. */
|
|
27
|
+
export function isNewerVersion(current: string, latest: string): boolean {
|
|
28
|
+
return Bun.semver.order(current, latest) === -1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Fetch the latest version from the npm registry. */
|
|
32
|
+
export async function fetchLatestVersion(signal?: AbortSignal): Promise<string> {
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(NPM_REGISTRY_URL, { signal });
|
|
35
|
+
if (!res.ok) return pkg.version;
|
|
36
|
+
const data = (await res.json()) as { version: string };
|
|
37
|
+
return data.version;
|
|
38
|
+
} catch {
|
|
39
|
+
return pkg.version;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Fetch changelog from GitHub releases between two versions. */
|
|
44
|
+
export async function fetchChangelog(
|
|
45
|
+
fromVersion: string,
|
|
46
|
+
toVersion: string,
|
|
47
|
+
signal?: AbortSignal,
|
|
48
|
+
): Promise<string | undefined> {
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=20`, {
|
|
51
|
+
signal,
|
|
52
|
+
headers: { Accept: "application/vnd.github.v3+json" },
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) return undefined;
|
|
55
|
+
|
|
56
|
+
const releases = (await res.json()) as Array<{
|
|
57
|
+
tag_name: string;
|
|
58
|
+
body: string | null;
|
|
59
|
+
}>;
|
|
60
|
+
|
|
61
|
+
const relevant = releases.filter((r) => {
|
|
62
|
+
const v = r.tag_name.replace(/^v/, "");
|
|
63
|
+
return isNewerVersion(fromVersion, v) && !isNewerVersion(toVersion, v);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (relevant.length === 0) return undefined;
|
|
67
|
+
|
|
68
|
+
return relevant
|
|
69
|
+
.map((r) => `## ${r.tag_name}\n${r.body ?? ""}`)
|
|
70
|
+
.join("\n\n")
|
|
71
|
+
.trim();
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Check npm for a newer version and fetch changelog if available. */
|
|
78
|
+
export async function checkForUpdate(
|
|
79
|
+
currentVersion: string,
|
|
80
|
+
signal?: AbortSignal,
|
|
81
|
+
): Promise<UpdateInfo> {
|
|
82
|
+
const latestVersion = await fetchLatestVersion(signal);
|
|
83
|
+
const hasUpdate = isNewerVersion(currentVersion, latestVersion);
|
|
84
|
+
const aheadOfLatest = isNewerVersion(latestVersion, currentVersion);
|
|
85
|
+
|
|
86
|
+
let changelog: string | undefined;
|
|
87
|
+
if (hasUpdate) {
|
|
88
|
+
changelog = await fetchChangelog(currentVersion, latestVersion, signal);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { currentVersion, latestVersion, hasUpdate, aheadOfLatest, changelog };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Returns true if the cache is missing or older than 24 hours. */
|
|
95
|
+
export function needsCheck(cache?: UpdateCache): boolean {
|
|
96
|
+
if (!cache?.lastCheckAt) return true;
|
|
97
|
+
return Date.now() - new Date(cache.lastCheckAt).getTime() > DEFAULTS.UPDATE_CHECK_INTERVAL_MS;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Detect how mcpx was installed. */
|
|
101
|
+
export function detectInstallMethod(): InstallMethod {
|
|
102
|
+
const script = process.argv[1] ?? "";
|
|
103
|
+
const execPath = process.execPath;
|
|
104
|
+
|
|
105
|
+
// Local dev: running src/cli.ts directly outside node_modules
|
|
106
|
+
if (script.includes("src/cli.ts") && !script.includes("node_modules")) {
|
|
107
|
+
return "local-dev";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Compiled binary: execPath is the binary itself (not bun/node)
|
|
111
|
+
if (!execPath.includes("bun") && !execPath.includes("node")) {
|
|
112
|
+
return "binary";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Bun global install: path contains .bun/install
|
|
116
|
+
if (script.includes(".bun/install") || script.includes(".bun/bin")) {
|
|
117
|
+
return "bun";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// npm global install: fallback for node_modules paths
|
|
121
|
+
return "npm";
|
|
122
|
+
}
|