@neonexai/beebole-mcp 1.0.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 ADDED
@@ -0,0 +1,157 @@
1
+ # @neonexai/beebole-mcp
2
+
3
+ MCP server que conecta **Claude** (Claude Code / Claude Desktop) con **Beebole**
4
+ (control de horas) a través de su **API GraphQL nueva** — la de la app rediseñada
5
+ **`app.beebole.com`**.
6
+
7
+ - **Endpoint:** `POST https://app.beebole.com/graphql`
8
+ - **Auth:** cabecera `apikey: <API_KEY>` (la key se obtiene en `app.beebole.com → Settings → API`).
9
+ - **Verificado e2e** (2026-06-25) contra la API real: 18/18 checks de lectura y
10
+ escritura (crear proyecto/tarea, fichar horas, editar, borrar, limpiar).
11
+
12
+ Dos transportes:
13
+
14
+ - **Local (stdio)** — *recomendado (GDPR)*: corre en el PC del cliente; su API key
15
+ vive solo en su máquina y los datos no pasan por ningún servidor intermedio.
16
+ - **Remoto (HTTP, VPS)** — centralizado; el token viaja por cabecera
17
+ `X-Beebole-Key` en cada petición y **no se almacena** (stateless multi-tenant).
18
+
19
+ ---
20
+
21
+ ## Arquitectura: híbrida (curadas + passthrough)
22
+
23
+ La API GraphQL nueva es enorme y *fine-grained*: **87 queries + 745 mutations**
24
+ (cada campo editable tiene su propia mutation). Exponer eso 1:1 saturaría a
25
+ cualquier agente. Por eso el server combina dos capas:
26
+
27
+ **A) ~24 tools curadas** para el flujo real de un estudio:
28
+
29
+ | Área | Tools |
30
+ |---|---|
31
+ | Identidad | `beebole_whoami` |
32
+ | Proyectos | `beebole_list_projects`, `beebole_get_project`, `beebole_add_project` |
33
+ | Tareas | `beebole_list_tasks`, `beebole_get_task`, `beebole_add_task` |
34
+ | Personas | `beebole_list_persons`, `beebole_get_person`, `beebole_add_person` |
35
+ | Fichaje de horas | `beebole_list_time_records`, `beebole_count_time_records`, `beebole_add_time_record`, `beebole_edit_time_record`, `beebole_delete_time_records`, `beebole_clone_time_records` |
36
+ | Timesheets | `beebole_submit_timesheet`, `beebole_approve_timesheet`, `beebole_reject_timesheet` |
37
+ | Catálogos | `beebole_list_tags`, `beebole_list_absence_types` |
38
+ | Informes | `beebole_list_reports`, `beebole_run_report`, `beebole_planned_vs_real` |
39
+
40
+ **B) 3 tools genéricas** para cobertura del **100%** de la API (las ~800
41
+ operaciones restantes de administración/configuración):
42
+
43
+ - `beebole_search_schema` — descubre cualquier operación por palabra clave.
44
+ - `beebole_describe_operation` — su firma completa (args + input objects + retorno).
45
+ - `beebole_graphql` — ejecuta cualquier query/mutation cruda.
46
+
47
+ Flujo para algo sin tool curada: **search → describe → graphql**.
48
+
49
+ ### Notas de dominio (del propio schema)
50
+
51
+ - **Timestamps**: `BeeboleTimestamp` = Unix epoch en **milisegundos** (`> 1e10`).
52
+ - **Duración** de un time record: entero en **minutos** (ej. `90` = 1 h 30 min).
53
+ Beebole lo interpreta según los *time settings* de la organización; confírmalo
54
+ visualmente la primera vez.
55
+ - **Estados** (`status`): `d`=draft, `s`=submitted, `a`=approved, `r`=rejected.
56
+ - **Color**: índice de paleta `0-71`. **Ausencias**: unidad `day` o `hour`.
57
+ - `addProject` / `addTask` requieren **`categoryId` o `parentId`** (la API
58
+ rechaza con `NoCategoryOrParentProvided` si no se da ninguno).
59
+
60
+ ---
61
+
62
+ ## Instalación
63
+
64
+ Requisitos: **Node ≥ 18** y una **API key de Beebole** (`app.beebole.com → Settings → API`).
65
+
66
+ ### A) Local en el PC del cliente (recomendado · stdio)
67
+
68
+ Se ejecuta directamente desde GitHub con `npx`, sin clonar nada:
69
+
70
+ ```bash
71
+ claude mcp add beebole \
72
+ --env BEEBOLE_API_KEY=TU_API_KEY \
73
+ -- npx -y github:NeoNexAI/beebole-mcp
74
+ ```
75
+
76
+ - `-s user` (scope usuario) lo deja disponible en **todos los proyectos** de ese PC:
77
+ ```bash
78
+ claude mcp add beebole -s user --env BEEBOLE_API_KEY=TU_API_KEY -- npx -y github:NeoNexAI/beebole-mcp
79
+ ```
80
+ - En **Claude Desktop**, añade el bloque equivalente en su `mcp.json`:
81
+ ```json
82
+ {
83
+ "mcpServers": {
84
+ "beebole": {
85
+ "command": "npx",
86
+ "args": ["-y", "github:NeoNexAI/beebole-mcp"],
87
+ "env": { "BEEBOLE_API_KEY": "TU_API_KEY" }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ Verifica la key **antes** de añadirlo (debe responder con tu nombre):
94
+
95
+ ```bash
96
+ curl -s -H "apikey: TU_API_KEY" -H "Content-Type: application/json" \
97
+ -X POST https://app.beebole.com/graphql \
98
+ -d '{"query":"{ currentPerson{ id name email } }"}'
99
+ ```
100
+
101
+ ### B) Despliegue para un equipo (plan Team)
102
+
103
+ No hay un “instalar para toda la organización” de un click para un MCP propio: cada
104
+ equipo lo corre **en local** (stdio) con la API key correspondiente. Para
105
+ estandarizarlo en varios PCs:
106
+
107
+ - **Misma cuenta Beebole de empresa** → repartid la **misma API key** (cada PC la
108
+ pone en su `BEEBOLE_API_KEY`; nunca en el repo).
109
+ - **Cada persona con su propio usuario Beebole** → cada PC usa **su** API key (el
110
+ server actúa siempre como esa persona).
111
+ - Para fijar la config en un repo compartido, commitea un `.mcp.json` (scope
112
+ *project*) **sin la key** y que cada entorno aporte `BEEBOLE_API_KEY` por
113
+ variable de entorno. La key es un secreto: **nunca** se commitea.
114
+
115
+ > Alternativa centralizada: desplegar el modo **HTTP** en el VPS (un solo sitio) y
116
+ > que cada cliente Claude apunte ahí enviando su token por `X-Beebole-Key`. Útil si
117
+ > no quieres instalar Node en cada PC; menos recomendable para datos GDPR sensibles.
118
+
119
+ ---
120
+
121
+ ## Desarrollo
122
+
123
+ ```bash
124
+ npm install
125
+ npm run build # tsc → dist/ (incluye schema.json para selección/búsqueda)
126
+ npm run typecheck
127
+ BEEBOLE_API_KEY=... npm run smoke # 9 checks de lectura e2e
128
+ BEEBOLE_API_KEY=... SMOKE_WRITE=1 npm run smoke # + ciclo de escritura (SOLO cuenta de pruebas)
129
+ ```
130
+
131
+ El `smoke` con `SMOKE_WRITE=1` crea entidades `ZZ_SMOKE_TEST_*`, ficha, edita y
132
+ **borra todo** al terminar; úsalo solo en una cuenta de pruebas.
133
+
134
+ ### Modo HTTP (VPS)
135
+
136
+ ```bash
137
+ MCP_TRANSPORT=http PORT=8087 node dist/index.js
138
+ # POST /mcp con cabecera X-Beebole-Key: <token> · GET /health
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Estructura
144
+
145
+ ```
146
+ src/
147
+ index.ts — entry point (stdio | HTTP)
148
+ client.ts — cliente GraphQL (auth apikey) + helpers de schema (selección/búsqueda/describe)
149
+ tools.ts — registro de las 27 tools (factoría buildServer(apiKey))
150
+ smoke.ts — test e2e contra la API real
151
+ schema.json — introspección de la API (bundleada; potencia selección + search/describe)
152
+ ```
153
+
154
+ ---
155
+
156
+ *Beebole API GraphQL — auth y endpoint verificados empíricamente 2026-06-25. Las
157
+ funciones de Beebole evolucionan; reverificar en `app.beebole.com` ante cambios.*
package/dist/client.js ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Cliente de la API GraphQL de Beebole (app nueva, app.beebole.com).
3
+ *
4
+ * - Endpoint: POST https://app.beebole.com/graphql
5
+ * - Auth: cabecera apikey: <API_KEY> (verificado empíricamente 2026-06-25;
6
+ * la key se encuentra en app.beebole.com › Settings › API)
7
+ * - Respuesta: { data, errors?, permissionsErrors? }
8
+ *
9
+ * La API GraphQL nueva es fine-grained (87 queries + 745 mutations). Este cliente
10
+ * expone:
11
+ * 1) `graphql()` — ejecutor crudo (lo usan las tools curadas y el passthrough).
12
+ * 2) Helpers de SCHEMA bundleado (schema.json en la raíz del paquete):
13
+ * - `selection()` genera selection-sets válidos sin hardcodear 267 tipos.
14
+ * - `searchOps()` descubre operaciones por palabra clave.
15
+ * - `describeOp()` da la firma completa (args + input fields + retorno).
16
+ *
17
+ * Notas de dominio (de las descripciones del schema):
18
+ * - BeeboleTimestamp = Unix epoch en MILISEGUNDOS (> 1e10).
19
+ * - BeeboleApprovalStatus: d=draft, s=submitted, a=approved, r=rejected.
20
+ * - BeeboleColor: índice de paleta 0-71. BeeboleAbsenceUnit: day|hour.
21
+ */
22
+ import { readFileSync } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+ export const BEEBOLE_GRAPHQL_ENDPOINT = process.env.BEEBOLE_GRAPHQL_ENDPOINT?.trim() || 'https://app.beebole.com/graphql';
25
+ export class BeeboleError extends Error {
26
+ }
27
+ const schemaPath = fileURLToPath(new URL('../schema.json', import.meta.url));
28
+ const SCHEMA = JSON.parse(readFileSync(schemaPath, 'utf8')).data.__schema;
29
+ const TYPES = new Map();
30
+ for (const t of SCHEMA.types)
31
+ if (t.name)
32
+ TYPES.set(t.name, t);
33
+ const QUERY_TYPE = TYPES.get(SCHEMA.queryType.name);
34
+ const MUTATION_TYPE = SCHEMA.mutationType ? TYPES.get(SCHEMA.mutationType.name) ?? null : null;
35
+ function unwrap(t) {
36
+ let cur = t ?? null;
37
+ while (cur && cur.ofType)
38
+ cur = cur.ofType;
39
+ return cur;
40
+ }
41
+ /** Render legible de un TypeRef: BeeboleId!, [BeeboleId], etc. */
42
+ export function typeStr(t) {
43
+ if (!t)
44
+ return '?';
45
+ if (t.kind === 'NON_NULL')
46
+ return `${typeStr(t.ofType)}!`;
47
+ if (t.kind === 'LIST')
48
+ return `[${typeStr(t.ofType)}]`;
49
+ return t.name ?? '?';
50
+ }
51
+ function isLeaf(typeName) {
52
+ if (!typeName)
53
+ return true;
54
+ const t = TYPES.get(typeName);
55
+ return !t || t.kind === 'SCALAR' || t.kind === 'ENUM';
56
+ }
57
+ /** Campos "resumen" de un objeto para profundidad de corte (id/name/fecha…). */
58
+ function shallow(typeName) {
59
+ if (!typeName)
60
+ return '';
61
+ const t = TYPES.get(typeName);
62
+ if (!t || !t.fields)
63
+ return '';
64
+ const want = new Set(['id', 'name', 'iso', 'ts', 'email', 'status', 'duration', 'color', 'archived']);
65
+ return t.fields
66
+ .filter((f) => !(f.args && f.args.length) && want.has(f.name) && isLeaf(unwrap(f.type)?.name))
67
+ .map((f) => f.name)
68
+ .join(' ');
69
+ }
70
+ /**
71
+ * Genera un selection-set GraphQL válido para un tipo OBJECT a partir del schema.
72
+ * `depth` = niveles de expansión COMPLETA; más allá, los objetos se resumen a
73
+ * {id name …}. depth=0 → escalares del tipo + objetos anidados resumidos
74
+ * (ideal para listados); depth=1 → además expande un nivel (ideal para "get").
75
+ * Salta campos que requieren argumentos (evita queries inválidas) y cicla-protege.
76
+ */
77
+ export function selection(typeName, depth = 0, _seen = new Set()) {
78
+ const t = TYPES.get(typeName);
79
+ if (!t || !t.fields)
80
+ return '';
81
+ if (_seen.has(typeName))
82
+ return '';
83
+ const seen = new Set(_seen);
84
+ seen.add(typeName);
85
+ const parts = [];
86
+ for (const f of t.fields) {
87
+ if (f.args && f.args.length)
88
+ continue;
89
+ const baseName = unwrap(f.type)?.name;
90
+ if (isLeaf(baseName)) {
91
+ parts.push(f.name);
92
+ continue;
93
+ }
94
+ if (depth <= 0) {
95
+ const sh = shallow(baseName);
96
+ if (sh)
97
+ parts.push(`${f.name}{ ${sh} }`);
98
+ continue;
99
+ }
100
+ const sub = selection(baseName, depth - 1, seen);
101
+ if (sub)
102
+ parts.push(`${f.name}{ ${sub} }`);
103
+ }
104
+ return parts.join(' ');
105
+ }
106
+ function opSignature(f) {
107
+ const args = (f.args ?? []).map((a) => `${a.name}: ${typeStr(a.type)}`).join(', ');
108
+ return `${f.name}(${args}): ${typeStr(f.type)}`;
109
+ }
110
+ function allOps() {
111
+ const out = [];
112
+ for (const f of QUERY_TYPE.fields ?? [])
113
+ out.push({ kind: 'query', name: f.name, description: f.description ?? '', signature: opSignature(f) });
114
+ for (const f of MUTATION_TYPE?.fields ?? [])
115
+ out.push({ kind: 'mutation', name: f.name, description: f.description ?? '', signature: opSignature(f) });
116
+ return out;
117
+ }
118
+ /** Busca operaciones (query+mutation) cuyo nombre o descripción casan TODAS las palabras. */
119
+ export function searchOps(keyword, limit = 40) {
120
+ const terms = keyword.toLowerCase().split(/\s+/).filter(Boolean);
121
+ const hits = allOps().filter((o) => {
122
+ const hay = `${o.name} ${o.description}`.toLowerCase();
123
+ return terms.every((t) => hay.includes(t));
124
+ });
125
+ // Prioriza match en nombre sobre match solo en descripción.
126
+ hits.sort((a, b) => {
127
+ const an = terms.every((t) => a.name.toLowerCase().includes(t)) ? 0 : 1;
128
+ const bn = terms.every((t) => b.name.toLowerCase().includes(t)) ? 0 : 1;
129
+ return an - bn || a.name.localeCompare(b.name);
130
+ });
131
+ return hits.slice(0, limit);
132
+ }
133
+ /** Firma completa de una operación, con expansión de los input objects (1 nivel). */
134
+ export function describeOp(name) {
135
+ const f = (QUERY_TYPE.fields ?? []).find((x) => x.name === name) ??
136
+ (MUTATION_TYPE?.fields ?? []).find((x) => x.name === name);
137
+ if (!f)
138
+ return null;
139
+ const kind = (QUERY_TYPE.fields ?? []).some((x) => x.name === name) ? 'query' : 'mutation';
140
+ const lines = [`${kind} ${opSignature(f)}`];
141
+ if (f.description)
142
+ lines.push(` // ${f.description}`);
143
+ for (const a of f.args ?? []) {
144
+ const baseName = unwrap(a.type)?.name;
145
+ const it = baseName ? TYPES.get(baseName) : null;
146
+ if (it && it.kind === 'INPUT_OBJECT' && it.inputFields?.length) {
147
+ lines.push(` input ${baseName} {`);
148
+ for (const inf of it.inputFields)
149
+ lines.push(` ${inf.name}: ${typeStr(inf.type)}`);
150
+ lines.push(' }');
151
+ }
152
+ if (it && it.kind === 'SCALAR' && it.description) {
153
+ lines.push(` scalar ${baseName}: ${it.description}`);
154
+ }
155
+ }
156
+ const retName = unwrap(f.type)?.name;
157
+ if (retName && !isLeaf(retName))
158
+ lines.push(` returns ${retName} { ${selection(retName, 0)} }`);
159
+ return lines.join('\n');
160
+ }
161
+ // ── Cliente HTTP ────────────────────────────────────────────────────────────
162
+ export class BeeboleClient {
163
+ apiKey;
164
+ endpoint;
165
+ constructor(apiKey, endpoint = BEEBOLE_GRAPHQL_ENDPOINT) {
166
+ this.apiKey = apiKey;
167
+ if (!apiKey || !apiKey.trim())
168
+ throw new BeeboleError('Falta la API key de Beebole.');
169
+ this.endpoint = endpoint;
170
+ }
171
+ /** Ejecuta una operación GraphQL. Devuelve `data`; lanza BeeboleError accionable. */
172
+ async graphql(query, variables = {}) {
173
+ let res;
174
+ try {
175
+ res = await fetch(this.endpoint, {
176
+ method: 'POST',
177
+ headers: {
178
+ 'Content-Type': 'application/json',
179
+ Accept: 'application/json',
180
+ apikey: this.apiKey,
181
+ },
182
+ body: JSON.stringify({ query, variables }),
183
+ signal: AbortSignal.timeout(60_000),
184
+ });
185
+ }
186
+ catch (err) {
187
+ throw new BeeboleError(`No se pudo contactar con Beebole: ${err.message}`);
188
+ }
189
+ const text = await res.text();
190
+ if (res.status === 401 || res.status === 403) {
191
+ throw new BeeboleError('Beebole rechazó la API key (HTTP ' +
192
+ res.status +
193
+ '). Verifica la key y que el acceso API esté habilitado en app.beebole.com › Settings.');
194
+ }
195
+ let body;
196
+ try {
197
+ body = JSON.parse(text);
198
+ }
199
+ catch {
200
+ throw new BeeboleError(`Respuesta no-JSON de Beebole (HTTP ${res.status}): ${text.slice(0, 200)}`);
201
+ }
202
+ if (body.errors?.length) {
203
+ throw new BeeboleError('Beebole GraphQL error: ' + body.errors.map((e) => e.message).join('; ').slice(0, 500));
204
+ }
205
+ const data = body.data;
206
+ const dataEmpty = !data ||
207
+ typeof data !== 'object' ||
208
+ Object.keys(data).length === 0 ||
209
+ Object.values(data).every((v) => v == null);
210
+ if (body.permissionsErrors?.length && dataEmpty) {
211
+ throw new BeeboleError('Beebole denegó permisos para: ' +
212
+ body.permissionsErrors.join(', ') +
213
+ '. La API key necesita un rol con acceso a esos datos.');
214
+ }
215
+ return (data ?? {});
216
+ }
217
+ }
218
+ /**
219
+ * Construye `(decls)` y `(args)` GraphQL solo con los argumentos definidos, más
220
+ * el objeto de variables. Evita "variable never used" declarando únicamente lo
221
+ * que se pasa. Cada spec: { name, type (GraphQL), value }.
222
+ */
223
+ export function gqlArgs(spec) {
224
+ const used = spec.filter((s) => s.value !== undefined && s.value !== null);
225
+ const decls = used.map((s) => `$${s.name}: ${s.type}`).join(', ');
226
+ const args = used.map((s) => `${s.name}: $${s.name}`).join(', ');
227
+ const variables = {};
228
+ for (const s of used)
229
+ variables[s.name] = s.value;
230
+ return { decls: decls ? `(${decls})` : '', args: args ? `(${args})` : '', variables };
231
+ }
package/dist/index.js ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Entry point del MCP de Beebole. Dos modos:
4
+ *
5
+ * 1) STDIO (por defecto, recomendado / local en el PC del cliente):
6
+ * el token vive en la variable de entorno BEEBOLE_API_KEY del propio PC.
7
+ * Uso: npx @neonexai/beebole-mcp (con BEEBOLE_API_KEY exportada)
8
+ *
9
+ * 2) HTTP remoto (VPS, MCP_TRANSPORT=http): stateless, multi-tenant. El token
10
+ * llega en la cabecera X-Beebole-Key en CADA petición y NO se almacena.
11
+ * Uso: MCP_TRANSPORT=http PORT=8087 node dist/index.js
12
+ */
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
15
+ import express from 'express';
16
+ import { buildServer } from './tools.js';
17
+ async function runStdio() {
18
+ const token = process.env.BEEBOLE_API_KEY ?? '';
19
+ if (!token.trim()) {
20
+ process.stderr.write('[beebole-mcp] Falta BEEBOLE_API_KEY en el entorno.\n');
21
+ process.exit(1);
22
+ }
23
+ const server = buildServer(token);
24
+ const transport = new StdioServerTransport();
25
+ await server.connect(transport);
26
+ process.stderr.write('[beebole-mcp] stdio listo.\n');
27
+ }
28
+ function runHttp() {
29
+ const port = Number(process.env.PORT ?? 8087);
30
+ const app = express();
31
+ app.use(express.json({ limit: '1mb' }));
32
+ // Health check (sin auth) para nginx/Coolify.
33
+ app.get('/health', (_req, res) => {
34
+ res.json({ ok: true, service: 'beebole-mcp' });
35
+ });
36
+ // MCP endpoint stateless: un server efímero por petición, ligado al token del header.
37
+ app.post('/mcp', async (req, res) => {
38
+ const token = (req.header('x-beebole-key') ?? '').trim();
39
+ if (!token) {
40
+ res.status(401).json({
41
+ jsonrpc: '2.0',
42
+ error: { code: -32001, message: 'Falta la cabecera X-Beebole-Key.' },
43
+ id: null,
44
+ });
45
+ return;
46
+ }
47
+ const server = buildServer(token);
48
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
49
+ res.on('close', () => {
50
+ transport.close();
51
+ server.close();
52
+ });
53
+ try {
54
+ await server.connect(transport);
55
+ await transport.handleRequest(req, res, req.body);
56
+ }
57
+ catch (err) {
58
+ process.stderr.write(`[beebole-mcp] error HTTP: ${err.message}\n`);
59
+ if (!res.headersSent) {
60
+ res.status(500).json({
61
+ jsonrpc: '2.0',
62
+ error: { code: -32603, message: 'Error interno del servidor MCP.' },
63
+ id: null,
64
+ });
65
+ }
66
+ }
67
+ });
68
+ app.listen(port, () => {
69
+ process.stderr.write(`[beebole-mcp] HTTP stateless escuchando en :${port} (POST /mcp)\n`);
70
+ });
71
+ }
72
+ if ((process.env.MCP_TRANSPORT ?? '').toLowerCase() === 'http') {
73
+ runHttp();
74
+ }
75
+ else {
76
+ runStdio().catch((err) => {
77
+ process.stderr.write(`[beebole-mcp] fallo al iniciar: ${err.message}\n`);
78
+ process.exit(1);
79
+ });
80
+ }
package/dist/smoke.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Smoke test e2e contra la API GraphQL real de Beebole (app.beebole.com/graphql).
3
+ *
4
+ * Uso:
5
+ * BEEBOLE_API_KEY=<key> npm run smoke
6
+ * BEEBOLE_API_KEY=<key> SMOKE_WRITE=1 npm run smoke ← incluye un ciclo de
7
+ * escritura (crear proyecto → fichar → editar → borrar → archivar) que
8
+ * SOLO debe usarse en una cuenta de PRUEBAS (deja todo limpio al terminar).
9
+ *
10
+ * Salida: lista de checks con OK/FALLO y un resumen. Código de salida ≠ 0 si algo
11
+ * falla, para poder usarlo en CI / como gate de "operativo-verificado".
12
+ */
13
+ import { BeeboleClient } from './client.js';
14
+ const key = (process.env.BEEBOLE_API_KEY ?? '').trim();
15
+ if (!key) {
16
+ process.stderr.write('Falta BEEBOLE_API_KEY en el entorno.\n');
17
+ process.exit(2);
18
+ }
19
+ const beebole = new BeeboleClient(key);
20
+ let ok = 0;
21
+ let fail = 0;
22
+ async function check(name, fn) {
23
+ try {
24
+ const detail = await fn();
25
+ ok++;
26
+ process.stdout.write(` ✓ ${name}${detail ? ` — ${detail}` : ''}\n`);
27
+ }
28
+ catch (err) {
29
+ fail++;
30
+ process.stdout.write(` ✗ ${name} — ${err.message}\n`);
31
+ }
32
+ }
33
+ const now = Date.now();
34
+ const days30 = 30 * 24 * 60 * 60 * 1000;
35
+ async function main() {
36
+ process.stdout.write('\n== Beebole GraphQL smoke (lectura) ==\n');
37
+ await check('currentPerson', async () => {
38
+ const d = await beebole.graphql('{ currentPerson{ id name email } }');
39
+ if (!d.currentPerson?.id)
40
+ throw new Error('sin currentPerson');
41
+ return `${d.currentPerson.name} <${d.currentPerson.email}>`;
42
+ });
43
+ await check('currentOrganisation', async () => {
44
+ const d = await beebole.graphql('{ currentOrganisation{ id name } }');
45
+ return d.currentOrganisation?.name ?? '(sin nombre)';
46
+ });
47
+ await check('getProjects', async () => {
48
+ const d = await beebole.graphql('{ getProjects{ id name } }');
49
+ return `${d.getProjects?.length ?? 0} proyectos`;
50
+ });
51
+ await check('getTasks', async () => {
52
+ const d = await beebole.graphql('{ getTasks{ id name } }');
53
+ return `${d.getTasks?.length ?? 0} tareas`;
54
+ });
55
+ await check('getPersons', async () => {
56
+ const d = await beebole.graphql('{ getPersons{ id name } }');
57
+ return `${d.getPersons?.length ?? 0} personas`;
58
+ });
59
+ await check('getTags', async () => {
60
+ const d = await beebole.graphql('{ getTags{ id name } }');
61
+ return `${d.getTags?.length ?? 0} tags`;
62
+ });
63
+ await check('getAbsenceTypes', async () => {
64
+ const d = await beebole.graphql('{ getAbsenceTypes{ id name unit } }');
65
+ return `${d.getAbsenceTypes?.length ?? 0} tipos`;
66
+ });
67
+ await check('getReports', async () => {
68
+ const d = await beebole.graphql('{ getReports{ id name } }');
69
+ return `${d.getReports?.length ?? 0} informes`;
70
+ });
71
+ await check('countTimeRecords (30d)', async () => {
72
+ const d = await beebole.graphql('query($s:BeeboleTimestamp,$e:BeeboleTimestamp){ countTimeRecords(startTime:$s,endTime:$e) }', { s: now - days30, e: now });
73
+ return `${d.countTimeRecords} registros`;
74
+ });
75
+ if (process.env.SMOKE_WRITE === '1') {
76
+ process.stdout.write('\n== Ciclo de ESCRITURA (cuenta de pruebas) ==\n');
77
+ let projectId = '';
78
+ let taskId = '';
79
+ let personId = '';
80
+ let projectCatId = '';
81
+ let taskCatId = '';
82
+ let trId = '';
83
+ const startOfDay = now - (now % 86_400_000); // medianoche UTC de hoy
84
+ await check('whoami → personId', async () => {
85
+ const d = await beebole.graphql('{ currentPerson{ id } }');
86
+ personId = d.currentPerson.id;
87
+ return personId;
88
+ });
89
+ await check('getProjectCategories + getTaskCategories', async () => {
90
+ const d = await beebole.graphql('{ getProjectCategories{ id name } getTaskCategories{ id name } }');
91
+ projectCatId = d.getProjectCategories?.[0]?.id ?? '';
92
+ taskCatId = d.getTaskCategories?.[0]?.id ?? '';
93
+ if (!projectCatId || !taskCatId)
94
+ throw new Error('faltan categorías por defecto');
95
+ return `projCat=${projectCatId} taskCat=${taskCatId}`;
96
+ });
97
+ await check('addProject (con categoría)', async () => {
98
+ const d = await beebole.graphql('mutation($n:BeeboleName!,$c:BeeboleId){ addProject(name:$n,categoryId:$c){ id name } }', { n: 'ZZ_SMOKE_TEST_PROJECT', c: projectCatId });
99
+ projectId = d.addProject.id;
100
+ return projectId;
101
+ });
102
+ await check('addTask (con categoría)', async () => {
103
+ const d = await beebole.graphql('mutation($n:BeeboleName!,$c:BeeboleId){ addTask(name:$n,categoryId:$c){ id name } }', { n: 'ZZ_SMOKE_TEST_TASK', c: taskCatId });
104
+ taskId = d.addTask.id;
105
+ return taskId;
106
+ });
107
+ await check('assignTaskToProject', async () => {
108
+ await beebole.graphql('mutation($t:BeeboleId!,$p:BeeboleId!){ assignTaskToProject(taskId:$t,projectId:$p){ id } }', { t: taskId, p: projectId });
109
+ return 'asignada';
110
+ });
111
+ await check('addTimeRecord (90 min)', async () => {
112
+ const d = await beebole.graphql('mutation($s:BeeboleTimestamp!,$dur:Int!,$p:BeeboleId!,$t:BeeboleId,$pr:[BeeboleId]){ addTimeRecord(startTime:$s,duration:$dur,personId:$p,taskId:$t,projectIds:$pr){ id duration } }', { s: startOfDay, dur: 90, p: personId, t: taskId, pr: projectId ? [projectId] : undefined });
113
+ trId = d.addTimeRecord.id;
114
+ return `id=${trId} duration=${d.addTimeRecord.duration}`;
115
+ });
116
+ await check('editTimeRecordComment', async () => {
117
+ const d = await beebole.graphql('mutation($id:BeeboleId!,$c:String!){ editTimeRecordComment(id:$id,comment:$c){ comment } }', { id: trId, c: 'smoke test' });
118
+ return `comment="${d.editTimeRecordComment.comment}"`;
119
+ });
120
+ await check('deleteTimeRecords', async () => {
121
+ await beebole.graphql('mutation($ids:[BeeboleId!]!){ deleteTimeRecords(ids:$ids){ id } }', { ids: [trId] });
122
+ return 'borrado';
123
+ });
124
+ await check('cleanup: deleteTask + deleteProject', async () => {
125
+ if (taskId)
126
+ await beebole.graphql('mutation($id:BeeboleId!){ deleteTask(id:$id){ id } }', { id: taskId });
127
+ if (projectId)
128
+ await beebole.graphql('mutation($id:BeeboleId!){ deleteProject(id:$id){ id } }', { id: projectId });
129
+ return 'borrados';
130
+ });
131
+ }
132
+ process.stdout.write(`\n== Resumen: OK=${ok} FALLO=${fail} ==\n`);
133
+ process.exit(fail === 0 ? 0 : 1);
134
+ }
135
+ main().catch((err) => {
136
+ process.stderr.write(`Smoke abortado: ${err.message}\n`);
137
+ process.exit(1);
138
+ });