@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.16.0",
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.16.0",
51
- "@f5xc-salesdemos/pi-agent-core": "18.16.0",
52
- "@f5xc-salesdemos/pi-ai": "18.16.0",
53
- "@f5xc-salesdemos/pi-natives": "18.16.0",
54
- "@f5xc-salesdemos/pi-tui": "18.16.0",
55
- "@f5xc-salesdemos/pi-utils": "18.16.0",
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.16.0",
21
- "commit": "3dd57e1d4e386be08789232ae35d2332f473a7df",
22
- "shortCommit": "3dd57e1",
20
+ "version": "18.17.0",
21
+ "commit": "fa5128a31943aaf4e91dc6892a0e74efd1dfba91",
22
+ "shortCommit": "fa5128a",
23
23
  "branch": "main",
24
- "tag": "v18.16.0",
25
- "commitDate": "2026-04-24T03:31:16Z",
26
- "buildDate": "2026-04-24T03:59:07.572Z",
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/3dd57e1d4e386be08789232ae35d2332f473a7df",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.16.0"
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
- fs.writeFileSync(tmpPath, content);
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",