@klevar/portal-cli 0.1.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.
Files changed (46) hide show
  1. package/.portal.env.example +3 -0
  2. package/README.md +100 -0
  3. package/dist/bin/klevar-portal.d.ts +2 -0
  4. package/dist/bin/klevar-portal.js +5 -0
  5. package/dist/bin/klevar-portal.js.map +1 -0
  6. package/dist/commands/_exemptions.d.ts +31 -0
  7. package/dist/commands/_exemptions.js +38 -0
  8. package/dist/commands/_exemptions.js.map +1 -0
  9. package/dist/commands/clients.d.ts +98 -0
  10. package/dist/commands/clients.js +17 -0
  11. package/dist/commands/clients.js.map +1 -0
  12. package/dist/commands/docs.d.ts +16 -0
  13. package/dist/commands/docs.js +5 -0
  14. package/dist/commands/docs.js.map +1 -0
  15. package/dist/commands/index.d.ts +518 -0
  16. package/dist/commands/index.js +22 -0
  17. package/dist/commands/index.js.map +1 -0
  18. package/dist/commands/metrics.d.ts +27 -0
  19. package/dist/commands/metrics.js +7 -0
  20. package/dist/commands/metrics.js.map +1 -0
  21. package/dist/commands/onboarding.d.ts +27 -0
  22. package/dist/commands/onboarding.js +7 -0
  23. package/dist/commands/onboarding.js.map +1 -0
  24. package/dist/commands/portal.d.ts +127 -0
  25. package/dist/commands/portal.js +23 -0
  26. package/dist/commands/portal.js.map +1 -0
  27. package/dist/commands/projects.d.ts +84 -0
  28. package/dist/commands/projects.js +16 -0
  29. package/dist/commands/projects.js.map +1 -0
  30. package/dist/commands/system.d.ts +57 -0
  31. package/dist/commands/system.js +12 -0
  32. package/dist/commands/system.js.map +1 -0
  33. package/dist/commands/tasks.d.ts +35 -0
  34. package/dist/commands/tasks.js +8 -0
  35. package/dist/commands/tasks.js.map +1 -0
  36. package/dist/commands/types.d.ts +12 -0
  37. package/dist/commands/types.js +2 -0
  38. package/dist/commands/types.js.map +1 -0
  39. package/dist/commands/updates.d.ts +42 -0
  40. package/dist/commands/updates.js +9 -0
  41. package/dist/commands/updates.js.map +1 -0
  42. package/dist/lib/legacy-runner.js +820 -0
  43. package/dist/portal.d.ts +1 -0
  44. package/dist/portal.js +25 -0
  45. package/dist/portal.js.map +1 -0
  46. package/package.json +29 -0
