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/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 { startLocalApiServer, DEFAULT_API_HOST, DEFAULT_API_PORT, DEFAULT_API_MODEL } from "./api-server.mjs";
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 key = raw.slice(2, eq >= 0 ? eq : undefined);
71
- let value = eq >= 0 ? raw.slice(eq + 1) : true;
72
- if (eq < 0 && argv[i + 1] && !argv[i + 1].startsWith("--")) value = argv[++i];
73
- out[toCamel(key)] = value;
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 previousMcpServerUrl = state.worker?.mcpServerUrl || "";
166
- const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
167
- const apiEnabled = args.noApi ? false : true;
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
- if (!args.daemonOnly) await stopAutostartBestEffort(Boolean(args.quiet));
192
-
193
- const lock = acquireDaemonLock(state);
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
- if (!apiEnabled) {
207
- if (args.json) printStartJson(state, null, { noPrintCredentials: Boolean(args.noPrintCredentials) });
208
- return;
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
- try {
218
- daemon = new LocalDaemon({
219
- workerUrl: state.worker.url,
220
- secret: state.worker.daemonSecret,
221
- workspace,
222
- policy: state.policy,
223
- logger: createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "daemon" }),
224
- });
225
-
226
- const waitForConnect = daemon.start();
227
- await waitForConnectWithNotice(waitForConnect, 20_000, Boolean(args.quiet || args.json));
228
- if (apiEnabled) apiServer = await startOptionalApiServer(state, args, logger);
229
- if (args.json) printStartJson(state, apiServer, { noPrintCredentials: Boolean(args.noPrintCredentials) });
230
- else {
231
- printMcpConnection(state, {
232
- noPrintCredentials: Boolean(args.noPrintCredentials),
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
- async function apiCommand(args) {
248
- assertNodeVersion();
249
- const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
250
- const state = loadState(workspace, { stateDir: args.stateDir });
251
- configureLocalApiState(state, args);
252
- saveState(state);
253
- const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "api" });
254
- const apiServer = await startConfiguredApiServer(state, args, logger);
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
- async function startOptionalApiServer(state, args, parentLogger) {
267
- const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "api" });
268
- const apiOptions = apiOptionsFromArgs(state, args);
269
- try {
270
- return await startLocalApiServer({ ...apiOptions, logger });
271
- } catch (error) {
272
- if (error?.code === "EADDRINUSE") {
273
- const baseUrl = apiBaseUrl(apiOptions.host, apiOptions.port);
274
- const health = await probeLocalApiHealth(baseUrl, state.localApi?.apiKey);
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
- parentLogger.warn(error.message);
280
- return null;
324
+ keepProcessAlive({ daemon, lock, logger });
325
+ } catch (error) {
326
+ try { daemon?.stop?.(); } catch {}
327
+ lock.release();
328
+ throw error;
281
329
  }
282
- parentLogger.warn(`Local API skipped: ${error.message}`);
283
- return null;
330
+ } finally {
331
+ startupLock.release();
284
332
  }
285
333
  }
286
334
 
287
- function apiBaseUrl(host, port) {
288
- const textHost = String(host || DEFAULT_API_HOST);
289
- const urlHost = textHost.includes(":") && !textHost.startsWith("[") ? `[${textHost}]` : textHost;
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 valueFromArgsEnv(argValue, ...envNames) {
341
- if (argValue !== undefined && argValue !== null && argValue !== true) return String(argValue);
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 = desiredHash;
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) logger.warn("Worker deployed but health check did not pass yet", { error: health.error });
387
- else logger.success("Worker ready", { url: state.worker.url });
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
- const content = readFileSync(file, "utf8");
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: true };
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, apiServer, { noPrintCredentials = false } = {}) {
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"}, local_api=${payload.policy.apiEnabled === false ? "off" : "on"}, unrestricted_paths=${payload.policy.unrestrictedPaths ? "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 printApiConnection(apiServer, state, { noPrintCredentials = false, quiet = false } = {}) {
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]) >= 20, detail: process.version });
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.stdout || whoami.stderr) });
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
- ensureWorkerSecrets(state, { rotateSecrets: true, workerName: args.workerName && String(args.workerName) });
634
- saveState(state);
635
- console.log(`Rotated MCP password: ${state.worker.oauthPassword}`);
636
- console.log(`Rotated daemon secret: ${previewSecret(state.worker.daemonSecret)}`);
637
- console.log("Run start to redeploy the Worker with the new secrets and revoke old OAuth access tokens.");
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
- if (result.code === 0) console.log(`Deleted Worker: ${name}`);
727
- else console.warn(`Failed to delete Worker ${name}: ${(result.stderr || result.stdout || "unknown error").trim()}`);
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
- if (state?.worker?.name) names.add(String(state.worker.name));
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 uninstallAutostart({ stateRoot, logger: structuredLogger(false) });
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 = String(text || "")
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 < 20) throw new Error(`Node.js >=20 is required; current ${process.version}`);
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 and local API
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 and local API connection details as JSON
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 apiUsage() {
836
- console.log(`machine-bridge-mcp local ChatGPT MCP-backed API
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}`);