specbox 6.14.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 +38 -0
- package/bin/specbox.mjs +202 -0
- package/lib/claude.mjs +167 -0
- package/lib/config.mjs +44 -0
- package/lib/device.mjs +68 -0
- package/lib/install.mjs +32 -0
- package/lib/internal.mjs +176 -0
- package/lib/login.mjs +87 -0
- package/lib/mcp-headers.mjs +53 -0
- package/lib/messages.mjs +124 -0
- package/lib/renew.mjs +130 -0
- package/lib/store.mjs +214 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# specbox
|
|
2
|
+
|
|
3
|
+
Connect this computer to [SpecBox](https://specbox.embed.build) without copying tokens.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx specbox login
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
1. It shows a one-time code and opens `https://cloud.specbox.build/device`.
|
|
10
|
+
2. You sign in with GitHub and confirm the code.
|
|
11
|
+
3. Done: the token goes straight into your system's secure store, and Claude Code is configured to send it on every connection. You never see it or paste it.
|
|
12
|
+
|
|
13
|
+
The token expires after 90 days and **renews itself** from 14 days before. If a connection ever ends ("the connection from this device has ended"), run `specbox login` again.
|
|
14
|
+
|
|
15
|
+
## Commands
|
|
16
|
+
|
|
17
|
+
| Command | What it does |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `specbox login` | Connects this computer (one device = this computer + Claude Code). Signing in again replaces the previous token of this device. |
|
|
20
|
+
| `specbox status` | Account, device, expiry date, whether the token is still valid and whether Claude Code uses it. |
|
|
21
|
+
| `specbox logout` | Disconnects this device in the panel and deletes the local credential. |
|
|
22
|
+
|
|
23
|
+
Options: `--no-browser` (just print the link), `--server <url>` and `--cloud <url>` for self-hosted servers (or `SPECBOX_MCP_URL` / `SPECBOX_CLOUD_API`).
|
|
24
|
+
|
|
25
|
+
## What it changes on your computer
|
|
26
|
+
|
|
27
|
+
- **Secure store:** the macOS Keychain, Secret Service on Linux (`secret-tool`) or Windows DPAPI. Without any of them (headless Linux), a `~/.specbox/credentials.json` file with `0600` permissions; `specbox login` says so.
|
|
28
|
+
- **`~/.specbox/bin/mcp-headers.mjs`:** a small helper. Claude Code runs it when it connects (and after a `401`) to get the `Authorization` header; it reads the store and renews the token when due. It never prints anything but that header.
|
|
29
|
+
- **Claude Code:** the `SpecBox-MCP` server (user scope) becomes `{"type": "http", "url": …, "headersHelper": "<node> ~/.specbox/bin/mcp-headers.mjs"}` through the official `claude mcp` command, and other entries pointing to the same server get the same helper.
|
|
30
|
+
- **`~/.specbox/device-id`:** only if the machine id cannot be read. The server only ever sees a SHA-256 of it.
|
|
31
|
+
|
|
32
|
+
Your devices are listed in the panel under **Profile → Connected devices**, where you can rename or disconnect them. For clients that cannot sign in (Cursor, Claude Desktop…), **Advanced** creates a manual token with the exact configuration for each client.
|
|
33
|
+
|
|
34
|
+
More: [How SpecBox connects](https://cloud.specbox.build/how-to-connect) · [Cómo se conecta SpecBox](https://cloud.specbox.build/como-se-conecta)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
**En español.** `npx specbox login` conecta este ordenador: te muestra un código, lo confirmas en el navegador con tu cuenta de GitHub y el token queda en el almacén seguro del sistema; Claude Code lo envía solo y se renueva solo. `specbox status` dice con qué cuenta estás conectado y cuándo caduca; `specbox logout` desconecta el ordenador. Los mensajes salen en español si tu sistema está en español.
|
package/bin/specbox.mjs
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `specbox` — conectar un ordenador a SpecBox sin copiar tokens (UC-3904).
|
|
4
|
+
*
|
|
5
|
+
* specbox login código de un solo uso → token al almacén seguro →
|
|
6
|
+
* ayudante en ~/.specbox/bin → Claude Code lo usa solo
|
|
7
|
+
* specbox status con qué cuenta y dispositivo, caducidad, si sigue valiendo
|
|
8
|
+
* specbox logout desconecta el dispositivo y borra la credencial local
|
|
9
|
+
*
|
|
10
|
+
* Todo se puede inyectar (`run`) para las pruebas.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { realpathSync } from "node:fs";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
import { claudeUsesHelper, configureClaudeCode, helperCommand, stableNodePath } from "../lib/claude.mjs";
|
|
16
|
+
import { CLIENT, HOW_TO_CONNECT_URL, accountFor, resolveConfig, specboxHome } from "../lib/config.mjs";
|
|
17
|
+
import { deviceId, hostLabel } from "../lib/device.mjs";
|
|
18
|
+
import { installHelper, packageVersion } from "../lib/install.mjs";
|
|
19
|
+
import { readStdinJson, runInternal } from "../lib/internal.mjs";
|
|
20
|
+
import { LoginError, deviceLogin } from "../lib/login.mjs";
|
|
21
|
+
import { messages, pickLang } from "../lib/messages.mjs";
|
|
22
|
+
import { createStore } from "../lib/store.mjs";
|
|
23
|
+
|
|
24
|
+
export function parseArgs(argv) {
|
|
25
|
+
const opts = { command: null, server: null, cloud: null, browser: true };
|
|
26
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
27
|
+
const arg = argv[i];
|
|
28
|
+
if (arg === "--server") opts.server = argv[(i += 1)];
|
|
29
|
+
else if (arg === "--cloud") opts.cloud = argv[(i += 1)];
|
|
30
|
+
else if (arg === "--no-browser") opts.browser = false;
|
|
31
|
+
else if (arg === "--version" || arg === "-v") opts.command = "version";
|
|
32
|
+
else if (arg === "--help" || arg === "-h") opts.command = "help";
|
|
33
|
+
else if (!opts.command) opts.command = arg;
|
|
34
|
+
}
|
|
35
|
+
return opts;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function openInBrowser(url) {
|
|
39
|
+
const [cmd, args] =
|
|
40
|
+
process.platform === "darwin"
|
|
41
|
+
? ["open", [url]]
|
|
42
|
+
: process.platform === "win32"
|
|
43
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
44
|
+
: ["xdg-open", [url]];
|
|
45
|
+
try {
|
|
46
|
+
spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true }).on("error", () => {}).unref();
|
|
47
|
+
} catch {
|
|
48
|
+
// sin navegador: la persona tiene el enlace en pantalla
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function run(argv, deps = {}) {
|
|
53
|
+
const env = deps.env ?? process.env;
|
|
54
|
+
const print = deps.print ?? ((line) => process.stdout.write(`${line}\n`));
|
|
55
|
+
const printErr = deps.printErr ?? ((line) => process.stderr.write(`${line}\n`));
|
|
56
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
57
|
+
const t = messages(pickLang(env));
|
|
58
|
+
const opts = parseArgs(argv);
|
|
59
|
+
const config = resolveConfig({ server: opts.server, cloud: opts.cloud }, env);
|
|
60
|
+
const home = specboxHome(env);
|
|
61
|
+
const store = deps.store ?? createStore({ home });
|
|
62
|
+
const account = accountFor(config.mcpUrl);
|
|
63
|
+
|
|
64
|
+
// Órdenes internas de la extensión de VSCode (JSON por stdin/stdout).
|
|
65
|
+
if (typeof opts.command === "string" && opts.command.startsWith("_")) {
|
|
66
|
+
return runInternal(opts.command, {
|
|
67
|
+
config,
|
|
68
|
+
store,
|
|
69
|
+
account,
|
|
70
|
+
home,
|
|
71
|
+
fetchImpl,
|
|
72
|
+
readInput: deps.readInput ?? readStdinJson,
|
|
73
|
+
out: (value) => print(JSON.stringify(value)),
|
|
74
|
+
deps,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
switch (opts.command) {
|
|
79
|
+
case "version":
|
|
80
|
+
print(packageVersion() ?? "unknown");
|
|
81
|
+
return 0;
|
|
82
|
+
case "login": {
|
|
83
|
+
print(t.loginTitle);
|
|
84
|
+
const device = {
|
|
85
|
+
device_id: deps.deviceId ?? deviceId(CLIENT, { home }),
|
|
86
|
+
host: deps.host ?? hostLabel(),
|
|
87
|
+
client: CLIENT,
|
|
88
|
+
};
|
|
89
|
+
let credential;
|
|
90
|
+
try {
|
|
91
|
+
credential = await deviceLogin({
|
|
92
|
+
cloudApi: config.cloudApi,
|
|
93
|
+
account,
|
|
94
|
+
store,
|
|
95
|
+
device,
|
|
96
|
+
fetchImpl,
|
|
97
|
+
sleep: deps.sleep,
|
|
98
|
+
onCode: (code) => {
|
|
99
|
+
print(t.openUrl(code.verification_uri_complete));
|
|
100
|
+
print(t.confirmCode(code.user_code));
|
|
101
|
+
print(t.waiting(Math.round((code.expires_in ?? 600) / 60)));
|
|
102
|
+
if (opts.browser) (deps.openUrl ?? openInBrowser)(code.verification_uri_complete);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
} catch (err) {
|
|
106
|
+
printErr(err instanceof LoginError ? t.loginError(err.code) : t.loginError(err?.message ?? "error"));
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
print(t.connected(credential, store.kind));
|
|
110
|
+
if (store.kind === "file") print(t.fileStoreWarning);
|
|
111
|
+
|
|
112
|
+
const helperPath = (deps.installHelper ?? installHelper)(home);
|
|
113
|
+
const result = configureClaudeCode({
|
|
114
|
+
mcpUrl: config.mcpUrl,
|
|
115
|
+
helperCmd: helperCommand(helperPath, deps.nodePath ?? stableNodePath()),
|
|
116
|
+
run: deps.runCommand,
|
|
117
|
+
claudeJsonPath: deps.claudeJsonPath,
|
|
118
|
+
});
|
|
119
|
+
if (result.ok) {
|
|
120
|
+
print(t.claudeConfigured(result.updated));
|
|
121
|
+
if (result.failed.length) print(t.claudeFailed(result.failed));
|
|
122
|
+
} else {
|
|
123
|
+
print(t.claudeManual(result.manual));
|
|
124
|
+
}
|
|
125
|
+
print(t.howItWorks(HOW_TO_CONNECT_URL));
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
case "status": {
|
|
129
|
+
const credential = store.read(account);
|
|
130
|
+
if (!credential?.token) {
|
|
131
|
+
print(t.notConnected);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
let valid = null;
|
|
135
|
+
try {
|
|
136
|
+
const res = await fetchImpl(`${credential.cloud_api ?? config.cloudApi}/whoami`, {
|
|
137
|
+
headers: { authorization: `Bearer ${credential.token}`, accept: "application/json" },
|
|
138
|
+
});
|
|
139
|
+
valid = res.status === 200 ? true : res.status === 401 ? false : null;
|
|
140
|
+
} catch {
|
|
141
|
+
valid = null;
|
|
142
|
+
}
|
|
143
|
+
print(t.statusTitle);
|
|
144
|
+
print(
|
|
145
|
+
t.statusLines(
|
|
146
|
+
credential,
|
|
147
|
+
store.kind,
|
|
148
|
+
config.mcpUrl,
|
|
149
|
+
valid,
|
|
150
|
+
claudeUsesHelper(deps.claudeJsonPath ? { claudeJsonPath: deps.claudeJsonPath } : {}),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
return valid === false ? 1 : 0;
|
|
154
|
+
}
|
|
155
|
+
case "logout": {
|
|
156
|
+
const credential = store.read(account);
|
|
157
|
+
if (!credential?.token) {
|
|
158
|
+
print(t.notConnected);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
let remote = false;
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetchImpl(`${credential.cloud_api ?? config.cloudApi}/devices/logout`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { authorization: `Bearer ${credential.token}` },
|
|
166
|
+
});
|
|
167
|
+
remote = res.status === 200 || res.status === 401; // 401: ya no valía
|
|
168
|
+
} catch {
|
|
169
|
+
remote = false;
|
|
170
|
+
}
|
|
171
|
+
store.remove(account);
|
|
172
|
+
print(t.loggedOut(credential.device_name ?? "?", remote));
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
case "help":
|
|
176
|
+
case null:
|
|
177
|
+
print(t.usage);
|
|
178
|
+
return opts.command === "help" ? 0 : 1;
|
|
179
|
+
default:
|
|
180
|
+
printErr(t.usage);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Por `npx`/npm el script se ejecuta a través de un enlace simbólico: se
|
|
186
|
+
// compara con la ruta real.
|
|
187
|
+
const invokedDirectly = (() => {
|
|
188
|
+
try {
|
|
189
|
+
return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
194
|
+
if (invokedDirectly) {
|
|
195
|
+
run(process.argv.slice(2)).then(
|
|
196
|
+
(code) => process.exit(code),
|
|
197
|
+
(err) => {
|
|
198
|
+
process.stderr.write(`${err?.stack ?? err}\n`);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
}
|
package/lib/claude.mjs
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurar Claude Code para que envíe el token en cada conexión (UC-3901
|
|
3
|
+
* AC-03): la entrada `SpecBox-MCP` (ámbito de usuario) pasa a ser
|
|
4
|
+
* `{type: "http", url, headersHelper}`, y las demás entradas que apuntan al
|
|
5
|
+
* mismo servidor (ámbito local de cada proyecto) reciben el mismo ayudante,
|
|
6
|
+
* de modo que los proyectos ya configurados siguen funcionando sin pasos
|
|
7
|
+
* manuales.
|
|
8
|
+
*
|
|
9
|
+
* Se usa la orden oficial `claude mcp` (remove + add-json), nunca se edita
|
|
10
|
+
* `~/.claude.json` a mano: Claude Code lo reescribe mientras está abierto. Si
|
|
11
|
+
* `claude` no está en el PATH, se devuelve la orden para ejecutarla a mano.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join, posix, win32 } from "node:path";
|
|
16
|
+
import { SERVER_NAME } from "./config.mjs";
|
|
17
|
+
import { runCommand } from "./store.mjs";
|
|
18
|
+
|
|
19
|
+
export function quoteArg(value) {
|
|
20
|
+
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* La ruta de Node que se graba en el ayudante. `process.execPath` es la ruta
|
|
25
|
+
* real del binario, que en Homebrew lleva la versión
|
|
26
|
+
* (`/opt/homebrew/Cellar/node/26.7.0/bin/node`) y desaparece con
|
|
27
|
+
* `brew upgrade`; se prefiere la primera entrada del PATH que apunta a ese
|
|
28
|
+
* mismo binario (`/opt/homebrew/bin/node`), que el gestor de paquetes mantiene
|
|
29
|
+
* al actualizar. Si ninguna apunta a él, se usa `process.execPath`.
|
|
30
|
+
*/
|
|
31
|
+
export function stableNodePath({
|
|
32
|
+
execPath = process.execPath,
|
|
33
|
+
pathEnv = process.env.PATH ?? "",
|
|
34
|
+
platform = process.platform,
|
|
35
|
+
realpath = realpathSync,
|
|
36
|
+
exists = existsSync,
|
|
37
|
+
} = {}) {
|
|
38
|
+
const path = platform === "win32" ? win32 : posix;
|
|
39
|
+
const binary = platform === "win32" ? "node.exe" : "node";
|
|
40
|
+
let target;
|
|
41
|
+
try {
|
|
42
|
+
target = realpath(execPath);
|
|
43
|
+
} catch {
|
|
44
|
+
return execPath;
|
|
45
|
+
}
|
|
46
|
+
for (const dir of pathEnv.split(path.delimiter)) {
|
|
47
|
+
if (!dir) continue;
|
|
48
|
+
const candidate = path.join(dir, binary);
|
|
49
|
+
try {
|
|
50
|
+
if (exists(candidate) && realpath(candidate) === target) return candidate;
|
|
51
|
+
} catch {
|
|
52
|
+
// entrada del PATH rota o sin permisos: se ignora
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return execPath;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** La orden que Claude Code ejecuta: node absoluto + ayudante absoluto. */
|
|
59
|
+
export function helperCommand(helperPath, nodePath = stableNodePath()) {
|
|
60
|
+
return `${quoteArg(nodePath)} ${quoteArg(helperPath)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Las dos rutas de una orden de `helperCommand`, o null si no tiene esa forma. */
|
|
64
|
+
export function helperPaths(cmd) {
|
|
65
|
+
const m = /^"([^"]+)" "([^"]+)"$/.exec(String(cmd ?? ""));
|
|
66
|
+
return m ? { node: m[1], helper: m[2] } : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function origin(url) {
|
|
70
|
+
try {
|
|
71
|
+
return new URL(url).origin;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Entradas de `~/.claude.json` (usuario y locales) que apuntan al servidor de SpecBox. */
|
|
78
|
+
export function findSpecboxEntries(claudeJson, mcpUrl) {
|
|
79
|
+
const target = origin(mcpUrl);
|
|
80
|
+
const matches = (entry) => entry && typeof entry.url === "string" && origin(entry.url) === target;
|
|
81
|
+
const found = [];
|
|
82
|
+
for (const [name, entry] of Object.entries(claudeJson?.mcpServers ?? {})) {
|
|
83
|
+
if (matches(entry)) found.push({ scope: "user", name, entry, cwd: null });
|
|
84
|
+
}
|
|
85
|
+
for (const [project, config] of Object.entries(claudeJson?.projects ?? {})) {
|
|
86
|
+
for (const [name, entry] of Object.entries(config?.mcpServers ?? {})) {
|
|
87
|
+
if (matches(entry)) found.push({ scope: "local", name, entry, cwd: project });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** La entrada con el ayudante, sin ninguna cabecera Authorization fija que lo contradiga. */
|
|
94
|
+
export function withHelper(entry, helperCmd) {
|
|
95
|
+
const { headers, headersHelper: _old, ...rest } = entry ?? {};
|
|
96
|
+
void _old;
|
|
97
|
+
const kept = Object.fromEntries(Object.entries(headers ?? {}).filter(([k]) => k.toLowerCase() !== "authorization"));
|
|
98
|
+
return {
|
|
99
|
+
...rest,
|
|
100
|
+
type: rest.type ?? "http",
|
|
101
|
+
...(Object.keys(kept).length ? { headers: kept } : {}),
|
|
102
|
+
headersHelper: helperCmd,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function configureClaudeCode({
|
|
107
|
+
mcpUrl,
|
|
108
|
+
helperCmd,
|
|
109
|
+
run = runCommand,
|
|
110
|
+
claudeJsonPath = join(homedir(), ".claude.json"),
|
|
111
|
+
readFile = readFileSync,
|
|
112
|
+
projectExists = existsSync,
|
|
113
|
+
}) {
|
|
114
|
+
const userEntry = JSON.stringify({ type: "http", url: mcpUrl, headersHelper: helperCmd });
|
|
115
|
+
const manual = `claude mcp add-json ${SERVER_NAME} '${userEntry}' --scope user`;
|
|
116
|
+
const probe = run("claude", ["--version"]);
|
|
117
|
+
if (probe.error || probe.status === null) return { ok: false, reason: "claude_missing", manual };
|
|
118
|
+
|
|
119
|
+
run("claude", ["mcp", "remove", SERVER_NAME, "--scope", "user"]); // puede no existir
|
|
120
|
+
const added = run("claude", ["mcp", "add-json", SERVER_NAME, userEntry, "--scope", "user"]);
|
|
121
|
+
if (added.status !== 0) {
|
|
122
|
+
return { ok: false, reason: "add_failed", detail: `${added.stderr}${added.stdout}`.trim(), manual };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const updated = [{ scope: "user", name: SERVER_NAME, cwd: null }];
|
|
126
|
+
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
|
|
137
|
+
const opts = found.cwd ? { cwd: found.cwd } : {};
|
|
138
|
+
run("claude", ["mcp", "remove", found.name, "--scope", found.scope], opts);
|
|
139
|
+
const res = run(
|
|
140
|
+
"claude",
|
|
141
|
+
["mcp", "add-json", found.name, JSON.stringify(withHelper(found.entry, helperCmd)), "--scope", found.scope],
|
|
142
|
+
opts,
|
|
143
|
+
);
|
|
144
|
+
(res.status === 0 ? updated : failed).push({ scope: found.scope, name: found.name, cwd: found.cwd });
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, updated, failed };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* ¿La entrada de usuario usa nuestro ayudante y este puede ejecutarse? (para
|
|
151
|
+
* `specbox status` y la extensión). Si el Node grabado ya no existe (p. ej.
|
|
152
|
+
* una versión que borró el gestor de paquetes), es `false`: la extensión
|
|
153
|
+
* vuelve a configurar Claude Code en el siguiente arranque.
|
|
154
|
+
*/
|
|
155
|
+
export function claudeUsesHelper({
|
|
156
|
+
claudeJsonPath = join(homedir(), ".claude.json"),
|
|
157
|
+
readFile = readFileSync,
|
|
158
|
+
exists = existsSync,
|
|
159
|
+
} = {}) {
|
|
160
|
+
try {
|
|
161
|
+
const entry = JSON.parse(readFile(claudeJsonPath, "utf8"))?.mcpServers?.[SERVER_NAME];
|
|
162
|
+
const paths = helperPaths(entry?.headersHelper);
|
|
163
|
+
return Boolean(paths && paths.helper.endsWith("mcp-headers.mjs") && exists(paths.node) && exists(paths.helper));
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Valores por defecto y rutas de `specbox` (UC-3904).
|
|
3
|
+
*
|
|
4
|
+
* Todo se puede cambiar por entorno para operadores con su propio servidor:
|
|
5
|
+
* SPECBOX_MCP_URL servidor MCP remoto
|
|
6
|
+
* SPECBOX_CLOUD_API API del panel (código de un solo uso, renovación)
|
|
7
|
+
* SPECBOX_HOME carpeta local de specbox (por defecto ~/.specbox)
|
|
8
|
+
*/
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_MCP_URL = "https://mcp-specbox-engine.jpsdeveloper.com/mcp";
|
|
13
|
+
export const DEFAULT_CLOUD_API = "https://api-cloud.specbox.build/api";
|
|
14
|
+
export const HOW_TO_CONNECT_URL = "https://cloud.specbox.build/como-se-conecta";
|
|
15
|
+
|
|
16
|
+
/** Nombre del servidor en Claude Code: las skills del engine usan `mcp__SpecBox-MCP__*`. */
|
|
17
|
+
export const SERVER_NAME = "SpecBox-MCP";
|
|
18
|
+
|
|
19
|
+
/** El cliente que usa el token guardado en el almacén: Claude Code. */
|
|
20
|
+
export const CLIENT = "claude-code";
|
|
21
|
+
|
|
22
|
+
/** Los clientes renuevan a partir de estos días antes de caducar (UC-3904 AC-03). */
|
|
23
|
+
export const RENEW_WINDOW_DAYS = 14;
|
|
24
|
+
|
|
25
|
+
export function specboxHome(env = process.env) {
|
|
26
|
+
return env.SPECBOX_HOME || join(homedir(), ".specbox");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveConfig({ server, cloud } = {}, env = process.env) {
|
|
30
|
+
return {
|
|
31
|
+
mcpUrl: stripSlash(server || env.SPECBOX_MCP_URL || DEFAULT_MCP_URL),
|
|
32
|
+
cloudApi: stripSlash(cloud || env.SPECBOX_CLOUD_API || DEFAULT_CLOUD_API),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Clave de la credencial en el almacén: la URL del servidor sin barra final. */
|
|
37
|
+
export function accountFor(mcpUrl) {
|
|
38
|
+
const url = new URL(mcpUrl);
|
|
39
|
+
return stripSlash(`${url.origin}${url.pathname}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stripSlash(value) {
|
|
43
|
+
return String(value).replace(/\/+$/, "");
|
|
44
|
+
}
|
package/lib/device.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identidad del dispositivo (UC-3904 AC-01): un dispositivo es un ordenador y
|
|
3
|
+
* un cliente MCP. El `device_id` es el SHA-256 del id de la máquina y el
|
|
4
|
+
* cliente: estable entre reinstalaciones y sin revelar el id real al servidor.
|
|
5
|
+
* Si no se puede leer el id de la máquina, se usa uno aleatorio guardado en
|
|
6
|
+
* `~/.specbox/device-id`.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { hostname } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export function machineId({ platform = process.platform, run = execFileSync, readFile = readFileSync } = {}) {
|
|
15
|
+
try {
|
|
16
|
+
if (platform === "darwin") {
|
|
17
|
+
const out = run("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { encoding: "utf8" });
|
|
18
|
+
const match = /"IOPlatformUUID" = "([^"]+)"/.exec(out);
|
|
19
|
+
if (match) return match[1];
|
|
20
|
+
} else if (platform === "win32") {
|
|
21
|
+
const out = run("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"], {
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
});
|
|
24
|
+
const match = /MachineGuid\s+REG_SZ\s+(\S+)/.exec(out);
|
|
25
|
+
if (match) return match[1];
|
|
26
|
+
} else {
|
|
27
|
+
for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
28
|
+
try {
|
|
29
|
+
const value = String(readFile(file, "utf8")).trim();
|
|
30
|
+
if (value) return value;
|
|
31
|
+
} catch {
|
|
32
|
+
// siguiente fuente
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// sin id de máquina: se usa el aleatorio persistido
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function persistedRandomId(home) {
|
|
43
|
+
const file = join(home, "device-id");
|
|
44
|
+
try {
|
|
45
|
+
const value = readFileSync(file, "utf8").trim();
|
|
46
|
+
if (value) return value;
|
|
47
|
+
} catch {
|
|
48
|
+
// se crea abajo
|
|
49
|
+
}
|
|
50
|
+
const id = randomUUID();
|
|
51
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
52
|
+
writeFileSync(file, id, { mode: 0o600 });
|
|
53
|
+
return id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function deviceId(client, { home, machine = machineId } = {}) {
|
|
57
|
+
const seed = (typeof machine === "function" ? machine() : machine) || persistedRandomId(home);
|
|
58
|
+
return createHash("sha256").update(`${seed}:${client}`).digest("hex");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Nombre legible del ordenador para el nombre del dispositivo (≤ 80 caracteres). */
|
|
62
|
+
export function hostLabel(name = hostname()) {
|
|
63
|
+
const clean = String(name)
|
|
64
|
+
.replace(/\.local$/i, "")
|
|
65
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
66
|
+
.trim();
|
|
67
|
+
return (clean || "ordenador").slice(0, 80);
|
|
68
|
+
}
|
package/lib/install.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instala el ayudante de cabeceras en `~/.specbox/bin` (ruta fija que no
|
|
3
|
+
* cambia al actualizar el paquete ni la extensión): el script y los módulos
|
|
4
|
+
* que importa, más un package.json que marca la carpeta como ESM. Se
|
|
5
|
+
* sobrescribe en cada `specbox login` para llevar siempre la última versión.
|
|
6
|
+
*/
|
|
7
|
+
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
export const RUNTIME_FILES = ["config.mjs", "store.mjs", "renew.mjs", "mcp-headers.mjs"];
|
|
12
|
+
|
|
13
|
+
const LIB_DIR = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
export function packageVersion() {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(join(LIB_DIR, "..", "package.json"), "utf8")).version;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function installHelper(home, { sourceDir = LIB_DIR, version = packageVersion() } = {}) {
|
|
24
|
+
const bin = join(home, "bin");
|
|
25
|
+
mkdirSync(bin, { recursive: true, mode: 0o700 });
|
|
26
|
+
for (const file of RUNTIME_FILES) copyFileSync(join(sourceDir, file), join(bin, file));
|
|
27
|
+
writeFileSync(
|
|
28
|
+
join(bin, "package.json"),
|
|
29
|
+
`${JSON.stringify({ private: true, type: "module", specbox_helper_version: version }, null, 2)}\n`,
|
|
30
|
+
);
|
|
31
|
+
return join(bin, "mcp-headers.mjs");
|
|
32
|
+
}
|