@f5xc-salesdemos/pi-utils 19.42.1 → 19.43.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.42.1",
4
+ "version": "19.43.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.42.1"
42
+ "@f5xc-salesdemos/pi-natives": "19.43.0"
43
43
  },
44
44
  "engines": {
45
45
  "bun": ">=1.3.7"
package/src/dirs.ts CHANGED
@@ -463,3 +463,18 @@ export function getF5XCActiveContextPath(): string {
463
463
  export function getF5XCContextPath(name: string): string {
464
464
  return path.join(getF5XCContextsDir(), `${name}.json`);
465
465
  }
466
+
467
+ /** Get the project-local F5 XC contexts directory (.xcsh/contexts under cwd). */
468
+ export function getLocalF5XCContextsDir(cwd: string = getProjectDir()): string {
469
+ return path.join(cwd, CONFIG_DIR_NAME, "contexts");
470
+ }
471
+
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");
475
+ }
476
+
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`);
480
+ }
@@ -0,0 +1,250 @@
1
+ import * as fs from "node:fs";
2
+ 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 {
13
+ name: string;
14
+ apiUrl: string;
15
+ apiToken: string;
16
+ defaultNamespace: string;
17
+ env?: Record<string, string>;
18
+ sensitiveKeys?: string[];
19
+ knowledgeSources?: KnowledgeSource[];
20
+ includeSkills?: string[];
21
+ excludeSkills?: string[];
22
+ version?: number;
23
+ metadata?: {
24
+ createdAt?: string;
25
+ expiresAt?: string;
26
+ lastRotatedAt?: string;
27
+ rotateAfterDays?: number;
28
+ };
29
+ }
30
+
31
+ export interface KnowledgeSource {
32
+ url: string;
33
+ label?: string;
34
+ type?: "llms-txt" | "skill-dir" | "docs-site";
35
+ }
36
+
37
+ export interface ContextOverrides {
38
+ defaultNamespace?: string;
39
+ env?: Record<string, string>;
40
+ sensitiveKeys?: string[];
41
+ knowledgeSources?: KnowledgeSource[];
42
+ includeSkills?: string[];
43
+ excludeSkills?: string[];
44
+ }
45
+
46
+ export interface PointerContext {
47
+ context: string;
48
+ overrides?: ContextOverrides;
49
+ }
50
+
51
+ export type ContextSource = "env" | "local" | "global";
52
+
53
+ export interface ResolvedContext {
54
+ context: F5XCContextData;
55
+ source: ContextSource;
56
+ sourcePath: string;
57
+ }
58
+
59
+ export function isPointerContext(data: unknown): data is PointerContext {
60
+ if (data === null || data === undefined || typeof data !== "object") return false;
61
+ const obj = data as Record<string, unknown>;
62
+ return typeof obj.context === "string" && !("apiUrl" in obj);
63
+ }
64
+
65
+ export function isInlineContext(data: unknown): boolean {
66
+ if (data === null || data === undefined || typeof data !== "object") return false;
67
+ const obj = data as Record<string, unknown>;
68
+ return typeof obj.apiUrl === "string";
69
+ }
70
+
71
+ export function validateLocalContextFile(data: unknown): { valid: boolean; error?: string } {
72
+ if (data === null || data === undefined || typeof data !== "object") {
73
+ return { valid: false, error: "Context file must be a JSON object" };
74
+ }
75
+ const obj = data as Record<string, unknown>;
76
+ const hasContext = typeof obj.context === "string";
77
+ const hasApiUrl = typeof obj.apiUrl === "string";
78
+
79
+ if (hasContext && hasApiUrl) {
80
+ return { valid: false, error: "Context file cannot have both 'context' (pointer) and 'apiUrl' (inline) fields" };
81
+ }
82
+ if (!hasContext && !hasApiUrl) {
83
+ return { valid: false, error: "Context file must have either 'context' (pointer) or 'apiUrl' (inline) field" };
84
+ }
85
+ return { valid: true };
86
+ }
87
+
88
+ export function mergePointerOverrides(base: F5XCContextData, overrides: ContextOverrides): F5XCContextData {
89
+ const merged = { ...base };
90
+
91
+ if (overrides.defaultNamespace !== undefined) {
92
+ merged.defaultNamespace = overrides.defaultNamespace;
93
+ }
94
+ if (overrides.sensitiveKeys !== undefined) {
95
+ merged.sensitiveKeys = overrides.sensitiveKeys;
96
+ }
97
+ if (overrides.knowledgeSources !== undefined) {
98
+ merged.knowledgeSources = overrides.knowledgeSources;
99
+ }
100
+ if (overrides.includeSkills !== undefined) {
101
+ merged.includeSkills = overrides.includeSkills;
102
+ }
103
+ if (overrides.excludeSkills !== undefined) {
104
+ merged.excludeSkills = overrides.excludeSkills;
105
+ }
106
+ if (overrides.env !== undefined) {
107
+ merged.env = { ...base.env, ...overrides.env };
108
+ }
109
+
110
+ return merged;
111
+ }
112
+
113
+ const CONTEXT_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
114
+
115
+ export function isSafeContextName(name: string): boolean {
116
+ return CONTEXT_NAME_RE.test(name);
117
+ }
118
+
119
+ export class ContextResolver {
120
+ resolve(cwd: string): Promise<ResolvedContext | null> {
121
+ // Priority 1: environment variables
122
+ const envUrl = process.env.F5XC_API_URL;
123
+ const envToken = process.env.F5XC_API_TOKEN;
124
+ 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
+ });
135
+ }
136
+
137
+ // Priority 2: local .xcsh/contexts/
138
+ const localDir = this.findLocalContextsDir(cwd);
139
+ if (localDir) {
140
+ const localResult = this.#resolveFromDir(localDir, "local", cwd);
141
+ if (localResult) return Promise.resolve(localResult);
142
+ }
143
+
144
+ // Priority 3: global ~/.config/f5xc/contexts/
145
+ const globalResult = this.#resolveGlobal();
146
+ return Promise.resolve(globalResult);
147
+ }
148
+
149
+ findLocalContextsDir(cwd: string): string | null {
150
+ const dir = getLocalF5XCContextsDir(cwd);
151
+ return fs.existsSync(dir) ? dir : null;
152
+ }
153
+
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
+ }
167
+ }
168
+
169
+ #resolveFromDir(_contextsDir: string, source: ContextSource, cwd: string): ResolvedContext | null {
170
+ const activeContextPath = source === "local" ? getLocalF5XCActiveContextPath(cwd) : getF5XCActiveContextPath();
171
+
172
+ const activeName = this.#readActivePointer(activeContextPath);
173
+ if (!activeName || !isSafeContextName(activeName)) return null;
174
+
175
+ const contextPath =
176
+ source === "local" ? getLocalF5XCContextPath(activeName, cwd) : getF5XCContextPath(activeName);
177
+
178
+ const data = this.#readJsonFile(contextPath);
179
+ if (!data) return null;
180
+
181
+ const validation = validateLocalContextFile(data);
182
+ if (!validation.valid) return null;
183
+
184
+ if (isPointerContext(data)) {
185
+ return this.#resolvePointer(data, contextPath, cwd);
186
+ }
187
+
188
+ if (isInlineContext(data)) {
189
+ const obj = data as Record<string, unknown>;
190
+ if (
191
+ typeof obj.name !== "string" ||
192
+ typeof obj.apiToken !== "string" ||
193
+ typeof obj.defaultNamespace !== "string"
194
+ ) {
195
+ return null;
196
+ }
197
+ return {
198
+ context: data as unknown as F5XCContextData,
199
+ source,
200
+ sourcePath: contextPath,
201
+ };
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ #resolveGlobal(): ResolvedContext | null {
208
+ const globalContextsDir = getF5XCContextsDir();
209
+ if (!fs.existsSync(globalContextsDir)) return null;
210
+ return this.#resolveFromDir(globalContextsDir, "global", "");
211
+ }
212
+
213
+ #resolvePointer(pointer: PointerContext, pointerPath: string, _cwd: string): ResolvedContext | null {
214
+ if (!isSafeContextName(pointer.context)) return null;
215
+ const globalPath = getF5XCContextPath(pointer.context);
216
+ const globalData = this.#readJsonFile(globalPath);
217
+ if (!globalData) return null;
218
+
219
+ let resolved = globalData as unknown as F5XCContextData;
220
+ if (pointer.overrides) {
221
+ resolved = mergePointerOverrides(resolved, pointer.overrides);
222
+ }
223
+
224
+ return {
225
+ context: resolved,
226
+ source: "local",
227
+ sourcePath: pointerPath,
228
+ };
229
+ }
230
+
231
+ #readActivePointer(filePath: string): string | null {
232
+ if (!fs.existsSync(filePath)) return null;
233
+ try {
234
+ const name = fs.readFileSync(filePath, "utf-8").trim();
235
+ return name || null;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ #readJsonFile(filePath: string): Record<string, unknown> | null {
242
+ if (!fs.existsSync(filePath)) return null;
243
+ try {
244
+ const raw = fs.readFileSync(filePath, "utf-8");
245
+ return JSON.parse(raw) as Record<string, unknown>;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./async";
3
3
  export * from "./color";
4
4
  export * from "./dirs";
5
5
  export * from "./env";
6
+ export * from "./f5xc-context-resolver";
6
7
  export * from "./format";
7
8
  export * from "./frontmatter";
8
9
  export * from "./fs-error";