@sdxc/spec 0.0.0-pre.1
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/LICENSE.md +21 -0
- package/README.md +924 -0
- package/dist/ast.d.ts +193 -0
- package/dist/ast.js +9 -0
- package/dist/builtins.d.ts +29 -0
- package/dist/builtins.js +66 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +297 -0
- package/dist/diagnostics.d.ts +47 -0
- package/dist/diagnostics.js +8 -0
- package/dist/errors.d.ts +131 -0
- package/dist/errors.js +159 -0
- package/dist/executor.d.ts +66 -0
- package/dist/executor.js +320 -0
- package/dist/expectation.d.ts +61 -0
- package/dist/expectation.js +222 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +36 -0
- package/dist/lexer.d.ts +22 -0
- package/dist/lexer.js +284 -0
- package/dist/loader.d.ts +21 -0
- package/dist/loader.js +81 -0
- package/dist/parser.d.ts +24 -0
- package/dist/parser.js +502 -0
- package/dist/permissions.d.ts +139 -0
- package/dist/permissions.js +325 -0
- package/dist/plugin.d.ts +90 -0
- package/dist/plugin.js +9 -0
- package/dist/plugins/browser.d.ts +24 -0
- package/dist/plugins/browser.js +896 -0
- package/dist/plugins/cli.d.ts +17 -0
- package/dist/plugins/cli.js +134 -0
- package/dist/plugins/db-e2e-probe.d.ts +14 -0
- package/dist/plugins/db-e2e-probe.js +112 -0
- package/dist/plugins/db.d.ts +19 -0
- package/dist/plugins/db.js +199 -0
- package/dist/plugins/demo.d.ts +17 -0
- package/dist/plugins/demo.js +70 -0
- package/dist/plugins/env.d.ts +18 -0
- package/dist/plugins/env.js +87 -0
- package/dist/plugins/fs.d.ts +16 -0
- package/dist/plugins/fs.js +415 -0
- package/dist/plugins/http.d.ts +19 -0
- package/dist/plugins/http.js +505 -0
- package/dist/plugins/jwt.d.ts +17 -0
- package/dist/plugins/jwt.js +342 -0
- package/dist/plugins/sample.d.ts +27 -0
- package/dist/plugins/sample.js +400 -0
- package/dist/plugins/url.d.ts +18 -0
- package/dist/plugins/url.js +126 -0
- package/dist/project-config.d.ts +163 -0
- package/dist/project-config.js +497 -0
- package/dist/registry.d.ts +56 -0
- package/dist/registry.js +110 -0
- package/dist/reporter.d.ts +30 -0
- package/dist/reporter.js +237 -0
- package/dist/run.d.ts +74 -0
- package/dist/run.js +179 -0
- package/dist/runner.d.ts +52 -0
- package/dist/runner.js +38 -0
- package/dist/source.d.ts +37 -0
- package/dist/source.js +31 -0
- package/dist/sources.d.ts +45 -0
- package/dist/sources.js +54 -0
- package/dist/tokens.d.ts +34 -0
- package/dist/tokens.js +25 -0
- package/dist/transport-stdio.d.ts +34 -0
- package/dist/transport-stdio.js +400 -0
- package/dist/values.d.ts +48 -0
- package/dist/values.js +52 -0
- package/dist/workers.d.ts +40 -0
- package/dist/workers.js +26 -0
- package/dist/workspace-none.d.ts +23 -0
- package/dist/workspace-none.js +33 -0
- package/dist/workspace.d.ts +47 -0
- package/dist/workspace.js +116 -0
- package/package.json +28 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suite's project configuration file, `spec/config.jsonc`: the CLI-internal
|
|
3
|
+
* machinery that reads it, decides which declared plugins the caller authorized
|
|
4
|
+
* to launch, and connects the authorized ones over the stdio transport. The
|
|
5
|
+
* file's `plugins` key maps a namespace to its launch command; launching one
|
|
6
|
+
* runs project-declared code, so it is deny-by-default — a plugin starts only
|
|
7
|
+
* when `--allow-plugins` grants it. `config.jsonc` is the suite's general
|
|
8
|
+
* configuration home; its `permissions` key is parsed here too.
|
|
9
|
+
*
|
|
10
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
11
|
+
* @copyright Sergio Xalambrí 2026
|
|
12
|
+
*/
|
|
13
|
+
import type { Result } from "@sdxc/result";
|
|
14
|
+
import type { ConfigPermissionEntry } from "./permissions.js";
|
|
15
|
+
import type { Plugin } from "./plugin.js";
|
|
16
|
+
import type { LoadedSuite } from "./sources.js";
|
|
17
|
+
import { SpecError } from "./errors.js";
|
|
18
|
+
/**
|
|
19
|
+
* Whether, and how far, the caller authorized launching declared plugins.
|
|
20
|
+
* Absent `--allow-plugins` leaves it `denied`; a bare flag is `all`; a scoped
|
|
21
|
+
* `--allow-plugins=a,b` names the namespaces allowed to launch.
|
|
22
|
+
*/
|
|
23
|
+
export type PluginLaunchGrant = {
|
|
24
|
+
mode: "denied";
|
|
25
|
+
} | {
|
|
26
|
+
mode: "all";
|
|
27
|
+
} | {
|
|
28
|
+
mode: "scoped";
|
|
29
|
+
namespaces: string[];
|
|
30
|
+
};
|
|
31
|
+
/** One plugin a project declares: its namespace and the command to launch it. */
|
|
32
|
+
export interface PluginDeclaration {
|
|
33
|
+
/** The namespace the plugin's tools live under, e.g. `"demo"`. */
|
|
34
|
+
namespace: string;
|
|
35
|
+
/**
|
|
36
|
+
* The argv that launches the plugin. Relative path arguments (those starting
|
|
37
|
+
* with `.`) are already resolved absolute against the config file's
|
|
38
|
+
* directory, so the command runs the same from any working directory.
|
|
39
|
+
*/
|
|
40
|
+
command: string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A parsed `spec/config.jsonc`: the suite's project configuration. The
|
|
44
|
+
* `plugins` key lists the plugins a project declares, in file order; the
|
|
45
|
+
* `permissions` key declares the grants that stay inert until `--allow-config`.
|
|
46
|
+
*/
|
|
47
|
+
export interface ProjectConfig {
|
|
48
|
+
/** The declared plugins; empty when no config exists or it declares none. */
|
|
49
|
+
plugins: PluginDeclaration[];
|
|
50
|
+
/** The suite's declared permission requirements; inert without `--allow-config`. */
|
|
51
|
+
permissions: PermissionsConfig;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The `permissions` key of `spec/config.jsonc`: a suite's declared grant
|
|
55
|
+
* requirements. Deny-by-default is preserved — these are inert until the caller
|
|
56
|
+
* passes `--allow-config`, so a cloned repo cannot self-grant.
|
|
57
|
+
*/
|
|
58
|
+
export interface PermissionsConfig {
|
|
59
|
+
/** The declared grants, in file order; empty when none are declared. */
|
|
60
|
+
allow: ConfigPermissionEntry[];
|
|
61
|
+
}
|
|
62
|
+
/** Splitting the config's declared plugins into those to launch and those refused. */
|
|
63
|
+
export interface LaunchPlan {
|
|
64
|
+
/** Declarations the grant authorized, to be connected. */
|
|
65
|
+
launch: PluginDeclaration[];
|
|
66
|
+
/** Namespaces declared but not authorized to launch. */
|
|
67
|
+
deniedNamespaces: string[];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Extract the `--allow-plugins` grant from a raw argument list, leaving the
|
|
71
|
+
* remaining arguments for the permission parser to handle. Plugin launch sits
|
|
72
|
+
* outside the four `--allow-*` capability families, so it gets its own parse here.
|
|
73
|
+
*
|
|
74
|
+
* @param args - The raw CLI arguments after `run`.
|
|
75
|
+
* @returns The launch grant plus the arguments that were not `--allow-plugins`.
|
|
76
|
+
*/
|
|
77
|
+
export declare function parsePluginGrant(args: string[]): Result<{
|
|
78
|
+
grant: PluginLaunchGrant;
|
|
79
|
+
remaining: string[];
|
|
80
|
+
}, SpecError>;
|
|
81
|
+
/**
|
|
82
|
+
* Read the `spec/config.jsonc` that governs a suite directory (JSONC, so
|
|
83
|
+
* comments and trailing commas are tolerated), trying `config.jsonc` before
|
|
84
|
+
* `config.json`. A missing file simply declares no plugins.
|
|
85
|
+
*
|
|
86
|
+
* @param suiteRoot - The suite directory `spec run` was pointed at.
|
|
87
|
+
* @returns The parsed config, or the failure that made it unreadable.
|
|
88
|
+
*/
|
|
89
|
+
export declare function loadProjectConfig(suiteRoot: string): Promise<Result<ProjectConfig, SpecError>>;
|
|
90
|
+
/**
|
|
91
|
+
* Split the config's declarations into the ones the grant authorizes and the
|
|
92
|
+
* namespaces it refuses. An unimported refusal stays harmless — only
|
|
93
|
+
* {@link deniedReferences} turns a refusal the suite depends on into a failure.
|
|
94
|
+
*
|
|
95
|
+
* @param config - The parsed project config.
|
|
96
|
+
* @param grant - The caller's `--allow-plugins` grant.
|
|
97
|
+
* @returns The plugins to launch and the namespaces refused.
|
|
98
|
+
*/
|
|
99
|
+
export declare function planPluginLaunch(config: ProjectConfig, grant: PluginLaunchGrant): LaunchPlan;
|
|
100
|
+
/**
|
|
101
|
+
* Which refused namespaces the suite actually imports with `use` — a denied
|
|
102
|
+
* declaration only matters when a spec depends on it. The result drives the
|
|
103
|
+
* deny-by-default diagnostic, naming exactly the grant a caller is missing.
|
|
104
|
+
*
|
|
105
|
+
* @param suite - The loaded suite, for its files' `use` imports.
|
|
106
|
+
* @param deniedNamespaces - The namespaces refused by {@link planPluginLaunch}.
|
|
107
|
+
* @returns The refused namespaces the suite imports, in first-seen order.
|
|
108
|
+
*/
|
|
109
|
+
export declare function deniedReferences(suite: LoadedSuite, deniedNamespaces: string[]): string[];
|
|
110
|
+
/**
|
|
111
|
+
* Build the deny-by-default diagnostic for a suite that imports plugins it was
|
|
112
|
+
* not authorized to launch, shaped like every other refused capability: code
|
|
113
|
+
* `permission-denied` with a `--allow-plugins` remedy.
|
|
114
|
+
*
|
|
115
|
+
* @param namespaces - The imported-but-unauthorized namespaces.
|
|
116
|
+
* @returns The error the CLI reports before any test runs.
|
|
117
|
+
*/
|
|
118
|
+
export declare function launchDeniedError(namespaces: string[]): SpecError;
|
|
119
|
+
/**
|
|
120
|
+
* Connect the authorized plugins over the stdio transport, in declaration
|
|
121
|
+
* order, each spawning its command and completing the describe handshake. A
|
|
122
|
+
* failure disposes every plugin already connected, so a launch never leaks a child.
|
|
123
|
+
*
|
|
124
|
+
* @param launch - The declarations {@link planPluginLaunch} authorized.
|
|
125
|
+
* @returns The connected plugins, ready to pass to `runSuite`, or the failure.
|
|
126
|
+
*/
|
|
127
|
+
export declare function connectDeclaredPlugins(launch: PluginDeclaration[]): Promise<Result<Plugin[], SpecError>>;
|
|
128
|
+
/**
|
|
129
|
+
* Dispose a set of connected plugins, best-effort. Used to unwind a partial
|
|
130
|
+
* launch and by the CLI when a run never reaches the runner's own teardown;
|
|
131
|
+
* a throwing or absent `dispose` is ignored, exactly as the runner treats it.
|
|
132
|
+
*
|
|
133
|
+
* @param plugins - The plugins to release.
|
|
134
|
+
*/
|
|
135
|
+
export declare function disposeAll(plugins: Plugin[]): Promise<void>;
|
|
136
|
+
/**
|
|
137
|
+
* The plugin launch grant a config's `permissions.allow` declares: a bare
|
|
138
|
+
* `"plugins"` entry authorizes every plugin, a `["plugins", ...]` tuple names
|
|
139
|
+
* the namespaces, applied under `--allow-config` and unioned with `--allow-plugins`.
|
|
140
|
+
*
|
|
141
|
+
* @param entries - The validated allow-list entries.
|
|
142
|
+
* @returns The launch grant the config declares.
|
|
143
|
+
*/
|
|
144
|
+
export declare function pluginGrantFromConfig(entries: readonly ConfigPermissionEntry[]): PluginLaunchGrant;
|
|
145
|
+
/**
|
|
146
|
+
* Union two plugin launch grants, widening `base` by whatever `extra` adds —
|
|
147
|
+
* the launch-grant analogue of the capability-grant union, used to fold a
|
|
148
|
+
* config's declared `plugins` grant into the caller's `--allow-plugins` grant.
|
|
149
|
+
*
|
|
150
|
+
* @param base - The caller's `--allow-plugins` grant.
|
|
151
|
+
* @param extra - The config's declared plugin launch grant.
|
|
152
|
+
* @returns The unioned launch grant.
|
|
153
|
+
*/
|
|
154
|
+
export declare function mergePluginGrants(base: PluginLaunchGrant, extra: PluginLaunchGrant): PluginLaunchGrant;
|
|
155
|
+
/**
|
|
156
|
+
* Whether a plugin launch grant authorizes launching a namespace — exposed so
|
|
157
|
+
* the CLI can decide the `--allow-config` hint for a plugin-launch denial.
|
|
158
|
+
*
|
|
159
|
+
* @param grant - The launch grant to test.
|
|
160
|
+
* @param namespace - The namespace being launched.
|
|
161
|
+
* @returns Whether the grant admits it.
|
|
162
|
+
*/
|
|
163
|
+
export declare function pluginGrantAdmits(grant: PluginLaunchGrant, namespace: string): boolean;
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suite's project configuration file, `spec/config.jsonc`: the CLI-internal
|
|
3
|
+
* machinery that reads it, decides which declared plugins the caller authorized
|
|
4
|
+
* to launch, and connects the authorized ones over the stdio transport. The
|
|
5
|
+
* file's `plugins` key maps a namespace to its launch command; launching one
|
|
6
|
+
* runs project-declared code, so it is deny-by-default — a plugin starts only
|
|
7
|
+
* when `--allow-plugins` grants it. `config.jsonc` is the suite's general
|
|
8
|
+
* configuration home; its `permissions` key is parsed here too.
|
|
9
|
+
*
|
|
10
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
11
|
+
* @copyright Sergio Xalambrí 2026
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { isAbsolute, resolve } from "node:path";
|
|
16
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
17
|
+
import { LoadError, SpecError, ToolError } from "./errors.js";
|
|
18
|
+
import { connectStdioPlugin } from "./transport-stdio.js";
|
|
19
|
+
/** The permission families a `spec/config.jsonc` `permissions.allow` may name. */
|
|
20
|
+
const PERMISSION_FAMILIES = new Set([
|
|
21
|
+
"run",
|
|
22
|
+
"net",
|
|
23
|
+
"env",
|
|
24
|
+
"host-fs",
|
|
25
|
+
"plugins",
|
|
26
|
+
]);
|
|
27
|
+
/** Conventional config file names, tried in this order under the suite dir. */
|
|
28
|
+
const CONFIG_NAMES = ["config.jsonc", "config.json"];
|
|
29
|
+
/** The `--allow-plugins` flag and its scoped form, parsed CLI-side. */
|
|
30
|
+
const ALLOW_PLUGINS_FLAG = "--allow-plugins";
|
|
31
|
+
/**
|
|
32
|
+
* Namespaces the runtime provides itself; the config's `plugins` key may not
|
|
33
|
+
* claim one of them, because a declared plugin sharing the name would silently
|
|
34
|
+
* shadow the built-in in the registry.
|
|
35
|
+
*/
|
|
36
|
+
const BUILT_IN_NAMESPACES = new Set([
|
|
37
|
+
"fs",
|
|
38
|
+
"cli",
|
|
39
|
+
"http",
|
|
40
|
+
"browser",
|
|
41
|
+
"db",
|
|
42
|
+
"url",
|
|
43
|
+
"jwt",
|
|
44
|
+
"env",
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* Extract the `--allow-plugins` grant from a raw argument list, leaving the
|
|
48
|
+
* remaining arguments for the permission parser to handle. Plugin launch sits
|
|
49
|
+
* outside the four `--allow-*` capability families, so it gets its own parse here.
|
|
50
|
+
*
|
|
51
|
+
* @param args - The raw CLI arguments after `run`.
|
|
52
|
+
* @returns The launch grant plus the arguments that were not `--allow-plugins`.
|
|
53
|
+
*/
|
|
54
|
+
export function parsePluginGrant(args) {
|
|
55
|
+
let grant = { mode: "denied" };
|
|
56
|
+
let remaining = [];
|
|
57
|
+
for (let argument of args) {
|
|
58
|
+
if (argument === ALLOW_PLUGINS_FLAG) {
|
|
59
|
+
grant = grant.mode === "all" ? grant : { mode: "all" };
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!argument.startsWith(`${ALLOW_PLUGINS_FLAG}=`)) {
|
|
63
|
+
remaining.push(argument);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let namespaces = argument
|
|
67
|
+
.slice(ALLOW_PLUGINS_FLAG.length + 1)
|
|
68
|
+
.split(",")
|
|
69
|
+
.map((namespace) => namespace.trim())
|
|
70
|
+
.filter((namespace) => namespace.length > 0);
|
|
71
|
+
if (namespaces.length === 0) {
|
|
72
|
+
return failure(new SpecError("usage-error", `${ALLOW_PLUGINS_FLAG}= expects a comma-separated namespace list, e.g. ${ALLOW_PLUGINS_FLAG}=demo.`));
|
|
73
|
+
}
|
|
74
|
+
grant = widenLaunchGrant(grant, namespaces);
|
|
75
|
+
}
|
|
76
|
+
return success({ grant, remaining });
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Read the `spec/config.jsonc` that governs a suite directory (JSONC, so
|
|
80
|
+
* comments and trailing commas are tolerated), trying `config.jsonc` before
|
|
81
|
+
* `config.json`. A missing file simply declares no plugins.
|
|
82
|
+
*
|
|
83
|
+
* @param suiteRoot - The suite directory `spec run` was pointed at.
|
|
84
|
+
* @returns The parsed config, or the failure that made it unreadable.
|
|
85
|
+
*/
|
|
86
|
+
export async function loadProjectConfig(suiteRoot) {
|
|
87
|
+
let directory = resolve(suiteRoot);
|
|
88
|
+
for (let name of CONFIG_NAMES) {
|
|
89
|
+
let path = resolve(directory, name);
|
|
90
|
+
if (!existsSync(path))
|
|
91
|
+
continue;
|
|
92
|
+
let text;
|
|
93
|
+
try {
|
|
94
|
+
text = await readFile(path, "utf8");
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
return failure(new LoadError("load-error", `Cannot read spec/config.jsonc ${path}: ${errorMessage(error)}`));
|
|
98
|
+
}
|
|
99
|
+
let parsed = parseJsonc(text, path);
|
|
100
|
+
if (isFailure(parsed))
|
|
101
|
+
return parsed;
|
|
102
|
+
return validateConfig(parsed.data, directory, path);
|
|
103
|
+
}
|
|
104
|
+
return success({ plugins: [], permissions: { allow: [] } });
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Split the config's declarations into the ones the grant authorizes and the
|
|
108
|
+
* namespaces it refuses. An unimported refusal stays harmless — only
|
|
109
|
+
* {@link deniedReferences} turns a refusal the suite depends on into a failure.
|
|
110
|
+
*
|
|
111
|
+
* @param config - The parsed project config.
|
|
112
|
+
* @param grant - The caller's `--allow-plugins` grant.
|
|
113
|
+
* @returns The plugins to launch and the namespaces refused.
|
|
114
|
+
*/
|
|
115
|
+
export function planPluginLaunch(config, grant) {
|
|
116
|
+
let launch = [];
|
|
117
|
+
let deniedNamespaces = [];
|
|
118
|
+
for (let declaration of config.plugins) {
|
|
119
|
+
if (grantAdmits(grant, declaration.namespace))
|
|
120
|
+
launch.push(declaration);
|
|
121
|
+
else
|
|
122
|
+
deniedNamespaces.push(declaration.namespace);
|
|
123
|
+
}
|
|
124
|
+
return { launch, deniedNamespaces };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Which refused namespaces the suite actually imports with `use` — a denied
|
|
128
|
+
* declaration only matters when a spec depends on it. The result drives the
|
|
129
|
+
* deny-by-default diagnostic, naming exactly the grant a caller is missing.
|
|
130
|
+
*
|
|
131
|
+
* @param suite - The loaded suite, for its files' `use` imports.
|
|
132
|
+
* @param deniedNamespaces - The namespaces refused by {@link planPluginLaunch}.
|
|
133
|
+
* @returns The refused namespaces the suite imports, in first-seen order.
|
|
134
|
+
*/
|
|
135
|
+
export function deniedReferences(suite, deniedNamespaces) {
|
|
136
|
+
if (deniedNamespaces.length === 0)
|
|
137
|
+
return [];
|
|
138
|
+
let denied = new Set(deniedNamespaces);
|
|
139
|
+
let referenced = [];
|
|
140
|
+
let seen = new Set();
|
|
141
|
+
for (let file of suite.files) {
|
|
142
|
+
for (let use of file.uses) {
|
|
143
|
+
if (denied.has(use.namespace) && !seen.has(use.namespace)) {
|
|
144
|
+
seen.add(use.namespace);
|
|
145
|
+
referenced.push(use.namespace);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return referenced;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Build the deny-by-default diagnostic for a suite that imports plugins it was
|
|
153
|
+
* not authorized to launch, shaped like every other refused capability: code
|
|
154
|
+
* `permission-denied` with a `--allow-plugins` remedy.
|
|
155
|
+
*
|
|
156
|
+
* @param namespaces - The imported-but-unauthorized namespaces.
|
|
157
|
+
* @returns The error the CLI reports before any test runs.
|
|
158
|
+
*/
|
|
159
|
+
export function launchDeniedError(namespaces) {
|
|
160
|
+
let list = namespaces.join(", ");
|
|
161
|
+
let plural = namespaces.length === 1 ? "namespace" : "namespaces";
|
|
162
|
+
let error = new SpecError("permission-denied", `Plugin launch denied: the suite imports the plugin ${plural} ${list}, declared in spec/config.jsonc but not authorized to launch. Launching a declared plugin executes the command the project declares for it, so it is denied unless you allow it.`);
|
|
163
|
+
error.remedy = `spec run ${ALLOW_PLUGINS_FLAG}=${namespaces.join(",")}`;
|
|
164
|
+
return error;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Connect the authorized plugins over the stdio transport, in declaration
|
|
168
|
+
* order, each spawning its command and completing the describe handshake. A
|
|
169
|
+
* failure disposes every plugin already connected, so a launch never leaks a child.
|
|
170
|
+
*
|
|
171
|
+
* @param launch - The declarations {@link planPluginLaunch} authorized.
|
|
172
|
+
* @returns The connected plugins, ready to pass to `runSuite`, or the failure.
|
|
173
|
+
*/
|
|
174
|
+
export async function connectDeclaredPlugins(launch) {
|
|
175
|
+
let connected = [];
|
|
176
|
+
for (let declaration of launch) {
|
|
177
|
+
let result = await connectStdioPlugin(declaration.command, declaration.namespace);
|
|
178
|
+
if (isFailure(result)) {
|
|
179
|
+
await disposeAll(connected);
|
|
180
|
+
return failure(new ToolError(`Failed to load plugin "${declaration.namespace}" from its spec/config.jsonc command "${declaration.command.join(" ")}": ${result.error.message}`));
|
|
181
|
+
}
|
|
182
|
+
connected.push(result.data);
|
|
183
|
+
}
|
|
184
|
+
return success(connected);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Dispose a set of connected plugins, best-effort. Used to unwind a partial
|
|
188
|
+
* launch and by the CLI when a run never reaches the runner's own teardown;
|
|
189
|
+
* a throwing or absent `dispose` is ignored, exactly as the runner treats it.
|
|
190
|
+
*
|
|
191
|
+
* @param plugins - The plugins to release.
|
|
192
|
+
*/
|
|
193
|
+
export async function disposeAll(plugins) {
|
|
194
|
+
for (let plugin of plugins) {
|
|
195
|
+
if (plugin.dispose === undefined)
|
|
196
|
+
continue;
|
|
197
|
+
try {
|
|
198
|
+
await plugin.dispose();
|
|
199
|
+
}
|
|
200
|
+
catch { }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Merge one `--allow-plugins=` occurrence into the grant so far. */
|
|
204
|
+
function widenLaunchGrant(current, namespaces) {
|
|
205
|
+
if (current.mode === "all")
|
|
206
|
+
return current;
|
|
207
|
+
if (current.mode === "denied")
|
|
208
|
+
return { mode: "scoped", namespaces: [...namespaces] };
|
|
209
|
+
let merged = [...current.namespaces];
|
|
210
|
+
for (let namespace of namespaces) {
|
|
211
|
+
if (!merged.includes(namespace))
|
|
212
|
+
merged.push(namespace);
|
|
213
|
+
}
|
|
214
|
+
return { mode: "scoped", namespaces: merged };
|
|
215
|
+
}
|
|
216
|
+
function grantAdmits(grant, namespace) {
|
|
217
|
+
if (grant.mode === "all")
|
|
218
|
+
return true;
|
|
219
|
+
if (grant.mode === "scoped")
|
|
220
|
+
return grant.namespaces.includes(namespace);
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
/** Parse config text as JSONC, tolerating comments and trailing commas. */
|
|
224
|
+
function parseJsonc(text, path) {
|
|
225
|
+
try {
|
|
226
|
+
return success(JSON.parse(stripTrailingCommas(stripComments(text))));
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
return failure(new LoadError("load-error", `spec/config.jsonc ${path} is not valid JSONC: ${errorMessage(error)}`));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Shape a parsed config object into a {@link ProjectConfig}, rejecting every
|
|
234
|
+
* malformed declaration with a `load-error` that names the offending plugin.
|
|
235
|
+
* Relative command paths are resolved absolute against the config directory.
|
|
236
|
+
*/
|
|
237
|
+
function validateConfig(parsed, directory, path) {
|
|
238
|
+
if (!isRecord(parsed)) {
|
|
239
|
+
return failure(new LoadError("load-error", `spec/config.jsonc ${path} must be a JSON object.`));
|
|
240
|
+
}
|
|
241
|
+
let permissions = validatePermissions(parsed.permissions, path);
|
|
242
|
+
if (isFailure(permissions))
|
|
243
|
+
return permissions;
|
|
244
|
+
let pluginsField = parsed.plugins;
|
|
245
|
+
if (pluginsField === undefined)
|
|
246
|
+
return success({ plugins: [], permissions: permissions.data });
|
|
247
|
+
if (!isRecord(pluginsField)) {
|
|
248
|
+
return failure(new LoadError("load-error", `spec/config.jsonc ${path} must map "plugins" to an object of namespace → { command }.`));
|
|
249
|
+
}
|
|
250
|
+
let plugins = [];
|
|
251
|
+
for (let [namespace, declaration] of Object.entries(pluginsField)) {
|
|
252
|
+
let validated = validateDeclaration(namespace, declaration, directory, path);
|
|
253
|
+
if (isFailure(validated))
|
|
254
|
+
return validated;
|
|
255
|
+
plugins.push(validated.data);
|
|
256
|
+
}
|
|
257
|
+
return success({ plugins, permissions: permissions.data });
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Validate the `permissions` key into a {@link PermissionsConfig}. An absent
|
|
261
|
+
* key declares nothing; every malformed entry becomes a `usage-error` naming
|
|
262
|
+
* it, checked at config load time regardless of `--allow-config`.
|
|
263
|
+
*
|
|
264
|
+
* @param field - The raw `permissions` value from the parsed config.
|
|
265
|
+
* @param path - The config file path, for diagnostics.
|
|
266
|
+
* @returns The validated permission entries, or the first malformed one.
|
|
267
|
+
*/
|
|
268
|
+
function validatePermissions(field, path) {
|
|
269
|
+
if (field === undefined)
|
|
270
|
+
return success({ allow: [] });
|
|
271
|
+
if (!isRecord(field)) {
|
|
272
|
+
return failure(new SpecError("usage-error", `spec/config.jsonc ${path} must map "permissions" to an object with an "allow" list.`));
|
|
273
|
+
}
|
|
274
|
+
let allowField = field.allow;
|
|
275
|
+
if (allowField === undefined)
|
|
276
|
+
return success({ allow: [] });
|
|
277
|
+
if (!Array.isArray(allowField)) {
|
|
278
|
+
return failure(new SpecError("usage-error", `spec/config.jsonc ${path} must map "permissions.allow" to a list of grants.`));
|
|
279
|
+
}
|
|
280
|
+
let allow = [];
|
|
281
|
+
for (let entry of allowField) {
|
|
282
|
+
let validated = validatePermissionEntry(entry, path);
|
|
283
|
+
if (isFailure(validated))
|
|
284
|
+
return validated;
|
|
285
|
+
allow.push(validated.data);
|
|
286
|
+
}
|
|
287
|
+
return success({ allow });
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Validate one `permissions.allow` entry: a bare family string (whole-family
|
|
291
|
+
* grant) or a `[family, ...scopes]` tuple with at least one non-empty scope,
|
|
292
|
+
* the family always one the runtime knows, else a `usage-error` naming it.
|
|
293
|
+
*/
|
|
294
|
+
function validatePermissionEntry(entry, path) {
|
|
295
|
+
if (typeof entry === "string") {
|
|
296
|
+
if (!PERMISSION_FAMILIES.has(entry))
|
|
297
|
+
return failure(unknownFamily(entry, path));
|
|
298
|
+
return success({ family: entry, scopes: [] });
|
|
299
|
+
}
|
|
300
|
+
if (Array.isArray(entry)) {
|
|
301
|
+
let family = entry[0];
|
|
302
|
+
if (typeof family !== "string" || !PERMISSION_FAMILIES.has(family)) {
|
|
303
|
+
return failure(unknownFamily(describeEntry(entry), path));
|
|
304
|
+
}
|
|
305
|
+
let scopes = entry.slice(1);
|
|
306
|
+
if (scopes.length === 0 ||
|
|
307
|
+
!scopes.every((scope) => typeof scope === "string" && scope.length > 0)) {
|
|
308
|
+
return failure(new SpecError("usage-error", `spec/config.jsonc ${path} declares a malformed grant ${describeEntry(entry)}: a tuple is [family, ...non-empty string scopes].`));
|
|
309
|
+
}
|
|
310
|
+
return success({
|
|
311
|
+
family: family,
|
|
312
|
+
scopes: scopes,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return failure(new SpecError("usage-error", `spec/config.jsonc ${path} declares a malformed grant ${describeEntry(entry)}: each allow entry is a family string or a [family, ...scopes] tuple.`));
|
|
316
|
+
}
|
|
317
|
+
/** A `usage-error` for an allow entry that names an unrecognized family. */
|
|
318
|
+
function unknownFamily(entry, path) {
|
|
319
|
+
return new SpecError("usage-error", `spec/config.jsonc ${path} declares an unknown permission family ${entry}: known families are run, net, env, host-fs, plugins.`);
|
|
320
|
+
}
|
|
321
|
+
/** Render an allow entry for a diagnostic, quoting strings and JSON-ing the rest. */
|
|
322
|
+
function describeEntry(entry) {
|
|
323
|
+
if (typeof entry === "string")
|
|
324
|
+
return `"${entry}"`;
|
|
325
|
+
try {
|
|
326
|
+
return JSON.stringify(entry);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return String(entry);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The plugin launch grant a config's `permissions.allow` declares: a bare
|
|
334
|
+
* `"plugins"` entry authorizes every plugin, a `["plugins", ...]` tuple names
|
|
335
|
+
* the namespaces, applied under `--allow-config` and unioned with `--allow-plugins`.
|
|
336
|
+
*
|
|
337
|
+
* @param entries - The validated allow-list entries.
|
|
338
|
+
* @returns The launch grant the config declares.
|
|
339
|
+
*/
|
|
340
|
+
export function pluginGrantFromConfig(entries) {
|
|
341
|
+
let grant = { mode: "denied" };
|
|
342
|
+
for (let entry of entries) {
|
|
343
|
+
if (entry.family !== "plugins")
|
|
344
|
+
continue;
|
|
345
|
+
grant = entry.scopes.length === 0 ? { mode: "all" } : widenLaunchGrant(grant, entry.scopes);
|
|
346
|
+
}
|
|
347
|
+
return grant;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Union two plugin launch grants, widening `base` by whatever `extra` adds —
|
|
351
|
+
* the launch-grant analogue of the capability-grant union, used to fold a
|
|
352
|
+
* config's declared `plugins` grant into the caller's `--allow-plugins` grant.
|
|
353
|
+
*
|
|
354
|
+
* @param base - The caller's `--allow-plugins` grant.
|
|
355
|
+
* @param extra - The config's declared plugin launch grant.
|
|
356
|
+
* @returns The unioned launch grant.
|
|
357
|
+
*/
|
|
358
|
+
export function mergePluginGrants(base, extra) {
|
|
359
|
+
if (extra.mode === "denied")
|
|
360
|
+
return base;
|
|
361
|
+
if (extra.mode === "all")
|
|
362
|
+
return { mode: "all" };
|
|
363
|
+
return widenLaunchGrant(base, extra.namespaces);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Whether a plugin launch grant authorizes launching a namespace — exposed so
|
|
367
|
+
* the CLI can decide the `--allow-config` hint for a plugin-launch denial.
|
|
368
|
+
*
|
|
369
|
+
* @param grant - The launch grant to test.
|
|
370
|
+
* @param namespace - The namespace being launched.
|
|
371
|
+
* @returns Whether the grant admits it.
|
|
372
|
+
*/
|
|
373
|
+
export function pluginGrantAdmits(grant, namespace) {
|
|
374
|
+
return grantAdmits(grant, namespace);
|
|
375
|
+
}
|
|
376
|
+
/** Validate one namespace → declaration entry from the config's `plugins` key. */
|
|
377
|
+
function validateDeclaration(namespace, declaration, directory, path) {
|
|
378
|
+
if (namespace.length === 0 || namespace.includes(".")) {
|
|
379
|
+
return failure(new LoadError("load-error", `spec/config.jsonc ${path} declares an invalid namespace "${namespace}": a namespace is non-empty and contains no dot.`));
|
|
380
|
+
}
|
|
381
|
+
if (BUILT_IN_NAMESPACES.has(namespace)) {
|
|
382
|
+
return failure(new LoadError("load-error", `spec/config.jsonc ${path} declares the namespace "${namespace}", which is a built-in capability and cannot be overridden.`));
|
|
383
|
+
}
|
|
384
|
+
if (!isRecord(declaration) || !Array.isArray(declaration.command)) {
|
|
385
|
+
return failure(new LoadError("load-error", `Plugin "${namespace}" in ${path} must be an object with a "command" array, e.g. { "command": ["bun", "./plugin.ts"] }.`));
|
|
386
|
+
}
|
|
387
|
+
let command = declaration.command;
|
|
388
|
+
if (command.length === 0 || !command.every((part) => typeof part === "string")) {
|
|
389
|
+
return failure(new LoadError("load-error", `Plugin "${namespace}" in ${path} needs a non-empty "command" array of strings.`));
|
|
390
|
+
}
|
|
391
|
+
let resolved = command.map((part) => resolveCommandPart(part, directory));
|
|
392
|
+
return success({ namespace, command: resolved });
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Resolve one command argument. An argument starting with `.` is a config-
|
|
396
|
+
* relative path, made absolute against the config directory so the command
|
|
397
|
+
* runs identically from any working directory; anything else is left verbatim.
|
|
398
|
+
*/
|
|
399
|
+
function resolveCommandPart(part, directory) {
|
|
400
|
+
if (part.startsWith("."))
|
|
401
|
+
return resolve(directory, part);
|
|
402
|
+
if (isAbsolute(part))
|
|
403
|
+
return part;
|
|
404
|
+
return part;
|
|
405
|
+
}
|
|
406
|
+
/** Whether a value is a non-null, non-array object. */
|
|
407
|
+
function isRecord(value) {
|
|
408
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Remove `//` line and block comments from JSONC text without touching comment
|
|
412
|
+
* markers that appear inside string literals.
|
|
413
|
+
*/
|
|
414
|
+
function stripComments(source) {
|
|
415
|
+
let out = "";
|
|
416
|
+
let inString = false;
|
|
417
|
+
let escaped = false;
|
|
418
|
+
let index = 0;
|
|
419
|
+
while (index < source.length) {
|
|
420
|
+
let char = source[index];
|
|
421
|
+
if (inString) {
|
|
422
|
+
out += char;
|
|
423
|
+
if (escaped)
|
|
424
|
+
escaped = false;
|
|
425
|
+
else if (char === "\\")
|
|
426
|
+
escaped = true;
|
|
427
|
+
else if (char === '"')
|
|
428
|
+
inString = false;
|
|
429
|
+
index += 1;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (char === '"') {
|
|
433
|
+
inString = true;
|
|
434
|
+
out += char;
|
|
435
|
+
index += 1;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (char === "/" && source[index + 1] === "/") {
|
|
439
|
+
index += 2;
|
|
440
|
+
while (index < source.length && source[index] !== "\n")
|
|
441
|
+
index += 1;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (char === "/" && source[index + 1] === "*") {
|
|
445
|
+
index += 2;
|
|
446
|
+
while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) {
|
|
447
|
+
index += 1;
|
|
448
|
+
}
|
|
449
|
+
index += 2;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
out += char;
|
|
453
|
+
index += 1;
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Remove commas that immediately precede a `}` or `]` (ignoring whitespace),
|
|
459
|
+
* again leaving string contents untouched — the second half of JSONC leniency.
|
|
460
|
+
*/
|
|
461
|
+
function stripTrailingCommas(source) {
|
|
462
|
+
let out = "";
|
|
463
|
+
let inString = false;
|
|
464
|
+
let escaped = false;
|
|
465
|
+
for (let index = 0; index < source.length; index++) {
|
|
466
|
+
let char = source[index];
|
|
467
|
+
if (inString) {
|
|
468
|
+
out += char;
|
|
469
|
+
if (escaped)
|
|
470
|
+
escaped = false;
|
|
471
|
+
else if (char === "\\")
|
|
472
|
+
escaped = true;
|
|
473
|
+
else if (char === '"')
|
|
474
|
+
inString = false;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (char === '"') {
|
|
478
|
+
inString = true;
|
|
479
|
+
out += char;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (char === ",") {
|
|
483
|
+
let ahead = index + 1;
|
|
484
|
+
while (ahead < source.length && /\s/.test(source[ahead] ?? ""))
|
|
485
|
+
ahead += 1;
|
|
486
|
+
let next = source[ahead];
|
|
487
|
+
if (next === "}" || next === "]")
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
out += char;
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
/** Render an unknown thrown value as a one-line message. */
|
|
495
|
+
function errorMessage(error) {
|
|
496
|
+
return error instanceof Error ? error.message : String(error);
|
|
497
|
+
}
|