@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 +2 -2
- package/src/dirs.ts +18 -18
- package/src/index.ts +2 -1
- package/src/xcsh-context-paths.ts +23 -0
- package/src/{f5xc-context-resolver.ts → xcsh-context-resolver.ts} +134 -62
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/pi-utils",
|
|
4
|
-
"version": "19.
|
|
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.
|
|
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
|
|
444
|
+
const XCSH_DIR_NAME = "xcsh";
|
|
445
445
|
|
|
446
|
-
/** Get the F5 XC config directory ($XDG_CONFIG_HOME/
|
|
447
|
-
export function
|
|
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,
|
|
449
|
+
return path.join(xdgConfig, XCSH_DIR_NAME);
|
|
450
450
|
}
|
|
451
451
|
|
|
452
|
-
/** Get the F5 XC contexts directory (~/.config/
|
|
453
|
-
export function
|
|
454
|
-
return path.join(
|
|
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/
|
|
458
|
-
export function
|
|
459
|
-
return path.join(
|
|
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/
|
|
463
|
-
export function
|
|
464
|
-
return path.join(
|
|
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
|
|
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
|
|
474
|
-
return path.join(
|
|
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
|
|
479
|
-
return path.join(
|
|
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
|
-
|
|
4
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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.
|
|
123
|
-
const envToken = process.env.
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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(
|
|
223
|
+
const localResult = this.#resolveFromDir("local", cwd);
|
|
141
224
|
if (localResult) return Promise.resolve(localResult);
|
|
142
225
|
}
|
|
143
226
|
|
|
144
|
-
// Priority 3: global ~/.config/
|
|
145
|
-
|
|
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 =
|
|
232
|
+
const dir = this.#paths.getLocalContextsDir(cwd);
|
|
151
233
|
return fs.existsSync(dir) ? dir : null;
|
|
152
234
|
}
|
|
153
235
|
|
|
154
|
-
|
|
155
|
-
|
|
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(
|
|
170
|
-
const activeContextPath =
|
|
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" ?
|
|
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
|
|
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 =
|
|
276
|
+
const globalContextsDir = this.#paths.getContextsDir();
|
|
209
277
|
if (!fs.existsSync(globalContextsDir)) return null;
|
|
210
|
-
return this.#resolveFromDir(
|
|
278
|
+
return this.#resolveFromDir("global", "");
|
|
211
279
|
}
|
|
212
280
|
|
|
213
|
-
#resolvePointer(pointer: PointerContext, pointerPath: string
|
|
281
|
+
#resolvePointer(pointer: PointerContext, pointerPath: string): ResolvedContext | null {
|
|
214
282
|
if (!isSafeContextName(pointer.context)) return null;
|
|
215
|
-
const globalPath =
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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 {
|