@@ -0,0 +1,820 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Klevar Client Portal CLI — Full admin management from the terminal.
5
+ * Uses X-API-Key header for authentication.
6
+ *
7
+ * Usage:
8
+ * node client.js <resource> <action> [id] [--key value ...]
9
+ *
10
+ * Resources: clients, projects, tasks, updates, onboarding, dashboard, token
11
+ *
12
+ * Config:
13
+ * PORTAL_API_KEY — API key (env var or ~/.klevar/portal.env)
14
+ * PORTAL_URL — API base URL (default: http://localhost:3000)
15
+ *
16
+ * To add a new API endpoint:
17
+ * 1. Add an entry to COMMANDS: 'resource.action': { method, path, body? }
18
+ * 2. That's it. The CLI router handles arg parsing, path interpolation, and output.
19
+ */
20
+
21
+ import { readFileSync, existsSync, writeFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+ import { COMMANDS } from '../commands/index.js';
25
+
26
+ // ── Credential Loading ──────────────────────────────────────────
27
+
28
+ function loadEnvFile(filePath) {
29
+ if (!existsSync(filePath)) return {};
30
+ const vars = {};
31
+ for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
32
+ const trimmed = line.trim();
33
+ if (!trimmed || trimmed.startsWith('#')) continue;
34
+ const eq = trimmed.indexOf('=');
35
+ if (eq === -1) continue;
36
+ vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
37
+ }
38
+ return vars;
39
+ }
40
+
41
+ const envFile = loadEnvFile(join(homedir(), '.klevar', 'portal.env'));
42
+ let BASE_URL =
43
+ process.env.PORTAL_API_URL ||
44
+ process.env.PORTAL_URL ||
45
+ envFile.PORTAL_API_URL ||
46
+ envFile.PORTAL_URL ||
47
+ 'http://127.0.0.1:3100';
48
+ let API_KEY = process.env.PORTAL_API_KEY || envFile.PORTAL_API_KEY;
49
+ let PORTAL_TOKEN =
50
+ process.env.PORTAL_TOKEN ||
51
+ process.env.PORTAL_PORTAL_TOKEN ||
52
+ envFile.PORTAL_TOKEN ||
53
+ envFile.PORTAL_PORTAL_TOKEN;
54
+
55
+ function requireApiKey() {
56
+ if (!API_KEY) {
57
+ console.error('Error: PORTAL_API_KEY is required');
58
+ console.error('Set it via env var or create ~/.klevar/portal.env');
59
+ process.exit(2);
60
+ }
61
+ }
62
+
63
+ function requirePortalToken() {
64
+ if (!PORTAL_TOKEN) {
65
+ console.error('Error: PORTAL_TOKEN is required for portal commands');
66
+ console.error('Set it via env var, --portal-token, or ~/.klevar/portal.env');
67
+ process.exit(2);
68
+ }
69
+ }
70
+
71
+ // ── Helpers ─────────────────────────────────────────────────────
72
+
73
+ function timeAgo(iso) {
74
+ const ms = Date.now() - new Date(iso).getTime();
75
+ const s = Math.floor(ms / 1000);
76
+ if (s < 60) return 'just now';
77
+ const m = Math.floor(s / 60);
78
+ if (m < 60) return `${m}m ago`;
79
+ const h = Math.floor(m / 60);
80
+ if (h < 24) return `${h}h ago`;
81
+ const d = Math.floor(h / 24);
82
+ return `${d}d ago`;
83
+ }
84
+
85
+ // ── API Client ──────────────────────────────────────────────────
86
+
87
+ const headers = { 'Content-Type': 'application/json', 'X-API-Key': API_KEY };
88
+
89
+ function withPortalToken(path) {
90
+ requirePortalToken();
91
+ const sep = path.includes('?') ? '&' : '?';
92
+ return `${path}${sep}token=${encodeURIComponent(PORTAL_TOKEN)}`;
93
+ }
94
+
95
+ async function api(method, path, body, auth = 'apiKey') {
96
+ const reqHeaders = {};
97
+ let requestPath = path;
98
+ if (auth === 'portal' || auth === 'portalToken') {
99
+ requestPath = withPortalToken(path);
100
+ } else if (auth === 'apiKey') {
101
+ requireApiKey();
102
+ reqHeaders['X-API-Key'] = API_KEY;
103
+ }
104
+ if (body) reqHeaders['Content-Type'] = 'application/json';
105
+ const res = await fetch(`${BASE_URL}${requestPath}`, {
106
+ method,
107
+ headers: reqHeaders,
108
+ body: body ? JSON.stringify(body) : undefined,
109
+ });
110
+
111
+ if (!res.ok) {
112
+ const err = await res.json().catch(() => ({ error: { message: res.statusText } }));
113
+ console.error(`Error ${res.status}: ${err.error?.message || 'Unknown error'}`);
114
+ process.exit(1);
115
+ }
116
+
117
+ if (res.status === 204) return null;
118
+ return res.json();
119
+ }
120
+
121
+ async function apiDownload(path, outputPath, auth = 'apiKey') {
122
+ const headers = { Accept: 'application/pdf, application/json' };
123
+ let requestPath = path;
124
+ if (auth === 'portal' || auth === 'portalToken') {
125
+ requestPath = withPortalToken(path);
126
+ } else {
127
+ requireApiKey();
128
+ headers['X-API-Key'] = API_KEY;
129
+ }
130
+ const res = await fetch(`${BASE_URL}${requestPath}`, {
131
+ method: 'GET',
132
+ headers,
133
+ });
134
+
135
+ if (!res.ok) {
136
+ const err = await res.json().catch(() => ({ error: { message: res.statusText } }));
137
+ console.error(`Error ${res.status}: ${err.error?.message || 'Unknown error'}`);
138
+ process.exit(1);
139
+ }
140
+
141
+ const contentType = res.headers.get('content-type') || '';
142
+ if (contentType.includes('application/json')) {
143
+ const data = await res.json();
144
+ const url = data.url || data.pdf_url || data.data?.pdf_url || data.data?.url;
145
+ if (!url) {
146
+ console.log(JSON.stringify(data, null, 2));
147
+ return;
148
+ }
149
+ if (!outputPath) {
150
+ console.log(url);
151
+ return;
152
+ }
153
+ await downloadPublicUrl(url, outputPath);
154
+ return;
155
+ }
156
+
157
+ if (!outputPath) {
158
+ console.error('Error: --output is required when the API returns PDF bytes');
159
+ process.exit(1);
160
+ }
161
+
162
+ const bytes = Buffer.from(await res.arrayBuffer());
163
+ writeFileSync(outputPath, bytes);
164
+ console.log(`Saved ${bytes.length} bytes to ${outputPath}`);
165
+ }
166
+
167
+ async function downloadPublicUrl(url, outputPath) {
168
+ const res = await fetch(url);
169
+ if (!res.ok) {
170
+ console.error(`Error ${res.status}: failed to download signed URL`);
171
+ process.exit(1);
172
+ }
173
+ const bytes = Buffer.from(await res.arrayBuffer());
174
+ writeFileSync(outputPath, bytes);
175
+ console.log(`Saved ${bytes.length} bytes to ${outputPath}`);
176
+ }
177
+
178
+ const MIME_MAP = {
179
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
180
+ '.pdf': 'application/pdf', '.csv': 'text/csv',
181
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
182
+ '.xls': 'application/vnd.ms-excel',
183
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
184
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
185
+ };
186
+
187
+ async function apiMultipart(method, path, fields, filePaths) {
188
+ const formData = new FormData();
189
+ for (const [key, val] of Object.entries(fields)) {
190
+ if (val !== undefined) formData.append(key, val);
191
+ }
192
+ for (const fp of filePaths) {
193
+ const { createReadStream } = await import('node:fs');
194
+ const { basename, extname } = await import('node:path');
195
+ const name = basename(fp);
196
+ const ext = extname(fp).toLowerCase();
197
+ const mime = MIME_MAP[ext] || 'application/octet-stream';
198
+ const stream = createReadStream(fp);
199
+ const buf = await streamToBuffer(stream);
200
+ formData.append('attachments', new File([buf], name, { type: mime }), name);
201
+ }
202
+ const res = await fetch(`${BASE_URL}${path}`, {
203
+ method,
204
+ headers: { 'X-API-Key': API_KEY },
205
+ body: formData,
206
+ });
207
+ if (!res.ok) {
208
+ const err = await res.json().catch(() => ({ error: { message: res.statusText } }));
209
+ console.error(`Error ${res.status}: ${err.error?.message || 'Unknown error'}`);
210
+ process.exit(1);
211
+ }
212
+ if (res.status === 204) return null;
213
+ return res.json();
214
+ }
215
+
216
+ async function streamToBuffer(stream) {
217
+ const chunks = [];
218
+ for await (const chunk of stream) chunks.push(chunk);
219
+ return Buffer.concat(chunks);
220
+ }
221
+
222
+ // ── Arg Parsing ─────────────────────────────────────────────────
223
+
224
+ function parseArgs(args) {
225
+ const positional = [];
226
+ const flags = {};
227
+ let i = 0;
228
+ while (i < args.length) {
229
+ if (args[i].startsWith('--')) {
230
+ const key = args[i].slice(2);
231
+ const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : 'true';
232
+ flags[key] = val;
233
+ i += val === 'true' ? 1 : 2;
234
+ } else {
235
+ positional.push(args[i]);
236
+ i++;
237
+ }
238
+ }
239
+ return { positional, flags };
240
+ }
241
+
242
+ function interpolatePath(template, id) {
243
+ return template.replace(':id', id);
244
+ }
245
+
246
+ // ── Output Formatting ───────────────────────────────────────────
247
+
248
+ function formatOutput(data, commandKey) {
249
+ if (!data) { console.log('Done.'); return; }
250
+
251
+ // Special formatting for known responses
252
+ if (commandKey === 'dashboard') {
253
+ const s = data.stats;
254
+ console.log('\nDashboard');
255
+ console.log('═'.repeat(40));
256
+ console.log('\nClients:');
257
+ for (const item of s.clientsByStatus) console.log(` ${item.status}: ${item.count}`);
258
+ console.log('\nProjects:');
259
+ for (const item of s.projectsByStatus) console.log(` ${item.status}: ${item.count}`);
260
+ console.log('\nOpen Tasks:');
261
+ for (const item of s.openTasksByPriority) console.log(` ${item.priority}: ${item.count}`);
262
+ console.log(`\nPending Onboarding: ${s.pendingOnboardingCount}`);
263
+ if (s.recentTasks?.length > 0) {
264
+ console.log('\nRecent Tasks:');
265
+ for (const t of s.recentTasks) {
266
+ const ago = timeAgo(t.createdAt);
267
+ console.log(` • ${t.title} [${t.status}] by ${t.submittedBy} — ${ago}`);
268
+ }
269
+ }
270
+ if (s.recentUpdates?.length > 0) {
271
+ console.log('\nRecent Updates:');
272
+ for (const u of s.recentUpdates) {
273
+ const ago = timeAgo(u.createdAt);
274
+ const snippet = u.content.length > 80 ? u.content.slice(0, 80) + '...' : u.content;
275
+ console.log(` • ${snippet} [${u.visibility}] by ${u.author} — ${ago}`);
276
+ }
277
+ }
278
+ return;
279
+ }
280
+
281
+ if (commandKey === 'clients.list') {
282
+ const items = data.data || [];
283
+ if (items.length === 0) { console.log('No clients found.'); return; }
284
+ console.log('\nClients:');
285
+ console.log('─'.repeat(70));
286
+ for (const c of items) {
287
+ const company = c.company ? ` (${c.company})` : '';
288
+ console.log(` ${c.name}${company} — ${c.status} [${c.platform}] [${c.id}]`);
289
+ }
290
+ console.log(`\nTotal: ${items.length}`);
291
+ return;
292
+ }
293
+
294
+ if (commandKey === 'clients.get') {
295
+ const c = data.client;
296
+ console.log(`\n${c.name}${c.company ? ` — ${c.company}` : ''}`);
297
+ console.log(` ID: ${c.id}`);
298
+ console.log(` Status: ${c.status} | Tier: ${c.tier || '—'} | Platform: ${c.platform}`);
299
+ console.log(` Email: ${c.email}${c.phone ? ` | Phone: ${c.phone}` : ''}`);
300
+ console.log(` Portal: /c/${c.portalSlug}?token=${c.portalToken}`);
301
+ if (data.projects?.length > 0) {
302
+ console.log(`\n Projects (${data.projects.length}):`);
303
+ for (const p of data.projects) console.log(` • ${p.name} [${p.status}] [${p.id}]`);
304
+ }
305
+ return;
306
+ }
307
+
308
+ if (commandKey === 'projects.get') {
309
+ const p = data.project;
310
+ console.log(`\n${p.name} [${p.status}]`);
311
+ console.log(` ID: ${p.id}`);
312
+ if (p.description) console.log(` ${p.description}`);
313
+ if (p.startDate) console.log(` Start: ${p.startDate} | Target: ${p.targetDate || '—'}`);
314
+ if (p.valueEur) console.log(` Value: €${p.valueEur}`);
315
+ if (data.tasks?.length > 0) {
316
+ console.log(`\n Tasks (${data.tasks.length}):`);
317
+ for (const t of data.tasks) {
318
+ const icon = t.status === 'done' ? '✓' : t.status === 'blocked' ? '!' : '○';
319
+ console.log(` ${icon} ${t.title} [${t.status}] (${t.priority}) [${t.id}]`);
320
+ }
321
+ }
322
+ if (data.updates?.length > 0) {
323
+ console.log(`\n Updates (${data.updates.length}):`);
324
+ for (const u of data.updates) {
325
+ const vis = u.visibility === 'internal' ? ' [INTERNAL]' : '';
326
+ console.log(` ${u.author}: ${u.content.slice(0, 80)}${u.content.length > 80 ? '...' : ''}${vis}`);
327
+ }
328
+ }
329
+ return;
330
+ }
331
+
332
+ if (commandKey === 'tasks.all') {
333
+ const items = data.data || [];
334
+ if (items.length === 0) { console.log('No tasks found.'); return; }
335
+ console.log('\nAll Tasks:');
336
+ console.log('─'.repeat(70));
337
+ for (const t of items) {
338
+ const icon = t.status === 'done' ? '✓' : t.status === 'blocked' ? '!' : '○';
339
+ const phase = t.phase && t.phase !== 'current' ? ` [${t.phase}]` : '';
340
+ const prio = t.priority && t.priority !== 'normal' ? ` (${t.priority})` : '';
341
+ console.log(` ${icon} ${t.title} [${t.status}]${prio}${phase} — ${t.projectName} / ${t.clientName} [${t.id}]`);
342
+ }
343
+ console.log(`\nTotal: ${items.length}`);
344
+ return;
345
+ }
346
+
347
+ if (commandKey === 'auth.me') {
348
+ const u = data.user;
349
+ console.log(`\n${u.name} (${u.email})`);
350
+ console.log(` Role: ${u.role}`);
351
+ console.log(` Tenant: ${u.tenantId}`);
352
+ return;
353
+ }
354
+
355
+ if (commandKey === 'metrics.list') {
356
+ const items = data.data || [];
357
+ if (items.length === 0) { console.log('No metrics snapshots yet.'); return; }
358
+ console.log(`\nMetrics History (${items.length} snapshots):`);
359
+ console.log('─'.repeat(60));
360
+ for (const m of items) {
361
+ const keys = Object.keys(m.metricsJson || {}).join(', ');
362
+ console.log(` ${m.snapshotDate} [${m.sourceRef}] — ${keys}`);
363
+ }
364
+ if (data.baseline) console.log(`\nBaseline: ${data.baseline.snapshotDate}`);
365
+ return;
366
+ }
367
+
368
+ if (commandKey === 'metrics.latest') {
369
+ if (!data.metric) { console.log('No metrics data yet.'); return; }
370
+ const m = data.metric;
371
+ console.log(`\nLatest Snapshot: ${m.snapshotDate} [${m.sourceRef}]`);
372
+ console.log('─'.repeat(50));
373
+ if (data.config?.kpis) {
374
+ for (const kpi of data.config.kpis) {
375
+ const val = m.metricsJson[kpi.key];
376
+ const unit = kpi.unit || '';
377
+ const prefix = unit === '€' ? '€' : '';
378
+ const suffix = unit && unit !== '€' ? unit : '';
379
+ const baseVal = data.baseline?.metricsJson?.[kpi.key];
380
+ let delta = '';
381
+ if (baseVal != null && val != null && kpi.format !== 'text' && kpi.show_delta !== false) {
382
+ const diff = Number(val) - Number(baseVal);
383
+ const arrow = diff > 0 ? '▲' : diff < 0 ? '▼' : '=';
384
+ delta = ` (${arrow} ${Math.abs(diff).toLocaleString()} vs baseline)`;
385
+ }
386
+ console.log(` ${kpi.label}: ${prefix}${val}${suffix}${delta}`);
387
+ }
388
+ }
389
+ if (data.items?.length > 0) {
390
+ console.log(`\n Items: ${data.items.length}`);
391
+ }
392
+ return;
393
+ }
394
+
395
+ if (commandKey === 'metrics.items') {
396
+ const items = data.data || [];
397
+ if (items.length === 0) { console.log('No items for this snapshot.'); return; }
398
+ const cols = data.items_config?.columns || [];
399
+ console.log(`\n${data.items_config?.label || 'Items'} (${items.length}):`);
400
+ console.log('─'.repeat(60));
401
+ for (const item of items.slice(0, 25)) {
402
+ const parts = cols.slice(0, 4).map(c => `${c.label}: ${item.dataJson[c.key] ?? '—'}`);
403
+ console.log(` ${parts.join(' | ')}`);
404
+ }
405
+ if (items.length > 25) console.log(` ... and ${items.length - 25} more`);
406
+ return;
407
+ }
408
+
409
+ if (
410
+ commandKey === 'clients.docs-invoices' ||
411
+ commandKey === 'projects.docs-invoices'
412
+ ) {
413
+ formatDocsInvoices(data.data || []);
414
+ return;
415
+ }
416
+
417
+ if (
418
+ commandKey === 'clients.docs-documents' ||
419
+ commandKey === 'projects.docs-documents'
420
+ ) {
421
+ formatDocsDocuments(data.data || []);
422
+ return;
423
+ }
424
+
425
+ if (commandKey === 'clients.docs-sync') {
426
+ const sync = data.data || data;
427
+ console.log('Klevar Docs sync complete.');
428
+ if (sync.created !== undefined || sync.updated !== undefined) {
429
+ console.log(` Created: ${sync.created ?? 0}`);
430
+ console.log(` Updated: ${sync.updated ?? 0}`);
431
+ }
432
+ if (sync.rows?.length) {
433
+ console.log(` Rows: ${sync.rows.length}`);
434
+ }
435
+ return;
436
+ }
437
+
438
+ if (commandKey === 'tasks.list' || commandKey === 'updates.list' || commandKey === 'projects.list' || commandKey === 'projects.all' || commandKey === 'onboarding.list') {
439
+ const items = data.data || [];
440
+ if (items.length === 0) { console.log('No items found.'); return; }
441
+
442
+ if (commandKey === 'tasks.list') {
443
+ console.log('\nTasks:');
444
+ for (const t of items) {
445
+ const icon = t.status === 'done' ? '✓' : t.status === 'blocked' ? '!' : '○';
446
+ console.log(` ${icon} ${t.title} [${t.status}] (${t.priority}) [${t.id}]`);
447
+ }
448
+ } else if (commandKey === 'updates.list') {
449
+ console.log('\nUpdates:');
450
+ for (const u of items) {
451
+ const vis = u.visibility === 'internal' ? ' [INTERNAL]' : '';
452
+ console.log(` ${u.author}: ${u.content.slice(0, 100)}${u.content.length > 100 ? '...' : ''}${vis} [${u.id}]`);
453
+ }
454
+ } else if (commandKey === 'projects.list' || commandKey === 'projects.all') {
455
+ console.log('\nProjects:');
456
+ for (const p of items) {
457
+ const client = p.clientName ? ` (${p.clientName})` : '';
458
+ console.log(` • ${p.name}${client} [${p.status}] [${p.id}]`);
459
+ }
460
+ } else if (commandKey === 'onboarding.list') {
461
+ console.log('\nPending Submissions:');
462
+ for (const s of items) {
463
+ const fd = s.formData || {};
464
+ console.log(` ${fd.name || 'Unknown'} (${fd.email || '—'}) — ${fd.platformSource || 'direct'} [${s.id}]`);
465
+ }
466
+ }
467
+ console.log(`\nTotal: ${items.length}`);
468
+ return;
469
+ }
470
+
471
+ // Metrics push
472
+ if (commandKey === 'metrics.push') {
473
+ console.log(`Metrics pushed: ${data.metric_id} (${data.items_count} items, upserted: ${data.upserted})`);
474
+ return;
475
+ }
476
+
477
+ // Comments
478
+ if (commandKey === 'comments.list') {
479
+ const items = data.data || [];
480
+ if (items.length === 0) { console.log('No comments yet.'); return; }
481
+ console.log(`\nComments (${items.length}):`);
482
+ for (const c of items) {
483
+ const badge = c.authorType === 'admin' ? '[admin]' : '[client]';
484
+ console.log(` ${c.authorName} ${badge} — ${timeAgo(c.createdAt)}`);
485
+ console.log(` ${c.content}`);
486
+ }
487
+ return;
488
+ }
489
+
490
+ if (commandKey === 'comments.create' && data.comment) {
491
+ const c = data.comment;
492
+ console.log(`Comment posted by ${c.authorName} [${c.authorType}] on ${c.visibility} update`);
493
+ return;
494
+ }
495
+
496
+ // Onboarding create/process/reject
497
+ if (commandKey === 'onboarding.create' && data.id) {
498
+ console.log(`Submission created: ${data.id} [${data.status}]`);
499
+ return;
500
+ }
501
+ if (commandKey === 'onboarding.process' && data.client) {
502
+ const c = data.client;
503
+ console.log(`Client created: ${c.name} [${c.status}] [${c.id}]`);
504
+ if (c.portalSlug) console.log(` Portal: /c/${c.portalSlug}?token=${c.portalToken}`);
505
+ return;
506
+ }
507
+ if (commandKey === 'onboarding.reject') {
508
+ console.log('Submission rejected.');
509
+ return;
510
+ }
511
+
512
+ // Create responses — formatted instead of raw JSON
513
+ if (data.client) {
514
+ const c = data.client;
515
+ console.log(`Client created: ${c.name} [${c.status}] [${c.id}]`);
516
+ if (c.portalSlug) console.log(` Portal: /c/${c.portalSlug}?token=${c.portalToken}`);
517
+ return;
518
+ }
519
+ if (data.project) {
520
+ const p = data.project;
521
+ console.log(`Project: ${p.name} [${p.status}] [${p.id}]`);
522
+ if (p.valueEur) console.log(` Value: €${p.valueEur}`);
523
+ return;
524
+ }
525
+ if (data.task) {
526
+ const t = data.task;
527
+ const icon = t.status === 'done' ? '✓' : t.status === 'blocked' ? '!' : '○';
528
+ console.log(`Task: ${icon} ${t.title} [${t.status}] (${t.priority}) [${t.id}]`);
529
+ return;
530
+ }
531
+ if (data.update) {
532
+ const u = data.update;
533
+ const vis = u.visibility === 'internal' ? ' [INTERNAL]' : '';
534
+ console.log(`Update posted${vis}: ${u.content.slice(0, 80)}${u.content.length > 80 ? '...' : ''} [${u.id}]`);
535
+ return;
536
+ }
537
+ if (data.portalToken) {
538
+ console.log(`New token: ${data.portalToken}`);
539
+ return;
540
+ }
541
+ if (data.tenant) {
542
+ const t = data.tenant;
543
+ console.log(`Tenant: ${t.name} (${t.slug}) [${t.id}]`);
544
+ return;
545
+ }
546
+
547
+ // Fallback: print JSON
548
+ console.log(JSON.stringify(data, null, 2));
549
+ }
550
+
551
+ function formatDocsInvoices(items) {
552
+ if (items.length === 0) { console.log('No Docs invoices found.'); return; }
553
+ console.log(`\nDocs Invoices (${items.length}):`);
554
+ console.log('-'.repeat(80));
555
+ for (const invoice of items) {
556
+ const number = invoice.invoiceNumber || invoice.docsInvoiceId;
557
+ const amount = invoice.total ? `${invoice.currency || ''} ${invoice.total}`.trim() : 'n/a';
558
+ const due = invoice.dueDate ? ` due ${invoice.dueDate}` : '';
559
+ const visibility = invoice.visibility === 'internal' ? ' [INTERNAL]' : '';
560
+ console.log(` ${number} [${invoice.status}] ${amount}${due}${visibility} [${invoice.id}]`);
561
+ }
562
+ }
563
+
564
+ function formatDocsDocuments(items) {
565
+ if (items.length === 0) { console.log('No Docs documents found.'); return; }
566
+ console.log(`\nDocs Documents (${items.length}):`);
567
+ console.log('-'.repeat(80));
568
+ for (const doc of items) {
569
+ const number = doc.documentNumber || doc.docsDocumentId;
570
+ const type = doc.documentType || 'document';
571
+ const visibility = doc.visibility === 'internal' ? ' [INTERNAL]' : '';
572
+ console.log(` ${number} (${type}) [${doc.status}]${visibility} [${doc.id}]`);
573
+ }
574
+ }
575
+
576
+ // ── Router ──────────────────────────────────────────────────────
577
+
578
+ let [,, resource, action, ...rest] = process.argv;
579
+
580
+ if (!resource || resource === 'help' || resource === '--help') {
581
+ console.log('Klevar Client Portal CLI\n');
582
+ console.log('Usage: node client.js <resource> <action> [id] [--key value ...]\n');
583
+ console.log('Commands:');
584
+ const grouped = {};
585
+ for (const [key, cmd] of Object.entries(COMMANDS)) {
586
+ const [res, act] = key.includes('.') ? key.split('.') : [key, ''];
587
+ if (!grouped[res]) grouped[res] = [];
588
+ grouped[res].push({ action: act, desc: (cmd.description || cmd.desc), body: cmd.body });
589
+ }
590
+ for (const [res, cmds] of Object.entries(grouped)) {
591
+ console.log(`\n ${res}:`);
592
+ for (const c of cmds) {
593
+ const bodyHint = c.body ? ` [${c.body.map(b => '--' + b).join(' ')}]` : '';
594
+ console.log(` ${c.action || '(default)'} — ${c.desc}${bodyHint}`);
595
+ }
596
+ }
597
+ console.log(`\nConfig: PORTAL_API_KEY, PORTAL_URL (or ~/.klevar/portal.env)`);
598
+ console.log(`Target: ${BASE_URL}`);
599
+ process.exit(0);
600
+ }
601
+
602
+ // Resolve command key — handle single-word commands (e.g. "search <term>")
603
+ let commandKey = action ? `${resource}.${action}` : resource;
604
+ let cmd = COMMANDS[commandKey];
605
+ if (!cmd && action && rest[0]) {
606
+ const nestedKey = `${resource}.${action}.${rest[0]}`;
607
+ if (COMMANDS[nestedKey]) {
608
+ commandKey = nestedKey;
609
+ cmd = COMMANDS[commandKey];
610
+ rest = rest.slice(1);
611
+ }
612
+ }
613
+ if (!cmd && COMMANDS[resource]) {
614
+ // "action" is actually the first positional arg, not a sub-command
615
+ commandKey = resource;
616
+ cmd = COMMANDS[commandKey];
617
+ rest = [action, ...rest];
618
+ }
619
+
620
+ if (!cmd) {
621
+ console.error(`Unknown command: ${commandKey}`);
622
+ console.error(`Run 'node client.js help' for available commands.`);
623
+ process.exit(1);
624
+ }
625
+
626
+ // Parse remaining args
627
+ const { positional, flags } = parseArgs(rest);
628
+ if (flags['api-url']) process.env.PORTAL_API_URL = flags['api-url'];
629
+ if (flags['api-key']) process.env.PORTAL_API_KEY = flags['api-key'];
630
+ if (flags['portal-token']) process.env.PORTAL_TOKEN = flags['portal-token'];
631
+ if (flags['api-url']) BASE_URL = flags['api-url'];
632
+ if (flags['api-key']) API_KEY = flags['api-key'];
633
+ if (flags['portal-token']) PORTAL_TOKEN = flags['portal-token'];
634
+ const id = positional[0];
635
+
636
+ // Interpolate path
637
+ let path = cmd.path;
638
+ const pathParams = [...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)/g)].map((match) => match[1]);
639
+ if (pathParams.length > 0) {
640
+ pathParams.forEach((paramName, index) => {
641
+ const value = positional[index];
642
+ if (!value) {
643
+ const ordinal = index === 0 ? 'an ID argument' : `argument ${index + 1} for :${paramName}`;
644
+ console.error(`Command '${commandKey}' requires ${ordinal}.`);
645
+ process.exit(1);
646
+ }
647
+ path = path.replace(`:${paramName}`, value);
648
+ });
649
+ }
650
+
651
+ // Append query parameters for GET commands with filters
652
+ if (cmd.queryParams || cmd.fixedQuery) {
653
+ const params = new URLSearchParams();
654
+ for (const [key, value] of Object.entries(cmd.fixedQuery || {})) {
655
+ params.set(key, value);
656
+ }
657
+ for (const key of cmd.queryParams || []) {
658
+ if (flags[key]) params.set(key, flags[key]);
659
+ }
660
+ const qs = params.toString();
661
+ if (qs) path += (path.includes('?') ? '&' : '?') + qs;
662
+ }
663
+
664
+ // Build body from flags or fixedBody
665
+ let body = undefined;
666
+ if (cmd.fixedBody) {
667
+ body = cmd.fixedBody;
668
+ } else if (cmd.body && Object.keys(flags).length > 0) {
669
+ body = {};
670
+ for (const key of cmd.body) {
671
+ if (flags[key] !== undefined) {
672
+ let val = flags[key];
673
+ // Auto-parse JSON strings (e.g. --metricsConfig '{"kpis":[...]}')
674
+ if (typeof val === 'string' && (val.startsWith('{') || val.startsWith('['))) {
675
+ try { val = JSON.parse(val); } catch { /* keep as string */ }
676
+ }
677
+ body[key] = val;
678
+ }
679
+ }
680
+ }
681
+
682
+ // For simple create commands, support positional content
683
+ if (commandKey === 'updates.create' && !body && positional[1]) {
684
+ body = { content: positional.slice(1).join(' '), visibility: 'client' };
685
+ }
686
+
687
+ // ── Search Command ──
688
+ if (commandKey === 'search') {
689
+ const q = [id, ...positional.slice(1)].filter(Boolean).join(' ');
690
+ if (!q) {
691
+ console.error('Usage: search <term>');
692
+ process.exit(1);
693
+ }
694
+ const data = await api('GET', `/api/admin/search?q=${encodeURIComponent(q)}`);
695
+ const hasResults = data.projects.length + data.tasks.length + data.updates.length > 0;
696
+
697
+ if (!hasResults) {
698
+ console.log(`No results for "${q}"`);
699
+ process.exit(0);
700
+ }
701
+
702
+ console.log(`\nSearch: "${q}"`);
703
+ console.log('─'.repeat(50));
704
+
705
+ if (data.projects.length > 0) {
706
+ console.log('\nProjects:');
707
+ data.projects.forEach((p) => {
708
+ console.log(` • ${p.name} (${p.clientName}) [${p.status}] [${p.id}]`);
709
+ });
710
+ } else {
711
+ console.log('\nProjects: (none)');
712
+ }
713
+
714
+ if (data.tasks.length > 0) {
715
+ console.log('\nTasks:');
716
+ data.tasks.forEach((t) => {
717
+ const prio = t.priority && t.priority !== 'normal' ? ` (${t.priority})` : '';
718
+ console.log(` • ${t.title} [${t.status}]${prio} — in: ${t.projectName} [${t.id}]`);
719
+ });
720
+ } else {
721
+ console.log('\nTasks: (none)');
722
+ }
723
+
724
+ if (data.updates.length > 0) {
725
+ console.log('\nUpdates:');
726
+ data.updates.forEach((u) => {
727
+ const vis = u.visibility === 'internal' ? ' [INTERNAL]' : '';
728
+ console.log(` • ${u.contentPreview}${vis} — in: ${u.projectName} [${u.id}]`);
729
+ });
730
+ } else {
731
+ console.log('\nUpdates: (none)');
732
+ }
733
+
734
+ process.exit(0);
735
+ }
736
+
737
+ // ── Milestone Commands (custom logic: fetch → modify → PATCH) ──
738
+
739
+ if (cmd.method === 'CUSTOM' && resource === 'milestones') {
740
+ const projectId = id;
741
+ if (!projectId) {
742
+ console.error('Usage: milestones <action> <projectId> [--name "..."] or [index]');
743
+ process.exit(1);
744
+ }
745
+
746
+ // Fetch current project
747
+ const projData = await api('GET', `/api/admin/projects/${projectId}`);
748
+ const milestones = projData.project.milestonesJson ?? [];
749
+
750
+ if (action === 'list') {
751
+ if (milestones.length === 0) {
752
+ console.log('No milestones defined.');
753
+ } else {
754
+ console.log(`\nMilestones for: ${projData.project.name}`);
755
+ console.log('─'.repeat(50));
756
+ milestones.forEach((m, i) => {
757
+ const icon = m.status === 'done' ? '✓' : m.status === 'in_progress' ? '◐' : '○';
758
+ console.log(` ${i + 1}. ${icon} ${m.name} [${m.status}]`);
759
+ });
760
+ }
761
+ process.exit(0);
762
+ }
763
+
764
+ if (action === 'add') {
765
+ const name = flags.name || positional[1];
766
+ if (!name) { console.error('Usage: milestones add <projectId> --name "Milestone name"'); process.exit(1); }
767
+ milestones.push({ name, status: 'pending' });
768
+ await api('PATCH', `/api/admin/projects/${projectId}`, { milestonesJson: milestones });
769
+ console.log(`Added milestone: ${name} (${milestones.length} total)`);
770
+ process.exit(0);
771
+ }
772
+
773
+ if (action === 'done' || action === 'undo' || action === 'remove') {
774
+ const idx = parseInt(positional[1] || flags.index, 10) - 1;
775
+ if (isNaN(idx) || idx < 0 || idx >= milestones.length) {
776
+ console.error(`Invalid index. Use 1-${milestones.length}.`);
777
+ process.exit(1);
778
+ }
779
+ if (action === 'done') {
780
+ milestones[idx].status = 'done';
781
+ await api('PATCH', `/api/admin/projects/${projectId}`, { milestonesJson: milestones });
782
+ console.log(`Marked done: ${milestones[idx].name}`);
783
+ } else if (action === 'undo') {
784
+ milestones[idx].status = 'pending';
785
+ await api('PATCH', `/api/admin/projects/${projectId}`, { milestonesJson: milestones });
786
+ console.log(`Reverted to pending: ${milestones[idx].name}`);
787
+ } else if (action === 'remove') {
788
+ const removed = milestones.splice(idx, 1);
789
+ await api('PATCH', `/api/admin/projects/${projectId}`, { milestonesJson: milestones });
790
+ console.log(`Removed: ${removed[0].name} (${milestones.length} remaining)`);
791
+ }
792
+ process.exit(0);
793
+ }
794
+
795
+ console.error(`Unknown milestone action: ${action}`);
796
+ process.exit(1);
797
+ }
798
+
799
+ // ── File Upload (multipart) ──
800
+ if (cmd.supportsFiles && flags.file) {
801
+ const filePaths = Array.isArray(flags.file) ? flags.file : [flags.file];
802
+ const fields = {};
803
+ if (body) Object.assign(fields, body);
804
+ if (!fields.content && commandKey === 'updates.create') {
805
+ fields.content = positional.slice(1).join(' ') || '';
806
+ }
807
+ if (!fields.visibility) fields.visibility = 'client';
808
+ const data = await apiMultipart(cmd.method, path, fields, filePaths);
809
+ formatOutput(data, commandKey);
810
+ process.exit(0);
811
+ }
812
+
813
+ if (cmd.method === 'DOWNLOAD') {
814
+ await apiDownload(path, flags.output, cmd.auth);
815
+ process.exit(0);
816
+ }
817
+
818
+ // Execute
819
+ const data = await api(cmd.method, path, body, cmd.auth);
820
+ formatOutput(data, commandKey);