@f5-sales-demo/xcsh 22.13.2 → 22.14.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 +8 -8
- package/src/cli/args.ts +4 -0
- package/src/cli/flag-spec.ts +1 -0
- package/src/cli.ts +13 -0
- package/src/commands/blindfold.ts +80 -0
- package/src/commands/context.ts +119 -0
- package/src/config/keybindings.ts +2 -0
- package/src/config/settings-schema.ts +1 -0
- package/src/config/settings.ts +5 -0
- package/src/documentation-measurement.ts +84 -0
- package/src/internal-urls/api-catalog-qmd-index.generated.ts +2 -2
- package/src/internal-urls/api-catalog-resolve.ts +4 -3
- package/src/internal-urls/build-info.generated.ts +9 -9
- package/src/internal-urls/docs-index.generated.ts +6 -3
- package/src/internal-urls/documentation-repository.ts +2 -2
- package/src/internal-urls/documentation-resolve.ts +1 -1
- package/src/internal-urls/public-citation-destinations.generated.ts +2 -2
- package/src/main.ts +1 -0
- package/src/modes/components/context-add-wizard.ts +131 -97
- package/src/modes/components/context-picker.ts +258 -0
- package/src/modes/controllers/context-command-controller.ts +498 -107
- package/src/npm-binary-bootstrap.ts +1 -0
- package/src/prompts/tools/xcsh-blindfold.md +1 -0
- package/src/qmd-smoke.ts +5 -0
- package/src/sandbox/enforce.ts +8 -0
- package/src/sdk.ts +75 -23
- package/src/services/blindfold.ts +312 -0
- package/src/services/context-env.ts +3 -2
- package/src/services/context-execution.ts +47 -0
- package/src/services/context-operations.ts +217 -0
- package/src/services/xcsh-context-command.ts +27 -8
- package/src/services/xcsh-context.ts +209 -10
- package/src/session/agent-session.ts +85 -43
- package/src/session/session-manager.ts +12 -1
- package/src/slash-commands/builtin-registry.ts +16 -3
- package/src/tools/catalog-workflow-runner.ts +13 -8
- package/src/tools/index.ts +2 -0
- package/src/tools/xcsh-api.ts +45 -7
- package/src/tools/xcsh-blindfold.ts +85 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "22.
|
|
4
|
+
"version": "22.14.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -68,13 +68,13 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
71
|
-
"@f5-sales-demo/pi-agent-core": "22.
|
|
72
|
-
"@f5-sales-demo/pi-ai": "22.
|
|
73
|
-
"@f5-sales-demo/pi-natives": "22.
|
|
74
|
-
"@f5-sales-demo/pi-resource-management": "22.
|
|
75
|
-
"@f5-sales-demo/pi-tui": "22.
|
|
76
|
-
"@f5-sales-demo/pi-utils": "22.
|
|
77
|
-
"@f5-sales-demo/xcsh-stats": "22.
|
|
71
|
+
"@f5-sales-demo/pi-agent-core": "22.14.0",
|
|
72
|
+
"@f5-sales-demo/pi-ai": "22.14.0",
|
|
73
|
+
"@f5-sales-demo/pi-natives": "22.14.0",
|
|
74
|
+
"@f5-sales-demo/pi-resource-management": "22.14.0",
|
|
75
|
+
"@f5-sales-demo/pi-tui": "22.14.0",
|
|
76
|
+
"@f5-sales-demo/pi-utils": "22.14.0",
|
|
77
|
+
"@f5-sales-demo/xcsh-stats": "22.14.0",
|
|
78
78
|
"@mozilla/readability": "^0.6",
|
|
79
79
|
"@resvg/resvg-wasm": "2.6.2",
|
|
80
80
|
"@sinclair/typebox": "0.34.52",
|
package/src/cli/args.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface Args {
|
|
|
28
28
|
allowPath?: string[];
|
|
29
29
|
provider?: string;
|
|
30
30
|
context?: string;
|
|
31
|
+
contextSource?: "local" | "global";
|
|
31
32
|
model?: string;
|
|
32
33
|
smol?: string;
|
|
33
34
|
slow?: string;
|
|
@@ -100,6 +101,9 @@ const APPLY: Record<LaunchFlagName, (result: Args, value: string | true) => void
|
|
|
100
101
|
provider: (r, v) => {
|
|
101
102
|
r.provider = v as string;
|
|
102
103
|
},
|
|
104
|
+
"context-source": (r, v) => {
|
|
105
|
+
r.contextSource = v as "local" | "global";
|
|
106
|
+
},
|
|
103
107
|
context: (r, v) => {
|
|
104
108
|
r.context = v as string;
|
|
105
109
|
},
|
package/src/cli/flag-spec.ts
CHANGED
|
@@ -46,6 +46,7 @@ export const LAUNCH_FLAGS = defineFlags({
|
|
|
46
46
|
slow: { arity: "value", description: "Slow/reasoning model for thorough analysis (or PI_SLOW_MODEL env)" },
|
|
47
47
|
plan: { arity: "value", description: "Plan model for architectural planning (or PI_PLAN_MODEL env)" },
|
|
48
48
|
provider: { arity: "value", description: "Provider to use (legacy; prefer --model)" },
|
|
49
|
+
"context-source": { arity: "value", description: "Context storage source", options: ["local", "global"] },
|
|
49
50
|
context: { arity: "value", description: "Bind this session to a named F5 XC context" },
|
|
50
51
|
"api-key": { arity: "value", description: "API key (defaults to env vars)" },
|
|
51
52
|
"system-prompt": { arity: "value", description: "System prompt (default: coding assistant prompt)" },
|
package/src/cli.ts
CHANGED
|
@@ -53,6 +53,8 @@ if (Bun.stringWidth("\x1b[0m\x1b]8;;\x07") !== 0) {
|
|
|
53
53
|
process.title = APP_NAME;
|
|
54
54
|
|
|
55
55
|
const commands: CommandEntry[] = [
|
|
56
|
+
{ name: "context", load: () => import("./commands/context").then(m => m.default) },
|
|
57
|
+
{ name: "blindfold", load: () => import("./commands/blindfold").then(m => m.default) },
|
|
56
58
|
{ name: "remote-control", load: () => import("./commands/remote-control").then(m => m.default) },
|
|
57
59
|
{ name: "apply", load: () => import("./commands/apply").then(m => m.default) },
|
|
58
60
|
{ name: "launch", load: () => import("./commands/launch").then(m => m.default) },
|
|
@@ -217,6 +219,17 @@ if (process.env.XCSH_SMOKE_TEST_QMD === "1") {
|
|
|
217
219
|
}
|
|
218
220
|
}
|
|
219
221
|
|
|
222
|
+
if (process.env.XCSH_MEASURE_DOCUMENTATION_REQUESTS) {
|
|
223
|
+
try {
|
|
224
|
+
const { runDocumentationMeasurement } = await import("./documentation-measurement");
|
|
225
|
+
process.stdout.write(`${await runDocumentationMeasurement(process.env.XCSH_MEASURE_DOCUMENTATION_REQUESTS)}\n`);
|
|
226
|
+
process.exit(0);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
220
233
|
if (process.env.XCSH_MEASURE_TERRAFORM_REQUESTS) {
|
|
221
234
|
try {
|
|
222
235
|
const { runTerraformMeasurement } = await import("./terraform-measurement");
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Args, Command, Flags } from "@f5-sales-demo/pi-utils/cli";
|
|
2
|
+
import { Settings } from "../config/settings";
|
|
3
|
+
import { BLINDFOLD_OPERATIONS, type BlindfoldOperation, BlindfoldService } from "../services/blindfold";
|
|
4
|
+
import { ContextService } from "../services/xcsh-context";
|
|
5
|
+
|
|
6
|
+
export default class Blindfold extends Command {
|
|
7
|
+
static description = "Encrypt secrets natively and create or rotate tenant TLS certificates";
|
|
8
|
+
static args = {
|
|
9
|
+
operation: Args.string({
|
|
10
|
+
required: true,
|
|
11
|
+
options: [...BLINDFOLD_OPERATIONS],
|
|
12
|
+
description: "public-key, policy, encrypt, certificate, create, or replace",
|
|
13
|
+
}),
|
|
14
|
+
};
|
|
15
|
+
static flags = {
|
|
16
|
+
input: Flags.string({ description: "Secret file to encrypt" }),
|
|
17
|
+
cert: Flags.string({ description: "PEM certificate chain" }),
|
|
18
|
+
key: Flags.string({ description: "PEM private key" }),
|
|
19
|
+
bundle: Flags.string({ description: "PKCS#12 .p12/.pfx bundle" }),
|
|
20
|
+
name: Flags.string({ description: "Certificate resource name" }),
|
|
21
|
+
namespace: Flags.string({ char: "n", description: "Certificate namespace; defaults to XCSH_NAMESPACE" }),
|
|
22
|
+
policy: Flags.string({ description: "Secret policy namespace/name", default: "shared/ves-io-allow-volterra" }),
|
|
23
|
+
"passphrase-env": Flags.string({
|
|
24
|
+
description: "Name of an environment variable containing the input passphrase",
|
|
25
|
+
}),
|
|
26
|
+
"context-name": Flags.string({ description: "Expected active context; fail if different" }),
|
|
27
|
+
"output-file": Flags.string({ description: "Create a new encrypted artifact or public material file (0600)" }),
|
|
28
|
+
"result-file": Flags.string({ description: "Create a new JSON report (0600)" }),
|
|
29
|
+
"dry-run": Flags.string({ description: "Validate without tenant writes", options: ["client"] }),
|
|
30
|
+
json: Flags.boolean({ description: "Print the public JSON report" }),
|
|
31
|
+
};
|
|
32
|
+
async run(): Promise<void> {
|
|
33
|
+
const { args, flags } = await this.parse(Blindfold);
|
|
34
|
+
let context: ContextService | undefined;
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const cancel = () => controller.abort();
|
|
37
|
+
process.once("SIGINT", cancel);
|
|
38
|
+
try {
|
|
39
|
+
await Settings.init({ cwd: process.cwd() });
|
|
40
|
+
context = await ContextService.getOrInit(undefined, process.cwd());
|
|
41
|
+
const env = {
|
|
42
|
+
...(Settings.instance.get("bash.environment") as Record<string, string> | undefined),
|
|
43
|
+
...process.env,
|
|
44
|
+
};
|
|
45
|
+
const service = new BlindfoldService({
|
|
46
|
+
env,
|
|
47
|
+
emit: content => {
|
|
48
|
+
if (!flags.json && !flags["output-file"]) process.stdout.write(content);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const report = await service.run(
|
|
52
|
+
{
|
|
53
|
+
operation: args.operation as BlindfoldOperation,
|
|
54
|
+
input: flags.input,
|
|
55
|
+
cert: flags.cert,
|
|
56
|
+
key: flags.key,
|
|
57
|
+
bundle: flags.bundle,
|
|
58
|
+
name: flags.name,
|
|
59
|
+
namespace: flags.namespace,
|
|
60
|
+
policy: flags.policy,
|
|
61
|
+
passphraseEnv: flags["passphrase-env"],
|
|
62
|
+
contextName: flags["context-name"],
|
|
63
|
+
outputFile: flags["output-file"],
|
|
64
|
+
resultFile: flags["result-file"],
|
|
65
|
+
dryRun: flags["dry-run"] as "client" | undefined,
|
|
66
|
+
},
|
|
67
|
+
{ signal: controller.signal },
|
|
68
|
+
);
|
|
69
|
+
if (flags.json || flags["output-file"] || ["create", "replace"].includes(report.operation))
|
|
70
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
71
|
+
context.stopRevalidation();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
process.stderr.write(`Blindfold: ${error instanceof Error ? error.message : "operation failed"}\n`);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
} finally {
|
|
76
|
+
context?.stopRevalidation();
|
|
77
|
+
process.removeListener("SIGINT", cancel);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { getProjectDir, getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
|
|
3
|
+
import { Args, CliUsageError, Command, Flags } from "@f5-sales-demo/pi-utils/cli";
|
|
4
|
+
import { Settings } from "../config/settings";
|
|
5
|
+
import { CONTEXT_ACTIONS, type ContextAction, executeContextOperation } from "../services/context-operations";
|
|
6
|
+
import { ContextError, ContextService } from "../services/xcsh-context";
|
|
7
|
+
|
|
8
|
+
export default class Context extends Command {
|
|
9
|
+
static description = "Manage saved F5 XC contexts without menus or model inference";
|
|
10
|
+
static args = {
|
|
11
|
+
action: Args.string({ options: CONTEXT_ACTIONS }),
|
|
12
|
+
target: Args.string({ description: "Explicit context name (import: file or - for stdin)" }),
|
|
13
|
+
value: Args.string({ description: "Create URL, rename name, link global name, or environment action" }),
|
|
14
|
+
values: Args.string({ multiple: true, description: "Environment assignments or names" }),
|
|
15
|
+
};
|
|
16
|
+
static flags = {
|
|
17
|
+
json: Flags.boolean({ description: "Emit structured JSON" }),
|
|
18
|
+
source: Flags.string({ options: ["local", "global"], default: "global" }),
|
|
19
|
+
url: Flags.string({ description: "Replacement HTTPS tenant endpoint" }),
|
|
20
|
+
namespace: Flags.string({ description: "Saved default namespace" }),
|
|
21
|
+
"token-stdin": Flags.boolean({ description: "Read a replacement API token from stdin" }),
|
|
22
|
+
confirm: Flags.boolean({ description: "Required for deletion" }),
|
|
23
|
+
overwrite: Flags.boolean({ description: "Replace conflicting global contexts during import" }),
|
|
24
|
+
"include-token": Flags.boolean({ description: "Explicitly include secrets in exported bundle" }),
|
|
25
|
+
};
|
|
26
|
+
async run(): Promise<void> {
|
|
27
|
+
const { args, flags } = await this.parse(Context);
|
|
28
|
+
const action = (args.action ?? "list") as ContextAction;
|
|
29
|
+
const target = args.target ? { name: args.target, source: flags.source as "local" | "global" } : undefined;
|
|
30
|
+
if (!["list", "import", "export"].includes(action) && !target)
|
|
31
|
+
throw new CliUsageError(`context ${action} requires a target`);
|
|
32
|
+
if (action === "create" && (!(args.value ?? flags.url) || !flags["token-stdin"]))
|
|
33
|
+
throw new CliUsageError("context create requires NAME URL --token-stdin");
|
|
34
|
+
if (action === "edit" && !flags.url && !flags["token-stdin"] && flags.namespace === undefined)
|
|
35
|
+
throw new CliUsageError("context edit requires --url, --namespace or --token-stdin");
|
|
36
|
+
if ((action === "rename" || action === "link") && !args.value)
|
|
37
|
+
throw new CliUsageError(`context ${action} requires a second name`);
|
|
38
|
+
if (action === "delete" && !flags.confirm) throw new CliUsageError("context delete requires --confirm");
|
|
39
|
+
if (action === "import" && !args.target) throw new CliUsageError("context import requires FILE or -");
|
|
40
|
+
if (flags["token-stdin"] && !["create", "edit"].includes(action))
|
|
41
|
+
throw new CliUsageError("--token-stdin applies only to create/edit");
|
|
42
|
+
if (flags["include-token"] && action !== "export")
|
|
43
|
+
throw new CliUsageError("--include-token applies only to export");
|
|
44
|
+
if (flags.overwrite && action !== "import") throw new CliUsageError("--overwrite applies only to import");
|
|
45
|
+
if (args.values?.length && action !== "env") throw new CliUsageError("Unexpected arguments");
|
|
46
|
+
if (args.value && !["create", "rename", "link", "env"].includes(action))
|
|
47
|
+
throw new CliUsageError("Unexpected argument");
|
|
48
|
+
let env: Record<string, string> | undefined;
|
|
49
|
+
let unset: string[] | undefined;
|
|
50
|
+
if (action === "env" && args.value) {
|
|
51
|
+
if (args.value === "set") {
|
|
52
|
+
if (!args.values?.length) throw new CliUsageError("context env NAME set requires KEY=VALUE");
|
|
53
|
+
env = {};
|
|
54
|
+
for (const assignment of args.values) {
|
|
55
|
+
const index = assignment.indexOf("=");
|
|
56
|
+
if (index < 1) throw new CliUsageError("Expected KEY=VALUE");
|
|
57
|
+
env[assignment.slice(0, index)] = assignment.slice(index + 1);
|
|
58
|
+
}
|
|
59
|
+
} else if (args.value === "unset") {
|
|
60
|
+
if (!args.values?.length) throw new CliUsageError("context env NAME unset requires keys");
|
|
61
|
+
unset = args.values;
|
|
62
|
+
} else if (args.value !== "list") throw new CliUsageError("context env action must be list, set or unset");
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
let bundle: unknown;
|
|
66
|
+
if (action === "import") {
|
|
67
|
+
const bytes =
|
|
68
|
+
args.target === "-"
|
|
69
|
+
? await new Response(Bun.stdin.stream()).text()
|
|
70
|
+
: fs.readFileSync(args.target!, "utf8");
|
|
71
|
+
try {
|
|
72
|
+
bundle = JSON.parse(bytes);
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error("Import must contain valid JSON.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const apiToken = flags["token-stdin"]
|
|
78
|
+
? (await new Response(Bun.stdin.stream()).text()).replace(/\r?\n$/, "")
|
|
79
|
+
: undefined;
|
|
80
|
+
if (apiToken !== undefined && (!apiToken || /[\r\n]/.test(apiToken)))
|
|
81
|
+
throw new CliUsageError("Token stdin must contain one non-empty line.");
|
|
82
|
+
await Settings.init({ cwd: getProjectDir(), inMemory: true });
|
|
83
|
+
const service = ContextService.init(getXCSHConfigDir());
|
|
84
|
+
const result = await executeContextOperation(
|
|
85
|
+
service,
|
|
86
|
+
{
|
|
87
|
+
action,
|
|
88
|
+
target,
|
|
89
|
+
newName: args.value,
|
|
90
|
+
apiUrl: flags.url ?? (action === "create" ? args.value : undefined),
|
|
91
|
+
apiToken,
|
|
92
|
+
namespace: flags.namespace,
|
|
93
|
+
confirm: flags.confirm,
|
|
94
|
+
includeToken: flags["include-token"],
|
|
95
|
+
overwrite: flags.overwrite,
|
|
96
|
+
bundle,
|
|
97
|
+
env,
|
|
98
|
+
unset,
|
|
99
|
+
},
|
|
100
|
+
getProjectDir(),
|
|
101
|
+
);
|
|
102
|
+
if (result && typeof result === "object" && "status" in result && result.status !== "connected")
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
if (flags.json || action === "export") process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
105
|
+
else if (Array.isArray(result))
|
|
106
|
+
for (const choice of result)
|
|
107
|
+
process.stdout.write(
|
|
108
|
+
`${choice.target.name} (${choice.target.source}) · ${choice.context?.apiUrl ?? choice.error}\n`,
|
|
109
|
+
);
|
|
110
|
+
else process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof CliUsageError) throw error;
|
|
113
|
+
process.stderr.write(
|
|
114
|
+
`Error: ${error instanceof ContextError ? error.message : "Context operation failed. Check the target, input and file permissions."}\n`,
|
|
115
|
+
);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -27,6 +27,7 @@ interface AppKeybindings {
|
|
|
27
27
|
"app.model.select": true;
|
|
28
28
|
"app.model.selectTemporary": true;
|
|
29
29
|
"app.tools.expand": true;
|
|
30
|
+
"app.context.actions": true;
|
|
30
31
|
"app.editor.external": true;
|
|
31
32
|
"app.message.followUp": true;
|
|
32
33
|
"app.message.dequeue": true;
|
|
@@ -100,6 +101,7 @@ export const KEYBINDINGS = {
|
|
|
100
101
|
defaultKeys: "alt+p",
|
|
101
102
|
description: "Select temporary model for current session",
|
|
102
103
|
},
|
|
104
|
+
"app.context.actions": { defaultKeys: "ctrl+o", description: "Open selected context actions in the context picker" },
|
|
103
105
|
"app.tools.expand": {
|
|
104
106
|
defaultKeys: "ctrl+o",
|
|
105
107
|
description: "Expand tools",
|
|
@@ -2139,6 +2139,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
2139
2139
|
"bash.environment": { type: "record", default: {} as Record<string, string> },
|
|
2140
2140
|
|
|
2141
2141
|
/** Sensitive env var key names from the active xcsh context (populated by ContextService) */
|
|
2142
|
+
"xcsh.contextSource": { type: "enum", values: ["local", "global"] as const, default: "global" },
|
|
2142
2143
|
"xcsh.sensitiveKeys": { type: "array", default: [] as string[] },
|
|
2143
2144
|
|
|
2144
2145
|
/** Clear terminal on startup */
|
package/src/config/settings.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
setForceSlot,
|
|
34
34
|
setSymbolPreset,
|
|
35
35
|
} from "../modes/theme/theme";
|
|
36
|
+
import { currentContextExecution } from "../services/context-execution";
|
|
36
37
|
import { AgentStorage } from "../session/agent-storage";
|
|
37
38
|
import { applyHyperlinkSetting } from "../tui/hyperlink";
|
|
38
39
|
import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
|
|
@@ -232,6 +233,10 @@ export class Settings {
|
|
|
232
233
|
* Returns the merged value from global + project + overrides, or the default.
|
|
233
234
|
*/
|
|
234
235
|
get<P extends SettingPath>(path: P): SettingValue<P> {
|
|
236
|
+
const execution = currentContextExecution();
|
|
237
|
+
if (execution && path === "bash.environment") return execution.environment as SettingValue<P>;
|
|
238
|
+
if (execution && path === "xcsh.sensitiveKeys") return execution.sensitiveKeys as SettingValue<P>;
|
|
239
|
+
if (execution && path === "xcsh.contextSource") return execution.source as SettingValue<P>;
|
|
235
240
|
const segments = path.split(".");
|
|
236
241
|
const value = getByPath(this.#merged, segments);
|
|
237
242
|
if (value !== undefined) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { BUILD_INFO } from "./internal-urls/build-info.generated";
|
|
6
|
+
import { EMBEDDED_DOCUMENTATION_ASSETS } from "./internal-urls/documentation-assets.generated";
|
|
7
|
+
import { createEmbeddedDocumentationRepository } from "./internal-urls/documentation-repository";
|
|
8
|
+
import { createDocumentationResolver } from "./internal-urls/documentation-resolve";
|
|
9
|
+
import type { InternalUrl } from "./internal-urls/types";
|
|
10
|
+
|
|
11
|
+
function hash(text: string | Uint8Array): string {
|
|
12
|
+
return createHash("sha256").update(text).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
export function validateDocumentationMeasurementRequests(input: unknown): string[] {
|
|
15
|
+
if (!Array.isArray(input) || input.length < 1 || input.length > 10000 || input.some(uri => typeof uri !== "string"))
|
|
16
|
+
throw new Error("Measurement requires one to 10000 request URIs");
|
|
17
|
+
for (const uri of input) {
|
|
18
|
+
const u = new URL(uri);
|
|
19
|
+
if (u.protocol !== "xcsh:" || u.host !== "documentation" || u.username || u.password)
|
|
20
|
+
throw new Error("Measurement requires general documentation URIs");
|
|
21
|
+
}
|
|
22
|
+
return input;
|
|
23
|
+
}
|
|
24
|
+
export async function measureDocumentationRequests(
|
|
25
|
+
input: unknown,
|
|
26
|
+
read: (uri: string) => Promise<{ content: string }>,
|
|
27
|
+
) {
|
|
28
|
+
const results = [];
|
|
29
|
+
for (const request of validateDocumentationMeasurementRequests(input)) {
|
|
30
|
+
let response = "",
|
|
31
|
+
context = "",
|
|
32
|
+
responseHash = "";
|
|
33
|
+
const times: number[] = [];
|
|
34
|
+
for (let n = 0; n < 5; n++) {
|
|
35
|
+
const start = performance.now();
|
|
36
|
+
response = (await read(request)).content;
|
|
37
|
+
times.push(performance.now() - start);
|
|
38
|
+
context = "";
|
|
39
|
+
const destination = [...response.matchAll(/^(?:- )?Read: `?(xcsh:\/\/[^`\s]+)`?/gm)][0]?.[1];
|
|
40
|
+
if (destination) {
|
|
41
|
+
validateDocumentationMeasurementRequests([destination]);
|
|
42
|
+
context = (await read(destination)).content;
|
|
43
|
+
}
|
|
44
|
+
const currentHash = hash(`${response}\0${context}`);
|
|
45
|
+
if (!n) responseHash = currentHash;
|
|
46
|
+
else if (currentHash !== responseHash) throw new Error("Non-deterministic complete retrieval response");
|
|
47
|
+
}
|
|
48
|
+
results.push({ request, response, context, response_sha256: responseHash, times_ms: times });
|
|
49
|
+
}
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
export async function runDocumentationMeasurement(requestFile: string): Promise<string> {
|
|
53
|
+
const assets = EMBEDDED_DOCUMENTATION_ASSETS;
|
|
54
|
+
if (!assets) throw new Error("Measurement requires embedded documentation");
|
|
55
|
+
const bytes = await readFile(requestFile);
|
|
56
|
+
if (bytes.length > 4 * 1024 * 1024) throw new Error("Measurement request file exceeds 4 MiB");
|
|
57
|
+
const repository = createEmbeddedDocumentationRepository(assets, {
|
|
58
|
+
cacheRoot: path.join(os.homedir(), ".xcsh/cache/documentation-measurement"),
|
|
59
|
+
});
|
|
60
|
+
const start = performance.now();
|
|
61
|
+
await repository.prime();
|
|
62
|
+
const materializationMs = performance.now() - start;
|
|
63
|
+
const resolver = createDocumentationResolver(repository);
|
|
64
|
+
const results = await measureDocumentationRequests(JSON.parse(bytes.toString()), async uri => {
|
|
65
|
+
const resource = await resolver.resolve(Object.assign(new URL(uri), { rawHost: "documentation" }) as InternalUrl);
|
|
66
|
+
if (typeof resource.content !== "string") throw new Error("Measurement requires text resources");
|
|
67
|
+
return { content: resource.content };
|
|
68
|
+
});
|
|
69
|
+
return JSON.stringify({
|
|
70
|
+
schema_version: 1,
|
|
71
|
+
installed_resolver: true,
|
|
72
|
+
regression_only: true,
|
|
73
|
+
build: BUILD_INFO,
|
|
74
|
+
release_tag: assets.releaseTag,
|
|
75
|
+
source_commit: assets.sourceCommit,
|
|
76
|
+
index_sha256: assets.indexSha256,
|
|
77
|
+
request_sha256: hash(bytes),
|
|
78
|
+
platform: os.platform(),
|
|
79
|
+
arch: os.arch(),
|
|
80
|
+
repetitions: 5,
|
|
81
|
+
materialization_ms: materializationMs,
|
|
82
|
+
results,
|
|
83
|
+
});
|
|
84
|
+
}
|