@f5xc-salesdemos/xcsh 18.13.0 → 18.14.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.
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.14.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
49
49
|
"@mozilla/readability": "^0.6",
|
|
50
|
-
"@f5xc-salesdemos/xcsh-stats": "18.
|
|
51
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
52
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
53
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
54
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
50
|
+
"@f5xc-salesdemos/xcsh-stats": "18.14.0",
|
|
51
|
+
"@f5xc-salesdemos/pi-agent-core": "18.14.0",
|
|
52
|
+
"@f5xc-salesdemos/pi-ai": "18.14.0",
|
|
53
|
+
"@f5xc-salesdemos/pi-natives": "18.14.0",
|
|
54
|
+
"@f5xc-salesdemos/pi-tui": "18.14.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-utils": "18.14.0",
|
|
56
56
|
"@sinclair/typebox": "^0.34",
|
|
57
57
|
"@xterm/headless": "^6.0",
|
|
58
58
|
"ajv": "^8.18",
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "18.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.14.0",
|
|
21
|
+
"commit": "f1b1d44fd6a0481b3745e233a70756df5d45f335",
|
|
22
|
+
"shortCommit": "f1b1d44",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-04-
|
|
26
|
-
"buildDate": "2026-04-
|
|
24
|
+
"tag": "v18.14.0",
|
|
25
|
+
"commitDate": "2026-04-23T21:05:32Z",
|
|
26
|
+
"buildDate": "2026-04-23T21:32:33.280Z",
|
|
27
27
|
"dirty": false,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
|
|
30
30
|
"repoSlug": "f5xc-salesdemos/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/f1b1d44fd6a0481b3745e233a70756df5d45f335",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.14.0"
|
|
33
33
|
};
|
|
@@ -11,7 +11,9 @@ const STARTUP_FIRST_TIMEOUT_MS = 4000;
|
|
|
11
11
|
const STARTUP_RETRY_TIMEOUT_MS = 5000;
|
|
12
12
|
const STARTUP_RETRY_DELAY_MS = 500;
|
|
13
13
|
|
|
14
|
-
type ProfileValidator = (opts: {
|
|
14
|
+
type ProfileValidator = (opts: {
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
}) => Promise<{ status: AuthStatus; latencyMs?: number; errorClass?: "network" | "credential" }>;
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Runs the profile validator once with a startup-sized timeout; if the result is `offline`
|
|
@@ -8,8 +8,14 @@ import {
|
|
|
8
8
|
F5XC_TENANT,
|
|
9
9
|
F5XC_USERNAME,
|
|
10
10
|
} from "./f5xc-env";
|
|
11
|
-
import { ProfileError, ProfileService } from "./f5xc-profile";
|
|
12
|
-
import {
|
|
11
|
+
import { CURRENT_SCHEMA_VERSION, ProfileError, ProfileService } from "./f5xc-profile";
|
|
12
|
+
import {
|
|
13
|
+
formatAuthIndicator,
|
|
14
|
+
formatExpiration,
|
|
15
|
+
formatRelativeTime,
|
|
16
|
+
renderF5XCTable,
|
|
17
|
+
type TableRow,
|
|
18
|
+
} from "./f5xc-table";
|
|
13
19
|
|
|
14
20
|
interface CommandContext {
|
|
15
21
|
showStatus(msg: string): void;
|
|
@@ -81,14 +87,16 @@ async function handleList(ctx: CommandContext, service: ProfileService): Promise
|
|
|
81
87
|
const status = service.getStatus();
|
|
82
88
|
const lines = profiles.map(p => {
|
|
83
89
|
const marker = p.name === status.activeProfileName ? "*" : " ";
|
|
84
|
-
|
|
90
|
+
const versionSuffix =
|
|
91
|
+
p.version !== undefined && p.version > CURRENT_SCHEMA_VERSION ? ` (v${p.version} — upgrade required)` : "";
|
|
92
|
+
return ` ${marker} ${sanitize(p.name).padEnd(20)} ${sanitize(p.apiUrl)}${versionSuffix}`;
|
|
85
93
|
});
|
|
86
94
|
ctx.showStatus(lines.join("\n"));
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
async function handleActivate(ctx: CommandContext, service: ProfileService, name: string): Promise<void> {
|
|
90
98
|
if (!name) {
|
|
91
|
-
ctx.showError("Usage: /profile activate <name
|
|
99
|
+
ctx.showError("Usage: /profile activate <name>. Run `/profile list` to see available profiles.");
|
|
92
100
|
return;
|
|
93
101
|
}
|
|
94
102
|
try {
|
|
@@ -110,13 +118,15 @@ function isSensitiveKey(key: string): boolean {
|
|
|
110
118
|
async function handleShow(ctx: CommandContext, service: ProfileService, name?: string): Promise<void> {
|
|
111
119
|
const targetName = name || service.getStatus().activeProfileName;
|
|
112
120
|
if (!targetName) {
|
|
113
|
-
ctx.showError(
|
|
121
|
+
ctx.showError(
|
|
122
|
+
"No active profile. Run `/profile create <name>` to create one, or `/profile activate <name>` if profiles exist.",
|
|
123
|
+
);
|
|
114
124
|
return;
|
|
115
125
|
}
|
|
116
126
|
const profiles = await service.listProfiles();
|
|
117
127
|
const profile = profiles.find(p => p.name === targetName);
|
|
118
128
|
if (!profile) {
|
|
119
|
-
ctx.showError(`Profile '${targetName}' not found.`);
|
|
129
|
+
ctx.showError(`Profile '${targetName}' not found. Run \`/profile list\` to see available profiles.`);
|
|
120
130
|
return;
|
|
121
131
|
}
|
|
122
132
|
|
|
@@ -143,7 +153,7 @@ async function handleShow(ctx: CommandContext, service: ProfileService, name?: s
|
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
// Auth status indicator
|
|
146
|
-
rows.push({ key: "Status", value: formatAuthIndicator(auth.status, auth.latencyMs) });
|
|
156
|
+
rows.push({ key: "Status", value: formatAuthIndicator(auth.status, auth.latencyMs, auth.errorClass) });
|
|
147
157
|
|
|
148
158
|
// Track where environment section starts
|
|
149
159
|
const envDividerIndex = rows.length;
|
|
@@ -157,7 +167,28 @@ async function handleShow(ctx: CommandContext, service: ProfileService, name?: s
|
|
|
157
167
|
}
|
|
158
168
|
}
|
|
159
169
|
|
|
160
|
-
|
|
170
|
+
// Metadata section (only rendered when at least one field is present)
|
|
171
|
+
const metaRows: TableRow[] = [];
|
|
172
|
+
if (profile.metadata?.createdAt) {
|
|
173
|
+
metaRows.push({ key: "Created", value: formatRelativeTime(profile.metadata.createdAt) });
|
|
174
|
+
}
|
|
175
|
+
if (profile.metadata?.expiresAt) {
|
|
176
|
+
metaRows.push({ key: "Expires", value: formatExpiration(profile.metadata.expiresAt) });
|
|
177
|
+
}
|
|
178
|
+
if (profile.metadata?.lastRotatedAt) {
|
|
179
|
+
metaRows.push({ key: "Last Rotated", value: formatRelativeTime(profile.metadata.lastRotatedAt) });
|
|
180
|
+
}
|
|
181
|
+
if (profile.metadata?.rotateAfterDays) {
|
|
182
|
+
metaRows.push({ key: "Rotation", value: `every ${profile.metadata.rotateAfterDays} days` });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const dividers: Array<{ before: number; label: string }> = [{ before: envDividerIndex, label: "Environment" }];
|
|
186
|
+
if (metaRows.length > 0) {
|
|
187
|
+
dividers.push({ before: rows.length, label: "Metadata" });
|
|
188
|
+
rows.push(...metaRows);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
ctx.showStatus(renderF5XCTable(profile.name, rows, { dividers }));
|
|
161
192
|
}
|
|
162
193
|
|
|
163
194
|
async function handleStatus(ctx: CommandContext, service: ProfileService): Promise<void> {
|
|
@@ -172,7 +203,7 @@ async function handleStatus(ctx: CommandContext, service: ProfileService): Promi
|
|
|
172
203
|
{ key: "Source", value: status.credentialSource },
|
|
173
204
|
{ key: "API URL", value: status.activeProfileUrl ?? "(not set)" },
|
|
174
205
|
{ key: "Namespace", value: status.activeProfileNamespace ?? "(not set)" },
|
|
175
|
-
{ key: "Status", value: formatAuthIndicator(auth.status, auth.latencyMs) },
|
|
206
|
+
{ key: "Status", value: formatAuthIndicator(auth.status, auth.latencyMs, auth.errorClass) },
|
|
176
207
|
];
|
|
177
208
|
ctx.showStatus(renderF5XCTable(status.activeProfileName ?? "status", rows));
|
|
178
209
|
}
|
|
@@ -219,7 +250,7 @@ async function handleDelete(ctx: CommandContext, service: ProfileService, args:
|
|
|
219
250
|
}
|
|
220
251
|
const status = service.getStatus();
|
|
221
252
|
if (name === status.activeProfileName) {
|
|
222
|
-
ctx.showError("Cannot delete the active profile.
|
|
253
|
+
ctx.showError("Cannot delete the active profile. Run `/profile activate <other>` to switch first.");
|
|
223
254
|
return;
|
|
224
255
|
}
|
|
225
256
|
if (!confirmed) {
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
hasEnvOverride,
|
|
13
13
|
} from "./f5xc-env";
|
|
14
14
|
|
|
15
|
+
export const CURRENT_SCHEMA_VERSION = 1;
|
|
16
|
+
|
|
15
17
|
export interface F5XCProfile {
|
|
16
18
|
name: string;
|
|
17
19
|
apiUrl: string;
|
|
@@ -20,6 +22,7 @@ export interface F5XCProfile {
|
|
|
20
22
|
env?: Record<string, string>;
|
|
21
23
|
/** Env var names from `env` whose values should be masked in output (e.g. ["F5XC_USERNAME"]). */
|
|
22
24
|
sensitiveKeys?: string[];
|
|
25
|
+
version?: number;
|
|
23
26
|
metadata?: {
|
|
24
27
|
createdAt?: string;
|
|
25
28
|
expiresAt?: string;
|
|
@@ -38,7 +41,6 @@ export interface ProfileStatus {
|
|
|
38
41
|
credentialSource: "profile" | "environment" | "mixed" | "none";
|
|
39
42
|
authStatus: AuthStatus;
|
|
40
43
|
isConfigured: boolean;
|
|
41
|
-
watcherActive: boolean;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export class ProfileError extends Error {
|
|
@@ -173,6 +175,17 @@ export class ProfileService {
|
|
|
173
175
|
return null;
|
|
174
176
|
}
|
|
175
177
|
|
|
178
|
+
// Gate: incompatible schema version — log warning and return null (don't crash startup)
|
|
179
|
+
try {
|
|
180
|
+
this.#assertCompatibleVersion(profile);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
logger.warn("F5XC: profile uses incompatible schema version, skipping", {
|
|
183
|
+
name: profileName,
|
|
184
|
+
error: String(err),
|
|
185
|
+
});
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
176
189
|
// Only persist active_profile after the profile validates
|
|
177
190
|
if (autoActivated) {
|
|
178
191
|
this.#atomicWrite(this.activeProfilePath, profileName);
|
|
@@ -190,17 +203,18 @@ export class ProfileService {
|
|
|
190
203
|
// Reject activation when env overrides are present — would create mismatched credentials
|
|
191
204
|
if (process.env[F5XC_API_URL]) {
|
|
192
205
|
throw new ProfileError(
|
|
193
|
-
"Cannot activate
|
|
194
|
-
"Unset F5XC_API_URL first, or restart xcsh without it.",
|
|
206
|
+
"Cannot activate: F5XC_API_URL environment variable overrides profile. Run `unset F5XC_API_URL` first, or restart without it.",
|
|
195
207
|
);
|
|
196
208
|
}
|
|
197
209
|
|
|
198
210
|
this.#validateProfileName(name);
|
|
199
211
|
const profile = this.#readProfile(name);
|
|
200
212
|
if (!profile) {
|
|
201
|
-
throw new ProfileError(`Profile '${name}' not found.`, name);
|
|
213
|
+
throw new ProfileError(`Profile '${name}' not found. Run \`/profile list\` to see available profiles.`, name);
|
|
202
214
|
}
|
|
203
215
|
|
|
216
|
+
this.#assertCompatibleVersion(profile);
|
|
217
|
+
|
|
204
218
|
// NFR-402: write active_profile first — if it fails, don't update settings
|
|
205
219
|
this.#atomicWrite(this.activeProfilePath, name);
|
|
206
220
|
|
|
@@ -223,7 +237,7 @@ export class ProfileService {
|
|
|
223
237
|
return profiles;
|
|
224
238
|
}
|
|
225
239
|
|
|
226
|
-
async createProfile(profile: Omit<F5XCProfile, "metadata">): Promise<void> {
|
|
240
|
+
async createProfile(profile: Omit<F5XCProfile, "metadata" | "version">): Promise<void> {
|
|
227
241
|
this.#validateProfileName(profile.name);
|
|
228
242
|
const profilePath = path.join(this.profilesDir, `${profile.name}.json`);
|
|
229
243
|
if (fs.existsSync(profilePath)) {
|
|
@@ -233,6 +247,7 @@ export class ProfileService {
|
|
|
233
247
|
fs.mkdirSync(this.#configDir, { recursive: true, mode: 0o700 });
|
|
234
248
|
const data: F5XCProfile = {
|
|
235
249
|
...profile,
|
|
250
|
+
version: CURRENT_SCHEMA_VERSION,
|
|
236
251
|
metadata: { createdAt: new Date().toISOString() },
|
|
237
252
|
};
|
|
238
253
|
const tmpPath = `${profilePath}.tmp`;
|
|
@@ -256,6 +271,8 @@ export class ProfileService {
|
|
|
256
271
|
const profile = this.#readProfile(name);
|
|
257
272
|
if (!profile) throw new ProfileError(`Profile '${name}' not found.`, name);
|
|
258
273
|
|
|
274
|
+
this.#assertCompatibleVersion(profile);
|
|
275
|
+
|
|
259
276
|
const env = { ...(profile.env ?? {}), ...vars };
|
|
260
277
|
const sensitiveSet = new Set(profile.sensitiveKeys ?? []);
|
|
261
278
|
const newSensitive: string[] = [];
|
|
@@ -289,6 +306,8 @@ export class ProfileService {
|
|
|
289
306
|
const profile = this.#readProfile(name);
|
|
290
307
|
if (!profile) throw new ProfileError(`Profile '${name}' not found.`, name);
|
|
291
308
|
|
|
309
|
+
this.#assertCompatibleVersion(profile);
|
|
310
|
+
|
|
292
311
|
const env = { ...(profile.env ?? {}) };
|
|
293
312
|
const removed: string[] = [];
|
|
294
313
|
for (const key of keys) {
|
|
@@ -322,7 +341,7 @@ export class ProfileService {
|
|
|
322
341
|
timeoutMs?: number;
|
|
323
342
|
apiUrl?: string;
|
|
324
343
|
apiToken?: string;
|
|
325
|
-
}): Promise<{ status: AuthStatus; latencyMs?: number }> {
|
|
344
|
+
}): Promise<{ status: AuthStatus; latencyMs?: number; errorClass?: "network" | "credential" }> {
|
|
326
345
|
// Use explicit credentials if provided (for non-active profiles or env-backed sessions),
|
|
327
346
|
// otherwise fall back to effective credentials (env override > active profile)
|
|
328
347
|
const effectiveUrl = options?.apiUrl ?? process.env[F5XC_API_URL] ?? this.#activeProfile?.apiUrl;
|
|
@@ -344,19 +363,20 @@ export class ProfileService {
|
|
|
344
363
|
}
|
|
345
364
|
if (response.status === 401 || response.status === 403) {
|
|
346
365
|
this.#authStatus = "auth_error";
|
|
347
|
-
return { status: "auth_error", latencyMs };
|
|
366
|
+
return { status: "auth_error", latencyMs, errorClass: "credential" };
|
|
348
367
|
}
|
|
349
|
-
|
|
350
|
-
|
|
368
|
+
// 5xx, 429, etc. — server reachable but unhealthy; treat as offline so startup retry fires
|
|
369
|
+
this.#authStatus = "offline";
|
|
370
|
+
return { status: "offline", latencyMs, errorClass: "network" };
|
|
351
371
|
} catch {
|
|
352
372
|
this.#authStatus = "offline";
|
|
353
|
-
return { status: "offline" };
|
|
373
|
+
return { status: "offline", errorClass: "network" };
|
|
354
374
|
}
|
|
355
375
|
}
|
|
356
376
|
|
|
357
377
|
setNamespace(namespace: string): void {
|
|
358
378
|
if (!this.#activeProfile) {
|
|
359
|
-
throw new ProfileError("No active profile.
|
|
379
|
+
throw new ProfileError("No active profile. Run `/profile activate <name>` to select one.");
|
|
360
380
|
}
|
|
361
381
|
this.#activeProfile = { ...this.#activeProfile, defaultNamespace: namespace };
|
|
362
382
|
// Re-apply settings with the new namespace
|
|
@@ -375,7 +395,6 @@ export class ProfileService {
|
|
|
375
395
|
credentialSource: this.#credentialSource,
|
|
376
396
|
authStatus: this.#authStatus,
|
|
377
397
|
isConfigured: this.#credentialSource !== "none",
|
|
378
|
-
watcherActive: false,
|
|
379
398
|
};
|
|
380
399
|
}
|
|
381
400
|
|
|
@@ -401,6 +420,15 @@ export class ProfileService {
|
|
|
401
420
|
}
|
|
402
421
|
}
|
|
403
422
|
|
|
423
|
+
#assertCompatibleVersion(profile: F5XCProfile): void {
|
|
424
|
+
if (profile.version !== undefined && profile.version > CURRENT_SCHEMA_VERSION) {
|
|
425
|
+
throw new ProfileError(
|
|
426
|
+
`Profile '${profile.name}' uses schema version ${profile.version}, but this version of xcsh only supports version ${CURRENT_SCHEMA_VERSION}. Upgrade xcsh to use this profile, or run \`/profile create\` to create a new one.`,
|
|
427
|
+
profile.name,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
404
432
|
#readActiveProfileName(): string | null {
|
|
405
433
|
try {
|
|
406
434
|
if (!fs.existsSync(this.activeProfilePath)) return null;
|
|
@@ -468,6 +496,7 @@ export class ProfileService {
|
|
|
468
496
|
defaultNamespace: parsed.defaultNamespace ?? "default",
|
|
469
497
|
env,
|
|
470
498
|
sensitiveKeys,
|
|
499
|
+
version: typeof parsed.version === "number" ? parsed.version : undefined,
|
|
471
500
|
metadata: parsed.metadata,
|
|
472
501
|
};
|
|
473
502
|
} catch (err) {
|
|
@@ -20,27 +20,64 @@ const BOX = {
|
|
|
20
20
|
|
|
21
21
|
const r = (s: string) => `${F5_RED}${s}${RESET}`;
|
|
22
22
|
|
|
23
|
-
export function formatAuthIndicator(
|
|
23
|
+
export function formatAuthIndicator(
|
|
24
|
+
status: AuthStatus,
|
|
25
|
+
latencyMs?: number,
|
|
26
|
+
errorClass?: "network" | "credential",
|
|
27
|
+
): string {
|
|
24
28
|
const ms = latencyMs !== undefined ? ` (${latencyMs}ms)` : "";
|
|
25
29
|
switch (status) {
|
|
26
30
|
case "connected":
|
|
27
31
|
return `${formatStatusIcon("connected")} Connected${ms}`;
|
|
28
32
|
case "auth_error":
|
|
29
|
-
return `${formatStatusIcon("error")} Auth Error${ms}`;
|
|
33
|
+
return `${formatStatusIcon("error")} Auth Error — check token${ms}`;
|
|
30
34
|
case "offline":
|
|
31
|
-
return `${formatStatusIcon("warning")} Offline`;
|
|
35
|
+
return `${formatStatusIcon("warning")} Offline — ${errorClass === "credential" ? "auth issue" : "network issue"}${ms}`;
|
|
32
36
|
default:
|
|
33
37
|
return `${formatStatusIcon("unknown")} Unknown`;
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
export function formatRelativeTime(isoDate: string, now?: Date): string {
|
|
42
|
+
const nowMs = (now ?? new Date()).getTime();
|
|
43
|
+
const thenMs = new Date(isoDate).getTime();
|
|
44
|
+
const diffMs = nowMs - thenMs;
|
|
45
|
+
const absDiffMs = Math.abs(diffMs);
|
|
46
|
+
const minutes = Math.floor(absDiffMs / 60_000);
|
|
47
|
+
const hours = Math.floor(minutes / 60);
|
|
48
|
+
const days = Math.floor(hours / 24);
|
|
49
|
+
const months = Math.floor(days / 30);
|
|
50
|
+
|
|
51
|
+
if (months > 0) return `${months} month${months > 1 ? "s" : ""} ago`;
|
|
52
|
+
if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
|
|
53
|
+
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
|
|
54
|
+
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
|
|
55
|
+
return "just now";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatExpiration(isoDate: string, now?: Date): string {
|
|
59
|
+
const nowMs = (now ?? new Date()).getTime();
|
|
60
|
+
const expiresMs = new Date(isoDate).getTime();
|
|
61
|
+
const dateStr = isoDate.split("T")[0];
|
|
62
|
+
const diffDays = Math.ceil((expiresMs - nowMs) / 86_400_000);
|
|
63
|
+
|
|
64
|
+
if (diffDays < 0) {
|
|
65
|
+
const ago = Math.abs(diffDays);
|
|
66
|
+
return `${dateStr} ${formatStatusIcon("warning")} expired ${ago} day${ago > 1 ? "s" : ""} ago`;
|
|
67
|
+
}
|
|
68
|
+
if (diffDays <= 7) {
|
|
69
|
+
return `${dateStr} ${formatStatusIcon("warning")} expires in ${diffDays} day${diffDays !== 1 ? "s" : ""}`;
|
|
70
|
+
}
|
|
71
|
+
return dateStr;
|
|
72
|
+
}
|
|
73
|
+
|
|
37
74
|
export interface TableRow {
|
|
38
75
|
key: string;
|
|
39
76
|
value: string;
|
|
40
77
|
}
|
|
41
78
|
|
|
42
79
|
export interface TableOptions {
|
|
43
|
-
|
|
80
|
+
dividers?: Array<{ before: number; label: string }>;
|
|
44
81
|
}
|
|
45
82
|
|
|
46
83
|
// Measures the visible terminal column width of a string.
|
|
@@ -64,9 +101,10 @@ export function renderF5XCTable(title: string, rows: TableRow[], options?: Table
|
|
|
64
101
|
|
|
65
102
|
// Rows
|
|
66
103
|
for (let i = 0; i < rows.length; i++) {
|
|
67
|
-
// Optional
|
|
68
|
-
|
|
69
|
-
|
|
104
|
+
// Optional labeled dividers
|
|
105
|
+
const divider = options?.dividers?.find(d => d.before === i);
|
|
106
|
+
if (divider) {
|
|
107
|
+
const divLabel = ` ${divider.label} `;
|
|
70
108
|
const divPad = innerWidth - visibleWidth(divLabel) - 1;
|
|
71
109
|
lines.push(`${r(BOX.lt + BOX.h)}${BOLD}${divLabel}${RESET}${r(BOX.h.repeat(Math.max(0, divPad)) + BOX.rt)}`);
|
|
72
110
|
}
|