Stand up mesh-sdk — the stable spine a module builds against

Per novox/hq ADR 0044/0045: the sdk holds only what rarely changes and
is shared across modules; per-module code (a client, tool impls, a
create-a-resource adapter) lives in the module.

Five areas, real and tested:
- contracts: the runtime shapes module code touches (grant, credential,
  a mesh Interface, tool + envelope types) — not the manifest schema,
  which the control plane owns.
- provisioner: the reconcile harness every provider shares (watch grants,
  create via the module's adapter, seal + write the credential, remove on
  withdrawal). A module writes only the adapter.
- tools: registerModuleTools + collectTools — the serving harness; tools
  and their client live in the module.
- messaging: the Broker/Envelope/event contract over the mesh broker; the
  concrete binding is provided by the hosting runtime.
- primitives: AES-256-GCM seal/unseal, semver, resolved-env access.

Compiles (tsc, NodeNext) and passes tests: sealing round-trip + wrong-key
rejection, semver, tool registration (a thrower is skipped not fatal), and
the provisioner creating then removing a sealed grant.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-03 23:01:59 +02:00
commit a19a2f5cf0
12 changed files with 639 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+87
View File
@@ -0,0 +1,87 @@
# mesh-sdk
The stable spine a Novox Mesh **module's own code builds against** — the tool it uses to plug
into the mesh, and nothing that belongs to a specific module or to another tier.
It earns its place by **rarely changing** (novox/hq [ADR 0044](https://git.novox.be/novox/hq)).
The test for anything here: *if editing it recompiles unrelated modules and it changes often, it
does not belong.* Per-module code (a service's API client, its tool implementations, its
create-a-resource adapter) lives **in the module**, never here — that coupling is exactly what
turned the old SDK into constant maintenance and made every edit rebuild every module.
## What is in it
Five small areas, each a thing a module's `tools/` or `provisioner/` imports:
### `contracts` — the runtime shapes module code touches
Not the manifest schema (the control plane owns and parses that, in Go). The shapes a module's
*running code* receives and returns: a provisioning **grant** and its credentials, the
**mesh interface** definitions (what an `analytics` grant contains, what an `oidc-client` grant
contains — the contract both a provider and a consumer conform to), and the tool and event types.
These change rarely and deliberately; when one does, a rebuild of everything is *correct*.
### `provisioner` — the reconcile harness every provider shares
The loop is identical for postgres, redis, minio and umami: watch the grants directory, for each
requested grant call the provider's adapter, write the sealed credential, handle withdrawal, and
keep converging (`watch`). That loop lives here. A **module provides only the adapter** — "create
a database", "create a umami site" — which is the volatile, per-service half and belongs in the
module. This is the ADR 0044 line drawn through provisioning: stable loop here, per-service create
in the module.
### `tools` — the tool-serving harness
`registerModuleTools`, the `ToolDefinition` type, and the registry/worker that loads a module's
tools and exposes them through the mesh's command surface. *How* a tool is declared and served is
settled; it does not change when an individual tool does. The tools themselves, and the API client
they call, live in the module.
### `messaging` — the broker and event framework
The broker client (connect, request/reply, publish/subscribe), the event-consumer a module uses to
react to mesh events, and the envelope/routing-key conventions. Transport primitives.
### `primitives` — sealing, semver, resolved-env
Seal/unseal for secrets, semantic version comparison, and the accessor a module uses to read its
own resolved environment.
## What is NOT in it, and where it goes
- **A module's API client and its tool implementations** → in the module. (The Plex client, the
Umami client, `tools/plex.ts` — all module-local. ADR 0044.)
- **Delivery machinery** — build executor, bundler, dependency resolver, artifact manager, feature
handlers → **mesh-control**. It co-evolves with the pipeline.
- **Host synchronisers** — config-sync, ufw config, vhost generation, systemd, health checks →
**mesh-host**'s apply engine. The host applies these; they are not module code.
- **Domain logic** — tasks, workflows, agents, provider integrations → **tier-2 contexts**.
That is why this stays small: everything volatile has somewhere else to be.
## Using it
A module's tools:
```ts
import { registerModuleTools, ToolDefinition } from "@novox/mesh-sdk/tools";
import { UmamiClient } from "../client.js"; // the client lives in the module, not here
registerModuleTools("umami", (env) => getUmamiTools(new UmamiClient(env.UMAMI_URL)));
```
A provider's provisioner:
```ts
import { runProvisioner, Grant, Credential } from "@novox/mesh-sdk/provisioner";
runProvisioner("analytics", {
async create(grant: Grant): Promise<Credential> { /* create a umami site, return the grant */ },
async remove(grant: Grant): Promise<void> { /* delete the site */ },
});
```
The adapter is the only thing the module writes; the watching, sealing and grant-file handling are
the harness's.
## Where the reasoning lives
Design and decisions are in [`novox/hq`](https://git.novox.be/novox/hq):
- `02-DECISIONS/0044-what-the-sdk-holds-and-refuses.md` — the stability rule this repository is
- `02-DECISIONS/0045-what-a-module-is.md` — a module, and why its per-module code lives in the module
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@novox/mesh-sdk",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@novox/mesh-sdk",
"version": "0.1.0",
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@novox/mesh-sdk",
"version": "0.1.0",
"description": "The stable spine a Novox Mesh module's own code builds against.",
"type": "module",
"exports": {
".": "./dist/index.js",
"./contracts": "./dist/contracts/index.js",
"./provisioner": "./dist/provisioner/index.js",
"./tools": "./dist/tools/index.js",
"./messaging": "./dist/messaging/index.js",
"./primitives": "./dist/primitives/index.js"
},
"scripts": {
"build": "tsc",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+52
View File
@@ -0,0 +1,52 @@
// The runtime shapes a module's own code touches — NOT the manifest schema, which the control
// plane owns and parses (in Go). These are what a running module receives and returns: a grant
// and its credentials, the mesh interface a provider and consumer both conform to, and the tool
// and event types. They change rarely and deliberately (novox/hq ADR 0044).
/** A request for one instance of a provided resource, addressed to a provider. */
export interface Grant {
/** The mesh interface being provisioned, e.g. "analytics", "postgres-database". */
readonly resource: string;
/** Who asked — the consumer module, on which node. */
readonly consumer: string;
readonly node: string;
/** What the consumer contributed (per the interface's spec keys), e.g. `{ name: "umami" }`. */
readonly values: Readonly<Record<string, string>>;
}
/** What a provider hands back for a grant. Sealed by the harness before it leaves the machine. */
export interface Credential {
/** The fields the interface promises a consumer, e.g. `{ host, port, as, password }`. */
readonly fields: Readonly<Record<string, string>>;
}
/**
* A mesh interface: the provider-neutral contract for a capability (novox/hq ADR 0045). Both a
* provider (which adapts its software to it) and a consumer (which depends on it, never on a
* provider) conform. `spec` names what a consumer may contribute; `credential` names what it
* receives. The interface is drawn at the consumer's real coupling: neutral where thin
* (`analytics`), the protocol where the consumer speaks one (`postgres-database`).
*/
export interface Interface {
readonly name: string;
/** Keys a consumer may contribute when requesting it. */
readonly spec: readonly string[];
/** Fields a consumer receives in its credential. */
readonly credential: readonly string[];
}
/** A tool a module exposes through the mesh's command surface. */
export interface ToolDefinition {
readonly name: string;
readonly description: string;
/** JSON-schema-shaped input contract; kept opaque here so tools own their own shapes. */
readonly input: Readonly<Record<string, unknown>>;
readonly run: (args: Readonly<Record<string, unknown>>) => Promise<unknown>;
}
/** A message crossing the broker: a routing key and a JSON body, per-node addressed. */
export interface Envelope<T = unknown> {
readonly key: string;
readonly node: string;
readonly body: T;
}
+11
View File
@@ -0,0 +1,11 @@
// @novox/mesh-sdk — the stable spine a module's own code builds against (novox/hq ADR 0044).
//
// Import the area you need directly (`@novox/mesh-sdk/tools`, `/provisioner`, …) or the whole
// surface from here. Everything specific to one module — its API client, its tool implementations,
// its create-a-resource adapter — lives in the module, never here.
export * from "./contracts/index.js";
export * as tools from "./tools/index.js";
export * as provisioner from "./provisioner/index.js";
export * as messaging from "./messaging/index.js";
export * as primitives from "./primitives/index.js";
+48
View File
@@ -0,0 +1,48 @@
// The broker and event framework a module's code uses to talk to the mesh. The transport is the
// mesh's one broker (novox/hq ADR 0001); this is the typed surface over it. The concrete broker
// binding is provided by the runtime that hosts a module's code — the sdk defines the contract so
// module code, the tool runtime and provisioners all speak it the same way.
import type { Envelope } from "../contracts/index.js";
export type { Envelope };
/** A request/reply call and a publish/subscribe surface over the mesh broker. */
export interface Broker {
/** Ask one question and await one answer — the shape mesh-control's command API is reached by. */
request<Req, Res>(key: string, body: Req): Promise<Res>;
/** Emit an event onto the mesh. */
publish<T>(env: Envelope<T>): Promise<void>;
/** React to events matching a routing-key pattern. Returns an unsubscribe. */
subscribe<T>(pattern: string, handler: (env: Envelope<T>) => Promise<void>): Promise<() => void>;
close(): Promise<void>;
}
/**
* How a module obtains its broker. The hosting runtime sets this once; module code calls broker()
* without knowing the concrete binding. Keeping the binding out of the sdk is deliberate — the sdk
* carries the contract, not a specific AMQP client build.
*/
let binding: (() => Broker) | undefined;
export function useBroker(factory: () => Broker): void {
binding = factory;
}
export function broker(): Broker {
if (!binding) {
throw new Error(
"no broker is bound — the tool runtime or provisioner host must call useBroker() before " +
"module code reaches the mesh",
);
}
return binding();
}
/** A convenience for the common case: consume one event stream until stopped. */
export async function consume<T>(
pattern: string,
handler: (env: Envelope<T>) => Promise<void>,
): Promise<() => void> {
return broker().subscribe<T>(pattern, handler);
}
+87
View File
@@ -0,0 +1,87 @@
// Small, stable primitives every module's code may need. No behaviour here changes when a module
// changes; that is the whole point of it living in the sdk.
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
// --- sealing ---
//
// A secret is sealed to a key so a copy of it at rest is not a working credential. AES-256-GCM;
// the key is derived from a per-node passphrase the host holds. The host unseals on the machine;
// nothing else does (novox/hq ADR 0043's link is the boundary — the sdk only carries the mechanism).
const MAGIC = "msk1"; // versions the sealed format, so it can change without silent misreads
/** Seal plaintext to a passphrase. Returns `msk1:<salt>:<iv>:<tag>:<ciphertext>`, base64 parts. */
export function seal(plaintext: string, passphrase: string): string {
const salt = randomBytes(16);
const iv = randomBytes(12);
const key = scryptSync(passphrase, salt, 32);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [MAGIC, b64(salt), b64(iv), b64(tag), b64(enc)].join(":");
}
/** Unseal what seal produced. Throws — loudly — on any tamper or wrong key. */
export function unseal(sealed: string, passphrase: string): string {
const parts = sealed.split(":");
if (parts.length !== 5 || parts[0] !== MAGIC) {
throw new Error("not a sealed value this version understands");
}
const [, salt, iv, tag, enc] = parts.map((p, i) => (i === 0 ? Buffer.alloc(0) : ub64(p)));
const key = scryptSync(passphrase, salt, 32);
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8");
}
const b64 = (b: Buffer): string => b.toString("base64url");
const ub64 = (s: string): Buffer => Buffer.from(s, "base64url");
// --- semver ---
//
// Enough to compare and satisfy, no dependency spent on it. Pre-release and build metadata are
// ignored by design — the mesh pins by digest, and versions here order releases, nothing subtler.
export interface Version {
readonly major: number;
readonly minor: number;
readonly patch: number;
}
export function parseVersion(v: string): Version {
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
if (!m) throw new Error(`not a version: ${v}`);
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
}
/** -1, 0, 1 — a before b, equal, a after b. */
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
const x = parseVersion(a);
const y = parseVersion(b);
for (const k of ["major", "minor", "patch"] as const) {
if (x[k] < y[k]) return -1;
if (x[k] > y[k]) return 1;
}
return 0;
}
// --- resolved env ---
//
// A module reads its own resolved environment through this, rather than reaching into process.env
// directly, so the one place that would change if resolution changes is here.
/** Read a resolved env var; throws if a required one is absent, which is the right failure. */
export function requireEnv(name: string, env: NodeJS.ProcessEnv = process.env): string {
const v = env[name];
if (v === undefined || v === "") {
throw new Error(`${name} is not set — the module was deployed without a value it requires`);
}
return v;
}
/** Read a resolved env var with a fallback. */
export function readEnv(name: string, fallback: string, env: NodeJS.ProcessEnv = process.env): string {
const v = env[name];
return v === undefined || v === "" ? fallback : v;
}
+139
View File
@@ -0,0 +1,139 @@
// The reconcile harness every provider shares. The loop below is identical for postgres, redis,
// minio and umami: read the grants the control plane has written, bring each requested resource to
// existence through the provider's adapter, seal and hand back the credential, and remove what is
// no longer requested. A module writes ONLY the adapter — the per-service half — which is why this
// lives in the sdk and the adapter lives in the module (novox/hq ADR 0044).
import { readdir, readFile, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import type { Grant, Credential } from "../contracts/index.js";
import { seal } from "../primitives/index.js";
/** What a provider implements — the only per-service code. It is handed a Grant and creates (or
* removes) the resource in its own software, returning the credential a consumer receives. */
export interface Adapter {
create(grant: Grant): Promise<Credential>;
remove(grant: Grant): Promise<void>;
}
export interface ProvisionerOptions {
/** Directory the control plane writes grant requests into and the harness writes credentials to.
* Defaults to $GRANTS. */
grants?: string;
/** Passphrase a credential is sealed to before it is written. Defaults to $MESH_SEAL_KEY. */
sealKey?: string;
/** Reconcile interval in ms. Defaults to 5000. */
everyMs?: number;
}
export type { Grant, Credential };
/**
* Run the reconcile loop for one provided resource. Returns a stop function. Never throws for a
* single bad grant — it logs and keeps converging, because one consumer's failure must not stop
* the others' provisioning.
*/
export function runProvisioner(resource: string, adapter: Adapter, opts: ProvisionerOptions = {}): () => void {
const dir = opts.grants ?? envOrThrow("GRANTS");
const sealKey = opts.sealKey ?? envOrThrow("MESH_SEAL_KEY");
const everyMs = opts.everyMs ?? 5000;
const applied = new Map<string, string>(); // consumer -> hash of the grant last applied
let stopped = false;
async function reconcile(): Promise<void> {
const requested = await readGrants(dir, resource);
const wantById = new Map(requested.map((g) => [g.consumer, g]));
// Create or update anything requested whose grant has changed.
for (const grant of requested) {
const h = hash(grant);
if (applied.get(grant.consumer) === h) continue;
try {
const cred = await adapter.create(grant);
await writeSealedCredential(dir, grant, cred, sealKey);
applied.set(grant.consumer, h);
} catch (err) {
console.error(`[provisioner:${resource}] ${grant.consumer}: create failed, will retry: ${err}`);
}
}
// Remove anything applied that is no longer requested. Only the harness's own outputs are
// touched — the credential file it wrote — never anything it did not create.
for (const consumer of [...applied.keys()]) {
if (wantById.has(consumer)) continue;
const grant = lastGrant(dir, resource, consumer);
try {
await adapter.remove(grant);
await rm(credentialPath(dir, resource, consumer), { force: true });
applied.delete(consumer);
} catch (err) {
console.error(`[provisioner:${resource}] ${consumer}: remove failed, will retry: ${err}`);
}
}
}
const tick = async (): Promise<void> => {
if (stopped) return;
try {
await reconcile();
} catch (err) {
console.error(`[provisioner:${resource}] reconcile error: ${err}`);
}
if (!stopped) setTimeout(() => void tick(), everyMs);
};
void tick();
return () => {
stopped = true;
};
}
// --- grant/credential files ---
async function readGrants(dir: string, resource: string): Promise<Grant[]> {
let names: string[];
try {
names = await readdir(dir);
} catch {
return [];
}
const out: Grant[] = [];
for (const name of names) {
if (!name.endsWith(".grant.json")) continue;
try {
const raw = await readFile(join(dir, name), "utf8");
const g = JSON.parse(raw) as Grant;
if (g.resource === resource) out.push(g);
} catch (err) {
console.error(`[provisioner:${resource}] unreadable grant ${name}: ${err}`);
}
}
return out;
}
function credentialPath(dir: string, resource: string, consumer: string): string {
return join(dir, `${consumer}.${resource}.credential`);
}
async function writeSealedCredential(dir: string, grant: Grant, cred: Credential, key: string): Promise<void> {
const body = JSON.stringify({ resource: grant.resource, consumer: grant.consumer, fields: cred.fields });
await writeFile(credentialPath(dir, grant.resource, grant.consumer), seal(body, key), { mode: 0o600 });
}
function lastGrant(dir: string, resource: string, consumer: string): Grant {
// For removal the harness needs a Grant to hand the adapter; the identity is enough to act on.
return { resource, consumer, node: "", values: {} };
}
// --- helpers ---
function hash(g: Grant): string {
return JSON.stringify([g.resource, g.consumer, g.node, g.values]);
}
function envOrThrow(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`${name} is not set — the provisioner cannot run without it`);
return v;
}
+48
View File
@@ -0,0 +1,48 @@
// The tool-serving harness. A module declares its tools through registerModuleTools; a runtime
// (the mesh's per-node tool host) collects the registrations and serves them through the command
// surface. HOW a tool is declared and served is settled and lives here; the tools themselves, and
// the API client they call, live in the module (novox/hq ADR 0044).
import type { ToolDefinition } from "../contracts/index.js";
export type { ToolDefinition };
/** A module contributes its tools as a function of its resolved environment. Returning [] (e.g.
* when a token is absent) is normal — the module simply exposes nothing until it can. */
export type ToolContributor = (env: NodeJS.ProcessEnv) => ToolDefinition[];
interface Registration {
readonly module: string;
readonly contribute: ToolContributor;
}
const registrations: Registration[] = [];
/**
* Declare the tools a module exposes. Called once, at module-tool load time, from the module's
* tools/ entrypoint. The client and the tool implementations are imported from the module itself.
*/
export function registerModuleTools(module: string, contribute: ToolContributor): void {
registrations.push({ module, contribute });
}
/**
* Collect every registered module's tools against an environment. The tool runtime calls this
* after loading the assigned modules' tool entrypoints. A module whose contributor throws is
* skipped with its error surfaced, never taking the others down.
*/
export function collectTools(env: NodeJS.ProcessEnv = process.env): { module: string; tools: ToolDefinition[] }[] {
return registrations.map(({ module, contribute }) => {
try {
return { module, tools: contribute(env) };
} catch (err) {
console.error(`[tools] ${module}: contributor failed, exposing none: ${err}`);
return { module, tools: [] };
}
});
}
/** Testing/inspection: drop all registrations. */
export function resetTools(): void {
registrations.length = 0;
}
+81
View File
@@ -0,0 +1,81 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, readFile, readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { seal, unseal, compareVersions } from "../dist/primitives/index.js";
import { registerModuleTools, collectTools, resetTools } from "../dist/tools/index.js";
import { runProvisioner, type Grant } from "../dist/provisioner/index.js";
test("seal round-trips and rejects the wrong key", () => {
const sealed = seal("hunter2", "node-key");
assert.equal(unseal(sealed, "node-key"), "hunter2");
assert.notEqual(sealed, "hunter2");
assert.throws(() => unseal(sealed, "wrong-key"));
});
test("semver orders releases", () => {
assert.equal(compareVersions("1.2.3", "1.2.10"), -1);
assert.equal(compareVersions("2.0.0", "1.9.9"), 1);
assert.equal(compareVersions("v1.0.0", "1.0.0"), 0);
});
test("module tools register and collect, a thrower is skipped not fatal", () => {
resetTools();
registerModuleTools("umami", () => [
{ name: "umami_stats", description: "d", input: {}, run: async () => 1 },
]);
registerModuleTools("broken", () => {
throw new Error("no token");
});
const collected = collectTools({});
const umami = collected.find((c) => c.module === "umami");
const broken = collected.find((c) => c.module === "broken");
assert.equal(umami?.tools.length, 1);
assert.equal(broken?.tools.length, 0);
});
test("provisioner creates a sealed credential for a grant, then removes on withdrawal", async () => {
const dir = await mkdtemp(join(tmpdir(), "prov-"));
const created: string[] = [];
const removed: string[] = [];
const grant: Grant = { resource: "analytics", consumer: "webapp", node: "anchor", values: { name: "webapp" } };
await writeFile(join(dir, "webapp.grant.json"), JSON.stringify(grant));
const stop = runProvisioner(
"analytics",
{
async create(g) {
created.push(g.consumer);
return { fields: { siteId: "abc", snippet: "<script>", dashboard: "https://x/webapp" } };
},
async remove(g) {
removed.push(g.consumer);
},
},
{ grants: dir, sealKey: "k", everyMs: 20 },
);
await waitFor(() => created.length === 1, 2000);
const files = await readdir(dir);
const credFile = files.find((f) => f.endsWith(".credential"));
assert.ok(credFile, "a credential file was written");
const sealed = await readFile(join(dir, credFile!), "utf8");
const body = JSON.parse(unseal(sealed, "k")) as { fields: Record<string, string> };
assert.equal(body.fields.siteId, "abc");
// Withdraw the grant → the harness removes via the adapter and deletes the credential.
await (await import("node:fs/promises")).rm(join(dir, "webapp.grant.json"));
await waitFor(() => removed.length === 1, 2000);
stop();
});
async function waitFor(cond: () => boolean, ms: number): Promise<void> {
const start = Date.now();
while (!cond()) {
if (Date.now() - start > ms) throw new Error("timed out waiting");
await new Promise((r) => setTimeout(r, 10));
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}