@saptools/cf-events 0.1.1

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/dist/cli.js ADDED
@@ -0,0 +1,1081 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { readFileSync, realpathSync } from "fs";
5
+ import { dirname, join as join2, resolve } from "path";
6
+ import process2 from "process";
7
+ import { fileURLToPath } from "url";
8
+ import {
9
+ formatCurrentCfAppSelector,
10
+ readCurrentCfTarget
11
+ } from "@saptools/cf-sync";
12
+ import { Command } from "commander";
13
+
14
+ // src/types.ts
15
+ var SSH_EVENT_TYPES = ["audit.app.ssh-authorized", "audit.app.ssh-unauthorized"];
16
+ var CRASH_EVENT_TYPES = ["audit.app.crash", "audit.app.process.crash"];
17
+
18
+ // src/events.ts
19
+ var DURATION_PATTERN = /^(\d+)\s*(s|m|h|d)$/;
20
+ var DURATION_UNIT_MS = {
21
+ s: 1e3,
22
+ m: 6e4,
23
+ h: 36e5,
24
+ d: 864e5
25
+ };
26
+ function isCrashEvent(event) {
27
+ return CRASH_EVENT_TYPES.includes(event.type);
28
+ }
29
+ function isSshEvent(event) {
30
+ return SSH_EVENT_TYPES.includes(event.type);
31
+ }
32
+ function parseDuration(raw) {
33
+ const match = DURATION_PATTERN.exec(raw.trim().toLowerCase());
34
+ const amountToken = match?.[1];
35
+ const unitToken = match?.[2];
36
+ if (amountToken === void 0 || unitToken === void 0) {
37
+ throw new Error(`Invalid duration "${raw}". Use forms like 30m, 6h, or 7d.`);
38
+ }
39
+ const amount = Number.parseInt(amountToken, 10);
40
+ const unitMs = DURATION_UNIT_MS[unitToken];
41
+ if (unitMs === void 0 || amount <= 0) {
42
+ throw new Error(`Invalid duration "${raw}". Use forms like 30m, 6h, or 7d.`);
43
+ }
44
+ return amount * unitMs;
45
+ }
46
+ function durationToCreatedAfter(raw, now) {
47
+ return new Date(now.getTime() - parseDuration(raw)).toISOString();
48
+ }
49
+ function parseTypeFilter(raw) {
50
+ if (raw === void 0) {
51
+ return [];
52
+ }
53
+ const tokens = raw.split(",").map((token) => token.trim()).filter((token) => token.length > 0);
54
+ const resolved = [];
55
+ for (const token of tokens) {
56
+ if (token === "ssh") {
57
+ resolved.push(...SSH_EVENT_TYPES);
58
+ } else if (token === "crash") {
59
+ resolved.push(...CRASH_EVENT_TYPES);
60
+ } else if (token.startsWith("audit.") || token.startsWith("app.")) {
61
+ resolved.push(token);
62
+ } else {
63
+ throw new Error(
64
+ `Unknown event type "${token}". Use a full CF event type (e.g. audit.app.start) or the shorthand "ssh"/"crash".`
65
+ );
66
+ }
67
+ }
68
+ return [...new Set(resolved)];
69
+ }
70
+ function readString(value) {
71
+ return typeof value === "string" && value.length > 0 ? value : void 0;
72
+ }
73
+ function readNumber(value) {
74
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
75
+ }
76
+ function toCrashRecord(event) {
77
+ return {
78
+ at: event.createdAt,
79
+ index: readNumber(event.data["index"]),
80
+ reason: readString(event.data["reason"]) ?? readString(event.data["exit_description"]),
81
+ exitStatus: readNumber(event.data["exit_status"])
82
+ };
83
+ }
84
+ function summarizeCrashes(appName, events) {
85
+ const crashes = events.filter(isCrashEvent).map(toCrashRecord).sort((left, right) => right.at.localeCompare(left.at));
86
+ const last = crashes[0];
87
+ return {
88
+ appName,
89
+ crashCount: crashes.length,
90
+ lastCrashAt: last?.at,
91
+ lastCrashReason: last?.reason,
92
+ crashes
93
+ };
94
+ }
95
+
96
+ // src/format.ts
97
+ var EVENT_LABELS = {
98
+ "audit.app.create": "App created",
99
+ "audit.app.update": "App updated",
100
+ "audit.app.delete-request": "App delete requested",
101
+ "audit.app.start": "App started",
102
+ "audit.app.stop": "App stopped",
103
+ "audit.app.restage": "App restaged",
104
+ "audit.app.ssh-authorized": "SSH session authorized",
105
+ "audit.app.ssh-unauthorized": "SSH attempt denied",
106
+ "audit.app.crash": "App crashed",
107
+ "audit.app.process.create": "Process created",
108
+ "audit.app.process.scale": "Process scaled",
109
+ "audit.app.process.update": "Process updated",
110
+ "audit.app.process.crash": "Process crashed",
111
+ "audit.app.process.terminate_instance": "Instance terminated",
112
+ "audit.app.map-route": "Route mapped",
113
+ "audit.app.unmap-route": "Route unmapped",
114
+ "audit.app.environment_variables.show": "Env variables viewed"
115
+ };
116
+ function describeEventType(type) {
117
+ return EVENT_LABELS[type] ?? type;
118
+ }
119
+ function formatUptime(seconds) {
120
+ if (seconds === void 0 || seconds <= 0) {
121
+ return "-";
122
+ }
123
+ const totalMinutes = Math.floor(seconds / 60);
124
+ const days = Math.floor(totalMinutes / 1440);
125
+ const hours = Math.floor(totalMinutes % 1440 / 60);
126
+ const minutes = totalMinutes % 60;
127
+ const parts = [];
128
+ if (days > 0) {
129
+ parts.push(`${days.toString()}d`);
130
+ }
131
+ if (hours > 0) {
132
+ parts.push(`${hours.toString()}h`);
133
+ }
134
+ if (minutes > 0 || parts.length === 0) {
135
+ parts.push(`${minutes.toString()}m`);
136
+ }
137
+ return parts.join(" ");
138
+ }
139
+ function formatBytes(bytes) {
140
+ if (bytes === void 0 || bytes < 0) {
141
+ return "-";
142
+ }
143
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"];
144
+ let value = bytes;
145
+ let unitIndex = 0;
146
+ while (value >= 1024 && unitIndex < units.length - 1) {
147
+ value /= 1024;
148
+ unitIndex += 1;
149
+ }
150
+ return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex] ?? "B"}`;
151
+ }
152
+ function formatCpu(cpu) {
153
+ if (cpu === void 0) {
154
+ return "-";
155
+ }
156
+ return `${(cpu * 100).toFixed(1)}%`;
157
+ }
158
+ function formatRelativeTime(timestamp, now) {
159
+ const at = Date.parse(timestamp);
160
+ if (Number.isNaN(at)) {
161
+ return "unknown";
162
+ }
163
+ const deltaMs = now.getTime() - at;
164
+ if (deltaMs < 6e4) {
165
+ return "just now";
166
+ }
167
+ const minutes = Math.floor(deltaMs / 6e4);
168
+ if (minutes < 60) {
169
+ return `${minutes.toString()}m ago`;
170
+ }
171
+ const hours = Math.floor(minutes / 60);
172
+ if (hours < 24) {
173
+ return `${hours.toString()}h ago`;
174
+ }
175
+ return `${Math.floor(hours / 24).toString()}d ago`;
176
+ }
177
+ function formatEventLine(event, now) {
178
+ const actor = event.actor.name.length > 0 ? event.actor.name : event.actor.type;
179
+ return [
180
+ ` ${event.createdAt.padEnd(26)}`,
181
+ formatRelativeTime(event.createdAt, now).padEnd(11),
182
+ describeEventType(event.type).padEnd(24),
183
+ actor.length > 0 ? actor : "(unknown actor)"
184
+ ].join(" ");
185
+ }
186
+ function formatEventsReport(appName, events, now) {
187
+ if (events.length === 0) {
188
+ return `No audit events found for ${appName}.`;
189
+ }
190
+ return [
191
+ `Audit events for ${appName} (${events.length.toString()}):`,
192
+ ...events.map((event) => formatEventLine(event, now))
193
+ ].join("\n");
194
+ }
195
+ function formatSshStatusReport(status, now) {
196
+ const sessionLines = status.sessions.map((session) => {
197
+ const tag = session.likelyActive ? "[active]" : "[past] ";
198
+ const when = formatRelativeTime(session.authorizedAt, now);
199
+ return ` ${tag} ${session.actor.padEnd(30)} authorized ${session.authorizedAt} (${when})`;
200
+ });
201
+ const deniedLines = status.deniedAttempts.map((attempt) => {
202
+ const actor = attempt.actor.name.length > 0 ? attempt.actor.name : attempt.actor.type;
203
+ return ` ${actor.padEnd(30)} ${attempt.createdAt}`;
204
+ });
205
+ return [
206
+ `SSH status for ${status.appName}`,
207
+ "",
208
+ ` SSH enabled: ${status.sshEnabled ? "yes" : "no"}`,
209
+ ...!status.sshEnabled && status.sshReason.length > 0 ? [` Disabled reason: ${status.sshReason}`] : [],
210
+ ` Recent SSH sessions: ${status.sessions.length.toString()}`,
211
+ ` Likely active sessions: ${status.activeSessionCount.toString()}`,
212
+ ...sessionLines.length > 0 ? ["", " Sessions:", ...sessionLines] : [],
213
+ ...deniedLines.length > 0 ? ["", ` Denied SSH attempts: ${status.deniedAttempts.length.toString()}`, ...deniedLines] : [],
214
+ "",
215
+ ' Note: Cloud Foundry exposes no live-session API; "likely active" sessions',
216
+ " are inferred from recent ssh-authorized audit events."
217
+ ].join("\n");
218
+ }
219
+ function formatCrashReport(summary, now) {
220
+ if (summary.crashCount === 0) {
221
+ return `No crashes found for ${summary.appName}.`;
222
+ }
223
+ const crashLines = summary.crashes.map((crash) => {
224
+ const index = crash.index === void 0 ? "-" : `#${crash.index.toString()}`;
225
+ const reason = crash.reason ?? "unknown";
226
+ const exit = crash.exitStatus === void 0 ? "" : ` exit ${crash.exitStatus.toString()}`;
227
+ return ` ${crash.at} instance ${index} ${reason}${exit}`;
228
+ });
229
+ return [
230
+ `Crash report for ${summary.appName}`,
231
+ "",
232
+ ` Crashes: ${summary.crashCount.toString()}`,
233
+ ...summary.lastCrashAt === void 0 ? [] : [` Last crash: ${summary.lastCrashAt} (${formatRelativeTime(summary.lastCrashAt, now)})`],
234
+ ...summary.lastCrashReason === void 0 ? [] : [` Last reason: ${summary.lastCrashReason}`],
235
+ "",
236
+ " Recent crashes:",
237
+ ...crashLines
238
+ ].join("\n");
239
+ }
240
+ function formatInstanceLine(instance) {
241
+ return [
242
+ ` #${instance.index.toString()}`.padEnd(6),
243
+ instance.state.padEnd(10),
244
+ `up ${formatUptime(instance.uptimeSeconds)}`.padEnd(16),
245
+ `cpu ${formatCpu(instance.cpu)}`.padEnd(13),
246
+ `mem ${formatBytes(instance.memBytes)} / ${formatBytes(instance.memQuotaBytes)}`
247
+ ].join(" ");
248
+ }
249
+ function formatStatusReport(health, now) {
250
+ const lastEventLine = health.lastEvent === void 0 ? " Last event: (none)" : ` Last event: ${describeEventType(health.lastEvent.type)} - ${health.lastEvent.createdAt} (${formatRelativeTime(health.lastEvent.createdAt, now)})`;
251
+ return [
252
+ `App status: ${health.appName}`,
253
+ "",
254
+ ` GUID: ${health.appGuid}`,
255
+ ` Requested state: ${health.requestedState.length > 0 ? health.requestedState : "unknown"}`,
256
+ ` SSH enabled: ${health.sshEnabled ? "yes" : "no"}`,
257
+ ` Instances: ${health.instances.length.toString()}`,
258
+ ...health.instances.length > 0 ? ["", ...health.instances.map((instance) => formatInstanceLine(instance))] : [],
259
+ "",
260
+ lastEventLine
261
+ ].join("\n");
262
+ }
263
+
264
+ // src/runtime.ts
265
+ import { setTimeout as delayPromise } from "timers/promises";
266
+
267
+ // src/api.ts
268
+ var MAX_PER_PAGE = 100;
269
+ function parseJson(raw) {
270
+ try {
271
+ return JSON.parse(raw);
272
+ } catch {
273
+ throw new Error("The CF API returned a response that is not valid JSON.");
274
+ }
275
+ }
276
+ function asRecord(value) {
277
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
278
+ return value;
279
+ }
280
+ return {};
281
+ }
282
+ function asArray(value) {
283
+ return Array.isArray(value) ? value : [];
284
+ }
285
+ function asString(value) {
286
+ return typeof value === "string" ? value : "";
287
+ }
288
+ function asNumber(value) {
289
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
290
+ }
291
+ function mapEntityRef(value) {
292
+ const record = asRecord(value);
293
+ return {
294
+ guid: asString(record["guid"]),
295
+ type: asString(record["type"]),
296
+ name: asString(record["name"])
297
+ };
298
+ }
299
+ function optionalGuid(value) {
300
+ const guid = asString(asRecord(value)["guid"]);
301
+ return guid.length > 0 ? guid : void 0;
302
+ }
303
+ function mapAuditEvent(value) {
304
+ const record = asRecord(value);
305
+ return {
306
+ guid: asString(record["guid"]),
307
+ type: asString(record["type"]),
308
+ createdAt: asString(record["created_at"]),
309
+ updatedAt: asString(record["updated_at"]),
310
+ actor: mapEntityRef(record["actor"]),
311
+ target: mapEntityRef(record["target"]),
312
+ data: asRecord(record["data"]),
313
+ spaceGuid: optionalGuid(record["space"]),
314
+ organizationGuid: optionalGuid(record["organization"])
315
+ };
316
+ }
317
+ function mapProcessStat(value) {
318
+ const record = asRecord(value);
319
+ const usage = asRecord(record["usage"]);
320
+ return {
321
+ type: asString(record["type"]),
322
+ index: asNumber(record["index"]) ?? 0,
323
+ state: asString(record["state"]),
324
+ uptimeSeconds: asNumber(record["uptime"]),
325
+ cpu: asNumber(usage["cpu"]),
326
+ memBytes: asNumber(usage["mem"]),
327
+ memQuotaBytes: asNumber(record["mem_quota"]),
328
+ diskBytes: asNumber(usage["disk"]),
329
+ diskQuotaBytes: asNumber(record["disk_quota"])
330
+ };
331
+ }
332
+ function buildAuditEventsPath(input, perPage) {
333
+ const params = new URLSearchParams();
334
+ params.set("target_guids", input.appGuid);
335
+ params.set("order_by", "-created_at");
336
+ params.set("per_page", perPage.toString());
337
+ if (input.types !== void 0 && input.types.length > 0) {
338
+ params.set("types", input.types.join(","));
339
+ }
340
+ if (input.createdAfter !== void 0) {
341
+ params.set("created_ats[gt]", input.createdAfter);
342
+ }
343
+ return `/v3/audit_events?${params.toString()}`;
344
+ }
345
+ function nextPagePath(body) {
346
+ const next = asRecord(asRecord(asRecord(body)["pagination"])["next"]);
347
+ const href = next["href"];
348
+ if (typeof href !== "string" || href.length === 0) {
349
+ return void 0;
350
+ }
351
+ try {
352
+ const url = new URL(href);
353
+ return `${url.pathname}${url.search}`;
354
+ } catch {
355
+ return void 0;
356
+ }
357
+ }
358
+ async function fetchAuditEvents(input, curl) {
359
+ const limit = Math.max(input.limit, 1);
360
+ const perPage = Math.min(limit, MAX_PER_PAGE);
361
+ const events = [];
362
+ let path = buildAuditEventsPath(input, perPage);
363
+ while (path !== void 0 && events.length < limit) {
364
+ const body = parseJson(await curl(path));
365
+ for (const resource of asArray(asRecord(body)["resources"])) {
366
+ events.push(mapAuditEvent(resource));
367
+ if (events.length >= limit) {
368
+ break;
369
+ }
370
+ }
371
+ path = events.length < limit ? nextPagePath(body) : void 0;
372
+ }
373
+ return events;
374
+ }
375
+ async function fetchApp(appGuid, curl) {
376
+ const body = asRecord(parseJson(await curl(`/v3/apps/${appGuid}`)));
377
+ return {
378
+ guid: asString(body["guid"]),
379
+ name: asString(body["name"]),
380
+ state: asString(body["state"])
381
+ };
382
+ }
383
+ async function fetchSshEnabled(appGuid, curl) {
384
+ const body = asRecord(parseJson(await curl(`/v3/apps/${appGuid}/ssh_enabled`)));
385
+ return {
386
+ enabled: body["enabled"] === true,
387
+ reason: asString(body["reason"])
388
+ };
389
+ }
390
+ async function fetchWebProcessStats(appGuid, curl) {
391
+ const body = asRecord(parseJson(await curl(`/v3/apps/${appGuid}/processes/web/stats`)));
392
+ return asArray(body["resources"]).map(mapProcessStat);
393
+ }
394
+
395
+ // src/cf.ts
396
+ import { execFile } from "child_process";
397
+ import { mkdtemp, rm } from "fs/promises";
398
+ import { tmpdir } from "os";
399
+ import { join } from "path";
400
+ var CF_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
401
+ var CF_COMMAND_TIMEOUT_MS = 3e4;
402
+ var CF_RETRY_MAX_ATTEMPTS = 3;
403
+ var CF_RETRY_BASE_DELAY_MS = 120;
404
+ var GUID_PATTERN = /^[0-9a-f-]{16,}$/i;
405
+ async function withCfSession(work) {
406
+ const cfHomeDir = await mkdtemp(join(tmpdir(), "saptools-cf-events-"));
407
+ try {
408
+ return await work({ cfHomeDir });
409
+ } finally {
410
+ await rm(cfHomeDir, { recursive: true, force: true });
411
+ }
412
+ }
413
+ async function prepareCfCliSession(input, ctx) {
414
+ await runCfCommand(["api", input.apiEndpoint], ctx, "Failed to set the CF API endpoint.");
415
+ await runCfCommand(["auth"], ctx, "Failed to authenticate the Cloud Foundry CLI.", {
416
+ CF_USERNAME: input.credentials.email,
417
+ CF_PASSWORD: input.credentials.password
418
+ });
419
+ await runCfCommand(
420
+ ["target", "-o", input.orgName, "-s", input.spaceName],
421
+ ctx,
422
+ "Failed to target the CF org and space."
423
+ );
424
+ }
425
+ async function cfAppGuid(appName, ctx) {
426
+ const stdout = await runCfCommand(
427
+ ["app", appName, "--guid"],
428
+ ctx,
429
+ `Failed to resolve the GUID for app "${appName}".`
430
+ );
431
+ const guid = stdout.trim();
432
+ if (!GUID_PATTERN.test(guid)) {
433
+ throw new Error(
434
+ `Could not resolve the GUID for app "${appName}" - the CF CLI returned an unexpected value.`
435
+ );
436
+ }
437
+ return guid;
438
+ }
439
+ async function cfCurl(path, ctx) {
440
+ return await runCfCommand(["curl", path], ctx, `Failed to call the CF API path "${path}".`);
441
+ }
442
+ async function runCfCommand(args, ctx, failureMessage, envOverrides = {}) {
443
+ const command = resolveCommand();
444
+ const env = buildEnv(ctx.cfHomeDir, envOverrides);
445
+ let lastError;
446
+ for (let attempt = 1; attempt <= CF_RETRY_MAX_ATTEMPTS; attempt += 1) {
447
+ try {
448
+ return await execFileWithResult(command.bin, [...command.argsPrefix, ...args], env);
449
+ } catch (error) {
450
+ lastError = error;
451
+ if (attempt >= CF_RETRY_MAX_ATTEMPTS || !shouldRetryCfCliError(error)) {
452
+ break;
453
+ }
454
+ await sleep(resolveRetryDelayMs(attempt));
455
+ }
456
+ }
457
+ const detail = extractSafeCliDetail(lastError);
458
+ throw new Error(detail.length > 0 ? `${failureMessage} ${detail}` : failureMessage, {
459
+ cause: lastError
460
+ });
461
+ }
462
+ function resolveCommand() {
463
+ const bin = process.env["CF_EVENTS_CF_BIN"] ?? "cf";
464
+ if (/\.(?:c|m)?js$/i.test(bin)) {
465
+ return { bin: process.execPath, argsPrefix: [bin] };
466
+ }
467
+ return { bin, argsPrefix: [] };
468
+ }
469
+ function buildEnv(cfHomeDir, overrides) {
470
+ const env = { ...process.env };
471
+ delete env["SAP_EMAIL"];
472
+ delete env["SAP_PASSWORD"];
473
+ env["CF_HOME"] = cfHomeDir;
474
+ Object.assign(env, overrides);
475
+ return env;
476
+ }
477
+ async function execFileWithResult(command, args, env) {
478
+ return await new Promise((resolvePromise, rejectPromise) => {
479
+ execFile(
480
+ command,
481
+ [...args],
482
+ { env, maxBuffer: CF_MAX_BUFFER_BYTES, timeout: CF_COMMAND_TIMEOUT_MS },
483
+ (error, stdout, stderr) => {
484
+ if (error !== null) {
485
+ rejectPromise(Object.assign(error, { stdout, stderr }));
486
+ return;
487
+ }
488
+ resolvePromise(stdout);
489
+ }
490
+ );
491
+ });
492
+ }
493
+ function shouldRetryCfCliError(error) {
494
+ const haystack = `${readErrorStderr(error)} ${readErrorMessage(error)}`.toLowerCase();
495
+ if (haystack.includes("invalid") || haystack.includes("credentials were rejected") || haystack.includes("authentication failed") || haystack.includes("not authorized") || haystack.includes("forbidden")) {
496
+ return false;
497
+ }
498
+ return haystack.includes("timeout") || haystack.includes("timed out") || haystack.includes("connection reset") || haystack.includes("temporarily unavailable") || haystack.includes("network") || haystack.includes("econnreset") || haystack.includes("econnrefused");
499
+ }
500
+ function readErrorStderr(error) {
501
+ if (typeof error !== "object" || error === null) {
502
+ return "";
503
+ }
504
+ const stderr = error.stderr;
505
+ return typeof stderr === "string" ? stderr : "";
506
+ }
507
+ function readErrorMessage(error) {
508
+ return error instanceof Error ? error.message : "";
509
+ }
510
+ function resolveRetryDelayMs(attempt) {
511
+ const jitter = Math.floor(Math.random() * CF_RETRY_BASE_DELAY_MS);
512
+ return CF_RETRY_BASE_DELAY_MS * attempt + jitter;
513
+ }
514
+ async function sleep(delayMs) {
515
+ await new Promise((resolvePromise) => {
516
+ setTimeout(resolvePromise, delayMs);
517
+ });
518
+ }
519
+ function extractSafeCliDetail(error) {
520
+ const stderr = readErrorStderr(error);
521
+ if (stderr.length === 0) {
522
+ return "";
523
+ }
524
+ const cleaned = stderr.replaceAll(/\s+/g, " ").trim();
525
+ return cleaned.length > 0 ? `(cli: ${cleaned.slice(0, 180)})` : "";
526
+ }
527
+
528
+ // src/selector.ts
529
+ import { readStructure } from "@saptools/cf-sync";
530
+ var SELECTOR_USAGE = 'use "region/org/space/app" or a bare app name';
531
+ function parseSelector(raw) {
532
+ const trimmed = raw.trim();
533
+ if (trimmed.length === 0) {
534
+ throw new Error(`A selector is required: ${SELECTOR_USAGE}.`);
535
+ }
536
+ const parts = trimmed.split("/").map((part) => part.trim());
537
+ if (parts.length === 1) {
538
+ const appName = parts[0] ?? "";
539
+ if (appName.length === 0) {
540
+ throw new Error(`A selector is required: ${SELECTOR_USAGE}.`);
541
+ }
542
+ return { kind: "appName", appName };
543
+ }
544
+ if (parts.length === 4) {
545
+ const [regionKey, orgName, spaceName, appName] = parts;
546
+ if (regionKey === void 0 || orgName === void 0 || spaceName === void 0 || appName === void 0 || regionKey.length === 0 || orgName.length === 0 || spaceName.length === 0 || appName.length === 0) {
547
+ throw new Error(`Invalid selector "${raw}": every region/org/space/app segment must be non-empty.`);
548
+ }
549
+ return { kind: "explicit", regionKey, orgName, spaceName, appName };
550
+ }
551
+ throw new Error(`Invalid selector "${raw}": ${SELECTOR_USAGE}.`);
552
+ }
553
+ function resolveExplicit(raw, parsed, structure) {
554
+ const region = structure.regions.find((entry) => entry.key === parsed.regionKey);
555
+ if (region === void 0) {
556
+ throw new Error(
557
+ `Region "${parsed.regionKey}" is not in the CF topology snapshot. Run \`cf-sync sync\` first (or \`cf-sync regions\` to list valid region keys).`
558
+ );
559
+ }
560
+ const org = region.orgs.find((entry) => entry.name === parsed.orgName);
561
+ if (org === void 0) {
562
+ throw new Error(
563
+ `Org "${parsed.orgName}" was not found in region ${parsed.regionKey}. Run \`cf-sync orgs ${parsed.regionKey}\` to refresh it.`
564
+ );
565
+ }
566
+ const space = org.spaces.find((entry) => entry.name === parsed.spaceName);
567
+ if (space === void 0) {
568
+ throw new Error(
569
+ `Space "${parsed.spaceName}" was not found in ${parsed.regionKey}/${parsed.orgName}. Run \`cf-sync org ${parsed.regionKey} ${parsed.orgName}\` to refresh it.`
570
+ );
571
+ }
572
+ const app = space.apps.find((entry) => entry.name === parsed.appName);
573
+ if (app === void 0) {
574
+ throw new Error(
575
+ `App "${parsed.appName}" was not found in ${parsed.regionKey}/${parsed.orgName}/${parsed.spaceName}. Run \`cf-sync space ${parsed.regionKey} ${parsed.orgName} ${parsed.spaceName}\` to refresh it.`
576
+ );
577
+ }
578
+ return {
579
+ raw,
580
+ regionKey: region.key,
581
+ apiEndpoint: region.apiEndpoint,
582
+ orgName: org.name,
583
+ spaceName: space.name,
584
+ appName: app.name
585
+ };
586
+ }
587
+ function resolveByAppName(raw, appName, structure) {
588
+ const matches = [];
589
+ for (const region of structure.regions) {
590
+ for (const org of region.orgs) {
591
+ for (const space of org.spaces) {
592
+ for (const app of space.apps) {
593
+ if (app.name === appName) {
594
+ matches.push({
595
+ raw,
596
+ regionKey: region.key,
597
+ apiEndpoint: region.apiEndpoint,
598
+ orgName: org.name,
599
+ spaceName: space.name,
600
+ appName: app.name
601
+ });
602
+ }
603
+ }
604
+ }
605
+ }
606
+ }
607
+ if (matches.length === 0) {
608
+ throw new Error(
609
+ `App "${appName}" was not found in the CF topology snapshot. Run \`cf-sync sync\` first, or pass a full region/org/space/app selector.`
610
+ );
611
+ }
612
+ if (matches.length > 1) {
613
+ const candidates = matches.map((match) => ` ${match.regionKey}/${match.orgName}/${match.spaceName}/${match.appName}`).join("\n");
614
+ throw new Error(
615
+ `App "${appName}" is ambiguous - it exists in multiple spaces:
616
+ ${candidates}
617
+ Pass a full region/org/space/app selector to disambiguate.`
618
+ );
619
+ }
620
+ const onlyMatch = matches[0];
621
+ if (onlyMatch === void 0) {
622
+ throw new Error(`App "${appName}" could not be resolved.`);
623
+ }
624
+ return onlyMatch;
625
+ }
626
+ async function resolveSelector(raw) {
627
+ const parsed = parseSelector(raw);
628
+ const structure = await readStructure();
629
+ if (structure === void 0) {
630
+ throw new Error(
631
+ "No CF topology snapshot found. Run `cf-sync sync` (or `cf-sync space ...`) first."
632
+ );
633
+ }
634
+ if (parsed.kind === "explicit") {
635
+ return resolveExplicit(raw, parsed, structure);
636
+ }
637
+ return resolveByAppName(raw, parsed.appName, structure);
638
+ }
639
+
640
+ // src/ssh.ts
641
+ var SSH_AUTHORIZED_TYPE = "audit.app.ssh-authorized";
642
+ var SSH_DENIED_TYPE = "audit.app.ssh-unauthorized";
643
+ var LIKELY_ACTIVE_WINDOW_MS = 60 * 60 * 1e3;
644
+ function isWithinActiveWindow(timestamp, now) {
645
+ const at = Date.parse(timestamp);
646
+ if (Number.isNaN(at)) {
647
+ return false;
648
+ }
649
+ const delta = now.getTime() - at;
650
+ return delta >= 0 && delta <= LIKELY_ACTIVE_WINDOW_MS;
651
+ }
652
+ function inferSshSessions(events, now) {
653
+ return events.filter((event) => event.type === SSH_AUTHORIZED_TYPE).map((event) => ({
654
+ actor: event.actor.name.length > 0 ? event.actor.name : event.actor.guid,
655
+ authorizedAt: event.createdAt,
656
+ likelyActive: isWithinActiveWindow(event.createdAt, now)
657
+ })).sort((left, right) => right.authorizedAt.localeCompare(left.authorizedAt));
658
+ }
659
+ function buildSshStatus(input) {
660
+ const sshEvents = input.events.filter(isSshEvent);
661
+ const sessions = inferSshSessions(sshEvents, input.now);
662
+ const deniedAttempts = sshEvents.filter((event) => event.type === SSH_DENIED_TYPE).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
663
+ return {
664
+ appName: input.appName,
665
+ sshEnabled: input.sshEnabled,
666
+ sshReason: input.sshReason,
667
+ sessions,
668
+ deniedAttempts,
669
+ activeSessionCount: sessions.filter((session) => session.likelyActive).length
670
+ };
671
+ }
672
+
673
+ // src/runtime.ts
674
+ var SSH_STATUS_EVENT_LIMIT = 200;
675
+ var WATCH_FETCH_LIMIT = 100;
676
+ function createCfClient(curl) {
677
+ return {
678
+ fetchAuditEvents: (input) => fetchAuditEvents(input, curl),
679
+ fetchApp: (appGuid) => fetchApp(appGuid, curl),
680
+ fetchSshEnabled: (appGuid) => fetchSshEnabled(appGuid, curl),
681
+ fetchWebProcessStats: (appGuid) => fetchWebProcessStats(appGuid, curl)
682
+ };
683
+ }
684
+ async function defaultWithCfApp(selector, credentials, work) {
685
+ return await withCfSession(async (ctx) => {
686
+ await prepareCfCliSession(
687
+ {
688
+ apiEndpoint: selector.apiEndpoint,
689
+ orgName: selector.orgName,
690
+ spaceName: selector.spaceName,
691
+ credentials
692
+ },
693
+ ctx
694
+ );
695
+ const appGuid = await cfAppGuid(selector.appName, ctx);
696
+ const client = createCfClient((path) => cfCurl(path, ctx));
697
+ return await work({ appGuid, client });
698
+ });
699
+ }
700
+ function createDefaultDependencies() {
701
+ return {
702
+ resolveSelector,
703
+ withCfApp: defaultWithCfApp,
704
+ now: () => /* @__PURE__ */ new Date()
705
+ };
706
+ }
707
+ async function delay(ms, signal) {
708
+ try {
709
+ await delayPromise(ms, void 0, { signal });
710
+ } catch {
711
+ }
712
+ }
713
+ var CfEventsRuntime = class {
714
+ deps;
715
+ constructor(deps = createDefaultDependencies()) {
716
+ this.deps = deps;
717
+ }
718
+ async fetchEvents(raw, credentials, options) {
719
+ const selector = await this.deps.resolveSelector(raw);
720
+ return await this.deps.withCfApp(selector, credentials, async ({ appGuid, client }) => {
721
+ return await client.fetchAuditEvents({
722
+ appGuid,
723
+ types: options.types.length > 0 ? options.types : void 0,
724
+ createdAfter: this.resolveCreatedAfter(options.since),
725
+ limit: options.limit
726
+ });
727
+ });
728
+ }
729
+ async getSshStatus(raw, credentials, since) {
730
+ const selector = await this.deps.resolveSelector(raw);
731
+ return await this.deps.withCfApp(selector, credentials, async ({ appGuid, client }) => {
732
+ const [sshEnabled, events] = await Promise.all([
733
+ client.fetchSshEnabled(appGuid),
734
+ client.fetchAuditEvents({
735
+ appGuid,
736
+ types: [...SSH_EVENT_TYPES],
737
+ createdAfter: durationToCreatedAfter(since, this.deps.now()),
738
+ limit: SSH_STATUS_EVENT_LIMIT
739
+ })
740
+ ]);
741
+ return buildSshStatus({
742
+ appName: selector.appName,
743
+ sshEnabled: sshEnabled.enabled,
744
+ sshReason: sshEnabled.reason,
745
+ events,
746
+ now: this.deps.now()
747
+ });
748
+ });
749
+ }
750
+ async getCrashes(raw, credentials, options) {
751
+ const selector = await this.deps.resolveSelector(raw);
752
+ return await this.deps.withCfApp(selector, credentials, async ({ appGuid, client }) => {
753
+ const events = await client.fetchAuditEvents({
754
+ appGuid,
755
+ types: [...CRASH_EVENT_TYPES],
756
+ createdAfter: this.resolveCreatedAfter(options.since),
757
+ limit: options.limit
758
+ });
759
+ return summarizeCrashes(selector.appName, events);
760
+ });
761
+ }
762
+ async getStatus(raw, credentials) {
763
+ const selector = await this.deps.resolveSelector(raw);
764
+ return await this.deps.withCfApp(selector, credentials, async ({ appGuid, client }) => {
765
+ const [app, instances, sshEnabled, recent] = await Promise.all([
766
+ client.fetchApp(appGuid),
767
+ client.fetchWebProcessStats(appGuid),
768
+ client.fetchSshEnabled(appGuid),
769
+ client.fetchAuditEvents({ appGuid, types: void 0, createdAfter: void 0, limit: 1 })
770
+ ]);
771
+ return {
772
+ appName: selector.appName,
773
+ appGuid,
774
+ requestedState: app.state,
775
+ sshEnabled: sshEnabled.enabled,
776
+ instances,
777
+ lastEvent: recent[0]
778
+ };
779
+ });
780
+ }
781
+ async watchEvents(raw, credentials, options, onEvent, signal) {
782
+ const selector = await this.deps.resolveSelector(raw);
783
+ await this.deps.withCfApp(selector, credentials, async ({ appGuid, client }) => {
784
+ const seen = /* @__PURE__ */ new Set();
785
+ let cursor = durationToCreatedAfter(options.lookback, this.deps.now());
786
+ while (!signal.aborted) {
787
+ const events = await client.fetchAuditEvents({
788
+ appGuid,
789
+ types: options.types.length > 0 ? options.types : void 0,
790
+ createdAfter: cursor,
791
+ limit: WATCH_FETCH_LIMIT
792
+ });
793
+ const fresh = [];
794
+ for (const event of events) {
795
+ if (!seen.has(event.guid)) {
796
+ seen.add(event.guid);
797
+ fresh.push(event);
798
+ }
799
+ }
800
+ for (const event of [...fresh].reverse()) {
801
+ onEvent(event);
802
+ }
803
+ const newest = events[0];
804
+ if (newest !== void 0 && newest.createdAt.length > 0) {
805
+ cursor = newest.createdAt;
806
+ }
807
+ await delay(options.intervalMs, signal);
808
+ }
809
+ });
810
+ }
811
+ resolveCreatedAfter(since) {
812
+ return since === void 0 ? void 0 : durationToCreatedAfter(since, this.deps.now());
813
+ }
814
+ };
815
+
816
+ // src/cli.ts
817
+ var DEFAULT_EVENT_LIMIT = 50;
818
+ var DEFAULT_CRASH_LIMIT = 50;
819
+ var DEFAULT_SSH_SINCE = "24h";
820
+ var DEFAULT_WATCH_INTERVAL_MS = 15e3;
821
+ var DEFAULT_WATCH_LOOKBACK = "2m";
822
+ var MIN_WATCH_INTERVAL_MS = 2e3;
823
+ function resolveCredential(value, envName, optionName) {
824
+ const direct = value?.trim();
825
+ if (direct !== void 0 && direct.length > 0) {
826
+ return direct;
827
+ }
828
+ const fromEnv = process2.env[envName]?.trim();
829
+ if (fromEnv !== void 0 && fromEnv.length > 0) {
830
+ return fromEnv;
831
+ }
832
+ throw new Error(`Missing SAP credentials. Pass --${optionName} or set the ${envName} environment variable.`);
833
+ }
834
+ function buildCredentials(flags) {
835
+ return {
836
+ email: resolveCredential(flags.email, "SAP_EMAIL", "email"),
837
+ password: resolveCredential(flags.password, "SAP_PASSWORD", "password")
838
+ };
839
+ }
840
+ function parsePositiveInteger(value, optionName) {
841
+ const parsed = Number.parseInt(value, 10);
842
+ if (!Number.isInteger(parsed) || parsed <= 0) {
843
+ throw new Error(`${optionName} must be a positive integer.`);
844
+ }
845
+ return parsed;
846
+ }
847
+ function appLabel(selector) {
848
+ return (selector.split("/").at(-1) ?? selector).trim();
849
+ }
850
+ async function resolveSelectorArgument(selector) {
851
+ if (selector.includes("/")) {
852
+ return selector;
853
+ }
854
+ const current = await readCurrentCfTarget(currentCfContext()).catch((error) => {
855
+ throw new Error(
856
+ "No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector.",
857
+ { cause: error }
858
+ );
859
+ });
860
+ if (current === void 0) {
861
+ throw new Error(
862
+ "No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector."
863
+ );
864
+ }
865
+ return formatCurrentCfAppSelector(current, selector);
866
+ }
867
+ function currentCfContext() {
868
+ const command = process2.env["CF_EVENTS_CF_BIN"];
869
+ return command === void 0 ? void 0 : { command };
870
+ }
871
+ function writeOut(text) {
872
+ process2.stdout.write(text.endsWith("\n") ? text : `${text}
873
+ `);
874
+ }
875
+ function writeJson(value) {
876
+ process2.stdout.write(`${JSON.stringify(value, null, 2)}
877
+ `);
878
+ }
879
+ function writeJsonLine(value) {
880
+ process2.stdout.write(`${JSON.stringify(value)}
881
+ `);
882
+ }
883
+ async function runEvents(selector, flags) {
884
+ const resolvedSelector = await resolveSelectorArgument(selector);
885
+ const runtime = new CfEventsRuntime();
886
+ const events = await runtime.fetchEvents(resolvedSelector, buildCredentials(flags), {
887
+ limit: flags.limit ?? DEFAULT_EVENT_LIMIT,
888
+ since: flags.since,
889
+ types: parseTypeFilter(flags.type)
890
+ });
891
+ if (flags.json === true) {
892
+ writeJson(events);
893
+ return;
894
+ }
895
+ writeOut(formatEventsReport(appLabel(resolvedSelector), events, /* @__PURE__ */ new Date()));
896
+ }
897
+ async function runSshStatus(selector, flags) {
898
+ const resolvedSelector = await resolveSelectorArgument(selector);
899
+ const runtime = new CfEventsRuntime();
900
+ const status = await runtime.getSshStatus(
901
+ resolvedSelector,
902
+ buildCredentials(flags),
903
+ flags.since ?? DEFAULT_SSH_SINCE
904
+ );
905
+ if (flags.json === true) {
906
+ writeJson(status);
907
+ return;
908
+ }
909
+ writeOut(formatSshStatusReport(status, /* @__PURE__ */ new Date()));
910
+ }
911
+ async function runCrashes(selector, flags) {
912
+ const resolvedSelector = await resolveSelectorArgument(selector);
913
+ const runtime = new CfEventsRuntime();
914
+ const summary = await runtime.getCrashes(resolvedSelector, buildCredentials(flags), {
915
+ limit: flags.limit ?? DEFAULT_CRASH_LIMIT,
916
+ since: flags.since
917
+ });
918
+ if (flags.json === true) {
919
+ writeJson(summary);
920
+ return;
921
+ }
922
+ writeOut(formatCrashReport(summary, /* @__PURE__ */ new Date()));
923
+ }
924
+ async function runStatus(selector, flags) {
925
+ const resolvedSelector = await resolveSelectorArgument(selector);
926
+ const runtime = new CfEventsRuntime();
927
+ const health = await runtime.getStatus(resolvedSelector, buildCredentials(flags));
928
+ if (flags.json === true) {
929
+ writeJson(health);
930
+ return;
931
+ }
932
+ writeOut(formatStatusReport(health, /* @__PURE__ */ new Date()));
933
+ }
934
+ function emitWatchEvent(event, asJson) {
935
+ if (asJson) {
936
+ writeJsonLine(event);
937
+ return;
938
+ }
939
+ writeOut(formatEventLine(event, /* @__PURE__ */ new Date()));
940
+ }
941
+ async function runWatch(selector, flags) {
942
+ const resolvedSelector = await resolveSelectorArgument(selector);
943
+ const interval = flags.interval ?? DEFAULT_WATCH_INTERVAL_MS;
944
+ if (interval < MIN_WATCH_INTERVAL_MS) {
945
+ throw new Error(`--interval must be at least ${MIN_WATCH_INTERVAL_MS.toString()}ms.`);
946
+ }
947
+ const runtime = new CfEventsRuntime();
948
+ const controller = new AbortController();
949
+ const cleanup = bindTerminationSignals(() => {
950
+ controller.abort();
951
+ });
952
+ const asJson = flags.json === true;
953
+ try {
954
+ process2.stderr.write(`Watching ${resolvedSelector} for new audit events. Press Ctrl+C to stop.
955
+ `);
956
+ await runtime.watchEvents(
957
+ resolvedSelector,
958
+ buildCredentials(flags),
959
+ {
960
+ intervalMs: interval,
961
+ lookback: flags.lookback ?? DEFAULT_WATCH_LOOKBACK,
962
+ types: parseTypeFilter(flags.type)
963
+ },
964
+ (event) => {
965
+ emitWatchEvent(event, asJson);
966
+ },
967
+ controller.signal
968
+ );
969
+ } finally {
970
+ cleanup();
971
+ }
972
+ }
973
+ function bindTerminationSignals(stop) {
974
+ const handler = () => {
975
+ stop();
976
+ };
977
+ process2.once("SIGINT", handler);
978
+ process2.once("SIGTERM", handler);
979
+ return () => {
980
+ process2.off("SIGINT", handler);
981
+ process2.off("SIGTERM", handler);
982
+ };
983
+ }
984
+ function addCommonOptions(command) {
985
+ return command.option("--email <value>", "SAP email (default: SAP_EMAIL env var)").option("--password <value>", "SAP password (default: SAP_PASSWORD env var)").option("--json", "Emit structured JSON instead of text", false);
986
+ }
987
+ function readPackageVersion() {
988
+ try {
989
+ const here = dirname(fileURLToPath(import.meta.url));
990
+ const raw = readFileSync(join2(here, "..", "package.json"), "utf8");
991
+ const parsed = JSON.parse(raw);
992
+ return typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : "0.0.0";
993
+ } catch {
994
+ return "0.0.0";
995
+ }
996
+ }
997
+ function suppressBrokenPipe() {
998
+ for (const stream of [process2.stdout, process2.stderr]) {
999
+ stream.on("error", (error) => {
1000
+ if (error.code === "EPIPE") {
1001
+ process2.exit(0);
1002
+ }
1003
+ throw error;
1004
+ });
1005
+ }
1006
+ }
1007
+ function buildProgram() {
1008
+ const program = new Command();
1009
+ program.name("cf-events").description("Inspect SAP BTP Cloud Foundry application audit events and SSH/debug sessions").version(readPackageVersion(), "-V, --version", "Print the cf-events package version");
1010
+ addCommonOptions(
1011
+ program.command("events").description("List recent audit events for an app").argument("<selector>", "region/org/space/app or a bare app name")
1012
+ ).option(
1013
+ "--limit <count>",
1014
+ "Maximum number of events to return",
1015
+ (value) => parsePositiveInteger(value, "--limit")
1016
+ ).option("--since <duration>", "Only events newer than a duration (e.g. 30m, 6h, 7d)").option("--type <types>", 'Comma-separated CF event types, or the shorthand "ssh"/"crash"').action(async (selector, flags) => {
1017
+ await runEvents(selector, flags);
1018
+ });
1019
+ addCommonOptions(
1020
+ program.command("ssh-status").description("Show whether SSH is enabled and detect recent SSH/debug sessions").argument("<selector>", "region/org/space/app or a bare app name")
1021
+ ).option("--since <duration>", "Look-back window for SSH events", DEFAULT_SSH_SINCE).action(async (selector, flags) => {
1022
+ await runSshStatus(selector, flags);
1023
+ });
1024
+ addCommonOptions(
1025
+ program.command("crashes").description("Summarize recent crash events for an app").argument("<selector>", "region/org/space/app or a bare app name")
1026
+ ).option(
1027
+ "--limit <count>",
1028
+ "Maximum number of crash events to inspect",
1029
+ (value) => parsePositiveInteger(value, "--limit")
1030
+ ).option("--since <duration>", "Only crashes newer than a duration (e.g. 30m, 6h, 7d)").action(async (selector, flags) => {
1031
+ await runCrashes(selector, flags);
1032
+ });
1033
+ addCommonOptions(
1034
+ program.command("status").description("Show app health: requested state, instances, SSH flag, and last event").argument("<selector>", "region/org/space/app or a bare app name")
1035
+ ).action(async (selector, flags) => {
1036
+ await runStatus(selector, flags);
1037
+ });
1038
+ addCommonOptions(
1039
+ program.command("watch").description("Poll for new audit events and print them as they appear").argument("<selector>", "region/org/space/app or a bare app name")
1040
+ ).option(
1041
+ "--interval <ms>",
1042
+ "Polling interval in milliseconds",
1043
+ (value) => parsePositiveInteger(value, "--interval")
1044
+ ).option("--lookback <duration>", "Initial look-back window on start (e.g. 2m)", DEFAULT_WATCH_LOOKBACK).option("--type <types>", 'Comma-separated CF event types, or the shorthand "ssh"/"crash"').action(async (selector, flags) => {
1045
+ await runWatch(selector, flags);
1046
+ });
1047
+ return program;
1048
+ }
1049
+ async function main(argv) {
1050
+ const program = buildProgram();
1051
+ await program.parseAsync([...argv]);
1052
+ }
1053
+ function isMainModule() {
1054
+ const executedPath = process2.argv[1];
1055
+ if (executedPath === void 0) {
1056
+ return false;
1057
+ }
1058
+ try {
1059
+ return realpathSync(executedPath) === fileURLToPath(import.meta.url);
1060
+ } catch {
1061
+ return resolve(executedPath) === fileURLToPath(import.meta.url);
1062
+ }
1063
+ }
1064
+ async function runCli() {
1065
+ suppressBrokenPipe();
1066
+ try {
1067
+ await main(process2.argv);
1068
+ } catch (error) {
1069
+ const message = error instanceof Error ? error.message : String(error);
1070
+ process2.stderr.write(`Error: ${message}
1071
+ `);
1072
+ process2.exit(1);
1073
+ }
1074
+ }
1075
+ if (isMainModule()) {
1076
+ await runCli();
1077
+ }
1078
+ export {
1079
+ main
1080
+ };
1081
+ //# sourceMappingURL=cli.js.map