@f5xc-salesdemos/pi-utils 19.44.2 → 19.45.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/pi-utils",
4
- "version": "19.44.2",
4
+ "version": "19.45.0",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "^1.3",
42
- "@f5xc-salesdemos/pi-natives": "19.44.2"
42
+ "@f5xc-salesdemos/pi-natives": "19.45.0"
43
43
  },
44
44
  "engines": {
45
45
  "bun": ">=1.3.7"
package/src/dirs.ts CHANGED
@@ -441,40 +441,40 @@ export function getSSHConfigPath(scope: "user" | "project", cwd: string = getPro
441
441
  // F5 Distributed Cloud (F5 XC) context paths
442
442
  // =============================================================================
443
443
 
444
- const F5XC_DIR_NAME = "f5xc";
444
+ const XCSH_DIR_NAME = "xcsh";
445
445
 
446
- /** Get the F5 XC config directory ($XDG_CONFIG_HOME/f5xc or ~/.config/f5xc). */
447
- export function getF5XCConfigDir(): string {
446
+ /** Get the F5 XC config directory ($XDG_CONFIG_HOME/xcsh or ~/.config/xcsh). */
447
+ export function getXCSHConfigDir(): string {
448
448
  const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
449
- return path.join(xdgConfig, F5XC_DIR_NAME);
449
+ return path.join(xdgConfig, XCSH_DIR_NAME);
450
450
  }
451
451
 
452
- /** Get the F5 XC contexts directory (~/.config/f5xc/contexts). */
453
- export function getF5XCContextsDir(): string {
454
- return path.join(getF5XCConfigDir(), "contexts");
452
+ /** Get the F5 XC contexts directory (~/.config/xcsh/contexts). */
453
+ export function getXCSHContextsDir(): string {
454
+ return path.join(getXCSHConfigDir(), "contexts");
455
455
  }
456
456
 
457
- /** Get the path to the active context indicator file (~/.config/f5xc/active_context). */
458
- export function getF5XCActiveContextPath(): string {
459
- return path.join(getF5XCConfigDir(), "active_context");
457
+ /** Get the path to the active context indicator file (~/.config/xcsh/active_context). */
458
+ export function getXCSHActiveContextPath(): string {
459
+ return path.join(getXCSHConfigDir(), "active_context");
460
460
  }
461
461
 
462
- /** Get the path to a specific context JSON file (~/.config/f5xc/contexts/<name>.json). */
463
- export function getF5XCContextPath(name: string): string {
464
- return path.join(getF5XCContextsDir(), `${name}.json`);
462
+ /** Get the path to a specific context JSON file (~/.config/xcsh/contexts/<name>.json). */
463
+ export function getXCSHContextPath(name: string): string {
464
+ return path.join(getXCSHContextsDir(), `${name}.json`);
465
465
  }
466
466
 
467
467
  /** Get the project-local F5 XC contexts directory (.xcsh/contexts under cwd). */
468
- export function getLocalF5XCContextsDir(cwd: string = getProjectDir()): string {
468
+ export function getLocalXCSHContextsDir(cwd: string = getProjectDir()): string {
469
469
  return path.join(cwd, CONFIG_DIR_NAME, "contexts");
470
470
  }
471
471
 
472
472
  /** Get the project-local active context pointer (.xcsh/contexts/active_context). */
473
- export function getLocalF5XCActiveContextPath(cwd: string = getProjectDir()): string {
474
- return path.join(getLocalF5XCContextsDir(cwd), "active_context");
473
+ export function getLocalXCSHActiveContextPath(cwd: string = getProjectDir()): string {
474
+ return path.join(getLocalXCSHContextsDir(cwd), "active_context");
475
475
  }
476
476
 
477
477
  /** Get the path to a project-local context JSON (.xcsh/contexts/<name>.json). */
478
- export function getLocalF5XCContextPath(name: string, cwd: string = getProjectDir()): string {
479
- return path.join(getLocalF5XCContextsDir(cwd), `${name}.json`);
478
+ export function getLocalXCSHContextPath(name: string, cwd: string = getProjectDir()): string {
479
+ return path.join(getLocalXCSHContextsDir(cwd), `${name}.json`);
480
480
  }
package/src/index.ts CHANGED
@@ -3,7 +3,6 @@ export * from "./async";
3
3
  export * from "./color";
4
4
  export * from "./dirs";
5
5
  export * from "./env";
6
- export * from "./f5xc-context-resolver";
7
6
  export * from "./format";
8
7
  export * from "./frontmatter";
9
8
  export * from "./fs-error";
@@ -38,6 +37,8 @@ export * from "./tab-spacing";
38
37
  export * from "./temp";
39
38
  export * from "./type-guards";
40
39
  export * from "./which";
40
+ export * from "./xcsh-context-paths";
41
+ export * from "./xcsh-context-resolver";
41
42
 
42
43
  function isPlainObject(val: object): val is Record<string, unknown> {
43
44
  return Object.getPrototypeOf(val) === Object.prototype || Array.isArray(val);
@@ -0,0 +1,23 @@
1
+ import {
2
+ getLocalXCSHActiveContextPath,
3
+ getLocalXCSHContextPath,
4
+ getLocalXCSHContextsDir,
5
+ getXCSHActiveContextPath,
6
+ getXCSHContextPath,
7
+ getXCSHContextsDir,
8
+ } from "./dirs";
9
+ import type { ContextPathProvider } from "./xcsh-context-resolver";
10
+
11
+ /**
12
+ * The xcsh host's path provider for {@link ContextResolver}, wired to the
13
+ * `dirs.ts` family (`~/.config/xcsh/...`). The VS Code extension supplies its
14
+ * own provider from `contextPaths.ts`; both must resolve identical directories.
15
+ */
16
+ export const xcshContextPaths: ContextPathProvider = {
17
+ getContextsDir: getXCSHContextsDir,
18
+ getActiveContextPath: getXCSHActiveContextPath,
19
+ getContextPath: getXCSHContextPath,
20
+ getLocalContextsDir: (cwd: string) => getLocalXCSHContextsDir(cwd),
21
+ getLocalActiveContextPath: (cwd: string) => getLocalXCSHActiveContextPath(cwd),
22
+ getLocalContextPath: (name: string, cwd: string) => getLocalXCSHContextPath(name, cwd),
23
+ };
@@ -1,15 +1,8 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
- import {
4
- getF5XCActiveContextPath,
5
- getF5XCContextPath,
6
- getF5XCContextsDir,
7
- getLocalF5XCActiveContextPath,
8
- getLocalF5XCContextPath,
9
- getLocalF5XCContextsDir,
10
- } from "./dirs";
11
-
12
- export interface F5XCContextData {
4
+
5
+ export interface XCSHContextData {
13
6
  name: string;
14
7
  apiUrl: string;
15
8
  apiToken: string;
@@ -51,11 +44,34 @@ export interface PointerContext {
51
44
  export type ContextSource = "env" | "local" | "global";
52
45
 
53
46
  export interface ResolvedContext {
54
- context: F5XCContextData;
47
+ context: XCSHContextData;
55
48
  source: ContextSource;
56
49
  sourcePath: string;
57
50
  }
58
51
 
52
+ /**
53
+ * Host-supplied path helpers. xcsh injects the `dirs.ts` family; the VS Code
54
+ * extension injects its `contextPaths.ts` family. Keeping the resolver free of a
55
+ * direct `dirs.ts` import keeps this module runtime-agnostic (no Bun globals, no
56
+ * JSON imports) so it bundles cleanly into the VS Code extension via webpack.
57
+ */
58
+ export interface ContextPathProvider {
59
+ getContextsDir(): string;
60
+ getActiveContextPath(): string;
61
+ getContextPath(name: string): string;
62
+ getLocalContextsDir(cwd: string): string;
63
+ getLocalActiveContextPath(cwd: string): string;
64
+ getLocalContextPath(name: string, cwd: string): string;
65
+ }
66
+
67
+ export type GitTracker = (filePath: string) => Promise<boolean>;
68
+
69
+ export interface ResolverDeps {
70
+ paths: ContextPathProvider;
71
+ /** Optional override; defaults to a `node:child_process` implementation that runs under both Bun and Node. */
72
+ gitTracker?: GitTracker;
73
+ }
74
+
59
75
  export function isPointerContext(data: unknown): data is PointerContext {
60
76
  if (data === null || data === undefined || typeof data !== "object") return false;
61
77
  const obj = data as Record<string, unknown>;
@@ -85,7 +101,7 @@ export function validateLocalContextFile(data: unknown): { valid: boolean; error
85
101
  return { valid: true };
86
102
  }
87
103
 
88
- export function mergePointerOverrides(base: F5XCContextData, overrides: ContextOverrides): F5XCContextData {
104
+ export function mergePointerOverrides(base: XCSHContextData, overrides: ContextOverrides): XCSHContextData {
89
105
  const merged = { ...base };
90
106
 
91
107
  if (overrides.defaultNamespace !== undefined) {
@@ -110,70 +126,126 @@ export function mergePointerOverrides(base: F5XCContextData, overrides: ContextO
110
126
  return merged;
111
127
  }
112
128
 
129
+ /**
130
+ * Subcommand names that must not be usable as context names — otherwise
131
+ * `/context <name>` could not disambiguate a switch from a subcommand. Single
132
+ * source of truth shared by both the resolver and the coding agent.
133
+ */
134
+ export const RESERVED_CONTEXT_NAMES = new Set([
135
+ "list",
136
+ "show",
137
+ "status",
138
+ "create",
139
+ "delete",
140
+ "rename",
141
+ "namespace",
142
+ "env",
143
+ "set",
144
+ "unset",
145
+ "add",
146
+ "remove",
147
+ "clear",
148
+ "activate",
149
+ "validate",
150
+ "export",
151
+ "import",
152
+ "wizard",
153
+ "help",
154
+ "link",
155
+ "unlink",
156
+ ]);
157
+
113
158
  const CONTEXT_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
114
159
 
115
160
  export function isSafeContextName(name: string): boolean {
116
- return CONTEXT_NAME_RE.test(name);
161
+ if (!CONTEXT_NAME_RE.test(name)) return false;
162
+ return !RESERVED_CONTEXT_NAMES.has(name.toLowerCase());
163
+ }
164
+
165
+ /**
166
+ * Normalize an API URL for safe path joining by stripping trailing slash(es).
167
+ *
168
+ * The shared resource library joins URLs by raw concatenation
169
+ * (`${apiUrl}${path}`) where path templates begin with `/api/...`. A trailing
170
+ * slash produces `https://host/api//api/...`; after the transport strips the
171
+ * base URL the remainder begins with `//`, which `new URL()` parses as a
172
+ * protocol-relative authority — collapsing the host to a bare label and breaking
173
+ * TLS altname verification. Stripping trailing slashes prevents this.
174
+ */
175
+ export function normalizeApiUrl(apiUrl: string): string {
176
+ return typeof apiUrl === "string" ? apiUrl.replace(/\/+$/, "") : apiUrl;
177
+ }
178
+
179
+ function defaultGitTracker(filePath: string): Promise<boolean> {
180
+ try {
181
+ const dir = path.dirname(filePath);
182
+ const res = spawnSync("git", ["ls-files", "--error-unmatch", filePath], {
183
+ cwd: dir,
184
+ stdio: "ignore",
185
+ });
186
+ return Promise.resolve(res.status === 0);
187
+ } catch {
188
+ return Promise.resolve(false);
189
+ }
117
190
  }
118
191
 
119
192
  export class ContextResolver {
193
+ readonly #paths: ContextPathProvider;
194
+ readonly #gitTracker: GitTracker;
195
+
196
+ constructor(deps: ResolverDeps) {
197
+ this.#paths = deps.paths;
198
+ this.#gitTracker = deps.gitTracker ?? defaultGitTracker;
199
+ }
200
+
120
201
  resolve(cwd: string): Promise<ResolvedContext | null> {
121
202
  // Priority 1: environment variables
122
- const envUrl = process.env.F5XC_API_URL;
123
- const envToken = process.env.F5XC_API_TOKEN;
203
+ const envUrl = process.env.XCSH_API_URL;
204
+ const envToken = process.env.XCSH_API_TOKEN;
124
205
  if (envUrl && envToken) {
125
- return Promise.resolve({
126
- context: {
127
- name: "(env)",
128
- apiUrl: envUrl,
129
- apiToken: envToken,
130
- defaultNamespace: process.env.F5XC_NAMESPACE ?? "system",
131
- },
132
- source: "env",
133
- sourcePath: "environment variables",
134
- });
206
+ return Promise.resolve(
207
+ this.#finalize(
208
+ {
209
+ name: "(env)",
210
+ apiUrl: envUrl,
211
+ apiToken: envToken,
212
+ defaultNamespace: process.env.XCSH_NAMESPACE ?? "system",
213
+ },
214
+ "env",
215
+ "environment variables",
216
+ ),
217
+ );
135
218
  }
136
219
 
137
220
  // Priority 2: local .xcsh/contexts/
138
221
  const localDir = this.findLocalContextsDir(cwd);
139
222
  if (localDir) {
140
- const localResult = this.#resolveFromDir(localDir, "local", cwd);
223
+ const localResult = this.#resolveFromDir("local", cwd);
141
224
  if (localResult) return Promise.resolve(localResult);
142
225
  }
143
226
 
144
- // Priority 3: global ~/.config/f5xc/contexts/
145
- const globalResult = this.#resolveGlobal();
146
- return Promise.resolve(globalResult);
227
+ // Priority 3: global ~/.config/xcsh/contexts/
228
+ return Promise.resolve(this.#resolveGlobal());
147
229
  }
148
230
 
149
231
  findLocalContextsDir(cwd: string): string | null {
150
- const dir = getLocalF5XCContextsDir(cwd);
232
+ const dir = this.#paths.getLocalContextsDir(cwd);
151
233
  return fs.existsSync(dir) ? dir : null;
152
234
  }
153
235
 
154
- async checkGitTracking(filePath: string): Promise<boolean> {
155
- try {
156
- const dir = path.dirname(filePath);
157
- const proc = Bun.spawn(["git", "ls-files", "--error-unmatch", filePath], {
158
- cwd: dir,
159
- stdout: "ignore",
160
- stderr: "ignore",
161
- });
162
- const code = await proc.exited;
163
- return code === 0;
164
- } catch {
165
- return false;
166
- }
236
+ checkGitTracking(filePath: string): Promise<boolean> {
237
+ return this.#gitTracker(filePath);
167
238
  }
168
239
 
169
- #resolveFromDir(_contextsDir: string, source: ContextSource, cwd: string): ResolvedContext | null {
170
- const activeContextPath = source === "local" ? getLocalF5XCActiveContextPath(cwd) : getF5XCActiveContextPath();
240
+ #resolveFromDir(source: ContextSource, cwd: string): ResolvedContext | null {
241
+ const activeContextPath =
242
+ source === "local" ? this.#paths.getLocalActiveContextPath(cwd) : this.#paths.getActiveContextPath();
171
243
 
172
244
  const activeName = this.#readActivePointer(activeContextPath);
173
245
  if (!activeName || !isSafeContextName(activeName)) return null;
174
246
 
175
247
  const contextPath =
176
- source === "local" ? getLocalF5XCContextPath(activeName, cwd) : getF5XCContextPath(activeName);
248
+ source === "local" ? this.#paths.getLocalContextPath(activeName, cwd) : this.#paths.getContextPath(activeName);
177
249
 
178
250
  const data = this.#readJsonFile(contextPath);
179
251
  if (!data) return null;
@@ -182,7 +254,7 @@ export class ContextResolver {
182
254
  if (!validation.valid) return null;
183
255
 
184
256
  if (isPointerContext(data)) {
185
- return this.#resolvePointer(data, contextPath, cwd);
257
+ return this.#resolvePointer(data, contextPath);
186
258
  }
187
259
 
188
260
  if (isInlineContext(data)) {
@@ -194,38 +266,38 @@ export class ContextResolver {
194
266
  ) {
195
267
  return null;
196
268
  }
197
- return {
198
- context: data as unknown as F5XCContextData,
199
- source,
200
- sourcePath: contextPath,
201
- };
269
+ return this.#finalize(data as unknown as XCSHContextData, source, contextPath);
202
270
  }
203
271
 
204
272
  return null;
205
273
  }
206
274
 
207
275
  #resolveGlobal(): ResolvedContext | null {
208
- const globalContextsDir = getF5XCContextsDir();
276
+ const globalContextsDir = this.#paths.getContextsDir();
209
277
  if (!fs.existsSync(globalContextsDir)) return null;
210
- return this.#resolveFromDir(globalContextsDir, "global", "");
278
+ return this.#resolveFromDir("global", "");
211
279
  }
212
280
 
213
- #resolvePointer(pointer: PointerContext, pointerPath: string, _cwd: string): ResolvedContext | null {
281
+ #resolvePointer(pointer: PointerContext, pointerPath: string): ResolvedContext | null {
214
282
  if (!isSafeContextName(pointer.context)) return null;
215
- const globalPath = getF5XCContextPath(pointer.context);
283
+ const globalPath = this.#paths.getContextPath(pointer.context);
216
284
  const globalData = this.#readJsonFile(globalPath);
217
285
  if (!globalData) return null;
218
286
 
219
- let resolved = globalData as unknown as F5XCContextData;
287
+ let resolved = globalData as unknown as XCSHContextData;
220
288
  if (pointer.overrides) {
221
289
  resolved = mergePointerOverrides(resolved, pointer.overrides);
222
290
  }
223
291
 
224
- return {
225
- context: resolved,
226
- source: "local",
227
- sourcePath: pointerPath,
228
- };
292
+ // A pointer always resolves through the local tier, so report "local".
293
+ return this.#finalize(resolved, "local", pointerPath);
294
+ }
295
+
296
+ /** Stamp source/path and normalize the resolved apiUrl uniformly across all sources. */
297
+ #finalize(context: XCSHContextData, source: ContextSource, sourcePath: string): ResolvedContext {
298
+ const normalized =
299
+ typeof context.apiUrl === "string" ? { ...context, apiUrl: normalizeApiUrl(context.apiUrl) } : context;
300
+ return { context: normalized, source, sourcePath };
229
301
  }
230
302
 
231
303
  #readActivePointer(filePath: string): string | null {