specbox 6.17.0 → 6.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/claude.mjs +67 -18
- package/lib/config.mjs +50 -2
- package/lib/store.mjs +44 -2
- package/package.json +1 -1
package/README.md
CHANGED
package/lib/claude.mjs
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* `~/.claude.json` a mano: Claude Code lo reescribe mientras está abierto. Si
|
|
11
11
|
* `claude` no está en el PATH, se devuelve la orden para ejecutarla a mano.
|
|
12
12
|
*/
|
|
13
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { join, posix, win32 } from "node:path";
|
|
16
|
-
import { SERVER_NAME } from "./config.mjs";
|
|
16
|
+
import { SERVER_NAME, isLegacyMcpUrl, migrateMcpUrl, serverOrigins, specboxHome } from "./config.mjs";
|
|
17
17
|
import { runCommand } from "./store.mjs";
|
|
18
18
|
|
|
19
19
|
export function quoteArg(value) {
|
|
@@ -74,10 +74,13 @@ function origin(url) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Entradas de `~/.claude.json` (usuario y locales) que apuntan al servidor de SpecBox, por
|
|
79
|
+
* cualquiera de sus nombres (UC-5102: también el anterior, para mudarlas al de ahora).
|
|
80
|
+
*/
|
|
78
81
|
export function findSpecboxEntries(claudeJson, mcpUrl) {
|
|
79
|
-
const
|
|
80
|
-
const matches = (entry) => entry && typeof entry.url === "string" && origin(entry.url)
|
|
82
|
+
const targets = new Set(serverOrigins(mcpUrl));
|
|
83
|
+
const matches = (entry) => entry && typeof entry.url === "string" && targets.has(origin(entry.url));
|
|
81
84
|
const found = [];
|
|
82
85
|
for (const [name, entry] of Object.entries(claudeJson?.mcpServers ?? {})) {
|
|
83
86
|
if (matches(entry)) found.push({ scope: "user", name, entry, cwd: null });
|
|
@@ -90,19 +93,51 @@ export function findSpecboxEntries(claudeJson, mcpUrl) {
|
|
|
90
93
|
return found;
|
|
91
94
|
}
|
|
92
95
|
|
|
93
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* La entrada con el ayudante, sin ninguna cabecera Authorization fija que lo contradiga, y
|
|
98
|
+
* con el nombre de ahora del servidor si apuntaba a uno anterior (UC-5102).
|
|
99
|
+
*/
|
|
94
100
|
export function withHelper(entry, helperCmd) {
|
|
95
101
|
const { headers, headersHelper: _old, ...rest } = entry ?? {};
|
|
96
102
|
void _old;
|
|
97
103
|
const kept = Object.fromEntries(Object.entries(headers ?? {}).filter(([k]) => k.toLowerCase() !== "authorization"));
|
|
98
104
|
return {
|
|
99
105
|
...rest,
|
|
106
|
+
...(typeof rest.url === "string" ? { url: migrateMcpUrl(rest.url) } : {}),
|
|
100
107
|
type: rest.type ?? "http",
|
|
101
108
|
...(Object.keys(kept).length ? { headers: kept } : {}),
|
|
102
109
|
headersHelper: helperCmd,
|
|
103
110
|
};
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
/** Una entrada para la copia de seguridad: igual, pero sin el valor de ninguna cabecera Authorization. */
|
|
114
|
+
export function redactEntry(entry) {
|
|
115
|
+
const headers = entry?.headers;
|
|
116
|
+
if (!headers) return entry;
|
|
117
|
+
return {
|
|
118
|
+
...entry,
|
|
119
|
+
headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, k.toLowerCase() === "authorization" ? "***" : v])),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* UC-5102 AC-02: guarda lo que se va a cambiar (las entradas tal como estaban) en
|
|
125
|
+
* ~/.specbox/backups/claude-mcp-<fecha>.json. Devuelve la ruta, o null si no pudo.
|
|
126
|
+
*/
|
|
127
|
+
export function saveEntriesBackup(entries, { dir = join(specboxHome(), "backups"), now = new Date() } = {}) {
|
|
128
|
+
if (!entries.length) return null;
|
|
129
|
+
try {
|
|
130
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
131
|
+
const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
132
|
+
const file = join(dir, `claude-mcp-${stamp}.json`);
|
|
133
|
+
const body = { saved_at: now.toISOString(), reason: "specbox: entradas de SpecBox-MCP antes de reconfigurarlas", entries };
|
|
134
|
+
writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
|
|
135
|
+
return file;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
106
141
|
export function configureClaudeCode({
|
|
107
142
|
mcpUrl,
|
|
108
143
|
helperCmd,
|
|
@@ -110,12 +145,32 @@ export function configureClaudeCode({
|
|
|
110
145
|
claudeJsonPath = join(homedir(), ".claude.json"),
|
|
111
146
|
readFile = readFileSync,
|
|
112
147
|
projectExists = existsSync,
|
|
148
|
+
backup = saveEntriesBackup,
|
|
113
149
|
}) {
|
|
114
150
|
const userEntry = JSON.stringify({ type: "http", url: mcpUrl, headersHelper: helperCmd });
|
|
115
151
|
const manual = `claude mcp add-json ${SERVER_NAME} '${userEntry}' --scope user`;
|
|
116
152
|
const probe = run("claude", ["--version"]);
|
|
117
153
|
if (probe.error || probe.status === null) return { ok: false, reason: "claude_missing", manual };
|
|
118
154
|
|
|
155
|
+
// Se lee antes de tocar nada: así la copia de seguridad guarda las entradas como estaban.
|
|
156
|
+
let claudeJson = {};
|
|
157
|
+
try {
|
|
158
|
+
claudeJson = JSON.parse(readFile(claudeJsonPath, "utf8"));
|
|
159
|
+
} catch {
|
|
160
|
+
// sin ~/.claude.json no hay más entradas que migrar
|
|
161
|
+
}
|
|
162
|
+
const pending = findSpecboxEntries(claudeJson, mcpUrl).filter((found) => {
|
|
163
|
+
if (found.scope === "user" && found.name === SERVER_NAME) return false;
|
|
164
|
+
if (found.entry.headersHelper === helperCmd && !isLegacyMcpUrl(found.entry.url)) return false;
|
|
165
|
+
return !(found.cwd && !projectExists(found.cwd)); // proyecto que ya no existe
|
|
166
|
+
});
|
|
167
|
+
const previousUser = claudeJson?.mcpServers?.[SERVER_NAME];
|
|
168
|
+
const userChanges = previousUser && JSON.stringify(previousUser) !== userEntry;
|
|
169
|
+
const backupFile = backup([
|
|
170
|
+
...(userChanges ? [{ scope: "user", name: SERVER_NAME, cwd: null, entry: redactEntry(previousUser) }] : []),
|
|
171
|
+
...pending.map((f) => ({ scope: f.scope, name: f.name, cwd: f.cwd, entry: redactEntry(f.entry) })),
|
|
172
|
+
]);
|
|
173
|
+
|
|
119
174
|
run("claude", ["mcp", "remove", SERVER_NAME, "--scope", "user"]); // puede no existir
|
|
120
175
|
const added = run("claude", ["mcp", "add-json", SERVER_NAME, userEntry, "--scope", "user"]);
|
|
121
176
|
if (added.status !== 0) {
|
|
@@ -124,16 +179,7 @@ export function configureClaudeCode({
|
|
|
124
179
|
|
|
125
180
|
const updated = [{ scope: "user", name: SERVER_NAME, cwd: null }];
|
|
126
181
|
const failed = [];
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
claudeJson = JSON.parse(readFile(claudeJsonPath, "utf8"));
|
|
130
|
-
} catch {
|
|
131
|
-
// sin ~/.claude.json no hay más entradas que migrar
|
|
132
|
-
}
|
|
133
|
-
for (const found of findSpecboxEntries(claudeJson, mcpUrl)) {
|
|
134
|
-
if (found.scope === "user" && found.name === SERVER_NAME) continue;
|
|
135
|
-
if (found.entry.headersHelper === helperCmd) continue;
|
|
136
|
-
if (found.cwd && !projectExists(found.cwd)) continue; // proyecto que ya no existe
|
|
182
|
+
for (const found of pending) {
|
|
137
183
|
const opts = found.cwd ? { cwd: found.cwd } : {};
|
|
138
184
|
run("claude", ["mcp", "remove", found.name, "--scope", found.scope], opts);
|
|
139
185
|
const res = run(
|
|
@@ -143,14 +189,16 @@ export function configureClaudeCode({
|
|
|
143
189
|
);
|
|
144
190
|
(res.status === 0 ? updated : failed).push({ scope: found.scope, name: found.name, cwd: found.cwd });
|
|
145
191
|
}
|
|
146
|
-
return { ok: true, updated, failed };
|
|
192
|
+
return { ok: true, updated, failed, ...(backupFile ? { backup: backupFile } : {}) };
|
|
147
193
|
}
|
|
148
194
|
|
|
149
195
|
/**
|
|
150
196
|
* ¿La entrada de usuario usa nuestro ayudante y este puede ejecutarse? (para
|
|
151
197
|
* `specbox status` y la extensión). Si el Node grabado ya no existe (p. ej.
|
|
152
198
|
* una versión que borró el gestor de paquetes), es `false`: la extensión
|
|
153
|
-
* vuelve a configurar Claude Code en el siguiente arranque.
|
|
199
|
+
* vuelve a configurar Claude Code en el siguiente arranque. También si la
|
|
200
|
+
* entrada apunta a un nombre anterior del servidor (UC-5102): así la extensión
|
|
201
|
+
* actualizada muda las entradas al nombre de ahora sin pasos manuales.
|
|
154
202
|
*/
|
|
155
203
|
export function claudeUsesHelper({
|
|
156
204
|
claudeJsonPath = join(homedir(), ".claude.json"),
|
|
@@ -159,6 +207,7 @@ export function claudeUsesHelper({
|
|
|
159
207
|
} = {}) {
|
|
160
208
|
try {
|
|
161
209
|
const entry = JSON.parse(readFile(claudeJsonPath, "utf8"))?.mcpServers?.[SERVER_NAME];
|
|
210
|
+
if (isLegacyMcpUrl(entry?.url)) return false;
|
|
162
211
|
const paths = helperPaths(entry?.headersHelper);
|
|
163
212
|
return Boolean(paths && paths.helper.endsWith("mcp-headers.mjs") && exists(paths.node) && exists(paths.helper));
|
|
164
213
|
} catch {
|
package/lib/config.mjs
CHANGED
|
@@ -9,7 +9,15 @@
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
12
|
-
export const DEFAULT_MCP_URL = "https://mcp
|
|
12
|
+
export const DEFAULT_MCP_URL = "https://mcp.specbox.build/mcp";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* UC-5102: nombres anteriores del mismo servidor. Comparten credencial con DEFAULT_MCP_URL
|
|
16
|
+
* (`accountFor` los traduce) y sus entradas en Claude Code se mudan a él. El nombre antiguo
|
|
17
|
+
* sigue respondiendo (UC-5101), así que una instalación sin actualizar no se rompe.
|
|
18
|
+
*/
|
|
19
|
+
export const LEGACY_MCP_URLS = ["https://mcp-specbox-engine.jpsdeveloper.com/mcp"];
|
|
20
|
+
|
|
13
21
|
export const DEFAULT_CLOUD_API = "https://api-cloud.specbox.build/api";
|
|
14
22
|
export const HOW_TO_CONNECT_URL = "https://cloud.specbox.build/como-se-conecta";
|
|
15
23
|
|
|
@@ -33,12 +41,52 @@ export function resolveConfig({ server, cloud } = {}, env = process.env) {
|
|
|
33
41
|
};
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Clave de la credencial en el almacén: la URL del servidor sin barra final. Un nombre
|
|
46
|
+
* anterior del servidor da la clave del nombre de ahora (UC-5102): una sola credencial.
|
|
47
|
+
*/
|
|
37
48
|
export function accountFor(mcpUrl) {
|
|
49
|
+
const key = urlKey(mcpUrl);
|
|
50
|
+
return LEGACY_MCP_URLS.some((legacy) => urlKey(legacy) === key) ? urlKey(DEFAULT_MCP_URL) : key;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Las claves con las que una versión anterior pudo guardar la credencial de `account`. */
|
|
54
|
+
export function legacyAccountsFor(account) {
|
|
55
|
+
return account === urlKey(DEFAULT_MCP_URL) ? LEGACY_MCP_URLS.map(urlKey) : [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** ¿Apunta esta URL a un nombre anterior del servidor? (cualquier ruta de ese origen) */
|
|
59
|
+
export function isLegacyMcpUrl(value) {
|
|
60
|
+
const from = originOf(value);
|
|
61
|
+
return Boolean(from) && LEGACY_MCP_URLS.some((legacy) => originOf(legacy) === from);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** La misma URL con el nombre de ahora en lugar de uno anterior; cualquier otra, igual. */
|
|
65
|
+
export function migrateMcpUrl(value) {
|
|
66
|
+
if (!isLegacyMcpUrl(value)) return value;
|
|
67
|
+
const url = new URL(value);
|
|
68
|
+
return `${new URL(DEFAULT_MCP_URL).origin}${url.pathname}${url.search}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Los orígenes que son este servidor: el suyo y, si es el de SpecBox, los anteriores. */
|
|
72
|
+
export function serverOrigins(mcpUrl) {
|
|
73
|
+
const own = originOf(mcpUrl);
|
|
74
|
+
return own === originOf(DEFAULT_MCP_URL) ? [own, ...LEGACY_MCP_URLS.map(originOf)] : [own];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function urlKey(mcpUrl) {
|
|
38
78
|
const url = new URL(mcpUrl);
|
|
39
79
|
return stripSlash(`${url.origin}${url.pathname}`);
|
|
40
80
|
}
|
|
41
81
|
|
|
82
|
+
function originOf(value) {
|
|
83
|
+
try {
|
|
84
|
+
return new URL(value).origin;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
42
90
|
function stripSlash(value) {
|
|
43
91
|
return String(value).replace(/\/+$/, "");
|
|
44
92
|
}
|
package/lib/store.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { spawnSync } from "node:child_process";
|
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
23
23
|
import { join } from "node:path";
|
|
24
|
+
import { legacyAccountsFor } from "./config.mjs";
|
|
24
25
|
|
|
25
26
|
export const SERVICE = "SpecBox MCP";
|
|
26
27
|
const SECRET_TOOL_SERVICE = "specbox-mcp";
|
|
@@ -205,10 +206,51 @@ function withFallback(primary, fallback) {
|
|
|
205
206
|
};
|
|
206
207
|
}
|
|
207
208
|
|
|
208
|
-
|
|
209
|
-
|
|
209
|
+
/**
|
|
210
|
+
* UC-5102: una versión anterior guardó la credencial con la URL antigua del servidor como
|
|
211
|
+
* clave. Al leer, si no está con la clave de ahora pero sí con una antigua, se copia a la de
|
|
212
|
+
* ahora y se usa: el ordenador sigue identificado tras la mudanza a mcp.specbox.build. La
|
|
213
|
+
* copia antigua se queda (una instalación sin actualizar la sigue leyendo). Al borrar
|
|
214
|
+
* (`specbox logout`) se borran todas.
|
|
215
|
+
*/
|
|
216
|
+
export function withLegacyAccounts(store) {
|
|
217
|
+
return {
|
|
218
|
+
get kind() {
|
|
219
|
+
return store.kind;
|
|
220
|
+
},
|
|
221
|
+
read(account) {
|
|
222
|
+
const value = store.read(account);
|
|
223
|
+
if (value) return value;
|
|
224
|
+
for (const legacy of legacyAccountsFor(account)) {
|
|
225
|
+
const old = store.read(legacy);
|
|
226
|
+
if (!old) continue;
|
|
227
|
+
try {
|
|
228
|
+
store.write(account, old);
|
|
229
|
+
} catch {
|
|
230
|
+
// se reintenta en la próxima lectura; la credencial sirve igual
|
|
231
|
+
}
|
|
232
|
+
return old;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
},
|
|
236
|
+
write(account, credential) {
|
|
237
|
+
store.write(account, credential);
|
|
238
|
+
},
|
|
239
|
+
remove(account) {
|
|
240
|
+
store.remove(account);
|
|
241
|
+
for (const legacy of legacyAccountsFor(account)) store.remove(legacy);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function platformStore({ platform, run, home }) {
|
|
210
247
|
if (platform === "darwin") return keychainStore(run);
|
|
211
248
|
if (platform === "win32") return dpapiStore(run, home);
|
|
212
249
|
if (platform === "linux") return withFallback(secretToolStore(run), fileStore(home));
|
|
213
250
|
return fileStore(home);
|
|
214
251
|
}
|
|
252
|
+
|
|
253
|
+
export function createStore({ platform = process.platform, run = runCommand, home } = {}) {
|
|
254
|
+
if (!home) throw new Error("createStore needs the specbox home");
|
|
255
|
+
return withLegacyAccounts(platformStore({ platform, run, home }));
|
|
256
|
+
}
|