@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,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in `cli` capability: run programs inside the test workspace. The
|
|
3
|
+
* `run` permission gates every call by the executable's basename, and child
|
|
4
|
+
* processes receive a minimal filtered environment — PATH/HOME/TMPDIR plus
|
|
5
|
+
* exactly the variables granted with `--allow-env` — so the host environment
|
|
6
|
+
* never leaks into a spec's subprocesses.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { Plugin } from "../plugin.js";
|
|
12
|
+
/**
|
|
13
|
+
* Create the built-in `cli` plugin: a single `run` tool that spawns a child
|
|
14
|
+
* process with its working directory at the workspace root and a filtered
|
|
15
|
+
* environment, and reports `{ stdout, stderr, exit_code }`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createCliPlugin(): Plugin;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in `cli` capability: run programs inside the test workspace. The
|
|
3
|
+
* `run` permission gates every call by the executable's basename, and child
|
|
4
|
+
* processes receive a minimal filtered environment — PATH/HOME/TMPDIR plus
|
|
5
|
+
* exactly the variables granted with `--allow-env` — so the host environment
|
|
6
|
+
* never leaks into a spec's subprocesses.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { basename } from "node:path";
|
|
13
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
14
|
+
import { ToolError } from "../errors.js";
|
|
15
|
+
import { formatValue } from "../values.js";
|
|
16
|
+
/** Host variables every child needs to execute at all; always forwarded. */
|
|
17
|
+
const BASE_ENV_NAMES = ["PATH", "HOME", "TMPDIR"];
|
|
18
|
+
/** Descriptors of every tool the `cli` namespace exposes. */
|
|
19
|
+
const CLI_TOOLS = [
|
|
20
|
+
{
|
|
21
|
+
name: "run",
|
|
22
|
+
summary: "Run an executable inside the workspace and capture its output.",
|
|
23
|
+
kind: "action",
|
|
24
|
+
requires: "run",
|
|
25
|
+
params: [
|
|
26
|
+
{
|
|
27
|
+
name: "executable",
|
|
28
|
+
kind: "value",
|
|
29
|
+
required: true,
|
|
30
|
+
summary: "The program to run; permission-checked by its basename.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "args",
|
|
34
|
+
kind: "value",
|
|
35
|
+
required: false,
|
|
36
|
+
summary: "Arguments passed to the program, each a string.",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* Create the built-in `cli` plugin: a single `run` tool that spawns a child
|
|
43
|
+
* process with its working directory at the workspace root and a filtered
|
|
44
|
+
* environment, and reports `{ stdout, stderr, exit_code }`.
|
|
45
|
+
*/
|
|
46
|
+
export function createCliPlugin() {
|
|
47
|
+
return {
|
|
48
|
+
namespace: "cli",
|
|
49
|
+
describe() {
|
|
50
|
+
return CLI_TOOLS;
|
|
51
|
+
},
|
|
52
|
+
async call(tool, args, context) {
|
|
53
|
+
if (tool !== "run") {
|
|
54
|
+
return failure(new ToolError(`cli has no tool named "${tool}"; tools: run`));
|
|
55
|
+
}
|
|
56
|
+
return await run(args, context);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** `cli.run executable args…` — spawn, wait, and capture stdout/stderr. */
|
|
61
|
+
async function run(args, context) {
|
|
62
|
+
let command = [];
|
|
63
|
+
for (let [index, arg] of args.entries()) {
|
|
64
|
+
if (arg.kind === "word") {
|
|
65
|
+
return failure(new ToolError(`cli.run arguments must all be strings; argument ${index + 1} is the bare word "${arg.word}"`));
|
|
66
|
+
}
|
|
67
|
+
if (typeof arg.value !== "string") {
|
|
68
|
+
return failure(new ToolError(`cli.run arguments must all be strings; argument ${index + 1} is ${formatValue(arg.value)}`));
|
|
69
|
+
}
|
|
70
|
+
command.push(arg.value);
|
|
71
|
+
}
|
|
72
|
+
let executable = command[0];
|
|
73
|
+
if (executable === undefined) {
|
|
74
|
+
return failure(new ToolError("cli.run expects an executable as its first argument"));
|
|
75
|
+
}
|
|
76
|
+
let allowed = context.permissions.checkRun(basename(executable));
|
|
77
|
+
if (isFailure(allowed))
|
|
78
|
+
return allowed;
|
|
79
|
+
try {
|
|
80
|
+
return success(await capture(executable, command.slice(1), context));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return failure(new ToolError(`cli.run failed to start "${executable}": ${describeError(error)}`));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Run one program to completion inside the workspace and collect everything a
|
|
88
|
+
* spec can assert on. A child terminated by a signal reports a nonzero
|
|
89
|
+
* `exit_code`, so `expect exit_code is 0` cannot pass for it.
|
|
90
|
+
*
|
|
91
|
+
* @param executable - The program to run.
|
|
92
|
+
* @param args - Its arguments, already validated as strings.
|
|
93
|
+
* @param context - Supplies the workspace root and the granted variables.
|
|
94
|
+
* @returns The captured stdout, stderr and exit code.
|
|
95
|
+
* @throws When the program cannot be started at all.
|
|
96
|
+
*/
|
|
97
|
+
async function capture(executable, args, context) {
|
|
98
|
+
let child = spawn(executable, args, {
|
|
99
|
+
cwd: context.workspace.root,
|
|
100
|
+
env: childEnvironment(context),
|
|
101
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
102
|
+
});
|
|
103
|
+
let stdout = "";
|
|
104
|
+
let stderr = "";
|
|
105
|
+
child.stdout?.setEncoding("utf8");
|
|
106
|
+
child.stderr?.setEncoding("utf8");
|
|
107
|
+
child.stdout?.on("data", (chunk) => void (stdout += chunk));
|
|
108
|
+
child.stderr?.on("data", (chunk) => void (stderr += chunk));
|
|
109
|
+
let code = await new Promise((settle, reject) => {
|
|
110
|
+
child.once("error", reject);
|
|
111
|
+
child.once("close", (exitCode) => settle(exitCode ?? 1));
|
|
112
|
+
});
|
|
113
|
+
return { stdout, stderr, exit_code: code };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Build the child's environment: PATH/HOME/TMPDIR from the host plus exactly
|
|
117
|
+
* the variables the caller granted with `--allow-env`, skipping any granted
|
|
118
|
+
* name the host does not actually define.
|
|
119
|
+
*/
|
|
120
|
+
function childEnvironment(context) {
|
|
121
|
+
let env = {};
|
|
122
|
+
for (let name of [...BASE_ENV_NAMES, ...context.permissions.grantedEnvNames()]) {
|
|
123
|
+
let value = process.env[name];
|
|
124
|
+
if (value !== undefined)
|
|
125
|
+
env[name] = value;
|
|
126
|
+
}
|
|
127
|
+
return env;
|
|
128
|
+
}
|
|
129
|
+
/** Render an unknown thrown value as a one-line message. */
|
|
130
|
+
function describeError(error) {
|
|
131
|
+
if (error instanceof Error)
|
|
132
|
+
return error.message;
|
|
133
|
+
return String(error);
|
|
134
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun-side driver for the `db` plugin's end-to-end assertions. It runs the DDL/DML/SELECT
|
|
3
|
+
* shaping, the SQL-error path and the connection-reuse lifecycle against a real temp-file
|
|
4
|
+
* SQLite database, then writes what it observed to stdout as one JSON object.
|
|
5
|
+
*
|
|
6
|
+
* It exists because the plugin's connection comes from Bun's SQL client, which has no `node:`
|
|
7
|
+
* counterpart: the assertions belong to Vitest, but the code under test has to execute under
|
|
8
|
+
* Bun. Keeping this side purely observational — it records values and never asserts — is what
|
|
9
|
+
* leaves the expectations in `db.test.ts` where a failure names them.
|
|
10
|
+
*
|
|
11
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
12
|
+
* @copyright Sergio Xalambrí 2026
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun-side driver for the `db` plugin's end-to-end assertions. It runs the DDL/DML/SELECT
|
|
3
|
+
* shaping, the SQL-error path and the connection-reuse lifecycle against a real temp-file
|
|
4
|
+
* SQLite database, then writes what it observed to stdout as one JSON object.
|
|
5
|
+
*
|
|
6
|
+
* It exists because the plugin's connection comes from Bun's SQL client, which has no `node:`
|
|
7
|
+
* counterpart: the assertions belong to Vitest, but the code under test has to execute under
|
|
8
|
+
* Bun. Keeping this side purely observational — it records values and never asserts — is what
|
|
9
|
+
* leaves the expectations in `db.test.ts` where a failure names them.
|
|
10
|
+
*
|
|
11
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
12
|
+
* @copyright Sergio Xalambrí 2026
|
|
13
|
+
*/
|
|
14
|
+
import { isFailure, success } from "@sdxc/result";
|
|
15
|
+
import { createRandom } from "@sdxc/sample";
|
|
16
|
+
import { ToolError } from "../errors.js";
|
|
17
|
+
import { createDbPlugin } from "./db.js";
|
|
18
|
+
/** A permission set granting every family; the driver's grants are not what is under test. */
|
|
19
|
+
function allowAll() {
|
|
20
|
+
return {
|
|
21
|
+
checkRun: () => success(undefined),
|
|
22
|
+
checkNet: () => success(undefined),
|
|
23
|
+
checkEnv: () => success(undefined),
|
|
24
|
+
checkHostFs: () => success(undefined),
|
|
25
|
+
grantedEnvNames: () => [],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** A workspace stub; the `db` plugin never touches it, but the context needs one. */
|
|
29
|
+
function stubWorkspace() {
|
|
30
|
+
return {
|
|
31
|
+
root: "/tmp/spec-db-e2e",
|
|
32
|
+
resolve: (path) => success(path),
|
|
33
|
+
cleanup: async () => undefined,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Unwrap a successful result, or throw so the driver exits non-zero with the message. */
|
|
37
|
+
function expectSuccess(result) {
|
|
38
|
+
if (isFailure(result))
|
|
39
|
+
throw new Error(`expected success, got: ${result.error.message}`);
|
|
40
|
+
return result.data;
|
|
41
|
+
}
|
|
42
|
+
/** Read a result value as an object, throwing when the plugin returned another shape. */
|
|
43
|
+
function asObject(data) {
|
|
44
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
45
|
+
throw new Error(`expected an object result, got ${JSON.stringify(data)}`);
|
|
46
|
+
}
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Run every end-to-end scenario against `dbPath` and return the observations as plain JSON.
|
|
51
|
+
*
|
|
52
|
+
* The shape mirrors the assertions in `db.test.ts` one-for-one: a new
|
|
53
|
+
* expectation there gets a new field here, each field carrying one fixed meaning.
|
|
54
|
+
*/
|
|
55
|
+
async function observe(dbPath) {
|
|
56
|
+
process.env.DATABASE_URL = `sqlite://${dbPath}`;
|
|
57
|
+
let plugin = createDbPlugin();
|
|
58
|
+
let context = {
|
|
59
|
+
workspace: stubWorkspace(),
|
|
60
|
+
permissions: allowAll(),
|
|
61
|
+
random: createRandom("test"),
|
|
62
|
+
now: new Date("2026-01-01T00:00:00.000Z"),
|
|
63
|
+
};
|
|
64
|
+
let created = asObject(expectSuccess(await plugin.call("query", [{ kind: "value", value: "CREATE TABLE ledger (id INTEGER PRIMARY KEY, entry TEXT)" }], context)));
|
|
65
|
+
let inserted = asObject(expectSuccess(await plugin.call("query", [{ kind: "value", value: "INSERT INTO ledger (entry) VALUES ('a')" }], context)));
|
|
66
|
+
let insertedMany = asObject(expectSuccess(await plugin.call("query", [{ kind: "value", value: "INSERT INTO ledger (entry) VALUES ('b'), ('c')" }], context)));
|
|
67
|
+
let selected = asObject(expectSuccess(await plugin.call("query", [{ kind: "value", value: "SELECT id, entry FROM ledger ORDER BY id" }], context)));
|
|
68
|
+
/** The failure path: a missing table must surface the database's own message. */
|
|
69
|
+
let errorResult = await plugin.call("query", [{ kind: "value", value: "SELECT * FROM does_not_exist" }], context);
|
|
70
|
+
if (!isFailure(errorResult))
|
|
71
|
+
throw new Error("expected the missing-table query to fail");
|
|
72
|
+
let sqlError = {
|
|
73
|
+
isToolError: errorResult.error instanceof ToolError,
|
|
74
|
+
code: errorResult.error.code,
|
|
75
|
+
message: errorResult.error.message,
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Connection reuse: two writes landing in one table is the observable
|
|
79
|
+
* proof that the same handle served every call.
|
|
80
|
+
*/
|
|
81
|
+
expectSuccess(await plugin.call("query", [{ kind: "value", value: "CREATE TABLE IF NOT EXISTS reuse (id INTEGER PRIMARY KEY)" }], context));
|
|
82
|
+
expectSuccess(await plugin.call("query", [{ kind: "value", value: "INSERT INTO reuse DEFAULT VALUES" }], context));
|
|
83
|
+
expectSuccess(await plugin.call("query", [{ kind: "value", value: "INSERT INTO reuse DEFAULT VALUES" }], context));
|
|
84
|
+
let reuseCounted = asObject(expectSuccess(await plugin.call("query", [{ kind: "value", value: "SELECT id FROM reuse" }], context)));
|
|
85
|
+
/** dispose is idempotent and best-effort: calling it twice never throws. */
|
|
86
|
+
let disposeThrew = false;
|
|
87
|
+
if (plugin.dispose !== undefined) {
|
|
88
|
+
try {
|
|
89
|
+
await plugin.dispose();
|
|
90
|
+
await plugin.dispose();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
disposeThrew = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
created: { rows: created.rows, count: created.count },
|
|
98
|
+
inserted: { rows: inserted.rows, count: inserted.count, affected_rows: inserted.affected_rows },
|
|
99
|
+
insertedMany: { affected_rows: insertedMany.affected_rows },
|
|
100
|
+
selected: {
|
|
101
|
+
rows: selected.rows,
|
|
102
|
+
count: selected.count,
|
|
103
|
+
affected_rows: selected.affected_rows,
|
|
104
|
+
},
|
|
105
|
+
sqlError,
|
|
106
|
+
reuse: { count: reuseCounted.count, disposeThrew },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
let dbPath = process.argv[2];
|
|
110
|
+
if (dbPath === undefined)
|
|
111
|
+
throw new Error("usage: db-e2e-probe.ts <sqlite-path>");
|
|
112
|
+
process.stdout.write(JSON.stringify(await observe(dbPath)));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in `db` capability: run raw SQL against a database whose location
|
|
3
|
+
* the operator supplies through the `DATABASE_URL` environment variable. The
|
|
4
|
+
* spec never chooses the destination — it names `db.query` and asserts on the
|
|
5
|
+
* result — so revealing the connection string is the whole privileged act, and
|
|
6
|
+
* the family is gated by `env` alone (no `net`, no `run`). The connection is
|
|
7
|
+
* opened lazily on the first query, pooled and reused across the run by Bun's
|
|
8
|
+
* SQL client, and closed once in {@link Plugin.dispose}.
|
|
9
|
+
*
|
|
10
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
11
|
+
* @copyright Sergio Xalambrí 2026
|
|
12
|
+
*/
|
|
13
|
+
import type { Plugin } from "../plugin.js";
|
|
14
|
+
/**
|
|
15
|
+
* Create the built-in `db` plugin (namespace `"db"`): a single `db.query`
|
|
16
|
+
* tool returning `{ rows, affected_rows, count }` from one pooled connection
|
|
17
|
+
* opened from `DATABASE_URL` and closed via {@link Plugin.dispose}.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createDbPlugin(): Plugin;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in `db` capability: run raw SQL against a database whose location
|
|
3
|
+
* the operator supplies through the `DATABASE_URL` environment variable. The
|
|
4
|
+
* spec never chooses the destination — it names `db.query` and asserts on the
|
|
5
|
+
* result — so revealing the connection string is the whole privileged act, and
|
|
6
|
+
* the family is gated by `env` alone (no `net`, no `run`). The connection is
|
|
7
|
+
* opened lazily on the first query, pooled and reused across the run by Bun's
|
|
8
|
+
* SQL client, and closed once in {@link Plugin.dispose}.
|
|
9
|
+
*
|
|
10
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
11
|
+
* @copyright Sergio Xalambrí 2026
|
|
12
|
+
*/
|
|
13
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
14
|
+
import { ToolError } from "../errors.js";
|
|
15
|
+
const DATABASE_URL_VAR = "DATABASE_URL";
|
|
16
|
+
const DB_TOOLS = [
|
|
17
|
+
{
|
|
18
|
+
name: "query",
|
|
19
|
+
summary: "Run a raw SQL statement against DATABASE_URL and return its rows and counts.",
|
|
20
|
+
kind: "action",
|
|
21
|
+
requires: "env",
|
|
22
|
+
params: [
|
|
23
|
+
{
|
|
24
|
+
name: "sql",
|
|
25
|
+
kind: "value",
|
|
26
|
+
required: true,
|
|
27
|
+
summary: 'The SQL text to run, typically a """multiline""" string.',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Create the built-in `db` plugin (namespace `"db"`): a single `db.query`
|
|
34
|
+
* tool returning `{ rows, affected_rows, count }` from one pooled connection
|
|
35
|
+
* opened from `DATABASE_URL` and closed via {@link Plugin.dispose}.
|
|
36
|
+
*/
|
|
37
|
+
export function createDbPlugin() {
|
|
38
|
+
let connection = null;
|
|
39
|
+
return {
|
|
40
|
+
namespace: "db",
|
|
41
|
+
describe() {
|
|
42
|
+
return DB_TOOLS;
|
|
43
|
+
},
|
|
44
|
+
async call(tool, args, context) {
|
|
45
|
+
if (tool !== "query") {
|
|
46
|
+
return failure(new ToolError(`db has no tool named "${tool}"; tools: query`));
|
|
47
|
+
}
|
|
48
|
+
let text = readSql(args);
|
|
49
|
+
if (isFailure(text))
|
|
50
|
+
return text;
|
|
51
|
+
/**
|
|
52
|
+
* The runtime's central gate already refuses the call if the whole `env`
|
|
53
|
+
* family was denied; this refines that to the one variable actually read,
|
|
54
|
+
* so granting some other variable still yields the exact missing name.
|
|
55
|
+
*/
|
|
56
|
+
let allowed = context.permissions.checkEnv(DATABASE_URL_VAR);
|
|
57
|
+
if (isFailure(allowed))
|
|
58
|
+
return allowed;
|
|
59
|
+
let url = process.env[DATABASE_URL_VAR];
|
|
60
|
+
if (url === undefined || url.trim().length === 0) {
|
|
61
|
+
return failure(new ToolError(`db.query reads its connection string from the ${DATABASE_URL_VAR} environment variable, which is unset or empty; set it, e.g. ${DATABASE_URL_VAR}=… spec run --allow-env=${DATABASE_URL_VAR}`));
|
|
62
|
+
}
|
|
63
|
+
if (connection === null) {
|
|
64
|
+
let opened = await openConnection(url);
|
|
65
|
+
if (isFailure(opened))
|
|
66
|
+
return opened;
|
|
67
|
+
connection = opened.data;
|
|
68
|
+
}
|
|
69
|
+
return await runQuery(connection, text.data);
|
|
70
|
+
},
|
|
71
|
+
async dispose() {
|
|
72
|
+
if (connection === null)
|
|
73
|
+
return;
|
|
74
|
+
/**
|
|
75
|
+
* Detach before awaiting so a slow or throwing close cannot leave a
|
|
76
|
+
* half-closed handle cached for a later call.
|
|
77
|
+
*/
|
|
78
|
+
let closing = connection;
|
|
79
|
+
connection = null;
|
|
80
|
+
try {
|
|
81
|
+
await closing.close();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/** Best-effort teardown: a failed close must never fail a run. */
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Validate `db.query`'s arguments: exactly one value argument, a string. Words
|
|
91
|
+
* are meaningless to a SQL runner, and a missing or non-string argument is a
|
|
92
|
+
* usage error, all reported as {@link ToolError}s the reporter renders inline.
|
|
93
|
+
*/
|
|
94
|
+
function readSql(args) {
|
|
95
|
+
if (args.length !== 1) {
|
|
96
|
+
return failure(new ToolError(`db.query takes exactly one argument, the SQL text; got ${args.length}`));
|
|
97
|
+
}
|
|
98
|
+
let arg = args[0];
|
|
99
|
+
if (arg === undefined || arg.kind !== "value" || typeof arg.value !== "string") {
|
|
100
|
+
return failure(new ToolError("db.query expects its single argument to be a SQL string"));
|
|
101
|
+
}
|
|
102
|
+
return success(arg.value);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Open a pooled connection to `url`, translating a malformed URL,
|
|
106
|
+
* unsupported scheme, or missing SQL client into a {@link ToolError}. The
|
|
107
|
+
* client loads inside this call so the module works under any runtime.
|
|
108
|
+
*/
|
|
109
|
+
async function openConnection(url) {
|
|
110
|
+
try {
|
|
111
|
+
let { SQL } = await import("bun");
|
|
112
|
+
return success(new SQL(url));
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
return failure(new ToolError(`db.query could not open a database connection: ${describeError(error)}`));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Run one raw SQL statement on the connection and shape the driver's result.
|
|
120
|
+
* A SQL or connection error surfaces as a {@link ToolError} carrying the
|
|
121
|
+
* database's own message, so the failing test reports what the database said.
|
|
122
|
+
*/
|
|
123
|
+
async function runQuery(connection, text) {
|
|
124
|
+
let result;
|
|
125
|
+
try {
|
|
126
|
+
result = await connection.unsafe(text);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
return failure(new ToolError(`db.query failed: ${describeError(error)}`));
|
|
130
|
+
}
|
|
131
|
+
return success(shapeResult(result));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Shape a driver result into the tool's value: `rows` are the returned
|
|
135
|
+
* records (empty for DML), `affected_rows` is the driver's row count, and
|
|
136
|
+
* `count` is `rows.length`.
|
|
137
|
+
*/
|
|
138
|
+
function shapeResult(result) {
|
|
139
|
+
let rows = [];
|
|
140
|
+
if (Array.isArray(result)) {
|
|
141
|
+
for (let row of result)
|
|
142
|
+
rows.push(toValue(row));
|
|
143
|
+
}
|
|
144
|
+
return { rows, affected_rows: affectedRows(result, rows.length), count: rows.length };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The driver's affected/returned-row count. Bun attaches `count` to the result
|
|
148
|
+
* array — the rows changed by DML, or the rows a SELECT returned; fall back to
|
|
149
|
+
* the row count when a driver omits it.
|
|
150
|
+
*/
|
|
151
|
+
function affectedRows(result, fallback) {
|
|
152
|
+
if (typeof result === "object" && result !== null) {
|
|
153
|
+
let count = result.count;
|
|
154
|
+
if (typeof count === "number" && Number.isFinite(count))
|
|
155
|
+
return count;
|
|
156
|
+
if (typeof count === "bigint")
|
|
157
|
+
return Number(count);
|
|
158
|
+
}
|
|
159
|
+
return fallback;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Coerce a driver-returned value into the JSON-shaped runtime {@link Value}
|
|
163
|
+
* model — dates to ISO strings, binary to base64, safe bigints to numbers
|
|
164
|
+
* and unsafe ones to strings — so every row is a plain, comparable value.
|
|
165
|
+
*/
|
|
166
|
+
function toValue(value) {
|
|
167
|
+
if (value === null || value === undefined)
|
|
168
|
+
return null;
|
|
169
|
+
if (typeof value === "string" || typeof value === "boolean")
|
|
170
|
+
return value;
|
|
171
|
+
if (typeof value === "number")
|
|
172
|
+
return Number.isFinite(value) ? value : String(value);
|
|
173
|
+
if (typeof value === "bigint") {
|
|
174
|
+
let inRange = value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER);
|
|
175
|
+
return inRange ? Number(value) : value.toString();
|
|
176
|
+
}
|
|
177
|
+
if (value instanceof Date)
|
|
178
|
+
return value.toISOString();
|
|
179
|
+
if (value instanceof Uint8Array)
|
|
180
|
+
return Buffer.from(value).toString("base64");
|
|
181
|
+
if (Array.isArray(value))
|
|
182
|
+
return value.map(toValue);
|
|
183
|
+
if (typeof value === "object") {
|
|
184
|
+
let object = {};
|
|
185
|
+
for (let [key, entry] of Object.entries(value))
|
|
186
|
+
object[key] = toValue(entry);
|
|
187
|
+
return object;
|
|
188
|
+
}
|
|
189
|
+
if (typeof value === "symbol" || typeof value === "function")
|
|
190
|
+
return value.toString();
|
|
191
|
+
/** Every `typeof` result is handled above, so nothing reaches here. */
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
/** Render an unknown thrown value as a one-line message. */
|
|
195
|
+
function describeError(error) {
|
|
196
|
+
if (error instanceof Error)
|
|
197
|
+
return error.message;
|
|
198
|
+
return String(error);
|
|
199
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* The reference external plugin: a runnable script that proves the stdio
|
|
4
|
+
* transport's language neutrality. It exposes namespace "demo" with a `say`
|
|
5
|
+
* action that echoes its input back and an `upper` observable that returns
|
|
6
|
+
* the uppercased text. Both tools are open to every caller, regardless of
|
|
7
|
+
* granted permissions.
|
|
8
|
+
*
|
|
9
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
10
|
+
* @copyright Sergio Xalambrí 2026
|
|
11
|
+
*/
|
|
12
|
+
import type { Plugin } from "../plugin.js";
|
|
13
|
+
/**
|
|
14
|
+
* Build the demo plugin. Exported so tests can exercise the tools in-process;
|
|
15
|
+
* running this file serves the same plugin over stdio.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createDemoPlugin(): Plugin;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* The reference external plugin: a runnable script that proves the stdio
|
|
4
|
+
* transport's language neutrality. It exposes namespace "demo" with a `say`
|
|
5
|
+
* action that echoes its input back and an `upper` observable that returns
|
|
6
|
+
* the uppercased text. Both tools are open to every caller, regardless of
|
|
7
|
+
* granted permissions.
|
|
8
|
+
*
|
|
9
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
10
|
+
* @copyright Sergio Xalambrí 2026
|
|
11
|
+
*/
|
|
12
|
+
import { failure, success } from "@sdxc/result";
|
|
13
|
+
import { ToolError } from "../errors.js";
|
|
14
|
+
import { servePlugin } from "../transport-stdio.js";
|
|
15
|
+
import { formatValue } from "../values.js";
|
|
16
|
+
/** The tools the demo plugin exposes, stable for the plugin's lifetime. */
|
|
17
|
+
const DEMO_TOOLS = [
|
|
18
|
+
{
|
|
19
|
+
name: "say",
|
|
20
|
+
summary: "Echo the given value back to the caller.",
|
|
21
|
+
kind: "action",
|
|
22
|
+
params: [{ name: "text", kind: "value", required: true, summary: "The value to echo back." }],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "upper",
|
|
26
|
+
summary: "Return the given text uppercased.",
|
|
27
|
+
kind: "observable",
|
|
28
|
+
params: [{ name: "text", kind: "value", required: true, summary: "The text to uppercase." }],
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* Build the demo plugin. Exported so tests can exercise the tools in-process;
|
|
33
|
+
* running this file serves the same plugin over stdio.
|
|
34
|
+
*/
|
|
35
|
+
export function createDemoPlugin() {
|
|
36
|
+
return {
|
|
37
|
+
namespace: "demo",
|
|
38
|
+
describe() {
|
|
39
|
+
return DEMO_TOOLS;
|
|
40
|
+
},
|
|
41
|
+
async call(tool, args) {
|
|
42
|
+
if (tool === "say")
|
|
43
|
+
return say(args);
|
|
44
|
+
if (tool === "upper")
|
|
45
|
+
return upper(args);
|
|
46
|
+
return failure(new ToolError(`Unknown tool "demo.${tool}"; this plugin exposes: say, upper`));
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Echo the single value argument back, whatever its shape. */
|
|
51
|
+
function say(args) {
|
|
52
|
+
let first = args[0];
|
|
53
|
+
if (args.length !== 1 || first === undefined || first.kind !== "value") {
|
|
54
|
+
return failure(new ToolError('demo.say expects exactly one value argument, e.g. say "hello"'));
|
|
55
|
+
}
|
|
56
|
+
return success(first.value);
|
|
57
|
+
}
|
|
58
|
+
function upper(args) {
|
|
59
|
+
let first = args[0];
|
|
60
|
+
if (args.length !== 1 || first === undefined || first.kind !== "value") {
|
|
61
|
+
return failure(new ToolError('demo.upper expects exactly one value argument, e.g. upper "hello"'));
|
|
62
|
+
}
|
|
63
|
+
if (typeof first.value !== "string") {
|
|
64
|
+
return failure(new ToolError(`demo.upper expects a string, got ${formatValue(first.value)}`));
|
|
65
|
+
}
|
|
66
|
+
return success(first.value.toUpperCase());
|
|
67
|
+
}
|
|
68
|
+
if (import.meta.main) {
|
|
69
|
+
await servePlugin(createDemoPlugin());
|
|
70
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in `env` capability: read one named environment variable the
|
|
3
|
+
* caller granted. It is how a spec names a secret without containing one
|
|
4
|
+
* (ADR-007 §6) — the suite says which variable holds the session cookie or the
|
|
5
|
+
* API token, and the environment says what it is. Every read is gated on the
|
|
6
|
+
* `env` permission for that exact name, so a spec can never widen its own
|
|
7
|
+
* reach by asking for a different variable.
|
|
8
|
+
*
|
|
9
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
10
|
+
* @copyright Sergio Xalambrí 2026
|
|
11
|
+
*/
|
|
12
|
+
import type { Plugin } from "../plugin.js";
|
|
13
|
+
/**
|
|
14
|
+
* Create the built-in `env` plugin (namespace `"env"`). `env.get NAME` reads
|
|
15
|
+
* the variable, falling back to a second argument when unset; the permission
|
|
16
|
+
* check always runs first, so a fallback substitutes only for an absent value.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createEnvPlugin(): Plugin;
|