chainlesschain 0.47.7 → 0.47.9
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/package.json +10 -8
- package/src/commands/activitypub.js +533 -0
- package/src/commands/compliance.js +597 -6
- package/src/commands/matrix.js +283 -0
- package/src/commands/mcp.js +344 -0
- package/src/commands/nostr.js +196 -7
- package/src/commands/social.js +265 -0
- package/src/index.js +2 -0
- package/src/lib/activitypub-bridge.js +623 -0
- package/src/lib/compliance-framework-reporter.js +600 -0
- package/src/lib/matrix-bridge.js +252 -0
- package/src/lib/mcp-registry.js +347 -0
- package/src/lib/mcp-scaffold.js +385 -0
- package/src/lib/nostr-bridge.js +214 -38
- package/src/lib/social-graph.js +408 -0
- package/src/lib/stix-parser.js +167 -0
- package/src/lib/threat-intel.js +268 -0
- package/src/lib/topic-classifier.js +400 -0
- package/src/lib/ueba.js +403 -0
- package/src/repl/agent-repl.js +23 -0
package/src/lib/ueba.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UEBA — User and Entity Behavior Analytics.
|
|
3
|
+
*
|
|
4
|
+
* Pure analytics over a stream of `{entity, action, resource,
|
|
5
|
+
* timestamp, success?}` events. No DB dependency — callers load
|
|
6
|
+
* events from `audit_log` (or anywhere else) and feed them in.
|
|
7
|
+
*
|
|
8
|
+
* Three primary surfaces:
|
|
9
|
+
*
|
|
10
|
+
* buildBaseline(events) — summarize per-entity behaviour
|
|
11
|
+
* scoreEvent(baseline, event) — anomaly score 0–1 vs. baseline
|
|
12
|
+
* detectAnomalies(baseline, candidates, {threshold})
|
|
13
|
+
* rankEntities(events, {topK}) — highest-risk entities overall
|
|
14
|
+
*
|
|
15
|
+
* Scoring is intentionally simple (frequency-based surprise +
|
|
16
|
+
* failure signal) — enough to surface "this user logged in at 3am
|
|
17
|
+
* from a never-seen resource" without dragging in ML baggage.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/* ── helpers ───────────────────────────────────────────────── */
|
|
21
|
+
|
|
22
|
+
function _toHour(ts) {
|
|
23
|
+
if (ts == null) return null;
|
|
24
|
+
const d = typeof ts === "number" ? new Date(ts) : new Date(String(ts));
|
|
25
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
26
|
+
return d.getUTCHours();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _increment(map, key) {
|
|
30
|
+
if (key == null) return;
|
|
31
|
+
map.set(key, (map.get(key) || 0) + 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _toObject(map) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const [k, v] of map) out[k] = v;
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ── buildBaseline ─────────────────────────────────────────── */
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build per-entity baselines from an event stream. Returns a Map of
|
|
44
|
+
* entityId → baseline. Each baseline is a frozen object; re-running
|
|
45
|
+
* `buildBaseline` on an extended event set returns new baselines
|
|
46
|
+
* rather than mutating prior results (important for watch loops).
|
|
47
|
+
*/
|
|
48
|
+
export function buildBaseline(events) {
|
|
49
|
+
if (!Array.isArray(events)) return new Map();
|
|
50
|
+
|
|
51
|
+
const perEntity = new Map();
|
|
52
|
+
|
|
53
|
+
for (const ev of events) {
|
|
54
|
+
if (!ev || !ev.entity) continue;
|
|
55
|
+
let b = perEntity.get(ev.entity);
|
|
56
|
+
if (!b) {
|
|
57
|
+
b = {
|
|
58
|
+
entity: ev.entity,
|
|
59
|
+
eventCount: 0,
|
|
60
|
+
successCount: 0,
|
|
61
|
+
failureCount: 0,
|
|
62
|
+
actionCounts: new Map(),
|
|
63
|
+
resourceCounts: new Map(),
|
|
64
|
+
hourCounts: new Array(24).fill(0),
|
|
65
|
+
firstSeen: null,
|
|
66
|
+
lastSeen: null,
|
|
67
|
+
};
|
|
68
|
+
perEntity.set(ev.entity, b);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
b.eventCount += 1;
|
|
72
|
+
if (ev.success === false) b.failureCount += 1;
|
|
73
|
+
else b.successCount += 1;
|
|
74
|
+
|
|
75
|
+
_increment(b.actionCounts, ev.action);
|
|
76
|
+
_increment(b.resourceCounts, ev.resource);
|
|
77
|
+
|
|
78
|
+
const h = _toHour(ev.timestamp);
|
|
79
|
+
if (h != null) b.hourCounts[h] += 1;
|
|
80
|
+
|
|
81
|
+
if (ev.timestamp != null) {
|
|
82
|
+
const t =
|
|
83
|
+
typeof ev.timestamp === "number"
|
|
84
|
+
? ev.timestamp
|
|
85
|
+
: new Date(ev.timestamp).getTime();
|
|
86
|
+
if (!Number.isNaN(t)) {
|
|
87
|
+
if (b.firstSeen == null || t < b.firstSeen) b.firstSeen = t;
|
|
88
|
+
if (b.lastSeen == null || t > b.lastSeen) b.lastSeen = t;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Freeze a serializable snapshot. Keep the Map-typed counts
|
|
94
|
+
// internally for O(1) scoring, plus JSON-friendly mirrors on the
|
|
95
|
+
// returned object.
|
|
96
|
+
for (const b of perEntity.values()) {
|
|
97
|
+
b.uniqueActions = b.actionCounts.size;
|
|
98
|
+
b.uniqueResources = b.resourceCounts.size;
|
|
99
|
+
b.failureRate = b.eventCount === 0 ? 0 : b.failureCount / b.eventCount;
|
|
100
|
+
b.actions = _toObject(b.actionCounts);
|
|
101
|
+
b.resources = _toObject(b.resourceCounts);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return perEntity;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Serialize a baseline Map to a plain-object dict suitable for
|
|
109
|
+
* `JSON.stringify` / DB persistence. Drops the internal Map copies.
|
|
110
|
+
*/
|
|
111
|
+
export function serializeBaseline(baselineMap) {
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const [entity, b] of baselineMap) {
|
|
114
|
+
out[entity] = {
|
|
115
|
+
entity: b.entity,
|
|
116
|
+
eventCount: b.eventCount,
|
|
117
|
+
successCount: b.successCount,
|
|
118
|
+
failureCount: b.failureCount,
|
|
119
|
+
failureRate: b.failureRate,
|
|
120
|
+
uniqueActions: b.uniqueActions,
|
|
121
|
+
uniqueResources: b.uniqueResources,
|
|
122
|
+
hourCounts: b.hourCounts.slice(),
|
|
123
|
+
actions: { ...b.actions },
|
|
124
|
+
resources: { ...b.resources },
|
|
125
|
+
firstSeen: b.firstSeen,
|
|
126
|
+
lastSeen: b.lastSeen,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Reverse of `serializeBaseline` — hydrate a persisted dict back
|
|
134
|
+
* into the runtime Map with `actionCounts` / `resourceCounts` Maps
|
|
135
|
+
* restored, so `scoreEvent` can use them directly.
|
|
136
|
+
*/
|
|
137
|
+
export function deserializeBaseline(dict) {
|
|
138
|
+
const map = new Map();
|
|
139
|
+
if (!dict || typeof dict !== "object") return map;
|
|
140
|
+
for (const [entity, b] of Object.entries(dict)) {
|
|
141
|
+
const rebuilt = {
|
|
142
|
+
entity,
|
|
143
|
+
eventCount: b.eventCount || 0,
|
|
144
|
+
successCount: b.successCount || 0,
|
|
145
|
+
failureCount: b.failureCount || 0,
|
|
146
|
+
failureRate: b.failureRate || 0,
|
|
147
|
+
uniqueActions: b.uniqueActions || 0,
|
|
148
|
+
uniqueResources: b.uniqueResources || 0,
|
|
149
|
+
hourCounts:
|
|
150
|
+
Array.isArray(b.hourCounts) && b.hourCounts.length === 24
|
|
151
|
+
? b.hourCounts.slice()
|
|
152
|
+
: new Array(24).fill(0),
|
|
153
|
+
actions: { ...(b.actions || {}) },
|
|
154
|
+
resources: { ...(b.resources || {}) },
|
|
155
|
+
actionCounts: new Map(Object.entries(b.actions || {})),
|
|
156
|
+
resourceCounts: new Map(Object.entries(b.resources || {})),
|
|
157
|
+
firstSeen: b.firstSeen ?? null,
|
|
158
|
+
lastSeen: b.lastSeen ?? null,
|
|
159
|
+
};
|
|
160
|
+
map.set(entity, rebuilt);
|
|
161
|
+
}
|
|
162
|
+
return map;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ── scoreEvent ────────────────────────────────────────────── */
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Score a single event against an entity's baseline. Returns
|
|
169
|
+
* { score: 0..1, reasons: string[] }
|
|
170
|
+
*
|
|
171
|
+
* Higher score = more anomalous. A baseline with zero prior events
|
|
172
|
+
* is treated as "fully unseen" — every incoming event scores 1.
|
|
173
|
+
*/
|
|
174
|
+
export function scoreEvent(baseline, event) {
|
|
175
|
+
if (!event) return { score: 0, reasons: [] };
|
|
176
|
+
if (!baseline || baseline.eventCount === 0) {
|
|
177
|
+
return { score: 1, reasons: ["no prior activity for entity"] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const total = baseline.eventCount;
|
|
181
|
+
const reasons = [];
|
|
182
|
+
|
|
183
|
+
// Hour surprise
|
|
184
|
+
const h = _toHour(event.timestamp);
|
|
185
|
+
let hourSurprise = 0;
|
|
186
|
+
if (h != null) {
|
|
187
|
+
const hc = baseline.hourCounts[h] || 0;
|
|
188
|
+
hourSurprise = 1 - hc / total;
|
|
189
|
+
if (hc === 0) reasons.push(`unseen hour ${h}:00 UTC`);
|
|
190
|
+
else if (hourSurprise > 0.9) reasons.push(`rare hour ${h}:00 UTC`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Action surprise
|
|
194
|
+
const ac = baseline.actionCounts.get(event.action) || 0;
|
|
195
|
+
const actionSurprise = ac === 0 ? 1 : 1 - ac / total;
|
|
196
|
+
if (ac === 0 && event.action) reasons.push(`unseen action "${event.action}"`);
|
|
197
|
+
else if (actionSurprise > 0.9 && event.action)
|
|
198
|
+
reasons.push(`rare action "${event.action}"`);
|
|
199
|
+
|
|
200
|
+
// Resource surprise
|
|
201
|
+
const rc = baseline.resourceCounts.get(event.resource) || 0;
|
|
202
|
+
const resourceSurprise = rc === 0 ? 1 : 1 - rc / total;
|
|
203
|
+
if (rc === 0 && event.resource)
|
|
204
|
+
reasons.push(`unseen resource "${event.resource}"`);
|
|
205
|
+
else if (resourceSurprise > 0.9 && event.resource)
|
|
206
|
+
reasons.push(`rare resource "${event.resource}"`);
|
|
207
|
+
|
|
208
|
+
const base = (hourSurprise + actionSurprise + resourceSurprise) / 3;
|
|
209
|
+
|
|
210
|
+
// Failure signal — a failure against a mostly-successful baseline
|
|
211
|
+
// is notable even if every other feature looks normal.
|
|
212
|
+
let failureBonus = 0;
|
|
213
|
+
if (event.success === false && baseline.failureRate < 0.1) {
|
|
214
|
+
failureBonus = 0.3;
|
|
215
|
+
reasons.push("failure against low-failure baseline");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const score = Math.min(1, base + failureBonus);
|
|
219
|
+
return { score, reasons };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* ── detectAnomalies ───────────────────────────────────────── */
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Score each candidate event against the relevant entity baseline,
|
|
226
|
+
* and return those whose score meets `threshold` (default 0.7).
|
|
227
|
+
* Returns `[{event, score, reasons}]` sorted by descending score.
|
|
228
|
+
*
|
|
229
|
+
* `baselineMap` is the Map returned by `buildBaseline` (or rehydrated
|
|
230
|
+
* via `deserializeBaseline`).
|
|
231
|
+
*/
|
|
232
|
+
export function detectAnomalies(baselineMap, candidateEvents, options = {}) {
|
|
233
|
+
const { threshold = 0.7 } = options;
|
|
234
|
+
if (!Array.isArray(candidateEvents)) return [];
|
|
235
|
+
if (!(baselineMap instanceof Map)) return [];
|
|
236
|
+
|
|
237
|
+
const out = [];
|
|
238
|
+
for (const ev of candidateEvents) {
|
|
239
|
+
if (!ev || !ev.entity) continue;
|
|
240
|
+
const baseline = baselineMap.get(ev.entity);
|
|
241
|
+
const result = scoreEvent(baseline, ev);
|
|
242
|
+
if (result.score >= threshold) {
|
|
243
|
+
out.push({ event: ev, score: result.score, reasons: result.reasons });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
out.sort((a, b) => b.score - a.score);
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* ── rankEntities ──────────────────────────────────────────── */
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Rank entities by composite risk derived directly from an event
|
|
255
|
+
* stream (no prior baseline required). Useful for the "top risky
|
|
256
|
+
* users this week" view.
|
|
257
|
+
*
|
|
258
|
+
* riskScore (0–100) = 40·failureRate
|
|
259
|
+
* + 30·uniqueResourceRatio
|
|
260
|
+
* + 30·burstiness
|
|
261
|
+
*
|
|
262
|
+
* where `burstiness` = max-hour / eventCount (1 means "all events
|
|
263
|
+
* fell into a single hour", 0.04 means "evenly spread".)
|
|
264
|
+
*/
|
|
265
|
+
export function rankEntities(events, options = {}) {
|
|
266
|
+
const { topK = 10 } = options;
|
|
267
|
+
const baseline = buildBaseline(events);
|
|
268
|
+
const rows = [];
|
|
269
|
+
|
|
270
|
+
for (const b of baseline.values()) {
|
|
271
|
+
const uniqueResourceRatio =
|
|
272
|
+
b.eventCount === 0 ? 0 : b.uniqueResources / b.eventCount;
|
|
273
|
+
const maxHour = Math.max(...b.hourCounts);
|
|
274
|
+
const burstiness = b.eventCount === 0 ? 0 : maxHour / b.eventCount;
|
|
275
|
+
const riskScore =
|
|
276
|
+
100 *
|
|
277
|
+
(0.4 * b.failureRate + 0.3 * uniqueResourceRatio + 0.3 * burstiness);
|
|
278
|
+
rows.push({
|
|
279
|
+
entity: b.entity,
|
|
280
|
+
eventCount: b.eventCount,
|
|
281
|
+
failureRate: b.failureRate,
|
|
282
|
+
uniqueResources: b.uniqueResources,
|
|
283
|
+
uniqueActions: b.uniqueActions,
|
|
284
|
+
burstiness,
|
|
285
|
+
riskScore: Math.round(riskScore * 100) / 100,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
rows.sort((a, b) => b.riskScore - a.riskScore);
|
|
290
|
+
return topK > 0 ? rows.slice(0, topK) : rows;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ── persistence helpers ──────────────────────────────────── */
|
|
294
|
+
|
|
295
|
+
export function ensureUebaTables(db) {
|
|
296
|
+
db.exec(`
|
|
297
|
+
CREATE TABLE IF NOT EXISTS ueba_baselines (
|
|
298
|
+
entity TEXT PRIMARY KEY,
|
|
299
|
+
event_count INTEGER NOT NULL,
|
|
300
|
+
failure_rate REAL NOT NULL,
|
|
301
|
+
unique_resources INTEGER NOT NULL,
|
|
302
|
+
payload TEXT NOT NULL,
|
|
303
|
+
first_seen INTEGER,
|
|
304
|
+
last_seen INTEGER,
|
|
305
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
306
|
+
)
|
|
307
|
+
`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Persist a baseline Map (output of `buildBaseline`) into
|
|
312
|
+
* `ueba_baselines`. Rows are upserted keyed by entity.
|
|
313
|
+
*/
|
|
314
|
+
export function saveBaselines(db, baselineMap) {
|
|
315
|
+
ensureUebaTables(db);
|
|
316
|
+
const serialized = serializeBaseline(baselineMap);
|
|
317
|
+
const insert = db.prepare(
|
|
318
|
+
`INSERT INTO ueba_baselines
|
|
319
|
+
(entity, event_count, failure_rate, unique_resources, payload,
|
|
320
|
+
first_seen, last_seen, updated_at)
|
|
321
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
|
322
|
+
);
|
|
323
|
+
const update = db.prepare(
|
|
324
|
+
`UPDATE ueba_baselines
|
|
325
|
+
SET event_count = ?,
|
|
326
|
+
failure_rate = ?,
|
|
327
|
+
unique_resources = ?,
|
|
328
|
+
payload = ?,
|
|
329
|
+
first_seen = ?,
|
|
330
|
+
last_seen = ?,
|
|
331
|
+
updated_at = datetime('now')
|
|
332
|
+
WHERE entity = ?`,
|
|
333
|
+
);
|
|
334
|
+
const selectExisting = db.prepare(
|
|
335
|
+
`SELECT entity FROM ueba_baselines WHERE entity = ?`,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
let saved = 0;
|
|
339
|
+
for (const [entity, b] of Object.entries(serialized)) {
|
|
340
|
+
const payload = JSON.stringify(b);
|
|
341
|
+
const prior = selectExisting.get(entity);
|
|
342
|
+
if (prior) {
|
|
343
|
+
update.run(
|
|
344
|
+
b.eventCount,
|
|
345
|
+
b.failureRate,
|
|
346
|
+
b.uniqueResources,
|
|
347
|
+
payload,
|
|
348
|
+
b.firstSeen ?? null,
|
|
349
|
+
b.lastSeen ?? null,
|
|
350
|
+
entity,
|
|
351
|
+
);
|
|
352
|
+
} else {
|
|
353
|
+
insert.run(
|
|
354
|
+
entity,
|
|
355
|
+
b.eventCount,
|
|
356
|
+
b.failureRate,
|
|
357
|
+
b.uniqueResources,
|
|
358
|
+
payload,
|
|
359
|
+
b.firstSeen ?? null,
|
|
360
|
+
b.lastSeen ?? null,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
saved += 1;
|
|
364
|
+
}
|
|
365
|
+
return saved;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Load a previously saved baseline for a single entity. Returns the
|
|
370
|
+
* fully-hydrated baseline object (with Maps restored) or null if no
|
|
371
|
+
* row exists.
|
|
372
|
+
*/
|
|
373
|
+
export function loadBaseline(db, entity) {
|
|
374
|
+
ensureUebaTables(db);
|
|
375
|
+
const row = db
|
|
376
|
+
.prepare(`SELECT payload FROM ueba_baselines WHERE entity = ?`)
|
|
377
|
+
.get(entity);
|
|
378
|
+
if (!row) return null;
|
|
379
|
+
try {
|
|
380
|
+
const dict = { [entity]: JSON.parse(row.payload) };
|
|
381
|
+
const map = deserializeBaseline(dict);
|
|
382
|
+
return map.get(entity) || null;
|
|
383
|
+
} catch {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Load all saved baselines into a Map<entity, baseline>.
|
|
390
|
+
*/
|
|
391
|
+
export function loadAllBaselines(db) {
|
|
392
|
+
ensureUebaTables(db);
|
|
393
|
+
const rows = db.prepare(`SELECT entity, payload FROM ueba_baselines`).all();
|
|
394
|
+
const dict = {};
|
|
395
|
+
for (const r of rows) {
|
|
396
|
+
try {
|
|
397
|
+
dict[r.entity] = JSON.parse(r.payload);
|
|
398
|
+
} catch {
|
|
399
|
+
// Skip malformed rows — better to ignore than poison the Map.
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return deserializeBaseline(dict);
|
|
403
|
+
}
|
package/src/repl/agent-repl.js
CHANGED
|
@@ -101,6 +101,29 @@ async function agentLoop(messages, options) {
|
|
|
101
101
|
process.stdout.write(
|
|
102
102
|
chalk.red(` Error: ${event.error || event.result?.error}\n`),
|
|
103
103
|
);
|
|
104
|
+
// Parity with Desktop AIChatPage's `Switch to Trusted` button:
|
|
105
|
+
// when the deny came from ApprovalGate (not shell-policy), surface
|
|
106
|
+
// the exact CLI command the user can run to relax the per-session
|
|
107
|
+
// policy. The structured `approval` outcome is attached by
|
|
108
|
+
// `evaluateShellCommandWithApproval` in agent-core.js.
|
|
109
|
+
const approval = event.result?.approval;
|
|
110
|
+
if (approval?.decision === "deny" && approval?.via !== "shell-policy") {
|
|
111
|
+
const sid = options?.sessionId;
|
|
112
|
+
const policy = approval.policy || "strict";
|
|
113
|
+
if (sid && policy === "strict") {
|
|
114
|
+
process.stdout.write(
|
|
115
|
+
chalk.yellow(
|
|
116
|
+
` Hint: relax policy with cc session policy ${sid} --set trusted\n`,
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
} else if (sid) {
|
|
120
|
+
process.stdout.write(
|
|
121
|
+
chalk.yellow(
|
|
122
|
+
` Hint: per-session policy is "${policy}" — see cc session policy ${sid}\n`,
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
104
127
|
} else if (event.result?.success) {
|
|
105
128
|
process.stdout.write(chalk.green(` Done\n`));
|
|
106
129
|
}
|