mobygate 0.7.3 → 0.8.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/CHANGELOG.md +180 -0
- package/bin/mobygate.js +292 -12
- package/index.html +1 -0
- package/inspector.html +422 -0
- package/lib/anthropic.js +23 -0
- package/lib/connectors/hermes.js +188 -0
- package/lib/connectors/index.js +80 -0
- package/lib/connectors/openclaw.js +290 -0
- package/lib/connectors/safety.js +141 -0
- package/lib/request-capture.js +394 -0
- package/package.json +2 -1
- package/server.js +248 -6
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client connector registry + orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Connectors auto-wire third-party clients (Hermes, OpenClaw, etc.) to
|
|
5
|
+
* use mobygate as their inference provider. This avoids the manual
|
|
6
|
+
* "find your client's config file → paste this JSON snippet → restart"
|
|
7
|
+
* dance for each client a user wants to connect.
|
|
8
|
+
*
|
|
9
|
+
* Each connector lives in `lib/connectors/<id>.js` and exports a
|
|
10
|
+
* uniform contract:
|
|
11
|
+
*
|
|
12
|
+
* - id — short stable identifier (e.g. 'hermes', 'openclaw')
|
|
13
|
+
* - displayName — human-readable name for prompts/logs
|
|
14
|
+
* - detect() — probe for the client; returns DetectionResult | null
|
|
15
|
+
* - inspect() — read current config; returns InspectionResult
|
|
16
|
+
* - plan(opts) — compute the diff to apply; returns Plan
|
|
17
|
+
* - apply(plan) — perform the modification atomically
|
|
18
|
+
* - disconnect() — remove our entries cleanly
|
|
19
|
+
*
|
|
20
|
+
* Branding: all provider entries we register use the `moby` prefix to
|
|
21
|
+
* make them visually identifiable. Two flavors:
|
|
22
|
+
* - `moby` — OpenAI-compat surface (POST /v1/chat/completions)
|
|
23
|
+
* - `moby-native` — Anthropic-messages surface (POST /v1/messages)
|
|
24
|
+
*
|
|
25
|
+
* Clients that only handle one wire format get whichever fits; clients
|
|
26
|
+
* that handle both get both registered, with `moby-native` as the
|
|
27
|
+
* preferred default.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { hermesConnector } from './hermes.js';
|
|
31
|
+
import { openclawConnector } from './openclaw.js';
|
|
32
|
+
|
|
33
|
+
// Public API for any caller that wants to know where mobygate is reachable.
|
|
34
|
+
// Defaults match server.js's defaults; init can override via opts.
|
|
35
|
+
export const DEFAULT_BASE_URL = 'http://127.0.0.1:3456';
|
|
36
|
+
export const DEFAULT_API_KEY = 'claude-max';
|
|
37
|
+
|
|
38
|
+
// Branded provider names. Short, distinct, unmistakably from mobygate.
|
|
39
|
+
export const PROVIDER_NAME_OPENAI = 'moby';
|
|
40
|
+
export const PROVIDER_NAME_ANTHROPIC = 'moby-native';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* All registered connectors, in display order. Add new ones here.
|
|
44
|
+
* v0.8.0 ships with hermes + openclaw; pi-agent and others are on the
|
|
45
|
+
* v0.8.x backlog.
|
|
46
|
+
*/
|
|
47
|
+
export const CONNECTORS = [
|
|
48
|
+
hermesConnector,
|
|
49
|
+
openclawConnector,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run detection across every registered connector. Returns an array of
|
|
54
|
+
* { connector, detection } where detection is the connector's
|
|
55
|
+
* DetectionResult (or null if not found). Connectors that throw are
|
|
56
|
+
* treated as "not detected" — we never let one broken adapter break
|
|
57
|
+
* the whole orchestrator.
|
|
58
|
+
*/
|
|
59
|
+
export async function detectAll() {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const c of CONNECTORS) {
|
|
62
|
+
let detection = null;
|
|
63
|
+
try {
|
|
64
|
+
detection = await c.detect();
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// Detection should be silent on failure — the client may simply
|
|
67
|
+
// not be installed. Don't surface noise.
|
|
68
|
+
detection = null;
|
|
69
|
+
}
|
|
70
|
+
out.push({ connector: c, detection });
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Convenience: get a connector by id, or null.
|
|
77
|
+
*/
|
|
78
|
+
export function getConnector(id) {
|
|
79
|
+
return CONNECTORS.find((c) => c.id === id) || null;
|
|
80
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw connector.
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw is the Discord-bot agent harness. Its config lives at
|
|
5
|
+
* `~/.openclaw/openclaw.json` (canonical on Linux/Mac, mirrored as
|
|
6
|
+
* `%USERPROFILE%\.openclaw\openclaw.json` on Windows — verified
|
|
7
|
+
* against a real Geekom install).
|
|
8
|
+
*
|
|
9
|
+
* Unlike Hermes, OpenClaw understands BOTH wire formats — `openai-completions`
|
|
10
|
+
* (for legacy OpenAI-shape providers) and `anthropic-messages` (for
|
|
11
|
+
* Anthropic-native providers, which unlocks vision + native tools +
|
|
12
|
+
* thinking blocks). So we register both surfaces:
|
|
13
|
+
* - moby (api: openai-completions → /v1/chat/completions)
|
|
14
|
+
* - moby-native (api: anthropic-messages → /v1/messages)
|
|
15
|
+
*
|
|
16
|
+
* `moby-native` is set as the main + default model when setDefault is
|
|
17
|
+
* true — that's the wire format that gives OpenClaw the full feature
|
|
18
|
+
* set. `moby` stays available as fallback / OpenAI-compat clients.
|
|
19
|
+
*
|
|
20
|
+
* Config schema (inferred from the user's Geekom config):
|
|
21
|
+
* {
|
|
22
|
+
* "models": {
|
|
23
|
+
* "main": "<provider>/<model-id>",
|
|
24
|
+
* "default": "<provider>/<model-id>",
|
|
25
|
+
* "providers": {
|
|
26
|
+
* "<provider-id>": {
|
|
27
|
+
* "baseUrl": "...",
|
|
28
|
+
* "apiKey": "...",
|
|
29
|
+
* "api": "openai-completions" | "anthropic-messages",
|
|
30
|
+
* "models": [ { id, name, contextWindow, maxTokens, input, reasoning, cost }, ... ]
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { readFileSync, existsSync } from 'fs';
|
|
38
|
+
import { join } from 'path';
|
|
39
|
+
import { homedir } from 'os';
|
|
40
|
+
import { writeConfigSafe, diffSummary } from './safety.js';
|
|
41
|
+
import {
|
|
42
|
+
DEFAULT_BASE_URL,
|
|
43
|
+
DEFAULT_API_KEY,
|
|
44
|
+
PROVIDER_NAME_OPENAI,
|
|
45
|
+
PROVIDER_NAME_ANTHROPIC,
|
|
46
|
+
} from './index.js';
|
|
47
|
+
|
|
48
|
+
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || join(homedir(), '.openclaw');
|
|
49
|
+
const OPENCLAW_CONFIG = join(OPENCLAW_HOME, 'openclaw.json');
|
|
50
|
+
|
|
51
|
+
// Probe order — first match wins. Lets us support installs that put the
|
|
52
|
+
// config somewhere other than ~/.openclaw/.
|
|
53
|
+
const CONFIG_PROBES = [
|
|
54
|
+
OPENCLAW_CONFIG,
|
|
55
|
+
// Add other plausible paths here as they're discovered. Empty list is
|
|
56
|
+
// fine for v0.8.0 — every install we've seen uses ~/.openclaw/.
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const MODELS_OPENAI_SURFACE = [
|
|
60
|
+
{ id: 'claude-opus-4-7', name: 'Claude Opus 4.7 (Max via Moby)', contextWindow: 1000000, maxTokens: 32768, input: ['text'], reasoning: false, cost: { input: 0, output: 0 } },
|
|
61
|
+
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6 (Max via Moby)', contextWindow: 200000, maxTokens: 16384, input: ['text'], reasoning: false, cost: { input: 0, output: 0 } },
|
|
62
|
+
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6 (Max via Moby)', contextWindow: 200000, maxTokens: 16384, input: ['text'], reasoning: false, cost: { input: 0, output: 0 } },
|
|
63
|
+
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5 (Max via Moby)', contextWindow: 200000, maxTokens: 16384, input: ['text'], reasoning: false, cost: { input: 0, output: 0 } },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
// Native surface declares text+image input and reasoning capability so
|
|
67
|
+
// OpenClaw will send vision content and surface thinking blocks.
|
|
68
|
+
const MODELS_NATIVE_SURFACE = [
|
|
69
|
+
{ id: 'claude-opus-4-7', name: 'Claude Opus 4.7 (Max, native)', contextWindow: 1000000, maxTokens: 32768, input: ['text', 'image'], reasoning: true, cost: { input: 0, output: 0 } },
|
|
70
|
+
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6 (Max, native)', contextWindow: 200000, maxTokens: 16384, input: ['text', 'image'], reasoning: true, cost: { input: 0, output: 0 } },
|
|
71
|
+
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6 (Max, native)', contextWindow: 200000, maxTokens: 16384, input: ['text', 'image'], reasoning: true, cost: { input: 0, output: 0 } },
|
|
72
|
+
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5 (Max, native)', contextWindow: 200000, maxTokens: 16384, input: ['text', 'image'], reasoning: true, cost: { input: 0, output: 0 } },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
function buildOpenAIProvider({ baseUrl, apiKey }) {
|
|
76
|
+
return {
|
|
77
|
+
baseUrl,
|
|
78
|
+
apiKey,
|
|
79
|
+
api: 'openai-completions',
|
|
80
|
+
models: MODELS_OPENAI_SURFACE,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildNativeProvider({ baseUrl, apiKey }) {
|
|
85
|
+
return {
|
|
86
|
+
baseUrl,
|
|
87
|
+
apiKey,
|
|
88
|
+
api: 'anthropic-messages',
|
|
89
|
+
models: MODELS_NATIVE_SURFACE,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function findConfigPath() {
|
|
94
|
+
for (const p of CONFIG_PROBES) {
|
|
95
|
+
if (existsSync(p)) return p;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isMobyDefaultPointer(s) {
|
|
101
|
+
if (typeof s !== 'string') return false;
|
|
102
|
+
return s.startsWith(`${PROVIDER_NAME_OPENAI}/`) || s.startsWith(`${PROVIDER_NAME_ANTHROPIC}/`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const openclawConnector = {
|
|
106
|
+
id: 'openclaw',
|
|
107
|
+
displayName: 'OpenClaw',
|
|
108
|
+
|
|
109
|
+
async detect() {
|
|
110
|
+
const configPath = findConfigPath();
|
|
111
|
+
if (!configPath) return null;
|
|
112
|
+
let raw;
|
|
113
|
+
try {
|
|
114
|
+
raw = readFileSync(configPath, 'utf8');
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(raw);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return { configPath, parseError: e.message };
|
|
123
|
+
}
|
|
124
|
+
return { configPath, parsed };
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async inspect({ baseUrl = DEFAULT_BASE_URL } = {}) {
|
|
128
|
+
const det = await this.detect();
|
|
129
|
+
if (!det) return { installed: false };
|
|
130
|
+
if (det.parseError) return { installed: true, parseError: det.parseError };
|
|
131
|
+
|
|
132
|
+
const providers = det.parsed?.models?.providers || {};
|
|
133
|
+
|
|
134
|
+
// Detect "shadow" providers: ones that point at our base URL but
|
|
135
|
+
// aren't registered under our canonical names. This catches the
|
|
136
|
+
// pre-v0.8.0 hand-rolled `claude-max-proxy` style configs that
|
|
137
|
+
// would otherwise silently bypass `mobygate connect`'s native
|
|
138
|
+
// surface — exactly the situation that caused OpenClaw to keep
|
|
139
|
+
// sending OpenAI-shape requests in the v0.8.0 → v0.8.1 era despite
|
|
140
|
+
// the connector having registered moby-native.
|
|
141
|
+
//
|
|
142
|
+
// For each shadow provider we report its name, current api type,
|
|
143
|
+
// and a recommendation. Surfacing this in inspect() (and the CLI)
|
|
144
|
+
// turns "why is the shape wrong?" from a forensics task into a
|
|
145
|
+
// single command.
|
|
146
|
+
const shadowProviders = [];
|
|
147
|
+
const baseHost = String(baseUrl).replace(/\/+$/, '');
|
|
148
|
+
for (const [name, p] of Object.entries(providers)) {
|
|
149
|
+
if (name === PROVIDER_NAME_OPENAI || name === PROVIDER_NAME_ANTHROPIC) continue;
|
|
150
|
+
if (!p?.baseUrl) continue;
|
|
151
|
+
const provHost = String(p.baseUrl).replace(/\/+$/, '');
|
|
152
|
+
// Match exact or with /v1 suffix; tolerate localhost vs 127.0.0.1.
|
|
153
|
+
const norm = (s) => s.replace('localhost', '127.0.0.1').replace(/\/v1$/, '');
|
|
154
|
+
if (norm(provHost) === norm(baseHost)) {
|
|
155
|
+
shadowProviders.push({
|
|
156
|
+
name,
|
|
157
|
+
api: p.api || '(unset)',
|
|
158
|
+
baseUrl: p.baseUrl,
|
|
159
|
+
recommendation: p.api === 'anthropic-messages'
|
|
160
|
+
? `OK — already on native shape. Could rename to "${PROVIDER_NAME_ANTHROPIC}" for clarity.`
|
|
161
|
+
: `Flip api: "${p.api}" → "anthropic-messages" to enable cache_control + native blocks. ` +
|
|
162
|
+
`Or run \`mobygate connect openclaw\` to register canonical providers.`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
installed: true,
|
|
169
|
+
configPath: det.configPath,
|
|
170
|
+
mobyProviderExists: !!providers[PROVIDER_NAME_OPENAI],
|
|
171
|
+
mobyNativeProviderExists: !!providers[PROVIDER_NAME_ANTHROPIC],
|
|
172
|
+
currentMain: det.parsed?.models?.main || null,
|
|
173
|
+
currentDefault: det.parsed?.models?.default || null,
|
|
174
|
+
shadowProviders, // pre-v0.8.0 entries pointing at our base URL
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
async plan({
|
|
179
|
+
baseUrl = DEFAULT_BASE_URL,
|
|
180
|
+
apiKey = DEFAULT_API_KEY,
|
|
181
|
+
setDefault = true,
|
|
182
|
+
registerOpenAISurface = true,
|
|
183
|
+
registerNativeSurface = true,
|
|
184
|
+
} = {}) {
|
|
185
|
+
const det = await this.detect();
|
|
186
|
+
if (!det) {
|
|
187
|
+
return { skip: true, reason: 'OpenClaw not detected (no ~/.openclaw/openclaw.json)' };
|
|
188
|
+
}
|
|
189
|
+
if (det.parseError) {
|
|
190
|
+
return { skip: true, reason: `OpenClaw config is unparseable JSON: ${det.parseError}` };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const before = det.parsed || {};
|
|
194
|
+
const after = JSON.parse(JSON.stringify(before)); // deep clone
|
|
195
|
+
|
|
196
|
+
if (!after.models) after.models = {};
|
|
197
|
+
if (!after.models.providers) after.models.providers = {};
|
|
198
|
+
|
|
199
|
+
if (registerOpenAISurface) {
|
|
200
|
+
after.models.providers[PROVIDER_NAME_OPENAI] = buildOpenAIProvider({ baseUrl, apiKey });
|
|
201
|
+
}
|
|
202
|
+
if (registerNativeSurface) {
|
|
203
|
+
after.models.providers[PROVIDER_NAME_ANTHROPIC] = buildNativeProvider({ baseUrl, apiKey });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (setDefault) {
|
|
207
|
+
// Prefer native if registered; fall back to openai-compat otherwise.
|
|
208
|
+
const preferredProvider = registerNativeSurface
|
|
209
|
+
? PROVIDER_NAME_ANTHROPIC
|
|
210
|
+
: registerOpenAISurface
|
|
211
|
+
? PROVIDER_NAME_OPENAI
|
|
212
|
+
: null;
|
|
213
|
+
if (preferredProvider) {
|
|
214
|
+
const target = `${preferredProvider}/claude-opus-4-7`;
|
|
215
|
+
after.models.main = target;
|
|
216
|
+
after.models.default = target;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const summary = diffSummary(
|
|
221
|
+
{ providers: before.models?.providers, main: before.models?.main, default: before.models?.default },
|
|
222
|
+
{ providers: after.models.providers, main: after.models.main, default: after.models.default },
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
skip: false,
|
|
227
|
+
configPath: det.configPath,
|
|
228
|
+
before,
|
|
229
|
+
after,
|
|
230
|
+
summary,
|
|
231
|
+
warnings: [],
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async apply(plan) {
|
|
236
|
+
if (plan.skip) return { applied: false, reason: plan.reason };
|
|
237
|
+
// OpenClaw uses 2-space indented JSON in its own config writes.
|
|
238
|
+
// Match that style so subsequent self-writes don't reformat the
|
|
239
|
+
// whole file.
|
|
240
|
+
const jsonOut = JSON.stringify(plan.after, null, 2) + '\n';
|
|
241
|
+
const result = writeConfigSafe(plan.configPath, jsonOut);
|
|
242
|
+
return {
|
|
243
|
+
applied: !result.unchanged,
|
|
244
|
+
unchanged: !!result.unchanged,
|
|
245
|
+
reason: result.unchanged ? 'config already up-to-date (byte-identical)' : null,
|
|
246
|
+
configPath: result.path,
|
|
247
|
+
backupPath: result.backupPath,
|
|
248
|
+
bytesWritten: result.bytesWritten,
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async disconnect() {
|
|
253
|
+
const det = await this.detect();
|
|
254
|
+
if (!det) return { applied: false, reason: 'OpenClaw not installed' };
|
|
255
|
+
if (det.parseError) return { applied: false, reason: `parse error: ${det.parseError}` };
|
|
256
|
+
const before = det.parsed || {};
|
|
257
|
+
const after = JSON.parse(JSON.stringify(before));
|
|
258
|
+
let changed = false;
|
|
259
|
+
|
|
260
|
+
const providers = after.models?.providers;
|
|
261
|
+
if (providers) {
|
|
262
|
+
for (const name of [PROVIDER_NAME_OPENAI, PROVIDER_NAME_ANTHROPIC]) {
|
|
263
|
+
if (providers[name]) { delete providers[name]; changed = true; }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// If main/default was pointing at us, blank them — let the user
|
|
267
|
+
// re-pick rather than guess at a replacement.
|
|
268
|
+
if (isMobyDefaultPointer(after.models?.main)) {
|
|
269
|
+
after.models.main = null;
|
|
270
|
+
changed = true;
|
|
271
|
+
}
|
|
272
|
+
if (isMobyDefaultPointer(after.models?.default)) {
|
|
273
|
+
after.models.default = null;
|
|
274
|
+
changed = true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!changed) return { applied: false, reason: 'No moby provider entries in OpenClaw config' };
|
|
278
|
+
|
|
279
|
+
const jsonOut = JSON.stringify(after, null, 2) + '\n';
|
|
280
|
+
const result = writeConfigSafe(det.configPath, jsonOut);
|
|
281
|
+
return {
|
|
282
|
+
applied: true,
|
|
283
|
+
configPath: result.path,
|
|
284
|
+
backupPath: result.backupPath,
|
|
285
|
+
note: (after.models?.main === null || after.models?.default === null)
|
|
286
|
+
? 'Reset main/default model to null — set a new model in OpenClaw before next request.'
|
|
287
|
+
: null,
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safety primitives for connector adapters.
|
|
3
|
+
*
|
|
4
|
+
* Every connector that modifies a third-party config file goes through
|
|
5
|
+
* these helpers — they enforce backup, atomic write, and a couple of
|
|
6
|
+
* sanity guards. Adapters MUST NOT call writeFileSync directly on a
|
|
7
|
+
* user's config; they MUST go through `writeConfigSafe`.
|
|
8
|
+
*
|
|
9
|
+
* The contract:
|
|
10
|
+
* 1. Always back up first to `<file>.mobygate-backup-<ISO-timestamp>`.
|
|
11
|
+
* 2. Write to a temp file in the same directory, fsync, then rename.
|
|
12
|
+
* Atomic rename on POSIX, near-atomic on Windows (NTFS handles it
|
|
13
|
+
* transparently for same-volume renames).
|
|
14
|
+
* 3. Verify the rename produced the expected content (read-back +
|
|
15
|
+
* length sanity check) before declaring success.
|
|
16
|
+
*
|
|
17
|
+
* This keeps a corrupt file or partial write from destroying a user's
|
|
18
|
+
* carefully-tuned client config.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
readFileSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
renameSync,
|
|
25
|
+
copyFileSync,
|
|
26
|
+
existsSync,
|
|
27
|
+
statSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
unlinkSync,
|
|
30
|
+
} from 'fs';
|
|
31
|
+
import { dirname, basename, join } from 'path';
|
|
32
|
+
|
|
33
|
+
const ISO_SAFE = (d = new Date()) => d.toISOString().replace(/[:.]/g, '-');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Make a timestamped backup of `path`. Returns the backup path.
|
|
37
|
+
* No-op (returns null) if `path` doesn't exist — this lets adapters
|
|
38
|
+
* call `backup()` unconditionally even when they're creating a new file.
|
|
39
|
+
*/
|
|
40
|
+
export function backup(path) {
|
|
41
|
+
if (!existsSync(path)) return null;
|
|
42
|
+
const dir = dirname(path);
|
|
43
|
+
const name = basename(path);
|
|
44
|
+
const backupPath = join(dir, `${name}.mobygate-backup-${ISO_SAFE()}`);
|
|
45
|
+
copyFileSync(path, backupPath);
|
|
46
|
+
return backupPath;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Atomically write `content` to `path`. Backs up first. Returns
|
|
51
|
+
* `{ path, backupPath, bytesWritten }` on success; throws on any failure
|
|
52
|
+
* (the original file is preserved by the backup).
|
|
53
|
+
*
|
|
54
|
+
* `content` must be a string. Adapters that work in structured formats
|
|
55
|
+
* (YAML, JSON) serialize before calling this.
|
|
56
|
+
*/
|
|
57
|
+
export function writeConfigSafe(path, content) {
|
|
58
|
+
if (typeof content !== 'string') {
|
|
59
|
+
throw new Error(`writeConfigSafe: content must be a string (got ${typeof content})`);
|
|
60
|
+
}
|
|
61
|
+
if (content.length === 0) {
|
|
62
|
+
throw new Error(`writeConfigSafe: refusing to write empty content to ${path}`);
|
|
63
|
+
}
|
|
64
|
+
const dir = dirname(path);
|
|
65
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
66
|
+
|
|
67
|
+
// Idempotency guard: if the existing on-disk content is byte-identical
|
|
68
|
+
// to what we'd write, skip the rewrite entirely. This prevents
|
|
69
|
+
// `mobygate connect <client>` from producing spurious "(changed)" diffs
|
|
70
|
+
// when re-run with no real change — a real bug seen in v0.8.0 where
|
|
71
|
+
// diffSummary's structural comparison disagreed with actual file bytes.
|
|
72
|
+
if (existsSync(path)) {
|
|
73
|
+
try {
|
|
74
|
+
const current = readFileSync(path, 'utf8');
|
|
75
|
+
if (current === content) {
|
|
76
|
+
return { path, backupPath: null, bytesWritten: 0, unchanged: true };
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Read failure is non-fatal — we'll fall through to the normal write
|
|
80
|
+
// path which will surface any real I/O problem.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const backupPath = backup(path);
|
|
85
|
+
const tempPath = `${path}.mobygate-tmp-${ISO_SAFE()}`;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
writeFileSync(tempPath, content, 'utf8');
|
|
89
|
+
// Sanity check: the temp file should be the size we just wrote.
|
|
90
|
+
const size = statSync(tempPath).size;
|
|
91
|
+
if (size === 0) throw new Error('temp file is empty after write');
|
|
92
|
+
renameSync(tempPath, path);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
// Best-effort cleanup of the temp file. If rename failed mid-flight
|
|
95
|
+
// (rare), the original is intact via the backup.
|
|
96
|
+
try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch {}
|
|
97
|
+
throw new Error(`writeConfigSafe failed for ${path}: ${e.message}` +
|
|
98
|
+
(backupPath ? ` (original preserved at ${backupPath})` : ''));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Final verify: read back what's on disk and confirm it matches.
|
|
102
|
+
const onDisk = readFileSync(path, 'utf8');
|
|
103
|
+
if (onDisk !== content) {
|
|
104
|
+
throw new Error(`writeConfigSafe verify failed for ${path}: ` +
|
|
105
|
+
`read-back differs from intended content` +
|
|
106
|
+
(backupPath ? ` (original preserved at ${backupPath})` : ''));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { path, backupPath, bytesWritten: Buffer.byteLength(content, 'utf8'), unchanged: false };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Compute a human-readable summary of a planned change. Used by adapters
|
|
114
|
+
* to produce dry-run output. `before` and `after` are arbitrary objects;
|
|
115
|
+
* we don't try to be clever — just a count of top-level differences.
|
|
116
|
+
*
|
|
117
|
+
* Returns lines like:
|
|
118
|
+
* + providers.moby (added)
|
|
119
|
+
* ~ providers.moby-native (changed)
|
|
120
|
+
* - providers.old-thing (removed)
|
|
121
|
+
*/
|
|
122
|
+
export function diffSummary(before, after, prefix = '') {
|
|
123
|
+
const lines = [];
|
|
124
|
+
const beforeKeys = new Set(Object.keys(before || {}));
|
|
125
|
+
const afterKeys = new Set(Object.keys(after || {}));
|
|
126
|
+
for (const k of afterKeys) {
|
|
127
|
+
const fullKey = prefix ? `${prefix}.${k}` : k;
|
|
128
|
+
if (!beforeKeys.has(k)) {
|
|
129
|
+
lines.push(`+ ${fullKey} (added)`);
|
|
130
|
+
} else if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
|
|
131
|
+
lines.push(`~ ${fullKey} (changed)`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const k of beforeKeys) {
|
|
135
|
+
if (!afterKeys.has(k)) {
|
|
136
|
+
const fullKey = prefix ? `${prefix}.${k}` : k;
|
|
137
|
+
lines.push(`- ${fullKey} (removed)`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return lines;
|
|
141
|
+
}
|