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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Órdenes internas para la extensión de VSCode (UC-3901 AC-03, UC-3904).
3
+ *
4
+ * La extensión empaqueta este paquete y lo ejecuta con el Node del sistema
5
+ * para no reimplementar (y dejar divergir) el almacén seguro, el ayudante ni
6
+ * la configuración de Claude Code: la extensión y `specbox login` hacen
7
+ * exactamente lo mismo en un mismo ordenador. Hablan JSON (una línea por
8
+ * stdout; la entrada, si la hay, por stdin) y no aparecen en `--help`.
9
+ *
10
+ * _device id y nombre del dispositivo (los mismos que usa `specbox login`)
11
+ * _status ¿hay credencial? dispositivo, caducidad y si Claude Code usa el ayudante
12
+ * (nunca el token: solo su huella)
13
+ * _connect guarda la credencial que trae la extensión, instala el ayudante y
14
+ * configura Claude Code
15
+ * _configure instala el ayudante y configura Claude Code, sin credencial
16
+ * _adopt con el token que ya tenía la extensión, obtiene un token de
17
+ * dispositivo (/devices/adopt, que NO revoca el anterior: puede estar
18
+ * pegado en otros sitios) y lo conecta como `_connect`
19
+ * _renew renueva si toca y devuelve el token vigente (para la extensión)
20
+ * _disconnect /devices/logout y borra la credencial local
21
+ */
22
+ import { createHash } from "node:crypto";
23
+ import { claudeUsesHelper, configureClaudeCode, helperCommand, stableNodePath } from "./claude.mjs";
24
+ import { CLIENT } from "./config.mjs";
25
+ import { deviceId, hostLabel } from "./device.mjs";
26
+ import { installHelper } from "./install.mjs";
27
+ import { credentialFromResponse, renewCredential, renewDue } from "./renew.mjs";
28
+
29
+ export async function readStdinJson(stream = process.stdin) {
30
+ let raw = "";
31
+ for await (const chunk of stream) raw += chunk;
32
+ return raw.trim() ? JSON.parse(raw) : {};
33
+ }
34
+
35
+ function tokenSha(token) {
36
+ return createHash("sha256").update(String(token)).digest("hex").slice(0, 16);
37
+ }
38
+
39
+ /** Lo que la extensión puede ver de una credencial: todo menos el token. */
40
+ export function credentialMeta(credential) {
41
+ return {
42
+ token_sha: tokenSha(credential.token),
43
+ token_id: credential.token_id ?? null,
44
+ device_id: credential.device_id ?? null,
45
+ device_name: credential.device_name ?? null,
46
+ client: credential.client ?? null,
47
+ expires_at: credential.expires_at ?? null,
48
+ renew_after: credential.renew_after ?? null,
49
+ developer: credential.developer ?? null,
50
+ };
51
+ }
52
+
53
+ function safeRead(store, account) {
54
+ try {
55
+ return store.read(account);
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export async function runInternal(command, ctx) {
62
+ const { config, store, account, home, fetchImpl, readInput, out, deps = {} } = ctx;
63
+ const device = () => ({
64
+ device_id: deps.deviceId ?? deviceId(CLIENT, { home }),
65
+ host: deps.host ?? hostLabel(),
66
+ client: CLIENT,
67
+ });
68
+ const connectClaude = () =>
69
+ configureClaudeCode({
70
+ mcpUrl: config.mcpUrl,
71
+ helperCmd: helperCommand((deps.installHelper ?? installHelper)(home), deps.nodePath ?? stableNodePath()),
72
+ run: deps.runCommand,
73
+ claudeJsonPath: deps.claudeJsonPath,
74
+ });
75
+ const usesHelper = () => claudeUsesHelper(deps.claudeJsonPath ? { claudeJsonPath: deps.claudeJsonPath } : {});
76
+
77
+ switch (command) {
78
+ case "_device":
79
+ out(device());
80
+ return 0;
81
+
82
+ case "_status": {
83
+ const credential = safeRead(store, account);
84
+ out({
85
+ connected: Boolean(credential?.token),
86
+ store: store.kind,
87
+ ...(credential?.token ? credentialMeta(credential) : {}),
88
+ claude_helper: usesHelper(),
89
+ });
90
+ return 0;
91
+ }
92
+
93
+ case "_connect": {
94
+ const { credential } = await readInput();
95
+ if (!credential?.token) {
96
+ out({ ok: false, reason: "missing_credential" });
97
+ return 1;
98
+ }
99
+ const stored = { ...credential, cloud_api: credential.cloud_api ?? config.cloudApi, updated_at: new Date().toISOString() };
100
+ store.write(account, stored);
101
+ out({ ok: true, store: store.kind, ...credentialMeta(stored), claude: connectClaude() });
102
+ return 0;
103
+ }
104
+
105
+ case "_configure":
106
+ out({ ok: true, claude: connectClaude() });
107
+ return 0;
108
+
109
+ case "_adopt": {
110
+ const { token, issued_via: issuedVia = "vscode" } = await readInput();
111
+ if (!token) {
112
+ out({ ok: false, reason: "missing_token" });
113
+ return 1;
114
+ }
115
+ let res;
116
+ try {
117
+ res = await fetchImpl(`${config.cloudApi}/devices/adopt`, {
118
+ method: "POST",
119
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
120
+ body: JSON.stringify({ ...device(), issued_via: issuedVia }),
121
+ });
122
+ } catch {
123
+ out({ ok: false, reason: "network" });
124
+ return 1;
125
+ }
126
+ if (res.status !== 200) {
127
+ const reason = res.status === 401 ? "invalid_token" : res.status === 409 ? "conflict" : `http_${res.status}`;
128
+ out({ ok: false, reason });
129
+ return 1;
130
+ }
131
+ const credential = credentialFromResponse(await res.json(), config.cloudApi);
132
+ store.write(account, credential);
133
+ out({ ok: true, token: credential.token, store: store.kind, ...credentialMeta(credential), claude: connectClaude() });
134
+ return 0;
135
+ }
136
+
137
+ case "_renew": {
138
+ let credential = safeRead(store, account);
139
+ if (!credential?.token) {
140
+ out({ connected: false });
141
+ return 0;
142
+ }
143
+ let renewed = false;
144
+ if (renewDue(credential)) {
145
+ const next = await renewCredential({ store, account, credential, fetchImpl, lockDir: home });
146
+ renewed = next.token !== credential.token;
147
+ credential = next;
148
+ }
149
+ out({ connected: true, renewed, token: credential.token, ...credentialMeta(credential) });
150
+ return 0;
151
+ }
152
+
153
+ case "_disconnect": {
154
+ const credential = safeRead(store, account);
155
+ let remote = false;
156
+ if (credential?.token) {
157
+ try {
158
+ const res = await fetchImpl(`${credential.cloud_api ?? config.cloudApi}/devices/logout`, {
159
+ method: "POST",
160
+ headers: { authorization: `Bearer ${credential.token}` },
161
+ });
162
+ remote = res.status === 200 || res.status === 401;
163
+ } catch {
164
+ remote = false;
165
+ }
166
+ }
167
+ store.remove(account);
168
+ out({ ok: true, remote, had_credential: Boolean(credential?.token) });
169
+ return 0;
170
+ }
171
+
172
+ default:
173
+ out({ ok: false, reason: "unknown_command" });
174
+ return 1;
175
+ }
176
+ }
package/lib/login.mjs ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `specbox login` — conectar este ordenador con un código de un solo uso
3
+ * (UC-3904 AC-02), gestionado por el panel de SpecBox:
4
+ *
5
+ * 1. POST {cloud}/device/code con el dispositivo → código y enlace.
6
+ * 2. La persona abre el enlace, entra con GitHub y confirma el código.
7
+ * 3. Mientras, se pregunta a POST {cloud}/device/token cada `interval`
8
+ * segundos (más 5 con cada `slow_down`) hasta recibir el token, que va
9
+ * directo al almacén seguro: nadie lo ve ni lo copia.
10
+ */
11
+ import { credentialFromResponse } from "./renew.mjs";
12
+
13
+ export class LoginError extends Error {
14
+ constructor(code, detail) {
15
+ super(detail ? `${code}: ${detail}` : code);
16
+ this.code = code;
17
+ }
18
+ }
19
+
20
+ async function postJson(fetchImpl, url, body) {
21
+ const res = await fetchImpl(url, {
22
+ method: "POST",
23
+ headers: { "content-type": "application/json", accept: "application/json" },
24
+ body: JSON.stringify(body),
25
+ });
26
+ let json = {};
27
+ try {
28
+ json = await res.json();
29
+ } catch {
30
+ // cuerpo vacío o no JSON
31
+ }
32
+ return { status: res.status, json };
33
+ }
34
+
35
+ /**
36
+ * Pide el código, avisa (`onCode`) y espera la confirmación. Devuelve la
37
+ * credencial ya guardada en `store` bajo `account`.
38
+ */
39
+ export async function deviceLogin({
40
+ cloudApi,
41
+ account,
42
+ store,
43
+ device,
44
+ onCode,
45
+ fetchImpl = fetch,
46
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
47
+ now = Date.now,
48
+ }) {
49
+ let code;
50
+ try {
51
+ code = await postJson(fetchImpl, `${cloudApi}/device/code`, device);
52
+ } catch (err) {
53
+ throw new LoginError("network", err?.message);
54
+ }
55
+ if (code.status !== 200 || !code.json.device_code) {
56
+ throw new LoginError(code.status === 429 ? "rate_limited" : "code_failed", code.json.error_description ?? code.json.message);
57
+ }
58
+ const { device_code: deviceCode, expires_in: expiresIn = 600 } = code.json;
59
+ let interval = Math.max(1, Number(code.json.interval) || 5);
60
+ await onCode?.(code.json);
61
+
62
+ const deadline = now() + expiresIn * 1000;
63
+ while (now() < deadline) {
64
+ await sleep(interval * 1000);
65
+ let res;
66
+ try {
67
+ res = await postJson(fetchImpl, `${cloudApi}/device/token`, { device_code: deviceCode });
68
+ } catch {
69
+ continue; // un corte de red momentáneo: se vuelve a preguntar
70
+ }
71
+ if (res.status === 200 && res.json.access_token) {
72
+ const credential = credentialFromResponse(res.json, cloudApi);
73
+ store.write(account, credential);
74
+ return credential;
75
+ }
76
+ const error = res.json.error;
77
+ if (error === "authorization_pending") continue;
78
+ if (error === "slow_down" || res.status === 429) {
79
+ interval += 5;
80
+ continue;
81
+ }
82
+ if (error === "access_denied") throw new LoginError("denied");
83
+ if (error === "expired_token" || error === "invalid_grant") throw new LoginError("expired");
84
+ throw new LoginError("unexpected", `HTTP ${res.status} ${error ?? ""}`.trim());
85
+ }
86
+ throw new LoginError("expired");
87
+ }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Ayudante de cabeceras para Claude Code (UC-3901 AC-03, UC-3904 AC-03).
4
+ *
5
+ * Claude Code lo ejecuta (`headersHelper`) al conectar con el servidor MCP, al
6
+ * reconectar y tras un 401/403, y usa como cabeceras el JSON que imprime. Lee
7
+ * la credencial del almacén seguro para la URL del servidor que recibe en
8
+ * `CLAUDE_CODE_MCP_SERVER_URL`, la renueva si le quedan menos de 14 días e
9
+ * imprime `{"Authorization": "Bearer …"}`.
10
+ *
11
+ * Sin credencial imprime `{}`: la conexión sale sin token y el servidor decide
12
+ * (en el periodo de gracia avisa; después la rechaza con el mensaje de cómo
13
+ * conectarse). Nunca imprime otra cosa por stdout ni falla: un error de
14
+ * Claude Code aquí dejaría a la persona sin MCP.
15
+ *
16
+ * Se instala en ~/.specbox/bin junto a los módulos que importa.
17
+ */
18
+ import { realpathSync } from "node:fs";
19
+ import { pathToFileURL } from "node:url";
20
+ import { DEFAULT_MCP_URL, accountFor, specboxHome } from "./config.mjs";
21
+ import { renewCredential, renewDue } from "./renew.mjs";
22
+ import { createStore } from "./store.mjs";
23
+
24
+ export async function headersFor({
25
+ env = process.env,
26
+ store = createStore({ home: specboxHome(env) }),
27
+ fetchImpl = fetch,
28
+ now = Date.now(),
29
+ } = {}) {
30
+ const url = env.CLAUDE_CODE_MCP_SERVER_URL || env.SPECBOX_MCP_URL || DEFAULT_MCP_URL;
31
+ const account = accountFor(url);
32
+ let credential = store.read(account);
33
+ if (!credential?.token) return {};
34
+ if (renewDue(credential, now)) {
35
+ credential = await renewCredential({ store, account, credential, fetchImpl, lockDir: specboxHome(env) });
36
+ }
37
+ return { Authorization: `Bearer ${credential.token}` };
38
+ }
39
+
40
+ // Por `npx`/npm el script se ejecuta a través de un enlace simbólico: se
41
+ // compara con la ruta real.
42
+ const invokedDirectly = (() => {
43
+ try {
44
+ return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
45
+ } catch {
46
+ return false;
47
+ }
48
+ })();
49
+ if (invokedDirectly) {
50
+ headersFor()
51
+ .then((headers) => process.stdout.write(JSON.stringify(headers)))
52
+ .catch(() => process.stdout.write("{}"));
53
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Textos de `specbox` en español e inglés, según el idioma del sistema
3
+ * (LC_ALL, LC_MESSAGES o LANG empezando por "es" → español).
4
+ */
5
+ export function pickLang(env = process.env) {
6
+ const raw = env.LC_ALL || env.LC_MESSAGES || env.LANG || "";
7
+ return raw.toLowerCase().startsWith("es") ? "es" : "en";
8
+ }
9
+
10
+ const STORE_LABEL = {
11
+ es: { keychain: "Llavero de macOS", "secret-service": "Secret Service", dpapi: "DPAPI de Windows", file: "fichero protegido (~/.specbox, 0600)" },
12
+ en: { keychain: "macOS Keychain", "secret-service": "Secret Service", dpapi: "Windows DPAPI", file: "protected file (~/.specbox, 0600)" },
13
+ };
14
+
15
+ function day(iso, lang) {
16
+ if (!iso) return "—";
17
+ return new Date(iso).toLocaleDateString(lang === "es" ? "es-ES" : "en-GB", { day: "numeric", month: "long", year: "numeric" });
18
+ }
19
+
20
+ export function messages(lang) {
21
+ const es = lang === "es";
22
+ return {
23
+ usage: es
24
+ ? [
25
+ "Uso: specbox <orden> [opciones]",
26
+ "",
27
+ "Órdenes:",
28
+ " login conecta este ordenador a SpecBox con tu cuenta de GitHub",
29
+ " status enseña con qué cuenta y dispositivo estás conectado",
30
+ " logout desconecta este ordenador",
31
+ "",
32
+ "Opciones:",
33
+ " --server <url> servidor MCP (por defecto el de SpecBox)",
34
+ " --cloud <url> API del panel",
35
+ " --no-browser no abrir el navegador",
36
+ " --version, --help",
37
+ ].join("\n")
38
+ : [
39
+ "Usage: specbox <command> [options]",
40
+ "",
41
+ "Commands:",
42
+ " login connect this computer to SpecBox with your GitHub account",
43
+ " status show which account and device you are connected with",
44
+ " logout disconnect this computer",
45
+ "",
46
+ "Options:",
47
+ " --server <url> MCP server (SpecBox's by default)",
48
+ " --cloud <url> panel API",
49
+ " --no-browser do not open the browser",
50
+ " --version, --help",
51
+ ].join("\n"),
52
+ loginTitle: es ? "SpecBox · conectar este ordenador" : "SpecBox · connect this computer",
53
+ openUrl: (url) => (es ? ` 1. Abre esta dirección (se abre sola si puede):\n ${url}` : ` 1. Open this address (it opens by itself if it can):\n ${url}`),
54
+ confirmCode: (code) =>
55
+ es
56
+ ? ` 2. Comprueba que el código es este y confírmalo con tu cuenta de GitHub:\n ${code}`
57
+ : ` 2. Check that the code is this one and confirm it with your GitHub account:\n ${code}`,
58
+ waiting: (minutes) => (es ? `\nEsperando la confirmación… (el código caduca en ${minutes} min)` : `\nWaiting for the confirmation… (the code expires in ${minutes} min)`),
59
+ connected: (cred, storeKind) => {
60
+ const who = cred.developer?.handle ? `@${cred.developer.handle}` : cred.developer?.display_name ?? "?";
61
+ return es
62
+ ? `\n✓ Conectado como ${who} en «${cred.device_name}».\n El token está en el ${STORE_LABEL.es[storeKind] ?? storeKind}; caduca el ${day(cred.expires_at, "es")} y se renueva solo.`
63
+ : `\n✓ Connected as ${who} on “${cred.device_name}”.\n The token is in the ${STORE_LABEL.en[storeKind] ?? storeKind}; it expires on ${day(cred.expires_at, "en")} and renews itself.`;
64
+ },
65
+ fileStoreWarning: es
66
+ ? " ⚠ No hay almacén seguro del sistema disponible: el token queda en ~/.specbox/credentials.json con permisos 0600."
67
+ : " ⚠ No system secure store is available: the token is kept in ~/.specbox/credentials.json with 0600 permissions.",
68
+ claudeConfigured: (updated) =>
69
+ es
70
+ ? `✓ Claude Code configurado: ${updated.map((u) => (u.cwd ? `${u.name} (${u.cwd})` : u.name)).join(", ")} envía el token en cada conexión.\n Reinicia Claude Code (o /mcp → reconectar) para usarlo ya.`
71
+ : `✓ Claude Code configured: ${updated.map((u) => (u.cwd ? `${u.name} (${u.cwd})` : u.name)).join(", ")} sends the token on every connection.\n Restart Claude Code (or /mcp → reconnect) to use it now.`,
72
+ claudeFailed: (failed) =>
73
+ es
74
+ ? ` ⚠ No se pudo actualizar: ${failed.map((f) => `${f.name} (${f.cwd ?? "usuario"})`).join(", ")}.`
75
+ : ` ⚠ Could not update: ${failed.map((f) => `${f.name} (${f.cwd ?? "user"})`).join(", ")}.`,
76
+ claudeManual: (cmd) =>
77
+ es
78
+ ? ` ⚠ No encuentro la orden \`claude\`. Configura Claude Code con:\n ${cmd}`
79
+ : ` ⚠ The \`claude\` command was not found. Configure Claude Code with:\n ${cmd}`,
80
+ howItWorks: (url) => (es ? `\nCómo funciona: ${url}` : `\nHow it works: ${url}`),
81
+ loginError: (code) =>
82
+ ({
83
+ denied: es ? "✗ Cancelado en el navegador: el ordenador no se ha conectado." : "✗ Cancelled in the browser: this computer was not connected.",
84
+ expired: es ? "✗ El código caducó antes de confirmarse. Ejecuta specbox login otra vez." : "✗ The code expired before it was confirmed. Run specbox login again.",
85
+ rate_limited: es ? "✗ Demasiados intentos seguidos. Espera unos minutos." : "✗ Too many attempts in a row. Wait a few minutes.",
86
+ network: es ? "✗ No hay conexión con el panel de SpecBox." : "✗ Cannot reach the SpecBox panel.",
87
+ })[code] ?? (es ? `✗ No se pudo conectar (${code}).` : `✗ Could not connect (${code}).`),
88
+ notConnected: es ? "Este ordenador no está conectado. Ejecuta: specbox login" : "This computer is not connected. Run: specbox login",
89
+ statusTitle: es ? "SpecBox · este ordenador" : "SpecBox · this computer",
90
+ statusLines: (cred, storeKind, server, valid, helper) => {
91
+ const who = cred.developer?.handle ? `@${cred.developer.handle} (${cred.developer.display_name})` : cred.developer?.display_name ?? "?";
92
+ const state =
93
+ valid === null
94
+ ? es ? "sin comprobar (no hay conexión con el panel)" : "not checked (cannot reach the panel)"
95
+ : valid
96
+ ? es ? "válido ✓" : "valid ✓"
97
+ : es ? "caducado o revocado ✗ → ejecuta specbox login" : "expired or revoked ✗ → run specbox login";
98
+ return (es
99
+ ? [
100
+ ` Cuenta: ${who}`,
101
+ ` Dispositivo: ${cred.device_name}`,
102
+ ` Caduca: ${day(cred.expires_at, "es")} (se renueva sola desde el ${day(cred.renew_after, "es")})`,
103
+ ` Almacén: ${STORE_LABEL.es[storeKind] ?? storeKind}`,
104
+ ` Servidor: ${server}`,
105
+ ` Token: ${state}`,
106
+ ` Claude Code: ${helper ? "usa el ayudante ✓" : "sin configurar ✗ → ejecuta specbox login"}`,
107
+ ]
108
+ : [
109
+ ` Account: ${who}`,
110
+ ` Device: ${cred.device_name}`,
111
+ ` Expires: ${day(cred.expires_at, "en")} (renews itself from ${day(cred.renew_after, "en")})`,
112
+ ` Store: ${STORE_LABEL.en[storeKind] ?? storeKind}`,
113
+ ` Server: ${server}`,
114
+ ` Token: ${state}`,
115
+ ` Claude Code: ${helper ? "uses the helper ✓" : "not configured ✗ → run specbox login"}`,
116
+ ]
117
+ ).join("\n");
118
+ },
119
+ loggedOut: (name, remote) =>
120
+ es
121
+ ? `✓ Dispositivo desconectado: «${name}».${remote ? "" : "\n ⚠ No se pudo avisar al panel: desconéctalo también en Perfil → Dispositivos conectados."}\n Claude Code seguirá configurado y se conectará sin cuenta hasta que vuelvas a ejecutar specbox login.`
122
+ : `✓ Device disconnected: “${name}”.${remote ? "" : "\n ⚠ The panel could not be told: disconnect it also in Profile → Connected devices."}\n Claude Code stays configured and connects without an account until you run specbox login again.`,
123
+ };
124
+ }
package/lib/renew.mjs ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Renovación automática del token (UC-3904 AC-03).
3
+ *
4
+ * A partir de 14 días antes de caducar, el cliente pide un token nuevo con el
5
+ * vigente (POST /api/devices/renew), guarda el nuevo y el viejo deja de valer.
6
+ * La persona no hace nada. Varias sesiones de Claude Code pueden intentarlo a
7
+ * la vez: un cerrojo en ~/.specbox las pone en fila, y quien llega tarde usa
8
+ * la credencial que ya renovó otra (también si el servidor responde 409).
9
+ * Si la red falla, se sigue con el token vigente: todavía le quedan días.
10
+ */
11
+ import { closeSync, mkdirSync, openSync, statSync, unlinkSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { RENEW_WINDOW_DAYS } from "./config.mjs";
14
+
15
+ const DAY = 86_400_000;
16
+ const STALE_LOCK_MS = 60_000;
17
+
18
+ export function renewDue(credential, now = Date.now()) {
19
+ if (!credential?.expires_at) return false;
20
+ const from = credential.renew_after
21
+ ? Date.parse(credential.renew_after)
22
+ : Date.parse(credential.expires_at) - RENEW_WINDOW_DAYS * DAY;
23
+ return now >= from;
24
+ }
25
+
26
+ /** La credencial que guarda el cliente, a partir de la respuesta del servidor. */
27
+ export function credentialFromResponse(body, cloudApi) {
28
+ return {
29
+ token: body.access_token,
30
+ token_id: body.token_id,
31
+ expires_at: body.expires_at,
32
+ renew_after: body.renew_after ?? null,
33
+ device_id: body.device?.device_id ?? null,
34
+ device_name: body.device?.device_name ?? null,
35
+ client: body.device?.client ?? null,
36
+ developer: body.developer ?? null,
37
+ cloud_api: cloudApi,
38
+ updated_at: new Date().toISOString(),
39
+ };
40
+ }
41
+
42
+ export function acquireLock(dir, now = Date.now) {
43
+ const file = join(dir, "renew.lock");
44
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
45
+ for (let attempt = 0; attempt < 2; attempt += 1) {
46
+ try {
47
+ closeSync(openSync(file, "wx"));
48
+ return () => {
49
+ try {
50
+ unlinkSync(file);
51
+ } catch {
52
+ // ya no estaba
53
+ }
54
+ };
55
+ } catch (err) {
56
+ if (err?.code !== "EEXIST") return () => {};
57
+ try {
58
+ if (now() - statSync(file).mtimeMs > STALE_LOCK_MS) {
59
+ unlinkSync(file); // cerrojo abandonado por un proceso que murió
60
+ continue;
61
+ }
62
+ } catch {
63
+ continue;
64
+ }
65
+ return null;
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ async function fetchWithTimeout(fetchImpl, url, init, timeoutMs) {
72
+ const controller = new AbortController();
73
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
74
+ try {
75
+ return await fetchImpl(url, { ...init, signal: controller.signal });
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Renueva `credential` si nadie lo está haciendo ya. Nunca lanza: devuelve la
83
+ * credencial con la que seguir (la nueva, la que renovó otro proceso o la
84
+ * misma si no se pudo).
85
+ */
86
+ export async function renewCredential({
87
+ store,
88
+ account,
89
+ credential,
90
+ lockDir,
91
+ fetchImpl = fetch,
92
+ timeoutMs = 5_000,
93
+ body = {},
94
+ }) {
95
+ const release = acquireLock(lockDir);
96
+ if (!release) return safeRead(store, account) ?? credential;
97
+ try {
98
+ const stored = safeRead(store, account);
99
+ if (stored?.token && stored.token !== credential.token) return stored; // otro proceso ya renovó
100
+ const res = await fetchWithTimeout(
101
+ fetchImpl,
102
+ `${credential.cloud_api}/devices/renew`,
103
+ {
104
+ method: "POST",
105
+ headers: { authorization: `Bearer ${credential.token}`, "content-type": "application/json" },
106
+ body: JSON.stringify(body),
107
+ },
108
+ timeoutMs,
109
+ );
110
+ if (res.status === 200) {
111
+ const next = credentialFromResponse(await res.json(), credential.cloud_api);
112
+ store.write(account, next);
113
+ return next;
114
+ }
115
+ if (res.status === 409) return safeRead(store, account) ?? credential;
116
+ return credential;
117
+ } catch {
118
+ return credential;
119
+ } finally {
120
+ release();
121
+ }
122
+ }
123
+
124
+ function safeRead(store, account) {
125
+ try {
126
+ return store.read(account);
127
+ } catch {
128
+ return null;
129
+ }
130
+ }