umami: complete, on the sdk — the first fully converted module
umami is now a whole module, not a manifest: its own API client, its
tools, and its provisioner all live in the module and build on
@novox/mesh-sdk.
- client.ts — umami's API client, moved out of the shared sdk into the
module (ADR 0044); umami's tools and provisioner both import it.
- tools/ — umami_create_site / umami_delete_site on the sdk tool harness
(registerModuleTools); the tool logic and client are the module's.
- provisioner/ — the adapter making umami a provider of the mesh
'analytics' interface: a consumer contributes {domain}, receives
{siteId, snippet, dashboard}. ~20 lines, because the watch/seal/grant
loop is the sdk harness's.
Type-checks against the real mesh-sdk (tsc --noEmit clean); the manifest
parses against internal/catalogue. Remaining to actually run: build and
publish the module + its mesh-provision-umami-analytics image (the
placeholder digest), which is the pipeline's job.
Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
// The umami API client — moved here from the shared sdk, because it is umami's own code and
|
||||
// changes when umami's API does (novox/hq ADR 0044). Both this module's tools and its provisioner
|
||||
// import it; nothing outside umami does.
|
||||
|
||||
export interface Website {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export class UmamiClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly username: string,
|
||||
private readonly password: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/** Build from the module's resolved environment. */
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): UmamiClient {
|
||||
const url = env.MESH_PROVISION_UMAMI_URL ?? env.UMAMI_URL;
|
||||
const username = env.UMAMI_USERNAME ?? "admin";
|
||||
const password = env.UMAMI_ADMIN_PASSWORD;
|
||||
if (!url || !password) {
|
||||
throw new Error("UMAMI url or admin password is not set — umami's own code cannot reach it");
|
||||
}
|
||||
return new UmamiClient(url, username, password);
|
||||
}
|
||||
|
||||
async getToken(): Promise<string> {
|
||||
const res = await fetch(`${this.baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: this.username, password: this.password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`umami login failed: ${res.status} ${await res.text()}`);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
}
|
||||
|
||||
async findWebsite(token: string, domain: string): Promise<Website | null> {
|
||||
const res = await fetch(`${this.baseUrl}/api/websites?limit=100`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`umami list websites failed: ${res.status} ${await res.text()}`);
|
||||
const data = (await res.json()) as { data: Website[] };
|
||||
return data.data.find((w) => w.domain === domain) ?? null;
|
||||
}
|
||||
|
||||
async createWebsite(token: string, domain: string, name: string): Promise<Website> {
|
||||
const res = await fetch(`${this.baseUrl}/api/websites`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ domain, name }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`umami create website failed: ${res.status} ${await res.text()}`);
|
||||
return (await res.json()) as Website;
|
||||
}
|
||||
|
||||
async deleteWebsite(token: string, id: string): Promise<void> {
|
||||
const res = await fetch(`${this.baseUrl}/api/websites/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`umami delete website failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
/** The embed snippet a tracked site includes — the heart of the analytics grant. */
|
||||
snippet(websiteId: string): string {
|
||||
return `<script defer src="${this.baseUrl}/script.js" data-website-id="${websiteId}"></script>`;
|
||||
}
|
||||
|
||||
dashboard(websiteId: string): string {
|
||||
return `${this.baseUrl}/websites/${websiteId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-umami",
|
||||
"version": "0.1.0",
|
||||
"description": "umami — web analytics. A provider of the mesh analytics interface, with its own tools.",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// umami's provisioner — the adapter that makes umami a provider of the mesh `analytics` interface.
|
||||
// The watching, sealing and grant-file handling are the sdk harness's; this writes only the
|
||||
// per-service half: how umami creates and removes a tracked site (novox/hq ADR 0044/0045).
|
||||
//
|
||||
// The `analytics` interface: a consumer contributes `{ domain }` (the site it wants tracked) and
|
||||
// receives `{ siteId, snippet, dashboard }`. umami adapts its own API to that contract, so a
|
||||
// consumer depends on `analytics`, not on umami.
|
||||
|
||||
import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner";
|
||||
import { UmamiClient } from "../client.js";
|
||||
|
||||
const umami = UmamiClient.fromEnv();
|
||||
|
||||
runProvisioner("analytics", {
|
||||
async create(grant: Grant): Promise<Credential> {
|
||||
const domain = grant.values.domain ?? grant.consumer;
|
||||
const name = grant.values.name ?? domain;
|
||||
const token = await umami.getToken();
|
||||
const site = (await umami.findWebsite(token, domain)) ?? (await umami.createWebsite(token, domain, name));
|
||||
return {
|
||||
fields: {
|
||||
siteId: site.id,
|
||||
snippet: umami.snippet(site.id),
|
||||
dashboard: umami.dashboard(site.id),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async remove(grant: Grant): Promise<void> {
|
||||
const domain = grant.values.domain ?? grant.consumer;
|
||||
const token = await umami.getToken();
|
||||
const site = await umami.findWebsite(token, domain);
|
||||
if (site) await umami.deleteWebsite(token, site.id);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// umami's tools — moved here from the shared sdk (novox/hq ADR 0044). They import umami's own
|
||||
// client (../client) and plug into the mesh through the sdk's tool harness. Editing them rebuilds
|
||||
// umami and nothing else.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { UmamiClient } from "../client.js";
|
||||
|
||||
export function getUmamiTools(umami: UmamiClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "umami_create_site",
|
||||
description: "Register a website in umami and return its tracking snippet.",
|
||||
input: {
|
||||
domain: { type: "string", description: "the site domain, e.g. my-app.example" },
|
||||
name: { type: "string", description: "display name (defaults to the domain)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const domain = String(args.domain);
|
||||
const name = args.name ? String(args.name) : domain;
|
||||
const token = await umami.getToken();
|
||||
const existing = await umami.findWebsite(token, domain);
|
||||
const site = existing ?? (await umami.createWebsite(token, domain, name));
|
||||
return {
|
||||
created: !existing,
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
domain: site.domain,
|
||||
snippet: umami.snippet(site.id),
|
||||
dashboard: umami.dashboard(site.id),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "umami_delete_site",
|
||||
description: "Delete a website from umami (destructive).",
|
||||
input: {
|
||||
domain: { type: "string", description: "the site domain to delete" },
|
||||
confirm: { type: "boolean", description: "must be true to delete" },
|
||||
},
|
||||
run: async (args) => {
|
||||
if (args.confirm !== true) return { deleted: false, reason: "confirm must be true" };
|
||||
const domain = String(args.domain);
|
||||
const token = await umami.getToken();
|
||||
const site = await umami.findWebsite(token, domain);
|
||||
if (!site) return { deleted: false, reason: `no website with domain ${domain}` };
|
||||
await umami.deleteWebsite(token, site.id);
|
||||
return { deleted: true, id: site.id, domain: site.domain };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The module contributes its tools as a function of its resolved environment; the client lives in
|
||||
// the module, so a token being absent simply yields no tools rather than a failure.
|
||||
registerModuleTools("umami", (env) => {
|
||||
try {
|
||||
return getUmamiTools(UmamiClient.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", "tools/**/*.ts", "provisioner/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user