Convert four hal modules: lidarr, mongodb, mssql, mosquitto
Mirrors the proven catalog patterns field-for-field: - lidarr -> the Servarr twin of radarr/sonarr (API v1, artist content); no provisioner (it is a consumer app). - mongodb -> postgres shape: mongodb-database provider, provisioner mints a per-consumer db+user (ADR 0053), client shells to mongosh (no npm driver, the psql convention). - mssql -> postgres shape: mssql-database provider, sqlcmd client. - mosquitto -> redis shape: mqtt-topic provider via the Dynamic Security plugin, deliberately avoiding hal's password_file (that file is nox issue 011 exactly); provisioner mints a per-consumer MQTT client+role. All four typecheck (strict, NodeNext) against the built @novox/mesh-sdk, and their service images are digest-pinned to resolved registry digests. The mesh-runtime-<mod> images keep the all-zeros placeholder the pipeline pins, as postgres/redis do, and must bundle each module's CLI (mongosh/sqlcmd/ mosquitto_ctrl) as mesh-runtime-postgres bundles psql. Not yet lab-verified: each module lists in-code what an integration test must prove (auth model, provisioner reconcile, mosquitto dynsec bootstrap ordering). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
// mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0044). Both this
|
||||
// module's tools and its provisioner import it, and nothing outside mssql does.
|
||||
//
|
||||
// SQL is executed through `sqlcmd`, not a wire-protocol driver: the module may take NO npm
|
||||
// dependency beyond @novox/mesh-sdk, and hand-rolling the TDS handshake, pre-login and query
|
||||
// protocol is more surface than this should carry — so it shells out to the client the mssql
|
||||
// tools ship, the same way postgres drives itself through `psql`, minio through `mc`, and mailu
|
||||
// through doveadm. One boundary, `run()`, and every method is built on it.
|
||||
//
|
||||
// Structured rows come back as JSON: SQL Server itself renders the result with `FOR JSON`, and
|
||||
// this parses the single JSON document sqlcmd prints — far more robust than parsing sqlcmd's
|
||||
// column-aligned text, since SQL Server owns the quoting and typing.
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
export interface QueryResult {
|
||||
/** The leading keyword of the statement, e.g. "SELECT", "CREATE". */
|
||||
readonly command: string;
|
||||
readonly rows: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface MssqlConn {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly user: string;
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
export class MssqlClient {
|
||||
constructor(private readonly conn: MssqlConn) {}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. Reads MESH_MSSQL_* first (the documented
|
||||
* names), falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner
|
||||
* container. Throws if it cannot find a host and an admin password.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): MssqlClient {
|
||||
const url = env.MESH_PROVISION_MSSQL ? safeUrl(env.MESH_PROVISION_MSSQL) : undefined;
|
||||
const host = env.MESH_MSSQL_HOST ?? url?.hostname;
|
||||
const port = Number(env.MESH_MSSQL_PORT ?? url?.port ?? "1433") || 1433;
|
||||
const user = env.MESH_MSSQL_USER ?? url?.username ?? "sa";
|
||||
const password = env.MESH_MSSQL_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE);
|
||||
if (!host || !password) {
|
||||
throw new Error("mssql host or admin password is not set — mssql's own code cannot reach the server");
|
||||
}
|
||||
return new MssqlClient({ host, port, user, password });
|
||||
}
|
||||
|
||||
get host(): string {
|
||||
return this.conn.host;
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
return this.conn.port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a batch that returns no rows (DDL and the like), through `sqlcmd`. The password is
|
||||
* passed by SQLCMDPASSWORD, never on argv, the way postgres passes PGPASSWORD; `-b` makes a
|
||||
* failed statement an error here rather than a success with a warning, and `-C` trusts the
|
||||
* server's self-signed certificate the mssql image ships with.
|
||||
*/
|
||||
async exec(sql: string, database = "master"): Promise<void> {
|
||||
await this.sqlcmd(sql, database);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is
|
||||
* wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document sqlcmd
|
||||
* prints (split across output lines for a large result, and reassembled here) is parsed. An
|
||||
* empty result yields no output at all — an empty array.
|
||||
*/
|
||||
async query(select: string, database = "master"): Promise<Record<string, unknown>[]> {
|
||||
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
|
||||
const stdout = await this.sqlcmd(wrapped, database);
|
||||
return parseJsonRows(stdout);
|
||||
}
|
||||
|
||||
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
|
||||
private async sqlcmd(sql: string, database: string): Promise<string> {
|
||||
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long
|
||||
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin
|
||||
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships
|
||||
// with postgres's — the module owns its own code (ADR 0044) and shells out to it.
|
||||
const { stdout } = await run(
|
||||
"sqlcmd",
|
||||
[
|
||||
"-S", `${this.conn.host},${this.conn.port}`,
|
||||
"-U", this.conn.user,
|
||||
"-d", database,
|
||||
"-C",
|
||||
"-b",
|
||||
"-h", "-1",
|
||||
"-y", "0",
|
||||
"-Y", "0",
|
||||
"-W",
|
||||
"-Q", sql,
|
||||
],
|
||||
{ env: { ...process.env, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a login and a database it owns (mapped as a db_owner user), idempotently. The login,
|
||||
* the database and the user all carry the consumer's minted name, so the consumer owns exactly
|
||||
* its own database — a name it cannot learn is a database it cannot reach (ADR 0053).
|
||||
*/
|
||||
async createDatabaseAndLogin(database: string, login: string, password: string): Promise<void> {
|
||||
const logins = await this.query(
|
||||
`SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`,
|
||||
);
|
||||
if (logins.length === 0) {
|
||||
await this.exec(
|
||||
`CREATE LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}, CHECK_POLICY = OFF`,
|
||||
);
|
||||
} else {
|
||||
await this.exec(`ALTER LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}`);
|
||||
}
|
||||
|
||||
const dbs = await this.query(
|
||||
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
|
||||
);
|
||||
if (dbs.length === 0) {
|
||||
// CREATE DATABASE must stand alone in its batch; it runs as its own sqlcmd invocation.
|
||||
await this.exec(`CREATE DATABASE ${ident(database)}`);
|
||||
}
|
||||
|
||||
// Map the login to a db_owner user inside the database it owns.
|
||||
const users = await this.query(
|
||||
`SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)}`,
|
||||
database,
|
||||
);
|
||||
if (users.length === 0) {
|
||||
await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database);
|
||||
}
|
||||
await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database);
|
||||
}
|
||||
|
||||
/** Drop a database and its login, idempotently, after evicting live connections. */
|
||||
async dropDatabaseAndLogin(database: string, login: string): Promise<void> {
|
||||
const dbs = await this.query(
|
||||
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
|
||||
);
|
||||
if (dbs.length > 0) {
|
||||
// SINGLE_USER WITH ROLLBACK IMMEDIATE evicts every other session before the drop.
|
||||
await this.exec(`ALTER DATABASE ${ident(database)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE`);
|
||||
await this.exec(`DROP DATABASE ${ident(database)}`);
|
||||
}
|
||||
const logins = await this.query(
|
||||
`SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`,
|
||||
);
|
||||
if (logins.length > 0) {
|
||||
await this.exec(`DROP LOGIN ${ident(login)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** List the user databases (database_id > 4 excludes the system four), with size, for the tool. */
|
||||
async listDatabases(): Promise<{ name: string; sizeBytes: number; state: string }[]> {
|
||||
const rows = await this.query(
|
||||
"SELECT d.name AS name, d.state_desc AS state, " +
|
||||
"SUM(CAST(f.size AS bigint)) * 8 * 1024 AS size_bytes " +
|
||||
"FROM sys.databases d JOIN sys.master_files f ON d.database_id = f.database_id " +
|
||||
"WHERE d.database_id > 4 GROUP BY d.name, d.state_desc ORDER BY d.name",
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
name: String(r.name),
|
||||
sizeBytes: Number(r.size_bytes ?? 0),
|
||||
state: String(r.state ?? ""),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Run a read-only SELECT against a named database, for the mssql_query tool. */
|
||||
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
|
||||
// The read-only guarantee is a wrapping transaction that is always rolled back: any write the
|
||||
// statement attempts is undone. The rows are rendered by FOR JSON inside query().
|
||||
const rows = await this.query(
|
||||
`BEGIN TRANSACTION;\n${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;\nROLLBACK;`,
|
||||
database,
|
||||
);
|
||||
return { command: sql.trimStart().split(/\s+/)[0]?.toUpperCase() ?? "", rows };
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a URL-safe password. */
|
||||
export function generatePassword(): string {
|
||||
return randomBytes(24).toString("base64url");
|
||||
}
|
||||
|
||||
/** Quote a T-SQL identifier (square brackets, doubled internal `]`). */
|
||||
export function ident(id: string): string {
|
||||
return "[" + id.replace(/]/g, "]]") + "]";
|
||||
}
|
||||
|
||||
/** Quote a T-SQL string literal (single quotes, doubled internal quotes). */
|
||||
export function literal(val: string): string {
|
||||
return "'" + val.replace(/'/g, "''") + "'";
|
||||
}
|
||||
|
||||
/** Strip trailing semicolons and whitespace so FOR JSON can be appended to a caller's SELECT. */
|
||||
function stripTrailingSemis(sql: string): string {
|
||||
return sql.replace(/[\s;]+$/, "");
|
||||
}
|
||||
|
||||
function readSecretFile(path: string | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
try {
|
||||
return readFileSync(path, "utf8").trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function safeUrl(raw: string): URL | undefined {
|
||||
try {
|
||||
return new URL(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the JSON a FOR JSON query prints through sqlcmd. SQL Server splits a large FOR JSON result
|
||||
* into ~2033-character chunks, one per output row; with `-h -1 -W` each lands on its own line, so
|
||||
* the document is reassembled by concatenating the non-empty lines. No output (an empty result, or
|
||||
* a pure DDL batch) means no rows.
|
||||
*/
|
||||
function parseJsonRows(stdout: string): Record<string, unknown>[] {
|
||||
const joined = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trimEnd())
|
||||
.filter((l) => l.length > 0)
|
||||
.join("");
|
||||
if (joined.length === 0) return [];
|
||||
const parsed = JSON.parse(joined);
|
||||
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// mssql's events entrypoint, loaded by the per-node tool host (the provisioner container runs
|
||||
// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where
|
||||
// the lifecycle actually happens (novox/hq ADR 0046/0047):
|
||||
// module.mssql.database.provisioned — a consumer's database + login/user was created
|
||||
// module.mssql.database.deprovisioned — that database was removed
|
||||
// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a
|
||||
// database and who lost one — observability the provider itself is best placed to log.
|
||||
|
||||
import { on } from "@novox/mesh-sdk/events";
|
||||
|
||||
interface DatabaseEvent {
|
||||
consumer: string;
|
||||
database: string;
|
||||
user?: string;
|
||||
}
|
||||
|
||||
await on<DatabaseEvent>("module.mssql.database.provisioned", async (e) => {
|
||||
console.log(`[mssql] database provisioned for ${e.body.consumer} (db ${e.body.database})`);
|
||||
});
|
||||
|
||||
await on<DatabaseEvent>("module.mssql.database.deprovisioned", async (e) => {
|
||||
console.log(`[mssql] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`);
|
||||
});
|
||||
|
||||
console.log("[mssql] auditing database lifecycle events");
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"module": "mssql",
|
||||
"version": "1",
|
||||
"provides": [
|
||||
{
|
||||
"name": "mssql-database",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.mssql.database.provisioned",
|
||||
"module.mssql.database.deprovisioned"
|
||||
],
|
||||
"consumes": [
|
||||
"module.mssql.database.provisioned",
|
||||
"module.mssql.database.deprovisioned"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"port": 1433,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "modules on any machine that were granted a database"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
"mssql-database": {}
|
||||
},
|
||||
"receives": {
|
||||
"mssql-database": "/var/lib/mssql/grants/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"mssql-database": "/var/lib/mssql/grants"
|
||||
},
|
||||
"own-secrets": {
|
||||
"sa": "/var/lib/mssql/sa.secret",
|
||||
"broker": "/var/lib/mesh/mssql/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/mssql",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mssql",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mssql/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "sa-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mssql/sa.env",
|
||||
"mode": "0600",
|
||||
"content": "ACCEPT_EULA=Y\nMSSQL_SA_PASSWORD=${secret:sa}\n"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/mssql/db-data",
|
||||
"mode": "0700",
|
||||
"owner": "10001:0"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
"type": "network",
|
||||
"name": "mssql"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "mssql",
|
||||
"image": "mcr.microsoft.com/mssql/server@sha256:ba4c8329f48fb8f02e1416be6a930ebfd71268caee78aa985f3af4315e457c89",
|
||||
"network": "mssql",
|
||||
"env-file": [
|
||||
"/var/lib/mssql/sa.env"
|
||||
],
|
||||
"ports": [
|
||||
"1433"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mssql/db-data:/var/opt/mssql"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-mssql",
|
||||
"image": "mesh-runtime-mssql@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "mssql",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mssql/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mssql/grants:/var/lib/mssql/grants:ro",
|
||||
"/var/lib/mssql/sa.secret:/run/secrets/sa:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master",
|
||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/sa",
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_RECEIVES": "/var/lib/mssql/grants/mesh.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-mssql",
|
||||
"version": "0.1.0",
|
||||
"description": "mssql — provides the mesh mssql-database interface. Its client, provisioner, tools and events live here (novox/hq ADR 0044).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// mssql's provisioner — the adapter that makes mssql a provider of the mesh `mssql-database`
|
||||
// interface. The reconcile loop, the contributions file, and reading the mesh's minted password
|
||||
// are the sdk harness's; this writes only the per-service half: how mssql creates and removes a
|
||||
// consumer's database + login/user with the mesh-minted credential (novox/hq ADR 0044/0045/0053).
|
||||
//
|
||||
// The `mssql-database` interface: a consumer connects to a database it alone owns, as `as` with
|
||||
// the password the mesh minted.
|
||||
//
|
||||
// **The login name and password are the mesh's, not the provisioner's (ADR 0053).** The mesh
|
||||
// derives the login and hands it to both ends, and mints the password. mssql creates a login, a
|
||||
// same-named database, and a db_owner user under exactly that login — a name the consumer cannot
|
||||
// learn is a database it cannot reach.
|
||||
//
|
||||
// The DDL runs through MssqlClient, which is the module's one pending boundary (see client.ts).
|
||||
|
||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { MssqlClient } from "../client.js";
|
||||
|
||||
const mssql = MssqlClient.fromEnv();
|
||||
|
||||
/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */
|
||||
async function announce(type: string, body: Record<string, string>): Promise<void> {
|
||||
try {
|
||||
await emit(type, body);
|
||||
} catch (err) {
|
||||
console.error(`[provisioner:mssql-database] emit ${type} failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
runProvisioner("mssql-database", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
// Database, login and user share the consumer's name, so the consumer owns exactly its own.
|
||||
const database = p.as;
|
||||
await mssql.createDatabaseAndLogin(database, p.as, p.password);
|
||||
await announce("module.mssql.database.provisioned", {
|
||||
consumer: p.consumer ?? "",
|
||||
database,
|
||||
user: p.as,
|
||||
});
|
||||
},
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
await mssql.dropDatabaseAndLogin(p.as, p.as);
|
||||
await announce("module.mssql.database.deprovisioned", { database: p.as });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// mssql's tools — mssql's own code (novox/hq ADR 0044), importing mssql's own client. They return
|
||||
// structured data; the mesh serves them through the sdk's tool harness. Both call through
|
||||
// MssqlClient, the module's one pending execution boundary (see client.ts): the tool shapes are
|
||||
// fixed and correct, and surface the work honestly through that boundary.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { MssqlClient } from "../client.js";
|
||||
|
||||
export function getMssqlTools(mssql: MssqlClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "mssql_list_databases",
|
||||
description: "List the user databases on the mssql server, with their on-disk size and state.",
|
||||
input: {},
|
||||
run: async () => ({ databases: await mssql.listDatabases() }),
|
||||
},
|
||||
{
|
||||
name: "mssql_query",
|
||||
description: "Run a read-only SELECT against a named database (wrapped in a rolled-back transaction).",
|
||||
input: {
|
||||
database: { type: "string", description: "the database to query" },
|
||||
sql: { type: "string", description: "a single SELECT statement" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const database = String(args.database ?? "");
|
||||
const sql = String(args.sql ?? "");
|
||||
if (!database) throw new Error("mssql_query: database is required");
|
||||
if (!sql) throw new Error("mssql_query: sql is required");
|
||||
const result = await mssql.readOnlyQuery(database, sql);
|
||||
return { database, command: result.command, rows: result.rows };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when the server can be reached from the environment; without it, mssql
|
||||
// contributes none rather than failing the whole tool runtime.
|
||||
registerModuleTools("mssql", (env) => {
|
||||
try {
|
||||
return getMssqlTools(MssqlClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user