machine-bridge-mcp 0.2.4 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +148 -176
- package/SECURITY.md +84 -0
- package/docs/ARCHITECTURE.md +102 -0
- package/package.json +20 -9
- package/src/local/cli.mjs +289 -298
- package/src/local/daemon.mjs +376 -84
- package/src/local/log.mjs +33 -11
- package/src/local/self-test.mjs +173 -186
- package/src/local/service.mjs +43 -5
- package/src/local/shell.mjs +68 -14
- package/src/local/state.mjs +240 -51
- package/src/worker/index.ts +664 -424
- package/wrangler.jsonc +2 -2
- package/src/local/api-server.mjs +0 -368
package/src/local/cli.mjs
CHANGED
|
@@ -4,15 +4,15 @@ import path, { resolve } from "node:path";
|
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import readline from "node:readline/promises";
|
|
6
6
|
import { LocalDaemon } from "./daemon.mjs";
|
|
7
|
-
import {
|
|
8
|
-
import { createLogger, redactSecret } from "./log.mjs";
|
|
7
|
+
import { createLogger, sanitizeLogText } from "./log.mjs";
|
|
9
8
|
import { runWrangler } from "./shell.mjs";
|
|
10
9
|
import {
|
|
11
10
|
acquireDaemonLock,
|
|
11
|
+
acquireStartupLock,
|
|
12
12
|
appName,
|
|
13
|
+
daemonLockPathForState,
|
|
13
14
|
defaultStateRoot,
|
|
14
15
|
ensureOwnerOnlyDir,
|
|
15
|
-
ensureLocalApiKey,
|
|
16
16
|
ensureWorkerSecrets,
|
|
17
17
|
expandHome,
|
|
18
18
|
loadGlobalConfig,
|
|
@@ -20,8 +20,10 @@ import {
|
|
|
20
20
|
ownerOnlyFile,
|
|
21
21
|
packageRoot,
|
|
22
22
|
previewSecret,
|
|
23
|
+
readDaemonLockOwner,
|
|
23
24
|
redactState,
|
|
24
25
|
removeStateRoot,
|
|
26
|
+
validateStateRootForRemoval,
|
|
25
27
|
resolveWorkspace,
|
|
26
28
|
saveGlobalConfig,
|
|
27
29
|
saveState,
|
|
@@ -29,16 +31,27 @@ import {
|
|
|
29
31
|
setSelectedWorkspace,
|
|
30
32
|
} from "./state.mjs";
|
|
31
33
|
|
|
34
|
+
const BOOLEAN_OPTIONS = new Set([
|
|
35
|
+
"help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
|
|
36
|
+
"daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials",
|
|
37
|
+
"printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths",
|
|
38
|
+
"yes", "keepWorker", "noApi", "api", "rotateApiKey",
|
|
39
|
+
]);
|
|
40
|
+
const VALUE_OPTIONS = new Set([
|
|
41
|
+
"workspace", "stateDir", "workerName", "apiPort", "apiHost", "apiKey", "apiModel", "port",
|
|
42
|
+
]);
|
|
43
|
+
|
|
32
44
|
export async function main(argv = process.argv.slice(2)) {
|
|
33
45
|
const [command, rest] = normalizeCommand(argv);
|
|
34
46
|
const args = parseArgs(rest);
|
|
35
|
-
if (command === "api" && args.help) return apiUsage();
|
|
36
47
|
if (args.help || command === "help") return usage();
|
|
37
48
|
if (args.version || command === "version") return version();
|
|
49
|
+
if (command === "api") throw removedLocalApiError();
|
|
50
|
+
validateCommandOptions(command, args);
|
|
51
|
+
validatePositionals(command, args);
|
|
38
52
|
|
|
39
53
|
switch (command) {
|
|
40
54
|
case "start": return startCommand(args);
|
|
41
|
-
case "api": return apiCommand(args);
|
|
42
55
|
case "status": return statusCommand(args);
|
|
43
56
|
case "doctor": return doctorCommand(args);
|
|
44
57
|
case "workspace": return workspaceCommand(args);
|
|
@@ -53,28 +66,102 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
53
66
|
}
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
const COMMAND_OPTIONS = {
|
|
70
|
+
start: new Set([
|
|
71
|
+
"workspace", "stateDir", "workerName", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
|
|
72
|
+
"daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials", "printCredentials",
|
|
73
|
+
"noWrite", "noExec", "fullEnv", "unrestrictedPaths",
|
|
74
|
+
"noApi", "api", "apiPort", "apiHost", "apiKey", "rotateApiKey", "apiModel", "port",
|
|
75
|
+
]),
|
|
76
|
+
status: new Set(["workspace", "stateDir"]),
|
|
77
|
+
doctor: new Set(["workspace", "stateDir"]),
|
|
78
|
+
"rotate-secrets": new Set(["workspace", "stateDir", "workerName", "noPrintCredentials", "quiet"]),
|
|
79
|
+
workspace: new Set(["workspace", "stateDir"]),
|
|
80
|
+
service: new Set(["workspace", "stateDir", "quiet"]),
|
|
81
|
+
autostart: new Set(["workspace", "stateDir", "quiet"]),
|
|
82
|
+
uninstall: new Set(["stateDir", "keepWorker", "yes"]),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function validateCommandOptions(command, args) {
|
|
86
|
+
const allowed = COMMAND_OPTIONS[command];
|
|
87
|
+
if (!allowed) return;
|
|
88
|
+
for (const key of Object.keys(args)) {
|
|
89
|
+
if (key === "_" || key === "help" || key === "version") continue;
|
|
90
|
+
if (!allowed.has(key)) throw new Error(`Option --${toKebab(key)} is not valid for ${command}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function toKebab(value) {
|
|
95
|
+
return String(value).replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function validatePositionals(command, args) {
|
|
99
|
+
const count = args._.length;
|
|
100
|
+
const conflict = Boolean(args.workspace) && count > (command === "workspace" || command === "service" || command === "autostart" ? 1 : 0);
|
|
101
|
+
if (conflict) throw new Error("workspace path was provided both positionally and with --workspace");
|
|
102
|
+
if (["start", "status", "doctor", "rotate-secrets"].includes(command)) {
|
|
103
|
+
if (count > 1) throw new Error(`${command} accepts at most one positional workspace path`);
|
|
104
|
+
if (args.workspace && count) throw new Error("workspace path was provided both positionally and with --workspace");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (command === "workspace") {
|
|
108
|
+
const action = String(args._[0] || "show");
|
|
109
|
+
const max = action === "set" || action === "select" ? 2 : 1;
|
|
110
|
+
if (count > max) throw new Error(`workspace ${action} received too many positional arguments`);
|
|
111
|
+
if (args.workspace && count > 1) throw new Error("workspace path was provided both positionally and with --workspace");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (command === "service" || command === "autostart") {
|
|
115
|
+
const action = String(args._[0] || "status");
|
|
116
|
+
const max = action === "install" ? 2 : 1;
|
|
117
|
+
if (count > max) throw new Error(`service ${action} received too many positional arguments`);
|
|
118
|
+
if (args.workspace && count > 1) throw new Error("workspace path was provided both positionally and with --workspace");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (command === "uninstall" && count) throw new Error("uninstall does not accept positional arguments");
|
|
122
|
+
}
|
|
123
|
+
|
|
56
124
|
function normalizeCommand(argv) {
|
|
57
125
|
if (!argv.length || argv[0].startsWith("--")) return ["start", argv];
|
|
58
126
|
return [argv[0], argv.slice(1)];
|
|
59
127
|
}
|
|
60
128
|
|
|
61
|
-
function parseArgs(argv) {
|
|
129
|
+
export function parseArgs(argv) {
|
|
62
130
|
const out = { _: [] };
|
|
131
|
+
let positionalOnly = false;
|
|
63
132
|
for (let i = 0; i < argv.length; i += 1) {
|
|
64
133
|
const raw = argv[i];
|
|
65
|
-
if (!raw.startsWith("--")) {
|
|
134
|
+
if (positionalOnly || raw === "-" || !raw.startsWith("--")) {
|
|
66
135
|
out._.push(raw);
|
|
67
136
|
continue;
|
|
68
137
|
}
|
|
138
|
+
if (raw === "--") {
|
|
139
|
+
positionalOnly = true;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
69
142
|
const eq = raw.indexOf("=");
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
143
|
+
const rawKey = raw.slice(2, eq >= 0 ? eq : undefined);
|
|
144
|
+
if (!rawKey) throw new Error("invalid empty option");
|
|
145
|
+
const key = toCamel(rawKey);
|
|
146
|
+
if (!BOOLEAN_OPTIONS.has(key) && !VALUE_OPTIONS.has(key)) throw new Error(`Unknown option: --${rawKey}`);
|
|
147
|
+
if (Object.prototype.hasOwnProperty.call(out, key)) throw new Error(`Duplicate option: --${rawKey}`);
|
|
148
|
+
if (BOOLEAN_OPTIONS.has(key)) {
|
|
149
|
+
out[key] = eq >= 0 ? parseBooleanOption(raw.slice(eq + 1), rawKey) : true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const value = eq >= 0 ? raw.slice(eq + 1) : argv[++i];
|
|
153
|
+
if (value === undefined || value === "" || value.startsWith("--")) throw new Error(`Option --${rawKey} requires a value`);
|
|
154
|
+
out[key] = value;
|
|
74
155
|
}
|
|
75
156
|
return out;
|
|
76
157
|
}
|
|
77
158
|
|
|
159
|
+
function parseBooleanOption(value, key) {
|
|
160
|
+
if (value === "true" || value === "1") return true;
|
|
161
|
+
if (value === "false" || value === "0") return false;
|
|
162
|
+
throw new Error(`Option --${key} expects true or false when using =`);
|
|
163
|
+
}
|
|
164
|
+
|
|
78
165
|
function toCamel(key) {
|
|
79
166
|
return key.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
|
|
80
167
|
}
|
|
@@ -159,198 +246,108 @@ async function confirm(prompt, assumeYes = false) {
|
|
|
159
246
|
|
|
160
247
|
async function startCommand(args) {
|
|
161
248
|
assertNodeVersion();
|
|
249
|
+
assertNoRemovedLocalApiOptions(args);
|
|
162
250
|
const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "cli" });
|
|
163
251
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
|
|
164
252
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName: args.workerName && String(args.workerName) });
|
|
170
|
-
if (apiEnabled) configureLocalApiState(state, args);
|
|
171
|
-
state.policy = {
|
|
172
|
-
allowWrite: args.noWrite ? false : true,
|
|
173
|
-
allowExec: args.noExec ? false : true,
|
|
174
|
-
unrestrictedPaths: true,
|
|
175
|
-
minimalEnv: args.fullEnv ? false : true,
|
|
176
|
-
apiEnabled,
|
|
177
|
-
updatedAt: new Date().toISOString(),
|
|
178
|
-
};
|
|
179
|
-
saveState(state);
|
|
180
|
-
|
|
181
|
-
if (!args.daemonOnly) await ensureWorker(state, args);
|
|
182
|
-
else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
|
|
183
|
-
|
|
184
|
-
const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
|
|
185
|
-
const shouldPrintMcpCredentials = Boolean(args.json || args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
|
|
186
|
-
|
|
187
|
-
if (!args.daemonOnly && !args.noAutostart) {
|
|
188
|
-
await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], policy: state.policy });
|
|
253
|
+
const startupLock = acquireStartupLock(state);
|
|
254
|
+
if (!startupLock.acquired) {
|
|
255
|
+
const pid = startupLock.owner?.pid ? `pid ${startupLock.owner.pid}` : "unknown pid";
|
|
256
|
+
throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
|
|
189
257
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
let daemon = null;
|
|
195
|
-
let apiServer = null;
|
|
196
|
-
if (!lock.acquired) {
|
|
197
|
-
if (!args.quiet) {
|
|
198
|
-
const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
|
|
199
|
-
logger.warn(`local daemon already running for this workspace (${pid}); not starting a duplicate`);
|
|
200
|
-
if (!args.json) printMcpConnection(state, {
|
|
201
|
-
noPrintCredentials: Boolean(args.noPrintCredentials),
|
|
202
|
-
includeCredentials: shouldPrintMcpCredentials,
|
|
203
|
-
quiet: Boolean(args.quiet),
|
|
204
|
-
});
|
|
258
|
+
try {
|
|
259
|
+
if (args.daemonOnly) {
|
|
260
|
+
const { trimAutostartLogs } = await import("./service.mjs");
|
|
261
|
+
trimAutostartLogs(state.paths.stateRoot);
|
|
205
262
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
263
|
+
const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
|
|
264
|
+
const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
|
|
265
|
+
|
|
266
|
+
const workerName = validateWorkerName(args.workerName);
|
|
267
|
+
ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
|
|
268
|
+
state.policy = {
|
|
269
|
+
allowWrite: args.noWrite ? false : true,
|
|
270
|
+
allowExec: args.noExec ? false : true,
|
|
271
|
+
unrestrictedPaths: Boolean(args.unrestrictedPaths),
|
|
272
|
+
minimalEnv: args.fullEnv ? false : true,
|
|
273
|
+
updatedAt: new Date().toISOString(),
|
|
274
|
+
};
|
|
275
|
+
saveState(state);
|
|
276
|
+
|
|
277
|
+
if (!args.daemonOnly) await ensureWorker(state, args);
|
|
278
|
+
else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
|
|
279
|
+
|
|
280
|
+
const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
|
|
281
|
+
const shouldPrintMcpCredentials = Boolean(args.json || args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
|
|
282
|
+
|
|
283
|
+
if (!args.daemonOnly && !args.noAutostart) {
|
|
284
|
+
await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], policy: state.policy });
|
|
209
285
|
}
|
|
210
|
-
apiServer = await startOptionalApiServer(state, args, logger);
|
|
211
|
-
if (args.json) printStartJson(state, apiServer, { noPrintCredentials: Boolean(args.noPrintCredentials) });
|
|
212
|
-
else if (apiServer) printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials), quiet: Boolean(args.quiet) });
|
|
213
|
-
if (apiServer && !apiServer.alreadyRunning) keepProcessAlive({ apiServer, logger });
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
286
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
includeCredentials: shouldPrintMcpCredentials,
|
|
234
|
-
quiet: Boolean(args.quiet),
|
|
235
|
-
});
|
|
236
|
-
if (apiServer) printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials), quiet: Boolean(args.quiet) });
|
|
287
|
+
if (!args.daemonOnly) await stopAutostartBestEffort(Boolean(args.quiet));
|
|
288
|
+
|
|
289
|
+
const lock = acquireDaemonLock(state);
|
|
290
|
+
let daemon = null;
|
|
291
|
+
if (!lock.acquired) {
|
|
292
|
+
if (!args.quiet) {
|
|
293
|
+
const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
|
|
294
|
+
logger.warn(`local daemon already running for this workspace (${pid}); not starting a duplicate`);
|
|
295
|
+
if (!args.json) printMcpConnection(state, {
|
|
296
|
+
noPrintCredentials: Boolean(args.noPrintCredentials),
|
|
297
|
+
includeCredentials: shouldPrintMcpCredentials,
|
|
298
|
+
quiet: Boolean(args.quiet),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
if (args.json) printStartJson(state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
|
|
302
|
+
return;
|
|
237
303
|
}
|
|
238
|
-
keepProcessAlive({ daemon, lock, apiServer: apiServer?.alreadyRunning ? null : apiServer, logger });
|
|
239
|
-
} catch (error) {
|
|
240
|
-
try { daemon?.stop?.(); } catch {}
|
|
241
|
-
lock.release();
|
|
242
|
-
if (apiServer && !apiServer.alreadyRunning) await apiServer.close().catch(() => {});
|
|
243
|
-
throw error;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
304
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (args.json) printApiJson(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
|
|
256
|
-
else printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials), quiet: Boolean(args.quiet) });
|
|
257
|
-
keepProcessAlive({ apiServer, logger });
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
async function startConfiguredApiServer(state, args, logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "api" })) {
|
|
262
|
-
const apiOptions = apiOptionsFromArgs(state, args);
|
|
263
|
-
return startLocalApiServer({ ...apiOptions, logger });
|
|
264
|
-
}
|
|
305
|
+
try {
|
|
306
|
+
daemon = new LocalDaemon({
|
|
307
|
+
workerUrl: state.worker.url,
|
|
308
|
+
secret: state.worker.daemonSecret,
|
|
309
|
+
workspace,
|
|
310
|
+
policy: state.policy,
|
|
311
|
+
logger: createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "daemon" }),
|
|
312
|
+
});
|
|
265
313
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (health.ok) {
|
|
276
|
-
logger.success("local OpenAI-compatible API already running", { baseUrl });
|
|
277
|
-
return { ...health, baseUrl, host: apiOptions.host, port: Number(apiOptions.port), alreadyRunning: true, close() { return Promise.resolve(); } };
|
|
314
|
+
const waitForConnect = daemon.start();
|
|
315
|
+
await waitForConnectWithNotice(waitForConnect, 20_000, Boolean(args.quiet || args.json));
|
|
316
|
+
if (args.json) printStartJson(state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
|
|
317
|
+
else {
|
|
318
|
+
printMcpConnection(state, {
|
|
319
|
+
noPrintCredentials: Boolean(args.noPrintCredentials),
|
|
320
|
+
includeCredentials: shouldPrintMcpCredentials,
|
|
321
|
+
quiet: Boolean(args.quiet),
|
|
322
|
+
});
|
|
278
323
|
}
|
|
279
|
-
|
|
280
|
-
|
|
324
|
+
keepProcessAlive({ daemon, lock, logger });
|
|
325
|
+
} catch (error) {
|
|
326
|
+
try { daemon?.stop?.(); } catch {}
|
|
327
|
+
lock.release();
|
|
328
|
+
throw error;
|
|
281
329
|
}
|
|
282
|
-
|
|
283
|
-
|
|
330
|
+
} finally {
|
|
331
|
+
startupLock.release();
|
|
284
332
|
}
|
|
285
333
|
}
|
|
286
334
|
|
|
287
|
-
function
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
return `http://${urlHost}:${port || DEFAULT_API_PORT}/v1`;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async function probeLocalApiHealth(baseUrl, expectedApiKey = "") {
|
|
294
|
-
try {
|
|
295
|
-
const healthUrl = `${String(baseUrl).replace(/\/v1$/, "")}/health`;
|
|
296
|
-
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(750) });
|
|
297
|
-
if (!response.ok) return { ok: false };
|
|
298
|
-
const body = await response.json().catch(() => null);
|
|
299
|
-
if (body?.service !== "machine-bridge-mcp-local-api") return { ok: false };
|
|
300
|
-
if (body.api_key_sha256 && expectedApiKey && body.api_key_sha256 !== sha256String(expectedApiKey)) return { ok: false, reason: "api_key_mismatch" };
|
|
301
|
-
return { ok: true };
|
|
302
|
-
} catch {
|
|
303
|
-
return { ok: false };
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function apiOptionsFromArgs(state, args = {}) {
|
|
308
|
-
const explicitPort = explicitArg(args.apiPort) ?? explicitArg(args.port);
|
|
309
|
-
const envPort = valueFromArgsEnv(undefined, "MBM_API_PORT", "PORT");
|
|
310
|
-
return {
|
|
311
|
-
host: valueFromArgsEnv(args.apiHost, "MBM_API_HOST") || state.localApi?.host || DEFAULT_API_HOST,
|
|
312
|
-
port: explicitPort ?? envPort ?? state.localApi?.port ?? DEFAULT_API_PORT,
|
|
313
|
-
apiKey: valueFromArgsEnv(args.apiKey, "MBM_API_KEY") || state.localApi?.apiKey || ensureLocalApiKey(state, { rotateApiKey: Boolean(args.rotateApiKey) }),
|
|
314
|
-
model: valueFromArgsEnv(args.apiModel, "MBM_API_MODEL") || state.localApi?.model || DEFAULT_API_MODEL,
|
|
315
|
-
workerUrl: state.worker?.url || "",
|
|
316
|
-
daemonSecret: state.worker?.daemonSecret || "",
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function configureLocalApiState(state, args = {}) {
|
|
321
|
-
state.localApi ||= {};
|
|
322
|
-
ensureLocalApiKey(state, { apiKey: explicitArg(args.apiKey), rotateApiKey: Boolean(args.rotateApiKey) });
|
|
323
|
-
const port = explicitArg(args.apiPort) ?? explicitArg(args.port);
|
|
324
|
-
if (port !== undefined && String(port) !== "0") state.localApi.port = String(port);
|
|
325
|
-
const host = explicitArg(args.apiHost);
|
|
326
|
-
if (host !== undefined) state.localApi.host = String(host);
|
|
327
|
-
const model = explicitArg(args.apiModel);
|
|
328
|
-
if (model !== undefined) state.localApi.model = String(model);
|
|
329
|
-
state.localApi.updatedAt = new Date().toISOString();
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function explicitArg(value) {
|
|
333
|
-
return value !== undefined && value !== null && value !== true ? String(value) : undefined;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function sha256String(value) {
|
|
337
|
-
return createHash("sha256").update(String(value)).digest("hex");
|
|
335
|
+
function assertNoRemovedLocalApiOptions(args) {
|
|
336
|
+
const removed = ["api", "noApi", "apiPort", "apiHost", "apiKey", "rotateApiKey", "apiModel", "port"].filter((key) => args[key] !== undefined);
|
|
337
|
+
if (removed.length) throw removedLocalApiError();
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
function
|
|
341
|
-
|
|
342
|
-
for (const name of envNames) {
|
|
343
|
-
if (process.env[name]) return process.env[name];
|
|
344
|
-
}
|
|
345
|
-
return undefined;
|
|
340
|
+
function removedLocalApiError() {
|
|
341
|
+
return new Error("Local /v1 API support has been removed. Use the printed Remote MCP Server URL/password with an MCP client instead.");
|
|
346
342
|
}
|
|
347
343
|
|
|
348
344
|
async function ensureWorker(state, args) {
|
|
349
345
|
const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "worker" });
|
|
350
346
|
const desiredHash = workerDeployHash(state);
|
|
347
|
+
const expectedVersion = currentPackageVersion();
|
|
351
348
|
const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.oauthPassword && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
|
|
352
349
|
if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
|
|
353
|
-
const health = await workerHealth(state.worker.url);
|
|
350
|
+
const health = await workerHealth(state.worker.url, expectedVersion);
|
|
354
351
|
if (health.ok) {
|
|
355
352
|
logger.success("Worker unchanged and healthy", { url: state.worker.url });
|
|
356
353
|
return state.worker;
|
|
@@ -378,13 +375,17 @@ async function ensureWorker(state, args) {
|
|
|
378
375
|
if (!workerUrl) throw new Error("Worker deployed but URL could not be detected. Re-run with --worker-name or inspect Wrangler output.");
|
|
379
376
|
state.worker.url = workerUrl.replace(/\/+$/, "");
|
|
380
377
|
state.worker.mcpServerUrl = `${state.worker.url}/mcp`;
|
|
381
|
-
state.worker.deployHash
|
|
378
|
+
delete state.worker.deployHash;
|
|
382
379
|
state.worker.updatedAt = new Date().toISOString();
|
|
383
380
|
saveState(state);
|
|
384
381
|
|
|
385
|
-
const health = await retryHealth(state.worker.url, 8);
|
|
386
|
-
if (!health.ok)
|
|
387
|
-
|
|
382
|
+
const health = await retryHealth(state.worker.url, expectedVersion, 8);
|
|
383
|
+
if (!health.ok) {
|
|
384
|
+
throw new Error(`Worker deployment did not become healthy at the expected version: ${health.error}`);
|
|
385
|
+
}
|
|
386
|
+
state.worker.deployHash = desiredHash;
|
|
387
|
+
saveState(state);
|
|
388
|
+
logger.success("Worker ready", { url: state.worker.url, version: health.version });
|
|
388
389
|
return state.worker;
|
|
389
390
|
}
|
|
390
391
|
|
|
@@ -432,11 +433,7 @@ function workerDeployHash(state) {
|
|
|
432
433
|
}
|
|
433
434
|
|
|
434
435
|
function workerHashContent(file) {
|
|
435
|
-
|
|
436
|
-
if (path.relative(packageRoot, file).replaceAll("\\", "/") === "src/worker/index.ts") {
|
|
437
|
-
return content.replace(/const SERVER_VERSION = "[^"]+";/, 'const SERVER_VERSION = "<ignored-for-deploy-hash>";');
|
|
438
|
-
}
|
|
439
|
-
return content;
|
|
436
|
+
return readFileSync(file, "utf8");
|
|
440
437
|
}
|
|
441
438
|
|
|
442
439
|
function workerDeployHashFiles() {
|
|
@@ -461,23 +458,24 @@ function collectHashFiles(target, out) {
|
|
|
461
458
|
}
|
|
462
459
|
}
|
|
463
460
|
|
|
464
|
-
async function workerHealth(workerUrl) {
|
|
461
|
+
async function workerHealth(workerUrl, expectedVersion = currentPackageVersion()) {
|
|
465
462
|
if (!workerUrl) return { ok: false, error: "missing_worker_url" };
|
|
466
463
|
try {
|
|
467
464
|
const response = await fetch(`${String(workerUrl).replace(/\/+$/, "")}/healthz`, { signal: AbortSignal.timeout(5000) });
|
|
468
465
|
if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
|
|
469
466
|
const body = await response.json().catch(() => null);
|
|
470
467
|
if (body?.ok !== true || body?.server !== appName) return { ok: false, error: "unexpected_health_response" };
|
|
471
|
-
return { ok:
|
|
468
|
+
if (body?.version !== expectedVersion) return { ok: false, error: `version_mismatch:${body?.version || "unknown"}!=${expectedVersion}` };
|
|
469
|
+
return { ok: true, version: body.version };
|
|
472
470
|
} catch (error) {
|
|
473
471
|
return { ok: false, error: error.message };
|
|
474
472
|
}
|
|
475
473
|
}
|
|
476
474
|
|
|
477
|
-
async function retryHealth(workerUrl, attempts) {
|
|
475
|
+
async function retryHealth(workerUrl, expectedVersion, attempts) {
|
|
478
476
|
let last = { ok: false, error: "not_checked" };
|
|
479
477
|
for (let i = 0; i < attempts; i += 1) {
|
|
480
|
-
last = await workerHealth(workerUrl);
|
|
478
|
+
last = await workerHealth(workerUrl, expectedVersion);
|
|
481
479
|
if (last.ok) return last;
|
|
482
480
|
await sleep(1000 + i * 500);
|
|
483
481
|
}
|
|
@@ -502,10 +500,8 @@ async function waitForConnectWithNotice(promise, timeoutMs, quiet = false) {
|
|
|
502
500
|
}
|
|
503
501
|
|
|
504
502
|
|
|
505
|
-
function printStartJson(state,
|
|
503
|
+
function printStartJson(state, { noPrintCredentials = false } = {}) {
|
|
506
504
|
const mcpPassword = noPrintCredentials ? previewSecret(state.worker.oauthPassword) : state.worker.oauthPassword;
|
|
507
|
-
const runtimeApiKey = apiServer?.apiKey || state.localApi?.apiKey || "";
|
|
508
|
-
const apiKey = runtimeApiKey ? (noPrintCredentials ? previewSecret(runtimeApiKey) : runtimeApiKey) : null;
|
|
509
505
|
createLogger({ component: "ready" }).json({
|
|
510
506
|
mcp: {
|
|
511
507
|
server_url: state.worker.mcpServerUrl,
|
|
@@ -513,37 +509,12 @@ function printStartJson(state, apiServer, { noPrintCredentials = false } = {}) {
|
|
|
513
509
|
worker_url: state.worker.url,
|
|
514
510
|
worker_name: state.worker.name,
|
|
515
511
|
},
|
|
516
|
-
local_api: apiServer ? {
|
|
517
|
-
base_url: apiServer.baseUrl,
|
|
518
|
-
api_key: apiKey,
|
|
519
|
-
already_running: Boolean(apiServer.alreadyRunning),
|
|
520
|
-
backend: "chatgpt-mcp",
|
|
521
|
-
model: state.localApi?.model || DEFAULT_API_MODEL,
|
|
522
|
-
mcp_bridge_configured: Boolean(state.worker?.url && state.worker?.daemonSecret),
|
|
523
|
-
client_type: "OpenAI-compatible",
|
|
524
|
-
} : null,
|
|
525
512
|
workspace: state.workspace.path,
|
|
526
513
|
state_path: state.paths.statePath,
|
|
527
514
|
policy: state.policy,
|
|
528
515
|
});
|
|
529
516
|
}
|
|
530
517
|
|
|
531
|
-
function printApiJson(apiServer, state, { noPrintCredentials = false } = {}) {
|
|
532
|
-
const runtimeApiKey = apiServer?.apiKey || state.localApi?.apiKey || "";
|
|
533
|
-
createLogger({ component: "ready" }).json({
|
|
534
|
-
local_api: {
|
|
535
|
-
base_url: apiServer.baseUrl,
|
|
536
|
-
api_key: noPrintCredentials ? previewSecret(runtimeApiKey) : runtimeApiKey,
|
|
537
|
-
backend: "chatgpt-mcp",
|
|
538
|
-
model: apiServer?.model || state.localApi?.model || DEFAULT_API_MODEL,
|
|
539
|
-
mcp_bridge_configured: Boolean(state.worker?.url && state.worker?.daemonSecret),
|
|
540
|
-
client_type: "OpenAI-compatible",
|
|
541
|
-
},
|
|
542
|
-
workspace: state.workspace.path,
|
|
543
|
-
state_path: state.paths.statePath,
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
|
|
547
518
|
function printMcpConnection(state, { json = false, noPrintCredentials = false, includeCredentials = false, quiet = false } = {}) {
|
|
548
519
|
const logger = createLogger({ component: "ready", quiet });
|
|
549
520
|
const payload = {
|
|
@@ -570,32 +541,17 @@ function printMcpConnection(state, { json = false, noPrintCredentials = false, i
|
|
|
570
541
|
logger.plain(" Use --print-mcp-credentials only when a ChatGPT app needs to reconnect.");
|
|
571
542
|
}
|
|
572
543
|
logger.plain(` Workspace cwd: ${payload.workspace}`);
|
|
573
|
-
logger.plain(` Policy: write=${payload.policy.allowWrite ? "on" : "off"}, exec=${payload.policy.allowExec ? "on" : "off"},
|
|
544
|
+
logger.plain(` Policy: write=${payload.policy.allowWrite ? "on" : "off"}, exec=${payload.policy.allowExec ? "on" : "off"}, unrestricted_paths=${payload.policy.unrestrictedPaths ? "on" : "off"}`);
|
|
574
545
|
logger.plain(` State: ${payload.state_path}`);
|
|
575
546
|
}
|
|
576
547
|
|
|
577
|
-
function
|
|
578
|
-
const logger = createLogger({ component: "ready", quiet });
|
|
579
|
-
const runtimeApiKey = apiServer?.apiKey || state.localApi?.apiKey || "";
|
|
580
|
-
logger.success("Local ChatGPT MCP-backed OpenAI-compatible API is running");
|
|
581
|
-
logger.plain(` API Base URL: ${apiServer.baseUrl}`);
|
|
582
|
-
logger.plain(` API key: ${noPrintCredentials ? `${redactSecret(runtimeApiKey)} (redacted)` : runtimeApiKey}`);
|
|
583
|
-
logger.plain(" Client type: OpenAI-compatible");
|
|
584
|
-
logger.plain(` Model: ${apiServer?.model || state.localApi?.model || DEFAULT_API_MODEL}`);
|
|
585
|
-
logger.plain(" Backend: ChatGPT MCP sampling via the connected ChatGPT app");
|
|
586
|
-
if (state.worker?.mcpServerUrl) logger.plain(` MCP Server URL: ${state.worker.mcpServerUrl}`);
|
|
587
|
-
else logger.plain(" MCP bridge: not deployed yet; run `machine-mcp` before using generation routes.");
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
function keepProcessAlive({ daemon = null, lock = null, apiServer = null, logger = createLogger({ component: "cli" }) } = {}) {
|
|
548
|
+
function keepProcessAlive({ daemon = null, lock = null, logger = createLogger({ component: "cli" }) } = {}) {
|
|
592
549
|
let stopping = false;
|
|
593
550
|
const stop = async () => {
|
|
594
551
|
if (stopping) return;
|
|
595
552
|
stopping = true;
|
|
596
553
|
logger.info("stopping local services");
|
|
597
554
|
try { daemon?.stop?.(); } catch {}
|
|
598
|
-
try { await apiServer?.close?.(); } catch {}
|
|
599
555
|
try { lock?.release?.(); } catch {}
|
|
600
556
|
process.exit(0);
|
|
601
557
|
};
|
|
@@ -616,11 +572,11 @@ async function statusCommand(args) {
|
|
|
616
572
|
async function doctorCommand(args) {
|
|
617
573
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
|
|
618
574
|
const checks = [];
|
|
619
|
-
checks.push({ name: "node", ok: Number(process.versions.node.split(".")[0]) >=
|
|
575
|
+
checks.push({ name: "node", ok: Number(process.versions.node.split(".")[0]) >= 22, detail: process.version });
|
|
620
576
|
const wrangler = await runWrangler(["--version"], { capture: true, allowFailure: true });
|
|
621
577
|
checks.push({ name: "wrangler", ok: wrangler.code === 0, detail: (wrangler.stdout || wrangler.stderr).trim() });
|
|
622
578
|
const whoami = await runWrangler(["whoami"], { capture: true, allowFailure: true });
|
|
623
|
-
checks.push({ name: "cloudflare-login", ok: whoami.code === 0, detail: sanitizeLines(whoami.
|
|
579
|
+
checks.push({ name: "cloudflare-login", ok: whoami.code === 0, detail: whoami.code === 0 ? "authenticated" : sanitizeLines(whoami.stderr || whoami.stdout) });
|
|
624
580
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
625
581
|
const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
|
|
626
582
|
checks.push({ name: "worker-health", ok: health.ok, detail: health.ok ? state.worker.url : health.error });
|
|
@@ -630,11 +586,26 @@ async function doctorCommand(args) {
|
|
|
630
586
|
async function rotateSecretsCommand(args) {
|
|
631
587
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
|
|
632
588
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
589
|
+
const startupLock = acquireStartupLock(state);
|
|
590
|
+
if (!startupLock.acquired) {
|
|
591
|
+
const pid = startupLock.owner?.pid ? `pid ${startupLock.owner.pid}` : "unknown pid";
|
|
592
|
+
throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
await stopAutostartBestEffort(Boolean(args.quiet));
|
|
596
|
+
await sleep(500);
|
|
597
|
+
const daemonOwner = readDaemonLockOwner(daemonLockPathForState(state));
|
|
598
|
+
if (daemonOwner?.pid && isPidAlive(daemonOwner.pid)) {
|
|
599
|
+
throw new Error(`refusing to rotate secrets while the daemon is active (pid ${daemonOwner.pid}); stop the foreground daemon and retry`);
|
|
600
|
+
}
|
|
601
|
+
ensureWorkerSecrets(state, { rotateSecrets: true, workerName: validateWorkerName(args.workerName) });
|
|
602
|
+
saveState(state);
|
|
603
|
+
console.log(`Rotated MCP password: ${args.noPrintCredentials ? "<redacted>" : state.worker.oauthPassword}`);
|
|
604
|
+
console.log(`Rotated daemon secret: ${args.noPrintCredentials ? "<redacted>" : previewSecret(state.worker.daemonSecret)}`);
|
|
605
|
+
console.log("Run start to redeploy the Worker with the new secrets and revoke old OAuth access tokens.");
|
|
606
|
+
} finally {
|
|
607
|
+
startupLock.release();
|
|
608
|
+
}
|
|
638
609
|
}
|
|
639
610
|
|
|
640
611
|
async function serviceCommand(args) {
|
|
@@ -698,6 +669,7 @@ async function stopAutostartBestEffort(quiet = false) {
|
|
|
698
669
|
async function uninstallCommand(args) {
|
|
699
670
|
const stateRoot = stateRootFromArgs(args);
|
|
700
671
|
const deleteRemote = !args.keepWorker;
|
|
672
|
+
validateStateRootForRemoval(stateRoot);
|
|
701
673
|
const action = deleteRemote
|
|
702
674
|
? `delete deployed Worker(s), remove autostart entries, and remove local state at ${stateRoot}`
|
|
703
675
|
: `remove autostart entries and local state at ${stateRoot} while keeping deployed Worker(s)`;
|
|
@@ -706,8 +678,15 @@ async function uninstallCommand(args) {
|
|
|
706
678
|
console.log("Uninstall cancelled. Re-run with `machine-mcp uninstall --yes` to skip confirmation.");
|
|
707
679
|
return;
|
|
708
680
|
}
|
|
681
|
+
const autostartRemoved = await removeAutostartBestEffort(stateRoot);
|
|
682
|
+
if (!autostartRemoved) throw new Error("autostart removal failed; state and Worker were kept so the uninstall can be retried safely");
|
|
683
|
+
await sleep(500);
|
|
684
|
+
const activeLocks = activeStateLocks(stateRoot);
|
|
685
|
+
if (activeLocks.length) {
|
|
686
|
+
const detail = activeLocks.map((item) => `${item.kind}:${item.pid || "unknown"}`).join(", ");
|
|
687
|
+
throw new Error(`refusing to uninstall while Machine Bridge processes are active (${detail}); stop foreground sessions and retry`);
|
|
688
|
+
}
|
|
709
689
|
if (deleteRemote) await deleteKnownWorkers(stateRoot);
|
|
710
|
-
await removeAutostartBestEffort(stateRoot);
|
|
711
690
|
removeStateRoot(stateRoot);
|
|
712
691
|
console.log("Removed local autostart entries and state.");
|
|
713
692
|
if (deleteRemote) console.log("Requested deletion for known deployed Worker(s).");
|
|
@@ -721,10 +700,18 @@ async function deleteKnownWorkers(stateRoot) {
|
|
|
721
700
|
console.log("No deployed Worker name found in local state.");
|
|
722
701
|
return;
|
|
723
702
|
}
|
|
703
|
+
const failures = [];
|
|
724
704
|
for (const name of names) {
|
|
725
705
|
const result = await runWrangler(["delete", name, "--force"], { capture: true, allowFailure: true });
|
|
726
|
-
|
|
727
|
-
|
|
706
|
+
const detail = (result.stderr || result.stdout || "unknown error").trim();
|
|
707
|
+
if (result.code === 0 || /not found|does not exist|could not find/i.test(detail)) {
|
|
708
|
+
console.log(`Deleted Worker: ${name}`);
|
|
709
|
+
} else {
|
|
710
|
+
failures.push({ name, detail: sanitizeLines(detail) || "unknown error" });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (failures.length) {
|
|
714
|
+
throw new Error(`failed to delete Worker(s): ${failures.map((item) => `${item.name} (${item.detail})`).join(", ")}; local state was kept for retry`);
|
|
728
715
|
}
|
|
729
716
|
}
|
|
730
717
|
|
|
@@ -738,7 +725,8 @@ function knownWorkerNames(stateRoot) {
|
|
|
738
725
|
if (!existsSync(stateFile)) continue;
|
|
739
726
|
try {
|
|
740
727
|
const state = JSON.parse(readFileSync(stateFile, "utf8"));
|
|
741
|
-
|
|
728
|
+
const name = String(state?.worker?.name || "");
|
|
729
|
+
if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) names.add(name);
|
|
742
730
|
} catch {}
|
|
743
731
|
}
|
|
744
732
|
return [...names];
|
|
@@ -746,10 +734,44 @@ function knownWorkerNames(stateRoot) {
|
|
|
746
734
|
|
|
747
735
|
async function removeAutostartBestEffort(stateRoot) {
|
|
748
736
|
try {
|
|
749
|
-
const { uninstallAutostart } = await import("./service.mjs");
|
|
750
|
-
await
|
|
737
|
+
const { stopAutostart, uninstallAutostart } = await import("./service.mjs");
|
|
738
|
+
await stopAutostart({ logger: structuredLogger(true) }).catch(() => {});
|
|
739
|
+
const result = await uninstallAutostart({ stateRoot, logger: structuredLogger(false) });
|
|
740
|
+
if (result?.ok === false) {
|
|
741
|
+
console.warn("Autostart removal reported failure.");
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
return true;
|
|
751
745
|
} catch (error) {
|
|
752
746
|
console.warn(`Autostart removal skipped or failed: ${error.message}`);
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function activeStateLocks(stateRoot) {
|
|
752
|
+
const profiles = resolve(expandHome(stateRoot), "profiles");
|
|
753
|
+
if (!existsSync(profiles)) return [];
|
|
754
|
+
const active = [];
|
|
755
|
+
for (const profile of readdirSync(profiles, { withFileTypes: true })) {
|
|
756
|
+
if (!profile.isDirectory()) continue;
|
|
757
|
+
for (const [kind, name] of [["daemon", "daemon.lock"], ["startup", "startup.lock"]]) {
|
|
758
|
+
const lockPath = resolve(profiles, profile.name, name);
|
|
759
|
+
if (!existsSync(lockPath)) continue;
|
|
760
|
+
const owner = readDaemonLockOwner(lockPath);
|
|
761
|
+
if (owner?.pid && isPidAlive(owner.pid)) active.push({ kind, pid: owner.pid, path: lockPath });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return active;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function isPidAlive(pid) {
|
|
768
|
+
const parsed = Number(pid);
|
|
769
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return false;
|
|
770
|
+
try {
|
|
771
|
+
process.kill(parsed, 0);
|
|
772
|
+
return true;
|
|
773
|
+
} catch (error) {
|
|
774
|
+
return error?.code === "EPERM";
|
|
753
775
|
}
|
|
754
776
|
}
|
|
755
777
|
|
|
@@ -760,7 +782,7 @@ function structuredLogger(quiet) {
|
|
|
760
782
|
function sanitizeLines(text) {
|
|
761
783
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
762
784
|
const homePattern = home ? new RegExp(escapeRegExp(home), "g") : null;
|
|
763
|
-
let value =
|
|
785
|
+
let value = sanitizeLogText(text)
|
|
764
786
|
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "<email>");
|
|
765
787
|
if (homePattern) value = value.replace(homePattern, "~");
|
|
766
788
|
return value
|
|
@@ -774,9 +796,18 @@ function escapeRegExp(value) {
|
|
|
774
796
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
775
797
|
}
|
|
776
798
|
|
|
799
|
+
function validateWorkerName(value) {
|
|
800
|
+
if (value === undefined || value === null || value === false) return undefined;
|
|
801
|
+
const name = String(value).trim();
|
|
802
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) {
|
|
803
|
+
throw new Error("--worker-name must be 1-63 lowercase letters, digits, or hyphens, and cannot start or end with a hyphen");
|
|
804
|
+
}
|
|
805
|
+
return name;
|
|
806
|
+
}
|
|
807
|
+
|
|
777
808
|
function assertNodeVersion() {
|
|
778
809
|
const major = Number(process.versions.node.split(".")[0]);
|
|
779
|
-
if (major <
|
|
810
|
+
if (major < 22) throw new Error(`Node.js >=22 is required; current ${process.version}`);
|
|
780
811
|
}
|
|
781
812
|
|
|
782
813
|
function usage() {
|
|
@@ -789,8 +820,7 @@ Usage:
|
|
|
789
820
|
.\\mbm.cmd # from source checkout on Windows cmd
|
|
790
821
|
|
|
791
822
|
Commands:
|
|
792
|
-
start Deploy/update Worker, install autostart, start daemon
|
|
793
|
-
api Start local ChatGPT MCP-backed OpenAI-compatible API only
|
|
823
|
+
start Deploy/update Worker, install autostart, start daemon
|
|
794
824
|
workspace show Show remembered workspace
|
|
795
825
|
workspace set Re-select workspace; prompts with current/default path
|
|
796
826
|
service status Show autostart status
|
|
@@ -815,16 +845,9 @@ Start options:
|
|
|
815
845
|
--no-write Disable write_file (default: write enabled)
|
|
816
846
|
--no-exec Disable exec_command (default: exec enabled)
|
|
817
847
|
--full-env Pass full parent environment to exec_command (default: minimal env)
|
|
848
|
+
--unrestricted-paths Allow absolute and parent paths outside the workspace (default: blocked)
|
|
818
849
|
--state-dir DIR Override state root
|
|
819
|
-
--json Print MCP
|
|
820
|
-
--no-api Do not start the local OpenAI-compatible API
|
|
821
|
-
--api Deprecated no-op; local API starts by default
|
|
822
|
-
--api-port PORT Local API port (default: 8765)
|
|
823
|
-
--port PORT Alias for --api-port on the api command
|
|
824
|
-
--api-host HOST Local API host (default: 127.0.0.1)
|
|
825
|
-
--api-key KEY Set or replace local API key in state
|
|
826
|
-
--rotate-api-key Rotate local API key
|
|
827
|
-
--api-model MODEL Local model id advertised by /v1/models (default: chatgpt-mcp)
|
|
850
|
+
--json Print MCP connection details as JSON
|
|
828
851
|
|
|
829
852
|
Uninstall options:
|
|
830
853
|
--keep-worker Do not delete deployed Worker(s) during uninstall
|
|
@@ -832,43 +855,11 @@ Uninstall options:
|
|
|
832
855
|
`);
|
|
833
856
|
}
|
|
834
857
|
|
|
835
|
-
function
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
Usage:
|
|
839
|
-
machine-mcp
|
|
840
|
-
machine-mcp --api-port 8766
|
|
841
|
-
machine-mcp api # local API only
|
|
842
|
-
machine-mcp api --api-port 8766
|
|
843
|
-
|
|
844
|
-
Client settings:
|
|
845
|
-
API Base URL: http://127.0.0.1:<port>/v1
|
|
846
|
-
API key: printed on startup unless --no-print-credentials is set
|
|
847
|
-
Model: chatgpt-mcp by default
|
|
848
|
-
Backend: connected ChatGPT app through the Remote MCP bridge
|
|
849
|
-
|
|
850
|
-
Options:
|
|
851
|
-
--workspace PATH Use and remember this workspace path
|
|
852
|
-
--api-host HOST Local API host (default: 127.0.0.1)
|
|
853
|
-
--api-port PORT Local API port (default: 8765)
|
|
854
|
-
--port PORT Alias for --api-port
|
|
855
|
-
--api-key KEY Set or replace local API key in state
|
|
856
|
-
--rotate-api-key Rotate local API key
|
|
857
|
-
--api-model MODEL Local model id advertised by /v1/models
|
|
858
|
-
--no-print-credentials Redact the local API key in console output
|
|
859
|
-
--no-api Only valid for start; disables the default local API
|
|
860
|
-
--state-dir DIR Override state root
|
|
861
|
-
|
|
862
|
-
Important:
|
|
863
|
-
Generation routes require a ChatGPT app connected to the printed MCP Server URL.
|
|
864
|
-
The local API sends sampling/createMessage requests through that MCP connection.
|
|
865
|
-
|
|
866
|
-
Environment:
|
|
867
|
-
MBM_API_HOST, MBM_API_PORT, MBM_API_KEY, MBM_API_MODEL
|
|
868
|
-
`);
|
|
858
|
+
function currentPackageVersion() {
|
|
859
|
+
const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
|
860
|
+
return String(pkg.version);
|
|
869
861
|
}
|
|
870
862
|
|
|
871
|
-
|
|
872
863
|
function version() {
|
|
873
864
|
const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
|
874
865
|
console.log(`${pkg.name} ${pkg.version}`);
|