diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index 7890e51..3620c8c 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -61,17 +61,15 @@ const { data } = useQuery({ ## Types -Document types come from Convex. Use `Doc<"tableName">` from the generated types: +Document types live in `packages/backend/convex/types.ts`. Import from `@clawe/backend/types`: ```tsx -import type { Doc } from "@clawe/backend/server"; - -type Task = Doc<"tasks">; -type Agent = Doc<"agents">; -type Message = Doc<"messages">; +import type { Agent, Tenant } from "@clawe/backend/types"; ``` -Or infer from query results (preferred when using the data directly). +- If a type doesn't exist yet, add it to `types.ts` using `Doc<>` +- Never use `Doc<>` outside of `types.ts` +- Or infer from query results (preferred when using the data directly) **Environment variables:** diff --git a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-disconnect-dialog.tsx b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-disconnect-dialog.tsx index 0062ee8..1770598 100644 --- a/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-disconnect-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-disconnect-dialog.tsx @@ -6,6 +6,7 @@ import { toast } from "@clawe/ui/components/sonner"; import { api } from "@clawe/backend"; import { Button } from "@clawe/ui/components/button"; import { Spinner } from "@clawe/ui/components/spinner"; +import { removeTelegramBot } from "@/lib/squadhub/actions"; import { AlertDialog, AlertDialogContent, @@ -32,6 +33,10 @@ export const TelegramDisconnectDialog = ({ const handleDisconnect = async () => { setIsDisconnecting(true); try { + // Remove token from squadhub config + await removeTelegramBot(); + + // Update Convex status await disconnectChannel({ type: "telegram" }); toast.success("Telegram disconnected"); onOpenChange(false); @@ -50,8 +55,8 @@ export const TelegramDisconnectDialog = ({ Your bot{" "} {botUsername && @{botUsername}}{" "} - will stop receiving messages. The bot token will remain saved and - you can reconnect anytime. + will stop receiving messages. You can reconnect anytime by adding a + new bot token. diff --git a/apps/web/src/app/api/chat/abort/route.spec.ts b/apps/web/src/app/api/chat/abort/route.spec.ts index f10ba65..58a09a7 100644 --- a/apps/web/src/app/api/chat/abort/route.spec.ts +++ b/apps/web/src/app/api/chat/abort/route.spec.ts @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +import { mockTenantAuth } from "@/test/mock-tenant-auth"; import { POST } from "./route"; +vi.mock("@/lib/api/tenant-auth", () => mockTenantAuth); + // Mock the shared client const mockRequest = vi.fn(); diff --git a/apps/web/src/app/api/chat/abort/route.ts b/apps/web/src/app/api/chat/abort/route.ts index 2e8c30b..1460a4a 100644 --- a/apps/web/src/app/api/chat/abort/route.ts +++ b/apps/web/src/app/api/chat/abort/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getSharedClient } from "@clawe/shared/squadhub"; +import { getAuthenticatedTenant } from "@/lib/api/tenant-auth"; import { getConnection } from "@/lib/squadhub/connection"; export const runtime = "nodejs"; @@ -15,6 +16,9 @@ type AbortRequestBody = { * Abort an in-progress chat generation. */ export async function POST(request: NextRequest) { + const auth = await getAuthenticatedTenant(request); + if (auth.error) return auth.error; + let body: AbortRequestBody; try { @@ -33,7 +37,7 @@ export async function POST(request: NextRequest) { } try { - const client = await getSharedClient(getConnection()); + const client = await getSharedClient(getConnection(auth.tenant)); await client.request("chat.abort", { sessionKey, diff --git a/apps/web/src/app/api/chat/history/route.spec.ts b/apps/web/src/app/api/chat/history/route.spec.ts index 6c76a70..ff656b5 100644 --- a/apps/web/src/app/api/chat/history/route.spec.ts +++ b/apps/web/src/app/api/chat/history/route.spec.ts @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +import { mockTenantAuth } from "@/test/mock-tenant-auth"; import { GET } from "./route"; +vi.mock("@/lib/api/tenant-auth", () => mockTenantAuth); + // Mock the shared client const mockRequest = vi.fn(); diff --git a/apps/web/src/app/api/chat/history/route.ts b/apps/web/src/app/api/chat/history/route.ts index a6600a0..13a583a 100644 --- a/apps/web/src/app/api/chat/history/route.ts +++ b/apps/web/src/app/api/chat/history/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getSharedClient } from "@clawe/shared/squadhub"; import type { ChatHistoryResponse } from "@clawe/shared/squadhub"; +import { getAuthenticatedTenant } from "@/lib/api/tenant-auth"; import { getConnection } from "@/lib/squadhub/connection"; export const runtime = "nodejs"; @@ -10,6 +11,9 @@ export const dynamic = "force-dynamic"; * GET /api/chat/history?sessionKey=xxx&limit=200 */ export async function GET(request: NextRequest) { + const auth = await getAuthenticatedTenant(request); + if (auth.error) return auth.error; + const searchParams = request.nextUrl.searchParams; const sessionKey = searchParams.get("sessionKey"); const limitParam = searchParams.get("limit"); @@ -30,7 +34,7 @@ export async function GET(request: NextRequest) { } try { - const client = await getSharedClient(getConnection()); + const client = await getSharedClient(getConnection(auth.tenant)); const response = await client.request("chat.history", { sessionKey, diff --git a/apps/web/src/app/api/chat/route.spec.ts b/apps/web/src/app/api/chat/route.spec.ts index 8175344..c4d28e1 100644 --- a/apps/web/src/app/api/chat/route.spec.ts +++ b/apps/web/src/app/api/chat/route.spec.ts @@ -1,6 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { mockTenantAuth } from "@/test/mock-tenant-auth"; import { POST } from "./route"; +vi.mock("@/lib/api/tenant-auth", () => mockTenantAuth); + // Mock the AI SDK vi.mock("@ai-sdk/openai", () => ({ createOpenAI: vi.fn(() => ({ @@ -25,7 +29,7 @@ describe("POST /api/chat", () => { }); it("returns 400 when sessionKey is missing", async () => { - const request = new Request("http://localhost/api/chat", { + const request = new NextRequest("http://localhost/api/chat", { method: "POST", body: JSON.stringify({ messages: [{ role: "user", content: "Hello" }] }), }); @@ -38,7 +42,7 @@ describe("POST /api/chat", () => { }); it("returns 400 when messages is missing", async () => { - const request = new Request("http://localhost/api/chat", { + const request = new NextRequest("http://localhost/api/chat", { method: "POST", body: JSON.stringify({ sessionKey: "test-session" }), }); @@ -51,7 +55,7 @@ describe("POST /api/chat", () => { }); it("returns stream response with valid request", async () => { - const request = new Request("http://localhost/api/chat", { + const request = new NextRequest("http://localhost/api/chat", { method: "POST", body: JSON.stringify({ sessionKey: "test-session", @@ -64,7 +68,7 @@ describe("POST /api/chat", () => { }); it("returns 500 on invalid JSON", async () => { - const request = new Request("http://localhost/api/chat", { + const request = new NextRequest("http://localhost/api/chat", { method: "POST", body: "invalid json", }); diff --git a/apps/web/src/app/api/chat/route.ts b/apps/web/src/app/api/chat/route.ts index 2c15835..31d0ee0 100644 --- a/apps/web/src/app/api/chat/route.ts +++ b/apps/web/src/app/api/chat/route.ts @@ -1,5 +1,7 @@ +import type { NextRequest } from "next/server"; import { createOpenAI } from "@ai-sdk/openai"; import { streamText } from "ai"; +import { getAuthenticatedTenant } from "@/lib/api/tenant-auth"; import { getConnection } from "@/lib/squadhub/connection"; export const runtime = "nodejs"; @@ -7,10 +9,13 @@ export const dynamic = "force-dynamic"; /** * POST /api/chat - * Proxy chat requests to the squadhub's OpenAI-compatible endpoint. + * Proxy chat requests to the tenant's squadhub OpenAI-compatible endpoint. */ -export async function POST(request: Request) { +export async function POST(request: NextRequest) { try { + const auth = await getAuthenticatedTenant(request); + if (auth.error) return auth.error; + const body = await request.json(); const { messages, sessionKey } = body; @@ -28,7 +33,7 @@ export async function POST(request: Request) { }); } - const { squadhubUrl, squadhubToken } = getConnection(); + const { squadhubUrl, squadhubToken } = getConnection(auth.tenant); // Create OpenAI-compatible client pointing to squadhub gateway const squadhub = createOpenAI({ diff --git a/apps/web/src/app/api/squadhub/health/route.ts b/apps/web/src/app/api/squadhub/health/route.ts index 2c81c12..58d02e6 100644 --- a/apps/web/src/app/api/squadhub/health/route.ts +++ b/apps/web/src/app/api/squadhub/health/route.ts @@ -1,8 +1,13 @@ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; import { checkHealth } from "@clawe/shared/squadhub"; +import { getAuthenticatedTenant } from "@/lib/api/tenant-auth"; import { getConnection } from "@/lib/squadhub/connection"; -export async function POST() { - const result = await checkHealth(getConnection()); +export async function POST(request: NextRequest) { + const auth = await getAuthenticatedTenant(request); + if (auth.error) return auth.error; + + const result = await checkHealth(getConnection(auth.tenant)); return NextResponse.json(result); } diff --git a/apps/web/src/app/api/squadhub/pairing/route.ts b/apps/web/src/app/api/squadhub/pairing/route.ts index f6dc601..5d05476 100644 --- a/apps/web/src/app/api/squadhub/pairing/route.ts +++ b/apps/web/src/app/api/squadhub/pairing/route.ts @@ -1,26 +1,34 @@ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; import { - listChannelPairingRequests, - approveChannelPairingCode, + listPairingRequests, + approvePairingCode, + parseToolText, } from "@clawe/shared/squadhub"; +import { getAuthenticatedTenant } from "@/lib/api/tenant-auth"; import { getConnection } from "@/lib/squadhub/connection"; // GET /api/squadhub/pairing?channel=telegram - List pending pairing requests -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const channel = searchParams.get("channel") || "telegram"; +export async function GET(request: NextRequest) { + const auth = await getAuthenticatedTenant(request); + if (auth.error) return auth.error; - const result = await listChannelPairingRequests(channel); + const channel = request.nextUrl.searchParams.get("channel") || "telegram"; + const result = await listPairingRequests(getConnection(auth.tenant), channel); if (!result.ok) { return NextResponse.json({ error: result.error.message }, { status: 500 }); } - return NextResponse.json(result.result); + const data = parseToolText<{ requests?: unknown[] }>(result); + return NextResponse.json({ requests: data?.requests ?? [] }); } // POST /api/squadhub/pairing - Approve a pairing code -export async function POST(request: Request) { +export async function POST(request: NextRequest) { + const auth = await getAuthenticatedTenant(request); + if (auth.error) return auth.error; + try { const body = await request.json(); const { channel = "telegram", code } = body as { @@ -32,18 +40,34 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Code is required" }, { status: 400 }); } - const result = await approveChannelPairingCode( - getConnection(), + const result = await approvePairingCode( + getConnection(auth.tenant), channel, code, ); if (!result.ok) { - const status = result.error.type === "not_found" ? 404 : 500; - return NextResponse.json({ error: result.error.message }, { status }); + return NextResponse.json( + { error: result.error.message }, + { status: 500 }, + ); + } + + const data = parseToolText<{ + ok: boolean; + id?: string; + approved?: boolean; + error?: string; + }>(result); + + if (!data?.ok) { + return NextResponse.json( + { error: data?.error || "Failed to approve pairing code" }, + { status: 404 }, + ); } - return NextResponse.json(result.result); + return NextResponse.json({ id: data.id, approved: data.approved }); } catch { return NextResponse.json( { error: "Failed to approve pairing code" }, diff --git a/apps/web/src/app/api/tenant/squadhub/restart/route.ts b/apps/web/src/app/api/tenant/squadhub/restart/route.ts index 8712be8..926c2f4 100644 --- a/apps/web/src/app/api/tenant/squadhub/restart/route.ts +++ b/apps/web/src/app/api/tenant/squadhub/restart/route.ts @@ -10,10 +10,8 @@ import { getAuthenticatedTenant } from "@/lib/api/tenant-auth"; * Dev: no-op. Cloud: forces new ECS task deployment. */ export const POST = async (request: NextRequest) => { - const result = await getAuthenticatedTenant(request); - if ("error" in result) return result.error; - - const { tenant } = result; + const { error, tenant } = await getAuthenticatedTenant(request); + if (error) return error; try { await loadPlugins(); diff --git a/apps/web/src/app/api/tenant/squadhub/route.ts b/apps/web/src/app/api/tenant/squadhub/route.ts index c893085..6e9789b 100644 --- a/apps/web/src/app/api/tenant/squadhub/route.ts +++ b/apps/web/src/app/api/tenant/squadhub/route.ts @@ -10,10 +10,8 @@ import { getAuthenticatedTenant } from "@/lib/api/tenant-auth"; * Dev: no-op. Cloud: deletes ECS service + EFS access point + CloudMap entry. */ export const DELETE = async (request: NextRequest) => { - const result = await getAuthenticatedTenant(request); - if ("error" in result) return result.error; - - const { tenant } = result; + const { error, tenant } = await getAuthenticatedTenant(request); + if (error) return error; try { await loadPlugins(); diff --git a/apps/web/src/app/api/tenant/squadhub/status/route.ts b/apps/web/src/app/api/tenant/squadhub/status/route.ts index c7cead3..fca4887 100644 --- a/apps/web/src/app/api/tenant/squadhub/status/route.ts +++ b/apps/web/src/app/api/tenant/squadhub/status/route.ts @@ -11,10 +11,8 @@ import { getAuthenticatedTenant } from "@/lib/api/tenant-auth"; * Cloud: checks ECS service running count + task health. */ export const GET = async (request: NextRequest) => { - const result = await getAuthenticatedTenant(request); - if ("error" in result) return result.error; - - const { tenant } = result; + const { error, tenant } = await getAuthenticatedTenant(request); + if (error) return error; try { await loadPlugins(); diff --git a/apps/web/src/app/api/tenant/status/route.ts b/apps/web/src/app/api/tenant/status/route.ts new file mode 100644 index 0000000..7c3f1fd --- /dev/null +++ b/apps/web/src/app/api/tenant/status/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { loadPlugins, getPlugin } from "@clawe/plugins"; +import { getAuthenticatedTenant } from "@/lib/api/tenant-auth"; + +/** + * GET /api/tenant/status + * + * Check provisioning status for the current user's tenant. + * Dev: always returns { status: "active" }. + * Cloud: returns real ECS provisioning status. + */ +export const GET = async (request: NextRequest) => { + const { error, tenant } = await getAuthenticatedTenant(request); + if (error) return error; + + try { + await loadPlugins(); + const provisioner = getPlugin("squadhub-provisioner"); + const status = await provisioner.getProvisioningStatus(tenant._id); + + return NextResponse.json(status); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +}; diff --git a/apps/web/src/app/setup/provisioning/page.tsx b/apps/web/src/app/setup/provisioning/page.tsx index 8029654..a192517 100644 --- a/apps/web/src/app/setup/provisioning/page.tsx +++ b/apps/web/src/app/setup/provisioning/page.tsx @@ -10,11 +10,15 @@ import { Spinner } from "@clawe/ui/components/spinner"; import { useAuth } from "@/providers/auth-provider"; import { useApiClient } from "@/hooks/use-api-client"; +const POLL_INTERVAL_MS = 3000; +const DEFAULT_MESSAGE = "Setting up your workspace..."; + export default function ProvisioningPage() { const router = useRouter(); const { isAuthenticated } = useAuth(); const apiClient = useApiClient(); const [error, setError] = useState(null); + const [progressMessage, setProgressMessage] = useState(DEFAULT_MESSAGE); const provisioningRef = useRef(false); const tenant = useQuery( @@ -39,8 +43,38 @@ export default function ProvisioningPage() { } }, [tenant?.status, isOnboardingComplete, router]); + // Poll provisioning status for progress messages + useEffect(() => { + if (tenant?.status !== "provisioning") return; + + const poll = async () => { + try { + const { data } = await apiClient.get<{ + status: "provisioning" | "active" | "error"; + message?: string; + }>("/api/tenant/status"); + + if (data.message) { + setProgressMessage(data.message); + } + + if (data.status === "error") { + setError(data.message ?? "Provisioning failed"); + provisioningRef.current = false; + } + } catch { + // Polling errors are non-fatal — Convex subscription handles redirect + } + }; + + poll(); + const interval = setInterval(poll, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [tenant?.status, apiClient]); + const provision = useCallback(async () => { setError(null); + setProgressMessage(DEFAULT_MESSAGE); try { await apiClient.post("/api/tenant/provision"); // Convex subscription will reactively update `tenant` → redirect fires @@ -87,7 +121,7 @@ export default function ProvisioningPage() {
-

Setting up your workspace...

+

{progressMessage}

This will only take a moment.

diff --git a/apps/web/src/hooks/use-squadhub-status.ts b/apps/web/src/hooks/use-squadhub-status.ts index 242e772..bca07af 100644 --- a/apps/web/src/hooks/use-squadhub-status.ts +++ b/apps/web/src/hooks/use-squadhub-status.ts @@ -22,8 +22,8 @@ export const useSquadhubStatus = () => { return false; } }, - refetchInterval: 30000, // Check every 30 seconds - staleTime: 10000, + refetchInterval: 10000, // Check every 10 seconds + staleTime: 5000, retry: false, }); diff --git a/apps/web/src/lib/api/tenant-auth.ts b/apps/web/src/lib/api/tenant-auth.ts index 7e0bd7b..724cfd0 100644 --- a/apps/web/src/lib/api/tenant-auth.ts +++ b/apps/web/src/lib/api/tenant-auth.ts @@ -2,13 +2,20 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { ConvexHttpClient } from "convex/browser"; import { api } from "@clawe/backend"; +import type { Tenant } from "@clawe/backend/types"; + +export type AuthResult = + | { error: NextResponse; convex: null; tenant: null } + | { error: null; convex: ConvexHttpClient; tenant: Tenant }; /** * Extract the auth token and resolve the tenant for the current user. * Returns a Convex client (with auth set) and the tenant record, * or a NextResponse error if auth/tenant resolution fails. */ -export async function getAuthenticatedTenant(request: NextRequest) { +export async function getAuthenticatedTenant( + request: NextRequest, +): Promise { const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; if (!convexUrl) { return { @@ -16,6 +23,8 @@ export async function getAuthenticatedTenant(request: NextRequest) { { error: "NEXT_PUBLIC_CONVEX_URL not configured" }, { status: 500 }, ), + convex: null, + tenant: null, }; } @@ -30,6 +39,8 @@ export async function getAuthenticatedTenant(request: NextRequest) { { error: "Missing Authorization header" }, { status: 401 }, ), + convex: null, + tenant: null, }; } @@ -44,8 +55,10 @@ export async function getAuthenticatedTenant(request: NextRequest) { { error: "No tenant found for current user" }, { status: 404 }, ), + convex: null, + tenant: null, }; } - return { convex, tenant }; + return { error: null, convex, tenant }; } diff --git a/apps/web/src/lib/squadhub/actions.spec.ts b/apps/web/src/lib/squadhub/actions.spec.ts index 8f1b6d6..2bc5b24 100644 --- a/apps/web/src/lib/squadhub/actions.spec.ts +++ b/apps/web/src/lib/squadhub/actions.spec.ts @@ -6,7 +6,7 @@ vi.mock("@clawe/shared/squadhub", () => ({ getConfig: vi.fn(), saveTelegramBotToken: vi.fn(), probeTelegramToken: vi.fn(), - approveChannelPairingCode: vi.fn(), + approvePairingCode: vi.fn(), })); import { diff --git a/apps/web/src/lib/squadhub/actions.ts b/apps/web/src/lib/squadhub/actions.ts index ffe25e0..dc85a51 100644 --- a/apps/web/src/lib/squadhub/actions.ts +++ b/apps/web/src/lib/squadhub/actions.ts @@ -6,7 +6,8 @@ import { saveTelegramBotToken as saveTelegramBotTokenClient, removeTelegramBotToken as removeTelegramBotTokenClient, probeTelegramToken, - approveChannelPairingCode, + approvePairingCode as approvePairingCodeClient, + parseToolText, } from "@clawe/shared/squadhub"; import { getConnection } from "./connection"; @@ -40,7 +41,36 @@ export async function approvePairingCode( code: string, channel: string = "telegram", ) { - return approveChannelPairingCode(getConnection(), channel, code); + const result = await approvePairingCodeClient(getConnection(), channel, code); + + if (!result.ok) { + return { + ok: false as const, + error: { type: "tool_error", message: result.error.message }, + }; + } + + const data = parseToolText<{ + ok: boolean; + id?: string; + approved?: boolean; + error?: string; + }>(result); + + if (!data?.ok) { + return { + ok: false as const, + error: { + type: "not_found", + message: data?.error || "Invalid or expired pairing code", + }, + }; + } + + return { + ok: true as const, + result: { id: data.id, approved: data.approved }, + }; } export async function removeTelegramBot() { diff --git a/apps/web/src/lib/squadhub/connection.ts b/apps/web/src/lib/squadhub/connection.ts index 4b7f642..9e47f40 100644 --- a/apps/web/src/lib/squadhub/connection.ts +++ b/apps/web/src/lib/squadhub/connection.ts @@ -1,8 +1,17 @@ import type { SquadhubConnection } from "@clawe/shared/squadhub"; +import type { Tenant } from "@clawe/backend/types"; -export function getConnection(): SquadhubConnection { +/** + * Get the squadhub connection for a tenant. + * If a tenant with squadhubUrl/squadhubToken is provided, uses those. + * Otherwise falls back to env vars (self-hosted / dev). + */ +export function getConnection(tenant?: Tenant | null): SquadhubConnection { return { - squadhubUrl: process.env.SQUADHUB_URL || "http://localhost:18790", - squadhubToken: process.env.SQUADHUB_TOKEN || "", + squadhubUrl: + tenant?.squadhubUrl || + process.env.SQUADHUB_URL || + "http://localhost:18790", + squadhubToken: tenant?.squadhubToken || process.env.SQUADHUB_TOKEN || "", }; } diff --git a/apps/web/src/test/mock-tenant-auth.ts b/apps/web/src/test/mock-tenant-auth.ts new file mode 100644 index 0000000..b19a9b7 --- /dev/null +++ b/apps/web/src/test/mock-tenant-auth.ts @@ -0,0 +1,21 @@ +import { vi } from "vitest"; + +/** + * Shared mock for `@/lib/api/tenant-auth` used across API route tests. + * Import this before the route under test to set up the mock. + * + * Usage: + * vi.mock("@/lib/api/tenant-auth", () => mockTenantAuth); + */ +export const mockTenantAuth = { + getAuthenticatedTenant: vi.fn(async () => ({ + error: null, + convex: {}, + tenant: { + _id: "test-tenant-id", + squadhubUrl: "http://localhost:18790", + squadhubToken: "test-token", + status: "active", + }, + })), +}; diff --git a/docker/squadhub/Dockerfile b/docker/squadhub/Dockerfile index 3dbc35f..147df6b 100644 --- a/docker/squadhub/Dockerfile +++ b/docker/squadhub/Dockerfile @@ -11,7 +11,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -RUN npm install -g openclaw@latest +RUN npm install -g openclaw@2026.2.17 RUN mkdir -p /data/config /data/workspace @@ -24,6 +24,11 @@ RUN chmod +x /opt/clawe/scripts/*.sh COPY packages/cli/dist/clawe.js /usr/local/bin/clawe RUN chmod +x /usr/local/bin/clawe +# Copy Clawe OpenClaw extensions (plugin bundle + manifest) +COPY packages/squadhub-extensions/dist/index.js /opt/clawe/extensions/clawe/dist/index.js +COPY packages/squadhub-extensions/openclaw.plugin.json /opt/clawe/extensions/clawe/openclaw.plugin.json +COPY packages/squadhub-extensions/package.json /opt/clawe/extensions/clawe/package.json + ENV OPENCLAW_STATE_DIR=/data/config ENV OPENCLAW_PORT=18789 ENV OPENCLAW_SKIP_GMAIL_WATCHER=1 diff --git a/docker/squadhub/entrypoint.sh b/docker/squadhub/entrypoint.sh index 0e01c04..50fef3f 100644 --- a/docker/squadhub/entrypoint.sh +++ b/docker/squadhub/entrypoint.sh @@ -62,8 +62,12 @@ node /opt/clawe/scripts/pair-device.js --watch & echo "==> Starting OpenClaw gateway on port $PORT..." +# OpenClaw does a "full process restart" on certain config changes (e.g. +# adding a Telegram channel). It spawns a child process and the parent +# exits. In Docker, PID 1 exiting kills the container. We rely on +# Docker's restart policy (restart: unless-stopped) to handle this. exec openclaw gateway run \ --port "$PORT" \ - --bind 0.0.0.0 \ + --bind lan \ --token "$TOKEN" \ --allow-unconfigured diff --git a/docker/squadhub/templates/config.template.json b/docker/squadhub/templates/config.template.json index df02ef7..3c0bbd1 100644 --- a/docker/squadhub/templates/config.template.json +++ b/docker/squadhub/templates/config.template.json @@ -64,6 +64,11 @@ } ] }, + "plugins": { + "load": { + "paths": ["/opt/clawe/extensions/clawe"] + } + }, "tools": { "agentToAgent": { "enabled": true, @@ -97,6 +102,9 @@ "mode": "token", "token": "${OPENCLAW_TOKEN}" }, + "tools": { + "allow": ["gateway"] + }, "http": { "endpoints": { "chatCompletions": { diff --git a/package.json b/package.json index 412eba4..1463b1e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "scripts": { "build": "dotenv -e .env -- turbo run build", "dev": "dotenv -e .env -- turbo run dev", - "dev:docker": "pnpm --filter @clawe/cli build && docker compose up --build squadhub", + "dev:docker": "pnpm --filter @clawe/cli build && pnpm --filter @clawe/squadhub-extensions build && docker compose up --build squadhub", "build:docker": "pnpm --filter @clawe/cli build && docker compose build --no-cache squadhub", "debug": "dotenv -e .env -- turbo run debug", "dev:web": "dotenv -e .env -- turbo run dev --filter=web", diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 9b9ae44..86a4697 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -15,6 +15,7 @@ import type * as businessContext from "../businessContext.js"; import type * as channels from "../channels.js"; import type * as documents from "../documents.js"; import type * as lib_auth from "../lib/auth.js"; +import type * as lib_helpers from "../lib/helpers.js"; import type * as messages from "../messages.js"; import type * as notifications from "../notifications.js"; import type * as routines from "../routines.js"; @@ -37,6 +38,7 @@ declare const fullApi: ApiFromModules<{ channels: typeof channels; documents: typeof documents; "lib/auth": typeof lib_auth; + "lib/helpers": typeof lib_helpers; messages: typeof messages; notifications: typeof notifications; routines: typeof routines; diff --git a/packages/backend/convex/lib/auth.ts b/packages/backend/convex/lib/auth.ts index a2532dc..c4076f6 100644 --- a/packages/backend/convex/lib/auth.ts +++ b/packages/backend/convex/lib/auth.ts @@ -1,5 +1,6 @@ -import type { Doc, Id } from "../_generated/dataModel"; +import type { Id } from "../_generated/dataModel"; import type { MutationCtx, QueryCtx } from "../_generated/server"; +import type { User, Account } from "../types"; type ReadCtx = { db: QueryCtx["db"]; auth: QueryCtx["auth"] }; type WriteCtx = { db: MutationCtx["db"] }; @@ -135,8 +136,8 @@ export async function resolveTenantId( */ export async function ensureAccountForUser( ctx: ReadCtx & WriteCtx, - user: Doc<"users">, -): Promise> { + user: User, +): Promise { const membership = await ctx.db .query("accountMembers") .withIndex("by_user", (q) => q.eq("userId", user._id)) diff --git a/packages/backend/convex/lib/helpers.ts b/packages/backend/convex/lib/helpers.ts index 33a29aa..0b4a4ac 100644 --- a/packages/backend/convex/lib/helpers.ts +++ b/packages/backend/convex/lib/helpers.ts @@ -1,11 +1,12 @@ import type { QueryCtx } from "../_generated/server"; -import type { Doc, Id } from "../_generated/dataModel"; +import type { Id } from "../_generated/dataModel"; +import type { Agent } from "../types"; export async function getAgentBySessionKey( ctx: { db: QueryCtx["db"] }, tenantId: Id<"tenants">, sessionKey: string, -): Promise | null> { +): Promise { return await ctx.db .query("agents") .withIndex("by_tenant_sessionKey", (q) => diff --git a/packages/backend/convex/types.ts b/packages/backend/convex/types.ts index bf5ef13..ab28d1a 100644 --- a/packages/backend/convex/types.ts +++ b/packages/backend/convex/types.ts @@ -8,6 +8,23 @@ import type { Doc, Id } from "./_generated/dataModel"; +// ============================================================================= +// User & Account Types +// ============================================================================= + +/** User document */ +export type User = Doc<"users">; + +/** Account document */ +export type Account = Doc<"accounts">; + +// ============================================================================= +// Tenant Types +// ============================================================================= + +/** Tenant document */ +export type Tenant = Doc<"tenants">; + // ============================================================================= // Agent Types // ============================================================================= diff --git a/packages/shared/src/squadhub/client.ts b/packages/shared/src/squadhub/client.ts index 0d5b156..9416f25 100644 --- a/packages/shared/src/squadhub/client.ts +++ b/packages/shared/src/squadhub/client.ts @@ -6,6 +6,7 @@ import type { SessionsListResult, GatewayHealthResult, TelegramProbeResult, + PairingRequest, } from "./types"; export type SquadhubConnection = { @@ -57,6 +58,23 @@ async function invokeTool( } } +/** + * Parse the text payload from a tool invoke result. + * Tool results wrap their data as JSON inside `content[0].text`. + */ +export function parseToolText>( + result: ToolResult, +): T | null { + if (!result.ok) return null; + const text = result.result.content[0]?.text; + if (!text) return null; + try { + return JSON.parse(text) as T; + } catch { + return null; + } +} + export async function checkHealth( connection: SquadhubConnection, ): Promise> { @@ -199,6 +217,31 @@ export async function sessionsSend( }); } +// Pairing (via clawe_pairing plugin tool) +export async function listPairingRequests( + connection: SquadhubConnection, + channel: string, +): Promise> { + return invokeTool(connection, "clawe_pairing", undefined, { + action: "list", + channel, + }); +} + +export async function approvePairingCode( + connection: SquadhubConnection, + channel: string, + code: string, +): Promise< + ToolResult<{ ok: boolean; id?: string; approved?: boolean; error?: string }> +> { + return invokeTool(connection, "clawe_pairing", undefined, { + action: "approve", + channel, + code, + }); +} + // Cron types (matching squadhub src/cron/types.ts) export type CronSchedule = | { kind: "at"; at: string } diff --git a/packages/shared/src/squadhub/index.ts b/packages/shared/src/squadhub/index.ts index 10d4321..6832bbc 100644 --- a/packages/shared/src/squadhub/index.ts +++ b/packages/shared/src/squadhub/index.ts @@ -11,6 +11,9 @@ export { sessionsSend, cronList, cronAdd, + listPairingRequests, + approvePairingCode, + parseToolText, } from "./client"; export type { SquadhubConnection, @@ -31,17 +34,10 @@ export { GatewayClient, createGatewayClient } from "./gateway-client"; export type { GatewayClientOptions } from "./gateway-client"; export { getSharedClient } from "./shared-client"; -// Pairing -export { - listChannelPairingRequests, - approveChannelPairingCode, -} from "./pairing"; - // Types export type { AgentToolResult, ToolResult, - DirectResult, ConfigGetResult, ConfigPatchResult, Session, @@ -50,8 +46,6 @@ export type { GatewayHealthResult, TelegramProbeResult, PairingRequest, - PairingListResult, - PairingApproveResult, } from "./types"; // Gateway Types diff --git a/packages/shared/src/squadhub/pairing.ts b/packages/shared/src/squadhub/pairing.ts deleted file mode 100644 index 7591345..0000000 --- a/packages/shared/src/squadhub/pairing.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { promises as fs } from "fs"; -import path from "path"; -import os from "os"; -import type { - PairingRequest, - PairingListResult, - PairingApproveResult, - DirectResult, -} from "./types"; -import { getConfig, patchConfig, type SquadhubConnection } from "./client"; - -const SQUADHUB_STATE_DIR = - process.env.SQUADHUB_STATE_DIR || path.join(os.homedir(), ".squadhub"); -const CREDENTIALS_DIR = path.join(SQUADHUB_STATE_DIR, "credentials"); - -type PairingStore = { - version: 1; - requests: PairingRequest[]; -}; - -const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000; // 1 hour - -function resolvePairingPath(channel: string): string { - // Sanitize channel name for filesystem - const safe = channel - .trim() - .toLowerCase() - .replace(/[\\/:*?"<>|]/g, "_"); - return path.join(CREDENTIALS_DIR, `${safe}-pairing.json`); -} - -async function readJsonFile(filePath: string, fallback: T): Promise { - try { - const raw = await fs.readFile(filePath, "utf-8"); - return JSON.parse(raw) as T; - } catch { - return fallback; - } -} - -async function writeJsonFile(filePath: string, value: unknown): Promise { - const dir = path.dirname(filePath); - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - await fs.writeFile(filePath, JSON.stringify(value, null, 2), "utf-8"); - await fs.chmod(filePath, 0o600); -} - -function isExpired(entry: PairingRequest, nowMs: number): boolean { - const createdAt = Date.parse(entry.createdAt); - if (!Number.isFinite(createdAt)) return true; - return nowMs - createdAt > PAIRING_PENDING_TTL_MS; -} - -function pruneExpiredRequests(reqs: PairingRequest[], nowMs: number) { - return reqs.filter((req) => !isExpired(req, nowMs)); -} - -export async function listChannelPairingRequests( - channel: string, -): Promise> { - try { - const filePath = resolvePairingPath(channel); - const store = await readJsonFile(filePath, { - version: 1, - requests: [], - }); - - const nowMs = Date.now(); - const requests = pruneExpiredRequests(store.requests || [], nowMs); - - return { ok: true, result: { requests } }; - } catch (error) { - return { - ok: false, - error: { - type: "read_error", - message: - error instanceof Error - ? error.message - : "Failed to read pairing requests", - }, - }; - } -} - -export async function approveChannelPairingCode( - connection: SquadhubConnection, - channel: string, - code: string, -): Promise> { - try { - if (!code) { - return { - ok: false, - error: { type: "invalid_code", message: "Pairing code is required" }, - }; - } - - const normalizedCode = code.trim().toUpperCase(); - const pairingPath = resolvePairingPath(channel); - - // Read current pairing requests (file-based - squadhub writes these) - const store = await readJsonFile(pairingPath, { - version: 1, - requests: [], - }); - - const nowMs = Date.now(); - const requests = pruneExpiredRequests(store.requests || [], nowMs); - - // Find the request with matching code - const entry = requests.find((r) => r.code.toUpperCase() === normalizedCode); - - if (!entry) { - return { - ok: false, - error: { - type: "not_found", - message: "Invalid or expired pairing code", - }, - }; - } - - // Get current config to read existing allowFrom list - const configResult = await getConfig(connection); - if (!configResult.ok) { - return { - ok: false, - error: { - type: "config_error", - message: "Failed to read current config", - }, - }; - } - - // Extract existing allowFrom list - const config = configResult.result.details.config as { - channels?: { - [key: string]: { - allowFrom?: string[]; - }; - }; - }; - const existingAllowFrom = config?.channels?.[channel]?.allowFrom ?? []; - - // Add user ID to allowFrom if not already present - if (!existingAllowFrom.includes(entry.id)) { - const patchResult = await patchConfig( - connection, - { - channels: { - [channel]: { - allowFrom: [...existingAllowFrom, entry.id], - }, - }, - }, - configResult.result.details.hash, - ); - - if (!patchResult.ok) { - return { - ok: false, - error: { - type: "config_error", - message: "Failed to update allowFrom config", - }, - }; - } - } - - // Remove from pending requests file - const remainingRequests = requests.filter((r) => r.id !== entry.id); - await writeJsonFile(pairingPath, { - version: 1, - requests: remainingRequests, - }); - - return { ok: true, result: { id: entry.id, approved: true } }; - } catch (error) { - return { - ok: false, - error: { - type: "write_error", - message: - error instanceof Error - ? error.message - : "Failed to approve pairing code", - }, - }; - } -} diff --git a/packages/shared/src/squadhub/types.ts b/packages/shared/src/squadhub/types.ts index 3ed726d..0f231bd 100644 --- a/packages/shared/src/squadhub/types.ts +++ b/packages/shared/src/squadhub/types.ts @@ -14,11 +14,6 @@ export type ToolResult = | { ok: true; result: AgentToolResult } | { ok: false; error: { type: string; message: string } }; -// DirectResult for operations that don't go through squadhub tools -export type DirectResult = - | { ok: true; result: T } - | { ok: false; error: { type: string; message: string } }; - export type ConfigGetResult = { config: Record; hash: string; @@ -69,12 +64,3 @@ export type PairingRequest = { lastSeenAt: string; meta?: Record; }; - -export type PairingListResult = { - requests: PairingRequest[]; -}; - -export type PairingApproveResult = { - id: string; - approved: boolean; -}; diff --git a/packages/squadhub-extensions/openclaw.plugin.json b/packages/squadhub-extensions/openclaw.plugin.json new file mode 100644 index 0000000..173bdf7 --- /dev/null +++ b/packages/squadhub-extensions/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "squadhub-extensions", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/packages/squadhub-extensions/package.json b/packages/squadhub-extensions/package.json new file mode 100644 index 0000000..efd8ccd --- /dev/null +++ b/packages/squadhub-extensions/package.json @@ -0,0 +1,28 @@ +{ + "name": "@clawe/squadhub-extensions", + "version": "0.1.0", + "description": "OpenClaw plugin extensions for Clawe squadhub", + "private": true, + "type": "module", + "scripts": { + "build": "tsup", + "check-types": "tsc --noEmit" + }, + "openclaw": { + "extensions": [ + "./dist/index.js" + ] + }, + "devDependencies": { + "@clawe/typescript-config": "workspace:*", + "@sinclair/typebox": "^0.34.0", + "@types/node": "^22.0.0", + "tsup": "^8.0.0", + "typescript": "^5.7.3", + "vitest": "^4.0.0" + }, + "files": [ + "dist", + "openclaw.plugin.json" + ] +} diff --git a/packages/squadhub-extensions/src/index.ts b/packages/squadhub-extensions/src/index.ts new file mode 100644 index 0000000..833f686 --- /dev/null +++ b/packages/squadhub-extensions/src/index.ts @@ -0,0 +1,6 @@ +import type { OpenClawPluginApi } from "./types"; +import { registerPairingTool } from "./tools/pairing"; + +export default function register(api: OpenClawPluginApi) { + registerPairingTool(api); +} diff --git a/packages/squadhub-extensions/src/tools/pairing.ts b/packages/squadhub-extensions/src/tools/pairing.ts new file mode 100644 index 0000000..bbf60d4 --- /dev/null +++ b/packages/squadhub-extensions/src/tools/pairing.ts @@ -0,0 +1,200 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { Type } from "@sinclair/typebox"; +import type { OpenClawPluginApi, ToolResult } from "../types"; + +/** + * Pairing tool for Clawe. + * + * Exposes channel pairing operations (list pending requests, approve by code) + * as an OpenClaw tool callable via POST /tools/invoke. + * + * File layout (relative to $OPENCLAW_STATE_DIR): + * credentials/-pairing.json — pending pairing requests + * credentials/-allowFrom.json — approved sender IDs + */ + +const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000; // 1 hour + +type PairingRequest = { + id: string; + code: string; + createdAt: string; + lastSeenAt: string; + meta?: Record; +}; + +type PairingStore = { + version: 1; + requests: PairingRequest[]; +}; + +type AllowFromStore = { + version: 1; + allowFrom: string[]; +}; + +function resolveStateDir(): string { + return process.env.OPENCLAW_STATE_DIR || "/data/config"; +} + +function safeChannelKey(channel: string): string { + const raw = channel.trim().toLowerCase(); + if (!raw) throw new Error("invalid pairing channel"); + const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_"); + if (!safe || safe === "_") throw new Error("invalid pairing channel"); + return safe; +} + +function resolvePairingPath(channel: string): string { + return path.join( + resolveStateDir(), + "credentials", + `${safeChannelKey(channel)}-pairing.json`, + ); +} + +function resolveAllowFromPath(channel: string): string { + return path.join( + resolveStateDir(), + "credentials", + `${safeChannelKey(channel)}-allowFrom.json`, + ); +} + +async function readJsonFile(filePath: string, fallback: T): Promise { + try { + const raw = await fs.readFile(filePath, "utf-8"); + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +async function writeJsonFile(filePath: string, value: unknown): Promise { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + await fs.writeFile(filePath, JSON.stringify(value, null, 2), "utf-8"); + await fs.chmod(filePath, 0o600); +} + +function isExpired(entry: PairingRequest, nowMs: number): boolean { + const createdAt = Date.parse(entry.createdAt); + if (!Number.isFinite(createdAt)) return true; + return nowMs - createdAt > PAIRING_PENDING_TTL_MS; +} + +function pruneExpired(reqs: PairingRequest[], nowMs: number): PairingRequest[] { + return reqs.filter((r) => !isExpired(r, nowMs)); +} + +function textResult(data: unknown): ToolResult { + return { + content: [{ type: "text", text: JSON.stringify(data) }], + details: + typeof data === "object" && data !== null + ? (data as Record) + : {}, + }; +} + +function errorResult(message: string): ToolResult { + return { + content: [ + { type: "text", text: JSON.stringify({ ok: false, error: message }) }, + ], + details: { ok: false, error: message }, + }; +} + +async function listAction(channel: string): Promise { + const filePath = resolvePairingPath(channel); + const store = await readJsonFile(filePath, { + version: 1, + requests: [], + }); + const requests = pruneExpired(store.requests || [], Date.now()); + return textResult({ ok: true, requests }); +} + +async function approveAction( + channel: string, + code: string, +): Promise { + if (!code) return errorResult("Pairing code is required"); + + const normalizedCode = code.trim().toUpperCase(); + const pairingPath = resolvePairingPath(channel); + + // Read pending requests + const store = await readJsonFile(pairingPath, { + version: 1, + requests: [], + }); + const requests = pruneExpired(store.requests || [], Date.now()); + + // Find matching request + const entry = requests.find((r) => r.code.toUpperCase() === normalizedCode); + if (!entry) return errorResult("Invalid or expired pairing code"); + + // Add to allow-from store + const allowFromPath = resolveAllowFromPath(channel); + const allowFromStore = await readJsonFile(allowFromPath, { + version: 1, + allowFrom: [], + }); + const existing = allowFromStore.allowFrom || []; + if (!existing.includes(entry.id)) { + await writeJsonFile(allowFromPath, { + version: 1, + allowFrom: [...existing, entry.id], + } satisfies AllowFromStore); + } + + // Remove approved request from pending + const remaining = requests.filter((r) => r.id !== entry.id); + await writeJsonFile(pairingPath, { + version: 1, + requests: remaining, + } satisfies PairingStore); + + return textResult({ ok: true, id: entry.id, approved: true }); +} + +export function registerPairingTool(api: OpenClawPluginApi) { + api.registerTool({ + name: "clawe_pairing", + description: + "Manage channel pairing requests (list pending, approve by code)", + parameters: Type.Object({ + action: Type.Union([Type.Literal("list"), Type.Literal("approve")], { + description: 'Action to perform: "list" or "approve"', + }), + channel: Type.String({ description: 'Channel name (e.g. "telegram")' }), + code: Type.Optional( + Type.String({ description: "Pairing code (required for approve)" }), + ), + }), + async execute( + _toolCallId: string, + params: Record, + ): Promise { + const action = params.action as string; + const channel = params.channel as string; + + if (!channel) return errorResult("Channel is required"); + + if (action === "list") { + return listAction(channel); + } + + if (action === "approve") { + const code = params.code as string | undefined; + if (!code) return errorResult("Code is required for approve action"); + return approveAction(channel, code); + } + + return errorResult(`Unknown action: ${action}`); + }, + }); +} diff --git a/packages/squadhub-extensions/src/types.ts b/packages/squadhub-extensions/src/types.ts new file mode 100644 index 0000000..4d3340a --- /dev/null +++ b/packages/squadhub-extensions/src/types.ts @@ -0,0 +1,32 @@ +import type { TObject } from "@sinclair/typebox"; + +/** + * Minimal type definitions for the OpenClaw plugin API. + * Only the surface we actually use — avoids importing from openclaw. + */ + +export type ToolResult = { + content: Array<{ type: "text"; text: string }>; + details?: Record; +}; + +export type AgentTool = { + name: string; + description: string; + parameters: TObject; + execute: ( + toolCallId: string, + params: Record, + ) => Promise; +}; + +export type OpenClawPluginApi = { + id: string; + name: string; + config: Record; + pluginConfig?: Record; + registerTool: ( + tool: AgentTool, + opts?: { name?: string; optional?: boolean }, + ) => void; +}; diff --git a/packages/squadhub-extensions/tsconfig.json b/packages/squadhub-extensions/tsconfig.json new file mode 100644 index 0000000..5bb6f44 --- /dev/null +++ b/packages/squadhub-extensions/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@clawe/typescript-config/base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "rootDir": "./src", + "outDir": "./dist", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "**/*.spec.ts"] +} diff --git a/packages/squadhub-extensions/tsup.config.ts b/packages/squadhub-extensions/tsup.config.ts new file mode 100644 index 0000000..d2754f4 --- /dev/null +++ b/packages/squadhub-extensions/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { index: "src/index.ts" }, + format: ["esm"], + target: "node22", + platform: "node", + outDir: "dist", + clean: true, + bundle: true, + noExternal: [/.*/], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1049082..5e9e7fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,6 +344,27 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.15.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)(tsx@4.21.0) + packages/squadhub-extensions: + devDependencies: + '@clawe/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@sinclair/typebox': + specifier: ^0.34.0 + version: 0.34.48 + '@types/node': + specifier: ^22.0.0 + version: 22.15.3 + tsup: + specifier: ^8.0.0 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2) + typescript: + specifier: ^5.7.3 + version: 5.9.2 + vitest: + specifier: ^4.0.0 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.15.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)(tsx@4.21.0) + packages/typescript-config: {} packages/ui: @@ -2192,6 +2213,9 @@ packages: cpu: [x64] os: [win32] + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + '@smithy/abort-controller@4.2.8': resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} engines: {node: '>=18.0.0'} @@ -4286,6 +4310,7 @@ packages: nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -7376,6 +7401,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@sinclair/typebox@0.34.48': {} + '@smithy/abort-controller@4.2.8': dependencies: '@smithy/types': 4.12.0