appduct 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -52
- package/dist/artifact-inspect.d.ts +0 -1
- package/dist/bin.d.ts +0 -1
- package/dist/bin.js +1250 -2
- package/dist/cli/command-options.d.ts +8 -1
- package/dist/cli/create-cli.d.ts +0 -1
- package/dist/cli/dispatch.d.ts +10 -1
- package/dist/cli/envelope.d.ts +19 -0
- package/dist/cli/global-flags.d.ts +30 -0
- package/dist/cli/open-target.d.ts +25 -8
- package/dist/cli/result-types.d.ts +31 -5
- package/dist/cli/router.d.ts +78 -0
- package/dist/cli/routes/daemon/index.d.ts +9 -0
- package/dist/cli/routes/daemon/index.js +848 -0
- package/dist/cli/routes/daemon/run.d.ts +3 -0
- package/dist/cli/routes/daemon/run.js +3246 -0
- package/dist/cli/routes/daemon/start.d.ts +3 -0
- package/dist/cli/routes/daemon/start.js +1677 -0
- package/dist/cli/routes/daemon/status.d.ts +4 -0
- package/dist/cli/routes/daemon/status.js +1685 -0
- package/dist/cli/routes/daemon/stop.d.ts +4 -0
- package/dist/cli/routes/daemon/stop.js +1655 -0
- package/dist/cli/routes/doctor.d.ts +3 -0
- package/dist/cli/routes/doctor.js +1075 -0
- package/dist/cli/routes/events.d.ts +3 -0
- package/dist/cli/routes/events.js +1915 -0
- package/dist/cli/routes/init.d.ts +3 -0
- package/dist/cli/routes/init.js +1486 -0
- package/dist/cli/routes/invoke.d.ts +3 -0
- package/dist/cli/routes/invoke.js +1893 -0
- package/dist/cli/routes/keygen.d.ts +3 -0
- package/dist/cli/routes/keygen.js +928 -0
- package/dist/cli/routes/link.d.ts +3 -0
- package/dist/cli/routes/link.js +2714 -0
- package/dist/cli/routes/ls.d.ts +3 -0
- package/dist/cli/routes/ls.js +1668 -0
- package/dist/cli/routes/mcp.d.ts +5 -0
- package/dist/cli/routes/mcp.js +3929 -0
- package/dist/cli/routes/revoke.d.ts +3 -0
- package/dist/cli/routes/revoke.js +1684 -0
- package/dist/cli/routes/tools.d.ts +3 -0
- package/dist/cli/routes/tools.js +1860 -0
- package/dist/cli/runner.d.ts +3 -11
- package/dist/cli/types.d.ts +9 -6
- package/dist/cli/version-guard.d.ts +20 -0
- package/dist/cli.d.ts +0 -1
- package/dist/client/app-client.d.ts +4 -4
- package/dist/client/bootstrap.d.ts +4 -4
- package/dist/client/connect.d.ts +0 -1
- package/dist/client/errors.d.ts +0 -1
- package/dist/client/index.d.ts +1 -2
- package/dist/client/index.js +2389 -16
- package/dist/commands/daemon/context.d.ts +14 -0
- package/dist/commands/daemon/run.d.ts +11 -0
- package/dist/commands/daemon/start.d.ts +5 -0
- package/dist/commands/daemon/status.d.ts +5 -0
- package/dist/commands/daemon/stop.d.ts +5 -0
- package/dist/commands/doctor.d.ts +0 -1
- package/dist/commands/events.d.ts +0 -1
- package/dist/commands/init.d.ts +17 -4
- package/dist/commands/invoke.d.ts +0 -1
- package/dist/commands/keygen.d.ts +0 -1
- package/dist/commands/link.d.ts +1 -2
- package/dist/commands/ls.d.ts +0 -1
- package/dist/commands/mcp.d.ts +2 -4
- package/dist/commands/revoke.d.ts +0 -1
- package/dist/commands/tools.d.ts +19 -3
- package/dist/daemon/address.d.ts +0 -1
- package/dist/daemon/audit.d.ts +6 -11
- package/dist/daemon/calls.d.ts +0 -1
- package/dist/daemon/config.d.ts +13 -16
- package/dist/daemon/daemon.d.ts +0 -1
- package/dist/daemon/event-bus.d.ts +0 -1
- package/dist/daemon/links.d.ts +0 -1
- package/dist/daemon/listener.d.ts +0 -1
- package/dist/daemon/log-rotation.d.ts +0 -1
- package/dist/daemon/pidfile.d.ts +14 -8
- package/dist/daemon/policy.d.ts +0 -1
- package/dist/daemon/registry.d.ts +0 -1
- package/dist/daemon/rpc-errors.d.ts +16 -0
- package/dist/daemon/rpc-server.d.ts +0 -11
- package/dist/daemon/sessions.d.ts +0 -1
- package/dist/daemon/socket-probe.d.ts +0 -1
- package/dist/daemon/state-dir.d.ts +0 -1
- package/dist/daemon/timers.d.ts +0 -1
- package/dist/daemon/tls.d.ts +0 -1
- package/dist/errors.d.ts +0 -1
- package/dist/host-certificate.d.ts +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +1307 -3
- package/dist/key-material.d.ts +0 -1
- package/dist/link.d.ts +8 -7
- package/dist/mcp/app-tools.d.ts +153 -0
- package/dist/mcp/connect-tool.d.ts +14 -8
- package/dist/mcp/daemon-tools.d.ts +2 -11
- package/dist/mcp/events-tool.d.ts +0 -1
- package/dist/mcp/server.d.ts +33 -14
- package/dist/native-scheme.d.ts +0 -1
- package/dist/output.d.ts +8 -11
- package/dist/package-root.d.ts +11 -0
- package/dist/package-version.d.ts +0 -1
- package/dist/qr-terminal.d.ts +0 -1
- package/dist/rpc/client.d.ts +3 -3
- package/dist/scheme.d.ts +60 -2
- package/dist/spki-pin.d.ts +0 -1
- package/package.json +6 -3
- package/dist/artifact-inspect.d.ts.map +0 -1
- package/dist/artifact-inspect.js +0 -334
- package/dist/bin.d.ts.map +0 -1
- package/dist/cli/command-options.d.ts.map +0 -1
- package/dist/cli/command-options.js +0 -95
- package/dist/cli/create-cli.d.ts.map +0 -1
- package/dist/cli/create-cli.js +0 -56
- package/dist/cli/dispatch.d.ts.map +0 -1
- package/dist/cli/dispatch.js +0 -275
- package/dist/cli/open-target.d.ts.map +0 -1
- package/dist/cli/open-target.js +0 -438
- package/dist/cli/result-types.d.ts.map +0 -1
- package/dist/cli/result-types.js +0 -6
- package/dist/cli/runner.d.ts.map +0 -1
- package/dist/cli/runner.js +0 -111
- package/dist/cli/types.d.ts.map +0 -1
- package/dist/cli/types.js +0 -10
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -2
- package/dist/client/app-client.d.ts.map +0 -1
- package/dist/client/app-client.js +0 -223
- package/dist/client/bootstrap.d.ts.map +0 -1
- package/dist/client/bootstrap.js +0 -108
- package/dist/client/connect.d.ts.map +0 -1
- package/dist/client/connect.js +0 -38
- package/dist/client/errors.d.ts.map +0 -1
- package/dist/client/errors.js +0 -40
- package/dist/client/index.d.ts.map +0 -1
- package/dist/commands/daemon.d.ts +0 -22
- package/dist/commands/daemon.d.ts.map +0 -1
- package/dist/commands/daemon.js +0 -128
- package/dist/commands/doctor.d.ts.map +0 -1
- package/dist/commands/doctor.js +0 -42
- package/dist/commands/events.d.ts.map +0 -1
- package/dist/commands/events.js +0 -76
- package/dist/commands/init.d.ts.map +0 -1
- package/dist/commands/init.js +0 -231
- package/dist/commands/invoke.d.ts.map +0 -1
- package/dist/commands/invoke.js +0 -54
- package/dist/commands/keygen.d.ts.map +0 -1
- package/dist/commands/keygen.js +0 -46
- package/dist/commands/link.d.ts.map +0 -1
- package/dist/commands/link.js +0 -43
- package/dist/commands/ls.d.ts.map +0 -1
- package/dist/commands/ls.js +0 -7
- package/dist/commands/mcp.d.ts.map +0 -1
- package/dist/commands/mcp.js +0 -98
- package/dist/commands/revoke.d.ts.map +0 -1
- package/dist/commands/revoke.js +0 -7
- package/dist/commands/tools.d.ts.map +0 -1
- package/dist/commands/tools.js +0 -52
- package/dist/daemon/address.d.ts.map +0 -1
- package/dist/daemon/address.js +0 -71
- package/dist/daemon/audit.d.ts.map +0 -1
- package/dist/daemon/audit.js +0 -237
- package/dist/daemon/calls.d.ts.map +0 -1
- package/dist/daemon/calls.js +0 -158
- package/dist/daemon/config.d.ts.map +0 -1
- package/dist/daemon/config.js +0 -170
- package/dist/daemon/daemon.d.ts.map +0 -1
- package/dist/daemon/daemon.js +0 -560
- package/dist/daemon/event-bus.d.ts.map +0 -1
- package/dist/daemon/event-bus.js +0 -111
- package/dist/daemon/links.d.ts.map +0 -1
- package/dist/daemon/links.js +0 -118
- package/dist/daemon/listener.d.ts.map +0 -1
- package/dist/daemon/listener.js +0 -132
- package/dist/daemon/log-rotation.d.ts.map +0 -1
- package/dist/daemon/log-rotation.js +0 -136
- package/dist/daemon/pidfile.d.ts.map +0 -1
- package/dist/daemon/pidfile.js +0 -90
- package/dist/daemon/policy.d.ts.map +0 -1
- package/dist/daemon/policy.js +0 -24
- package/dist/daemon/registry.d.ts.map +0 -1
- package/dist/daemon/registry.js +0 -31
- package/dist/daemon/rpc-server.d.ts.map +0 -1
- package/dist/daemon/rpc-server.js +0 -203
- package/dist/daemon/sessions.d.ts.map +0 -1
- package/dist/daemon/sessions.js +0 -429
- package/dist/daemon/socket-probe.d.ts.map +0 -1
- package/dist/daemon/socket-probe.js +0 -28
- package/dist/daemon/state-dir.d.ts.map +0 -1
- package/dist/daemon/state-dir.js +0 -43
- package/dist/daemon/timers.d.ts.map +0 -1
- package/dist/daemon/timers.js +0 -12
- package/dist/daemon/tls.d.ts.map +0 -1
- package/dist/daemon/tls.js +0 -79
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js +0 -162
- package/dist/host-certificate.d.ts.map +0 -1
- package/dist/host-certificate.js +0 -111
- package/dist/index.d.ts.map +0 -1
- package/dist/key-material.d.ts.map +0 -1
- package/dist/key-material.js +0 -42
- package/dist/link.d.ts.map +0 -1
- package/dist/link.js +0 -117
- package/dist/mcp/connect-tool.d.ts.map +0 -1
- package/dist/mcp/connect-tool.js +0 -396
- package/dist/mcp/daemon-tools.d.ts.map +0 -1
- package/dist/mcp/daemon-tools.js +0 -29
- package/dist/mcp/events-tool.d.ts.map +0 -1
- package/dist/mcp/events-tool.js +0 -322
- package/dist/mcp/server.d.ts.map +0 -1
- package/dist/mcp/server.js +0 -538
- package/dist/mcp/tool-mapping.d.ts +0 -58
- package/dist/mcp/tool-mapping.d.ts.map +0 -1
- package/dist/mcp/tool-mapping.js +0 -143
- package/dist/mcp/tool-namespace.d.ts +0 -40
- package/dist/mcp/tool-namespace.d.ts.map +0 -1
- package/dist/mcp/tool-namespace.js +0 -65
- package/dist/native-scheme.d.ts.map +0 -1
- package/dist/native-scheme.js +0 -465
- package/dist/output.d.ts.map +0 -1
- package/dist/output.js +0 -342
- package/dist/package-version.d.ts.map +0 -1
- package/dist/package-version.js +0 -47
- package/dist/qr-terminal.d.ts.map +0 -1
- package/dist/qr-terminal.js +0 -45
- package/dist/rpc/client.d.ts.map +0 -1
- package/dist/rpc/client.js +0 -819
- package/dist/scheme.d.ts.map +0 -1
- package/dist/scheme.js +0 -321
- package/dist/spki-pin.d.ts.map +0 -1
- package/dist/spki-pin.js +0 -14
|
@@ -0,0 +1,3929 @@
|
|
|
1
|
+
// src/commands/mcp.ts
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
|
|
4
|
+
// src/daemon/config.ts
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
|
|
7
|
+
"wssPort",
|
|
8
|
+
"keyPath",
|
|
9
|
+
"graceSeconds",
|
|
10
|
+
"linkTtlSeconds",
|
|
11
|
+
"keepaliveIntervalSeconds",
|
|
12
|
+
"eventBufferSize",
|
|
13
|
+
"auditRetentionDays",
|
|
14
|
+
"daemonLogMaxBytes",
|
|
15
|
+
"policy",
|
|
16
|
+
"advertisedIp",
|
|
17
|
+
"scheme",
|
|
18
|
+
"restartDaemonOnVersionMismatch"
|
|
19
|
+
]);
|
|
20
|
+
var KNOWN_POLICY_KEYS = /* @__PURE__ */ new Set(["default", "destructive", "tools"]);
|
|
21
|
+
var POLICY_DECISIONS = /* @__PURE__ */ new Set(["allow", "deny", "prompt"]);
|
|
22
|
+
var DEFAULT_AUDIT_RETENTION_DAYS = 30;
|
|
23
|
+
var DEFAULT_DAEMON_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
24
|
+
var AppductConfigError = class extends Error {
|
|
25
|
+
constructor(key, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.key = key;
|
|
28
|
+
this.name = "AppductConfigError";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var configError = (key, message) => {
|
|
32
|
+
return new AppductConfigError(key, `Invalid Appduct config value for "${key}": ${message}`);
|
|
33
|
+
};
|
|
34
|
+
var requireWssPort = (value, key) => {
|
|
35
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 65535) {
|
|
36
|
+
throw configError(key, "must be a port number between 0 and 65535 (0 binds an OS-assigned port).");
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
};
|
|
40
|
+
var requirePositiveInteger = (value, key) => {
|
|
41
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
42
|
+
throw configError(key, "must be a positive integer.");
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
};
|
|
46
|
+
var requireNonEmptyString = (value, key) => {
|
|
47
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
48
|
+
throw configError(key, "must be a non-empty string.");
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
};
|
|
52
|
+
var requireBoolean = (value, key) => {
|
|
53
|
+
if (typeof value !== "boolean") {
|
|
54
|
+
throw configError(key, "must be a boolean.");
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
};
|
|
58
|
+
var requirePolicyDecision = (value, key) => {
|
|
59
|
+
if (typeof value !== "string" || !POLICY_DECISIONS.has(value)) {
|
|
60
|
+
throw configError(key, 'must be "allow", "deny", or "prompt".');
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
};
|
|
64
|
+
var defaultConfig = (paths) => {
|
|
65
|
+
return {
|
|
66
|
+
wssPort: 8443,
|
|
67
|
+
keyPath: paths.keyPath,
|
|
68
|
+
graceSeconds: 600,
|
|
69
|
+
linkTtlSeconds: 300,
|
|
70
|
+
keepaliveIntervalSeconds: 15,
|
|
71
|
+
eventBufferSize: 256,
|
|
72
|
+
auditRetentionDays: DEFAULT_AUDIT_RETENTION_DAYS,
|
|
73
|
+
daemonLogMaxBytes: DEFAULT_DAEMON_LOG_MAX_BYTES,
|
|
74
|
+
restartDaemonOnVersionMismatch: false,
|
|
75
|
+
policy: {
|
|
76
|
+
default: "allow",
|
|
77
|
+
destructive: "allow"
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
var loadConfig = async (paths, options = {}) => {
|
|
82
|
+
const warn = options.warn ?? (() => {
|
|
83
|
+
});
|
|
84
|
+
const config = defaultConfig(paths);
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = JSON.parse(await readFile(paths.configPath, "utf8"));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error.code === "ENOENT") {
|
|
90
|
+
return config;
|
|
91
|
+
}
|
|
92
|
+
throw configError("config.json", `could not be read/parsed (${error.message}).`);
|
|
93
|
+
}
|
|
94
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
95
|
+
throw configError("config.json", "must be a JSON object.");
|
|
96
|
+
}
|
|
97
|
+
const parsed = raw;
|
|
98
|
+
for (const key of Object.keys(parsed)) {
|
|
99
|
+
if (!KNOWN_TOP_LEVEL_KEYS.has(key)) {
|
|
100
|
+
warn(`Unknown Appduct config key "${key}" is ignored.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (parsed.wssPort !== void 0) {
|
|
104
|
+
config.wssPort = requireWssPort(parsed.wssPort, "wssPort");
|
|
105
|
+
}
|
|
106
|
+
if (parsed.keyPath !== void 0) {
|
|
107
|
+
config.keyPath = requireNonEmptyString(parsed.keyPath, "keyPath");
|
|
108
|
+
}
|
|
109
|
+
if (parsed.advertisedIp !== void 0) {
|
|
110
|
+
config.advertisedIp = requireNonEmptyString(parsed.advertisedIp, "advertisedIp");
|
|
111
|
+
}
|
|
112
|
+
if (parsed.scheme !== void 0) {
|
|
113
|
+
config.scheme = requireNonEmptyString(parsed.scheme, "scheme");
|
|
114
|
+
}
|
|
115
|
+
if (parsed.graceSeconds !== void 0) {
|
|
116
|
+
config.graceSeconds = requirePositiveInteger(parsed.graceSeconds, "graceSeconds");
|
|
117
|
+
}
|
|
118
|
+
if (parsed.linkTtlSeconds !== void 0) {
|
|
119
|
+
config.linkTtlSeconds = requirePositiveInteger(parsed.linkTtlSeconds, "linkTtlSeconds");
|
|
120
|
+
}
|
|
121
|
+
if (parsed.keepaliveIntervalSeconds !== void 0) {
|
|
122
|
+
config.keepaliveIntervalSeconds = requirePositiveInteger(
|
|
123
|
+
parsed.keepaliveIntervalSeconds,
|
|
124
|
+
"keepaliveIntervalSeconds"
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (parsed.eventBufferSize !== void 0) {
|
|
128
|
+
config.eventBufferSize = requirePositiveInteger(parsed.eventBufferSize, "eventBufferSize");
|
|
129
|
+
}
|
|
130
|
+
if (parsed.auditRetentionDays !== void 0) {
|
|
131
|
+
config.auditRetentionDays = requirePositiveInteger(parsed.auditRetentionDays, "auditRetentionDays");
|
|
132
|
+
}
|
|
133
|
+
if (parsed.daemonLogMaxBytes !== void 0) {
|
|
134
|
+
config.daemonLogMaxBytes = requirePositiveInteger(parsed.daemonLogMaxBytes, "daemonLogMaxBytes");
|
|
135
|
+
}
|
|
136
|
+
if (parsed.restartDaemonOnVersionMismatch !== void 0) {
|
|
137
|
+
config.restartDaemonOnVersionMismatch = requireBoolean(
|
|
138
|
+
parsed.restartDaemonOnVersionMismatch,
|
|
139
|
+
"restartDaemonOnVersionMismatch"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (parsed.policy !== void 0) {
|
|
143
|
+
if (typeof parsed.policy !== "object" || parsed.policy === null || Array.isArray(parsed.policy)) {
|
|
144
|
+
throw configError("policy", "must be an object.");
|
|
145
|
+
}
|
|
146
|
+
const policy = parsed.policy;
|
|
147
|
+
for (const key of Object.keys(policy)) {
|
|
148
|
+
if (!KNOWN_POLICY_KEYS.has(key)) {
|
|
149
|
+
warn(`Unknown Appduct config key "policy.${key}" is ignored.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (policy.default !== void 0) {
|
|
153
|
+
config.policy.default = requirePolicyDecision(policy.default, "policy.default");
|
|
154
|
+
}
|
|
155
|
+
if (policy.destructive !== void 0) {
|
|
156
|
+
config.policy.destructive = requirePolicyDecision(policy.destructive, "policy.destructive");
|
|
157
|
+
}
|
|
158
|
+
if (policy.tools !== void 0) {
|
|
159
|
+
if (typeof policy.tools !== "object" || policy.tools === null || Array.isArray(policy.tools)) {
|
|
160
|
+
throw configError("policy.tools", "must be an object.");
|
|
161
|
+
}
|
|
162
|
+
const tools = {};
|
|
163
|
+
for (const [toolKey, toolValue] of Object.entries(policy.tools)) {
|
|
164
|
+
tools[toolKey] = requirePolicyDecision(toolValue, `policy.tools["${toolKey}"]`);
|
|
165
|
+
}
|
|
166
|
+
config.policy.tools = tools;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return config;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// src/daemon/state-dir.ts
|
|
173
|
+
import { join } from "node:path";
|
|
174
|
+
import { mkdir, chmod } from "node:fs/promises";
|
|
175
|
+
var getStateDirPaths = (stateDir) => {
|
|
176
|
+
return {
|
|
177
|
+
root: stateDir,
|
|
178
|
+
socketPath: join(stateDir, "daemon.sock"),
|
|
179
|
+
pidFilePath: join(stateDir, "daemon.pid"),
|
|
180
|
+
logFilePath: join(stateDir, "daemon.log"),
|
|
181
|
+
keyPath: join(stateDir, "key.pem"),
|
|
182
|
+
configPath: join(stateDir, "config.json"),
|
|
183
|
+
auditDir: join(stateDir, "audit"),
|
|
184
|
+
spawnLockPath: join(stateDir, "daemon.spawn.lock")
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
var ensureStateDir = async (stateDir) => {
|
|
188
|
+
const paths = getStateDirPaths(stateDir);
|
|
189
|
+
await mkdir(paths.auditDir, { recursive: true });
|
|
190
|
+
await chmod(paths.root, 448);
|
|
191
|
+
await chmod(paths.auditDir, 448);
|
|
192
|
+
return paths;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// src/mcp/server.ts
|
|
196
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
197
|
+
import {
|
|
198
|
+
CallToolRequestSchema,
|
|
199
|
+
ErrorCode,
|
|
200
|
+
ListResourcesRequestSchema,
|
|
201
|
+
ListToolsRequestSchema,
|
|
202
|
+
McpError,
|
|
203
|
+
ReadResourceRequestSchema
|
|
204
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
205
|
+
import { RPC_METHODS as RPC_METHODS6 } from "@appduct/shared";
|
|
206
|
+
|
|
207
|
+
// src/daemon/calls.ts
|
|
208
|
+
import {
|
|
209
|
+
clampToolTimeoutMs,
|
|
210
|
+
DEFAULT_TOOL_TIMEOUT_MS,
|
|
211
|
+
MAX_TOOL_TIMEOUT_MS,
|
|
212
|
+
MIN_TOOL_TIMEOUT_MS
|
|
213
|
+
} from "@appduct/shared";
|
|
214
|
+
var DEFAULT_CALL_TIMEOUT_MS = DEFAULT_TOOL_TIMEOUT_MS;
|
|
215
|
+
var CALL_TRANSPORT_TIMEOUT_SLACK_MS = 5e3;
|
|
216
|
+
var clampTimeout = (timeoutMs) => {
|
|
217
|
+
if (timeoutMs === void 0 || !Number.isFinite(timeoutMs)) {
|
|
218
|
+
return DEFAULT_CALL_TIMEOUT_MS;
|
|
219
|
+
}
|
|
220
|
+
return clampToolTimeoutMs(timeoutMs);
|
|
221
|
+
};
|
|
222
|
+
var deriveCallTransportTimeoutMs = (timeoutMs) => {
|
|
223
|
+
return clampTimeout(timeoutMs) + CALL_TRANSPORT_TIMEOUT_SLACK_MS;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// src/package-version.ts
|
|
227
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
228
|
+
import { join as join3 } from "node:path";
|
|
229
|
+
|
|
230
|
+
// src/package-root.ts
|
|
231
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
232
|
+
import { dirname, join as join2 } from "node:path";
|
|
233
|
+
import { fileURLToPath } from "node:url";
|
|
234
|
+
var PACKAGE_NAME = "appduct";
|
|
235
|
+
var cached;
|
|
236
|
+
var getPackageRoot = () => {
|
|
237
|
+
if (cached !== void 0) {
|
|
238
|
+
return cached;
|
|
239
|
+
}
|
|
240
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
241
|
+
for (; ; ) {
|
|
242
|
+
const candidate = join2(dir, "package.json");
|
|
243
|
+
if (existsSync(candidate)) {
|
|
244
|
+
let name;
|
|
245
|
+
try {
|
|
246
|
+
name = JSON.parse(readFileSync(candidate, "utf8")).name;
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
if (name === PACKAGE_NAME) {
|
|
250
|
+
cached = dir;
|
|
251
|
+
return dir;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const parent = dirname(dir);
|
|
255
|
+
if (parent === dir) {
|
|
256
|
+
throw new Error(`Could not locate the "${PACKAGE_NAME}" package root above ${import.meta.url}.`);
|
|
257
|
+
}
|
|
258
|
+
dir = parent;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// src/package-version.ts
|
|
263
|
+
var DAEMON_VERSION_OVERRIDE_ENV = "APPDUCT_DAEMON_VERSION_OVERRIDE";
|
|
264
|
+
var cached2;
|
|
265
|
+
var getPackageVersion = () => {
|
|
266
|
+
cached2 ??= JSON.parse(readFileSync2(join3(getPackageRoot(), "package.json"), "utf8")).version;
|
|
267
|
+
return cached2;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// src/rpc/client.ts
|
|
271
|
+
import { connect as connect2 } from "node:net";
|
|
272
|
+
import { spawn as spawnChildProcess } from "node:child_process";
|
|
273
|
+
import { open as open2, readFile as readFile3, rm as rm2, stat as stat2 } from "node:fs/promises";
|
|
274
|
+
import { join as join4 } from "node:path";
|
|
275
|
+
import { RPC_METHODS } from "@appduct/shared";
|
|
276
|
+
|
|
277
|
+
// src/daemon/log-rotation.ts
|
|
278
|
+
import { chmod as chmod2, rename, stat } from "node:fs/promises";
|
|
279
|
+
|
|
280
|
+
// src/daemon/pidfile.ts
|
|
281
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
282
|
+
import { open, readFile as readFile2, rm } from "node:fs/promises";
|
|
283
|
+
var readProcStatus = (pid) => {
|
|
284
|
+
try {
|
|
285
|
+
return readFileSync3(`/proc/${pid}/status`, "utf8");
|
|
286
|
+
} catch {
|
|
287
|
+
return void 0;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
var isZombie = (pid, readStatus) => {
|
|
291
|
+
const status = readStatus(pid);
|
|
292
|
+
if (status === void 0) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
return /^State:\s*Z\b/mu.test(status);
|
|
296
|
+
};
|
|
297
|
+
var isProcessAlive = (pid, readStatus = readProcStatus) => {
|
|
298
|
+
try {
|
|
299
|
+
process.kill(pid, 0);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
return error.code === "EPERM";
|
|
302
|
+
}
|
|
303
|
+
return !isZombie(pid, readStatus);
|
|
304
|
+
};
|
|
305
|
+
var readPidFromFile = async (pidFilePath) => {
|
|
306
|
+
let contents;
|
|
307
|
+
try {
|
|
308
|
+
contents = await readFile2(pidFilePath, "utf8");
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error.code === "ENOENT") {
|
|
311
|
+
return void 0;
|
|
312
|
+
}
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
const pid = Number.parseInt(contents.trim(), 10);
|
|
316
|
+
return Number.isInteger(pid) && pid > 0 ? pid : void 0;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// src/daemon/socket-probe.ts
|
|
320
|
+
import { connect } from "node:net";
|
|
321
|
+
var isSocketConnectable = (socketPath) => {
|
|
322
|
+
return new Promise((resolve2) => {
|
|
323
|
+
const socket = connect(socketPath);
|
|
324
|
+
socket.once("connect", () => {
|
|
325
|
+
socket.destroy();
|
|
326
|
+
resolve2(true);
|
|
327
|
+
});
|
|
328
|
+
socket.once("error", () => {
|
|
329
|
+
socket.destroy();
|
|
330
|
+
resolve2(false);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// src/daemon/log-rotation.ts
|
|
336
|
+
var STATE_FILE_MODE = 384;
|
|
337
|
+
var daemonLogBackupPath = (logFilePath) => `${logFilePath}.1`;
|
|
338
|
+
var isDaemonHoldingLog = async (pidFilePath) => {
|
|
339
|
+
try {
|
|
340
|
+
const pid = await readPidFromFile(pidFilePath);
|
|
341
|
+
return pid !== void 0 && isProcessAlive(pid);
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
var rotateDaemonLogIfNeeded = async (options) => {
|
|
347
|
+
const warn = options.warn ?? ((message) => {
|
|
348
|
+
process.stderr.write(`${message}
|
|
349
|
+
`);
|
|
350
|
+
});
|
|
351
|
+
try {
|
|
352
|
+
const stats = await stat(options.logFilePath);
|
|
353
|
+
if (!stats.isFile() || stats.size <= options.maxBytes) {
|
|
354
|
+
return { rotated: false };
|
|
355
|
+
}
|
|
356
|
+
const held = options.pidFilePath !== void 0 && await isDaemonHoldingLog(options.pidFilePath) || options.socketPath !== void 0 && await isSocketConnectable(options.socketPath);
|
|
357
|
+
if (held) {
|
|
358
|
+
warn(
|
|
359
|
+
`appduct: "${options.logFilePath}" is over its size cap but a daemon still holds it; leaving it in place.`
|
|
360
|
+
);
|
|
361
|
+
return { rotated: false, skipped: "daemon_running" };
|
|
362
|
+
}
|
|
363
|
+
const backupPath = daemonLogBackupPath(options.logFilePath);
|
|
364
|
+
await rename(options.logFilePath, backupPath);
|
|
365
|
+
try {
|
|
366
|
+
await chmod2(backupPath, STATE_FILE_MODE);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
warn(`appduct: rotated "${options.logFilePath}" but could not set 0600 on "${backupPath}": ${error.message}`);
|
|
369
|
+
}
|
|
370
|
+
return { rotated: true, rotatedBytes: stats.size };
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (error.code === "ENOENT") {
|
|
373
|
+
return { rotated: false };
|
|
374
|
+
}
|
|
375
|
+
warn(`appduct: failed to rotate "${options.logFilePath}": ${error.message}`);
|
|
376
|
+
return { rotated: false };
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
var ensureDaemonLogMode = async (logFilePath, warn = (message) => process.stderr.write(`${message}
|
|
380
|
+
`)) => {
|
|
381
|
+
try {
|
|
382
|
+
await chmod2(logFilePath, STATE_FILE_MODE);
|
|
383
|
+
return true;
|
|
384
|
+
} catch (error) {
|
|
385
|
+
warn(`appduct: could not set 0600 on "${logFilePath}": ${error.message}`);
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
var resolveDaemonLogMaxBytes = async (stateDir) => {
|
|
390
|
+
try {
|
|
391
|
+
return (await loadConfig(getStateDirPaths(stateDir))).daemonLogMaxBytes;
|
|
392
|
+
} catch {
|
|
393
|
+
return DEFAULT_DAEMON_LOG_MAX_BYTES;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// src/rpc/client.ts
|
|
398
|
+
var defaultBinPath = join4(getPackageRoot(), "bin.js");
|
|
399
|
+
var DaemonRpcError = class extends Error {
|
|
400
|
+
constructor(code, message, data) {
|
|
401
|
+
super(message);
|
|
402
|
+
this.code = code;
|
|
403
|
+
this.data = data;
|
|
404
|
+
this.name = "DaemonRpcError";
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
var DaemonUnavailableError = class extends Error {
|
|
408
|
+
constructor(message, reason = "unreachable") {
|
|
409
|
+
super(message);
|
|
410
|
+
this.reason = reason;
|
|
411
|
+
this.name = "DaemonUnavailableError";
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
var isRequestTimeoutError = (error) => {
|
|
415
|
+
return error instanceof DaemonUnavailableError && error.reason === "timeout";
|
|
416
|
+
};
|
|
417
|
+
var describeLiveState = (state) => {
|
|
418
|
+
const parts = [];
|
|
419
|
+
if (state.sessions > 0) {
|
|
420
|
+
parts.push(`${state.sessions} connected session(s)`);
|
|
421
|
+
}
|
|
422
|
+
if (state.pendingLinks > 0) {
|
|
423
|
+
parts.push(`${state.pendingLinks} unclaimed link(s)`);
|
|
424
|
+
}
|
|
425
|
+
return parts.join(" and ");
|
|
426
|
+
};
|
|
427
|
+
var DaemonVersionMismatchError = class extends Error {
|
|
428
|
+
constructor(daemonVersion, clientVersion, live, message) {
|
|
429
|
+
super(
|
|
430
|
+
message ?? `The running Appduct daemon is version ${daemonVersion}, but this client is version ${clientVersion}. It was not restarted automatically because ${describeLiveState(live)} would be lost. Run "appduct daemon stop" once that state is expendable, or force it with "--daemon-restart".`
|
|
431
|
+
);
|
|
432
|
+
this.daemonVersion = daemonVersion;
|
|
433
|
+
this.clientVersion = clientVersion;
|
|
434
|
+
this.name = "DaemonVersionMismatchError";
|
|
435
|
+
this.sessionCount = live.sessions;
|
|
436
|
+
this.pendingLinkCount = live.pendingLinks;
|
|
437
|
+
}
|
|
438
|
+
sessionCount;
|
|
439
|
+
pendingLinkCount;
|
|
440
|
+
};
|
|
441
|
+
var DaemonVersionRestartIneffectiveError = class extends DaemonVersionMismatchError {
|
|
442
|
+
constructor(daemonVersion, clientVersion, live) {
|
|
443
|
+
super(
|
|
444
|
+
daemonVersion,
|
|
445
|
+
clientVersion,
|
|
446
|
+
live,
|
|
447
|
+
`The Appduct daemon was restarted to match this client (version ${clientVersion}), but the daemon now answering still reports version ${daemonVersion}. Either the old daemon never exited (compare "daemon.pid" with what "appduct daemon status" reports), or another process spawned an older daemon in the meantime. Run "appduct daemon stop", then retry.`
|
|
448
|
+
);
|
|
449
|
+
this.name = "DaemonVersionRestartIneffectiveError";
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
var parseVersion = (value) => {
|
|
453
|
+
const match = /^(\d+(?:\.\d+)*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value.trim());
|
|
454
|
+
if (!match) {
|
|
455
|
+
return void 0;
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
numbers: match[1].split(".").map(Number),
|
|
459
|
+
// Build metadata (`+…`) is dropped, per semver: it never affects precedence, so two versions
|
|
460
|
+
// differing only there are the same version and neither restarts nor warns about the other.
|
|
461
|
+
prerelease: match[2]?.split(".")
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
var isNumericIdentifier = (identifier) => /^\d+$/u.test(identifier);
|
|
465
|
+
var comparePrerelease = (left, right) => {
|
|
466
|
+
const length = Math.max(left.length, right.length);
|
|
467
|
+
for (let index = 0; index < length; index += 1) {
|
|
468
|
+
const a = left[index];
|
|
469
|
+
const b = right[index];
|
|
470
|
+
if (a === void 0) {
|
|
471
|
+
return -1;
|
|
472
|
+
}
|
|
473
|
+
if (b === void 0) {
|
|
474
|
+
return 1;
|
|
475
|
+
}
|
|
476
|
+
if (a === b) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const aNumeric = isNumericIdentifier(a);
|
|
480
|
+
const bNumeric = isNumericIdentifier(b);
|
|
481
|
+
if (aNumeric && bNumeric) {
|
|
482
|
+
return Number(a) < Number(b) ? -1 : 1;
|
|
483
|
+
}
|
|
484
|
+
if (aNumeric !== bNumeric) {
|
|
485
|
+
return aNumeric ? -1 : 1;
|
|
486
|
+
}
|
|
487
|
+
return a < b ? -1 : 1;
|
|
488
|
+
}
|
|
489
|
+
return 0;
|
|
490
|
+
};
|
|
491
|
+
var compareVersions = (a, b) => {
|
|
492
|
+
const left = parseVersion(a);
|
|
493
|
+
const right = parseVersion(b);
|
|
494
|
+
if (!left || !right) {
|
|
495
|
+
return void 0;
|
|
496
|
+
}
|
|
497
|
+
const length = Math.max(left.numbers.length, right.numbers.length);
|
|
498
|
+
for (let index = 0; index < length; index += 1) {
|
|
499
|
+
const difference = (left.numbers[index] ?? 0) - (right.numbers[index] ?? 0);
|
|
500
|
+
if (difference !== 0) {
|
|
501
|
+
return difference < 0 ? -1 : 1;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (left.prerelease === void 0 && right.prerelease === void 0) {
|
|
505
|
+
return 0;
|
|
506
|
+
}
|
|
507
|
+
if (left.prerelease === void 0) {
|
|
508
|
+
return 1;
|
|
509
|
+
}
|
|
510
|
+
if (right.prerelease === void 0) {
|
|
511
|
+
return -1;
|
|
512
|
+
}
|
|
513
|
+
return comparePrerelease(left.prerelease, right.prerelease);
|
|
514
|
+
};
|
|
515
|
+
var isSameVersion = (a, b) => {
|
|
516
|
+
return a === b || compareVersions(a, b) === 0;
|
|
517
|
+
};
|
|
518
|
+
var isConnectionMissingError = (error) => {
|
|
519
|
+
const code = error.code;
|
|
520
|
+
return code === "ENOENT" || code === "ECONNREFUSED";
|
|
521
|
+
};
|
|
522
|
+
var isDaemonUnreachableError = (error) => {
|
|
523
|
+
return error instanceof DaemonUnavailableError || isConnectionMissingError(error);
|
|
524
|
+
};
|
|
525
|
+
var nextRequestId = 1;
|
|
526
|
+
var sendRequest = (socketPath, method, params, timeoutMs) => {
|
|
527
|
+
return new Promise((resolve2, reject) => {
|
|
528
|
+
const id = nextRequestId;
|
|
529
|
+
nextRequestId += 1;
|
|
530
|
+
let socket;
|
|
531
|
+
let settled = false;
|
|
532
|
+
let buffer = "";
|
|
533
|
+
const finish = (fn) => {
|
|
534
|
+
if (settled) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
settled = true;
|
|
538
|
+
clearTimeout(timeout);
|
|
539
|
+
socket.destroy();
|
|
540
|
+
fn();
|
|
541
|
+
};
|
|
542
|
+
const timeout = setTimeout(() => {
|
|
543
|
+
finish(
|
|
544
|
+
() => reject(new DaemonUnavailableError(`Timed out waiting for a response to "${method}".`, "timeout"))
|
|
545
|
+
);
|
|
546
|
+
}, timeoutMs);
|
|
547
|
+
socket = connect2(socketPath);
|
|
548
|
+
socket.on("error", (error) => {
|
|
549
|
+
finish(() => reject(error));
|
|
550
|
+
});
|
|
551
|
+
socket.on("connect", () => {
|
|
552
|
+
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
|
|
553
|
+
`);
|
|
554
|
+
});
|
|
555
|
+
socket.on("data", (chunk) => {
|
|
556
|
+
buffer += chunk.toString("utf8");
|
|
557
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
558
|
+
while (newlineIndex !== -1) {
|
|
559
|
+
const line = buffer.slice(0, newlineIndex);
|
|
560
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
561
|
+
newlineIndex = buffer.indexOf("\n");
|
|
562
|
+
if (line.length === 0) {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
let message;
|
|
566
|
+
try {
|
|
567
|
+
message = JSON.parse(line);
|
|
568
|
+
} catch {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (message.id !== id) {
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (message.error) {
|
|
575
|
+
finish(
|
|
576
|
+
() => reject(new DaemonRpcError(message.error.code, message.error.message, message.error.data))
|
|
577
|
+
);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
finish(() => resolve2(message.result));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
socket.on("close", () => {
|
|
585
|
+
finish(
|
|
586
|
+
() => reject(new DaemonUnavailableError("Connection to the Appduct daemon closed.", "closed"))
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
};
|
|
591
|
+
var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
592
|
+
var pollForSocket = async (socketPath, timeoutMs, pollIntervalMs) => {
|
|
593
|
+
const deadline = Date.now() + timeoutMs;
|
|
594
|
+
while (Date.now() < deadline) {
|
|
595
|
+
if (await isSocketConnectable(socketPath)) {
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
await sleep(pollIntervalMs);
|
|
599
|
+
}
|
|
600
|
+
return isSocketConnectable(socketPath);
|
|
601
|
+
};
|
|
602
|
+
var unlinkStaleSocketIfDaemonDead = async (paths) => {
|
|
603
|
+
let pid;
|
|
604
|
+
try {
|
|
605
|
+
pid = Number.parseInt((await readFile3(paths.pidFilePath, "utf8")).trim(), 10);
|
|
606
|
+
} catch {
|
|
607
|
+
pid = void 0;
|
|
608
|
+
}
|
|
609
|
+
if (pid === void 0 || Number.isNaN(pid) || !isProcessAlive(pid)) {
|
|
610
|
+
await rm2(paths.socketPath, { force: true });
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
var defaultSpawn = async (args, context) => {
|
|
614
|
+
const paths = getStateDirPaths(context.stateDir);
|
|
615
|
+
await rotateDaemonLogIfNeeded({
|
|
616
|
+
logFilePath: context.logFilePath,
|
|
617
|
+
maxBytes: await resolveDaemonLogMaxBytes(context.stateDir),
|
|
618
|
+
pidFilePath: paths.pidFilePath,
|
|
619
|
+
socketPath: paths.socketPath
|
|
620
|
+
});
|
|
621
|
+
const logFd = await open2(context.logFilePath, "a", 384);
|
|
622
|
+
try {
|
|
623
|
+
await ensureDaemonLogMode(context.logFilePath);
|
|
624
|
+
const childEnv = { ...process.env, APPDUCT_STATE_DIR: context.stateDir };
|
|
625
|
+
delete childEnv[DAEMON_VERSION_OVERRIDE_ENV];
|
|
626
|
+
const child = spawnChildProcess(process.execPath, [defaultBinPath, ...args], {
|
|
627
|
+
detached: true,
|
|
628
|
+
stdio: ["ignore", logFd.fd, logFd.fd],
|
|
629
|
+
env: childEnv
|
|
630
|
+
});
|
|
631
|
+
child.unref();
|
|
632
|
+
} finally {
|
|
633
|
+
await logFd.close();
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
var SPAWN_LOCK_STALE_MS = 3e4;
|
|
637
|
+
var pollUntilLockReleased = async (lockPath, timeoutMs, pollIntervalMs) => {
|
|
638
|
+
const deadline = Date.now() + timeoutMs;
|
|
639
|
+
while (Date.now() < deadline) {
|
|
640
|
+
if (!await pathExists(lockPath)) {
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
await sleep(pollIntervalMs);
|
|
644
|
+
}
|
|
645
|
+
return !await pathExists(lockPath);
|
|
646
|
+
};
|
|
647
|
+
var readPidfilePid = async (paths) => {
|
|
648
|
+
try {
|
|
649
|
+
const pid = Number.parseInt((await readFile3(paths.pidFilePath, "utf8")).trim(), 10);
|
|
650
|
+
return Number.isNaN(pid) ? void 0 : pid;
|
|
651
|
+
} catch {
|
|
652
|
+
return void 0;
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
var pathExists = async (target) => {
|
|
656
|
+
try {
|
|
657
|
+
await stat2(target);
|
|
658
|
+
return true;
|
|
659
|
+
} catch {
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
var acquireSpawnLock = async (lockPath) => {
|
|
664
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
665
|
+
try {
|
|
666
|
+
const lockHandle = await open2(lockPath, "wx", 384);
|
|
667
|
+
await lockHandle.close();
|
|
668
|
+
return true;
|
|
669
|
+
} catch (error) {
|
|
670
|
+
if (error.code !== "EEXIST") {
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
let heldForMs;
|
|
675
|
+
try {
|
|
676
|
+
heldForMs = Date.now() - (await stat2(lockPath)).mtimeMs;
|
|
677
|
+
} catch {
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (heldForMs < SPAWN_LOCK_STALE_MS) {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
await rm2(lockPath, { force: true });
|
|
684
|
+
}
|
|
685
|
+
return false;
|
|
686
|
+
};
|
|
687
|
+
var spawnDaemonAndWait = async (paths, spawn, waitTimeoutMs, pollIntervalMs, options = {}) => {
|
|
688
|
+
await ensureStateDir(paths.root);
|
|
689
|
+
const acquiredLock = await acquireSpawnLock(paths.spawnLockPath);
|
|
690
|
+
let lockWaitTimedOut = false;
|
|
691
|
+
if (acquiredLock) {
|
|
692
|
+
try {
|
|
693
|
+
const shouldSpawn = options.beforeSpawn ? await options.beforeSpawn() : true;
|
|
694
|
+
if (shouldSpawn) {
|
|
695
|
+
await unlinkStaleSocketIfDaemonDead(paths);
|
|
696
|
+
await spawn(["daemon", "run"], { stateDir: paths.root, logFilePath: paths.logFilePath });
|
|
697
|
+
}
|
|
698
|
+
} finally {
|
|
699
|
+
await rm2(paths.spawnLockPath, { force: true });
|
|
700
|
+
}
|
|
701
|
+
} else if (options.awaitLockRelease) {
|
|
702
|
+
lockWaitTimedOut = !await pollUntilLockReleased(paths.spawnLockPath, waitTimeoutMs, pollIntervalMs);
|
|
703
|
+
}
|
|
704
|
+
const ready = await pollForSocket(paths.socketPath, waitTimeoutMs, pollIntervalMs);
|
|
705
|
+
if (!ready) {
|
|
706
|
+
const lockHint = await pathExists(paths.spawnLockPath) ? ` Another process holds the spawn-lock at "${paths.spawnLockPath}"; if no "appduct daemon" is starting, delete it and retry.` : "";
|
|
707
|
+
throw new DaemonUnavailableError(
|
|
708
|
+
`Timed out waiting for the Appduct daemon socket at "${paths.socketPath}".${lockHint}`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
return { ownedLock: acquiredLock, lockWaitTimedOut };
|
|
712
|
+
};
|
|
713
|
+
var versionChecks = /* @__PURE__ */ new Map();
|
|
714
|
+
var RESTART_REQUEST_TIMEOUT_MS = 3e3;
|
|
715
|
+
var readStatusSpawningIfNeeded = async (context) => {
|
|
716
|
+
try {
|
|
717
|
+
return await sendRequest(
|
|
718
|
+
context.paths.socketPath,
|
|
719
|
+
RPC_METHODS.daemonStatus,
|
|
720
|
+
{},
|
|
721
|
+
context.requestTimeoutMs
|
|
722
|
+
);
|
|
723
|
+
} catch (error) {
|
|
724
|
+
if (!isConnectionMissingError(error)) {
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
if (!context.autoSpawn) {
|
|
728
|
+
return void 0;
|
|
729
|
+
}
|
|
730
|
+
await spawnDaemonAndWait(context.paths, context.spawn, context.waitTimeoutMs, context.pollIntervalMs);
|
|
731
|
+
return sendRequest(
|
|
732
|
+
context.paths.socketPath,
|
|
733
|
+
RPC_METHODS.daemonStatus,
|
|
734
|
+
{},
|
|
735
|
+
context.requestTimeoutMs
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
var readStatusIfReachable = async (context) => {
|
|
740
|
+
try {
|
|
741
|
+
return await sendRequest(
|
|
742
|
+
context.paths.socketPath,
|
|
743
|
+
RPC_METHODS.daemonStatus,
|
|
744
|
+
{},
|
|
745
|
+
RESTART_REQUEST_TIMEOUT_MS
|
|
746
|
+
);
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (isConnectionMissingError(error)) {
|
|
749
|
+
return void 0;
|
|
750
|
+
}
|
|
751
|
+
if (isRequestTimeoutError(error)) {
|
|
752
|
+
throw new DaemonUnavailableError(
|
|
753
|
+
`The Appduct daemon at "${context.paths.socketPath}" did not answer "daemon.status" within ${RESTART_REQUEST_TIMEOUT_MS}ms, so it was not replaced. It is running but unresponsive \u2014 run "appduct daemon stop" and retry.`,
|
|
754
|
+
"timeout"
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
throw error;
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
var isDaemonGone = async (paths) => {
|
|
761
|
+
if (await isSocketConnectable(paths.socketPath)) {
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
let pid;
|
|
765
|
+
try {
|
|
766
|
+
pid = Number.parseInt((await readFile3(paths.pidFilePath, "utf8")).trim(), 10);
|
|
767
|
+
} catch {
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
770
|
+
return pid === void 0 || Number.isNaN(pid) || !isProcessAlive(pid);
|
|
771
|
+
};
|
|
772
|
+
var readLiveState = (status) => {
|
|
773
|
+
return {
|
|
774
|
+
sessions: Array.isArray(status.sessions) ? status.sessions.length : 0,
|
|
775
|
+
pendingLinks: typeof status.pendingLinks === "number" ? status.pendingLinks : 0
|
|
776
|
+
};
|
|
777
|
+
};
|
|
778
|
+
var isSafeToRestart = (live) => {
|
|
779
|
+
return live.sessions === 0 && live.pendingLinks === 0;
|
|
780
|
+
};
|
|
781
|
+
var pollUntilDaemonGone = async (paths, timeoutMs, pollIntervalMs) => {
|
|
782
|
+
const deadline = Date.now() + timeoutMs;
|
|
783
|
+
while (Date.now() < deadline) {
|
|
784
|
+
if (await isDaemonGone(paths)) {
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
await sleep(pollIntervalMs);
|
|
788
|
+
}
|
|
789
|
+
return isDaemonGone(paths);
|
|
790
|
+
};
|
|
791
|
+
var restartDaemon = async (context) => {
|
|
792
|
+
const pidBefore = await readPidfilePid(context.paths);
|
|
793
|
+
const outcome = await spawnDaemonAndWait(
|
|
794
|
+
context.paths,
|
|
795
|
+
context.spawn,
|
|
796
|
+
context.waitTimeoutMs,
|
|
797
|
+
context.pollIntervalMs,
|
|
798
|
+
{
|
|
799
|
+
awaitLockRelease: true,
|
|
800
|
+
beforeSpawn: async () => {
|
|
801
|
+
const status = await readStatusIfReachable(context);
|
|
802
|
+
if (status === void 0) {
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
if (isSameVersion(status.version, context.check.clientVersion)) {
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
const live = readLiveState(status);
|
|
809
|
+
if (!isSafeToRestart(live) && !context.check.forceRestart) {
|
|
810
|
+
throw new DaemonVersionMismatchError(status.version, context.check.clientVersion, live);
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
await sendRequest(
|
|
814
|
+
context.paths.socketPath,
|
|
815
|
+
RPC_METHODS.daemonShutdown,
|
|
816
|
+
{},
|
|
817
|
+
RESTART_REQUEST_TIMEOUT_MS
|
|
818
|
+
);
|
|
819
|
+
} catch (error) {
|
|
820
|
+
if (isRequestTimeoutError(error)) {
|
|
821
|
+
throw new DaemonUnavailableError(
|
|
822
|
+
`The Appduct daemon at "${context.paths.socketPath}" did not acknowledge "daemon.shutdown" within ${RESTART_REQUEST_TIMEOUT_MS}ms, so it was left running. Run "appduct daemon stop" and retry.`,
|
|
823
|
+
"timeout"
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
if (!isDaemonUnreachableError(error)) {
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (!await pollUntilDaemonGone(context.paths, context.waitTimeoutMs, context.pollIntervalMs)) {
|
|
831
|
+
throw new DaemonUnavailableError(
|
|
832
|
+
`The Appduct daemon at "${context.paths.socketPath}" did not shut down in time; run "appduct daemon stop" and retry.`
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
);
|
|
839
|
+
if (!outcome.ownedLock && outcome.lockWaitTimedOut && await readPidfilePid(context.paths) === pidBefore) {
|
|
840
|
+
throw new DaemonUnavailableError(
|
|
841
|
+
`The Appduct daemon at "${context.paths.socketPath}" did not shut down in time: another process has held the spawn-lock at "${context.paths.spawnLockPath}" for longer than this command waited. Retry, or run "appduct daemon stop" if nothing is starting a daemon.`
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
var warnOlderClient = (context, daemonVersion) => {
|
|
846
|
+
const message = `appduct: the running daemon is version ${daemonVersion}, newer than this client (${context.check.clientVersion}). Continuing without restarting it \u2014 a newer daemon serves an older client, and replacing it would downgrade whatever started it. Run "appduct daemon stop" if you want this client's daemon instead.
|
|
847
|
+
`;
|
|
848
|
+
const warn = context.check.onWarning ?? ((text) => void process.stderr.write(text));
|
|
849
|
+
warn(message);
|
|
850
|
+
};
|
|
851
|
+
var runVersionCheck = async (context) => {
|
|
852
|
+
const status = await readStatusSpawningIfNeeded(context);
|
|
853
|
+
if (status === void 0) {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (isSameVersion(status.version, context.check.clientVersion)) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if ((compareVersions(context.check.clientVersion, status.version) ?? -1) <= 0) {
|
|
860
|
+
warnOlderClient(context, status.version);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
const live = readLiveState(status);
|
|
864
|
+
if (!isSafeToRestart(live) && !context.check.forceRestart) {
|
|
865
|
+
throw new DaemonVersionMismatchError(status.version, context.check.clientVersion, live);
|
|
866
|
+
}
|
|
867
|
+
await restartDaemon(context);
|
|
868
|
+
const replacement = await readStatusIfReachable(context);
|
|
869
|
+
if (replacement === void 0) {
|
|
870
|
+
throw new DaemonUnavailableError(
|
|
871
|
+
`The Appduct daemon was restarted but nothing is listening at "${context.paths.socketPath}"; check "${context.paths.logFilePath}".`
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
if (isSameVersion(replacement.version, context.check.clientVersion)) {
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if ((compareVersions(context.check.clientVersion, replacement.version) ?? -1) <= 0) {
|
|
878
|
+
warnOlderClient(context, replacement.version);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
throw new DaemonVersionRestartIneffectiveError(
|
|
882
|
+
replacement.version,
|
|
883
|
+
context.check.clientVersion,
|
|
884
|
+
readLiveState(replacement)
|
|
885
|
+
);
|
|
886
|
+
};
|
|
887
|
+
var ensureDaemonVersion = async (paths, options, spawn) => {
|
|
888
|
+
const check = options.checkVersion;
|
|
889
|
+
if (!check) {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const cached3 = versionChecks.get(paths.socketPath);
|
|
893
|
+
if (cached3) {
|
|
894
|
+
return cached3;
|
|
895
|
+
}
|
|
896
|
+
const pending = runVersionCheck({
|
|
897
|
+
paths,
|
|
898
|
+
spawn,
|
|
899
|
+
autoSpawn: options.autoSpawn ?? true,
|
|
900
|
+
requestTimeoutMs: options.requestTimeoutMs ?? 1e4,
|
|
901
|
+
waitTimeoutMs: options.spawnWaitTimeoutMs ?? 5e3,
|
|
902
|
+
pollIntervalMs: options.spawnPollIntervalMs ?? 100,
|
|
903
|
+
check
|
|
904
|
+
}).catch((error) => {
|
|
905
|
+
if (!(error instanceof DaemonVersionMismatchError)) {
|
|
906
|
+
versionChecks.delete(paths.socketPath);
|
|
907
|
+
}
|
|
908
|
+
throw error;
|
|
909
|
+
});
|
|
910
|
+
versionChecks.set(paths.socketPath, pending);
|
|
911
|
+
return pending;
|
|
912
|
+
};
|
|
913
|
+
var openDaemonStream = async (options) => {
|
|
914
|
+
const paths = getStateDirPaths(options.stateDir);
|
|
915
|
+
const autoSpawn = options.autoSpawn ?? true;
|
|
916
|
+
const spawn = options.spawn ?? defaultSpawn;
|
|
917
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 1e4;
|
|
918
|
+
const connectSocket = () => {
|
|
919
|
+
return new Promise((resolve2, reject) => {
|
|
920
|
+
const socket2 = connect2(paths.socketPath);
|
|
921
|
+
socket2.once("connect", () => {
|
|
922
|
+
socket2.off("error", onError);
|
|
923
|
+
resolve2(socket2);
|
|
924
|
+
});
|
|
925
|
+
const onError = (error) => {
|
|
926
|
+
reject(error);
|
|
927
|
+
};
|
|
928
|
+
socket2.once("error", onError);
|
|
929
|
+
});
|
|
930
|
+
};
|
|
931
|
+
await ensureDaemonVersion(paths, options, spawn);
|
|
932
|
+
let socket;
|
|
933
|
+
try {
|
|
934
|
+
socket = await connectSocket();
|
|
935
|
+
} catch (error) {
|
|
936
|
+
if (!autoSpawn || !isConnectionMissingError(error)) {
|
|
937
|
+
throw error;
|
|
938
|
+
}
|
|
939
|
+
await spawnDaemonAndWait(
|
|
940
|
+
paths,
|
|
941
|
+
spawn,
|
|
942
|
+
options.spawnWaitTimeoutMs ?? 5e3,
|
|
943
|
+
options.spawnPollIntervalMs ?? 100
|
|
944
|
+
);
|
|
945
|
+
socket = await connectSocket();
|
|
946
|
+
}
|
|
947
|
+
socket.on("error", () => {
|
|
948
|
+
});
|
|
949
|
+
const notificationListeners = /* @__PURE__ */ new Set();
|
|
950
|
+
const closeListeners = /* @__PURE__ */ new Set();
|
|
951
|
+
const pendingCalls = /* @__PURE__ */ new Map();
|
|
952
|
+
let buffer = "";
|
|
953
|
+
socket.on("data", (chunk) => {
|
|
954
|
+
buffer += chunk.toString("utf8");
|
|
955
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
956
|
+
while (newlineIndex !== -1) {
|
|
957
|
+
const line = buffer.slice(0, newlineIndex);
|
|
958
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
959
|
+
newlineIndex = buffer.indexOf("\n");
|
|
960
|
+
if (line.length === 0) {
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
let message;
|
|
964
|
+
try {
|
|
965
|
+
message = JSON.parse(line);
|
|
966
|
+
} catch {
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
if (message.method === "event") {
|
|
970
|
+
for (const listener of notificationListeners) {
|
|
971
|
+
try {
|
|
972
|
+
listener(message.params);
|
|
973
|
+
} catch {
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
const pending = typeof message.id === "number" ? pendingCalls.get(message.id) : void 0;
|
|
979
|
+
if (!pending) {
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
pendingCalls.delete(message.id);
|
|
983
|
+
clearTimeout(pending.timeout);
|
|
984
|
+
if (message.error) {
|
|
985
|
+
pending.reject(new DaemonRpcError(message.error.code, message.error.message, message.error.data));
|
|
986
|
+
} else {
|
|
987
|
+
pending.resolve(message.result);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
socket.on("close", () => {
|
|
992
|
+
for (const [, pending] of pendingCalls) {
|
|
993
|
+
clearTimeout(pending.timeout);
|
|
994
|
+
pending.reject(new DaemonUnavailableError("Connection to the Appduct daemon closed.", "closed"));
|
|
995
|
+
}
|
|
996
|
+
pendingCalls.clear();
|
|
997
|
+
for (const listener of closeListeners) {
|
|
998
|
+
listener();
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
return {
|
|
1002
|
+
call: (method, params, timeoutMs) => {
|
|
1003
|
+
return new Promise((resolve2, reject) => {
|
|
1004
|
+
const id = nextRequestId;
|
|
1005
|
+
nextRequestId += 1;
|
|
1006
|
+
const timeout = setTimeout(() => {
|
|
1007
|
+
pendingCalls.delete(id);
|
|
1008
|
+
reject(new DaemonUnavailableError(`Timed out waiting for a response to "${method}".`, "timeout"));
|
|
1009
|
+
}, timeoutMs ?? requestTimeoutMs);
|
|
1010
|
+
pendingCalls.set(id, {
|
|
1011
|
+
resolve: resolve2,
|
|
1012
|
+
reject,
|
|
1013
|
+
timeout
|
|
1014
|
+
});
|
|
1015
|
+
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
|
|
1016
|
+
`);
|
|
1017
|
+
});
|
|
1018
|
+
},
|
|
1019
|
+
onNotification: (callback) => {
|
|
1020
|
+
notificationListeners.add(callback);
|
|
1021
|
+
return () => {
|
|
1022
|
+
notificationListeners.delete(callback);
|
|
1023
|
+
};
|
|
1024
|
+
},
|
|
1025
|
+
onClose: (callback) => {
|
|
1026
|
+
closeListeners.add(callback);
|
|
1027
|
+
return () => {
|
|
1028
|
+
closeListeners.delete(callback);
|
|
1029
|
+
};
|
|
1030
|
+
},
|
|
1031
|
+
close: () => {
|
|
1032
|
+
socket.destroy();
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
// src/mcp/app-tools.ts
|
|
1038
|
+
import {
|
|
1039
|
+
MAX_TOOLS_FILTER_LENGTH,
|
|
1040
|
+
MAX_TOOL_TIMEOUT_MS as MAX_TOOL_TIMEOUT_MS2,
|
|
1041
|
+
MIN_TOOL_TIMEOUT_MS as MIN_TOOL_TIMEOUT_MS2,
|
|
1042
|
+
RPC_METHODS as RPC_METHODS4,
|
|
1043
|
+
renderToolSignature,
|
|
1044
|
+
summarizeToolDescription,
|
|
1045
|
+
TOOL_GROUP_PATTERN
|
|
1046
|
+
} from "@appduct/shared";
|
|
1047
|
+
|
|
1048
|
+
// src/mcp/connect-tool.ts
|
|
1049
|
+
import {
|
|
1050
|
+
RPC_METHODS as RPC_METHODS3
|
|
1051
|
+
} from "@appduct/shared";
|
|
1052
|
+
|
|
1053
|
+
// src/cli/open-target.ts
|
|
1054
|
+
import { execFile } from "node:child_process";
|
|
1055
|
+
import { mkdtemp, readFile as readFile4, rm as rm3 } from "node:fs/promises";
|
|
1056
|
+
import { tmpdir } from "node:os";
|
|
1057
|
+
import path from "node:path";
|
|
1058
|
+
|
|
1059
|
+
// src/errors.ts
|
|
1060
|
+
var EXIT_CODE_BY_ERROR_TYPE = {
|
|
1061
|
+
usage_error: 64,
|
|
1062
|
+
validation_error: 65,
|
|
1063
|
+
// EX_NOINPUT (66): "an input file did not exist or was not readable" — `doctor` throws this for a
|
|
1064
|
+
// missing external tool or an unreadable/corrupt artifact, deliberately distinct from every other
|
|
1065
|
+
// code here so a release pipeline can't mistake "we couldn't tell" for either a clean pass or an
|
|
1066
|
+
// assertion failure (docs/tasks/08-appduct-doctor.md).
|
|
1067
|
+
inspection_error: 66,
|
|
1068
|
+
connection_error: 70,
|
|
1069
|
+
session_error: 71,
|
|
1070
|
+
tool_error: 72,
|
|
1071
|
+
permission_error: 77,
|
|
1072
|
+
internal_error: 1,
|
|
1073
|
+
// `doctor --assert-present`/`--assert-absent`: the artifact was inspected successfully and the
|
|
1074
|
+
// assertion did not hold. Distinct from `inspection_error` (66) — this means the answer is known
|
|
1075
|
+
// and it's "no", not "couldn't tell" — and distinct from `internal_error` (1) so a caller can't
|
|
1076
|
+
// conflate a normal assertion failure with an unexpected crash.
|
|
1077
|
+
assertion_error: 3
|
|
1078
|
+
};
|
|
1079
|
+
var RPC_ERROR_EXIT_CLASS = {
|
|
1080
|
+
no_session: "session_error",
|
|
1081
|
+
ambiguous_session: "session_error",
|
|
1082
|
+
unknown_session: "session_error",
|
|
1083
|
+
session_not_active: "session_error",
|
|
1084
|
+
session_suspended: "session_error",
|
|
1085
|
+
tool_not_found: "tool_error",
|
|
1086
|
+
tool_input_validation_error: "tool_error",
|
|
1087
|
+
tool_output_validation_error: "tool_error",
|
|
1088
|
+
tool_execution_error: "tool_error",
|
|
1089
|
+
tool_serialization_error: "tool_error",
|
|
1090
|
+
tool_timeout: "tool_error",
|
|
1091
|
+
tool_cancelled: "tool_error",
|
|
1092
|
+
policy_denied: "permission_error",
|
|
1093
|
+
invalid_request: "validation_error"
|
|
1094
|
+
};
|
|
1095
|
+
var AppductCliError = class extends Error {
|
|
1096
|
+
type;
|
|
1097
|
+
details;
|
|
1098
|
+
constructor(type, message, details) {
|
|
1099
|
+
super(message);
|
|
1100
|
+
this.name = "AppductCliError";
|
|
1101
|
+
this.type = type;
|
|
1102
|
+
this.details = details;
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
var usageError = (message, details) => {
|
|
1106
|
+
return new AppductCliError("usage_error", message, details);
|
|
1107
|
+
};
|
|
1108
|
+
var connectionError = (message, details) => {
|
|
1109
|
+
return new AppductCliError("connection_error", message, details);
|
|
1110
|
+
};
|
|
1111
|
+
var isAppductCliError = (value) => {
|
|
1112
|
+
return value instanceof AppductCliError;
|
|
1113
|
+
};
|
|
1114
|
+
var rpcErrorClass = (error) => {
|
|
1115
|
+
const type = error.data?.type;
|
|
1116
|
+
return type ? RPC_ERROR_EXIT_CLASS[type] : void 0;
|
|
1117
|
+
};
|
|
1118
|
+
var toCliError = (error) => {
|
|
1119
|
+
if (isAppductCliError(error)) {
|
|
1120
|
+
return {
|
|
1121
|
+
type: error.type,
|
|
1122
|
+
message: error.message,
|
|
1123
|
+
details: error.details
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
if (error instanceof DaemonVersionMismatchError) {
|
|
1127
|
+
return {
|
|
1128
|
+
type: "connection_error",
|
|
1129
|
+
message: error.message,
|
|
1130
|
+
details: {
|
|
1131
|
+
daemon_version: error.daemonVersion,
|
|
1132
|
+
client_version: error.clientVersion,
|
|
1133
|
+
session_count: error.sessionCount,
|
|
1134
|
+
pending_link_count: error.pendingLinkCount
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
if (error instanceof DaemonRpcError && error.data?.type) {
|
|
1139
|
+
return {
|
|
1140
|
+
type: error.data.type,
|
|
1141
|
+
message: error.message,
|
|
1142
|
+
details: error.data.details
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
if (isDaemonUnreachableError(error)) {
|
|
1146
|
+
return {
|
|
1147
|
+
type: "connection_error",
|
|
1148
|
+
message: error instanceof Error ? error.message : "The Appduct daemon is unreachable."
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
if (error instanceof Error) {
|
|
1152
|
+
return {
|
|
1153
|
+
type: "internal_error",
|
|
1154
|
+
message: error.message
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
return {
|
|
1158
|
+
type: "internal_error",
|
|
1159
|
+
message: "An unexpected error occurred.",
|
|
1160
|
+
details: error
|
|
1161
|
+
};
|
|
1162
|
+
};
|
|
1163
|
+
var getExitCodeForError = (error) => {
|
|
1164
|
+
if (isAppductCliError(error)) {
|
|
1165
|
+
return EXIT_CODE_BY_ERROR_TYPE[error.type];
|
|
1166
|
+
}
|
|
1167
|
+
if (error instanceof DaemonVersionMismatchError) {
|
|
1168
|
+
return EXIT_CODE_BY_ERROR_TYPE.connection_error;
|
|
1169
|
+
}
|
|
1170
|
+
if (error instanceof DaemonRpcError) {
|
|
1171
|
+
const cliErrorClass = rpcErrorClass(error);
|
|
1172
|
+
return EXIT_CODE_BY_ERROR_TYPE[cliErrorClass ?? "internal_error"];
|
|
1173
|
+
}
|
|
1174
|
+
if (isDaemonUnreachableError(error)) {
|
|
1175
|
+
return EXIT_CODE_BY_ERROR_TYPE.connection_error;
|
|
1176
|
+
}
|
|
1177
|
+
return EXIT_CODE_BY_ERROR_TYPE.internal_error;
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
// src/cli/open-target.ts
|
|
1181
|
+
var isOpenTarget = (value) => {
|
|
1182
|
+
return value === "android" || value === "ios-sim" || value === "ios-device";
|
|
1183
|
+
};
|
|
1184
|
+
var usesLoopbackAddress = (target) => {
|
|
1185
|
+
return target === "android" || target === "ios-sim";
|
|
1186
|
+
};
|
|
1187
|
+
var platformOf = (target) => {
|
|
1188
|
+
if (target === "android") {
|
|
1189
|
+
return "android";
|
|
1190
|
+
}
|
|
1191
|
+
if (target === "ios-device") {
|
|
1192
|
+
return "ios";
|
|
1193
|
+
}
|
|
1194
|
+
return void 0;
|
|
1195
|
+
};
|
|
1196
|
+
var isLoopbackAddress = (address) => {
|
|
1197
|
+
const normalized = address.trim().toLowerCase().replace(/^\[|\]$/gu, "");
|
|
1198
|
+
return normalized === "localhost" || normalized === "::1" || normalized === "0.0.0.0" || normalized === "::" || /^127\./u.test(normalized);
|
|
1199
|
+
};
|
|
1200
|
+
var loopbackAddressMessage = (address) => `The daemon advertised "${address}", which a physical device cannot reach \u2014 it would point the phone at itself and the session would never be claimed. No routable LAN address was detected; set "advertisedIp" in config.json to this machine's address on the network the device is on, and retry.`;
|
|
1201
|
+
var defaultExec = (command, args) => {
|
|
1202
|
+
return new Promise((resolve2, reject) => {
|
|
1203
|
+
execFile(command, args, { encoding: "utf8" }, (error, stdout, stderr) => {
|
|
1204
|
+
if (error) {
|
|
1205
|
+
reject(Object.assign(error, { stdout, stderr }));
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
resolve2({ stdout, stderr });
|
|
1209
|
+
});
|
|
1210
|
+
});
|
|
1211
|
+
};
|
|
1212
|
+
var TOOL_INSTALL_HINT = {
|
|
1213
|
+
adb: "the Android platform-tools (adb)",
|
|
1214
|
+
xcrun: "the Xcode command line tools (xcrun)"
|
|
1215
|
+
};
|
|
1216
|
+
var run = async (exec, command, args) => {
|
|
1217
|
+
try {
|
|
1218
|
+
return await exec(command, args);
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
const err = error;
|
|
1221
|
+
if (err.code === "ENOENT") {
|
|
1222
|
+
throw connectionError(
|
|
1223
|
+
`"${command}" was not found on PATH. Install ${TOOL_INSTALL_HINT[command] ?? command} and retry.`
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
const stderr = (err.stderr ?? "").trim();
|
|
1227
|
+
throw connectionError(
|
|
1228
|
+
`"${command} ${args.join(" ")}" failed${stderr ? `: ${stderr}` : `: ${err.message}`}`
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
var parseBootedSimulators = (stdout) => {
|
|
1233
|
+
let parsed;
|
|
1234
|
+
try {
|
|
1235
|
+
parsed = JSON.parse(stdout);
|
|
1236
|
+
} catch {
|
|
1237
|
+
return [];
|
|
1238
|
+
}
|
|
1239
|
+
return Object.values(parsed.devices ?? {}).flat().filter(
|
|
1240
|
+
(entry) => entry.state === "Booted" && typeof entry.udid === "string" && entry.udid.length > 0
|
|
1241
|
+
).map((entry) => ({ udid: entry.udid, name: entry.name ?? entry.udid }));
|
|
1242
|
+
};
|
|
1243
|
+
var listBootedSimulators = async (exec) => {
|
|
1244
|
+
const preflight = await run(exec, "xcrun", ["simctl", "list", "devices", "booted", "--json"]);
|
|
1245
|
+
return parseBootedSimulators(preflight.stdout);
|
|
1246
|
+
};
|
|
1247
|
+
var describeSimulator = (simulator) => `${simulator.name} (${simulator.udid})`;
|
|
1248
|
+
var deliverIosSim = async (exec, deepLink, device) => {
|
|
1249
|
+
if (device) {
|
|
1250
|
+
await run(exec, "xcrun", ["simctl", "openurl", device, deepLink]);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
const booted = await listBootedSimulators(exec);
|
|
1254
|
+
if (booted.length === 0) {
|
|
1255
|
+
throw usageError("No booted iOS simulator found. Boot a simulator (e.g. via Xcode) and retry.");
|
|
1256
|
+
}
|
|
1257
|
+
if (booted.length > 1) {
|
|
1258
|
+
throw usageError(
|
|
1259
|
+
`Multiple iOS simulators are booted (${booted.map(describeSimulator).join(", ")}); specify --device <udid>.`
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
await run(exec, "xcrun", ["simctl", "openurl", booted[0].udid, deepLink]);
|
|
1263
|
+
};
|
|
1264
|
+
var runDevicectlJson = async (exec, buildArgs) => {
|
|
1265
|
+
let dir;
|
|
1266
|
+
try {
|
|
1267
|
+
dir = await mkdtemp(path.join(tmpdir(), "appduct-devicectl-"));
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
throw connectionError(
|
|
1270
|
+
`A temporary directory for devicectl's --json-output could not be created in "${tmpdir()}" (${error.message}). Set TMPDIR to a writable directory and retry.`
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
const file = path.join(dir, "devicectl.json");
|
|
1274
|
+
try {
|
|
1275
|
+
await run(exec, "xcrun", buildArgs(file));
|
|
1276
|
+
try {
|
|
1277
|
+
return JSON.parse(await readFile4(file, "utf8"));
|
|
1278
|
+
} catch {
|
|
1279
|
+
return void 0;
|
|
1280
|
+
}
|
|
1281
|
+
} finally {
|
|
1282
|
+
await rm3(dir, { force: true, recursive: true }).catch(() => {
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
var DELIVERABLE_PLATFORM = "ios";
|
|
1287
|
+
var UNREACHABLE_TUNNEL_STATE = "unavailable";
|
|
1288
|
+
var USABLE_PAIRING_STATE = "paired";
|
|
1289
|
+
var parsePairedIosDevices = (payload) => {
|
|
1290
|
+
const devices = payload?.result?.devices;
|
|
1291
|
+
if (!Array.isArray(devices)) {
|
|
1292
|
+
return [];
|
|
1293
|
+
}
|
|
1294
|
+
return devices.map((entry) => {
|
|
1295
|
+
const platform = entry?.hardwareProperties?.platform;
|
|
1296
|
+
if (typeof platform === "string" && platform.toLowerCase() !== DELIVERABLE_PLATFORM) {
|
|
1297
|
+
return void 0;
|
|
1298
|
+
}
|
|
1299
|
+
const tunnelState = entry?.connectionProperties?.tunnelState;
|
|
1300
|
+
if (typeof tunnelState === "string" && tunnelState.toLowerCase() === UNREACHABLE_TUNNEL_STATE) {
|
|
1301
|
+
return void 0;
|
|
1302
|
+
}
|
|
1303
|
+
const pairingState = entry?.connectionProperties?.pairingState;
|
|
1304
|
+
if (typeof pairingState === "string" && pairingState.toLowerCase() !== USABLE_PAIRING_STATE) {
|
|
1305
|
+
return void 0;
|
|
1306
|
+
}
|
|
1307
|
+
const hardwareUdid = entry?.hardwareProperties?.udid;
|
|
1308
|
+
const udid = typeof hardwareUdid === "string" && hardwareUdid.length > 0 ? hardwareUdid : entry?.identifier;
|
|
1309
|
+
return typeof udid === "string" && udid.length > 0 ? { udid, name: entry?.deviceProperties?.name ?? udid } : void 0;
|
|
1310
|
+
}).filter((device) => device !== void 0);
|
|
1311
|
+
};
|
|
1312
|
+
var listPairedIosDevices = async (exec) => {
|
|
1313
|
+
const payload = await runDevicectlJson(exec, (file) => [
|
|
1314
|
+
"devicectl",
|
|
1315
|
+
"list",
|
|
1316
|
+
"devices",
|
|
1317
|
+
"--timeout",
|
|
1318
|
+
"5",
|
|
1319
|
+
"--json-output",
|
|
1320
|
+
file
|
|
1321
|
+
]);
|
|
1322
|
+
return parsePairedIosDevices(payload);
|
|
1323
|
+
};
|
|
1324
|
+
var describeIosDevice = (device) => `${device.name} (${device.udid})`;
|
|
1325
|
+
var MISSING_APP_ID_MESSAGE = 'Delivering to a device needs the installed app\'s id (the Android package name, or the iOS bundle id): pass "--app-id <id>" (or "appId" over MCP), or record it as "appId.<platform>" in .appduct/config.json \u2014 run `appduct init --android-app-id <id> --ios-app-id <id>` to write it.';
|
|
1326
|
+
var APP_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
1327
|
+
var isValidAppId = (value) => APP_ID_PATTERN.test(value);
|
|
1328
|
+
var invalidAppIdMessage = (value) => `"${value}" is not a valid app id (letters, digits, ".", "_" and "-", starting with a letter or digit).`;
|
|
1329
|
+
var resolveSingleIosDevice = async (exec) => {
|
|
1330
|
+
const paired = await listPairedIosDevices(exec);
|
|
1331
|
+
if (paired.length === 0) {
|
|
1332
|
+
throw usageError(
|
|
1333
|
+
'No connected iOS device was found ("xcrun devicectl list devices" listed none, ignoring disconnected devices and non-iOS ones such as a paired Watch or Vision Pro). Connect the device, trust this Mac, and enable Developer Mode on it, then retry.'
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
if (paired.length > 1) {
|
|
1337
|
+
throw usageError(
|
|
1338
|
+
`Multiple connected iOS devices were found (${paired.map(describeIosDevice).join(", ")}); specify --device <udid>.`
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
return paired[0].udid;
|
|
1342
|
+
};
|
|
1343
|
+
var deliverIosDevice = async (exec, deepLink, device, appId, relaunch) => {
|
|
1344
|
+
if (!appId) {
|
|
1345
|
+
throw usageError(MISSING_APP_ID_MESSAGE);
|
|
1346
|
+
}
|
|
1347
|
+
if (!isValidAppId(appId)) {
|
|
1348
|
+
throw usageError(invalidAppIdMessage(appId));
|
|
1349
|
+
}
|
|
1350
|
+
const udid = device ?? await resolveSingleIosDevice(exec);
|
|
1351
|
+
await run(exec, "xcrun", [
|
|
1352
|
+
"devicectl",
|
|
1353
|
+
"device",
|
|
1354
|
+
"process",
|
|
1355
|
+
"launch",
|
|
1356
|
+
"--device",
|
|
1357
|
+
udid,
|
|
1358
|
+
// Opt-in only (`--relaunch` / `relaunch: true`). A plain `process launch` is what the vendored
|
|
1359
|
+
// Expo CLI does, so it is the better-attested default; what it does when the app is *already
|
|
1360
|
+
// running* is the one thing nobody here can verify without hardware. If that turns out to fail
|
|
1361
|
+
// rather than deliver the URL, `--terminate-existing` is the escape hatch — it kills the
|
|
1362
|
+
// running instance first, which Appduct copes with because a delivered link supersedes a
|
|
1363
|
+
// held session.
|
|
1364
|
+
...relaunch ? ["--terminate-existing"] : [],
|
|
1365
|
+
"--payload-url",
|
|
1366
|
+
deepLink,
|
|
1367
|
+
appId
|
|
1368
|
+
]);
|
|
1369
|
+
};
|
|
1370
|
+
var parseAdbDevices = (stdout) => {
|
|
1371
|
+
return stdout.split("\n").slice(1).map((line) => line.trim()).filter((line) => line.length > 0).map((line) => {
|
|
1372
|
+
const [serial = "", state = ""] = line.split(/\s+/u);
|
|
1373
|
+
return { serial, state };
|
|
1374
|
+
}).filter((entry) => entry.serial.length > 0);
|
|
1375
|
+
};
|
|
1376
|
+
var listAttachedAndroidDevices = async (exec) => {
|
|
1377
|
+
const listing = await run(exec, "adb", ["devices"]);
|
|
1378
|
+
return parseAdbDevices(listing.stdout).filter((entry) => entry.state === "device").map((entry) => entry.serial);
|
|
1379
|
+
};
|
|
1380
|
+
var resolveAndroidSerial = async (exec, device, env) => {
|
|
1381
|
+
if (device) {
|
|
1382
|
+
return device;
|
|
1383
|
+
}
|
|
1384
|
+
if (env.ANDROID_SERIAL) {
|
|
1385
|
+
return void 0;
|
|
1386
|
+
}
|
|
1387
|
+
const attached = await listAttachedAndroidDevices(exec);
|
|
1388
|
+
if (attached.length === 0) {
|
|
1389
|
+
throw usageError('No Android device or emulator is attached ("adb devices" listed none).');
|
|
1390
|
+
}
|
|
1391
|
+
if (attached.length > 1) {
|
|
1392
|
+
throw usageError(
|
|
1393
|
+
`Multiple Android devices are attached (${attached.join(
|
|
1394
|
+
", "
|
|
1395
|
+
)}); specify --device <serial> or set ANDROID_SERIAL.`
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
return void 0;
|
|
1399
|
+
};
|
|
1400
|
+
var withSerial = (args, serial) => {
|
|
1401
|
+
return serial ? ["-s", serial, ...args] : args;
|
|
1402
|
+
};
|
|
1403
|
+
var AM_START_ERROR_PATTERN = /^(?:Error:|Exception occurred while executing|Security exception:)/mu;
|
|
1404
|
+
var deliverAndroid = async (exec, deepLink, wssPort, device, env, appId) => {
|
|
1405
|
+
if (!isValidAppId(appId)) {
|
|
1406
|
+
throw usageError(invalidAppIdMessage(appId));
|
|
1407
|
+
}
|
|
1408
|
+
const serial = await resolveAndroidSerial(exec, device, env);
|
|
1409
|
+
await run(exec, "adb", withSerial(["reverse", `tcp:${wssPort}`, `tcp:${wssPort}`], serial));
|
|
1410
|
+
const { stdout, stderr } = await run(
|
|
1411
|
+
exec,
|
|
1412
|
+
"adb",
|
|
1413
|
+
withSerial(
|
|
1414
|
+
[
|
|
1415
|
+
"shell",
|
|
1416
|
+
"am",
|
|
1417
|
+
"start",
|
|
1418
|
+
"-a",
|
|
1419
|
+
"android.intent.action.VIEW",
|
|
1420
|
+
"-d",
|
|
1421
|
+
// `adb shell` reconstructs everything after "shell" into one string and re-parses it on the
|
|
1422
|
+
// device's own shell; single-quoting the URL here (not just for local execFile, which never
|
|
1423
|
+
// re-tokenizes argv) protects against that second, remote parse (ARCHITECTURE.md §8).
|
|
1424
|
+
`'${deepLink}'`,
|
|
1425
|
+
// Names the app explicitly: without this, more than one installed app declaring the same
|
|
1426
|
+
// scheme makes Android show an "Open with" chooser, which `am start` still reports as a
|
|
1427
|
+
// successful launch — see `AM_START_ERROR_PATTERN` below for the other half of the fix.
|
|
1428
|
+
"-p",
|
|
1429
|
+
appId
|
|
1430
|
+
],
|
|
1431
|
+
serial
|
|
1432
|
+
)
|
|
1433
|
+
);
|
|
1434
|
+
if (AM_START_ERROR_PATTERN.test(stdout) || AM_START_ERROR_PATTERN.test(stderr)) {
|
|
1435
|
+
throw usageError(
|
|
1436
|
+
`"adb shell am start" could not launch "${appId}": ${(stdout + stderr).trim() || "(no output)"} \u2014 the app is likely not installed on this device, or its installed build does not declare a matching deep-link scheme.`
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
var safeList = async (list) => {
|
|
1441
|
+
try {
|
|
1442
|
+
return await list();
|
|
1443
|
+
} catch {
|
|
1444
|
+
return [];
|
|
1445
|
+
}
|
|
1446
|
+
};
|
|
1447
|
+
var detectBootedTargets = async (options = {}) => {
|
|
1448
|
+
const exec = options.exec ?? defaultExec;
|
|
1449
|
+
const env = options.env ?? process.env;
|
|
1450
|
+
const [simulators, androidSerials] = await Promise.all([
|
|
1451
|
+
safeList(() => listBootedSimulators(exec)),
|
|
1452
|
+
env.ANDROID_SERIAL ? Promise.resolve([env.ANDROID_SERIAL]) : safeList(() => listAttachedAndroidDevices(exec))
|
|
1453
|
+
]);
|
|
1454
|
+
const candidates = [
|
|
1455
|
+
...simulators.map((simulator) => ({
|
|
1456
|
+
target: "ios-sim",
|
|
1457
|
+
device: simulator.udid,
|
|
1458
|
+
label: describeSimulator(simulator)
|
|
1459
|
+
})),
|
|
1460
|
+
...androidSerials.map((serial) => ({
|
|
1461
|
+
target: "android",
|
|
1462
|
+
device: serial,
|
|
1463
|
+
label: serial
|
|
1464
|
+
}))
|
|
1465
|
+
];
|
|
1466
|
+
if (candidates.length === 0) {
|
|
1467
|
+
return { kind: "none" };
|
|
1468
|
+
}
|
|
1469
|
+
if (candidates.length === 1) {
|
|
1470
|
+
return { kind: "single", detected: candidates[0] };
|
|
1471
|
+
}
|
|
1472
|
+
return { kind: "ambiguous", candidates };
|
|
1473
|
+
};
|
|
1474
|
+
var deliverToOpenTarget = async (options) => {
|
|
1475
|
+
const exec = options.exec ?? defaultExec;
|
|
1476
|
+
if (options.target === "ios-sim") {
|
|
1477
|
+
await deliverIosSim(exec, options.deepLink, options.device);
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
if (options.target === "ios-device") {
|
|
1481
|
+
await deliverIosDevice(
|
|
1482
|
+
exec,
|
|
1483
|
+
options.deepLink,
|
|
1484
|
+
options.device,
|
|
1485
|
+
options.appId,
|
|
1486
|
+
options.relaunch ?? false
|
|
1487
|
+
);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
if (!options.appId) {
|
|
1491
|
+
throw usageError(MISSING_APP_ID_MESSAGE);
|
|
1492
|
+
}
|
|
1493
|
+
await deliverAndroid(
|
|
1494
|
+
exec,
|
|
1495
|
+
options.deepLink,
|
|
1496
|
+
options.wssPort,
|
|
1497
|
+
options.device,
|
|
1498
|
+
options.env ?? process.env,
|
|
1499
|
+
options.appId
|
|
1500
|
+
);
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
// src/link.ts
|
|
1504
|
+
import { RPC_METHODS as RPC_METHODS2 } from "@appduct/shared";
|
|
1505
|
+
|
|
1506
|
+
// src/scheme.ts
|
|
1507
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
1508
|
+
import { readFile as readFile6 } from "node:fs/promises";
|
|
1509
|
+
import { homedir } from "node:os";
|
|
1510
|
+
import { dirname as dirname2, join as join6, resolve } from "node:path";
|
|
1511
|
+
|
|
1512
|
+
// src/native-scheme.ts
|
|
1513
|
+
import { readdir, readFile as readFile5, stat as stat3 } from "node:fs/promises";
|
|
1514
|
+
import { join as join5 } from "node:path";
|
|
1515
|
+
var MAX_PROBE_FILE_BYTES = 2 * 1024 * 1024;
|
|
1516
|
+
var MAX_PLIST_WALK_DEPTH = 2;
|
|
1517
|
+
var MAX_WALK_ENTRIES = 5e3;
|
|
1518
|
+
var EXCLUDED_WALK_DIRS = /* @__PURE__ */ new Set(["Pods", "build", "node_modules", "DerivedData"]);
|
|
1519
|
+
var readCappedText = async (path2) => {
|
|
1520
|
+
const buffer = await readCappedBuffer(path2);
|
|
1521
|
+
if (!buffer.ok) {
|
|
1522
|
+
return buffer;
|
|
1523
|
+
}
|
|
1524
|
+
return { ok: true, text: buffer.data.toString("utf8") };
|
|
1525
|
+
};
|
|
1526
|
+
var readCappedBuffer = async (path2) => {
|
|
1527
|
+
let size;
|
|
1528
|
+
try {
|
|
1529
|
+
const stats = await stat3(path2);
|
|
1530
|
+
if (!stats.isFile()) {
|
|
1531
|
+
return { ok: false, reason: "not-found" };
|
|
1532
|
+
}
|
|
1533
|
+
size = stats.size;
|
|
1534
|
+
} catch {
|
|
1535
|
+
return { ok: false, reason: "not-found" };
|
|
1536
|
+
}
|
|
1537
|
+
if (size > MAX_PROBE_FILE_BYTES) {
|
|
1538
|
+
return { ok: false, reason: "too-large" };
|
|
1539
|
+
}
|
|
1540
|
+
try {
|
|
1541
|
+
return { ok: true, data: await readFile5(path2) };
|
|
1542
|
+
} catch {
|
|
1543
|
+
return { ok: false, reason: "unreadable" };
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
var requireValidNativeScheme = (value, origin) => {
|
|
1547
|
+
if (!isValidScheme(value)) {
|
|
1548
|
+
throw usageError(
|
|
1549
|
+
`Invalid deep-link scheme ${JSON.stringify(value)} from ${origin}: a scheme must start with a letter and contain only letters, digits, "+", "-" or "." (for example "myapp") \u2014 do not include "://".`
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
return value;
|
|
1553
|
+
};
|
|
1554
|
+
var GRADLE_PLACEHOLDER_PATTERN = /manifestPlaceholders\s*(?:\[\s*["']appductScheme["']\s*\]|\.\s*appductScheme)\s*=\s*["']([^"']*)["']/u;
|
|
1555
|
+
var extractGradleScheme = (text) => {
|
|
1556
|
+
const match = GRADLE_PLACEHOLDER_PATTERN.exec(text);
|
|
1557
|
+
const value = match?.[1];
|
|
1558
|
+
return value !== void 0 && value.length > 0 ? value : void 0;
|
|
1559
|
+
};
|
|
1560
|
+
var probeAndroidGradle = async (root) => {
|
|
1561
|
+
const candidates = [join5(root, "app", "build.gradle.kts"), join5(root, "app", "build.gradle")];
|
|
1562
|
+
const notes = [];
|
|
1563
|
+
for (const candidate of candidates) {
|
|
1564
|
+
const read = await readCappedText(candidate);
|
|
1565
|
+
if (!read.ok) {
|
|
1566
|
+
if (read.reason === "too-large") {
|
|
1567
|
+
notes.push(`${candidate} (too large, skipped)`);
|
|
1568
|
+
}
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
const scheme = extractGradleScheme(read.text);
|
|
1572
|
+
if (scheme !== void 0) {
|
|
1573
|
+
requireValidNativeScheme(scheme, `"appductScheme" in ${candidate}`);
|
|
1574
|
+
return {
|
|
1575
|
+
result: {
|
|
1576
|
+
source: "android-gradle",
|
|
1577
|
+
scheme,
|
|
1578
|
+
origin: `"appductScheme" in ${candidate}`
|
|
1579
|
+
},
|
|
1580
|
+
tried: [describeAndroidGradleTried(candidates, notes)]
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
return { tried: [describeAndroidGradleTried(candidates, notes)] };
|
|
1585
|
+
};
|
|
1586
|
+
var describeAndroidGradleTried = (candidates, notes) => {
|
|
1587
|
+
const base = `${candidates.join(", ")} ("appductScheme" manifest placeholder)`;
|
|
1588
|
+
return notes.length === 0 ? base : `${base} \u2014 ${notes.join(", ")}`;
|
|
1589
|
+
};
|
|
1590
|
+
var INTENT_FILTER_PATTERN = /<intent-filter\b[^>]*>([\s\S]*?)<\/intent-filter>/giu;
|
|
1591
|
+
var VIEW_ACTION_PATTERN = /<action\b[^>]*\bandroid:name\s*=\s*["']android\.intent\.action\.VIEW["']/iu;
|
|
1592
|
+
var DATA_SCHEME_PATTERN = /<data\b[^>]*\bandroid:scheme\s*=\s*["']([^"']*)["']/iu;
|
|
1593
|
+
var extractManifestScheme = (xml) => {
|
|
1594
|
+
for (const match of xml.matchAll(INTENT_FILTER_PATTERN)) {
|
|
1595
|
+
const block = match[1] ?? "";
|
|
1596
|
+
if (!VIEW_ACTION_PATTERN.test(block)) {
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
const dataMatch = DATA_SCHEME_PATTERN.exec(block);
|
|
1600
|
+
const scheme = dataMatch?.[1];
|
|
1601
|
+
if (scheme !== void 0 && scheme.length > 0) {
|
|
1602
|
+
return scheme;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
return void 0;
|
|
1606
|
+
};
|
|
1607
|
+
var probeAndroidManifest = async (root) => {
|
|
1608
|
+
const manifestPath = join5(root, "app", "src", "main", "AndroidManifest.xml");
|
|
1609
|
+
const read = await readCappedText(manifestPath);
|
|
1610
|
+
const description = `${manifestPath} (first <data android:scheme> in a VIEW intent-filter)`;
|
|
1611
|
+
if (!read.ok) {
|
|
1612
|
+
return {
|
|
1613
|
+
tried: [read.reason === "too-large" ? `${description} \u2014 too large, skipped` : description]
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
const scheme = extractManifestScheme(read.text);
|
|
1617
|
+
if (scheme === void 0) {
|
|
1618
|
+
return { tried: [description] };
|
|
1619
|
+
}
|
|
1620
|
+
requireValidNativeScheme(scheme, `"android:scheme" in ${manifestPath}`);
|
|
1621
|
+
return {
|
|
1622
|
+
result: {
|
|
1623
|
+
source: "android-manifest",
|
|
1624
|
+
scheme,
|
|
1625
|
+
origin: `"android:scheme" in ${manifestPath}`
|
|
1626
|
+
},
|
|
1627
|
+
tried: [description]
|
|
1628
|
+
};
|
|
1629
|
+
};
|
|
1630
|
+
var XML_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
1631
|
+
var decodeXmlEntities = (text) => {
|
|
1632
|
+
return text.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/gu, (whole, entity) => {
|
|
1633
|
+
if (entity.startsWith("#x")) {
|
|
1634
|
+
const code = Number.parseInt(entity.slice(2), 16);
|
|
1635
|
+
return Number.isNaN(code) ? whole : String.fromCodePoint(code);
|
|
1636
|
+
}
|
|
1637
|
+
if (entity.startsWith("#")) {
|
|
1638
|
+
const code = Number.parseInt(entity.slice(1), 10);
|
|
1639
|
+
return Number.isNaN(code) ? whole : String.fromCodePoint(code);
|
|
1640
|
+
}
|
|
1641
|
+
return XML_ENTITIES[entity] ?? whole;
|
|
1642
|
+
});
|
|
1643
|
+
};
|
|
1644
|
+
var tokenizePlistXml = (xml) => {
|
|
1645
|
+
const cleaned = xml.replace(/<\?[\s\S]*?\?>/gu, "").replace(/<!DOCTYPE[\s\S]*?>/giu, "").replace(/<!--[\s\S]*?-->/gu, "");
|
|
1646
|
+
const root = { tag: "#root", children: [], text: "" };
|
|
1647
|
+
const stack = [root];
|
|
1648
|
+
const tagOrTextPattern = /<(\/?)([a-zA-Z][\w.:-]*)\b[^>]*?(\/?)>|([^<]+)/gu;
|
|
1649
|
+
for (const match of cleaned.matchAll(tagOrTextPattern)) {
|
|
1650
|
+
const [, closing, name, selfClosing, text] = match;
|
|
1651
|
+
const top = stack[stack.length - 1];
|
|
1652
|
+
if (text !== void 0) {
|
|
1653
|
+
top.text += decodeXmlEntities(text);
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
if (closing) {
|
|
1657
|
+
if (stack.length > 1) {
|
|
1658
|
+
stack.pop();
|
|
1659
|
+
}
|
|
1660
|
+
continue;
|
|
1661
|
+
}
|
|
1662
|
+
const node = { tag: name ?? "", children: [], text: "" };
|
|
1663
|
+
top.children.push(node);
|
|
1664
|
+
if (!selfClosing) {
|
|
1665
|
+
stack.push(node);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
return root;
|
|
1669
|
+
};
|
|
1670
|
+
var plistNodeToValue = (node) => {
|
|
1671
|
+
switch (node.tag) {
|
|
1672
|
+
case "true":
|
|
1673
|
+
return true;
|
|
1674
|
+
case "false":
|
|
1675
|
+
return false;
|
|
1676
|
+
case "integer": {
|
|
1677
|
+
const value = Number.parseInt(node.text.trim(), 10);
|
|
1678
|
+
return Number.isNaN(value) ? node.text.trim() : value;
|
|
1679
|
+
}
|
|
1680
|
+
case "real": {
|
|
1681
|
+
const value = Number.parseFloat(node.text.trim());
|
|
1682
|
+
return Number.isNaN(value) ? node.text.trim() : value;
|
|
1683
|
+
}
|
|
1684
|
+
case "array":
|
|
1685
|
+
return node.children.map(plistNodeToValue);
|
|
1686
|
+
case "dict": {
|
|
1687
|
+
const result = {};
|
|
1688
|
+
for (let index = 0; index < node.children.length; index += 2) {
|
|
1689
|
+
const keyNode = node.children[index];
|
|
1690
|
+
const valueNode = node.children[index + 1];
|
|
1691
|
+
if (keyNode?.tag === "key" && valueNode !== void 0) {
|
|
1692
|
+
result[keyNode.text.trim()] = plistNodeToValue(valueNode);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
return result;
|
|
1696
|
+
}
|
|
1697
|
+
default:
|
|
1698
|
+
return node.text.trim();
|
|
1699
|
+
}
|
|
1700
|
+
};
|
|
1701
|
+
var parsePlistXml = (xml) => {
|
|
1702
|
+
const root = tokenizePlistXml(xml);
|
|
1703
|
+
const plistNode = root.children.find((child) => child.tag === "plist");
|
|
1704
|
+
const valueNode = plistNode?.children[0];
|
|
1705
|
+
return valueNode ? plistNodeToValue(valueNode) : void 0;
|
|
1706
|
+
};
|
|
1707
|
+
var isBinaryPlist = (buffer) => {
|
|
1708
|
+
return buffer.length >= 6 && buffer.subarray(0, 6).toString("latin1") === "bplist";
|
|
1709
|
+
};
|
|
1710
|
+
var firstPlistUrlScheme = (cfBundleURLTypes) => {
|
|
1711
|
+
if (!Array.isArray(cfBundleURLTypes)) {
|
|
1712
|
+
return void 0;
|
|
1713
|
+
}
|
|
1714
|
+
for (const entry of cfBundleURLTypes) {
|
|
1715
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
const scheme = firstConfiguredScheme(entry.CFBundleURLSchemes);
|
|
1719
|
+
if (scheme !== void 0) {
|
|
1720
|
+
return scheme;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
return void 0;
|
|
1724
|
+
};
|
|
1725
|
+
var walkForFilename = async (root, filename, maxDepth) => {
|
|
1726
|
+
const hits = [];
|
|
1727
|
+
let visited = 0;
|
|
1728
|
+
const walk = async (dir, depth) => {
|
|
1729
|
+
let entries;
|
|
1730
|
+
try {
|
|
1731
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1732
|
+
} catch {
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
for (const entry of entries) {
|
|
1736
|
+
if (visited >= MAX_WALK_ENTRIES) {
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
visited += 1;
|
|
1740
|
+
if (entry.isSymbolicLink()) {
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
if (entry.isDirectory()) {
|
|
1744
|
+
if (EXCLUDED_WALK_DIRS.has(entry.name)) {
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
if (depth < maxDepth) {
|
|
1748
|
+
await walk(join5(dir, entry.name), depth + 1);
|
|
1749
|
+
}
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
if (entry.isFile() && entry.name === filename) {
|
|
1753
|
+
hits.push({ path: join5(dir, entry.name), depth });
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
};
|
|
1757
|
+
await walk(root, 0);
|
|
1758
|
+
hits.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path));
|
|
1759
|
+
return hits;
|
|
1760
|
+
};
|
|
1761
|
+
var probeIosInfoPlist = async (root) => {
|
|
1762
|
+
const files = await walkForFilename(root, "Info.plist", MAX_PLIST_WALK_DEPTH);
|
|
1763
|
+
const describeSearch = (notes2) => {
|
|
1764
|
+
const base = files.length === 0 ? `Info.plist up to ${MAX_PLIST_WALK_DEPTH} levels below ${root} (excluding Pods/build/node_modules/DerivedData) \u2014 none found` : `${files.map((file) => file.path).join(", ")} (first "CFBundleURLSchemes" entry)`;
|
|
1765
|
+
return notes2.length === 0 ? base : `${base} \u2014 ${notes2.join(", ")}`;
|
|
1766
|
+
};
|
|
1767
|
+
const notes = [];
|
|
1768
|
+
for (const file of files) {
|
|
1769
|
+
const read = await readCappedBuffer(file.path);
|
|
1770
|
+
if (!read.ok) {
|
|
1771
|
+
notes.push(
|
|
1772
|
+
`${file.path} (${read.reason === "too-large" ? "too large, skipped" : "unreadable"})`
|
|
1773
|
+
);
|
|
1774
|
+
continue;
|
|
1775
|
+
}
|
|
1776
|
+
if (isBinaryPlist(read.data)) {
|
|
1777
|
+
notes.push(`${file.path} (binary plist \u2014 unreadable, use --scheme)`);
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
const value = parsePlistXml(read.data.toString("utf8"));
|
|
1781
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1782
|
+
continue;
|
|
1783
|
+
}
|
|
1784
|
+
const scheme = firstPlistUrlScheme(value.CFBundleURLTypes);
|
|
1785
|
+
if (scheme === void 0) {
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
requireValidNativeScheme(scheme, `"CFBundleURLSchemes" in ${file.path}`);
|
|
1789
|
+
return {
|
|
1790
|
+
result: {
|
|
1791
|
+
source: "ios-info-plist",
|
|
1792
|
+
scheme,
|
|
1793
|
+
origin: `"CFBundleURLSchemes" in ${file.path}`
|
|
1794
|
+
},
|
|
1795
|
+
tried: [describeSearch(notes)]
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
return { tried: [describeSearch(notes)] };
|
|
1799
|
+
};
|
|
1800
|
+
var PROJECT_YML_SCHEME_PATTERN = /CFBundleURLSchemes\s*:\s*(?:\[\s*["']?([^"'\],\r\n]*)["']?|\r?\n[ \t]*-[ \t]*["']?([^"'\r\n]*)["']?)/u;
|
|
1801
|
+
var extractProjectYmlScheme = (text) => {
|
|
1802
|
+
const match = PROJECT_YML_SCHEME_PATTERN.exec(text);
|
|
1803
|
+
const raw = (match?.[1] ?? match?.[2])?.trim();
|
|
1804
|
+
return raw !== void 0 && raw.length > 0 ? raw : void 0;
|
|
1805
|
+
};
|
|
1806
|
+
var probeIosProjectYml = async (root) => {
|
|
1807
|
+
const path2 = join5(root, "project.yml");
|
|
1808
|
+
const description = `${path2} ("info.properties.CFBundleURLTypes" > "CFBundleURLSchemes")`;
|
|
1809
|
+
const read = await readCappedText(path2);
|
|
1810
|
+
if (!read.ok) {
|
|
1811
|
+
return {
|
|
1812
|
+
tried: [read.reason === "too-large" ? `${description} \u2014 too large, skipped` : description]
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
const scheme = extractProjectYmlScheme(read.text);
|
|
1816
|
+
if (scheme === void 0) {
|
|
1817
|
+
return { tried: [description] };
|
|
1818
|
+
}
|
|
1819
|
+
requireValidNativeScheme(scheme, `"CFBundleURLSchemes" in ${path2}`);
|
|
1820
|
+
return {
|
|
1821
|
+
result: { source: "ios-project-yml", scheme, origin: `"CFBundleURLSchemes" in ${path2}` },
|
|
1822
|
+
tried: [description]
|
|
1823
|
+
};
|
|
1824
|
+
};
|
|
1825
|
+
var PROBES = [
|
|
1826
|
+
probeAndroidGradle,
|
|
1827
|
+
probeAndroidManifest,
|
|
1828
|
+
probeIosInfoPlist,
|
|
1829
|
+
probeIosProjectYml
|
|
1830
|
+
];
|
|
1831
|
+
var discoverNativeScheme = async (root) => {
|
|
1832
|
+
const tried = [];
|
|
1833
|
+
const results = [];
|
|
1834
|
+
for (const probe of PROBES) {
|
|
1835
|
+
const outcome = await probe(root);
|
|
1836
|
+
tried.push(...outcome.tried);
|
|
1837
|
+
if (outcome.result !== void 0) {
|
|
1838
|
+
results.push(outcome.result);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
if (results.length === 0) {
|
|
1842
|
+
return { tried };
|
|
1843
|
+
}
|
|
1844
|
+
const distinctSchemes = new Set(results.map((entry) => entry.scheme));
|
|
1845
|
+
if (distinctSchemes.size > 1) {
|
|
1846
|
+
throw usageError(
|
|
1847
|
+
"Conflicting deep-link schemes found in this project: " + results.map((entry) => `${entry.origin} says ${JSON.stringify(entry.scheme)}`).join(", but ") + ". Appduct will not guess which one is right \u2014 pass --scheme <scheme> to pick one."
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
return { result: results[0], tried };
|
|
1851
|
+
};
|
|
1852
|
+
|
|
1853
|
+
// src/scheme.ts
|
|
1854
|
+
var PROJECT_CONFIG_DIR = ".appduct";
|
|
1855
|
+
var PROJECT_CONFIG_FILENAME = "config.json";
|
|
1856
|
+
var PROJECT_CONFIG_RELATIVE_PATH = join6(PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME);
|
|
1857
|
+
var APP_JSON_FILENAME = "app.json";
|
|
1858
|
+
var SCHEME_ENV_VAR = "APPDUCT_SCHEME";
|
|
1859
|
+
var SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*$/u;
|
|
1860
|
+
var isValidScheme = (value) => SCHEME_PATTERN.test(value);
|
|
1861
|
+
var requireValidScheme = (value, origin) => {
|
|
1862
|
+
if (!isValidScheme(value)) {
|
|
1863
|
+
throw usageError(
|
|
1864
|
+
`Invalid deep-link scheme ${JSON.stringify(value)} from ${origin}: a scheme must start with a letter and contain only letters, digits, "+", "-" or "." (for example "myapp") \u2014 do not include "://".`
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
return value;
|
|
1868
|
+
};
|
|
1869
|
+
var isPlainObject = (value) => {
|
|
1870
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1871
|
+
};
|
|
1872
|
+
var readJsonFile = async (path2) => {
|
|
1873
|
+
let text;
|
|
1874
|
+
try {
|
|
1875
|
+
text = await readFile6(path2, "utf8");
|
|
1876
|
+
} catch (error) {
|
|
1877
|
+
const code = error.code;
|
|
1878
|
+
if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR") {
|
|
1879
|
+
return void 0;
|
|
1880
|
+
}
|
|
1881
|
+
throw error;
|
|
1882
|
+
}
|
|
1883
|
+
try {
|
|
1884
|
+
return { raw: JSON.parse(text) };
|
|
1885
|
+
} catch (error) {
|
|
1886
|
+
throw usageError(`Could not parse ${path2}: ${error.message}`);
|
|
1887
|
+
}
|
|
1888
|
+
};
|
|
1889
|
+
var firstConfiguredScheme = (value) => {
|
|
1890
|
+
if (Array.isArray(value)) {
|
|
1891
|
+
return value.find((entry) => typeof entry === "string" && entry.length > 0);
|
|
1892
|
+
}
|
|
1893
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1894
|
+
};
|
|
1895
|
+
var discoverExpoScheme = async (dir) => {
|
|
1896
|
+
const path2 = join6(resolve(dir), APP_JSON_FILENAME);
|
|
1897
|
+
const file = await readJsonFile(path2);
|
|
1898
|
+
if (!file || !isPlainObject(file.raw)) {
|
|
1899
|
+
return void 0;
|
|
1900
|
+
}
|
|
1901
|
+
const expo = file.raw.expo;
|
|
1902
|
+
if (!isPlainObject(expo)) {
|
|
1903
|
+
return void 0;
|
|
1904
|
+
}
|
|
1905
|
+
const scheme = firstConfiguredScheme(expo.scheme);
|
|
1906
|
+
return scheme === void 0 ? void 0 : requireValidScheme(scheme, `"expo.scheme" in ${path2}`);
|
|
1907
|
+
};
|
|
1908
|
+
var discoverStaticProjectScheme = async (cwd) => {
|
|
1909
|
+
const appJsonPath = join6(resolve(cwd), APP_JSON_FILENAME);
|
|
1910
|
+
const tried = [`${appJsonPath} ("expo.scheme")`];
|
|
1911
|
+
const appJsonScheme = await discoverExpoScheme(cwd);
|
|
1912
|
+
if (appJsonScheme !== void 0) {
|
|
1913
|
+
return { scheme: appJsonScheme, source: "app-json", origin: appJsonPath, tried };
|
|
1914
|
+
}
|
|
1915
|
+
const native = await discoverNativeScheme(resolve(cwd));
|
|
1916
|
+
tried.push(...native.tried);
|
|
1917
|
+
if (native.result !== void 0) {
|
|
1918
|
+
return {
|
|
1919
|
+
scheme: native.result.scheme,
|
|
1920
|
+
source: native.result.source,
|
|
1921
|
+
origin: native.result.origin,
|
|
1922
|
+
tried
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
return { tried };
|
|
1926
|
+
};
|
|
1927
|
+
var globalConfigDirs = (options = {}) => {
|
|
1928
|
+
const excluded = /* @__PURE__ */ new Set([join6(resolve(options.homeDir ?? homedir()), PROJECT_CONFIG_DIR)]);
|
|
1929
|
+
if (options.stateDirRoot !== void 0) {
|
|
1930
|
+
excluded.add(resolve(options.stateDirRoot));
|
|
1931
|
+
}
|
|
1932
|
+
return excluded;
|
|
1933
|
+
};
|
|
1934
|
+
var findProjectConfigs = (startDir, options = {}) => {
|
|
1935
|
+
const excluded = globalConfigDirs(options);
|
|
1936
|
+
const found = [];
|
|
1937
|
+
let dir = resolve(startDir);
|
|
1938
|
+
for (; ; ) {
|
|
1939
|
+
const projectDir = join6(dir, PROJECT_CONFIG_DIR);
|
|
1940
|
+
if (!excluded.has(projectDir)) {
|
|
1941
|
+
const candidate = join6(projectDir, PROJECT_CONFIG_FILENAME);
|
|
1942
|
+
if (existsSync2(candidate)) {
|
|
1943
|
+
found.push(candidate);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
const parent = dirname2(dir);
|
|
1947
|
+
if (parent === dir) {
|
|
1948
|
+
return found;
|
|
1949
|
+
}
|
|
1950
|
+
dir = parent;
|
|
1951
|
+
}
|
|
1952
|
+
};
|
|
1953
|
+
var readProjectConfigScheme = async (path2) => {
|
|
1954
|
+
const file = await readJsonFile(path2);
|
|
1955
|
+
if (!file) {
|
|
1956
|
+
return void 0;
|
|
1957
|
+
}
|
|
1958
|
+
if (!isPlainObject(file.raw)) {
|
|
1959
|
+
throw usageError(`Invalid Appduct project config at ${path2}: must be a JSON object.`);
|
|
1960
|
+
}
|
|
1961
|
+
const scheme = file.raw.scheme;
|
|
1962
|
+
if (scheme === void 0) {
|
|
1963
|
+
return void 0;
|
|
1964
|
+
}
|
|
1965
|
+
if (typeof scheme !== "string" || scheme.length === 0) {
|
|
1966
|
+
throw usageError(
|
|
1967
|
+
`Invalid Appduct project config at ${path2}: "scheme" must be a non-empty string.`
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
return requireValidScheme(scheme, `"scheme" in ${path2}`);
|
|
1971
|
+
};
|
|
1972
|
+
var APP_ID_PLATFORMS = /* @__PURE__ */ new Set(["ios", "android"]);
|
|
1973
|
+
var readProjectConfigAppId = async (path2, platform) => {
|
|
1974
|
+
const file = await readJsonFile(path2);
|
|
1975
|
+
if (!file) {
|
|
1976
|
+
return void 0;
|
|
1977
|
+
}
|
|
1978
|
+
if (!isPlainObject(file.raw)) {
|
|
1979
|
+
throw usageError(`Invalid Appduct project config at ${path2}: must be a JSON object.`);
|
|
1980
|
+
}
|
|
1981
|
+
const appId = file.raw.appId;
|
|
1982
|
+
if (appId === void 0) {
|
|
1983
|
+
return void 0;
|
|
1984
|
+
}
|
|
1985
|
+
if (!isPlainObject(appId)) {
|
|
1986
|
+
throw usageError(
|
|
1987
|
+
`Invalid Appduct project config at ${path2}: "appId" must be an object with "ios" and/or "android" keys.`
|
|
1988
|
+
);
|
|
1989
|
+
}
|
|
1990
|
+
for (const key of Object.keys(appId)) {
|
|
1991
|
+
if (!APP_ID_PLATFORMS.has(key)) {
|
|
1992
|
+
throw usageError(
|
|
1993
|
+
`Invalid Appduct project config at ${path2}: unknown key "appId.${key}" (expected "ios" and/or "android").`
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
const value = appId[platform];
|
|
1998
|
+
if (value === void 0) {
|
|
1999
|
+
return void 0;
|
|
2000
|
+
}
|
|
2001
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
2002
|
+
throw usageError(
|
|
2003
|
+
`Invalid Appduct project config at ${path2}: "appId.${platform}" must be a non-empty string.`
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
return value;
|
|
2007
|
+
};
|
|
2008
|
+
var resolveScheme = async (options = {}) => {
|
|
2009
|
+
const env = options.env ?? process.env;
|
|
2010
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
2011
|
+
const tried = [];
|
|
2012
|
+
tried.push("the --scheme flag");
|
|
2013
|
+
if (options.flagScheme !== void 0 && options.flagScheme.length > 0) {
|
|
2014
|
+
return {
|
|
2015
|
+
scheme: requireValidScheme(options.flagScheme, "the --scheme flag"),
|
|
2016
|
+
source: "flag",
|
|
2017
|
+
tried
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
tried.push(`the ${SCHEME_ENV_VAR} environment variable`);
|
|
2021
|
+
const envScheme = env[SCHEME_ENV_VAR];
|
|
2022
|
+
if (typeof envScheme === "string" && envScheme.length > 0) {
|
|
2023
|
+
return {
|
|
2024
|
+
scheme: requireValidScheme(envScheme, `the ${SCHEME_ENV_VAR} environment variable`),
|
|
2025
|
+
source: "env",
|
|
2026
|
+
tried
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
const projectConfigPaths = findProjectConfigs(cwd, {
|
|
2030
|
+
stateDirRoot: options.stateDirRoot,
|
|
2031
|
+
homeDir: options.homeDir
|
|
2032
|
+
});
|
|
2033
|
+
tried.push(
|
|
2034
|
+
projectConfigPaths.length === 0 ? `${PROJECT_CONFIG_RELATIVE_PATH} (searched upwards from ${cwd})` : projectConfigPaths.join(", ")
|
|
2035
|
+
);
|
|
2036
|
+
for (const projectConfigPath of projectConfigPaths) {
|
|
2037
|
+
const projectScheme = await readProjectConfigScheme(projectConfigPath);
|
|
2038
|
+
if (projectScheme !== void 0) {
|
|
2039
|
+
return { scheme: projectScheme, source: "project-config", tried };
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
tried.push(
|
|
2043
|
+
options.stateConfigPath === void 0 ? `the state directory's config.json ("scheme")` : `${options.stateConfigPath} ("scheme")`
|
|
2044
|
+
);
|
|
2045
|
+
if (options.configScheme !== void 0 && options.configScheme.length > 0) {
|
|
2046
|
+
return {
|
|
2047
|
+
scheme: requireValidScheme(
|
|
2048
|
+
options.configScheme,
|
|
2049
|
+
options.stateConfigPath === void 0 ? `the state directory's config.json "scheme"` : `"scheme" in ${options.stateConfigPath}`
|
|
2050
|
+
),
|
|
2051
|
+
source: "state-config",
|
|
2052
|
+
tried
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
const discovered = await discoverStaticProjectScheme(cwd);
|
|
2056
|
+
tried.push(...discovered.tried);
|
|
2057
|
+
if (discovered.scheme !== void 0) {
|
|
2058
|
+
return { scheme: discovered.scheme, source: discovered.source, tried };
|
|
2059
|
+
}
|
|
2060
|
+
return { tried };
|
|
2061
|
+
};
|
|
2062
|
+
var describeMissingScheme = (tried) => {
|
|
2063
|
+
const locations = tried.length === 0 ? "" : `Looked in, in order:
|
|
2064
|
+
${tried.map((location, index) => ` ${index + 1}. ${location}`).join("\n")}
|
|
2065
|
+
`;
|
|
2066
|
+
return "A deep-link scheme is required to compose the link, and none was found. " + locations + `Set one with --scheme <scheme>, ${SCHEME_ENV_VAR}=<scheme>, "expo.scheme" in ${APP_JSON_FILENAME}, or run \`appduct init\` in your app root to write ${PROJECT_CONFIG_RELATIVE_PATH}.`;
|
|
2067
|
+
};
|
|
2068
|
+
var resolveAppId = async (options) => {
|
|
2069
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
2070
|
+
const tried = [];
|
|
2071
|
+
tried.push("the --app-id flag");
|
|
2072
|
+
if (options.flagAppId !== void 0 && options.flagAppId.length > 0) {
|
|
2073
|
+
return { appId: options.flagAppId, source: "flag", tried };
|
|
2074
|
+
}
|
|
2075
|
+
const projectConfigPaths = findProjectConfigs(cwd, {
|
|
2076
|
+
stateDirRoot: options.stateDirRoot,
|
|
2077
|
+
homeDir: options.homeDir
|
|
2078
|
+
});
|
|
2079
|
+
tried.push(
|
|
2080
|
+
projectConfigPaths.length === 0 ? `${PROJECT_CONFIG_RELATIVE_PATH} (searched upwards from ${cwd})` : projectConfigPaths.join(", ")
|
|
2081
|
+
);
|
|
2082
|
+
for (const projectConfigPath of projectConfigPaths) {
|
|
2083
|
+
const projectAppId = await readProjectConfigAppId(projectConfigPath, options.platform);
|
|
2084
|
+
if (projectAppId !== void 0) {
|
|
2085
|
+
return { appId: projectAppId, source: "project-config", tried };
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
return { tried };
|
|
2089
|
+
};
|
|
2090
|
+
var describeMissingAppId = (platform, tried) => {
|
|
2091
|
+
const locations = tried.length === 0 ? "" : `Looked in, in order:
|
|
2092
|
+
${tried.map((location, index) => ` ${index + 1}. ${location}`).join("\n")}
|
|
2093
|
+
`;
|
|
2094
|
+
const platformLabel = platform === "android" ? "Android" : "a physical iPhone/iPad";
|
|
2095
|
+
const initFlag = platform === "android" ? "--android-app-id" : "--ios-app-id";
|
|
2096
|
+
return `Delivering to ${platformLabel} needs the installed app's id, and none was found. ${locations}Set one with \`appduct init ${initFlag} <id>\`, --app-id <id>, or "appId.${platform}" in ${PROJECT_CONFIG_RELATIVE_PATH}.`;
|
|
2097
|
+
};
|
|
2098
|
+
|
|
2099
|
+
// src/link.ts
|
|
2100
|
+
var composeDeepLink = (scheme, result) => `${scheme}:///?appduct=${result.deepLinkPayload}&pin=${encodeURIComponent(result.pin)}`;
|
|
2101
|
+
|
|
2102
|
+
// src/qr-terminal.ts
|
|
2103
|
+
import { toQR } from "toqr";
|
|
2104
|
+
var HALF_UPPER = "\u2580";
|
|
2105
|
+
var HALF_LOWER = "\u2584";
|
|
2106
|
+
var FULL = "\u2588";
|
|
2107
|
+
var renderQrToTerminal = (content, options) => {
|
|
2108
|
+
const margin = options?.margin ?? 0;
|
|
2109
|
+
const ecLevel = options?.ecLevel ?? 1;
|
|
2110
|
+
const modules = toQR(content, ecLevel);
|
|
2111
|
+
const side = Math.sqrt(modules.length);
|
|
2112
|
+
if (!Number.isInteger(side)) {
|
|
2113
|
+
throw new Error("Invalid QR matrix size");
|
|
2114
|
+
}
|
|
2115
|
+
const dim = side + 2 * margin;
|
|
2116
|
+
const isDark = (row, col) => {
|
|
2117
|
+
if (row < margin || col < margin || row >= margin + side || col >= margin + side) {
|
|
2118
|
+
return false;
|
|
2119
|
+
}
|
|
2120
|
+
const mr = row - margin;
|
|
2121
|
+
const mc = col - margin;
|
|
2122
|
+
return modules[mr * side + mc] === 1;
|
|
2123
|
+
};
|
|
2124
|
+
const lines = [];
|
|
2125
|
+
for (let row = 0; row < dim; row += 2) {
|
|
2126
|
+
let line = "";
|
|
2127
|
+
for (let col = 0; col < dim; col++) {
|
|
2128
|
+
const top = isDark(row, col);
|
|
2129
|
+
const bottom = row + 1 < dim ? isDark(row + 1, col) : false;
|
|
2130
|
+
line += top ? bottom ? FULL : HALF_UPPER : bottom ? HALF_LOWER : " ";
|
|
2131
|
+
}
|
|
2132
|
+
lines.push(line);
|
|
2133
|
+
}
|
|
2134
|
+
return lines.join("\n");
|
|
2135
|
+
};
|
|
2136
|
+
|
|
2137
|
+
// src/mcp/connect-tool.ts
|
|
2138
|
+
var CONNECT_TOOL_NAME = "appduct_connect";
|
|
2139
|
+
var WAIT_FOR_SESSION_TOOL_NAME = "appduct_wait_for_session";
|
|
2140
|
+
var OPEN_TARGET_ADDRESS_OVERRIDE = "127.0.0.1";
|
|
2141
|
+
var DEFAULT_WAIT_TIMEOUT_MS = 12e4;
|
|
2142
|
+
var NO_TARGET = "none";
|
|
2143
|
+
var QR_INSTRUCTIONS = 'This link was NOT delivered to a device \u2014 a human has to open it. Before calling appduct_wait_for_session: show the user the "qr" field verbatim inside a fenced code block (it is pre-rendered terminal art \u2014 do not redraw, summarize, or describe it), show "deepLink" underneath it for a device that cannot scan, and ask the user to scan it with the target device. Only then call appduct_wait_for_session; it blocks until the device connects and gives no output while it waits, so calling it before you have asked will look like a hang.';
|
|
2144
|
+
var CONNECT_TOOL_DESCRIPTOR = {
|
|
2145
|
+
name: CONNECT_TOOL_NAME,
|
|
2146
|
+
description: 'Mint an Appduct session link and get it onto a device. With no "target", auto-detects a booted iOS simulator or attached Android device and delivers the link to it, returning { sessionId, delivered: true, autoDetected: true } \u2014 this is the normal agent path and needs no human. Pass target "android" or "ios-sim" (optionally with "device") to choose explicitly, or target "none" to force the human flow. Delivering to "android" (explicit or auto-detected) needs "appId" (the Android package name) \u2014 without it, more than one installed app declaring the same scheme would show an ambiguous "Open with" chooser on the device instead of failing loudly. Target "ios-device" is an experimental, never-auto-detected path that delivers to a paired physical iPhone/iPad via xcrun devicectl; it needs "appId" (the iOS bundle id), iOS 17+, Xcode 15+, a connected and trusted device with Developer Mode on, a dev-signed build installed, and the phone on the same network as this machine. An "appId" not supplied here falls back to "appId.<platform>" in the nearest .appduct/config.json (see "appduct init"); it is rejected outright with target "ios-sim" or "none", which need no app id. Pass "relaunch": true with ios-device if the app is already running and the delivery does not take. Only when no device is detected (or target is "none") does this return a QR code for a human to scan, along with an "instructions" field saying what to do with it. Follow up with appduct_wait_for_session to know when the device has connected.',
|
|
2147
|
+
inputSchema: {
|
|
2148
|
+
type: "object",
|
|
2149
|
+
properties: {
|
|
2150
|
+
target: { type: "string", enum: ["android", "ios-sim", "ios-device", NO_TARGET] },
|
|
2151
|
+
device: { type: "string" },
|
|
2152
|
+
appId: { type: "string" },
|
|
2153
|
+
relaunch: { type: "boolean" },
|
|
2154
|
+
ttlSeconds: { type: "number", exclusiveMinimum: 0 }
|
|
2155
|
+
},
|
|
2156
|
+
additionalProperties: false
|
|
2157
|
+
}
|
|
2158
|
+
};
|
|
2159
|
+
var WAIT_FOR_SESSION_TOOL_DESCRIPTOR = {
|
|
2160
|
+
name: WAIT_FOR_SESSION_TOOL_NAME,
|
|
2161
|
+
description: "Wait for a session minted by appduct_connect to be claimed by a device. Resolves as soon as the device connects (or immediately if it already has); rejects with tool_timeout if timeoutMs elapses first. If appduct_connect returned a QR instead of delivering the link, show that QR to the user and ask them to scan it before calling this \u2014 it produces no output while it waits. Once it resolves, find the app's tools with appduct_list_tools.",
|
|
2162
|
+
inputSchema: {
|
|
2163
|
+
type: "object",
|
|
2164
|
+
properties: {
|
|
2165
|
+
sessionId: { type: "string" },
|
|
2166
|
+
timeoutMs: { type: "number", exclusiveMinimum: 0 }
|
|
2167
|
+
},
|
|
2168
|
+
required: ["sessionId"],
|
|
2169
|
+
additionalProperties: false
|
|
2170
|
+
}
|
|
2171
|
+
};
|
|
2172
|
+
var McpBuiltinToolError = class extends Error {
|
|
2173
|
+
constructor(type, message) {
|
|
2174
|
+
super(message);
|
|
2175
|
+
this.type = type;
|
|
2176
|
+
this.name = "McpBuiltinToolError";
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
2179
|
+
var asRecord = (value) => {
|
|
2180
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2181
|
+
return {};
|
|
2182
|
+
}
|
|
2183
|
+
return value;
|
|
2184
|
+
};
|
|
2185
|
+
var asOptionalConnectTarget = (value) => {
|
|
2186
|
+
if (value === void 0) {
|
|
2187
|
+
return void 0;
|
|
2188
|
+
}
|
|
2189
|
+
if (typeof value !== "string" || !(isOpenTarget(value) || value === NO_TARGET)) {
|
|
2190
|
+
throw new McpBuiltinToolError(
|
|
2191
|
+
"invalid_request",
|
|
2192
|
+
'"target" must be "android", "ios-sim", "ios-device", or "none".'
|
|
2193
|
+
);
|
|
2194
|
+
}
|
|
2195
|
+
return value;
|
|
2196
|
+
};
|
|
2197
|
+
var asOptionalNonEmptyString = (value, field) => {
|
|
2198
|
+
if (value === void 0) {
|
|
2199
|
+
return void 0;
|
|
2200
|
+
}
|
|
2201
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
2202
|
+
throw new McpBuiltinToolError("invalid_request", `"${field}" must be a non-empty string.`);
|
|
2203
|
+
}
|
|
2204
|
+
return value;
|
|
2205
|
+
};
|
|
2206
|
+
var asOptionalBoolean = (value, field) => {
|
|
2207
|
+
if (value === void 0) {
|
|
2208
|
+
return void 0;
|
|
2209
|
+
}
|
|
2210
|
+
if (typeof value !== "boolean") {
|
|
2211
|
+
throw new McpBuiltinToolError("invalid_request", `"${field}" must be a boolean.`);
|
|
2212
|
+
}
|
|
2213
|
+
return value;
|
|
2214
|
+
};
|
|
2215
|
+
var asOptionalPositiveNumber = (value, field) => {
|
|
2216
|
+
if (value === void 0) {
|
|
2217
|
+
return void 0;
|
|
2218
|
+
}
|
|
2219
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
2220
|
+
throw new McpBuiltinToolError("invalid_request", `"${field}" must be a positive number.`);
|
|
2221
|
+
}
|
|
2222
|
+
return value;
|
|
2223
|
+
};
|
|
2224
|
+
var withResolvedAppId = async (base, flagAppId, deps) => {
|
|
2225
|
+
const platform = platformOf(base.target);
|
|
2226
|
+
if (platform === void 0) {
|
|
2227
|
+
return { delivery: base };
|
|
2228
|
+
}
|
|
2229
|
+
const resolved = await resolveAppId({
|
|
2230
|
+
platform,
|
|
2231
|
+
flagAppId,
|
|
2232
|
+
cwd: deps.cwd,
|
|
2233
|
+
stateDirRoot: deps.stateDirRoot
|
|
2234
|
+
});
|
|
2235
|
+
return { delivery: { ...base, appId: resolved.appId }, appIdTried: resolved.tried };
|
|
2236
|
+
};
|
|
2237
|
+
var resolveDelivery = async (requested, device, appId, deps) => {
|
|
2238
|
+
if (requested !== void 0 && requested !== NO_TARGET) {
|
|
2239
|
+
return withResolvedAppId(
|
|
2240
|
+
{ target: requested, device, label: device ?? requested, autoDetected: false },
|
|
2241
|
+
appId,
|
|
2242
|
+
deps
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
if (requested === NO_TARGET) {
|
|
2246
|
+
return {
|
|
2247
|
+
note: 'Called with target: "none", so no device delivery was attempted.'
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
const detection = await detectBootedTargets({ exec: deps.exec, env: deps.env });
|
|
2251
|
+
if (detection.kind === "single") {
|
|
2252
|
+
return withResolvedAppId({ ...detection.detected, autoDetected: true }, appId, deps);
|
|
2253
|
+
}
|
|
2254
|
+
if (detection.kind === "ambiguous") {
|
|
2255
|
+
return {
|
|
2256
|
+
note: 'No "target" was given and several devices are available (' + detection.candidates.map((candidate) => `${candidate.target}: ${candidate.label}`).join("; ") + '), so none was picked automatically. Re-call with "target" and "device" to deliver to one of them, or use the QR below.'
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
return {
|
|
2260
|
+
note: 'No "target" was given and no booted iOS simulator or attached Android device was detected, so the link could not be delivered automatically. A physical iPhone/iPad is never auto-detected: to deliver to one, re-call with target "ios-device" plus "appId" (needs iOS 17+, Xcode 15+ and a paired, trusted device with Developer Mode on) \u2014 otherwise use the QR flow below.'
|
|
2261
|
+
};
|
|
2262
|
+
};
|
|
2263
|
+
var handleConnectTool = async (rawArgs, deps) => {
|
|
2264
|
+
const args = asRecord(rawArgs);
|
|
2265
|
+
const requestedTarget = asOptionalConnectTarget(args.target);
|
|
2266
|
+
const device = asOptionalNonEmptyString(args.device, "device");
|
|
2267
|
+
const appId = asOptionalNonEmptyString(args.appId, "appId");
|
|
2268
|
+
const relaunch = asOptionalBoolean(args.relaunch, "relaunch");
|
|
2269
|
+
const ttlSeconds = asOptionalPositiveNumber(args.ttlSeconds, "ttlSeconds");
|
|
2270
|
+
if (device !== void 0 && (requestedTarget === void 0 || requestedTarget === NO_TARGET)) {
|
|
2271
|
+
throw new McpBuiltinToolError(
|
|
2272
|
+
"invalid_request",
|
|
2273
|
+
'"device" requires an explicit "target" of "android", "ios-sim" or "ios-device" \u2014 a bare serial/udid does not say which platform it belongs to.'
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
if (appId !== void 0 && (requestedTarget === "ios-sim" || requestedTarget === NO_TARGET)) {
|
|
2277
|
+
throw new McpBuiltinToolError(
|
|
2278
|
+
"invalid_request",
|
|
2279
|
+
'"appId" only applies with target "android", target "ios-device", or no target at all (auto-detection).'
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
if (relaunch !== void 0 && requestedTarget !== "ios-device") {
|
|
2283
|
+
throw new McpBuiltinToolError(
|
|
2284
|
+
"invalid_request",
|
|
2285
|
+
'"relaunch" only applies with target "ios-device".'
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
if (!deps.scheme) {
|
|
2289
|
+
throw new McpBuiltinToolError("invalid_request", describeMissingScheme(deps.schemeTried ?? []));
|
|
2290
|
+
}
|
|
2291
|
+
const scheme = deps.scheme;
|
|
2292
|
+
const { delivery, note, appIdTried } = await resolveDelivery(requestedTarget, device, appId, deps);
|
|
2293
|
+
const deliveryPlatform = delivery ? platformOf(delivery.target) : void 0;
|
|
2294
|
+
if (deliveryPlatform !== void 0 && delivery?.appId === void 0) {
|
|
2295
|
+
throw new McpBuiltinToolError(
|
|
2296
|
+
"invalid_request",
|
|
2297
|
+
describeMissingAppId(deliveryPlatform, appIdTried ?? [])
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
2300
|
+
if (delivery?.appId !== void 0 && !isValidAppId(delivery.appId)) {
|
|
2301
|
+
throw new McpBuiltinToolError("invalid_request", invalidAppIdMessage(delivery.appId));
|
|
2302
|
+
}
|
|
2303
|
+
const mint = (deliverLocally) => {
|
|
2304
|
+
return deps.call(RPC_METHODS3.linkCreate, {
|
|
2305
|
+
ttlSeconds,
|
|
2306
|
+
addressOverride: deliverLocally ? OPEN_TARGET_ADDRESS_OVERRIDE : void 0
|
|
2307
|
+
});
|
|
2308
|
+
};
|
|
2309
|
+
const asQrResult = (result2, qrNote) => {
|
|
2310
|
+
const deepLink2 = composeDeepLink(scheme, result2);
|
|
2311
|
+
return {
|
|
2312
|
+
sessionId: result2.sessionId,
|
|
2313
|
+
deepLink: deepLink2,
|
|
2314
|
+
expiresAt: result2.expiresAt,
|
|
2315
|
+
...qrNote === void 0 ? {} : { note: qrNote },
|
|
2316
|
+
qr: renderQrToTerminal(deepLink2),
|
|
2317
|
+
instructions: QR_INSTRUCTIONS
|
|
2318
|
+
};
|
|
2319
|
+
};
|
|
2320
|
+
if (!delivery) {
|
|
2321
|
+
return asQrResult(await mint(false), note);
|
|
2322
|
+
}
|
|
2323
|
+
const result = await mint(usesLoopbackAddress(delivery.target));
|
|
2324
|
+
if (delivery.target === "ios-device" && isLoopbackAddress(result.endpoint.address)) {
|
|
2325
|
+
throw new McpBuiltinToolError(
|
|
2326
|
+
"invalid_request",
|
|
2327
|
+
loopbackAddressMessage(result.endpoint.address)
|
|
2328
|
+
);
|
|
2329
|
+
}
|
|
2330
|
+
const deepLink = composeDeepLink(scheme, result);
|
|
2331
|
+
try {
|
|
2332
|
+
await deliverToOpenTarget({
|
|
2333
|
+
target: delivery.target,
|
|
2334
|
+
device: delivery.device,
|
|
2335
|
+
appId: delivery.appId,
|
|
2336
|
+
relaunch,
|
|
2337
|
+
deepLink,
|
|
2338
|
+
wssPort: result.endpoint.port,
|
|
2339
|
+
exec: deps.exec,
|
|
2340
|
+
env: deps.env
|
|
2341
|
+
});
|
|
2342
|
+
} catch (error) {
|
|
2343
|
+
const detail = error instanceof Error ? error.message : "Failed to deliver the deep link.";
|
|
2344
|
+
if (!delivery.autoDetected) {
|
|
2345
|
+
throw new McpBuiltinToolError("tool_execution_error", detail);
|
|
2346
|
+
}
|
|
2347
|
+
return asQrResult(
|
|
2348
|
+
await mint(false),
|
|
2349
|
+
`Auto-detected ${delivery.target} target ${delivery.label}, but delivering the link to it failed: ${detail}. Falling back to a QR code \u2014 pass an explicit "target" (and "device") if a different device is the right one.`
|
|
2350
|
+
);
|
|
2351
|
+
}
|
|
2352
|
+
return {
|
|
2353
|
+
sessionId: result.sessionId,
|
|
2354
|
+
deepLink,
|
|
2355
|
+
expiresAt: result.expiresAt,
|
|
2356
|
+
delivered: true,
|
|
2357
|
+
target: delivery.target,
|
|
2358
|
+
...delivery.device === void 0 ? {} : { device: delivery.device },
|
|
2359
|
+
...delivery.autoDetected ? {
|
|
2360
|
+
autoDetected: true,
|
|
2361
|
+
note: `No "target" was given; delivered to the only device found: ${delivery.label}.`
|
|
2362
|
+
} : {}
|
|
2363
|
+
};
|
|
2364
|
+
};
|
|
2365
|
+
var CONNECTION_LOST_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "EPIPE"]);
|
|
2366
|
+
var describeCause = (error) => {
|
|
2367
|
+
return error instanceof Error && error.message.length > 0 ? ` (${error.message})` : "";
|
|
2368
|
+
};
|
|
2369
|
+
var daemonClosedMessage = (sessionId) => `The connection to the Appduct daemon closed while waiting for session "${sessionId}".`;
|
|
2370
|
+
var daemonUnreachableMessage = (sessionId, error) => `The Appduct daemon is unreachable, so session "${sessionId}" cannot be waited on${describeCause(error)}.`;
|
|
2371
|
+
var isConnectionLost = (error) => {
|
|
2372
|
+
if (isDaemonUnreachableError(error)) {
|
|
2373
|
+
return true;
|
|
2374
|
+
}
|
|
2375
|
+
const code = error?.code;
|
|
2376
|
+
return typeof code === "string" && CONNECTION_LOST_CODES.has(code);
|
|
2377
|
+
};
|
|
2378
|
+
var handleWaitForSessionTool = async (rawArgs, deps) => {
|
|
2379
|
+
const args = asRecord(rawArgs);
|
|
2380
|
+
if (typeof args.sessionId !== "string" || args.sessionId.length === 0) {
|
|
2381
|
+
throw new McpBuiltinToolError("invalid_request", '"sessionId" must be a non-empty string.');
|
|
2382
|
+
}
|
|
2383
|
+
const sessionId = args.sessionId;
|
|
2384
|
+
const timeoutMs = asOptionalPositiveNumber(args.timeoutMs, "timeoutMs") ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
2385
|
+
let stream;
|
|
2386
|
+
try {
|
|
2387
|
+
stream = await openDaemonStream({ stateDir: deps.stateDir, spawn: deps.spawn });
|
|
2388
|
+
} catch (error) {
|
|
2389
|
+
if (isConnectionLost(error)) {
|
|
2390
|
+
throw new McpBuiltinToolError(
|
|
2391
|
+
"tool_execution_error",
|
|
2392
|
+
daemonUnreachableMessage(sessionId, error)
|
|
2393
|
+
);
|
|
2394
|
+
}
|
|
2395
|
+
throw error;
|
|
2396
|
+
}
|
|
2397
|
+
try {
|
|
2398
|
+
return await new Promise((resolve2, reject) => {
|
|
2399
|
+
let settled = false;
|
|
2400
|
+
const settle = (fn) => {
|
|
2401
|
+
if (settled) {
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
settled = true;
|
|
2405
|
+
clearTimeout(timer);
|
|
2406
|
+
unsubscribeNotification();
|
|
2407
|
+
unsubscribeClose();
|
|
2408
|
+
fn();
|
|
2409
|
+
};
|
|
2410
|
+
const timer = setTimeout(() => {
|
|
2411
|
+
settle(
|
|
2412
|
+
() => reject(
|
|
2413
|
+
new McpBuiltinToolError(
|
|
2414
|
+
"tool_timeout",
|
|
2415
|
+
`Timed out after ${timeoutMs}ms waiting for session "${sessionId}" to be claimed.`
|
|
2416
|
+
)
|
|
2417
|
+
)
|
|
2418
|
+
);
|
|
2419
|
+
}, timeoutMs);
|
|
2420
|
+
const connectionLost = () => new McpBuiltinToolError("tool_execution_error", daemonClosedMessage(sessionId));
|
|
2421
|
+
const unsubscribeClose = stream.onClose(() => {
|
|
2422
|
+
settle(() => reject(connectionLost()));
|
|
2423
|
+
});
|
|
2424
|
+
const unsubscribeNotification = stream.onNotification((payload) => {
|
|
2425
|
+
const event = payload;
|
|
2426
|
+
if (event.kind === "session_claimed" && event.sessionId === sessionId) {
|
|
2427
|
+
settle(() => resolve2({ sessionId, claimed: true, alias: event.alias ?? "" }));
|
|
2428
|
+
}
|
|
2429
|
+
});
|
|
2430
|
+
stream.call(RPC_METHODS3.eventsSubscribe, { sessionSelector: sessionId, kinds: ["session_claimed"] }).then(async () => {
|
|
2431
|
+
if (settled) {
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
const existing = await stream.call(RPC_METHODS3.sessionsDescribe, {
|
|
2435
|
+
selector: sessionId
|
|
2436
|
+
});
|
|
2437
|
+
settle(() => resolve2({ sessionId, claimed: true, alias: existing.alias }));
|
|
2438
|
+
}).catch((error) => {
|
|
2439
|
+
if (error instanceof DaemonRpcError && error.data?.type === "unknown_session") {
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
settle(() => reject(isConnectionLost(error) ? connectionLost() : error));
|
|
2443
|
+
});
|
|
2444
|
+
});
|
|
2445
|
+
} finally {
|
|
2446
|
+
stream.close();
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
|
|
2450
|
+
// src/mcp/app-tools.ts
|
|
2451
|
+
var LIST_TOOLS_TOOL_NAME = "appduct_list_tools";
|
|
2452
|
+
var DESCRIBE_TOOL_TOOL_NAME = "appduct_describe_tool";
|
|
2453
|
+
var CALL_TOOL_TOOL_NAME = "appduct_call_tool";
|
|
2454
|
+
var DEFAULT_LIST_TOOLS_LIMIT = 50;
|
|
2455
|
+
var SELECTOR_PROPERTY = {
|
|
2456
|
+
type: "string",
|
|
2457
|
+
minLength: 1,
|
|
2458
|
+
description: "Session alias or id. Omit to target the sole active/suspended session."
|
|
2459
|
+
};
|
|
2460
|
+
var NAME_PROPERTY = { type: "string", minLength: 1 };
|
|
2461
|
+
var LIST_TOOLS_TOOL_DESCRIPTOR = {
|
|
2462
|
+
name: LIST_TOOLS_TOOL_NAME,
|
|
2463
|
+
description: `List the tools the connected app registered, as one-line signatures (\`name(param: type, optional?: type) -> result\`) with the first line of each description, the tool's group and its effective policy. Start here: the app's tools are not MCP tools of their own. Every result also carries groups: the app's tool groups with counts, over all its tools. On a large app, list one area with group, copying the exact name from groups, parent path included ("diagnostics/progress", not "progress"); "checkout" includes "checkout/payment". filter is a case-insensitive substring match on name and description only, never on group names; limit (default ${DEFAULT_LIST_TOOLS_LIMIT}) and offset page the name-sorted list, and total counts every match before paging, so page on with offset when total is larger. Use appduct_describe_tool for one tool's full input/output schema, then appduct_call_tool to run it. A tool with policy "prompt" asks the user to approve each call; one with policy "deny" cannot be called.`,
|
|
2464
|
+
inputSchema: {
|
|
2465
|
+
type: "object",
|
|
2466
|
+
properties: {
|
|
2467
|
+
selector: SELECTOR_PROPERTY,
|
|
2468
|
+
group: { type: "string", pattern: TOOL_GROUP_PATTERN.source },
|
|
2469
|
+
filter: { type: "string", maxLength: MAX_TOOLS_FILTER_LENGTH },
|
|
2470
|
+
limit: { type: "integer", exclusiveMinimum: 0 },
|
|
2471
|
+
offset: { type: "integer", minimum: 0 }
|
|
2472
|
+
},
|
|
2473
|
+
additionalProperties: false
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
var DESCRIBE_TOOL_TOOL_DESCRIPTOR = {
|
|
2477
|
+
name: DESCRIBE_TOOL_TOOL_NAME,
|
|
2478
|
+
description: "Show one of the connected app's tools in full: description, group, input_schema (JSON Schema for appduct_call_tool's args), output_schema, annotations, timeout and effective policy. Find names with appduct_list_tools.",
|
|
2479
|
+
inputSchema: {
|
|
2480
|
+
type: "object",
|
|
2481
|
+
properties: {
|
|
2482
|
+
selector: SELECTOR_PROPERTY,
|
|
2483
|
+
name: NAME_PROPERTY
|
|
2484
|
+
},
|
|
2485
|
+
required: ["name"],
|
|
2486
|
+
additionalProperties: false
|
|
2487
|
+
}
|
|
2488
|
+
};
|
|
2489
|
+
var CALL_TOOL_TOOL_DESCRIPTOR = {
|
|
2490
|
+
name: CALL_TOOL_TOOL_NAME,
|
|
2491
|
+
description: `Call one of the connected app's tools by name. args must match the tool's input_schema (see appduct_describe_tool); omit it for a tool that takes no input. The call runs under the tool's own deadline (timeout_ms in appduct_describe_tool, 10000 ms if it declares none); timeoutMs can only shorten it, since the app stops the tool at its own deadline. Returns the tool's result as JSON. A tool with policy "prompt" asks the user to approve the call first, and fails if they decline or this client cannot ask.`,
|
|
2492
|
+
inputSchema: {
|
|
2493
|
+
type: "object",
|
|
2494
|
+
properties: {
|
|
2495
|
+
selector: SELECTOR_PROPERTY,
|
|
2496
|
+
name: NAME_PROPERTY,
|
|
2497
|
+
args: { type: "object" },
|
|
2498
|
+
timeoutMs: { type: "integer", minimum: MIN_TOOL_TIMEOUT_MS2, maximum: MAX_TOOL_TIMEOUT_MS2 }
|
|
2499
|
+
},
|
|
2500
|
+
required: ["name"],
|
|
2501
|
+
additionalProperties: false
|
|
2502
|
+
}
|
|
2503
|
+
};
|
|
2504
|
+
var asRecord2 = (value) => {
|
|
2505
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2506
|
+
return {};
|
|
2507
|
+
}
|
|
2508
|
+
return value;
|
|
2509
|
+
};
|
|
2510
|
+
var rejectUnknownKeys = (args, tool, allowed) => {
|
|
2511
|
+
const unknown = Object.keys(args).filter((key) => !allowed.includes(key));
|
|
2512
|
+
if (unknown.length > 0) {
|
|
2513
|
+
throw new McpBuiltinToolError(
|
|
2514
|
+
"invalid_request",
|
|
2515
|
+
`${tool} does not take ${unknown.map((key) => `"${key}"`).join(", ")}. It takes: ${allowed.join(", ")}.`
|
|
2516
|
+
);
|
|
2517
|
+
}
|
|
2518
|
+
};
|
|
2519
|
+
var asOptionalString = (value, field) => {
|
|
2520
|
+
if (value === void 0 || value === null) {
|
|
2521
|
+
return void 0;
|
|
2522
|
+
}
|
|
2523
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
2524
|
+
throw new McpBuiltinToolError("invalid_request", `"${field}" must be a non-empty string.`);
|
|
2525
|
+
}
|
|
2526
|
+
return value;
|
|
2527
|
+
};
|
|
2528
|
+
var asRequiredString = (value, field) => {
|
|
2529
|
+
const parsed = asOptionalString(value, field);
|
|
2530
|
+
if (parsed === void 0) {
|
|
2531
|
+
throw new McpBuiltinToolError("invalid_request", `"${field}" is required.`);
|
|
2532
|
+
}
|
|
2533
|
+
return parsed;
|
|
2534
|
+
};
|
|
2535
|
+
var toDescriptor = (entry) => {
|
|
2536
|
+
return {
|
|
2537
|
+
name: entry.name,
|
|
2538
|
+
description: entry.description,
|
|
2539
|
+
input_schema: entry.input_schema,
|
|
2540
|
+
output_schema: entry.output_schema,
|
|
2541
|
+
annotations: entry.annotations,
|
|
2542
|
+
timeout_ms: entry.timeout_ms,
|
|
2543
|
+
// Normalised the same way the listing normalises it, so `appduct_describe_tool` never
|
|
2544
|
+
// disagrees with `appduct_list_tools` about whether a tool has a group. The `?? null` also
|
|
2545
|
+
// covers a daemon that predates groups and omits the key entirely.
|
|
2546
|
+
group: entry.group ?? null
|
|
2547
|
+
};
|
|
2548
|
+
};
|
|
2549
|
+
var resolveSession = async (call, selector) => {
|
|
2550
|
+
const session = await call(RPC_METHODS4.sessionsDescribe, { selector });
|
|
2551
|
+
return { sessionId: session.sessionId, alias: session.alias };
|
|
2552
|
+
};
|
|
2553
|
+
var findTool = async (call, selector, name) => {
|
|
2554
|
+
const session = await resolveSession(call, selector);
|
|
2555
|
+
const { tools } = await call(RPC_METHODS4.toolsList, { selector: session.sessionId });
|
|
2556
|
+
const entry = tools.find((tool) => tool.name === name);
|
|
2557
|
+
if (!entry) {
|
|
2558
|
+
throw new McpBuiltinToolError(
|
|
2559
|
+
"tool_not_found",
|
|
2560
|
+
`Tool "${name}" is not registered on session "${session.alias}". Use ${LIST_TOOLS_TOOL_NAME} (optionally with filter) to find it.`
|
|
2561
|
+
);
|
|
2562
|
+
}
|
|
2563
|
+
return { session, entry };
|
|
2564
|
+
};
|
|
2565
|
+
var handleListToolsTool = async (rawArgs, call) => {
|
|
2566
|
+
const args = asRecord2(rawArgs);
|
|
2567
|
+
rejectUnknownKeys(args, LIST_TOOLS_TOOL_NAME, ["selector", "group", "filter", "limit", "offset"]);
|
|
2568
|
+
const selector = asOptionalString(args.selector, "selector");
|
|
2569
|
+
const session = await resolveSession(call, selector);
|
|
2570
|
+
const params = {
|
|
2571
|
+
...args.group !== void 0 && args.group !== null ? { group: args.group } : {},
|
|
2572
|
+
...args.filter !== void 0 && args.filter !== null ? { filter: args.filter } : {},
|
|
2573
|
+
limit: args.limit ?? DEFAULT_LIST_TOOLS_LIMIT,
|
|
2574
|
+
...args.offset !== void 0 && args.offset !== null ? { offset: args.offset } : {}
|
|
2575
|
+
};
|
|
2576
|
+
const result = await call(RPC_METHODS4.toolsList, { selector: session.sessionId, ...params });
|
|
2577
|
+
if (!Array.isArray(result.groups) && "group" in params) {
|
|
2578
|
+
throw new McpBuiltinToolError(
|
|
2579
|
+
"connection_error",
|
|
2580
|
+
"The running Appduct daemon does not support tool groups. Restart it with a newer version (`appduct daemon stop`), then restart this MCP server."
|
|
2581
|
+
);
|
|
2582
|
+
}
|
|
2583
|
+
return {
|
|
2584
|
+
session: session.alias,
|
|
2585
|
+
total: result.total,
|
|
2586
|
+
...params,
|
|
2587
|
+
tools: result.tools.map((entry) => ({
|
|
2588
|
+
name: entry.name,
|
|
2589
|
+
signature: renderToolSignature(entry),
|
|
2590
|
+
summary: summarizeToolDescription(entry.description),
|
|
2591
|
+
// Always present: `null` for an ungrouped tool, mirroring `groups`. The `?? null` also covers
|
|
2592
|
+
// a daemon that predates groups and omits the key from its entries entirely.
|
|
2593
|
+
group: entry.group ?? null,
|
|
2594
|
+
policy: entry.policy,
|
|
2595
|
+
...entry.annotations ? { annotations: entry.annotations } : {}
|
|
2596
|
+
})),
|
|
2597
|
+
...Array.isArray(result.groups) ? { groups: result.groups } : {}
|
|
2598
|
+
};
|
|
2599
|
+
};
|
|
2600
|
+
var handleDescribeToolTool = async (rawArgs, call) => {
|
|
2601
|
+
const args = asRecord2(rawArgs);
|
|
2602
|
+
rejectUnknownKeys(args, DESCRIBE_TOOL_TOOL_NAME, ["selector", "name"]);
|
|
2603
|
+
const selector = asOptionalString(args.selector, "selector");
|
|
2604
|
+
const name = asRequiredString(args.name, "name");
|
|
2605
|
+
const { session, entry } = await findTool(call, selector, name);
|
|
2606
|
+
return {
|
|
2607
|
+
session: session.alias,
|
|
2608
|
+
signature: renderToolSignature(entry),
|
|
2609
|
+
policy: entry.policy,
|
|
2610
|
+
...toDescriptor(entry)
|
|
2611
|
+
};
|
|
2612
|
+
};
|
|
2613
|
+
var parseCallToolArgs = (rawArgs) => {
|
|
2614
|
+
const args = asRecord2(rawArgs);
|
|
2615
|
+
rejectUnknownKeys(args, CALL_TOOL_TOOL_NAME, ["selector", "name", "args", "timeoutMs"]);
|
|
2616
|
+
const selector = asOptionalString(args.selector, "selector");
|
|
2617
|
+
const name = asRequiredString(args.name, "name");
|
|
2618
|
+
const toolArgs = args.args ?? {};
|
|
2619
|
+
if (typeof toolArgs !== "object" || Array.isArray(toolArgs)) {
|
|
2620
|
+
throw new McpBuiltinToolError("invalid_request", '"args" must be an object.');
|
|
2621
|
+
}
|
|
2622
|
+
const timeoutMs = args.timeoutMs ?? void 0;
|
|
2623
|
+
if (timeoutMs !== void 0 && (typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs) || timeoutMs < MIN_TOOL_TIMEOUT_MS2 || timeoutMs > MAX_TOOL_TIMEOUT_MS2)) {
|
|
2624
|
+
throw new McpBuiltinToolError(
|
|
2625
|
+
"invalid_request",
|
|
2626
|
+
`"timeoutMs" must be an integer number of milliseconds from ${MIN_TOOL_TIMEOUT_MS2} to ${MAX_TOOL_TIMEOUT_MS2}.`
|
|
2627
|
+
);
|
|
2628
|
+
}
|
|
2629
|
+
return {
|
|
2630
|
+
selector,
|
|
2631
|
+
name,
|
|
2632
|
+
args: toolArgs,
|
|
2633
|
+
timeoutMs
|
|
2634
|
+
};
|
|
2635
|
+
};
|
|
2636
|
+
var resolveCallDeadline = (tool, requestedTimeoutMs) => {
|
|
2637
|
+
const toolDeadline = clampTimeout(tool.descriptor.timeout_ms);
|
|
2638
|
+
if (requestedTimeoutMs !== void 0 && requestedTimeoutMs > toolDeadline) {
|
|
2639
|
+
throw new McpBuiltinToolError(
|
|
2640
|
+
"invalid_request",
|
|
2641
|
+
`"timeoutMs" can only shorten "${tool.descriptor.name}"'s own deadline of ${toolDeadline} ms: the app stops the tool at that deadline. Raising it takes a larger timeoutMs in the tool's registration.`
|
|
2642
|
+
);
|
|
2643
|
+
}
|
|
2644
|
+
return requestedTimeoutMs ?? toolDeadline;
|
|
2645
|
+
};
|
|
2646
|
+
var resolveAppTool = async (call, selector, name) => {
|
|
2647
|
+
const { session, entry } = await findTool(call, selector, name);
|
|
2648
|
+
return { ...session, descriptor: toDescriptor(entry), policy: entry.policy };
|
|
2649
|
+
};
|
|
2650
|
+
|
|
2651
|
+
// src/mcp/events-tool.ts
|
|
2652
|
+
import {
|
|
2653
|
+
RPC_METHODS as RPC_METHODS5,
|
|
2654
|
+
EVENT_KINDS
|
|
2655
|
+
} from "@appduct/shared";
|
|
2656
|
+
var EVENTS_TOOL_NAME = "appduct_events";
|
|
2657
|
+
var WAIT_FOR_EVENT_TOOL_NAME = "appduct_wait_for_event";
|
|
2658
|
+
var DEFAULT_WAIT_FOR_EVENT_TIMEOUT_MS = 12e4;
|
|
2659
|
+
var MAX_WAIT_FOR_EVENT_TIMEOUT_MS = 25 * 60 * 1e3;
|
|
2660
|
+
var WAIT_FOR_EVENT_PROGRESS_INTERVAL_MS = 6e4;
|
|
2661
|
+
var KNOWN_EVENT_KINDS = new Set(EVENT_KINDS);
|
|
2662
|
+
var clampWaitForEventTimeoutMs = (requestedMs) => {
|
|
2663
|
+
return Math.min(requestedMs ?? DEFAULT_WAIT_FOR_EVENT_TIMEOUT_MS, MAX_WAIT_FOR_EVENT_TIMEOUT_MS);
|
|
2664
|
+
};
|
|
2665
|
+
var EVENTS_TOOL_DESCRIPTOR = {
|
|
2666
|
+
name: EVENTS_TOOL_NAME,
|
|
2667
|
+
description: "Drain events retained in the daemon's per-session ring buffer since a cursor \u2014 the pull counterpart to a live subscription, for checking what happened after calling a tool or triggering app behavior. With no selector, targets the sole active/suspended session. Returns { events, cursor }; pass cursor back as since on the next call to avoid re-reading events you've already seen. limit (if given) keeps the OLDEST events in the window and advances cursor only past what was actually returned, so repeated calls page forward through everything retained rather than skipping ahead.",
|
|
2668
|
+
inputSchema: {
|
|
2669
|
+
type: "object",
|
|
2670
|
+
properties: {
|
|
2671
|
+
selector: { type: "string" },
|
|
2672
|
+
since: { type: "integer", minimum: 0 },
|
|
2673
|
+
kinds: { type: "array", items: { type: "string", enum: EVENT_KINDS } },
|
|
2674
|
+
limit: { type: "integer", exclusiveMinimum: 0 }
|
|
2675
|
+
},
|
|
2676
|
+
additionalProperties: false
|
|
2677
|
+
}
|
|
2678
|
+
};
|
|
2679
|
+
var WAIT_FOR_EVENT_TOOL_DESCRIPTOR = {
|
|
2680
|
+
name: WAIT_FOR_EVENT_TOOL_NAME,
|
|
2681
|
+
description: "Wait for the connected app to push an event via postEvent(name, payload) matching name (and, if match is given, every one of its keys strictly-equal \u2014 primitives only, not objects/arrays \u2014 in the event's payload). Checks already-retained events first, so an event that already fired before this call still resolves immediately; pass since (a cursor from a previous appduct_events/appduct_wait_for_event call) to skip events you've already handled and wait only for a new one \u2014 omitting it can return an old match instantly on every call. Requires a claimed session to already exist (use appduct_wait_for_session first if not). Resolves with { sessionId, alias, name, payload, ts, seq }, or rejects with tool_timeout after timeoutMs (default 120000ms, capped server-side at 1500000ms). A call still running after about two minutes moves to a Claude Code background task, so the result may arrive well after this call returns.",
|
|
2682
|
+
inputSchema: {
|
|
2683
|
+
type: "object",
|
|
2684
|
+
properties: {
|
|
2685
|
+
selector: { type: "string" },
|
|
2686
|
+
name: { type: "string" },
|
|
2687
|
+
match: { type: "object", additionalProperties: { type: ["string", "number", "boolean", "null"] } },
|
|
2688
|
+
since: { type: "integer", minimum: 0 },
|
|
2689
|
+
timeoutMs: { type: "number", exclusiveMinimum: 0 }
|
|
2690
|
+
},
|
|
2691
|
+
required: ["name"],
|
|
2692
|
+
additionalProperties: false
|
|
2693
|
+
}
|
|
2694
|
+
};
|
|
2695
|
+
var asRecord3 = (value) => {
|
|
2696
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2697
|
+
return {};
|
|
2698
|
+
}
|
|
2699
|
+
return value;
|
|
2700
|
+
};
|
|
2701
|
+
var asOptionalString2 = (value, field) => {
|
|
2702
|
+
if (value === void 0) {
|
|
2703
|
+
return void 0;
|
|
2704
|
+
}
|
|
2705
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
2706
|
+
throw new McpBuiltinToolError("invalid_request", `"${field}" must be a non-empty string.`);
|
|
2707
|
+
}
|
|
2708
|
+
return value;
|
|
2709
|
+
};
|
|
2710
|
+
var asOptionalNonNegativeInteger = (value, field) => {
|
|
2711
|
+
if (value === void 0) {
|
|
2712
|
+
return void 0;
|
|
2713
|
+
}
|
|
2714
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
2715
|
+
throw new McpBuiltinToolError("invalid_request", `"${field}" must be a non-negative integer.`);
|
|
2716
|
+
}
|
|
2717
|
+
return value;
|
|
2718
|
+
};
|
|
2719
|
+
var asOptionalPositiveInteger = (value, field) => {
|
|
2720
|
+
if (value === void 0) {
|
|
2721
|
+
return void 0;
|
|
2722
|
+
}
|
|
2723
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
2724
|
+
throw new McpBuiltinToolError("invalid_request", `"${field}" must be a positive integer.`);
|
|
2725
|
+
}
|
|
2726
|
+
return value;
|
|
2727
|
+
};
|
|
2728
|
+
var asOptionalPositiveNumber2 = (value, field) => {
|
|
2729
|
+
if (value === void 0) {
|
|
2730
|
+
return void 0;
|
|
2731
|
+
}
|
|
2732
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
2733
|
+
throw new McpBuiltinToolError("invalid_request", `"${field}" must be a positive number.`);
|
|
2734
|
+
}
|
|
2735
|
+
return value;
|
|
2736
|
+
};
|
|
2737
|
+
var asOptionalEventKinds = (value) => {
|
|
2738
|
+
if (value === void 0) {
|
|
2739
|
+
return void 0;
|
|
2740
|
+
}
|
|
2741
|
+
if (!Array.isArray(value) || !value.every((kind) => typeof kind === "string" && KNOWN_EVENT_KINDS.has(kind))) {
|
|
2742
|
+
throw new McpBuiltinToolError("invalid_request", '"kinds" must be an array of known event kinds.');
|
|
2743
|
+
}
|
|
2744
|
+
return value;
|
|
2745
|
+
};
|
|
2746
|
+
var isMatchPrimitive = (value) => {
|
|
2747
|
+
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
2748
|
+
};
|
|
2749
|
+
var asOptionalMatch = (value) => {
|
|
2750
|
+
if (value === void 0) {
|
|
2751
|
+
return void 0;
|
|
2752
|
+
}
|
|
2753
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2754
|
+
throw new McpBuiltinToolError("invalid_request", '"match" must be a JSON object.');
|
|
2755
|
+
}
|
|
2756
|
+
const record = value;
|
|
2757
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
2758
|
+
if (!isMatchPrimitive(entry)) {
|
|
2759
|
+
throw new McpBuiltinToolError(
|
|
2760
|
+
"invalid_request",
|
|
2761
|
+
`"match.${key}" must be a string, number, boolean, or null (objects/arrays can never match).`
|
|
2762
|
+
);
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
return record;
|
|
2766
|
+
};
|
|
2767
|
+
var handleEventsTool = async (rawArgs, deps) => {
|
|
2768
|
+
const args = asRecord3(rawArgs);
|
|
2769
|
+
return deps.call(RPC_METHODS5.eventsSince, {
|
|
2770
|
+
selector: asOptionalString2(args.selector, "selector"),
|
|
2771
|
+
since: asOptionalNonNegativeInteger(args.since, "since"),
|
|
2772
|
+
kinds: asOptionalEventKinds(args.kinds),
|
|
2773
|
+
limit: asOptionalPositiveInteger(args.limit, "limit")
|
|
2774
|
+
});
|
|
2775
|
+
};
|
|
2776
|
+
var payloadShallowMatches = (payload, match) => {
|
|
2777
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
2778
|
+
return false;
|
|
2779
|
+
}
|
|
2780
|
+
const record = payload;
|
|
2781
|
+
return Object.entries(match).every(([key, value]) => record[key] === value);
|
|
2782
|
+
};
|
|
2783
|
+
var handleWaitForEventTool = async (rawArgs, deps) => {
|
|
2784
|
+
const args = asRecord3(rawArgs);
|
|
2785
|
+
const selector = asOptionalString2(args.selector, "selector");
|
|
2786
|
+
const name = asOptionalString2(args.name, "name");
|
|
2787
|
+
if (!name) {
|
|
2788
|
+
throw new McpBuiltinToolError("invalid_request", '"name" must be a non-empty string.');
|
|
2789
|
+
}
|
|
2790
|
+
const match = asOptionalMatch(args.match);
|
|
2791
|
+
const since = asOptionalNonNegativeInteger(args.since, "since");
|
|
2792
|
+
const timeoutMs = clampWaitForEventTimeoutMs(asOptionalPositiveNumber2(args.timeoutMs, "timeoutMs"));
|
|
2793
|
+
const stream = await openDaemonStream({ stateDir: deps.stateDir, spawn: deps.spawn });
|
|
2794
|
+
try {
|
|
2795
|
+
const described = await stream.call(RPC_METHODS5.sessionsDescribe, { selector });
|
|
2796
|
+
const sessionId = described.sessionId;
|
|
2797
|
+
const alias = described.alias;
|
|
2798
|
+
const toResult = (event) => {
|
|
2799
|
+
if (event.kind !== "app_event" || event.sessionId !== sessionId) {
|
|
2800
|
+
return void 0;
|
|
2801
|
+
}
|
|
2802
|
+
const data = event.data;
|
|
2803
|
+
if (data.name !== name) {
|
|
2804
|
+
return void 0;
|
|
2805
|
+
}
|
|
2806
|
+
if (match && !payloadShallowMatches(data.payload, match)) {
|
|
2807
|
+
return void 0;
|
|
2808
|
+
}
|
|
2809
|
+
return { sessionId, alias, name, payload: data.payload, ts: event.ts, seq: event.seq };
|
|
2810
|
+
};
|
|
2811
|
+
let liveHandler = null;
|
|
2812
|
+
const earlyEvents = [];
|
|
2813
|
+
const unsubscribeNotification = stream.onNotification((payload) => {
|
|
2814
|
+
const event = payload;
|
|
2815
|
+
if (liveHandler) {
|
|
2816
|
+
liveHandler(event);
|
|
2817
|
+
} else {
|
|
2818
|
+
earlyEvents.push(event);
|
|
2819
|
+
}
|
|
2820
|
+
});
|
|
2821
|
+
let unsubscribeClose = () => {
|
|
2822
|
+
};
|
|
2823
|
+
try {
|
|
2824
|
+
await stream.call(RPC_METHODS5.eventsSubscribe, { sessionSelector: sessionId, kinds: ["app_event"] });
|
|
2825
|
+
const sinceResult = await stream.call(RPC_METHODS5.eventsSince, {
|
|
2826
|
+
selector: sessionId,
|
|
2827
|
+
since,
|
|
2828
|
+
kinds: ["app_event"]
|
|
2829
|
+
});
|
|
2830
|
+
const seenSeqs = /* @__PURE__ */ new Set();
|
|
2831
|
+
const backlog = [];
|
|
2832
|
+
for (const event of [...sinceResult.events, ...earlyEvents]) {
|
|
2833
|
+
if (event.sessionId === sessionId && !seenSeqs.has(event.seq)) {
|
|
2834
|
+
seenSeqs.add(event.seq);
|
|
2835
|
+
backlog.push(event);
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
backlog.sort((a, b) => a.seq - b.seq);
|
|
2839
|
+
for (const event of backlog) {
|
|
2840
|
+
const result = toResult(event);
|
|
2841
|
+
if (result) {
|
|
2842
|
+
return result;
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
const highestConsideredSeq = backlog.length > 0 ? backlog[backlog.length - 1].seq : sinceResult.cursor;
|
|
2846
|
+
return await new Promise((resolve2, reject) => {
|
|
2847
|
+
let settled = false;
|
|
2848
|
+
const settle = (fn) => {
|
|
2849
|
+
if (settled) {
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2852
|
+
settled = true;
|
|
2853
|
+
clearTimeout(timer);
|
|
2854
|
+
if (progressTimer) {
|
|
2855
|
+
clearInterval(progressTimer);
|
|
2856
|
+
}
|
|
2857
|
+
liveHandler = null;
|
|
2858
|
+
fn();
|
|
2859
|
+
};
|
|
2860
|
+
const timer = setTimeout(() => {
|
|
2861
|
+
settle(
|
|
2862
|
+
() => reject(
|
|
2863
|
+
new McpBuiltinToolError(
|
|
2864
|
+
"tool_timeout",
|
|
2865
|
+
`Timed out after ${timeoutMs}ms waiting for event "${name}".`
|
|
2866
|
+
)
|
|
2867
|
+
)
|
|
2868
|
+
);
|
|
2869
|
+
}, timeoutMs);
|
|
2870
|
+
const startedAt = Date.now();
|
|
2871
|
+
const progressTimer = deps.progress ? setInterval(() => {
|
|
2872
|
+
deps.progress.sendNotification({
|
|
2873
|
+
method: "notifications/progress",
|
|
2874
|
+
params: {
|
|
2875
|
+
progressToken: deps.progress.token,
|
|
2876
|
+
progress: Date.now() - startedAt,
|
|
2877
|
+
total: timeoutMs,
|
|
2878
|
+
message: `Still waiting for event "${name}"...`
|
|
2879
|
+
}
|
|
2880
|
+
}).catch(() => {
|
|
2881
|
+
});
|
|
2882
|
+
}, WAIT_FOR_EVENT_PROGRESS_INTERVAL_MS) : void 0;
|
|
2883
|
+
unsubscribeClose = stream.onClose(() => {
|
|
2884
|
+
settle(
|
|
2885
|
+
() => reject(
|
|
2886
|
+
new McpBuiltinToolError(
|
|
2887
|
+
"tool_execution_error",
|
|
2888
|
+
`The connection to the Appduct daemon closed while waiting for event "${name}".`
|
|
2889
|
+
)
|
|
2890
|
+
)
|
|
2891
|
+
);
|
|
2892
|
+
});
|
|
2893
|
+
liveHandler = (event) => {
|
|
2894
|
+
if (settled) {
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
if (event.sessionId === sessionId && event.seq <= highestConsideredSeq) {
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
const result = toResult(event);
|
|
2901
|
+
if (result) {
|
|
2902
|
+
settle(() => resolve2(result));
|
|
2903
|
+
}
|
|
2904
|
+
};
|
|
2905
|
+
});
|
|
2906
|
+
} finally {
|
|
2907
|
+
unsubscribeNotification();
|
|
2908
|
+
unsubscribeClose();
|
|
2909
|
+
}
|
|
2910
|
+
} finally {
|
|
2911
|
+
stream.close();
|
|
2912
|
+
}
|
|
2913
|
+
};
|
|
2914
|
+
|
|
2915
|
+
// src/mcp/server.ts
|
|
2916
|
+
var packageVersion = getPackageVersion();
|
|
2917
|
+
var SESSIONS_RESOURCE_URI = "appduct://sessions";
|
|
2918
|
+
var isPlainObject2 = (value) => {
|
|
2919
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2920
|
+
};
|
|
2921
|
+
var clientSupportsElicitation = (server) => {
|
|
2922
|
+
return server.getClientCapabilities()?.elicitation !== void 0;
|
|
2923
|
+
};
|
|
2924
|
+
var ELICITATION_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
2925
|
+
var ELICITATION_ARGS_PREVIEW_MAX_CHARS = 2048;
|
|
2926
|
+
var formatElicitationArgsPreview = (args) => {
|
|
2927
|
+
const json = JSON.stringify(args);
|
|
2928
|
+
if (json.length <= ELICITATION_ARGS_PREVIEW_MAX_CHARS) {
|
|
2929
|
+
return json;
|
|
2930
|
+
}
|
|
2931
|
+
return `${json.slice(0, ELICITATION_ARGS_PREVIEW_MAX_CHARS)}\u2026 [truncated at ${ELICITATION_ARGS_PREVIEW_MAX_CHARS} of ${json.length} characters]`;
|
|
2932
|
+
};
|
|
2933
|
+
var buildElicitationMessage = (tool, args) => {
|
|
2934
|
+
return `Appduct: allow the app tool "${tool.descriptor.name}" to run on session "${tool.alias}" (${tool.sessionId})? Arguments: ${formatElicitationArgsPreview(args)}`;
|
|
2935
|
+
};
|
|
2936
|
+
var ElicitationDeclinedError = class extends Error {
|
|
2937
|
+
constructor(message) {
|
|
2938
|
+
super(message);
|
|
2939
|
+
this.name = "ElicitationDeclinedError";
|
|
2940
|
+
}
|
|
2941
|
+
};
|
|
2942
|
+
var requestElicitationConsent = async (server, tool, args, timeoutMs) => {
|
|
2943
|
+
try {
|
|
2944
|
+
const result = await server.elicitInput(
|
|
2945
|
+
{
|
|
2946
|
+
message: buildElicitationMessage(tool, args),
|
|
2947
|
+
// The accept/decline/cancel action itself is the consent signal — nothing is actually being
|
|
2948
|
+
// collected, so the schema asks for no fields. An empty `properties` record is a valid
|
|
2949
|
+
// restricted-JSON-Schema object per the MCP spec (the SDK's request schema requires the key
|
|
2950
|
+
// but not that it be non-empty), and renders as a plain confirm/decline prompt.
|
|
2951
|
+
requestedSchema: { type: "object", properties: {} }
|
|
2952
|
+
},
|
|
2953
|
+
{ timeout: timeoutMs }
|
|
2954
|
+
);
|
|
2955
|
+
if (result.action === "accept") {
|
|
2956
|
+
return { type: "accepted" };
|
|
2957
|
+
}
|
|
2958
|
+
return {
|
|
2959
|
+
type: "declined",
|
|
2960
|
+
message: `The user ${result.action === "cancel" ? "cancelled" : "declined"} the request to call "${tool.descriptor.name}" on session "${tool.alias}".`
|
|
2961
|
+
};
|
|
2962
|
+
} catch (error) {
|
|
2963
|
+
if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) {
|
|
2964
|
+
return {
|
|
2965
|
+
type: "declined",
|
|
2966
|
+
message: `Timed out after ${timeoutMs / 1e3}s waiting for a human to respond to the consent prompt for "${tool.descriptor.name}" on session "${tool.alias}".`
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
return { type: "no-channel" };
|
|
2970
|
+
}
|
|
2971
|
+
};
|
|
2972
|
+
var toolSuccessContent = (result) => {
|
|
2973
|
+
const content = [{ type: "text", text: JSON.stringify(result) }];
|
|
2974
|
+
if (isPlainObject2(result)) {
|
|
2975
|
+
return { content, structuredContent: result };
|
|
2976
|
+
}
|
|
2977
|
+
return { content };
|
|
2978
|
+
};
|
|
2979
|
+
var toolErrorContent = (type, message, details) => {
|
|
2980
|
+
const suffix = details !== void 0 ? ` ${JSON.stringify(details)}` : "";
|
|
2981
|
+
return {
|
|
2982
|
+
isError: true,
|
|
2983
|
+
content: [{ type: "text", text: `${type}: ${message}${suffix}` }]
|
|
2984
|
+
};
|
|
2985
|
+
};
|
|
2986
|
+
var toolErrorContentFromError = (error) => {
|
|
2987
|
+
if (error instanceof DaemonRpcError && error.data?.type) {
|
|
2988
|
+
return toolErrorContent(error.data.type, error.message, error.data.details);
|
|
2989
|
+
}
|
|
2990
|
+
if (error instanceof McpBuiltinToolError) {
|
|
2991
|
+
return toolErrorContent(error.type, error.message);
|
|
2992
|
+
}
|
|
2993
|
+
if (error instanceof ElicitationDeclinedError) {
|
|
2994
|
+
return toolErrorContent("consent_declined", error.message);
|
|
2995
|
+
}
|
|
2996
|
+
if (error instanceof Error) {
|
|
2997
|
+
return toolErrorContent("tool_execution_error", error.message);
|
|
2998
|
+
}
|
|
2999
|
+
return toolErrorContent("tool_execution_error", "An unexpected error occurred.");
|
|
3000
|
+
};
|
|
3001
|
+
var createMcpServer = async (options) => {
|
|
3002
|
+
const openStream = options.openStream ?? openDaemonStream;
|
|
3003
|
+
let stream;
|
|
3004
|
+
try {
|
|
3005
|
+
stream = await openStream({
|
|
3006
|
+
stateDir: options.stateDir,
|
|
3007
|
+
spawn: options.spawn,
|
|
3008
|
+
checkVersion: options.checkVersion
|
|
3009
|
+
});
|
|
3010
|
+
} catch (error) {
|
|
3011
|
+
if (error instanceof DaemonVersionMismatchError) {
|
|
3012
|
+
console.error(`appduct mcp: refusing to start. ${error.message}`);
|
|
3013
|
+
}
|
|
3014
|
+
throw error;
|
|
3015
|
+
}
|
|
3016
|
+
const server = new Server(
|
|
3017
|
+
{ name: "appduct", version: packageVersion },
|
|
3018
|
+
// `tools/list` is a fixed set of built-ins, so there is never a list change to announce.
|
|
3019
|
+
{ capabilities: { tools: {}, resources: {} } }
|
|
3020
|
+
);
|
|
3021
|
+
const resolveToolCallConsent = async (tool, args) => {
|
|
3022
|
+
if (tool.policy !== "prompt" || !clientSupportsElicitation(server)) {
|
|
3023
|
+
return void 0;
|
|
3024
|
+
}
|
|
3025
|
+
const outcome = await requestElicitationConsent(
|
|
3026
|
+
server,
|
|
3027
|
+
tool,
|
|
3028
|
+
args,
|
|
3029
|
+
options.elicitationTimeoutMs ?? ELICITATION_TIMEOUT_MS
|
|
3030
|
+
);
|
|
3031
|
+
if (outcome.type === "accepted") {
|
|
3032
|
+
return "elicitation";
|
|
3033
|
+
}
|
|
3034
|
+
if (outcome.type === "declined") {
|
|
3035
|
+
throw new ElicitationDeclinedError(outcome.message);
|
|
3036
|
+
}
|
|
3037
|
+
return void 0;
|
|
3038
|
+
};
|
|
3039
|
+
const callAppTool = async (tool, args, requestedTimeoutMs, progressToken, sendNotification, signal) => {
|
|
3040
|
+
const timeoutMs = resolveCallDeadline(tool, requestedTimeoutMs);
|
|
3041
|
+
const transportTimeoutMs = deriveCallTransportTimeoutMs(timeoutMs);
|
|
3042
|
+
const consent = await resolveToolCallConsent(tool, args);
|
|
3043
|
+
if (signal.aborted) {
|
|
3044
|
+
throw new McpBuiltinToolError("tool_cancelled", `The call to "${tool.descriptor.name}" was cancelled before it started.`);
|
|
3045
|
+
}
|
|
3046
|
+
if (progressToken === void 0) {
|
|
3047
|
+
const result = await stream.call(
|
|
3048
|
+
RPC_METHODS6.toolsCall,
|
|
3049
|
+
{
|
|
3050
|
+
selector: tool.sessionId,
|
|
3051
|
+
name: tool.descriptor.name,
|
|
3052
|
+
args,
|
|
3053
|
+
caller: "mcp",
|
|
3054
|
+
timeoutMs,
|
|
3055
|
+
...consent ? { consent } : {}
|
|
3056
|
+
},
|
|
3057
|
+
transportTimeoutMs
|
|
3058
|
+
);
|
|
3059
|
+
return result.result;
|
|
3060
|
+
}
|
|
3061
|
+
const progressStream = await openStream({ stateDir: options.stateDir, spawn: options.spawn });
|
|
3062
|
+
try {
|
|
3063
|
+
await progressStream.call(RPC_METHODS6.eventsSubscribe, {
|
|
3064
|
+
sessionSelector: tool.sessionId,
|
|
3065
|
+
kinds: ["tool_call_started", "tool_call_progress"]
|
|
3066
|
+
});
|
|
3067
|
+
let callId;
|
|
3068
|
+
let cancelRequested = signal.aborted;
|
|
3069
|
+
const maybeCancel = () => {
|
|
3070
|
+
if (callId === void 0 || !cancelRequested) {
|
|
3071
|
+
return;
|
|
3072
|
+
}
|
|
3073
|
+
progressStream.call(RPC_METHODS6.toolsCancel, { selector: tool.sessionId, callId, reason: "mcp_client_cancelled" }).catch((error) => {
|
|
3074
|
+
console.error("appduct mcp: failed to cancel an app tool call:", error);
|
|
3075
|
+
});
|
|
3076
|
+
};
|
|
3077
|
+
const onAbort = () => {
|
|
3078
|
+
cancelRequested = true;
|
|
3079
|
+
maybeCancel();
|
|
3080
|
+
};
|
|
3081
|
+
signal.addEventListener("abort", onAbort);
|
|
3082
|
+
const unsubscribe = progressStream.onNotification((payload) => {
|
|
3083
|
+
const event = payload;
|
|
3084
|
+
if (event.kind === "tool_call_started" && callId === void 0) {
|
|
3085
|
+
const data = event.data;
|
|
3086
|
+
if (data.name === tool.descriptor.name) {
|
|
3087
|
+
callId = data.callId;
|
|
3088
|
+
maybeCancel();
|
|
3089
|
+
}
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
if (event.kind === "tool_call_progress" && callId !== void 0) {
|
|
3093
|
+
const data = event.data;
|
|
3094
|
+
if (data.callId === callId) {
|
|
3095
|
+
sendNotification({
|
|
3096
|
+
method: "notifications/progress",
|
|
3097
|
+
params: { progressToken, progress: data.progress ?? 0, message: data.message }
|
|
3098
|
+
}).catch((error) => {
|
|
3099
|
+
console.error("appduct mcp: failed to send a progress notification:", error);
|
|
3100
|
+
});
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
});
|
|
3104
|
+
try {
|
|
3105
|
+
const result = await progressStream.call(
|
|
3106
|
+
RPC_METHODS6.toolsCall,
|
|
3107
|
+
{
|
|
3108
|
+
selector: tool.sessionId,
|
|
3109
|
+
name: tool.descriptor.name,
|
|
3110
|
+
args,
|
|
3111
|
+
caller: "mcp",
|
|
3112
|
+
timeoutMs,
|
|
3113
|
+
...consent ? { consent } : {}
|
|
3114
|
+
},
|
|
3115
|
+
transportTimeoutMs
|
|
3116
|
+
);
|
|
3117
|
+
return result.result;
|
|
3118
|
+
} finally {
|
|
3119
|
+
unsubscribe();
|
|
3120
|
+
signal.removeEventListener("abort", onAbort);
|
|
3121
|
+
}
|
|
3122
|
+
} finally {
|
|
3123
|
+
progressStream.close();
|
|
3124
|
+
}
|
|
3125
|
+
};
|
|
3126
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
3127
|
+
return {
|
|
3128
|
+
tools: [
|
|
3129
|
+
CONNECT_TOOL_DESCRIPTOR,
|
|
3130
|
+
WAIT_FOR_SESSION_TOOL_DESCRIPTOR,
|
|
3131
|
+
LIST_TOOLS_TOOL_DESCRIPTOR,
|
|
3132
|
+
DESCRIBE_TOOL_TOOL_DESCRIPTOR,
|
|
3133
|
+
CALL_TOOL_TOOL_DESCRIPTOR,
|
|
3134
|
+
EVENTS_TOOL_DESCRIPTOR,
|
|
3135
|
+
WAIT_FOR_EVENT_TOOL_DESCRIPTOR
|
|
3136
|
+
]
|
|
3137
|
+
};
|
|
3138
|
+
});
|
|
3139
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
3140
|
+
const name = request.params.name;
|
|
3141
|
+
const args = request.params.arguments ?? {};
|
|
3142
|
+
const progressToken = extra._meta?.progressToken;
|
|
3143
|
+
try {
|
|
3144
|
+
if (name === CONNECT_TOOL_NAME) {
|
|
3145
|
+
return toolSuccessContent(
|
|
3146
|
+
await handleConnectTool(args, {
|
|
3147
|
+
call: stream.call,
|
|
3148
|
+
scheme: options.scheme,
|
|
3149
|
+
schemeTried: options.schemeTried,
|
|
3150
|
+
cwd: options.cwd,
|
|
3151
|
+
stateDirRoot: options.stateDirRoot,
|
|
3152
|
+
exec: options.exec,
|
|
3153
|
+
env: options.env
|
|
3154
|
+
})
|
|
3155
|
+
);
|
|
3156
|
+
}
|
|
3157
|
+
if (name === WAIT_FOR_SESSION_TOOL_NAME) {
|
|
3158
|
+
return toolSuccessContent(
|
|
3159
|
+
await handleWaitForSessionTool(args, { stateDir: options.stateDir, spawn: options.spawn })
|
|
3160
|
+
);
|
|
3161
|
+
}
|
|
3162
|
+
if (name === EVENTS_TOOL_NAME) {
|
|
3163
|
+
return toolSuccessContent(await handleEventsTool(args, { call: stream.call }));
|
|
3164
|
+
}
|
|
3165
|
+
if (name === WAIT_FOR_EVENT_TOOL_NAME) {
|
|
3166
|
+
return toolSuccessContent(
|
|
3167
|
+
await handleWaitForEventTool(args, {
|
|
3168
|
+
stateDir: options.stateDir,
|
|
3169
|
+
spawn: options.spawn,
|
|
3170
|
+
progress: progressToken !== void 0 ? { token: progressToken, sendNotification: extra.sendNotification } : void 0
|
|
3171
|
+
})
|
|
3172
|
+
);
|
|
3173
|
+
}
|
|
3174
|
+
if (name === LIST_TOOLS_TOOL_NAME) {
|
|
3175
|
+
return toolSuccessContent(await handleListToolsTool(args, stream.call));
|
|
3176
|
+
}
|
|
3177
|
+
if (name === DESCRIBE_TOOL_TOOL_NAME) {
|
|
3178
|
+
return toolSuccessContent(await handleDescribeToolTool(args, stream.call));
|
|
3179
|
+
}
|
|
3180
|
+
if (name === CALL_TOOL_TOOL_NAME) {
|
|
3181
|
+
const callArgs = parseCallToolArgs(args);
|
|
3182
|
+
const tool = await resolveAppTool(stream.call, callArgs.selector, callArgs.name);
|
|
3183
|
+
return toolSuccessContent(
|
|
3184
|
+
await callAppTool(
|
|
3185
|
+
tool,
|
|
3186
|
+
callArgs.args,
|
|
3187
|
+
callArgs.timeoutMs,
|
|
3188
|
+
progressToken,
|
|
3189
|
+
extra.sendNotification,
|
|
3190
|
+
extra.signal
|
|
3191
|
+
)
|
|
3192
|
+
);
|
|
3193
|
+
}
|
|
3194
|
+
return toolErrorContent(
|
|
3195
|
+
"tool_not_found",
|
|
3196
|
+
`"${name}" is not an Appduct MCP tool. Use ${LIST_TOOLS_TOOL_NAME} to find the app's tools and ${CALL_TOOL_TOOL_NAME} to run one.`
|
|
3197
|
+
);
|
|
3198
|
+
} catch (error) {
|
|
3199
|
+
return toolErrorContentFromError(error);
|
|
3200
|
+
}
|
|
3201
|
+
});
|
|
3202
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
3203
|
+
return {
|
|
3204
|
+
resources: [
|
|
3205
|
+
{
|
|
3206
|
+
uri: SESSIONS_RESOURCE_URI,
|
|
3207
|
+
name: "sessions",
|
|
3208
|
+
description: "Live Appduct device sessions (sessions.list passthrough), as JSON.",
|
|
3209
|
+
mimeType: "application/json"
|
|
3210
|
+
}
|
|
3211
|
+
]
|
|
3212
|
+
};
|
|
3213
|
+
});
|
|
3214
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
3215
|
+
if (request.params.uri !== SESSIONS_RESOURCE_URI) {
|
|
3216
|
+
throw new Error(`Unknown resource "${request.params.uri}".`);
|
|
3217
|
+
}
|
|
3218
|
+
const sessions = await stream.call(RPC_METHODS6.sessionsList);
|
|
3219
|
+
return {
|
|
3220
|
+
contents: [{ uri: SESSIONS_RESOURCE_URI, mimeType: "application/json", text: JSON.stringify(sessions) }]
|
|
3221
|
+
};
|
|
3222
|
+
});
|
|
3223
|
+
const close = async () => {
|
|
3224
|
+
stream.close();
|
|
3225
|
+
};
|
|
3226
|
+
return {
|
|
3227
|
+
server,
|
|
3228
|
+
connect: (transport) => server.connect(transport),
|
|
3229
|
+
close
|
|
3230
|
+
};
|
|
3231
|
+
};
|
|
3232
|
+
|
|
3233
|
+
// src/commands/mcp.ts
|
|
3234
|
+
var resolveSchemeForServer = async (context, configScheme, paths) => {
|
|
3235
|
+
try {
|
|
3236
|
+
return await resolveScheme({
|
|
3237
|
+
flagScheme: context.scheme,
|
|
3238
|
+
env: context.schemeEnv,
|
|
3239
|
+
cwd: context.cwd,
|
|
3240
|
+
configScheme,
|
|
3241
|
+
stateConfigPath: paths.configPath,
|
|
3242
|
+
stateDirRoot: paths.root
|
|
3243
|
+
});
|
|
3244
|
+
} catch (error) {
|
|
3245
|
+
const message = error.message;
|
|
3246
|
+
(context.stderr ?? process.stderr).write(
|
|
3247
|
+
`appduct mcp: could not resolve a deep-link scheme: ${message}
|
|
3248
|
+
The server is starting anyway; appduct_connect will fail until this is fixed.
|
|
3249
|
+
`
|
|
3250
|
+
);
|
|
3251
|
+
return { tried: [`(scheme resolution failed: ${message})`] };
|
|
3252
|
+
}
|
|
3253
|
+
};
|
|
3254
|
+
var handleMcpCommand = async (context) => {
|
|
3255
|
+
const paths = getStateDirPaths(context.stateDir);
|
|
3256
|
+
const config = await loadConfig(paths);
|
|
3257
|
+
const { scheme, tried } = await resolveSchemeForServer(context, config.scheme, paths);
|
|
3258
|
+
const handle = await createMcpServer({
|
|
3259
|
+
stateDir: context.stateDir,
|
|
3260
|
+
spawn: context.spawn,
|
|
3261
|
+
checkVersion: context.checkVersion,
|
|
3262
|
+
scheme,
|
|
3263
|
+
schemeTried: tried,
|
|
3264
|
+
cwd: context.cwd,
|
|
3265
|
+
stateDirRoot: paths.root,
|
|
3266
|
+
exec: context.exec,
|
|
3267
|
+
env: context.env
|
|
3268
|
+
});
|
|
3269
|
+
let resolveCompletion;
|
|
3270
|
+
const completion = new Promise((resolve2) => {
|
|
3271
|
+
resolveCompletion = resolve2;
|
|
3272
|
+
});
|
|
3273
|
+
let stopped = false;
|
|
3274
|
+
const stop = () => {
|
|
3275
|
+
if (stopped) {
|
|
3276
|
+
return;
|
|
3277
|
+
}
|
|
3278
|
+
stopped = true;
|
|
3279
|
+
void handle.close().finally(resolveCompletion);
|
|
3280
|
+
};
|
|
3281
|
+
handle.server.onclose = stop;
|
|
3282
|
+
handle.server.onerror = (error) => {
|
|
3283
|
+
console.error("appduct mcp: transport error:", error);
|
|
3284
|
+
};
|
|
3285
|
+
try {
|
|
3286
|
+
await handle.connect(new StdioServerTransport(context.stdin, context.stdout));
|
|
3287
|
+
} catch (error) {
|
|
3288
|
+
await handle.close();
|
|
3289
|
+
throw error;
|
|
3290
|
+
}
|
|
3291
|
+
return {
|
|
3292
|
+
// Never rendered: the dispatcher suppresses the bootstrap render for this command (see the
|
|
3293
|
+
// doc comment above) so stdout carries only MCP protocol frames.
|
|
3294
|
+
result: { ok: true, data: void 0 },
|
|
3295
|
+
completion,
|
|
3296
|
+
stop,
|
|
3297
|
+
handle
|
|
3298
|
+
};
|
|
3299
|
+
};
|
|
3300
|
+
|
|
3301
|
+
// src/cli/global-flags.ts
|
|
3302
|
+
var formatJson = (value, flags) => {
|
|
3303
|
+
return flags.pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value);
|
|
3304
|
+
};
|
|
3305
|
+
|
|
3306
|
+
// src/output.ts
|
|
3307
|
+
import pc from "picocolors";
|
|
3308
|
+
import {
|
|
3309
|
+
formatAgentWebSocketUrl,
|
|
3310
|
+
renderToolSignature as renderToolSignature2,
|
|
3311
|
+
summarizeToolDescription as summarizeToolDescription2
|
|
3312
|
+
} from "@appduct/shared";
|
|
3313
|
+
var renderMetaLines = (meta) => {
|
|
3314
|
+
if (!meta) {
|
|
3315
|
+
return [];
|
|
3316
|
+
}
|
|
3317
|
+
return [
|
|
3318
|
+
"Meta",
|
|
3319
|
+
` Command: ${meta.command}`,
|
|
3320
|
+
` Timestamp: ${meta.timestamp}`,
|
|
3321
|
+
...typeof meta.duration_ms === "number" ? [` Duration: ${meta.duration_ms} ms`] : []
|
|
3322
|
+
];
|
|
3323
|
+
};
|
|
3324
|
+
var formatScalar = (value, flags) => {
|
|
3325
|
+
if (typeof value === "string") {
|
|
3326
|
+
return value;
|
|
3327
|
+
}
|
|
3328
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
3329
|
+
return String(value);
|
|
3330
|
+
}
|
|
3331
|
+
if (value === null || value === void 0) {
|
|
3332
|
+
return "null";
|
|
3333
|
+
}
|
|
3334
|
+
return formatJson(value, flags);
|
|
3335
|
+
};
|
|
3336
|
+
var renderFields = (title, fields, flags) => {
|
|
3337
|
+
const visibleFields = fields.filter(([, value]) => value !== void 0);
|
|
3338
|
+
if (visibleFields.length === 0) {
|
|
3339
|
+
return [];
|
|
3340
|
+
}
|
|
3341
|
+
const width = Math.max(...visibleFields.map(([label]) => label.length));
|
|
3342
|
+
return [
|
|
3343
|
+
title,
|
|
3344
|
+
...visibleFields.map(([label, value]) => ` ${label.padEnd(width)} ${formatScalar(value, flags)}`)
|
|
3345
|
+
];
|
|
3346
|
+
};
|
|
3347
|
+
var formatAge = (fromIso, toIso) => {
|
|
3348
|
+
const elapsedMs = Math.max(0, new Date(toIso).getTime() - new Date(fromIso).getTime());
|
|
3349
|
+
const elapsedSeconds = Math.floor(elapsedMs / 1e3);
|
|
3350
|
+
if (elapsedSeconds < 60) {
|
|
3351
|
+
return `${elapsedSeconds}s`;
|
|
3352
|
+
}
|
|
3353
|
+
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
|
3354
|
+
if (elapsedMinutes < 60) {
|
|
3355
|
+
return `${elapsedMinutes}m`;
|
|
3356
|
+
}
|
|
3357
|
+
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
|
3358
|
+
if (elapsedHours < 24) {
|
|
3359
|
+
return `${elapsedHours}h`;
|
|
3360
|
+
}
|
|
3361
|
+
return `${Math.floor(elapsedHours / 24)}d`;
|
|
3362
|
+
};
|
|
3363
|
+
var formatDevice = (device) => {
|
|
3364
|
+
const parts = [device.manufacturer, device.model].filter((part) => Boolean(part));
|
|
3365
|
+
const label = parts.length > 0 ? parts.join(" ") : "unknown device";
|
|
3366
|
+
return device.os ? `${label} (${device.os})` : label;
|
|
3367
|
+
};
|
|
3368
|
+
var renderLsData = (colors, data, now) => {
|
|
3369
|
+
if (data.length === 0) {
|
|
3370
|
+
return [colors.dim("Sessions"), " No Appduct sessions are registered."];
|
|
3371
|
+
}
|
|
3372
|
+
const nowIso = now.toISOString();
|
|
3373
|
+
const headers = ["Alias", "State", "Device", "Tools", "Age"];
|
|
3374
|
+
const rows = data.map((session) => [
|
|
3375
|
+
session.alias,
|
|
3376
|
+
session.state,
|
|
3377
|
+
formatDevice(session.device),
|
|
3378
|
+
String(session.toolCount),
|
|
3379
|
+
formatAge(session.claimedAt ?? session.createdAt, nowIso)
|
|
3380
|
+
]);
|
|
3381
|
+
const widths = headers.map(
|
|
3382
|
+
(header, index) => Math.max(header.length, ...rows.map((row) => row[index].length))
|
|
3383
|
+
);
|
|
3384
|
+
return [
|
|
3385
|
+
colors.green("Sessions"),
|
|
3386
|
+
` ${headers.map((header, index) => header.padEnd(widths[index])).join(" ")}`,
|
|
3387
|
+
...rows.map((row) => ` ${row.map((cell, index) => cell.padEnd(widths[index])).join(" ")}`)
|
|
3388
|
+
];
|
|
3389
|
+
};
|
|
3390
|
+
var isToolsListing = (data) => {
|
|
3391
|
+
return typeof data === "object" && data !== null && Array.isArray(data.tools);
|
|
3392
|
+
};
|
|
3393
|
+
var isToolGroupsListing = (data) => {
|
|
3394
|
+
return typeof data === "object" && data !== null && Array.isArray(data.groups) && !Array.isArray(data.tools);
|
|
3395
|
+
};
|
|
3396
|
+
var UNGROUPED_LABEL = "(ungrouped)";
|
|
3397
|
+
var MAX_FOOTER_GROUPS = 10;
|
|
3398
|
+
var hasAnyGroup = (groups) => {
|
|
3399
|
+
return (groups ?? []).some((entry) => entry.group !== null);
|
|
3400
|
+
};
|
|
3401
|
+
var topLevelGroups = (groups) => {
|
|
3402
|
+
return (groups ?? []).filter((entry) => entry.group !== null && !entry.group.includes("/"));
|
|
3403
|
+
};
|
|
3404
|
+
var renderEmptyToolsLine = (data) => {
|
|
3405
|
+
if (data.total > 0) {
|
|
3406
|
+
return ` No tools at offset ${data.offset ?? 0}; ${data.total} matching tool${data.total === 1 ? "" : "s"} in total.`;
|
|
3407
|
+
}
|
|
3408
|
+
if (data.group !== void 0) {
|
|
3409
|
+
const match = data.filter === void 0 ? "" : ` match ${JSON.stringify(data.filter)}`;
|
|
3410
|
+
return ` No tools in group ${JSON.stringify(data.group)}${match}. Run \`appduct tools --groups\` to see the session's groups.`;
|
|
3411
|
+
}
|
|
3412
|
+
return data.filter === void 0 ? " No tools registered." : ` No tools match ${JSON.stringify(data.filter)}.`;
|
|
3413
|
+
};
|
|
3414
|
+
var renderGroupHint = (data) => {
|
|
3415
|
+
if (data.group !== void 0 || !hasAnyGroup(data.groups)) {
|
|
3416
|
+
return void 0;
|
|
3417
|
+
}
|
|
3418
|
+
const top = topLevelGroups(data.groups);
|
|
3419
|
+
const named = top.slice(0, MAX_FOOTER_GROUPS).map((entry) => `${entry.group} ${entry.total}`);
|
|
3420
|
+
const more = top.length > MAX_FOOTER_GROUPS ? `, ... ${top.length - MAX_FOOTER_GROUPS} more; see --groups` : "";
|
|
3421
|
+
return `--group <name> (groups: ${named.join(", ")}${more})`;
|
|
3422
|
+
};
|
|
3423
|
+
var renderTruncationLine = (data) => {
|
|
3424
|
+
if (data.tools.length >= data.total) {
|
|
3425
|
+
return [];
|
|
3426
|
+
}
|
|
3427
|
+
const groupHint = renderGroupHint(data);
|
|
3428
|
+
const narrow = groupHint === void 0 ? "--filter <text> or" : `${groupHint} or --filter <text>, or`;
|
|
3429
|
+
return [
|
|
3430
|
+
"",
|
|
3431
|
+
`Showing ${data.tools.length} of ${data.total} tools (offset ${data.offset ?? 0}). Narrow with ${narrow} page with --offset <n>.`
|
|
3432
|
+
];
|
|
3433
|
+
};
|
|
3434
|
+
var renderToolSummaryLines = (tool, indent) => {
|
|
3435
|
+
const tag = tool.policy === "allow" ? "" : ` [${tool.policy}]`;
|
|
3436
|
+
return [`${indent}${renderToolSignature2(tool)}${tag}`, `${indent} ${summarizeToolDescription2(tool.description)}`];
|
|
3437
|
+
};
|
|
3438
|
+
var renderGroupedToolLines = (colors, tools) => {
|
|
3439
|
+
const byGroup = /* @__PURE__ */ new Map();
|
|
3440
|
+
for (const tool of tools) {
|
|
3441
|
+
const key = tool.group ?? null;
|
|
3442
|
+
const bucket = byGroup.get(key);
|
|
3443
|
+
if (bucket) {
|
|
3444
|
+
bucket.push(tool);
|
|
3445
|
+
} else {
|
|
3446
|
+
byGroup.set(key, [tool]);
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
const tops = /* @__PURE__ */ new Map();
|
|
3450
|
+
for (const key of byGroup.keys()) {
|
|
3451
|
+
if (key === null) {
|
|
3452
|
+
continue;
|
|
3453
|
+
}
|
|
3454
|
+
const slash = key.indexOf("/");
|
|
3455
|
+
const top = slash === -1 ? key : key.slice(0, slash);
|
|
3456
|
+
const subs = tops.get(top) ?? [];
|
|
3457
|
+
if (slash !== -1) {
|
|
3458
|
+
subs.push(key);
|
|
3459
|
+
}
|
|
3460
|
+
tops.set(top, subs);
|
|
3461
|
+
}
|
|
3462
|
+
const byCodePoint = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
3463
|
+
const lines = [];
|
|
3464
|
+
for (const top of [...tops.keys()].sort(byCodePoint)) {
|
|
3465
|
+
lines.push(` ${colors.cyan(top)}`);
|
|
3466
|
+
lines.push(...(byGroup.get(top) ?? []).flatMap((tool) => renderToolSummaryLines(tool, " ")));
|
|
3467
|
+
for (const sub of tops.get(top).sort(byCodePoint)) {
|
|
3468
|
+
lines.push(` ${colors.cyan(sub)}`);
|
|
3469
|
+
lines.push(...byGroup.get(sub).flatMap((tool) => renderToolSummaryLines(tool, " ")));
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
const ungrouped = byGroup.get(null);
|
|
3473
|
+
if (ungrouped) {
|
|
3474
|
+
lines.push(` ${colors.cyan(UNGROUPED_LABEL)}`);
|
|
3475
|
+
lines.push(...ungrouped.flatMap((tool) => renderToolSummaryLines(tool, " ")));
|
|
3476
|
+
}
|
|
3477
|
+
return lines;
|
|
3478
|
+
};
|
|
3479
|
+
var renderToolsTitle = (colors, data) => {
|
|
3480
|
+
return colors.green(data.group === void 0 ? "Tools" : `Tools in group ${data.group}`);
|
|
3481
|
+
};
|
|
3482
|
+
var renderToolSummaryTable = (colors, data) => {
|
|
3483
|
+
if (data.tools.length === 0) {
|
|
3484
|
+
return [renderToolsTitle(colors, data), renderEmptyToolsLine(data)];
|
|
3485
|
+
}
|
|
3486
|
+
const grouped = data.group === void 0 && hasAnyGroup(data.groups);
|
|
3487
|
+
return [
|
|
3488
|
+
renderToolsTitle(colors, data),
|
|
3489
|
+
...grouped ? renderGroupedToolLines(colors, data.tools) : data.tools.flatMap((tool) => renderToolSummaryLines(tool, " ")),
|
|
3490
|
+
...renderTruncationLine(data),
|
|
3491
|
+
"",
|
|
3492
|
+
"Run `appduct tools <name>` for a tool's full schema."
|
|
3493
|
+
];
|
|
3494
|
+
};
|
|
3495
|
+
var renderToolDetail = (colors, tool, flags) => {
|
|
3496
|
+
return renderFields(
|
|
3497
|
+
colors.green(`Tool: ${tool.name}`),
|
|
3498
|
+
[
|
|
3499
|
+
["Signature", renderToolSignature2(tool)],
|
|
3500
|
+
["Description", tool.description],
|
|
3501
|
+
["Input schema", tool.input_schema],
|
|
3502
|
+
["Output schema", tool.output_schema],
|
|
3503
|
+
// `?? undefined` keeps an ungrouped tool's row hidden rather than printing a bare "null":
|
|
3504
|
+
// `renderFields` drops undefined rows, and the machine form's `null` is for consumers, not
|
|
3505
|
+
// for a human scanning one tool's schema.
|
|
3506
|
+
["Group", tool.group ?? void 0],
|
|
3507
|
+
["Annotations", tool.annotations],
|
|
3508
|
+
// Only rendered for a tool that declares one; `renderFields` drops undefined rows, so a
|
|
3509
|
+
// tool on the daemon's default deadline shows no line at all rather than a misleading
|
|
3510
|
+
// "10000".
|
|
3511
|
+
["Timeout (ms)", tool.timeout_ms]
|
|
3512
|
+
],
|
|
3513
|
+
flags
|
|
3514
|
+
);
|
|
3515
|
+
};
|
|
3516
|
+
var renderToolsFullListing = (colors, data, flags) => {
|
|
3517
|
+
if (data.tools.length === 0) {
|
|
3518
|
+
return [renderToolsTitle(colors, data), renderEmptyToolsLine(data)];
|
|
3519
|
+
}
|
|
3520
|
+
return [
|
|
3521
|
+
...data.tools.flatMap((tool, index) => [
|
|
3522
|
+
...index > 0 ? [""] : [],
|
|
3523
|
+
...renderToolDetail(colors, tool, flags)
|
|
3524
|
+
]),
|
|
3525
|
+
...renderTruncationLine(data)
|
|
3526
|
+
];
|
|
3527
|
+
};
|
|
3528
|
+
var renderToolGroups = (colors, data) => {
|
|
3529
|
+
if (data.groups.length === 0) {
|
|
3530
|
+
return [colors.green("Groups"), " No tools registered."];
|
|
3531
|
+
}
|
|
3532
|
+
const label = (entry) => {
|
|
3533
|
+
if (entry.group === null) {
|
|
3534
|
+
return UNGROUPED_LABEL;
|
|
3535
|
+
}
|
|
3536
|
+
return entry.group.includes("/") ? ` ${entry.group}` : entry.group;
|
|
3537
|
+
};
|
|
3538
|
+
const width = Math.max(...data.groups.map((entry) => label(entry).length));
|
|
3539
|
+
const countWidth = Math.max(...data.groups.map((entry) => String(entry.total).length));
|
|
3540
|
+
return [
|
|
3541
|
+
colors.green("Groups"),
|
|
3542
|
+
...data.groups.map((entry) => ` ${label(entry).padEnd(width)} ${String(entry.total).padStart(countWidth)}`),
|
|
3543
|
+
"",
|
|
3544
|
+
`${data.total} tool${data.total === 1 ? "" : "s"} in total. ` + (hasAnyGroup(data.groups) ? "Run `appduct tools --group <name>` to list one group's tools." : "No tool declares a group.")
|
|
3545
|
+
];
|
|
3546
|
+
};
|
|
3547
|
+
var renderToolsData = (colors, data, flags, full) => {
|
|
3548
|
+
if (isToolGroupsListing(data)) {
|
|
3549
|
+
return renderToolGroups(colors, data);
|
|
3550
|
+
}
|
|
3551
|
+
if (!isToolsListing(data)) {
|
|
3552
|
+
return renderToolDetail(colors, data, flags);
|
|
3553
|
+
}
|
|
3554
|
+
return full ? renderToolsFullListing(colors, data, flags) : renderToolSummaryTable(colors, data);
|
|
3555
|
+
};
|
|
3556
|
+
var renderInvokeData = (colors, data, flags) => {
|
|
3557
|
+
return [colors.green("Result"), formatScalar(data, flags)];
|
|
3558
|
+
};
|
|
3559
|
+
var renderLinkData = (colors, data, flags, qr) => {
|
|
3560
|
+
const lines = [
|
|
3561
|
+
colors.green("Link Created"),
|
|
3562
|
+
...renderFields(
|
|
3563
|
+
"Link",
|
|
3564
|
+
[
|
|
3565
|
+
["Session", data.sessionId],
|
|
3566
|
+
["Deep link", data.deepLink],
|
|
3567
|
+
["Endpoint", formatAgentWebSocketUrl(data.endpoint)],
|
|
3568
|
+
["Pin", data.pin],
|
|
3569
|
+
["Expires", new Date(data.expiresAt * 1e3).toISOString()],
|
|
3570
|
+
["Delivered", data.delivered ? `yes (${data.target})` : void 0]
|
|
3571
|
+
],
|
|
3572
|
+
flags
|
|
3573
|
+
)
|
|
3574
|
+
];
|
|
3575
|
+
if (qr) {
|
|
3576
|
+
lines.push("", "QR", renderQrToTerminal(data.deepLink));
|
|
3577
|
+
}
|
|
3578
|
+
return lines;
|
|
3579
|
+
};
|
|
3580
|
+
var renderRevokeData = (colors, _data) => {
|
|
3581
|
+
return [colors.green("Session Revoked")];
|
|
3582
|
+
};
|
|
3583
|
+
var renderKeygenData = (colors, data, flags) => {
|
|
3584
|
+
return [
|
|
3585
|
+
colors.green("Key Ready"),
|
|
3586
|
+
...renderFields(
|
|
3587
|
+
"Key",
|
|
3588
|
+
[
|
|
3589
|
+
["Path", data.path],
|
|
3590
|
+
["Fingerprint", data.pin]
|
|
3591
|
+
],
|
|
3592
|
+
flags
|
|
3593
|
+
)
|
|
3594
|
+
];
|
|
3595
|
+
};
|
|
3596
|
+
var renderInitData = (colors, data, flags) => {
|
|
3597
|
+
const snippet = JSON.stringify(
|
|
3598
|
+
{ mcpServers: { appduct: data.mcpServerEntry } },
|
|
3599
|
+
null,
|
|
3600
|
+
2
|
|
3601
|
+
);
|
|
3602
|
+
return [
|
|
3603
|
+
colors.green(data.changed ? "Project Initialized" : "Project Already Initialized"),
|
|
3604
|
+
...renderFields(
|
|
3605
|
+
"Config",
|
|
3606
|
+
[
|
|
3607
|
+
["Path", data.path],
|
|
3608
|
+
["Scheme", data.scheme],
|
|
3609
|
+
["Source", data.source],
|
|
3610
|
+
// Only present for a scheme discovery read off a static project file (app.json, or one of
|
|
3611
|
+
// the native Android/iOS probes) — names the exact file/key, not just which platform.
|
|
3612
|
+
["Read from", data.origin],
|
|
3613
|
+
["Written", data.changed ? data.created ? "created" : "updated" : "unchanged"]
|
|
3614
|
+
],
|
|
3615
|
+
flags
|
|
3616
|
+
),
|
|
3617
|
+
...data.note === void 0 ? [] : ["", colors.yellow(`Note: ${data.note}`)],
|
|
3618
|
+
"",
|
|
3619
|
+
"MCP server entry",
|
|
3620
|
+
...snippet.split("\n").map((line) => ` ${line}`),
|
|
3621
|
+
"",
|
|
3622
|
+
"Next",
|
|
3623
|
+
...data.nextSteps.map((step, index) => ` ${index + 1}. ${step}`)
|
|
3624
|
+
];
|
|
3625
|
+
};
|
|
3626
|
+
var renderDaemonRunData = (colors, data, flags) => {
|
|
3627
|
+
return [
|
|
3628
|
+
colors.green("Daemon Running"),
|
|
3629
|
+
...renderFields(
|
|
3630
|
+
"Daemon",
|
|
3631
|
+
[
|
|
3632
|
+
["PID", data.daemon.pid],
|
|
3633
|
+
["State dir", data.daemon.state_dir],
|
|
3634
|
+
["Socket", data.daemon.socket_path]
|
|
3635
|
+
],
|
|
3636
|
+
flags
|
|
3637
|
+
)
|
|
3638
|
+
];
|
|
3639
|
+
};
|
|
3640
|
+
var renderDaemonStartData = (colors, data, flags) => {
|
|
3641
|
+
return [
|
|
3642
|
+
colors.green("Daemon Started"),
|
|
3643
|
+
...renderFields(
|
|
3644
|
+
"Daemon",
|
|
3645
|
+
[
|
|
3646
|
+
["PID", data.daemon.pid],
|
|
3647
|
+
["WSS port", data.daemon.wss_port],
|
|
3648
|
+
["Started at", data.daemon.started_at]
|
|
3649
|
+
],
|
|
3650
|
+
flags
|
|
3651
|
+
)
|
|
3652
|
+
];
|
|
3653
|
+
};
|
|
3654
|
+
var renderDaemonStopData = (colors, data, flags) => {
|
|
3655
|
+
return [
|
|
3656
|
+
colors.green("Daemon Stopped"),
|
|
3657
|
+
...renderFields("Daemon", [["Method", data.daemon.method]], flags)
|
|
3658
|
+
];
|
|
3659
|
+
};
|
|
3660
|
+
var formatByteSize = (bytes) => {
|
|
3661
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
3662
|
+
let value = bytes;
|
|
3663
|
+
let unit = 0;
|
|
3664
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
3665
|
+
value /= 1024;
|
|
3666
|
+
unit += 1;
|
|
3667
|
+
}
|
|
3668
|
+
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
|
|
3669
|
+
};
|
|
3670
|
+
var renderDaemonStatusData = (colors, data, flags) => {
|
|
3671
|
+
return [
|
|
3672
|
+
colors.green("Daemon Status"),
|
|
3673
|
+
...renderFields(
|
|
3674
|
+
"Daemon",
|
|
3675
|
+
[
|
|
3676
|
+
["Version", data.daemon.version],
|
|
3677
|
+
["PID", data.daemon.pid],
|
|
3678
|
+
["Started at", data.daemon.started_at],
|
|
3679
|
+
["WSS port", data.daemon.wss_port],
|
|
3680
|
+
["Pinned keys", data.daemon.pinned_keys.length],
|
|
3681
|
+
["Sessions", data.daemon.session_count]
|
|
3682
|
+
],
|
|
3683
|
+
flags
|
|
3684
|
+
),
|
|
3685
|
+
"",
|
|
3686
|
+
...renderFields(
|
|
3687
|
+
"Policy",
|
|
3688
|
+
[
|
|
3689
|
+
["Default", data.policy.default],
|
|
3690
|
+
["Destructive", data.policy.destructive],
|
|
3691
|
+
["Overrides", data.policy.tools ? Object.keys(data.policy.tools).length : 0]
|
|
3692
|
+
],
|
|
3693
|
+
flags
|
|
3694
|
+
),
|
|
3695
|
+
"",
|
|
3696
|
+
// The retention rows drop out entirely against a daemon that predates them (`renderFields`
|
|
3697
|
+
// skips `undefined`), rather than printing "undefined" or an invented zero.
|
|
3698
|
+
...renderFields(
|
|
3699
|
+
"Audit",
|
|
3700
|
+
[
|
|
3701
|
+
["Path", data.audit.path],
|
|
3702
|
+
["Retention", data.audit.retention_days === void 0 ? void 0 : `${data.audit.retention_days} days`],
|
|
3703
|
+
["Files", data.audit.files],
|
|
3704
|
+
["Size", data.audit.bytes === void 0 ? void 0 : formatByteSize(data.audit.bytes)],
|
|
3705
|
+
["Failed writes", data.audit.failed_writes],
|
|
3706
|
+
["Failed prunes", data.audit.failed_prunes]
|
|
3707
|
+
],
|
|
3708
|
+
flags
|
|
3709
|
+
),
|
|
3710
|
+
// Version drift (issue #30) is the one thing here an operator has to act on, so it goes last
|
|
3711
|
+
// — the line still on screen after the block scrolls — and in yellow, not a quiet field row.
|
|
3712
|
+
...data.warning ? ["", colors.yellow(`Warning: ${data.warning}`)] : []
|
|
3713
|
+
];
|
|
3714
|
+
};
|
|
3715
|
+
var renderDoctorData = (colors, data, flags) => {
|
|
3716
|
+
return [
|
|
3717
|
+
data.present ? colors.green("Appduct Present") : colors.yellow("Appduct Absent"),
|
|
3718
|
+
...renderFields(
|
|
3719
|
+
"Artifact",
|
|
3720
|
+
[
|
|
3721
|
+
["Path", data.artifact],
|
|
3722
|
+
["Platform", data.platform],
|
|
3723
|
+
["Format", data.format],
|
|
3724
|
+
["Present", data.present],
|
|
3725
|
+
["Signals", data.signals.length > 0 ? data.signals.join(", ") : "none"],
|
|
3726
|
+
["Assertion", data.assertion ? `${data.assertion.expected} (holds)` : void 0]
|
|
3727
|
+
],
|
|
3728
|
+
flags
|
|
3729
|
+
)
|
|
3730
|
+
];
|
|
3731
|
+
};
|
|
3732
|
+
var renderSuccessData = (colors, command, data, options) => {
|
|
3733
|
+
const flags = options.flags;
|
|
3734
|
+
switch (command) {
|
|
3735
|
+
case "init":
|
|
3736
|
+
return renderInitData(colors, data, flags);
|
|
3737
|
+
case "keygen":
|
|
3738
|
+
return renderKeygenData(colors, data, flags);
|
|
3739
|
+
case "link":
|
|
3740
|
+
return renderLinkData(colors, data, flags, options.qr);
|
|
3741
|
+
case "ls":
|
|
3742
|
+
return renderLsData(colors, data, options.now ?? /* @__PURE__ */ new Date());
|
|
3743
|
+
case "tools":
|
|
3744
|
+
return renderToolsData(colors, data, flags, options.full);
|
|
3745
|
+
case "invoke":
|
|
3746
|
+
return renderInvokeData(colors, data, flags);
|
|
3747
|
+
case "revoke":
|
|
3748
|
+
return renderRevokeData(colors, data);
|
|
3749
|
+
case "daemon run":
|
|
3750
|
+
return renderDaemonRunData(colors, data, flags);
|
|
3751
|
+
case "daemon start":
|
|
3752
|
+
return renderDaemonStartData(colors, data, flags);
|
|
3753
|
+
case "daemon stop":
|
|
3754
|
+
return renderDaemonStopData(colors, data, flags);
|
|
3755
|
+
case "daemon status":
|
|
3756
|
+
return renderDaemonStatusData(colors, data, flags);
|
|
3757
|
+
case "doctor":
|
|
3758
|
+
return renderDoctorData(colors, data, flags);
|
|
3759
|
+
default:
|
|
3760
|
+
return [colors.green("Command Complete"), formatJson(data, flags)];
|
|
3761
|
+
}
|
|
3762
|
+
};
|
|
3763
|
+
var renderHumanError = (colors, error, flags, meta) => {
|
|
3764
|
+
return [
|
|
3765
|
+
colors.red("Command Failed"),
|
|
3766
|
+
...renderFields(
|
|
3767
|
+
"Error",
|
|
3768
|
+
[
|
|
3769
|
+
["Type", error.type],
|
|
3770
|
+
["Message", error.message],
|
|
3771
|
+
["Details", error.details]
|
|
3772
|
+
],
|
|
3773
|
+
flags
|
|
3774
|
+
),
|
|
3775
|
+
...meta ? ["", ...renderMetaLines(meta)] : []
|
|
3776
|
+
].join("\n");
|
|
3777
|
+
};
|
|
3778
|
+
var renderResult = (result, options) => {
|
|
3779
|
+
if (options.flags.json) {
|
|
3780
|
+
return {
|
|
3781
|
+
stdout: `${formatJson(result, options.flags)}
|
|
3782
|
+
`
|
|
3783
|
+
};
|
|
3784
|
+
}
|
|
3785
|
+
const colors = pc.createColors(options.flags.color);
|
|
3786
|
+
if (!result.ok) {
|
|
3787
|
+
return {
|
|
3788
|
+
stderr: `${renderHumanError(colors, result.error, options.flags, result.meta)}
|
|
3789
|
+
`
|
|
3790
|
+
};
|
|
3791
|
+
}
|
|
3792
|
+
const lines = [
|
|
3793
|
+
...renderSuccessData(colors, options.command, result.data, options),
|
|
3794
|
+
"",
|
|
3795
|
+
...renderMetaLines(result.meta)
|
|
3796
|
+
].filter((line, index, collection) => {
|
|
3797
|
+
if (line !== "") {
|
|
3798
|
+
return true;
|
|
3799
|
+
}
|
|
3800
|
+
return index > 0 && collection[index - 1] !== "" && index < collection.length - 1;
|
|
3801
|
+
});
|
|
3802
|
+
return {
|
|
3803
|
+
stdout: `${lines.join("\n")}
|
|
3804
|
+
`
|
|
3805
|
+
};
|
|
3806
|
+
};
|
|
3807
|
+
|
|
3808
|
+
// src/cli/envelope.ts
|
|
3809
|
+
var createCommandMeta = (command, startedAt, finishedAt) => {
|
|
3810
|
+
return {
|
|
3811
|
+
command,
|
|
3812
|
+
timestamp: finishedAt.toISOString(),
|
|
3813
|
+
duration_ms: finishedAt.getTime() - startedAt.getTime()
|
|
3814
|
+
};
|
|
3815
|
+
};
|
|
3816
|
+
var finalizeResult = (result, timing, flags) => {
|
|
3817
|
+
if (!flags.verbose) {
|
|
3818
|
+
return result;
|
|
3819
|
+
}
|
|
3820
|
+
return {
|
|
3821
|
+
...result,
|
|
3822
|
+
meta: createCommandMeta(timing.command, timing.startedAt, timing.finishedAt)
|
|
3823
|
+
};
|
|
3824
|
+
};
|
|
3825
|
+
|
|
3826
|
+
// src/cli/runner.ts
|
|
3827
|
+
var writeRenderedOutput = (rendered, writers) => {
|
|
3828
|
+
if (rendered.stdout) {
|
|
3829
|
+
writers.stdout.write(rendered.stdout);
|
|
3830
|
+
}
|
|
3831
|
+
if (rendered.stderr) {
|
|
3832
|
+
writers.stderr.write(rendered.stderr);
|
|
3833
|
+
}
|
|
3834
|
+
};
|
|
3835
|
+
var executeHostedCommand = async (command, handler, env, reporter) => {
|
|
3836
|
+
const startedAt = env.clock.now();
|
|
3837
|
+
let renderedSuccess = false;
|
|
3838
|
+
try {
|
|
3839
|
+
const hosted = await handler();
|
|
3840
|
+
const finishedAt = env.clock.now();
|
|
3841
|
+
const finalized = finalizeResult(hosted.result, { command, startedAt, finishedAt }, env.flags);
|
|
3842
|
+
const shouldRenderBootstrap = !reporter || reporter.kind === "plain";
|
|
3843
|
+
if (shouldRenderBootstrap) {
|
|
3844
|
+
writeRenderedOutput(
|
|
3845
|
+
renderResult(finalized, { command, flags: env.flags, now: finishedAt }),
|
|
3846
|
+
env
|
|
3847
|
+
);
|
|
3848
|
+
renderedSuccess = true;
|
|
3849
|
+
}
|
|
3850
|
+
let resolved = false;
|
|
3851
|
+
const stop = () => {
|
|
3852
|
+
if (resolved) {
|
|
3853
|
+
return;
|
|
3854
|
+
}
|
|
3855
|
+
resolved = true;
|
|
3856
|
+
hosted.stop();
|
|
3857
|
+
};
|
|
3858
|
+
process.once("SIGINT", stop);
|
|
3859
|
+
process.once("SIGTERM", stop);
|
|
3860
|
+
try {
|
|
3861
|
+
await hosted.completion;
|
|
3862
|
+
} finally {
|
|
3863
|
+
resolved = true;
|
|
3864
|
+
process.off("SIGINT", stop);
|
|
3865
|
+
process.off("SIGTERM", stop);
|
|
3866
|
+
reporter?.dispose();
|
|
3867
|
+
}
|
|
3868
|
+
return 0;
|
|
3869
|
+
} catch (error) {
|
|
3870
|
+
reporter?.dispose();
|
|
3871
|
+
const finishedAt = env.clock.now();
|
|
3872
|
+
const cliError = toCliError(error);
|
|
3873
|
+
const result = { ok: false, error: cliError };
|
|
3874
|
+
const finalized = finalizeResult(result, { command, startedAt, finishedAt }, env.flags);
|
|
3875
|
+
if (renderedSuccess && env.flags.json) {
|
|
3876
|
+
env.stderr.write(`${formatJson(finalized, env.flags)}
|
|
3877
|
+
`);
|
|
3878
|
+
return getExitCodeForError(error);
|
|
3879
|
+
}
|
|
3880
|
+
writeRenderedOutput(
|
|
3881
|
+
renderResult(finalized, { command, flags: env.flags, now: finishedAt }),
|
|
3882
|
+
env
|
|
3883
|
+
);
|
|
3884
|
+
return getExitCodeForError(error);
|
|
3885
|
+
}
|
|
3886
|
+
};
|
|
3887
|
+
|
|
3888
|
+
// src/cli/router.ts
|
|
3889
|
+
var commandName = (context) => {
|
|
3890
|
+
return context.path.join(" ");
|
|
3891
|
+
};
|
|
3892
|
+
|
|
3893
|
+
// src/cli/version-guard.ts
|
|
3894
|
+
var versionCheckOptions = async (context, onWarning = context.versionCheck.warn) => {
|
|
3895
|
+
return {
|
|
3896
|
+
clientVersion: context.versionCheck.clientVersion,
|
|
3897
|
+
forceRestart: await context.versionCheck.forceRestart(),
|
|
3898
|
+
onWarning
|
|
3899
|
+
};
|
|
3900
|
+
};
|
|
3901
|
+
|
|
3902
|
+
// src/cli/routes/mcp.ts
|
|
3903
|
+
var route = async (context) => {
|
|
3904
|
+
const { options, stateDir, env } = context;
|
|
3905
|
+
return executeHostedCommand(
|
|
3906
|
+
commandName(context),
|
|
3907
|
+
// Unlike every other command the check is threaded into the server itself, not run ahead
|
|
3908
|
+
// of it: the MCP server is long-lived and auto-spawns its own daemon, so the check belongs
|
|
3909
|
+
// on the startup stream that establishes the connection it keeps (ARCHITECTURE.md §9).
|
|
3910
|
+
// The notice always goes to stderr here, `--json` or not: stdout carries MCP protocol
|
|
3911
|
+
// frames, and stderr is this server's only log channel (ARCHITECTURE.md §9).
|
|
3912
|
+
async () => handleMcpCommand({
|
|
3913
|
+
stateDir,
|
|
3914
|
+
scheme: typeof options.scheme === "string" ? options.scheme : void 0,
|
|
3915
|
+
checkVersion: await versionCheckOptions(context, (message) => void env.stderr.write(message))
|
|
3916
|
+
}),
|
|
3917
|
+
env,
|
|
3918
|
+
{
|
|
3919
|
+
kind: "interactive",
|
|
3920
|
+
onEvent: () => {
|
|
3921
|
+
},
|
|
3922
|
+
dispose: () => {
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
);
|
|
3926
|
+
};
|
|
3927
|
+
export {
|
|
3928
|
+
route
|
|
3929
|
+
};
|