@commandgarden/cli 2.1.0 → 2.3.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.
@@ -10,8 +10,8 @@
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
11
  <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
12
12
  <title>commandGarden</title>
13
- <script type="module" crossorigin src="/assets/index-GpK1-Tyq.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-JB_Cdl_F.css">
13
+ <script type="module" crossorigin src="/assets/index-VwLyBHHl.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-DlAU-HAE.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -2,6 +2,7 @@ import { HIGH_RISK_CAPABILITIES } from '@commandgarden/shared';
2
2
  const APP_ROUTES = {
3
3
  'timetracking/report': '/apps/timetracking',
4
4
  'teams/room-availability': '/apps/rooms',
5
+ 'saba/pending-training': '/apps/saba',
5
6
  'tokenmaster/clients-list': '/apps/trusted-peer-expiry',
6
7
  'tokenmaster/client-trustedby': '/apps/trusted-peer-expiry',
7
8
  };
@@ -8,6 +8,7 @@ import { goalsRoutes } from './goals.js';
8
8
  import { timetrackingCacheRoutes } from './timetracking-cache.js';
9
9
  import { journalRoutes } from './journal.js';
10
10
  import { securityNewsCacheRoutes } from './security-news-cache.js';
11
+ import { trustedPeersCacheRoutes } from './trusted-peers-cache.js';
11
12
  import { skillsRoutes } from './skills.js';
