zinkee 0.1.2 → 0.1.7
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/.claude/settings.local.json +14 -0
- package/AGENTS.md +1 -0
- package/README.md +16 -0
- package/dist/index.js +3421 -455
- package/dist/index.js.map +1 -1
- package/docs/cli-contract.md +41 -0
- package/docs/npm-release.md +95 -0
- package/docs/superpowers/plans/2026-03-24-zinkee-cli-implementation.md +837 -0
- package/docs/superpowers/plans/2026-03-25-cli-backend-error-contract.md +503 -0
- package/docs/superpowers/plans/2026-03-26-display-freeform-create.md +389 -0
- package/docs/superpowers/specs/2026-03-24-zinkee-cli-backend-blockers.md +172 -0
- package/docs/superpowers/specs/2026-03-24-zinkee-cli-design.md +1576 -0
- package/docs/superpowers/specs/2026-03-24-zinkee-cli-e2e-checklist.md +215 -0
- package/docs/superpowers/specs/2026-03-24-zinkee-cli-e2e-design.md +492 -0
- package/docs/superpowers/specs/2026-03-24-zinkee-cli-e2e-status.md +307 -0
- package/package.json +1 -1
- package/src/api/logs.ts +34 -0
- package/src/cli-examples.ts +7 -0
- package/src/command-registry.ts +7 -0
- package/src/commands/logs.test.ts +123 -0
- package/src/commands/logs.ts +159 -0
- package/src/config.test.ts +45 -0
- package/src/config.ts +1 -0
- package/src/index.test.ts +1 -1
- package/src/parsers/expressions.test.ts +75 -30
- package/src/parsers/expressions.ts +21 -4
- package/src/program.ts +2 -0
- package/src/runtime-context.ts +2 -1
- package/src/utils/examples.test.ts +150 -0
- package/src/utils/examples.ts +3121 -138
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# Zinkee CLI E2E Status
|
|
2
|
+
|
|
3
|
+
Estado de pruebas E2E reales ejecutadas contra el perfil `local` de:
|
|
4
|
+
|
|
5
|
+
- `/Users/david/.config/zinkee/config.toml`
|
|
6
|
+
|
|
7
|
+
Las pruebas se han lanzado contra el binario real:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
node dist/index.js ...
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Preflight y Contrato Global
|
|
14
|
+
|
|
15
|
+
- [x] `node dist/index.js --json config validate`
|
|
16
|
+
- [x] `node dist/index.js --json --profile local profiles get local`
|
|
17
|
+
- [x] `node dist/index.js --json --profile local schemas list`
|
|
18
|
+
- [x] `--read-only` bloquea escrituras con `exitCode = 7`
|
|
19
|
+
- [x] `--example` validado en E2E real
|
|
20
|
+
- [x] errores JSON validados en más casos representativos
|
|
21
|
+
|
|
22
|
+
Notas transversales:
|
|
23
|
+
|
|
24
|
+
- `--example` está validado en real, pero la cobertura es irregular: por ejemplo `automations create`, `schemas fields create` y `navigation resources move` sí tienen ejemplo, mientras `schemas get`, `records update`, `files upload`, `teamspace folders create`, `automations folders create`, `automations webhook set` y `automations connections create` no.
|
|
25
|
+
- `--json` no se respeta en todos los errores: fallos de runtime sí devuelven `{ error, meta }`, pero errores de parseo/Commander como `schemas get` sin argumento siguen saliendo en texto plano.
|
|
26
|
+
- muchos `400`/`404` siguen colapsando a `code="unknown_error"` aunque el backend sí devuelve JSON estructurado con `reason`, `action`, `requestId` y `timestamp`; la CLI está perdiendo esa información.
|
|
27
|
+
- la ayuda global anuncia `--example` sin explicar bien la ruta de comando esperada, así que desde fuera hay algo de prueba y error.
|
|
28
|
+
|
|
29
|
+
## Files E2E
|
|
30
|
+
|
|
31
|
+
Estado: `PASS`
|
|
32
|
+
|
|
33
|
+
Recursos usados:
|
|
34
|
+
|
|
35
|
+
- `0349c88d-65e0-40a4-b8f5-72025de79308`
|
|
36
|
+
- `5cbaacc7-0b5e-43d5-893f-22eb5826e0f4`
|
|
37
|
+
|
|
38
|
+
- [x] upload
|
|
39
|
+
- [x] storage info
|
|
40
|
+
- [x] download binario
|
|
41
|
+
- [x] `--json --output`
|
|
42
|
+
- [x] delete
|
|
43
|
+
- [x] cleanup remoto
|
|
44
|
+
- [x] cleanup local
|
|
45
|
+
|
|
46
|
+
## Navigation E2E
|
|
47
|
+
|
|
48
|
+
Estado: `PASS`
|
|
49
|
+
|
|
50
|
+
Run id:
|
|
51
|
+
|
|
52
|
+
- `e2e-nav-20260324203721-22108`
|
|
53
|
+
|
|
54
|
+
Recursos usados:
|
|
55
|
+
|
|
56
|
+
- folder id: `506bba9e-2756-40f0-a58d-3588c6fee35e`
|
|
57
|
+
|
|
58
|
+
- [x] create folder
|
|
59
|
+
- [x] get folder por UUID
|
|
60
|
+
- [x] get folder por nombre exacto
|
|
61
|
+
- [x] update folder
|
|
62
|
+
- [x] move resource
|
|
63
|
+
- [x] delete folder
|
|
64
|
+
- [x] cleanup correcto
|
|
65
|
+
|
|
66
|
+
## Teamspace E2E
|
|
67
|
+
|
|
68
|
+
Estado: `PASS_WITH_CONCERNS`
|
|
69
|
+
|
|
70
|
+
Run id:
|
|
71
|
+
|
|
72
|
+
- `e2e-team-20260324203721-31756`
|
|
73
|
+
|
|
74
|
+
Recursos usados:
|
|
75
|
+
|
|
76
|
+
- folder id: `e7ec8dc8-0e5a-4c52-b019-3433557b116a`
|
|
77
|
+
|
|
78
|
+
- [x] create folder
|
|
79
|
+
- [x] list folders
|
|
80
|
+
- [x] list con `--include-resources`
|
|
81
|
+
- [x] get folder por UUID
|
|
82
|
+
- [x] get folder por nombre exacto
|
|
83
|
+
- [x] update folder
|
|
84
|
+
- [x] publish resource
|
|
85
|
+
- [x] unpublish resource
|
|
86
|
+
- [x] delete folder
|
|
87
|
+
- [x] cleanup correcto
|
|
88
|
+
- [x] `folders get` devuelve el recurso completo
|
|
89
|
+
|
|
90
|
+
Nota:
|
|
91
|
+
|
|
92
|
+
- el comando `teamspace folders get` está devolviendo el selector resuelto (`{ kind, value }`) en vez del recurso de carpeta completo
|
|
93
|
+
|
|
94
|
+
## Schemas E2E
|
|
95
|
+
|
|
96
|
+
Estado: `PARTIAL`
|
|
97
|
+
|
|
98
|
+
Run útil observado:
|
|
99
|
+
|
|
100
|
+
- `e2e-schema-20260324193743-256e46`
|
|
101
|
+
|
|
102
|
+
Recursos observados:
|
|
103
|
+
|
|
104
|
+
- schema id: `4d90b9e8-3f1f-411e-9ed9-b2b92bdef3a0`
|
|
105
|
+
- field ids:
|
|
106
|
+
- `d1d8fd9a-5646-4453-b0d8-af9466211463`
|
|
107
|
+
- `13cd6117-3fe0-4408-81f7-71368bfb2475`
|
|
108
|
+
|
|
109
|
+
- [x] create schema
|
|
110
|
+
- [x] get schema por slug
|
|
111
|
+
- [x] get schema por UUID
|
|
112
|
+
- [x] create field sin `config`
|
|
113
|
+
- [x] create field en schema temporal
|
|
114
|
+
- [x] list fields
|
|
115
|
+
- [x] get field
|
|
116
|
+
- [x] update field sin `config`
|
|
117
|
+
- [x] update field en schema temporal
|
|
118
|
+
- [x] update schema
|
|
119
|
+
- [x] delete field
|
|
120
|
+
- [x] delete schema
|
|
121
|
+
- [ ] create field con `config`
|
|
122
|
+
|
|
123
|
+
Fallos observados:
|
|
124
|
+
|
|
125
|
+
- `schemas fields create` con `config` devuelve `400`
|
|
126
|
+
- el backend responde `reason=schema_field_create_request_invalid`, pero la CLI lo aplana a `unknown_error`
|
|
127
|
+
|
|
128
|
+
## Records E2E
|
|
129
|
+
|
|
130
|
+
Estado: `PARTIAL`
|
|
131
|
+
|
|
132
|
+
Schema real usado:
|
|
133
|
+
|
|
134
|
+
- `contacts`
|
|
135
|
+
- id: `2eb8bdcf-6d9c-4fbb-9018-7fa8180f4c5e`
|
|
136
|
+
|
|
137
|
+
Records temporales usados:
|
|
138
|
+
|
|
139
|
+
- `038efec2-73ef-4f7a-a8fe-b30990ff923e`
|
|
140
|
+
- `76d5fdce-5bfa-4a0d-809b-ab6d97237c99`
|
|
141
|
+
|
|
142
|
+
- [x] create record sobre schema real existente
|
|
143
|
+
- [x] delete record sobre schema real existente
|
|
144
|
+
- [x] create record sobre schema temporal recién creado
|
|
145
|
+
- [ ] query real sobre schema temporal
|
|
146
|
+
- [x] update real sobre schema temporal
|
|
147
|
+
- [x] delete real sobre schema temporal
|
|
148
|
+
|
|
149
|
+
Nota:
|
|
150
|
+
|
|
151
|
+
- el rojo anterior de `records create` sobre schema temporal ya no reproduce tras los últimos fixes
|
|
152
|
+
- `records create` y `records query` siguen fallando si se usa el slug del schema temporal aunque la ayuda diga `schema uuid or slug`
|
|
153
|
+
- el backend responde `reason=schema_id_invalid` y pide UUID real en el path `schemaId`; no es un slug que resuelva la CLI en esas rutas
|
|
154
|
+
- `records query` sobre schema temporal funciona por UUID y con `--where`, pero sin filtro devolvió `data: []` aunque el record existía
|
|
155
|
+
|
|
156
|
+
## Comments E2E
|
|
157
|
+
|
|
158
|
+
Estado: `PASS`
|
|
159
|
+
|
|
160
|
+
- [x] list comments sobre record real
|
|
161
|
+
- [x] create comment sobre record real
|
|
162
|
+
- [x] create comment sobre schema `commentable`
|
|
163
|
+
- [x] list comment en schema de prueba
|
|
164
|
+
|
|
165
|
+
## Displays E2E
|
|
166
|
+
|
|
167
|
+
Estado: `PARTIAL`
|
|
168
|
+
|
|
169
|
+
Run id:
|
|
170
|
+
|
|
171
|
+
- `e2e-disp-20260324203805-21463`
|
|
172
|
+
|
|
173
|
+
Recursos usados:
|
|
174
|
+
|
|
175
|
+
- display id: `1cf8e169-22cc-42ce-9f9f-9908fc2f9a00`
|
|
176
|
+
- variable id: `a6f156ab-8394-4315-ba33-d268eec2fffe`
|
|
177
|
+
|
|
178
|
+
- [x] create display
|
|
179
|
+
- [x] get display
|
|
180
|
+
- [x] get layout
|
|
181
|
+
- [x] create tab con layout compatible (`side_tabs_widgets`)
|
|
182
|
+
- [x] create variable
|
|
183
|
+
- [x] create widget con `--type table`
|
|
184
|
+
- [x] delete variable
|
|
185
|
+
- [x] delete created tab
|
|
186
|
+
- [x] delete created widget
|
|
187
|
+
- [x] list widgets
|
|
188
|
+
- [x] get widget
|
|
189
|
+
- [x] list tabs
|
|
190
|
+
- [x] update display
|
|
191
|
+
- [x] update layout
|
|
192
|
+
- [ ] create tab en más layouts válidos
|
|
193
|
+
- [x] update tab
|
|
194
|
+
- [x] delete tab
|
|
195
|
+
- [x] list/get/update variable completo
|
|
196
|
+
- [x] update widget
|
|
197
|
+
- [x] delete widget
|
|
198
|
+
- [x] navigation-targets lifecycle
|
|
199
|
+
- [x] cross-widget-filter lifecycle
|
|
200
|
+
- [x] delete display con cleanup correcto
|
|
201
|
+
|
|
202
|
+
Fallos observados:
|
|
203
|
+
|
|
204
|
+
- `displays tabs create` devuelve `400` fuera del caso validado `side_tabs_widgets`; en real siguió fallando en `widget_row_tabs` y `top_bar_tabs`
|
|
205
|
+
|
|
206
|
+
Nota:
|
|
207
|
+
|
|
208
|
+
- `displays widgets create` ya funciona para el caso `--type table`
|
|
209
|
+
- `displays tabs create` ya funciona para `side_tabs_widgets`
|
|
210
|
+
- los intentos `chart`/`text` siguen sin validarse en real
|
|
211
|
+
- `displays delete` ya no reproduce el `500`; en esta tanda el cleanup real quedó OK
|
|
212
|
+
|
|
213
|
+
## Automations E2E
|
|
214
|
+
|
|
215
|
+
Estado: `PARTIAL`
|
|
216
|
+
|
|
217
|
+
- [x] folders list
|
|
218
|
+
- [x] folders create
|
|
219
|
+
- [x] folders update
|
|
220
|
+
- [x] folders delete
|
|
221
|
+
- [x] folders move
|
|
222
|
+
- [x] automations list
|
|
223
|
+
- [x] automations get
|
|
224
|
+
- [ ] automations create
|
|
225
|
+
- [ ] automations update
|
|
226
|
+
- [x] automations delete
|
|
227
|
+
- [x] activate
|
|
228
|
+
- [x] deactivate
|
|
229
|
+
- [x] trigger get
|
|
230
|
+
- [ ] trigger set
|
|
231
|
+
- [x] actions list
|
|
232
|
+
- [x] actions add
|
|
233
|
+
- [ ] actions update
|
|
234
|
+
- [x] actions delete
|
|
235
|
+
- [x] flow get
|
|
236
|
+
- [x] flow set
|
|
237
|
+
- [x] webhook get
|
|
238
|
+
- [x] webhook set
|
|
239
|
+
- [x] webhook delete
|
|
240
|
+
- [x] plugins list
|
|
241
|
+
- [x] plugins get
|
|
242
|
+
- [x] connections list
|
|
243
|
+
- [x] connections get
|
|
244
|
+
- [ ] connections create
|
|
245
|
+
- [x] connections update
|
|
246
|
+
- [x] connections delete
|
|
247
|
+
|
|
248
|
+
- [x] read-only block on folder writes
|
|
249
|
+
- [x] reversible activate/deactivate on existing test automation
|
|
250
|
+
|
|
251
|
+
Fallos observados:
|
|
252
|
+
|
|
253
|
+
- `automations create` devuelve `400` con `reason=automation_create_payload_invalid`
|
|
254
|
+
- `automations update` devuelve `200` pero no aplica la mutación observada en real
|
|
255
|
+
- `trigger set`, `actions update` y `connections create` siguen devolviendo `4xx` genéricos en las rutas probadas
|
|
256
|
+
|
|
257
|
+
Nota:
|
|
258
|
+
|
|
259
|
+
- varias rutas write solo fueron utilizables de forma fiable con `--raw`: `actions add`, `webhook set` y `connections update`
|
|
260
|
+
- `connections update` ya no parece romper el parser en la forma `--set path=value`, pero la mutación es parcial: `name` sí cambia y `config.headerName` no
|
|
261
|
+
- `automations update` manda `PATCH` con `{"name","description"}`, pero el backend devuelve el recurso con `name` y `description` originales
|
|
262
|
+
|
|
263
|
+
## Flujos Cruzados
|
|
264
|
+
|
|
265
|
+
- [x] schema -> record -> comment
|
|
266
|
+
- [x] schema -> navigation -> teamspace
|
|
267
|
+
- [x] schema -> display
|
|
268
|
+
- [ ] schema -> automation
|
|
269
|
+
|
|
270
|
+
Notas:
|
|
271
|
+
|
|
272
|
+
- `schema -> navigation -> teamspace` quedó validado con schema temporal `e964e712-ec1d-43ca-98a0-55c3631114d4`, folder de navigation `ff639d7d-a715-47e7-b626-240656a5fbad` y folder de teamspace `21807303-d9da-445d-a08e-cebcd9c49535`
|
|
273
|
+
- `schema -> display` quedó validado creando un widget table sobre un schema temporal en display `1ebcc70c-1513-4e5c-b3fe-0e9874506e25`
|
|
274
|
+
- `schema -> automation` sigue bloqueado porque `automations create` devolvió `400` también con `--trigger-type record_created --trigger-schema <uuid>`
|
|
275
|
+
|
|
276
|
+
## Prioridad Recomendada
|
|
277
|
+
|
|
278
|
+
- [x] arreglar `schemas get` por slug
|
|
279
|
+
- [x] arreglar `schemas fields update`
|
|
280
|
+
- [x] arreglar `comments create`
|
|
281
|
+
- [x] arreglar `records create` sobre schema temporal
|
|
282
|
+
- [ ] arreglar `schemas fields create` con `config`
|
|
283
|
+
- [x] probar `comments create` sobre schema realmente `commentable` como flujo dedicado
|
|
284
|
+
- [x] arreglar `displays delete`
|
|
285
|
+
- [ ] ampliar `displays tabs` más allá del caso compatible ya validado
|
|
286
|
+
- [ ] ampliar `displays widgets` más allá del caso compatible ya validado
|
|
287
|
+
- [ ] cerrar E2E de `automations` en `create/update/trigger/actions/connections` sin depender de `--raw`
|
|
288
|
+
|
|
289
|
+
## Estado Global
|
|
290
|
+
|
|
291
|
+
- [x] CLI funcional y estable en local
|
|
292
|
+
- [x] binario real validado
|
|
293
|
+
- [x] varios dominios con E2E verde real
|
|
294
|
+
- [x] fallos actuales ya son de integración real, no de scaffolding
|
|
295
|
+
|
|
296
|
+
## Bloqueos Reales Pendientes
|
|
297
|
+
|
|
298
|
+
- [ ] `schemas fields create` con `config`
|
|
299
|
+
- en real devuelve `400` para `POST /api/v2/schemas/{id}/fields` con `reason=schema_field_create_request_invalid`
|
|
300
|
+
- [ ] `automations create`
|
|
301
|
+
- en real devuelve `400` con `reason=automation_create_payload_invalid` incluso con payload alineado con la ayuda y con el ejemplo expuesto por la CLI
|
|
302
|
+
- [ ] `automations update`
|
|
303
|
+
- en real devuelve `200`, pero el objeto sale sin los cambios observados
|
|
304
|
+
- [ ] `records create/query` con slug
|
|
305
|
+
- en real devuelve `400` con `reason=schema_id_invalid`; esas rutas del backend exigen UUID real en `schemaId`
|
|
306
|
+
- [ ] `automations connections update` para paths anidados
|
|
307
|
+
- en real acepta el patch y actualiza campos planos como `name`, pero ignora claves anidadas como `config.headerName`
|
package/package.json
CHANGED
package/src/api/logs.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ApiClient } from "../client.js";
|
|
2
|
+
import type { NormalizedUuidOrSlug } from "../parsers/selectors.js";
|
|
3
|
+
|
|
4
|
+
export interface ApiLogChange {
|
|
5
|
+
beforeValue?: string | null;
|
|
6
|
+
afterValue?: string | null;
|
|
7
|
+
author?: string;
|
|
8
|
+
operation?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ApiLog {
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
recordId?: string;
|
|
14
|
+
timestamp?: string;
|
|
15
|
+
changeField?: string;
|
|
16
|
+
change?: ApiLogChange;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function selectorValue(selector: NormalizedUuidOrSlug): string {
|
|
20
|
+
return selector.value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class LogsApi {
|
|
24
|
+
constructor(private readonly client: ApiClient) {}
|
|
25
|
+
|
|
26
|
+
listLogs(
|
|
27
|
+
schema: NormalizedUuidOrSlug,
|
|
28
|
+
recordId: string,
|
|
29
|
+
): Promise<ApiLog[]> {
|
|
30
|
+
return this.client.getJson<ApiLog[]>(
|
|
31
|
+
`/api/v2/schemas/${selectorValue(schema)}/records/${recordId}/logs`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/cli-examples.ts
CHANGED
|
@@ -171,5 +171,12 @@ export async function showExamples(
|
|
|
171
171
|
writer.write(` ${line}\n`);
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
+
|
|
175
|
+
if (example.response) {
|
|
176
|
+
writer.write(" Example response:\n");
|
|
177
|
+
for (const line of example.response.split("\n")) {
|
|
178
|
+
writer.write(` ${line}\n`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
174
181
|
}
|
|
175
182
|
}
|
package/src/command-registry.ts
CHANGED
|
@@ -679,6 +679,13 @@ export const commandRegistry = {
|
|
|
679
679
|
supportsRaw: true,
|
|
680
680
|
output: writeOutput,
|
|
681
681
|
},
|
|
682
|
+
"logs list": {
|
|
683
|
+
command: "logs list",
|
|
684
|
+
accessLevel: "read",
|
|
685
|
+
supportsExample: true,
|
|
686
|
+
supportsRaw: false,
|
|
687
|
+
output: readOutput,
|
|
688
|
+
},
|
|
682
689
|
"teamspace folders list": {
|
|
683
690
|
command: "teamspace folders list",
|
|
684
691
|
accessLevel: "read",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { buildProgram } from "../index.js";
|
|
8
|
+
|
|
9
|
+
function createTempConfig(contents: string): string {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "zinkee-logs-"));
|
|
11
|
+
const filePath = join(dir, "config.toml");
|
|
12
|
+
writeFileSync(filePath, contents, "utf8");
|
|
13
|
+
return filePath;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createApiConfig(): string {
|
|
17
|
+
return createTempConfig(`
|
|
18
|
+
default_profile = "prod"
|
|
19
|
+
|
|
20
|
+
[profiles.prod]
|
|
21
|
+
api_key = "logs-token-1234"
|
|
22
|
+
base_url = "https://api.zinkee.test"
|
|
23
|
+
`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createStdoutBuffer() {
|
|
27
|
+
let output = "";
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
stdout: {
|
|
31
|
+
write(chunk: string) {
|
|
32
|
+
output += chunk;
|
|
33
|
+
return true;
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
read() {
|
|
37
|
+
return output;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function jsonResponse(body: unknown, status: number = 200): Response {
|
|
43
|
+
return new Response(JSON.stringify(body), {
|
|
44
|
+
status,
|
|
45
|
+
headers: {
|
|
46
|
+
"content-type": "application/json",
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createFetchMock() {
|
|
52
|
+
const fetchMock = vi.fn<typeof fetch>();
|
|
53
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
54
|
+
return fetchMock;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.unstubAllGlobals();
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("logs commands", () => {
|
|
63
|
+
it("registers the logs command group", () => {
|
|
64
|
+
const program = buildProgram();
|
|
65
|
+
|
|
66
|
+
expect(program.commands.some((command) => command.name() === "logs")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("lists logs for a record and lowercases the record uuid in the path", async () => {
|
|
70
|
+
const configPath = createApiConfig();
|
|
71
|
+
const output = createStdoutBuffer();
|
|
72
|
+
const fetchMock = createFetchMock();
|
|
73
|
+
fetchMock.mockResolvedValueOnce(
|
|
74
|
+
jsonResponse([
|
|
75
|
+
{
|
|
76
|
+
recordId: "550e8400-e29b-41d4-a716-446655440001",
|
|
77
|
+
timestamp: "2026-04-22T10:00:00Z",
|
|
78
|
+
changeField: "",
|
|
79
|
+
change: {
|
|
80
|
+
beforeValue: null,
|
|
81
|
+
afterValue: null,
|
|
82
|
+
author: "developer@zinkee.com",
|
|
83
|
+
operation: "CREATE",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
]),
|
|
87
|
+
);
|
|
88
|
+
const program = buildProgram({ configPath, stdout: output.stdout });
|
|
89
|
+
|
|
90
|
+
await program.parseAsync([
|
|
91
|
+
"node",
|
|
92
|
+
"zinkee",
|
|
93
|
+
"--json",
|
|
94
|
+
"logs",
|
|
95
|
+
"list",
|
|
96
|
+
"contacts",
|
|
97
|
+
"550E8400-E29B-41D4-A716-446655440001",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
101
|
+
"https://api.zinkee.test/api/v2/schemas/contacts/records/550e8400-e29b-41d4-a716-446655440001/logs",
|
|
102
|
+
);
|
|
103
|
+
expect(fetchMock.mock.calls[0]?.[1]?.method ?? "GET").toBe("GET");
|
|
104
|
+
expect(JSON.parse(output.read())).toEqual({
|
|
105
|
+
data: [
|
|
106
|
+
{
|
|
107
|
+
recordId: "550e8400-e29b-41d4-a716-446655440001",
|
|
108
|
+
timestamp: "2026-04-22T10:00:00Z",
|
|
109
|
+
changeField: "",
|
|
110
|
+
change: {
|
|
111
|
+
beforeValue: null,
|
|
112
|
+
afterValue: null,
|
|
113
|
+
author: "developer@zinkee.com",
|
|
114
|
+
operation: "CREATE",
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
meta: {
|
|
119
|
+
command: "logs list",
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Writable } from "node:stream";
|
|
2
|
+
|
|
3
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
4
|
+
|
|
5
|
+
import { LogsApi } from "../api/logs.js";
|
|
6
|
+
import { ApiClient } from "../client.js";
|
|
7
|
+
import { defaultConfigPath, readRuntimeConfig } from "../config.js";
|
|
8
|
+
import {
|
|
9
|
+
normalizeUuidOrSlug,
|
|
10
|
+
type NormalizedUuidOrSlug,
|
|
11
|
+
} from "../parsers/selectors.js";
|
|
12
|
+
import { resolveRuntimeContext } from "../runtime-context.js";
|
|
13
|
+
import type { JsonObject, RuntimeOverrides } from "../types.js";
|
|
14
|
+
import { CliError } from "../utils/errors.js";
|
|
15
|
+
import { buildJsonSuccess, printTable } from "../utils/output.js";
|
|
16
|
+
|
|
17
|
+
interface CommandIo {
|
|
18
|
+
write(chunk: string): unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RegisterLogsCommandsOptions {
|
|
22
|
+
configPath?: string;
|
|
23
|
+
stdout?: CommandIo;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface GlobalOptions extends RuntimeOverrides {}
|
|
27
|
+
|
|
28
|
+
function getCommandIo(stdout: CommandIo | undefined): CommandIo {
|
|
29
|
+
return stdout ?? (process.stdout as Writable);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeLine(stdout: CommandIo, value: string): void {
|
|
33
|
+
stdout.write(`${value}\n`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getGlobalOptions(command: Command): GlobalOptions {
|
|
37
|
+
return command.optsWithGlobals<GlobalOptions>();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function emitJson(
|
|
41
|
+
stdout: CommandIo,
|
|
42
|
+
commandName: string,
|
|
43
|
+
data: unknown,
|
|
44
|
+
..._extra: unknown[]
|
|
45
|
+
): void {
|
|
46
|
+
writeLine(
|
|
47
|
+
stdout,
|
|
48
|
+
JSON.stringify(buildJsonSuccess(data, { command: commandName })),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toConfigurationError(filePath: string, error: unknown): never {
|
|
53
|
+
if (error instanceof CliError) {
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (
|
|
58
|
+
typeof error === "object" &&
|
|
59
|
+
error !== null &&
|
|
60
|
+
"code" in error &&
|
|
61
|
+
error.code === "ENOENT"
|
|
62
|
+
) {
|
|
63
|
+
throw new CliError("configuration_error", `Config file not found at ${filePath}.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (error instanceof Error) {
|
|
67
|
+
throw new CliError("configuration_error", error.message);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createLogsApi(
|
|
74
|
+
command: Command,
|
|
75
|
+
filePath: string,
|
|
76
|
+
): { api: LogsApi; json: boolean } {
|
|
77
|
+
try {
|
|
78
|
+
const config = readRuntimeConfig(filePath);
|
|
79
|
+
const runtime = resolveRuntimeContext(config, getGlobalOptions(command));
|
|
80
|
+
|
|
81
|
+
if (!runtime.baseUrl) {
|
|
82
|
+
throw new CliError(
|
|
83
|
+
"configuration_error",
|
|
84
|
+
`Resolved profile "${runtime.profile}" is missing a base URL.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
api: new LogsApi(
|
|
90
|
+
new ApiClient({
|
|
91
|
+
baseUrl: runtime.baseUrl,
|
|
92
|
+
apiKey: runtime.apiKey,
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
json: runtime.json,
|
|
96
|
+
};
|
|
97
|
+
} catch (error) {
|
|
98
|
+
toConfigurationError(filePath, error);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseUuidOrSlugSelector(input: string): NormalizedUuidOrSlug {
|
|
103
|
+
try {
|
|
104
|
+
return normalizeUuidOrSlug(input);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const message = error instanceof Error ? error.message : "Invalid selector.";
|
|
107
|
+
throw new InvalidArgumentError(message);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isJsonObject(value: unknown): value is JsonObject {
|
|
112
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toTableRows(data: unknown): Record<string, unknown>[] {
|
|
116
|
+
if (Array.isArray(data)) {
|
|
117
|
+
return data.map((entry) => (isJsonObject(entry) ? entry : { value: entry }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isJsonObject(data)) {
|
|
121
|
+
return [data];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return [{ value: data }];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function emitTable(stdout: CommandIo, data: unknown): void {
|
|
128
|
+
printTable(toTableRows(data), undefined, stdout);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function registerLogsCommands(
|
|
132
|
+
program: Command,
|
|
133
|
+
options: RegisterLogsCommandsOptions = {},
|
|
134
|
+
): void {
|
|
135
|
+
const filePath = options.configPath ?? defaultConfigPath;
|
|
136
|
+
const stdout = getCommandIo(options.stdout);
|
|
137
|
+
const logs = program.command("logs").description("Inspect record change logs");
|
|
138
|
+
|
|
139
|
+
logs
|
|
140
|
+
.command("list")
|
|
141
|
+
.description("List change logs for a record")
|
|
142
|
+
.argument("<schema>", "schema uuid or slug", parseUuidOrSlugSelector)
|
|
143
|
+
.argument("<record>", "record uuid or slug", parseUuidOrSlugSelector)
|
|
144
|
+
.action(async function action(
|
|
145
|
+
this: Command,
|
|
146
|
+
schema: NormalizedUuidOrSlug,
|
|
147
|
+
record: NormalizedUuidOrSlug,
|
|
148
|
+
) {
|
|
149
|
+
const { api, json } = createLogsApi(this, filePath);
|
|
150
|
+
const data = await api.listLogs(schema, record.value);
|
|
151
|
+
|
|
152
|
+
if (json) {
|
|
153
|
+
emitJson(stdout, "logs list", data, filePath);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
emitTable(stdout, data);
|
|
158
|
+
});
|
|
159
|
+
}
|