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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # specbox
2
2
 
3
- Connect this computer to [SpecBox](https://specbox.embed.build) without copying tokens.
3
+ Connect this computer to [SpecBox](https://specbox.build/en/) without copying tokens.
4
4
 
5
5
  ```bash
6
6
  npx specbox login
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
- /** Entradas de `~/.claude.json` (usuario y locales) que apuntan al servidor de SpecBox. */
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 target = origin(mcpUrl);
80
- const matches = (entry) => entry && typeof entry.url === "string" && origin(entry.url) === target;
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
- /** La entrada con el ayudante, sin ninguna cabecera Authorization fija que lo contradiga. */
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
- let claudeJson = {};
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-specbox-engine.jpsdeveloper.com/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
- /** Clave de la credencial en el almacén: la URL del servidor sin barra final. */
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
- export function createStore({ platform = process.platform, run = runCommand, home } = {}) {
209
- if (!home) throw new Error("createStore needs the specbox home");
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specbox",
3
- "version": "6.17.0",
3
+ "version": "6.18.0",
4
4
  "description": "Conecta este ordenador a SpecBox sin copiar tokens: specbox login, status y logout.",
5
5
  "type": "module",
6
6
  "bin": {