@f5xc-salesdemos/xcsh 18.16.0 → 18.17.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.17.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.17.0",
|
|
51
|
+
"@f5xc-salesdemos/pi-agent-core": "18.17.0",
|
|
52
|
+
"@f5xc-salesdemos/pi-ai": "18.17.0",
|
|
53
|
+
"@f5xc-salesdemos/pi-natives": "18.17.0",
|
|
54
|
+
"@f5xc-salesdemos/pi-tui": "18.17.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-utils": "18.17.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.17.0",
|
|
21
|
+
"commit": "fa5128a31943aaf4e91dc6892a0e74efd1dfba91",
|
|
22
|
+
"shortCommit": "fa5128a",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-04-
|
|
26
|
-
"buildDate": "2026-04-
|
|
24
|
+
"tag": "v18.17.0",
|
|
25
|
+
"commitDate": "2026-04-24T16:56:03Z",
|
|
26
|
+
"buildDate": "2026-04-24T17:14:33.461Z",
|
|
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/fa5128a31943aaf4e91dc6892a0e74efd1dfba91",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.17.0"
|
|
33
33
|
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
1
2
|
import { SECRET_ENV_PATTERNS } from "../secrets/index";
|
|
3
|
+
import { expandTilde } from "../tools/path-utils";
|
|
2
4
|
import {
|
|
3
5
|
deriveTenantFromUrl,
|
|
4
6
|
F5XC_API_TOKEN,
|
|
@@ -43,6 +45,8 @@ export async function handleProfileCommand(
|
|
|
43
45
|
return handleList(ctx, service);
|
|
44
46
|
case "activate":
|
|
45
47
|
return handleActivate(ctx, service, arg);
|
|
48
|
+
case "validate":
|
|
49
|
+
return handleValidate(ctx, service, arg);
|
|
46
50
|
case "show":
|
|
47
51
|
return handleShow(ctx, service, arg);
|
|
48
52
|
case "status":
|
|
@@ -51,6 +55,19 @@ export async function handleProfileCommand(
|
|
|
51
55
|
return handleCreate(ctx, service, rest);
|
|
52
56
|
case "delete":
|
|
53
57
|
return handleDelete(ctx, service, rest);
|
|
58
|
+
case "rename":
|
|
59
|
+
return handleRename(ctx, service, rest);
|
|
60
|
+
case "export":
|
|
61
|
+
return handleExport(ctx, service, rest);
|
|
62
|
+
case "import": {
|
|
63
|
+
// Pass the raw args string (everything after the "import" subcommand)
|
|
64
|
+
// rather than the whitespace-tokenized `rest`. Inline JSON values can
|
|
65
|
+
// contain runs of whitespace inside string literals (tabs, multiple
|
|
66
|
+
// spaces) that `command.args.trim().split(/\s+/).join(" ")` would
|
|
67
|
+
// collapse — corrupting the bytes before JSON.parse.
|
|
68
|
+
const rawImportArgs = command.args.trim().replace(/^\S+\s*/, "");
|
|
69
|
+
return handleImport(ctx, service, rawImportArgs);
|
|
70
|
+
}
|
|
54
71
|
case "namespace":
|
|
55
72
|
return handleNamespace(ctx, service, arg);
|
|
56
73
|
case "env":
|
|
@@ -68,7 +85,7 @@ export async function handleProfileCommand(
|
|
|
68
85
|
return handleEnvSet(ctx, service, command.args);
|
|
69
86
|
}
|
|
70
87
|
ctx.showError(
|
|
71
|
-
`Unknown subcommand: ${sub}. Use /profile list|activate|show|status|create|delete|namespace|env|set|unset`,
|
|
88
|
+
`Unknown subcommand: ${sub}. Use /profile list|activate|validate|show|status|create|delete|rename|export|import|namespace|env|set|unset`,
|
|
72
89
|
);
|
|
73
90
|
}
|
|
74
91
|
}
|
|
@@ -78,6 +95,32 @@ function sanitize(value: string): string {
|
|
|
78
95
|
return value.replace(/[\x00-\x1f\x7f]/g, "");
|
|
79
96
|
}
|
|
80
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Split a positional+flag argument list into two groups.
|
|
100
|
+
*
|
|
101
|
+
* Only tokens that exactly match one of `knownFlags` are treated as flags;
|
|
102
|
+
* everything else — including `--`-prefixed tokens that look flag-ish —
|
|
103
|
+
* goes to positionals. This matters because profile names allow leading
|
|
104
|
+
* dashes (the name regex is `/^[a-zA-Z0-9_-]{1,64}$/`), so a user with a
|
|
105
|
+
* profile named `--prod` needs `splitArgs(["--prod"], new Set(["--include-token"]))`
|
|
106
|
+
* to return `positionals=["--prod"], flags=new Set()` rather than silently
|
|
107
|
+
* eating the name as an unrecognized flag.
|
|
108
|
+
*
|
|
109
|
+
* Callers list the flags they actually understand. Unknown `--`-prefixed
|
|
110
|
+
* tokens that happen not to be valid profile names are left in positionals
|
|
111
|
+
* and surface downstream as "not found" errors rather than being silently
|
|
112
|
+
* absorbed.
|
|
113
|
+
*/
|
|
114
|
+
function splitArgs(args: string[], knownFlags: Set<string>): { positionals: string[]; flags: Set<string> } {
|
|
115
|
+
const positionals: string[] = [];
|
|
116
|
+
const flags = new Set<string>();
|
|
117
|
+
for (const a of args) {
|
|
118
|
+
if (knownFlags.has(a)) flags.add(a);
|
|
119
|
+
else positionals.push(a);
|
|
120
|
+
}
|
|
121
|
+
return { positionals, flags };
|
|
122
|
+
}
|
|
123
|
+
|
|
81
124
|
async function handleList(ctx: CommandContext, service: ProfileService): Promise<void> {
|
|
82
125
|
const profiles = await service.listProfiles();
|
|
83
126
|
if (profiles.length === 0) {
|
|
@@ -191,6 +234,28 @@ async function handleShow(ctx: CommandContext, service: ProfileService, name?: s
|
|
|
191
234
|
ctx.showStatus(renderF5XCTable(profile.name, rows, { dividers }));
|
|
192
235
|
}
|
|
193
236
|
|
|
237
|
+
async function handleValidate(ctx: CommandContext, service: ProfileService, name: string): Promise<void> {
|
|
238
|
+
if (!name) {
|
|
239
|
+
ctx.showError(
|
|
240
|
+
"Missing profile name. Usage: /profile validate <name>. For the active profile, use /profile status.",
|
|
241
|
+
);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const result = await service.validateProfileByName(name);
|
|
246
|
+
const tenant = deriveTenantFromUrl(result.profile.apiUrl) ?? "";
|
|
247
|
+
const rows: TableRow[] = [
|
|
248
|
+
{ key: F5XC_TENANT, value: sanitize(tenant) },
|
|
249
|
+
{ key: F5XC_API_URL, value: sanitize(result.profile.apiUrl) },
|
|
250
|
+
{ key: F5XC_API_TOKEN, value: service.maskToken(result.profile.apiToken) },
|
|
251
|
+
{ key: "Status", value: formatAuthIndicator(result.status, result.latencyMs, result.errorClass) },
|
|
252
|
+
];
|
|
253
|
+
ctx.showStatus(renderF5XCTable(`${result.profile.name} (validation only)`, rows));
|
|
254
|
+
} catch (err) {
|
|
255
|
+
ctx.showError(err instanceof ProfileError ? err.message : String(err));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
194
259
|
async function handleStatus(ctx: CommandContext, service: ProfileService): Promise<void> {
|
|
195
260
|
const status = service.getStatus();
|
|
196
261
|
if (!status.isConfigured) {
|
|
@@ -241,6 +306,122 @@ async function handleCreate(ctx: CommandContext, service: ProfileService, args:
|
|
|
241
306
|
}
|
|
242
307
|
}
|
|
243
308
|
|
|
309
|
+
async function handleRename(ctx: CommandContext, service: ProfileService, args: string[]): Promise<void> {
|
|
310
|
+
const [oldName, newName] = args;
|
|
311
|
+
if (!oldName || !newName) {
|
|
312
|
+
ctx.showError("Usage: /profile rename <old> <new>");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
await service.renameProfile(oldName, newName);
|
|
317
|
+
ctx.showStatus(`Profile '${oldName}' renamed to '${newName}'.`);
|
|
318
|
+
ctx.statusLine?.invalidate();
|
|
319
|
+
ctx.updateEditorTopBorder?.();
|
|
320
|
+
ctx.ui?.requestRender();
|
|
321
|
+
} catch (err) {
|
|
322
|
+
ctx.showError(err instanceof ProfileError ? err.message : String(err));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const EXPORT_KNOWN_FLAGS = new Set(["--include-token"]);
|
|
327
|
+
|
|
328
|
+
async function handleExport(ctx: CommandContext, service: ProfileService, args: string[]): Promise<void> {
|
|
329
|
+
const { positionals, flags } = splitArgs(args, EXPORT_KNOWN_FLAGS);
|
|
330
|
+
if (positionals.length > 1) {
|
|
331
|
+
ctx.showError("Usage: /profile export [name] [--include-token]");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const includeToken = flags.has("--include-token");
|
|
335
|
+
try {
|
|
336
|
+
const bundle = await service.exportProfiles({
|
|
337
|
+
names: positionals.length === 1 ? [positionals[0]] : undefined,
|
|
338
|
+
includeToken,
|
|
339
|
+
});
|
|
340
|
+
ctx.showStatus(JSON.stringify(bundle, null, 2));
|
|
341
|
+
} catch (err) {
|
|
342
|
+
ctx.showError(err instanceof ProfileError ? err.message : String(err));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function handleImport(ctx: CommandContext, service: ProfileService, rawArgs: string): Promise<void> {
|
|
347
|
+
// Detect --overwrite at the leading or trailing edge of the raw args ONLY.
|
|
348
|
+
// Matching anywhere would falsely strip the literal "--overwrite" that could
|
|
349
|
+
// appear inside a JSON string value (e.g. `{"note":"--overwrite happened"}`).
|
|
350
|
+
// Leading/trailing is the natural CLI usage and leaves the source bytes
|
|
351
|
+
// intact for brace-balanced JSON parsing below.
|
|
352
|
+
let source = rawArgs.trim();
|
|
353
|
+
let overwrite = false;
|
|
354
|
+
if (source.startsWith("--overwrite")) {
|
|
355
|
+
const after = source.slice("--overwrite".length);
|
|
356
|
+
if (after === "" || /^\s/.test(after)) {
|
|
357
|
+
overwrite = true;
|
|
358
|
+
source = after.trimStart();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (source.endsWith("--overwrite")) {
|
|
362
|
+
const before = source.slice(0, -"--overwrite".length);
|
|
363
|
+
if (before === "" || /\s$/.test(before)) {
|
|
364
|
+
overwrite = true;
|
|
365
|
+
source = before.trimEnd();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (!source) {
|
|
369
|
+
ctx.showError("Usage: /profile import <path-or-json> [--overwrite]");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let parsed: unknown;
|
|
374
|
+
if (source.startsWith("{")) {
|
|
375
|
+
// Inline JSON
|
|
376
|
+
try {
|
|
377
|
+
parsed = JSON.parse(source);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
ctx.showError(`Import source is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
} else {
|
|
383
|
+
// File path — pass process.env.HOME so tests that mutate HOME are honoured
|
|
384
|
+
const filePath = expandTilde(source, process.env.HOME);
|
|
385
|
+
if (!fs.existsSync(filePath)) {
|
|
386
|
+
ctx.showError(`Import file not found: ${source}`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
391
|
+
parsed = JSON.parse(content);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
ctx.showError(`Import source is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
const result = await service.importProfiles(parsed, { overwrite });
|
|
400
|
+
const lines: string[] = [];
|
|
401
|
+
lines.push(`Imported ${result.imported.length} profile${result.imported.length === 1 ? "" : "s"}:`);
|
|
402
|
+
for (const name of result.imported) lines.push(` + ${name}`);
|
|
403
|
+
if (result.overwritten.length > 0) {
|
|
404
|
+
lines.push(`Overwrote ${result.overwritten.length}: ${result.overwritten.join(", ")}`);
|
|
405
|
+
}
|
|
406
|
+
ctx.showStatus(lines.join("\n"));
|
|
407
|
+
// Invalidate TUI chrome IF the active profile was overwritten. The
|
|
408
|
+
// service's importProfiles re-activates the active profile when an
|
|
409
|
+
// overwrite touches it, which means #activeProfile, bash.environment,
|
|
410
|
+
// and cached auth metadata all mutated. The status-line segment and
|
|
411
|
+
// editor top-border are handler-driven (not listener-driven), so
|
|
412
|
+
// without this the chrome advertises the old tenant until another
|
|
413
|
+
// command triggers a refresh. Match the pattern in handleRename.
|
|
414
|
+
const activeName = service.getStatus().activeProfileName;
|
|
415
|
+
if (activeName && result.overwritten.includes(activeName)) {
|
|
416
|
+
ctx.statusLine?.invalidate();
|
|
417
|
+
ctx.updateEditorTopBorder?.();
|
|
418
|
+
ctx.ui?.requestRender();
|
|
419
|
+
}
|
|
420
|
+
} catch (err) {
|
|
421
|
+
ctx.showError(err instanceof ProfileError ? err.message : String(err));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
244
425
|
async function handleDelete(ctx: CommandContext, service: ProfileService, args: string[]): Promise<void> {
|
|
245
426
|
const name = args[0];
|
|
246
427
|
const confirmed = args.includes("--confirm");
|
|
@@ -14,6 +14,24 @@ import {
|
|
|
14
14
|
|
|
15
15
|
export const CURRENT_SCHEMA_VERSION = 1;
|
|
16
16
|
|
|
17
|
+
export const CURRENT_EXPORT_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
export interface ExportBundle {
|
|
20
|
+
/** Export format version — distinct from per-profile F5XCProfile.version (schema version). */
|
|
21
|
+
version: number;
|
|
22
|
+
exportedAt: string;
|
|
23
|
+
/** When true, importProfiles rejects this bundle. */
|
|
24
|
+
tokensMasked: boolean;
|
|
25
|
+
/** Same shape as on-disk profile JSON. Tokens masked iff tokensMasked=true. */
|
|
26
|
+
profiles: F5XCProfile[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ImportResult {
|
|
30
|
+
imported: string[];
|
|
31
|
+
overwritten: string[];
|
|
32
|
+
skipped: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
export interface F5XCProfile {
|
|
18
36
|
name: string;
|
|
19
37
|
apiUrl: string;
|
|
@@ -47,6 +65,23 @@ export interface ProfileStatus {
|
|
|
47
65
|
authCheckedAt?: number;
|
|
48
66
|
}
|
|
49
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Result of validating credentials for a named profile without activating it.
|
|
70
|
+
* Returned by `ProfileService.validateProfileByName()`. Callers get the full
|
|
71
|
+
* profile back rather than correlating by name so rendering code can use a
|
|
72
|
+
* single object (tenant, URL, masked token, status) without a second lookup.
|
|
73
|
+
*
|
|
74
|
+
* Auth failure is carried here as `status: "auth_error" | "offline"` with
|
|
75
|
+
* optional `errorClass` — not thrown. The method throws only for missing /
|
|
76
|
+
* invalid-name / incompatible-version cases.
|
|
77
|
+
*/
|
|
78
|
+
export interface ValidationResult {
|
|
79
|
+
profile: F5XCProfile;
|
|
80
|
+
status: AuthStatus;
|
|
81
|
+
latencyMs?: number;
|
|
82
|
+
errorClass?: "network" | "credential";
|
|
83
|
+
}
|
|
84
|
+
|
|
50
85
|
export class ProfileError extends Error {
|
|
51
86
|
constructor(
|
|
52
87
|
message: string,
|
|
@@ -324,6 +359,307 @@ export class ProfileService {
|
|
|
324
359
|
this.#profilesCache = this.#profilesCache.filter(p => p.name !== name);
|
|
325
360
|
}
|
|
326
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Export one or more profiles as an ExportBundle. Profiles are deep-cloned
|
|
364
|
+
* before any masking to guarantee the in-memory cache (#profilesCache and
|
|
365
|
+
* #activeProfile, which may share references) is never mutated.
|
|
366
|
+
*
|
|
367
|
+
* When includeToken is false, apiToken and every env value whose key is in
|
|
368
|
+
* sensitiveKeys is replaced with the masked form. The envelope's
|
|
369
|
+
* tokensMasked flag reflects this so importProfiles can refuse masked
|
|
370
|
+
* bundles.
|
|
371
|
+
*
|
|
372
|
+
* Throws ProfileError when a requested name does not exist on disk.
|
|
373
|
+
*/
|
|
374
|
+
async exportProfiles(opts: { names?: string[]; includeToken: boolean }): Promise<ExportBundle> {
|
|
375
|
+
const all = await this.listProfiles();
|
|
376
|
+
let selected: F5XCProfile[];
|
|
377
|
+
if (opts.names && opts.names.length > 0) {
|
|
378
|
+
const byName = new Map(all.map(p => [p.name, p]));
|
|
379
|
+
selected = [];
|
|
380
|
+
const missing: string[] = [];
|
|
381
|
+
for (const n of opts.names) {
|
|
382
|
+
const p = byName.get(n);
|
|
383
|
+
if (!p) missing.push(n);
|
|
384
|
+
else selected.push(p);
|
|
385
|
+
}
|
|
386
|
+
if (missing.length > 0) {
|
|
387
|
+
throw new ProfileError(`Profile(s) not found: ${missing.join(", ")}.`, missing[0]);
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
selected = all;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Deep-clone BEFORE masking. maskToken is destructive; mutating cache
|
|
394
|
+
// entries would break subsequent activate/validate/show operations.
|
|
395
|
+
const cloned = selected.map(p => structuredClone(p));
|
|
396
|
+
|
|
397
|
+
if (!opts.includeToken) {
|
|
398
|
+
for (const p of cloned) {
|
|
399
|
+
p.apiToken = this.maskToken(p.apiToken);
|
|
400
|
+
if (p.env) {
|
|
401
|
+
// Mask env values whose key is either in sensitiveKeys OR
|
|
402
|
+
// matches SECRET_ENV_PATTERNS. Mirrors the show() handler's
|
|
403
|
+
// masking contract: `setEnvVars` auto-populates sensitiveKeys
|
|
404
|
+
// from the pattern, but profiles edited directly on disk or
|
|
405
|
+
// imported from older formats may have secret-looking keys
|
|
406
|
+
// (e.g. F5XC_CONSOLE_PASSWORD, *_TOKEN, *_SECRET) without
|
|
407
|
+
// `sensitiveKeys` entries. Export must match show() to avoid
|
|
408
|
+
// leaking credentials that show() already masks.
|
|
409
|
+
const sensitive = new Set(p.sensitiveKeys ?? []);
|
|
410
|
+
for (const [k, v] of Object.entries(p.env)) {
|
|
411
|
+
if (sensitive.has(k) || SECRET_ENV_PATTERNS.test(k)) {
|
|
412
|
+
p.env[k] = this.maskToken(v);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
version: CURRENT_EXPORT_VERSION,
|
|
421
|
+
exportedAt: new Date().toISOString(),
|
|
422
|
+
tokensMasked: !opts.includeToken,
|
|
423
|
+
profiles: cloned,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Import profiles from a bundle. Validation order is load-bearing:
|
|
429
|
+
* 1. Envelope schema (object with version/tokensMasked/profiles).
|
|
430
|
+
* 2. Version match.
|
|
431
|
+
* 3. tokensMasked: true is rejected — masked tokens would pass write but
|
|
432
|
+
* fail runtime auth with a misleading error.
|
|
433
|
+
* 4. Per-profile field-shape via #validateProfileShape — any failure
|
|
434
|
+
* rejects the whole import; no writes occur.
|
|
435
|
+
* 5. Conflict detection against a fresh listProfiles() read — not the
|
|
436
|
+
* in-memory cache, which can miss concurrent-session edits.
|
|
437
|
+
* 6. Atomic per-file write loop. Each write is atomic individually via
|
|
438
|
+
* #atomicWrite, but the overall import is NOT transactional: if the
|
|
439
|
+
* Nth of M writes throws, the first N-1 profiles are kept and the
|
|
440
|
+
* remainder are not written. Multi-file rollback would require a
|
|
441
|
+
* two-phase commit we do not implement; validation steps 1–5 catch
|
|
442
|
+
* all foreseeable failures before any write begins.
|
|
443
|
+
* 7. Cache refresh.
|
|
444
|
+
*/
|
|
445
|
+
async importProfiles(bundle: unknown, opts: { overwrite: boolean }): Promise<ImportResult> {
|
|
446
|
+
// 1. Envelope schema
|
|
447
|
+
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
|
|
448
|
+
throw new ProfileError("Import bundle missing required fields: bundle must be an object.");
|
|
449
|
+
}
|
|
450
|
+
const b = bundle as Record<string, unknown>;
|
|
451
|
+
if (typeof b.version !== "number" || typeof b.tokensMasked !== "boolean" || !Array.isArray(b.profiles)) {
|
|
452
|
+
throw new ProfileError(
|
|
453
|
+
"Import bundle missing required fields: expected { version: number, tokensMasked: boolean, profiles: array }.",
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 2. Version
|
|
458
|
+
if (b.version !== CURRENT_EXPORT_VERSION) {
|
|
459
|
+
throw new ProfileError(
|
|
460
|
+
`Import bundle uses export version ${b.version}, but this version of xcsh only supports ${CURRENT_EXPORT_VERSION}.`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 3. Masked-token gate
|
|
465
|
+
if (b.tokensMasked === true) {
|
|
466
|
+
throw new ProfileError(
|
|
467
|
+
"Bundle contains masked tokens. Re-export with --include-token to produce an importable bundle.",
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 4. Per-profile field-shape
|
|
472
|
+
const rawProfiles = b.profiles as unknown[];
|
|
473
|
+
const normalized: F5XCProfile[] = [];
|
|
474
|
+
const badNames: string[] = [];
|
|
475
|
+
for (let i = 0; i < rawProfiles.length; i++) {
|
|
476
|
+
const raw = rawProfiles[i];
|
|
477
|
+
const rawObj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
|
478
|
+
const name = typeof rawObj.name === "string" ? rawObj.name : `<entry ${i}>`;
|
|
479
|
+
if (typeof rawObj.name !== "string" || !this.#isValidProfileName(rawObj.name)) {
|
|
480
|
+
badNames.push(`${name} (invalid name)`);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const shape = this.#validateProfileShape(raw, rawObj.name);
|
|
484
|
+
if (!shape) {
|
|
485
|
+
badNames.push(`${rawObj.name} (invalid shape)`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
normalized.push(shape);
|
|
489
|
+
}
|
|
490
|
+
if (badNames.length > 0) {
|
|
491
|
+
throw new ProfileError(`Import bundle has ${badNames.length} invalid profile(s): ${badNames.join(", ")}.`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 4.5. Per-profile schema-version compatibility. The envelope version
|
|
495
|
+
// (step 2) is the bundle format; `profile.version` is the per-profile
|
|
496
|
+
// schema version. Without this check a bundle produced by a newer xcsh
|
|
497
|
+
// (version: 2) would pass shape checks and reach the write loop,
|
|
498
|
+
// leaving unusable profiles on disk that activate/loadActive reject.
|
|
499
|
+
// In the overwrite-active path that would mean the active profile is
|
|
500
|
+
// silently bricked on the next startup. Reject upfront.
|
|
501
|
+
const incompatibleNames: string[] = [];
|
|
502
|
+
for (const p of normalized) {
|
|
503
|
+
if (p.version !== undefined && p.version > CURRENT_SCHEMA_VERSION) {
|
|
504
|
+
incompatibleNames.push(`${p.name} (v${p.version})`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (incompatibleNames.length > 0) {
|
|
508
|
+
throw new ProfileError(
|
|
509
|
+
`Import bundle has ${incompatibleNames.length} profile(s) with incompatible schema version (this xcsh supports v${CURRENT_SCHEMA_VERSION}): ${incompatibleNames.join(", ")}. Upgrade xcsh to import this bundle.`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 4.6. Intra-bundle duplicate-name rejection. A bundle listing the
|
|
514
|
+
// same name twice would silently clobber the first entry in the write
|
|
515
|
+
// loop and emit misleading duplicated names in `imported[]`. Reject
|
|
516
|
+
// before any write so the user can fix the malformed bundle.
|
|
517
|
+
const seen = new Set<string>();
|
|
518
|
+
const intraDuplicates = new Set<string>();
|
|
519
|
+
for (const p of normalized) {
|
|
520
|
+
if (seen.has(p.name)) intraDuplicates.add(p.name);
|
|
521
|
+
else seen.add(p.name);
|
|
522
|
+
}
|
|
523
|
+
if (intraDuplicates.size > 0) {
|
|
524
|
+
throw new ProfileError(
|
|
525
|
+
`Import bundle contains duplicate profile name(s): ${[...intraDuplicates].join(", ")}. Each name must appear at most once.`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// 5. Conflict detection — fresh disk read, NOT listProfileNamesCached
|
|
530
|
+
const existing = await this.listProfiles();
|
|
531
|
+
const existingNames = new Set(existing.map(p => p.name));
|
|
532
|
+
const conflicts = normalized.filter(p => existingNames.has(p.name)).map(p => p.name);
|
|
533
|
+
if (conflicts.length > 0 && !opts.overwrite) {
|
|
534
|
+
throw new ProfileError(
|
|
535
|
+
`${conflicts.length} profile(s) conflict: ${conflicts.join(", ")}. Re-run with --overwrite to replace, or delete conflicts first.`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// 6. Write loop — atomic per-file
|
|
540
|
+
fs.mkdirSync(this.profilesDir, { recursive: true, mode: 0o700 });
|
|
541
|
+
const imported: string[] = [];
|
|
542
|
+
const overwritten: string[] = [];
|
|
543
|
+
for (const profile of normalized) {
|
|
544
|
+
const filePath = path.join(this.profilesDir, `${profile.name}.json`);
|
|
545
|
+
const wasExisting = existingNames.has(profile.name);
|
|
546
|
+
const payload: F5XCProfile = {
|
|
547
|
+
...profile,
|
|
548
|
+
version: profile.version ?? CURRENT_SCHEMA_VERSION,
|
|
549
|
+
metadata: profile.metadata ?? { createdAt: new Date().toISOString() },
|
|
550
|
+
};
|
|
551
|
+
this.#atomicWrite(filePath, JSON.stringify(payload, null, 2));
|
|
552
|
+
imported.push(profile.name);
|
|
553
|
+
if (wasExisting) overwritten.push(profile.name);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// 7. Cache refresh
|
|
557
|
+
await this.listProfiles();
|
|
558
|
+
|
|
559
|
+
// 8. Refresh active-profile state if its backing file was overwritten.
|
|
560
|
+
// importProfiles's write loop replaces the on-disk JSON, but #activeProfile,
|
|
561
|
+
// Settings.bash.environment (apiUrl/apiToken/namespace), and the cached
|
|
562
|
+
// auth metadata all hold a snapshot from the prior activate() call. Without
|
|
563
|
+
// this step, a successful `/profile import --overwrite` that touches the
|
|
564
|
+
// active profile leaves the session talking to the wrong tenant with the
|
|
565
|
+
// wrong token until the user restarts or re-activates manually.
|
|
566
|
+
const activeName = this.#activeProfile?.name;
|
|
567
|
+
if (activeName && overwritten.includes(activeName)) {
|
|
568
|
+
await this.activate(activeName);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return { imported, overwritten, skipped: [] };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Rename a profile. File is renamed first (atomic rename(2)); if the profile
|
|
576
|
+
* is active, active_profile is then updated to point at the new name. If the
|
|
577
|
+
* pointer update fails, the file rename is rolled back.
|
|
578
|
+
*
|
|
579
|
+
* Throws ProfileError for invalid names, missing source, or a target name
|
|
580
|
+
* that already exists. If the pointer-write rollback itself fails, logs a
|
|
581
|
+
* warning and throws a ProfileError documenting the inconsistent filesystem
|
|
582
|
+
* state for manual recovery.
|
|
583
|
+
*
|
|
584
|
+
* Fires onProfileChange listeners when the active profile is renamed.
|
|
585
|
+
*
|
|
586
|
+
* Note: does not rewrite the JSON body's "name" field. #readProfile treats
|
|
587
|
+
* the filename as canonical identity, so the stale field is inert.
|
|
588
|
+
*/
|
|
589
|
+
async renameProfile(oldName: string, newName: string): Promise<void> {
|
|
590
|
+
this.#validateProfileName(oldName);
|
|
591
|
+
this.#validateProfileName(newName);
|
|
592
|
+
|
|
593
|
+
const oldPath = path.join(this.profilesDir, `${oldName}.json`);
|
|
594
|
+
const newPath = path.join(this.profilesDir, `${newName}.json`);
|
|
595
|
+
|
|
596
|
+
// Existence check fires BEFORE the identity short-circuit so
|
|
597
|
+
// `renameProfile("ghost", "ghost")` returns the expected not-found error
|
|
598
|
+
// instead of a silent success that hides a typo.
|
|
599
|
+
if (!fs.existsSync(oldPath)) {
|
|
600
|
+
throw new ProfileError(`Profile '${oldName}' not found.`, oldName);
|
|
601
|
+
}
|
|
602
|
+
if (oldName === newName) return;
|
|
603
|
+
if (fs.existsSync(newPath)) {
|
|
604
|
+
throw new ProfileError(`Profile '${newName}' already exists.`, newName);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Step 1: rename file (atomic rename(2) on the same filesystem)
|
|
608
|
+
fs.renameSync(oldPath, newPath);
|
|
609
|
+
|
|
610
|
+
// Step 2: if renaming the active profile, update the pointer. On failure
|
|
611
|
+
// we must roll back the file rename so the user sees a consistent state.
|
|
612
|
+
// Consult BOTH the hydrated in-memory state AND the on-disk pointer:
|
|
613
|
+
// loadActive() leaves #activeProfile null when F5XC_API_URL overrides
|
|
614
|
+
// the profile, but the on-disk active_profile file may still name the
|
|
615
|
+
// profile being renamed — and the next non-env session relies on that
|
|
616
|
+
// pointer to restore the user's active selection.
|
|
617
|
+
const onDiskActiveName = this.#readActiveProfileName();
|
|
618
|
+
const wasActive = this.#activeProfile?.name === oldName || onDiskActiveName === oldName;
|
|
619
|
+
if (wasActive) {
|
|
620
|
+
try {
|
|
621
|
+
this.#atomicWrite(this.activeProfilePath, newName);
|
|
622
|
+
} catch (err) {
|
|
623
|
+
// Rollback. Inner try wraps ONLY the rename-back call so the
|
|
624
|
+
// rollback-succeeded / rollback-failed paths are clearly separated.
|
|
625
|
+
try {
|
|
626
|
+
fs.renameSync(newPath, oldPath);
|
|
627
|
+
} catch (rollbackErr) {
|
|
628
|
+
logger.warn("F5XC profile rename rollback failed — manual recovery required", {
|
|
629
|
+
oldName,
|
|
630
|
+
newName,
|
|
631
|
+
originalError: String(err),
|
|
632
|
+
rollbackError: String(rollbackErr),
|
|
633
|
+
});
|
|
634
|
+
throw new ProfileError(
|
|
635
|
+
`Rename failed and rollback failed. Filesystem state: profiles/${newName}.json exists, active_profile still points at '${oldName}'. Manually rename profiles/${newName}.json back to profiles/${oldName}.json, or update active_profile to '${newName}'. Original error: ${err instanceof Error ? err.message : String(err)}. Rollback error: ${String(rollbackErr)}`,
|
|
636
|
+
oldName,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
// Rollback succeeded — throw the user-friendly error.
|
|
640
|
+
throw new ProfileError(
|
|
641
|
+
`Failed to update active profile pointer: ${err instanceof Error ? err.message : String(err)}. Profile was not renamed.`,
|
|
642
|
+
oldName,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Step 3: update cache + active-profile pointer in memory.
|
|
648
|
+
// Private-static listener access uses the same idiom as #applyToSettings
|
|
649
|
+
// (the loop `for (const cb of ProfileService.#onProfileChangeListeners)`
|
|
650
|
+
// already appears in that method) — direct `ProfileService.#name` access
|
|
651
|
+
// from inside the class body.
|
|
652
|
+
this.#profilesCache = this.#profilesCache
|
|
653
|
+
.map(p => (p.name === oldName ? { ...p, name: newName } : p))
|
|
654
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
655
|
+
if (wasActive && this.#activeProfile) {
|
|
656
|
+
this.#activeProfile = { ...this.#activeProfile, name: newName };
|
|
657
|
+
for (const cb of ProfileService.#onProfileChangeListeners) {
|
|
658
|
+
cb(this.#activeProfile);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
327
663
|
/** Add or update environment variables on a profile. Keys matching secret
|
|
328
664
|
* naming patterns are automatically added to sensitiveKeys. */
|
|
329
665
|
async setEnvVars(name: string, vars: Record<string, string>): Promise<{ sensitive: string[] }> {
|
|
@@ -511,6 +847,29 @@ export class ProfileService {
|
|
|
511
847
|
}
|
|
512
848
|
}
|
|
513
849
|
|
|
850
|
+
/**
|
|
851
|
+
* Validate credentials for a named profile without switching the active one.
|
|
852
|
+
* Uses validateToken's ad-hoc branch (explicit apiUrl + apiToken), so no
|
|
853
|
+
* cached auth state, namespace cache, or active profile is mutated.
|
|
854
|
+
*
|
|
855
|
+
* Throws ProfileError when the name is invalid, the profile is missing, or
|
|
856
|
+
* the profile's schema version is incompatible. Auth failure is not thrown:
|
|
857
|
+
* it is returned as ValidationResult.status = "auth_error" / "offline".
|
|
858
|
+
*/
|
|
859
|
+
async validateProfileByName(name: string): Promise<ValidationResult> {
|
|
860
|
+
this.#validateProfileName(name);
|
|
861
|
+
const profile = this.#readProfile(name);
|
|
862
|
+
if (!profile) {
|
|
863
|
+
throw new ProfileError(`Profile '${name}' not found.`, name);
|
|
864
|
+
}
|
|
865
|
+
this.#assertCompatibleVersion(profile);
|
|
866
|
+
const { status, latencyMs, errorClass } = await this.validateToken({
|
|
867
|
+
apiUrl: profile.apiUrl,
|
|
868
|
+
apiToken: profile.apiToken,
|
|
869
|
+
});
|
|
870
|
+
return { profile, status, latencyMs, errorClass };
|
|
871
|
+
}
|
|
872
|
+
|
|
514
873
|
setNamespace(namespace: string): void {
|
|
515
874
|
if (!this.#activeProfile) {
|
|
516
875
|
throw new ProfileError("No active profile. Run `/profile activate <name>` to select one.");
|
|
@@ -579,7 +938,16 @@ export class ProfileService {
|
|
|
579
938
|
|
|
580
939
|
#atomicWrite(filePath: string, content: string): void {
|
|
581
940
|
const tmpPath = `${filePath}.tmp`;
|
|
582
|
-
|
|
941
|
+
// Force 0o600 on the tmp file so the atomic rename produces a
|
|
942
|
+
// destination with credential-file permissions. Without this, the
|
|
943
|
+
// tmp inherits process umask (typically 0644), fs.renameSync carries
|
|
944
|
+
// those permissions onto the destination, and any profile JSON
|
|
945
|
+
// updated through this helper (setEnvVars, unsetEnvVars, import
|
|
946
|
+
// overwrite) ends up world-readable even though createProfile
|
|
947
|
+
// explicitly writes at 0o600. active_profile pointer is also
|
|
948
|
+
// tightened — it names the profile but carries no credentials, so
|
|
949
|
+
// 0o600 is strictly no worse.
|
|
950
|
+
fs.writeFileSync(tmpPath, content, { mode: 0o600 });
|
|
583
951
|
fs.renameSync(tmpPath, filePath);
|
|
584
952
|
}
|
|
585
953
|
|
|
@@ -621,6 +989,69 @@ export class ProfileService {
|
|
|
621
989
|
}
|
|
622
990
|
}
|
|
623
991
|
|
|
992
|
+
/**
|
|
993
|
+
* Field-shape check for a parsed profile object. Returns a normalized
|
|
994
|
+
* F5XCProfile when obj passes the same rules #readProfile enforces on disk
|
|
995
|
+
* reads, or null when a required field is missing/wrong-typed.
|
|
996
|
+
*
|
|
997
|
+
* Used by #readProfile (canonical name = filename) and by importProfiles
|
|
998
|
+
* (canonical name = obj.name, which the caller must already have validated
|
|
999
|
+
* via #isValidProfileName).
|
|
1000
|
+
*
|
|
1001
|
+
* Side effect: logger.warn on failure, matching #readProfile's original
|
|
1002
|
+
* behavior so existing log-assertion tests continue to pass.
|
|
1003
|
+
*/
|
|
1004
|
+
#validateProfileShape(obj: unknown, canonicalName: string): F5XCProfile | null {
|
|
1005
|
+
if (!obj || typeof obj !== "object") {
|
|
1006
|
+
logger.warn("F5XC profile is not an object", { name: canonicalName });
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
const parsed = obj as Record<string, unknown>;
|
|
1010
|
+
|
|
1011
|
+
if (
|
|
1012
|
+
!parsed.apiUrl ||
|
|
1013
|
+
typeof parsed.apiUrl !== "string" ||
|
|
1014
|
+
!parsed.apiToken ||
|
|
1015
|
+
typeof parsed.apiToken !== "string"
|
|
1016
|
+
) {
|
|
1017
|
+
logger.warn("F5XC profile missing or invalid required fields", { name: canonicalName });
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
if (parsed.defaultNamespace && typeof parsed.defaultNamespace !== "string") {
|
|
1021
|
+
logger.warn("F5XC profile has non-string defaultNamespace", { name: canonicalName });
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
let env: Record<string, string> | undefined;
|
|
1026
|
+
if (parsed.env && typeof parsed.env === "object" && !Array.isArray(parsed.env)) {
|
|
1027
|
+
env = {};
|
|
1028
|
+
for (const [k, v] of Object.entries(parsed.env)) {
|
|
1029
|
+
if (typeof v === "string") env[k] = v;
|
|
1030
|
+
}
|
|
1031
|
+
if (Object.keys(env).length === 0) env = undefined;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
let sensitiveKeys: string[] | undefined;
|
|
1035
|
+
if (Array.isArray(parsed.sensitiveKeys) && env) {
|
|
1036
|
+
const filtered = parsed.sensitiveKeys.filter((k: unknown): k is string => typeof k === "string" && k in env);
|
|
1037
|
+
sensitiveKeys = filtered.length > 0 ? filtered : undefined;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return {
|
|
1041
|
+
name: canonicalName,
|
|
1042
|
+
apiUrl: parsed.apiUrl,
|
|
1043
|
+
apiToken: parsed.apiToken,
|
|
1044
|
+
defaultNamespace: typeof parsed.defaultNamespace === "string" ? parsed.defaultNamespace : "default",
|
|
1045
|
+
env,
|
|
1046
|
+
sensitiveKeys,
|
|
1047
|
+
version: typeof parsed.version === "number" ? parsed.version : undefined,
|
|
1048
|
+
metadata:
|
|
1049
|
+
parsed.metadata && typeof parsed.metadata === "object" && !Array.isArray(parsed.metadata)
|
|
1050
|
+
? (parsed.metadata as F5XCProfile["metadata"])
|
|
1051
|
+
: undefined,
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
|
|
624
1055
|
#readProfile(name: string): F5XCProfile | null {
|
|
625
1056
|
const filePath = path.join(this.profilesDir, `${name}.json`);
|
|
626
1057
|
try {
|
|
@@ -630,51 +1061,7 @@ export class ProfileService {
|
|
|
630
1061
|
}
|
|
631
1062
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
632
1063
|
const parsed = JSON.parse(content);
|
|
633
|
-
|
|
634
|
-
// Validate required fields exist and are strings
|
|
635
|
-
if (
|
|
636
|
-
!parsed.apiUrl ||
|
|
637
|
-
typeof parsed.apiUrl !== "string" ||
|
|
638
|
-
!parsed.apiToken ||
|
|
639
|
-
typeof parsed.apiToken !== "string"
|
|
640
|
-
) {
|
|
641
|
-
logger.warn("F5XC profile missing or invalid required fields", { name });
|
|
642
|
-
return null;
|
|
643
|
-
}
|
|
644
|
-
if (parsed.defaultNamespace && typeof parsed.defaultNamespace !== "string") {
|
|
645
|
-
logger.warn("F5XC profile has non-string defaultNamespace", { name });
|
|
646
|
-
return null;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// Read optional env map — accept only string values
|
|
650
|
-
let env: Record<string, string> | undefined;
|
|
651
|
-
if (parsed.env && typeof parsed.env === "object" && !Array.isArray(parsed.env)) {
|
|
652
|
-
env = {};
|
|
653
|
-
for (const [k, v] of Object.entries(parsed.env)) {
|
|
654
|
-
if (typeof v === "string") env[k] = v;
|
|
655
|
-
}
|
|
656
|
-
if (Object.keys(env).length === 0) env = undefined;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// Read optional sensitiveKeys — accept only string[] with keys present in env
|
|
660
|
-
let sensitiveKeys: string[] | undefined;
|
|
661
|
-
if (Array.isArray(parsed.sensitiveKeys) && env) {
|
|
662
|
-
const filtered = parsed.sensitiveKeys.filter(
|
|
663
|
-
(k: unknown): k is string => typeof k === "string" && k in env,
|
|
664
|
-
);
|
|
665
|
-
sensitiveKeys = filtered.length > 0 ? filtered : undefined;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
return {
|
|
669
|
-
name, // Canonical identity is the filename, not parsed.name
|
|
670
|
-
apiUrl: parsed.apiUrl,
|
|
671
|
-
apiToken: parsed.apiToken,
|
|
672
|
-
defaultNamespace: parsed.defaultNamespace ?? "default",
|
|
673
|
-
env,
|
|
674
|
-
sensitiveKeys,
|
|
675
|
-
version: typeof parsed.version === "number" ? parsed.version : undefined,
|
|
676
|
-
metadata: parsed.metadata,
|
|
677
|
-
};
|
|
1064
|
+
return this.#validateProfileShape(parsed, name);
|
|
678
1065
|
} catch (err) {
|
|
679
1066
|
logger.warn("F5XC profile read error", { name, error: String(err) });
|
|
680
1067
|
return null;
|
|
@@ -1015,10 +1015,129 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
|
|
|
1015
1015
|
return items.length > 0 ? items : null;
|
|
1016
1016
|
},
|
|
1017
1017
|
},
|
|
1018
|
+
{
|
|
1019
|
+
name: "validate",
|
|
1020
|
+
description: "Validate credentials for a profile without activating",
|
|
1021
|
+
usage: "<name>",
|
|
1022
|
+
getArgumentCompletions(prefix: string) {
|
|
1023
|
+
if (prefix.includes(" ")) return null;
|
|
1024
|
+
const svc = tryGetProfileService();
|
|
1025
|
+
if (!svc) return null;
|
|
1026
|
+
const lower = prefix.toLowerCase();
|
|
1027
|
+
const items = svc
|
|
1028
|
+
.listProfileNamesCached()
|
|
1029
|
+
.filter(n => n.toLowerCase().startsWith(lower))
|
|
1030
|
+
.map(n => {
|
|
1031
|
+
const hint = svc.getProfileHint(n);
|
|
1032
|
+
const parts: string[] = [];
|
|
1033
|
+
if (hint?.apiUrl) parts.push(hint.apiUrl);
|
|
1034
|
+
if (hint?.incompatible && hint.schemaVersion !== undefined) {
|
|
1035
|
+
parts.push(`incompatible: v${hint.schemaVersion}`);
|
|
1036
|
+
}
|
|
1037
|
+
return {
|
|
1038
|
+
value: n,
|
|
1039
|
+
label: n,
|
|
1040
|
+
description: parts.length > 0 ? parts.join(" · ") : undefined,
|
|
1041
|
+
};
|
|
1042
|
+
});
|
|
1043
|
+
return items.length > 0 ? items : null;
|
|
1044
|
+
},
|
|
1045
|
+
},
|
|
1018
1046
|
{ name: "show", description: "Show profile details (masked)", usage: "[name]" },
|
|
1019
1047
|
{ name: "status", description: "Show current auth status" },
|
|
1020
1048
|
{ name: "create", description: "Create a new profile", usage: "<name> <url> <token> [namespace]" },
|
|
1021
1049
|
{ name: "delete", description: "Delete a profile", usage: "<name> --confirm" },
|
|
1050
|
+
{
|
|
1051
|
+
name: "rename",
|
|
1052
|
+
description: "Rename a profile",
|
|
1053
|
+
usage: "<old> <new>",
|
|
1054
|
+
getArgumentCompletions(prefix: string) {
|
|
1055
|
+
if (prefix.includes(" ")) return null;
|
|
1056
|
+
const svc = tryGetProfileService();
|
|
1057
|
+
if (!svc) return null;
|
|
1058
|
+
const lower = prefix.toLowerCase();
|
|
1059
|
+
const items = svc
|
|
1060
|
+
.listProfileNamesCached()
|
|
1061
|
+
.filter(n => n.toLowerCase().startsWith(lower))
|
|
1062
|
+
.map(n => ({ value: n, label: n }));
|
|
1063
|
+
return items.length > 0 ? items : null;
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
name: "export",
|
|
1068
|
+
description: "Export a profile (or all profiles) as JSON",
|
|
1069
|
+
usage: "[name] [--include-token]",
|
|
1070
|
+
getArgumentCompletions(prefix: string) {
|
|
1071
|
+
const svc = tryGetProfileService();
|
|
1072
|
+
if (!svc) return null;
|
|
1073
|
+
const tokens = prefix.split(/\s+/).filter(Boolean);
|
|
1074
|
+
const hasIncludeToken = tokens.includes("--include-token");
|
|
1075
|
+
const positionalsTyped = tokens.filter(t => !t.startsWith("--"));
|
|
1076
|
+
// Last token is "in-progress" if the prefix does not end with space.
|
|
1077
|
+
const trailingSpace = prefix.endsWith(" ") || prefix === "";
|
|
1078
|
+
const typedPositionalCount = trailingSpace
|
|
1079
|
+
? positionalsTyped.length
|
|
1080
|
+
: Math.max(0, positionalsTyped.length - 1);
|
|
1081
|
+
const completingToken = trailingSpace ? "" : (tokens[tokens.length - 1] ?? "");
|
|
1082
|
+
// `head` is every already-typed token EXCEPT the one being
|
|
1083
|
+
// completed. getArgumentCompletions.value replaces the whole
|
|
1084
|
+
// argument tail, so value must carry every token the user
|
|
1085
|
+
// should keep — otherwise accepting a suggestion silently
|
|
1086
|
+
// drops the other args. Contract: see SubcommandDef JSDoc
|
|
1087
|
+
// above (line ~58).
|
|
1088
|
+
const headTokens = trailingSpace ? tokens : tokens.slice(0, -1);
|
|
1089
|
+
const head = headTokens.length > 0 ? `${headTokens.join(" ")} ` : "";
|
|
1090
|
+
|
|
1091
|
+
const items: { value: string; label: string; description?: string }[] = [];
|
|
1092
|
+
|
|
1093
|
+
// Offer profile names only if no positional has been filled yet.
|
|
1094
|
+
// No startsWith("--") guard: profile names legitimately allow
|
|
1095
|
+
// leading dashes (the regex is /^[a-zA-Z0-9_-]{1,64}$/), and
|
|
1096
|
+
// the handler's splitArgs uses a known-flags allowlist that
|
|
1097
|
+
// treats only --include-token as a flag. So a profile like
|
|
1098
|
+
// `--prod` is valid; the completion filters by prefix and
|
|
1099
|
+
// matches it naturally. When the user types `--in`, the
|
|
1100
|
+
// flag-completion branch below matches `--include-token` by
|
|
1101
|
+
// prefix; if there's ALSO a profile starting with `--in` it
|
|
1102
|
+
// is offered here. Both lists are disjoint by filter so
|
|
1103
|
+
// there's no double-offer of the same token.
|
|
1104
|
+
if (typedPositionalCount === 0) {
|
|
1105
|
+
const lower = completingToken.toLowerCase();
|
|
1106
|
+
for (const n of svc.listProfileNamesCached()) {
|
|
1107
|
+
if (!n.toLowerCase().startsWith(lower)) continue;
|
|
1108
|
+
const hint = svc.getProfileHint(n);
|
|
1109
|
+
items.push({
|
|
1110
|
+
value: `${head}${n}`,
|
|
1111
|
+
label: n,
|
|
1112
|
+
description: hint?.apiUrl,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Offer --include-token unless already present. Match is
|
|
1118
|
+
// case-sensitive because the handler's flag check uses
|
|
1119
|
+
// exact-match `flags.has("--include-token")` — offering
|
|
1120
|
+
// the suggestion for mis-cased prefixes (e.g. `--INCLUDE`)
|
|
1121
|
+
// would produce a suggestion the handler then ignores.
|
|
1122
|
+
if (!hasIncludeToken && "--include-token".startsWith(completingToken)) {
|
|
1123
|
+
items.push({
|
|
1124
|
+
value: `${head}--include-token`,
|
|
1125
|
+
label: "--include-token",
|
|
1126
|
+
description: "emit unmasked tokens",
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
return items.length > 0 ? items : null;
|
|
1131
|
+
},
|
|
1132
|
+
},
|
|
1133
|
+
{
|
|
1134
|
+
name: "import",
|
|
1135
|
+
description: "Import profiles from a file path or inline JSON",
|
|
1136
|
+
usage: "<path-or-json> [--overwrite]",
|
|
1137
|
+
// No dynamic completion — paths are hard to complete correctly,
|
|
1138
|
+
// and faking it would only mislead. Users pre-expand paths in
|
|
1139
|
+
// their shell.
|
|
1140
|
+
},
|
|
1022
1141
|
{
|
|
1023
1142
|
name: "namespace",
|
|
1024
1143
|
description: "Switch namespace within active profile",
|