mirror of
https://github.com/twentyhq/twenty.git
synced 2026-04-19 22:39:30 -04:00
Improve apps (#19120)
fixes https://discord.com/channels/1130383047699738754/1488094970241089586
This commit is contained in:
@@ -110,7 +110,7 @@ npx create-twenty-app@latest my-app -m
|
||||
|
||||
## Local server
|
||||
|
||||
The scaffolder can start a local Twenty dev server for you (all-in-one Docker image with PostgreSQL, Redis, server, and worker). You can also manage it manually:
|
||||
The scaffolder can start a local Twenty dev server for you (all-in-one Docker image with PostgreSQL, Redis, server, and worker on port 2020). These commands only apply to the Docker-based dev server — they do not manage a Twenty instance started from source (e.g. `npx nx start twenty-server` on port 3000). You can also manage it manually:
|
||||
|
||||
```bash
|
||||
yarn twenty server start # Start (pulls image if needed)
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as path from 'path';
|
||||
import { basename } from 'path';
|
||||
import {
|
||||
authLoginOAuth,
|
||||
detectLocalServer,
|
||||
serverStart,
|
||||
type ServerStartResult,
|
||||
} from 'twenty-sdk/cli';
|
||||
@@ -62,15 +63,19 @@ export class CreateAppCommand {
|
||||
let serverResult: ServerStartResult | undefined;
|
||||
|
||||
if (!options.skipLocalInstance) {
|
||||
const startResult = await serverStart({
|
||||
onProgress: (message: string) => console.log(chalk.gray(message)),
|
||||
});
|
||||
const shouldStartServer = await this.shouldStartServer();
|
||||
|
||||
if (startResult.success) {
|
||||
serverResult = startResult.data;
|
||||
await this.connectToLocal(serverResult.url);
|
||||
} else {
|
||||
console.log(chalk.yellow(`\n${startResult.error.message}`));
|
||||
if (shouldStartServer) {
|
||||
const startResult = await serverStart({
|
||||
onProgress: (message: string) => console.log(chalk.gray(message)),
|
||||
});
|
||||
|
||||
if (startResult.success) {
|
||||
serverResult = startResult.data;
|
||||
await this.promptConnectToLocal(serverResult.url);
|
||||
} else {
|
||||
console.log(chalk.yellow(`\n${startResult.error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +206,46 @@ export class CreateAppCommand {
|
||||
);
|
||||
}
|
||||
|
||||
private async connectToLocal(serverUrl: string): Promise<void> {
|
||||
private async shouldStartServer(): Promise<boolean> {
|
||||
const existingServerUrl = await detectLocalServer();
|
||||
|
||||
if (existingServerUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { startDocker } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'startDocker',
|
||||
message:
|
||||
'No running Twenty instance found. Would you like to start one using Docker?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return startDocker;
|
||||
}
|
||||
|
||||
private async promptConnectToLocal(serverUrl: string): Promise<void> {
|
||||
const { shouldAuthenticate } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'shouldAuthenticate',
|
||||
message: `Would you like to authenticate to the local Twenty instance (${serverUrl})?`,
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!shouldAuthenticate) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
'Authentication skipped. Run `yarn twenty remote add` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authLoginOAuth({
|
||||
apiUrl: serverUrl,
|
||||
@@ -211,14 +255,14 @@ export class CreateAppCommand {
|
||||
if (!result.success) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Authentication skipped. Run `yarn twenty remote add` manually.',
|
||||
'Authentication failed. Run `yarn twenty remote add` manually.',
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Authentication skipped. Run `yarn twenty remote add` manually.',
|
||||
'Authentication failed. Run `yarn twenty remote add` manually.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ If you plan to [publish your app](/developers/extend/apps/publishing), these opt
|
||||
| `category` | App category for marketplace filtering |
|
||||
| `logoUrl` | Path to your app logo (relative to `./assets/`) |
|
||||
| `screenshots` | Array of screenshot paths (relative to `./assets/`) |
|
||||
| `aboutDescription` | Longer markdown description for the "About" tab |
|
||||
| `aboutDescription` | Longer markdown description for the "About" tab. If omitted, the marketplace uses the package's `README.md` from npm |
|
||||
| `websiteUrl` | Link to your website |
|
||||
| `termsUrl` | Link to terms of service |
|
||||
| `emailSupport` | Support email address |
|
||||
|
||||
@@ -9,17 +9,18 @@ Apps are currently in alpha testing. The feature is functional but still evolvin
|
||||
|
||||
Apps let you extend Twenty with custom objects, fields, logic functions, AI skills, and UI components — all managed as code.
|
||||
|
||||
**What you can do today:**
|
||||
- Define custom objects and fields as code (managed data model)
|
||||
- Build logic functions with custom triggers (HTTP routes, cron, database events)
|
||||
- Define skills for AI agents
|
||||
- Build front components that render inside Twenty's UI
|
||||
- Deploy the same app across multiple workspaces
|
||||
**What you can build:**
|
||||
- Custom objects, fields, views, and navigation items to shape your data model
|
||||
- Logic functions triggered by HTTP routes, cron schedules, or database events
|
||||
- Front components that render directly inside Twenty's UI
|
||||
- Skills that extend Twenty's AI agents
|
||||
- Deploy an app across multiple workspaces
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24+ and Yarn 4
|
||||
- Docker (for the local Twenty dev server)
|
||||
- Node.js 24+
|
||||
- Yarn 4
|
||||
- Docker (or a running local Twenty instance)
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -28,21 +29,9 @@ Create a new app using the official scaffolder, then authenticate and start deve
|
||||
```bash filename="Terminal"
|
||||
# Scaffold a new app (includes all examples by default)
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Start dev mode: automatically syncs local changes to your workspace
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
The scaffolder supports two modes for controlling which example files are included:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill, agent)
|
||||
npx create-twenty-app@latest my-app
|
||||
|
||||
# Minimal: only core files (application-config.ts and default-role.ts)
|
||||
npx create-twenty-app@latest my-app --minimal
|
||||
```
|
||||
> Use `--minimal` option to scaffold a minimal installation
|
||||
|
||||
From here you can:
|
||||
|
||||
|
||||
@@ -102,6 +102,10 @@ yarn twenty catalog-sync -r production
|
||||
|
||||
The metadata shown in the marketplace comes from your `defineApplication()` call in your app source code — fields like `displayName`, `description`, `author`, `category`, `logoUrl`, `screenshots`, `aboutDescription`, `websiteUrl`, and `termsUrl`.
|
||||
|
||||
<Note>
|
||||
If your app does not define an `aboutDescription` in `defineApplication()`, the marketplace will automatically use your package's `README.md` from npm as the about page content. This means you can maintain a single README for both npm and the Twenty marketplace. If you want a different description in the marketplace, explicitly set `aboutDescription`.
|
||||
</Note>
|
||||
|
||||
### CI publishing
|
||||
|
||||
The scaffolded project includes a GitHub Actions workflow that publishes on every release:
|
||||
|
||||
@@ -37,7 +37,7 @@ yarn twenty dev
|
||||
|
||||
### Local Server Management
|
||||
|
||||
The SDK includes commands to manage a local Twenty dev server (all-in-one Docker image with PostgreSQL, Redis, server, and worker):
|
||||
The SDK includes commands to manage a local Twenty dev server (all-in-one Docker image with PostgreSQL, Redis, server, and worker on port 2020). These commands only apply to the Docker-based dev server — they do not manage a Twenty instance started from source (e.g. `npx nx start twenty-server` on port 3000):
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Start the local server (pulls the image if needed)
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
IconAlertTriangle,
|
||||
IconBook,
|
||||
IconBox,
|
||||
IconBrandNpm,
|
||||
IconCheck,
|
||||
IconCommand,
|
||||
IconDownload,
|
||||
@@ -221,6 +222,11 @@ export const SettingsAvailableApplicationDetails = () => {
|
||||
const sourceType = detail?.sourceType;
|
||||
const isNpmApp = sourceType === ApplicationRegistrationSourceType.NPM;
|
||||
const registrationId = detail?.id;
|
||||
const sourcePackage = detail?.sourcePackage;
|
||||
const sourcePackageUrl =
|
||||
isNpmApp && detail?.sourcePackage
|
||||
? `https://www.npmjs.com/package/${detail.sourcePackage}`
|
||||
: undefined;
|
||||
|
||||
const isUnlisted = isDefined(detail) && !detail.isListed;
|
||||
const installedApp = applicationData?.findOneApplication;
|
||||
@@ -519,6 +525,16 @@ export const SettingsAvailableApplicationDetails = () => {
|
||||
{t`Report and issue`}
|
||||
</StyledLink>
|
||||
)}
|
||||
{sourcePackageUrl && (
|
||||
<StyledLink
|
||||
href={sourcePackageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconBrandNpm size={16} />
|
||||
{t`Npm package`}
|
||||
</StyledLink>
|
||||
)}
|
||||
</StyledSidebarSection>
|
||||
)}
|
||||
</StyledSidebar>
|
||||
|
||||
@@ -81,7 +81,7 @@ In a project created with `create-twenty-app` (recommended), use `yarn twenty <c
|
||||
|
||||
### Server
|
||||
|
||||
Manage a local Twenty dev server (all-in-one Docker image).
|
||||
Manage a local Twenty dev server (all-in-one Docker image on port 2020). These commands only apply to the Docker-based dev server — they do not manage a Twenty instance started from source (e.g. `npx nx start twenty-server` on port 3000).
|
||||
|
||||
- `twenty server start` — Start the local server (pulls image if needed). Automatically configures the `local` remote.
|
||||
- Options:
|
||||
|
||||
@@ -40,6 +40,9 @@ registerCommands(program);
|
||||
|
||||
program.exitOverride();
|
||||
|
||||
const isExitPromptError = (error: unknown): boolean =>
|
||||
error instanceof Error && error.name === 'ExitPromptError';
|
||||
|
||||
try {
|
||||
program.parse();
|
||||
} catch (error) {
|
||||
|
||||
@@ -108,7 +108,7 @@ export const registerRemoteCommands = (program: Command): void => {
|
||||
if (!detectedUrl) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
'No local Twenty server found on ports 2020 or 3000.\n' +
|
||||
'No local Twenty server found.\n' +
|
||||
'Start one with: yarn twenty server start',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -37,10 +37,6 @@ export const registerServerCommands = (program: Command): void => {
|
||||
console.error(chalk.red(result.error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(`\nLocal remote configured → ${result.data.url}`),
|
||||
);
|
||||
});
|
||||
|
||||
server
|
||||
|
||||
@@ -25,6 +25,7 @@ export type { FunctionExecuteOptions } from './execute';
|
||||
// Server
|
||||
export { serverStart } from './server-start';
|
||||
export type { ServerStartOptions, ServerStartResult } from './server-start';
|
||||
export { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
|
||||
|
||||
// Shared types and error codes
|
||||
export {
|
||||
|
||||
@@ -126,6 +126,8 @@ const innerServerStart = async (
|
||||
} else {
|
||||
onProgress?.('Starting Twenty container...');
|
||||
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
|
||||
const runResult = spawnSync(
|
||||
'docker',
|
||||
[
|
||||
@@ -133,6 +135,8 @@ const innerServerStart = async (
|
||||
'-d',
|
||||
'--name',
|
||||
CONTAINER_NAME,
|
||||
'-e',
|
||||
`SERVER_URL=${serverUrl}`,
|
||||
'-p',
|
||||
`${port}:3000`,
|
||||
'-v',
|
||||
|
||||
@@ -51,7 +51,7 @@ export class ApiClient {
|
||||
if (error.response?.status === 401) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
'Authentication failed. Run `twenty remote add` to authenticate.',
|
||||
'Authentication failed. Run `yarn twenty remote add` to authenticate.',
|
||||
),
|
||||
);
|
||||
} else if (error.response?.status === 403) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { type ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
|
||||
|
||||
export type CheckServerOrchestratorStepOutput = {
|
||||
isReady: boolean;
|
||||
@@ -25,11 +27,24 @@ export class CheckServerOrchestratorStep {
|
||||
this.notify = notify;
|
||||
}
|
||||
|
||||
private hasRetried = false;
|
||||
|
||||
async execute(): Promise<boolean> {
|
||||
const step = this.state.steps.checkServer;
|
||||
const validateAuth = await this.apiService.validateAuth();
|
||||
|
||||
if (!validateAuth.serverUp) {
|
||||
const detectedUrl = await detectLocalServer();
|
||||
|
||||
if (detectedUrl && !this.hasRetried) {
|
||||
this.hasRetried = true;
|
||||
const configService = new ConfigService();
|
||||
|
||||
await configService.setConfig({ apiUrl: detectedUrl });
|
||||
|
||||
return this.execute();
|
||||
}
|
||||
|
||||
if (!step.output.errorLogged) {
|
||||
step.output = { isReady: false, errorLogged: true };
|
||||
step.status = 'error';
|
||||
@@ -58,7 +73,7 @@ export class CheckServerOrchestratorStep {
|
||||
this.state.applyStepEvents([
|
||||
{
|
||||
message:
|
||||
'Authentication failed. Run `twenty remote add <url>` to authenticate.',
|
||||
'Authentication failed. Run `yarn twenty remote add` to authenticate.',
|
||||
status: 'error',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
export type CuratedAppEntry = {
|
||||
universalIdentifier: string;
|
||||
sourcePackage: string;
|
||||
isFeatured: boolean;
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
latestAvailableVersion?: string;
|
||||
};
|
||||
|
||||
const MOCK_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#1a2744"><ellipse cx="38" cy="20" rx="28" ry="10"/><rect x="10" y="20" width="56" height="50"/><ellipse cx="38" cy="70" rx="28" ry="10"/><ellipse cx="38" cy="35" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><ellipse cx="38" cy="52" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><circle cx="72" cy="62" r="22" fill="#1a2744"/><circle cx="72" cy="62" r="18" fill="#fff"/><path d="M72 50 L72 74 M62 58 L72 48 L82 58" stroke="#1a2744" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
|
||||
const ENCODED_MOCK_LOGO = `data:image/svg+xml,${encodeURIComponent(MOCK_LOGO_SVG)}`;
|
||||
|
||||
export const MARKETPLACE_CATALOG_INDEX: CuratedAppEntry[] = [
|
||||
{
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000001',
|
||||
sourcePackage: '@twentyhq/app-data-enrichment',
|
||||
isFeatured: true,
|
||||
name: 'Data Enrichment',
|
||||
description: 'Enrich your data easily. Choose your provider.',
|
||||
author: 'Twenty',
|
||||
logoUrl: ENCODED_MOCK_LOGO,
|
||||
websiteUrl: 'https://twenty.com',
|
||||
latestAvailableVersion: '1.0.0',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
export const MARKETPLACE_CURATED_APPLICATIONS: {
|
||||
universalIdentifier: string;
|
||||
position?: number;
|
||||
}[] = [];
|
||||
@@ -2,11 +2,11 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { MARKETPLACE_CATALOG_INDEX } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-catalog-index.constant';
|
||||
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
|
||||
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { MARKETPLACE_CURATED_APPLICATIONS } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-curated-applications.constant';
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceCatalogSyncService {
|
||||
@@ -19,59 +19,54 @@ export class MarketplaceCatalogSyncService {
|
||||
) {}
|
||||
|
||||
async syncCatalog(): Promise<void> {
|
||||
await this.syncCuratedApps();
|
||||
await this.syncRegistryApps();
|
||||
|
||||
this.logger.log('Marketplace catalog sync completed');
|
||||
}
|
||||
|
||||
private async syncCuratedApps(): Promise<void> {
|
||||
for (const entry of MARKETPLACE_CATALOG_INDEX) {
|
||||
try {
|
||||
await this.applicationRegistrationService.upsertFromCatalog({
|
||||
universalIdentifier: entry.universalIdentifier,
|
||||
name: entry.name,
|
||||
sourceType: ApplicationRegistrationSourceType.NPM,
|
||||
sourcePackage: entry.sourcePackage,
|
||||
latestAvailableVersion: entry.latestAvailableVersion ?? null,
|
||||
isListed: true,
|
||||
isFeatured: entry.isFeatured,
|
||||
manifest: null,
|
||||
ownerWorkspaceId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to sync curated app "${entry.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async syncRegistryApps(): Promise<void> {
|
||||
const packages = await this.marketplaceService.fetchAppsFromRegistry();
|
||||
|
||||
const curatedIdentifiers = new Set(
|
||||
MARKETPLACE_CATALOG_INDEX.map((entry) => entry.universalIdentifier),
|
||||
MARKETPLACE_CURATED_APPLICATIONS.map(
|
||||
(entry) => entry.universalIdentifier,
|
||||
),
|
||||
);
|
||||
|
||||
for (const pkg of packages) {
|
||||
try {
|
||||
const manifest =
|
||||
const fetchedManifest =
|
||||
await this.marketplaceService.fetchManifestFromRegistryCdn(
|
||||
pkg.name,
|
||||
pkg.version,
|
||||
);
|
||||
|
||||
if (!manifest) {
|
||||
if (!fetchedManifest) {
|
||||
this.logger.debug(`Skipping ${pkg.name}: no manifest found on CDN`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const universalIdentifier = manifest.application.universalIdentifier;
|
||||
const universalIdentifier =
|
||||
fetchedManifest.application.universalIdentifier;
|
||||
|
||||
if (curatedIdentifiers.has(universalIdentifier)) {
|
||||
continue;
|
||||
}
|
||||
const isFeatured = curatedIdentifiers.has(universalIdentifier);
|
||||
|
||||
const aboutDescription =
|
||||
fetchedManifest.application.aboutDescription ??
|
||||
(await this.marketplaceService.fetchReadmeFromRegistryCdn(
|
||||
pkg.name,
|
||||
pkg.version,
|
||||
));
|
||||
|
||||
const manifest = aboutDescription
|
||||
? {
|
||||
...fetchedManifest,
|
||||
application: {
|
||||
...fetchedManifest.application,
|
||||
aboutDescription,
|
||||
},
|
||||
}
|
||||
: fetchedManifest;
|
||||
|
||||
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
|
||||
|
||||
@@ -93,7 +88,7 @@ export class MarketplaceCatalogSyncService {
|
||||
sourcePackage: pkg.name,
|
||||
latestAvailableVersion: pkg.version ?? null,
|
||||
isListed: true,
|
||||
isFeatured: false,
|
||||
isFeatured,
|
||||
manifest: manifestWithResolvedUrls,
|
||||
ownerWorkspaceId: null,
|
||||
});
|
||||
|
||||
@@ -73,6 +73,39 @@ export class MarketplaceService {
|
||||
}
|
||||
}
|
||||
|
||||
async fetchReadmeFromRegistryCdn(
|
||||
packageName: string,
|
||||
version: string,
|
||||
): Promise<string | null> {
|
||||
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
|
||||
const url = buildRegistryCdnUrl({
|
||||
cdnBaseUrl,
|
||||
packageName,
|
||||
version,
|
||||
filePath: 'README.md',
|
||||
});
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(url, {
|
||||
headers: { 'User-Agent': 'Twenty-Marketplace' },
|
||||
timeout: 5_000,
|
||||
responseType: 'text',
|
||||
});
|
||||
|
||||
if (!data || data.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch {
|
||||
this.logger.debug(
|
||||
`Could not fetch README from CDN for ${packageName}@${version}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAppsFromRegistry(): Promise<RegistryPackageInfo[]> {
|
||||
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export {
|
||||
IconBrandGraphql,
|
||||
IconBrandLinkedin,
|
||||
IconBrandOpenai,
|
||||
IconBrandNpm,
|
||||
IconBrandX,
|
||||
IconBriefcase,
|
||||
IconBroadcast,
|
||||
|
||||
@@ -126,6 +126,7 @@ export {
|
||||
IconBrandGraphql,
|
||||
IconBrandLinkedin,
|
||||
IconBrandOpenai,
|
||||
IconBrandNpm,
|
||||
IconBrandX,
|
||||
IconBriefcase,
|
||||
IconBroadcast,
|
||||
|
||||
Reference in New Issue
Block a user