diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 00000000..27bc8afe --- /dev/null +++ b/admin/README.md @@ -0,0 +1,161 @@ +# OpenPanel Admin CLI + +An interactive CLI tool to help manage and lookup OpenPanel organizations, projects, and clients. + +## Setup + +First, install dependencies: + +```bash +cd admin +pnpm install +``` + +## Usage + +Run the CLI from the admin directory: + +```bash +pnpm start +``` + +Or use the convenient shell script from anywhere: + +```bash +./admin/cli +``` + +## Features + +The CLI provides 4 focused lookup commands for easier navigation: + +### šŸ¢ Lookup by Organization + +Search and view detailed information about an organization. + +- Fuzzy search across all organizations by name or ID +- Shows full organization details with all projects, clients, and members + +### šŸ“Š Lookup by Project + +Search for a specific project and view its organization context. + +- Fuzzy search across all projects by name or ID +- Highlights the selected project in the organization view +- Displays: `org → project` + +### šŸ”‘ Lookup by Client ID + +Search for a specific client and view its full context. + +- Fuzzy search across all clients by name or ID +- Highlights the selected client and its project +- Displays: `org → project → client` + +### šŸ“§ Lookup by Email + +Search for a member by email address. + +- Fuzzy search across all member emails +- Shows which organization(s) the member belongs to +- Displays member role (šŸ‘‘ owner, ⭐ admin, šŸ‘¤ member) + +**All lookups display:** +- Organization information (ID, name, subscription status, timezone, event usage) +- Organization members and their roles +- All projects with their settings (domain, CORS, event counts) +- All clients for each project (ID, name, type, credentials) +- Deletion warnings if scheduled + +--- + +### šŸ—‘ļø Clear Cache + +Clear cache for an organization and all its projects. + +- Fuzzy search to find the organization +- Shows organization details and all projects +- Confirms before clearing cache +- Provides organization ID and all project IDs for cache clearing logic + +**Use when:** +- You need to invalidate cache after data changes +- Troubleshooting caching issues +- After manual database updates + +**Note:** The cache clearing logic needs to be implemented. The command provides the organization and project data structure for you to add your cache clearing calls. + +--- + +### šŸ”“ Delete Organization + +Permanently delete an organization and all its data. + +- Fuzzy search to find the organization +- Shows detailed preview of what will be deleted (projects, members, events) +- Requires **3 confirmations**: + 1. Initial confirmation + 2. Type organization name to confirm + 3. Final warning confirmation +- Deletes from both PostgreSQL and ClickHouse + +**Use when:** +- Removing organizations that are no longer needed +- Cleaning up test/demo organizations +- Handling deletion requests + +**āš ļø WARNING:** This action is PERMANENT and cannot be undone! + +**What gets deleted:** +- Organization record +- All projects and their settings +- All clients and credentials +- All events and analytics data (from ClickHouse) +- All member associations +- All dashboards and reports + +--- + +### šŸ”“ Delete User + +Permanently delete a user account and remove them from all organizations. + +- Fuzzy search by email or name +- Shows which organizations the user belongs to +- Shows if user created any organizations (won't delete those orgs) +- Requires **3 confirmations**: + 1. Initial confirmation + 2. Type user email to confirm + 3. Final warning confirmation + +**Use when:** +- Removing user accounts at user request +- Cleaning up inactive accounts +- Handling GDPR/data deletion requests + +**āš ļø WARNING:** This action is PERMANENT and cannot be undone! + +**What gets deleted:** +- User account +- All auth sessions and tokens +- All memberships (removed from all orgs) +- All personal data + +**What is NOT deleted:** +- Organizations created by the user (only the creator reference is removed) + +## Environment Variables + +Make sure you have the proper environment variables set up: +- `DATABASE_URL` - PostgreSQL connection string +- `DATABASE_URL_REPLICA` (optional) - Read replica connection string + +## Development + +The CLI uses: +- **jiti** - Direct TypeScript execution without build step +- **inquirer** - Interactive prompts +- **inquirer-autocomplete-prompt** - Fuzzy search functionality +- **chalk** - Colored terminal output +- **@openpanel/db** - Direct Prisma database access + diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 00000000..88408c1c --- /dev/null +++ b/admin/package.json @@ -0,0 +1,25 @@ +{ + "name": "@openpanel/admin", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "start": "dotenv -e .env -c -- jiti src/cli.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@openpanel/common": "workspace:*", + "@openpanel/db": "workspace:*", + "chalk": "^5.3.0", + "fuzzy": "^0.1.3", + "inquirer": "^9.3.5", + "inquirer-autocomplete-prompt": "^3.0.1", + "jiti": "^2.4.2" + }, + "devDependencies": { + "@types/inquirer": "^9.0.7", + "@types/inquirer-autocomplete-prompt": "^3.0.3", + "@types/node": "catalog:", + "typescript": "catalog:" + } +} diff --git a/admin/src/cli.ts b/admin/src/cli.ts new file mode 100644 index 00000000..ad1b3b9f --- /dev/null +++ b/admin/src/cli.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import inquirer from 'inquirer'; +import { clearCache } from './commands/clear-cache'; +import { deleteOrganization } from './commands/delete-organization'; +import { deleteUser } from './commands/delete-user'; +import { lookupByClient } from './commands/lookup-client'; +import { lookupByEmail } from './commands/lookup-email'; +import { lookupByOrg } from './commands/lookup-org'; +import { lookupByProject } from './commands/lookup-project'; + +const secureEnv = (url: string) => { + const parsed = new URL(url); + if (parsed.username && parsed.password) { + return `${parsed.protocol}//${parsed.username}:${parsed.password.slice(0, 1)}...${parsed.password.slice(-1)}@${parsed.hostname}:${parsed.port}`; + } + + return url; +}; + +async function main() { + console.log('\nšŸ”§ OpenPanel Admin CLI\n'); + + const DATABASE_URL = process.env.DATABASE_URL; + const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL; + const REDIS_URL = process.env.REDIS_URL; + + if (!DATABASE_URL || !CLICKHOUSE_URL || !REDIS_URL) { + console.error('Environment variables are not set'); + process.exit(1); + } + + // Log environment variables for debugging + console.log('Environment:', { + NODE_ENV: process.env.NODE_ENV, + SELF_HOSTED: process.env.SELF_HOSTED ? 'Yes' : 'No', + DATABASE_URL: secureEnv(DATABASE_URL), + CLICKHOUSE_URL: secureEnv(CLICKHOUSE_URL), + REDIS_URL: secureEnv(REDIS_URL), + }); + console.log(''); + + const { command } = await inquirer.prompt([ + { + type: 'list', + name: 'command', + message: 'What would you like to do?', + pageSize: 20, + choices: [ + { + name: 'šŸ¢ Lookup by Organization', + value: 'lookup-org', + }, + { + name: 'šŸ“Š Lookup by Project', + value: 'lookup-project', + }, + { + name: 'šŸ”‘ Lookup by Client ID', + value: 'lookup-client', + }, + { + name: 'šŸ“§ Lookup by Email', + value: 'lookup-email', + }, + { + name: 'šŸ—‘ļø Clear Cache', + value: 'clear-cache', + }, + { name: '─────────────────────', value: 'separator', disabled: true }, + { + name: 'šŸ”“ Delete Organization', + value: 'delete-org', + }, + { + name: 'šŸ”“ Delete User', + value: 'delete-user', + }, + { name: '─────────────────────', value: 'separator', disabled: true }, + { name: 'āŒ Exit', value: 'exit' }, + ], + }, + ]); + + switch (command) { + case 'lookup-org': + await lookupByOrg(); + break; + case 'lookup-project': + await lookupByProject(); + break; + case 'lookup-client': + await lookupByClient(); + break; + case 'lookup-email': + await lookupByEmail(); + break; + case 'clear-cache': + await clearCache(); + break; + case 'delete-org': + await deleteOrganization(); + break; + case 'delete-user': + await deleteUser(); + break; + case 'exit': + console.log('Goodbye! šŸ‘‹'); + process.exit(0); + } + + // Loop back to main menu + await main(); +} + +main().catch((error) => { + console.error('Error:', error); + process.exit(1); +}); diff --git a/admin/src/commands/clear-cache.ts b/admin/src/commands/clear-cache.ts new file mode 100644 index 00000000..5eb2b8cf --- /dev/null +++ b/admin/src/commands/clear-cache.ts @@ -0,0 +1,162 @@ +import { + db, + getOrganizationAccess, + getOrganizationByProjectIdCached, + getProjectAccess, + getProjectByIdCached, +} from '@openpanel/db'; +import chalk from 'chalk'; +import fuzzy from 'fuzzy'; +import inquirer from 'inquirer'; +import autocomplete from 'inquirer-autocomplete-prompt'; + +// Register autocomplete prompt +inquirer.registerPrompt('autocomplete', autocomplete); + +interface OrgSearchItem { + id: string; + name: string; + displayText: string; +} + +export async function clearCache() { + console.log(chalk.blue('\nšŸ—‘ļø Clear Cache\n')); + console.log('Loading organizations...\n'); + + const organizations = await db.organization.findMany({ + orderBy: { + name: 'asc', + }, + }); + + if (organizations.length === 0) { + console.log(chalk.red('No organizations found.')); + return; + } + + const searchItems: OrgSearchItem[] = organizations.map((org) => ({ + id: org.id, + name: org.name, + displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`, + })); + + const searchFunction = async (_answers: unknown, input = '') => { + const fuzzyResult = fuzzy.filter(input, searchItems, { + extract: (item: OrgSearchItem) => `${item.name} ${item.id}`, + }); + + return fuzzyResult.map((result: fuzzy.FilterResult) => ({ + name: result.original.displayText, + value: result.original, + })); + }; + + const { selectedOrg } = (await inquirer.prompt([ + { + type: 'autocomplete', + name: 'selectedOrg', + message: 'Search for an organization:', + source: searchFunction, + pageSize: 15, + }, + ])) as { selectedOrg: OrgSearchItem }; + + // Fetch organization with all projects + const organization = await db.organization.findUnique({ + where: { + id: selectedOrg.id, + }, + include: { + projects: { + orderBy: { + name: 'asc', + }, + }, + members: { + include: { + user: true, + }, + }, + }, + }); + + if (!organization) { + console.log(chalk.red('Organization not found.')); + return; + } + + console.log(chalk.yellow('\nšŸ“‹ Organization Details:\n')); + console.log(` ${chalk.gray('ID:')} ${organization.id}`); + console.log(` ${chalk.gray('Name:')} ${organization.name}`); + console.log(` ${chalk.gray('Projects:')} ${organization.projects.length}`); + + if (organization.projects.length > 0) { + console.log(chalk.yellow('\nšŸ“Š Projects:\n')); + for (const project of organization.projects) { + console.log( + ` - ${project.name} ${chalk.gray(`(${project.id})`)} - ${chalk.cyan(`${project.eventsCount.toLocaleString()} events`)}`, + ); + } + } + + // Confirm before clearing cache + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: `Clear cache for organization "${organization.name}" and all ${organization.projects.length} projects?`, + default: false, + }, + ]); + + if (!confirm) { + console.log(chalk.yellow('\nCache clear cancelled.')); + return; + } + + console.log(chalk.blue('\nšŸ”„ Clearing cache...\n')); + + for (const project of organization.projects) { + // Clear project access cache for each member + for (const member of organization.members) { + if (!member.user?.id) continue; + console.log( + `Clearing cache for project: ${project.name} and member: ${member.user?.email}`, + ); + await getProjectAccess.clear({ + userId: member.user?.id, + projectId: project.id, + }); + await getOrganizationAccess.clear({ + userId: member.user?.id, + organizationId: organization.id, + }); + } + + console.log(`Clearing cache for project: ${project.name}`); + await getOrganizationByProjectIdCached.clear(project.id); + await getProjectByIdCached.clear(project.id); + } + + console.log(chalk.gray(`Organization ID: ${organization.id}`)); + console.log( + chalk.gray( + `Project IDs: ${organization.projects.map((p) => p.id).join(', ')}`, + ), + ); + + // Example of what you might do: + /* + for (const project of organization.projects) { + console.log(`Clearing cache for project: ${project.name}...`); + // await clearProjectCache(project.id); + // await redis.del(`project:${project.id}:*`); + } + + // Clear organization-level cache + // await clearOrganizationCache(organization.id); + // await redis.del(`organization:${organization.id}:*`); + + console.log(chalk.green('\nāœ… Cache cleared successfully!')); + */ +} diff --git a/admin/src/commands/delete-organization.ts b/admin/src/commands/delete-organization.ts new file mode 100644 index 00000000..22cec740 --- /dev/null +++ b/admin/src/commands/delete-organization.ts @@ -0,0 +1,215 @@ +import { + db, + deleteFromClickhouse, + deleteOrganization as deleteOrg, +} from '@openpanel/db'; +import chalk from 'chalk'; +import fuzzy from 'fuzzy'; +import inquirer from 'inquirer'; +import autocomplete from 'inquirer-autocomplete-prompt'; + +// Register autocomplete prompt +inquirer.registerPrompt('autocomplete', autocomplete); + +interface OrgSearchItem { + id: string; + name: string; + displayText: string; +} + +export async function deleteOrganization() { + console.log(chalk.red('\nšŸ—‘ļø Delete Organization\n')); + console.log( + chalk.yellow( + 'āš ļø WARNING: This will permanently delete the organization and all its data!\n', + ), + ); + console.log('Loading organizations...\n'); + + const organizations = await db.organization.findMany({ + include: { + projects: true, + members: { + include: { + user: true, + }, + }, + }, + orderBy: { + name: 'asc', + }, + }); + + if (organizations.length === 0) { + console.log(chalk.red('No organizations found.')); + return; + } + + const searchItems: OrgSearchItem[] = organizations.map((org) => ({ + id: org.id, + name: org.name, + displayText: `${org.name} ${chalk.gray(`(${org.id})`)} ${chalk.cyan(`- ${org.projects.length} projects, ${org.members.length} members`)}`, + })); + + const searchFunction = async (_answers: unknown, input = '') => { + const fuzzyResult = fuzzy.filter(input, searchItems, { + extract: (item: OrgSearchItem) => `${item.name} ${item.id}`, + }); + + return fuzzyResult.map((result: fuzzy.FilterResult) => ({ + name: result.original.displayText, + value: result.original, + })); + }; + + const { selectedOrg } = (await inquirer.prompt([ + { + type: 'autocomplete', + name: 'selectedOrg', + message: 'Search for an organization to delete:', + source: searchFunction, + pageSize: 15, + }, + ])) as { selectedOrg: OrgSearchItem }; + + // Fetch full organization details + const organization = await db.organization.findUnique({ + where: { + id: selectedOrg.id, + }, + include: { + projects: { + include: { + clients: true, + }, + }, + members: { + include: { + user: true, + }, + }, + }, + }); + + if (!organization) { + console.log(chalk.red('Organization not found.')); + return; + } + + // Display what will be deleted + console.log(chalk.red('\nāš ļø YOU ARE ABOUT TO DELETE:\n')); + console.log(` ${chalk.bold('Organization:')} ${organization.name}`); + console.log(` ${chalk.gray('ID:')} ${organization.id}`); + console.log(` ${chalk.gray('Projects:')} ${organization.projects.length}`); + console.log(` ${chalk.gray('Members:')} ${organization.members.length}`); + + if (organization.projects.length > 0) { + console.log(chalk.red('\n Projects that will be deleted:')); + for (const project of organization.projects) { + console.log( + ` - ${project.name} ${chalk.gray(`(${project.eventsCount.toLocaleString()} events, ${project.clients.length} clients)`)}`, + ); + } + } + + if (organization.members.length > 0) { + console.log(chalk.red('\n Members who will lose access:')); + for (const member of organization.members) { + const email = member.user?.email || member.email || 'Unknown'; + console.log(` - ${email} ${chalk.gray(`(${member.role})`)}`); + } + } + + console.log( + chalk.red( + '\nāš ļø This will delete ALL projects, clients, events, and data associated with this organization!', + ), + ); + + // First confirmation + const { confirmFirst } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmFirst', + message: chalk.red( + `Are you ABSOLUTELY SURE you want to delete "${organization.name}"?`, + ), + default: false, + }, + ]); + + if (!confirmFirst) { + console.log(chalk.yellow('\nDeletion cancelled.')); + return; + } + + // Second confirmation - type organization name + const { confirmName } = await inquirer.prompt([ + { + type: 'input', + name: 'confirmName', + message: `Type the organization name "${organization.name}" to confirm deletion:`, + }, + ]); + + if (confirmName !== organization.name) { + console.log( + chalk.red('\nāŒ Organization name does not match. Deletion cancelled.'), + ); + return; + } + + // Final confirmation + const { confirmFinal } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmFinal', + message: chalk.red( + 'FINAL WARNING: This action CANNOT be undone. Delete now?', + ), + default: false, + }, + ]); + + if (!confirmFinal) { + console.log(chalk.yellow('\nDeletion cancelled.')); + return; + } + + console.log(chalk.red('\nšŸ—‘ļø Deleting organization...\n')); + + try { + const projectIds = organization.projects.map((p) => p.id); + + // Step 1: Delete from ClickHouse (events, profiles, etc.) + if (projectIds.length > 0) { + console.log( + chalk.yellow( + `Deleting data from ClickHouse for ${projectIds.length} projects...`, + ), + ); + await deleteFromClickhouse(projectIds); + console.log(chalk.green('āœ“ ClickHouse data deletion initiated')); + } + + // Step 2: Delete the organization from PostgreSQL (cascade will handle related records) + console.log(chalk.yellow('Deleting organization from database...')); + await deleteOrg(organization.id); + console.log(chalk.green('āœ“ Organization deleted from database')); + + console.log(chalk.green('\nāœ… Organization deleted successfully!')); + console.log( + chalk.gray( + `Deleted: ${organization.name} with ${organization.projects.length} projects and ${organization.members.length} members`, + ), + ); + console.log( + chalk.gray( + '\nNote: ClickHouse deletions are processed asynchronously and may take a few moments to complete.', + ), + ); + } catch (error) { + console.error(chalk.red('\nāŒ Error deleting organization:'), error); + throw error; + } +} diff --git a/admin/src/commands/delete-user.ts b/admin/src/commands/delete-user.ts new file mode 100644 index 00000000..dfe04d96 --- /dev/null +++ b/admin/src/commands/delete-user.ts @@ -0,0 +1,220 @@ +import { db } from '@openpanel/db'; +import chalk from 'chalk'; +import fuzzy from 'fuzzy'; +import inquirer from 'inquirer'; +import autocomplete from 'inquirer-autocomplete-prompt'; + +// Register autocomplete prompt +inquirer.registerPrompt('autocomplete', autocomplete); + +interface UserSearchItem { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + displayText: string; +} + +export async function deleteUser() { + console.log(chalk.red('\nšŸ—‘ļø Delete User\n')); + console.log( + chalk.yellow( + 'āš ļø WARNING: This will permanently delete the user and remove them from all organizations!\n', + ), + ); + console.log('Loading users...\n'); + + const users = await db.user.findMany({ + include: { + membership: { + include: { + organization: true, + }, + }, + accounts: true, + }, + orderBy: { + email: 'asc', + }, + }); + + if (users.length === 0) { + console.log(chalk.red('No users found.')); + return; + } + + const searchItems: UserSearchItem[] = users.map((user) => { + const fullName = + user.firstName || user.lastName + ? `${user.firstName || ''} ${user.lastName || ''}`.trim() + : ''; + const orgCount = user.membership.length; + + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + displayText: `${user.email} ${fullName ? chalk.gray(`(${fullName})`) : ''} ${chalk.cyan(`- ${orgCount} orgs`)}`, + }; + }); + + const searchFunction = async (_answers: unknown, input = '') => { + const fuzzyResult = fuzzy.filter(input, searchItems, { + extract: (item: UserSearchItem) => + `${item.email} ${item.firstName || ''} ${item.lastName || ''}`, + }); + + return fuzzyResult.map((result: fuzzy.FilterResult) => ({ + name: result.original.displayText, + value: result.original, + })); + }; + + const { selectedUser } = (await inquirer.prompt([ + { + type: 'autocomplete', + name: 'selectedUser', + message: 'Search for a user to delete:', + source: searchFunction, + pageSize: 15, + }, + ])) as { selectedUser: UserSearchItem }; + + // Fetch full user details + const user = await db.user.findUnique({ + where: { + id: selectedUser.id, + }, + include: { + membership: { + include: { + organization: true, + }, + }, + accounts: true, + createdOrganizations: true, + }, + }); + + if (!user) { + console.log(chalk.red('User not found.')); + return; + } + + // Display what will be deleted + console.log(chalk.red('\nāš ļø YOU ARE ABOUT TO DELETE:\n')); + console.log(` ${chalk.bold('User:')} ${user.email}`); + if (user.firstName || user.lastName) { + console.log( + ` ${chalk.gray('Name:')} ${user.firstName || ''} ${user.lastName || ''}`, + ); + } + console.log(` ${chalk.gray('ID:')} ${user.id}`); + console.log( + ` ${chalk.gray('Member of:')} ${user.membership.length} organizations`, + ); + console.log(` ${chalk.gray('Auth accounts:')} ${user.accounts.length}`); + + if (user.createdOrganizations.length > 0) { + console.log( + chalk.red( + `\n āš ļø This user CREATED ${user.createdOrganizations.length} organization(s):`, + ), + ); + for (const org of user.createdOrganizations) { + console.log(` - ${org.name} ${chalk.gray(`(${org.id})`)}`); + } + console.log( + chalk.yellow( + ' Note: These organizations will NOT be deleted, only the user reference.', + ), + ); + } + + if (user.membership.length > 0) { + console.log( + chalk.red('\n Organizations where user will be removed from:'), + ); + for (const member of user.membership) { + console.log( + ` - ${member.organization.name} ${chalk.gray(`(${member.role})`)}`, + ); + } + } + + console.log( + chalk.red( + '\nāš ļø This will delete the user account, all sessions, and remove them from all organizations!', + ), + ); + + // First confirmation + const { confirmFirst } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmFirst', + message: chalk.red( + `Are you ABSOLUTELY SURE you want to delete user "${user.email}"?`, + ), + default: false, + }, + ]); + + if (!confirmFirst) { + console.log(chalk.yellow('\nDeletion cancelled.')); + return; + } + + // Second confirmation - type email + const { confirmEmail } = await inquirer.prompt([ + { + type: 'input', + name: 'confirmEmail', + message: `Type the user email "${user.email}" to confirm deletion:`, + }, + ]); + + if (confirmEmail !== user.email) { + console.log(chalk.red('\nāŒ Email does not match. Deletion cancelled.')); + return; + } + + // Final confirmation + const { confirmFinal } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmFinal', + message: chalk.red( + 'FINAL WARNING: This action CANNOT be undone. Delete now?', + ), + default: false, + }, + ]); + + if (!confirmFinal) { + console.log(chalk.yellow('\nDeletion cancelled.')); + return; + } + + console.log(chalk.red('\nšŸ—‘ļø Deleting user...\n')); + + try { + // Delete the user (cascade will handle related records like sessions, accounts, memberships) + await db.user.delete({ + where: { + id: user.id, + }, + }); + + console.log(chalk.green('\nāœ… User deleted successfully!')); + console.log( + chalk.gray( + `Deleted: ${user.email} (removed from ${user.membership.length} organizations)`, + ), + ); + } catch (error) { + console.error(chalk.red('\nāŒ Error deleting user:'), error); + throw error; + } +} diff --git a/admin/src/commands/lookup-client.ts b/admin/src/commands/lookup-client.ts new file mode 100644 index 00000000..634dfd6b --- /dev/null +++ b/admin/src/commands/lookup-client.ts @@ -0,0 +1,104 @@ +import { db } from '@openpanel/db'; +import chalk from 'chalk'; +import fuzzy from 'fuzzy'; +import inquirer from 'inquirer'; +import autocomplete from 'inquirer-autocomplete-prompt'; +import { displayOrganizationDetails } from '../utils/display'; + +// Register autocomplete prompt +inquirer.registerPrompt('autocomplete', autocomplete); + +interface ClientSearchItem { + id: string; + name: string; + organizationId: string; + organizationName: string; + projectId: string | null; + projectName: string | null; + displayText: string; +} + +export async function lookupByClient() { + console.log(chalk.blue('\nšŸ”‘ Lookup by Client ID\n')); + console.log('Loading clients...\n'); + + const clients = await db.client.findMany({ + include: { + organization: true, + project: true, + }, + orderBy: { + name: 'asc', + }, + }); + + if (clients.length === 0) { + console.log(chalk.red('No clients found.')); + return; + } + + const searchItems: ClientSearchItem[] = clients.map((client) => ({ + id: client.id, + name: client.name, + organizationId: client.organizationId, + organizationName: client.organization.name, + projectId: client.projectId, + projectName: client.project?.name || null, + displayText: `${client.organization.name} → ${client.project?.name || '[No Project]'} → ${client.name} ${chalk.gray(`(${client.id})`)}`, + })); + + const searchFunction = async (_answers: unknown, input = '') => { + const fuzzyResult = fuzzy.filter(input, searchItems, { + extract: (item: ClientSearchItem) => + `${item.organizationName} ${item.projectName || ''} ${item.name} ${item.id}`, + }); + + return fuzzyResult.map((result: fuzzy.FilterResult) => ({ + name: result.original.displayText, + value: result.original, + })); + }; + + const { selectedClient } = (await inquirer.prompt([ + { + type: 'autocomplete', + name: 'selectedClient', + message: 'Search for a client:', + source: searchFunction, + pageSize: 15, + }, + ])) as { selectedClient: ClientSearchItem }; + + // Fetch full organization details + const organization = await db.organization.findUnique({ + where: { + id: selectedClient.organizationId, + }, + include: { + projects: { + include: { + clients: true, + }, + orderBy: { + name: 'asc', + }, + }, + members: { + include: { + user: true, + }, + }, + }, + }); + + if (!organization) { + console.log(chalk.red('Organization not found.')); + return; + } + + displayOrganizationDetails(organization, { + highlightProjectId: selectedClient.projectId || undefined, + highlightClientId: selectedClient.id, + }); +} + diff --git a/admin/src/commands/lookup-email.ts b/admin/src/commands/lookup-email.ts new file mode 100644 index 00000000..fd9695a7 --- /dev/null +++ b/admin/src/commands/lookup-email.ts @@ -0,0 +1,112 @@ +import { db } from '@openpanel/db'; +import chalk from 'chalk'; +import fuzzy from 'fuzzy'; +import inquirer from 'inquirer'; +import autocomplete from 'inquirer-autocomplete-prompt'; +import { displayOrganizationDetails } from '../utils/display'; + +// Register autocomplete prompt +inquirer.registerPrompt('autocomplete', autocomplete); + +interface EmailSearchItem { + email: string; + organizationId: string; + organizationName: string; + role: string; + userId: string | null; + displayText: string; +} + +export async function lookupByEmail() { + console.log(chalk.blue('\nšŸ“§ Lookup by Email\n')); + console.log('Loading members...\n'); + + const members = await db.member.findMany({ + include: { + organization: true, + user: true, + }, + orderBy: { + email: 'asc', + }, + }); + + if (members.length === 0) { + console.log(chalk.red('No members found.')); + return; + } + + // Group by email (in case same email is in multiple orgs) + const searchItems: EmailSearchItem[] = members.map((member) => { + const email = member.user?.email || member.email || 'Unknown'; + const roleBadge = + member.role === 'owner' ? 'šŸ‘‘' : member.role === 'admin' ? '⭐' : 'šŸ‘¤'; + + return { + email, + organizationId: member.organizationId, + organizationName: member.organization.name, + role: member.role, + userId: member.userId, + displayText: `${email} ${chalk.gray(`→ ${member.organization.name}`)} ${roleBadge}`, + }; + }); + + const searchFunction = async (_answers: unknown, input = '') => { + const fuzzyResult = fuzzy.filter(input, searchItems, { + extract: (item: EmailSearchItem) => + `${item.email} ${item.organizationName}`, + }); + + return fuzzyResult.map((result: fuzzy.FilterResult) => ({ + name: result.original.displayText, + value: result.original, + })); + }; + + const { selectedMember } = (await inquirer.prompt([ + { + type: 'autocomplete', + name: 'selectedMember', + message: 'Search for a member by email:', + source: searchFunction, + pageSize: 15, + }, + ])) as { selectedMember: EmailSearchItem }; + + // Fetch full organization details + const organization = await db.organization.findUnique({ + where: { + id: selectedMember.organizationId, + }, + include: { + projects: { + include: { + clients: true, + }, + orderBy: { + name: 'asc', + }, + }, + members: { + include: { + user: true, + }, + }, + }, + }); + + if (!organization) { + console.log(chalk.red('Organization not found.')); + return; + } + + console.log( + chalk.yellow( + `\nShowing organization for: ${selectedMember.email} (${selectedMember.role})\n`, + ), + ); + + displayOrganizationDetails(organization); +} + diff --git a/admin/src/commands/lookup-org.ts b/admin/src/commands/lookup-org.ts new file mode 100644 index 00000000..120ce47e --- /dev/null +++ b/admin/src/commands/lookup-org.ts @@ -0,0 +1,88 @@ +import { db } from '@openpanel/db'; +import chalk from 'chalk'; +import fuzzy from 'fuzzy'; +import inquirer from 'inquirer'; +import autocomplete from 'inquirer-autocomplete-prompt'; +import { displayOrganizationDetails } from '../utils/display'; + +// Register autocomplete prompt +inquirer.registerPrompt('autocomplete', autocomplete); + +interface OrgSearchItem { + id: string; + name: string; + displayText: string; +} + +export async function lookupByOrg() { + console.log(chalk.blue('\nšŸ¢ Lookup by Organization\n')); + console.log('Loading organizations...\n'); + + const organizations = await db.organization.findMany({ + orderBy: { + name: 'asc', + }, + }); + + if (organizations.length === 0) { + console.log(chalk.red('No organizations found.')); + return; + } + + const searchItems: OrgSearchItem[] = organizations.map((org) => ({ + id: org.id, + name: org.name, + displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`, + })); + + const searchFunction = async (_answers: unknown, input = '') => { + const fuzzyResult = fuzzy.filter(input, searchItems, { + extract: (item: OrgSearchItem) => `${item.name} ${item.id}`, + }); + + return fuzzyResult.map((result: fuzzy.FilterResult) => ({ + name: result.original.displayText, + value: result.original, + })); + }; + + const { selectedOrg } = (await inquirer.prompt([ + { + type: 'autocomplete', + name: 'selectedOrg', + message: 'Search for an organization:', + source: searchFunction, + pageSize: 15, + }, + ])) as { selectedOrg: OrgSearchItem }; + + // Fetch full organization details + const organization = await db.organization.findUnique({ + where: { + id: selectedOrg.id, + }, + include: { + projects: { + include: { + clients: true, + }, + orderBy: { + name: 'asc', + }, + }, + members: { + include: { + user: true, + }, + }, + }, + }); + + if (!organization) { + console.log(chalk.red('Organization not found.')); + return; + } + + displayOrganizationDetails(organization); +} + diff --git a/admin/src/commands/lookup-project.ts b/admin/src/commands/lookup-project.ts new file mode 100644 index 00000000..0f6ddbe4 --- /dev/null +++ b/admin/src/commands/lookup-project.ts @@ -0,0 +1,98 @@ +import { db } from '@openpanel/db'; +import chalk from 'chalk'; +import fuzzy from 'fuzzy'; +import inquirer from 'inquirer'; +import autocomplete from 'inquirer-autocomplete-prompt'; +import { displayOrganizationDetails } from '../utils/display'; + +// Register autocomplete prompt +inquirer.registerPrompt('autocomplete', autocomplete); + +interface ProjectSearchItem { + id: string; + name: string; + organizationId: string; + organizationName: string; + displayText: string; +} + +export async function lookupByProject() { + console.log(chalk.blue('\nšŸ“Š Lookup by Project\n')); + console.log('Loading projects...\n'); + + const projects = await db.project.findMany({ + include: { + organization: true, + }, + orderBy: { + name: 'asc', + }, + }); + + if (projects.length === 0) { + console.log(chalk.red('No projects found.')); + return; + } + + const searchItems: ProjectSearchItem[] = projects.map((project) => ({ + id: project.id, + name: project.name, + organizationId: project.organizationId, + organizationName: project.organization.name, + displayText: `${project.organization.name} → ${project.name} ${chalk.gray(`(${project.id})`)}`, + })); + + const searchFunction = async (_answers: unknown, input = '') => { + const fuzzyResult = fuzzy.filter(input, searchItems, { + extract: (item: ProjectSearchItem) => + `${item.organizationName} ${item.name} ${item.id}`, + }); + + return fuzzyResult.map((result: fuzzy.FilterResult) => ({ + name: result.original.displayText, + value: result.original, + })); + }; + + const { selectedProject } = (await inquirer.prompt([ + { + type: 'autocomplete', + name: 'selectedProject', + message: 'Search for a project:', + source: searchFunction, + pageSize: 15, + }, + ])) as { selectedProject: ProjectSearchItem }; + + // Fetch full organization details + const organization = await db.organization.findUnique({ + where: { + id: selectedProject.organizationId, + }, + include: { + projects: { + include: { + clients: true, + }, + orderBy: { + name: 'asc', + }, + }, + members: { + include: { + user: true, + }, + }, + }, + }); + + if (!organization) { + console.log(chalk.red('Organization not found.')); + return; + } + + displayOrganizationDetails(organization, { + highlightProjectId: selectedProject.id, + }); +} + diff --git a/admin/src/utils/display.ts b/admin/src/utils/display.ts new file mode 100644 index 00000000..c10e26c4 --- /dev/null +++ b/admin/src/utils/display.ts @@ -0,0 +1,206 @@ +import type { + Client, + Member, + Organization, + Project, + User, +} from '@openpanel/db'; +import chalk from 'chalk'; + +type OrganizationWithDetails = Organization & { + projects: (Project & { + clients: Client[]; + })[]; + members: (Member & { + user: User | null; + })[]; +}; + +interface DisplayOptions { + highlightProjectId?: string; + highlightClientId?: string; +} + +export function displayOrganizationDetails( + organization: OrganizationWithDetails, + options: DisplayOptions = {}, +) { + console.log(`\n${'='.repeat(80)}`); + console.log(chalk.bold.yellow(`\nšŸ“Š ORGANIZATION: ${organization.name}`)); + console.log(`${'='.repeat(80)}\n`); + + // Organization Details + console.log(chalk.bold('Organization Details:')); + console.log(` ${chalk.gray('ID:')} ${organization.id}`); + console.log(` ${chalk.gray('Name:')} ${organization.name}`); + console.log( + ` ${chalk.gray('Created:')} ${organization.createdAt.toISOString()}`, + ); + console.log(` ${chalk.gray('Timezone:')} ${organization.timezone || 'UTC'}`); + + // Subscription info + if (organization.subscriptionStatus) { + console.log( + ` ${chalk.gray('Subscription Status:')} ${getSubscriptionStatusColor(organization.subscriptionStatus)}`, + ); + if (organization.subscriptionPriceId) { + console.log( + ` ${chalk.gray('Price ID:')} ${organization.subscriptionPriceId}`, + ); + } + if (organization.subscriptionPeriodEventsLimit) { + const usage = `${organization.subscriptionPeriodEventsCount}/${organization.subscriptionPeriodEventsLimit}`; + const percentage = + (organization.subscriptionPeriodEventsCount / + organization.subscriptionPeriodEventsLimit) * + 100; + const color = + percentage > 90 + ? chalk.red + : percentage > 70 + ? chalk.yellow + : chalk.green; + console.log( + ` ${chalk.gray('Event Usage:')} ${color(usage)} (${percentage.toFixed(1)}%)`, + ); + } + if (organization.subscriptionStartsAt) { + console.log( + ` ${chalk.gray('Starts:')} ${organization.subscriptionStartsAt.toISOString()}`, + ); + } + if (organization.subscriptionEndsAt) { + console.log( + ` ${chalk.gray('Ends:')} ${organization.subscriptionEndsAt.toISOString()}`, + ); + } + } + + if (organization.deleteAt) { + console.log( + ` ${chalk.red.bold('āš ļø Scheduled for deletion:')} ${organization.deleteAt.toISOString()}`, + ); + } + + // Members + console.log(`\n${chalk.bold('Members:')}`); + if (organization.members.length === 0) { + console.log(' No members'); + } else { + for (const member of organization.members) { + const roleBadge = getRoleBadge(member.role); + console.log( + ` ${roleBadge} ${member.user?.email || member.email || 'Unknown'} ${chalk.gray(`(${member.role})`)}`, + ); + } + } + + // Projects + console.log(`\n${chalk.bold(`Projects (${organization.projects.length}):`)}`); + + if (organization.projects.length === 0) { + console.log(' No projects'); + } else { + for (const project of organization.projects) { + const isHighlighted = project.id === options.highlightProjectId; + const projectPrefix = isHighlighted ? chalk.yellow.bold('→ ') : ' '; + + console.log(`\n${projectPrefix}${chalk.bold.green(project.name)}`); + console.log(` ${chalk.gray('ID:')} ${project.id}`); + console.log( + ` ${chalk.gray('Events Count:')} ${project.eventsCount.toLocaleString()}`, + ); + + if (project.domain) { + console.log(` ${chalk.gray('Domain:')} ${project.domain}`); + } + + if (project.cors.length > 0) { + console.log(` ${chalk.gray('CORS:')} ${project.cors.join(', ')}`); + } + + console.log( + ` ${chalk.gray('Cross Domain:')} ${project.crossDomain ? chalk.green('āœ“') : chalk.red('āœ—')}`, + ); + console.log( + ` ${chalk.gray('Created:')} ${project.createdAt.toISOString()}`, + ); + + if (project.deleteAt) { + console.log( + ` ${chalk.red.bold('āš ļø Scheduled for deletion:')} ${project.deleteAt.toISOString()}`, + ); + } + + // Clients for this project + if (project.clients.length > 0) { + console.log(` ${chalk.gray('Clients:')}`); + for (const client of project.clients) { + const isClientHighlighted = client.id === options.highlightClientId; + const clientPrefix = isClientHighlighted + ? chalk.yellow.bold(' → ') + : ' '; + const typeBadge = getClientTypeBadge(client.type); + + console.log(`${clientPrefix}${typeBadge} ${chalk.cyan(client.name)}`); + console.log(` ${chalk.gray('ID:')} ${client.id}`); + console.log(` ${chalk.gray('Type:')} ${client.type}`); + console.log( + ` ${chalk.gray('Has Secret:')} ${client.secret ? chalk.green('āœ“') : chalk.red('āœ—')}`, + ); + console.log( + ` ${chalk.gray('Ignore CORS/Secret:')} ${client.ignoreCorsAndSecret ? chalk.yellow('āœ“') : chalk.gray('āœ—')}`, + ); + } + } else { + console.log(` ${chalk.gray('Clients:')} None`); + } + } + } + + // Clients without projects (organization-level clients) + const orgLevelClients = organization.projects.length > 0 ? [] : []; // We need to query these separately + + console.log(`\n${'='.repeat(80)}\n`); +} + +function getSubscriptionStatusColor(status: string): string { + switch (status) { + case 'active': + return chalk.green(status); + case 'trialing': + return chalk.blue(status); + case 'canceled': + return chalk.red(status); + case 'past_due': + return chalk.yellow(status); + default: + return chalk.gray(status); + } +} + +function getRoleBadge(role: string): string { + switch (role) { + case 'owner': + return chalk.red.bold('šŸ‘‘'); + case 'admin': + return chalk.yellow.bold('⭐'); + case 'member': + return chalk.blue('šŸ‘¤'); + default: + return chalk.gray('•'); + } +} + +function getClientTypeBadge(type: string): string { + switch (type) { + case 'root': + return chalk.red.bold('[ROOT]'); + case 'write': + return chalk.green('[WRITE]'); + case 'read': + return chalk.blue('[READ]'); + default: + return chalk.gray('[UNKNOWN]'); + } +} diff --git a/admin/tsconfig.json b/admin/tsconfig.json new file mode 100644 index 00000000..509dacd1 --- /dev/null +++ b/admin/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tooling/typescript/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src"] +} + diff --git a/apps/worker/src/jobs/cron.delete-projects.ts b/apps/worker/src/jobs/cron.delete-projects.ts index 9b675b1e..88000784 100644 --- a/apps/worker/src/jobs/cron.delete-projects.ts +++ b/apps/worker/src/jobs/cron.delete-projects.ts @@ -1,10 +1,9 @@ import { logger } from '@/utils/logger'; -import { TABLE_NAMES, ch, db, getReplicatedTableName } from '@openpanel/db'; +import { db, deleteFromClickhouse, deleteProjects } from '@openpanel/db'; import type { CronQueuePayload } from '@openpanel/queue'; import type { Job } from 'bullmq'; -import sqlstring from 'sqlstring'; -export async function deleteProjects(job: Job) { +export async function jobdeleteProjects(job: Job) { const projects = await db.project.findMany({ where: { deleteAt: { @@ -17,44 +16,13 @@ export async function deleteProjects(job: Job) { return; } - for (const project of projects) { - await db.project.delete({ - where: { - id: project.id, - }, - }); - } + await deleteProjects(projects.map((project) => project.id)); logger.info('Deleting projects', { projects, }); - projects.forEach((project) => { - job.log(`Delete project: "${project.id}"`); - }); - - const where = `project_id IN (${projects.map((project) => sqlstring.escape(project.id)).join(',')})`; - const tables = [ - TABLE_NAMES.events, - TABLE_NAMES.profiles, - TABLE_NAMES.events_bots, - TABLE_NAMES.sessions, - TABLE_NAMES.cohort_events_mv, - TABLE_NAMES.dau_mv, - TABLE_NAMES.event_names_mv, - TABLE_NAMES.event_property_values_mv, - ]; - - for (const table of tables) { - const query = `ALTER TABLE ${getReplicatedTableName(table)} DELETE WHERE ${where};`; - - await ch.command({ - query, - clickhouse_settings: { - lightweight_deletes_sync: '0', - }, - }); - } + await deleteFromClickhouse(projects.map((project) => project.id)); logger.info(`Deleted ${projects.length} projects`, { projects, diff --git a/apps/worker/src/jobs/cron.ts b/apps/worker/src/jobs/cron.ts index f4ccf9bd..b50e3beb 100644 --- a/apps/worker/src/jobs/cron.ts +++ b/apps/worker/src/jobs/cron.ts @@ -3,7 +3,7 @@ import type { Job } from 'bullmq'; import { eventBuffer, profileBuffer, sessionBuffer } from '@openpanel/db'; import type { CronQueuePayload } from '@openpanel/queue'; -import { deleteProjects } from './cron.delete-projects'; +import { jobdeleteProjects } from './cron.delete-projects'; import { ping } from './cron.ping'; import { salt } from './cron.salt'; @@ -25,7 +25,7 @@ export async function cronJob(job: Job) { return await ping(); } case 'deleteProjects': { - return await deleteProjects(job); + return await jobdeleteProjects(job); } } } diff --git a/packages/db/index.ts b/packages/db/index.ts index 5a725e8c..c0c91330 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -21,6 +21,7 @@ export * from './src/services/id.service'; export * from './src/services/retention.service'; export * from './src/services/notification.service'; export * from './src/services/access.service'; +export * from './src/services/delete.service'; export * from './src/buffers'; export * from './src/types'; export * from './src/clickhouse/query-builder'; diff --git a/packages/db/src/services/delete.service.ts b/packages/db/src/services/delete.service.ts new file mode 100644 index 00000000..9df45bd1 --- /dev/null +++ b/packages/db/src/services/delete.service.ts @@ -0,0 +1,66 @@ +import { TABLE_NAMES, ch, getReplicatedTableName } from '../clickhouse/client'; +import { logger } from '../logger'; +import { db } from '../prisma-client'; + +import sqlstring from 'sqlstring'; + +export async function deleteOrganization(organizationId: string) { + return await db.organization.delete({ + where: { + id: organizationId, + }, + }); +} + +export async function deleteProjects(projectIds: string[]) { + const projects = await db.project.findMany({ + where: { + id: { + in: projectIds, + }, + }, + }); + + if (projects.length === 0) { + return; + } + + for (const project of projects) { + await db.project.delete({ + where: { + id: project.id, + }, + }); + } + + return projects; +} + +export async function deleteFromClickhouse(projectIds: string[]) { + const where = `project_id IN (${projectIds.map((projectId) => sqlstring.escape(projectId)).join(',')})`; + const tables = [ + TABLE_NAMES.events, + TABLE_NAMES.profiles, + TABLE_NAMES.events_bots, + TABLE_NAMES.sessions, + TABLE_NAMES.cohort_events_mv, + TABLE_NAMES.dau_mv, + TABLE_NAMES.event_names_mv, + TABLE_NAMES.event_property_values_mv, + ]; + + for (const table of tables) { + // If materialized view, use ALTER TABLE since DELETE is not supported + const query = table.endsWith('_mv') + ? `ALTER TABLE ${getReplicatedTableName(table)} DELETE WHERE ${where};` + : `DELETE FROM ${getReplicatedTableName(table)} WHERE ${where};`; + + logger.info('Deleting from ClickHouse table:', { query }); + await ch.command({ + query, + clickhouse_settings: { + lightweight_deletes_sync: '0', + }, + }); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28941df1..630f042c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,43 @@ importers: specifier: ^3.0.4 version: 3.1.3(@types/debug@4.1.12)(@types/node@24.7.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.27.1)(tsx@4.20.5) + admin: + dependencies: + '@openpanel/common': + specifier: workspace:* + version: link:../packages/common + '@openpanel/db': + specifier: workspace:* + version: link:../packages/db + chalk: + specifier: ^5.3.0 + version: 5.4.1 + fuzzy: + specifier: ^0.1.3 + version: 0.1.3 + inquirer: + specifier: ^9.3.5 + version: 9.3.6 + inquirer-autocomplete-prompt: + specifier: ^3.0.1 + version: 3.0.1(inquirer@9.3.6) + jiti: + specifier: ^2.4.2 + version: 2.6.1 + devDependencies: + '@types/inquirer': + specifier: ^9.0.7 + version: 9.0.7 + '@types/inquirer-autocomplete-prompt': + specifier: ^3.0.3 + version: 3.0.3 + '@types/node': + specifier: 'catalog:' + version: 24.7.1 + typescript: + specifier: 'catalog:' + version: 5.9.3 + apps/api: dependencies: '@ai-sdk/anthropic': @@ -11272,6 +11309,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fuzzy@0.1.3: + resolution: {integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==} + engines: {node: '>= 0.6.0'} + gaxios@6.7.1: resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} engines: {node: '>=14'} @@ -12143,10 +12184,6 @@ packages: resolution: {integrity: sha512-yPBThwecp1wS9DmoA4x4KR2h3QoslacnDR8ypuFM962kI4/456Iy1oHx2RAgh4jfZNdn0bctsdadceiBUgpU1g==} hasBin: true - jiti@2.5.1: - resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} - hasBin: true - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -23512,7 +23549,7 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.18.3 - jiti: 2.5.1 + jiti: 2.6.1 lightningcss: 1.30.1 magic-string: 0.30.17 source-map-js: 1.2.1 @@ -25463,7 +25500,7 @@ snapshots: dotenv: 16.6.1 exsolve: 1.0.7 giget: 2.0.0 - jiti: 2.5.1 + jiti: 2.6.1 ohash: 2.0.11 pathe: 2.0.3 perfect-debounce: 1.0.0 @@ -27813,6 +27850,8 @@ snapshots: functions-have-names@1.2.3: {} + fuzzy@0.1.3: {} + gaxios@6.7.1: dependencies: extend: 3.0.2 @@ -28834,8 +28873,6 @@ snapshots: jiti@2.4.1: {} - jiti@2.5.1: {} - jiti@2.6.1: {} joi@17.12.1: @@ -33445,7 +33482,7 @@ snapshots: dependencies: '@quansync/fs': 0.1.5 defu: 6.1.4 - jiti: 2.5.1 + jiti: 2.6.1 quansync: 0.2.11 uncrypto@0.1.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eda3c9c7..30fcbb89 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - "apps/*" - "packages/**" - "tooling/*" + - "admin" # Define a catalog of version ranges. catalog: