diff --git a/modules/confluence/client.ts b/modules/confluence/client.ts new file mode 100644 index 0000000..a77820e --- /dev/null +++ b/modules/confluence/client.ts @@ -0,0 +1,117 @@ +// confluence's own Confluence API client — its own code, living in the module (novox/hq ADR 0039). +// Ported from the hal sdk's shared ConfluenceClient, where a change to the Atlassian API rebuilt +// everything; here it rebuilds only confluence. This module's tools import it, and nothing outside +// confluence does. +// +// confluence is a tools-only, outbound-only integration with an external SaaS (Atlassian Cloud): it +// holds no service of its own, listens for nothing, and only ever calls out to a Confluence instance +// over its REST API, authenticated with HTTP Basic (email + API token) against the Atlassian site. +// +// The client is built lazily and NEVER throws at construction (the Servarr lesson): the runtime must +// come up and register every tool even with no valid credentials — the lab has no real Atlassian. A +// missing URL, email or token surfaces only when a tool is actually invoked, as a clear error from +// that one call, not as a runtime that refuses to serve. + +import { readFileSync } from "node:fs"; + +export class ConfluenceClient { + constructor( + /** The Atlassian site URL, e.g. "https://acme.atlassian.net". A public setting (config file). */ + private readonly url: string | undefined, + /** The Atlassian account email — a public setting (config file). */ + private readonly email: string | undefined, + /** The Atlassian API token — confluence's one secret (own-secret). */ + private readonly token: string | undefined, + ) {} + + static fromEnv(env: NodeJS.ProcessEnv = process.env): ConfluenceClient { + // The site URL and account email are a mesh's own facts, not this module's — settings merged into + // a config file the mesh manages (novox/hq ADR 0046) under ATLASSIAN_URL / ATLASSIAN_EMAIL, read + // here. The API token is the one secret and stays an own-secret, read from its file. Env is + // honoured as a fallback for a hand-run instance. No absence throws: the client still constructs, + // so every tool still registers and serves. + const config = readConfig(env.MESH_CONFLUENCE_CONFIG_FILE); + const url = config.ATLASSIAN_URL ?? env.MESH_CONFLUENCE_URL ?? env.ATLASSIAN_URL; + const email = config.ATLASSIAN_EMAIL ?? env.MESH_CONFLUENCE_EMAIL ?? env.ATLASSIAN_EMAIL; + const token = env.MESH_CONFLUENCE_TOKEN ?? readSecret(env.MESH_CONFLUENCE_TOKEN_FILE) ?? env.ATLASSIAN_TOKEN; + return new ConfluenceClient(url, email, token); + } + + /** Whether the module is configured enough to make a call. */ + configured(): boolean { + return Boolean(this.url && this.email && this.token); + } + + private baseUrl(): string { + if (!this.url || !this.email || !this.token) { + throw new Error( + "confluence is not configured — set its site URL and account email in settings " + + "(ATLASSIAN_URL, ATLASSIAN_EMAIL) and its API token as its own-secret; until then it " + + "answers no calls", + ); + } + return this.url.replace(/\/+$/, ""); + } + + private authHeader(): string { + return "Basic " + Buffer.from(`${this.email}:${this.token}`).toString("base64"); + } + + private async request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(`${this.baseUrl()}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: this.authHeader(), + ...(options.headers as Record), + }, + }); + if (!res.ok) { + throw new Error(`Confluence API error ${res.status}: ${await res.text()}`); + } + if (res.status === 204) return null as T; + return res.json() as Promise; + } + + async search(cql: string, limit = 25): Promise { + const params = new URLSearchParams({ cql, limit: String(limit) }); + return this.request(`/wiki/rest/api/content/search?${params}`); + } + + async getPage(pageId: string, expand?: string): Promise { + const params = expand ? `?body-format=${encodeURIComponent(expand)}` : ""; + return this.request(`/wiki/api/v2/pages/${pageId}${params}`); + } + + async listSpaces(params: Record = {}): Promise { + const qs = new URLSearchParams(params).toString(); + return this.request(`/wiki/api/v2/spaces?${qs}`); + } +} + +function readSecret(path: string | undefined): string | undefined { + if (!path) return undefined; + try { + return readFileSync(path, "utf8").trim(); + } catch { + return undefined; + } +} + +interface Config { + ATLASSIAN_URL?: string; + ATLASSIAN_EMAIL?: string; +} + +/** The settings-managed config file (a JSON document the mesh merges settings into), holding the + * public ATLASSIAN_URL and ATLASSIAN_EMAIL settings. Absent or unparseable yields an empty config — + * the module then answers no calls until its URL, email and token are set, but still registers and + * serves every tool. */ +function readConfig(path: string | undefined): Config { + if (!path) return {}; + try { + return JSON.parse(readFileSync(path, "utf8")) as Config; + } catch { + return {}; + } +} diff --git a/modules/confluence/module.json b/modules/confluence/module.json new file mode 100644 index 0000000..0e1c0e9 --- /dev/null +++ b/modules/confluence/module.json @@ -0,0 +1,50 @@ +{ + "module": "confluence", + "version": "1", + "own-secrets": { + "token": "/var/lib/confluence/token", + "broker": "/var/lib/mesh/confluence/broker" + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/confluence", + "mode": "0700" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/confluence", + "mode": "0700" + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/confluence/config.json", + "merge": "json", + "content": "{}", + "mode": "0600" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-runtime-confluence", + "image": "mesh-runtime-confluence@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/confluence/config.json:/run/config/config.json:ro", + "/var/lib/confluence/token:/run/secrets/token:ro", + "/var/lib/mesh/confluence/broker:/run/secrets/broker:ro" + ], + "env": { + "MESH_CONFLUENCE_TOKEN_FILE": "/run/secrets/token", + "MESH_CONFLUENCE_CONFIG_FILE": "/run/config/config.json", + "MESH_BROKER_FILE": "/run/secrets/broker" + } + } + ], + "capabilities": [ + "container-runtime" + ] +} diff --git a/modules/confluence/package.json b/modules/confluence/package.json new file mode 100644 index 0000000..843a16d --- /dev/null +++ b/modules/confluence/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-confluence", + "version": "0.1.0", + "description": "confluence — a tools-only, outbound-only Atlassian Confluence SaaS integration (novox/hq ADR 0039): its API client and tools live here.", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/confluence/tools/index.ts b/modules/confluence/tools/index.ts new file mode 100644 index 0000000..74f81fe --- /dev/null +++ b/modules/confluence/tools/index.ts @@ -0,0 +1,70 @@ +// confluence's tools — confluence's own code (novox/hq ADR 0039), importing confluence's own +// Confluence API client. They return structured data; the mesh serves them through the sdk's tool +// harness. confluence is tools-only and outbound-only: no service, no events, no listener — it +// reaches out to an Atlassian Confluence instance and exposes its content search, pages and spaces. +// +// Every tool is registered unconditionally, even with no credentials configured (the Servarr +// lesson): the client is built lazily and never throws, so the runtime always comes up and serves +// the full tool surface — a call made before the URL/email/token are set fails with a clear error, +// but the runtime does not refuse to serve. The install proof is the runtime logging +// `[mesh-tools] serving N tool(s)`. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { ConfluenceClient } from "../client.js"; + +function str(value: unknown): string { + return String(value ?? ""); +} + +function num(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +export function getConfluenceTools(confluence: ConfluenceClient): ToolDefinition[] { + return [ + { + name: "confluence_search", + description: "Search Confluence content using CQL.", + input: { + cql: { type: "string", description: "CQL query string" }, + limit: { type: "number", description: "max results (default 25)" }, + }, + run: async (args) => ({ results: await confluence.search(str(args.cql), num(args.limit, 25)) }), + }, + { + name: "confluence_get_page", + description: "Get a Confluence page by ID.", + input: { + page_id: { type: "string", description: "Page ID" }, + }, + run: async (args) => ({ page: await confluence.getPage(str(args.page_id)) }), + }, + { + name: "confluence_list_spaces", + description: "List Confluence spaces.", + input: { + page: { type: "number", description: "page number, 1-based (default 1)" }, + limit: { type: "number", description: "results per page (default 25)" }, + }, + run: async (args) => { + const page = num(args.page, 1); + const limit = num(args.limit, 25); + const cursor = String((page - 1) * limit); + return { spaces: await confluence.listSpaces({ cursor, limit: String(limit) }) }; + }, + }, + ]; +} + +// confluence always registers its full tool surface: the client is built lazily and never throws, so +// the runtime comes up and serves every tool even before the URL/email/token are configured (the lab +// has no real Atlassian). A tool called before the module is configured fails with a clear error from +// that call. +registerModuleTools("confluence", (env) => { + try { + return getConfluenceTools(ConfluenceClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/confluence/tsconfig.json b/modules/confluence/tsconfig.json new file mode 100644 index 0000000..1f1b70a --- /dev/null +++ b/modules/confluence/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "client.ts", + "tools/index.ts" + ] +} diff --git a/modules/jira/client.ts b/modules/jira/client.ts new file mode 100644 index 0000000..cfdbae8 --- /dev/null +++ b/modules/jira/client.ts @@ -0,0 +1,178 @@ +// jira's own Jira API client — its own code, living in the module (novox/hq ADR 0039). Ported from +// the hal sdk's shared JiraClient, where a change to the Atlassian API rebuilt everything; here it +// rebuilds only jira. This module's tools import it, and nothing outside jira does. +// +// jira is a tools-only, outbound-only integration with an external SaaS (Atlassian Cloud): it holds +// no service of its own, listens for nothing, and only ever calls out to a Jira instance over its +// REST v3 API, authenticated with HTTP Basic (email + API token) against the Atlassian site. +// +// The client is built lazily and NEVER throws at construction (the Servarr lesson): the runtime must +// come up and register every tool even with no valid credentials — the lab has no real Atlassian. A +// missing URL, email or token surfaces only when a tool is actually invoked, as a clear error from +// that one call, not as a runtime that refuses to serve. + +import { readFileSync } from "node:fs"; + +export class JiraClient { + constructor( + /** The Atlassian site URL, e.g. "https://acme.atlassian.net". A public setting (config file). */ + private readonly url: string | undefined, + /** The Atlassian account email — a public setting (config file). */ + private readonly email: string | undefined, + /** The Atlassian API token — jira's one secret (own-secret). */ + private readonly token: string | undefined, + ) {} + + static fromEnv(env: NodeJS.ProcessEnv = process.env): JiraClient { + // The site URL and account email are a mesh's own facts, not this module's — settings merged into + // a config file the mesh manages (novox/hq ADR 0046) under ATLASSIAN_URL / ATLASSIAN_EMAIL, read + // here. The API token is the one secret and stays an own-secret, read from its file. Env is + // honoured as a fallback for a hand-run instance. No absence throws: the client still constructs, + // so every tool still registers and serves. + const config = readConfig(env.MESH_JIRA_CONFIG_FILE); + const url = config.ATLASSIAN_URL ?? env.MESH_JIRA_URL ?? env.ATLASSIAN_URL; + const email = config.ATLASSIAN_EMAIL ?? env.MESH_JIRA_EMAIL ?? env.ATLASSIAN_EMAIL; + const token = env.MESH_JIRA_TOKEN ?? readSecret(env.MESH_JIRA_TOKEN_FILE) ?? env.ATLASSIAN_TOKEN; + return new JiraClient(url, email, token); + } + + /** Whether the module is configured enough to make a call. */ + configured(): boolean { + return Boolean(this.url && this.email && this.token); + } + + private baseUrl(): string { + if (!this.url || !this.email || !this.token) { + throw new Error( + "jira is not configured — set its site URL and account email in settings " + + "(ATLASSIAN_URL, ATLASSIAN_EMAIL) and its API token as its own-secret; until then it " + + "answers no calls", + ); + } + return this.url.replace(/\/+$/, ""); + } + + private authHeader(): string { + return "Basic " + Buffer.from(`${this.email}:${this.token}`).toString("base64"); + } + + private async request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(`${this.baseUrl()}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: this.authHeader(), + ...(options.headers as Record), + }, + }); + if (!res.ok) { + throw new Error(`Jira API error ${res.status}: ${await res.text()}`); + } + if (res.status === 204) return null as T; + return res.json() as Promise; + } + + async searchIssues(jql: string, fields?: string, maxResults = 50): Promise { + const params = new URLSearchParams({ jql, maxResults: String(maxResults) }); + if (fields) params.set("fields", fields); + return this.request(`/rest/api/3/search/jql?${params}`); + } + + async getIssue(issueKey: string, fields?: string): Promise { + const params = fields ? `?fields=${encodeURIComponent(fields)}` : ""; + return this.request(`/rest/api/3/issue/${issueKey}${params}`); + } + + async createIssue(data: { + projectKey: string; + issueType: string; + summary: string; + description?: string; + }): Promise { + const body: Record = { + fields: { + project: { key: data.projectKey }, + issuetype: { name: data.issueType }, + summary: data.summary, + ...(data.description ? { description: textToAdf(data.description) } : {}), + }, + }; + return this.request("/rest/api/3/issue", { + method: "POST", + body: JSON.stringify(body), + }); + } + + async updateIssue(issueKey: string, data: { summary?: string; description?: string }): Promise { + const fields: Record = {}; + if (data.summary) fields.summary = data.summary; + if (data.description) fields.description = textToAdf(data.description); + return this.request(`/rest/api/3/issue/${issueKey}`, { + method: "PUT", + body: JSON.stringify({ fields }), + }); + } + + async addComment(issueKey: string, body: string): Promise { + return this.request(`/rest/api/3/issue/${issueKey}/comment`, { + method: "POST", + body: JSON.stringify({ body: textToAdf(body) }), + }); + } + + async listTransitions(issueKey: string): Promise { + return this.request(`/rest/api/3/issue/${issueKey}/transitions`); + } + + async transitionIssue(issueKey: string, transitionId: string): Promise { + return this.request(`/rest/api/3/issue/${issueKey}/transitions`, { + method: "POST", + body: JSON.stringify({ transition: { id: transitionId } }), + }); + } + + async listProjects(params: Record = {}): Promise { + const qs = new URLSearchParams(params).toString(); + return this.request(`/rest/api/3/project/search?${qs}`); + } +} + +/** Convert plain text into Atlassian Document Format — Jira v3 takes rich-text fields as ADF, not + * plain strings. Blank-line-separated blocks become paragraphs. */ +function textToAdf(text: string): object { + return { + version: 1, + type: "doc", + content: text.split("\n\n").map((paragraph) => ({ + type: "paragraph", + content: [{ type: "text", text: paragraph }], + })), + }; +} + +function readSecret(path: string | undefined): string | undefined { + if (!path) return undefined; + try { + return readFileSync(path, "utf8").trim(); + } catch { + return undefined; + } +} + +interface Config { + ATLASSIAN_URL?: string; + ATLASSIAN_EMAIL?: string; +} + +/** The settings-managed config file (a JSON document the mesh merges settings into), holding the + * public ATLASSIAN_URL and ATLASSIAN_EMAIL settings. Absent or unparseable yields an empty config — + * the module then answers no calls until its URL, email and token are set, but still registers and + * serves every tool. */ +function readConfig(path: string | undefined): Config { + if (!path) return {}; + try { + return JSON.parse(readFileSync(path, "utf8")) as Config; + } catch { + return {}; + } +} diff --git a/modules/jira/module.json b/modules/jira/module.json new file mode 100644 index 0000000..42bbdc1 --- /dev/null +++ b/modules/jira/module.json @@ -0,0 +1,50 @@ +{ + "module": "jira", + "version": "1", + "own-secrets": { + "token": "/var/lib/jira/token", + "broker": "/var/lib/mesh/jira/broker" + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/jira", + "mode": "0700" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/jira", + "mode": "0700" + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/jira/config.json", + "merge": "json", + "content": "{}", + "mode": "0600" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-runtime-jira", + "image": "mesh-runtime-jira@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/jira/config.json:/run/config/config.json:ro", + "/var/lib/jira/token:/run/secrets/token:ro", + "/var/lib/mesh/jira/broker:/run/secrets/broker:ro" + ], + "env": { + "MESH_JIRA_TOKEN_FILE": "/run/secrets/token", + "MESH_JIRA_CONFIG_FILE": "/run/config/config.json", + "MESH_BROKER_FILE": "/run/secrets/broker" + } + } + ], + "capabilities": [ + "container-runtime" + ] +} diff --git a/modules/jira/package.json b/modules/jira/package.json new file mode 100644 index 0000000..51e0dfe --- /dev/null +++ b/modules/jira/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-jira", + "version": "0.1.0", + "description": "jira — a tools-only, outbound-only Atlassian Jira SaaS integration (novox/hq ADR 0039): its API client and tools live here.", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/jira/tools/index.ts b/modules/jira/tools/index.ts new file mode 100644 index 0000000..829a4e8 --- /dev/null +++ b/modules/jira/tools/index.ts @@ -0,0 +1,156 @@ +// jira's tools — jira's own code (novox/hq ADR 0039), importing jira's own Jira API client. They +// return structured data; the mesh serves them through the sdk's tool harness. jira is tools-only and +// outbound-only: no service, no events, no listener — it reaches out to an Atlassian Jira instance +// and exposes its issues, comments, transitions and projects. +// +// NOTE — the HAL jira module also shipped a periodic ticket-poller (update-tickets.service/.timer) +// that pulled tickets on a schedule. It is deliberately NOT ported: the mesh has no scheduled-task +// primitive yet (a pending decision). Only jira's TOOLS are ported here; the poller is deferred until +// the scheduled-task capability lands. +// +// Every tool is registered unconditionally, even with no credentials configured (the Servarr +// lesson): the client is built lazily and never throws, so the runtime always comes up and serves the +// full tool surface — a call made before the URL/email/token are set fails with a clear error, but +// the runtime does not refuse to serve. The install proof is the runtime logging +// `[mesh-tools] serving N tool(s)`. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { JiraClient } from "../client.js"; + +function str(value: unknown): string { + return String(value ?? ""); +} + +function num(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +export function getJiraTools(jira: JiraClient): ToolDefinition[] { + return [ + // --- Issues --- + { + name: "jira_search_issues", + description: "Search Jira issues using JQL.", + input: { + jql: { type: "string", description: "JQL query string" }, + fields: { type: "string", description: "comma-separated field names to return (optional)" }, + max_results: { type: "number", description: "max results (default 50)" }, + }, + run: async (args) => ({ + results: await jira.searchIssues( + str(args.jql), + args.fields !== undefined ? str(args.fields) : undefined, + num(args.max_results, 50), + ), + }), + }, + { + name: "jira_get_issue", + description: "Get a single Jira issue by key (e.g. PROJ-123).", + input: { + issue_key: { type: "string", description: "Issue key, e.g. PROJ-123" }, + fields: { type: "string", description: "comma-separated field names to return (optional)" }, + }, + run: async (args) => ({ + issue: await jira.getIssue(str(args.issue_key), args.fields !== undefined ? str(args.fields) : undefined), + }), + }, + { + name: "jira_create_issue", + description: "Create a new Jira issue.", + input: { + project_key: { type: "string", description: "Project key, e.g. PROJ" }, + issue_type: { type: "string", description: "Issue type name, e.g. Task, Bug, Story" }, + summary: { type: "string", description: "Issue summary" }, + description: { type: "string", description: "Plain text description, converted to ADF internally (optional)" }, + }, + run: async (args) => ({ + issue: await jira.createIssue({ + projectKey: str(args.project_key), + issueType: str(args.issue_type), + summary: str(args.summary), + description: args.description !== undefined ? str(args.description) : undefined, + }), + }), + }, + { + name: "jira_update_issue", + description: "Update an existing Jira issue.", + input: { + issue_key: { type: "string", description: "Issue key, e.g. PROJ-123" }, + summary: { type: "string", description: "new summary (optional)" }, + description: { type: "string", description: "new plain text description, converted to ADF internally (optional)" }, + }, + run: async (args) => { + await jira.updateIssue(str(args.issue_key), { + summary: args.summary !== undefined ? str(args.summary) : undefined, + description: args.description !== undefined ? str(args.description) : undefined, + }); + return { updated: true, issue_key: str(args.issue_key) }; + }, + }, + { + name: "jira_add_comment", + description: "Add a comment to a Jira issue.", + input: { + issue_key: { type: "string", description: "Issue key, e.g. PROJ-123" }, + body: { type: "string", description: "comment text" }, + }, + run: async (args) => ({ comment: await jira.addComment(str(args.issue_key), str(args.body)) }), + }, + { + name: "jira_list_transitions", + description: "List available transitions for a Jira issue.", + input: { + issue_key: { type: "string", description: "Issue key, e.g. PROJ-123" }, + }, + run: async (args) => ({ transitions: await jira.listTransitions(str(args.issue_key)) }), + }, + { + name: "jira_transition_issue", + description: "Transition a Jira issue to a new status.", + input: { + issue_key: { type: "string", description: "Issue key, e.g. PROJ-123" }, + transition_id: { type: "string", description: "Transition ID (get from jira_list_transitions)" }, + }, + run: async (args) => { + await jira.transitionIssue(str(args.issue_key), str(args.transition_id)); + return { transitioned: true, issue_key: str(args.issue_key) }; + }, + }, + + // --- Projects --- + { + name: "jira_list_projects", + description: "List Jira projects.", + input: { + query: { type: "string", description: "search query for project name (optional)" }, + page: { type: "number", description: "page index, 0-based (default 0)" }, + max_results: { type: "number", description: "results per page (default 50)" }, + }, + run: async (args) => { + const page = num(args.page, 0); + const maxResults = num(args.max_results, 50); + const params: Record = { + startAt: String(page * maxResults), + maxResults: String(maxResults), + }; + if (args.query) params.query = str(args.query); + return { projects: await jira.listProjects(params) }; + }, + }, + ]; +} + +// jira always registers its full tool surface: the client is built lazily and never throws, so the +// runtime comes up and serves every tool even before the URL/email/token are configured (the lab has +// no real Atlassian). A tool called before the module is configured fails with a clear error from +// that call. +registerModuleTools("jira", (env) => { + try { + return getJiraTools(JiraClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/jira/tsconfig.json b/modules/jira/tsconfig.json new file mode 100644 index 0000000..1f1b70a --- /dev/null +++ b/modules/jira/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "client.ts", + "tools/index.ts" + ] +}