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/lib/store.mjs ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Almacén seguro de la credencial del dispositivo (UC-3904 AC-02).
3
+ *
4
+ * Una credencial por servidor MCP (clave = URL del servidor), guardada en el
5
+ * almacén del sistema:
6
+ *
7
+ * - macOS: Llavero (`security`). El secreto entra por stdin con
8
+ * `security -i`, nunca como argumento visible en `ps`.
9
+ * - Linux: Secret Service (`secret-tool`), secreto por stdin.
10
+ * - Windows: DPAPI del usuario (PowerShell), en ~/.specbox/credentials/.
11
+ * - Si no hay ninguno (Linux sin sesión gráfica, contenedores): un fichero
12
+ * ~/.specbox/credentials.json con permisos 0600. `specbox status` lo avisa.
13
+ *
14
+ * La credencial es un JSON (token, caducidad, dispositivo, persona) que se
15
+ * guarda en base64: así no hay comillas ni espacios que escapar.
16
+ *
17
+ * Todo es síncrono y sin dependencias: el ayudante que ejecuta Claude Code en
18
+ * cada conexión tiene 10 s y no puede instalar nada.
19
+ */
20
+ import { spawnSync } from "node:child_process";
21
+ import { createHash } from "node:crypto";
22
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+
25
+ export const SERVICE = "SpecBox MCP";
26
+ const SECRET_TOOL_SERVICE = "specbox-mcp";
27
+
28
+ /** Ejecuta un programa sin shell. Nunca lanza: devuelve el resultado o el error. */
29
+ export function runCommand(cmd, args, { input, env, cwd, timeout = 8_000 } = {}) {
30
+ const res = spawnSync(cmd, args, {
31
+ input,
32
+ cwd,
33
+ env: env ? { ...process.env, ...env } : process.env,
34
+ encoding: "utf8",
35
+ windowsHide: true,
36
+ timeout,
37
+ // Sin shell nunca: los argumentos (JSON de Claude Code) no se reinterpretan.
38
+ // En Windows, un `claude.cmd` de npm no arranca así (EINVAL) y se ofrece la
39
+ // orden para ejecutarla a mano; el `claude.exe` nativo sí.
40
+ });
41
+ return { status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "", error: res.error ?? null };
42
+ }
43
+
44
+ export function encode(credential) {
45
+ return Buffer.from(JSON.stringify(credential), "utf8").toString("base64");
46
+ }
47
+
48
+ export function decode(raw) {
49
+ const text = String(raw ?? "").trim();
50
+ if (!text) return null;
51
+ try {
52
+ return JSON.parse(Buffer.from(text, "base64").toString("utf8"));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function missing(res) {
59
+ return res.error?.code === "ENOENT";
60
+ }
61
+
62
+ function keychainStore(run) {
63
+ return {
64
+ kind: "keychain",
65
+ read(account) {
66
+ const res = run("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w"]);
67
+ return res.status === 0 ? decode(res.stdout) : null;
68
+ },
69
+ write(account, credential) {
70
+ const res = run("security", ["-i"], {
71
+ input: `add-generic-password -U -s "${SERVICE}" -a "${account}" -w ${encode(credential)}\n`,
72
+ });
73
+ if (res.status !== 0 || /error/i.test(res.stderr)) throw new Error(`keychain write failed: ${res.stderr.trim()}`);
74
+ },
75
+ remove(account) {
76
+ run("security", ["delete-generic-password", "-s", SERVICE, "-a", account]);
77
+ },
78
+ };
79
+ }
80
+
81
+ function secretToolStore(run) {
82
+ const attrs = (account) => ["service", SECRET_TOOL_SERVICE, "account", account];
83
+ return {
84
+ kind: "secret-service",
85
+ read(account) {
86
+ const res = run("secret-tool", ["lookup", ...attrs(account)]);
87
+ if (missing(res)) throw Object.assign(new Error("secret-tool missing"), { code: "ENOENT" });
88
+ return res.status === 0 ? decode(res.stdout) : null;
89
+ },
90
+ write(account, credential) {
91
+ const res = run("secret-tool", ["store", `--label=${SERVICE}`, ...attrs(account)], { input: encode(credential) });
92
+ if (missing(res)) throw Object.assign(new Error("secret-tool missing"), { code: "ENOENT" });
93
+ if (res.status !== 0) throw new Error(`secret-tool store failed: ${res.stderr.trim()}`);
94
+ },
95
+ remove(account) {
96
+ run("secret-tool", ["clear", ...attrs(account)]);
97
+ },
98
+ };
99
+ }
100
+
101
+ const PS_WRITE =
102
+ "Add-Type -AssemblyName System.Security; $d=[Console]::In.ReadToEnd().Trim(); " +
103
+ "$b=[Text.Encoding]::UTF8.GetBytes($d); " +
104
+ "$p=[Security.Cryptography.ProtectedData]::Protect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser); " +
105
+ "[IO.File]::WriteAllBytes($env:SPECBOX_CRED_FILE,$p)";
106
+ const PS_READ =
107
+ "Add-Type -AssemblyName System.Security; $p=[IO.File]::ReadAllBytes($env:SPECBOX_CRED_FILE); " +
108
+ "$b=[Security.Cryptography.ProtectedData]::Unprotect($p,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser); " +
109
+ "[Console]::Out.Write([Text.Encoding]::UTF8.GetString($b))";
110
+
111
+ function dpapiStore(run, home) {
112
+ const fileFor = (account) =>
113
+ join(home, "credentials", `${createHash("sha256").update(account).digest("hex").slice(0, 32)}.dpapi`);
114
+ return {
115
+ kind: "dpapi",
116
+ read(account) {
117
+ const res = run("powershell", ["-NoProfile", "-NonInteractive", "-Command", PS_READ], {
118
+ env: { SPECBOX_CRED_FILE: fileFor(account) },
119
+ });
120
+ return res.status === 0 ? decode(res.stdout) : null;
121
+ },
122
+ write(account, credential) {
123
+ mkdirSync(join(home, "credentials"), { recursive: true });
124
+ const res = run("powershell", ["-NoProfile", "-NonInteractive", "-Command", PS_WRITE], {
125
+ input: encode(credential),
126
+ env: { SPECBOX_CRED_FILE: fileFor(account) },
127
+ });
128
+ if (res.status !== 0) throw new Error(`DPAPI write failed: ${res.stderr.trim()}`);
129
+ },
130
+ remove(account) {
131
+ rmSync(fileFor(account), { force: true });
132
+ },
133
+ };
134
+ }
135
+
136
+ export function fileStore(home) {
137
+ const file = join(home, "credentials.json");
138
+ const load = () => {
139
+ try {
140
+ return JSON.parse(readFileSync(file, "utf8"));
141
+ } catch {
142
+ return {};
143
+ }
144
+ };
145
+ const save = (all) => {
146
+ mkdirSync(home, { recursive: true, mode: 0o700 });
147
+ writeFileSync(file, JSON.stringify(all, null, 2), { mode: 0o600 });
148
+ chmodSync(file, 0o600);
149
+ };
150
+ return {
151
+ kind: "file",
152
+ read(account) {
153
+ return decode(load()[account]);
154
+ },
155
+ write(account, credential) {
156
+ save({ ...load(), [account]: encode(credential) });
157
+ },
158
+ remove(account) {
159
+ const all = load();
160
+ if (!(account in all)) return;
161
+ delete all[account];
162
+ save(all);
163
+ },
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Si el almacén del sistema no está o no responde (Linux sin sesión gráfica),
169
+ * se usa el fichero protegido. `kind` dice dónde quedó la última escritura.
170
+ */
171
+ function withFallback(primary, fallback) {
172
+ let kind = primary.kind;
173
+ return {
174
+ get kind() {
175
+ return kind;
176
+ },
177
+ read(account) {
178
+ try {
179
+ const value = primary.read(account);
180
+ if (value) return value;
181
+ } catch {
182
+ // sin almacén del sistema: se mira el fichero
183
+ }
184
+ return fallback.read(account);
185
+ },
186
+ write(account, credential) {
187
+ try {
188
+ primary.write(account, credential);
189
+ kind = primary.kind;
190
+ return;
191
+ } catch {
192
+ // cae al fichero protegido
193
+ }
194
+ fallback.write(account, credential);
195
+ kind = fallback.kind;
196
+ },
197
+ remove(account) {
198
+ try {
199
+ primary.remove(account);
200
+ } catch {
201
+ // nada que borrar en el almacén del sistema
202
+ }
203
+ fallback.remove(account);
204
+ },
205
+ };
206
+ }
207
+
208
+ export function createStore({ platform = process.platform, run = runCommand, home } = {}) {
209
+ if (!home) throw new Error("createStore needs the specbox home");
210
+ if (platform === "darwin") return keychainStore(run);
211
+ if (platform === "win32") return dpapiStore(run, home);
212
+ if (platform === "linux") return withFallback(secretToolStore(run), fileStore(home));
213
+ return fileStore(home);
214
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "specbox",
3
+ "version": "6.14.0",
4
+ "description": "Conecta este ordenador a SpecBox sin copiar tokens: specbox login, status y logout.",
5
+ "type": "module",
6
+ "bin": {
7
+ "specbox": "bin/specbox.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18.17"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test test/*.test.mjs"
19
+ },
20
+ "keywords": [
21
+ "specbox",
22
+ "mcp",
23
+ "claude-code",
24
+ "login"
25
+ ],
26
+ "license": "MIT",
27
+ "author": "EmbedBuild",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/EmbedBuild/specbox-engine.git",
31
+ "directory": "packages/specbox-cli"
32
+ },
33
+ "homepage": "https://cloud.specbox.build/como-se-conecta",
34
+ "bugs": {
35
+ "url": "https://github.com/EmbedBuild/specbox-engine/issues"
36
+ }
37
+ }