12
13
  export function registerRoutes(app, daemon, store) {
13
14
  statusRoutes(app, daemon);
@@ -20,5 +21,6 @@ export function registerRoutes(app, daemon, store) {
20
21
  timetrackingCacheRoutes(app, store);
21
22
  journalRoutes(app, daemon, store);
22
23
  securityNewsCacheRoutes(app, store);
24
+ trustedPeersCacheRoutes(app, store);
23
25
  skillsRoutes(app);
24
26
  }
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { AppStore } from '../store.js';
3
+ export declare function trustedPeersCacheRoutes(app: FastifyInstance, store: AppStore): void;
@@ -0,0 +1,17 @@
1
+ export function trustedPeersCacheRoutes(app, store) {
2
+ app.get('/api/trusted-peers/cache', async () => {
3
+ const cached = store.getCachedTrustedPeers();
4
+ if (!cached)
5
+ return { ok: true, data: null, fetchedAt: null };
6
+ return { ok: true, data: cached.data, fetchedAt: cached.fetchedAt };
7
+ });
8
+ app.post('/api/trusted-peers/cache', async (req, reply) => {
9
+ const { data } = req.body;
10
+ if (!Array.isArray(data)) {
11
+ reply.code(400);
12
+ return { ok: false, error: 'Missing data array' };
13
+ }
14
+ store.cacheTrustedPeers(data);
15
+ return { ok: true };
16
+ });
17
+ }
@@ -43,6 +43,11 @@ export declare class AppStore {
43
43
  data: Record<string, unknown>[];
44
44
  fetchedAt: string;
45
45
  } | null;
46
+ cacheTrustedPeers(data: Record<string, unknown>[]): void;
47
+ getCachedTrustedPeers(): {
48
+ data: Record<string, unknown>[];
49
+ fetchedAt: string;
50
+ } | null;
46
51
  cacheSecurityNews(data: Record<string, unknown>[]): void;
47
52
  getCachedSecurityNews(): {
48
53
  data: Record<string, unknown>[];
@@ -101,6 +101,11 @@ export class AppStore {
101
101
  id INTEGER PRIMARY KEY CHECK (id = 1),
102
102
  data TEXT NOT NULL,
103
103
  fetched_at TEXT NOT NULL
104
+ )`);
105
+ this.db.run(`CREATE TABLE IF NOT EXISTS trusted_peers_cache (
106
+ id INTEGER PRIMARY KEY CHECK (id = 1),
107
+ data TEXT NOT NULL,
108
+ fetched_at TEXT NOT NULL
104
109
  )`);
105
110
  }
106
111
  getPreference(key) {
@@ -200,6 +205,20 @@ export class AppStore {
200
205
  fetchedAt: rows[0].fetched_at,
201
206
  };
202
207
  }
208
+ cacheTrustedPeers(data) {
209
+ const now = new Date().toISOString();
210
+ this.db.run('INSERT OR REPLACE INTO trusted_peers_cache (id, data, fetched_at) VALUES (1, ?, ?)', [JSON.stringify(data), now]);
211
+ this.persist();
212
+ }
213
+ getCachedTrustedPeers() {
214
+ const rows = this.query('SELECT data, fetched_at FROM trusted_peers_cache WHERE id = 1');
215
+ if (rows.length === 0)
216
+ return null;
217
+ return {
218
+ data: JSON.parse(rows[0].data),
219
+ fetchedAt: rows[0].fetched_at,
220
+ };
221
+ }
203
222
  cacheSecurityNews(data) {
204
223
  const now = new Date().toISOString();
205
224
  this.db.run('INSERT OR REPLACE INTO security_news_cache (id, data, fetched_at) VALUES (1, ?, ?)', [JSON.stringify(data), now]);
@@ -0,0 +1,94 @@
1
+ // Runs in Saba Cloud page context via js_evaluate step.
2
+ // Fetches pending mandatory learning items from Saba Cloud (v64+) using two
3
+ // confirmed API endpoints:
4
+ //
5
+ // 1. GET /Saba/api/ui/torque/uicontext/currentuser
6
+ // → userInfo.userId (e.g. "emplo000000001653633")
7
+ //
8
+ // 2. GET /Saba/api/common/todocontroller/detail/{userId}?context=learning&expand=savetabpref
9
+ // → searchResults[1] (Saba list format: ["list", [...items]])
10
+
11
+ const ORIGIN = 'https://daimler.sabacloud.com';
12
+
13
+ // ── Wait for valid Saba session (SSO / redirects, up to 60s) ─────────────
14
+
15
+ const __deadline = Date.now() + 60000;
16
+ while (Date.now() < __deadline) {
17
+ if (location.hostname === 'daimler.sabacloud.com' && document.readyState === 'complete') break;
18
+ await new Promise(r => setTimeout(r, 1000));
19
+ }
20
+ if (location.hostname !== 'daimler.sabacloud.com') {
21
+ throw new Error('AUTH_REQUIRED: Not signed in to Saba — log in at daimler.sabacloud.com and retry');
22
+ }
23
+
24
+ // ── Step 1: Resolve current user ID ──────────────────────────────────────
25
+
26
+ const meResp = await fetch(`${ORIGIN}/Saba/api/ui/torque/uicontext/currentuser`, {
27
+ headers: { Accept: 'application/json' },
28
+ credentials: 'include',
29
+ });
30
+ if (meResp.status === 401 || meResp.status === 403) {
31
+ throw new Error('AUTH_REQUIRED: Saba session expired — log in and retry');
32
+ }
33
+ if (!meResp.ok) {
34
+ throw new Error(`currentuser API returned HTTP ${meResp.status}`);
35
+ }
36
+ const meData = await meResp.json();
37
+ const userId = meData?.userInfo?.userId;
38
+ if (!userId) {
39
+ throw new Error('Could not read userInfo.userId from currentuser response');
40
+ }
41
+
42
+ // ── Step 2: Fetch pending learning to-do items ────────────────────────────
43
+
44
+ const todoResp = await fetch(
45
+ `${ORIGIN}/Saba/api/common/todocontroller/detail/${encodeURIComponent(userId)}?context=learning&expand=savetabpref`,
46
+ { headers: { Accept: 'application/json' }, credentials: 'include' }
47
+ );
48
+ if (!todoResp.ok) {
49
+ throw new Error(`todocontroller API returned HTTP ${todoResp.status} for userId=${userId}`);
50
+ }
51
+ const todoData = await todoResp.json();
52
+
53
+ // searchResults is serialised as ["list", [item, item, ...]]
54
+ const rawList = todoData?.searchResults;
55
+ const items = Array.isArray(rawList)
56
+ ? (rawList[0] === 'list' ? rawList[1] : rawList)
57
+ : [];
58
+
59
+ if (!Array.isArray(items)) {
60
+ throw new Error('Unexpected todocontroller response shape — searchResults is not a list');
61
+ }
62
+
63
+ // ── Step 3: Process and return rows ──────────────────────────────────────
64
+
65
+ const DONE_STATUSES = new Set([
66
+ 'completed', 'passed', 'withdrawn', 'cancelled', 'waived', 'successful',
67
+ ]);
68
+
69
+ const today = new Date();
70
+ today.setHours(0, 0, 0, 0);
71
+
72
+ return items
73
+ .filter(item => {
74
+ if (!item.mandatory) return false;
75
+ const status = (item.itemDisplayStatus || '').toLowerCase();
76
+ return !DONE_STATUSES.has(status);
77
+ })
78
+ .map(item => {
79
+ const dueDateMs = item.dueDate?.date ?? null;
80
+ const dueDateObj = dueDateMs ? new Date(dueDateMs) : null;
81
+ const daysUntilDue = item.dueDateTs?.localDaysDifference
82
+ ?? (dueDateObj !== null
83
+ ? Math.ceil((dueDateObj.getTime() - today.getTime()) / 86400000)
84
+ : null);
85
+ return {
86
+ title: item.itemName || '(unknown)',
87
+ type: item.itemType || 'Training',
88
+ status: item.itemDisplayStatus || 'Pending',
89
+ dueDate: dueDateObj ? dueDateObj.toISOString().slice(0, 10) : '',
90
+ daysUntilDue: daysUntilDue ?? 9999,
91
+ isOverdue: item.overdue ?? (daysUntilDue !== null && daysUntilDue < 0),
92
+ };
93
+ })
94
+ .sort((a, b) => a.daysUntilDue - b.daysUntilDue);
@@ -0,0 +1,34 @@
1
+ site: saba
2
+ name: pending-training
3
+ version: "1.0"
4
+ description: "Pending mandatory learning plan items from Saba Cloud (daimler.sabacloud.com)"
5
+ access: read
6
+
7
+ domains:
8
+ - "daimler.sabacloud.com"
9
+ capabilities:
10
+ - navigate
11
+ - js_evaluate
12
+
13
+ columns:
14
+ - name: title
15
+ type: string
16
+ - name: type
17
+ type: string
18
+ - name: status
19
+ type: string
20
+ - name: dueDate
21
+ type: string
22
+ - name: daysUntilDue
23
+ type: number
24
+ - name: isOverdue
25
+ type: boolean
26
+
27
+ pipeline:
28
+ - step: navigate
29
+ url: "https://daimler.sabacloud.com/"
30
+ - step: wait
31
+ selector: "body"
32
+ timeout: 60000
33
+ - step: js_evaluate
34
+ file: saba-pending-training.eval.js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commandgarden/cli",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Enterprise browser automation CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "@fastify/static": "^8.0.0",
43
43
  "@fastify/websocket": "^11.0.0",
44
44
  "sql.js": "^1.11.0",
45
+ "turndown": "^7.2.4",
45
46
  "cli-table3": "^0.6.0",
46
47
  "commander": "^12.0.0",
47
48
  "fastify": "^5.0.0",